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:
loki5512344 2026-09-25 20:22:13 +02:00
parent 72bc4c7148
commit 7f4b532f99
257 changed files with 13085 additions and 6582 deletions

View 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?,
))
}

View file

@ -0,0 +1,2 @@
pub mod handlers;
pub mod repo;

View 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,
}
}
}