work
This commit is contained in:
+222
-20
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
extract::{Multipart, Path, State},
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
@@ -17,6 +17,18 @@ const MIN_PASSWORD_LENGTH: usize = 8;
|
||||
const MAX_PASSWORD_LENGTH: usize = 128;
|
||||
const MIN_WORKSPACE_SLUG_LENGTH: usize = 6;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PublishResponse {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PublicPageResponse {
|
||||
title: String,
|
||||
content: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateWorkspaceRequest {
|
||||
name: String,
|
||||
@@ -89,7 +101,7 @@ pub async fn create_workspace(
|
||||
State(state): State<SharedState>,
|
||||
Json(payload): Json<CreateWorkspaceRequest>,
|
||||
) -> Result<(StatusCode, Json<CreateWorkspaceResponse>), ApiError> {
|
||||
let title = validate_name(&payload.name, "Nazwa workspace")?;
|
||||
let title = validate_name(&payload.name, "Workspace name")?;
|
||||
let password = validate_password(payload.password.as_deref())?;
|
||||
let slug = unique_workspace_slug(&state, title).await?;
|
||||
|
||||
@@ -144,10 +156,10 @@ pub async fn create_note(
|
||||
Json(payload): Json<CreateNoteRequest>,
|
||||
) -> Result<(StatusCode, Json<NoteListItem>), ApiError> {
|
||||
let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref()).await?;
|
||||
let title = validate_name(&payload.name, "Nazwa notatki")?;
|
||||
let title = validate_name(&payload.name, "Note name")?;
|
||||
let base = slugify(title);
|
||||
if base.is_empty() {
|
||||
return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu"));
|
||||
return Err(ApiError::bad_request("The name cannot be converted into a valid address"));
|
||||
}
|
||||
|
||||
let slug = unique_note_slug(&state, workspace.id, &base).await?;
|
||||
@@ -211,11 +223,13 @@ pub async fn restore(
|
||||
.await?;
|
||||
let content = content.ok_or_else(ApiError::not_found_revision)?;
|
||||
let (revision_id, updated_at) =
|
||||
db::save_revision(&state.db, note.id, workspace.id, &content).await?;
|
||||
db::save_revision(&state.db, note.id, workspace.id, &content, Some("restore"), "[]").await?;
|
||||
let update = NoteUpdate {
|
||||
content,
|
||||
revision_id,
|
||||
updated_at,
|
||||
author: Some("restore".into()),
|
||||
owner_map: "[]".into(),
|
||||
};
|
||||
let _ = state.note_channel(&workspace_slug, ¬e_slug).await.send(update);
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
@@ -262,7 +276,7 @@ fn validate_name<'a>(value: &'a str, field: &str) -> Result<&'a str, ApiError> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.chars().count() > MAX_NAME_LENGTH {
|
||||
return Err(ApiError::bad_request(&format!(
|
||||
"{field} musi mieć od 1 do {MAX_NAME_LENGTH} znaków"
|
||||
"{field} must contain between 1 and {MAX_NAME_LENGTH} characters"
|
||||
)));
|
||||
}
|
||||
Ok(value)
|
||||
@@ -275,7 +289,7 @@ fn validate_password(password: Option<&str>) -> Result<Option<&str>, ApiError> {
|
||||
let length = password.chars().count();
|
||||
if !(MIN_PASSWORD_LENGTH..=MAX_PASSWORD_LENGTH).contains(&length) {
|
||||
return Err(ApiError::bad_request(
|
||||
"Hasło musi mieć od 8 do 128 znaków",
|
||||
"Password must contain between 8 and 128 characters",
|
||||
));
|
||||
}
|
||||
Ok(Some(password))
|
||||
@@ -284,7 +298,7 @@ fn validate_password(password: Option<&str>) -> Result<Option<&str>, ApiError> {
|
||||
async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result<String, ApiError> {
|
||||
let base = slugify(title);
|
||||
if base.is_empty() {
|
||||
return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu"));
|
||||
return Err(ApiError::bad_request("The name cannot be converted into a valid address"));
|
||||
}
|
||||
|
||||
let needs_suffix = base.chars().count() < MIN_WORKSPACE_SLUG_LENGTH
|
||||
@@ -299,7 +313,7 @@ async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result<Strin
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu"))
|
||||
Err(ApiError::internal("Failed to create a unique address"))
|
||||
}
|
||||
|
||||
async fn unique_note_slug(
|
||||
@@ -319,7 +333,7 @@ async fn unique_note_slug(
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu"))
|
||||
Err(ApiError::internal("Failed to create a unique address"))
|
||||
}
|
||||
|
||||
|
||||
@@ -349,11 +363,11 @@ pub async fn create_pad(
|
||||
State(state): State<SharedState>,
|
||||
Json(payload): Json<CreatePadRequest>,
|
||||
) -> Result<(StatusCode, Json<CreatePadResponse>), ApiError> {
|
||||
let title = validate_name(&payload.name, "Nazwa notatki")?;
|
||||
let title = validate_name(&payload.name, "Note name")?;
|
||||
let password = validate_password(payload.password.as_deref())?;
|
||||
let base = slugify(title);
|
||||
if base.is_empty() {
|
||||
return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu"));
|
||||
return Err(ApiError::bad_request("The name cannot be converted into a valid address"));
|
||||
}
|
||||
let slug = unique_pad_slug(&state, &base).await?;
|
||||
db::create_pad(&state.db, &slug, title, password).await?;
|
||||
@@ -382,6 +396,40 @@ pub async fn pad_info(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn publish_pad_page(
|
||||
State(state): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<PublishResponse>, ApiError> {
|
||||
let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?;
|
||||
let token = db::publish_pad(&state.db, pad.id).await?;
|
||||
Ok(Json(PublishResponse { url: format!("/s/{token}") }))
|
||||
}
|
||||
|
||||
pub async fn publish_note_page(
|
||||
State(state): State<SharedState>,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<PublishResponse>, ApiError> {
|
||||
let (_, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?;
|
||||
let token = db::publish_note(&state.db, note.id).await?;
|
||||
Ok(Json(PublishResponse { url: format!("/s/{token}") }))
|
||||
}
|
||||
|
||||
pub async fn public_page(
|
||||
State(state): State<SharedState>,
|
||||
Path(token): Path<String>,
|
||||
) -> Result<Json<PublicPageResponse>, ApiError> {
|
||||
let page = db::find_published_page(&state.db, &token)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
Ok(Json(PublicPageResponse {
|
||||
title: page.title,
|
||||
content: page.content,
|
||||
updated_at: db::normalize_timestamp(&page.updated_at),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn pad_history(
|
||||
State(state): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
@@ -405,11 +453,19 @@ pub async fn pad_restore(
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let content = content.ok_or_else(ApiError::not_found_revision)?;
|
||||
let (revision_id, updated_at) = db::save_pad_revision(&state.db, pad.id, &content).await?;
|
||||
let owner_map: Option<String> = sqlx::query_scalar("SELECT owner_map FROM revisions WHERE id = ? AND pad_id = ?")
|
||||
.bind(payload.revision_id)
|
||||
.bind(pad.id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let owner_map = owner_map.unwrap_or_else(|| "[]".into());
|
||||
let (revision_id, updated_at) = db::save_pad_revision(&state.db, pad.id, &content, Some("restore"), &owner_map).await?;
|
||||
let update = NoteUpdate {
|
||||
content,
|
||||
revision_id,
|
||||
updated_at,
|
||||
author: Some("restore".into()),
|
||||
owner_map,
|
||||
};
|
||||
let _ = state.pad_channel(&slug).await.send(update);
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
@@ -439,7 +495,143 @@ async fn unique_pad_slug(state: &SharedState, base: &str) -> Result<String, ApiE
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu"))
|
||||
Err(ApiError::internal("Failed to create a unique address"))
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub async fn upload_pad_file(
|
||||
State(state): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let mut password: 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 == "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()).await?;
|
||||
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 directory = format!("{}_{}", pad.id, file_token);
|
||||
let dir = std::path::Path::new(&state.files_dir).join("pads").join(&directory);
|
||||
tokio::fs::create_dir_all(&dir).await.map_err(|_| ApiError::internal("Failed to create the files directory"))?;
|
||||
let mut stored = safe.clone();
|
||||
let mut path = dir.join(&stored);
|
||||
if path.exists() {
|
||||
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);
|
||||
path = dir.join(&stored);
|
||||
}
|
||||
tokio::fs::write(&path, bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?;
|
||||
Ok(Json(serde_json::json!({"name": stored, "url": format!("/f/{}/{}", file_token, stored)})))
|
||||
}
|
||||
|
||||
pub async fn upload_note_file(
|
||||
State(state): State<SharedState>,
|
||||
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 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 == "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()).await?;
|
||||
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 directory = format!("{}_{}", note.id, file_token);
|
||||
let dir = std::path::Path::new(&state.files_dir).join("notes").join(&directory);
|
||||
tokio::fs::create_dir_all(&dir).await.map_err(|_| ApiError::internal("Failed to create the files directory"))?;
|
||||
let mut stored = safe.clone();
|
||||
let mut path = dir.join(&stored);
|
||||
if path.exists() {
|
||||
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);
|
||||
path = dir.join(&stored);
|
||||
}
|
||||
tokio::fs::write(&path, bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?;
|
||||
Ok(Json(serde_json::json!({"name": stored, "url": format!("/f/{}/{}", file_token, stored)})))
|
||||
}
|
||||
|
||||
|
||||
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 directory = format!("{}_{}", owner.id, token);
|
||||
let canonical = std::path::Path::new(&state.files_dir).join(kind).join(&directory).join(&safe);
|
||||
let legacy = std::path::Path::new(&state.files_dir).join(&directory).join(&safe);
|
||||
let path = if canonical.is_file() { canonical } else { legacy };
|
||||
let bytes = tokio::fs::read(&path).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"));
|
||||
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() }
|
||||
}
|
||||
|
||||
pub struct ApiError {
|
||||
@@ -454,28 +646,38 @@ impl ApiError {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
fn payload_too_large(max_bytes: usize) -> Self {
|
||||
let max_mb = max_bytes / (1024 * 1024);
|
||||
Self {
|
||||
status: StatusCode::PAYLOAD_TOO_LARGE,
|
||||
message: format!("The file may be at most {max_mb} MB"),
|
||||
}
|
||||
}
|
||||
fn not_found_file() -> Self {
|
||||
Self { status: StatusCode::NOT_FOUND, message: "File not found".into() }
|
||||
}
|
||||
fn unauthorized() -> Self {
|
||||
Self {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
message: "Nieprawidłowe hasło".into(),
|
||||
message: "Invalid password".into(),
|
||||
}
|
||||
}
|
||||
fn not_found_workspace() -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "Nie znaleziono workspace".into(),
|
||||
message: "Workspace not found".into(),
|
||||
}
|
||||
}
|
||||
fn not_found_note() -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "Nie znaleziono notatki".into(),
|
||||
message: "Note not found".into(),
|
||||
}
|
||||
}
|
||||
fn not_found_revision() -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "Nie znaleziono wersji".into(),
|
||||
message: "Revision not found".into(),
|
||||
}
|
||||
}
|
||||
fn internal(message: &str) -> Self {
|
||||
@@ -489,7 +691,7 @@ impl ApiError {
|
||||
impl From<sqlx::Error> for ApiError {
|
||||
fn from(error: sqlx::Error) -> Self {
|
||||
tracing::error!(%error, "database error");
|
||||
Self::internal("Błąd bazy danych")
|
||||
Self::internal("Database error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user