use bytes::Bytes;
use serde::{Deserialize, Serialize};
pub const MAX_NDEF_RECORDS: usize = 64;
pub const MAX_NDEF_BYTES: usize = 32 * 1024;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct NdefRecord {
pub record_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
pub media_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
pub encoding: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
pub lang: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
pub data: Option<String>,
#[serde(
default,
rename = "dataBase64",
skip_serializing_if = "Option::is_none",
with = "opt_base64"
)]
#[cfg_attr(feature = "ts", ts(rename = "dataBase64", optional, type = "string"))]
#[schemars(with = "Option<String>")]
pub data_base64: Option<Bytes>,
}
impl NdefRecord {
pub fn text(text: impl Into<String>) -> Self {
Self {
record_type: "text".into(),
data: Some(text.into()),
..Self::default()
}
}
pub fn url(url: impl Into<String>) -> Self {
Self {
record_type: "url".into(),
data: Some(url.into()),
..Self::default()
}
}
pub fn payload(&self) -> Vec<u8> {
match (&self.data, &self.data_base64) {
(Some(text), _) => text.as_bytes().to_vec(),
(None, Some(bytes)) => bytes.to_vec(),
(None, None) => Vec::new(),
}
}
pub fn validate(&self) -> Result<(), String> {
let kind = self.record_type.as_str();
if kind.is_empty() || kind.len() > 255 {
return Err("recordType must be 1 to 255 characters".into());
}
if self.data.is_some() && self.data_base64.is_some() {
return Err("set either data or dataBase64, not both".into());
}
let has_data = self.data.is_some() || self.data_base64.is_some();
match kind {
"empty" if has_data || self.media_type.is_some() => {
Err("an empty record has no data or mediaType".into())
}
"empty" | "mime" | "unknown" => Ok(()),
"text" | "url" | "absolute-url" if !has_data => {
Err(format!("a {kind} record needs data"))
}
"text" => match self.encoding.as_deref() {
None | Some("utf-8" | "utf-16" | "utf-16be" | "utf-16le") => Ok(()),
Some(other) => Err(format!("unsupported text encoding `{other}`")),
},
"url" | "absolute-url" => Ok(()),
"smart-poster" => {
Err("smart-poster records (nested messages) are not supported".into())
}
local if local.starts_with(':') => {
Err("local record types are only allowed inside nested messages".into())
}
external if external.contains(':') => Ok(()),
other => Err(format!("unknown recordType `{other}`")),
}
}
}
pub fn validate_ndef_message(records: &[NdefRecord]) -> Result<(), String> {
if records.len() > MAX_NDEF_RECORDS {
return Err(format!("at most {MAX_NDEF_RECORDS} records"));
}
let mut total = 0usize;
for (i, record) in records.iter().enumerate() {
record.validate().map_err(|e| format!("record {i}: {e}"))?;
total += record.payload().len();
}
if total > MAX_NDEF_BYTES {
return Err(format!("records hold more than {MAX_NDEF_BYTES} bytes"));
}
Ok(())
}
pub fn serial_number(uid: &str) -> Result<String, String> {
let hex: String = uid
.chars()
.filter(|c| !matches!(c, ':' | '-' | ' '))
.collect();
if hex.is_empty() || !hex.len().is_multiple_of(2) || !hex.chars().all(|c| c.is_ascii_hexdigit())
{
return Err(format!("uid `{uid}` is not hex bytes, e.g. 04a2b3c4d5e6f7"));
}
if hex.len() > 20 {
return Err(format!("uid `{uid}` is longer than 10 bytes"));
}
let lower = hex.to_ascii_lowercase();
let bytes: Vec<&str> = (0..lower.len())
.step_by(2)
.filter_map(|i| lower.get(i..i + 2))
.collect();
Ok(bytes.join(":"))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "camelCase")]
pub struct NfcReading {
pub serial_number: String,
pub records: Vec<NdefRecord>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "kebab-case")]
pub enum NfcRejection {
NoCard,
ReadOnly,
NotEmpty,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum NfcEvent {
Reading(NfcReading),
#[serde(rename_all = "camelCase")]
ReadError {
serial_number: String,
},
#[serde(rename_all = "camelCase")]
Written {
serial_number: String,
records: Vec<NdefRecord>,
},
#[serde(rename_all = "camelCase")]
MadeReadOnly {
serial_number: String,
},
Rejected {
reason: NfcRejection,
message: String,
},
}
mod opt_base64 {
use bytes::Bytes;
use serde::{de, Deserialize, Deserializer, Serializer};
pub(crate) fn serialize<S: Serializer>(data: &Option<Bytes>, s: S) -> Result<S::Ok, S::Error> {
match data {
Some(bytes) => s.serialize_str(&crate::base64::encode(bytes)),
None => s.serialize_none(),
}
}
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Bytes>, D::Error> {
let text = Option::<std::borrow::Cow<'de, str>>::deserialize(d)?;
text.map(|t| {
crate::base64::decode(&t)
.map(Bytes::from)
.map_err(de::Error::custom)
})
.transpose()
}
}