/* * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl * Source-Available Code / Dual-Licensed. * * Free for non-commercial and evaluation use under terms of BSL/GPLv3. * Commercial or production use requires a valid paid license. * See LICENSE file in repository root for details. */ use super::*; use sqlx::{Row, any::AnyRow}; #[derive(Debug, Deserialize)] pub struct FavoriteTarget { kind: String, slug: String, #[serde(default)] workspace_slug: Option, } #[derive(Debug, Deserialize)] pub struct FavoriteStatusQuery { kind: String, slug: String, #[serde(default)] workspace_slug: Option, } #[derive(Debug, Deserialize)] pub struct FavoriteListQuery { #[serde(default)] q: String, } #[derive(Debug, Serialize)] pub struct FavoriteStatus { favorite: bool, } #[derive(Debug, Serialize)] pub struct FavoriteList { items: Vec, } #[derive(Debug, Serialize)] pub struct FavoriteItem { kind: String, slug: String, workspace_slug: Option, workspace_title: Option, title: String, url: String, updated_at: String, favorited_at: String, } #[derive(Debug)] struct FavoritePadRow { slug: String, title: String, updated_at: String, private_resource: i64, protected_resource: i64, favorited_at: String, } impl<'r> sqlx::FromRow<'r, AnyRow> for FavoritePadRow { fn from_row(row: &'r AnyRow) -> Result { Ok(Self { slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, updated_at: crate::row_decode::text(row, "updated_at")?, private_resource: row.try_get("private")?, protected_resource: row.try_get("protected")?, favorited_at: crate::row_decode::text(row, "favorited_at")?, }) } } #[derive(Debug)] struct FavoriteNoteRow { slug: String, title: String, updated_at: String, workspace_slug: String, workspace_title: String, private_resource: i64, protected_resource: i64, favorited_at: String, } impl<'r> sqlx::FromRow<'r, AnyRow> for FavoriteNoteRow { fn from_row(row: &'r AnyRow) -> Result { Ok(Self { slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, updated_at: crate::row_decode::text(row, "updated_at")?, workspace_slug: crate::row_decode::text(row, "workspace_slug")?, workspace_title: crate::row_decode::text(row, "workspace_title")?, private_resource: row.try_get("private")?, protected_resource: row.try_get("protected")?, favorited_at: crate::row_decode::text(row, "favorited_at")?, }) } } #[derive(Clone, Copy)] enum FavoriteResource { Pad(i64), Note(i64), } async fn favorite_user( state: &SharedState, headers: &HeaderMap, ) -> Result { session_user(state, headers) .await? .ok_or_else(|| ApiError::forbidden("Log in to use favorites")) } async fn resolve_favorite_target( state: &SharedState, headers: &HeaderMap, target: &FavoriteTarget, ) -> Result { let kind = target.kind.trim(); let slug = target.slug.trim(); if slug.is_empty() { return Err(ApiError::bad_request("Favorite slug is required")); } match kind { "pad" => { let pad = db::find_pad(&state.db, slug) .await? .ok_or_else(ApiError::not_found_note)?; if (pad.is_private != 0 || pad.password_hash.is_some()) && !has_header_resource_access(state, headers, "pad", &pad.slug).await? { return Err(ApiError::not_found_note()); } Ok(FavoriteResource::Pad(pad.id)) } "note" => { let workspace_slug = target .workspace_slug .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| ApiError::bad_request("Workspace slug is required for a note"))?; let workspace = db::find_workspace(&state.db, workspace_slug) .await? .ok_or_else(ApiError::not_found_workspace)?; if (workspace.is_private != 0 || workspace.password_hash.is_some()) && !has_header_resource_access(state, headers, "workspace", &workspace.slug).await? { return Err(ApiError::not_found_workspace()); } let note = db::find_note(&state.db, workspace.id, slug) .await? .ok_or_else(ApiError::not_found_note)?; Ok(FavoriteResource::Note(note.id)) } _ => Err(ApiError::bad_request("Invalid favorite resource kind")), } } async fn favorite_exists( state: &SharedState, user_id: i64, resource: FavoriteResource, ) -> Result { let (query, resource_id) = match resource { FavoriteResource::Pad(id) => (queries::USER_FAVORITE_PAD_EXISTS, id), FavoriteResource::Note(id) => (queries::USER_FAVORITE_NOTE_EXISTS, id), }; let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), query)) .bind(user_id) .bind(resource_id) .fetch_one(state.db.pool()) .await?; Ok(count > 0) } pub async fn favorite_status( State(state): State, headers: HeaderMap, Query(query): Query, ) -> Result, ApiError> { let user = favorite_user(&state, &headers).await?; let target = FavoriteTarget { kind: query.kind, slug: query.slug, workspace_slug: query.workspace_slug, }; let resource = resolve_favorite_target(&state, &headers, &target).await?; Ok(Json(FavoriteStatus { favorite: favorite_exists(&state, user.id, resource).await?, })) } pub async fn add_favorite( State(state): State, headers: HeaderMap, Json(target): Json, ) -> Result, ApiError> { let user = favorite_user(&state, &headers).await?; let resource = resolve_favorite_target(&state, &headers, &target).await?; let (query, resource_id) = match resource { FavoriteResource::Pad(id) => (queries::USER_FAVORITE_PAD_INSERT, id), FavoriteResource::Note(id) => (queries::USER_FAVORITE_NOTE_INSERT, id), }; sqlx::query(queries::get(state.db.kind(), query)) .bind(user.id) .bind(resource_id) .execute(state.db.pool()) .await?; Ok(Json(FavoriteStatus { favorite: true })) } pub async fn remove_favorite( State(state): State, headers: HeaderMap, Json(target): Json, ) -> Result, ApiError> { let user = favorite_user(&state, &headers).await?; let resource = resolve_favorite_target(&state, &headers, &target).await?; let (query, resource_id) = match resource { FavoriteResource::Pad(id) => (queries::USER_FAVORITE_PAD_DELETE, id), FavoriteResource::Note(id) => (queries::USER_FAVORITE_NOTE_DELETE, id), }; sqlx::query(queries::get(state.db.kind(), query)) .bind(user.id) .bind(resource_id) .execute(state.db.pool()) .await?; Ok(Json(FavoriteStatus { favorite: false })) } pub async fn list_favorites( State(state): State, headers: HeaderMap, Query(query): Query, ) -> Result, ApiError> { let user = favorite_user(&state, &headers).await?; let pads = sqlx::query_as::<_, FavoritePadRow>(queries::get( state.db.kind(), queries::USER_FAVORITE_PAD_LIST, )) .bind(user.id) .fetch_all(state.db.pool()) .await?; let notes = sqlx::query_as::<_, FavoriteNoteRow>(queries::get( state.db.kind(), queries::USER_FAVORITE_NOTE_LIST, )) .bind(user.id) .fetch_all(state.db.pool()) .await?; let search = query.q.trim().to_lowercase(); let mut items = Vec::with_capacity(pads.len() + notes.len()); for pad in pads { if (pad.private_resource != 0 || pad.protected_resource != 0) && !has_header_resource_access(&state, &headers, "pad", &pad.slug).await? { continue; } if !search.is_empty() && !pad.title.to_lowercase().contains(&search) && !pad.slug.to_lowercase().contains(&search) { continue; } items.push(FavoriteItem { kind: "pad".into(), url: format!("/p/{}", pad.slug), slug: pad.slug, workspace_slug: None, workspace_title: None, title: pad.title, updated_at: db::normalize_timestamp(&pad.updated_at), favorited_at: db::normalize_timestamp(&pad.favorited_at), }); } for note in notes { if (note.private_resource != 0 || note.protected_resource != 0) && !has_header_resource_access( &state, &headers, "workspace", ¬e.workspace_slug, ) .await? { continue; } if !search.is_empty() && !note.title.to_lowercase().contains(&search) && !note.slug.to_lowercase().contains(&search) && !note.workspace_title.to_lowercase().contains(&search) && !note.workspace_slug.to_lowercase().contains(&search) { continue; } items.push(FavoriteItem { kind: "note".into(), url: format!("/w/{}/n/{}", note.workspace_slug, note.slug), slug: note.slug, workspace_slug: Some(note.workspace_slug), workspace_title: Some(note.workspace_title), title: note.title, updated_at: db::normalize_timestamp(¬e.updated_at), favorited_at: db::normalize_timestamp(¬e.favorited_at), }); } items.sort_by(|left, right| right.favorited_at.cmp(&left.favorited_at)); Ok(Json(FavoriteList { items })) } #[cfg(test)] mod tests { use super::*; use crate::{database::Database, state::AppState, storage::Storage}; use sha2::{Digest, Sha256}; use std::sync::Arc; async fn test_state() -> SharedState { let db = Database::connect("sqlite::memory:", 1) .await .expect("test database"); crate::run_migrations(&db).await.expect("test migrations"); Arc::new(AppState::new( db, "test".into(), Storage::Local { root: std::env::temp_dir().join("rustpad-favorite-tests"), }, 1_000_000, true, 1_000_000, 0, None, None, true, false, false, "error".into(), 7, 7, 7, None, )) } async fn logged_in_headers(state: &SharedState, suffix: &str) -> (HeaderMap, i64) { let nickname = format!("Favorite {suffix}"); let nickname_key = nickname.to_lowercase(); let email = format!("favorite-{suffix}@example.test"); sqlx::query( "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)", ) .bind(&nickname) .bind(&nickname_key) .bind(&email) .bind(&email) .bind("password-hash") .execute(state.db.pool()) .await .unwrap(); let user_id: i64 = sqlx::query_scalar("SELECT id FROM users WHERE email_key = ?") .bind(&email) .fetch_one(state.db.pool()) .await .unwrap(); let token = format!("favorite-session-{suffix}"); let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339(); sqlx::query("INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)") .bind(&token) .bind(user_id) .bind(expires_at) .execute(state.db.pool()) .await .unwrap(); let mut headers = HeaderMap::new(); headers.insert( header::COOKIE, HeaderValue::from_str(&format!( "{}={token}", crate::security::SESSION_COOKIE )) .unwrap(), ); (headers, user_id) } #[tokio::test] async fn favorite_pad_is_idempotent_listed_and_deleted_with_pad() { let state = test_state().await; let (headers, user_id) = logged_in_headers(&state, "pad").await; let pad = db::create_pad(&state.db, "favorite-pad", "Favorite Pad", None, None) .await .unwrap(); let target = || FavoriteTarget { kind: "pad".into(), slug: pad.slug.clone(), workspace_slug: None, }; for _ in 0..2 { let result = add_favorite( State(state.clone()), headers.clone(), Json(target()), ) .await .unwrap(); assert!(result.0.favorite); } let stored: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?", ) .bind(user_id) .bind(pad.id) .fetch_one(state.db.pool()) .await .unwrap(); assert_eq!(stored, 1); let listed = list_favorites( State(state.clone()), headers.clone(), Query(FavoriteListQuery { q: String::new() }), ) .await .unwrap(); assert_eq!(listed.0.items.len(), 1); assert_eq!(listed.0.items[0].kind, "pad"); assert_eq!(listed.0.items[0].url, "/p/favorite-pad"); sqlx::query("DELETE FROM pads WHERE id = ?") .bind(pad.id) .execute(state.db.pool()) .await .unwrap(); let stored_after_delete: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ?") .bind(user_id) .fetch_one(state.db.pool()) .await .unwrap(); assert_eq!(stored_after_delete, 0); } #[tokio::test] async fn private_note_favorite_is_hidden_after_permission_revocation() { let state = test_state().await; let (headers, user_id) = logged_in_headers(&state, "private-note").await; let workspace = db::create_workspace( &state.db, "private-workspace", "Private Workspace", None, None, ) .await .unwrap(); sqlx::query("UPDATE workspaces SET is_private = 1 WHERE id = ?") .bind(workspace.id) .execute(state.db.pool()) .await .unwrap(); let note = db::create_note( &state.db, workspace.id, "shared-note", "Shared Note", false, None, None, ) .await .unwrap(); sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_PERMISSION_INSERT, )) .bind("workspace") .bind(&workspace.slug) .bind(user_id) .bind("ro") .execute(state.db.pool()) .await .unwrap(); add_favorite( State(state.clone()), headers.clone(), Json(FavoriteTarget { kind: "note".into(), slug: note.slug.clone(), workspace_slug: Some(workspace.slug.clone()), }), ) .await .unwrap(); let before = list_favorites( State(state.clone()), headers.clone(), Query(FavoriteListQuery { q: String::new() }), ) .await .unwrap(); assert_eq!(before.0.items.len(), 1); sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER, )) .bind("workspace") .bind(&workspace.slug) .bind(user_id) .execute(state.db.pool()) .await .unwrap(); let after = list_favorites( State(state.clone()), headers.clone(), Query(FavoriteListQuery { q: String::new() }), ) .await .unwrap(); assert!(after.0.items.is_empty()); let stored: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?", ) .bind(user_id) .bind(note.id) .fetch_one(state.db.pool()) .await .unwrap(); assert_eq!(stored, 1); sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_PERMISSION_INSERT, )) .bind("workspace") .bind(&workspace.slug) .bind(user_id) .bind("ro") .execute(state.db.pool()) .await .unwrap(); let restored = list_favorites( State(state.clone()), headers, Query(FavoriteListQuery { q: String::new() }), ) .await .unwrap(); assert_eq!(restored.0.items.len(), 1); } #[tokio::test] async fn public_note_favorite_hides_when_workspace_becomes_private_and_returns_when_public() { let state = test_state().await; let (headers, user_id) = logged_in_headers(&state, "privacy-toggle").await; let workspace = db::create_workspace( &state.db, "privacy-toggle-workspace", "Privacy Toggle Workspace", None, None, ) .await .unwrap(); let note = db::create_note( &state.db, workspace.id, "privacy-toggle-note", "Privacy Toggle Note", false, None, None, ) .await .unwrap(); add_favorite( State(state.clone()), headers.clone(), Json(FavoriteTarget { kind: "note".into(), slug: note.slug.clone(), workspace_slug: Some(workspace.slug.clone()), }), ) .await .unwrap(); sqlx::query("UPDATE workspaces SET is_private = 1 WHERE id = ?") .bind(workspace.id) .execute(state.db.pool()) .await .unwrap(); let hidden = list_favorites( State(state.clone()), headers.clone(), Query(FavoriteListQuery { q: String::new() }), ) .await .unwrap(); assert!(hidden.0.items.is_empty()); let stored: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?", ) .bind(user_id) .bind(note.id) .fetch_one(state.db.pool()) .await .unwrap(); assert_eq!(stored, 1); sqlx::query("UPDATE workspaces SET is_private = 0 WHERE id = ?") .bind(workspace.id) .execute(state.db.pool()) .await .unwrap(); let visible_again = list_favorites( State(state), headers, Query(FavoriteListQuery { q: String::new() }), ) .await .unwrap(); assert_eq!(visible_again.0.items.len(), 1); } #[tokio::test] async fn revoked_share_link_hides_favorite_but_keeps_marker() { let state = test_state().await; let (mut headers, user_id) = logged_in_headers(&state, "share-revoke").await; let workspace = db::create_workspace( &state.db, "share-revoke-workspace", "Share Revoke Workspace", None, None, ) .await .unwrap(); sqlx::query("UPDATE workspaces SET is_private = 1 WHERE id = ?") .bind(workspace.id) .execute(state.db.pool()) .await .unwrap(); let note = db::create_note( &state.db, workspace.id, "share-revoke-note", "Share Revoke Note", false, None, None, ) .await .unwrap(); let share_token = "a".repeat(64); let token_hash = hex::encode(Sha256::digest(share_token.as_bytes())); sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT)) .bind(&token_hash) .bind(Some("favorite test".to_owned())) .bind("workspace") .bind(&workspace.slug) .bind("ro") .bind(Option::::None) .bind(user_id) .execute(state.db.pool()) .await .unwrap(); headers.insert( header::AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {share_token}")).unwrap(), ); add_favorite( State(state.clone()), headers.clone(), Json(FavoriteTarget { kind: "note".into(), slug: note.slug.clone(), workspace_slug: Some(workspace.slug.clone()), }), ) .await .unwrap(); let visible = list_favorites( State(state.clone()), headers.clone(), Query(FavoriteListQuery { q: String::new() }), ) .await .unwrap(); assert_eq!(visible.0.items.len(), 1); sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_REVOKE)) .bind(Utc::now().to_rfc3339()) .bind(&token_hash) .bind("workspace") .bind(&workspace.slug) .execute(state.db.pool()) .await .unwrap(); let hidden = list_favorites( State(state.clone()), headers, Query(FavoriteListQuery { q: String::new() }), ) .await .unwrap(); assert!(hidden.0.items.is_empty()); let stored: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?", ) .bind(user_id) .bind(note.id) .fetch_one(state.db.pool()) .await .unwrap(); assert_eq!(stored, 1); } #[tokio::test] async fn revoked_password_token_hides_protected_pad_favorite() { let state = test_state().await; let (mut headers, user_id) = logged_in_headers(&state, "password-revoke").await; let pad = db::create_pad( &state.db, "password-revoke-pad", "Password Revoke Pad", Some("password123"), None, ) .await .unwrap(); let access_token = "b".repeat(64); let token_hash = hex::encode(Sha256::digest(access_token.as_bytes())); sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_INSERT, )) .bind(token_hash) .bind("pad") .bind(&pad.slug) .bind((Utc::now() + Duration::days(7)).to_rfc3339()) .execute(state.db.pool()) .await .unwrap(); headers.insert( header::AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {access_token}")).unwrap(), ); add_favorite( State(state.clone()), headers.clone(), Json(FavoriteTarget { kind: "pad".into(), slug: pad.slug.clone(), workspace_slug: None, }), ) .await .unwrap(); let visible = list_favorites( State(state.clone()), headers.clone(), Query(FavoriteListQuery { q: String::new() }), ) .await .unwrap(); assert_eq!(visible.0.items.len(), 1); sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE, )) .bind("pad") .bind(&pad.slug) .execute(state.db.pool()) .await .unwrap(); let hidden = list_favorites( State(state.clone()), headers, Query(FavoriteListQuery { q: String::new() }), ) .await .unwrap(); assert!(hidden.0.items.is_empty()); let stored: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?", ) .bind(user_id) .bind(pad.id) .fetch_one(state.db.pool()) .await .unwrap(); assert_eq!(stored, 1); } #[tokio::test] async fn favorites_require_an_active_login() { let state = test_state().await; db::create_pad(&state.db, "login-only-pad", "Login only", None, None) .await .unwrap(); let target = || FavoriteTarget { kind: "pad".into(), slug: "login-only-pad".into(), workspace_slug: None, }; let anonymous = add_favorite( State(state.clone()), HeaderMap::new(), Json(target()), ) .await .expect_err("anonymous favorite must fail"); assert_eq!(anonymous.into_response().status(), StatusCode::FORBIDDEN); let (inactive_headers, user_id) = logged_in_headers(&state, "inactive").await; sqlx::query("UPDATE users SET is_active = 0 WHERE id = ?") .bind(user_id) .execute(state.db.pool()) .await .unwrap(); let inactive = add_favorite( State(state), inactive_headers, Json(target()), ) .await .expect_err("inactive account favorite must fail"); assert_eq!(inactive.into_response().status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn password_protected_pad_requires_current_resource_access() { let state = test_state().await; let (headers, _) = logged_in_headers(&state, "protected").await; db::create_pad( &state.db, "protected-pad", "Protected Pad", Some("password123"), None, ) .await .unwrap(); let error = add_favorite( State(state), headers, Json(FavoriteTarget { kind: "pad".into(), slug: "protected-pad".into(), workspace_slug: None, }), ) .await .expect_err("protected favorite without resource access must fail"); assert_eq!(error.into_response().status(), StatusCode::NOT_FOUND); } }