953 lines
35 KiB
Rust
953 lines
35 KiB
Rust
use axum::{
|
|
extract::{Multipart, Path, State},
|
|
http::{header, HeaderMap, HeaderValue, StatusCode},
|
|
response::{IntoResponse, Response},
|
|
Json,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use chrono::{Duration, Utc};
|
|
use rand_core::{OsRng, RngCore};
|
|
use sha2::{Digest, Sha256};
|
|
use slug::slugify;
|
|
|
|
use crate::{
|
|
db, queries,
|
|
state::{NoteUpdate, RoomEvent, 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, Serialize)]
|
|
pub struct PublishResponse {
|
|
url: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct PublicPageResponse {
|
|
title: String,
|
|
content: String,
|
|
updated_at: String,
|
|
allow_task_updates: bool,
|
|
}
|
|
|
|
#[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>,
|
|
#[serde(default)]
|
|
access_token: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PublishRequest {
|
|
#[serde(default)]
|
|
password: Option<String>,
|
|
#[serde(default)]
|
|
access_token: Option<String>,
|
|
#[serde(default)]
|
|
allow_task_updates: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PublicTaskUpdateRequest {
|
|
source_line: usize,
|
|
checked: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateNoteRequest {
|
|
name: String,
|
|
#[serde(default)]
|
|
password: Option<String>,
|
|
#[serde(default)]
|
|
access_token: Option<String>,
|
|
#[serde(default)]
|
|
protect: bool,
|
|
#[serde(default)]
|
|
created_by: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct RestoreRequest {
|
|
#[serde(default)]
|
|
password: Option<String>,
|
|
#[serde(default)]
|
|
access_token: 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,
|
|
protected: bool,
|
|
created_by: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct NoteInfo {
|
|
workspace_slug: String,
|
|
workspace_title: String,
|
|
slug: String,
|
|
title: String,
|
|
protected: bool,
|
|
note_protected: bool,
|
|
allow_public_task_updates: bool,
|
|
created_at: String,
|
|
updated_at: String,
|
|
}
|
|
|
|
pub async fn create_workspace(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(payload): Json<CreateWorkspaceRequest>,
|
|
) -> Result<(StatusCode, Json<CreateWorkspaceResponse>), ApiError> {
|
|
let title = validate_name(&payload.name, "Workspace name")?;
|
|
let password = validate_password(payload.password.as_deref())?;
|
|
let slug = unique_workspace_slug(&state, title).await?;
|
|
|
|
let workspace = db::create_workspace(&state.db, &slug, title, password).await?;
|
|
if let Some(user) = crate::auth::optional_user(&state, &headers).await.map_err(|e| ApiError::forbidden(&e.message))? {
|
|
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_WORKSPACE)).bind(user.id).bind(&workspace.slug).execute(state.db.pool()).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(), payload.access_token.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),
|
|
protected: note.protected,
|
|
created_by: note.created_by,
|
|
})
|
|
.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(), payload.access_token.as_deref()).await?;
|
|
let title = validate_name(&payload.name, "Note name")?;
|
|
let base = slugify(title);
|
|
if base.is_empty() {
|
|
return Err(ApiError::bad_request("The name cannot be converted into a valid address"));
|
|
}
|
|
|
|
let slug = unique_note_slug(&state, workspace.id, &base).await?;
|
|
let created_by = payload.created_by.as_deref().map(str::trim).filter(|v| !v.is_empty()).map(|v| v.chars().take(40).collect::<String>());
|
|
let note = db::create_note(&state.db, workspace.id, &slug, title, payload.protect, created_by.as_deref()).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),
|
|
protected: note.protected,
|
|
created_by: note.created_by,
|
|
}),
|
|
))
|
|
}
|
|
|
|
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(),
|
|
note_protected: note.protected,
|
|
allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?,
|
|
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(), payload.access_token.as_deref()).await?;
|
|
let _ = workspace;
|
|
let revisions = db::list_revisions(&state.db, note.id)
|
|
.await?
|
|
.into_iter()
|
|
.map(|mut revision| {
|
|
revision.created_at = db::normalize_timestamp(&revision.created_at);
|
|
revision
|
|
})
|
|
.collect();
|
|
Ok(Json(revisions))
|
|
}
|
|
|
|
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(), payload.access_token.as_deref()).await?;
|
|
let content: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q028))
|
|
.bind(payload.revision_id)
|
|
.bind(note.id)
|
|
.fetch_optional(state.db.pool())
|
|
.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, 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(RoomEvent::Document(update));
|
|
Ok(Json(serde_json::json!({"ok": true})))
|
|
}
|
|
|
|
pub async fn authorized_workspace(
|
|
state: &SharedState,
|
|
slug: &str,
|
|
password: Option<&str>,
|
|
access_token: Option<&str>,
|
|
) -> Result<db::Workspace, ApiError> {
|
|
let workspace = db::find_workspace(&state.db, slug)
|
|
.await?
|
|
.ok_or_else(ApiError::not_found_workspace)?;
|
|
let token_access = verify_resource_access_token(state, "workspace", slug, access_token).await?;
|
|
if workspace.is_private != 0 && !token_access { return Err(ApiError::forbidden("This workspace is private.")); }
|
|
if workspace.password_hash.is_some() && !db::verify_workspace_password(&workspace, password) && !token_access {
|
|
return Err(ApiError::unauthorized());
|
|
}
|
|
Ok(workspace)
|
|
}
|
|
|
|
async fn authorized_note(
|
|
state: &SharedState,
|
|
workspace_slug: &str,
|
|
note_slug: &str,
|
|
password: Option<&str>,
|
|
access_token: Option<&str>,
|
|
) -> Result<(db::Workspace, db::Note), ApiError> {
|
|
let workspace = authorized_workspace(state, workspace_slug, password, access_token).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} must contain between 1 and {MAX_NAME_LENGTH} characters"
|
|
)));
|
|
}
|
|
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(
|
|
"Password must contain between 8 and 128 characters",
|
|
));
|
|
}
|
|
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("The name cannot be converted into a valid address"));
|
|
}
|
|
|
|
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("Failed to create a unique address"))
|
|
}
|
|
|
|
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("Failed to create a unique address"))
|
|
}
|
|
|
|
|
|
#[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,
|
|
allow_public_task_updates: bool,
|
|
created_at: String,
|
|
updated_at: String,
|
|
}
|
|
|
|
pub async fn create_pad(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(payload): Json<CreatePadRequest>,
|
|
) -> Result<(StatusCode, Json<CreatePadResponse>), ApiError> {
|
|
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("The name cannot be converted into a valid address"));
|
|
}
|
|
let slug = unique_pad_slug(&state, &base).await?;
|
|
let pad = db::create_pad(&state.db, &slug, title, password).await?;
|
|
if let Some(user) = crate::auth::optional_user(&state, &headers).await.map_err(|e| ApiError::forbidden(&e.message))? {
|
|
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD)).bind(user.id).bind(&pad.slug).execute(state.db.pool()).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(),
|
|
allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?,
|
|
created_at: db::normalize_timestamp(&pad.created_at),
|
|
updated_at: db::normalize_timestamp(&pad.updated_at),
|
|
}))
|
|
}
|
|
|
|
pub async fn publish_pad_page(
|
|
State(state): State<SharedState>,
|
|
Path(slug): Path<String>,
|
|
Json(payload): Json<PublishRequest>,
|
|
) -> Result<Json<PublishResponse>, ApiError> {
|
|
let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?;
|
|
let token = db::publish_pad(&state.db, pad.id).await?;
|
|
db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).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<PublishRequest>,
|
|
) -> Result<Json<PublishResponse>, ApiError> {
|
|
let (_, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?;
|
|
let token = db::publish_note(&state.db, note.id).await?;
|
|
db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).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),
|
|
allow_task_updates: page.allow_task_updates,
|
|
}))
|
|
}
|
|
|
|
pub async fn update_public_task(
|
|
State(state): State<SharedState>,
|
|
Path(token): Path<String>,
|
|
Json(payload): Json<PublicTaskUpdateRequest>,
|
|
) -> Result<Json<PublicPageResponse>, ApiError> {
|
|
let current = db::find_published_page(&state.db, &token).await?.ok_or_else(ApiError::not_found_note)?;
|
|
if !current.allow_task_updates { return Err(ApiError::forbidden("Task updates are disabled for this page")); }
|
|
let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked).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),
|
|
allow_task_updates: page.allow_task_updates,
|
|
}))
|
|
}
|
|
|
|
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(), payload.access_token.as_deref()).await?;
|
|
let revisions = db::list_pad_revisions(&state.db, pad.id)
|
|
.await?
|
|
.into_iter()
|
|
.map(|mut revision| {
|
|
revision.created_at = db::normalize_timestamp(&revision.created_at);
|
|
revision
|
|
})
|
|
.collect();
|
|
Ok(Json(revisions))
|
|
}
|
|
|
|
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(), payload.access_token.as_deref()).await?;
|
|
let content: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q029))
|
|
.bind(payload.revision_id)
|
|
.bind(pad.id)
|
|
.fetch_optional(state.db.pool())
|
|
.await?;
|
|
let content = content.ok_or_else(ApiError::not_found_revision)?;
|
|
let owner_map: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q030))
|
|
.bind(payload.revision_id)
|
|
.bind(pad.id)
|
|
.fetch_optional(state.db.pool())
|
|
.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(RoomEvent::Document(update));
|
|
Ok(Json(serde_json::json!({"ok": true})))
|
|
}
|
|
|
|
async fn authorized_pad(
|
|
state: &SharedState,
|
|
slug: &str,
|
|
password: Option<&str>,
|
|
access_token: Option<&str>,
|
|
) -> Result<db::Pad, ApiError> {
|
|
let pad = db::find_pad(&state.db, slug)
|
|
.await?
|
|
.ok_or_else(ApiError::not_found_note)?;
|
|
let token_access = verify_resource_access_token(state, "pad", slug, access_token).await?;
|
|
if pad.is_private != 0 && !token_access { return Err(ApiError::forbidden("This note is private.")); }
|
|
if pad.password_hash.is_some() && !db::verify_pad_password(&pad, password) && !token_access {
|
|
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("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 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()).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 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>,
|
|
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()).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 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 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()).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 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>,
|
|
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()).await?;
|
|
if note.protected { return Err(ApiError::bad_request("This note is protected and cannot be deleted")); }
|
|
db::delete_note(&state.db, note.id).await?;
|
|
Ok(Json(serde_json::json!({"ok": true})))
|
|
}
|
|
|
|
pub async fn note_files(
|
|
State(state): State<SharedState>,
|
|
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()).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>,
|
|
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()).await?;
|
|
if workspace.password_hash.is_none() || payload.password.as_deref().unwrap_or_default().is_empty() {
|
|
return Err(ApiError::unauthorized());
|
|
}
|
|
let file = db::find_note_file(&state.db, note.id, file_id).await?
|
|
.ok_or_else(ApiError::not_found_file)?;
|
|
let relative = file.url.trim_start_matches('/').split('/').collect::<Vec<_>>();
|
|
if relative.len() == 3 && relative[0] == "f" {
|
|
let key = crate::storage::object_key("notes", note.id, relative[1], &sanitize_filename(relative[2]));
|
|
state.storage.delete(&key).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(
|
|
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() }
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct AccessTokenRequest {
|
|
kind: String,
|
|
slug: String,
|
|
password: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct AccessTokenResponse {
|
|
access_token: String,
|
|
expires_at: String,
|
|
}
|
|
|
|
pub async fn create_resource_access_token(
|
|
State(state): State<SharedState>,
|
|
Json(payload): Json<AccessTokenRequest>,
|
|
) -> Result<Json<AccessTokenResponse>, ApiError> {
|
|
let kind = payload.kind.trim();
|
|
let slug = payload.slug.trim();
|
|
match kind {
|
|
"workspace" => {
|
|
let workspace = db::find_workspace(&state.db, slug).await?.ok_or_else(ApiError::not_found_workspace)?;
|
|
if !db::verify_workspace_password(&workspace, Some(payload.password.as_str())) {
|
|
return Err(ApiError::unauthorized());
|
|
}
|
|
}
|
|
"pad" => {
|
|
let pad = db::find_pad(&state.db, slug).await?.ok_or_else(ApiError::not_found_note)?;
|
|
if !db::verify_pad_password(&pad, Some(payload.password.as_str())) {
|
|
return Err(ApiError::unauthorized());
|
|
}
|
|
}
|
|
_ => return Err(ApiError::bad_request("Invalid resource kind")),
|
|
}
|
|
|
|
let mut bytes = [0u8; 32];
|
|
OsRng.fill_bytes(&mut bytes);
|
|
let token = hex::encode(bytes);
|
|
let expires_at = (Utc::now() + Duration::days(state.anonymous_access_token_ttl_days)).to_rfc3339();
|
|
sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"))
|
|
.bind(hash_access_token(&token))
|
|
.bind(kind)
|
|
.bind(slug)
|
|
.bind(&expires_at)
|
|
.execute(state.db.pool())
|
|
.await?;
|
|
Ok(Json(AccessTokenResponse { access_token: token, expires_at }))
|
|
}
|
|
|
|
pub async fn verify_resource_access_token(
|
|
state: &SharedState,
|
|
kind: &str,
|
|
slug: &str,
|
|
token: Option<&str>,
|
|
) -> Result<bool, ApiError> {
|
|
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
|
|
return Ok(false);
|
|
};
|
|
if crate::auth::resource_permission(state, kind, slug, Some(token)).await.map_err(|error| ApiError::forbidden(&error.message))?.is_some() {
|
|
return Ok(true);
|
|
}
|
|
let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?"))
|
|
.bind(hash_access_token(token))
|
|
.bind(kind)
|
|
.bind(slug)
|
|
.bind(Utc::now().to_rfc3339())
|
|
.fetch_one(state.db.pool())
|
|
.await?;
|
|
Ok(count > 0)
|
|
}
|
|
|
|
fn hash_access_token(token: &str) -> String {
|
|
hex::encode(Sha256::digest(token.as_bytes()))
|
|
}
|
|
|
|
pub struct ApiError {
|
|
status: StatusCode,
|
|
message: String,
|
|
}
|
|
|
|
impl ApiError {
|
|
fn bad_request(message: &str) -> Self {
|
|
Self {
|
|
status: StatusCode::BAD_REQUEST,
|
|
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: "Invalid password".into(),
|
|
}
|
|
}
|
|
fn forbidden(message: &str) -> Self {
|
|
Self { status: StatusCode::FORBIDDEN, message: message.into() }
|
|
}
|
|
fn not_found_workspace() -> Self {
|
|
Self {
|
|
status: StatusCode::NOT_FOUND,
|
|
message: "Workspace not found".into(),
|
|
}
|
|
}
|
|
fn not_found_note() -> Self {
|
|
Self {
|
|
status: StatusCode::NOT_FOUND,
|
|
message: "Note not found".into(),
|
|
}
|
|
}
|
|
fn not_found_revision() -> Self {
|
|
Self {
|
|
status: StatusCode::NOT_FOUND,
|
|
message: "Revision not found".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("Database error")
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for ApiError {
|
|
fn into_response(self) -> Response {
|
|
(self.status, Json(serde_json::json!({"error": self.message}))).into_response()
|
|
}
|
|
}
|