new finctions and fixes

This commit is contained in:
Mateusz Gruszczyński
2026-07-30 00:12:48 +02:00
parent 9ca055de5f
commit 2274cf57c9
31 changed files with 1348 additions and 505 deletions
+37 -9
View File
@@ -101,14 +101,37 @@ pub async fn upload_pad_file(
let mime = mime_guess::from_path(&stored)
.first_or_octet_stream()
.to_string();
let cache_control = format!("public, max-age={}", state.file_cache_max_age_seconds);
let cache_control = crate::cache::cache_control(state.file_cache_max_age_seconds);
state
.storage
.put(&key, bytes.clone().into(), &mime, &cache_control)
.await
.map_err(|_| ApiError::internal("Failed to save the file"))?;
db::register_pad_file(&state.db, pad.id, &stored, &url, &mime, bytes.len() as i64).await?;
Ok(Json(serde_json::json!({"name": stored, "url": url})))
Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime})))
}
pub(super) fn content_references_file(content: &str, filename: &str, url: &str) -> bool {
if content.contains(url) {
return true;
}
for marker in ["[file=", "[image=", "[img="] {
let mut remaining = content;
while let Some(index) = remaining.find(marker) {
let after = &remaining[index + marker.len()..];
let end = after
.find(|character| character == ',' || character == ']')
.unwrap_or(after.len());
if after[..end].trim() == filename {
return true;
}
if end >= after.len() {
break;
}
remaining = &after[end + 1..];
}
}
false
}
pub async fn pad_files(
@@ -127,7 +150,7 @@ pub async fn pad_files(
.await?;
let mut files = db::list_pad_files(&state.db, pad.id).await?;
for file in &mut files {
let attached = pad.content.contains(&file.url);
let attached = content_references_file(&pad.content, &file.filename, &file.url);
if attached != file.is_attached {
db::set_pad_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached;
@@ -265,14 +288,14 @@ pub async fn upload_note_file(
let mime = mime_guess::from_path(&stored)
.first_or_octet_stream()
.to_string();
let cache_control = format!("public, max-age={}", state.file_cache_max_age_seconds);
let cache_control = crate::cache::cache_control(state.file_cache_max_age_seconds);
state
.storage
.put(&key, bytes.clone().into(), &mime, &cache_control)
.await
.map_err(|_| ApiError::internal("Failed to save the file"))?;
db::register_note_file(&state.db, note.id, &stored, &url, &mime, bytes.len() as i64).await?;
Ok(Json(serde_json::json!({"name": stored, "url": url})))
Ok(Json(serde_json::json!({"name": stored, "url": url, "mime_type": mime})))
}
pub async fn delete_note(
@@ -316,6 +339,12 @@ pub async fn delete_note(
.await
.map_err(|_| ApiError::internal("Failed to delete note files"))?;
}
db::delete_resource_editor_state(
&state.db,
"note",
&format!("{workspace_slug}/{note_slug}"),
)
.await?;
db::delete_note(&state.db, note.id).await?;
Ok(Json(serde_json::json!({"ok": true})))
}
@@ -337,7 +366,7 @@ pub async fn note_files(
.await?;
let mut files = db::list_note_files(&state.db, note.id).await?;
for file in &mut files {
let attached = note.content.contains(&file.url);
let attached = content_references_file(&note.content, &file.filename, &file.url);
if attached != file.is_attached {
db::set_note_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached;
@@ -464,9 +493,8 @@ async fn serve_token_file(
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_str(&format!(
"public, max-age={}",
state.file_cache_max_age_seconds
HeaderValue::from_str(&crate::cache::cache_control(
state.file_cache_max_age_seconds,
))
.expect("valid file cache-control header"),
);
+261 -85
View File
@@ -48,18 +48,99 @@ fn bearer_token(headers: &HeaderMap) -> Option<&str> {
.filter(|value| !value.is_empty())
}
fn user_session_token(headers: &HeaderMap) -> Option<&str> {
headers
.get("x-rustpad-user-token")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| bearer_token(headers))
}
async fn session_user(
state: &SharedState,
headers: &HeaderMap,
) -> Result<Option<crate::auth::User>, ApiError> {
let Some(token) = user_session_token(headers) else {
return Ok(None);
};
crate::auth::user_from_token(state, token)
.await
.map_err(|error| ApiError::forbidden(&error.message))
}
async fn has_write_permission(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
) -> Result<bool, ApiError> {
let bearer = bearer_token(headers);
if token_access_level(state, kind, slug, bearer).await? >= AccessLevel::Write {
return Ok(true);
}
let session = user_session_token(headers);
if session.is_some() && session != bearer {
return Ok(token_access_level(state, kind, slug, session).await? >= AccessLevel::Write);
}
Ok(false)
}
#[derive(Debug, Serialize)]
pub struct PublishResponse {
url: Option<String>,
enabled: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct MarkdownFileReference {
filename: String,
url: String,
mime_type: String,
}
impl From<db::NoteFile> for MarkdownFileReference {
fn from(file: db::NoteFile) -> Self {
Self {
filename: file.filename,
url: file.url,
mime_type: file.mime_type,
}
}
}
pub(crate) async fn markdown_file_references(
state: &SharedState,
pad_id: Option<i64>,
note_id: Option<i64>,
content: Option<&str>,
) -> Result<Vec<MarkdownFileReference>, ApiError> {
let file_rows = if let Some(id) = pad_id {
db::list_pad_files(&state.db, id).await?
} else if let Some(id) = note_id {
db::list_note_files(&state.db, id).await?
} else {
Vec::new()
};
Ok(file_rows
.into_iter()
.filter(|file| {
content
.map(|value| files::content_references_file(value, &file.filename, &file.url))
.unwrap_or(true)
})
.map(Into::into)
.collect())
}
#[derive(Debug, Serialize)]
pub struct PublicPageResponse {
title: String,
content: String,
updated_at: String,
allow_task_updates: bool,
files: Vec<MarkdownFileReference>,
}
#[derive(Debug, Deserialize)]
@@ -173,7 +254,16 @@ pub struct NoteInfo {
note_color: Option<String>,
authorship_mode: String,
colors_enabled: bool,
compact_view: bool,
editor_line_numbers: bool,
preview_line_numbers: bool,
line_links: bool,
font_family: String,
font_size: i64,
personal_editor_settings: bool,
can_save_editor_settings: bool,
can_manage_authorship: bool,
files: Vec<MarkdownFileReference>,
}
#[derive(Debug, Deserialize)]
@@ -183,35 +273,38 @@ pub struct EditorColorRequest {
#[derive(Debug, Deserialize)]
pub struct EditorSettingsRequest {
authorship_mode: String,
colors_enabled: bool,
#[serde(default)]
authorship_mode: Option<String>,
#[serde(default)]
colors_enabled: Option<bool>,
#[serde(default)]
compact_view: Option<bool>,
#[serde(default)]
editor_line_numbers: Option<bool>,
#[serde(default)]
preview_line_numbers: Option<bool>,
#[serde(default)]
line_links: Option<bool>,
#[serde(default)]
font_family: Option<String>,
#[serde(default)]
font_size: Option<i64>,
}
async fn editor_settings(
async fn user_editor_preferences(
state: &SharedState,
kind: &str,
slug: &str,
) -> Result<(String, bool), ApiError> {
let row: Option<(String, i64)> = sqlx::query_as(queries::get(
state.db.kind(),
queries::RESOURCE_EDITOR_SETTINGS_SELECT,
headers: &HeaderMap,
resource: db::EditorPreferenceResource,
) -> Result<(db::EditorPreferences, bool), ApiError> {
let Some(user) = session_user(state, headers).await? else {
return Ok((db::EditorPreferences::default(), false));
};
Ok((
db::load_editor_preferences(&state.db, user.id, resource)
.await?
.unwrap_or_default(),
true,
))
.bind(kind)
.bind(slug)
.fetch_optional(state.db.pool())
.await?;
Ok(row
.map(|(mode, colors)| {
(
if mode == "full" {
"full".into()
} else {
"simple".into()
},
colors != 0,
)
})
.unwrap_or_else(|| ("simple".into(), true)))
}
async fn save_editor_settings(
@@ -221,49 +314,123 @@ async fn save_editor_settings(
permission_slug: &str,
settings_kind: &str,
settings_slug: &str,
resource: db::EditorPreferenceResource,
payload: EditorSettingsRequest,
) -> Result<Json<serde_json::Value>, ApiError> {
let permission = crate::auth::resource_permission(
if !has_write_permission(
state,
headers,
permission_kind,
permission_slug,
bearer_token(headers),
)
.await
.map_err(|e| ApiError::forbidden(&e.message))?;
if permission.as_deref() != Some("rw") {
.await?
{
return Err(ApiError::forbidden(
"Read and write access is required to save editor settings",
"Read and write access is required to save editor preferences",
));
}
let mode = match payload.authorship_mode.as_str() {
"simple" => "simple",
"full" | "advanced" => "full",
_ => return Err(ApiError::bad_request("Invalid authorship mode")),
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()
|| payload.editor_line_numbers.is_some()
|| payload.preview_line_numbers.is_some()
|| payload.line_links.is_some()
|| payload.font_family.is_some()
|| payload.font_size.is_some();
let wants_global_update = payload.authorship_mode.is_some() || payload.colors_enabled.is_some();
if !wants_personal_update && !wants_global_update {
return Err(ApiError::bad_request("No editor settings were provided"));
}
let can_manage_authorship = if wants_global_update {
crate::auth::is_resource_owner(
state,
permission_kind,
permission_slug,
user_session_token(headers),
)
.await
.unwrap_or(false)
} else {
false
};
let mut tx = state.db.pool().begin().await?;
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_EDITOR_SETTINGS_DELETE,
))
.bind(settings_kind)
.bind(settings_slug)
.execute(&mut *tx)
if wants_global_update && !can_manage_authorship {
return Err(ApiError::forbidden(
"Only the resource owner can change authorship settings",
));
}
let preferences = if wants_personal_update {
let mut preferences = db::load_editor_preferences(&state.db, user.id, resource)
.await?
.unwrap_or_default();
if let Some(value) = payload.compact_view {
preferences.compact_view = value;
}
if let Some(value) = payload.editor_line_numbers {
preferences.editor_line_numbers = value;
}
if let Some(value) = payload.preview_line_numbers {
preferences.preview_line_numbers = value;
}
if let Some(value) = payload.line_links {
preferences.line_links = value;
}
if let Some(value) = payload.font_family {
preferences.font_family = match value.as_str() {
"mono" | "system" | "serif" | "arial" | "georgia" => value,
_ => return Err(ApiError::bad_request("Invalid editor font")),
};
}
if let Some(value) = payload.font_size {
if !matches!(value, 14 | 16 | 18 | 20 | 22) {
return Err(ApiError::bad_request("Invalid editor font size"));
}
preferences.font_size = value;
}
Some(preferences)
} else {
None
};
let resource_settings = if wants_global_update {
let mut settings = db::load_resource_editor_settings(
&state.db,
settings_kind,
settings_slug,
)
.await?;
if let Some(mode) = payload.authorship_mode {
settings.authorship_mode = match mode.as_str() {
"simple" => "simple".into(),
"full" | "advanced" => "full".into(),
_ => return Err(ApiError::bad_request("Invalid authorship mode")),
};
}
if let Some(value) = payload.colors_enabled {
settings.colors_enabled = value;
}
Some(settings)
} else {
None
};
db::save_editor_configuration(
&state.db,
user.id,
resource,
preferences.as_ref(),
resource_settings
.as_ref()
.map(|settings| (settings_kind, settings_slug, settings)),
)
.await?;
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_EDITOR_SETTINGS_INSERT,
))
.bind(settings_kind)
.bind(settings_slug)
.bind(mode)
.bind(payload.colors_enabled)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(
serde_json::json!({"authorship_mode": mode, "colors_enabled": payload.colors_enabled}),
))
Ok(Json(serde_json::json!({
"preferences": preferences,
"resource_settings": resource_settings,
})))
}
pub async fn create_workspace(
@@ -456,10 +623,7 @@ async fn editor_colors(
kind: &str,
slug: &str,
) -> Result<(Option<String>, Option<String>), ApiError> {
let Some(user) = crate::auth::optional_user(state, headers)
.await
.map_err(|e| ApiError::forbidden(&e.message))?
else {
let Some(user) = session_user(state, headers).await? else {
return Ok((None, None));
};
let global: Option<String> = sqlx::query_scalar(queries::get(
@@ -488,9 +652,8 @@ async fn save_editor_color(
slug: &str,
color: Option<&str>,
) -> Result<Json<serde_json::Value>, ApiError> {
let user = crate::auth::optional_user(state, headers)
.await
.map_err(|e| ApiError::forbidden(&e.message))?
let user = session_user(state, headers)
.await?
.ok_or_else(|| ApiError::forbidden("Log in to save note colors"))?;
let color = clean_editor_color(color)?;
let mut tx = state.db.pool().begin().await?;
@@ -540,18 +703,24 @@ pub async fn note_info(
.ok_or_else(ApiError::not_found_note)?;
let color_slug = format!("{}/{}", workspace_slug, note_slug);
let (global_color, note_color) = editor_colors(&state, &headers, "note", &color_slug).await?;
let (authorship_mode, colors_enabled) = editor_settings(&state, "note", &color_slug).await?;
let can_save_editor_settings = crate::auth::resource_permission(
let (editor_preferences, personal_editor_settings) = user_editor_preferences(
&state,
&headers,
db::EditorPreferenceResource::Note(note.id),
)
.await?;
let resource_editor_settings =
db::load_resource_editor_settings(&state.db, "note", &color_slug).await?;
let can_save_editor_settings = personal_editor_settings
&& has_write_permission(&state, &headers, "workspace", &workspace_slug).await?;
let can_manage_authorship = crate::auth::is_resource_owner(
&state,
"workspace",
&workspace_slug,
bearer_token(&headers),
user_session_token(&headers),
)
.await
.ok()
.flatten()
.as_deref()
== Some("rw");
.unwrap_or(false);
if workspace.is_private == 0
&& !db::note_public_page_disabled(&state.db, note.id).await?
@@ -573,15 +742,7 @@ pub async fn note_info(
created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
can_delete_files: {
let workspace_owner = crate::auth::is_resource_owner(
&state,
"workspace",
&workspace_slug,
bearer_token(&headers),
)
.await
.unwrap_or(false);
let note_owner = crate::auth::optional_user(&state, &headers)
let note_owner = session_user(&state, &headers)
.await
.ok()
.flatten()
@@ -591,13 +752,22 @@ pub async fn note_info(
.map(|creator| creator == user.nickname)
})
.unwrap_or(false);
workspace_owner || note_owner
can_manage_authorship || note_owner
},
global_color,
note_color,
authorship_mode,
colors_enabled,
authorship_mode: resource_editor_settings.authorship_mode,
colors_enabled: resource_editor_settings.colors_enabled,
compact_view: editor_preferences.compact_view,
editor_line_numbers: editor_preferences.editor_line_numbers,
preview_line_numbers: editor_preferences.preview_line_numbers,
line_links: editor_preferences.line_links,
font_family: editor_preferences.font_family,
font_size: editor_preferences.font_size,
personal_editor_settings,
can_save_editor_settings,
can_manage_authorship,
files: markdown_file_references(&state, None, Some(note.id), None).await?,
}))
}
@@ -607,14 +777,20 @@ pub async fn set_note_editor_settings(
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<EditorSettingsRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let settings_slug = format!("{}/{}", workspace_slug, note_slug);
let workspace = db::find_workspace(&state.db, &workspace_slug)
.await?
.ok_or_else(ApiError::not_found_workspace)?;
let note = db::find_note(&state.db, workspace.id, &note_slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
save_editor_settings(
&state,
&headers,
"workspace",
&workspace_slug,
"note",
&settings_slug,
&format!("{workspace_slug}/{note_slug}"),
db::EditorPreferenceResource::Note(note.id),
payload,
)
.await
+69 -19
View File
@@ -38,7 +38,16 @@ pub struct PadInfo {
note_color: Option<String>,
authorship_mode: String,
colors_enabled: bool,
compact_view: bool,
editor_line_numbers: bool,
preview_line_numbers: bool,
line_links: bool,
font_family: String,
font_size: i64,
personal_editor_settings: bool,
can_save_editor_settings: bool,
can_manage_authorship: bool,
files: Vec<MarkdownFileReference>,
}
pub async fn create_pad(
@@ -92,14 +101,24 @@ pub async fn pad_info(
)
.await?;
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
let (authorship_mode, colors_enabled) = editor_settings(&state, "pad", &slug).await?;
let can_save_editor_settings =
crate::auth::resource_permission(&state, "pad", &slug, bearer_token(&headers))
.await
.ok()
.flatten()
.as_deref()
== Some("rw");
let (editor_preferences, personal_editor_settings) = user_editor_preferences(
&state,
&headers,
db::EditorPreferenceResource::Pad(pad.id),
)
.await?;
let resource_editor_settings =
db::load_resource_editor_settings(&state.db, "pad", &slug).await?;
let can_manage_authorship = crate::auth::is_resource_owner(
&state,
"pad",
&slug,
user_session_token(&headers),
)
.await
.unwrap_or(false);
let can_save_editor_settings = personal_editor_settings
&& has_write_permission(&state, &headers, "pad", &slug).await?;
if pad.is_private == 0
&& !db::pad_public_page_disabled(&state.db, pad.id).await?
&& !db::pad_public_page_enabled(&state.db, pad.id).await?
@@ -116,19 +135,21 @@ pub async fn pad_info(
private: pad.is_private != 0,
created_at: db::normalize_timestamp(&pad.created_at),
updated_at: db::normalize_timestamp(&pad.updated_at),
can_delete_files: crate::auth::is_resource_owner(
&state,
"pad",
&slug,
bearer_token(&headers),
)
.await
.unwrap_or(false),
can_delete_files: can_manage_authorship,
global_color,
note_color,
authorship_mode,
colors_enabled,
authorship_mode: resource_editor_settings.authorship_mode,
colors_enabled: resource_editor_settings.colors_enabled,
compact_view: editor_preferences.compact_view,
editor_line_numbers: editor_preferences.editor_line_numbers,
preview_line_numbers: editor_preferences.preview_line_numbers,
line_links: editor_preferences.line_links,
font_family: editor_preferences.font_family,
font_size: editor_preferences.font_size,
personal_editor_settings,
can_save_editor_settings,
can_manage_authorship,
files: markdown_file_references(&state, Some(pad.id), None, None).await?,
}))
}
@@ -138,7 +159,20 @@ pub async fn set_pad_editor_settings(
Path(slug): Path<String>,
Json(payload): Json<EditorSettingsRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
save_editor_settings(&state, &headers, "pad", &slug, "pad", &slug, payload).await
let pad = db::find_pad(&state.db, &slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
save_editor_settings(
&state,
&headers,
"pad",
&slug,
"pad",
&slug,
db::EditorPreferenceResource::Pad(pad.id),
payload,
)
.await
}
pub async fn pad_editor_color(
@@ -382,11 +416,19 @@ pub async fn public_page(
.await?
.ok_or_else(ApiError::not_found_note)?;
ensure_public_page_access(&state, &headers, &page).await?;
let files = markdown_file_references(
&state,
page.pad_id,
page.note_id,
Some(&page.content),
)
.await?;
Ok(Json(PublicPageResponse {
title: page.title,
content: page.content,
updated_at: db::normalize_timestamp(&page.updated_at),
allow_task_updates: page.allow_task_updates,
files,
}))
}
@@ -408,11 +450,19 @@ pub async fn update_public_task(
let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked)
.await?
.ok_or_else(ApiError::not_found_note)?;
let files = markdown_file_references(
&state,
page.pad_id,
page.note_id,
Some(&page.content),
)
.await?;
Ok(Json(PublicPageResponse {
title: page.title,
content: page.content,
updated_at: db::normalize_timestamp(&page.updated_at),
allow_task_updates: page.allow_task_updates,
files,
}))
}