Skip to main content

sl_map_web/
usb_notecard.rs

1//! Helpers for accepting a USB notecard from either an upload, a textarea,
2//! or a reference to a previously-saved notecard by id.
3
4use axum::extract::Multipart;
5use sl_types::map::USBNotecard;
6use uuid::Uuid;
7
8use crate::error::Error;
9
10/// Fields extracted from the multipart form for the notecard endpoints.
11#[derive(Debug, Default)]
12pub struct NotecardForm {
13    /// the parsed USB notecard, when the form supplied the body inline
14    /// (file upload or pasted text).
15    pub notecard: Option<USBNotecard>,
16    /// the id of a previously-saved notecard the caller wants to reuse.
17    /// Mutually exclusive with an inline body; the handler is responsible
18    /// for resolving the id to a body via the auth-checked lookup path.
19    pub notecard_id: Option<Uuid>,
20    /// uniform border in regions on every side (mutually exclusive with the
21    /// per-side fields below).
22    pub border_regions: Option<u16>,
23    /// border added on the north (+y) side.
24    pub border_north: Option<u16>,
25    /// border added on the south (-y) side.
26    pub border_south: Option<u16>,
27    /// border added on the east (+x) side.
28    pub border_east: Option<u16>,
29    /// border added on the west (-x) side.
30    pub border_west: Option<u16>,
31}
32
33impl NotecardForm {
34    /// Resolve the four per-side border values, applying the `border_regions`
35    /// shorthand if it was supplied.
36    #[must_use]
37    pub fn borders(&self) -> (u16, u16, u16, u16) {
38        if let Some(b) = self.border_regions {
39            (b, b, b, b)
40        } else {
41            (
42                self.border_north.unwrap_or(0),
43                self.border_south.unwrap_or(0),
44                self.border_east.unwrap_or(0),
45                self.border_west.unwrap_or(0),
46            )
47        }
48    }
49}
50
51/// Parse a `multipart/form-data` body that may carry a USB notecard as a
52/// file upload (`notecard`), as pasted text (`notecard_text`), or as a
53/// reference to a saved notecard (`notecard_id`), plus optional border
54/// fields. Exactly one of the three notecard sources must be supplied;
55/// resolving the `notecard_id` to a body is the caller's job because it
56/// requires the auth-checked DB lookup that this helper deliberately does
57/// not know about.
58///
59/// # Errors
60///
61/// Returns a [`crate::error::Error`] if the multipart body fails to parse,
62/// the notecard is not valid UTF-8, the notecard text cannot be parsed, a
63/// numeric field is malformed, more than one of the three sources is
64/// supplied, or none of them is.
65pub async fn parse_notecard_form(mut multipart: Multipart) -> Result<NotecardForm, Error> {
66    let mut form = NotecardForm::default();
67    let mut notecard_text: Option<String> = None;
68    let mut notecard_file: Option<String> = None;
69    let mut notecard_id: Option<Uuid> = None;
70    while let Some(field) = multipart.next_field().await? {
71        let Some(name) = field.name().map(str::to_owned) else {
72            continue;
73        };
74        match name.as_str() {
75            "notecard" => {
76                let bytes = field.bytes().await?;
77                if !bytes.is_empty() {
78                    let text = String::from_utf8(bytes.to_vec())
79                        .map_err(|e| Error::BadRequest(format!("notecard is not UTF-8: {e}")))?;
80                    notecard_file = Some(text);
81                }
82            }
83            "notecard_text" => {
84                let text = field.text().await?;
85                if !text.trim().is_empty() {
86                    notecard_text = Some(text);
87                }
88            }
89            "notecard_id" => {
90                let raw = field.text().await?;
91                let trimmed = raw.trim();
92                if !trimmed.is_empty() {
93                    notecard_id = Some(
94                        Uuid::parse_str(trimmed)
95                            .map_err(|e| Error::BadRequest(format!("invalid notecard_id: {e}")))?,
96                    );
97                }
98            }
99            "border_regions" => form.border_regions = parse_optional_u16(field.text().await?)?,
100            "border_north" => form.border_north = parse_optional_u16(field.text().await?)?,
101            "border_south" => form.border_south = parse_optional_u16(field.text().await?)?,
102            "border_east" => form.border_east = parse_optional_u16(field.text().await?)?,
103            "border_west" => form.border_west = parse_optional_u16(field.text().await?)?,
104            _ => {}
105        }
106    }
107    let inline = notecard_file.or(notecard_text);
108    match (inline, notecard_id) {
109        (Some(raw), None) => {
110            form.notecard = Some(raw.parse()?);
111        }
112        (None, Some(id)) => {
113            form.notecard_id = Some(id);
114        }
115        (Some(_), Some(_)) => {
116            return Err(Error::BadRequest(
117                "supply either `notecard_id` or notecard text/file, not both".to_owned(),
118            ));
119        }
120        (None, None) => {
121            return Err(Error::BadRequest(
122                "supply a `notecard_id`, `notecard` file upload, or `notecard_text` field"
123                    .to_owned(),
124            ));
125        }
126    }
127    Ok(form)
128}
129
130/// Parse a possibly-empty textual u16. Returns `Ok(None)` for an empty
131/// string so optional form fields can be left blank in the browser.
132fn parse_optional_u16(s: String) -> Result<Option<u16>, Error> {
133    let trimmed = s.trim();
134    if trimmed.is_empty() {
135        return Ok(None);
136    }
137    trimmed
138        .parse::<u16>()
139        .map(Some)
140        .map_err(|e| Error::BadRequest(format!("invalid u16 `{trimmed}`: {e}")))
141}