1use bytes::Bytes;
9use serde::{Deserialize, Serialize};
10
11pub const MAX_NDEF_RECORDS: usize = 64;
13
14pub const MAX_NDEF_BYTES: usize = 32 * 1024;
17
18#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
32#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
33#[serde(rename_all = "camelCase", deny_unknown_fields)]
34pub struct NdefRecord {
35 pub record_type: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 #[cfg_attr(feature = "ts", ts(optional))]
41 pub media_type: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 #[cfg_attr(feature = "ts", ts(optional))]
45 pub id: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 #[cfg_attr(feature = "ts", ts(optional))]
50 pub encoding: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 #[cfg_attr(feature = "ts", ts(optional))]
54 pub lang: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
58 #[cfg_attr(feature = "ts", ts(optional))]
59 pub data: Option<String>,
60 #[serde(
62 default,
63 rename = "dataBase64",
64 skip_serializing_if = "Option::is_none",
65 with = "opt_base64"
66 )]
67 #[cfg_attr(feature = "ts", ts(rename = "dataBase64", optional, type = "string"))]
68 #[schemars(with = "Option<String>")]
69 pub data_base64: Option<Bytes>,
70}
71
72impl NdefRecord {
73 pub fn text(text: impl Into<String>) -> Self {
79 Self {
80 record_type: "text".into(),
81 data: Some(text.into()),
82 ..Self::default()
83 }
84 }
85
86 pub fn url(url: impl Into<String>) -> Self {
88 Self {
89 record_type: "url".into(),
90 data: Some(url.into()),
91 ..Self::default()
92 }
93 }
94
95 pub fn payload(&self) -> Vec<u8> {
97 match (&self.data, &self.data_base64) {
98 (Some(text), _) => text.as_bytes().to_vec(),
99 (None, Some(bytes)) => bytes.to_vec(),
100 (None, None) => Vec::new(),
101 }
102 }
103
104 pub fn validate(&self) -> Result<(), String> {
106 let kind = self.record_type.as_str();
107 if kind.is_empty() || kind.len() > 255 {
108 return Err("recordType must be 1 to 255 characters".into());
109 }
110 if self.data.is_some() && self.data_base64.is_some() {
111 return Err("set either data or dataBase64, not both".into());
112 }
113 let has_data = self.data.is_some() || self.data_base64.is_some();
114 match kind {
115 "empty" if has_data || self.media_type.is_some() => {
116 Err("an empty record has no data or mediaType".into())
117 }
118 "empty" | "mime" | "unknown" => Ok(()),
119 "text" | "url" | "absolute-url" if !has_data => {
120 Err(format!("a {kind} record needs data"))
121 }
122 "text" => match self.encoding.as_deref() {
123 None | Some("utf-8" | "utf-16" | "utf-16be" | "utf-16le") => Ok(()),
124 Some(other) => Err(format!("unsupported text encoding `{other}`")),
125 },
126 "url" | "absolute-url" => Ok(()),
127 "smart-poster" => {
128 Err("smart-poster records (nested messages) are not supported".into())
129 }
130 local if local.starts_with(':') => {
131 Err("local record types are only allowed inside nested messages".into())
132 }
133 external if external.contains(':') => Ok(()),
134 other => Err(format!("unknown recordType `{other}`")),
135 }
136 }
137}
138
139pub fn validate_ndef_message(records: &[NdefRecord]) -> Result<(), String> {
147 if records.len() > MAX_NDEF_RECORDS {
148 return Err(format!("at most {MAX_NDEF_RECORDS} records"));
149 }
150 let mut total = 0usize;
151 for (i, record) in records.iter().enumerate() {
152 record.validate().map_err(|e| format!("record {i}: {e}"))?;
153 total += record.payload().len();
154 }
155 if total > MAX_NDEF_BYTES {
156 return Err(format!("records hold more than {MAX_NDEF_BYTES} bytes"));
157 }
158 Ok(())
159}
160
161pub fn serial_number(uid: &str) -> Result<String, String> {
173 let hex: String = uid
174 .chars()
175 .filter(|c| !matches!(c, ':' | '-' | ' '))
176 .collect();
177 if hex.is_empty() || !hex.len().is_multiple_of(2) || !hex.chars().all(|c| c.is_ascii_hexdigit())
178 {
179 return Err(format!("uid `{uid}` is not hex bytes, e.g. 04a2b3c4d5e6f7"));
180 }
181 if hex.len() > 20 {
182 return Err(format!("uid `{uid}` is longer than 10 bytes"));
183 }
184 let lower = hex.to_ascii_lowercase();
185 let bytes: Vec<&str> = (0..lower.len())
186 .step_by(2)
187 .filter_map(|i| lower.get(i..i + 2))
188 .collect();
189 Ok(bytes.join(":"))
190}
191
192#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
203#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
204#[serde(rename_all = "camelCase")]
205pub struct NfcReading {
206 pub serial_number: String,
208 pub records: Vec<NdefRecord>,
210}
211
212#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
214#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
215#[serde(rename_all = "kebab-case")]
216pub enum NfcRejection {
217 NoCard,
219 ReadOnly,
221 NotEmpty,
223}
224
225#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
236#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
237#[serde(tag = "type", rename_all = "kebab-case")]
238pub enum NfcEvent {
239 Reading(NfcReading),
241 #[serde(rename_all = "camelCase")]
243 ReadError {
244 serial_number: String,
246 },
247 #[serde(rename_all = "camelCase")]
249 Written {
250 serial_number: String,
252 records: Vec<NdefRecord>,
254 },
255 #[serde(rename_all = "camelCase")]
257 MadeReadOnly {
258 serial_number: String,
260 },
261 Rejected {
263 reason: NfcRejection,
265 message: String,
267 },
268}
269
270mod opt_base64 {
272 use bytes::Bytes;
273 use serde::{de, Deserialize, Deserializer, Serializer};
274
275 pub(crate) fn serialize<S: Serializer>(data: &Option<Bytes>, s: S) -> Result<S::Ok, S::Error> {
276 match data {
277 Some(bytes) => s.serialize_str(&crate::base64::encode(bytes)),
278 None => s.serialize_none(),
279 }
280 }
281
282 pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Bytes>, D::Error> {
283 let text = Option::<std::borrow::Cow<'de, str>>::deserialize(d)?;
284 text.map(|t| {
285 crate::base64::decode(&t)
286 .map(Bytes::from)
287 .map_err(de::Error::custom)
288 })
289 .transpose()
290 }
291}