Files
rustpad/src/api/pads_public.rs
T
2026-08-06 13:49:50 +02:00

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,
&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 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, &current).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, &current, payload.source_line, payload.checked)
.await?;
} else {
commit_public_note_task_update(&state, &current, 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"))
}