new functions and fixes

This commit is contained in:
Mateusz Gruszczyński
2026-08-05 22:13:04 +02:00
parent 9fc32d0d31
commit e842b26978
34 changed files with 2505 additions and 115 deletions
+123 -6
View File
@@ -130,7 +130,7 @@ pub(super) fn content_references_file(content: &str, filename: &str, url: &str)
if content.contains(url) {
return true;
}
for marker in ["[file=", "[image=", "[img="] {
for marker in ["[file=", "[image=", "[img=", "[video="] {
let mut remaining = content;
while let Some(index) = remaining.find(marker) {
let after = &remaining[index + marker.len()..];
@@ -513,17 +513,38 @@ async fn require_upload_permission(
.ok_or_else(|| ApiError::forbidden("File uploads are disabled for guests."))
}
#[derive(Debug, Default, Deserialize)]
pub struct FileDownloadQuery {
#[serde(default)]
download: Option<String>,
}
impl FileDownloadQuery {
fn force_download(&self) -> bool {
self.download.as_deref().is_some_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "download"
)
})
}
}
pub async fn download_file(
State(state): State<SharedState>,
headers: HeaderMap,
Path((token, filename)): Path<(String, String)>,
Query(query): Query<FileDownloadQuery>,
) -> Result<Response, ApiError> {
serve_token_file(&state, &token, &filename).await
serve_token_file(&state, &headers, &token, &filename, query.force_download()).await
}
async fn serve_token_file(
state: &SharedState,
headers: &HeaderMap,
token: &str,
filename: &str,
force_download: bool,
) -> Result<Response, ApiError> {
let safe = sanitize_filename(filename);
if safe != filename {
@@ -543,15 +564,44 @@ async fn serve_token_file(
.get_local_with_legacy(&key, &legacy_key)
.await
.map_err(|_| ApiError::not_found_file())?;
let total_len = bytes.len();
let guessed_mime = mime_guess::from_path(&safe).first_or_octet_stream();
let inline_image = is_safe_inline_image_mime(guessed_mime.essence_str());
let served_mime = if inline_image {
let safe_inline = is_safe_inline_image_mime(guessed_mime.essence_str())
|| is_safe_inline_video_mime(guessed_mime.essence_str());
let served_mime = if safe_inline {
guessed_mime.as_ref()
} else {
"application/octet-stream"
};
let disposition = if inline_image { "inline" } else { "attachment" };
let mut response = bytes.into_response();
let disposition = if safe_inline && !force_download {
"inline"
} else {
"attachment"
};
let requested_range = headers
.get(header::RANGE)
.and_then(|value| value.to_str().ok());
let range = match requested_range {
Some(value) => match parse_byte_range(value, total_len) {
Ok(range) => range,
Err(()) => return Ok(range_not_satisfiable(total_len)),
},
None => None,
};
let (body, status, content_range) = if let Some((start, end)) = range {
(
bytes.slice(start..end),
StatusCode::PARTIAL_CONTENT,
Some(format!("bytes {start}-{}/{}", end - 1, total_len)),
)
} else {
(bytes, StatusCode::OK, None)
};
let body_len = body.len();
let mut response = body.into_response();
*response.status_mut() = status;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_str(served_mime)
@@ -562,6 +612,20 @@ async fn serve_token_file(
HeaderValue::from_str(&format!("{disposition}; filename=\"{safe}\""))
.expect("sanitized attachment filename"),
);
response.headers_mut().insert(
header::ACCEPT_RANGES,
HeaderValue::from_static("bytes"),
);
response.headers_mut().insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(&body_len.to_string()).expect("valid content length"),
);
if let Some(content_range) = content_range {
response.headers_mut().insert(
header::CONTENT_RANGE,
HeaderValue::from_str(&content_range).expect("valid content range"),
);
}
response.headers_mut().insert(
HeaderName::from_static("content-security-policy"),
HeaderValue::from_static("default-src 'none'; sandbox"),
@@ -584,6 +648,52 @@ async fn serve_token_file(
Ok(response)
}
fn range_not_satisfiable(total_len: usize) -> Response {
let mut response = StatusCode::RANGE_NOT_SATISFIABLE.into_response();
response.headers_mut().insert(
header::CONTENT_RANGE,
HeaderValue::from_str(&format!("bytes */{total_len}")).expect("valid content range"),
);
response.headers_mut().insert(
header::ACCEPT_RANGES,
HeaderValue::from_static("bytes"),
);
response
}
fn parse_byte_range(value: &str, total_len: usize) -> Result<Option<(usize, usize)>, ()> {
if total_len == 0 {
return Err(());
}
let range = value.trim().strip_prefix("bytes=").ok_or(())?;
if range.contains(',') {
return Err(());
}
let (start, end) = range.split_once('-').ok_or(())?;
if start.is_empty() {
let suffix_len = end.parse::<usize>().map_err(|_| ())?;
if suffix_len == 0 {
return Err(());
}
let start = total_len.saturating_sub(suffix_len);
return Ok(Some((start, total_len)));
}
let start = start.parse::<usize>().map_err(|_| ())?;
if start >= total_len {
return Err(());
}
let end_inclusive = if end.is_empty() {
total_len - 1
} else {
end.parse::<usize>().map_err(|_| ())?.min(total_len - 1)
};
if end_inclusive < start {
return Err(());
}
Ok(Some((start, end_inclusive + 1)))
}
fn is_safe_inline_image_mime(value: &str) -> bool {
matches!(
value,
@@ -597,6 +707,13 @@ fn is_safe_inline_image_mime(value: &str) -> bool {
)
}
fn is_safe_inline_video_mime(value: &str) -> bool {
matches!(
value,
"video/mp4" | "video/webm" | "video/ogg" | "video/quicktime" | "video/x-m4v"
)
}
fn sanitize_filename(value: &str) -> String {
let name = std::path::Path::new(value)
.file_name()
+8
View File
@@ -432,6 +432,7 @@ pub struct NoteInfo {
editor_line_numbers: bool,
preview_line_numbers: bool,
line_links: bool,
toolbar_collapsed: bool,
font_family: String,
font_size: i64,
personal_editor_settings: bool,
@@ -461,6 +462,8 @@ pub struct EditorSettingsRequest {
#[serde(default)]
line_links: Option<bool>,
#[serde(default)]
toolbar_collapsed: Option<bool>,
#[serde(default)]
font_family: Option<String>,
#[serde(default)]
font_size: Option<i64>,
@@ -502,6 +505,7 @@ async fn save_editor_settings(
|| payload.editor_line_numbers.is_some()
|| payload.preview_line_numbers.is_some()
|| payload.line_links.is_some()
|| payload.toolbar_collapsed.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();
@@ -553,6 +557,9 @@ async fn save_editor_settings(
if let Some(value) = payload.line_links {
preferences.line_links = value;
}
if let Some(value) = payload.toolbar_collapsed {
preferences.toolbar_collapsed = value;
}
if let Some(value) = payload.font_family {
preferences.font_family = match value.as_str() {
"mono" | "system" | "serif" | "arial" | "georgia" => value,
@@ -1084,6 +1091,7 @@ pub async fn note_info(
editor_line_numbers: editor_preferences.editor_line_numbers,
preview_line_numbers: editor_preferences.preview_line_numbers,
line_links: editor_preferences.line_links,
toolbar_collapsed: editor_preferences.toolbar_collapsed,
font_family: editor_preferences.font_family,
font_size: editor_preferences.font_size,
personal_editor_settings,
+2
View File
@@ -54,6 +54,7 @@ pub struct PadInfo {
editor_line_numbers: bool,
preview_line_numbers: bool,
line_links: bool,
toolbar_collapsed: bool,
font_family: String,
font_size: i64,
personal_editor_settings: bool,
@@ -172,6 +173,7 @@ pub async fn pad_info(
editor_line_numbers: editor_preferences.editor_line_numbers,
preview_line_numbers: editor_preferences.preview_line_numbers,
line_links: editor_preferences.line_links,
toolbar_collapsed: editor_preferences.toolbar_collapsed,
font_family: editor_preferences.font_family,
font_size: editor_preferences.font_size,
personal_editor_settings,