This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 13:07:22 +01:00
1 parent b15a6c54ce
commit d36a4b2451
22 files changed
+926 -114

No files matched your search

+118 -17
View File
@@ -193,6 +193,25 @@ async fn can_manage_workspace_settings(
account_owner || workspace_creator_is_requester(headers, workspace)
}
async fn has_explicit_write_permission(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
supplied_access_token: Option<&str>,
) -> Result<bool, ApiError> {
Ok(request_access_level(
state,
headers,
kind,
slug,
supplied_access_token,
bearer_token(headers),
)
.await?
>= AccessLevel::Write)
}
async fn has_write_permission(
state: &SharedState,
headers: &HeaderMap,
@@ -363,6 +382,21 @@ pub struct CreateNoteRequest {
created_by: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RenameRequest {
name: String,
#[serde(default)]
password: Option<String>,
#[serde(default)]
access_token: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct RenameResponse {
title: String,
updated_at: String,
}
#[derive(Debug, Deserialize)]
pub struct SetWorkspacePasswordRequest {
password: String,
@@ -439,6 +473,7 @@ fn normalize_list_per_page(value: usize) -> usize {
pub struct NoteListItem {
slug: String,
title: String,
can_rename: bool,
created_at: String,
updated_at: String,
url: String,
@@ -469,6 +504,7 @@ pub struct NoteInfo {
workspace_title: String,
slug: String,
title: String,
can_rename: bool,
protected: bool,
access_level: String,
note_protected: bool,
@@ -695,14 +731,8 @@ pub async fn create_workspace(
} else {
None
};
let workspace = db::create_workspace(
&state.db,
&slug,
title,
password,
created_by_guest_id,
)
.await?;
let workspace =
db::create_workspace(&state.db, &slug, title, password, created_by_guest_id).await?;
if let Some(user) = account_user {
sqlx::query(queries::get(
state.db.kind(),
@@ -772,12 +802,8 @@ pub async fn set_workspace_settings(
"Only the workspace owner can change workspace settings.",
));
}
db::set_workspace_archive_deleted_notes(
&state.db,
workspace.id,
payload.archive_deleted_notes,
)
.await?;
db::set_workspace_archive_deleted_notes(&state.db, workspace.id, payload.archive_deleted_notes)
.await?;
let workspace = db::find_workspace(&state.db, &workspace_slug)
.await?
.ok_or_else(ApiError::not_found_workspace)?;
@@ -870,8 +896,7 @@ pub async fn set_workspace_password(
"Only the workspace owner can set its password.",
));
}
let except_client_id =
crate::websocket::clean_collaboration_client_id(payload.client_id);
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_workspace_password(&state.db, &workspace_slug, password).await?;
@@ -902,6 +927,21 @@ pub async fn open_workspace(
&headers,
)
.await?;
let password_write_access =
db::verify_workspace_password(&workspace, payload.password.as_deref());
let explicit_write_access = has_explicit_write_permission(
&state,
&headers,
"workspace",
&workspace.slug,
resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
)
.await?;
let mut access_level = effective_header_access_level(
&state,
&headers,
@@ -911,7 +951,7 @@ pub async fn open_workspace(
workspace.password_hash.is_some(),
)
.await?;
if db::verify_workspace_password(&workspace, payload.password.as_deref()) {
if password_write_access {
access_level = AccessLevel::Write;
}
let workspace_account_owner = crate::auth::is_resource_owner(
@@ -981,6 +1021,10 @@ pub async fn open_workspace(
url: format!("/w/{}/n/{}", workspace.slug, note.slug),
slug: note.slug,
title: note.title,
can_rename: workspace_owner
|| note_owner
|| password_write_access
|| explicit_write_access,
created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
protected: note.protected,
@@ -1118,6 +1162,7 @@ pub async fn create_note(
url: format!("/w/{workspace_slug}/n/{slug}"),
slug: note.slug,
title: note.title,
can_rename: true,
created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
protected: note.protected,
@@ -1132,6 +1177,59 @@ pub async fn create_note(
))
}
pub async fn rename_note(
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<RenameRequest>,
) -> Result<Json<RenameResponse>, 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 requester_is_owner = requester_owns_note(&state, &headers, &workspace, &note).await?;
let password_write_access =
db::verify_workspace_password(&workspace, payload.password.as_deref());
let explicit_write_access = has_explicit_write_permission(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
)
.await?;
if !requester_is_owner && !password_write_access && !explicit_write_access {
return Err(ApiError::forbidden(
"Only the note owner or a user with write permission can rename it.",
));
}
let title = validate_name(&payload.name, "Note name")?;
db::rename_note(&state.db, note.id, workspace.id, title).await?;
let updated = db::find_note(&state.db, workspace.id, &note_slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
Ok(Json(RenameResponse {
title: updated.title,
updated_at: db::normalize_timestamp(&updated.updated_at),
}))
}
fn clean_editor_color(value: Option<&str>) -> Result<Option<String>, ApiError> {
let Some(value) = value else {
return Ok(None);
@@ -1263,6 +1361,8 @@ pub async fn note_info(
let password_write_access =
has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
let requester_is_owner = workspace_owner || workspace_guest_owner || note_owner;
let explicit_rename_access =
has_explicit_write_permission(&state, &headers, "workspace", &workspace_slug, None).await?;
let can_manage_authorship = can_manage_resource_settings(
workspace_owner || workspace_guest_owner,
note_owner,
@@ -1288,6 +1388,7 @@ pub async fn note_info(
workspace_title: workspace.title,
slug: note.slug,
title: note.title,
can_rename: requester_is_owner || explicit_rename_access,
protected: workspace.password_hash.is_some(),
access_level: access_level_name(access_level).into(),
note_protected: note.protected,
+53 -6
View File
@@ -35,6 +35,7 @@ pub struct CreatePadResponse {
pub struct PadInfo {
slug: String,
title: String,
can_rename: bool,
protected: bool,
access_level: String,
allow_public_task_updates: bool,
@@ -139,6 +140,8 @@ pub async fn pad_info(
.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 explicit_rename_access =
has_explicit_write_permission(&state, &headers, "pad", &slug, None).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?;
@@ -155,6 +158,7 @@ pub async fn pad_info(
Ok(Json(PadInfo {
slug: pad.slug,
title: pad.title,
can_rename: guest_owner || explicit_rename_access,
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?,
@@ -190,6 +194,47 @@ pub async fn pad_info(
}))
}
pub async fn rename_pad(
State(state): State<SharedState>,
headers: HeaderMap,
Path(slug): Path<String>,
Json(payload): Json<RenameRequest>,
) -> Result<Json<RenameResponse>, 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 guest_owner = pad_creator_is_requester(&headers, &pad);
let password_write_access = db::verify_pad_password(&pad, payload.password.as_deref());
let explicit_write_access = has_explicit_write_permission(
&state,
&headers,
"pad",
&slug,
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
)
.await?;
if !guest_owner && !password_write_access && !explicit_write_access {
return Err(ApiError::forbidden(
"Only the note owner or a user with write permission can rename it.",
));
}
let title = validate_name(&payload.name, "Note name")?;
db::rename_pad(&state.db, pad.id, title).await?;
let updated = db::find_pad(&state.db, &slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
Ok(Json(RenameResponse {
title: updated.title,
updated_at: db::normalize_timestamp(&updated.updated_at),
}))
}
pub async fn set_pad_password(
State(state): State<SharedState>,
headers: HeaderMap,
@@ -202,15 +247,17 @@ pub async fn set_pad_password(
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 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."));
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 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?;
+11 -8
View File
@@ -161,6 +161,7 @@ pub fn router(
.route("/api/public/{token}/tasks", post(api::update_public_task))
.route("/api/pads", post(api::create_pad))
.route("/api/pads/{slug}", get(api::pad_info))
.route("/api/pads/{slug}/rename", post(api::rename_pad))
.route("/api/pads/{slug}/history", post(api::pad_history))
.route(
"/api/pads/{slug}/editor-color",
@@ -211,6 +212,10 @@ pub fn router(
"/api/workspaces/{workspace_slug}/notes/{note_slug}",
get(api::note_info).delete(api::delete_note),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/rename",
post(api::rename_note),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-color",
get(api::note_editor_color).post(api::set_note_editor_color),
@@ -287,10 +292,9 @@ pub fn files_router(state: SharedState) -> Router {
async fn language_catalog() -> Response {
let mut response = Json(crate::i18n::metadata()).into_response();
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=300"),
);
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
response
}
@@ -307,10 +311,9 @@ async fn language_bundle(Path(code): Path<String>) -> Response {
header::CONTENT_TYPE,
HeaderValue::from_static("application/json; charset=utf-8"),
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=86400"),
);
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
response
}
+3 -1
View File
@@ -181,7 +181,9 @@ impl Config {
"GUEST_UPLOAD_MAX_SIZE_MB",
guest_upload_max_size_mb,
)?,
asset_version: format!("{}", env!("CARGO_PKG_VERSION")),
asset_version: option_env!("RUSTPAD_ASSET_VERSION")
.unwrap_or(env!("CARGO_PKG_VERSION"))
.to_string(),
http_compression: values.bool("HTTP_COMPRESSION", false)?,
asset_cache_max_age_seconds: values
.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
+49
View File
@@ -442,6 +442,36 @@ pub async fn create_note(
.ok_or(sqlx::Error::RowNotFound)
}
pub async fn rename_note(
pool: &Database,
note_id: i64,
workspace_id: i64,
title: &str,
) -> Result<(), sqlx::Error> {
let (note_query, workspace_query) = match pool.kind() {
DatabaseKind::Postgres => (
"UPDATE notes SET title = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2",
"UPDATE workspaces SET updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $1",
),
DatabaseKind::Sqlite | DatabaseKind::MySql => (
"UPDATE notes SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
"UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
),
};
let mut tx = pool.pool().begin().await?;
sqlx::query(note_query)
.bind(title)
.bind(note_id)
.execute(&mut *tx)
.await?;
sqlx::query(workspace_query)
.bind(workspace_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn save_revision(
pool: &Database,
note_id: i64,
@@ -650,6 +680,25 @@ pub async fn create_pad(
.await
}
pub async fn rename_pad(
pool: &Database,
pad_id: i64,
title: &str,
) -> Result<(), sqlx::Error> {
let query = match pool.kind() {
DatabaseKind::Postgres =>
"UPDATE pads SET title = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2",
DatabaseKind::Sqlite | DatabaseKind::MySql =>
"UPDATE pads SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
};
sqlx::query(query)
.bind(title)
.bind(pad_id)
.execute(pool.pool())
.await?;
Ok(())
}
pub async fn set_pad_password(
pool: &Database,
slug: &str,