Files
rustpad/src/api/files.rs
T
2026-08-05 09:53:28 +02:00

625 lines
21 KiB
Rust

/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
use super::pads_public::authorized_pad;
use super::*;
pub async fn upload_pad_file(
State(state): State<SharedState>,
headers: HeaderMap,
Path(slug): Path<String>,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, ApiError> {
let upload_max_size_bytes = require_upload_permission(&state, &headers).await?;
let mut password: Option<String> = None;
let mut access_token: Option<String> = None;
let mut file: Option<(String, Vec<u8>)> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|_| ApiError::bad_request("Invalid form data"))?
{
let name = field.name().unwrap_or_default().to_owned();
if name == "password" {
password = Some(
field
.text()
.await
.map_err(|_| ApiError::bad_request("Invalid password"))?,
);
} else if name == "access_token" {
access_token = Some(
field
.text()
.await
.map_err(|_| ApiError::bad_request("Invalid access token"))?,
);
} else if name == "file" {
let filename = field.file_name().unwrap_or("plik").to_owned();
let bytes = field
.bytes()
.await
.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
if bytes.len() > upload_max_size_bytes {
return Err(ApiError::payload_too_large(upload_max_size_bytes));
}
file = Some((filename, bytes.to_vec()));
}
}
let pad = authorized_pad(
&state,
&slug,
password.as_deref(),
resource_request_token(&headers, "pad", &slug, access_token.as_deref()),
bearer_token(&headers),
&headers,
)
.await?;
let level = if db::verify_pad_password(&pad, password.as_deref())
|| (pad.is_private == 0 && pad.password_hash.is_none())
{
AccessLevel::Write
} else {
request_access_level(
&state,
&headers,
"pad",
&slug,
resource_request_token(&headers, "pad", &slug, access_token.as_deref()),
bearer_token(&headers),
)
.await?
};
require_write(level)?;
let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?;
let safe = sanitize_filename(&original);
let file_token = db::pad_file_token(&state.db, pad.id).await?;
let mut stored = safe.clone();
let mut key = crate::storage::object_key("pads", pad.id, &file_token, &stored);
if state
.storage
.exists(&key)
.await
.map_err(|_| ApiError::internal("Failed to check file storage"))?
{
let stem = std::path::Path::new(&safe)
.file_stem()
.and_then(|v| v.to_str())
.unwrap_or("plik");
let ext = std::path::Path::new(&safe)
.extension()
.and_then(|v| v.to_str())
.map(|v| format!(".{v}"))
.unwrap_or_default();
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
key = crate::storage::object_key("pads", pad.id, &file_token, &stored);
}
let stored_url = crate::file_urls::stored_file_path(&file_token, &stored);
let public_url = crate::file_urls::public_file_url(
state.files_public_url.as_deref(),
&stored_url,
);
let mime = mime_guess::from_path(&stored)
.first_or_octet_stream()
.to_string();
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,
&stored_url,
&mime,
bytes.len() as i64,
)
.await?;
Ok(Json(serde_json::json!({"name": stored, "url": public_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(super) fn content_references_stored_file(
content: &str,
filename: &str,
stored_url: &str,
public_base: Option<&str>,
) -> bool {
if content_references_file(content, filename, stored_url) {
return true;
}
let canonical = crate::file_urls::canonical_file_path(stored_url);
if canonical
.as_deref()
.is_some_and(|url| url != stored_url && content.contains(url))
{
return true;
}
let public_url = crate::file_urls::public_file_url(public_base, stored_url);
public_url != stored_url && content.contains(&public_url)
}
pub async fn pad_files(
State(state): State<SharedState>,
headers: HeaderMap,
Path(slug): Path<String>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<Vec<db::NoteFile>>, 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 mut files = db::list_pad_files(&state.db, pad.id).await?;
for file in &mut files {
let attached = content_references_stored_file(
&pad.content,
&file.filename,
&file.url,
state.files_public_url.as_deref(),
);
if attached != file.is_attached {
db::set_pad_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached;
file.detached_at = if attached {
None
} else {
Some(chrono::Utc::now().to_rfc3339())
};
}
file.created_at = db::normalize_timestamp(&file.created_at);
file.url = crate::file_urls::public_file_url(
state.files_public_url.as_deref(),
&file.url,
);
}
Ok(Json(files))
}
pub async fn delete_pad_file(
State(state): State<SharedState>,
headers: HeaderMap,
Path((slug, file_id)): Path<(String, i64)>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<serde_json::Value>, 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 account_owner = crate::auth::is_resource_owner(
&state,
"pad",
&pad.slug,
bearer_token(&headers),
)
.await
.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"));
}
let file = db::find_pad_file(&state.db, pad.id, file_id)
.await?
.ok_or_else(ApiError::not_found_file)?;
crate::storage::delete_url_file(&state.storage, "pads", pad.id, &file.url)
.await
.map_err(|_| ApiError::internal("Failed to delete the file"))?;
db::delete_pad_file(&state.db, pad.id, file_id).await?;
Ok(Json(serde_json::json!({"ok": true})))
}
pub async fn upload_note_file(
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug)): Path<(String, String)>,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, ApiError> {
let upload_max_size_bytes = require_upload_permission(&state, &headers).await?;
let mut password: Option<String> = None;
let mut access_token: Option<String> = None;
let mut file: Option<(String, Vec<u8>)> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|_| ApiError::bad_request("Invalid form data"))?
{
let name = field.name().unwrap_or_default().to_owned();
if name == "password" {
password = Some(
field
.text()
.await
.map_err(|_| ApiError::bad_request("Invalid password"))?,
);
} else if name == "access_token" {
access_token = Some(
field
.text()
.await
.map_err(|_| ApiError::bad_request("Invalid access token"))?,
);
} else if name == "file" {
let filename = field.file_name().unwrap_or("plik").to_owned();
let bytes = field
.bytes()
.await
.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
if bytes.len() > upload_max_size_bytes {
return Err(ApiError::payload_too_large(upload_max_size_bytes));
}
file = Some((filename, bytes.to_vec()));
}
}
let (workspace, note) = authorized_note(
&state,
&workspace_slug,
&note_slug,
password.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, access_token.as_deref()),
bearer_token(&headers),
&headers,
)
.await?;
let level = if db::verify_workspace_password(&workspace, password.as_deref())
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
{
AccessLevel::Write
} else {
request_access_level(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(&headers, "workspace", &workspace_slug, access_token.as_deref()),
bearer_token(&headers),
)
.await?
};
require_write(level)?;
let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?;
let safe = sanitize_filename(&original);
let file_token = db::note_file_token(&state.db, note.id).await?;
let mut stored = safe.clone();
let mut key = crate::storage::object_key("notes", note.id, &file_token, &stored);
if state
.storage
.exists(&key)
.await
.map_err(|_| ApiError::internal("Failed to check file storage"))?
{
let stem = std::path::Path::new(&safe)
.file_stem()
.and_then(|v| v.to_str())
.unwrap_or("plik");
let ext = std::path::Path::new(&safe)
.extension()
.and_then(|v| v.to_str())
.map(|v| format!(".{v}"))
.unwrap_or_default();
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
key = crate::storage::object_key("notes", note.id, &file_token, &stored);
}
let stored_url = crate::file_urls::stored_file_path(&file_token, &stored);
let public_url = crate::file_urls::public_file_url(
state.files_public_url.as_deref(),
&stored_url,
);
let mime = mime_guess::from_path(&stored)
.first_or_octet_stream()
.to_string();
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,
&stored_url,
&mime,
bytes.len() as i64,
)
.await?;
Ok(Json(serde_json::json!({"name": stored, "url": public_url, "mime_type": mime})))
}
pub async fn delete_note(
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<serde_json::Value>, 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 level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|| (workspace.is_private == 0 && workspace.password_hash.is_none())
{
AccessLevel::Write
} else {
request_access_level(
&state,
&headers,
"workspace",
&workspace_slug,
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
bearer_token(&headers),
)
.await?
};
require_write(level)?;
if note.protected {
return Err(ApiError::bad_request(
"This note is protected and cannot be deleted",
));
}
for file in db::list_note_files(&state.db, note.id).await? {
crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url)
.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})))
}
pub async fn note_files(
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<Vec<db::NoteFile>>, 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 mut files = db::list_note_files(&state.db, note.id).await?;
for file in &mut files {
let attached = content_references_stored_file(
&note.content,
&file.filename,
&file.url,
state.files_public_url.as_deref(),
);
if attached != file.is_attached {
db::set_note_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached;
file.detached_at = if attached {
None
} else {
Some(chrono::Utc::now().to_rfc3339())
};
}
file.created_at = db::normalize_timestamp(&file.created_at);
file.url = crate::file_urls::public_file_url(
state.files_public_url.as_deref(),
&file.url,
);
}
Ok(Json(files))
}
pub async fn delete_note_file(
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug, file_id)): Path<(String, String, i64)>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<serde_json::Value>, 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 workspace_owner = crate::auth::is_resource_owner(
&state,
"workspace",
&workspace.slug,
bearer_token(&headers),
)
.await
.unwrap_or(false);
let note_owner = note_creator_is_requester(&state, &headers, &note).await?;
let password_write_access =
has_password_write_access(&state, &headers, "workspace", &workspace.slug).await?;
if !workspace_owner && !note_owner && !password_write_access {
return Err(ApiError::forbidden(
"Only the note owner, workspace owner, or password holder can delete files",
));
}
let file = db::find_note_file(&state.db, note.id, file_id)
.await?
.ok_or_else(ApiError::not_found_file)?;
crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url)
.await
.map_err(|_| ApiError::internal("Failed to delete the file"))?;
db::delete_note_file(&state.db, note.id, file_id).await?;
Ok(Json(serde_json::json!({"ok": true})))
}
async fn require_upload_permission(
state: &SharedState,
headers: &HeaderMap,
) -> Result<usize, ApiError> {
upload_limit_for_request(state, headers)
.await?
.ok_or_else(|| ApiError::forbidden("File uploads are disabled for guests."))
}
pub async fn download_file(
State(state): State<SharedState>,
Path((token, filename)): Path<(String, String)>,
) -> Result<Response, ApiError> {
serve_token_file(&state, &token, &filename).await
}
async fn serve_token_file(
state: &SharedState,
token: &str,
filename: &str,
) -> Result<Response, ApiError> {
let safe = sanitize_filename(filename);
if safe != filename {
return Err(ApiError::not_found_file());
}
let owner = db::find_file_owner(&state.db, token)
.await?
.ok_or_else(ApiError::not_found_file)?;
let kind = match owner.kind {
db::FileOwnerKind::Pad => "pads",
db::FileOwnerKind::Note => "notes",
};
let key = crate::storage::object_key(kind, owner.id, token, &safe);
let legacy_key = crate::storage::legacy_key(owner.id, token, &safe);
let bytes = state
.storage
.get_local_with_legacy(&key, &legacy_key)
.await
.map_err(|_| ApiError::not_found_file())?;
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 {
guessed_mime.as_ref()
} else {
"application/octet-stream"
};
let disposition = if inline_image { "inline" } else { "attachment" };
let mut response = bytes.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_str(served_mime)
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
);
response.headers_mut().insert(
header::CONTENT_DISPOSITION,
HeaderValue::from_str(&format!("{disposition}; filename=\"{safe}\""))
.expect("sanitized attachment filename"),
);
response.headers_mut().insert(
HeaderName::from_static("content-security-policy"),
HeaderValue::from_static("default-src 'none'; sandbox"),
);
response.headers_mut().insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
response.headers_mut().insert(
HeaderName::from_static("x-robots-tag"),
HeaderValue::from_static("noindex, nofollow, noarchive, nosnippet"),
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_str(&crate::cache::cache_control(
state.file_cache_max_age_seconds,
))
.expect("valid file cache-control header"),
);
Ok(response)
}
fn is_safe_inline_image_mime(value: &str) -> bool {
matches!(
value,
"image/png"
| "image/jpeg"
| "image/gif"
| "image/webp"
| "image/avif"
| "image/bmp"
| "image/x-icon"
)
}
fn sanitize_filename(value: &str) -> String {
let name = std::path::Path::new(value)
.file_name()
.and_then(|v| v.to_str())
.unwrap_or("plik");
let clean: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
c
} else {
'_'
}
})
.collect();
if clean.is_empty() || clean == "." || clean == ".." {
"plik".into()
} else {
clean.chars().take(160).collect()
}
}
#[cfg(test)]
#[path = "../tests/api_files.rs"]
mod tests;