use crate::action::{Access, DataQuery};
use crate::audit::{RecordRef, plain};
use crate::ctx::Ctx;
use crate::i18n::Text;
use crate::model::{FieldMeta, Model};
use crate::render::{Render, escape};
use crate::value::Value;
use serde::Serialize;
use serde_json::Value as Json;
use std::cell::RefCell;
const DOTS: &str = "••••";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MaskStyle {
LastFour,
Email,
Full,
}
impl MaskStyle {
pub fn apply(self, value: &str) -> String {
let value = value.trim();
match self {
MaskStyle::LastFour => {
let chars: Vec<char> = value.chars().collect();
if chars.len() <= 4 {
return DOTS.into();
}
let tail: String = chars[chars.len() - 4..].iter().collect();
format!("{DOTS} {tail}")
}
MaskStyle::Email => match value.split_once('@') {
Some((local, domain)) if !domain.is_empty() => match local.chars().next() {
Some(first) => format!("{first}{DOTS}@{domain}"),
None => DOTS.into(),
},
_ => DOTS.into(),
},
MaskStyle::Full => DOTS.into(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct Masking {
pub style: MaskStyle,
pub permission: &'static str,
}
pub fn can_unmask(ctx: Option<&Ctx>, meta: &FieldMeta) -> bool {
match meta.mask {
None => true,
Some(m) => ctx.is_some_and(|c| !c.via_agent() && c.identity().can(&Access::Permission(m.permission))),
}
}
pub fn masked_text(meta: &FieldMeta, value: &str) -> String {
match meta.mask {
Some(m) => m.style.apply(value),
None => value.to_string(),
}
}
pub struct Revealed {
pub meta: &'static FieldMeta,
pub value: String,
pub record: RecordRef,
}
pub fn reveal_in<M: Model>(model: &M, field: &str) -> Option<Revealed> {
let (i, meta) = M::fields().iter().enumerate().find(|(_, m)| m.name == field && m.mask.is_some())?;
let value = model.value_at(i).and_then(|v| plain(&v)).unwrap_or_default();
Some(Revealed { meta, value, record: RecordRef::of(model) })
}
thread_local! {
static CLIENT: RefCell<Option<Ctx>> = const { RefCell::new(None) };
}
pub(crate) fn client_json<T: Serialize>(value: &T, ctx: &Ctx) -> serde_json::Result<Json> {
struct Restore(Option<Ctx>);
impl Drop for Restore {
fn drop(&mut self) {
let previous = self.0.take();
CLIENT.with(|c| *c.borrow_mut() = previous);
}
}
let _restore = Restore(CLIENT.with(|c| c.replace(Some(ctx.clone()))));
serde_json::to_value(value)
}
#[doc(hidden)]
pub mod output {
use super::{CLIENT, can_unmask, masked_text};
use crate::audit::{FieldRef, RecordRef, plain};
use crate::model::FieldMeta;
use crate::value::{FieldType, Value};
use serde::{Serialize, Serializer};
pub fn masked_output<T, S>(value: &T, meta: &'static FieldMeta, s: S) -> Result<S::Ok, S::Error>
where
T: Serialize + FieldType,
S: Serializer,
{
let hide = CLIENT.with(|c| {
c.borrow().as_ref().map(|ctx| {
let shown = can_unmask(Some(ctx), meta);
if shown {
ctx.update_trail(|t| t.revealed.push(FieldRef { model: meta.model, id: None, field: meta.name }));
}
!shown
})
});
match (hide, value.to_value()) {
(Some(true), Value::Text(text)) => s.serialize_str(&masked_text(meta, &text)),
_ => value.serialize(s),
}
}
pub fn read_output<T, S>(value: &T, meta: &'static FieldMeta, s: S) -> Result<S::Ok, S::Error>
where
T: Serialize + FieldType,
S: Serializer,
{
CLIENT.with(|c| {
if let Some(ctx) = c.borrow().as_ref() {
ctx.note_read(RecordRef { model: meta.model, id: plain(&value.to_value()) });
}
});
value.serialize(s)
}
}
pub(crate) fn strip_masked(query: &mut DataQuery, fields: &[FieldMeta], ctx: &Ctx) {
let hidden = |name: &str| fields.iter().any(|m| m.name == name && !can_unmask(Some(ctx), m));
query.filters.retain(|f| !hidden(&f.field));
query.sort.retain(|s| !hidden(&s.field));
}
fn reveal_button(r: &Render, meta: &FieldMeta, key: Option<&str>) -> String {
if !can_unmask(r.ctx, meta) {
return String::new();
}
let key = key.map(|k| format!(r#" data-zeyn-key="{}""#, escape(k))).unwrap_or_default();
let label = r.text(&Text::key("zeyn.privacy.reveal").arg("label", meta.label_text()));
format!(
r#"<button type="button" class="z-reveal" data-zeyn-reveal="{}"{key} aria-label="{label}" title="{label}"><span class="z-icon z-icon-eye" aria-hidden="true"></span></button>"#,
meta.name
)
}
pub(crate) fn cell<E: Model>(r: &Render, meta: &FieldMeta, value: &Value, row: &E) -> String {
let text = plain(value).map(|v| masked_text(meta, &v)).unwrap_or_default();
let key = RecordRef::of(row).id;
format!(r#"<span data-zeyn-masked>{}</span>{}"#, escape(&text), reveal_button(r, meta, key.as_deref()))
}
pub(crate) fn form_field(out: &mut String, r: &Render, meta: &FieldMeta, value: &Value) {
let id = format!("{}-{}", r.scope, meta.name);
let text = plain(value).map(|v| masked_text(meta, &v)).unwrap_or_default();
out.push_str(&format!(
r#"<div class="z-field"><label for="{id}">{}</label><div class="z-masked-field"><input type="text" id="{id}" name="{}" data-testid="{}.field.{}" aria-describedby="{id}-error" readonly data-zeyn-masked value="{}">{}</div><p class="z-field-error" id="{id}-error" aria-live="polite"></p></div>"#,
r.label(meta),
meta.name,
r.scope,
meta.name,
escape(&text),
reveal_button(r, meta, None),
));
}
fn key_of<M: Model>(object: &serde_json::Map<String, Json>) -> Option<String> {
M::fields()
.iter()
.find(|m| m.key)
.and_then(|m| object.get(m.name))
.map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()))
}
pub(crate) fn protect_object<M: Model>(value: &mut Json, ctx: &Ctx) {
let Some(object) = value.as_object_mut() else { return };
if M::SENSITIVE {
ctx.note_read(RecordRef { model: M::NAME, id: key_of::<M>(object) });
}
let mut revealed = Vec::new();
for meta in M::fields().iter().filter(|m| m.mask.is_some()) {
let Some(Json::String(text)) = object.get(meta.name) else { continue };
if can_unmask(Some(ctx), meta) {
revealed.push(meta.name);
} else {
let masked = masked_text(meta, text);
object.insert(meta.name.to_string(), Json::String(masked));
}
}
if !revealed.is_empty() {
let id = key_of::<M>(object);
ctx.update_trail(|t| {
for field in revealed {
t.revealed.push(crate::audit::FieldRef { model: M::NAME, id: id.clone(), field });
}
});
}
}