diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 124b772..2c91be5 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -15,7 +15,7 @@ The Schuly **Microsoft plugin** is the only caller. once for the push/TOTP sign-in, holds it only in memory for that call, and never stores, caches, logs or returns it. Every other route still never sees a password. Do **not** add any other password/credential endpoints. -- Password sign-in jobs (`src/password_signin.py`) are **in memory only** (TTL <= 10 min, sweeper, +- Password sign-in jobs (`src/infrastructure/password_signin/`) are **in memory only** (TTL <= 10 min, sweeper, max concurrent jobs, per-client and per-account rate limits). They hold progress and the result, never the password, so the service **must run as ONE process** (`asgi.py` pins `workers=1`; do not add uvicorn/gunicorn workers or replicas). Keep the password out of logs, job objects, errors @@ -23,30 +23,30 @@ The Schuly **Microsoft plugin** is the only caller. - Uses only the **public** OutNote/Entrance API (`outnote.OutNote`, `entrance.Broker`, `entrance.http_client`) plus the documented OAuth calls for the browser and device flows. Don't reach into their private modules. Needs Python 3.11 or newer. The one non-Graph surface is the Teams chat service for group - chat pictures (`teams_chat.py`): a Teams-client token for `api.spaces.skype.com` is traded at + chat pictures (`TeamsChatClient`): a Teams-client token for `api.spaces.skype.com` is traded at `teams.microsoft.com/api/authsvc/v1.0/authz` for a skype token. ## OutNote / Entrance 2.x - `entrance.http_client()` offers only `request`, `post`, `head` and `close`, takes a float timeout and reads every body into memory. Anything that must stay capped or be streamed uses - `outnote_client.download_client()` (httpx, HTTP/1.1, no redirects by default, no cookies kept, + `IMicrosoftHttpClients.download_client()` (httpx, HTTP/1.1, no redirects by default, no cookies kept, because all accounts share it). Don't call `note.download` or `note.download_shared`: they hold the whole file with no cap and no host check. - `except entrance.HTTPError` works alone, but it is a tuple: unpack it next to other types. -- `teams_files.download_url` stays: it serves the `shares/...` path chat files need, which OutNote's +- `TeamsFiles.download_url` stays: it serves the `shares/...` path chat files need, which OutNote's `download_url` can't. A folder is a 404 that says so; Graph's refusal at `/content` keeps its own status (403, 404, 415, 429). - One `OutNote` per account lives on its broker. A throttled call raises `GraphError` 429 after about - 60 s, which `_read` answers as 429 with `Retry-After` (and any rotated token). -- `_read` strips `_native` from every result. No route calls OutNote's native reads today: chat - messages stay on Graph's list because `chat_read` and the callers need its attachments, mentions and + 60 s, which `AccountRunner` answers as 429 with `Retry-After` (and any rotated token). +- `AccountRunner` strips `_native` from every result. No route calls OutNote's native reads today: chat + messages stay on Graph's list because `ChatReader` and the callers need its attachments, mentions and reactions. `emails()` gets an explicit `$select` because the web's list shows `toRecipients`. - Never add `SQLiteCache` or any other on-disk cache. `stream()` cursors would have to live in the plugin. ## Cold start -- Read `/me` only through `profile.get`: it asks `/me` once without OutNote's retries (Graph 429s it with ~21 s Retry-After) and falls back to the access token's claims (oid, name, upn). +- Read `/me` only through `IProfileReader.get`: it asks `/me` once without OutNote's retries (Graph 429s it with ~21 s Retry-After) and falls back to the access token's claims (oid, name, upn). - `GET /health/live` is always 200; `GET /health/ready` is 503 until the startup warm-up finished or 15 s passed (the Dockerfile `HEALTHCHECK` uses it). @@ -56,36 +56,62 @@ The Schuly **Microsoft plugin** is the only caller. across multiple lines, however long. Call sites, comprehensions and multi-line `if`/`for` stay as they are. +## Architecture + +Onion clean architecture with CQRS through [mediatorx](https://pypi.org/project/mediatorx/), in SchulwareAPI's style and with SchulyServer's ideas. +**Dependencies only point inward**; `tests/test_architecture.py` parses every import under `src/` and fails when a file breaks this. + +- `domain`: pure types, errors and validators. Imports nothing from the other rings and no framework. +- `application`: commands, queries, DTOs, options, services, and the ABCs it needs from outside (`abstractions/`). Imports only `domain` (plus `mediatorx` and `pydantic`), never FastAPI, OutNote, Entrance or httpx. +- `infrastructure`: implements those ABCs over OutNote, Entrance, Graph, Teams, Giphy and the file libraries. Never imports `api`. +- `api`: controllers, middleware and the composition root. Only `src/api/extensions/` may name concrete infrastructure classes. + +Conventions: + +- **Controllers are thin**: `@controller(router)` classes with `mediator: Mediator = Depends(get_mediator)` that translate HTTP and call `self.mediator.send(...)`. Route method names are the operation ids, so keep them; `OpenApiExtensions` strips the class name the decorator adds. +- **A command or query** is a dataclass paired with its handler in one file, one file per operation. A refresh token field is `field(repr=False)`, and so is anything else secret. +- **Constructor injection only**: nothing is reached through a module-level singleton. `ServiceCollection` holds the lifetimes (`add_singleton`, `add_scoped`, `add_handler`), one `Extensions` class per area, called from `Program` in `src/api/app.py`. Handlers are scoped; everything with state is a singleton: `JobStore`, `OutNoteClientFactory` (the per-account broker cache), `MicrosoftHttpClients`, `EmojiCatalogue`, `GifSearch`, `WarmUpTracker`. +- **Refusals**: raise `ServiceError(status, detail, headers)` or a subclass from anywhere; `ExceptionHandlingExtensions` is the one place that makes a response from it. `SignInRefused` is the password routes' shape, and they alone get the stripped 422. +- **Token rotation**: `AccountRunner` records the rotated token through `ITokenRotation`; `TokenRotationMiddleware` puts it on the response. It records on success, on a `ServiceError` raised by the action and on a 429, and not on a plain 401, 404 or 502. mediatorx has pipeline behaviors, but a behavior can't see the broker. + +To add a route: a query or command with its handler in `src/application/`, its registration in the area's `src/api/extensions/_extensions.py`, and a method on `src/api/controllers/_controller.py`. A streaming answer returns a `FileStream` and the controller answers `FileResponses.stream(...)`. + ## Layout ``` -asgi.py # entrypoint (uvicorn on :8010) +asgi.py # entrypoint (uvicorn on :8010) src/ - app.py # FastAPI app + routes - config.py # client ids, scopes, endpoints - browser_auth.py # browser sign-in (authorize URL + PKCE, code exchange) - password_signin.py # password sign-in jobs (worker thread per job, MfaPrompt bridge, rate limits) - outnote_client.py # thin OutNote/Entrance wrapper (device start/poll, build_client) - models.py # pydantic DTOs - account_cache.py # per-account TTL cache living on the shared broker - profile.py # the user's /me profile: one try, token claims on 429; cached 1 h (claims 5 min) - folder_roles.py # well-known mail folder names via one $batch - teams_chat.py # group chat pictures over the Teams chat service (skype token, AMS) - chat_read.py # chat messages + media/files/cards for the web - chat_html.py # composer HTML allowlist sanitizer, body media/card extraction - chat_send.py # rich sends: hosted images, GIFs, mentions, OneDrive file shares - people_photos.py # profile photos by e-mail (mail senders), cached per account - emojis.py # Teams emoji catalogue (public CDN, 24 h cache), message emoji parsing, send allowlist - file_thumbs.py # file kinds, SharePoint/OneDrive host check, chat file and drive thumbnails - file_pdf.py # Office files as PDF (Graph format=pdf); temp app-folder upload + delete + hourly sweep, converted-host allowlist - teams_files.py # joined teams, channels, team/channel library browsing (drive id via C_SITES), drive items, shared/recent files, download host check - search.py # Search API (files / mail / chats / channel posts) with hit locations, per-drive fallback, per-source errors; never logs the query - file_text.py # text out of docx/xlsx/pptx/pdf/text (lazy imports, zip-bomb guard, caps, paging) - channel_messages.py # Teams channel posts and replies, normalized (html to text) - gifs.py # Giphy search (needs GIPHY_API_KEY; 503 without) - onenote.py # OneNote over Graph: notebooks (own + teams), section tree, pages, search, writes, resource stream - onenote_html.py # OneNote page HTML allowlist sanitizer (read and write), text-box editability, InkML to SVG -tests/ # pytest, fully mocked (no network) + api/ + app.py # Program: the composition root and the request pipeline + controller.py # class-based controller decorator (vendored, MIT, from SchulwareAPI) + dependencies.py # get_mediator, the password routes' service key and user id gate + http_headers.py # header names + controllers/ # one @controller class per area + extensions/ # Extensions (registration per area), exception handling, OpenAPI names + di/ # ServiceCollection, ServiceProvider (mediatorx's resolver) + middleware/ # TokenRotationMiddleware + token_rotation/ # ContextTokenRotation: the per-request slot behind ITokenRotation + request_bodies/ # capped raw upload body, chat send body (JSON or multipart) + responses/ # FileResponses: download, stream, inline PDF and image headers + application/ + abstractions/ # I*: what the application needs from outside + commands/ queries/ # one dataclass + handler per operation + dtos/ # pydantic request and response models, one per file + options/ # MicrosoftOptions, ServiceAuthOptions, PasswordSignInOptions, GiphyOptions, EmojiOptions + services/ # ChatHtmlSanitizer, OneNoteHtmlSanitizer, MailMessageBuilder, DriveFileTextReader, PasswordSignInResponder, ApplicationLifetime, WarmUpTracker, ... + domain/ + errors/ # ServiceError and its subclasses, SignInRefused, RateLimited, ... + validation/ # GraphIds (the one id validator), RefreshTokenFormat + files/ chat/ sign_in/ # limits, file results, outgoing message, emoji catalogue, sign-in states + infrastructure/ + logging_config.py # logger names + microsoft/ # AccountRunner, OutNoteClientFactory, BrokerCache, SharedBroker, AccountCache, MicrosoftHttpClients, DeviceLoginClient, BrowserSignInClient, ProfileReader + graph/ # TeamsFiles, GraphSearch, OneNoteClient, OneNoteRunner, PdfConverter, FileThumbnails, ChannelMessages, PeoplePhotos, MailFolderRoles + teams/ # TeamsChatClient (skype token, AMS), ChatReader, ChatSender, EmojiCatalogue + giphy/ # GifSearch (needs GIPHY_API_KEY; 503 without) + files/ # FileTextExtractor (lazy imports, zip-bomb guard, caps, paging) + password_signin/ # JobStore, SignInJob (worker thread per job, MfaPrompt bridge), RateLimiter +tests/ # pytest, fully mocked (no network); services.py resolves the singletons the tests patch ``` ## Run / test diff --git a/README.md b/README.md index 059859c..2b8bd53 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,17 @@ seconds, after waiting about 60 seconds itself. A rotated refresh token still co On the file routes, a folder is a `404` that says so, and a file Microsoft refuses to serve answers `403`, `404` or `415`. +## Architecture + +Onion clean architecture with CQRS through `mediatorx`; dependencies only point inward, and a test enforces it. + +| Ring | Holds | +|---|---| +| `src/domain` | Pure types, errors and validators | +| `src/application` | Commands and queries with their handlers, DTOs, options, services, and the abstractions it needs from outside | +| `src/infrastructure` | OutNote, Entrance, Graph, Teams, Giphy and file parsing, implementing those abstractions | +| `src/api` | Controllers, middleware and the composition root (`src/api/app.py`, `src/api/extensions`) | + ## Run Needs Python 3.11 or newer. The Docker image uses 3.13. diff --git a/asgi.py b/asgi.py index d6927bf..70fe48d 100644 --- a/asgi.py +++ b/asgi.py @@ -1,8 +1,8 @@ import uvicorn -from src.app import app +from src.api.app import app if __name__ == "__main__": - # workers=1 on purpose: password sign-in jobs live in this process memory (see src/password_signin.py). + # workers=1 on purpose: password sign-in jobs live in this process memory (see src/infrastructure/password_signin). # access_log=False on purpose: query strings carry search text, file paths and mail folders, and must not reach the logs. uvicorn.run(app, host="0.0.0.0", port=8010, workers=1, access_log=False) diff --git a/docs/setup/development.md b/docs/setup/development.md index afe4edd..bf04489 100644 --- a/docs/setup/development.md +++ b/docs/setup/development.md @@ -34,5 +34,6 @@ python -m pytest tests -q - Keep passwords out of logs, sign-in jobs, errors, and responses. - Keep the service to one process. Don't add workers or replicas. - Use only the public OutNote and Entrance APIs. +- Keep dependencies pointing inward: `domain`, then `application`, then `infrastructure` and `api`. `tests/test_architecture.py` fails otherwise. For the workflow, see [Contribute](/contribute/). diff --git a/requirements.txt b/requirements.txt index 7d0a35d..f06f40c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,5 +10,6 @@ openpyxl==3.1.5 python-pptx==1.0.2 pypdf==6.21.4 defusedxml==0.7.1 +mediatorx==1.0.1 pytest==8.3.5 pytest-asyncio==0.26.0 diff --git a/src/api/app.py b/src/api/app.py new file mode 100644 index 0000000..66e7f9e --- /dev/null +++ b/src/api/app.py @@ -0,0 +1,77 @@ +"""SchulyMicrosoft: stateless Microsoft 365 access for Schuly, over OutNote / Entrance. + +The user signs in once with the device-code or browser flow on Microsoft's real page (no password +reaches this service; the one exception is the web password sign-in route, which uses the password +once in memory and never stores or logs it); the caller stores the returned FOCI refresh token and sends it back +on each read. Reads return the rotated refresh token in the X-MS-Refresh-Token header. +""" + +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from src.api.controllers import auth_controller, chats_controller, emojis_controller, files_controller, gifs_controller, health_controller, mail_controller, me_controller, one_note_controller, people_controller, search_controller, teams_controller +from src.api.di.service_collection import ServiceCollection, ServiceProvider +from src.api.extensions.chats_extensions import ChatsExtensions +from src.api.extensions.emojis_extensions import EmojisExtensions +from src.api.extensions.exception_handling_extensions import ExceptionHandlingExtensions +from src.api.extensions.files_extensions import FilesExtensions +from src.api.extensions.gifs_extensions import GifsExtensions +from src.api.extensions.health_extensions import HealthExtensions +from src.api.extensions.mail_extensions import MailExtensions +from src.api.extensions.me_extensions import MeExtensions +from src.api.extensions.microsoft_extensions import MicrosoftExtensions +from src.api.extensions.one_note_extensions import OneNoteExtensions +from src.api.extensions.open_api_extensions import OpenApiExtensions +from src.api.extensions.people_extensions import PeopleExtensions +from src.api.extensions.search_extensions import SearchExtensions +from src.api.extensions.sign_in_extensions import SignInExtensions +from src.api.extensions.teams_extensions import TeamsExtensions +from src.api.middleware.token_rotation_middleware import TokenRotationMiddleware +from src.application.abstractions.i_token_rotation import ITokenRotation +from src.application.services.application_lifetime import ApplicationLifetime + + +class Program: + """The composition root, like Program.cs: what exists and for how long, then how a request flows.""" + + @staticmethod + def add_services() -> ServiceProvider: + services = ServiceCollection() + MicrosoftExtensions.add_microsoft_access(services) + HealthExtensions.add_health(services) + SignInExtensions.add_sign_in(services) + MeExtensions.add_me(services) + MailExtensions.add_mail(services) + OneNoteExtensions.add_one_note(services) + ChatsExtensions.add_chats(services) + PeopleExtensions.add_people(services) + FilesExtensions.add_files(services) + TeamsExtensions.add_teams(services) + SearchExtensions.add_search(services) + EmojisExtensions.add_emojis(services) + GifsExtensions.add_gifs(services) + return services.build() + + @classmethod + def create_app(cls) -> FastAPI: + provider = cls.add_services() + + @asynccontextmanager + async def lifespan(_: FastAPI): + lifetime = provider.get(ApplicationLifetime) + lifetime.start() + yield + await lifetime.stop() + + app = FastAPI(title="SchulyMicrosoft", description="Microsoft 365 access for Schuly via OutNote.", lifespan=lifespan) + app.state.services = provider + ExceptionHandlingExtensions.use_schuly_exception_handling(app) + app.add_middleware(TokenRotationMiddleware, rotation=provider.get(ITokenRotation)) + for module in (health_controller, auth_controller, me_controller, mail_controller, one_note_controller, chats_controller, people_controller, files_controller, teams_controller, search_controller, emojis_controller, gifs_controller): + app.include_router(module.router) + OpenApiExtensions.use_plain_operation_names(app) + return app + + +app = Program.create_app() diff --git a/src/api/controller.py b/src/api/controller.py new file mode 100644 index 0000000..0eb7506 --- /dev/null +++ b/src/api/controller.py @@ -0,0 +1,182 @@ +"""Class-based controller decorator for FastAPI. + +Vendored from fastapi-utils 0.8.0 (MIT licensed, https://github.com/fastapiutils/fastapi-utils), +with the `typing_inspect.is_classvar` dependency replaced by a small inline check +against `typing.ClassVar` so we have zero external runtime deps for this module. + +Usage: + router = APIRouter(prefix="/api/foo", tags=["Foo"]) + + @controller(router) + class FooController: + mediator: Mediator = Depends(get_mediator) # class-level Depends + + @router.get("/bar") + async def bar(self, request: Request): + return await self.mediator.send(...) +""" + +import inspect +from collections.abc import Callable +from typing import ( + Any, + ClassVar, + TypeVar, + cast, + get_origin, + get_type_hints, +) + +from fastapi import APIRouter, Depends +from fastapi.routing import APIRoute +from starlette.routing import Route, WebSocketRoute + +T = TypeVar("T") + +CONTROLLER_CLASS_KEY = "__controller_class__" +INCLUDE_INIT_PARAMS_KEY = "__include_init_params__" +RETURN_TYPES_FUNC_KEY = "__return_types_func__" + + +def _is_classvar(hint: Any) -> bool: + return hint is ClassVar or get_origin(hint) is ClassVar + + +def controller(router: APIRouter, *urls: str) -> Callable[[type[T]], type[T]]: + """ + Decorator that converts the decorated class into a class-based controller for the given router. + + Any methods decorated as endpoints using `router` will become endpoints in that router. + The first positional argument (typically `self`) will be populated with an instance + created via FastAPI's dependency injection, so class-level annotated attributes + (e.g. ``mediator: Mediator = Depends(get_mediator)``) act as shared per-request deps. + """ + + def decorator(cls: type[T]) -> type[T]: + return _build_controller(router, cls, *urls) + + return decorator + + +def _build_controller(router: APIRouter, cls: type[T], *urls: str, instance: Any = None) -> type[T]: + _init_controller(cls, instance) + _register_endpoints(router, cls, *urls) + return cls + + +def _init_controller(cls: type[Any], instance: Any = None) -> None: + """Idempotently rewrite `cls.__init__` so FastAPI can inject class-annotated dependencies.""" + if getattr(cls, CONTROLLER_CLASS_KEY, False): + return + old_init: Callable[..., Any] = cls.__init__ + old_signature = inspect.signature(old_init) + old_parameters = list(old_signature.parameters.values())[1:] # drop `self` + new_parameters = [ + x for x in old_parameters + if x.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) + ] + + dependency_names: list[str] = [] + for name, hint in get_type_hints(cls).items(): + if _is_classvar(hint): + continue + parameter_kwargs = {"default": getattr(cls, name, Ellipsis)} + dependency_names.append(name) + new_parameters.append( + inspect.Parameter( + name=name, + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=hint, + **parameter_kwargs, + ) + ) + new_signature = inspect.Signature(()) + if not instance or hasattr(cls, INCLUDE_INIT_PARAMS_KEY): + new_signature = old_signature.replace(parameters=new_parameters) + + def new_init(self: Any, *args: Any, **kwargs: Any) -> None: + for dep_name in dependency_names: + dep_value = kwargs.pop(dep_name) + setattr(self, dep_name, dep_value) + if instance and not hasattr(cls, INCLUDE_INIT_PARAMS_KEY): + self.__class__ = instance.__class__ + self.__dict__ = instance.__dict__ + else: + old_init(self, *args, **kwargs) + + setattr(cls, "__signature__", new_signature) + setattr(cls, "__init__", new_init) + setattr(cls, CONTROLLER_CLASS_KEY, True) + + +def _register_endpoints(router: APIRouter, cls: type[Any], *urls: str) -> None: + inner_router = APIRouter() + function_members = inspect.getmembers(cls, inspect.isfunction) + for url in urls: + _allocate_routes_by_method_name(router, url, function_members) + router_roles = [] + for route in router.routes: + if not isinstance(route, APIRoute): + raise ValueError("The provided routes should be of type APIRoute") + + route_methods: Any = route.methods + cast(tuple[Any], route_methods) + router_roles.append((route.path, tuple(route_methods))) + + if len(set(router_roles)) != len(router_roles): + raise Exception("An identical route role has been implemented more than once") + + functions_set = {func for _, func in function_members} + controller_routes = [ + route for route in router.routes + if isinstance(route, (Route, WebSocketRoute)) and route.endpoint in functions_set + ] + prefix_length = len(router.prefix) + for route in controller_routes: + router.routes.remove(route) + route.path = route.path[prefix_length:] + _update_route_endpoint_signature(cls, route) + route.name = cls.__name__ + "." + route.name + inner_router.routes.append(route) + router.include_router(inner_router) + + +def _allocate_routes_by_method_name(router: APIRouter, url: str, function_members: list[tuple[str, Any]]) -> None: + existing_routes_endpoints: list[tuple[Any, str]] = [ + (route.endpoint, route.path) for route in router.routes if isinstance(route, APIRoute) + ] + for name, func in function_members: + if hasattr(router, name) and not name.startswith("__") and not name.endswith("__"): + if (func, url) not in existing_routes_endpoints: + response_model = None + responses = None + kwargs = {} + status_code = 200 + return_types_func = getattr(func, RETURN_TYPES_FUNC_KEY, None) + if return_types_func: + response_model, status_code, responses, kwargs = return_types_func() + + api_resource = router.api_route( + url, + methods=[name.capitalize()], + response_model=response_model, + status_code=status_code, + responses=responses, + **kwargs, + ) + api_resource(func) + + +def _update_route_endpoint_signature(cls: type[Any], route: Route | WebSocketRoute) -> None: + """Fix the endpoint signature so FastAPI's DI resolves `self` via `Depends(cls)`.""" + old_endpoint = route.endpoint + old_signature = inspect.signature(old_endpoint) + old_parameters: list[inspect.Parameter] = list(old_signature.parameters.values()) + old_first_parameter = old_parameters[0] + new_first_parameter = old_first_parameter.replace(default=Depends(cls)) + new_parameters = [new_first_parameter] + [ + parameter.replace(kind=inspect.Parameter.KEYWORD_ONLY) for parameter in old_parameters[1:] + ] + + new_signature = old_signature.replace(parameters=new_parameters) + setattr(route.endpoint, "__signature__", new_signature) diff --git a/src/api/controllers/auth_controller.py b/src/api/controllers/auth_controller.py new file mode 100644 index 0000000..d8ed54a --- /dev/null +++ b/src/api/controllers/auth_controller.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, Depends, Response +from mediatorx import Mediator + +from src.api.controller import controller +from src.api.dependencies import get_mediator, get_password_caller +from src.application.commands.cancel_password_sign_in_command import CancelPasswordSignInCommand +from src.application.commands.choose_password_method_command import ChoosePasswordMethodCommand +from src.application.commands.complete_browser_login_command import CompleteBrowserLoginCommand +from src.application.commands.poll_device_login_command import PollDeviceLoginCommand +from src.application.commands.start_browser_login_command import StartBrowserLoginCommand +from src.application.commands.start_device_login_command import StartDeviceLoginCommand +from src.application.commands.start_password_sign_in_command import StartPasswordSignInCommand +from src.application.commands.submit_password_otp_command import SubmitPasswordOtpCommand +from src.application.dtos.browser_complete_request import BrowserCompleteRequest +from src.application.dtos.browser_start_response import BrowserStartResponse +from src.application.dtos.device_poll_request import DevicePollRequest +from src.application.dtos.device_poll_response import DevicePollResponse +from src.application.dtos.device_start_response import DeviceStartResponse +from src.application.dtos.password_method_request import PasswordMethodRequest +from src.application.dtos.password_otp_request import PasswordOtpRequest +from src.application.dtos.password_sign_in_response import PasswordSignInResponse +from src.application.dtos.password_start_request import PasswordStartRequest +from src.application.queries.get_password_sign_in_query import GetPasswordSignInQuery + +router = APIRouter(prefix="/api/auth") + + +@controller(router) +class AuthController: + mediator: Mediator = Depends(get_mediator) + + @router.post("/device/start", tags=["Auth"], response_model=DeviceStartResponse) + async def device_start(self): + return await self.mediator.send(StartDeviceLoginCommand()) + + @router.post("/device/poll", tags=["Auth"], response_model=DevicePollResponse) + async def device_poll(self, body: DevicePollRequest): + return await self.mediator.send(PollDeviceLoginCommand(body.device_code)) + + @router.post("/browser/start", tags=["Auth"], response_model=BrowserStartResponse) + async def browser_start(self): + return await self.mediator.send(StartBrowserLoginCommand()) + + @router.post("/browser/complete", tags=["Auth"], response_model=DevicePollResponse) + async def browser_complete(self, body: BrowserCompleteRequest): + return await self.mediator.send(CompleteBrowserLoginCommand(body.code, body.code_verifier)) + + @router.post("/password/start", tags=["Auth"], response_model=PasswordSignInResponse, response_model_exclude_none=True) + async def password_start(self, body: PasswordStartRequest, user_id: str = Depends(get_password_caller)): + """Sign in with the Microsoft password. The password is used once, held only in memory for this + sign-in and never stored, cached, logged or returned. Completes straight away when Microsoft asks + for no MFA; otherwise returns mfa_required with the methods Microsoft offers.""" + password = body.password + body.password = None # the request model no longer references the password + return await self.mediator.send(StartPasswordSignInCommand(user_id, body.username, password)) + + @router.post("/password/{sign_in_id}/method", tags=["Auth"], response_model=PasswordSignInResponse, response_model_exclude_none=True) + async def password_method(self, sign_in_id: str, body: PasswordMethodRequest, user_id: str = Depends(get_password_caller)): + """Choose (or switch to) push or otp. push -> push_pending with the number to tap; otp -> otp_required.""" + return await self.mediator.send(ChoosePasswordMethodCommand(user_id, sign_in_id, body.method)) + + @router.post("/password/{sign_in_id}/otp", tags=["Auth"], response_model=PasswordSignInResponse, response_model_exclude_none=True) + async def password_otp(self, sign_in_id: str, body: PasswordOtpRequest, user_id: str = Depends(get_password_caller)): + """Submit the authenticator code. complete, or otp_invalid with attempts_left (3 tries per method choice).""" + return await self.mediator.send(SubmitPasswordOtpCommand(user_id, sign_in_id, body.code)) + + @router.get("/password/{sign_in_id}", tags=["Auth"], response_model=PasswordSignInResponse, response_model_exclude_none=True) + async def password_status(self, sign_in_id: str, user_id: str = Depends(get_password_caller)): + return await self.mediator.send(GetPasswordSignInQuery(user_id, sign_in_id)) + + @router.delete("/password/{sign_in_id}", tags=["Auth"], status_code=204) + async def password_cancel(self, sign_in_id: str, user_id: str = Depends(get_password_caller)): + await self.mediator.send(CancelPasswordSignInCommand(user_id, sign_in_id)) + return Response(status_code=204) diff --git a/src/api/controllers/chats_controller.py b/src/api/controllers/chats_controller.py new file mode 100644 index 0000000..dec6b94 --- /dev/null +++ b/src/api/controllers/chats_controller.py @@ -0,0 +1,72 @@ +from fastapi import APIRouter, Depends, Header, Query, Request, Response +from fastapi.responses import StreamingResponse +from mediatorx import Mediator + +from src.api.controller import controller +from src.api.dependencies import get_mediator +from src.api.http_headers import HttpHeaders +from src.api.request_bodies.chat_message_request_body import ChatMessageRequestBody +from src.api.responses.file_responses import FileResponses +from src.application.commands.mark_chat_read_command import MarkChatReadCommand +from src.application.commands.schedule_chat_meeting_command import ScheduleChatMeetingCommand +from src.application.commands.send_chat_message_command import SendChatMessageCommand +from src.application.dtos.meeting_request import MeetingRequest +from src.application.queries.get_chat_file_pdf_query import GetChatFilePdfQuery +from src.application.queries.get_chat_file_query import GetChatFileQuery +from src.application.queries.get_chat_file_thumbnail_query import GetChatFileThumbnailQuery +from src.application.queries.get_chat_hosted_content_query import GetChatHostedContentQuery +from src.application.queries.get_chat_photo_query import GetChatPhotoQuery +from src.application.queries.list_chat_messages_query import ListChatMessagesQuery +from src.application.queries.list_chats_query import ListChatsQuery + +router = APIRouter(prefix="/api") + + +@controller(router) +class ChatsController: + mediator: Mediator = Depends(get_mediator) + + @router.get("/chats", tags=["Microsoft 365"]) + async def chats(self, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), top: int = Query(25, le=200)): + return await self.mediator.send(ListChatsQuery(token, top)) + + @router.get("/chats/{chat_id}/messages", tags=["Microsoft 365"]) + async def chat_messages(self, chat_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), top: int = Query(30, ge=1, le=300)): + return await self.mediator.send(ListChatMessagesQuery(token, chat_id, top)) + + @router.get("/chats/{chat_id}/messages/{message_id}/hostedContents/{hosted_id:path}", tags=["Microsoft 365"], response_class=Response, responses={200: {"content": {"image/png": {}}}}) + async def chat_hosted_content(self, chat_id: str, message_id: str, hosted_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + return FileResponses.image(await self.mediator.send(GetChatHostedContentQuery(token, chat_id, message_id, hosted_id))) + + @router.get("/chats/{chat_id}/messages/{message_id}/files/{attachment_id}", tags=["Microsoft 365"], response_class=StreamingResponse, responses={200: {"content": {"application/octet-stream": {}}}}) + async def chat_file(self, chat_id: str, message_id: str, attachment_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + return FileResponses.stream(await self.mediator.send(GetChatFileQuery(token, chat_id, message_id, attachment_id))) + + @router.get("/chats/{chat_id}/messages/{message_id}/files/{attachment_id}/thumbnail", tags=["Microsoft 365"], response_class=Response, responses={200: {"content": {"image/jpeg": {}}}}) + async def chat_file_thumbnail(self, chat_id: str, message_id: str, attachment_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), size: str = Query("medium")): + """The preview image of a file shared in a message (`files[].hasThumbnail`): JPEG, PNG or WEBP; 404 when the file has none. size is small, medium or large.""" + return FileResponses.image(await self.mediator.send(GetChatFileThumbnailQuery(token, chat_id, message_id, attachment_id, size))) + + @router.get("/chats/{chat_id}/messages/{message_id}/files/{attachment_id}/pdf", tags=["Files as PDF"], response_class=StreamingResponse, responses={200: {"content": {"application/pdf": {}}}}) + async def chat_file_pdf(self, chat_id: str, message_id: str, attachment_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + """A file shared in a chat message (`files[].id`) converted to PDF where it lives (SharePoint / OneDrive). 415 when the type can't be converted.""" + return FileResponses.pdf(await self.mediator.send(GetChatFilePdfQuery(token, chat_id, message_id, attachment_id))) + + @router.post("/chats/{chat_id}/messages", tags=["Microsoft 365"]) + async def send_chat_message(self, chat_id: str, request: Request, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + """JSON `{content, contentType, importance, mentions, gif}` (or the legacy `{text}`), or multipart/form-data with the same fields (mentions and gif as JSON strings) plus `images` and `files` parts.""" + return await self.mediator.send(SendChatMessageCommand(token, chat_id, ChatMessageRequestBody(request))) + + @router.post("/chats/{chat_id}/meeting", status_code=201, tags=["Microsoft 365"]) + async def schedule_chat_meeting(self, chat_id: str, body: MeetingRequest, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + """Creates a Teams meeting in the user's calendar with the chat's members as attendees.""" + return await self.mediator.send(ScheduleChatMeetingCommand(token, chat_id, body)) + + @router.post("/chats/{chat_id}/read", status_code=204, tags=["Microsoft 365"]) + async def mark_chat_read(self, chat_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + await self.mediator.send(MarkChatReadCommand(token, chat_id)) + return Response(status_code=204) + + @router.get("/chats/{chat_id}/photo", tags=["Microsoft 365"], response_class=Response, responses={200: {"content": {"image/jpeg": {}}}}) + async def chat_photo(self, chat_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + return FileResponses.image(await self.mediator.send(GetChatPhotoQuery(token, chat_id))) diff --git a/src/api/controllers/emojis_controller.py b/src/api/controllers/emojis_controller.py new file mode 100644 index 0000000..1e5c3e1 --- /dev/null +++ b/src/api/controllers/emojis_controller.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, Depends, Header, Query, Response +from mediatorx import Mediator + +from src.api.controller import controller +from src.api.dependencies import get_mediator +from src.api.http_headers import HttpHeaders +from src.api.responses.file_responses import FileResponses +from src.application.queries.get_custom_emoji_query import GetCustomEmojiQuery +from src.application.queries.get_emoji_catalogue_query import GetEmojiCatalogueQuery + +router = APIRouter(prefix="/api") + + +@controller(router) +class EmojisController: + mediator: Mediator = Depends(get_mediator) + + @router.get("/emojis/catalogue", tags=["Emojis"]) + async def emoji_catalogue(self, response: Response, locale: str = Query("default", max_length=20)): + """Teams' emoji catalogue `{locale, version, assetBase, sizes, fps, categories: [{id, title, emojis: [{id, shortcuts, unicode, title, keywords, diverse, animated, staticUrl, animatedUrl, frames, frameMs, firstFrame, etag}]}], custom}`. + + Public data (no token involved), cached for 24 hours per locale. The URLs are 30 px; swap the size in the file name (20, 30, 40, 50, 60, 100) for another. An animated emoji is a vertical sprite strip of `frames` square frames shown at `frameMs` each.""" + catalogue = await self.mediator.send(GetEmojiCatalogueQuery(locale)) + response.headers["Cache-Control"] = "public, max-age=3600" + return catalogue + + @router.get("/emojis/custom/{document_id}", tags=["Emojis"], response_class=Response, responses={200: {"content": {"image/png": {}}}}) + async def custom_emoji(self, document_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), animated: bool = Query(True)): + """A tenant custom emoji's picture (`emojis[].documentId` of a message emoji with kind custom). Animated is a GIF/WEBP that plays in a plain image tag.""" + return FileResponses.image(await self.mediator.send(GetCustomEmojiQuery(token, document_id, animated))) diff --git a/src/api/controllers/files_controller.py b/src/api/controllers/files_controller.py new file mode 100644 index 0000000..8c04d1e --- /dev/null +++ b/src/api/controllers/files_controller.py @@ -0,0 +1,117 @@ +from fastapi import APIRouter, Depends, Header, Query, Request, Response +from fastapi.responses import StreamingResponse +from mediatorx import Mediator + +from src.api.controller import controller +from src.api.dependencies import get_mediator +from src.api.http_headers import HttpHeaders +from src.api.request_bodies.raw_request_body import RawRequestBody +from src.api.responses.file_responses import FileResponses +from src.application.commands.convert_to_pdf_command import ConvertToPdfCommand +from src.application.commands.convert_to_text_command import ConvertToTextCommand +from src.application.queries.get_drive_item_content_query import GetDriveItemContentQuery +from src.application.queries.get_drive_item_pdf_query import GetDriveItemPdfQuery +from src.application.queries.get_drive_item_query import GetDriveItemQuery +from src.application.queries.get_drive_item_text_query import GetDriveItemTextQuery +from src.application.queries.get_drive_thumbnail_query import GetDriveThumbnailQuery +from src.application.queries.get_shared_drive_item_pdf_query import GetSharedDriveItemPdfQuery +from src.application.queries.get_shared_drive_item_text_query import GetSharedDriveItemTextQuery +from src.application.queries.get_shared_drive_thumbnail_query import GetSharedDriveThumbnailQuery +from src.application.queries.list_drive_item_children_query import ListDriveItemChildrenQuery +from src.application.queries.list_files_query import ListFilesQuery +from src.application.queries.list_recent_files_query import ListRecentFilesQuery +from src.application.queries.list_shared_files_query import ListSharedFilesQuery +from src.domain.files.file_text_limits import FileTextLimits + +router = APIRouter(prefix="/api") + + +class FileTextParameters: + """The paging parameters every FileText route shares.""" + + OFFSET = Query(0, ge=0, le=100_000_000) + CHARS = Query(FileTextLimits.DEFAULT_CHARS, ge=1, le=FileTextLimits.MAX_CHARS) + SHEET = Query(None, max_length=64) + ROW_OFFSET = Query(0, ge=0, le=FileTextLimits.MAX_ROW_OFFSET) + ROWS = Query(FileTextLimits.DEFAULT_ROWS, ge=1, le=FileTextLimits.MAX_ROWS) + + +@controller(router) +class FilesController: + mediator: Mediator = Depends(get_mediator) + + @router.get("/files", tags=["Microsoft 365"]) + async def files(self, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), path: str = Query("root"), top: int = Query(100, le=1000)): + return await self.mediator.send(ListFilesQuery(token, path, top)) + + @router.get("/files/{item_id}/thumbnail", tags=["Microsoft 365"], response_class=Response, responses={200: {"content": {"image/jpeg": {}}}}) + async def drive_thumbnail(self, item_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), size: str = Query("medium")): + """The preview image of a OneDrive item (an `id` from /api/files); 404 when it has none.""" + return FileResponses.image(await self.mediator.send(GetDriveThumbnailQuery(token, item_id, size))) + + @router.get("/drives/{drive_id}/items/{item_id}/thumbnail", tags=["Teams files"], response_class=Response, responses={200: {"content": {"image/jpeg": {}}}}) + async def shared_drive_thumbnail(self, drive_id: str, item_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), size: str = Query("medium")): + """The preview image of an item in any drive the user can read (a team library, a shared drive); 404 when it has none.""" + return FileResponses.image(await self.mediator.send(GetSharedDriveThumbnailQuery(token, drive_id, item_id, size))) + + @router.get("/files/{item_id}/pdf", tags=["Files as PDF"], response_class=StreamingResponse, responses={200: {"content": {"application/pdf": {}}}}) + async def drive_item_pdf(self, item_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + """A OneDrive item (an `id` from /api/files) converted to PDF by Microsoft 365. 415 when the type can't be converted.""" + return FileResponses.pdf(await self.mediator.send(GetDriveItemPdfQuery(token, item_id))) + + @router.post("/convert/pdf", tags=["Files as PDF"], response_class=StreamingResponse, responses={200: {"content": {"application/pdf": {}}}}) + async def convert_pdf(self, request: Request, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), name: str = Query(..., min_length=1, max_length=255)): + """The request body (raw bytes, up to 105 MB) converted to PDF; `name` is the file name, whose extension picks the type (Word, Excel, PowerPoint, OpenDocument, RTF, CSV). + + For files that aren't in a drive: they go to a temporary file in the app folder, which is deleted right after the conversion.""" + return FileResponses.pdf(await self.mediator.send(ConvertToPdfCommand(token, name, RawRequestBody(request)))) + + @router.get("/drives/{drive_id}/items/{item_id}", tags=["Teams files"]) + async def drive_item(self, drive_id: str, item_id: str, response: Response, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + """One driveItem with a fresh download URL.""" + response.headers["Cache-Control"] = "no-store" + return await self.mediator.send(GetDriveItemQuery(token, drive_id, item_id)) + + @router.get("/drives/{drive_id}/items/{item_id}/children", tags=["Teams files"]) + async def drive_item_children(self, drive_id: str, item_id: str, response: Response, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), top: int = Query(100, ge=1, le=1000)): + response.headers["Cache-Control"] = "no-store" + return await self.mediator.send(ListDriveItemChildrenQuery(token, drive_id, item_id, top)) + + @router.get("/drives/{drive_id}/items/{item_id}/content", tags=["Teams files"], response_class=StreamingResponse, responses={200: {"content": {"application/octet-stream": {}}}}) + async def drive_item_content(self, drive_id: str, item_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + """The file's bytes as an attachment (<= 100 MB, no-store).""" + return FileResponses.stream(await self.mediator.send(GetDriveItemContentQuery(token, drive_id, item_id))) + + @router.get("/drives/{drive_id}/items/{item_id}/pdf", tags=["Files as PDF"], response_class=StreamingResponse, responses={200: {"content": {"application/pdf": {}}}}) + async def shared_drive_item_pdf(self, drive_id: str, item_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + """An item of any readable drive converted to PDF by Microsoft 365. 415 when the type can't be converted.""" + return FileResponses.pdf(await self.mediator.send(GetSharedDriveItemPdfQuery(token, drive_id, item_id))) + + @router.get("/drives/{drive_id}/items/{item_id}/text", tags=["File text"]) + async def shared_drive_item_text(self, drive_id: str, item_id: str, response: Response, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), offset: int = FileTextParameters.OFFSET, max_chars: int = FileTextParameters.CHARS, sheet: str | None = FileTextParameters.SHEET, row_offset: int = FileTextParameters.ROW_OFFSET, max_rows: int = FileTextParameters.ROWS): + """Plain text of a document, spreadsheet, presentation, PDF or text file (FileText), paged by characters (and rows for a sheet).""" + response.headers["Cache-Control"] = "no-store" + return await self.mediator.send(GetSharedDriveItemTextQuery(token, drive_id, item_id, offset, max_chars, sheet, row_offset, max_rows)) + + @router.get("/files/{item_id}/text", tags=["File text"]) + async def drive_item_text(self, item_id: str, response: Response, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), offset: int = FileTextParameters.OFFSET, max_chars: int = FileTextParameters.CHARS, sheet: str | None = FileTextParameters.SHEET, row_offset: int = FileTextParameters.ROW_OFFSET, max_rows: int = FileTextParameters.ROWS): + """FileText of an item in the user's own OneDrive.""" + response.headers["Cache-Control"] = "no-store" + return await self.mediator.send(GetDriveItemTextQuery(token, item_id, offset, max_chars, sheet, row_offset, max_rows)) + + @router.post("/convert/text", tags=["File text"]) + async def convert_text(self, request: Request, response: Response, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), name: str = Query(..., min_length=1, max_length=255), offset: int = FileTextParameters.OFFSET, max_chars: int = FileTextParameters.CHARS, sheet: str | None = FileTextParameters.SHEET, row_offset: int = FileTextParameters.ROW_OFFSET, max_rows: int = FileTextParameters.ROWS): + """FileText of the request body (raw bytes, up to 25 MB); `name`'s extension picks the type. For files that aren't in a drive.""" + response.headers["Cache-Control"] = "no-store" + return await self.mediator.send(ConvertToTextCommand(token, name, offset, max_chars, sheet, row_offset, max_rows, RawRequestBody(request))) + + @router.get("/files/shared", tags=["Teams files"]) + async def files_shared(self, response: Response, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), top: int = Query(50, ge=1, le=200)): + """Files shared with the user, flattened from `remoteItem`, each with `parentReference.driveId` and `shared: {sharedBy, sharedDateTime}`.""" + response.headers["Cache-Control"] = "no-store" + return await self.mediator.send(ListSharedFilesQuery(token, top)) + + @router.get("/files/recent", tags=["Teams files"]) + async def files_recent(self, response: Response, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), top: int = Query(50, ge=1, le=200)): + response.headers["Cache-Control"] = "no-store" + return await self.mediator.send(ListRecentFilesQuery(token, top)) diff --git a/src/api/controllers/gifs_controller.py b/src/api/controllers/gifs_controller.py new file mode 100644 index 0000000..f4c7dfc --- /dev/null +++ b/src/api/controllers/gifs_controller.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends, Header, Query +from mediatorx import Mediator + +from src.api.controller import controller +from src.api.dependencies import get_mediator +from src.api.http_headers import HttpHeaders +from src.application.queries.search_gifs_query import SearchGifsQuery + +router = APIRouter(prefix="/api") + + +@controller(router) +class GifsController: + mediator: Mediator = Depends(get_mediator) + + @router.get("/gifs/search", tags=["GIFs"]) + async def gif_search(self, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), q: str = Query(..., min_length=1, max_length=100), limit: int = Query(24, ge=1, le=50)): + return await self.mediator.send(SearchGifsQuery(token, q, limit)) + + @router.get("/gifs/trending", tags=["GIFs"]) + async def gif_trending(self, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), limit: int = Query(24, ge=1, le=50)): + return await self.mediator.send(SearchGifsQuery(token, None, limit)) diff --git a/src/api/controllers/health_controller.py b/src/api/controllers/health_controller.py new file mode 100644 index 0000000..ea957c9 --- /dev/null +++ b/src/api/controllers/health_controller.py @@ -0,0 +1,28 @@ +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse +from mediatorx import Mediator + +from src.api.controller import controller +from src.api.dependencies import get_mediator +from src.application.queries.get_readiness_query import GetReadinessQuery + +router = APIRouter() + + +@controller(router) +class HealthController: + mediator: Mediator = Depends(get_mediator) + + @router.get("/health/live", include_in_schema=False) + async def health_live(self): + return {"status": "ok"} + + @router.get("/health/ready", include_in_schema=False) + async def health_ready(self): + if await self.mediator.send(GetReadinessQuery()): + return {"status": "ready"} + return JSONResponse({"status": "starting"}, status_code=503) + + @router.get("/", include_in_schema=False) + async def root(self): + return {"service": "SchulyMicrosoft", "status": "ok"} diff --git a/src/api/controllers/mail_controller.py b/src/api/controllers/mail_controller.py new file mode 100644 index 0000000..b01f69a --- /dev/null +++ b/src/api/controllers/mail_controller.py @@ -0,0 +1,103 @@ +from fastapi import APIRouter, Depends, Header, Query, Response +from fastapi.responses import StreamingResponse +from mediatorx import Mediator + +from src.api.controller import controller +from src.api.dependencies import get_mediator +from src.api.http_headers import HttpHeaders +from src.api.responses.file_responses import FileResponses +from src.application.commands.create_mail_draft_command import CreateMailDraftCommand +from src.application.commands.delete_mail_command import DeleteMailCommand +from src.application.commands.flag_mail_command import FlagMailCommand +from src.application.commands.forward_mail_command import ForwardMailCommand +from src.application.commands.mark_mail_read_command import MarkMailReadCommand +from src.application.commands.move_mail_command import MoveMailCommand +from src.application.commands.reply_mail_command import ReplyMailCommand +from src.application.commands.send_mail_command import SendMailCommand +from src.application.commands.send_mail_draft_command import SendMailDraftCommand +from src.application.dtos.mail_flag_request import MailFlagRequest +from src.application.dtos.mail_forward_request import MailForwardRequest +from src.application.dtos.mail_message_in import MailMessageIn +from src.application.dtos.mail_move_request import MailMoveRequest +from src.application.dtos.mail_read_request import MailReadRequest +from src.application.dtos.mail_reply_request import MailReplyRequest +from src.application.queries.get_mail_attachment_pdf_query import GetMailAttachmentPdfQuery +from src.application.queries.get_mail_attachment_query import GetMailAttachmentQuery +from src.application.queries.get_mail_message_query import GetMailMessageQuery +from src.application.queries.list_mail_attachments_query import ListMailAttachmentsQuery +from src.application.queries.list_mail_folders_query import ListMailFoldersQuery +from src.application.queries.list_mail_query import ListMailQuery + +router = APIRouter(prefix="/api") + + +@controller(router) +class MailController: + mediator: Mediator = Depends(get_mediator) + + @router.get("/mail", tags=["Microsoft 365"]) + async def mail(self, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), folder: str = Query("inbox"), top: int = Query(25, ge=1, le=500), skip: int = Query(0, ge=0, le=10000), unread: bool = Query(False), search: str | None = Query(None)): + return await self.mediator.send(ListMailQuery(token, folder, top, skip, unread, search)) + + # Fixed paths (folders/send/drafts) are declared before the {message_id} routes so they can never be read as an id. + @router.get("/mail/folders", tags=["Microsoft 365"]) + async def mail_folders(self, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + return await self.mediator.send(ListMailFoldersQuery(token)) + + @router.post("/mail/send", status_code=204, tags=["Microsoft 365"]) + async def mail_send(self, body: MailMessageIn, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + await self.mediator.send(SendMailCommand(token, body)) + return Response(status_code=204) + + @router.post("/mail/drafts", status_code=201, tags=["Microsoft 365"]) + async def mail_create_draft(self, body: MailMessageIn, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + return await self.mediator.send(CreateMailDraftCommand(token, body)) + + @router.post("/mail/drafts/{message_id}/send", status_code=204, tags=["Microsoft 365"]) + async def mail_send_draft(self, message_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + await self.mediator.send(SendMailDraftCommand(token, message_id)) + return Response(status_code=204) + + @router.get("/mail/{message_id}", tags=["Microsoft 365"]) + async def mail_message(self, message_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + return await self.mediator.send(GetMailMessageQuery(token, message_id)) + + @router.get("/mail/{message_id}/attachments", tags=["Microsoft 365"]) + async def mail_attachments(self, message_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + return await self.mediator.send(ListMailAttachmentsQuery(token, message_id)) + + @router.get("/mail/{message_id}/attachments/{attachment_id}", tags=["Microsoft 365"], response_class=Response, responses={200: {"content": {"application/octet-stream": {}}}}) + async def mail_attachment(self, message_id: str, attachment_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + return FileResponses.attachment(await self.mediator.send(GetMailAttachmentQuery(token, message_id, attachment_id))) + + @router.get("/mail/{message_id}/attachments/{attachment_id}/pdf", tags=["Files as PDF"], response_class=StreamingResponse, responses={200: {"content": {"application/pdf": {}}}}) + async def mail_attachment_pdf(self, message_id: str, attachment_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + """A mail attachment (Word, Excel, PowerPoint, OpenDocument, RTF, CSV) converted to PDF through a temporary file in the app folder. 415 when the type can't be converted.""" + return FileResponses.pdf(await self.mediator.send(GetMailAttachmentPdfQuery(token, message_id, attachment_id))) + + @router.post("/mail/{message_id}/reply", status_code=204, tags=["Microsoft 365"]) + async def mail_reply(self, message_id: str, body: MailReplyRequest, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + await self.mediator.send(ReplyMailCommand(token, message_id, body.comment, body.replyAll)) + return Response(status_code=204) + + @router.post("/mail/{message_id}/forward", status_code=204, tags=["Microsoft 365"]) + async def mail_forward(self, message_id: str, body: MailForwardRequest, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + await self.mediator.send(ForwardMailCommand(token, message_id, body.to, body.comment)) + return Response(status_code=204) + + @router.post("/mail/{message_id}/read", tags=["Microsoft 365"]) + async def mail_mark_read(self, message_id: str, body: MailReadRequest, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + return await self.mediator.send(MarkMailReadCommand(token, message_id, body.read)) + + @router.post("/mail/{message_id}/flag", tags=["Microsoft 365"]) + async def mail_flag(self, message_id: str, body: MailFlagRequest, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + return await self.mediator.send(FlagMailCommand(token, message_id, body.status)) + + @router.post("/mail/{message_id}/move", tags=["Microsoft 365"]) + async def mail_move(self, message_id: str, body: MailMoveRequest, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + return await self.mediator.send(MoveMailCommand(token, message_id, body.folder)) + + @router.delete("/mail/{message_id}", status_code=204, tags=["Microsoft 365"]) + async def mail_delete(self, message_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + await self.mediator.send(DeleteMailCommand(token, message_id)) + return Response(status_code=204) diff --git a/src/api/controllers/me_controller.py b/src/api/controllers/me_controller.py new file mode 100644 index 0000000..ed84c8d --- /dev/null +++ b/src/api/controllers/me_controller.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Depends, Header, Query +from mediatorx import Mediator + +from src.api.controller import controller +from src.api.dependencies import get_mediator +from src.api.http_headers import HttpHeaders +from src.application.queries.get_profile_query import GetProfileQuery +from src.application.queries.list_calendar_events_query import ListCalendarEventsQuery + +router = APIRouter(prefix="/api") + + +@controller(router) +class MeController: + mediator: Mediator = Depends(get_mediator) + + @router.get("/me", tags=["Microsoft 365"]) + async def me(self, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + return await self.mediator.send(GetProfileQuery(token)) + + @router.get("/calendar", tags=["Microsoft 365"]) + async def calendar(self, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), top: int = Query(25, le=500), start: str | None = Query(None), end: str | None = Query(None)): + return await self.mediator.send(ListCalendarEventsQuery(token, top, start, end)) diff --git a/src/api/controllers/one_note_controller.py b/src/api/controllers/one_note_controller.py new file mode 100644 index 0000000..903284c --- /dev/null +++ b/src/api/controllers/one_note_controller.py @@ -0,0 +1,75 @@ +from fastapi import APIRouter, Depends, Header, Query, Response +from mediatorx import Mediator + +from src.api.controller import controller +from src.api.dependencies import get_mediator +from src.api.http_headers import HttpHeaders +from src.api.responses.file_responses import FileResponses +from src.application.commands.create_one_note_page_command import CreateOneNotePageCommand +from src.application.commands.delete_one_note_page_command import DeleteOneNotePageCommand +from src.application.commands.update_one_note_page_command import UpdateOneNotePageCommand +from src.application.dtos.one_note_page_create import OneNotePageCreate +from src.application.dtos.one_note_page_update import OneNotePageUpdate +from src.application.queries.get_one_note_page_query import GetOneNotePageQuery +from src.application.queries.get_one_note_resource_query import GetOneNoteResourceQuery +from src.application.queries.get_one_note_sections_query import GetOneNoteSectionsQuery +from src.application.queries.list_notebooks_query import ListNotebooksQuery +from src.application.queries.list_one_note_notebooks_query import ListOneNoteNotebooksQuery +from src.application.queries.list_one_note_pages_query import ListOneNotePagesQuery +from src.application.queries.search_one_note_query import SearchOneNoteQuery + +router = APIRouter(prefix="/api") + + +@controller(router) +class OneNoteController: + mediator: Mediator = Depends(get_mediator) + + @router.get("/notebooks", tags=["Microsoft 365"]) + async def notebooks(self, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + return await self.mediator.send(ListNotebooksQuery(token)) + + @router.get("/onenote/notebooks", tags=["OneNote"]) + async def onenote_notebooks(self, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), teams: bool = Query(True)): + """The person's own notebooks, then each joined team's (class notebooks), with where they come from and whether they may be changed.""" + return await self.mediator.send(ListOneNoteNotebooksQuery(token, teams)) + + @router.get("/onenote/notebooks/{notebook_id}/sections", tags=["OneNote"]) + async def onenote_sections(self, notebook_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + """A notebook's sections and section groups as a tree.""" + return await self.mediator.send(GetOneNoteSectionsQuery(token, notebook_id)) + + @router.get("/onenote/sections/{section_id}/pages", tags=["OneNote"]) + async def onenote_pages(self, section_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), top: int = Query(50, ge=1, le=100), skip: int = Query(0, ge=0, le=5000)): + """One section's pages in OneNote's order, subpages with their level; `nextSkip` pages on.""" + return await self.mediator.send(ListOneNotePagesQuery(token, section_id, top, skip)) + + @router.post("/onenote/sections/{section_id}/pages", status_code=201, tags=["OneNote"]) + async def onenote_create_page(self, section_id: str, body: OneNotePageCreate, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + return await self.mediator.send(CreateOneNotePageCommand(token, section_id, body)) + + @router.get("/onenote/pages/{page_id}", tags=["OneNote"]) + async def onenote_page(self, page_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + """A page as sanitized HTML (pictures by resource id, ink as SVG), its resources and its text boxes.""" + return await self.mediator.send(GetOneNotePageQuery(token, page_id)) + + @router.patch("/onenote/pages/{page_id}", tags=["OneNote"]) + async def onenote_update_page(self, page_id: str, body: OneNotePageUpdate, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + """Replaces the title and the text of editable text boxes, and appends text, as Graph PATCH commands. Answers the page as it is after the save.""" + return await self.mediator.send(UpdateOneNotePageCommand(token, page_id, body)) + + @router.delete("/onenote/pages/{page_id}", status_code=204, tags=["OneNote"]) + async def onenote_delete_page(self, page_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + await self.mediator.send(DeleteOneNotePageCommand(token, page_id)) + return Response(status_code=204) + + @router.get("/onenote/resources/{resource_id}", tags=["OneNote"], response_class=Response, responses={200: {"content": {"image/png": {}, "application/octet-stream": {}}}}) + async def onenote_resource(self, resource_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), kind: str = Query("image"), name: str | None = Query(None, max_length=255)): + """A picture (kind=image, served inline only when it is a PNG, JPEG, GIF or WEBP) or an attached file (kind=file, always a download) of a page.""" + content = await self.mediator.send(GetOneNoteResourceQuery(token, resource_id, kind, name)) + return FileResponses.image(content) if kind == "image" else FileResponses.attachment(content) + + @router.get("/onenote/search", tags=["OneNote"]) + async def onenote_search(self, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), q: str = Query(..., min_length=1, max_length=100), notebook: str | None = Query(None), top: int = Query(25, ge=1, le=50)): + """Pages whose title holds `q` (Graph has no OneNote content search), in one notebook or everywhere. `partial` is true when a big class notebook was only searched in part.""" + return await self.mediator.send(SearchOneNoteQuery(token, q, notebook, top)) diff --git a/src/api/controllers/people_controller.py b/src/api/controllers/people_controller.py new file mode 100644 index 0000000..5b4c00a --- /dev/null +++ b/src/api/controllers/people_controller.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, Depends, Header, Query, Response +from mediatorx import Mediator + +from src.api.controller import controller +from src.api.dependencies import get_mediator +from src.api.http_headers import HttpHeaders +from src.api.responses.file_responses import FileResponses +from src.application.queries.get_person_photo_query import GetPersonPhotoQuery +from src.application.queries.get_user_photo_query import GetUserPhotoQuery + +router = APIRouter(prefix="/api") + + +@controller(router) +class PeopleController: + mediator: Mediator = Depends(get_mediator) + + @router.get("/people/photo", tags=["Microsoft 365"], response_class=Response, responses={200: {"content": {"image/jpeg": {}}}}) + async def people_photo(self, email: str = Query(..., max_length=254), token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), size: int = Query(48)): + """A person's profile photo by e-mail address (mail senders); 404 when they have none or are outside the tenant.""" + return FileResponses.image(await self.mediator.send(GetPersonPhotoQuery(token, email, size))) + + @router.get("/users/{user_id}/photo", tags=["Microsoft 365"], response_class=Response, responses={200: {"content": {"image/jpeg": {}}}}) + async def user_photo(self, user_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), size: int = Query(64)): + return FileResponses.user_photo(await self.mediator.send(GetUserPhotoQuery(token, user_id, size))) diff --git a/src/api/controllers/search_controller.py b/src/api/controllers/search_controller.py new file mode 100644 index 0000000..deb311c --- /dev/null +++ b/src/api/controllers/search_controller.py @@ -0,0 +1,28 @@ +from fastapi import APIRouter, Depends, Header, Response +from mediatorx import Mediator + +from src.api.controller import controller +from src.api.dependencies import get_mediator +from src.api.http_headers import HttpHeaders +from src.application.dtos.files_search_request import FilesSearchRequest +from src.application.dtos.search_request import SearchRequest +from src.application.queries.search_query import SearchQuery + +router = APIRouter(prefix="/api") + + +@controller(router) +class SearchController: + mediator: Mediator = Depends(get_mediator) + + @router.post("/files/search", tags=["Search"]) + async def files_search(self, body: FilesSearchRequest, response: Response, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + """SearchResult with file hits only. With `teamId` that team's library is searched. The query never reaches a log.""" + response.headers["Cache-Control"] = "no-store" + return await self.mediator.send(SearchQuery(token, body.q, ["files"], body.start, body.size, body.teamId)) + + @router.post("/search", tags=["Search"]) + async def search_all(self, body: SearchRequest, response: Response, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + """SearchResult over files, mail, chats and channel posts (`types`), each hit with the place it lives in.""" + response.headers["Cache-Control"] = "no-store" + return await self.mediator.send(SearchQuery(token, body.q, body.types, body.start, body.size, body.teamId)) diff --git a/src/api/controllers/teams_controller.py b/src/api/controllers/teams_controller.py new file mode 100644 index 0000000..24f01a6 --- /dev/null +++ b/src/api/controllers/teams_controller.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends, Header, Query, Response +from mediatorx import Mediator + +from src.api.controller import controller +from src.api.dependencies import get_mediator +from src.api.http_headers import HttpHeaders +from src.application.queries.list_channel_message_replies_query import ListChannelMessageRepliesQuery +from src.application.queries.list_channel_messages_query import ListChannelMessagesQuery +from src.application.queries.list_joined_teams_query import ListJoinedTeamsQuery +from src.application.queries.list_team_channels_query import ListTeamChannelsQuery +from src.application.queries.list_team_files_query import ListTeamFilesQuery +from src.domain.files.teams_path_limits import TeamsPathLimits + +router = APIRouter(prefix="/api") + + +@controller(router) +class TeamsController: + mediator: Mediator = Depends(get_mediator) + + @router.get("/teams", tags=["Teams files"]) + async def joined_teams(self, response: Response, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + """The joined teams `[{id, displayName, description}]`, cached 10 minutes per account.""" + response.headers["Cache-Control"] = "no-store" + return await self.mediator.send(ListJoinedTeamsQuery(token)) + + @router.get("/teams/{team_id}/channels", tags=["Teams files"]) + async def team_channels(self, team_id: str, response: Response, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + response.headers["Cache-Control"] = "no-store" + return await self.mediator.send(ListTeamChannelsQuery(token, team_id)) + + @router.get("/teams/{team_id}/files", tags=["Teams files"]) + async def team_files(self, team_id: str, response: Response, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), channel_id: str | None = Query(None), path: str | None = Query(None, max_length=TeamsPathLimits.MAX_PATH_CHARS), top: int = Query(100, ge=1, le=1000)): + """`{driveId, folder: {id, name, path}, items}`: the team library root, a channel's folder (`channel_id`) or a sub-folder (`path`, folder names under that folder).""" + response.headers["Cache-Control"] = "no-store" + return await self.mediator.send(ListTeamFilesQuery(token, team_id, channel_id, path, top)) + + @router.get("/teams/{team_id}/channels/{channel_id}/messages", tags=["Teams files"]) + async def channel_message_list(self, team_id: str, channel_id: str, response: Response, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), top: int = Query(30, ge=1, le=200)): + response.headers["Cache-Control"] = "no-store" + return await self.mediator.send(ListChannelMessagesQuery(token, team_id, channel_id, top)) + + @router.get("/teams/{team_id}/channels/{channel_id}/messages/{message_id}/replies", tags=["Teams files"]) + async def channel_message_replies(self, team_id: str, channel_id: str, message_id: str, response: Response, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER), top: int = Query(50, ge=1, le=200)): + response.headers["Cache-Control"] = "no-store" + return await self.mediator.send(ListChannelMessageRepliesQuery(token, team_id, channel_id, message_id, top)) diff --git a/src/api/dependencies.py b/src/api/dependencies.py new file mode 100644 index 0000000..8f716f5 --- /dev/null +++ b/src/api/dependencies.py @@ -0,0 +1,30 @@ +"""FastAPI dependencies. The composition itself lives in `src/api/extensions` and `src/api/app.py`.""" + +import hmac + +from fastapi import Request +from mediatorx import Mediator + +from src.api.http_headers import HttpHeaders +from src.application.options.service_auth_options import ServiceAuthOptions +from src.domain.errors.service_error import ServiceError +from src.domain.validation.graph_ids import GraphIds + + +def get_mediator(request: Request) -> Mediator: + """FastAPI dependency. Returns the process-wide Mediator instance.""" + return request.app.state.services.mediator + + +def get_password_caller(request: Request) -> str: + """Gate for the password routes: the shared service key plus the Schuly user id. Returns the user id.""" + expected = request.app.state.services.get(ServiceAuthOptions).service_key + if not expected: + raise ServiceError(503, "Password sign-in is disabled: no service key is configured.") + given = request.headers.get(HttpHeaders.SERVICE_KEY_HEADER, "") + if not hmac.compare_digest(given.encode(), expected.encode()): + raise ServiceError(401, "Missing or invalid service key.") + user_id = request.headers.get(HttpHeaders.USER_ID_HEADER, "") + if not GraphIds.SCHULY_USER_ID_PATTERN.fullmatch(user_id): + raise ServiceError(401, "Missing or invalid user id.") + return user_id diff --git a/src/api/di/service_collection.py b/src/api/di/service_collection.py new file mode 100644 index 0000000..31da73a --- /dev/null +++ b/src/api/di/service_collection.py @@ -0,0 +1,72 @@ +import inspect +import threading +from typing import get_type_hints + +from mediatorx import Mediator + + +class ServiceProvider: + """Builds what was registered: a singleton once for the process, a scoped service at each resolve. The mediator resolves a handler per `send`, so once per request; handlers hold no state of their own.""" + + SINGLETON, INSTANCE, SCOPED = "singleton", "instance", "scoped" + + def __init__(self, registrations: dict[type, tuple[str, object]], handlers: dict[type, type]): + self._registrations = registrations + self._singletons: dict[type, object] = {} + self._lock = threading.RLock() + self.mediator = Mediator(resolver=self) + for message, handler in handlers.items(): + self.mediator.register(message, handler) + + def get(self, service: type) -> object: + if service not in self._registrations: + raise KeyError(f"no registration for {service.__name__}") + lifetime, implementation = self._registrations[service] + if lifetime == self.INSTANCE: + return implementation + if lifetime == self.SCOPED: + return self._create(implementation) + # FastAPI runs sync dependencies on worker threads: two first uses must still build one singleton. + with self._lock: + if service not in self._singletons: + self._singletons[service] = self._create(implementation) + return self._singletons[service] + + def resolve(self, t: type) -> object: + """mediatorx's resolver protocol.""" + return self.get(t) + + def _create(self, implementation: type) -> object: + if "__init__" not in vars(implementation): + return implementation() + hints = get_type_hints(implementation.__init__) + names = [name for name in inspect.signature(implementation.__init__).parameters if name != "self"] + return implementation(**{name: self.get(hints[name]) for name in names}) + + +class ServiceCollection: + """The explicit lifetimes, like `AddSingleton` and `AddScoped`. Constructor parameters are resolved by their type hints.""" + + def __init__(self): + self._registrations: dict[type, tuple[str, object]] = {} + self._handlers: dict[type, type] = {} + + def add_singleton(self, service: type, implementation: type | None = None) -> "ServiceCollection": + self._registrations[service] = (ServiceProvider.SINGLETON, implementation or service) + return self + + def add_instance(self, service: type, instance: object) -> "ServiceCollection": + self._registrations[service] = (ServiceProvider.INSTANCE, instance) + return self + + def add_scoped(self, service: type, implementation: type | None = None) -> "ServiceCollection": + self._registrations[service] = (ServiceProvider.SCOPED, implementation or service) + return self + + def add_handler(self, message: type, handler: type) -> "ServiceCollection": + """A command or query with its handler, which is scoped.""" + self._handlers[message] = handler + return self.add_scoped(handler) + + def build(self) -> ServiceProvider: + return ServiceProvider(self._registrations, self._handlers) diff --git a/src/api/extensions/chats_extensions.py b/src/api/extensions/chats_extensions.py new file mode 100644 index 0000000..94e7280 --- /dev/null +++ b/src/api/extensions/chats_extensions.py @@ -0,0 +1,41 @@ +from src.api.di.service_collection import ServiceCollection +from src.application.abstractions.i_chat_reader import IChatReader +from src.application.abstractions.i_chat_sender import IChatSender +from src.application.abstractions.i_teams_chat import ITeamsChat +from src.application.commands.mark_chat_read_command import MarkChatReadCommand, MarkChatReadHandler +from src.application.commands.schedule_chat_meeting_command import ScheduleChatMeetingCommand, ScheduleChatMeetingHandler +from src.application.commands.send_chat_message_command import SendChatMessageCommand, SendChatMessageHandler +from src.application.queries.get_chat_file_pdf_query import GetChatFilePdfQuery, GetChatFilePdfHandler +from src.application.queries.get_chat_file_query import GetChatFileQuery, GetChatFileHandler +from src.application.queries.get_chat_file_thumbnail_query import GetChatFileThumbnailQuery, GetChatFileThumbnailHandler +from src.application.queries.get_chat_hosted_content_query import GetChatHostedContentQuery, GetChatHostedContentHandler +from src.application.queries.get_chat_photo_query import GetChatPhotoQuery, GetChatPhotoHandler +from src.application.queries.list_chat_messages_query import ListChatMessagesQuery, ListChatMessagesHandler +from src.application.queries.list_chats_query import ListChatsQuery, ListChatsHandler +from src.application.services.chat_file_lookup import ChatFileLookup +from src.application.services.chat_html_sanitizer import ChatHtmlSanitizer +from src.infrastructure.teams.chat_reader import ChatReader +from src.infrastructure.teams.chat_sender import ChatSender +from src.infrastructure.teams.teams_chat_client import TeamsChatClient + + +class ChatsExtensions: + @staticmethod + def add_chats(services: ServiceCollection) -> ServiceCollection: + services.add_singleton(ChatHtmlSanitizer) + services.add_singleton(ChatFileLookup) + services.add_singleton(ITeamsChat, TeamsChatClient) + services.add_singleton(IChatReader, ChatReader) + services.add_singleton(IChatSender, ChatSender) + + services.add_handler(ListChatsQuery, ListChatsHandler) + services.add_handler(ListChatMessagesQuery, ListChatMessagesHandler) + services.add_handler(GetChatHostedContentQuery, GetChatHostedContentHandler) + services.add_handler(GetChatFileQuery, GetChatFileHandler) + services.add_handler(GetChatFileThumbnailQuery, GetChatFileThumbnailHandler) + services.add_handler(GetChatFilePdfQuery, GetChatFilePdfHandler) + services.add_handler(SendChatMessageCommand, SendChatMessageHandler) + services.add_handler(ScheduleChatMeetingCommand, ScheduleChatMeetingHandler) + services.add_handler(MarkChatReadCommand, MarkChatReadHandler) + services.add_handler(GetChatPhotoQuery, GetChatPhotoHandler) + return services diff --git a/src/api/extensions/emojis_extensions.py b/src/api/extensions/emojis_extensions.py new file mode 100644 index 0000000..b935e8d --- /dev/null +++ b/src/api/extensions/emojis_extensions.py @@ -0,0 +1,18 @@ +from src.api.di.service_collection import ServiceCollection +from src.application.abstractions.i_emoji_catalogue import IEmojiCatalogue +from src.application.options.emoji_options import EmojiOptions +from src.application.queries.get_custom_emoji_query import GetCustomEmojiQuery, GetCustomEmojiHandler +from src.application.queries.get_emoji_catalogue_query import GetEmojiCatalogueQuery, GetEmojiCatalogueHandler +from src.infrastructure.teams.emoji_catalogue import EmojiCatalogue + + +class EmojisExtensions: + @staticmethod + def add_emojis(services: ServiceCollection) -> ServiceCollection: + """The catalogue cache is process-wide.""" + services.add_instance(EmojiOptions, EmojiOptions.from_environment()) + services.add_singleton(IEmojiCatalogue, EmojiCatalogue) + + services.add_handler(GetEmojiCatalogueQuery, GetEmojiCatalogueHandler) + services.add_handler(GetCustomEmojiQuery, GetCustomEmojiHandler) + return services diff --git a/src/api/extensions/exception_handling_extensions.py b/src/api/extensions/exception_handling_extensions.py new file mode 100644 index 0000000..2f98c6a --- /dev/null +++ b/src/api/extensions/exception_handling_extensions.py @@ -0,0 +1,36 @@ +from fastapi import FastAPI, Request +from fastapi.exception_handlers import request_validation_exception_handler +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from src.domain.errors.service_error import ServiceError +from src.domain.errors.sign_in_refused import SignInRefused + + +class ExceptionHandlingExtensions: + """The one place that turns exceptions into responses, like `AddSchulyExceptionHandling`.""" + + @classmethod + def use_schuly_exception_handling(cls, app: FastAPI) -> None: + app.add_exception_handler(ServiceError, cls._service_error) + app.add_exception_handler(SignInRefused, cls._sign_in_refused) + app.add_exception_handler(RequestValidationError, cls._validation_error) + + @staticmethod + async def _service_error(_: Request, exc: ServiceError) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}, headers=exc.headers) + + @staticmethod + async def _sign_in_refused(_: Request, exc: SignInRefused) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, content={"status": "error", "code": exc.code, "message": exc.message}) + + @staticmethod + async def _validation_error(request: Request, exc: RequestValidationError) -> JSONResponse: + # FastAPI's default 422 echoes the rejected input; for the password routes that would be the password. + if request.url.path.startswith("/api/auth/password"): + details = [{"loc": list(e.get("loc", ())), "msg": e.get("msg"), "type": e.get("type")} for e in exc.errors()] + return JSONResponse(status_code=422, content={"status": "error", "code": "invalid_request", "message": "The request is invalid.", "detail": details}) + if request.url.path in ("/api/search", "/api/files/search"): + # The rejected input would be the search query. + return JSONResponse(status_code=422, content={"detail": [{"loc": list(e.get("loc", ())), "msg": e.get("msg"), "type": e.get("type")} for e in exc.errors()]}) + return await request_validation_exception_handler(request, exc) diff --git a/src/api/extensions/files_extensions.py b/src/api/extensions/files_extensions.py new file mode 100644 index 0000000..9b2c3cf --- /dev/null +++ b/src/api/extensions/files_extensions.py @@ -0,0 +1,54 @@ +from src.api.di.service_collection import ServiceCollection +from src.application.abstractions.i_file_text_extractor import IFileTextExtractor +from src.application.abstractions.i_file_thumbnails import IFileThumbnails +from src.application.abstractions.i_pdf_converter import IPdfConverter +from src.application.abstractions.i_teams_files import ITeamsFiles +from src.application.commands.convert_to_pdf_command import ConvertToPdfCommand, ConvertToPdfHandler +from src.application.commands.convert_to_text_command import ConvertToTextCommand, ConvertToTextHandler +from src.application.queries.get_drive_item_content_query import GetDriveItemContentQuery, GetDriveItemContentHandler +from src.application.queries.get_drive_item_pdf_query import GetDriveItemPdfQuery, GetDriveItemPdfHandler +from src.application.queries.get_drive_item_query import GetDriveItemQuery, GetDriveItemHandler +from src.application.queries.get_drive_item_text_query import GetDriveItemTextQuery, GetDriveItemTextHandler +from src.application.queries.get_drive_thumbnail_query import GetDriveThumbnailQuery, GetDriveThumbnailHandler +from src.application.queries.get_shared_drive_item_pdf_query import GetSharedDriveItemPdfQuery, GetSharedDriveItemPdfHandler +from src.application.queries.get_shared_drive_item_text_query import GetSharedDriveItemTextQuery, GetSharedDriveItemTextHandler +from src.application.queries.get_shared_drive_thumbnail_query import GetSharedDriveThumbnailQuery, GetSharedDriveThumbnailHandler +from src.application.queries.list_drive_item_children_query import ListDriveItemChildrenQuery, ListDriveItemChildrenHandler +from src.application.queries.list_files_query import ListFilesQuery, ListFilesHandler +from src.application.queries.list_recent_files_query import ListRecentFilesQuery, ListRecentFilesHandler +from src.application.queries.list_shared_files_query import ListSharedFilesQuery, ListSharedFilesHandler +from src.application.services.account_image_reader import AccountImageReader +from src.application.services.drive_file_text_reader import DriveFileTextReader +from src.application.services.drive_item_checks import DriveItemChecks +from src.infrastructure.files.file_text_extractor import FileTextExtractor +from src.infrastructure.graph.file_thumbnails import FileThumbnails +from src.infrastructure.graph.pdf_converter import PdfConverter +from src.infrastructure.graph.teams_files import TeamsFiles + + +class FilesExtensions: + @staticmethod + def add_files(services: ServiceCollection) -> ServiceCollection: + services.add_singleton(IFileThumbnails, FileThumbnails) + services.add_singleton(IPdfConverter, PdfConverter) + services.add_singleton(IFileTextExtractor, FileTextExtractor) + services.add_singleton(ITeamsFiles, TeamsFiles) + services.add_singleton(DriveItemChecks) + services.add_singleton(DriveFileTextReader) + services.add_singleton(AccountImageReader) + + services.add_handler(ListFilesQuery, ListFilesHandler) + services.add_handler(GetDriveThumbnailQuery, GetDriveThumbnailHandler) + services.add_handler(GetSharedDriveThumbnailQuery, GetSharedDriveThumbnailHandler) + services.add_handler(GetDriveItemPdfQuery, GetDriveItemPdfHandler) + services.add_handler(GetSharedDriveItemPdfQuery, GetSharedDriveItemPdfHandler) + services.add_handler(ConvertToPdfCommand, ConvertToPdfHandler) + services.add_handler(GetSharedDriveItemTextQuery, GetSharedDriveItemTextHandler) + services.add_handler(GetDriveItemTextQuery, GetDriveItemTextHandler) + services.add_handler(ConvertToTextCommand, ConvertToTextHandler) + services.add_handler(GetDriveItemQuery, GetDriveItemHandler) + services.add_handler(ListDriveItemChildrenQuery, ListDriveItemChildrenHandler) + services.add_handler(GetDriveItemContentQuery, GetDriveItemContentHandler) + services.add_handler(ListSharedFilesQuery, ListSharedFilesHandler) + services.add_handler(ListRecentFilesQuery, ListRecentFilesHandler) + return services diff --git a/src/api/extensions/gifs_extensions.py b/src/api/extensions/gifs_extensions.py new file mode 100644 index 0000000..4ab9335 --- /dev/null +++ b/src/api/extensions/gifs_extensions.py @@ -0,0 +1,16 @@ +from src.api.di.service_collection import ServiceCollection +from src.application.abstractions.i_gif_search import IGifSearch +from src.application.options.giphy_options import GiphyOptions +from src.application.queries.search_gifs_query import SearchGifsQuery, SearchGifsHandler +from src.infrastructure.giphy.gif_search import GifSearch + + +class GifsExtensions: + @staticmethod + def add_gifs(services: ServiceCollection) -> ServiceCollection: + """The search result cache is process-wide.""" + services.add_instance(GiphyOptions, GiphyOptions.from_environment()) + services.add_singleton(IGifSearch, GifSearch) + + services.add_handler(SearchGifsQuery, SearchGifsHandler) + return services diff --git a/src/api/extensions/health_extensions.py b/src/api/extensions/health_extensions.py new file mode 100644 index 0000000..cdac523 --- /dev/null +++ b/src/api/extensions/health_extensions.py @@ -0,0 +1,15 @@ +from src.api.di.service_collection import ServiceCollection +from src.application.queries.get_readiness_query import GetReadinessQuery, GetReadinessHandler +from src.application.services.application_lifetime import ApplicationLifetime +from src.application.services.warm_up_tracker import WarmUpTracker + + +class HealthExtensions: + @staticmethod + def add_health(services: ServiceCollection) -> ServiceCollection: + """The warm-up state and the process lifetime are singletons.""" + services.add_singleton(WarmUpTracker) + services.add_singleton(ApplicationLifetime) + + services.add_handler(GetReadinessQuery, GetReadinessHandler) + return services diff --git a/src/api/extensions/mail_extensions.py b/src/api/extensions/mail_extensions.py new file mode 100644 index 0000000..d693cdd --- /dev/null +++ b/src/api/extensions/mail_extensions.py @@ -0,0 +1,47 @@ +from src.api.di.service_collection import ServiceCollection +from src.application.abstractions.i_mail_folder_roles import IMailFolderRoles +from src.application.commands.create_mail_draft_command import CreateMailDraftCommand, CreateMailDraftHandler +from src.application.commands.delete_mail_command import DeleteMailCommand, DeleteMailHandler +from src.application.commands.flag_mail_command import FlagMailCommand, FlagMailHandler +from src.application.commands.forward_mail_command import ForwardMailCommand, ForwardMailHandler +from src.application.commands.mark_mail_read_command import MarkMailReadCommand, MarkMailReadHandler +from src.application.commands.move_mail_command import MoveMailCommand, MoveMailHandler +from src.application.commands.reply_mail_command import ReplyMailCommand, ReplyMailHandler +from src.application.commands.send_mail_command import SendMailCommand, SendMailHandler +from src.application.commands.send_mail_draft_command import SendMailDraftCommand, SendMailDraftHandler +from src.application.queries.get_mail_attachment_pdf_query import GetMailAttachmentPdfQuery, GetMailAttachmentPdfHandler +from src.application.queries.get_mail_attachment_query import GetMailAttachmentQuery, GetMailAttachmentHandler +from src.application.queries.get_mail_message_query import GetMailMessageQuery, GetMailMessageHandler +from src.application.queries.list_mail_attachments_query import ListMailAttachmentsQuery, ListMailAttachmentsHandler +from src.application.queries.list_mail_folders_query import ListMailFoldersQuery, ListMailFoldersHandler +from src.application.queries.list_mail_query import ListMailQuery, ListMailHandler +from src.application.services.mail_attachment_reader import MailAttachmentReader +from src.application.services.mail_folder_tree import MailFolderTree +from src.application.services.mail_message_builder import MailMessageBuilder +from src.infrastructure.graph.mail_folder_roles import MailFolderRoles + + +class MailExtensions: + @staticmethod + def add_mail(services: ServiceCollection) -> ServiceCollection: + services.add_singleton(IMailFolderRoles, MailFolderRoles) + services.add_singleton(MailFolderTree) + services.add_singleton(MailMessageBuilder) + services.add_singleton(MailAttachmentReader) + + services.add_handler(ListMailQuery, ListMailHandler) + services.add_handler(ListMailFoldersQuery, ListMailFoldersHandler) + services.add_handler(SendMailCommand, SendMailHandler) + services.add_handler(CreateMailDraftCommand, CreateMailDraftHandler) + services.add_handler(SendMailDraftCommand, SendMailDraftHandler) + services.add_handler(GetMailMessageQuery, GetMailMessageHandler) + services.add_handler(ListMailAttachmentsQuery, ListMailAttachmentsHandler) + services.add_handler(GetMailAttachmentQuery, GetMailAttachmentHandler) + services.add_handler(GetMailAttachmentPdfQuery, GetMailAttachmentPdfHandler) + services.add_handler(ReplyMailCommand, ReplyMailHandler) + services.add_handler(ForwardMailCommand, ForwardMailHandler) + services.add_handler(MarkMailReadCommand, MarkMailReadHandler) + services.add_handler(FlagMailCommand, FlagMailHandler) + services.add_handler(MoveMailCommand, MoveMailHandler) + services.add_handler(DeleteMailCommand, DeleteMailHandler) + return services diff --git a/src/api/extensions/me_extensions.py b/src/api/extensions/me_extensions.py new file mode 100644 index 0000000..95d12ad --- /dev/null +++ b/src/api/extensions/me_extensions.py @@ -0,0 +1,16 @@ +from src.api.di.service_collection import ServiceCollection +from src.application.abstractions.i_profile_reader import IProfileReader +from src.application.queries.get_profile_query import GetProfileQuery, GetProfileHandler +from src.application.queries.list_calendar_events_query import ListCalendarEventsQuery, ListCalendarEventsHandler +from src.infrastructure.microsoft.profile_reader import ProfileReader + + +class MeExtensions: + @staticmethod + def add_me(services: ServiceCollection) -> ServiceCollection: + """The profile lives on the account's broker, so the reader holds no state of its own.""" + services.add_singleton(IProfileReader, ProfileReader) + + services.add_handler(GetProfileQuery, GetProfileHandler) + services.add_handler(ListCalendarEventsQuery, ListCalendarEventsHandler) + return services diff --git a/src/api/extensions/microsoft_extensions.py b/src/api/extensions/microsoft_extensions.py new file mode 100644 index 0000000..0f01191 --- /dev/null +++ b/src/api/extensions/microsoft_extensions.py @@ -0,0 +1,22 @@ +from src.api.di.service_collection import ServiceCollection +from src.api.token_rotation.context_token_rotation import ContextTokenRotation +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_graph_client_factory import IGraphClientFactory +from src.application.abstractions.i_microsoft_http_clients import IMicrosoftHttpClients +from src.application.abstractions.i_token_rotation import ITokenRotation +from src.application.options.microsoft_options import MicrosoftOptions +from src.infrastructure.microsoft.account_runner import AccountRunner +from src.infrastructure.microsoft.microsoft_http_clients import MicrosoftHttpClients +from src.infrastructure.microsoft.outnote_client_factory import OutNoteClientFactory + + +class MicrosoftExtensions: + @staticmethod + def add_microsoft_access(services: ServiceCollection) -> ServiceCollection: + """The shared HTTP clients and the per-token brokers are singletons: a broker per request would redeem the refresh token on every call.""" + services.add_instance(MicrosoftOptions, MicrosoftOptions.from_environment()) + services.add_singleton(IMicrosoftHttpClients, MicrosoftHttpClients) + services.add_singleton(IGraphClientFactory, OutNoteClientFactory) + services.add_singleton(ITokenRotation, ContextTokenRotation) + services.add_singleton(IAccountRunner, AccountRunner) + return services diff --git a/src/api/extensions/one_note_extensions.py b/src/api/extensions/one_note_extensions.py new file mode 100644 index 0000000..2c3baae --- /dev/null +++ b/src/api/extensions/one_note_extensions.py @@ -0,0 +1,36 @@ +from src.api.di.service_collection import ServiceCollection +from src.application.abstractions.i_one_note import IOneNote +from src.application.abstractions.i_one_note_runner import IOneNoteRunner +from src.application.commands.create_one_note_page_command import CreateOneNotePageCommand, CreateOneNotePageHandler +from src.application.commands.delete_one_note_page_command import DeleteOneNotePageCommand, DeleteOneNotePageHandler +from src.application.commands.update_one_note_page_command import UpdateOneNotePageCommand, UpdateOneNotePageHandler +from src.application.queries.get_one_note_page_query import GetOneNotePageQuery, GetOneNotePageHandler +from src.application.queries.get_one_note_resource_query import GetOneNoteResourceQuery, GetOneNoteResourceHandler +from src.application.queries.get_one_note_sections_query import GetOneNoteSectionsQuery, GetOneNoteSectionsHandler +from src.application.queries.list_notebooks_query import ListNotebooksQuery, ListNotebooksHandler +from src.application.queries.list_one_note_notebooks_query import ListOneNoteNotebooksQuery, ListOneNoteNotebooksHandler +from src.application.queries.list_one_note_pages_query import ListOneNotePagesQuery, ListOneNotePagesHandler +from src.application.queries.search_one_note_query import SearchOneNoteQuery, SearchOneNoteHandler +from src.application.services.onenote_html_sanitizer import OneNoteHtmlSanitizer +from src.infrastructure.graph.onenote_client import OneNoteClient +from src.infrastructure.graph.onenote_runner import OneNoteRunner + + +class OneNoteExtensions: + @staticmethod + def add_one_note(services: ServiceCollection) -> ServiceCollection: + services.add_singleton(OneNoteHtmlSanitizer) + services.add_singleton(IOneNote, OneNoteClient) + services.add_singleton(IOneNoteRunner, OneNoteRunner) + + services.add_handler(ListNotebooksQuery, ListNotebooksHandler) + services.add_handler(ListOneNoteNotebooksQuery, ListOneNoteNotebooksHandler) + services.add_handler(GetOneNoteSectionsQuery, GetOneNoteSectionsHandler) + services.add_handler(ListOneNotePagesQuery, ListOneNotePagesHandler) + services.add_handler(CreateOneNotePageCommand, CreateOneNotePageHandler) + services.add_handler(GetOneNotePageQuery, GetOneNotePageHandler) + services.add_handler(UpdateOneNotePageCommand, UpdateOneNotePageHandler) + services.add_handler(DeleteOneNotePageCommand, DeleteOneNotePageHandler) + services.add_handler(GetOneNoteResourceQuery, GetOneNoteResourceHandler) + services.add_handler(SearchOneNoteQuery, SearchOneNoteHandler) + return services diff --git a/src/api/extensions/open_api_extensions.py b/src/api/extensions/open_api_extensions.py new file mode 100644 index 0000000..09796a9 --- /dev/null +++ b/src/api/extensions/open_api_extensions.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI +from fastapi.routing import APIRoute +from fastapi.utils import generate_unique_id + + +class OpenApiExtensions: + @staticmethod + def use_plain_operation_names(app: FastAPI) -> None: + """The controller decorator prefixes route names with the class name; operation ids and summaries stay what callers already see.""" + for route in app.routes: + if isinstance(route, APIRoute): + route.name = route.name.rsplit(".", 1)[-1] + route.unique_id = route.operation_id or generate_unique_id(route) diff --git a/src/api/extensions/people_extensions.py b/src/api/extensions/people_extensions.py new file mode 100644 index 0000000..09cdb50 --- /dev/null +++ b/src/api/extensions/people_extensions.py @@ -0,0 +1,15 @@ +from src.api.di.service_collection import ServiceCollection +from src.application.abstractions.i_people_photos import IPeoplePhotos +from src.application.queries.get_person_photo_query import GetPersonPhotoQuery, GetPersonPhotoHandler +from src.application.queries.get_user_photo_query import GetUserPhotoQuery, GetUserPhotoHandler +from src.infrastructure.graph.people_photos import PeoplePhotos + + +class PeopleExtensions: + @staticmethod + def add_people(services: ServiceCollection) -> ServiceCollection: + services.add_singleton(IPeoplePhotos, PeoplePhotos) + + services.add_handler(GetPersonPhotoQuery, GetPersonPhotoHandler) + services.add_handler(GetUserPhotoQuery, GetUserPhotoHandler) + return services diff --git a/src/api/extensions/search_extensions.py b/src/api/extensions/search_extensions.py new file mode 100644 index 0000000..983ab41 --- /dev/null +++ b/src/api/extensions/search_extensions.py @@ -0,0 +1,13 @@ +from src.api.di.service_collection import ServiceCollection +from src.application.abstractions.i_graph_search import IGraphSearch +from src.application.queries.search_query import SearchQuery, SearchHandler +from src.infrastructure.graph.graph_search import GraphSearch + + +class SearchExtensions: + @staticmethod + def add_search(services: ServiceCollection) -> ServiceCollection: + services.add_singleton(IGraphSearch, GraphSearch) + + services.add_handler(SearchQuery, SearchHandler) + return services diff --git a/src/api/extensions/sign_in_extensions.py b/src/api/extensions/sign_in_extensions.py new file mode 100644 index 0000000..bd044a6 --- /dev/null +++ b/src/api/extensions/sign_in_extensions.py @@ -0,0 +1,44 @@ +from src.api.di.service_collection import ServiceCollection +from src.application.abstractions.i_browser_sign_in import IBrowserSignIn +from src.application.abstractions.i_device_login import IDeviceLogin +from src.application.abstractions.i_sign_in_store import ISignInStore +from src.application.commands.cancel_password_sign_in_command import CancelPasswordSignInCommand, CancelPasswordSignInHandler +from src.application.commands.choose_password_method_command import ChoosePasswordMethodCommand, ChoosePasswordMethodHandler +from src.application.commands.complete_browser_login_command import CompleteBrowserLoginCommand, CompleteBrowserLoginHandler +from src.application.commands.poll_device_login_command import PollDeviceLoginCommand, PollDeviceLoginHandler +from src.application.commands.start_browser_login_command import StartBrowserLoginCommand, StartBrowserLoginHandler +from src.application.commands.start_device_login_command import StartDeviceLoginCommand, StartDeviceLoginHandler +from src.application.commands.start_password_sign_in_command import StartPasswordSignInCommand, StartPasswordSignInHandler +from src.application.commands.submit_password_otp_command import SubmitPasswordOtpCommand, SubmitPasswordOtpHandler +from src.application.options.password_sign_in_options import PasswordSignInOptions +from src.application.options.service_auth_options import ServiceAuthOptions +from src.application.queries.get_password_sign_in_query import GetPasswordSignInQuery, GetPasswordSignInHandler +from src.application.services.linked_identity_reader import LinkedIdentityReader +from src.application.services.password_sign_in_responder import PasswordSignInResponder +from src.infrastructure.microsoft.browser_sign_in_client import BrowserSignInClient +from src.infrastructure.microsoft.device_login_client import DeviceLoginClient +from src.infrastructure.password_signin.job_store import JobStore + + +class SignInExtensions: + @staticmethod + def add_sign_in(services: ServiceCollection) -> ServiceCollection: + """Password sign-in jobs are in memory, so the store is one singleton for the process (asgi.py pins workers=1).""" + services.add_instance(PasswordSignInOptions, PasswordSignInOptions.from_environment()) + services.add_instance(ServiceAuthOptions, ServiceAuthOptions()) + services.add_singleton(IDeviceLogin, DeviceLoginClient) + services.add_singleton(IBrowserSignIn, BrowserSignInClient) + services.add_singleton(ISignInStore, JobStore) + services.add_singleton(LinkedIdentityReader) + services.add_singleton(PasswordSignInResponder) + + services.add_handler(StartDeviceLoginCommand, StartDeviceLoginHandler) + services.add_handler(PollDeviceLoginCommand, PollDeviceLoginHandler) + services.add_handler(StartBrowserLoginCommand, StartBrowserLoginHandler) + services.add_handler(CompleteBrowserLoginCommand, CompleteBrowserLoginHandler) + services.add_handler(StartPasswordSignInCommand, StartPasswordSignInHandler) + services.add_handler(ChoosePasswordMethodCommand, ChoosePasswordMethodHandler) + services.add_handler(SubmitPasswordOtpCommand, SubmitPasswordOtpHandler) + services.add_handler(GetPasswordSignInQuery, GetPasswordSignInHandler) + services.add_handler(CancelPasswordSignInCommand, CancelPasswordSignInHandler) + return services diff --git a/src/api/extensions/teams_extensions.py b/src/api/extensions/teams_extensions.py new file mode 100644 index 0000000..07d23dc --- /dev/null +++ b/src/api/extensions/teams_extensions.py @@ -0,0 +1,21 @@ +from src.api.di.service_collection import ServiceCollection +from src.application.abstractions.i_channel_messages import IChannelMessages +from src.application.queries.list_channel_message_replies_query import ListChannelMessageRepliesQuery, ListChannelMessageRepliesHandler +from src.application.queries.list_channel_messages_query import ListChannelMessagesQuery, ListChannelMessagesHandler +from src.application.queries.list_joined_teams_query import ListJoinedTeamsQuery, ListJoinedTeamsHandler +from src.application.queries.list_team_channels_query import ListTeamChannelsQuery, ListTeamChannelsHandler +from src.application.queries.list_team_files_query import ListTeamFilesQuery, ListTeamFilesHandler +from src.infrastructure.graph.channel_messages import ChannelMessages + + +class TeamsExtensions: + @staticmethod + def add_teams(services: ServiceCollection) -> ServiceCollection: + services.add_singleton(IChannelMessages, ChannelMessages) + + services.add_handler(ListJoinedTeamsQuery, ListJoinedTeamsHandler) + services.add_handler(ListTeamChannelsQuery, ListTeamChannelsHandler) + services.add_handler(ListTeamFilesQuery, ListTeamFilesHandler) + services.add_handler(ListChannelMessagesQuery, ListChannelMessagesHandler) + services.add_handler(ListChannelMessageRepliesQuery, ListChannelMessageRepliesHandler) + return services diff --git a/src/api/http_headers.py b/src/api/http_headers.py new file mode 100644 index 0000000..84a312f --- /dev/null +++ b/src/api/http_headers.py @@ -0,0 +1,6 @@ +class HttpHeaders: + """The headers this service reads and writes.""" + + REFRESH_TOKEN_HEADER = "X-MS-Refresh-Token" + SERVICE_KEY_HEADER = "X-Schuly-Service-Key" + USER_ID_HEADER = "X-Schuly-User-Id" diff --git a/src/api/middleware/token_rotation_middleware.py b/src/api/middleware/token_rotation_middleware.py new file mode 100644 index 0000000..7565cae --- /dev/null +++ b/src/api/middleware/token_rotation_middleware.py @@ -0,0 +1,26 @@ +from starlette.datastructures import MutableHeaders +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from src.api.http_headers import HttpHeaders +from src.api.token_rotation.context_token_rotation import ContextTokenRotation + + +class TokenRotationMiddleware: + """Puts the rotated refresh token recorded during a request on its response, whatever kind of response that turns out to be. Pure ASGI, so a stream is never buffered.""" + + def __init__(self, app: ASGIApp, rotation: ContextTokenRotation): + self._app = app + self._rotation = rotation + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self._app(scope, receive, send) + return + slot = self._rotation.open_slot() + + async def send_with_token(message: Message) -> None: + if message["type"] == "http.response.start" and slot.refresh_token: + MutableHeaders(scope=message)[HttpHeaders.REFRESH_TOKEN_HEADER] = slot.refresh_token + await send(message) + + await self._app(scope, receive, send_with_token) diff --git a/src/api/request_bodies/chat_message_request_body.py b/src/api/request_bodies/chat_message_request_body.py new file mode 100644 index 0000000..30e764f --- /dev/null +++ b/src/api/request_bodies/chat_message_request_body.py @@ -0,0 +1,70 @@ +import json + +from fastapi import Request +from starlette.datastructures import UploadFile + +from src.api.request_bodies.limited_request import LimitedRequest +from src.application.abstractions.i_chat_message_body import IChatMessageBody +from src.domain.chat.chat_send_limits import ChatSendLimits +from src.domain.chat.upload import Upload +from src.domain.errors.service_error import ServiceError + + +class ChatMessageRequestBody(IChatMessageBody): + """A chat send's JSON or multipart body.""" + + def __init__(self, request: Request): + self._request = request + self._form = None + + @property + def is_multipart(self) -> bool: + return self._request.headers.get("content-type", "").lower().startswith("multipart/form-data") + + @property + def declared_length(self) -> str | None: + return self._request.headers.get("content-length") + + async def read(self, cap: int) -> tuple[dict, list[Upload], list[Upload]]: + multipart = self.is_multipart + request = LimitedRequest.limit(self._request, cap) + if multipart: + # Starlette spools each file part to a temporary file past 1 MB; close() deletes them. + form = self._form = await request.form(max_files=ChatSendLimits.MAX_IMAGES + ChatSendLimits.MAX_FILES, max_fields=ChatSendLimits.MAX_FIELDS, max_part_size=ChatSendLimits.MAX_PART_SIZE) + fields = {k: form.get(k) for k in ("content", "text", "contentType", "importance") if isinstance(form.get(k), str)} + fields["mentions"] = self._json_field(form.get("mentions"), "mentions") + fields["gif"] = self._json_field(form.get("gif"), "gif") + images = [self._upload(f) for f in form.getlist("images") if isinstance(f, UploadFile)] + files = [self._upload(f) for f in form.getlist("files") if isinstance(f, UploadFile)] + return fields, images, files + try: + fields = await request.json() + except ValueError: + raise ServiceError(400, "The body must be JSON or multipart/form-data.") + if not isinstance(fields, dict): + raise ServiceError(400, "The body must be a JSON object.") + return fields, [], [] + + async def close(self) -> None: + if self._form is not None: + await self._form.close() + + @staticmethod + def _json_field(value, name: str): + if value is None or value == "": + return None + if not isinstance(value, str): + raise ServiceError(400, f"{name} must be a JSON string.") + try: + return json.loads(value) + except ValueError: + raise ServiceError(400, f"{name} is not valid JSON.") + + @staticmethod + def _upload(part: UploadFile) -> Upload: + size = part.size + if size is None: + part.file.seek(0, 2) + size = part.file.tell() + part.file.seek(0) + return Upload(name=part.filename or "file", file=part.file, size=size, content_type=part.content_type) diff --git a/src/api/request_bodies/limited_request.py b/src/api/request_bodies/limited_request.py new file mode 100644 index 0000000..5c1a9c8 --- /dev/null +++ b/src/api/request_bodies/limited_request.py @@ -0,0 +1,23 @@ +from fastapi import Request + +from src.domain.errors.service_error import ServiceError + + +class LimitedRequest: + """A request whose body is cut off past a cap, whatever Content-Length claims.""" + + @staticmethod + def limit(request: Request, cap: int) -> Request: + received = 0 + upstream = request.receive + + async def receive(): + nonlocal received + message = await upstream() + if message.get("type") == "http.request": + received += len(message.get("body", b"")) + if received > cap: + raise ServiceError(413, "The message is larger than the send limit.") + return message + + return Request(request.scope, receive) diff --git a/src/api/request_bodies/raw_request_body.py b/src/api/request_bodies/raw_request_body.py new file mode 100644 index 0000000..552d3c6 --- /dev/null +++ b/src/api/request_bodies/raw_request_body.py @@ -0,0 +1,23 @@ +from fastapi import Request + +from src.api.request_bodies.limited_request import LimitedRequest +from src.application.abstractions.i_request_body import IRequestBody + + +class RawRequestBody(IRequestBody): + """An upload's raw bytes.""" + + def __init__(self, request: Request): + self._request = request + + @property + def declared_length(self) -> str | None: + return self._request.headers.get("content-length") + + async def read(self, cap: int) -> bytes: + body = bytearray() + async for chunk in LimitedRequest.limit(self._request, cap).stream(): + body.extend(chunk) + data = bytes(body) + del body + return data diff --git a/src/api/responses/file_responses.py b/src/api/responses/file_responses.py new file mode 100644 index 0000000..3b2c4d0 --- /dev/null +++ b/src/api/responses/file_responses.py @@ -0,0 +1,55 @@ +import posixpath +from urllib.parse import quote + +from fastapi import Response +from fastapi.responses import StreamingResponse + +from src.application.services.content_names import ContentNames +from src.domain.files.binary_content import BinaryContent +from src.domain.files.file_stream import FileStream +from src.domain.files.spooled_pdf import SpooledPdf + + +class FileResponses: + """File answers. The rotated refresh token may ride on any of them (see the middleware), so nothing may store them; the plugin caches images itself.""" + + SANDBOX = "sandbox; default-src 'none'" + + @classmethod + def attachment(cls, content: BinaryContent) -> Response: + return Response(content=content.data, media_type=content.content_type, headers=cls._download_headers(content.file_name)) + + @classmethod + def stream(cls, content: FileStream) -> StreamingResponse: + return StreamingResponse(content.chunks, media_type=content.content_type, headers=cls._download_headers(content.file_name)) + + @classmethod + def image(cls, content: BinaryContent) -> Response: + return Response(content=content.data, media_type=content.content_type, headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"}) + + @staticmethod + def user_photo(content: BinaryContent) -> Response: + # The caller caches the photo itself. + return Response(content=content.data, media_type=content.content_type, headers={"Cache-Control": "no-store"}) + + @classmethod + def pdf(cls, pdf: SpooledPdf) -> StreamingResponse: + """Inline (it was checked to be a PDF), sandboxed, never stored.""" + pdf_name = posixpath.splitext(ContentNames.safe_name(pdf.source_name))[0] + ".pdf" + spooled = pdf.file + size = spooled.seek(0, 2) + spooled.seek(0) + headers = {"Content-Disposition": f"inline; filename*=UTF-8''{quote(pdf_name, safe='')}", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Content-Security-Policy": cls.SANDBOX, "Content-Length": str(size)} + + def body(): + try: + while chunk := spooled.read(64 * 1024): + yield chunk + finally: + spooled.close() + + return StreamingResponse(body(), media_type="application/pdf", headers=headers) + + @classmethod + def _download_headers(cls, name: str) -> dict[str, str]: + return {"Content-Disposition": f"attachment; filename*=UTF-8''{quote(name, safe='')}", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Content-Security-Policy": cls.SANDBOX} diff --git a/src/api/token_rotation/context_token_rotation.py b/src/api/token_rotation/context_token_rotation.py new file mode 100644 index 0000000..3b74bf0 --- /dev/null +++ b/src/api/token_rotation/context_token_rotation.py @@ -0,0 +1,27 @@ +from contextvars import ContextVar + +from src.application.abstractions.i_token_rotation import ITokenRotation + + +class RotationSlot: + """The rotated refresh token of one request, if any.""" + + def __init__(self): + self.refresh_token: str | None = None + + +class ContextTokenRotation(ITokenRotation): + """Per-request state, like HttpContext: the middleware opens a slot, the account runner fills it after reading the broker.""" + + def __init__(self): + self._slot: ContextVar[RotationSlot | None] = ContextVar("token_rotation_slot", default=None) + + def open_slot(self) -> RotationSlot: + slot = RotationSlot() + self._slot.set(slot) + return slot + + def record(self, refresh_token: str) -> None: + slot = self._slot.get() + if slot is not None: + slot.refresh_token = refresh_token diff --git a/src/app.py b/src/app.py deleted file mode 100644 index 56cc29c..0000000 --- a/src/app.py +++ /dev/null @@ -1,1532 +0,0 @@ -"""SchulyMicrosoft: stateless Microsoft 365 access for Schuly, over OutNote / Entrance. - -The user signs in once with the device-code or browser flow on Microsoft's real page (no password -reaches this service; the one exception is the web password sign-in route, which uses the password -once in memory and never stores or logs it); the caller stores the returned FOCI refresh token and sends it back -on each read. Reads return the rotated refresh token in the X-MS-Refresh-Token header. -""" - -import asyncio -import base64 -import binascii -import hmac -import os -import json -import logging -import math -import posixpath -import re -import time -from concurrent.futures import ThreadPoolExecutor -from contextlib import asynccontextmanager -from datetime import timezone -from urllib.parse import quote -from collections.abc import Callable - -from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response -from fastapi.exception_handlers import request_validation_exception_handler -from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse, StreamingResponse -from starlette.datastructures import UploadFile - -import entrance -from outnote import GraphError - -from src import password_signin, browser_auth, channel_messages, chat_html, chat_read, chat_send, config, emojis, file_pdf, file_text, file_thumbs, folder_roles, gifs, onenote, onenote_html, outnote_client, people_photos, profile, search, teams_chat, teams_files -from src.models import FilesSearchRequest, SearchRequest, PasswordMethodRequest, PasswordOtpRequest, PasswordSignInResponse, PasswordStartRequest, BrowserCompleteRequest, BrowserStartResponse, DevicePollRequest, DevicePollResponse, DeviceStartResponse, LinkedIdentity, MailFlagRequest, MailForwardRequest, MailMessageIn, MailMoveRequest, MailReadRequest, MailReplyRequest, MeetingRequest, OneNotePageCreate, OneNotePageUpdate - -@asynccontextmanager -async def lifespan(_: FastAPI): - # Pre-open the TLS connections to Microsoft in the background: the first call after a cold start otherwise pays DNS, TCP and TLS (and any dead-address timeout) itself. - _warm_state.update(started=time.monotonic(), done=False) - warm = asyncio.create_task(_warm_up()) - sweeper = asyncio.create_task(_sweep_sign_ins()) - yield - warm.cancel() - sweeper.cancel() - password_signin.store.clear() - await outnote_client.close_shared_client() - - -log = logging.getLogger("schuly.microsoft") - -READY_GRACE = 15.0 -_warm_state = {"started": time.monotonic(), "done": False} - - -async def _warm_up(): - started = time.monotonic() - try: - await outnote_client.warm_up() - except Exception as ex: - log.warning("Warm-up failed after %.0f ms: %s", (time.monotonic() - started) * 1000, ex) - else: - log.info("Warm-up finished in %.0f ms", (time.monotonic() - started) * 1000) - finally: - _warm_state["done"] = True - - -async def _sweep_sign_ins(): - """Drop expired password sign-in jobs (and stale rate-limit windows) every few seconds.""" - while True: - await asyncio.sleep(15) - password_signin.store.sweep() - - -app = FastAPI(title="SchulyMicrosoft", description="Microsoft 365 access for Schuly via OutNote.", lifespan=lifespan) - -@app.get("/health/live", include_in_schema=False) -async def health_live(): - return {"status": "ok"} - - -@app.get("/health/ready", include_in_schema=False) -async def health_ready(): - if _warm_state["done"] or time.monotonic() - _warm_state["started"] >= READY_GRACE: - return {"status": "ready"} - return JSONResponse({"status": "starting"}, status_code=503) - - -CHAT_ID_PATTERN = re.compile(r"[A-Za-z0-9:@._=-]{1,300}") -USER_ID_PATTERN = re.compile(r"[A-Za-z0-9@._-]{1,200}") -PHOTO_SIZES = (48, 64, 96, 120, 240) -GRAPH_ID_PATTERN = re.compile(r"[A-Za-z0-9=_\-+/.]{1,512}") -EMAIL_PATTERN = re.compile(r"[^@\s<>(),;:\\\"\[\]]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?\.[A-Za-z]{2,}") -CONTENT_TYPE_PATTERN = re.compile(r"[A-Za-z0-9][\w.+-]{0,126}/[A-Za-z0-9][\w.+-]{0,126}") -MESSAGE_ID_PATTERN = re.compile(r"[A-Za-z0-9_-]{1,64}") -ATTACHMENT_ID_PATTERN = re.compile(r"[A-Za-z0-9_-]{1,100}") -DOCUMENT_ID_PATTERN = re.compile(r"[A-Za-z0-9_-]{1,120}") -DRIVE_ITEM_PATTERN = re.compile(r"[A-Za-z0-9!=_\-+.]{1,512}") -TEAM_ID_PATTERN = re.compile(r"[0-9a-fA-F-]{36}") -CHANNEL_ID_PATTERN = re.compile(r"19:[A-Za-z0-9_-]{1,200}@thread\.(tacv2|skype)") -DRIVE_ID_PATTERN = re.compile(r"[A-Za-z0-9!_-]{1,200}") -CHANNEL_MESSAGE_ID_PATTERN = re.compile(r"[0-9]{1,20}") -MAX_TEXT_SOURCE_BYTES = 25 * 1024 * 1024 -MAX_CHAT_FILE_BYTES = 100 * 1024 * 1024 -MAX_SEND_BYTES = 105 * 1024 * 1024 # a whole chat send: one 100 MB file plus the message and inline images -MAX_JSON_SEND_BYTES = 256 * 1024 -TOKEN_PATTERN = re.compile(r"[\x21-\x7e]{4,16384}") # printable ASCII, no whitespace -ACTIVE_TYPE_PARTS = ("html", "svg", "xml", "javascript", "ecmascript", "multipart") -MAX_RECIPIENTS = 100 -MAX_SUBJECT = 255 -MAX_BODY_BYTES = 1024 * 1024 -MAX_SEND_ATTACHMENT_BYTES = 3 * 1024 * 1024 -MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024 -FOLDER_DETAIL = "That item is a folder, which has no content to download." -DEFAULT_RETRY_AFTER = 30 # seconds, for a throttled call that named no wait -# Field lists keep list responses small: no mail body in lists (the single-message route returns it), and no unused chat metadata. -# Explicit, not OutNote's default list fields: the web's mail list shows `toRecipients`, which that default leaves out. -MAIL_LIST_FIELDS = "id,subject,from,toRecipients,receivedDateTime,bodyPreview,isRead,flag,hasAttachments,importance,conversationId" -CHAT_FIELDS = "id,topic,chatType,createdDateTime,lastUpdatedDateTime,webUrl,viewpoint" -# Graph answers 500 when $select is set on chat messages, so they are fetched whole. - - -@app.exception_handler(RequestValidationError) -async def _validation_error(request: Request, exc: RequestValidationError): - # FastAPI's default 422 echoes the rejected input; for the password routes that would be the password. - if request.url.path.startswith("/api/auth/password"): - details = [{"loc": list(e.get("loc", ())), "msg": e.get("msg"), "type": e.get("type")} for e in exc.errors()] - return JSONResponse(status_code=422, content={"status": "error", "code": "invalid_request", "message": "The request is invalid.", "detail": details}) - if request.url.path in ("/api/search", "/api/files/search"): - # The rejected input would be the search query. - return JSONResponse(status_code=422, content={"detail": [{"loc": list(e.get("loc", ())), "msg": e.get("msg"), "type": e.get("type")} for e in exc.errors()]}) - return await request_validation_exception_handler(request, exc) - - -@app.get("/", include_in_schema=False) -async def root(): - return {"service": "SchulyMicrosoft", "status": "ok"} - - -@app.post("/api/auth/device/start", response_model=DeviceStartResponse, tags=["Auth"]) -async def device_start(): - try: - data = await outnote_client.start_device_login() - except outnote_client.MicrosoftError as ex: - raise HTTPException(status_code=502, detail=str(ex)) - return DeviceStartResponse( - user_code=data["user_code"], - verification_uri=data.get("verification_uri") or data.get("verification_url"), - device_code=data["device_code"], - interval=data.get("interval", 5), - expires_in=data.get("expires_in", 900), - message=data.get("message"), - ) - - -@app.post("/api/auth/device/poll", response_model=DevicePollResponse, tags=["Auth"]) -async def device_poll(body: DevicePollRequest): - try: - result = await outnote_client.poll_device_login(body.device_code) - except outnote_client.MicrosoftError as ex: - raise HTTPException(status_code=502, detail=str(ex)) - - if result.get("access_token"): - refresh_token = result.get("refresh_token") - if not refresh_token: - return DevicePollResponse(status="error", message="No refresh token in the response.") - account = await _identity(refresh_token) - return DevicePollResponse(status="complete", refresh_token=refresh_token, account=account) - - error = result.get("error") - if error in ("authorization_pending", "slow_down"): - return DevicePollResponse(status="pending", message=error) - return DevicePollResponse(status="error", message=result.get("error_description") or error or "sign-in failed") - - -@app.post("/api/auth/browser/start", response_model=BrowserStartResponse, tags=["Auth"]) -async def browser_start(): - url, verifier = browser_auth.build_authorize_url() - return BrowserStartResponse(authorize_url=url, redirect_uri=browser_auth.NATIVE_REDIRECT, code_verifier=verifier) - - -@app.post("/api/auth/browser/complete", response_model=DevicePollResponse, tags=["Auth"]) -async def browser_complete(body: BrowserCompleteRequest): - try: - result = await browser_auth.exchange_code(body.code, body.code_verifier) - except outnote_client.MicrosoftError as ex: - raise HTTPException(status_code=502, detail=str(ex)) - - if not result.get("access_token") or not result.get("refresh_token"): - return DevicePollResponse(status="error", message=result.get("error_description") or result.get("error") or "sign-in failed") - refresh_token = result["refresh_token"] - account = await _identity(refresh_token) - return DevicePollResponse(status="complete", refresh_token=refresh_token, account=account) - - -USER_ID_PATTERN_SCHULY = re.compile(r"[A-Za-z0-9._:@-]{1,128}") - - -def _password_caller(request: Request) -> str: - """Gate for the password routes: the shared service key plus the Schuly user id. Returns the user id.""" - expected = os.getenv(config.SERVICE_KEY_ENV, "") - if not expected: - raise HTTPException(status_code=503, detail="Password sign-in is disabled: no service key is configured.") - given = request.headers.get(config.SERVICE_KEY_HEADER, "") - if not hmac.compare_digest(given.encode(), expected.encode()): - raise HTTPException(status_code=401, detail="Missing or invalid service key.") - user_id = request.headers.get(config.USER_ID_HEADER, "") - if not USER_ID_PATTERN_SCHULY.fullmatch(user_id): - raise HTTPException(status_code=401, detail="Missing or invalid user id.") - return user_id - - -SIGN_IN_WAIT = 60 # seconds /start waits for the sign-in to finish or reach the method choice -METHOD_WAIT = 30 # seconds /method waits for the push number / the code prompt -OTP_WAIT = 45 # seconds /otp waits for Microsoft's verdict -_sign_in_waiters = ThreadPoolExecutor(max_workers=password_signin.MAX_JOBS + 20, thread_name_prefix="pwd-wait") - - -def _sign_in_error(status_code: int, code: str, message: str) -> JSONResponse: - return JSONResponse(status_code=status_code, content={"status": "error", "code": code, "message": message}) - - -async def _wait_settled(job: password_signin.SignInJob, after_version: int, timeout: float) -> None: - await asyncio.get_running_loop().run_in_executor(_sign_in_waiters, job.wait_settled, after_version, timeout) - - -async def _sign_in_response(job: password_signin.SignInJob) -> PasswordSignInResponse: - """The job's current place as a response. Reads progress only; the password is not on the job.""" - snap = job.snapshot() - state = snap["state"] - base = {"sign_in_id": job.id} - if state == password_signin.COMPLETE: - if job.account is None: - job.account = await _identity(snap["refresh_token"]) - return PasswordSignInResponse(**base, status="complete", refresh_token=snap["refresh_token"], account=job.account) - if state == password_signin.ERROR: - return PasswordSignInResponse(**base, status="error", code=snap["code"], message=snap["message"]) - if state == password_signin.PUSH_PENDING: - return PasswordSignInResponse(**base, status="push_pending", number=snap["number"], expires_in=snap["expires_in"], methods=snap["methods"]) - if state == password_signin.OTP_REQUIRED: - status = "otp_invalid" if snap.get("otp_invalid") else "otp_required" - return PasswordSignInResponse(**base, status=status, attempts_left=snap["attempts_left"], methods=snap["methods"]) - if state in (password_signin.MFA_REQUIRED, password_signin.DENIED, password_signin.TIMED_OUT): - if snap["notice"] == "otp_exhausted": - return PasswordSignInResponse(**base, status="otp_invalid", attempts_left=0, methods=snap["methods"], message="Too many wrong codes; choose a method again.") - return PasswordSignInResponse(**base, status=state, methods=snap["methods"]) - return PasswordSignInResponse(**base, status="working") - - -@app.post("/api/auth/password/start", response_model=PasswordSignInResponse, response_model_exclude_none=True, tags=["Auth"]) -async def password_start(body: PasswordStartRequest, user_id: str = Depends(_password_caller)): - """Sign in with the Microsoft password. The password is used once, held only in memory for this - sign-in and never stored, cached, logged or returned. Completes straight away when Microsoft asks - for no MFA; otherwise returns mfa_required with the methods Microsoft offers.""" - secret = entrance.Secret(body.password.get_secret_value()) - username = body.username - body.password = None # the request model no longer references the password - try: - job = password_signin.store.start(user_id, username, secret) - except password_signin.RateLimited as ex: - return _sign_in_error(429, "rate_limited", str(ex)) - finally: - del secret - await _wait_settled(job, -1, SIGN_IN_WAIT) - snap = job.snapshot() - if snap["state"] != password_signin.WORKING and snap.get("code") != "invalid_credentials": - password_signin.store.promote(job) # the password passed: this replaces the user's earlier attempt - return await _sign_in_response(job) - - -@app.post("/api/auth/password/{sign_in_id}/method", response_model=PasswordSignInResponse, response_model_exclude_none=True, tags=["Auth"]) -async def password_method(sign_in_id: str, body: PasswordMethodRequest, user_id: str = Depends(_password_caller)): - """Choose (or switch to) push or otp. push -> push_pending with the number to tap; otp -> otp_required.""" - job = password_signin.store.get(sign_in_id, user_id) - if job is None: - return _sign_in_error(404, "not_found", "Unknown or expired sign-in.") - version = job.snapshot()["version"] - outcome = job.request_method(body.method) - if outcome == "unavailable": - return _sign_in_error(409, "method_unavailable", "Microsoft doesn't offer that method for this account.") - if outcome == "invalid_state": - return _sign_in_error(409, "invalid_state", "The sign-in isn't waiting for a method.") - if outcome == "ok": - await _wait_settled(job, version, METHOD_WAIT) - return await _sign_in_response(job) - - -@app.post("/api/auth/password/{sign_in_id}/otp", response_model=PasswordSignInResponse, response_model_exclude_none=True, tags=["Auth"]) -async def password_otp(sign_in_id: str, body: PasswordOtpRequest, user_id: str = Depends(_password_caller)): - """Submit the authenticator code. complete, or otp_invalid with attempts_left (3 tries per method choice).""" - job = password_signin.store.get(sign_in_id, user_id) - if job is None: - return _sign_in_error(404, "not_found", "Unknown or expired sign-in.") - code = re.sub(r"[\s-]", "", body.code) - if not code.isdigit() or not 6 <= len(code) <= 8: - return _sign_in_error(422, "invalid_request", "The code must be 6 digits.") - version = job.snapshot()["version"] - if not job.submit_code(code): - return _sign_in_error(409, "invalid_state", "The sign-in isn't waiting for a code.") - await _wait_settled(job, version, OTP_WAIT) - return await _sign_in_response(job) - - -@app.get("/api/auth/password/{sign_in_id}", response_model=PasswordSignInResponse, response_model_exclude_none=True, tags=["Auth"]) -async def password_status(sign_in_id: str, user_id: str = Depends(_password_caller)): - job = password_signin.store.get(sign_in_id, user_id) - if job is None: - return _sign_in_error(404, "not_found", "Unknown or expired sign-in.") - return await _sign_in_response(job) - - -@app.delete("/api/auth/password/{sign_in_id}", status_code=204, tags=["Auth"]) -async def password_cancel(sign_in_id: str, user_id: str = Depends(_password_caller)): - password_signin.store.remove(sign_in_id, user_id) - return Response(status_code=204) - - -@app.get("/api/me", tags=["Microsoft 365"]) -async def me(response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - return await _read(response, token, profile.get, with_broker=True) - - -@app.get("/api/mail", tags=["Microsoft 365"]) -async def mail(response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), folder: str = Query("inbox"), top: int = Query(25, ge=1, le=500), skip: int = Query(0, ge=0, le=10000), unread: bool = Query(False), search: str | None = Query(None)): - _check_id(folder, GRAPH_ID_PATTERN, "folder") - if skip and search: - raise HTTPException(status_code=400, detail="skip cannot be combined with search.") - if not skip: - return await _read(response, token, lambda note: note.emails(folder=_seg(folder), top=top, unread=unread, search=search, select=MAIL_LIST_FIELDS)) - # Paging: newest first, `skip` messages in. Graph does not allow $skip together with $search. - params = {"$top": min(top, 100), "$skip": skip, "$select": MAIL_LIST_FIELDS, "$orderby": "receivedDateTime desc"} - if unread: - params["$filter"] = "isRead eq false" - return await _read(response, token, lambda note: note.list(f"me/mailFolders/{_seg(folder)}/messages", params, limit=top)) - - -# Fixed paths (folders/send/drafts) are declared before the {message_id} routes so they can never be read as an id. -@app.get("/api/mail/folders", tags=["Microsoft 365"]) -async def mail_folders(response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - return await _read(response, token, _folders, with_broker=True) - - -@app.post("/api/mail/send", status_code=204, tags=["Microsoft 365"]) -async def mail_send(body: MailMessageIn, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - args = _message_args(body) - return await _no_content(token, lambda note: note.send_mail(**args)) - - -@app.post("/api/mail/drafts", status_code=201, tags=["Microsoft 365"]) -async def mail_create_draft(body: MailMessageIn, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - args = _message_args(body) - return await _read(response, token, lambda note: note.create_draft(**args)) - - -@app.post("/api/mail/drafts/{message_id}/send", status_code=204, tags=["Microsoft 365"]) -async def mail_send_draft(message_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_id(message_id, GRAPH_ID_PATTERN, "message_id") - return await _no_content(token, lambda note: note.send_draft(_seg(message_id))) - - -@app.get("/api/mail/{message_id}", tags=["Microsoft 365"]) -async def mail_message(message_id: str, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_id(message_id, GRAPH_ID_PATTERN, "message_id") - return await _read(response, token, lambda note: note.email(_seg(message_id))) - - -@app.get("/api/mail/{message_id}/attachments", tags=["Microsoft 365"]) -async def mail_attachments(message_id: str, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_id(message_id, GRAPH_ID_PATTERN, "message_id") - return await _read(response, token, lambda note: [_attachment_meta(a) for a in note.list(f"me/messages/{_seg(message_id)}/attachments")]) - - -@app.get("/api/mail/{message_id}/attachments/{attachment_id}", tags=["Microsoft 365"], response_class=Response, responses={200: {"content": {"application/octet-stream": {}}}}) -async def mail_attachment(message_id: str, attachment_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_id(message_id, GRAPH_ID_PATTERN, "message_id") - _check_id(attachment_id, GRAPH_ID_PATTERN, "attachment_id") - - def fetch(note): - # Look the attachment up in the metadata list first: it carries the type and size, so an oversized one is refused before downloading it. - meta = next((a for a in note.list(f"me/messages/{_seg(message_id)}/attachments") if a.get("id") == attachment_id), None) - if meta is None: - raise HTTPException(status_code=404, detail="Attachment not found.") - if (meta.get("size") or 0) > MAX_DOWNLOAD_BYTES: - raise HTTPException(status_code=413, detail="The attachment is larger than 25 MB.") - try: - data = note.download_attachment(_seg(message_id), _seg(attachment_id)) - except ValueError as ex: - raise HTTPException(status_code=422, detail=str(ex)) - if len(data) > MAX_DOWNLOAD_BYTES: - raise HTTPException(status_code=413, detail="The attachment is larger than 25 MB.") - return meta, data - - carrier = Response() - meta, data = await _read(carrier, token, fetch) - name = _safe_name(meta.get("name")) - is_item = str(meta.get("@odata.type", "")).endswith("itemAttachment") - content_type = _safe_content_type("message/rfc822" if is_item else meta.get("contentType")) - if is_item and "." not in name: - name += ".eml" - headers = _token_headers(carrier) - headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{quote(name, safe='')}" - headers["Cache-Control"] = "no-store" - headers["X-Content-Type-Options"] = "nosniff" - headers["Content-Security-Policy"] = "sandbox; default-src 'none'" - return Response(content=data, media_type=content_type, headers=headers) - - -@app.get("/api/mail/{message_id}/attachments/{attachment_id}/pdf", tags=["Files as PDF"], response_class=StreamingResponse, responses={200: {"content": {"application/pdf": {}}}}) -async def mail_attachment_pdf(message_id: str, attachment_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - """A mail attachment (Word, Excel, PowerPoint, OpenDocument, RTF, CSV) converted to PDF through a temporary file in the app folder. 415 when the type can't be converted.""" - _check_id(message_id, GRAPH_ID_PATTERN, "message_id") - _check_id(attachment_id, GRAPH_ID_PATTERN, "attachment_id") - - def convert(note, broker): - meta = next((a for a in note.list(f"me/messages/{_seg(message_id)}/attachments") if a.get("id") == attachment_id), None) - if meta is None: - raise HTTPException(status_code=404, detail="Attachment not found.") - if str(meta.get("@odata.type", "")).endswith("itemAttachment") or not file_pdf.is_convertible(meta.get("name")): - raise HTTPException(status_code=415, detail="This file type cannot be converted to PDF.") - if (meta.get("size") or 0) > MAX_DOWNLOAD_BYTES: - raise HTTPException(status_code=413, detail="The attachment is larger than 25 MB.") - try: - data = note.download_attachment(_seg(message_id), _seg(attachment_id)) - except ValueError as ex: - raise HTTPException(status_code=422, detail=str(ex)) - if len(data) > MAX_DOWNLOAD_BYTES: - raise HTTPException(status_code=413, detail="The attachment is larger than 25 MB.") - return meta.get("name"), _converted(lambda: file_pdf.bytes_pdf(note, broker, data, meta.get("name"))) - - return await _pdf(token, convert) - - -@app.post("/api/mail/{message_id}/reply", status_code=204, tags=["Microsoft 365"]) -async def mail_reply(message_id: str, body: MailReplyRequest, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_id(message_id, GRAPH_ID_PATTERN, "message_id") - _check_text(body.comment, "comment") - return await _no_content(token, lambda note: note.reply(_seg(message_id), body.comment, reply_all=body.replyAll)) - - -@app.post("/api/mail/{message_id}/forward", status_code=204, tags=["Microsoft 365"]) -async def mail_forward(message_id: str, body: MailForwardRequest, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_id(message_id, GRAPH_ID_PATTERN, "message_id") - _check_text(body.comment, "comment") - to = _addresses(body.to, "to") - if not to: - raise HTTPException(status_code=400, detail="At least one recipient is required.") - if len(to) > MAX_RECIPIENTS: - raise HTTPException(status_code=400, detail=f"At most {MAX_RECIPIENTS} recipients are allowed.") - return await _no_content(token, lambda note: note.forward(_seg(message_id), to, body.comment)) - - -@app.post("/api/mail/{message_id}/read", tags=["Microsoft 365"]) -async def mail_mark_read(message_id: str, body: MailReadRequest, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_id(message_id, GRAPH_ID_PATTERN, "message_id") - return await _read(response, token, lambda note: note.mark_read(_seg(message_id), body.read) or {"isRead": body.read}) - - -@app.post("/api/mail/{message_id}/flag", tags=["Microsoft 365"]) -async def mail_flag(message_id: str, body: MailFlagRequest, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_id(message_id, GRAPH_ID_PATTERN, "message_id") - return await _read(response, token, lambda note: note.flag(_seg(message_id), body.status) or {"flag": {"flagStatus": body.status}}) - - -@app.post("/api/mail/{message_id}/move", tags=["Microsoft 365"]) -async def mail_move(message_id: str, body: MailMoveRequest, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_id(message_id, GRAPH_ID_PATTERN, "message_id") - _check_id(body.folder, GRAPH_ID_PATTERN, "folder") - return await _read(response, token, lambda note: note.move_email(_seg(message_id), _seg(body.folder))) - - -@app.delete("/api/mail/{message_id}", status_code=204, tags=["Microsoft 365"]) -async def mail_delete(message_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_id(message_id, GRAPH_ID_PATTERN, "message_id") - return await _no_content(token, lambda note: note.delete_email(_seg(message_id))) - - -@app.get("/api/calendar", tags=["Microsoft 365"]) -async def calendar(response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), top: int = Query(25, le=500), start: str | None = Query(None), end: str | None = Query(None)): - return await _read(response, token, lambda note: note.events(start=start, end=end, top=top)) - - -@app.get("/api/files", tags=["Microsoft 365"]) -async def files(response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), path: str = Query("root"), top: int = Query(100, le=1000)): - if _has_dot_segment(path) or "%" in path: - raise HTTPException(status_code=400, detail="Invalid path.") - return await _read(response, token, lambda note: note.files(path=path, top=top)) - - -@app.get("/api/chats", tags=["Microsoft 365"]) -async def chats(response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), top: int = Query(25, le=200)): - def fetch(note, broker): - items = note.list("me/chats", {"$top": min(top, 50), "$select": CHAT_FIELDS, "$expand": "members,lastMessagePreview", "$orderby": "lastMessagePreview/createdDateTime desc"}, limit=top) - try: - return teams_chat.mark_images(broker, items) - except Exception as ex: - # The picture flag is a nicety; the chat list never fails over it. - log.info("chat picture lookup failed: %s", type(ex).__name__) - for item in items: - item["hasImage"] = False - return items - - return await _read(response, token, fetch, with_broker=True) - - -@app.get("/api/notebooks", tags=["Microsoft 365"]) -async def notebooks(response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - return await _read(response, token, lambda note: note.notebooks()) - - -@app.get("/api/onenote/notebooks", tags=["OneNote"]) -async def onenote_notebooks(response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), teams: bool = Query(True)): - """The person's own notebooks, then each joined team's (class notebooks), with where they come from and whether they may be changed.""" - return await _onenote(response, token, lambda note, broker: onenote.notebooks(note, broker, teams)) - - -@app.get("/api/onenote/notebooks/{notebook_id}/sections", tags=["OneNote"]) -async def onenote_sections(notebook_id: str, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - """A notebook's sections and section groups as a tree.""" - _check_onenote_id(notebook_id, "notebook_id") - return await _onenote(response, token, lambda note, broker: onenote.tree(note, broker, notebook_id)) - - -@app.get("/api/onenote/sections/{section_id}/pages", tags=["OneNote"]) -async def onenote_pages(section_id: str, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), top: int = Query(50, ge=1, le=100), skip: int = Query(0, ge=0, le=5000)): - """One section's pages in OneNote's order, subpages with their level; `nextSkip` pages on.""" - _check_onenote_id(section_id, "section_id") - return await _onenote(response, token, lambda note, broker: onenote.pages(note, section_id, top, skip)) - - -@app.post("/api/onenote/sections/{section_id}/pages", status_code=201, tags=["OneNote"]) -async def onenote_create_page(section_id: str, body: OneNotePageCreate, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_onenote_id(section_id, "section_id") - _check_text(body.html or "", "html") - return await _onenote(response, token, lambda note, broker: onenote.create_page(note, broker, section_id, body.title, body.html)) - - -@app.get("/api/onenote/pages/{page_id}", tags=["OneNote"]) -async def onenote_page(page_id: str, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - """A page as sanitized HTML (pictures by resource id, ink as SVG), its resources and its text boxes.""" - _check_onenote_id(page_id, "page_id") - return await _onenote(response, token, lambda note, broker: onenote.page(note, page_id)) - - -@app.patch("/api/onenote/pages/{page_id}", tags=["OneNote"]) -async def onenote_update_page(page_id: str, body: OneNotePageUpdate, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - """Replaces the title and the text of editable text boxes, and appends text, as Graph PATCH commands. Answers the page as it is after the save.""" - _check_onenote_id(page_id, "page_id") - outlines = [{"id": o.id, "html": o.html} for o in body.outlines or []] - for item in outlines: - _check_id(item["id"], onenote_html.SAFE_ID, "outline id") - _check_text((body.append or "") + "".join(o["html"] for o in outlines), "html") - return await _onenote(response, token, lambda note, broker: onenote.update_page(note, broker, page_id, body.title, outlines, body.append)) - - -@app.delete("/api/onenote/pages/{page_id}", status_code=204, tags=["OneNote"]) -async def onenote_delete_page(page_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_onenote_id(page_id, "page_id") - carrier = Response() - await _onenote(carrier, token, lambda note, broker: onenote.delete_page(note, broker, page_id)) - return Response(status_code=204, headers=_token_headers(carrier)) - - -@app.get("/api/onenote/resources/{resource_id}", tags=["OneNote"], response_class=Response, responses={200: {"content": {"image/png": {}, "application/octet-stream": {}}}}) -async def onenote_resource(resource_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), kind: str = Query("image"), name: str | None = Query(None, max_length=255)): - """A picture (kind=image, served inline only when it is a PNG, JPEG, GIF or WEBP) or an attached file (kind=file, always a download) of a page.""" - _check_onenote_id(resource_id, "resource_id") - if kind not in ("image", "file"): - raise HTTPException(status_code=400, detail="kind must be image or file") - - def fetch(note, broker): - try: - return onenote.resource(broker, resource_id, MAX_DOWNLOAD_BYTES) - except onenote.ResourceTooLarge: - raise HTTPException(status_code=413, detail="The resource is larger than 25 MB.") - - carrier = Response() - data = await _onenote(carrier, token, fetch) - headers = _token_headers(carrier) - # The rotated refresh token may ride on this response, so nothing may store it; the plugin caches images itself. - headers["Cache-Control"] = "no-store" - headers["X-Content-Type-Options"] = "nosniff" - if kind == "image": - mime = chat_send.image_type((data or b"")[:12]) - if mime is None: - raise HTTPException(status_code=415, detail="The resource is not a PNG, JPEG, GIF or WEBP image.", headers=headers) - return Response(content=data, media_type=mime, headers=headers) - headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{quote(_safe_name(name), safe='')}" - headers["Content-Security-Policy"] = "sandbox; default-src 'none'" - return Response(content=data, media_type="application/octet-stream", headers=headers) - - -@app.get("/api/onenote/search", tags=["OneNote"]) -async def onenote_search(response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), q: str = Query(..., min_length=1, max_length=100), notebook: str | None = Query(None), top: int = Query(25, ge=1, le=50)): - """Pages whose title holds `q` (Graph has no OneNote content search), in one notebook or everywhere. `partial` is true when a big class notebook was only searched in part.""" - if not q.strip(): - raise HTTPException(status_code=400, detail="q is empty.") - if notebook: - _check_onenote_id(notebook, "notebook") - return await _onenote(response, token, lambda note, broker: onenote.search(note, broker, q, notebook, top)) - - -@app.get("/api/chats/{chat_id}/messages", tags=["Microsoft 365"]) -async def chat_messages(chat_id: str, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), top: int = Query(30, ge=1, le=300)): - _check_id(chat_id, CHAT_ID_PATTERN, "chat_id") - # No $select: Graph answers 500 for chat messages when one is set. - return await _read(response, token, lambda note, broker: chat_read.enrich(note, broker, note.list(f"me/chats/{chat_id}/messages", {"$top": min(top, 50)}, limit=top)), with_broker=True) - - -@app.get("/api/chats/{chat_id}/messages/{message_id}/hostedContents/{hosted_id:path}", tags=["Microsoft 365"], response_class=Response, responses={200: {"content": {"image/png": {}}}}) -async def chat_hosted_content(chat_id: str, message_id: str, hosted_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_id(chat_id, CHAT_ID_PATTERN, "chat_id") - _check_id(message_id, MESSAGE_ID_PATTERN, "message_id") - _check_id(hosted_id, GRAPH_ID_PATTERN, "hosted_id") - carrier = Response() - data = await _read(carrier, token, lambda note: note.get(f"{chat_read.message_path(chat_id, message_id)}/hostedContents/{_seg(hosted_id)}/$value", raw="bytes")) - headers = _token_headers(carrier) - mime = chat_send.image_type((data or b"")[:12]) - if mime is None: - # Only images are proxied; anything else a hosted content could hold is refused. - raise HTTPException(status_code=415, detail="The hosted content is not a PNG, JPEG, GIF or WEBP image.", headers=headers) - if len(data) > MAX_DOWNLOAD_BYTES: - raise HTTPException(status_code=413, detail="The image is larger than 25 MB.", headers=headers) - # The rotated refresh token may ride on this response, so nothing may store it; the plugin caches the image itself. - headers["Cache-Control"] = "no-store" - headers["X-Content-Type-Options"] = "nosniff" - return Response(content=data, media_type=mime, headers=headers) - - -@app.get("/api/chats/{chat_id}/messages/{message_id}/files/{attachment_id}", tags=["Microsoft 365"], response_class=StreamingResponse, responses={200: {"content": {"application/octet-stream": {}}}}) -async def chat_file(chat_id: str, message_id: str, attachment_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_id(chat_id, CHAT_ID_PATTERN, "chat_id") - _check_id(message_id, MESSAGE_ID_PATTERN, "message_id") - _check_id(attachment_id, ATTACHMENT_ID_PATTERN, "attachment_id") - - def resolve(note): - message = note.get(chat_read.message_path(chat_id, message_id)) - ref = next((f for f in chat_html.reference_files(message) if f.get("id") == attachment_id), None) - if ref is None: - raise HTTPException(status_code=404, detail="File not found in the message.") - item = note.get(chat_read.drive_item_path(ref["contentUrl"], "name,size,file,folder,@microsoft.graph.downloadUrl")) - if item.get("folder") is not None: - raise HTTPException(status_code=404, detail=FOLDER_DETAIL) - if (item.get("size") or 0) > MAX_CHAT_FILE_BYTES: - raise HTTPException(status_code=413, detail="The file is larger than 100 MB.") - url = _download_link(note, f"shares/{chat_read.share_id(ref['contentUrl'])}/driveItem", item) - if not teams_files.is_download_url(url): - raise HTTPException(status_code=502, detail="The file has an unexpected download location.") - return item, url - - carrier = Response() - item, url = await _read(carrier, token, resolve) - headers = _token_headers(carrier) - name = _safe_name(item.get("name")) - headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{quote(name, safe='')}" - headers["Cache-Control"] = "no-store" - headers["X-Content-Type-Options"] = "nosniff" - headers["Content-Security-Policy"] = "sandbox; default-src 'none'" - # The download URL is pre-authorized and short-lived; the bytes stream through without being held. - return StreamingResponse(outnote_client.stream_download(url, MAX_CHAT_FILE_BYTES, follow_redirects=False), media_type=_safe_content_type((item.get("file") or {}).get("mimeType")), headers=headers) - - -@app.get("/api/chats/{chat_id}/messages/{message_id}/files/{attachment_id}/thumbnail", tags=["Microsoft 365"], response_class=Response, responses={200: {"content": {"image/jpeg": {}}}}) -async def chat_file_thumbnail(chat_id: str, message_id: str, attachment_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), size: str = Query("medium")): - """The preview image of a file shared in a message (`files[].hasThumbnail`): JPEG, PNG or WEBP; 404 when the file has none. size is small, medium or large.""" - _check_id(chat_id, CHAT_ID_PATTERN, "chat_id") - _check_id(message_id, MESSAGE_ID_PATTERN, "message_id") - _check_id(attachment_id, ATTACHMENT_ID_PATTERN, "attachment_id") - _check_thumbnail_size(size) - - def fetch(note, broker): - message = note.get(chat_read.message_path(chat_id, message_id)) - ref = next((f for f in chat_html.reference_files(message) if f.get("id") == attachment_id), None) - if ref is None or not file_thumbs.is_share_url(ref["contentUrl"]): - return None - return file_thumbs.chat_file_thumbnail(note, broker, ref["contentUrl"], size) - - return await _thumbnail(token, fetch) - - -@app.get("/api/files/{item_id}/thumbnail", tags=["Microsoft 365"], response_class=Response, responses={200: {"content": {"image/jpeg": {}}}}) -async def drive_thumbnail(item_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), size: str = Query("medium")): - """The preview image of a OneDrive item (an `id` from /api/files); 404 when it has none.""" - _check_id(item_id, DRIVE_ITEM_PATTERN, "item_id") - _check_thumbnail_size(size) - return await _thumbnail(token, lambda note, broker: file_thumbs.drive_item_thumbnail(note, item_id, size)) - - -@app.get("/api/drives/{drive_id}/items/{item_id}/thumbnail", tags=["Teams files"], response_class=Response, responses={200: {"content": {"image/jpeg": {}}}}) -async def shared_drive_thumbnail(drive_id: str, item_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), size: str = Query("medium")): - """The preview image of an item in any drive the user can read (a team library, a shared drive); 404 when it has none.""" - _check_id(drive_id, DRIVE_ID_PATTERN, "drive_id") - _check_id(item_id, DRIVE_ITEM_PATTERN, "item_id") - _check_thumbnail_size(size) - return await _thumbnail(token, lambda note, broker: file_thumbs.drive_item_thumbnail(note, item_id, size, drive_id)) - - -def _check_thumbnail_size(size: str): - if size not in file_thumbs.SIZES: - raise HTTPException(status_code=400, detail=f"size must be one of {', '.join(file_thumbs.SIZES)}") - - -async def _thumbnail(token: str, fetch): - carrier = Response() - data = await _read(carrier, token, fetch, with_broker=True) - headers = _token_headers(carrier) - mime = file_thumbs.image_type((data or b"")[:12]) - if mime is None: - raise HTTPException(status_code=404, detail="The file has no thumbnail.", headers=headers) - # The rotated refresh token may ride on this response, so nothing may store it; the plugin caches the image itself. - headers["Cache-Control"] = "no-store" - headers["X-Content-Type-Options"] = "nosniff" - return Response(content=data, media_type=mime, headers=headers) - - -@app.get("/api/files/{item_id}/pdf", tags=["Files as PDF"], response_class=StreamingResponse, responses={200: {"content": {"application/pdf": {}}}}) -async def drive_item_pdf(item_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - """A OneDrive item (an `id` from /api/files) converted to PDF by Microsoft 365. 415 when the type can't be converted.""" - _check_id(item_id, DRIVE_ITEM_PATTERN, "item_id") - - def convert(note, broker): - item = note.get(f"me/drive/items/{_seg(item_id)}", {"$select": "id,name,size,file"}) - _check_source(item) - return item.get("name"), _converted(lambda: file_pdf.drive_item_pdf(note, item_id)) - - return await _pdf(token, convert) - - -@app.get("/api/chats/{chat_id}/messages/{message_id}/files/{attachment_id}/pdf", tags=["Files as PDF"], response_class=StreamingResponse, responses={200: {"content": {"application/pdf": {}}}}) -async def chat_file_pdf(chat_id: str, message_id: str, attachment_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - """A file shared in a chat message (`files[].id`) converted to PDF where it lives (SharePoint / OneDrive). 415 when the type can't be converted.""" - _check_id(chat_id, CHAT_ID_PATTERN, "chat_id") - _check_id(message_id, MESSAGE_ID_PATTERN, "message_id") - _check_id(attachment_id, ATTACHMENT_ID_PATTERN, "attachment_id") - - def convert(note, broker): - message = note.get(chat_read.message_path(chat_id, message_id)) - ref = next((f for f in chat_html.reference_files(message) if f.get("id") == attachment_id), None) - if ref is None: - raise HTTPException(status_code=404, detail="File not found in the message.") - if not file_thumbs.is_share_url(ref.get("contentUrl")): - raise HTTPException(status_code=415, detail="Only files in SharePoint or OneDrive can be converted.") - item = note.get(chat_read.drive_item_path(ref["contentUrl"], "id,name,size,file,parentReference")) - _check_source(item) - drive_id = (item.get("parentReference") or {}).get("driveId") - if not file_thumbs.safe_graph_id(drive_id) or not file_thumbs.safe_graph_id(item.get("id")): - raise HTTPException(status_code=404, detail="File not found.") - return item.get("name"), _converted(lambda: file_pdf.drive_item_pdf(note, item["id"], drive_id)) - - return await _pdf(token, convert) - - -@app.post("/api/convert/pdf", tags=["Files as PDF"], response_class=StreamingResponse, responses={200: {"content": {"application/pdf": {}}}}) -async def convert_pdf(request: Request, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), name: str = Query(..., min_length=1, max_length=255)): - """The request body (raw bytes, up to 105 MB) converted to PDF; `name` is the file name, whose extension picks the type (Word, Excel, PowerPoint, OpenDocument, RTF, CSV). - - For files that aren't in a drive: they go to a temporary file in the app folder, which is deleted right after the conversion.""" - _check_token(token) - safe = _safe_name(name) - if not file_pdf.is_convertible(safe): - raise HTTPException(status_code=415, detail="This file type cannot be converted to PDF.") - declared = request.headers.get("content-length") - if declared is not None and (not declared.isdigit() or int(declared) > MAX_SEND_BYTES): - raise HTTPException(status_code=413, detail="The file is larger than the conversion limit.") - body = bytearray() - async for chunk in _limited(request, MAX_SEND_BYTES).stream(): - body.extend(chunk) - if not body: - raise HTTPException(status_code=400, detail="The body is empty.") - data = bytes(body) - del body - return await _pdf(token, lambda note, broker: (safe, _converted(lambda: file_pdf.bytes_pdf(note, broker, data, safe)))) - - -def _check_file(item: dict): - if isinstance(item, dict) and item.get("folder") is not None: - raise HTTPException(status_code=404, detail=FOLDER_DETAIL) - if not isinstance(item, dict) or item.get("file") is None: - raise HTTPException(status_code=404, detail="File not found.") - - -def _download_link(note, path: str, item: dict) -> str: - """The item's download link, with Graph's refusal as a status of its own. A 404 and a 429 are answered by `_read`.""" - try: - url = teams_files.download_url(note, path, item) - except GraphError as ex: - if ex.status == 403: - raise HTTPException(status_code=403, detail="Microsoft 365 refused access to this file.") - if ex.status in (406, 415): - raise HTTPException(status_code=415, detail="Microsoft 365 cannot serve this file.") - raise - if not url: - raise HTTPException(status_code=502, detail="Microsoft did not offer a download link for this file.") - return url - - -def _check_source(item: dict): - if not isinstance(item, dict) or item.get("file") is None: - raise HTTPException(status_code=404, detail="File not found.") - if not file_pdf.is_convertible(item.get("name")): - raise HTTPException(status_code=415, detail="This file type cannot be converted to PDF.") - if (item.get("size") or 0) > MAX_CHAT_FILE_BYTES: - raise HTTPException(status_code=413, detail="The file is larger than 100 MB.") - - -def _converted(run): - try: - return run() - except file_pdf.ConvertError as ex: - raise HTTPException(status_code=ex.status, detail=str(ex)) - - -async def _pdf(token: str, convert): - """Runs `convert(note, broker) -> (source name, spooled PDF)` and streams the PDF: inline (it was checked to be a PDF), sandboxed, never stored.""" - carrier = Response() - name, spooled = await _read(carrier, token, convert, with_broker=True) - headers = _token_headers(carrier) - pdf_name = posixpath.splitext(_safe_name(name))[0] + ".pdf" - headers["Content-Disposition"] = f"inline; filename*=UTF-8''{quote(pdf_name, safe='')}" - # The rotated refresh token may ride on this response, so nothing may store it. - headers["Cache-Control"] = "no-store" - headers["X-Content-Type-Options"] = "nosniff" - headers["Content-Security-Policy"] = "sandbox; default-src 'none'" - size = spooled.seek(0, 2) - spooled.seek(0) - headers["Content-Length"] = str(size) - - def body(): - try: - while chunk := spooled.read(64 * 1024): - yield chunk - finally: - spooled.close() - - return StreamingResponse(body(), media_type="application/pdf", headers=headers) - - -# --- Teams files, shared / recent files, search, channel messages, file text --------------------------------------- -async def _private_read(response: Response, token: str, action: Callable[..., object], with_broker: bool = False): - # The rotated refresh token may ride on this response, so nothing may store it. - response.headers["Cache-Control"] = "no-store" - return await _read(response, token, action, with_broker=with_broker) - - -def _check_path(path: str | None) -> str | None: - try: - teams_files.valid_path(path) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid path.") - return path - - -@app.get("/api/teams", tags=["Teams files"]) -async def joined_teams(response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - """The joined teams `[{id, displayName, description}]`, cached 10 minutes per account.""" - return await _private_read(response, token, teams_files.joined_teams, with_broker=True) - - -@app.get("/api/teams/{team_id}/channels", tags=["Teams files"]) -async def team_channels(team_id: str, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_id(team_id, TEAM_ID_PATTERN, "team_id") - return await _private_read(response, token, lambda note, broker: teams_files.channels(note, broker, team_id), with_broker=True) - - -@app.get("/api/teams/{team_id}/files", tags=["Teams files"]) -async def team_files(team_id: str, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), channel_id: str | None = Query(None), path: str | None = Query(None, max_length=teams_files.MAX_PATH_CHARS), top: int = Query(100, ge=1, le=1000)): - """`{driveId, folder: {id, name, path}, items}`: the team library root, a channel's folder (`channel_id`) or a sub-folder (`path`, folder names under that folder).""" - _check_id(team_id, TEAM_ID_PATTERN, "team_id") - if channel_id: - _check_id(channel_id, CHANNEL_ID_PATTERN, "channel_id") - _check_path(path) - return await _private_read(response, token, lambda note, broker: teams_files.team_files(note, broker, team_id, channel_id or None, path, top), with_broker=True) - - -@app.get("/api/teams/{team_id}/channels/{channel_id}/messages", tags=["Teams files"]) -async def channel_message_list(team_id: str, channel_id: str, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), top: int = Query(30, ge=1, le=200)): - _check_id(team_id, TEAM_ID_PATTERN, "team_id") - _check_id(channel_id, CHANNEL_ID_PATTERN, "channel_id") - return await _private_read(response, token, lambda note: channel_messages.messages(note, team_id, channel_id, top)) - - -@app.get("/api/teams/{team_id}/channels/{channel_id}/messages/{message_id}/replies", tags=["Teams files"]) -async def channel_message_replies(team_id: str, channel_id: str, message_id: str, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), top: int = Query(50, ge=1, le=200)): - _check_id(team_id, TEAM_ID_PATTERN, "team_id") - _check_id(channel_id, CHANNEL_ID_PATTERN, "channel_id") - _check_id(message_id, CHANNEL_MESSAGE_ID_PATTERN, "message_id") - return await _private_read(response, token, lambda note: channel_messages.replies(note, team_id, channel_id, message_id, top)) - - -@app.get("/api/drives/{drive_id}/items/{item_id}", tags=["Teams files"]) -async def drive_item(drive_id: str, item_id: str, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - """One driveItem with a fresh download URL.""" - _check_id(drive_id, DRIVE_ID_PATTERN, "drive_id") - _check_id(item_id, DRIVE_ITEM_PATTERN, "item_id") - return await _private_read(response, token, lambda note: teams_files.drive_item(note, drive_id, item_id)) - - -@app.get("/api/drives/{drive_id}/items/{item_id}/children", tags=["Teams files"]) -async def drive_item_children(drive_id: str, item_id: str, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), top: int = Query(100, ge=1, le=1000)): - _check_id(drive_id, DRIVE_ID_PATTERN, "drive_id") - _check_id(item_id, DRIVE_ITEM_PATTERN, "item_id") - return await _private_read(response, token, lambda note: teams_files.children(note, drive_id, item_id, top)) - - -@app.get("/api/drives/{drive_id}/items/{item_id}/content", tags=["Teams files"], response_class=StreamingResponse, responses={200: {"content": {"application/octet-stream": {}}}}) -async def drive_item_content(drive_id: str, item_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - """The file's bytes as an attachment (<= 100 MB, no-store).""" - _check_id(drive_id, DRIVE_ID_PATTERN, "drive_id") - _check_id(item_id, DRIVE_ITEM_PATTERN, "item_id") - - def resolve(note): - item = note.get(teams_files.item_base(drive_id, item_id), {"$select": "id,name,size,file,folder,@microsoft.graph.downloadUrl"}) - _check_file(item) - if (item.get("size") or 0) > teams_files.MAX_DOWNLOAD_BYTES: - raise HTTPException(status_code=413, detail="The file is larger than 100 MB.") - url = _download_link(note, teams_files.item_base(drive_id, item_id), item) - if not teams_files.is_download_url(url): - raise HTTPException(status_code=502, detail="The file has an unexpected download location.") - return item, url - - carrier = Response() - item, url = await _read(carrier, token, resolve) - headers = _token_headers(carrier) - headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{quote(_safe_name(item.get('name')), safe='')}" - headers["Cache-Control"] = "no-store" - headers["X-Content-Type-Options"] = "nosniff" - headers["Content-Security-Policy"] = "sandbox; default-src 'none'" - return StreamingResponse(outnote_client.stream_download(url, teams_files.MAX_DOWNLOAD_BYTES, follow_redirects=False), media_type=_safe_content_type((item.get("file") or {}).get("mimeType")), headers=headers) - - -@app.get("/api/drives/{drive_id}/items/{item_id}/pdf", tags=["Files as PDF"], response_class=StreamingResponse, responses={200: {"content": {"application/pdf": {}}}}) -async def shared_drive_item_pdf(drive_id: str, item_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - """An item of any readable drive converted to PDF by Microsoft 365. 415 when the type can't be converted.""" - _check_id(drive_id, DRIVE_ID_PATTERN, "drive_id") - _check_id(item_id, DRIVE_ITEM_PATTERN, "item_id") - - def convert(note, broker): - item = note.get(teams_files.item_base(drive_id, item_id), {"$select": "id,name,size,file"}) - _check_source(item) - return item.get("name"), _converted(lambda: file_pdf.drive_item_pdf(note, item_id, drive_id)) - - return await _pdf(token, convert) - - -def _file_text(note, drive_id: str | None, item_id: str, offset: int, max_chars: int, sheet: str | None, row_offset: int, max_rows: int) -> dict: - item = note.get(teams_files.item_base(drive_id, item_id), {"$select": "id,name,size,file,folder,@microsoft.graph.downloadUrl"}) - _check_file(item) - name = item.get("name") or "" - kind = file_text.kind_of(name) - if kind is None: - raise HTTPException(status_code=415, detail=file_text.UNSUPPORTED) - if (item.get("size") or 0) > file_text.MAX_SOURCE_BYTES and kind != "legacy": - raise HTTPException(status_code=413, detail="The file is larger than 25 MB.") - notes = [] - try: - if kind == "legacy": - spooled = _converted(lambda: file_pdf.drive_item_pdf(note, item_id, drive_id)) - try: - data = spooled.read(file_text.MAX_SOURCE_BYTES + 1) - finally: - spooled.close() - name = posixpath.splitext(name)[0] + ".pdf" - notes.append("Converted to PDF by Microsoft 365 before reading.") - else: - url = _download_link(note, teams_files.item_base(drive_id, item_id), item) - data = teams_files.fetch_bytes(url, file_text.MAX_SOURCE_BYTES) - result = file_text.extract(name, data, offset, max_chars, sheet, row_offset, max_rows, notes) - except file_text.TextError as ex: - raise HTTPException(status_code=ex.status, detail=str(ex)) - except teams_files.FileFetchError as ex: - raise HTTPException(status_code=ex.status, detail=str(ex)) - result["name"] = item.get("name") or result["name"] - return result - - -_TEXT_OFFSET = Query(0, ge=0, le=100_000_000) -_TEXT_CHARS = Query(file_text.DEFAULT_CHARS, ge=1, le=file_text.MAX_CHARS) -_TEXT_SHEET = Query(None, max_length=64) -_TEXT_ROW_OFFSET = Query(0, ge=0, le=file_text.MAX_ROW_OFFSET) -_TEXT_ROWS = Query(file_text.DEFAULT_ROWS, ge=1, le=file_text.MAX_ROWS) - - -@app.get("/api/drives/{drive_id}/items/{item_id}/text", tags=["File text"]) -async def shared_drive_item_text(drive_id: str, item_id: str, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), offset: int = _TEXT_OFFSET, max_chars: int = _TEXT_CHARS, sheet: str | None = _TEXT_SHEET, row_offset: int = _TEXT_ROW_OFFSET, max_rows: int = _TEXT_ROWS): - """Plain text of a document, spreadsheet, presentation, PDF or text file (FileText), paged by characters (and rows for a sheet).""" - _check_id(drive_id, DRIVE_ID_PATTERN, "drive_id") - _check_id(item_id, DRIVE_ITEM_PATTERN, "item_id") - return await _private_read(response, token, lambda note: _file_text(note, drive_id, item_id, offset, max_chars, sheet, row_offset, max_rows)) - - -@app.get("/api/files/{item_id}/text", tags=["File text"]) -async def drive_item_text(item_id: str, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), offset: int = _TEXT_OFFSET, max_chars: int = _TEXT_CHARS, sheet: str | None = _TEXT_SHEET, row_offset: int = _TEXT_ROW_OFFSET, max_rows: int = _TEXT_ROWS): - """FileText of an item in the user's own OneDrive.""" - _check_id(item_id, DRIVE_ITEM_PATTERN, "item_id") - return await _private_read(response, token, lambda note: _file_text(note, None, item_id, offset, max_chars, sheet, row_offset, max_rows)) - - -@app.post("/api/convert/text", tags=["File text"]) -async def convert_text(request: Request, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), name: str = Query(..., min_length=1, max_length=255), offset: int = _TEXT_OFFSET, max_chars: int = _TEXT_CHARS, sheet: str | None = _TEXT_SHEET, row_offset: int = _TEXT_ROW_OFFSET, max_rows: int = _TEXT_ROWS): - """FileText of the request body (raw bytes, up to 25 MB); `name`'s extension picks the type. For files that aren't in a drive.""" - _check_token(token) - safe = _safe_name(name) - if file_text.kind_of(safe) is None: - raise HTTPException(status_code=415, detail=file_text.UNSUPPORTED) - declared = request.headers.get("content-length") - if declared is not None and (not declared.isdigit() or int(declared) > MAX_TEXT_SOURCE_BYTES): - raise HTTPException(status_code=413, detail="The file is larger than 25 MB.") - body = bytearray() - async for chunk in _limited(request, MAX_TEXT_SOURCE_BYTES).stream(): - body.extend(chunk) - if not body: - raise HTTPException(status_code=400, detail="The body is empty.") - data = bytes(body) - del body - - def run(note, broker): - notes = [] - try: - if file_text.kind_of(safe) == "legacy": - spooled = _converted(lambda: file_pdf.bytes_pdf(note, broker, data, safe)) - try: - pdf = spooled.read(file_text.MAX_SOURCE_BYTES + 1) - finally: - spooled.close() - notes.append("Converted to PDF by Microsoft 365 before reading.") - result = file_text.extract(posixpath.splitext(safe)[0] + ".pdf", pdf, offset, max_chars, sheet, row_offset, max_rows, notes) - result["name"] = safe - return result - return file_text.extract(safe, data, offset, max_chars, sheet, row_offset, max_rows, notes) - except file_text.TextError as ex: - raise HTTPException(status_code=ex.status, detail=str(ex)) - - return await _private_read(response, token, run, with_broker=True) - - -@app.get("/api/files/shared", tags=["Teams files"]) -async def files_shared(response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), top: int = Query(50, ge=1, le=200)): - """Files shared with the user, flattened from `remoteItem`, each with `parentReference.driveId` and `shared: {sharedBy, sharedDateTime}`.""" - return await _private_read(response, token, lambda note: teams_files.shared_with_me(note, top)) - - -@app.get("/api/files/recent", tags=["Teams files"]) -async def files_recent(response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), top: int = Query(50, ge=1, le=200)): - return await _private_read(response, token, lambda note: teams_files.recent(note, top)) - - -def _check_team(team_id: str | None): - if team_id is not None: - _check_id(team_id, TEAM_ID_PATTERN, "teamId") - - -@app.post("/api/files/search", tags=["Search"]) -async def files_search(body: FilesSearchRequest, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - """SearchResult with file hits only. With `teamId` that team's library is searched. The query never reaches a log.""" - _check_team(body.teamId) - return await _private_read(response, token, lambda note, broker: search.run(note, broker, body.q, ["files"], body.start, body.size, body.teamId), with_broker=True) - - -@app.post("/api/search", tags=["Search"]) -async def search_all(body: SearchRequest, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - """SearchResult over files, mail, chats and channel posts (`types`), each hit with the place it lives in.""" - _check_team(body.teamId) - return await _private_read(response, token, lambda note, broker: search.run(note, broker, body.q, body.types, body.start, body.size, body.teamId), with_broker=True) - - -@app.get("/api/emojis/catalogue", tags=["Emojis"]) -async def emoji_catalogue(response: Response, locale: str = Query("default", max_length=20)): - """Teams' emoji catalogue `{locale, version, assetBase, sizes, fps, categories: [{id, title, emojis: [{id, shortcuts, unicode, title, keywords, diverse, animated, staticUrl, animatedUrl, frames, frameMs, firstFrame, etag}]}], custom}`. - - Public data (no token involved), cached for 24 hours per locale. The URLs are 30 px; swap the size in the file name (20, 30, 40, 50, 60, 100) for another. An animated emoji is a vertical sprite strip of `frames` square frames shown at `frameMs` each.""" - name = emojis.normalize_locale(locale) - if name is None: - raise HTTPException(status_code=400, detail="Invalid locale.") - try: - cat = await asyncio.to_thread(emojis.catalogue, name) - except emojis.EmojiError as ex: - raise HTTPException(status_code=502, detail=str(ex)) - response.headers["Cache-Control"] = "public, max-age=3600" - return cat.payload() - - -@app.get("/api/emojis/custom/{document_id}", tags=["Emojis"], response_class=Response, responses={200: {"content": {"image/png": {}}}}) -async def custom_emoji(document_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), animated: bool = Query(True)): - """A tenant custom emoji's picture (`emojis[].documentId` of a message emoji with kind custom). Animated is a GIF/WEBP that plays in a plain image tag.""" - _check_id(document_id, DOCUMENT_ID_PATTERN, "document_id") - - def fetch(note, broker): - try: - return teams_chat.custom_emoji(broker, document_id, animated) - except teams_chat.ChatServiceError as ex: - if ex.status in (401, 403): - return None - raise - - carrier = Response() - data = await _read(carrier, token, fetch, with_broker=True) - headers = _token_headers(carrier) - mime = chat_send.image_type((data or b"")[:12]) - if mime is None: - raise HTTPException(status_code=404, detail="No such custom emoji.", headers=headers) - headers["Cache-Control"] = "no-store" - headers["X-Content-Type-Options"] = "nosniff" - return Response(content=data, media_type=mime, headers=headers) - - -@app.post("/api/chats/{chat_id}/messages", tags=["Microsoft 365"]) -async def send_chat_message(chat_id: str, request: Request, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - """JSON `{content, contentType, importance, mentions, gif}` (or the legacy `{text}`), or multipart/form-data with the same fields (mentions and gif as JSON strings) plus `images` and `files` parts.""" - _check_id(chat_id, CHAT_ID_PATTERN, "chat_id") - _check_token(token) - multipart = request.headers.get("content-type", "").lower().startswith("multipart/form-data") - cap = MAX_SEND_BYTES if multipart else MAX_JSON_SEND_BYTES - declared = request.headers.get("content-length") - if declared is not None and (not declared.isdigit() or int(declared) > cap): - raise HTTPException(status_code=413, detail="The message is larger than the send limit.") - request = _limited(request, cap) - form = None - try: - if multipart: - # Starlette spools each file part to a temporary file past 1 MB; form.close() deletes them. - form = await request.form(max_files=chat_send.MAX_IMAGES + chat_send.MAX_FILES, max_fields=20, max_part_size=256 * 1024) - fields = {k: form.get(k) for k in ("content", "text", "contentType", "importance") if isinstance(form.get(k), str)} - fields["mentions"] = _json_field(form.get("mentions"), "mentions") - fields["gif"] = _json_field(form.get("gif"), "gif") - images = [_upload(f) for f in form.getlist("images") if isinstance(f, UploadFile)] - files = [_upload(f) for f in form.getlist("files") if isinstance(f, UploadFile)] - else: - try: - fields = await request.json() - except ValueError: - raise HTTPException(status_code=400, detail="The body must be JSON or multipart/form-data.") - if not isinstance(fields, dict): - raise HTTPException(status_code=400, detail="The body must be a JSON object.") - images, files = [], [] - msg = _outgoing(fields, images, files) - - def send(note, broker): - try: - created = chat_send.send(note, broker, chat_id, msg) - except chat_send.SendError as ex: - raise HTTPException(status_code=ex.status, detail=str(ex)) - return chat_read.enrich(note, broker, [created or {}])[0] - - return await _read(response, token, send, with_broker=True) - finally: - if form is not None: - await form.close() - - -@app.post("/api/chats/{chat_id}/meeting", status_code=201, tags=["Microsoft 365"]) -async def schedule_chat_meeting(chat_id: str, body: MeetingRequest, response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - """Creates a Teams meeting in the user's calendar with the chat's members as attendees.""" - _check_id(chat_id, CHAT_ID_PATTERN, "chat_id") - if body.end <= body.start: - raise HTTPException(status_code=400, detail="end must be after start.") - - def create(note, broker): - members = [] if chat_id == chat_send.SELF_CHAT else note.list(f"chats/{chat_id}/members") - me = profile.get(note, broker) - mine = {(me.get("mail") or "").lower(), (me.get("userPrincipalName") or "").lower()} - attendees = [{"emailAddress": {"address": m["email"], "name": m.get("displayName") or m["email"]}, "type": "required"} for m in members if m.get("email") and m["email"].lower() not in mine] - event = { - "subject": body.subject, - "start": {"dateTime": _utc(body.start), "timeZone": "UTC"}, - "end": {"dateTime": _utc(body.end), "timeZone": "UTC"}, - "isOnlineMeeting": True, - "onlineMeetingProvider": "teamsForBusiness", - "attendees": attendees, - } - if body.description: - event["body"] = {"contentType": "text", "content": body.description} - created = note.post("me/events", event) - return {"id": created.get("id"), "subject": created.get("subject"), "start": created.get("start"), "end": created.get("end"), "webLink": created.get("webLink"), "joinUrl": (created.get("onlineMeeting") or {}).get("joinUrl"), "attendees": [a["emailAddress"] for a in attendees]} - - return await _read(response, token, create, with_broker=True) - - -@app.get("/api/gifs/search", tags=["GIFs"]) -async def gif_search(response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), q: str = Query(..., min_length=1, max_length=100), limit: int = Query(24, ge=1, le=50)): - return await _gifs(response, token, q, limit) - - -@app.get("/api/gifs/trending", tags=["GIFs"]) -async def gif_trending(response: Response, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), limit: int = Query(24, ge=1, le=50)): - return await _gifs(response, token, None, limit) - - -async def _gifs(response: Response, token: str, query: str | None, limit: int): - _check_token(token) - - def run(note, broker): - # Only a live Microsoft sign-in may spend the GIF key: the token must mint a Graph token (cached after the first call), and each account is rate limited. - broker(config.DEVICE_CLIENT_ID) - if not gifs.allow(broker): - raise HTTPException(status_code=429, detail="Too many GIF searches; wait a minute.", headers={"Retry-After": "60"}) - try: - return gifs.search(query, limit) - except gifs.GifSearchUnavailable as ex: - raise HTTPException(status_code=503, detail=str(ex)) - except Exception as ex: - raise HTTPException(status_code=502, detail=f"GIF search failed: {type(ex).__name__}") - - return await _read(response, token, run, with_broker=True) - - -def _check_token(token: str): - if not TOKEN_PATTERN.fullmatch(token): - raise HTTPException(status_code=401, detail="Missing or malformed Microsoft token.") - - -def _utc(value) -> str: - if value.tzinfo is None: - value = value.replace(tzinfo=timezone.utc) - return value.astimezone(timezone.utc).replace(tzinfo=None).isoformat() - - -def _limited(request: Request, cap: int) -> Request: - """The request with its body cut off past `cap` bytes, whatever Content-Length claims.""" - received = 0 - upstream = request.receive - - async def receive(): - nonlocal received - message = await upstream() - if message.get("type") == "http.request": - received += len(message.get("body", b"")) - if received > cap: - raise HTTPException(status_code=413, detail="The message is larger than the send limit.") - return message - - return Request(request.scope, receive) - - -def _json_field(value, name: str): - if value is None or value == "": - return None - if not isinstance(value, str): - raise HTTPException(status_code=400, detail=f"{name} must be a JSON string.") - try: - return json.loads(value) - except ValueError: - raise HTTPException(status_code=400, detail=f"{name} is not valid JSON.") - - -def _upload(part: UploadFile) -> chat_send.Upload: - size = part.size - if size is None: - part.file.seek(0, 2) - size = part.file.tell() - part.file.seek(0) - return chat_send.Upload(name=part.filename or "file", file=part.file, size=size, content_type=part.content_type) - - -def _outgoing(fields: dict, images: list, files: list) -> chat_send.OutgoingMessage: - content = fields.get("content") - if content is None: - content = fields.get("text") - mentions = fields.get("mentions") or [] - gif = fields.get("gif") - if not isinstance(content, (str, type(None))) or not isinstance(mentions, list) or not isinstance(gif, (dict, type(None))): - raise HTTPException(status_code=400, detail="Invalid message fields.") - content_type, importance = fields.get("contentType") or "text", fields.get("importance") or None - if not isinstance(content_type, str) or not isinstance(importance, (str, type(None))): - raise HTTPException(status_code=400, detail="Invalid message fields.") - msg = chat_send.OutgoingMessage(content=content or "", content_type=content_type, importance=importance, mentions=mentions, gif=gif, images=images, files=files) - try: - return chat_send.validate(msg) - except chat_send.SendError as ex: - raise HTTPException(status_code=ex.status, detail=str(ex)) - - -@app.post("/api/chats/{chat_id}/read", status_code=204, tags=["Microsoft 365"]) -async def mark_chat_read(chat_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_id(chat_id, CHAT_ID_PATTERN, "chat_id") - carrier = Response() - await _read(carrier, token, lambda note: note.mark_chat_read(chat_id)) - return Response(status_code=204, headers=_token_headers(carrier)) - - -@app.get("/api/chats/{chat_id}/photo", tags=["Microsoft 365"], response_class=Response, responses={200: {"content": {"image/jpeg": {}}}}) -async def chat_photo(chat_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER)): - _check_id(chat_id, CHAT_ID_PATTERN, "chat_id") - - def fetch(note, broker): - try: - return teams_chat.picture(broker, chat_id) - except teams_chat.ChatServiceError as ex: - if ex.status in (401, 403): - # Teams refused this account (tenant policy, no Teams licence): there is no picture to show. - return None - raise - - carrier = Response() - data = await _read(carrier, token, fetch, with_broker=True) - headers = _token_headers(carrier) - if not data: - raise HTTPException(status_code=404, detail="The chat has no picture.", headers=headers) - headers["Cache-Control"] = "no-store" - headers["X-Content-Type-Options"] = "nosniff" - return Response(content=data, media_type=_image_type(data), headers=headers) - - -@app.get("/api/people/photo", tags=["Microsoft 365"], response_class=Response, responses={200: {"content": {"image/jpeg": {}}}}) -async def people_photo(email: str = Query(..., max_length=254), token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), size: int = Query(48)): - """A person's profile photo by e-mail address (mail senders); 404 when they have none or are outside the tenant.""" - if not EMAIL_PATTERN.fullmatch(email.strip()): - raise HTTPException(status_code=400, detail="Invalid email.") - if size not in PHOTO_SIZES: - raise HTTPException(status_code=400, detail=f"size must be one of {', '.join(str(s) for s in PHOTO_SIZES)}") - - def fetch(note, broker): - try: - return people_photos.photo(note, broker, email, size) - except people_photos.Throttled as ex: - raise HTTPException(status_code=429, detail="Microsoft is throttling photo requests.", headers={"Retry-After": _retry_after(ex.retry_after)}) - - carrier = Response() - data = await _read(carrier, token, fetch, with_broker=True) - headers = _token_headers(carrier) - if not data: - raise HTTPException(status_code=404, detail="No profile photo for that address.", headers=headers) - headers["Cache-Control"] = "no-store" - headers["X-Content-Type-Options"] = "nosniff" - return Response(content=data, media_type=_image_type(data), headers=headers) - - -@app.get("/api/users/{user_id}/photo", tags=["Microsoft 365"], response_class=Response, responses={200: {"content": {"image/jpeg": {}}}}) -async def user_photo(user_id: str, token: str = Header(..., alias=config.REFRESH_TOKEN_HEADER), size: int = Query(64)): - _check_id(user_id, USER_ID_PATTERN, "user_id") - if size not in PHOTO_SIZES: - raise HTTPException(status_code=400, detail=f"size must be one of {', '.join(str(s) for s in PHOTO_SIZES)}") - - def fetch(note): - for path in (f"users/{user_id}/photos/{size}x{size}/$value", f"users/{user_id}/photo/$value"): - try: - return note.get(path, raw="bytes") - except GraphError as ex: - if ex.status != 404: - raise - return None - - carrier = Response() - data = await _read(carrier, token, fetch) - headers = _token_headers(carrier) - if not data: - raise HTTPException(status_code=404, detail="The user has no profile photo.", headers=headers) - # The rotated refresh token rides on this response, so nothing may store it; the caller caches the photo itself. - headers["Cache-Control"] = "no-store" - return Response(content=data, media_type=_image_type(data), headers=headers) - - -async def _identity(refresh_token: str) -> LinkedIdentity: - try: - note, broker = outnote_client.build_client(refresh_token) - me = await asyncio.to_thread(profile.get, note, broker) - return LinkedIdentity(id=me.get("id"), email=me.get("mail") or me.get("userPrincipalName"), name=me.get("displayName")) - except Exception: - return LinkedIdentity() - - -async def _read(response: Response, refresh_token: str, action: Callable[..., object], with_broker: bool = False): - note, broker = outnote_client.build_client(refresh_token) - try: - data = await asyncio.to_thread(action, note, broker) if with_broker else await asyncio.to_thread(action, note) - except HTTPException as ex: - # The token may have rotated before the action refused; keep it for the caller. - if broker.refresh_token and broker.refresh_token != refresh_token: - ex.headers = {**(ex.headers or {}), config.REFRESH_TOKEN_HEADER: broker.refresh_token} - raise - except Exception as ex: - if "invalid_grant" in str(ex): - raise HTTPException(status_code=401, detail="The Microsoft sign-in expired; reconnect the account.") - if isinstance(ex, GraphError) and ex.status == 404: - raise HTTPException(status_code=404, detail="Not found in Microsoft 365.") - if isinstance(ex, GraphError) and ex.status == 429: - # OutNote already waited about 60 s. The token may have rotated first; keep it for the caller. - headers = {"Retry-After": _retry_after(ex.retry_after)} - if broker.refresh_token and broker.refresh_token != refresh_token: - headers[config.REFRESH_TOKEN_HEADER] = broker.refresh_token - raise HTTPException(status_code=429, detail="Microsoft is throttling this account; try again later.", headers=headers) - raise HTTPException(status_code=502, detail=f"Microsoft Graph error: {ex}") - # Hand back the rotated refresh token so the caller can persist it. - if broker.refresh_token and broker.refresh_token != refresh_token: - response.headers[config.REFRESH_TOKEN_HEADER] = broker.refresh_token - return outnote_client.strip_native(data) - - -def _retry_after(seconds: float | None) -> str: - """Whole seconds for a Retry-After header; Graph's value when it gave one.""" - return str(max(1, math.ceil(seconds))) if seconds else str(DEFAULT_RETRY_AFTER) - - -async def _no_content(token: str, action: Callable[[object], object]): - carrier = Response() - await _read(carrier, token, action) - return Response(status_code=204, headers=_token_headers(carrier)) - - -def _seg(value: str) -> str: - # OutNote does not escape path segments; ids may hold "+" or "/", so percent-encode them for Graph. - return quote(value, safe="") - - -def _check_text(value: str, name: str): - if len(value.encode("utf-8")) > MAX_BODY_BYTES: - raise HTTPException(status_code=413, detail=f"{name} is larger than 1 MB.") - - -def _addresses(values: list[str] | None, name: str) -> list[str]: - out = [v.strip() for v in values or []] - for address in out: - if len(address) > 254 or not EMAIL_PATTERN.fullmatch(address): - raise HTTPException(status_code=400, detail=f"Invalid address in {name}.") - return out - - -def _safe_content_type(value: str | None) -> str: - # Attachments are untrusted: anything that could run as a page or script is served as opaque bytes. - content_type = (value or "").split(";")[0].strip().lower() - if not CONTENT_TYPE_PATTERN.fullmatch(content_type) or any(part in content_type for part in ACTIVE_TYPE_PARTS): - return "application/octet-stream" - return content_type - - -def _safe_name(name: str | None) -> str: - cleaned = re.sub(r'[\x00-\x1f\x7f\\/:*?"<>|]', "_", name or "") - return cleaned.strip(" .")[:255] or "attachment" - - -def _message_args(body: MailMessageIn) -> dict: - to, cc, bcc = _addresses(body.to, "to"), _addresses(body.cc, "cc"), _addresses(body.bcc, "bcc") - if not to and not cc and not bcc: - raise HTTPException(status_code=400, detail="At least one recipient is required.") - if len(to) + len(cc) + len(bcc) > MAX_RECIPIENTS: - raise HTTPException(status_code=400, detail=f"At most {MAX_RECIPIENTS} recipients are allowed.") - if len(body.subject) > MAX_SUBJECT: - raise HTTPException(status_code=400, detail=f"The subject may be at most {MAX_SUBJECT} characters.") - _check_text(body.body, "body") - attachments, total = [], 0 - for item in body.attachments or []: - try: - data = base64.b64decode(item.contentBase64, validate=True) - except (binascii.Error, ValueError): - raise HTTPException(status_code=400, detail="An attachment is not valid base64.") - total += len(data) - if total > MAX_SEND_ATTACHMENT_BYTES: - raise HTTPException(status_code=413, detail="Attachments may total at most 3 MB.") - attachments.append((_safe_name(item.name), data)) - return {"to": to, "subject": body.subject, "body": body.body, "html": body.html, "cc": cc or None, "bcc": bcc or None, "attachments": attachments or None} - - -def _attachment_meta(item: dict) -> dict: - keep = ("id", "name", "contentType", "size", "isInline", "contentId") - return {k: item.get(k) for k in keep} - - -def _folders(note, broker) -> list[dict]: - roles = folder_roles.cached(broker) - if roles is not None: - return _folder_tree(note.mail_folders(include_children=True), roles) - # First list this hour: resolve the well-known roles in parallel with the folder walk, so they cost no extra round trip. - with ThreadPoolExecutor(max_workers=1) as pool: - pending = pool.submit(folder_roles.lookup, note) - flat = note.mail_folders(include_children=True) - try: - roles = pending.result() - except Exception as ex: - # Roles only label folders; the tree is still useful without them. - log.info("mail folder role lookup failed: %s", type(ex).__name__) - roles = {} - folder_roles.remember(broker, roles) - return _folder_tree(flat, roles) - - -def _folder_tree(flat: list[dict], roles: dict[str, str] | None = None) -> list[dict]: - # OutNote walks the subfolders breadth-first into a flat list; rebuild the nesting from parentFolderId. - roles = roles or {} - nodes = {f["id"]: {**f, "wellKnownName": roles.get(f["id"]), "childFolders": []} for f in flat} - roots = [] - for f in flat: - parent = nodes.get(f.get("parentFolderId")) - (parent["childFolders"] if parent is not None and f["id"] != f.get("parentFolderId") else roots).append(nodes[f["id"]]) - return roots - - -def _check_onenote_id(value: str, name: str): - """OneNote ids (bare, or `@` for a team's notebook) through the shared validator, then the scope check.""" - _check_id(value, onenote.ID_PATTERN, name) - if not onenote.is_id(value): - raise HTTPException(status_code=400, detail=f"Invalid {name}.") - - -async def _onenote(response: Response, token: str, action: Callable[..., object]): - """`_read` for OneNote: refusals OneNote gives as such keep their meaning instead of becoming a 502.""" - - def run(note, broker): - try: - return action(note, broker) - except onenote.OneNoteError as ex: - raise HTTPException(status_code=ex.status, detail=str(ex)) - except GraphError as ex: - if ex.status == 403: - raise HTTPException(status_code=403, detail="OneNote refused access to this notebook.") - if ex.status in (400, 409, 422): - raise HTTPException(status_code=422, detail=f"OneNote refused the request: {str(ex)[:200]}") - raise - - return await _read(response, token, run, with_broker=True) - - -def _check_id(value: str, pattern: re.Pattern, name: str): - """The one validator for ids that become upstream path segments: the pattern, and no dot segment ("." / ".." collapse in URL handling and retarget the call).""" - if not pattern.fullmatch(value) or _has_dot_segment(value): - raise HTTPException(status_code=400, detail=f"Invalid {name}.") - - -def _has_dot_segment(value: str) -> bool: - return ".." in value or any(part == "." for part in re.split(r"[/\\]", value)) - - -def _token_headers(carrier: Response) -> dict[str, str]: - rotated = carrier.headers.get(config.REFRESH_TOKEN_HEADER) - return {config.REFRESH_TOKEN_HEADER: rotated} if rotated else {} - - -def _image_type(data: bytes) -> str: - # OutNote's public getter returns only the body bytes, so sniff the format from the magic number. - if data[1:4] == b"PNG": - return "image/png" - if data[:6] in (b"GIF87a", b"GIF89a"): - return "image/gif" - return "image/jpeg" diff --git a/src/application/abstractions/i_account_broker.py b/src/application/abstractions/i_account_broker.py new file mode 100644 index 0000000..7ea7bfb --- /dev/null +++ b/src/application/abstractions/i_account_broker.py @@ -0,0 +1,10 @@ +from abc import ABC, abstractmethod + + +class IAccountBroker(ABC): + """An account's token broker. Calling it mints an access token, which may rotate `refresh_token`.""" + + refresh_token: str | None + + @abstractmethod + def __call__(self, client_id, scope="https://graph.microsoft.com/.default"): ... diff --git a/src/application/abstractions/i_account_runner.py b/src/application/abstractions/i_account_runner.py new file mode 100644 index 0000000..6c69684 --- /dev/null +++ b/src/application/abstractions/i_account_runner.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod +from collections.abc import Callable + +from src.application.abstractions.i_account_broker import IAccountBroker +from src.application.abstractions.i_graph_client import IGraphClient + + +class IAccountRunner(ABC): + """Runs blocking work for an account off the event loop and turns what Microsoft answers into the service's refusals.""" + + @abstractmethod + async def run(self, refresh_token: str, action: Callable[[IGraphClient, IAccountBroker], object]) -> object: ... diff --git a/src/application/abstractions/i_browser_sign_in.py b/src/application/abstractions/i_browser_sign_in.py new file mode 100644 index 0000000..4777bc0 --- /dev/null +++ b/src/application/abstractions/i_browser_sign_in.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + + +class IBrowserSignIn(ABC): + """The browser sign-in: authorization code + PKCE on Microsoft's real page.""" + + NATIVE_REDIRECT: str + + @abstractmethod + def build_authorize_url(self) -> tuple[str, str]: ... + + @abstractmethod + async def exchange_code(self, code: str, code_verifier: str, client=None) -> dict: ... diff --git a/src/application/abstractions/i_channel_messages.py b/src/application/abstractions/i_channel_messages.py new file mode 100644 index 0000000..9b68512 --- /dev/null +++ b/src/application/abstractions/i_channel_messages.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from src.application.abstractions.i_graph_client import IGraphClient + + +class IChannelMessages(ABC): + """Teams channel posts and replies, normalized.""" + + @abstractmethod + def messages(self, note: IGraphClient, team_id: str, channel_id: str, top: int) -> list[dict]: ... + + @abstractmethod + def replies(self, note: IGraphClient, team_id: str, channel_id: str, message_id: str, top: int) -> list[dict]: ... diff --git a/src/application/abstractions/i_chat_message_body.py b/src/application/abstractions/i_chat_message_body.py new file mode 100644 index 0000000..d74cee7 --- /dev/null +++ b/src/application/abstractions/i_chat_message_body.py @@ -0,0 +1,23 @@ +from abc import ABC, abstractmethod + +from src.domain.chat.upload import Upload + + +class IChatMessageBody(ABC): + """The body of a chat send: JSON, or multipart/form-data with `images` and `files` parts.""" + + @property + @abstractmethod + def is_multipart(self) -> bool: ... + + @property + @abstractmethod + def declared_length(self) -> str | None: ... + + @abstractmethod + async def read(self, cap: int) -> tuple[dict, list[Upload], list[Upload]]: + """(fields, images, files), refused past `cap` bytes whatever the declared length claims.""" + + @abstractmethod + async def close(self) -> None: + """Deletes any spooled file parts.""" diff --git a/src/application/abstractions/i_chat_reader.py b/src/application/abstractions/i_chat_reader.py new file mode 100644 index 0000000..e6780fc --- /dev/null +++ b/src/application/abstractions/i_chat_reader.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + +from src.application.abstractions.i_account_broker import IAccountBroker +from src.application.abstractions.i_graph_client import IGraphClient + + +class IChatReader(ABC): + """Graph chat messages made ready for the web.""" + + @abstractmethod + def enrich(self, note: IGraphClient, broker: IAccountBroker, messages: list[dict]) -> list[dict]: ... diff --git a/src/application/abstractions/i_chat_sender.py b/src/application/abstractions/i_chat_sender.py new file mode 100644 index 0000000..4abff6d --- /dev/null +++ b/src/application/abstractions/i_chat_sender.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod + +from src.application.abstractions.i_account_broker import IAccountBroker +from src.application.abstractions.i_graph_client import IGraphClient +from src.domain.chat.outgoing_message import OutgoingMessage + + +class IChatSender(ABC): + """Validating and sending a chat message.""" + + SELF_CHAT: str + + @abstractmethod + def validate(self, msg: OutgoingMessage) -> OutgoingMessage: ... + + @abstractmethod + def send(self, note: IGraphClient, broker: IAccountBroker, chat_id: str, msg: OutgoingMessage) -> dict: ... diff --git a/src/application/abstractions/i_device_login.py b/src/application/abstractions/i_device_login.py new file mode 100644 index 0000000..d49dd03 --- /dev/null +++ b/src/application/abstractions/i_device_login.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + + +class IDeviceLogin(ABC): + """The device-code sign-in: two documented OAuth calls.""" + + @abstractmethod + async def start_device_login(self, client=None) -> dict: ... + + @abstractmethod + async def poll_device_login(self, device_code: str, client=None) -> dict: ... diff --git a/src/application/abstractions/i_emoji_catalogue.py b/src/application/abstractions/i_emoji_catalogue.py new file mode 100644 index 0000000..a7dd2ce --- /dev/null +++ b/src/application/abstractions/i_emoji_catalogue.py @@ -0,0 +1,19 @@ +from abc import ABC, abstractmethod + +from src.domain.chat.catalogue import Catalogue + + +class IEmojiCatalogue(ABC): + """Teams' emoji: the catalogue, what may be sent, and what a message body holds.""" + + @abstractmethod + def normalize_locale(self, value: str | None) -> str | None: ... + + @abstractmethod + def catalogue(self, locale: str = "default") -> Catalogue: ... + + @abstractmethod + def for_send(self, ref) -> tuple[str, str] | None: ... + + @abstractmethod + def message_emojis(self, content: str, cat: Catalogue | None = None) -> tuple[str, list[dict]]: ... diff --git a/src/application/abstractions/i_file_text_extractor.py b/src/application/abstractions/i_file_text_extractor.py new file mode 100644 index 0000000..cf568d4 --- /dev/null +++ b/src/application/abstractions/i_file_text_extractor.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + + +class IFileTextExtractor(ABC): + """Plain text out of a file's bytes.""" + + MAX_SOURCE_BYTES: int + + @abstractmethod + def kind_of(self, name) -> str | None: ... + + @abstractmethod + def extract(self, name: str, data: bytes, offset: int = 0, max_chars: int = 20000, sheet: str | None = None, row_offset: int = 0, max_rows: int = 500, notes: list[str] | None = None) -> dict: ... diff --git a/src/application/abstractions/i_file_thumbnails.py b/src/application/abstractions/i_file_thumbnails.py new file mode 100644 index 0000000..0862312 --- /dev/null +++ b/src/application/abstractions/i_file_thumbnails.py @@ -0,0 +1,31 @@ +from abc import ABC, abstractmethod + +from src.application.abstractions.i_account_broker import IAccountBroker +from src.application.abstractions.i_graph_client import IGraphClient + + +class IFileThumbnails(ABC): + """File kinds, the share-link host check, and thumbnails.""" + + SIZES: tuple[str, ...] + + @abstractmethod + def is_share_url(self, url) -> bool: ... + + @abstractmethod + def file_kind(self, name, mime) -> str: ... + + @abstractmethod + def has_thumbnail(self, url, kind: str) -> bool: ... + + @abstractmethod + def image_type(self, head: bytes) -> str | None: ... + + @abstractmethod + def safe_graph_id(self, value) -> bool: ... + + @abstractmethod + def chat_file_thumbnail(self, note: IGraphClient, broker: IAccountBroker, url: str, size: str) -> bytes | None: ... + + @abstractmethod + def drive_item_thumbnail(self, note: IGraphClient, item_id: str, size: str, drive_id: str | None = None) -> bytes | None: ... diff --git a/src/application/abstractions/i_gif_search.py b/src/application/abstractions/i_gif_search.py new file mode 100644 index 0000000..6676557 --- /dev/null +++ b/src/application/abstractions/i_gif_search.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from src.application.abstractions.i_account_broker import IAccountBroker + + +class IGifSearch(ABC): + """GIF search, spent per account.""" + + @abstractmethod + def allow(self, broker: IAccountBroker) -> bool: ... + + @abstractmethod + def search(self, query: str | None, limit: int) -> list[dict]: ... diff --git a/src/application/abstractions/i_graph_client.py b/src/application/abstractions/i_graph_client.py new file mode 100644 index 0000000..06ec0e0 --- /dev/null +++ b/src/application/abstractions/i_graph_client.py @@ -0,0 +1,65 @@ +from abc import ABC, abstractmethod + + +class IGraphClient(ABC): + """What the application asks of a per-account Graph client. OutNote is registered as one by the infrastructure.""" + + @abstractmethod + def get(self, path, params=None, client=None, raw=False): ... + + @abstractmethod + def list(self, path, params=None, limit=None, client=None): ... + + @abstractmethod + def post(self, path, body=None, client=None): ... + + @abstractmethod + def emails(self, folder="inbox", top=25, unread=False, search=None, select=None): ... + + @abstractmethod + def email(self, message_id): ... + + @abstractmethod + def send_mail(self, to, subject, body, html=False, cc=None, bcc=None, attachments=None): ... + + @abstractmethod + def create_draft(self, to, subject, body, html=False, cc=None, bcc=None, attachments=None): ... + + @abstractmethod + def send_draft(self, message_id): ... + + @abstractmethod + def reply(self, message_id, comment, reply_all=False): ... + + @abstractmethod + def forward(self, message_id, to, comment=""): ... + + @abstractmethod + def mark_read(self, message_id, read=True): ... + + @abstractmethod + def flag(self, message_id, status="flagged"): ... + + @abstractmethod + def move_email(self, message_id, folder): ... + + @abstractmethod + def delete_email(self, message_id): ... + + @abstractmethod + def download_attachment(self, message_id, attachment_id): ... + + @abstractmethod + def mail_folders(self, include_children=False): ... + + @abstractmethod + def events(self, start=None, end=None, top=25): ... + + @abstractmethod + def files(self, path="root", top=100): ... + + @abstractmethod + def notebooks(self): ... + + @abstractmethod + def mark_chat_read(self, chat_id): ... diff --git a/src/application/abstractions/i_graph_client_factory.py b/src/application/abstractions/i_graph_client_factory.py new file mode 100644 index 0000000..770c083 --- /dev/null +++ b/src/application/abstractions/i_graph_client_factory.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + +from src.application.abstractions.i_account_broker import IAccountBroker +from src.application.abstractions.i_graph_client import IGraphClient + + +class IGraphClientFactory(ABC): + """The account's client and broker for a refresh token. Both are shared by every request that carries that token.""" + + @abstractmethod + def build_client(self, refresh_token: str) -> tuple[IGraphClient, IAccountBroker]: ... diff --git a/src/application/abstractions/i_graph_search.py b/src/application/abstractions/i_graph_search.py new file mode 100644 index 0000000..9e11e7f --- /dev/null +++ b/src/application/abstractions/i_graph_search.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + +from src.application.abstractions.i_account_broker import IAccountBroker +from src.application.abstractions.i_graph_client import IGraphClient + + +class IGraphSearch(ABC): + """Search over files, mail, chats and channel posts. The query never reaches a log.""" + + @abstractmethod + def run(self, note: IGraphClient, broker: IAccountBroker, q: str, types: list[str], start: int, size: int, team_id: str | None = None) -> dict: ... diff --git a/src/application/abstractions/i_mail_folder_roles.py b/src/application/abstractions/i_mail_folder_roles.py new file mode 100644 index 0000000..89b32ba --- /dev/null +++ b/src/application/abstractions/i_mail_folder_roles.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod + +from src.application.abstractions.i_account_broker import IAccountBroker +from src.application.abstractions.i_graph_client import IGraphClient + + +class IMailFolderRoles(ABC): + """Well-known mail folder names by folder id.""" + + @abstractmethod + def lookup(self, note: IGraphClient) -> dict[str, str]: ... + + @abstractmethod + def cached(self, broker: IAccountBroker) -> dict[str, str] | None: ... + + @abstractmethod + def remember(self, broker: IAccountBroker, roles: dict[str, str]) -> None: ... diff --git a/src/application/abstractions/i_microsoft_http_clients.py b/src/application/abstractions/i_microsoft_http_clients.py new file mode 100644 index 0000000..bbc752d --- /dev/null +++ b/src/application/abstractions/i_microsoft_http_clients.py @@ -0,0 +1,21 @@ +from abc import ABC, abstractmethod +from collections.abc import Iterator + + +class IMicrosoftHttpClients(ABC): + """The shared keep-alive HTTP clients, their warm-up and their shutdown.""" + + @abstractmethod + def download_client(self): ... + + @abstractmethod + def stream_download(self, url: str, limit: int, follow_redirects: bool = True) -> Iterator[bytes]: ... + + @abstractmethod + async def post_form(self, url: str, data: dict, client=None) -> dict: ... + + @abstractmethod + async def warm_up(self) -> None: ... + + @abstractmethod + async def close_shared_client(self) -> None: ... diff --git a/src/application/abstractions/i_one_note.py b/src/application/abstractions/i_one_note.py new file mode 100644 index 0000000..9194adc --- /dev/null +++ b/src/application/abstractions/i_one_note.py @@ -0,0 +1,40 @@ +from abc import ABC, abstractmethod + +from src.application.abstractions.i_account_broker import IAccountBroker +from src.application.abstractions.i_graph_client import IGraphClient + + +class IOneNote(ABC): + """OneNote over Graph.""" + + ID_PATTERN: object + + @abstractmethod + def is_id(self, value: str) -> bool: ... + + @abstractmethod + def notebooks(self, note: IGraphClient, broker: IAccountBroker, teams: bool = True) -> list[dict]: ... + + @abstractmethod + def tree(self, note: IGraphClient, broker: IAccountBroker, notebook: str) -> dict: ... + + @abstractmethod + def pages(self, note: IGraphClient, section: str, top: int, skip: int) -> dict: ... + + @abstractmethod + def page(self, note: IGraphClient, page_scoped: str) -> dict: ... + + @abstractmethod + def create_page(self, note: IGraphClient, broker: IAccountBroker, section: str, title: str, body: str | None) -> dict: ... + + @abstractmethod + def update_page(self, note: IGraphClient, broker: IAccountBroker, page_scoped: str, title: str | None, outlines: list[dict] | None, append: str | None) -> dict: ... + + @abstractmethod + def delete_page(self, note: IGraphClient, broker: IAccountBroker, page_scoped: str) -> None: ... + + @abstractmethod + def resource(self, broker: IAccountBroker, resource_scoped: str, limit: int) -> bytes: ... + + @abstractmethod + def search(self, note: IGraphClient, broker: IAccountBroker, query: str, notebook: str | None, top: int) -> dict: ... diff --git a/src/application/abstractions/i_one_note_runner.py b/src/application/abstractions/i_one_note_runner.py new file mode 100644 index 0000000..46e456a --- /dev/null +++ b/src/application/abstractions/i_one_note_runner.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod +from collections.abc import Callable + +from src.application.abstractions.i_account_broker import IAccountBroker +from src.application.abstractions.i_graph_client import IGraphClient + + +class IOneNoteRunner(ABC): + """`IAccountRunner` for OneNote: refusals OneNote gives as such keep their meaning instead of becoming a 502.""" + + @abstractmethod + async def run(self, refresh_token: str, action: Callable[[IGraphClient, IAccountBroker], object]) -> object: ... diff --git a/src/application/abstractions/i_pdf_converter.py b/src/application/abstractions/i_pdf_converter.py new file mode 100644 index 0000000..dac8c78 --- /dev/null +++ b/src/application/abstractions/i_pdf_converter.py @@ -0,0 +1,18 @@ +from abc import ABC, abstractmethod +from typing import BinaryIO + +from src.application.abstractions.i_account_broker import IAccountBroker +from src.application.abstractions.i_graph_client import IGraphClient + + +class IPdfConverter(ABC): + """Office files as PDF, converted by Microsoft 365. Each answer is a spooled file positioned at 0.""" + + @abstractmethod + def is_convertible(self, name) -> bool: ... + + @abstractmethod + def drive_item_pdf(self, note: IGraphClient, item_id: str, drive_id: str | None = None) -> BinaryIO: ... + + @abstractmethod + def bytes_pdf(self, note: IGraphClient, broker: IAccountBroker, data: bytes, name: str) -> BinaryIO: ... diff --git a/src/application/abstractions/i_people_photos.py b/src/application/abstractions/i_people_photos.py new file mode 100644 index 0000000..7e90dff --- /dev/null +++ b/src/application/abstractions/i_people_photos.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod + +from src.application.abstractions.i_account_broker import IAccountBroker +from src.application.abstractions.i_graph_client import IGraphClient + + +class IPeoplePhotos(ABC): + """Profile photos by e-mail address and by user id.""" + + @abstractmethod + def photo(self, note: IGraphClient, broker: IAccountBroker, email: str, size: int) -> bytes | None: ... + + @abstractmethod + def user_photo(self, note: IGraphClient, user_id: str, size: int) -> bytes | None: ... diff --git a/src/application/abstractions/i_profile_reader.py b/src/application/abstractions/i_profile_reader.py new file mode 100644 index 0000000..8cdc5e2 --- /dev/null +++ b/src/application/abstractions/i_profile_reader.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + +from src.application.abstractions.i_account_broker import IAccountBroker +from src.application.abstractions.i_graph_client import IGraphClient + + +class IProfileReader(ABC): + """The signed-in user's profile. `/me` is read only through this.""" + + @abstractmethod + def get(self, note: IGraphClient, broker: IAccountBroker) -> dict: ... diff --git a/src/application/abstractions/i_request_body.py b/src/application/abstractions/i_request_body.py new file mode 100644 index 0000000..6c2dc3b --- /dev/null +++ b/src/application/abstractions/i_request_body.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + + +class IRequestBody(ABC): + """The raw request body of an upload, read only after the request passed its checks.""" + + @property + @abstractmethod + def declared_length(self) -> str | None: ... + + @abstractmethod + async def read(self, cap: int) -> bytes: + """The whole body, refused past `cap` bytes whatever the declared length claims.""" diff --git a/src/application/abstractions/i_sign_in_job.py b/src/application/abstractions/i_sign_in_job.py new file mode 100644 index 0000000..ee959d1 --- /dev/null +++ b/src/application/abstractions/i_sign_in_job.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod + + +class ISignInJob(ABC): + """Progress of one password sign-in. Holds no password.""" + + id: str + account: object | None + + @abstractmethod + def snapshot(self) -> dict: ... + + @abstractmethod + def request_method(self, method: str) -> str: ... + + @abstractmethod + def submit_code(self, code: str) -> bool: ... diff --git a/src/application/abstractions/i_sign_in_store.py b/src/application/abstractions/i_sign_in_store.py new file mode 100644 index 0000000..4d3d02d --- /dev/null +++ b/src/application/abstractions/i_sign_in_store.py @@ -0,0 +1,30 @@ +from abc import ABC, abstractmethod + +from pydantic import SecretStr + +from src.application.abstractions.i_sign_in_job import ISignInJob + + +class ISignInStore(ABC): + """The in-memory password sign-in jobs and their rate limits. One per process.""" + + @abstractmethod + def start(self, user_id: str, username: str, password: SecretStr) -> ISignInJob: ... + + @abstractmethod + def promote(self, job: ISignInJob) -> None: ... + + @abstractmethod + def get(self, job_id: str, user_id: str) -> ISignInJob | None: ... + + @abstractmethod + def remove(self, job_id: str, user_id: str) -> None: ... + + @abstractmethod + def sweep(self) -> None: ... + + @abstractmethod + def clear(self) -> None: ... + + @abstractmethod + async def wait_settled(self, job: ISignInJob, after_version: int, timeout: float) -> None: ... diff --git a/src/application/abstractions/i_teams_chat.py b/src/application/abstractions/i_teams_chat.py new file mode 100644 index 0000000..48f752d --- /dev/null +++ b/src/application/abstractions/i_teams_chat.py @@ -0,0 +1,16 @@ +from abc import ABC, abstractmethod + +from src.application.abstractions.i_account_broker import IAccountBroker + + +class ITeamsChat(ABC): + """The Teams chat service: group chat pictures and custom emoji.""" + + @abstractmethod + def mark_images(self, broker: IAccountBroker, chats: list[dict]) -> list[dict]: ... + + @abstractmethod + def picture(self, broker: IAccountBroker, chat_id: str) -> bytes | None: ... + + @abstractmethod + def custom_emoji(self, broker: IAccountBroker, document_id: str, animated: bool) -> bytes | None: ... diff --git a/src/application/abstractions/i_teams_files.py b/src/application/abstractions/i_teams_files.py new file mode 100644 index 0000000..67e1877 --- /dev/null +++ b/src/application/abstractions/i_teams_files.py @@ -0,0 +1,53 @@ +from abc import ABC, abstractmethod + +from src.application.abstractions.i_account_broker import IAccountBroker +from src.application.abstractions.i_graph_client import IGraphClient + + +class ITeamsFiles(ABC): + """Teams, channels, drive items, shared and recent files, and download links.""" + + MAX_DOWNLOAD_BYTES: int + MAX_PATH_CHARS: int + + @abstractmethod + def valid_path(self, path) -> list[str]: ... + + @abstractmethod + def is_download_url(self, url) -> bool: ... + + @abstractmethod + def fetch_bytes(self, url: str, cap: int) -> bytes: ... + + @abstractmethod + def download_link(self, note: IGraphClient, path: str, item: dict) -> str: ... + + @abstractmethod + def item_base(self, drive_id: str | None, item_id: str) -> str: ... + + @abstractmethod + def joined_teams(self, note: IGraphClient, broker: IAccountBroker) -> list[dict]: ... + + @abstractmethod + def channels(self, note: IGraphClient, broker: IAccountBroker, team_id: str) -> list[dict]: ... + + @abstractmethod + def team_drive_id(self, note: IGraphClient, broker: IAccountBroker, team_id: str) -> str: ... + + @abstractmethod + def own_drive_id(self, note: IGraphClient, broker: IAccountBroker) -> str | None: ... + + @abstractmethod + def team_files(self, note: IGraphClient, broker: IAccountBroker, team_id: str, channel_id: str | None, path: str | None, top: int) -> dict: ... + + @abstractmethod + def drive_item(self, note: IGraphClient, drive_id: str, item_id: str) -> dict: ... + + @abstractmethod + def children(self, note: IGraphClient, drive_id: str, item_id: str, top: int) -> list[dict]: ... + + @abstractmethod + def shared_with_me(self, note: IGraphClient, top: int) -> list[dict]: ... + + @abstractmethod + def recent(self, note: IGraphClient, top: int) -> list[dict]: ... diff --git a/src/application/abstractions/i_token_rotation.py b/src/application/abstractions/i_token_rotation.py new file mode 100644 index 0000000..548ea2c --- /dev/null +++ b/src/application/abstractions/i_token_rotation.py @@ -0,0 +1,8 @@ +from abc import ABC, abstractmethod + + +class ITokenRotation(ABC): + """Where the rotated refresh token goes so that the current response carries it.""" + + @abstractmethod + def record(self, refresh_token: str) -> None: ... diff --git a/src/application/commands/cancel_password_sign_in_command.py b/src/application/commands/cancel_password_sign_in_command.py new file mode 100644 index 0000000..431a287 --- /dev/null +++ b/src/application/commands/cancel_password_sign_in_command.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_sign_in_store import ISignInStore + + +@dataclass +class CancelPasswordSignInCommand(ICommand[None]): + user_id: str + sign_in_id: str + + +class CancelPasswordSignInHandler(ICommandHandler[CancelPasswordSignInCommand, None]): + def __init__(self, store: ISignInStore): + self._store = store + + async def handle(self, command: CancelPasswordSignInCommand) -> None: + self._store.remove(command.sign_in_id, command.user_id) diff --git a/src/application/commands/choose_password_method_command.py b/src/application/commands/choose_password_method_command.py new file mode 100644 index 0000000..a1d83c3 --- /dev/null +++ b/src/application/commands/choose_password_method_command.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_sign_in_store import ISignInStore +from src.application.dtos.password_sign_in_response import PasswordSignInResponse +from src.application.services.password_sign_in_responder import PasswordSignInResponder +from src.domain.errors.sign_in_refused import SignInRefused + + +@dataclass +class ChoosePasswordMethodCommand(ICommand[PasswordSignInResponse]): + user_id: str + sign_in_id: str + method: str + + +class ChoosePasswordMethodHandler(ICommandHandler[ChoosePasswordMethodCommand, PasswordSignInResponse]): + METHOD_WAIT = 30 # seconds /method waits for the push number / the code prompt + + def __init__(self, store: ISignInStore, responder: PasswordSignInResponder): + self._store = store + self._responder = responder + + async def handle(self, command: ChoosePasswordMethodCommand) -> PasswordSignInResponse: + job = self._store.get(command.sign_in_id, command.user_id) + if job is None: + raise SignInRefused(404, "not_found", "Unknown or expired sign-in.") + version = job.snapshot()["version"] + outcome = job.request_method(command.method) + if outcome == "unavailable": + raise SignInRefused(409, "method_unavailable", "Microsoft doesn't offer that method for this account.") + if outcome == "invalid_state": + raise SignInRefused(409, "invalid_state", "The sign-in isn't waiting for a method.") + if outcome == "ok": + await self._store.wait_settled(job, version, self.METHOD_WAIT) + return await self._responder.respond(job) diff --git a/src/application/commands/complete_browser_login_command.py b/src/application/commands/complete_browser_login_command.py new file mode 100644 index 0000000..d397196 --- /dev/null +++ b/src/application/commands/complete_browser_login_command.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_browser_sign_in import IBrowserSignIn +from src.application.dtos.device_poll_response import DevicePollResponse +from src.application.services.linked_identity_reader import LinkedIdentityReader + + +@dataclass +class CompleteBrowserLoginCommand(ICommand[DevicePollResponse]): + code: str = field(repr=False) + code_verifier: str = field(repr=False) + + +class CompleteBrowserLoginHandler(ICommandHandler[CompleteBrowserLoginCommand, DevicePollResponse]): + def __init__(self, browser: IBrowserSignIn, identities: LinkedIdentityReader): + self._browser = browser + self._identities = identities + + async def handle(self, command: CompleteBrowserLoginCommand) -> DevicePollResponse: + result = await self._browser.exchange_code(command.code, command.code_verifier) + + if not result.get("access_token") or not result.get("refresh_token"): + return DevicePollResponse(status="error", message=result.get("error_description") or result.get("error") or "sign-in failed") + refresh_token = result["refresh_token"] + account = await self._identities.read(refresh_token) + return DevicePollResponse(status="complete", refresh_token=refresh_token, account=account) diff --git a/src/application/commands/convert_to_pdf_command.py b/src/application/commands/convert_to_pdf_command.py new file mode 100644 index 0000000..deabe16 --- /dev/null +++ b/src/application/commands/convert_to_pdf_command.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_pdf_converter import IPdfConverter +from src.application.abstractions.i_request_body import IRequestBody +from src.application.services.content_names import ContentNames +from src.domain.errors.service_error import ServiceError +from src.domain.files.file_limits import FileLimits +from src.domain.files.spooled_pdf import SpooledPdf +from src.domain.validation.refresh_token_format import RefreshTokenFormat + + +@dataclass +class ConvertToPdfCommand(ICommand[SpooledPdf]): + refresh_token: str = field(repr=False) + name: str + body: IRequestBody + + +class ConvertToPdfHandler(ICommandHandler[ConvertToPdfCommand, SpooledPdf]): + def __init__(self, accounts: IAccountRunner, pdf: IPdfConverter): + self._accounts = accounts + self._pdf = pdf + + async def handle(self, command: ConvertToPdfCommand) -> SpooledPdf: + RefreshTokenFormat.check(command.refresh_token) + safe = ContentNames.safe_name(command.name) + if not self._pdf.is_convertible(safe): + raise ServiceError(415, "This file type cannot be converted to PDF.") + declared = command.body.declared_length + if declared is not None and (not declared.isdigit() or int(declared) > FileLimits.MAX_SEND_BYTES): + raise ServiceError(413, "The file is larger than the conversion limit.") + data = await command.body.read(FileLimits.MAX_SEND_BYTES) + if not data: + raise ServiceError(400, "The body is empty.") + return await self._accounts.run(command.refresh_token, lambda note, broker: SpooledPdf(safe, self._pdf.bytes_pdf(note, broker, data, safe))) diff --git a/src/application/commands/convert_to_text_command.py b/src/application/commands/convert_to_text_command.py new file mode 100644 index 0000000..5debc05 --- /dev/null +++ b/src/application/commands/convert_to_text_command.py @@ -0,0 +1,61 @@ +import posixpath +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_file_text_extractor import IFileTextExtractor +from src.application.abstractions.i_pdf_converter import IPdfConverter +from src.application.abstractions.i_request_body import IRequestBody +from src.application.services.content_names import ContentNames +from src.domain.errors.service_error import ServiceError +from src.domain.files.file_limits import FileLimits +from src.domain.files.file_text_limits import FileTextLimits +from src.domain.validation.refresh_token_format import RefreshTokenFormat + + +@dataclass +class ConvertToTextCommand(ICommand[dict]): + refresh_token: str = field(repr=False) + name: str + offset: int + max_chars: int + sheet: str | None + row_offset: int + max_rows: int + body: IRequestBody + + +class ConvertToTextHandler(ICommandHandler[ConvertToTextCommand, dict]): + def __init__(self, accounts: IAccountRunner, pdf: IPdfConverter, text: IFileTextExtractor): + self._accounts = accounts + self._pdf = pdf + self._text = text + + async def handle(self, command: ConvertToTextCommand) -> dict: + RefreshTokenFormat.check(command.refresh_token) + safe = ContentNames.safe_name(command.name) + if self._text.kind_of(safe) is None: + raise ServiceError(415, FileTextLimits.UNSUPPORTED) + declared = command.body.declared_length + if declared is not None and (not declared.isdigit() or int(declared) > FileLimits.MAX_TEXT_SOURCE_BYTES): + raise ServiceError(413, "The file is larger than 25 MB.") + data = await command.body.read(FileLimits.MAX_TEXT_SOURCE_BYTES) + if not data: + raise ServiceError(400, "The body is empty.") + + def run(note, broker): + notes = [] + if self._text.kind_of(safe) == "legacy": + spooled = self._pdf.bytes_pdf(note, broker, data, safe) + try: + pdf = spooled.read(self._text.MAX_SOURCE_BYTES + 1) + finally: + spooled.close() + notes.append("Converted to PDF by Microsoft 365 before reading.") + result = self._text.extract(posixpath.splitext(safe)[0] + ".pdf", pdf, command.offset, command.max_chars, command.sheet, command.row_offset, command.max_rows, notes) + result["name"] = safe + return result + return self._text.extract(safe, data, command.offset, command.max_chars, command.sheet, command.row_offset, command.max_rows, notes) + + return await self._accounts.run(command.refresh_token, run) diff --git a/src/application/commands/create_mail_draft_command.py b/src/application/commands/create_mail_draft_command.py new file mode 100644 index 0000000..e39d0b0 --- /dev/null +++ b/src/application/commands/create_mail_draft_command.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.dtos.mail_message_in import MailMessageIn +from src.application.services.mail_message_builder import MailMessageBuilder + + +@dataclass +class CreateMailDraftCommand(ICommand[dict]): + refresh_token: str = field(repr=False) + body: MailMessageIn + + +class CreateMailDraftHandler(ICommandHandler[CreateMailDraftCommand, dict]): + def __init__(self, accounts: IAccountRunner, builder: MailMessageBuilder): + self._accounts = accounts + self._builder = builder + + async def handle(self, command: CreateMailDraftCommand) -> dict: + args = self._builder.message_args(command.body) + return await self._accounts.run(command.refresh_token, lambda note, broker: note.create_draft(**args)) diff --git a/src/application/commands/create_one_note_page_command.py b/src/application/commands/create_one_note_page_command.py new file mode 100644 index 0000000..4cf4d88 --- /dev/null +++ b/src/application/commands/create_one_note_page_command.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_one_note import IOneNote +from src.application.abstractions.i_one_note_runner import IOneNoteRunner +from src.application.dtos.one_note_page_create import OneNotePageCreate +from src.application.services.mail_message_builder import MailMessageBuilder +from src.domain.errors.service_error import ServiceError +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class CreateOneNotePageCommand(ICommand[dict]): + refresh_token: str = field(repr=False) + section_id: str + body: OneNotePageCreate + + +class CreateOneNotePageHandler(ICommandHandler[CreateOneNotePageCommand, dict]): + def __init__(self, runner: IOneNoteRunner, onenote: IOneNote, builder: MailMessageBuilder): + self._runner = runner + self._onenote = onenote + self._builder = builder + + async def handle(self, command: CreateOneNotePageCommand) -> dict: + GraphIds.check(command.section_id, self._onenote.ID_PATTERN, "section_id") + if not self._onenote.is_id(command.section_id): + raise ServiceError(400, "Invalid section_id.") + self._builder.check_text(command.body.html or "", "html") + return await self._runner.run(command.refresh_token, lambda note, broker: self._onenote.create_page(note, broker, command.section_id, command.body.title, command.body.html)) diff --git a/src/application/commands/delete_mail_command.py b/src/application/commands/delete_mail_command.py new file mode 100644 index 0000000..f925b37 --- /dev/null +++ b/src/application/commands/delete_mail_command.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class DeleteMailCommand(ICommand[None]): + refresh_token: str = field(repr=False) + message_id: str + + +class DeleteMailHandler(ICommandHandler[DeleteMailCommand, None]): + def __init__(self, accounts: IAccountRunner): + self._accounts = accounts + + async def handle(self, command: DeleteMailCommand) -> None: + GraphIds.check(command.message_id, GraphIds.GRAPH_ID_PATTERN, "message_id") + await self._accounts.run(command.refresh_token, lambda note, broker: note.delete_email(GraphIds.seg(command.message_id))) diff --git a/src/application/commands/delete_one_note_page_command.py b/src/application/commands/delete_one_note_page_command.py new file mode 100644 index 0000000..adc6066 --- /dev/null +++ b/src/application/commands/delete_one_note_page_command.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_one_note import IOneNote +from src.application.abstractions.i_one_note_runner import IOneNoteRunner +from src.domain.errors.service_error import ServiceError +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class DeleteOneNotePageCommand(ICommand[None]): + refresh_token: str = field(repr=False) + page_id: str + + +class DeleteOneNotePageHandler(ICommandHandler[DeleteOneNotePageCommand, None]): + def __init__(self, runner: IOneNoteRunner, onenote: IOneNote): + self._runner = runner + self._onenote = onenote + + async def handle(self, command: DeleteOneNotePageCommand) -> None: + GraphIds.check(command.page_id, self._onenote.ID_PATTERN, "page_id") + if not self._onenote.is_id(command.page_id): + raise ServiceError(400, "Invalid page_id.") + await self._runner.run(command.refresh_token, lambda note, broker: self._onenote.delete_page(note, broker, command.page_id)) diff --git a/src/application/commands/flag_mail_command.py b/src/application/commands/flag_mail_command.py new file mode 100644 index 0000000..707d702 --- /dev/null +++ b/src/application/commands/flag_mail_command.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class FlagMailCommand(ICommand[dict]): + refresh_token: str = field(repr=False) + message_id: str + status: str + + +class FlagMailHandler(ICommandHandler[FlagMailCommand, dict]): + def __init__(self, accounts: IAccountRunner): + self._accounts = accounts + + async def handle(self, command: FlagMailCommand) -> dict: + GraphIds.check(command.message_id, GraphIds.GRAPH_ID_PATTERN, "message_id") + return await self._accounts.run(command.refresh_token, lambda note, broker: note.flag(GraphIds.seg(command.message_id), command.status) or {"flag": {"flagStatus": command.status}}) diff --git a/src/application/commands/forward_mail_command.py b/src/application/commands/forward_mail_command.py new file mode 100644 index 0000000..c503ba6 --- /dev/null +++ b/src/application/commands/forward_mail_command.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.services.mail_message_builder import MailMessageBuilder +from src.domain.errors.service_error import ServiceError +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class ForwardMailCommand(ICommand[None]): + refresh_token: str = field(repr=False) + message_id: str + to: list[str] + comment: str + + +class ForwardMailHandler(ICommandHandler[ForwardMailCommand, None]): + def __init__(self, accounts: IAccountRunner, builder: MailMessageBuilder): + self._accounts = accounts + self._builder = builder + + async def handle(self, command: ForwardMailCommand) -> None: + GraphIds.check(command.message_id, GraphIds.GRAPH_ID_PATTERN, "message_id") + self._builder.check_text(command.comment, "comment") + to = self._builder.addresses(command.to, "to") + if not to: + raise ServiceError(400, "At least one recipient is required.") + if len(to) > self._builder.MAX_RECIPIENTS: + raise ServiceError(400, f"At most {self._builder.MAX_RECIPIENTS} recipients are allowed.") + await self._accounts.run(command.refresh_token, lambda note, broker: note.forward(GraphIds.seg(command.message_id), to, command.comment)) diff --git a/src/application/commands/mark_chat_read_command.py b/src/application/commands/mark_chat_read_command.py new file mode 100644 index 0000000..a459e7d --- /dev/null +++ b/src/application/commands/mark_chat_read_command.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class MarkChatReadCommand(ICommand[None]): + refresh_token: str = field(repr=False) + chat_id: str + + +class MarkChatReadHandler(ICommandHandler[MarkChatReadCommand, None]): + def __init__(self, accounts: IAccountRunner): + self._accounts = accounts + + async def handle(self, command: MarkChatReadCommand) -> None: + GraphIds.check(command.chat_id, GraphIds.CHAT_ID_PATTERN, "chat_id") + await self._accounts.run(command.refresh_token, lambda note, broker: note.mark_chat_read(command.chat_id)) diff --git a/src/application/commands/mark_mail_read_command.py b/src/application/commands/mark_mail_read_command.py new file mode 100644 index 0000000..f5ae83d --- /dev/null +++ b/src/application/commands/mark_mail_read_command.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class MarkMailReadCommand(ICommand[dict]): + refresh_token: str = field(repr=False) + message_id: str + read: bool + + +class MarkMailReadHandler(ICommandHandler[MarkMailReadCommand, dict]): + def __init__(self, accounts: IAccountRunner): + self._accounts = accounts + + async def handle(self, command: MarkMailReadCommand) -> dict: + GraphIds.check(command.message_id, GraphIds.GRAPH_ID_PATTERN, "message_id") + return await self._accounts.run(command.refresh_token, lambda note, broker: note.mark_read(GraphIds.seg(command.message_id), command.read) or {"isRead": command.read}) diff --git a/src/application/commands/move_mail_command.py b/src/application/commands/move_mail_command.py new file mode 100644 index 0000000..7ac631d --- /dev/null +++ b/src/application/commands/move_mail_command.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class MoveMailCommand(ICommand[dict]): + refresh_token: str = field(repr=False) + message_id: str + folder: str + + +class MoveMailHandler(ICommandHandler[MoveMailCommand, dict]): + def __init__(self, accounts: IAccountRunner): + self._accounts = accounts + + async def handle(self, command: MoveMailCommand) -> dict: + GraphIds.check(command.message_id, GraphIds.GRAPH_ID_PATTERN, "message_id") + GraphIds.check(command.folder, GraphIds.GRAPH_ID_PATTERN, "folder") + return await self._accounts.run(command.refresh_token, lambda note, broker: note.move_email(GraphIds.seg(command.message_id), GraphIds.seg(command.folder))) diff --git a/src/application/commands/poll_device_login_command.py b/src/application/commands/poll_device_login_command.py new file mode 100644 index 0000000..97e8399 --- /dev/null +++ b/src/application/commands/poll_device_login_command.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_device_login import IDeviceLogin +from src.application.dtos.device_poll_response import DevicePollResponse +from src.application.services.linked_identity_reader import LinkedIdentityReader + + +@dataclass +class PollDeviceLoginCommand(ICommand[DevicePollResponse]): + device_code: str = field(repr=False) + + +class PollDeviceLoginHandler(ICommandHandler[PollDeviceLoginCommand, DevicePollResponse]): + def __init__(self, device: IDeviceLogin, identities: LinkedIdentityReader): + self._device = device + self._identities = identities + + async def handle(self, command: PollDeviceLoginCommand) -> DevicePollResponse: + result = await self._device.poll_device_login(command.device_code) + + if result.get("access_token"): + refresh_token = result.get("refresh_token") + if not refresh_token: + return DevicePollResponse(status="error", message="No refresh token in the response.") + account = await self._identities.read(refresh_token) + return DevicePollResponse(status="complete", refresh_token=refresh_token, account=account) + + error = result.get("error") + if error in ("authorization_pending", "slow_down"): + return DevicePollResponse(status="pending", message=error) + return DevicePollResponse(status="error", message=result.get("error_description") or error or "sign-in failed") diff --git a/src/application/commands/reply_mail_command.py b/src/application/commands/reply_mail_command.py new file mode 100644 index 0000000..8e70430 --- /dev/null +++ b/src/application/commands/reply_mail_command.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.services.mail_message_builder import MailMessageBuilder +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class ReplyMailCommand(ICommand[None]): + refresh_token: str = field(repr=False) + message_id: str + comment: str + reply_all: bool + + +class ReplyMailHandler(ICommandHandler[ReplyMailCommand, None]): + def __init__(self, accounts: IAccountRunner, builder: MailMessageBuilder): + self._accounts = accounts + self._builder = builder + + async def handle(self, command: ReplyMailCommand) -> None: + GraphIds.check(command.message_id, GraphIds.GRAPH_ID_PATTERN, "message_id") + self._builder.check_text(command.comment, "comment") + await self._accounts.run(command.refresh_token, lambda note, broker: note.reply(GraphIds.seg(command.message_id), command.comment, reply_all=command.reply_all)) diff --git a/src/application/commands/schedule_chat_meeting_command.py b/src/application/commands/schedule_chat_meeting_command.py new file mode 100644 index 0000000..94f3435 --- /dev/null +++ b/src/application/commands/schedule_chat_meeting_command.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass, field +from datetime import timezone + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_chat_sender import IChatSender +from src.application.abstractions.i_profile_reader import IProfileReader +from src.application.dtos.meeting_request import MeetingRequest +from src.domain.errors.service_error import ServiceError +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class ScheduleChatMeetingCommand(ICommand[dict]): + refresh_token: str = field(repr=False) + chat_id: str + body: MeetingRequest + + +class ScheduleChatMeetingHandler(ICommandHandler[ScheduleChatMeetingCommand, dict]): + @staticmethod + def _utc(value) -> str: + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc).replace(tzinfo=None).isoformat() + + def __init__(self, accounts: IAccountRunner, sender: IChatSender, profile: IProfileReader): + self._accounts = accounts + self._sender = sender + self._profile = profile + + async def handle(self, command: ScheduleChatMeetingCommand) -> dict: + GraphIds.check(command.chat_id, GraphIds.CHAT_ID_PATTERN, "chat_id") + body = command.body + if body.end <= body.start: + raise ServiceError(400, "end must be after start.") + + def create(note, broker): + members = [] if command.chat_id == self._sender.SELF_CHAT else note.list(f"chats/{command.chat_id}/members") + me = self._profile.get(note, broker) + mine = {(me.get("mail") or "").lower(), (me.get("userPrincipalName") or "").lower()} + attendees = [{"emailAddress": {"address": m["email"], "name": m.get("displayName") or m["email"]}, "type": "required"} for m in members if m.get("email") and m["email"].lower() not in mine] + event = { + "subject": body.subject, + "start": {"dateTime": self._utc(body.start), "timeZone": "UTC"}, + "end": {"dateTime": self._utc(body.end), "timeZone": "UTC"}, + "isOnlineMeeting": True, + "onlineMeetingProvider": "teamsForBusiness", + "attendees": attendees, + } + if body.description: + event["body"] = {"contentType": "text", "content": body.description} + created = note.post("me/events", event) + return {"id": created.get("id"), "subject": created.get("subject"), "start": created.get("start"), "end": created.get("end"), "webLink": created.get("webLink"), "joinUrl": (created.get("onlineMeeting") or {}).get("joinUrl"), "attendees": [a["emailAddress"] for a in attendees]} + + return await self._accounts.run(command.refresh_token, create) diff --git a/src/application/commands/send_chat_message_command.py b/src/application/commands/send_chat_message_command.py new file mode 100644 index 0000000..8410123 --- /dev/null +++ b/src/application/commands/send_chat_message_command.py @@ -0,0 +1,61 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_chat_message_body import IChatMessageBody +from src.application.abstractions.i_chat_reader import IChatReader +from src.application.abstractions.i_chat_sender import IChatSender +from src.domain.chat.outgoing_message import OutgoingMessage +from src.domain.errors.service_error import ServiceError +from src.domain.files.file_limits import FileLimits +from src.domain.validation.graph_ids import GraphIds +from src.domain.validation.refresh_token_format import RefreshTokenFormat + + +@dataclass +class SendChatMessageCommand(ICommand[dict]): + refresh_token: str = field(repr=False) + chat_id: str + body: IChatMessageBody + + +class SendChatMessageHandler(ICommandHandler[SendChatMessageCommand, dict]): + def _outgoing(self, fields: dict, images: list, files: list) -> OutgoingMessage: + content = fields.get("content") + if content is None: + content = fields.get("text") + mentions = fields.get("mentions") or [] + gif = fields.get("gif") + if not isinstance(content, (str, type(None))) or not isinstance(mentions, list) or not isinstance(gif, (dict, type(None))): + raise ServiceError(400, "Invalid message fields.") + content_type, importance = fields.get("contentType") or "text", fields.get("importance") or None + if not isinstance(content_type, str) or not isinstance(importance, (str, type(None))): + raise ServiceError(400, "Invalid message fields.") + msg = OutgoingMessage(content=content or "", content_type=content_type, importance=importance, mentions=mentions, gif=gif, images=images, files=files) + return self._sender.validate(msg) + + def __init__(self, accounts: IAccountRunner, sender: IChatSender, reader: IChatReader): + self._accounts = accounts + self._sender = sender + self._reader = reader + + async def handle(self, command: SendChatMessageCommand) -> dict: + GraphIds.check(command.chat_id, GraphIds.CHAT_ID_PATTERN, "chat_id") + RefreshTokenFormat.check(command.refresh_token) + body = command.body + cap = FileLimits.MAX_SEND_BYTES if body.is_multipart else FileLimits.MAX_JSON_SEND_BYTES + declared = body.declared_length + if declared is not None and (not declared.isdigit() or int(declared) > cap): + raise ServiceError(413, "The message is larger than the send limit.") + try: + fields, images, files = await body.read(cap) + msg = self._outgoing(fields, images, files) + + def send(note, broker): + created = self._sender.send(note, broker, command.chat_id, msg) + return self._reader.enrich(note, broker, [created or {}])[0] + + return await self._accounts.run(command.refresh_token, send) + finally: + await body.close() diff --git a/src/application/commands/send_mail_command.py b/src/application/commands/send_mail_command.py new file mode 100644 index 0000000..b98bc5a --- /dev/null +++ b/src/application/commands/send_mail_command.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.dtos.mail_message_in import MailMessageIn +from src.application.services.mail_message_builder import MailMessageBuilder + + +@dataclass +class SendMailCommand(ICommand[None]): + refresh_token: str = field(repr=False) + body: MailMessageIn + + +class SendMailHandler(ICommandHandler[SendMailCommand, None]): + def __init__(self, accounts: IAccountRunner, builder: MailMessageBuilder): + self._accounts = accounts + self._builder = builder + + async def handle(self, command: SendMailCommand) -> None: + args = self._builder.message_args(command.body) + await self._accounts.run(command.refresh_token, lambda note, broker: note.send_mail(**args)) diff --git a/src/application/commands/send_mail_draft_command.py b/src/application/commands/send_mail_draft_command.py new file mode 100644 index 0000000..0c8cedc --- /dev/null +++ b/src/application/commands/send_mail_draft_command.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class SendMailDraftCommand(ICommand[None]): + refresh_token: str = field(repr=False) + message_id: str + + +class SendMailDraftHandler(ICommandHandler[SendMailDraftCommand, None]): + def __init__(self, accounts: IAccountRunner): + self._accounts = accounts + + async def handle(self, command: SendMailDraftCommand) -> None: + GraphIds.check(command.message_id, GraphIds.GRAPH_ID_PATTERN, "message_id") + await self._accounts.run(command.refresh_token, lambda note, broker: note.send_draft(GraphIds.seg(command.message_id))) diff --git a/src/application/commands/start_browser_login_command.py b/src/application/commands/start_browser_login_command.py new file mode 100644 index 0000000..df45d8e --- /dev/null +++ b/src/application/commands/start_browser_login_command.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_browser_sign_in import IBrowserSignIn +from src.application.dtos.browser_start_response import BrowserStartResponse + + +@dataclass +class StartBrowserLoginCommand(ICommand[BrowserStartResponse]): + pass + + +class StartBrowserLoginHandler(ICommandHandler[StartBrowserLoginCommand, BrowserStartResponse]): + def __init__(self, browser: IBrowserSignIn): + self._browser = browser + + async def handle(self, command: StartBrowserLoginCommand) -> BrowserStartResponse: + url, verifier = self._browser.build_authorize_url() + return BrowserStartResponse(authorize_url=url, redirect_uri=self._browser.NATIVE_REDIRECT, code_verifier=verifier) diff --git a/src/application/commands/start_device_login_command.py b/src/application/commands/start_device_login_command.py new file mode 100644 index 0000000..7c05a7b --- /dev/null +++ b/src/application/commands/start_device_login_command.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_device_login import IDeviceLogin +from src.application.dtos.device_start_response import DeviceStartResponse + + +@dataclass +class StartDeviceLoginCommand(ICommand[DeviceStartResponse]): + pass + + +class StartDeviceLoginHandler(ICommandHandler[StartDeviceLoginCommand, DeviceStartResponse]): + def __init__(self, device: IDeviceLogin): + self._device = device + + async def handle(self, command: StartDeviceLoginCommand) -> DeviceStartResponse: + data = await self._device.start_device_login() + return DeviceStartResponse( + user_code=data["user_code"], + verification_uri=data.get("verification_uri") or data.get("verification_url"), + device_code=data["device_code"], + interval=data.get("interval", 5), + expires_in=data.get("expires_in", 900), + message=data.get("message"), + ) diff --git a/src/application/commands/start_password_sign_in_command.py b/src/application/commands/start_password_sign_in_command.py new file mode 100644 index 0000000..ff73b6f --- /dev/null +++ b/src/application/commands/start_password_sign_in_command.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler +from pydantic import SecretStr + +from src.application.abstractions.i_sign_in_store import ISignInStore +from src.application.dtos.password_sign_in_response import PasswordSignInResponse +from src.application.services.password_sign_in_responder import PasswordSignInResponder +from src.domain.errors.rate_limited import RateLimited +from src.domain.errors.sign_in_refused import SignInRefused +from src.domain.sign_in.sign_in_state import SignInState + + +@dataclass +class StartPasswordSignInCommand(ICommand[PasswordSignInResponse]): + user_id: str + username: str + password: SecretStr | None = field(repr=False) + + +class StartPasswordSignInHandler(ICommandHandler[StartPasswordSignInCommand, PasswordSignInResponse]): + SIGN_IN_WAIT = 60 # seconds /start waits for the sign-in to finish or reach the method choice + + def __init__(self, store: ISignInStore, responder: PasswordSignInResponder): + self._store = store + self._responder = responder + + async def handle(self, command: StartPasswordSignInCommand) -> PasswordSignInResponse: + password, command.password = command.password, None # the command no longer references the password + try: + job = self._store.start(command.user_id, command.username, password) + except RateLimited as ex: + raise SignInRefused(429, "rate_limited", str(ex)) + finally: + del password + await self._store.wait_settled(job, -1, self.SIGN_IN_WAIT) + snap = job.snapshot() + if snap["state"] != SignInState.WORKING and snap.get("code") != "invalid_credentials": + self._store.promote(job) # the password passed: this replaces the user's earlier attempt + return await self._responder.respond(job) diff --git a/src/application/commands/submit_password_otp_command.py b/src/application/commands/submit_password_otp_command.py new file mode 100644 index 0000000..6291fec --- /dev/null +++ b/src/application/commands/submit_password_otp_command.py @@ -0,0 +1,37 @@ +import re +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_sign_in_store import ISignInStore +from src.application.dtos.password_sign_in_response import PasswordSignInResponse +from src.application.services.password_sign_in_responder import PasswordSignInResponder +from src.domain.errors.sign_in_refused import SignInRefused + + +@dataclass +class SubmitPasswordOtpCommand(ICommand[PasswordSignInResponse]): + user_id: str + sign_in_id: str + code: str = field(repr=False) + + +class SubmitPasswordOtpHandler(ICommandHandler[SubmitPasswordOtpCommand, PasswordSignInResponse]): + OTP_WAIT = 45 # seconds /otp waits for Microsoft's verdict + + def __init__(self, store: ISignInStore, responder: PasswordSignInResponder): + self._store = store + self._responder = responder + + async def handle(self, command: SubmitPasswordOtpCommand) -> PasswordSignInResponse: + job = self._store.get(command.sign_in_id, command.user_id) + if job is None: + raise SignInRefused(404, "not_found", "Unknown or expired sign-in.") + code = re.sub(r"[\s-]", "", command.code) + if not code.isdigit() or not 6 <= len(code) <= 8: + raise SignInRefused(422, "invalid_request", "The code must be 6 digits.") + version = job.snapshot()["version"] + if not job.submit_code(code): + raise SignInRefused(409, "invalid_state", "The sign-in isn't waiting for a code.") + await self._store.wait_settled(job, version, self.OTP_WAIT) + return await self._responder.respond(job) diff --git a/src/application/commands/update_one_note_page_command.py b/src/application/commands/update_one_note_page_command.py new file mode 100644 index 0000000..7d4f4fd --- /dev/null +++ b/src/application/commands/update_one_note_page_command.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass, field + +from mediatorx import ICommand, ICommandHandler + +from src.application.abstractions.i_one_note import IOneNote +from src.application.abstractions.i_one_note_runner import IOneNoteRunner +from src.application.dtos.one_note_page_update import OneNotePageUpdate +from src.application.services.mail_message_builder import MailMessageBuilder +from src.application.services.onenote_html_sanitizer import OneNoteHtmlSanitizer +from src.domain.errors.service_error import ServiceError +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class UpdateOneNotePageCommand(ICommand[dict]): + refresh_token: str = field(repr=False) + page_id: str + body: OneNotePageUpdate + + +class UpdateOneNotePageHandler(ICommandHandler[UpdateOneNotePageCommand, dict]): + def __init__(self, runner: IOneNoteRunner, onenote: IOneNote, builder: MailMessageBuilder, html: OneNoteHtmlSanitizer): + self._runner = runner + self._onenote = onenote + self._builder = builder + self._html = html + + async def handle(self, command: UpdateOneNotePageCommand) -> dict: + GraphIds.check(command.page_id, self._onenote.ID_PATTERN, "page_id") + if not self._onenote.is_id(command.page_id): + raise ServiceError(400, "Invalid page_id.") + body = command.body + outlines = [{"id": o.id, "html": o.html} for o in body.outlines or []] + for item in outlines: + GraphIds.check(item["id"], self._html.SAFE_ID, "outline id") + self._builder.check_text((body.append or "") + "".join(o["html"] for o in outlines), "html") + return await self._runner.run(command.refresh_token, lambda note, broker: self._onenote.update_page(note, broker, command.page_id, body.title, outlines, body.append)) diff --git a/src/application/dtos/browser_complete_request.py b/src/application/dtos/browser_complete_request.py new file mode 100644 index 0000000..34e4519 --- /dev/null +++ b/src/application/dtos/browser_complete_request.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class BrowserCompleteRequest(BaseModel): + code: str + code_verifier: str diff --git a/src/application/dtos/browser_start_response.py b/src/application/dtos/browser_start_response.py new file mode 100644 index 0000000..0bcd59f --- /dev/null +++ b/src/application/dtos/browser_start_response.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel, Field + + +class BrowserStartResponse(BaseModel): + authorize_url: str = Field(..., description="Open in a WebView; wait for the redirect to redirect_uri carrying ?code=.") + redirect_uri: str = Field(..., description="The URL the code comes back on; the app watches for it and then closes the WebView.") + code_verifier: str = Field(..., description="Opaque PKCE value; the caller holds it and sends it to /complete.") diff --git a/src/application/dtos/device_poll_request.py b/src/application/dtos/device_poll_request.py new file mode 100644 index 0000000..6aeaebd --- /dev/null +++ b/src/application/dtos/device_poll_request.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class DevicePollRequest(BaseModel): + device_code: str diff --git a/src/application/dtos/device_poll_response.py b/src/application/dtos/device_poll_response.py new file mode 100644 index 0000000..eda144a --- /dev/null +++ b/src/application/dtos/device_poll_response.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, Field + +from src.application.dtos.linked_identity import LinkedIdentity + + +class DevicePollResponse(BaseModel): + status: str = Field(..., description="pending | complete | error") + refresh_token: str | None = None + account: LinkedIdentity | None = None + message: str | None = None diff --git a/src/application/dtos/device_start_response.py b/src/application/dtos/device_start_response.py new file mode 100644 index 0000000..841ebaf --- /dev/null +++ b/src/application/dtos/device_start_response.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, Field + + +class DeviceStartResponse(BaseModel): + user_code: str = Field(..., description="Shown to the user; they enter it at verification_uri.") + verification_uri: str + device_code: str = Field(..., description="Opaque; the caller holds it and sends it to /poll.") + interval: int = 5 + expires_in: int = 900 + message: str | None = None diff --git a/src/application/dtos/files_search_request.py b/src/application/dtos/files_search_request.py new file mode 100644 index 0000000..2a3b5b4 --- /dev/null +++ b/src/application/dtos/files_search_request.py @@ -0,0 +1,12 @@ +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints + + +class FilesSearchRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + q: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=200)] + start: int = Field(0, ge=0, le=5000, alias="from") + size: int = Field(25, ge=1, le=50) + teamId: str | None = Field(None, max_length=36) diff --git a/src/application/dtos/linked_identity.py b/src/application/dtos/linked_identity.py new file mode 100644 index 0000000..cdb5918 --- /dev/null +++ b/src/application/dtos/linked_identity.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class LinkedIdentity(BaseModel): + id: str | None = None + email: str | None = None + name: str | None = None diff --git a/src/application/dtos/mail_attachment_in.py b/src/application/dtos/mail_attachment_in.py new file mode 100644 index 0000000..07f28b7 --- /dev/null +++ b/src/application/dtos/mail_attachment_in.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class MailAttachmentIn(BaseModel): + name: str + contentType: str | None = None + contentBase64: str diff --git a/src/application/dtos/mail_flag_request.py b/src/application/dtos/mail_flag_request.py new file mode 100644 index 0000000..4af43b2 --- /dev/null +++ b/src/application/dtos/mail_flag_request.py @@ -0,0 +1,7 @@ +from typing import Literal + +from pydantic import BaseModel + + +class MailFlagRequest(BaseModel): + status: Literal["flagged", "complete", "notFlagged"] diff --git a/src/application/dtos/mail_forward_request.py b/src/application/dtos/mail_forward_request.py new file mode 100644 index 0000000..b7eebbe --- /dev/null +++ b/src/application/dtos/mail_forward_request.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class MailForwardRequest(BaseModel): + to: list[str] + comment: str = "" diff --git a/src/application/dtos/mail_message_in.py b/src/application/dtos/mail_message_in.py new file mode 100644 index 0000000..135570b --- /dev/null +++ b/src/application/dtos/mail_message_in.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, Field + +from src.application.dtos.mail_attachment_in import MailAttachmentIn + + +class MailMessageIn(BaseModel): + to: list[str] = Field(default_factory=list) + cc: list[str] | None = None + bcc: list[str] | None = None + subject: str + body: str + html: bool = False + attachments: list[MailAttachmentIn] | None = None diff --git a/src/application/dtos/mail_move_request.py b/src/application/dtos/mail_move_request.py new file mode 100644 index 0000000..a36ed11 --- /dev/null +++ b/src/application/dtos/mail_move_request.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class MailMoveRequest(BaseModel): + folder: str diff --git a/src/application/dtos/mail_read_request.py b/src/application/dtos/mail_read_request.py new file mode 100644 index 0000000..4b16e10 --- /dev/null +++ b/src/application/dtos/mail_read_request.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class MailReadRequest(BaseModel): + read: bool diff --git a/src/application/dtos/mail_reply_request.py b/src/application/dtos/mail_reply_request.py new file mode 100644 index 0000000..c0de0bd --- /dev/null +++ b/src/application/dtos/mail_reply_request.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class MailReplyRequest(BaseModel): + comment: str + replyAll: bool = False diff --git a/src/application/dtos/meeting_request.py b/src/application/dtos/meeting_request.py new file mode 100644 index 0000000..1afb487 --- /dev/null +++ b/src/application/dtos/meeting_request.py @@ -0,0 +1,10 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class MeetingRequest(BaseModel): + subject: str = Field(..., min_length=1, max_length=255) + start: datetime = Field(..., description="ISO 8601 with an offset, e.g. 2026-11-02T14:00:00+01:00.") + end: datetime + description: str | None = Field(None, max_length=4000) diff --git a/src/application/dtos/one_note_outline_update.py b/src/application/dtos/one_note_outline_update.py new file mode 100644 index 0000000..dc769f3 --- /dev/null +++ b/src/application/dtos/one_note_outline_update.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, Field + + +class OneNoteOutlineUpdate(BaseModel): + id: str = Field(..., min_length=1, max_length=200, description="An editable outline id from the page content.") + html: str diff --git a/src/application/dtos/one_note_page_create.py b/src/application/dtos/one_note_page_create.py new file mode 100644 index 0000000..9d814d3 --- /dev/null +++ b/src/application/dtos/one_note_page_create.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, Field + + +class OneNotePageCreate(BaseModel): + title: str = Field(..., min_length=1, max_length=255) + html: str | None = Field(None, description="Rich text for the page body; cut down to the text formatting OneNote keeps.") diff --git a/src/application/dtos/one_note_page_update.py b/src/application/dtos/one_note_page_update.py new file mode 100644 index 0000000..b8071c6 --- /dev/null +++ b/src/application/dtos/one_note_page_update.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, Field + +from src.application.dtos.one_note_outline_update import OneNoteOutlineUpdate + + +class OneNotePageUpdate(BaseModel): + title: str | None = Field(None, min_length=1, max_length=255) + outlines: list[OneNoteOutlineUpdate] | None = Field(None, max_length=50) + append: str | None = Field(None, description="Rich text appended to the page as a new text box.") diff --git a/src/application/dtos/password_method_request.py b/src/application/dtos/password_method_request.py new file mode 100644 index 0000000..cec55db --- /dev/null +++ b/src/application/dtos/password_method_request.py @@ -0,0 +1,7 @@ +from typing import Literal + +from pydantic import BaseModel + + +class PasswordMethodRequest(BaseModel): + method: Literal["push", "otp"] diff --git a/src/application/dtos/password_otp_request.py b/src/application/dtos/password_otp_request.py new file mode 100644 index 0000000..9b79d46 --- /dev/null +++ b/src/application/dtos/password_otp_request.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, Field + + +class PasswordOtpRequest(BaseModel): + code: str = Field(..., min_length=6, max_length=12, description="The authenticator app code (digits; spaces are ignored).") diff --git a/src/application/dtos/password_sign_in_response.py b/src/application/dtos/password_sign_in_response.py new file mode 100644 index 0000000..6e67707 --- /dev/null +++ b/src/application/dtos/password_sign_in_response.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel, Field + +from src.application.dtos.linked_identity import LinkedIdentity + + +class PasswordSignInResponse(BaseModel): + sign_in_id: str | None = None + status: str = Field(..., description="complete | mfa_required | push_pending | otp_required | otp_invalid | denied | timed_out | working | error") + methods: list[str] | None = Field(None, description="Only the methods Microsoft offers for the account: push, otp.") + number: str | None = Field(None, description="push_pending: the number to tap in Microsoft Authenticator.") + expires_in: int | None = Field(None, description="push_pending: seconds left to approve.") + attempts_left: int | None = None + refresh_token: str | None = None + account: LinkedIdentity | None = None + code: str | None = Field(None, description="error: invalid_credentials | unsupported | blocked | rate_limited | failed") + message: str | None = None diff --git a/src/application/dtos/password_start_request.py b/src/application/dtos/password_start_request.py new file mode 100644 index 0000000..f608833 --- /dev/null +++ b/src/application/dtos/password_start_request.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, Field, SecretStr + + +class PasswordStartRequest(BaseModel): + username: str = Field(..., min_length=3, max_length=320) + password: SecretStr = Field(..., min_length=1, max_length=256, description="Used once for this sign-in, held only in memory, never stored, logged or returned.") diff --git a/src/application/dtos/search_request.py b/src/application/dtos/search_request.py new file mode 100644 index 0000000..8bf2589 --- /dev/null +++ b/src/application/dtos/search_request.py @@ -0,0 +1,9 @@ +from typing import Literal + +from pydantic import Field + +from src.application.dtos.files_search_request import FilesSearchRequest + + +class SearchRequest(FilesSearchRequest): + types: list[Literal["files", "mail", "chats", "channels"]] = Field(["files", "mail", "chats", "channels"], min_length=1, max_length=4) diff --git a/src/application/options/emoji_options.py b/src/application/options/emoji_options.py new file mode 100644 index 0000000..5c144b8 --- /dev/null +++ b/src/application/options/emoji_options.py @@ -0,0 +1,15 @@ +import os +from dataclasses import dataclass + + +@dataclass +class EmojiOptions: + """The Teams emoji asset version to start from.""" + + DEFAULT_VERSION = "0f52465a47bf42f299c74a639443f33e" + + version: str + + @classmethod + def from_environment(cls) -> "EmojiOptions": + return cls(version=os.getenv("TEAMS_EMOTICON_VERSION", cls.DEFAULT_VERSION)) diff --git a/src/application/options/giphy_options.py b/src/application/options/giphy_options.py new file mode 100644 index 0000000..2eac499 --- /dev/null +++ b/src/application/options/giphy_options.py @@ -0,0 +1,16 @@ +import os +from dataclasses import dataclass + + +@dataclass +class GiphyOptions: + """Giphy's rating filter, and its key (free from developers.giphy.com), read at each search: without one the search answers 503.""" + + rating: str + + @classmethod + def from_environment(cls) -> "GiphyOptions": + return cls(rating=os.getenv("GIPHY_RATING", "g")) + + def api_key(self) -> str | None: + return os.getenv("GIPHY_API_KEY") or None diff --git a/src/application/options/microsoft_options.py b/src/application/options/microsoft_options.py new file mode 100644 index 0000000..48bb284 --- /dev/null +++ b/src/application/options/microsoft_options.py @@ -0,0 +1,32 @@ +import os +from dataclasses import dataclass + + +@dataclass +class MicrosoftOptions: + """Reaching Microsoft 365 through the FOCI first-party clients. + + No app registration: the device-code sign-in runs against Microsoft's own Office client, which supports the device flow and issues a + FOCI family refresh token that OutNote's broker trades for whatever first-party client each Graph call needs. That client never handles a password. + """ + + device_client_id: str + tenant: str + graph_default: str = "https://graph.microsoft.com/.default" + device_grant: str = "urn:ietf:params:oauth:grant-type:device_code" + + @classmethod + def from_environment(cls) -> "MicrosoftOptions": + return cls(device_client_id=os.getenv("MS_DEVICE_CLIENT_ID", "d3590ed6-52b3-4102-aeff-aad2292ab01c"), tenant=os.getenv("MS_TENANT", "common")) + + @property + def device_scope(self) -> str: + return f"{self.graph_default} offline_access openid profile" + + @property + def devicecode_url(self) -> str: + return f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/devicecode" + + @property + def token_url(self) -> str: + return f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/token" diff --git a/src/application/options/password_sign_in_options.py b/src/application/options/password_sign_in_options.py new file mode 100644 index 0000000..14c437d --- /dev/null +++ b/src/application/options/password_sign_in_options.py @@ -0,0 +1,21 @@ +import os +from dataclasses import dataclass + + +@dataclass +class PasswordSignInOptions: + """Limits of the in-memory password sign-in jobs.""" + + job_ttl: int # hard cap per job, seconds (<= 10 min) + max_jobs: int + client_limit: int # starts per Schuly user per minute + account_limit: int # FAILED password attempts per (Schuly user, account) per 10 minutes + done_ttl: int = 120 # a finished job stays readable this long (a lost response can be re-read) + otp_job_budget: int = 5 # authenticator codes tried per job, however often the method is re-chosen + otp_account_budget: int = 10 # codes per (Schuly user, account) per hour, across jobs + push_timeout: int = 120 + push_interval: float = 1.5 + + @classmethod + def from_environment(cls) -> "PasswordSignInOptions": + return cls(job_ttl=min(int(os.getenv("PASSWORD_SIGNIN_TTL", "600")), 600), max_jobs=int(os.getenv("PASSWORD_SIGNIN_MAX_JOBS", "100")), client_limit=int(os.getenv("PASSWORD_SIGNIN_CLIENT_LIMIT", "30")), account_limit=int(os.getenv("PASSWORD_SIGNIN_ACCOUNT_LIMIT", "6"))) diff --git a/src/application/options/service_auth_options.py b/src/application/options/service_auth_options.py new file mode 100644 index 0000000..ddd0695 --- /dev/null +++ b/src/application/options/service_auth_options.py @@ -0,0 +1,11 @@ +import os + + +class ServiceAuthOptions: + """The password routes take a Microsoft password, so only the Schuly plugin may call them. It sends the shared secret and the signed-in Schuly user's id. With no key configured the routes answer 503 (never open).""" + + SERVICE_KEY_ENV = "SCHULY_SERVICE_KEY" + + @property + def service_key(self) -> str: + return os.getenv(self.SERVICE_KEY_ENV, "") diff --git a/src/application/queries/get_chat_file_pdf_query.py b/src/application/queries/get_chat_file_pdf_query.py new file mode 100644 index 0000000..0f4a68e --- /dev/null +++ b/src/application/queries/get_chat_file_pdf_query.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_file_thumbnails import IFileThumbnails +from src.application.abstractions.i_pdf_converter import IPdfConverter +from src.application.services.chat_file_lookup import ChatFileLookup +from src.application.services.drive_item_checks import DriveItemChecks +from src.application.services.graph_paths import GraphPaths +from src.domain.errors.service_error import ServiceError +from src.domain.files.spooled_pdf import SpooledPdf +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetChatFilePdfQuery(IQuery[SpooledPdf]): + refresh_token: str = field(repr=False) + chat_id: str + message_id: str + attachment_id: str + + +class GetChatFilePdfHandler(IQueryHandler[GetChatFilePdfQuery, SpooledPdf]): + def __init__(self, accounts: IAccountRunner, lookup: ChatFileLookup, thumbnails: IFileThumbnails, pdf: IPdfConverter, checks: DriveItemChecks): + self._accounts = accounts + self._lookup = lookup + self._thumbnails = thumbnails + self._pdf = pdf + self._checks = checks + + async def handle(self, query: GetChatFilePdfQuery) -> SpooledPdf: + GraphIds.check(query.chat_id, GraphIds.CHAT_ID_PATTERN, "chat_id") + GraphIds.check(query.message_id, GraphIds.MESSAGE_ID_PATTERN, "message_id") + GraphIds.check(query.attachment_id, GraphIds.ATTACHMENT_ID_PATTERN, "attachment_id") + + def convert(note, broker): + ref = self._lookup.find(note, query.chat_id, query.message_id, query.attachment_id) + if ref is None: + raise ServiceError(404, "File not found in the message.") + if not self._thumbnails.is_share_url(ref.get("contentUrl")): + raise ServiceError(415, "Only files in SharePoint or OneDrive can be converted.") + item = note.get(GraphPaths.drive_item_path(ref["contentUrl"], "id,name,size,file,parentReference")) + self._checks.check_source(item) + drive_id = (item.get("parentReference") or {}).get("driveId") + if not self._thumbnails.safe_graph_id(drive_id) or not self._thumbnails.safe_graph_id(item.get("id")): + raise ServiceError(404, "File not found.") + return SpooledPdf(item.get("name"), self._pdf.drive_item_pdf(note, item["id"], drive_id)) + + return await self._accounts.run(query.refresh_token, convert) diff --git a/src/application/queries/get_chat_file_query.py b/src/application/queries/get_chat_file_query.py new file mode 100644 index 0000000..5eb294b --- /dev/null +++ b/src/application/queries/get_chat_file_query.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_microsoft_http_clients import IMicrosoftHttpClients +from src.application.abstractions.i_teams_files import ITeamsFiles +from src.application.services.chat_file_lookup import ChatFileLookup +from src.application.services.content_names import ContentNames +from src.application.services.drive_item_checks import DriveItemChecks +from src.application.services.graph_paths import GraphPaths +from src.domain.errors.service_error import ServiceError +from src.domain.files.file_limits import FileLimits +from src.domain.files.file_stream import FileStream +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetChatFileQuery(IQuery[FileStream]): + refresh_token: str = field(repr=False) + chat_id: str + message_id: str + attachment_id: str + + +class GetChatFileHandler(IQueryHandler[GetChatFileQuery, FileStream]): + def __init__(self, accounts: IAccountRunner, lookup: ChatFileLookup, files: ITeamsFiles, http: IMicrosoftHttpClients): + self._accounts = accounts + self._lookup = lookup + self._files = files + self._http = http + + async def handle(self, query: GetChatFileQuery) -> FileStream: + GraphIds.check(query.chat_id, GraphIds.CHAT_ID_PATTERN, "chat_id") + GraphIds.check(query.message_id, GraphIds.MESSAGE_ID_PATTERN, "message_id") + GraphIds.check(query.attachment_id, GraphIds.ATTACHMENT_ID_PATTERN, "attachment_id") + + def resolve(note, broker): + ref = self._lookup.find(note, query.chat_id, query.message_id, query.attachment_id) + if ref is None: + raise ServiceError(404, "File not found in the message.") + item = note.get(GraphPaths.drive_item_path(ref["contentUrl"], "name,size,file,folder,@microsoft.graph.downloadUrl")) + if item.get("folder") is not None: + raise ServiceError(404, DriveItemChecks.FOLDER_DETAIL) + if (item.get("size") or 0) > FileLimits.MAX_CHAT_FILE_BYTES: + raise ServiceError(413, "The file is larger than 100 MB.") + url = self._files.download_link(note, f"shares/{GraphPaths.share_id(ref['contentUrl'])}/driveItem", item) + if not self._files.is_download_url(url): + raise ServiceError(502, "The file has an unexpected download location.") + return item, url + + item, url = await self._accounts.run(query.refresh_token, resolve) + # The download URL is pre-authorized and short-lived; the bytes stream through without being held. + return FileStream(self._http.stream_download(url, FileLimits.MAX_CHAT_FILE_BYTES, follow_redirects=False), ContentNames.safe_content_type((item.get("file") or {}).get("mimeType")), ContentNames.safe_name(item.get("name"))) diff --git a/src/application/queries/get_chat_file_thumbnail_query.py b/src/application/queries/get_chat_file_thumbnail_query.py new file mode 100644 index 0000000..f7e12f1 --- /dev/null +++ b/src/application/queries/get_chat_file_thumbnail_query.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_file_thumbnails import IFileThumbnails +from src.application.services.account_image_reader import AccountImageReader +from src.application.services.chat_file_lookup import ChatFileLookup +from src.domain.files.binary_content import BinaryContent +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetChatFileThumbnailQuery(IQuery[BinaryContent]): + refresh_token: str = field(repr=False) + chat_id: str + message_id: str + attachment_id: str + size: str + + +class GetChatFileThumbnailHandler(IQueryHandler[GetChatFileThumbnailQuery, BinaryContent]): + def __init__(self, images: AccountImageReader, lookup: ChatFileLookup, thumbnails: IFileThumbnails): + self._images = images + self._lookup = lookup + self._thumbnails = thumbnails + + async def handle(self, query: GetChatFileThumbnailQuery) -> BinaryContent: + GraphIds.check(query.chat_id, GraphIds.CHAT_ID_PATTERN, "chat_id") + GraphIds.check(query.message_id, GraphIds.MESSAGE_ID_PATTERN, "message_id") + GraphIds.check(query.attachment_id, GraphIds.ATTACHMENT_ID_PATTERN, "attachment_id") + self._images.check_thumbnail_size(query.size) + + def fetch(note, broker): + ref = self._lookup.find(note, query.chat_id, query.message_id, query.attachment_id) + if ref is None or not self._thumbnails.is_share_url(ref["contentUrl"]): + return None + return self._thumbnails.chat_file_thumbnail(note, broker, ref["contentUrl"], query.size) + + return await self._images.thumbnail(query.refresh_token, fetch) diff --git a/src/application/queries/get_chat_hosted_content_query.py b/src/application/queries/get_chat_hosted_content_query.py new file mode 100644 index 0000000..d0bb61f --- /dev/null +++ b/src/application/queries/get_chat_hosted_content_query.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.services.graph_paths import GraphPaths +from src.application.services.image_sniffer import ImageSniffer +from src.domain.errors.service_error import ServiceError +from src.domain.files.binary_content import BinaryContent +from src.domain.files.file_limits import FileLimits +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetChatHostedContentQuery(IQuery[BinaryContent]): + refresh_token: str = field(repr=False) + chat_id: str + message_id: str + hosted_id: str + + +class GetChatHostedContentHandler(IQueryHandler[GetChatHostedContentQuery, BinaryContent]): + def __init__(self, accounts: IAccountRunner): + self._accounts = accounts + + async def handle(self, query: GetChatHostedContentQuery) -> BinaryContent: + GraphIds.check(query.chat_id, GraphIds.CHAT_ID_PATTERN, "chat_id") + GraphIds.check(query.message_id, GraphIds.MESSAGE_ID_PATTERN, "message_id") + GraphIds.check(query.hosted_id, GraphIds.GRAPH_ID_PATTERN, "hosted_id") + data = await self._accounts.run(query.refresh_token, lambda note, broker: note.get(f"{GraphPaths.message_path(query.chat_id, query.message_id)}/hostedContents/{GraphIds.seg(query.hosted_id)}/$value", raw="bytes")) + mime = ImageSniffer.chat_image((data or b"")[:12]) + if mime is None: + # Only images are proxied; anything else a hosted content could hold is refused. + raise ServiceError(415, "The hosted content is not a PNG, JPEG, GIF or WEBP image.") + if len(data) > FileLimits.MAX_DOWNLOAD_BYTES: + raise ServiceError(413, "The image is larger than 25 MB.") + return BinaryContent(data, mime) diff --git a/src/application/queries/get_chat_photo_query.py b/src/application/queries/get_chat_photo_query.py new file mode 100644 index 0000000..ba6c898 --- /dev/null +++ b/src/application/queries/get_chat_photo_query.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_teams_chat import ITeamsChat +from src.application.services.account_image_reader import AccountImageReader +from src.domain.errors.chat_service_error import ChatServiceError +from src.domain.files.binary_content import BinaryContent +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetChatPhotoQuery(IQuery[BinaryContent]): + refresh_token: str = field(repr=False) + chat_id: str + + +class GetChatPhotoHandler(IQueryHandler[GetChatPhotoQuery, BinaryContent]): + def __init__(self, images: AccountImageReader, teams_chat: ITeamsChat): + self._images = images + self._teams_chat = teams_chat + + async def handle(self, query: GetChatPhotoQuery) -> BinaryContent: + GraphIds.check(query.chat_id, GraphIds.CHAT_ID_PATTERN, "chat_id") + + def fetch(note, broker): + try: + return self._teams_chat.picture(broker, query.chat_id) + except ChatServiceError as ex: + if ex.status in (401, 403): + # Teams refused this account (tenant policy, no Teams licence): there is no picture to show. + return None + raise + + return await self._images.photo(query.refresh_token, fetch, "The chat has no picture.") diff --git a/src/application/queries/get_custom_emoji_query.py b/src/application/queries/get_custom_emoji_query.py new file mode 100644 index 0000000..462181f --- /dev/null +++ b/src/application/queries/get_custom_emoji_query.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_teams_chat import ITeamsChat +from src.application.services.image_sniffer import ImageSniffer +from src.domain.errors.chat_service_error import ChatServiceError +from src.domain.errors.service_error import ServiceError +from src.domain.files.binary_content import BinaryContent +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetCustomEmojiQuery(IQuery[BinaryContent]): + refresh_token: str = field(repr=False) + document_id: str + animated: bool + + +class GetCustomEmojiHandler(IQueryHandler[GetCustomEmojiQuery, BinaryContent]): + def __init__(self, accounts: IAccountRunner, teams_chat: ITeamsChat): + self._accounts = accounts + self._teams_chat = teams_chat + + async def handle(self, query: GetCustomEmojiQuery) -> BinaryContent: + GraphIds.check(query.document_id, GraphIds.DOCUMENT_ID_PATTERN, "document_id") + + def fetch(note, broker): + try: + return self._teams_chat.custom_emoji(broker, query.document_id, query.animated) + except ChatServiceError as ex: + if ex.status in (401, 403): + return None + raise + + data = await self._accounts.run(query.refresh_token, fetch) + mime = ImageSniffer.chat_image((data or b"")[:12]) + if mime is None: + raise ServiceError(404, "No such custom emoji.") + return BinaryContent(data, mime) diff --git a/src/application/queries/get_drive_item_content_query.py b/src/application/queries/get_drive_item_content_query.py new file mode 100644 index 0000000..484ab08 --- /dev/null +++ b/src/application/queries/get_drive_item_content_query.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_microsoft_http_clients import IMicrosoftHttpClients +from src.application.abstractions.i_teams_files import ITeamsFiles +from src.application.services.content_names import ContentNames +from src.application.services.drive_item_checks import DriveItemChecks +from src.domain.errors.service_error import ServiceError +from src.domain.files.file_stream import FileStream +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetDriveItemContentQuery(IQuery[FileStream]): + refresh_token: str = field(repr=False) + drive_id: str + item_id: str + + +class GetDriveItemContentHandler(IQueryHandler[GetDriveItemContentQuery, FileStream]): + def __init__(self, accounts: IAccountRunner, files: ITeamsFiles, checks: DriveItemChecks, http: IMicrosoftHttpClients): + self._accounts = accounts + self._files = files + self._checks = checks + self._http = http + + async def handle(self, query: GetDriveItemContentQuery) -> FileStream: + GraphIds.check(query.drive_id, GraphIds.DRIVE_ID_PATTERN, "drive_id") + GraphIds.check(query.item_id, GraphIds.DRIVE_ITEM_PATTERN, "item_id") + + def resolve(note, broker): + item = note.get(self._files.item_base(query.drive_id, query.item_id), {"$select": "id,name,size,file,folder,@microsoft.graph.downloadUrl"}) + self._checks.check_file(item) + if (item.get("size") or 0) > self._files.MAX_DOWNLOAD_BYTES: + raise ServiceError(413, "The file is larger than 100 MB.") + url = self._files.download_link(note, self._files.item_base(query.drive_id, query.item_id), item) + if not self._files.is_download_url(url): + raise ServiceError(502, "The file has an unexpected download location.") + return item, url + + item, url = await self._accounts.run(query.refresh_token, resolve) + return FileStream(self._http.stream_download(url, self._files.MAX_DOWNLOAD_BYTES, follow_redirects=False), ContentNames.safe_content_type((item.get("file") or {}).get("mimeType")), ContentNames.safe_name(item.get("name"))) diff --git a/src/application/queries/get_drive_item_pdf_query.py b/src/application/queries/get_drive_item_pdf_query.py new file mode 100644 index 0000000..2907c76 --- /dev/null +++ b/src/application/queries/get_drive_item_pdf_query.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_pdf_converter import IPdfConverter +from src.application.services.drive_item_checks import DriveItemChecks +from src.domain.files.spooled_pdf import SpooledPdf +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetDriveItemPdfQuery(IQuery[SpooledPdf]): + refresh_token: str = field(repr=False) + item_id: str + + +class GetDriveItemPdfHandler(IQueryHandler[GetDriveItemPdfQuery, SpooledPdf]): + def __init__(self, accounts: IAccountRunner, pdf: IPdfConverter, checks: DriveItemChecks): + self._accounts = accounts + self._pdf = pdf + self._checks = checks + + async def handle(self, query: GetDriveItemPdfQuery) -> SpooledPdf: + GraphIds.check(query.item_id, GraphIds.DRIVE_ITEM_PATTERN, "item_id") + + def convert(note, broker): + item = note.get(f"me/drive/items/{GraphIds.seg(query.item_id)}", {"$select": "id,name,size,file"}) + self._checks.check_source(item) + return SpooledPdf(item.get("name"), self._pdf.drive_item_pdf(note, query.item_id)) + + return await self._accounts.run(query.refresh_token, convert) diff --git a/src/application/queries/get_drive_item_query.py b/src/application/queries/get_drive_item_query.py new file mode 100644 index 0000000..40e71fd --- /dev/null +++ b/src/application/queries/get_drive_item_query.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_teams_files import ITeamsFiles +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetDriveItemQuery(IQuery[dict]): + refresh_token: str = field(repr=False) + drive_id: str + item_id: str + + +class GetDriveItemHandler(IQueryHandler[GetDriveItemQuery, dict]): + def __init__(self, accounts: IAccountRunner, files: ITeamsFiles): + self._accounts = accounts + self._files = files + + async def handle(self, query: GetDriveItemQuery) -> dict: + GraphIds.check(query.drive_id, GraphIds.DRIVE_ID_PATTERN, "drive_id") + GraphIds.check(query.item_id, GraphIds.DRIVE_ITEM_PATTERN, "item_id") + return await self._accounts.run(query.refresh_token, lambda note, broker: self._files.drive_item(note, query.drive_id, query.item_id)) diff --git a/src/application/queries/get_drive_item_text_query.py b/src/application/queries/get_drive_item_text_query.py new file mode 100644 index 0000000..53371f3 --- /dev/null +++ b/src/application/queries/get_drive_item_text_query.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.services.drive_file_text_reader import DriveFileTextReader +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetDriveItemTextQuery(IQuery[dict]): + refresh_token: str = field(repr=False) + item_id: str + offset: int + max_chars: int + sheet: str | None + row_offset: int + max_rows: int + + +class GetDriveItemTextHandler(IQueryHandler[GetDriveItemTextQuery, dict]): + def __init__(self, accounts: IAccountRunner, reader: DriveFileTextReader): + self._accounts = accounts + self._reader = reader + + async def handle(self, query: GetDriveItemTextQuery) -> dict: + GraphIds.check(query.item_id, GraphIds.DRIVE_ITEM_PATTERN, "item_id") + return await self._accounts.run(query.refresh_token, lambda note, broker: self._reader.read(note, None, query.item_id, query.offset, query.max_chars, query.sheet, query.row_offset, query.max_rows)) diff --git a/src/application/queries/get_drive_thumbnail_query.py b/src/application/queries/get_drive_thumbnail_query.py new file mode 100644 index 0000000..8c0824c --- /dev/null +++ b/src/application/queries/get_drive_thumbnail_query.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_file_thumbnails import IFileThumbnails +from src.application.services.account_image_reader import AccountImageReader +from src.domain.files.binary_content import BinaryContent +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetDriveThumbnailQuery(IQuery[BinaryContent]): + refresh_token: str = field(repr=False) + item_id: str + size: str + + +class GetDriveThumbnailHandler(IQueryHandler[GetDriveThumbnailQuery, BinaryContent]): + def __init__(self, images: AccountImageReader, thumbnails: IFileThumbnails): + self._images = images + self._thumbnails = thumbnails + + async def handle(self, query: GetDriveThumbnailQuery) -> BinaryContent: + GraphIds.check(query.item_id, GraphIds.DRIVE_ITEM_PATTERN, "item_id") + self._images.check_thumbnail_size(query.size) + return await self._images.thumbnail(query.refresh_token, lambda note, broker: self._thumbnails.drive_item_thumbnail(note, query.item_id, query.size)) diff --git a/src/application/queries/get_emoji_catalogue_query.py b/src/application/queries/get_emoji_catalogue_query.py new file mode 100644 index 0000000..b65782b --- /dev/null +++ b/src/application/queries/get_emoji_catalogue_query.py @@ -0,0 +1,24 @@ +import asyncio +from dataclasses import dataclass + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_emoji_catalogue import IEmojiCatalogue +from src.domain.errors.service_error import ServiceError + + +@dataclass +class GetEmojiCatalogueQuery(IQuery[dict]): + locale: str + + +class GetEmojiCatalogueHandler(IQueryHandler[GetEmojiCatalogueQuery, dict]): + def __init__(self, emojis: IEmojiCatalogue): + self._emojis = emojis + + async def handle(self, query: GetEmojiCatalogueQuery) -> dict: + name = self._emojis.normalize_locale(query.locale) + if name is None: + raise ServiceError(400, "Invalid locale.") + cat = await asyncio.to_thread(self._emojis.catalogue, name) + return cat.payload() diff --git a/src/application/queries/get_mail_attachment_pdf_query.py b/src/application/queries/get_mail_attachment_pdf_query.py new file mode 100644 index 0000000..4c39df4 --- /dev/null +++ b/src/application/queries/get_mail_attachment_pdf_query.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_pdf_converter import IPdfConverter +from src.application.services.mail_attachment_reader import MailAttachmentReader +from src.domain.errors.service_error import ServiceError +from src.domain.files.spooled_pdf import SpooledPdf +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetMailAttachmentPdfQuery(IQuery[SpooledPdf]): + refresh_token: str = field(repr=False) + message_id: str + attachment_id: str + + +class GetMailAttachmentPdfHandler(IQueryHandler[GetMailAttachmentPdfQuery, SpooledPdf]): + def __init__(self, accounts: IAccountRunner, attachments: MailAttachmentReader, pdf: IPdfConverter): + self._accounts = accounts + self._attachments = attachments + self._pdf = pdf + + async def handle(self, query: GetMailAttachmentPdfQuery) -> SpooledPdf: + GraphIds.check(query.message_id, GraphIds.GRAPH_ID_PATTERN, "message_id") + GraphIds.check(query.attachment_id, GraphIds.GRAPH_ID_PATTERN, "attachment_id") + + def convert(note, broker): + meta = self._attachments.metadata(note, query.message_id, query.attachment_id) + if str(meta.get("@odata.type", "")).endswith("itemAttachment") or not self._pdf.is_convertible(meta.get("name")): + raise ServiceError(415, "This file type cannot be converted to PDF.") + self._attachments.check_declared_size(meta) + data = self._attachments.download(note, query.message_id, query.attachment_id) + return SpooledPdf(meta.get("name"), self._pdf.bytes_pdf(note, broker, data, meta.get("name"))) + + return await self._accounts.run(query.refresh_token, convert) diff --git a/src/application/queries/get_mail_attachment_query.py b/src/application/queries/get_mail_attachment_query.py new file mode 100644 index 0000000..aeb0ca8 --- /dev/null +++ b/src/application/queries/get_mail_attachment_query.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.services.content_names import ContentNames +from src.application.services.mail_attachment_reader import MailAttachmentReader +from src.domain.files.binary_content import BinaryContent +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetMailAttachmentQuery(IQuery[BinaryContent]): + refresh_token: str = field(repr=False) + message_id: str + attachment_id: str + + +class GetMailAttachmentHandler(IQueryHandler[GetMailAttachmentQuery, BinaryContent]): + def __init__(self, accounts: IAccountRunner, attachments: MailAttachmentReader): + self._accounts = accounts + self._attachments = attachments + + async def handle(self, query: GetMailAttachmentQuery) -> BinaryContent: + GraphIds.check(query.message_id, GraphIds.GRAPH_ID_PATTERN, "message_id") + GraphIds.check(query.attachment_id, GraphIds.GRAPH_ID_PATTERN, "attachment_id") + + def fetch(note, broker): + meta = self._attachments.metadata(note, query.message_id, query.attachment_id) + self._attachments.check_declared_size(meta) + return meta, self._attachments.download(note, query.message_id, query.attachment_id) + + meta, data = await self._accounts.run(query.refresh_token, fetch) + name = ContentNames.safe_name(meta.get("name")) + is_item = str(meta.get("@odata.type", "")).endswith("itemAttachment") + content_type = ContentNames.safe_content_type("message/rfc822" if is_item else meta.get("contentType")) + if is_item and "." not in name: + name += ".eml" + return BinaryContent(data, content_type, name) diff --git a/src/application/queries/get_mail_message_query.py b/src/application/queries/get_mail_message_query.py new file mode 100644 index 0000000..bc8746f --- /dev/null +++ b/src/application/queries/get_mail_message_query.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetMailMessageQuery(IQuery[dict]): + refresh_token: str = field(repr=False) + message_id: str + + +class GetMailMessageHandler(IQueryHandler[GetMailMessageQuery, dict]): + def __init__(self, accounts: IAccountRunner): + self._accounts = accounts + + async def handle(self, query: GetMailMessageQuery) -> dict: + GraphIds.check(query.message_id, GraphIds.GRAPH_ID_PATTERN, "message_id") + return await self._accounts.run(query.refresh_token, lambda note, broker: note.email(GraphIds.seg(query.message_id))) diff --git a/src/application/queries/get_one_note_page_query.py b/src/application/queries/get_one_note_page_query.py new file mode 100644 index 0000000..e3f3f2a --- /dev/null +++ b/src/application/queries/get_one_note_page_query.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_one_note import IOneNote +from src.application.abstractions.i_one_note_runner import IOneNoteRunner +from src.domain.errors.service_error import ServiceError +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetOneNotePageQuery(IQuery[dict]): + refresh_token: str = field(repr=False) + page_id: str + + +class GetOneNotePageHandler(IQueryHandler[GetOneNotePageQuery, dict]): + def __init__(self, runner: IOneNoteRunner, onenote: IOneNote): + self._runner = runner + self._onenote = onenote + + async def handle(self, query: GetOneNotePageQuery) -> dict: + GraphIds.check(query.page_id, self._onenote.ID_PATTERN, "page_id") + if not self._onenote.is_id(query.page_id): + raise ServiceError(400, "Invalid page_id.") + return await self._runner.run(query.refresh_token, lambda note, broker: self._onenote.page(note, query.page_id)) diff --git a/src/application/queries/get_one_note_resource_query.py b/src/application/queries/get_one_note_resource_query.py new file mode 100644 index 0000000..2b170ce --- /dev/null +++ b/src/application/queries/get_one_note_resource_query.py @@ -0,0 +1,48 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_one_note import IOneNote +from src.application.abstractions.i_one_note_runner import IOneNoteRunner +from src.application.services.content_names import ContentNames +from src.application.services.image_sniffer import ImageSniffer +from src.domain.errors.resource_too_large import ResourceTooLarge +from src.domain.errors.service_error import ServiceError +from src.domain.files.binary_content import BinaryContent +from src.domain.files.file_limits import FileLimits +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetOneNoteResourceQuery(IQuery[BinaryContent]): + refresh_token: str = field(repr=False) + resource_id: str + kind: str + name: str | None + + +class GetOneNoteResourceHandler(IQueryHandler[GetOneNoteResourceQuery, BinaryContent]): + def __init__(self, runner: IOneNoteRunner, onenote: IOneNote): + self._runner = runner + self._onenote = onenote + + async def handle(self, query: GetOneNoteResourceQuery) -> BinaryContent: + GraphIds.check(query.resource_id, self._onenote.ID_PATTERN, "resource_id") + if not self._onenote.is_id(query.resource_id): + raise ServiceError(400, "Invalid resource_id.") + if query.kind not in ("image", "file"): + raise ServiceError(400, "kind must be image or file") + + def fetch(note, broker): + try: + return self._onenote.resource(broker, query.resource_id, FileLimits.MAX_DOWNLOAD_BYTES) + except ResourceTooLarge: + raise ServiceError(413, "The resource is larger than 25 MB.") + + data = await self._runner.run(query.refresh_token, fetch) + if query.kind == "image": + mime = ImageSniffer.chat_image((data or b"")[:12]) + if mime is None: + raise ServiceError(415, "The resource is not a PNG, JPEG, GIF or WEBP image.") + return BinaryContent(data, mime) + return BinaryContent(data, "application/octet-stream", ContentNames.safe_name(query.name)) diff --git a/src/application/queries/get_one_note_sections_query.py b/src/application/queries/get_one_note_sections_query.py new file mode 100644 index 0000000..24f2b18 --- /dev/null +++ b/src/application/queries/get_one_note_sections_query.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_one_note import IOneNote +from src.application.abstractions.i_one_note_runner import IOneNoteRunner +from src.domain.errors.service_error import ServiceError +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetOneNoteSectionsQuery(IQuery[dict]): + refresh_token: str = field(repr=False) + notebook_id: str + + +class GetOneNoteSectionsHandler(IQueryHandler[GetOneNoteSectionsQuery, dict]): + def __init__(self, runner: IOneNoteRunner, onenote: IOneNote): + self._runner = runner + self._onenote = onenote + + async def handle(self, query: GetOneNoteSectionsQuery) -> dict: + GraphIds.check(query.notebook_id, self._onenote.ID_PATTERN, "notebook_id") + if not self._onenote.is_id(query.notebook_id): + raise ServiceError(400, "Invalid notebook_id.") + return await self._runner.run(query.refresh_token, lambda note, broker: self._onenote.tree(note, broker, query.notebook_id)) diff --git a/src/application/queries/get_password_sign_in_query.py b/src/application/queries/get_password_sign_in_query.py new file mode 100644 index 0000000..c828ce4 --- /dev/null +++ b/src/application/queries/get_password_sign_in_query.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_sign_in_store import ISignInStore +from src.application.dtos.password_sign_in_response import PasswordSignInResponse +from src.application.services.password_sign_in_responder import PasswordSignInResponder +from src.domain.errors.sign_in_refused import SignInRefused + + +@dataclass +class GetPasswordSignInQuery(IQuery[PasswordSignInResponse]): + user_id: str + sign_in_id: str + + +class GetPasswordSignInHandler(IQueryHandler[GetPasswordSignInQuery, PasswordSignInResponse]): + def __init__(self, store: ISignInStore, responder: PasswordSignInResponder): + self._store = store + self._responder = responder + + async def handle(self, query: GetPasswordSignInQuery) -> PasswordSignInResponse: + job = self._store.get(query.sign_in_id, query.user_id) + if job is None: + raise SignInRefused(404, "not_found", "Unknown or expired sign-in.") + return await self._responder.respond(job) diff --git a/src/application/queries/get_person_photo_query.py b/src/application/queries/get_person_photo_query.py new file mode 100644 index 0000000..89c4e05 --- /dev/null +++ b/src/application/queries/get_person_photo_query.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_people_photos import IPeoplePhotos +from src.application.services.account_image_reader import AccountImageReader +from src.application.services.mail_message_builder import MailMessageBuilder +from src.application.services.retry_after import RetryAfter +from src.domain.errors.photo_throttled import PhotoThrottled +from src.domain.errors.service_error import ServiceError +from src.domain.files.binary_content import BinaryContent + + +@dataclass +class GetPersonPhotoQuery(IQuery[BinaryContent]): + refresh_token: str = field(repr=False) + email: str + size: int + + +class GetPersonPhotoHandler(IQueryHandler[GetPersonPhotoQuery, BinaryContent]): + PHOTO_SIZES = (48, 64, 96, 120, 240) + + def __init__(self, images: AccountImageReader, photos: IPeoplePhotos): + self._images = images + self._photos = photos + + async def handle(self, query: GetPersonPhotoQuery) -> BinaryContent: + if not MailMessageBuilder.EMAIL_PATTERN.fullmatch(query.email.strip()): + raise ServiceError(400, "Invalid email.") + if query.size not in self.PHOTO_SIZES: + raise ServiceError(400, f"size must be one of {', '.join(str(s) for s in self.PHOTO_SIZES)}") + + def fetch(note, broker): + try: + return self._photos.photo(note, broker, query.email, query.size) + except PhotoThrottled as ex: + raise ServiceError(429, "Microsoft is throttling photo requests.", headers={"Retry-After": RetryAfter.header(ex.retry_after)}) + + return await self._images.photo(query.refresh_token, fetch, "No profile photo for that address.") diff --git a/src/application/queries/get_profile_query.py b/src/application/queries/get_profile_query.py new file mode 100644 index 0000000..7546ad8 --- /dev/null +++ b/src/application/queries/get_profile_query.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_profile_reader import IProfileReader + + +@dataclass +class GetProfileQuery(IQuery[dict]): + refresh_token: str = field(repr=False) + + +class GetProfileHandler(IQueryHandler[GetProfileQuery, dict]): + def __init__(self, accounts: IAccountRunner, profile: IProfileReader): + self._accounts = accounts + self._profile = profile + + async def handle(self, query: GetProfileQuery) -> dict: + return await self._accounts.run(query.refresh_token, self._profile.get) diff --git a/src/application/queries/get_readiness_query.py b/src/application/queries/get_readiness_query.py new file mode 100644 index 0000000..a36af41 --- /dev/null +++ b/src/application/queries/get_readiness_query.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +from mediatorx import IQuery, IQueryHandler + +from src.application.services.warm_up_tracker import WarmUpTracker + + +@dataclass +class GetReadinessQuery(IQuery[bool]): + """Whether the startup warm-up finished or its grace period passed.""" + + pass + + +class GetReadinessHandler(IQueryHandler[GetReadinessQuery, bool]): + def __init__(self, tracker: WarmUpTracker): + self._tracker = tracker + + async def handle(self, query: GetReadinessQuery) -> bool: + return self._tracker.is_ready() diff --git a/src/application/queries/get_shared_drive_item_pdf_query.py b/src/application/queries/get_shared_drive_item_pdf_query.py new file mode 100644 index 0000000..7263438 --- /dev/null +++ b/src/application/queries/get_shared_drive_item_pdf_query.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_pdf_converter import IPdfConverter +from src.application.abstractions.i_teams_files import ITeamsFiles +from src.application.services.drive_item_checks import DriveItemChecks +from src.domain.files.spooled_pdf import SpooledPdf +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetSharedDriveItemPdfQuery(IQuery[SpooledPdf]): + refresh_token: str = field(repr=False) + drive_id: str + item_id: str + + +class GetSharedDriveItemPdfHandler(IQueryHandler[GetSharedDriveItemPdfQuery, SpooledPdf]): + def __init__(self, accounts: IAccountRunner, files: ITeamsFiles, pdf: IPdfConverter, checks: DriveItemChecks): + self._accounts = accounts + self._files = files + self._pdf = pdf + self._checks = checks + + async def handle(self, query: GetSharedDriveItemPdfQuery) -> SpooledPdf: + GraphIds.check(query.drive_id, GraphIds.DRIVE_ID_PATTERN, "drive_id") + GraphIds.check(query.item_id, GraphIds.DRIVE_ITEM_PATTERN, "item_id") + + def convert(note, broker): + item = note.get(self._files.item_base(query.drive_id, query.item_id), {"$select": "id,name,size,file"}) + self._checks.check_source(item) + return SpooledPdf(item.get("name"), self._pdf.drive_item_pdf(note, query.item_id, query.drive_id)) + + return await self._accounts.run(query.refresh_token, convert) diff --git a/src/application/queries/get_shared_drive_item_text_query.py b/src/application/queries/get_shared_drive_item_text_query.py new file mode 100644 index 0000000..2e75482 --- /dev/null +++ b/src/application/queries/get_shared_drive_item_text_query.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.services.drive_file_text_reader import DriveFileTextReader +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetSharedDriveItemTextQuery(IQuery[dict]): + refresh_token: str = field(repr=False) + drive_id: str + item_id: str + offset: int + max_chars: int + sheet: str | None + row_offset: int + max_rows: int + + +class GetSharedDriveItemTextHandler(IQueryHandler[GetSharedDriveItemTextQuery, dict]): + def __init__(self, accounts: IAccountRunner, reader: DriveFileTextReader): + self._accounts = accounts + self._reader = reader + + async def handle(self, query: GetSharedDriveItemTextQuery) -> dict: + GraphIds.check(query.drive_id, GraphIds.DRIVE_ID_PATTERN, "drive_id") + GraphIds.check(query.item_id, GraphIds.DRIVE_ITEM_PATTERN, "item_id") + return await self._accounts.run(query.refresh_token, lambda note, broker: self._reader.read(note, query.drive_id, query.item_id, query.offset, query.max_chars, query.sheet, query.row_offset, query.max_rows)) diff --git a/src/application/queries/get_shared_drive_thumbnail_query.py b/src/application/queries/get_shared_drive_thumbnail_query.py new file mode 100644 index 0000000..a40ade6 --- /dev/null +++ b/src/application/queries/get_shared_drive_thumbnail_query.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_file_thumbnails import IFileThumbnails +from src.application.services.account_image_reader import AccountImageReader +from src.domain.files.binary_content import BinaryContent +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetSharedDriveThumbnailQuery(IQuery[BinaryContent]): + refresh_token: str = field(repr=False) + drive_id: str + item_id: str + size: str + + +class GetSharedDriveThumbnailHandler(IQueryHandler[GetSharedDriveThumbnailQuery, BinaryContent]): + def __init__(self, images: AccountImageReader, thumbnails: IFileThumbnails): + self._images = images + self._thumbnails = thumbnails + + async def handle(self, query: GetSharedDriveThumbnailQuery) -> BinaryContent: + GraphIds.check(query.drive_id, GraphIds.DRIVE_ID_PATTERN, "drive_id") + GraphIds.check(query.item_id, GraphIds.DRIVE_ITEM_PATTERN, "item_id") + self._images.check_thumbnail_size(query.size) + return await self._images.thumbnail(query.refresh_token, lambda note, broker: self._thumbnails.drive_item_thumbnail(note, query.item_id, query.size, query.drive_id)) diff --git a/src/application/queries/get_user_photo_query.py b/src/application/queries/get_user_photo_query.py new file mode 100644 index 0000000..843c34b --- /dev/null +++ b/src/application/queries/get_user_photo_query.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_people_photos import IPeoplePhotos +from src.application.services.account_image_reader import AccountImageReader +from src.domain.errors.service_error import ServiceError +from src.domain.files.binary_content import BinaryContent +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetUserPhotoQuery(IQuery[BinaryContent]): + refresh_token: str = field(repr=False) + user_id: str + size: int + + +class GetUserPhotoHandler(IQueryHandler[GetUserPhotoQuery, BinaryContent]): + PHOTO_SIZES = (48, 64, 96, 120, 240) + + def __init__(self, images: AccountImageReader, photos: IPeoplePhotos): + self._images = images + self._photos = photos + + async def handle(self, query: GetUserPhotoQuery) -> BinaryContent: + GraphIds.check(query.user_id, GraphIds.USER_ID_PATTERN, "user_id") + if query.size not in self.PHOTO_SIZES: + raise ServiceError(400, f"size must be one of {', '.join(str(s) for s in self.PHOTO_SIZES)}") + return await self._images.photo(query.refresh_token, lambda note, broker: self._photos.user_photo(note, query.user_id, query.size), "The user has no profile photo.") diff --git a/src/application/queries/list_calendar_events_query.py b/src/application/queries/list_calendar_events_query.py new file mode 100644 index 0000000..e4d8c82 --- /dev/null +++ b/src/application/queries/list_calendar_events_query.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner + + +@dataclass +class ListCalendarEventsQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + top: int + start: str | None + end: str | None + + +class ListCalendarEventsHandler(IQueryHandler[ListCalendarEventsQuery, list[dict]]): + def __init__(self, accounts: IAccountRunner): + self._accounts = accounts + + async def handle(self, query: ListCalendarEventsQuery) -> list[dict]: + return await self._accounts.run(query.refresh_token, lambda note, broker: note.events(start=query.start, end=query.end, top=query.top)) diff --git a/src/application/queries/list_channel_message_replies_query.py b/src/application/queries/list_channel_message_replies_query.py new file mode 100644 index 0000000..0218a14 --- /dev/null +++ b/src/application/queries/list_channel_message_replies_query.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_channel_messages import IChannelMessages +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class ListChannelMessageRepliesQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + team_id: str + channel_id: str + message_id: str + top: int + + +class ListChannelMessageRepliesHandler(IQueryHandler[ListChannelMessageRepliesQuery, list[dict]]): + def __init__(self, accounts: IAccountRunner, channels: IChannelMessages): + self._accounts = accounts + self._channels = channels + + async def handle(self, query: ListChannelMessageRepliesQuery) -> list[dict]: + GraphIds.check(query.team_id, GraphIds.TEAM_ID_PATTERN, "team_id") + GraphIds.check(query.channel_id, GraphIds.CHANNEL_ID_PATTERN, "channel_id") + GraphIds.check(query.message_id, GraphIds.CHANNEL_MESSAGE_ID_PATTERN, "message_id") + return await self._accounts.run(query.refresh_token, lambda note, broker: self._channels.replies(note, query.team_id, query.channel_id, query.message_id, query.top)) diff --git a/src/application/queries/list_channel_messages_query.py b/src/application/queries/list_channel_messages_query.py new file mode 100644 index 0000000..d1045c7 --- /dev/null +++ b/src/application/queries/list_channel_messages_query.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_channel_messages import IChannelMessages +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class ListChannelMessagesQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + team_id: str + channel_id: str + top: int + + +class ListChannelMessagesHandler(IQueryHandler[ListChannelMessagesQuery, list[dict]]): + def __init__(self, accounts: IAccountRunner, channels: IChannelMessages): + self._accounts = accounts + self._channels = channels + + async def handle(self, query: ListChannelMessagesQuery) -> list[dict]: + GraphIds.check(query.team_id, GraphIds.TEAM_ID_PATTERN, "team_id") + GraphIds.check(query.channel_id, GraphIds.CHANNEL_ID_PATTERN, "channel_id") + return await self._accounts.run(query.refresh_token, lambda note, broker: self._channels.messages(note, query.team_id, query.channel_id, query.top)) diff --git a/src/application/queries/list_chat_messages_query.py b/src/application/queries/list_chat_messages_query.py new file mode 100644 index 0000000..f6829d3 --- /dev/null +++ b/src/application/queries/list_chat_messages_query.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_chat_reader import IChatReader +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class ListChatMessagesQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + chat_id: str + top: int + + +class ListChatMessagesHandler(IQueryHandler[ListChatMessagesQuery, list[dict]]): + def __init__(self, accounts: IAccountRunner, reader: IChatReader): + self._accounts = accounts + self._reader = reader + + async def handle(self, query: ListChatMessagesQuery) -> list[dict]: + GraphIds.check(query.chat_id, GraphIds.CHAT_ID_PATTERN, "chat_id") + # No $select: Graph answers 500 for chat messages when one is set. + return await self._accounts.run(query.refresh_token, lambda note, broker: self._reader.enrich(note, broker, note.list(f"me/chats/{query.chat_id}/messages", {"$top": min(query.top, 50)}, limit=query.top))) diff --git a/src/application/queries/list_chats_query.py b/src/application/queries/list_chats_query.py new file mode 100644 index 0000000..f8da393 --- /dev/null +++ b/src/application/queries/list_chats_query.py @@ -0,0 +1,36 @@ +import logging +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_teams_chat import ITeamsChat + + +@dataclass +class ListChatsQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + top: int + + +class ListChatsHandler(IQueryHandler[ListChatsQuery, list[dict]]): + CHAT_FIELDS = "id,topic,chatType,createdDateTime,lastUpdatedDateTime,webUrl,viewpoint" + log = logging.getLogger("schuly.microsoft") + + def __init__(self, accounts: IAccountRunner, teams_chat: ITeamsChat): + self._accounts = accounts + self._teams_chat = teams_chat + + async def handle(self, query: ListChatsQuery) -> list[dict]: + def fetch(note, broker): + items = note.list("me/chats", {"$top": min(query.top, 50), "$select": self.CHAT_FIELDS, "$expand": "members,lastMessagePreview", "$orderby": "lastMessagePreview/createdDateTime desc"}, limit=query.top) + try: + return self._teams_chat.mark_images(broker, items) + except Exception as ex: + # The picture flag is a nicety; the chat list never fails over it. + self.log.info("chat picture lookup failed: %s", type(ex).__name__) + for item in items: + item["hasImage"] = False + return items + + return await self._accounts.run(query.refresh_token, fetch) diff --git a/src/application/queries/list_drive_item_children_query.py b/src/application/queries/list_drive_item_children_query.py new file mode 100644 index 0000000..2c8e604 --- /dev/null +++ b/src/application/queries/list_drive_item_children_query.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_teams_files import ITeamsFiles +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class ListDriveItemChildrenQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + drive_id: str + item_id: str + top: int + + +class ListDriveItemChildrenHandler(IQueryHandler[ListDriveItemChildrenQuery, list[dict]]): + def __init__(self, accounts: IAccountRunner, files: ITeamsFiles): + self._accounts = accounts + self._files = files + + async def handle(self, query: ListDriveItemChildrenQuery) -> list[dict]: + GraphIds.check(query.drive_id, GraphIds.DRIVE_ID_PATTERN, "drive_id") + GraphIds.check(query.item_id, GraphIds.DRIVE_ITEM_PATTERN, "item_id") + return await self._accounts.run(query.refresh_token, lambda note, broker: self._files.children(note, query.drive_id, query.item_id, query.top)) diff --git a/src/application/queries/list_files_query.py b/src/application/queries/list_files_query.py new file mode 100644 index 0000000..84a7e31 --- /dev/null +++ b/src/application/queries/list_files_query.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.domain.errors.service_error import ServiceError +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class ListFilesQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + path: str + top: int + + +class ListFilesHandler(IQueryHandler[ListFilesQuery, list[dict]]): + def __init__(self, accounts: IAccountRunner): + self._accounts = accounts + + async def handle(self, query: ListFilesQuery) -> list[dict]: + if GraphIds.has_dot_segment(query.path) or "%" in query.path: + raise ServiceError(400, "Invalid path.") + return await self._accounts.run(query.refresh_token, lambda note, broker: note.files(path=query.path, top=query.top)) diff --git a/src/application/queries/list_joined_teams_query.py b/src/application/queries/list_joined_teams_query.py new file mode 100644 index 0000000..80ba926 --- /dev/null +++ b/src/application/queries/list_joined_teams_query.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_teams_files import ITeamsFiles + + +@dataclass +class ListJoinedTeamsQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + + +class ListJoinedTeamsHandler(IQueryHandler[ListJoinedTeamsQuery, list[dict]]): + def __init__(self, accounts: IAccountRunner, files: ITeamsFiles): + self._accounts = accounts + self._files = files + + async def handle(self, query: ListJoinedTeamsQuery) -> list[dict]: + return await self._accounts.run(query.refresh_token, self._files.joined_teams) diff --git a/src/application/queries/list_mail_attachments_query.py b/src/application/queries/list_mail_attachments_query.py new file mode 100644 index 0000000..3d12f7f --- /dev/null +++ b/src/application/queries/list_mail_attachments_query.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.services.mail_attachment_reader import MailAttachmentReader +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class ListMailAttachmentsQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + message_id: str + + +class ListMailAttachmentsHandler(IQueryHandler[ListMailAttachmentsQuery, list[dict]]): + def __init__(self, accounts: IAccountRunner, attachments: MailAttachmentReader): + self._accounts = accounts + self._attachments = attachments + + async def handle(self, query: ListMailAttachmentsQuery) -> list[dict]: + GraphIds.check(query.message_id, GraphIds.GRAPH_ID_PATTERN, "message_id") + return await self._accounts.run(query.refresh_token, lambda note, broker: [self._attachments.attachment_meta(a) for a in note.list(f"me/messages/{GraphIds.seg(query.message_id)}/attachments")]) diff --git a/src/application/queries/list_mail_folders_query.py b/src/application/queries/list_mail_folders_query.py new file mode 100644 index 0000000..a3cfb88 --- /dev/null +++ b/src/application/queries/list_mail_folders_query.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.services.mail_folder_tree import MailFolderTree + + +@dataclass +class ListMailFoldersQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + + +class ListMailFoldersHandler(IQueryHandler[ListMailFoldersQuery, list[dict]]): + def __init__(self, accounts: IAccountRunner, tree: MailFolderTree): + self._accounts = accounts + self._tree = tree + + async def handle(self, query: ListMailFoldersQuery) -> list[dict]: + return await self._accounts.run(query.refresh_token, self._tree.read) diff --git a/src/application/queries/list_mail_query.py b/src/application/queries/list_mail_query.py new file mode 100644 index 0000000..376960b --- /dev/null +++ b/src/application/queries/list_mail_query.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.domain.errors.service_error import ServiceError +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class ListMailQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + folder: str + top: int + skip: int + unread: bool + search: str | None + + +class ListMailHandler(IQueryHandler[ListMailQuery, list[dict]]): + # Field lists keep list responses small: no mail body in lists (the single-message route returns it). + # Explicit, not OutNote's default list fields: the web's mail list shows `toRecipients`, which that default leaves out. + MAIL_LIST_FIELDS = "id,subject,from,toRecipients,receivedDateTime,bodyPreview,isRead,flag,hasAttachments,importance,conversationId" + + def __init__(self, accounts: IAccountRunner): + self._accounts = accounts + + async def handle(self, query: ListMailQuery) -> list[dict]: + GraphIds.check(query.folder, GraphIds.GRAPH_ID_PATTERN, "folder") + if query.skip and query.search: + raise ServiceError(400, "skip cannot be combined with search.") + folder = GraphIds.seg(query.folder) + if not query.skip: + return await self._accounts.run(query.refresh_token, lambda note, broker: note.emails(folder=folder, top=query.top, unread=query.unread, search=query.search, select=self.MAIL_LIST_FIELDS)) + # Paging: newest first, `skip` messages in. Graph does not allow $skip together with $search. + params = {"$top": min(query.top, 100), "$skip": query.skip, "$select": self.MAIL_LIST_FIELDS, "$orderby": "receivedDateTime desc"} + if query.unread: + params["$filter"] = "isRead eq false" + return await self._accounts.run(query.refresh_token, lambda note, broker: note.list(f"me/mailFolders/{folder}/messages", params, limit=query.top)) diff --git a/src/application/queries/list_notebooks_query.py b/src/application/queries/list_notebooks_query.py new file mode 100644 index 0000000..0374773 --- /dev/null +++ b/src/application/queries/list_notebooks_query.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner + + +@dataclass +class ListNotebooksQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + + +class ListNotebooksHandler(IQueryHandler[ListNotebooksQuery, list[dict]]): + def __init__(self, accounts: IAccountRunner): + self._accounts = accounts + + async def handle(self, query: ListNotebooksQuery) -> list[dict]: + return await self._accounts.run(query.refresh_token, lambda note, broker: note.notebooks()) diff --git a/src/application/queries/list_one_note_notebooks_query.py b/src/application/queries/list_one_note_notebooks_query.py new file mode 100644 index 0000000..588e6d2 --- /dev/null +++ b/src/application/queries/list_one_note_notebooks_query.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_one_note import IOneNote +from src.application.abstractions.i_one_note_runner import IOneNoteRunner + + +@dataclass +class ListOneNoteNotebooksQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + teams: bool + + +class ListOneNoteNotebooksHandler(IQueryHandler[ListOneNoteNotebooksQuery, list[dict]]): + def __init__(self, runner: IOneNoteRunner, onenote: IOneNote): + self._runner = runner + self._onenote = onenote + + async def handle(self, query: ListOneNoteNotebooksQuery) -> list[dict]: + return await self._runner.run(query.refresh_token, lambda note, broker: self._onenote.notebooks(note, broker, query.teams)) diff --git a/src/application/queries/list_one_note_pages_query.py b/src/application/queries/list_one_note_pages_query.py new file mode 100644 index 0000000..3e9eded --- /dev/null +++ b/src/application/queries/list_one_note_pages_query.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_one_note import IOneNote +from src.application.abstractions.i_one_note_runner import IOneNoteRunner +from src.domain.errors.service_error import ServiceError +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class ListOneNotePagesQuery(IQuery[dict]): + refresh_token: str = field(repr=False) + section_id: str + top: int + skip: int + + +class ListOneNotePagesHandler(IQueryHandler[ListOneNotePagesQuery, dict]): + def __init__(self, runner: IOneNoteRunner, onenote: IOneNote): + self._runner = runner + self._onenote = onenote + + async def handle(self, query: ListOneNotePagesQuery) -> dict: + GraphIds.check(query.section_id, self._onenote.ID_PATTERN, "section_id") + if not self._onenote.is_id(query.section_id): + raise ServiceError(400, "Invalid section_id.") + return await self._runner.run(query.refresh_token, lambda note, broker: self._onenote.pages(note, query.section_id, query.top, query.skip)) diff --git a/src/application/queries/list_recent_files_query.py b/src/application/queries/list_recent_files_query.py new file mode 100644 index 0000000..6df4370 --- /dev/null +++ b/src/application/queries/list_recent_files_query.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_teams_files import ITeamsFiles + + +@dataclass +class ListRecentFilesQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + top: int + + +class ListRecentFilesHandler(IQueryHandler[ListRecentFilesQuery, list[dict]]): + def __init__(self, accounts: IAccountRunner, files: ITeamsFiles): + self._accounts = accounts + self._files = files + + async def handle(self, query: ListRecentFilesQuery) -> list[dict]: + return await self._accounts.run(query.refresh_token, lambda note, broker: self._files.recent(note, query.top)) diff --git a/src/application/queries/list_shared_files_query.py b/src/application/queries/list_shared_files_query.py new file mode 100644 index 0000000..0ba3557 --- /dev/null +++ b/src/application/queries/list_shared_files_query.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_teams_files import ITeamsFiles + + +@dataclass +class ListSharedFilesQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + top: int + + +class ListSharedFilesHandler(IQueryHandler[ListSharedFilesQuery, list[dict]]): + def __init__(self, accounts: IAccountRunner, files: ITeamsFiles): + self._accounts = accounts + self._files = files + + async def handle(self, query: ListSharedFilesQuery) -> list[dict]: + return await self._accounts.run(query.refresh_token, lambda note, broker: self._files.shared_with_me(note, query.top)) diff --git a/src/application/queries/list_team_channels_query.py b/src/application/queries/list_team_channels_query.py new file mode 100644 index 0000000..dcbad60 --- /dev/null +++ b/src/application/queries/list_team_channels_query.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_teams_files import ITeamsFiles +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class ListTeamChannelsQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + team_id: str + + +class ListTeamChannelsHandler(IQueryHandler[ListTeamChannelsQuery, list[dict]]): + def __init__(self, accounts: IAccountRunner, files: ITeamsFiles): + self._accounts = accounts + self._files = files + + async def handle(self, query: ListTeamChannelsQuery) -> list[dict]: + GraphIds.check(query.team_id, GraphIds.TEAM_ID_PATTERN, "team_id") + return await self._accounts.run(query.refresh_token, lambda note, broker: self._files.channels(note, broker, query.team_id)) diff --git a/src/application/queries/list_team_files_query.py b/src/application/queries/list_team_files_query.py new file mode 100644 index 0000000..54a751c --- /dev/null +++ b/src/application/queries/list_team_files_query.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_teams_files import ITeamsFiles +from src.domain.errors.service_error import ServiceError +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class ListTeamFilesQuery(IQuery[dict]): + refresh_token: str = field(repr=False) + team_id: str + channel_id: str | None + path: str | None + top: int + + +class ListTeamFilesHandler(IQueryHandler[ListTeamFilesQuery, dict]): + def __init__(self, accounts: IAccountRunner, files: ITeamsFiles): + self._accounts = accounts + self._files = files + + async def handle(self, query: ListTeamFilesQuery) -> dict: + GraphIds.check(query.team_id, GraphIds.TEAM_ID_PATTERN, "team_id") + if query.channel_id: + GraphIds.check(query.channel_id, GraphIds.CHANNEL_ID_PATTERN, "channel_id") + try: + self._files.valid_path(query.path) + except ValueError: + raise ServiceError(400, "Invalid path.") + return await self._accounts.run(query.refresh_token, lambda note, broker: self._files.team_files(note, broker, query.team_id, query.channel_id or None, query.path, query.top)) diff --git a/src/application/queries/search_gifs_query.py b/src/application/queries/search_gifs_query.py new file mode 100644 index 0000000..b3218d2 --- /dev/null +++ b/src/application/queries/search_gifs_query.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_gif_search import IGifSearch +from src.application.options.microsoft_options import MicrosoftOptions +from src.domain.errors.gif_search_unavailable import GifSearchUnavailable +from src.domain.errors.service_error import ServiceError +from src.domain.validation.refresh_token_format import RefreshTokenFormat + + +@dataclass +class SearchGifsQuery(IQuery[list[dict]]): + refresh_token: str = field(repr=False) + q: str | None + limit: int + + +class SearchGifsHandler(IQueryHandler[SearchGifsQuery, list[dict]]): + def __init__(self, accounts: IAccountRunner, gifs: IGifSearch, options: MicrosoftOptions): + self._accounts = accounts + self._gifs = gifs + self._options = options + + async def handle(self, query: SearchGifsQuery) -> list[dict]: + RefreshTokenFormat.check(query.refresh_token) + + def run(note, broker): + # Only a live Microsoft sign-in may spend the GIF key: the token must mint a Graph token (cached after the first call), and each account is rate limited. + broker(self._options.device_client_id) + if not self._gifs.allow(broker): + raise ServiceError(429, "Too many GIF searches; wait a minute.", headers={"Retry-After": "60"}) + try: + return self._gifs.search(query.q, query.limit) + except GifSearchUnavailable as ex: + raise ServiceError(503, str(ex)) + except Exception as ex: + raise ServiceError(502, f"GIF search failed: {type(ex).__name__}") + + return await self._accounts.run(query.refresh_token, run) diff --git a/src/application/queries/search_one_note_query.py b/src/application/queries/search_one_note_query.py new file mode 100644 index 0000000..51995a3 --- /dev/null +++ b/src/application/queries/search_one_note_query.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_one_note import IOneNote +from src.application.abstractions.i_one_note_runner import IOneNoteRunner +from src.domain.errors.service_error import ServiceError +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class SearchOneNoteQuery(IQuery[dict]): + refresh_token: str = field(repr=False) + q: str + notebook: str | None + top: int + + +class SearchOneNoteHandler(IQueryHandler[SearchOneNoteQuery, dict]): + def __init__(self, runner: IOneNoteRunner, onenote: IOneNote): + self._runner = runner + self._onenote = onenote + + async def handle(self, query: SearchOneNoteQuery) -> dict: + if not query.q.strip(): + raise ServiceError(400, "q is empty.") + if query.notebook: + GraphIds.check(query.notebook, self._onenote.ID_PATTERN, "notebook") + if not self._onenote.is_id(query.notebook): + raise ServiceError(400, "Invalid notebook.") + return await self._runner.run(query.refresh_token, lambda note, broker: self._onenote.search(note, broker, query.q, query.notebook, query.top)) diff --git a/src/application/queries/search_query.py b/src/application/queries/search_query.py new file mode 100644 index 0000000..07b6efd --- /dev/null +++ b/src/application/queries/search_query.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_graph_search import IGraphSearch +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class SearchQuery(IQuery[dict]): + """`q` stays out of the repr: the query never reaches a log.""" + + refresh_token: str = field(repr=False) + q: str = field(repr=False) + types: list[str] + start: int + size: int + team_id: str | None + + +class SearchHandler(IQueryHandler[SearchQuery, dict]): + def __init__(self, accounts: IAccountRunner, search: IGraphSearch): + self._accounts = accounts + self._search = search + + async def handle(self, query: SearchQuery) -> dict: + if query.team_id is not None: + GraphIds.check(query.team_id, GraphIds.TEAM_ID_PATTERN, "teamId") + return await self._accounts.run(query.refresh_token, lambda note, broker: self._search.run(note, broker, query.q, query.types, query.start, query.size, query.team_id)) diff --git a/src/application/services/account_image_reader.py b/src/application/services/account_image_reader.py new file mode 100644 index 0000000..8600088 --- /dev/null +++ b/src/application/services/account_image_reader.py @@ -0,0 +1,32 @@ +from collections.abc import Callable + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_file_thumbnails import IFileThumbnails +from src.application.services.image_sniffer import ImageSniffer +from src.domain.errors.service_error import ServiceError +from src.domain.files.binary_content import BinaryContent + + +class AccountImageReader: + """Images read for an account: thumbnails, and photos and pictures with a 404 of their own when there are none.""" + + def __init__(self, accounts: IAccountRunner, thumbnails: IFileThumbnails): + self._accounts = accounts + self._thumbnails = thumbnails + + def check_thumbnail_size(self, size: str) -> None: + if size not in self._thumbnails.SIZES: + raise ServiceError(400, f"size must be one of {', '.join(self._thumbnails.SIZES)}") + + async def thumbnail(self, token: str, fetch: Callable) -> BinaryContent: + data = await self._accounts.run(token, fetch) + mime = self._thumbnails.image_type((data or b"")[:12]) + if mime is None: + raise ServiceError(404, "The file has no thumbnail.") + return BinaryContent(data, mime) + + async def photo(self, token: str, fetch: Callable, missing: str) -> BinaryContent: + data = await self._accounts.run(token, fetch) + if not data: + raise ServiceError(404, missing) + return BinaryContent(data, ImageSniffer.photo(data)) diff --git a/src/application/services/application_lifetime.py b/src/application/services/application_lifetime.py new file mode 100644 index 0000000..4d18bee --- /dev/null +++ b/src/application/services/application_lifetime.py @@ -0,0 +1,35 @@ +import asyncio + +from src.application.abstractions.i_microsoft_http_clients import IMicrosoftHttpClients +from src.application.abstractions.i_sign_in_store import ISignInStore +from src.application.services.warm_up_tracker import WarmUpTracker + + +class ApplicationLifetime: + """What runs for as long as the process does: the warm-up, the sign-in sweeper, and the shutdown.""" + + SWEEP_EVERY = 15 + + def __init__(self, tracker: WarmUpTracker, store: ISignInStore, http: IMicrosoftHttpClients): + self._tracker = tracker + self._store = store + self._http = http + self._tasks: list[asyncio.Task] = [] + + def start(self) -> None: + # Pre-open the TLS connections to Microsoft in the background: the first call after a cold start otherwise pays DNS, TCP and TLS (and any dead-address timeout) itself. + self._tracker.begin() + self._tasks = [asyncio.create_task(self._tracker.warm_up()), asyncio.create_task(self._sweep_sign_ins())] + + async def stop(self) -> None: + for task in self._tasks: + task.cancel() + self._tasks = [] + self._store.clear() + await self._http.close_shared_client() + + async def _sweep_sign_ins(self) -> None: + """Drop expired password sign-in jobs (and stale rate-limit windows) every few seconds.""" + while True: + await asyncio.sleep(self.SWEEP_EVERY) + self._store.sweep() diff --git a/src/application/services/chat_file_lookup.py b/src/application/services/chat_file_lookup.py new file mode 100644 index 0000000..7f37207 --- /dev/null +++ b/src/application/services/chat_file_lookup.py @@ -0,0 +1,14 @@ +from src.application.abstractions.i_graph_client import IGraphClient +from src.application.services.chat_html_sanitizer import ChatHtmlSanitizer +from src.application.services.graph_paths import GraphPaths + + +class ChatFileLookup: + """A file shared in a chat message, found by its attachment id.""" + + def __init__(self, html: ChatHtmlSanitizer): + self._html = html + + def find(self, note: IGraphClient, chat_id: str, message_id: str, attachment_id: str) -> dict | None: + message = note.get(GraphPaths.message_path(chat_id, message_id)) + return next((f for f in self._html.reference_files(message) if f.get("id") == attachment_id), None) diff --git a/src/application/services/chat_html_sanitizer.py b/src/application/services/chat_html_sanitizer.py new file mode 100644 index 0000000..d05d882 --- /dev/null +++ b/src/application/services/chat_html_sanitizer.py @@ -0,0 +1,248 @@ +"""Chat message HTML: a strict allowlist sanitizer for what the composer sends, and the media and +file references pulled out of what Graph returns. + +Sending: only formatting tags survive, with no attributes except `a[href]` (http, https, mailto), +`at[id]` for a mention the request declares, `img[src]` pointing at one of the request's own +inline images (`../hostedContents/{n}/$value`), and `emoji[id]` for an id in the Teams emoji catalogue, rewritten to exactly +what Teams sends (``, alt and title taken from the catalogue, never from the +request). Everything else is dropped (the text inside an +unknown tag is kept and escaped; script/style/template contents are dropped whole). +""" + +import html +import json +import re +from html.parser import HTMLParser +from urllib.parse import urlsplit + + +class _Sanitizer(HTMLParser): + def __init__(self, mention_ids: set[int], image_ids: set[int], emoji_lookup=None): + super().__init__(convert_charrefs=True) + self.out: list[str] = [] + self.open: list[str] = [] + self.skip = 0 + self.mention_ids = mention_ids + self.image_ids = image_ids + self.used_mentions: set[int] = set() + self.used_images: set[int] = set() + self.emoji_lookup = emoji_lookup + self.emoji_count = 0 + + def handle_starttag(self, tag, attrs): + tag = tag.lower() + if tag in ChatHtmlSanitizer.DROP_CONTENT: + self.skip += 1 + return + if self.skip: + return + attrs = dict(attrs) + if tag == "a": + href = ChatHtmlSanitizer._safe_href(attrs.get("href")) + self.out.append(f'' if href else "") + self.open.append(tag) + elif tag == "at": + mid = ChatHtmlSanitizer._int(attrs.get("id")) + if mid is not None and mid in self.mention_ids: + self.out.append(f'') + self.used_mentions.add(mid) + self.open.append(tag) + elif tag == "emoji": + ref = attrs.get("id") + found = self.emoji_lookup(ref) if self.emoji_lookup and isinstance(ref, str) and self.emoji_count < ChatHtmlSanitizer.MAX_EMOJIS else None + if found: + alt, title = found + self.out.append(f'') + self.emoji_count += 1 + elif tag == "img": + m = ChatHtmlSanitizer.HOSTED_SRC.fullmatch(attrs.get("src") or "") + if m and int(m.group(1)) in self.image_ids: + n = int(m.group(1)) + self.out.append(f'') + self.used_images.add(n) + elif tag in ChatHtmlSanitizer.VOID: + self.out.append(f"<{tag}>") + elif tag in ChatHtmlSanitizer.ALLOWED: + self.out.append(f"<{tag}>") + self.open.append(tag) + + def handle_endtag(self, tag): + tag = tag.lower() + if tag in ChatHtmlSanitizer.DROP_CONTENT: + self.skip = max(0, self.skip - 1) + return + if self.skip or tag in ChatHtmlSanitizer.VOID or tag not in self.open: + return + # Close everything opened after it too, so the output always nests. + while self.open: + top = self.open.pop() + self.out.append(f"") + if top == tag: + break + + def handle_data(self, data): + if not self.skip: + self.out.append(html.escape(data, quote=False)) + + def result(self) -> str: + self.close() + while self.open: + self.out.append(f"") + return "".join(self.out) + + +class ChatHtmlSanitizer: + """The chat composer's HTML allowlist and the media, files and cards inside a Graph chat message.""" + + ALLOWED = {"b", "strong", "i", "em", "u", "s", "strike", "br", "p", "div", "span", "ul", "ol", "li", "a", "code", "pre", "blockquote"} + VOID = {"br"} + DROP_CONTENT = {"script", "style", "template", "noscript", "iframe", "object", "embed", "svg", "math", "head", "title", "textarea", "select"} + LINK_SCHEMES = ("http", "https", "mailto") + MAX_EMOJIS = 100 + HOSTED_SRC = re.compile(r"\.\./hostedContents/(\d{1,2})/\$value") + _IMG_TAG = re.compile(r"]*>", re.IGNORECASE) + _ATTR = re.compile(r"""([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')""") + _HOSTED_PATH = re.compile(r"/(?:v1\.0|beta)/chats/[^/]+/messages/[^/]+/hostedContents/([^/?#]+)/\$value") + GRAPH_HOST = "graph.microsoft.com" + # Exact hosts only (no suffix matching): Giphy's and Tenor's media CDNs, and the sticker hosts seen in received sticker cards. + GIF_HOSTS = frozenset({"media.giphy.com", "media0.giphy.com", "media1.giphy.com", "media2.giphy.com", "media3.giphy.com", "media4.giphy.com", "i.giphy.com", "media.tenor.com", "media1.tenor.com", "c.tenor.com"}) + STICKER_HOSTS = frozenset({"stickers.newfuture.cc"}) + GIPHY_ITEMTYPE = "http://schema.skype.com/Giphy" + CARD_PREFIX = "application/vnd.microsoft.card." + MAX_CARD_CHARS = 64 * 1024 + + @staticmethod + def _int(value): + return int(value) if isinstance(value, str) and value.isdigit() and len(value) <= 2 else None + + @staticmethod + def _safe_href(value) -> str | None: + value = (value or "").strip() + if not value or any(ord(ch) < 32 for ch in value): + return None + parts = urlsplit(value) + if parts.scheme.lower() not in ChatHtmlSanitizer.LINK_SCHEMES: + return None + if parts.scheme.lower() != "mailto" and not parts.netloc: + return None + return value + + @staticmethod + def sanitize(content: str, mention_ids: set[int] = frozenset(), image_ids: set[int] = frozenset(), emoji_lookup=None) -> tuple[str, set[int], set[int]]: + """(clean HTML, mention ids it references, inline image ids it references). `emoji_lookup(id)` returns (alt, title) for a catalogue emoji id, else None.""" + parser = _Sanitizer(set(mention_ids), set(image_ids), emoji_lookup) + parser.feed(content) + return parser.result(), parser.used_mentions, parser.used_images + + @staticmethod + def text_to_html(text: str) -> str: + return html.escape(text, quote=False).replace("\r\n", "\n").replace("\n", "
") + + @staticmethod + def _attrs(tag: str) -> dict[str, str]: + return {m.group(1).lower(): html.unescape(m.group(2) if m.group(2) is not None else m.group(3)) for m in ChatHtmlSanitizer._ATTR.finditer(tag)} + + @staticmethod + def _size(value) -> int | None: + try: + n = round(float(value)) + except (TypeError, ValueError): + return None + return n if 0 < n <= 4096 else None + + @staticmethod + def _https_host(url: str, hosts: frozenset) -> bool: + try: + parts = urlsplit(url or "") + port = parts.port + except ValueError: + return False + return parts.scheme == "https" and port in (None, 443) and not parts.username and not parts.password and (parts.hostname or "").lower() in hosts + + @staticmethod + def is_gif_host(url: str) -> bool: + return ChatHtmlSanitizer._https_host(url, ChatHtmlSanitizer.GIF_HOSTS) + + @staticmethod + def hosted_content_id(src: str) -> str | None: + """The hosted-content id when `src` is exactly a Graph hosted-content URL, else None.""" + if not ChatHtmlSanitizer._https_host(src, frozenset({ChatHtmlSanitizer.GRAPH_HOST})): + return None + m = ChatHtmlSanitizer._HOSTED_PATH.fullmatch(urlsplit(src).path) + return m.group(1) if m else None + + @staticmethod + def media(message: dict) -> list[dict]: + """Images the body shows, in order: [{kind, url, hostedContentId, alt, width, height}]. + + kind "hosted": a pasted image held by Graph; `url` is null and `hostedContentId` is the id the web + passes to the plugin's media proxy. kind "gif": a Giphy/Tenor GIF; `url` is the public media URL + (exact allowlisted hosts, https only). Other images (emoji, external) are left out.""" + content = (message.get("body") or {}).get("content") or "" + out, seen = [], set() + for tag in ChatHtmlSanitizer._IMG_TAG.findall(content): + a = ChatHtmlSanitizer._attrs(tag) + src = a.get("src") or "" + if src in seen: + continue + hosted = ChatHtmlSanitizer.hosted_content_id(src) + if hosted: + item = {"kind": "hosted", "url": None, "hostedContentId": hosted} + elif ChatHtmlSanitizer.is_gif_host(src): + item = {"kind": "gif", "url": src, "hostedContentId": None} + else: + continue + seen.add(src) + item.update({"alt": (a.get("alt") or "")[:300] or None, "width": ChatHtmlSanitizer._size(a.get("width")), "height": ChatHtmlSanitizer._size(a.get("height"))}) + out.append(item) + return out + + @staticmethod + def reference_files(message: dict) -> list[dict]: + """Files shared into the chat (reference attachments): [{id, name, contentUrl}] (type and size are filled in separately).""" + out = [] + for a in message.get("attachments") or []: + if a.get("contentType") == "reference" and a.get("contentUrl"): + out.append({"id": a.get("id"), "name": a.get("name"), "contentUrl": a.get("contentUrl")}) + return out + + @staticmethod + def cards(message: dict) -> list[dict]: + """Card attachments (adaptive cards, polls, sticker apps, link previews): [{id, contentType, teamsAppId, name, content, sticker}]. + + `content` is the parsed card JSON for a read-only render (null when it isn't JSON or is too large); + `sticker` is {url, alt} when the card is a single image (sticker apps send those), else null.""" + out = [] + for a in message.get("attachments") or []: + ctype = a.get("contentType") or "" + if not ctype.startswith(ChatHtmlSanitizer.CARD_PREFIX): + continue + raw = a.get("content") + parsed = None + if isinstance(raw, str) and len(raw) <= ChatHtmlSanitizer.MAX_CARD_CHARS: + try: + parsed = json.loads(raw) + except ValueError: + parsed = None + elif isinstance(raw, dict): + parsed = raw + out.append({"id": a.get("id"), "contentType": ctype, "teamsAppId": a.get("teamsAppId"), "name": a.get("name"), "content": parsed, "sticker": ChatHtmlSanitizer._sticker(parsed)}) + return out + + @staticmethod + def _sticker(card) -> dict | None: + if not isinstance(card, dict) or card.get("type") != "AdaptiveCard": + return None + body = card.get("body") + if not isinstance(body, list) or len(body) != 1 or not isinstance(body[0], dict) or body[0].get("type") != "Image": + return None + url = body[0].get("url") + if not isinstance(url, str) or not (ChatHtmlSanitizer._https_host(url, ChatHtmlSanitizer.STICKER_HOSTS) or ChatHtmlSanitizer.is_gif_host(url)): + return None + return {"url": url, "alt": body[0].get("altText")} + + @staticmethod + def gif_html(url: str, alt: str | None, width: int | None, height: int | None) -> str: + """The markup Teams itself sends for a GIF picked from its GIF tab.""" + size = "".join(f' {k}="{v}"' for k, v in (("width", width), ("height", height)) if v) + return f'{html.escape(alt or ' diff --git a/src/application/services/content_names.py b/src/application/services/content_names.py new file mode 100644 index 0000000..1540a64 --- /dev/null +++ b/src/application/services/content_names.py @@ -0,0 +1,21 @@ +import re + + +class ContentNames: + """File names and content types of untrusted files, made safe to serve.""" + + CONTENT_TYPE_PATTERN = re.compile(r"[A-Za-z0-9][\w.+-]{0,126}/[A-Za-z0-9][\w.+-]{0,126}") + ACTIVE_TYPE_PARTS = ("html", "svg", "xml", "javascript", "ecmascript", "multipart") + + @classmethod + def safe_content_type(cls, value: str | None) -> str: + # Attachments are untrusted: anything that could run as a page or script is served as opaque bytes. + content_type = (value or "").split(";")[0].strip().lower() + if not cls.CONTENT_TYPE_PATTERN.fullmatch(content_type) or any(part in content_type for part in cls.ACTIVE_TYPE_PARTS): + return "application/octet-stream" + return content_type + + @staticmethod + def safe_name(name: str | None) -> str: + cleaned = re.sub(r'[\x00-\x1f\x7f\\/:*?"<>|]', "_", name or "") + return cleaned.strip(" .")[:255] or "attachment" diff --git a/src/application/services/drive_file_text_reader.py b/src/application/services/drive_file_text_reader.py new file mode 100644 index 0000000..6fc055a --- /dev/null +++ b/src/application/services/drive_file_text_reader.py @@ -0,0 +1,44 @@ +import posixpath + +from src.application.abstractions.i_file_text_extractor import IFileTextExtractor +from src.application.abstractions.i_graph_client import IGraphClient +from src.application.abstractions.i_pdf_converter import IPdfConverter +from src.application.abstractions.i_teams_files import ITeamsFiles +from src.application.services.drive_item_checks import DriveItemChecks +from src.domain.errors.service_error import ServiceError +from src.domain.files.file_text_limits import FileTextLimits + + +class DriveFileTextReader: + """The FileText of a drive item: legacy formats go through a PDF conversion first.""" + + def __init__(self, files: ITeamsFiles, pdf: IPdfConverter, text: IFileTextExtractor, checks: DriveItemChecks): + self._files = files + self._pdf = pdf + self._text = text + self._checks = checks + + def read(self, note: IGraphClient, drive_id: str | None, item_id: str, offset: int, max_chars: int, sheet: str | None, row_offset: int, max_rows: int) -> dict: + item = note.get(self._files.item_base(drive_id, item_id), {"$select": "id,name,size,file,folder,@microsoft.graph.downloadUrl"}) + self._checks.check_file(item) + name = item.get("name") or "" + kind = self._text.kind_of(name) + if kind is None: + raise ServiceError(415, FileTextLimits.UNSUPPORTED) + if (item.get("size") or 0) > self._text.MAX_SOURCE_BYTES and kind != "legacy": + raise ServiceError(413, "The file is larger than 25 MB.") + notes = [] + if kind == "legacy": + spooled = self._pdf.drive_item_pdf(note, item_id, drive_id) + try: + data = spooled.read(self._text.MAX_SOURCE_BYTES + 1) + finally: + spooled.close() + name = posixpath.splitext(name)[0] + ".pdf" + notes.append("Converted to PDF by Microsoft 365 before reading.") + else: + url = self._files.download_link(note, self._files.item_base(drive_id, item_id), item) + data = self._files.fetch_bytes(url, self._text.MAX_SOURCE_BYTES) + result = self._text.extract(name, data, offset, max_chars, sheet, row_offset, max_rows, notes) + result["name"] = item.get("name") or result["name"] + return result diff --git a/src/application/services/drive_item_checks.py b/src/application/services/drive_item_checks.py new file mode 100644 index 0000000..de92aaf --- /dev/null +++ b/src/application/services/drive_item_checks.py @@ -0,0 +1,26 @@ +from src.application.abstractions.i_pdf_converter import IPdfConverter +from src.domain.errors.service_error import ServiceError +from src.domain.files.file_limits import FileLimits + + +class DriveItemChecks: + """What a drive item must be before it is read or converted.""" + + FOLDER_DETAIL = "That item is a folder, which has no content to download." + + def __init__(self, pdf: IPdfConverter): + self._pdf = pdf + + def check_file(self, item: dict) -> None: + if isinstance(item, dict) and item.get("folder") is not None: + raise ServiceError(404, self.FOLDER_DETAIL) + if not isinstance(item, dict) or item.get("file") is None: + raise ServiceError(404, "File not found.") + + def check_source(self, item: dict) -> None: + if not isinstance(item, dict) or item.get("file") is None: + raise ServiceError(404, "File not found.") + if not self._pdf.is_convertible(item.get("name")): + raise ServiceError(415, "This file type cannot be converted to PDF.") + if (item.get("size") or 0) > FileLimits.MAX_CHAT_FILE_BYTES: + raise ServiceError(413, "The file is larger than 100 MB.") diff --git a/src/application/services/graph_paths.py b/src/application/services/graph_paths.py new file mode 100644 index 0000000..aefbe2b --- /dev/null +++ b/src/application/services/graph_paths.py @@ -0,0 +1,20 @@ +import base64 +from urllib.parse import quote + + +class GraphPaths: + """Graph paths of chat messages and of the drive items behind sharing links.""" + + DRIVE_ITEM_FIELDS = "id,name,size,file,webUrl" + + @staticmethod + def share_id(url: str) -> str: + return "u!" + base64.urlsafe_b64encode(url.encode()).decode().rstrip("=") + + @classmethod + def drive_item_path(cls, url: str, select: str = DRIVE_ITEM_FIELDS) -> str: + return f"shares/{cls.share_id(url)}/driveItem?$select={select}" + + @staticmethod + def message_path(chat_id: str, message_id: str) -> str: + return f"chats/{chat_id}/messages/{quote(message_id, safe='')}" diff --git a/src/application/services/image_sniffer.py b/src/application/services/image_sniffer.py new file mode 100644 index 0000000..4931738 --- /dev/null +++ b/src/application/services/image_sniffer.py @@ -0,0 +1,23 @@ +class ImageSniffer: + """Image formats from the magic bytes.""" + + CHAT_IMAGE_TYPES = {b"\x89PNG": "image/png", b"\xff\xd8\xff": "image/jpeg", b"GIF8": "image/gif"} + + @classmethod + def chat_image(cls, head: bytes) -> str | None: + """PNG, JPEG, GIF or WEBP from the magic bytes; anything else is None.""" + for magic, mime in cls.CHAT_IMAGE_TYPES.items(): + if head.startswith(magic): + return mime + if head[:4] == b"RIFF" and head[8:12] == b"WEBP": + return "image/webp" + return None + + @staticmethod + def photo(data: bytes) -> str: + # OutNote's public getter returns only the body bytes, so sniff the format from the magic number. + if data[1:4] == b"PNG": + return "image/png" + if data[:6] in (b"GIF87a", b"GIF89a"): + return "image/gif" + return "image/jpeg" diff --git a/src/application/services/linked_identity_reader.py b/src/application/services/linked_identity_reader.py new file mode 100644 index 0000000..8d954f0 --- /dev/null +++ b/src/application/services/linked_identity_reader.py @@ -0,0 +1,21 @@ +import asyncio + +from src.application.abstractions.i_graph_client_factory import IGraphClientFactory +from src.application.abstractions.i_profile_reader import IProfileReader +from src.application.dtos.linked_identity import LinkedIdentity + + +class LinkedIdentityReader: + """Who a new refresh token belongs to; empty when that can't be read.""" + + def __init__(self, clients: IGraphClientFactory, profile: IProfileReader): + self._clients = clients + self._profile = profile + + async def read(self, refresh_token: str) -> LinkedIdentity: + try: + note, broker = self._clients.build_client(refresh_token) + me = await asyncio.to_thread(self._profile.get, note, broker) + return LinkedIdentity(id=me.get("id"), email=me.get("mail") or me.get("userPrincipalName"), name=me.get("displayName")) + except Exception: + return LinkedIdentity() diff --git a/src/application/services/mail_attachment_reader.py b/src/application/services/mail_attachment_reader.py new file mode 100644 index 0000000..6f8c6ac --- /dev/null +++ b/src/application/services/mail_attachment_reader.py @@ -0,0 +1,36 @@ +from src.application.abstractions.i_graph_client import IGraphClient +from src.domain.errors.service_error import ServiceError +from src.domain.files.file_limits import FileLimits +from src.domain.validation.graph_ids import GraphIds + + +class MailAttachmentReader: + """A mail attachment's metadata and bytes, capped.""" + + @staticmethod + def metadata(note: IGraphClient, message_id: str, attachment_id: str) -> dict: + # Look the attachment up in the metadata list first: it carries the type and size, so an oversized one is refused before downloading it. + meta = next((a for a in note.list(f"me/messages/{GraphIds.seg(message_id)}/attachments") if a.get("id") == attachment_id), None) + if meta is None: + raise ServiceError(404, "Attachment not found.") + return meta + + @staticmethod + def check_declared_size(meta: dict) -> None: + if (meta.get("size") or 0) > FileLimits.MAX_DOWNLOAD_BYTES: + raise ServiceError(413, "The attachment is larger than 25 MB.") + + @staticmethod + def download(note: IGraphClient, message_id: str, attachment_id: str) -> bytes: + try: + data = note.download_attachment(GraphIds.seg(message_id), GraphIds.seg(attachment_id)) + except ValueError as ex: + raise ServiceError(422, str(ex)) + if len(data) > FileLimits.MAX_DOWNLOAD_BYTES: + raise ServiceError(413, "The attachment is larger than 25 MB.") + return data + + @staticmethod + def attachment_meta(item: dict) -> dict: + keep = ("id", "name", "contentType", "size", "isInline", "contentId") + return {k: item.get(k) for k in keep} diff --git a/src/application/services/mail_folder_tree.py b/src/application/services/mail_folder_tree.py new file mode 100644 index 0000000..72da507 --- /dev/null +++ b/src/application/services/mail_folder_tree.py @@ -0,0 +1,43 @@ +import logging +from concurrent.futures import ThreadPoolExecutor + +from src.application.abstractions.i_account_broker import IAccountBroker +from src.application.abstractions.i_graph_client import IGraphClient +from src.application.abstractions.i_mail_folder_roles import IMailFolderRoles + + +class MailFolderTree: + """The mail folders as a tree, with their well-known names.""" + + log = logging.getLogger("schuly.microsoft") + + def __init__(self, roles: IMailFolderRoles): + self._roles = roles + + def read(self, note: IGraphClient, broker: IAccountBroker) -> list[dict]: + roles = self._roles.cached(broker) + if roles is not None: + return self.tree(note.mail_folders(include_children=True), roles) + # First list this hour: resolve the well-known roles in parallel with the folder walk, so they cost no extra round trip. + with ThreadPoolExecutor(max_workers=1) as pool: + pending = pool.submit(self._roles.lookup, note) + flat = note.mail_folders(include_children=True) + try: + roles = pending.result() + except Exception as ex: + # Roles only label folders; the tree is still useful without them. + self.log.info("mail folder role lookup failed: %s", type(ex).__name__) + roles = {} + self._roles.remember(broker, roles) + return self.tree(flat, roles) + + @staticmethod + def tree(flat: list[dict], roles: dict[str, str] | None = None) -> list[dict]: + # OutNote walks the subfolders breadth-first into a flat list; rebuild the nesting from parentFolderId. + roles = roles or {} + nodes = {f["id"]: {**f, "wellKnownName": roles.get(f["id"]), "childFolders": []} for f in flat} + roots = [] + for f in flat: + parent = nodes.get(f.get("parentFolderId")) + (parent["childFolders"] if parent is not None and f["id"] != f.get("parentFolderId") else roots).append(nodes[f["id"]]) + return roots diff --git a/src/application/services/mail_message_builder.py b/src/application/services/mail_message_builder.py new file mode 100644 index 0000000..8d7cda8 --- /dev/null +++ b/src/application/services/mail_message_builder.py @@ -0,0 +1,49 @@ +import base64 +import binascii +import re + +from src.application.dtos.mail_message_in import MailMessageIn +from src.application.services.content_names import ContentNames +from src.domain.errors.service_error import ServiceError +from src.domain.files.file_limits import FileLimits + + +class MailMessageBuilder: + """A mail message from the caller's input, checked: recipients, subject, body size and attachments.""" + + EMAIL_PATTERN = re.compile(r"[^@\s<>(),;:\\\"\[\]]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?\.[A-Za-z]{2,}") + MAX_RECIPIENTS = 100 + MAX_SUBJECT = 255 + + @staticmethod + def check_text(value: str, name: str) -> None: + if len(value.encode("utf-8")) > FileLimits.MAX_BODY_BYTES: + raise ServiceError(413, f"{name} is larger than 1 MB.") + + def addresses(self, values: list[str] | None, name: str) -> list[str]: + out = [v.strip() for v in values or []] + for address in out: + if len(address) > 254 or not self.EMAIL_PATTERN.fullmatch(address): + raise ServiceError(400, f"Invalid address in {name}.") + return out + + def message_args(self, body: MailMessageIn) -> dict: + to, cc, bcc = self.addresses(body.to, "to"), self.addresses(body.cc, "cc"), self.addresses(body.bcc, "bcc") + if not to and not cc and not bcc: + raise ServiceError(400, "At least one recipient is required.") + if len(to) + len(cc) + len(bcc) > self.MAX_RECIPIENTS: + raise ServiceError(400, f"At most {self.MAX_RECIPIENTS} recipients are allowed.") + if len(body.subject) > self.MAX_SUBJECT: + raise ServiceError(400, f"The subject may be at most {self.MAX_SUBJECT} characters.") + self.check_text(body.body, "body") + attachments, total = [], 0 + for item in body.attachments or []: + try: + data = base64.b64decode(item.contentBase64, validate=True) + except (binascii.Error, ValueError): + raise ServiceError(400, "An attachment is not valid base64.") + total += len(data) + if total > FileLimits.MAX_SEND_ATTACHMENT_BYTES: + raise ServiceError(413, "Attachments may total at most 3 MB.") + attachments.append((ContentNames.safe_name(item.name), data)) + return {"to": to, "subject": body.subject, "body": body.body, "html": body.html, "cc": cc or None, "bcc": bcc or None, "attachments": attachments or None} diff --git a/src/application/services/onenote_html_sanitizer.py b/src/application/services/onenote_html_sanitizer.py new file mode 100644 index 0000000..d490a95 --- /dev/null +++ b/src/application/services/onenote_html_sanitizer.py @@ -0,0 +1,488 @@ +"""OneNote page HTML, in both directions, and the page's ink. + +Reading is the sanitising step. A page is parsed into a small tree and rebuilt from an allowlist: +nothing of the page's own markup is passed through. Scripts, styles, event attributes, forms and +every tag outside the list are dropped (the text inside an unknown tag is kept), styles keep only a +few text properties with plain values, links keep only http, https and mailto, and positioned items +(OneNote's text boxes, pictures and files) keep only a numeric left, top and width. + +Pictures and attached files are OneNote resources behind the account's token. Their address is +never handed out: an `img` keeps only `data-resource` (the resource id, scoped like every other id) +and the caller fetches the bytes through the resource route. Ink, which Graph only sends as InkML +in a second multipart part, becomes inline SVG path data. + +Writing (a new page, a replaced text box, appended text) goes through the same allowlist with no +styles beyond simple text formatting, and comes out as the XHTML OneNote takes. + +A text box is editable only when it holds nothing but text: replacing a box with a picture, a file +or ink in it would drop them, so such a box is shown and never written over. +""" + +import html +import re +from html.parser import HTMLParser +from urllib.parse import urlsplit + + +class _Node: + __slots__ = ("tag", "attrs", "children") + + def __init__(self, tag: str, attrs: dict[str, str]): + self.tag = tag + self.attrs = attrs + self.children: list = [] + + +class _Tree(HTMLParser): + """A forgiving tree builder: OneNote's markup is regular, and anything that is not is closed where it can be.""" + + def __init__(self): + super().__init__(convert_charrefs=True) + self.root = _Node("#root", {}) + self.stack = [self.root] + + def handle_starttag(self, tag, attrs): + node = _Node(tag.lower(), {k.lower(): (v or "") for k, v in attrs}) + self.stack[-1].children.append(node) + if node.tag not in OneNoteHtmlSanitizer.VOID: + self.stack.append(node) + + def handle_startendtag(self, tag, attrs): + self.stack[-1].children.append(_Node(tag.lower(), {k.lower(): (v or "") for k, v in attrs})) + + def handle_endtag(self, tag): + tag = tag.lower() + for i in range(len(self.stack) - 1, 0, -1): + if self.stack[i].tag == tag: + del self.stack[i:] + return + + def handle_data(self, data): + self.stack[-1].children.append(data) + + +class _Render: + def __init__(self, scope: str | None, write: bool = False): + self.scope = scope + self.write = write + self.resources: dict[str, dict] = {} + + def children(self, node: _Node) -> str: + return "".join(self.node(c) for c in node.children) + + def node(self, node) -> str: + if isinstance(node, str): + return html.escape(node, quote=False) + tag = node.tag + if tag in OneNoteHtmlSanitizer.DROP_CONTENT: + return "" + if tag == "img": + return "" if self.write else self.image(node, positioned=False) + if tag == "object": + return "" if self.write else self.file(node, positioned=False) + if tag not in OneNoteHtmlSanitizer.TEXT_TAGS: + return self.children(node) + if tag == "br": + return "
" if self.write else "
" + attrs = [] + if tag == "a": + href = OneNoteHtmlSanitizer.safe_href(node.attrs.get("href")) + if href: + attrs.append(f'href="{html.escape(href, quote=True)}"') + if not self.write: + attrs.append('rel="noopener noreferrer"') + if tag in ("td", "th"): + for name in ("colspan", "rowspan"): + value = node.attrs.get(name, "") + if value.isdigit() and 1 <= int(value) <= 100: + attrs.append(f'{name}="{int(value)}"') + tag_value = node.attrs.get("data-tag", "").lower() + if tag_value and OneNoteHtmlSanitizer.TAG_VALUE.fullmatch(tag_value) and tag in ("p", "li", "span", "div", "h1", "h2", "h3", "h4", "h5", "h6"): + attrs.append(f'data-tag="{tag_value}"') + style = OneNoteHtmlSanitizer._style(node.attrs.get("style"), OneNoteHtmlSanitizer.WRITE_STYLE_PROPS if self.write else OneNoteHtmlSanitizer.STYLE_PROPS) + if style: + attrs.append(f'style="{html.escape(style, quote=True)}"') + head = f"<{tag} {' '.join(attrs)}>" if attrs else f"<{tag}>" + return f"{head}{self.children(node)}" + + def _position(self, node: _Node, positioned: bool, width: float | None = None) -> str: + if not positioned: + return "" + style = node.attrs.get("style") + left, top = OneNoteHtmlSanitizer._length(style, "left") or 0.0, OneNoteHtmlSanitizer._length(style, "top") or 0.0 + parts = [f"position:absolute;left:{OneNoteHtmlSanitizer._num(left)}px;top:{OneNoteHtmlSanitizer._num(top)}px"] + if width is not None: + parts.append(f"width:{OneNoteHtmlSanitizer._num(width)}px") + return f' style="{";".join(parts)}"' + + def image(self, node: _Node, positioned: bool) -> str: + rid = OneNoteHtmlSanitizer.resource_id(node.attrs.get("data-fullres-src")) or OneNoteHtmlSanitizer.resource_id(node.attrs.get("src")) + if rid is None: + return "" + sid = OneNoteHtmlSanitizer.scoped(self.scope, rid) + kind = (node.attrs.get("data-fullres-src-type") or node.attrs.get("data-src-type") or "").lower() + self.resources.setdefault(sid, {"id": sid, "kind": "image", "name": None, "contentType": kind if OneNoteHtmlSanitizer.CONTENT_TYPE.fullmatch(kind) else None}) + alt = (node.attrs.get("alt") or "")[:OneNoteHtmlSanitizer.MAX_ALT] + width, height = OneNoteHtmlSanitizer._attr_number(node.attrs.get("width")), OneNoteHtmlSanitizer._attr_number(node.attrs.get("height")) + size = (f' width="{OneNoteHtmlSanitizer._num(width)}"' if width else "") + (f' height="{OneNoteHtmlSanitizer._num(height)}"' if height else "") + return f'{html.escape(alt, quote=True)}' + + def file(self, node: _Node, positioned: bool) -> str: + rid = OneNoteHtmlSanitizer.resource_id(node.attrs.get("data")) + if rid is None: + return "" + sid = OneNoteHtmlSanitizer.scoped(self.scope, rid) + name = re.sub(r"[\x00-\x1f\x7f]", "", node.attrs.get("data-attachment") or "")[:OneNoteHtmlSanitizer.MAX_NAME] or "file" + kind = (node.attrs.get("type") or "").lower() + self.resources.setdefault(sid, {"id": sid, "kind": "file", "name": name, "contentType": kind if OneNoteHtmlSanitizer.CONTENT_TYPE.fullmatch(kind) else None}) + return f'{html.escape(name)}' + + +class _Ink(HTMLParser): + """InkML read as tags: brushes with their properties, and traces with the brush they use. Parsed as markup, never as XML (no entities, no DTD).""" + + def __init__(self): + super().__init__(convert_charrefs=True) + self.brushes: dict[str, dict] = {} + self.brush: dict | None = None + self.traces: list[tuple[str, list[str]]] = [] + self.trace: tuple[str, list[str]] | None = None + + def handle_starttag(self, tag, attrs): + attrs = {k.lower(): v or "" for k, v in attrs} + if tag == "inkml:brush": + self.brush = {"color": "#333333", "width": 50.0, "opacity": 1.0, "highlight": False} + self.brushes[attrs.get("xml:id", "")] = self.brush + elif tag == "inkml:brushproperty" and self.brush is not None: + name, value = attrs.get("name", "").lower(), attrs.get("value", "") + if name == "color" and OneNoteHtmlSanitizer.COLOR.fullmatch(value): + self.brush["color"] = value + elif name == "width" and OneNoteHtmlSanitizer.NUMBER.fullmatch(value): + self.brush["width"] = float(value) + elif name == "transparency" and OneNoteHtmlSanitizer.NUMBER.fullmatch(value): + self.brush["opacity"] = min(1.0, max(0.05, 1.0 - float(value) / 255.0)) + elif name == "rasterop": + self.brush["highlight"] = value.lower() == "maskpen" + elif tag == "inkml:trace" and len(self.traces) < OneNoteHtmlSanitizer.MAX_TRACES: + self.trace = (attrs.get("brushref", "").lstrip("#"), []) + self.traces.append(self.trace) + + handle_startendtag = handle_starttag + + def handle_endtag(self, tag): + if tag == "inkml:brush": + self.brush = None + elif tag == "inkml:trace": + self.trace = None + + def handle_data(self, data): + if self.trace is not None: + self.trace[1].append(data) + + +class OneNoteHtmlSanitizer: + """OneNote page HTML: the allowlist for reading and writing, text-box editability, and InkML as SVG.""" + + TEXT_TAGS = {"p", "div", "span", "b", "strong", "i", "em", "u", "s", "strike", "del", "sub", "sup", "a", "br", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", "table", "thead", "tbody", "tfoot", "tr", "td", "th", "pre", "code", "blockquote", "cite"} + VOID = {"br", "img", "hr", "meta", "link", "input", "col", "area", "base", "wbr", "source", "param", "embed"} + DROP_CONTENT = {"script", "style", "head", "title", "template", "noscript", "iframe", "frame", "frameset", "svg", "math", "canvas", "video", "audio", "form", "button", "select", "textarea", "applet", "embed", "xml"} + LINK_SCHEMES = ("http", "https", "mailto") + STYLE_PROPS = {"color", "background-color", "font-family", "font-size", "font-weight", "font-style", "text-decoration", "text-align", "vertical-align", "margin-top", "margin-bottom", "margin-left", "line-height", "border", "border-width", "border-style", "border-color", "border-collapse", "padding", "width"} + WRITE_STYLE_PROPS = {"color", "background-color", "font-weight", "font-style", "text-decoration", "text-align"} + STYLE_VALUE = re.compile(r"[#\w\s.,%'\"()+-]{1,100}") + STYLE_BLOCKED = ("url", "expression", "javascript", "image", "attr(", "var(", "env(") + NUMBER = re.compile(r"-?\d{1,6}(?:\.\d{1,3})?") + TAG_VALUE = re.compile(r"[a-z0-9:,-]{1,60}") + CONTENT_TYPE = re.compile(r"[a-z0-9][\w.+-]{0,126}/[a-z0-9][\w.+-]{0,126}") + COLOR = re.compile(r"#[0-9A-Fa-f]{6}") + # The ids OneNote gives page elements (`div:{guid}{n}`) and its resources (`0-abc!1-def`). + SAFE_ID = re.compile(r"[A-Za-z0-9!_{}:.\-]{1,200}") + RESOURCE_PATH = re.compile(r"/onenote/resources/([A-Za-z0-9!_{}:.\-]{1,200})/(?:\$value|content)$") + GRAPH_HOST = "graph.microsoft.com" + MAX_ALT = 2000 + MAX_NAME = 255 + # Ink bounds: a page drawn on for a whole lesson is thousands of strokes; past these the rest is left out. + MAX_TRACES = 5000 + MAX_POINTS = 400_000 + PX_PER_HIMETRIC = 96.0 / 2540.0 + BOX_WIDTH = 600 + BOX_HEIGHT = 120 + + @staticmethod + def _parse(markup: str) -> _Node: + tree = _Tree() + tree.feed(markup) + tree.close() + return tree.root + + @staticmethod + def _find(node: _Node, tag: str) -> _Node | None: + for child in node.children: + if isinstance(child, _Node): + if child.tag == tag: + return child + found = OneNoteHtmlSanitizer._find(child, tag) + if found is not None: + return found + return None + + @staticmethod + def _text(node: _Node) -> str: + return "".join(c if isinstance(c, str) else OneNoteHtmlSanitizer._text(c) for c in node.children) + + @staticmethod + def safe_href(value: str | None) -> str | None: + value = (value or "").strip() + if not value or len(value) > 2000 or any(ord(ch) < 32 for ch in value): + return None + try: + parts = urlsplit(value) + except ValueError: + return None + scheme = parts.scheme.lower() + if scheme not in OneNoteHtmlSanitizer.LINK_SCHEMES or (scheme != "mailto" and not parts.netloc): + return None + return value + + @staticmethod + def _style(value: str | None, allowed: set[str]) -> str: + out = [] + for rule in (value or "").split(";"): + name, sep, val = rule.partition(":") + name, val = name.strip().lower(), val.strip() + if not sep or name not in allowed or not OneNoteHtmlSanitizer.STYLE_VALUE.fullmatch(val) or any(b in val.lower() for b in OneNoteHtmlSanitizer.STYLE_BLOCKED): + continue + out.append(f"{name}:{val}") + return ";".join(out) + + @staticmethod + def _length(style: str | None, prop: str) -> float | None: + """A number of CSS px out of `left:48px;top:12pt` (a pt value is scaled).""" + for rule in (style or "").split(";"): + name, sep, val = rule.partition(":") + if sep and name.strip().lower() == prop: + val = val.strip().lower() + unit = "pt" if val.endswith("pt") else "" + number = val.removesuffix("px").removesuffix("pt").strip() + if OneNoteHtmlSanitizer.NUMBER.fullmatch(number): + px = float(number) * (96.0 / 72.0 if unit == "pt" else 1.0) + return round(px, 1) + return None + + @staticmethod + def _attr_number(value: str | None) -> float | None: + value = (value or "").strip().removesuffix("px") + return round(float(value), 1) if OneNoteHtmlSanitizer.NUMBER.fullmatch(value) else None + + @staticmethod + def _num(value: float) -> str: + return str(int(value)) if value == int(value) else str(value) + + @staticmethod + def scoped(scope: str | None, graph_id: str) -> str: + """An id handed out: bare for the person's own OneNote, `@` for a team's.""" + return f"{scope}@{graph_id}" if scope else graph_id + + @staticmethod + def resource_id(url: str | None) -> str | None: + """The resource id in a Graph resource address, accepted only from Graph's own https host.""" + if not url or len(url) > 2000: + return None + try: + parts = urlsplit(html.unescape(url)) + port = parts.port + except ValueError: + return None + if parts.scheme != "https" or (parts.hostname or "").lower() != OneNoteHtmlSanitizer.GRAPH_HOST or port not in (None, 443) or parts.username or parts.password: + return None + match = OneNoteHtmlSanitizer.RESOURCE_PATH.search(parts.path) + return match.group(1) if match and OneNoteHtmlSanitizer.SAFE_ID.fullmatch(match.group(1)) else None + + @staticmethod + def element_id(node: _Node) -> str | None: + """The id a PATCH can target: the one OneNote generated (`includeIDs=true`).""" + for name in ("id", "data-id"): + value = node.attrs.get(name) + if value and OneNoteHtmlSanitizer.SAFE_ID.fullmatch(value): + return value + return None + + @staticmethod + def text_only(node: _Node) -> bool: + return all(isinstance(c, str) or (c.tag in OneNoteHtmlSanitizer.TEXT_TAGS and OneNoteHtmlSanitizer.text_only(c)) for c in node.children) + + @staticmethod + def split_parts(body: str) -> tuple[str, str | None]: + """The HTML and the InkML of an answer. With `includeInkML` Graph answers multipart, the HTML first; without, the HTML alone.""" + first = body.split("\n", 1)[0].strip() + if not first.startswith("--") or len(first) < 3: + return body, None + page = ink = None + for part in body.split(first): + head, sep, rest = part.partition("\r\n\r\n") + if not sep: + head, sep, rest = part.partition("\n\n") + if not sep: + continue + head = head.lower() + if "text/html" in head and page is None: + page = rest.strip().removesuffix("--").strip() + elif "inkml" in head and ink is None: + ink = rest.strip().removesuffix("--").strip() + return page if page is not None else body, ink + + @staticmethod + def has_ink(page: str) -> bool: + """OneNote only marks ink in the HTML; the strokes come with `includeInkML`.""" + return "InkNode is not supported" in page + + @staticmethod + def render_page(page: str, ink: str | None = None, scope: str | None = None) -> dict: + """A page as sanitized markup, its resources, its text boxes and the size of its canvas.""" + root = OneNoteHtmlSanitizer._parse(page) + title_node = OneNoteHtmlSanitizer._find(root, "title") + title = re.sub(r"\s+", " ", OneNoteHtmlSanitizer._text(title_node)).strip() if title_node else "" + body = OneNoteHtmlSanitizer._find(root, "body") or root + render = _Render(scope) + parts, outlines = [], [] + width = height = 0.0 + for child in body.children: + if isinstance(child, str): + parts.append(render.node(child)) + continue + style = child.attrs.get("style") + left, top = OneNoteHtmlSanitizer._length(style, "left"), OneNoteHtmlSanitizer._length(style, "top") + positioned = left is not None or top is not None + if child.tag == "div": + oid = OneNoteHtmlSanitizer.element_id(child) + box_width = OneNoteHtmlSanitizer._length(style, "width") + inner = render.children(child) + editable = oid is not None and OneNoteHtmlSanitizer.text_only(child) + attrs = (f' data-outline="{html.escape(oid, quote=True)}" data-editable="{"true" if editable else "false"}"' if oid else "") + render._position(child, positioned, box_width) + parts.append(f"{inner}") + if oid: + outlines.append({"id": oid, "editable": editable, "html": inner}) + if positioned: + width = max(width, (left or 0) + (box_width or OneNoteHtmlSanitizer.BOX_WIDTH)) + height = max(height, (top or 0) + OneNoteHtmlSanitizer.BOX_HEIGHT) + elif child.tag == "img": + parts.append(render.image(child, positioned)) + if positioned: + width = max(width, (left or 0) + (OneNoteHtmlSanitizer._attr_number(child.attrs.get("width")) or 0)) + height = max(height, (top or 0) + (OneNoteHtmlSanitizer._attr_number(child.attrs.get("height")) or 0)) + elif child.tag == "object": + parts.append(render.file(child, positioned)) + if positioned: + width, height = max(width, (left or 0) + 240), max(height, (top or 0) + 40) + else: + parts.append(render.node(child)) + strokes = OneNoteHtmlSanitizer.ink_svg(ink) if ink else None + if strokes: + svg, ink_w, ink_h = strokes + parts.append(svg) + width, height = max(width, ink_w), max(height, ink_h) + return { + "title": title, + "html": "".join(parts), + "width": int(width + 24) if width else 0, + "height": int(height + 24) if height else 0, + "resources": list(render.resources.values()), + "outlines": outlines, + "hasInk": OneNoteHtmlSanitizer.has_ink(page), + } + + @staticmethod + def outline_style(page: str, outline: str) -> str | None: + """The place of one editable text box of the current page (`position:absolute;left;top;width`), or None when it is not one.""" + body = OneNoteHtmlSanitizer._find(OneNoteHtmlSanitizer._parse(page), "body") + for child in body.children if body else []: + if isinstance(child, _Node) and child.tag == "div" and OneNoteHtmlSanitizer.element_id(child) == outline: + if not OneNoteHtmlSanitizer.text_only(child): + return None + style = child.attrs.get("style") + left, top, width = OneNoteHtmlSanitizer._length(style, "left"), OneNoteHtmlSanitizer._length(style, "top"), OneNoteHtmlSanitizer._length(style, "width") + if left is None and top is None: + return "" + out = f"position:absolute;left:{OneNoteHtmlSanitizer._num(left or 0)}px;top:{OneNoteHtmlSanitizer._num(top or 0)}px" + return out + (f";width:{OneNoteHtmlSanitizer._num(width)}px" if width else "") + return None + + @staticmethod + def sanitize_for_write(markup: str) -> str: + """Rich text from the editor, cut down to formatting OneNote keeps, as XHTML. No pictures, files or positions.""" + return _Render(None, write=True).children(OneNoteHtmlSanitizer._parse(markup or "")) + + @staticmethod + def new_page(title: str, body: str | None) -> str: + """The XHTML document a new page is created from.""" + content = f"
{OneNoteHtmlSanitizer.sanitize_for_write(body)}
" if body and body.strip() else "" + return f"{html.escape(title, quote=False)}{content}" + + @staticmethod + def _points(trace: str, budget: int) -> list[tuple[float, float]]: + """The points of one trace (`x y f, x y f`), where a value may carry `'` (first difference) or `"` (second difference).""" + out, value, speed = [], [0.0, 0.0], [0.0, 0.0] + for sample in trace.split(","): + if len(out) >= budget: + break + for channel, token in enumerate(sample.split()[:2]): + kind = 1 if token.startswith("'") else 2 if token.startswith('"') else 0 + token = token.lstrip("'\"") + try: + number = float(token) + except ValueError: + continue + if kind == 0: + value[channel], speed[channel] = number, 0.0 + elif kind == 1: + speed[channel] = number + value[channel] += number + else: + speed[channel] += number + value[channel] += speed[channel] + out.append((value[0] * OneNoteHtmlSanitizer.PX_PER_HIMETRIC, value[1] * OneNoteHtmlSanitizer.PX_PER_HIMETRIC)) + return out + + @staticmethod + def ink_svg(xml: str) -> tuple[str, float, float] | None: + """The strokes as one SVG of paths in page pixels, with its width and height; None when there are none.""" + reader = _Ink() + try: + reader.feed(xml) + reader.close() + except Exception: + return None + paths: dict[str, list[str]] = {} + right = bottom = 0.0 + budget = OneNoteHtmlSanitizer.MAX_POINTS + for brush_id, chunks in reader.traces: + if brush_id not in reader.brushes or budget <= 0: + continue + points = OneNoteHtmlSanitizer._points("".join(chunks), budget) + budget -= len(points) + d, last = [], None + for x, y in points: + if last and abs(x - last[0]) < 0.4 and abs(y - last[1]) < 0.4: + continue + d.append(f"{'L' if d else 'M'}{x:.1f} {y:.1f}" + ("" if d else "l0.1 0")) + last = (x, y) + right, bottom = max(right, x), max(bottom, y) + if d: + paths.setdefault(brush_id, []).append("".join(d)) + if not paths: + return None + width, height = round(right + 8), round(bottom + 8) + out = [f'") + return "".join(out), float(width), float(height) + + @staticmethod + def plain_text(markup: str) -> str: + """Visible text of sanitized markup (search snippets, tests).""" + return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", markup))).strip() diff --git a/src/application/services/password_sign_in_responder.py b/src/application/services/password_sign_in_responder.py new file mode 100644 index 0000000..7ff5887 --- /dev/null +++ b/src/application/services/password_sign_in_responder.py @@ -0,0 +1,32 @@ +from src.application.abstractions.i_sign_in_job import ISignInJob +from src.application.dtos.password_sign_in_response import PasswordSignInResponse +from src.application.services.linked_identity_reader import LinkedIdentityReader +from src.domain.sign_in.sign_in_state import SignInState + + +class PasswordSignInResponder: + """A sign-in job's current place as a response. Reads progress only; the password is not on the job.""" + + def __init__(self, identities: LinkedIdentityReader): + self._identities = identities + + async def respond(self, job: ISignInJob) -> PasswordSignInResponse: + snap = job.snapshot() + state = snap["state"] + base = {"sign_in_id": job.id} + if state == SignInState.COMPLETE: + if job.account is None: + job.account = await self._identities.read(snap["refresh_token"]) + return PasswordSignInResponse(**base, status="complete", refresh_token=snap["refresh_token"], account=job.account) + if state == SignInState.ERROR: + return PasswordSignInResponse(**base, status="error", code=snap["code"], message=snap["message"]) + if state == SignInState.PUSH_PENDING: + return PasswordSignInResponse(**base, status="push_pending", number=snap["number"], expires_in=snap["expires_in"], methods=snap["methods"]) + if state == SignInState.OTP_REQUIRED: + status = "otp_invalid" if snap.get("otp_invalid") else "otp_required" + return PasswordSignInResponse(**base, status=status, attempts_left=snap["attempts_left"], methods=snap["methods"]) + if state in (SignInState.MFA_REQUIRED, SignInState.DENIED, SignInState.TIMED_OUT): + if snap["notice"] == "otp_exhausted": + return PasswordSignInResponse(**base, status="otp_invalid", attempts_left=0, methods=snap["methods"], message="Too many wrong codes; choose a method again.") + return PasswordSignInResponse(**base, status=state, methods=snap["methods"]) + return PasswordSignInResponse(**base, status="working") diff --git a/src/application/services/retry_after.py b/src/application/services/retry_after.py new file mode 100644 index 0000000..10a9b22 --- /dev/null +++ b/src/application/services/retry_after.py @@ -0,0 +1,12 @@ +import math + + +class RetryAfter: + """Whole seconds for a Retry-After header.""" + + DEFAULT_RETRY_AFTER = 30 # seconds, for a throttled call that named no wait + + @classmethod + def header(cls, seconds: float | None) -> str: + """Graph's value when it gave one.""" + return str(max(1, math.ceil(seconds))) if seconds else str(cls.DEFAULT_RETRY_AFTER) diff --git a/src/application/services/warm_up_tracker.py b/src/application/services/warm_up_tracker.py new file mode 100644 index 0000000..1033c8f --- /dev/null +++ b/src/application/services/warm_up_tracker.py @@ -0,0 +1,34 @@ +import logging +import time + +from src.application.abstractions.i_microsoft_http_clients import IMicrosoftHttpClients + + +class WarmUpTracker: + """The startup warm-up and what the readiness probe reads from it.""" + + READY_GRACE = 15.0 + log = logging.getLogger("schuly.microsoft") + + def __init__(self, http: IMicrosoftHttpClients): + self._http = http + self.started = time.monotonic() + self.done = False + + def begin(self) -> None: + self.started = time.monotonic() + self.done = False + + def is_ready(self) -> bool: + return self.done or time.monotonic() - self.started >= self.READY_GRACE + + async def warm_up(self) -> None: + started = time.monotonic() + try: + await self._http.warm_up() + except Exception as ex: + self.log.warning("Warm-up failed after %.0f ms: %s", (time.monotonic() - started) * 1000, ex) + else: + self.log.info("Warm-up finished in %.0f ms", (time.monotonic() - started) * 1000) + finally: + self.done = True diff --git a/src/browser_auth.py b/src/browser_auth.py deleted file mode 100644 index ce292e7..0000000 --- a/src/browser_auth.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Browser sign-in: standard OAuth 2.0 authorization-code + PKCE against the OfficeHome client. - -This is the flow `outnote login` uses and works on tenants that block the device-code flow. -The app opens the authorize URL in a WebView on Microsoft's real sign-in page, waits for the -redirect to the native-client URL carrying `?code=`, and sends the code here to exchange for a -FOCI refresh token. The caller holds the `code_verifier` between the two calls; nothing is stored. -""" - -import base64 -import hashlib -import secrets -from urllib.parse import urlencode - -import httpx - -from src import config, outnote_client - -# OfficeHome: pre-consented, issues a FOCI family refresh token. Public client id. -OFFICEHOME_CLIENT_ID = "4765445b-32c6-49b0-83e6-1d93765276ca" -# Microsoft's documented native-client redirect — the code lands here in the URL; nothing is hosted. -NATIVE_REDIRECT = "https://login.microsoftonline.com/common/oauth2/nativeclient" -AUTHORIZE_URL = f"https://login.microsoftonline.com/{config.TENANT}/oauth2/v2.0/authorize" -SCOPE = f"{config.GRAPH_DEFAULT} offline_access openid profile" - - -def build_authorize_url() -> tuple[str, str]: - """The Microsoft authorize URL for the WebView, and the PKCE code_verifier the caller keeps.""" - verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode() - challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() - url = AUTHORIZE_URL + "?" + urlencode({ - "client_id": OFFICEHOME_CLIENT_ID, - "response_type": "code", - "redirect_uri": NATIVE_REDIRECT, - "response_mode": "query", - "scope": SCOPE, - "state": secrets.token_urlsafe(16), - "code_challenge": challenge, - "code_challenge_method": "S256", - "prompt": "select_account", - }) - return url, verifier - - -async def exchange_code(code: str, code_verifier: str, client: httpx.AsyncClient | None = None) -> dict: - """Redeem the authorization code from the redirect for tokens.""" - return await outnote_client._post(config.TOKEN_URL, { - "grant_type": "authorization_code", - "client_id": OFFICEHOME_CLIENT_ID, - "code": code, - "redirect_uri": NATIVE_REDIRECT, - "code_verifier": code_verifier, - }, client) diff --git a/src/channel_messages.py b/src/channel_messages.py deleted file mode 100644 index 7fb01b6..0000000 --- a/src/channel_messages.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Teams channel posts and their replies, normalized for the web and the MCP tools (read only).""" - -from html import unescape - -from outnote import html_to_text - -from src.teams_files import seg - -MAX_TEXT = 4000 -PAGE = 50 - - -def _text(body) -> str: - if not isinstance(body, dict): - return "" - content = body.get("content") or "" - text = unescape(content).strip() if body.get("contentType") == "text" else html_to_text(content) - return text[:MAX_TEXT] - - -def _sender(value): - if not isinstance(value, dict): - return None - who = value.get("user") or value.get("application") or value.get("device") - if not isinstance(who, dict): - return None - return {"displayName": who.get("displayName"), "id": who.get("id")} - - -def normalize(message: dict) -> dict | None: - """A ChannelMessage, or None for system messages (`messageType` != message) and deleted ones.""" - if not isinstance(message, dict) or message.get("messageType") != "message" or message.get("deletedDateTime"): - return None - attachments = [{"name": a.get("name"), "contentType": a.get("contentType"), "webUrl": a.get("contentUrl")} for a in message.get("attachments") or [] if isinstance(a, dict) and a.get("name")] - return {"id": message.get("id"), "replyToId": message.get("replyToId"), "from": _sender(message.get("from")), "createdDateTime": message.get("createdDateTime"), "lastModifiedDateTime": message.get("lastModifiedDateTime"), "subject": message.get("subject"), "text": _text(message.get("body")), "attachments": attachments, "replyCount": None, "webUrl": message.get("webUrl"), "messageType": "message"} - - -def _rows(note, path: str, top: int) -> list[dict]: - rows = note.list(path, {"$top": min(top, PAGE)}, limit=top) - return [m for m in (normalize(r) for r in rows) if m] - - -def messages(note, team_id: str, channel_id: str, top: int) -> list[dict]: - """Top-level posts, newest first (follows nextLink until `top` raw posts were read).""" - out = _rows(note, f"teams/{seg(team_id)}/channels/{seg(channel_id)}/messages", top) - return sorted(out, key=lambda m: m.get("createdDateTime") or "", reverse=True) - - -def replies(note, team_id: str, channel_id: str, message_id: str, top: int) -> list[dict]: - """Replies to one post, oldest first.""" - out = _rows(note, f"teams/{seg(team_id)}/channels/{seg(channel_id)}/messages/{seg(message_id)}/replies", top) - return sorted(out, key=lambda m: m.get("createdDateTime") or "") diff --git a/src/chat_html.py b/src/chat_html.py deleted file mode 100644 index 3dee1d9..0000000 --- a/src/chat_html.py +++ /dev/null @@ -1,247 +0,0 @@ -"""Chat message HTML: a strict allowlist sanitizer for what the composer sends, and the media and -file references pulled out of what Graph returns. - -Sending: only formatting tags survive, with no attributes except `a[href]` (http, https, mailto), -`at[id]` for a mention the request declares, `img[src]` pointing at one of the request's own -inline images (`../hostedContents/{n}/$value`), and `emoji[id]` for an id in the Teams emoji catalogue, rewritten to exactly -what Teams sends (``, alt and title taken from the catalogue, never from the -request). Everything else is dropped (the text inside an -unknown tag is kept and escaped; script/style/template contents are dropped whole). -""" - -import html -import json -import re -from html.parser import HTMLParser -from urllib.parse import urlsplit - -ALLOWED = {"b", "strong", "i", "em", "u", "s", "strike", "br", "p", "div", "span", "ul", "ol", "li", "a", "code", "pre", "blockquote"} -VOID = {"br"} -DROP_CONTENT = {"script", "style", "template", "noscript", "iframe", "object", "embed", "svg", "math", "head", "title", "textarea", "select"} -LINK_SCHEMES = ("http", "https", "mailto") -MAX_EMOJIS = 100 -HOSTED_SRC = re.compile(r"\.\./hostedContents/(\d{1,2})/\$value") - - -class _Sanitizer(HTMLParser): - def __init__(self, mention_ids: set[int], image_ids: set[int], emoji_lookup=None): - super().__init__(convert_charrefs=True) - self.out: list[str] = [] - self.open: list[str] = [] - self.skip = 0 - self.mention_ids = mention_ids - self.image_ids = image_ids - self.used_mentions: set[int] = set() - self.used_images: set[int] = set() - self.emoji_lookup = emoji_lookup - self.emoji_count = 0 - - def handle_starttag(self, tag, attrs): - tag = tag.lower() - if tag in DROP_CONTENT: - self.skip += 1 - return - if self.skip: - return - attrs = dict(attrs) - if tag == "a": - href = _safe_href(attrs.get("href")) - self.out.append(f'
' if href else "") - self.open.append(tag) - elif tag == "at": - mid = _int(attrs.get("id")) - if mid is not None and mid in self.mention_ids: - self.out.append(f'') - self.used_mentions.add(mid) - self.open.append(tag) - elif tag == "emoji": - ref = attrs.get("id") - found = self.emoji_lookup(ref) if self.emoji_lookup and isinstance(ref, str) and self.emoji_count < MAX_EMOJIS else None - if found: - alt, title = found - self.out.append(f'') - self.emoji_count += 1 - elif tag == "img": - m = HOSTED_SRC.fullmatch(attrs.get("src") or "") - if m and int(m.group(1)) in self.image_ids: - n = int(m.group(1)) - self.out.append(f'') - self.used_images.add(n) - elif tag in VOID: - self.out.append(f"<{tag}>") - elif tag in ALLOWED: - self.out.append(f"<{tag}>") - self.open.append(tag) - - def handle_endtag(self, tag): - tag = tag.lower() - if tag in DROP_CONTENT: - self.skip = max(0, self.skip - 1) - return - if self.skip or tag in VOID or tag not in self.open: - return - # Close everything opened after it too, so the output always nests. - while self.open: - top = self.open.pop() - self.out.append(f"") - if top == tag: - break - - def handle_data(self, data): - if not self.skip: - self.out.append(html.escape(data, quote=False)) - - def result(self) -> str: - self.close() - while self.open: - self.out.append(f"") - return "".join(self.out) - - -def _int(value): - return int(value) if isinstance(value, str) and value.isdigit() and len(value) <= 2 else None - - -def _safe_href(value) -> str | None: - value = (value or "").strip() - if not value or any(ord(ch) < 32 for ch in value): - return None - parts = urlsplit(value) - if parts.scheme.lower() not in LINK_SCHEMES: - return None - if parts.scheme.lower() != "mailto" and not parts.netloc: - return None - return value - - -def sanitize(content: str, mention_ids: set[int] = frozenset(), image_ids: set[int] = frozenset(), emoji_lookup=None) -> tuple[str, set[int], set[int]]: - """(clean HTML, mention ids it references, inline image ids it references). `emoji_lookup(id)` returns (alt, title) for a catalogue emoji id, else None.""" - parser = _Sanitizer(set(mention_ids), set(image_ids), emoji_lookup) - parser.feed(content) - return parser.result(), parser.used_mentions, parser.used_images - - -def text_to_html(text: str) -> str: - return html.escape(text, quote=False).replace("\r\n", "\n").replace("\n", "
") - - -# --- what Graph returns ------------------------------------------------------ -_IMG_TAG = re.compile(r"]*>", re.IGNORECASE) -_ATTR = re.compile(r"""([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')""") -_HOSTED_PATH = re.compile(r"/(?:v1\.0|beta)/chats/[^/]+/messages/[^/]+/hostedContents/([^/?#]+)/\$value") -GRAPH_HOST = "graph.microsoft.com" -# Exact hosts only (no suffix matching): Giphy's and Tenor's media CDNs, and the sticker hosts seen in received sticker cards. -GIF_HOSTS = frozenset({"media.giphy.com", "media0.giphy.com", "media1.giphy.com", "media2.giphy.com", "media3.giphy.com", "media4.giphy.com", "i.giphy.com", "media.tenor.com", "media1.tenor.com", "c.tenor.com"}) -STICKER_HOSTS = frozenset({"stickers.newfuture.cc"}) -GIPHY_ITEMTYPE = "http://schema.skype.com/Giphy" -CARD_PREFIX = "application/vnd.microsoft.card." -MAX_CARD_CHARS = 64 * 1024 - - -def _attrs(tag: str) -> dict[str, str]: - return {m.group(1).lower(): html.unescape(m.group(2) if m.group(2) is not None else m.group(3)) for m in _ATTR.finditer(tag)} - - -def _size(value) -> int | None: - try: - n = round(float(value)) - except (TypeError, ValueError): - return None - return n if 0 < n <= 4096 else None - - -def _https_host(url: str, hosts: frozenset) -> bool: - try: - parts = urlsplit(url or "") - port = parts.port - except ValueError: - return False - return parts.scheme == "https" and port in (None, 443) and not parts.username and not parts.password and (parts.hostname or "").lower() in hosts - - -def is_gif_host(url: str) -> bool: - return _https_host(url, GIF_HOSTS) - - -def hosted_content_id(src: str) -> str | None: - """The hosted-content id when `src` is exactly a Graph hosted-content URL, else None.""" - if not _https_host(src, frozenset({GRAPH_HOST})): - return None - m = _HOSTED_PATH.fullmatch(urlsplit(src).path) - return m.group(1) if m else None - - -def media(message: dict) -> list[dict]: - """Images the body shows, in order: [{kind, url, hostedContentId, alt, width, height}]. - - kind "hosted": a pasted image held by Graph; `url` is null and `hostedContentId` is the id the web - passes to the plugin's media proxy. kind "gif": a Giphy/Tenor GIF; `url` is the public media URL - (exact allowlisted hosts, https only). Other images (emoji, external) are left out.""" - content = (message.get("body") or {}).get("content") or "" - out, seen = [], set() - for tag in _IMG_TAG.findall(content): - a = _attrs(tag) - src = a.get("src") or "" - if src in seen: - continue - hosted = hosted_content_id(src) - if hosted: - item = {"kind": "hosted", "url": None, "hostedContentId": hosted} - elif is_gif_host(src): - item = {"kind": "gif", "url": src, "hostedContentId": None} - else: - continue - seen.add(src) - item.update({"alt": (a.get("alt") or "")[:300] or None, "width": _size(a.get("width")), "height": _size(a.get("height"))}) - out.append(item) - return out - - -def reference_files(message: dict) -> list[dict]: - """Files shared into the chat (reference attachments): [{id, name, contentUrl}] (type and size are filled in separately).""" - out = [] - for a in message.get("attachments") or []: - if a.get("contentType") == "reference" and a.get("contentUrl"): - out.append({"id": a.get("id"), "name": a.get("name"), "contentUrl": a.get("contentUrl")}) - return out - - -def cards(message: dict) -> list[dict]: - """Card attachments (adaptive cards, polls, sticker apps, link previews): [{id, contentType, teamsAppId, name, content, sticker}]. - - `content` is the parsed card JSON for a read-only render (null when it isn't JSON or is too large); - `sticker` is {url, alt} when the card is a single image (sticker apps send those), else null.""" - out = [] - for a in message.get("attachments") or []: - ctype = a.get("contentType") or "" - if not ctype.startswith(CARD_PREFIX): - continue - raw = a.get("content") - parsed = None - if isinstance(raw, str) and len(raw) <= MAX_CARD_CHARS: - try: - parsed = json.loads(raw) - except ValueError: - parsed = None - elif isinstance(raw, dict): - parsed = raw - out.append({"id": a.get("id"), "contentType": ctype, "teamsAppId": a.get("teamsAppId"), "name": a.get("name"), "content": parsed, "sticker": _sticker(parsed)}) - return out - - -def _sticker(card) -> dict | None: - if not isinstance(card, dict) or card.get("type") != "AdaptiveCard": - return None - body = card.get("body") - if not isinstance(body, list) or len(body) != 1 or not isinstance(body[0], dict) or body[0].get("type") != "Image": - return None - url = body[0].get("url") - if not isinstance(url, str) or not (_https_host(url, STICKER_HOSTS) or is_gif_host(url)): - return None - return {"url": url, "alt": body[0].get("altText")} - - -def gif_html(url: str, alt: str | None, width: int | None, height: int | None) -> str: - """The markup Teams itself sends for a GIF picked from its GIF tab.""" - size = "".join(f' {k}="{v}"' for k, v in (("width", width), ("height", height)) if v) - return f'{html.escape(alt or ' diff --git a/src/chat_read.py b/src/chat_read.py deleted file mode 100644 index 959c3bc..0000000 --- a/src/chat_read.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Chat messages as the web renders them: Graph's chatMessage plus `media` (pasted images and -GIFs), `files` (files shared in the chat, with type, size, `kind`, `hasThumbnail` and `webUrl`), `cards` (adaptive cards, polls, -stickers) and `emojis` (Teams and custom emoji, see `emojis.message_emojis`) pulled out of the body and attachments. - -A file's type and size are not on the attachment; they come from the drive item behind its -sharing URL (`shares/u!{url}/driveItem`), looked up in one `$batch` per page and cached per account. -""" - -import base64 -import logging -from urllib.parse import quote - -from src import chat_html, emojis, file_thumbs -from src.account_cache import for_broker - -log = logging.getLogger("schuly.microsoft") -FILE_META_TTL = 3600 -DRIVE_ITEM_FIELDS = "id,name,size,file,webUrl" - - -def share_id(url: str) -> str: - return "u!" + base64.urlsafe_b64encode(url.encode()).decode().rstrip("=") - - -def drive_item_path(url: str, select: str = DRIVE_ITEM_FIELDS) -> str: - return f"shares/{share_id(url)}/driveItem?$select={select}" - - -def file_meta(note, broker, urls: list[str]) -> dict[str, dict]: - """contentUrl -> {contentType, size} (None values when the item can't be read).""" - cache = for_broker(broker) - out, todo = {}, [] - for url in dict.fromkeys(urls): - hit = cache.get(("file_meta", url)) - if hit is not None: - out[url] = hit - else: - todo.append(url) - if todo: - try: - results = note.batch([drive_item_path(u, "size,file") for u in todo]) - except Exception: - results = [None] * len(todo) - for url, item in zip(todo, results): - if isinstance(item, dict): - meta = {"contentType": (item.get("file") or {}).get("mimeType"), "size": item.get("size")} - cache.set(("file_meta", url), meta, FILE_META_TTL) - else: - meta = {"contentType": None, "size": None} - out[url] = meta - return out - - -def enrich(note, broker, messages: list[dict]) -> list[dict]: - files_by_message = {} - urls = [] - for m in messages: - files = chat_html.reference_files(m) - files_by_message[id(m)] = files - urls += [f["contentUrl"] for f in files] - meta = file_meta(note, broker, urls) if urls else {} - cat = _catalogue() if any("emoji" in ((m.get("body") or {}).get("content") or "").lower() for m in messages) else None - for m in messages: - m["media"] = chat_html.media(m) - m["files"] = [_file_view({**f, **meta.get(f["contentUrl"], {"contentType": None, "size": None})}) for f in files_by_message[id(m)]] - body = m.get("body") - if isinstance(body, dict) and isinstance(body.get("content"), str): - content, m["emojis"] = emojis.message_emojis(body["content"], cat) - if m["emojis"]: - m["body"] = {**body, "content": content} - else: - m["emojis"] = [] - m["cards"] = chat_html.cards(m) - m.setdefault("importance", "normal") - return messages - - -def _catalogue(): - # Emoji pictures work from the id alone, so a catalogue that can't be loaded only costs the frame counts. - try: - return emojis.catalogue("default") - except Exception as ex: - log.info("emoji catalogue unavailable: %s", type(ex).__name__) - return None - - -def _file_view(f: dict) -> dict: - """A chat file with its `kind`, `hasThumbnail` and `webUrl` (the SharePoint / OneDrive link, only when it is one).""" - kind = file_thumbs.file_kind(f.get("name"), f.get("contentType")) - shared = file_thumbs.is_share_url(f.get("contentUrl")) - return {**f, "kind": kind, "hasThumbnail": file_thumbs.has_thumbnail(f.get("contentUrl"), kind), "webUrl": f["contentUrl"] if shared else None} - - -def message_path(chat_id: str, message_id: str) -> str: - return f"chats/{chat_id}/messages/{quote(message_id, safe='')}" diff --git a/src/chat_send.py b/src/chat_send.py deleted file mode 100644 index 8ad3ff7..0000000 --- a/src/chat_send.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Posting a chat message the way Teams does: sanitized HTML, @mentions, inline images as hosted -contents, a GIF, and files shared through the sender's OneDrive. - -Files follow the Teams client: upload to `Microsoft Teams Chat Files/` in the sender's OneDrive -(an upload session in 320 KiB-multiple chunks above 4 MB), grant the chat members read access -without an e-mail, then reference the file in the message (`` + a -`reference` attachment whose id is the GUID from the drive item's eTag). -""" - -import base64 -import re -from dataclasses import dataclass, field -from typing import BinaryIO -from urllib.parse import quote - -from entrance import http_client - -from src import chat_html, emojis, profile - -MAX_CONTENT_CHARS = 28000 # Graph's chat message body limit is about 28 KB -MAX_IMAGES_BYTES = 3 * 1024 * 1024 # about 4 MB once base64-encoded, Graph's request limit -MAX_IMAGES = 10 -MAX_FILE_BYTES = 100 * 1024 * 1024 -MAX_FILES = 10 -MAX_MENTIONS = 20 -SIMPLE_UPLOAD_MAX = 4 * 1024 * 1024 -UPLOAD_CHUNK = 10 * 320 * 1024 # upload sessions take chunks in multiples of 320 KiB -CHAT_FILES_FOLDER = "Microsoft Teams Chat Files" -SELF_CHAT = "48:notes" -IMPORTANCE = ("normal", "high", "urgent") -GUID_IN_ETAG = re.compile(r"\{([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12})\}") -USER_ID = re.compile(r"[A-Za-z0-9@._-]{1,200}") -IMAGE_TYPES = {b"\x89PNG": "image/png", b"\xff\xd8\xff": "image/jpeg", b"GIF8": "image/gif"} - - -class SendError(ValueError): - def __init__(self, status: int, message: str): - self.status = status - super().__init__(message) - - -@dataclass -class Upload: - name: str - file: BinaryIO - size: int - content_type: str | None = None - - -@dataclass -class OutgoingMessage: - content: str = "" - content_type: str = "text" - importance: str | None = None - mentions: list[dict] = field(default_factory=list) - gif: dict | None = None - images: list[Upload] = field(default_factory=list) - files: list[Upload] = field(default_factory=list) - - -def image_type(head: bytes) -> str | None: - """PNG, JPEG, GIF or WEBP from the magic bytes; anything else is None.""" - for magic, mime in IMAGE_TYPES.items(): - if head.startswith(magic): - return mime - if head[:4] == b"RIFF" and head[8:12] == b"WEBP": - return "image/webp" - return None - - -def safe_name(name: str | None) -> str: - cleaned = re.sub(r'[\x00-\x1f\x7f\\/:*?"<>|#%]', "_", name or "") - return cleaned.strip(" .")[:200] or "file" - - -def validate(msg: OutgoingMessage) -> OutgoingMessage: - if msg.content_type not in ("text", "html"): - raise SendError(400, "contentType must be text or html.") - if len(msg.content) > MAX_CONTENT_CHARS: - raise SendError(413, f"The message may be at most {MAX_CONTENT_CHARS} characters.") - if msg.importance is not None and msg.importance not in IMPORTANCE: - raise SendError(400, "importance must be normal, high or urgent.") - if len(msg.mentions) > MAX_MENTIONS: - raise SendError(400, f"At most {MAX_MENTIONS} mentions are allowed.") - clean = [] - for m in msg.mentions: - user = ((m or {}).get("mentioned") or {}).get("user") or {} - mid, uid, text = (m or {}).get("id"), user.get("id"), (m or {}).get("mentionText") - if not isinstance(mid, int) or not 0 <= mid < MAX_MENTIONS or not isinstance(uid, str) or not USER_ID.fullmatch(uid) or not isinstance(text, str) or not 0 < len(text) <= 200: - raise SendError(400, "Invalid mention.") - clean.append({"id": mid, "mentionText": text, "mentioned": {"user": {"id": uid, "displayName": str(user.get("displayName") or text)[:200], "userIdentityType": "aadUser"}}}) - msg.mentions = clean - if msg.gif is not None: - url = msg.gif.get("url") if isinstance(msg.gif, dict) else None - if not isinstance(url, str) or len(url) > 2000 or not chat_html.is_gif_host(url): - raise SendError(400, "gif.url must be an https Giphy or Tenor media URL.") - if len(msg.images) > MAX_IMAGES: - raise SendError(400, f"At most {MAX_IMAGES} images are allowed.") - if sum(i.size for i in msg.images) > MAX_IMAGES_BYTES: - raise SendError(413, "Inline images may total at most 3 MB; send larger ones as files.") - if len(msg.files) > MAX_FILES: - raise SendError(400, f"At most {MAX_FILES} files are allowed.") - for f in msg.files: - if f.size > MAX_FILE_BYTES: - raise SendError(413, "A file may be at most 100 MB.") - if not msg.content.strip() and not msg.gif and not msg.images and not msg.files: - raise SendError(400, "The message is empty.") - return msg - - -def _hosted_contents(images: list[Upload]) -> list[dict]: - out = [] - for n, image in enumerate(images, start=1): - data = image.file.read(MAX_IMAGES_BYTES + 1) - mime = image_type(data[:12]) - if mime is None: - raise SendError(415, "Inline images must be PNG, JPEG, GIF or WEBP.") - out.append({"@microsoft.graph.temporaryId": str(n), "contentBytes": base64.b64encode(data).decode(), "contentType": mime}) - return out - - -def payload(msg: OutgoingMessage, attachments: list[dict] | None = None) -> dict: - """The Graph chatMessage body for `msg` (files already uploaded into `attachments`).""" - attachments = attachments or [] - rich = msg.content_type == "html" or msg.mentions or msg.gif or msg.images or attachments - if not rich: - body = {"contentType": "text", "content": msg.content} - mentions = [] - else: - image_ids = set(range(1, len(msg.images) + 1)) - if msg.content_type == "html": - try: - content, used_mentions, used_images = chat_html.sanitize(msg.content, {m["id"] for m in msg.mentions}, image_ids, emojis.for_send) - except emojis.EmojiError as ex: - raise SendError(503, f"Emoji can't be sent right now: {ex}") from None - else: - content, used_mentions, used_images = chat_html.text_to_html(msg.content), set(), set() - # Graph refuses a mention without its tag, so mentions the body doesn't reference are dropped. - mentions = [m for m in msg.mentions if m["id"] in used_mentions] - parts = [content] if content else [] - parts += [f'

' for n in sorted(image_ids - used_images)] - if msg.gif: - parts.append(chat_html.gif_html(msg.gif["url"], msg.gif.get("alt"), _dim(msg.gif.get("width")), _dim(msg.gif.get("height")))) - parts += [f'' for a in attachments] - body = {"contentType": "html", "content": "".join(parts)} - out = {"body": body} - if mentions: - out["mentions"] = mentions - if msg.importance: - out["importance"] = msg.importance - if msg.images: - out["hostedContents"] = _hosted_contents(msg.images) - if attachments: - out["attachments"] = attachments - return out - - -def _dim(value) -> int | None: - return value if isinstance(value, int) and 0 < value <= 4096 else None - - -def upload_file(note, upload: Upload) -> dict: - """Uploads into the sender's `Microsoft Teams Chat Files` folder (renaming on a clash); returns the driveItem.""" - target = f"me/drive/root:/{quote(CHAT_FILES_FOLDER, safe='')}/{quote(safe_name(upload.name), safe='')}:" - if upload.size <= SIMPLE_UPLOAD_MAX: - data = upload.file.read(SIMPLE_UPLOAD_MAX + 1) - return note.put(f"{target}/content?@microsoft.graph.conflictBehavior=rename", data, content_type=upload.content_type or "application/octet-stream") - session = note.post(f"{target}/createUploadSession", {"item": {"@microsoft.graph.conflictBehavior": "rename"}}) - url = session["uploadUrl"] - client = http_client() - sent, item = 0, None - try: - while sent < upload.size: - chunk = upload.file.read(min(UPLOAD_CHUNK, upload.size - sent)) - if not chunk: - raise SendError(400, "The file ended before its declared size.") - # The session URL is pre-authorized; it must not carry the Graph bearer token. - r = client.request("PUT", url, content=chunk, headers={"Content-Length": str(len(chunk)), "Content-Range": f"bytes {sent}-{sent + len(chunk) - 1}/{upload.size}"}, timeout=120) - if r.status_code >= 400: - raise SendError(502, f"OneDrive refused the upload ({r.status_code}).") - sent += len(chunk) - if r.status_code in (200, 201): - item = r.json() - except BaseException: - try: - client.request("DELETE", url, timeout=15) - except Exception: - pass - raise - if not item: - raise SendError(502, "OneDrive did not confirm the upload.") - return item - - -def chat_member_ids(note, broker, chat_id: str) -> list[str]: - if chat_id == SELF_CHAT: - return [] - me = profile.get(note, broker).get("id") - ids = [] - for m in note.list(f"chats/{chat_id}/members"): - uid = m.get("userId") - if isinstance(uid, str) and uid != me and uid not in ids: - ids.append(uid) - return ids - - -def share_with(note, item_id: str, member_ids: list[str]): - if not member_ids: - return - note.post(f"me/drive/items/{quote(item_id, safe='')}/invite", {"recipients": [{"objectId": uid} for uid in member_ids], "roles": ["read"], "requireSignIn": True, "sendInvitation": False}) - - -def reference_attachment(item: dict) -> dict: - m = GUID_IN_ETAG.search(item.get("eTag") or "") - if not m or not item.get("webUrl"): - raise SendError(502, "OneDrive returned an item without an eTag GUID or webUrl.") - return {"id": m.group(1).lower(), "contentType": "reference", "contentUrl": item["webUrl"], "name": item.get("name")} - - -def send(note, broker, chat_id: str, msg: OutgoingMessage) -> dict: - attachments = [] - if msg.files: - members = chat_member_ids(note, broker, chat_id) - for upload in msg.files: - item = upload_file(note, upload) - share_with(note, item["id"], members) - attachments.append(reference_attachment(item)) - return note.post(f"chats/{quote(chat_id, safe=':@.=-_')}/messages", payload(msg, attachments)) diff --git a/src/config.py b/src/config.py deleted file mode 100644 index f727d42..0000000 --- a/src/config.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Constants for reaching Microsoft 365 through the FOCI first-party clients. - -No app registration: the device-code sign-in runs against Microsoft's own Office -client, which supports the device flow and issues a FOCI family refresh token that -OutNote's broker trades for whatever first-party client each Graph call needs. -""" - -import os - -# Microsoft "Office" client: device-flow enabled and FOCI. The user signs in on -# Microsoft's real page, so this never handles a password. -DEVICE_CLIENT_ID = os.getenv("MS_DEVICE_CLIENT_ID", "d3590ed6-52b3-4102-aeff-aad2292ab01c") -TENANT = os.getenv("MS_TENANT", "common") - -GRAPH_DEFAULT = "https://graph.microsoft.com/.default" -DEVICE_SCOPE = f"{GRAPH_DEFAULT} offline_access openid profile" - -DEVICECODE_URL = f"https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/devicecode" -TOKEN_URL = f"https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/token" -DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code" - -REFRESH_TOKEN_HEADER = "X-MS-Refresh-Token" - -# The password sign-in routes take a Microsoft password, so only the Schuly plugin may call them: it sends -# the shared secret (SCHULY_SERVICE_KEY) in SERVICE_KEY_HEADER and the signed-in Schuly user's id in USER_ID_HEADER. -# With SCHULY_SERVICE_KEY unset the routes answer 503 (never open). -SERVICE_KEY_ENV = "SCHULY_SERVICE_KEY" -SERVICE_KEY_HEADER = "X-Schuly-Service-Key" -USER_ID_HEADER = "X-Schuly-User-Id" diff --git a/src/domain/chat/catalogue.py b/src/domain/chat/catalogue.py new file mode 100644 index 0000000..e411063 --- /dev/null +++ b/src/domain/chat/catalogue.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass, field + +from src.domain.chat.emoji_assets import EmojiAssets + + +@dataclass +class Catalogue: + locale: str + version: str + categories: list[dict] = field(default_factory=list) + by_id: dict[str, dict] = field(default_factory=dict) + + def payload(self) -> dict: + return {"locale": self.locale, "version": self.version, "assetBase": EmojiAssets.ASSET_BASE, "sizes": list(EmojiAssets.SIZES), "fps": EmojiAssets.FPS, "categories": self.categories, "custom": []} diff --git a/src/domain/chat/chat_send_limits.py b/src/domain/chat/chat_send_limits.py new file mode 100644 index 0000000..42a4039 --- /dev/null +++ b/src/domain/chat/chat_send_limits.py @@ -0,0 +1,7 @@ +class ChatSendLimits: + """How many parts a chat send may carry.""" + + MAX_IMAGES = 10 + MAX_FILES = 10 + MAX_FIELDS = 20 + MAX_PART_SIZE = 256 * 1024 diff --git a/src/domain/chat/emoji_assets.py b/src/domain/chat/emoji_assets.py new file mode 100644 index 0000000..ae0fe41 --- /dev/null +++ b/src/domain/chat/emoji_assets.py @@ -0,0 +1,6 @@ +class EmojiAssets: + """Where Teams' emoji pictures live and how they are animated.""" + + ASSET_BASE = "https://statics.teams.cdn.office.net/evergreen-assets/personal-expressions/v2/assets/emoticons" + SIZES = (20, 30, 40, 50, 60, 100) + FPS = 24 diff --git a/src/domain/chat/outgoing_message.py b/src/domain/chat/outgoing_message.py new file mode 100644 index 0000000..bf778a4 --- /dev/null +++ b/src/domain/chat/outgoing_message.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass, field + +from src.domain.chat.upload import Upload + + +@dataclass +class OutgoingMessage: + content: str = "" + content_type: str = "text" + importance: str | None = None + mentions: list[dict] = field(default_factory=list) + gif: dict | None = None + images: list[Upload] = field(default_factory=list) + files: list[Upload] = field(default_factory=list) diff --git a/src/domain/chat/upload.py b/src/domain/chat/upload.py new file mode 100644 index 0000000..6a44584 --- /dev/null +++ b/src/domain/chat/upload.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from typing import BinaryIO + + +@dataclass +class Upload: + name: str + file: BinaryIO + size: int + content_type: str | None = None diff --git a/src/domain/errors/chat_service_error.py b/src/domain/errors/chat_service_error.py new file mode 100644 index 0000000..8b86776 --- /dev/null +++ b/src/domain/errors/chat_service_error.py @@ -0,0 +1,4 @@ +class ChatServiceError(RuntimeError): + def __init__(self, status: int, message: str): + self.status = status + super().__init__(f"Teams chat service {status}: {message}") diff --git a/src/domain/errors/convert_error.py b/src/domain/errors/convert_error.py new file mode 100644 index 0000000..145bbf6 --- /dev/null +++ b/src/domain/errors/convert_error.py @@ -0,0 +1,5 @@ +from src.domain.errors.service_error import ServiceError + + +class ConvertError(ServiceError): + """The file can't be shown as PDF: `status` is the HTTP status to answer with (413, 415, 502).""" diff --git a/src/domain/errors/emoji_error.py b/src/domain/errors/emoji_error.py new file mode 100644 index 0000000..b88586b --- /dev/null +++ b/src/domain/errors/emoji_error.py @@ -0,0 +1,5 @@ +from src.domain.errors.service_error import ServiceError + + +class EmojiError(ServiceError): + """The Teams emoji catalogue can't be loaded.""" diff --git a/src/domain/errors/file_fetch_error.py b/src/domain/errors/file_fetch_error.py new file mode 100644 index 0000000..fb594e7 --- /dev/null +++ b/src/domain/errors/file_fetch_error.py @@ -0,0 +1,5 @@ +from src.domain.errors.service_error import ServiceError + + +class FileFetchError(ServiceError): + """A download that can't be served: `status` is the HTTP status to answer with.""" diff --git a/src/domain/errors/gif_search_unavailable.py b/src/domain/errors/gif_search_unavailable.py new file mode 100644 index 0000000..7bb5ac5 --- /dev/null +++ b/src/domain/errors/gif_search_unavailable.py @@ -0,0 +1,2 @@ +class GifSearchUnavailable(RuntimeError): + pass diff --git a/src/domain/errors/microsoft_error.py b/src/domain/errors/microsoft_error.py new file mode 100644 index 0000000..69f55a4 --- /dev/null +++ b/src/domain/errors/microsoft_error.py @@ -0,0 +1,9 @@ +from src.domain.errors.service_error import ServiceError + + +class MicrosoftError(ServiceError): + """Microsoft's sign-in endpoint answered with an error; a 502 for the caller.""" + + def __init__(self, error: str, description: str = ""): + self.error = error + super().__init__(502, f"{error}: {description}" if description else error) diff --git a/src/domain/errors/one_note_error.py b/src/domain/errors/one_note_error.py new file mode 100644 index 0000000..c007723 --- /dev/null +++ b/src/domain/errors/one_note_error.py @@ -0,0 +1,5 @@ +from src.domain.errors.service_error import ServiceError + + +class OneNoteError(ServiceError): + """A refusal OneNote gives as such: `status` is the HTTP status to answer with.""" diff --git a/src/domain/errors/photo_throttled.py b/src/domain/errors/photo_throttled.py new file mode 100644 index 0000000..aff31e9 --- /dev/null +++ b/src/domain/errors/photo_throttled.py @@ -0,0 +1,4 @@ +class PhotoThrottled(RuntimeError): + def __init__(self, retry_after: float | None = None): + self.retry_after = retry_after + super().__init__("throttled") diff --git a/src/domain/errors/rate_limited.py b/src/domain/errors/rate_limited.py new file mode 100644 index 0000000..fedaca8 --- /dev/null +++ b/src/domain/errors/rate_limited.py @@ -0,0 +1,2 @@ +class RateLimited(Exception): + """Too many sign-in attempts (per client or per account) or too many jobs in progress.""" diff --git a/src/domain/errors/resource_too_large.py b/src/domain/errors/resource_too_large.py new file mode 100644 index 0000000..dadab2e --- /dev/null +++ b/src/domain/errors/resource_too_large.py @@ -0,0 +1,2 @@ +class ResourceTooLarge(Exception): + """A OneNote resource past the size limit.""" diff --git a/src/domain/errors/send_error.py b/src/domain/errors/send_error.py new file mode 100644 index 0000000..3d1b5ef --- /dev/null +++ b/src/domain/errors/send_error.py @@ -0,0 +1,5 @@ +from src.domain.errors.service_error import ServiceError + + +class SendError(ServiceError): + """A chat message that can't be sent: `status` is the HTTP status to answer with.""" diff --git a/src/domain/errors/service_error.py b/src/domain/errors/service_error.py new file mode 100644 index 0000000..adf2851 --- /dev/null +++ b/src/domain/errors/service_error.py @@ -0,0 +1,12 @@ +class ServiceError(Exception): + """A request the service refuses: `status_code` and `detail` are what the caller reads. One exception handler answers every one of them.""" + + def __init__(self, status_code: int, detail: str, headers: dict[str, str] | None = None): + self.status_code = status_code + self.detail = detail + self.headers = headers + super().__init__(detail) + + @property + def status(self) -> int: + return self.status_code diff --git a/src/domain/errors/sign_in_refused.py b/src/domain/errors/sign_in_refused.py new file mode 100644 index 0000000..aaec608 --- /dev/null +++ b/src/domain/errors/sign_in_refused.py @@ -0,0 +1,8 @@ +class SignInRefused(Exception): + """A password sign-in the service refuses: answered as `{status: error, code, message}`, never with any input.""" + + def __init__(self, status_code: int, code: str, message: str): + self.status_code = status_code + self.code = code + self.message = message + super().__init__(message) diff --git a/src/domain/errors/text_error.py b/src/domain/errors/text_error.py new file mode 100644 index 0000000..a6392c7 --- /dev/null +++ b/src/domain/errors/text_error.py @@ -0,0 +1,5 @@ +from src.domain.errors.service_error import ServiceError + + +class TextError(ServiceError): + """The file can't be read as text: `status` is the HTTP status to answer with (413, 415, 422).""" diff --git a/src/domain/files/binary_content.py b/src/domain/files/binary_content.py new file mode 100644 index 0000000..8095810 --- /dev/null +++ b/src/domain/files/binary_content.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + + +@dataclass +class BinaryContent: + """Bytes held in memory, with the type to serve them as and, for a download, the file name.""" + + data: bytes + content_type: str + file_name: str | None = None diff --git a/src/domain/files/file_limits.py b/src/domain/files/file_limits.py new file mode 100644 index 0000000..71aaa61 --- /dev/null +++ b/src/domain/files/file_limits.py @@ -0,0 +1,10 @@ +class FileLimits: + """Size caps of the file and send routes. Read at call time.""" + + MAX_TEXT_SOURCE_BYTES = 25 * 1024 * 1024 + MAX_CHAT_FILE_BYTES = 100 * 1024 * 1024 + MAX_SEND_BYTES = 105 * 1024 * 1024 # a whole chat send: one 100 MB file plus the message and inline images + MAX_JSON_SEND_BYTES = 256 * 1024 + MAX_BODY_BYTES = 1024 * 1024 + MAX_SEND_ATTACHMENT_BYTES = 3 * 1024 * 1024 + MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024 diff --git a/src/domain/files/file_stream.py b/src/domain/files/file_stream.py new file mode 100644 index 0000000..9b63e69 --- /dev/null +++ b/src/domain/files/file_stream.py @@ -0,0 +1,11 @@ +from collections.abc import Iterator +from dataclasses import dataclass + + +@dataclass +class FileStream: + """Bytes that stream through without being held.""" + + chunks: Iterator[bytes] + content_type: str + file_name: str diff --git a/src/domain/files/file_text_limits.py b/src/domain/files/file_text_limits.py new file mode 100644 index 0000000..72f86b7 --- /dev/null +++ b/src/domain/files/file_text_limits.py @@ -0,0 +1,10 @@ +class FileTextLimits: + """The paging bounds of a FileText read, shared by the routes and the extractor.""" + + MAX_SOURCE_BYTES = 25 * 1024 * 1024 + DEFAULT_CHARS = 20000 + MAX_CHARS = 100000 + DEFAULT_ROWS = 500 + MAX_ROWS = 2000 + MAX_ROW_OFFSET = 1_000_000 + UNSUPPORTED = "This file type cannot be read as text." diff --git a/src/domain/files/spooled_pdf.py b/src/domain/files/spooled_pdf.py new file mode 100644 index 0000000..3171f89 --- /dev/null +++ b/src/domain/files/spooled_pdf.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from typing import BinaryIO + + +@dataclass +class SpooledPdf: + """A converted PDF in a spooled temporary file, positioned at 0, and the name of the file it came from.""" + + source_name: str | None + file: BinaryIO diff --git a/src/domain/files/teams_path_limits.py b/src/domain/files/teams_path_limits.py new file mode 100644 index 0000000..dc93948 --- /dev/null +++ b/src/domain/files/teams_path_limits.py @@ -0,0 +1,4 @@ +class TeamsPathLimits: + """How long a path into a team's files may be.""" + + MAX_PATH_CHARS = 400 diff --git a/src/domain/sign_in/sign_in_state.py b/src/domain/sign_in/sign_in_state.py new file mode 100644 index 0000000..2ecdfc3 --- /dev/null +++ b/src/domain/sign_in/sign_in_state.py @@ -0,0 +1,8 @@ +class SignInState: + """A password sign-in's states. Transient ones mean "the worker is busy"; everything else is a place the caller can act on.""" + + WORKING, STARTING_PUSH, STARTING_OTP, VERIFYING = "working", "starting_push", "starting_otp", "verifying" + MFA_REQUIRED, PUSH_PENDING, OTP_REQUIRED = "mfa_required", "push_pending", "otp_required" + DENIED, TIMED_OUT, COMPLETE, ERROR = "denied", "timed_out", "complete", "error" + TRANSIENT = {WORKING, STARTING_PUSH, STARTING_OTP, VERIFYING} + CHOOSABLE = {MFA_REQUIRED, DENIED, TIMED_OUT} # states where /method picks a (new) method diff --git a/src/domain/validation/graph_ids.py b/src/domain/validation/graph_ids.py new file mode 100644 index 0000000..4483cef --- /dev/null +++ b/src/domain/validation/graph_ids.py @@ -0,0 +1,36 @@ +import re +from urllib.parse import quote + +from src.domain.errors.service_error import ServiceError + + +class GraphIds: + """The one validator for ids that become upstream path segments, and their patterns.""" + + CHAT_ID_PATTERN = re.compile(r"[A-Za-z0-9:@._=-]{1,300}") + USER_ID_PATTERN = re.compile(r"[A-Za-z0-9@._-]{1,200}") + SCHULY_USER_ID_PATTERN = re.compile(r"[A-Za-z0-9._:@-]{1,128}") + GRAPH_ID_PATTERN = re.compile(r"[A-Za-z0-9=_\-+/.]{1,512}") + MESSAGE_ID_PATTERN = re.compile(r"[A-Za-z0-9_-]{1,64}") + ATTACHMENT_ID_PATTERN = re.compile(r"[A-Za-z0-9_-]{1,100}") + DOCUMENT_ID_PATTERN = re.compile(r"[A-Za-z0-9_-]{1,120}") + DRIVE_ITEM_PATTERN = re.compile(r"[A-Za-z0-9!=_\-+.]{1,512}") + TEAM_ID_PATTERN = re.compile(r"[0-9a-fA-F-]{36}") + CHANNEL_ID_PATTERN = re.compile(r"19:[A-Za-z0-9_-]{1,200}@thread\.(tacv2|skype)") + DRIVE_ID_PATTERN = re.compile(r"[A-Za-z0-9!_-]{1,200}") + CHANNEL_MESSAGE_ID_PATTERN = re.compile(r"[0-9]{1,20}") + + @classmethod + def check(cls, value: str, pattern: re.Pattern, name: str) -> None: + """The pattern, and no dot segment ("." / ".." collapse in URL handling and retarget the call).""" + if not pattern.fullmatch(value) or cls.has_dot_segment(value): + raise ServiceError(400, f"Invalid {name}.") + + @staticmethod + def has_dot_segment(value: str) -> bool: + return ".." in value or any(part == "." for part in re.split(r"[/\\]", value)) + + @staticmethod + def seg(value: str) -> str: + # OutNote does not escape path segments; ids may hold "+" or "/", so percent-encode them for Graph. + return quote(value, safe="") diff --git a/src/domain/validation/refresh_token_format.py b/src/domain/validation/refresh_token_format.py new file mode 100644 index 0000000..a4ba357 --- /dev/null +++ b/src/domain/validation/refresh_token_format.py @@ -0,0 +1,14 @@ +import re + +from src.domain.errors.service_error import ServiceError + + +class RefreshTokenFormat: + """The shape a caller's refresh token must have before it is used at all.""" + + TOKEN_PATTERN = re.compile(r"[\x21-\x7e]{4,16384}") # printable ASCII, no whitespace + + @classmethod + def check(cls, token: str) -> None: + if not cls.TOKEN_PATTERN.fullmatch(token): + raise ServiceError(401, "Missing or malformed Microsoft token.") diff --git a/src/emojis.py b/src/emojis.py deleted file mode 100644 index 4a29b28..0000000 --- a/src/emojis.py +++ /dev/null @@ -1,343 +0,0 @@ -"""Teams emojis (the animated "personal expressions") and tenant custom emojis. - -Teams emoji ------------ -Graph returns a Teams emoji in a chat message body as `` -(some clients send ``; both are read). The picture -is served by Teams' public CDN, per emoji id and size: - - {ASSET_BASE}/{id}/default/{size}_f.png still (size x size) - {ASSET_BASE}/{id}/default/{size}_anim_f.png vertical sprite strip, `frames` square frames, played at 24 fps - ... _f_s{tone+1}.png skin tone variants (`{id}-tone{1..5}` in markup) - -Sizes: 20, 30, 40, 50, 60, 100. The catalogue (categories, ids, shortcuts, unicode, localized titles, frame counts) is -the JSON Teams loads itself: `{METADATA_BASE}/{version}/{locale}.json`, falling back to `default.json`. It is public, -so no token is involved; it is cached here for 24 hours per locale. - -Custom emoji ------------- -A tenant's own emoji live in the Teams media service (AMS) and need the skype token, so they are never linked -directly: a message carries `kind: "custom"` and a `documentId`, and the image comes from the proxy route. -The tenant's custom emoji list is a Graph beta API (`TeamworkCustomEmoji.Read`) that the first-party clients -used here are not granted, so the catalogue carries `custom: []`. -""" - -import html -import os -import re -import threading -import time -from dataclasses import dataclass, field -from urllib.parse import urlsplit - -from entrance import http_client - -CDN_HOSTS = frozenset({"statics.teams.cdn.office.net", "teams.public.onecdn.static.microsoft"}) -ASSET_BASE = "https://statics.teams.cdn.office.net/evergreen-assets/personal-expressions/v2/assets/emoticons" -METADATA_BASE = "https://statics.teams.cdn.office.net/evergreen-assets/personal-expressions/v1/metadata" -TEAMS_PAGE = "https://teams.microsoft.com/v2/" -DEFAULT_VERSION = "0f52465a47bf42f299c74a639443f33e" -VERSION_PATTERN = re.compile(r"[0-9a-f]{32}") -SIZES = (20, 30, 40, 50, 60, 100) -INLINE_SIZE = 30 -BIG_SIZE = 50 # a message that is only emoji -FPS = 24 -CATALOGUE_TTL = 24 * 3600 -MAX_LOCALES = 24 -MAX_METADATA_BYTES = 4 * 1024 * 1024 -MAX_PAGE_BYTES = 2 * 1024 * 1024 -FETCH_TIMEOUT = 8.0 -UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36" - -EMOJI_ID = re.compile(r"[a-z0-9_]{1,64}") -EMOJI_REF = re.compile(r"([a-z0-9_]{1,64})(?:-tone([1-5]))?") -LOCALE = re.compile(r"[a-z]{2,3}(?:-[a-z0-9]{2,8}){0,2}") -DOCUMENT_ID = re.compile(r"[A-Za-z0-9_-]{1,120}") -SHORTCUT = re.compile(r"[a-z0-9_-]{1,50}") -AMS_PATH = re.compile(r"/v1/objects/([A-Za-z0-9_-]{1,120})/views/") -AMS_SUFFIXES = (".asyncgw.teams.microsoft.com", ".asm.skype.com") -MAX_ALT = 40 -MAX_TITLE = 100 - -_EMOJI_TAG = re.compile(r"]*)>(?:\s*)?", re.IGNORECASE) -_EMOJI_IMG = re.compile(r"]*\bitemtype\s*=\s*[\"']http://schema\.skype\.com/(?:Custom)?Emoji[\"'][^>]*>", re.IGNORECASE) -_EMOJI_ANY = re.compile(_EMOJI_TAG.pattern + "|" + _EMOJI_IMG.pattern, re.IGNORECASE) -_READONLY = re.compile(r"]*>\s*(]*>)\s*", re.IGNORECASE) -_ATTR = re.compile(r"""([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')""") -_TAG = re.compile(r"<[^>]*>") -_EMOJI_OUT = re.compile(r"]*>(?:[^<]*)?") - - -class EmojiError(RuntimeError): - def __init__(self, status: int, message: str): - self.status = status - super().__init__(message) - - -@dataclass -class Catalogue: - locale: str - version: str - categories: list[dict] = field(default_factory=list) - by_id: dict[str, dict] = field(default_factory=dict) - - def payload(self) -> dict: - return {"locale": self.locale, "version": self.version, "assetBase": ASSET_BASE, "sizes": list(SIZES), "fps": FPS, "categories": self.categories, "custom": []} - - -# --- URLs -------------------------------------------------------------------- -def asset_url(emoji_id: str, size: int, animated: bool, tone: int = 0, etag: str | None = None) -> str: - suffix = f"_s{tone + 1}" if tone else "" - query = f"?v={etag}" if etag and re.fullmatch(r"[A-Za-z0-9._-]{1,20}", etag) else "" - return f"{ASSET_BASE}/{emoji_id}/default/{size}{'_anim' if animated else ''}_f{suffix}.png{query}" - - -def is_cdn_url(url: str) -> bool: - try: - parts = urlsplit(url or "") - port = parts.port - except ValueError: - return False - return parts.scheme == "https" and port in (None, 443) and not parts.username and not parts.password and (parts.hostname or "").lower() in CDN_HOSTS - - -def parse_ref(value) -> tuple[str, int] | None: - """(base id, skin tone 0-5) for a Teams emoji id such as `laugh` or `yes-tone3`, else None.""" - m = EMOJI_REF.fullmatch(value) if isinstance(value, str) else None - return (m.group(1), int(m.group(2) or 0)) if m else None - - -def normalize_locale(value: str | None) -> str | None: - """`default` or a lowercase locale such as `de-de`; None when it is not shaped like one.""" - value = (value or "").strip().lower().replace("_", "-") - if value in ("", "default"): - return "default" - return value if LOCALE.fullmatch(value) else None - - -# --- catalogue --------------------------------------------------------------- -_cache: dict[str, tuple[float, Catalogue]] = {} -_lock = threading.Lock() -_clock = time.monotonic -_version = {"value": os.getenv("TEAMS_EMOTICON_VERSION", DEFAULT_VERSION)} - - -def _json(url: str): - r = http_client().request("GET", url, timeout=FETCH_TIMEOUT) - if r.status_code == 404: - return None - if r.status_code != 200: - raise EmojiError(502, f"Teams emoji metadata {r.status_code}") - if len(r.content) > MAX_METADATA_BYTES: - raise EmojiError(502, "Teams emoji metadata is too large") - try: - return r.json() - except ValueError: - raise EmojiError(502, "Teams emoji metadata is not JSON") from None - - -def _discover_version() -> str | None: - """The asset version Teams' own web client is currently using (its config bundle names it), or None.""" - try: - page = http_client().request("GET", TEAMS_PAGE, headers={"User-Agent": UA}, timeout=FETCH_TIMEOUT, follow_redirects=True) - if page.status_code != 200 or len(page.content) > MAX_PAGE_BYTES: - return None - m = re.search(r"//([\w.-]+)/teams-modular-packages/hashed-assets/(config-prod-[0-9a-f]+\.js)", page.text) - if not m or m.group(1).lower() not in CDN_HOSTS: - return None - bundle = http_client().request("GET", f"https://{m.group(1).lower()}/teams-modular-packages/hashed-assets/{m.group(2)}", timeout=FETCH_TIMEOUT, follow_redirects=True) - if bundle.status_code != 200 or len(bundle.content) > 8 * MAX_PAGE_BYTES: - return None - v = re.search(r'emoticonAssetVersion:\[\{value:"([0-9a-f]{32})"', bundle.text) - return v.group(1) if v else None - except Exception: - return None - - -def _candidates(locale: str) -> list[str]: - names = [locale] - if locale != "default" and "-" not in locale: - names.append(f"{locale}-{locale}") - if locale != "default": - names.append("default") - return names - - -def _fetch(locale: str) -> Catalogue: - for attempt in range(2): - version = _version["value"] - for name in _candidates(locale): - raw = _json(f"{METADATA_BASE}/{version}/{name}.json") - if isinstance(raw, dict) and isinstance(raw.get("categories"), list): - return _build(raw, name, version) - found = _discover_version() if attempt == 0 else None - if not found or found == version: - break - _version["value"] = found - raise EmojiError(502, "The Teams emoji catalogue is unavailable") - - -def _strings(values, count: int, length: int) -> list[str]: - return [v[:length] for v in (values or [])[:count] if isinstance(v, str)] if isinstance(values, list) else [] - - -def _item(e) -> dict | None: - if not isinstance(e, dict) or not isinstance(e.get("id"), str) or not EMOJI_ID.fullmatch(e["id"]): - return None - anim = e.get("animation") if isinstance(e.get("animation"), dict) else {} - frames = anim.get("framesCount") if isinstance(anim.get("framesCount"), int) and 0 < anim.get("framesCount") <= 1000 else 1 - fps = anim.get("fps") if isinstance(anim.get("fps"), (int, float)) and 0 < anim.get("fps") <= 120 else FPS - first = anim.get("firstFrame") if isinstance(anim.get("firstFrame"), int) and 1 <= anim.get("firstFrame") <= frames else 1 - etag = e.get("etag") if isinstance(e.get("etag"), str) else None - animated = frames > 1 - return { - "id": e["id"], - "shortcuts": _strings(e.get("shortcuts"), 20, 40), - "unicode": str(e.get("unicode") or "")[:32], - "title": str(e.get("description") or "")[:MAX_TITLE], - "keywords": _strings(e.get("keywords"), 30, 30), - "diverse": bool(e.get("diverse")), - "animated": animated, - "staticUrl": asset_url(e["id"], INLINE_SIZE, False, 0, etag), - "animatedUrl": asset_url(e["id"], INLINE_SIZE, True, 0, etag) if animated else None, - "frames": frames, - "frameMs": round(1000 / fps, 2) if animated else None, - "firstFrame": first, - "etag": etag, - } - - -def _build(raw: dict, locale: str, version: str) -> Catalogue: - cat = Catalogue(locale, version) - for c in raw["categories"]: - if not isinstance(c, dict): - continue - items = [] - for e in c.get("emoticons") or []: - item = _item(e) - if item and item["id"] not in cat.by_id: - items.append(item) - cat.by_id[item["id"]] = item - cat.categories.append({"id": str(c.get("id") or "")[:100], "title": str(c.get("title") or "")[:100], "emojis": items}) - if not cat.by_id: - raise EmojiError(502, "The Teams emoji catalogue is empty") - return cat - - -def catalogue(locale: str = "default") -> Catalogue: - """The catalogue for `locale` (already normalized), cached 24 hours; concurrent misses fetch once.""" - hit = _cache.get(locale) - if hit and hit[0] > _clock(): - return hit[1] - with _lock: - hit = _cache.get(locale) - if hit and hit[0] > _clock(): - return hit[1] - cat = _fetch(locale) - _cache.pop(locale, None) - _cache[locale] = (_clock() + CATALOGUE_TTL, cat) - while len(_cache) > MAX_LOCALES: - del _cache[next(iter(_cache))] - return cat - - -def cached_catalogue(locale: str = "default") -> Catalogue | None: - hit = _cache.get(locale) - return hit[1] if hit and hit[0] > _clock() else None - - -def clear_cache(): - with _lock: - _cache.clear() - - -def for_send(ref) -> tuple[str, str] | None: - """(alt, title) for an emoji id the catalogue knows (skin tones only on diverse ones), else None. Raises EmojiError if the catalogue can't be loaded.""" - parsed = parse_ref(ref) - if not parsed: - return None - item = catalogue("default").by_id.get(parsed[0]) - if not item or (parsed[1] and not item["diverse"]): - return None - return item["unicode"], item["title"] - - -# --- reading message bodies -------------------------------------------------- -def _attrs(tag: str) -> dict[str, str]: - return {m.group(1).lower(): html.unescape(m.group(2) if m.group(2) is not None else m.group(3)) for m in _ATTR.finditer(tag)} - - -def _custom_document(emoji_id: str, src: str) -> tuple[str, str] | None: - """(shortcut, document id) of a custom emoji: its id is `shortcut;documentId`, or the AMS object in its picture URL.""" - shortcut = "" - if ";" in emoji_id: - shortcut, _, doc = emoji_id.partition(";") - if DOCUMENT_ID.fullmatch(doc) and ".." not in doc: - return (shortcut if SHORTCUT.fullmatch(shortcut) else "", doc) - return None - try: - parts = urlsplit(src or "") - host = (parts.hostname or "").lower() - except ValueError: - return None - m = AMS_PATH.search(parts.path or "") - if m and parts.scheme == "https" and any(host.endswith(s) for s in AMS_SUFFIXES) and ".." not in m.group(1): - return (emoji_id if SHORTCUT.fullmatch(emoji_id or "") else "", m.group(1)) - return None - - -def message_emojis(content: str, cat: Catalogue | None = None) -> tuple[str, list[dict]]: - """(content with each emoji turned into `...` (the alt as text, so clients that strip the tag still show the emoji), the emoji list). - - Entries are `{key, id, alt, title, kind: "teams"|"custom", animated, staticUrl, animatedUrl, frames, frameMs, size, firstFrame, documentId}`; - one entry per distinct emoji, however often it repeats. `size` is the sprite width to use: 50 for a message that is only emoji, else 30. - Anything that looks like an emoji but is neither a valid Teams id nor a custom one is replaced by its alt text.""" - if not content or "emoji" not in content.lower(): - return content, [] - content = _READONLY.sub(lambda m: m.group(1), content) - entries: dict[tuple[str, str], dict] = {} - - def replace(match): - tag = match.group(0) - a = _attrs(tag) - raw_id = (a.get("id") if tag[:2].lower() == "{html.escape(e["alt"])}' - - content = _EMOJI_ANY.sub(replace, content) - if not entries: - return content, [] - lowered = content.lower() - bare = html.unescape(_TAG.sub("", _EMOJI_OUT.sub("", content))).replace(chr(160), "") - only_emoji = ".`), is converted, and is deleted again in a `finally`. Leftovers (a crash between -upload and delete) are swept: files in that folder older than an hour are deleted, at most once per 10 minutes per account. - -Graph answers a conversion with a 302 to a short-lived pre-authenticated URL on `-mediap.svc.ms`; only that host (or a -SharePoint host) is fetched, https only, and the bytes are held in a spooled temporary file so they can be checked (`%PDF`, size) -before anything is sent. -""" - -import logging -import posixpath -import re -import secrets -import tempfile -from datetime import datetime, timedelta, timezone -from urllib.parse import quote, urlsplit - -import httpx -from outnote import GraphError - -from src import outnote_client -from src.account_cache import for_broker - -log = logging.getLogger("schuly.microsoft") - -# Graph's format=pdf source types. -CONVERTIBLE = frozenset({"csv", "doc", "docx", "odp", "ods", "odt", "pot", "potm", "potx", "pps", "ppsx", "ppsxm", "ppt", "pptm", "pptx", "rtf", "xls", "xlsx"}) -TEMP_FOLDER = "schuly-preview" -TEMP_MAX_AGE = timedelta(hours=1) -SWEEP_EVERY = 600 -MAX_PDF_BYTES = 150 * 1024 * 1024 -SPOOL_IN_MEMORY = 8 * 1024 * 1024 -FETCH_TIMEOUT = httpx.Timeout(120.0, connect=5.0) -CONVERTED_HOST = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*-mediap\.svc\.ms") -HOST_SUFFIXES = (".sharepoint.com",) - - -class ConvertError(Exception): - """The file can't be shown as PDF: `status` is the HTTP status to answer with (413, 415, 502).""" - - def __init__(self, status: int, message: str): - self.status = status - super().__init__(message) - - -def extension(name) -> str: - return posixpath.splitext(name or "")[1].lstrip(".").lower() if isinstance(name, str) else "" - - -def is_convertible(name) -> bool: - return extension(name) in CONVERTIBLE - - -def is_converted_url(url) -> bool: - """True only for the https URL Graph redirects a conversion to: `-mediap.svc.ms` exactly, or a SharePoint host.""" - if not isinstance(url, str) or not url or len(url) > 8000 or any(ord(c) < 33 for c in url): - return False - try: - parts = urlsplit(url) - port = parts.port - except ValueError: - return False - host = (parts.hostname or "").lower() - return parts.scheme == "https" and port in (None, 443) and not parts.username and not parts.password and (bool(CONVERTED_HOST.fullmatch(host)) or any(host.endswith(s) for s in HOST_SUFFIXES)) - - -def drive_item_pdf(note, item_id: str, drive_id: str | None = None): - """A drive item converted to PDF, as a spooled file positioned at 0.""" - try: - url = note.converted_url(quote(item_id, safe=""), "pdf", quote(drive_id, safe="") if drive_id else None) - except GraphError as ex: - if ex.status in (400, 406, 415, 422, 501): - raise ConvertError(415, "Microsoft 365 cannot convert this file to PDF.") - raise - return fetch_pdf(url) - - -def fetch_pdf(url: str): - """The converted bytes behind a pre-authenticated URL (no bearer token is sent), checked to be a PDF and capped.""" - if not is_converted_url(url): - raise ConvertError(502, "The conversion answered with an unexpected location.") - out = tempfile.SpooledTemporaryFile(max_size=SPOOL_IN_MEMORY) - try: - with outnote_client.download_client().stream("GET", url, timeout=FETCH_TIMEOUT, follow_redirects=False) as r: - if r.status_code != 200: - raise ConvertError(502, f"The conversion failed ({r.status_code}).") - size = 0 - for chunk in r.iter_bytes(64 * 1024): - size += len(chunk) - if size > MAX_PDF_BYTES: - raise ConvertError(413, "The converted PDF is larger than 150 MB.") - out.write(chunk) - out.seek(0) - if not out.read(1024).lstrip().startswith(b"%PDF"): - raise ConvertError(502, "The conversion did not return a PDF.") - out.seek(0) - return out - except BaseException: - out.close() - raise - - -def bytes_pdf(note, broker, data: bytes, name: str): - """Bytes that aren't in a drive, converted through a temporary file in the app folder that is always deleted again.""" - ext = extension(name) - if ext not in CONVERTIBLE: - raise ConvertError(415, "This file type cannot be converted to PDF.") - sweep(note, broker) - item_id = None - try: - item = note.upload(data, f"{TEMP_FOLDER}/{secrets.token_hex(16)}.{ext}", conflict="fail", app_folder=True) - item_id = (item or {}).get("id") - if not item_id: - raise ConvertError(502, "The temporary upload failed.") - return drive_item_pdf(note, item_id) - finally: - if item_id: - try: - note.delete_file(quote(item_id, safe="")) - except Exception as ex: - # The sweep removes it within the hour. - log.warning("temporary preview file not deleted: %s", type(ex).__name__) - - -def sweep(note, broker, now: datetime | None = None) -> int: - """Deletes temporary files older than an hour (best effort, at most every 10 minutes per account); returns how many.""" - cache = for_broker(broker) - if cache.has(("preview_sweep",)): - return 0 - cache.set(("preview_sweep",), True, SWEEP_EVERY) - now = now or datetime.now(timezone.utc) - deleted = 0 - try: - items = note.list(f"me/drive/special/approot:/{TEMP_FOLDER}:/children", {"$select": "id,name,createdDateTime", "$top": 200}, limit=200) - except GraphError as ex: - if ex.status != 404: - log.info("preview sweep list failed: %s", ex.status) - return 0 - except Exception as ex: - log.info("preview sweep list failed: %s", type(ex).__name__) - return 0 - for item in items: - created = _parse_time(item.get("createdDateTime")) - if created is None or now - created < TEMP_MAX_AGE or not isinstance(item.get("id"), str): - continue - try: - note.delete_file(quote(item["id"], safe="")) - deleted += 1 - except Exception as ex: - log.info("preview sweep delete failed: %s", type(ex).__name__) - return deleted - - -def _parse_time(value) -> datetime | None: - if not isinstance(value, str): - return None - try: - parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError: - return None - return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) diff --git a/src/file_text.py b/src/file_text.py deleted file mode 100644 index a9444f9..0000000 --- a/src/file_text.py +++ /dev/null @@ -1,313 +0,0 @@ -"""Plain text out of documents, spreadsheets, presentations, PDFs and text files (the FileText answer). - -Office and PDF libraries are imported lazily. Untrusted bytes are checked before they reach an OOXML library: the source is capped, -the zip's declared uncompressed size is summed (zipfile never reads past a member's declared size) and a member with an absurd -compression ratio or an XML part declaring entities is refused. Nothing here logs content; it runs in the worker thread. -""" - -import csv -import io -import posixpath -import zipfile -from datetime import date, datetime, time - -MAX_SOURCE_BYTES = 25 * 1024 * 1024 -MAX_UNCOMPRESSED = 200 * 1024 * 1024 -MAX_ZIP_MEMBERS = 10000 -MAX_RATIO = 250 -RATIO_FROM = 4 * 1024 * 1024 -DEFAULT_CHARS = 20000 -MAX_CHARS = 100000 -TEXT_CAP = 2_000_000 -MAX_PDF_PAGES = 300 -MAX_SLIDES = 500 -DEFAULT_ROWS = 500 -MAX_ROWS = 2000 -MAX_ROW_OFFSET = 1_000_000 -PEEK_ROWS = 10000 - -DOCUMENT = {"docx"} -SPREADSHEET = {"xlsx", "xlsm"} -PRESENTATION = {"pptx"} -PDF = {"pdf"} -TEXT = {"txt", "md", "markdown", "csv", "tsv", "json", "xml", "html", "htm", "ics", "log", "yaml", "yml", "ini", "cfg", "conf", "toml", "tex", "sql", "css", "js", "ts", "py", "java", "cs", "c", "cpp", "h", "sh", "ps1", "rb", "go", "rs", "php", "kt", "swift", "dart", "lua", "vcf", "srt"} -LEGACY = {"doc", "xls", "ppt", "odt", "ods", "odp", "rtf"} -UNSUPPORTED = "This file type cannot be read as text." - - -class TextError(Exception): - """The file can't be read as text: `status` is the HTTP status to answer with (413, 415, 422).""" - - def __init__(self, status: int, message: str): - self.status = status - super().__init__(message) - - -def extension(name) -> str: - return posixpath.splitext(name or "")[1].lstrip(".").lower() if isinstance(name, str) else "" - - -def kind_of(name) -> str | None: - """document | spreadsheet | presentation | pdf | text | legacy (convert to PDF first), or None when the type can't be read.""" - ext = extension(name) - for kind, exts in (("document", DOCUMENT), ("spreadsheet", SPREADSHEET), ("presentation", PRESENTATION), ("pdf", PDF), ("text", TEXT), ("legacy", LEGACY)): - if ext in exts: - return kind - return None - - -def extract(name: str, data: bytes, offset: int = 0, max_chars: int = DEFAULT_CHARS, sheet: str | None = None, row_offset: int = 0, max_rows: int = DEFAULT_ROWS, notes: list[str] | None = None) -> dict: - """The FileText answer for `data`. Legacy formats must be converted to PDF by the caller (`extract` then gets the PDF, name ending in .pdf).""" - kind = kind_of(name) - if kind is None or kind == "legacy": - raise TextError(415, UNSUPPORTED) - if len(data) > MAX_SOURCE_BYTES: - raise TextError(413, "The file is larger than 25 MB.") - max_chars = max(1, min(max_chars, MAX_CHARS)) - offset = max(0, offset) - notes = list(notes or []) - result = {"name": name, "kind": kind} - try: - if kind == "spreadsheet": - _zip_guard(data) - text, extra = _spreadsheet(data, sheet, max(0, min(row_offset, MAX_ROW_OFFSET)), max(1, min(max_rows, MAX_ROWS)), notes) - elif kind == "document": - _zip_guard(data) - text, extra = _document(data, notes), {} - elif kind == "presentation": - _zip_guard(data) - text, extra = _presentation(data, notes, result), {} - elif kind == "pdf": - text, extra = _pdf(data, notes, result), {} - else: - text, extra = _plain(name, data, notes), {} - except TextError: - raise - except Exception: - raise TextError(422, "The file could not be read.") - capped = len(text) > TEXT_CAP - if capped: - text = text[:TEXT_CAP] - notes.append(f"Only the first {TEXT_CAP} characters are counted.") - page = text[offset : offset + max_chars] - end = offset + len(page) - more = end < len(text) - result.update({"text": page, "offset": offset, "nextOffset": end if more else None, "totalChars": len(text), "truncated": more or capped or extra.pop("limited", False), "notes": notes}) - result.update(extra) - return result - - -# --- guards ----------------------------------------------------------------- -def _zip_guard(data: bytes): - try: - archive = zipfile.ZipFile(io.BytesIO(data)) - except zipfile.BadZipFile: - raise TextError(422, "The file is damaged or not an Office document.") - with archive: - members = archive.infolist() - if len(members) > MAX_ZIP_MEMBERS: - raise TextError(413, "The file contains too many parts.") - total = 0 - for m in members: - total += m.file_size - if m.file_size >= RATIO_FROM and m.file_size > MAX_RATIO * max(m.compress_size, 1): - raise TextError(413, "The file is compressed suspiciously well and is refused.") - if total > MAX_UNCOMPRESSED: - raise TextError(413, "The file unpacks to more than 200 MB.") - for m in members: - if m.filename.lower().endswith((".xml", ".rels")) and m.file_size: - with archive.open(m) as part: - head = part.read(4096).upper() - if b" str: - return " ".join(str(text).split()).replace("|", "\\|") - - -def _document(data: bytes, notes: list[str]) -> str: - from docx import Document - from docx.table import Table - from docx.text.paragraph import Paragraph - - doc = Document(io.BytesIO(data)) - out, size = [], 0 - for child in doc.element.body.iterchildren(): - tag = child.tag.rsplit("}", 1)[-1] - if tag == "p": - para = Paragraph(child, doc) - text = para.text - if not text.strip(): - out.append("") - continue - style = (para.style.name if para.style is not None else "") or "" - if style == "Title": - text = "# " + text - elif style.startswith("Heading "): - level = style[8:] - text = "#" * (int(level) if level.isdigit() and 1 <= int(level) <= 6 else 1) + " " + text - out.append(text) - size += len(text) - elif tag == "tbl": - for row in Table(child, doc).rows: - seen, cells = set(), [] - for cell in row.cells: - if id(cell._tc) in seen: - continue - seen.add(id(cell._tc)) - cells.append(_cell(cell.text)) - line = "| " + " | ".join(cells) + " |" - out.append(line) - size += len(line) - if size > TEXT_CAP: - notes.append("The document is longer than the reader's limit; the rest is left out.") - break - return _squeeze("\n".join(out)) - - -def _squeeze(text: str) -> str: - while "\n\n\n" in text: - text = text.replace("\n\n\n", "\n\n") - return text.strip() - - -# --- spreadsheet ------------------------------------------------------------ -def _value(v) -> str: - if v is None: - return "" - if isinstance(v, (datetime, date, time)): - return v.isoformat() - if isinstance(v, bool): - return "TRUE" if v else "FALSE" - return str(v) - - -def _spreadsheet(data: bytes, sheet: str | None, row_offset: int, max_rows: int, notes: list[str]): - import defusedxml # noqa: F401 (openpyxl parses with defusedxml when it is installed) - from itertools import islice - - from openpyxl import load_workbook - - wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True) - try: - sheets = [{"name": ws.title, "rows": int(ws.max_row or 0), "columns": int(ws.max_column or 0)} for ws in wb.worksheets] - if not sheets: - raise TextError(422, "The workbook has no sheets.") - name = sheet if sheet is not None else sheets[0]["name"] - if name not in wb.sheetnames: - raise TextError(404, "Sheet not found.") - ws = wb[name] - rows = ws.iter_rows(values_only=True) - for _ in islice(rows, row_offset): - pass - page = [list(r) for r in islice(rows, max_rows)] - more = False - for peeked in islice(rows, PEEK_ROWS): - if any(v is not None and v != "" for v in peeked): - more = True - break - while page and all(v is None or v == "" for v in page[-1]): - page.pop() - width = max((max((i + 1 for i, v in enumerate(r) if v is not None and v != ""), default=0) for r in page), default=0) - buf = io.StringIO() - writer = csv.writer(buf, lineterminator="\n") - for r in page: - writer.writerow([_value(v) for v in (r + [None] * width)[:width]]) - count = len(page) - if more: - notes.append(f"Only rows {row_offset + 1} to {row_offset + count} of sheet {name} are included; continue with rowOffset={row_offset + count}.") - extra = {"sheets": sheets, "sheet": name, "rowOffset": row_offset, "rowCount": count, "nextRowOffset": row_offset + count if more else None, "limited": more} - return buf.getvalue(), extra - finally: - wb.close() - - -# --- presentation ----------------------------------------------------------- -def _shape_text(shape, out: list[str]): - if getattr(shape, "has_text_frame", False) and shape.has_text_frame: - text = shape.text_frame.text.strip() - if text: - out.append(text) - if getattr(shape, "has_table", False) and shape.has_table: - for row in shape.table.rows: - out.append("| " + " | ".join(_cell(c.text) for c in row.cells) + " |") - if shape.shape_type == 6: # group - for inner in shape.shapes: - _shape_text(inner, out) - - -def _presentation(data: bytes, notes: list[str], result: dict) -> str: - from pptx import Presentation - - prs = Presentation(io.BytesIO(data)) - slides = list(prs.slides) - result["pages"] = len(slides) - out, size = [], 0 - for number, slide in enumerate(slides, 1): - if number > MAX_SLIDES: - notes.append(f"Only the first {MAX_SLIDES} slides are included.") - break - lines = [f"--- Slide {number} ---"] - for shape in slide.shapes: - _shape_text(shape, lines) - if slide.has_notes_slide: - spoken = slide.notes_slide.notes_text_frame.text.strip() if slide.notes_slide.notes_text_frame is not None else "" - if spoken: - lines.append("Notes: " + spoken) - block = "\n".join(lines) - out.append(block) - size += len(block) - if size > TEXT_CAP: - notes.append("The presentation is longer than the reader's limit; the rest is left out.") - break - return "\n\n".join(out) - - -# --- pdf -------------------------------------------------------------------- -def _pdf(data: bytes, notes: list[str], result: dict) -> str: - from pypdf import PdfReader - - reader = PdfReader(io.BytesIO(data)) - if reader.is_encrypted: - try: - ok = reader.decrypt("") - except Exception: - ok = 0 - if not ok: - raise TextError(422, "The PDF is password protected.") - total = len(reader.pages) - result["pages"] = total - out, size = [], 0 - for number, page in enumerate(reader.pages, 1): - if number > MAX_PDF_PAGES: - notes.append(f"Only the first {MAX_PDF_PAGES} pages are included.") - break - out.append(f"--- Page {number} ---\n{(page.extract_text() or '').strip()}") - size += len(out[-1]) - if size > TEXT_CAP: - notes.append("The PDF is longer than the reader's limit; the rest is left out.") - break - return "\n\n".join(out) - - -# --- plain text ------------------------------------------------------------- -def decode(data: bytes) -> str: - if data.startswith(b"\xef\xbb\xbf"): - return data[3:].decode("utf-8", "replace") - if data.startswith((b"\xff\xfe", b"\xfe\xff")): - return data.decode("utf-16", "replace") - try: - return data.decode("utf-8") - except UnicodeDecodeError: - return data.decode("latin-1") - - -def _plain(name: str, data: bytes, notes: list[str]) -> str: - text = decode(data) - if extension(name) in ("html", "htm"): - from outnote import html_to_text - - return html_to_text(text) - return text.replace("\r\n", "\n").replace("\r", "\n") diff --git a/src/file_thumbs.py b/src/file_thumbs.py deleted file mode 100644 index c168f58..0000000 --- a/src/file_thumbs.py +++ /dev/null @@ -1,152 +0,0 @@ -"""File kinds and thumbnails for files shared in chats and for OneDrive items. - -A file shared in a chat is a `reference` attachment whose `contentUrl` is a SharePoint / OneDrive link. Graph turns that link into -its drive item (`shares/{u!base64url(url)}/driveItem`), and the item has thumbnails (`thumbnails/0/{small|medium|large}/content`) for -Office documents, PDFs, images and videos. The resolved item ids are cached per account for an hour. - -Only SharePoint / OneDrive hosts are accepted (`*.sharepoint.com` by suffix, `onedrive.live.com` and `1drv.ms` exactly), https only. -""" - -import posixpath -import re -from urllib.parse import quote, urlsplit - -from outnote import GraphError - -from src.account_cache import for_broker - -ITEM_TTL = 3600 -SIZES = ("small", "medium", "large") -EXACT_HOSTS = frozenset({"onedrive.live.com", "1drv.ms"}) -HOST_SUFFIXES = (".sharepoint.com",) -GRAPH_ID = re.compile(r"[A-Za-z0-9!=_\-+.]{1,512}") -MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024 - -KINDS_BY_EXTENSION = { - "word": {"doc", "docx", "docm", "dot", "dotx", "odt", "rtf"}, - "excel": {"xls", "xlsx", "xlsm", "xlsb", "xlt", "xltx", "ods", "csv"}, - "powerpoint": {"ppt", "pptx", "pptm", "pps", "ppsx", "odp"}, - "pdf": {"pdf"}, - "onenote": {"one", "onetoc2"}, - "image": {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "heic", "tif", "tiff", "ico", "avif"}, - "video": {"mp4", "mov", "mkv", "webm", "avi", "m4v", "wmv", "mpg", "mpeg"}, - "audio": {"mp3", "wav", "m4a", "ogg", "oga", "flac", "aac", "wma", "opus"}, - "archive": {"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz"}, - "code": {"py", "js", "ts", "tsx", "jsx", "java", "cs", "c", "cpp", "h", "hpp", "go", "rs", "rb", "php", "sh", "ps1", "json", "xml", "yml", "yaml", "html", "htm", "css", "scss", "sql", "kt", "swift", "dart", "lua", "ipynb", "md"}, - "text": {"txt", "log", "ini", "cfg", "conf", "tex"}, -} -EXTENSION_KIND = {ext: kind for kind, exts in KINDS_BY_EXTENSION.items() for ext in exts} -THUMBNAIL_KINDS = frozenset({"word", "excel", "powerpoint", "pdf", "image", "video", "text"}) - - -def is_share_url(url) -> bool: - """True only for an https SharePoint / OneDrive link (no credentials, default port).""" - if not isinstance(url, str) or not url or len(url) > 4000 or any(ord(c) < 33 for c in url): - return False - try: - parts = urlsplit(url) - port = parts.port - except ValueError: - return False - host = (parts.hostname or "").lower() - return parts.scheme == "https" and port in (None, 443) and not parts.username and not parts.password and (host in EXACT_HOSTS or any(host.endswith(s) for s in HOST_SUFFIXES)) - - -def file_kind(name, mime) -> str: - """word | excel | powerpoint | pdf | onenote | image | video | audio | archive | code | text | other, from the extension, then the MIME type.""" - ext = posixpath.splitext(name or "")[1].lstrip(".").lower() if isinstance(name, str) else "" - if ext in EXTENSION_KIND: - return EXTENSION_KIND[ext] - mime = (mime or "").split(";")[0].strip().lower() if isinstance(mime, str) else "" - major = mime.split("/")[0] - if major in ("image", "video", "audio"): - return major - if mime == "application/pdf": - return "pdf" - if "wordprocessingml" in mime or mime == "application/msword": - return "word" - if "spreadsheetml" in mime or mime == "application/vnd.ms-excel": - return "excel" - if "presentationml" in mime or mime == "application/vnd.ms-powerpoint": - return "powerpoint" - if mime in ("application/zip", "application/x-7z-compressed", "application/vnd.rar", "application/gzip", "application/x-tar"): - return "archive" - if mime in ("application/json", "application/xml", "application/javascript", "text/html", "text/css", "text/x-python"): - return "code" - if major == "text": - return "text" - return "other" - - -def has_thumbnail(url, kind: str) -> bool: - """Whether the file's host and type are ones that have thumbnails; decided from the data already at hand (no call per file).""" - return kind in THUMBNAIL_KINDS and is_share_url(url) - - -def image_type(head: bytes) -> str | None: - """JPEG, PNG or WEBP from the magic bytes; anything else is None.""" - if head.startswith(b"\xff\xd8\xff"): - return "image/jpeg" - if head.startswith(b"\x89PNG\r\n\x1a\n"): - return "image/png" - if head[:4] == b"RIFF" and head[8:12] == b"WEBP": - return "image/webp" - return None - - -def safe_graph_id(value) -> bool: - return isinstance(value, str) and bool(GRAPH_ID.fullmatch(value)) and ".." not in value and value != "." - - -def _resolve(note, broker, url: str) -> tuple[str, str] | None: - """(drive id, item id) of the file behind a share URL, cached for an hour; None when it is gone, a folder or unreadable.""" - from src import chat_read # chat_read imports this module for the file views - - cache = for_broker(broker) - key = ("share_item", url) - marker = object() - hit = cache.get(key, marker) - if hit is not marker: - return hit - try: - item = note.get(chat_read.drive_item_path(url, "id,parentReference,file,size,webUrl")) - except GraphError as ex: - if ex.status in (400, 403, 404): - cache.set(key, None, ITEM_TTL) - return None - raise - drive = ((item or {}).get("parentReference") or {}).get("driveId") - item_id = (item or {}).get("id") - found = (drive, item_id) if (item or {}).get("file") is not None and safe_graph_id(drive) and safe_graph_id(item_id) else None - cache.set(key, found, ITEM_TTL) - return found - - -def _content(note, path: str) -> bytes | None: - try: - data = note.get(path, raw="bytes") - except GraphError as ex: - if ex.status in (400, 404, 415): - return None - raise - if not data or len(data) > MAX_THUMBNAIL_BYTES or image_type(data[:12]) is None: - return None - return data - - -def chat_file_thumbnail(note, broker, url: str, size: str) -> bytes | None: - """The thumbnail bytes of a chat-shared file, or None when the URL isn't a share link or the file has no thumbnail.""" - if size not in SIZES or not is_share_url(url): - return None - ids = _resolve(note, broker, url) - if not ids: - return None - return _content(note, f"drives/{quote(ids[0], safe='')}/items/{quote(ids[1], safe='')}/thumbnails/0/{size}/content") - - -def drive_item_thumbnail(note, item_id: str, size: str, drive_id: str | None = None) -> bytes | None: - """The thumbnail of an item in the user's OneDrive, or of `drive_id`'s item (a team library, a shared drive).""" - if size not in SIZES or not safe_graph_id(item_id) or (drive_id is not None and not safe_graph_id(drive_id)): - return None - base = f"drives/{quote(drive_id, safe='')}/items" if drive_id else "me/drive/items" - return _content(note, f"{base}/{quote(item_id, safe='')}/thumbnails/0/{size}/content") diff --git a/src/folder_roles.py b/src/folder_roles.py deleted file mode 100644 index 5ad3719..0000000 --- a/src/folder_roles.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Well-known mail folder roles. - -Graph lists mail folders by display name, which is in the mailbox language ("Posteingang", -"Gesendete Elemente", ...). Each well-known folder is also addressable by its fixed name -(`me/mailFolders/inbox`), so one `$batch` of those lookups maps folder ids to roles. The map is -cached per account for an hour; a name the mailbox lacks (404) is simply absent. -""" - -from src.account_cache import for_broker - -WELL_KNOWN_NAMES = ("inbox", "drafts", "sentitems", "deleteditems", "junkemail", "archive", "outbox", "conversationhistory", "syncissues", "conflicts", "localfailures", "serverfailures", "rssfeeds") -ROLES_TTL = 3600 -CACHE_KEY = "mail_folder_roles" - - -def lookup(note) -> dict[str, str]: - """folder id -> well-known name, from one Graph $batch (13 requests, under the 20 per batch limit).""" - results = note.batch([f"me/mailFolders/{name}?$select=id" for name in WELL_KNOWN_NAMES]) - roles = {} - for name, result in zip(WELL_KNOWN_NAMES, results): - if isinstance(result, dict) and isinstance(result.get("id"), str): - roles.setdefault(result["id"], name) - return roles - - -def cached(broker) -> dict[str, str] | None: - return for_broker(broker).get(CACHE_KEY) - - -def remember(broker, roles: dict[str, str]): - # An empty map means the lookup failed (every mailbox has an inbox); try again on the next list. - if roles: - for_broker(broker).set(CACHE_KEY, roles, ROLES_TTL) diff --git a/src/gifs.py b/src/gifs.py deleted file mode 100644 index e67af46..0000000 --- a/src/gifs.py +++ /dev/null @@ -1,91 +0,0 @@ -"""GIF search over Giphy's public API. - -Teams shows Giphy GIFs as a plain `` -in the message body, so a GIF picked here sends the same way. The Teams clients query Giphy with -Microsoft's own key; Schuly needs its own (`GIPHY_API_KEY`, free from developers.giphy.com). -Without one the search answers 503. -""" - -import os -import time -from urllib.parse import urlencode - -from entrance import http_client - -from src import chat_html -from src.account_cache import AccountCache, for_broker - -API = "https://api.giphy.com/v1/gifs" -RATING = os.getenv("GIPHY_RATING", "g") -MAX_LIMIT = 50 -RESULTS_TTL = 300 # identical searches within 5 minutes share one Giphy call -RATE_PER_MINUTE = 30 -_results = AccountCache(max_items=500) - - -class GifSearchUnavailable(RuntimeError): - pass - - -def api_key() -> str | None: - return os.getenv("GIPHY_API_KEY") or None - - -def _image(images: dict, *names: str) -> dict: - for name in names: - img = images.get(name) or {} - if img.get("url"): - return img - return {} - - -def _int(value) -> int | None: - try: - return int(value) - except (TypeError, ValueError): - return None - - -def _shape(item: dict) -> dict | None: - images = item.get("images") or {} - full = _image(images, "downsized_medium", "downsized", "original") - preview = _image(images, "fixed_width_small", "fixed_width", "preview_gif") or full - url = (full.get("url") or "").split("?")[0] - if not chat_html.is_gif_host(url): - return None - return {"id": item.get("id"), "title": item.get("title") or "GIF", "url": url, "previewUrl": (preview.get("url") or url).split("?")[0], "width": _int(full.get("width")), "height": _int(full.get("height"))} - - -def allow(broker) -> bool: - """A fixed one-minute window per account: at most RATE_PER_MINUTE searches.""" - cache = for_broker(broker) - window = ("gif_rate", int(time.time() // 60)) - with cache.lock("gif_rate"): - used = cache.get(window, 0) - if used >= RATE_PER_MINUTE: - return False - cache.set(window, used + 1, 120) - return True - - -def search(query: str | None, limit: int) -> list[dict]: - key = api_key() - if not key: - raise GifSearchUnavailable("GIF search needs a Giphy API key (GIPHY_API_KEY).") - cache_key = ((query or "").strip().lower() or None, limit) - hit = _results.get(cache_key) - if hit is not None: - return hit - found = _search(key, query, limit) - _results.set(cache_key, found, RESULTS_TTL) - return found - - -def _search(key: str, query: str | None, limit: int) -> list[dict]: - params = {"api_key": key, "limit": max(1, min(limit, MAX_LIMIT)), "rating": RATING} - if query: - params["q"] = query - r = http_client().request("GET", f"{API}/{'search' if query else 'trending'}?{urlencode(params)}", timeout=15) - if r.status_code != 200: - raise RuntimeError(f"Giphy answered {r.status_code}") - return [g for g in (_shape(i) for i in r.json().get("data") or []) if g] diff --git a/src/infrastructure/files/file_text_extractor.py b/src/infrastructure/files/file_text_extractor.py new file mode 100644 index 0000000..247d5e5 --- /dev/null +++ b/src/infrastructure/files/file_text_extractor.py @@ -0,0 +1,292 @@ +"""Plain text out of documents, spreadsheets, presentations, PDFs and text files (the FileText answer). + +Office and PDF libraries are imported lazily. Untrusted bytes are checked before they reach an OOXML library: the source is capped, +the zip's declared uncompressed size is summed (zipfile never reads past a member's declared size) and a member with an absurd +compression ratio or an XML part declaring entities is refused. Nothing here logs content; it runs in the worker thread. +""" + +import csv +import io +import posixpath +import zipfile +from datetime import date, datetime, time + +from src.application.abstractions.i_file_text_extractor import IFileTextExtractor +from src.domain.errors.text_error import TextError +from src.domain.files.file_text_limits import FileTextLimits + + +class FileTextExtractor(IFileTextExtractor): + """Plain text out of documents, spreadsheets, presentations, PDFs and text files.""" + + MAX_SOURCE_BYTES = FileTextLimits.MAX_SOURCE_BYTES + MAX_UNCOMPRESSED = 200 * 1024 * 1024 + MAX_ZIP_MEMBERS = 10000 + MAX_RATIO = 250 + RATIO_FROM = 4 * 1024 * 1024 + DEFAULT_CHARS = FileTextLimits.DEFAULT_CHARS + MAX_CHARS = FileTextLimits.MAX_CHARS + TEXT_CAP = 2_000_000 + MAX_PDF_PAGES = 300 + MAX_SLIDES = 500 + DEFAULT_ROWS = FileTextLimits.DEFAULT_ROWS + MAX_ROWS = FileTextLimits.MAX_ROWS + MAX_ROW_OFFSET = FileTextLimits.MAX_ROW_OFFSET + PEEK_ROWS = 10000 + DOCUMENT = {"docx"} + SPREADSHEET = {"xlsx", "xlsm"} + PRESENTATION = {"pptx"} + PDF = {"pdf"} + TEXT = {"txt", "md", "markdown", "csv", "tsv", "json", "xml", "html", "htm", "ics", "log", "yaml", "yml", "ini", "cfg", "conf", "toml", "tex", "sql", "css", "js", "ts", "py", "java", "cs", "c", "cpp", "h", "sh", "ps1", "rb", "go", "rs", "php", "kt", "swift", "dart", "lua", "vcf", "srt"} + LEGACY = {"doc", "xls", "ppt", "odt", "ods", "odp", "rtf"} + UNSUPPORTED = FileTextLimits.UNSUPPORTED + + def extension(self, name) -> str: + return posixpath.splitext(name or "")[1].lstrip(".").lower() if isinstance(name, str) else "" + + def kind_of(self, name) -> str | None: + """document | spreadsheet | presentation | pdf | text | legacy (convert to PDF first), or None when the type can't be read.""" + ext = self.extension(name) + for kind, exts in (("document", self.DOCUMENT), ("spreadsheet", self.SPREADSHEET), ("presentation", self.PRESENTATION), ("pdf", self.PDF), ("text", self.TEXT), ("legacy", self.LEGACY)): + if ext in exts: + return kind + return None + + def extract(self, name: str, data: bytes, offset: int = 0, max_chars: int = DEFAULT_CHARS, sheet: str | None = None, row_offset: int = 0, max_rows: int = DEFAULT_ROWS, notes: list[str] | None = None) -> dict: + """The FileText answer for `data`. Legacy formats must be converted to PDF by the caller (`extract` then gets the PDF, name ending in .pdf).""" + kind = self.kind_of(name) + if kind is None or kind == "legacy": + raise TextError(415, self.UNSUPPORTED) + if len(data) > self.MAX_SOURCE_BYTES: + raise TextError(413, "The file is larger than 25 MB.") + max_chars = max(1, min(max_chars, self.MAX_CHARS)) + offset = max(0, offset) + notes = list(notes or []) + result = {"name": name, "kind": kind} + try: + if kind == "spreadsheet": + self._zip_guard(data) + text, extra = self._spreadsheet(data, sheet, max(0, min(row_offset, self.MAX_ROW_OFFSET)), max(1, min(max_rows, self.MAX_ROWS)), notes) + elif kind == "document": + self._zip_guard(data) + text, extra = self._document(data, notes), {} + elif kind == "presentation": + self._zip_guard(data) + text, extra = self._presentation(data, notes, result), {} + elif kind == "pdf": + text, extra = self._pdf(data, notes, result), {} + else: + text, extra = self._plain(name, data, notes), {} + except TextError: + raise + except Exception: + raise TextError(422, "The file could not be read.") + capped = len(text) > self.TEXT_CAP + if capped: + text = text[:self.TEXT_CAP] + notes.append(f"Only the first {self.TEXT_CAP} characters are counted.") + page = text[offset : offset + max_chars] + end = offset + len(page) + more = end < len(text) + result.update({"text": page, "offset": offset, "nextOffset": end if more else None, "totalChars": len(text), "truncated": more or capped or extra.pop("limited", False), "notes": notes}) + result.update(extra) + return result + + def _zip_guard(self, data: bytes): + try: + archive = zipfile.ZipFile(io.BytesIO(data)) + except zipfile.BadZipFile: + raise TextError(422, "The file is damaged or not an Office document.") + with archive: + members = archive.infolist() + if len(members) > self.MAX_ZIP_MEMBERS: + raise TextError(413, "The file contains too many parts.") + total = 0 + for m in members: + total += m.file_size + if m.file_size >= self.RATIO_FROM and m.file_size > self.MAX_RATIO * max(m.compress_size, 1): + raise TextError(413, "The file is compressed suspiciously well and is refused.") + if total > self.MAX_UNCOMPRESSED: + raise TextError(413, "The file unpacks to more than 200 MB.") + for m in members: + if m.filename.lower().endswith((".xml", ".rels")) and m.file_size: + with archive.open(m) as part: + head = part.read(4096).upper() + if b" str: + return " ".join(str(text).split()).replace("|", "\\|") + + def _document(self, data: bytes, notes: list[str]) -> str: + from docx import Document + from docx.table import Table + from docx.text.paragraph import Paragraph + + doc = Document(io.BytesIO(data)) + out, size = [], 0 + for child in doc.element.body.iterchildren(): + tag = child.tag.rsplit("}", 1)[-1] + if tag == "p": + para = Paragraph(child, doc) + text = para.text + if not text.strip(): + out.append("") + continue + style = (para.style.name if para.style is not None else "") or "" + if style == "Title": + text = "# " + text + elif style.startswith("Heading "): + level = style[8:] + text = "#" * (int(level) if level.isdigit() and 1 <= int(level) <= 6 else 1) + " " + text + out.append(text) + size += len(text) + elif tag == "tbl": + for row in Table(child, doc).rows: + seen, cells = set(), [] + for cell in row.cells: + if id(cell._tc) in seen: + continue + seen.add(id(cell._tc)) + cells.append(self._cell(cell.text)) + line = "| " + " | ".join(cells) + " |" + out.append(line) + size += len(line) + if size > self.TEXT_CAP: + notes.append("The document is longer than the reader's limit; the rest is left out.") + break + return self._squeeze("\n".join(out)) + + def _squeeze(self, text: str) -> str: + while "\n\n\n" in text: + text = text.replace("\n\n\n", "\n\n") + return text.strip() + + def _value(self, v) -> str: + if v is None: + return "" + if isinstance(v, (datetime, date, time)): + return v.isoformat() + if isinstance(v, bool): + return "TRUE" if v else "FALSE" + return str(v) + + def _spreadsheet(self, data: bytes, sheet: str | None, row_offset: int, max_rows: int, notes: list[str]): + import defusedxml # noqa: F401 (openpyxl parses with defusedxml when it is installed) + from itertools import islice + + from openpyxl import load_workbook + + wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True) + try: + sheets = [{"name": ws.title, "rows": int(ws.max_row or 0), "columns": int(ws.max_column or 0)} for ws in wb.worksheets] + if not sheets: + raise TextError(422, "The workbook has no sheets.") + name = sheet if sheet is not None else sheets[0]["name"] + if name not in wb.sheetnames: + raise TextError(404, "Sheet not found.") + ws = wb[name] + rows = ws.iter_rows(values_only=True) + for _ in islice(rows, row_offset): + pass + page = [list(r) for r in islice(rows, max_rows)] + more = False + for peeked in islice(rows, self.PEEK_ROWS): + if any(v is not None and v != "" for v in peeked): + more = True + break + while page and all(v is None or v == "" for v in page[-1]): + page.pop() + width = max((max((i + 1 for i, v in enumerate(r) if v is not None and v != ""), default=0) for r in page), default=0) + buf = io.StringIO() + writer = csv.writer(buf, lineterminator="\n") + for r in page: + writer.writerow([self._value(v) for v in (r + [None] * width)[:width]]) + count = len(page) + if more: + notes.append(f"Only rows {row_offset + 1} to {row_offset + count} of sheet {name} are included; continue with rowOffset={row_offset + count}.") + extra = {"sheets": sheets, "sheet": name, "rowOffset": row_offset, "rowCount": count, "nextRowOffset": row_offset + count if more else None, "limited": more} + return buf.getvalue(), extra + finally: + wb.close() + + def _shape_text(self, shape, out: list[str]): + if getattr(shape, "has_text_frame", False) and shape.has_text_frame: + text = shape.text_frame.text.strip() + if text: + out.append(text) + if getattr(shape, "has_table", False) and shape.has_table: + for row in shape.table.rows: + out.append("| " + " | ".join(self._cell(c.text) for c in row.cells) + " |") + if shape.shape_type == 6: # group + for inner in shape.shapes: + self._shape_text(inner, out) + + def _presentation(self, data: bytes, notes: list[str], result: dict) -> str: + from pptx import Presentation + + prs = Presentation(io.BytesIO(data)) + slides = list(prs.slides) + result["pages"] = len(slides) + out, size = [], 0 + for number, slide in enumerate(slides, 1): + if number > self.MAX_SLIDES: + notes.append(f"Only the first {self.MAX_SLIDES} slides are included.") + break + lines = [f"--- Slide {number} ---"] + for shape in slide.shapes: + self._shape_text(shape, lines) + if slide.has_notes_slide: + spoken = slide.notes_slide.notes_text_frame.text.strip() if slide.notes_slide.notes_text_frame is not None else "" + if spoken: + lines.append("Notes: " + spoken) + block = "\n".join(lines) + out.append(block) + size += len(block) + if size > self.TEXT_CAP: + notes.append("The presentation is longer than the reader's limit; the rest is left out.") + break + return "\n\n".join(out) + + def _pdf(self, data: bytes, notes: list[str], result: dict) -> str: + from pypdf import PdfReader + + reader = PdfReader(io.BytesIO(data)) + if reader.is_encrypted: + try: + ok = reader.decrypt("") + except Exception: + ok = 0 + if not ok: + raise TextError(422, "The PDF is password protected.") + total = len(reader.pages) + result["pages"] = total + out, size = [], 0 + for number, page in enumerate(reader.pages, 1): + if number > self.MAX_PDF_PAGES: + notes.append(f"Only the first {self.MAX_PDF_PAGES} pages are included.") + break + out.append(f"--- Page {number} ---\n{(page.extract_text() or '').strip()}") + size += len(out[-1]) + if size > self.TEXT_CAP: + notes.append("The PDF is longer than the reader's limit; the rest is left out.") + break + return "\n\n".join(out) + + def decode(self, data: bytes) -> str: + if data.startswith(b"\xef\xbb\xbf"): + return data[3:].decode("utf-8", "replace") + if data.startswith((b"\xff\xfe", b"\xfe\xff")): + return data.decode("utf-16", "replace") + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data.decode("latin-1") + + def _plain(self, name: str, data: bytes, notes: list[str]) -> str: + text = self.decode(data) + if self.extension(name) in ("html", "htm"): + from outnote import html_to_text + + return html_to_text(text) + return text.replace("\r\n", "\n").replace("\r", "\n") diff --git a/src/infrastructure/giphy/gif_search.py b/src/infrastructure/giphy/gif_search.py new file mode 100644 index 0000000..2410953 --- /dev/null +++ b/src/infrastructure/giphy/gif_search.py @@ -0,0 +1,86 @@ +"""GIF search over Giphy's public API. + +Teams shows Giphy GIFs as a plain `` +in the message body, so a GIF picked here sends the same way. The Teams clients query Giphy with +Microsoft's own key; Schuly needs its own (`GIPHY_API_KEY`, free from developers.giphy.com). +Without one the search answers 503. +""" + +import time +from urllib.parse import urlencode +from entrance import http_client + +from src.application.abstractions.i_gif_search import IGifSearch +from src.application.options.giphy_options import GiphyOptions +from src.application.services.chat_html_sanitizer import ChatHtmlSanitizer +from src.domain.errors.gif_search_unavailable import GifSearchUnavailable +from src.infrastructure.microsoft.account_cache import AccountCache + + +class GifSearch(IGifSearch): + """GIF search over Giphy's public API, with a per-account rate limit.""" + + API = "https://api.giphy.com/v1/gifs" + MAX_LIMIT = 50 + RESULTS_TTL = 300 # identical searches within 5 minutes share one Giphy call + RATE_PER_MINUTE = 30 + http_client = staticmethod(http_client) + + def __init__(self, html: ChatHtmlSanitizer, options: GiphyOptions): + self._html = html + self._options = options + self._results = AccountCache(max_items=500) + + def _image(self, images: dict, *names: str) -> dict: + for name in names: + img = images.get(name) or {} + if img.get("url"): + return img + return {} + + def _int(self, value) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + def _shape(self, item: dict) -> dict | None: + images = item.get("images") or {} + full = self._image(images, "downsized_medium", "downsized", "original") + preview = self._image(images, "fixed_width_small", "fixed_width", "preview_gif") or full + url = (full.get("url") or "").split("?")[0] + if not self._html.is_gif_host(url): + return None + return {"id": item.get("id"), "title": item.get("title") or "GIF", "url": url, "previewUrl": (preview.get("url") or url).split("?")[0], "width": self._int(full.get("width")), "height": self._int(full.get("height"))} + + def allow(self, broker) -> bool: + """A fixed one-minute window per account: at most RATE_PER_MINUTE searches.""" + cache = AccountCache.for_broker(broker) + window = ("gif_rate", int(time.time() // 60)) + with cache.lock("gif_rate"): + used = cache.get(window, 0) + if used >= self.RATE_PER_MINUTE: + return False + cache.set(window, used + 1, 120) + return True + + def search(self, query: str | None, limit: int) -> list[dict]: + key = self._options.api_key() + if not key: + raise GifSearchUnavailable("GIF search needs a Giphy API key (GIPHY_API_KEY).") + cache_key = ((query or "").strip().lower() or None, limit) + hit = self._results.get(cache_key) + if hit is not None: + return hit + found = self._search(key, query, limit) + self._results.set(cache_key, found, self.RESULTS_TTL) + return found + + def _search(self, key: str, query: str | None, limit: int) -> list[dict]: + params = {"api_key": key, "limit": max(1, min(limit, self.MAX_LIMIT)), "rating": self._options.rating} + if query: + params["q"] = query + r = self.http_client().request("GET", f"{self.API}/{'search' if query else 'trending'}?{urlencode(params)}", timeout=15) + if r.status_code != 200: + raise RuntimeError(f"Giphy answered {r.status_code}") + return [g for g in (self._shape(i) for i in r.json().get("data") or []) if g] diff --git a/src/infrastructure/graph/channel_messages.py b/src/infrastructure/graph/channel_messages.py new file mode 100644 index 0000000..b097b51 --- /dev/null +++ b/src/infrastructure/graph/channel_messages.py @@ -0,0 +1,50 @@ +"""Teams channel posts and their replies, normalized for the web and the MCP tools (read only).""" + +from html import unescape +from outnote import html_to_text + +from src.application.abstractions.i_channel_messages import IChannelMessages +from src.domain.validation.graph_ids import GraphIds + + +class ChannelMessages(IChannelMessages): + """Teams channel posts and their replies, normalized.""" + + MAX_TEXT = 4000 + PAGE = 50 + + def _text(self, body) -> str: + if not isinstance(body, dict): + return "" + content = body.get("content") or "" + text = unescape(content).strip() if body.get("contentType") == "text" else html_to_text(content) + return text[:self.MAX_TEXT] + + def _sender(self, value): + if not isinstance(value, dict): + return None + who = value.get("user") or value.get("application") or value.get("device") + if not isinstance(who, dict): + return None + return {"displayName": who.get("displayName"), "id": who.get("id")} + + def normalize(self, message: dict) -> dict | None: + """A ChannelMessage, or None for system messages (`messageType` != message) and deleted ones.""" + if not isinstance(message, dict) or message.get("messageType") != "message" or message.get("deletedDateTime"): + return None + attachments = [{"name": a.get("name"), "contentType": a.get("contentType"), "webUrl": a.get("contentUrl")} for a in message.get("attachments") or [] if isinstance(a, dict) and a.get("name")] + return {"id": message.get("id"), "replyToId": message.get("replyToId"), "from": self._sender(message.get("from")), "createdDateTime": message.get("createdDateTime"), "lastModifiedDateTime": message.get("lastModifiedDateTime"), "subject": message.get("subject"), "text": self._text(message.get("body")), "attachments": attachments, "replyCount": None, "webUrl": message.get("webUrl"), "messageType": "message"} + + def _rows(self, note, path: str, top: int) -> list[dict]: + rows = note.list(path, {"$top": min(top, self.PAGE)}, limit=top) + return [m for m in (self.normalize(r) for r in rows) if m] + + def messages(self, note, team_id: str, channel_id: str, top: int) -> list[dict]: + """Top-level posts, newest first (follows nextLink until `top` raw posts were read).""" + out = self._rows(note, f"teams/{GraphIds.seg(team_id)}/channels/{GraphIds.seg(channel_id)}/messages", top) + return sorted(out, key=lambda m: m.get("createdDateTime") or "", reverse=True) + + def replies(self, note, team_id: str, channel_id: str, message_id: str, top: int) -> list[dict]: + """Replies to one post, oldest first.""" + out = self._rows(note, f"teams/{GraphIds.seg(team_id)}/channels/{GraphIds.seg(channel_id)}/messages/{GraphIds.seg(message_id)}/replies", top) + return sorted(out, key=lambda m: m.get("createdDateTime") or "") diff --git a/src/infrastructure/graph/file_thumbnails.py b/src/infrastructure/graph/file_thumbnails.py new file mode 100644 index 0000000..0bee8e3 --- /dev/null +++ b/src/infrastructure/graph/file_thumbnails.py @@ -0,0 +1,145 @@ +"""File kinds and thumbnails for files shared in chats and for OneDrive items. + +A file shared in a chat is a `reference` attachment whose `contentUrl` is a SharePoint / OneDrive link. Graph turns that link into +its drive item (`shares/{u!base64url(url)}/driveItem`), and the item has thumbnails (`thumbnails/0/{small|medium|large}/content`) for +Office documents, PDFs, images and videos. The resolved item ids are cached per account for an hour. + +Only SharePoint / OneDrive hosts are accepted (`*.sharepoint.com` by suffix, `onedrive.live.com` and `1drv.ms` exactly), https only. +""" + +import posixpath +import re +from urllib.parse import quote, urlsplit +from outnote import GraphError + +from src.application.abstractions.i_file_thumbnails import IFileThumbnails +from src.application.services.graph_paths import GraphPaths +from src.infrastructure.microsoft.account_cache import AccountCache + + +class FileThumbnails(IFileThumbnails): + """File kinds, the SharePoint / OneDrive host check, and thumbnails of chat files and drive items.""" + + ITEM_TTL = 3600 + SIZES = ("small", "medium", "large") + EXACT_HOSTS = frozenset({"onedrive.live.com", "1drv.ms"}) + HOST_SUFFIXES = (".sharepoint.com",) + GRAPH_ID = re.compile(r"[A-Za-z0-9!=_\-+.]{1,512}") + MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024 + KINDS_BY_EXTENSION = { + "word": {"doc", "docx", "docm", "dot", "dotx", "odt", "rtf"}, + "excel": {"xls", "xlsx", "xlsm", "xlsb", "xlt", "xltx", "ods", "csv"}, + "powerpoint": {"ppt", "pptx", "pptm", "pps", "ppsx", "odp"}, + "pdf": {"pdf"}, + "onenote": {"one", "onetoc2"}, + "image": {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "heic", "tif", "tiff", "ico", "avif"}, + "video": {"mp4", "mov", "mkv", "webm", "avi", "m4v", "wmv", "mpg", "mpeg"}, + "audio": {"mp3", "wav", "m4a", "ogg", "oga", "flac", "aac", "wma", "opus"}, + "archive": {"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz"}, + "code": {"py", "js", "ts", "tsx", "jsx", "java", "cs", "c", "cpp", "h", "hpp", "go", "rs", "rb", "php", "sh", "ps1", "json", "xml", "yml", "yaml", "html", "htm", "css", "scss", "sql", "kt", "swift", "dart", "lua", "ipynb", "md"}, + "text": {"txt", "log", "ini", "cfg", "conf", "tex"}, + } + EXTENSION_KIND = {ext: kind for kind, exts in KINDS_BY_EXTENSION.items() for ext in exts} + THUMBNAIL_KINDS = frozenset({"word", "excel", "powerpoint", "pdf", "image", "video", "text"}) + + def is_share_url(self, url) -> bool: + """True only for an https SharePoint / OneDrive link (no credentials, default port).""" + if not isinstance(url, str) or not url or len(url) > 4000 or any(ord(c) < 33 for c in url): + return False + try: + parts = urlsplit(url) + port = parts.port + except ValueError: + return False + host = (parts.hostname or "").lower() + return parts.scheme == "https" and port in (None, 443) and not parts.username and not parts.password and (host in self.EXACT_HOSTS or any(host.endswith(s) for s in self.HOST_SUFFIXES)) + + def file_kind(self, name, mime) -> str: + """word | excel | powerpoint | pdf | onenote | image | video | audio | archive | code | text | other, from the extension, then the MIME type.""" + ext = posixpath.splitext(name or "")[1].lstrip(".").lower() if isinstance(name, str) else "" + if ext in self.EXTENSION_KIND: + return self.EXTENSION_KIND[ext] + mime = (mime or "").split(";")[0].strip().lower() if isinstance(mime, str) else "" + major = mime.split("/")[0] + if major in ("image", "video", "audio"): + return major + if mime == "application/pdf": + return "pdf" + if "wordprocessingml" in mime or mime == "application/msword": + return "word" + if "spreadsheetml" in mime or mime == "application/vnd.ms-excel": + return "excel" + if "presentationml" in mime or mime == "application/vnd.ms-powerpoint": + return "powerpoint" + if mime in ("application/zip", "application/x-7z-compressed", "application/vnd.rar", "application/gzip", "application/x-tar"): + return "archive" + if mime in ("application/json", "application/xml", "application/javascript", "text/html", "text/css", "text/x-python"): + return "code" + if major == "text": + return "text" + return "other" + + def has_thumbnail(self, url, kind: str) -> bool: + """Whether the file's host and type are ones that have thumbnails; decided from the data already at hand (no call per file).""" + return kind in self.THUMBNAIL_KINDS and self.is_share_url(url) + + def image_type(self, head: bytes) -> str | None: + """JPEG, PNG or WEBP from the magic bytes; anything else is None.""" + if head.startswith(b"\xff\xd8\xff"): + return "image/jpeg" + if head.startswith(b"\x89PNG\r\n\x1a\n"): + return "image/png" + if head[:4] == b"RIFF" and head[8:12] == b"WEBP": + return "image/webp" + return None + + def safe_graph_id(self, value) -> bool: + return isinstance(value, str) and bool(self.GRAPH_ID.fullmatch(value)) and ".." not in value and value != "." + + def _resolve(self, note, broker, url: str) -> tuple[str, str] | None: + """(drive id, item id) of the file behind a share URL, cached for an hour; None when it is gone, a folder or unreadable.""" + cache = AccountCache.for_broker(broker) + key = ("share_item", url) + marker = object() + hit = cache.get(key, marker) + if hit is not marker: + return hit + try: + item = note.get(GraphPaths.drive_item_path(url, "id,parentReference,file,size,webUrl")) + except GraphError as ex: + if ex.status in (400, 403, 404): + cache.set(key, None, self.ITEM_TTL) + return None + raise + drive = ((item or {}).get("parentReference") or {}).get("driveId") + item_id = (item or {}).get("id") + found = (drive, item_id) if (item or {}).get("file") is not None and self.safe_graph_id(drive) and self.safe_graph_id(item_id) else None + cache.set(key, found, self.ITEM_TTL) + return found + + def _content(self, note, path: str) -> bytes | None: + try: + data = note.get(path, raw="bytes") + except GraphError as ex: + if ex.status in (400, 404, 415): + return None + raise + if not data or len(data) > self.MAX_THUMBNAIL_BYTES or self.image_type(data[:12]) is None: + return None + return data + + def chat_file_thumbnail(self, note, broker, url: str, size: str) -> bytes | None: + """The thumbnail bytes of a chat-shared file, or None when the URL isn't a share link or the file has no thumbnail.""" + if size not in self.SIZES or not self.is_share_url(url): + return None + ids = self._resolve(note, broker, url) + if not ids: + return None + return self._content(note, f"drives/{quote(ids[0], safe='')}/items/{quote(ids[1], safe='')}/thumbnails/0/{size}/content") + + def drive_item_thumbnail(self, note, item_id: str, size: str, drive_id: str | None = None) -> bytes | None: + """The thumbnail of an item in the user's OneDrive, or of `drive_id`'s item (a team library, a shared drive).""" + if size not in self.SIZES or not self.safe_graph_id(item_id) or (drive_id is not None and not self.safe_graph_id(drive_id)): + return None + base = f"drives/{quote(drive_id, safe='')}/items" if drive_id else "me/drive/items" + return self._content(note, f"{base}/{quote(item_id, safe='')}/thumbnails/0/{size}/content") diff --git a/src/infrastructure/graph/graph_search.py b/src/infrastructure/graph/graph_search.py new file mode 100644 index 0000000..06ec404 --- /dev/null +++ b/src/infrastructure/graph/graph_search.py @@ -0,0 +1,359 @@ +"""Search over files, mail, chats and channel posts with the Microsoft Search API, with the place each hit lives in. + +Files (`driveItem`) and messages (`message` + `chatMessage`) can't share one Search API request ("Invalid entity type combination"), +so they run as two requests in parallel; a source that fails is reported in `errors` and never fails the search. When the Search API +refuses the file search (400 / 403 / 5xx), the user's drive and each joined team's library are searched one by one instead. The query +text is never logged; it only travels in the request body to Microsoft (and, for the drive fallback, in a Graph URL). +""" + +import logging +import re +from concurrent.futures import ThreadPoolExecutor +from html import unescape +from urllib.parse import quote, unquote +from outnote import GraphError + +from src.application.abstractions.i_graph_search import IGraphSearch +from src.application.abstractions.i_teams_files import ITeamsFiles +from src.infrastructure.microsoft.account_cache import AccountCache + + +class Context: + """Lazily loaded lookups shared by the hits of one search; each failure only costs the detail it would have given.""" + + def __init__(self, note, broker, teams_files: ITeamsFiles): + self.note, self.cache = note, AccountCache.for_broker(broker) + self.broker = broker + self._teams_files = teams_files + self._own = self._drives = self._shared = None + self._teams = None + self._channels: dict[str, dict[str, str]] = {} + + def teams(self) -> dict[str, str]: + if self._teams is None: + try: + self._teams = {t["id"]: t.get("displayName") or "" for t in self._teams_files.joined_teams(self.note, self.broker)} + except Exception as ex: + GraphSearch.log.info("search: joined teams failed: %s", type(ex).__name__) + self._teams = {} + return self._teams + + def own_drive(self): + if self._own is None: + try: + self._own = self._teams_files.own_drive_id(self.note, self.broker) or "" + except Exception as ex: + GraphSearch.log.info("search: own drive failed: %s", type(ex).__name__) + self._own = "" + return self._own + + def team_drives(self) -> dict[str, str]: + """Library drive id -> team id for the joined teams (cached 1 h).""" + if self._drives is None: + hit = self.cache.get(("search", "team_drives")) + if hit is None: + hit = {} + complete = True + + def one(team_id): + try: + return team_id, self._teams_files.team_drive_id(self.note, self.broker, team_id) + except Exception: + return team_id, None + + with ThreadPoolExecutor(max_workers=GraphSearch.FALLBACK_WORKERS) as pool: + for team_id, drive_id in pool.map(one, list(self.teams())): + if drive_id: + hit[drive_id] = team_id + else: + complete = False + self.cache.set(("search", "team_drives"), hit, GraphSearch.MAP_TTL if complete else 300) + self._drives = hit + return self._drives + + def channel_names(self, team_id: str) -> dict[str, str]: + """Channel id -> name (cached through `ITeamsFiles.channels`).""" + if team_id not in self._channels: + try: + self._channels[team_id] = {c["id"]: c.get("displayName") or "" for c in self._teams_files.channels(self.note, self.broker, team_id)} + except Exception as ex: + GraphSearch.log.info("search: channels failed: %s", type(ex).__name__) + self._channels[team_id] = {} + return self._channels[team_id] + + def shared_ids(self) -> set[str]: + if self._shared is None: + hit = self.cache.get(("search", "shared_ids")) + if hit is None: + try: + hit = {i["id"] for i in self._teams_files.shared_with_me(self.note, 200)} + except Exception as ex: + GraphSearch.log.info("search: sharedWithMe failed: %s", type(ex).__name__) + hit = set() + self.cache.set(("search", "shared_ids"), hit, GraphSearch.SHARED_TTL) + self._shared = hit + return self._shared + + +class GraphSearch(IGraphSearch): + """The Search API over files, mail, chats and channel posts, with hit locations and a per-drive fallback. Never logs the query.""" + + log = logging.getLogger("schuly.microsoft") + SNIPPET_MAX = 300 + MAP_TTL = 3600 + SHARED_TTL = 600 + FALLBACK_WORKERS = 4 + FALLBACK_PER_DRIVE = 50 + TEAM_SEARCH_CAP = 200 + TAGS = re.compile(r"<[^>]*>") + TYPES = ("files", "mail", "chats", "channels") + + def __init__(self, teams_files: ITeamsFiles): + self._teams_files = teams_files + + def snippet(self, summary) -> str: + """The Search API's summary as plain text: `` becomes an ellipsis, highlight and other tags go, entities are decoded.""" + if not isinstance(summary, str): + return "" + text = summary.replace("", "…") + text = unescape(self.TAGS.sub("", text)) + text = " ".join(text.split()) + return text if len(text) <= self.SNIPPET_MAX else text[: self.SNIPPET_MAX - 1].rstrip() + "…" + + def reason(self, ex: Exception) -> str: + if isinstance(ex, GraphError): + if ex.status in (401, 403): + return f"not permitted ({ex.status})" + if ex.status == 429: + return "throttled (429)" + if ex.status >= 500: + return f"service error ({ex.status})" + return f"request refused ({ex.status})" + return "request failed" + + def _sender(self, person): + """`{displayName, email}` out of a Graph emailAddress wrapper, an identitySet or a bare user.""" + if not isinstance(person, dict): + return None + inner = person.get("emailAddress") or person.get("user") or person + name, mail = inner.get("name") or inner.get("displayName"), inner.get("address") or inner.get("email") + return {"displayName": name, "email": mail} if name or mail else None + + def _identity(self, value): + return self._sender(value) if isinstance(value, dict) else None + + def _query(self, note, entities: list[str], q: str, start: int, size: int): + """One Search API request: (hits, total, more).""" + body = {"requests": [{"entityTypes": entities, "query": {"queryString": q}, "from": start, "size": size}]} + data = note.post("search/query", body) or {} + hits, total, more = [], 0, False + for container in ((data.get("value") or [{}])[0].get("hitsContainers") or []): + hits.extend(container.get("hits") or []) + total += int(container.get("total") or 0) + more = more or bool(container.get("moreResultsAvailable")) + return hits, total, more + + def file_hit(self, hit: dict) -> dict | None: + r = hit.get("resource") if isinstance(hit.get("resource"), dict) else {} + ref = r.get("parentReference") or {} + item_id = r.get("id") or hit.get("hitId") + if not item_id: + return None + out = {"type": "file", "id": item_id, "driveId": ref.get("driveId"), "title": r.get("name"), "snippet": self.snippet(hit.get("summary")), "date": r.get("lastModifiedDateTime"), "webUrl": r.get("webUrl"), "size": r.get("size"), "isFolder": r.get("folder") is not None, "mimeType": (r.get("file") or {}).get("mimeType"), "from": self._identity(r.get("lastModifiedBy") or r.get("createdBy"))} + path = ref.get("path") + if isinstance(path, str) and "root:" in path: + out["_path"] = self._clean_path(path) + return out + + def _clean_path(self, raw: str) -> str: + return unquote(raw.split("root:", 1)[1]).strip("/") + + def message_hit(self, hit: dict) -> dict | None: + r = hit.get("resource") if isinstance(hit.get("resource"), dict) else {} + kind = str(r.get("@odata.type") or "") + mid = r.get("id") or hit.get("hitId") + if not mid: + return None + if kind.endswith("chatMessage") or "channelIdentity" in r or "chatId" in r: + sender = self._sender(r.get("from")) + channel = r.get("channelIdentity") if isinstance(r.get("channelIdentity"), dict) else None + return {"type": "channel" if channel else "chat", "id": mid, "title": r.get("subject") or (sender or {}).get("displayName") or "Chat", "snippet": self.snippet(hit.get("summary")), "date": r.get("createdDateTime"), "webUrl": r.get("webLink") or r.get("webUrl"), "from": sender, "_channel": channel, "_chatId": r.get("chatId")} + return {"type": "mail", "id": mid, "title": r.get("subject"), "snippet": self.snippet(hit.get("summary") or r.get("bodyPreview")), "date": r.get("receivedDateTime") or r.get("sentDateTime"), "webUrl": r.get("webLink"), "from": self._sender(r.get("from"))} + + def _paths(self, note, hits: list[dict]): + """Fills `_path` of file hits that have none through `$batch` (parentReference.path is not in Search API results).""" + missing = [h for h in hits if h.get("driveId") and "_path" not in h] + if not missing: + return + try: + rows = note.batch([f"drives/{self._teams_files.seg(h['driveId'])}/items/{self._teams_files.seg(h['id'])}?$select=id,parentReference" for h in missing]) + except Exception as ex: + self.log.info("search: path lookup failed: %s", type(ex).__name__) + return + for h, row in zip(missing, rows): + path = ((row or {}).get("parentReference") or {}).get("path") if isinstance(row, dict) else None + if isinstance(path, str) and "root:" in path: + h["_path"] = self._clean_path(path) + elif isinstance(path, str) and path.endswith("/root"): + h["_path"] = "" + + def _join(self, *parts) -> str: + return " > ".join(p for p in parts if p) + + def locate(self, hits: list[dict], ctx: Context): + """Adds `location` to every hit and drops the helper keys.""" + files = [h for h in hits if h["type"] == "file"] + if files: + self._paths(ctx.note, files) + own = ctx.own_drive() if files else "" + drives = ctx.team_drives() if any(h.get("driveId") and h["driveId"] != own for h in files) else {} + for h in hits: + if h["type"] == "file": + path = h.pop("_path", None) + drive = h.get("driveId") + loc = {"path": path} + if drive and drive == own: + loc["kind"] = "onedrive" + loc["label"] = self._join("OneDrive", path) + elif drive in drives: + team_id = drives[drive] + name = ctx.teams().get(team_id) or "" + loc.update(kind="team", teamId=team_id, teamName=name) + top = (path or "").split("/", 1)[0] + if top: + for cid, cname in ctx.channel_names(team_id).items(): + if cname == top: + loc.update(channelId=cid, channelName=cname) + break + loc["label"] = self._join(f"Team {name}" if name else "Team", path) + elif h["id"] in ctx.shared_ids(): + loc["kind"] = "shared" + loc["label"] = self._join("Shared with me", path) + else: + loc["kind"] = "sharepoint" + loc["label"] = self._join("SharePoint", path) + h["location"] = loc + elif h["type"] == "mail": + h["location"] = {"kind": "mailbox", "label": "Mailbox"} + elif h["type"] == "channel": + channel = h.pop("_channel", None) or {} + h.pop("_chatId", None) + team_id, channel_id = channel.get("teamId"), channel.get("channelId") + team_name = ctx.teams().get(team_id) if team_id else None + channel_name = ctx.channel_names(team_id).get(channel_id) if team_id and channel_id else None + h["location"] = {"kind": "channel", "teamId": team_id, "teamName": team_name, "channelId": channel_id, "channelName": channel_name, "label": self._join(f"Team {team_name}" if team_name else "Team", channel_name)} + else: + chat_id = h.pop("_chatId", None) + h.pop("_channel", None) + h["location"] = {"kind": "chat", "chatId": chat_id, "label": "Chat"} + + def drive_search(self, note, drive_id: str | None, q: str, top: int) -> list[dict]: + """Hits of `drives/{d}/root/search(q=)` (or the user's own drive when `drive_id` is None) shaped like Search API hits.""" + literal = quote(q.replace("'", "''"), safe="") + base = f"drives/{self._teams_files.seg(drive_id)}" if drive_id else "me/drive" + rows = note.list(f"{base}/root/search(q='{literal}')", {"$top": min(top, 200), "$select": self._teams_files.ITEM_FIELDS}, limit=top) + return [{"resource": self._teams_files._with_drive(r, drive_id)} for r in rows] + + def fallback_files(self, note, broker, q: str, ctx: Context): + """Own drive + each joined team's library, searched in parallel; returns (hits, errors).""" + targets: list[str | None] = [None] + errors = [] + try: + targets += list(ctx.team_drives()) + except Exception as ex: + errors.append(f"teams: {self.reason(ex)}") + + def one(drive_id): + try: + return self.drive_search(note, drive_id, q, self.FALLBACK_PER_DRIVE), None + except Exception as ex: + if "invalid_grant" in str(ex): + raise + return [], self.reason(ex) + + hits, failed = [], 0 + with ThreadPoolExecutor(max_workers=self.FALLBACK_WORKERS) as pool: + for found, err in pool.map(one, targets): + hits.extend(found) + failed += 1 if err else 0 + if failed: + errors.append(f"files: {failed} of {len(targets)} libraries could not be searched") + return hits, errors + + def _by_date(self, hits: list[dict]) -> list[dict]: + return sorted(hits, key=lambda h: (h.get("resource") or {}).get("lastModifiedDateTime") or "", reverse=True) + + def run(self, note, broker, q: str, types: list[str], start: int, size: int, team_id: str | None = None) -> dict: + """The SearchResult for `q` over the wanted `types` (files, mail, chats, channels), optionally limited to one team.""" + wanted = [t for t in self.TYPES if t in types] + if team_id: + wanted = [t for t in wanted if t in ("files", "channels")] + ctx = Context(note, broker, self._teams_files) + errors: list[str] = [] + out: list[dict] = [] + totals = {"files": 0, "messages": 0} + more = False + fallback = False + + def files_source(): + if team_id: + drive_id = self._teams_files.team_drive_id(note, broker, team_id) + rows = self.drive_search(note, drive_id, q, min(start + size + 1, self.TEAM_SEARCH_CAP)) + page = rows[start : start + size] + return page, len(rows), len(rows) > start + size, False + try: + hits, total, has_more = self._query(note, ["driveItem"], q, start, size) + return hits, total, has_more, False + except GraphError as ex: + if ex.status in (400, 403) or ex.status >= 500: + found, errs = self.fallback_files(note, broker, q, ctx) + errors.extend(errs) + found = self._by_date(found) + return found[start : start + size], len(found), len(found) > start + size, True + raise + + entities = [] + if "mail" in wanted: + entities.append("message") + if "chats" in wanted or "channels" in wanted: + entities.append("chatMessage") + + def messages_source(): + hits, total, has_more = self._query(note, entities, q, start, size) + return hits, total, has_more + + jobs = {} + with ThreadPoolExecutor(max_workers=2) as pool: + if "files" in wanted: + jobs["files"] = pool.submit(files_source) + if entities: + jobs["messages"] = pool.submit(messages_source) + results = {} + for name, future in jobs.items(): + try: + results[name] = future.result() + except Exception as ex: + if "invalid_grant" in str(ex): + raise + label = name if name == "files" else ("mail" if entities == ["message"] else "messages") + errors.append(f"{label}: {self.reason(ex)}") + if "files" in results: + hits, totals["files"], has_more, fallback = results["files"] + more = more or has_more + out.extend(h for h in (self.file_hit(x) for x in hits) if h) + if "messages" in results: + hits, totals["messages"], has_more = results["messages"] + more = more or has_more + for h in (self.message_hit(x) for x in hits): + if h is None: + continue + if h["type"] == "mail" and "mail" not in wanted: + continue + if h["type"] == "chat" and "chats" not in wanted: + continue + if h["type"] == "channel" and ("channels" not in wanted or (team_id and (h.get("_channel") or {}).get("teamId") != team_id)): + continue + out.append(h) + self.locate(out, ctx) + out.sort(key=lambda h: h.get("date") or "", reverse=True) + return {"query": q, "hits": out, "from": start, "size": size, "moreAvailable": more, "nextFrom": start + size if more else None, "totals": totals, "fallback": fallback, "errors": errors} diff --git a/src/infrastructure/graph/mail_folder_roles.py b/src/infrastructure/graph/mail_folder_roles.py new file mode 100644 index 0000000..ebe718f --- /dev/null +++ b/src/infrastructure/graph/mail_folder_roles.py @@ -0,0 +1,36 @@ +"""Well-known mail folder roles. + +Graph lists mail folders by display name, which is in the mailbox language ("Posteingang", +"Gesendete Elemente", ...). Each well-known folder is also addressable by its fixed name +(`me/mailFolders/inbox`), so one `$batch` of those lookups maps folder ids to roles. The map is +cached per account for an hour; a name the mailbox lacks (404) is simply absent. +""" + + +from src.application.abstractions.i_mail_folder_roles import IMailFolderRoles +from src.infrastructure.microsoft.account_cache import AccountCache + + +class MailFolderRoles(IMailFolderRoles): + """Well-known mail folder names, looked up with one $batch and cached per account.""" + + WELL_KNOWN_NAMES = ("inbox", "drafts", "sentitems", "deleteditems", "junkemail", "archive", "outbox", "conversationhistory", "syncissues", "conflicts", "localfailures", "serverfailures", "rssfeeds") + ROLES_TTL = 3600 + CACHE_KEY = "mail_folder_roles" + + def lookup(self, note) -> dict[str, str]: + """folder id -> well-known name, from one Graph $batch (13 requests, under the 20 per batch limit).""" + results = note.batch([f"me/mailFolders/{name}?$select=id" for name in self.WELL_KNOWN_NAMES]) + roles = {} + for name, result in zip(self.WELL_KNOWN_NAMES, results): + if isinstance(result, dict) and isinstance(result.get("id"), str): + roles.setdefault(result["id"], name) + return roles + + def cached(self, broker) -> dict[str, str] | None: + return AccountCache.for_broker(broker).get(self.CACHE_KEY) + + def remember(self, broker, roles: dict[str, str]): + # An empty map means the lookup failed (every mailbox has an inbox); try again on the next list. + if roles: + AccountCache.for_broker(broker).set(self.CACHE_KEY, roles, self.ROLES_TTL) diff --git a/src/infrastructure/graph/onenote_client.py b/src/infrastructure/graph/onenote_client.py new file mode 100644 index 0000000..64ca15a --- /dev/null +++ b/src/infrastructure/graph/onenote_client.py @@ -0,0 +1,437 @@ +"""OneNote over Graph: notebooks (the person's own and each joined team's), the section tree, pages, +page content, resources, title search and page writes. + +Ids. A team's notebooks (a class notebook is one) live under `groups/{id}/onenote`, the person's own +under `me/onenote`. Every id handed out says which: the bare Graph id for the person's own, and +`@` for a team's. Ids go into Graph paths, so each is checked before use. + +Lists the screen asks for again and again (notebooks, a root's sections and section groups, a +section's page titles for search) are kept on the account for a short while: OneNote throttles a +person who reads their whole notebook list several times a minute, and a class notebook is slow +enough already. Any write drops them. +""" + +import html +import logging +import re +import time +from concurrent.futures import ThreadPoolExecutor +import httpx +from outnote import C_ONENOTE, GraphError + +from src.application.abstractions.i_one_note import IOneNote +from src.application.abstractions.i_microsoft_http_clients import IMicrosoftHttpClients +from src.application.services.onenote_html_sanitizer import OneNoteHtmlSanitizer +from src.domain.errors.one_note_error import OneNoteError +from src.domain.errors.resource_too_large import ResourceTooLarge +from src.infrastructure.microsoft.account_cache import AccountCache + + +class OneNoteClient(IOneNote): + """OneNote over Graph: notebooks (own and teams), section tree, pages, search, writes and resource stream.""" + + C_ONENOTE = C_ONENOTE + log = logging.getLogger("schuly.microsoft") + ID_PATTERN = re.compile(r"[A-Za-z0-9!@_{}:.\-]{1,300}") + GRAPH_ID = re.compile(r"[A-Za-z0-9!_{}:.\-]{1,200}") + GROUP_ID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + GRAPH = "https://graph.microsoft.com/v1.0" + KEEP = 90.0 + TEAMS_KEEP = 600.0 + MAX_SECTIONS = 2000 + MAX_GROUP_DEPTH = 6 + MAX_TITLE = 255 + SEARCH_SECTIONS = 40 + TEAM_WORKERS = 3 + RESOURCE_RETRIES = 2 + NOTEBOOK_FIELDS = "id,displayName,createdDateTime,lastModifiedDateTime,isDefault,isShared,userRole,links" + TREE_FIELDS = "id,displayName,lastModifiedDateTime" + PAGE_FIELDS = "id,title,createdDateTime,lastModifiedDateTime,level,order,links" + PARENTS = "parentNotebook($select=id),parentSectionGroup($select=id)" + HIT_EXPAND = "parentSection($select=id,displayName),parentNotebook($select=id,displayName)" + + def __init__(self, html: OneNoteHtmlSanitizer, http: IMicrosoftHttpClients): + self._html = html + self._http = http + + def parse_id(self, value: str) -> tuple[str | None, str]: + """(group id or None, Graph id) of an id handed out; ValueError when it is not one.""" + if not isinstance(value, str) or not self.ID_PATTERN.fullmatch(value) or ".." in value or value.count("@") > 1: + raise ValueError("invalid id") + group, sep, graph_id = value.rpartition("@") + if sep and not self.GROUP_ID.fullmatch(group): + raise ValueError("invalid id") + if not self.GRAPH_ID.fullmatch(graph_id) or graph_id in (".", ".."): + raise ValueError("invalid id") + return (group.lower() if sep else None), graph_id + + def is_id(self, value: str) -> bool: + try: + self.parse_id(value) + return True + except ValueError: + return False + + def root(self, group: str | None) -> str: + return f"groups/{group}/onenote" if group else "me/onenote" + + def tag(self, group: str | None, graph_id: str | None) -> str | None: + return self._html.scoped(group, graph_id) if graph_id else None + + def web_url(self, links) -> str | None: + href = ((links or {}).get("oneNoteWebUrl") or {}).get("href") + return href if isinstance(href, str) and href.startswith("https://") and len(href) < 4000 else None + + def _kept(self, broker, key, make, ttl: float = KEEP): + cache = AccountCache.for_broker(broker) + hit = cache.get(key) + if hit is not None: + return hit + with cache.lock(repr(key)): + hit = cache.get(key) + if hit is not None: + return hit + value = make() + cache.set(key, value, ttl) + return value + + def changed(self, broker): + """What was read before a write is not what is there after it.""" + AccountCache.for_broker(broker).discard(lambda key: isinstance(key, tuple) and key[:1] == ("onenote",) and key[1:2] != ("teams",)) + + def _ref(self, group, obj) -> dict | None: + if not isinstance(obj, dict) or not obj.get("id"): + return None + return {"id": self.tag(group, obj["id"]), "displayName": obj.get("displayName") or ""} + + def _teams(self, note, broker) -> list[dict]: + def make(): + try: + rows = note.list("me/joinedTeams", {"$select": "id,displayName"}, limit=200) + except GraphError: + return [] + return [{"id": t["id"].lower(), "name": t.get("displayName")} for t in rows if isinstance(t.get("id"), str) and self.GROUP_ID.fullmatch(t["id"])] + + return self._kept(broker, ("onenote", "teams"), make, self.TEAMS_KEEP) + + def _notebook(self, group, team_name, n) -> dict: + role = n.get("userRole") + return { + "id": self.tag(group, n["id"]), + "displayName": n.get("displayName") or "", + "createdDateTime": n.get("createdDateTime"), + "lastModifiedDateTime": n.get("lastModifiedDateTime"), + "isDefault": n.get("isDefault") is True, + "isShared": n.get("isShared") is True, + "userRole": role if role in ("Owner", "Contributor", "Reader") else None, + "writable": role != "Reader", + "webUrl": self.web_url(n.get("links")), + "source": {"kind": "team" if group else "personal", "id": group, "name": team_name}, + } + + def _team_pages(self, note, joined: list[dict]) -> list[list[dict] | None]: + """Each team's notebooks through `$batch`: `[]` for a team Graph refused, None for one to ask on its own (the batch failed, or the answer has more pages).""" + if not joined: + return [] + try: + answers = note.batch([f"{self.root(t['id'])}/notebooks?$select={self.NOTEBOOK_FIELDS}" for t in joined], client=C_ONENOTE) + except (GraphError, OSError) as ex: + if getattr(ex, "status", None) == 429: + raise + self.log.info("onenote: notebook batch failed: %s", type(ex).__name__) + return [None] * len(joined) + return [[] if isinstance(a, GraphError) else a.get("value", []) if isinstance(a, dict) and "@odata.nextLink" not in a else None for a in answers] + + def notebooks(self, note, broker, teams: bool = True) -> list[dict]: + def make(): + own = note.list(f"{self.root(None)}/notebooks", {"$select": self.NOTEBOOK_FIELDS}, limit=500, client=C_ONENOTE) + out = [self._notebook(None, None, n) for n in own if self.GRAPH_ID.fullmatch(str(n.get("id", "")))] + out.sort(key=lambda n: n["displayName"].lower()) + if not teams: + return out + joined = self._teams(note, broker) + + def team_books(team, rows): + try: + if rows is None: + rows = note.list(f"{self.root(team['id'])}/notebooks", {"$select": self.NOTEBOOK_FIELDS}, limit=200, client=C_ONENOTE) + except GraphError: + # A team whose notebooks cannot be listed is somewhere the person has no notebook to open. + return [] + return [self._notebook(team["id"], team["name"], n) for n in rows if self.GRAPH_ID.fullmatch(str(n.get("id", "")))] + + pages = self._team_pages(note, joined) + # Only what the batch left open goes one by one, a few at a time: OneNote answers a burst of requests from one person with a wait. + with ThreadPoolExecutor(max_workers=self.TEAM_WORKERS) as pool: + found = list(pool.map(team_books, joined, pages)) + for team, rows in sorted(zip(joined, found), key=lambda pair: (pair[0]["name"] or "").lower()): + out.extend(sorted(rows, key=lambda n: n["displayName"].lower())) + return out + + return self._kept(broker, ("onenote", "notebooks", teams), make) + + def _flat(self, note, broker, group, what: str) -> list[dict]: + """Every section or section group of a root, with its parents, kept a short while.""" + return self._kept(broker, ("onenote", what, group), lambda: note.list(f"{self.root(group)}/{what}", {"$select": self.TREE_FIELDS + (",isDefault" if what == "sections" else ""), "$expand": self.PARENTS, "$top": 100}, limit=self.MAX_SECTIONS, client=C_ONENOTE)) + + def _walk_groups(self, note, group, notebook_id) -> list[dict]: + """A notebook's section groups level by level, for when the root's collection of all of them fails (Graph answers it with a transient 500 for some personal accounts).""" + out, level = [], [(f"{self.root(group)}/notebooks/{notebook_id}/sectionGroups", None)] + for _ in range(self.MAX_GROUP_DEPTH): + nxt = [] + for path, parent in level: + for g in note.list(path, {"$select": self.TREE_FIELDS}, limit=500, client=C_ONENOTE): + if not self.GRAPH_ID.fullmatch(str(g.get("id", ""))): + continue + out.append({**g, "parentNotebook": {"id": notebook_id}, "parentSectionGroup": {"id": parent} if parent else None}) + nxt.append((f"{self.root(group)}/sectionGroups/{g['id']}/sectionGroups", g["id"])) + level = nxt + if not level: + break + return out + + def tree(self, note, broker, notebook: str) -> dict: + group, notebook_id = self.parse_id(notebook) + sections = self._flat(note, broker, group, "sections") + if group is None: + # The person's own collection of all section groups also holds what others shared, and Graph often answers it with a + # transient 500 after a long wait: one notebook's groups are walked instead (most have none, which is one request). + groups = self._kept(broker, ("onenote", "walked", group, notebook_id), lambda: self._walk_groups(note, group, notebook_id)) + else: + # A team's collection holds its own notebooks only, and walking a class notebook's dozens of groups is dozens of requests. + groups = self._flat(note, broker, group, "sectionGroups") + mine = lambda row: ((row.get("parentNotebook") or {}).get("id")) == notebook_id # noqa: E731 + sections = [s for s in sections if mine(s)] + groups = [g for g in groups if mine(g)] + nodes = {} + for g in groups: + if self.GRAPH_ID.fullmatch(str(g.get("id", ""))): + nodes[g["id"]] = {"id": self.tag(group, g["id"]), "kind": "group", "displayName": g.get("displayName") or "", "lastModifiedDateTime": g.get("lastModifiedDateTime"), "isDefault": False, "children": [], "_parent": (g.get("parentSectionGroup") or {}).get("id")} + top: list[dict] = [] + for s in sections: + if not self.GRAPH_ID.fullmatch(str(s.get("id", ""))): + continue + node = {"id": self.tag(group, s["id"]), "kind": "section", "displayName": s.get("displayName") or "", "lastModifiedDateTime": s.get("lastModifiedDateTime"), "isDefault": s.get("isDefault") is True, "children": []} + parent = nodes.get((s.get("parentSectionGroup") or {}).get("id")) + (parent["children"] if parent else top).append(node) + top_groups = [] + for g in nodes.values(): + parent = nodes.get(g["_parent"]) if g["_parent"] != g["id"] else None + (parent["children"] if parent else top_groups).append(g) + + def finish(node, depth=0): + node.pop("_parent", None) + if depth >= self.MAX_GROUP_DEPTH: + node["children"] = [] + # OneNote lists a group's sections before its groups. + node["children"].sort(key=lambda c: c["kind"] == "group") + for c in node["children"]: + finish(c, depth + 1) + return node + + return {"notebookId": notebook, "children": top + [finish(g) for g in top_groups]} + + def _summary(self, group, section, p) -> dict: + level = p.get("level") + order = p.get("order") + return { + "id": self.tag(group, p["id"]), + "title": p.get("title") or "", + "level": level if isinstance(level, int) and 0 <= level <= 10 else 0, + "order": order if isinstance(order, int) else None, + "createdDateTime": p.get("createdDateTime"), + "lastModifiedDateTime": p.get("lastModifiedDateTime"), + "webUrl": self.web_url(p.get("links")), + "sectionId": section, + } + + def pages(self, note, section: str, top: int, skip: int) -> dict: + group, section_id = self.parse_id(section) + answer = note.get(f"{self.root(group)}/sections/{section_id}/pages", {"$select": self.PAGE_FIELDS, "pagelevel": "true", "$top": top, "$skip": skip or None}, client=C_ONENOTE) or {} + rows = [p for p in answer.get("value") or [] if self.GRAPH_ID.fullmatch(str(p.get("id", "")))] + items = [self._summary(group, section, p) for p in rows] + return {"items": items, "nextSkip": skip + len(answer.get("value") or []) if answer.get("@odata.nextLink") else None} + + def _meta(self, note, group, page_id) -> dict: + return note.get(f"{self.root(group)}/pages/{page_id}", {"$select": "id,title,createdDateTime,lastModifiedDateTime,links", "$expand": "parentSection($select=id,displayName),parentNotebook($select=id,displayName,userRole)"}, client=C_ONENOTE) or {} + + def _content(self, note, group, page_id, ink: bool = True) -> tuple[str, str | None]: + path = f"{self.root(group)}/pages/{page_id}/content" + page = note.get(path, {"includeIDs": "true"}, raw=True, client=C_ONENOTE) or "" + if not ink or not self._html.has_ink(page): + return page, None + # The strokes are most of a drawn page's weight, so they are asked for only when the page has some. + return self._html.split_parts(note.get(path, {"includeIDs": "true", "includeInkML": "true"}, raw=True, client=C_ONENOTE) or "") + + def page(self, note, page_scoped: str) -> dict: + group, page_id = self.parse_id(page_scoped) + meta = self._meta(note, group, page_id) + content, ink = self._content(note, group, page_id) + rendered = self._html.render_page(content, ink, group) + role = (meta.get("parentNotebook") or {}).get("userRole") + return { + "id": page_scoped, + "title": meta.get("title") or rendered["title"], + "createdDateTime": meta.get("createdDateTime"), + "lastModifiedDateTime": meta.get("lastModifiedDateTime"), + "webUrl": self.web_url(meta.get("links")), + "section": self._ref(group, meta.get("parentSection")), + "notebook": self._ref(group, meta.get("parentNotebook")), + "writable": role != "Reader", + "html": rendered["html"], + "width": rendered["width"], + "height": rendered["height"], + "resources": rendered["resources"], + "outlines": rendered["outlines"], + "hasInk": rendered["hasInk"], + } + + def check_title(self, title: str) -> str: + title = re.sub(r"\s+", " ", title or "").strip() + if not title or len(title) > self.MAX_TITLE: + raise OneNoteError(400, f"A page title is 1-{self.MAX_TITLE} characters.") + return title + + def create_page(self, note, broker, section: str, title: str, body: str | None) -> dict: + group, section_id = self.parse_id(section) + made = note.post(f"{self.root(group)}/sections/{section_id}/pages", self._html.new_page(self.check_title(title), body), client=C_ONENOTE, content_type="application/xhtml+xml") or {} + self.changed(broker) + if not self.GRAPH_ID.fullmatch(str(made.get("id", ""))): + raise OneNoteError(502, "OneNote did not return the new page.") + return self._summary(group, section, made) + + def update_page(self, note, broker, page_scoped: str, title: str | None, outlines: list[dict] | None, append: str | None) -> dict: + group, page_id = self.parse_id(page_scoped) + meta = self._meta(note, group, page_id) + if (meta.get("parentNotebook") or {}).get("userRole") == "Reader": + raise OneNoteError(403, "This notebook is read only.") + commands = [] + if title is not None: + commands.append({"target": "title", "action": "replace", "content": html.escape(self.check_title(title), quote=False)}) + if outlines: + current, _ = self._content(note, group, page_id, ink=False) + for item in outlines: + style = self._html.outline_style(current, item["id"]) + if style is None: + raise OneNoteError(400, "That text box is not on the page or holds more than text, so it is not changed.") + inner = self._html.sanitize_for_write(item["html"]) + commands.append({"target": item["id"], "action": "replace", "content": f'
{inner}
' if style else f"
{inner}
"}) + if append and append.strip(): + inner = self._html.sanitize_for_write(append) + if inner.strip(): + commands.append({"target": "body", "action": "append", "content": f"
{inner}
"}) + if commands: + note.patch(f"{self.root(group)}/pages/{page_id}/content", commands, client=C_ONENOTE) + self.changed(broker) + return self.page(note, page_scoped) + + def delete_page(self, note, broker, page_scoped: str): + group, page_id = self.parse_id(page_scoped) + note.delete(f"{self.root(group)}/pages/{page_id}", client=C_ONENOTE) + self.changed(broker) + + def resource(self, broker, resource_scoped: str, limit: int) -> bytes: + """A picture's or a file's bytes, streamed from Graph's own host with the OneNote token and cut off past `limit`.""" + group, resource_id = self.parse_id(resource_scoped) + url = f"{self.GRAPH}/{self.root(group)}/resources/{resource_id}/$value" + for attempt in range(self.RESOURCE_RETRIES + 1): + headers = {"Authorization": f"Bearer {broker(C_ONENOTE)}"} + # No redirects: the bearer token must never follow a response to another host. + with self._http.download_client().stream("GET", url, headers=headers, timeout=httpx.Timeout(60.0, connect=5.0), follow_redirects=False) as r: + if r.status_code in (429, 503) and attempt < self.RESOURCE_RETRIES: + wait = r.headers.get("Retry-After", "") + time.sleep(min(6.0, float(wait)) if wait.isdigit() else 1.0 + attempt) + continue + if r.status_code >= 300: + raise GraphError(r.status_code if r.status_code >= 400 else 502, "resource") + size = r.headers.get("Content-Length", "") + if size.isdigit() and int(size) > limit: + raise ResourceTooLarge() + data = bytearray() + for chunk in r.iter_bytes(64 * 1024): + data.extend(chunk) + if len(data) > limit: + raise ResourceTooLarge() + return bytes(data) + raise GraphError(429, "resource") + + def _hit(self, group, p) -> dict: + return {"id": self.tag(group, p["id"]), "title": p.get("title") or "", "lastModifiedDateTime": p.get("lastModifiedDateTime"), "webUrl": self.web_url(p.get("links")), "section": self._ref(group, p.get("parentSection")), "notebook": self._ref(group, p.get("parentNotebook"))} + + def _filtered(self, note, group, query: str, top: int) -> list[dict]: + literal = query.lower().replace("'", "''") + rows = note.list(f"{self.root(group)}/pages", {"$filter": f"contains(tolower(title),'{literal}')", "$select": "id,title,lastModifiedDateTime,links", "$expand": self.HIT_EXPAND, "$top": min(top, 100)}, limit=top, client=C_ONENOTE) + return [p for p in rows if self.GRAPH_ID.fullmatch(str(p.get("id", "")))] + + def _filter_refused(self, ex: GraphError) -> bool: + """Graph's title filter covers a whole root and refuses one with too many sections, or with one section it cannot sync.""" + return ex.status in (400, 422) + + def _walk_search(self, note, broker, group, notebook_id, query: str, top: int) -> tuple[list[dict], bool]: + """Title search one section at a time (of one notebook, or of the whole root), for where Graph's own filter is refused.""" + flat = [s for s in self._flat(note, broker, group, "sections") if notebook_id is None or (s.get("parentNotebook") or {}).get("id") == notebook_id] + flat.sort(key=lambda s: s.get("lastModifiedDateTime") or "", reverse=True) + partial = len(flat) > self.SEARCH_SECTIONS + needle = query.lower() + + def titles(s): + try: + return self._kept(broker, ("onenote", "titles", group, s["id"]), lambda: note.list(f"{self.root(group)}/sections/{s['id']}/pages", {"$select": "id,title,lastModifiedDateTime,links", "$top": 100}, limit=500, client=C_ONENOTE)) + except GraphError: + return None + + # A few sections at a time, most recently changed first; their page titles are kept a short while for the next search. + with ThreadPoolExecutor(max_workers=self.TEAM_WORKERS) as pool: + found = list(pool.map(titles, flat[:self.SEARCH_SECTIONS])) + hits = [] + for s, rows in zip(flat, found): + if rows is None: + # A section OneNote cannot sync is not one the person has pages in to find. + continue + parent = (s.get("parentNotebook") or {}).get("id") + for p in rows: + if needle in (p.get("title") or "").lower() and self.GRAPH_ID.fullmatch(str(p.get("id", ""))): + hits.append(self._hit(group, {**p, "parentSection": {"id": s["id"], "displayName": s.get("displayName")}, "parentNotebook": {"id": parent, "displayName": None} if parent else None})) + hits.sort(key=lambda h: h.get("lastModifiedDateTime") or "", reverse=True) + return hits[:top], partial + + def _name_notebooks(self, note, broker, hits: list[dict]): + names = {n["id"]: n["displayName"] for n in self.notebooks(note, broker)} + for h in hits: + if h["notebook"] and not h["notebook"]["displayName"]: + h["notebook"]["displayName"] = names.get(h["notebook"]["id"], "") + + def search(self, note, broker, query: str, notebook: str | None, top: int) -> dict: + query = re.sub(r"\s+", " ", query).strip() + if notebook: + group, notebook_id = self.parse_id(notebook) + try: + rows = [p for p in self._filtered(note, group, query, 100) if (p.get("parentNotebook") or {}).get("id") == notebook_id] + return {"items": [self._hit(group, p) for p in rows[:top]], "partial": False} + except GraphError as ex: + if not self._filter_refused(ex): + raise + hits, partial = self._walk_search(note, broker, group, notebook_id, query, top) + self._name_notebooks(note, broker, hits) + return {"items": hits, "partial": partial} + roots = [None] + [t["id"] for t in self._teams(note, broker)] + + def one(group): + try: + return [self._hit(group, p) for p in self._filtered(note, group, query, top)], False + except GraphError as ex: + if not self._filter_refused(ex): + if group is None: + raise + return [], True + if group is None: + # The person's own notebooks are few sections, so they are walked; a class notebook is searched by picking it. + return self._walk_search(note, broker, None, None, query, top) + return [], True + + with ThreadPoolExecutor(max_workers=self.TEAM_WORKERS) as pool: + found = list(pool.map(one, roots)) + items = [h for rows, _ in found for h in rows] + self._name_notebooks(note, broker, items) + items.sort(key=lambda h: h.get("lastModifiedDateTime") or "", reverse=True) + return {"items": items[:top], "partial": any(p for _, p in found)} diff --git a/src/infrastructure/graph/onenote_runner.py b/src/infrastructure/graph/onenote_runner.py new file mode 100644 index 0000000..f4c98a8 --- /dev/null +++ b/src/infrastructure/graph/onenote_runner.py @@ -0,0 +1,27 @@ +from collections.abc import Callable + +from outnote import GraphError + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_one_note_runner import IOneNoteRunner +from src.domain.errors.service_error import ServiceError + + +class OneNoteRunner(IOneNoteRunner): + """Graph's refusals for OneNote calls, mapped before the account runner sees them.""" + + def __init__(self, accounts: IAccountRunner): + self._accounts = accounts + + async def run(self, refresh_token: str, action: Callable[..., object]) -> object: + def run(note, broker): + try: + return action(note, broker) + except GraphError as ex: + if ex.status == 403: + raise ServiceError(403, "OneNote refused access to this notebook.") + if ex.status in (400, 409, 422): + raise ServiceError(422, f"OneNote refused the request: {str(ex)[:200]}") + raise + + return await self._accounts.run(refresh_token, run) diff --git a/src/infrastructure/graph/pdf_converter.py b/src/infrastructure/graph/pdf_converter.py new file mode 100644 index 0000000..075463f --- /dev/null +++ b/src/infrastructure/graph/pdf_converter.py @@ -0,0 +1,155 @@ +"""Office files as PDF for the web viewer, converted by Microsoft 365 (Graph's `content?format=pdf`). + +A file already in OneDrive / SharePoint (a drive item, a file shared in a chat) is converted where it is. A file that isn't in a +drive (a mail attachment, bytes the caller uploads) goes up to a temporary file in the app's own OneDrive folder +(`special/approot:/schuly-preview/.`), is converted, and is deleted again in a `finally`. Leftovers (a crash between +upload and delete) are swept: files in that folder older than an hour are deleted, at most once per 10 minutes per account. + +Graph answers a conversion with a 302 to a short-lived pre-authenticated URL on `-mediap.svc.ms`; only that host (or a +SharePoint host) is fetched, https only, and the bytes are held in a spooled temporary file so they can be checked (`%PDF`, size) +before anything is sent. +""" + +import logging +import posixpath +import re +import secrets +import tempfile +from datetime import datetime, timedelta, timezone +from urllib.parse import quote, urlsplit +import httpx +from outnote import GraphError + +from src.application.abstractions.i_pdf_converter import IPdfConverter +from src.application.abstractions.i_microsoft_http_clients import IMicrosoftHttpClients +from src.domain.errors.convert_error import ConvertError +from src.infrastructure.microsoft.account_cache import AccountCache + + +class PdfConverter(IPdfConverter): + """Office files as PDF through Graph's format=pdf, with temporary app-folder files for bytes outside a drive.""" + + log = logging.getLogger("schuly.microsoft") + # Graph's format=pdf source types. + CONVERTIBLE = frozenset({"csv", "doc", "docx", "odp", "ods", "odt", "pot", "potm", "potx", "pps", "ppsx", "ppsxm", "ppt", "pptm", "pptx", "rtf", "xls", "xlsx"}) + TEMP_FOLDER = "schuly-preview" + TEMP_MAX_AGE = timedelta(hours=1) + SWEEP_EVERY = 600 + MAX_PDF_BYTES = 150 * 1024 * 1024 + SPOOL_IN_MEMORY = 8 * 1024 * 1024 + FETCH_TIMEOUT = httpx.Timeout(120.0, connect=5.0) + CONVERTED_HOST = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*-mediap\.svc\.ms") + HOST_SUFFIXES = (".sharepoint.com",) + + def __init__(self, http: IMicrosoftHttpClients): + self._http = http + + def extension(self, name) -> str: + return posixpath.splitext(name or "")[1].lstrip(".").lower() if isinstance(name, str) else "" + + def is_convertible(self, name) -> bool: + return self.extension(name) in self.CONVERTIBLE + + def is_converted_url(self, url) -> bool: + """True only for the https URL Graph redirects a conversion to: `-mediap.svc.ms` exactly, or a SharePoint host.""" + if not isinstance(url, str) or not url or len(url) > 8000 or any(ord(c) < 33 for c in url): + return False + try: + parts = urlsplit(url) + port = parts.port + except ValueError: + return False + host = (parts.hostname or "").lower() + return parts.scheme == "https" and port in (None, 443) and not parts.username and not parts.password and (bool(self.CONVERTED_HOST.fullmatch(host)) or any(host.endswith(s) for s in self.HOST_SUFFIXES)) + + def drive_item_pdf(self, note, item_id: str, drive_id: str | None = None): + """A drive item converted to PDF, as a spooled file positioned at 0.""" + try: + url = note.converted_url(quote(item_id, safe=""), "pdf", quote(drive_id, safe="") if drive_id else None) + except GraphError as ex: + if ex.status in (400, 406, 415, 422, 501): + raise ConvertError(415, "Microsoft 365 cannot convert this file to PDF.") + raise + return self.fetch_pdf(url) + + def fetch_pdf(self, url: str): + """The converted bytes behind a pre-authenticated URL (no bearer token is sent), checked to be a PDF and capped.""" + if not self.is_converted_url(url): + raise ConvertError(502, "The conversion answered with an unexpected location.") + out = tempfile.SpooledTemporaryFile(max_size=self.SPOOL_IN_MEMORY) + try: + with self._http.download_client().stream("GET", url, timeout=self.FETCH_TIMEOUT, follow_redirects=False) as r: + if r.status_code != 200: + raise ConvertError(502, f"The conversion failed ({r.status_code}).") + size = 0 + for chunk in r.iter_bytes(64 * 1024): + size += len(chunk) + if size > self.MAX_PDF_BYTES: + raise ConvertError(413, "The converted PDF is larger than 150 MB.") + out.write(chunk) + out.seek(0) + if not out.read(1024).lstrip().startswith(b"%PDF"): + raise ConvertError(502, "The conversion did not return a PDF.") + out.seek(0) + return out + except BaseException: + out.close() + raise + + def bytes_pdf(self, note, broker, data: bytes, name: str): + """Bytes that aren't in a drive, converted through a temporary file in the app folder that is always deleted again.""" + ext = self.extension(name) + if ext not in self.CONVERTIBLE: + raise ConvertError(415, "This file type cannot be converted to PDF.") + self.sweep(note, broker) + item_id = None + try: + item = note.upload(data, f"{self.TEMP_FOLDER}/{secrets.token_hex(16)}.{ext}", conflict="fail", app_folder=True) + item_id = (item or {}).get("id") + if not item_id: + raise ConvertError(502, "The temporary upload failed.") + return self.drive_item_pdf(note, item_id) + finally: + if item_id: + try: + note.delete_file(quote(item_id, safe="")) + except Exception as ex: + # The sweep removes it within the hour. + self.log.warning("temporary preview file not deleted: %s", type(ex).__name__) + + def sweep(self, note, broker, now: datetime | None = None) -> int: + """Deletes temporary files older than an hour (best effort, at most every 10 minutes per account); returns how many.""" + cache = AccountCache.for_broker(broker) + if cache.has(("preview_sweep",)): + return 0 + cache.set(("preview_sweep",), True, self.SWEEP_EVERY) + now = now or datetime.now(timezone.utc) + deleted = 0 + try: + items = note.list(f"me/drive/special/approot:/{self.TEMP_FOLDER}:/children", {"$select": "id,name,createdDateTime", "$top": 200}, limit=200) + except GraphError as ex: + if ex.status != 404: + self.log.info("preview sweep list failed: %s", ex.status) + return 0 + except Exception as ex: + self.log.info("preview sweep list failed: %s", type(ex).__name__) + return 0 + for item in items: + created = self._parse_time(item.get("createdDateTime")) + if created is None or now - created < self.TEMP_MAX_AGE or not isinstance(item.get("id"), str): + continue + try: + note.delete_file(quote(item["id"], safe="")) + deleted += 1 + except Exception as ex: + self.log.info("preview sweep delete failed: %s", type(ex).__name__) + return deleted + + def _parse_time(self, value) -> datetime | None: + if not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) diff --git a/src/infrastructure/graph/people_photos.py b/src/infrastructure/graph/people_photos.py new file mode 100644 index 0000000..abf57a6 --- /dev/null +++ b/src/infrastructure/graph/people_photos.py @@ -0,0 +1,76 @@ +"""Profile photos by e-mail address, for mail senders (which come with an address, not a user id). + +Graph accepts a UPN in `users/{id}`, and UPN and mail are usually equal, so the address is tried +directly first; when that 404s, the user is resolved with `users?$filter=mail eq '...'`. A person +outside the tenant resolves to nobody and reads as no photo. Results are cached per account: a +photo for 6 hours, a miss for 30 minutes. +""" + +from urllib.parse import quote +from outnote import GraphError + +from src.application.abstractions.i_people_photos import IPeoplePhotos +from src.domain.errors.photo_throttled import PhotoThrottled +from src.infrastructure.microsoft.account_cache import AccountCache + + +class PeoplePhotos(IPeoplePhotos): + """Profile photos by e-mail address (mail senders) and by user id.""" + + HIT_TTL = 6 * 3600 + MISS_TTL = 30 * 60 + MAX_CACHED_BYTES = 200 * 1024 + + def _odata_string(self, value: str) -> str: + return value.replace("'", "''") + + def _first(self, note, user: str, size: int) -> bytes | None: + seg = quote(user, safe="@") + for path in (f"users/{seg}/photos/{size}x{size}/$value", f"users/{seg}/photo/$value"): + try: + return note.get(path, raw="bytes") + except GraphError as ex: + if ex.status == 429: + raise PhotoThrottled(ex.retry_after) from None + if ex.status not in (400, 404): + raise + return None + + def _resolve(self, note, email: str) -> str | None: + try: + found = note.list("users", {"$filter": f"mail eq '{self._odata_string(email)}'", "$select": "id", "$top": 1}, limit=1) + except GraphError as ex: + if ex.status == 429: + raise PhotoThrottled(ex.retry_after) from None + if ex.status in (400, 403, 404): + return None + raise + return found[0].get("id") if found else None + + def user_photo(self, note, user_id: str, size: int) -> bytes | None: + for path in (f"users/{user_id}/photos/{size}x{size}/$value", f"users/{user_id}/photo/$value"): + try: + return note.get(path, raw="bytes") + except GraphError as ex: + if ex.status != 404: + raise + return None + + def photo(self, note, broker, email: str, size: int) -> bytes | None: + email = email.strip().lower() + cache = AccountCache.for_broker(broker) + key = ("people_photo", email, size) + marker = object() + hit = cache.get(key, marker) + if hit is not marker: + return hit + data = self._first(note, email, size) + if data is None: + user_id = self._resolve(note, email) + if user_id: + data = self._first(note, user_id, size) + if data is None: + cache.set(key, None, self.MISS_TTL) + elif len(data) <= self.MAX_CACHED_BYTES: + cache.set(key, data, self.HIT_TTL) + return data diff --git a/src/infrastructure/graph/teams_files.py b/src/infrastructure/graph/teams_files.py new file mode 100644 index 0000000..6169556 --- /dev/null +++ b/src/infrastructure/graph/teams_files.py @@ -0,0 +1,229 @@ +"""Teams, channels and the files behind them (team libraries, channel folders), plus shared / recent files and raw drive items. + +Live facts: `groups/{id}/drive` answers 404 on the Office client, so a team's library id is resolved with the OfficeHome client +(`C_SITES`) and cached for an hour; browsing `drives/{driveId}/...` then works on the default client. Every drive entry handed out +carries `parentReference.driveId` so the caller can address it again. +""" + +import re +from urllib.parse import quote, urlsplit +import httpx +from outnote import C_SITES, GraphError + +from src.application.abstractions.i_teams_files import ITeamsFiles +from src.application.abstractions.i_microsoft_http_clients import IMicrosoftHttpClients +from src.domain.errors.file_fetch_error import FileFetchError +from src.domain.errors.service_error import ServiceError +from src.domain.files.teams_path_limits import TeamsPathLimits +from src.infrastructure.microsoft.account_cache import AccountCache + + +class TeamsFiles(ITeamsFiles): + """Teams, channels and the files behind them, shared and recent files, raw drive items, and the download host check.""" + + LIST_TTL = 600 + DRIVE_TTL = 3600 + MAX_PATH_CHARS = TeamsPathLimits.MAX_PATH_CHARS + MAX_PATH_SEGMENTS = 30 + MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024 + FETCH_TIMEOUT = httpx.Timeout(120.0, connect=5.0) + ITEM_FIELDS = "id,name,size,file,folder,webUrl,lastModifiedDateTime,createdDateTime,parentReference,lastModifiedBy,createdBy" + FORBIDDEN_PATH = re.compile(r"[%\\:#?\x00-\x1f\x7f]") + + def __init__(self, http: IMicrosoftHttpClients): + self._http = http + + def seg(self, value: str) -> str: + return quote(value, safe="") + + def valid_path(self, path) -> list[str]: + """The folder names of a relative path ('' -> none). Raises ValueError on dot segments, `% \\ : # ?`, control characters or a path over 400 chars.""" + if path is None or path == "": + return [] + if not isinstance(path, str) or len(path) > self.MAX_PATH_CHARS or self.FORBIDDEN_PATH.search(path): + raise ValueError("invalid path") + parts = [p for p in path.split("/") if p != ""] + if len(parts) > self.MAX_PATH_SEGMENTS or any(p in (".", "..") or ".." in p or p != p.strip() for p in parts): + raise ValueError("invalid path") + return parts + + def is_download_url(self, url) -> bool: + """True only for an https SharePoint download URL: host ends with `.sharepoint.com`, default port, no credentials.""" + if not isinstance(url, str) or not url or len(url) > 8000 or any(ord(c) < 33 for c in url): + return False + try: + parts = urlsplit(url) + port = parts.port + except ValueError: + return False + host = (parts.hostname or "").lower() + return parts.scheme == "https" and port in (None, 443) and not parts.username and not parts.password and host.endswith(".sharepoint.com") + + def fetch_bytes(self, url: str, cap: int) -> bytes: + """The bytes behind a pre-authorized download URL (no bearer token, no redirects), 413 past `cap`.""" + if not self.is_download_url(url): + raise FileFetchError(502, "The file has an unexpected download location.") + out = bytearray() + with self._http.download_client().stream("GET", url, timeout=self.FETCH_TIMEOUT, follow_redirects=False) as r: + if r.status_code != 200: + raise FileFetchError(502, f"The download failed ({r.status_code}).") + for chunk in r.iter_bytes(64 * 1024): + out.extend(chunk) + if len(out) > cap: + raise FileFetchError(413, "The file is too large.") + return bytes(out) + + def download_url(self, note, path: str, item: dict) -> str | None: + """The pre-authorized link of the item at `path`, or None when `/content` answers without a redirect. Graph can leave it out of a `$select` answer, so ask again without one, then through `/content`; a refusal there is raised. + + OutNote 2.0.1's `download_url` has the same fallback but can't take the `shares/...` path chat files need and always asks Graph again, and its `download` holds the whole file in memory with no cap.""" + url = item.get("@microsoft.graph.downloadUrl") + if url: + return url + full = note.get(path) + url = full.get("@microsoft.graph.downloadUrl") if isinstance(full, dict) else None + if url: + return url + try: + return note.redirect(f"{path}/content") + except GraphError as ex: + if ex.status >= 400: + raise + return None + + def download_link(self, note, path: str, item: dict) -> str: + """The item's download link, with Graph's refusal as a status of its own. A 404 and a 429 are answered by the account runner.""" + try: + url = self.download_url(note, path, item) + except GraphError as ex: + if ex.status == 403: + raise ServiceError(403, "Microsoft 365 refused access to this file.") + if ex.status in (406, 415): + raise ServiceError(415, "Microsoft 365 cannot serve this file.") + raise + if not url: + raise ServiceError(502, "Microsoft did not offer a download link for this file.") + return url + + def item_base(self, drive_id: str | None, item_id: str) -> str: + return f"drives/{self.seg(drive_id)}/items/{self.seg(item_id)}" if drive_id else f"me/drive/items/{self.seg(item_id)}" + + def _with_drive(self, item: dict, drive_id: str | None) -> dict: + if drive_id and isinstance(item, dict): + ref = item.get("parentReference") + if not isinstance(ref, dict): + ref = item["parentReference"] = {} + ref.setdefault("driveId", drive_id) + return item + + def joined_teams(self, note, broker) -> list[dict]: + cache = AccountCache.for_broker(broker) + hit = cache.get(("teams", "joined")) + if hit is not None: + return hit + rows = note.list("me/joinedTeams", {"$select": "id,displayName,description"}) + teams = [{"id": t.get("id"), "displayName": t.get("displayName"), "description": t.get("description")} for t in rows if t.get("id")] + cache.set(("teams", "joined"), teams, self.LIST_TTL) + return teams + + def channels(self, note, broker, team_id: str) -> list[dict]: + cache = AccountCache.for_broker(broker) + key = ("teams", "channels", team_id) + hit = cache.get(key) + if hit is not None: + return hit + rows = note.list(f"teams/{self.seg(team_id)}/channels", {"$select": "id,displayName,membershipType,description"}) + out = [{"id": c.get("id"), "displayName": c.get("displayName"), "membershipType": c.get("membershipType"), "description": c.get("description")} for c in rows if c.get("id")] + cache.set(key, out, self.LIST_TTL) + return out + + def team_drive_id(self, note, broker, team_id: str) -> str: + """The team's document library id (the Office client can't read `groups/{id}/drive`, the OfficeHome client can); cached 1 h.""" + cache = AccountCache.for_broker(broker) + key = ("teams", "drive", team_id) + hit = cache.get(key) + if hit: + return hit + drive = note.get(f"groups/{self.seg(team_id)}/drive", {"$select": "id"}, client=C_SITES) or {} + drive_id = drive.get("id") + if not drive_id: + raise GraphError(404, "The team has no document library.") + cache.set(key, drive_id, self.DRIVE_TTL) + return drive_id + + def own_drive_id(self, note, broker) -> str | None: + cache = AccountCache.for_broker(broker) + key = ("drive", "own") + hit = cache.get(key) + if hit: + return hit + drive_id = (note.get("me/drive", {"$select": "id"}) or {}).get("id") + if drive_id: + cache.set(key, drive_id, self.DRIVE_TTL) + return drive_id + + def team_files(self, note, broker, team_id: str, channel_id: str | None, path: str | None, top: int) -> dict: + """`{driveId, folder: {id, name, path}, items}` for a team's library root, or a channel's folder, or a sub-folder of either.""" + names = self.valid_path(path) + if channel_id: + root = note.get(f"teams/{self.seg(team_id)}/channels/{self.seg(channel_id)}/filesFolder", {"$select": "id,name,parentReference"}) or {} + drive_id = (root.get("parentReference") or {}).get("driveId") + if not drive_id or not root.get("id"): + raise GraphError(404, "The channel has no files folder.") + else: + drive_id = self.team_drive_id(note, broker, team_id) + root = note.get(f"drives/{self.seg(drive_id)}/root", {"$select": "id,name"}) or {} + if not root.get("id"): + raise GraphError(404, "The team library has no root.") + folder = root + if names: + folder = note.get(f"drives/{self.seg(drive_id)}/items/{self.seg(root['id'])}:/{'/'.join(self.seg(n) for n in names)}", {"$select": "id,name,folder"}) or {} + if folder.get("folder") is None or not folder.get("id"): + raise GraphError(404, "Folder not found.") + items = note.list(f"drives/{self.seg(drive_id)}/items/{self.seg(folder['id'])}/children", {"$top": min(top, 200), "$select": self.ITEM_FIELDS}, limit=top) + return {"driveId": drive_id, "folder": {"id": folder["id"], "name": folder.get("name"), "path": "/".join(names)}, "items": [self._with_drive(i, drive_id) for i in items]} + + def drive_item(self, note, drive_id: str, item_id: str) -> dict: + """One drive item with a fresh download URL.""" + return self._with_drive(note.get(self.item_base(drive_id, item_id)) or {}, drive_id) + + def children(self, note, drive_id: str, item_id: str, top: int) -> list[dict]: + rows = note.list(f"{self.item_base(drive_id, item_id)}/children", {"$top": min(top, 200), "$select": self.ITEM_FIELDS}, limit=top) + return [self._with_drive(i, drive_id) for i in rows] + + def _identity(self, value): + """`{displayName, email}` out of a Graph identitySet (or a bare identity).""" + if not isinstance(value, dict): + return None + user = value.get("user") if isinstance(value.get("user"), dict) else value + name, email = user.get("displayName"), user.get("email") + return {"displayName": name, "email": email} if name or email else None + + def flatten(self, entry: dict) -> dict | None: + """A sharedWithMe / recent row as a plain driveItem (the `remoteItem` unwrapped), always with `parentReference.driveId`.""" + remote = entry.get("remoteItem") if isinstance(entry.get("remoteItem"), dict) else entry + ref = remote.get("parentReference") or entry.get("parentReference") or {} + drive_id = ref.get("driveId") + if not remote.get("id") or not drive_id: + return None + out = {"id": remote["id"], "name": remote.get("name") or entry.get("name"), "size": remote.get("size", entry.get("size")), "webUrl": remote.get("webUrl") or entry.get("webUrl"), "lastModifiedDateTime": remote.get("lastModifiedDateTime") or entry.get("lastModifiedDateTime"), "parentReference": {"driveId": drive_id}} + if remote.get("file") is not None: + out["file"] = remote["file"] + if remote.get("folder") is not None: + out["folder"] = remote["folder"] + by = self._identity(remote.get("lastModifiedBy") or entry.get("lastModifiedBy")) + if by: + out["lastModifiedBy"] = {"user": by} + shared = remote.get("shared") or entry.get("shared") or {} + sharer = self._identity(shared.get("sharedBy") or shared.get("owner")) + if sharer or shared.get("sharedDateTime"): + out["shared"] = {"sharedBy": sharer, "sharedDateTime": shared.get("sharedDateTime")} + return out + + def shared_with_me(self, note, top: int) -> list[dict]: + rows = note.list("me/drive/sharedWithMe", {"$top": min(top, 200)}, limit=top) + return [f for f in (self.flatten(r) for r in rows) if f] + + def recent(self, note, top: int) -> list[dict]: + rows = note.list("me/drive/recent", {"$top": min(top, 200)}, limit=top) + return [f for f in (self.flatten(r) for r in rows) if f] diff --git a/src/infrastructure/logging_config.py b/src/infrastructure/logging_config.py new file mode 100644 index 0000000..c89b814 --- /dev/null +++ b/src/infrastructure/logging_config.py @@ -0,0 +1,5 @@ +class LoggerNames: + """The service's logger names. They are not configured here: uvicorn's defaults stay, and its access log stays off (see asgi.py).""" + + SERVICE = "schuly.microsoft" + PASSWORD_SIGN_IN = "schuly.microsoft.password" diff --git a/src/account_cache.py b/src/infrastructure/microsoft/account_cache.py similarity index 69% rename from src/account_cache.py rename to src/infrastructure/microsoft/account_cache.py index 9aa1002..4386c97 100644 --- a/src/account_cache.py +++ b/src/infrastructure/microsoft/account_cache.py @@ -1,23 +1,35 @@ """A small TTL store per linked account. -It lives on the account's shared broker (see `outnote_client._SharedBroker`), so it follows the -account across refresh-token rotation and is evicted together with the broker. Nothing in it is -persisted; it only spares repeat Microsoft calls for data that rarely changes. +It lives on the account's shared broker (see `SharedBroker`), so it follows the account across refresh-token rotation and is evicted +together with the broker. Nothing in it is persisted; it only spares repeat Microsoft calls for data that rarely changes. """ import threading import time -MAX_ITEMS = 2000 - class AccountCache: + MAX_ITEMS = 2000 + _attach = threading.Lock() + def __init__(self, max_items: int = MAX_ITEMS): self._max = max_items self._items: dict[object, tuple[object, float]] = {} self._locks: dict[str, threading.Lock] = {} self._guard = threading.Lock() + @classmethod + def for_broker(cls, broker) -> "AccountCache": + """The broker's cache, attached on first use (a broker built elsewhere gets one too).""" + cache = getattr(broker, "cache", None) + if cache is None: + with cls._attach: + cache = getattr(broker, "cache", None) + if cache is None: + cache = cls() + broker.cache = cache + return cache + def get(self, key, default=None): with self._guard: hit = self._items.get(key) @@ -54,18 +66,3 @@ def lock(self, name: str) -> threading.Lock: """A lock per name, so concurrent misses for the same value fetch it once.""" with self._guard: return self._locks.setdefault(name, threading.Lock()) - - -_attach = threading.Lock() - - -def for_broker(broker) -> AccountCache: - """The broker's cache, attached on first use (a broker built elsewhere gets one too).""" - cache = getattr(broker, "cache", None) - if cache is None: - with _attach: - cache = getattr(broker, "cache", None) - if cache is None: - cache = AccountCache() - broker.cache = cache - return cache diff --git a/src/infrastructure/microsoft/account_runner.py b/src/infrastructure/microsoft/account_runner.py new file mode 100644 index 0000000..45a37e8 --- /dev/null +++ b/src/infrastructure/microsoft/account_runner.py @@ -0,0 +1,56 @@ +import asyncio +from collections.abc import Callable + +from outnote import GraphError + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_graph_client_factory import IGraphClientFactory +from src.application.abstractions.i_token_rotation import ITokenRotation +from src.application.services.retry_after import RetryAfter +from src.domain.errors.service_error import ServiceError + + +class AccountRunner(IAccountRunner): + """Every read and write for an account goes through here: off the event loop, with the rotated token handed back and `_native` stripped.""" + + def __init__(self, clients: IGraphClientFactory, rotation: ITokenRotation): + self._clients = clients + self._rotation = rotation + + async def run(self, refresh_token: str, action: Callable[..., object]) -> object: + note, broker = self._clients.build_client(refresh_token) + try: + data = await asyncio.to_thread(action, note, broker) + except ServiceError: + # The token may have rotated before the action refused; keep it for the caller. + self._keep_rotated(broker, refresh_token) + raise + except Exception as ex: + if "invalid_grant" in str(ex): + raise ServiceError(401, "The Microsoft sign-in expired; reconnect the account.") + if isinstance(ex, GraphError) and ex.status == 404: + raise ServiceError(404, "Not found in Microsoft 365.") + if isinstance(ex, GraphError) and ex.status == 429: + # OutNote already waited about 60 s. The token may have rotated first; keep it for the caller. + self._keep_rotated(broker, refresh_token) + raise ServiceError(429, "Microsoft is throttling this account; try again later.", headers={"Retry-After": RetryAfter.header(ex.retry_after)}) + raise ServiceError(502, f"Microsoft Graph error: {ex}") + # Hand back the rotated refresh token so the caller can persist it. + self._keep_rotated(broker, refresh_token) + return self.strip_native(data) + + def _keep_rotated(self, broker, refresh_token: str) -> None: + if broker.refresh_token and broker.refresh_token != refresh_token: + self._rotation.record(broker.refresh_token) + + @classmethod + def strip_native(cls, value): + """Removes OutNote's `_native` copies (the raw Teams answer, with raw ids) from a result, in place, at any depth.""" + if isinstance(value, dict): + value.pop("_native", None) + for item in value.values(): + cls.strip_native(item) + elif isinstance(value, (list, tuple)): + for item in value: + cls.strip_native(item) + return value diff --git a/src/infrastructure/microsoft/broker_cache.py b/src/infrastructure/microsoft/broker_cache.py new file mode 100644 index 0000000..e4ddf9b --- /dev/null +++ b/src/infrastructure/microsoft/broker_cache.py @@ -0,0 +1,45 @@ +import hashlib +import threading +from collections import OrderedDict + +from src.infrastructure.microsoft.shared_broker import SharedBroker + + +class BrokerCache: + """In-memory LRU of brokers keyed by SHA-256 of the refresh token (the raw token is never a key).""" + + BROKER_CACHE_SIZE = 1000 + + def __init__(self, max_size: int = BROKER_CACHE_SIZE): + self._max = max_size + self._items: OrderedDict[str, SharedBroker] = OrderedDict() + self._lock = threading.Lock() + + @staticmethod + def _key(refresh_token: str) -> str: + return hashlib.sha256(refresh_token.encode()).hexdigest() + + def get(self, refresh_token: str) -> SharedBroker: + key = self._key(refresh_token) + with self._lock: + broker = self._items.get(key) + if broker is None: + broker = SharedBroker(refresh_token, self._register) + self._store(key, broker) + else: + self._items.move_to_end(key) + return broker + + def _register(self, refresh_token: str, broker: SharedBroker): + with self._lock: + self._store(self._key(refresh_token), broker) + + def _store(self, key: str, broker: SharedBroker): + self._items[key] = broker + self._items.move_to_end(key) + while len(self._items) > self._max: + self._items.popitem(last=False) + + def clear(self): + with self._lock: + self._items.clear() diff --git a/src/infrastructure/microsoft/browser_sign_in_client.py b/src/infrastructure/microsoft/browser_sign_in_client.py new file mode 100644 index 0000000..66ad6f9 --- /dev/null +++ b/src/infrastructure/microsoft/browser_sign_in_client.py @@ -0,0 +1,56 @@ +"""Browser sign-in: standard OAuth 2.0 authorization-code + PKCE against the OfficeHome client. + +This is the flow `outnote login` uses and works on tenants that block the device-code flow. +The app opens the authorize URL in a WebView on Microsoft's real sign-in page, waits for the +redirect to the native-client URL carrying `?code=`, and sends the code here to exchange for a +FOCI refresh token. The caller holds the `code_verifier` between the two calls; nothing is stored. +""" + +import base64 +import hashlib +import secrets +from urllib.parse import urlencode + +import httpx + +from src.application.abstractions.i_browser_sign_in import IBrowserSignIn +from src.application.abstractions.i_microsoft_http_clients import IMicrosoftHttpClients +from src.application.options.microsoft_options import MicrosoftOptions + + +class BrowserSignInClient(IBrowserSignIn): + # OfficeHome: pre-consented, issues a FOCI family refresh token. Public client id. + OFFICEHOME_CLIENT_ID = "4765445b-32c6-49b0-83e6-1d93765276ca" + # Microsoft's documented native-client redirect — the code lands here in the URL; nothing is hosted. + NATIVE_REDIRECT = "https://login.microsoftonline.com/common/oauth2/nativeclient" + + def __init__(self, http: IMicrosoftHttpClients, options: MicrosoftOptions): + self._http = http + self._options = options + + def build_authorize_url(self) -> tuple[str, str]: + """The Microsoft authorize URL for the WebView, and the PKCE code_verifier the caller keeps.""" + verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode() + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + url = f"https://login.microsoftonline.com/{self._options.tenant}/oauth2/v2.0/authorize" + "?" + urlencode({ + "client_id": self.OFFICEHOME_CLIENT_ID, + "response_type": "code", + "redirect_uri": self.NATIVE_REDIRECT, + "response_mode": "query", + "scope": f"{self._options.graph_default} offline_access openid profile", + "state": secrets.token_urlsafe(16), + "code_challenge": challenge, + "code_challenge_method": "S256", + "prompt": "select_account", + }) + return url, verifier + + async def exchange_code(self, code: str, code_verifier: str, client: httpx.AsyncClient | None = None) -> dict: + """Redeem the authorization code from the redirect for tokens.""" + return await self._http.post_form(self._options.token_url, { + "grant_type": "authorization_code", + "client_id": self.OFFICEHOME_CLIENT_ID, + "code": code, + "redirect_uri": self.NATIVE_REDIRECT, + "code_verifier": code_verifier, + }, client) diff --git a/src/infrastructure/microsoft/device_login_client.py b/src/infrastructure/microsoft/device_login_client.py new file mode 100644 index 0000000..e2faae6 --- /dev/null +++ b/src/infrastructure/microsoft/device_login_client.py @@ -0,0 +1,25 @@ +import httpx + +from src.application.abstractions.i_device_login import IDeviceLogin +from src.application.abstractions.i_microsoft_http_clients import IMicrosoftHttpClients +from src.application.options.microsoft_options import MicrosoftOptions +from src.domain.errors.microsoft_error import MicrosoftError + + +class DeviceLoginClient(IDeviceLogin): + """The device-code sign-in. Nothing here is stored server-side.""" + + def __init__(self, http: IMicrosoftHttpClients, options: MicrosoftOptions): + self._http = http + self._options = options + + async def start_device_login(self, client: httpx.AsyncClient | None = None) -> dict: + """Begin the device-code flow: returns user_code, verification_uri and the device_code to poll with.""" + data = await self._http.post_form(self._options.devicecode_url, {"client_id": self._options.device_client_id, "scope": self._options.device_scope}, client) + if not data.get("device_code"): + raise MicrosoftError(data.get("error", "no_device_code"), data.get("error_description", "the client may not support the device flow")) + return data + + async def poll_device_login(self, device_code: str, client: httpx.AsyncClient | None = None) -> dict: + """Poll once. Returns the raw token response: access_token + refresh_token, or error=authorization_pending.""" + return await self._http.post_form(self._options.token_url, {"grant_type": self._options.device_grant, "client_id": self._options.device_client_id, "device_code": device_code}, client) diff --git a/src/infrastructure/microsoft/microsoft_http_clients.py b/src/infrastructure/microsoft/microsoft_http_clients.py new file mode 100644 index 0000000..07c72f2 --- /dev/null +++ b/src/infrastructure/microsoft/microsoft_http_clients.py @@ -0,0 +1,84 @@ +import asyncio +import logging +import threading +import time +from http.cookiejar import DefaultCookiePolicy + +import entrance +import httpx + +from src.application.abstractions.i_microsoft_http_clients import IMicrosoftHttpClients +from src.application.options.microsoft_options import MicrosoftOptions +from src.infrastructure.logging_config import LoggerNames + + +class MicrosoftHttpClients(IMicrosoftHttpClients): + """The shared keep-alive clients: one for capped or streamed bodies and one for the sign-in calls.""" + + log = logging.getLogger(LoggerNames.SERVICE) + + def __init__(self, options: MicrosoftOptions): + self._options = options + self._async_client: httpx.AsyncClient | None = None + self._download_client: httpx.Client | None = None + self._download_lock = threading.Lock() + + def download_client(self) -> httpx.Client: + """One keep-alive client for bodies that must stay capped or be streamed: `entrance.http_client()` reads every body into memory first and has no stream. + + It is shared by all accounts, so it keeps no cookies (SharePoint sets them on downloads) and never holds a token; a bearer goes per request, with redirects off.""" + if self._download_client is None: + with self._download_lock: + if self._download_client is None: + client = httpx.Client( + timeout=httpx.Timeout(30.0, connect=5.0), + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20, keepalive_expiry=60), + transport=httpx.HTTPTransport(retries=3), + follow_redirects=False, + ) + client.cookies.jar.set_policy(DefaultCookiePolicy(allowed_domains=[])) + self._download_client = client + return self._download_client + + def shared_async_client(self) -> httpx.AsyncClient: + """One keep-alive client for the sign-in calls (connect bounded to 5 s, connect retries).""" + if self._async_client is None: + self._async_client = httpx.AsyncClient( + timeout=httpx.Timeout(30.0, connect=5.0), + limits=httpx.Limits(max_connections=50, max_keepalive_connections=10, keepalive_expiry=60), + transport=httpx.AsyncHTTPTransport(retries=3), + ) + return self._async_client + + async def close_shared_client(self) -> None: + client, self._async_client = self._async_client, None + if client is not None: + await client.aclose() + with self._download_lock: + download, self._download_client = self._download_client, None + if download is not None: + download.close() + + async def warm_up(self) -> None: + """Open connections to Microsoft's login and Graph hosts so the first real call skips DNS, TCP and TLS.""" + await asyncio.to_thread(entrance.warm_up, [self._options.token_url, "https://graph.microsoft.com/v1.0/"]) + + async def post_form(self, url: str, data: dict, client: httpx.AsyncClient | None = None) -> dict: + client = client or self.shared_async_client() + started = time.perf_counter() + try: + response = await client.post(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) + return response.json() + finally: + self.log.debug("POST %s took %.0f ms", url, (time.perf_counter() - started) * 1000) + + def stream_download(self, url: str, limit: int, follow_redirects: bool = True): + """Yields a pre-authorized download URL's bytes in chunks (no bearer token is sent), stopping past `limit` bytes.""" + with self.download_client().stream("GET", url, timeout=httpx.Timeout(60.0, connect=5.0), follow_redirects=follow_redirects) as r: + r.raise_for_status() + sent = 0 + for chunk in r.iter_bytes(64 * 1024): + sent += len(chunk) + if sent > limit: + return + yield chunk diff --git a/src/infrastructure/microsoft/outnote_client_factory.py b/src/infrastructure/microsoft/outnote_client_factory.py new file mode 100644 index 0000000..2cc5254 --- /dev/null +++ b/src/infrastructure/microsoft/outnote_client_factory.py @@ -0,0 +1,17 @@ +from outnote import OutNote + +from src.application.abstractions.i_graph_client_factory import IGraphClientFactory +from src.infrastructure.microsoft.broker_cache import BrokerCache +from src.infrastructure.microsoft.shared_broker import SharedBroker + + +class OutNoteClientFactory(IGraphClientFactory): + """OutNote clients over the per-token brokers, the one place that holds them.""" + + def __init__(self): + self._brokers = BrokerCache() + + def build_client(self, refresh_token: str) -> tuple[OutNote, SharedBroker]: + """An OutNote client over a shared per-token broker; read `broker.refresh_token` after use for the (possibly rotated) value.""" + broker = self._brokers.get(refresh_token) + return broker.note, broker diff --git a/src/infrastructure/microsoft/profile_reader.py b/src/infrastructure/microsoft/profile_reader.py new file mode 100644 index 0000000..f40df4e --- /dev/null +++ b/src/infrastructure/microsoft/profile_reader.py @@ -0,0 +1,78 @@ +"""The signed-in user's own profile (`GET /me`), cached per account, never stalled by throttling. + +Graph throttles `/me` (a directory read) on its own, with 429 and Retry-After ~21 s, even when every +other route answers at once. OutNote sleeps each Retry-After in the worker thread and retries up to +4 times, which made the first `/api/me` after a start take 42 s (two 21 s waits). So `/me` is asked +exactly once, without OutNote's retries; on 429 (or any failure) the profile comes from the claims of +the access token the broker just minted (oid, name, upn), which Microsoft signed for this user. A +`/me` answer is kept for an hour, a profile from claims for five minutes, so `/me` is tried again later. +""" + +import base64 +import binascii +import json +import logging +from entrance import HTTPError, http_client +from outnote import C_OFFICE, GraphError + +from src.application.abstractions.i_profile_reader import IProfileReader +from src.infrastructure.microsoft.account_cache import AccountCache + + +class ProfileReader(IProfileReader): + """The signed-in user's /me profile: one try without retries, the token's claims on failure, cached per account.""" + + log = logging.getLogger("schuly.microsoft") + CACHE_KEY = "me_profile" + TTL = 3600.0 + CLAIMS_TTL = 300.0 + ME_URL = "https://graph.microsoft.com/v1.0/me" + ME_TIMEOUT = 10.0 + http_client = staticmethod(http_client) + + def get(self, note, broker) -> dict: + cache = AccountCache.for_broker(broker) + hit = cache.get(self.CACHE_KEY) + if hit is not None: + return hit + with cache.lock(self.CACHE_KEY): + hit = cache.get(self.CACHE_KEY) + if hit is None: + hit, ttl = self._fetch(note, broker) + cache.set(self.CACHE_KEY, hit, ttl) + return hit + + def _fetch(self, note, broker) -> tuple[dict, float]: + token = broker(C_OFFICE) + status = None + try: + r = self.http_client().request("GET", self.ME_URL, headers={"Authorization": f"Bearer {token}"}, timeout=self.ME_TIMEOUT) + status = r.status_code + if status == 200: + return r.json(), self.TTL + self.log.info("/me answered %s (Retry-After %s); using the token's claims", status, r.headers.get("Retry-After")) + except (*HTTPError, ValueError) as ex: # entrance's HTTPError is a tuple, so it is unpacked + self.log.info("/me failed (%s); using the token's claims", type(ex).__name__) + claims = self.from_claims(token) + if claims is None: + raise GraphError(status or 502, "The profile is unavailable.") + return claims, self.CLAIMS_TTL + + def from_claims(self, access_token: str) -> dict | None: + """A `/me`-shaped profile from the access token's claims, or None when it carries no user object id.""" + try: + payload = access_token.split(".")[1] + claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) + except (IndexError, ValueError, binascii.Error): + return None + if not isinstance(claims, dict) or not isinstance(claims.get("oid"), str): + return None + upn = claims.get("upn") or claims.get("unique_name") or claims.get("preferred_username") + return { + "id": claims["oid"], + "displayName": claims.get("name"), + "givenName": claims.get("given_name"), + "surname": claims.get("family_name"), + "userPrincipalName": upn, + "mail": claims.get("email") or upn, + } diff --git a/src/infrastructure/microsoft/shared_broker.py b/src/infrastructure/microsoft/shared_broker.py new file mode 100644 index 0000000..f45322e --- /dev/null +++ b/src/infrastructure/microsoft/shared_broker.py @@ -0,0 +1,42 @@ +import threading +from collections.abc import Callable + +from entrance import Broker +from outnote import OutNote + +from src.application.abstractions.i_account_broker import IAccountBroker +from src.application.abstractions.i_graph_client import IGraphClient +from src.infrastructure.microsoft.account_cache import AccountCache + +IGraphClient.register(OutNote) +IAccountBroker.register(Broker) + + +class SharedBroker(Broker): + """A Broker shared by every request that carries the same refresh token. + + Entrance's Broker already keeps one access token per (client id, scope) and only redeems the + refresh token (which makes Microsoft rotate it) when that entry is missing or within 60s of + expiry. The Broker used to be rebuilt per request, so every call redeemed. Sharing it means + repeat calls reuse the minted access tokens and the refresh token stays unchanged. The lock makes + concurrent misses redeem once instead of racing; `on_rotate` re-registers the broker under the + new token's hash so the caller's next request (which sends the rotated token) still hits. + + It also owns the account's one OutNote client, as OutNote recommends for long-lived use: the ids that client keeps in + memory (user, tenant) belong to this account and live and die with this broker, so accounts never share one.""" + + def __init__(self, refresh_token: str, on_rotate: Callable[[str, "SharedBroker"], None]): + super().__init__(refresh_token) + self._lock = threading.Lock() + self._on_rotate = on_rotate + # Per-account values that rarely change (folder roles, chat pictures); shared with the broker's lifetime. + self.cache = AccountCache() + self.note = OutNote(self) + + def __call__(self, client_id, scope="https://graph.microsoft.com/.default"): + with self._lock: + before = self.refresh_token + token = super().__call__(client_id, scope) + if self.refresh_token != before: + self._on_rotate(self.refresh_token, self) + return token diff --git a/src/infrastructure/password_signin/job_store.py b/src/infrastructure/password_signin/job_store.py new file mode 100644 index 0000000..20952a0 --- /dev/null +++ b/src/infrastructure/password_signin/job_store.py @@ -0,0 +1,114 @@ +import asyncio +import hashlib +import secrets +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +from entrance import Secret +from pydantic import SecretStr + +from src.application.abstractions.i_sign_in_store import ISignInStore +from src.application.options.password_sign_in_options import PasswordSignInOptions +from src.domain.errors.rate_limited import RateLimited +from src.infrastructure.password_signin.rate_limiter import RateLimiter +from src.infrastructure.password_signin.sign_in_job import SignInJob + + +class JobStore(ISignInStore): + """In-memory jobs plus the rate limits. One instance per process.""" + + def __init__(self, options: PasswordSignInOptions): + self._options = options + self._jobs: dict[str, SignInJob] = {} + self._by_owner: dict[str, str] = {} + self._lock = threading.Lock() + self._starts = RateLimiter() + self._failures = RateLimiter() + self._otp_hits = RateLimiter() + self._waiters = ThreadPoolExecutor(max_workers=options.max_jobs + 20, thread_name_prefix="pwd-wait") + + @staticmethod + def _hash(*parts: str) -> str: + return hashlib.sha256("\x00".join(p.strip().lower() for p in parts).encode()).hexdigest() + + def start(self, user_id: str, username: str, password: SecretStr) -> SignInJob: + user = self._hash(user_id) + account = self._hash(user_id, username) + if not self._starts.allow(user, self._options.client_limit, 60): + raise RateLimited("Too many sign-in attempts.") + if self._failures.count(account, 600) >= self._options.account_limit: + raise RateLimited("Too many failed attempts for this account. Try again later.") + job = SignInJob(secrets.token_urlsafe(24), user, account, self._failures, self._otp_hits, self._options) + with self._lock: + self._prune() + if len(self._jobs) >= self._options.max_jobs: + raise RateLimited("Too many sign-ins in progress.") + job.replaces = self._by_owner.get(account) + self._jobs[job.id] = job + threading.Thread(target=job.run, args=(username.strip(), Secret(password.get_secret_value())), name=f"pwd-signin-{job.id[:6]}", daemon=True).start() + return job + + async def wait_settled(self, job: SignInJob, after_version: int, timeout: float) -> None: + await asyncio.get_running_loop().run_in_executor(self._waiters, job.wait_settled, after_version, timeout) + + def promote(self, job: SignInJob): + """The password passed: this job becomes the user's current one for the account and replaces their earlier one.""" + with self._lock: + if job.id not in self._jobs: + return + previous = self._jobs.get(job.replaces or "") + if previous is not None and previous.user == job.user: + self._drop(previous) + self._by_owner[job.acct] = job.id + + def get(self, job_id: str, user_id: str) -> SignInJob | None: + """The job, only for the Schuly user that started it (anyone else gets None, like an unknown id).""" + with self._lock: + job = self._jobs.get(job_id) + if job is None or job.user != self._hash(user_id): + return None + if self._expired(job): + self._drop(job) + return None + return job + + def remove(self, job_id: str, user_id: str): + with self._lock: + job = self._jobs.get(job_id) + if job is not None and job.user == self._hash(user_id): + self._drop(job) + + def sweep(self): + with self._lock: + self._prune() + self._starts.sweep(60) + self._failures.sweep(600) + self._otp_hits.sweep(3600) + + def clear(self): + with self._lock: + for job in self._jobs.values(): + job.stop() + self._jobs.clear() + self._by_owner.clear() + self._starts = RateLimiter() + self._failures = RateLimiter() + self._otp_hits = RateLimiter() + + def __len__(self): + return len(self._jobs) + + def _expired(self, job: SignInJob) -> bool: + now = time.monotonic() + return now > job.expires or (job.done_at is not None and now - job.done_at > self._options.done_ttl) + + def _drop(self, job: SignInJob): + job.stop() + self._jobs.pop(job.id, None) + if self._by_owner.get(job.acct) == job.id: + del self._by_owner[job.acct] + + def _prune(self): + for job in [j for j in self._jobs.values() if self._expired(j)]: + self._drop(job) diff --git a/src/infrastructure/password_signin/rate_limiter.py b/src/infrastructure/password_signin/rate_limiter.py new file mode 100644 index 0000000..0d23d06 --- /dev/null +++ b/src/infrastructure/password_signin/rate_limiter.py @@ -0,0 +1,42 @@ +import threading +import time +from collections import deque + + +class RateLimiter: + """Sliding-window counter keyed by an opaque string (never the raw account name).""" + + def __init__(self): + self._hits: dict[str, deque] = {} + self._lock = threading.Lock() + + def allow(self, key: str, limit: int, window: float) -> bool: + now = time.monotonic() + with self._lock: + hits = self._hits.setdefault(key, deque()) + while hits and now - hits[0] > window: + hits.popleft() + if len(hits) >= limit: + return False + hits.append(now) + return True + + def add(self, key: str): + with self._lock: + self._hits.setdefault(key, deque()).append(time.monotonic()) + + def count(self, key: str, window: float) -> int: + now = time.monotonic() + with self._lock: + hits = self._hits.get(key) + if not hits: + return 0 + while hits and now - hits[0] > window: + hits.popleft() + return len(hits) + + def sweep(self, window: float = 600): + now = time.monotonic() + with self._lock: + for key in [k for k, h in self._hits.items() if not h or now - h[-1] > window]: + del self._hits[key] diff --git a/src/infrastructure/password_signin/sign_in_job.py b/src/infrastructure/password_signin/sign_in_job.py new file mode 100644 index 0000000..80c8907 --- /dev/null +++ b/src/infrastructure/password_signin/sign_in_job.py @@ -0,0 +1,235 @@ +"""Microsoft password sign-in with an interactive second factor, as short-lived in-memory jobs. + +The ONE place in this service that receives a Microsoft password. It is handed to Entrance once, +on a worker thread, wrapped in a one-shot `entrance.Secret`; Entrance posts it to Microsoft and +drops it. It is never stored, cached, logged or returned, and never lives on a job: a job only holds +progress (state, methods, the push number, the result). Jobs are in memory only, so the service must +run as ONE process (see asgi.py) - a second worker would not know a job started on the first. + +Entrance runs the sign-in on the worker thread and calls back into the job (an `MfaPrompt`) when +Microsoft asks for MFA; the job blocks there on a condition until the HTTP side supplies the choice +or the code. +""" + +import logging +import threading +import time + +import entrance +from entrance import MfaCancelled, MfaPrompt, MfaSwitch, Secret + +from src.application.abstractions.i_sign_in_job import ISignInJob +from src.application.options.password_sign_in_options import PasswordSignInOptions +from src.domain.sign_in.sign_in_state import SignInState +from src.infrastructure.logging_config import LoggerNames +from src.infrastructure.password_signin.rate_limiter import RateLimiter + + +class SignInJob(MfaPrompt, ISignInJob): + """Progress of one sign-in, and the `MfaPrompt` Entrance calls back into. Holds no password.""" + + OTP_ATTEMPTS = entrance.OTP_ATTEMPTS + MESSAGES = { + "invalid_credentials": "Wrong email or password.", + "unsupported": "This Microsoft account can't be signed in with a password here.", + "blocked": "Microsoft blocked this sign-in because of a security policy.", + "failed": "The Microsoft sign-in failed.", + "otp_exhausted": "Too many wrong codes. Start the sign-in again later.", + } + log = logging.getLogger(LoggerNames.PASSWORD_SIGN_IN) + + def __init__(self, job_id: str, user: str, account: str, failures: RateLimiter, otp_hits: RateLimiter, options: PasswordSignInOptions): + self.id = job_id + self.user = user # hash of the Schuly user id that started the job; only that user may touch it + self.acct = account # hash of (Schuly user, Microsoft account name) + self.replaces: str | None = None # this user's earlier job for the same account, replaced once the password passed + self._failures = failures + self._otp_hits = otp_hits + self._options = options + self.otp_used = 0 + self._budget_out = False + self.expires = time.monotonic() + options.job_ttl + self.done_at: float | None = None + self.cond = threading.Condition() + self.state = SignInState.WORKING + self.version = 0 + self.methods: list[str] = [] + self.notice: str | None = None + self.number: str | None = None + self.push_deadline = 0.0 + self.attempts_left = 0 + self.otp_invalid = False + self.requested: str | None = None + self.code: str | None = None # a one-time authenticator code, taken (cleared) by the worker + self.refresh_token: str | None = None + self.account = None + self.error_code: str | None = None + self.error_message: str | None = None + self.cancel = False + self._retry_notice: str | None = None + + @classmethod + def failure_for(cls, ex: BaseException) -> tuple[str, str]: + """Map a sign-in exception to a stable (code, message). Only the exception TYPE decides; the + exception text is never copied into a response.""" + if isinstance(ex, entrance.InvalidCredentials): + code = "invalid_credentials" + elif isinstance(ex, entrance.OtpRejected): + code = "otp_exhausted" + elif isinstance(ex, entrance.Unsupported): + code = "unsupported" + elif isinstance(ex, entrance.Blocked): + code = "blocked" + else: + code = "failed" + return code, cls.MESSAGES[code] + + # -- state --------------------------------------------------------------------------------- + def _set(self, state: str, **fields): + """Move to a state (caller holds `cond`) and wake every waiter.""" + self.state = state + self.version += 1 + for key, value in fields.items(): + setattr(self, key, value) + self.cond.notify_all() + + def _dead(self) -> bool: + return self.cancel or time.monotonic() > self.expires + + def _wait(self, predicate): + """Block (holding `cond`) until `predicate()` or the job is cancelled / expired.""" + while not predicate(): + if self._dead(): + raise MfaCancelled("Sign-in cancelled") + self.cond.wait(timeout=max(0.05, min(1.0, self.expires - time.monotonic()))) + + def stop(self): + with self.cond: + self.cancel = True + self.cond.notify_all() + + def wait_settled(self, after_version: int, timeout: float) -> bool: + """Wait (from the HTTP side) until a non-transient state newer than `after_version`.""" + deadline = time.monotonic() + timeout + with self.cond: + while not (self.version > after_version and self.state not in SignInState.TRANSIENT): + left = deadline - time.monotonic() + if left <= 0: + return False + self.cond.wait(timeout=left) + return True + + def snapshot(self) -> dict: + """Everything a response needs, taken under the lock. Never contains the password.""" + with self.cond: + snap = {"state": self.state, "version": self.version, "methods": list(self.methods), "notice": self.notice} + if self.state == SignInState.PUSH_PENDING: + snap["number"] = self.number + snap["expires_in"] = max(0, int(self.push_deadline - time.monotonic())) + if self.state in (SignInState.OTP_REQUIRED, SignInState.MFA_REQUIRED) or self.otp_invalid: + snap["attempts_left"] = self.attempts_left + snap["otp_invalid"] = self.otp_invalid + if self.state == SignInState.COMPLETE: + snap["refresh_token"] = self.refresh_token + if self.state == SignInState.ERROR: + snap["code"], snap["message"] = self.error_code, self.error_message + return snap + + # -- actions from the HTTP side -------------------------------------------------------------- + def request_method(self, method: str) -> str: + """Ask for a method. Returns "ok", "same" (already on it), "unavailable" (not offered) or "invalid_state".""" + with self.cond: + if method not in self.methods: + return "unavailable" + if self.state in SignInState.CHOOSABLE or (self.state == SignInState.PUSH_PENDING and method == "otp") or (self.state == SignInState.OTP_REQUIRED and method == "push"): + self.requested = method + self.cond.notify_all() + return "ok" + if (self.state == SignInState.PUSH_PENDING and method == "push") or (self.state == SignInState.OTP_REQUIRED and method == "otp"): + return "same" # already on that method + return "invalid_state" + + def submit_code(self, code: str) -> bool: + with self.cond: + if self.state != SignInState.OTP_REQUIRED or self.code is not None: + return False + self.code = code + self.cond.notify_all() + return True + + # -- MfaPrompt (called by Entrance on the worker thread) ------------------------------------- + def choose(self, methods): + with self.cond: + self.methods = list(methods) + if self.requested not in methods: + notice, self._retry_notice = self._retry_notice, None + state = notice if notice in (SignInState.DENIED, SignInState.TIMED_OUT) else SignInState.MFA_REQUIRED + self._set(state, notice=notice, number=None) + self._wait(lambda: self.requested in self.methods) + method, self.requested = self.requested, None + self._set(SignInState.STARTING_PUSH if method == "push" else SignInState.STARTING_OTP, notice=None, otp_invalid=False) + return method + + def on_push(self, event): + if event.state == entrance.NUMBER_MATCHING: + with self.cond: + self._set(SignInState.PUSH_PENDING, number=event.number, push_deadline=time.monotonic() + (event.expires_in or self._options.push_timeout)) + elif event.state == entrance.APPROVED: + with self.cond: + self._set(SignInState.VERIFYING) + + def should_switch(self): + return self.requested is not None + + def cancelled(self): + return self._dead() + + def otp(self, attempt): + with self.cond: + if self.otp_used >= self._options.otp_job_budget or self._otp_hits.count(self.acct, 3600) >= self._options.otp_account_budget: + self._budget_out = True + raise entrance.OtpRejected("authenticator code budget spent", attempts=self.otp_used) + self.attempts_left = min(self.OTP_ATTEMPTS - attempt + 1, self._options.otp_job_budget - self.otp_used) + self._set(SignInState.OTP_REQUIRED, otp_invalid=attempt > 1) + self._wait(lambda: self.code is not None or self.requested is not None) + if self.code is None: + raise MfaSwitch() + code, self.code = self.code, None + self.otp_used += 1 + self._otp_hits.add(self.acct) + self._set(SignInState.VERIFYING) + return code + + def retry(self, error): + if isinstance(error, entrance.PushError) and error.state in (SignInState.DENIED, SignInState.TIMED_OUT): + self._retry_notice = error.state + return True + if isinstance(error, entrance.OtpRejected): + if self._budget_out or self.otp_used >= self._options.otp_job_budget: + return False # terminal: a fresh sign-in is required + with self.cond: + self.attempts_left = 0 + self.otp_invalid = True + self._retry_notice = "otp_exhausted" + return True + return False + + # -- the worker -------------------------------------------------------------------------------- + def run(self, username: str, secret: Secret): + try: + broker = entrance.foci_login(username, secret, cookies=None, frt_store=None, mfa=self, push_timeout=self._options.push_timeout, push_interval=self._options.push_interval) + with self.cond: + self.done_at = time.monotonic() + self._set(SignInState.COMPLETE, refresh_token=broker.refresh_token) + except MfaCancelled: + self.log.info("password sign-in %s cancelled", self.id) + except BaseException as ex: # noqa: BLE001 - the worker must always end in a terminal state + code, message = self.failure_for(ex) + if code == "invalid_credentials": + self._failures.add(self.acct) # only failed password attempts count against the limiter + self.log.warning("password sign-in %s ended: %s (%s)", self.id, code, type(ex).__name__) + with self.cond: + self.done_at = time.monotonic() + self._set(SignInState.ERROR, error_code=code, error_message=message) + finally: + secret.take() # no-op once submitted; guarantees nothing outlives the call diff --git a/src/infrastructure/teams/chat_reader.py b/src/infrastructure/teams/chat_reader.py new file mode 100644 index 0000000..fa55bc6 --- /dev/null +++ b/src/infrastructure/teams/chat_reader.py @@ -0,0 +1,89 @@ +"""Chat messages as the web renders them: Graph's chatMessage plus `media` (pasted images and +GIFs), `files` (files shared in the chat, with type, size, `kind`, `hasThumbnail` and `webUrl`), `cards` (adaptive cards, polls, +stickers) and `emojis` (Teams and custom emoji, see `IEmojiCatalogue.message_emojis`) pulled out of the body and attachments. + +A file's type and size are not on the attachment; they come from the drive item behind its +sharing URL (`shares/u!{url}/driveItem`), looked up in one `$batch` per page and cached per account. +""" + +import logging + +from src.application.abstractions.i_chat_reader import IChatReader +from src.application.abstractions.i_emoji_catalogue import IEmojiCatalogue +from src.application.abstractions.i_file_thumbnails import IFileThumbnails +from src.application.services.chat_html_sanitizer import ChatHtmlSanitizer +from src.application.services.graph_paths import GraphPaths +from src.infrastructure.microsoft.account_cache import AccountCache + + +class ChatReader(IChatReader): + """Chat messages as the web renders them: Graph's chatMessage plus media, files, cards and emojis.""" + + log = logging.getLogger("schuly.microsoft") + FILE_META_TTL = 3600 + + def __init__(self, html: ChatHtmlSanitizer, emojis: IEmojiCatalogue, thumbnails: IFileThumbnails): + self._html = html + self._emojis = emojis + self._thumbnails = thumbnails + + def file_meta(self, note, broker, urls: list[str]) -> dict[str, dict]: + """contentUrl -> {contentType, size} (None values when the item can't be read).""" + cache = AccountCache.for_broker(broker) + out, todo = {}, [] + for url in dict.fromkeys(urls): + hit = cache.get(("file_meta", url)) + if hit is not None: + out[url] = hit + else: + todo.append(url) + if todo: + try: + results = note.batch([GraphPaths.drive_item_path(u, "size,file") for u in todo]) + except Exception: + results = [None] * len(todo) + for url, item in zip(todo, results): + if isinstance(item, dict): + meta = {"contentType": (item.get("file") or {}).get("mimeType"), "size": item.get("size")} + cache.set(("file_meta", url), meta, self.FILE_META_TTL) + else: + meta = {"contentType": None, "size": None} + out[url] = meta + return out + + def enrich(self, note, broker, messages: list[dict]) -> list[dict]: + files_by_message = {} + urls = [] + for m in messages: + files = self._html.reference_files(m) + files_by_message[id(m)] = files + urls += [f["contentUrl"] for f in files] + meta = self.file_meta(note, broker, urls) if urls else {} + cat = self._catalogue() if any("emoji" in ((m.get("body") or {}).get("content") or "").lower() for m in messages) else None + for m in messages: + m["media"] = self._html.media(m) + m["files"] = [self._file_view({**f, **meta.get(f["contentUrl"], {"contentType": None, "size": None})}) for f in files_by_message[id(m)]] + body = m.get("body") + if isinstance(body, dict) and isinstance(body.get("content"), str): + content, m["emojis"] = self._emojis.message_emojis(body["content"], cat) + if m["emojis"]: + m["body"] = {**body, "content": content} + else: + m["emojis"] = [] + m["cards"] = self._html.cards(m) + m.setdefault("importance", "normal") + return messages + + def _catalogue(self): + # Emoji pictures work from the id alone, so a catalogue that can't be loaded only costs the frame counts. + try: + return self._emojis.catalogue("default") + except Exception as ex: + self.log.info("emoji catalogue unavailable: %s", type(ex).__name__) + return None + + def _file_view(self, f: dict) -> dict: + """A chat file with its `kind`, `hasThumbnail` and `webUrl` (the SharePoint / OneDrive link, only when it is one).""" + kind = self._thumbnails.file_kind(f.get("name"), f.get("contentType")) + shared = self._thumbnails.is_share_url(f.get("contentUrl")) + return {**f, "kind": kind, "hasThumbnail": self._thumbnails.has_thumbnail(f.get("contentUrl"), kind), "webUrl": f["contentUrl"] if shared else None} diff --git a/src/infrastructure/teams/chat_sender.py b/src/infrastructure/teams/chat_sender.py new file mode 100644 index 0000000..2babdef --- /dev/null +++ b/src/infrastructure/teams/chat_sender.py @@ -0,0 +1,198 @@ +"""Posting a chat message the way Teams does: sanitized HTML, @mentions, inline images as hosted +contents, a GIF, and files shared through the sender's OneDrive. + +Files follow the Teams client: upload to `Microsoft Teams Chat Files/` in the sender's OneDrive +(an upload session in 320 KiB-multiple chunks above 4 MB), grant the chat members read access +without an e-mail, then reference the file in the message (`` + a +`reference` attachment whose id is the GUID from the drive item's eTag). +""" + +import base64 +import re +from urllib.parse import quote +from entrance import http_client + +from src.application.abstractions.i_chat_sender import IChatSender +from src.application.abstractions.i_emoji_catalogue import IEmojiCatalogue +from src.application.abstractions.i_profile_reader import IProfileReader +from src.application.services.chat_html_sanitizer import ChatHtmlSanitizer +from src.application.services.image_sniffer import ImageSniffer +from src.domain.chat.chat_send_limits import ChatSendLimits +from src.domain.chat.outgoing_message import OutgoingMessage +from src.domain.chat.upload import Upload +from src.domain.errors.emoji_error import EmojiError +from src.domain.errors.send_error import SendError + + +class ChatSender(IChatSender): + """Posting a chat message the way Teams does: sanitized HTML, mentions, hosted images, a GIF and shared files.""" + + MAX_CONTENT_CHARS = 28000 # Graph's chat message body limit is about 28 KB + MAX_IMAGES_BYTES = 3 * 1024 * 1024 # about 4 MB once base64-encoded, Graph's request limit + MAX_IMAGES = ChatSendLimits.MAX_IMAGES + MAX_FILE_BYTES = 100 * 1024 * 1024 + MAX_FILES = ChatSendLimits.MAX_FILES + MAX_MENTIONS = 20 + SIMPLE_UPLOAD_MAX = 4 * 1024 * 1024 + UPLOAD_CHUNK = 10 * 320 * 1024 # upload sessions take chunks in multiples of 320 KiB + CHAT_FILES_FOLDER = "Microsoft Teams Chat Files" + SELF_CHAT = "48:notes" + IMPORTANCE = ("normal", "high", "urgent") + GUID_IN_ETAG = re.compile(r"\{([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12})\}") + USER_ID = re.compile(r"[A-Za-z0-9@._-]{1,200}") + http_client = staticmethod(http_client) + + def __init__(self, html: ChatHtmlSanitizer, emojis: IEmojiCatalogue, profile: IProfileReader): + self._html = html + self._emojis = emojis + self._profile = profile + + def safe_name(self, name: str | None) -> str: + cleaned = re.sub(r'[\x00-\x1f\x7f\\/:*?"<>|#%]', "_", name or "") + return cleaned.strip(" .")[:200] or "file" + + def validate(self, msg: OutgoingMessage) -> OutgoingMessage: + if msg.content_type not in ("text", "html"): + raise SendError(400, "contentType must be text or html.") + if len(msg.content) > self.MAX_CONTENT_CHARS: + raise SendError(413, f"The message may be at most {self.MAX_CONTENT_CHARS} characters.") + if msg.importance is not None and msg.importance not in self.IMPORTANCE: + raise SendError(400, "importance must be normal, high or urgent.") + if len(msg.mentions) > self.MAX_MENTIONS: + raise SendError(400, f"At most {self.MAX_MENTIONS} mentions are allowed.") + clean = [] + for m in msg.mentions: + user = ((m or {}).get("mentioned") or {}).get("user") or {} + mid, uid, text = (m or {}).get("id"), user.get("id"), (m or {}).get("mentionText") + if not isinstance(mid, int) or not 0 <= mid < self.MAX_MENTIONS or not isinstance(uid, str) or not self.USER_ID.fullmatch(uid) or not isinstance(text, str) or not 0 < len(text) <= 200: + raise SendError(400, "Invalid mention.") + clean.append({"id": mid, "mentionText": text, "mentioned": {"user": {"id": uid, "displayName": str(user.get("displayName") or text)[:200], "userIdentityType": "aadUser"}}}) + msg.mentions = clean + if msg.gif is not None: + url = msg.gif.get("url") if isinstance(msg.gif, dict) else None + if not isinstance(url, str) or len(url) > 2000 or not self._html.is_gif_host(url): + raise SendError(400, "gif.url must be an https Giphy or Tenor media URL.") + if len(msg.images) > self.MAX_IMAGES: + raise SendError(400, f"At most {self.MAX_IMAGES} images are allowed.") + if sum(i.size for i in msg.images) > self.MAX_IMAGES_BYTES: + raise SendError(413, "Inline images may total at most 3 MB; send larger ones as files.") + if len(msg.files) > self.MAX_FILES: + raise SendError(400, f"At most {self.MAX_FILES} files are allowed.") + for f in msg.files: + if f.size > self.MAX_FILE_BYTES: + raise SendError(413, "A file may be at most 100 MB.") + if not msg.content.strip() and not msg.gif and not msg.images and not msg.files: + raise SendError(400, "The message is empty.") + return msg + + def _hosted_contents(self, images: list[Upload]) -> list[dict]: + out = [] + for n, image in enumerate(images, start=1): + data = image.file.read(self.MAX_IMAGES_BYTES + 1) + mime = ImageSniffer.chat_image(data[:12]) + if mime is None: + raise SendError(415, "Inline images must be PNG, JPEG, GIF or WEBP.") + out.append({"@microsoft.graph.temporaryId": str(n), "contentBytes": base64.b64encode(data).decode(), "contentType": mime}) + return out + + def payload(self, msg: OutgoingMessage, attachments: list[dict] | None = None) -> dict: + """The Graph chatMessage body for `msg` (files already uploaded into `attachments`).""" + attachments = attachments or [] + rich = msg.content_type == "html" or msg.mentions or msg.gif or msg.images or attachments + if not rich: + body = {"contentType": "text", "content": msg.content} + mentions = [] + else: + image_ids = set(range(1, len(msg.images) + 1)) + if msg.content_type == "html": + try: + content, used_mentions, used_images = self._html.sanitize(msg.content, {m["id"] for m in msg.mentions}, image_ids, self._emojis.for_send) + except EmojiError as ex: + raise SendError(503, f"Emoji can't be sent right now: {ex}") from None + else: + content, used_mentions, used_images = self._html.text_to_html(msg.content), set(), set() + # Graph refuses a mention without its tag, so mentions the body doesn't reference are dropped. + mentions = [m for m in msg.mentions if m["id"] in used_mentions] + parts = [content] if content else [] + parts += [f'

' for n in sorted(image_ids - used_images)] + if msg.gif: + parts.append(self._html.gif_html(msg.gif["url"], msg.gif.get("alt"), self._dim(msg.gif.get("width")), self._dim(msg.gif.get("height")))) + parts += [f'' for a in attachments] + body = {"contentType": "html", "content": "".join(parts)} + out = {"body": body} + if mentions: + out["mentions"] = mentions + if msg.importance: + out["importance"] = msg.importance + if msg.images: + out["hostedContents"] = self._hosted_contents(msg.images) + if attachments: + out["attachments"] = attachments + return out + + def _dim(self, value) -> int | None: + return value if isinstance(value, int) and 0 < value <= 4096 else None + + def upload_file(self, note, upload: Upload) -> dict: + """Uploads into the sender's `Microsoft Teams Chat Files` folder (renaming on a clash); returns the driveItem.""" + target = f"me/drive/root:/{quote(self.CHAT_FILES_FOLDER, safe='')}/{quote(self.safe_name(upload.name), safe='')}:" + if upload.size <= self.SIMPLE_UPLOAD_MAX: + data = upload.file.read(self.SIMPLE_UPLOAD_MAX + 1) + return note.put(f"{target}/content?@microsoft.graph.conflictBehavior=rename", data, content_type=upload.content_type or "application/octet-stream") + session = note.post(f"{target}/createUploadSession", {"item": {"@microsoft.graph.conflictBehavior": "rename"}}) + url = session["uploadUrl"] + client = self.http_client() + sent, item = 0, None + try: + while sent < upload.size: + chunk = upload.file.read(min(self.UPLOAD_CHUNK, upload.size - sent)) + if not chunk: + raise SendError(400, "The file ended before its declared size.") + # The session URL is pre-authorized; it must not carry the Graph bearer token. + r = client.request("PUT", url, content=chunk, headers={"Content-Length": str(len(chunk)), "Content-Range": f"bytes {sent}-{sent + len(chunk) - 1}/{upload.size}"}, timeout=120) + if r.status_code >= 400: + raise SendError(502, f"OneDrive refused the upload ({r.status_code}).") + sent += len(chunk) + if r.status_code in (200, 201): + item = r.json() + except BaseException: + try: + client.request("DELETE", url, timeout=15) + except Exception: + pass + raise + if not item: + raise SendError(502, "OneDrive did not confirm the upload.") + return item + + def chat_member_ids(self, note, broker, chat_id: str) -> list[str]: + if chat_id == self.SELF_CHAT: + return [] + me = self._profile.get(note, broker).get("id") + ids = [] + for m in note.list(f"chats/{chat_id}/members"): + uid = m.get("userId") + if isinstance(uid, str) and uid != me and uid not in ids: + ids.append(uid) + return ids + + def share_with(self, note, item_id: str, member_ids: list[str]): + if not member_ids: + return + note.post(f"me/drive/items/{quote(item_id, safe='')}/invite", {"recipients": [{"objectId": uid} for uid in member_ids], "roles": ["read"], "requireSignIn": True, "sendInvitation": False}) + + def reference_attachment(self, item: dict) -> dict: + m = self.GUID_IN_ETAG.search(item.get("eTag") or "") + if not m or not item.get("webUrl"): + raise SendError(502, "OneDrive returned an item without an eTag GUID or webUrl.") + return {"id": m.group(1).lower(), "contentType": "reference", "contentUrl": item["webUrl"], "name": item.get("name")} + + def send(self, note, broker, chat_id: str, msg: OutgoingMessage) -> dict: + attachments = [] + if msg.files: + members = self.chat_member_ids(note, broker, chat_id) + for upload in msg.files: + item = self.upload_file(note, upload) + self.share_with(note, item["id"], members) + attachments.append(self.reference_attachment(item)) + return note.post(f"chats/{quote(chat_id, safe=':@.=-_')}/messages", self.payload(msg, attachments)) diff --git a/src/infrastructure/teams/emoji_catalogue.py b/src/infrastructure/teams/emoji_catalogue.py new file mode 100644 index 0000000..9106af7 --- /dev/null +++ b/src/infrastructure/teams/emoji_catalogue.py @@ -0,0 +1,309 @@ +"""Teams emojis (the animated "personal expressions") and tenant custom emojis. + +Teams emoji +----------- +Graph returns a Teams emoji in a chat message body as `` +(some clients send ``; both are read). The picture +is served by Teams' public CDN, per emoji id and size: + + {ASSET_BASE}/{id}/default/{size}_f.png still (size x size) + {ASSET_BASE}/{id}/default/{size}_anim_f.png vertical sprite strip, `frames` square frames, played at 24 fps + ... _f_s{tone+1}.png skin tone variants (`{id}-tone{1..5}` in markup) + +Sizes: 20, 30, 40, 50, 60, 100. The catalogue (categories, ids, shortcuts, unicode, localized titles, frame counts) is +the JSON Teams loads itself: `{METADATA_BASE}/{version}/{locale}.json`, falling back to `default.json`. It is public, +so no token is involved; it is cached here for 24 hours per locale. + +Custom emoji +------------ +A tenant's own emoji live in the Teams media service (AMS) and need the skype token, so they are never linked +directly: a message carries `kind: "custom"` and a `documentId`, and the image comes from the proxy route. +The tenant's custom emoji list is a Graph beta API (`TeamworkCustomEmoji.Read`) that the first-party clients +used here are not granted, so the catalogue carries `custom: []`. +""" + +import html +import re +import threading +import time +from urllib.parse import urlsplit +from entrance import http_client + +from src.application.abstractions.i_emoji_catalogue import IEmojiCatalogue +from src.application.options.emoji_options import EmojiOptions +from src.domain.chat.catalogue import Catalogue +from src.domain.chat.emoji_assets import EmojiAssets +from src.domain.errors.emoji_error import EmojiError + + +class EmojiCatalogue(IEmojiCatalogue): + """Teams' public emoji catalogue (cached 24 hours), message emoji parsing and the send allowlist.""" + + CDN_HOSTS = frozenset({"statics.teams.cdn.office.net", "teams.public.onecdn.static.microsoft"}) + ASSET_BASE = EmojiAssets.ASSET_BASE + METADATA_BASE = "https://statics.teams.cdn.office.net/evergreen-assets/personal-expressions/v1/metadata" + TEAMS_PAGE = "https://teams.microsoft.com/v2/" + SIZES = EmojiAssets.SIZES + INLINE_SIZE = 30 + BIG_SIZE = 50 # a message that is only emoji + FPS = EmojiAssets.FPS + CATALOGUE_TTL = 24 * 3600 + MAX_LOCALES = 24 + MAX_METADATA_BYTES = 4 * 1024 * 1024 + MAX_PAGE_BYTES = 2 * 1024 * 1024 + FETCH_TIMEOUT = 8.0 + UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36" + EMOJI_ID = re.compile(r"[a-z0-9_]{1,64}") + EMOJI_REF = re.compile(r"([a-z0-9_]{1,64})(?:-tone([1-5]))?") + LOCALE = re.compile(r"[a-z]{2,3}(?:-[a-z0-9]{2,8}){0,2}") + DOCUMENT_ID = re.compile(r"[A-Za-z0-9_-]{1,120}") + SHORTCUT = re.compile(r"[a-z0-9_-]{1,50}") + AMS_PATH = re.compile(r"/v1/objects/([A-Za-z0-9_-]{1,120})/views/") + AMS_SUFFIXES = (".asyncgw.teams.microsoft.com", ".asm.skype.com") + MAX_ALT = 40 + MAX_TITLE = 100 + _EMOJI_TAG = re.compile(r"]*)>(?:\s*)?", re.IGNORECASE) + _EMOJI_IMG = re.compile(r"]*\bitemtype\s*=\s*[\"']http://schema\.skype\.com/(?:Custom)?Emoji[\"'][^>]*>", re.IGNORECASE) + _EMOJI_ANY = re.compile(_EMOJI_TAG.pattern + "|" + _EMOJI_IMG.pattern, re.IGNORECASE) + _READONLY = re.compile(r"]*>\s*(]*>)\s*", re.IGNORECASE) + _ATTR = re.compile(r"""([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')""") + _TAG = re.compile(r"<[^>]*>") + _EMOJI_OUT = re.compile(r"]*>(?:[^<]*)?") + http_client = staticmethod(http_client) + _clock = staticmethod(time.monotonic) + + def __init__(self, options: EmojiOptions): + self._cache: dict[str, tuple[float, Catalogue]] = {} + self._lock = threading.Lock() + self._version = {"value": options.version} + + def asset_url(self, emoji_id: str, size: int, animated: bool, tone: int = 0, etag: str | None = None) -> str: + suffix = f"_s{tone + 1}" if tone else "" + query = f"?v={etag}" if etag and re.fullmatch(r"[A-Za-z0-9._-]{1,20}", etag) else "" + return f"{self.ASSET_BASE}/{emoji_id}/default/{size}{'_anim' if animated else ''}_f{suffix}.png{query}" + + def is_cdn_url(self, url: str) -> bool: + try: + parts = urlsplit(url or "") + port = parts.port + except ValueError: + return False + return parts.scheme == "https" and port in (None, 443) and not parts.username and not parts.password and (parts.hostname or "").lower() in self.CDN_HOSTS + + def parse_ref(self, value) -> tuple[str, int] | None: + """(base id, skin tone 0-5) for a Teams emoji id such as `laugh` or `yes-tone3`, else None.""" + m = self.EMOJI_REF.fullmatch(value) if isinstance(value, str) else None + return (m.group(1), int(m.group(2) or 0)) if m else None + + def normalize_locale(self, value: str | None) -> str | None: + """`default` or a lowercase locale such as `de-de`; None when it is not shaped like one.""" + value = (value or "").strip().lower().replace("_", "-") + if value in ("", "default"): + return "default" + return value if self.LOCALE.fullmatch(value) else None + + def _json(self, url: str): + r = self.http_client().request("GET", url, timeout=self.FETCH_TIMEOUT) + if r.status_code == 404: + return None + if r.status_code != 200: + raise EmojiError(502, f"Teams emoji metadata {r.status_code}") + if len(r.content) > self.MAX_METADATA_BYTES: + raise EmojiError(502, "Teams emoji metadata is too large") + try: + return r.json() + except ValueError: + raise EmojiError(502, "Teams emoji metadata is not JSON") from None + + def _discover_version(self) -> str | None: + """The asset version Teams' own web client is currently using (its config bundle names it), or None.""" + try: + page = self.http_client().request("GET", self.TEAMS_PAGE, headers={"User-Agent": self.UA}, timeout=self.FETCH_TIMEOUT, follow_redirects=True) + if page.status_code != 200 or len(page.content) > self.MAX_PAGE_BYTES: + return None + m = re.search(r"//([\w.-]+)/teams-modular-packages/hashed-assets/(config-prod-[0-9a-f]+\.js)", page.text) + if not m or m.group(1).lower() not in self.CDN_HOSTS: + return None + bundle = self.http_client().request("GET", f"https://{m.group(1).lower()}/teams-modular-packages/hashed-assets/{m.group(2)}", timeout=self.FETCH_TIMEOUT, follow_redirects=True) + if bundle.status_code != 200 or len(bundle.content) > 8 * self.MAX_PAGE_BYTES: + return None + v = re.search(r'emoticonAssetVersion:\[\{value:"([0-9a-f]{32})"', bundle.text) + return v.group(1) if v else None + except Exception: + return None + + def _candidates(self, locale: str) -> list[str]: + names = [locale] + if locale != "default" and "-" not in locale: + names.append(f"{locale}-{locale}") + if locale != "default": + names.append("default") + return names + + def _fetch(self, locale: str) -> Catalogue: + for attempt in range(2): + version = self._version["value"] + for name in self._candidates(locale): + raw = self._json(f"{self.METADATA_BASE}/{version}/{name}.json") + if isinstance(raw, dict) and isinstance(raw.get("categories"), list): + return self._build(raw, name, version) + found = self._discover_version() if attempt == 0 else None + if not found or found == version: + break + self._version["value"] = found + raise EmojiError(502, "The Teams emoji catalogue is unavailable") + + def _strings(self, values, count: int, length: int) -> list[str]: + return [v[:length] for v in (values or [])[:count] if isinstance(v, str)] if isinstance(values, list) else [] + + def _item(self, e) -> dict | None: + if not isinstance(e, dict) or not isinstance(e.get("id"), str) or not self.EMOJI_ID.fullmatch(e["id"]): + return None + anim = e.get("animation") if isinstance(e.get("animation"), dict) else {} + frames = anim.get("framesCount") if isinstance(anim.get("framesCount"), int) and 0 < anim.get("framesCount") <= 1000 else 1 + fps = anim.get("fps") if isinstance(anim.get("fps"), (int, float)) and 0 < anim.get("fps") <= 120 else self.FPS + first = anim.get("firstFrame") if isinstance(anim.get("firstFrame"), int) and 1 <= anim.get("firstFrame") <= frames else 1 + etag = e.get("etag") if isinstance(e.get("etag"), str) else None + animated = frames > 1 + return { + "id": e["id"], + "shortcuts": self._strings(e.get("shortcuts"), 20, 40), + "unicode": str(e.get("unicode") or "")[:32], + "title": str(e.get("description") or "")[:self.MAX_TITLE], + "keywords": self._strings(e.get("keywords"), 30, 30), + "diverse": bool(e.get("diverse")), + "animated": animated, + "staticUrl": self.asset_url(e["id"], self.INLINE_SIZE, False, 0, etag), + "animatedUrl": self.asset_url(e["id"], self.INLINE_SIZE, True, 0, etag) if animated else None, + "frames": frames, + "frameMs": round(1000 / fps, 2) if animated else None, + "firstFrame": first, + "etag": etag, + } + + def _build(self, raw: dict, locale: str, version: str) -> Catalogue: + cat = Catalogue(locale, version) + for c in raw["categories"]: + if not isinstance(c, dict): + continue + items = [] + for e in c.get("emoticons") or []: + item = self._item(e) + if item and item["id"] not in cat.by_id: + items.append(item) + cat.by_id[item["id"]] = item + cat.categories.append({"id": str(c.get("id") or "")[:100], "title": str(c.get("title") or "")[:100], "emojis": items}) + if not cat.by_id: + raise EmojiError(502, "The Teams emoji catalogue is empty") + return cat + + def catalogue(self, locale: str = "default") -> Catalogue: + """The catalogue for `locale` (already normalized), cached 24 hours; concurrent misses fetch once.""" + hit = self._cache.get(locale) + if hit and hit[0] > self._clock(): + return hit[1] + with self._lock: + hit = self._cache.get(locale) + if hit and hit[0] > self._clock(): + return hit[1] + cat = self._fetch(locale) + self._cache.pop(locale, None) + self._cache[locale] = (self._clock() + self.CATALOGUE_TTL, cat) + while len(self._cache) > self.MAX_LOCALES: + del self._cache[next(iter(self._cache))] + return cat + + def cached_catalogue(self, locale: str = "default") -> Catalogue | None: + hit = self._cache.get(locale) + return hit[1] if hit and hit[0] > self._clock() else None + + def clear_cache(self): + with self._lock: + self._cache.clear() + + def for_send(self, ref) -> tuple[str, str] | None: + """(alt, title) for an emoji id the catalogue knows (skin tones only on diverse ones), else None. Raises EmojiError if the catalogue can't be loaded.""" + parsed = self.parse_ref(ref) + if not parsed: + return None + item = self.catalogue("default").by_id.get(parsed[0]) + if not item or (parsed[1] and not item["diverse"]): + return None + return item["unicode"], item["title"] + + def _attrs(self, tag: str) -> dict[str, str]: + return {m.group(1).lower(): html.unescape(m.group(2) if m.group(2) is not None else m.group(3)) for m in self._ATTR.finditer(tag)} + + def _custom_document(self, emoji_id: str, src: str) -> tuple[str, str] | None: + """(shortcut, document id) of a custom emoji: its id is `shortcut;documentId`, or the AMS object in its picture URL.""" + shortcut = "" + if ";" in emoji_id: + shortcut, _, doc = emoji_id.partition(";") + if self.DOCUMENT_ID.fullmatch(doc) and ".." not in doc: + return (shortcut if self.SHORTCUT.fullmatch(shortcut) else "", doc) + return None + try: + parts = urlsplit(src or "") + host = (parts.hostname or "").lower() + except ValueError: + return None + m = self.AMS_PATH.search(parts.path or "") + if m and parts.scheme == "https" and any(host.endswith(s) for s in self.AMS_SUFFIXES) and ".." not in m.group(1): + return (emoji_id if self.SHORTCUT.fullmatch(emoji_id or "") else "", m.group(1)) + return None + + def message_emojis(self, content: str, cat: Catalogue | None = None) -> tuple[str, list[dict]]: + """(content with each emoji turned into `...` (the alt as text, so clients that strip the tag still show the emoji), the emoji list). + + Entries are `{key, id, alt, title, kind: "teams"|"custom", animated, staticUrl, animatedUrl, frames, frameMs, size, firstFrame, documentId}`; + one entry per distinct emoji, however often it repeats. `size` is the sprite width to use: 50 for a message that is only emoji, else 30. + Anything that looks like an emoji but is neither a valid Teams id nor a custom one is replaced by its alt text.""" + if not content or "emoji" not in content.lower(): + return content, [] + content = self._READONLY.sub(lambda m: m.group(1), content) + entries: dict[tuple[str, str], dict] = {} + + def replace(match): + tag = match.group(0) + a = self._attrs(tag) + raw_id = (a.get("id") if tag[:2].lower() == "{html.escape(e["alt"])}' + + content = self._EMOJI_ANY.sub(replace, content) + if not entries: + return content, [] + lowered = content.lower() + bare = html.unescape(self._TAG.sub("", self._EMOJI_OUT.sub("", content))).replace(chr(160), "") + only_emoji = "`, and the +image is served by AMS (the async media gateway). Both accept a skype token, which the Teams auth +service issues for a token to `api.spaces.skype.com`: + +1. broker(Teams client, https://api.spaces.skype.com/.default) -> access token (FOCI redeem) +2. POST https://teams.microsoft.com/api/authsvc/v1.0/authz (Bearer) -> skypeToken, regionGtms.chatService +3. GET {chatService}/v1/users/ME/conversations/{chatId}?view=msnp24Equivalent (Authentication: skypetoken=...) +4. GET (Authorization: skype_token ...) -> image bytes + +The skype token and each chat's picture URL are cached per account (see `AccountCache`). +""" + +import time +from concurrent.futures import ThreadPoolExecutor, wait +from urllib.parse import quote, urlsplit +import httpx +from entrance import TokenError, http_client + +from src.application.abstractions.i_teams_chat import ITeamsChat +from src.application.abstractions.i_microsoft_http_clients import IMicrosoftHttpClients +from src.domain.errors.chat_service_error import ChatServiceError +from src.infrastructure.microsoft.account_cache import AccountCache + + +class TeamsChatClient(ITeamsChat): + """Group chat pictures and custom emoji over the Teams chat service.""" + + TEAMS_CLIENT = "1fec8e78-bce4-4aaf-ab1b-5451cc387264" # Microsoft Teams, a FOCI family client + SPACES_SCOPE = "https://api.spaces.skype.com/.default" + AUTHZ_URL = "https://teams.microsoft.com/api/authsvc/v1.0/authz" + # Picture URLs come from chat data any member can change, so only Microsoft's media hosts are fetched (the skype token rides on that request). + PICTURE_HOSTS = (".teams.microsoft.com", ".asm.skype.com") + PICTURE_TTL = 3600 + SESSION_MARGIN = 120 + SESSION_RETRY = 600 # after a refused sign-in to Teams, the list stops asking for a while + MAX_PICTURE_BYTES = 5 * 1024 * 1024 + LOOKUP_WORKERS = 8 + LOOKUP_BUDGET = 4.0 # seconds the chat list waits for uncached picture lookups + _TIMEOUT = 15.0 + _STREAM_TIMEOUT = httpx.Timeout(15.0, connect=5.0) + CUSTOM_EMOJI_VIEWS = {True: "imgt2_anim", False: "img_thumb_tiny"} + MAX_EMOJI_BYTES = 2 * 1024 * 1024 + http_client = staticmethod(http_client) + + def __init__(self, http: IMicrosoftHttpClients): + self._http = http + + def _session(self, broker) -> tuple[str, str]: + """(skype token, chat service base URL) for the account, minted once per token lifetime.""" + cache = AccountCache.for_broker(broker) + hit = cache.get("teams_session") + if hit: + return hit + with cache.lock("teams_session"): + hit = cache.get("teams_session") + if hit: + return hit + failed = cache.get("teams_session_failed") + if failed: + raise ChatServiceError(failed, "Teams sign-in refused recently") + try: + return self._new_session(broker, cache) + except ChatServiceError as ex: + if ex.status in (401, 403): + cache.set("teams_session_failed", ex.status, self.SESSION_RETRY) + raise + + def _new_session(self, broker, cache) -> tuple[str, str]: + try: + bearer = broker(self.TEAMS_CLIENT, self.SPACES_SCOPE) + except TokenError as ex: + if ex.error == "invalid_grant": + raise + # The tenant or client may not allow the Teams resource; that is a missing feature, not an expired sign-in. + raise ChatServiceError(403, ex.error) from None + r = self.http_client().post(self.AUTHZ_URL, headers={"Authorization": f"Bearer {bearer}"}, content=b"", timeout=self._TIMEOUT) + if r.status_code != 200: + raise ChatServiceError(r.status_code, "authz refused") + body = r.json() + skype = (body.get("tokens") or {}).get("skypeToken") + service = ((body.get("regionGtms") or {}).get("chatService") or "").rstrip("/") + if not skype or not service.startswith("https://"): + raise ChatServiceError(502, "authz returned no skype token or chat service") + lifetime = int((body.get("tokens") or {}).get("expiresIn") or 3600) + session = (skype, service) + ams = self._media_host((body.get("regionGtms") or {}).get("amsV2") or (body.get("regionGtms") or {}).get("ams")) + cache.set("teams_ams", ams, max(60, lifetime - self.SESSION_MARGIN)) + cache.set("teams_session", session, max(60, lifetime - self.SESSION_MARGIN)) + return session + + def _drop_session(self, broker): + # A skype token the chat service no longer accepts; the next call mints a fresh one. + AccountCache.for_broker(broker).set("teams_session", None, 0) + + def _media_host(self, value) -> str | None: + """The media service base URL when it is an https Microsoft media host, else None.""" + if not isinstance(value, str): + return None + parts = urlsplit(value) + host = (parts.hostname or "").lower() + return f"https://{host}" if parts.scheme == "https" and parts.port in (None, 443) and any(host.endswith(h) for h in self.PICTURE_HOSTS) else None + + def _picture_url(self, value) -> str | None: + if not isinstance(value, str) or not value.startswith("URL@"): + return None + url = value[4:] + parts = urlsplit(url) + host = (parts.hostname or "").lower() + if parts.scheme != "https" or not any(host.endswith(h) for h in self.PICTURE_HOSTS): + return None + return url + + def picture_url(self, broker, chat_id: str) -> str | None: + """The chat's custom picture URL, or None when it has none (or the chat service doesn't know the chat).""" + cache = AccountCache.for_broker(broker) + key = ("chat_picture", chat_id) + marker = object() + hit = cache.get(key, marker) + if hit is not marker: + return hit + skype, service = self._session(broker) + r = self.http_client().request("GET", f"{service}/v1/users/ME/conversations/{quote(chat_id, safe='')}?view=msnp24Equivalent", headers={"Authentication": f"skypetoken={skype}"}, timeout=self._TIMEOUT) + if r.status_code == 404: + url = None + elif r.status_code != 200: + if r.status_code == 401: + self._drop_session(broker) + raise ChatServiceError(r.status_code, "conversation lookup failed") + else: + try: + url = self._picture_url((r.json().get("threadProperties") or {}).get("picture")) + except (ValueError, AttributeError): + url = None + cache.set(key, url, self.PICTURE_TTL) + return url + + def picture(self, broker, chat_id: str) -> bytes | None: + """The chat's custom picture bytes, or None when it has none. A skype token the service stopped accepting is replaced once.""" + try: + return self._download(broker, chat_id) + except ChatServiceError as ex: + if ex.status != 401: + raise + return self._download(broker, chat_id) + + def _download(self, broker, chat_id: str) -> bytes | None: + url = self.picture_url(broker, chat_id) + if not url: + return None + skype, _ = self._session(broker) + with self._http.download_client().stream("GET", url, headers={"Authorization": f"skype_token {skype}"}, timeout=self._STREAM_TIMEOUT, follow_redirects=False) as r: + if r.status_code == 404: + AccountCache.for_broker(broker).set(("chat_picture", chat_id), None, self.PICTURE_TTL) + return None + if r.status_code != 200: + if r.status_code == 401: + self._drop_session(broker) + raise ChatServiceError(r.status_code, "picture download failed") + data = bytearray() + for chunk in r.iter_bytes(): + data.extend(chunk) + if len(data) > self.MAX_PICTURE_BYTES: + raise ChatServiceError(413, "picture larger than 5 MB") + return bytes(data) + + def mark_images(self, broker, chats: list[dict], budget: float = LOOKUP_BUDGET) -> list[dict]: + """Sets `hasImage` on each Graph chat. Only group chats can carry a custom picture; uncached ones are looked up in parallel within `budget` seconds, and a lookup that fails or runs late reads as False (it is cached once it lands).""" + cache = AccountCache.for_broker(broker) + found: dict[str, bool] = {} + todo = [] + for chat in chats: + cid = chat.get("id") + if chat.get("chatType") != "group" or not isinstance(cid, str): + found[cid] = False + continue + marker = object() + hit = cache.get(("chat_picture", cid), marker) + if hit is marker: + todo.append(cid) + else: + found[cid] = bool(hit) + if todo: + pool = ThreadPoolExecutor(max_workers=min(self.LOOKUP_WORKERS, len(todo))) + futures = {pool.submit(self.picture_url, broker, cid): cid for cid in todo} + deadline = time.monotonic() + budget + done, _ = wait(futures, timeout=max(0.0, deadline - time.monotonic())) + pool.shutdown(wait=False) + for future, cid in futures.items(): + found[cid] = future in done and future.exception() is None and bool(future.result()) + for chat in chats: + chat["hasImage"] = found.get(chat.get("id"), False) + return chats + + def custom_emoji(self, broker, document_id: str, animated: bool) -> bytes | None: + """A tenant custom emoji's picture from the Teams media service (`document_id` already validated), or None when it is gone. An animated view is a real GIF/WEBP, not a sprite.""" + try: + return self._custom_emoji(broker, document_id, animated) + except ChatServiceError as ex: + if ex.status != 401: + raise + return self._custom_emoji(broker, document_id, animated) + + def _custom_emoji(self, broker, document_id: str, animated: bool) -> bytes | None: + skype, _ = self._session(broker) + base = AccountCache.for_broker(broker).get("teams_ams") + if not base: + raise ChatServiceError(404, "no media service for this account") + url = f"{base}/v1/objects/{quote(document_id, safe='')}/views/{self.CUSTOM_EMOJI_VIEWS[animated]}" + with self._http.download_client().stream("GET", url, headers={"Authorization": f"skype_token {skype}"}, timeout=self._STREAM_TIMEOUT, follow_redirects=False) as r: + if r.status_code == 404: + return None + if r.status_code != 200: + if r.status_code == 401: + self._drop_session(broker) + raise ChatServiceError(r.status_code, "custom emoji download failed") + data = bytearray() + for chunk in r.iter_bytes(): + data.extend(chunk) + if len(data) > self.MAX_EMOJI_BYTES: + raise ChatServiceError(413, "custom emoji larger than 2 MB") + return bytes(data) diff --git a/src/models.py b/src/models.py deleted file mode 100644 index 9bb3a75..0000000 --- a/src/models.py +++ /dev/null @@ -1,141 +0,0 @@ -from datetime import datetime -from typing import Annotated, Literal - -from pydantic import BaseModel, ConfigDict, Field, SecretStr, StringConstraints - - -class DeviceStartResponse(BaseModel): - user_code: str = Field(..., description="Shown to the user; they enter it at verification_uri.") - verification_uri: str - device_code: str = Field(..., description="Opaque; the caller holds it and sends it to /poll.") - interval: int = 5 - expires_in: int = 900 - message: str | None = None - - -class DevicePollRequest(BaseModel): - device_code: str - - -class BrowserStartResponse(BaseModel): - authorize_url: str = Field(..., description="Open in a WebView; wait for the redirect to redirect_uri carrying ?code=.") - redirect_uri: str = Field(..., description="The URL the code comes back on; the app watches for it and then closes the WebView.") - code_verifier: str = Field(..., description="Opaque PKCE value; the caller holds it and sends it to /complete.") - - -class BrowserCompleteRequest(BaseModel): - code: str - code_verifier: str - - -class LinkedIdentity(BaseModel): - id: str | None = None - email: str | None = None - name: str | None = None - - -class DevicePollResponse(BaseModel): - status: str = Field(..., description="pending | complete | error") - refresh_token: str | None = None - account: LinkedIdentity | None = None - message: str | None = None - - -class PasswordStartRequest(BaseModel): - username: str = Field(..., min_length=3, max_length=320) - password: SecretStr = Field(..., min_length=1, max_length=256, description="Used once for this sign-in, held only in memory, never stored, logged or returned.") - - -class PasswordMethodRequest(BaseModel): - method: Literal["push", "otp"] - - -class PasswordOtpRequest(BaseModel): - code: str = Field(..., min_length=6, max_length=12, description="The authenticator app code (digits; spaces are ignored).") - - -class PasswordSignInResponse(BaseModel): - sign_in_id: str | None = None - status: str = Field(..., description="complete | mfa_required | push_pending | otp_required | otp_invalid | denied | timed_out | working | error") - methods: list[str] | None = Field(None, description="Only the methods Microsoft offers for the account: push, otp.") - number: str | None = Field(None, description="push_pending: the number to tap in Microsoft Authenticator.") - expires_in: int | None = Field(None, description="push_pending: seconds left to approve.") - attempts_left: int | None = None - refresh_token: str | None = None - account: LinkedIdentity | None = None - code: str | None = Field(None, description="error: invalid_credentials | unsupported | blocked | rate_limited | failed") - message: str | None = None - - -class MeetingRequest(BaseModel): - subject: str = Field(..., min_length=1, max_length=255) - start: datetime = Field(..., description="ISO 8601 with an offset, e.g. 2026-11-02T14:00:00+01:00.") - end: datetime - description: str | None = Field(None, max_length=4000) - - -class MailAttachmentIn(BaseModel): - name: str - contentType: str | None = None - contentBase64: str - - -class MailMessageIn(BaseModel): - to: list[str] = Field(default_factory=list) - cc: list[str] | None = None - bcc: list[str] | None = None - subject: str - body: str - html: bool = False - attachments: list[MailAttachmentIn] | None = None - - -class MailReplyRequest(BaseModel): - comment: str - replyAll: bool = False - - -class MailForwardRequest(BaseModel): - to: list[str] - comment: str = "" - - -class MailReadRequest(BaseModel): - read: bool - - -class MailFlagRequest(BaseModel): - status: Literal["flagged", "complete", "notFlagged"] - - -class MailMoveRequest(BaseModel): - folder: str - - -class OneNotePageCreate(BaseModel): - title: str = Field(..., min_length=1, max_length=255) - html: str | None = Field(None, description="Rich text for the page body; cut down to the text formatting OneNote keeps.") - - -class OneNoteOutlineUpdate(BaseModel): - id: str = Field(..., min_length=1, max_length=200, description="An editable outline id from the page content.") - html: str - - -class OneNotePageUpdate(BaseModel): - title: str | None = Field(None, min_length=1, max_length=255) - outlines: list[OneNoteOutlineUpdate] | None = Field(None, max_length=50) - append: str | None = Field(None, description="Rich text appended to the page as a new text box.") - - -class FilesSearchRequest(BaseModel): - model_config = ConfigDict(populate_by_name=True) - - q: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=200)] - start: int = Field(0, ge=0, le=5000, alias="from") - size: int = Field(25, ge=1, le=50) - teamId: str | None = Field(None, max_length=36) - - -class SearchRequest(FilesSearchRequest): - types: list[Literal["files", "mail", "chats", "channels"]] = Field(["files", "mail", "chats", "channels"], min_length=1, max_length=4) diff --git a/src/onenote.py b/src/onenote.py deleted file mode 100644 index e53e056..0000000 --- a/src/onenote.py +++ /dev/null @@ -1,484 +0,0 @@ -"""OneNote over Graph: notebooks (the person's own and each joined team's), the section tree, pages, -page content, resources, title search and page writes. - -Ids. A team's notebooks (a class notebook is one) live under `groups/{id}/onenote`, the person's own -under `me/onenote`. Every id handed out says which: the bare Graph id for the person's own, and -`@` for a team's. Ids go into Graph paths, so each is checked before use. - -Lists the screen asks for again and again (notebooks, a root's sections and section groups, a -section's page titles for search) are kept on the account for a short while: OneNote throttles a -person who reads their whole notebook list several times a minute, and a class notebook is slow -enough already. Any write drops them. -""" - -import html -import logging -import re -import time -from concurrent.futures import ThreadPoolExecutor - -import httpx -from outnote import C_ONENOTE, GraphError - -from src import onenote_html, outnote_client -from src.account_cache import for_broker - -log = logging.getLogger("schuly.microsoft") - -ID_PATTERN = re.compile(r"[A-Za-z0-9!@_{}:.\-]{1,300}") -GRAPH_ID = re.compile(r"[A-Za-z0-9!_{}:.\-]{1,200}") -GROUP_ID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") -GRAPH = "https://graph.microsoft.com/v1.0" -KEEP = 90.0 -TEAMS_KEEP = 600.0 -MAX_SECTIONS = 2000 -MAX_GROUP_DEPTH = 6 -MAX_TITLE = 255 -SEARCH_SECTIONS = 40 -TEAM_WORKERS = 3 -RESOURCE_RETRIES = 2 -NOTEBOOK_FIELDS = "id,displayName,createdDateTime,lastModifiedDateTime,isDefault,isShared,userRole,links" -TREE_FIELDS = "id,displayName,lastModifiedDateTime" -PAGE_FIELDS = "id,title,createdDateTime,lastModifiedDateTime,level,order,links" -PARENTS = "parentNotebook($select=id),parentSectionGroup($select=id)" -HIT_EXPAND = "parentSection($select=id,displayName),parentNotebook($select=id,displayName)" - - -class OneNoteError(Exception): - """A refusal the caller should see as such (not a Graph outage).""" - - def __init__(self, status: int, message: str): - self.status = status - super().__init__(message) - - -class ResourceTooLarge(Exception): - pass - - -def parse_id(value: str) -> tuple[str | None, str]: - """(group id or None, Graph id) of an id handed out; ValueError when it is not one.""" - if not isinstance(value, str) or not ID_PATTERN.fullmatch(value) or ".." in value or value.count("@") > 1: - raise ValueError("invalid id") - group, sep, graph_id = value.rpartition("@") - if sep and not GROUP_ID.fullmatch(group): - raise ValueError("invalid id") - if not GRAPH_ID.fullmatch(graph_id) or graph_id in (".", ".."): - raise ValueError("invalid id") - return (group.lower() if sep else None), graph_id - - -def is_id(value: str) -> bool: - try: - parse_id(value) - return True - except ValueError: - return False - - -def root(group: str | None) -> str: - return f"groups/{group}/onenote" if group else "me/onenote" - - -def tag(group: str | None, graph_id: str | None) -> str | None: - return onenote_html.scoped(group, graph_id) if graph_id else None - - -def web_url(links) -> str | None: - href = ((links or {}).get("oneNoteWebUrl") or {}).get("href") - return href if isinstance(href, str) and href.startswith("https://") and len(href) < 4000 else None - - -def _kept(broker, key, make, ttl: float = KEEP): - cache = for_broker(broker) - hit = cache.get(key) - if hit is not None: - return hit - with cache.lock(repr(key)): - hit = cache.get(key) - if hit is not None: - return hit - value = make() - cache.set(key, value, ttl) - return value - - -def changed(broker): - """What was read before a write is not what is there after it.""" - for_broker(broker).discard(lambda key: isinstance(key, tuple) and key[:1] == ("onenote",) and key[1:2] != ("teams",)) - - -def _ref(group, obj) -> dict | None: - if not isinstance(obj, dict) or not obj.get("id"): - return None - return {"id": tag(group, obj["id"]), "displayName": obj.get("displayName") or ""} - - -# --- notebooks ------------------------------------------------------------------------------- - - -def _teams(note, broker) -> list[dict]: - def make(): - try: - rows = note.list("me/joinedTeams", {"$select": "id,displayName"}, limit=200) - except GraphError: - return [] - return [{"id": t["id"].lower(), "name": t.get("displayName")} for t in rows if isinstance(t.get("id"), str) and GROUP_ID.fullmatch(t["id"])] - - return _kept(broker, ("onenote", "teams"), make, TEAMS_KEEP) - - -def _notebook(group, team_name, n) -> dict: - role = n.get("userRole") - return { - "id": tag(group, n["id"]), - "displayName": n.get("displayName") or "", - "createdDateTime": n.get("createdDateTime"), - "lastModifiedDateTime": n.get("lastModifiedDateTime"), - "isDefault": n.get("isDefault") is True, - "isShared": n.get("isShared") is True, - "userRole": role if role in ("Owner", "Contributor", "Reader") else None, - "writable": role != "Reader", - "webUrl": web_url(n.get("links")), - "source": {"kind": "team" if group else "personal", "id": group, "name": team_name}, - } - - -def _team_pages(note, joined: list[dict]) -> list[list[dict] | None]: - """Each team's notebooks through `$batch`: `[]` for a team Graph refused, None for one to ask on its own (the batch failed, or the answer has more pages).""" - if not joined: - return [] - try: - answers = note.batch([f"{root(t['id'])}/notebooks?$select={NOTEBOOK_FIELDS}" for t in joined], client=C_ONENOTE) - except (GraphError, OSError) as ex: - if getattr(ex, "status", None) == 429: - raise - log.info("onenote: notebook batch failed: %s", type(ex).__name__) - return [None] * len(joined) - return [[] if isinstance(a, GraphError) else a.get("value", []) if isinstance(a, dict) and "@odata.nextLink" not in a else None for a in answers] - - -def notebooks(note, broker, teams: bool = True) -> list[dict]: - def make(): - own = note.list(f"{root(None)}/notebooks", {"$select": NOTEBOOK_FIELDS}, limit=500, client=C_ONENOTE) - out = [_notebook(None, None, n) for n in own if GRAPH_ID.fullmatch(str(n.get("id", "")))] - out.sort(key=lambda n: n["displayName"].lower()) - if not teams: - return out - joined = _teams(note, broker) - - def team_books(team, rows): - try: - if rows is None: - rows = note.list(f"{root(team['id'])}/notebooks", {"$select": NOTEBOOK_FIELDS}, limit=200, client=C_ONENOTE) - except GraphError: - # A team whose notebooks cannot be listed is somewhere the person has no notebook to open. - return [] - return [_notebook(team["id"], team["name"], n) for n in rows if GRAPH_ID.fullmatch(str(n.get("id", "")))] - - pages = _team_pages(note, joined) - # Only what the batch left open goes one by one, a few at a time: OneNote answers a burst of requests from one person with a wait. - with ThreadPoolExecutor(max_workers=TEAM_WORKERS) as pool: - found = list(pool.map(team_books, joined, pages)) - for team, rows in sorted(zip(joined, found), key=lambda pair: (pair[0]["name"] or "").lower()): - out.extend(sorted(rows, key=lambda n: n["displayName"].lower())) - return out - - return _kept(broker, ("onenote", "notebooks", teams), make) - - -# --- section tree ---------------------------------------------------------------------------- - - -def _flat(note, broker, group, what: str) -> list[dict]: - """Every section or section group of a root, with its parents, kept a short while.""" - return _kept(broker, ("onenote", what, group), lambda: note.list(f"{root(group)}/{what}", {"$select": TREE_FIELDS + (",isDefault" if what == "sections" else ""), "$expand": PARENTS, "$top": 100}, limit=MAX_SECTIONS, client=C_ONENOTE)) - - -def _walk_groups(note, group, notebook_id) -> list[dict]: - """A notebook's section groups level by level, for when the root's collection of all of them fails (Graph answers it with a transient 500 for some personal accounts).""" - out, level = [], [(f"{root(group)}/notebooks/{notebook_id}/sectionGroups", None)] - for _ in range(MAX_GROUP_DEPTH): - nxt = [] - for path, parent in level: - for g in note.list(path, {"$select": TREE_FIELDS}, limit=500, client=C_ONENOTE): - if not GRAPH_ID.fullmatch(str(g.get("id", ""))): - continue - out.append({**g, "parentNotebook": {"id": notebook_id}, "parentSectionGroup": {"id": parent} if parent else None}) - nxt.append((f"{root(group)}/sectionGroups/{g['id']}/sectionGroups", g["id"])) - level = nxt - if not level: - break - return out - - -def tree(note, broker, notebook: str) -> dict: - group, notebook_id = parse_id(notebook) - sections = _flat(note, broker, group, "sections") - if group is None: - # The person's own collection of all section groups also holds what others shared, and Graph often answers it with a - # transient 500 after a long wait: one notebook's groups are walked instead (most have none, which is one request). - groups = _kept(broker, ("onenote", "walked", group, notebook_id), lambda: _walk_groups(note, group, notebook_id)) - else: - # A team's collection holds its own notebooks only, and walking a class notebook's dozens of groups is dozens of requests. - groups = _flat(note, broker, group, "sectionGroups") - mine = lambda row: ((row.get("parentNotebook") or {}).get("id")) == notebook_id # noqa: E731 - sections = [s for s in sections if mine(s)] - groups = [g for g in groups if mine(g)] - nodes = {} - for g in groups: - if GRAPH_ID.fullmatch(str(g.get("id", ""))): - nodes[g["id"]] = {"id": tag(group, g["id"]), "kind": "group", "displayName": g.get("displayName") or "", "lastModifiedDateTime": g.get("lastModifiedDateTime"), "isDefault": False, "children": [], "_parent": (g.get("parentSectionGroup") or {}).get("id")} - top: list[dict] = [] - for s in sections: - if not GRAPH_ID.fullmatch(str(s.get("id", ""))): - continue - node = {"id": tag(group, s["id"]), "kind": "section", "displayName": s.get("displayName") or "", "lastModifiedDateTime": s.get("lastModifiedDateTime"), "isDefault": s.get("isDefault") is True, "children": []} - parent = nodes.get((s.get("parentSectionGroup") or {}).get("id")) - (parent["children"] if parent else top).append(node) - top_groups = [] - for g in nodes.values(): - parent = nodes.get(g["_parent"]) if g["_parent"] != g["id"] else None - (parent["children"] if parent else top_groups).append(g) - - def finish(node, depth=0): - node.pop("_parent", None) - if depth >= MAX_GROUP_DEPTH: - node["children"] = [] - # OneNote lists a group's sections before its groups. - node["children"].sort(key=lambda c: c["kind"] == "group") - for c in node["children"]: - finish(c, depth + 1) - return node - - return {"notebookId": notebook, "children": top + [finish(g) for g in top_groups]} - - -# --- pages ----------------------------------------------------------------------------------- - - -def _summary(group, section, p) -> dict: - level = p.get("level") - order = p.get("order") - return { - "id": tag(group, p["id"]), - "title": p.get("title") or "", - "level": level if isinstance(level, int) and 0 <= level <= 10 else 0, - "order": order if isinstance(order, int) else None, - "createdDateTime": p.get("createdDateTime"), - "lastModifiedDateTime": p.get("lastModifiedDateTime"), - "webUrl": web_url(p.get("links")), - "sectionId": section, - } - - -def pages(note, section: str, top: int, skip: int) -> dict: - group, section_id = parse_id(section) - answer = note.get(f"{root(group)}/sections/{section_id}/pages", {"$select": PAGE_FIELDS, "pagelevel": "true", "$top": top, "$skip": skip or None}, client=C_ONENOTE) or {} - rows = [p for p in answer.get("value") or [] if GRAPH_ID.fullmatch(str(p.get("id", "")))] - items = [_summary(group, section, p) for p in rows] - return {"items": items, "nextSkip": skip + len(answer.get("value") or []) if answer.get("@odata.nextLink") else None} - - -def _meta(note, group, page_id) -> dict: - return note.get(f"{root(group)}/pages/{page_id}", {"$select": "id,title,createdDateTime,lastModifiedDateTime,links", "$expand": "parentSection($select=id,displayName),parentNotebook($select=id,displayName,userRole)"}, client=C_ONENOTE) or {} - - -def _content(note, group, page_id, ink: bool = True) -> tuple[str, str | None]: - path = f"{root(group)}/pages/{page_id}/content" - page = note.get(path, {"includeIDs": "true"}, raw=True, client=C_ONENOTE) or "" - if not ink or not onenote_html.has_ink(page): - return page, None - # The strokes are most of a drawn page's weight, so they are asked for only when the page has some. - return onenote_html.split_parts(note.get(path, {"includeIDs": "true", "includeInkML": "true"}, raw=True, client=C_ONENOTE) or "") - - -def page(note, page_scoped: str) -> dict: - group, page_id = parse_id(page_scoped) - meta = _meta(note, group, page_id) - content, ink = _content(note, group, page_id) - rendered = onenote_html.render_page(content, ink, group) - role = (meta.get("parentNotebook") or {}).get("userRole") - return { - "id": page_scoped, - "title": meta.get("title") or rendered["title"], - "createdDateTime": meta.get("createdDateTime"), - "lastModifiedDateTime": meta.get("lastModifiedDateTime"), - "webUrl": web_url(meta.get("links")), - "section": _ref(group, meta.get("parentSection")), - "notebook": _ref(group, meta.get("parentNotebook")), - "writable": role != "Reader", - "html": rendered["html"], - "width": rendered["width"], - "height": rendered["height"], - "resources": rendered["resources"], - "outlines": rendered["outlines"], - "hasInk": rendered["hasInk"], - } - - -def check_title(title: str) -> str: - title = re.sub(r"\s+", " ", title or "").strip() - if not title or len(title) > MAX_TITLE: - raise OneNoteError(400, f"A page title is 1-{MAX_TITLE} characters.") - return title - - -def create_page(note, broker, section: str, title: str, body: str | None) -> dict: - group, section_id = parse_id(section) - made = note.post(f"{root(group)}/sections/{section_id}/pages", onenote_html.new_page(check_title(title), body), client=C_ONENOTE, content_type="application/xhtml+xml") or {} - changed(broker) - if not GRAPH_ID.fullmatch(str(made.get("id", ""))): - raise OneNoteError(502, "OneNote did not return the new page.") - return _summary(group, section, made) - - -def update_page(note, broker, page_scoped: str, title: str | None, outlines: list[dict] | None, append: str | None) -> dict: - group, page_id = parse_id(page_scoped) - meta = _meta(note, group, page_id) - if (meta.get("parentNotebook") or {}).get("userRole") == "Reader": - raise OneNoteError(403, "This notebook is read only.") - commands = [] - if title is not None: - commands.append({"target": "title", "action": "replace", "content": html.escape(check_title(title), quote=False)}) - if outlines: - current, _ = _content(note, group, page_id, ink=False) - for item in outlines: - style = onenote_html.outline_style(current, item["id"]) - if style is None: - raise OneNoteError(400, "That text box is not on the page or holds more than text, so it is not changed.") - inner = onenote_html.sanitize_for_write(item["html"]) - commands.append({"target": item["id"], "action": "replace", "content": f'
{inner}
' if style else f"
{inner}
"}) - if append and append.strip(): - inner = onenote_html.sanitize_for_write(append) - if inner.strip(): - commands.append({"target": "body", "action": "append", "content": f"
{inner}
"}) - if commands: - note.patch(f"{root(group)}/pages/{page_id}/content", commands, client=C_ONENOTE) - changed(broker) - return page(note, page_scoped) - - -def delete_page(note, broker, page_scoped: str): - group, page_id = parse_id(page_scoped) - note.delete(f"{root(group)}/pages/{page_id}", client=C_ONENOTE) - changed(broker) - - -# --- resources ------------------------------------------------------------------------------- - - -def resource(broker, resource_scoped: str, limit: int) -> bytes: - """A picture's or a file's bytes, streamed from Graph's own host with the OneNote token and cut off past `limit`.""" - group, resource_id = parse_id(resource_scoped) - url = f"{GRAPH}/{root(group)}/resources/{resource_id}/$value" - for attempt in range(RESOURCE_RETRIES + 1): - headers = {"Authorization": f"Bearer {broker(C_ONENOTE)}"} - # No redirects: the bearer token must never follow a response to another host. - with outnote_client.download_client().stream("GET", url, headers=headers, timeout=httpx.Timeout(60.0, connect=5.0), follow_redirects=False) as r: - if r.status_code in (429, 503) and attempt < RESOURCE_RETRIES: - wait = r.headers.get("Retry-After", "") - time.sleep(min(6.0, float(wait)) if wait.isdigit() else 1.0 + attempt) - continue - if r.status_code >= 300: - raise GraphError(r.status_code if r.status_code >= 400 else 502, "resource") - size = r.headers.get("Content-Length", "") - if size.isdigit() and int(size) > limit: - raise ResourceTooLarge() - data = bytearray() - for chunk in r.iter_bytes(64 * 1024): - data.extend(chunk) - if len(data) > limit: - raise ResourceTooLarge() - return bytes(data) - raise GraphError(429, "resource") - - -# --- search ---------------------------------------------------------------------------------- - - -def _hit(group, p) -> dict: - return {"id": tag(group, p["id"]), "title": p.get("title") or "", "lastModifiedDateTime": p.get("lastModifiedDateTime"), "webUrl": web_url(p.get("links")), "section": _ref(group, p.get("parentSection")), "notebook": _ref(group, p.get("parentNotebook"))} - - -def _filtered(note, group, query: str, top: int) -> list[dict]: - literal = query.lower().replace("'", "''") - rows = note.list(f"{root(group)}/pages", {"$filter": f"contains(tolower(title),'{literal}')", "$select": "id,title,lastModifiedDateTime,links", "$expand": HIT_EXPAND, "$top": min(top, 100)}, limit=top, client=C_ONENOTE) - return [p for p in rows if GRAPH_ID.fullmatch(str(p.get("id", "")))] - - -def _filter_refused(ex: GraphError) -> bool: - """Graph's title filter covers a whole root and refuses one with too many sections, or with one section it cannot sync.""" - return ex.status in (400, 422) - - -def _walk_search(note, broker, group, notebook_id, query: str, top: int) -> tuple[list[dict], bool]: - """Title search one section at a time (of one notebook, or of the whole root), for where Graph's own filter is refused.""" - flat = [s for s in _flat(note, broker, group, "sections") if notebook_id is None or (s.get("parentNotebook") or {}).get("id") == notebook_id] - flat.sort(key=lambda s: s.get("lastModifiedDateTime") or "", reverse=True) - partial = len(flat) > SEARCH_SECTIONS - needle = query.lower() - - def titles(s): - try: - return _kept(broker, ("onenote", "titles", group, s["id"]), lambda: note.list(f"{root(group)}/sections/{s['id']}/pages", {"$select": "id,title,lastModifiedDateTime,links", "$top": 100}, limit=500, client=C_ONENOTE)) - except GraphError: - return None - - # A few sections at a time, most recently changed first; their page titles are kept a short while for the next search. - with ThreadPoolExecutor(max_workers=TEAM_WORKERS) as pool: - found = list(pool.map(titles, flat[:SEARCH_SECTIONS])) - hits = [] - for s, rows in zip(flat, found): - if rows is None: - # A section OneNote cannot sync is not one the person has pages in to find. - continue - parent = (s.get("parentNotebook") or {}).get("id") - for p in rows: - if needle in (p.get("title") or "").lower() and GRAPH_ID.fullmatch(str(p.get("id", ""))): - hits.append(_hit(group, {**p, "parentSection": {"id": s["id"], "displayName": s.get("displayName")}, "parentNotebook": {"id": parent, "displayName": None} if parent else None})) - hits.sort(key=lambda h: h.get("lastModifiedDateTime") or "", reverse=True) - return hits[:top], partial - - -def _name_notebooks(note, broker, hits: list[dict]): - names = {n["id"]: n["displayName"] for n in notebooks(note, broker)} - for h in hits: - if h["notebook"] and not h["notebook"]["displayName"]: - h["notebook"]["displayName"] = names.get(h["notebook"]["id"], "") - - -def search(note, broker, query: str, notebook: str | None, top: int) -> dict: - query = re.sub(r"\s+", " ", query).strip() - if notebook: - group, notebook_id = parse_id(notebook) - try: - rows = [p for p in _filtered(note, group, query, 100) if (p.get("parentNotebook") or {}).get("id") == notebook_id] - return {"items": [_hit(group, p) for p in rows[:top]], "partial": False} - except GraphError as ex: - if not _filter_refused(ex): - raise - hits, partial = _walk_search(note, broker, group, notebook_id, query, top) - _name_notebooks(note, broker, hits) - return {"items": hits, "partial": partial} - roots = [None] + [t["id"] for t in _teams(note, broker)] - - def one(group): - try: - return [_hit(group, p) for p in _filtered(note, group, query, top)], False - except GraphError as ex: - if not _filter_refused(ex): - if group is None: - raise - return [], True - if group is None: - # The person's own notebooks are few sections, so they are walked; a class notebook is searched by picking it. - return _walk_search(note, broker, None, None, query, top) - return [], True - - with ThreadPoolExecutor(max_workers=TEAM_WORKERS) as pool: - found = list(pool.map(one, roots)) - items = [h for rows, _ in found for h in rows] - _name_notebooks(note, broker, items) - items.sort(key=lambda h: h.get("lastModifiedDateTime") or "", reverse=True) - return {"items": items[:top], "partial": any(p for _, p in found)} diff --git a/src/onenote_html.py b/src/onenote_html.py deleted file mode 100644 index 08bb672..0000000 --- a/src/onenote_html.py +++ /dev/null @@ -1,487 +0,0 @@ -"""OneNote page HTML, in both directions, and the page's ink. - -Reading is the sanitising step. A page is parsed into a small tree and rebuilt from an allowlist: -nothing of the page's own markup is passed through. Scripts, styles, event attributes, forms and -every tag outside the list are dropped (the text inside an unknown tag is kept), styles keep only a -few text properties with plain values, links keep only http, https and mailto, and positioned items -(OneNote's text boxes, pictures and files) keep only a numeric left, top and width. - -Pictures and attached files are OneNote resources behind the account's token. Their address is -never handed out: an `img` keeps only `data-resource` (the resource id, scoped like every other id) -and the caller fetches the bytes through the resource route. Ink, which Graph only sends as InkML -in a second multipart part, becomes inline SVG path data. - -Writing (a new page, a replaced text box, appended text) goes through the same allowlist with no -styles beyond simple text formatting, and comes out as the XHTML OneNote takes. - -A text box is editable only when it holds nothing but text: replacing a box with a picture, a file -or ink in it would drop them, so such a box is shown and never written over. -""" - -import html -import re -from html.parser import HTMLParser -from urllib.parse import urlsplit - -TEXT_TAGS = {"p", "div", "span", "b", "strong", "i", "em", "u", "s", "strike", "del", "sub", "sup", "a", "br", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", "table", "thead", "tbody", "tfoot", "tr", "td", "th", "pre", "code", "blockquote", "cite"} -VOID = {"br", "img", "hr", "meta", "link", "input", "col", "area", "base", "wbr", "source", "param", "embed"} -DROP_CONTENT = {"script", "style", "head", "title", "template", "noscript", "iframe", "frame", "frameset", "svg", "math", "canvas", "video", "audio", "form", "button", "select", "textarea", "applet", "embed", "xml"} -LINK_SCHEMES = ("http", "https", "mailto") -STYLE_PROPS = {"color", "background-color", "font-family", "font-size", "font-weight", "font-style", "text-decoration", "text-align", "vertical-align", "margin-top", "margin-bottom", "margin-left", "line-height", "border", "border-width", "border-style", "border-color", "border-collapse", "padding", "width"} -WRITE_STYLE_PROPS = {"color", "background-color", "font-weight", "font-style", "text-decoration", "text-align"} -STYLE_VALUE = re.compile(r"[#\w\s.,%'\"()+-]{1,100}") -STYLE_BLOCKED = ("url", "expression", "javascript", "image", "attr(", "var(", "env(") -NUMBER = re.compile(r"-?\d{1,6}(?:\.\d{1,3})?") -TAG_VALUE = re.compile(r"[a-z0-9:,-]{1,60}") -CONTENT_TYPE = re.compile(r"[a-z0-9][\w.+-]{0,126}/[a-z0-9][\w.+-]{0,126}") -COLOR = re.compile(r"#[0-9A-Fa-f]{6}") -# The ids OneNote gives page elements (`div:{guid}{n}`) and its resources (`0-abc!1-def`). -SAFE_ID = re.compile(r"[A-Za-z0-9!_{}:.\-]{1,200}") -RESOURCE_PATH = re.compile(r"/onenote/resources/([A-Za-z0-9!_{}:.\-]{1,200})/(?:\$value|content)$") -GRAPH_HOST = "graph.microsoft.com" -MAX_ALT = 2000 -MAX_NAME = 255 -# Ink bounds: a page drawn on for a whole lesson is thousands of strokes; past these the rest is left out. -MAX_TRACES = 5000 -MAX_POINTS = 400_000 -PX_PER_HIMETRIC = 96.0 / 2540.0 -BOX_WIDTH = 600 -BOX_HEIGHT = 120 - - -class _Node: - __slots__ = ("tag", "attrs", "children") - - def __init__(self, tag: str, attrs: dict[str, str]): - self.tag = tag - self.attrs = attrs - self.children: list = [] - - -class _Tree(HTMLParser): - """A forgiving tree builder: OneNote's markup is regular, and anything that is not is closed where it can be.""" - - def __init__(self): - super().__init__(convert_charrefs=True) - self.root = _Node("#root", {}) - self.stack = [self.root] - - def handle_starttag(self, tag, attrs): - node = _Node(tag.lower(), {k.lower(): (v or "") for k, v in attrs}) - self.stack[-1].children.append(node) - if node.tag not in VOID: - self.stack.append(node) - - def handle_startendtag(self, tag, attrs): - self.stack[-1].children.append(_Node(tag.lower(), {k.lower(): (v or "") for k, v in attrs})) - - def handle_endtag(self, tag): - tag = tag.lower() - for i in range(len(self.stack) - 1, 0, -1): - if self.stack[i].tag == tag: - del self.stack[i:] - return - - def handle_data(self, data): - self.stack[-1].children.append(data) - - -def _parse(markup: str) -> _Node: - tree = _Tree() - tree.feed(markup) - tree.close() - return tree.root - - -def _find(node: _Node, tag: str) -> _Node | None: - for child in node.children: - if isinstance(child, _Node): - if child.tag == tag: - return child - found = _find(child, tag) - if found is not None: - return found - return None - - -def _text(node: _Node) -> str: - return "".join(c if isinstance(c, str) else _text(c) for c in node.children) - - -def safe_href(value: str | None) -> str | None: - value = (value or "").strip() - if not value or len(value) > 2000 or any(ord(ch) < 32 for ch in value): - return None - try: - parts = urlsplit(value) - except ValueError: - return None - scheme = parts.scheme.lower() - if scheme not in LINK_SCHEMES or (scheme != "mailto" and not parts.netloc): - return None - return value - - -def _style(value: str | None, allowed: set[str]) -> str: - out = [] - for rule in (value or "").split(";"): - name, sep, val = rule.partition(":") - name, val = name.strip().lower(), val.strip() - if not sep or name not in allowed or not STYLE_VALUE.fullmatch(val) or any(b in val.lower() for b in STYLE_BLOCKED): - continue - out.append(f"{name}:{val}") - return ";".join(out) - - -def _length(style: str | None, prop: str) -> float | None: - """A number of CSS px out of `left:48px;top:12pt` (a pt value is scaled).""" - for rule in (style or "").split(";"): - name, sep, val = rule.partition(":") - if sep and name.strip().lower() == prop: - val = val.strip().lower() - unit = "pt" if val.endswith("pt") else "" - number = val.removesuffix("px").removesuffix("pt").strip() - if NUMBER.fullmatch(number): - px = float(number) * (96.0 / 72.0 if unit == "pt" else 1.0) - return round(px, 1) - return None - - -def _attr_number(value: str | None) -> float | None: - value = (value or "").strip().removesuffix("px") - return round(float(value), 1) if NUMBER.fullmatch(value) else None - - -def _num(value: float) -> str: - return str(int(value)) if value == int(value) else str(value) - - -def scoped(scope: str | None, graph_id: str) -> str: - """An id handed out: bare for the person's own OneNote, `@` for a team's.""" - return f"{scope}@{graph_id}" if scope else graph_id - - -def resource_id(url: str | None) -> str | None: - """The resource id in a Graph resource address, accepted only from Graph's own https host.""" - if not url or len(url) > 2000: - return None - try: - parts = urlsplit(html.unescape(url)) - port = parts.port - except ValueError: - return None - if parts.scheme != "https" or (parts.hostname or "").lower() != GRAPH_HOST or port not in (None, 443) or parts.username or parts.password: - return None - match = RESOURCE_PATH.search(parts.path) - return match.group(1) if match and SAFE_ID.fullmatch(match.group(1)) else None - - -def element_id(node: _Node) -> str | None: - """The id a PATCH can target: the one OneNote generated (`includeIDs=true`).""" - for name in ("id", "data-id"): - value = node.attrs.get(name) - if value and SAFE_ID.fullmatch(value): - return value - return None - - -def text_only(node: _Node) -> bool: - return all(isinstance(c, str) or (c.tag in TEXT_TAGS and text_only(c)) for c in node.children) - - -class _Render: - def __init__(self, scope: str | None, write: bool = False): - self.scope = scope - self.write = write - self.resources: dict[str, dict] = {} - - def children(self, node: _Node) -> str: - return "".join(self.node(c) for c in node.children) - - def node(self, node) -> str: - if isinstance(node, str): - return html.escape(node, quote=False) - tag = node.tag - if tag in DROP_CONTENT: - return "" - if tag == "img": - return "" if self.write else self.image(node, positioned=False) - if tag == "object": - return "" if self.write else self.file(node, positioned=False) - if tag not in TEXT_TAGS: - return self.children(node) - if tag == "br": - return "
" if self.write else "
" - attrs = [] - if tag == "a": - href = safe_href(node.attrs.get("href")) - if href: - attrs.append(f'href="{html.escape(href, quote=True)}"') - if not self.write: - attrs.append('rel="noopener noreferrer"') - if tag in ("td", "th"): - for name in ("colspan", "rowspan"): - value = node.attrs.get(name, "") - if value.isdigit() and 1 <= int(value) <= 100: - attrs.append(f'{name}="{int(value)}"') - tag_value = node.attrs.get("data-tag", "").lower() - if tag_value and TAG_VALUE.fullmatch(tag_value) and tag in ("p", "li", "span", "div", "h1", "h2", "h3", "h4", "h5", "h6"): - attrs.append(f'data-tag="{tag_value}"') - style = _style(node.attrs.get("style"), WRITE_STYLE_PROPS if self.write else STYLE_PROPS) - if style: - attrs.append(f'style="{html.escape(style, quote=True)}"') - head = f"<{tag} {' '.join(attrs)}>" if attrs else f"<{tag}>" - return f"{head}{self.children(node)}" - - def _position(self, node: _Node, positioned: bool, width: float | None = None) -> str: - if not positioned: - return "" - style = node.attrs.get("style") - left, top = _length(style, "left") or 0.0, _length(style, "top") or 0.0 - parts = [f"position:absolute;left:{_num(left)}px;top:{_num(top)}px"] - if width is not None: - parts.append(f"width:{_num(width)}px") - return f' style="{";".join(parts)}"' - - def image(self, node: _Node, positioned: bool) -> str: - rid = resource_id(node.attrs.get("data-fullres-src")) or resource_id(node.attrs.get("src")) - if rid is None: - return "" - sid = scoped(self.scope, rid) - kind = (node.attrs.get("data-fullres-src-type") or node.attrs.get("data-src-type") or "").lower() - self.resources.setdefault(sid, {"id": sid, "kind": "image", "name": None, "contentType": kind if CONTENT_TYPE.fullmatch(kind) else None}) - alt = (node.attrs.get("alt") or "")[:MAX_ALT] - width, height = _attr_number(node.attrs.get("width")), _attr_number(node.attrs.get("height")) - size = (f' width="{_num(width)}"' if width else "") + (f' height="{_num(height)}"' if height else "") - return f'{html.escape(alt, quote=True)}' - - def file(self, node: _Node, positioned: bool) -> str: - rid = resource_id(node.attrs.get("data")) - if rid is None: - return "" - sid = scoped(self.scope, rid) - name = re.sub(r"[\x00-\x1f\x7f]", "", node.attrs.get("data-attachment") or "")[:MAX_NAME] or "file" - kind = (node.attrs.get("type") or "").lower() - self.resources.setdefault(sid, {"id": sid, "kind": "file", "name": name, "contentType": kind if CONTENT_TYPE.fullmatch(kind) else None}) - return f'{html.escape(name)}' - - -def split_parts(body: str) -> tuple[str, str | None]: - """The HTML and the InkML of an answer. With `includeInkML` Graph answers multipart, the HTML first; without, the HTML alone.""" - first = body.split("\n", 1)[0].strip() - if not first.startswith("--") or len(first) < 3: - return body, None - page = ink = None - for part in body.split(first): - head, sep, rest = part.partition("\r\n\r\n") - if not sep: - head, sep, rest = part.partition("\n\n") - if not sep: - continue - head = head.lower() - if "text/html" in head and page is None: - page = rest.strip().removesuffix("--").strip() - elif "inkml" in head and ink is None: - ink = rest.strip().removesuffix("--").strip() - return page if page is not None else body, ink - - -def has_ink(page: str) -> bool: - """OneNote only marks ink in the HTML; the strokes come with `includeInkML`.""" - return "InkNode is not supported" in page - - -def render_page(page: str, ink: str | None = None, scope: str | None = None) -> dict: - """A page as sanitized markup, its resources, its text boxes and the size of its canvas.""" - root = _parse(page) - title_node = _find(root, "title") - title = re.sub(r"\s+", " ", _text(title_node)).strip() if title_node else "" - body = _find(root, "body") or root - render = _Render(scope) - parts, outlines = [], [] - width = height = 0.0 - for child in body.children: - if isinstance(child, str): - parts.append(render.node(child)) - continue - style = child.attrs.get("style") - left, top = _length(style, "left"), _length(style, "top") - positioned = left is not None or top is not None - if child.tag == "div": - oid = element_id(child) - box_width = _length(style, "width") - inner = render.children(child) - editable = oid is not None and text_only(child) - attrs = (f' data-outline="{html.escape(oid, quote=True)}" data-editable="{"true" if editable else "false"}"' if oid else "") + render._position(child, positioned, box_width) - parts.append(f"{inner}") - if oid: - outlines.append({"id": oid, "editable": editable, "html": inner}) - if positioned: - width = max(width, (left or 0) + (box_width or BOX_WIDTH)) - height = max(height, (top or 0) + BOX_HEIGHT) - elif child.tag == "img": - parts.append(render.image(child, positioned)) - if positioned: - width = max(width, (left or 0) + (_attr_number(child.attrs.get("width")) or 0)) - height = max(height, (top or 0) + (_attr_number(child.attrs.get("height")) or 0)) - elif child.tag == "object": - parts.append(render.file(child, positioned)) - if positioned: - width, height = max(width, (left or 0) + 240), max(height, (top or 0) + 40) - else: - parts.append(render.node(child)) - strokes = ink_svg(ink) if ink else None - if strokes: - svg, ink_w, ink_h = strokes - parts.append(svg) - width, height = max(width, ink_w), max(height, ink_h) - return { - "title": title, - "html": "".join(parts), - "width": int(width + 24) if width else 0, - "height": int(height + 24) if height else 0, - "resources": list(render.resources.values()), - "outlines": outlines, - "hasInk": has_ink(page), - } - - -def outline_style(page: str, outline: str) -> str | None: - """The place of one editable text box of the current page (`position:absolute;left;top;width`), or None when it is not one.""" - body = _find(_parse(page), "body") - for child in body.children if body else []: - if isinstance(child, _Node) and child.tag == "div" and element_id(child) == outline: - if not text_only(child): - return None - style = child.attrs.get("style") - left, top, width = _length(style, "left"), _length(style, "top"), _length(style, "width") - if left is None and top is None: - return "" - out = f"position:absolute;left:{_num(left or 0)}px;top:{_num(top or 0)}px" - return out + (f";width:{_num(width)}px" if width else "") - return None - - -def sanitize_for_write(markup: str) -> str: - """Rich text from the editor, cut down to formatting OneNote keeps, as XHTML. No pictures, files or positions.""" - return _Render(None, write=True).children(_parse(markup or "")) - - -def new_page(title: str, body: str | None) -> str: - """The XHTML document a new page is created from.""" - content = f"
{sanitize_for_write(body)}
" if body and body.strip() else "" - return f"{html.escape(title, quote=False)}{content}" - - -# --- ink ------------------------------------------------------------------------------------- - - -class _Ink(HTMLParser): - """InkML read as tags: brushes with their properties, and traces with the brush they use. Parsed as markup, never as XML (no entities, no DTD).""" - - def __init__(self): - super().__init__(convert_charrefs=True) - self.brushes: dict[str, dict] = {} - self.brush: dict | None = None - self.traces: list[tuple[str, list[str]]] = [] - self.trace: tuple[str, list[str]] | None = None - - def handle_starttag(self, tag, attrs): - attrs = {k.lower(): v or "" for k, v in attrs} - if tag == "inkml:brush": - self.brush = {"color": "#333333", "width": 50.0, "opacity": 1.0, "highlight": False} - self.brushes[attrs.get("xml:id", "")] = self.brush - elif tag == "inkml:brushproperty" and self.brush is not None: - name, value = attrs.get("name", "").lower(), attrs.get("value", "") - if name == "color" and COLOR.fullmatch(value): - self.brush["color"] = value - elif name == "width" and NUMBER.fullmatch(value): - self.brush["width"] = float(value) - elif name == "transparency" and NUMBER.fullmatch(value): - self.brush["opacity"] = min(1.0, max(0.05, 1.0 - float(value) / 255.0)) - elif name == "rasterop": - self.brush["highlight"] = value.lower() == "maskpen" - elif tag == "inkml:trace" and len(self.traces) < MAX_TRACES: - self.trace = (attrs.get("brushref", "").lstrip("#"), []) - self.traces.append(self.trace) - - handle_startendtag = handle_starttag - - def handle_endtag(self, tag): - if tag == "inkml:brush": - self.brush = None - elif tag == "inkml:trace": - self.trace = None - - def handle_data(self, data): - if self.trace is not None: - self.trace[1].append(data) - - -def _points(trace: str, budget: int) -> list[tuple[float, float]]: - """The points of one trace (`x y f, x y f`), where a value may carry `'` (first difference) or `"` (second difference).""" - out, value, speed = [], [0.0, 0.0], [0.0, 0.0] - for sample in trace.split(","): - if len(out) >= budget: - break - for channel, token in enumerate(sample.split()[:2]): - kind = 1 if token.startswith("'") else 2 if token.startswith('"') else 0 - token = token.lstrip("'\"") - try: - number = float(token) - except ValueError: - continue - if kind == 0: - value[channel], speed[channel] = number, 0.0 - elif kind == 1: - speed[channel] = number - value[channel] += number - else: - speed[channel] += number - value[channel] += speed[channel] - out.append((value[0] * PX_PER_HIMETRIC, value[1] * PX_PER_HIMETRIC)) - return out - - -def ink_svg(xml: str) -> tuple[str, float, float] | None: - """The strokes as one SVG of paths in page pixels, with its width and height; None when there are none.""" - reader = _Ink() - try: - reader.feed(xml) - reader.close() - except Exception: - return None - paths: dict[str, list[str]] = {} - right = bottom = 0.0 - budget = MAX_POINTS - for brush_id, chunks in reader.traces: - if brush_id not in reader.brushes or budget <= 0: - continue - points = _points("".join(chunks), budget) - budget -= len(points) - d, last = [], None - for x, y in points: - if last and abs(x - last[0]) < 0.4 and abs(y - last[1]) < 0.4: - continue - d.append(f"{'L' if d else 'M'}{x:.1f} {y:.1f}" + ("" if d else "l0.1 0")) - last = (x, y) - right, bottom = max(right, x), max(bottom, y) - if d: - paths.setdefault(brush_id, []).append("".join(d)) - if not paths: - return None - width, height = round(right + 8), round(bottom + 8) - out = [f'") - return "".join(out), float(width), float(height) - - -def plain_text(markup: str) -> str: - """Visible text of sanitized markup (search snippets, tests).""" - return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", markup))).strip() diff --git a/src/outnote_client.py b/src/outnote_client.py deleted file mode 100644 index 8bc480f..0000000 --- a/src/outnote_client.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Thin, stateless wrapper over OutNote / Entrance. - -The device-code sign-in is two documented OAuth calls (start + poll). Data reads -build an OutNote client from the caller's FOCI refresh token, which the broker may -rotate; callers get the rotated token back and own its persistence. Nothing here is -stored server-side. -""" - -import asyncio -import hashlib -import logging -import threading -import time -from collections import OrderedDict -from collections.abc import Callable -from http.cookiejar import DefaultCookiePolicy - -import entrance -import httpx -from entrance import Broker -from outnote import OutNote - -from src import config -from src.account_cache import AccountCache - -log = logging.getLogger("schuly.microsoft") - - -class MicrosoftError(Exception): - def __init__(self, error: str, description: str = ""): - self.error = error - super().__init__(f"{error}: {description}" if description else error) - - -async def start_device_login(client: httpx.AsyncClient | None = None) -> dict: - """Begin the device-code flow: returns user_code, verification_uri and the device_code to poll with.""" - data = await _post(config.DEVICECODE_URL, {"client_id": config.DEVICE_CLIENT_ID, "scope": config.DEVICE_SCOPE}, client) - if not data.get("device_code"): - raise MicrosoftError(data.get("error", "no_device_code"), data.get("error_description", "the client may not support the device flow")) - return data - - -async def poll_device_login(device_code: str, client: httpx.AsyncClient | None = None) -> dict: - """Poll once. Returns the raw token response: access_token + refresh_token, or error=authorization_pending.""" - return await _post(config.TOKEN_URL, {"grant_type": config.DEVICE_GRANT, "client_id": config.DEVICE_CLIENT_ID, "device_code": device_code}, client) - - -BROKER_CACHE_SIZE = 1000 - - -class _SharedBroker(Broker): - """A Broker shared by every request that carries the same refresh token. - - Entrance's Broker already keeps one access token per (client id, scope) and only redeems the - refresh token (which makes Microsoft rotate it) when that entry is missing or within 60s of - expiry. The Broker used to be rebuilt per request, so every call redeemed. Sharing it means - repeat calls reuse the minted access tokens and the refresh token stays unchanged. The lock makes - concurrent misses redeem once instead of racing; `on_rotate` re-registers the broker under the - new token's hash so the caller's next request (which sends the rotated token) still hits. - - It also owns the account's one OutNote client, as OutNote recommends for long-lived use: the ids that client keeps in - memory (user, tenant) belong to this account and live and die with this broker, so accounts never share one.""" - - def __init__(self, refresh_token: str, on_rotate: Callable[[str, "_SharedBroker"], None]): - super().__init__(refresh_token) - self._lock = threading.Lock() - self._on_rotate = on_rotate - # Per-account values that rarely change (folder roles, chat pictures); shared with the broker's lifetime. - self.cache = AccountCache() - self.note = OutNote(self) - - def __call__(self, client_id, scope="https://graph.microsoft.com/.default"): - with self._lock: - before = self.refresh_token - token = super().__call__(client_id, scope) - if self.refresh_token != before: - self._on_rotate(self.refresh_token, self) - return token - - -class _BrokerCache: - """In-memory LRU of brokers keyed by SHA-256 of the refresh token (the raw token is never a key).""" - - def __init__(self, max_size: int = BROKER_CACHE_SIZE): - self._max = max_size - self._items: OrderedDict[str, _SharedBroker] = OrderedDict() - self._lock = threading.Lock() - - @staticmethod - def _key(refresh_token: str) -> str: - return hashlib.sha256(refresh_token.encode()).hexdigest() - - def get(self, refresh_token: str) -> _SharedBroker: - key = self._key(refresh_token) - with self._lock: - broker = self._items.get(key) - if broker is None: - broker = _SharedBroker(refresh_token, self._register) - self._store(key, broker) - else: - self._items.move_to_end(key) - return broker - - def _register(self, refresh_token: str, broker: _SharedBroker): - with self._lock: - self._store(self._key(refresh_token), broker) - - def _store(self, key: str, broker: _SharedBroker): - self._items[key] = broker - self._items.move_to_end(key) - while len(self._items) > self._max: - self._items.popitem(last=False) - - def clear(self): - with self._lock: - self._items.clear() - - -_brokers = _BrokerCache() - - -def build_client(refresh_token: str) -> tuple[OutNote, Broker]: - """An OutNote client over a shared per-token broker; read `broker.refresh_token` after use for the (possibly rotated) value.""" - broker = _brokers.get(refresh_token) - return broker.note, broker - - -def strip_native(value): - """Removes OutNote's `_native` copies (the raw Teams answer, with raw ids) from a result, in place, at any depth.""" - if isinstance(value, dict): - value.pop("_native", None) - for item in value.values(): - strip_native(item) - elif isinstance(value, (list, tuple)): - for item in value: - strip_native(item) - return value - - -_async_client: httpx.AsyncClient | None = None -_download_client: httpx.Client | None = None -_download_lock = threading.Lock() - - -def download_client() -> httpx.Client: - """One keep-alive client for bodies that must stay capped or be streamed: `entrance.http_client()` reads every body into memory first and has no stream. - - It is shared by all accounts, so it keeps no cookies (SharePoint sets them on downloads) and never holds a token; a bearer goes per request, with redirects off.""" - global _download_client - if _download_client is None: - with _download_lock: - if _download_client is None: - client = httpx.Client( - timeout=httpx.Timeout(30.0, connect=5.0), - limits=httpx.Limits(max_connections=100, max_keepalive_connections=20, keepalive_expiry=60), - transport=httpx.HTTPTransport(retries=3), - follow_redirects=False, - ) - client.cookies.jar.set_policy(DefaultCookiePolicy(allowed_domains=[])) - _download_client = client - return _download_client - - -def shared_async_client() -> httpx.AsyncClient: - """One keep-alive client for the sign-in calls (connect bounded to 5 s, connect retries).""" - global _async_client - if _async_client is None: - _async_client = httpx.AsyncClient( - timeout=httpx.Timeout(30.0, connect=5.0), - limits=httpx.Limits(max_connections=50, max_keepalive_connections=10, keepalive_expiry=60), - transport=httpx.AsyncHTTPTransport(retries=3), - ) - return _async_client - - -async def close_shared_client(): - global _async_client, _download_client - client, _async_client = _async_client, None - if client is not None: - await client.aclose() - with _download_lock: - download, _download_client = _download_client, None - if download is not None: - download.close() - - -async def warm_up(): - """Open connections to Microsoft's login and Graph hosts so the first real call skips DNS, TCP and TLS.""" - await asyncio.to_thread(entrance.warm_up, [config.TOKEN_URL, "https://graph.microsoft.com/v1.0/"]) - - -async def _post(url: str, data: dict, client: httpx.AsyncClient | None) -> dict: - client = client or shared_async_client() - started = time.perf_counter() - try: - response = await client.post(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) - return response.json() - finally: - log.debug("POST %s took %.0f ms", url, (time.perf_counter() - started) * 1000) - - -def stream_download(url: str, limit: int, follow_redirects: bool = True): - """Yields a pre-authorized download URL's bytes in chunks (no bearer token is sent), stopping past `limit` bytes.""" - with download_client().stream("GET", url, timeout=httpx.Timeout(60.0, connect=5.0), follow_redirects=follow_redirects) as r: - r.raise_for_status() - sent = 0 - for chunk in r.iter_bytes(64 * 1024): - sent += len(chunk) - if sent > limit: - return - yield chunk - - -# Read helpers, kept tiny so routers stay declarative. Each returns plain JSON-able data. -def read(outnote: OutNote, action: Callable[[OutNote], object]) -> object: - return action(outnote) diff --git a/src/password_signin.py b/src/password_signin.py deleted file mode 100644 index 84f6e53..0000000 --- a/src/password_signin.py +++ /dev/null @@ -1,392 +0,0 @@ -"""Microsoft password sign-in with an interactive second factor, as short-lived in-memory jobs. - -The ONE place in this service that receives a Microsoft password. It is handed to Entrance once, -on a worker thread, wrapped in a one-shot `entrance.Secret`; Entrance posts it to Microsoft and -drops it. It is never stored, cached, logged or returned, and never lives on a job: a job only holds -progress (state, methods, the push number, the result). Jobs are in memory only, so the service must -run as ONE process (see asgi.py) - a second worker would not know a job started on the first. - -Entrance runs the sign-in on the worker thread and calls back into the job (an `MfaPrompt`) when -Microsoft asks for MFA; the job blocks there on a condition until the HTTP side supplies the choice -or the code. -""" - -import hashlib -import logging -import os -import secrets -import threading -import time -from collections import deque - -import entrance -from entrance import MfaCancelled, MfaPrompt, MfaSwitch, Secret - -log = logging.getLogger("schuly.microsoft.password") - -JOB_TTL = int(os.getenv("PASSWORD_SIGNIN_TTL", "600")) # hard cap per job, seconds (<= 10 min) -JOB_TTL = min(JOB_TTL, 600) -DONE_TTL = 120 # a finished job stays readable this long (a lost response can be re-read) -MAX_JOBS = int(os.getenv("PASSWORD_SIGNIN_MAX_JOBS", "100")) -CLIENT_LIMIT = int(os.getenv("PASSWORD_SIGNIN_CLIENT_LIMIT", "30")) # starts per Schuly user per minute -ACCOUNT_LIMIT = int(os.getenv("PASSWORD_SIGNIN_ACCOUNT_LIMIT", "6")) # FAILED password attempts per (Schuly user, account) per 10 minutes -OTP_JOB_BUDGET = 5 # authenticator codes tried per job, however often the method is re-chosen -OTP_ACCOUNT_BUDGET = 10 # codes per (Schuly user, account) per hour, across jobs -PUSH_TIMEOUT = 120 -PUSH_INTERVAL = 1.5 -OTP_ATTEMPTS = entrance.OTP_ATTEMPTS - -# States. Transient ones mean "the worker is busy"; everything else is a place the caller can act on. -WORKING, STARTING_PUSH, STARTING_OTP, VERIFYING = "working", "starting_push", "starting_otp", "verifying" -MFA_REQUIRED, PUSH_PENDING, OTP_REQUIRED = "mfa_required", "push_pending", "otp_required" -DENIED, TIMED_OUT, COMPLETE, ERROR = "denied", "timed_out", "complete", "error" -TRANSIENT = {WORKING, STARTING_PUSH, STARTING_OTP, VERIFYING} -CHOOSABLE = {MFA_REQUIRED, DENIED, TIMED_OUT} # states where /method picks a (new) method - -MESSAGES = { - "invalid_credentials": "Wrong email or password.", - "unsupported": "This Microsoft account can't be signed in with a password here.", - "blocked": "Microsoft blocked this sign-in because of a security policy.", - "failed": "The Microsoft sign-in failed.", - "otp_exhausted": "Too many wrong codes. Start the sign-in again later.", -} - - -class RateLimited(Exception): - """Too many sign-in attempts (per client or per account) or too many jobs in progress.""" - - -class RateLimiter: - """Sliding-window counter keyed by an opaque string (never the raw account name).""" - - def __init__(self): - self._hits: dict[str, deque] = {} - self._lock = threading.Lock() - - def allow(self, key: str, limit: int, window: float) -> bool: - now = time.monotonic() - with self._lock: - hits = self._hits.setdefault(key, deque()) - while hits and now - hits[0] > window: - hits.popleft() - if len(hits) >= limit: - return False - hits.append(now) - return True - - def add(self, key: str): - with self._lock: - self._hits.setdefault(key, deque()).append(time.monotonic()) - - def count(self, key: str, window: float) -> int: - now = time.monotonic() - with self._lock: - hits = self._hits.get(key) - if not hits: - return 0 - while hits and now - hits[0] > window: - hits.popleft() - return len(hits) - - def sweep(self, window: float = 600): - now = time.monotonic() - with self._lock: - for key in [k for k, h in self._hits.items() if not h or now - h[-1] > window]: - del self._hits[key] - - -def failure_for(ex: BaseException) -> tuple[str, str]: - """Map a sign-in exception to a stable (code, message). Only the exception TYPE decides; the - exception text is never copied into a response.""" - if isinstance(ex, entrance.InvalidCredentials): - code = "invalid_credentials" - elif isinstance(ex, entrance.OtpRejected): - code = "otp_exhausted" - elif isinstance(ex, entrance.Unsupported): - code = "unsupported" - elif isinstance(ex, entrance.Blocked): - code = "blocked" - else: - code = "failed" - return code, MESSAGES[code] - - -class SignInJob(MfaPrompt): - """Progress of one sign-in, and the `MfaPrompt` Entrance calls back into. Holds no password.""" - - def __init__(self, job_id: str, user: str, account: str, failures: "RateLimiter", otp_hits: "RateLimiter"): - self.id = job_id - self.user = user # hash of the Schuly user id that started the job; only that user may touch it - self.acct = account # hash of (Schuly user, Microsoft account name) - self.replaces: str | None = None # this user's earlier job for the same account, replaced once the password passed - self._failures = failures - self._otp_hits = otp_hits - self.otp_used = 0 - self._budget_out = False - self.expires = time.monotonic() + JOB_TTL - self.done_at: float | None = None - self.cond = threading.Condition() - self.state = WORKING - self.version = 0 - self.methods: list[str] = [] - self.notice: str | None = None - self.number: str | None = None - self.push_deadline = 0.0 - self.attempts_left = 0 - self.otp_invalid = False - self.requested: str | None = None - self.code: str | None = None # a one-time authenticator code, taken (cleared) by the worker - self.refresh_token: str | None = None - self.account = None - self.error_code: str | None = None - self.error_message: str | None = None - self.cancel = False - self._retry_notice: str | None = None - - # -- state --------------------------------------------------------------------------------- - def _set(self, state: str, **fields): - """Move to a state (caller holds `cond`) and wake every waiter.""" - self.state = state - self.version += 1 - for key, value in fields.items(): - setattr(self, key, value) - self.cond.notify_all() - - def _dead(self) -> bool: - return self.cancel or time.monotonic() > self.expires - - def _wait(self, predicate): - """Block (holding `cond`) until `predicate()` or the job is cancelled / expired.""" - while not predicate(): - if self._dead(): - raise MfaCancelled("Sign-in cancelled") - self.cond.wait(timeout=max(0.05, min(1.0, self.expires - time.monotonic()))) - - def stop(self): - with self.cond: - self.cancel = True - self.cond.notify_all() - - def wait_settled(self, after_version: int, timeout: float) -> bool: - """Wait (from the HTTP side) until a non-transient state newer than `after_version`.""" - deadline = time.monotonic() + timeout - with self.cond: - while not (self.version > after_version and self.state not in TRANSIENT): - left = deadline - time.monotonic() - if left <= 0: - return False - self.cond.wait(timeout=left) - return True - - def snapshot(self) -> dict: - """Everything a response needs, taken under the lock. Never contains the password.""" - with self.cond: - snap = {"state": self.state, "version": self.version, "methods": list(self.methods), "notice": self.notice} - if self.state == PUSH_PENDING: - snap["number"] = self.number - snap["expires_in"] = max(0, int(self.push_deadline - time.monotonic())) - if self.state in (OTP_REQUIRED, MFA_REQUIRED) or self.otp_invalid: - snap["attempts_left"] = self.attempts_left - snap["otp_invalid"] = self.otp_invalid - if self.state == COMPLETE: - snap["refresh_token"] = self.refresh_token - if self.state == ERROR: - snap["code"], snap["message"] = self.error_code, self.error_message - return snap - - # -- actions from the HTTP side -------------------------------------------------------------- - def request_method(self, method: str) -> str: - """Ask for a method. Returns "ok", "same" (already on it), "unavailable" (not offered) or "invalid_state".""" - with self.cond: - if method not in self.methods: - return "unavailable" - if self.state in CHOOSABLE or (self.state == PUSH_PENDING and method == "otp") or (self.state == OTP_REQUIRED and method == "push"): - self.requested = method - self.cond.notify_all() - return "ok" - if (self.state == PUSH_PENDING and method == "push") or (self.state == OTP_REQUIRED and method == "otp"): - return "same" # already on that method - return "invalid_state" - - def submit_code(self, code: str) -> bool: - with self.cond: - if self.state != OTP_REQUIRED or self.code is not None: - return False - self.code = code - self.cond.notify_all() - return True - - # -- MfaPrompt (called by Entrance on the worker thread) ------------------------------------- - def choose(self, methods): - with self.cond: - self.methods = list(methods) - if self.requested not in methods: - notice, self._retry_notice = self._retry_notice, None - state = notice if notice in (DENIED, TIMED_OUT) else MFA_REQUIRED - self._set(state, notice=notice, number=None) - self._wait(lambda: self.requested in self.methods) - method, self.requested = self.requested, None - self._set(STARTING_PUSH if method == "push" else STARTING_OTP, notice=None, otp_invalid=False) - return method - - def on_push(self, event): - if event.state == entrance.NUMBER_MATCHING: - with self.cond: - self._set(PUSH_PENDING, number=event.number, push_deadline=time.monotonic() + (event.expires_in or PUSH_TIMEOUT)) - elif event.state == entrance.APPROVED: - with self.cond: - self._set(VERIFYING) - - def should_switch(self): - return self.requested is not None - - def cancelled(self): - return self._dead() - - def otp(self, attempt): - with self.cond: - if self.otp_used >= OTP_JOB_BUDGET or self._otp_hits.count(self.acct, 3600) >= OTP_ACCOUNT_BUDGET: - self._budget_out = True - raise entrance.OtpRejected("authenticator code budget spent", attempts=self.otp_used) - self.attempts_left = min(OTP_ATTEMPTS - attempt + 1, OTP_JOB_BUDGET - self.otp_used) - self._set(OTP_REQUIRED, otp_invalid=attempt > 1) - self._wait(lambda: self.code is not None or self.requested is not None) - if self.code is None: - raise MfaSwitch() - code, self.code = self.code, None - self.otp_used += 1 - self._otp_hits.add(self.acct) - self._set(VERIFYING) - return code - - def retry(self, error): - if isinstance(error, entrance.PushError) and error.state in (DENIED, TIMED_OUT): - self._retry_notice = error.state - return True - if isinstance(error, entrance.OtpRejected): - if self._budget_out or self.otp_used >= OTP_JOB_BUDGET: - return False # terminal: a fresh sign-in is required - with self.cond: - self.attempts_left = 0 - self.otp_invalid = True - self._retry_notice = "otp_exhausted" - return True - return False - - # -- the worker -------------------------------------------------------------------------------- - def run(self, username: str, secret: Secret): - try: - broker = entrance.foci_login(username, secret, cookies=None, frt_store=None, mfa=self, push_timeout=PUSH_TIMEOUT, push_interval=PUSH_INTERVAL) - with self.cond: - self.done_at = time.monotonic() - self._set(COMPLETE, refresh_token=broker.refresh_token) - except MfaCancelled: - log.info("password sign-in %s cancelled", self.id) - except BaseException as ex: # noqa: BLE001 - the worker must always end in a terminal state - code, message = failure_for(ex) - if code == "invalid_credentials": - self._failures.add(self.acct) # only failed password attempts count against the limiter - log.warning("password sign-in %s ended: %s (%s)", self.id, code, type(ex).__name__) - with self.cond: - self.done_at = time.monotonic() - self._set(ERROR, error_code=code, error_message=message) - finally: - secret.take() # no-op once submitted; guarantees nothing outlives the call - - -class JobStore: - """In-memory jobs plus the rate limits. One instance per process.""" - - def __init__(self): - self._jobs: dict[str, SignInJob] = {} - self._by_owner: dict[str, str] = {} - self._lock = threading.Lock() - self._starts = RateLimiter() - self._failures = RateLimiter() - self._otp_hits = RateLimiter() - - @staticmethod - def _hash(*parts: str) -> str: - return hashlib.sha256("\x00".join(p.strip().lower() for p in parts).encode()).hexdigest() - - def start(self, user_id: str, username: str, secret: Secret) -> SignInJob: - user = self._hash(user_id) - account = self._hash(user_id, username) - if not self._starts.allow(user, CLIENT_LIMIT, 60): - raise RateLimited("Too many sign-in attempts.") - if self._failures.count(account, 600) >= ACCOUNT_LIMIT: - raise RateLimited("Too many failed attempts for this account. Try again later.") - job = SignInJob(secrets.token_urlsafe(24), user, account, self._failures, self._otp_hits) - with self._lock: - self._prune() - if len(self._jobs) >= MAX_JOBS: - raise RateLimited("Too many sign-ins in progress.") - job.replaces = self._by_owner.get(account) - self._jobs[job.id] = job - threading.Thread(target=job.run, args=(username.strip(), secret), name=f"pwd-signin-{job.id[:6]}", daemon=True).start() - return job - - def promote(self, job: SignInJob): - """The password passed: this job becomes the user's current one for the account and replaces their earlier one.""" - with self._lock: - if job.id not in self._jobs: - return - previous = self._jobs.get(job.replaces or "") - if previous is not None and previous.user == job.user: - self._drop(previous) - self._by_owner[job.acct] = job.id - - def get(self, job_id: str, user_id: str) -> SignInJob | None: - """The job, only for the Schuly user that started it (anyone else gets None, like an unknown id).""" - with self._lock: - job = self._jobs.get(job_id) - if job is None or job.user != self._hash(user_id): - return None - if self._expired(job): - self._drop(job) - return None - return job - - def remove(self, job_id: str, user_id: str): - with self._lock: - job = self._jobs.get(job_id) - if job is not None and job.user == self._hash(user_id): - self._drop(job) - - def sweep(self): - with self._lock: - self._prune() - self._starts.sweep(60) - self._failures.sweep(600) - self._otp_hits.sweep(3600) - - def clear(self): - with self._lock: - for job in self._jobs.values(): - job.stop() - self._jobs.clear() - self._by_owner.clear() - self._starts = RateLimiter() - self._failures = RateLimiter() - self._otp_hits = RateLimiter() - - def __len__(self): - return len(self._jobs) - - @staticmethod - def _expired(job: SignInJob) -> bool: - now = time.monotonic() - return now > job.expires or (job.done_at is not None and now - job.done_at > DONE_TTL) - - def _drop(self, job: SignInJob): - job.stop() - self._jobs.pop(job.id, None) - if self._by_owner.get(job.acct) == job.id: - del self._by_owner[job.acct] - - def _prune(self): - for job in [j for j in self._jobs.values() if self._expired(j)]: - self._drop(job) - - -store = JobStore() diff --git a/src/people_photos.py b/src/people_photos.py deleted file mode 100644 index dcc487e..0000000 --- a/src/people_photos.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Profile photos by e-mail address, for mail senders (which come with an address, not a user id). - -Graph accepts a UPN in `users/{id}`, and UPN and mail are usually equal, so the address is tried -directly first; when that 404s, the user is resolved with `users?$filter=mail eq '...'`. A person -outside the tenant resolves to nobody and reads as no photo. Results are cached per account: a -photo for 6 hours, a miss for 30 minutes. -""" - -from urllib.parse import quote - -from outnote import GraphError - -from src.account_cache import for_broker - -HIT_TTL = 6 * 3600 -MISS_TTL = 30 * 60 -MAX_CACHED_BYTES = 200 * 1024 - - -class Throttled(RuntimeError): - def __init__(self, retry_after: float | None = None): - self.retry_after = retry_after - super().__init__("throttled") - - -def _odata_string(value: str) -> str: - return value.replace("'", "''") - - -def _first(note, user: str, size: int) -> bytes | None: - seg = quote(user, safe="@") - for path in (f"users/{seg}/photos/{size}x{size}/$value", f"users/{seg}/photo/$value"): - try: - return note.get(path, raw="bytes") - except GraphError as ex: - if ex.status == 429: - raise Throttled(ex.retry_after) from None - if ex.status not in (400, 404): - raise - return None - - -def _resolve(note, email: str) -> str | None: - try: - found = note.list("users", {"$filter": f"mail eq '{_odata_string(email)}'", "$select": "id", "$top": 1}, limit=1) - except GraphError as ex: - if ex.status == 429: - raise Throttled(ex.retry_after) from None - if ex.status in (400, 403, 404): - return None - raise - return found[0].get("id") if found else None - - -def photo(note, broker, email: str, size: int) -> bytes | None: - email = email.strip().lower() - cache = for_broker(broker) - key = ("people_photo", email, size) - marker = object() - hit = cache.get(key, marker) - if hit is not marker: - return hit - data = _first(note, email, size) - if data is None: - user_id = _resolve(note, email) - if user_id: - data = _first(note, user_id, size) - if data is None: - cache.set(key, None, MISS_TTL) - elif len(data) <= MAX_CACHED_BYTES: - cache.set(key, data, HIT_TTL) - return data diff --git a/src/profile.py b/src/profile.py deleted file mode 100644 index 75c4405..0000000 --- a/src/profile.py +++ /dev/null @@ -1,77 +0,0 @@ -"""The signed-in user's own profile (`GET /me`), cached per account, never stalled by throttling. - -Graph throttles `/me` (a directory read) on its own, with 429 and Retry-After ~21 s, even when every -other route answers at once. OutNote sleeps each Retry-After in the worker thread and retries up to -4 times, which made the first `/api/me` after a start take 42 s (two 21 s waits). So `/me` is asked -exactly once, without OutNote's retries; on 429 (or any failure) the profile comes from the claims of -the access token the broker just minted (oid, name, upn), which Microsoft signed for this user. A -`/me` answer is kept for an hour, a profile from claims for five minutes, so `/me` is tried again later. -""" - -import base64 -import binascii -import json -import logging - -from entrance import HTTPError, http_client -from outnote import C_OFFICE, GraphError - -from src.account_cache import for_broker - -log = logging.getLogger("schuly.microsoft") - -CACHE_KEY = "me_profile" -TTL = 3600.0 -CLAIMS_TTL = 300.0 -ME_URL = "https://graph.microsoft.com/v1.0/me" -ME_TIMEOUT = 10.0 - - -def get(note, broker) -> dict: - cache = for_broker(broker) - hit = cache.get(CACHE_KEY) - if hit is not None: - return hit - with cache.lock(CACHE_KEY): - hit = cache.get(CACHE_KEY) - if hit is None: - hit, ttl = _fetch(note, broker) - cache.set(CACHE_KEY, hit, ttl) - return hit - - -def _fetch(note, broker) -> tuple[dict, float]: - token = broker(C_OFFICE) - status = None - try: - r = http_client().request("GET", ME_URL, headers={"Authorization": f"Bearer {token}"}, timeout=ME_TIMEOUT) - status = r.status_code - if status == 200: - return r.json(), TTL - log.info("/me answered %s (Retry-After %s); using the token's claims", status, r.headers.get("Retry-After")) - except (*HTTPError, ValueError) as ex: # entrance's HTTPError is a tuple, so it is unpacked - log.info("/me failed (%s); using the token's claims", type(ex).__name__) - claims = from_claims(token) - if claims is None: - raise GraphError(status or 502, "The profile is unavailable.") - return claims, CLAIMS_TTL - - -def from_claims(access_token: str) -> dict | None: - """A `/me`-shaped profile from the access token's claims, or None when it carries no user object id.""" - try: - payload = access_token.split(".")[1] - claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) - except (IndexError, ValueError, binascii.Error): - return None - if not isinstance(claims, dict) or not isinstance(claims.get("oid"), str): - return None - upn = claims.get("upn") or claims.get("unique_name") or claims.get("preferred_username") - return { - "id": claims["oid"], - "displayName": claims.get("name"), - "givenName": claims.get("given_name"), - "surname": claims.get("family_name"), - "userPrincipalName": upn, - "mail": claims.get("email") or upn, - } diff --git a/src/search.py b/src/search.py deleted file mode 100644 index b06a127..0000000 --- a/src/search.py +++ /dev/null @@ -1,371 +0,0 @@ -"""Search over files, mail, chats and channel posts with the Microsoft Search API, with the place each hit lives in. - -Files (`driveItem`) and messages (`message` + `chatMessage`) can't share one Search API request ("Invalid entity type combination"), -so they run as two requests in parallel; a source that fails is reported in `errors` and never fails the search. When the Search API -refuses the file search (400 / 403 / 5xx), the user's drive and each joined team's library are searched one by one instead. The query -text is never logged; it only travels in the request body to Microsoft (and, for the drive fallback, in a Graph URL). -""" - -import logging -import re -from concurrent.futures import ThreadPoolExecutor -from html import unescape -from urllib.parse import quote, unquote - -from outnote import GraphError - -from src import teams_files -from src.account_cache import for_broker - -log = logging.getLogger("schuly.microsoft") - -SNIPPET_MAX = 300 -MAP_TTL = 3600 -SHARED_TTL = 600 -FALLBACK_WORKERS = 4 -FALLBACK_PER_DRIVE = 50 -TEAM_SEARCH_CAP = 200 -TAGS = re.compile(r"<[^>]*>") -TYPES = ("files", "mail", "chats", "channels") - - -def snippet(summary) -> str: - """The Search API's summary as plain text: `` becomes an ellipsis, highlight and other tags go, entities are decoded.""" - if not isinstance(summary, str): - return "" - text = summary.replace("", "…") - text = unescape(TAGS.sub("", text)) - text = " ".join(text.split()) - return text if len(text) <= SNIPPET_MAX else text[: SNIPPET_MAX - 1].rstrip() + "…" - - -def reason(ex: Exception) -> str: - if isinstance(ex, GraphError): - if ex.status in (401, 403): - return f"not permitted ({ex.status})" - if ex.status == 429: - return "throttled (429)" - if ex.status >= 500: - return f"service error ({ex.status})" - return f"request refused ({ex.status})" - return "request failed" - - -def _sender(person): - """`{displayName, email}` out of a Graph emailAddress wrapper, an identitySet or a bare user.""" - if not isinstance(person, dict): - return None - inner = person.get("emailAddress") or person.get("user") or person - name, mail = inner.get("name") or inner.get("displayName"), inner.get("address") or inner.get("email") - return {"displayName": name, "email": mail} if name or mail else None - - -def _identity(value): - return _sender(value) if isinstance(value, dict) else None - - -# --- Search API ------------------------------------------------------------- -def _query(note, entities: list[str], q: str, start: int, size: int): - """One Search API request: (hits, total, more).""" - body = {"requests": [{"entityTypes": entities, "query": {"queryString": q}, "from": start, "size": size}]} - data = note.post("search/query", body) or {} - hits, total, more = [], 0, False - for container in ((data.get("value") or [{}])[0].get("hitsContainers") or []): - hits.extend(container.get("hits") or []) - total += int(container.get("total") or 0) - more = more or bool(container.get("moreResultsAvailable")) - return hits, total, more - - -def file_hit(hit: dict) -> dict | None: - r = hit.get("resource") if isinstance(hit.get("resource"), dict) else {} - ref = r.get("parentReference") or {} - item_id = r.get("id") or hit.get("hitId") - if not item_id: - return None - out = {"type": "file", "id": item_id, "driveId": ref.get("driveId"), "title": r.get("name"), "snippet": snippet(hit.get("summary")), "date": r.get("lastModifiedDateTime"), "webUrl": r.get("webUrl"), "size": r.get("size"), "isFolder": r.get("folder") is not None, "mimeType": (r.get("file") or {}).get("mimeType"), "from": _identity(r.get("lastModifiedBy") or r.get("createdBy"))} - path = ref.get("path") - if isinstance(path, str) and "root:" in path: - out["_path"] = _clean_path(path) - return out - - -def _clean_path(raw: str) -> str: - return unquote(raw.split("root:", 1)[1]).strip("/") - - -def message_hit(hit: dict) -> dict | None: - r = hit.get("resource") if isinstance(hit.get("resource"), dict) else {} - kind = str(r.get("@odata.type") or "") - mid = r.get("id") or hit.get("hitId") - if not mid: - return None - if kind.endswith("chatMessage") or "channelIdentity" in r or "chatId" in r: - sender = _sender(r.get("from")) - channel = r.get("channelIdentity") if isinstance(r.get("channelIdentity"), dict) else None - return {"type": "channel" if channel else "chat", "id": mid, "title": r.get("subject") or (sender or {}).get("displayName") or "Chat", "snippet": snippet(hit.get("summary")), "date": r.get("createdDateTime"), "webUrl": r.get("webLink") or r.get("webUrl"), "from": sender, "_channel": channel, "_chatId": r.get("chatId")} - return {"type": "mail", "id": mid, "title": r.get("subject"), "snippet": snippet(hit.get("summary") or r.get("bodyPreview")), "date": r.get("receivedDateTime") or r.get("sentDateTime"), "webUrl": r.get("webLink"), "from": _sender(r.get("from"))} - - -# --- location context ------------------------------------------------------- -class Context: - """Lazily loaded lookups shared by the hits of one search; each failure only costs the detail it would have given.""" - - def __init__(self, note, broker): - self.note, self.cache = note, for_broker(broker) - self.broker = broker - self._own = self._drives = self._shared = None - self._teams = None - self._channels: dict[str, dict[str, str]] = {} - - def teams(self) -> dict[str, str]: - if self._teams is None: - try: - self._teams = {t["id"]: t.get("displayName") or "" for t in teams_files.joined_teams(self.note, self.broker)} - except Exception as ex: - log.info("search: joined teams failed: %s", type(ex).__name__) - self._teams = {} - return self._teams - - def own_drive(self): - if self._own is None: - try: - self._own = teams_files.own_drive_id(self.note, self.broker) or "" - except Exception as ex: - log.info("search: own drive failed: %s", type(ex).__name__) - self._own = "" - return self._own - - def team_drives(self) -> dict[str, str]: - """Library drive id -> team id for the joined teams (cached 1 h).""" - if self._drives is None: - hit = self.cache.get(("search", "team_drives")) - if hit is None: - hit = {} - complete = True - - def one(team_id): - try: - return team_id, teams_files.team_drive_id(self.note, self.broker, team_id) - except Exception: - return team_id, None - - with ThreadPoolExecutor(max_workers=FALLBACK_WORKERS) as pool: - for team_id, drive_id in pool.map(one, list(self.teams())): - if drive_id: - hit[drive_id] = team_id - else: - complete = False - self.cache.set(("search", "team_drives"), hit, MAP_TTL if complete else 300) - self._drives = hit - return self._drives - - def channel_names(self, team_id: str) -> dict[str, str]: - """Channel id -> name (cached through teams_files.channels).""" - if team_id not in self._channels: - try: - self._channels[team_id] = {c["id"]: c.get("displayName") or "" for c in teams_files.channels(self.note, self.broker, team_id)} - except Exception as ex: - log.info("search: channels failed: %s", type(ex).__name__) - self._channels[team_id] = {} - return self._channels[team_id] - - def shared_ids(self) -> set[str]: - if self._shared is None: - hit = self.cache.get(("search", "shared_ids")) - if hit is None: - try: - hit = {i["id"] for i in teams_files.shared_with_me(self.note, 200)} - except Exception as ex: - log.info("search: sharedWithMe failed: %s", type(ex).__name__) - hit = set() - self.cache.set(("search", "shared_ids"), hit, SHARED_TTL) - self._shared = hit - return self._shared - - -def _paths(note, hits: list[dict]): - """Fills `_path` of file hits that have none through `$batch` (parentReference.path is not in Search API results).""" - missing = [h for h in hits if h.get("driveId") and "_path" not in h] - if not missing: - return - try: - rows = note.batch([f"drives/{teams_files.seg(h['driveId'])}/items/{teams_files.seg(h['id'])}?$select=id,parentReference" for h in missing]) - except Exception as ex: - log.info("search: path lookup failed: %s", type(ex).__name__) - return - for h, row in zip(missing, rows): - path = ((row or {}).get("parentReference") or {}).get("path") if isinstance(row, dict) else None - if isinstance(path, str) and "root:" in path: - h["_path"] = _clean_path(path) - elif isinstance(path, str) and path.endswith("/root"): - h["_path"] = "" - - -def _join(*parts) -> str: - return " > ".join(p for p in parts if p) - - -def locate(hits: list[dict], ctx: Context): - """Adds `location` to every hit and drops the helper keys.""" - files = [h for h in hits if h["type"] == "file"] - if files: - _paths(ctx.note, files) - own = ctx.own_drive() if files else "" - drives = ctx.team_drives() if any(h.get("driveId") and h["driveId"] != own for h in files) else {} - for h in hits: - if h["type"] == "file": - path = h.pop("_path", None) - drive = h.get("driveId") - loc = {"path": path} - if drive and drive == own: - loc["kind"] = "onedrive" - loc["label"] = _join("OneDrive", path) - elif drive in drives: - team_id = drives[drive] - name = ctx.teams().get(team_id) or "" - loc.update(kind="team", teamId=team_id, teamName=name) - top = (path or "").split("/", 1)[0] - if top: - for cid, cname in ctx.channel_names(team_id).items(): - if cname == top: - loc.update(channelId=cid, channelName=cname) - break - loc["label"] = _join(f"Team {name}" if name else "Team", path) - elif h["id"] in ctx.shared_ids(): - loc["kind"] = "shared" - loc["label"] = _join("Shared with me", path) - else: - loc["kind"] = "sharepoint" - loc["label"] = _join("SharePoint", path) - h["location"] = loc - elif h["type"] == "mail": - h["location"] = {"kind": "mailbox", "label": "Mailbox"} - elif h["type"] == "channel": - channel = h.pop("_channel", None) or {} - h.pop("_chatId", None) - team_id, channel_id = channel.get("teamId"), channel.get("channelId") - team_name = ctx.teams().get(team_id) if team_id else None - channel_name = ctx.channel_names(team_id).get(channel_id) if team_id and channel_id else None - h["location"] = {"kind": "channel", "teamId": team_id, "teamName": team_name, "channelId": channel_id, "channelName": channel_name, "label": _join(f"Team {team_name}" if team_name else "Team", channel_name)} - else: - chat_id = h.pop("_chatId", None) - h.pop("_channel", None) - h["location"] = {"kind": "chat", "chatId": chat_id, "label": "Chat"} - - -# --- fallback and team scope ------------------------------------------------ -def drive_search(note, drive_id: str | None, q: str, top: int) -> list[dict]: - """Hits of `drives/{d}/root/search(q=)` (or the user's own drive when `drive_id` is None) shaped like Search API hits.""" - literal = quote(q.replace("'", "''"), safe="") - base = f"drives/{teams_files.seg(drive_id)}" if drive_id else "me/drive" - rows = note.list(f"{base}/root/search(q='{literal}')", {"$top": min(top, 200), "$select": teams_files.ITEM_FIELDS}, limit=top) - return [{"resource": teams_files._with_drive(r, drive_id)} for r in rows] - - -def fallback_files(note, broker, q: str, ctx: Context): - """Own drive + each joined team's library, searched in parallel; returns (hits, errors).""" - targets: list[str | None] = [None] - errors = [] - try: - targets += list(ctx.team_drives()) - except Exception as ex: - errors.append(f"teams: {reason(ex)}") - - def one(drive_id): - try: - return drive_search(note, drive_id, q, FALLBACK_PER_DRIVE), None - except Exception as ex: - if "invalid_grant" in str(ex): - raise - return [], reason(ex) - - hits, failed = [], 0 - with ThreadPoolExecutor(max_workers=FALLBACK_WORKERS) as pool: - for found, err in pool.map(one, targets): - hits.extend(found) - failed += 1 if err else 0 - if failed: - errors.append(f"files: {failed} of {len(targets)} libraries could not be searched") - return hits, errors - - -def _by_date(hits: list[dict]) -> list[dict]: - return sorted(hits, key=lambda h: (h.get("resource") or {}).get("lastModifiedDateTime") or "", reverse=True) - - -# --- entry point ------------------------------------------------------------ -def run(note, broker, q: str, types: list[str], start: int, size: int, team_id: str | None = None) -> dict: - """The SearchResult for `q` over the wanted `types` (files, mail, chats, channels), optionally limited to one team.""" - wanted = [t for t in TYPES if t in types] - if team_id: - wanted = [t for t in wanted if t in ("files", "channels")] - ctx = Context(note, broker) - errors: list[str] = [] - out: list[dict] = [] - totals = {"files": 0, "messages": 0} - more = False - fallback = False - - def files_source(): - if team_id: - drive_id = teams_files.team_drive_id(note, broker, team_id) - rows = drive_search(note, drive_id, q, min(start + size + 1, TEAM_SEARCH_CAP)) - page = rows[start : start + size] - return page, len(rows), len(rows) > start + size, False - try: - hits, total, has_more = _query(note, ["driveItem"], q, start, size) - return hits, total, has_more, False - except GraphError as ex: - if ex.status in (400, 403) or ex.status >= 500: - found, errs = fallback_files(note, broker, q, ctx) - errors.extend(errs) - found = _by_date(found) - return found[start : start + size], len(found), len(found) > start + size, True - raise - - entities = [] - if "mail" in wanted: - entities.append("message") - if "chats" in wanted or "channels" in wanted: - entities.append("chatMessage") - - def messages_source(): - hits, total, has_more = _query(note, entities, q, start, size) - return hits, total, has_more - - jobs = {} - with ThreadPoolExecutor(max_workers=2) as pool: - if "files" in wanted: - jobs["files"] = pool.submit(files_source) - if entities: - jobs["messages"] = pool.submit(messages_source) - results = {} - for name, future in jobs.items(): - try: - results[name] = future.result() - except Exception as ex: - if "invalid_grant" in str(ex): - raise - label = name if name == "files" else ("mail" if entities == ["message"] else "messages") - errors.append(f"{label}: {reason(ex)}") - if "files" in results: - hits, totals["files"], has_more, fallback = results["files"] - more = more or has_more - out.extend(h for h in (file_hit(x) for x in hits) if h) - if "messages" in results: - hits, totals["messages"], has_more = results["messages"] - more = more or has_more - for h in (message_hit(x) for x in hits): - if h is None: - continue - if h["type"] == "mail" and "mail" not in wanted: - continue - if h["type"] == "chat" and "chats" not in wanted: - continue - if h["type"] == "channel" and ("channels" not in wanted or (team_id and (h.get("_channel") or {}).get("teamId") != team_id)): - continue - out.append(h) - locate(out, ctx) - out.sort(key=lambda h: h.get("date") or "", reverse=True) - return {"query": q, "hits": out, "from": start, "size": size, "moreAvailable": more, "nextFrom": start + size if more else None, "totals": totals, "fallback": fallback, "errors": errors} diff --git a/src/teams_chat.py b/src/teams_chat.py deleted file mode 100644 index 6b4f59b..0000000 --- a/src/teams_chat.py +++ /dev/null @@ -1,235 +0,0 @@ -"""Group chat pictures from the Teams chat service. - -Graph has no endpoint for a chat's custom picture, but the Teams clients read it from the chat -service: the conversation's `threadProperties.picture` holds `URL@`, and the -image is served by AMS (the async media gateway). Both accept a skype token, which the Teams auth -service issues for a token to `api.spaces.skype.com`: - -1. broker(Teams client, https://api.spaces.skype.com/.default) -> access token (FOCI redeem) -2. POST https://teams.microsoft.com/api/authsvc/v1.0/authz (Bearer) -> skypeToken, regionGtms.chatService -3. GET {chatService}/v1/users/ME/conversations/{chatId}?view=msnp24Equivalent (Authentication: skypetoken=...) -4. GET (Authorization: skype_token ...) -> image bytes - -The skype token and each chat's picture URL are cached per account (see `account_cache`). -""" - -import time -from concurrent.futures import ThreadPoolExecutor, wait -from urllib.parse import quote, urlsplit - -import httpx -from entrance import TokenError, http_client - -from src import outnote_client -from src.account_cache import for_broker - -TEAMS_CLIENT = "1fec8e78-bce4-4aaf-ab1b-5451cc387264" # Microsoft Teams, a FOCI family client -SPACES_SCOPE = "https://api.spaces.skype.com/.default" -AUTHZ_URL = "https://teams.microsoft.com/api/authsvc/v1.0/authz" -# Picture URLs come from chat data any member can change, so only Microsoft's media hosts are fetched (the skype token rides on that request). -PICTURE_HOSTS = (".teams.microsoft.com", ".asm.skype.com") -PICTURE_TTL = 3600 -SESSION_MARGIN = 120 -SESSION_RETRY = 600 # after a refused sign-in to Teams, the list stops asking for a while -MAX_PICTURE_BYTES = 5 * 1024 * 1024 -LOOKUP_WORKERS = 8 -LOOKUP_BUDGET = 4.0 # seconds the chat list waits for uncached picture lookups -_TIMEOUT = 15.0 -_STREAM_TIMEOUT = httpx.Timeout(15.0, connect=5.0) - - -class ChatServiceError(RuntimeError): - def __init__(self, status: int, message: str): - self.status = status - super().__init__(f"Teams chat service {status}: {message}") - - -def _session(broker) -> tuple[str, str]: - """(skype token, chat service base URL) for the account, minted once per token lifetime.""" - cache = for_broker(broker) - hit = cache.get("teams_session") - if hit: - return hit - with cache.lock("teams_session"): - hit = cache.get("teams_session") - if hit: - return hit - failed = cache.get("teams_session_failed") - if failed: - raise ChatServiceError(failed, "Teams sign-in refused recently") - try: - return _new_session(broker, cache) - except ChatServiceError as ex: - if ex.status in (401, 403): - cache.set("teams_session_failed", ex.status, SESSION_RETRY) - raise - - -def _new_session(broker, cache) -> tuple[str, str]: - try: - bearer = broker(TEAMS_CLIENT, SPACES_SCOPE) - except TokenError as ex: - if ex.error == "invalid_grant": - raise - # The tenant or client may not allow the Teams resource; that is a missing feature, not an expired sign-in. - raise ChatServiceError(403, ex.error) from None - r = http_client().post(AUTHZ_URL, headers={"Authorization": f"Bearer {bearer}"}, content=b"", timeout=_TIMEOUT) - if r.status_code != 200: - raise ChatServiceError(r.status_code, "authz refused") - body = r.json() - skype = (body.get("tokens") or {}).get("skypeToken") - service = ((body.get("regionGtms") or {}).get("chatService") or "").rstrip("/") - if not skype or not service.startswith("https://"): - raise ChatServiceError(502, "authz returned no skype token or chat service") - lifetime = int((body.get("tokens") or {}).get("expiresIn") or 3600) - session = (skype, service) - ams = _media_host((body.get("regionGtms") or {}).get("amsV2") or (body.get("regionGtms") or {}).get("ams")) - cache.set("teams_ams", ams, max(60, lifetime - SESSION_MARGIN)) - cache.set("teams_session", session, max(60, lifetime - SESSION_MARGIN)) - return session - - -def _drop_session(broker): - # A skype token the chat service no longer accepts; the next call mints a fresh one. - for_broker(broker).set("teams_session", None, 0) - - -def _media_host(value) -> str | None: - """The media service base URL when it is an https Microsoft media host, else None.""" - if not isinstance(value, str): - return None - parts = urlsplit(value) - host = (parts.hostname or "").lower() - return f"https://{host}" if parts.scheme == "https" and parts.port in (None, 443) and any(host.endswith(h) for h in PICTURE_HOSTS) else None - - -def _picture_url(value) -> str | None: - if not isinstance(value, str) or not value.startswith("URL@"): - return None - url = value[4:] - parts = urlsplit(url) - host = (parts.hostname or "").lower() - if parts.scheme != "https" or not any(host.endswith(h) for h in PICTURE_HOSTS): - return None - return url - - -def picture_url(broker, chat_id: str) -> str | None: - """The chat's custom picture URL, or None when it has none (or the chat service doesn't know the chat).""" - cache = for_broker(broker) - key = ("chat_picture", chat_id) - marker = object() - hit = cache.get(key, marker) - if hit is not marker: - return hit - skype, service = _session(broker) - r = http_client().request("GET", f"{service}/v1/users/ME/conversations/{quote(chat_id, safe='')}?view=msnp24Equivalent", headers={"Authentication": f"skypetoken={skype}"}, timeout=_TIMEOUT) - if r.status_code == 404: - url = None - elif r.status_code != 200: - if r.status_code == 401: - _drop_session(broker) - raise ChatServiceError(r.status_code, "conversation lookup failed") - else: - try: - url = _picture_url((r.json().get("threadProperties") or {}).get("picture")) - except (ValueError, AttributeError): - url = None - cache.set(key, url, PICTURE_TTL) - return url - - -def picture(broker, chat_id: str) -> bytes | None: - """The chat's custom picture bytes, or None when it has none. A skype token the service stopped accepting is replaced once.""" - try: - return _download(broker, chat_id) - except ChatServiceError as ex: - if ex.status != 401: - raise - return _download(broker, chat_id) - - -def _download(broker, chat_id: str) -> bytes | None: - url = picture_url(broker, chat_id) - if not url: - return None - skype, _ = _session(broker) - with outnote_client.download_client().stream("GET", url, headers={"Authorization": f"skype_token {skype}"}, timeout=_STREAM_TIMEOUT, follow_redirects=False) as r: - if r.status_code == 404: - for_broker(broker).set(("chat_picture", chat_id), None, PICTURE_TTL) - return None - if r.status_code != 200: - if r.status_code == 401: - _drop_session(broker) - raise ChatServiceError(r.status_code, "picture download failed") - data = bytearray() - for chunk in r.iter_bytes(): - data.extend(chunk) - if len(data) > MAX_PICTURE_BYTES: - raise ChatServiceError(413, "picture larger than 5 MB") - return bytes(data) - - -def mark_images(broker, chats: list[dict], budget: float = LOOKUP_BUDGET) -> list[dict]: - """Sets `hasImage` on each Graph chat. Only group chats can carry a custom picture; uncached ones are looked up in parallel within `budget` seconds, and a lookup that fails or runs late reads as False (it is cached once it lands).""" - cache = for_broker(broker) - found: dict[str, bool] = {} - todo = [] - for chat in chats: - cid = chat.get("id") - if chat.get("chatType") != "group" or not isinstance(cid, str): - found[cid] = False - continue - marker = object() - hit = cache.get(("chat_picture", cid), marker) - if hit is marker: - todo.append(cid) - else: - found[cid] = bool(hit) - if todo: - pool = ThreadPoolExecutor(max_workers=min(LOOKUP_WORKERS, len(todo))) - futures = {pool.submit(picture_url, broker, cid): cid for cid in todo} - deadline = time.monotonic() + budget - done, _ = wait(futures, timeout=max(0.0, deadline - time.monotonic())) - pool.shutdown(wait=False) - for future, cid in futures.items(): - found[cid] = future in done and future.exception() is None and bool(future.result()) - for chat in chats: - chat["hasImage"] = found.get(chat.get("id"), False) - return chats - - - -CUSTOM_EMOJI_VIEWS = {True: "imgt2_anim", False: "img_thumb_tiny"} -MAX_EMOJI_BYTES = 2 * 1024 * 1024 - - -def custom_emoji(broker, document_id: str, animated: bool) -> bytes | None: - """A tenant custom emoji's picture from the Teams media service (`document_id` already validated), or None when it is gone. An animated view is a real GIF/WEBP, not a sprite.""" - try: - return _custom_emoji(broker, document_id, animated) - except ChatServiceError as ex: - if ex.status != 401: - raise - return _custom_emoji(broker, document_id, animated) - - -def _custom_emoji(broker, document_id: str, animated: bool) -> bytes | None: - skype, _ = _session(broker) - base = for_broker(broker).get("teams_ams") - if not base: - raise ChatServiceError(404, "no media service for this account") - url = f"{base}/v1/objects/{quote(document_id, safe='')}/views/{CUSTOM_EMOJI_VIEWS[animated]}" - with outnote_client.download_client().stream("GET", url, headers={"Authorization": f"skype_token {skype}"}, timeout=_STREAM_TIMEOUT, follow_redirects=False) as r: - if r.status_code == 404: - return None - if r.status_code != 200: - if r.status_code == 401: - _drop_session(broker) - raise ChatServiceError(r.status_code, "custom emoji download failed") - data = bytearray() - for chunk in r.iter_bytes(): - data.extend(chunk) - if len(data) > MAX_EMOJI_BYTES: - raise ChatServiceError(413, "custom emoji larger than 2 MB") - return bytes(data) diff --git a/src/teams_files.py b/src/teams_files.py deleted file mode 100644 index 48c03f1..0000000 --- a/src/teams_files.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Teams, channels and the files behind them (team libraries, channel folders), plus shared / recent files and raw drive items. - -Live facts: `groups/{id}/drive` answers 404 on the Office client, so a team's library id is resolved with the OfficeHome client -(`C_SITES`) and cached for an hour; browsing `drives/{driveId}/...` then works on the default client. Every drive entry handed out -carries `parentReference.driveId` so the caller can address it again. -""" - -import re -from urllib.parse import quote, urlsplit - -import httpx -from outnote import C_SITES, GraphError - -from src import outnote_client -from src.account_cache import for_broker - -LIST_TTL = 600 -DRIVE_TTL = 3600 -MAX_PATH_CHARS = 400 -MAX_PATH_SEGMENTS = 30 -MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024 -FETCH_TIMEOUT = httpx.Timeout(120.0, connect=5.0) -ITEM_FIELDS = "id,name,size,file,folder,webUrl,lastModifiedDateTime,createdDateTime,parentReference,lastModifiedBy,createdBy" -FORBIDDEN_PATH = re.compile(r"[%\\:#?\x00-\x1f\x7f]") - - -class FileFetchError(Exception): - """A download that can't be served: `status` is the HTTP status to answer with.""" - - def __init__(self, status: int, message: str): - self.status = status - super().__init__(message) - - -def seg(value: str) -> str: - return quote(value, safe="") - - -def valid_path(path) -> list[str]: - """The folder names of a relative path ('' -> none). Raises ValueError on dot segments, `% \\ : # ?`, control characters or a path over 400 chars.""" - if path is None or path == "": - return [] - if not isinstance(path, str) or len(path) > MAX_PATH_CHARS or FORBIDDEN_PATH.search(path): - raise ValueError("invalid path") - parts = [p for p in path.split("/") if p != ""] - if len(parts) > MAX_PATH_SEGMENTS or any(p in (".", "..") or ".." in p or p != p.strip() for p in parts): - raise ValueError("invalid path") - return parts - - -def is_download_url(url) -> bool: - """True only for an https SharePoint download URL: host ends with `.sharepoint.com`, default port, no credentials.""" - if not isinstance(url, str) or not url or len(url) > 8000 or any(ord(c) < 33 for c in url): - return False - try: - parts = urlsplit(url) - port = parts.port - except ValueError: - return False - host = (parts.hostname or "").lower() - return parts.scheme == "https" and port in (None, 443) and not parts.username and not parts.password and host.endswith(".sharepoint.com") - - -def fetch_bytes(url: str, cap: int) -> bytes: - """The bytes behind a pre-authorized download URL (no bearer token, no redirects), 413 past `cap`.""" - if not is_download_url(url): - raise FileFetchError(502, "The file has an unexpected download location.") - out = bytearray() - with outnote_client.download_client().stream("GET", url, timeout=FETCH_TIMEOUT, follow_redirects=False) as r: - if r.status_code != 200: - raise FileFetchError(502, f"The download failed ({r.status_code}).") - for chunk in r.iter_bytes(64 * 1024): - out.extend(chunk) - if len(out) > cap: - raise FileFetchError(413, "The file is too large.") - return bytes(out) - - -def download_url(note, path: str, item: dict) -> str | None: - """The pre-authorized link of the item at `path`, or None when `/content` answers without a redirect. Graph can leave it out of a `$select` answer, so ask again without one, then through `/content`; a refusal there is raised. - - OutNote 2.0.1's `download_url` has the same fallback but can't take the `shares/...` path chat files need and always asks Graph again, and its `download` holds the whole file in memory with no cap.""" - url = item.get("@microsoft.graph.downloadUrl") - if url: - return url - full = note.get(path) - url = full.get("@microsoft.graph.downloadUrl") if isinstance(full, dict) else None - if url: - return url - try: - return note.redirect(f"{path}/content") - except GraphError as ex: - if ex.status >= 400: - raise - return None - - -def item_base(drive_id: str | None, item_id: str) -> str: - return f"drives/{seg(drive_id)}/items/{seg(item_id)}" if drive_id else f"me/drive/items/{seg(item_id)}" - - -def _with_drive(item: dict, drive_id: str | None) -> dict: - if drive_id and isinstance(item, dict): - ref = item.get("parentReference") - if not isinstance(ref, dict): - ref = item["parentReference"] = {} - ref.setdefault("driveId", drive_id) - return item - - -def joined_teams(note, broker) -> list[dict]: - cache = for_broker(broker) - hit = cache.get(("teams", "joined")) - if hit is not None: - return hit - rows = note.list("me/joinedTeams", {"$select": "id,displayName,description"}) - teams = [{"id": t.get("id"), "displayName": t.get("displayName"), "description": t.get("description")} for t in rows if t.get("id")] - cache.set(("teams", "joined"), teams, LIST_TTL) - return teams - - -def channels(note, broker, team_id: str) -> list[dict]: - cache = for_broker(broker) - key = ("teams", "channels", team_id) - hit = cache.get(key) - if hit is not None: - return hit - rows = note.list(f"teams/{seg(team_id)}/channels", {"$select": "id,displayName,membershipType,description"}) - out = [{"id": c.get("id"), "displayName": c.get("displayName"), "membershipType": c.get("membershipType"), "description": c.get("description")} for c in rows if c.get("id")] - cache.set(key, out, LIST_TTL) - return out - - -def team_drive_id(note, broker, team_id: str) -> str: - """The team's document library id (the Office client can't read `groups/{id}/drive`, the OfficeHome client can); cached 1 h.""" - cache = for_broker(broker) - key = ("teams", "drive", team_id) - hit = cache.get(key) - if hit: - return hit - drive = note.get(f"groups/{seg(team_id)}/drive", {"$select": "id"}, client=C_SITES) or {} - drive_id = drive.get("id") - if not drive_id: - raise GraphError(404, "The team has no document library.") - cache.set(key, drive_id, DRIVE_TTL) - return drive_id - - -def own_drive_id(note, broker) -> str | None: - cache = for_broker(broker) - key = ("drive", "own") - hit = cache.get(key) - if hit: - return hit - drive_id = (note.get("me/drive", {"$select": "id"}) or {}).get("id") - if drive_id: - cache.set(key, drive_id, DRIVE_TTL) - return drive_id - - -def team_files(note, broker, team_id: str, channel_id: str | None, path: str | None, top: int) -> dict: - """`{driveId, folder: {id, name, path}, items}` for a team's library root, or a channel's folder, or a sub-folder of either.""" - names = valid_path(path) - if channel_id: - root = note.get(f"teams/{seg(team_id)}/channels/{seg(channel_id)}/filesFolder", {"$select": "id,name,parentReference"}) or {} - drive_id = (root.get("parentReference") or {}).get("driveId") - if not drive_id or not root.get("id"): - raise GraphError(404, "The channel has no files folder.") - else: - drive_id = team_drive_id(note, broker, team_id) - root = note.get(f"drives/{seg(drive_id)}/root", {"$select": "id,name"}) or {} - if not root.get("id"): - raise GraphError(404, "The team library has no root.") - folder = root - if names: - folder = note.get(f"drives/{seg(drive_id)}/items/{seg(root['id'])}:/{'/'.join(seg(n) for n in names)}", {"$select": "id,name,folder"}) or {} - if folder.get("folder") is None or not folder.get("id"): - raise GraphError(404, "Folder not found.") - items = note.list(f"drives/{seg(drive_id)}/items/{seg(folder['id'])}/children", {"$top": min(top, 200), "$select": ITEM_FIELDS}, limit=top) - return {"driveId": drive_id, "folder": {"id": folder["id"], "name": folder.get("name"), "path": "/".join(names)}, "items": [_with_drive(i, drive_id) for i in items]} - - -def drive_item(note, drive_id: str, item_id: str) -> dict: - """One drive item with a fresh download URL.""" - return _with_drive(note.get(item_base(drive_id, item_id)) or {}, drive_id) - - -def children(note, drive_id: str, item_id: str, top: int) -> list[dict]: - rows = note.list(f"{item_base(drive_id, item_id)}/children", {"$top": min(top, 200), "$select": ITEM_FIELDS}, limit=top) - return [_with_drive(i, drive_id) for i in rows] - - -def _identity(value): - """`{displayName, email}` out of a Graph identitySet (or a bare identity).""" - if not isinstance(value, dict): - return None - user = value.get("user") if isinstance(value.get("user"), dict) else value - name, email = user.get("displayName"), user.get("email") - return {"displayName": name, "email": email} if name or email else None - - -def flatten(entry: dict) -> dict | None: - """A sharedWithMe / recent row as a plain driveItem (the `remoteItem` unwrapped), always with `parentReference.driveId`.""" - remote = entry.get("remoteItem") if isinstance(entry.get("remoteItem"), dict) else entry - ref = remote.get("parentReference") or entry.get("parentReference") or {} - drive_id = ref.get("driveId") - if not remote.get("id") or not drive_id: - return None - out = {"id": remote["id"], "name": remote.get("name") or entry.get("name"), "size": remote.get("size", entry.get("size")), "webUrl": remote.get("webUrl") or entry.get("webUrl"), "lastModifiedDateTime": remote.get("lastModifiedDateTime") or entry.get("lastModifiedDateTime"), "parentReference": {"driveId": drive_id}} - if remote.get("file") is not None: - out["file"] = remote["file"] - if remote.get("folder") is not None: - out["folder"] = remote["folder"] - by = _identity(remote.get("lastModifiedBy") or entry.get("lastModifiedBy")) - if by: - out["lastModifiedBy"] = {"user": by} - shared = remote.get("shared") or entry.get("shared") or {} - sharer = _identity(shared.get("sharedBy") or shared.get("owner")) - if sharer or shared.get("sharedDateTime"): - out["shared"] = {"sharedBy": sharer, "sharedDateTime": shared.get("sharedDateTime")} - return out - - -def shared_with_me(note, top: int) -> list[dict]: - rows = note.list("me/drive/sharedWithMe", {"$top": min(top, 200)}, limit=top) - return [f for f in (flatten(r) for r in rows) if f] - - -def recent(note, top: int) -> list[dict]: - rows = note.list("me/drive/recent", {"$top": min(top, 200)}, limit=top) - return [f for f in (flatten(r) for r in rows) if f] diff --git a/tests/conftest.py b/tests/conftest.py index 60f6226..cdca115 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ import pytest -from src import profile +from tests.services import profile REAL_FETCH = profile._fetch diff --git a/tests/services.py b/tests/services.py new file mode 100644 index 0000000..a592582 --- /dev/null +++ b/tests/services.py @@ -0,0 +1,58 @@ +"""What the tests reach into, resolved from the composition root: the same singletons the routes use, so a patch here is a patch there.""" + +import src.infrastructure.microsoft.account_cache as account_cache # noqa: F401 +from src.api.app import app +from src.api.http_headers import HttpHeaders as config # noqa: F401 +from src.application.abstractions.i_browser_sign_in import IBrowserSignIn +from src.application.abstractions.i_chat_sender import IChatSender +from src.application.abstractions.i_emoji_catalogue import IEmojiCatalogue +from src.application.abstractions.i_file_text_extractor import IFileTextExtractor +from src.application.abstractions.i_file_thumbnails import IFileThumbnails +from src.application.abstractions.i_gif_search import IGifSearch +from src.application.abstractions.i_graph_client_factory import IGraphClientFactory +from src.application.abstractions.i_graph_search import IGraphSearch +from src.application.abstractions.i_mail_folder_roles import IMailFolderRoles +from src.application.abstractions.i_microsoft_http_clients import IMicrosoftHttpClients +from src.application.abstractions.i_one_note import IOneNote +from src.application.abstractions.i_pdf_converter import IPdfConverter +from src.application.abstractions.i_profile_reader import IProfileReader +from src.application.abstractions.i_sign_in_store import ISignInStore +from src.application.abstractions.i_teams_chat import ITeamsChat +from src.application.abstractions.i_teams_files import ITeamsFiles +from src.application.options.password_sign_in_options import PasswordSignInOptions +from src.application.services.chat_html_sanitizer import ChatHtmlSanitizer +from src.application.services.onenote_html_sanitizer import OneNoteHtmlSanitizer +from src.application.services.warm_up_tracker import WarmUpTracker +from src.domain.chat.outgoing_message import OutgoingMessage # noqa: F401 +from src.domain.chat.upload import Upload # noqa: F401 +from src.domain.errors.file_fetch_error import FileFetchError # noqa: F401 +from src.domain.errors.resource_too_large import ResourceTooLarge # noqa: F401 +from src.domain.errors.send_error import SendError # noqa: F401 +from src.domain.errors.text_error import TextError # noqa: F401 +from src.domain.files.file_limits import FileLimits as app_module # noqa: F401 +from src.infrastructure.microsoft.account_runner import AccountRunner # noqa: F401 +from src.infrastructure.microsoft.broker_cache import BrokerCache as _BrokerCache # noqa: F401 +from src.infrastructure.password_signin.sign_in_job import SignInJob # noqa: F401 + +services = app.state.services + +outnote_client = services.get(IGraphClientFactory) +http_clients = services.get(IMicrosoftHttpClients) +browser_auth = services.get(IBrowserSignIn) +profile = services.get(IProfileReader) +folder_roles = services.get(IMailFolderRoles) +teams_chat = services.get(ITeamsChat) +chat_send = services.get(IChatSender) +emojis = services.get(IEmojiCatalogue) +file_pdf = services.get(IPdfConverter) +file_text = services.get(IFileTextExtractor) +file_thumbs = services.get(IFileThumbnails) +gifs = services.get(IGifSearch) +onenote = services.get(IOneNote) +search = services.get(IGraphSearch) +teams_files = services.get(ITeamsFiles) +chat_html = services.get(ChatHtmlSanitizer) +onenote_html = services.get(OneNoteHtmlSanitizer) +sign_in_store = services.get(ISignInStore) +sign_in_options = services.get(PasswordSignInOptions) +warm_up_tracker = services.get(WarmUpTracker) diff --git a/tests/test_architecture.py b/tests/test_architecture.py new file mode 100644 index 0000000..0f79ffd --- /dev/null +++ b/tests/test_architecture.py @@ -0,0 +1,71 @@ +"""The onion rule, read from the imports of every file under src/: dependencies only point inward.""" + +import ast +import sys +from pathlib import Path + +SRC = Path(__file__).resolve().parents[1] / "src" +THIRD_PARTY_FOR = { + "domain": set(), + "application": {"mediatorx", "pydantic"}, +} +# The composition root: the only part of `api` that may name concrete infrastructure classes. +COMPOSITION_ROOT = SRC / "api" / "extensions" + + +def imports_of(path: Path) -> set[str]: + found = set() + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.Import): + found.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + found.add(node.module) + return found + + +def ring_of(module: str) -> str | None: + parts = module.split(".") + return parts[1] if parts[0] == "src" and len(parts) > 1 else None + + +def broken_rules(path: Path, ring: str) -> list[str]: + broken = [] + for module in imports_of(path): + target = ring_of(module) + top = module.split(".")[0] + if target is None and top != "src": + if ring in THIRD_PARTY_FOR and top not in sys.stdlib_module_names and top not in THIRD_PARTY_FOR[ring]: + broken.append(f"{ring} imports {module}") + continue + if ring == "domain" and target != "domain": + broken.append(f"domain imports {module}") + elif ring == "application" and target not in ("domain", "application"): + broken.append(f"application imports {module}") + elif ring == "infrastructure" and target == "api": + broken.append(f"infrastructure imports {module}") + elif ring == "api" and target == "infrastructure" and COMPOSITION_ROOT not in path.parents: + broken.append(f"api outside the composition root imports {module}") + return broken + + +def test_dependencies_only_point_inward(): + broken = {} + for ring in ("domain", "application", "infrastructure", "api"): + for path in (SRC / ring).rglob("*.py"): + rules = broken_rules(path, ring) + if rules: + broken[str(path.relative_to(SRC))] = rules + assert broken == {} + + +def test_every_file_under_src_belongs_to_a_ring(): + strays = [p.relative_to(SRC) for p in SRC.rglob("*.py") if p.relative_to(SRC).parts[0] not in ("domain", "application", "infrastructure", "api") and p.name != "__init__.py"] + assert strays == [] + + +def test_the_rule_catches_a_violation(tmp_path): + bad = tmp_path / "bad.py" + bad.write_text("from src.infrastructure.logging_config import LoggerNames\nimport fastapi\nfrom outnote import GraphError\n", encoding="utf-8") + assert len(broken_rules(bad, "application")) == 3 + assert len(broken_rules(bad, "domain")) == 3 + assert any("infrastructure" in rule for rule in broken_rules(bad, "api")) diff --git a/tests/test_broker_cache.py b/tests/test_broker_cache.py index bbef62c..575e5c6 100644 --- a/tests/test_broker_cache.py +++ b/tests/test_broker_cache.py @@ -3,8 +3,7 @@ import pytest from entrance import _foci -from src import outnote_client -from src.outnote_client import _BrokerCache +from tests.services import _BrokerCache, outnote_client class Calls(list): diff --git a/tests/test_browser_auth.py b/tests/test_browser_auth.py index ea307e6..c69906a 100644 --- a/tests/test_browser_auth.py +++ b/tests/test_browser_auth.py @@ -6,8 +6,8 @@ import pytest from fastapi.testclient import TestClient -from src import browser_auth, outnote_client -from src.app import app +from tests.services import browser_auth, http_clients, outnote_client +from src.api.app import app def test_start_builds_a_pkce_authorize_url(): @@ -41,13 +41,13 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"access_token": "at", "refresh_token": "frt-from-browser", "expires_in": 3600}) return httpx.Response(400, json={"error": "invalid_grant", "error_description": "bad code"}) - original = outnote_client._post + original = http_clients.post_form async def patched(url, data, client=None): async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as c: return await original(url, data, c) - monkeypatch.setattr(outnote_client, "_post", patched) + monkeypatch.setattr(http_clients, "post_form", patched) monkeypatch.setattr(outnote_client, "build_client", lambda frt: (FakeNote(), type("B", (), {"refresh_token": frt})())) return seen diff --git a/tests/test_cold_start.py b/tests/test_cold_start.py index 81c066e..f7c5120 100644 --- a/tests/test_cold_start.py +++ b/tests/test_cold_start.py @@ -9,10 +9,10 @@ from entrance import _foci from fastapi.testclient import TestClient -from src import app as app_module -from src import config, outnote_client -from src.app import app -from src.outnote_client import _BrokerCache + +from tests.services import _BrokerCache, config, http_clients, outnote_client, warm_up_tracker +from src.api.app import app + HEADER = {config.REFRESH_TOKEN_HEADER: "frt-cold"} REDEEM_SECONDS = 0.5 @@ -49,7 +49,7 @@ def post(url, data): async def no_warm_up(): return None - monkeypatch.setattr(outnote_client, "warm_up", no_warm_up) + monkeypatch.setattr(http_clients, "warm_up", no_warm_up) return redeems, reads @@ -81,7 +81,7 @@ def test_ready_is_503_until_warm_up_finishes(monkeypatch): async def slow_warm_up(): await asyncio.to_thread(release.wait, 10) - monkeypatch.setattr(outnote_client, "warm_up", slow_warm_up) + monkeypatch.setattr(http_clients, "warm_up", slow_warm_up) with TestClient(app) as client: assert client.get("/health/live").json() == {"status": "ok"} blocked = client.get("/health/ready") @@ -102,8 +102,8 @@ def test_ready_after_grace_period_even_if_warm_up_hangs(monkeypatch): async def slow_warm_up(): await asyncio.to_thread(release.wait, 10) - monkeypatch.setattr(outnote_client, "warm_up", slow_warm_up) - monkeypatch.setattr(app_module, "READY_GRACE", 0.0) + monkeypatch.setattr(http_clients, "warm_up", slow_warm_up) + monkeypatch.setattr(warm_up_tracker, "READY_GRACE", 0.0) with TestClient(app) as client: assert client.get("/health/ready").status_code == 200 release.set() diff --git a/tests/test_device_flow.py b/tests/test_device_flow.py index 7d362b9..335b15e 100644 --- a/tests/test_device_flow.py +++ b/tests/test_device_flow.py @@ -4,8 +4,8 @@ import pytest from fastapi.testclient import TestClient -from src import outnote_client -from src.app import app +from tests.services import http_clients, outnote_client +from src.api.app import app class FakeNote: @@ -31,13 +31,13 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"access_token": "at", "refresh_token": "new-frt", "expires_in": 3600}) transport = httpx.MockTransport(handler) - original = outnote_client._post + original = http_clients.post_form async def patched(url, data, client=None): async with httpx.AsyncClient(transport=transport) as c: return await original(url, data, c) - monkeypatch.setattr(outnote_client, "_post", patched) + monkeypatch.setattr(http_clients, "post_form", patched) monkeypatch.setattr(outnote_client, "build_client", lambda frt: (FakeNote(), FakeBroker(frt))) return state diff --git a/tests/test_emojis.py b/tests/test_emojis.py index 83329a2..fc851ff 100644 --- a/tests/test_emojis.py +++ b/tests/test_emojis.py @@ -6,8 +6,8 @@ from fastapi.testclient import TestClient from outnote import GraphError -from src import chat_html, chat_send, config, emojis, file_thumbs, outnote_client, teams_chat -from src.app import app +from tests.services import OutgoingMessage, SendError, chat_html, chat_send, config, emojis, file_thumbs, http_clients, outnote_client, teams_chat +from src.api.app import app from tests.test_teams_extras import Broker, Note H = {config.REFRESH_TOKEN_HEADER: "old-frt"} @@ -191,15 +191,15 @@ def test_skin_tones_only_on_diverse_emoji(cdn): def test_send_posts_teams_emoji_markup(cdn): - msg = chat_send.OutgoingMessage(content='

go

', content_type="html") + msg = OutgoingMessage(content='

go

', content_type="html") body = chat_send.payload(chat_send.validate(msg))["body"] assert body == {"contentType": "html", "content": '

go

'} def test_send_says_503_when_the_catalogue_is_unavailable(cdn): cdn.fail = True - with pytest.raises(chat_send.SendError) as ex: - chat_send.payload(chat_send.validate(chat_send.OutgoingMessage(content='', content_type="html"))) + with pytest.raises(SendError) as ex: + chat_send.payload(chat_send.validate(OutgoingMessage(content='', content_type="html"))) assert ex.value.status == 503 @@ -234,7 +234,7 @@ def stream(self, method, url, headers=None, timeout=None, follow_redirects=None) def ams(account, monkeypatch): fake = AmsHttp() monkeypatch.setattr(teams_chat, "http_client", lambda: fake) - monkeypatch.setattr(outnote_client, "download_client", lambda: fake) + monkeypatch.setattr(http_clients, "download_client", lambda: fake) return fake diff --git a/tests/test_file_pdf.py b/tests/test_file_pdf.py index 88bcf52..59ccf07 100644 --- a/tests/test_file_pdf.py +++ b/tests/test_file_pdf.py @@ -8,8 +8,8 @@ from fastapi.testclient import TestClient from outnote import GraphError -from src import config, file_pdf, outnote_client -from src.app import app +from tests.services import config, file_pdf, http_clients, outnote_client +from src.api.app import app H = {config.REFRESH_TOKEN_HEADER: "old-frt"} PDF = b"%PDF-1.7\n" + b"x" * 300 @@ -104,7 +104,7 @@ def env(monkeypatch): note = Note(broker) http = Http() monkeypatch.setattr(outnote_client, "build_client", lambda frt: (note, broker)) - monkeypatch.setattr(outnote_client, "download_client", lambda: http) + monkeypatch.setattr(http_clients, "download_client", lambda: http) return note, http, TestClient(app) @@ -289,7 +289,7 @@ def test_convert_route_caps_the_declared_length(env): def test_convert_route_counts_a_chunked_body(env, monkeypatch): - import src.app as service + from tests.services import app_module as service monkeypatch.setattr(service, "MAX_SEND_BYTES", 1024) note, _, client = env diff --git a/tests/test_file_text.py b/tests/test_file_text.py index 75e0366..48ef6a3 100644 --- a/tests/test_file_text.py +++ b/tests/test_file_text.py @@ -6,7 +6,7 @@ import pytest -from src import file_text +from tests.services import TextError, file_text def docx_bytes(): @@ -106,7 +106,7 @@ def test_xlsx_rows_dates_sheets_and_paging(): assert nxt["text"].splitlines()[0] == "r1,,1" and nxt["nextRowOffset"] is None other = file_text.extract("Sheet.xlsx", xlsx_bytes(), sheet="Other") assert other["text"] == "only\n" and other["sheet"] == "Other" - with pytest.raises(file_text.TextError) as ex: + with pytest.raises(TextError) as ex: file_text.extract("Sheet.xlsx", xlsx_bytes(), sheet="Nope") assert ex.value.status == 404 @@ -140,24 +140,24 @@ def test_text_decoding_and_html(): def test_unknown_and_legacy_types_are_415(): for name in ("a.exe", "a.png", "noext"): - with pytest.raises(file_text.TextError) as ex: + with pytest.raises(TextError) as ex: file_text.extract(name, b"x") assert ex.value.status == 415 and str(ex.value) == "This file type cannot be read as text." assert file_text.kind_of("old.doc") == "legacy" and file_text.kind_of("a.ODS") == "legacy" - with pytest.raises(file_text.TextError) as ex: + with pytest.raises(TextError) as ex: file_text.extract("old.doc", b"x") assert ex.value.status == 415 def test_source_cap_is_413(monkeypatch): monkeypatch.setattr(file_text, "MAX_SOURCE_BYTES", 10) - with pytest.raises(file_text.TextError) as ex: + with pytest.raises(TextError) as ex: file_text.extract("a.txt", b"x" * 11) assert ex.value.status == 413 def test_damaged_office_file_is_422(): - with pytest.raises(file_text.TextError) as ex: + with pytest.raises(TextError) as ex: file_text.extract("a.docx", b"not a zip") assert ex.value.status == 422 @@ -172,18 +172,18 @@ def _zip(members): def test_zip_bomb_guards(monkeypatch): monkeypatch.setattr(file_text, "MAX_UNCOMPRESSED", 1000) - with pytest.raises(file_text.TextError) as ex: + with pytest.raises(TextError) as ex: file_text.extract("a.xlsx", _zip({"a.xml": b"0" * 5000})) assert ex.value.status == 413 monkeypatch.setattr(file_text, "MAX_UNCOMPRESSED", 200 * 1024 * 1024) monkeypatch.setattr(file_text, "RATIO_FROM", 1000) - with pytest.raises(file_text.TextError) as ex: + with pytest.raises(TextError) as ex: file_text.extract("a.docx", _zip({"word/document.xml": b"0" * 100000})) assert ex.value.status == 413 def test_xml_entity_declarations_are_refused(): evil = b']>&a;' - with pytest.raises(file_text.TextError) as ex: + with pytest.raises(TextError) as ex: file_text.extract("a.pptx", _zip({"ppt/presentation.xml": evil})) assert ex.value.status == 422 diff --git a/tests/test_http.py b/tests/test_http.py index f5e3c93..9d2d230 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -1,24 +1,25 @@ """Shared HTTP client for sign-in calls and the startup warm-up.""" +import entrance import httpx import pytest -from src import outnote_client +from tests.services import http_clients @pytest.mark.asyncio async def test_post_reuses_one_shared_client_with_bounded_connect(): - first = outnote_client.shared_async_client() - assert outnote_client.shared_async_client() is first + first = http_clients.shared_async_client() + assert http_clients.shared_async_client() is first assert first.timeout.connect == 5.0 and first.timeout.read == 30.0 - await outnote_client.close_shared_client() - assert outnote_client.shared_async_client() is not first - await outnote_client.close_shared_client() + await http_clients.close_shared_client() + assert http_clients.shared_async_client() is not first + await http_clients.close_shared_client() @pytest.mark.asyncio async def test_warm_up_opens_login_and_graph_hosts_and_never_raises(monkeypatch): seen = [] - monkeypatch.setattr(outnote_client.entrance, "warm_up", lambda urls: seen.extend(urls)) - await outnote_client.warm_up() + monkeypatch.setattr(entrance, "warm_up", lambda urls: seen.extend(urls)) + await http_clients.warm_up() assert any("login.microsoftonline.com" in u for u in seen) and any("graph.microsoft.com" in u for u in seen) diff --git a/tests/test_mail.py b/tests/test_mail.py index 2e50564..4398d37 100644 --- a/tests/test_mail.py +++ b/tests/test_mail.py @@ -6,8 +6,8 @@ from fastapi.testclient import TestClient from outnote import GraphError -from src import config, outnote_client -from src.app import app +from tests.services import config, outnote_client +from src.api.app import app H = {config.REFRESH_TOKEN_HEADER: "old-frt"} MID = "AAMkAGI2=-_x" diff --git a/tests/test_onenote.py b/tests/test_onenote.py index dcd1b6e..7289de2 100644 --- a/tests/test_onenote.py +++ b/tests/test_onenote.py @@ -6,9 +6,9 @@ from fastapi.testclient import TestClient from outnote import GraphError -from src import config, onenote, onenote_html, outnote_client -from src.account_cache import AccountCache -from src.app import app +from tests.services import ResourceTooLarge, config, onenote, onenote_html, outnote_client +from src.infrastructure.microsoft.account_cache import AccountCache +from src.api.app import app GROUP = "3f2a9c10-1111-4222-8333-944455556666" H = {config.REFRESH_TOKEN_HEADER: "old-frt"} @@ -378,7 +378,7 @@ def test_resources_serve_only_images_inline_and_files_as_downloads(graph, monkey def test_an_oversized_resource_is_refused(graph, monkeypatch): def too_big(broker, rid, limit): - raise onenote.ResourceTooLarge() + raise ResourceTooLarge() monkeypatch.setattr(onenote, "resource", too_big) assert client().get("/api/onenote/resources/0-a", headers=H).status_code == 413 diff --git a/tests/test_outnote2.py b/tests/test_outnote2.py index fa1cac9..b500f36 100644 --- a/tests/test_outnote2.py +++ b/tests/test_outnote2.py @@ -13,9 +13,9 @@ from outnote import GraphError, OutNote from outnote._native import message_from_native -from src import chat_send, config, gifs, onenote, outnote_client, profile, teams_files -from src.app import app -from src.outnote_client import _BrokerCache +from tests.services import AccountRunner, FileFetchError, ResourceTooLarge, Upload, _BrokerCache, chat_send, config, gifs, http_clients, onenote, outnote_client, profile, teams_files +from src.api.app import app + from tests.conftest import REAL_FETCH H = {config.REFRESH_TOKEN_HEADER: "frt-1"} @@ -85,9 +85,9 @@ def server(): @pytest.fixture def fresh_download_client(monkeypatch): - monkeypatch.setattr(outnote_client, "_download_client", None) + monkeypatch.setattr(http_clients, "_download_client", None) yield - client = outnote_client._download_client + client = http_clients._download_client if client is not None: client.close() @@ -124,7 +124,7 @@ class N: def post(self, path, body=None): return {"uploadUrl": server.url + "/session"} - assert chat_send.upload_file(N(), chat_send.Upload("big.mp4", io.BytesIO(b"x" * size), size, "video/mp4")) == {"id": "BIG"} + assert chat_send.upload_file(N(), Upload("big.mp4", io.BytesIO(b"x" * size), size, "video/mp4")) == {"id": "BIG"} puts = [s for s in server.seen if s["method"] == "PUT"] assert [len(p["body"]) for p in puts] == [chat_send.UPLOAD_CHUNK, size - chat_send.UPLOAD_CHUNK] assert puts[1]["headers"]["Content-Range"] == f"bytes {chat_send.UPLOAD_CHUNK}-{size - 1}/{size}" @@ -148,7 +148,7 @@ def failing(method, url, **kw): client = type("C", (), {"request": staticmethod(failing)})() monkeypatch.setattr(chat_send, "http_client", lambda: client) with pytest.raises(RuntimeError): - chat_send.upload_file(N(), chat_send.Upload("big.mp4", io.BytesIO(b"x" * size), size, "video/mp4")) + chat_send.upload_file(N(), Upload("big.mp4", io.BytesIO(b"x" * size), size, "video/mp4")) assert [s["method"] for s in server.seen] == ["DELETE"] @@ -156,7 +156,7 @@ def failing(method, url, **kw): def test_a_download_streams_chunk_by_chunk_instead_of_loading_the_file(server, fresh_download_client): - stream = outnote_client.stream_download(server.url + "/slow", 10 * CHUNK) + stream = http_clients.stream_download(server.url + "/slow", 10 * CHUNK) first = next(stream) # the server holds the rest back until this arrives: a buffering client would hang here assert first and set(first) == {ord("a")} and len(first) <= CHUNK server.got_first.set() @@ -165,16 +165,16 @@ def test_a_download_streams_chunk_by_chunk_instead_of_loading_the_file(server, f def test_a_download_stops_at_its_cap(server, fresh_download_client): - got = b"".join(outnote_client.stream_download(server.url + "/big", CHUNK)) + got = b"".join(http_clients.stream_download(server.url + "/big", CHUNK)) assert len(got) <= CHUNK def test_fetch_bytes_refuses_past_its_cap_and_does_not_follow_redirects(server, fresh_download_client, monkeypatch): monkeypatch.setattr(teams_files, "is_download_url", lambda url: True) - with pytest.raises(teams_files.FileFetchError) as big: + with pytest.raises(FileFetchError) as big: teams_files.fetch_bytes(server.url + "/big", CHUNK) assert big.value.status == 413 - with pytest.raises(teams_files.FileFetchError) as moved: + with pytest.raises(FileFetchError) as moved: teams_files.fetch_bytes(server.url + "/redirect", CHUNK) assert moved.value.status == 502 assert not any(s["path"] == "/landed" for s in server.seen) @@ -182,20 +182,20 @@ def test_fetch_bytes_refuses_past_its_cap_and_does_not_follow_redirects(server, def test_stream_download_can_refuse_redirects(server, fresh_download_client): with pytest.raises(Exception): - list(outnote_client.stream_download(server.url + "/redirect", CHUNK, follow_redirects=False)) + list(http_clients.stream_download(server.url + "/redirect", CHUNK, follow_redirects=False)) assert not any(s["path"] == "/landed" for s in server.seen) def test_the_shared_download_client_keeps_no_cookies_between_accounts(server, fresh_download_client): - list(outnote_client.stream_download(server.url + "/cookie", CHUNK)) - list(outnote_client.stream_download(server.url + "/cookie", CHUNK)) + list(http_clients.stream_download(server.url + "/cookie", CHUNK)) + list(http_clients.stream_download(server.url + "/cookie", CHUNK)) assert not any(s["headers"].get("Cookie") for s in server.seen) - assert outnote_client.download_client() is outnote_client.download_client() + assert http_clients.download_client() is http_clients.download_client() def test_the_onenote_resource_stays_capped_with_the_real_stream(server, fresh_download_client, monkeypatch): monkeypatch.setattr(onenote, "GRAPH", server.url + "/big") - with pytest.raises(onenote.ResourceTooLarge): + with pytest.raises(ResourceTooLarge): onenote.resource(lambda client: "bearer", "1-res", CHUNK) assert server.seen[0]["headers"]["Authorization"] == "Bearer bearer" @@ -342,8 +342,8 @@ def batch(self, requests): def test_strip_native_walks_tuples_and_leaves_other_values_alone(): item, data = {"_native": 1, "a": 2}, b"bytes" - assert outnote_client.strip_native((item, data)) == ({"a": 2}, b"bytes") - assert outnote_client.strip_native(None) is None + assert AccountRunner.strip_native((item, data)) == ({"a": 2}, b"bytes") + assert AccountRunner.strip_native(None) is None # --- client reuse -------------------------------------------------------------------------------- diff --git a/tests/test_password_signin.py b/tests/test_password_signin.py index ed2ca37..e1c4eef 100644 --- a/tests/test_password_signin.py +++ b/tests/test_password_signin.py @@ -11,8 +11,8 @@ from entrance import MfaCancelled, MfaSwitch, PushEvent from fastapi.testclient import TestClient -from src import outnote_client, password_signin -from src.app import app +from tests.services import SignInJob, outnote_client, sign_in_options, sign_in_store +from src.api.app import app PASSWORD = "Tr0ub4dor&3-never-leak-me" USER = "alex@school.ch" @@ -37,10 +37,10 @@ def __init__(self, refresh_token="frt-new"): @pytest.fixture(autouse=True) def clean(monkeypatch): monkeypatch.setenv("SCHULY_SERVICE_KEY", KEY) - password_signin.store.clear() + sign_in_store.clear() monkeypatch.setattr(outnote_client, "build_client", lambda frt: (FakeNote(), FakeBroker(frt))) yield - password_signin.store.clear() + sign_in_store.clear() @pytest.fixture @@ -58,7 +58,7 @@ def login(username, secret, **kw): del submitted return script(kw["mfa"]) - monkeypatch.setattr(password_signin.entrance, "foci_login", login) + monkeypatch.setattr(entrance, "foci_login", login) def drive(prompt, offered, push=None, code=None, wrong=0): @@ -255,7 +255,7 @@ def fail(prompt): scripted(monkeypatch, fail) body = start(api).json() assert body["status"] == "error" and body["code"] == code - assert body["message"] == password_signin.MESSAGES[code] + assert body["message"] == SignInJob.MESSAGES[code] assert "refresh_token" not in body @@ -285,23 +285,23 @@ def test_expired_job_is_gone(api, monkeypatch): approve = threading.Event() scripted(monkeypatch, lambda p: drive(p, BOTH, push=push_wait(approve))) sid = start(api).json()["sign_in_id"] - job = password_signin.store.get(sid, UID) + job = sign_in_store.get(sid, UID) job.expires = time.monotonic() - 1 assert api.get(f"/api/auth/password/{sid}").status_code == 404 - assert len(password_signin.store) == 0 + assert len(sign_in_store) == 0 def test_sweeper_drops_expired_and_stale_finished_jobs(api, monkeypatch): scripted(monkeypatch, lambda p: FakeBroker()) sid = start(api).json()["sign_in_id"] - assert len(password_signin.store) == 1 - password_signin.store.get(sid, UID).done_at = time.monotonic() - password_signin.DONE_TTL - 1 - password_signin.store.sweep() - assert len(password_signin.store) == 0 + assert len(sign_in_store) == 1 + sign_in_store.get(sid, UID).done_at = time.monotonic() - sign_in_options.done_ttl - 1 + sign_in_store.sweep() + assert len(sign_in_store) == 0 def test_ttl_is_capped_at_ten_minutes(): - assert password_signin.JOB_TTL <= 600 + assert sign_in_options.job_ttl <= 600 def test_delete_cancels_the_worker(api, monkeypatch): @@ -363,7 +363,7 @@ def test_malformed_code_is_rejected(api, monkeypatch): # --- rate limits ------------------------------------------------------------------------------------- def test_per_client_rate_limit(api, monkeypatch): - monkeypatch.setattr(password_signin, "CLIENT_LIMIT", 2) + monkeypatch.setattr(sign_in_options, "client_limit", 2) scripted(monkeypatch, lambda p: FakeBroker()) assert start(api, username="a@school.ch").status_code == 200 assert start(api, username="b@school.ch").status_code == 200 @@ -372,7 +372,7 @@ def test_per_client_rate_limit(api, monkeypatch): def test_only_failed_attempts_count_against_the_account_limit(api, monkeypatch): - monkeypatch.setattr(password_signin, "ACCOUNT_LIMIT", 2) + monkeypatch.setattr(sign_in_options, "account_limit", 2) scripted(monkeypatch, lambda p: FakeBroker()) for _ in range(4): # successes never count assert start(api).json()["status"] == "complete" @@ -389,7 +389,7 @@ def wrong(prompt): def test_a_third_party_cannot_lock_a_victim_out(api, monkeypatch): - monkeypatch.setattr(password_signin, "ACCOUNT_LIMIT", 2) + monkeypatch.setattr(sign_in_options, "account_limit", 2) def wrong(prompt): raise entrance.InvalidCredentials("no") @@ -404,7 +404,7 @@ def wrong(prompt): def test_max_concurrent_jobs(api, monkeypatch): - monkeypatch.setattr(password_signin, "MAX_JOBS", 1) + monkeypatch.setattr(sign_in_options, "max_jobs", 1) scripted(monkeypatch, lambda p: drive(p, BOTH)) assert start(api, username="a@school.ch").json()["status"] == "mfa_required" r = start(api, username="b@school.ch") @@ -447,13 +447,13 @@ def test_password_is_not_in_the_job_store_at_any_stage(api, monkeypatch): approve = threading.Event() scripted(monkeypatch, lambda p: drive(p, BOTH, push=push_wait(approve))) sid = start(api).json()["sign_in_id"] - assert not reaches(password_signin.store, PASSWORD) + assert not reaches(sign_in_store, PASSWORD) api.post(f"/api/auth/password/{sid}/method", json={"method": "push"}) - assert not reaches(password_signin.store, PASSWORD) + assert not reaches(sign_in_store, PASSWORD) approve.set() until(api, sid, "complete") - assert not reaches(password_signin.store, PASSWORD) - assert not reaches(password_signin.store._jobs[sid], PASSWORD) + assert not reaches(sign_in_store, PASSWORD) + assert not reaches(sign_in_store._jobs[sid], PASSWORD) def test_reaches_helper_actually_finds_strings(): @@ -488,7 +488,7 @@ def script(prompt): for text in seen: assert PASSWORD not in text assert PASSWORD not in caplog.text - assert not reaches(password_signin.store, PASSWORD) + assert not reaches(sign_in_store, PASSWORD) def test_validation_errors_do_not_echo_the_password(api): @@ -507,7 +507,7 @@ def test_other_routes_keep_the_default_validation_body(api): def test_password_model_never_reprs_the_password(): - from src.models import PasswordStartRequest + from src.application.dtos.password_start_request import PasswordStartRequest model = PasswordStartRequest(username=USER, password=PASSWORD) assert PASSWORD not in repr(model) and PASSWORD not in model.model_dump_json() @@ -521,7 +521,7 @@ def login(username, secret, **kw): spent.append(secret) return FakeBroker() - monkeypatch.setattr(password_signin.entrance, "foci_login", login) + monkeypatch.setattr(entrance, "foci_login", login) assert start(api).json()["status"] == "complete" assert spent and not spent[0] and PASSWORD not in repr(spent[0]) @@ -599,7 +599,7 @@ def get(self, url, **kw): _MsSession.gate = threading.Event() real_sleep = time.sleep monkeypatch.setattr(ep.time, "sleep", lambda s: real_sleep(0.005)) - monkeypatch.setattr(password_signin, "PUSH_INTERVAL", 0) + monkeypatch.setattr(sign_in_options, "push_interval", 0) monkeypatch.setattr(ef, "_post_token", lambda url, data: {"refresh_token": "real-frt", "access_token": "at"}) @@ -612,7 +612,7 @@ def test_real_entrance_push(api, real_entrance): _MsSession.gate.set() done = until(api, sid, "complete") assert done["refresh_token"] == "real-frt" and done["account"]["email"] == USER - assert not reaches(password_signin.store, PASSWORD) + assert not reaches(sign_in_store, PASSWORD) def test_real_entrance_otp_wrong_then_ok(api, real_entrance): @@ -644,7 +644,7 @@ def test_password_routes_need_the_service_key_and_a_user_id(monkeypatch, headers assert bare.delete("/api/auth/password/x", headers=headers).status_code == 401 assert bare.post("/api/auth/password/x/method", json={"method": "push"}, headers=headers).status_code == 401 assert bare.post("/api/auth/password/x/otp", json={"code": "123456"}, headers=headers).status_code == 401 - assert len(password_signin.store) == 0 + assert len(sign_in_store) == 0 def test_routes_are_disabled_when_no_service_key_is_configured(monkeypatch): @@ -710,7 +710,7 @@ def test_otp_budget_per_job_cannot_be_reset_by_rechoosing(api, monkeypatch): def test_otp_budget_per_account_holds_across_jobs(api, monkeypatch): - monkeypatch.setattr(password_signin, "OTP_ACCOUNT_BUDGET", 4) + monkeypatch.setattr(sign_in_options, "otp_account_budget", 4) scripted(monkeypatch, lambda p: drive(p, BOTH, code="654321")) sid = start(api).json()["sign_in_id"] api.post(f"/api/auth/password/{sid}/method", json={"method": "otp"}) diff --git a/tests/test_path_ids.py b/tests/test_path_ids.py index fdf0770..e6ce85d 100644 --- a/tests/test_path_ids.py +++ b/tests/test_path_ids.py @@ -3,8 +3,8 @@ import pytest from fastapi.testclient import TestClient -from src import config, outnote_client -from src.app import app +from tests.services import config, outnote_client +from src.api.app import app H = {config.REFRESH_TOKEN_HEADER: "old-frt"} BAD = ["..", ".", "a..b", "%2E%2E", "%252E%252E"] @@ -30,11 +30,11 @@ class Broker: @pytest.mark.parametrize("bad", BAD) def test_the_shared_validator_refuses_dot_segments(bad): - from fastapi import HTTPException - from src.app import GRAPH_ID_PATTERN, _check_id + from src.domain.errors.service_error import ServiceError as HTTPException + from src.domain.validation.graph_ids import GraphIds for value in (bad, f"a/{bad}/b"): with pytest.raises(HTTPException): - _check_id(value, GRAPH_ID_PATTERN, "id") + GraphIds.check(value, GraphIds.GRAPH_ID_PATTERN, "id") # A literal "." or ".." in a URL path is collapsed by the HTTP client before it is sent, so the routes are probed with the encoded and embedded forms. diff --git a/tests/test_people_photos.py b/tests/test_people_photos.py index 5169ee1..7b41bf4 100644 --- a/tests/test_people_photos.py +++ b/tests/test_people_photos.py @@ -4,8 +4,8 @@ from fastapi.testclient import TestClient from outnote import GraphError -from src import config, outnote_client -from src.app import app +from tests.services import config, outnote_client +from src.api.app import app H = {config.REFRESH_TOKEN_HEADER: "old-frt"} JPEG = bytes([0xFF, 0xD8, 0xFF]) + b"photo" diff --git a/tests/test_profile.py b/tests/test_profile.py index 5c2d991..eb7cadd 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -8,7 +8,7 @@ import wreq.exceptions import pytest -from src import profile +from tests.services import profile from tests.conftest import REAL_FETCH diff --git a/tests/test_reads.py b/tests/test_reads.py index d1fcb1f..504439f 100644 --- a/tests/test_reads.py +++ b/tests/test_reads.py @@ -4,9 +4,9 @@ from fastapi.testclient import TestClient from outnote import GraphError -from src import config, outnote_client -from src import app as app_module -from src.app import app +from tests.services import config, outnote_client + +from src.api.app import app class FakeNote: diff --git a/tests/test_search.py b/tests/test_search.py index 0ffedac..2d58919 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -4,8 +4,8 @@ from fastapi.testclient import TestClient from outnote import GraphError -from src import config, outnote_client, search -from src.app import app +from tests.services import config, outnote_client, search +from src.api.app import app H = {config.REFRESH_TOKEN_HEADER: "old-frt"} TEAM = "11111111-2222-3333-4444-555555555555" diff --git a/tests/test_teams_extras.py b/tests/test_teams_extras.py index 76d3c17..322f621 100644 --- a/tests/test_teams_extras.py +++ b/tests/test_teams_extras.py @@ -7,8 +7,8 @@ from fastapi.testclient import TestClient from outnote import GraphError -from src import account_cache, chat_html, chat_send, config, folder_roles, outnote_client, teams_chat -from src.app import app +from tests.services import OutgoingMessage, SendError, Upload, account_cache, chat_html, chat_send, config, folder_roles, http_clients, outnote_client, teams_chat +from src.api.app import app H = {config.REFRESH_TOKEN_HEADER: "old-frt"} PNG = b"\x89PNG\r\n\x1a\n" + b"x" * 20 @@ -158,7 +158,7 @@ def stream(self, method, url, headers=None, timeout=None, follow_redirects=None) def http(monkeypatch): fake = FakeHttp() monkeypatch.setattr(teams_chat, "http_client", lambda: fake) - monkeypatch.setattr(outnote_client, "download_client", lambda: fake) + monkeypatch.setattr(http_clients, "download_client", lambda: fake) return fake @@ -318,7 +318,7 @@ def post(self, path, body=None): assert path.endswith(":/createUploadSession") return {"uploadUrl": "https://upload.example/session"} - item = chat_send.upload_file(N(), chat_send.Upload("big.mp4", io.BytesIO(b"x" * size), size, "video/mp4")) + item = chat_send.upload_file(N(), Upload("big.mp4", io.BytesIO(b"x" * size), size, "video/mp4")) assert item == {"id": "BIG"} assert all(n % (320 * 1024) == 0 for n, _ in http.puts[:-1]) assert http.puts[-1][1] == f"bytes {chat_send.UPLOAD_CHUNK}-{size - 1}/{size}" @@ -326,8 +326,8 @@ def post(self, path, body=None): def test_files_over_100_mb_are_refused(): import io - with pytest.raises(chat_send.SendError) as ex: - chat_send.validate(chat_send.OutgoingMessage(content="x", files=[chat_send.Upload("a", io.BytesIO(), chat_send.MAX_FILE_BYTES + 1)])) + with pytest.raises(SendError) as ex: + chat_send.validate(OutgoingMessage(content="x", files=[Upload("a", io.BytesIO(), chat_send.MAX_FILE_BYTES + 1)])) assert ex.value.status == 413 @@ -358,7 +358,7 @@ def test_gif_search_needs_a_microsoft_sign_in(env, monkeypatch): def test_gif_search_is_rate_limited_per_account(env, monkeypatch): - from src import gifs + from tests.services import gifs monkeypatch.setenv("GIPHY_API_KEY", "k") monkeypatch.setattr(gifs, "_search", lambda key, query, limit: []) client = TestClient(app) @@ -367,7 +367,7 @@ def test_gif_search_is_rate_limited_per_account(env, monkeypatch): def test_gif_search_returns_only_giphy_urls(env, monkeypatch): - from src import gifs + from tests.services import gifs calls = [] class Http: @@ -400,7 +400,7 @@ def test_account_cache_expires_and_is_bounded(monkeypatch): def test_no_response_carrying_a_token_is_cacheable(env, http, monkeypatch): """Every route that can hand back a rotated refresh token must forbid storing the response.""" - from src import gifs + from tests.services import gifs note, _ = env monkeypatch.setenv("GIPHY_API_KEY", "k") monkeypatch.setattr(gifs, "_search", lambda key, query, limit: []) @@ -455,7 +455,7 @@ def test_oversized_sends_are_refused_before_parsing(env): r = client.post("/api/chats/c1/messages", headers={**H, "Content-Length": str(200 * 1024 * 1024), "Content-Type": "multipart/form-data; boundary=x"}, content=b"--x--") assert r.status_code == 413 big = b"--x\r\nContent-Disposition: form-data; name=\"files\"; filename=\"a.bin\"\r\n\r\n" + b"0" * (300 * 1024) + b"\r\n--x--\r\n" - import src.app as app_module + from tests.services import app_module old = app_module.MAX_SEND_BYTES app_module.MAX_SEND_BYTES = 100 * 1024 try: diff --git a/tests/test_teams_files.py b/tests/test_teams_files.py index dd7e115..4de7dfc 100644 --- a/tests/test_teams_files.py +++ b/tests/test_teams_files.py @@ -7,8 +7,8 @@ from fastapi.testclient import TestClient from outnote import C_SITES, GraphError -from src import config, file_text, outnote_client, teams_files -from src.app import app +from tests.services import config, http_clients, outnote_client, teams_files +from src.api.app import app from tests.test_file_text import docx_bytes H = {config.REFRESH_TOKEN_HEADER: "old-frt"} @@ -104,7 +104,7 @@ def env(monkeypatch): note = Note(broker) http = Http() monkeypatch.setattr(outnote_client, "build_client", lambda frt: (note, broker)) - monkeypatch.setattr(outnote_client, "download_client", lambda: http) + monkeypatch.setattr(http_clients, "download_client", lambda: http) return note, http, TestClient(app) @@ -251,7 +251,7 @@ def test_text_route_converts_legacy_through_pdf(env, monkeypatch): note, http, client = env from tests.test_file_text import pdf_bytes import io - from src import file_pdf + from tests.services import file_pdf note.item = {"id": "I", "name": "old.doc", "size": 5, "file": {}} monkeypatch.setattr(file_pdf, "drive_item_pdf", lambda n, item_id, drive_id=None: io.BytesIO(pdf_bytes(1))) @@ -273,7 +273,7 @@ def test_convert_text_raw_bytes(env): def test_convert_text_body_cap(env, monkeypatch): _, _, client = env - import src.app as app_module + from tests.services import app_module monkeypatch.setattr(app_module, "MAX_TEXT_SOURCE_BYTES", 100) assert client.post("/api/convert/text?name=x.txt", content=b"a" * 101, headers=H).status_code == 413 @@ -291,7 +291,7 @@ def get(self, path): def redirect(self, path): self.paths.append(path) if self.location is None: - raise teams_files.GraphError(200, "expected a redirect, got 200") + raise GraphError(200, "expected a redirect, got 200") return self.location