1698 lines
50 KiB
Rust
1698 lines
50 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 requester_owns_note(
|
|
state: &SharedState,
|
|
headers: &HeaderMap,
|
|
workspace: &db::Workspace,
|
|
note: &db::Note,
|
|
) -> Result<bool, ApiError> {
|
|
let workspace_account_owner = crate::auth::is_resource_owner(
|
|
state,
|
|
"workspace",
|
|
&workspace.slug,
|
|
user_session_token(headers),
|
|
)
|
|
.await
|
|
.unwrap_or(false);
|
|
Ok(workspace_account_owner
|
|
|| workspace_creator_is_requester(headers, workspace)
|
|
|| note_creator_is_requester(state, headers, note).await?)
|
|
}
|
|
|
|
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,
|
|
can_delete: 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: bool,
|
|
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 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 workspace_account_owner = crate::auth::is_resource_owner(
|
|
&state,
|
|
"workspace",
|
|
&workspace.slug,
|
|
bearer_token(&headers),
|
|
)
|
|
.await
|
|
.unwrap_or(false);
|
|
let workspace_owner =
|
|
workspace_account_owner || workspace_creator_is_requester(&headers, &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 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(¬e.id);
|
|
let note_owner = if let Some(owner_guest_id) = note.created_by_guest_id.as_deref() {
|
|
requester_guest.is_some_and(|requester| requester == owner_guest_id)
|
|
} else {
|
|
note.created_by
|
|
.as_deref()
|
|
.zip(requester_nickname)
|
|
.is_some_and(|(owner, requester)| owner == requester)
|
|
};
|
|
let can_delete = can_delete_workspace_note(
|
|
access_level,
|
|
note.protected,
|
|
workspace_owner || note_owner,
|
|
);
|
|
NoteListItem {
|
|
url: format!("/w/{}/n/{}", workspace.slug, note.slug),
|
|
slug: note.slug,
|
|
title: note.title,
|
|
created_at: db::normalize_timestamp(¬e.created_at),
|
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
|
protected: note.protected,
|
|
can_delete,
|
|
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 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(¬e.created_at),
|
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
|
protected: note.protected,
|
|
can_delete: true,
|
|
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, ¬e_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, ¬e).await?;
|
|
let password_write_access =
|
|
has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
|
|
let requester_is_owner = workspace_owner || workspace_guest_owner || note_owner;
|
|
let can_manage_authorship = can_manage_resource_settings(
|
|
workspace_owner || workspace_guest_owner,
|
|
note_owner,
|
|
password_write_access,
|
|
);
|
|
let can_delete_files =
|
|
can_delete_workspace_note(access_level, note.protected, requester_is_owner);
|
|
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(¬e.created_at),
|
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
|
can_delete: can_delete_files,
|
|
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, ¬e_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, ¬e).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,
|
|
¬e_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,
|
|
¬e_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, ¬e_slug);
|
|
let channel = state.note_channel(&workspace_slug, ¬e_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 can_delete_workspace_note(
|
|
level: AccessLevel,
|
|
note_protected: bool,
|
|
requester_is_owner: bool,
|
|
) -> bool {
|
|
level >= AccessLevel::Write && (!note_protected || requester_is_owner)
|
|
}
|
|
|
|
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;
|