security upgrade
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE notes ADD COLUMN created_by_guest_id VARCHAR(64) NULL;
|
||||||
|
ALTER TABLE pads ADD COLUMN created_by_guest_id VARCHAR(64) NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE notes ADD COLUMN created_by_guest_id TEXT;
|
||||||
|
ALTER TABLE pads ADD COLUMN created_by_guest_id TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE notes ADD COLUMN created_by_guest_id TEXT;
|
||||||
|
ALTER TABLE pads ADD COLUMN created_by_guest_id TEXT;
|
||||||
+23
-55
@@ -16,6 +16,7 @@ pub async fn upload_pad_file(
|
|||||||
Path(slug): Path<String>,
|
Path(slug): Path<String>,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
require_upload_permission(&state, &headers).await?;
|
||||||
let mut password: Option<String> = None;
|
let mut password: Option<String> = None;
|
||||||
let mut access_token: Option<String> = None;
|
let mut access_token: Option<String> = None;
|
||||||
let mut file: Option<(String, Vec<u8>)> = None;
|
let mut file: Option<(String, Vec<u8>)> = None;
|
||||||
@@ -60,14 +61,6 @@ pub async fn upload_pad_file(
|
|||||||
&headers,
|
&headers,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
require_upload_permission(
|
|
||||||
&state,
|
|
||||||
&headers,
|
|
||||||
"pad",
|
|
||||||
&slug,
|
|
||||||
resource_request_token(&headers, "pad", &slug, access_token.as_deref()),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let level = if db::verify_pad_password(&pad, password.as_deref())
|
let level = if db::verify_pad_password(&pad, password.as_deref())
|
||||||
|| (pad.is_private == 0 && pad.password_hash.is_none())
|
|| (pad.is_private == 0 && pad.password_hash.is_none())
|
||||||
{
|
{
|
||||||
@@ -190,10 +183,18 @@ pub async fn delete_pad_file(
|
|||||||
&headers,
|
&headers,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if !crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers))
|
let account_owner = crate::auth::is_resource_owner(
|
||||||
|
&state,
|
||||||
|
"pad",
|
||||||
|
&pad.slug,
|
||||||
|
bearer_token(&headers),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false)
|
.unwrap_or(false);
|
||||||
{
|
let guest_owner = pad_creator_is_requester(&headers, &pad);
|
||||||
|
let password_write_access =
|
||||||
|
has_password_write_access(&state, &headers, "pad", &pad.slug).await?;
|
||||||
|
if !account_owner && !guest_owner && !password_write_access {
|
||||||
return Err(ApiError::forbidden("Only the note owner can delete files"));
|
return Err(ApiError::forbidden("Only the note owner can delete files"));
|
||||||
}
|
}
|
||||||
let file = db::find_pad_file(&state.db, pad.id, file_id)
|
let file = db::find_pad_file(&state.db, pad.id, file_id)
|
||||||
@@ -212,6 +213,7 @@ pub async fn upload_note_file(
|
|||||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
require_upload_permission(&state, &headers).await?;
|
||||||
let mut password: Option<String> = None;
|
let mut password: Option<String> = None;
|
||||||
let mut access_token: Option<String> = None;
|
let mut access_token: Option<String> = None;
|
||||||
let mut file: Option<(String, Vec<u8>)> = None;
|
let mut file: Option<(String, Vec<u8>)> = None;
|
||||||
@@ -258,20 +260,6 @@ pub async fn upload_note_file(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
require_upload_permission(
|
|
||||||
&state,
|
|
||||||
&headers,
|
|
||||||
"workspace",
|
|
||||||
&workspace_slug,
|
|
||||||
resource_request_token(
|
|
||||||
&headers,
|
|
||||||
"workspace",
|
|
||||||
&workspace_slug,
|
|
||||||
access_token.as_deref(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let level = if db::verify_workspace_password(&workspace, password.as_deref())
|
let level = if db::verify_workspace_password(&workspace, password.as_deref())
|
||||||
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
|
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
|
||||||
{
|
{
|
||||||
@@ -433,19 +421,12 @@ pub async fn delete_note_file(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let note_owner = crate::auth::optional_user(&state, &headers)
|
let note_owner = note_creator_is_requester(&state, &headers, ¬e).await?;
|
||||||
.await
|
let password_write_access =
|
||||||
.ok()
|
has_password_write_access(&state, &headers, "workspace", &workspace.slug).await?;
|
||||||
.flatten()
|
if !workspace_owner && !note_owner && !password_write_access {
|
||||||
.and_then(|user| {
|
|
||||||
note.created_by
|
|
||||||
.as_deref()
|
|
||||||
.map(|creator| creator == user.nickname)
|
|
||||||
})
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !workspace_owner && !note_owner {
|
|
||||||
return Err(ApiError::forbidden(
|
return Err(ApiError::forbidden(
|
||||||
"Only the note owner or workspace owner can delete files",
|
"Only the note owner, workspace owner, or password holder can delete files",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let file = db::find_note_file(&state.db, note.id, file_id)
|
let file = db::find_note_file(&state.db, note.id, file_id)
|
||||||
@@ -461,29 +442,16 @@ pub async fn delete_note_file(
|
|||||||
async fn require_upload_permission(
|
async fn require_upload_permission(
|
||||||
state: &SharedState,
|
state: &SharedState,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
kind: &str,
|
|
||||||
slug: &str,
|
|
||||||
resource_token: Option<&str>,
|
|
||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
let permission = crate::auth::share_link_permission(state, kind, slug, resource_token)
|
|
||||||
.await
|
|
||||||
.map_err(|error| ApiError::forbidden(&error.message))?;
|
|
||||||
if permission.as_deref() == Some("rw") {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let user = crate::auth::optional_user(state, headers)
|
let user = crate::auth::optional_user(state, headers)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| ApiError::forbidden(&error.message))?;
|
.map_err(|error| ApiError::forbidden(&error.message))?;
|
||||||
if user.is_some() {
|
if user.is_some() {
|
||||||
return Ok(());
|
Ok(())
|
||||||
}
|
} else {
|
||||||
|
Err(ApiError::forbidden(
|
||||||
match permission.as_deref() {
|
"Log in with read-write access to upload files.",
|
||||||
Some("ro") => Err(ApiError::forbidden("Read-only access.")),
|
))
|
||||||
_ => Err(ApiError::forbidden(
|
|
||||||
"Log in or use a read-write share link to upload files.",
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+113
-28
@@ -76,6 +76,44 @@ async fn session_user(
|
|||||||
.map_err(|error| ApiError::forbidden(&error.message))
|
.map_err(|error| ApiError::forbidden(&error.message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn requester_guest_id(headers: &HeaderMap) -> Option<&str> {
|
||||||
|
crate::security::cookie_value(headers, "rustpad_guest_id")
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| {
|
||||||
|
(16..=64).contains(&value.len())
|
||||||
|
&& value
|
||||||
|
.chars()
|
||||||
|
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn note_creator_is_requester(
|
||||||
|
state: &SharedState,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
note: &db::Note,
|
||||||
|
) -> Result<bool, ApiError> {
|
||||||
|
if let Some(owner_guest_id) = note.created_by_guest_id.as_deref() {
|
||||||
|
return Ok(requester_guest_id(headers)
|
||||||
|
.is_some_and(|requester_guest_id| requester_guest_id == owner_guest_id));
|
||||||
|
}
|
||||||
|
let Some(user) = session_user(state, headers).await? else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
Ok(note
|
||||||
|
.created_by
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|creator| creator == user.nickname))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pad_creator_is_requester(headers: &HeaderMap, pad: &db::Pad) -> bool {
|
||||||
|
pad.created_by_guest_id
|
||||||
|
.as_deref()
|
||||||
|
.zip(requester_guest_id(headers))
|
||||||
|
.is_some_and(|(owner_guest_id, requester_guest_id)| {
|
||||||
|
owner_guest_id == requester_guest_id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn has_write_permission(
|
async fn has_write_permission(
|
||||||
state: &SharedState,
|
state: &SharedState,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
@@ -94,9 +132,21 @@ async fn has_write_permission(
|
|||||||
}
|
}
|
||||||
let session = crate::security::session_cookie_token(headers);
|
let session = crate::security::session_cookie_token(headers);
|
||||||
if session != resource && session != authorization {
|
if session != resource && session != authorization {
|
||||||
return Ok(account_token_access_level(state, kind, slug, session).await? >= AccessLevel::Write);
|
if account_token_access_level(state, kind, slug, session).await? >= AccessLevel::Write {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match kind {
|
||||||
|
"workspace" => Ok(db::find_workspace(&state.db, slug)
|
||||||
|
.await?
|
||||||
|
.is_some_and(|workspace| {
|
||||||
|
workspace.is_private == 0 && workspace.password_hash.is_none()
|
||||||
|
})),
|
||||||
|
"pad" => Ok(db::find_pad(&state.db, slug)
|
||||||
|
.await?
|
||||||
|
.is_some_and(|pad| pad.is_private == 0 && pad.password_hash.is_none())),
|
||||||
|
_ => Ok(false),
|
||||||
}
|
}
|
||||||
Ok(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -262,6 +312,7 @@ pub struct NoteInfo {
|
|||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
can_delete_files: bool,
|
can_delete_files: bool,
|
||||||
|
can_upload_files: bool,
|
||||||
global_color: Option<String>,
|
global_color: Option<String>,
|
||||||
note_color: Option<String>,
|
note_color: Option<String>,
|
||||||
authorship_mode: String,
|
authorship_mode: String,
|
||||||
@@ -327,6 +378,7 @@ async fn save_editor_settings(
|
|||||||
settings_kind: &str,
|
settings_kind: &str,
|
||||||
settings_slug: &str,
|
settings_slug: &str,
|
||||||
resource: db::EditorPreferenceResource,
|
resource: db::EditorPreferenceResource,
|
||||||
|
creator_can_manage_authorship: bool,
|
||||||
payload: EditorSettingsRequest,
|
payload: EditorSettingsRequest,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
if !has_write_permission(
|
if !has_write_permission(
|
||||||
@@ -341,10 +393,6 @@ async fn save_editor_settings(
|
|||||||
"Read and write access is required to save editor preferences",
|
"Read and write access is required to save editor preferences",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let user = session_user(state, headers)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| ApiError::forbidden("Log in to save personal editor preferences"))?;
|
|
||||||
|
|
||||||
let wants_personal_update = payload.compact_view.is_some()
|
let wants_personal_update = payload.compact_view.is_some()
|
||||||
|| payload.editor_line_numbers.is_some()
|
|| payload.editor_line_numbers.is_some()
|
||||||
|| payload.preview_line_numbers.is_some()
|
|| payload.preview_line_numbers.is_some()
|
||||||
@@ -355,8 +403,15 @@ async fn save_editor_settings(
|
|||||||
if !wants_personal_update && !wants_global_update {
|
if !wants_personal_update && !wants_global_update {
|
||||||
return Err(ApiError::bad_request("No editor settings were provided"));
|
return Err(ApiError::bad_request("No editor settings were provided"));
|
||||||
}
|
}
|
||||||
|
let user = session_user(state, headers).await?;
|
||||||
|
if wants_personal_update && user.is_none() {
|
||||||
|
return Err(ApiError::forbidden(
|
||||||
|
"Log in to save personal editor preferences",
|
||||||
|
));
|
||||||
|
}
|
||||||
let can_manage_authorship = if wants_global_update {
|
let can_manage_authorship = if wants_global_update {
|
||||||
crate::auth::is_resource_owner(
|
creator_can_manage_authorship
|
||||||
|
|| crate::auth::is_resource_owner(
|
||||||
state,
|
state,
|
||||||
permission_kind,
|
permission_kind,
|
||||||
permission_slug,
|
permission_slug,
|
||||||
@@ -374,7 +429,8 @@ async fn save_editor_settings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let preferences = if wants_personal_update {
|
let preferences = if wants_personal_update {
|
||||||
let mut preferences = db::load_editor_preferences(&state.db, user.id, resource)
|
let user_id = user.as_ref().expect("personal preferences require a user").id;
|
||||||
|
let mut preferences = db::load_editor_preferences(&state.db, user_id, resource)
|
||||||
.await?
|
.await?
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if let Some(value) = payload.compact_view {
|
if let Some(value) = payload.compact_view {
|
||||||
@@ -430,7 +486,7 @@ async fn save_editor_settings(
|
|||||||
|
|
||||||
db::save_editor_configuration(
|
db::save_editor_configuration(
|
||||||
&state.db,
|
&state.db,
|
||||||
user.id,
|
user.as_ref().map(|value| value.id),
|
||||||
resource,
|
resource,
|
||||||
preferences.as_ref(),
|
preferences.as_ref(),
|
||||||
resource_settings
|
resource_settings
|
||||||
@@ -583,12 +639,23 @@ pub async fn create_note(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let slug = unique_note_slug(&state, workspace.id, &base).await?;
|
let slug = unique_note_slug(&state, workspace.id, &base).await?;
|
||||||
let created_by = payload
|
let account_user = session_user(&state, &headers).await?;
|
||||||
|
let created_by = account_user
|
||||||
|
.as_ref()
|
||||||
|
.map(|user| user.nickname.clone())
|
||||||
|
.or_else(|| {
|
||||||
|
payload
|
||||||
.created_by
|
.created_by
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|v| !v.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(|v| v.chars().take(40).collect::<String>());
|
.map(|value| value.chars().take(40).collect::<String>())
|
||||||
|
});
|
||||||
|
let created_by_guest_id = if account_user.is_none() {
|
||||||
|
requester_guest_id(&headers)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let note = db::create_note(
|
let note = db::create_note(
|
||||||
&state.db,
|
&state.db,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
@@ -596,6 +663,7 @@ pub async fn create_note(
|
|||||||
title,
|
title,
|
||||||
payload.protect,
|
payload.protect,
|
||||||
created_by.as_deref(),
|
created_by.as_deref(),
|
||||||
|
created_by_guest_id.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok((
|
Ok((
|
||||||
@@ -725,9 +793,7 @@ pub async fn note_info(
|
|||||||
.await?;
|
.await?;
|
||||||
let resource_editor_settings =
|
let resource_editor_settings =
|
||||||
db::load_resource_editor_settings(&state.db, "note", &color_slug).await?;
|
db::load_resource_editor_settings(&state.db, "note", &color_slug).await?;
|
||||||
let can_save_editor_settings = personal_editor_settings
|
let workspace_owner = crate::auth::is_resource_owner(
|
||||||
&& has_write_permission(&state, &headers, "workspace", &workspace_slug).await?;
|
|
||||||
let can_manage_authorship = crate::auth::is_resource_owner(
|
|
||||||
&state,
|
&state,
|
||||||
"workspace",
|
"workspace",
|
||||||
&workspace_slug,
|
&workspace_slug,
|
||||||
@@ -735,6 +801,15 @@ pub async fn note_info(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
let note_owner = note_creator_is_requester(&state, &headers, ¬e).await?;
|
||||||
|
let password_write_access =
|
||||||
|
has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
|
||||||
|
let can_manage_authorship = workspace_owner || note_owner || password_write_access;
|
||||||
|
let can_delete_files = can_manage_authorship;
|
||||||
|
let can_upload_files = session_user(&state, &headers).await?.is_some()
|
||||||
|
&& has_write_permission(&state, &headers, "workspace", &workspace_slug).await?;
|
||||||
|
let can_save_editor_settings = (personal_editor_settings || can_manage_authorship)
|
||||||
|
&& has_write_permission(&state, &headers, "workspace", &workspace_slug).await?;
|
||||||
|
|
||||||
if workspace.is_private == 0
|
if workspace.is_private == 0
|
||||||
&& !db::note_public_page_disabled(&state.db, note.id).await?
|
&& !db::note_public_page_disabled(&state.db, note.id).await?
|
||||||
@@ -755,19 +830,8 @@ pub async fn note_info(
|
|||||||
private: workspace.is_private != 0,
|
private: workspace.is_private != 0,
|
||||||
created_at: db::normalize_timestamp(¬e.created_at),
|
created_at: db::normalize_timestamp(¬e.created_at),
|
||||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||||
can_delete_files: {
|
can_delete_files,
|
||||||
let note_owner = session_user(&state, &headers)
|
can_upload_files,
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.and_then(|user| {
|
|
||||||
note.created_by
|
|
||||||
.as_deref()
|
|
||||||
.map(|creator| creator == user.nickname)
|
|
||||||
})
|
|
||||||
.unwrap_or(false);
|
|
||||||
can_manage_authorship || note_owner
|
|
||||||
},
|
|
||||||
global_color,
|
global_color,
|
||||||
note_color,
|
note_color,
|
||||||
authorship_mode: resource_editor_settings.authorship_mode,
|
authorship_mode: resource_editor_settings.authorship_mode,
|
||||||
@@ -797,6 +861,9 @@ pub async fn set_note_editor_settings(
|
|||||||
let note = db::find_note(&state.db, workspace.id, ¬e_slug)
|
let note = db::find_note(&state.db, workspace.id, ¬e_slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
|
let creator_can_manage_authorship =
|
||||||
|
note_creator_is_requester(&state, &headers, ¬e).await?
|
||||||
|
|| has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
|
||||||
save_editor_settings(
|
save_editor_settings(
|
||||||
&state,
|
&state,
|
||||||
&headers,
|
&headers,
|
||||||
@@ -805,6 +872,7 @@ pub async fn set_note_editor_settings(
|
|||||||
"note",
|
"note",
|
||||||
&format!("{workspace_slug}/{note_slug}"),
|
&format!("{workspace_slug}/{note_slug}"),
|
||||||
db::EditorPreferenceResource::Note(note.id),
|
db::EditorPreferenceResource::Note(note.id),
|
||||||
|
creator_can_manage_authorship,
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -935,6 +1003,23 @@ async fn anonymous_access_token_valid(
|
|||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn has_password_write_access(
|
||||||
|
state: &SharedState,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
kind: &str,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<bool, ApiError> {
|
||||||
|
for token in [
|
||||||
|
crate::security::resource_token(headers, kind, slug),
|
||||||
|
authorization_token(headers),
|
||||||
|
] {
|
||||||
|
if anonymous_access_token_valid(state, kind, slug, token).await? {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
async fn external_token_access_level(
|
async fn external_token_access_level(
|
||||||
state: &SharedState,
|
state: &SharedState,
|
||||||
kind: &str,
|
kind: &str,
|
||||||
|
|||||||
+28
-6
@@ -34,6 +34,7 @@ pub struct PadInfo {
|
|||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
can_delete_files: bool,
|
can_delete_files: bool,
|
||||||
|
can_upload_files: bool,
|
||||||
global_color: Option<String>,
|
global_color: Option<String>,
|
||||||
note_color: Option<String>,
|
note_color: Option<String>,
|
||||||
authorship_mode: String,
|
authorship_mode: String,
|
||||||
@@ -64,11 +65,23 @@ pub async fn create_pad(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let slug = unique_pad_slug(&state, &base).await?;
|
let slug = unique_pad_slug(&state, &base).await?;
|
||||||
let pad = db::create_pad(&state.db, &slug, title, password).await?;
|
let account_user = crate::auth::optional_user(&state, &headers)
|
||||||
if let Some(user) = crate::auth::optional_user(&state, &headers)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::forbidden(&e.message))?
|
.map_err(|e| ApiError::forbidden(&e.message))?;
|
||||||
{
|
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 {
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD))
|
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD))
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.bind(&pad.slug)
|
.bind(&pad.slug)
|
||||||
@@ -109,7 +122,7 @@ pub async fn pad_info(
|
|||||||
.await?;
|
.await?;
|
||||||
let resource_editor_settings =
|
let resource_editor_settings =
|
||||||
db::load_resource_editor_settings(&state.db, "pad", &slug).await?;
|
db::load_resource_editor_settings(&state.db, "pad", &slug).await?;
|
||||||
let can_manage_authorship = crate::auth::is_resource_owner(
|
let account_owner = crate::auth::is_resource_owner(
|
||||||
&state,
|
&state,
|
||||||
"pad",
|
"pad",
|
||||||
&slug,
|
&slug,
|
||||||
@@ -117,7 +130,12 @@ pub async fn pad_info(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let can_save_editor_settings = personal_editor_settings
|
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 = account_owner || guest_owner || password_write_access;
|
||||||
|
let can_upload_files = session_user(&state, &headers).await?.is_some()
|
||||||
|
&& has_write_permission(&state, &headers, "pad", &slug).await?;
|
||||||
|
let can_save_editor_settings = (personal_editor_settings || can_manage_authorship)
|
||||||
&& has_write_permission(&state, &headers, "pad", &slug).await?;
|
&& has_write_permission(&state, &headers, "pad", &slug).await?;
|
||||||
if pad.is_private == 0
|
if pad.is_private == 0
|
||||||
&& !db::pad_public_page_disabled(&state.db, pad.id).await?
|
&& !db::pad_public_page_disabled(&state.db, pad.id).await?
|
||||||
@@ -136,6 +154,7 @@ pub async fn pad_info(
|
|||||||
created_at: db::normalize_timestamp(&pad.created_at),
|
created_at: db::normalize_timestamp(&pad.created_at),
|
||||||
updated_at: db::normalize_timestamp(&pad.updated_at),
|
updated_at: db::normalize_timestamp(&pad.updated_at),
|
||||||
can_delete_files: can_manage_authorship,
|
can_delete_files: can_manage_authorship,
|
||||||
|
can_upload_files,
|
||||||
global_color,
|
global_color,
|
||||||
note_color,
|
note_color,
|
||||||
authorship_mode: resource_editor_settings.authorship_mode,
|
authorship_mode: resource_editor_settings.authorship_mode,
|
||||||
@@ -162,6 +181,8 @@ pub async fn set_pad_editor_settings(
|
|||||||
let pad = db::find_pad(&state.db, &slug)
|
let pad = db::find_pad(&state.db, &slug)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_note)?;
|
.ok_or_else(ApiError::not_found_note)?;
|
||||||
|
let creator_can_manage_authorship = pad_creator_is_requester(&headers, &pad)
|
||||||
|
|| has_password_write_access(&state, &headers, "pad", &slug).await?;
|
||||||
save_editor_settings(
|
save_editor_settings(
|
||||||
&state,
|
&state,
|
||||||
&headers,
|
&headers,
|
||||||
@@ -170,6 +191,7 @@ pub async fn set_pad_editor_settings(
|
|||||||
"pad",
|
"pad",
|
||||||
&slug,
|
&slug,
|
||||||
db::EditorPreferenceResource::Pad(pad.id),
|
db::EditorPreferenceResource::Pad(pad.id),
|
||||||
|
creator_can_manage_authorship,
|
||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ pub async fn load_editor_preferences(
|
|||||||
|
|
||||||
pub async fn save_editor_configuration(
|
pub async fn save_editor_configuration(
|
||||||
pool: &Database,
|
pool: &Database,
|
||||||
user_id: i64,
|
user_id: Option<i64>,
|
||||||
resource: EditorPreferenceResource,
|
resource: EditorPreferenceResource,
|
||||||
preferences: Option<&EditorPreferences>,
|
preferences: Option<&EditorPreferences>,
|
||||||
resource_settings: Option<(&str, &str, &ResourceEditorSettings)>,
|
resource_settings: Option<(&str, &str, &ResourceEditorSettings)>,
|
||||||
@@ -110,6 +110,9 @@ pub async fn save_editor_configuration(
|
|||||||
let mut tx = pool.pool().begin().await?;
|
let mut tx = pool.pool().begin().await?;
|
||||||
|
|
||||||
if let Some(preferences) = preferences {
|
if let Some(preferences) = preferences {
|
||||||
|
let user_id = user_id.ok_or_else(|| {
|
||||||
|
sqlx::Error::Protocol("user id is required for personal editor preferences".into())
|
||||||
|
})?;
|
||||||
sqlx::query(queries::get(
|
sqlx::query(queries::get(
|
||||||
pool.kind(),
|
pool.kind(),
|
||||||
preference_upsert_query(resource),
|
preference_upsert_query(resource),
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ pub struct Note {
|
|||||||
pub owner_map: String,
|
pub owner_map: String,
|
||||||
pub protected: bool,
|
pub protected: bool,
|
||||||
pub created_by: Option<String>,
|
pub created_by: Option<String>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub created_by_guest_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, FromRow)]
|
#[derive(Debug, Clone, FromRow)]
|
||||||
@@ -82,6 +84,7 @@ struct SqliteNote {
|
|||||||
owner_map: String,
|
owner_map: String,
|
||||||
protected: i64,
|
protected: i64,
|
||||||
created_by: Option<String>,
|
created_by: Option<String>,
|
||||||
|
created_by_guest_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<SqliteNote> for Note {
|
impl From<SqliteNote> for Note {
|
||||||
@@ -97,6 +100,7 @@ impl From<SqliteNote> for Note {
|
|||||||
owner_map: value.owner_map,
|
owner_map: value.owner_map,
|
||||||
protected: value.protected != 0,
|
protected: value.protected != 0,
|
||||||
created_by: value.created_by,
|
created_by: value.created_by,
|
||||||
|
created_by_guest_id: value.created_by_guest_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,6 +264,7 @@ pub async fn create_note(
|
|||||||
title: &str,
|
title: &str,
|
||||||
protected: bool,
|
protected: bool,
|
||||||
created_by: Option<&str>,
|
created_by: Option<&str>,
|
||||||
|
created_by_guest_id: Option<&str>,
|
||||||
) -> Result<Note, sqlx::Error> {
|
) -> Result<Note, sqlx::Error> {
|
||||||
sqlx::query(queries::get(pool.kind(), queries::Q005))
|
sqlx::query(queries::get(pool.kind(), queries::Q005))
|
||||||
.bind(workspace_id)
|
.bind(workspace_id)
|
||||||
@@ -267,6 +272,7 @@ pub async fn create_note(
|
|||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(protected)
|
.bind(protected)
|
||||||
.bind(created_by)
|
.bind(created_by)
|
||||||
|
.bind(created_by_guest_id)
|
||||||
.execute(pool.pool())
|
.execute(pool.pool())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -388,6 +394,7 @@ pub struct Pad {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
pub owner_map: String,
|
pub owner_map: String,
|
||||||
pub is_private: i64,
|
pub is_private: i64,
|
||||||
|
pub created_by_guest_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_pad(pool: &Database, slug: &str) -> Result<Option<Pad>, sqlx::Error> {
|
pub async fn find_pad(pool: &Database, slug: &str) -> Result<Option<Pad>, sqlx::Error> {
|
||||||
@@ -402,6 +409,7 @@ pub async fn create_pad(
|
|||||||
slug: &str,
|
slug: &str,
|
||||||
title: &str,
|
title: &str,
|
||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
|
created_by_guest_id: Option<&str>,
|
||||||
) -> Result<Pad, sqlx::Error> {
|
) -> Result<Pad, sqlx::Error> {
|
||||||
let password_hash = password
|
let password_hash = password
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
@@ -410,6 +418,7 @@ pub async fn create_pad(
|
|||||||
.bind(slug)
|
.bind(slug)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(password_hash)
|
.bind(password_hash)
|
||||||
|
.bind(created_by_guest_id)
|
||||||
.execute(pool.pool())
|
.execute(pool.pool())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -504,6 +513,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for Note {
|
|||||||
owner_map: crate::row_decode::text(row, "owner_map")?,
|
owner_map: crate::row_decode::text(row, "owner_map")?,
|
||||||
protected: protected != 0,
|
protected: protected != 0,
|
||||||
created_by: crate::row_decode::optional_text(row, "created_by")?,
|
created_by: crate::row_decode::optional_text(row, "created_by")?,
|
||||||
|
created_by_guest_id: crate::row_decode::optional_text(row, "created_by_guest_id")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -530,6 +540,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for Pad {
|
|||||||
updated_at: crate::row_decode::text(row, "updated_at")?,
|
updated_at: crate::row_decode::text(row, "updated_at")?,
|
||||||
owner_map: crate::row_decode::text(row, "owner_map")?,
|
owner_map: crate::row_decode::text(row, "owner_map")?,
|
||||||
is_private: row.try_get("is_private")?,
|
is_private: row.try_get("is_private")?,
|
||||||
|
created_by_guest_id: crate::row_decode::optional_text(row, "created_by_guest_id")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,13 +255,13 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
}
|
}
|
||||||
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"#,
|
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"#,
|
||||||
Query::Q003 => {
|
Query::Q003 => {
|
||||||
r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"#
|
r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"#
|
||||||
}
|
}
|
||||||
Query::Q004 => {
|
Query::Q004 => {
|
||||||
r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by FROM notes WHERE workspace_id = ? AND slug = ?"#
|
r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM notes WHERE workspace_id = ? AND slug = ?"#
|
||||||
}
|
}
|
||||||
Query::Q005 => {
|
Query::Q005 => {
|
||||||
r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES (?, ?, ?, ?, ?)"#
|
r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by, created_by_guest_id) VALUES (?, ?, ?, ?, ?, ?)"#
|
||||||
}
|
}
|
||||||
Query::Q006 => {
|
Query::Q006 => {
|
||||||
r#"UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
r#"UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
||||||
@@ -275,9 +275,9 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT id, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, CAST(author AS CHAR CHARACTER SET utf8mb4) AS author, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"#
|
r#"SELECT id, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, CAST(author AS CHAR CHARACTER SET utf8mb4) AS author, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"#
|
||||||
}
|
}
|
||||||
Query::Q011 => {
|
Query::Q011 => {
|
||||||
r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private FROM pads WHERE slug = ?"#
|
r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM pads WHERE slug = ?"#
|
||||||
}
|
}
|
||||||
Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)"#,
|
Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#,
|
||||||
Query::Q013 => {
|
Query::Q013 => {
|
||||||
r#"UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
r#"UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,13 +257,13 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
}
|
}
|
||||||
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES ($1, $2, $3)"#,
|
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES ($1, $2, $3)"#,
|
||||||
Query::Q003 => {
|
Query::Q003 => {
|
||||||
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by FROM notes WHERE workspace_id = $1 ORDER BY updated_at DESC, id DESC"#
|
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = $1 ORDER BY updated_at DESC, id DESC"#
|
||||||
}
|
}
|
||||||
Query::Q004 => {
|
Query::Q004 => {
|
||||||
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by FROM notes WHERE workspace_id = $1 AND slug = $2"#
|
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = $1 AND slug = $2"#
|
||||||
}
|
}
|
||||||
Query::Q005 => {
|
Query::Q005 => {
|
||||||
r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES ($1, $2, $3, $4, $5)"#
|
r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by, created_by_guest_id) VALUES ($1, $2, $3, $4, $5, $6)"#
|
||||||
}
|
}
|
||||||
Query::Q006 => {
|
Query::Q006 => {
|
||||||
r#"UPDATE notes SET content = $1, owner_map = $2, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $3"#
|
r#"UPDATE notes SET content = $1, owner_map = $2, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $3"#
|
||||||
@@ -279,9 +279,9 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = $1 ORDER BY id DESC LIMIT 100"#
|
r#"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = $1 ORDER BY id DESC LIMIT 100"#
|
||||||
}
|
}
|
||||||
Query::Q011 => {
|
Query::Q011 => {
|
||||||
r#"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM pads WHERE slug = $1"#
|
r#"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private, created_by_guest_id FROM pads WHERE slug = $1"#
|
||||||
}
|
}
|
||||||
Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash) VALUES ($1, $2, $3)"#,
|
Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash, created_by_guest_id) VALUES ($1, $2, $3, $4)"#,
|
||||||
Query::Q013 => {
|
Query::Q013 => {
|
||||||
r#"UPDATE pads SET content = $1, owner_map = $2, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $3"#
|
r#"UPDATE pads SET content = $1, owner_map = $2, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $3"#
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,13 +255,13 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
}
|
}
|
||||||
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"#,
|
Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"#,
|
||||||
Query::Q003 => {
|
Query::Q003 => {
|
||||||
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"#
|
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"#
|
||||||
}
|
}
|
||||||
Query::Q004 => {
|
Query::Q004 => {
|
||||||
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? AND slug = ?"#
|
r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = ? AND slug = ?"#
|
||||||
}
|
}
|
||||||
Query::Q005 => {
|
Query::Q005 => {
|
||||||
r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES (?, ?, ?, ?, ?)"#
|
r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by, created_by_guest_id) VALUES (?, ?, ?, ?, ?, ?)"#
|
||||||
}
|
}
|
||||||
Query::Q006 => {
|
Query::Q006 => {
|
||||||
r#"UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
r#"UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
||||||
@@ -275,9 +275,9 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"#
|
r#"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"#
|
||||||
}
|
}
|
||||||
Query::Q011 => {
|
Query::Q011 => {
|
||||||
r#"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private FROM pads WHERE slug = ?"#
|
r#"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private, created_by_guest_id FROM pads WHERE slug = ?"#
|
||||||
}
|
}
|
||||||
Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)"#,
|
Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#,
|
||||||
Query::Q013 => {
|
Query::Q013 => {
|
||||||
r#"UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
r#"UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -107,7 +107,7 @@ fn resource_cookie_name(kind: &str, slug: &str) -> String {
|
|||||||
format!("__Host-rustpad_access_{}", hex::encode(&digest[..12]))
|
format!("__Host-rustpad_access_{}", hex::encode(&digest[..12]))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
pub fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||||
headers
|
headers
|
||||||
.get(header::COOKIE)
|
.get(header::COOKIE)
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
|
|||||||
+2
-1
@@ -11,7 +11,7 @@ import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
|
|||||||
installGlobalDiagnostics();
|
installGlobalDiagnostics();
|
||||||
|
|
||||||
import { bindIdentityDialog, handleAccountActionToken, handleAccountConfirmationToken, handleResetToken, handleShareInvitationToken, logoutCurrentSession, validateCurrentSession } from "@rustpad/auth-ui";
|
import { bindIdentityDialog, handleAccountActionToken, handleAccountConfirmationToken, handleResetToken, handleShareInvitationToken, logoutCurrentSession, validateCurrentSession } from "@rustpad/auth-ui";
|
||||||
import { getAuthToken, setAccessToken } from "@rustpad/session";
|
import { getAuthToken, getGuestId, setAccessToken } from "@rustpad/session";
|
||||||
import { api } from "@rustpad/api";
|
import { api } from "@rustpad/api";
|
||||||
import { copyText } from "@rustpad/clipboard";
|
import { copyText } from "@rustpad/clipboard";
|
||||||
import { safeAppUrl } from "@rustpad/security";
|
import { safeAppUrl } from "@rustpad/security";
|
||||||
@@ -38,6 +38,7 @@ function setBusy(button, busy, idleText, busyText) {
|
|||||||
button.textContent = busy ? busyText : idleText;
|
button.textContent = busy ? busyText : idleText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getGuestId();
|
||||||
bindPreview("#pad-name", "#pad-slug-preview", "#pad-name-count", "/p/", "note");
|
bindPreview("#pad-name", "#pad-slug-preview", "#pad-name-count", "/p/", "note");
|
||||||
bindPreview("#workspace-name", "#workspace-slug-preview", "#workspace-name-count", "/w/", "workspace");
|
bindPreview("#workspace-name", "#workspace-slug-preview", "#workspace-name-count", "/w/", "workspace");
|
||||||
|
|
||||||
|
|||||||
@@ -612,7 +612,9 @@ export function startNoteEditor(adapter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { loadFiles } = bindNoteFiles({
|
const { loadFiles } = bindNoteFiles({
|
||||||
editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files),
|
editor, toast, getAccessToken: () => accessToken,
|
||||||
|
canDelete: () => Boolean(info?.can_delete_files),
|
||||||
|
canUpload: () => Boolean(info?.can_upload_files),
|
||||||
endpoints: adapter.fileEndpoints,
|
endpoints: adapter.fileEndpoints,
|
||||||
onFilesChanged: files => updateMarkdownFiles(files, { rerender: true }),
|
onFilesChanged: files => updateMarkdownFiles(files, { rerender: true }),
|
||||||
});
|
});
|
||||||
@@ -897,7 +899,7 @@ export function startNoteEditor(adapter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleEditorSettingsSave({ personal = false, authorship = false } = {}) {
|
function scheduleEditorSettingsSave({ personal = false, authorship = false } = {}) {
|
||||||
if (personal) pendingPersonalSettingsSave = true;
|
if (personal && info?.personal_editor_settings) pendingPersonalSettingsSave = true;
|
||||||
if (authorship && info?.can_manage_authorship) pendingAuthorshipSettingsSave = true;
|
if (authorship && info?.can_manage_authorship) pendingAuthorshipSettingsSave = true;
|
||||||
if (!info?.can_save_editor_settings || (!pendingPersonalSettingsSave && !pendingAuthorshipSettingsSave)) return;
|
if (!info?.can_save_editor_settings || (!pendingPersonalSettingsSave && !pendingAuthorshipSettingsSave)) return;
|
||||||
clearTimeout(editorSettingsSaveTimer);
|
clearTimeout(editorSettingsSaveTimer);
|
||||||
@@ -1141,7 +1143,7 @@ export function startNoteEditor(adapter) {
|
|||||||
document.querySelector("#open-password")?.focus();
|
document.querySelector("#open-password")?.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
|
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
|
||||||
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
|
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
|
||||||
|
|
||||||
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast(error.message); } });
|
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast(error.message); } });
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { api, uploadWithProgress } from "@rustpad/api";
|
|||||||
import { copyText } from "@rustpad/clipboard";
|
import { copyText } from "@rustpad/clipboard";
|
||||||
import { prepareImageFile } from "@rustpad/image-upload";
|
import { prepareImageFile } from "@rustpad/image-upload";
|
||||||
import { askConfirm } from "@rustpad/modal";
|
import { askConfirm } from "@rustpad/modal";
|
||||||
import { getAuthToken } from "@rustpad/session";
|
|
||||||
import { safeAppUrl } from "@rustpad/security";
|
import { safeAppUrl } from "@rustpad/security";
|
||||||
import { createUploadToast } from "@rustpad/toast";
|
import { createUploadToast } from "@rustpad/toast";
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ function markdownCode(url, label, mimeType) {
|
|||||||
return String(mimeType || "").startsWith("image/") ? `` : `[${label}](${url})`;
|
return String(mimeType || "").startsWith("image/") ? `` : `[${label}](${url})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, toast, onFilesChanged = () => {} }) {
|
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, canUpload, toast, onFilesChanged = () => {} }) {
|
||||||
const dialog = document.querySelector("#files-dialog");
|
const dialog = document.querySelector("#files-dialog");
|
||||||
const list = document.querySelector("#files-list");
|
const list = document.querySelector("#files-list");
|
||||||
const input = document.querySelector("#file-input");
|
const input = document.querySelector("#file-input");
|
||||||
@@ -75,8 +74,8 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector("#upload-button").addEventListener("click", () => {
|
document.querySelector("#upload-button").addEventListener("click", () => {
|
||||||
if (!getAuthToken() && !getAccessToken()) {
|
if (!canUpload()) {
|
||||||
toast("Log in or use a read-write share link to upload files.");
|
toast("Log in with read-write access to upload files.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
input.click();
|
input.click();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ installGlobalDiagnostics();
|
|||||||
|
|
||||||
import { api } from "@rustpad/api";
|
import { api } from "@rustpad/api";
|
||||||
import { copyText } from "@rustpad/clipboard";
|
import { copyText } from "@rustpad/clipboard";
|
||||||
import { getNickname, getAccessToken, getAuthToken, setAccessToken } from "@rustpad/session";
|
import { getNickname, getAccessToken, getAuthToken, getGuestId, setAccessToken } from "@rustpad/session";
|
||||||
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
|
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
|
||||||
import { askConfirm } from "@rustpad/modal";
|
import { askConfirm } from "@rustpad/modal";
|
||||||
import { safeAppUrl } from "@rustpad/security";
|
import { safeAppUrl } from "@rustpad/security";
|
||||||
@@ -24,6 +24,7 @@ let info;
|
|||||||
const shareToken = new URLSearchParams(location.search).get("share");
|
const shareToken = new URLSearchParams(location.search).get("share");
|
||||||
let accessToken = shareToken || getAccessToken("workspace", slug);
|
let accessToken = shareToken || getAccessToken("workspace", slug);
|
||||||
let nickname = getNickname();
|
let nickname = getNickname();
|
||||||
|
getGuestId();
|
||||||
const dialog = document.querySelector("#password-dialog");
|
const dialog = document.querySelector("#password-dialog");
|
||||||
const identityDialog = document.querySelector("#identity-dialog");
|
const identityDialog = document.querySelector("#identity-dialog");
|
||||||
const workspaceContent = document.querySelector("#workspace-content");
|
const workspaceContent = document.querySelector("#workspace-content");
|
||||||
|
|||||||
Reference in New Issue
Block a user