497 lines
16 KiB
Rust
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,
|
|
¬e_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,
|
|
¬e_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,
|
|
¬e_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,
|
|
¬e_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()
|
|
}
|
|
}
|