first commit
This commit is contained in:
+500
@@ -0,0 +1,500 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use slug::slugify;
|
||||
|
||||
use crate::{
|
||||
db,
|
||||
state::{NoteUpdate, SharedState},
|
||||
};
|
||||
|
||||
const MAX_NAME_LENGTH: usize = 80;
|
||||
const MIN_PASSWORD_LENGTH: usize = 8;
|
||||
const MAX_PASSWORD_LENGTH: usize = 128;
|
||||
const MIN_WORKSPACE_SLUG_LENGTH: usize = 6;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateWorkspaceRequest {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateWorkspaceResponse {
|
||||
slug: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PasswordRequest {
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateNoteRequest {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RestoreRequest {
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
revision_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WorkspaceInfo {
|
||||
slug: String,
|
||||
title: String,
|
||||
protected: bool,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WorkspaceOpenResponse {
|
||||
workspace: WorkspaceInfo,
|
||||
notes: Vec<NoteListItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NoteListItem {
|
||||
slug: String,
|
||||
title: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NoteInfo {
|
||||
workspace_slug: String,
|
||||
workspace_title: String,
|
||||
slug: String,
|
||||
title: String,
|
||||
protected: bool,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
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 password = validate_password(payload.password.as_deref())?;
|
||||
let slug = unique_workspace_slug(&state, title).await?;
|
||||
|
||||
db::create_workspace(&state.db, &slug, title, password).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(CreateWorkspaceResponse {
|
||||
url: format!("/w/{slug}"),
|
||||
slug,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn workspace_info(
|
||||
State(state): State<SharedState>,
|
||||
Path(workspace_slug): Path<String>,
|
||||
) -> Result<Json<WorkspaceInfo>, ApiError> {
|
||||
let workspace = db::find_workspace(&state.db, &workspace_slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_workspace)?;
|
||||
Ok(Json(workspace_info_from(&workspace)))
|
||||
}
|
||||
|
||||
pub async fn open_workspace(
|
||||
State(state): State<SharedState>,
|
||||
Path(workspace_slug): Path<String>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<WorkspaceOpenResponse>, ApiError> {
|
||||
let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref()).await?;
|
||||
let notes = db::list_notes(&state.db, workspace.id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|note| NoteListItem {
|
||||
url: format!("/w/{}/n/{}", workspace.slug, note.slug),
|
||||
slug: note.slug,
|
||||
title: note.title,
|
||||
created_at: db::normalize_timestamp(¬e.created_at),
|
||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(WorkspaceOpenResponse {
|
||||
workspace: workspace_info_from(&workspace),
|
||||
notes,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn create_note(
|
||||
State(state): State<SharedState>,
|
||||
Path(workspace_slug): Path<String>,
|
||||
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 base = slugify(title);
|
||||
if base.is_empty() {
|
||||
return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu"));
|
||||
}
|
||||
|
||||
let slug = unique_note_slug(&state, workspace.id, &base).await?;
|
||||
let note = db::create_note(&state.db, workspace.id, &slug, title).await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(NoteListItem {
|
||||
url: format!("/w/{workspace_slug}/n/{slug}"),
|
||||
slug: note.slug,
|
||||
title: note.title,
|
||||
created_at: db::normalize_timestamp(¬e.created_at),
|
||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn note_info(
|
||||
State(state): State<SharedState>,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
) -> Result<Json<NoteInfo>, ApiError> {
|
||||
let workspace = db::find_workspace(&state.db, &workspace_slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_workspace)?;
|
||||
let note = db::find_note(&state.db, workspace.id, ¬e_slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
|
||||
Ok(Json(NoteInfo {
|
||||
workspace_slug: workspace.slug,
|
||||
workspace_title: workspace.title,
|
||||
slug: note.slug,
|
||||
title: note.title,
|
||||
protected: workspace.password_hash.is_some(),
|
||||
created_at: db::normalize_timestamp(¬e.created_at),
|
||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn history(
|
||||
State(state): State<SharedState>,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<Vec<db::Revision>>, ApiError> {
|
||||
let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?;
|
||||
let _ = workspace;
|
||||
Ok(Json(db::list_revisions(&state.db, note.id).await?))
|
||||
}
|
||||
|
||||
pub async fn restore(
|
||||
State(state): State<SharedState>,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
Json(payload): Json<RestoreRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?;
|
||||
let content: Option<String> = sqlx::query_scalar(
|
||||
"SELECT content FROM note_revisions WHERE id = ? AND note_id = ?",
|
||||
)
|
||||
.bind(payload.revision_id)
|
||||
.bind(note.id)
|
||||
.fetch_optional(&state.db)
|
||||
.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?;
|
||||
let update = NoteUpdate {
|
||||
content,
|
||||
revision_id,
|
||||
updated_at,
|
||||
};
|
||||
let _ = state.note_channel(&workspace_slug, ¬e_slug).await.send(update);
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
pub async fn authorized_workspace(
|
||||
state: &SharedState,
|
||||
slug: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<db::Workspace, ApiError> {
|
||||
let workspace = db::find_workspace(&state.db, slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_workspace)?;
|
||||
if !db::verify_workspace_password(&workspace, password) {
|
||||
return Err(ApiError::unauthorized());
|
||||
}
|
||||
Ok(workspace)
|
||||
}
|
||||
|
||||
async fn authorized_note(
|
||||
state: &SharedState,
|
||||
workspace_slug: &str,
|
||||
note_slug: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<(db::Workspace, db::Note), ApiError> {
|
||||
let workspace = authorized_workspace(state, workspace_slug, password).await?;
|
||||
let note = db::find_note(&state.db, workspace.id, note_slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
Ok((workspace, note))
|
||||
}
|
||||
|
||||
fn workspace_info_from(workspace: &db::Workspace) -> WorkspaceInfo {
|
||||
WorkspaceInfo {
|
||||
slug: workspace.slug.clone(),
|
||||
title: workspace.title.clone(),
|
||||
protected: workspace.password_hash.is_some(),
|
||||
created_at: db::normalize_timestamp(&workspace.created_at),
|
||||
updated_at: db::normalize_timestamp(&workspace.updated_at),
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
)));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn validate_password(password: Option<&str>) -> Result<Option<&str>, ApiError> {
|
||||
let Some(password) = password.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
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",
|
||||
));
|
||||
}
|
||||
Ok(Some(password))
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
let needs_suffix = base.chars().count() < MIN_WORKSPACE_SLUG_LENGTH
|
||||
|| db::find_workspace(&state.db, &base).await?.is_some();
|
||||
if !needs_suffix {
|
||||
return Ok(base);
|
||||
}
|
||||
|
||||
for _ in 0..8 {
|
||||
let candidate = format!("{base}-{}", db::random_suffix(8));
|
||||
if db::find_workspace(&state.db, &candidate).await?.is_none() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu"))
|
||||
}
|
||||
|
||||
async fn unique_note_slug(
|
||||
state: &SharedState,
|
||||
workspace_id: i64,
|
||||
base: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
if db::find_note(&state.db, workspace_id, base).await?.is_none() {
|
||||
return Ok(base.to_owned());
|
||||
}
|
||||
for _ in 0..8 {
|
||||
let candidate = format!("{base}-{}", db::random_suffix(6));
|
||||
if db::find_note(&state.db, workspace_id, &candidate)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu"))
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatePadRequest {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreatePadResponse {
|
||||
slug: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PadInfo {
|
||||
slug: String,
|
||||
title: String,
|
||||
protected: bool,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
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 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"));
|
||||
}
|
||||
let slug = unique_pad_slug(&state, &base).await?;
|
||||
db::create_pad(&state.db, &slug, title, password).await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(CreatePadResponse {
|
||||
url: format!("/p/{slug}"),
|
||||
slug,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn pad_info(
|
||||
State(state): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<PadInfo>, ApiError> {
|
||||
let pad = db::find_pad(&state.db, &slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
Ok(Json(PadInfo {
|
||||
slug: pad.slug,
|
||||
title: pad.title,
|
||||
protected: pad.password_hash.is_some(),
|
||||
created_at: db::normalize_timestamp(&pad.created_at),
|
||||
updated_at: db::normalize_timestamp(&pad.updated_at),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn pad_history(
|
||||
State(state): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<Vec<db::Revision>>, ApiError> {
|
||||
let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?;
|
||||
Ok(Json(db::list_pad_revisions(&state.db, pad.id).await?))
|
||||
}
|
||||
|
||||
pub async fn pad_restore(
|
||||
State(state): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
Json(payload): Json<RestoreRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?;
|
||||
let content: Option<String> = sqlx::query_scalar(
|
||||
"SELECT content FROM revisions WHERE id = ? AND pad_id = ?",
|
||||
)
|
||||
.bind(payload.revision_id)
|
||||
.bind(pad.id)
|
||||
.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 update = NoteUpdate {
|
||||
content,
|
||||
revision_id,
|
||||
updated_at,
|
||||
};
|
||||
let _ = state.pad_channel(&slug).await.send(update);
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn authorized_pad(
|
||||
state: &SharedState,
|
||||
slug: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<db::Pad, ApiError> {
|
||||
let pad = db::find_pad(&state.db, slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
if !db::verify_pad_password(&pad, password) {
|
||||
return Err(ApiError::unauthorized());
|
||||
}
|
||||
Ok(pad)
|
||||
}
|
||||
|
||||
async fn unique_pad_slug(state: &SharedState, base: &str) -> Result<String, ApiError> {
|
||||
if db::find_pad(&state.db, base).await?.is_none() {
|
||||
return Ok(base.to_owned());
|
||||
}
|
||||
for _ in 0..8 {
|
||||
let candidate = format!("{base}-{}", db::random_suffix(6));
|
||||
if db::find_pad(&state.db, &candidate).await?.is_none() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu"))
|
||||
}
|
||||
|
||||
pub struct ApiError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
fn bad_request(message: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
fn unauthorized() -> Self {
|
||||
Self {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
message: "Nieprawidłowe hasło".into(),
|
||||
}
|
||||
}
|
||||
fn not_found_workspace() -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "Nie znaleziono workspace".into(),
|
||||
}
|
||||
}
|
||||
fn not_found_note() -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "Nie znaleziono notatki".into(),
|
||||
}
|
||||
}
|
||||
fn not_found_revision() -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "Nie znaleziono wersji".into(),
|
||||
}
|
||||
}
|
||||
fn internal(message: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for ApiError {
|
||||
fn from(error: sqlx::Error) -> Self {
|
||||
tracing::error!(%error, "database error");
|
||||
Self::internal("Błąd bazy danych")
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.status, Json(serde_json::json!({"error": self.message}))).into_response()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user