Files
rustpad/src/api/favorites.rs
T

884 lines
27 KiB
Rust

/*
* 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<String>,
}
#[derive(Debug, Deserialize)]
pub struct FavoriteStatusQuery {
kind: String,
slug: String,
#[serde(default)]
workspace_slug: Option<String>,
}
#[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<FavoriteItem>,
}
#[derive(Debug, Serialize)]
pub struct FavoriteItem {
kind: String,
slug: String,
workspace_slug: Option<String>,
workspace_title: Option<String>,
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<Self, sqlx::Error> {
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<Self, sqlx::Error> {
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<crate::auth::User, ApiError> {
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<FavoriteResource, ApiError> {
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<bool, ApiError> {
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<SharedState>,
headers: HeaderMap,
Query(query): Query<FavoriteStatusQuery>,
) -> Result<Json<FavoriteStatus>, 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<SharedState>,
headers: HeaderMap,
Json(target): Json<FavoriteTarget>,
) -> Result<Json<FavoriteStatus>, 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<SharedState>,
headers: HeaderMap,
Json(target): Json<FavoriteTarget>,
) -> Result<Json<FavoriteStatus>, 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<SharedState>,
headers: HeaderMap,
Query(query): Query<FavoriteListQuery>,
) -> Result<Json<FavoriteList>, 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",
&note.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(&note.updated_at),
favorited_at: db::normalize_timestamp(&note.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::<String>::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);
}
}