Skip to main content

sl_map_web/routes/
notecards.rs

1//! HTTP handlers for saved notecards.
2
3use axum::Json;
4use axum::extract::{Multipart, Path, Query, State};
5use axum::http::StatusCode as ReqwestStatusCode;
6use axum::http::header;
7use axum::response::{IntoResponse as _, Response};
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use sl_types::map::USBNotecard;
11use uuid::Uuid;
12
13use crate::auth::{CurrentUser, uuid_from_bytes};
14use crate::error::{self, Error};
15use crate::library::{self, Destination, NotecardView};
16use crate::rate_limit::{self, RateCategory};
17use crate::state::AppState;
18
19/// Query parameters for `GET /api/notecards`.
20#[derive(Debug, Deserialize)]
21pub struct ScopeQuery {
22    /// `"personal"` or `"group:<uuid>"`.
23    pub scope: String,
24}
25
26/// Response carrying the listing of notecards in a scope.
27#[derive(Debug, Serialize)]
28pub struct ListNotecardsResponse {
29    /// the saved notecards visible in the requested scope, newest first.
30    pub notecards: Vec<NotecardView>,
31}
32
33/// Response carrying a single saved notecard's metadata.
34#[derive(Debug, Serialize)]
35pub struct NotecardResponse {
36    /// the requested saved notecard's metadata (display name, owner,
37    /// scope) — the raw body is fetched separately via the `/text`
38    /// sub-route.
39    pub notecard: NotecardView,
40}
41
42/// `GET /api/notecards?scope=...` — list notecards in a scope.
43///
44/// # Errors
45///
46/// Returns [`Error::BadRequest`] for an invalid scope; [`Error::Forbidden`]
47/// if the user is not allowed to view that scope.
48pub async fn list(
49    user: CurrentUser,
50    State(state): State<AppState>,
51    Query(query): Query<ScopeQuery>,
52) -> Result<Json<ListNotecardsResponse>, Error> {
53    let destination = Destination::parse(&query.scope)?;
54    library::assert_can_view(&state.db, user.user_id, destination).await?;
55    let notecards = fetch_notecards_for(&state, user.user_id, destination).await?;
56    Ok(Json(ListNotecardsResponse { notecards }))
57}
58
59/// `POST /api/notecards` — save a fresh notecard. Multipart body:
60/// `notecard` (file) or `notecard_text` (textarea), `name`, `destination`.
61///
62/// # Errors
63///
64/// Returns [`Error::BadRequest`] for malformed input; [`Error::Forbidden`]
65/// for writing to a group the user does not own.
66pub async fn create(
67    user: CurrentUser,
68    State(state): State<AppState>,
69    multipart: Multipart,
70) -> Result<(ReqwestStatusCode, Json<NotecardResponse>), Error> {
71    rate_limit::try_acquire(&state.db, RateCategory::NotecardCreate, user.user_id).await?;
72    let parsed = parse_create_form(multipart).await?;
73    library::assert_can_write(&state.db, user.user_id, parsed.destination).await?;
74
75    let notecard_id = insert_notecard_row(
76        &state,
77        parsed.destination,
78        user.user_id,
79        &parsed.name,
80        &parsed.text,
81    )
82    .await?;
83    let (start_region, end_region, waypoint_count) = notecard_summary(&parsed.text);
84    let view = build_view(
85        notecard_id,
86        parsed.destination,
87        Some(user.user_id),
88        Some(&user.username),
89        Some(&user.legacy_name),
90        &parsed.name,
91        Utc::now(),
92        NotecardExtras {
93            start_region,
94            end_region,
95            waypoint_count,
96            ..NotecardExtras::default()
97        },
98    );
99    Ok((
100        ReqwestStatusCode::CREATED,
101        Json(NotecardResponse { notecard: view }),
102    ))
103}
104
105/// `GET /api/notecards/{id}` — fetch a notecard's metadata.
106///
107/// # Errors
108///
109/// Returns [`Error::Forbidden`] if the caller may not view it;
110/// [`Error::NotFound`] if it doesn't exist.
111pub async fn get(
112    user: CurrentUser,
113    State(state): State<AppState>,
114    Path(notecard_id): Path<Uuid>,
115) -> Result<Json<NotecardResponse>, Error> {
116    let row = library::assert_can_read_notecard(&state.db, user.user_id, notecard_id).await?;
117    let destination =
118        library::destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
119    let names = match row.uploaded_by {
120        Some(id) => lookup_user_names(&state, id).await.ok(),
121        None => None,
122    };
123    let (uploader_username, uploader_legacy) = match names {
124        Some((u, l)) => (Some(u), Some(l)),
125        None => (None, None),
126    };
127    let (start_region, end_region, waypoint_count) = notecard_summary(&row.body);
128    let view = build_view(
129        notecard_id,
130        destination,
131        row.uploaded_by,
132        uploader_username.as_deref(),
133        uploader_legacy.as_deref(),
134        &row.name,
135        row.created_at,
136        NotecardExtras {
137            start_region,
138            end_region,
139            waypoint_count,
140            lower_left_x: row.lower_left_x,
141            lower_left_y: row.lower_left_y,
142            upper_right_x: row.upper_right_x,
143            upper_right_y: row.upper_right_y,
144        },
145    );
146    Ok(Json(NotecardResponse { notecard: view }))
147}
148
149/// `GET /api/notecards/{id}/text` — download the raw notecard text.
150///
151/// # Errors
152///
153/// As [`get`].
154pub async fn download_text(
155    user: CurrentUser,
156    State(state): State<AppState>,
157    Path(notecard_id): Path<Uuid>,
158) -> Result<Response, Error> {
159    let row = library::assert_can_read_notecard(&state.db, user.user_id, notecard_id).await?;
160    let filename = format!(
161        "{}.txt",
162        sanitise_for_filename(&row.name).unwrap_or_else(|| notecard_id.to_string())
163    );
164    let disposition = format!("attachment; filename=\"{filename}\"");
165    let body = row.body;
166    let headers = [
167        (header::CONTENT_TYPE, "text/plain; charset=utf-8".to_owned()),
168        (header::CONTENT_DISPOSITION, disposition),
169    ];
170    Ok((headers, body).into_response())
171}
172
173/// `DELETE /api/notecards/{id}` — delete a notecard. Personal owner or
174/// group owner only. Returns a friendly 400 if a render still references it
175/// (FK RESTRICT).
176///
177/// # Errors
178///
179/// Returns [`Error::Forbidden`] for permission failure, [`Error::BadRequest`]
180/// for FK violations, or [`Error::Database`] for other DB failures.
181pub async fn delete(
182    user: CurrentUser,
183    State(state): State<AppState>,
184    Path(notecard_id): Path<Uuid>,
185) -> Result<Response, Error> {
186    library::assert_can_delete_notecard(&state.db, user.user_id, notecard_id).await?;
187    let result = sqlx::query("DELETE FROM saved_notecards WHERE notecard_id = ?1")
188        .bind(notecard_id.as_bytes().to_vec())
189        .execute(&state.db)
190        .await;
191    match result {
192        Ok(_) => Ok((ReqwestStatusCode::NO_CONTENT, "").into_response()),
193        Err(err) => {
194            if error::is_fk_violation(&err) {
195                return Err(Error::BadRequest(
196                    "cannot delete this notecard: one or more saved renders still reference it. \
197                     Delete those renders first."
198                        .to_owned(),
199                ));
200            }
201            tracing::error!("notecard delete failed: {err}");
202            Err(Error::Database)
203        }
204    }
205}
206
207/// Parsed `POST /api/notecards` form fields.
208struct CreateForm {
209    /// destination scope (personal or a group).
210    destination: Destination,
211    /// the human-supplied display name.
212    name: String,
213    /// the raw notecard text (validated by parsing through `USBNotecard`).
214    text: String,
215}
216
217/// Parse the multipart form for a `POST /api/notecards` request.
218async fn parse_create_form(mut multipart: Multipart) -> Result<CreateForm, Error> {
219    let mut text_from_file: Option<String> = None;
220    let mut text_pasted: Option<String> = None;
221    let mut name: Option<String> = None;
222    let mut destination_raw: Option<String> = None;
223    while let Some(field) = multipart.next_field().await? {
224        let Some(field_name) = field.name().map(str::to_owned) else {
225            continue;
226        };
227        match field_name.as_str() {
228            "notecard" => {
229                let bytes = field.bytes().await?;
230                if !bytes.is_empty() {
231                    text_from_file =
232                        Some(String::from_utf8(bytes.to_vec()).map_err(|e| {
233                            Error::BadRequest(format!("notecard is not UTF-8: {e}"))
234                        })?);
235                }
236            }
237            "notecard_text" => {
238                let text = field.text().await?;
239                if !text.trim().is_empty() {
240                    text_pasted = Some(text);
241                }
242            }
243            "name" => {
244                let t = field.text().await?;
245                if !t.trim().is_empty() {
246                    name = Some(t);
247                }
248            }
249            "destination" => destination_raw = Some(field.text().await?),
250            _ => {}
251        }
252    }
253    let raw = text_from_file
254        .or(text_pasted)
255        .ok_or_else(|| Error::BadRequest("supply a notecard file or text".to_owned()))?;
256    // Validate by parsing; we still store the raw text for fidelity (the
257    // upstream USBNotecard type may strip whitespace etc. during parse).
258    let _parsed: USBNotecard = raw.parse()?;
259    let name_raw = name.ok_or_else(|| Error::BadRequest("name is required".to_owned()))?;
260    let name = library::sanitise_display_name(&name_raw, "notecard name")?;
261    let destination = Destination::parse(destination_raw.as_deref().unwrap_or("personal"))?;
262    Ok(CreateForm {
263        destination,
264        name,
265        text: raw,
266    })
267}
268
269/// Insert a `saved_notecards` row and return its new id.
270pub(crate) async fn insert_notecard_row(
271    state: &AppState,
272    destination: Destination,
273    uploaded_by: Uuid,
274    name: &str,
275    text: &str,
276) -> Result<Uuid, Error> {
277    let notecard_id = Uuid::new_v4();
278    let now = Utc::now();
279    let (owner_user, owner_group) = match destination {
280        Destination::Personal => (Some(uploaded_by.as_bytes().to_vec()), None),
281        Destination::Group { group_id } => (None, Some(group_id.as_bytes().to_vec())),
282    };
283    sqlx::query(
284        "INSERT INTO saved_notecards \
285            (notecard_id, owner_user_id, owner_group_id, uploaded_by, name, body, created_at) \
286         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
287    )
288    .bind(notecard_id.as_bytes().to_vec())
289    .bind(owner_user)
290    .bind(owner_group)
291    .bind(uploaded_by.as_bytes().to_vec())
292    .bind(name)
293    .bind(text)
294    .bind(now)
295    .execute(&state.db)
296    .await
297    .map_err(|err| {
298        tracing::error!("insert saved_notecards failed: {err}");
299        Error::Database
300    })?;
301    Ok(notecard_id)
302}
303
304/// Bundle of optional notecard-row fields that are not part of the core
305/// view but are surfaced through the API for the library UI.
306#[derive(Default)]
307struct NotecardExtras {
308    /// route start region (first waypoint's region name).
309    start_region: Option<String>,
310    /// route end region (last waypoint's region name).
311    end_region: Option<String>,
312    /// number of waypoints in the route.
313    waypoint_count: Option<u32>,
314    /// lower-left x grid coordinate of the route's bounding box.
315    lower_left_x: Option<u16>,
316    /// lower-left y grid coordinate of the route's bounding box.
317    lower_left_y: Option<u16>,
318    /// upper-right x grid coordinate of the route's bounding box.
319    upper_right_x: Option<u16>,
320    /// upper-right y grid coordinate of the route's bounding box.
321    upper_right_y: Option<u16>,
322}
323
324/// Derive the start/end region names and waypoint count from a notecard
325/// body. All three are `None` if the body fails to parse; start/end are
326/// also `None` if the parsed route has no waypoints. The body has
327/// already been validated at insert time so parse failures here would
328/// indicate corruption, but we still degrade gracefully rather than
329/// 500ing the list endpoint.
330fn notecard_summary(body: &str) -> (Option<String>, Option<String>, Option<u32>) {
331    let Ok(parsed) = body.parse::<USBNotecard>() else {
332        return (None, None, None);
333    };
334    let waypoints = parsed.waypoints();
335    let start = waypoints
336        .first()
337        .map(|w| w.location().region_name.to_string());
338    let end = waypoints
339        .last()
340        .map(|w| w.location().region_name.to_string());
341    let count = u32::try_from(waypoints.len()).ok();
342    (start, end, count)
343}
344
345/// Convert a raw SQLite `INTEGER` from the bounds columns to `u16`.
346/// Returns `None` for `NULL` or for values that fall outside the grid's
347/// `u16` range (which the schema does not constrain but the renderer
348/// would reject anyway).
349fn bound_u16(v: Option<i64>) -> Option<u16> {
350    v.and_then(|n| u16::try_from(n).ok())
351}
352
353/// Build a [`NotecardView`] from the gathered pieces.
354#[expect(
355    clippy::too_many_arguments,
356    reason = "every argument is a distinct view field; bundling them would add indirection without removing parameters"
357)]
358fn build_view(
359    notecard_id: Uuid,
360    destination: Destination,
361    uploaded_by: Option<Uuid>,
362    uploaded_by_username: Option<&str>,
363    uploaded_by_legacy_name: Option<&str>,
364    name: &str,
365    created_at: DateTime<Utc>,
366    extras: NotecardExtras,
367) -> NotecardView {
368    NotecardView {
369        notecard_id,
370        destination,
371        uploaded_by,
372        uploaded_by_username: uploaded_by_username.map(str::to_owned),
373        uploaded_by_legacy_name: uploaded_by_legacy_name.map(str::to_owned),
374        name: name.to_owned(),
375        created_at,
376        start_region: extras.start_region,
377        end_region: extras.end_region,
378        waypoint_count: extras.waypoint_count,
379        lower_left_x: extras.lower_left_x,
380        lower_left_y: extras.lower_left_y,
381        upper_right_x: extras.upper_right_x,
382        upper_right_y: extras.upper_right_y,
383    }
384}
385
386/// Row shape returned by the listing queries for `saved_notecards`. A
387/// `FromRow` struct is used instead of a tuple so we can grow the column
388/// list without worrying about sqlx's tuple-`FromRow` arity (16) and so
389/// the field-by-field destructuring stays readable.
390#[derive(sqlx::FromRow)]
391struct NotecardListRow {
392    /// raw bytes of `saved_notecards.notecard_id`.
393    notecard_id: Vec<u8>,
394    /// raw bytes of `saved_notecards.owner_user_id`, if set.
395    owner_user_id: Option<Vec<u8>>,
396    /// raw bytes of `saved_notecards.owner_group_id`, if set.
397    owner_group_id: Option<Vec<u8>>,
398    /// raw bytes of the uploading user's id. `None` when the account
399    /// has been deleted (FK is `ON DELETE SET NULL`).
400    uploaded_by: Option<Vec<u8>>,
401    /// the uploading user's `username`. `None` if the uploader's
402    /// account has been deleted.
403    uploader_username: Option<String>,
404    /// the uploading user's `legacy_name`. `None` if deleted.
405    uploader_legacy: Option<String>,
406    /// notecard display name.
407    name: String,
408    /// the raw notecard body (used to extract the start / end region
409    /// names; not returned to the client through the list response).
410    body: String,
411    /// row creation timestamp.
412    created_at: DateTime<Utc>,
413    /// lower-left x grid coordinate of the route's bounding box, if a
414    /// previous render has resolved and cached it.
415    lower_left_x: Option<i64>,
416    /// lower-left y grid coordinate of the route's bounding box.
417    lower_left_y: Option<i64>,
418    /// upper-right x grid coordinate of the route's bounding box.
419    upper_right_x: Option<i64>,
420    /// upper-right y grid coordinate of the route's bounding box.
421    upper_right_y: Option<i64>,
422}
423
424/// Run the list query for the given scope.
425async fn fetch_notecards_for(
426    state: &AppState,
427    current_user: Uuid,
428    destination: Destination,
429) -> Result<Vec<NotecardView>, Error> {
430    let rows: Vec<NotecardListRow> = match destination {
431        Destination::Personal => sqlx::query_as(
432            "SELECT n.notecard_id, n.owner_user_id, n.owner_group_id, n.uploaded_by AS uploaded_by, \
433                    u.username AS uploader_username, u.legacy_name AS uploader_legacy, \
434                    n.name, n.body, n.created_at, \
435                    n.lower_left_x, n.lower_left_y, n.upper_right_x, n.upper_right_y \
436             FROM saved_notecards AS n \
437             LEFT JOIN users AS u ON u.user_id = n.uploaded_by \
438             WHERE n.owner_user_id = ?1 \
439             ORDER BY n.created_at DESC",
440        )
441        .bind(current_user.as_bytes().to_vec())
442        .fetch_all(&state.db)
443        .await
444        .map_err(|err| {
445            tracing::error!("list notecards (personal) failed: {err}");
446            Error::Database
447        })?,
448        Destination::Group { group_id } => sqlx::query_as(
449            // The JOIN against `group_memberships` enforces visibility at the
450            // SQL layer so a forgotten `assert_can_view` call site cannot leak
451            // a group's notecards to a non-member.
452            "SELECT n.notecard_id, n.owner_user_id, n.owner_group_id, n.uploaded_by AS uploaded_by, \
453                    u.username AS uploader_username, u.legacy_name AS uploader_legacy, \
454                    n.name, n.body, n.created_at, \
455                    n.lower_left_x, n.lower_left_y, n.upper_right_x, n.upper_right_y \
456             FROM saved_notecards AS n \
457             LEFT JOIN users AS u ON u.user_id = n.uploaded_by \
458             JOIN group_memberships AS gm \
459               ON gm.group_id = n.owner_group_id AND gm.user_id = ?2 \
460             WHERE n.owner_group_id = ?1 \
461             ORDER BY n.created_at DESC",
462        )
463        .bind(group_id.as_bytes().to_vec())
464        .bind(current_user.as_bytes().to_vec())
465        .fetch_all(&state.db)
466        .await
467        .map_err(|err| {
468            tracing::error!("list notecards (group) failed: {err}");
469            Error::Database
470        })?,
471    };
472    let mut out = Vec::with_capacity(rows.len());
473    for row in rows {
474        let notecard_id = uuid_from_bytes(&row.notecard_id).ok_or_else(|| {
475            tracing::error!("bad notecard uuid");
476            Error::Database
477        })?;
478        let row_dest = library::destination_from_columns(row.owner_user_id, row.owner_group_id)?;
479        let uploaded_by = row
480            .uploaded_by
481            .as_deref()
482            .map(uuid_from_bytes)
483            .map(|opt| {
484                opt.ok_or_else(|| {
485                    tracing::error!("bad uploaded_by uuid");
486                    Error::Database
487                })
488            })
489            .transpose()?;
490        let (start_region, end_region, waypoint_count) = notecard_summary(&row.body);
491        out.push(NotecardView {
492            notecard_id,
493            destination: row_dest,
494            uploaded_by,
495            uploaded_by_username: row.uploader_username,
496            uploaded_by_legacy_name: row.uploader_legacy,
497            name: row.name,
498            created_at: row.created_at,
499            start_region,
500            end_region,
501            waypoint_count,
502            lower_left_x: bound_u16(row.lower_left_x),
503            lower_left_y: bound_u16(row.lower_left_y),
504            upper_right_x: bound_u16(row.upper_right_x),
505            upper_right_y: bound_u16(row.upper_right_y),
506        });
507    }
508    Ok(out)
509}
510
511/// Look up a user's display fields for view-building.
512async fn lookup_user_names(state: &AppState, user_id: Uuid) -> Result<(String, String), Error> {
513    let row: Option<(String, String)> =
514        sqlx::query_as("SELECT username, legacy_name FROM users WHERE user_id = ?1")
515            .bind(user_id.as_bytes().to_vec())
516            .fetch_optional(&state.db)
517            .await
518            .map_err(|err| {
519                tracing::error!("user name lookup failed: {err}");
520                Error::Database
521            })?;
522    row.ok_or_else(|| Error::NotFound(format!("user {user_id}")))
523}
524
525/// Reduce a free-form notecard name to something safe to embed inside a
526/// Content-Disposition filename. Returns None if nothing usable is left.
527pub(crate) fn sanitise_for_filename(name: &str) -> Option<String> {
528    let cleaned: String = name
529        .chars()
530        .map(|c| {
531            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
532                c
533            } else {
534                '_'
535            }
536        })
537        .collect();
538    let trimmed = cleaned.trim_matches('_').to_owned();
539    if trimmed.is_empty() {
540        None
541    } else {
542        Some(trimmed)
543    }
544}