Files
rustpad/src/api/files.rs
T

497 lines
16 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 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() > state.upload_max_size_bytes {
return Err(ApiError::payload_too_large(state.upload_max_size_bytes));
}
file = Some((filename, bytes.to_vec()));
}
}
let pad = authorized_pad(
&state,
&slug,
password.as_deref(),
access_token.as_deref(),
bearer_token(&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 {
combined_token_access_level(
&state,
"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 url = format!("/f/{}/{}", file_token, stored);
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);
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})))
}
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(),
payload.access_token.as_deref(),
bearer_token(&headers),
)
.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);
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);
}
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(),
payload.access_token.as_deref(),
bearer_token(&headers),
)
.await?;
if !crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers))
.await
.unwrap_or(false)
{
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 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() > state.upload_max_size_bytes {
return Err(ApiError::payload_too_large(state.upload_max_size_bytes));
}
file = Some((filename, bytes.to_vec()));
}
}
let (workspace, note) = authorized_note(
&state,
&workspace_slug,
&note_slug,
password.as_deref(),
access_token.as_deref(),
bearer_token(&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 {
combined_token_access_level(
&state,
"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 url = format!("/f/{}/{}", file_token, stored);
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);
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})))
}
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(),
payload.access_token.as_deref(),
bearer_token(&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 {
combined_token_access_level(
&state,
"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_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(),
payload.access_token.as_deref(),
bearer_token(&headers),
)
.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);
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);
}
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(),
payload.access_token.as_deref(),
bearer_token(&headers),
)
.await?;
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)
.await
.ok()
.flatten()
.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(
"Only the note owner or workspace owner 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})))
}
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
}
pub async fn download_legacy_file(
State(state): State<SharedState>,
Path((directory, filename)): Path<(String, String)>,
) -> Result<Response, ApiError> {
let Some((id_part, token)) = directory.split_once('_') else {
return Err(ApiError::not_found_file());
};
let id: i64 = id_part.parse().map_err(|_| ApiError::not_found_file())?;
let owner = db::find_file_owner(&state.db, token)
.await?
.ok_or_else(ApiError::not_found_file)?;
if owner.id != id {
return Err(ApiError::not_found_file());
}
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 mime = mime_guess::from_path(&safe).first_or_octet_stream();
let mut response = bytes.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_str(mime.as_ref())
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
);
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(&format!(
"public, max-age={}",
state.file_cache_max_age_seconds
))
.expect("valid file cache-control header"),
);
Ok(response)
}
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()
}
}