fix in smtp and split rs files
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
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::REFERRER_POLICY,
|
||||
HeaderValue::from_static("no-referrer"),
|
||||
);
|
||||
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 bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user