chore(history): squash 67 commit(s) from 2026-09-25
- feat(accounts): persist device links with opaque hashed tokens, list and revoke endpoints - feat(frontend): app shell, routing and landing page with the chat-command hero - feat(frontend): Cyrillic-first fonts (Unbounded, Onest, JetBrains Mono); add i18next and motion - docs: free mod, bilingual site, one-click download, theme editor, public profiles, rich landing in plans - feat(accounts): internal gRPC AuthenticateDevice guarded by internal key - feat(frontend): ru/en i18n with typed per-feature dictionaries and language switch - feat(accounts): GET /me profile endpoint - feat(gateway): scaffold crate with config validation and health check - feat(gateway): reverse proxy to accounts and configs services - feat(gateway): resolve identity once from access JWT or device token via gRPC - feat(gateway): per-route and global rate limits with Retry-After - feat(gateway): CORS for the site origin; docs for gateway and internal contract - feat(configs): scaffold service with schema, config validation and health check - feat(configs): four config slots per account with list, get and save - feat(configs): permanent share codes with regenerate and public load-by-code - feat(accounts): GetPublicProfiles gRPC for showcase author info - style(accounts,common): apply rustfmt to existing sources - feat(configs): public showcase with publish, browse, detail and copy-to-slot - feat(backend): public profile endpoint and showcase author filter - fix(gateway): silence clippy collapsible-if and needless-ref warnings - docs(backend): configs-service implemented; Подсистема 1 backend complete - feat(mod): add Optimize module skeleton with OptimizeState holder - feat(mod): gate glass blur behind Optimize no_glass knob - feat(mod): cut MotionBlur and DoF sample counts behind lite_post knob - feat(mod): trim procedural sky noise behind lite_sky knob - feat(mod): drop fade gradients and digit rolls behind lean_hud knob - docs(todo): mark Optimize module phase 9.2 complete - refactor(mod): drop dead Renderer2D compatibility shims - refactor(mod): prune unreachable Renderer2D overload towers - refactor(mod): remove unused Renderer2D overloads and imports - docs(todo): mark Renderer2D giant-splitting done (2179 to 1597) - refactor(mod): extract shader id constants from LoVisualRenderPipelines - docs(todo): record registry wave 2026-09-25 (Renderer2D, pipelines) - refactor(mod): move Renderer2D instance state into base class - refactor(mod): extract Renderer2DRounded drawing family - refactor(mod): extract Renderer2DPath connector and chamfer family - refactor(mod): extract Renderer2DShapes circle line and texture primitives - refactor(mod): extract Renderer2DGlass and Renderer2DItem families - refactor(mod): prune Renderer2D imports after facade split - docs(todo): record Renderer2D facade inheritance split (1597 to 475) - docs: easter eggs — .env honeypot, konami troll mode, devtools banner, IDDQD config, breakable 404 block, 418 teapot - feat(mod): introduce surface style system core (SurfaceStyle, StyleSpec, StyleConfig, SurfaceRenderer) - refactor(mod): delegate HudRenderUtil liquid glass draws to SurfaceRenderer (dedupe glass constants) - refactor(mod): route bespoke glass call sites through SurfaceRenderer.plateSpec - feat(mod): add Auto option to HUD bg effects via shared HudBgStyles resolution - feat(mod): flat fallback for no-glass optimize mode and persist global HUD config - feat(mod): default HUD bg effects to Auto so the global surface style drives widgets - feat(mod): add global cycle-style hotkey with surface style notification - feat(mod): add surface style swatch strip under the global style picker - feat(gateway): reject ambiguous paths and answer .env probes with a honeypot - fix(gateway): charge failed credentials against the rate limit, allow stale ones on /auth - feat(frontend): ClickGui theme pipeline generated from the mod, live site theming - feat(frontend): landing v2 hero — voxel/particle backdrop, live ClickGui, theme strip - docs(todo): drop the FPS A/B measurement from phase 9.3, close phase 9 - feat(gateway): answer /coffee with a 418 teapot - feat(frontend): land the rest of landing v2 — HUD, module wall, showcase, FAQ, footer - feat(frontend): one-click download from GitHub releases, changelog page, release CI - feat(frontend): theme editor with live ClickGui preview, mod-compatible export and share links - fix(frontend): landing HUD playground now shows real mod widgets (fps, coordinates, module list, keybinds, ping) - style(frontend): apply ClickGui glass effect to landing HUD playground widgets - fix(frontend): prevent color field row overflow in theme editor grid - fix(frontend): never attach stale bearer token to /auth/* requests - fix(configs): unpublish/publish can no longer bypass moderation - refactor(accounts): shrink auth/handlers.rs under the 250-line cap - fix(accounts): tolerate concurrent refresh without killing every session - fix(gateway): minor hardening from the backend review - feat(configs): IDDQD easter egg config
This commit is contained in:
parent
72bc4c7148
commit
7f4b532f99
257 changed files with 13085 additions and 6582 deletions
89
backend/configs-service/src/slots/handlers.rs
Normal file
89
backend/configs-service/src/slots/handlers.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
use super::repo::{self, Slot, SlotSummary};
|
||||
use crate::error::{AppError, AppJson, AppPath};
|
||||
use axum::{Json, extract::State};
|
||||
use common::internal::GatewayIdentity;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub const MAX_DATA_BYTES: usize = 256 * 1024;
|
||||
const MAX_NAME_CHARS: usize = 32;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SlotsState {
|
||||
pub pool: sqlx::PgPool,
|
||||
}
|
||||
|
||||
pub fn validate_slot(slot: i16) -> Result<i16, AppError> {
|
||||
if (1..=4).contains(&slot) {
|
||||
Ok(slot)
|
||||
} else {
|
||||
Err(AppError::Validation("slot must be 1..4".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Rejects any ASCII/Unicode control character (including a bare CR/LF), which
|
||||
/// would otherwise corrupt log lines or list rendering downstream.
|
||||
pub fn has_control_chars(s: &str) -> bool {
|
||||
s.chars().any(|c| c.is_control())
|
||||
}
|
||||
|
||||
pub fn validate_name(name: &str) -> Result<String, AppError> {
|
||||
let name = name.trim();
|
||||
let len = name.chars().count();
|
||||
if len == 0 || len > MAX_NAME_CHARS {
|
||||
return Err(AppError::Validation(format!(
|
||||
"name must be 1..{MAX_NAME_CHARS} characters"
|
||||
)));
|
||||
}
|
||||
if has_control_chars(name) {
|
||||
return Err(AppError::Validation(
|
||||
"name must not contain control characters".into(),
|
||||
));
|
||||
}
|
||||
Ok(name.to_owned())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SaveRequest {
|
||||
pub name: String,
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<SlotsState>,
|
||||
id: GatewayIdentity,
|
||||
) -> Result<Json<Vec<SlotSummary>>, AppError> {
|
||||
Ok(Json(repo::list(&state.pool, id.account_id).await?))
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
State(state): State<SlotsState>,
|
||||
id: GatewayIdentity,
|
||||
AppPath(slot): AppPath<i16>,
|
||||
) -> Result<Json<Slot>, AppError> {
|
||||
let slot = validate_slot(slot)?;
|
||||
repo::get(&state.pool, id.account_id, slot)
|
||||
.await?
|
||||
.map(Json)
|
||||
.ok_or_else(|| AppError::NotFound("slot is empty".into()))
|
||||
}
|
||||
|
||||
pub async fn save(
|
||||
State(state): State<SlotsState>,
|
||||
id: GatewayIdentity,
|
||||
AppPath(slot): AppPath<i16>,
|
||||
AppJson(req): AppJson<SaveRequest>,
|
||||
) -> Result<Json<Slot>, AppError> {
|
||||
let slot = validate_slot(slot)?;
|
||||
let name = validate_name(&req.name)?;
|
||||
let size = serde_json::to_vec(&req.data)
|
||||
.map_err(|e| AppError::Internal(e.into()))?
|
||||
.len();
|
||||
if size > MAX_DATA_BYTES {
|
||||
return Err(AppError::Validation(format!(
|
||||
"config data must be at most {MAX_DATA_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
Ok(Json(
|
||||
repo::save(&state.pool, id.account_id, slot, &name, &req.data).await?,
|
||||
))
|
||||
}
|
||||
2
backend/configs-service/src/slots/mod.rs
Normal file
2
backend/configs-service/src/slots/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod handlers;
|
||||
pub mod repo;
|
||||
119
backend/configs-service/src/slots/repo.rs
Normal file
119
backend/configs-service/src/slots/repo.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
use crate::sharing::codes::new_code;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct SlotSummary {
|
||||
pub slot: i16,
|
||||
pub name: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub share_code: String,
|
||||
pub published: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct Slot {
|
||||
pub slot: i16,
|
||||
pub name: String,
|
||||
pub data: serde_json::Value,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub share_code: String,
|
||||
}
|
||||
|
||||
pub async fn list(pool: &PgPool, account_id: Uuid) -> Result<Vec<SlotSummary>, sqlx::Error> {
|
||||
sqlx::query_as(
|
||||
"SELECT s.slot_index AS slot, s.name, s.updated_at, s.share_code, (l.id IS NOT NULL) AS published
|
||||
FROM config_slots s
|
||||
LEFT JOIN showcase_listings l ON l.account_id = s.account_id AND l.slot_index = s.slot_index
|
||||
WHERE s.account_id = $1 ORDER BY s.slot_index",
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, account_id: Uuid, slot: i16) -> Result<Option<Slot>, sqlx::Error> {
|
||||
sqlx::query_as(
|
||||
"SELECT slot_index AS slot, name, data, updated_at, share_code
|
||||
FROM config_slots WHERE account_id = $1 AND slot_index = $2",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(slot)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn is_share_code_collision(err: &sqlx::Error) -> bool {
|
||||
matches!(err, sqlx::Error::Database(db) if db.constraint() == Some("config_slots_share_code_key"))
|
||||
}
|
||||
|
||||
pub async fn save(
|
||||
pool: &PgPool,
|
||||
account_id: Uuid,
|
||||
slot: i16,
|
||||
name: &str,
|
||||
data: &serde_json::Value,
|
||||
) -> Result<Slot, sqlx::Error> {
|
||||
// share_code is only used on INSERT; an update keeps the existing one.
|
||||
// 31^8 codes make collisions vanishingly rare, but never fatal.
|
||||
let mut attempts = 0;
|
||||
loop {
|
||||
let result = sqlx::query_as(
|
||||
"INSERT INTO config_slots (account_id, slot_index, name, data, share_code)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (account_id, slot_index)
|
||||
DO UPDATE SET name = EXCLUDED.name, data = EXCLUDED.data, updated_at = now()
|
||||
RETURNING slot_index AS slot, name, data, updated_at, share_code",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(slot)
|
||||
.bind(name)
|
||||
.bind(data)
|
||||
.bind(new_code())
|
||||
.fetch_one(pool)
|
||||
.await;
|
||||
match result {
|
||||
Err(err) if is_share_code_collision(&err) && attempts < 3 => attempts += 1,
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct SharedConfig {
|
||||
pub name: String,
|
||||
pub data: serde_json::Value,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn by_share_code(pool: &PgPool, code: &str) -> Result<Option<SharedConfig>, sqlx::Error> {
|
||||
sqlx::query_as("SELECT name, data, updated_at FROM config_slots WHERE share_code = $1")
|
||||
.bind(code)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn regenerate_code(
|
||||
pool: &PgPool,
|
||||
account_id: Uuid,
|
||||
slot: i16,
|
||||
) -> Result<Option<String>, sqlx::Error> {
|
||||
let mut attempts = 0;
|
||||
loop {
|
||||
let result = sqlx::query_scalar(
|
||||
"UPDATE config_slots SET share_code = $3
|
||||
WHERE account_id = $1 AND slot_index = $2 RETURNING share_code",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(slot)
|
||||
.bind(new_code())
|
||||
.fetch_optional(pool)
|
||||
.await;
|
||||
match result {
|
||||
Err(err) if is_share_code_collision(&err) && attempts < 3 => attempts += 1,
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue