api-proxy: configurable credential generation for new users (#942)

* api-proxy: configurable credential generation for new users
* fix: HLS test fail
This commit is contained in:
euzu
2026-10-27 21:55:55 +01:00
committed by GitHub
parent 63487beef9
commit 978b3bb4f0
21 changed files with 1497 additions and 126 deletions
+10
View File
@@ -192,6 +192,12 @@
## 🌟 New Features
- **Configurable credential generation for new users.** The username, password and token pre-filled when creating
an API user in the Web UI can now be tuned via `credential_generation` in `api-proxy.yml`: length (1-64), allowed
`chars` with ranges, `exclude`, or ordered `segments` (for example 3 letters followed by 3 digits). Defaults match the
previous behavior (6 characters). Only ASCII letters, digits and `! . _ ~ -` are accepted. Editable in the Web UI
under Config → Api.
- **`playback_provider` input option.** Binds a global `provider` profile to an input whose `url` and alias URLs are
plain `http(s)://` URLs. Mappings that rewrite channel URLs to `provider://<name>/...` now resolve on such inputs
instead of failing with `Provider config for '<name>' not found in input` (HTTP 503), so CDN URL failover works for
@@ -1780,6 +1786,10 @@
## ⚙️ New Settings
- **api-proxy.yml (`credential_generation`)**: `username`, `password` and `token` each take `length` (default `6`),
`chars` (default `A-Z0-9`, token `A-Za-z0-9`) and `exclude` (default empty), or an ordered `segments` list of the
same keys.
- **config.yml (`reverse_proxy.stream`)**:
- `hls_wrap_media_playlist` (default `true`): answers a proxied live HLS entry request whose provider returns a media
playlist with a single-variant master playlist, so playlist refreshes keep their provider account across client
+69 -34
View File
@@ -577,9 +577,18 @@ async fn save_config_sources(
response_with_revision_header(StatusCode::OK.into_response(), HEADER_CONFIG_SOURCES_REVISION, &updated_revision)
}
async fn get_config_api_proxy_config_public(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
) -> impl IntoResponse + Send {
/// Reads `api-proxy.yml` for a config response.
///
/// `permissions: None` means the caller is unauthenticated because the Web UI has no
/// auth configured; there is no identity to filter for, so the document is returned in
/// full. This matches `get_config_common` for `config.yml`, which already ships the very
/// same servers and users to the same caller. Filtering with an empty permission set
/// would strip every server and user and leave the Web UI with a config it cannot save
/// back.
async fn get_config_api_proxy_config_common(
app_state: &Arc<AppState>,
permissions: Option<PermissionSet>,
) -> axum::response::Response {
let paths = app_state.app_config.paths.load();
let api_proxy_file_path = paths.api_proxy_file_path.clone();
let revision = match read_file_revision(&api_proxy_file_path).await {
@@ -595,18 +604,27 @@ async fn get_config_api_proxy_config_public(
app_state.app_config.api_proxy.load().as_ref().and_then(|config| config.templates.as_deref()),
) {
Ok(Some(mut api_proxy_dto)) => {
filter_api_proxy_by_permissions(&mut api_proxy_dto, PermissionSet::new());
if let Some(permissions) = permissions {
filter_api_proxy_by_permissions(&mut api_proxy_dto, permissions);
}
let response = axum::response::Json(api_proxy_dto).into_response();
return response_with_revision_header(response, HEADER_CONFIG_API_PROXY_REVISION, &revision);
response_with_revision_header(response, HEADER_CONFIG_API_PROXY_REVISION, &revision)
}
Ok(None) => {
error!("Failed to read api proxy config");
internal_server_error!()
}
Err(err) => {
error!("Failed to read api proxy config: {err}");
internal_server_error!()
}
}
internal_server_error!()
}
async fn get_config_api_proxy_config_public(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
) -> impl IntoResponse + Send {
get_config_api_proxy_config_common(&app_state, None).await
}
async fn save_config_api_proxy_config(
@@ -648,6 +666,7 @@ async fn save_config_api_proxy_config(
use_user_db: req_api_proxy.use_user_db,
server: req_api_proxy.server.iter().map(Into::into).collect(),
auth_error_status: req_api_proxy.auth_error_status,
credential_generation: req_api_proxy.credential_generation.clone(),
..base
};
@@ -777,34 +796,7 @@ async fn get_config_api_proxy_config(
return axum::http::StatusCode::FORBIDDEN.into_response();
}
let paths = app_state.app_config.paths.load();
let api_proxy_file_path = paths.api_proxy_file_path.clone();
let revision = match read_file_revision(&api_proxy_file_path).await {
Ok(revision) => revision,
Err(err) => {
error!("Failed to read revision for api-proxy.yml '{api_proxy_file_path}': {err}");
return internal_server_error!();
}
};
match read_api_proxy_file_with_templates(
api_proxy_file_path.as_str(),
true,
app_state.app_config.api_proxy.load().as_ref().and_then(|config| config.templates.as_deref()),
) {
Ok(Some(mut api_proxy_dto)) => {
filter_api_proxy_by_permissions(&mut api_proxy_dto, permissions);
let response = axum::response::Json(api_proxy_dto).into_response();
response_with_revision_header(response, HEADER_CONFIG_API_PROXY_REVISION, &revision)
}
Ok(None) => {
error!("Failed to read api proxy config");
internal_server_error!()
}
Err(err) => {
error!("Failed to read api proxy config: {err}");
internal_server_error!()
}
}
get_config_api_proxy_config_common(&app_state, Some(permissions)).await
}
async fn config_batch_content(
@@ -1330,6 +1322,7 @@ mod tests {
user: vec![TargetUserDto { target: String::from("target-a"), credentials: vec![] }],
use_user_db: true,
auth_error_status: 401,
credential_generation: shared::model::CredentialGenerationDto::default(),
}
}
@@ -1666,4 +1659,46 @@ mod tests {
serde_saphyr::from_str(&tokio::fs::read_to_string(&sources_file).await.unwrap()).unwrap();
assert_eq!(saved.sources[0].targets[0].curation.as_ref(), Some(&curation));
}
#[tokio::test]
async fn public_api_proxy_config_keeps_servers_and_users_for_the_web_ui() {
use axum::{
body::{to_bytes, Body},
http::Request,
};
use tempfile::TempDir;
use tower::ServiceExt;
let temp_dir = TempDir::new().unwrap();
let api_proxy_file = temp_dir.path().join("api-proxy.yml");
let stored = ApiProxyConfigDto {
server: vec![ApiProxyServerInfoDto {
name: "default".to_string(),
protocol: "http".to_string(),
host: "localhost".to_string(),
..ApiProxyServerInfoDto::default()
}],
user: vec![TargetUserDto { target: "target-a".to_string(), credentials: vec![] }],
..ApiProxyConfigDto::default()
};
tokio::fs::write(&api_proxy_file, serde_saphyr::to_string(&stored).unwrap()).await.unwrap();
let app_state = crate::api::model::create_test_app_state(crate::model::Config::default());
let mut paths = app_state.app_config.paths.load_full().as_ref().clone();
paths.home_path = temp_dir.path().to_string_lossy().to_string();
paths.api_proxy_file_path = api_proxy_file.to_string_lossy().to_string();
app_state.app_config.paths.store(Arc::new(paths));
// Auth is disabled, so the Web UI router serves `/config/apiproxy` from the public handler.
let router = super::v1_api_config_register(axum::routing::Router::new()).with_state(Arc::clone(&app_state));
let response =
router.oneshot(Request::builder().uri("/config/apiproxy").body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let returned: ApiProxyConfigDto = serde_json::from_slice(&body).unwrap();
assert_eq!(returned.server.len(), 1);
assert_eq!(returned.server[0].name, "default");
assert_eq!(returned.user.len(), 1);
}
}
@@ -130,6 +130,7 @@ pub struct ApiProxyConfig {
pub use_user_db: bool,
/// HTTP status code for auth failures. 0 means default (403).
pub auth_error_status: u16,
pub credential_generation: shared::model::CredentialGenerationDto,
pub templates: Option<Arc<[shared::model::PatternTemplate]>>,
}
@@ -168,6 +169,7 @@ impl ApiProxyConfig {
user,
use_user_db: dto.use_user_db,
auth_error_status: dto.auth_error_status,
credential_generation: dto.credential_generation.clone(),
}
}
}
@@ -179,6 +181,7 @@ impl From<&ApiProxyConfig> for ApiProxyConfigDto {
user: instance.user.iter().map(TargetUserDto::from).collect(),
use_user_db: instance.use_user_db,
auth_error_status: instance.auth_error_status,
credential_generation: instance.credential_generation.clone(),
}
}
}
+38 -8
View File
@@ -6,7 +6,10 @@ use super::{
};
use log::{debug, warn};
use std::sync::Arc;
use tokio::{sync::OwnedSemaphorePermit, time::timeout};
use tokio::{
sync::{Notify, OwnedSemaphorePermit},
time::timeout,
};
use tuliprox_core::utils::current_time_millis;
impl HlsSegmentWorkerPool {
@@ -83,13 +86,7 @@ impl HlsSegmentWorkerPool {
};
self.wake_scheduler(context.clone(), now_ms).await;
let wait_timeout = self
.runtime
.load()
.policy
.demand_wait_timeout_for_session(workload, context.session.read().await.target_duration);
if timeout(wait_timeout, notifier.notified()).await.is_err() {
if !self.await_segment_fetch_wake(&context, segment_file, &notifier, workload).await {
return SegmentDemandFetchOutcome::TimedOut;
}
@@ -114,6 +111,39 @@ impl HlsSegmentWorkerPool {
}
}
/// Waits for the wake that announces progress on an in-flight segment fetch.
///
/// A completed fetch drops its notifier from the session and wakes only waiters that are
/// already registered, so the wake is registered before the entry is inspected again: a
/// completion that already happened is visible in the state, one that happens later still
/// completes the wait. Returns `false` when the fetch budget expired without progress.
async fn await_segment_fetch_wake(
&self,
context: &SegmentFetchContext,
segment_file: &HlsSegmentFile,
notifier: &Notify,
workload: HlsSegmentFetchWorkload,
) -> bool {
let wake = notifier.notified();
tokio::pin!(wake);
wake.as_mut().enable();
let (target_duration, already_ready) = {
let session = context.session.read().await;
(
session.target_duration,
matches!(
session.segments.get(&segment_file.proxy_seq).map(|entry| &entry.status),
Some(SegmentCacheStatus::Ready { .. })
),
)
};
if already_ready {
return true;
}
let wait_timeout = self.runtime.load().policy.demand_wait_timeout_for_session(workload, target_duration);
timeout(wait_timeout, wake).await.is_ok()
}
pub(super) async fn next_ready_segment_key_fetch_snapshot(
&self,
context: &SegmentFetchContext,
@@ -358,6 +358,45 @@ async fn invalidated_generation_discards_late_segment_completion_without_sleep()
assert!(context.segment_cache.metadata(&cache_key).await.expect("metadata reads").is_none());
}
#[tokio::test]
async fn demand_wait_observes_a_commit_that_lands_while_the_scheduler_wakes() {
let server = spawn_segment_server(0).await;
let temp_dir = tempfile::tempdir().expect("temp dir");
let policy = SegmentFetchPolicy { retry_delays_ms: [0; 5], retry_jitter_max_ms: 0, ..Default::default() };
let (worker, context, segment_file) = fetch_context(&server, &temp_dir, &policy).await;
clear_scheduled_prefetch(&context, &policy).await;
let barrier = worker.pause_next_origin_prepare_for_test();
let demand = {
let worker = Arc::clone(&worker);
let context = context.clone();
let segment_file = segment_file.clone();
tokio::spawn(async move { worker.demand_fetch_and_wait(context, &segment_file, 20).await })
};
tokio::time::timeout(Duration::from_secs(3), barrier.entered.notified())
.await
.expect("the demand wakes the scheduler before it waits");
// The segment is installed while this demand is still inside the wake: the completion drops the
// notifier from the session and wakes only waiters that are already registered.
{
let mut session = context.session.write().await;
let entry = session.segments.get_mut(&segment_file.proxy_seq).expect("queued segment");
entry.status = SegmentCacheStatus::Ready { content_length: 1, ready_at_ms: 20 };
if let Some(notifier) = session.segment_fetch_notifiers.remove(&segment_file.proxy_seq) {
notifier.notify_waiters();
}
}
barrier.prepare.notify_one();
tokio::time::timeout(Duration::from_secs(3), barrier.prepared.notified()).await.expect("the wake prepares");
barrier.proceed.notify_one();
let outcome = tokio::time::timeout(Duration::from_secs(5), demand)
.await
.expect("a committed segment must not park the demand until its fetch budget expires")
.expect("the demand task joins");
assert_eq!(outcome, super::super::SegmentDemandFetchOutcome::Ready);
}
#[tokio::test]
async fn same_origin_sequence_resource_conflict_preserves_in_flight_binding() {
let (server, request_seen, release_response) = spawn_controlled_segment_server().await;
+41 -7
View File
@@ -11,6 +11,7 @@ specific permissions, proxy modes, and priorities.
```yaml
auth_error_status: 403
use_user_db: false
credential_generation:
server:
plans:
user:
@@ -18,13 +19,14 @@ user:
### Global Parameters
| Parameter | Type | Required | Default | Technical Impact & Background |
|:--------------------|:-----|:--------:|:--------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `auth_error_status` | Int | No | `403` | The HTTP status code Tuliprox returns when a player sends invalid credentials or tokens. (Only applies to [Xtream/M3U API Endpoints](#api-endpoints-for-clients-players), stream paths, and resource paths, NOT the Web UI / REST API). |
| `use_user_db` | Bool | No | `false` | If set to `true`, Tuliprox migrates all users from this YAML file into a highly performant SQLite database (`api_user.db`). **From then on, Tuliprox ignores the users in the YAML file!** You **must** subsequently manage users entirely via the Web UI Dashboard. Switching the option to `false` or `true` automatically migrates users back to the corresponding file (`false` → `api-proxy.yml`, `true` → `api_user.db`). |
| `server` | List | Yes | `[]` | See [Server Definitions](#1-server-definitions-server) for how to define servers. |
| `plans` | List | No | `[]` | Reusable user capability tiers. See [User Plans](#3-user-plans-plans). |
| `user` | List | No | `[]` | See [User Definitions](#2-user-definitions-user) for how to define users & permissions. |
| Parameter | Type | Required | Default | Technical Impact & Background |
| :---------------------- | :----- | :------: | :-------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `auth_error_status` | Int | No | `403` | The HTTP status code Tuliprox returns when a player sends invalid credentials or tokens. (Only applies to [Xtream/M3U API Endpoints](#api-endpoints-for-clients-players), stream paths, and resource paths, NOT the Web UI / REST API). |
| `use_user_db` | Bool | No | `false` | If set to `true`, Tuliprox migrates all users from this YAML file into a highly performant SQLite database (`api_user.db`). **From then on, Tuliprox ignores the users in the YAML file!** You **must** subsequently manage users entirely via the Web UI Dashboard. Switching the option to `false` or `true` automatically migrates users back to the corresponding file (`false` → `api-proxy.yml`, `true` → `api_user.db`). |
| `credential_generation` | Object | No | see below | Rules for the username, password and token pre-filled when creating a user in the Web UI. See [Credential Generation](#credential-generation). |
| `server` | List | Yes | `[]` | See [Server Definitions](#1-server-definitions-server) for how to define servers. |
| `plans` | List | No | `[]` | Reusable user capability tiers. See [User Plans](#3-user-plans-plans). |
| `user` | List | No | `[]` | See [User Definitions](#2-user-definitions-user) for how to define users & permissions. |
### Subsections (Object Keys)
@@ -34,6 +36,38 @@ user:
| `user` | User credentials, proxy modes, and access management. | [See section](#2-user-definitions-user) |
| `plans` | Reusable capability tiers referenced by users. | [See section](#3-user-plans-plans) |
### Credential Generation
When you create a new user in the Web UI, Tuliprox pre-fills `username`, `password` and `token` with random values.
`credential_generation` controls how these values look. Every part is optional; the defaults match the built-in
behavior.
```yaml
credential_generation:
username:
segments: # ordered parts, e.g. CTR287
- { length: 3, chars: "A-Z" }
- { length: 3, chars: "0-9" }
password: { length: 8, exclude: "O0" }
token: { length: 12, chars: "A-Za-z0-9", exclude: "1lI0O" }
```
Each of `username`, `password` and `token` uses **either** the flat form (`length`, `chars`, `exclude`) **or** a
`segments` list of objects with the same keys. The generated value is the concatenation of all segments in order.
Giving both forms for one credential is an error.
| Key | Type | Default | Description |
| :--------- | :----- | :------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------- |
| `length` | Int | `6` | Number of generated characters (1-64). The total over all segments must not exceed 64. |
| `chars` | String | `A-Z0-9` (`username`, `password`), `A-Za-z0-9` (`token`) | Allowed characters: literals and ranges such as `a-f`. Escape a literal dash as `\-`. Missing or empty uses the default. |
| `exclude` | String | empty | Literal characters removed from `chars`, for example `1lI0O` to avoid look-alikes. |
| `segments` | List | empty | Ordered list of `{length, chars, exclude}` objects. Mutually exclusive with the flat keys; at least one entry when present. |
Only ASCII letters, digits and `! . _ ~ -` are accepted in `chars`. Whitespace, URL-reserved characters
(such as `? / # % & : @ + =`) and non-ASCII characters are rejected with an error naming the character, because
credentials are embedded in stream URLs. The effective charset of every segment (`chars` minus `exclude`) must not be
empty.
---
## 1. Server Definitions (`server`)
+12 -1
View File
@@ -262,7 +262,10 @@
"TABLE_COLUMNS": "استخدم المفاتيح لإظهار الأعمدة أو إخفائها. اسحب المقبض لتغيير الترتيب. يحفظ زر الحفظ التغييرات، وتعيد استعادة الإعدادات الافتراضية التخطيط الأصلي.\n\nاضغط المسافة أو Enter لالتقاط العمود أو إفلاته. استخدم السهمين لأعلى ولأسفل لتحريكه؛ Escape يلغي التحريك.",
"API_PROXY_CONFIG": {
"USE_USER_DB": "في حالة التفعيل، يتم تحميل مستخدمي وكيل API من قاعدة بيانات المستخدمين.",
"AUTH_ERROR_STATUS": "رمز حالة HTTP الذي يتم إرجاعه عند فشل المصادقة. الافتراضي هو 403 (ممنوع)."
"AUTH_ERROR_STATUS": "رمز حالة HTTP الذي يتم إرجاعه عند فشل المصادقة. الافتراضي هو 403 (ممنوع).",
"CREDENTIAL_LENGTH": "عدد الأحرف التي يتم إنشاؤها لهذا المقطع (بين 1 و 64). لا يمكن أن يتجاوز الطول الإجمالي لجميع مقاطع هذا الحقل 64.",
"CREDENTIAL_CHARS": "الأحرف المستخدمة لهذا الجزء. أحرف مفردة ونطاقات مثل A-Z أو 0-9؛ اكتب الشرطة الحرفية بالشكل \\-. يُسمح فقط بحروف ASCII والأرقام و ! . _ ~ -. اتركه فارغًا للقيمة الافتراضية (اسم المستخدم/كلمة المرور A-Z0-9، الرمز A-Za-z0-9). أضف عدة مقاطع للحصول على قيمة مرتبة، مثل 3 أحرف ثم 3 أرقام.",
"CREDENTIAL_EXCLUDE": "أحرف تُزال من المجموعة المسموحة، مثل 1lI0O لتجنب الأحرف المتشابهة. تُكتب كأحرف عادية بدون نطاقات."
},
"API_PROXY_SERVER_INFO": {
"HOST": "المضيف الأعلى الذي يشير إليه ملف تعريف خادم الوكيل هذا.",
@@ -1772,6 +1775,14 @@
"USE_OUTPUT": "استخدام المخرج",
"USE_USER_DB": "استخدام قاعدة بيانات المستخدم",
"AUTH_ERROR_STATUS": "حالة خطأ المصادقة",
"CREDENTIAL_GENERATION": "إنشاء بيانات الاعتماد",
"CREDENTIAL_LENGTH": "الطول",
"CREDENTIAL_CHARS": "الأحرف المسموحة",
"CREDENTIAL_EXCLUDE": "الأحرف المستبعدة",
"CREDENTIAL_ADD_SEGMENT": "إضافة مقطع",
"CREDENTIAL_REMOVE_SEGMENT": "إزالة المقطع",
"CREDENTIAL_SEGMENT": "مقطع",
"CREDENTIAL_DEFAULT": "افتراضي",
"VALIDATION": "تحقق",
"VERSION": "الإصدار",
"VIDEO": "فيديو",
+12 -1
View File
@@ -262,7 +262,10 @@
"TABLE_COLUMNS": "Use the switches to show or hide columns. Drag a handle to change the order. Save applies your changes; Restore defaults resets the layout.\n\nSpace or Enter picks up or drops a column. Use Up/Down to move; Escape cancels the move.",
"API_PROXY_CONFIG": {
"USE_USER_DB": "If enabled, API proxy users are loaded from the user database.",
"AUTH_ERROR_STATUS": "HTTP status code returned for authentication failures. Default is 403 (Forbidden)."
"AUTH_ERROR_STATUS": "HTTP status code returned for authentication failures. Default is 403 (Forbidden).",
"CREDENTIAL_LENGTH": "Number of generated characters for this segment (1 to 64). The total length across all segments for this field cannot exceed 64.",
"CREDENTIAL_CHARS": "Characters used for this part. Literal characters and ranges such as A-Z or 0-9; escape a literal dash as \\-. Only ASCII letters, digits and ! . _ ~ - are allowed. Leave empty for the default (username/password A-Z0-9, token A-Za-z0-9). Add several segments to build an ordered value, e.g. 3 letters then 3 digits.",
"CREDENTIAL_EXCLUDE": "Characters removed from the allowed set, e.g. 1lI0O to avoid look-alikes. Written as plain characters, no ranges."
},
"API_PROXY_SERVER_INFO": {
"HOST": "Upstream host that this proxy server profile points to.",
@@ -1840,6 +1843,14 @@
"USE_OUTPUT": "Use output",
"USE_USER_DB": "Use User DB",
"AUTH_ERROR_STATUS": "Auth Error Status",
"CREDENTIAL_GENERATION": "Credential generation",
"CREDENTIAL_LENGTH": "Length",
"CREDENTIAL_CHARS": "Allowed characters",
"CREDENTIAL_EXCLUDE": "Excluded characters",
"CREDENTIAL_ADD_SEGMENT": "Add segment",
"CREDENTIAL_REMOVE_SEGMENT": "Remove segment",
"CREDENTIAL_SEGMENT": "Segment",
"CREDENTIAL_DEFAULT": "Default",
"VALIDATION": "Validation",
"VERSION": "Version",
"VIDEO": "Video",
+12 -1
View File
@@ -262,7 +262,10 @@
"TABLE_COLUMNS": "Usa los interruptores para mostrar u ocultar columnas. Arrastra el tirador para cambiar el orden. Guardar aplica los cambios; Restaurar valores predeterminados restablece el diseño.\n\nEspacio o Intro toma o suelta una columna. Usa Arriba/Abajo para moverla; Escape cancela el movimiento.",
"API_PROXY_CONFIG": {
"USE_USER_DB": "Si está habilitado, los usuarios del proxy API se cargan desde la base de datos de usuarios.",
"AUTH_ERROR_STATUS": "Código de estado HTTP devuelto para errores de autenticación. El valor por defecto es 403 (Forbidden)."
"AUTH_ERROR_STATUS": "Código de estado HTTP devuelto para errores de autenticación. El valor por defecto es 403 (Forbidden).",
"CREDENTIAL_LENGTH": "Número de caracteres generados para este segmento (de 1 a 64). La longitud total de todos los segmentos para este campo no puede superar 64.",
"CREDENTIAL_CHARS": "Caracteres usados en esta parte. Caracteres literales y rangos como A-Z o 0-9; escriba un guion literal como \\-. Solo se permiten letras ASCII, dígitos y ! . _ ~ -. Déjelo vacío para usar el valor por defecto (usuario/contraseña A-Z0-9, token A-Za-z0-9). Añada varios segmentos para un valor ordenado, p. ej. 3 letras y luego 3 dígitos.",
"CREDENTIAL_EXCLUDE": "Caracteres eliminados del conjunto permitido, p. ej. 1lI0O para evitar caracteres parecidos. Se escriben como caracteres simples, sin rangos."
},
"API_PROXY_SERVER_INFO": {
"HOST": "Host de origen al que apunta este perfil de servidor proxy API.",
@@ -1840,6 +1843,14 @@
"USE_OUTPUT": "Usar salida",
"USE_USER_DB": "Usar BD de usuarios",
"AUTH_ERROR_STATUS": "Estado de error de autenticación",
"CREDENTIAL_GENERATION": "Generación de credenciales",
"CREDENTIAL_LENGTH": "Longitud",
"CREDENTIAL_CHARS": "Caracteres permitidos",
"CREDENTIAL_EXCLUDE": "Caracteres excluidos",
"CREDENTIAL_ADD_SEGMENT": "Añadir segmento",
"CREDENTIAL_REMOVE_SEGMENT": "Quitar segmento",
"CREDENTIAL_SEGMENT": "Segmento",
"CREDENTIAL_DEFAULT": "Predeterminado",
"VALIDATION": "Validación",
"VERSION": "Versión",
"VIDEO": "Vídeo",
+12 -1
View File
@@ -262,7 +262,10 @@
"TABLE_COLUMNS": "Используйте переключатели, чтобы показывать или скрывать столбцы. Перетаскивайте столбцы за ручку, чтобы изменить порядок. Сохранить применяет изменения; Восстановить значения по умолчанию сбрасывает расположение.\n\nПробел или Enter выбирает или отпускает столбец. Стрелки вверх/вниз перемещают его; Escape отменяет перемещение.",
"API_PROXY_CONFIG": {
"USE_USER_DB": "Если включено, пользователи API прокси загружаются из базы данных пользователей.",
"AUTH_ERROR_STATUS": "HTTP-код состояния, возвращаемый при сбоях аутентификации. По умолчанию 403 (Forbidden)."
"AUTH_ERROR_STATUS": "HTTP-код состояния, возвращаемый при сбоях аутентификации. По умолчанию 403 (Forbidden).",
"CREDENTIAL_LENGTH": "Количество генерируемых символов для этого сегмента (от 1 до 64). Общая длина всех сегментов этого поля не может превышать 64.",
"CREDENTIAL_CHARS": "Символы для этой части. Отдельные символы и диапазоны, например A-Z или 0-9; литеральный дефис записывается как \\-. Допускаются только ASCII-буквы, цифры и ! . _ ~ -. Пусто — значение по умолчанию (логин/пароль A-Z0-9, токен A-Za-z0-9). Несколько сегментов задают упорядоченное значение, например 3 буквы, затем 3 цифры.",
"CREDENTIAL_EXCLUDE": "Символы, удаляемые из допустимого набора, например 1lI0O для исключения похожих символов. Записываются обычными символами, без диапазонов."
},
"API_PROXY_SERVER_INFO": {
"HOST": "Upstream-хост, на который указывает этот профиль прокси-сервера.",
@@ -1758,6 +1761,14 @@
"USE_OUTPUT": "Использовать вывод",
"USE_USER_DB": "Использовать базу данных пользователей",
"AUTH_ERROR_STATUS": "Статус ошибки авторизации",
"CREDENTIAL_GENERATION": "Генерация учётных данных",
"CREDENTIAL_LENGTH": "Длина",
"CREDENTIAL_CHARS": "Допустимые символы",
"CREDENTIAL_EXCLUDE": "Исключённые символы",
"CREDENTIAL_ADD_SEGMENT": "Добавить сегмент",
"CREDENTIAL_REMOVE_SEGMENT": "Удалить сегмент",
"CREDENTIAL_SEGMENT": "Сегмент",
"CREDENTIAL_DEFAULT": "По умолчанию",
"VALIDATION": "Валидация",
"VERSION": "Версия",
"VIDEO": "Видео",
@@ -25,6 +25,12 @@
max-width: calc(100vw - 1rem);
}
// A select's list is right-aligned with its input and sits close to it, so the corner
// facing away from the field reads as attached rather than rounded.
&--select {
border-top-left-radius: 0;
}
ul {
margin: 0;
padding: 0;
@@ -22,6 +22,107 @@
}
}
&__credential-gen-list {
display: flex;
flex-flow: row wrap;
gap: var(--gap-large);
width: 100%;
box-sizing: border-box;
}
&__credential-gen {
display: flex;
flex-flow: column;
flex: 1 1 22rem;
min-width: min(20rem, 100%);
max-width: min(36rem, 100%);
gap: var(--gap-default);
padding: var(--padding-default);
background-color: var(--sub-card-background-color);
border: 1px solid var(--card-border-color);
border-radius: var(--border-radius);
box-sizing: border-box;
&__header {
display: flex;
flex-flow: row;
justify-content: space-between;
align-items: center;
gap: var(--gap-default);
.tp__api-config-view__section-title {
font-size: var(--font-size-lg);
font-weight: 600;
}
}
&__badge {
font-size: var(--font-size-sm);
color: var(--modest-text-color);
padding: var(--gap-mini) var(--gap-default);
background-color: var(--card-background-color);
border: 1px solid var(--card-border-color);
border-radius: var(--border-radius-small);
}
&__segments {
display: flex;
flex-flow: column;
gap: var(--gap-default);
}
&__segment {
display: flex;
flex-flow: column;
gap: var(--gap-small);
padding: var(--padding-small) var(--padding-default);
background-color: var(--card-background-color);
border: 1px solid var(--card-border-color);
border-radius: var(--border-radius-small);
&-header {
display: flex;
flex-flow: row;
justify-content: space-between;
align-items: center;
min-height: 2rem;
}
&-title {
font-size: var(--font-size-base);
font-weight: 600;
color: var(--text-color);
}
&-fields {
display: grid;
grid-template-columns: minmax(4.5rem, 5.5rem) 1fr 1fr;
gap: var(--gap-small);
align-items: start;
.tp__form-field {
min-width: 0;
}
@media (max-width: size.$mobile-breakpoint) {
grid-template-columns: 1fr;
}
}
}
&__actions {
display: flex;
flex-flow: row;
justify-content: flex-start;
margin-top: var(--gap-mini);
}
}
&__credential-gen-error {
color: var(--error-color);
font-size: var(--font-size-sm);
}
&__server {
display: flex;
flex-flow: column;
@@ -8,17 +8,22 @@ use crate::{
},
input::Input,
menu_item::MenuItem,
number_input::NumberInput,
popup_menu::PopupMenu,
AppIcon, Card, CustomDialog, NoContent, Table, TableDefinition, TextButton,
AppIcon, Card, CustomDialog, IconButton, NoContent, Table, TableDefinition, TextButton,
},
context::ConfigContext,
},
config_field, config_field_bool, config_field_empty, edit_field_bool, edit_field_number_u16, edit_field_text,
generate_form_reducer, html_if,
config_field, config_field_bool, config_field_custom, config_field_empty, edit_field_bool, edit_field_number_u16,
edit_field_text, generate_form_reducer, html_if,
i18n::use_translation,
use_default_form_reducer,
};
use shared::model::{ApiProxyConfigDto, ApiProxyServerInfoDto, ConfigApiDto, SortOrder};
use shared::model::{
ApiProxyConfigDto, ApiProxyServerInfoDto, ConfigApiDto, CredentialFieldGenDto, CredentialGenerationDto,
CredentialSegmentDto, SortOrder, CREDENTIAL_LENGTH_MAX, CREDENTIAL_LENGTH_MIN, CREDENTIAL_MIXED_CHARS,
CREDENTIAL_UPPER_CHARS,
};
use std::{rc::Rc, str::FromStr};
use yew::prelude::*;
@@ -47,9 +52,22 @@ const LABEL_TIMEZONE: &str = "LABEL.TIMEZONE";
const LABEL_MESSAGE: &str = "LABEL.MESSAGE";
const LABEL_PATH: &str = "LABEL.PATH";
const LABEL_WEB_ROOT: &str = "LABEL.WEB_ROOT";
const LABEL_API_CONFIG: &str = "LABEL.API_CONFIG";
const LABEL_API_PROXY_CONFIG: &str = "LABEL.API_PROXY_CONFIG";
const LABEL_USE_USER_DB: &str = "LABEL.USE_USER_DB";
const LABEL_AUTH_ERROR_STATUS: &str = "LABEL.AUTH_ERROR_STATUS";
const LABEL_CREDENTIAL_GENERATION: &str = "LABEL.CREDENTIAL_GENERATION";
const LABEL_CREDENTIAL_LENGTH: &str = "LABEL.CREDENTIAL_LENGTH";
const LABEL_CREDENTIAL_CHARS: &str = "LABEL.CREDENTIAL_CHARS";
const LABEL_CREDENTIAL_EXCLUDE: &str = "LABEL.CREDENTIAL_EXCLUDE";
const LABEL_CREDENTIAL_ADD_SEGMENT: &str = "LABEL.CREDENTIAL_ADD_SEGMENT";
const LABEL_CREDENTIAL_REMOVE_SEGMENT: &str = "LABEL.CREDENTIAL_REMOVE_SEGMENT";
const LABEL_CREDENTIAL_SEGMENT: &str = "LABEL.CREDENTIAL_SEGMENT";
const LABEL_CREDENTIAL_DEFAULT: &str = "LABEL.CREDENTIAL_DEFAULT";
const LABEL_TOTAL: &str = "LABEL.TOTAL";
const LABEL_USERNAME: &str = "LABEL.USERNAME";
const LABEL_PASSWORD: &str = "LABEL.PASSWORD";
const LABEL_TOKEN: &str = "LABEL.TOKEN";
const LABEL_SERVER: &str = "LABEL.SERVER";
const LABEL_ADD_SERVER: &str = "LABEL.ADD_SERVER";
const MSG_NON_UNIQUE_SERVER_NAME: &str = "MESSAGES.SAVE.API_PROXY_CONFIG.NON_UNIQUE_SERVER_NAME";
@@ -124,9 +142,181 @@ generate_form_reducer!(
Server => server: Vec<ApiProxyServerInfoDto>,
UseUserDb => use_user_db: bool,
AuthErrorStatus => auth_error_status: u16,
CredentialGeneration => credential_generation: CredentialGenerationDto,
}
);
type CredentialFieldSelector = fn(&mut CredentialGenerationDto) -> &mut CredentialFieldGenDto;
struct CredentialEditFieldItem {
name: &'static str,
label: &'static str,
default_chars: &'static str,
value: CredentialFieldGenDto,
error: Option<String>,
select: CredentialFieldSelector,
}
struct CredentialViewFieldItem<'a> {
label: &'static str,
default_chars: &'static str,
value: &'a CredentialFieldGenDto,
}
#[derive(Properties, Clone, PartialEq)]
struct CredentialGenEditProps {
title: String,
name: &'static str,
default_chars: &'static str,
value: CredentialFieldGenDto,
error: Option<String>,
on_change: Callback<CredentialFieldGenDto>,
}
#[component]
fn CredentialGenEdit(props: &CredentialGenEditProps) -> Html {
let translate = use_translation();
let segments = props.value.to_segments();
let count = segments.len();
let name = props.name;
let default_chars = props.default_chars;
let total_length: u32 = segments.iter().map(|s| u32::from(s.length)).sum();
let rows = segments.iter().enumerate().map(|(idx, segment)| {
let on_length = {
let (segments, on_change) = (segments.clone(), props.on_change.clone());
Callback::from(move |length: Option<i64>| {
let mut next = segments.clone();
if let Some(seg) = next.get_mut(idx) {
seg.length = length.and_then(|l| u16::try_from(l).ok()).unwrap_or(0);
on_change.emit(CredentialFieldGenDto::from_segments(next));
}
})
};
let on_chars = {
let (segments, on_change) = (segments.clone(), props.on_change.clone());
Callback::from(move |chars: String| {
let mut next = segments.clone();
if let Some(seg) = next.get_mut(idx) {
seg.chars = chars;
on_change.emit(CredentialFieldGenDto::from_segments(next));
}
})
};
let on_exclude = {
let (segments, on_change) = (segments.clone(), props.on_change.clone());
Callback::from(move |exclude: String| {
let mut next = segments.clone();
if let Some(seg) = next.get_mut(idx) {
seg.exclude = exclude;
on_change.emit(CredentialFieldGenDto::from_segments(next));
}
})
};
let on_remove = {
let (segments, on_change) = (segments.clone(), props.on_change.clone());
Callback::from(move |(_name, _event): (String, MouseEvent)| {
let mut next = segments.clone();
if next.len() > 1 && idx < next.len() {
next.remove(idx);
on_change.emit(CredentialFieldGenDto::from_segments(next));
}
})
};
let placeholder_chars = format!("{} ({})", default_chars, translate.t(LABEL_CREDENTIAL_DEFAULT));
html! {
<div class="tp__api-config-view__credential-gen__segment" key={idx}>
<div class="tp__api-config-view__credential-gen__segment-header">
<span class="tp__api-config-view__credential-gen__segment-title">
{format!("{} {}", translate.t(LABEL_CREDENTIAL_SEGMENT), idx + 1)}
</span>
{ html_if!(count > 1, {
<IconButton
icon="Delete"
name={format!("{name}_{idx}_remove")}
hint={translate.t(LABEL_CREDENTIAL_REMOVE_SEGMENT)}
onclick={on_remove}
/>
}) }
</div>
<div class="tp__api-config-view__credential-gen__segment-fields">
<div class="tp__form-field tp__form-field__number">
<NumberInput
label={translate.t(LABEL_CREDENTIAL_LENGTH)}
name={format!("{name}_{idx}_length")}
hint_key={Some("API_PROXY_CONFIG.CREDENTIAL_LENGTH".to_string())}
value={i64::from(segment.length)}
min_i64={i64::from(CREDENTIAL_LENGTH_MIN)}
max_i64={i64::from(CREDENTIAL_LENGTH_MAX)}
on_change={on_length}
/>
</div>
<div class="tp__form-field tp__form-field__text">
<Input
label={translate.t(LABEL_CREDENTIAL_CHARS)}
name={format!("{name}_{idx}_chars")}
placeholder={placeholder_chars}
autocomplete={false}
hint_key={Some("API_PROXY_CONFIG.CREDENTIAL_CHARS".to_string())}
value={segment.chars.clone()}
on_change={Some(on_chars)}
/>
</div>
<div class="tp__form-field tp__form-field__text">
<Input
label={translate.t(LABEL_CREDENTIAL_EXCLUDE)}
name={format!("{name}_{idx}_exclude")}
placeholder={"1lI0O".to_string()}
autocomplete={false}
hint_key={Some("API_PROXY_CONFIG.CREDENTIAL_EXCLUDE".to_string())}
value={segment.exclude.clone()}
on_change={Some(on_exclude)}
/>
</div>
</div>
</div>
}
});
let on_add = {
let (segments, on_change) = (segments.clone(), props.on_change.clone());
Callback::from(move |_: String| {
let mut next = segments.clone();
next.push(CredentialSegmentDto::default());
on_change.emit(CredentialFieldGenDto::from_segments(next));
})
};
html! {
<div class="tp__api-config-view__credential-gen">
<div class="tp__api-config-view__credential-gen__header">
<div class="tp__api-config-view__section-title">{props.title.clone()}</div>
<span class="tp__api-config-view__credential-gen__badge">
{format!("{}: {total_length}", translate.t(LABEL_TOTAL))}
</span>
</div>
<div class="tp__api-config-view__credential-gen__segments">
{ for rows }
</div>
{ props.error.as_ref().map_or_else(Html::default, |error| html! {
<div class="tp__api-config-view__credential-gen-error" role="alert">{error.clone()}</div>
}) }
<div class="tp__api-config-view__credential-gen__actions">
<TextButton
class="secondary"
name={format!("{name}_add")}
icon="Add"
title={translate.t(LABEL_CREDENTIAL_ADD_SEGMENT)}
onclick={on_add}
/>
</div>
</div>
}
}
#[component]
pub fn ApiConfigView() -> Html {
let translate = use_translation();
@@ -414,6 +604,7 @@ pub fn ApiConfigView() -> Html {
api_config.prepare();
html! {
<Card class="tp__api-config-card">
<div class="tp__api-config-view__section-title">{translate.t(LABEL_API_CONFIG)}</div>
{ config_field!(api_config, translate.t(LABEL_HOST), host) }
{ config_field!(api_config, translate.t(LABEL_PORT), port) }
{ config_field!(api_config, translate.t(LABEL_WEB_ROOT), web_root) }
@@ -422,6 +613,7 @@ pub fn ApiConfigView() -> Html {
} else {
html! {
<Card class="tp__api-config-card">
<div class="tp__api-config-view__section-title">{translate.t(LABEL_API_CONFIG)}</div>
{ config_field_empty!(translate.t(LABEL_HOST), "CONFIG_API.HOST") }
{ config_field_empty!(translate.t(LABEL_PORT), "CONFIG_API.PORT") }
{ config_field_empty!(translate.t(LABEL_WEB_ROOT), "CONFIG_API.WEB_ROOT") }
@@ -433,6 +625,7 @@ pub fn ApiConfigView() -> Html {
let render_edit_mode_api_config = || {
html! {
<Card class="tp__api-config-card">
<div class="tp__api-config-view__section-title">{translate.t(LABEL_API_CONFIG)}</div>
{ edit_field_text!(form_state_api_config, translate.t(LABEL_HOST), host, ApiConfigFormAction::Host) }
{ edit_field_number_u16!(form_state_api_config, translate.t(LABEL_PORT), port, ApiConfigFormAction::Port) }
{ edit_field_text!(form_state_api_config, translate.t(LABEL_WEB_ROOT), web_root, ApiConfigFormAction::WebRoot) }
@@ -443,7 +636,7 @@ pub fn ApiConfigView() -> Html {
let render_edit_mode_api_proxy_config = || {
html! {
<Card class="tp__api-config-card">
<div class="tp__config-view-page__title tp__api-config-view__section-title">{translate.t(LABEL_API_PROXY_CONFIG)}</div>
<div class="tp__api-config-view__section-title">{translate.t(LABEL_API_PROXY_CONFIG)}</div>
<div class="tp__api-config-view__section">
{ edit_field_bool!(form_state_api_proxy_config, translate.t(LABEL_USE_USER_DB), use_user_db, ApiProxyConfigFormAction::UseUserDb) }
{ edit_field_number_u16!(form_state_api_proxy_config, translate.t(LABEL_AUTH_ERROR_STATUS), auth_error_status, ApiProxyConfigFormAction::AuthErrorStatus) }
@@ -475,10 +668,73 @@ pub fn ApiConfigView() -> Html {
}
};
let render_edit_mode_credential_generation = || {
let generation = form_state_api_proxy_config.form.credential_generation.clone();
let [username_error, password_error, token_error] = generation.field_errors();
let fields = [
CredentialEditFieldItem {
name: "username",
label: LABEL_USERNAME,
default_chars: CREDENTIAL_UPPER_CHARS,
value: generation.username.clone(),
error: username_error,
select: |g| &mut g.username,
},
CredentialEditFieldItem {
name: "password",
label: LABEL_PASSWORD,
default_chars: CREDENTIAL_UPPER_CHARS,
value: generation.password.clone(),
error: password_error,
select: |g| &mut g.password,
},
CredentialEditFieldItem {
name: "token",
label: LABEL_TOKEN,
default_chars: CREDENTIAL_MIXED_CHARS,
value: generation.token.clone(),
error: token_error,
select: |g| &mut g.token,
},
];
html! {
<Card class="tp__api-config-card">
<div class="tp__api-config-view__section-title">
{translate.t(LABEL_CREDENTIAL_GENERATION)}
</div>
<div class="tp__api-config-view__credential-gen-list">
{
fields.into_iter().map(|item| {
let (state, generation) = (form_state_api_proxy_config.clone(), generation.clone());
let select = item.select;
let on_change = Callback::from(move |next: CredentialFieldGenDto| {
let mut generation = generation.clone();
*select(&mut generation) = next;
state.dispatch(ApiProxyConfigFormAction::CredentialGeneration(generation));
});
html! {
<CredentialGenEdit
key={item.name}
name={item.name}
title={translate.t(item.label)}
default_chars={item.default_chars}
value={item.value}
error={item.error}
{on_change}
/>
}
}).collect::<Html>()
}
</div>
</Card>
}
};
let render_view_mode_api_proxy_config = || {
html! {
<Card class="tp__api-config-card">
<div class="tp__config-view-page__title tp__api-config-view__section-title">{translate.t(LABEL_API_PROXY_CONFIG)}</div>
<div class="tp__api-config-view__section-title">{translate.t(LABEL_API_PROXY_CONFIG)}</div>
<div class="tp__api-config-view__section">
{ config_field_bool!(form_state_api_proxy_config.form, translate.t(LABEL_USE_USER_DB), use_user_db) }
{ config_field!(form_state_api_proxy_config.form, translate.t(LABEL_AUTH_ERROR_STATUS), auth_error_status) }
@@ -502,6 +758,82 @@ pub fn ApiConfigView() -> Html {
}
};
let render_view_mode_credential_generation = || {
let generation = &form_state_api_proxy_config.form.credential_generation;
let fields = [
CredentialViewFieldItem {
label: LABEL_USERNAME,
default_chars: CREDENTIAL_UPPER_CHARS,
value: &generation.username,
},
CredentialViewFieldItem {
label: LABEL_PASSWORD,
default_chars: CREDENTIAL_UPPER_CHARS,
value: &generation.password,
},
CredentialViewFieldItem {
label: LABEL_TOKEN,
default_chars: CREDENTIAL_MIXED_CHARS,
value: &generation.token,
},
];
html! {
<Card class="tp__api-config-card">
<div class="tp__api-config-view__section-title">
{translate.t(LABEL_CREDENTIAL_GENERATION)}
</div>
<div class="tp__api-config-view__credential-gen-list">
{
fields.into_iter().map(|item| {
let segments = item.value.to_segments();
let total_length: u32 = segments.iter().map(|s| u32::from(s.length)).sum();
let default_chars = item.default_chars;
html! {
<div class="tp__api-config-view__credential-gen" key={item.label}>
<div class="tp__api-config-view__credential-gen__header">
<div class="tp__api-config-view__section-title">{translate.t(item.label)}</div>
<span class="tp__api-config-view__credential-gen__badge">
{format!("{}: {total_length}", translate.t(LABEL_TOTAL))}
</span>
</div>
<div class="tp__api-config-view__credential-gen__segments">
{ for segments.iter().enumerate().map(|(idx, segment)| {
let chars_display = if segment.chars.is_empty() {
format!("{} ({})", default_chars, translate.t(LABEL_CREDENTIAL_DEFAULT))
} else {
segment.chars.clone()
};
let exclude_display = if segment.exclude.is_empty() {
"-".to_string()
} else {
segment.exclude.clone()
};
html! {
<div class="tp__api-config-view__credential-gen__segment" key={idx}>
<div class="tp__api-config-view__credential-gen__segment-header">
<span class="tp__api-config-view__credential-gen__segment-title">
{format!("{} {}", translate.t(LABEL_CREDENTIAL_SEGMENT), idx + 1)}
</span>
</div>
<div class="tp__api-config-view__credential-gen__segment-fields">
{ config_field!(segment, translate.t(LABEL_CREDENTIAL_LENGTH), length, Some("API_PROXY_CONFIG.CREDENTIAL_LENGTH".to_string())) }
{ config_field_custom!(translate.t(LABEL_CREDENTIAL_CHARS), chars_display, Some("API_PROXY_CONFIG.CREDENTIAL_CHARS".to_string())) }
{ config_field_custom!(translate.t(LABEL_CREDENTIAL_EXCLUDE), exclude_display, Some("API_PROXY_CONFIG.CREDENTIAL_EXCLUDE".to_string())) }
</div>
</div>
}
}) }
</div>
</div>
}
}).collect::<Html>()
}
</div>
</Card>
}
};
let server_dialog_html = if let Some(mode) = *server_dialog_mode {
let title = match mode {
ServerDialogMode::Add => translate.t(LABEL_ADD_SERVER),
@@ -627,6 +959,9 @@ pub fn ApiConfigView() -> Html {
<div class="tp__api-config-view__section tp__config-view-page__body">
{ render_edit_mode_api_proxy_config() }
</div>
<div class="tp__api-config-view__section tp__config-view-page__body">
{ render_edit_mode_credential_generation() }
</div>
</>
}
} else {
@@ -638,6 +973,9 @@ pub fn ApiConfigView() -> Html {
<div class="tp__api-config-view__section">
{ render_view_mode_api_proxy_config() }
</div>
<div class="tp__api-config-view__section">
{ render_view_mode_credential_generation() }
</div>
</>
}
}
@@ -154,6 +154,18 @@ macro_rules! config_field_custom {
</div>
}
};
($label:expr, $value:expr, $hint:expr) => {
html! {
<div class="tp__form-field tp__form-field__text">
<$crate::app::components::FieldLabel
label={$label.to_string()}
field_id={$label.to_string()}
hint_key={$hint}
/>
<span class="tp__form-field__value">{$value}</span>
</div>
}
};
}
#[macro_export]
@@ -8,7 +8,7 @@ use crate::{
};
use std::{collections::HashSet, rc::Rc};
use web_sys::MouseEvent;
use yew::{classes, component, html, use_effect_with, use_state, Callback, Html, NodeRef, Properties};
use yew::{classes, component, html, use_effect_with, use_state, Callback, Classes, Html, NodeRef, Properties};
use yew_hooks::use_set;
#[derive(Debug, Clone, PartialEq)]
@@ -45,6 +45,12 @@ pub struct DropDownIconButtonProps {
pub popup_width: PopupMenuWidth,
#[prop_or_default]
pub popup_placement: PopupMenuPlacement,
/// Distance between the field and the menu; `None` uses the default viewport gutter.
#[prop_or_default]
pub popup_gap: Option<f64>,
/// Extra classes on the menu root, for caller-specific shape variants.
#[prop_or_default]
pub popup_class: Classes,
#[prop_or_default]
pub width_anchor_ref: Option<NodeRef>,
#[prop_or_default]
@@ -140,7 +146,8 @@ pub fn DropDownIconButton(props: &DropDownIconButtonProps) -> Html {
aria_invalid={props.aria_invalid}
aria_describedby={props.aria_describedby.clone()} />
<PopupMenu list_role="listbox" is_open={*popup_is_open} anchor_ref={(*popup_anchor_ref).clone()}
width={props.popup_width} placement={props.popup_placement}
width={props.popup_width} placement={props.popup_placement} gap={props.popup_gap}
class={props.popup_class.clone()}
width_anchor_ref={props.width_anchor_ref.clone()} on_close={handle_popup_close}>
{
for props.options.iter().map(|o| {
+5 -1
View File
@@ -37,6 +37,8 @@ pub struct NumberInputProps {
#[prop_or_default]
pub label: Option<String>,
#[prop_or_default]
pub hint_key: Option<String>,
#[prop_or_default]
pub value: Option<i64>,
#[prop_or_default]
pub float_value: Option<f64>,
@@ -181,7 +183,7 @@ pub fn NumberInput(props: &NumberInputProps) -> Html {
};
html! {
<FieldWrapper label={props.label.clone()} field_id={resolved_field_id.clone()}>
<FieldWrapper label={props.label.clone()} field_id={resolved_field_id.clone()} hint_key={props.hint_key.clone()}>
<input
id={resolved_field_id}
ref={input_ref.clone()}
@@ -277,6 +279,7 @@ mod browser_tests {
name: "unsigned-test".to_string(),
field_id: None,
label: None,
hint_key: None,
value: None,
float_value: None,
on_change: Callback::noop(),
@@ -298,6 +301,7 @@ mod browser_tests {
name: "bounded-signed-test".to_string(),
field_id: None,
label: None,
hint_key: None,
value: Some(value),
float_value: None,
on_change,
+164 -45
View File
@@ -6,6 +6,9 @@ use yew::{create_portal, prelude::*};
const VIEWPORT_GUTTER_PX: f64 = 8.0;
/// Distance between an anchor and its menu when a caller does not tune it.
pub const POPUP_MENU_DEFAULT_GAP_PX: f64 = VIEWPORT_GUTTER_PX;
/// Opt-in popup width policy; ordinary menus keep their content-sized trigger anchor.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PopupMenuWidth {
@@ -21,6 +24,17 @@ pub enum PopupMenuPlacement {
Adaptive,
/// Keep the popup's top-right corner attached to the anchor's bottom-right corner.
BottomEnd,
/// Keep the popup's right edge attached to the anchor's right edge, but flip it above the
/// anchor when the space below is too small.
BottomEndFlip,
}
impl PopupMenuPlacement {
/// End-aligned menus attach to the full field instead of the trigger button inside it.
const fn aligns_with_anchor_end(self) -> bool { matches!(self, Self::BottomEnd | Self::BottomEndFlip) }
/// Flipping menus may move above the anchor; pinned menus keep following it.
const fn flips_above_anchor(self) -> bool { matches!(self, Self::Adaptive | Self::BottomEndFlip) }
}
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -64,46 +78,76 @@ fn popup_anchor_rect(anchor: &web_sys::Element) -> Option<PopupAnchorRect> {
})
}
/// Picks the rectangle that positions the menu.
///
/// A right-anchored menu attaches to the full field when one is known, so its right edge
/// lines up with the input instead of hanging off the trigger button inside the field.
fn resolve_popup_anchor(
width: PopupMenuWidth,
placement: PopupMenuPlacement,
trigger: PopupAnchorRect,
field: Option<PopupAnchorRect>,
) -> Option<PopupAnchorRect> {
match width {
PopupMenuWidth::MatchAnchor => field,
PopupMenuWidth::Content => Some(trigger),
if width == PopupMenuWidth::MatchAnchor {
return field;
}
if placement.aligns_with_anchor_end() {
field.or(Some(trigger))
} else {
Some(trigger)
}
}
// Use layout bounds so pinch zoom does not detach or shrink the menu relative to its field.
#[derive(Clone, Copy, Debug, PartialEq)]
struct PopupSpacing {
/// Minimum distance between the menu and the viewport edges.
gutter: f64,
/// Distance between the anchor's edge and the menu.
gap: f64,
}
impl PopupSpacing {
const DEFAULT: Self = Self { gutter: VIEWPORT_GUTTER_PX, gap: POPUP_MENU_DEFAULT_GAP_PX };
/// A caller-tuned gap still keeps the default viewport gutter.
fn with_gap(gap: Option<f64>) -> Self { Self { gap: gap.unwrap_or(Self::DEFAULT.gap), ..Self::DEFAULT } }
}
fn popup_position(
anchor: PopupAnchorRect,
popup: PopupSize,
viewport: PopupSize,
gutter: f64,
spacing: PopupSpacing,
placement: PopupMenuPlacement,
) -> PopupPosition {
match placement {
PopupMenuPlacement::Adaptive => {
let max_bottom = viewport.height - gutter;
let max_right = viewport.width - gutter;
let mut top = anchor.bottom + gutter;
let mut left = anchor.left;
if left + popup.width > max_right {
left = max_right - popup.width;
}
if top + popup.height > max_bottom {
let top_above = anchor.top - popup.height - gutter;
top = if top_above >= gutter { top_above } else { max_bottom - popup.height };
}
PopupPosition { top: top.max(gutter), left: left.max(gutter) }
let PopupSpacing { gutter, gap } = spacing;
let left = if placement.aligns_with_anchor_end() {
// Right edges stay attached; the gutter keeps a menu wider than the room left of
// the field from leaving the viewport.
(anchor.left + anchor.width - popup.width).max(gutter)
} else {
let max_right = viewport.width - gutter;
let mut left = anchor.left;
if left + popup.width > max_right {
left = max_right - popup.width;
}
PopupMenuPlacement::BottomEnd => {
PopupPosition { top: anchor.bottom + gutter, left: anchor.left + anchor.width - popup.width }
left.max(gutter)
};
let top = if placement.flips_above_anchor() {
let max_bottom = viewport.height - gutter;
let mut top = anchor.bottom + gap;
if top + popup.height > max_bottom {
let top_above = anchor.top - popup.height - gap;
top = if top_above >= gutter { top_above } else { max_bottom - popup.height };
}
}
top.max(gutter)
} else {
// A pinned menu follows its anchor off-screen instead of sticking to the viewport edge.
anchor.bottom + gap
};
PopupPosition { top, left }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -197,6 +241,12 @@ pub struct PopupMenuProps {
/// Full field whose width and edges the opt-in menu should match on every screen size.
#[prop_or_default]
pub width_anchor_ref: Option<NodeRef>,
/// Distance between the anchor and the menu; `None` uses the default viewport gutter.
#[prop_or_default]
pub gap: Option<f64>,
/// Extra classes on the menu root, for caller-specific shape variants.
#[prop_or_default]
pub class: Classes,
#[prop_or_default]
pub on_close: Callback<()>,
/// ARIA role of the option list, e.g. `listbox` for select-style popups.
@@ -227,10 +277,11 @@ pub fn PopupMenu(props: &PopupMenuProps) -> Html {
let width = props.width;
let placement = props.placement;
let width_anchor_ref = props.width_anchor_ref.clone();
let gap = props.gap;
let on_close = props.on_close.clone();
use_effect_with(
(props.is_open, anchor_ref, popup_ref.clone(), width, placement, width_anchor_ref),
move |(is_open, anchor_ref, popup_ref, width, placement, width_anchor_ref)| {
(props.is_open, anchor_ref, popup_ref.clone(), width, placement, width_anchor_ref, gap),
move |(is_open, anchor_ref, popup_ref, width, placement, width_anchor_ref, gap)| {
let cleanup = is_open.then(|| {
let reposition = {
let anchor_ref = anchor_ref.clone();
@@ -238,6 +289,7 @@ pub fn PopupMenu(props: &PopupMenuProps) -> Html {
let width = *width;
let placement = *placement;
let width_anchor_ref = width_anchor_ref.clone();
let spacing = PopupSpacing::with_gap(*gap);
let first_position = Cell::new(true);
Rc::new(move || {
let popup = popup_ref.cast::<HtmlElement>()?;
@@ -247,7 +299,7 @@ pub fn PopupMenu(props: &PopupMenuProps) -> Html {
height: f64::from(root.client_height()),
};
let trigger = popup_anchor_rect(anchor_ref.as_ref()?)?;
let field = if width == PopupMenuWidth::MatchAnchor {
let field = if width == PopupMenuWidth::MatchAnchor || placement.aligns_with_anchor_end() {
width_anchor_ref
.as_ref()
.and_then(NodeRef::cast::<web_sys::Element>)
@@ -255,7 +307,7 @@ pub fn PopupMenu(props: &PopupMenuProps) -> Html {
} else {
None
};
let anchor = resolve_popup_anchor(width, trigger, field)?;
let anchor = resolve_popup_anchor(width, placement, trigger, field)?;
// Set width before measuring height: wrapping can affect flip/clamping.
let _ = popup.style().set_property("--popup-anchor-width", &format!("{}px", anchor.width));
// Safari can report client rectangles relative to the visual viewport.
@@ -269,7 +321,7 @@ pub fn PopupMenu(props: &PopupMenuProps) -> Html {
anchor.in_fixed_coordinates(fixed_origin),
PopupSize { width: popup_rect.width(), height: popup_rect.height() },
layout_viewport,
VIEWPORT_GUTTER_PX,
spacing,
placement,
);
let _ = popup.style().set_property("--popup-top", &format!("{}px", position.top));
@@ -409,6 +461,7 @@ pub fn PopupMenu(props: &PopupMenuProps) -> Html {
<div class={classes!(
"tp__popup-menu",
(props.width == PopupMenuWidth::MatchAnchor).then_some("tp__popup-menu--match-anchor"),
props.class.clone(),
(*style).clone()
)} ref={popup_ref}>
<ul role={props.list_role.clone()}>
@@ -433,6 +486,8 @@ mod tests {
const POPUP_SIZE: PopupSize = PopupSize { width: 160.0, height: 120.0 };
const VIEWPORT: PopupSize = PopupSize { width: 1024.0, height: 768.0 };
/// Mirrors the select's tuned gap; the component supplies it through `PopupMenuProps::gap`.
const SELECT_SPACING: PopupSpacing = PopupSpacing { gutter: VIEWPORT_GUTTER_PX, gap: 2.0 };
struct TestListener {
id: usize,
@@ -498,7 +553,7 @@ mod tests {
anchor,
POPUP_SIZE,
popup.viewport.get(),
8.0,
PopupSpacing::DEFAULT,
PopupMenuPlacement::Adaptive,
));
Some(())
@@ -514,13 +569,13 @@ mod tests {
fn playlist_update_dropdown_opens_below_when_space_is_available() {
let anchor = PopupAnchorRect { top: 100.0, bottom: 140.0, left: 100.0, width: 40.0 };
assert_eq!(
popup_position(anchor, POPUP_SIZE, VIEWPORT, 8.0, PopupMenuPlacement::Adaptive),
popup_position(anchor, POPUP_SIZE, VIEWPORT, PopupSpacing::DEFAULT, PopupMenuPlacement::Adaptive),
PopupPosition { top: 148.0, left: 100.0 }
);
let exact_fit = PopupAnchorRect { top: 592.0, bottom: 632.0, left: 100.0, width: 40.0 };
assert_eq!(
popup_position(exact_fit, POPUP_SIZE, VIEWPORT, 8.0, PopupMenuPlacement::Adaptive),
popup_position(exact_fit, POPUP_SIZE, VIEWPORT, PopupSpacing::DEFAULT, PopupMenuPlacement::Adaptive),
PopupPosition { top: 640.0, left: 100.0 }
);
}
@@ -529,7 +584,7 @@ mod tests {
fn playlist_update_dropdown_flips_above_when_space_below_is_insufficient() {
let anchor = PopupAnchorRect { top: 680.0, bottom: 720.0, left: 100.0, width: 40.0 };
assert_eq!(
popup_position(anchor, POPUP_SIZE, VIEWPORT, 8.0, PopupMenuPlacement::Adaptive),
popup_position(anchor, POPUP_SIZE, VIEWPORT, PopupSpacing::DEFAULT, PopupMenuPlacement::Adaptive),
PopupPosition { top: 552.0, left: 100.0 }
);
}
@@ -538,7 +593,7 @@ mod tests {
fn playlist_update_dropdown_bottom_end_remains_attached_near_viewport_edges() {
let anchor = PopupAnchorRect { top: 680.0, bottom: 720.0, left: 600.0, width: 240.0 };
let popup = PopupSize { width: 160.0, height: 120.0 };
let position = popup_position(anchor, popup, VIEWPORT, 8.0, PopupMenuPlacement::BottomEnd);
let position = popup_position(anchor, popup, VIEWPORT, PopupSpacing::DEFAULT, PopupMenuPlacement::BottomEnd);
assert_eq!(position, PopupPosition { top: 728.0, left: 680.0 });
assert_eq!(position.top, anchor.bottom + 8.0, "menu top must stay below the field");
@@ -547,7 +602,7 @@ mod tests {
let scrolled_anchor = PopupAnchorRect { top: -180.0, bottom: -140.0, ..anchor };
assert_eq!(
popup_position(scrolled_anchor, popup, VIEWPORT, 8.0, PopupMenuPlacement::BottomEnd),
popup_position(scrolled_anchor, popup, VIEWPORT, PopupSpacing::DEFAULT, PopupMenuPlacement::BottomEnd),
PopupPosition { top: -132.0, left: 680.0 },
"the menu must follow an off-screen field instead of sticking over the page header"
);
@@ -558,7 +613,7 @@ mod tests {
for (anchor_left, expected_left) in [(-30.0, 8.0), (0.0, 8.0), (856.0, 856.0), (1000.0, 856.0)] {
let anchor = PopupAnchorRect { top: 100.0, bottom: 140.0, left: anchor_left, width: 40.0 };
assert_eq!(
popup_position(anchor, POPUP_SIZE, VIEWPORT, 8.0, PopupMenuPlacement::Adaptive),
popup_position(anchor, POPUP_SIZE, VIEWPORT, PopupSpacing::DEFAULT, PopupMenuPlacement::Adaptive),
PopupPosition { top: 148.0, left: expected_left }
);
}
@@ -569,12 +624,12 @@ mod tests {
let viewport = PopupSize { width: 320.0, height: 160.0 };
let anchor = PopupAnchorRect { top: 60.0, bottom: 100.0, left: 100.0, width: 40.0 };
assert_eq!(
popup_position(anchor, POPUP_SIZE, viewport, 8.0, PopupMenuPlacement::Adaptive),
popup_position(anchor, POPUP_SIZE, viewport, PopupSpacing::DEFAULT, PopupMenuPlacement::Adaptive),
PopupPosition { top: 32.0, left: 100.0 }
);
let above_viewport = PopupAnchorRect { top: -80.0, bottom: -40.0, left: 100.0, width: 40.0 };
assert_eq!(
popup_position(above_viewport, POPUP_SIZE, viewport, 8.0, PopupMenuPlacement::Adaptive),
popup_position(above_viewport, POPUP_SIZE, viewport, PopupSpacing::DEFAULT, PopupMenuPlacement::Adaptive),
PopupPosition { top: 8.0, left: 100.0 }
);
}
@@ -684,11 +739,13 @@ mod tests {
width: field_width,
};
let trigger = PopupAnchorRect { top: 100.0, bottom: 138.0, left: viewport_width - 69.0, width: 40.0 };
let anchor = resolve_popup_anchor(PopupMenuWidth::MatchAnchor, trigger, Some(field)).unwrap();
let anchor =
resolve_popup_anchor(PopupMenuWidth::MatchAnchor, PopupMenuPlacement::Adaptive, trigger, Some(field))
.unwrap();
assert_eq!(anchor, field);
let menu = PopupSize { width: anchor.width, height: 160.0 };
let position = popup_position(anchor, menu, viewport, 8.0, PopupMenuPlacement::Adaptive);
let position = popup_position(anchor, menu, viewport, PopupSpacing::DEFAULT, PopupMenuPlacement::Adaptive);
assert_eq!(position, PopupPosition { top: 147.0, left: field.left });
assert_eq!(position.left + menu.width, field.left + field.width, "right edges must align");
}
@@ -700,14 +757,74 @@ mod tests {
let field = PopupAnchorRect { top: 99.0, bottom: 141.0, left: 21.0, width: 348.0 };
assert_eq!(PopupMenuWidth::default(), PopupMenuWidth::Content);
assert_eq!(PopupMenuPlacement::default(), PopupMenuPlacement::Adaptive);
assert_eq!(resolve_popup_anchor(PopupMenuWidth::Content, trigger, Some(field)), Some(trigger));
assert_eq!(resolve_popup_anchor(PopupMenuWidth::Content, trigger, None), Some(trigger));
let placement = PopupMenuPlacement::Adaptive;
assert_eq!(resolve_popup_anchor(PopupMenuWidth::Content, placement, trigger, Some(field)), Some(trigger));
assert_eq!(resolve_popup_anchor(PopupMenuWidth::Content, placement, trigger, None), Some(trigger));
}
#[test]
fn select_dropdown_keeps_its_content_width_and_aligns_with_the_field_right_edge() {
// The select's trigger button sits at the right end of the field, so anchoring on it
// pushes a content-sized list off to the input's right. The field anchors it instead.
let field = PopupAnchorRect { top: 99.0, bottom: 141.0, left: 21.0, width: 348.0 };
let trigger = PopupAnchorRect { top: 100.0, bottom: 140.0, left: 333.0, width: 32.0 };
let placement = PopupMenuPlacement::BottomEndFlip;
let anchor = resolve_popup_anchor(PopupMenuWidth::Content, placement, trigger, Some(field)).unwrap();
assert_eq!(anchor, field);
assert_eq!(resolve_popup_anchor(PopupMenuWidth::Content, placement, trigger, None), Some(trigger));
let menu = PopupSize { width: 160.0, height: 120.0 };
let position = popup_position(anchor, menu, VIEWPORT, SELECT_SPACING, placement);
assert_eq!(position.left + menu.width, field.left + field.width, "right edges must align with the input");
assert_eq!(position.top, field.bottom + 2.0);
}
#[test]
fn select_dropdown_flips_above_the_field_when_the_space_below_is_short() {
let field = PopupAnchorRect { top: 640.0, bottom: 682.0, left: 21.0, width: 348.0 };
let menu = PopupSize { width: 160.0, height: 120.0 };
let placement = PopupMenuPlacement::BottomEndFlip;
let flipped = popup_position(field, menu, VIEWPORT, SELECT_SPACING, placement);
assert_eq!(flipped.top, field.top - menu.height - 2.0, "the list must move above the field");
assert_eq!(flipped.left + menu.width, field.left + field.width, "right edges must stay aligned when flipped");
// Playlist dropdowns keep the pinned variant, so they never flip a menu upward.
let pinned = popup_position(field, menu, VIEWPORT, SELECT_SPACING, PopupMenuPlacement::BottomEnd);
assert_eq!(pinned.top, field.bottom + 2.0);
}
#[test]
fn select_dropdown_wider_than_its_room_stays_inside_the_viewport() {
let field = PopupAnchorRect { top: 99.0, bottom: 141.0, left: 21.0, width: 120.0 };
let menu = PopupSize { width: 240.0, height: 120.0 };
let position = popup_position(field, menu, VIEWPORT, SELECT_SPACING, PopupMenuPlacement::BottomEndFlip);
assert_eq!(position.left, 8.0);
assert_eq!(position.top, field.bottom + 2.0);
}
#[test]
fn popup_gap_moves_only_the_anchor_distance_not_the_viewport_gutter() {
assert_eq!(PopupSpacing::with_gap(None), PopupSpacing::DEFAULT);
assert_eq!(SELECT_SPACING, PopupSpacing { gutter: VIEWPORT_GUTTER_PX, gap: 2.0 });
let field = PopupAnchorRect { top: 99.0, bottom: 141.0, left: 21.0, width: 348.0 };
let menu = PopupSize { width: 160.0, height: 120.0 };
let adaptive = popup_position(field, menu, VIEWPORT, SELECT_SPACING, PopupMenuPlacement::Adaptive);
assert_eq!(adaptive, PopupPosition { top: 143.0, left: 21.0 });
// Clamping still uses the viewport gutter, so an anchor at the edge keeps its margin.
let at_edge = PopupAnchorRect { left: -30.0, ..field };
let clamped = popup_position(at_edge, menu, VIEWPORT, SELECT_SPACING, PopupMenuPlacement::Adaptive);
assert_eq!(clamped.left, 8.0);
}
#[test]
fn playlist_update_dropdown_missing_field_does_not_use_stale_trigger_geometry() {
let trigger = PopupAnchorRect { top: 100.0, bottom: 140.0, left: 310.0, width: 40.0 };
assert_eq!(resolve_popup_anchor(PopupMenuWidth::MatchAnchor, trigger, None), None);
let placement = PopupMenuPlacement::Adaptive;
assert_eq!(resolve_popup_anchor(PopupMenuWidth::MatchAnchor, placement, trigger, None), None);
}
#[test]
@@ -774,7 +891,7 @@ mod tests {
let anchor = client_rect.in_fixed_coordinates(origin);
assert_eq!(anchor, field, "panning must retain field width and layout position");
assert_eq!(
popup_position(anchor, menu, layout, 8.0, PopupMenuPlacement::Adaptive),
popup_position(anchor, menu, layout, PopupSpacing::DEFAULT, PopupMenuPlacement::Adaptive),
PopupPosition { top: 466.0, left: 21.0 }
);
}
@@ -788,14 +905,16 @@ mod tests {
let trigger = PopupAnchorRect { top: 100.0, bottom: 140.0, left: 800.0, width: 40.0 };
let field = PopupAnchorRect { top: 99.0, bottom: 141.0, left: 600.0, width: 240.0 };
let layout = PopupSize { width: 1024.0, height: 768.0 };
let anchor = resolve_popup_anchor(PopupMenuWidth::MatchAnchor, trigger, Some(field)).unwrap();
let anchor =
resolve_popup_anchor(PopupMenuWidth::MatchAnchor, PopupMenuPlacement::Adaptive, trigger, Some(field))
.unwrap();
assert_eq!(anchor, field, "field matching must not depend on the pinch-zoom viewport");
assert_eq!(
popup_position(
anchor,
PopupSize { width: anchor.width, height: POPUP_SIZE.height },
layout,
8.0,
PopupSpacing::DEFAULT,
PopupMenuPlacement::Adaptive,
),
PopupPosition { top: 149.0, left: 600.0 }
+25 -2
View File
@@ -9,6 +9,9 @@ fn map_selection(o: &DropDownOption) -> Html {
html! {<span>{o.label.clone()}</span>}
}
/// A select's list hugs the field instead of floating at the default menu distance.
const SELECT_POPUP_GAP_PX: f64 = 2.0;
#[derive(Properties, Clone, PartialEq, Debug)]
pub struct SelectProps {
pub name: String,
@@ -19,7 +22,10 @@ pub struct SelectProps {
pub class: String,
#[prop_or_default]
pub popup_width: PopupMenuWidth,
#[prop_or_default]
/// Selects right-align their list with the field so it lines up with the input instead of
/// hanging off the icon button on the field's right end, and flip it above the field when the
/// space below is too small.
#[prop_or(PopupMenuPlacement::BottomEndFlip)]
pub popup_placement: PopupMenuPlacement,
pub options: Rc<Vec<DropDownOption>>,
#[prop_or_default]
@@ -80,6 +86,8 @@ pub fn Select(props: &SelectProps) -> Html {
button_ref={button_ref}
popup_width={props.popup_width}
popup_placement={props.popup_placement}
popup_gap={Some(SELECT_POPUP_GAP_PX)}
popup_class={classes!("tp__popup-menu--select")}
width_anchor_ref={Some(field_ref)}
multi_select={props.multi_select}
options={props.options.clone()}
@@ -110,7 +118,7 @@ mod tests {
on_select: Callback::noop(),
});
assert_eq!(props.popup_width, PopupMenuWidth::Content);
assert_eq!(props.popup_placement, PopupMenuPlacement::Adaptive);
assert_eq!(props.popup_placement, PopupMenuPlacement::BottomEndFlip);
let source = include_str!("select.rs").split("#[cfg(test)]").next().unwrap();
let dropdown = include_str!("drop_down_icon_button.rs");
assert!(source.contains("ref={field_ref.clone()}"));
@@ -121,4 +129,19 @@ mod tests {
assert!(dropdown.contains("placement={props.popup_placement}"));
assert!(dropdown.contains("width_anchor_ref={props.width_anchor_ref.clone()}"));
}
#[test]
fn select_popup_hugs_the_field_and_drops_its_top_left_radius() {
let source = include_str!("select.rs").split("#[cfg(test)]").next().unwrap();
let dropdown = include_str!("drop_down_icon_button.rs");
const { assert!(SELECT_POPUP_GAP_PX < crate::app::components::popup_menu::POPUP_MENU_DEFAULT_GAP_PX) };
assert!(source.contains("popup_gap={Some(SELECT_POPUP_GAP_PX)}"));
assert!(source.contains("popup_class={classes!(\"tp__popup-menu--select\")}"));
assert!(dropdown.contains("gap={props.popup_gap}"));
assert!(dropdown.contains("class={props.popup_class.clone()}"));
let styles = include_str!("../../../scss/app/components/_popup_menu.scss");
let select_rule = styles.split_once("&--select {").unwrap().1.split('}').next().unwrap();
assert!(select_rule.contains("border-top-left-radius: 0;"));
}
}
@@ -9,7 +9,7 @@ use crate::{
ClusterFlagsInput, ClusterFlagsInputMode, DropDownOption, DropDownSelection, FilterInput, NoSubmitForm,
Tag, TextButton, UserStatus,
},
TargetUser,
ConfigContext, TargetUser,
},
config_field_child, config_field_custom, edit_field_bool, edit_field_date, edit_field_list_option,
edit_field_number, edit_field_number_i8, edit_field_number_u16, edit_field_text_option, generate_form_reducer,
@@ -18,14 +18,11 @@ use crate::{
i18n::use_translation,
};
use chrono::{Duration, Utc};
use shared::{
model::{
permission::Permission, ApiProxyServerInfoDto, ClusterFlags, ConfigTargetDto, NetworkAccessDto, ProxyType,
ProxyUserCredentialsDto, ProxyUserStatus, UserPlanDto,
},
utils::generate_random_string,
use shared::model::{
permission::Permission, ApiProxyServerInfoDto, ClusterFlags, ConfigTargetDto, CredentialGenerationDto,
NetworkAccessDto, ProxyType, ProxyUserCredentialsDto, ProxyUserStatus, UserPlanDto,
};
use std::{net::IpAddr, rc::Rc};
use std::{cell::Cell, net::IpAddr, rc::Rc};
use strum::IntoEnumIterator;
use yew::prelude::*;
@@ -43,6 +40,46 @@ impl UserFormFieldErrors {
fn has_errors(&self) -> bool { self.username.is_some() || self.password.is_some() || self.target.is_some() }
}
/// Credential values auto-filled into a new-user form. The stored values let the
/// form tell an untouched auto-filled field from one the user edited.
#[derive(Clone)]
struct GeneratedCredentials {
username: String,
password: String,
token: Option<String>,
}
impl GeneratedCredentials {
/// Which fields still hold the auto-filled value, i.e. were not edited by the user.
fn untouched(&self, form: &ProxyUserCredentialsDto) -> (bool, bool, bool) {
(form.username == self.username, form.password == self.password, form.token == self.token)
}
}
/// Remembers whether the create draft was already built for the open edit view.
///
/// Leaving the edit view ends the create session: the next "new user" must start
/// from freshly generated credentials instead of the abandoned draft.
#[derive(Default)]
struct CreateDraftSession {
initialized: Cell<bool>,
}
impl CreateDraftSession {
/// The edit view was left, so the draft is abandoned.
fn abandon(&self) { self.initialized.set(false); }
/// Returns true when the create draft has to be built; later calls keep the draft.
fn begin(&self) -> bool {
if self.initialized.get() {
false
} else {
self.initialized.set(true);
true
}
}
}
fn cluster_flags_label(flags: ClusterFlags, t: impl Fn(&str) -> String) -> String {
let mut parts = Vec::new();
if flags.contains(ClusterFlags::Live) {
@@ -243,7 +280,14 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
options
});
let create_initialized = use_ref(|| std::cell::Cell::new(false));
let create_draft = use_ref(CreateDraftSession::default);
let generated_credentials = use_mut_ref(|| None::<GeneratedCredentials>);
let credential_effect_started = use_mut_ref(|| false);
let config_ctx = use_context::<ConfigContext>();
let credential_generation = config_ctx
.as_ref()
.and_then(|ctx| ctx.api_proxy.as_ref())
.map_or_else(CredentialGenerationDto::default, |api_proxy| api_proxy.credential_generation.clone());
{
let form_state = form_state.clone();
@@ -251,10 +295,18 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
let set_update = update.clone();
let set_allowed_countries = allowed_countries.clone();
let set_allowed_networks = allowed_networks.clone();
let create_initialized = create_initialized.clone();
let create_draft = create_draft.clone();
let generated_credentials = generated_credentials.clone();
let main_credential_generation = credential_generation.clone();
use_effect_with(
(props.active_page, props.user.clone(), props.server.clone()),
move |(active_page, user, server)| {
if *active_page == Some(UserlistPage::List) {
// Leaving the edit view abandons the create draft, so the next create
// session generates fresh credentials.
create_draft.abandon();
return;
}
if active_page.is_none() || *active_page == Some(UserlistPage::Edit) {
if let Some(u) = user.clone() {
set_update.set(true);
@@ -280,9 +332,9 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
set_allowed_countries.set(Vec::new());
set_allowed_networks.set(Vec::new());
}
create_initialized.set(false);
create_draft.abandon();
form_state.dispatch(UserFormAction::SetAll(creds));
} else if create_initialized.get() {
} else if !create_draft.begin() {
// Create form already initialized — only merge the default
// server when the user hasn't picked one yet.
if form_state.data().server.is_none() {
@@ -291,7 +343,6 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
}
}
} else {
create_initialized.set(true);
set_update.set(false);
set_selected_target.set(None);
set_allowed_countries.set(Vec::new());
@@ -310,9 +361,14 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
let in_one_year = now + Duration::days(DEFAULT_EXPIRATION_DAYS);
user.exp_date = Some(in_one_year.timestamp());
user.username = generate_random_string(6).to_uppercase();
user.password = generate_random_string(6).to_uppercase();
user.token = Some(generate_random_string(6));
user.username = main_credential_generation.generate_username();
user.password = main_credential_generation.generate_password();
user.token = Some(main_credential_generation.generate_token());
*generated_credentials.borrow_mut() = Some(GeneratedCredentials {
username: user.username.clone(),
password: user.password.clone(),
token: user.token.clone(),
});
form_state.dispatch(UserFormAction::SetAll(user));
}
@@ -321,6 +377,48 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
);
}
{
let form_state = form_state.clone();
let generated_credentials = generated_credentials.clone();
let credential_effect_started = credential_effect_started.clone();
let is_new_user = props.user.is_none();
use_effect_with(credential_generation, move |generation| {
{
let mut started = credential_effect_started.borrow_mut();
if !*started {
*started = true;
return;
}
}
// Only regenerate the create form; an existing user's stored credentials
// must never change because the configuration changed.
if !is_new_user {
return;
}
let Some(generated) = generated_credentials.borrow().clone() else {
return;
};
let (username_untouched, password_untouched, token_untouched) = generated.untouched(form_state.data());
let mut next = generated.clone();
if username_untouched {
let value = generation.generate_username();
next.username.clone_from(&value);
form_state.dispatch(UserFormAction::Username(value));
}
if password_untouched {
let value = generation.generate_password();
next.password.clone_from(&value);
form_state.dispatch(UserFormAction::Password(value));
}
if token_untouched {
let value = generation.generate_token();
next.token = Some(value.clone());
form_state.dispatch(UserFormAction::Token(Some(value)));
}
*generated_credentials.borrow_mut() = Some(next);
});
}
let handle_cancel = {
let oncancel = props.on_cancel.clone();
Callback::from(move |_| {
@@ -762,6 +860,43 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
mod tests {
use super::*;
#[test]
fn create_draft_begins_once_per_edit_view() {
let draft = CreateDraftSession::default();
assert!(draft.begin());
assert!(!draft.begin());
assert!(!draft.begin());
}
#[test]
fn create_draft_begins_again_after_leaving_the_edit_view() {
let draft = CreateDraftSession::default();
assert!(draft.begin());
draft.abandon();
assert!(draft.begin());
}
#[test]
fn generated_credentials_report_only_unedited_fields() {
let generated = GeneratedCredentials {
username: "AAA111".to_string(),
password: "BBB222".to_string(),
token: Some("ccc333".to_string()),
};
let mut form = ProxyUserCredentialsDto {
username: generated.username.clone(),
password: "edited".to_string(),
token: generated.token.clone(),
..ProxyUserCredentialsDto::default()
};
assert_eq!(generated.untouched(&form), (true, false, true));
form.password.clone_from(&generated.password);
form.token = None;
assert_eq!(generated.untouched(&form), (true, true, false));
}
#[test]
fn build_network_access_returns_none_for_empty_inputs() {
assert_eq!(build_network_access(&[], &[]), Ok(None));
+287 -1
View File
@@ -3,7 +3,7 @@ use crate::{
error::TuliproxError,
foundation::get_filter,
model::{ClusterFlags, ProxyType, ProxyUserCredentialsDto},
utils::{is_blank_optional_string, parse_duration_seconds},
utils::{build_charset, generate_random_string_from, is_blank_optional_string, parse_duration_seconds},
};
use std::collections::HashSet;
@@ -93,6 +93,171 @@ pub struct ApiProxyServerInfoDto {
pub path: Option<String>,
}
pub const CREDENTIAL_LENGTH_MIN: u16 = 1;
pub const CREDENTIAL_LENGTH_MAX: u16 = 64;
pub const CREDENTIAL_DEFAULT_LENGTH: u16 = 6;
pub const CREDENTIAL_UPPER_CHARS: &str = "A-Z0-9";
pub const CREDENTIAL_MIXED_CHARS: &str = "A-Za-z0-9";
const fn default_credential_length() -> u16 { CREDENTIAL_DEFAULT_LENGTH }
/// One part of a generated credential: `length` chars picked from `chars` minus `exclude`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CredentialSegmentDto {
#[serde(default = "default_credential_length")]
pub length: u16,
/// Charset expression: literal chars and ranges `X-Y`; `\-` and `\\` escape literals.
/// Only ASCII alphanumerics and `! . _ ~ -` are allowed. Empty means the default of the credential.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub chars: String,
/// Literal chars removed from `chars`.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub exclude: String,
}
impl Default for CredentialSegmentDto {
fn default() -> Self { Self { length: CREDENTIAL_DEFAULT_LENGTH, chars: String::new(), exclude: String::new() } }
}
impl CredentialSegmentDto {
fn charset(&self, default_chars: &str) -> Result<Vec<char>, String> {
if !(CREDENTIAL_LENGTH_MIN..=CREDENTIAL_LENGTH_MAX).contains(&self.length) {
return Err(format!(
"length must be between {CREDENTIAL_LENGTH_MIN} and {CREDENTIAL_LENGTH_MAX}, got {}",
self.length
));
}
let chars = if self.chars.trim().is_empty() { default_chars } else { self.chars.as_str() };
build_charset(chars, &self.exclude)
}
}
/// Generation rules for one auto-generated credential (username, password or token).
/// Either the flat form (`length`, `chars`, `exclude` describe one segment) or an
/// ordered `segments` list is used, never both.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CredentialFieldGenDto {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub length: Option<u16>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub chars: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub exclude: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub segments: Vec<CredentialSegmentDto>,
}
impl CredentialFieldGenDto {
fn is_flat(&self) -> bool { self.length.is_some() || !self.chars.is_empty() || !self.exclude.is_empty() }
/// The effective segments, whichever form is used.
pub fn to_segments(&self) -> Vec<CredentialSegmentDto> {
if self.segments.is_empty() {
vec![CredentialSegmentDto {
length: self.length.unwrap_or(CREDENTIAL_DEFAULT_LENGTH),
chars: self.chars.clone(),
exclude: self.exclude.clone(),
}]
} else {
self.segments.clone()
}
}
/// Builds the canonical form: a single segment is stored flat, defaults are omitted.
pub fn from_segments(mut segments: Vec<CredentialSegmentDto>) -> Self {
if segments.len() == 1 {
let CredentialSegmentDto { length, chars, exclude } = segments.remove(0);
Self {
length: (length != CREDENTIAL_DEFAULT_LENGTH).then_some(length),
chars,
exclude,
segments: Vec::new(),
}
} else {
Self { segments, ..Self::default() }
}
}
fn charsets(&self, default_chars: &str) -> Result<Vec<(Vec<char>, u16)>, String> {
if !self.segments.is_empty() && self.is_flat() {
return Err("use either length/chars/exclude or segments, not both".to_string());
}
let segments = self.to_segments();
let mut total = 0u32;
let mut result = Vec::with_capacity(segments.len());
for (idx, segment) in segments.iter().enumerate() {
let charset = segment.charset(default_chars).map_err(|err| {
if segments.len() > 1 {
format!("segment {}: {err}", idx + 1)
} else {
err
}
})?;
total += u32::from(segment.length);
result.push((charset, segment.length));
}
if total > u32::from(CREDENTIAL_LENGTH_MAX) {
return Err(format!("total length must not exceed {CREDENTIAL_LENGTH_MAX}, got {total}"));
}
Ok(result)
}
pub fn validate(&self, default_chars: &str) -> Result<(), String> { self.charsets(default_chars).map(|_| ()) }
/// Generates a value; falls back to a default 6 char value when the configuration is invalid.
pub fn generate(&self, default_chars: &str) -> String {
let parts = self.charsets(default_chars).unwrap_or_else(|_| {
vec![(build_charset(default_chars, "").unwrap_or_default(), CREDENTIAL_DEFAULT_LENGTH)]
});
parts.iter().map(|(charset, length)| generate_random_string_from(charset, usize::from(*length))).collect()
}
}
/// Rules for the credentials auto-filled when creating a new api user in the web UI.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CredentialGenerationDto {
#[serde(default, skip_serializing_if = "CredentialFieldGenDto::is_default")]
pub username: CredentialFieldGenDto,
#[serde(default, skip_serializing_if = "CredentialFieldGenDto::is_default")]
pub password: CredentialFieldGenDto,
#[serde(default, skip_serializing_if = "CredentialFieldGenDto::is_default")]
pub token: CredentialFieldGenDto,
}
impl CredentialFieldGenDto {
pub fn is_default(&self) -> bool { *self == Self::default() }
}
impl CredentialGenerationDto {
pub fn is_default(&self) -> bool { *self == Self::default() }
pub fn generate_username(&self) -> String { self.username.generate(CREDENTIAL_UPPER_CHARS) }
pub fn generate_password(&self) -> String { self.password.generate(CREDENTIAL_UPPER_CHARS) }
pub fn generate_token(&self) -> String { self.token.generate(CREDENTIAL_MIXED_CHARS) }
/// Validation error per credential, `None` when valid.
pub fn field_errors(&self) -> [Option<String>; 3] {
[
self.username.validate(CREDENTIAL_UPPER_CHARS).err(),
self.password.validate(CREDENTIAL_UPPER_CHARS).err(),
self.token.validate(CREDENTIAL_MIXED_CHARS).err(),
]
}
fn prepare(&self, errors: &mut Vec<String>) {
for (name, err) in ["username", "password", "token"].into_iter().zip(self.field_errors()) {
if let Some(err) = err {
errors.push(format!("credential_generation.{name}: {err}"));
}
}
}
}
fn is_default_credential_generation(value: &CredentialGenerationDto) -> bool { value.is_default() }
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct ApiProxyConfigDto {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -104,6 +269,9 @@ pub struct ApiProxyConfigDto {
/// HTTP status code returned for authentication failures (default: 403).
#[serde(default = "default_auth_error_status", skip_serializing_if = "is_default_auth_error_status")]
pub auth_error_status: u16,
/// Length and charset of the credentials auto-generated for new users in the web UI.
#[serde(default, skip_serializing_if = "is_default_credential_generation")]
pub credential_generation: CredentialGenerationDto,
}
impl Default for ApiProxyConfigDto {
@@ -113,6 +281,7 @@ impl Default for ApiProxyConfigDto {
user: Vec::new(),
use_user_db: false,
auth_error_status: default_auth_error_status(),
credential_generation: CredentialGenerationDto::default(),
}
}
}
@@ -238,6 +407,7 @@ impl ApiProxyConfigDto {
self.auth_error_status
));
}
self.credential_generation.prepare(&mut errors);
if errors.is_empty() {
Ok(())
} else {
@@ -274,4 +444,120 @@ mod tests {
let deserialized_none: UserPlanDto = serde_json::from_str(&serialized_none).expect("should deserialize");
assert_eq!(plan_none, deserialized_none);
}
fn ok(field: &CredentialFieldGenDto) -> bool { field.validate("A-Z0-9").is_ok() }
fn segment(length: u16, chars: &str) -> CredentialSegmentDto {
CredentialSegmentDto { length, chars: chars.to_string(), exclude: String::new() }
}
#[test]
fn credential_generation_defaults_are_not_serialized() {
let dto = ApiProxyConfigDto::default();
let json = serde_json::to_string(&dto).unwrap();
assert!(!json.contains("credential_generation"));
let parsed: ApiProxyConfigDto = serde_json::from_str("{}").unwrap();
assert_eq!(parsed.credential_generation, CredentialGenerationDto::default());
}
#[test]
fn credential_generation_roundtrip_flat_and_segments() {
let json = r#"{"credential_generation":{"token":{"length":12,"chars":"a-z0-9","exclude":"l1"},
"username":{"segments":[{"length":3,"chars":"A-Z"},{"length":3,"chars":"0-9"}]}}}"#;
let dto: ApiProxyConfigDto = serde_json::from_str(json).unwrap();
assert_eq!(dto.credential_generation.token.length, Some(12));
assert_eq!(dto.credential_generation.username.segments.len(), 2);
let back: ApiProxyConfigDto = serde_json::from_str(&serde_json::to_string(&dto).unwrap()).unwrap();
assert_eq!(dto, back);
}
#[test]
fn credential_generation_missing_chars_uses_field_default() {
let flat: CredentialFieldGenDto = serde_json::from_str(r#"{"length":8}"#).unwrap();
assert!(flat.validate("A-Z0-9").is_ok());
let value = flat.generate("0-1");
assert_eq!(value.len(), 8);
assert!(value.chars().all(|c| c == '0' || c == '1'));
let seg: CredentialFieldGenDto =
serde_json::from_str(r#"{"segments":[{"length":2},{"length":2,"chars":"x"}]}"#).unwrap();
assert_eq!(&seg.generate("0")[..], "00xx");
}
#[test]
fn credential_generation_segments_are_ordered() {
let field = CredentialFieldGenDto::from_segments(vec![segment(3, "A-Z"), segment(3, "0-9")]);
for _ in 0..20 {
let value = field.generate("A-Z0-9");
assert_eq!(value.len(), 6);
assert!(value[..3].chars().all(|c| c.is_ascii_uppercase()), "{value}");
assert!(value[3..].chars().all(|c| c.is_ascii_digit()), "{value}");
}
}
#[test]
fn credential_generation_validation() {
// both forms given
let both = CredentialFieldGenDto { length: Some(4), segments: vec![segment(2, "A")], ..Default::default() };
assert!(both.validate("A-Z").unwrap_err().contains("not both"));
// chars/exclude alone also count as flat
let both =
CredentialFieldGenDto { chars: "A".to_string(), segments: vec![segment(2, "A")], ..Default::default() };
assert!(both.validate("A-Z").is_err());
// length bounds, flat and per segment
assert!(!ok(&CredentialFieldGenDto { length: Some(0), ..Default::default() }));
assert!(!ok(&CredentialFieldGenDto { length: Some(65), ..Default::default() }));
assert!(ok(&CredentialFieldGenDto { length: Some(64), ..Default::default() }));
let err =
CredentialFieldGenDto::from_segments(vec![segment(2, "A"), segment(0, "B")]).validate("A").unwrap_err();
assert!(err.starts_with("segment 2"), "{err}");
// total length
assert!(!ok(&CredentialFieldGenDto::from_segments(vec![segment(40, "A"), segment(25, "B")])));
assert!(ok(&CredentialFieldGenDto::from_segments(vec![segment(40, "A"), segment(24, "B")])));
// invalid chars name the char, empty effective charset
assert!(CredentialFieldGenDto { chars: "a b".to_string(), ..Default::default() }
.validate("A")
.unwrap_err()
.contains("' '"));
let empty = CredentialFieldGenDto { chars: "ab".to_string(), exclude: "ab".to_string(), ..Default::default() };
assert!(!ok(&empty));
// segments key present but empty means flat default
let none: CredentialFieldGenDto = serde_json::from_str(r#"{"segments":[]}"#).unwrap();
assert!(ok(&none));
}
#[test]
fn credential_generation_prepare_reports_field_names() {
let mut generation = CredentialGenerationDto::default();
generation.token.length = Some(0);
let mut errors = Vec::new();
generation.prepare(&mut errors);
assert_eq!(errors.len(), 1);
assert!(errors[0].starts_with("credential_generation.token"));
}
#[test]
fn credential_generation_from_segments_is_canonical() {
assert!(CredentialFieldGenDto::from_segments(vec![CredentialSegmentDto::default()]).is_default());
let single = CredentialFieldGenDto::from_segments(vec![segment(9, "a-f")]);
assert_eq!(single.length, Some(9));
assert_eq!(single.segments.len(), 0);
assert_eq!(single.to_segments(), vec![segment(9, "a-f")]);
}
#[test]
fn credential_generation_default_output_shape() {
let generation = CredentialGenerationDto::default();
let username = generation.generate_username();
assert_eq!(username.len(), 6);
assert!(username.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()));
let token = generation.generate_token();
assert_eq!(token.len(), 6);
assert!(token.chars().all(|c| c.is_ascii_alphanumeric()));
}
#[test]
fn invalid_credential_generation_falls_back_to_default() {
let bad = CredentialFieldGenDto { length: Some(0), chars: "?".to_string(), ..Default::default() };
assert_eq!(bad.generate("A-Z0-9").len(), 6);
}
}
+145
View File
@@ -36,6 +36,87 @@ pub fn generate_random_string(length: usize) -> String {
random_string
}
/// Generates a random string of `length` chars picked from `charset`.
/// Returns an empty string when `charset` is empty.
pub fn generate_random_string_from(charset: &[char], length: usize) -> String {
if charset.is_empty() {
return String::new();
}
let mut rng = fastrand::Rng::new();
(0..length).map(|_| charset[rng.usize(..charset.len())]).collect()
}
const CHARSET_EXTRA_ALLOWED: &str = "!._~-";
fn is_allowed_charset_char(c: char) -> bool { c.is_ascii_alphanumeric() || CHARSET_EXTRA_ALLOWED.contains(c) }
fn check_charset_char(c: char) -> Result<char, String> {
if is_allowed_charset_char(c) {
Ok(c)
} else {
Err(format!("character {c:?} is not allowed (allowed: ASCII letters, digits and ! . _ ~ -)"))
}
}
/// Expands a charset expression into its chars.
/// Literal chars and ranges `X-Y` are supported; `\-` and `\\` escape literals.
/// Only ASCII alphanumerics and `! . _ ~ -` are accepted.
pub fn expand_charset_expression(expr: &str) -> Result<Vec<char>, String> {
// (char, escaped)
let mut tokens = Vec::new();
let mut iter = expr.chars();
while let Some(c) = iter.next() {
if c == '\\' {
match iter.next() {
Some(escaped) => tokens.push((escaped, true)),
None => return Err("dangling escape '\\' at end of expression".to_string()),
}
} else {
tokens.push((c, false));
}
}
let mut result = Vec::new();
let mut idx = 0;
while idx < tokens.len() {
let (c, escaped) = tokens[idx];
if !escaped && c == '-' {
return Err("unescaped '-' must be between two chars to form a range, use '\\-' for a literal".to_string());
}
if tokens.get(idx + 1).is_some_and(|&(sep, sep_escaped)| sep == '-' && !sep_escaped) {
let Some(&(end, _)) = tokens.get(idx + 2) else {
return Err(format!("range starting at {c:?} has no end char"));
};
let (start, end) = (check_charset_char(c)?, check_charset_char(end)?);
if start > end {
return Err(format!("invalid range {start}-{end}: start is greater than end"));
}
for value in start..=end {
result.push(check_charset_char(value)?);
}
idx += 3;
} else {
result.push(check_charset_char(c)?);
idx += 1;
}
}
Ok(result)
}
/// Builds the effective charset: expanded `chars` minus `exclude`, de-duplicated.
pub fn build_charset(chars: &str, exclude: &str) -> Result<Vec<char>, String> {
let mut charset: Vec<char> = Vec::new();
for c in expand_charset_expression(chars)? {
if !exclude.contains(c) && !charset.contains(&c) {
charset.push(c);
}
}
if charset.is_empty() {
return Err("resulting charset is empty".to_string());
}
Ok(charset)
}
// compare 2 small vecs without HashSet
pub fn small_vecs_equal_unordered<T: PartialEq>(a: &[T], b: &[T]) -> bool {
if a.len() != b.len() {
@@ -404,3 +485,67 @@ mod test {
assert_eq!(percent_encode_unreserved("Grüße 1.ts"), "Gr%C3%BC%C3%9Fe%201.ts");
}
}
#[cfg(test)]
mod charset_tests {
use super::*;
fn expand(expr: &str) -> String { expand_charset_expression(expr).unwrap().into_iter().collect() }
#[test]
fn expands_ranges_and_literals() {
assert_eq!(expand("A-C0-2"), "ABC012");
assert_eq!(expand("abx"), "abx");
assert_eq!(expand("a-c!_"), "abc!_");
}
#[test]
fn escapes_produce_literals() {
assert_eq!(expand("a\\-z"), "a-z");
assert_eq!(expand("\\-.\\-"), "-.-");
}
#[test]
fn rejects_bad_ranges() {
assert!(expand_charset_expression("z-a").is_err());
assert!(expand_charset_expression("-a").is_err());
assert!(expand_charset_expression("a-").is_err());
assert!(expand_charset_expression("a\\").is_err());
}
#[test]
fn rejects_disallowed_chars_naming_them() {
for bad in [" ", "\t", "?", "/", "#", "%", "&", ":", "@", "+", "=", ",", ";", "*", "(", "$", "\"", "ä", "\\\\"]
{
let err = expand_charset_expression(&format!("a{bad}")).unwrap_err();
assert!(err.contains("not allowed"), "{bad:?}: {err}");
}
let err = expand_charset_expression("a?").unwrap_err();
assert!(err.contains("'?'"), "{err}");
// range spanning disallowed chars
assert!(expand_charset_expression("!-9").is_err());
}
#[test]
fn exclude_removes_and_dedups() {
let cs: String = build_charset("A-C", "B").unwrap().into_iter().collect();
assert_eq!(cs, "AC");
let cs: String = build_charset("aab", "").unwrap().into_iter().collect();
assert_eq!(cs, "ab");
}
#[test]
fn empty_effective_charset_is_error() {
assert!(build_charset("ab", "ab").is_err());
assert!(build_charset("", "").is_err());
}
#[test]
fn generator_respects_length_and_charset() {
let cs = build_charset("A-C", "").unwrap();
let value = generate_random_string_from(&cs, 40);
assert_eq!(value.len(), 40);
assert!(value.chars().all(|c| cs.contains(&c)));
assert_eq!(generate_random_string_from(&[], 5), "");
}
}