implement tags

This commit is contained in:
2026-02-25 02:45:42 +01:00
parent 5a92740785
commit 11476f7c5b
4 changed files with 271 additions and 5 deletions

View File

@@ -4,9 +4,13 @@ use axum::{
routing::get, routing::get,
}; };
use crate::users::{UserError, auth::AuthError, sessions::SessionError}; use crate::{
tags::TagError,
users::{UserError, auth::AuthError, sessions::SessionError},
};
mod sessions; mod sessions;
mod tags;
mod users; mod users;
// TODO: PERMISSIONS FOR ENDPOINTS & ACTIONS // 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/{id}", get(users::get_by_id))
.route("/api/users/@{handle}", get(users::get_by_handle)) .route("/api/users/@{handle}", get(users::get_by_handle))
.route("/api/sessions/{id}", get(sessions::get_by_id)) .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); pub struct CompositeError(Response);
@@ -37,4 +43,4 @@ macro_rules! composite_from {
)+ )+
}; };
} }
composite_from!(AuthError, UserError, SessionError); composite_from!(AuthError, UserError, SessionError, TagError);

32
src/api/tags.rs Normal file
View File

@@ -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<Uuid>,
headers: HeaderMap,
) -> Result<Response, CompositeError> {
User::authenticate(&headers)?.required()?;
Ok(Json(Tag::get_by_id(id)?).into_response())
}
pub async fn get_by_name(
Path(name): Path<TagName>,
headers: HeaderMap,
) -> Result<Response, CompositeError> {
User::authenticate(&headers)?.required()?;
Ok(Json(Tag::get_by_name(name)?).into_response())
}

View File

@@ -68,7 +68,7 @@ CREATE UNIQUE INDEX lines_unique_ordering ON lines(quote_id, ordering);
CREATE TABLE tags ( CREATE TABLE tags (
id BLOB NOT NULL UNIQUE PRIMARY KEY, -- UUIDv7 as bytes 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 ( CREATE TABLE user_quote_likes (

View File

@@ -1,2 +1,230 @@
#[allow(unused)] use std::{fmt::Display, hash::Hash, ops::Deref, str::FromStr};
pub struct Tag;
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<Tag, TagError> {
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<Tag, TagError> {
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<rusqlite::Error> 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<str>) -> Result<Self, TagNameError> {
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<H: std::hash::Hasher>(&self, state: &mut H) {
self.0.to_ascii_lowercase().hash(state);
}
}
impl AsRef<str> 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, Self::Err> {
Self::validate_str(s)?;
Ok(TagName(s.to_string()))
}
}
impl TryFrom<String> for TagName {
type Error = TagNameError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::validate_str(&value)?;
Ok(TagName(value))
}
}
impl From<TagName> for String {
fn from(value: TagName) -> Self {
value.0
}
}
impl ToSql for TagName {
fn to_sql(&self) -> RusqliteResult<ToSqlOutput<'_>> {
Ok(self.0.to_sql()?)
}
}
impl FromSql for TagName {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
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();
}