625 lines
21 KiB
Rust
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,
|
|
¬e_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,
|
|
¬e_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,
|
|
¬e_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(
|
|
¬e.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,
|
|
¬e_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, ¬e).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;
|