Реализована проверка версионности и требование soft\force обновлений приложения
This commit is contained in:
@@ -84,3 +84,18 @@ pytest
|
|||||||
зависимости read API. Remote Message Safety не выключает чтение/общую readiness:
|
зависимости read API. Remote Message Safety не выключает чтение/общую readiness:
|
||||||
send endpoint отдельно проверяет требуемую capability и fail-closed возвращает
|
send endpoint отдельно проверяет требуемую capability и fail-closed возвращает
|
||||||
`503`, если ВМ2 недоступна.
|
`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.
|
||||||
|
|||||||
@@ -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.chat_settings import CHAT_MESSAGE_MAX_LENGTH_KEY, validate_chat_settings
|
||||||
from app.db import AppSetting, Database
|
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.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
|
||||||
from app.settings import get_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")
|
raise ValueError(f"{key}: type must be integer")
|
||||||
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and value_type != "integer":
|
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and value_type != "integer":
|
||||||
raise ValueError(f"{key}: type must be 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):
|
if not isinstance(raw.get("public"), bool):
|
||||||
raise ValueError(f"{key}: public must be a boolean")
|
raise ValueError(f"{key}: public must be a boolean")
|
||||||
if key in OTP_SETTING_KEYS and raw["public"]:
|
if key in OTP_SETTING_KEYS and raw["public"]:
|
||||||
raise ValueError(f"{key}: OTP setting must not be public")
|
raise ValueError(f"{key}: OTP setting must not be public")
|
||||||
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and not raw["public"]:
|
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and not raw["public"]:
|
||||||
raise ValueError(f"{key}: setting must be 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")
|
description = raw.get("description")
|
||||||
if description is not None and not isinstance(description, str):
|
if description is not None and not isinstance(description, str):
|
||||||
raise ValueError(f"{key}: description must be a string")
|
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_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_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
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +90,8 @@ def serialize_value(key: str, value_type: str, value: Any) -> str:
|
|||||||
raise ValueError(f"{key}: boolean value expected")
|
raise ValueError(f"{key}: boolean value expected")
|
||||||
return str(value).lower()
|
return str(value).lower()
|
||||||
if value_type == "integer":
|
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):
|
if not isinstance(value, int) or isinstance(value, bool):
|
||||||
raise ValueError(f"{key}: integer value expected")
|
raise ValueError(f"{key}: integer value expected")
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ from app.schemas import (
|
|||||||
MessageRequest,
|
MessageRequest,
|
||||||
OpenLinesInbox,
|
OpenLinesInbox,
|
||||||
OtpSettingsResponse,
|
OtpSettingsResponse,
|
||||||
|
PublicAppConfigResponse,
|
||||||
SessionStartRequest,
|
SessionStartRequest,
|
||||||
decode_cursor,
|
decode_cursor,
|
||||||
encode_cursor,
|
encode_cursor,
|
||||||
@@ -192,6 +193,15 @@ app.include_router(notification_router)
|
|||||||
log = structlog.get_logger()
|
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:
|
def client_ip(request: Request) -> str | None:
|
||||||
"""Trust forwarded client addresses only from configured reverse proxies."""
|
"""Trust forwarded client addresses only from configured reverse proxies."""
|
||||||
peer = request.client.host if request.client else ""
|
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"])
|
@app.get(
|
||||||
async def app_config(request: Request, response: Response, settings: SnapshotDep):
|
"/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(
|
await enforce_limit(
|
||||||
request,
|
request,
|
||||||
"ip",
|
"ip",
|
||||||
@@ -511,12 +530,15 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep
|
|||||||
settings.limit("rate_limit.public_endpoints.per_ip"),
|
settings.limit("rate_limit.public_endpoints.per_ip"),
|
||||||
fail_closed=False,
|
fail_closed=False,
|
||||||
)
|
)
|
||||||
response.headers["Cache-Control"] = (
|
headers = {"Cache-Control": (
|
||||||
f"public, max-age={settings.integer('security.public_cache.max_age_seconds')}"
|
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
|
values = settings.values
|
||||||
return {
|
body = {
|
||||||
"auth": {
|
"auth": {
|
||||||
"phone_enabled": settings.boolean("auth.phone.enabled"),
|
"phone_enabled": settings.boolean("auth.phone.enabled"),
|
||||||
"password_enabled": settings.boolean("auth.password.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")},
|
"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"])
|
@app.get("/api/v1/public/content", tags=["public"])
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -27,6 +27,96 @@ class OtpSettingsResponse(StrictModel):
|
|||||||
cache_ttl_seconds: int = Field(strict=True, gt=0)
|
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):
|
class Device(StrictModel):
|
||||||
platform: Literal["ios", "android", "web"]
|
platform: Literal["ios", "android", "web"]
|
||||||
app_version: str = Field(min_length=1, max_length=64)
|
app_version: str = Field(min_length=1, max_length=64)
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ from app.integrations import (
|
|||||||
SafetyClient,
|
SafetyClient,
|
||||||
fresh_openlines_payload,
|
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.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
|
||||||
from app.realtime import RealtimeFanout
|
from app.realtime import RealtimeFanout
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
@@ -226,7 +232,7 @@ REQUIRED_SETTINGS = {
|
|||||||
"rate_limit.notification_upload.per_user",
|
"rate_limit.notification_upload.per_user",
|
||||||
"rate_limit.notifications_public.per_ip",
|
"rate_limit.notifications_public.per_ip",
|
||||||
CHAT_MESSAGE_MAX_LENGTH_KEY,
|
CHAT_MESSAGE_MAX_LENGTH_KEY,
|
||||||
} | OTP_SETTING_KEYS
|
} | OTP_SETTING_KEYS | MOBILE_UPDATE_SETTING_KEYS
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -286,15 +292,33 @@ async def load_settings(session: AsyncSession) -> SettingsSnapshot:
|
|||||||
invalid_metadata = sorted(
|
invalid_metadata = sorted(
|
||||||
row.setting_key
|
row.setting_key
|
||||||
for row in rows
|
for row in rows
|
||||||
if row.setting_key in OTP_SETTING_KEYS
|
if (
|
||||||
and (row.value_type != "integer" or row.is_public)
|
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:
|
if invalid_metadata:
|
||||||
raise ValueError(
|
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_otp_settings(values)
|
||||||
validate_chat_settings(values)
|
validate_chat_settings(values)
|
||||||
|
validate_mobile_update_settings(values)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
raise DomainError(
|
raise DomainError(
|
||||||
"dependency_unavailable",
|
"dependency_unavailable",
|
||||||
|
|||||||
@@ -19,20 +19,15 @@ paths:
|
|||||||
/api/v1/public/app-config:
|
/api/v1/public/app-config:
|
||||||
get:
|
get:
|
||||||
operationId: getPublicAppConfig
|
operationId: getPublicAppConfig
|
||||||
|
parameters:
|
||||||
|
- {name: If-None-Match, in: header, schema: {type: string}}
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Public application configuration
|
description: Public application configuration
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema: {$ref: "#/components/schemas/PublicAppConfigResponse"}
|
||||||
type: object
|
"304": {description: Cached configuration is still current}
|
||||||
required: [messages]
|
|
||||||
properties:
|
|
||||||
messages:
|
|
||||||
type: object
|
|
||||||
required: [max_text_length]
|
|
||||||
properties:
|
|
||||||
max_text_length: {type: integer, minimum: 1, maximum: 10000}
|
|
||||||
/api/v1/public/content:
|
/api/v1/public/content:
|
||||||
get:
|
get:
|
||||||
operationId: getPublicContent
|
operationId: getPublicContent
|
||||||
@@ -405,6 +400,105 @@ components:
|
|||||||
description: Catalog action is not allowed
|
description: Catalog action is not allowed
|
||||||
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
||||||
schemas:
|
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:
|
OtpSettingsResponse:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from pydantic import SecretStr
|
|||||||
from app.main import (
|
from app.main import (
|
||||||
EXPECTED_API_DB_REVISION,
|
EXPECTED_API_DB_REVISION,
|
||||||
app,
|
app,
|
||||||
|
app_config,
|
||||||
otp_settings,
|
otp_settings,
|
||||||
refresh_jwks_cache,
|
refresh_jwks_cache,
|
||||||
websocket_token,
|
websocket_token,
|
||||||
@@ -152,13 +153,116 @@ def test_committed_openapi_server_does_not_double_api_prefix() -> None:
|
|||||||
assert committed["servers"] == [{"url": "/"}]
|
assert committed["servers"] == [{"url": "/"}]
|
||||||
|
|
||||||
|
|
||||||
def test_public_config_contract_exposes_message_length() -> None:
|
def test_public_config_contract_is_strict_and_exposes_mobile_update() -> None:
|
||||||
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
|
generated = app.openapi()
|
||||||
response = committed["paths"]["/api/v1/public/app-config"]["get"]["responses"]["200"]
|
response = generated["paths"]["/api/v1/public/app-config"]["get"]["responses"]["200"]
|
||||||
messages = response["content"]["application/json"]["schema"]["properties"]["messages"]
|
schema_ref = response["content"]["application/json"]["schema"]["$ref"]
|
||||||
|
schema = generated["components"]["schemas"][schema_ref.rsplit("/", 1)[-1]]
|
||||||
|
|
||||||
assert messages["required"] == ["max_text_length"]
|
assert schema["additionalProperties"] is False
|
||||||
assert messages["properties"]["max_text_length"]["maximum"] == 10_000
|
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:
|
def test_otp_settings_contract_is_strict_and_complete() -> None:
|
||||||
|
|||||||
@@ -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.ttl_seconds"] == "60"
|
||||||
assert values["otp.phone.sms_order_timeout_ms"] == "3000"
|
assert values["otp.phone.sms_order_timeout_ms"] == "3000"
|
||||||
assert values["chat.message.max_length"] == "4000"
|
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:
|
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"):
|
with pytest.raises(ValueError, match="value must be between 1 and 10000"):
|
||||||
load_seed(path)
|
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)
|
||||||
|
|||||||
@@ -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 <BUILD_ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
Зафиксируйте:
|
||||||
|
|
||||||
|
- 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<APPLE_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.
|
||||||
|
- [ ] Итоговые значения политики и время применения записаны в журнал выпуска.
|
||||||
@@ -376,6 +376,9 @@ stack unit. Обновление active/exited oneshot всегда требуе
|
|||||||
```sh
|
```sh
|
||||||
curl -sS -o /dev/null -w '%{http_code}\n' http://<PUBLIC_HOST>/
|
curl -sS -o /dev/null -w '%{http_code}\n' http://<PUBLIC_HOST>/
|
||||||
curl -fsS https://<PUBLIC_HOST>/api/v1/public/app-config
|
curl -fsS https://<PUBLIC_HOST>/api/v1/public/app-config
|
||||||
|
ETAG="$(curl -fsSI https://<PUBLIC_HOST>/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://<PUBLIC_HOST>/api/v1/public/app-config
|
||||||
curl -fsS https://<PUBLIC_HOST>/auth/realms/han-chat/.well-known/openid-configuration
|
curl -fsS https://<PUBLIC_HOST>/auth/realms/han-chat/.well-known/openid-configuration
|
||||||
curl -sS -o /dev/null -w '%{http_code}\n' \
|
curl -sS -o /dev/null -w '%{http_code}\n' \
|
||||||
https://<PUBLIC_HOST>/internal/safety/v2/messages/check
|
https://<PUBLIC_HOST>/internal/safety/v2/messages/check
|
||||||
@@ -383,7 +386,12 @@ openssl s_client -connect <PUBLIC_HOST>:443 -servername <PUBLIC_HOST> \
|
|||||||
-verify_hostname <PUBLIC_HOST> -verify_return_error </dev/null
|
-verify_hostname <PUBLIC_HOST> -verify_return_error </dev/null
|
||||||
```
|
```
|
||||||
|
|
||||||
Ожидается `308`, public endpoints `200`, internal route `404`, valid chain.
|
Ожидается `308`, public endpoints `200`, повторный app-config `304`, internal
|
||||||
|
route `404`, valid chain. В app-config проверьте `mobile_update`: Google Play и
|
||||||
|
RuStore включены (`latest_build=2`, `minimum_build=1`, `latest_version=1.0.1`,
|
||||||
|
RuStore URL `https://www.rustore.ru/catalog/app/ru.han.chat`), App Store
|
||||||
|
отключён, его release-поля равны `null`; пустой `release_notes` у всех политик
|
||||||
|
также возвращается как `null`.
|
||||||
Проверьте guest/auth PKCE/OTP, SMS mode, Open Lines, idempotency, ownership,
|
Проверьте guest/auth PKCE/OTP, SMS mode, Open Lines, idempotency, ownership,
|
||||||
rate limits, WS reconciliation, S3 quarantine/promote/deny и Safety v2
|
rate limits, WS reconciliation, S3 quarantine/promote/deny и Safety v2
|
||||||
allow/deny/pending/timeout. Safety status `stub` не принимается.
|
allow/deny/pending/timeout. Safety status `stub` не принимается.
|
||||||
@@ -427,6 +435,12 @@ staging, выполнять config test и HUP.
|
|||||||
5xx/auth/Safety/PG/Redis/OOM/disk/OTEL queue/TLS. Отправьте только fake canary
|
5xx/auth/Safety/PG/Redis/OOM/disk/OTEL queue/TLS. Отправьте только fake canary
|
||||||
token/PII markers и докажите их отсутствие в logs/traces.
|
token/PII markers и докажите их отсутствие в logs/traces.
|
||||||
|
|
||||||
|
KESL 12.4 устанавливается и принимается только по отдельному операторскому
|
||||||
|
runbook `deployment/kesl/RUNBOOK.KESL.ru.md`. Не совмещайте установку,
|
||||||
|
полную/контейнерную антивирусную проверку или изменение File Threat Protection
|
||||||
|
с deploy, миграциями, PG backup, TLS renewal и перезапуском Docker/стека.
|
||||||
|
Изменение политики KESL не является частью обычного application release.
|
||||||
|
|
||||||
Перед reboot проверьте admin SSH и provider console:
|
Перед reboot проверьте admin SSH и provider console:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -47,5 +47,23 @@ settings:
|
|||||||
notification.expire_job.run_at: {type: string, value: "00:01", public: false}
|
notification.expire_job.run_at: {type: string, value: "00:01", public: false}
|
||||||
notification.upload_draft.ttl_days: {type: integer, value: 7, public: false}
|
notification.upload_draft.ttl_days: {type: integer, value: 7, public: false}
|
||||||
ux.session.idle_timeout_minutes: {type: integer, value: 30, public: true}
|
ux.session.idle_timeout_minutes: {type: integer, value: 30, public: true}
|
||||||
|
mobile_update.google_play.enabled: {type: boolean, value: true, public: true}
|
||||||
|
mobile_update.google_play.latest_build: {type: integer, value: 2, 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.google_play.store_url: {type: string, value: "https://play.google.com/store/apps/details?id=ru.han.chat", public: true}
|
||||||
|
mobile_update.google_play.release_notes: {type: string, value: "", public: true}
|
||||||
|
mobile_update.rustore.enabled: {type: boolean, value: true, public: true}
|
||||||
|
mobile_update.rustore.latest_build: {type: integer, value: 2, 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}
|
||||||
|
mobile_update.rustore.store_url: {type: string, value: "https://www.rustore.ru/catalog/app/ru.han.chat", public: true}
|
||||||
|
mobile_update.rustore.release_notes: {type: string, value: "", public: true}
|
||||||
|
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}
|
||||||
security.cors.allowed_origins: {type: string_list, value: "https://chat.example.ru", public: false}
|
security.cors.allowed_origins: {type: string_list, value: "https://chat.example.ru", public: false}
|
||||||
security.public_cache.max_age_seconds: {type: integer, value: 3600, public: false}
|
security.public_cache.max_age_seconds: {type: integer, value: 60, public: false}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Карта доказательств АВЗ.1 и АВЗ.2 для ВМ1
|
||||||
|
|
||||||
|
Форма заполняется оператором после выполнения `RUNBOOK.KESL.ru.md`. Она не
|
||||||
|
должна содержать activation code, ключи, токены, DSN, environment, содержимое
|
||||||
|
secret-файлов, персональные данные или тестовый файл EICAR.
|
||||||
|
|
||||||
|
## 1. Идентификация изменения
|
||||||
|
|
||||||
|
- Change ID:
|
||||||
|
- Дата и окно:
|
||||||
|
- Оператор:
|
||||||
|
- Security approver:
|
||||||
|
- Service owner:
|
||||||
|
- Hostname ВМ1:
|
||||||
|
- Ubuntu version:
|
||||||
|
- Kernel version:
|
||||||
|
- KESL package/version:
|
||||||
|
- SHA-256 DEB:
|
||||||
|
- Источник пакета:
|
||||||
|
- HAN release SHA:
|
||||||
|
- Container image digests зафиксированы: да / нет
|
||||||
|
|
||||||
|
Коммерческая KESL 12.4 не должна быть обозначена как сертифицированная ФСТЭК
|
||||||
|
сборка. Решение о допустимости коммерческой версии и ссылка на модель угроз:
|
||||||
|
|
||||||
|
- Решение:
|
||||||
|
- Документ/раздел:
|
||||||
|
- Утвердил:
|
||||||
|
|
||||||
|
## 2. Входной baseline
|
||||||
|
|
||||||
|
- Все steady-state контейнеры healthy/running:
|
||||||
|
- Restart count:
|
||||||
|
- Public smoke:
|
||||||
|
- Negative port probes:
|
||||||
|
- CPU:
|
||||||
|
- Available RAM:
|
||||||
|
- Swap activity:
|
||||||
|
- Disk free:
|
||||||
|
- IO wait:
|
||||||
|
- API p95:
|
||||||
|
- Redis latency / blocked clients:
|
||||||
|
- OTEL queue:
|
||||||
|
- Открытые до установки проблемы:
|
||||||
|
|
||||||
|
Stop conditions и численные пороги утверждены:
|
||||||
|
|
||||||
|
- p95/Redis:
|
||||||
|
- available RAM/swap:
|
||||||
|
- IO wait:
|
||||||
|
- disk:
|
||||||
|
- health/restarts:
|
||||||
|
|
||||||
|
## 3. АВЗ.1 — реализация антивирусной защиты
|
||||||
|
|
||||||
|
Нормативная опора:
|
||||||
|
|
||||||
|
- Приказ ФСТЭК России № 21, приложение, АВЗ.1 — «Реализация
|
||||||
|
антивирусной защиты»;
|
||||||
|
- пункт 8.6 — обнаружение вредоносных программ/информации и реагирование.
|
||||||
|
|
||||||
|
Необходимые доказательства:
|
||||||
|
|
||||||
|
- [ ] `kesl` active.
|
||||||
|
- [ ] Лицензия действительна.
|
||||||
|
- [ ] File Threat Protection (ID 1) имеет состояние `Started`.
|
||||||
|
- [ ] InterceptorProtectionMode = `Block`.
|
||||||
|
- [ ] ActionOnThreat = `DisinfectDeleteIfNotPossible` либо иное утверждённое
|
||||||
|
блокирующее/лечащее действие.
|
||||||
|
- [ ] ScanArchived = `No` для real-time защиты.
|
||||||
|
- [ ] Исключения ограничены тремя утверждёнными hot-data mountpoint.
|
||||||
|
- [ ] Контролируемый EICAR заблокирован/обезврежен/помещён в карантин.
|
||||||
|
- [ ] Событие EICAR зарегистрировано в журнале KESL.
|
||||||
|
- [ ] После теста EICAR отсутствует вне карантина и тестовый каталог удалён.
|
||||||
|
- [ ] Public smoke и health после включения Block успешны.
|
||||||
|
- [ ] UFW и `HAN-CHAT-DOCKER` не изменены.
|
||||||
|
- [ ] За 24 часа нет новых restart/OOM/5xx и неприемлемой деградации.
|
||||||
|
|
||||||
|
Артефакты без секретов:
|
||||||
|
|
||||||
|
- `systemctl is-active kesl`:
|
||||||
|
- `kesl-control --app-info`:
|
||||||
|
- `kesl-control --get-task-state 1`:
|
||||||
|
- reviewed excerpt `kesl-control --get-settings 1`:
|
||||||
|
- EICAR event ID/time/action:
|
||||||
|
- smoke result/time:
|
||||||
|
- firewall comparison:
|
||||||
|
- 24h resource comparison:
|
||||||
|
|
||||||
|
Вывод по АВЗ.1: реализована / не реализована.
|
||||||
|
|
||||||
|
## 4. АВЗ.2 — обновление баз признаков вредоносных программ
|
||||||
|
|
||||||
|
Нормативная опора:
|
||||||
|
|
||||||
|
- Приказ ФСТЭК России № 21, приложение, АВЗ.2 — «Обновление базы данных
|
||||||
|
признаков вредоносных компьютерных программ (вирусов)».
|
||||||
|
|
||||||
|
Необходимые доказательства:
|
||||||
|
|
||||||
|
- [ ] Update (ID 6) завершилась успешно.
|
||||||
|
- [ ] Базы загружены.
|
||||||
|
- [ ] Дата выпуска баз актуальна на момент проверки.
|
||||||
|
- [ ] Расписание Update = `Hourly`.
|
||||||
|
- [ ] Утверждён alert/регламент на ошибку и устаревание баз.
|
||||||
|
- [ ] Назначен ответственный за ежедневный контроль.
|
||||||
|
- [ ] Проверено успешное автоматическое обновление после ручного запуска.
|
||||||
|
|
||||||
|
Артефакты без секретов:
|
||||||
|
|
||||||
|
- `kesl-control --app-info`:
|
||||||
|
- `kesl-control --get-task-state 6`:
|
||||||
|
- `kesl-control --get-schedule 6`:
|
||||||
|
- время последнего успешного автоматического Update:
|
||||||
|
- ссылка на alert/регламент:
|
||||||
|
- ответственный:
|
||||||
|
|
||||||
|
Вывод по АВЗ.2: реализована / не реализована.
|
||||||
|
|
||||||
|
## 5. Связанные меры
|
||||||
|
|
||||||
|
### РСБ.1–3, РСБ.7
|
||||||
|
|
||||||
|
- [ ] Определены события: detection, remediation/quarantine, component stop,
|
||||||
|
update failure, stale bases, license failure.
|
||||||
|
- [ ] Определён состав полей: time, host, component/task, threat, object,
|
||||||
|
action, result, severity.
|
||||||
|
- [ ] Определены срок и место хранения.
|
||||||
|
- [ ] Доступ к журналу ограничен; изменение/удаление контролируется.
|
||||||
|
- [ ] Экспорт в syslog/SIEM включён либо документирован локальный контроль.
|
||||||
|
|
||||||
|
Ссылка на регламент и настройки:
|
||||||
|
|
||||||
|
### АНЗ.2
|
||||||
|
|
||||||
|
- [ ] Контролируется версия и жизненный цикл самого KESL, а не только баз.
|
||||||
|
- [ ] Upgrade KESL проходит совместимость, pilot, smoke и rollback review.
|
||||||
|
- [ ] Обновление kernel/Docker вызывает повторную проверку совместимости.
|
||||||
|
|
||||||
|
Ссылка на регламент:
|
||||||
|
|
||||||
|
## 6. Исключения и компенсирующие проверки
|
||||||
|
|
||||||
|
Для каждого исключения укажите точный фактический mountpoint, владельца,
|
||||||
|
причину, риск, компенсирующую проверку и дату пересмотра.
|
||||||
|
|
||||||
|
### Redis data
|
||||||
|
|
||||||
|
- Mountpoint:
|
||||||
|
- Причина: AOF/RDB, latency-sensitive write path.
|
||||||
|
- Компенсация:
|
||||||
|
- Владелец:
|
||||||
|
- Review date:
|
||||||
|
|
||||||
|
### OTEL queue
|
||||||
|
|
||||||
|
- Mountpoint:
|
||||||
|
- Причина: persistent high-churn telemetry queue.
|
||||||
|
- Компенсация:
|
||||||
|
- Владелец:
|
||||||
|
- Review date:
|
||||||
|
|
||||||
|
### nginx cache
|
||||||
|
|
||||||
|
- Mountpoint:
|
||||||
|
- Причина: regenerable high-churn cache.
|
||||||
|
- Компенсация:
|
||||||
|
- Владелец:
|
||||||
|
- Review date:
|
||||||
|
|
||||||
|
Иных исключений нет / перечислить отдельно с утверждением Security:
|
||||||
|
|
||||||
|
## 7. Проверка отката
|
||||||
|
|
||||||
|
- [ ] Команда переключения `Block` → `Notify` проверена документально.
|
||||||
|
- [ ] Процедура остановки KESL доступна break-glass admin.
|
||||||
|
- [ ] Процедура `apt-get purge kesl` проверена по документации текущей версии.
|
||||||
|
- [ ] Откат не использует `docker compose down -v` и не удаляет volumes.
|
||||||
|
- [ ] После отката предусмотрены smoke, health и firewall checks.
|
||||||
|
- [ ] Reboot выполняется только отдельным согласованным окном при необходимости.
|
||||||
|
|
||||||
|
Результат rehearsal/desk check:
|
||||||
|
|
||||||
|
## 8. Итоговая приёмка
|
||||||
|
|
||||||
|
- АВЗ.1: принято / не принято.
|
||||||
|
- АВЗ.2: принято / не принято.
|
||||||
|
- Ограничения/остаточные риски:
|
||||||
|
- Следующий review:
|
||||||
|
- Operations, ФИО/подпись/дата:
|
||||||
|
- Security, ФИО/подпись/дата:
|
||||||
|
- Service owner, ФИО/подпись/дата:
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
# KESL 12.4 standalone на production ВМ1
|
||||||
|
|
||||||
|
Это операторский runbook для поэтапного внедрения Kaspersky Endpoint Security
|
||||||
|
для Linux 12.4 на Ubuntu 24.04 ВМ1. Команды выполняются только персональной
|
||||||
|
ролью `admin` через `sudo`; repository automation этот runbook не запускает.
|
||||||
|
|
||||||
|
Цель: реализовать АВЗ.1 (обнаружение и реагирование) и АВЗ.2 (обновление баз)
|
||||||
|
без деградации Docker-стека HAN Chat.
|
||||||
|
|
||||||
|
Не выполняйте установку одновременно с deploy, миграциями, backup, ротацией
|
||||||
|
секретов, TLS renewal, перезапуском Docker или host reboot.
|
||||||
|
|
||||||
|
Официальная документация:
|
||||||
|
|
||||||
|
- [программные требования](https://support.kaspersky.ru/kes-for-linux/12.4.0/197645);
|
||||||
|
- [краткое руководство по установке](https://support.kaspersky.ru/kes-for-linux/12.4.0/install/16099);
|
||||||
|
- [автоматическая первоначальная настройка](https://support.kaspersky.ru/kes-for-linux/12.4.0/197909);
|
||||||
|
- [параметры autoinstall.ini](https://support.kaspersky.ru/kes-for-linux/12.4.0/197593);
|
||||||
|
- [ограничение CPU и памяти](https://support.kaspersky.ru/kes-for-linux/12.4.0/264979);
|
||||||
|
- [настройка File Threat Protection](https://support.kaspersky.ru/kes-for-linux/12.4.0/248490);
|
||||||
|
- [проверка контейнеров](https://support.kaspersky.ru/kes-for-linux/12.4.0/197612);
|
||||||
|
- [удаление DEB-пакета](https://support.kaspersky.ru/kes-for-linux/12.4.0/197596).
|
||||||
|
|
||||||
|
## 0. Участники, входные данные и stop conditions
|
||||||
|
|
||||||
|
До окна работ зафиксируйте:
|
||||||
|
|
||||||
|
- change ID, время окна, оператора, approver Security и on-call;
|
||||||
|
- hostname/IP ВМ1, фактическую версию Ubuntu и ядра;
|
||||||
|
- точное имя, версию и SHA-256 полученного от Kaspersky DEB-пакета;
|
||||||
|
- источник пакета и лицензию/код активации;
|
||||||
|
- текущий release SHA и image digests;
|
||||||
|
- место хранения evidence вне immutable release.
|
||||||
|
|
||||||
|
Значения лицензии, activation code, proxy credentials и секреты HAN нельзя
|
||||||
|
помещать в репозиторий, shell history, журналы или evidence.
|
||||||
|
|
||||||
|
Немедленно остановитесь, если:
|
||||||
|
|
||||||
|
- ОС, архитектура или ядро отсутствуют в матрице KESL 12.4;
|
||||||
|
- уже установлен другой антивирус или неизвестная версия KESL;
|
||||||
|
- свободно менее 4 ГБ или нет рабочего swap;
|
||||||
|
- до установки есть unhealthy/restarting контейнеры, 5xx или дефицит ресурсов;
|
||||||
|
- не совпал SHA-256 пакета;
|
||||||
|
- после этапа выросли restart count, 5xx, Redis latency/blocked clients,
|
||||||
|
OTEL queue или host IO wait сверх согласованного порога;
|
||||||
|
- File Threat Protection изменил UFW/iptables или доступность портов;
|
||||||
|
- базы не загрузились либо лицензия недействительна.
|
||||||
|
|
||||||
|
Рекомендуемые пороги отката для пилота (утвердить до установки):
|
||||||
|
|
||||||
|
- новый unhealthy/restart любого steady-state сервиса;
|
||||||
|
- публичный smoke не проходит два запуска подряд;
|
||||||
|
- host available memory менее 2 ГБ или начинается устойчивый swap-in/swap-out;
|
||||||
|
- IO wait более 10% в течение 5 минут;
|
||||||
|
- p95 API/Redis latency выросла более чем на 20% от baseline в течение 10 минут;
|
||||||
|
- свободное место уменьшилось ниже 10 ГБ или ниже 15%.
|
||||||
|
|
||||||
|
### 0.1. Исключение только для constrained test VM
|
||||||
|
|
||||||
|
На тестовой ВМ допускается пилот с 4 ГБ RAM без увеличения памяти только по
|
||||||
|
явному решению владельца среды. Это не отменяет production-gate и не является
|
||||||
|
обоснованием для переноса той же конфигурации в боевую среду.
|
||||||
|
|
||||||
|
Обязательные ограничения такого пилота:
|
||||||
|
|
||||||
|
- provider snapshot/console и оператор доступны до начала;
|
||||||
|
- `ScanMemoryLimit=512`, `MaxMemory=1024MB`;
|
||||||
|
- `UseOnDemandCPULimit=Yes`, `OnDemandCPULimit=15`;
|
||||||
|
- не запускать full filesystem scan и проверку архивов;
|
||||||
|
- ODS и ContainerScan выполнять по одному объекту, не одновременно;
|
||||||
|
- сначала Update и health, затем один stateless image, затем File Threat
|
||||||
|
Protection в `Notify`;
|
||||||
|
- остановить KESL при available memory менее 512 МБ, устойчивом swap IO,
|
||||||
|
появлении host/container OOM, restart или провале smoke;
|
||||||
|
- до режима `Block` требуется отдельное подтверждение стабильности.
|
||||||
|
|
||||||
|
Перед production-внедрением повторить sizing и baseline на боевых ресурсах;
|
||||||
|
test-профиль 512/1024 МБ автоматически не переносить.
|
||||||
|
|
||||||
|
## 1. Read-only preflight и baseline
|
||||||
|
|
||||||
|
Команды разделены на небольшие блоки: сохраните вывод каждого блока в
|
||||||
|
change record, предварительно проверив отсутствие секретов. Не публикуйте
|
||||||
|
полный `docker inspect`, Compose config или environment.
|
||||||
|
|
||||||
|
### 1.1. Host и совместимость
|
||||||
|
|
||||||
|
```sh
|
||||||
|
date -Is
|
||||||
|
hostnamectl
|
||||||
|
uname -a
|
||||||
|
dpkg --print-architecture
|
||||||
|
findmnt -no TARGET,SOURCE,FSTYPE,OPTIONS / /var/lib/docker /tmp 2>/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/<KESL_12_4_AMD64_DEB> /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=<Yes_AFTER_APPROVAL>
|
||||||
|
PRIVACY_POLICY_AGREED=<Yes_AFTER_APPROVAL>
|
||||||
|
USE_KSN=<Yes_OR_No_AFTER_APPROVAL>
|
||||||
|
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. Разрешены три области:
|
||||||
|
|
||||||
|
- `<REDIS_DATA_MOUNTPOINT>` — AOF/RDB;
|
||||||
|
- `<OTEL_QUEUE_MOUNTPOINT>` — persistent telemetry queue;
|
||||||
|
- `<NGINX_CACHE_MOUNTPOINT>` — 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 <REDIS_DATA_MOUNTPOINT>
|
||||||
|
kesl-control --set-settings 1 \
|
||||||
|
--add-exclusion <OTEL_QUEUE_MOUNTPOINT>
|
||||||
|
kesl-control --set-settings 1 \
|
||||||
|
--add-exclusion <NGINX_CACHE_MOUNTPOINT>
|
||||||
|
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 <STATELESS_CONTAINER_OR_IMAGE>
|
||||||
|
```
|
||||||
|
|
||||||
|
После успешного одиночного теста задачу `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.
|
||||||
@@ -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=<Yes_OR_No_AFTER_SECURITY_AND_LEGAL_APPROVAL>
|
||||||
|
|
||||||
|
[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=<APPROVED_THRESHOLD>
|
||||||
|
|
||||||
|
[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=<REDIS_DATA_MOUNTPOINT>
|
||||||
|
item_0001=<OTEL_QUEUE_MOUNTPOINT>
|
||||||
|
item_0002=<NGINX_CACHE_MOUNTPOINT>
|
||||||
|
|
||||||
|
[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=<APPROVED_WEEKLY_LOW_LOAD_WINDOW>
|
||||||
|
|
||||||
|
[container_scan_task_18]
|
||||||
|
initial_schedule=Manual
|
||||||
|
steady_schedule=<APPROVED_WEEKLY_LOW_LOAD_WINDOW>
|
||||||
|
post_deploy=after_smoke_only
|
||||||
|
# Review before enabling: the vendor default can stop a container when scan
|
||||||
|
# fails technically.
|
||||||
|
container_scan_action=<SECURITY_APPROVED_NON_DISRUPTIVE_VALUE>
|
||||||
|
|
||||||
|
[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
|
||||||
@@ -73,7 +73,9 @@ server {
|
|||||||
proxy_cache_methods GET HEAD;
|
proxy_cache_methods GET HEAD;
|
||||||
proxy_cache_bypass $http_authorization;
|
proxy_cache_bypass $http_authorization;
|
||||||
proxy_no_cache $http_authorization $upstream_http_set_cookie;
|
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;
|
proxy_pass http://api_backend;
|
||||||
}
|
}
|
||||||
location = /api/v1/public/content {
|
location = /api/v1/public/content {
|
||||||
|
|||||||
Binary file not shown.
@@ -10,9 +10,11 @@ import { AuthOtp } from './pages/AuthOtp';
|
|||||||
import { AuthLoading } from './pages/AuthLoading';
|
import { AuthLoading } from './pages/AuthLoading';
|
||||||
import { AuthConsent } from './pages/AuthConsent';
|
import { AuthConsent } from './pages/AuthConsent';
|
||||||
import { Root } from './pages/Root';
|
import { Root } from './pages/Root';
|
||||||
|
import { UpdateProvider } from './contexts/UpdateContext';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
<UpdateProvider>
|
||||||
<MemoryRouter initialEntries={['/']}>
|
<MemoryRouter initialEntries={['/']}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Root />}>
|
<Route path="/" element={<Root />}>
|
||||||
@@ -30,5 +32,6 @@ export default function App() {
|
|||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
|
</UpdateProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<DialogPrimitive.Root open={true} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
{/* Оверлей: в strict-режиме pointer-events-none отключаем клик по фону */}
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
className={[
|
||||||
|
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
|
||||||
|
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
|
isStrict ? "pointer-events-none" : "",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
// В strict-режиме блокируем закрытие по Escape и клику вне
|
||||||
|
onEscapeKeyDown={(e) => 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(" ")}
|
||||||
|
>
|
||||||
|
{/* Иконка и шапка */}
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"flex flex-col items-center gap-3 rounded-t-2xl px-6 pt-7 pb-5",
|
||||||
|
isStrict
|
||||||
|
? "bg-destructive/10"
|
||||||
|
: "bg-primary/5",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"flex items-center justify-center w-14 h-14 rounded-full",
|
||||||
|
isStrict
|
||||||
|
? "bg-destructive/15 text-destructive"
|
||||||
|
: "bg-primary/10 text-primary",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{isStrict ? (
|
||||||
|
<AlertTriangle className="w-7 h-7" strokeWidth={2} />
|
||||||
|
) : (
|
||||||
|
<ArrowUpCircle className="w-7 h-7" strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<DialogPrimitive.Title className="text-lg font-semibold text-foreground leading-tight">
|
||||||
|
{isStrict ? "Обновление обязательно" : "Доступно обновление"}
|
||||||
|
</DialogPrimitive.Title>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Версия{" "}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{config.newVersion}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Тело */}
|
||||||
|
<div className="px-6 py-5 space-y-4">
|
||||||
|
<DialogPrimitive.Description className="text-sm text-muted-foreground leading-relaxed text-center">
|
||||||
|
{isStrict ? (
|
||||||
|
<>
|
||||||
|
Версия <strong>{config.currentVersion}</strong> больше не
|
||||||
|
поддерживается. Для продолжения работы необходимо установить
|
||||||
|
обновление.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Вышла новая версия приложения. Вы можете обновить его сейчас
|
||||||
|
или сделать это позже.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Description>
|
||||||
|
|
||||||
|
{config.releaseNotes && (
|
||||||
|
<div className="rounded-xl bg-muted/60 px-4 py-3 text-xs text-muted-foreground leading-relaxed">
|
||||||
|
{config.releaseNotes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Плашка текущей / новой версии */}
|
||||||
|
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="rounded-md bg-muted px-2 py-1">
|
||||||
|
{config.currentVersion}
|
||||||
|
</span>
|
||||||
|
<span>→</span>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"rounded-md px-2 py-1 font-medium",
|
||||||
|
isStrict
|
||||||
|
? "bg-destructive/10 text-destructive"
|
||||||
|
: "bg-primary/10 text-primary",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{config.newVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопки */}
|
||||||
|
<div className="px-6 pb-6 flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleUpdate}
|
||||||
|
className={[
|
||||||
|
"w-full rounded-xl py-3 text-sm font-semibold transition-opacity active:opacity-80",
|
||||||
|
isStrict
|
||||||
|
? "bg-destructive text-destructive-foreground"
|
||||||
|
: "bg-primary text-primary-foreground",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
Обновить приложение
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isStrict && (
|
||||||
|
<button
|
||||||
|
onClick={dismissUpdate}
|
||||||
|
className="w-full rounded-xl py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
|
||||||
|
>
|
||||||
|
Позже
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Крестик только в soft-режиме */}
|
||||||
|
{!isStrict && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
onClick={dismissUpdate}
|
||||||
|
className="absolute right-4 top-4 rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
</DialogPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<UpdateContextValue | null>(null);
|
||||||
|
|
||||||
|
export function UpdateProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [config, setConfig] = useState<UpdateConfig | null>(null);
|
||||||
|
|
||||||
|
const showUpdate = useCallback((cfg: UpdateConfig) => {
|
||||||
|
setConfig(cfg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissUpdate = useCallback(() => {
|
||||||
|
setConfig(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UpdateContext.Provider value={{ config, showUpdate, dismissUpdate }}>
|
||||||
|
{children}
|
||||||
|
</UpdateContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdate() {
|
||||||
|
const ctx = useContext(UpdateContext);
|
||||||
|
if (!ctx) throw new Error("useUpdate must be used inside UpdateProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -3,13 +3,44 @@ import { Notifications } from '../components/Notifications';
|
|||||||
import { PopularQuestions } from '../components/PopularQuestions';
|
import { PopularQuestions } from '../components/PopularQuestions';
|
||||||
import { ChatInput } from '../components/ChatInput';
|
import { ChatInput } from '../components/ChatInput';
|
||||||
import { QuickActions } from '../components/QuickActions';
|
import { QuickActions } from '../components/QuickActions';
|
||||||
|
import { useUpdate } from '../contexts/UpdateContext';
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
|
const { showUpdate } = useUpdate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Компактный логотип HAN */}
|
{/* Компактный логотип HAN */}
|
||||||
<HanLogo />
|
<HanLogo />
|
||||||
|
|
||||||
|
{/* Demo: триггеры для тестирования диалогов обновления */}
|
||||||
|
<div className="px-4 py-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
showUpdate({
|
||||||
|
type: "soft",
|
||||||
|
currentVersion: "2.4.1",
|
||||||
|
newVersion: "2.5.0",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex-1 rounded-xl border border-border bg-muted/40 py-2 text-xs font-medium text-muted-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
Soft update
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
showUpdate({
|
||||||
|
type: "strict",
|
||||||
|
currentVersion: "1.8.0",
|
||||||
|
newVersion: "2.5.0",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex-1 rounded-xl border border-destructive/30 bg-destructive/5 py-2 text-xs font-medium text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
>
|
||||||
|
Strict update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Уведомления от компании */}
|
{/* Уведомления от компании */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<Notifications />
|
<Notifications />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Outlet, useLocation } from 'react-router';
|
import { Outlet, useLocation } from 'react-router';
|
||||||
import { Header } from '../components/Header';
|
import { Header } from '../components/Header';
|
||||||
|
import { UpdateModal } from '../components/UpdateModal';
|
||||||
|
|
||||||
export function Root() {
|
export function Root() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -14,6 +15,8 @@ export function Root() {
|
|||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UpdateModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ EXPO_PUBLIC_AUTH_BASE_URL=https://chat.example.ru/auth
|
|||||||
EXPO_PUBLIC_KEYCLOAK_REALM=han-chat
|
EXPO_PUBLIC_KEYCLOAK_REALM=han-chat
|
||||||
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend
|
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
|
||||||
|
|
||||||
# Только публичные значения. Service tokens, S3 credentials и OTP-код запрещены.
|
# Только публичные значения. Service tokens, S3 credentials и OTP-код запрещены.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# HAN Chat Mobile
|
# 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/`.
|
Запуск приложения выполняется отдельно командой `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)
|
||||||
|
|
||||||
Профили заданы в `eas.json`. Env для билда берётся из профиля (`eas.json` → `env`), а не из локального `.env`.
|
Профили заданы в `eas.json`. Env для билда берётся из профиля (`eas.json` → `env`), а не из локального `.env`.
|
||||||
|
|
||||||
| Профиль | Артефакт | API | `EXPO_PUBLIC_APP_ENV` |
|
| Профиль | Артефакт | API | `EXPO_PUBLIC_APP_ENV` |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `preview` | APK (внутренняя установка) | `dev-chat.han0107.ru` | `production-like` (есть «Диагностика») |
|
| `preview` | APK (внутренняя установка) | `dev-chat.han0107.ru` | update policy отключена |
|
||||||
| `production` | AAB (Google Play) | `chat.han0107.ru` | `production` (без диагностики) |
|
| `preview-rustore` | APK (тест RuStore policy) | `dev-chat.han0107.ru` | `rustore` |
|
||||||
| `development` | Dev Client | из окружения/локально | для разработки с `expo-dev-client` |
|
| `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
|
### Один раз: вход в Expo
|
||||||
|
|
||||||
@@ -58,19 +93,52 @@ npx eas-cli build --profile preview --platform android
|
|||||||
|
|
||||||
После успеха откройте ссылку из вывода CLI (или `https://expo.dev/accounts/anzh/projects/han-chat/builds`) и скачайте APK.
|
После успеха откройте ссылку из вывода CLI (или `https://expo.dev/accounts/anzh/projects/han-chat/builds`) и скачайте APK.
|
||||||
|
|
||||||
### Production — AAB для Play Store
|
### Preview RuStore — проверка soft/force update
|
||||||
|
|
||||||
```powershell
|
```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
|
```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 <BUILD_ID>
|
npx eas-cli build:view <BUILD_ID>
|
||||||
@@ -78,6 +146,11 @@ npx eas-cli build:view <BUILD_ID>
|
|||||||
# список последних сборок
|
# список последних сборок
|
||||||
npx eas-cli build:list --platform android --limit 5
|
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 <BUILD_ID>
|
npx eas-cli build:download --id <BUILD_ID>
|
||||||
|
|
||||||
@@ -85,6 +158,9 @@ npx eas-cli build:download --id <BUILD_ID>
|
|||||||
npx eas-cli build:version:set
|
npx eas-cli build:version:set
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Полная процедура rollout, включения soft/force policy и отката:
|
||||||
|
`../VM1_app/codebase/backend/deployment/RUNBOOK.mobile-updates.ru.md`.
|
||||||
|
|
||||||
### Keycloak redirect URI
|
### Keycloak redirect URI
|
||||||
|
|
||||||
Для боевого хоста в клиенте `han-chat-frontend` должны быть exact URI:
|
Для боевого хоста в клиенте `han-chat-frontend` должны быть exact URI:
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ const config: ExpoConfig = {
|
|||||||
name: "HAN Chat",
|
name: "HAN Chat",
|
||||||
slug: "han-chat",
|
slug: "han-chat",
|
||||||
owner: "anzh",
|
owner: "anzh",
|
||||||
version: "1.0.0",
|
version: "1.0.1",
|
||||||
scheme: "han-chat",
|
scheme: "han-chat",
|
||||||
icon: "./.assets/icons/icon.png",
|
icon: "./.assets/icons/icon.png",
|
||||||
orientation: "portrait",
|
orientation: "portrait",
|
||||||
userInterfaceStyle: "light",
|
userInterfaceStyle: "light",
|
||||||
android: {
|
android: {
|
||||||
package: "ru.han.chat",
|
package: "ru.han.chat",
|
||||||
|
versionCode: 2,
|
||||||
softwareKeyboardLayoutMode: "resize",
|
softwareKeyboardLayoutMode: "resize",
|
||||||
adaptiveIcon: {
|
adaptiveIcon: {
|
||||||
foregroundImage: "./.assets/icons/adaptive-foreground.png",
|
foregroundImage: "./.assets/icons/adaptive-foreground.png",
|
||||||
@@ -32,6 +33,10 @@ const config: ExpoConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
ios: {
|
||||||
|
bundleIdentifier: "ru.han.chat",
|
||||||
|
buildNumber: "2",
|
||||||
|
},
|
||||||
experiments: {
|
experiments: {
|
||||||
typedRoutes: true,
|
typedRoutes: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ import { StatusBar } from "expo-status-bar";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { AppProvider } from "../src/app-context";
|
import { AppProvider } from "../src/app-context";
|
||||||
|
import { AppUpdateProvider } from "../src/app-update-context";
|
||||||
import { colors } from "../src/theme";
|
import { colors } from "../src/theme";
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return <SafeAreaProvider>
|
return <SafeAreaProvider>
|
||||||
<AppProvider>
|
<AppUpdateProvider>
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
<AppProvider>
|
||||||
<StatusBar style="dark" />
|
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||||
<Stack screenOptions={{ headerShown: false }} />
|
<StatusBar style="dark" />
|
||||||
</SafeAreaView>
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
</AppProvider>
|
</SafeAreaView>
|
||||||
|
</AppProvider>
|
||||||
|
</AppUpdateProvider>
|
||||||
</SafeAreaProvider>;
|
</SafeAreaProvider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
"developmentClient": true,
|
"developmentClient": true,
|
||||||
"distribution": "internal"
|
"distribution": "internal",
|
||||||
|
"env": {
|
||||||
|
"EXPO_PUBLIC_DISTRIBUTION_STORE": "disabled"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
@@ -19,18 +22,66 @@
|
|||||||
"EXPO_PUBLIC_AUTH_BASE_URL": "https://dev-chat.han0107.ru/auth",
|
"EXPO_PUBLIC_AUTH_BASE_URL": "https://dev-chat.han0107.ru/auth",
|
||||||
"EXPO_PUBLIC_KEYCLOAK_REALM": "han-chat",
|
"EXPO_PUBLIC_KEYCLOAK_REALM": "han-chat",
|
||||||
"EXPO_PUBLIC_KEYCLOAK_CLIENT_ID": "han-chat-frontend",
|
"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",
|
"distribution": "store",
|
||||||
"autoIncrement": true,
|
"android": {
|
||||||
|
"autoIncrement": true
|
||||||
|
},
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_API_BASE_URL": "https://chat.han0107.ru",
|
"EXPO_PUBLIC_API_BASE_URL": "https://chat.han0107.ru",
|
||||||
"EXPO_PUBLIC_AUTH_BASE_URL": "https://chat.han0107.ru/auth",
|
"EXPO_PUBLIC_AUTH_BASE_URL": "https://chat.han0107.ru/auth",
|
||||||
"EXPO_PUBLIC_KEYCLOAK_REALM": "han-chat",
|
"EXPO_PUBLIC_KEYCLOAK_REALM": "han-chat",
|
||||||
"EXPO_PUBLIC_KEYCLOAK_CLIENT_ID": "han-chat-frontend",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3
-2
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "han-chat",
|
"name": "han-chat",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "han-chat",
|
"name": "han-chat",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "15.0.3",
|
"@expo/vector-icons": "15.0.3",
|
||||||
"@tanstack/react-query": "5.101.2",
|
"@tanstack/react-query": "5.101.2",
|
||||||
"expo": "~57.0.15",
|
"expo": "~57.0.15",
|
||||||
|
"expo-application": "~57.0.2",
|
||||||
"expo-auth-session": "~57.0.8",
|
"expo-auth-session": "~57.0.8",
|
||||||
"expo-constants": "~57.0.13",
|
"expo-constants": "~57.0.13",
|
||||||
"expo-crypto": "~57.0.1",
|
"expo-crypto": "~57.0.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "han-chat",
|
"name": "han-chat",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"@expo/vector-icons": "15.0.3",
|
"@expo/vector-icons": "15.0.3",
|
||||||
"@tanstack/react-query": "5.101.2",
|
"@tanstack/react-query": "5.101.2",
|
||||||
"expo": "~57.0.15",
|
"expo": "~57.0.15",
|
||||||
|
"expo-application": "~57.0.2",
|
||||||
"expo-auth-session": "~57.0.8",
|
"expo-auth-session": "~57.0.8",
|
||||||
"expo-constants": "~57.0.13",
|
"expo-constants": "~57.0.13",
|
||||||
"expo-crypto": "~57.0.1",
|
"expo-crypto": "~57.0.1",
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
dismissSoftUpdate: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Context = createContext<AppUpdateContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AppUpdateProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [update, setUpdate] = useState<AppUpdate | null>(null);
|
||||||
|
const checkPromise = useRef<Promise<void> | null>(null);
|
||||||
|
const appState = useRef<AppStateStatus>(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<AppUpdateContextValue>(() => ({
|
||||||
|
update,
|
||||||
|
checkForUpdate,
|
||||||
|
dismissSoftUpdate,
|
||||||
|
}), [update, checkForUpdate, dismissSoftUpdate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Context.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<UpdateModal update={update} onDismiss={dismissSoftUpdate} />
|
||||||
|
</Context.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppUpdate() {
|
||||||
|
const value = useContext(Context);
|
||||||
|
if (!value) throw new Error("AppUpdateProvider is missing");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -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<string | null>;
|
||||||
|
setItem: (key: string, value: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppUpdate = {
|
||||||
|
kind: Exclude<UpdateKind, "none">;
|
||||||
|
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<Record<DistributionStore, MobileUpdatePolicy>> | 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<UpdateDismissStore, "getItem">,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return Boolean(await store.getItem(softDismissKey(update.channel, update.latestBuild)));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistSoftUpdateDismiss(
|
||||||
|
update: AppUpdate,
|
||||||
|
store: Pick<UpdateDismissStore, "setItem">,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await store.setItem(softDismissKey(update.channel, update.latestBuild), new Date().toISOString());
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UpdateModal({ update, onDismiss }: Props) {
|
||||||
|
const [opening, setOpening] = useState(false);
|
||||||
|
const [urlError, setUrlError] = useState<string | null>(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 (
|
||||||
|
<Modal
|
||||||
|
visible
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
statusBarTranslucent
|
||||||
|
onRequestClose={dismiss}
|
||||||
|
accessibilityViewIsModal
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<View
|
||||||
|
style={styles.card}
|
||||||
|
accessibilityRole="alert"
|
||||||
|
accessibilityLabel={forced ? "Обновление приложения обязательно" : "Доступно обновление приложения"}
|
||||||
|
>
|
||||||
|
<View style={[styles.header, forced ? styles.forceHeader : styles.softHeader]}>
|
||||||
|
{!forced && (
|
||||||
|
<Pressable
|
||||||
|
onPress={dismiss}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Закрыть предложение обновления"
|
||||||
|
hitSlop={12}
|
||||||
|
style={({ pressed }) => [styles.close, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={20} color={colors.mutedForeground} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
<View style={[styles.iconCircle, forced ? styles.forceIcon : styles.softIcon]}>
|
||||||
|
<Ionicons
|
||||||
|
name={forced ? "warning-outline" : "arrow-up-circle-outline"}
|
||||||
|
size={30}
|
||||||
|
color={forced ? colors.destructive : colors.primary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.title}>
|
||||||
|
{forced ? "Обновление обязательно" : "Доступно обновление"}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Версия <Text style={styles.versionStrong}>{update.latestVersion}</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.body}>
|
||||||
|
<Text style={styles.description}>
|
||||||
|
{forced
|
||||||
|
? `Версия ${update.currentVersion} больше не поддерживается. Для продолжения работы необходимо установить обновление.`
|
||||||
|
: "Вышла новая версия приложения. Вы можете обновить его сейчас или сделать это позже."}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{update.releaseNotes ? (
|
||||||
|
<View style={styles.notes}>
|
||||||
|
<Text style={styles.notesText}>{update.releaseNotes}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.versionRow} accessibilityLabel={`Текущая версия ${update.currentVersion}, новая версия ${update.latestVersion}`}>
|
||||||
|
<Text style={styles.versionBadge}>{update.currentVersion}</Text>
|
||||||
|
<Ionicons name="arrow-forward" size={14} color={colors.mutedForeground} />
|
||||||
|
<Text style={[
|
||||||
|
styles.versionBadge,
|
||||||
|
styles.latestBadge,
|
||||||
|
forced ? styles.forceLatest : styles.softLatest,
|
||||||
|
]}>
|
||||||
|
{update.latestVersion}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{urlError ? (
|
||||||
|
<Text style={styles.error} accessibilityRole="alert" accessibilityLiveRegion="polite">
|
||||||
|
{urlError}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => 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
|
||||||
|
? <ActivityIndicator color={colors.primaryForeground} />
|
||||||
|
: <Text style={styles.primaryButtonText}>Обновить приложение</Text>}
|
||||||
|
</Pressable>
|
||||||
|
{!forced && (
|
||||||
|
<Pressable
|
||||||
|
onPress={dismiss}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Напомнить об обновлении позже"
|
||||||
|
style={({ pressed }) => [styles.laterButton, pressed && styles.pressed]}
|
||||||
|
>
|
||||||
|
<Text style={styles.laterText}>Позже</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
const required = (value: string | undefined, fallback: string) =>
|
const required = (value: string | undefined, fallback: string) =>
|
||||||
(value ?? fallback).replace(/\/$/, "");
|
(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({
|
export const env = Object.freeze({
|
||||||
apiBaseUrl: required(process.env.EXPO_PUBLIC_API_BASE_URL, "http://localhost:8000"),
|
apiBaseUrl: required(process.env.EXPO_PUBLIC_API_BASE_URL, "http://localhost:8000"),
|
||||||
authBaseUrl: required(
|
authBaseUrl: required(
|
||||||
@@ -10,6 +17,7 @@ export const env = Object.freeze({
|
|||||||
realm: process.env.EXPO_PUBLIC_KEYCLOAK_REALM ?? "han-chat",
|
realm: process.env.EXPO_PUBLIC_KEYCLOAK_REALM ?? "han-chat",
|
||||||
clientId: process.env.EXPO_PUBLIC_KEYCLOAK_CLIENT_ID ?? "han-chat-frontend",
|
clientId: process.env.EXPO_PUBLIC_KEYCLOAK_CLIENT_ID ?? "han-chat-frontend",
|
||||||
appEnv: process.env.EXPO_PUBLIC_APP_ENV ?? "development",
|
appEnv: process.env.EXPO_PUBLIC_APP_ENV ?? "development",
|
||||||
|
distributionStore: distributionStore(process.env.EXPO_PUBLIC_DISTRIBUTION_STORE),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mobileHttpsRedirectUri = `${env.apiBaseUrl}/mobile/oidc/callback`;
|
export const mobileHttpsRedirectUri = `${env.apiBaseUrl}/mobile/oidc/callback`;
|
||||||
|
|||||||
@@ -58,6 +58,21 @@ export type DocumentItem = {
|
|||||||
sent_at: string;
|
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 = {
|
export type PublicConfig = {
|
||||||
auth: { phone_enabled: boolean; password_enabled: boolean };
|
auth: { phone_enabled: boolean; password_enabled: boolean };
|
||||||
operator: { call_phone: string };
|
operator: { call_phone: string };
|
||||||
@@ -78,6 +93,7 @@ export type PublicConfig = {
|
|||||||
allowed_mime_types: string[];
|
allowed_mime_types: string[];
|
||||||
};
|
};
|
||||||
ux: { idle_timeout_minutes: number };
|
ux: { idle_timeout_minutes: number };
|
||||||
|
mobile_update: MobileUpdateConfig;
|
||||||
};
|
};
|
||||||
export type PublicContent = {
|
export type PublicContent = {
|
||||||
locale: string;
|
locale: string;
|
||||||
|
|||||||
@@ -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> = {}): 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<DistributionStore, string> = {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
const secureValues = vi.hoisted(() => new Map<string, string>());
|
const secureValues = vi.hoisted(() => new Map<string, string>());
|
||||||
|
|
||||||
vi.mock("expo-constants", () => ({
|
vi.mock("expo-constants", () => ({
|
||||||
default: { expoConfig: { version: "1.0.0" } },
|
default: { expoConfig: { version: "1.0.1" } },
|
||||||
}));
|
}));
|
||||||
vi.mock("expo-crypto", () => ({
|
vi.mock("expo-crypto", () => ({
|
||||||
CryptoDigestAlgorithm: { SHA256: "SHA-256" },
|
CryptoDigestAlgorithm: { SHA256: "SHA-256" },
|
||||||
@@ -217,7 +217,7 @@ describe("OIDC device metadata", () => {
|
|||||||
expect(first).toMatchObject({
|
expect(first).toMatchObject({
|
||||||
han_fingerprint: "stable-fingerprint",
|
han_fingerprint: "stable-fingerprint",
|
||||||
han_platform: "android",
|
han_platform: "android",
|
||||||
han_app_version: "1.0.0",
|
han_app_version: "1.0.1",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}}"
|
||||||
|
|
||||||
|
# Сборка тестовой версии приложения
|
||||||
Reference in New Issue
Block a user