From ec540a1a725862a8619fcb758daec4ad197f0dd5 Mon Sep 17 00:00:00 2001 From: Mikhail Verkovykh Date: Thu, 10 Sep 2026 18:01:35 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=B3=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20skeleton=20=D0=BB=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=BD=D0=BE=D0=B9=204=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BC=D0=B5=D1=81=D1=81=D0=B5=D0=BD=D0=B4=D0=B6=D0=B5?= =?UTF-8?q?=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 20 + .env.example | 20 + .github/pull_request_template.md | 18 + .github/workflows/contracts.yml | 16 + .gitignore | 32 + CONTRIBUTING.md | 19 + COURSE.md | 53 + Dockerfile.example | 8 + MAINTAINERS.md | 38 + Makefile | 30 + README.md | 105 + REPORT.md | 37 + compose.example.yaml | 12 + contracts/README.md | 61 + contracts/examples.json | 60 + contracts/infrastructure.json | 46 + contracts/openapi.json | 3506 ++++++++++++++++++++++++++++++ evidence/README.md | 5 + examples/requests.http | 53 + infra/README.md | 7 + lab.json | 8 + requirements-dev.txt | 2 + scripts/check_contract.py | 54 + scripts/restart.example.sh | 10 + scripts/stop-one.example.sh | 10 + scripts/validate_openapi.py | 23 + tests/README.md | 57 + tests/client.py | 217 ++ tests/fixtures/README.md | 9 + tests/fixtures/sample.png | Bin 0 -> 111817 bytes tests/image_probe.py | 26 + tests/load.py | 58 + tests/resilience.py | 129 ++ tests/smoke.py | 82 + 34 files changed, 4831 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/contracts.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 COURSE.md create mode 100644 Dockerfile.example create mode 100644 MAINTAINERS.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 REPORT.md create mode 100644 compose.example.yaml create mode 100644 contracts/README.md create mode 100644 contracts/examples.json create mode 100644 contracts/infrastructure.json create mode 100644 contracts/openapi.json create mode 100644 evidence/README.md create mode 100644 examples/requests.http create mode 100644 infra/README.md create mode 100644 lab.json create mode 100644 requirements-dev.txt create mode 100644 scripts/check_contract.py create mode 100755 scripts/restart.example.sh create mode 100755 scripts/stop-one.example.sh create mode 100644 scripts/validate_openapi.py create mode 100644 tests/README.md create mode 100644 tests/client.py create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/sample.png create mode 100644 tests/image_probe.py create mode 100644 tests/load.py create mode 100644 tests/resilience.py create mode 100644 tests/smoke.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bc16e55 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.github +.env +.env.* +!.env.example +secrets +certs +volumes +data +backups +*.key +*.pem +*.p12 +*.pfx +*.jks +.venv +__pycache__ +node_modules +evidence +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d99d26a --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Copy to .env for your LOCAL synthetic test environment; never commit .env. +# These are logical interface suggestions; map actual framework names in REPORT.md. +APP_HOST=0.0.0.0 +APP_PORT=8080 +APP_ENV=lab +SESSION_TTL_SECONDS=3600 +# Choose credentials locally. File paths contain no secret values. +DATABASE_URL_FILE=/run/secrets/database_url +# Optional on 3, required use on 4 in lab2; Redis OR Valkey: +SESSION_STORE=postgres +CACHE_URL_FILE=/run/secrets/cache_url +API_REPLICAS=2 +IDEMPOTENCY_TTL_SECONDS=86400 +S3_ENDPOINT=http://s3:9000 +S3_REGION=us-east-1 +S3_BUCKET=messenger-attachments +S3_ACCESS_KEY_ID_FILE=/run/secrets/s3_access_key_id +S3_SECRET_ACCESS_KEY_FILE=/run/secrets/s3_secret_access_key +MAX_UPLOAD_BYTES=10485760 +MAX_IMAGE_PIXELS=40000000 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..1bec547 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Лабораторная 4 + +Автор / группа: TODO +Целевая оценка (3/4/5): TODO +Трек 5, если заявлен: TODO + +Что реализовано и что перенесено: TODO +Команды запуска из чистого clone: TODO +Результаты проверок и ссылка на REPORT.md: TODO +Ограничения / известные ошибки: TODO +Источники и использование ИИ: TODO + +- [ ] Весь обязательный минимум текущей и предыдущих работ сохранён с объявленными переходами. +- [ ] Контракт и публичные тесты не ослаблены. +- [ ] make check и make test выполнены; приложен реальный вывод. +- [ ] Сценарии отказов/безопасности своего уровня воспроизведены. +- [ ] Все секреты и приватные ключи находятся вне Git; отчёт не содержит tokens. +- [ ] README задания сохранён, REPORT.md содержит инструкцию именно моего решения. diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 0000000..11cf289 --- /dev/null +++ b/.github/workflows/contracts.yml @@ -0,0 +1,16 @@ +name: Skeleton contracts +on: [push, pull_request] +permissions: + contents: read +jobs: + contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install -r requirements-dev.txt + - run: make check validate PYTHON=python +# This job validates the skeleton, not a student solution. +# Add build, readiness and make test in a separate application job. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5a7189 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Local/private state +.env +.env.* +!.env.example +secrets/ +certs/private/ +*.key +*.p12 +*.pfx +*.jks +*.pem +*.tfstate* +volumes/ +data/ +backups/ +# Runtime/build artifacts +.venv/ +__pycache__/ +*.py[cod] +node_modules/ +dist/ +build/ +target/ +bin/ +obj/ +coverage/ +*.log +.DS_Store +.idea/ +.vscode/ +# Keep small, sanitized evidence summaries in evidence/ under version control. +# Inspect git diff --cached: ignore rules are not a secret scanner. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ee5533d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,19 @@ +# Работа через fork и pull request + +1. После публикации преподавателем сделайте fork репозитория **текущей** лабораторной в своём аккаунте и clone своего fork. URL преподаватель выдаёт отдельно. +2. Создайте ветку `solution/-` от исходной ветки skeleton. Ветка преподавателя и файлы контракта остаются основой проверки. +3. Для lab2–7 перенесите из своей предыдущей лабы только исходники, миграции, собственные тесты, Dockerfile и нужные конфигурации. Не копируйте `.git`, `.env`, credentials, volumes, `README.md`, `COURSE.md`, публичные `tests/` и `contracts/` поверх новой версии. Сначала сравните изменения API в `COURSE.md`. +4. Реализуйте задание, добавьте свою инструкцию запуска и заполните `REPORT.md`. Публичные проверки дополняйте отдельными тестами, не ослабляйте assertions. +5. Выполните `make check`, запустите свой стенд, затем `make test`. Выполните сценарии отказов текущей лабы. Их запуск всегда явный: scaffold сам не останавливает контейнеры. +6. Commit и push делайте в свой fork. Откройте PR в исходный репозиторий преподавателя, заголовок: `ЛР N — Группа — Фамилия — на 3/4/5`. Выберите правильную базовую ветку и заполните шаблон. +7. После замечаний обновляйте тот же PR. Чужие решения не переносите без указания источника и согласованных правил курса. + +Платформа может называть PR «merge request» — процесс тот же. `.github/` содержит необязательные удобства для GitHub; проверки локально от платформы не зависят. Инициализация и публикация исходных Git-репозиториев выполняются преподавателем отдельно. + +## Правила изменения skeleton + +Не удаляйте обязательные endpoint, поля, рубрику и публичные проверки. Изменение контракта согласуйте отдельным комментарием/PR; обход тестов не является решением. Код приложения размещайте в `src/` либо в принятой для языка структуре. Собственные тесты — `student-tests/` или штатный каталог фреймворка. Ссылки на результаты проверки и команды запуска должны переживать clone на другой машине. + +## CI + +Готовый GitHub workflow проверяет целостность контракта и синтаксис Python-проверок. Он **не запускает отсутствующее приложение** и **не подтверждает оценку**. Студент добавляет сборку своего приложения, запуск/готовность стенда и `make test` в отдельный job. Не используйте `pull_request_target` для запуска кода из fork, не выдавайте CI секреты или доступ к общему Docker-host. diff --git a/COURSE.md b/COURSE.md new file mode 100644 index 0000000..b02172b --- /dev/null +++ b/COURSE.md @@ -0,0 +1,53 @@ +# Сквозной проект: мессенджер под нагрузкой + +Семь самостоятельных репозиториев содержат задания и проверяемые контракты одной системы. Вы переносите собственную реализацию между лабораторными. Язык, фреймворк и библиотеки выбираете сами. Web UI, WebSocket, редактирование/удаление сообщений, публичный поиск пользователей и Kubernetes не входят в обязательный минимум: достаточно REST API. + +| Лаба | Новая инженерная задача | Что появляется в системе | +| --- | --- | --- | +| 1 | Контракт, процессы и контейнер | In-memory API, Dockerfile, HTTP smoke | +| 2 | Сохранность и идентификация | PostgreSQL, Basic → сессия; Redis/Valkey на 4 | +| 3 | Горизонтальное масштабирование | Балансировщик, ≥2 API-реплики, общие сессии, идемпотентность | +| 4 | Бинарные данные | Приватное S3-совместимое хранилище и вложения | +| 5 | Фоновая работа | Брокер, worker, состояния вложений и преобразование изображений | +| 6 | Диагностика | OTel Collector, хранилища сигналов, Grafana | +| 7 | Защита и эксплуатация | Argon2id, TLS/mTLS, OpenBao, минимальные привилегии | + +## Как оценивать + +Оценки накопительные **внутри лабораторной**: 4 = весь уровень 3 + весь уровень 4; 5 = весь уровень 4 + **один** полностью выполненный трек 5 на выбор. Несколько недоделанных треков не заменяют один завершённый. Условие «весь уровень» включает реализацию, проверку и объяснение на защите. Красивые схемы и дополнительные технологии не компенсируют неработающий обязательный сценарий. + +Для начала следующей лабы достаточно обязательного минимума предыдущей. Сохраняются предыдущие обязательные возможности, с явно описанными переходами API ниже. Не требуется сначала получить 5 за все ранние лабы. Можно исправлять свою базу по ходу курса; такие исправления выделяйте в PR. Реализация на 3 работоспособна, но не претендует на промышленную готовность. Даже 5 в lab7 — учебная доказанная защита в заявленной модели угроз, а не сертификат полной безопасности. + +На 3 студент самостоятельно собирает рабочую систему. На 4 показывает практики, ожидаемые от стажёра/джуна в команде: воспроизводимость, диагностику, обработку ошибок и проверки отказов. На 5 формулирует гипотезу, ставит эксперимент, приводит измерения и обсуждает границы решения — это предмет конкретной похвалы магистранту. + +## Контракт и совместимость + +`contracts/openapi.json` — OpenAPI 3.1.0, `contracts/README.md` — нормативная семантика. Контракт каждого репозитория самодостаточен; соседние папки на машине преподавателя не нужны. `tests/` — публичные проверки, а не эталонное решение. Контракт, рубрика и публичные тесты имеют приоритет над случайным поведением библиотек. При противоречии создайте вопрос преподавателю; не подгоняйте тест под приложение. + +Три объявленных изменения обязательного API: + +1. **1 → 2:** регистрация требует `password`; `X-User-Id` заменяется Bearer. Basic используется только при создании сессии. +2. **2 → 3:** `Idempotency-Key` обязателен для отправки сообщения; ответы API содержат `X-Instance-Id`. +3. **4 → 5:** загрузка вложения возвращает `202` вместо `201`, состояние становится асинхронным. Клиент опрашивает метаданные. Старое текстовое сообщение остаётся валидным: `attachment_ids` по умолчанию `[]`. + +У lab7 меняется транспорт на HTTPS, бизнес-эндпоинты остаются прежними. Внутреннее mTLS не заменяет пользовательскую сессию. Новые возможности на 4/5 добавляйте обратно совместимо, в отдельном `contracts/extensions.openapi.json` или документе; обязательный контракт сохраняйте. + +## Общая предметная модель + +Пользователь (`username`, `display_name`) состоит в чатах. Список участников задаётся при создании чата и в обязательном API не изменяется. В чате отправляются сообщения, с lab4 — со ссылками на вложения этого чата. Сервер назначает UUID и время. Порядок истории — `(created_at, id)` по возрастанию. Для защиты достаточно показать взаимодействие нескольких API-клиентов; фронтенд не требуется. + +С lab2 данные PostgreSQL — источник истины. Сессии могут храниться в PostgreSQL или Redis/Valkey, если выполняют контракт срока жизни и перезапуска. Файлы с lab4 живут в S3, метаданные — в PostgreSQL. Redis и Valkey — альтернативы: оба одновременно не нужны. Аналогично выбирается одно S3-хранилище, один брокер и один основной путь оркестрации. + +## Воспроизводимость и честные измерения + +- Укажите версии runtime и контейнерных образов, аппаратную конфигурацию, CPU/RAM-лимиты, команду запуска и время прогрева. Для образов используйте явный тег версии или digest, не `latest`. +- Все тестовые пользователи и изображения синтетические. `make test` создаёт данные: запускайте его на учебном стенде. Не публикуйте пароли, токены, приватные ключи, персональные данные или подписанные URL. +- Минимальный отчёт о нагрузке: длительность, конкуренция/интенсивность, успешные RPS, доля ошибок, p50/p95/p99, CPU/RAM, размер набора данных. Универсального проходного RPS для разных ноутбуков нет. +- Сравнивайте одинаковые запросы при одинаковых ресурсах. Успешный `/health/live` не доказывает сохранность сообщений, а HTTP `202` — окончание обработки. +- Один Docker-host не обеспечивает отказоустойчивость к потере этого host. Несколько API-процессов не делают единственную БД или LB высокодоступными. Отдельно называйте, какие отказы выдерживает эксперимент. + +## Что сдавать + +Исходники, Dockerfile, нужные манифесты и конфигурации, миграции, собственные тесты, заполненный `REPORT.md`, сведения об использовании внешнего кода и ИИ. Уметь объяснить и изменить своё решение на защите обязательно. Приложите команды и текстовые результаты; скриншоты — дополнение, а не единственное доказательство. + +PR должен собираться из чистого clone по инструкции автора. Секреты создаются отдельно; тестовые fixtures и открытые конфигурации хранятся в репозитории. Перенос кода между лабами описан в [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/Dockerfile.example b/Dockerfile.example new file mode 100644 index 0000000..e333a12 --- /dev/null +++ b/Dockerfile.example @@ -0,0 +1,8 @@ +# Teaching outline, not a runnable Dockerfile. Implement your own Dockerfile. +# 1. Choose a runtime/toolchain version and pin the base image tag/digest. +# 2. Copy dependency manifests first; install from the language lockfile. +# 3. Copy source and build. For compiled languages use a separate runtime stage. +# 4. Run the HTTP process as PID 1; listen on 0.0.0.0:8080. +# 5. Add non-root, signals and healthcheck according to the current rubric. +# 6. Pass runtime config/secrets at startup, never COPY .env or secret files. +# This file deliberately has no FROM/CMD: selecting/implementing them is the task. diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..bb10fed --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,38 @@ +# Памятка преподавателю — лабораторная 4 + +## Что подготовлено + +Язык реализации не задан. README содержит накопительные уровни 3/4/5, сценарий защиты и вопросы. В каждой лабе собственная полная версия OpenAPI, поэтому fork не зависит от соседних каталогов. Публичный HTTP smoke не содержит реализации сервера; Dockerfile.example и compose.example.yaml (если есть) — задания/ориентиры, не готовый deployment. + +## Перед публикацией + +1. Выберите GitHub/GitLab и организацию, опубликуйте каждый каталог как отдельный template/skeleton repository, сообщите студентам URL и правила именования PR/MR. Эти материалы не публикуют репозитории автоматически. +2. Задайте default branch, защиту исходных материалов и правила приёма. `.github/` уже содержит шаблон PR и CI контракта; в GitLab перенесите эквивалентные настройки, локальные Makefile-команды одинаковы. +3. Сообщите, индивидуальная работа или командная, сроки, правила использования стороннего кода/ИИ и ресурсы демонстрационного стенда. Эти организационные решения намеренно не выдуманы в задании. +4. Уточните ограничения площадки (например доступна ли виртуализация/несколько узлов). Для 5 предусмотрено по два трека: один можно выполнить без настоящего multi-node cloud. Универсальный проходной RPS не установлен. + +## Что проверять + +| Проверка | Что подтверждает | Чего не подтверждает | +| --- | --- | --- | +| make check | Структура файлов, JSON, локальные refs, синтаксис Python | Реализация/запуск API | +| make validate | Полная OpenAPI 3.1, JSON Schema и примеры | Поведение сервера | +| make test | Один сквозной контрактный сценарий текущей лабы | Все ошибки, persistence, HA, security | +| test-persistence (lab2+) | Данные/прежний токен после вызова restart hook | Реальный перезапуск, если hook — no-op | +| test-failover (lab3+) | Смена обслуживающей реплики, сохранность, retry без дубля | Несколько физических узлов, HA DB/LB | +| test-security (lab7) | Доверенный HTTPS и отказ недоверенного CA | Внутреннее mTLS/identity/ACL/OpenBao | +| Защита и тесты студента | Реальность инфраструктуры и критерии выбранного уровня | Неограниченная промышленная безопасность | + +Проверяйте реальные контейнеры/логи процессов и данные до/после отказа: одного X-Instance-Id недостаточно. Для 4 требуются все пункты 3+4, для 5 — все пункты 3+4 и один **завершённый** трек 5. Сравнивайте подтверждённые результаты со сформулированными критериями, а не количество установленных сервисов. + +## Приём PR + +Проверка запускается на изолированном учебном окружении с синтетическими secrets, не на общей production БД. Студенческие Dockerfile/hooks/CI — выполняемый код: обычный fork workflow без секретов и без доступа к общему Docker socket. Не запускайте PR через pull_request_target с привилегиями преподавателя. + +Исходные README/контракт/публичные tests сохраняются, реализация переносится студентом между лабами через собственные исходники и миграции. Исправление общей спецификации публикуйте отдельным изменением, объясняя влияние на все затронутые лабораторные. + +## Проверка этой версии skeleton + +На 2026-09-10 выполнены структурные проверки и полная валидация всех семи OpenAPI/JSON Schema, проверка локальных Markdown-ссылок/YAML и декодирование PNG fixture. HTTP-клиенты проверены на временном изолированном тестовом стенде: позитивный smoke 1–7, отрицательные варианты неправильного sender, pagination, ACL, logout, bytes и пустого API; также пути persistence/failover/load и внешнего TLS. + +Временный стенд не включён в студенческие каталоги и не является эталонным решением. Реальное приложение, Compose, PostgreSQL/S3/broker и mTLS/OpenBao в skeleton отсутствуют: их работоспособность не заявляется. Docker CLI в среде подготовки не был доступен. Это проверка материалов и тестовых драйверов, не приёмка будущих решений студентов. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5d86d17 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +PYTHON ?= python3 +BASE_URL ?= http://localhost:8080 +CA_FILE ?= +ACTION_SCRIPT ?= +TLS_ARGS = $(if $(CA_FILE),--ca-file "$(CA_FILE)",) + +.PHONY: help check validate test load test-persistence test-failover + +help: + @echo "check: offline structure; validate: full OpenAPI (requirements-dev.txt); test: running API; load: synthetic writes" + +check: + $(PYTHON) scripts/check_contract.py + +validate: + $(PYTHON) scripts/validate_openapi.py + +test: + $(PYTHON) tests/smoke.py --base-url "$(BASE_URL)" $(TLS_ARGS) + +load: + $(PYTHON) tests/load.py --base-url "$(BASE_URL)" $(TLS_ARGS) + +test-persistence: + @test -n "$(ACTION_SCRIPT)" || (echo "Set ACTION_SCRIPT to your implemented executable restart hook"; exit 2) + $(PYTHON) tests/resilience.py --mode persistence --action-script "$(ACTION_SCRIPT)" --base-url "$(BASE_URL)" $(TLS_ARGS) + +test-failover: + @test -n "$(ACTION_SCRIPT)" || (echo "Set ACTION_SCRIPT to your implemented executable stop-one hook"; exit 2) + $(PYTHON) tests/resilience.py --mode failover --action-script "$(ACTION_SCRIPT)" --base-url "$(BASE_URL)" $(TLS_ARGS) diff --git a/README.md b/README.md new file mode 100644 index 0000000..947094c --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# Лабораторная 4. S3-хранилище и вложения + +Разделить метаданные и бинарное содержимое, добавить изображения в сообщения и сохранить доступ к ним при переключении API-реплики. + +Это **skeleton**, а не готовый сервер. Здесь есть задание, контракт и публичные проверки. Реализацию приложения, Dockerfile и требуемую инфраструктуру пишет студент. Язык и framework свободные; UI не обязателен. + +[Карта курса](COURSE.md) · [Процесс fork/PR](CONTRIBUTING.md) · [OpenAPI](contracts/openapi.json) · [Семантика API](contracts/README.md) · [Проверки](tests/README.md) · [Отчёт](REPORT.md) · [Примеры JSON](contracts/examples.json) · [Памятка преподавателю](MAINTAINERS.md) + +## Откуда начинаем + +Перенесите минимум lab3. Дополняются поля сообщения и endpoint вложений; прежние текстовые POST остаются валидными. + +**Архитектура:** Клиент → LB → API × 2+ → PostgreSQL (метаданные) + приватное S3-хранилище (байты). + +## Что сделать + +1. Выберите один S3-совместимый сервер: MinIO, SeaweedFS или RustFS. Закрепите проверенную версию образа и опишите создание приватного bucket. +2. Спроектируйте Attachment и связь с чатом/сообщением. S3 object key назначает сервер, filename используется только для отображения. +3. Реализуйте синхронный binary upload PNG/JPEG с ограничениями, сохранение метаданных и авторизованный download через API. +4. Включите S3 и persistent volumes в общий Compose/Swarm. Проверьте, что другая API-реплика скачивает файл после остановки загрузившей. +5. Проверьте недоступность S3, чужой чат и restart всего стенда. + +## Оценка «3»: работающий минимум + +- Все обязательные возможности lab3 сохранены. S3-сервер находится в общем стенде; метаданные вложений живут в PostgreSQL, байты — в приватном bucket, не в БД или локальном каталоге API. +- POST вложения возвращает 201 ready после сохранения; оригинал скачивается побайтно. Message с attachment_ids читается участниками чата. Публичный smoke проверяет checksum и отсутствие доступа у постороннего. +- Принимаются реальные PNG/JPEG ≤10 MiB и ≤40 млн пикселей; MIME проверяется по содержимому, опасные filename отклоняются, лимиты применяются до неограниченного чтения/декодирования. +- Upload → остановка обслужившей API-реплики → download через другую возвращает тот же файл. Restart с сохранением volumes сохраняет метаданные и оригинал. + +## Оценка «4»: уровень стажёра/джуна + +Весь уровень 3 **и все** пункты ниже: + +- Тесты покрывают слишком большой файл, повреждённое изображение, подмену MIME, чужое attachment_id и привязку файла другого чата. Пользовательская ошибка не превращается в 500. +- Прерывание загрузки и отказ между записью S3/БД не оставляют вечных неучтённых объектов: есть документированный механизм компенсации/сверки/очистки с безопасным grace period. +- Лимиты/таймауты и потоковая обработка не позволяют одной загрузке занять неограниченную RAM. Проведён эксперимент ≥10 одновременных загрузок с измерением пика памяти. +- Bucket bootstrap идемпотентен; API использует отдельные S3 credentials с ограниченными правами. Административная консоль не открыта в общей сети. Есть проверка private bucket вне API. + +## Оценка «5»: исследование и доказанный результат + +Весь уровень 4 **и один законченный трек на выбор**. Приведите гипотезу, повторяемую методику, результаты и ограничения. + +**Трек 1. Прямой upload.** Добавьте отдельный безопасный presigned-upload flow: короткая подпись, уникальный ключ, проверка принадлежности, размера/checksum при complete, обработка незавершённой загрузки. Сохраните обязательный proxy endpoint. Сравните трафик и RAM API при ≥2 размерах файлов, покажите негативные сценарии. + +**Трек 2. Согласованность двух хранилищ.** Разработайте повторяемый reconciler для orphan/missing объектов и метаданных. Внедрите ошибки на обеих сторонах, покажите обнаружение и безопасное исправление с dry-run/повторным запуском. Докажите, что свежие и используемые вложения не удаляются; измерьте стоимость обхода. + +## Как начать и проверить + +```sh +# Работает прямо в skeleton, Python 3.9+; приложение не запускается: +make check + +# После реализации запустите свой сервер/стенд по REPORT.md. +# По умолчанию тест использует http://localhost:8080. +make test +``` + +`.env.example`, `compose.example.yaml` и `contracts/infrastructure.json` описывают настройки и роли компонентов. **compose.example.yaml — список ролей с пустым services, не запускаемый стенд.** Реализуйте свой `compose.yaml`/`stack.yaml`, закрепите версии образов, заполните локальный `.env` и опишите bootstrap/миграции. Все зависимости поднимаются из этого репозитория; ссылка «у меня БД уже установлена» не заменяет воспроизводимость. + +```sh +# Compose-вариант после реализации: +docker compose --env-file .env config --quiet +docker compose --env-file .env up -d --build +# Дождитесь /health/ready; затем make test. +# Для Swarm укажите эквивалентные build/push/deploy-команды в REPORT.md. +``` + + +Примеры запросов доступны в `examples/requests.http`, примеры JSON — в `contracts/examples.json`. Для полной проверки схемы (по желанию локально; CI делает её автоматически): + +```sh +python3 -m venv .venv +.venv/bin/python -m pip install -r requirements-dev.txt +make validate PYTHON=.venv/bin/python +``` + +Smoke не выставляет оценку автоматически. Проверку сохранности/отказов запускайте явно по [tests/README.md](tests/README.md), остальные доказательства приведите в отчёте. + +## Сценарий защиты + +1. Загрузить fixture, отправить message с attachment_ids, скачать как второй участник и сравнить SHA-256. +2. С третьим пользователем получить 404 для метаданных и bytes; direct unauthenticated S3 GET не отдаёт объект. +3. Выполнить persistence и failover: общий тест начиная с lab4 сохраняет и повторно читает оригинал через API. +4. Показать, что на API нет обязательного локального файла оригинала. Для 4/5 — отказы и эксперименты. + +## Что должно быть в PR + +Дополненный стенд, настройка bucket/политики, миграции Attachment, тесты файлов, описание согласованности БД/S3 и результаты. + +Заполните `REPORT.md` и [шаблон PR](.github/pull_request_template.md), укажите целевую оценку/трек. Не меняйте обязательный контракт и публичные tests ради зелёного результата. Сохраняйте возможности предыдущих лабораторных в пределах [объявленных переходов](COURSE.md). + +## Вопросы на защите + +- Почему файл в Docker volume одной API-реплики не равен объектному хранилищу? +- Когда можно честно вернуть 201? +- Что произойдёт, если S3 PUT прошёл, а COMMIT в PostgreSQL — нет? +- Почему случайный object key и приватный bucket не заменяют ACL приложения? + +## Первичные материалы + +- [S3 object uploads](https://docs.aws.amazon.com/AmazonS3/latest/userguide/upload-objects.html) +- [Presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html) +- [MinIO](https://github.com/minio/minio) +- [SeaweedFS](https://github.com/seaweedfs/seaweedfs) +- [RustFS](https://docs.rustfs.com/) diff --git a/REPORT.md b/REPORT.md new file mode 100644 index 0000000..9ad2b84 --- /dev/null +++ b/REPORT.md @@ -0,0 +1,37 @@ +# Отчёт — лабораторная 4 + +- Автор, группа: TODO +- Целевая оценка; выбранный трек 5, если нужен: TODO +- Commit/PR: TODO после публикации +- Использованные источники, библиотеки и помощь ИИ: TODO + +## Запуск из чистого clone + +TODO: версии runtime/образов, prerequisites, команды создания локальных secrets/.env, build, миграций, запуска, ожидания готовности, тестов и остановки. Указать необходимые действия оператора. Не вставлять секреты. Отдельно указать безопасную остановку и команду намеренного удаления учебных данных. + +## Архитектура и решения + +TODO: схема процессов/хранилищ, источник истины, транзакционные границы, ограничения. Что перенесено из lab3, что изменено в этой работе. Соответствие переменных из .env.example реальной конфигурации. + +## Доказательства критериев + +| Критерий README | Команда/сценарий | Наблюдаемый результат | Файл доказательства | +| --- | --- | --- | --- | +| Публичный smoke | make test | TODO | TODO | +| Остальные пункты уровня 3 | TODO | TODO | TODO | +| Все пункты уровня 4, если заявлен | TODO | TODO | TODO | +| Выбранный трек 5, если заявлен | TODO | TODO | TODO | + +TODO: добавить отдельную строку для каждого заявленного критерия. PASS без команды и наблюдения не является доказательством. Проверка инфраструктурного hook требует подтверждения, какие реальные процессы/хранилища перезапущены. + +## Эксперименты и отказы + +TODO: гипотеза, hardware/лимиты, версии, объём данных, профиль нагрузки, concurrency/arrival rate, длительность/прогрев, повторы, успешные RPS, ошибки, p50/p95/p99, CPU/RAM. Сохранить raw summary без tokens и объяснить ограничения измерения. + +| Воздействие | Что ожидаем | Что наблюдали | Время восстановления / потеря данных | +| --- | --- | --- | --- | +| TODO | TODO | TODO | TODO | + +## Ограничения и следующие шаги + +TODO: какие отказы выдерживаются и какие нет; что не реализовано; где учебное упрощение; какое улучшение подтверждено, а какое пока гипотеза. diff --git a/compose.example.yaml b/compose.example.yaml new file mode 100644 index 0000000..76a785f --- /dev/null +++ b/compose.example.yaml @@ -0,0 +1,12 @@ +# Topology worksheet, deliberately NOT a runnable stack (no application supplied). +# Implement compose.yaml or stack.yaml; do not submit this empty file as deployment. +# Service names below are logical roles, not imposed container names. +x-lab-number: 4 +x-required-roles: + - api + - postgres + - load_balancer + - s3 +# TODO: implement real images/builds, network wiring, volumes, healthchecks, +# credentials and bootstrap. Details: contracts/infrastructure.json and README.md. +services: {} diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 0000000..8c58b32 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,61 @@ +# Семантика API + +Этот текст дополняет [openapi.json](openapi.json). Обязательный API соответствует уровню 3 текущей лабораторной. Факультативный crop или rate limiting не становятся обязательными только из-за наличия допустимого имени варианта или кода ошибки в схеме. + +## Формат и ошибки + +- JSON — UTF-8, `Content-Type: application/json`. UUID — строка; время — RFC 3339 UTC с `Z`, назначается сервером. Часы не считаются уникальным ID. +- Каждый ответ API, включая ошибки и health, содержит непустой `X-Request-Id`. Тело ошибки: `{"error":{"code":"invalid_request","message":"...","request_id":"..."}}`; `request_id` совпадает с заголовком. Сообщение ошибки не раскрывает stack trace, SQL, credentials или наличие чужого объекта. +- Обязательные строки не состоят только из пробельных символов. `username` строго `[a-z0-9_]{3,32}`, уникален; `display_name` — 1–100 Unicode code points. JSON с лишними полями или неверными типами — `400`; malformed JSON также `400`. +- `400 invalid_request` — неверные поля, UUID, cursor, limit; `401 unauthorized` — нет действительной идентификации/сессии; `404 not_found` — объекта нет или он чужой; `409 conflict` — username занят, повторный ключ с другим сообщением, объект ещё не готов; `413 payload_too_large`; `415 unsupported_media_type`; `429 rate_limited`; `503 unavailable`. Неожиданное исключение — `500 internal_error` без внутренних деталей. +- Сначала аутентификация, затем доступ к конкретному ресурсу. Любой аутентифицированный участник чата может читать его историю и вложения и писать в него; посторонний получает `404`. Списки содержат только доступные объекты. Не используйте `403` для раскрытия существования чужого чата. +- `GET /health/live` проверяет только жизнь процесса, возвращает `200 {"status":"ok"}`. `GET /health/ready` возвращает 200, если приложение может безопасно обслуживать обязательный синхронный путь; иначе 503 в общем формате ошибки. Какие зависимости критичны, зафиксируйте в отчёте; недоступность Grafana не должна останавливать API. + +## Пользователи, чаты и сообщения + +- `POST /users` публичен; возвращает `201 User`. Пароль/его hash и сессионные данные никогда не входят в User. +- `POST /chats` принимает title и явный список существующих `member_ids`, включая вызывающего. Дубликаты, неизвестные участники и отсутствие вызывающего — 400. Чат создаётся атомарно. GET списка чатов сортируется по `(created_at, id)` по возрастанию. +- Отправитель сообщения выводится из текущей идентификации, поле `sender_id` в запросе не принимается. Текст — до 4000 Unicode code points. Нельзя отправить пустой/пробельный текст без вложений. +- История использует keyset cursor: `(created_at, id)`, по возрастанию; `limit` — целое 1–100, по умолчанию 50. Ответ `{"items":[...],"next_cursor":null}`. Непустой cursor — непрозрачная для клиента строка; последний элемент предыдущей страницы не повторяется. Cursor привязан к ресурсу/запросу: от другого чата — 400. Изменять `limit` при продолжении можно. +- `next_cursor` равен null, если на момент чтения следующей записи нет. Гарантируется отсутствие повторов при обходе неизменной истории; snapshot изоляция между несколькими HTTP-запросами не требуется. При конкурентных вставках опишите выбранную семантику и ограничения часов. +- Подтверждённое сообщение видно следующему чтению через любую API-реплику. Допускается кэш, но он не должен возвращать устаревшую историю после успешной записи. Удаление и изменение участников в обязательный API не входят. + +## Идентификация и сессии + +**Lab1:** защищённые endpoint требуют `X-User-Id: `; отсутствие/неизвестный ID — 401. Это учебный выбор пользователя: любой клиент может выдать себя за другого. + +**Lab2–7:** `POST /auth/sessions` принимает только `Authorization: Basic `, без JSON body. Username ограничен ASCII; пароль кодируется UTF-8, 12–128 code points, двоеточие в пароле допустимо. Неверные credentials — одинаковый 401 и `WWW-Authenticate: Basic realm="messenger", charset="UTF-8"`. + +Вход возвращает `201 {"token":"...","token_type":"Bearer","expires_at":"...Z","user":{...}}`. Токен — непрозрачное случайное значение, не username/UUID и не обязательный JWT; энтропия не менее 256 бит. Храните серверную сессию с expiry; TTL задаётся `SESSION_TTL_SECONDS`, по умолчанию 3600 (в тесте истечения можно 2). В обычном smoke TTL должен быть не менее 300. + +Все защищённые endpoint принимают только `Authorization: Bearer `. Истёкший/отозванный/неизвестный токен — 401 с `WWW-Authenticate: Bearer realm="messenger"`. Basic на бизнес-endpoint — 401. `X-User-Id` с lab2 игнорируется: он не меняет владельца сессии и сам по себе не даёт доступ. `DELETE /auth/sessions/current` отзывает текущий токен и возвращает 204 без тела; повтор с отозванным токеном — 401. + +Пользователи, чаты, сообщения и ещё действительные сессии переживают перезапуск приложения и используемых хранилищ с сохранёнными volumes. TTL не продлевается из-за рестарта. Пароли уже с lab2 хешируются библиотечным password KDF (Argon2id, scrypt или bcrypt с учётом ограничений библиотеки), не SHA-256 и не plaintext. Если bcrypt не поддерживает весь диапазон UTF-8 паролей без усечения, выберите другую библиотечную схему. В lab7 обязателен Argon2id. + +Basic — это кодирование, не шифрование. HTTP lab1–6 допускается только для локального изолированного стенда с синтетическими данными; для удалённой демонстрации нужен TLS. Lab7 использует HTTPS обязательно. См. [RFC 7617](https://www.rfc-editor.org/info/rfc7617/). + +## Балансировка и повторная отправка (с lab3) + +- Каждый ответ, созданный API, содержит `X-Instance-Id`; значение стабильно для жизни процесса. Ошибки соединения, созданные самим LB, могут не иметь этого заголовка. В lab7 используйте случайный alias реплики без раскрытия hostname/IP. +- Для POST сообщения обязателен `Idempotency-Key` (ASCII `[A-Za-z0-9._:-]{1,128}`). Нет/неверный ключ — 400. Область уникальности: текущий пользователь + HTTP-метод + путь чата + ключ. +- При повторе того же валидного JSON в течение 24 часов сервер возвращает тот же код 201 и то же Message. Порядок ключей JSON и отсутствующий `attachment_ids` вместо `[]` семантически равны. Другой body при том же ключе — 409. Разные пользователи/чаты могут использовать одинаковый ключ независимо. +- Одновременные повторы через разные реплики создают ровно одно сообщение. Повтор снова проверяет действительность сессии и доступ. Транзиентные 5xx не фиксируются как окончательный результат; побочный эффект и запись результата должны быть атомарны. +- Ключ обязателен только для сообщений. Самостоятельный retry загрузки файла или создания чата может создать ещё один объект; не добавляйте прозрачные повторы этих POST без расширения контракта. + +## Вложения (с lab4) + +- `POST /chats/{chat_id}/attachments?filename=photo.png`: сырые bytes одного PNG/JPEG; `Content-Type: image/png` или `image/jpeg`. Не multipart. Максимум **10 MiB = 10 485 760 байт**. MIME проверяется по содержимому; заведомо повреждённые/чужие форматы — 415, превышение bytes — 413. Не более 40 миллионов декодированных пикселей (превышение — 413). Устанавливайте лимит до полной распаковки изображения. +- `filename` — отображаемое имя (1–255 code points). Не используйте его как путь/ключ S3. Имя с `/`, `\`, NUL или управляющими символами — 400. Bucket приватен; object key генерирует сервер. Оригинал возвращается побайтно, его lowercase SHA-256 и размер соответствуют исходному файлу. +- Lab4 возвращает `201 Attachment` со `status=ready`, `variants=[]`, `error_code=null` только после сохранения оригинала и метаданных. Lab5–7 возвращают `202 Attachment` со снимком `status=queued`; готовность определяется последующим GET метаданных. Быстрый worker может закончить до первого GET — это нормально. +- Файл заранее привязан к чату. В POST сообщения добавляется `attachment_ids` (0–10 уникальных ID, по умолчанию []). Можно отправить `text=""` с хотя бы одним вложением. Все вложения должны принадлежать этому чату; чужое/неизвестное — 404. Любой участник может ссылаться на доступное вложение чата. Failed-вложение прикреплять нельзя (409); queued/processing с lab5 можно. +- `GET /attachments/{id}` и `/content` доступны только участникам чата. Download возвращает байты с истинным Content-Type, через авторизованный API, **без redirect** в обязательном контракте. Приватный bucket сам по себе не заменяет эту проверку. `variant` по умолчанию original. Для queued/processing content — 409; для failed — 409. Отсутствующий вариант ready-объекта — 404. +- В ready-метаданных `error_code=null`. В failed — краткий машинный код (например `invalid_image`, `processing_failed`, `retry_exhausted`), без stack trace. `size_bytes`, `sha256`, `content_type` всегда описывают оригинал; у вариантов отдельные поля. Публичный API не раскрывает ключи бакета, внутренние адреса и credentials. +- Прямой upload/download по временной подписи — факультативное расширение. Его дополняют ограничение срока/размера, уникальный ключ и подтверждение реально загруженного объекта; он не заменяет стандартный endpoint. Особенности подписанных URL: [S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html). + +## Фоновая обработка (с lab5) + +До 202 все байты приняты в **устойчивый staging**, а намерение обработать файл надёжно сохранено. HTTP-передача клиента не исчезает и не становится мгновенной. Staging — приватный S3-prefix либо общий persistent volume, доступный worker; локальная память/API ephemeral disk не подходит. Worker асинхронно переносит/публикует оригинал в конечный S3-prefix и создаёт варианты; если staging уже в S3, опишите эту границу честно. + +Обязательный thumbnail: JPEG, изображение вписано в 256×256 с сохранением пропорций, без увеличения маленьких исходников, EXIF orientation применяется; alpha компонуется на белый. Размеры округляются вниз, минимум 1 px. Fixture 320×200 должен дать 256×160. Статус ready ставится после сохранения оригинала и thumbnail. Crop на 4: JPEG, центрированный квадрат 128×128 (для маленьких исходников допускается увеличение). Optimized на 4: WebP, вписан в 1280×1280 без увеличения, параметры качества и удаления EXIF документируются; не обещайте уменьшение каждого маленького файла. + +Допустимые состояния и события описаны в `jobs.schema.json` и `attachment-states.md`. Доставка — at least once; обработка идемпотентна, повторы не размножают варианты. Финальные ошибки наблюдаемы, бесконечный немой retry недопустим. Обязательный smoke ждёт ready до 60 секунд; это бюджет локального smoke для одного файла при свободном worker, не универсальный production SLA. В очередь идут ссылки/ID, не бинарное содержимое. diff --git a/contracts/examples.json b/contracts/examples.json new file mode 100644 index 0000000..060abd0 --- /dev/null +++ b/contracts/examples.json @@ -0,0 +1,60 @@ +{ + "CreateUser": { + "username": "alice", + "display_name": "Алиса", + "password": "Example-only-not-a-real-secret-123" + }, + "User": { + "id": "11111111-1111-4111-8111-111111111111", + "username": "alice", + "display_name": "Алиса", + "created_at": "2026-09-01T12:00:00Z" + }, + "CreateChat": { + "title": "Проект по ВНП", + "member_ids": [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222" + ] + }, + "Chat": { + "title": "Проект по ВНП", + "member_ids": [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222" + ], + "id": "33333333-3333-4333-8333-333333333333", + "created_at": "2026-09-01T12:00:00Z" + }, + "CreateMessage": { + "text": "Привет, Боб!" + }, + "Message": { + "id": "44444444-4444-4444-8444-444444444444", + "chat_id": "33333333-3333-4333-8333-333333333333", + "sender_id": "11111111-1111-4111-8111-111111111111", + "text": "Привет, Боб!", + "created_at": "2026-09-01T12:00:00Z", + "attachment_ids": [] + }, + "MessagePage": { + "items": [ + { + "id": "44444444-4444-4444-8444-444444444444", + "chat_id": "33333333-3333-4333-8333-333333333333", + "sender_id": "11111111-1111-4111-8111-111111111111", + "text": "Привет, Боб!", + "created_at": "2026-09-01T12:00:00Z", + "attachment_ids": [] + } + ], + "next_cursor": null + }, + "Error": { + "error": { + "code": "not_found", + "message": "Объект не найден", + "request_id": "example-request-id" + } + } +} diff --git a/contracts/infrastructure.json b/contracts/infrastructure.json new file mode 100644 index 0000000..dd2edf2 --- /dev/null +++ b/contracts/infrastructure.json @@ -0,0 +1,46 @@ +{ + "lab": 4, + "kind": "requirements, not runnable deployment", + "required_for_grade_3": [ + { + "role": "api", + "count_min": 2, + "student_implements": true, + "persistent_local_state": false + }, + { + "role": "postgres", + "purpose": "users/chats/membership/messages/metadata", + "persistent_volume": true + }, + { + "role": "load_balancer", + "public_entrypoint": true, + "backend_readiness": true + }, + { + "role": "s3", + "choose_one": [ + "MinIO", + "SeaweedFS", + "RustFS" + ], + "private_bucket": true, + "persistent_volume": true + } + ], + "optional_components": [ + { + "role": "redis_or_valkey", + "required_for_lab2_grade4": true, + "note": "Choose one; if only store of sessions, configure durable persistence." + } + ], + "rules": [ + "Choose Compose or Swarm for lab3+; do not need both.", + "Never publish internal API replica ports in bypass of LB from lab3.", + "Versions/digests must be fixed in implementation.", + "Do not remove volumes in restart/failover checks.", + "Document service names, healthchecks, bootstrap/migrations and credentials." + ] +} diff --git a/contracts/openapi.json b/contracts/openapi.json new file mode 100644 index 0000000..138f07c --- /dev/null +++ b/contracts/openapi.json @@ -0,0 +1,3506 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "MTUSI Messenger — лабораторная 4", + "version": "1.4.0", + "description": "Нормативный минимум на 3. Семантика: contracts/README.md. Расширения на 4/5 не должны менять этот API. Наличие кода ошибки в схеме не требует реализовать факультативную причину его выдачи." + }, + "servers": [ + { + "url": "http://localhost:8080", + "description": "Локальный учебный стенд" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/health/live": { + "get": { + "operationId": "liveness", + "summary": "Процесс жив", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [] + } + }, + "/health/ready": { + "get": { + "operationId": "readiness", + "summary": "Готов принимать трафик", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [] + } + }, + "/api/v1/users": { + "post": { + "operationId": "registerUser", + "summary": "Создать пользователя", + "responses": { + "201": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUser" + } + } + } + } + } + }, + "/api/v1/users/me": { + "get": { + "operationId": "currentUser", + "summary": "Текущий пользователь", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/chats": { + "post": { + "operationId": "createChat", + "summary": "Создать чат; вызывающий должен входить в member_ids", + "responses": { + "201": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Chat" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateChat" + } + } + } + } + }, + "get": { + "operationId": "listChats", + "summary": "Чаты текущего пользователя", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatPage" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "minLength": 1 + } + } + ] + } + }, + "/api/v1/chats/{chat_id}": { + "get": { + "operationId": "getChat", + "summary": "Получить доступный чат", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Chat" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "chat_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ] + } + }, + "/api/v1/chats/{chat_id}/messages": { + "post": { + "operationId": "createMessage", + "summary": "Отправить сообщение", + "responses": { + "201": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "chat_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "description": "Обязателен с lab3. Область: пользователь + метод + путь + ключ; хранить результат не менее 24 ч. Повтор того же JSON возвращает исходный 201 и то же сообщение, другой JSON — 409." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMessage" + } + } + } + } + }, + "get": { + "operationId": "listMessages", + "summary": "Сообщения по (created_at, id) по возрастанию", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessagePage" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "chat_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "minLength": 1 + } + } + ] + } + }, + "/api/v1/auth/sessions": { + "post": { + "operationId": "createSession", + "summary": "Вход: Basic username:password; выдаёт сессию", + "responses": { + "201": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "basicAuth": [] + } + ] + } + }, + "/api/v1/auth/sessions/current": { + "delete": { + "operationId": "revokeSession", + "summary": "Отозвать текущую сессию", + "responses": { + "204": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/chats/{chat_id}/attachments": { + "post": { + "operationId": "uploadAttachment", + "summary": "Принять один PNG/JPEG, до 10 MiB", + "responses": { + "201": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Attachment" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Превышен лимит", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "Неподдерживаемый тип", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "chat_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "filename", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255 + } + } + ], + "requestBody": { + "required": true, + "description": "Сырые байты изображения, НЕ multipart и НЕ JSON/base64.", + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "/api/v1/attachments/{attachment_id}": { + "get": { + "operationId": "getAttachment", + "summary": "Метаданные вложения, доступ участникам чата", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Attachment" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "attachment_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ] + } + }, + "/api/v1/attachments/{attachment_id}/content": { + "get": { + "operationId": "downloadAttachment", + "summary": "Скачать байты через авторизованный API", + "responses": { + "200": { + "description": "Успех", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/webp": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Неверный запрос", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Нет действительной аутентификации", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "WWW-Authenticate": { + "description": "Basic realm=\"messenger\", charset=\"UTF-8\" для входа; Bearer realm=\"messenger\" для защищённого API lab2+.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Объект отсутствует или недоступен", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Конфликт", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Ограничение частоты запросов", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "Retry-After": { + "description": "Положительное число секунд.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Зависимость недоступна", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Неожиданная ошибка без внутренних деталей", + "headers": { + "X-Request-Id": { + "description": "Непустой идентификатор запроса; обязателен во всех ответах.", + "schema": { + "type": "string", + "minLength": 1 + } + }, + "X-Instance-Id": { + "description": "Идентификатор обслужившей API-реплики; стабилен в пределах жизни процесса. В lab7 используйте непрозрачный alias, без hostname/IP.", + "schema": { + "type": "string", + "minLength": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "attachment_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "variant", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "original" + ], + "default": "original" + } + } + ] + } + } + }, + "components": { + "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "opaque session token" + } + }, + "schemas": { + "Error": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "invalid_request", + "unauthorized", + "not_found", + "conflict", + "payload_too_large", + "unsupported_media_type", + "rate_limited", + "unavailable", + "internal_error" + ] + }, + "message": { + "type": "string", + "minLength": 1 + }, + "request_id": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "code", + "message", + "request_id" + ], + "additionalProperties": false + } + }, + "required": [ + "error" + ], + "additionalProperties": false, + "examples": [ + { + "error": { + "code": "not_found", + "message": "Объект не найден", + "request_id": "example-request-id" + } + } + ] + }, + "Health": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": false + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string", + "pattern": "^[a-z0-9_]{3,32}$" + }, + "display_name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "username", + "display_name", + "created_at" + ], + "additionalProperties": false, + "examples": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "username": "alice", + "display_name": "Алиса", + "created_at": "2026-09-01T12:00:00Z" + } + ] + }, + "CreateUser": { + "type": "object", + "properties": { + "username": { + "type": "string", + "pattern": "^[a-z0-9_]{3,32}$" + }, + "display_name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "password": { + "type": "string", + "minLength": 12, + "maxLength": 128, + "writeOnly": true + } + }, + "required": [ + "username", + "display_name", + "password" + ], + "additionalProperties": false, + "examples": [ + { + "username": "alice", + "display_name": "Алиса", + "password": "Example-only-not-a-real-secret-123" + } + ] + }, + "Chat": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "member_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "minItems": 1, + "maxItems": 100, + "uniqueItems": true + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "title", + "member_ids", + "created_at" + ], + "additionalProperties": false, + "examples": [ + { + "title": "Проект по ВНП", + "member_ids": [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222" + ], + "id": "33333333-3333-4333-8333-333333333333", + "created_at": "2026-09-01T12:00:00Z" + } + ] + }, + "CreateChat": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "member_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "minItems": 1, + "maxItems": 100, + "uniqueItems": true + } + }, + "required": [ + "title", + "member_ids" + ], + "additionalProperties": false, + "examples": [ + { + "title": "Проект по ВНП", + "member_ids": [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222" + ] + } + ] + }, + "Message": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "chat_id": { + "type": "string", + "format": "uuid" + }, + "sender_id": { + "type": "string", + "format": "uuid" + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "attachment_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 10, + "uniqueItems": true + } + }, + "required": [ + "id", + "chat_id", + "sender_id", + "text", + "created_at", + "attachment_ids" + ], + "additionalProperties": false, + "anyOf": [ + { + "properties": { + "text": { + "minLength": 1 + } + } + }, + { + "properties": { + "attachment_ids": { + "minItems": 1 + } + } + } + ], + "examples": [ + { + "id": "44444444-4444-4444-8444-444444444444", + "chat_id": "33333333-3333-4333-8333-333333333333", + "sender_id": "11111111-1111-4111-8111-111111111111", + "text": "Привет, Боб!", + "created_at": "2026-09-01T12:00:00Z", + "attachment_ids": [] + } + ] + }, + "CreateMessage": { + "type": "object", + "properties": { + "text": { + "type": "string", + "maxLength": 4000 + }, + "attachment_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 10, + "uniqueItems": true, + "default": [] + } + }, + "required": [ + "text" + ], + "additionalProperties": false, + "anyOf": [ + { + "properties": { + "text": { + "minLength": 1 + } + } + }, + { + "properties": { + "attachment_ids": { + "minItems": 1 + } + }, + "required": [ + "attachment_ids" + ] + } + ], + "examples": [ + { + "text": "Привет, Боб!" + } + ] + }, + "ChatPage": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Chat" + } + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items", + "next_cursor" + ], + "additionalProperties": false + }, + "MessagePage": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message" + } + }, + "next_cursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items", + "next_cursor" + ], + "additionalProperties": false, + "examples": [ + { + "items": [ + { + "id": "44444444-4444-4444-8444-444444444444", + "chat_id": "33333333-3333-4333-8333-333333333333", + "sender_id": "11111111-1111-4111-8111-111111111111", + "text": "Привет, Боб!", + "created_at": "2026-09-01T12:00:00Z", + "attachment_ids": [] + } + ], + "next_cursor": null + } + ] + }, + "Session": { + "type": "object", + "properties": { + "token": { + "type": "string", + "minLength": 32 + }, + "token_type": { + "type": "string", + "const": "Bearer" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "user": { + "$ref": "#/components/schemas/User" + } + }, + "required": [ + "token", + "token_type", + "expires_at", + "user" + ], + "additionalProperties": false + }, + "Variant": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "thumbnail", + "crop", + "optimized" + ] + }, + "content_type": { + "type": "string", + "enum": [ + "image/jpeg", + "image/png", + "image/webp" + ] + }, + "size_bytes": { + "type": "integer", + "minimum": 1 + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "name", + "content_type", + "size_bytes", + "width", + "height", + "sha256" + ], + "additionalProperties": false + }, + "Attachment": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "chat_id": { + "type": "string", + "format": "uuid" + }, + "owner_id": { + "type": "string", + "format": "uuid" + }, + "filename": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "content_type": { + "type": "string", + "enum": [ + "image/png", + "image/jpeg" + ] + }, + "size_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 10485760 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "status": { + "type": "string", + "enum": [ + "ready" + ] + }, + "variants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Variant" + } + }, + "error_code": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "chat_id", + "owner_id", + "filename", + "content_type", + "size_bytes", + "sha256", + "status", + "variants", + "error_code", + "created_at", + "updated_at" + ], + "additionalProperties": false + } + } + } +} diff --git a/evidence/README.md b/evidence/README.md new file mode 100644 index 0000000..8df7001 --- /dev/null +++ b/evidence/README.md @@ -0,0 +1,5 @@ +# Доказательства + +Храните небольшие обезличенные результаты: команды и stdout проверок, JSON/CSV агрегатов нагрузки, EXPLAIN, dashboard JSON, текст разбора отказа. Укажите дату, commit, аппаратные лимиты и профиль нагрузки. + +Не добавляйте .env, session tokens, cookies, приватные ключи, credentials, реальные сообщения, raw dumps и signed URLs. Для скриншотов скрывайте секреты. Большие логи/бинарные traces сохраняйте отдельно по правилам преподавателя; одного скриншота без методики недостаточно. diff --git a/examples/requests.http b/examples/requests.http new file mode 100644 index 0000000..052497b --- /dev/null +++ b/examples/requests.http @@ -0,0 +1,53 @@ +# Optional HTTP-client worksheet; no extension is required by the course. +# Replace IDs/token locally from real responses; do not commit filled-in credentials. +# Canonical API: contracts/openapi.json and contracts/README.md. +@baseUrl = http://localhost:8080 +@aliceId = REPLACE_WITH_CREATED_ALICE_UUID +@bobId = REPLACE_WITH_CREATED_BOB_UUID +@chatId = REPLACE_WITH_CREATED_CHAT_UUID +@sessionToken = REPLACE_LOCALLY_DO_NOT_COMMIT + +### Liveness +GET {{baseUrl}}/health/live + +### Register Alice (repeat with username bob/display_name Боб) +POST {{baseUrl}}/api/v1/users +Content-Type: application/json + +{ + "username": "alice", + "display_name": "Алиса", + "password": "Example-only-not-a-real-secret-123" +} + +### Create session: generate the Base64 value locally, do not commit credentials +# Base64 of UTF-8 username:password; only this endpoint accepts Basic. +POST {{baseUrl}}/api/v1/auth/sessions +Authorization: Basic REPLACE_WITH_LOCAL_BASE64_CREDENTIAL + + +### Create chat; both UUIDs must be real registered users +POST {{baseUrl}}/api/v1/chats +Authorization: Bearer {{sessionToken}} +Content-Type: application/json + +{"title":"Проект по ВНП","member_ids":["{{aliceId}}","{{bobId}}"]} + +### Send message +POST {{baseUrl}}/api/v1/chats/{{chatId}}/messages +Authorization: Bearer {{sessionToken}} +Idempotency-Key: example-message-001 +Content-Type: application/json + +{"text":"Привет, Боб!"} + +### Read first page +GET {{baseUrl}}/api/v1/chats/{{chatId}}/messages?limit=50 +Authorization: Bearer {{sessionToken}} + +### Binary upload; HTTP file-client syntax for reading local fixture +POST {{baseUrl}}/api/v1/chats/{{chatId}}/attachments?filename=sample.png +Authorization: Bearer {{sessionToken}} +Content-Type: image/png + +< ../tests/fixtures/sample.png diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..5a39e86 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,7 @@ +# Инфраструктура студента + +Здесь разместите конфигурации выбранных сервисов; основную топологию — в корневом compose.yaml/stack.yaml. `compose.example.yaml` — только worksheet ролей и намеренно не запускается. `contracts/infrastructure.json` задаёт минимальные роли, не готовые образы/пароли. + +В REPORT.md сопоставьте роль → service name → версия/digest → health/readiness → volume → сеть/порт → способ передачи credentials. Приведите bootstrap (schema, bucket, broker topology, а с lab6 provisioning) и безопасное повторение этих команд. + +Приложение строится из исходников. Не используйте заранее существующий образ преподавательского сервера вместо своей реализации. Restart hook перезапускает все соответствующие stores с volumes; failover hook останавливает только заданную API-реплику. diff --git a/lab.json b/lab.json new file mode 100644 index 0000000..e9ffc9f --- /dev/null +++ b/lab.json @@ -0,0 +1,8 @@ +{ + "number": 4, + "title": "S3-хранилище и вложения", + "contract_version": "1.4.0", + "default_base_url": "http://localhost:8080", + "grading": "3; 4 includes 3; 5 includes 4 plus one completed research track", + "scaffold_has_application": false +} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..6b103dd --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +# Only for full OpenAPI/JSON Schema validation; HTTP tests use stdlib. +openapi-spec-validator==0.7.2 diff --git a/scripts/check_contract.py b/scripts/check_contract.py new file mode 100644 index 0000000..dcb34df --- /dev/null +++ b/scripts/check_contract.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Offline structural checks; full schema validation is a separate target.""" +import ast +import json +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[1] + +def main(): + lab = json.loads((ROOT/'lab.json').read_text())['number'] + spec = json.loads((ROOT/'contracts/openapi.json').read_text()) + assert spec['openapi']=='3.1.0' + assert spec['info']['version']=='1.%d.0'%lab + operation_ids = set() + def walk(value): + if isinstance(value,dict): + if '$ref' in value: + pointer = value['$ref'] + assert pointer.startswith('#/'), 'unexpected external reference' + target = spec + for part in pointer[2:].split('/'): + target = target[part.replace('~1','/').replace('~0','~')] + for v in value.values(): + walk(v) + elif isinstance(value,list): + for v in value: + walk(v) + walk(spec) + for route,methods in spec['paths'].items(): + placeholders = set(re.findall(r'\{([^}]+)\}',route)) + for method,op in methods.items(): + assert op['operationId'] not in operation_ids, 'duplicate operationId' + operation_ids.add(op['operationId']) + params = {p['name'] for p in op.get('parameters',[]) if p['in']=='path' and p.get('required')} + assert placeholders==params, 'path parameter mismatch: '+route + for response in op['responses'].values(): + assert 'X-Request-Id' in response['headers'] + if lab>=3: + assert 'X-Instance-Id' in response['headers'] + assert ('/api/v1/auth/sessions' in spec['paths']) == (lab>=2) + assert ('/api/v1/attachments/{attachment_id}' in spec['paths']) == (lab>=4) + for file in list((ROOT/'tests').glob('*.py'))+list((ROOT/'scripts').glob('*.py')): + ast.parse(file.read_text(),filename=str(file)) + for file in ROOT.rglob('*.json'): + if any(part in {'.git','.venv','node_modules','vendor','output'} for part in file.parts): + continue + json.loads(file.read_text()) + for required in ('README.md','COURSE.md','CONTRIBUTING.md','REPORT.md','contracts/README.md','tests/smoke.py','tests/README.md'): + assert (ROOT/required).is_file(), 'missing '+required + print('PASS: offline skeleton structure, local refs and Python syntax; application NOT tested.') + +if __name__=='__main__': + main() diff --git a/scripts/restart.example.sh b/scripts/restart.example.sh new file mode 100755 index 0000000..967e951 --- /dev/null +++ b/scripts/restart.example.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu +# TODO: copy to restart.sh and implement using YOUR compose/stack services. +# Restart API and ALL used state stores (DB, sessions, S3, broker as applicable). +# Preserve persistent volumes and schema/data; never down -v / flush / re-seed. +# Allow operator unseal if required in lab7; do not print credentials. +# Exit 0 only after the intended action; test waits for API readiness separately. +printf '%s +' 'TODO: implement restart.sh for your infrastructure' >&2 +exit 2 diff --git a/scripts/stop-one.example.sh b/scripts/stop-one.example.sh new file mode 100755 index 0000000..ae149e8 --- /dev/null +++ b/scripts/stop-one.example.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu +# TODO: copy to stop-one.sh, map TARGET_INSTANCE_ID to ONE API container/task. +# Stop that instance and leave it stopped; no stop of DB/LB/volumes. +# Return quickly (within 10 s); the test sends requests during this script. +# Restore the replica explicitly after the test. Never print credentials. +: "${TARGET_INSTANCE_ID:?test provides target API instance alias}" +printf '%s +' 'TODO: implement stop-one.sh for your infrastructure' >&2 +exit 2 diff --git a/scripts/validate_openapi.py b/scripts/validate_openapi.py new file mode 100644 index 0000000..1dfe4a9 --- /dev/null +++ b/scripts/validate_openapi.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Full OpenAPI and JSON Schema checks; install requirements-dev.txt first.""" +import json +from pathlib import Path +from openapi_spec_validator import validate +from jsonschema import Draft202012Validator + +root = Path(__file__).resolve().parents[1] +spec = json.loads((root/'contracts/openapi.json').read_text()) +validate(spec) +from referencing import Registry, Resource +# The base URI resolves local #/components references inside examples. +resource = dict(spec, **{'$schema': 'https://json-schema.org/draft/2020-12/schema'}) +registry = Registry().with_resource('urn:mtusi:openapi', Resource.from_contents(resource)) +for name, example in json.loads((root/'contracts/examples.json').read_text()).items(): + schema = {'$ref': 'urn:mtusi:openapi#/components/schemas/' + name} + Draft202012Validator(schema, registry=registry).validate(example) +for path in (root/'contracts').glob('*.schema.json'): + schema = json.loads(path.read_text()) + Draft202012Validator.check_schema(schema) + for example in schema.get('examples',[]): + Draft202012Validator(schema).validate(example) +print('PASS: full OpenAPI 3.1 and JSON Schema validation; application NOT tested.') diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..0dfa3f8 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,57 @@ +# Публичные проверки + +Python **3.9+**, стандартная библиотека. Сервер уже должен быть запущен; проверки не создают готовое приложение и сами не управляют Docker. `make check` можно выполнить на исходном skeleton, `make test` — только после реализации API. Полная OpenAPI-валидация отдельно: `make validate` после установки `requirements-dev.txt` в venv. + +```sh +make check +make test BASE_URL=http://localhost:8080 +# без make: +python3 tests/smoke.py --base-url http://localhost:8080 +``` + +`smoke.py` выполняет **один сквозной сценарий**, автоматически выбирая профиль из `lab.json`: health → три пользователя → вход с lab2 → чат → сообщения → пагинация → ошибки доступа → идемпотентность с lab3 → файл с lab4 → thumbnail с lab5 → logout. Он проверяет JSON по используемому подмножеству схемы, коды/headers, Unicode, SHA-256 оригинала и фактические JPEG-размеры thumbnail. Это публичный минимум, не полный fuzz/security/load suite. + +Каждый запуск создаёт уникальных синтетических пользователей, чат и сообщения. Данные автоматически не удаляются: в API курса нет delete. Для чистого повтора используйте отдельный тестовый стенд/volume и осознанный сброс своего окружения. Session tokens хранятся только в памяти процесса и не печатаются/не сохраняются. + +## Проверка сохранности, lab2+ + +Скопируйте `scripts/restart.example.sh` в `scripts/restart.sh`, реализуйте перезапуск **API и всех используемых хранилищ** с сохранением volumes и задайте executable bit. Скрипт может опираться на ваш compose.yaml или stack.yaml. Он не должен удалять volumes, повторно seed-ить БД или менять тестовые данные. + +```sh +chmod +x scripts/restart.sh +make test-persistence ACTION_SCRIPT=scripts/restart.sh +``` + +Тест создаёт данные и сессию, запускает указанный файл без shell interpolation, ожидает готовности и проверяет те же данные **с прежним токеном**. В lab4+ также проверяется исходный файл. Hook может работать до 90 секунд, готовность затем ожидается до 90 секунд. TTL сессии для этого теста — минимум 300 секунд. Успех не доказывает, что hook действительно перезапустил БД: приложите команды/состояния контейнеров до и после. + +## Проверка отказа, lab3+ + +Реализуйте `scripts/stop-one.sh` на основе примера. `TARGET_INSTANCE_ID` в окружении содержит alias обслужившей тест API-реплики: сопоставьте его своему контейнеру/Swarm task и остановите именно его. Скрипт должен быстро вернуть 0 и **оставить эту реплику остановленной**; восстановление выполняйте отдельно. Swarm может создать новую task с другим ID — это допустимо. + +```sh +chmod +x scripts/stop-one.sh +make test-failover ACTION_SCRIPT=scripts/stop-one.sh +``` + +Перед отказом тест наблюдает ≥2 API-реплики; 15 секунд отправляет сообщения параллельно выполнению hook, повторяет неопределённый POST с тем же ключом, проверяет восстановление записи за ≤10 секунд, отсутствие остановленной реплики в последних ответах, сохранность подтверждённых сообщений и отсутствие дублей. Ошибки переходного периода учитываются. Затем восстановите реплику и повторите для другой. Скрипт — проверка одного отказа API, не гарантия HA БД/LB/host. Вывод hook скрыт, чтобы не утекли секреты; отлаживайте его отдельно с безопасным выводом. + +## Нагрузка + +```sh +python3 tests/load.py --duration 30 --concurrency 4 --output evidence/load.json +``` + +Это простой **closed-loop** генератор POST сообщений; он ограничен производительностью клиента и страдает coordinated omission. Он выдаёт successful RPS, ошибки и p50/p95/p99 **только успешных запросов**. Во время нагрузки нет прозрачных retry; timeout мог скрыть совершённую запись, поэтому это throughput HTTP-подтверждений, не точный счётчик COMMIT. Изменение размера истории входит в профиль. Для серьёзного исследования используйте k6/Locust/wrk либо свой обоснованный генератор, а не выводите максимальную пропускную способность из одного запуска этого скрипта. Для нагрузки pipeline изображений нужен отдельный сценарий студента. + +## HTTPS, lab7 + +```sh +make test BASE_URL=https://localhost:8443 CA_FILE=/absolute/path/to/ca.crt +make test-security BASE_URL=https://localhost:8443 CA_FILE=/absolute/path/to/ca.crt +``` + +Все скрипты принимают `--ca-file`/`CA_FILE` и проверяют серверный сертификат и hostname. `--insecure` отсутствует. TLS-тест сначала проверяет рабочее доверенное соединение, затем намеренно пустой trust store и именно ошибку проверки сертификата; network timeout не засчитывается как правильный отказ. **Внутреннее mTLS, identity/authorization, OpenBao policy и hardening проверяют отдельные тесты студента** по trust matrix. Клиентский сертификат внешнему учебному REST-клиенту не требуется: mTLS находится на внутренних связях. + +## Что ещё остаётся доказать + +Smoke не доказывает persistence, число реальных контейнеров, durability брокера, outbox, отсутствие утечек/уязвимостей, полноту telemetry или выполнение уровня 4/5. Конкурентные/нагрузочные/негативные проверки своего решения добавляйте отдельно. Список защиты текущей лабы находится в README.md. Проверки рассчитаны на localhost-стенд, не на production. diff --git a/tests/client.py b/tests/client.py new file mode 100644 index 0000000..f0499a2 --- /dev/null +++ b/tests/client.py @@ -0,0 +1,217 @@ +"""HTTP helpers for public black-box tests; Python 3.9+, standard library only.""" +import base64 +import datetime as dt +import hashlib +import json +import os +from pathlib import Path +import re +import ssl +import time +import urllib.error +import urllib.request +import uuid + +ROOT = Path(__file__).resolve().parents[1] +LAB = json.loads((ROOT / 'lab.json').read_text())['number'] +SPEC = json.loads((ROOT / 'contracts/openapi.json').read_text()) + +class Failure(AssertionError): + pass + +def check(condition, message): + if not condition: + raise Failure(message) + +def timestamp(value): + check(isinstance(value, str) and value.endswith('Z'), 'timestamp must be UTC ending in Z') + try: + return dt.datetime.fromisoformat(value[:-1] + '+00:00') + except ValueError as exc: + raise Failure('invalid RFC3339 timestamp') from exc + +def validate(value, schema, where='response'): + """Validate the JSON Schema subset used by the supplied contract, not arbitrary OpenAPI.""" + if '$ref' in schema: + target = SPEC + for part in schema['$ref'].split('/')[1:]: + target = target[part] + return validate(value, target, where) + kinds = schema.get('type', []) + if isinstance(kinds, str): + kinds = [kinds] + matches = {'null':value is None, 'boolean':isinstance(value,bool), 'integer':isinstance(value,int) and not isinstance(value,bool), 'number':isinstance(value,(int,float)) and not isinstance(value,bool), 'string':isinstance(value,str), 'array':isinstance(value,list), 'object':isinstance(value,dict)} + if kinds: + check(any(matches.get(k,False) for k in kinds), where + ': incorrect type') + if 'enum' in schema: + check(value in schema['enum'], where + ': invalid enum') + if 'const' in schema: + check(value == schema['const'], where + ': invalid const') + if isinstance(value,dict): + props = schema.get('properties',{}) + check(all(k in value for k in schema.get('required',[])), where + ': missing required fields') + if schema.get('additionalProperties') is False: + check(not set(value).difference(props), where + ': unexpected fields') + for key, sub in props.items(): + if key in value: + validate(value[key], sub, where + '.' + key) + if isinstance(value,list): + check(len(value) >= schema.get('minItems',0), where + ': too few items') + check(len(value) <= schema.get('maxItems',float('inf')), where + ': too many items') + if schema.get('uniqueItems'): + encoded = [json.dumps(x,sort_keys=True) for x in value] + check(len(encoded)==len(set(encoded)), where + ': duplicate items') + if 'items' in schema: + for item in value: + validate(item, schema['items'], where + '[]') + if isinstance(value,str): + check(len(value) >= schema.get('minLength',0), where + ': too short') + check(len(value) <= schema.get('maxLength',float('inf')), where + ': too long') + if 'pattern' in schema: + check(re.search(schema['pattern'], value) is not None, where + ': pattern mismatch') + if schema.get('format') == 'uuid': + try: + uuid.UUID(value) + except ValueError as exc: + raise Failure(where + ': invalid UUID') from exc + if schema.get('format') == 'date-time': + timestamp(value) + if isinstance(value,(int,float)) and not isinstance(value,bool): + check(value >= schema.get('minimum',-float('inf')), where + ': below minimum') + check(value <= schema.get('maximum',float('inf')), where + ': above maximum') + if 'anyOf' in schema: + for alternative in schema['anyOf']: + try: + validate(value, alternative, where) + break + except Failure: + pass + else: + raise Failure(where + ': no anyOf branch matches') + +def shape(value, name): + validate(value, SPEC['components']['schemas'][name]) + return value + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + +class Client: + def __init__(self, base_url=None, ca_file=None, timeout=10): + self.base = (base_url or os.environ.get('BASE_URL') or ('https://localhost:8443' if LAB==7 else 'http://localhost:8080')).rstrip('/') + check(self.base.startswith(('http://','https://')), 'BASE_URL must be HTTP(S)') + check(LAB != 7 or self.base.startswith('https://'), 'lab7 requires HTTPS') + self.timeout = timeout + context = ssl.create_default_context(cafile=ca_file or os.environ.get('CA_FILE') or None) + self.opener = urllib.request.build_opener(NoRedirect(), urllib.request.HTTPSHandler(context=context)) + + def call(self, method, path, expected=200, body=None, actor=None, headers=None, raw=None, schema=None): + h = {'Accept':'application/json'} + if actor: + h['X-User-Id' if LAB==1 else 'Authorization'] = actor['id'] if LAB==1 else 'Bearer ' + actor['token'] + if body is not None: + raw = json.dumps(body,ensure_ascii=False).encode('utf-8') + h['Content-Type'] = 'application/json' + h.update(headers or {}) + req = urllib.request.Request(self.base + path, data=raw, headers=h, method=method) + start = time.monotonic() + try: + response = self.opener.open(req, timeout=self.timeout) + except urllib.error.HTTPError as error: + response = error + except (urllib.error.URLError,TimeoutError,OSError) as exc: + # Do not print URL query, headers, request body, or credentials. + raise Failure(method + ' ' + path.split('?')[0] + ': connection/TLS failure (' + type(exc).__name__ + ')') from None + with response: + status = response.code + rh = response.headers + data = response.read(16*1024*1024 + 1) + check(len(data) <= 16*1024*1024, 'response exceeds test safety limit') + allowed = [expected] if isinstance(expected,int) else expected + check(status in allowed, method + ' ' + path.split('?')[0] + ': expected ' + str(allowed) + ', received ' + str(status)) + check(bool(rh.get('X-Request-Id')), 'missing X-Request-Id') + if LAB>=3: + check(bool(rh.get('X-Instance-Id')), 'missing X-Instance-Id') + payload = None + if schema or status>=400: + check(rh.get_content_type()=='application/json', 'expected application/json') + try: + payload = json.loads(data) + except (ValueError,UnicodeError): + raise Failure('invalid response JSON') from None + shape(payload, 'Error' if status>=400 else schema) + if status>=400: + check(payload['error']['request_id']==rh.get('X-Request-Id'),'error request_id differs from header') + if status==401 and LAB>=2: + scheme = 'Basic' if path=='/api/v1/auth/sessions' else 'Bearer' + check(rh.get('WWW-Authenticate','').lower().startswith(scheme.lower()), 'incorrect WWW-Authenticate challenge') + if status==204: + check(not data, '204 must not contain body') + return {'status':status,'headers':rh,'json':payload,'bytes':data,'elapsed':time.monotonic()-start} + + def user(self, label): + username = 'u_' + label + '_' + uuid.uuid4().hex[:12] + password = 'T3st-' + uuid.uuid4().hex + body = {'username':username,'display_name':'Студент ' + label} + if LAB>=2: + body['password'] = password + user = self.call('POST','/api/v1/users',201,body=body,schema='User')['json'] + check(user['username']==username,'username changed') + actor = {'id':user['id'],'user':user,'registration':body} + if LAB>=2: + credential = base64.b64encode((username+':'+password).encode()).decode() + session = self.call('POST','/api/v1/auth/sessions',201,headers={'Authorization':'Basic '+credential},schema='Session')['json'] + check(session['user']==user,'session user mismatch') + check(timestamp(session['expires_at']) > dt.datetime.now(dt.timezone.utc),'session already expired') + actor['token'] = session['token'] + return actor + + def bootstrap(self): + self.call('GET','/health/live',schema='Health') + self.call('GET','/health/ready',schema='Health') + alice, bob, eve = (self.user(label) for label in ('alice','bob','eve')) + chat = self.call('POST','/api/v1/chats',201,actor=alice,body={'title':'Контрактный тест','member_ids':[alice['id'],bob['id']]},schema='Chat')['json'] + check(set(chat['member_ids'])=={alice['id'],bob['id']},'chat member mismatch') + return alice,bob,eve,chat + + def message(self, actor, chat, text, key=None, attachments=None): + body = {'text':text} + if attachments is not None: + body['attachment_ids'] = attachments + headers = {'Idempotency-Key':key or uuid.uuid4().hex} if LAB>=3 else {} + return self.call('POST','/api/v1/chats/'+chat['id']+'/messages',201,actor=actor,body=body,headers=headers,schema='Message') + + def attachment(self, actor, chat): + content = (ROOT / 'tests/fixtures/sample.png').read_bytes() + att = self.call('POST','/api/v1/chats/'+chat['id']+'/attachments?filename=sample.png',201 if LAB==4 else 202,actor=actor,raw=content,headers={'Content-Type':'image/png'},schema='Attachment')['json'] + check(att['status']==('ready' if LAB==4 else 'queued'),'incorrect upload status') + check(att['chat_id']==chat['id'] and att['owner_id']==actor['id'],'attachment ownership mismatch') + check(att['sha256']==hashlib.sha256(content).hexdigest() and att['size_bytes']==len(content),'original checksum/size mismatch') + return att,content + + def wait_ready(self, actor, att, wait_seconds=60): + deadline = time.monotonic()+wait_seconds + while time.monotonic()|3C0bDCg50hY&IhVa-}PAeB?jayAv^IER`~Iyjp-)LWvQLWRh=OeBYm zoGEFQvV&768SgUXUB9RA@1NcF=XKp~yIqgH?$_f%K51_)Dx@g1Wy==Pw!jr)^g?I<%@C1k??w5p=(q(7=5 z`O)3FZPp38c>W(UJfUrpsC1dH4|d6t6J5HOg!bOh-6L~LXiXxo=aMmpV`1f-lBN^-U^$ZC)OrursoPQL4nqDJjNOl@^j=pB-ybDDfPPX79G`*_N^9O*vMc%u6}|94&9p<2h~vcw#jcs+B+ z9RC-(0KXC<)T!ZAe6>F%@sZGTDL>tfqpVh;=h8E}8;4n#l!F#7o}7ks&}Vq+H1}nwTAdz zJ?%HQ6(uesSdI6XQ$vX(>|9kIG~FKTb)7_(v5z6FtAo*=Rl(KlV8kQK5JZ>@vZDME z){Ra?y{is3z*Q|)Z9`?t6}!Z^O;}mE6B7reT}$q*oF--rI=hxUScxH~4u+%JYw*rR z_g6ym7OL>>MU^Y@d2&@>)zB_+HwH^km&g=X-}0u`40E;<;jW>8|wQ>v%}NXE9kt7HAQ>0s*7r)G6t&&cLqP9&Q?*ar)${eh_xEh zp_B9^l!}v)^RN@QA;a1Qk5X|q@}eUVITUS|VOJvudM;ZB;ZQ?5N{`Po6?QSNSux8S zqsX~*R;_5}3Dec{3~J)7=4#lcc@)>qx)o9GgF#JH{=;~ijz>|q2QRvG>V9-?^rj0T zbm=HWjcZ3eo3HUGJq=~<#BJnR(koHs&fGTM5xOVhi%YGXl1r#d<8k^~?)?mX)RTuB zC*mKiDCNyO+^~aygr zUO(t_UcVt#(KYG8VYyfnc|}{%TMed@ubIJ-DV1d<0)7^|j)>k!{a`{EXvIQB+D#Ra|??I2ITFD2k7h?qZK|x)sGINL9fP zxrSLICMtDh^E*VX&tp$mL{A*Cuo1Nm3D)!s39ewti-yq~6|-NB+gn_kIBa1pIv&}v zc(1HE6|ML*L)xUIin}{Yo8oLz@{ntiRY@^6x$uhN!@14Y%L)p?TXWjj_^gGHjzfXs z#Iin$$zO%$b>m>OTFG3+h&QCP zzMX9Qtc&M5yf~Id$2q67oK5TzuT1RU7+UlmVEc-$oum5o@+B&&OfFwfy-m24nr4bG zXE|~UQr{}h2a~+Z8n;L34lhh)tJZ@6(TvM*&F z44!_#wdUsbm74x3=elzX`@Sh&eM7a_DC0V0T|KwqwNXK=?7M2Z^zwW{>|_Y3Z zcRb)Tu5(In-P@ zx`cxq`h@pdFvCF(eZu=K@Z%tdVtwy5SB^ps73VL0eN+KCWbLP%|7kOaI46|dKS2(C zp?dajQHC7C)x9=4)(<(9t%j?6Lxda(EXY=h-OQmyWOek-QOF@(=pDCE#+Bd-4w z_oIL;bICB@;$|K_lCW$3Dl~FF+0z8+?vGz5qG2Fog4) zSce>1AQabs+03D6^5}gW&GcW4k;_ehQ4fs915glKf*x{ec3p=(29c``m%9&0aFY)6!WKcRjwFv zC?@>PdiyBkQ1R8z8#W7&L&f`J|I|e1`wEBNJ!*PaVcbfYFLmpyYnq{av7%T3Zho?s5tTh+dr1x579uLBJBs*Wf8(Syr7E+Yt{Vc`d&j+7498z~O$d0Brv%=~ zWBU$y5o;4TA}z+_zA=X56I(=(1~`Sq(7Tj`I`;M?ZAJV5bATPZJge@e+IbgyOeMBml8p%3ly{5tp1C*ayL`NR zN05J80z$wo!&Sgdi8RwCNXSYM7mYv74CIm%97N;KGNZXE36BM)6g&(t@{C~r+=LOf z4WE}ts3|Xx_l~(w;JGQUnD>z>F3_s5;ka>nIVkD5{D$ks*~s?p8PO$)tMVJKn0^9( zW&g@gpIi1zYR~lamo|O4b0iu4xxmo2Kua&6uqvd-h$S>;c|f|8d?;Bdw<}7<(a0=fdh6J2~oSHGfg)J`pU@z&+L{0qx>362mZ5D@n1Paygra* zr$SgcIWajKpjCJqKW#Z8sg-R)yh)xfPa)sZ%EnBbXu(YM(Tud-WdzzGgUXA^Kg*NI z*;@W=(!_vekhG6&3_`mAKQVh;%R#oX0RMXSoR))JP{G%;P3;55^ix`6n7HhLXnLI1 z*uJ=g0TuRAnknHf zImAw6FR@rYl#ovTVmG}j^^W{m_EG8Hzx|S0)_0I+RohV=9Hpc81tr>}kJKlt->Va0;d#(K2@%^*y>;uw6cD06$PPKa*-_d?+ znM;J&#s{-Cq*W;nw(;lL#?qw}1KV2D#!=e;8oOynr3-CCW&Q3j2!xt}dfFSU=SJ&~ z2ENnQw5}Sqjm+k-wQ~;^ZqR4z+52+e+x-cTO5^p;mTLXoyWYy{pS92ZduRI0)Hbgw zNBv;g7{~s^fbJImo$rtM2xs%VlVm@iTGrpL-Xd{|F>*vtO1L`ZgZ^pt){`E3`b%mk z$`AdebBzhX!9)W}mL5jdPH!oUs7m>r@t@vO1a}KXFe6D%C6p^cNy~U3E8ff)X0v3^ zXE#LNOR3N^k<-^}X^Ct}aVvkEf|1Q`9Ct&8u&~14^jhSU^iSB4Ksgo|O_fta7v*QK9f%1$- zmo;c)yg#B!tWN2aMK?#bQG0swWb2y79sX^Ne4MgXwoZ~y-#aAWdde4?zp#a#cLYno zXtKvpc%P^IpM%*A*q7`*lzrqv{f`8$8>NCSPYKk|ZYCAVM{qAtE_xL0T52KX)7$m! zWpefGpTMQL7>%%FgpKv4HI^PnmZtpH$L|Ve9TpZpRiJbB6p7KJA*+JOIaLrAke$*` zmzJIPEC^y9=iW@I)W-(~bkgte%a%Uo710;y!m@rXynpB^gd6mH{Z0bcm3xOym-TDq zrIKE;r6@M~otht?P>0$3WoHoIPkDp~q@?5#@={*Qer#P%@CamC2&40Y5%N|)_Fa0Z;olkgK^a%Yc+MTVuBzlDjo{JWDd|Mk4?NBIcX;FCTZRqLd#$ZBp zMB#77wENef=&OZ-4E#yt=~e&2GzQ+qmPQ!zOhsF8K*jy6im(=` zM`10E#LTQGY!9XWx4}Ub&ldeFDnuDr-CEglo4l4;<^(K~#tcLmeH>Xj@=dCc8-hI_XKZzrce8l`pw+Z|AG4XcR z5FLT3Ya!-kT`7N_RTC!CK+KX4=6EV4g?eiXyq70%{-?Ary$qB3mO9b5{Fj(Kasy-i zw&VD8=&EdC>pP@=9|IW@T{QBZMk;K5-_bk2ome^29{MpXy1d|Ybkm3qrZVFkrt*D3 z07r#bpY=O*I)bzHu#)&Y(u+P@qoKZDj7lT7iZt98suUfHN0&4hz3Ia%Ib>9Z;=?!! zN~>X0dJXHfBMYJC2EGmJtr23BcqOYGVtyKDsH{7?t7FG%gt8H%d4Q*(g z=+javq~~LvFc{}ob-9m6B0{f(tnTMN8&M4nZ(gt94a|>Wt~RdM@J8k@6h41Ht@fv3 zy>jGv=pX$*jcu>ypJOh*ecUB>-bL^ZpU))GA8V-W(R1PCn*>f5q9o~mV)23rJCrTO zE<{UEXoo-hUlhFkLRm)4Chb!CV8G?HOSsToin!^a0FAUPE9taL`=|R^orHsFzxU(2 zSPAT1X@dLlG*(%8b#E^c6HTHf-O)fIKQZ?Rj#vo`>MJ9^-OUUBb~h_25ZQw0$6-eg z?-%@LB`Bz@+||u3&rKQ?dx~8|*+-JfdvlQ`diF%p!`@fO67*;!VCxV87^yY)#!NN;pqWaRy%8sxvy|0#Qa z^S_o9LOUe*1}8tu#2GQVB?RB>rydrGH5~5F5=;G?qk+th!49(Q*k{vPu^q2j!A5;7 zk2DVQqgLPkjt?wlG41sI${**J!_%bcyVG)&?TJ`d+i2{EZV|z?^h?Uq3(PBm;yZAp z@oo#Tvl*wvR4y#1Ct1@2#m??8;4q2YIEL9*N+|{*1CV=151pX)_YL-&)jkZH>bB)>~Ab} zsG9$RW5v^PZ=e?8-jq*3EtIEdq+H#y_hdAyeTU_udQ@W}#bYIt*cvciG2#ev?`# zx<1}>KrP?d=jHgkfm)tYr|7%nfLhS0RO-h~YM}(6!(%q7g%x#g$Po?H(%wOzdxQpR zLH9pJE&#QBoco6U9tqU)X$75su^p(Tt>|l1#t=};&?od+Dp1Q8wz=F|JL%9kJqfK6 z1=JG7ZJ=0t1GPjOy{9APfLgqVV~xf(sYTAAozzE<&o>qJHvh6>mJih8-MP4;nGe*G zZ_pl3-=vm&icja#CbbM{qVt~uwS0@ZJ#^6&-)c z-aswUZ6D^oqk&qy|9o08$OmfiUW!?-AMy#;Z%9*ooiq>Bf|pmZ6~EPRVDbl(6q&M6 zRw6h9)FOT(?E{{s&b|IW#s{Dl@s$3TKrJgCGn^6?pca+26~+so78>WH${H7_WsLJj zMSK~kN=^wyZe~tpe17FInX7&H-w{mwe`$#wzodYELn_r*g*PfLfSbs~m$kpq9A!w;Wv+pq4neo7fznmIG)mb#jwha)$7o zoK0#;n^SQk0ky<=d}Cb}2Wn9<|9AOD8c>Uh`H$t>X+SM1Q~&c=0BT_dRHWId0JR(l zHns3bq%P6#dXNJ8VA(EAy)QZJ+QQL zKH>6YmAQ%P(<)Bya=@>K|Zk9d*k<>_*>>c zEn~mT*Zu`+`L`{9?5H_Vi}~8mwwy7b7W1{WxNBoTE#~4s-hHzX68UG>-d=kesiFf%f7SCm196HrTM(C-+)>st^HK< z*MVACoD-_<>p(5Psqgx?r~G57*LCO z1UIf`45($S(Y${hsAVm#eQbX`P|L55p@k3TKrQClzl+#FE%VV&a}>;hT7FT-`u{Um zk@%N;HzzF~s3ji%l_RMN)Dl1Z)2M%wT2!Mi53d5X4EDb=FX0-^0JW5k{fZymq?S^H zU-1J#EiV>^%>B4PEh7tr()!;(EuW&vWB1L0T7G-auPT=UwZ!YKHTD3t4ECQ_{r(%M zh086=$%zMQ;c}~U#^QlmmK&$~ajHNqs!EqbSAbeR1yXVznFF=_-soFsH3w?>z0tjZ zDFteY|MPWKt`w*xK74JxeGI6j^y;sTp-pNj-GBMdm*{*yVdUMTqIdbnt%|oC-C}%A z^XryaKI~R+7Jrpom$*c_;UO9S)-8?4w%yx$LHs%ye@L=H1gK@(lsGl0OQ1ZU>)AGp z5Kzlb>cy_h+kjexsaL!BwgI&Wks`Y+wgI&W;Pw2QucZlAnW=Z(*qSW_)UsoIZ`Z_D zpq8!gwqvz`T0*aJwzUWYwd@!-+OiF(g?|xw*XCLsJNcTn0A2^E<>s|Y0lYp?OXjr? z+b)O$wdgY6ZXMeR)MCI~-)gV}s6~Fc{MrFNpccW7-TvRV0JUuWB|y~!YRTB*EV=p*tNr3fm--Pc1B*lrbHXx(z|V1ev?|%nJ=zYYy)Z$BiVF$@d34L z%NE6=fm-6b-t&Ew1Zpv6`fU9!2Gk;iHDlhq=1gz7_KI(K2T;qtuFG4O#Fwx6UlZiR z%K)_?n5->1+kskS{V!ZI+X~dOy+Fy|_Zm>k))KL0+iUAvLu9wdW0=uffm*i58!>IR z0<~=Q6XTue0&3YRCm4SKsAc4u%{H_sP|JR1)3xv0fm-DJP>a>I$6KfPfm*gR?qO1GV_Q_|HN`0;uKWizEvbDWDdU*#Nb|6g(cNMJ-!|=#oBPZk2vZEn5tz zMQlQgW~2txVu6$^_euX*Zjqj?CIQrb3uMa@qLs6}W! z>V^9epcdga?Ccjcpq5l`si<==fLhe@Q{uIOTAoP^S=8h&`zr+0qCGo${cq|tVrrXpm7_+mWQ?PBqGWfb|IYnKKJ2jH z?oN{YY_+VhT|HC6ilKi*@1Gs#l0RsiR?j(Usi(1|_Avd2#*zY1i#)MDJxd*^MSV$` zSeg#hqQ0ccy_OEtqOOAEUQY*Vkrbye^w=!P^V#W-O6K=F_PJ2 zpcW+-b;mb#pceI*%xgPYHYHkLk3WbCc9eFfdbT`L1<0?paIm9LApST zRtIXKkgm{#)qz@$cI+XHr31A{8ju$yCf1xpVSg3oe0=1a*H0;=Sbdy@rv3uE7=|C+SAEmfc=@s;X^gxYl3Q&tG_h?j7P*KrL1zY>$Q{P>WT8 za)5m@P>baJ(E>T37MJ8o4ZL)~71|ws$vMWB`o!)J_U z$UrRyeJk?11fUi|Z8rBxra0}6ylNkB4^T_4d}$v~52%GA9}-wg0cuHaH0paG57c7x zgvL+=YKa|=AY4%bYPmeDN(iR_wdl-`8C@jTYD_S-L{ zWp)lFUrI*>{Fi)53#dhFS~s8~Im;?H`I7duHc-p+lNMD@*SJYH>~+-T8Dk zP|GnAp(j@gsO326e9tQ>pq66{{eZP(pcbi;;^m@bpcbi;(&fZtpq8BmCm6;+EyNxh zsh{>hEqCrlBo0XdwH%8)lUO4K)FSO&?0+aRgx0nFjh6gPpcZWR_3dx8fm-Yg^}4fm zZc>X>wk;L~)H0vky1T;^s6~s#NajcZwP<%7U@7keYLWh!x4e`rO~03%D{X%qsKo|* zpgVIrP>b|*KJ(9Z@f}(u!)}Y6KrK5}?3a%v0=4Wst6h)_)MA;~wsT$+s3nj2Y5Umj zg3^YwhkJsg&GUg;j`YY$ciseQxw$--T)!Krt!-fi!|DXXWs+V zvh$X<#|@yCJpb6lHEE!h-8{o?>zzO?yLl$vK|6t3c6${1`y~D#SR`ic#ApJw*fwZ( zPbCAjNMFfaexD4~BK_xjq*o76%g(FzJewY%mYuEI8wY?|ta}bh|H%Vtap)=D`PYuB zTg`9pSn*)oX|dd1NF(KTkJN>Xr#eM4b<0kZHmu4WmIdl38oi*^qVrOwz4C=+N%^r; z4}e;9zEn;d1GN~=`RXX$2Wq)Dy?@SJ2dL%#^uf8UIzTP=@z^;v9iSFxm0L}x278?+ zkvG^cWUTK4wcHE7&0dswWcfuV%n6wb)PkWCb>7_vYPq*quuTW3#VN*qLKCRv$e^@y z$xWb^6NApqCAWZDjt++Fv{wSPIAe}JO@ANoV^Fs zQpoONWG5~6+mT{;gY0)=snhHCa z7p}D31Zr{WELgdJQwQKtqkIsvuVG~($Q zGP?AKGBwT}r9drO^fVpw6F@Dy>6JR>Hb5=v^mj5}oPb)KLTwv$fLhLE=<7Ur0Mt^v zQh0Ob0Z>cnO4`l$PCzXu)}sggoPb(v+78Tp*8ysA`g41w{w7e1(~|9a#GubP{m4{B z)1+HKEqmn^hDB~Q7*CEcPe-QYm-z}DwAj@n;+XnjFHL>m`dP*S3#}CqtG>NJErT8h zIVB1}EedOcj6FatyEz{f)&_uD3^;!j#9sil?BR4Phz|g@+~GE4j_g$_DVt9hu|A7M z0kx=G42f6=0ks4L=dub!fLau?hsM!BEov71A}@eiZk9!-q7{Hz_LdZI?_~nD>@B&& zZOH^`*?VD#aR8{LHZv#)uMO1Fk+~4mu?wgrppUZm=P*!95%-_WXU0G+XBP`uO(H-o zd%bV79*X>D`B%g}Nd7udi(&6$1%Wd_Eu)6Jdw(bZwHS*GMFwuRTGBH$jI#%UTFf~5 z3K1hfEdy8#hnfl0q7X4gvW8a6Xy(N;9s7Y=bU3q_&U=Ac_S%mbY5=u-6j?h%JqXmI zpfbFC5?U>8scFXee}Gy%Qr{}fp9X5V5fGjlrvTJ)hJ@=iR+tZ}J$0S?HgoI@P|I~L zJ=5R}P|KNkGmfqTP|F#(n;)4#Eym~pstHibx6Glvow-0QzNvExZl{4-&Uh5F{u2Re zQ7|7~cHC^WD435dpW1A-C`_I8$Ome<9uSynrvTJq96Hco3e@r?bLR902B_tC=E2j` zw}4ul`$~=f`~%eD)%Q)|>KIVV4enUx)iXdXxx}EptHw)%=M$nQtIS%EPpfi!Uj)oI zBo|kNi)p9nb|8O5@yxbK&ZkFxy`>?0Jlmvy$!zb9g*{QX%+@4aM}L{E4Fk0dx4DlV zH3MofTN`b2*le|!t&PPU+HAF$iI2t|gI3F{qO`ftTdT`RQ|npm-hM|jpcXUFc`Od7 z1!rA|?4Jc{86kRSUoZn|F~iLtf>uiL+4yfgy)ybmDIiQvs&0eER zWmZ;%TBW<7-GBP@PcnGKk)&I(@WWeZP&`4s@*nB)1s3oPyU_M?4sAbQ>keS~A zP>cQoyQqEvsO5I_@zMKcKrIX2w^oabfLfyT#v0XuT2TF;k>3}9S_ZfQ**Q@_Ed$(h z*<(>aEiW1|0MK9PsqnWebP%ZJcA!o6BQv0ug$=ERRx_ZMg$=cZCq+OlQGbe7^NWC5 zqQb}4FF~s%r|9bZhAvP`Y0>`ZKZVimzQW#jkLuq=0JUs8y0saoWm~K;mfxF&sB-Tz zkVtPhqJKL}kg0BWgWzU3d&0cxpduJar0 z0c!DBE>Aol4AdgmanJwzHlP;%U$WHNO={WZE=zp~)bhORHNSuiP>WtUt~NiF5f7l{=@KrLvJO}CdYP>WEuKDKU?TDsp0e{=+DX=eKHe@6qg zps;Psn~6XziLZo*_W-rDc3tLQG5~5x5ERC{0JXR>S=(}CfLh%AFC>`p1GUH$c=`Ko zwp#d0(95<7>--_IGVxEC(fmLyGVzT}8-Ab`em^wt#Ad67UrsLm*(S9l+6bZbfm+&` zO^M%SfLh%B;}Q`3KrQ@ka-`>*)RG`B9PhG8EeQ_7@ot;clJJ;+3IWt2!|?EThE~ha zHZP%2eV~@7%zgaN^?_PCnd1Dd2%r|1<%PuOGC(bEk(at>gn?RQHkz4!{6H--)2Eha z65BI8nNp_bcRm7YxoLQ!KvORuzsjpehb2^R`Aq5xv|2RFrP}k-t(?^L8!thtMKj}{ zGtg>rZMrgWzy_$rX34e55n3(B2cFq1Id4+Sz<)L>CpW2OAjw9>38eh5=v(=(GhK{q}Y_({P8N?liR!h%6a(Q1*0ku$PH8n5g1GVT+ zw9?LK0=3+vHql%)fm$@{&_;JRsf8S3BZ2~I@g^K2f3X2-xh3z$?vvW27ESA0NY_nj z8Spys(G#e}IVyC3s0q|!^U-%D`yZed&F`qVi_mIu8W_;5yb08jSL91Ly4h;cT+$_8 zg;t9V-h*8x1=J!Xo=>{BNi9;%Hp4nVE#QZ>Okpo2?ej{Ji+eO=^)EvZ>tz)Pib!O8cz| z)N&%;gIys7)N&%;lie%@)N-O$zp)OerHs}mRd@oZGC>2&hHqT*?RC)9UpnUG#K;S~7m?l;L==-9NYB^pblRjAIvwFx57g4k z{wKpm7pSFU1ml~AcGP>W+gSV}Ac zs0D#>W4HjdH1*sP^3nxrspHwvBXogU>UkFQFa%JG>qZ;f00GqEI_=9{%K&OY{H^7= zZ?;+x7u_~m*>w+PspjV=e~o{(kc<7?TP5CXt{3pe`opit&%FrW%ab+Y&*3G}l(oy> zgV~>YucjyD@bRQ4_P+aFr}UwCbA>YfLHzifhN#uC-psFGl|`G?P1mI#jF@x~3U9-Tf!FHzmY9HMne6l4#(+x%V$$s z_jI^UKZq>q;RU>yYc+12tA249giyi8;NuRIq|;Gl6!-#p8N8(L_tKYgf61islgM%KID-juc60U zrVjB%rp}#um<8>;lpNjoBdnv1$62{TrB1vS(ot5kP^mMogLIg6_Wy<_9komZzn4rX z;=N4AL#8SJd%75Zy)Mb3yO6EVAE8UK?zV5V>24Rgpi6S-sAux=tD<F+Y`J?m4iPze5Rpw94 zq=1`aO?JjJm#_MtA2O`zov6>`Uns?@;HvT)3fJH9r8SCa}QFU z7yPI^Yw!u`J^3dJk@s*=?1*!9jfu;h@`@8S_aD8K6s(SM{U7SGWYyPudtDUB9%ss% zng?C19@$#evHcM#dt6;m=UtHMVy?u33^Yni&c_v5R$iE~RGyZh;d+SfSYDY?;rg%B zgx9~0Z{6pNP`}B4P&uw~MT1+}q&)2^yyM&<;y;6jQGzu{_0)&>Q*$~f2SjBx-e*n$ zl}0vpEx8{>&1j~Zp+;SaB^j%9eiYip=m_14O+e&VQC;X~*ii`gDykFR6(%Ncs$gCc zOSqTu#dZ2FVe%^0b51@l(Dmb z**=JN*ADHE?%ae7ao70W=5AbahJ))BEpzwvltBYmct*LcXAMTU!ZXT!J!w$Y_0R4r zZtIjmKi5B6SKQar1`nYA%3Q79@R~b_`s*6P7gFnL&GA)SlZ`c9sv!Dh{jj{8C2E41 zWE}fiNxYgR7>qw@GR3eAbj}*FjLy{*isF)K=kd0I za#?AVI>nrEhF##r2@B2*#WL=m31`lP;-zz6^?wD!C`A7MwjllIvG%=Nh#jfYCPh^N zcd^HNcZnvMibz_R7S*z3L@P61nH1HrL_~XOw-rZU8hUfyvXNN}=kX4lb@sL_x%0>} zt~#-?&&I^}A$Kpatxwm)x0;J0KJE)K@vY(FjGQ<>6eGs5pAX7E3dl@tR1BBdcaD0h zS6?w4(Rxn4GN2=MC!sv`y{YZX$TX^R?`ctI6I;Z^a|I6qo~9}g9;BX93<~M+nyw&b z_gyj_MNDEHIPqD+w9ATe=e_R{8$HU2)qTHB(b7Yv=6AV=vvg@EvT97~rg+y=Jwr_J zwJb@|5IRoui)rZiauvz8m!nuR-ryMea`|3rr3qfYWMa8Cb-@I0;5*)Ms+XeZD`gN8 zZ^e<#@}gt1>d(j9aV|7kaK0$cjPo9lY&h=~XC|o5UtjX-`aUSmNPiF6IK)X)JSe3a zvSG=oR5X_^4dET>%~$M{@(bY|?QK=;l%5IU9q!FhydrflglE-TuXsiJc?i#VU!pw4txdGmkN&T`b5sDQ^f`3*&sS~FNBLq@_9%V{ zEpM(KJzL&33MJdmp_-mwLMtjBWnUZ&a~3E!`qk);+_w8z-KGV>zLD{hYcsJ}E}X1(wXw;=Sns4}{9 zl%H*0EF6n|+2)})(dM%7A1n0XaXmvpkwI`iM|C>K@zq zAiAAVwi;U;u^8{%aepd^j)kJku?Oveug3&g-A#W}{rU!g%1%ihITw{`5A!8X2Pkz@gNNu^^VF_`O$ zwRsA|qAH;c68F0Y{j<6)Xk`N8#tb@>RbJDrA#%Z(;q$K{>E2;^{E2@}NG;vI0+%S} zFbaXu$>tWMQ6Ssc@DMVBxt&j2QGSp8Uc(n%{(Kg&1<_W*(z~kJCVZ7j*xoKf;)8@{ z5s`hQC)j>wo4}Ycsgv5pOcAh_?>Ocix-6BHuh?Ph9lk7-ryw8UpI8vgyug<$GOe~`V)(X8hR>Pq zBk(|>AlyH-;GF-h?zaNpW!L130+)T0qWMKfjj=P#ZN$><6ahH}Z@pXc-shLEBn{D` zlEmfX$C*NnFPW&M8d3Apcc&Ej#Fa?2F3-l%uK)2ViGUazFH;k=MM7l?MeygCI(!b4 zAQ5~B(~J+CSR@p)T%M3m8xrs{qSBdejU&t&0Y77^&sRg<)9!sDojM=)@}6{G6j_pK z7wHU%Z0%m7{T4WA$Sdp4q4f(KG~!h!jL{YZIt`caC&0l(7p@&F>L7XDrFn+T~*@scRfEE=AZlcbHVh`?8oxDfy=f_Ax$M!5lsmL zsai_p|7@2cR<`5{X7p(l7KEN}x*Z(elrnHj%Zogv^}ZlDq`Wvd;)qe%oyA=y(oc8x zChOqtG}tn9)E~k2knX=Nzbktm zF_A27CnBSLTmCemfviTTBInu_5!f!oJLFe(MFE5Y>?_(St<*xiS5e@KckaS%yq~xK z%8^_RTi@MiOdNT@#?Ds@y)Q0hK-bP!vknuNH4tLwt9|rN$H!SwE$jaTE9^3jjHYN= zY%S@1g;YB25gQ}TvCZ6PblBE}*iJsKwIp%zPC+OkgIvu;hoD6W2>Em3RXc2PuB0(ETmNG`<|Eo)4OEl37P{tNcwpJ?=>xgeNp;q0V9y@ zz|AK=x7!G0`!!=4=$^59OJ|4?j&IoJvTKh$941>CT?7kC7VV1cS}W-qozE(eVyB0B z%&;|SW%LkiDA2*Bgz2?B9=C64z3XvOKStsX{|8v4$SCW3$c74MV_zhObT>08T}QNq z%TkWfWTk|Q_3z1soRN*D2zu#Ro@;FK2;@o-tLVLY7aB>_#q-=9ln;6rM*^lG1G4swkq@yW zJ)dMp8w_oW&aw1_d-Y57M;l3X^i{nQ19Staj4ml_ASLJ3L1W*SHIUBt>gZwT$<|A` zdv)}&AIsKDYv}t%a0Mt8f08C-`#j`BSVxJuDTVse1eP1IFoh`F=T)#v%ai({XSeXh z^!@s_PYmfjF2wwlbFvS-3e?Ve7MvkIp|{fy$S!zvykUDErmX6Xwo(sG2NBEYRBN@IgtZF9rN9sywhV?BI0Yp1Xh zT}#-(lQh`#imf0#=1CgufkvKip?*g&OOx9~ch?JVq`HPS^B&UQ=!MJe*Xs=Dic%sn zTJ<`^xXP6MbTs9==X4nN0JoDau6HGvtHEugJLp|G&obt=r5NbNpJTyVASFUCK7<8p zffQA}jbN5Gw>ia6Z{s{`AGbZ_px)QUjkXYPWw9`0vI`9SoB24* zn4BD=V|;!`p&XeK=1W+0D$FMjh4}`oo+xxD*M#{7u6h=}A>Y8%x5g!nIR5PpRtgNw zd`zsM4)l#+!W)S-)R8`{5+}@7LON_ZJn)~a{j_2XJXr~W52-_aCQ7fuh9!){hMUaZ z&wpU66h?=B42iD0I}(Rkc<;Syskv=@UcHb)mnyUg`xp^@+ar8+YoYi%Z#B1f-e;m8 zkEDf8dIoX=lyVpzx>IjGA_9}L;4L>srInkUl|{(S(hkF);hf`UW(9@egE%lF$Qr@; zws8L;y`9&^v@)hqPu|AAo)5vaQj{@%t?Q{H`{-VnnJ4SnBgKULk&9tVPu5FFGxLI& zgDvYfc#L^%%)!Up+q~&MIZS5@7bcE<`!Jo4xiE3;6USU>;pXz*_8DNVJmwbiKJ=+# zelmE@8)rBXN`D!=H+7<48{CxBE}2bxO~z7ldM_(aN&isBL^8$$G#X2L|5H|BE+3j; z1W2dZDPyEZl%GU0UIst~H$X`+Ou0q+oAMqp7eVFZ`9l|BkYZ&mc#H3Sf`(WW6Ss`y zsYi|v9A4Zd)tE#(93)uX>v_9(n$;~P?1-yHPGRwu$j{4$X+B65^iv$ZCtzP1I7^x} zF1Z(+CCz&O*SUt)MGj0t0{?Fbg8ah73WBqU*+)i}CxNpFCLu*G7+Q+NMm8lmBa2Y> z(UDJ+jFBSJK{)JCcVrqk3$o0Zs66?cJ9j9lRX9ZiaRM+Ww|Dq(Wm{vMjEc~M3KsPvxV6L() z(a?2z&i_u*C$Y2Xx5dty6@6OZWb$vUWV;s_gO&$9O-z+*JVVS7cw029@+VM*)$EDC9Qscd@l=}B`soqd_T*+ zafsC}c5#2aL&x`JzBEr^v566SC08>eD2FHW308>!#AdYu@ z<1156@HviWx3R<&7rcbr(D@U!+?sS1x$$LE;KHez?4f7gOZuOmPWR7sqQ8)Tp_Qni zi%mBeV{?Dd;*oK8gTFSBhaR8{$@6G0YUrmX*bOyhQJ$dRk$<9PV_Eq2nc~i?ix6l4OPzyS$k)dFIjZnWYu~{FFUDZs-ti6?L)LxPl=x zT3pV@M}^f?I0Vizq~X&u2hK87;qx=w{#t}J)*J=`+=^2P9+=r6wMK+L*OjvvDmnhA#fJ7L5x|?95_pUKDA?V z4xA<5o!aqo4xAa~^#3t+ zC;m{c@BhHF@4K-MLTKzmX=oZnQrRkm>?DfHpc*w~HyB1C%AhDz3TYW9$ueV*$Z1nK znZ;JAW@s7Z)bEidA8nSMrRhfTELO*7t zXo9n7c7%)`PrXND&LAtl|3$T!PpW;iY^_Q98DgE%IXDy5PM_IR`Tc%~>ACyFo|$ax z08)i@KxfFuMMKRyFKC1bhOzaF!eiDi?oY5d;4DSVP%JyDVPcxIL(`{|un)`S1Zj?S z5+Fqo)lsAw+O;O^_;TSRE9-lOhpoepEnj#9&hqGGXg7NioaNEI&~9#0)5OeTXyyIT zZnL*1mNOqIg{H5;9bYbZ+~rk~*F7j3fsrGC0o@P^@R{p(n~*r^tOCO1qUoMqD|E}heJS896f5lAFW@ZSCI(!CmB3k)3Zn}b`D$3|JUGix|7xY|P2en>s0(a$C2$rj^%GlO z37kcLbkTNF=q!}Elh`q#vrsfoBH2P`VR~T0-V2?DX@L!UCv+BOJT~kDILqJMulh%M zLTC9HNzQNA2WLs8z3R8p2WLsunv3kp2WQEDv&nN2oaJf%ekETlI7@2PY~)}0;4Jw; z`mv|LS-AKT=BZ7ih%cBiE|CJxqOW-}WEz}>gTKoBxe1(Q6OP5-LIG!?T*u!0CMeC{ z7mvj*uvL`6S(H9(O5lOBjP!H$YkA--i@eMEO;~UiKCdNz4hzo0YkSpSqz}$=ipbz< zP{3IzttW|(!CCsbAM~eq;4F{0Z}g}5;4F)Ro6LXo!C6kN^lQT@&=9b^ZkpFKY>|rAqKTd;o>-_W$Co7M&Hf|<`;~=23ahf|B0OdW~r+f z)2RbyNzjH1sdE=B%mTBpm$ujOfLY!x#nn9^0<#dme8rO~z$}#LG~^sG%Uh-l z&ie~6%XD-pr56Xxg7f}{FQfpo;Jm-%^C-Y92C>UppM=aZmYh~QCuEi}xwH}t+DVlGe>hE*b-784F13o&{!MFCD8pDgb7gSvp+zSpdwk zOnfC zfLYR3*wGXUFbnS2H$H`O`rp?*BJs~GqDbs1shS6VI>C4B6e5ii32bTE<<&*0x*ln7l@xmN5n`)YP)EPVyR?Hg~W)MKC(?YrpG+Os|T1xL`iPK1ehhh$5`~Z959PCVgF`v zU>12oN>7*wFbf1iAaEkgfwYU1$N{q$uyR(JNdmLT+OF+kt#XANh?x||1eoRaswOFn zF)+)4j1EzGC14gaR;S3A95BmjR<4M!2gfm!5j9ec`G0keoSN>u3s zvuH=`?Rg~v%py)zr-6K#Q6>{!<_ zu8ZN+FTS|2N=b^a2AIXV=f^5tQD7E19wEa_6qrR6tIfK)3YbL{tIO(K1*hm!X*evskgNi-gMovq%e0_G}OZW|0*f?>Qj~%p%&Yn|PuJm_@`{S`gU-%p&3} zBRH4=%p%e)&5OwZW)bO@;T_HZW)Vr3=0#@!vxub2@ZvLoSwwzGx0wO6w5~cWB{1uG zxaBab^v?U7Nef_>BZFx!pLBs)3~p^1%rgXLartDhm{AJMVpyG%paaaJf6Hy~oFOoa z;hUT|U0@ai7nw_W^Q&(wpOiKRX31-FnJ`4+cS&!^(d^UQLh?53Q$9m-U1R8!zuoo4 zu*7RbZC&(<+AdbAefMuy$))fLZdePWxL+AGp;ir@124 zfLRPU6Uxk7U=}633rWotm?c+BJt~o87!^kRv+N^1b)~8Rv)I#v4D<4VS=1IM zOXqTdS(F$1N{wBBSrk)u40^Z%vnZvy4O+MYvnZx|4aU0yv*dN*rWFi@%;HLxK|27m zG%9b%rhQKVj#EsJ%4S`t^w4!hjWgBekVxNm%;*yWm}LV8 zr}DiRn1wR2I;x)p%%bw0LfA|fbK{b{j2w!AStt`mQIANle@i-Sbh{Xsg>rH0F5?vC zed3!SjiRn+9H_D&U4mJgrQ2J?Dq8K*BpsQvovw0Ly2kjjN_>pU5=qM4a;tWGVW1i? zOB}u5=y(Aziw>Ve`o{>E#YkUUMFp6}SYNL_fd01L2l610Hxb4_5^}gkz%1?7>60~^GHHwQ5o2(r3=jBn^SE(sS3;zoP$>xa0h1D zM%&bPSsIwd*t5je1ehft=b%cXJ21<(Dg^#G9hgOBZfn;DPDn3l$!L@U%%X{pq`RpA zv#3;V>v9HW!E(e@49LJNWSTkWJxR%}`HpE{zlweVFiTMzt}jm-n8gUI&n}SGWi(3r zY$KQhvz(V6+eWYkX33SVHtx~_W=Wz47#%49W+@UJqQ5i(W-;p4=NFTJS&X{%_%)=B zjGLs-+a~pZS<>lBM(J9>EO@$)QMwK=OKOgd5m6hM5ZELSPo%dvjI)RF9Hje2qrqon|tW!-Ef`aT_)r8sr#$PqL!3z|YM zr~+m|Q%YW00JE5Fo6=&^fmxUshXFhqm?fI3%%y7qv!EpzT1a3P9qzPN6CIdEgZo7* zmJZB9#_uJw(ZDQp?1tG?G%yRj%XXGj1ZZ6);P5n99;{6);P5nA+0oDqt3L zD<#!+1ej%BD>{l?49tQWc#J+$0L)@FK-1zz1G7X=>PPP(1GAt_nN_O5EEWT|wVp-; zvqX;+I&K7JsjW(mdaIj!Gh!qaJ;wxQDeAPHt*4QdYF<|k zFpFwfRpQn@U=|sh9fX5@z$`MY+X*p!z$`MYI|zsSKF|kqUYd0o3YjG(3oSEX3d~Z@ zniapd2AJhsL`W822ADtA_m}OMFOwDr#FiSYz$`a%j+zn8fLScq_hn9)0khQcvU3bffmx`$vYa|oV3t}|k$AW%FiQ=q zT71eBn5B-DFYas#%tB>Via#_3W~pUS#80ULv+U%Z$U3dMQvFQQJLtOi@Pj?TERB<9 zGuu^RUa*Dky``IawXB1fFi0~o-a4@R4Kr?ovlwUTo-f0GNyKsRi3-ncP}b_4={^Y zYT&Rn3Yf)L|IDr6;ZODM!^XZwz$~%D4Zh!Y0<-KG-@rhlfLTx_Uigqg5spCJ>@o%o z%(AC&3reyHn57Zp$UvchS*~CwsAn%L6WV*#Wb>FMoNt%K?~W|L_Ojx1~a6abi@dNBaV^?8GM*2JZo8@jzQM1nO#Z zy86AQs&ZhK*uo)H<7Hr$ooFk@7j=6&tak4i^#*1!o1vC}Z=_mOC4~(yqkviVSbKJ^ z1!h?@^LP38CSaB(;_8`f6fg@a;7Ul%;wAMvFD?@>3}X~93+hGFL@6*!U?I~Ndj^=L zoHL~Ea|M{CjPpu;>9h%oBSpZO?IZc17>p zF02}u#g6+``{68{Z`t*EHPS@kwjQ$iOPFp?>P+2V&6Y4?`sFl z5>s_9@+7RY9LNjOj*SIoDZ_gYoU$K9j9_fID`a36ZOsEA<-jaw@dpQf+5@xL<4XCx zWDEK*`MTZBQNb=)XR*Ul*=OYA8Gp%tuum8SX0hz&YS#_|vkde0=QY^@vkdVP^5*P- zSq9r2`-`-JSz?JM+;ik$#$9sj0iqQ!i!t|u_S7ISiv{MDF5-Tw7uayU8 zv0JHO*UA4ra3?eQ#kI2JRw^)y?{R2U>3yWLzajHFpHc6YT`C9OBcKfZY2lIB6k<{FpDK2r8i6hm<7Th5H=BJK-wirEPz?USvjI+8o(^( zw#_{(QD7EPGfm7DA+v}!X<{0L%#zt5D{lkL(!}bN7_$IoxyZ_ua90Or3E`d1s?-2x z3F8%J1!w@Xgz_$Az0?3^u_UDR>dFe4MN`W+B0;##B5}kz(L=b+A~Ar_3I}EhWPKAY zlLcn6wC(IE7X@aKXhc+n0kZ@}?CpIe0nDOK_2s?IN|ultwZJ?SGD~l+geC%*MLlF6 z?-wlpE@mmI6Wf4UZn3UqU6;f80keoIX%a32v)t_YA*w42%woYKWSYqWv&dqDSXV`X zS!A)ntWHs27TE-AUX*Z~MIszUy8_H|gLPdZ+ya9$u23cShbHVZ66SBZ8vfaUn zCxqK963%M{k-}{j31^hxpm3W-qI)ebM!3x)(T(CA7H+dhq_5>g3%6M$(owv4;WmrJ zueEJWLS_*?ttn{gdFXbSb@tBt^2zH$W*JJ``6(EfCH&Twp*#~{mYtu%7c=Q!MhZwc>uFiX!%7Y!Zu4>L74}2kC0gko_bJs1G99{ zgG}-&fmwVPC(q7R0JH2`>^p0`6PU$1b;pp02QZ6Gs@sr-2QZ6us@G7w2QbUIuI_0C z6CtyBkWr982WC;-P)WNYWENHKPO=R!izWV4!44B(mUC4hQ9EIq<)-S>ot`$pESBi& z(+g*TS$2-1Qjfwmi^*ICH5{0wb-M3t(N18Noz_;Vr(v5Vbx3L_E)bX{D9L~5xru=} zFiSzc2X+rIOF&dwfr1GzO9i2eu4V$vVp40#_Y-cjnABSFLxkHbCJvQ^b|JHzZ7rYN z1I)5_@%h=-3SgFU;)7{j6JVAK;{EB3CcrEvQ&ye#h0Jo+pnRoG$Sh~;%2#d+nMF0c zTyRUsEUHuGg1bUyQFSgCvl3Yn$gp~s3ZXX=(iB5bpmfAR-r zxhH(S{su5h>%_&V{z70D#P?QME(T^P^fGt20nE}mQ6KfF5SRtFgm2#fW@)|Xx61;U zMdAS@QPlM;0uc+cC79-{;NI)3qKhs~vXQXOf;cN%V{sV~AA?vblnSui8q|I*&{xPT zjDGXuSAkjf@kxdMm;j$YIfLSc`L)!yjo5csRAdoc?MnDpBx#qwu0hmjD1qfgk zbNOqwG+>t7eM1QEtH3Nb`raW%n}Jz^*e$aC7Qif52}c-9=D;k?gh+-80+=PB>oVsG z0+=PBtDaMV0A>m3YUJEO0JB(xh4b?Yh0KB&@Y4zwGD~i?#pE6#v*h9t0|CG+dub2) zF3SS5Sa{yFy&`0m+=Gb50AQBARsQ(n!fh7BoL|>HV3xuq^U+pdmH>Pt!wmtq+TEZ5t*`|@OgS;hR}7FnOYglj@( zksaGhxG7{7*=mcfKq0d*0?dzG1!lP}IK+5q4$NZS9mX#%1ZFYs4&~Pr0<#o;-a8p8 zWEO^!d3vCbSr|U%>HCDtA`A!w37IALs5voM$Sk=A<|~0hW)Uv^?-Mc$?D{WU1!lS4 zH--3hm3P!%mzHm>NUYQO6x{)2}s&J6DyvK+pqeB<-| zm&fk^mZQ8m_TXlCnCyF0bL+*-xaNyzvIb-?kQ!w#pyF;`%#FKxu_Eh*?CZ5Hao5{w z`o76-B$dc+MER*7*SO~5b?IhXk*)d#S*zT;w9S_qlx0Klmt1@+69Z7 zdnbI@>t$OJ|02Sgcqbv1q9A+Jk5JZE&9IU^>QAWdd&AhAdtz;uFMBmQkgc5SigPZZp@q!rHA$%mWPZQC7nHPh0XNTmrt|j_! zVZ_UZ1@Q8+Qlq-FBxK+E@y-!~dtD?xuO$R@HL=np4Aus&(Xa@tkGPrzTYFguS)5|<}styWJrcCc94%RV9d zP}6zs%5Jug>|N3g+4Q}ne`B{Vtm{}5YT&C z_8aMe>^IazD=TA@Zg((h>Uq#bZOejN}wcoZ{zl#lqU}-PJGtl16^`GlkpTEL9Pq{+G?|&Rk#X7t&I%?r(0rd)&+I1paEF zzPYt8zH|O+ePyAA-xrO5YqXti1{G8NZ6L!Z|;0U&qiH z*bbkutmyEECt-Fi*AG|vbz9ChQ?D-~Dq62K6TBE7%-`-p`-}v%Z)Q}Rzg0bYJ+PUh zhk$sspRj}kQACeIc=%cRw?l-f-31y+AVO>Iyr*;D47|fz5$~6NE$K#JQ__RsF~4+Z zvsu0}-0F8kIsE!$b5hyxQTl;Dtt2zwIZ_qQ*H(5gG}`?cW9I3*+r1J3_}2@k{8p@g z?cz5T>X{!e3^o6?hkvi|H2nzT_cehJLm&QM;1ECkk-IEJ;#-;<`O3x58JBdFo)TyS z##qGZ#mqxkZ!VuBUTk)#)=_QVA8Ky8)SU-a19{tYbmHTTE*y$$ywsVeQGX?Gua2;rq`m&ez;T^7 z@e8Y+4lP{XJw6fCth2uOuL0}6Cvk^vuX_z4kdke2DU4j5O~u^qONms@DT z{k96<<7fesP3&|9r8q6?ZE;#xBg^xo7_K_o;~O>`hk9Y@&1 z-CA5m_tmL;As`P7W$84l?-HY^7`(2 zzP|#rkq|=rb#-P7mUT4Z2)_OBM5cn=`mP4Hu#M#Vdg8^%Gl4CU1$kF=&J_sDNd&w4 zp>cxit`iWy5=gAa7Z3QXhp^kan=b|D2ZZ$`51$Dt1{9!;M0fp>zXin;E&Sl(se>zF zD;N3h#m)yOjP4#11ohkK+%5R1lm47noj1iW(E0VO&9xsgZvEjpzh3Yt6F1o53cn9d z7=Jr7(G>YGPi&t_L=ED3ihg~^Ul_l+NGGM2sX5KUV2ArV>^@|}QeX}99X9MwTgduj zFI3ImwcJ?RU(-bLJyzSuP0@=b*&m}`;U?%EqdPi{KF8OVEV+e3!^yLk)@|ByGlUL0- z@8FP&Pse3yZqjx&H9dW)P1?nDb28Fv-=324{LFi3VQP4SZ!g*7_l~yJeS_XVj4Mtj z=V_kInfj)ZFsGYO#)Ctya?SP9QobPqJ}fjX>D0U|{N>d8H1)x#2uHGJ!~3osbB{S1 zHTT|kdCh%{x(K}^Z?7hoYPBSjMlU&ic9K5vL93CgulK$1XUb$NS46L$A+2Y8jQltM zB4Il-PA@uzj8OVO*vf2h(pTfAkYA#=2p`1M^LbBgPCiT6Irr45Zrm|w*PMEd?=hT3 z?Ndjuxs)2%v#RMx8GQlC>Me6cPT_wq->PazzBw9@(tWLgvtFLzuF92Jhj~k9RBE=ML3C#yaKZ`0~|J z*pwLk>g4=%8G4WADxDtw-MNK$Be`MJ%juy?^|AEpOB-vZiVRYIH7}Xz1=IKHIe!qi zF&)}NnXOLgPxiBDuGn6PZoXB0gh9M^StEl4e@O z5NsrGhnSjzr>c)O;dPI_q^X=vRaC^a%veV9|H+(WzLErS(ISlZcu0n6B+HMi=M`oK zDD3Szz&nspG&u{4QfRMxWbp`RBAr?QXX`}S7rjjIG&&fDpP^2i>bMg=bMeY@4 zntrmyHx+CVhEq)QF3>)#etS5nYUq^bTZ>QjkN}bLRMdn|)$1IGr?WYRWer{>Xve+S zghiDy$_1h)+1~N(w0IdjEaizi7*$`egM(7@sU{52_is`2_4HiNZy}7**Qq`Af=`xS zBX;OFblCD&v$;SRqCbi%Dd;o}L-Vf<6~6TBKC-A#wnQhE$$54kT!gxSps~}l9J+mg-ed|N9ZX+T3r6VBAD|X~ zK`>00QQM2p~{dn2K3oDPOKXa7I zPG6Xegl`Qoi#BDat5@6?9F|*$etUjWIpy`qzH@3?q(ikWxA2gLV*9R8Xo5VtwufjY9=xme1}c-pci*U)Au2z7U2z{k+DQuq(uggDjfc zhaKZA*WYBE)c6n#FW>D;*x%mHFs;AJIG|A-oM5Hqa2h`fuUmcTNG#5ccRMZj zGKluJZxuc}SJ!HCKl=c_C^y(@@&Nld`bnRj#;4o1+vV@t())JFozSD-w^HmNP#C8e zX&RsJ2{IV$s2s)_4fi18F6@5%Z3bF{S(tByi{kGtq7>jYn8DNtK9mD6%&a_b+wQ=| zun)?O1y)(cMHc2>)u=^`1yvo0{3{pMH*~DGn6#aZ}Z9+R#Dd( zbymNk@YNGX_yI)^gH}RUBKbZ=8!Z6@Eq}%Ej~4wV{iE^wj-ZaQDXJBI65oTd5&oig zckg~YVCZnI$n`Mt05?(BouuPXavNV>+`vFPl-$8ni@DGf;(dGf(`{Dxv&F9+yzk-* zG0z7MI{4g6&BHvM4RRQ}8{|%fu8``vSJ?1pcY?+ar2T0xDNb;>`)cdDJFi9#Bv)H) z^{Bp)aQE)3fo*QVx*rp6-y7eOa(jFzFRGzq*Woya6y4CA$o|`rui6+A4oKZLcA?y1 zf&)=EQ~Ge&e*0V6Mf3v!)mI;TPCLj|Yre5`t9a~@aJ=JHXvm?C{Kt{t3W6y3JH_b^H{0qwczVb6B-^MMYIqwdoL&wGG>x1p;fK^Gu5 zp(~DSqH9y+=rH@5z!>x=PRa-i2-L~BL7@mT0(0ol~&O*Ua_2)M$J+nsb z6SKlV3VfhCe6wx&(qkyGDW(XUNuV=eKnDs*{tKgU?RDX8m;~ZMu7$2H{Uv06CiZi6 z+g}abbLj5iKM?c|P#n^4b-E?oS?+ioo^V9%&Ea2%mNryBEOJixbuc;qPX+}_Ne(X= zw7-E;lHVPj?hAK1$7a*3r51MfD4Jh$#L%}K!#8L5Baxq2(!2Y6&nT88CEmsi^tLOO zBqw%ZdV32L4>>iPcqi?8(>tW-tP;R~{(KxW!rr9#8S&k*YlY5&1bWs~>blu`b1SyccvOyGakrjkt9&8>QIjMA(a+`#*V; zs*fztm26GDu4z^bn%Ns7)ruiJk7UFvhMy4R^@FbTODImIz#CI<*e>~>G4RIJ94{^3 z028uCV`B&>@oGQlNVqC}R#>H$ZKFq=iO3eb z?%gI;jU*a7Y!tlcJ)zjG>5LJK_xdQ_Bi;C~j0AM0KV0wEEDv-A%1AyX!WYaWL1q6# zyf3m)qjOnKybJHoFC1)O(^ozuHN(W|ahtC!57m;7>zQv8%$&e)=s#LW;YI z5P~;kiQtq;%3adrp1?H`0k#tMXR;*-XqaPps%^Rxp&_#0R@sQzXm08aV<94r;!OyM z2`TuSko+O?auwWmdXDvc?OBBvOEgYYOYERY&=52Q7+=Ldt@)zwu3v!Z(#fZSvDuR>o6o&&NqbV~s zV<@XfJ;I#VLQt%d^pt{Cm>$ar2Ut`QA*c{!;NObKtZ3`1)+|J8W^4A*446L>MFghs zyVnho?iPb*F2$y#oL9j=6qaft3f5z!0x}G;U`%BhN37?y3&aD(h_bL5lkv4Dwg<)z zmIuoMe&sq7(g~v7D%F`sGe2j*|B)U_j}J?u9kHUlA_3EB_QR})nQ$=lIQQzYyxZkg ztnC_XJV}3 zmk%+c)Z9lPBb5Q23*7ov+G{sw&6hOV-Y+NcHANpn<&`(kXyXho>mwTXXtd{y!`4SM z#X=|99B)mK5lz<+`D1waD|jSpHKLQ!CcE6A0CbbYI zW{j;3jxASd%_Vg`lCd_>^q~>|W^C028L_TC&LQhNMF*`L@5ASSzp7@U z?iPWJScj$X&lZ70Bz;T3=~8#+^zTC7X%VHQN=|*q43uc(sF`-z6fN*x;uqmQ3Nsq z6=4ZU{NjlPPO#=wryy*F#c|hk?wmA&BAQn-HrDqFK3bnXwp{%PWW@S+N@xEJq|#=> zt$!b1rc8WX46pp%IcfauugSTjhmXXLm_#Tco@0@mOMhXW^+)O}eoxG4X0x{|xu;_G zA-U{8C1xrnjLKqfRa(F4nW|Z8^GEi6B{&&2QD^xn6p)ckwKMz#3do55*jGX=6J&() z75m~lw2Xfdw$o5RMxcVm;X51;UnYj5<0*%+ez(603ulx-MwDv6F`eXuEjZtwxczCt zkqQoB_3{{Uwti~o5!#u1{8GE<(Hkg0Bl@QZ zT_aqe5oVZvD{mq=1hPPsH0&2dz^VB;wvG}U7Wy}r5+CFPjVNt6NgL`18d17;lJ>YC zXe56lcJf_hsn$XyDgToGCnwU0Q^;AqJ_Tq5`}Q+mgaS13rv+sFMWNcx*p+#<83kyB;(Tg_!F6c=huf-udP#6!(9f;YKS6+{hSZfu+$sIvD)%@2 z`oMRl0F6+7Vn2N+^jsHTXsEwHaV5e(kRO;4CL0GdLPWmft~CG}AtFC;F$O>*MCyD) zBL!&0;7%HKfeSQ3c}lGPv;;IV)UHn1 zu>Qhc=B0@neMvntHW+KES{Ituh_gTQ(~=mr(4h{}&42Yc7I=P4A7`_a+ByHOqjJmw zf<^&9x`ty741nixKbC0&OyGIS2V%(Q=nPsfvlw?|5#K>$FmK?FEaC5K&2tqEPW;eX zBl%N{6WvU?ugLiam%rQv!5@hGeE&)8nM*|X&26u1AmY~QFFL-S)2aiW#~JXKo5$dv zOdU7Po3{)_n{#D2(;ro9Y8UypDX~OPjW1{h)6W2S{{N(k3_8+;2{C>>rIOfXIV-68 zz(p9e{%C=snGd)ro}i>#n=9m5}n(6+$BvuDJU*|!z{@Uqj`MtAmE*}?A z=#RrKw!dZq&l|)OiSMIt1kOc2_+Rr3{|cp*n4lc~b7DTai~>AwF!e()$^@P_aQ-0} zn$u{1J~xIt&F}O|_`ZC7Y>N2H`WJ7xX-p3X->GrG7nkpi0ngX{{;@JPr{Df^4p;Xx zO`m5WBGufi!75z^JTIasmq^Gk-deoMT@qss^G4MwrXOIzhH#{3Nfd?-$rjDF^gs19+eCDw5{xcLCTO&!_Gr|{ z^z0Q0mP$~&SbsR#72%Ian8UMB5 zu-rr%GN1+LxI&fW$X5L}X#&1ykDmn8%-qX>b4BEoc$Xz>ral%zMy=9tu4wbFWu%G1 zxgs^M$+{pMLPT!LLCuT^CvVjTNgAr>G3VT>dy+J(o{v!x85>0>wIfQkkP+K6E{O=w z6)B_!uQmhD716f_yv&S#1}Vc>tV9;hm9?z-45*_)C`l35uo^_*ToEl%!WgpPVcZNK z$ttagvjp>=ry{j7wn4VSM3ESrD{{70JzrMA&gH5O(Gf-A{%lW?NSG{~D{2u(G6F=q zblNy~GFqjG#w;w_nU$4sR1(e=y~Ice{lK{*CrIy65`}X`%vy%Fjs>{@R$+#LB%CV} zg0(&GSHZa=+9Ja{m;qZ;8DB+ka&WE~^;C*<%fh*$7ZIJ|B?9M)7*UsXe-)f7qAOag zri@_viwtL}6%&?2`zBVa$Z0t^SBzP8A}7S*T#;C@U`>g@xgz#Um-jH^6#Z?+Pti|G zgw@UB$ol$yBv%(WSMo5HbhaU!D=tWFPO~zcD=tV~4ow-(6&I>j{Q=TG&Sm90u2dvP z9e;@Q)TLH`Q67I_u*0QR?^X%MY`W8MOb*T!-CJRUKMdhqG1SdXK;9~d)43HiIBN*! zieYeWg5E7JQsG_0*ST>9_1W`U^}f<3TVpTfJ#m>g{0lNi+I_NcuDD>>y;J4^AzFQM z?0o61T8s6iy9Wc;7~aqGbUk3C6qM72*^j#B5~g}9c<>YF@6t^!VH%4`#2JE(xPFntdy(SZek;GsrQztS@UA3X*GYBG0lWt(*mY8aa~y3;*Mqbedb_+L zdJX-Fvd~6c73ETW2Ej%QKPxS79%M&t9Xw;`o(ndjo|H0(HiX&L6{os5UJ5p1$jqlI zFT!j7gJ!Ot^5`8{1m~UdSWcCtmM~Fd2sV;ir4eLE?DTn8RjVQLG>5a-@BZl1y{7@3i zw1(;OeBVJUD50ry{iT{zJ)yt&vh-mt*oeyFSm{O=uo0JEh{eyPU?YaV6_&#XH$@#C zOf&qRk5%3)TC!`mRiB{>*oZMwgUyqMYm7<>Ingf%Y(%A`I58v#Y(&MIyvyFj3cn}k zwTgF1Vj*UI-$4~03Ly_;&k0f)D}lR=8h?^B=LR-X9B32uFA3^Kgv~T16I<66Pgq7R zk*u~t(#Ys;$%Ltk+Z$oo4CTp|6!H^Pmg=RBUC@B@MLIM`*nr~(Hljk6Cb0_Tj0q|$ z(wWl6Ve05Yt)ewiFG&aKp3^EtBu#fqH-EZ^(eWa<*yyyo(;pdujci*$@C!*`BPy+h z6J{>q_+vTgDmM!fZ_FF8|B`;`4mLtcjLxYx0vjnvjL&&v1U9mbX53e!0yi9$yscU~ zY_O3N^hlM@1%y_b4d-7K0}>{L{9hpr*oaX{3C5HY$WNrZ8{u^LXtXs)KvFXT8zBky z=M<>GRmTXc$38DzCSmMZV7tDrgVQPf)X0-!>pDWVwdh-r1{*Pgyw4SBuo38?A(-@C zSFs2y5}c%OFajGfwk8P<(7{GjG`A7WIPgp^>9hOFYK~Dm=1=_$*og5>k|2^UY@MMP zwUofkXHDM)>AG!TBW{5P><($L5n&YQx-{6xpVk?+Zo6aOJ(cbv-UESQA4Mg-kmi<9 zEJ&t@8-a}&|I*>#Bf%BO=$AI$oesef`a`2%dVGWSjhu%nzl&(@3&rr0_(hVc-`RsU zzsA6<*>(&cNA8b8>T{)c8xNd8mlWb}V@wCy(IrLrE{x$o0s7DY+>e3F!_^mE%}CJi$c@wP@E>^r zMBN;x+Yky#Vz3dk6Ju9YipC*%2plyO*+x}C;OH)Gb)K3SIs_jn|6c>nNKw=_bo3ee zf{uOh`2y?#l_gHqmF7aIm=R`-JfgpmQiDbwxs6plhze8bxJR>_^+*4t!#%iU+-6`s z8f+vwL=!4zz(&wV$ar5e*a-SaF+Q05hS8&Sf=;s^SdGRElxn#~3vHyt(Q(TH6!LeIpQE8YgdV8IeX0d( z-dZ1`i5gsZkeh51O+-XzLl$U2t2zp7geh*7SW{cY$@%%N-){0(< zfm!`(NW(*GFm}DWWlA)dYcLMIc$pGSvo#p>mdE0-c_MSj4CB~~;ye>?GQnfycnlGSP}dBoitA|K>H;7D0P&0*&RCQ@yDU1UcN6+If7YZ6sgXIfZ$vP^ht;zpi?-F<5#EzjnJ zsx`{kpE0vk4WqWH`$O^vYG_X25BBNH*mw{+=g+qV^jWCFXlvSK9$6<2qpi#j6Pju7 zo%+*RA5=r?A~I-(P%m?2XJQA_oH?doZU#6-L{g7$!;q>@pcsQy6V` z@=~&5RXsI4ZB2TgiNk2C>gi#-sTVf6dt1ai)L^vT&Wp;bRE5!YO;t6kLcGo2ZKL%{oe-6$XN=d%tu31cFd(jrR5I zF`3glXkH0Eg805ERTyoz3u629WMH(F`MpzcqVF{QQ_gSG6&tpG`v!J*&QCS{?dBp) z&CTaHrRp%+q7*&wghCi?)!iF0<_s8Z)tQYLYX*$A>Sm42=j&m#b^EAp)&!&N10}I9l-E5lx`D@wv7qrZlw;xXgj_Y;4;DqpiBP?Bzs8E~-mCy>N35%4Vnb z6|&peTk*r|VoVpy-G+;N&()jHn4s=o0blWYWjqsu0$+iAQL5YUTh4HKz?FqE@D(Sw zhw3jcgRl7DLkmCng0Ebrp&4E%@RiH7^^C(P@D<<5Gn`TNY&uKbb&tH4&{sCjw3UCq z48Bsi_^Vv$GWg167U#UzY4$GjxS1uFn7<6^;6;!P^ z-?MNiOBg1)0KO7Zco5at1isQ(RS|W(5PSs{=E>hvSP3;W$-Z4@InT=xtyhSfW^_^D zD=5v&M0@a+kL7ZZ_W@rC8~*N_UJAZKP1-Sh1qHr>3b?#t3%+8`a7XQJ0$({B8)DsVoi>-r|_1YeyfG}o}F<-u3%spt4#$lxpXwH5pYGWd%2 z*keN2z>?cPjZXJYc(0Tqfa+)1ngJS0^*%##D8l-*mQDRyiT!Z8{ z9H60qSI)`bJ3zAnUWv%tXg7Htc!e6dJ@1mX@ER10Jjbsk!!^k6Z5=<84A-DPO*F!5 z5O`KNkhQKQYWS;|7RlqCe{ZF-Phu|92UoP)!57(eawpRUL zqdvoM4Jv~IngF}53+{se=iT$AiI_!-oZS$2IYO##yt{VgM=xdVYmj> z{HdFPYmhy$ntfj$u0eJyRqQ4*T!YBYu`4FPE1S5j+NU20uR+{8?GyXq8Wgi)!JX2E zYmoh~YW_nqT!Y9z?LLhWR$mvVHq`GcaZQ42&@9H1$##TmP!jSyw;2i7pd@59mxhFE zP!hGO;XnyogOGQUsZ`*VLnTj>YHNX44vcmr)z$#7n9X%Mjy-~FP<2Nbw9>#e$WeDD zfeO5Gp(AK?))B5jj=?htH630hg?Ala&&1UMuT(YoDw+VV%nGwca1F{o4_Tukxg?BZ zX+J`9miJ%vP1oAka(j&(qc6SHy0fh zGRk)}n+C%_vowTMONPyp8G;kj-qBQUw(4y3p%U0UacrEas*TR8Iv>5ag1q>;z`oh1Rud%Mp43HiY0$y`Y3(qk%Yo+Mk3EPIc_=85Cf*z%T9wP43w7`xT;0T*1$q6IvDCUN! zHA}^>arZVe@2q#eS}Y3X9#;(=#g@=bWQX zjGj*ZRhdkgs7?+a{nt@xx*|i)&(9)pd**x26VZGGrmIJ#ep{xo9MYc^hxg2kmO}=> z=Ny+zHMwV&yF93V7msI_$${`&CGP8eDhI-EN!Z)lAqT>*j=jf9l>p(Fdtnjy5WeZO zMKokV#7MbuzVZ>u&8z#eG!bOk0VCAJorrz5YN@^TEQW-~Zhr{v(prN+W; z-mNTHJdrqs=&FYgJTbl3VGa06KuGAt@XUBwSUl0B-R$`)0gETHlYy*8QCK{Yo!rN| zD+-Gz5}(#4#%6AbO3XBr$kPxmo`fa__MVc3FFh87)4fu%uy`U{Vu`uN%H~|kYLF{I zVfJD}cxSRG8nAd`f$3)DWWnNzEG{S_Dsu-1Qazf4#-9DXqQ0`8R`AK^X3uR=D5O!g zmm4v+z25^1`Jy%&gsVM|qP#OZCBjg=L%oGBWxK<8#aT=Al`J{g?jRnNCqlkx3MIHn zXk`ggKD6zY*0js4zyAb{EM(II6R!mNvl>LN%faFas;#l7Ndgv6BwlC=THp&%k!ZMv z;98F)|5#?RgaZN=PppMmAX!*E`I7~bg~b!OU;mG(J8_44|NqB-PBqln3WIEwLdIUw z&}6G9l-&Qe*jD1c~qi8`XODIHHXUr1Ha$1a%%&4?TGxUzm>D~D~Ue5dT zyS{(Ixvtyw?BRZ1_qhkcx6`jU-!pque%_h-Dq>`&t4w&1ZV7zEjhX4H@V|mgmQJ$% zLBoe9GINIQJyRK#ilp4UC|!92k04EdQIjH_2_K$pB_GMHa)u9249GFL)6VeW$yPEk z_p&a0c;fubgdCR(AD-wQGE6zu4fVTs)uLZBbpP{>@p*KfY^yhp#^7Tu1Et}YQeriV*9+aX+W4)(kk&=29t`Q6UJ zz7Vc7L$!!d0^y2Lici0dE`%#C-weqIb0J(&9J|oojf5+?8ZHywNVt+4;4YB}*+3t~7A|QoMEn!W9qEpkl}c2vXeT%}CC@xqXc8%b*b}f%==FrTrKs) zDy(G#QE9X#?@6toGY?W0?EPBeXYBnNh*_{Lbq;@H;f1LisX;qqtYuo!{+MrA_=^bh z80+`XOsUP!9<`siGyiyc^gACtviI1?mTiBgwJwSu#3#QdC7)>Tn42?n-cnJYbI;jS z{n*Qj14s6Le~gVaH1u3L-L-)+(<83GPXBP&z7g$ie{%FzW;f6v6us??3YDGtDt?G0K(o*K0Ek4b==AYNtM-c|K;rfnKb$=!QE}5C0(%v&IOx z8ny=KE&61=xEg&@#S2^RFEqx$WJI0#JNoNw(&hGMb_cdRSO`DTfsXXXHM0RLW)jLG z82r`TWJ72;>T6E2A*C^M(QwI*>c?`zPy<=|qNiw^x~T=UWv(f9>0V|dcFCXW z#d?pv?=8L~@Mqme-zT*1nXM-gvN{Z5S0qHJfcfPobR)WD*%FpqvS3#Y`?HJPh55DV zS3PX=r2DjoXxY-Dc83)z=g0-X!!k1gAeu4 z1`66sK)qH@AdKw!<~`!_yYe@c)5_m`M%?Vv&FzGC$O4VZ9%XqSoGbjD>>%8qRizAe z(3q@RwoBIXB#x%B>%WsYCSJ%S&XOn&m67RgzlDe*Y~8PXL&wX^(Z1pO+MAhJ6afox zJV~vYWHc{7TG`bdnWg#Fmy2#hHfQ9B_t+YkNtmQD-zW6`-z<$d30g+wdUM_sCalmv z^>u4`1*sZQgZ{caGs87PHDmBMLW{0v(lPkT(1y}mp-2}9DvJh`K!}S zx#BCP4xro&DE?usk;*o8Vymn{?gBc*eW{WSKVHZ7K@$uEH^0DA4+sa*W4Q$7?pjh8 zdyMlL{lGX<<1jlK};4OYDG6UOb|jbp(| zWff90(zmX`g&<=w%BnRV>R~JftYBVyClE%#%BTwpN@Lu}!HT!M zkCp2?u<~yIg(`2bvb=}`Ry+yEZ-bQ!7o7ucZZ`4ySo_TztY9+o-1luE!u-sBZ?IzS zxUZpd?ae$ahER)GA-Cp{v0%m9)t!7N53z!6Bv>7I2UZ3&d?s(qgB2(MwcSQ0jOLRr zB;Q7?q%a3QV!_Jo@`||X0kDGoNjY3Pk~I9UTqLQ;V|Y{RMPMaw*qhow1S?!77WTHl3O521;0;!41=Ylk z0kARRitc*}9dBqlB zB?`Z9x<(zWSX^z8$QFW?LgO9x|3u!E@aV!=3$S8we@tLMJ+2N`Dyp*McYu}SxR5@S zI#@aWpSe8U>dCOGAse_f{l)yd*Ie1s( zTr7;9BkzjvfSlWLihp=(q=ikL#HtXisE3R!Rr0_J&j(6DU?qxrV0us;tXOo{lDl|d zMI9NOfl`pM+{tLcZ6R36UbN_J6vDfb&9I=>3&BeEum!at46Go7Gk+rQN?(LrfCX5I z5>$tE@W2W$NIf|ku_BQ!w2k_CV0Lt=nHORKR-P_(@VeB&O4P(HiD@BNDNK+4=``Kr zpBSq3Jn92(zY|z-iq(?-j9Bp?IPKN~E7tN)$?Zj8#k%OvQ9C|ZDRQ({|BP6f#$Dlm zMy#Y%4izVWl~4G6Gd0#=CE;qrg6tKra>Y3D{-4OZ5*d9ZHUX?8+#egapBc9XD^*oR z@rhvNBQAuGvIZ+3Kh#eXwZMwj>x6_ccvqf{x=1I06;K31B5b{<)Pa ze1_mKv;Rs}0$8!eT&W!bD_&#AKY$gadUOk{eEjq+0jy|c6uCQXA!-pm>`wqI=8jGc zRcmh+VYLXK5G&-?BC<7DNpKw@-zh?@ST~MY9hd%k3k@Ke9D4!KQA(>Izt~4jK7T`Ty_KfR#_w12coxUBrI7+E&fE6ed z(fAr*#f(zPDwYK+>Ui!{jTBf}E)>zg3Nl)=4c?W(&}dq$ELf4fzf-`P8kefQ5`MiZ zBVG=ytjE3SMM;5`^&euUh%3R$%Ga_9JK5$-#Wseu(p4PRZ~Y_k1>`ZrmyvN9uM#BK|5B|&Y! z>sUbLq+$$N2vYjW9dnJP*ON~M9m5gCo>It}4*u6}t z?rrd{@OmRw2FQXHbwNaE2MesQf~1l)z{*LnG|g5Wte7t~vqEIS%J!uWR+ki5QJ;tu zo6^7vEnVX$f2zkZF|_!3R4M+^5wLP3wph9h4^|G39obzBR`e9Mr?mHj6+Onb@!q#! zrQcCcqYMvL=5b2=vSVO{T$x+!T5fdtGLFls(Stb{*YXo0StYQdWPGq?8yT$ZjaG_v zZNYqTZP_`>;*9InT?xNe)gOQGs=oCl{F}EZJyT`XOS3Wa#A2|5l!11RgO%~YEw1pp zK(60+j1M0HD;zr|nyah|d{?l;{4octaP})zxd!TB^^}!rbHR$k&ahH7V^^h%YhIlN zE5gfS*S|z@#Tor0N45})38k*C)jHd)ct=j$TYIw~TTHmD306{C`^kFz{AkxhW+`|2 zKmVfT>NV^%@8^J(T#bVhM?_$SygxnMAlD$xOPUtObuAkn>3SD=Yo~ulVuaf z=D|w;qHFibd9c#Ya7~Mu2P^%uUeC54VC8K@ae!-@;r+`K5pf;4U?n$5 zFR2WElJ2BPTFLhE5?GNm=Z3ic1S^t`+%COeYtt_aB1NW3U_~jtY>Ch5(fhS%oBuq@ z5C4b=SctJO6a!f9`i&8H=K~fMg=R`S7qF->?g@HDfQ9R*q7i^#k>F(b0e}VR6crl* z7JnR>&#|W?(eQy;E69Eg`PKIU#Sd3&_%^tG>3wyszl@VZp z?on+PV6krt^#d$27g4V=H=8t1`HPJJOMV7-gt&zWog!DGYMo{rk9gwl+M8T#KEWTb z+@rK|q5hK}ZFH!Ka)*mxQE6y1>t_R&|Fw+FVRA;M%@4sMOp;0V11v2hX4Xd)z~WaL z7FL}FSXAbak`Z9ZZ#K3$LN2<_McPGVz(VfkqErCOVd{j)As?{hyBL|YwgVQ)fqb{a z)Rzvekw*Avp_L3^Q3-AmS7rg0EFTr$0Kno;>1PkB02ZUpsIaaqz@jqQB(BZ^Ecqt` zCXPt}3wP0|`=kW0a2ZBvF%rPS9X3ikK?W=$rb^GfCBPzz$PX|AEdCP_#1D3nu2_&$A=@p3eQf176VpdWLwQ2^S}ykzeZIISg};rs4WC5 zd)mSTU`6Ai+^cM`B77;10V`!0PevlP5X%Sx*BG#3#fv!6y!Pf3Y#HGtVkM>Z3E2{? z#2ji$x$^|EV%gAU-p>Oog&MIFN2cLn;dyJc2@osONgBxl#0r^N_|X!q2uj1ks|&%3 zfKlf{_slM7(w2_q&gW&x}`8IDOi5f4`0Gc9}WArH&@2|7ly>Z!_qA2DD1KXD=!6+)213=MI-&y5}((Tlo(2X9)-g{3Ii)) zv2^KOV1?{A7PgxXR^%1pDeZk=MV=8S=oNyMK1X?tU0~(27$?o&1y+z%m|{(^VvXan zYUII+X8GKdtTb4WHr8s1L*5npXz5r@u%g+LJi=m)%hz3*ZmQ~w*8(e6_%}k7JXo=s zorOXWSfRhxOiG4#B|$K_NfWGSeoqt(hk+HA9Wp(mvPV-k(d;n`tg!Y=S80M3d1dKZ znoHL577keXcMHcP9@)YHEA))Mk+3a9IsxaZS*;U~nv41QHYGXTS=sR2W)K11s`#%lk51 zBYLxz&0A8@^**|mjfm7xIzvM0Kw!m+IstVcutImyG)aVaMJPT%ceA3tv~P{n#DAW$ zk_IdC!6b1d4Xn_7CaAYfv7nXm)-I?V^Dd`AL$vng&*2ZD!(w7_9UyYIeiY zjHIHEp_v9tGm@IVVa>ET5?B#3<$L0gcSRThqcvc~dg48)g9cV;LGnqvz{>y4&&-m= z&9o3ruo5rspmoWE6>GuFl&LgWkxt*W#Ao#!N(^N_k7~z1Ito^f#xkWl5G#krj_ziH z6%&Q{l=eZeV#0_U?|lbW1|3Z_IuI-KI8}ZJVuf6pUmOTl9^$y%8WXS*SUx9`RRt@m z#z8G{$h)#HS~WHhtOT|skFvPqCSc`W)nI%OSb2bd^A1*GlvN+h&dw8=V1@ZQFew?{ zm4xv@-9WGs_&sra_$XN6+Nsh4!3s1#%pb$grLejCRjUHQ3Y^BZ`C!E%IjkM5s9uzL zm9^O)# z<{+E5aIg&0gKR`_u*}4D528%K$^+`eI|n9MVY&pGB*MEQoIk*Hdq91;uQf6d|5;?E z3RX;lNs`KZu#)cseG#zokkZc`GyyAtou7zZ`C!Flk|e2yzKAjNWXHq@O_^Oq)XvmUm2(o;c)Pc^AP92m~t+C*H?(^b?KzPc%6J|{!9NNIE8(#W=~S>H@f!=@ z%>XOf3Ja8WK3LIa%nN#_!3y6|TO$>*BEc!}Q^AUuR7o$!gOyV_ZeNWySowDi2COI; z?{1k#-jzMk3bA;wf^S(IVfBq`gO$5g{P^8qB?bRx8l??ZQf6l*LGM`-qaapq<_=~E0j`UST!B2XwNM!o#BHOM)Pi) zw{WlU>AP)2aIegeyZI<>u#!TZn08=*6^0AmIPfEDdv zvAB{BR_H$3zNuj46s5m!P#dg3^(3r|4py`$#o}r@SYez@o%n#&a%$o|xq}W?=t0^^sfZP!w1Vv^up$vR(?jrJ zWkK9Q@6rYmX8^0{f*4Pa%1@#&U% z7X@<;U#o0`VGHx%QfxvVbLlqUQ*eGwPWq_4Ei(yM(Wy3{8EW(aIY+H(b#~MAJmB%hySMH zo=}5(R1z+!mh%OBEo#LD5FN4ow1E1MK#QYeqW$|eSSd?N%bVUHX)Y0wcX^H4RRBUZ@7 zoMH#Cl8w{l)NBGP4&~aSg0)~}t?`}~G`uT@dq1y@bpR_4EefNXIOChZ%DwPM@q55Z z7T)$PY7w5ZU6yG-2d7q=DM6`YunBuRz%;|CZ9#D zkO?^-H-VM2rR$(70#-K7A>|XW@<;O?8|Ap7&__t?1nw2pxU@&8O<*O9ibv*XIHEt0 zIU2ZE*3I|-;g&_UhJeKZuOWJ{7OZRvUM(T!fR!AdO}=!nl1(w-3~mA|4xQ>SPy<#r zO|F(iCinrVaOs&RgcD9yGiwdT-zV z5#Rt;vL{sHQggscPSB<#I$}jcUu&Ck;h;qORvPHvyccnmJsG|c`biNk~Od(&{$nITD(*Y|wvV~?w zkgw>lAFZy^0W0fDR!5x$EB1`gySMOLiQvlGD7|Mwwg`%As$zQG|O%m7K;wtph9j zsCW@O2dw0{=$I5D?~0^9$88_g8uAq#yoT_>YOt~{I7du83s%nhtn+mOD~=Qc_TW0O zqSL7!cKR$>SvQ#@jyMZea!xu;sKLE*g0ra8y#el(Fb+c}O%v{wo7TVSLe8dWwSWrv^D?FSouf`m#5X-fv3)H}hnsInb9`dd@epZVmf)!#5V{{X5 z+#IYlhd+)F2P<5>?R%6tSmDlULQw>)l)fe=G2mS(7&p`-f)(QT!f{i`S2pr`)o4Vp zLX<5uH-dachxbUWiU?NBOVpwYz{(y*7zeDV`O5Bsd?jDV6BEHosr_TKh%KU00>_mI zR;=_Qw42u+eOy~g;2~C05+9Sz!3yzEPD<=!#EN+X!`uM!mAC?p@CjwOS9E#L)Y>?R zmFe$LKtZgK2~ZOOE1c4G;SmL3#e9zO!|pLyDQyn7QI0PPeM}FxQG|O%H6E#nfE6wk z4>b|6QtCo9DMa3th5k}EF4Y?H6(U|^`hgl)F%Qm>5DUOcfseT_AFS{w2E0LYutMxq z4?kT1R?H`JBoPH*rSv3!LJjVf6OR{(-5cOu346>SrfI^xa{TcyF-;rp6`l7?^PW89 zU3ve$G=K)Noj1<8nB{E z#S7X0Pd5aAPTYUiO_yp7`HCXGZ0f-(u(D>kZ~|5`eb)HugB3lB0c&s#SW)b}42==6 zvSxC>I3g3Qz|71;Z9nW{4tKaS^9xcC7&`dIIKD<0`PSDqVj`pVf09ajN8b z)D8Sr$X5)HekqZ@g;+Vf^JrHISTRs=NudmY6$6Iz_(sTA{;i21R_5E4`L_@&WMW>i zCs?V*>2hlfz=~)2RZ)R5SW!0iYH>#1m3^O;V?Du&XN%kDChoWaSh*KI5bp(6ZsKj< z!90wz>do2mc~J>iDS7Rg$st=Cs^_PesJ6r@|BI;US*mmSb^q;xe??mI^0Lf zRi0pFc>@QmIJkw~04vJA=v|PnxCpDoo?xZKe!%SL77+~2xO##WE4`yv;a(Z2Eg@7R zR#FlN$Od4=^U(g3*a5_fL4%vQ0pu%jc^Y05C6KS^a-S(f2L(opMBkN@Zy;94guIUi zVC6>XIbuW}STUG$`(X#IllqcoFPjp$R|e=_Hs|49VaBBmpbWsuO)CB!y9C~q5*JUC zgYd4Lo9{1iyGgZ%e8m%ACVHR@Rt$poONe=3CC>-?B4DMOV!$0V04tuImx-tIz>2}- zen|xMMedZGyfsmZ+$#f%p551wdu4#(nO2V6D+9xxX;+bZ z6BpxB^T0}8kU`Qd#EOWnY+DWQ$^l7yUWg}RMUtA=WdK&H1x!(+GFVYgzqO>#?Kz$} zRq#CO2!1Q%D~91;3Zx^!O26OE@U8-|vR+}3Lg9gx^^5_*M#xvfc#i8eA`vT+c3FNT zSb0PuW*2V*D-k%|zMA!5<=+_?up(=`y=4G-SN43Cjok)TwzUk8Z0Z|d4_5Am^WwLI zm7{puY1DeKa&)#_A}RnY1+TXy4a2+gSYWt$8(7)){fWR7@|BH!y|T1zU}c-^6Eh>o zS9JOw$yRLxE9*;Sqq4z@{cz|JumY`=U68K~PDO~fft3P#o>}-7Q32tI>$Ym0L7ZOr zRk&ApwFQI-urfeN(wIT4 zEI`*}2C+gW78S>VmG?MZevK7ai7UT4U7!h8G>zk129S5f>9b~R99W5K86MrlAGZQ4 z&Ed#2jK1|dyzLCi3aq@FEng6ogO&2vaY@7Qu0TUXKMt(KeSb1;3i--Ley=7i4y?q< zJ~20fd_{-28B>b0W0P9LuQd%MCF9Hu5n<+ zN-y#%+$%%1<%IW$m6XIGvK3f~JM<_eb_lV8v`-8mUx_Qyh@U7y?iKzs&9=9QmFe%A z$!`%WWJ1wLE3opm^jvsE5m>RB8~$N81Xf^##-;@Bl_7e(&3U+2mPcr;z{)!+euiBR zR?1!COr9X`%0hp++dHZ?F>1o&n@tnR#Cx z5C>M?Ph5;oEdnb=K~_mKh?Qx&rtN#giX^@$Bo476NiFKK0xRzY%;`o=u%ekhv!u`O zNlBc_d>*BYzYY0HUFergX;rXd;MW$~l?hg09fm?-ffW=3E4Ys2D=bHphALPI5Vx=3 ztAdrSBw|J}8my=w6EG;Sf-Vo9Dp&zlRv62)VBuY1}kV;W3zh5 zS1NlSt*AnS6;#QJs0^1ZUpv!KWw5fs_xBFSR}80A#AvXRY0qkcd?k~h?24||F~n7e z1~2d5!1xSU!BP@gWE5CIAKFTZWg%8j4W?!_NWPMxAv56(_sY%QXDiy2!HO@8&m=2@ z6(bTM<0A^JD3^MKMr42$)ST&(9Sf{vHp8F{+$$`)jEyJUD+E#+3xxtJ7;5JfI}@yA zx}Z&r;a%|%_h-6cs1G4u`FC0dte}Fo!n6!n$?!q>s)Ci}`UwiGpga9TPiKG?)Z|ui zLda&@WR8PIn3_ybwl?h)` zY6e)z2tp;Pf|Zad`U+bWuwo#N&j>+-6+>}qMi&aKs0avCjVr*)|BcTiPMv=qRf4|_ z`AXf9FXyG1h?T=_N4m~~6y6IVrQ^& z5qA@+CSb+6JXlnq2v!u0U0Sg4uGH=QtQhMIR-9W*M=Lnvx?tsAcz?VLSh;}jd5h8o zD;H)1=SAni%K6vMNv80w7>(C$aRw{S-;Ku`Az!)9=~bjTgB534WAl2*S1LJ=6sw%U z3e-}f-~$$42h*?;u%hU@rUUX7L*YfSGgvuq-`@oJ3e-(poxzG#^^st>SNdzu6D}fF zQWE>28Im9Ed}wP*?D7XJx(%l0HAuelzXFQ+P0lmLwi3jO=(}Qa31Wpz$o;4bR!T}e zpe+JcbmvT=e*#v{H@nz)!@bgv6i?t@A;hKiqjbT_1!^aCB~DoSvhJQ&`_R4=xSX zV4l?yzhJckxqJ3r!M?ad?80heKM^ENSnXis6BZwQZJt~$rRA6(*U={?kQ>gq>w|AV z^9dEXxC*qsukZ#I#P|wJpe~UWA!>nwMAr7&k-(PQ4uja1TJ|MTJ+`I6p$rRXYDc=! zTD7Dk45;B2`QY2~W^xbt;Cu2^y(JE056mutM1)OR7gQ&>llQs~9+`S#BepURhKsP@ z?+|;i-&I%Enl+(mwPsKXVUJ*C>mYBbL@!rR(zhZN6z6kN&K)KD_qznp{$QT-{61OL zWjoiPv0OvhwRpEuaz3w8IWNyr?uf;)uQe81FZ}24Q?}Z}>B8~l+KJMC4b|LPy=eF) z4~%S0=rL3uM~rs#HoKSUK}Snh?9=3JR5$el9ejIg-`&nk z45rhYH7L4(4!$$hiVjvi>D_cM(+AUOFj+hG1wC04+Un;ee2IQ@hjhg+M3{}<>AQ55 z(!*B3{%m3&B*tfL@L3{fUCVJZ{8`T)HvH)TD>ej7T99z3=oPxBPW(4IZLO`Z*b8M# zXxjJFlhufcxibY@HmG!OaU==~a_C>RQ(w_ZIqBG?w#yMV-s^v)A!N6)PV>$9Zfmt%ngPjF+;jkJ&+1g83fSX>=qO z)DV`=u%$6JTW*u?v+0MZwUKa_5~XEmR#> zJu4d1T?b$qNi=pL8b%rof7%J@=$*Mw(6*jSciC>7TWDLar3Y+N4inwuxzx%&$!SCP zcrA5hZs5Sbh@Rr+OkLhNL&2tB9?+uvM|oDcLwVM> z$%z$wQ?F@2{qLHIkfX-rGXpDqUicn%(!6o6VSwfX!>!0f%_;2Xd?~B~A?55qwrKwa zry9KfJ9ClW1%3@Kn7c-_7gOk6enWI*)Q@X`y?px^_K?r0EB83|a=r@IzZMs?&ybv* zx80}wrYHtBYhGZ7e8^V^)Mx5C?cQZ!O%+w^Vyfm9FEH~4y@MO>-5E#{p)Y*#Npc_e z2&}nR?0kFH=;7_z+IwXKb$gRBgt~UeeF{kb!yDa1R$;pH?DlQdtIhG|U=`2RmO?q@T)qSM z28(fg#KE+=1c(DH+5wqjsZHg~99(ytGDE2Brl9{KkmE`3S zd^)Q|i7@GP0qa{s@#?%Oip6}r?PvVrc3|Cn(1p*OQx`P6Pk6(wbjUkttgZKh12Mio z&J8ep(sXQmeq0(D_DS2+?fv7zK%-CE=5C)KR|dZN{M?x4Eh)~s<`c87=k}5h*B1k0 zjXpmsL~a=P&38V;j@=M$k<-uT zsUI30*}KP!T$>%Ce&{Lu12;0_+4oW1KX<#uzq2*@@fIyFmS<~T;6kQR>ZZ>q=i2?k zH42%>I=us{C2@tZJMJqHj>RV*};`<**5BbMZtD?wA%Hs>)G&&{@I(d%)p=2x`VC_g z8qyQla^eLgpBJ!WQt!kVh0fcxV^VkSh2JPHxJt*n@G>ve;?uvQIFlU;F>*yx1e(6y zG4YO7>JH`}dv0uc@kfq#-xj$`*@4RHdeK{VcypEIymq)gH4Az?Lb}Ggq#l|3_YRxu z68l1hHBsevBw>Z{uaWwO=S!u$2gIv9{tlZ8a<|`g$$>(*kD>QS`%Nalm1v=w-9;-c3Ts*tDCP5{Z*MU)R(AT(OiM^ zobj+`yhtg)`Oc87i^j;AI1i`ITKXSxhb>-^^NRGWFXwqBT0A9RE_z>?Rdmt%(AcPR zdBwD=R^&%qKzUVFE7B*q)y(^0Z90-tF$8}U}Gxpl+n=$^p3X=|GL?z5v-wmFTx}uQ?v(jTfGKLfqPbk6w z&5ecdD{!$K;0efTFu z$y(_jmb{inYC^1ky|}yQXVr9w);}MSks86Ba7Izrse_@k9pAUN_ptm$ztN=C>ydSt zN1;J9X$_c~j}n{F{v-QBRZdp>gm^Vgj(bjuv5nHgg5z0=v7OYJp~T6RQeThj@9nR= zo8cvEVLswr9(DZ^?A2JP8q2m=kRE3Eiey(ts^bQG$3>be@5q%$PvL1hhIUdiSTb_3 zy=f`nP3cORVwmeYCqe^gUpN@4R~oaSO;zoGhU(MI1cwvM1Kk-C!<0tJ69;>sHS#2v_vg7v>@ZI6hHT~ z++?WOkp{P}+$2%#OKZ!0B{vx+K155-?U3`8E!uf|_4DfyVr|+(Gug{h#Zi>6B#oF~ba$9tEnDyI5Y*fw4=$CR{>0{3A^U=8>_j9~Ob(0E zXbNi7k*PhIQjr=|!IY*Xhvg(^69IprM~cZbH#N9&)u?|?c}c0}-IB8jC!J$)rJ{_? z#q}98vPPIeS^UW<)JoI5cT&D;l>Xj95oTqhI^{|4XOY}WL%I5G;%drZ?<=WE^Ctg# z^Y#~09Z)*?kJL`JiDTm3l5UoQY}zqWu^>jgnYKaW=U!@*csuQy1{}D@#fG#Hjh~Lx zXz?!E8;vFVpU0;3SK4X_bkfYlbs2Z0U|w2dsjv5%6pUX<{n{pO%g_>SSox3o(r_>Q z#B=Y7m8r6`JEB&U)!)6=|KEcz6s1e?BTz0$ZY^!@Q{Zf78z3yY7^Q2;@ z(wMD97AYnDRR^^<7wt$X&z(M4zVVR7=p)Vny+bO62g_lTCAV7St@OmTJfiZ!v9QXU zxla$aC`Ojzo^lTB4XMnO9*Y$PD$QJ~CwKSDxCS4a*CF5Sx70H=PpN5t{PvyRtIIL^ z4QAt$N2H6jxEJ(xT{_!8RGHu3px1G!(>Y-$xfKRxzO5a*+&;9|O{r_|Nu|@S-*$|; zecSnO?}QVowA?#!mEHMo?}T$qDcrSSht;9Llk-x=J1=)0n24EIDQ>$Ae;1vYmoHAf z-03nAdyg2`d0^jdy&c7irO&d${sk0ud$c!3XI)M1 z0X2djXo`GKV<CNOgx!Od{ zn-|A(@x_cXD*G+-=jKKp#77cq{31jtN~Wb052KxT z*&G+WV3o#F%5zS%9{fF2>UW%YA$KP?KyT7gQk46gSlHjH_h!d~L-ji!u--Zr!>aVb zw9xrI#bum`;tiL63f}6Ayo#?~`Z@7dRpe3}a_Q&Gw>w1v#cwYCoa88ooRn-YEsb+j zB>A}^2V-_f%9dVpHt7AbfKk1j5@v4C!P&L{lrD{N()D0e4@xM4aAM}PUzeaN> zVIQwol%WzGP`_FJp5K^=r2x8<$o+fXz{;hN%@J23%EY^C6%Fjr29BNR7__4al^#I z1>@jQi9^0>ZocvTyOdn63-_psX`A2$aThGEd{^1!H@mf=MbNKfl%x`5l+-GCBJ!#1 z$f`3A46mTKzhEb-obvymdn({VctvAYT4hhx?@*^2jh?%H24xyWrm5leQ5M^@r8@t~7VPW^O{ðy=(TIZ6WiN zD=V{jxcoTzV*4A>N|g;QOMhJFj;n0Ak8{E=Z`T|yH++8>nk7mqGx;c$Yb{G9+$_=Y z{9(VqBjmeT>Ms8_OI*0yWQGGMEOtKeK^8_O_@4M4h0Cr}32qiYr1Y_!W$KLJZ|8gB z`&r3G^(xUu4XsNJ+(cp*S59T8Kh>=h+9mlL{3o_p2K;;^;;Y!MyXOD%v1mr6XKjf8 z&jHbxO3%7C{y&FAiz@Jkv;R^*dsyXuPKeRZsIa;$SeI7$)g-=^1#=QvJw`uc!{|tv zMCDgAjCI~?dd1e3NxgHq*D`80GCW$pT=*S zsta%vymgl9d7EQ3k4_)c;OB(LYDO+l%Jg%TVlD5boPSchFTCuQAcojngL@vaz3kR_ zjETP>=J-NF*&WV$%i)%ki%*)lR+a&;@MG^&EThU#POs2Vec8T0Fs7=o&?P1iYtl@9 zP) zJfYO~YI%%iPBHn_ZGqa!J?m=ap73^CCWHyhV*M=VUeoU%k^ z#d+nrH!A-8lgAMz=(6J2i8oNN-dDE6xo6JLunj|+0Z^alXyS<|Dcsd&Su`m3H3x~^w#{;n!$ElO= zWh_IKmzQVGKVfnWEkjy=Tz)df)vzSI3_QAUP;&7}TWoiDe5+r0_?^P!*m|YUu`x|| zM|r8D(;6EDOCvlzn4od8w7Dd}N(|ndlYNA2LA}=5BK5Tz_w3j86FCvY&Ykr*CD($oj%CMiO zaCH7IynQ*cJyWcC)ku98o*Sk_hl~YAXz)+M;z!wGF7#WwVswW{lp~?52i9%9XyW@~OF#ust*Tx#`-6ucR}58S>J(EO|Ok^Ao95ZH{!d@0`3- z?(&Gt=g{)@N!GuEH0|T8e+OyWU$eC2k&a0FD|;@BBAw^1bxeMad|jq5<700e>8nkP zmbH8k{c7pdNU|DEmb;gJu1^rA<4G^pjF6A_z?IK7CHP^yG!G@hJWqmopYXJNgmtOz zT(bBKO(w1V=IwOwAt%5S!b zr$`^RrVa@`?>-hT$cK=YD*fh2S$#~+ZlhrGQf=RuW;c`sU~C6Vhm1wD`-@nM4jGGd zv{vWm&?|mlLus@^`AMa(yOw%cba~6%C-OFN;=44+SmbTu#SdtZvB-DFiCbxqvB-DF zi@W+DW06mfTWaoujD_A~J@H8l8B3q7=C6;WGQphKl%Bq8V$(13Qe)pgn(#A3nsZ6* zd|~h{y{3HizaI$R?1PLYe`V0KBo8Oex0Ma~v>;6#`9yMQ{wb4B`BEnG$>g*7kg=#b zJ;1%>LdK%Xe}JES2N?^qF!1Wz`6Hu(cLpY9?J?LxiQ|vo9XBbpY61gfLG8U5)WO7|TWGp6gB=W6%$XHCUCcF;!v!5K| z$qbnwH4`YOblDrWa!@6oM#4+{c$CQ9cXmcQK=e<`2+?g;bdU~Q== zu6FRa$*uhLCST*tpK>8%$(Ihqe-Lf5S8Wc$&k~C$9B!3~cDy;^alE8GUqA4%s!SmM zqsTxNG8U7oW$!?)O;~qzV~}szzW;g5)aY4OiO4CgJXZm?X)$d`1ve$0iY( zFA1V>I?lxI`xFcitGr@4*FYTz@m3Yk$75C|ou_z?&)|?eW#zODI zKU;7Qu@IBm>6`Hu%1)pG79|4Z*w+dg9WoZ!GU?qDIm+EbP8CXHvQssM9;yVehuiX=R zCl*O``mWs*x^v&%C1#T}9dCup^i=!@wm9@u6MmvYA(m0(G*+drNBOo}N_&s_X!u+6 z@1;43Z`I|1Tq$F z8?m^84jBubfv3(Qzv4k3##85nkg?Dk@zgIu$XMuK@zk#p$XNK-@V{os`Ab~bD``9R z^We`pi2@`26#Q&r>hfMma!xvad9MUA7DmVJIZ69i-J01dUYgDKMGqIvscAcyBYCz? z`p50h)1ez6V>zw)zUnFiG8RVPX-#2O1@2G2hfUFUasloIpKQZeOv%KJMsB{Q{{w&6 z;(JQou%1m>p4b1!)V=sK-T!d|56Ss7XNHi&9L5NvwaT%a&xb5Bg-T_prdG+>FbPpa zQBmaBVag?kZH_r~RJm$4hfY^*bk&rr?)UoLkH>xg2R&+iKJWMQ`+a?1C+(NhE>3{4 zOq|F2eShg$J-h5(8^3~!tmby!5k^WD>sc#!G>>!zVmVCwD*>G`2*#pU@iXCL{Uv(8 z#|b?hN~%iOk9Tuoc&7q1{{D}53tY3QzZak3Tlp_t2p^Z7YB!(!hWP8`H~z~P@mr?e zF0QE+J1LLIJSoq2e7b0jnOT&qH9Sc})SsmB9iI<^u@q}lX5MkZSc)SlGatBMEXA8B zGw-=zEXCAfK3+vwnKHw5QO{9$k~`0Rb28y)D&;W)3s*!>;Rkv9-vFQ2zAdd`!u|Tbf#Zvope322p_TQE22{4w4#gjekWw-X3Md@0r6-q=m zXE~&{b%nC4dp70TL%~p@umU?gBu`!pI<>zUT&JgfvfJd%cmA1Tg_DVh zs*{vCZmb>{%Sp-tH$@MOarq<@j1K7X0adyPhqAvDBUx ztUg(UwwuLh{O@mFF00(-=-S^a@Hy6%nSWA>C;mF+!(L!NNhhVIgRzLvWXP0WFcuMQaU6<0Tu15+ z61A6_4zAW}i|BnKYOii7dRLZwyf>6nm>$0lXU=}fsZPHoR-?`9USl_|khCpbMvSll zEk9}8Yb1hJtq`Z%BE#EF!J)WS?GL&a3nn z;-56zD&Fa|`StdQ{LKF)BC!^!kp!J1N!xp`h~(zaig>I=Y9^Iw=p=dfwu`XxKZ?yL zvRP}uSj1)!?B{F1Sj1+O*yC%!Sj2)w3l%O)+NGoy}k4x3`B4KMOC^kGtOmAIBv1EVb6s4PswMYxDlg-($Mczu$cQ#A4+4nwx z+{wQp_G_m#*qiyM(iKE08oNuUk?i_4dJ+OxiWa+bO|68$nWD*VS`#fHaQ|OziNxx* zRSjMPRF=#boRSh8=)EO!TKS>Gs<5&IKJzT{TUv0eSBF!Z{(If36|}9Hvp=Vw5dAE- zUo^L|Jv#3{m!}AcEUdNbKZ;;1`X6$zScX1lQZYBXQN6AwL6O5#^vFsurOA?l^XgrY zhBP@6VTkKev))(srRm%beLuA#ml{OJj-l(Er)M8I*Qj-LB`9_59eUz&7ZIO@vz?33 zpHN%OO4M1JIlGwMNII1#<4n-t7?4PL_WEck(ha%=V_yGNPJ&5`pcq}@jmO~EHCHi zEvw*YFgJ9zp7HK%tTRmx<4e%vxuM8J#)=ks|5V zsDE4Wd)8#OQon3WHvO31EB#An4{i7JmVBQp>zqR*4w>#j+9>TC+M(Zhw#(GnS3V-A znTpbH$f;IKzF(=d)T3zYGKTn`OJ2{&)YnDGVm2`b^cV6xoN-#5cEuECw_W5 zU(YDjuaR!|$LVk`C>A(d$VbHP7>aknt0&(GS6f;+Yn4R_uu$kSnDf$)R%^|o*v%c# zk5)gNMbJzR83JR`e=EP@JM@g`lXpk|ja<8LvqHN*gQW<@;zCj63@BDH78Lce1y}}r z(ok4mf@7%vsV@;YG2nr-;@fNieolcicy>$ns^gp*-)kt^<&fM;m0%hB)~dtYX?^&7y#BG=8Rg`|;mTO&qW=s&>0LRGUEhC3X}?FKp*+>!p}$W_ zUMbfdYvKHt$-@_Y>J zRU6zB)%dsb;7yr4OT!x7_HwKt=YrA%m3$X#Lj0Jw=#H~;E?PDuD9-jvg0UFh&QIEr z1;(O;E}pb^-b8ZC`ljUM`p&ckx=ch3Qq^`Dq!vwD_J`D+U^E#F_gB!ZN{h#`_0N=hE?NF#R-q%!&4z6@ezYk|Y zm=m$92$ zWkYu*ZeIR&Ltkp8+nAdYDVbqI4Drs#+Hr2@Jz*B+eRr$Wj5$x=(l4f@TgW@RJL{#A zF3s8lhnSs`EOx6jUvMMi`jwRy3dEIa3VFd<*_`*16RzPZF(ihTmjPaz-<9XZ?8uw& zpy+c7h-nN}rD&?4gKWfUQi?7R+$S4yM)H~rdQfkuf}@OM1}$m_44R90hq8_kPiDzM zFA3SNTW>Z~QqO&QgT05qw0}?uuT<+Uu;^b@>QRh#7nt?GQ0h^7>n=d|e^)xKxW#=H z#hFmLmltjDON(@X0mh>AJ5LbLfLe+_g(ak1fyw_0OQ4p5zKK(lccT7cFJ}KI_1NDU zU@REQ|DPh1^Vv(xccN$6p+#i?_$7M{$x7z;)>kH5PxcWvw#jh|HL$SKh%W|1wx zSgcrFOidmbOCEnuVFMA2Wsq#ls?#WleIAZjepxxe1Y==R@Cz~+FqYVsyp_N)FcwU7 z(aO#-FcwT0i=Ya|5;}G-wnrI^MH%quSS%PzY+_zJ28`uD4Lydy8jQt}<$=M|z*si2 z;xYJQFcuqD7^a5?#`34Pq!^3^v)~7vC15Ow@$0!B{ZA zi&mn?pzdbuZ_IbbjP{GQzW%Wf*ZwpCV%eUwJL8oU5DVg|Pf}6_5Q~(&m%kMd%lf`q zDcW`*mJ960wH_uwELCiJ)}RRxOJ!R?e<=cp#RO**Qh#AdlFacuO zPLAvEL;$hun0A`&lA7@H=dP|*-$1tpV&OpWgm4asB_d-~`L+)bOUdPv8S+v1!{!Rjl2moazO{Qp5X@^hGya>cn zljPc8A_c@EHMjv;1;m2ylao5+om5J9?6Z_QBd`lfA0eS)=Af8_+J z&pR&57@oP%>nS7_Whbe>vuY~OPWowVkYzHm0{XEr29L8!;nUM80UN5T+q{8T5WYTG zOCT1**^^Rn#y~87ysa4$%D4@cyN_n%Dg&`>AaCr8l>%as()HuHW~>mmWtb@A%7Iv% z`fR1tjKZtiSm&}Pj45a!7K_1U`Q0}e*VyWHjl2IfD#9|^?-V_ixl3AmzDvjV>$5B8mD0x4UD>a8Rq~dW6 z>jcCytulBGhX-O|lobWmlmW3|=TWGtt3WJ1FD>Txp@3LWk=JpXfmqx}Y=dgbfmnP; zo}(sw(xRwZiw*Sk&`1Kr(vVQfIi~`|5=f{KHkoj!D*ptfZUhxTDnKl#L)%HQR3H}Ap&g_HR3H}A=k26;Di90G48>~j&${UBMe>v0rGbKc3KfXu z(&HWPuYYA^m7N+{55#i4Fpz~(F1cP^X5)3Cz+ir^3?eF(=aTo2>_Y*upuTuvY=Kx# zQzZg%l^p#(mChhsH4w`NW<5&R3y8&wPo|m#0Fa1;Y6TN|0wlF17cyAyUUw#0!I0%TvN7!Hz1jHh2Flh|}V)5bcqJ9YiV)0%H8mSEe zV!66%H~+j0h$ZM($F={E|*GBj<&eXj|`VpDU0-&_d9Vsm$#e2BGd^Ax*fKwJ|& z2E>AOsgkYO1H^KEc1iZ2EfC9|gxV`EIW*1))S0XYVli@gA^W!t5KDZw6W2}?h{ZOl zq8<84>W;DSnm^-!SmKdqNxNCl55Usb%#8zLiAR=@_ORg9N)|?QFb;@iBFROV7TIuU zJjp93(iVtiBFQ6XlPwU-xc{&Nyc4P}-U2HEL_AZ{T)n){X+Pj0z|$bsHd- zctQ#PS|JdNP0vL>G@2AR*tCq%&45^hu@Z_25R37^E7>n`KrHdoXQ7$|h(!}W#EZ#+ z_bGFVZ79`1Ec z;psRamiRxNB|t1Te>zK`nWFH4?dM^-Lz7fB5R2ot2_TlU++L_80b-eNtm3kfKrAW9 z^Yu}*A9dSk11ZQ7AQqd!07u$55R3gF$+36>hy@d0Uu`lE#4?`dId1F-#FAn^nznO% zq0VjmyrbU)5KGDOSatk75KC$25n-yN0*J*CJw%G30kJr~nxk(3V(CCuEGAR|u^4## zMCv31u`IkR6LSP&alF6q?mQ67=EY}@t;0YpRm=CQH>UuxqWvCWih>4ECq-q zMSj?EBM=L2F|XP%1&AeuHtc8*#B!3Rojg+s#F9dbOrE(2#BzeRDS74s5X&)|Cpe6X zur_6;)J5H0-Zr;fNGyw~$@d*MWAqzpZHItZ9P6t|-flhk38}CM#4^lJpbaJiu_WVaxcSIsW(Lx4foulE zl1htDwis#00>Uqr1Y((| zY+Bsp2*lzD;S&cS7W`liQac5RWrDwrrjP=}l0qrta*;qRDU|cvWh4+w3Z@AtY_@yCM$6BJQDri~wTUmE_&mE)K*Z zGvm)@i2||6%mlKZivqF8%mlEJ z9EgPgbtZc1KrB1iouaksKr9jLCedhhAeKn>1JSqYKrG?BhKw!hKrAT1fxcVfKrHI3 z_3T=4AQti8W`bjVKr9)*)mB@9Sen?MGfqfSvxh{b{woL}z+#9~Pz40FAJSS(&P&h0P)V)3d8?AS33#B%PD zXH6&&OF+lo;U``|EM>UPxdAVli?m-xC>b=A#z83D1BQSQtgFalyJBSa*J3*sSgnK&KlB1(DOWKPA3_46H#Ty3X0*n-E7%Td{Y~tB zsxV%H44dbNz|W<*BcR+QB&{QXFpmncztM36duJprq$YH4%2eIR`H-3@;e=ZrADR|g z4pYm`#9i}UAvIxcMaxa}%T$>r*qsS(Rc7cL9N33cE5fGk3TIriS7B3Es4%yfIfa}t zgS#js^;TV^Dx5DNQM=~tLf|(OnzpNaa}!WY*vp2Io)B1{n~?tVy=Jfyg~;DXPAG>l z*UTf>v10^Am-5(~$VevrcS`Ep)7bLU$iatN1_nC=JvHV2arP`Y9)F;I0`R-Dkp4QnR z8M`uq`V;#QGl*_Pl+r1wZBj8(CFDC<^sK8{!F!ClL!G0X^+$}xP%($3mOYy*qd6If zXe+)P`%lg)0m?0$o~6?=M}tq!Huk1uWA2&_x2lE zkBkyge0BbT|63r$kIdiXITOKwvL(cC3w|r&x7q{GnO&S3#CNJNz{RgcDCEKaEi?XG z#BcKu(NO;Yw>~9lnu-SsyK`HJ_+Z|C>6_-*&~0)7L473Y{isGuTA07bwq4=?HmcQE z*?Ytr@{UXUhCmKxA15_iPNn7w&wI*$HbQzLbb9;7&}rA0pzOuG6xEsr9wqw`LtNS_ zm|W6dz%-J!3Ly(qB*BC$m%B35q<2DuqPm@ra7f28W0>%60+-2P3fZ?3gfy=!U9?SG*9#_+gV zci1d6h^Q-=Qv7FKq+xTH)(aKnWq7#hiJw&c8Z;R#>u>Gfw_ZGxsVMHEDjQ@?DD>x5 zLJ~z+yjcdjCBYbgt+*5i#Kt8^D9teA`=|dxMWbSUf>lRb#uw4yrQX*?(CMLY8iZ6CG2LxH4YSr6g)R; z2@s}6AO>>Cf6Co0l2lKv3f{_mRCkH`&CIFsovG7}Pt6?#BP|@Y@_RSCedhn+$d@0x z+3mI0VQSZ2h4N!f-5y_q(^^M<2EVb=^k{b;3H6%QilvInUc(Wfj-!;@d)VNH% zOxP}R7h9IynXp==3%pAjWaiv zj`4FxwuJn;wsg4NYyMFAiQvzwKZ8GqtrU-laW0RznEeh}sog0R?&78@=aGO7L|&Us zkPERx8s6O3*fJX~XXol8H@L6y+LgQ5C>I^MBX-s6eV-SuS#y~zJ)H*@()QO;eCyPU(m^qeZUO>+O(rA|%; zq&}XGh*dAz#kABq{ES@1dd<|)`XX!(IaK$6rK|PjIl1`V4SrP7_kG>bxIg)heQ^67 zpoDV`p)=(Fb)O7umCM`+vE7Zb&#b-S(-F=Cv0Co?$%Mkk);k9@iRa$Q!)BU-xIXXoJ8_~}NEo46~qR7jx#|ke@jX+|=0zXCf<|Y@_QYSVx zPqqaPEOKPzqW23t2VQWr<(f7&Kj+;N_zry6IyKqm-u$f1ci_0(7V4iENoGzFu{7tA z*4>0vx7C}ZCe}f%dj)Sdex9TUalddBicare_2RxIRmK|hVAo)~)Be1RPP8Y&CKFOO&B^J*2Vt0z zyFtg~Bi8Nd6VlD`A5MPC4IdLPLqkbCHOFbF$GfoLZD$e57yl(AGJ~ zEOI*ZSVYI6;HcB9kMAzm*T1{k**Oj|6YBq>A(!qIY)XA|V;OzvZ`Wg&HmAP1wX8c8 zwD_0PmnUft$y*mQo%9|92R>hE8}~@Xnf-Y8uBpD3_KTQKJL9zOy@o~3JB^#Mm9$x6 z7Hu%K@F}0JaV_>Xl(4g?P+{`1@^UOEruu41$GAzVuhO3=$P*`+TaNfXaon}o$#J|i z@sYA^v4CTA$?9YEmCg++P@8hW$?nR>jj3agW2C)uZw*XgBK>ol2A=S#yd!I!oOMdb zAs)?!QzcaI!2?doI@zWlE9+xhXlYL73m*xWmycXJoab<)=UU2@aTr;g)ZY`Dmv`}N z#*LgRK2V}5mzP~G>AhbKr_`lf7@v3AqFVbtT$nb2GGeEmr~GnSE7QR#^nLg(ZY2K_ z?aPtX%dth_O|h3~|2myg+45cxF}O}gn`x|5btG|Wr9cp}7<%b->LK-q?`N*0G>k(# zvD5FTD;47pndnOoj(nc#3j6e=D|&H@6AUX(3h!6L7Ed2H+AXFUB8!Y+Zzq+Vsh113 zODb%PyL|sltlU#YJ2_L~V?R8jpYFYK(myrhD0% z>n2p>5kZ?d`}k> zyJU~5LVL(F_Q7?B)sJa(KVk1$_W&X%Cp5ZWvp3f@^qr7<6VE%vPw$hE!|#Em{U9$U zvsi;1{1r3#V(S*M|CnpU#N=dcAkmGne%fm-Ho%k=E7sK5$0lJ$dK1KAWeZigvV{)T zeZ7nUC@$F}%z_xyY!sCBK@P<5x>j{u()~W@kzH5oAjs%bXL_xRw#N?a-eI~fB-2)I zzcaL^RPOCnIWGdal-+kPi@ZpWXRFR?OUh=gGvMDmGrg6#UiHG67qWBfqyy}&2YU%( z%d(+k4)k6&bde|?)}oBEoih&?9Fs-c(5KlCIGZ!QWYKnl9zicVcik2n7;YJCo6J$U zQ)YPEw*FpEvDyMFS;{`Pxma|;VcBMzwpYFPIGe;EJJQ#y)&9KKN$jKCs_7{kUgsIP zb@o)Bb@tlRM4mdosqfahdzv%oQ?~R0wr}Rcf-SPY_CZ0(=>lPu$xLig>vVk5!@kq9 zx{4BVx;BE=zTbtOy~buYE3y5I5{{{|M^)o~jz+*E*CWb<7aR5WU&M~PS}NaR@{K=K zHXh(WFhJN&lE_gBn1FVYk5xFI>O&o8hHbrTP+AvpqjN8fwewlVRh5bRmyo2E;brfd z!yOmsy>r>dRyJwvbjI8@V=LRVu6(Mw_eO`QepfHjwfs=y78}wX2%KmsLtq3FAe?o7 z`b``z(udc2&se;=kZ`yzGoMyhIy`Rtpz@R1Axv7+@N4fk=+D+^4Z{z-k1GG|^UXT# z-tY-mztZm)yC>%Ulg?CS4a?bEMfA5>d@fV+F9eQky8O=gjDBqPDWF&5bWXc4?yx&2o zQqsqj6QbOn&u}%fQEt63J}KBx99#q#rq_ZnAJtp-!#h}rq&#?VDvDR|5XGln&&GJG%b zPX1x$G2>f&j;|BHTjdsoIJ0{gNbRacW8WcmCa`6Bjj zqF6zEI4)@8ZSr|$r=>lj$kM)j`YFeeLnv5`z`Z5flFl$?ETi`Di6lsmSVqP1lVw!H z!V?Z%$<98{@szn`LD)OHf9h4gom$JCq)YTS{c>ur!V}C7-A*bM52r`m?wow%EFq)g zW>Rp;@@V^X$PAGmT5vC-d)G`1-?!jc#3$qXk)LAthYG&u)u?^BL#}21Wd1MO!Gza1 z|MZ5;222e)N6Y+Y0lTw>>}~6z{@oZxzrW0Yg{-=v1uib;YW5)0RHojtu-!Veu+#cY z|5_Pk1@4A|QcHXciAsLV2~w-QD=4hoPrAr_uLg^A1mz7!MlD*!DuVKoBU@l}B|O-2 z{C1<%-*quD=f#7<)e>cB*)KEH2r@OLuYuT+96w99Wwvqn*GH-_yZ5KMMDU0sU$DvY7y4J5pn>_&;+N&oh?#?=V&-4OTIPRhzuU=Mk+;c1 zoQG<^JIzF1H#QQvlG+~B`b6Zx`kO_uu6Ekb4k8cK=QQr)VzuuiK2;lh-56WnQUm~O z@7qD@C<=1jtljXc`S9{a()~y4>?hjNt|ufcTU5orPTZjNS{N;Pl)~t;pXf~Mc@)PH ztx|v0J+#2&AgU@}b&oB~+ncqtP0hH=Xj?MN?RUPUkFcKA-6=YdNI1ZCBK=jQop`%_ zDs(1+YpmTy>P(2Q|Eoye-ihjKzv=ZSVrLVbxp2$=aA(>B`oO{^`!5|YeNyke99a0< z{)^J)gKrKj&T8GXMnde_qCR3?(G~lg zml|mIX~=wR*9K*=cYm!B8{4cAAKP6dWpAv@+F@_WMw3Q5`ECk2LMQoZD}_s@@l*eBWn)Wubk&Epgf&sog1lkZzCDZk2rN zzT5D&QhwM$hCM?2#!zB+oZ-EIypAGA`-NAP@ts6rqe=9=l^pVug-Cn#SCq)^*p!k- zOnc}ien}vv+<5ezDPh0m_Dabks3Nwncu6?49K^p+q-r1f3aW@>xL(@PFQND~HnzOz znSGDy+n0hjECu`Ld3WrezYIUj_28El)e5600f`-uI8m#5m=}HU(4DSF3rG1?k96#h zy@GDym}S4J(=S&eKXr7yUeK*N!NgbnJoqbiIUSp@eD=}lgTLa#M34FhFyXB!fE7pV zt8F0?04wr0H({RxtUT!>$?tarSUKiDxNVmNz=}@w9D9!hz>2)@k?^-dSjm+`*31H| zOwLBgPaFkUIXcaV3Caan(WzPFQE~xRWO47Y0Ukf+083m8m8Vup%rp zDbEC0k$8cnKNrG^gabB#3$QZP=OfPsSQ+1TLP7@#u!1~#bebuIm4Qf|Hw!{o8Q7%r zW*%VWb6f7f108@Br|uV!?EqN8QYL3z{-jRyjtcTJ0aheV>U>QBSV^*;>{~AZup;52 zBbx%SlEkabERhFTK^7jhW&x~>L&8KEDTEcI?@>Y40Km$hS`i_v4D6F{*aWb$$VVCCavoRW0N@&GHDz1!XatT^TAY>*Eu zaU?(5_8DMBA_X~i#Cif?C07R7^H~Th1NV^VRDhMiS%cgH9e|Y-I%7647hnZ>>K)H8 z7hna6PNB~TVMW3m*_;Zn(%<*L#E5{^XC~341F!OsQ@bjv$gWSlL1!dlkN>{mIqjo|GsH;5MX6;RzLT* z)9S>Hwceq@`o-&uRXj>ON<)U>OzCh+vxsncx}n(cKnct#dvrR`@bs|+myf%Eb9-v& zXY@fCw&eVE3{A#`I(?EF$$o14M&pZ$KKXEzFwz(+uC^o*l0Hd^)K1T$m(dI9)y?#b zMBlsXBdO-Yp>rW~&+6>b{Q_Z49+ zX53;NWB{OWd^za4s&~F$*MBztth71*ME=h_xMdib3>R0csVZ_MIg1kfRsZL?|Au}M z*EpU-HX2>vLzv1j9t_wg&UqRSP`Mk^8Hg4b(Q)2)AY^U2i{i;Wg z0R}V@cHK-zDdUyIL5(QT?Li zm3NH+_X)>^W5|i+OSKn<`%=vChGA#ThhdwUtC$O$gS|Idwd>+)D8=KH->&%R)eM!}@B?ATyqIrAKH2Rd`QN>O9u*5KG8PSZlMD_NnX_rr`KlA=$ zY+;Cy&SG6xy;eO|znn&!-=yRE=V2Fo3zmFT=YgqyR+USq; z=8sD-m5!DYYCbK&R62hH{ow)Dr}}f8g z>R>7rhJ=#gpPj+HhDN85x<;wy1p8wz`}e|BIvR>@g>IOHf0nc=Ufl2YC45<0S8U2hm8ntEM9dbe(A^@g{ib)91S@lv;Sp(2s8gT`$I@o{(INgB+|VCm`c^Z-=|M7 zVJcNuxKAE(gQ=7`fYv3gG`{0@w2q!a|11zeOWa4!CAVpCk{d^3(76RlXuTB2$MZ0i zj!vNm=}2={7xGJT#%O+S%(;SLbWIY*?N8Rn?*aT0j$j@!Q`KQACE&hr#MNOcC3Jrx z<`kUsCp4nt?oVcY?oVi0hikko}R&ix?d-n&MqZjDjZVO9x z6Z;4TERy*53&2ItZ<5<5ncyPo1q6$u{1);A6QS<4tJ?~n#J^Hd zOrTicN$q3IWOZ;6LQ7KD<9TqAis<{RBlGb4sAwXz99_937@z-+KAqI%-h8BMeExU^ zxCr6br{v60aFNkR=zDa5+v*2mQ^7&>-GaA-Pf6`hnBXGnr|%0!ncyM?f1|(Ar_H~0 zO@HC&se_BC=Mq*wa<<$#i3IkntOB@*?)XyD-CS^yTw`76sPU-&IEzzQ zJdt3G<|SRpEx-{h(EOwuxuZD3=XQ>C@uSUmdDDS}_w7(Mjf=9}d7_)ey`$Tz{NhA+ zU#xa9xQOoICCOrN5#84(KC#>D-_2d=%thm7i?8O6iL$)f|#3b5_sESB1)2VmP(@WNUA z%Y$uqAXgW^#Di^jz(!$oGn^#L^nR{qF>JfIQ}6la#jx$-T9)XqxUlUOzdfs0)4^Dom=OM=cpIb8>;kFLXs8O|(hyScY?@20_#vfMT>;Hv=JE^hTbw9vq|3w<@y z+?dws#h8b=r*TL{iOt9*UTf~}ZqHu4`Aq??e-X}=T92R2AC}=B@E--uAD7`=nJA^yd|HNcW$_07!%yM4Qh%n{mICL>59}y3&cL~XzbHufz=d#2}n2!uZaR2b?Q|19)A&@LcI^oj8Sua|JK)138xh=SoWBL<}Cz6};ZEFO%gc!-Esl8Y?8jmH;K)tUaoql zd)XuXf!K`U@pW{EUZ{vs|6hDZ1U@+@qASna-s|C3n|@O4o6K~ytR&W&eK|doc{bfe zOkM^{D3lb0_m+t0sn?6rk{NpoH~1Djn!ImK(`8eevPeYSr%feoV9Jb@z*u(@b};oBYs=VNhH2tq~e9#J8`&# zL^9K3dbf9LS<6?h^>$O^-APaDbuJSdm%=)*yVs~Li&;qWj;F&VB=SbO&ARtB=hB)2 zQ44uq3)!mo+!`-2xP&BmG3mvka0yAZS@bTgflElVMUrmAhDXHd(d$;t*e^I$=}n?7 z@`76eH2b^AX-T?!vm71G{*exskmxUDl1pz9F{t;E$i20232E&2(70-nYcC^uI;Et%XZSd|HoPyGDZ1wn^owO8%=a85QHGrKKjy$Cq_K4MtVu3hLNaOl^M2^VC6o)XqC!Qu zg!IRyNk{YC>qvRV`cP8iQLWACR$R=5OGuIwlULvjmyi@`U*4!QLAQfLJ^RSzuH?%= zf@Vj^P?IxULb=_>b9eON63Xq?-&1uKE+PHH()nlM64HO2`$-YzRh7DwJhVPsLe4W< zoV$u}2|3Sba#|GscKxon*?C5XqdR5G_)Gt@4C&$j;ycpt$vJs`5~O$F63Y6d-4P9g z>(-$&`V!f231uO*lEa5)iLOI~&NSH-x`t|U>JUbMkP0<5xz&ova0#hEH;suiTtYcy zGsY8rxP)B1WiXo=olqh(kxSNx;q{uLRc@tH-3DPb&ISFAS8}bL$EEj4n_ty=Eaez! zeO3Fh%nFs~vQB^CYzf&8!begUs1}d~T0Wa?np>@$d}t_5-)tc_T#@syBH+ABw<<9v z4{%;zEjt{^*~O2_GtxhwLyrCv3W7jTI0ZqB$eY*4tJY={l#=%j!8oh`PdR*f92lo#jM#aqAqKii{OzP* zM!jKZ5xKBYt^GJ7*6_KaouRIJdnn@-N04{K;CT@lUZmvgQ1YXZsg3IVr;`5~RvY^k zPp!{6v}x~ z5>iB}|4aTT9a8^a@<-`@{%57*uJld)o9p7UP8iOp&z|CM&5|(0sa%23;_R$su`8@N z`O^->hk5@o)APh28HEj(&Bt!!wB^AYD0#(hNKHNoW5#)x7i&OMh6vHBdw0F=|r zN&2}~8vIH6G#KIxue(>O#T?IqHAktYnCF}YIIk2%vo>_zVXE|0($+A+8JcmbRi{)! zT{qP!lkVBP4IoSeUBB#bKttC~f(3jQLFl&S0U0 z(^%;-*sKNPie=otm4hr8S6Dc7+^tF&S1=zL&Dt=o5XVZ1HAom&tQKxyJ`BRRqFcYM z(3S|}3JYrvg)=a&U@kHev|wC0RJcs6)`M|nJu4f7roySA>zE7c3Z8^p7(6ZJY>#%Ogxh2xm}3>a7PNe2pF z6JcD*BgGd!Ai}s3`!$brtnh^EgvNIk-HP=e=ZuB|i)`u+(IVCWMmHbE73I#uW2Z1M zu3#isNEH}Y5Ge^`F&G$EFnR@$G2#+~$EGlY3>a6KNWB#2F&I|@3x|bKA2*yP_l5?< zeB8Z@FqW*Z8ZfR9aeCZr4H#F5-N;z?!gKz_Mob)QGD~+L&(Zuks<{$kM+=F4m502o z7R>vLH5sz8?wn_pq_GWgW*wES%&}OtI%JCPSQloI*;uI;yRGm=Y`6ZKLVjprHs+1m z7e=^BEU6H19&?@%uEjmc-&Uv)OHtv*@^=*O#hf1$1XI*f+{Vf=ThyZ&t2PT^n1^a_ z83fgoBV&N`n1{tHE@O7YgJVZ9F9ufwR#lc4E47J-RH}%FXkA7uE6xjz$k@b!_Fb!L zu^xq&FsIZ{GX%D*Xv{CQ2gFabb`w@qyA|t~#%~68y*vx6!hNgpn^~T&92&fX?Ai}F zFIDUtZa0gm+u8p?s@Nyo<*7HB(7#jaKmk|zITY2*Lhd8W#0cj&i>iz24^n>Wmvj-k zd0Xb%_zl>gjowM28J)@-5k<-ycT8^VbL7mdT{OaO*=9@HmLa1|C}R^zJ2JeK3Fp~M zS*l?h5^LJr`pzoM!E$T7Afu==fPVsn;74%@28#gPb>XN8I+B#>eDl z>9{qly`-GFV=`ps9Q*m&dq(tKGbQZtwa1L!8Q(W~SHk|h7I0qbvrkfV2C#{gt~bv= z0|Ee$-NBvX1x%U5yv_O#0bg-O$a`Z=XLn;Z%uZ$vAmr5z5%}{-d-?~Njal`|v>n*c zLN6?SHY-b6Vc_c8Qe|ApZYtTPFG#A!msePMaraJWqp4AyFe2moWTc{0tc=KxeT8d{ zu59pE9`n8|wfRg*QmAuKxYR#czWpyVAb1qjAC^^Xyer5>tw`@A)4qpNzr4&u8k!r=E#;K~&IjsV>exRL>w2f`S&(j3M^58~C3@MN zz~^d%4b7z;heiPBQGT9j$s<9of0Z>{Z>;5PBps)&Lyd1IT~DawG^@lnBy7-XNH|xw zj|!rO8uucdqQ-GDRMfB2O_@w#IOsavf;o>eZ`uA5x3{1Sa2^$Tt=nw=S>2J51A$Rh zOHQPdBiey?cf1URzLo1`+tWG|DmqS%$fGtOQ_GD zQ$DGFQ-;jHQ2=Z~GgX`(6~K96a_5Q)yr!$7&_LcWUti}p(u4YW?UU8ItH{b^|B*i8 z_K_>7+-pd*`&Ej?{7@O-Jj$a1S(@CeQISj;kwOWP4Q1RwHejmo_fQ)GahEun7(3=| zl_xWVtzDXKUs$`5uRTV^Cm?KufYyfnjA9n3_Ua?-Jt9DnNRpw)^{(Q(*;xJsI^1MS5*#Xc*99Z0h&9%mm5 zI}n$od0;Q>Koj96v40oB4rE(X#-A&M9Z1vfk^hzf*nwo_Y^qCUVFwzB&>SC!9cX-V zgUb_H*nw=TD`r<^VFwbXg93732hv18N{WCl`h*fn+S6ESny>@e-W?%_uwVx&e6gq7 zo(nrrp~IfU(stN^xIUUCeAt0ve-`T4zz$@SIzDOS0y~f}^mBd|b|7Ksr~FFQyP7K= zIS*`M2a2OR3_v}My22i^^?-{e>_D2j!dkg7S7l{vx@%_PJ=vUlHWp*NyJVArI@to-Zu2*zZ5sw2 z%L2~ZP%3BZWdY}HEJoXK0}Y(cLUYIw3A+CO;+dO;O}3}ZdqxE(S$Z}gwYDvzbUe41 z(@?l2e&#Hxe!7g5!m`vnrSvdv`eICE&Oe-cvYTu^jqx4`3)xYuE_q56IeB^t?sPz95+nXMbSpmQpayP7ArkV4xLC#9M`Ig8|NlJ zABl)W)^_@i-*<#C*#Zu=IPB=B^lx&_#rjh;cd7alWI;Ts2w4c)qe4QaM;sUDs;g3>Xw(%)wK?+UL2b?>V_ zSuN)tZZ{wNj69JHxS#xKg6~HY|7sZ45~JVwB5z_?7oz7L`E3-AP4r9%G4NqP{rNZ`kyE~&ym?^n&f`Q)@?vE zUKRUbtKk%VYpJM@Ots;-Wm43cWtol2>MHcMUN>>HT7?SzcJE&3m=7np^(}};swARX zEZ{5gA>yrSw`8J%cn}4GY!7^t-ly*_hn3-=>e^T3_8{XQnWLuD0jXw_U0W+e;qi|6 zATp7EAF92ArzWn@3Sj2;2r!7u8glKmT&ECX5H1REPg%DLQzJH2C~7bj&j#N$;rW`|6o-$6G) zIAo4g0anB_Gh+I-cki@DSE_ish4SuXr1d$M$&910_t@Q{s>?DKro7{409M4`n6ukc?c58~N<~D&*!{i2AItJp8$UEGL9Js!qm9ulxPxr8%1G{-U@Lp8Ls&2O z!e~315a|BI22Pgqq$thBiYae`KP!7LA(#aNiw)Y6+hK#Y>ij=^oryn`i`)NC3$hJj z#x_Wul(D6vAyiZ4z;L_MH9LxM3)td)=H$#{Z+K@m zSW4VEb`wSx;*6blX%GBjc8=wamLP9J%7xu?Cl14dNcl+)#Z?&uOBbynt)&a*+uXsj zw3cqbi!N_s=e!ONUYnI=as&>_FtzMd*XP~X2|t>y&#petcf!k%H%d3m;TAlLc2aXp zs(Zk(+{(;0dDH_V?oR7mllwhDm|MK=0*4uRbFNJ5^j#*qv70nD$xu^zxHso?un+ z%)T?jlM;zZC1|NCk4TX%j29Az2|J#~8Il7;csM2Z_VtGKaYxa%H6AH>r`sDP?z`-; z>Z$f{PH506*fQgcukBbt>&sU2-$=Mu@gez~5bP{;PLO1lN+`MrFDBS9UP|N=XwNz# z4&LiPqjBg5=+^_d0iDPvYpwFoM09m^8|Ju3kjNR41@Y%`cjD^emJc}{ARRby0Ow4& zPPp#kkn{$fgLa07h-f0`w#2YA?Ra%9zf59Z4N{Agpo&9^MVN}{7^GMvNWtBOR@u>u z4w7@#8F%LFLAL&@CcjBEx$xtK^>M{GVKXvCqgS2V7tpJk->WBUCBD~qP=)1jS9FB6 zad4MGq(x`sWc$Q1yEcjQpUXq8BtRNT;=GQT1LpwOBjJLAdMV$5lGi~;tC!T(;7@QI zB{H2s>6G({32g0ZgAA9*xH!mz{CeDZV1K)|#Bp@FHr}mWS)xzDSKF^*GC(AqVIl#Y zBQ6KzDMfKhXwscKm45dj_aTWU-K}FWB@7%RD?2dUoe8-*&n!9FTwH=4y6HsZW50)W z=^afHwQ|F_XU8=an&gLd0&45xJ5(9z5~euX(e~cbtT%8NT?a!hSkzEX1kp_i6XS{U}0dLQNYzLH1Jm4E8Km=N$ zwUVy~KboK6j)x)1#0Md_I;JJw%b^o=6mSv*5u@EAPvaucN*zuf+QiB>OvYN5*z!qp zj?M;!YZ4QRH=yC9bQ8J?X*dx{AU>>fA`*nLLI(~~HBux02_18n?}gORpo1KQw~*h9 z#B(QMc1Jq*d_ulLkTdO20D)%5*&_jKFWSO0?H5HL8;f4OJLxL|gEV^e{$zy6m85;> z)q9h{B5=e*;yZy~3_q4!s5bk$eWdF3`szol7~HZ8QUma)2^vP!e?>vZ$h0O-22HIr zBTdWUKBa6SsNY-k%VsD2hRY;G6?Fa0jm+Lwfp=sc<(@@LI~A02ohY|cUJ(jPxVDs> zltf&g^Wc$!veNpLFv4!R*-E@SQv`QA#Rqr0isryPggZ~FMoL8<1Ze7?jB4)EaXO=2 zb7J&_w@x+^1{tdes9IE^JnZ^(wsGh;32=eG)){ULiaEI^@`t0MPL$8$BaWvZeNdTR(O zy4X$@HH+edF5dgH?H@!R`_l!9cR0aT)5(=*U5ZYv+eIs|6`|L=a=*5BIw4JV3nA@<;c2Flp)(VY<`HaVKFXS-egt5&4>z_c$8oKD5+iD?(yo|IZb(23?#)0lcB_wl~s-;~JMfA@^QctjB7 zr$EPu=65GSZpw24TSt@;R1`ZaKbE2gc{rqsZ&-vAGETt9CRa5F&HoMO} zs84`E!?a56K;%8zF)y3+hr8h`!uf!*`iHFZjK?aqufEzHAA?v()9}avo;1Wl)QraO za(o7l()Cn=-VB@?zh9D?IA%1i`P6^zKIgdfJEoh;{7^_{;-y0}(#z-ODnSW9FbP8FNH5H zB2UqER7B4!ZHC&C`<}E!IWp9is2o18w1wrbRPOmKQJh?wcof=3@;2Z1&%5vWG*Ov+ zKk<}`?|FD0`4^N^T3I4-D(-KDw^?xEN9Fx%VGb*Se1pYQc|SxusQcz;pkpB^g@sl@ zr$0zkxh?8OZ&8u*^sj?H5f4f0_^S3-{Gy)~C(;Y4N35zf2d}3PRCXdElIkI%f3-h6 ziYh4Stj8))2%xg>rLdj_mtIu%_^sOVU=JeQ-GB8M@37!8%T8s_`SHz7{^R!rs?wl$ zc1PEGwj{#o7nPWo{Dwq$4ynYv;LMr*r)2UCD}r13O!uCW@$s=1?COY%$HG(eO?F63)vsd zj{9DwJnNGWM?xW#M;W4~#|J47@OJDErhNlY$Y*11rRMv|@7KP;K_a`^^mfuxctI_1 zt(x7TT@d~tRq!?!8tD&3CHL7}Xp+tS@QgHzHmy}~`4CXqklhE1bA^H7O>d&oY0k43 z{%&oL+zag>UrqPUJv%U7QC60bA3m;Y`hj+c{m|4jbu2u5kkXQ|_n?c~yYTVKvKE>R z`J7&61kqV6$a;>B%>aRQmZPjuzvP}axb0HTZO(*1A zGxl3o@Gi@JNV@Q=>52Md*7vszJ0`s`wJ9VNY9z^As2OZ_{#i9*ZMtu0C0XbMog>8` zKW-r_hywgg;`+{*8(R3=86gj%<6=m?8N11T7<`F~b5Xw2b_J z8%Ekh&wus}xe^aEi|Bb(vw=yNS)kTMos7&Z?R05%PncO=PCANa8pF)u%O}ROm0@Pt z6B!pPFAXz`@jzRfwkXUj%J_F}%Azo{DEs+M2CRdbMN~xSQW^kqU7uGEEdQTdsI|Q709BJ=i~7-7RVBN3hG6GEGh-QA+7BqaiZ=< z!rSdY7SZ>+g*oj&7N`|@ZwzEHqV-IM#sgW}(W2=12eB%**Su?M5tZ^(sgtVS?IFp& zdcJKJkVSgEsO7qa)ED~$HW>n0M0c)hjH_${$Omj9(; z#2Cnu^(`OBvg6d)RIC<|CBNEhQ8o+6VyhmsxFHM3qLsNbsu5N%&9m6I&HBy)Svuuy z3;ei1mSj<_)Wm9ms2N8pLdqqS$Hi%#ISSLuGM4l zkYU@@{;YW0)<&5##)6dOb}e`_(n`Fg6hywJbnZWyk)(R9|71q;{K&s2cWT{Fs?@rF zD=&zgn0Jnx(>ba&o7Ai|d#eCa9(Ouj>{@-0o7{`7;ONdC!;#PTDy7D@9BOQjE+kD$++oI)TlLr{zHt&?nKVo|qnnrpK z29?TI+2v?eY3-BMxJ~j3eb@>85uKT~F#%BiS=lMAwQtu7Ea^_=YCYfC>=+#wn$_8> z^(5(`){|SSEC%da$-mDZ3F5*r=Hz1A)c`K6Rwci;T@B*O2+EV6+pY$Zp*Di?Tlfl@Txp{B??%y>pS<`dD3!7btL_)oQn+kaJyIQDbv zF_{fScWkg=z2vsj_@e~@C9w%7bqjFE8$zBY?9wgJ?t3&a&@qZpNeAbVhIHFB{kZ=xj-!)Iu=X9PorHN@mu3<)DW?e~}`~-y5+EZ}l$5UXJ8hnbh9dn8l zAP7vgVVw9+vLmB{Nb+>7uQ**%a%w>D+4*+a`y$=YDbf_8OT(B+E{G#FCyo5pBwYq( zqhuw~8}7BbZfE$X-eW#s$}p&yVIt`~rH~jb*pjN@ij)>n;+g%r@GYuNc#m1D3$M?v z<7X)iOg(ZH{F^~i z!Vg`jTh#sBRLAZ(Q>EpRZ}hdH`FSriUFAOxRQFrjM5*gFHI}K5s=)WmaEvqSPPt&u zEp5-Y9y2@qx*@8%mlrUk$R0CmXF)n-N9!sK>vh)n&phuL3xwAZs>XWx~x@CHhG`aD-(HDZj$X8lo0P5&0~XCv&#G*OU?tr{OFC1({V)nGWz z+Fv>2Y6%Q#SV>QZZ{1KeQvZ$xEk>&Q zMunsBY1>8h`Ka(6OM`2#dSz5N#)2w<^ecg@zFy@c-d<1A&5gglHCnS5ny%>os6o>e zzWNJ)KUFL_UTugHnMAi@dZJ_^BCpY(a*t50P0ZfHNA4oJmzw%)T`PWqBaS*A9`ZYW z^uRi?ay*_;Ff$o0=8MOlESSDi65pVWc`8jlW-dD}Dy0A0Bdp zuA;d28G~~V<(kxuJu}ajpt__~VVlB7x;T&`q8p>eOrISled3$?zJKfq6E6KLYA+rp zT*y1e=VM{QHK8%vAEIEw#fDQtTH;~C6?=yr|8S>;*4_RNszO3)=QxuVV&-b_DM4*~ zVR8)>CS0t=XTBU2CR~(8n2<(?377g0*5U(Sd+Z6m2B(rM=;?|JL+d%j7qDRT_onRn_A;&3<#-07aN^^;KH%!-rtPOeotxuoDS~SG zPgMx07DYGG_&lhVH;48pMmz=85=w!{00~qJjn;q2UJ+D_;&Mx58=_irWm=*ch-&F( zlPu;zwG4aj%e8+BswHBjvD=pfswHgYS+^nyR0|C|7ka%LQ7!ssD25}TT3$1EDAL_P zwYa5)8omY9GQ`9y^4vhRxY>ppj)H1=!`!X-$_-SDTTiIT7^oIV0pu>Z>C1+%Jm?-K zfocg^x!?VS1geFke{rR$8&pfTw|;I5s214zucSG-y&fRV=}%nn-0&)4ntV^v6|YZH zEJ|Izwy-%w?kT7ieKK6GcB&4&(fq;|u?>C5Q$vsc1xd5sqV&z76rW-W4UTAKg z_SFZ~LR)<~?V=B=#Vshbc@$L3n`z_R_cTx~v)oIHV{TCC8ZO96Ep`LdazT)v`rZvx zOSm96^|>3U7MgIFc|{*oi~hxy5gxhLRlbg`@-1roJ)&CJZ`Eb~1l2O)r3Tl?LAA^X zLNcu*LACrDd6Qjc4yq-}Y=}ou2i0O;G|4$A1FFRwI!IEepjy{DvPx zR11}*-uD-%mRU};jAA6HmMFh@j-`xZ6sQ)qjoF!xpjvu()9ST+P%T|vHkl89rUn9d z^$*#gTGVTozP^)q0`Y->h5C3lNL+Q(-)Nn5khsv(HvX}sf!1rKDD&hJNZh{$dg+Dg zAaQ?&24q0uLTF&%l@)A&)BjOd=Yzz3!K1^Oagewl1%{cKmq6nFHPX+^QU{4^Zt+E+ zoCy-w%wjArj8j-ogibhTjnV>!d$82Pmd_#hjvVc*l_$T$DDI%gV{B8khs(lE3PKG z%IvIqs7wO!OZ}h3zd+&+@KiF-n}ftPkC_s9WrD;t58?}Nu|eXhKmSvh%?62^nQs>K z6(sIov|ipGb&$AG!U_6C8IZWH9%9QpKS{r~70DQn7HcNzOD>Z>E0t!aTmQ&;fga4P&`%9hL6X`Y`3NL-7IzbFNn zAaOOe3C#v)LE;umM;CFIK;rhlx?BX6B9xr|M2r5fqeq}tq#kNTjOAum%-+w6Km~~p z1S6jf%pS7vCw=B^)X4qYPZJ3bfPN=w*DMacwARr8i3`mmexGle8>-&t~_JTKD5}IIu?CV5Y^HT&!OUX(GwpU;5jt=4*tKR;5oEp@oyK& z#KIC}2U4ifW}kU-8t@#lP+t?Mmcd_k$wq}QFy z0~TlAQVqUEGiN=D;W<=Hk47z4H(lzT>49}rrUUbH?$pPCxO*&`K7Q=I>_*L}r zWfQBOKTW!!WCWf=8cWeV%k}#Q*i{y1mIUw|V&_{-EY>?9|dOd~1K3@7&U5xs|lbS{5hkyL^9U4c=HB z(;#TTbLeulpnm9Q;fS?`#aFo2{TiA_idP`t%Hi2)xUY_6D8!T=^B)H zNCjTEjk6|WgM$lJd9vacYcc%c*ODOtS9wr9!WP48PA0Fh-?&XIK$d^=DvY>mi`4nh zDU#B#)>ta?V%uTp6-kF)k-B8O!Q{#sgAF52yCp}=CKJ{eNI3AgzvQl`?cOg*LDB;x zxyfS`!quELr$ylU22_tg;|Pqn>qv^ftO3=su2E({vrU@Ix~jHrLIDB<+LL-~f~7}J zjbrL;+Q!yRXg(GFjN$iP1=X@<0TpTAcIi;&)#ZQ7Nci8cLU_QzOktAqOXm8yE$Y&I z;#KxKGtIw6=M*O)2mn1LMJT@QRgk!Aie&hfR~4C8;WCGm1plQ)w8^B-8Zj~K=0s5} zCM2>=j(oPwagDVYb_+`ss~8g9CQc@|9bL0ctYGtf(E?1!Z*9tCAIJ;%h~;gW5zSNd zh`A~fx8{sA|MXQ*Eo&yE_|8`o$Vj`$1gh?!uJq5v|AnMuSJ7*3{^H-Ja(ch{WXl?9 zPtnG;HBx?(+wQ0iND7Pi>qOVkuP&r+_rUHD&r2b!X+XjT)hGwi3Q^&H=oYcN`uJz# zh=l~7aurkyRF4RC+u(EeFYcm3T-#y6g{yXJjKxMaH;Im5CiAa?YFQhj5pt;wRLhzx z62h=HP%Ue&ND42tfol0*ibhb5Ue9H}QQ^k>2FJaOSWVPMF=~Z8_qr^o7E7!;H%S&$ zi)MxdB|ORN;2*N=M7;jN%arIOdCMZD#a}6*FGMx%#iJhJ^`<^(_HT%~kJmX%zwS#7 z__a@b6hGChnV+;|S){QTcirw#1B3+5C@q%C8tJ1bN0V%<8f9}6Xl4wwW{rYNf}e6J zAqf_&WCJZ5r77t&W5xwd*-}{FJorsk&2oHGkkqK*l$~ZUrqFU?x|yi=VNAOfTCwPZm(=`0&)A#B)}J0`2H;0ZK*3G$f&qRl(Q5sNvtkOcx~0H`cl^Ch|W~k z^`;QZr<#9UUl8ASR(7Y@ln=0$v+9+oi&H95mm{BL=A44 ztOgY+qaj-*^;<^kw{8%4C)HX8D7aHc6v$yodgSv-fts!2kbu;mTGo6*McQc=%dhXF z!XYS?#V6OFQ&+84l^1KTU!jg`eivT}e(}Q+ZoFI0qnfu(P1I>kZZP_qT6g6doY&p( zcnemE17NvkDu+r&z|z5y%Sb}N!l~F`Z4F>a4M8eK04&z9QlBgbV6hI6g^CdX%jP0= zL0S@kWwUWwOx$EOHAOk@gG#3p9UxR<4J@fZ%(Q%s~V!Ka&FhmUKSx8hZTtmy<+~ChpIepNa+af3JdAMFz_LYDj-CWyA!7}b0W5M2Nl^^T z>I{d&6^9Y9c-mciEC+XXkw5|E8h}O4_#jd-!cE=aj#P{QSgzU08CwHb4v%0s`AGnl z%`qC3OAG*u+!Z8D0AP{3k}A9eIq71#A1T5J2AtPT`f+&GfC~*q-1($So9EO0*Jc1% zl)kY6EJ==gS+Rxy7IuX^@46Czg^X3_B`E<|3^P(F;b{OCB{mDMe;B}$CQmL>S^}{2 zh#J}_BVd{NVA!98fW?QN=gS7LB#+`}nhgOgUi&7xDF&zO5>jE^509X!xQ&J-XSkgufXY33CEaZueOG9}8 z7Pw*i%zOsG(i3F3kcNO|X+Ce60bogra_=cJ1h5z`WUlN_1F#sXX9$j@0a%nWnUQ8J z083hoVNn_a7TyP?eg*;--ind|1Hh7GxPx^^2?2{&>&CE<9smoHE?@&#SXgvecn|Eh z^iT}h?1D{NtOA9w&>jFw&pE?#Hh_gyfcCnW24G2xGMr%pSa@%gYMBUFc)d!&OaKd$ zh~<4z0GeuA9(;4AK6`L02VUK zFe?whvMco#8^EIEo#vkgU@;tG5LLYZEW9!$4K@N6FRQeEGJqvX;GI@W2C%S46uiRH z04!;NhONm6SZ0z>>TY-1CDBU?IIt;!MR+M-j06EEoZ>{NI9+*ES&malDNHmTh=VPO>^IumW@S4{IqKTmW{@?krz7vEZ5TIi}I0)g|i}Va0I|&Gh)U`5H~mmU|FC0 zD{c1y083oAJn059vBc$wpWX^!IX<9uU~D~rMV^%Vi#UKqzR`X_vmL;4O-+8{CNi;b z^u&Yh04zCmHXUQ~02Zy!IsCqB02c9u%?*j}0I+l%72mcEz@k-<6Y^UJfTiPXXi}-{s-#HGz62}yuI1XU3tJ7^?Tn}K0LyO;B@86+v+R~iUA};Nz)wr?77Qj-H zKVSo3krx%GUjwjs7VJ0*U=eS)7S&-{ecVB-LJPo>=V2H3SRBCe-vuMazY2BR0W9m? zwS>5K085;mxbZds3*-bQ^Z!j1gk1XX#t{IE_!S!=w2d%|#eW(b@o(dw#b{2`K;;g}@Vyb_iIQ7qqV<6U(7*3Th_+ zEViSD({|bbmJ<`dFAm)VuxPj3eP%ulVCf3dUa$qQWG~L&T)qonITGdGRiq7I(O$4$ z*`ESn(N;e$IFbTjQOLX(X~qPwq{L_!*#cOKxgQkz?;`8n+!Y0by8xCWhC7&d6cDhS zZ2dhnqzk~(<*7|A2e917W`~A%0a&^y+U#-w3v`c!hIRp1kjjyA0L%S?Z1Tkv082`g z_Dnf(Qy%w?LhU^O%MEU?LhwBR%ROQN_lp96MWOqIu04R|F7XDEI;bwW_xfaOK1?jf zbf&v*Hieu(G6w(_By#{@fvywC91ydi>!cjOqTrq4p8{afMoLFuVkzd9DQJ`fSZm6H8)_@@a1XOW%N&)tD53 zWfv)TjWU2`S7ZNxCLO?%rnYP1CNi<`^pt~p5wO(Tu*P-)SbRR`2>Q|hEXoUgk@hSA zOWLyc=UXtb6binlL52Xp0>vMhX8|lo@duQCX#VXpmy48sB!5@_S^5EBQ7-D|TP6Zn zq}J_{>iP*5o{qAZFM!1dn?s3Y0a#d$%GSOB79VUbC7K0bVI76q5de!%K@R0N7Jvm} z13tb07N5La&zMA!IOQ|F{L_g57UhW^zH=ggC6TE-(Fb7Zt<$A1NB(aJZa{8=ie z51aE^l%+j=8X*t>U@6NV>;|wv*9kohz~WV4*a=`!Zb*w_SyuNs_*D2HVDYp|e5?#$ zQMTyfQxaifQ8qp+)P;MesZ#DfLL42yl4z%F>i3MO${?R3jU;$Xte)y~!@Bl2l`Ls(r=X?Fv_5oP1-(~?UFC6!>V@&`o zvlUsq>sSCwD7J`~gaxpeWOPx&GXN}D_6%O%8o-hvA6k@$Oe}q(Cia~OSY|(%^uIvB z;!7{^odvLTj^byVO#m#RMa4^T1ptdtJ-`uw+D;%*+Bkz5wLh!W%P#vSY8OcGipNtEVCmwy}~j8EE$0&t(^#1W|vK# z%pzb}DlgbKi-2Y6Nx}131T0In1y^PPES)RCeLq40ETQv#&D&-to@xEhrjf^zSRyj9 z#F~nrv;i#DZ5)w|?EscjlND>N6#*{0S^3|aR3&HqBDFb z6-jB3FxHK{*bZQcONU|+m{@8jS40f916XuMo=ql*7~lXbYmpQI;#gw4B}tCR#1fk$ zavBX_f$kBg909OMl3acf0kB9m8Vr;mjwMb_a>5BFmdZ&zkzhRli*ub#`$CMek<@ z0KgK97P-0Bzg^|@fj=f&M5H}2jS@Awev;*PTnBUjERv!k^tgr83J=l_Z2*f%1Cl$a z)_1^EU;r#c54+gMKPyKpaC}NEfCaiogw=>+S?i7wK0q8xteuFlB7g-m;yjrj2Vjwi zaSyrF4qy?vf)j?d16V|0JNr^QfaQN_8qo)^Z2XoBVA<~2!ie1pV9BjG$-TZ2z;X3dtp~qtR67P>(H*UyvfBz^IW}=(ap-pd%hr~g&;CFhOJ~s51zi9O zVR8QV<(mMO?NRQXMOy(ZTNeyg_9p{awyNt3jwAzEHfCl=K0_Q!a?I8uT>y(a_ru2i zn*bIU?#f1kn*f&WhINcP8xgSBw4MkJ=>)KJdTyoW0$6gegwXI#081xjD?1m!l2hOm z8rlh9={&c!JQu){Q$QeJOa`zdM{S+SC2k^e-)yYS2Cz7Cdp8DW!^DzJByqoN1h8!E zKBj8`V7WQWZPV%s6U))6sjlCfLXJJ%`p56E;eXa)r!Iiy7;9_R@0CQt zPMur10G5s3$^OXzmaRiKiLNlQxO2-ui33F=};jKRmpXr5Ybc@%0V>zS_oJIbC7`D@{vFZ zK(nEI1nbTeClnEL-w^}y_80l}M&>#uoq17F_7A{4oL%Tvx| z&$>h%$c7&bF5e}}Gh311$m1gQA}?{Lle!Eu10$jy)gYuEL&d5(D z<KV7zm2!L^I1!N*-ego#t$5fqo~ z2$v}PZ1_Gzm~ip#$HA|LFyTJEj$pQ3{Q;MQQ?D$|jBHF{KZtymvhCn|+}{MjSu`+) zVCKf$MS#p}6?X-9rhoBLFxhb-eqBEcCC{M4H%3>#Kgu=IM{XOUaI3 zuHKfy`$x$LYIO3^xT>aBHBXspc54QxPAs1Rn zM>;xaW4_M#363keRjALfE zLSAaEjxJ7Dr#dAVH}1-Lf{xkNTFa+OT&d=76NDu|%;0W=B1}D33Z$Di}LPKJa@`cdKJY!Zj&J!c{u}!XkcE z{M`xVI%ZY{m34(34GKjM@32fH`UoKqJ7J1Q=sShnh(p>(^a%rJ4v0~1#dV`eCGfP% zj>~|C5r0a0+3lBpd@YL_S9joXG!JHdZ_1%H~Ol;DNVkmvnB5fpI=_Y=ZL9WIP23HOu2 zrya@U-#hj|BMBaYCV0E{e2M$1$`U@fynArEJI+L62A8*OMu&LEqa^MSxjt^IL=KLq zm7_x}@wgX9AU}>Xk{H4f%j;ObC8#O{Inj;=Kp2GMD)AlzKbtbXTf#zepu~F@VQj}2 zGPOen4V@r2ghY;hLh<%-y|3GaP6rOfZgvtW)*eup>u~VE2}!YA14J_73>0V`Cnq7E z60L11awCq|?y}kOI_`zS3?xnxpxc5FT^3T?5sds=T!ir*8tCu8#yAU8JKWLG8tWpY zc3hVDp>Rce)s?eP;s?QhZB>P%4KoJ!RZ*-0@4!5OLt!2hUOQ5fQea00hjO7@OWAVB z4gHtXYnQ?OyPXD)FdcCj7!x=4U?x-O$u-uBtB(2#xE=__H>{!bT^ z{nPui~$K~{;vee{7K656fq~@ z*9k*QCLO0Pm-!&lmdV2vL59Svl6*6z{5yRR$F9Iug%&8)E}qNouYmo$^2MlZgNkTJ zTZfh3yK)Jwo6itdZg&k6jw@t2wU&fFg7@+VUFUEUPKIth4u&V0y66TGLXV@N3)9Td zow*h*NzmPxO3-zzcVh0WCov_^I)v(tRfKBCdS|9-w$V`4_?^(QE<18Vmmco@iIt|2 z2a9Ui3lF0015U2AcF8yeDeiTOsR%9a%4D!{5tXY4r-SOPndLYfPNC$1&^F<|rDCGQ zN#-n07b!W3F6lrQNw^=n!?lrnkmSU`wQhuf%Axx^72T_JopNwhT-EGLgqW(uy@Efw zG@QOyEN&Atbh$fyf3Ubm(9*TfY5wF&xzJ|%J?@9&3gkw85IqSq zLSiSB*hEPq(p?VR^iH%fY8)OoK)TNfm3Hxi=iI%LGEqO;>7U&rg}cKs!{r*o zDRRjlDG>QwR$t4CDSggbqax;y{k3KoPvl9fP?J=RChk%ySe{&;bh^<*DaV2%8W_LIT1lJSgRMdy~Ns&&_HljYvzot~lIVxSY+w3omj3NAC z9xPG9hgcHvzj=d+fLV(w5{MoI)$>3fZ0ezh2)+3>ch zM5LOlSiEr~{dC9fJQa&!4{_J{GLEEl4SzRMapIohV0Bk>h@X9A_g$5p z>CM8^UM2OfiRkx!<5dFJ^!LdNM&IuUtkd@;7aM&q6}Y6sQ^@Fh38k2|kLj-RV@R0E zy7KsTo2mcnm-WRN|1j5>qFz(3XT%>0RJ#z4E#<*FRR;Y6wuE=Y(Z$+a?XL^FWu-6d zeo48RQN}bh&3F@4(8ptno7%sQ`n`|EJzH2l7f`z7H0b2JtFRY>BYGE_UXGsjRULGm zJ!fh^9F>Sa>ASbk_k)?Mb$VEMK^4zTZ8lXh9EqF2nNgc?(u1-;GEm%|YPp|imTcIs zQd7IIpZ$ibqBi_-?rvEF?YOUEq1y$x{(s@?!01U|2r&9;6kfgnRcjYe111i$n;?hM zZW=sba&Ww2NvqK60!n_*Cv43UM(s(;SonAi52Y57(h@%Ykf*M;oboLkiJhn+v6Hj` zL0yJ5#7@*-31}JH7)R9XKPuhhRsINR>@zfFf5dKMeTqIkyUP@gyMKs2Gb?H8t{@-Y zdUwgYur|r%LhF5K2$5(0S{OW6P#u<%_aN+c#-0ndio3#*pb0zRfEs*pcw=baGhDx) zZK;;ZAgkGg>%OVC)g7+4WFJ$jV)(0_53hbxf800yFVj79;}v09Bh_K4eNVz+ZuuB( zJ=Ua%qd8zR%5c-(a)r=RrYbMhfasg{j4>k`Dt3CG^*V zW|Yl{DOSyWE+nCxJ}fsUJaNr*vs!&d?ZMJ5Z*F|(VGf&07T)W7AUr&Krttm8)#K5% zOJarlKCT{JtzJ?reEtzyV{4XV3crQJ#PV|GN#B+5?^VO=jUCEzV*io)Y=GJkawj%k z3ajg{TH!S*yAIy4q_9Dg8`fE;JQe+@ToHUH*3a+Gjo30NtX_s;NOe0;O5Nc2j>yvX zEGc8mHdQmGhkSf_%6-v%<$$2N1iy22+3iE3V@XY-_PZjVw(nxx5*<@E>z*7H^)K+6 zB(0lO#=Da|CN0+~D$}5XWb(wiFVf+%xyrQhHnb?baw*UG{UTa}8CIeV?ccVpka7E6^j+pKQH&x>VCn`s_aiG10% zeVzS3)H3M|L;TPr-f&{_m1wacvdTHRB7joHC99vqeA=@qe>)uRK|Lfn`661+HPsYzqO|Ks=m~^`?3Af#17+0C1J-^9kX?qWH2`({)*DL zFW+8}+x}eoyBFWROsg(g<+1H&U-A{o)#P<{*t1*@$FYNI^3*)M6V`_tB5$3CKk?eg zvH##@d1;7^q}yV>xqg(iWG_2)-^EBuT&KKU(b-4!_|YkCXc#dg>^2(hIUAOxchDy5 zp>31#=;21AsN0>d?20t|c8~T=ozm(zcI08@XK&;C87q)EQM^ri!GN{^`P|z`p$M&q zF|Adzg958~5GC0`c}!z59&4H2n$xY>HOf5ek_AV3v&?P3?uKe2<5Bim_pE;oUddW# zi`s2+gcKZF*QsiYlG}6pS?82P7M;t3q^5Ny`c7YGqPW}~BN`9AJ&rUV>yiu`%I>RqS2{(xA>p4;QU8t0Fb z7xl7&Qy*%*H(aUdTxJ+&E#$AZOjI@t%l9R zQ%|&>L)2uoeJWGy%Eo>9tIVldEohC+U!_l3WL-&lZu>oOQ6uZeZKGJd*A?EL`y{`m zuG7W(aW+YcrM|^{B;pyclDtEb>Wc5^xKUb@@Ehi%tKTMQGCRxJE@_?WrHef;z^AfP z<#mhv1iz-HGdnT%@Q=W)EUjevVDeo9G*3fRr2a9}xbz|Om2OdxK#Fpoc@fk9D6%O* zWtNB^?uf$lDJl|a109Dja65!Zdu@41QVqf;`cFa<5|FTo>FKKpRZOISWT4a1mr}^o z$F!XOPp?RQ`Og*+3(U9E&{Xb<^`P8g9^n3#8m~9uK}n(wa_unG(^##bCj2;84-qvk<-bSJkP<1SZwI=Ywp39~KbDCT_~ev}Kl zz1-cB!Kd@|eTaGIJaZDDZe)g-ciyuoFW56SApwI)W@2)PM1Rj)#30Xvglvp+@*B($ zk$B#-kg4)XFQ^Xw^jh_!TnxleqFn1%V6HqC<_q6(H89JkR!^>a3xZRB z5LbsEhzFD%)>Hj9I^vG?;O$ox8^se}H#xFdn#QoCIf@@=$r)!TTnK#K;Q0RVy1{|N zTU4z_@u#s~JYqh3)a*=w$I`+4ZKDCY$D5*3dbN!0(RP8x4Wr{el7laObn+htHpvAB z7WpiN=ldq@3T$eK%IR$ge2r(bnvMG!D6O=%pHUO!+%xN*@!PSvP5KR#=5(C^*xGzF zI4BxG(yOVbJWM}sY?}Hh;9~vg8=pOc?|fwP%>$uHG4N~s=&;Ye!S^%2ucy?~`dR-NtJeu&)t9wF zRc0*mpqW3oKfXSamOj{H(V;(86m8a&5h{fvp}(hYZ_i zY>eHTR#GD0%$$ds5EDn+M=OcFoyO%!mB!_xk*`?chIE##YC}K|rqPhiBB?GhqE#gW zbQ|lB=r+|eSSM7w8KJ5=0o9H5$Eus^S*+8lp8|%6!C~>e>Bf7KdySurKn!J!T-6&9 z(0YE!yuNqloAJK7X1C9ED!#``vEjE#FQ$>+B))m4k%R*X1Y^XKhSy)EsgWNfQi zJXUutkX|oHkameQj#iCLH#p?C<&oT&@Yt`$Csc3slA;3}>Q21{$r3RCc;#X50%O_u zO5I9XZ!x3a_)7gsRquPog7J^K=Kh&tu0j5lu~qiW^NV&KVn+TZBmIbC5fd}Tzd#gA zyeSGr0mahO&Ov2_fMWS{rzrk!6et#yiV408Q7mF?Q?ouqu~4^}nsp+Ig?iP*+HAz!rT0#e(X)5KzCH}lxcqk~A*}7DE4GI(s>Jt9*2T&|u>N4rAC{QdYF+6q- z6w3t1gSrO~iX}WUi(Y~P#iE9ah)j(K#exco2%*MO;p>&Vpj0s$zq}L9tw zgd-e%YB3%Z3tss802368Sh2|fcCI7D!&Ep9ibX6V-T+JM2%Fry0Sk&n>_+?uC>F|f z(-&A!EYt-0OHeGlkXm}M8Yq?sVLV+!4HU~oVJh8S4HQd+kV?Od0>xsoI>ys^SPi{a?6{|-XV%5_Y0|LdNDxJ zsE_iWh6)4y5K2jEDhU(|g;JUNRUZ@!i8RlJBRVNnr10=HilA6Xq)$ly1lKgzjdbw> zD3*v(y0?8UD3;vIq^}o1u|$}(P4CYI#iB3P@~j41H}@u@_To#8z`0wE4RCc-9WLBS~;PQx5k*dXEt zTUW!NSl%<&DoVP6VsX=@)V~JBGR~Ax)NupF;s)V~A*N}r(NN3yWN2A8D3)$L{r91u zSUxVQzGBYm z>w30?!1^~R7RmRACPA@0O(HS$L9wL1J~RW0rA3#NqYsKD_mW%8Cq%JyYmmOrA&RBj zo%H=9qFB23k>228!jkfSLMcHYgUh zv%2dgP%OWh_3}QbgJStDU{;`;>3azj3pLCfiJMSjsgmZTDMYbQZOj{&L9xtmLSH3Yb#q_{nBrsST23$)z`8?v9N#UKz^-R#*gr% z)j_eCPfSrN*q~T4>E;t$M6poE%*Q#1VxhK}kMj`4Lj7iLHi;+}s+_v|S5Pdk>0avU z%b-|3aE?NF1W_zs@hl!O6BJ9P{iSFYqF6HfFGaT_iiMqP*7_MyEKn%XIuD9vN+6w? zWe$qP{P+_8J{1%Tv{Lv)<>}3NW>A~eL9wV~B8hXLSU!6uQ}3yRVo`UFBz^$J^2IZq z`dED^s=9uHCz`2h4vNJ*NFdB%gJQ{Ch>RXa6bpOVJZ2IU3rAp;xz`*N%U4=AZ<{(O z7Illi90YeZyrKK5LoQ{9S)RJZvWNIsJdYzOQ^PkCs;`xCPZ_Y9()NGeNPKf1easXM$oe|IQV-WP)Nb|IVQlvq7<_|M)7*WMBE?cAJI& z>&5!wOi(Nqs6Q#!GeNOvT!_YU5yg^8zl`O8Vi~qJ*ZAu)D3;5+7b!P0L9sx*B&whv z6pMxZpHaW}gJLNz7Y0DQWbn1`uHxRypja+9Esnkg#WFN|&cc2f6w52$y~VyOW@DgO zq69eOM3X+2{gDZZMI-kctpiak8fur@5XGV~{P)}xD3(57#bUS1 zpja+{<&VAs#WLorQGEF_D3;48zR3WhSo+&7g886W=9aXItuBLN`G%ce!f1eEi5{Ql zp)^3TM2~;rscV2@i5_3z{U>jd#uwCOf@1kMZ^AgDVgI+%^iRdoZ)m~(8x+g8|5MhT zxI?}8e;?1jFJmx_F&ZK}6EP%}CB>jfQj-`8QPyglrm`FRRuRflNywIjrZC37PMuRu zsTf-aWrmKXIGy{g@Av-Q*Y&&m54tYbc+Au1{drN}&YM_6V);(}FfU;ZiG})NX7zLl zB$g{L=2kD1Kw>EgR++YQfZ+zZ*s%u~?h)CO$!8S&B>>eM*JI@`Ktt{|rt`-cesHuL_hv zVkuGkG4a=CVi`SYy@v~lh0A@Il`3OjX>Gk)_I)gIs_QGaRR&&gj8Qwq3rn2u`i9^^ zVzE|NYsjjzV{81K^Boe4wM5DN(JEfgym!gRACOppePN^FdiVll~yc`68rMR9U%%OycbEDDofwtN$W#Dbdqx#d6SCO&pC$pVmAr2czu zvR|08qoZ=mW?~V@6eE~HVzHil#aAK*iN$bomG6`&Bo;q6ttn zbGHP==tE+WJ|?Iy0Eva~oA8DiB$oAmrpMWh^e#PhTF8|xv$q8`WsJOz(c1W;t`(4V*$p4>gDZ8%h z3>&P%N?)`SJ#W1ycuCm_YC_m161Jt;!nM4az>UX_98`hYOM8 zv{$ypxDiQ~A=3*tBJDL@oIRgpWi<1h9hZ$tqHE8eiWfc3HNDjWuQk z!3~5p3Ty0&7=Fi09?l_=y08bHIVaK@a6Ktp+#P8ykp_tccSnY6Lql~Z$QmV5ESP%# zxft2aw8QH#Rd8C#@Ya{40>zc#>{s3C*(SCl;;=+9Q=A=XJAz!bH56?%o>6Zg%;aV} z*%~4TZEHnacg*NESk4q?2iw+49QM0Hlfs!Ljc+qc9+zW3l7+;A`yj?WPy5DfmR(Nf zZDn3;(10_Ni%Gl#R1DdZ=4!K~c-iJEx>3#zakg(1z7k*XO)#~+ihU_}@xb;O6z=3y z;#Zi!XqWfx)Aq-#=;b63ZO`B$xEE;ZcFN_tFT@(mnNM*qkROwIIA#{^s@$Gro+h&n zcNN*5%+p}LkcGrzvjNZG$^M(f`}nW=>q>?2b{aGmb*vJ%lm?9jv1n(tja>#S4KO*S z&dw=6&-@Ls=n%3My+1^rTS|< z?)d&dg|Z6?X65G+Ua1#jrzFGABwR#{q=sE&qUY_b%-PY1Q54FKZp^kvz$(2R-ITme zgT{g|%A(jap|K!#?YCn>V?oqqjO&mC!cQg`BHH%D>oXlbA<6<0;AQX$)(E%_+5W>p zN!HgVVITX4XuQURtS_wv&u~U0?N`z|pJ{BxfD;sjr}p&SY*oav{3Uz(F7|H3vcfle z`X06b0^R~+k3n#!XwXm+FEuL9mXKS8&S8PVgM5oTB!#SA85uF40-%cMiVV`f3e{iyJF?=qs%|!5c77$ zD&cnu)WY{M6A`GCZbZHv2_1gx7&hFVc?02@@(1FL9Z3cLgXl$#5L!K+q19l)xG5TH zPdO2i!Es5rq(R@_Y){$45J5DjsUob?>rEK&e1hmRb*h~2({UvC73~Hs^^nF@#lvZp z5*p>o+*R7mdJRiNJMELWQMnqP#=9@7u^%x_JC(AHjK-&Br`2i5BL+l0xXuaB5ic@0 zw|UkK8N}6eP9D#KVT8Dv!71YHW~d?7?0N1R3RBCX|Jw1)tHdym_dhCF@18h?kzMU5 z5b3r$_Isw+_Bk9L-e%ZsWi^MuXUSjid!6RB=ZpZ$_{`k`hiQg1iGyI6Lb zskFU2P~Rmp+%a7X@1o%BXPU;c+AqP{vAQGcfCtn4b!5dhxEwsdc)K|@mgeWgW8nnb3-opU1^Kt zs1(-D{qQE|{aH%8{orK_eqU2q=KJ@s_VmOk!4$aMn_eZ%bdnM@7X0~CylIbdcx3t% z9v*3`ZZVkqj9;cjKWNdLdyijEiLPrgn)?T@M*Ch$(Vn9f$n;X}tkA0s1zEjRoAmVW z{_qFlk{>erdzFxWA?e{{YWf2|E95l1s5gDyk1jiHkH_^m;k`4tsg#{P!Fca1{9$4$ zcR%b_z#D?_72Q;mxhTBG+)%+|@`z%(#rC<0f>q{5fdttwz4d^8X6v@Omj#2&l>$9- zZF;L?O(twbzb#lK|9(sj4ni{UW+}t?W+p|i=N;UETq4s|rgt|O^sM8pX$>crXk1@d zjm65^&QpSCSqe&eYM4h7(5sINjuTHR;fD)KdYckdU!{DvCwfxw{}k*SGO|}P zNmcIMaNLG}Rp6CQqN?7d9qy@24J9vSaL=VfW5HjgasASvvEZ+!aD&sKvEYBwV2q-K zU8i)dm$$vAJ=}RRVdq)R38Sf>?3e@*Ei3J*&hm>4OEH;)6nOZ1YDz5Y0Oej~QN1_g zhnNa2NlZo4N_UFIE=cIrA{cgZ*$IN%wFo-<{K6yR+~rIUhE)X?mU_j_i5;hH9*G3r zF3kumnrsq$u$^FAe5>?i++(@o+x0ybVx!I!Zw53LF(V@7FasKkn2|FqPKbniF$R&| zIUIOslaVQw!#Nx7%m_wabR730<(E3e)r!HIjo5G7KHVyX2fIsM<6emUfU^+cZAt|` z*)7(clY-{kl+@1?!`$P_#FA2qkdm%pP7G))Vxg`S2L?2j|K6Q+UlCh&&XHckz> zvF)l02bL<38(LRgISz4G#D2Bdsrt+vG<;pS$DWjCTjaV0>1m84mvH* zy*Ri0V5+PX57t!tQQm_K^H%x8fV@rRzIK4o06JfMXLQ z!voR218HvF`i2Lig9g@gcF_jiyzi`}UWdlwc9EEJ9U2QPR5ZE|jIi6}Pr5}p|7Uo@ ze)QMO#1*%%&MeojuK2Cp9rDmvbc7$Y;JRhQ{ZfnEB%SfZiDEJ(^@Q68-SbY-mj`ya zeSqbP=t~2a-9G4jaf-e+AmRou405zkpY767Lu6IJCdIT)iL8dZ^{t2_0}p=2vvhjy zDHV~6mHxlO6XF4`TWY>LUESN6dpz}xJ5fH*jh;h}NQH+rQ-gFq5-m;+9k4KgSqj*r z7&&NRK5K>FGaGkZ!rf3V&E4=|tLf~H2CLcB>w50Ba@Fp&2V2c%+vIn?elVU*_Dgl% zJOR=9;K*$rFJ#}AU#_6IhB$Grrpma5pmw=Y3&;Vf&{%XX-sf%WKHso?_JNL`jzU?* zhW`DVZm9i^CbRQ82EGrX+^*)lRPc6$qm&gVL`6CrrL@Z(956k7Qyq2cfwtRqoho*b z{AG7)5qlT+N~)UMMgbchkV`$~wo%A7=Uz_T0Bz zOFLofbgZ(>0Hwb2c7scg?GCK2^dN=Oqp<_4vv8&+zu{_+aJg^Ke&wtIN)thAxAa&@xA08h&9dku#q!fhN0pBU1ZuoJLon$2OL;^#;7nlt zRE=anKux$yPo&A6d@eP7Uyp=|_jQ=2KiK1B(mXo8k93o}lJ4nGcO%_s@Z0ZCKTgWy zzD>Wq81 zo=tiKI}*O-1=puCB=bz*Wl|=@LEf33zsUQ0ZYc+xE;cBD|8A@De9Cuals~B;Jm*+3 z{BQ3o=cmjnd-{`Zh2J=KJKVYFiSnD%l?~QiNs=liA#z0~A^mWH^0fQ4xSOm1^vF9Y4amabQP<@?*(Xo~3P0+0Y?{kN)FDQ|~09v6yURPxVSd zV=>vtoq8h)jm2am$M;kcG#2HbBOKSHYmW;%EP{U@XgxIojm1J{DEiPyy#43m3lZ4d zRd}3tq$?7evwGmLdBF4Hza!0Lr6SD+qK}L~W3k8@4ku+oW3g}<3U|xwVb5S5%mn66 zA7Pm~JjM*ey@)|%(*VoTL4{@38)V@!8lFTjad?Iam?i8A$cT)%Q?yDihQ<=Hd1X=z zjU~dn@bQ%qX?A0A{ybfM{sz0MST$08{ttFx@te7&>yIC|7&_!*PDa8`R^-<{mWczb zVmXvxu0(!C{uBA@3}kEgmCR0y^L==`iGt}0jBTVO(tKX&&h*Ve`Ao{_b{O|1;u`;1%-acN6aH#P$m&0 zLl%1TA)Uo=buw(>IyBn4b)HcS58xI%MHmjZ67@%0)!}%gnz>LM98o*mYUq&hw}siv zc!6Kuc(I>T=FwaUA z3P&kbJgwdjn2&Qj7i!6LGG@;l@8Cx9bUvnij_15_XF3bB9&dD)oqcE%cVwEaajW_FF787NNHa zgzAnzg@TLSITLa73Rb5`hd7Kllmh)2$&K@Jb9O>DUx7}aR1ks}VJa*glZBm%H}dpm zp9gW!ScKZ-KOKizPw!HP*gHb-l0u;$@}EwWmV7$pvo{tV5rO*?Ny9MPSZFN#<_b#3 zeF|DyJ2?DL<>?+1M>%V8m*na06CRu|ao^I+G==)v*S=W1YPc z*0}wO)W*rmIM;tf?n=R2XXbP5Tf6iM3@Q6#sppiXEk zoeDypiX_dWD3W{Fxy}nhXKRe=%Q|}ap|S9v5T|=go@;p0u}kQK0S6w{p8SVjO@7?) zqCBs@V}u_Xi^P&H=U^-}mQGiRqvEGzv_sUAJ(W zzPi4WLb}9N&D^-4&YHc3FANXKYfBvIHWyk`mD z4UMH@=85L68_-zvSoiJT$ib|q+-}FO`p{SmSr69G0Tu2#w`t z%R}av95fcqr2Llqn~f#+X+Hh#W@E{{luv)K*;sPFvGn&kT6;rd zX`IQ^bhxqESWc3ODGi#?STye#a)~L>STye#acxqdvB(+SptNo_md%Ti=g?RlGF3FS z@}aTZEI7I{oC}R5*TiW=*WyI|OmS|c6EqelL+#djXe=!=xw%eG&{&*mwObpZv9!$; z<_0^}>Oo^kkQN64jPN*2OaMD6lg4R%lW)gXe@W(jO5}?Xe=bMXNs#M zG#1CJ#~a`^-8`Xe{}>yUZ7I&{!NdPO?kn{=K;~wz}qU z^}s(>@U{8SY^(?N$Siynx^bb(>fR=AJ+R)h2oE63QLnmzA0F^)s5<1x>|qc1XnD9Y zuA5oC`QxIn{b;zG0#p`{VmJFdUHIl)9eC7!6|O~YdNfs)Ur+$y>bg|3zv_Zoj6HWUeaOT6_~~_BFm4r{KA=|xZt*zXt9y7Bei!N1 zJ!*8n0iQ1(Q4h<572sz^_qQXg$ElE76s}Z#I$^(u)t<`dAz4GtZh4W~=`nF=ler4! z9{8qIpo?x2texC|Uq@B+=L5T{Ha}u!-+O#;pM@VA-Q~KKSY4h*b$r&( z!`gcmz7^eYJ!(<)OS&HE56K}4pcmacwd`BE@Egg4OI0vB!i7(nZYK{qsT+QBbiJ$D zf9pbU@t_~>p3#Lb(H_L=m0?}@V&pMW6LIv$W1;F|_t^n?VrbuQ&%Kkh>}J9c(S(gX(C6J^7s1MB&wy@i6iwkj!kV>Phs2>&(k3qx@i+18sA{J z+Mi7flCSV6aG_QhKW9jao1_xOP4xQ8w^sL6Y<(7o5w}KJD9pHJpV)`Z@!V%E^j$$- zR!D(bR+=5hD5u4hi>K;n8#-!hKP~qucoxVyz*XsBco2? zsz#M|O;vp%W1&2WQ7B$kf3xyYeI}z#d@%)%ShRUZ_t~-z?DG?HmN!)xmN)erv)Sjw za@-dzbWXlW;j?^`=h(h|4y=Rw0)$S=M=89QkMiW)W-QywB<@yNa~nUgQOa>h+@rJM zwNd`R4p5Y8!f3zlPNu}KdGLxDAOB6>BcFcA*sW6=f4oyk$WH;jgDVgZaUR6AGe3&| zJZyZpa~s~PQ(nkjfrxyiFm%Y+y;C25q7#!CsQ{lv3WkSxb&SL~ag+|JChY(2k$BQz zT}OxXC@kYxb=Ymj-{R)93~_UviJGvqxRyua%{t>SB{Tle=hrLNJvwfjDu#|m5LCRi3A;ZJ;Fud8< zokg;jP%yC9Q+VXjbeMIt$(`kGFQTAozg^+BN7P}KN0dA3WcMZAcGT`8Ye)WcIvvW5 zA5io7d2c0vn>JvryIutk=2c@#BT5JMk?RL=y2(`-t)%J=kJ(V&YyYbKJOno-2Ndoy z0~P#^qa1F@Xka}ylpp8;W0BrA@azG2Qb*P zb?qDc9I_rT+n?}J+alSYT{nR=Aj6?xoW(E{47s1kREO_VC*BLxgMu1n%02BKEnwfnNd3(+G9y4IDn-zh#B;bNv*zw>eq+`~8Y zWbM4Fo*PK4!r$tdQBUl-j&={)ICAfG!G6Ev8GkVY(654rO7VC5?%^LN>7yG{L(q-E zePvUTIqtTGQms2@5YanlUS*%Mklw0U`IPZhd4sK)SYV9i4WIymJmdNHu3y5UY`U0G~W z=uT7l3{%tBV|KRgETV0sl!0lJ{3Fw**JJx_Pq18UFH5PKM#;l@$?GwDTThm=?Io#? zK^xv*$Hv|Lj53~g%b>6Q&dcj5={7>I2QF1Nlzgg#9hO+MNJ=LP8*M=)TEF*iE?>6VeQhR%M^dIOH#^Y z=aAgvokJ{w=QJD=P4j2`dzPeK8NN9_0Z>j zmEn0k!|11hw0Bj$MML|Ub15^^k|OVIyCQ~o-@MywPSIh+~nbizhz#UyrugB@&2lN&hY^B?Vt-4$dbQ^ zbtds6>FRkSLza!7`g9xr>Z{dA&K$LOQEiQyd7a}h^GA-W!&OxwyM1t`pndOdy>|KC z%1p9lYMPv-dbSN&5Pr>iwooT{hns-Cf8+Nv0Ph(Y3K#Q zz3KW)4R&RwpJnDE0q3$nxIf*LX~M1RffGf4GnKYEeiEN~AWOtdDZj(V1`ZDFgpn#6!kYvS9fXEr+=be(m01;sml|C{+#;9|rw5eugE>y|01@Fql^P#_h<|-!XPp#4M5*R%ePJg+ zq_a@i7a(%3KC`n;7$72yR^q+hYs>1}>nGqW2@sJqH6LT_bz%+d4Hh^j2@sKNG9T;Q z>%i*Y8z68}5+EWOWzM_2!lTMop#YIUPHyL*FhE4OPMLllAQHs6+4)WwAR@f1Ob-Q!1afjZdxZfa z!X?Vo^8k^c$s4h6gaIOa-7#kYBC$_};jE-yl5XOo5|*$zdGaB~h+m*ARz_G|13T_d z;?EOv28i4uhOGGI0YtR&3W;X`BECEB_aD&$h&Wf^Z@u;hi1dHeGARIv+!_i|PfG!a zq%3Jg6aYj_a|Z3sBLO1F@5C8%fQTtKF~vp;AflC8sC@@aw!6c6j}wW(E9-*{Z3j&MF5dP`k9rset<~-39Zl~fJh-dVC6+W zK&0P6ORWeXQb;|s(wYJgN%7S3E&_;X$s?=8oIh!OC^RM(Fm`Z963zic#73EykuOAv z30l?#v>}R5TDX|LRue$PAK%cOWdIO4Mm&qZ)eR8ozOLx*1rTX2=pi3Z1BfWTIx*w} z5b?)9PSjTfh$uFm=<@}Lys|NrXf*(c7|cA*{%FH@c#MHk6!!v%9Me8qR*(n~Nz7Gr z^wK^D5b17IY;OXH1T@gQ#T5Y}ih3tD-m(EA-BpU!UI39}H9-iNK6wj^CpEzUkx*DO z2{izSz;?+P^S_fN2KoRI{if!z9-9-ak_$#te z8bIXNtwT3JL~;GtQZPUyG(4s|RuLegn0yR#79et->zUT>wF!|fhNy&w0YJncy}|f3 z1J2ACf)dw`14PbEslksXK*WI9VEhNe6#t$fDPdv&5HT2PAiQD2Uq$L{Zv{5CRZ6J5`W4tq2fNbnzmb1Be83i@R490V0Yu-(C0YrwGW_y~yryL-gJRJ5n1BjTpza^dq zh`c?6E($sa5c!iBY7##T5E&jaYa9oN{MmP2BRK;gqS6{N(+m)Kk~6yhstQ0v<Zqsnf4zt0x8!zVoBEu_YdgB0*KWjo(gNpznX4P+N&HzMw zA6Gb!14Q1{_&u&228d(`o4t4f5NTwM??~53Gl0nWlHcRHVSvamypH%SK;#{U99cOG z5E(vo?&q0b5K++xNpAv(G~;`Ej;R1dRFEMXL7NcCxE4Ze0*Ex@yL*nP07T#qIYUi& zr=ezi?;NQJAW|d|@-rA9GHh>#`hOcD0Fhx2GtYMbk+-CP$mhcVkzr>u^LGF0ElGxnPt8Mh`c47o^Hwjhzv)XjlKhjyruiE zHVy+shJ(%O-T_43(oe584+BJoFPkmD1Bkq(`mZ(&14M=c%u3z?MBY+PPd~{hp!nG~ zl)W8GI6wVJsw+uHUu8LD+zd1ImJ>pMI+fP3KX5ueqc2nBB0$8~CK(_ijoh^{zF9=N z?n&oa07MAHzl?8m0Ys96FclX7B6G&iyCkInBGM+i`q%)GxvkIQG134L)tQLwcThy$ z?6Vg6t_l!QrB0P*#{)#-%cWB-03rnKze=yizm%RHAm}e{E?uNVNDuC+nFNT;)Hin3 zsRBd@H4F8HT>z1;Lg^`h$mjaZt~P0ah%}nOdksaTZ=autGX@}nF}*a#fFd%mFIeOp z1|WiIx-`}aMWlaUfXGP9tsb*+=wiy2c5c$g|KmNTmKty`YVtg4O@@Xl05c!L9v+JESKty`kg1!t8`NYZT>XinFNS9bpzW_x3 zn!FMJMj9X@&>gz~5Q%>(Eh$n_kD*8U>MBBtWE< zxU}Mz4-hfTYa`A9M5cD!A3S0R5II>9e(UvS5gGhyXwm`@X&qWpPfG)cq%9dnv;ah+ zat7_sqW~hP@1&Va0FfweVw#O1K*TV$O?wU?GF5&r&CSs9S-ReIW6hw^Nq|VJ{&Hj7 zAV6erz_9%yK!n{`HF(w#AYvHYmM{nq8MHK9Yy*fq3!G%1KmkNh87ED{0V0vC33~|? zKm_&ZWK%dmB$D;PUIYaYf#dk7aDYf8>wWhnLx2crE$Yv~%_1_WcJk-UCPW6U4cFTM zBF`|B5v79wkwKhcavMP8*~TBUp@y3f34;@m11Nw9%8!(O0U&Y--`QPa2oQP3>+QBf z0Yp&lB;o~t$R&JNHxUI8K}C{AE&xO>wRX(58=}P9NHY-)|1%Z>5NRXLMm!k=hzwpZ zeDe$-(l#*@v3b|QZf+Z&BR@+6h@}16?9f~5Fj#m!Z7q1K%|X6zw%-bATsD+sP+sX(ng(I zX-xx&q8KLdyu%A=|zoMF18&6rfch~SPSeg=q0jCNl}y$~fO7+SZ`CMZ5x;bK-= zUjZUB_=euB-2f2^aRGm;7a-Dm9qS$q5Lqke@jIRc5W&8>JTwUqnZZ9!*2e-wu#K1d zrZypBYbevY8z8cKW;OdG6p^tm6jnSMAVSezC@V+?h$QD?9iz2B14Mcoe@$9w&o|I} z#jyYptls5~x0^+zw+dSw4G^K!{Dr^*M6ipLnni%fa#?J0=x%_BmFe0T6N<=?ZKRBW z6+pzQX>F_ripYrVWf@f~fQVJp+E_Ofkzw0QG9RM>BAoG8enwdU5$v_Eyu4n32zH&a zvP7-E@A;9*wASDXMo5e_fGFR79fIE zi>59CL>9QWdgrkK5v+GK=mjqn_howDlYexYhS;E#Y{sxGw zu*MybY5)E$U24{Svd+289jC7=iD!dsA*8szXC+o@IAfoT5Bdn z4N2YjYZD?_*Qms=0FgC(ckdB3fQZ_!6Aq`LHGJ-2WdX?`6xhS)Y;m6eS!@T$vS750FfWmnbn3-fXHZob;&wFYxSc9%V-my@=zoU>30T5BG zcj;*31BmdU1$kyrM6~x#Z*k@ai13@rjHyEr(cQba5bVV1Q3y{ckOt=2StQWL=d9{MFbOeprecriU{8|Dc*^W zO^EQPiyH5MB4XK5u*FXRAR<5%E01EZ*3@MQ`SumBO|$s;kZ_y8h&qe50{01+jQ zTgMit_D07Z>yyF9i@GT3`)c_(&oWmWxd;k%?5+N!UAfi0!5%Y$R%y!sn&i*Jk!U-?!n!JibDEoKW4NhD%crn3Pe zxV$8yB0vPO<8*?p0X zw)-Ia-DXAxAR@`Nr`g~DBDmBfZAE|xqTHY6hI33#*F!dj^cmR#L=yGY8g2Sg%ia10 zaP86n5xK^*eP?k15nOPRUElHYi+z^3#Uy}8a-b~xovf7oUD*selkEL ziKGy5p>HSoT;B!Un`D4U(u90OWS;?!-JCS8NWM%{B?r(>;w~mb5lI?Hk+0DN*giB{ zoOCikB#EHNrqGaVf0{eaB^e-+WQAIx_DQhGeNH%|WPnH#U2)~NJ{9(vz7x1mxLNs< zL`SV$?Gs`9_Br6xk^v$~RK=BO8j5|I=85x828iIU%bpc={)GFGWNcHw5a${us4@`e zMU9y2vM)q!5^&ZDGzMjo7A~f)l@1V*$A>esGyo!YL^R%uIiqgRypC|U2Z&@AXp!I2 z03wK24nwj45qW%Mf<6Kuf@pN;LjXifY~W-?10bR?la{>)iij}-g%Gy~h}dbP%Z??m z%k2_!5svoSs`y$Wvk}pr4n+jjKxK*}03rxIhYd?8B96=|M72FY#I8mefdGiW4M~j( zKt!!nSOh=>k!*)S14OXgH?(&9O^9ePXrdY# z01=J!6k{_eBG!xq(QEqwBC1m*qVVz+xjYSGit$b;BD)#sq9z&u5sje~f;kkCJ?uD` z(jaLog&$_Zl?LrTBHx~b1&AnzdobZjgZ2dR#-5}G5K#(uXAUEt+QEYWz9$ksBLE_Z zi}vHH01*}6Qwjed03rx!djbX^f}T2-FpU6+AYAMTssIrcE{VAcLn~@oB%Njg&n(Tg_EH0I!_Pi||m-PbSyaoOpw0E(qwegAjKUj-30RR91 literal 0 HcmV?d00001 diff --git a/tests/image_probe.py b/tests/image_probe.py new file mode 100644 index 0000000..0a96d17 --- /dev/null +++ b/tests/image_probe.py @@ -0,0 +1,26 @@ +"""Read JPEG SOF dimensions without external libraries; not a full image decoder.""" +from client import Failure, check + +def jpeg_size(data): + check(data[:2]==b'\xff\xd8','thumbnail is not JPEG') + i = 2 + sof = {0xc0,0xc1,0xc2,0xc3,0xc5,0xc6,0xc7,0xc9,0xca,0xcb,0xcd,0xce,0xcf} + while i < len(data): + check(data[i]==0xff,'invalid JPEG marker') + while i=2 and i+length<=len(data),'invalid JPEG segment length') + if marker in sof: + check(length>=8,'invalid JPEG SOF') + return (int.from_bytes(data[i+5:i+7],'big'),int.from_bytes(data[i+3:i+5],'big')) + i += length + raise Failure('JPEG dimensions not found') diff --git a/tests/load.py b/tests/load.py new file mode 100644 index 0000000..ad81d59 --- /dev/null +++ b/tests/load.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Small closed-loop HTTP write benchmark; not an open-loop capacity proof.""" +from concurrent.futures import ThreadPoolExecutor +import json +import math +from pathlib import Path +import threading +import time +from client import Client, Failure, LAB, arguments, check, run + + +def main(): + def configure(p): + p.add_argument('--duration',type=float,default=30) + p.add_argument('--concurrency',type=int,default=4) + p.add_argument('--output',default=None,help='Optional JSON metrics, no session tokens') + args = arguments('Synthetic POST message load, closed loop; creates users/messages',configure) + check(1<=args.duration<=3600,'duration must be 1..3600 seconds') + check(1<=args.concurrency<=128,'concurrency must be 1..128') + c = Client(args.base_url,args.ca_file) + alice,bob,eve,chat = c.bootstrap() + barrier = threading.Barrier(args.concurrency) + def worker(worker_id): + local = Client(args.base_url,args.ca_file,timeout=5) + barrier.wait() + deadline = time.monotonic()+args.duration + latencies,errors = [],0 + index = 0 + while time.monotonic()=2,'persistence requires lab2+') + check(args.mode!='failover' or LAB>=3,'failover requires lab3+') + script = Path(args.action_script).expanduser().resolve() + check(script.is_file() and os.access(script,os.X_OK),'action script must exist and be executable (chmod +x)') + check('.example.' not in script.name,'copy and implement the example hook first') + c = Client(args.base_url,args.ca_file,timeout=2) + alice,bob,eve,chat = c.bootstrap() + sent = c.message(alice,chat,'Сохранить при отказе') + original = sent['json'] + target = sent['headers'].get('X-Instance-Id','') + attachment = content = None + if LAB>=4: + attachment,content = c.attachment(alice,chat) + if LAB>=5: + attachment = c.wait_ready(alice,attachment) + env = dict(os.environ) + env['TARGET_INSTANCE_ID'] = target + observed = set() + if args.mode=='failover': + for _ in range(40): + observed.add(c.call('GET','/api/v1/users/me',actor=alice,schema='User')['headers']['X-Instance-Id']) + check(len(observed)>=2,'less than two API instances observed before failure; verify LB/session affinity') + print('Executing explicit '+args.mode+' hook; hook output suppressed to avoid leaking secrets.') + # No shell interpolation and no Docker commands in this test. + proc = subprocess.Popen([str(script)],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + started = time.monotonic() + successes,failures,after_instances = [],0,[] + try: + if args.mode=='persistence': + try: + code = proc.wait(timeout=90) + except subprocess.TimeoutExpired: + raise Failure('restart hook exceeded 90 seconds') from None + check(code==0,'restart hook failed (inspect your script locally)') + deadline = time.monotonic()+90 + while True: + try: + c.call('GET','/health/ready',schema='Health') + c.call('GET','/api/v1/users/me',actor=alice,schema='User') + break + except Failure: + check(time.monotonic()=5,'too few successful writes after stop') + check(target not in after_instances[-5:],'target replica still serves requests; hook must leave it stopped') + print(json.dumps({'observed_instances_before':len(observed),'successful_writes':len(successes),'failed_attempts':failures,'first_recovery_seconds':round(first_recovery,3)},ensure_ascii=False)) + # Existing token, no re-login. Read all confirmations and reject duplicates. + messages = history(c,bob,chat) + ids = [m['id'] for m in messages] + check(len(ids)==len(set(ids)),'duplicated message IDs in history') + check(any(m==original for m in messages),'confirmed pre-failure message lost or altered') + for msg in successes: + check(any(m==msg for m in messages),'confirmed in-flight message lost or altered') + # Bodies are unique in this scenario: an uncertain request may exist once, never twice. + texts = [m['text'] for m in messages] + check(len(texts)==len(set(texts)),'uncertain retry created duplicate message') + if attachment: + c.call('GET','/api/v1/attachments/'+attachment['id'],actor=bob,schema='Attachment') + downloaded = c.call('GET','/api/v1/attachments/'+attachment['id']+'/content',actor=bob) + check(downloaded['bytes']==content,'attachment lost or changed across failure') + print('PASS: '+args.mode+' preserved confirmed data and existing session. Hook scope needs operator evidence.') + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + +if __name__=='__main__': + run(main) diff --git a/tests/smoke.py b/tests/smoke.py new file mode 100644 index 0000000..4cbfa55 --- /dev/null +++ b/tests/smoke.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""One complete public acceptance scenario, cumulative by lab number.""" +import base64 +import hashlib +import uuid +from client import Client, LAB, arguments, check, run, timestamp + + +def main(): + args = arguments('Messenger public smoke; creates synthetic users and messages') + c = Client(args.base_url,args.ca_file) + alice,bob,eve,chat = c.bootstrap() + path = '/api/v1/chats/'+chat['id'] + c.call('POST','/api/v1/users',409,body=alice['registration']) + c.call('GET','/api/v1/users/me',401) + c.call('GET',path,404,actor=eve) + me = c.call('GET','/api/v1/users/me',actor=alice,schema='User')['json'] + check(me==alice['user'],'current user mismatch') + listed = c.call('GET','/api/v1/chats?limit=100',actor=bob,schema='ChatPage')['json'] + check(any(x['id']==chat['id'] for x in listed['items']),'member cannot list chat') + hidden = c.call('GET','/api/v1/chats?limit=100',actor=eve,schema='ChatPage')['json'] + check(all(x['id']!=chat['id'] for x in hidden['items']),'chat leaks to outsider') + key = uuid.uuid4().hex + first = c.message(alice,chat,'Привет, мир 👋',key=key)['json'] + second = c.message(bob,chat,'Сообщение Боба')['json'] + check(first['sender_id']==alice['id'] and second['sender_id']==bob['id'],'sender was not derived from actor') + check(first['chat_id']==second['chat_id']==chat['id'],'wrong chat in message') + check(first['text']=='Привет, мир 👋' and second['text']=='Сообщение Боба','message text changed') + page1 = c.call('GET',path+'/messages?limit=1',actor=bob,schema='MessagePage')['json'] + check(len(page1['items'])==1 and page1['next_cursor'],'first page must have one item and cursor') + from urllib.parse import quote + page2 = c.call('GET',path+'/messages?limit=1&cursor='+quote(page1['next_cursor'],safe=''),actor=alice,schema='MessagePage')['json'] + check(len(page2['items'])==1 and page2['next_cursor'] is None,'second page must finish history') + ordered = sorted([first,second],key=lambda x:(timestamp(x['created_at']),uuid.UUID(x['id']).int)) + check(page1['items']+page2['items']==ordered,'pagination lost, duplicated or reordered a message') + h = {'Idempotency-Key':uuid.uuid4().hex} if LAB>=3 else {} + c.call('POST',path+'/messages',400,actor=alice,body={'text':''},headers=h) + c.call('POST',path+'/messages',404,actor=eve,body={'text':'чужое'},headers=h) + c.call('GET',path+'/messages?limit=0',400,actor=alice) + if LAB>=2: + bad = base64.b64encode((alice['registration']['username']+':wrong-password').encode()).decode() + c.call('POST','/api/v1/auth/sessions',401,headers={'Authorization':'Basic '+bad}) + good = base64.b64encode((alice['registration']['username']+':'+alice['registration']['password']).encode()).decode() + c.call('GET','/api/v1/users/me',401,headers={'Authorization':'Basic '+good}) + c.call('GET','/api/v1/users/me',401,headers={'X-User-Id':alice['id']}) + me = c.call('GET','/api/v1/users/me',actor=alice,headers={'X-User-Id':eve['id']},schema='User')['json'] + check(me['id']==alice['id'],'X-User-Id overrode bearer identity') + if LAB>=3: + replay = c.message(alice,chat,first['text'],key=key)['json'] + check(replay==first,'idempotency replay differs') + c.call('POST',path+'/messages',409,actor=alice,body={'text':'другой body'},headers={'Idempotency-Key':key}) + c.call('POST',path+'/messages',400,actor=alice,body={'text':'без ключа'}) + if LAB>=4: + att,content = c.attachment(alice,chat) + # A queued attachment must already be linkable to its own chat. + attached = c.message(alice,chat,'',attachments=[att['id']])['json'] + check(attached['attachment_ids']==[att['id']],'message lost attachment') + ready = c.wait_ready(bob,att) if LAB>=5 else att + check(ready['error_code'] is None,'ready attachment has an error') + download = c.call('GET','/api/v1/attachments/'+att['id']+'/content',actor=bob) + check(download['bytes']==content,'download differs from original') + check(download['headers'].get_content_type()=='image/png','original content type mismatch') + for suffix in ('','/content'): + c.call('GET','/api/v1/attachments/'+att['id']+suffix,404,actor=eve) + if LAB>=5: + variants = [x for x in ready['variants'] if x['name']=='thumbnail'] + check(len(variants)==1,'expected exactly one thumbnail') + v = variants[0] + check((v['width'],v['height'])==(256,160),'thumbnail metadata has wrong dimensions') + thumb = c.call('GET','/api/v1/attachments/'+att['id']+'/content?variant=thumbnail',actor=bob) + check(thumb['headers'].get_content_type()=='image/jpeg','thumbnail content type mismatch') + check(len(thumb['bytes'])==v['size_bytes'] and hashlib.sha256(thumb['bytes']).hexdigest()==v['sha256'],'thumbnail checksum mismatch') + from image_probe import jpeg_size + check(jpeg_size(thumb['bytes'])==(256,160),'actual JPEG dimensions mismatch') + if LAB>=2: + c.call('DELETE','/api/v1/auth/sessions/current',204,actor=alice) + c.call('GET','/api/v1/users/me',401,actor=alice) + c.call('DELETE','/api/v1/auth/sessions/current',401,actor=alice) + print('PASS: lab%d public API scenario; infrastructure and grading evidence remain separate.' % LAB) + +if __name__=='__main__': + run(main)