Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 60 additions & 34 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,38 +15,38 @@ 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
and responses (the 422 handler for `/api/auth/password*` strips the echoed input on purpose).
- 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).

Expand All @@ -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 `<Area>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/<area>_extensions.py`, and a method on `src/api/controllers/<area>_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/ # <Area>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
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions asgi.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions docs/setup/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
77 changes: 77 additions & 0 deletions src/api/app.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading