favourited notes

This commit is contained in:
Mateusz Gruszczyński
2026-09-07 16:39:58 +02:00
parent 87b61d6088
commit f9cf17554a
25 changed files with 1534 additions and 22 deletions
+883
View File
@@ -0,0 +1,883 @@
/*
* 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);
}
}
+19
View File
@@ -9,11 +9,13 @@
mod access_tokens;
mod error;
mod favorites;
mod files;
mod pads_public;
pub use access_tokens::*;
pub use error::ApiError;
pub use favorites::*;
pub use files::*;
pub use pads_public::*;
@@ -419,6 +421,7 @@ pub struct NoteListItem {
url: String,
protected: bool,
can_delete: bool,
favorite: bool,
created_by: Option<String>,
participant_count: i64,
file_count: i64,
@@ -794,6 +797,20 @@ pub async fn open_workspace(
let requester_user = session_user(&state, &headers).await?;
let requester_nickname = requester_user.as_ref().map(|user| user.nickname.as_str());
let requester_guest = requester_guest_id(&headers);
let favorite_note_ids = if let Some(user) = requester_user.as_ref() {
sqlx::query_scalar::<_, i64>(queries::get(
state.db.kind(),
queries::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE,
))
.bind(user.id)
.bind(workspace.id)
.fetch_all(state.db.pool())
.await?
.into_iter()
.collect::<std::collections::HashSet<_>>()
} else {
std::collections::HashSet::new()
};
let stats = db::list_note_stats(&state.db, workspace.id)
.await?
@@ -838,6 +855,7 @@ pub async fn open_workspace(
updated_at: db::normalize_timestamp(&note.updated_at),
protected: note.protected,
can_delete,
favorite: favorite_note_ids.contains(&note.id),
created_by: note.created_by,
participant_count: stats.map_or(0, |value| value.participant_count),
file_count: stats.map_or(0, |value| value.file_count),
@@ -968,6 +986,7 @@ pub async fn create_note(
updated_at: db::normalize_timestamp(&note.updated_at),
protected: note.protected,
can_delete: true,
favorite: false,
created_by: note.created_by,
participant_count: 0,
file_count: 0,
+7
View File
@@ -137,6 +137,13 @@ pub fn router(
.put(auth::update_share_link)
.delete(auth::revoke_share_link),
)
.route(
"/api/auth/favorites",
get(api::list_favorites)
.put(api::add_favorite)
.delete(api::remove_favorite),
)
.route("/api/auth/favorites/status", get(api::favorite_status))
.route(
"/share-invitations/{token}/accept",
get(home).post(auth::accept_share_invitation),
+16
View File
@@ -1135,6 +1135,22 @@ pub async fn confirm_account_action(
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::USER_FAVORITE_PADS_DELETE_BY_USER,
))
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::USER_FAVORITE_NOTES_DELETE_BY_USER,
))
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), queries::AUTH_ANONYMIZE_USER))
.bind(&deleted_nickname)
.bind(normalize(&deleted_nickname))
+22
View File
@@ -80,6 +80,17 @@ pub enum Query {
USER_ATTACH_PAD,
USER_LIST_WORKSPACES,
USER_LIST_PADS,
USER_FAVORITE_PAD_INSERT,
USER_FAVORITE_PAD_DELETE,
USER_FAVORITE_PAD_EXISTS,
USER_FAVORITE_PAD_LIST,
USER_FAVORITE_NOTE_INSERT,
USER_FAVORITE_NOTE_DELETE,
USER_FAVORITE_NOTE_EXISTS,
USER_FAVORITE_NOTE_LIST,
USER_FAVORITE_NOTE_IDS_BY_WORKSPACE,
USER_FAVORITE_PADS_DELETE_BY_USER,
USER_FAVORITE_NOTES_DELETE_BY_USER,
USER_OWNS_WORKSPACE,
USER_OWNS_PAD,
USER_DELETE_WORKSPACE,
@@ -246,6 +257,17 @@ pub const USER_ATTACH_WORKSPACE: Query = Query::USER_ATTACH_WORKSPACE;
pub const USER_ATTACH_PAD: Query = Query::USER_ATTACH_PAD;
pub const USER_LIST_WORKSPACES: Query = Query::USER_LIST_WORKSPACES;
pub const USER_LIST_PADS: Query = Query::USER_LIST_PADS;
pub const USER_FAVORITE_PAD_INSERT: Query = Query::USER_FAVORITE_PAD_INSERT;
pub const USER_FAVORITE_PAD_DELETE: Query = Query::USER_FAVORITE_PAD_DELETE;
pub const USER_FAVORITE_PAD_EXISTS: Query = Query::USER_FAVORITE_PAD_EXISTS;
pub const USER_FAVORITE_PAD_LIST: Query = Query::USER_FAVORITE_PAD_LIST;
pub const USER_FAVORITE_NOTE_INSERT: Query = Query::USER_FAVORITE_NOTE_INSERT;
pub const USER_FAVORITE_NOTE_DELETE: Query = Query::USER_FAVORITE_NOTE_DELETE;
pub const USER_FAVORITE_NOTE_EXISTS: Query = Query::USER_FAVORITE_NOTE_EXISTS;
pub const USER_FAVORITE_NOTE_LIST: Query = Query::USER_FAVORITE_NOTE_LIST;
pub const USER_FAVORITE_NOTE_IDS_BY_WORKSPACE: Query = Query::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE;
pub const USER_FAVORITE_PADS_DELETE_BY_USER: Query = Query::USER_FAVORITE_PADS_DELETE_BY_USER;
pub const USER_FAVORITE_NOTES_DELETE_BY_USER: Query = Query::USER_FAVORITE_NOTES_DELETE_BY_USER;
pub const USER_OWNS_WORKSPACE: Query = Query::USER_OWNS_WORKSPACE;
pub const USER_OWNS_PAD: Query = Query::USER_OWNS_PAD;
pub const USER_DELETE_WORKSPACE: Query = Query::USER_DELETE_WORKSPACE;
+33
View File
@@ -178,6 +178,39 @@ pub fn get(query: Query) -> &'static str {
Query::USER_LIST_PADS => {
r#"SELECT p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED) AS protected, p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS SIGNED) AS private, CAST(1 AS SIGNED) AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? UNION SELECT p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED), p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS SIGNED), CAST(0 AS SIGNED), rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = ? ORDER BY updated_at DESC"#
}
Query::USER_FAVORITE_PAD_INSERT => {
r#"INSERT IGNORE INTO user_pad_favorites (user_id, pad_id) VALUES (?, ?)"#
}
Query::USER_FAVORITE_PAD_DELETE => {
r#"DELETE FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?"#
}
Query::USER_FAVORITE_PAD_EXISTS => {
r#"SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?"#
}
Query::USER_FAVORITE_PAD_LIST => {
r#"SELECT p.id, p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS SIGNED) AS private, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED) AS protected, f.created_at AS favorited_at FROM user_pad_favorites f JOIN pads p ON p.id = f.pad_id WHERE f.user_id = ? ORDER BY f.created_at DESC, p.id DESC"#
}
Query::USER_FAVORITE_NOTE_INSERT => {
r#"INSERT IGNORE INTO user_note_favorites (user_id, note_id) VALUES (?, ?)"#
}
Query::USER_FAVORITE_NOTE_DELETE => {
r#"DELETE FROM user_note_favorites WHERE user_id = ? AND note_id = ?"#
}
Query::USER_FAVORITE_NOTE_EXISTS => {
r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?"#
}
Query::USER_FAVORITE_NOTE_LIST => {
r#"SELECT n.id, n.slug, CAST(n.title AS CHAR CHARACTER SET utf8mb4) AS title, n.updated_at, w.slug AS workspace_slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS workspace_title, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS SIGNED) AS private, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED) AS protected, f.created_at AS favorited_at FROM user_note_favorites f JOIN notes n ON n.id = f.note_id JOIN workspaces w ON w.id = n.workspace_id WHERE f.user_id = ? ORDER BY f.created_at DESC, n.id DESC"#
}
Query::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE => {
r#"SELECT f.note_id FROM user_note_favorites f JOIN notes n ON n.id = f.note_id WHERE f.user_id = ? AND n.workspace_id = ?"#
}
Query::USER_FAVORITE_PADS_DELETE_BY_USER => {
r#"DELETE FROM user_pad_favorites WHERE user_id = ?"#
}
Query::USER_FAVORITE_NOTES_DELETE_BY_USER => {
r#"DELETE FROM user_note_favorites WHERE user_id = ?"#
}
Query::USER_OWNS_WORKSPACE => {
r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?"#
}
+33
View File
@@ -180,6 +180,39 @@ pub fn get(query: Query) -> &'static str {
Query::USER_LIST_PADS => {
r#"SELECT p.slug, p.title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS BIGINT) AS private, CAST(1 AS BIGINT) AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = $1 UNION SELECT p.slug, p.title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT), p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS BIGINT), CAST(0 AS BIGINT), rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = $2 ORDER BY updated_at DESC"#
}
Query::USER_FAVORITE_PAD_INSERT => {
r#"INSERT INTO user_pad_favorites (user_id, pad_id) VALUES ($1, $2) ON CONFLICT (user_id, pad_id) DO NOTHING"#
}
Query::USER_FAVORITE_PAD_DELETE => {
r#"DELETE FROM user_pad_favorites WHERE user_id = $1 AND pad_id = $2"#
}
Query::USER_FAVORITE_PAD_EXISTS => {
r#"SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = $1 AND pad_id = $2"#
}
Query::USER_FAVORITE_PAD_LIST => {
r#"SELECT p.id, p.slug, p.title, p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS BIGINT) AS private, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, f.created_at AS favorited_at FROM user_pad_favorites f JOIN pads p ON p.id = f.pad_id WHERE f.user_id = $1 ORDER BY f.created_at DESC, p.id DESC"#
}
Query::USER_FAVORITE_NOTE_INSERT => {
r#"INSERT INTO user_note_favorites (user_id, note_id) VALUES ($1, $2) ON CONFLICT (user_id, note_id) DO NOTHING"#
}
Query::USER_FAVORITE_NOTE_DELETE => {
r#"DELETE FROM user_note_favorites WHERE user_id = $1 AND note_id = $2"#
}
Query::USER_FAVORITE_NOTE_EXISTS => {
r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = $1 AND note_id = $2"#
}
Query::USER_FAVORITE_NOTE_LIST => {
r#"SELECT n.id, n.slug, n.title, n.updated_at, w.slug AS workspace_slug, w.title AS workspace_title, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS BIGINT) AS private, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, f.created_at AS favorited_at FROM user_note_favorites f JOIN notes n ON n.id = f.note_id JOIN workspaces w ON w.id = n.workspace_id WHERE f.user_id = $1 ORDER BY f.created_at DESC, n.id DESC"#
}
Query::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE => {
r#"SELECT f.note_id FROM user_note_favorites f JOIN notes n ON n.id = f.note_id WHERE f.user_id = $1 AND n.workspace_id = $2"#
}
Query::USER_FAVORITE_PADS_DELETE_BY_USER => {
r#"DELETE FROM user_pad_favorites WHERE user_id = $1"#
}
Query::USER_FAVORITE_NOTES_DELETE_BY_USER => {
r#"DELETE FROM user_note_favorites WHERE user_id = $1"#
}
Query::USER_OWNS_WORKSPACE => {
r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = $1 AND w.slug = $2"#
}
+33
View File
@@ -178,6 +178,39 @@ pub fn get(query: Query) -> &'static str {
Query::USER_LIST_PADS => {
r#"SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? UNION SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = ? ORDER BY updated_at DESC"#
}
Query::USER_FAVORITE_PAD_INSERT => {
r#"INSERT OR IGNORE INTO user_pad_favorites (user_id, pad_id) VALUES (?, ?)"#
}
Query::USER_FAVORITE_PAD_DELETE => {
r#"DELETE FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?"#
}
Query::USER_FAVORITE_PAD_EXISTS => {
r#"SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?"#
}
Query::USER_FAVORITE_PAD_LIST => {
r#"SELECT p.id, p.slug, p.title, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END AS private, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, f.created_at AS favorited_at FROM user_pad_favorites f JOIN pads p ON p.id = f.pad_id WHERE f.user_id = ? ORDER BY f.created_at DESC, p.id DESC"#
}
Query::USER_FAVORITE_NOTE_INSERT => {
r#"INSERT OR IGNORE INTO user_note_favorites (user_id, note_id) VALUES (?, ?)"#
}
Query::USER_FAVORITE_NOTE_DELETE => {
r#"DELETE FROM user_note_favorites WHERE user_id = ? AND note_id = ?"#
}
Query::USER_FAVORITE_NOTE_EXISTS => {
r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?"#
}
Query::USER_FAVORITE_NOTE_LIST => {
r#"SELECT n.id, n.slug, n.title, n.updated_at, w.slug AS workspace_slug, w.title AS workspace_title, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, f.created_at AS favorited_at FROM user_note_favorites f JOIN notes n ON n.id = f.note_id JOIN workspaces w ON w.id = n.workspace_id WHERE f.user_id = ? ORDER BY f.created_at DESC, n.id DESC"#
}
Query::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE => {
r#"SELECT f.note_id FROM user_note_favorites f JOIN notes n ON n.id = f.note_id WHERE f.user_id = ? AND n.workspace_id = ?"#
}
Query::USER_FAVORITE_PADS_DELETE_BY_USER => {
r#"DELETE FROM user_pad_favorites WHERE user_id = ?"#
}
Query::USER_FAVORITE_NOTES_DELETE_BY_USER => {
r#"DELETE FROM user_note_favorites WHERE user_id = ?"#
}
Query::USER_OWNS_WORKSPACE => {
r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?"#
}
+11
View File
@@ -19,6 +19,17 @@ fn every_backend_has_explicit_queries() {
Q033,
USER_LIST_WORKSPACES,
USER_LIST_PADS,
USER_FAVORITE_PAD_INSERT,
USER_FAVORITE_PAD_DELETE,
USER_FAVORITE_PAD_EXISTS,
USER_FAVORITE_PAD_LIST,
USER_FAVORITE_NOTE_INSERT,
USER_FAVORITE_NOTE_DELETE,
USER_FAVORITE_NOTE_EXISTS,
USER_FAVORITE_NOTE_LIST,
USER_FAVORITE_NOTE_IDS_BY_WORKSPACE,
USER_FAVORITE_PADS_DELETE_BY_USER,
USER_FAVORITE_NOTES_DELETE_BY_USER,
RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH,
SHARE_LINK_SESSION_SOURCE,
SHARE_SESSION_INSERT,
+2
View File
@@ -23,6 +23,8 @@ fn boolean_projections_are_normalized_for_sqlx_any() {
(Query::AUTH_USER_BY_SHARE_IDENTIFIER, 1),
(Query::USER_LIST_WORKSPACES, 6),
(Query::USER_LIST_PADS, 6),
(Query::USER_FAVORITE_PAD_LIST, 2),
(Query::USER_FAVORITE_NOTE_LIST, 2),
(Query::PAD_PUBLIC_PAGE_DISABLED, 1),
(Query::NOTE_PUBLIC_PAGE_DISABLED, 1),
(Query::Q001, 1),