first commit

This commit is contained in:
Mateusz Gruszczyński
2026-07-17 15:29:08 +02:00
commit 771494671b
35 changed files with 5020 additions and 0 deletions
+500
View File
@@ -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(&note.created_at),
updated_at: db::normalize_timestamp(&note.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(&note.created_at),
updated_at: db::normalize_timestamp(&note.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, &note_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(&note.created_at),
updated_at: db::normalize_timestamp(&note.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, &note_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, &note_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, &note_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()
}
}