From 44db38f6fe15d26ea9972a36ff9d9a44f1ac25a3 Mon Sep 17 00:00:00 2001 From: mi Date: Thu, 3 Sep 2026 19:12:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=BE=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=20=D0=B8=20=D1=82=D1=80=D0=B5=D0=B1?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20soft\force=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../codebase/backend/api-backend/README.md | 15 + .../api-backend/app/cli/seed_settings.py | 24 + .../codebase/backend/api-backend/app/main.py | 55 +- .../api-backend/app/mobile_update_settings.py | 112 ++++ .../backend/api-backend/app/schemas.py | 90 +++ .../backend/api-backend/app/services.py | 32 +- .../codebase/backend/api-backend/openapi.yaml | 112 +++- .../tests/contract/test_openapi.py | 116 +++- .../tests/unit/test_cli_settings.py | 106 ++++ .../deployment/RUNBOOK.mobile-updates.ru.md | 479 +++++++++++++++ .../deployment/RUNBOOK.production.ru.md | 16 +- .../app-settings.production-like.yaml | 20 +- .../deployment/kesl/EVIDENCE.AVZ.ru.md | 192 ++++++ .../deployment/kesl/RUNBOOK.KESL.ru.md | 566 ++++++++++++++++++ .../kesl/policy-standalone.conf.example | 90 +++ .../nginx/templates/site-tls.conf.template | 4 +- VM1_app/figma/Main page specification.zip | Bin 96955 -> 100339 bytes VM1_app/figma/src/app/App.tsx | 3 + .../figma/src/app/components/UpdateModal.tsx | 170 ++++++ .../figma/src/app/contexts/UpdateContext.tsx | 43 ++ VM1_app/figma/src/app/pages/Home.tsx | 31 + VM1_app/figma/src/app/pages/Root.tsx | 3 + VM4_Expo-mobile/.env.example | 1 + VM4_Expo-mobile/README.md | 92 ++- VM4_Expo-mobile/app.config.ts | 7 +- VM4_Expo-mobile/app/_layout.tsx | 15 +- VM4_Expo-mobile/eas.json | 61 +- VM4_Expo-mobile/package-lock.json | 5 +- VM4_Expo-mobile/package.json | 3 +- VM4_Expo-mobile/src/app-update-context.tsx | 98 +++ VM4_Expo-mobile/src/app-update.ts | 134 +++++ .../src/components/UpdateModal.tsx | 232 +++++++ VM4_Expo-mobile/src/config.ts | 8 + VM4_Expo-mobile/src/types.ts | 16 + VM4_Expo-mobile/tests/unit/app-update.test.ts | 114 ++++ VM4_Expo-mobile/tests/unit/core.test.ts | 4 +- support¬es/deploy_stories/260903_2.md | 41 ++ 37 files changed, 3057 insertions(+), 53 deletions(-) create mode 100644 VM1_app/codebase/backend/api-backend/app/mobile_update_settings.py create mode 100644 VM1_app/codebase/backend/deployment/RUNBOOK.mobile-updates.ru.md create mode 100644 VM1_app/codebase/backend/deployment/kesl/EVIDENCE.AVZ.ru.md create mode 100644 VM1_app/codebase/backend/deployment/kesl/RUNBOOK.KESL.ru.md create mode 100644 VM1_app/codebase/backend/deployment/kesl/policy-standalone.conf.example create mode 100644 VM1_app/figma/src/app/components/UpdateModal.tsx create mode 100644 VM1_app/figma/src/app/contexts/UpdateContext.tsx create mode 100644 VM4_Expo-mobile/src/app-update-context.tsx create mode 100644 VM4_Expo-mobile/src/app-update.ts create mode 100644 VM4_Expo-mobile/src/components/UpdateModal.tsx create mode 100644 VM4_Expo-mobile/tests/unit/app-update.test.ts create mode 100644 support¬es/deploy_stories/260903_2.md diff --git a/VM1_app/codebase/backend/api-backend/README.md b/VM1_app/codebase/backend/api-backend/README.md index b1623f4..46d5159 100644 --- a/VM1_app/codebase/backend/api-backend/README.md +++ b/VM1_app/codebase/backend/api-backend/README.md @@ -84,3 +84,18 @@ pytest зависимости read API. Remote Message Safety не выключает чтение/общую readiness: send endpoint отдельно проверяет требуемую capability и fail-closed возвращает `503`, если ВМ2 недоступна. + +## Публичная конфигурация мобильных обновлений + +`GET /api/v1/public/app-config` возвращает строгий объект `mobile_update` с +политиками `google_play`, `rustore` и `app_store`. Для включённого магазина +обязательны `latest_build`, `minimum_build`, `latest_version` и HTTPS `store_url`; +`minimum_build` не может превышать `latest_build`. Для отключённого магазина +эти поля возвращаются как `null`. Опциональное `release_notes` также возвращается +как `null`, если в settings задана пустая строка. Канонический URL RuStore: +`https://www.rustore.ru/catalog/app/ru.han.chat`. + +Ответ содержит `ETag`, поддерживает `If-None-Match` с ответом `304` и в +production-like конфигурации кэшируется клиентом и nginx 60 секунд. Изменения +политики применяются через штатный идемпотентный `seed-settings`; некорректные +пороги, типы и URL блокируют загрузку settings. diff --git a/VM1_app/codebase/backend/api-backend/app/cli/seed_settings.py b/VM1_app/codebase/backend/api-backend/app/cli/seed_settings.py index 2d2e592..f7e2378 100644 --- a/VM1_app/codebase/backend/api-backend/app/cli/seed_settings.py +++ b/VM1_app/codebase/backend/api-backend/app/cli/seed_settings.py @@ -11,6 +11,12 @@ from sqlalchemy.dialects.postgresql import insert from app.chat_settings import CHAT_MESSAGE_MAX_LENGTH_KEY, validate_chat_settings from app.db import AppSetting, Database +from app.mobile_update_settings import ( + MOBILE_UPDATE_BOOLEAN_KEYS, + MOBILE_UPDATE_INTEGER_KEYS, + MOBILE_UPDATE_SETTING_KEYS, + validate_mobile_update_settings, +) from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings from app.settings import get_settings @@ -38,12 +44,25 @@ def load_seed(path: Path) -> list[dict[str, Any]]: raise ValueError(f"{key}: type must be integer") if key == CHAT_MESSAGE_MAX_LENGTH_KEY and value_type != "integer": raise ValueError(f"{key}: type must be integer") + if key in MOBILE_UPDATE_BOOLEAN_KEYS and value_type != "boolean": + raise ValueError(f"{key}: type must be boolean") + if key in MOBILE_UPDATE_INTEGER_KEYS and value_type != "integer": + raise ValueError(f"{key}: type must be integer") + if ( + key in MOBILE_UPDATE_SETTING_KEYS + - MOBILE_UPDATE_BOOLEAN_KEYS + - MOBILE_UPDATE_INTEGER_KEYS + and value_type != "string" + ): + raise ValueError(f"{key}: type must be string") if not isinstance(raw.get("public"), bool): raise ValueError(f"{key}: public must be a boolean") if key in OTP_SETTING_KEYS and raw["public"]: raise ValueError(f"{key}: OTP setting must not be public") if key == CHAT_MESSAGE_MAX_LENGTH_KEY and not raw["public"]: raise ValueError(f"{key}: setting must be public") + if key in MOBILE_UPDATE_SETTING_KEYS and not raw["public"]: + raise ValueError(f"{key}: mobile update setting must be public") description = raw.get("description") if description is not None and not isinstance(description, str): raise ValueError(f"{key}: description must be a string") @@ -59,6 +78,9 @@ def load_seed(path: Path) -> list[dict[str, Any]]: ) validate_otp_settings({row["setting_key"]: row["setting_value"] for row in rows}) validate_chat_settings({row["setting_key"]: row["setting_value"] for row in rows}) + validate_mobile_update_settings( + {row["setting_key"]: row["setting_value"] for row in rows} + ) return rows @@ -68,6 +90,8 @@ def serialize_value(key: str, value_type: str, value: Any) -> str: raise ValueError(f"{key}: boolean value expected") return str(value).lower() if value_type == "integer": + if key in MOBILE_UPDATE_INTEGER_KEYS and value is None: + return "" if not isinstance(value, int) or isinstance(value, bool): raise ValueError(f"{key}: integer value expected") return str(value) diff --git a/VM1_app/codebase/backend/api-backend/app/main.py b/VM1_app/codebase/backend/api-backend/app/main.py index c7ace34..56402bf 100644 --- a/VM1_app/codebase/backend/api-backend/app/main.py +++ b/VM1_app/codebase/backend/api-backend/app/main.py @@ -63,6 +63,7 @@ from app.schemas import ( MessageRequest, OpenLinesInbox, OtpSettingsResponse, + PublicAppConfigResponse, SessionStartRequest, decode_cursor, encode_cursor, @@ -192,6 +193,15 @@ app.include_router(notification_router) log = structlog.get_logger() +def etag_matches(if_none_match: str | None, etag: str) -> bool: + if not if_none_match: + return False + return any( + candidate.strip().removeprefix("W/") in {"*", etag} + for candidate in if_none_match.split(",") + ) + + def client_ip(request: Request) -> str | None: """Trust forwarded client addresses only from configured reverse proxies.""" peer = request.client.host if request.client else "" @@ -501,8 +511,17 @@ async def ready(request: Request, db: Session): ) -@app.get("/api/v1/public/app-config", tags=["public"]) -async def app_config(request: Request, response: Response, settings: SnapshotDep): +@app.get( + "/api/v1/public/app-config", + tags=["public"], + response_model=PublicAppConfigResponse, + responses={304: {"description": "Cached configuration is still current"}}, +) +async def app_config( + request: Request, + settings: SnapshotDep, + if_none_match: Annotated[str | None, Header()] = None, +): await enforce_limit( request, "ip", @@ -511,12 +530,15 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep settings.limit("rate_limit.public_endpoints.per_ip"), fail_closed=False, ) - response.headers["Cache-Control"] = ( + headers = {"Cache-Control": ( f"public, max-age={settings.integer('security.public_cache.max_age_seconds')}" - ) - response.headers["ETag"] = f'"{settings.version}"' + )} + etag = f'"{settings.version}"' + headers["ETag"] = etag + if etag_matches(if_none_match, etag): + return Response(status_code=304, headers=headers) values = settings.values - return { + body = { "auth": { "phone_enabled": settings.boolean("auth.phone.enabled"), "password_enabled": settings.boolean("auth.password.enabled"), @@ -557,7 +579,28 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep ), }, "ux": {"idle_timeout_minutes": settings.integer("ux.session.idle_timeout_minutes")}, + "mobile_update": { + store: { + "enabled": settings.boolean(f"mobile_update.{store}.enabled"), + "latest_build": ( + settings.integer(f"mobile_update.{store}.latest_build") + if values[f"mobile_update.{store}.latest_build"] + else None + ), + "minimum_build": ( + settings.integer(f"mobile_update.{store}.minimum_build") + if values[f"mobile_update.{store}.minimum_build"] + else None + ), + "latest_version": values[f"mobile_update.{store}.latest_version"] or None, + "store_url": values[f"mobile_update.{store}.store_url"] or None, + "release_notes": values[f"mobile_update.{store}.release_notes"] or None, + } + for store in ("google_play", "rustore", "app_store") + }, } + validated = PublicAppConfigResponse.model_validate(body) + return JSONResponse(validated.model_dump(mode="json"), headers=headers) @app.get("/api/v1/public/content", tags=["public"]) diff --git a/VM1_app/codebase/backend/api-backend/app/mobile_update_settings.py b/VM1_app/codebase/backend/api-backend/app/mobile_update_settings.py new file mode 100644 index 0000000..d2f03b6 --- /dev/null +++ b/VM1_app/codebase/backend/api-backend/app/mobile_update_settings.py @@ -0,0 +1,112 @@ +from collections.abc import Mapping +from urllib.parse import parse_qs, urlparse + +MOBILE_UPDATE_STORES = ("google_play", "rustore", "app_store") +MOBILE_UPDATE_FIELDS = ( + "enabled", + "latest_build", + "minimum_build", + "latest_version", + "store_url", + "release_notes", +) +MOBILE_UPDATE_SETTING_KEYS = { + f"mobile_update.{store}.{field}" + for store in MOBILE_UPDATE_STORES + for field in MOBILE_UPDATE_FIELDS +} +MOBILE_UPDATE_BOOLEAN_KEYS = { + f"mobile_update.{store}.enabled" for store in MOBILE_UPDATE_STORES +} +MOBILE_UPDATE_INTEGER_KEYS = { + f"mobile_update.{store}.{field}" + for store in MOBILE_UPDATE_STORES + for field in ("latest_build", "minimum_build") +} + +ANDROID_PACKAGE = "ru.han.chat" + + +def validate_mobile_update_settings(values: Mapping[str, str]) -> None: + for store in MOBILE_UPDATE_STORES: + prefix = f"mobile_update.{store}" + required = [f"{prefix}.{field}" for field in MOBILE_UPDATE_FIELDS] + present = [key for key in required if key in values] + if not present: + continue + if len(present) != len(required): + missing = sorted(set(required) - values.keys()) + raise ValueError(f"{prefix}: incomplete policy; missing {missing}") + + enabled = _boolean(values[f"{prefix}.enabled"], f"{prefix}.enabled") + latest_build = _optional_build(values[f"{prefix}.latest_build"], f"{prefix}.latest_build") + minimum_build = _optional_build( + values[f"{prefix}.minimum_build"], f"{prefix}.minimum_build" + ) + latest_version = values[f"{prefix}.latest_version"].strip() + store_url = values[f"{prefix}.store_url"].strip() + release_notes = values[f"{prefix}.release_notes"].strip() + if len(release_notes) > 4000: + raise ValueError(f"{prefix}.release_notes: value must not exceed 4000 characters") + + if not enabled: + if any( + value not in (None, "") + for value in ( + latest_build, + minimum_build, + latest_version, + store_url, + ) + ): + raise ValueError(f"{prefix}: disabled policy fields must be empty") + continue + + if latest_build is None or minimum_build is None: + raise ValueError(f"{prefix}: enabled policy requires build thresholds") + if minimum_build > latest_build: + raise ValueError(f"{prefix}: minimum_build must not exceed latest_build") + if not latest_version: + raise ValueError(f"{prefix}: enabled policy requires latest_version") + _validate_store_url(store, store_url, f"{prefix}.store_url") + + +def _boolean(raw: str, key: str) -> bool: + if raw not in {"true", "false"}: + raise ValueError(f"{key}: canonical boolean value expected") + return raw == "true" + + +def _optional_build(raw: str, key: str) -> int | None: + if raw == "": + return None + try: + value = int(raw) + except ValueError as error: + raise ValueError(f"{key}: integer value expected") from error + if str(value) != raw or value < 1: + raise ValueError(f"{key}: positive canonical integer value expected") + return value + + +def _validate_store_url(store: str, raw: str, key: str) -> None: + parsed = urlparse(raw) + if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password: + raise ValueError(f"{key}: absolute HTTPS URL expected") + if parsed.fragment: + raise ValueError(f"{key}: URL fragments are not allowed") + + host = parsed.hostname.lower() + if store == "google_play": + package = parse_qs(parsed.query).get("id", []) + valid = host == "play.google.com" and parsed.path == "/store/apps/details" + valid = valid and package == [ANDROID_PACKAGE] + elif store == "rustore": + valid = host == "www.rustore.ru" and parsed.path == ( + f"/catalog/app/{ANDROID_PACKAGE}" + ) + valid = valid and not parsed.query + else: + valid = host == "apps.apple.com" and "/id" in parsed.path + if not valid: + raise ValueError(f"{key}: URL does not match the {store} application") diff --git a/VM1_app/codebase/backend/api-backend/app/schemas.py b/VM1_app/codebase/backend/api-backend/app/schemas.py index 2e7f5b7..89a7aaa 100644 --- a/VM1_app/codebase/backend/api-backend/app/schemas.py +++ b/VM1_app/codebase/backend/api-backend/app/schemas.py @@ -27,6 +27,96 @@ class OtpSettingsResponse(StrictModel): cache_ttl_seconds: int = Field(strict=True, gt=0) +class PublicAuthConfig(StrictModel): + phone_enabled: bool + password_enabled: bool + + +class PublicOperatorConfig(StrictModel): + call_phone: str = Field(min_length=1, max_length=32) + + +class PublicMessagesConfig(StrictModel): + max_text_length: int = Field(strict=True, ge=1, le=CHAT_MESSAGE_TRANSPORT_MAX_LENGTH) + + +class PublicConsentConfig(StrictModel): + required: bool + document_url: HttpUrl + version: str = Field(min_length=1, max_length=64) + + +class PublicPersonalDataConsentConfig(PublicConsentConfig): + privacy_policy_document_url: HttpUrl + + +class PublicConsentsConfig(StrictModel): + personal_data: PublicPersonalDataConsentConfig + user_agreement: PublicConsentConfig + marketing: PublicConsentConfig + + +class PublicAttachmentsConfig(StrictModel): + allowed_extensions: list[str] + allowed_mime_types: list[str] + max_size_mb: int = Field(strict=True, gt=0) + + +class PublicNotificationConfig(StrictModel): + carousel_autoplay_enabled: bool + carousel_autoplay_interval_ms: int = Field(strict=True, gt=0) + + +class PublicUxConfig(StrictModel): + idle_timeout_minutes: int = Field(strict=True, gt=0) + + +class MobileStoreUpdatePolicy(StrictModel): + enabled: bool + latest_build: int | None = Field(strict=True, ge=1) + minimum_build: int | None = Field(strict=True, ge=1) + latest_version: str | None = Field(min_length=1, max_length=64) + store_url: HttpUrl | None + release_notes: str | None = Field(max_length=4000) + + @model_validator(mode="after") + def validate_policy(self) -> "MobileStoreUpdatePolicy": + release_fields = ( + self.latest_build, + self.minimum_build, + self.latest_version, + self.store_url, + ) + if self.enabled and any(value is None for value in release_fields): + raise ValueError("enabled update policy requires all release fields") + if not self.enabled and any(value is not None for value in release_fields): + raise ValueError("disabled update policy must not expose release fields") + if ( + self.minimum_build is not None + and self.latest_build is not None + and self.minimum_build > self.latest_build + ): + raise ValueError("minimum_build must not exceed latest_build") + return self + + +class MobileUpdatePolicy(StrictModel): + google_play: MobileStoreUpdatePolicy + rustore: MobileStoreUpdatePolicy + app_store: MobileStoreUpdatePolicy + + +class PublicAppConfigResponse(StrictModel): + auth: PublicAuthConfig + operator: PublicOperatorConfig + messages: PublicMessagesConfig + consents: PublicConsentsConfig + attachments: PublicAttachmentsConfig + notification: PublicNotificationConfig + ux: PublicUxConfig + mobile_update: MobileUpdatePolicy + + class Device(StrictModel): platform: Literal["ios", "android", "web"] app_version: str = Field(min_length=1, max_length=64) diff --git a/VM1_app/codebase/backend/api-backend/app/services.py b/VM1_app/codebase/backend/api-backend/app/services.py index c929a38..c602663 100644 --- a/VM1_app/codebase/backend/api-backend/app/services.py +++ b/VM1_app/codebase/backend/api-backend/app/services.py @@ -38,6 +38,12 @@ from app.integrations import ( SafetyClient, fresh_openlines_payload, ) +from app.mobile_update_settings import ( + MOBILE_UPDATE_BOOLEAN_KEYS, + MOBILE_UPDATE_INTEGER_KEYS, + MOBILE_UPDATE_SETTING_KEYS, + validate_mobile_update_settings, +) from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings from app.realtime import RealtimeFanout from app.schemas import ( @@ -226,7 +232,7 @@ REQUIRED_SETTINGS = { "rate_limit.notification_upload.per_user", "rate_limit.notifications_public.per_ip", CHAT_MESSAGE_MAX_LENGTH_KEY, -} | OTP_SETTING_KEYS +} | OTP_SETTING_KEYS | MOBILE_UPDATE_SETTING_KEYS @dataclass(frozen=True, slots=True) @@ -286,15 +292,33 @@ async def load_settings(session: AsyncSession) -> SettingsSnapshot: invalid_metadata = sorted( row.setting_key for row in rows - if row.setting_key in OTP_SETTING_KEYS - and (row.value_type != "integer" or row.is_public) + if ( + row.setting_key in OTP_SETTING_KEYS + and (row.value_type != "integer" or row.is_public) + ) + or ( + row.setting_key in MOBILE_UPDATE_BOOLEAN_KEYS + and (row.value_type != "boolean" or not row.is_public) + ) + or ( + row.setting_key in MOBILE_UPDATE_INTEGER_KEYS + and (row.value_type != "integer" or not row.is_public) + ) + or ( + row.setting_key + in MOBILE_UPDATE_SETTING_KEYS + - MOBILE_UPDATE_BOOLEAN_KEYS + - MOBILE_UPDATE_INTEGER_KEYS + and (row.value_type != "string" or not row.is_public) + ) ) if invalid_metadata: raise ValueError( - f"OTP settings must have integer type and be private: {invalid_metadata}" + f"Settings metadata is invalid: {invalid_metadata}" ) validate_otp_settings(values) validate_chat_settings(values) + validate_mobile_update_settings(values) except ValueError as error: raise DomainError( "dependency_unavailable", diff --git a/VM1_app/codebase/backend/api-backend/openapi.yaml b/VM1_app/codebase/backend/api-backend/openapi.yaml index 50197e1..f5e82df 100644 --- a/VM1_app/codebase/backend/api-backend/openapi.yaml +++ b/VM1_app/codebase/backend/api-backend/openapi.yaml @@ -19,20 +19,15 @@ paths: /api/v1/public/app-config: get: operationId: getPublicAppConfig + parameters: + - {name: If-None-Match, in: header, schema: {type: string}} responses: "200": description: Public application configuration content: application/json: - schema: - type: object - required: [messages] - properties: - messages: - type: object - required: [max_text_length] - properties: - max_text_length: {type: integer, minimum: 1, maximum: 10000} + schema: {$ref: "#/components/schemas/PublicAppConfigResponse"} + "304": {description: Cached configuration is still current} /api/v1/public/content: get: operationId: getPublicContent @@ -405,6 +400,105 @@ components: description: Catalog action is not allowed content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}} schemas: + PublicAppConfigResponse: + type: object + additionalProperties: false + required: [auth, operator, messages, consents, attachments, notification, ux, mobile_update] + properties: + auth: {$ref: "#/components/schemas/PublicAuthConfig"} + operator: {$ref: "#/components/schemas/PublicOperatorConfig"} + messages: {$ref: "#/components/schemas/PublicMessagesConfig"} + consents: {$ref: "#/components/schemas/PublicConsentsConfig"} + attachments: {$ref: "#/components/schemas/PublicAttachmentsConfig"} + notification: {$ref: "#/components/schemas/PublicNotificationConfig"} + ux: {$ref: "#/components/schemas/PublicUxConfig"} + mobile_update: {$ref: "#/components/schemas/MobileUpdatePolicy"} + PublicAuthConfig: + type: object + additionalProperties: false + required: [phone_enabled, password_enabled] + properties: + phone_enabled: {type: boolean} + password_enabled: {type: boolean} + PublicOperatorConfig: + type: object + additionalProperties: false + required: [call_phone] + properties: + call_phone: {type: string, minLength: 1, maxLength: 32} + PublicMessagesConfig: + type: object + additionalProperties: false + required: [max_text_length] + properties: + max_text_length: {type: integer, minimum: 1, maximum: 10000} + PublicConsentConfig: + type: object + additionalProperties: false + required: [required, document_url, version] + properties: + required: {type: boolean} + document_url: {type: string, format: uri, minLength: 1} + version: {type: string, minLength: 1, maxLength: 64} + PublicPersonalDataConsentConfig: + allOf: + - {$ref: "#/components/schemas/PublicConsentConfig"} + - type: object + additionalProperties: false + required: [required, document_url, version, privacy_policy_document_url] + properties: + required: {type: boolean} + document_url: {type: string, format: uri, minLength: 1} + version: {type: string, minLength: 1, maxLength: 64} + privacy_policy_document_url: {type: string, format: uri, minLength: 1} + PublicConsentsConfig: + type: object + additionalProperties: false + required: [personal_data, user_agreement, marketing] + properties: + personal_data: {$ref: "#/components/schemas/PublicPersonalDataConsentConfig"} + user_agreement: {$ref: "#/components/schemas/PublicConsentConfig"} + marketing: {$ref: "#/components/schemas/PublicConsentConfig"} + PublicAttachmentsConfig: + type: object + additionalProperties: false + required: [allowed_extensions, allowed_mime_types, max_size_mb] + properties: + allowed_extensions: {type: array, items: {type: string}} + allowed_mime_types: {type: array, items: {type: string}} + max_size_mb: {type: integer, minimum: 1} + PublicNotificationConfig: + type: object + additionalProperties: false + required: [carousel_autoplay_enabled, carousel_autoplay_interval_ms] + properties: + carousel_autoplay_enabled: {type: boolean} + carousel_autoplay_interval_ms: {type: integer, minimum: 1} + PublicUxConfig: + type: object + additionalProperties: false + required: [idle_timeout_minutes] + properties: + idle_timeout_minutes: {type: integer, minimum: 1} + MobileStoreUpdatePolicy: + type: object + additionalProperties: false + required: [enabled, latest_build, minimum_build, latest_version, store_url, release_notes] + properties: + enabled: {type: boolean} + latest_build: {type: [integer, "null"], minimum: 1} + minimum_build: {type: [integer, "null"], minimum: 1} + latest_version: {type: [string, "null"], minLength: 1, maxLength: 64} + store_url: {type: [string, "null"], format: uri, minLength: 1} + release_notes: {type: [string, "null"], maxLength: 4000} + MobileUpdatePolicy: + type: object + additionalProperties: false + required: [google_play, rustore, app_store] + properties: + google_play: {$ref: "#/components/schemas/MobileStoreUpdatePolicy"} + rustore: {$ref: "#/components/schemas/MobileStoreUpdatePolicy"} + app_store: {$ref: "#/components/schemas/MobileStoreUpdatePolicy"} OtpSettingsResponse: type: object additionalProperties: false diff --git a/VM1_app/codebase/backend/api-backend/tests/contract/test_openapi.py b/VM1_app/codebase/backend/api-backend/tests/contract/test_openapi.py index c74b52f..12a7a33 100644 --- a/VM1_app/codebase/backend/api-backend/tests/contract/test_openapi.py +++ b/VM1_app/codebase/backend/api-backend/tests/contract/test_openapi.py @@ -13,6 +13,7 @@ from pydantic import SecretStr from app.main import ( EXPECTED_API_DB_REVISION, app, + app_config, otp_settings, refresh_jwks_cache, websocket_token, @@ -152,13 +153,116 @@ def test_committed_openapi_server_does_not_double_api_prefix() -> None: assert committed["servers"] == [{"url": "/"}] -def test_public_config_contract_exposes_message_length() -> None: - committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8")) - response = committed["paths"]["/api/v1/public/app-config"]["get"]["responses"]["200"] - messages = response["content"]["application/json"]["schema"]["properties"]["messages"] +def test_public_config_contract_is_strict_and_exposes_mobile_update() -> None: + generated = app.openapi() + response = generated["paths"]["/api/v1/public/app-config"]["get"]["responses"]["200"] + schema_ref = response["content"]["application/json"]["schema"]["$ref"] + schema = generated["components"]["schemas"][schema_ref.rsplit("/", 1)[-1]] - assert messages["required"] == ["max_text_length"] - assert messages["properties"]["max_text_length"]["maximum"] == 10_000 + assert schema["additionalProperties"] is False + assert set(schema["required"]) == { + "auth", + "operator", + "messages", + "consents", + "attachments", + "notification", + "ux", + "mobile_update", + } + mobile_ref = schema["properties"]["mobile_update"]["$ref"] + mobile = generated["components"]["schemas"][mobile_ref.rsplit("/", 1)[-1]] + assert mobile["additionalProperties"] is False + assert set(mobile["required"]) == {"google_play", "rustore", "app_store"} + store_ref = mobile["properties"]["rustore"]["$ref"] + store = generated["components"]["schemas"][store_ref.rsplit("/", 1)[-1]] + assert "release_notes" in store["required"] + release_notes = store["properties"]["release_notes"] + assert {"type": "string", "maxLength": 4000} in release_notes["anyOf"] + assert {"type": "null"} in release_notes["anyOf"] + assert "304" in generated["paths"]["/api/v1/public/app-config"]["get"]["responses"] + + +async def test_public_config_returns_mobile_policy_and_supports_etag(monkeypatch) -> None: + values = { + "rate_limit.public_endpoints.per_ip": "60/minute", + "security.public_cache.max_age_seconds": "60", + "auth.phone.enabled": "true", + "auth.password.enabled": "false", + "operator.call.phone": "+74999591007", + "chat.message.max_length": "4000", + "consent.personal_data.required": "true", + "consent.personal_data.document_url": "https://example.ru/personal", + "consent.privacy_policy.document_url": "https://example.ru/privacy", + "consent.personal_data.version": "2026-06-10", + "consent.user_agreement.required": "true", + "consent.user_agreement.document_url": "https://example.ru/agreement", + "consent.user_agreement.version": "2026-06-10", + "consent.marketing.required": "false", + "consent.marketing.document_url": "https://example.ru/marketing", + "consent.marketing.version": "2026-06-10", + "chat.attachments.allowed_extensions": "jpg,pdf", + "chat.attachments.allowed_mime_types": "image/jpeg,application/pdf", + "chat.attachments.max_size_mb": "5", + "notification.carousel.autoplay_enabled": "false", + "notification.carousel.autoplay_interval_ms": "5000", + "ux.session.idle_timeout_minutes": "30", + "mobile_update.google_play.enabled": "true", + "mobile_update.google_play.latest_build": "2", + "mobile_update.google_play.minimum_build": "1", + "mobile_update.google_play.latest_version": "1.0.1", + "mobile_update.google_play.store_url": ( + "https://play.google.com/store/apps/details?id=ru.han.chat" + ), + "mobile_update.google_play.release_notes": "", + "mobile_update.rustore.enabled": "true", + "mobile_update.rustore.latest_build": "2", + "mobile_update.rustore.minimum_build": "1", + "mobile_update.rustore.latest_version": "1.0.1", + "mobile_update.rustore.store_url": ( + "https://www.rustore.ru/catalog/app/ru.han.chat" + ), + "mobile_update.rustore.release_notes": "", + "mobile_update.app_store.enabled": "false", + "mobile_update.app_store.latest_build": "", + "mobile_update.app_store.minimum_build": "", + "mobile_update.app_store.latest_version": "", + "mobile_update.app_store.store_url": "", + "mobile_update.app_store.release_notes": "", + } + settings = SettingsSnapshot(values, "settings-version") + request = SimpleNamespace( + headers={}, + client=None, + app=SimpleNamespace(state=SimpleNamespace()), + ) + + async def no_limit(*args, **kwargs) -> None: + return None + + monkeypatch.setattr("app.main.enforce_limit", no_limit) + + response = await app_config(request, settings) + body = json.loads(response.body) + assert response.headers["etag"] == '"settings-version"' + assert response.headers["cache-control"] == "public, max-age=60" + assert body["mobile_update"]["google_play"]["latest_build"] == 2 + assert body["mobile_update"]["google_play"]["release_notes"] is None + assert body["mobile_update"]["rustore"]["store_url"] == ( + "https://www.rustore.ru/catalog/app/ru.han.chat" + ) + assert body["mobile_update"]["app_store"] == { + "enabled": False, + "latest_build": None, + "minimum_build": None, + "latest_version": None, + "store_url": None, + "release_notes": None, + } + + cached = await app_config(request, settings, 'W/"settings-version", "old"') + assert cached.status_code == 304 + assert cached.headers["etag"] == '"settings-version"' def test_otp_settings_contract_is_strict_and_complete() -> None: diff --git a/VM1_app/codebase/backend/api-backend/tests/unit/test_cli_settings.py b/VM1_app/codebase/backend/api-backend/tests/unit/test_cli_settings.py index eb61d40..c2fb4db 100644 --- a/VM1_app/codebase/backend/api-backend/tests/unit/test_cli_settings.py +++ b/VM1_app/codebase/backend/api-backend/tests/unit/test_cli_settings.py @@ -17,6 +17,17 @@ def test_production_like_seed_contains_all_mandatory_settings() -> None: assert values["otp.phone.ttl_seconds"] == "60" assert values["otp.phone.sms_order_timeout_ms"] == "3000" assert values["chat.message.max_length"] == "4000" + assert values["mobile_update.google_play.latest_build"] == "2" + assert values["mobile_update.google_play.minimum_build"] == "1" + assert values["mobile_update.google_play.latest_version"] == "1.0.1" + assert values["mobile_update.rustore.latest_build"] == "2" + assert values["mobile_update.rustore.store_url"] == ( + "https://www.rustore.ru/catalog/app/ru.han.chat" + ) + assert values["mobile_update.rustore.release_notes"] == "" + assert values["mobile_update.app_store.enabled"] == "false" + assert values["mobile_update.app_store.latest_build"] == "" + assert values["security.public_cache.max_age_seconds"] == "60" def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None: @@ -76,3 +87,98 @@ def test_seed_rejects_invalid_chat_message_max_length(tmp_path: Path, value: int with pytest.raises(ValueError, match="value must be between 1 and 10000"): load_seed(path) + + +def _mobile_policy_yaml( + *, + store: str = "google_play", + enabled: bool = True, + latest_build: int = 2, + minimum_build: int = 1, + store_url: str = "https://play.google.com/store/apps/details?id=ru.han.chat", +) -> str: + enabled_yaml = str(enabled).lower() + return ( + "schema_version: 1\nsettings:\n" + f" mobile_update.{store}.enabled: " + f"{{type: boolean, value: {enabled_yaml}, public: true}}\n" + f" mobile_update.{store}.latest_build: " + f"{{type: integer, value: {latest_build}, public: true}}\n" + f" mobile_update.{store}.minimum_build: " + f"{{type: integer, value: {minimum_build}, public: true}}\n" + f' mobile_update.{store}.latest_version: ' + '{type: string, value: "1.0.1", public: true}\n' + f' mobile_update.{store}.store_url: ' + f'{{type: string, value: "{store_url}", public: true}}\n' + f' mobile_update.{store}.release_notes: ' + '{type: string, value: "", public: true}\n' + ) + + +def test_seed_rejects_inverted_mobile_build_thresholds(tmp_path: Path) -> None: + path = tmp_path / "settings.yaml" + path.write_text( + _mobile_policy_yaml(latest_build=1, minimum_build=2), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="minimum_build must not exceed latest_build"): + load_seed(path) + + +def test_seed_rejects_wrong_mobile_store_url(tmp_path: Path) -> None: + path = tmp_path / "settings.yaml" + path.write_text( + _mobile_policy_yaml( + store_url="https://play.google.com/store/apps/details?id=other.package" + ), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="does not match the google_play application"): + load_seed(path) + + +def test_seed_rejects_non_https_mobile_store_url(tmp_path: Path) -> None: + path = tmp_path / "settings.yaml" + path.write_text( + _mobile_policy_yaml( + store_url="http://play.google.com/store/apps/details?id=ru.han.chat" + ), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="absolute HTTPS URL expected"): + load_seed(path) + + +def test_seed_accepts_canonical_rustore_url(tmp_path: Path) -> None: + path = tmp_path / "settings.yaml" + path.write_text( + _mobile_policy_yaml( + store="rustore", + store_url="https://www.rustore.ru/catalog/app/ru.han.chat", + ), + encoding="utf-8", + ) + + rows = load_seed(path) + assert any( + row["setting_key"] == "mobile_update.rustore.store_url" + and row["setting_value"] == "https://www.rustore.ru/catalog/app/ru.han.chat" + for row in rows + ) + + +def test_seed_rejects_legacy_rustore_url(tmp_path: Path) -> None: + path = tmp_path / "settings.yaml" + path.write_text( + _mobile_policy_yaml( + store="rustore", + store_url="https://apps.rustore.ru/app/ru.han.chat", + ), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="does not match the rustore application"): + load_seed(path) diff --git a/VM1_app/codebase/backend/deployment/RUNBOOK.mobile-updates.ru.md b/VM1_app/codebase/backend/deployment/RUNBOOK.mobile-updates.ru.md new file mode 100644 index 0000000..0719842 --- /dev/null +++ b/VM1_app/codebase/backend/deployment/RUNBOOK.mobile-updates.ru.md @@ -0,0 +1,479 @@ +# Runbook внедрения soft/force update мобильного приложения + +## 1. Назначение и границы + +Документ описывает выпуск нативных версий HAN Chat через Google Play, RuStore +и App Store и последующее включение `soft`/`force update` через публичную +backend-политику. + +Механика не использует EAS OTA Update. Пользователь всегда направляется в +магазин, соответствующий каналу установленной сборки: + +- `google_play`; +- `rustore`; +- `app_store`. + +Канал встраивается в бинарный файл через +`EXPO_PUBLIC_DISTRIBUTION_STORE`. Поэтому Android-сборки Google Play и RuStore +нужно собирать отдельно. + +Решение принимает мобильный клиент: + +- `current_build < minimum_build` — обязательное обновление (`force`); +- `minimum_build <= current_build < latest_build` — мягкое обновление (`soft`); +- `current_build >= latest_build` — карточка не показывается. + +`latest_version` используется только в интерфейсе. Сравнение выполняется по +целому Android `versionCode` или iOS `buildNumber`. + +## 2. Ответственные и данные окна выпуска + +Перед началом назначьте: + +- ответственного за EAS Build; +- ответственного за Google Play Console; +- ответственного за RuStore Console; +- ответственного за App Store Connect, если выпускается iOS; +- оператора ВМ1, применяющего backend settings; +- владельца решения о переводе soft update в force update. + +Создайте запись окна выпуска: + +```text +Маркетинговая версия: +Git commit/tag мобильного приложения: +Git commit/tag backend: +Google Play EAS build ID: +Google Play versionCode: +RuStore EAS build ID: +RuStore versionCode: +App Store EAS build ID: +App Store buildNumber: +Дата полной доступности каждого релиза: +Минимальная поддерживаемая сборка каждого канала: +``` + +Не вычисляйте пороги по порядку запуска команд. Записывайте фактические значения +из завершённой EAS-сборки и подтверждайте их в консоли соответствующего магазина. + +## 3. Важная особенность EAS remote version + +В проекте используется: + +```json +{ + "cli": { + "appVersionSource": "remote" + } +} +``` + +Android remote `versionCode` привязан к application ID `ru.han.chat`. Профили +`google-play` и `rustore` используют один application ID и общий счётчик. + +Если текущее remote-значение равно `6`, последовательная сборка обычно даст: + +1. первая Android-сборка — `versionCode=7`; +2. вторая Android-сборка — `versionCode=8`. + +Это допустимо. Политики Google Play и RuStore независимы, поэтому в backend +следует указать `latest_build=7` для одного канала и `latest_build=8` для +другого, если именно такие артефакты опубликованы. + +Локальный `android.versionCode` в `app.config.ts` не является источником истины +при remote version source. Источник истины для rollout — опубликованный +артефакт магазина. + +## 4. Предварительные проверки + +### 4.1. Мобильное приложение + +На локальной машине: + +```powershell +cd C:\Users\MI\Documents\Assistent\HAN_chat_specification\VM4_Expo-mobile +npm run typecheck +npm test +npx eas-cli whoami +``` + +Проверьте: + +- `version` в `app.config.ts` соответствует выпускаемой маркетинговой версии; +- профиль `google-play` содержит `EXPO_PUBLIC_DISTRIBUTION_STORE=google_play`; +- профиль `rustore` содержит `EXPO_PUBLIC_DISTRIBUTION_STORE=rustore`; +- профиль `app-store` содержит `EXPO_PUBLIC_DISTRIBUTION_STORE=app_store`; +- `preview` и `development` не включают store policy; +- production API указывает на `https://chat.han0107.ru`. + +### 4.2. Backend + +На локальной машине: + +```powershell +cd C:\Users\MI\Documents\Assistent\HAN_chat_specification\VM1_app\codebase\backend\api-backend +python -m pytest + +cd .. +python -m pytest tests +``` + +Проверьте, что backend-релиз содержит: + +- строгий объект `mobile_update` в `/api/v1/public/app-config`; +- поддержку `ETag` и `If-None-Match`; +- TTL app-config 60 секунд; +- валидацию build numbers и store URL; +- актуальный `openapi.yaml`; +- nginx `proxy_cache_valid 200 60s` для app-config. + +## 5. Безопасное внедрение backend до выпуска приложения + +Сначала разверните backend-код и nginx, затем мобильные сборки. Старые клиенты +игнорируют новую секцию `mobile_update`. + +До публикации новой версии политика должна быть безопасной: + +- либо канал отключён; +- либо `latest_build` не превышает уже опубликованный build; +- `minimum_build` не должен внезапно исключать поддерживаемые версии. + +Начальные `latest_build=2` при фактической установленной сборке `6` не показывают +карточку и поэтому безопасны как временное no-op состояние. + +Развёртывание backend выполняйте по основному production runbook: + +`deployment/RUNBOOK.production.ru.md`. + +После обновления образов и конфигурации оператор ВМ1 выполняет штатный job: + +```sh +/usr/local/sbin/han-vm1-compose --profile ops run --rm seed-settings +``` + +Команда идемпотентна и включает `validate_settings`. Невалидная комбинация +порогов или URL должна завершить job ошибкой. + +Проверка публичного контракта с внешней машины: + +```sh +curl -fsS https://chat.han0107.ru/api/v1/public/app-config +ETAG="$(curl -fsSI https://chat.han0107.ru/api/v1/public/app-config \ + | awk -F': ' 'tolower($1)=="etag" {gsub("\r","",$2); print $2}')" +curl -sS -o /dev/null -w '%{http_code}\n' \ + -H "If-None-Match: $ETAG" \ + https://chat.han0107.ru/api/v1/public/app-config +``` + +Ожидается: + +- первый запрос — `200`; +- в ответе присутствуют три store policy; +- повторный запрос с актуальным ETag — `304`; +- `Cache-Control` содержит `max-age=60`. + +## 6. Получение текущего remote build number + +На локальной машине: + +```powershell +cd C:\Users\MI\Documents\Assistent\HAN_chat_specification\VM4_Expo-mobile + +npx eas-cli build:version:get --platform android --profile google-play +npx eas-cli build:version:get --platform android --profile rustore +npx eas-cli build:version:get --platform ios --profile app-store +``` + +Для автоматической обработки: + +```powershell +npx eas-cli build:version:get --platform android --profile google-play --json +``` + +Одинаковое значение для двух Android-профилей до сборки ожидаемо: они используют +общий application ID. Каждая последующая Android-сборка с `autoIncrement` +увеличивает общий счётчик. + +### 6.1. Тестовый APK с каналом RuStore + +Профиль `preview-rustore` создаёт внутренний APK, обращается к +`https://dev-chat.han0107.ru` и встраивает канал `rustore`: + +```powershell +npx eas-cli build:version:get --platform android --profile preview-rustore +npx eas-cli build --profile preview-rustore --platform android +``` + +У профиля задано `autoIncrement: false`, поэтому тестовая сборка не расходует +следующий production `versionCode`. Пусть фактический build APK равен `B`. Для +проверки на dev-backend задайте: + +```text +soft: latest_build = B + 1, minimum_build <= B +force: latest_build = B + 1, minimum_build = B + 1 +none: latest_build <= B +``` + +Меняйте только RuStore policy dev-окружения и применяйте её через штатный +`seed-settings` этого окружения. Не используйте тестовые пороги на production. + +После отказа от soft update запись сохраняется в SecureStore без TTL. Для +повторной проверки той же политики очистите данные приложения либо увеличьте +`latest_build`. + +APK использует package `ru.han.chat` и может заменить установленную +store-сборку. Для теста предпочтительно отдельное устройство. + +## 7. Создание store-сборок + +Рекомендуется собирать и публиковать магазины по одному, сразу записывая +фактический build number: + +```powershell +npx eas-cli build --profile google-play --platform android +npx eas-cli build --profile rustore --platform android +npx eas-cli build --profile app-store --platform ios +``` + +App Store-команду не выполняйте, пока не настроены Apple credentials, Apple App +ID и рабочий `store_url`. + +После каждой сборки: + +```powershell +npx eas-cli build:list --platform android --limit 5 +npx eas-cli build:view +``` + +Зафиксируйте: + +- EAS build ID; +- commit; +- channel/profile; +- `version`; +- фактический `versionCode`/`buildNumber`; +- checksum скачанного артефакта, если он используется в процедуре публикации. + +Не запускайте вторую Android-сборку, пока не записан номер первой. + +## 8. Публикация и проверка магазинов + +### 8.1. Google Play + +1. Загрузите AAB в требуемый track. +2. Убедитесь, что Console показывает ожидаемый `versionCode`. +3. Проведите internal/closed testing. +4. Проверьте установку и переход по ссылке: + `https://play.google.com/store/apps/details?id=ru.han.chat`. +5. Зафиксируйте процент rollout и время полной доступности. + +### 8.2. RuStore + +1. Загрузите предназначенный для RuStore артефакт. +2. Убедитесь, что Console показывает фактический `versionCode`. +3. Проведите тестирование канала. +4. Проверьте страницу: + `https://www.rustore.ru/catalog/app/ru.han.chat`. +5. Зафиксируйте статус модерации и время доступности. + +### 8.3. App Store + +До включения политики: + +1. получите Apple App ID; +2. опубликуйте и проверьте сборку в App Store Connect/TestFlight; +3. укажите канонический URL `https://apps.apple.com/.../id`; +4. подтвердите фактический `buildNumber`; +5. только после этого установите `enabled: true`. + +## 9. Включение soft update + +Изменяйте +`deployment/app-settings.production-like.yaml` отдельно для каждого магазина. + +Пример, если Google Play опубликовал build `7`, а RuStore — build `8`: + +```yaml +mobile_update.google_play.enabled: {type: boolean, value: true, public: true} +mobile_update.google_play.latest_build: {type: integer, value: 7, public: true} +mobile_update.google_play.minimum_build: {type: integer, value: 1, public: true} +mobile_update.google_play.latest_version: {type: string, value: "1.0.1", public: true} + +mobile_update.rustore.enabled: {type: boolean, value: true, public: true} +mobile_update.rustore.latest_build: {type: integer, value: 8, public: true} +mobile_update.rustore.minimum_build: {type: integer, value: 1, public: true} +mobile_update.rustore.latest_version: {type: string, value: "1.0.1", public: true} +``` + +Выбор `minimum_build` требует отдельного решения: + +- оставить `1` — все более старые builds получают soft update; +- установить `6` — builds `1–5` немедленно получают force update, а build `6` + получает soft update; +- установить новый build (`7` или `8`) — все предыдущие builds получают force. + +Для первого rollout рекомендуется сохранить прежний минимальный поддерживаемый +build и включить только soft update. + +После review и merge настроек оператор ВМ1 выполняет: + +```sh +/usr/local/sbin/han-vm1-compose --profile ops run --rm seed-settings +``` + +Подождите до 60 секунд и повторно запросите app-config. Если внешний nginx уже +имел закешированный ответ, допускайте до двух минут на проверку с разных +клиентов, но не продолжайте rollout при значении старше ожидаемого. + +## 10. Приёмка soft update + +Используйте реальное устройство со старой store-сборкой каждого канала. + +Проверьте: + +1. При cold start появляется «Доступно обновление». +2. Указаны правильные текущая и новая версии. +3. Кнопка открывает правильный магазин, а не другой Android-магазин. +4. «Позже», крестик и Android Back закрывают карточку. +5. После отказа карточка той же `latest_build` не появляется при следующем + запуске: отказ хранится в SecureStore без TTL. +6. После увеличения `latest_build` появляется новая карточка. +7. После установки нового build карточка исчезает. +8. При недоступном backend приложение не блокируется. + +Если продукту требуется повторное напоминание через интервал, текущую механику +следует изменить отдельно: сейчас soft-dismiss действует до появления нового +`latest_build`, очистки данных или переустановки. + +## 11. Перевод в force update + +Force разрешено включать только когда обязательный build: + +- прошёл модерацию; +- доступен в нужном production track; +- доступен всем пользователям, которых затронет `minimum_build`; +- устанавливается и запускается; +- корректно открывается по `store_url`; +- backend и store не находятся в инциденте. + +Для Google Play build `7`: + +```yaml +mobile_update.google_play.latest_build: {type: integer, value: 7, public: true} +mobile_update.google_play.minimum_build: {type: integer, value: 7, public: true} +``` + +Для RuStore build `8`: + +```yaml +mobile_update.rustore.latest_build: {type: integer, value: 8, public: true} +mobile_update.rustore.minimum_build: {type: integer, value: 8, public: true} +``` + +Не повышайте minimum одного магазина только потому, что релиз доступен в другом. + +После изменения снова примените `seed-settings`, проверьте app-config и +протестируйте старую сборку: + +- force-карточка не имеет крестика и кнопки «Позже»; +- Android Back не закрывает её; +- кнопка открывает правильный магазин; +- после возврата без установки карточка остаётся; +- после установки поддерживаемого build блокировка исчезает. + +## 12. App Store policy + +Пока Apple App ID неизвестен, политика должна оставаться полностью выключенной: + +```yaml +mobile_update.app_store.enabled: {type: boolean, value: false, public: true} +mobile_update.app_store.latest_build: {type: integer, value: null, public: true} +mobile_update.app_store.minimum_build: {type: integer, value: null, public: true} +mobile_update.app_store.latest_version: {type: string, value: "", public: true} +mobile_update.app_store.store_url: {type: string, value: "", public: true} +mobile_update.app_store.release_notes: {type: string, value: "", public: true} +``` + +Отключённая политика не должна содержать частично заполненные release-поля: +backend отклонит такую конфигурацию. + +## 13. Откат + +### 13.1. Немедленно снять force + +Понизьте `minimum_build` до последнего подтверждённого поддерживаемого значения, +не меняя `latest_build`, затем примените seed. + +Пример: + +```yaml +mobile_update.google_play.latest_build: {type: integer, value: 7, public: true} +mobile_update.google_play.minimum_build: {type: integer, value: 1, public: true} +``` + +После успешного получения новой политики клиент снимет force-блокировку. +Кратковременная сетевая ошибка сохраняет уже показанный force до следующей +успешной проверки, поэтому дополнительно подтвердите доступность app-config. + +### 13.2. Полностью отключить канал + +Установите `enabled=false`, integer-поля в `null`, строковые release-поля в +пустую строку: + +```yaml +mobile_update.google_play.enabled: {type: boolean, value: false, public: true} +mobile_update.google_play.latest_build: {type: integer, value: null, public: true} +mobile_update.google_play.minimum_build: {type: integer, value: null, public: true} +mobile_update.google_play.latest_version: {type: string, value: "", public: true} +mobile_update.google_play.store_url: {type: string, value: "", public: true} +mobile_update.google_play.release_notes: {type: string, value: "", public: true} +``` + +Не откатывайте уже использованный store build number и не публикуйте другой +артефакт с тем же `versionCode`/`buildNumber`. + +### 13.3. Дефект новой версии + +Если новая версия дефектна: + +1. не направляйте на неё новых пользователей — отключите policy или верните + `latest_build` к безопасному опубликованному build; +2. остановите rollout в соответствующем магазине; +3. выпустите исправленную сборку с новым build number; +4. после публикации укажите новый `latest_build`; +5. только после приёмки принимайте решение о новом `minimum_build`. + +## 14. Наблюдение после включения + +В течение окна наблюдения контролируйте: + +- `5xx` и latency `/api/v1/public/app-config`; +- долю `200/304`; +- ошибки rate limit публичного endpoint; +- доступность страниц магазинов; +- crash/error rate новой мобильной версии; +- обращения о циклической force-карточке; +- соответствие фактического store build политике каждого канала. + +Stop conditions: + +- URL ведёт не в тот магазин или не на HAN Chat; +- опубликованный build ниже `latest_build`; +- часть rollout-групп не может скачать minimum build; +- app-config отдаёт старую или частичную политику дольше двух минут; +- новая версия не запускается или не проходит авторизацию; +- force нельзя снять успешным изменением backend policy. + +## 15. Контрольный чек-лист + +- [ ] Backend с `mobile_update` развёрнут до мобильного rollout. +- [ ] ETag/304 и TTL 60 секунд проверены извне. +- [ ] Фактические build numbers записаны после каждой EAS-сборки. +- [ ] Build numbers подтверждены в консолях магазинов. +- [ ] Google Play и RuStore thresholds заполнены независимо. +- [ ] Soft update проверен на старой сборке каждого канала. +- [ ] Soft-dismiss и повторный показ для нового latest build проверены. +- [ ] Force включается только после полной доступности minimum build. +- [ ] App Store остаётся disabled до получения Apple App ID. +- [ ] Процедура отката проверена до включения force. +- [ ] Итоговые значения политики и время применения записаны в журнал выпуска. diff --git a/VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md b/VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md index 9a71e90..955fdca 100644 --- a/VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md +++ b/VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md @@ -376,6 +376,9 @@ stack unit. Обновление active/exited oneshot всегда требуе ```sh curl -sS -o /dev/null -w '%{http_code}\n' http:/// curl -fsS https:///api/v1/public/app-config +ETAG="$(curl -fsSI https:///api/v1/public/app-config | awk -F': ' 'tolower($1)=="etag" {gsub("\r","",$2); print $2}')" +curl -sS -o /dev/null -w '%{http_code}\n' \ + -H "If-None-Match: $ETAG" https:///api/v1/public/app-config curl -fsS https:///auth/realms/han-chat/.well-known/openid-configuration curl -sS -o /dev/null -w '%{http_code}\n' \ https:///internal/safety/v2/messages/check @@ -383,7 +386,12 @@ openssl s_client -connect :443 -servername \ -verify_hostname -verify_return_error /dev/null +free -h +swapon --show +df -hT / /var/lib/docker /tmp +df -ih / /var/lib/docker /tmp +systemctl is-active docker fail2ban ufw +dpkg-query -W -f='${Package}\t${Version}\t${Status}\n' \ + kesl kesl-gui kav4fs 2>/dev/null || true +``` + +Проверка проходит только для `amd64`/поддерживаемой архитектуры, Ubuntu 24.04 +LTS и поддерживаемого KESL ядра. Требования Kaspersky — минимум 2 ГБ RAM, +1 ГБ swap и 4 ГБ свободного диска, но этого недостаточно для данной ВМ: +Compose-лимиты суммарно около 11,6 ГБ. При RAM менее 16 ГБ установка требует +отдельного решения владельца сервиса о доступном запасе. + +### 1.2. Docker и приложение + +```sh +docker info --format \ + 'driver={{.Driver}} root={{.DockerRootDir}} containers={{.Containers}} running={{.ContainersRunning}}' +/usr/local/sbin/han-vm1-compose ps +docker ps --format \ + 'table {{.Names}}\t{{.Status}}\t{{.Image}}' +docker stats --no-stream --format \ + 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.BlockIO}}\t{{.PIDs}}' +``` + +Сохраните список и состояние именованных volumes без содержимого: + +```sh +for volume in redis-data otel-queue nginx-cache; do + docker volume ls --format '{{.Name}}' | + while IFS= read -r name; do + case "$name" in + *"$volume"*) + docker volume inspect --format \ + '{{.Name}}\t{{.Mountpoint}}' "$name" + ;; + esac + done +done +``` + +В change record перенесите три фактических mountpoint. Не подставляйте +предполагаемый Compose prefix. + +### 1.3. Health, edge и firewall + +Под root на ВМ: + +```sh +systemctl --no-pager status \ + han-secrets@production.service han-stack@production.service +iptables -S HAN-CHAT-DOCKER +iptables -L HAN-CHAT-DOCKER -n -v +ufw status verbose +journalctl --since '-30 min' --no-pager \ + -u han-stack@production.service -p warning +``` + +С trusted external host выполните smoke из +`deployment/RUNBOOK.production.ru.md`, раздел 10. Если запускается repository +`deployment/scripts/smoke.sh`, он должен использовать штатный secret launcher; +не печатайте resolved environment. + +Снимите из штатного observability baseline: + +- API request rate, 5xx и p50/p95/p99 latency; +- Redis latency, blocked clients и memory; +- container restart/OOM count; +- host CPU, available RAM, swap, disk IO/IO wait; +- OTEL exporter failures и queue depth. + +Без доступных baseline и rollback approver к установке не переходить. + +## 2. Проверка пакета и подготовка + +GUI на сервер не устанавливается. Пакет передаётся в root-only staging, +например `/root/kesl-install`, и удаляется после приёмки. + +```sh +install -d -m 0700 -o root -g root /root/kesl-install +install -m 0600 -o root -g root \ + /tmp/ /root/kesl-install/kesl.deb +sha256sum /root/kesl-install/kesl.deb +dpkg-deb -f /root/kesl-install/kesl.deb Package Version Architecture +``` + +Сравните SHA-256 с опубликованным/полученным по доверенному каналу значением. +Не продолжайте при package name не `kesl`, неверной архитектуре или версии не +12.4.x. + +Перед установкой сохраните только безопасные snapshots: + +```sh +cp -a /etc/docker/daemon.json /root/kesl-install/docker-daemon.before.json +iptables-save > /root/kesl-install/iptables.before +ufw status verbose > /root/kesl-install/ufw.before +``` + +## 3. Установка с отключённой защитой + +Установка изменяет host и выполняется только в maintenance window. + +```sh +apt-get install /root/kesl-install/kesl.deb +``` + +Создайте `/root/kesl-install/autoinstall.ini` с mode `0600`. Значения EULA, +Privacy Policy и KSN должны быть осознанно согласованы с Security/Legal, а не +скопированы механически: + +```ini +KSVLA_MODE=No +ENDPOINT_AGENT_MODE=No +EULA_AGREED= +PRIVACY_POLICY_AGREED= +USE_KSN= +GROUP_CLEAN=Yes +LOCALE=ru_RU.UTF-8 +INSTALL_LICENSE=None +UPDATER_SOURCE=KLServers +UPDATE_EXECUTE=No +KERNEL_SRCS_INSTALL=No +USE_GUI=No +CONFIGURE_SELINUX=No +DISABLE_PROTECTION=Yes +INTERCEPTOR_MODE=UseFanotify +ENABLE_TRACES_ON_FIRST_STARTUP=No +``` + +Для Ubuntu AppArmor значение `CONFIGURE_SELINUX=No` ожидаемо. `UseFanotify` +не требует сборки стороннего kernel module. Если выбран KSN, документируйте +передачу данных и правовое основание. + +Первоначальная настройка: + +```sh +chmod 0600 /root/kesl-install/autoinstall.ini +/opt/kaspersky/kesl/bin/kesl-setup.pl \ + --autoinstall=/root/kesl-install/autoinstall.ini +test "$?" -eq 0 +systemctl --no-pager status kesl +kesl-control --app-info +kesl-control --supported-tech-info +kesl-control --get-task-list +``` + +Если используется activation code, не передавайте его аргументом команды и не +храните в этом репозитории. Выполните активацию по официальной инструкции +Kaspersky с защищённым локальным вводом/файлом ключа. + +## 4. Ресурсные ограничения до первого scan + +В KESL 12.4 `ScanMemoryLimit` по умолчанию равен 8192 МБ, а `MaxMemory=auto` +может разрешить до 50% доступной RAM. Для ВМ1 эти defaults не принимаются без +измерений. + +Выберите значения по фактическому baseline: + +- constrained test VM, 4 ГБ RAM: `ScanMemoryLimit=512`, + `MaxMemory=1024MB`, `OnDemandCPULimit=15`; только по разделу 0.1; +- 16 ГБ RAM: начните с `ScanMemoryLimit=1024`, `MaxMemory=2048MB`; +- 24–32 ГБ RAM: начните с `ScanMemoryLimit=2048`, `MaxMemory=4096MB`; +- иной размер: согласуйте значения; `ScanMemoryLimit` должен быть ниже + `MaxMemory`, а после резервирования KESL у приложения должен оставаться + исходный запас. + +Сначала сохраните исходные настройки: + +```sh +kesl-control --get-app-settings \ + --file /root/kesl-install/app-settings.before.ini +install -m 0600 -o root -g root \ + /var/opt/kaspersky/kesl/common/kesl.ini \ + /root/kesl-install/kesl.ini.before +``` + +Установите CPU limit для ODS/ContainerScan: + +```sh +kesl-control --set-app-settings \ + UseOnDemandCPULimit=Yes OnDemandCPULimit=<15_FOR_TEST_OR_APPROVED_VALUE> +``` + +Для изменения `ScanMemoryLimit` и `MaxMemory` следуйте официальной процедуре: +остановите KESL, внесите значения в секцию `[General]` файла +`/var/opt/kaspersky/kesl/common/kesl.ini`, затем запустите KESL. Не заменяйте +файл целиком и не применяйте шаблон из репозитория как готовый конфиг. + +После запуска проверьте: + +```sh +systemctl is-active kesl +kesl-control --get-app-settings +kesl-control --app-info +``` + +## 5. Обновление баз — АВЗ.2 + +Запустите предустановленную задачу Update (ID 6) и дождитесь результата: + +```sh +kesl-control --get-settings 6 +kesl-control --get-schedule 6 +kesl-control --start-task 6 -W +kesl-control --get-task-state 6 +kesl-control --app-info +``` + +Проверьте действующую лицензию, `Базы приложения загружены: Да`, свежую дату +выпуска баз и успешное завершение Update. Затем задайте почасовой запуск: + +```sh +kesl-control --set-schedule 6 RuleType=Hourly +kesl-control --get-schedule 6 +``` + +Если установленная сборка требует интервал в `StartTime`, не угадывайте +синтаксис: экспортируйте schedule и измените его по документации именно этой +сборки. До успешного автообновления АВЗ.2 не принимается. + +Сразу после обновления повторите разделы 1.2–1.3. При деградации выполните +rollback из раздела 11. + +## 6. Исключения hot-data + +Исключения создаются только после получения фактических mountpoint в разделе +1.2. Разрешены три области: + +- `` — AOF/RDB; +- `` — persistent telemetry queue; +- `` — regenerable cache. + +До изменения экспортируйте параметры: + +```sh +kesl-control --get-settings 1 \ + --file /root/kesl-install/file-threat.before.ini +``` + +Добавьте обычные исключения File Threat Protection: + +```sh +kesl-control --set-settings 1 \ + --add-exclusion +kesl-control --set-settings 1 \ + --add-exclusion +kesl-control --set-settings 1 \ + --add-exclusion +kesl-control --get-settings 1 +``` + +Не исключать: + +- весь `/var/lib/docker`, `/var/lib/docker/overlay2` или все volumes; +- `/opt/han-chat/releases` и `/opt/han-chat/current`; +- `/var/lib/han-deploy/incoming`; +- `/etc/han`, `/run/han-chat`, `/etc/letsencrypt`; +- `/tmp`, `/var/tmp`, `/root` или весь filesystem. + +Обычное исключение из scan может не исключить файловый перехват. Не создавайте +bind mounts и не добавляйте `ExcludedMountPoint` в первой итерации. Это +допустимо только если измерена деградация и Security письменно принял +компенсацию плановой проверкой/container scan. + +## 7. Пилот задач проверки + +Убедитесь, что все ODS/ContainerScan schedules, кроме Update, пока ручные: + +```sh +kesl-control --get-task-list +kesl-control --get-schedule 2 +kesl-control --get-schedule 18 +kesl-control --set-schedule 2 RuleType=Manual +kesl-control --set-schedule 18 RuleType=Manual +``` + +Идентификаторы подтвердите через `--get-task-list`; не применяйте команды, +если тип задачи не совпадает. + +### 7.1. Ограниченная on-demand проверка host + +Сначала проверьте небольшой immutable release, не корень filesystem: + +```sh +kesl-control --scan-file /opt/han-chat/current/backend \ + --action Inform +``` + +В первом пилоте действие `Inform` не изменяет release. Проверьте результат, +events, ресурсы и application health: + +```sh +kesl-control -E --query -n 100 --reverse +kesl-control --get-statistic +/usr/local/sbin/han-vm1-compose ps +docker stats --no-stream +``` + +### 7.2. Проверка контейнеров + +Перед scan снимите список running containers. Проверяйте по одному объекту, +начиная с stateless/oneshot image, не Redis и не Keycloak: + +```sh +docker ps --format '{{.Names}}\t{{.Image}}' +kesl-control --get-settings 19 +kesl-control --scan-container +``` + +После успешного одиночного теста задачу `Container_Scan` (ID 18) можно +назначить еженедельно в согласованное время. Перед этим проверьте параметры: +по умолчанию `ContainerScanAction=StopContainerIfFailed`; production-контейнер +не должен останавливаться из-за технической ошибки сканирования. Итоговое +действие отдельно утверждает Security. + +Container scan после deploy выполняется только после завершения smoke, а не +одновременно с pull/start/migrations. + +## 8. Ступенчатое включение File Threat Protection — АВЗ.1 + +`DISABLE_PROTECTION=Yes` отключает компоненты после setup. До старта сохраните +параметры и убедитесь, что `ScanArchived=No`: + +```sh +kesl-control --get-settings 1 +kesl-control --get-task-state 1 +``` + +Для пилота включите асинхронный режим перехватчика `Notify`, при котором +KESL журналирует обнаружения, но не выполняет блокирующее действие: + +```sh +kesl-control --set-app-settings InterceptorProtectionMode=Notify +kesl-control --start-task 1 +kesl-control --get-task-state 1 +``` + +Пилот длится минимум 2–4 часа обычной нагрузки. Каждые 15 минут проверяйте +метрики раздела 1 и события KESL. Не считайте этот режим конечной реализацией +АВЗ.1: он не обеспечивает блокирование/лечение. + +Если пилот стабилен, в том же maintenance window: + +1. проверьте, что `ActionOnThreat=DisinfectDeleteIfNotPossible`; +2. установите `InterceptorProtectionMode=Block`; +3. перезапустите task 1, если этого требует текущая сборка; +4. повторите health, smoke, firewall и resource checks. + +```sh +kesl-control --set-settings 1 \ + ActionOnThreat=DisinfectDeleteIfNotPossible ScanArchived=No +kesl-control --set-app-settings InterceptorProtectionMode=Block +kesl-control --stop-task 1 +kesl-control --start-task 1 +kesl-control --get-task-state 1 +kesl-control --app-info +``` + +В режиме `Block` доступ к файлу ожидает результат проверки. При появлении +latency вернитесь в `Notify` либо остановите task 1 и выполните rollback; +не расширяйте исключения вслепую. + +## 9. Приёмочный тест и evidence + +Тест EICAR выполняется только с письменным разрешением Security в отдельном +безопасном каталоге, не в release, volume, backup, secret или upload path. +Используйте официальную контрольную строку/файл с сайта EICAR/Kaspersky; этот +репозиторий намеренно не содержит тестовый образец. + +До теста: + +```sh +install -d -m 0700 -o root -g root /root/kesl-eicar-test +date -Is +kesl-control --app-info +kesl-control --get-task-state 1 +``` + +Ожидается блокирование/лечение/карантин и событие KESL. Не прикладывайте сам +образец к evidence. Сохраните: + +```sh +kesl-control --app-info +kesl-control --get-task-list +kesl-control --get-settings 1 +kesl-control --get-schedule 6 +kesl-control -E --query -n 100 --reverse +``` + +Очистите тестовый каталог после подтверждения реакции и заполните +`deployment/kesl/EVIDENCE.AVZ.ru.md`. + +## 10. Финальные проверки + +На ВМ: + +```sh +systemctl is-active kesl docker \ + han-secrets@production.service han-stack@production.service +kesl-control --app-info +kesl-control --get-task-state 1 +kesl-control --get-task-state 6 +/usr/local/sbin/han-vm1-compose ps +iptables -S HAN-CHAT-DOCKER +ufw status verbose +``` + +С внешнего trusted host повторите production smoke и negative port probes. +Сравните метрики минимум за 24 часа. Не выполняйте reboot только ради KESL. +Если пакет/ядро явно запросили reboot, проведите его отдельным окном по +reboot gate основного production-runbook. + +После приёмки удалите package/autoinstall и временные snapshots с ВМ только +после переноса разрешённого evidence: + +```sh +rm -rf /root/kesl-install /root/kesl-eicar-test +``` + +## 11. Rollback + +### 11.1. До включения блокирующей защиты + +```sh +kesl-control --stop-task 1 2>/dev/null || true +apt-get purge kesl +systemctl daemon-reload +systemctl restart han-chat-docker-firewall.service +/usr/local/sbin/han-vm1-compose ps +iptables -S HAN-CHAT-DOCKER +ufw status verbose +``` + +Повторите smoke и resource checks. Docker и application stack без причины не +перезапускайте. + +### 11.2. При инциденте после включения Block + +Сначала минимально обратимое действие: + +```sh +kesl-control --set-app-settings InterceptorProtectionMode=Notify +``` + +Если управление KESL не отвечает: + +```sh +systemctl stop kesl +``` + +Затем восстановите доступность и соберите события. Полный `apt-get purge kesl` +выполняйте только по решению change approver. Reboot — только если он требуется +для удаления/ядра и есть отдельное окно. + +Нельзя выполнять `docker compose down -v`, удалять volumes, чистить Redis AOF, +пересоздавать VM или менять firewall ради обхода проблемы KESL. + +## 12. Эксплуатационный режим + +- Update (ID 6): каждый час; alert при ошибке или устаревании баз. +- File Threat Protection (ID 1): постоянно, `Block`, + `DisinfectDeleteIfNotPossible`, `ScanArchived=No`. +- ODS: еженедельно в низкую нагрузку после подтверждения resource budget. +- ContainerScan: еженедельно и после deploy, только после smoke. +- Ежедневно: статус лицензии, компонентов, дата баз и ошибки KESL. +- Ежемесячно: review исключений и фактической нагрузки. +- После upgrade KESL/kernel/Docker: повтор пилота, smoke и evidence delta. + +Любое новое исключение должно иметь владельца, причину, срок пересмотра, +компенсирующую проверку и подтверждение Security. diff --git a/VM1_app/codebase/backend/deployment/kesl/policy-standalone.conf.example b/VM1_app/codebase/backend/deployment/kesl/policy-standalone.conf.example new file mode 100644 index 0000000..d66dc19 --- /dev/null +++ b/VM1_app/codebase/backend/deployment/kesl/policy-standalone.conf.example @@ -0,0 +1,90 @@ +# KESL 12.4 standalone policy decisions for HAN Chat VM1. +# +# REFERENCE ONLY: this is deliberately not a complete kesl-control import file. +# Export the settings from the installed build, review the diff, and apply +# individual values by the commands in RUNBOOK.KESL.ru.md. Importing a partial +# or version-mismatched file can reset settings that are not listed here. +# +# This file contains no license, activation code, proxy credentials, hostname, +# IP address, secret or environment value. + +[deployment] +product_major_minor=12.4 +mode=standard_standalone +gui=disabled +update_source=KLServers +interceptor=fanotify +network_features=disabled +ksn= + +[resource_budget] +# Choose from measured host capacity; see runbook section 4. +scan_memory_limit_mb=<1024_OR_APPROVED_VALUE> +max_memory=<2048MB_OR_APPROVED_VALUE> +use_on_demand_cpu_limit=Yes +on_demand_cpu_limit_percent=25 + +[constrained_test_vm_override] +# Explicitly approved only for the 4 GB non-production VM. Never copy this +# profile to production without new sizing and baseline. +scan_memory_limit_mb=512 +max_memory=1024MB +use_on_demand_cpu_limit=Yes +on_demand_cpu_limit_percent=15 +full_filesystem_scan=forbidden +scan_archived=No +parallel_ods_and_container_scan=forbidden +stop_available_memory_mb=512 + +[update_task_6] +rule_type=Hourly +required_result=completed_successfully +required_bases_loaded=Yes +stale_bases_alert= + +[file_threat_protection_task_1] +steady_state=Started +interceptor_protection_mode=Block +action_on_threat=DisinfectDeleteIfNotPossible +scan_archived=No + +[file_threat_exclusions] +# Replace placeholders only with mountpoints returned by docker volume inspect. +# Do not guess the Compose project prefix. +item_0000= +item_0001= +item_0002= + +[forbidden_broad_exclusions] +item_0000=/var/lib/docker +item_0001=/var/lib/docker/overlay2 +item_0002=/opt/han-chat +item_0003=/var/lib/han-deploy/incoming +item_0004=/etc/han +item_0005=/run/han-chat +item_0006=/tmp +item_0007=/ + +[on_demand_scan] +initial_scope=/opt/han-chat/current/backend +initial_action=Inform +steady_schedule= + +[container_scan_task_18] +initial_schedule=Manual +steady_schedule= +post_deploy=after_smoke_only +# Review before enabling: the vendor default can stop a container when scan +# fails technically. +container_scan_action= + +[evidence] +application_info=required +license_valid=required +bases_loaded_and_fresh=required +file_threat_task_started=required +update_schedule_hourly=required +eicar_block_or_remediation_event=required +application_smoke_after_each_stage=required +firewall_unchanged=required +resource_comparison_24h=required diff --git a/VM1_app/codebase/backend/nginx/templates/site-tls.conf.template b/VM1_app/codebase/backend/nginx/templates/site-tls.conf.template index 32c6da6..7eb0597 100644 --- a/VM1_app/codebase/backend/nginx/templates/site-tls.conf.template +++ b/VM1_app/codebase/backend/nginx/templates/site-tls.conf.template @@ -73,7 +73,9 @@ server { proxy_cache_methods GET HEAD; proxy_cache_bypass $http_authorization; proxy_no_cache $http_authorization $upstream_http_set_cookie; - proxy_cache_valid 200 1h; + proxy_cache_revalidate on; + proxy_cache_valid 200 60s; + proxy_cache_valid 304 60s; proxy_pass http://api_backend; } location = /api/v1/public/content { diff --git a/VM1_app/figma/Main page specification.zip b/VM1_app/figma/Main page specification.zip index cfcabbbb0e3853737d8016a480680e1e545a28d9..48561198ff173a418b5fcb80d02b7a9d24ce93fd 100644 GIT binary patch delta 16459 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 delta 13297 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 apx_UN1#9EC7{f&3zg>FW diff --git a/VM1_app/figma/src/app/App.tsx b/VM1_app/figma/src/app/App.tsx index 3140497..8f08e49 100644 --- a/VM1_app/figma/src/app/App.tsx +++ b/VM1_app/figma/src/app/App.tsx @@ -10,9 +10,11 @@ import { AuthOtp } from './pages/AuthOtp'; import { AuthLoading } from './pages/AuthLoading'; import { AuthConsent } from './pages/AuthConsent'; import { Root } from './pages/Root'; +import { UpdateProvider } from './contexts/UpdateContext'; export default function App() { return ( + }> @@ -30,5 +32,6 @@ export default function App() { } /> + ); } diff --git a/VM1_app/figma/src/app/components/UpdateModal.tsx b/VM1_app/figma/src/app/components/UpdateModal.tsx new file mode 100644 index 0000000..a3fd522 --- /dev/null +++ b/VM1_app/figma/src/app/components/UpdateModal.tsx @@ -0,0 +1,170 @@ +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { ArrowUpCircle, AlertTriangle, X } from "lucide-react"; +import { useUpdate } from "../contexts/UpdateContext"; + +export function UpdateModal() { + const { config, dismissUpdate } = useUpdate(); + + if (!config) return null; + + const isStrict = config.type === "strict"; + + const handleUpdate = () => { + if (config.updateUrl) { + window.open(config.updateUrl, "_blank", "noopener,noreferrer"); + } + // В strict-режиме не закрываем — пользователь обязан обновиться + if (!isStrict) { + dismissUpdate(); + } + }; + + const handleOpenChange = (open: boolean) => { + if (!open && !isStrict) { + dismissUpdate(); + } + // В strict-режиме игнорируем попытки закрыть + }; + + return ( + + + {/* Оверлей: в strict-режиме pointer-events-none отключаем клик по фону */} + + + isStrict && e.preventDefault()} + onPointerDownOutside={(e) => isStrict && e.preventDefault()} + onInteractOutside={(e) => isStrict && e.preventDefault()} + className={[ + "fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2", + "w-full max-w-[360px] rounded-2xl border border-border bg-background shadow-2xl", + "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", + "focus:outline-none", + ].join(" ")} + > + {/* Иконка и шапка */} +
+
+ {isStrict ? ( + + ) : ( + + )} +
+ +
+ + {isStrict ? "Обновление обязательно" : "Доступно обновление"} + +

+ Версия{" "} + + {config.newVersion} + +

+
+
+ + {/* Тело */} +
+ + {isStrict ? ( + <> + Версия {config.currentVersion} больше не + поддерживается. Для продолжения работы необходимо установить + обновление. + + ) : ( + <> + Вышла новая версия приложения. Вы можете обновить его сейчас + или сделать это позже. + + )} + + + {config.releaseNotes && ( +
+ {config.releaseNotes} +
+ )} + + {/* Плашка текущей / новой версии */} +
+ + {config.currentVersion} + + + + {config.newVersion} + +
+
+ + {/* Кнопки */} +
+ + + {!isStrict && ( + + )} +
+ + {/* Крестик только в soft-режиме */} + {!isStrict && ( + + + + )} +
+
+
+ ); +} diff --git a/VM1_app/figma/src/app/contexts/UpdateContext.tsx b/VM1_app/figma/src/app/contexts/UpdateContext.tsx new file mode 100644 index 0000000..4333a9f --- /dev/null +++ b/VM1_app/figma/src/app/contexts/UpdateContext.tsx @@ -0,0 +1,43 @@ +import React, { createContext, useContext, useState, useCallback } from "react"; + +export type UpdateType = "soft" | "strict"; + +export interface UpdateConfig { + type: UpdateType; + currentVersion: string; + newVersion: string; + updateUrl?: string; + releaseNotes?: string; +} + +interface UpdateContextValue { + config: UpdateConfig | null; + showUpdate: (cfg: UpdateConfig) => void; + dismissUpdate: () => void; +} + +const UpdateContext = createContext(null); + +export function UpdateProvider({ children }: { children: React.ReactNode }) { + const [config, setConfig] = useState(null); + + const showUpdate = useCallback((cfg: UpdateConfig) => { + setConfig(cfg); + }, []); + + const dismissUpdate = useCallback(() => { + setConfig(null); + }, []); + + return ( + + {children} + + ); +} + +export function useUpdate() { + const ctx = useContext(UpdateContext); + if (!ctx) throw new Error("useUpdate must be used inside UpdateProvider"); + return ctx; +} diff --git a/VM1_app/figma/src/app/pages/Home.tsx b/VM1_app/figma/src/app/pages/Home.tsx index 00a815d..7515818 100644 --- a/VM1_app/figma/src/app/pages/Home.tsx +++ b/VM1_app/figma/src/app/pages/Home.tsx @@ -3,13 +3,44 @@ import { Notifications } from '../components/Notifications'; import { PopularQuestions } from '../components/PopularQuestions'; import { ChatInput } from '../components/ChatInput'; import { QuickActions } from '../components/QuickActions'; +import { useUpdate } from '../contexts/UpdateContext'; export function Home() { + const { showUpdate } = useUpdate(); + return ( <> {/* Компактный логотип HAN */} + {/* Demo: триггеры для тестирования диалогов обновления */} +
+ + +
+ {/* Уведомления от компании */}
diff --git a/VM1_app/figma/src/app/pages/Root.tsx b/VM1_app/figma/src/app/pages/Root.tsx index 0b14c6e..8020636 100644 --- a/VM1_app/figma/src/app/pages/Root.tsx +++ b/VM1_app/figma/src/app/pages/Root.tsx @@ -1,5 +1,6 @@ import { Outlet, useLocation } from 'react-router'; import { Header } from '../components/Header'; +import { UpdateModal } from '../components/UpdateModal'; export function Root() { const location = useLocation(); @@ -14,6 +15,8 @@ export function Root() {
+ + ); } diff --git a/VM4_Expo-mobile/.env.example b/VM4_Expo-mobile/.env.example index 10972b1..efafbcb 100644 --- a/VM4_Expo-mobile/.env.example +++ b/VM4_Expo-mobile/.env.example @@ -3,5 +3,6 @@ EXPO_PUBLIC_AUTH_BASE_URL=https://chat.example.ru/auth EXPO_PUBLIC_KEYCLOAK_REALM=han-chat EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend EXPO_PUBLIC_APP_ENV=production-like +EXPO_PUBLIC_DISTRIBUTION_STORE=disabled # Только публичные значения. Service tokens, S3 credentials и OTP-код запрещены. diff --git a/VM4_Expo-mobile/README.md b/VM4_Expo-mobile/README.md index 4ec8d33..e52f232 100644 --- a/VM4_Expo-mobile/README.md +++ b/VM4_Expo-mobile/README.md @@ -1,6 +1,7 @@ # HAN Chat Mobile -Мобильный Android-клиент HAN Chat на Expo SDK 57. +Мобильный клиент HAN Chat на Expo SDK 57. Текущая версия приложения — `1.0.1`, +native identity — Android `versionCode: 2` и iOS `buildNumber: 2`. ## Подготовка @@ -23,15 +24,49 @@ npm test Запуск приложения выполняется отдельно командой `npm run android`. Проект использует managed Expo workflow и не хранит каталоги `android/` и `ios/`. +## Store-only обновления + +Приложение не загружает JS/asset OTA-обновления. Оно читает `mobile_update` из +`GET /api/v1/public/app-config` и отправляет пользователя в магазин своей сборки. +Канал фиксируется на этапе EAS build: + +- `EXPO_PUBLIC_DISTRIBUTION_STORE=google_play`; +- `EXPO_PUBLIC_DISTRIBUTION_STORE=rustore`; +- `EXPO_PUBLIC_DISTRIBUTION_STORE=app_store`. + +Неизвестное или отсутствующее значение отключает механику. Поэтому профили +`development` и `preview` имеют значение `disabled`. Профиль +`preview-rustore` намеренно задаёт `rustore`, чтобы проверять update policy на +внутреннем APK через dev-backend. + +Решение принимается только по целому native build: + +- build ниже `minimum_build` — обязательное (`force`) обновление; +- build ниже `latest_build`, но не ниже minimum — мягкое (`soft`) обновление; +- build не ниже latest — обновление не показывается. + +Soft-карточку можно закрыть; выбор запоминается в SecureStore отдельно для пары +`канал + latest_build`. Force-карточка блокирует Android Back и не имеет способов +закрытия. Проверка выполняется на холодном старте и при возврате в foreground, +параллельные проверки объединяются. Сетевая ошибка, невалидный build или +недоверенный URL работают fail-open и не блокируют интерфейс. + +Допускаются только HTTPS-ссылки соответствующего магазина: +`play.google.com/store/apps/details?id=ru.han.chat`, +`www.rustore.ru/catalog/app/ru.han.chat` и App Store URL на `apps.apple.com` с Apple ID. + ## Сборка (EAS) Профили заданы в `eas.json`. Env для билда берётся из профиля (`eas.json` → `env`), а не из локального `.env`. | Профиль | Артефакт | API | `EXPO_PUBLIC_APP_ENV` | |---|---|---|---| -| `preview` | APK (внутренняя установка) | `dev-chat.han0107.ru` | `production-like` (есть «Диагностика») | -| `production` | AAB (Google Play) | `chat.han0107.ru` | `production` (без диагностики) | -| `development` | Dev Client | из окружения/локально | для разработки с `expo-dev-client` | +| `preview` | APK (внутренняя установка) | `dev-chat.han0107.ru` | update policy отключена | +| `preview-rustore` | APK (тест RuStore policy) | `dev-chat.han0107.ru` | `rustore` | +| `google-play` | AAB (Google Play) | `chat.han0107.ru` | `google_play` | +| `rustore` | AAB (RuStore) | `chat.han0107.ru` | `rustore` | +| `app-store` | iOS App Store | `chat.han0107.ru` | `app_store` | +| `development` | Dev Client | из окружения/локально | update policy отключена | ### Один раз: вход в Expo @@ -58,19 +93,52 @@ npx eas-cli build --profile preview --platform android После успеха откройте ссылку из вывода CLI (или `https://expo.dev/accounts/anzh/projects/han-chat/builds`) и скачайте APK. -### Production — AAB для Play Store +### Preview RuStore — проверка soft/force update ```powershell -npx eas-cli build --profile production --platform android +npx eas-cli build:version:get --platform android --profile preview-rustore +npx eas-cli build --profile preview-rustore --platform android ``` -`versionCode` поднимается автоматически (`appVersionSource: remote` + `autoIncrement`). +Профиль использует dev API и встраивает канал `rustore`, но не увеличивает общий +remote Android `versionCode` (`autoIncrement: false`). Запишите build из +`build:version:get` или карточки завершённой сборки. Для build `B` настройте +политику RuStore на dev-backend: + +- soft: `latest_build=B+1`, `minimum_build<=B`; +- force: `latest_build=B+1`, `minimum_build=B+1`; +- none: `latest_build<=B`. + +После изменения dev settings примените штатный `seed-settings`, подождите до +истечения cache TTL и перезапустите APK. Для повторной проверки soft на том же +`latest_build` очистите данные приложения или увеличьте `latest_build`, так как +«Позже» сохраняется в SecureStore без TTL. + +APK имеет тот же package `ru.han.chat` и подпись EAS, поэтому может заменить +установленную store-сборку. Используйте отдельное тестовое устройство или заранее +учтите замену приложения и его локальных данных. + +### Store-сборки + +```powershell +npx eas-cli build --profile google-play --platform android +npx eas-cli build --profile rustore --platform android +npx eas-cli build --profile app-store --platform ios +``` + +Native build поднимается через `appVersionSource: remote` и platform-specific +`autoIncrement`. Google Play и RuStore используют общий Android application ID +`ru.han.chat`, поэтому их последовательные сборки увеличивают один remote +`versionCode`: например, первая получит `7`, следующая — `8`. Backend-пороги +магазинов поэтому заполняются независимо по фактически опубликованным +артефактам. Локальные `versionCode: 2`/`buildNumber: 2` служат исходной native +identity, но при remote source фактическое значение сборки определяет EAS. ### Полезные команды ```powershell # только отправить билд и сразу вернуть ссылку (не ждать окончания) -npx eas-cli build --profile production --platform android --no-wait +npx eas-cli build --profile google-play --platform android --no-wait # статус конкретной сборки npx eas-cli build:view @@ -78,6 +146,11 @@ npx eas-cli build:view # список последних сборок npx eas-cli build:list --platform android --limit 5 +# прочитать текущий remote versionCode/buildNumber +npx eas-cli build:version:get --platform android --profile google-play +npx eas-cli build:version:get --platform android --profile rustore +npx eas-cli build:version:get --platform ios --profile app-store + # скачать артефакт готовой сборки npx eas-cli build:download --id @@ -85,6 +158,9 @@ npx eas-cli build:download --id npx eas-cli build:version:set ``` +Полная процедура rollout, включения soft/force policy и отката: +`../VM1_app/codebase/backend/deployment/RUNBOOK.mobile-updates.ru.md`. + ### Keycloak redirect URI Для боевого хоста в клиенте `han-chat-frontend` должны быть exact URI: diff --git a/VM4_Expo-mobile/app.config.ts b/VM4_Expo-mobile/app.config.ts index cdc54e3..d4b0404 100644 --- a/VM4_Expo-mobile/app.config.ts +++ b/VM4_Expo-mobile/app.config.ts @@ -4,13 +4,14 @@ const config: ExpoConfig = { name: "HAN Chat", slug: "han-chat", owner: "anzh", - version: "1.0.0", + version: "1.0.1", scheme: "han-chat", icon: "./.assets/icons/icon.png", orientation: "portrait", userInterfaceStyle: "light", android: { package: "ru.han.chat", + versionCode: 2, softwareKeyboardLayoutMode: "resize", adaptiveIcon: { foregroundImage: "./.assets/icons/adaptive-foreground.png", @@ -32,6 +33,10 @@ const config: ExpoConfig = { }, ], }, + ios: { + bundleIdentifier: "ru.han.chat", + buildNumber: "2", + }, experiments: { typedRoutes: true, }, diff --git a/VM4_Expo-mobile/app/_layout.tsx b/VM4_Expo-mobile/app/_layout.tsx index 14a662b..d3639f2 100644 --- a/VM4_Expo-mobile/app/_layout.tsx +++ b/VM4_Expo-mobile/app/_layout.tsx @@ -3,15 +3,18 @@ import { StatusBar } from "expo-status-bar"; import React from "react"; import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; import { AppProvider } from "../src/app-context"; +import { AppUpdateProvider } from "../src/app-update-context"; import { colors } from "../src/theme"; export default function RootLayout() { return - - - - - - + + + + + + + + ; } diff --git a/VM4_Expo-mobile/eas.json b/VM4_Expo-mobile/eas.json index eac0375..7112db3 100644 --- a/VM4_Expo-mobile/eas.json +++ b/VM4_Expo-mobile/eas.json @@ -6,7 +6,10 @@ "build": { "development": { "developmentClient": true, - "distribution": "internal" + "distribution": "internal", + "env": { + "EXPO_PUBLIC_DISTRIBUTION_STORE": "disabled" + } }, "preview": { "distribution": "internal", @@ -19,18 +22,66 @@ "EXPO_PUBLIC_AUTH_BASE_URL": "https://dev-chat.han0107.ru/auth", "EXPO_PUBLIC_KEYCLOAK_REALM": "han-chat", "EXPO_PUBLIC_KEYCLOAK_CLIENT_ID": "han-chat-frontend", - "EXPO_PUBLIC_APP_ENV": "production-like" + "EXPO_PUBLIC_APP_ENV": "production-like", + "EXPO_PUBLIC_DISTRIBUTION_STORE": "disabled" } }, - "production": { + "preview-rustore": { + "distribution": "internal", + "android": { + "buildType": "apk", + "autoIncrement": false + }, + "env": { + "EXPO_PUBLIC_API_BASE_URL": "https://dev-chat.han0107.ru", + "EXPO_PUBLIC_AUTH_BASE_URL": "https://dev-chat.han0107.ru/auth", + "EXPO_PUBLIC_KEYCLOAK_REALM": "han-chat", + "EXPO_PUBLIC_KEYCLOAK_CLIENT_ID": "han-chat-frontend", + "EXPO_PUBLIC_APP_ENV": "production-like", + "EXPO_PUBLIC_DISTRIBUTION_STORE": "rustore" + } + }, + "google-play": { "distribution": "store", - "autoIncrement": true, + "android": { + "autoIncrement": true + }, "env": { "EXPO_PUBLIC_API_BASE_URL": "https://chat.han0107.ru", "EXPO_PUBLIC_AUTH_BASE_URL": "https://chat.han0107.ru/auth", "EXPO_PUBLIC_KEYCLOAK_REALM": "han-chat", "EXPO_PUBLIC_KEYCLOAK_CLIENT_ID": "han-chat-frontend", - "EXPO_PUBLIC_APP_ENV": "production" + "EXPO_PUBLIC_APP_ENV": "production", + "EXPO_PUBLIC_DISTRIBUTION_STORE": "google_play" + } + }, + "rustore": { + "distribution": "store", + "android": { + "buildType": "app-bundle", + "autoIncrement": true + }, + "env": { + "EXPO_PUBLIC_API_BASE_URL": "https://chat.han0107.ru", + "EXPO_PUBLIC_AUTH_BASE_URL": "https://chat.han0107.ru/auth", + "EXPO_PUBLIC_KEYCLOAK_REALM": "han-chat", + "EXPO_PUBLIC_KEYCLOAK_CLIENT_ID": "han-chat-frontend", + "EXPO_PUBLIC_APP_ENV": "production", + "EXPO_PUBLIC_DISTRIBUTION_STORE": "rustore" + } + }, + "app-store": { + "distribution": "store", + "ios": { + "autoIncrement": true + }, + "env": { + "EXPO_PUBLIC_API_BASE_URL": "https://chat.han0107.ru", + "EXPO_PUBLIC_AUTH_BASE_URL": "https://chat.han0107.ru/auth", + "EXPO_PUBLIC_KEYCLOAK_REALM": "han-chat", + "EXPO_PUBLIC_KEYCLOAK_CLIENT_ID": "han-chat-frontend", + "EXPO_PUBLIC_APP_ENV": "production", + "EXPO_PUBLIC_DISTRIBUTION_STORE": "app_store" } } } diff --git a/VM4_Expo-mobile/package-lock.json b/VM4_Expo-mobile/package-lock.json index ea224fe..2128475 100644 --- a/VM4_Expo-mobile/package-lock.json +++ b/VM4_Expo-mobile/package-lock.json @@ -1,16 +1,17 @@ { "name": "han-chat", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "han-chat", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "@expo/vector-icons": "15.0.3", "@tanstack/react-query": "5.101.2", "expo": "~57.0.15", + "expo-application": "~57.0.2", "expo-auth-session": "~57.0.8", "expo-constants": "~57.0.13", "expo-crypto": "~57.0.1", diff --git a/VM4_Expo-mobile/package.json b/VM4_Expo-mobile/package.json index 6d18feb..e3a657c 100644 --- a/VM4_Expo-mobile/package.json +++ b/VM4_Expo-mobile/package.json @@ -1,6 +1,6 @@ { "name": "han-chat", - "version": "1.0.0", + "version": "1.0.1", "private": true, "main": "expo-router/entry", "scripts": { @@ -16,6 +16,7 @@ "@expo/vector-icons": "15.0.3", "@tanstack/react-query": "5.101.2", "expo": "~57.0.15", + "expo-application": "~57.0.2", "expo-auth-session": "~57.0.8", "expo-constants": "~57.0.13", "expo-crypto": "~57.0.1", diff --git a/VM4_Expo-mobile/src/app-update-context.tsx b/VM4_Expo-mobile/src/app-update-context.tsx new file mode 100644 index 0000000..c7b482a --- /dev/null +++ b/VM4_Expo-mobile/src/app-update-context.tsx @@ -0,0 +1,98 @@ +import * as Application from "expo-application"; +import * as SecureStore from "expo-secure-store"; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { AppState, type AppStateStatus } from "react-native"; +import { UpdateModal } from "./components/UpdateModal"; +import { env } from "./config"; +import { + isSoftUpdateDismissed, + parseNativeBuild, + persistSoftUpdateDismiss, + selectAppUpdate, + type AppUpdate, +} from "./app-update"; +import { publicApi } from "./services"; + +type AppUpdateContextValue = { + update: AppUpdate | null; + checkForUpdate: () => Promise; + dismissSoftUpdate: () => Promise; +}; + +const Context = createContext(null); + +export function AppUpdateProvider({ children }: { children: React.ReactNode }) { + const [update, setUpdate] = useState(null); + const checkPromise = useRef | null>(null); + const appState = useRef(AppState.currentState); + + const checkForUpdate = useCallback(() => { + if (checkPromise.current) return checkPromise.current; + + const operation = (async () => { + try { + const config = await publicApi.config(); + const candidate = selectAppUpdate( + env.distributionStore, + { + version: Application.nativeApplicationVersion ?? "—", + build: parseNativeBuild(Application.nativeBuildVersion), + }, + config.mobile_update, + ); + + if (candidate?.kind === "soft") { + const dismissed = await isSoftUpdateDismissed(candidate, { + getItem: SecureStore.getItemAsync, + }); + setUpdate(dismissed ? null : candidate); + } else { + setUpdate(candidate); + } + } catch { + // Fail-open: недоступная или некорректная политика не блокирует приложение. + setUpdate((current) => current?.kind === "force" ? current : null); + } + })().finally(() => { + checkPromise.current = null; + }); + + checkPromise.current = operation; + return operation; + }, []); + + useEffect(() => { + void checkForUpdate(); + const subscription = AppState.addEventListener("change", (nextState) => { + const wasInactive = appState.current === "inactive" || appState.current === "background"; + appState.current = nextState; + if (wasInactive && nextState === "active") void checkForUpdate(); + }); + return () => subscription.remove(); + }, [checkForUpdate]); + + const dismissSoftUpdate = useCallback(async () => { + if (update?.kind !== "soft") return; + setUpdate(null); + await persistSoftUpdateDismiss(update, { setItem: SecureStore.setItemAsync }); + }, [update]); + + const value = useMemo(() => ({ + update, + checkForUpdate, + dismissSoftUpdate, + }), [update, checkForUpdate, dismissSoftUpdate]); + + return ( + + {children} + + + ); +} + +export function useAppUpdate() { + const value = useContext(Context); + if (!value) throw new Error("AppUpdateProvider is missing"); + return value; +} diff --git a/VM4_Expo-mobile/src/app-update.ts b/VM4_Expo-mobile/src/app-update.ts new file mode 100644 index 0000000..e8a0349 --- /dev/null +++ b/VM4_Expo-mobile/src/app-update.ts @@ -0,0 +1,134 @@ +import type { DistributionStore } from "./config"; +import type { MobileUpdatePolicy } from "./types"; + +export type UpdateKind = "none" | "soft" | "force"; + +export type AppIdentity = { + version: string; + build: number | null; +}; + +export type UpdateDismissStore = { + getItem: (key: string) => Promise; + setItem: (key: string, value: string) => Promise; +}; + +export type AppUpdate = { + kind: Exclude; + channel: DistributionStore; + currentVersion: string; + currentBuild: number; + latestVersion: string; + latestBuild: number; + storeUrl: string; + releaseNotes?: string; +}; + +export function parseNativeBuild(value: string | null | undefined): number | null { + if (!value || !/^[1-9]\d*$/.test(value)) return null; + const build = Number(value); + return Number.isSafeInteger(build) ? build : null; +} + +export function isTrustedStoreUrl(channel: DistributionStore, value: string): boolean { + try { + const url = new URL(value); + if ( + url.protocol !== "https:" || + url.username !== "" || + url.password !== "" || + url.hash !== "" + ) return false; + + if (channel === "google_play") { + return url.hostname.toLowerCase() === "play.google.com" && + url.pathname === "/store/apps/details" && + url.searchParams.getAll("id").length === 1 && + url.searchParams.get("id") === "ru.han.chat"; + } + if (channel === "rustore") { + return url.hostname.toLowerCase() === "www.rustore.ru" && + url.pathname.replace(/\/$/, "") === "/catalog/app/ru.han.chat" && + url.search === ""; + } + return url.hostname.toLowerCase() === "apps.apple.com" && + /\/id\d+\/?$/.test(url.pathname); + } catch { + return false; + } +} + +export function classifyUpdate(currentBuild: number | null, policy: MobileUpdatePolicy): UpdateKind { + if ( + !policy.enabled || + currentBuild === null || + !Number.isSafeInteger(currentBuild) || + currentBuild < 1 || + policy.minimum_build === null || + policy.latest_build === null || + !Number.isSafeInteger(policy.minimum_build) || + !Number.isSafeInteger(policy.latest_build) || + policy.minimum_build < 1 || + policy.latest_build < policy.minimum_build + ) return "none"; + + if (currentBuild < policy.minimum_build) return "force"; + if (currentBuild < policy.latest_build) return "soft"; + return "none"; +} + +export function selectAppUpdate( + channel: DistributionStore | null, + identity: AppIdentity, + policies: Partial> | undefined, +): AppUpdate | null { + if (!channel) return null; + const policy = policies?.[channel]; + if ( + !policy || + policy.latest_build === null || + policy.latest_version === null || + policy.store_url === null || + !isTrustedStoreUrl(channel, policy.store_url) + ) return null; + const kind = classifyUpdate(identity.build, policy); + if (kind === "none" || identity.build === null) return null; + + return { + kind, + channel, + currentVersion: identity.version, + currentBuild: identity.build, + latestVersion: policy.latest_version, + latestBuild: policy.latest_build, + storeUrl: policy.store_url, + ...(policy.release_notes ? { releaseNotes: policy.release_notes } : {}), + }; +} + +export function softDismissKey(channel: DistributionStore, latestBuild: number): string { + return `han.app-update.dismissed.${channel}.${latestBuild}`; +} + +export async function isSoftUpdateDismissed( + update: AppUpdate, + store: Pick, +): Promise { + try { + return Boolean(await store.getItem(softDismissKey(update.channel, update.latestBuild))); + } catch { + return false; + } +} + +export async function persistSoftUpdateDismiss( + update: AppUpdate, + store: Pick, +): Promise { + try { + await store.setItem(softDismissKey(update.channel, update.latestBuild), new Date().toISOString()); + return true; + } catch { + return false; + } +} diff --git a/VM4_Expo-mobile/src/components/UpdateModal.tsx b/VM4_Expo-mobile/src/components/UpdateModal.tsx new file mode 100644 index 0000000..731ef5e --- /dev/null +++ b/VM4_Expo-mobile/src/components/UpdateModal.tsx @@ -0,0 +1,232 @@ +import { Ionicons } from "@expo/vector-icons"; +import React, { useEffect, useState } from "react"; +import { + ActivityIndicator, + Linking, + Modal, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; +import type { AppUpdate } from "../app-update"; +import { colors, radii, spacing } from "../theme"; + +type Props = { + update: AppUpdate | null; + onDismiss: () => Promise; +}; + +export function UpdateModal({ update, onDismiss }: Props) { + const [opening, setOpening] = useState(false); + const [urlError, setUrlError] = useState(null); + const forced = update?.kind === "force"; + + useEffect(() => setUrlError(null), [update]); + + if (!update) return null; + + const openStore = async () => { + if (opening) return; + setOpening(true); + setUrlError(null); + try { + await Linking.openURL(update.storeUrl); + if (!forced) await onDismiss(); + } catch { + setUrlError("Не удалось открыть магазин. Проверьте подключение и повторите попытку."); + } finally { + setOpening(false); + } + }; + + const dismiss = () => { + if (!forced) void onDismiss(); + }; + + return ( + + + + + {!forced && ( + [styles.close, pressed && styles.pressed]} + > + + + )} + + + + + {forced ? "Обновление обязательно" : "Доступно обновление"} + + + Версия {update.latestVersion} + + + + + + {forced + ? `Версия ${update.currentVersion} больше не поддерживается. Для продолжения работы необходимо установить обновление.` + : "Вышла новая версия приложения. Вы можете обновить его сейчас или сделать это позже."} + + + {update.releaseNotes ? ( + + {update.releaseNotes} + + ) : null} + + + {update.currentVersion} + + + {update.latestVersion} + + + + {urlError ? ( + + {urlError} + + ) : null} + + + + void openStore()} + disabled={opening} + accessibilityRole="button" + accessibilityLabel="Обновить приложение в магазине" + accessibilityState={{ disabled: opening, busy: opening }} + style={({ pressed }) => [ + styles.primaryButton, + forced ? styles.forceButton : styles.softButton, + (pressed || opening) && styles.pressed, + ]} + > + {opening + ? + : Обновить приложение} + + {!forced && ( + [styles.laterButton, pressed && styles.pressed]} + > + Позже + + )} + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: spacing.lg, + backgroundColor: "rgba(0, 0, 0, 0.60)", + }, + card: { + width: "100%", + maxWidth: 360, + overflow: "hidden", + borderRadius: radii.xl, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + backgroundColor: colors.card, + shadowColor: "#000", + shadowOffset: { width: 0, height: 12 }, + shadowOpacity: 0.3, + shadowRadius: 24, + elevation: 16, + }, + header: { alignItems: "center", paddingHorizontal: 24, paddingTop: 28, paddingBottom: 20 }, + softHeader: { backgroundColor: "#f7f7fa" }, + forceHeader: { backgroundColor: "#fdf0f3" }, + close: { position: "absolute", right: 16, top: 16, padding: 4, borderRadius: radii.full }, + iconCircle: { + width: 56, + height: 56, + alignItems: "center", + justifyContent: "center", + borderRadius: radii.full, + marginBottom: spacing.md, + }, + softIcon: { backgroundColor: "#e9e9ee" }, + forceIcon: { backgroundColor: "#f9dfe5" }, + title: { color: colors.foreground, fontSize: 18, lineHeight: 24, fontWeight: "600", textAlign: "center" }, + subtitle: { marginTop: spacing.xs, color: colors.mutedForeground, fontSize: 14 }, + versionStrong: { color: colors.foreground, fontWeight: "600" }, + body: { paddingHorizontal: 24, paddingTop: spacing.xl }, + description: { color: colors.mutedForeground, fontSize: 14, lineHeight: 21, textAlign: "center" }, + notes: { marginTop: spacing.lg, padding: spacing.md, borderRadius: 12, backgroundColor: "#f5f5f7" }, + notesText: { color: colors.mutedForeground, fontSize: 12, lineHeight: 18 }, + versionRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: spacing.sm, + marginTop: spacing.lg, + }, + versionBadge: { + overflow: "hidden", + borderRadius: radii.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + color: colors.mutedForeground, + backgroundColor: colors.muted, + fontSize: 12, + }, + latestBadge: { fontWeight: "600" }, + softLatest: { color: colors.primary, backgroundColor: "#e9e9ee" }, + forceLatest: { color: colors.destructive, backgroundColor: "#fbe8ed" }, + error: { marginTop: spacing.md, color: colors.destructive, fontSize: 12, lineHeight: 17, textAlign: "center" }, + actions: { gap: spacing.sm, paddingHorizontal: 24, paddingTop: spacing.xl, paddingBottom: 24 }, + primaryButton: { + minHeight: 48, + alignItems: "center", + justifyContent: "center", + borderRadius: 12, + paddingHorizontal: spacing.lg, + }, + softButton: { backgroundColor: colors.primary }, + forceButton: { backgroundColor: colors.destructive }, + primaryButtonText: { color: colors.primaryForeground, fontSize: 14, fontWeight: "600" }, + laterButton: { minHeight: 44, alignItems: "center", justifyContent: "center", borderRadius: 12 }, + laterText: { color: colors.mutedForeground, fontSize: 14, fontWeight: "500" }, + pressed: { opacity: 0.72 }, +}); diff --git a/VM4_Expo-mobile/src/config.ts b/VM4_Expo-mobile/src/config.ts index eaa2d48..4c7a5c9 100644 --- a/VM4_Expo-mobile/src/config.ts +++ b/VM4_Expo-mobile/src/config.ts @@ -1,6 +1,13 @@ const required = (value: string | undefined, fallback: string) => (value ?? fallback).replace(/\/$/, ""); +export type DistributionStore = "google_play" | "rustore" | "app_store"; + +const distributionStore = (value: string | undefined): DistributionStore | null => { + if (value === "google_play" || value === "rustore" || value === "app_store") return value; + return null; +}; + export const env = Object.freeze({ apiBaseUrl: required(process.env.EXPO_PUBLIC_API_BASE_URL, "http://localhost:8000"), authBaseUrl: required( @@ -10,6 +17,7 @@ export const env = Object.freeze({ realm: process.env.EXPO_PUBLIC_KEYCLOAK_REALM ?? "han-chat", clientId: process.env.EXPO_PUBLIC_KEYCLOAK_CLIENT_ID ?? "han-chat-frontend", appEnv: process.env.EXPO_PUBLIC_APP_ENV ?? "development", + distributionStore: distributionStore(process.env.EXPO_PUBLIC_DISTRIBUTION_STORE), }); export const mobileHttpsRedirectUri = `${env.apiBaseUrl}/mobile/oidc/callback`; diff --git a/VM4_Expo-mobile/src/types.ts b/VM4_Expo-mobile/src/types.ts index b0c7603..053c620 100644 --- a/VM4_Expo-mobile/src/types.ts +++ b/VM4_Expo-mobile/src/types.ts @@ -58,6 +58,21 @@ export type DocumentItem = { sent_at: string; }; +export type MobileUpdatePolicy = { + enabled: boolean; + minimum_build: number | null; + latest_build: number | null; + latest_version: string | null; + store_url: string | null; + release_notes?: string | null; +}; + +export type MobileUpdateConfig = { + google_play: MobileUpdatePolicy; + rustore: MobileUpdatePolicy; + app_store: MobileUpdatePolicy; +}; + export type PublicConfig = { auth: { phone_enabled: boolean; password_enabled: boolean }; operator: { call_phone: string }; @@ -78,6 +93,7 @@ export type PublicConfig = { allowed_mime_types: string[]; }; ux: { idle_timeout_minutes: number }; + mobile_update: MobileUpdateConfig; }; export type PublicContent = { locale: string; diff --git a/VM4_Expo-mobile/tests/unit/app-update.test.ts b/VM4_Expo-mobile/tests/unit/app-update.test.ts new file mode 100644 index 0000000..81c34f7 --- /dev/null +++ b/VM4_Expo-mobile/tests/unit/app-update.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { + classifyUpdate, + isSoftUpdateDismissed, + isTrustedStoreUrl, + parseNativeBuild, + persistSoftUpdateDismiss, + selectAppUpdate, + softDismissKey, +} from "../../src/app-update"; +import type { DistributionStore } from "../../src/config"; +import type { MobileUpdatePolicy } from "../../src/types"; + +const policy = (overrides: Partial = {}): MobileUpdatePolicy => ({ + enabled: true, + minimum_build: 2, + latest_build: 4, + latest_version: "1.0.4", + store_url: "https://play.google.com/store/apps/details?id=ru.han.chat", + ...overrides, +}); + +describe("app update classification", () => { + it("классифицирует целочисленные границы как force, soft и none", () => { + expect(classifyUpdate(1, policy())).toBe("force"); + expect(classifyUpdate(2, policy())).toBe("soft"); + expect(classifyUpdate(3, policy())).toBe("soft"); + expect(classifyUpdate(4, policy())).toBe("none"); + expect(classifyUpdate(5, policy())).toBe("none"); + }); + + it("работает fail-open для выключенной, отсутствующей и некорректной политики", () => { + expect(classifyUpdate(null, policy())).toBe("none"); + expect(classifyUpdate(1, policy({ enabled: false }))).toBe("none"); + expect(classifyUpdate(1.5, policy())).toBe("none"); + expect(classifyUpdate(1, policy({ minimum_build: 5, latest_build: 4 }))).toBe("none"); + }); + + it("принимает только положительный integer native build", () => { + expect(parseNativeBuild("2")).toBe(2); + expect(parseNativeBuild("002")).toBeNull(); + expect(parseNativeBuild("2.0")).toBeNull(); + expect(parseNativeBuild("0")).toBeNull(); + expect(parseNativeBuild(undefined)).toBeNull(); + }); +}); + +describe("store channels and URLs", () => { + const trusted: Record = { + google_play: "https://play.google.com/store/apps/details?id=ru.han.chat", + rustore: "https://www.rustore.ru/catalog/app/ru.han.chat", + app_store: "https://apps.apple.com/ru/app/han-chat/id123456789", + }; + + it("разрешает только HTTPS URL доверенного магазина для каждого канала", () => { + for (const [channel, url] of Object.entries(trusted) as Array<[DistributionStore, string]>) { + expect(isTrustedStoreUrl(channel, url)).toBe(true); + } + expect(isTrustedStoreUrl("google_play", trusted.rustore)).toBe(false); + expect(isTrustedStoreUrl("rustore", "http://www.rustore.ru/catalog/app/ru.han.chat")).toBe(false); + expect(isTrustedStoreUrl("app_store", "https://apps.apple.com.evil.test/app/id1")).toBe(false); + expect(isTrustedStoreUrl("google_play", `${trusted.google_play}#redirect`)).toBe(false); + expect(isTrustedStoreUrl( + "google_play", + "https://play.google.com/store/apps/details?id=ru.attacker.app", + )).toBe(false); + expect(isTrustedStoreUrl( + "rustore", + "https://www.rustore.ru/catalog/app/ru.attacker.app", + )).toBe(false); + expect(isTrustedStoreUrl("rustore", `${trusted.rustore}?ref=other`)).toBe(false); + expect(isTrustedStoreUrl("app_store", "https://apps.apple.com/ru/app/han-chat")).toBe(false); + }); + + it("выбирает только политику канала текущей store-сборки", () => { + const policies = { + google_play: policy({ store_url: trusted.google_play }), + rustore: policy({ store_url: trusted.rustore }), + app_store: policy({ store_url: trusted.app_store }), + }; + const update = selectAppUpdate("rustore", { version: "1.0.1", build: 1 }, policies); + expect(update).toMatchObject({ kind: "force", channel: "rustore", storeUrl: trusted.rustore }); + expect(selectAppUpdate(null, { version: "1.0.1", build: 1 }, policies)).toBeNull(); + }); + + it("формирует отдельный dismiss key по каналу и latest build", () => { + expect(softDismissKey("google_play", 4)).toBe("han.app-update.dismissed.google_play.4"); + expect(softDismissKey("rustore", 4)).not.toBe(softDismissKey("google_play", 4)); + expect(softDismissKey("google_play", 5)).not.toBe(softDismissKey("google_play", 4)); + }); + + it("не скрывает soft update при ошибке чтения SecureStore", async () => { + const update = selectAppUpdate( + "google_play", + { version: "1.0.1", build: 2 }, + { google_play: policy() }, + ); + expect(update?.kind).toBe("soft"); + await expect(isSoftUpdateDismissed(update!, { + getItem: async () => { throw new Error("secure store unavailable"); }, + })).resolves.toBe(false); + }); + + it("позволяет закрыть soft update при ошибке записи SecureStore", async () => { + const update = selectAppUpdate( + "google_play", + { version: "1.0.1", build: 2 }, + { google_play: policy() }, + ); + await expect(persistSoftUpdateDismiss(update!, { + setItem: async () => { throw new Error("secure store unavailable"); }, + })).resolves.toBe(false); + }); +}); diff --git a/VM4_Expo-mobile/tests/unit/core.test.ts b/VM4_Expo-mobile/tests/unit/core.test.ts index db3cbea..577361b 100644 --- a/VM4_Expo-mobile/tests/unit/core.test.ts +++ b/VM4_Expo-mobile/tests/unit/core.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; const secureValues = vi.hoisted(() => new Map()); vi.mock("expo-constants", () => ({ - default: { expoConfig: { version: "1.0.0" } }, + default: { expoConfig: { version: "1.0.1" } }, })); vi.mock("expo-crypto", () => ({ CryptoDigestAlgorithm: { SHA256: "SHA-256" }, @@ -217,7 +217,7 @@ describe("OIDC device metadata", () => { expect(first).toMatchObject({ han_fingerprint: "stable-fingerprint", han_platform: "android", - han_app_version: "1.0.0", + han_app_version: "1.0.1", }); }); }); diff --git a/support¬es/deploy_stories/260903_2.md b/support¬es/deploy_stories/260903_2.md new file mode 100644 index 0000000..23d60c0 --- /dev/null +++ b/support¬es/deploy_stories/260903_2.md @@ -0,0 +1,41 @@ +Поддержка версионирования приложений + +# Копируем файл на ВМ1 (HAN_chat_specification\VM1_app\codebase\backend\deployment\app-settings.production-like.yaml) + +cd C:/Users/MI/Documents/Assistent/HAN_chat_specification/VM1_app/ + +$Hotfix = "update_260903_2" +tar -czf "vm1-$Hotfix.tar.gz" -C ./codebase ` + backend/deployment/app-settings.production-like.yaml +Get-FileHash "vm1-$Hotfix.tar.gz" -Algorithm SHA256 +scp "vm1-$Hotfix.tar.gz" devVM1Deploy:/var/lib/han-deploy/incoming/ +scp "vm1-$Hotfix.tar.gz" prodVM1Deploy:/var/lib/han-deploy/incoming/ + + +HOTFIX='update_260903_2' +EXPECTED_SHA256='85EBAF9D1DA60FD4F4CAD00AC1971BC8A2AA42D4F61989BC91B77BCC2472576A' +ARCHIVE="/var/lib/han-deploy/incoming/vm1-${HOTFIX}.tar.gz" +printf '%s %s\n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check - + +STAGING="$(mktemp -d /opt/han-chat/.bug-hotfix.XXXXXX)" +tar -xzf "$ARCHIVE" -C "$STAGING" --no-same-owner --no-same-permissions +install -m 0755 -o root -g root "$STAGING/backend/deployment/app-settings.production-like.yaml" /opt/han-chat/current/backend/deployment/app-settings.production-like.yaml + +rm -rf "$STAGING" + +sed -i 's/\r$//' \ +/opt/han-chat/current/backend/deployment/app-settings.production-like.yaml + +# Редактируем файл /etc/han/vm1.env +editor /etc/han/vm1.env + +# Запускаем обновление +/opt/han-chat/current/backend/deployment/preflight.sh +/usr/local/sbin/han-vm1-compose config --quiet +/usr/local/sbin/han-vm1-compose --profile ops run --rm seed-settings +/usr/local/sbin/han-vm1-compose up -d api-backend +/usr/local/sbin/han-vm1-compose up -d nginx + +/usr/local/sbin/han-vm1-compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}" + +# Сборка тестовой версии приложения \ No newline at end of file