Files
rustpad/src/api/mod.rs
T

1634 lines
48 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.
*/
mod access_tokens;
mod error;
mod files;
mod pads_public;
pub use access_tokens::*;
pub use error::ApiError;
pub use files::*;
pub use pads_public::*;
use axum::{
Json,
extract::{Multipart, Path, Query, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use chrono::{Duration, Utc};
use rand_core::{OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use slug::slugify;
use crate::{
collab::{self, AppliedOperation},
db, queries,
state::{NoteUpdate, RoomEvent, SharedState},
};
const MAX_NAME_LENGTH: usize = 80;
const MIN_PASSWORD_LENGTH: usize = 8;
const MAX_PASSWORD_LENGTH: usize = 128;
const MIN_WORKSPACE_SLUG_LENGTH: usize = 6;
const MAX_DOCUMENT_SIZE_BYTES: usize = 2_000_000;
fn bearer_token(headers: &HeaderMap) -> Option<&str> {
crate::security::session_token(headers)
}
fn authorization_token(headers: &HeaderMap) -> Option<&str> {
crate::security::bearer_token(headers)
}
fn user_session_token(headers: &HeaderMap) -> Option<&str> {
crate::security::session_token(headers)
}
fn resource_request_token<'a>(
headers: &'a HeaderMap,
kind: &str,
slug: &str,
supplied: Option<&'a str>,
) -> Option<&'a str> {
supplied
.map(str::trim)
.filter(|value| !value.is_empty() && *value != "cookie")
.or_else(|| crate::security::resource_token(headers, kind, slug))
.or_else(|| authorization_token(headers))
}
async fn session_user(
state: &SharedState,
headers: &HeaderMap,
) -> Result<Option<crate::auth::User>, ApiError> {
let Some(token) = user_session_token(headers) else {
return Ok(None);
};
crate::auth::user_from_token(state, token)
.await
.map_err(|error| ApiError::forbidden(&error.message))
}
fn requester_guest_id(headers: &HeaderMap) -> Option<&str> {
crate::security::cookie_value(headers, "rustpad_guest_id")
.map(str::trim)
.filter(|value| {
(16..=64).contains(&value.len())
&& value.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_')
})
})
}
fn guest_owner_is_requester(headers: &HeaderMap, owner_guest_id: Option<&str>) -> bool {
owner_guest_id
.zip(requester_guest_id(headers))
.is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id)
}
fn can_set_resource_password(
password_protected: bool,
account_owner: bool,
guest_owner: bool,
) -> bool {
!password_protected && (account_owner || guest_owner)
}
fn can_manage_resource_settings(
account_owner: bool,
guest_owner: bool,
password_write_access: bool,
) -> bool {
account_owner || guest_owner || password_write_access
}
async fn note_creator_is_requester(
state: &SharedState,
headers: &HeaderMap,
note: &db::Note,
) -> Result<bool, ApiError> {
if let Some(owner_guest_id) = note.created_by_guest_id.as_deref() {
return Ok(guest_owner_is_requester(headers, Some(owner_guest_id)));
}
let Some(user) = session_user(state, headers).await? else {
return Ok(false);
};
Ok(note
.created_by
.as_deref()
.is_some_and(|creator| creator == user.nickname))
}
fn pad_creator_is_requester(headers: &HeaderMap, pad: &db::Pad) -> bool {
guest_owner_is_requester(headers, pad.created_by_guest_id.as_deref())
}
fn workspace_creator_is_requester(headers: &HeaderMap, workspace: &db::Workspace) -> bool {
guest_owner_is_requester(headers, workspace.created_by_guest_id.as_deref())
}
async fn can_set_workspace_password(
state: &SharedState,
headers: &HeaderMap,
workspace: &db::Workspace,
) -> bool {
let account_owner = crate::auth::is_resource_owner(
state,
"workspace",
&workspace.slug,
user_session_token(headers),
)
.await
.unwrap_or(false);
can_set_resource_password(
workspace.password_hash.is_some(),
account_owner,
workspace_creator_is_requester(headers, workspace),
)
}
async fn has_write_permission(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
) -> Result<bool, ApiError> {
if request_access_level(
state,
headers,
kind,
slug,
None,
crate::security::session_cookie_token(headers),
)
.await?
>= AccessLevel::Write
{
return Ok(true);
}
match kind {
"workspace" => Ok(db::find_workspace(&state.db, slug)
.await?
.is_some_and(|workspace| {
workspace.is_private == 0 && workspace.password_hash.is_none()
})),
"pad" => Ok(db::find_pad(&state.db, slug)
.await?
.is_some_and(|pad| pad.is_private == 0 && pad.password_hash.is_none())),
_ => Ok(false),
}
}
async fn upload_limit_for_request(
state: &SharedState,
headers: &HeaderMap,
) -> Result<Option<usize>, ApiError> {
if session_user(state, headers).await?.is_some() {
return Ok(Some(state.upload_max_size_bytes));
}
Ok(state
.guest_upload_enabled
.then_some(state.guest_upload_max_size_bytes))
}
async fn resource_upload_limit(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
) -> Result<Option<usize>, ApiError> {
let Some(limit) = upload_limit_for_request(state, headers).await? else {
return Ok(None);
};
Ok(has_write_permission(state, headers, kind, slug)
.await?
.then_some(limit))
}
#[derive(Debug, Serialize)]
pub struct PublishResponse {
url: Option<String>,
enabled: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct MarkdownFileReference {
filename: String,
url: String,
mime_type: String,
}
pub(crate) async fn markdown_file_references(
state: &SharedState,
pad_id: Option<i64>,
note_id: Option<i64>,
content: Option<&str>,
) -> Result<Vec<MarkdownFileReference>, ApiError> {
let file_rows = if let Some(id) = pad_id {
db::list_pad_files(&state.db, id).await?
} else if let Some(id) = note_id {
db::list_note_files(&state.db, id).await?
} else {
Vec::new()
};
Ok(file_rows
.into_iter()
.filter(|file| {
content
.map(|value| {
files::content_references_stored_file(
value,
&file.filename,
&file.url,
state.files_public_url.as_deref(),
)
})
.unwrap_or(true)
})
.map(|file| MarkdownFileReference {
filename: file.filename,
url: crate::file_urls::public_file_url(state.files_public_url.as_deref(), &file.url),
mime_type: file.mime_type,
})
.collect())
}
#[derive(Debug, Serialize)]
pub struct PublicPageResponse {
title: String,
content: String,
updated_at: String,
allow_task_updates: bool,
files: Vec<MarkdownFileReference>,
}
#[derive(Debug, Deserialize)]
pub struct CreateWorkspaceRequest {
name: String,
#[serde(default)]
password: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CreateWorkspaceResponse {
slug: String,
url: String,
}
#[derive(Debug, Deserialize)]
pub struct PasswordRequest {
#[serde(default)]
password: Option<String>,
#[serde(default)]
access_token: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct PublishRequest {
#[serde(default)]
password: Option<String>,
#[serde(default)]
access_token: Option<String>,
#[serde(default)]
allow_task_updates: bool,
#[serde(default)]
unprotect_page: bool,
enabled: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct PublicTaskUpdateRequest {
source_line: usize,
checked: bool,
}
#[derive(Debug, Deserialize)]
pub struct CreateNoteRequest {
name: String,
#[serde(default)]
content: Option<String>,
#[serde(default)]
password: Option<String>,
#[serde(default)]
access_token: Option<String>,
#[serde(default)]
protect: bool,
#[serde(default)]
created_by: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SetWorkspacePasswordRequest {
password: String,
#[serde(default)]
client_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RestoreRequest {
#[serde(default)]
password: Option<String>,
#[serde(default)]
access_token: Option<String>,
revision_id: i64,
}
#[derive(Debug, Serialize)]
pub struct WorkspaceInfo {
slug: String,
title: String,
protected: bool,
access_level: String,
can_set_password: bool,
created_at: String,
updated_at: String,
}
#[derive(Debug, Serialize)]
pub struct WorkspaceOpenResponse {
workspace: WorkspaceInfo,
notes: Vec<NoteListItem>,
pagination: ListPaginationMeta,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceNotesQuery {
#[serde(default)]
q: String,
#[serde(default = "default_list_page")]
page: usize,
#[serde(default = "default_list_per_page")]
per_page: usize,
}
#[derive(Debug, Serialize)]
pub struct ListPaginationMeta {
page: usize,
per_page: usize,
total: usize,
total_pages: usize,
}
fn default_list_page() -> usize {
1
}
fn default_list_per_page() -> usize {
25
}
fn normalize_list_per_page(value: usize) -> usize {
match value {
25 | 50 | 100 => value,
_ => 25,
}
}
#[derive(Debug, Serialize)]
pub struct NoteListItem {
slug: String,
title: String,
created_at: String,
updated_at: String,
url: String,
protected: bool,
created_by: Option<String>,
participant_count: i64,
file_count: i64,
file_size_bytes: i64,
revision_count: i64,
}
#[derive(Debug, Serialize)]
pub struct NoteInfo {
workspace_slug: String,
workspace_title: String,
slug: String,
title: String,
protected: bool,
access_level: String,
note_protected: bool,
allow_public_task_updates: bool,
public_page_unprotected: bool,
public_page_enabled: bool,
private: bool,
created_at: String,
updated_at: String,
can_delete_files: bool,
can_upload_files: bool,
upload_max_size_bytes: Option<usize>,
global_color: Option<String>,
note_color: Option<String>,
authorship_mode: String,
colors_enabled: bool,
compact_view: bool,
editor_line_numbers: bool,
preview_line_numbers: bool,
line_links: bool,
toolbar_collapsed: bool,
font_family: String,
font_size: i64,
personal_editor_settings: bool,
can_save_editor_settings: bool,
can_manage_authorship: bool,
can_set_password: bool,
files: Vec<MarkdownFileReference>,
}
#[derive(Debug, Deserialize)]
pub struct EditorColorRequest {
color: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct EditorSettingsRequest {
#[serde(default)]
authorship_mode: Option<String>,
#[serde(default)]
colors_enabled: Option<bool>,
#[serde(default)]
compact_view: Option<bool>,
#[serde(default)]
editor_line_numbers: Option<bool>,
#[serde(default)]
preview_line_numbers: Option<bool>,
#[serde(default)]
line_links: Option<bool>,
#[serde(default)]
toolbar_collapsed: Option<bool>,
#[serde(default)]
font_family: Option<String>,
#[serde(default)]
font_size: Option<i64>,
}
async fn user_editor_preferences(
state: &SharedState,
headers: &HeaderMap,
resource: db::EditorPreferenceResource,
) -> Result<(db::EditorPreferences, bool), ApiError> {
let Some(user) = session_user(state, headers).await? else {
return Ok((db::EditorPreferences::default(), false));
};
Ok((
db::load_editor_preferences(&state.db, user.id, resource)
.await?
.unwrap_or_default(),
true,
))
}
async fn save_editor_settings(
state: &SharedState,
headers: &HeaderMap,
permission_kind: &str,
permission_slug: &str,
settings_kind: &str,
settings_slug: &str,
resource: db::EditorPreferenceResource,
creator_can_manage_authorship: bool,
payload: EditorSettingsRequest,
) -> Result<Json<serde_json::Value>, ApiError> {
if !has_write_permission(state, headers, permission_kind, permission_slug).await? {
return Err(ApiError::forbidden(
"Read and write access is required to save editor preferences",
));
}
let wants_personal_update = payload.compact_view.is_some()
|| payload.editor_line_numbers.is_some()
|| payload.preview_line_numbers.is_some()
|| payload.line_links.is_some()
|| payload.toolbar_collapsed.is_some()
|| payload.font_family.is_some()
|| payload.font_size.is_some();
let wants_global_update = payload.authorship_mode.is_some() || payload.colors_enabled.is_some();
if !wants_personal_update && !wants_global_update {
return Err(ApiError::bad_request("No editor settings were provided"));
}
let user = session_user(state, headers).await?;
if wants_personal_update && user.is_none() {
return Err(ApiError::forbidden(
"Log in to save personal editor preferences",
));
}
let can_manage_authorship = if wants_global_update {
creator_can_manage_authorship
|| crate::auth::is_resource_owner(
state,
permission_kind,
permission_slug,
user_session_token(headers),
)
.await
.unwrap_or(false)
} else {
false
};
if wants_global_update && !can_manage_authorship {
return Err(ApiError::forbidden(
"Only the resource owner can change authorship settings",
));
}
let preferences = if wants_personal_update {
let user_id = user
.as_ref()
.expect("personal preferences require a user")
.id;
let mut preferences = db::load_editor_preferences(&state.db, user_id, resource)
.await?
.unwrap_or_default();
if let Some(value) = payload.compact_view {
preferences.compact_view = value;
}
if let Some(value) = payload.editor_line_numbers {
preferences.editor_line_numbers = value;
}
if let Some(value) = payload.preview_line_numbers {
preferences.preview_line_numbers = value;
}
if let Some(value) = payload.line_links {
preferences.line_links = value;
}
if let Some(value) = payload.toolbar_collapsed {
preferences.toolbar_collapsed = value;
}
if let Some(value) = payload.font_family {
preferences.font_family = match value.as_str() {
"mono" | "system" | "serif" | "arial" | "georgia" => value,
_ => return Err(ApiError::bad_request("Invalid editor font")),
};
}
if let Some(value) = payload.font_size {
if !matches!(value, 14 | 16 | 18 | 20 | 22) {
return Err(ApiError::bad_request("Invalid editor font size"));
}
preferences.font_size = value;
}
Some(preferences)
} else {
None
};
let resource_settings = if wants_global_update {
let mut settings =
db::load_resource_editor_settings(&state.db, settings_kind, settings_slug).await?;
if let Some(mode) = payload.authorship_mode {
settings.authorship_mode = match mode.as_str() {
"simple" => "simple".into(),
"full" | "advanced" => "full".into(),
_ => return Err(ApiError::bad_request("Invalid authorship mode")),
};
}
if let Some(value) = payload.colors_enabled {
settings.colors_enabled = value;
}
Some(settings)
} else {
None
};
db::save_editor_configuration(
&state.db,
user.as_ref().map(|value| value.id),
resource,
preferences.as_ref(),
resource_settings
.as_ref()
.map(|settings| (settings_kind, settings_slug, settings)),
)
.await?;
Ok(Json(serde_json::json!({
"preferences": preferences,
"resource_settings": resource_settings,
})))
}
pub async fn create_workspace(
State(state): State<SharedState>,
headers: HeaderMap,
Json(payload): Json<CreateWorkspaceRequest>,
) -> Result<(StatusCode, Json<CreateWorkspaceResponse>), ApiError> {
let title = validate_name(&payload.name, "Workspace name")?;
let password = validate_password(payload.password.as_deref())?;
let slug = unique_workspace_slug(&state, title).await?;
let account_user = crate::auth::optional_user(&state, &headers)
.await
.map_err(|e| ApiError::forbidden(&e.message))?;
let created_by_guest_id = if account_user.is_none() {
requester_guest_id(&headers)
} else {
None
};
let workspace = db::create_workspace(
&state.db,
&slug,
title,
password,
created_by_guest_id,
)
.await?;
if let Some(user) = account_user {
sqlx::query(queries::get(
state.db.kind(),
queries::USER_ATTACH_WORKSPACE,
))
.bind(user.id)
.bind(&workspace.slug)
.execute(state.db.pool())
.await?;
}
Ok((
StatusCode::CREATED,
Json(CreateWorkspaceResponse {
url: format!("/w/{slug}"),
slug,
}),
))
}
pub async fn workspace_info(
State(state): State<SharedState>,
headers: HeaderMap,
Path(workspace_slug): Path<String>,
) -> Result<Json<WorkspaceInfo>, ApiError> {
let workspace = db::find_workspace(&state.db, &workspace_slug)
.await?
.ok_or_else(ApiError::not_found_workspace)?;
ensure_private_resource_access(
&state,
&headers,
"workspace",
&workspace.slug,
workspace.is_private,
)
.await?;
let access_level = effective_header_access_level(
&state,
&headers,
"workspace",
&workspace.slug,
workspace.is_private,
workspace.password_hash.is_some(),
)
.await?;
let can_set_password = can_set_workspace_password(&state, &headers, &workspace).await;
Ok(Json(workspace_info_from(
&workspace,
access_level,
can_set_password,
)))
}
pub async fn set_workspace_password(
State(state): State<SharedState>,
headers: HeaderMap,
Path(workspace_slug): Path<String>,
Json(payload): Json<SetWorkspacePasswordRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let workspace = db::find_workspace(&state.db, &workspace_slug)
.await?
.ok_or_else(ApiError::not_found_workspace)?;
if workspace.password_hash.is_some() {
return Err(ApiError::bad_request(
"This workspace already has a password.",
));
}
if !can_set_workspace_password(&state, &headers, &workspace).await {
return Err(ApiError::forbidden(
"Only the workspace owner can set its password.",
));
}
let except_client_id =
crate::websocket::clean_collaboration_client_id(payload.client_id);
let password = validate_password(Some(payload.password.as_str()))?
.ok_or_else(|| ApiError::bad_request("Password is required."))?;
db::set_workspace_password(&state.db, &workspace_slug, password).await?;
state
.notify_workspace_password_required(&workspace_slug, except_client_id)
.await;
Ok(Json(serde_json::json!({"ok": true, "protected": true})))
}
pub async fn open_workspace(
State(state): State<SharedState>,
headers: HeaderMap,
Path(workspace_slug): Path<String>,
Query(query): Query<WorkspaceNotesQuery>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<WorkspaceOpenResponse>, ApiError> {
let workspace = authorized_workspace(
&state,
&workspace_slug,
payload.password.as_deref(),
resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers),
&headers,
)
.await?;
let stats = db::list_note_stats(&state.db, workspace.id)
.await?
.into_iter()
.map(|stats| (stats.note_id, stats))
.collect::<std::collections::HashMap<_, _>>();
let search = query.q.trim().to_lowercase();
let mut notes = db::list_notes(&state.db, workspace.id)
.await?
.into_iter()
.filter(|note| {
search.is_empty()
|| note.title.to_lowercase().contains(&search)
|| note.slug.to_lowercase().contains(&search)
|| note
.created_by
.as_deref()
.unwrap_or_default()
.to_lowercase()
.contains(&search)
})
.map(|note| {
let stats = stats.get(&note.id);
NoteListItem {
url: format!("/w/{}/n/{}", workspace.slug, note.slug),
slug: note.slug,
title: note.title,
created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
protected: note.protected,
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),
file_size_bytes: stats.map_or(0, |value| value.file_size_bytes),
revision_count: stats.map_or(0, |value| value.revision_count),
}
})
.collect::<Vec<_>>();
notes.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
let page = query.page.max(1);
let per_page = normalize_list_per_page(query.per_page);
let total = notes.len();
let total_pages = ((total + per_page - 1) / per_page).max(1);
let page = page.min(total_pages);
let start = (page - 1) * per_page;
let notes = notes.into_iter().skip(start).take(per_page).collect();
let mut access_level = effective_header_access_level(
&state,
&headers,
"workspace",
&workspace.slug,
workspace.is_private,
workspace.password_hash.is_some(),
)
.await?;
if db::verify_workspace_password(&workspace, payload.password.as_deref()) {
access_level = AccessLevel::Write;
}
let can_set_password = can_set_workspace_password(&state, &headers, &workspace).await;
Ok(Json(WorkspaceOpenResponse {
workspace: workspace_info_from(&workspace, access_level, can_set_password),
notes,
pagination: ListPaginationMeta {
page,
per_page,
total,
total_pages,
},
}))
}
pub async fn create_note(
State(state): State<SharedState>,
headers: HeaderMap,
Path(workspace_slug): Path<String>,
Json(payload): Json<CreateNoteRequest>,
) -> Result<(StatusCode, Json<NoteListItem>), ApiError> {
let workspace = authorized_workspace(
&state,
&workspace_slug,
payload.password.as_deref(),
resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers),
&headers,
)
.await?;
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
{
AccessLevel::Write
} else {
request_access_level(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers),
)
.await?
};
require_write(level)?;
let title = validate_name(&payload.name, "Note name")?;
let initial_content = validate_initial_content(payload.content.as_deref())?;
let base = slugify(title);
if base.is_empty() {
return Err(ApiError::bad_request(
"The name cannot be converted into a valid address",
));
}
let slug = unique_note_slug(&state, workspace.id, &base).await?;
let account_user = session_user(&state, &headers).await?;
let created_by = account_user
.as_ref()
.map(|user| user.nickname.clone())
.or_else(|| {
payload
.created_by
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.chars().take(40).collect::<String>())
});
let created_by_guest_id = if account_user.is_none() {
requester_guest_id(&headers)
} else {
None
};
let note = db::create_note(
&state.db,
workspace.id,
&slug,
title,
payload.protect,
created_by.as_deref(),
created_by_guest_id.as_deref(),
)
.await?;
if let Some(content) = initial_content {
db::save_revision(
&state.db,
note.id,
workspace.id,
content,
created_by.as_deref(),
"[]",
)
.await?;
}
Ok((
StatusCode::CREATED,
Json(NoteListItem {
url: format!("/w/{workspace_slug}/n/{slug}"),
slug: note.slug,
title: note.title,
created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
protected: note.protected,
created_by: note.created_by,
participant_count: 0,
file_count: 0,
file_size_bytes: 0,
revision_count: 0,
}),
))
}
fn clean_editor_color(value: Option<&str>) -> Result<Option<String>, ApiError> {
let Some(value) = value else {
return Ok(None);
};
let value = value.trim();
if value.len() == 7
&& value.starts_with('#')
&& value[1..].chars().all(|c| c.is_ascii_hexdigit())
{
Ok(Some(value.to_ascii_lowercase()))
} else {
Err(ApiError::bad_request("Invalid editor color"))
}
}
async fn editor_colors(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
) -> Result<(Option<String>, Option<String>), ApiError> {
let Some(user) = session_user(state, headers).await? else {
return Ok((None, None));
};
let global: Option<String> = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::AUTH_EDITOR_COLOR_BY_USER,
))
.bind(user.id)
.fetch_one(state.db.pool())
.await?;
let note: Option<String> = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::RESOURCE_COLOR_BY_USER,
))
.bind(user.id)
.bind(kind)
.bind(slug)
.fetch_optional(state.db.pool())
.await?;
Ok((global, note))
}
async fn save_editor_color(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
color: Option<&str>,
) -> Result<Json<serde_json::Value>, ApiError> {
let user = session_user(state, headers)
.await?
.ok_or_else(|| ApiError::forbidden("Log in to save note colors"))?;
let color = clean_editor_color(color)?;
let mut tx = state.db.pool().begin().await?;
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_COLOR_DELETE,
))
.bind(user.id)
.bind(kind)
.bind(slug)
.execute(&mut *tx)
.await?;
if let Some(value) = color.as_deref() {
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_COLOR_INSERT,
))
.bind(user.id)
.bind(kind)
.bind(slug)
.bind(value)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(Json(serde_json::json!({"color": color})))
}
pub async fn note_info(
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug)): Path<(String, String)>,
) -> Result<Json<NoteInfo>, ApiError> {
let workspace = db::find_workspace(&state.db, &workspace_slug)
.await?
.ok_or_else(ApiError::not_found_workspace)?;
ensure_private_resource_access(
&state,
&headers,
"workspace",
&workspace.slug,
workspace.is_private,
)
.await?;
let access_level = effective_header_access_level(
&state,
&headers,
"workspace",
&workspace.slug,
workspace.is_private,
workspace.password_hash.is_some(),
)
.await?;
let note = db::find_note(&state.db, workspace.id, &note_slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
let color_slug = format!("{}/{}", workspace_slug, note_slug);
let (global_color, note_color) = editor_colors(&state, &headers, "note", &color_slug).await?;
let (editor_preferences, personal_editor_settings) = user_editor_preferences(
&state,
&headers,
db::EditorPreferenceResource::Note(note.id),
)
.await?;
let resource_editor_settings =
db::load_resource_editor_settings(&state.db, "note", &color_slug).await?;
let workspace_owner = crate::auth::is_resource_owner(
&state,
"workspace",
&workspace_slug,
user_session_token(&headers),
)
.await
.unwrap_or(false);
let workspace_guest_owner = workspace_creator_is_requester(&headers, &workspace);
let note_owner = note_creator_is_requester(&state, &headers, &note).await?;
let password_write_access =
has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
let can_manage_authorship =
can_manage_resource_settings(workspace_owner, note_owner, password_write_access);
let can_delete_files = can_manage_authorship;
let upload_max_size_bytes =
resource_upload_limit(&state, &headers, "workspace", &workspace_slug).await?;
let can_upload_files = upload_max_size_bytes.is_some();
let can_save_editor_settings = (personal_editor_settings || can_manage_authorship)
&& has_write_permission(&state, &headers, "workspace", &workspace_slug).await?;
if workspace.is_private == 0
&& workspace.password_hash.is_some()
&& !db::note_public_page_disabled(&state.db, note.id).await?
&& !db::note_public_page_enabled(&state.db, note.id).await?
{
db::publish_note(&state.db, note.id).await?;
}
Ok(Json(NoteInfo {
workspace_slug: workspace.slug,
workspace_title: workspace.title,
slug: note.slug,
title: note.title,
protected: workspace.password_hash.is_some(),
access_level: access_level_name(access_level).into(),
note_protected: note.protected,
allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?,
public_page_unprotected: db::note_public_page_unprotected(&state.db, note.id).await?,
public_page_enabled: db::note_public_page_enabled(&state.db, note.id).await?,
private: workspace.is_private != 0,
created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
can_delete_files,
can_upload_files,
upload_max_size_bytes,
global_color,
note_color,
authorship_mode: resource_editor_settings.authorship_mode,
colors_enabled: resource_editor_settings.colors_enabled,
compact_view: editor_preferences.compact_view,
editor_line_numbers: editor_preferences.editor_line_numbers,
preview_line_numbers: editor_preferences.preview_line_numbers,
line_links: editor_preferences.line_links,
toolbar_collapsed: editor_preferences.toolbar_collapsed,
font_family: editor_preferences.font_family,
font_size: editor_preferences.font_size,
personal_editor_settings,
can_save_editor_settings,
can_manage_authorship,
can_set_password: can_set_resource_password(
workspace.password_hash.is_some(),
workspace_owner,
workspace_guest_owner,
),
files: markdown_file_references(&state, None, Some(note.id), None).await?,
}))
}
pub async fn set_note_editor_settings(
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<EditorSettingsRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let workspace = db::find_workspace(&state.db, &workspace_slug)
.await?
.ok_or_else(ApiError::not_found_workspace)?;
let note = db::find_note(&state.db, workspace.id, &note_slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
let creator_can_manage_authorship = can_manage_resource_settings(
false,
note_creator_is_requester(&state, &headers, &note).await?,
has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?,
);
save_editor_settings(
&state,
&headers,
"workspace",
&workspace_slug,
"note",
&format!("{workspace_slug}/{note_slug}"),
db::EditorPreferenceResource::Note(note.id),
creator_can_manage_authorship,
payload,
)
.await
}
pub async fn history(
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<Vec<db::Revision>>, ApiError> {
let (workspace, note) = authorized_note(
&state,
&workspace_slug,
&note_slug,
payload.password.as_deref(),
resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers),
&headers,
)
.await?;
let _ = workspace;
let revisions = db::list_revisions(&state.db, note.id)
.await?
.into_iter()
.map(|mut revision| {
revision.created_at = db::normalize_timestamp(&revision.created_at);
revision
})
.collect();
Ok(Json(revisions))
}
pub async fn restore(
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<RestoreRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let (workspace, note) = authorized_note(
&state,
&workspace_slug,
&note_slug,
payload.password.as_deref(),
resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers),
&headers,
)
.await?;
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
{
AccessLevel::Write
} else {
request_access_level(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers),
)
.await?
};
require_write(level)?;
let content: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q028))
.bind(payload.revision_id)
.bind(note.id)
.fetch_optional(state.db.pool())
.await?;
let content = content.ok_or_else(ApiError::not_found_revision)?;
let room_key = crate::state::AppState::note_room_key(&workspace_slug, &note_slug);
let channel = state.note_channel(&workspace_slug, &note_slug).await;
let collaboration_snapshot = db::note_collaboration_snapshot(&state.db, note.id).await?;
let collaborative_document = state
.collaborative_document(
&room_key,
collaboration_snapshot.content,
collaboration_snapshot.owner_map,
collaboration_snapshot.revision_id,
)
.await;
let mut document = collaborative_document.lock().await;
let base_revision_id = document.revision_id;
let operation =
collab::replace_operation(document.content.encode_utf16().count(), content, Vec::new());
let (content, owner_map) = collab::apply_operation_to_document(
&document.content,
&document.owner_map,
&operation,
&[],
)
.map_err(|_| ApiError::bad_request("The selected revision could not be restored"))?;
let (revision_id, updated_at) = db::save_revision(
&state.db,
note.id,
workspace.id,
&content,
Some("restore"),
&owner_map,
)
.await?;
let update_id = u64::try_from(revision_id).unwrap_or_default().max(1);
let applied = AppliedOperation {
base_revision_id,
revision_id,
client_id: "server_restore".into(),
update_id,
operation: operation.clone(),
owner_replacements: Vec::new(),
};
document.content.clone_from(&content);
document.owner_map.clone_from(&owner_map);
document.revision_id = revision_id;
document.record(applied);
let update = NoteUpdate {
base_revision_id,
revision_id,
updated_at,
author: Some("restore".into()),
client_id: "server_restore".into(),
update_id,
operation,
owner_replacements: Vec::new(),
};
let _ = channel.send(RoomEvent::Document(update));
drop(document);
Ok(Json(serde_json::json!({"ok": true})))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum AccessLevel {
None,
Read,
Write,
}
fn permission_level(permission: Option<&str>) -> AccessLevel {
match permission {
Some("rw") => AccessLevel::Write,
Some("ro") => AccessLevel::Read,
_ => AccessLevel::None,
}
}
async fn has_password_write_access(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
) -> Result<bool, ApiError> {
for token in [
crate::security::resource_token(headers, kind, slug),
authorization_token(headers),
] {
if verify_password_access_token(state, kind, slug, token).await? {
return Ok(true);
}
}
Ok(false)
}
async fn external_token_access_level(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<AccessLevel, ApiError> {
let permission = crate::auth::share_access_permission(state, kind, slug, token)
.await
.map_err(|error| ApiError::forbidden(&error.message))?;
let level = permission_level(permission.as_deref());
if level != AccessLevel::None {
return Ok(level);
}
if verify_password_access_token(state, kind, slug, token).await? {
// A server-issued token created after a correct resource password
// retains the historical read/write semantics of password access.
return Ok(AccessLevel::Write);
}
Ok(AccessLevel::None)
}
async fn account_token_access_level(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<AccessLevel, ApiError> {
let permission = crate::auth::account_resource_permission(state, kind, slug, token)
.await
.map_err(|error| ApiError::forbidden(&error.message))?;
Ok(permission_level(permission.as_deref()))
}
async fn request_access_level(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
supplied_access_token: Option<&str>,
account_token: Option<&str>,
) -> Result<AccessLevel, ApiError> {
let mut level = account_token_access_level(state, kind, slug, account_token).await?;
if level == AccessLevel::Write {
return Ok(level);
}
let mut checked_tokens = Vec::with_capacity(4);
for token in [
supplied_access_token,
crate::security::share_session_token(headers, kind, slug),
crate::security::resource_token(headers, kind, slug),
authorization_token(headers),
] {
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
continue;
};
if checked_tokens.contains(&token) {
continue;
}
checked_tokens.push(token);
level = std::cmp::max(
level,
external_token_access_level(state, kind, slug, Some(token)).await?,
);
if level == AccessLevel::Write {
break;
}
}
Ok(level)
}
fn access_level_name(level: AccessLevel) -> &'static str {
match level {
AccessLevel::None => "none",
AccessLevel::Read => "read",
AccessLevel::Write => "write",
}
}
async fn effective_header_access_level(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
is_private: i64,
password_protected: bool,
) -> Result<AccessLevel, ApiError> {
let mut level =
request_access_level(state, headers, kind, slug, None, bearer_token(headers)).await?;
if is_private == 0 && !password_protected {
level = std::cmp::max(level, AccessLevel::Write);
}
Ok(level)
}
fn require_write(level: AccessLevel) -> Result<(), ApiError> {
if level >= AccessLevel::Write {
Ok(())
} else {
Err(ApiError::forbidden("Read-only access."))
}
}
async fn has_header_resource_access(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
) -> Result<bool, ApiError> {
Ok(request_access_level(
state,
headers,
kind,
slug,
None,
crate::security::session_cookie_token(headers),
)
.await?
!= AccessLevel::None)
}
async fn ensure_private_resource_access(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
is_private: i64,
) -> Result<(), ApiError> {
if is_private == 0 {
return Ok(());
}
if has_header_resource_access(state, headers, kind, slug).await? {
return Ok(());
}
Err(ApiError::not_found_workspace())
}
async fn check_resource_password_attempt(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
password: Option<&str>,
password_ok: bool,
) -> Result<(), ApiError> {
let Some(_) = password.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(());
};
let client_key = crate::security::client_key(headers);
let window = std::time::Duration::from_secs(15 * 60);
state
.check_rate_limit(format!("resource-password-client:{client_key}"), 50, window)
.await
.map_err(|seconds| {
ApiError::rate_limited(&format!(
"Too many password attempts. Try again in {seconds} seconds."
))
})?;
let limit_key = format!("resource-password:{client_key}:{kind}:{slug}");
state
.check_rate_limit(limit_key.clone(), 10, window)
.await
.map_err(|seconds| {
ApiError::rate_limited(&format!(
"Too many password attempts. Try again in {seconds} seconds."
))
})?;
if password_ok {
state.clear_rate_limit(&limit_key).await;
}
Ok(())
}
pub async fn authorized_workspace(
state: &SharedState,
slug: &str,
password: Option<&str>,
access_token: Option<&str>,
bearer: Option<&str>,
headers: &HeaderMap,
) -> Result<db::Workspace, ApiError> {
let workspace = db::find_workspace(&state.db, slug)
.await?
.ok_or_else(ApiError::not_found_workspace)?;
let token_level =
request_access_level(state, headers, "workspace", slug, access_token, bearer).await?;
if workspace.is_private != 0 && token_level == AccessLevel::None {
return Err(ApiError::not_found_workspace());
}
if workspace.password_hash.is_some() && token_level < AccessLevel::Write {
let password_ok = db::verify_workspace_password(&workspace, password);
check_resource_password_attempt(state, headers, "workspace", slug, password, password_ok)
.await?;
if token_level == AccessLevel::None && !password_ok {
return Err(ApiError::unauthorized());
}
}
Ok(workspace)
}
async fn authorized_note(
state: &SharedState,
workspace_slug: &str,
note_slug: &str,
password: Option<&str>,
access_token: Option<&str>,
bearer: Option<&str>,
headers: &HeaderMap,
) -> Result<(db::Workspace, db::Note), ApiError> {
let workspace = authorized_workspace(
state,
workspace_slug,
password,
access_token,
bearer,
headers,
)
.await?;
let note = db::find_note(&state.db, workspace.id, note_slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
Ok((workspace, note))
}
fn workspace_info_from(
workspace: &db::Workspace,
access_level: AccessLevel,
can_set_password: bool,
) -> WorkspaceInfo {
WorkspaceInfo {
slug: workspace.slug.clone(),
title: workspace.title.clone(),
protected: workspace.password_hash.is_some(),
access_level: access_level_name(access_level).into(),
can_set_password,
created_at: db::normalize_timestamp(&workspace.created_at),
updated_at: db::normalize_timestamp(&workspace.updated_at),
}
}
fn validate_name<'a>(value: &'a str, field: &str) -> Result<&'a str, ApiError> {
let value = value.trim();
if value.is_empty() || value.chars().count() > MAX_NAME_LENGTH {
return Err(ApiError::bad_request(&format!(
"{field} must contain between 1 and {MAX_NAME_LENGTH} characters"
)));
}
Ok(value)
}
fn validate_password(password: Option<&str>) -> Result<Option<&str>, ApiError> {
let Some(password) = password.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
let length = password.chars().count();
if !(MIN_PASSWORD_LENGTH..=MAX_PASSWORD_LENGTH).contains(&length) {
return Err(ApiError::bad_request(
"Password must contain between 8 and 128 characters",
));
}
Ok(Some(password))
}
fn validate_initial_content(content: Option<&str>) -> Result<Option<&str>, ApiError> {
let Some(content) = content.filter(|value| !value.is_empty()) else {
return Ok(None);
};
if content.len() > MAX_DOCUMENT_SIZE_BYTES {
return Err(ApiError::bad_request("The document is too large"));
}
Ok(Some(content))
}
async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result<String, ApiError> {
let base = slugify(title);
if base.is_empty() {
return Err(ApiError::bad_request(
"The name cannot be converted into a valid address",
));
}
let needs_suffix = base.chars().count() < MIN_WORKSPACE_SLUG_LENGTH
|| db::find_workspace(&state.db, &base).await?.is_some();
if !needs_suffix {
return Ok(base);
}
for _ in 0..8 {
let candidate = format!("{base}-{}", db::random_suffix(8));
if db::find_workspace(&state.db, &candidate).await?.is_none() {
return Ok(candidate);
}
}
Err(ApiError::internal("Failed to create a unique address"))
}
async fn unique_note_slug(
state: &SharedState,
workspace_id: i64,
base: &str,
) -> Result<String, ApiError> {
if db::find_note(&state.db, workspace_id, base)
.await?
.is_none()
{
return Ok(base.to_owned());
}
for _ in 0..8 {
let candidate = format!("{base}-{}", db::random_suffix(6));
if db::find_note(&state.db, workspace_id, &candidate)
.await?
.is_none()
{
return Ok(candidate);
}
}
Err(ApiError::internal("Failed to create a unique address"))
}
#[cfg(test)]
#[path = "../tests/api.rs"]
mod guest_resource_access_tests;