fixes
This commit is contained in:
+6
-2
@@ -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(¬e.created_at),
|
||||
updated_at: db::normalize_timestamp(¬e.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
@@ -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, ¤t).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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, ¬e_slug).await {
|
||||
Ok(Some(note)) => {
|
||||
let html = include_str!("../../static/note.html")
|
||||
.replace("__NOTE_TITLE__", &escape_html(¬e.title))
|
||||
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title))
|
||||
.replace("__NOTE_TITLE__", &escape_html(if workspace.is_private != 0 { "Note" } else { ¬e.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) => {
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = ?"#,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"#,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = ?"#,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user