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)
}
-1
View File
@@ -50,7 +50,6 @@ pub fn router(
.route("/s/{token}", get(public_page))
.route("/w/{workspace_slug}", get(workspace))
.route("/w/{workspace_slug}/n/{note_slug}", get(note))
.route("/errors/private-workspace", get(private_workspace_error))
.route("/health", get(health))
.route("/robots.txt", get(robots_txt))
.route("/favicon.ico", get(favicon))
+11 -23
View File
@@ -6,18 +6,6 @@ use axum::{
use crate::{assets, db, state::SharedState};
pub(super) async fn private_workspace_error(State(state): State<SharedState>) -> Response {
error_response(
StatusCode::FORBIDDEN,
"403",
"Private workspace",
"You do not have permission to access this private workspace. Ask the owner to share it with your account or use a valid share link.",
"/",
"Home page",
&state.asset_version,
)
}
pub(super) async fn health() -> &'static str {
"ok"
}
@@ -112,7 +100,7 @@ pub(super) async fn workspace(
match db::find_workspace(&state.db, &workspace_slug).await {
Ok(Some(workspace)) => {
let html = include_str!("../../static/workspace.html")
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title));
.replace("__WORKSPACE_TITLE__", &escape_html(if workspace.is_private != 0 { "Workspace" } else { &workspace.title }));
assets::render_html(
&html,
&state.asset_version,
@@ -126,8 +114,8 @@ pub(super) async fn workspace(
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
"Workspace not found",
"This workspace does not exist or has been deleted.",
"Page not found",
"Check the address or return to the home page.",
"/",
"Home page",
&state.asset_version,
@@ -149,8 +137,8 @@ pub(super) async fn note(
return error_response(
StatusCode::NOT_FOUND,
"404",
"Workspace not found",
"The workspace for this note does not exist or has been deleted.",
"Page not found",
"Check the address or return to the home page.",
"/",
"Home page",
&state.asset_version,
@@ -165,8 +153,8 @@ pub(super) async fn note(
match db::find_note(&state.db, workspace.id, &note_slug).await {
Ok(Some(note)) => {
let html = include_str!("../../static/note.html")
.replace("__NOTE_TITLE__", &escape_html(&note.title))
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title))
.replace("__NOTE_TITLE__", &escape_html(if workspace.is_private != 0 { "Note" } else { &note.title }))
.replace("__WORKSPACE_TITLE__", &escape_html(if workspace.is_private != 0 { "Workspace" } else { &workspace.title }))
.replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug));
assets::render_html(
&html,
@@ -181,10 +169,10 @@ pub(super) async fn note(
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
"Note not found",
"This note does not exist or has been deleted.",
&format!("/w/{workspace_slug}"),
"Back do workspace",
"Page not found",
"Check the address or return to the home page.",
"/",
"Home page",
&state.asset_version,
),
Err(error) => {
-5
View File
@@ -279,11 +279,6 @@ pub async fn identity(
State(state): State<SharedState>,
Json(req): Json<IdentityRequest>,
) -> Result<Json<IdentityResponse>, AuthError> {
if state.ldap.is_some() && req.session_token.is_none() {
return Err(AuthError::unauthorized(
"Log in with your organization account.",
));
}
let nickname = validate_nickname(&req.nickname)?;
debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested");
match find_user_by_nickname(&state, &nickname).await? {
+31
View File
@@ -185,6 +185,37 @@ pub async fn set_note_public_task_updates(
Ok(())
}
pub async fn pad_public_page_unprotected(pool: &Database, pad_id: i64) -> Result<bool, sqlx::Error> {
if pool.kind() == DatabaseKind::Postgres {
return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q048)).bind(pad_id).fetch_optional(pool.pool()).await?.unwrap_or(false));
}
Ok(sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q048)).bind(pad_id).fetch_optional(pool.pool()).await?.unwrap_or(0) != 0)
}
pub async fn note_public_page_unprotected(pool: &Database, note_id: i64) -> Result<bool, sqlx::Error> {
if pool.kind() == DatabaseKind::Postgres {
return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q049)).bind(note_id).fetch_optional(pool.pool()).await?.unwrap_or(false));
}
Ok(sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q049)).bind(note_id).fetch_optional(pool.pool()).await?.unwrap_or(0) != 0)
}
pub async fn set_pad_public_page_unprotected(pool: &Database, pad_id: i64, value: bool) -> Result<(), sqlx::Error> {
publish_pad(pool, pad_id).await?;
let mut query = sqlx::query(queries::get(pool.kind(), queries::Q050));
query = if pool.kind() == DatabaseKind::Postgres { query.bind(value) } else { query.bind(if value { 1i64 } else { 0i64 }) };
query.bind(pad_id).execute(pool.pool()).await?;
Ok(())
}
pub async fn set_note_public_page_unprotected(pool: &Database, note_id: i64, value: bool) -> Result<(), sqlx::Error> {
publish_note(pool, note_id).await?;
let mut query = sqlx::query(queries::get(pool.kind(), queries::Q051));
query = if pool.kind() == DatabaseKind::Postgres { query.bind(value) } else { query.bind(if value { 1i64 } else { 0i64 }) };
query.bind(note_id).execute(pool.pool()).await?;
Ok(())
}
pub async fn update_public_task(
pool: &Database,
token: &str,
+8
View File
@@ -132,6 +132,10 @@ pub enum Query {
Q043,
Q044,
Q045,
Q048,
Q049,
Q050,
Q051,
}
pub fn get(kind: DatabaseKind, query: Query) -> &'static str {
@@ -267,6 +271,10 @@ pub const Q042: Query = Query::Q042;
pub const Q043: Query = Query::Q043;
pub const Q044: Query = Query::Q044;
pub const Q045: Query = Query::Q045;
pub const Q048: Query = Query::Q048;
pub const Q049: Query = Query::Q049;
pub const Q050: Query = Query::Q050;
pub const Q051: Query = Query::Q051;
#[cfg(test)]
mod tests {
+5
View File
@@ -127,5 +127,10 @@ pub fn get(query: Query) -> &'static str {
Query::Q043 => r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#,
Query::Q044 => r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE pad_id = ?"#,
Query::Q045 => r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE note_id = ?"#,
Query::Q048 => r#"SELECT CAST(CASE WHEN unprotected THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE pad_id = ?"#,
Query::Q049 => r#"SELECT CAST(CASE WHEN unprotected THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE note_id = ?"#,
Query::Q050 => r#"UPDATE published_pages SET unprotected = ? WHERE pad_id = ?"#,
Query::Q051 => r#"UPDATE published_pages SET unprotected = ? WHERE note_id = ?"#,
}
}
+5
View File
@@ -127,5 +127,10 @@ pub fn get(query: Query) -> &'static str {
Query::Q043 => r#"UPDATE notes SET content = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2"#,
Query::Q044 => r#"SELECT allow_task_updates FROM published_pages WHERE pad_id = $1"#,
Query::Q045 => r#"SELECT allow_task_updates FROM published_pages WHERE note_id = $1"#,
Query::Q048 => r#"SELECT unprotected FROM published_pages WHERE pad_id = $1"#,
Query::Q049 => r#"SELECT unprotected FROM published_pages WHERE note_id = $1"#,
Query::Q050 => r#"UPDATE published_pages SET unprotected = $1 WHERE pad_id = $2"#,
Query::Q051 => r#"UPDATE published_pages SET unprotected = $1 WHERE note_id = $2"#,
}
}
+5
View File
@@ -127,5 +127,10 @@ pub fn get(query: Query) -> &'static str {
Query::Q043 => r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#,
Query::Q044 => r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"#,
Query::Q045 => r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"#,
Query::Q048 => r#"SELECT CASE WHEN unprotected THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"#,
Query::Q049 => r#"SELECT CASE WHEN unprotected THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"#,
Query::Q050 => r#"UPDATE published_pages SET unprotected = ? WHERE pad_id = ?"#,
Query::Q051 => r#"UPDATE published_pages SET unprotected = ? WHERE note_id = ?"#,
}
}
+16
View File
@@ -41,9 +41,24 @@ pub struct NoteUpdate {
#[derive(Debug, Clone, Serialize)]
pub struct PresenceUser {
pub name: String,
pub compact_name: String,
pub color: Option<String>,
}
fn compact_presence_name(name: &str) -> String {
let trimmed = name.trim();
let Some((first, rest)) = trimmed.split_once('.') else {
return trimmed.to_string();
};
if first.is_empty() || rest.is_empty() {
return trimmed.to_string();
}
let Some(initial) = first.chars().next() else {
return trimmed.to_string();
};
format!("{initial}.{rest}")
}
#[derive(Debug, Clone)]
struct PresenceConnection {
identity: String,
@@ -158,6 +173,7 @@ impl AppState {
PresenceConnection {
identity,
user: PresenceUser {
compact_name: compact_presence_name(&nickname),
name: nickname,
color,
},
+1 -1
View File
@@ -171,7 +171,7 @@ async fn handle_socket(
.unwrap_or(false);
let password_ok = db::verify_workspace_password(&workspace, password.as_deref());
if workspace.is_private != 0 && permission.is_none() && !anonymous_token_ok {
let _ = send_error(&mut socket, "This workspace is private").await;
let _ = send_error(&mut socket, "Workspace not found").await;
return;
}
if workspace.password_hash.is_some()
+1 -1
View File
@@ -114,7 +114,7 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: "This note is private".into(),
message: "Note not found".into(),
},
)
.await;