Реализована проверка версионности и требование soft\force обновлений приложения

This commit is contained in:
mi
2026-09-03 19:12:38 +03:00
parent 465a70d488
commit 44db38f6fe
37 changed files with 3057 additions and 53 deletions
@@ -11,6 +11,12 @@ from sqlalchemy.dialects.postgresql import insert
from app.chat_settings import CHAT_MESSAGE_MAX_LENGTH_KEY, validate_chat_settings
from app.db import AppSetting, Database
from app.mobile_update_settings import (
MOBILE_UPDATE_BOOLEAN_KEYS,
MOBILE_UPDATE_INTEGER_KEYS,
MOBILE_UPDATE_SETTING_KEYS,
validate_mobile_update_settings,
)
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
from app.settings import get_settings
@@ -38,12 +44,25 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
raise ValueError(f"{key}: type must be integer")
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and value_type != "integer":
raise ValueError(f"{key}: type must be integer")
if key in MOBILE_UPDATE_BOOLEAN_KEYS and value_type != "boolean":
raise ValueError(f"{key}: type must be boolean")
if key in MOBILE_UPDATE_INTEGER_KEYS and value_type != "integer":
raise ValueError(f"{key}: type must be integer")
if (
key in MOBILE_UPDATE_SETTING_KEYS
- MOBILE_UPDATE_BOOLEAN_KEYS
- MOBILE_UPDATE_INTEGER_KEYS
and value_type != "string"
):
raise ValueError(f"{key}: type must be string")
if not isinstance(raw.get("public"), bool):
raise ValueError(f"{key}: public must be a boolean")
if key in OTP_SETTING_KEYS and raw["public"]:
raise ValueError(f"{key}: OTP setting must not be public")
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and not raw["public"]:
raise ValueError(f"{key}: setting must be public")
if key in MOBILE_UPDATE_SETTING_KEYS and not raw["public"]:
raise ValueError(f"{key}: mobile update setting must be public")
description = raw.get("description")
if description is not None and not isinstance(description, str):
raise ValueError(f"{key}: description must be a string")
@@ -59,6 +78,9 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
)
validate_otp_settings({row["setting_key"]: row["setting_value"] for row in rows})
validate_chat_settings({row["setting_key"]: row["setting_value"] for row in rows})
validate_mobile_update_settings(
{row["setting_key"]: row["setting_value"] for row in rows}
)
return rows
@@ -68,6 +90,8 @@ def serialize_value(key: str, value_type: str, value: Any) -> str:
raise ValueError(f"{key}: boolean value expected")
return str(value).lower()
if value_type == "integer":
if key in MOBILE_UPDATE_INTEGER_KEYS and value is None:
return ""
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(f"{key}: integer value expected")
return str(value)
@@ -63,6 +63,7 @@ from app.schemas import (
MessageRequest,
OpenLinesInbox,
OtpSettingsResponse,
PublicAppConfigResponse,
SessionStartRequest,
decode_cursor,
encode_cursor,
@@ -192,6 +193,15 @@ app.include_router(notification_router)
log = structlog.get_logger()
def etag_matches(if_none_match: str | None, etag: str) -> bool:
if not if_none_match:
return False
return any(
candidate.strip().removeprefix("W/") in {"*", etag}
for candidate in if_none_match.split(",")
)
def client_ip(request: Request) -> str | None:
"""Trust forwarded client addresses only from configured reverse proxies."""
peer = request.client.host if request.client else ""
@@ -501,8 +511,17 @@ async def ready(request: Request, db: Session):
)
@app.get("/api/v1/public/app-config", tags=["public"])
async def app_config(request: Request, response: Response, settings: SnapshotDep):
@app.get(
"/api/v1/public/app-config",
tags=["public"],
response_model=PublicAppConfigResponse,
responses={304: {"description": "Cached configuration is still current"}},
)
async def app_config(
request: Request,
settings: SnapshotDep,
if_none_match: Annotated[str | None, Header()] = None,
):
await enforce_limit(
request,
"ip",
@@ -511,12 +530,15 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep
settings.limit("rate_limit.public_endpoints.per_ip"),
fail_closed=False,
)
response.headers["Cache-Control"] = (
headers = {"Cache-Control": (
f"public, max-age={settings.integer('security.public_cache.max_age_seconds')}"
)
response.headers["ETag"] = f'"{settings.version}"'
)}
etag = f'"{settings.version}"'
headers["ETag"] = etag
if etag_matches(if_none_match, etag):
return Response(status_code=304, headers=headers)
values = settings.values
return {
body = {
"auth": {
"phone_enabled": settings.boolean("auth.phone.enabled"),
"password_enabled": settings.boolean("auth.password.enabled"),
@@ -557,7 +579,28 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep
),
},
"ux": {"idle_timeout_minutes": settings.integer("ux.session.idle_timeout_minutes")},
"mobile_update": {
store: {
"enabled": settings.boolean(f"mobile_update.{store}.enabled"),
"latest_build": (
settings.integer(f"mobile_update.{store}.latest_build")
if values[f"mobile_update.{store}.latest_build"]
else None
),
"minimum_build": (
settings.integer(f"mobile_update.{store}.minimum_build")
if values[f"mobile_update.{store}.minimum_build"]
else None
),
"latest_version": values[f"mobile_update.{store}.latest_version"] or None,
"store_url": values[f"mobile_update.{store}.store_url"] or None,
"release_notes": values[f"mobile_update.{store}.release_notes"] or None,
}
for store in ("google_play", "rustore", "app_store")
},
}
validated = PublicAppConfigResponse.model_validate(body)
return JSONResponse(validated.model_dump(mode="json"), headers=headers)
@app.get("/api/v1/public/content", tags=["public"])
@@ -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)
class PublicAuthConfig(StrictModel):
phone_enabled: bool
password_enabled: bool
class PublicOperatorConfig(StrictModel):
call_phone: str = Field(min_length=1, max_length=32)
class PublicMessagesConfig(StrictModel):
max_text_length: int = Field(strict=True, ge=1, le=CHAT_MESSAGE_TRANSPORT_MAX_LENGTH)
class PublicConsentConfig(StrictModel):
required: bool
document_url: HttpUrl
version: str = Field(min_length=1, max_length=64)
class PublicPersonalDataConsentConfig(PublicConsentConfig):
privacy_policy_document_url: HttpUrl
class PublicConsentsConfig(StrictModel):
personal_data: PublicPersonalDataConsentConfig
user_agreement: PublicConsentConfig
marketing: PublicConsentConfig
class PublicAttachmentsConfig(StrictModel):
allowed_extensions: list[str]
allowed_mime_types: list[str]
max_size_mb: int = Field(strict=True, gt=0)
class PublicNotificationConfig(StrictModel):
carousel_autoplay_enabled: bool
carousel_autoplay_interval_ms: int = Field(strict=True, gt=0)
class PublicUxConfig(StrictModel):
idle_timeout_minutes: int = Field(strict=True, gt=0)
class MobileStoreUpdatePolicy(StrictModel):
enabled: bool
latest_build: int | None = Field(strict=True, ge=1)
minimum_build: int | None = Field(strict=True, ge=1)
latest_version: str | None = Field(min_length=1, max_length=64)
store_url: HttpUrl | None
release_notes: str | None = Field(max_length=4000)
@model_validator(mode="after")
def validate_policy(self) -> "MobileStoreUpdatePolicy":
release_fields = (
self.latest_build,
self.minimum_build,
self.latest_version,
self.store_url,
)
if self.enabled and any(value is None for value in release_fields):
raise ValueError("enabled update policy requires all release fields")
if not self.enabled and any(value is not None for value in release_fields):
raise ValueError("disabled update policy must not expose release fields")
if (
self.minimum_build is not None
and self.latest_build is not None
and self.minimum_build > self.latest_build
):
raise ValueError("minimum_build must not exceed latest_build")
return self
class MobileUpdatePolicy(StrictModel):
google_play: MobileStoreUpdatePolicy
rustore: MobileStoreUpdatePolicy
app_store: MobileStoreUpdatePolicy
class PublicAppConfigResponse(StrictModel):
auth: PublicAuthConfig
operator: PublicOperatorConfig
messages: PublicMessagesConfig
consents: PublicConsentsConfig
attachments: PublicAttachmentsConfig
notification: PublicNotificationConfig
ux: PublicUxConfig
mobile_update: MobileUpdatePolicy
class Device(StrictModel):
platform: Literal["ios", "android", "web"]
app_version: str = Field(min_length=1, max_length=64)
@@ -38,6 +38,12 @@ from app.integrations import (
SafetyClient,
fresh_openlines_payload,
)
from app.mobile_update_settings import (
MOBILE_UPDATE_BOOLEAN_KEYS,
MOBILE_UPDATE_INTEGER_KEYS,
MOBILE_UPDATE_SETTING_KEYS,
validate_mobile_update_settings,
)
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
from app.realtime import RealtimeFanout
from app.schemas import (
@@ -226,7 +232,7 @@ REQUIRED_SETTINGS = {
"rate_limit.notification_upload.per_user",
"rate_limit.notifications_public.per_ip",
CHAT_MESSAGE_MAX_LENGTH_KEY,
} | OTP_SETTING_KEYS
} | OTP_SETTING_KEYS | MOBILE_UPDATE_SETTING_KEYS
@dataclass(frozen=True, slots=True)
@@ -286,15 +292,33 @@ async def load_settings(session: AsyncSession) -> SettingsSnapshot:
invalid_metadata = sorted(
row.setting_key
for row in rows
if row.setting_key in OTP_SETTING_KEYS
and (row.value_type != "integer" or row.is_public)
if (
row.setting_key in OTP_SETTING_KEYS
and (row.value_type != "integer" or row.is_public)
)
or (
row.setting_key in MOBILE_UPDATE_BOOLEAN_KEYS
and (row.value_type != "boolean" or not row.is_public)
)
or (
row.setting_key in MOBILE_UPDATE_INTEGER_KEYS
and (row.value_type != "integer" or not row.is_public)
)
or (
row.setting_key
in MOBILE_UPDATE_SETTING_KEYS
- MOBILE_UPDATE_BOOLEAN_KEYS
- MOBILE_UPDATE_INTEGER_KEYS
and (row.value_type != "string" or not row.is_public)
)
)
if invalid_metadata:
raise ValueError(
f"OTP settings must have integer type and be private: {invalid_metadata}"
f"Settings metadata is invalid: {invalid_metadata}"
)
validate_otp_settings(values)
validate_chat_settings(values)
validate_mobile_update_settings(values)
except ValueError as error:
raise DomainError(
"dependency_unavailable",