use axum::Json;
use axum::extract::{Multipart, Path, Query, State};
use axum::http::StatusCode as ReqwestStatusCode;
use axum::http::header;
use axum::response::{IntoResponse as _, Response};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sl_types::map::USBNotecard;
use uuid::Uuid;
use crate::auth::{CurrentUser, uuid_from_bytes};
use crate::error::{self, Error};
use crate::library::{self, Destination, NotecardView};
use crate::rate_limit::{self, RateCategory};
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct ScopeQuery {
pub scope: String,
}
#[derive(Debug, Serialize)]
pub struct ListNotecardsResponse {
pub notecards: Vec<NotecardView>,
}
#[derive(Debug, Serialize)]
pub struct NotecardResponse {
pub notecard: NotecardView,
}
pub async fn list(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<ScopeQuery>,
) -> Result<Json<ListNotecardsResponse>, Error> {
let destination = Destination::parse(&query.scope)?;
library::assert_can_view(&state.db, user.user_id, destination).await?;
let notecards = fetch_notecards_for(&state, user.user_id, destination).await?;
Ok(Json(ListNotecardsResponse { notecards }))
}
pub async fn create(
user: CurrentUser,
State(state): State<AppState>,
multipart: Multipart,
) -> Result<(ReqwestStatusCode, Json<NotecardResponse>), Error> {
rate_limit::try_acquire(&state.db, RateCategory::NotecardCreate, user.user_id).await?;
let parsed = parse_create_form(multipart).await?;
library::assert_can_write(&state.db, user.user_id, parsed.destination).await?;
let notecard_id = insert_notecard_row(
&state,
parsed.destination,
user.user_id,
&parsed.name,
&parsed.text,
)
.await?;
let (start_region, end_region, waypoint_count) = notecard_summary(&parsed.text);
let view = build_view(
notecard_id,
parsed.destination,
Some(user.user_id),
Some(&user.username),
Some(&user.legacy_name),
&parsed.name,
Utc::now(),
NotecardExtras {
start_region,
end_region,
waypoint_count,
..NotecardExtras::default()
},
);
Ok((
ReqwestStatusCode::CREATED,
Json(NotecardResponse { notecard: view }),
))
}
pub async fn get(
user: CurrentUser,
State(state): State<AppState>,
Path(notecard_id): Path<Uuid>,
) -> Result<Json<NotecardResponse>, Error> {
let row = library::assert_can_read_notecard(&state.db, user.user_id, notecard_id).await?;
let destination =
library::destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
let names = match row.uploaded_by {
Some(id) => lookup_user_names(&state, id).await.ok(),
None => None,
};
let (uploader_username, uploader_legacy) = match names {
Some((u, l)) => (Some(u), Some(l)),
None => (None, None),
};
let (start_region, end_region, waypoint_count) = notecard_summary(&row.body);
let view = build_view(
notecard_id,
destination,
row.uploaded_by,
uploader_username.as_deref(),
uploader_legacy.as_deref(),
&row.name,
row.created_at,
NotecardExtras {
start_region,
end_region,
waypoint_count,
lower_left_x: row.lower_left_x,
lower_left_y: row.lower_left_y,
upper_right_x: row.upper_right_x,
upper_right_y: row.upper_right_y,
},
);
Ok(Json(NotecardResponse { notecard: view }))
}
pub async fn download_text(
user: CurrentUser,
State(state): State<AppState>,
Path(notecard_id): Path<Uuid>,
) -> Result<Response, Error> {
let row = library::assert_can_read_notecard(&state.db, user.user_id, notecard_id).await?;
let filename = format!(
"{}.txt",
sanitise_for_filename(&row.name).unwrap_or_else(|| notecard_id.to_string())
);
let disposition = format!("attachment; filename=\"{filename}\"");
let body = row.body;
let headers = [
(header::CONTENT_TYPE, "text/plain; charset=utf-8".to_owned()),
(header::CONTENT_DISPOSITION, disposition),
];
Ok((headers, body).into_response())
}
pub async fn delete(
user: CurrentUser,
State(state): State<AppState>,
Path(notecard_id): Path<Uuid>,
) -> Result<Response, Error> {
library::assert_can_delete_notecard(&state.db, user.user_id, notecard_id).await?;
let result = sqlx::query("DELETE FROM saved_notecards WHERE notecard_id = ?1")
.bind(notecard_id.as_bytes().to_vec())
.execute(&state.db)
.await;
match result {
Ok(_) => Ok((ReqwestStatusCode::NO_CONTENT, "").into_response()),
Err(err) => {
if error::is_fk_violation(&err) {
return Err(Error::BadRequest(
"cannot delete this notecard: one or more saved renders still reference it. \
Delete those renders first."
.to_owned(),
));
}
tracing::error!("notecard delete failed: {err}");
Err(Error::Database)
}
}
}
struct CreateForm {
destination: Destination,
name: String,
text: String,
}
async fn parse_create_form(mut multipart: Multipart) -> Result<CreateForm, Error> {
let mut text_from_file: Option<String> = None;
let mut text_pasted: Option<String> = None;
let mut name: Option<String> = None;
let mut destination_raw: Option<String> = None;
while let Some(field) = multipart.next_field().await? {
let Some(field_name) = field.name().map(str::to_owned) else {
continue;
};
match field_name.as_str() {
"notecard" => {
let bytes = field.bytes().await?;
if !bytes.is_empty() {
text_from_file =
Some(String::from_utf8(bytes.to_vec()).map_err(|e| {
Error::BadRequest(format!("notecard is not UTF-8: {e}"))
})?);
}
}
"notecard_text" => {
let text = field.text().await?;
if !text.trim().is_empty() {
text_pasted = Some(text);
}
}
"name" => {
let t = field.text().await?;
if !t.trim().is_empty() {
name = Some(t);
}
}
"destination" => destination_raw = Some(field.text().await?),
_ => {}
}
}
let raw = text_from_file
.or(text_pasted)
.ok_or_else(|| Error::BadRequest("supply a notecard file or text".to_owned()))?;
let _parsed: USBNotecard = raw.parse()?;
let name_raw = name.ok_or_else(|| Error::BadRequest("name is required".to_owned()))?;
let name = library::sanitise_display_name(&name_raw, "notecard name")?;
let destination = Destination::parse(destination_raw.as_deref().unwrap_or("personal"))?;
Ok(CreateForm {
destination,
name,
text: raw,
})
}
pub(crate) async fn insert_notecard_row(
state: &AppState,
destination: Destination,
uploaded_by: Uuid,
name: &str,
text: &str,
) -> Result<Uuid, Error> {
let notecard_id = Uuid::new_v4();
let now = Utc::now();
let (owner_user, owner_group) = match destination {
Destination::Personal => (Some(uploaded_by.as_bytes().to_vec()), None),
Destination::Group { group_id } => (None, Some(group_id.as_bytes().to_vec())),
};
sqlx::query(
"INSERT INTO saved_notecards \
(notecard_id, owner_user_id, owner_group_id, uploaded_by, name, body, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)
.bind(notecard_id.as_bytes().to_vec())
.bind(owner_user)
.bind(owner_group)
.bind(uploaded_by.as_bytes().to_vec())
.bind(name)
.bind(text)
.bind(now)
.execute(&state.db)
.await
.map_err(|err| {
tracing::error!("insert saved_notecards failed: {err}");
Error::Database
})?;
Ok(notecard_id)
}
#[derive(Default)]
struct NotecardExtras {
start_region: Option<String>,
end_region: Option<String>,
waypoint_count: Option<u32>,
lower_left_x: Option<u16>,
lower_left_y: Option<u16>,
upper_right_x: Option<u16>,
upper_right_y: Option<u16>,
}
fn notecard_summary(body: &str) -> (Option<String>, Option<String>, Option<u32>) {
let Ok(parsed) = body.parse::<USBNotecard>() else {
return (None, None, None);
};
let waypoints = parsed.waypoints();
let start = waypoints
.first()
.map(|w| w.location().region_name.to_string());
let end = waypoints
.last()
.map(|w| w.location().region_name.to_string());
let count = u32::try_from(waypoints.len()).ok();
(start, end, count)
}
fn bound_u16(v: Option<i64>) -> Option<u16> {
v.and_then(|n| u16::try_from(n).ok())
}
#[expect(
clippy::too_many_arguments,
reason = "every argument is a distinct view field; bundling them would add indirection without removing parameters"
)]
fn build_view(
notecard_id: Uuid,
destination: Destination,
uploaded_by: Option<Uuid>,
uploaded_by_username: Option<&str>,
uploaded_by_legacy_name: Option<&str>,
name: &str,
created_at: DateTime<Utc>,
extras: NotecardExtras,
) -> NotecardView {
NotecardView {
notecard_id,
destination,
uploaded_by,
uploaded_by_username: uploaded_by_username.map(str::to_owned),
uploaded_by_legacy_name: uploaded_by_legacy_name.map(str::to_owned),
name: name.to_owned(),
created_at,
start_region: extras.start_region,
end_region: extras.end_region,
waypoint_count: extras.waypoint_count,
lower_left_x: extras.lower_left_x,
lower_left_y: extras.lower_left_y,
upper_right_x: extras.upper_right_x,
upper_right_y: extras.upper_right_y,
}
}
#[derive(sqlx::FromRow)]
struct NotecardListRow {
notecard_id: Vec<u8>,
owner_user_id: Option<Vec<u8>>,
owner_group_id: Option<Vec<u8>>,
uploaded_by: Option<Vec<u8>>,
uploader_username: Option<String>,
uploader_legacy: Option<String>,
name: String,
body: String,
created_at: DateTime<Utc>,
lower_left_x: Option<i64>,
lower_left_y: Option<i64>,
upper_right_x: Option<i64>,
upper_right_y: Option<i64>,
}
async fn fetch_notecards_for(
state: &AppState,
current_user: Uuid,
destination: Destination,
) -> Result<Vec<NotecardView>, Error> {
let rows: Vec<NotecardListRow> = match destination {
Destination::Personal => sqlx::query_as(
"SELECT n.notecard_id, n.owner_user_id, n.owner_group_id, n.uploaded_by AS uploaded_by, \
u.username AS uploader_username, u.legacy_name AS uploader_legacy, \
n.name, n.body, n.created_at, \
n.lower_left_x, n.lower_left_y, n.upper_right_x, n.upper_right_y \
FROM saved_notecards AS n \
LEFT JOIN users AS u ON u.user_id = n.uploaded_by \
WHERE n.owner_user_id = ?1 \
ORDER BY n.created_at DESC",
)
.bind(current_user.as_bytes().to_vec())
.fetch_all(&state.db)
.await
.map_err(|err| {
tracing::error!("list notecards (personal) failed: {err}");
Error::Database
})?,
Destination::Group { group_id } => sqlx::query_as(
"SELECT n.notecard_id, n.owner_user_id, n.owner_group_id, n.uploaded_by AS uploaded_by, \
u.username AS uploader_username, u.legacy_name AS uploader_legacy, \
n.name, n.body, n.created_at, \
n.lower_left_x, n.lower_left_y, n.upper_right_x, n.upper_right_y \
FROM saved_notecards AS n \
LEFT JOIN users AS u ON u.user_id = n.uploaded_by \
JOIN group_memberships AS gm \
ON gm.group_id = n.owner_group_id AND gm.user_id = ?2 \
WHERE n.owner_group_id = ?1 \
ORDER BY n.created_at DESC",
)
.bind(group_id.as_bytes().to_vec())
.bind(current_user.as_bytes().to_vec())
.fetch_all(&state.db)
.await
.map_err(|err| {
tracing::error!("list notecards (group) failed: {err}");
Error::Database
})?,
};
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let notecard_id = uuid_from_bytes(&row.notecard_id).ok_or_else(|| {
tracing::error!("bad notecard uuid");
Error::Database
})?;
let row_dest = library::destination_from_columns(row.owner_user_id, row.owner_group_id)?;
let uploaded_by = row
.uploaded_by
.as_deref()
.map(uuid_from_bytes)
.map(|opt| {
opt.ok_or_else(|| {
tracing::error!("bad uploaded_by uuid");
Error::Database
})
})
.transpose()?;
let (start_region, end_region, waypoint_count) = notecard_summary(&row.body);
out.push(NotecardView {
notecard_id,
destination: row_dest,
uploaded_by,
uploaded_by_username: row.uploader_username,
uploaded_by_legacy_name: row.uploader_legacy,
name: row.name,
created_at: row.created_at,
start_region,
end_region,
waypoint_count,
lower_left_x: bound_u16(row.lower_left_x),
lower_left_y: bound_u16(row.lower_left_y),
upper_right_x: bound_u16(row.upper_right_x),
upper_right_y: bound_u16(row.upper_right_y),
});
}
Ok(out)
}
async fn lookup_user_names(state: &AppState, user_id: Uuid) -> Result<(String, String), Error> {
let row: Option<(String, String)> =
sqlx::query_as("SELECT username, legacy_name FROM users WHERE user_id = ?1")
.bind(user_id.as_bytes().to_vec())
.fetch_optional(&state.db)
.await
.map_err(|err| {
tracing::error!("user name lookup failed: {err}");
Error::Database
})?;
row.ok_or_else(|| Error::NotFound(format!("user {user_id}")))
}
pub(crate) fn sanitise_for_filename(name: &str) -> Option<String> {
let cleaned: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect();
let trimmed = cleaned.trim_matches('_').to_owned();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}