This commit is contained in:
Mateusz Gruszczyński committed 2026-10-12 12:19:33 +02:00
1 parent acfe926066
commit df403c0aa7
26 files changed
+1502 -144

No files matched your search

+12 -33
View File
@@ -288,13 +288,8 @@ pub async fn list_favorites(
for note in notes {
if (note.private_resource != 0 || note.protected_resource != 0)
&& !has_header_resource_access(
&state,
&headers,
"workspace",
&note.workspace_slug,
)
.await?
&& !has_header_resource_access(&state, &headers, "workspace", &note.workspace_slug)
.await?
{
continue;
}
@@ -389,11 +384,7 @@ mod tests {
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
HeaderValue::from_str(&format!(
"{}={token}",
crate::security::SESSION_COOKIE
))
.unwrap(),
HeaderValue::from_str(&format!("{}={token}", crate::security::SESSION_COOKIE)).unwrap(),
);
(headers, user_id)
}
@@ -412,13 +403,9 @@ mod tests {
};
for _ in 0..2 {
let result = add_favorite(
State(state.clone()),
headers.clone(),
Json(target()),
)
.await
.unwrap();
let result = add_favorite(State(state.clone()), headers.clone(), Json(target()))
.await
.unwrap();
assert!(result.0.favorite);
}
@@ -828,13 +815,9 @@ mod tests {
workspace_slug: None,
};
let anonymous = add_favorite(
State(state.clone()),
HeaderMap::new(),
Json(target()),
)
.await
.expect_err("anonymous favorite must fail");
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;
@@ -843,13 +826,9 @@ mod tests {
.execute(state.db.pool())
.await
.unwrap();
let inactive = add_favorite(
State(state),
inactive_headers,
Json(target()),
)
.await
.expect_err("inactive account favorite must fail");
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);
}
+5 -2
View File
@@ -125,6 +125,7 @@ pub fn router(
"/api/auth/resources/privacy",
post(auth::set_resource_privacy),
)
.route("/api/auth/resources/mentions", get(auth::resource_mentions))
.route(
"/api/auth/resources/sharing",
get(auth::resource_sharing)
@@ -261,7 +262,6 @@ pub fn router(
.with_state(state)
}
async fn language_catalog() -> Response {
let mut response = Json(crate::i18n::metadata()).into_response();
response.headers_mut().insert(
@@ -375,7 +375,10 @@ fn is_file_path(path: &str) -> bool {
}
fn is_asset_path(path: &str) -> bool {
path == "/assets" || path.starts_with("/assets/") || path == "/lang" || path.starts_with("/lang/")
path == "/assets"
|| path.starts_with("/assets/")
|| path == "/lang"
|| path.starts_with("/lang/")
}
fn is_icon_path(path: &str) -> bool {
+32 -27
View File
@@ -81,27 +81,21 @@ async fn share_session_redirect(
) -> Response {
let client_key = crate::security::client_key(headers);
let cookie = match share.map(str::trim).filter(|value| !value.is_empty()) {
Some(share) => match auth::create_share_session(
state,
kind,
slug,
share,
&client_key,
)
.await
{
Ok(Some(session)) => Some(crate::security::share_session_cookie(
kind,
slug,
&session.token,
session.max_age_seconds,
)),
Ok(None) => None,
Err(error) => {
tracing::warn!(error = %error.message, kind, slug, "failed to exchange share link for guest session");
None
Some(share) => {
match auth::create_share_session(state, kind, slug, share, &client_key).await {
Ok(Some(session)) => Some(crate::security::share_session_cookie(
kind,
slug,
&session.token,
session.max_age_seconds,
)),
Ok(None) => None,
Err(error) => {
tracing::warn!(error = %error.message, kind, slug, "failed to exchange share link for guest session");
None
}
}
},
}
None => None,
};
@@ -118,10 +112,9 @@ async fn share_session_redirect(
header::CACHE_CONTROL,
HeaderValue::from_static("no-store, max-age=0"),
);
response.headers_mut().insert(
"referrer-policy",
HeaderValue::from_static("no-referrer"),
);
response
.headers_mut()
.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
response
}
@@ -383,7 +376,11 @@ pub(super) async fn workspace(
)
.replace(
"__WORKSPACE_TITLE_I18N__",
if workspace.is_private != 0 { r#"data-i18n="common.workspace""# } else { "" },
if workspace.is_private != 0 {
r#"data-i18n="common.workspace""#
} else {
""
},
);
assets::render_html(
&html,
@@ -460,7 +457,11 @@ pub(super) async fn note(
} else {
&note.title
},
if workspace.is_private != 0 { Some("common.note") } else { None },
if workspace.is_private != 0 {
Some("common.note")
} else {
None
},
if workspace.is_private != 0 {
"Workspace"
} else {
@@ -548,7 +549,11 @@ pub(super) fn error_response(
let html = include_str!("../../static/error.html")
.replace(
"__APP_THEME_BOOTSTRAP__",
&format!("{}{}", assets::theme_bootstrap(), assets::language_bootstrap()),
&format!(
"{}{}",
assets::theme_bootstrap(),
assets::language_bootstrap()
),
)
.replace(
"__APP_STYLESHEET__",
+2
View File
@@ -31,6 +31,8 @@ const MODULES: &[&str] = &[
"logger",
"line-links",
"markdown",
"mention-autocomplete",
"mention-navigation",
"modal",
"note-api",
"note-editor",
+64 -6
View File
@@ -1791,6 +1791,69 @@ pub async fn remove_resource_user(
Ok(Json(serde_json::json!({"ok":true})))
}
/// Return only explicitly authorized account names; never expose e-mail addresses.
/// The requester must themselves own the resource or have an accepted direct share.
pub async fn resource_mentions(
State(state): State<SharedState>,
headers: HeaderMap,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, AuthError> {
let user = require_user(&state, &headers).await?;
let kind = params.get("kind").map(String::as_str).unwrap_or("");
let slug = params.get("slug").map(String::as_str).unwrap_or("").trim();
let (query, owner_query) = match kind {
"workspace" => (
queries::RESOURCE_MENTION_USERS_WORKSPACE,
queries::USER_OWNS_WORKSPACE,
),
"pad" => (queries::RESOURCE_MENTION_USERS_PAD, queries::USER_OWNS_PAD),
_ => return Err(AuthError::bad_request("Unknown resource type.")),
};
if slug.is_empty() {
return Err(AuthError::bad_request("Missing slug."));
}
let owned: i64 = sqlx::query_scalar(queries::get(state.db.kind(), owner_query))
.bind(user.id)
.bind(slug)
.fetch_one(state.db.pool())
.await
.map_err(AuthError::database)?;
if owned == 0 {
let access: Option<String> = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::RESOURCE_PERMISSION_BY_USER,
))
.bind(kind)
.bind(slug)
.bind(user.id)
.fetch_optional(state.db.pool())
.await
.map_err(AuthError::database)?;
if access.is_none() {
return Err(AuthError::forbidden(
"This resource was not shared with your account.",
));
}
}
let rows = sqlx::query(queries::get(state.db.kind(), query))
.bind(slug)
.bind(kind)
.bind(slug)
.fetch_all(state.db.pool())
.await
.map_err(AuthError::database)?;
let users: Vec<_> = rows
.iter()
.map(|row| -> Result<serde_json::Value, AuthError> {
Ok(serde_json::json!({
"nickname": crate::row_decode::text(row, 0).map_err(AuthError::database)?,
"color": crate::row_decode::optional_text(row, 1).map_err(AuthError::database)?,
}))
})
.collect::<Result<_, _>>()?;
Ok(Json(serde_json::json!({ "users": users })))
}
pub async fn resource_sharing(
State(state): State<SharedState>,
headers: HeaderMap,
@@ -2262,12 +2325,7 @@ pub async fn logout(
let mut revoked_access_tokens = 0_u64;
if session_token.is_some() || has_access_tokens {
let mut tx = state
.db
.pool()
.begin()
.await
.map_err(AuthError::database)?;
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
if let Some(token) = session_token.as_deref() {
let result = sqlx::query(queries::get(
+3 -6
View File
@@ -76,8 +76,6 @@ pub struct Note {
#[derive(Debug, Clone, Serialize)]
pub struct ArchivedNote {
#[serde(skip_serializing)]
pub id: i64,
pub slug: String,
pub title: String,
pub created_at: String,
@@ -345,20 +343,20 @@ pub async fn list_archived_notes(
) -> Result<Vec<ArchivedNote>, sqlx::Error> {
let query = match pool.kind() {
DatabaseKind::Sqlite => {
r#"SELECT id, slug, title, created_at, updated_at, deleted_at, protected, created_by
r#"SELECT slug, title, created_at, updated_at, deleted_at, protected, created_by
FROM notes
WHERE workspace_id = ? AND deleted_at IS NOT NULL
ORDER BY deleted_at DESC, id DESC"#
}
DatabaseKind::Postgres => {
r#"SELECT id, slug, title, created_at, updated_at, deleted_at,
r#"SELECT slug, title, created_at, updated_at, deleted_at,
CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by
FROM notes
WHERE workspace_id = $1 AND deleted_at IS NOT NULL
ORDER BY deleted_at DESC, id DESC"#
}
DatabaseKind::MySql => {
r#"SELECT id, CAST(slug AS CHAR CHARACTER SET utf8mb4) AS slug,
r#"SELECT CAST(slug AS CHAR CHARACTER SET utf8mb4) AS slug,
CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, created_at, updated_at, deleted_at,
CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected,
CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by
@@ -374,7 +372,6 @@ pub async fn list_archived_notes(
rows.into_iter()
.map(|row| {
Ok(ArchivedNote {
id: row.try_get("id")?,
slug: crate::row_decode::text(&row, "slug")?,
title: crate::row_decode::text(&row, "title")?,
created_at: crate::row_decode::text(&row, "created_at")?,
+4
View File
@@ -114,6 +114,8 @@ pub enum Query {
SHARE_INVITATION_FIND_TOKEN,
SHARE_INVITATION_ACCEPT,
RESOURCE_SHARING_USERS,
RESOURCE_MENTION_USERS_WORKSPACE,
RESOURCE_MENTION_USERS_PAD,
RESOURCE_SHARING_LINKS,
RESOURCE_SHARING_PENDING,
SHARE_LINK_INSERT,
@@ -296,6 +298,8 @@ pub const SHARE_INVITATION_DELETE_TOKEN: Query = Query::SHARE_INVITATION_DELETE_
pub const SHARE_INVITATION_FIND_TOKEN: Query = Query::SHARE_INVITATION_FIND_TOKEN;
pub const SHARE_INVITATION_ACCEPT: Query = Query::SHARE_INVITATION_ACCEPT;
pub const RESOURCE_SHARING_USERS: Query = Query::RESOURCE_SHARING_USERS;
pub const RESOURCE_MENTION_USERS_WORKSPACE: Query = Query::RESOURCE_MENTION_USERS_WORKSPACE;
pub const RESOURCE_MENTION_USERS_PAD: Query = Query::RESOURCE_MENTION_USERS_PAD;
pub const RESOURCE_SHARING_LINKS: Query = Query::RESOURCE_SHARING_LINKS;
pub const RESOURCE_SHARING_PENDING: Query = Query::RESOURCE_SHARING_PENDING;
pub const SHARE_LINK_INSERT: Query = Query::SHARE_LINK_INSERT;
+12 -2
View File
@@ -32,7 +32,9 @@ pub fn get(query: Query) -> &'static str {
r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"#
}
Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"#,
Query::AUTH_UPDATE_LANGUAGE => r#"UPDATE users SET language = ?, updated_at = ? WHERE id = ?"#,
Query::AUTH_UPDATE_LANGUAGE => {
r#"UPDATE users SET language = ?, updated_at = ? WHERE id = ?"#
}
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#,
Query::RESOURCE_COLOR_BY_USER => {
r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
@@ -276,6 +278,12 @@ pub fn get(query: Query) -> &'static str {
Query::RESOURCE_SHARING_USERS => {
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"#
}
Query::RESOURCE_MENTION_USERS_WORKSPACE => {
r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = 1 AND (EXISTS (SELECT 1 FROM user_workspaces own JOIN workspaces r ON r.id = own.workspace_id WHERE r.slug = ? AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = ? AND rp.resource_slug = ? AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"#
}
Query::RESOURCE_MENTION_USERS_PAD => {
r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = 1 AND (EXISTS (SELECT 1 FROM user_pads own JOIN pads r ON r.id = own.pad_id WHERE r.slug = ? AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = ? AND rp.resource_slug = ? AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"#
}
Query::RESOURCE_SHARING_LINKS => {
r#"SELECT token_hash, CAST(label AS CHAR CHARACTER SET utf8mb4) AS label, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(created_at AS CHAR CHARACTER SET utf8mb4) AS created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
}
@@ -322,7 +330,9 @@ pub fn get(query: Query) -> &'static str {
Query::Q001 => {
r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private, CAST(CASE WHEN archive_deleted_notes THEN 1 ELSE 0 END AS SIGNED) AS archive_deleted_notes, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM workspaces WHERE slug = ?"#
}
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#,
Query::Q002 => {
r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#
}
Query::Q003 => {
r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM notes WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC"#
}
+12 -2
View File
@@ -32,7 +32,9 @@ pub fn get(query: Query) -> &'static str {
r#"UPDATE users SET editor_color = $1, updated_at = $2 WHERE id = $3"#
}
Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = $1, updated_at = $2 WHERE id = $3"#,
Query::AUTH_UPDATE_LANGUAGE => r#"UPDATE users SET language = $1, updated_at = $2 WHERE id = $3"#,
Query::AUTH_UPDATE_LANGUAGE => {
r#"UPDATE users SET language = $1, updated_at = $2 WHERE id = $3"#
}
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = $1"#,
Query::RESOURCE_COLOR_BY_USER => {
r#"SELECT color FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"#
@@ -278,6 +280,12 @@ pub fn get(query: Query) -> &'static str {
Query::RESOURCE_SHARING_USERS => {
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = $1 AND rp.resource_slug = $2 ORDER BY u.email"#
}
Query::RESOURCE_MENTION_USERS_WORKSPACE => {
r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = TRUE AND (EXISTS (SELECT 1 FROM user_workspaces own JOIN workspaces r ON r.id = own.workspace_id WHERE r.slug = $1 AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = $2 AND rp.resource_slug = $3 AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"#
}
Query::RESOURCE_MENTION_USERS_PAD => {
r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = TRUE AND (EXISTS (SELECT 1 FROM user_pads own JOIN pads r ON r.id = own.pad_id WHERE r.slug = $1 AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = $2 AND rp.resource_slug = $3 AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"#
}
Query::RESOURCE_SHARING_LINKS => {
r#"SELECT token_hash, label, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = $1 AND resource_slug = $2 AND revoked_at IS NULL ORDER BY created_at DESC"#
}
@@ -322,7 +330,9 @@ pub fn get(query: Query) -> &'static str {
Query::Q001 => {
r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private, CAST(CASE WHEN archive_deleted_notes THEN 1 ELSE 0 END AS BIGINT) AS archive_deleted_notes, created_by_guest_id FROM workspaces WHERE slug = $1"#
}
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES ($1, $2, $3, $4)"#,
Query::Q002 => {
r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES ($1, $2, $3, $4)"#
}
Query::Q003 => {
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = $1 AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC"#
}
+12 -2
View File
@@ -32,7 +32,9 @@ pub fn get(query: Query) -> &'static str {
r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"#
}
Query::AUTH_UPDATE_THEME => r#"UPDATE users SET theme = ?, updated_at = ? WHERE id = ?"#,
Query::AUTH_UPDATE_LANGUAGE => r#"UPDATE users SET language = ?, updated_at = ? WHERE id = ?"#,
Query::AUTH_UPDATE_LANGUAGE => {
r#"UPDATE users SET language = ?, updated_at = ? WHERE id = ?"#
}
Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#,
Query::RESOURCE_COLOR_BY_USER => {
r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#
@@ -276,6 +278,12 @@ pub fn get(query: Query) -> &'static str {
Query::RESOURCE_SHARING_USERS => {
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"#
}
Query::RESOURCE_MENTION_USERS_WORKSPACE => {
r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = 1 AND (EXISTS (SELECT 1 FROM user_workspaces own JOIN workspaces r ON r.id = own.workspace_id WHERE r.slug = ? AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = ? AND rp.resource_slug = ? AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"#
}
Query::RESOURCE_MENTION_USERS_PAD => {
r#"SELECT u.nickname, u.editor_color FROM users u WHERE u.is_active = 1 AND (EXISTS (SELECT 1 FROM user_pads own JOIN pads r ON r.id = own.pad_id WHERE r.slug = ? AND own.user_id = u.id) OR EXISTS (SELECT 1 FROM resource_permissions rp WHERE rp.resource_kind = ? AND rp.resource_slug = ? AND rp.user_id = u.id)) ORDER BY LOWER(u.nickname), u.nickname"#
}
Query::RESOURCE_SHARING_LINKS => {
r#"SELECT token_hash, label, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
}
@@ -322,7 +330,9 @@ pub fn get(query: Query) -> &'static str {
Query::Q001 => {
r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private, archive_deleted_notes, created_by_guest_id FROM workspaces WHERE slug = ?"#
}
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#,
Query::Q002 => {
r#"INSERT INTO workspaces (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#
}
Query::Q003 => {
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC"#
}