Merge pull request #374 from euzu/feature/user_edit_change_playlist

Changing playlist in user editor now possible

Custom video response optimizatation
This commit is contained in:
euzu
2025-10-15 20:46:27 +02:00
committed by GitHub
8 changed files with 255 additions and 146 deletions
+114 -59
View File
@@ -1,11 +1,13 @@
use std::path::PathBuf;
use crate::api::model::AppState;
use crate::model::ProxyUserCredentials;
use crate::model::{ApiProxyConfig, ProxyUserCredentials, TargetUser};
use crate::repository::user_repository::store_api_user;
use axum::response::IntoResponse;
use axum::Router;
use serde_json::json;
use shared::model::{ApiProxyConfigDto, ProxyUserCredentialsDto};
use std::sync::Arc;
use shared::utils::mask_credentials;
async fn save_config_api_proxy_user(
method: axum::http::Method,
@@ -13,76 +15,129 @@ async fn save_config_api_proxy_user(
axum::extract::Path(target_name): axum::extract::Path<String>,
axum::extract::Json(mut credential): axum::extract::Json<ProxyUserCredentialsDto>,
) -> impl axum::response::IntoResponse + Send {
let virtual_file = PathBuf::from("api_proxy");
let _lock = app_state.app_config.file_locks.write_lock(&virtual_file).await;
credential.prepare();
if let Err(err) = credential.validate() {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response();
return (
axum::http::StatusCode::BAD_REQUEST,
axum::Json(json!({"error": err.to_string()}))
).into_response();
}
let is_update = method == axum::http::Method::PUT;
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
let mut api_proxy = (*old_api_proxy).clone();
let mut target_found = false;
for target_user in &api_proxy.user {
if target_user.target == target_name {
target_found = true;
}
for user in &target_user.credentials {
if !is_update && user.username == credential.username {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("Duplicate username {}", &credential.username)}))).into_response();
}
if let (Some(u), Some(c)) = (&user.token, &credential.token) {
if u == c && user.username != credential.username {
return (
axum::http::StatusCode::BAD_REQUEST,
axum::Json(json!({"error": format!("Duplicate token {c}")}))
).into_response();
}
let mut api_proxy = if let Some(old) = app_state.app_config.api_proxy.load().clone() {
(*old).clone()
} else {
ApiProxyConfig::default()
};
// ---------- Search for existing Target and existing User ----------
let mut existing_target_index: Option<usize> = None; // index of target (target_name), falls vorhanden
let mut existing_user_target_index: Option<usize> = None; // index of existing users target
let mut existing_user_index: Option<usize> = None; // index of the user in the targets credentials list
for (t_idx, target_user) in api_proxy.user.iter().enumerate() {
if target_user.target == target_name {
existing_target_index = Some(t_idx);
}
for (u_idx, user) in target_user.credentials.iter().enumerate() {
if let (Some(u), Some(c)) = (&user.token, &credential.token) {
if u == c && user.username != credential.username {
return ( axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("Duplicate token {}", mask_credentials(c))})) ).into_response();
}
}
}
if is_update && !target_found {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("Target not found {target_name}")}))).into_response();
}
for target in &mut api_proxy.user {
if target.target == target_name {
if is_update {
let mut updated = false;
for user in &mut target.credentials {
if user.username == credential.username {
*user = ProxyUserCredentials::from(&credential);
updated = true;
break;
}
}
if !updated {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("User {} not found in target {target_name}", credential.username)}))).into_response();
}
} else {
target.credentials.push(ProxyUserCredentials::from(&credential));
if user.username == credential.username {
// if not an update und username exists -> Error (duplicate username)
if !is_update {
return (
axum::http::StatusCode::BAD_REQUEST,
axum::Json(json!({"error": format!("Duplicate username {}", &credential.username)}))
).into_response();
}
// mark position of user (for update / move)
existing_user_target_index = Some(t_idx);
existing_user_index = Some(u_idx);
}
}
let new_api_proxy = Arc::new(api_proxy);
if new_api_proxy.use_user_db {
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
} else {
let config = app_state.app_config.config.load();
let backup_dir = config.get_backup_dir();
let paths = app_state.app_config.paths.load();
if let Some(err) = crate::api::endpoints::v1_api_config::intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(&*new_api_proxy), paths.api_proxy_file_path.as_str()) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
}
// Udate state after successful save
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
}
// ---------- if update but no user found -> Error ----------
if is_update && existing_user_index.is_none() {
return (
axum::http::StatusCode::BAD_REQUEST,
axum::Json(json!({"error": format!("User {} not found", credential.username)}))
).into_response();
}
// ---------- create target if new target does not exist ----------
let target_idx = if let Some(idx) = existing_target_index {
idx
} else {
api_proxy.user.push(TargetUser {
target: target_name.clone(),
credentials: vec![],
});
api_proxy.user.len() - 1
};
if is_update {
let mut remove_empty_target = false;
// existing_user_index and existing_user_target_index exists at this point
let user_idx = existing_user_index.unwrap();
let user_target_idx = existing_user_target_index.unwrap();
if user_target_idx == target_idx {
// Update
api_proxy.user[user_target_idx].credentials[user_idx] = ProxyUserCredentials::from(&credential);
} else {
// Move: remove from old target and insert into new target
api_proxy.user[user_target_idx].credentials.remove(user_idx);
api_proxy.user[target_idx].credentials.push(ProxyUserCredentials::from(&credential));
remove_empty_target = api_proxy.user[user_target_idx].credentials.is_empty();
}
if remove_empty_target {
api_proxy.user.retain(|t| !t.credentials.is_empty());
}
} else {
// new user
api_proxy.user[target_idx].credentials.push(ProxyUserCredentials::from(&credential));
}
let new_api_proxy = Arc::new(api_proxy);
if new_api_proxy.use_user_db {
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user) {
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(json!({"error": err.to_string()}))
).into_response();
}
} else {
let config = app_state.app_config.config.load();
let backup_dir = config.get_backup_dir();
let paths = app_state.app_config.paths.load();
if let Some(err) = crate::api::endpoints::v1_api_config::intern_save_config_api_proxy(
backup_dir.as_ref(),
&ApiProxyConfigDto::from(&*new_api_proxy),
paths.api_proxy_file_path.as_str(),
) {
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(json!({"error": err.to_string()}))
).into_response();
}
}
// Update state after successful save
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
axum::http::StatusCode::OK.into_response()
}
@@ -1,19 +1,26 @@
use crate::api::model::StreamError;
use crate::api::model::TransportStreamBuffer;
use crate::api::model::{StreamError, TransportStreamBuffer};
use bytes::Bytes;
use futures::Stream;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::{
pin::Pin,
task::{Context, Poll},
time::Duration,
};
use std::future::Future;
use tokio::time::{Sleep, sleep};
pub struct CustomVideoStream {
buffer: TransportStreamBuffer,
delay: Option<Pin<Box<Sleep>>>,
frame_interval: Duration,
}
impl CustomVideoStream {
pub fn new(buffer: TransportStreamBuffer) -> Self {
Self {
buffer
buffer,
delay: None,
frame_interval: Duration::from_millis(40), // ca. 25 FPS
}
}
}
@@ -21,7 +28,24 @@ impl CustomVideoStream {
impl Stream for CustomVideoStream {
type Item = Result<Bytes, StreamError>;
fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>,) -> Poll<Option<Self::Item>> {
Poll::Ready(Some(Ok(self.buffer.next_chunk())))
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
// is a timer active ?
if let Some(ref mut delay) = self.delay {
if delay.as_mut().poll(cx).is_pending() {
return Poll::Pending;
}
}
let chunk = self.buffer.next_chunk();
// new sleep-timer
self.delay = Some(Box::pin(sleep(self.frame_interval)));
// Pre-register waker so we get notified even if the consumer doesn't poll again immediately.
if let Some(ref mut delay) = self.delay {
let _ = delay.as_mut().poll(cx);
}
Poll::Ready(Some(Ok(chunk)))
}
}
@@ -60,7 +60,7 @@ fn encode_pcr(pcr: u64) -> [u8; 6] {
]
}
type TsInfoExtraction = (Vec<(usize, Option<(usize, usize, u16)>)>, Vec<(u16, u8)>);
type TsInfoExtraction = (Vec<(usize, Option<(usize, usize, u16)>)>, HashMap<u16, u8>);
/// Extracts PTS and DTS info from MPEG-TS data.
/// Returns a vector of tuples containing:
@@ -158,30 +158,27 @@ pub fn extract_pts_dts_indices_with_continuity(ts_data: &[u8]) -> TsInfoExtracti
result[first_dts_idx] = (idx, Some((pts, dts, (avg_diff & 0xFFFF) as u16)));
}
}
let mut vec = Vec::with_capacity(continuity_counters.len());
vec.extend(continuity_counters.iter().map(|(&k, &v)| (k, v)));
(result, vec)
}
/// Replace PTS and DTS timestamps in the TS packet slice
fn replace_pts_dts(packet_slice: &[u8], pts_index: usize, dts_index: usize, new_presentation_ts: u64, new_decoding_ts: u64) -> Vec<u8> {
let before_pts = &packet_slice[..pts_index];
let between_pts_dts = &packet_slice[pts_index + 5..dts_index];
let after_dts = &packet_slice[dts_index + 5..];
let new_presentation_ts_bytes = encode_timestamp(new_presentation_ts);
let new_decoding_ts_bytes = encode_timestamp(new_decoding_ts);
let mut new_packet = Vec::with_capacity(packet_slice.len());
new_packet.extend_from_slice(before_pts);
new_packet.extend_from_slice(&new_presentation_ts_bytes);
new_packet.extend_from_slice(between_pts_dts);
new_packet.extend_from_slice(&new_decoding_ts_bytes);
new_packet.extend_from_slice(after_dts);
new_packet
(result, continuity_counters)
}
//
// /// Replace PTS and DTS timestamps in the TS packet slice
// fn replace_pts_dts(packet_slice: &[u8], pts_index: usize, dts_index: usize, new_presentation_ts: u64, new_decoding_ts: u64) -> Vec<u8> {
// let before_pts = &packet_slice[..pts_index];
// let between_pts_dts = &packet_slice[pts_index + 5..dts_index];
// let after_dts = &packet_slice[dts_index + 5..];
//
// let new_presentation_ts_bytes = encode_timestamp(new_presentation_ts);
// let new_decoding_ts_bytes = encode_timestamp(new_decoding_ts);
//
// let mut new_packet = Vec::with_capacity(packet_slice.len());
// new_packet.extend_from_slice(before_pts);
// new_packet.extend_from_slice(&new_presentation_ts_bytes);
// new_packet.extend_from_slice(between_pts_dts);
// new_packet.extend_from_slice(&new_decoding_ts_bytes);
// new_packet.extend_from_slice(after_dts);
//
// new_packet
// }
/// Finds TS alignment by checking for 0x47 sync byte every 188 bytes
fn find_ts_alignment(buf: &[u8]) -> Option<usize> {
@@ -238,8 +235,8 @@ pub struct TransportStreamBuffer {
timestamp_offset: u64,
length: usize,
stream_duration_90khz: u64, // Duration in 90kHz units
initial_continuity_counters: Arc<Vec<(u16,u8)>>,
continuity_counters: Vec<(u16,u8)>,
initial_continuity_counters: Arc<HashMap<u16,u8>>,
continuity_counters: HashMap<u16,u8>,
}
impl Clone for TransportStreamBuffer {
@@ -289,77 +286,72 @@ impl TransportStreamBuffer {
}
}
/// Returns next chunks with adjusted PTS/DTS and PCR
/// Returns next chunks with adjusted PTS/DTS and PCR
pub fn next_chunk(&mut self) -> Bytes {
let mut bytes = BytesMut::with_capacity(CHUNK_SIZE);
// we send this amount of packets in one chunk
// Change continuity_counters to HashMap<u16, u8> instead of Vec<(u16,u8)>
// to avoid O(n) searches per PID. (Change type in struct definition)
// continuity_counters: HashMap<u16, u8>,
let mut packets_remaining = PACKET_COUNT;
while packets_remaining > 0 {
if self.current_pos >= self.length {
// Loop back and update timestamp offset
self.current_pos = 0;
let new_offset = (self.timestamp_offset + self.stream_duration_90khz) % MAX_PTS_DTS;
self.timestamp_offset = new_offset;
self.timestamp_offset = (self.timestamp_offset + self.stream_duration_90khz) % MAX_PTS_DTS;
self.current_dts = 0;
}
let current_pos = self.current_pos;
let (packet_start, pts_dts_maybe) = self.packet_indices[current_pos];
let (packet_start, pts_dts_maybe) = self.packet_indices[self.current_pos];
let packet = &self.buffer[packet_start..packet_start + TS_PACKET_SIZE];
let mut new_packet = packet.to_vec();
// Reuse stack array instead of heap allocation
let mut new_packet = [0u8; TS_PACKET_SIZE];
new_packet.copy_from_slice(packet);
// update continuity counter
let pid = (u16::from(new_packet[1] & 0x1F) << 8) | u16::from(new_packet[2]);
// let counter = &mut self.continuity_counters[pid as usize];
// ---- Continuity Counter ----
// let pid = (u16::from(new_packet[1] & 0x1F) << 8) | u16::from(new_packet[2]);
// let counter = self.continuity_counters.entry(pid).or_insert(0);
// new_packet[3] = (new_packet[3] & 0xF0) | (*counter & 0x0F);
// *counter = (*counter + 1) % 16;
// Find the entry with this PID (mutable), or insert a new entry if it doesn't exist
if let Some((_pid, counter)) = self.continuity_counters.iter_mut().find(|(p, _)| *p == pid) {
new_packet[3] = (new_packet[3] & 0xF0) | (*counter & 0x0F);
let pid = (u16::from(new_packet[1] & 0x1F) << 8) | u16::from(new_packet[2]);
let afc = (new_packet[3] >> 4) & 0b11;
let has_payload = (afc & 0x01) != 0;
let counter = self.continuity_counters.entry(pid).or_insert(0);
new_packet[3] = (new_packet[3] & 0xF0) | (*counter & 0x0F);
if pid != 0x1FFF && has_payload {
*counter = (*counter + 1) % 16;
} else {
// This should not happen because we scan the entire pids.
// PID not present yet, insert new entry with counter = 0
self.continuity_counters.push((pid, 1)); // Start at 1, since we immediately use the counter
new_packet[3] &= 0xF0;
}
// adjust PCR based on the original PCR, then add the offset
// ---- PCR update ----
let afc = (new_packet[3] >> 4) & 0b11;
if afc == 2 || afc == 3 {
let adaptation_len = new_packet[4] as usize;
if adaptation_len > 0 {
if adaptation_len >= 7 {
let flags = new_packet[5];
if (flags & ADAPTATION_FIELD_FLAG_PCR) != 0 {
let pcr_pos = 6;
if new_packet.len() >= pcr_pos + 6 {
// read original PCR
let orig_pcr = decode_pcr(&new_packet[pcr_pos..pcr_pos + 6]);
// Apply PCR offset; PCR runs at 27 MHz, so multiply by 300 to convert from 90 kHz to 27 MHz
let offset = self.timestamp_offset * 300;
let new_pcr = (orig_pcr + offset) % MAX_PCR;
let pcr_bytes = encode_pcr(new_pcr);
new_packet[pcr_pos..pcr_pos + 6].copy_from_slice(&pcr_bytes);
}
let orig_pcr = decode_pcr(&new_packet[pcr_pos..pcr_pos + 6]);
let new_pcr = (orig_pcr + self.timestamp_offset * 300) % MAX_PCR;
let pcr_bytes = encode_pcr(new_pcr);
new_packet[pcr_pos..pcr_pos + 6].copy_from_slice(&pcr_bytes);
}
}
}
// adjust PTS/DTS
// ---- PTS/DTS update ----
if let Some((pts_offset, dts_offset, _diff)) = pts_dts_maybe {
let orig_dts = decode_timestamp(&new_packet[dts_offset..dts_offset + 5]);
let new_decoding_ts = (orig_dts + self.timestamp_offset) % MAX_PTS_DTS;
let decoding_ts_orig = decode_timestamp(&new_packet[dts_offset..dts_offset + 5]);
let presentation_ts_orig = decode_timestamp(&new_packet[pts_offset..pts_offset + 5]);
let new_decoding_ts = (decoding_ts_orig + self.timestamp_offset) % MAX_PTS_DTS;
let new_presentation_ts = (presentation_ts_orig + self.timestamp_offset) % MAX_PTS_DTS;
let orig_presentation_ts = decode_timestamp(&new_packet[pts_offset..pts_offset + 5]);
let new_presentation_ts = (orig_presentation_ts + self.timestamp_offset) % MAX_PTS_DTS;
let replaced = replace_pts_dts(&new_packet, pts_offset, dts_offset, new_presentation_ts, new_decoding_ts);
new_packet = replaced;
// Replace in-place, no new Vec allocation
new_packet[pts_offset..pts_offset + 5].copy_from_slice(&encode_timestamp(new_decoding_ts));
new_packet[dts_offset..dts_offset + 5].copy_from_slice(&encode_timestamp(new_presentation_ts));
}
bytes.extend_from_slice(&new_packet);
+1 -1
View File
@@ -71,7 +71,7 @@
--text-color: #ffffff;
--modest-text-color: #a7abb4;
--background-color: #0a0b10;
--border-color: rgba(90, 140, 255, 0.25);
--border-color: rgba(60, 100, 195, 0.25);
--input-background-color: rgba(255, 255, 255, 0);
--input-border-color: var(--border-color);
--input-focus-border-color: rgba(80, 150, 255, 0.8);
+1 -1
View File
@@ -12,7 +12,7 @@
align-items: center;
overflow: hidden;
box-sizing: border-box;
padding-left: var(--padding-mini);
padding-right: var(--padding-mini);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius);
background-color: var(--input-background-color);
+22 -9
View File
@@ -37,7 +37,7 @@ pub fn UserEdit() -> Html {
}
});
let handle_back = {
let handle_cancel = {
let userlist_ctx = userlist_ctx.clone();
Callback::from(move |_| {
userlist_ctx.active_page.set(UserlistPage::List);
@@ -45,6 +45,13 @@ pub fn UserEdit() -> Html {
})
};
let handle_back = {
let handle_cancel = handle_cancel.clone();
Callback::from(move |_| {
handle_cancel.emit(());
})
};
let handle_user_save = {
let userlist = userlist_ctx.clone();
let handleback = handle_back.clone();
@@ -56,16 +63,22 @@ pub fn UserEdit() -> Html {
let userlist = userlist.clone();
let translate = translate.clone();
spawn_local(async move {
match if is_update { services.user.update_user(target.clone(), user.clone()).await } else { services.user.create_user(target.clone(), user.clone()).await } {
let save_result = if is_update {
services.user.update_user(target.clone(), user.clone()).await
} else {
services.user.create_user(target.clone(), user.clone()).await
};
match save_result {
Ok(()) => {
let new_user = Rc::new(TargetUser {target: target.clone(), credentials: Rc::new(user.clone()) });
let new_user_list = if let Some(user_list) = userlist.users.as_ref() {
let mut new_list: Vec<Rc<TargetUser>> = user_list.iter().map(|target_user| {
let mut cloned = target_user.as_ref().clone();
if is_update && cloned.target == target && cloned.credentials.username == user.username {
cloned.credentials = Rc::new(user.clone());
}
Rc::new(cloned)
let mut new_list: Vec<Rc<TargetUser>> = user_list.iter().map(|target_user| {
let mut cloned_target = target_user.as_ref().clone();
if is_update && cloned_target.credentials.username == user.username {
cloned_target.credentials = Rc::new(user.clone());
cloned_target.target = target.clone();
}
Rc::new(cloned_target)
}).collect();
if !is_update {
@@ -98,7 +111,7 @@ pub fn UserEdit() -> Html {
</div>
<div class="tp__userlist-edit__body tp__list-create__body">
<Card>
<ProxyUserCredentialsForm server={server.clone()} targets={targets.clone()} user={(*userlist_ctx.selected_user).clone()} on_save={handle_user_save}/>
<ProxyUserCredentialsForm server={server.clone()} targets={targets.clone()} user={(*userlist_ctx.selected_user).clone()} on_save={handle_user_save} on_cancel={handle_cancel}/>
</Card>
</div>
</div>
@@ -1,5 +1,6 @@
use std::rc::Rc;
use chrono::{Duration, Utc};
use log::debug;
use yew::prelude::*;
use yew_i18n::use_translation;
use shared::model::{ApiProxyServerInfoDto, ConfigTargetDto, ProxyType, ProxyUserCredentialsDto, ProxyUserStatus};
@@ -39,6 +40,7 @@ pub struct ProxyUserCredentialsFormProps {
pub targets: Rc<Vec<Rc<ConfigTargetDto>>>,
pub server: Rc<Vec<ApiProxyServerInfoDto>>,
pub on_save: Callback<(bool, String, ProxyUserCredentialsDto)>,
pub on_cancel: Callback<()>,
}
#[function_component]
@@ -116,6 +118,13 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
);
}
let handle_cancel = {
let oncancel = props.on_cancel.clone();
Callback::from(move |_| {
oncancel.emit(());
})
};
let handle_save_user = {
let user = form_state.clone();
let original = props.user.clone();
@@ -125,8 +134,11 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
let onsave = props.on_save.clone();
let is_update = update.clone();
Callback::from(move |_| {
let nothing_to_save = || services.toastr.warning(translate_clone.t("MESSAGES.SAVE.USER.NOTHING_TO_SAVE"));
if let Some(target_name) = (*target).as_ref().cloned() {
if user.modified() {
let original_target = original.as_ref().map(|u| u.target.clone()).unwrap_or_default();
let target_changed = target_name != original_target;
if target_changed || user.modified() {
let user = user.data();
if let Err(err) = user.validate() {
services.toastr.error(err.to_string());
@@ -134,14 +146,16 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
match original.as_ref().map(|t| t.credentials.clone()) {
None => onsave.emit((*is_update, target_name, user.clone())),
Some(original_user) => {
if &(*original_user) != user {
if target_changed || &(*original_user) != user {
onsave.emit((*is_update, target_name, user.clone()));
} else {
nothing_to_save();
}
}
};
}
} else {
services.toastr.warning(translate_clone.t("MESSAGES.SAVE.USER.NOTHING_TO_SAVE"));
nothing_to_save();
}
} else {
services.toastr.error(translate_clone.t("MESSAGES.SAVE.USER.TARGET_NOT_SELECTED"));
@@ -227,6 +241,10 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
icon="Save"
title={ translate.t("LABEL.SAVE")}
onclick={handle_save_user}></TextButton>
<TextButton class="secondary" name="cancel_user"
icon="Cancel"
title={ translate.t("LABEL.CANCEL")}
onclick={handle_cancel}></TextButton>
</div>
</div>
}
+7
View File
@@ -99,6 +99,13 @@ pub fn truncate_string(s: &str, max_len: usize) -> String {
}
}
pub fn mask_credentials(s: &str) -> String {
match s.chars().next() {
Some(first) => format!("{}...", first),
None => "...".to_string(),
}
}
#[cfg(test)]
mod test {
use std::collections::HashSet;