diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 0000000..9c5308b --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,7 @@ +--- +name: code-reviewer +description: Expert code review specialist. Proactively reviews code for quality, security, and maintainability. Use immediately after writing or modifying code. +tools: Read, Grep, Glob, Bash +model: sonnet +--- +You are a senior code reviewer. Read the recent git diff or modified files, then report only what matters: correctness bugs, security vulnerabilities, and maintainability traps. Lead with the highest-severity finding. diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..16d24f5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "sandbox": { + "filesystem": { + "allowRead": ["."], + "denyRead": ["**/.env", "./DB", "./RTMQ", "./.vscode", "./.pytest_cache", "**/__pycache__"] + } + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3d509c5..4f00b33 100644 --- a/.gitignore +++ b/.gitignore @@ -17,12 +17,17 @@ __pycache__/ .DS_Store Thumbs.db -#env +# env files *.env -#db + +# DB *.db DB/ -#logs + +# rabbitmq +RTMQ/ + +# logs logs/ #Примеры документов diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..389b054 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,206 @@ +# The DisExcel Project — Context for Claude Code + +Backend: FastAPI + SQLAlchemy (async) + Pydantic v2 + PostgreSQL. JWT auth +(access + refresh tokens), RBAC permissions, Redis cache, RabbitMQ background +workers. Python >=3.13,<4.0, Poetry for dependency management. + +## Auth & Permissions + +- Users have `direct_permissions` (list of `Permissions`) and `group` + (list of `PermissionsGroups`, each with its own `permissions`) — + many-to-many both ways. +- Effective permissions = `direct_permissions ∪ (union of all groups' permissions)`. +- `require_permissions(*permissions)` in `src/web/protected_routes` is a + FastAPI dependency factory — wraps `CurrentUserService.get_current_user`. + Call with no args (`require_permissions()`) for "just authenticated, no + specific permission needed". +- Access tokens carry a `jti` claim. Logout writes `revoked_access_token:{jti}` + to Redis with TTL = remaining token lifetime — `get_current_user` checks + this key before anything else. +- `secure` flag on refresh_token cookie is driven by `env_settings.PROD_MODE` + (bool) — `False` locally/tests so cookies work over plain HTTP, `True` in + prod. + +## Redis (`src/cache/`) + +- `RedisClient(redis.Redis)` — module-level shared singleton, subclasses + `redis.Redis` directly (inherits all commands, no manual wrapping needed). +- Three uses: permissions is-cache was considered and rejected (no real DB + savings — `get_user_by_id` already eager-loads everything via `selectin` + in one call); rate limiting on login (`RateLimit.rate_limit(ip)` — + `INCR` + `EXPIRE` on first attempt, blocks >5/60s); access-token revoke + blacklist (see above). +- Rate limit is only triggered inside `except HTTPException` on `/protected/token` + — i.e. only on failed logins, not successful ones (otherwise legitimate + repeated logins would trip it). + +## RabbitMQ (`src/messaging/`) + +- `RabbitMQClient` — shared class, lazy `connect()` (can't be async `__init__`), + holds one `connection` + one `channel`, `get_channel()` ensures setup. + `connect()` retries `connect_robust()` up to 5 times with exponential + backoff (`asyncio.sleep(2**attempt)`) before raising — needed because + when running the app locally (outside Docker Compose) right after + `docker compose up rabbitmq`, the broker's AMQP listener isn't + immediately ready and resets the connection mid-handshake + (`ConnectionResetError`). Compose's own `depends_on: condition: + service_healthy` only helps when the app itself also runs as a Compose + service — it does nothing when the app is started on the host. +- **Topology is centralized, not declared ad-hoc.** `src/models/rabbitmq_models/email.py` + defines the whole topology as pydantic data (`Topology`/`ExchangeSpec`/`QueueSpec`, + exported as `email_topology`) — topic exchange `"email"`, one durable + quorum queue per message type (`queue_welcome_email`/`email.welcome`, + `queue_reset_email`/`email.reset`), each with a matching dead-letter queue. + `src/messaging/topology_setup.py::apply_topology(channel, topology)` is + the only place that actually calls `declare_exchange`/`declare_queue`/`bind` + against RabbitMQ. It's called once at startup — in `main.py`'s `lifespan` + (web) and in `daemon_run.py`'s `main()` (daemon entrypoint) — **before** + any producer/consumer touches the exchange/queues. `EmailProducer`/ + `WelcomeEmailConsumer`/`ResetEmailConsumer` no longer declare or bind + anything themselves — they only `get_exchange("email")` / + `get_queue("queue_...")`, assuming `apply_topology` already ran. + Changing a queue's arguments in `email_topology` requires manually + deleting the existing queue in RabbitMQ first — `declare_queue` on an + already-existing queue with different arguments fails with + `PRECONDITION_FAILED`, it doesn't update in place. +- **Dead-lettering**: each original queue's `x-dead-letter-exchange` points + at `email.dlx`, a **`direct`** exchange (not `fanout` — fanout would + broadcast every dead-lettered message to *all* DLQs, mixing up + welcome/reset failures). Each DLQ is bound to `email.dlx` with the same + routing key as the original queue (`email.welcome`/`email.reset`); + RabbitMQ preserves a message's original routing key when dead-lettering, + so no explicit `x-dead-letter-routing-key` override is needed — the + `direct` exchange alone routes each dead letter back to the correct DLQ. +- `message.process(ignore_processed=True)` + manual `await message.nack(...)` + in `process_message`, not the plain auto-ack/nack that `message.process()` + does by default — needed because business-logic exceptions are caught + *inside* the block (to log them and decide retry vs. dead-letter) rather + than left to propagate, so `process()` itself must be told not to also + try to ack/nack on exit. Classification: transient errors (SMTP/network: + `SMTPServerDisconnected`, `SMTPConnectError`, `TimeoutError`, + `ConnectionRefusedError`) → `nack(requeue=True)`; everything else + (bad message body, `SMTPAuthenticationError`, bugs) → `nack(requeue=False)` + → DLQ. Note `SMTPAuthenticationError` is a known rough edge here — it's + a systemic config problem (bad credentials), not a per-message one, but + is currently DLQ'd per-message like any other permanent error rather + than halting the daemon with an alert. +- Email templates: Jinja2 (`src/service/email/jinja_env.py` — one shared + `Environment`/`FileSystemLoader` singleton, templates in + `src/service/email/templates/`), inline CSS (email clients don't support + `