1use 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#[derive(Debug, Deserialize)]
21pub struct ScopeQuery {
22 pub scope: String,
24}
25
26#[derive(Debug, Serialize)]
28pub struct ListNotecardsResponse {
29 pub notecards: Vec<NotecardView>,
31}
32
33#[derive(Debug, Serialize)]
35pub struct NotecardResponse {
36 pub notecard: NotecardView,
40}
41
42pub 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
59pub 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
105pub 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
149pub 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
173pub 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
207struct CreateForm {
209 destination: Destination,
211 name: String,
213 text: String,
215}
216
217async 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 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
269pub(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#[derive(Default)]
307struct NotecardExtras {
308 start_region: Option<String>,
310 end_region: Option<String>,
312 waypoint_count: Option<u32>,
314 lower_left_x: Option<u16>,
316 lower_left_y: Option<u16>,
318 upper_right_x: Option<u16>,
320 upper_right_y: Option<u16>,
322}
323
324fn 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
345fn bound_u16(v: Option<i64>) -> Option<u16> {
350 v.and_then(|n| u16::try_from(n).ok())
351}
352
353#[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#[derive(sqlx::FromRow)]
391struct NotecardListRow {
392 notecard_id: Vec<u8>,
394 owner_user_id: Option<Vec<u8>>,
396 owner_group_id: Option<Vec<u8>>,
398 uploaded_by: Option<Vec<u8>>,
401 uploader_username: Option<String>,
404 uploader_legacy: Option<String>,
406 name: String,
408 body: String,
411 created_at: DateTime<Utc>,
413 lower_left_x: Option<i64>,
416 lower_left_y: Option<i64>,
418 upper_right_x: Option<i64>,
420 upper_right_y: Option<i64>,
422}
423
424async 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 "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
511async 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
525pub(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}