878 lines
30 KiB
Rust
878 lines
30 KiB
Rust
/*
|
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
|
* Source-Available Code / Dual-Licensed.
|
|
*
|
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
|
* Commercial or production use requires a valid paid license.
|
|
* See LICENSE file in repository root for details.
|
|
*/
|
|
|
|
use super::*;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreatePadRequest {
|
|
name: String,
|
|
#[serde(default)]
|
|
password: Option<String>,
|
|
#[serde(default)]
|
|
content: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct SetPadPasswordRequest {
|
|
password: String,
|
|
#[serde(default)]
|
|
client_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct CreatePadResponse {
|
|
slug: String,
|
|
url: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct PadInfo {
|
|
slug: String,
|
|
title: String,
|
|
protected: bool,
|
|
access_level: String,
|
|
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,
|
|
navbar_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>,
|
|
}
|
|
|
|
pub async fn create_pad(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(payload): Json<CreatePadRequest>,
|
|
) -> Result<(StatusCode, Json<CreatePadResponse>), ApiError> {
|
|
let title = validate_name(&payload.name, "Note name")?;
|
|
let password = validate_password(payload.password.as_deref())?;
|
|
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_pad_slug(&state, &base).await?;
|
|
let account_user = crate::auth::optional_user(&state, &headers)
|
|
.await
|
|
.map_err(|e| ApiError::forbidden(&e.message))?;
|
|
let author = account_user.as_ref().map(|user| user.nickname.clone());
|
|
let created_by_guest_id = if account_user.is_none() {
|
|
requester_guest_id(&headers)
|
|
} else {
|
|
None
|
|
};
|
|
let pad = db::create_pad(&state.db, &slug, title, password, created_by_guest_id).await?;
|
|
if let Some(user) = account_user.as_ref() {
|
|
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD))
|
|
.bind(user.id)
|
|
.bind(&pad.slug)
|
|
.execute(state.db.pool())
|
|
.await?;
|
|
}
|
|
if let Some(content) = initial_content {
|
|
db::save_pad_revision(&state.db, pad.id, content, author.as_deref(), "[]").await?;
|
|
}
|
|
Ok((
|
|
StatusCode::CREATED,
|
|
Json(CreatePadResponse {
|
|
url: format!("/p/{slug}"),
|
|
slug,
|
|
}),
|
|
))
|
|
}
|
|
|
|
pub async fn pad_info(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Path(slug): Path<String>,
|
|
) -> Result<Json<PadInfo>, ApiError> {
|
|
let pad = db::find_pad(&state.db, &slug)
|
|
.await?
|
|
.ok_or_else(ApiError::not_found_note)?;
|
|
ensure_private_resource_access(&state, &headers, "pad", &pad.slug, pad.is_private).await?;
|
|
let access_level = effective_header_access_level(
|
|
&state,
|
|
&headers,
|
|
"pad",
|
|
&pad.slug,
|
|
pad.is_private,
|
|
pad.password_hash.is_some(),
|
|
)
|
|
.await?;
|
|
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
|
|
let (editor_preferences, personal_editor_settings) =
|
|
user_editor_preferences(&state, &headers, db::EditorPreferenceResource::Pad(pad.id))
|
|
.await?;
|
|
let resource_editor_settings =
|
|
db::load_resource_editor_settings(&state.db, "pad", &slug).await?;
|
|
let account_owner =
|
|
crate::auth::is_resource_owner(&state, "pad", &slug, user_session_token(&headers))
|
|
.await
|
|
.unwrap_or(false);
|
|
let guest_owner = pad_creator_is_requester(&headers, &pad);
|
|
let password_write_access = has_password_write_access(&state, &headers, "pad", &slug).await?;
|
|
let can_manage_authorship =
|
|
can_manage_resource_settings(account_owner, guest_owner, password_write_access);
|
|
let upload_max_size_bytes = resource_upload_limit(&state, &headers, "pad", &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, "pad", &slug).await?;
|
|
if pad.is_private == 0
|
|
&& pad.password_hash.is_some()
|
|
&& !db::pad_public_page_disabled(&state.db, pad.id).await?
|
|
&& !db::pad_public_page_enabled(&state.db, pad.id).await?
|
|
{
|
|
db::publish_pad(&state.db, pad.id).await?;
|
|
}
|
|
Ok(Json(PadInfo {
|
|
slug: pad.slug,
|
|
title: pad.title,
|
|
protected: pad.password_hash.is_some(),
|
|
access_level: access_level_name(access_level).into(),
|
|
allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?,
|
|
public_page_unprotected: db::pad_public_page_unprotected(&state.db, pad.id).await?,
|
|
public_page_enabled: db::pad_public_page_enabled(&state.db, pad.id).await?,
|
|
private: pad.is_private != 0,
|
|
created_at: db::normalize_timestamp(&pad.created_at),
|
|
updated_at: db::normalize_timestamp(&pad.updated_at),
|
|
can_delete_files: can_manage_authorship,
|
|
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,
|
|
navbar_collapsed: editor_preferences.navbar_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(
|
|
pad.password_hash.is_some(),
|
|
account_owner,
|
|
guest_owner,
|
|
),
|
|
files: markdown_file_references(&state, Some(pad.id), None, None).await?,
|
|
}))
|
|
}
|
|
|
|
pub async fn set_pad_password(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Path(slug): Path<String>,
|
|
Json(payload): Json<SetPadPasswordRequest>,
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
let pad = db::find_pad(&state.db, &slug)
|
|
.await?
|
|
.ok_or_else(ApiError::not_found_note)?;
|
|
if pad.password_hash.is_some() {
|
|
return Err(ApiError::bad_request("This note already has a password."));
|
|
}
|
|
let account_owner = crate::auth::is_resource_owner(
|
|
&state, "pad", &slug, user_session_token(&headers),
|
|
).await.unwrap_or(false);
|
|
let guest_owner = pad_creator_is_requester(&headers, &pad);
|
|
if !can_set_resource_password(pad.password_hash.is_some(), account_owner, guest_owner) {
|
|
return Err(ApiError::forbidden("Only the note 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_pad_password(&state.db, &slug, password).await?;
|
|
state
|
|
.notify_pad_password_required(&slug, except_client_id)
|
|
.await;
|
|
Ok(Json(serde_json::json!({"ok": true, "protected": true})))
|
|
}
|
|
|
|
pub async fn set_pad_editor_settings(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Path(slug): Path<String>,
|
|
Json(payload): Json<EditorSettingsRequest>,
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
let pad = db::find_pad(&state.db, &slug)
|
|
.await?
|
|
.ok_or_else(ApiError::not_found_note)?;
|
|
let creator_can_manage_authorship = can_manage_resource_settings(
|
|
false,
|
|
pad_creator_is_requester(&headers, &pad),
|
|
has_password_write_access(&state, &headers, "pad", &slug).await?,
|
|
);
|
|
save_editor_settings(
|
|
&state,
|
|
&headers,
|
|
"pad",
|
|
&slug,
|
|
"pad",
|
|
&slug,
|
|
db::EditorPreferenceResource::Pad(pad.id),
|
|
creator_can_manage_authorship,
|
|
payload,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn pad_editor_color(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Path(slug): Path<String>,
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
|
|
Ok(Json(
|
|
serde_json::json!({"global_color": global_color, "note_color": note_color}),
|
|
))
|
|
}
|
|
|
|
pub async fn note_editor_color(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
let (global_color, note_color) = editor_colors(
|
|
&state,
|
|
&headers,
|
|
"note",
|
|
&format!("{}/{}", workspace_slug, note_slug),
|
|
)
|
|
.await?;
|
|
Ok(Json(
|
|
serde_json::json!({"global_color": global_color, "note_color": note_color}),
|
|
))
|
|
}
|
|
|
|
pub async fn set_pad_editor_color(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Path(slug): Path<String>,
|
|
Json(payload): Json<EditorColorRequest>,
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
save_editor_color(&state, &headers, "pad", &slug, payload.color.as_deref()).await
|
|
}
|
|
|
|
pub async fn set_note_editor_color(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
|
Json(payload): Json<EditorColorRequest>,
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
save_editor_color(
|
|
&state,
|
|
&headers,
|
|
"note",
|
|
&format!("{}/{}", workspace_slug, note_slug),
|
|
payload.color.as_deref(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn publish_pad_page(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Path(slug): Path<String>,
|
|
Json(payload): Json<PublishRequest>,
|
|
) -> Result<Json<PublishResponse>, ApiError> {
|
|
let pad = authorized_pad(
|
|
&state,
|
|
&slug,
|
|
payload.password.as_deref(),
|
|
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
|
bearer_token(&headers),
|
|
&headers,
|
|
)
|
|
.await?;
|
|
let level = if db::verify_pad_password(&pad, payload.password.as_deref())
|
|
|| (pad.is_private == 0 && pad.password_hash.is_none())
|
|
{
|
|
AccessLevel::Write
|
|
} else {
|
|
request_access_level(
|
|
&state,
|
|
&headers,
|
|
"pad",
|
|
&slug,
|
|
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
|
bearer_token(&headers),
|
|
)
|
|
.await?
|
|
};
|
|
require_write(level)?;
|
|
let enabled = payload.enabled.unwrap_or(true);
|
|
if enabled && pad.password_hash.is_none() {
|
|
return Err(ApiError::bad_request(
|
|
"Set a resource password before enabling the published page.",
|
|
));
|
|
}
|
|
if !enabled {
|
|
db::unpublish_pad(&state.db, pad.id).await?;
|
|
db::set_pad_public_page_disabled(&state.db, pad.id, true).await?;
|
|
return Ok(Json(PublishResponse {
|
|
url: None,
|
|
enabled: false,
|
|
}));
|
|
}
|
|
db::set_pad_public_page_disabled(&state.db, pad.id, false).await?;
|
|
let token = db::publish_pad(&state.db, pad.id).await?;
|
|
db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).await?;
|
|
db::set_pad_public_page_unprotected(&state.db, pad.id, payload.unprotect_page).await?;
|
|
Ok(Json(PublishResponse {
|
|
url: Some(format!("/s/{token}")),
|
|
enabled: true,
|
|
}))
|
|
}
|
|
|
|
pub async fn publish_note_page(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
|
Json(payload): Json<PublishRequest>,
|
|
) -> Result<Json<PublishResponse>, 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 enabled = payload.enabled.unwrap_or(true);
|
|
if enabled && workspace.password_hash.is_none() {
|
|
return Err(ApiError::bad_request(
|
|
"Set a workspace password before enabling the published page.",
|
|
));
|
|
}
|
|
if !enabled {
|
|
db::unpublish_note(&state.db, note.id).await?;
|
|
db::set_note_public_page_disabled(&state.db, note.id, true).await?;
|
|
return Ok(Json(PublishResponse {
|
|
url: None,
|
|
enabled: false,
|
|
}));
|
|
}
|
|
db::set_note_public_page_disabled(&state.db, note.id, false).await?;
|
|
let token = db::publish_note(&state.db, note.id).await?;
|
|
db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).await?;
|
|
db::set_note_public_page_unprotected(&state.db, note.id, payload.unprotect_page).await?;
|
|
Ok(Json(PublishResponse {
|
|
url: Some(format!("/s/{token}")),
|
|
enabled: true,
|
|
}))
|
|
}
|
|
|
|
fn page_password(headers: &HeaderMap) -> Option<&str> {
|
|
headers
|
|
.get("x-rustpad-page-password")
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
}
|
|
|
|
async fn ensure_public_page_access(
|
|
state: &SharedState,
|
|
headers: &HeaderMap,
|
|
page: &db::PublishedPage,
|
|
) -> Result<(), ApiError> {
|
|
let password = page_password(headers);
|
|
if let Some(pad_id) = page.pad_id {
|
|
let page_unprotected = db::pad_public_page_unprotected(&state.db, pad_id).await?;
|
|
let sql = match state.db.kind() {
|
|
crate::database::DatabaseKind::Postgres => "SELECT slug FROM pads WHERE id = $1",
|
|
_ => "SELECT slug FROM pads WHERE id = ?",
|
|
};
|
|
let slug: Option<String> = sqlx::query_scalar(sql)
|
|
.bind(pad_id)
|
|
.fetch_optional(state.db.pool())
|
|
.await?;
|
|
let Some(slug) = slug else {
|
|
return Err(ApiError::not_found_note());
|
|
};
|
|
let pad = db::find_pad(&state.db, &slug)
|
|
.await?
|
|
.ok_or_else(ApiError::not_found_note)?;
|
|
if pad.password_hash.is_none() {
|
|
return Err(ApiError::forbidden(
|
|
"This published page is unavailable until a resource password is set.",
|
|
));
|
|
}
|
|
if page_unprotected || has_header_resource_access(state, headers, "pad", &slug).await? {
|
|
return Ok(());
|
|
}
|
|
let password_ok = db::verify_pad_password(&pad, password);
|
|
check_resource_password_attempt(state, headers, "pad", &slug, password, password_ok)
|
|
.await?;
|
|
if password_ok {
|
|
return Ok(());
|
|
}
|
|
return if pad.password_hash.is_some() {
|
|
Err(ApiError::forbidden("Password required or incorrect."))
|
|
} else {
|
|
Err(ApiError::forbidden("This published page is protected."))
|
|
};
|
|
}
|
|
if let Some(note_id) = page.note_id {
|
|
let page_unprotected = db::note_public_page_unprotected(&state.db, note_id).await?;
|
|
let sql = match state.db.kind() {
|
|
crate::database::DatabaseKind::Postgres => {
|
|
"SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = $1"
|
|
}
|
|
_ => {
|
|
"SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = ?"
|
|
}
|
|
};
|
|
let slug: Option<String> = sqlx::query_scalar(sql)
|
|
.bind(note_id)
|
|
.fetch_optional(state.db.pool())
|
|
.await?;
|
|
let Some(slug) = slug else {
|
|
return Err(ApiError::not_found_note());
|
|
};
|
|
let workspace = db::find_workspace(&state.db, &slug)
|
|
.await?
|
|
.ok_or_else(ApiError::not_found_workspace)?;
|
|
if workspace.password_hash.is_none() {
|
|
return Err(ApiError::forbidden(
|
|
"This published page is unavailable until a workspace password is set.",
|
|
));
|
|
}
|
|
if page_unprotected
|
|
|| has_header_resource_access(state, headers, "workspace", &slug).await?
|
|
{
|
|
return Ok(());
|
|
}
|
|
let password_ok = db::verify_workspace_password(&workspace, password);
|
|
check_resource_password_attempt(state, headers, "workspace", &slug, password, password_ok)
|
|
.await?;
|
|
if password_ok {
|
|
return Ok(());
|
|
}
|
|
return if workspace.password_hash.is_some() {
|
|
Err(ApiError::forbidden("Password required or incorrect."))
|
|
} else {
|
|
Err(ApiError::forbidden("This published page is protected."))
|
|
};
|
|
}
|
|
Err(ApiError::not_found_note())
|
|
}
|
|
|
|
pub async fn public_page(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Path(token): Path<String>,
|
|
) -> Result<Json<PublicPageResponse>, ApiError> {
|
|
let page = db::find_published_page(&state.db, &token)
|
|
.await?
|
|
.ok_or_else(ApiError::not_found_note)?;
|
|
ensure_public_page_access(&state, &headers, &page).await?;
|
|
let files =
|
|
markdown_file_references(&state, page.pad_id, page.note_id, Some(&page.content)).await?;
|
|
Ok(Json(PublicPageResponse {
|
|
title: page.title,
|
|
content: page.content,
|
|
updated_at: db::normalize_timestamp(&page.updated_at),
|
|
allow_task_updates: page.allow_task_updates,
|
|
files,
|
|
}))
|
|
}
|
|
|
|
async fn commit_public_pad_task_update(
|
|
state: &SharedState,
|
|
page: &db::PublishedPage,
|
|
source_line: usize,
|
|
checked: bool,
|
|
) -> Result<(), ApiError> {
|
|
let pad_id = page.pad_id.ok_or_else(ApiError::not_found_note)?;
|
|
let room_key = crate::state::AppState::pad_room_key(&page.resource_slug);
|
|
let channel = state.pad_channel(&page.resource_slug).await;
|
|
let collaboration_snapshot = db::pad_collaboration_snapshot(&state.db, pad_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 Some(next_content) =
|
|
db::updated_public_task_content(&document.content, source_line, checked)
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let base_revision_id = document.revision_id;
|
|
let operation =
|
|
collab::operation_from_edit(&document.content, &next_content, &document.owner_map);
|
|
let (content, owner_map) = collab::apply_operation_to_document(
|
|
&document.content,
|
|
&document.owner_map,
|
|
&operation,
|
|
&[],
|
|
)
|
|
.map_err(|_| ApiError::bad_request("The task could not be updated"))?;
|
|
let (revision_id, updated_at) =
|
|
db::save_pad_revision(&state.db, pad_id, &content, Some("public"), &owner_map).await?;
|
|
let update_id = u64::try_from(revision_id).unwrap_or_default().max(1);
|
|
document.content.clone_from(&content);
|
|
document.owner_map.clone_from(&owner_map);
|
|
document.revision_id = revision_id;
|
|
document.record(AppliedOperation {
|
|
base_revision_id,
|
|
revision_id,
|
|
client_id: "public_task".into(),
|
|
update_id,
|
|
operation: operation.clone(),
|
|
owner_replacements: Vec::new(),
|
|
});
|
|
let _ = channel.send(RoomEvent::Document(NoteUpdate {
|
|
base_revision_id,
|
|
revision_id,
|
|
updated_at,
|
|
author: Some("public".into()),
|
|
client_id: "public_task".into(),
|
|
update_id,
|
|
operation,
|
|
owner_replacements: Vec::new(),
|
|
}));
|
|
drop(document);
|
|
Ok(())
|
|
}
|
|
|
|
async fn commit_public_note_task_update(
|
|
state: &SharedState,
|
|
page: &db::PublishedPage,
|
|
source_line: usize,
|
|
checked: bool,
|
|
) -> Result<(), ApiError> {
|
|
let note_id = page.note_id.ok_or_else(ApiError::not_found_note)?;
|
|
let workspace_id = page.workspace_id.ok_or_else(ApiError::not_found_note)?;
|
|
let workspace_slug = page
|
|
.workspace_slug
|
|
.as_deref()
|
|
.ok_or_else(ApiError::not_found_note)?;
|
|
let room_key = crate::state::AppState::note_room_key(workspace_slug, &page.resource_slug);
|
|
let channel = state
|
|
.note_channel(workspace_slug, &page.resource_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 Some(next_content) =
|
|
db::updated_public_task_content(&document.content, source_line, checked)
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let base_revision_id = document.revision_id;
|
|
let operation =
|
|
collab::operation_from_edit(&document.content, &next_content, &document.owner_map);
|
|
let (content, owner_map) = collab::apply_operation_to_document(
|
|
&document.content,
|
|
&document.owner_map,
|
|
&operation,
|
|
&[],
|
|
)
|
|
.map_err(|_| ApiError::bad_request("The task could not be updated"))?;
|
|
let (revision_id, updated_at) = db::save_revision(
|
|
&state.db,
|
|
note_id,
|
|
workspace_id,
|
|
&content,
|
|
Some("public"),
|
|
&owner_map,
|
|
)
|
|
.await?;
|
|
let update_id = u64::try_from(revision_id).unwrap_or_default().max(1);
|
|
document.content.clone_from(&content);
|
|
document.owner_map.clone_from(&owner_map);
|
|
document.revision_id = revision_id;
|
|
document.record(AppliedOperation {
|
|
base_revision_id,
|
|
revision_id,
|
|
client_id: "public_task".into(),
|
|
update_id,
|
|
operation: operation.clone(),
|
|
owner_replacements: Vec::new(),
|
|
});
|
|
let _ = channel.send(RoomEvent::Document(NoteUpdate {
|
|
base_revision_id,
|
|
revision_id,
|
|
updated_at,
|
|
author: Some("public".into()),
|
|
client_id: "public_task".into(),
|
|
update_id,
|
|
operation,
|
|
owner_replacements: Vec::new(),
|
|
}));
|
|
drop(document);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn update_public_task(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Path(token): Path<String>,
|
|
Json(payload): Json<PublicTaskUpdateRequest>,
|
|
) -> Result<Json<PublicPageResponse>, ApiError> {
|
|
let current = db::find_published_page(&state.db, &token)
|
|
.await?
|
|
.ok_or_else(ApiError::not_found_note)?;
|
|
ensure_public_page_access(&state, &headers, ¤t).await?;
|
|
if !current.allow_task_updates {
|
|
return Err(ApiError::forbidden(
|
|
"Task updates are disabled for this page",
|
|
));
|
|
}
|
|
if current.pad_id.is_some() {
|
|
commit_public_pad_task_update(&state, ¤t, payload.source_line, payload.checked)
|
|
.await?;
|
|
} else {
|
|
commit_public_note_task_update(&state, ¤t, payload.source_line, payload.checked)
|
|
.await?;
|
|
}
|
|
let page = db::find_published_page(&state.db, &token)
|
|
.await?
|
|
.ok_or_else(ApiError::not_found_note)?;
|
|
let files =
|
|
markdown_file_references(&state, page.pad_id, page.note_id, Some(&page.content)).await?;
|
|
Ok(Json(PublicPageResponse {
|
|
title: page.title,
|
|
content: page.content,
|
|
updated_at: db::normalize_timestamp(&page.updated_at),
|
|
allow_task_updates: page.allow_task_updates,
|
|
files,
|
|
}))
|
|
}
|
|
|
|
pub async fn pad_history(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Path(slug): Path<String>,
|
|
Json(payload): Json<PasswordRequest>,
|
|
) -> Result<Json<Vec<db::Revision>>, ApiError> {
|
|
let pad = authorized_pad(
|
|
&state,
|
|
&slug,
|
|
payload.password.as_deref(),
|
|
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
|
bearer_token(&headers),
|
|
&headers,
|
|
)
|
|
.await?;
|
|
let revisions = db::list_pad_revisions(&state.db, pad.id)
|
|
.await?
|
|
.into_iter()
|
|
.map(|mut revision| {
|
|
revision.created_at = db::normalize_timestamp(&revision.created_at);
|
|
revision
|
|
})
|
|
.collect();
|
|
Ok(Json(revisions))
|
|
}
|
|
|
|
pub async fn pad_restore(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Path(slug): Path<String>,
|
|
Json(payload): Json<RestoreRequest>,
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
let pad = authorized_pad(
|
|
&state,
|
|
&slug,
|
|
payload.password.as_deref(),
|
|
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
|
bearer_token(&headers),
|
|
&headers,
|
|
)
|
|
.await?;
|
|
let level = if db::verify_pad_password(&pad, payload.password.as_deref())
|
|
|| (pad.is_private == 0 && pad.password_hash.is_none())
|
|
{
|
|
AccessLevel::Write
|
|
} else {
|
|
request_access_level(
|
|
&state,
|
|
&headers,
|
|
"pad",
|
|
&slug,
|
|
resource_request_token(&headers, "pad", &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::Q029))
|
|
.bind(payload.revision_id)
|
|
.bind(pad.id)
|
|
.fetch_optional(state.db.pool())
|
|
.await?;
|
|
let content = content.ok_or_else(ApiError::not_found_revision)?;
|
|
let owner_map: Option<String> =
|
|
sqlx::query_scalar(queries::get(state.db.kind(), queries::Q030))
|
|
.bind(payload.revision_id)
|
|
.bind(pad.id)
|
|
.fetch_optional(state.db.pool())
|
|
.await?;
|
|
let owner_map = owner_map.unwrap_or_else(|| "[]".into());
|
|
let room_key = crate::state::AppState::pad_room_key(&slug);
|
|
let channel = state.pad_channel(&slug).await;
|
|
let collaboration_snapshot = db::pad_collaboration_snapshot(&state.db, pad.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 restored_owners = collab::owner_spans_from_map(&content, &owner_map);
|
|
let operation = collab::replace_operation(
|
|
document.content.encode_utf16().count(),
|
|
content,
|
|
restored_owners,
|
|
);
|
|
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_pad_revision(&state.db, pad.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})))
|
|
}
|
|
|
|
pub(super) async fn authorized_pad(
|
|
state: &SharedState,
|
|
slug: &str,
|
|
password: Option<&str>,
|
|
access_token: Option<&str>,
|
|
bearer: Option<&str>,
|
|
headers: &HeaderMap,
|
|
) -> Result<db::Pad, ApiError> {
|
|
let pad = db::find_pad(&state.db, slug)
|
|
.await?
|
|
.ok_or_else(ApiError::not_found_note)?;
|
|
let token_level =
|
|
request_access_level(state, headers, "pad", slug, access_token, bearer).await?;
|
|
if pad.is_private != 0 && token_level == AccessLevel::None {
|
|
return Err(ApiError::not_found_note());
|
|
}
|
|
if pad.password_hash.is_some() && token_level < AccessLevel::Write {
|
|
let password_ok = db::verify_pad_password(&pad, password);
|
|
check_resource_password_attempt(state, headers, "pad", slug, password, password_ok).await?;
|
|
if token_level == AccessLevel::None && !password_ok {
|
|
return Err(ApiError::forbidden("Password required or incorrect."));
|
|
}
|
|
}
|
|
Ok(pad)
|
|
}
|
|
|
|
async fn unique_pad_slug(state: &SharedState, base: &str) -> Result<String, ApiError> {
|
|
if db::find_pad(&state.db, base).await?.is_none() {
|
|
return Ok(base.to_owned());
|
|
}
|
|
for _ in 0..8 {
|
|
let candidate = format!("{base}-{}", db::random_suffix(6));
|
|
if db::find_pad(&state.db, &candidate).await?.is_none() {
|
|
return Ok(candidate);
|
|
}
|
|
}
|
|
Err(ApiError::internal("Failed to create a unique address"))
|
|
}
|