From 11476f7c5b62bfa27adcd42efd2d6b7588ce207b Mon Sep 17 00:00:00 2001 From: jakubmanczak Date: Wed, 25 Feb 2026 02:45:42 +0100 Subject: [PATCH] implement tags --- src/api/mod.rs | 10 +- src/api/tags.rs | 32 +++ src/database/migrations/2026-02-20--01.sql | 2 +- src/tags.rs | 232 ++++++++++++++++++++- 4 files changed, 271 insertions(+), 5 deletions(-) create mode 100644 src/api/tags.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index ab616dc..af9a8d1 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -4,9 +4,13 @@ use axum::{ routing::get, }; -use crate::users::{UserError, auth::AuthError, sessions::SessionError}; +use crate::{ + tags::TagError, + users::{UserError, auth::AuthError, sessions::SessionError}, +}; mod sessions; +mod tags; mod users; // TODO: PERMISSIONS FOR ENDPOINTS & ACTIONS @@ -17,6 +21,8 @@ pub fn api_router() -> Router { .route("/api/users/{id}", get(users::get_by_id)) .route("/api/users/@{handle}", get(users::get_by_handle)) .route("/api/sessions/{id}", get(sessions::get_by_id)) + .route("/api/tags/{id}", get(tags::get_by_id)) + .route("/api/tags/#{id}", get(tags::get_by_name)) } pub struct CompositeError(Response); @@ -37,4 +43,4 @@ macro_rules! composite_from { )+ }; } -composite_from!(AuthError, UserError, SessionError); +composite_from!(AuthError, UserError, SessionError, TagError); diff --git a/src/api/tags.rs b/src/api/tags.rs new file mode 100644 index 0000000..a5241a3 --- /dev/null +++ b/src/api/tags.rs @@ -0,0 +1,32 @@ +use axum::{ + Json, + extract::Path, + http::HeaderMap, + response::{IntoResponse, Response}, +}; +use uuid::Uuid; + +use crate::{ + api::CompositeError, + tags::{Tag, TagName}, + users::{ + User, + auth::{UserAuthRequired, UserAuthenticate}, + }, +}; + +pub async fn get_by_id( + Path(id): Path, + headers: HeaderMap, +) -> Result { + User::authenticate(&headers)?.required()?; + Ok(Json(Tag::get_by_id(id)?).into_response()) +} + +pub async fn get_by_name( + Path(name): Path, + headers: HeaderMap, +) -> Result { + User::authenticate(&headers)?.required()?; + Ok(Json(Tag::get_by_name(name)?).into_response()) +} diff --git a/src/database/migrations/2026-02-20--01.sql b/src/database/migrations/2026-02-20--01.sql index a994ff5..b6ad257 100644 --- a/src/database/migrations/2026-02-20--01.sql +++ b/src/database/migrations/2026-02-20--01.sql @@ -68,7 +68,7 @@ CREATE UNIQUE INDEX lines_unique_ordering ON lines(quote_id, ordering); CREATE TABLE tags ( id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes - tagname TEXT NOT NULL UNIQUE + tagname TEXT NOT NULL UNIQUE COLLATE NOCASE ); CREATE TABLE user_quote_likes ( diff --git a/src/tags.rs b/src/tags.rs index e6e4201..a37cbd1 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -1,2 +1,230 @@ -#[allow(unused)] -pub struct Tag; +use std::{fmt::Display, hash::Hash, ops::Deref, str::FromStr}; + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; +use rusqlite::{ + OptionalExtension, Result as RusqliteResult, ToSql, + types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef}, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ISE_MSG, database}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Tag { + pub id: Uuid, + pub name: TagName, +} + +impl Tag { + pub fn get_by_id(id: Uuid) -> Result { + let res = database::conn()? + .prepare("SELECT tagname FROM tags WHERE id = ?1")? + .query_one((&id,), |r| { + Ok(Tag { + id, + name: r.get(0)?, + }) + }) + .optional()?; + match res { + Some(t) => Ok(t), + None => Err(TagError::NoTagWithId(id)), + } + } + pub fn get_by_name(name: TagName) -> Result { + let res = database::conn()? + .prepare("SELECT id, tagname FROM tags WHERE tagname = ?1")? + .query_one((&name,), |r| { + Ok(Tag { + id: r.get(0)?, + name: r.get(1)?, + }) + }) + .optional()?; + match res { + Some(u) => Ok(u), + None => Err(TagError::NoTagWithName(name)), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum TagError { + #[error("TagNameError: {0}")] + TagNameError(#[from] TagNameError), + #[error("No tag found with ID {0}")] + NoTagWithId(Uuid), + #[error("No tag found with name {0}")] + NoTagWithName(TagName), + #[error("Database error: {0}")] + DatabaseError(String), +} +impl From for TagError { + fn from(error: rusqlite::Error) -> Self { + TagError::DatabaseError(error.to_string()) + } +} +impl IntoResponse for TagError { + fn into_response(self) -> Response { + match self { + Self::DatabaseError(e) => { + eprintln!("[ERROR] Database error occured: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, ISE_MSG.into()) + } + Self::TagNameError(_) => (StatusCode::BAD_REQUEST, self.to_string()), + Self::NoTagWithId(_) => (StatusCode::BAD_REQUEST, self.to_string()), + Self::NoTagWithName(_) => (StatusCode::BAD_REQUEST, self.to_string()), + } + .into_response() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(into = "String")] +#[serde(try_from = "String")] +pub struct TagName(String); + +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq, Serialize)] +pub enum TagNameError { + #[error("Tag is too short - must be 3 or more characters.")] + TagTooShort, + #[error("Tag is too long - must be 16 or less characters.")] + TagTooLong, + #[error("Tag must consist of ASCII alphanumerics or mid-tag dashes only.")] + TagNonDashAsciiAlphanumeric, + #[error("Tag must not have a leading or trailing dash.")] + TagLeadingTrailingDash, + #[error("Tag must not have consecutive dashes.")] + TagConsecutiveDashes, +} + +impl TagName { + pub fn new(input: impl AsRef) -> Result { + let s = input.as_ref(); + TagName::validate_str(s)?; + Ok(TagName(s.to_string())) + } + pub fn validate_str(str: &str) -> Result<(), TagNameError> { + match str.len() { + ..=2 => return Err(TagNameError::TagTooShort), + 17.. => return Err(TagNameError::TagTooLong), + _ => (), + }; + if str.bytes().any(|c| !c.is_ascii_alphanumeric() && c != b'-') { + return Err(TagNameError::TagNonDashAsciiAlphanumeric); + } + if str.starts_with('-') || str.ends_with('-') { + return Err(TagNameError::TagLeadingTrailingDash); + } + if str + .as_bytes() + .windows(2) + .any(|w| w[0] == b'-' && w[1] == b'-') + { + return Err(TagNameError::TagConsecutiveDashes); + } + Ok(()) + } + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl PartialEq for TagName { + fn eq(&self, other: &Self) -> bool { + self.0.eq_ignore_ascii_case(&other.0) + } +} +impl Eq for TagName {} +impl Hash for TagName { + fn hash(&self, state: &mut H) { + self.0.to_ascii_lowercase().hash(state); + } +} + +impl AsRef for TagName { + fn as_ref(&self) -> &str { + &self.0 + } +} +impl Deref for TagName { + type Target = str; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl Display for TagName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} +impl FromStr for TagName { + type Err = TagNameError; + fn from_str(s: &str) -> Result { + Self::validate_str(s)?; + Ok(TagName(s.to_string())) + } +} +impl TryFrom for TagName { + type Error = TagNameError; + fn try_from(value: String) -> Result { + Self::validate_str(&value)?; + Ok(TagName(value)) + } +} +impl From for String { + fn from(value: TagName) -> Self { + value.0 + } +} + +impl ToSql for TagName { + fn to_sql(&self) -> RusqliteResult> { + Ok(self.0.to_sql()?) + } +} + +impl FromSql for TagName { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + TagName::from_str(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e))) + } +} + +#[test] +#[should_panic] +fn tagname_leading_dash_fail() { + TagName::new("-test").unwrap(); +} +#[test] +#[should_panic] +fn tagname_trailing_dash_fail() { + TagName::new("test-").unwrap(); +} +#[test] +#[should_panic] +fn tagname_consecutive_dash_fail() { + TagName::new("test1--test2").unwrap(); +} +#[test] +#[should_panic] +fn tagname_short_fail() { + TagName::new("xd").unwrap(); +} +#[test] +#[should_panic] +fn tagname_long_fail() { + TagName::new("xddddddddddddddddddddddddddd").unwrap(); +} +#[test] +#[should_panic] +fn tagname_nondashasciialphanumerics_fail() { + TagName::new("hate_underscores").unwrap(); +} +#[test] +fn tagname_pass() { + TagName::new("H311-yeah").unwrap(); +}