This commit is contained in:
Mateusz Gruszczyński
2026-07-28 23:53:37 +02:00
parent bc25e49bd6
commit 09669ff330
28 changed files with 412 additions and 89 deletions
+6 -2
View File
@@ -81,6 +81,8 @@ pub struct PublishRequest {
access_token: Option<String>,
#[serde(default)]
allow_task_updates: bool,
#[serde(default)]
unprotect_page: bool,
}
#[derive(Debug, Deserialize)]
@@ -150,6 +152,7 @@ pub struct NoteInfo {
protected: bool,
note_protected: bool,
allow_public_task_updates: bool,
public_page_unprotected: bool,
created_at: String,
updated_at: String,
can_delete_files: bool,
@@ -445,6 +448,7 @@ pub async fn note_info(
protected: workspace.password_hash.is_some(),
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?,
created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
can_delete_files: {
@@ -651,7 +655,7 @@ async fn ensure_private_resource_access(
if verify_resource_access_token(state, kind, slug, token).await? {
return Ok(());
}
Err(ApiError::forbidden("This resource is private."))
Err(ApiError::not_found_workspace())
}
pub async fn authorized_workspace(
@@ -667,7 +671,7 @@ pub async fn authorized_workspace(
let token_level =
combined_token_access_level(state, "workspace", slug, access_token, bearer).await?;
if workspace.is_private != 0 && token_level == AccessLevel::None {
return Err(ApiError::forbidden("This workspace is private."));
return Err(ApiError::not_found_workspace());
}
if workspace.password_hash.is_some()
&& !db::verify_workspace_password(&workspace, password)
+47 -2
View File
@@ -19,6 +19,7 @@ pub struct PadInfo {
title: String,
protected: bool,
allow_public_task_updates: bool,
public_page_unprotected: bool,
created_at: String,
updated_at: String,
can_delete_files: bool,
@@ -82,6 +83,7 @@ pub async fn pad_info(
title: pad.title,
protected: pad.password_hash.is_some(),
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?,
created_at: db::normalize_timestamp(&pad.created_at),
updated_at: db::normalize_timestamp(&pad.updated_at),
can_delete_files: crate::auth::is_resource_owner(
@@ -181,6 +183,7 @@ pub async fn publish_pad_page(
require_write(level)?;
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: format!("/s/{token}"),
}))
@@ -218,18 +221,58 @@ pub async fn publish_note_page(
require_write(level)?;
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: format!("/s/{token}"),
}))
}
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);
let bearer = bearer_token(headers);
if let Some(pad_id) = page.pad_id {
if db::pad_public_page_unprotected(&state.db, pad_id).await? { return Ok(()); }
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 db::verify_pad_password(&pad, password) { return Ok(()); }
if verify_resource_access_token(state, "pad", &slug, bearer).await? { 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 {
if db::note_public_page_unprotected(&state.db, note_id).await? { return Ok(()); }
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 db::verify_workspace_password(&workspace, password) { return Ok(()); }
if verify_resource_access_token(state, "workspace", &slug, bearer).await? { 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?;
Ok(Json(PublicPageResponse {
title: page.title,
content: page.content,
@@ -240,12 +283,14 @@ pub async fn public_page(
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",
@@ -357,13 +402,13 @@ pub(super) async fn authorized_pad(
.ok_or_else(ApiError::not_found_note)?;
let token_level = combined_token_access_level(state, "pad", slug, access_token, bearer).await?;
if pad.is_private != 0 && token_level == AccessLevel::None {
return Err(ApiError::forbidden("This note is private."));
return Err(ApiError::not_found_note());
}
if pad.password_hash.is_some()
&& !db::verify_pad_password(&pad, password)
&& token_level == AccessLevel::None
{
return Err(ApiError::unauthorized());
return Err(ApiError::forbidden("Password required or incorrect."));
}
Ok(pad)
}