fix in smtp and split rs files

This commit is contained in:
Mateusz Gruszczyński
2026-07-27 22:56:56 +02:00
parent cc3b172e56
commit d6c1c52310
30 changed files with 4327 additions and 4387 deletions
-1787
View File
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
#[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(),
queries::RESOURCE_ACCESS_TOKENS_INSERT,
))
.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(),
queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT,
))
.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()
}
}
+498
View File
@@ -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,
&note_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,
&note_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,
&note_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,
&note_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()
}
}
+27
View File
@@ -0,0 +1,27 @@
use axum::{
Json,
extract::{Multipart, Path, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use chrono::{Duration, Utc};
use rand_core::{OsRng, RngCore};
use serde::{Deserialize, Serialize};
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;
include!("workspace_notes.rs");
include!("pads_public.rs");
include!("files.rs");
include!("access_tokens.rs");
+381
View File
@@ -0,0 +1,381 @@
#[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,
can_delete_files: bool,
global_color: Option<String>,
note_color: Option<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>,
headers: HeaderMap,
Path(slug): Path<String>,
) -> Result<Json<PadInfo>, ApiError> {
let pad = db::find_pad(&state.db, &slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
ensure_private_resource_access(
&state,
"pad",
&pad.slug,
pad.is_private,
bearer_token(&headers),
)
.await?;
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
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),
can_delete_files: crate::auth::is_resource_owner(
&state,
"pad",
&slug,
bearer_token(&headers),
)
.await
.unwrap_or(false),
global_color,
note_color,
}))
}
pub async fn pad_editor_color(
State(state): State<SharedState>,
headers: HeaderMap,
Path(slug): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
Ok(Json(
serde_json::json!({"global_color": global_color, "note_color": note_color}),
))
}
pub async fn note_editor_color(
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, ApiError> {
let (global_color, note_color) = editor_colors(
&state,
&headers,
"note",
&format!("{}/{}", workspace_slug, note_slug),
)
.await?;
Ok(Json(
serde_json::json!({"global_color": global_color, "note_color": note_color}),
))
}
pub async fn set_pad_editor_color(
State(state): State<SharedState>,
headers: HeaderMap,
Path(slug): Path<String>,
Json(payload): Json<EditorColorRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
save_editor_color(&state, &headers, "pad", &slug, payload.color.as_deref()).await
}
pub async fn set_note_editor_color(
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<EditorColorRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
save_editor_color(
&state,
&headers,
"note",
&format!("{}/{}", workspace_slug, note_slug),
payload.color.as_deref(),
)
.await
}
pub async fn publish_pad_page(
State(state): State<SharedState>,
headers: HeaderMap,
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(),
bearer_token(&headers),
)
.await?;
let level = if db::verify_pad_password(&pad, payload.password.as_deref())
|| (pad.is_private == 0 && pad.password_hash.is_none())
{
AccessLevel::Write
} else {
combined_token_access_level(
&state,
"pad",
&slug,
payload.access_token.as_deref(),
bearer_token(&headers),
)
.await?
};
require_write(level)?;
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>,
headers: HeaderMap,
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<PublishRequest>,
) -> Result<Json<PublishResponse>, ApiError> {
let (workspace, note) = authorized_note(
&state,
&workspace_slug,
&note_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)?;
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>,
headers: HeaderMap,
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(),
bearer_token(&headers),
)
.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>,
headers: HeaderMap,
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(),
bearer_token(&headers),
)
.await?;
let level = if db::verify_pad_password(&pad, payload.password.as_deref())
|| (pad.is_private == 0 && pad.password_hash.is_none())
{
AccessLevel::Write
} else {
combined_token_access_level(
&state,
"pad",
&slug,
payload.access_token.as_deref(),
bearer_token(&headers),
)
.await?
};
require_write(level)?;
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>,
bearer: Option<&str>,
) -> Result<db::Pad, ApiError> {
let pad = db::find_pad(&state.db, slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
let token_level = combined_token_access_level(state, "pad", slug, access_token, bearer).await?;
if pad.is_private != 0 && token_level == AccessLevel::None {
return Err(ApiError::forbidden("This note is private."));
}
if pad.password_hash.is_some()
&& !db::verify_pad_password(&pad, password)
&& token_level == AccessLevel::None
{
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"))
}
+714
View File
@@ -0,0 +1,714 @@
#[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,
can_delete_files: bool,
global_color: Option<String>,
note_color: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct EditorColorRequest {
color: Option<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>,
headers: HeaderMap,
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)?;
ensure_private_resource_access(
&state,
"workspace",
&workspace.slug,
workspace.is_private,
bearer_token(&headers),
)
.await?;
Ok(Json(workspace_info_from(&workspace)))
}
pub async fn open_workspace(
State(state): State<SharedState>,
headers: HeaderMap,
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(),
bearer_token(&headers),
)
.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),
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>,
headers: HeaderMap,
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(),
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)?;
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(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
protected: note.protected,
created_by: note.created_by,
}),
))
}
fn clean_editor_color(value: Option<&str>) -> Result<Option<String>, ApiError> {
let Some(value) = value else {
return Ok(None);
};
let value = value.trim();
if value.len() == 7
&& value.starts_with('#')
&& value[1..].chars().all(|c| c.is_ascii_hexdigit())
{
Ok(Some(value.to_ascii_lowercase()))
} else {
Err(ApiError::bad_request("Invalid editor color"))
}
}
async fn editor_colors(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
) -> Result<(Option<String>, Option<String>), ApiError> {
let Some(user) = crate::auth::optional_user(state, headers)
.await
.map_err(|e| ApiError::forbidden(&e.message))?
else {
return Ok((None, None));
};
let global: Option<String> = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::AUTH_EDITOR_COLOR_BY_USER,
))
.bind(user.id)
.fetch_one(state.db.pool())
.await?;
let note: Option<String> = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::RESOURCE_COLOR_BY_USER,
))
.bind(user.id)
.bind(kind)
.bind(slug)
.fetch_optional(state.db.pool())
.await?;
Ok((global, note))
}
async fn save_editor_color(
state: &SharedState,
headers: &HeaderMap,
kind: &str,
slug: &str,
color: Option<&str>,
) -> Result<Json<serde_json::Value>, ApiError> {
let user = crate::auth::optional_user(state, headers)
.await
.map_err(|e| ApiError::forbidden(&e.message))?
.ok_or_else(|| ApiError::forbidden("Log in to save note colors"))?;
let color = clean_editor_color(color)?;
let mut tx = state.db.pool().begin().await?;
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_COLOR_DELETE,
))
.bind(user.id)
.bind(kind)
.bind(slug)
.execute(&mut *tx)
.await?;
if let Some(value) = color.as_deref() {
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_COLOR_INSERT,
))
.bind(user.id)
.bind(kind)
.bind(slug)
.bind(value)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(Json(serde_json::json!({"color": color})))
}
pub async fn note_info(
State(state): State<SharedState>,
headers: HeaderMap,
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)?;
ensure_private_resource_access(
&state,
"workspace",
&workspace.slug,
workspace.is_private,
bearer_token(&headers),
)
.await?;
let note = db::find_note(&state.db, workspace.id, &note_slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
let color_slug = format!("{}/{}", workspace_slug, note_slug);
let (global_color, note_color) = editor_colors(&state, &headers, "note", &color_slug).await?;
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(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
can_delete_files: {
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);
workspace_owner || note_owner
},
global_color,
note_color,
}))
}
pub async fn history(
State(state): State<SharedState>,
headers: HeaderMap,
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(),
payload.access_token.as_deref(),
bearer_token(&headers),
)
.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>,
headers: HeaderMap,
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(),
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)?;
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, &note_slug)
.await
.send(RoomEvent::Document(update));
Ok(Json(serde_json::json!({"ok": true})))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum AccessLevel {
None,
Read,
Write,
}
fn permission_level(permission: Option<&str>) -> AccessLevel {
match permission {
Some("rw") => AccessLevel::Write,
Some("ro") => AccessLevel::Read,
_ => AccessLevel::None,
}
}
async fn anonymous_access_token_valid(
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);
};
let count: i64 = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT,
))
.bind(hash_access_token(token))
.bind(kind)
.bind(slug)
.bind(Utc::now().to_rfc3339())
.fetch_one(state.db.pool())
.await?;
Ok(count > 0)
}
async fn token_access_level(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<AccessLevel, ApiError> {
let permission = crate::auth::resource_permission(state, kind, slug, token)
.await
.map_err(|error| ApiError::forbidden(&error.message))?;
let level = permission_level(permission.as_deref());
if level != AccessLevel::None {
return Ok(level);
}
if anonymous_access_token_valid(state, kind, slug, token).await? {
// A server-issued token created after a correct resource password
// retains the historical read/write semantics of password access.
return Ok(AccessLevel::Write);
}
Ok(AccessLevel::None)
}
async fn combined_token_access_level(
state: &SharedState,
kind: &str,
slug: &str,
access_token: Option<&str>,
bearer: Option<&str>,
) -> Result<AccessLevel, ApiError> {
Ok(std::cmp::max(
token_access_level(state, kind, slug, access_token).await?,
token_access_level(state, kind, slug, bearer).await?,
))
}
fn require_write(level: AccessLevel) -> Result<(), ApiError> {
if level >= AccessLevel::Write {
Ok(())
} else {
Err(ApiError::forbidden("Read-only access."))
}
}
async fn ensure_private_resource_access(
state: &SharedState,
kind: &str,
slug: &str,
is_private: i64,
token: Option<&str>,
) -> Result<(), ApiError> {
if is_private == 0 {
return Ok(());
}
if verify_resource_access_token(state, kind, slug, token).await? {
return Ok(());
}
Err(ApiError::forbidden("This resource is private."))
}
pub async fn authorized_workspace(
state: &SharedState,
slug: &str,
password: Option<&str>,
access_token: Option<&str>,
bearer: Option<&str>,
) -> Result<db::Workspace, ApiError> {
let workspace = db::find_workspace(&state.db, slug)
.await?
.ok_or_else(ApiError::not_found_workspace)?;
let token_level =
combined_token_access_level(state, "workspace", slug, access_token, bearer).await?;
if workspace.is_private != 0 && token_level == AccessLevel::None {
return Err(ApiError::forbidden("This workspace is private."));
}
if workspace.password_hash.is_some()
&& !db::verify_workspace_password(&workspace, password)
&& token_level == AccessLevel::None
{
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>,
bearer: Option<&str>,
) -> Result<(db::Workspace, db::Note), ApiError> {
let workspace =
authorized_workspace(state, workspace_slug, password, access_token, bearer).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"))
}
-496
View File
@@ -1,496 +0,0 @@
use axum::{
Router,
extract::{DefaultBodyLimit, Path, Request, State},
http::{HeaderName, HeaderValue, StatusCode, header},
middleware::{self, Next},
response::{Html, IntoResponse, Response},
routing::{get, post},
};
use tower::{ServiceBuilder, service_fn};
use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer};
use crate::{api, assets, auth, db, state::SharedState, websocket};
use std::convert::Infallible;
pub fn router(
state: SharedState,
static_dir: &str,
upload_max_size_bytes: usize,
asset_cache_max_age_seconds: u64,
) -> Router {
let asset_version = state.asset_version.clone();
let asset_not_found = service_fn(move |_request| {
let asset_version = asset_version.clone();
async move {
Ok::<_, Infallible>(error_response(
StatusCode::NOT_FOUND,
"404",
"File not found",
"The requested application asset does not exist.",
"/",
"Home page",
&asset_version,
))
}
});
let asset_cache_control =
HeaderValue::from_str(&format!("public, max-age={asset_cache_max_age_seconds}"))
.expect("valid asset cache-control header");
Router::new()
.route("/", get(home))
.route("/p/{slug}", get(pad))
.route("/s/{token}", get(public_page))
.route("/w/{workspace_slug}", get(workspace))
.route("/w/{workspace_slug}/n/{note_slug}", get(note))
.route("/errors/private-workspace", get(private_workspace_error))
.route("/health", get(health))
.route("/robots.txt", get(robots_txt))
.route("/favicon.ico", get(favicon))
.route("/f/{token}/{filename}", get(api::download_file))
.route(
"/files/{directory}/{filename}",
get(api::download_legacy_file),
)
.route("/api/auth/identity", post(auth::identity))
.route("/api/access-token", post(api::create_resource_access_token))
.route("/api/auth/register", post(auth::register))
.route("/api/auth/login", post(auth::login))
.route("/api/auth/confirm-account", post(auth::confirm_account))
.route(
"/api/auth/resend-confirmation",
post(auth::resend_confirmation),
)
.route("/api/auth/me", get(auth::me))
.route("/api/auth/profile", post(auth::update_profile))
.route(
"/api/auth/account/delete",
post(auth::request_account_deletion),
)
.route(
"/api/auth/account-action/confirm",
post(auth::confirm_account_action),
)
.route("/api/auth/logout", post(auth::logout))
.route(
"/api/auth/resources",
get(auth::resources)
.put(auth::update_resource)
.delete(auth::delete_resource),
)
.route(
"/api/auth/resources/privacy",
post(auth::set_resource_privacy),
)
.route(
"/api/auth/resources/sharing",
get(auth::resource_sharing)
.post(auth::share_resource_users)
.delete(auth::remove_resource_user),
)
.route(
"/api/auth/resources/share-links",
post(auth::create_share_link)
.put(auth::update_share_link)
.delete(auth::revoke_share_link),
)
.route(
"/share-invitations/{token}/accept",
get(auth::accept_share_invitation),
)
.route("/api/auth/password-reset", post(auth::request_reset))
.route(
"/api/auth/password-reset/confirm",
post(auth::confirm_reset),
)
.route("/api/public/{token}", get(api::public_page))
.route("/api/public/{token}/tasks", post(api::update_public_task))
.route("/api/pads", post(api::create_pad))
.route("/api/pads/{slug}", get(api::pad_info))
.route("/api/pads/{slug}/history", post(api::pad_history))
.route(
"/api/pads/{slug}/editor-color",
get(api::pad_editor_color).post(api::set_pad_editor_color),
)
.route("/api/pads/{slug}/publish", post(api::publish_pad_page))
.route("/api/pads/{slug}/restore", post(api::pad_restore))
.route(
"/api/pads/{slug}/files",
post(api::upload_pad_file).put(api::pad_files),
)
.route(
"/api/pads/{slug}/files/{file_id}",
axum::routing::delete(api::delete_pad_file),
)
.route("/api/workspaces", post(api::create_workspace))
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
.route(
"/api/workspaces/{workspace_slug}/open",
post(api::open_workspace),
)
.route(
"/api/workspaces/{workspace_slug}/notes",
post(api::create_note),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}",
get(api::note_info).delete(api::delete_note),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-color",
get(api::note_editor_color).post(api::set_note_editor_color),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/publish",
post(api::publish_note_page),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/history",
post(api::history),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/restore",
post(api::restore),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/files",
post(api::upload_note_file).put(api::note_files),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/files/{file_id}",
axum::routing::delete(api::delete_note_file),
)
.route("/ws/p/{slug}", get(websocket::upgrade_pad))
.route("/ws/{workspace_slug}/{note_slug}", get(websocket::upgrade))
.route("/static", get(static_not_found))
.route("/static/{*path}", get(static_not_found))
.nest_service(
"/assets",
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
asset_cache_control,
))
.service(ServeDir::new(static_dir).not_found_service(asset_not_found)),
)
.fallback(not_found)
.method_not_allowed_fallback(method_not_allowed)
.layer(DefaultBodyLimit::max(
upload_max_size_bytes.saturating_add(1024 * 1024),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("x-frame-options"),
HeaderValue::from_static("DENY"),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("cross-origin-opener-policy"),
HeaderValue::from_static("same-origin"),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("cross-origin-resource-policy"),
HeaderValue::from_static("same-origin"),
))
.layer(TraceLayer::new_for_http())
.layer(middleware::from_fn(add_non_asset_security_headers))
.with_state(state)
}
async fn add_non_asset_security_headers(request: Request, next: Next) -> Response {
let is_asset = request.uri().path().starts_with("/assets/");
let mut response = next.run(request).await;
if !is_asset {
let headers = response.headers_mut();
headers
.entry("x-content-type-options")
.or_insert(HeaderValue::from_static("nosniff"));
headers
.entry("referrer-policy")
.or_insert(HeaderValue::from_static("strict-origin-when-cross-origin"));
headers
.entry("permissions-policy")
.or_insert(HeaderValue::from_static(
"camera=(), microphone=(), geolocation=(), payment=(), usb=()",
));
}
response
}
async fn private_workspace_error(State(state): State<SharedState>) -> Response {
error_response(
StatusCode::FORBIDDEN,
"403",
"Private workspace",
"You do not have permission to access this private workspace. Ask the owner to share it with your account or use a valid share link.",
"/",
"Home page",
&state.asset_version,
)
}
async fn health() -> &'static str {
"ok"
}
async fn favicon() -> StatusCode {
StatusCode::NO_CONTENT
}
async fn robots_txt() -> Response {
let mut response = "User-agent: *\nDisallow: /f/\nDisallow: /files/\n".into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
response
}
async fn home(State(state): State<SharedState>) -> Response {
assets::render_html(
include_str!("../static/home.html"),
&state.asset_version,
state.registration_enabled,
state.ldap.is_some(),
&state.frontend_log_level,
state.upload_max_size_bytes,
"home",
)
}
async fn pad(State(state): State<SharedState>, Path(slug): Path<String>) -> Response {
match db::find_pad(&state.db, &slug).await {
Ok(Some(pad)) => {
let html = include_str!("../static/pad.html")
.replace("__PAD_TITLE__", &escape_html(&pad.title));
assets::render_html(
&html,
&state.asset_version,
state.registration_enabled,
state.ldap.is_some(),
&state.frontend_log_level,
state.upload_max_size_bytes,
"pad",
)
}
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
"Note not found",
"This note does not exist or has been deleted.",
"/",
"Home page",
&state.asset_version,
),
Err(error) => {
tracing::error!(%error, %slug, "failed to load standalone pad");
internal_error(&state.asset_version)
}
}
}
async fn public_page(State(state): State<SharedState>, Path(token): Path<String>) -> Response {
match db::find_published_page(&state.db, &token).await {
Ok(Some(_)) => assets::render_html(
include_str!("../static/public.html"),
&state.asset_version,
state.registration_enabled,
state.ldap.is_some(),
&state.frontend_log_level,
state.upload_max_size_bytes,
"public",
),
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
"Published page not found",
"The link is invalid or the published page has been removed.",
"/",
"Home page",
&state.asset_version,
),
Err(error) => {
tracing::error!(%error, %token, "failed to load published page");
internal_error(&state.asset_version)
}
}
}
async fn workspace(
State(state): State<SharedState>,
Path(workspace_slug): Path<String>,
) -> Response {
match db::find_workspace(&state.db, &workspace_slug).await {
Ok(Some(workspace)) => {
let html = include_str!("../static/workspace.html")
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title));
assets::render_html(
&html,
&state.asset_version,
state.registration_enabled,
state.ldap.is_some(),
&state.frontend_log_level,
state.upload_max_size_bytes,
"workspace",
)
}
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
"Workspace not found",
"This workspace does not exist or has been deleted.",
"/",
"Home page",
&state.asset_version,
),
Err(error) => {
tracing::error!(%error, %workspace_slug, "failed to load workspace page");
internal_error(&state.asset_version)
}
}
}
async fn note(
State(state): State<SharedState>,
Path((workspace_slug, note_slug)): Path<(String, String)>,
) -> Response {
let workspace = match db::find_workspace(&state.db, &workspace_slug).await {
Ok(Some(workspace)) => workspace,
Ok(None) => {
return error_response(
StatusCode::NOT_FOUND,
"404",
"Workspace not found",
"The workspace for this note does not exist or has been deleted.",
"/",
"Home page",
&state.asset_version,
);
}
Err(error) => {
tracing::error!(%error, %workspace_slug, "failed to load note workspace");
return internal_error(&state.asset_version);
}
};
match db::find_note(&state.db, workspace.id, &note_slug).await {
Ok(Some(note)) => {
let html = include_str!("../static/note.html")
.replace("__NOTE_TITLE__", &escape_html(&note.title))
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title))
.replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug));
assets::render_html(
&html,
&state.asset_version,
state.registration_enabled,
state.ldap.is_some(),
&state.frontend_log_level,
state.upload_max_size_bytes,
"note",
)
}
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
"Note not found",
"This note does not exist or has been deleted.",
&format!("/w/{workspace_slug}"),
"Back do workspace",
&state.asset_version,
),
Err(error) => {
tracing::error!(%error, %workspace_slug, %note_slug, "failed to load note page");
internal_error(&state.asset_version)
}
}
}
async fn static_not_found(State(state): State<SharedState>) -> Response {
error_response(
StatusCode::NOT_FOUND,
"404",
"File not found",
"The requested static file does not exist.",
"/",
"Home page",
&state.asset_version,
)
}
async fn method_not_allowed(State(state): State<SharedState>) -> Response {
error_response(
StatusCode::METHOD_NOT_ALLOWED,
"405",
"Method not allowed",
"This address does not support the requested operation.",
"/",
"Home page",
&state.asset_version,
)
}
async fn not_found(State(state): State<SharedState>) -> Response {
error_response(
StatusCode::NOT_FOUND,
"404",
"Page not found",
"Check the address or return to the home page.",
"/",
"Home page",
&state.asset_version,
)
}
fn internal_error(asset_version: &str) -> Response {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"500",
"Server error",
"The page could not be loaded. Please try again shortly.",
"/",
"Home page",
asset_version,
)
}
fn error_response(
status: StatusCode,
code: &str,
title: &str,
message: &str,
primary_url: &str,
primary_label: &str,
asset_version: &str,
) -> Response {
let html = include_str!("../static/error.html")
.replace(
"__APP_STYLESHEET__",
&assets::stylesheet_tag(asset_version, "styles"),
)
.replace("__ERROR_CODE__", &escape_html(code))
.replace("__ERROR_TITLE__", &escape_html(title))
.replace("__ERROR_MESSAGE__", &escape_html(message))
.replace("__PRIMARY_URL__", &escape_html(primary_url))
.replace("__PRIMARY_LABEL__", &escape_html(primary_label));
let mut response = (status, Html(html)).into_response();
no_store(&mut response);
response
}
fn no_store(response: &mut Response) {
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store"),
);
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
+223
View File
@@ -0,0 +1,223 @@
mod pages;
use pages::*;
use axum::{
Router,
extract::{DefaultBodyLimit, Request},
http::{HeaderName, HeaderValue, StatusCode, header},
middleware::{self, Next},
response::Response,
routing::{get, post},
};
use tower::{ServiceBuilder, service_fn};
use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer};
use crate::{api, auth, state::SharedState, websocket};
use std::convert::Infallible;
pub fn router(
state: SharedState,
static_dir: &str,
upload_max_size_bytes: usize,
asset_cache_max_age_seconds: u64,
) -> Router {
let asset_version = state.asset_version.clone();
let asset_not_found = service_fn(move |_request| {
let asset_version = asset_version.clone();
async move {
Ok::<_, Infallible>(error_response(
StatusCode::NOT_FOUND,
"404",
"File not found",
"The requested application asset does not exist.",
"/",
"Home page",
&asset_version,
))
}
});
let asset_cache_control =
HeaderValue::from_str(&format!("public, max-age={asset_cache_max_age_seconds}"))
.expect("valid asset cache-control header");
Router::new()
.route("/", get(home))
.route("/p/{slug}", get(pad))
.route("/s/{token}", get(public_page))
.route("/w/{workspace_slug}", get(workspace))
.route("/w/{workspace_slug}/n/{note_slug}", get(note))
.route("/errors/private-workspace", get(private_workspace_error))
.route("/health", get(health))
.route("/robots.txt", get(robots_txt))
.route("/favicon.ico", get(favicon))
.route("/f/{token}/{filename}", get(api::download_file))
.route(
"/files/{directory}/{filename}",
get(api::download_legacy_file),
)
.route("/api/auth/identity", post(auth::identity))
.route("/api/access-token", post(api::create_resource_access_token))
.route("/api/auth/register", post(auth::register))
.route("/api/auth/login", post(auth::login))
.route("/api/auth/confirm-account", post(auth::confirm_account))
.route(
"/api/auth/resend-confirmation",
post(auth::resend_confirmation),
)
.route("/api/auth/me", get(auth::me))
.route("/api/auth/profile", post(auth::update_profile))
.route(
"/api/auth/account/delete",
post(auth::request_account_deletion),
)
.route(
"/api/auth/account-action/confirm",
post(auth::confirm_account_action),
)
.route("/api/auth/logout", post(auth::logout))
.route(
"/api/auth/resources",
get(auth::resources)
.put(auth::update_resource)
.delete(auth::delete_resource),
)
.route(
"/api/auth/resources/privacy",
post(auth::set_resource_privacy),
)
.route(
"/api/auth/resources/sharing",
get(auth::resource_sharing)
.post(auth::share_resource_users)
.delete(auth::remove_resource_user),
)
.route(
"/api/auth/resources/share-links",
post(auth::create_share_link)
.put(auth::update_share_link)
.delete(auth::revoke_share_link),
)
.route(
"/share-invitations/{token}/accept",
get(auth::accept_share_invitation),
)
.route("/api/auth/password-reset", post(auth::request_reset))
.route(
"/api/auth/password-reset/confirm",
post(auth::confirm_reset),
)
.route("/api/public/{token}", get(api::public_page))
.route("/api/public/{token}/tasks", post(api::update_public_task))
.route("/api/pads", post(api::create_pad))
.route("/api/pads/{slug}", get(api::pad_info))
.route("/api/pads/{slug}/history", post(api::pad_history))
.route(
"/api/pads/{slug}/editor-color",
get(api::pad_editor_color).post(api::set_pad_editor_color),
)
.route("/api/pads/{slug}/publish", post(api::publish_pad_page))
.route("/api/pads/{slug}/restore", post(api::pad_restore))
.route(
"/api/pads/{slug}/files",
post(api::upload_pad_file).put(api::pad_files),
)
.route(
"/api/pads/{slug}/files/{file_id}",
axum::routing::delete(api::delete_pad_file),
)
.route("/api/workspaces", post(api::create_workspace))
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
.route(
"/api/workspaces/{workspace_slug}/open",
post(api::open_workspace),
)
.route(
"/api/workspaces/{workspace_slug}/notes",
post(api::create_note),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}",
get(api::note_info).delete(api::delete_note),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-color",
get(api::note_editor_color).post(api::set_note_editor_color),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/publish",
post(api::publish_note_page),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/history",
post(api::history),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/restore",
post(api::restore),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/files",
post(api::upload_note_file).put(api::note_files),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/files/{file_id}",
axum::routing::delete(api::delete_note_file),
)
.route("/ws/p/{slug}", get(websocket::upgrade_pad))
.route("/ws/{workspace_slug}/{note_slug}", get(websocket::upgrade))
.route("/static", get(static_not_found))
.route("/static/{*path}", get(static_not_found))
.nest_service(
"/assets",
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
asset_cache_control,
))
.service(ServeDir::new(static_dir).not_found_service(asset_not_found)),
)
.fallback(not_found)
.method_not_allowed_fallback(method_not_allowed)
.layer(DefaultBodyLimit::max(
upload_max_size_bytes.saturating_add(1024 * 1024),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("x-frame-options"),
HeaderValue::from_static("DENY"),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("cross-origin-opener-policy"),
HeaderValue::from_static("same-origin"),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("cross-origin-resource-policy"),
HeaderValue::from_static("same-origin"),
))
.layer(TraceLayer::new_for_http())
.layer(middleware::from_fn(add_non_asset_security_headers))
.with_state(state)
}
async fn add_non_asset_security_headers(request: Request, next: Next) -> Response {
let is_asset = request.uri().path().starts_with("/assets/");
let mut response = next.run(request).await;
if !is_asset {
let headers = response.headers_mut();
headers
.entry("x-content-type-options")
.or_insert(HeaderValue::from_static("nosniff"));
headers
.entry("referrer-policy")
.or_insert(HeaderValue::from_static("strict-origin-when-cross-origin"));
headers
.entry("permissions-policy")
.or_insert(HeaderValue::from_static(
"camera=(), microphone=(), geolocation=(), payment=(), usb=()",
));
}
response
}
+284
View File
@@ -0,0 +1,284 @@
use axum::{
extract::{Path, State},
http::{HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Response},
};
use crate::{assets, db, state::SharedState};
pub(super) async fn private_workspace_error(State(state): State<SharedState>) -> Response {
error_response(
StatusCode::FORBIDDEN,
"403",
"Private workspace",
"You do not have permission to access this private workspace. Ask the owner to share it with your account or use a valid share link.",
"/",
"Home page",
&state.asset_version,
)
}
pub(super) async fn health() -> &'static str {
"ok"
}
pub(super) async fn favicon() -> StatusCode {
StatusCode::NO_CONTENT
}
pub(super) async fn robots_txt() -> Response {
let mut response = "User-agent: *\nDisallow: /f/\nDisallow: /files/\n".into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
response
}
pub(super) async fn home(State(state): State<SharedState>) -> Response {
assets::render_html(
include_str!("../../static/home.html"),
&state.asset_version,
state.registration_enabled,
state.ldap.is_some(),
&state.frontend_log_level,
state.upload_max_size_bytes,
"home",
)
}
pub(super) async fn pad(State(state): State<SharedState>, Path(slug): Path<String>) -> Response {
match db::find_pad(&state.db, &slug).await {
Ok(Some(pad)) => {
let html = include_str!("../../static/pad.html")
.replace("__PAD_TITLE__", &escape_html(&pad.title));
assets::render_html(
&html,
&state.asset_version,
state.registration_enabled,
state.ldap.is_some(),
&state.frontend_log_level,
state.upload_max_size_bytes,
"pad",
)
}
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
"Note not found",
"This note does not exist or has been deleted.",
"/",
"Home page",
&state.asset_version,
),
Err(error) => {
tracing::error!(%error, %slug, "failed to load standalone pad");
internal_error(&state.asset_version)
}
}
}
pub(super) async fn public_page(State(state): State<SharedState>, Path(token): Path<String>) -> Response {
match db::find_published_page(&state.db, &token).await {
Ok(Some(_)) => assets::render_html(
include_str!("../../static/public.html"),
&state.asset_version,
state.registration_enabled,
state.ldap.is_some(),
&state.frontend_log_level,
state.upload_max_size_bytes,
"public",
),
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
"Published page not found",
"The link is invalid or the published page has been removed.",
"/",
"Home page",
&state.asset_version,
),
Err(error) => {
tracing::error!(%error, %token, "failed to load published page");
internal_error(&state.asset_version)
}
}
}
pub(super) async fn workspace(
State(state): State<SharedState>,
Path(workspace_slug): Path<String>,
) -> Response {
match db::find_workspace(&state.db, &workspace_slug).await {
Ok(Some(workspace)) => {
let html = include_str!("../../static/workspace.html")
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title));
assets::render_html(
&html,
&state.asset_version,
state.registration_enabled,
state.ldap.is_some(),
&state.frontend_log_level,
state.upload_max_size_bytes,
"workspace",
)
}
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
"Workspace not found",
"This workspace does not exist or has been deleted.",
"/",
"Home page",
&state.asset_version,
),
Err(error) => {
tracing::error!(%error, %workspace_slug, "failed to load workspace page");
internal_error(&state.asset_version)
}
}
}
pub(super) async fn note(
State(state): State<SharedState>,
Path((workspace_slug, note_slug)): Path<(String, String)>,
) -> Response {
let workspace = match db::find_workspace(&state.db, &workspace_slug).await {
Ok(Some(workspace)) => workspace,
Ok(None) => {
return error_response(
StatusCode::NOT_FOUND,
"404",
"Workspace not found",
"The workspace for this note does not exist or has been deleted.",
"/",
"Home page",
&state.asset_version,
);
}
Err(error) => {
tracing::error!(%error, %workspace_slug, "failed to load note workspace");
return internal_error(&state.asset_version);
}
};
match db::find_note(&state.db, workspace.id, &note_slug).await {
Ok(Some(note)) => {
let html = include_str!("../../static/note.html")
.replace("__NOTE_TITLE__", &escape_html(&note.title))
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title))
.replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug));
assets::render_html(
&html,
&state.asset_version,
state.registration_enabled,
state.ldap.is_some(),
&state.frontend_log_level,
state.upload_max_size_bytes,
"note",
)
}
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
"Note not found",
"This note does not exist or has been deleted.",
&format!("/w/{workspace_slug}"),
"Back do workspace",
&state.asset_version,
),
Err(error) => {
tracing::error!(%error, %workspace_slug, %note_slug, "failed to load note page");
internal_error(&state.asset_version)
}
}
}
pub(super) async fn static_not_found(State(state): State<SharedState>) -> Response {
error_response(
StatusCode::NOT_FOUND,
"404",
"File not found",
"The requested static file does not exist.",
"/",
"Home page",
&state.asset_version,
)
}
pub(super) async fn method_not_allowed(State(state): State<SharedState>) -> Response {
error_response(
StatusCode::METHOD_NOT_ALLOWED,
"405",
"Method not allowed",
"This address does not support the requested operation.",
"/",
"Home page",
&state.asset_version,
)
}
pub(super) async fn not_found(State(state): State<SharedState>) -> Response {
error_response(
StatusCode::NOT_FOUND,
"404",
"Page not found",
"Check the address or return to the home page.",
"/",
"Home page",
&state.asset_version,
)
}
fn internal_error(asset_version: &str) -> Response {
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"500",
"Server error",
"The page could not be loaded. Please try again shortly.",
"/",
"Home page",
asset_version,
)
}
pub(super) fn error_response(
status: StatusCode,
code: &str,
title: &str,
message: &str,
primary_url: &str,
primary_label: &str,
asset_version: &str,
) -> Response {
let html = include_str!("../../static/error.html")
.replace(
"__APP_STYLESHEET__",
&assets::stylesheet_tag(asset_version, "styles"),
)
.replace("__ERROR_CODE__", &escape_html(code))
.replace("__ERROR_TITLE__", &escape_html(title))
.replace("__ERROR_MESSAGE__", &escape_html(message))
.replace("__PRIMARY_URL__", &escape_html(primary_url))
.replace("__PRIMARY_LABEL__", &escape_html(primary_label));
let mut response = (status, Html(html)).into_response();
no_store(&mut response);
response
}
fn no_store(response: &mut Response) {
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store"),
);
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
+2 -2
View File
@@ -20,7 +20,7 @@ use lettre::{
use rand_core::{OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::{any::AnyRow, Row};
use sqlx::{Row, any::AnyRow};
use tracing::{debug, info, warn};
use crate::{
@@ -2113,7 +2113,7 @@ async fn send_message(smtp: &SmtpConfig, message: Message, label: &str) -> Resul
}
.port(smtp.port);
if !smtp.username.is_empty() {
if !smtp.username.trim().is_empty() && !smtp.password.is_empty() {
builder = builder.credentials(Credentials::new(
smtp.username.clone(),
smtp.password.clone(),
-459
View File
@@ -1,459 +0,0 @@
use lettre::message::Mailbox;
use std::{collections::HashMap, env, net::IpAddr, path::Path};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthorizationType {
Local,
Ldap,
Ad,
}
impl AuthorizationType {
fn from_values(values: &ConfigValues) -> Result<Self, Box<dyn std::error::Error>> {
match values.get("AUTHORIZATION_TYPE", "local").trim().to_ascii_lowercase().as_str() {
"local" => Ok(Self::Local),
"ldap" => Ok(Self::Ldap),
"ad" => Ok(Self::Ad),
_ => Err("AUTHORIZATION_TYPE must be one of: local, ldap, ad".into()),
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Local => "local",
Self::Ldap => "ldap",
Self::Ad => "ad",
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub host: IpAddr,
pub port: u16,
pub database_url: String,
pub database_max_connections: u32,
pub static_dir: String,
pub files_dir: String,
pub storage: crate::storage::StorageConfig,
pub upload_max_size_bytes: usize,
pub asset_version: String,
pub asset_cache_max_age_seconds: u64,
pub file_cache_max_age_seconds: u64,
pub smtp: Option<crate::state::SmtpConfig>,
pub registration_enabled: bool,
pub account_confirmation_required: bool,
pub share_confirmation_required: bool,
pub frontend_log_level: String,
pub anonymous_access_token_ttl_days: i64,
pub user_session_ttl_days: i64,
pub unconfirmed_account_ttl_days: i64,
pub authorization_type: AuthorizationType,
pub ldap: Option<crate::auth::ldap::LdapConfig>,
}
impl Config {
pub fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
let values = ConfigValues::load(path)?;
let host = values.get("APP_HOST", "127.0.0.1").parse()?;
let port = values.get("APP_PORT", "3000").parse()?;
let database_max_connections = values.get("DATABASE_MAX_CONNECTIONS", "8").parse()?;
let upload_max_size_mb: usize = values.get("UPLOAD_MAX_SIZE_MB", "20").parse()?;
let anonymous_access_token_ttl_days = values.positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?;
let user_session_ttl_days = values.positive_i64("USER_SESSION_TTL_DAYS", 3)?;
let unconfirmed_account_ttl_days = values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?;
let files_dir = values.get("FILES_DIR", "data/files");
let storage = match values.get("STORAGE_DRIVER", "local").trim().to_ascii_lowercase().as_str() {
"local" => crate::storage::StorageConfig::Local { root: files_dir.clone().into() },
"s3" => crate::storage::StorageConfig::S3 {
endpoint: values.optional("S3_ENDPOINT"),
region: values.get("S3_REGION", "us-east-1"),
bucket: values.required("S3_BUCKET", "STORAGE_DRIVER=s3")?,
access_key: values.required("S3_ACCESS_KEY", "STORAGE_DRIVER=s3")?,
secret_key: values.required("S3_SECRET_KEY", "STORAGE_DRIVER=s3")?,
force_path_style: values.bool("S3_FORCE_PATH_STYLE", false)?,
},
_ => return Err("STORAGE_DRIVER must be local or s3".into()),
};
if upload_max_size_mb == 0 {
return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into());
}
let authorization_type = AuthorizationType::from_values(&values)?;
let ldap = match authorization_type {
AuthorizationType::Local => None,
AuthorizationType::Ldap | AuthorizationType::Ad => {
let context = format!("AUTHORIZATION_TYPE={}", authorization_type.as_str());
let (default_filter, default_username_attribute) = match authorization_type {
AuthorizationType::Ldap => ("(uid={username})", "uid"),
AuthorizationType::Ad => ("(|(sAMAccountName={username})(userPrincipalName={username}))", "sAMAccountName"),
AuthorizationType::Local => unreachable!(),
};
Some(crate::auth::ldap::LdapConfig {
url: values.required("LDAP_URL", &context)?,
starttls: values.bool("LDAP_STARTTLS", false)?,
bind_dn: values.get("LDAP_BIND_DN", ""),
bind_password: values.get("LDAP_BIND_PASSWORD", ""),
base_dn: values.required("LDAP_BASE_DN", &context)?,
user_filter: values.get("LDAP_USER_FILTER", default_filter),
username_attribute: values.get("LDAP_USERNAME_ATTRIBUTE", default_username_attribute),
email_attribute: values.get("LDAP_EMAIL_ATTRIBUTE", "mail"),
display_name_attribute: values.get("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"),
external_id_attribute: values.get("LDAP_EXTERNAL_ID_ATTRIBUTE", match authorization_type {
AuthorizationType::Ldap => "entryUUID",
AuthorizationType::Ad => "objectGUID",
AuthorizationType::Local => unreachable!(),
}),
organization: values.get("LDAP_ORGANIZATION", "organization"),
provider: authorization_type.as_str().to_owned(),
email_required: values.bool("LDAP_EMAIL_REQUIRED", true)?,
link_existing_by_email: values.bool("LDAP_LINK_EXISTING_BY_EMAIL", false)?,
tls_verify: values.bool("LDAP_TLS_VERIFY", true)?,
connect_timeout_seconds: values.positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?,
operation_timeout_seconds: values.positive_u64("LDAP_OPERATION_TIMEOUT_SECONDS", 10)?,
})
}
};
let smtp = if let Some(host) = values.optional("SMTP_HOST") {
Some(crate::state::SmtpConfig {
host,
port: values.get("SMTP_PORT", "587").parse()?,
security: parse_smtp_security(&values.get(
"SMTP_SECURITY",
if values.get("SMTP_PORT", "587") == "465" { "tls" } else { "starttls" },
))?,
username: values.get("SMTP_USERNAME", ""),
password: values.get("SMTP_PASSWORD", ""),
from: normalize_smtp_from(
values.required("SMTP_FROM", "SMTP_HOST is set")?,
)?,
public_url: values.required("PUBLIC_URL", "SMTP_HOST is set")?,
})
} else {
None
};
let config = Self {
host,
port,
database_url: values.get("DATABASE_URL", "sqlite:///data/db/rustpad.db?mode=rwc"),
database_max_connections,
static_dir: values.get("STATIC_DIR", "static"),
files_dir,
storage,
upload_max_size_bytes: upload_max_size_mb.checked_mul(1024 * 1024).ok_or("UPLOAD_MAX_SIZE_MB is too large")?,
asset_version: env!("CARGO_PKG_VERSION").to_owned(),
asset_cache_max_age_seconds: values.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
file_cache_max_age_seconds: values.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?,
smtp,
registration_enabled: values.bool("REGISTRATION_ENABLED", false)?,
account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
share_confirmation_required: values.bool("SHARE_CONFIRMATION_REQUIRED", false)?,
frontend_log_level: values.log_level("FRONTEND_LOG_LEVEL", "warn")?,
anonymous_access_token_ttl_days,
user_session_ttl_days,
unconfirmed_account_ttl_days,
authorization_type,
ldap,
};
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<(), Box<dyn std::error::Error>> {
if !(self.database_url.starts_with("sqlite:")
|| self.database_url.starts_with("postgres:")
|| self.database_url.starts_with("postgresql:")
|| self.database_url.starts_with("mysql:"))
{
return Err("DATABASE_URL must use sqlite, postgres/postgresql, or mysql".into());
}
if self.database_max_connections == 0 {
return Err("DATABASE_MAX_CONNECTIONS must be greater than 0".into());
}
if self.static_dir.trim().is_empty() || self.files_dir.trim().is_empty() {
return Err("STATIC_DIR and FILES_DIR cannot be empty".into());
}
if let Some(smtp) = &self.smtp {
smtp.from
.parse::<Mailbox>()
.map_err(|error| format!("SMTP_FROM is not a valid mailbox: {error}"))?;
if !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://")) {
return Err("PUBLIC_URL must start with http:// or https://".into());
}
}
Ok(())
}
}
fn parse_smtp_security(value: &str) -> Result<crate::state::SmtpSecurity, Box<dyn std::error::Error>> {
match value.trim().to_ascii_lowercase().as_str() {
"none" | "plain" => Ok(crate::state::SmtpSecurity::None),
"starttls" => Ok(crate::state::SmtpSecurity::StartTls),
"tls" | "ssl" | "smtps" => Ok(crate::state::SmtpSecurity::Tls),
_ => Err("SMTP_SECURITY must be one of: none, starttls, tls".into()),
}
}
fn normalize_smtp_from(value: String) -> Result<String, Box<dyn std::error::Error>> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("SMTP_FROM cannot be empty".into());
}
if trimmed.parse::<Mailbox>().is_ok() {
return Ok(trimmed.to_owned());
}
let unquoted = match (trimmed.as_bytes().first(), trimmed.as_bytes().last()) {
(Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) if trimmed.len() >= 2 => {
trimmed[1..trimmed.len() - 1].trim()
}
_ => {
return Err(format!("SMTP_FROM is not a valid mailbox: {trimmed}").into());
}
};
if unquoted.is_empty() {
return Err("SMTP_FROM cannot be empty".into());
}
unquoted
.parse::<Mailbox>()
.map_err(|error| format!("SMTP_FROM is not a valid mailbox: {error}"))?;
Ok(unquoted.to_owned())
}
const KNOWN_CONFIG_KEYS: &[&str] = &[
"APP_HOST", "APP_PORT", "DATABASE_URL", "DATABASE_MAX_CONNECTIONS",
"STATIC_DIR", "FILES_DIR", "STORAGE_DRIVER", "UPLOAD_MAX_SIZE_MB",
"ASSET_CACHE_MAX_AGE_SECONDS", "FILE_CACHE_MAX_AGE_SECONDS",
"REGISTRATION_ENABLED", "ACCOUNT_CONFIRMATION_REQUIRED", "SHARE_CONFIRMATION_REQUIRED",
"FRONTEND_LOG_LEVEL", "ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", "USER_SESSION_TTL_DAYS",
"UNCONFIRMED_ACCOUNT_TTL_DAYS", "AUTHORIZATION_TYPE",
"S3_ENDPOINT", "S3_REGION", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY",
"S3_FORCE_PATH_STYLE", "SMTP_HOST", "SMTP_PORT", "SMTP_SECURITY", "SMTP_USERNAME", "SMTP_PASSWORD",
"SMTP_FROM", "PUBLIC_URL", "LDAP_URL", "LDAP_STARTTLS", "LDAP_BIND_DN",
"LDAP_BIND_PASSWORD", "LDAP_BASE_DN", "LDAP_USER_FILTER", "LDAP_USERNAME_ATTRIBUTE",
"LDAP_EMAIL_ATTRIBUTE", "LDAP_DISPLAY_NAME_ATTRIBUTE", "LDAP_EXTERNAL_ID_ATTRIBUTE",
"LDAP_ORGANIZATION", "LDAP_EMAIL_REQUIRED", "LDAP_LINK_EXISTING_BY_EMAIL",
"LDAP_TLS_VERIFY", "LDAP_CONNECT_TIMEOUT_SECONDS", "LDAP_OPERATION_TIMEOUT_SECONDS",
];
#[derive(Default)]
struct ConfigValues {
file: HashMap<String, String>,
}
impl ConfigValues {
fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
let Some(path) = path else { return Ok(Self::default()); };
let content = std::fs::read_to_string(path)
.map_err(|error| format!("cannot read config file {}: {error}", path.display()))?;
let file = parse_yaml_config(&content)
.map_err(|error| format!("invalid YAML in {}: {error}", path.display()))?;
for key in file.keys() {
if !KNOWN_CONFIG_KEYS.contains(&key.as_str()) {
return Err(format!("unknown configuration key: {key}").into());
}
}
Ok(Self { file })
}
fn get(&self, name: &str, default: &str) -> String {
env::var(name).ok().or_else(|| self.file.get(name).cloned()).unwrap_or_else(|| default.to_owned())
}
fn optional(&self, name: &str) -> Option<String> {
env::var(name).ok().or_else(|| self.file.get(name).cloned()).filter(|value| !value.trim().is_empty())
}
fn required(&self, name: &str, context: &str) -> Result<String, Box<dyn std::error::Error>> {
self.optional(name).ok_or_else(|| format!("{name} is required when {context}").into())
}
fn bool(&self, name: &str, default: bool) -> Result<bool, Box<dyn std::error::Error>> {
match self.get(name, if default { "true" } else { "false" }).trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => Err(format!("{name} must be true or false").into()),
}
}
fn log_level(&self, name: &str, default: &str) -> Result<String, Box<dyn std::error::Error>> {
let value = self.get(name, default).trim().to_ascii_lowercase();
match value.as_str() {
"off" | "error" | "warn" | "info" | "debug" => Ok(value),
_ => Err(format!("{name} must be one of: off, error, warn, info, debug").into()),
}
}
fn positive_i64(&self, name: &str, default: i64) -> Result<i64, Box<dyn std::error::Error>> {
let value: i64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be an integer"))?;
if value <= 0 { return Err(format!("{name} must be greater than 0").into()); }
Ok(value)
}
fn positive_u64(&self, name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
let value: u64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer"))?;
if value == 0 { return Err(format!("{name} must be greater than 0").into()); }
Ok(value)
}
fn nonnegative_u64(&self, name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer").into())
}
}
fn parse_yaml_config(content: &str) -> Result<HashMap<String, String>, String> {
let mut output = HashMap::new();
let mut sections: Vec<(usize, String)> = Vec::new();
for (index, original) in content.lines().enumerate() {
let line_number = index + 1;
if original.contains('\t') {
return Err(format!("line {line_number}: tabs are not allowed for indentation"));
}
let without_comment = strip_yaml_comment(original);
if without_comment.trim().is_empty() || without_comment.trim() == "---" {
continue;
}
let indent = without_comment.len() - without_comment.trim_start().len();
let line = without_comment.trim();
let (raw_key, raw_value) = line
.split_once(':')
.ok_or_else(|| format!("line {line_number}: expected key: value"))?;
let key = raw_key.trim();
if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
return Err(format!("line {line_number}: invalid key {key:?}"));
}
while sections.last().is_some_and(|(section_indent, _)| *section_indent >= indent) {
sections.pop();
}
let normalized = key.to_ascii_uppercase().replace('-', "_");
let value = raw_value.trim();
if value.is_empty() {
sections.push((indent, normalized));
continue;
}
if matches!(value.chars().next(), Some('[' | '{' | '|' | '>' | '&' | '*' | '!')) {
return Err(format!("line {line_number}: only scalar values and nested mappings are supported"));
}
let mut path: Vec<&str> = sections.iter().map(|(_, key)| key.as_str()).collect();
path.push(&normalized);
let full_key = path.join("_");
let parsed_value = parse_yaml_scalar(value)
.map_err(|error| format!("line {line_number}: {error}"))?;
if output.insert(full_key.clone(), parsed_value).is_some() {
return Err(format!("line {line_number}: duplicate key {full_key}"));
}
}
Ok(output)
}
fn strip_yaml_comment(line: &str) -> &str {
let mut single = false;
let mut double = false;
let mut escaped = false;
for (index, character) in line.char_indices() {
if escaped {
escaped = false;
continue;
}
match character {
'\\' if double => escaped = true,
'\'' if !double => single = !single,
'"' if !single => double = !double,
'#' if !single && !double => return &line[..index],
_ => {}
}
}
line
}
fn parse_yaml_scalar(value: &str) -> Result<String, String> {
if value.starts_with('"') {
if !value.ends_with('"') || value.len() < 2 {
return Err("unterminated double-quoted value".to_owned());
}
return serde_json::from_str::<String>(value)
.map_err(|error| format!("invalid double-quoted value: {error}"));
}
if value.starts_with('\'') {
if !value.ends_with('\'') || value.len() < 2 {
return Err("unterminated single-quoted value".to_owned());
}
return Ok(value[1..value.len() - 1].replace("''", "'"));
}
if value.eq_ignore_ascii_case("null") || value == "~" {
return Ok(String::new());
}
Ok(value.to_owned())
}
#[cfg(test)]
mod tests {
use super::{normalize_smtp_from, parse_smtp_security};
use crate::state::SmtpSecurity;
#[test]
fn smtp_security_accepts_supported_modes() {
assert_eq!(parse_smtp_security("none").unwrap(), SmtpSecurity::None);
assert_eq!(parse_smtp_security("starttls").unwrap(), SmtpSecurity::StartTls);
assert_eq!(parse_smtp_security("tls").unwrap(), SmtpSecurity::Tls);
}
#[test]
fn smtp_security_rejects_unknown_mode() {
assert!(parse_smtp_security("auto").is_err());
}
#[test]
fn smtp_from_accepts_unquoted_value() {
assert_eq!(
normalize_smtp_from("RustPad <rustpad@notes.example>".to_owned()).unwrap(),
"RustPad <rustpad@notes.example>"
);
}
#[test]
fn smtp_from_removes_matching_double_quotes() {
assert_eq!(
normalize_smtp_from(" \"RustPad <rustpad@notes.example>\" ".to_owned()).unwrap(),
"RustPad <rustpad@notes.example>"
);
}
#[test]
fn smtp_from_removes_matching_single_quotes() {
assert_eq!(
normalize_smtp_from(" 'RustPad <rustpad@notes.example>' ".to_owned()).unwrap(),
"RustPad <rustpad@notes.example>"
);
}
#[test]
fn smtp_from_preserves_valid_quoted_display_name() {
assert_eq!(
normalize_smtp_from("\"Rust, Pad\" <rustpad@notes.example>".to_owned()).unwrap(),
"\"Rust, Pad\" <rustpad@notes.example>"
);
}
#[test]
fn smtp_from_rejects_unmatched_quotes() {
assert!(normalize_smtp_from("\"RustPad <rustpad@notes.example>".to_owned()).is_err());
}
#[test]
fn smtp_from_rejects_invalid_mailbox() {
assert!(normalize_smtp_from("RustPad".to_owned()).is_err());
}
}
+176
View File
@@ -0,0 +1,176 @@
mod smtp;
mod values;
use std::{net::IpAddr, path::Path};
use smtp::load_smtp;
use values::ConfigValues;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthorizationType {
Local,
Ldap,
Ad,
}
impl AuthorizationType {
fn from_values(values: &ConfigValues) -> Result<Self, Box<dyn std::error::Error>> {
match values.get("AUTHORIZATION_TYPE", "local").trim().to_ascii_lowercase().as_str() {
"local" => Ok(Self::Local),
"ldap" => Ok(Self::Ldap),
"ad" => Ok(Self::Ad),
_ => Err("AUTHORIZATION_TYPE must be one of: local, ldap, ad".into()),
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Local => "local",
Self::Ldap => "ldap",
Self::Ad => "ad",
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub host: IpAddr,
pub port: u16,
pub database_url: String,
pub database_max_connections: u32,
pub static_dir: String,
pub files_dir: String,
pub storage: crate::storage::StorageConfig,
pub upload_max_size_bytes: usize,
pub asset_version: String,
pub asset_cache_max_age_seconds: u64,
pub file_cache_max_age_seconds: u64,
pub smtp: Option<crate::state::SmtpConfig>,
pub registration_enabled: bool,
pub account_confirmation_required: bool,
pub share_confirmation_required: bool,
pub frontend_log_level: String,
pub anonymous_access_token_ttl_days: i64,
pub user_session_ttl_days: i64,
pub unconfirmed_account_ttl_days: i64,
pub authorization_type: AuthorizationType,
pub ldap: Option<crate::auth::ldap::LdapConfig>,
}
impl Config {
pub fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
let values = ConfigValues::load(path)?;
let host = values.get("APP_HOST", "127.0.0.1").parse()?;
let port = values.get("APP_PORT", "3000").parse()?;
let database_max_connections = values.get("DATABASE_MAX_CONNECTIONS", "8").parse()?;
let upload_max_size_mb: usize = values.get("UPLOAD_MAX_SIZE_MB", "20").parse()?;
let anonymous_access_token_ttl_days = values.positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?;
let user_session_ttl_days = values.positive_i64("USER_SESSION_TTL_DAYS", 3)?;
let unconfirmed_account_ttl_days = values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?;
let files_dir = values.get("FILES_DIR", "data/files");
let storage = match values.get("STORAGE_DRIVER", "local").trim().to_ascii_lowercase().as_str() {
"local" => crate::storage::StorageConfig::Local { root: files_dir.clone().into() },
"s3" => crate::storage::StorageConfig::S3 {
endpoint: values.optional("S3_ENDPOINT"),
region: values.get("S3_REGION", "us-east-1"),
bucket: values.required("S3_BUCKET", "STORAGE_DRIVER=s3")?,
access_key: values.required("S3_ACCESS_KEY", "STORAGE_DRIVER=s3")?,
secret_key: values.required("S3_SECRET_KEY", "STORAGE_DRIVER=s3")?,
force_path_style: values.bool("S3_FORCE_PATH_STYLE", false)?,
},
_ => return Err("STORAGE_DRIVER must be local or s3".into()),
};
if upload_max_size_mb == 0 {
return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into());
}
let authorization_type = AuthorizationType::from_values(&values)?;
let ldap = match authorization_type {
AuthorizationType::Local => None,
AuthorizationType::Ldap | AuthorizationType::Ad => {
let context = format!("AUTHORIZATION_TYPE={}", authorization_type.as_str());
let (default_filter, default_username_attribute) = match authorization_type {
AuthorizationType::Ldap => ("(uid={username})", "uid"),
AuthorizationType::Ad => ("(|(sAMAccountName={username})(userPrincipalName={username}))", "sAMAccountName"),
AuthorizationType::Local => unreachable!(),
};
Some(crate::auth::ldap::LdapConfig {
url: values.required("LDAP_URL", &context)?,
starttls: values.bool("LDAP_STARTTLS", false)?,
bind_dn: values.get("LDAP_BIND_DN", ""),
bind_password: values.get("LDAP_BIND_PASSWORD", ""),
base_dn: values.required("LDAP_BASE_DN", &context)?,
user_filter: values.get("LDAP_USER_FILTER", default_filter),
username_attribute: values.get("LDAP_USERNAME_ATTRIBUTE", default_username_attribute),
email_attribute: values.get("LDAP_EMAIL_ATTRIBUTE", "mail"),
display_name_attribute: values.get("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"),
external_id_attribute: values.get("LDAP_EXTERNAL_ID_ATTRIBUTE", match authorization_type {
AuthorizationType::Ldap => "entryUUID",
AuthorizationType::Ad => "objectGUID",
AuthorizationType::Local => unreachable!(),
}),
organization: values.get("LDAP_ORGANIZATION", "organization"),
provider: authorization_type.as_str().to_owned(),
email_required: values.bool("LDAP_EMAIL_REQUIRED", true)?,
link_existing_by_email: values.bool("LDAP_LINK_EXISTING_BY_EMAIL", false)?,
tls_verify: values.bool("LDAP_TLS_VERIFY", true)?,
connect_timeout_seconds: values.positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?,
operation_timeout_seconds: values.positive_u64("LDAP_OPERATION_TIMEOUT_SECONDS", 10)?,
})
}
};
let smtp = load_smtp(&values)?;
let config = Self {
host,
port,
database_url: values.get("DATABASE_URL", "sqlite:///data/db/rustpad.db?mode=rwc"),
database_max_connections,
static_dir: values.get("STATIC_DIR", "static"),
files_dir,
storage,
upload_max_size_bytes: upload_max_size_mb.checked_mul(1024 * 1024).ok_or("UPLOAD_MAX_SIZE_MB is too large")?,
asset_version: env!("CARGO_PKG_VERSION").to_owned(),
asset_cache_max_age_seconds: values.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
file_cache_max_age_seconds: values.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?,
smtp,
registration_enabled: values.bool("REGISTRATION_ENABLED", false)?,
account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
share_confirmation_required: values.bool("SHARE_CONFIRMATION_REQUIRED", false)?,
frontend_log_level: values.log_level("FRONTEND_LOG_LEVEL", "warn")?,
anonymous_access_token_ttl_days,
user_session_ttl_days,
unconfirmed_account_ttl_days,
authorization_type,
ldap,
};
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<(), Box<dyn std::error::Error>> {
if !(self.database_url.starts_with("sqlite:")
|| self.database_url.starts_with("postgres:")
|| self.database_url.starts_with("postgresql:")
|| self.database_url.starts_with("mysql:"))
{
return Err("DATABASE_URL must use sqlite, postgres/postgresql, or mysql".into());
}
if self.database_max_connections == 0 {
return Err("DATABASE_MAX_CONNECTIONS must be greater than 0".into());
}
if self.static_dir.trim().is_empty() || self.files_dir.trim().is_empty() {
return Err("STATIC_DIR and FILES_DIR cannot be empty".into());
}
if let Some(smtp) = &self.smtp {
if !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://")) {
return Err("PUBLIC_URL must start with http:// or https://".into());
}
}
Ok(())
}
}
+100
View File
@@ -0,0 +1,100 @@
use lettre::message::Mailbox;
use crate::state::{SmtpConfig, SmtpSecurity};
use super::values::ConfigValues;
pub(super) fn load_smtp(
values: &ConfigValues,
) -> Result<Option<SmtpConfig>, Box<dyn std::error::Error>> {
let Some(host) = values.optional("SMTP_HOST") else {
return Ok(None);
};
let port = values.get("SMTP_PORT", "587").parse()?;
let security = match values.optional("SMTP_SECURITY") {
Some(value) => parse_smtp_security(&value)?,
None => smtp_security_for_port(port),
};
Ok(Some(SmtpConfig {
host,
port,
security,
username: values.get("SMTP_USERNAME", ""),
password: values.get("SMTP_PASSWORD", ""),
from: normalize_smtp_from(values.required("SMTP_FROM", "SMTP_HOST is set")?)?,
public_url: values.required("PUBLIC_URL", "SMTP_HOST is set")?,
}))
}
fn smtp_security_for_port(port: u16) -> SmtpSecurity {
match port {
465 => SmtpSecurity::Tls,
587 => SmtpSecurity::StartTls,
_ => SmtpSecurity::None,
}
}
fn parse_smtp_security(value: &str) -> Result<SmtpSecurity, Box<dyn std::error::Error>> {
match value.trim().to_ascii_lowercase().as_str() {
"none" | "plain" => Ok(SmtpSecurity::None),
"starttls" => Ok(SmtpSecurity::StartTls),
"tls" | "ssl" | "smtps" => Ok(SmtpSecurity::Tls),
_ => Err("SMTP_SECURITY must be one of: none, starttls, tls".into()),
}
}
fn normalize_smtp_from(value: String) -> Result<String, Box<dyn std::error::Error>> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("SMTP_FROM cannot be empty".into());
}
if trimmed.parse::<Mailbox>().is_ok() {
return Ok(trimmed.to_owned());
}
let unquoted = match (trimmed.as_bytes().first(), trimmed.as_bytes().last()) {
(Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) if trimmed.len() >= 2 => {
trimmed[1..trimmed.len() - 1].trim()
}
_ => return Err(format!("SMTP_FROM is not a valid mailbox: {trimmed}").into()),
};
if unquoted.is_empty() {
return Err("SMTP_FROM cannot be empty".into());
}
unquoted
.parse::<Mailbox>()
.map_err(|error| format!("SMTP_FROM is not a valid mailbox: {error}"))?;
Ok(unquoted.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_security_from_standard_ports() {
assert_eq!(smtp_security_for_port(25), SmtpSecurity::None);
assert_eq!(smtp_security_for_port(465), SmtpSecurity::Tls);
assert_eq!(smtp_security_for_port(587), SmtpSecurity::StartTls);
assert_eq!(smtp_security_for_port(2525), SmtpSecurity::None);
}
#[test]
fn accepts_supported_security_modes() {
assert_eq!(parse_smtp_security("none").unwrap(), SmtpSecurity::None);
assert_eq!(parse_smtp_security("starttls").unwrap(), SmtpSecurity::StartTls);
assert_eq!(parse_smtp_security("tls").unwrap(), SmtpSecurity::Tls);
assert!(parse_smtp_security("auto").is_err());
}
#[test]
fn normalizes_smtp_from() {
assert_eq!(
normalize_smtp_from(" \"RustPad <rustpad@notes.example>\" ".to_owned()).unwrap(),
"RustPad <rustpad@notes.example>"
);
assert!(normalize_smtp_from("RustPad".to_owned()).is_err());
}
}
+169
View File
@@ -0,0 +1,169 @@
use std::{collections::HashMap, env, path::Path};
const KNOWN_CONFIG_KEYS: &[&str] = &[
"APP_HOST", "APP_PORT", "DATABASE_URL", "DATABASE_MAX_CONNECTIONS",
"STATIC_DIR", "FILES_DIR", "STORAGE_DRIVER", "UPLOAD_MAX_SIZE_MB",
"ASSET_CACHE_MAX_AGE_SECONDS", "FILE_CACHE_MAX_AGE_SECONDS",
"REGISTRATION_ENABLED", "ACCOUNT_CONFIRMATION_REQUIRED", "SHARE_CONFIRMATION_REQUIRED",
"FRONTEND_LOG_LEVEL", "ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", "USER_SESSION_TTL_DAYS",
"UNCONFIRMED_ACCOUNT_TTL_DAYS", "AUTHORIZATION_TYPE",
"S3_ENDPOINT", "S3_REGION", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY",
"S3_FORCE_PATH_STYLE", "SMTP_HOST", "SMTP_PORT", "SMTP_SECURITY", "SMTP_USERNAME", "SMTP_PASSWORD",
"SMTP_FROM", "PUBLIC_URL", "LDAP_URL", "LDAP_STARTTLS", "LDAP_BIND_DN",
"LDAP_BIND_PASSWORD", "LDAP_BASE_DN", "LDAP_USER_FILTER", "LDAP_USERNAME_ATTRIBUTE",
"LDAP_EMAIL_ATTRIBUTE", "LDAP_DISPLAY_NAME_ATTRIBUTE", "LDAP_EXTERNAL_ID_ATTRIBUTE",
"LDAP_ORGANIZATION", "LDAP_EMAIL_REQUIRED", "LDAP_LINK_EXISTING_BY_EMAIL",
"LDAP_TLS_VERIFY", "LDAP_CONNECT_TIMEOUT_SECONDS", "LDAP_OPERATION_TIMEOUT_SECONDS",
];
#[derive(Default)]
pub(super) struct ConfigValues {
file: HashMap<String, String>,
}
impl ConfigValues {
pub(super) fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
let Some(path) = path else { return Ok(Self::default()); };
let content = std::fs::read_to_string(path)
.map_err(|error| format!("cannot read config file {}: {error}", path.display()))?;
let file = parse_yaml_config(&content)
.map_err(|error| format!("invalid YAML in {}: {error}", path.display()))?;
for key in file.keys() {
if !KNOWN_CONFIG_KEYS.contains(&key.as_str()) {
return Err(format!("unknown configuration key: {key}").into());
}
}
Ok(Self { file })
}
pub(super) fn get(&self, name: &str, default: &str) -> String {
env::var(name).ok().or_else(|| self.file.get(name).cloned()).unwrap_or_else(|| default.to_owned())
}
pub(super) fn optional(&self, name: &str) -> Option<String> {
env::var(name).ok().or_else(|| self.file.get(name).cloned()).filter(|value| !value.trim().is_empty())
}
pub(super) fn required(&self, name: &str, context: &str) -> Result<String, Box<dyn std::error::Error>> {
self.optional(name).ok_or_else(|| format!("{name} is required when {context}").into())
}
pub(super) fn bool(&self, name: &str, default: bool) -> Result<bool, Box<dyn std::error::Error>> {
match self.get(name, if default { "true" } else { "false" }).trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => Err(format!("{name} must be true or false").into()),
}
}
pub(super) fn log_level(&self, name: &str, default: &str) -> Result<String, Box<dyn std::error::Error>> {
let value = self.get(name, default).trim().to_ascii_lowercase();
match value.as_str() {
"off" | "error" | "warn" | "info" | "debug" => Ok(value),
_ => Err(format!("{name} must be one of: off, error, warn, info, debug").into()),
}
}
pub(super) fn positive_i64(&self, name: &str, default: i64) -> Result<i64, Box<dyn std::error::Error>> {
let value: i64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be an integer"))?;
if value <= 0 { return Err(format!("{name} must be greater than 0").into()); }
Ok(value)
}
pub(super) fn positive_u64(&self, name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
let value: u64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer"))?;
if value == 0 { return Err(format!("{name} must be greater than 0").into()); }
Ok(value)
}
pub(super) fn nonnegative_u64(&self, name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer").into())
}
}
fn parse_yaml_config(content: &str) -> Result<HashMap<String, String>, String> {
let mut output = HashMap::new();
let mut sections: Vec<(usize, String)> = Vec::new();
for (index, original) in content.lines().enumerate() {
let line_number = index + 1;
if original.contains('\t') {
return Err(format!("line {line_number}: tabs are not allowed for indentation"));
}
let without_comment = strip_yaml_comment(original);
if without_comment.trim().is_empty() || without_comment.trim() == "---" {
continue;
}
let indent = without_comment.len() - without_comment.trim_start().len();
let line = without_comment.trim();
let (raw_key, raw_value) = line
.split_once(':')
.ok_or_else(|| format!("line {line_number}: expected key: value"))?;
let key = raw_key.trim();
if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
return Err(format!("line {line_number}: invalid key {key:?}"));
}
while sections.last().is_some_and(|(section_indent, _)| *section_indent >= indent) {
sections.pop();
}
let normalized = key.to_ascii_uppercase().replace('-', "_");
let value = raw_value.trim();
if value.is_empty() {
sections.push((indent, normalized));
continue;
}
if matches!(value.chars().next(), Some('[' | '{' | '|' | '>' | '&' | '*' | '!')) {
return Err(format!("line {line_number}: only scalar values and nested mappings are supported"));
}
let mut path: Vec<&str> = sections.iter().map(|(_, key)| key.as_str()).collect();
path.push(&normalized);
let full_key = path.join("_");
let parsed_value = parse_yaml_scalar(value)
.map_err(|error| format!("line {line_number}: {error}"))?;
if output.insert(full_key.clone(), parsed_value).is_some() {
return Err(format!("line {line_number}: duplicate key {full_key}"));
}
}
Ok(output)
}
fn strip_yaml_comment(line: &str) -> &str {
let mut single = false;
let mut double = false;
let mut escaped = false;
for (index, character) in line.char_indices() {
if escaped {
escaped = false;
continue;
}
match character {
'\\' if double => escaped = true,
'\'' if !double => single = !single,
'"' if !single => double = !double,
'#' if !single && !double => return &line[..index],
_ => {}
}
}
line
}
fn parse_yaml_scalar(value: &str) -> Result<String, String> {
if value.starts_with('"') {
if !value.ends_with('"') || value.len() < 2 {
return Err("unterminated double-quoted value".to_owned());
}
return serde_json::from_str::<String>(value)
.map_err(|error| format!("invalid double-quoted value: {error}"));
}
if value.starts_with('\'') {
if !value.ends_with('\'') || value.len() < 2 {
return Err("unterminated single-quoted value".to_owned());
}
return Ok(value[1..value.len() - 1].replace("''", "'"));
}
if value.eq_ignore_ascii_case("null") || value == "~" {
return Ok(String::new());
}
Ok(value.to_owned())
}
-968
View File
@@ -1,968 +0,0 @@
use crate::{
database::{Database, DatabaseKind},
queries,
};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
use chrono::{DateTime, NaiveDateTime, Utc};
use rand_core::{OsRng, RngCore};
use serde::Serialize;
use sqlx::FromRow;
use sqlx::{any::AnyRow, Any, Row, Transaction};
async fn inserted_id(
kind: DatabaseKind,
tx: &mut Transaction<'_, Any>,
table: &str,
) -> Result<i64, sqlx::Error> {
let query = match kind {
DatabaseKind::Sqlite => queries::get(kind, queries::SQLITE_LAST_INSERT_ID),
DatabaseKind::MySql => queries::get(kind, queries::MYSQL_LAST_INSERT_ID),
DatabaseKind::Postgres => match table {
"note_revisions" => queries::get(kind, queries::POSTGRES_NOTE_REVISION_LAST_INSERT_ID),
"revisions" => queries::get(kind, queries::POSTGRES_PAD_REVISION_LAST_INSERT_ID),
_ => unreachable!("unsupported identity table"),
},
};
sqlx::query_scalar(query).fetch_one(&mut **tx).await
}
#[derive(Debug, Clone)]
pub struct Workspace {
pub id: i64,
pub slug: String,
pub title: String,
pub password_hash: Option<String>,
pub created_at: String,
pub updated_at: String,
pub is_private: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct Note {
pub id: i64,
#[serde(skip_serializing)]
pub _workspace_id: i64,
pub slug: String,
pub title: String,
#[serde(skip_serializing)]
pub content: String,
pub created_at: String,
pub updated_at: String,
pub owner_map: String,
pub protected: bool,
pub created_by: Option<String>,
}
#[derive(Debug, Clone, FromRow)]
struct SqliteNote {
id: i64,
workspace_id: i64,
slug: String,
title: String,
content: String,
created_at: String,
updated_at: String,
owner_map: String,
protected: i64,
created_by: Option<String>,
}
impl From<SqliteNote> for Note {
fn from(value: SqliteNote) -> Self {
Self {
id: value.id,
_workspace_id: value.workspace_id,
slug: value.slug,
title: value.title,
content: value.content,
created_at: value.created_at,
updated_at: value.updated_at,
owner_map: value.owner_map,
protected: value.protected != 0,
created_by: value.created_by,
}
}
}
#[derive(Debug, Serialize)]
pub struct Revision {
pub id: i64,
pub content: String,
pub created_at: String,
pub author: Option<String>,
pub owner_map: String,
}
pub async fn find_workspace(pool: &Database, slug: &str) -> Result<Option<Workspace>, sqlx::Error> {
sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001))
.bind(slug)
.fetch_optional(pool.pool())
.await
}
pub async fn create_workspace(
pool: &Database,
slug: &str,
title: &str,
password: Option<&str>,
) -> Result<Workspace, sqlx::Error> {
let password_hash = password
.filter(|value| !value.is_empty())
.map(hash_password);
sqlx::query(queries::get(pool.kind(), queries::Q002))
.bind(slug)
.bind(title)
.bind(password_hash)
.execute(pool.pool())
.await?;
sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001))
.bind(slug)
.fetch_one(pool.pool())
.await
}
pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) -> bool {
match (
&workspace.password_hash,
password.filter(|value| !value.is_empty()),
) {
(None, _) => true,
(Some(hash), Some(password)) => PasswordHash::new(hash)
.ok()
.and_then(|parsed| {
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.ok()
})
.is_some(),
(Some(_), None) => false,
}
}
pub async fn list_notes(pool: &Database, workspace_id: i64) -> Result<Vec<Note>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNote>(queries::get(pool.kind(), queries::Q003))
.bind(workspace_id)
.fetch_all(pool.pool())
.await?
.into_iter()
.map(Note::from)
.collect());
}
sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q003))
.bind(workspace_id)
.fetch_all(pool.pool())
.await
}
pub async fn find_note(
pool: &Database,
workspace_id: i64,
slug: &str,
) -> Result<Option<Note>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNote>(queries::get(pool.kind(), queries::Q004))
.bind(workspace_id)
.bind(slug)
.fetch_optional(pool.pool())
.await?
.map(Note::from));
}
sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004))
.bind(workspace_id)
.bind(slug)
.fetch_optional(pool.pool())
.await
}
pub async fn create_note(
pool: &Database,
workspace_id: i64,
slug: &str,
title: &str,
protected: bool,
created_by: Option<&str>,
) -> Result<Note, sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q005))
.bind(workspace_id)
.bind(slug)
.bind(title)
.bind(protected)
.bind(created_by)
.execute(pool.pool())
.await?;
find_note(pool, workspace_id, slug)
.await?
.ok_or(sqlx::Error::RowNotFound)
}
pub async fn save_revision(
pool: &Database,
note_id: i64,
workspace_id: i64,
content: &str,
author: Option<&str>,
owner_map: &str,
) -> Result<(i64, String), sqlx::Error> {
let mut tx = pool.pool().begin().await?;
sqlx::query(queries::get(pool.kind(), queries::Q006))
.bind(content)
.bind(owner_map)
.bind(note_id)
.execute(&mut *tx)
.await?;
sqlx::query(queries::get(pool.kind(), queries::Q007))
.bind(workspace_id)
.execute(&mut *tx)
.await?;
sqlx::query(queries::get(pool.kind(), queries::Q008))
.bind(note_id)
.bind(content)
.bind(author)
.bind(owner_map)
.execute(&mut *tx)
.await?;
let revision_id = inserted_id(pool.kind(), &mut tx, "note_revisions").await?;
let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q009))
.bind(note_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok((revision_id, updated_at))
}
pub async fn list_revisions(pool: &Database, note_id: i64) -> Result<Vec<Revision>, sqlx::Error> {
sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q010))
.bind(note_id)
.fetch_all(pool.pool())
.await
}
pub fn random_suffix(length: usize) -> String {
const ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789";
let mut bytes = vec![0_u8; length];
let mut rng = OsRng;
rng.fill_bytes(&mut bytes);
bytes
.into_iter()
.map(|value| ALPHABET[(value as usize) % ALPHABET.len()] as char)
.collect()
}
fn hash_password(password: &str) -> String {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.expect("Argon2 hashing should succeed")
.to_string()
}
pub fn normalize_timestamp(value: &str) -> String {
let value = value.trim();
if let Ok(timestamp) = DateTime::parse_from_rfc3339(value) {
return timestamp.with_timezone(&Utc).to_rfc3339();
}
// PostgreSQL renders TEXT timestamps as e.g. `2026-07-20 14:32:10.123456+00`.
// RFC 3339 requires `T` and a colon in the numeric offset.
let mut postgres = value.replacen(' ', "T", 1);
if postgres.len() >= 3 {
let offset_start = postgres.len() - 3;
let offset = &postgres[offset_start..];
if (offset.starts_with('+') || offset.starts_with('-'))
&& offset[1..]
.chars()
.all(|character| character.is_ascii_digit())
{
postgres.push_str(":00");
}
}
if let Ok(timestamp) = DateTime::parse_from_rfc3339(&postgres) {
return timestamp.with_timezone(&Utc).to_rfc3339();
}
for format in ["%Y-%m-%d %H:%M:%S%.f%:z", "%Y-%m-%dT%H:%M:%S%.f%:z"] {
if let Ok(timestamp) = DateTime::parse_from_str(value, format) {
return timestamp.with_timezone(&Utc).to_rfc3339();
}
}
for format in ["%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S%.f"] {
if let Ok(timestamp) = NaiveDateTime::parse_from_str(value, format) {
return timestamp.and_utc().to_rfc3339();
}
}
// Existing rows should still be readable even if they were stored without a zone.
value.to_string()
}
#[derive(Debug, Clone)]
pub struct Pad {
pub id: i64,
pub slug: String,
pub title: String,
pub content: String,
pub password_hash: Option<String>,
pub created_at: String,
pub updated_at: String,
pub owner_map: String,
pub is_private: i64,
}
pub async fn find_pad(pool: &Database, slug: &str) -> Result<Option<Pad>, sqlx::Error> {
sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011))
.bind(slug)
.fetch_optional(pool.pool())
.await
}
pub async fn create_pad(
pool: &Database,
slug: &str,
title: &str,
password: Option<&str>,
) -> Result<Pad, sqlx::Error> {
let password_hash = password
.filter(|value| !value.is_empty())
.map(hash_password);
sqlx::query(queries::get(pool.kind(), queries::Q012))
.bind(slug)
.bind(title)
.bind(password_hash)
.execute(pool.pool())
.await?;
sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011))
.bind(slug)
.fetch_one(pool.pool())
.await
}
pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool {
match (
&pad.password_hash,
password.filter(|value| !value.is_empty()),
) {
(None, _) => true,
(Some(hash), Some(password)) => PasswordHash::new(hash)
.ok()
.and_then(|parsed| {
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.ok()
})
.is_some(),
(Some(_), None) => false,
}
}
pub async fn save_pad_revision(
pool: &Database,
pad_id: i64,
content: &str,
author: Option<&str>,
owner_map: &str,
) -> Result<(i64, String), sqlx::Error> {
let mut tx = pool.pool().begin().await?;
sqlx::query(queries::get(pool.kind(), queries::Q013))
.bind(content)
.bind(owner_map)
.bind(pad_id)
.execute(&mut *tx)
.await?;
sqlx::query(queries::get(pool.kind(), queries::Q014))
.bind(pad_id)
.bind(content)
.bind(author)
.bind(owner_map)
.execute(&mut *tx)
.await?;
let revision_id = inserted_id(pool.kind(), &mut tx, "revisions").await?;
let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q015))
.bind(pad_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok((revision_id, updated_at))
}
pub async fn list_pad_revisions(
pool: &Database,
pad_id: i64,
) -> Result<Vec<Revision>, sqlx::Error> {
sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q016))
.bind(pad_id)
.fetch_all(pool.pool())
.await
}
#[derive(Debug, Clone, Serialize)]
pub struct PublishedPage {
pub token: String,
pub pad_id: Option<i64>,
pub note_id: Option<i64>,
pub allow_task_updates: bool,
pub title: String,
pub content: String,
pub updated_at: String,
}
#[derive(Debug, Clone)]
struct PublishedPageRow {
token: String,
pad_id: Option<i64>,
note_id: Option<i64>,
allow_task_updates: i64,
title: String,
content: String,
updated_at: String,
}
#[derive(Debug, Clone)]
struct PostgresPublishedPageRow {
token: String,
pad_id: Option<i64>,
note_id: Option<i64>,
allow_task_updates: bool,
title: String,
content: String,
updated_at: String,
}
impl From<PostgresPublishedPageRow> for PublishedPage {
fn from(value: PostgresPublishedPageRow) -> Self {
Self {
token: value.token,
pad_id: value.pad_id,
note_id: value.note_id,
allow_task_updates: value.allow_task_updates,
title: value.title,
content: value.content,
updated_at: value.updated_at,
}
}
}
impl From<PublishedPageRow> for PublishedPage {
fn from(value: PublishedPageRow) -> Self {
Self {
token: value.token,
pad_id: value.pad_id,
note_id: value.note_id,
allow_task_updates: value.allow_task_updates != 0,
title: value.title,
content: value.content,
updated_at: value.updated_at,
}
}
}
pub async fn publish_pad(pool: &Database, pad_id: i64) -> Result<String, sqlx::Error> {
if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q017))
.bind(pad_id)
.fetch_optional(pool.pool())
.await?
{
return Ok(token);
}
let token = random_suffix(18);
sqlx::query(queries::get(pool.kind(), queries::Q018))
.bind(&token)
.bind(pad_id)
.execute(pool.pool())
.await?;
Ok(token)
}
pub async fn publish_note(pool: &Database, note_id: i64) -> Result<String, sqlx::Error> {
if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q019))
.bind(note_id)
.fetch_optional(pool.pool())
.await?
{
return Ok(token);
}
let token = random_suffix(18);
sqlx::query(queries::get(pool.kind(), queries::Q020))
.bind(&token)
.bind(note_id)
.execute(pool.pool())
.await?;
Ok(token)
}
pub async fn find_published_page(
pool: &Database,
token: &str,
) -> Result<Option<PublishedPage>, sqlx::Error> {
if pool.kind() == DatabaseKind::Postgres {
return Ok(
sqlx::query_as::<_, PostgresPublishedPageRow>(queries::get(pool.kind(), queries::Q021))
.bind(token)
.fetch_optional(pool.pool())
.await?
.map(Into::into),
);
}
Ok(
sqlx::query_as::<_, PublishedPageRow>(queries::get(pool.kind(), queries::Q021))
.bind(token)
.fetch_optional(pool.pool())
.await?
.map(Into::into),
)
}
pub async fn pad_public_task_updates(pool: &Database, pad_id: i64) -> Result<bool, sqlx::Error> {
if pool.kind() == DatabaseKind::Postgres {
return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q044))
.bind(pad_id)
.fetch_optional(pool.pool())
.await?
.unwrap_or(false));
}
let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q044))
.bind(pad_id)
.fetch_optional(pool.pool())
.await?
.unwrap_or(0);
Ok(value != 0)
}
pub async fn note_public_task_updates(pool: &Database, note_id: i64) -> Result<bool, sqlx::Error> {
if pool.kind() == DatabaseKind::Postgres {
return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q045))
.bind(note_id)
.fetch_optional(pool.pool())
.await?
.unwrap_or(false));
}
let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q045))
.bind(note_id)
.fetch_optional(pool.pool())
.await?
.unwrap_or(0);
Ok(value != 0)
}
pub async fn set_pad_public_task_updates(
pool: &Database,
pad_id: i64,
allow: bool,
) -> Result<(), sqlx::Error> {
publish_pad(pool, pad_id).await?;
let mut query = sqlx::query(queries::get(pool.kind(), queries::Q040));
query = if pool.kind() == DatabaseKind::Postgres {
query.bind(allow)
} else {
query.bind(if allow { 1i64 } else { 0i64 })
};
query.bind(pad_id).execute(pool.pool()).await?;
Ok(())
}
pub async fn set_note_public_task_updates(
pool: &Database,
note_id: i64,
allow: bool,
) -> Result<(), sqlx::Error> {
publish_note(pool, note_id).await?;
let mut query = sqlx::query(queries::get(pool.kind(), queries::Q041));
query = if pool.kind() == DatabaseKind::Postgres {
query.bind(allow)
} else {
query.bind(if allow { 1i64 } else { 0i64 })
};
query.bind(note_id).execute(pool.pool()).await?;
Ok(())
}
pub async fn update_public_task(
pool: &Database,
token: &str,
source_line: usize,
checked: bool,
) -> Result<Option<PublishedPage>, sqlx::Error> {
let Some(mut page) = find_published_page(pool, token).await? else {
return Ok(None);
};
if !page.allow_task_updates || source_line == 0 {
return Ok(Some(page));
}
let mut lines: Vec<String> = page.content.split('\n').map(str::to_owned).collect();
let Some(line) = lines.get_mut(source_line - 1) else {
return Ok(Some(page));
};
let bytes = line.as_bytes();
let mut i = 0usize;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() || !matches!(bytes[i], b'-' | b'*' | b'+') {
return Ok(Some(page));
}
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i + 2 >= bytes.len()
|| bytes[i] != b'['
|| !matches!(bytes[i + 1], b' ' | b'x' | b'X')
|| bytes[i + 2] != b']'
{
return Ok(Some(page));
}
line.replace_range(i + 1..i + 2, if checked { "x" } else { " " });
page.content = lines.join("\n");
if let Some(id) = page.pad_id {
sqlx::query(queries::get(pool.kind(), queries::Q042))
.bind(&page.content)
.bind(id)
.execute(pool.pool())
.await?;
} else if let Some(id) = page.note_id {
sqlx::query(queries::get(pool.kind(), queries::Q043))
.bind(&page.content)
.bind(id)
.execute(pool.pool())
.await?;
}
find_published_page(pool, token).await
}
pub async fn pad_file_token(pool: &Database, pad_id: i64) -> Result<String, sqlx::Error> {
if let Some(token) =
sqlx::query_scalar::<_, Option<String>>(queries::get(pool.kind(), queries::Q022))
.bind(pad_id)
.fetch_one(pool.pool())
.await?
{
return Ok(token);
}
let token = format!("p_{}", random_suffix(24));
sqlx::query(queries::get(pool.kind(), queries::Q023))
.bind(&token)
.bind(pad_id)
.execute(pool.pool())
.await?;
sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q022))
.bind(pad_id)
.fetch_one(pool.pool())
.await
}
pub async fn note_file_token(pool: &Database, note_id: i64) -> Result<String, sqlx::Error> {
if let Some(token) =
sqlx::query_scalar::<_, Option<String>>(queries::get(pool.kind(), queries::Q024))
.bind(note_id)
.fetch_one(pool.pool())
.await?
{
return Ok(token);
}
let token = format!("n_{}", random_suffix(24));
sqlx::query(queries::get(pool.kind(), queries::Q025))
.bind(&token)
.bind(note_id)
.execute(pool.pool())
.await?;
sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q024))
.bind(note_id)
.fetch_one(pool.pool())
.await
}
#[derive(Debug, Clone, Copy)]
pub enum FileOwnerKind {
Pad,
Note,
}
#[derive(Debug, Clone, Copy)]
pub struct FileOwner {
pub kind: FileOwnerKind,
pub id: i64,
}
pub async fn find_file_owner(
pool: &Database,
token: &str,
) -> Result<Option<FileOwner>, sqlx::Error> {
if let Some(id) = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q026))
.bind(token)
.fetch_optional(pool.pool())
.await?
{
return Ok(Some(FileOwner {
kind: FileOwnerKind::Pad,
id,
}));
}
if let Some(id) = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q027))
.bind(token)
.fetch_optional(pool.pool())
.await?
{
return Ok(Some(FileOwner {
kind: FileOwnerKind::Note,
id,
}));
}
Ok(None)
}
#[derive(Debug, Clone, Serialize)]
pub struct NoteFile {
pub id: i64,
pub filename: String,
pub url: String,
pub mime_type: String,
pub size_bytes: i64,
pub created_at: String,
pub is_attached: bool,
pub detached_at: Option<String>,
}
#[derive(Debug, Clone, FromRow)]
struct SqliteNoteFile {
id: i64,
filename: String,
url: String,
mime_type: String,
size_bytes: i64,
created_at: String,
is_attached: i64,
detached_at: Option<String>,
}
impl From<SqliteNoteFile> for NoteFile {
fn from(value: SqliteNoteFile) -> Self {
Self {
id: value.id,
filename: value.filename,
url: value.url,
mime_type: value.mime_type,
size_bytes: value.size_bytes,
created_at: value.created_at,
is_attached: value.is_attached != 0,
detached_at: value.detached_at,
}
}
}
pub async fn delete_note(pool: &Database, note_id: i64) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q031))
.bind(note_id)
.execute(pool.pool())
.await?;
Ok(())
}
pub async fn register_note_file(
pool: &Database,
note_id: i64,
filename: &str,
url: &str,
mime_type: &str,
size_bytes: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q032))
.bind(note_id)
.bind(filename)
.bind(url)
.bind(mime_type)
.bind(size_bytes)
.execute(pool.pool())
.await?;
Ok(())
}
pub async fn list_note_files(pool: &Database, note_id: i64) -> Result<Vec<NoteFile>, sqlx::Error> {
list_files(pool, queries::Q033, note_id).await
}
async fn list_files(
pool: &Database,
query: queries::Query,
owner_id: i64,
) -> Result<Vec<NoteFile>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), query))
.bind(owner_id)
.fetch_all(pool.pool())
.await?
.into_iter()
.map(NoteFile::from)
.collect());
}
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), query))
.bind(owner_id)
.fetch_all(pool.pool())
.await
}
pub async fn set_note_file_attached(
pool: &Database,
file_id: i64,
attached: bool,
) -> Result<(), sqlx::Error> {
let detached_at: Option<String> = if attached {
None
} else {
Some(chrono::Utc::now().to_rfc3339())
};
sqlx::query(queries::get(pool.kind(), queries::Q034))
.bind(attached)
.bind(detached_at)
.bind(file_id)
.execute(pool.pool())
.await?;
Ok(())
}
pub async fn register_pad_file(
pool: &Database,
pad_id: i64,
filename: &str,
url: &str,
mime_type: &str,
size_bytes: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q035))
.bind(pad_id)
.bind(filename)
.bind(url)
.bind(mime_type)
.bind(size_bytes)
.execute(pool.pool())
.await?;
Ok(())
}
pub async fn list_pad_files(pool: &Database, pad_id: i64) -> Result<Vec<NoteFile>, sqlx::Error> {
list_files(pool, queries::Q036, pad_id).await
}
pub async fn set_pad_file_attached(
pool: &Database,
file_id: i64,
attached: bool,
) -> Result<(), sqlx::Error> {
let detached_at: Option<String> = if attached {
None
} else {
Some(chrono::Utc::now().to_rfc3339())
};
sqlx::query(queries::get(pool.kind(), queries::Q037))
.bind(attached)
.bind(detached_at)
.bind(file_id)
.execute(pool.pool())
.await?;
Ok(())
}
pub async fn find_note_file(
pool: &Database,
note_id: i64,
file_id: i64,
) -> Result<Option<NoteFile>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), queries::Q038))
.bind(file_id)
.bind(note_id)
.fetch_optional(pool.pool())
.await?
.map(NoteFile::from));
}
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q038))
.bind(file_id)
.bind(note_id)
.fetch_optional(pool.pool())
.await
}
pub async fn delete_note_file(
pool: &Database,
note_id: i64,
file_id: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q039))
.bind(file_id)
.bind(note_id)
.execute(pool.pool())
.await?;
Ok(())
}
pub async fn find_pad_file(
pool: &Database,
pad_id: i64,
file_id: i64,
) -> Result<Option<NoteFile>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), queries::Q046))
.bind(file_id)
.bind(pad_id)
.fetch_optional(pool.pool())
.await?
.map(NoteFile::from));
}
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q046))
.bind(file_id)
.bind(pad_id)
.fetch_optional(pool.pool())
.await
}
pub async fn delete_pad_file(
pool: &Database,
pad_id: i64,
file_id: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q047))
.bind(file_id)
.bind(pad_id)
.execute(pool.pool())
.await?;
Ok(())
}
impl<'r> sqlx::FromRow<'r, AnyRow> for Workspace {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self { id: row.try_get("id")?, slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, password_hash: crate::row_decode::optional_text(row, "password_hash")?, created_at: crate::row_decode::text(row, "created_at")?, updated_at: crate::row_decode::text(row, "updated_at")?, is_private: row.try_get("is_private")? })
}
}
impl<'r> sqlx::FromRow<'r, AnyRow> for Note {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
let protected: i64 = row.try_get("protected")?;
Ok(Self { id: row.try_get("id")?, _workspace_id: row.try_get("workspace_id")?, slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, content: crate::row_decode::text(row, "content")?, created_at: crate::row_decode::text(row, "created_at")?, updated_at: crate::row_decode::text(row, "updated_at")?, owner_map: crate::row_decode::text(row, "owner_map")?, protected: protected != 0, created_by: crate::row_decode::optional_text(row, "created_by")? })
}
}
impl<'r> sqlx::FromRow<'r, AnyRow> for Revision {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self { id: row.try_get("id")?, content: crate::row_decode::text(row, "content")?, created_at: crate::row_decode::text(row, "created_at")?, author: crate::row_decode::optional_text(row, "author")?, owner_map: crate::row_decode::text(row, "owner_map")? })
}
}
impl<'r> sqlx::FromRow<'r, AnyRow> for Pad {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self { id: row.try_get("id")?, slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, content: crate::row_decode::text(row, "content")?, password_hash: crate::row_decode::optional_text(row, "password_hash")?, created_at: crate::row_decode::text(row, "created_at")?, updated_at: crate::row_decode::text(row, "updated_at")?, owner_map: crate::row_decode::text(row, "owner_map")?, is_private: row.try_get("is_private")? })
}
}
impl<'r> sqlx::FromRow<'r, AnyRow> for PublishedPageRow {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> { Ok(Self { token: crate::row_decode::text(row,"token")?, pad_id: row.try_get("pad_id")?, note_id: row.try_get("note_id")?, allow_task_updates: row.try_get("allow_task_updates")?, title: crate::row_decode::text(row,"title")?, content: crate::row_decode::text(row,"content")?, updated_at: crate::row_decode::text(row,"updated_at")? }) }
}
impl<'r> sqlx::FromRow<'r, AnyRow> for PostgresPublishedPageRow {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> { Ok(Self { token: crate::row_decode::text(row,"token")?, pad_id: row.try_get("pad_id")?, note_id: row.try_get("note_id")?, allow_task_updates: row.try_get("allow_task_updates")?, title: crate::row_decode::text(row,"title")?, content: crate::row_decode::text(row,"content")?, updated_at: crate::row_decode::text(row,"updated_at")? }) }
}
impl<'r> sqlx::FromRow<'r, AnyRow> for NoteFile {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> { let attached:i64=row.try_get("is_attached")?; Ok(Self { id:row.try_get("id")?, filename:crate::row_decode::text(row,"filename")?, url:crate::row_decode::text(row,"url")?, mime_type:crate::row_decode::text(row,"mime_type")?, size_bytes:row.try_get("size_bytes")?, created_at:crate::row_decode::text(row,"created_at")?, is_attached:attached!=0, detached_at:crate::row_decode::optional_text(row,"detached_at")? }) }
}
+287
View File
@@ -0,0 +1,287 @@
#[derive(Debug, Clone, Copy)]
pub enum FileOwnerKind {
Pad,
Note,
}
#[derive(Debug, Clone, Copy)]
pub struct FileOwner {
pub kind: FileOwnerKind,
pub id: i64,
}
pub async fn find_file_owner(
pool: &Database,
token: &str,
) -> Result<Option<FileOwner>, sqlx::Error> {
if let Some(id) = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q026))
.bind(token)
.fetch_optional(pool.pool())
.await?
{
return Ok(Some(FileOwner {
kind: FileOwnerKind::Pad,
id,
}));
}
if let Some(id) = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q027))
.bind(token)
.fetch_optional(pool.pool())
.await?
{
return Ok(Some(FileOwner {
kind: FileOwnerKind::Note,
id,
}));
}
Ok(None)
}
#[derive(Debug, Clone, Serialize)]
pub struct NoteFile {
pub id: i64,
pub filename: String,
pub url: String,
pub mime_type: String,
pub size_bytes: i64,
pub created_at: String,
pub is_attached: bool,
pub detached_at: Option<String>,
}
#[derive(Debug, Clone, FromRow)]
struct SqliteNoteFile {
id: i64,
filename: String,
url: String,
mime_type: String,
size_bytes: i64,
created_at: String,
is_attached: i64,
detached_at: Option<String>,
}
impl From<SqliteNoteFile> for NoteFile {
fn from(value: SqliteNoteFile) -> Self {
Self {
id: value.id,
filename: value.filename,
url: value.url,
mime_type: value.mime_type,
size_bytes: value.size_bytes,
created_at: value.created_at,
is_attached: value.is_attached != 0,
detached_at: value.detached_at,
}
}
}
pub async fn delete_note(pool: &Database, note_id: i64) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q031))
.bind(note_id)
.execute(pool.pool())
.await?;
Ok(())
}
pub async fn register_note_file(
pool: &Database,
note_id: i64,
filename: &str,
url: &str,
mime_type: &str,
size_bytes: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q032))
.bind(note_id)
.bind(filename)
.bind(url)
.bind(mime_type)
.bind(size_bytes)
.execute(pool.pool())
.await?;
Ok(())
}
pub async fn list_note_files(pool: &Database, note_id: i64) -> Result<Vec<NoteFile>, sqlx::Error> {
list_files(pool, queries::Q033, note_id).await
}
async fn list_files(
pool: &Database,
query: queries::Query,
owner_id: i64,
) -> Result<Vec<NoteFile>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), query))
.bind(owner_id)
.fetch_all(pool.pool())
.await?
.into_iter()
.map(NoteFile::from)
.collect());
}
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), query))
.bind(owner_id)
.fetch_all(pool.pool())
.await
}
pub async fn set_note_file_attached(
pool: &Database,
file_id: i64,
attached: bool,
) -> Result<(), sqlx::Error> {
let detached_at: Option<String> = if attached {
None
} else {
Some(chrono::Utc::now().to_rfc3339())
};
sqlx::query(queries::get(pool.kind(), queries::Q034))
.bind(attached)
.bind(detached_at)
.bind(file_id)
.execute(pool.pool())
.await?;
Ok(())
}
pub async fn register_pad_file(
pool: &Database,
pad_id: i64,
filename: &str,
url: &str,
mime_type: &str,
size_bytes: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q035))
.bind(pad_id)
.bind(filename)
.bind(url)
.bind(mime_type)
.bind(size_bytes)
.execute(pool.pool())
.await?;
Ok(())
}
pub async fn list_pad_files(pool: &Database, pad_id: i64) -> Result<Vec<NoteFile>, sqlx::Error> {
list_files(pool, queries::Q036, pad_id).await
}
pub async fn set_pad_file_attached(
pool: &Database,
file_id: i64,
attached: bool,
) -> Result<(), sqlx::Error> {
let detached_at: Option<String> = if attached {
None
} else {
Some(chrono::Utc::now().to_rfc3339())
};
sqlx::query(queries::get(pool.kind(), queries::Q037))
.bind(attached)
.bind(detached_at)
.bind(file_id)
.execute(pool.pool())
.await?;
Ok(())
}
pub async fn find_note_file(
pool: &Database,
note_id: i64,
file_id: i64,
) -> Result<Option<NoteFile>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), queries::Q038))
.bind(file_id)
.bind(note_id)
.fetch_optional(pool.pool())
.await?
.map(NoteFile::from));
}
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q038))
.bind(file_id)
.bind(note_id)
.fetch_optional(pool.pool())
.await
}
pub async fn delete_note_file(
pool: &Database,
note_id: i64,
file_id: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q039))
.bind(file_id)
.bind(note_id)
.execute(pool.pool())
.await?;
Ok(())
}
pub async fn find_pad_file(
pool: &Database,
pad_id: i64,
file_id: i64,
) -> Result<Option<NoteFile>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), queries::Q046))
.bind(file_id)
.bind(pad_id)
.fetch_optional(pool.pool())
.await?
.map(NoteFile::from));
}
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q046))
.bind(file_id)
.bind(pad_id)
.fetch_optional(pool.pool())
.await
}
pub async fn delete_pad_file(
pool: &Database,
pad_id: i64,
file_id: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q047))
.bind(file_id)
.bind(pad_id)
.execute(pool.pool())
.await?;
Ok(())
}
impl<'r> sqlx::FromRow<'r, AnyRow> for Workspace {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self { id: row.try_get("id")?, slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, password_hash: crate::row_decode::optional_text(row, "password_hash")?, created_at: crate::row_decode::text(row, "created_at")?, updated_at: crate::row_decode::text(row, "updated_at")?, is_private: row.try_get("is_private")? })
}
}
impl<'r> sqlx::FromRow<'r, AnyRow> for Note {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
let protected: i64 = row.try_get("protected")?;
Ok(Self { id: row.try_get("id")?, _workspace_id: row.try_get("workspace_id")?, slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, content: crate::row_decode::text(row, "content")?, created_at: crate::row_decode::text(row, "created_at")?, updated_at: crate::row_decode::text(row, "updated_at")?, owner_map: crate::row_decode::text(row, "owner_map")?, protected: protected != 0, created_by: crate::row_decode::optional_text(row, "created_by")? })
}
}
impl<'r> sqlx::FromRow<'r, AnyRow> for Revision {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self { id: row.try_get("id")?, content: crate::row_decode::text(row, "content")?, created_at: crate::row_decode::text(row, "created_at")?, author: crate::row_decode::optional_text(row, "author")?, owner_map: crate::row_decode::text(row, "owner_map")? })
}
}
impl<'r> sqlx::FromRow<'r, AnyRow> for Pad {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
Ok(Self { id: row.try_get("id")?, slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, content: crate::row_decode::text(row, "content")?, password_hash: crate::row_decode::optional_text(row, "password_hash")?, created_at: crate::row_decode::text(row, "created_at")?, updated_at: crate::row_decode::text(row, "updated_at")?, owner_map: crate::row_decode::text(row, "owner_map")?, is_private: row.try_get("is_private")? })
}
}
impl<'r> sqlx::FromRow<'r, AnyRow> for PublishedPageRow {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> { Ok(Self { token: crate::row_decode::text(row,"token")?, pad_id: row.try_get("pad_id")?, note_id: row.try_get("note_id")?, allow_task_updates: row.try_get("allow_task_updates")?, title: crate::row_decode::text(row,"title")?, content: crate::row_decode::text(row,"content")?, updated_at: crate::row_decode::text(row,"updated_at")? }) }
}
impl<'r> sqlx::FromRow<'r, AnyRow> for PostgresPublishedPageRow {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> { Ok(Self { token: crate::row_decode::text(row,"token")?, pad_id: row.try_get("pad_id")?, note_id: row.try_get("note_id")?, allow_task_updates: row.try_get("allow_task_updates")?, title: crate::row_decode::text(row,"title")?, content: crate::row_decode::text(row,"content")?, updated_at: crate::row_decode::text(row,"updated_at")? }) }
}
impl<'r> sqlx::FromRow<'r, AnyRow> for NoteFile {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> { let attached:i64=row.try_get("is_attached")?; Ok(Self { id:row.try_get("id")?, filename:crate::row_decode::text(row,"filename")?, url:crate::row_decode::text(row,"url")?, mime_type:crate::row_decode::text(row,"mime_type")?, size_bytes:row.try_get("size_bytes")?, created_at:crate::row_decode::text(row,"created_at")?, is_attached:attached!=0, detached_at:crate::row_decode::optional_text(row,"detached_at")? }) }
}
+15
View File
@@ -0,0 +1,15 @@
use crate::{
database::{Database, DatabaseKind},
queries,
};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
use chrono::{DateTime, NaiveDateTime, Utc};
use rand_core::{OsRng, RngCore};
use serde::Serialize;
use sqlx::FromRow;
use sqlx::{any::AnyRow, Any, Row, Transaction};
include!("workspace_notes.rs");
include!("pads_public.rs");
include!("files.rs");
+379
View File
@@ -0,0 +1,379 @@
#[derive(Debug, Clone)]
pub struct Pad {
pub id: i64,
pub slug: String,
pub title: String,
pub content: String,
pub password_hash: Option<String>,
pub created_at: String,
pub updated_at: String,
pub owner_map: String,
pub is_private: i64,
}
pub async fn find_pad(pool: &Database, slug: &str) -> Result<Option<Pad>, sqlx::Error> {
sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011))
.bind(slug)
.fetch_optional(pool.pool())
.await
}
pub async fn create_pad(
pool: &Database,
slug: &str,
title: &str,
password: Option<&str>,
) -> Result<Pad, sqlx::Error> {
let password_hash = password
.filter(|value| !value.is_empty())
.map(hash_password);
sqlx::query(queries::get(pool.kind(), queries::Q012))
.bind(slug)
.bind(title)
.bind(password_hash)
.execute(pool.pool())
.await?;
sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011))
.bind(slug)
.fetch_one(pool.pool())
.await
}
pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool {
match (
&pad.password_hash,
password.filter(|value| !value.is_empty()),
) {
(None, _) => true,
(Some(hash), Some(password)) => PasswordHash::new(hash)
.ok()
.and_then(|parsed| {
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.ok()
})
.is_some(),
(Some(_), None) => false,
}
}
pub async fn save_pad_revision(
pool: &Database,
pad_id: i64,
content: &str,
author: Option<&str>,
owner_map: &str,
) -> Result<(i64, String), sqlx::Error> {
let mut tx = pool.pool().begin().await?;
sqlx::query(queries::get(pool.kind(), queries::Q013))
.bind(content)
.bind(owner_map)
.bind(pad_id)
.execute(&mut *tx)
.await?;
sqlx::query(queries::get(pool.kind(), queries::Q014))
.bind(pad_id)
.bind(content)
.bind(author)
.bind(owner_map)
.execute(&mut *tx)
.await?;
let revision_id = inserted_id(pool.kind(), &mut tx, "revisions").await?;
let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q015))
.bind(pad_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok((revision_id, updated_at))
}
pub async fn list_pad_revisions(
pool: &Database,
pad_id: i64,
) -> Result<Vec<Revision>, sqlx::Error> {
sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q016))
.bind(pad_id)
.fetch_all(pool.pool())
.await
}
#[derive(Debug, Clone, Serialize)]
pub struct PublishedPage {
pub token: String,
pub pad_id: Option<i64>,
pub note_id: Option<i64>,
pub allow_task_updates: bool,
pub title: String,
pub content: String,
pub updated_at: String,
}
#[derive(Debug, Clone)]
struct PublishedPageRow {
token: String,
pad_id: Option<i64>,
note_id: Option<i64>,
allow_task_updates: i64,
title: String,
content: String,
updated_at: String,
}
#[derive(Debug, Clone)]
struct PostgresPublishedPageRow {
token: String,
pad_id: Option<i64>,
note_id: Option<i64>,
allow_task_updates: bool,
title: String,
content: String,
updated_at: String,
}
impl From<PostgresPublishedPageRow> for PublishedPage {
fn from(value: PostgresPublishedPageRow) -> Self {
Self {
token: value.token,
pad_id: value.pad_id,
note_id: value.note_id,
allow_task_updates: value.allow_task_updates,
title: value.title,
content: value.content,
updated_at: value.updated_at,
}
}
}
impl From<PublishedPageRow> for PublishedPage {
fn from(value: PublishedPageRow) -> Self {
Self {
token: value.token,
pad_id: value.pad_id,
note_id: value.note_id,
allow_task_updates: value.allow_task_updates != 0,
title: value.title,
content: value.content,
updated_at: value.updated_at,
}
}
}
pub async fn publish_pad(pool: &Database, pad_id: i64) -> Result<String, sqlx::Error> {
if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q017))
.bind(pad_id)
.fetch_optional(pool.pool())
.await?
{
return Ok(token);
}
let token = random_suffix(18);
sqlx::query(queries::get(pool.kind(), queries::Q018))
.bind(&token)
.bind(pad_id)
.execute(pool.pool())
.await?;
Ok(token)
}
pub async fn publish_note(pool: &Database, note_id: i64) -> Result<String, sqlx::Error> {
if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q019))
.bind(note_id)
.fetch_optional(pool.pool())
.await?
{
return Ok(token);
}
let token = random_suffix(18);
sqlx::query(queries::get(pool.kind(), queries::Q020))
.bind(&token)
.bind(note_id)
.execute(pool.pool())
.await?;
Ok(token)
}
pub async fn find_published_page(
pool: &Database,
token: &str,
) -> Result<Option<PublishedPage>, sqlx::Error> {
if pool.kind() == DatabaseKind::Postgres {
return Ok(
sqlx::query_as::<_, PostgresPublishedPageRow>(queries::get(pool.kind(), queries::Q021))
.bind(token)
.fetch_optional(pool.pool())
.await?
.map(Into::into),
);
}
Ok(
sqlx::query_as::<_, PublishedPageRow>(queries::get(pool.kind(), queries::Q021))
.bind(token)
.fetch_optional(pool.pool())
.await?
.map(Into::into),
)
}
pub async fn pad_public_task_updates(pool: &Database, pad_id: i64) -> Result<bool, sqlx::Error> {
if pool.kind() == DatabaseKind::Postgres {
return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q044))
.bind(pad_id)
.fetch_optional(pool.pool())
.await?
.unwrap_or(false));
}
let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q044))
.bind(pad_id)
.fetch_optional(pool.pool())
.await?
.unwrap_or(0);
Ok(value != 0)
}
pub async fn note_public_task_updates(pool: &Database, note_id: i64) -> Result<bool, sqlx::Error> {
if pool.kind() == DatabaseKind::Postgres {
return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q045))
.bind(note_id)
.fetch_optional(pool.pool())
.await?
.unwrap_or(false));
}
let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q045))
.bind(note_id)
.fetch_optional(pool.pool())
.await?
.unwrap_or(0);
Ok(value != 0)
}
pub async fn set_pad_public_task_updates(
pool: &Database,
pad_id: i64,
allow: bool,
) -> Result<(), sqlx::Error> {
publish_pad(pool, pad_id).await?;
let mut query = sqlx::query(queries::get(pool.kind(), queries::Q040));
query = if pool.kind() == DatabaseKind::Postgres {
query.bind(allow)
} else {
query.bind(if allow { 1i64 } else { 0i64 })
};
query.bind(pad_id).execute(pool.pool()).await?;
Ok(())
}
pub async fn set_note_public_task_updates(
pool: &Database,
note_id: i64,
allow: bool,
) -> Result<(), sqlx::Error> {
publish_note(pool, note_id).await?;
let mut query = sqlx::query(queries::get(pool.kind(), queries::Q041));
query = if pool.kind() == DatabaseKind::Postgres {
query.bind(allow)
} else {
query.bind(if allow { 1i64 } else { 0i64 })
};
query.bind(note_id).execute(pool.pool()).await?;
Ok(())
}
pub async fn update_public_task(
pool: &Database,
token: &str,
source_line: usize,
checked: bool,
) -> Result<Option<PublishedPage>, sqlx::Error> {
let Some(mut page) = find_published_page(pool, token).await? else {
return Ok(None);
};
if !page.allow_task_updates || source_line == 0 {
return Ok(Some(page));
}
let mut lines: Vec<String> = page.content.split('\n').map(str::to_owned).collect();
let Some(line) = lines.get_mut(source_line - 1) else {
return Ok(Some(page));
};
let bytes = line.as_bytes();
let mut i = 0usize;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() || !matches!(bytes[i], b'-' | b'*' | b'+') {
return Ok(Some(page));
}
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i + 2 >= bytes.len()
|| bytes[i] != b'['
|| !matches!(bytes[i + 1], b' ' | b'x' | b'X')
|| bytes[i + 2] != b']'
{
return Ok(Some(page));
}
line.replace_range(i + 1..i + 2, if checked { "x" } else { " " });
page.content = lines.join("\n");
if let Some(id) = page.pad_id {
sqlx::query(queries::get(pool.kind(), queries::Q042))
.bind(&page.content)
.bind(id)
.execute(pool.pool())
.await?;
} else if let Some(id) = page.note_id {
sqlx::query(queries::get(pool.kind(), queries::Q043))
.bind(&page.content)
.bind(id)
.execute(pool.pool())
.await?;
}
find_published_page(pool, token).await
}
pub async fn pad_file_token(pool: &Database, pad_id: i64) -> Result<String, sqlx::Error> {
if let Some(token) =
sqlx::query_scalar::<_, Option<String>>(queries::get(pool.kind(), queries::Q022))
.bind(pad_id)
.fetch_one(pool.pool())
.await?
{
return Ok(token);
}
let token = format!("p_{}", random_suffix(24));
sqlx::query(queries::get(pool.kind(), queries::Q023))
.bind(&token)
.bind(pad_id)
.execute(pool.pool())
.await?;
sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q022))
.bind(pad_id)
.fetch_one(pool.pool())
.await
}
pub async fn note_file_token(pool: &Database, note_id: i64) -> Result<String, sqlx::Error> {
if let Some(token) =
sqlx::query_scalar::<_, Option<String>>(queries::get(pool.kind(), queries::Q024))
.bind(note_id)
.fetch_one(pool.pool())
.await?
{
return Ok(token);
}
let token = format!("n_{}", random_suffix(24));
sqlx::query(queries::get(pool.kind(), queries::Q025))
.bind(&token)
.bind(note_id)
.execute(pool.pool())
.await?;
sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q024))
.bind(note_id)
.fetch_one(pool.pool())
.await
}
+291
View File
@@ -0,0 +1,291 @@
async fn inserted_id(
kind: DatabaseKind,
tx: &mut Transaction<'_, Any>,
table: &str,
) -> Result<i64, sqlx::Error> {
let query = match kind {
DatabaseKind::Sqlite => queries::get(kind, queries::SQLITE_LAST_INSERT_ID),
DatabaseKind::MySql => queries::get(kind, queries::MYSQL_LAST_INSERT_ID),
DatabaseKind::Postgres => match table {
"note_revisions" => queries::get(kind, queries::POSTGRES_NOTE_REVISION_LAST_INSERT_ID),
"revisions" => queries::get(kind, queries::POSTGRES_PAD_REVISION_LAST_INSERT_ID),
_ => unreachable!("unsupported identity table"),
},
};
sqlx::query_scalar(query).fetch_one(&mut **tx).await
}
#[derive(Debug, Clone)]
pub struct Workspace {
pub id: i64,
pub slug: String,
pub title: String,
pub password_hash: Option<String>,
pub created_at: String,
pub updated_at: String,
pub is_private: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct Note {
pub id: i64,
#[serde(skip_serializing)]
pub _workspace_id: i64,
pub slug: String,
pub title: String,
#[serde(skip_serializing)]
pub content: String,
pub created_at: String,
pub updated_at: String,
pub owner_map: String,
pub protected: bool,
pub created_by: Option<String>,
}
#[derive(Debug, Clone, FromRow)]
struct SqliteNote {
id: i64,
workspace_id: i64,
slug: String,
title: String,
content: String,
created_at: String,
updated_at: String,
owner_map: String,
protected: i64,
created_by: Option<String>,
}
impl From<SqliteNote> for Note {
fn from(value: SqliteNote) -> Self {
Self {
id: value.id,
_workspace_id: value.workspace_id,
slug: value.slug,
title: value.title,
content: value.content,
created_at: value.created_at,
updated_at: value.updated_at,
owner_map: value.owner_map,
protected: value.protected != 0,
created_by: value.created_by,
}
}
}
#[derive(Debug, Serialize)]
pub struct Revision {
pub id: i64,
pub content: String,
pub created_at: String,
pub author: Option<String>,
pub owner_map: String,
}
pub async fn find_workspace(pool: &Database, slug: &str) -> Result<Option<Workspace>, sqlx::Error> {
sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001))
.bind(slug)
.fetch_optional(pool.pool())
.await
}
pub async fn create_workspace(
pool: &Database,
slug: &str,
title: &str,
password: Option<&str>,
) -> Result<Workspace, sqlx::Error> {
let password_hash = password
.filter(|value| !value.is_empty())
.map(hash_password);
sqlx::query(queries::get(pool.kind(), queries::Q002))
.bind(slug)
.bind(title)
.bind(password_hash)
.execute(pool.pool())
.await?;
sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001))
.bind(slug)
.fetch_one(pool.pool())
.await
}
pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) -> bool {
match (
&workspace.password_hash,
password.filter(|value| !value.is_empty()),
) {
(None, _) => true,
(Some(hash), Some(password)) => PasswordHash::new(hash)
.ok()
.and_then(|parsed| {
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.ok()
})
.is_some(),
(Some(_), None) => false,
}
}
pub async fn list_notes(pool: &Database, workspace_id: i64) -> Result<Vec<Note>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNote>(queries::get(pool.kind(), queries::Q003))
.bind(workspace_id)
.fetch_all(pool.pool())
.await?
.into_iter()
.map(Note::from)
.collect());
}
sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q003))
.bind(workspace_id)
.fetch_all(pool.pool())
.await
}
pub async fn find_note(
pool: &Database,
workspace_id: i64,
slug: &str,
) -> Result<Option<Note>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNote>(queries::get(pool.kind(), queries::Q004))
.bind(workspace_id)
.bind(slug)
.fetch_optional(pool.pool())
.await?
.map(Note::from));
}
sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004))
.bind(workspace_id)
.bind(slug)
.fetch_optional(pool.pool())
.await
}
pub async fn create_note(
pool: &Database,
workspace_id: i64,
slug: &str,
title: &str,
protected: bool,
created_by: Option<&str>,
) -> Result<Note, sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q005))
.bind(workspace_id)
.bind(slug)
.bind(title)
.bind(protected)
.bind(created_by)
.execute(pool.pool())
.await?;
find_note(pool, workspace_id, slug)
.await?
.ok_or(sqlx::Error::RowNotFound)
}
pub async fn save_revision(
pool: &Database,
note_id: i64,
workspace_id: i64,
content: &str,
author: Option<&str>,
owner_map: &str,
) -> Result<(i64, String), sqlx::Error> {
let mut tx = pool.pool().begin().await?;
sqlx::query(queries::get(pool.kind(), queries::Q006))
.bind(content)
.bind(owner_map)
.bind(note_id)
.execute(&mut *tx)
.await?;
sqlx::query(queries::get(pool.kind(), queries::Q007))
.bind(workspace_id)
.execute(&mut *tx)
.await?;
sqlx::query(queries::get(pool.kind(), queries::Q008))
.bind(note_id)
.bind(content)
.bind(author)
.bind(owner_map)
.execute(&mut *tx)
.await?;
let revision_id = inserted_id(pool.kind(), &mut tx, "note_revisions").await?;
let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q009))
.bind(note_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok((revision_id, updated_at))
}
pub async fn list_revisions(pool: &Database, note_id: i64) -> Result<Vec<Revision>, sqlx::Error> {
sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q010))
.bind(note_id)
.fetch_all(pool.pool())
.await
}
pub fn random_suffix(length: usize) -> String {
const ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789";
let mut bytes = vec![0_u8; length];
let mut rng = OsRng;
rng.fill_bytes(&mut bytes);
bytes
.into_iter()
.map(|value| ALPHABET[(value as usize) % ALPHABET.len()] as char)
.collect()
}
fn hash_password(password: &str) -> String {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.expect("Argon2 hashing should succeed")
.to_string()
}
pub fn normalize_timestamp(value: &str) -> String {
let value = value.trim();
if let Ok(timestamp) = DateTime::parse_from_rfc3339(value) {
return timestamp.with_timezone(&Utc).to_rfc3339();
}
// PostgreSQL renders TEXT timestamps as e.g. `2026-07-20 14:32:10.123456+00`.
// RFC 3339 requires `T` and a colon in the numeric offset.
let mut postgres = value.replacen(' ', "T", 1);
if postgres.len() >= 3 {
let offset_start = postgres.len() - 3;
let offset = &postgres[offset_start..];
if (offset.starts_with('+') || offset.starts_with('-'))
&& offset[1..]
.chars()
.all(|character| character.is_ascii_digit())
{
postgres.push_str(":00");
}
}
if let Ok(timestamp) = DateTime::parse_from_rfc3339(&postgres) {
return timestamp.with_timezone(&Utc).to_rfc3339();
}
for format in ["%Y-%m-%d %H:%M:%S%.f%:z", "%Y-%m-%dT%H:%M:%S%.f%:z"] {
if let Ok(timestamp) = DateTime::parse_from_str(value, format) {
return timestamp.with_timezone(&Utc).to_rfc3339();
}
}
for format in ["%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S%.f"] {
if let Ok(timestamp) = NaiveDateTime::parse_from_str(value, format) {
return timestamp.and_utc().to_rfc3339();
}
}
// Existing rows should still be readable even if they were stored without a zone.
value.to_string()
}
-142
View File
@@ -1,142 +0,0 @@
use ldap3::{LdapConnAsync, LdapConnSettings, Scope, SearchEntry};
#[derive(Debug, Clone)]
pub struct LdapConfig {
pub url: String,
pub starttls: bool,
pub bind_dn: String,
pub bind_password: String,
pub base_dn: String,
pub user_filter: String,
pub username_attribute: String,
pub email_attribute: String,
pub display_name_attribute: String,
pub organization: String,
}
#[derive(Debug, Clone)]
pub struct LdapIdentity {
pub username: String,
pub email: String,
pub nickname: String,
}
pub async fn authenticate(
config: &LdapConfig,
login: &str,
password: &str,
) -> Result<Option<LdapIdentity>, String> {
if login.trim().is_empty() || password.is_empty() {
return Ok(None);
}
let settings = LdapConnSettings::new().set_starttls(config.starttls);
let (connection, mut ldap) = LdapConnAsync::with_settings(settings, &config.url)
.await
.map_err(|error| format!("LDAP connection failed: {error}"))?;
ldap3::drive!(connection);
if !config.bind_dn.trim().is_empty() {
let result = ldap
.simple_bind(&config.bind_dn, &config.bind_password)
.await
.map_err(|error| format!("LDAP service bind failed: {error}"))?;
result
.success()
.map_err(|error| format!("LDAP service bind rejected: {error}"))?;
}
let escaped = escape_filter(login.trim());
let filter = config.user_filter.replace("{username}", &escaped);
let attributes = vec![
config.username_attribute.as_str(),
config.email_attribute.as_str(),
config.display_name_attribute.as_str(),
];
let (mut entries, _) = ldap
.search(&config.base_dn, Scope::Subtree, &filter, attributes.clone())
.await
.map_err(|error| format!("LDAP search failed: {error}"))?
.success()
.map_err(|error| format!("LDAP search rejected: {error}"))?;
// Allow users to sign in with their directory e-mail even when the configured
// primary filter searches by uid/sAMAccountName only.
if entries.is_empty() && login.trim().contains('@') {
let email_filter = format!("({}={})", config.email_attribute, escaped);
let (email_entries, _) = ldap
.search(&config.base_dn, Scope::Subtree, &email_filter, attributes)
.await
.map_err(|error| format!("LDAP e-mail search failed: {error}"))?
.success()
.map_err(|error| format!("LDAP e-mail search rejected: {error}"))?;
entries = email_entries;
}
if entries.len() != 1 {
let _ = ldap.unbind().await;
return Ok(None);
}
let entry = SearchEntry::construct(entries.into_iter().next().unwrap());
let user_dn = entry.dn.clone();
let username = first_attr(&entry, &config.username_attribute)
.unwrap_or_else(|| login.trim().to_owned());
let email = first_attr(&entry, &config.email_attribute)
.filter(|value| value.contains('@'))
.unwrap_or_else(|| format!("{}@ldap.local", safe_identifier(&username)));
let display_name = first_attr(&entry, &config.display_name_attribute)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| username.clone());
let result = ldap
.simple_bind(&user_dn, password)
.await
.map_err(|error| format!("LDAP user bind failed: {error}"))?;
if result.success().is_err() {
let _ = ldap.unbind().await;
return Ok(None);
}
let _ = ldap.unbind().await;
let organization = config.organization.trim();
let nickname = if organization.is_empty() {
display_name
} else {
format!("{organization}/{display_name}")
};
Ok(Some(LdapIdentity {
username,
email,
nickname,
}))
}
fn first_attr(entry: &SearchEntry, name: &str) -> Option<String> {
entry.attrs.get(name).and_then(|values| values.first()).cloned()
}
fn safe_identifier(value: &str) -> String {
let result: String = value
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { ch } else { '_' })
.collect();
if result.is_empty() { "user".into() } else { result }
}
fn escape_filter(value: &str) -> String {
let mut result = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'*' => result.push_str("\\2a"),
b'(' => result.push_str("\\28"),
b')' => result.push_str("\\29"),
b'\\' => result.push_str("\\5c"),
0 => result.push_str("\\00"),
value if value < 0x20 || value >= 0x7f => result.push_str(&format!("\\{value:02x}")),
value => result.push(value as char),
}
}
result
}
-530
View File
@@ -1,530 +0,0 @@
use crate::{
auth, db,
state::{AppState, NoteUpdate, PresenceUser, RoomEvent, SharedState},
};
use axum::{
extract::{
Path, State, WebSocketUpgrade,
ws::{Message, WebSocket},
},
response::Response,
};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
use tracing::{debug, info, warn};
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ClientMessage {
Authenticate {
password: Option<String>,
access_token: Option<String>,
nickname: Option<String>,
session_token: Option<String>,
guest_id: Option<String>,
color: Option<String>,
},
Update {
content: String,
owner_map: Option<String>,
},
Ping {
nonce: u64,
},
Chat {
text: String,
},
SetColor {
color: Option<String>,
},
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ServerMessage {
Authenticated {
workspace_title: String,
note_title: String,
content: String,
owner_map: String,
access_level: String,
},
Document {
content: String,
revision_id: i64,
updated_at: String,
author: Option<String>,
owner_map: String,
},
Presence {
users: Vec<PresenceUser>,
},
Chat {
sender: String,
text: String,
},
Pong {
nonce: u64,
},
Error {
message: String,
},
}
pub async fn upgrade(
ws: WebSocketUpgrade,
Path((workspace_slug, note_slug)): Path<(String, String)>,
State(state): State<SharedState>,
) -> Response {
ws.on_upgrade(move |socket| handle_socket(socket, state, workspace_slug, note_slug))
}
async fn handle_socket(
mut socket: WebSocket,
state: SharedState,
workspace_slug: String,
note_slug: String,
) {
info!(%workspace_slug, %note_slug, "note websocket connected");
let Some(workspace) = db::find_workspace(&state.db, &workspace_slug)
.await
.ok()
.flatten()
else {
warn!(%workspace_slug, %note_slug, "note websocket rejected: workspace not found");
let _ = send_error(&mut socket, "Workspace not found").await;
return;
};
let Some(note) = db::find_note(&state.db, workspace.id, &note_slug)
.await
.ok()
.flatten()
else {
warn!(%workspace_slug, %note_slug, "note websocket rejected: note not found");
let _ = send_error(&mut socket, "Note not found").await;
return;
};
let (password, access_token, nickname, session_token, guest_id, color) =
match socket.recv().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Authenticate {
password,
access_token,
nickname,
session_token,
guest_id,
color,
}) => (
password,
access_token,
clean_nickname(nickname),
session_token,
clean_guest_id(guest_id),
clean_color(color),
),
_ => {
let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await;
return;
}
},
_ => return,
};
let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await {
Ok(value) => value,
Err(message) => {
let _ = send_error(&mut socket, &message).await;
return;
}
};
let presence_identity = match session_token.as_deref() {
Some(token) => auth::user_from_token(&state, token)
.await
.ok()
.flatten()
.map(|user| format!("user:{}", user.id)),
None => guest_id.as_ref().and_then(|id| {
nickname
.as_ref()
.map(|name| format!("guest:{id}:{}", name.to_lowercase()))
}),
};
let supplied_token = session_token.as_deref().or(access_token.as_deref());
let permission = auth::resource_permission(
&state,
"workspace",
&workspace_slug,
supplied_token,
)
.await
.ok()
.flatten();
let anonymous_token_ok = permission.is_none()
&& crate::api::verify_resource_access_token(&state, "workspace", &workspace_slug, supplied_token)
.await
.unwrap_or(false);
let password_ok = db::verify_workspace_password(&workspace, password.as_deref());
if workspace.is_private != 0 && permission.is_none() && !anonymous_token_ok {
let _ = send_error(&mut socket, "This workspace is private").await;
return;
}
if workspace.password_hash.is_some() && !password_ok && permission.is_none() && !anonymous_token_ok {
warn!(
workspace_id = workspace.id,
note_id = note.id,
"note websocket rejected: invalid workspace password"
);
let _ = send_error(&mut socket, "Invalid password").await;
return;
}
let write_allowed = permission.as_deref() == Some("rw")
|| anonymous_token_ok
|| password_ok
|| (workspace.is_private == 0 && workspace.password_hash.is_none() && permission.is_none());
info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated");
if send(
&mut socket,
&ServerMessage::Authenticated {
workspace_title: workspace.title.clone(),
note_title: note.title.clone(),
content: note.content.clone(),
owner_map: note.owner_map.clone(),
access_level: if write_allowed { "full".into() } else { "read_only".into() },
},
)
.await
.is_err()
{
return;
}
let room_key = AppState::note_room_key(&workspace_slug, &note_slug);
let channel = state.note_channel(&workspace_slug, &note_slug).await;
let mut updates = channel.subscribe();
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
let (connection_id, users) = state
.join_room(&room_key, display_name.clone(), color, presence_identity)
.await;
let _ = channel.send(RoomEvent::Presence(users));
let mut last_chat = Instant::now() - Duration::from_secs(1);
let (mut sender, mut receiver) = socket.split();
loop {
tokio::select! {
incoming=receiver.next()=>match incoming {
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Update{content,owner_map})=>{
if !write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; }
if content.len()>2_000_000 { let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await; continue; }
let owner_map=owner_map.unwrap_or_else(||"[]".into());
match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await {
Ok((revision_id,updated_at))=>{let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}));}
Err(error)=>warn!(%error, workspace_id = workspace.id, note_id = note.id, "failed to save revision"),
}
}
Ok(ClientMessage::Ping{nonce})=>{ let _=send_split(&mut sender,&ServerMessage::Pong{nonce}).await; },
Ok(ClientMessage::Chat{text})=>{
let text=clean_chat(text);
if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); }
}
Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); },
Ok(ClientMessage::Authenticate{..})=>{}, Err(error)=>warn!(%error,"invalid websocket message"),
},
Some(Ok(Message::Close(_)))|None=>break, Some(Ok(_))=>{}, Some(Err(error))=>{debug!(%error,"websocket receive error");break;}
},
update=updates.recv()=>match update {
Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;},
Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;},
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,&note_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
}
}
}
let users = state.leave_room(&room_key, connection_id).await;
let _ = channel.send(RoomEvent::Presence(users));
info!(
workspace_id = workspace.id,
note_id = note.id,
"note websocket disconnected"
);
}
fn clean_nickname(value: Option<String>) -> Option<String> {
value
.map(|v| v.trim().chars().take(40).collect::<String>())
.filter(|v| !v.is_empty())
}
fn clean_guest_id(value: Option<String>) -> Option<String> {
value
.map(|v| v.trim().chars().take(64).collect::<String>())
.filter(|v| {
v.len() >= 16
&& v.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
})
}
fn clean_color(value: Option<String>) -> Option<String> {
value.map(|v| v.trim().to_ascii_lowercase()).filter(|v| {
v.len() == 7 && v.starts_with('#') && v[1..].chars().all(|c| c.is_ascii_hexdigit())
})
}
fn clean_chat(value: String) -> String {
value
.chars()
.map(|c| {
if matches!(c, '\r' | '\n' | '\0') {
' '
} else {
c
}
})
.collect::<String>()
.trim()
.chars()
.take(1000)
.collect()
}
async fn send_error(socket: &mut WebSocket, message: &str) -> Result<(), axum::Error> {
send(
socket,
&ServerMessage::Error {
message: message.into(),
},
)
.await
}
async fn send(socket: &mut WebSocket, message: &ServerMessage) -> Result<(), axum::Error> {
socket
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
}
async fn send_split(
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
message: &ServerMessage,
) -> Result<(), axum::Error> {
sender
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum PadServerMessage {
Authenticated {
title: String,
content: String,
owner_map: String,
access_level: String,
},
Document {
content: String,
revision_id: i64,
updated_at: String,
author: Option<String>,
owner_map: String,
},
Presence {
users: Vec<PresenceUser>,
},
Chat {
sender: String,
text: String,
},
Pong {
nonce: u64,
},
Error {
message: String,
},
}
pub async fn upgrade_pad(
ws: WebSocketUpgrade,
Path(slug): Path<String>,
State(state): State<SharedState>,
) -> Response {
ws.on_upgrade(move |socket| handle_pad_socket(socket, state, slug))
}
async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: String) {
info!(%slug, "pad websocket connected");
let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else {
warn!(%slug, "pad websocket rejected: pad not found");
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: "Note not found".into(),
},
)
.await;
return;
};
let (password, access_token, nickname, session_token, guest_id, color) =
match socket.recv().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Authenticate {
password,
access_token,
nickname,
session_token,
guest_id,
color,
}) => (
password,
access_token,
clean_nickname(nickname),
session_token,
clean_guest_id(guest_id),
clean_color(color),
),
_ => {
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: "Wymagane uwierzytelnienie".into(),
},
)
.await;
return;
}
},
_ => return,
};
let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await {
Ok(value) => value,
Err(message) => {
let _ = send_pad(&mut socket, &PadServerMessage::Error { message }).await;
return;
}
};
let presence_identity = match session_token.as_deref() {
Some(token) => auth::user_from_token(&state, token)
.await
.ok()
.flatten()
.map(|user| format!("user:{}", user.id)),
None => guest_id.as_ref().and_then(|id| {
nickname
.as_ref()
.map(|name| format!("guest:{id}:{}", name.to_lowercase()))
}),
};
let supplied_token = session_token.as_deref().or(access_token.as_deref());
let permission = auth::resource_permission(
&state,
"pad",
&slug,
supplied_token,
)
.await
.ok()
.flatten();
let anonymous_token_ok = permission.is_none()
&& crate::api::verify_resource_access_token(&state, "pad", &slug, supplied_token)
.await
.unwrap_or(false);
let password_ok = db::verify_pad_password(&pad, password.as_deref());
if pad.is_private != 0 && permission.is_none() && !anonymous_token_ok {
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: "This note is private".into(),
},
)
.await;
return;
}
if pad.password_hash.is_some() && !password_ok && permission.is_none() && !anonymous_token_ok {
warn!(pad_id = pad.id, "pad websocket rejected: invalid password");
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: "Invalid password".into(),
},
)
.await;
return;
}
let write_allowed = permission.as_deref() == Some("rw")
|| anonymous_token_ok
|| password_ok
|| (pad.is_private == 0 && pad.password_hash.is_none() && permission.is_none());
info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated");
if send_pad(
&mut socket,
&PadServerMessage::Authenticated {
title: pad.title.clone(),
content: pad.content.clone(),
owner_map: pad.owner_map.clone(),
access_level: if write_allowed { "full".into() } else { "read_only".into() },
},
)
.await
.is_err()
{
return;
}
let room_key = AppState::pad_room_key(&slug);
let channel = state.pad_channel(&slug).await;
let mut updates = channel.subscribe();
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
let (connection_id, users) = state
.join_room(&room_key, display_name.clone(), color, presence_identity)
.await;
let _ = channel.send(RoomEvent::Presence(users));
let mut last_chat = Instant::now() - Duration::from_secs(1);
let (mut sender, mut receiver) = socket.split();
loop {
tokio::select! {
incoming=receiver.next()=>match incoming{
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text){
Ok(ClientMessage::Update{content,owner_map})=>{if !write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;}
if content.len()>2_000_000 { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await; continue; }
let owner_map=owner_map.unwrap_or_else(||"[]".into());
if let Ok((revision_id,updated_at))=db::save_pad_revision(&state.db,pad.id,&content,nickname.as_deref(),&owner_map).await{
let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}));
}
}
Ok(ClientMessage::Ping{nonce})=>{ let _=send_pad_split(&mut sender,&PadServerMessage::Pong{nonce}).await; },
Ok(ClientMessage::Chat{text})=>{
let text=clean_chat(text);
if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); }
}
Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); },
Ok(ClientMessage::Authenticate{..})=>{},
Err(error)=>warn!(%error,"invalid pad websocket message"),
},
Some(Ok(Message::Close(_)))|None=>break,
Some(Ok(_))=>{},
Some(Err(error))=>{debug!(%error,"pad websocket receive error");break;}
},
update=updates.recv()=>match update{
Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{content:u.content,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,owner_map:u.owner_map}).await.is_err(){break;},
Ok(RoomEvent::Presence(users))=>if send_pad_split(&mut sender,&PadServerMessage::Presence{users}).await.is_err(){break;},
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_pad_split(&mut sender,&PadServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_pad(&state.db,&slug).await { if send_pad_split(&mut sender,&PadServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
}
}
}
let users = state.leave_room(&room_key, connection_id).await;
let _ = channel.send(RoomEvent::Presence(users));
info!(pad_id = pad.id, "pad websocket disconnected");
}
async fn send_pad(socket: &mut WebSocket, message: &PadServerMessage) -> Result<(), axum::Error> {
socket
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
}
async fn send_pad_split(
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
message: &PadServerMessage,
) -> Result<(), axum::Error> {
sender
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
}
+73
View File
@@ -0,0 +1,73 @@
let _ = channel.send(RoomEvent::Presence(users));
info!(
workspace_id = workspace.id,
note_id = note.id,
"note websocket disconnected"
);
}
fn clean_nickname(value: Option<String>) -> Option<String> {
value
.map(|v| v.trim().chars().take(40).collect::<String>())
.filter(|v| !v.is_empty())
}
fn clean_guest_id(value: Option<String>) -> Option<String> {
value
.map(|v| v.trim().chars().take(64).collect::<String>())
.filter(|v| {
v.len() >= 16
&& v.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
})
}
fn clean_color(value: Option<String>) -> Option<String> {
value.map(|v| v.trim().to_ascii_lowercase()).filter(|v| {
v.len() == 7 && v.starts_with('#') && v[1..].chars().all(|c| c.is_ascii_hexdigit())
})
}
fn clean_chat(value: String) -> String {
value
.chars()
.map(|c| {
if matches!(c, '\r' | '\n' | '\0') {
' '
} else {
c
}
})
.collect::<String>()
.trim()
.chars()
.take(1000)
.collect()
}
async fn send_error(socket: &mut WebSocket, message: &str) -> Result<(), axum::Error> {
send(
socket,
&ServerMessage::Error {
message: message.into(),
},
)
.await
}
async fn send(socket: &mut WebSocket, message: &ServerMessage) -> Result<(), axum::Error> {
socket
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
}
async fn send_split(
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
message: &ServerMessage,
) -> Result<(), axum::Error> {
sender
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum PadServerMessage {
Authenticated {
+57
View File
@@ -0,0 +1,57 @@
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ClientMessage {
Authenticate {
password: Option<String>,
access_token: Option<String>,
nickname: Option<String>,
session_token: Option<String>,
guest_id: Option<String>,
color: Option<String>,
},
Update {
content: String,
owner_map: Option<String>,
},
Ping {
nonce: u64,
},
Chat {
text: String,
},
SetColor {
color: Option<String>,
},
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ServerMessage {
Authenticated {
workspace_title: String,
note_title: String,
content: String,
owner_map: String,
access_level: String,
},
Document {
content: String,
revision_id: i64,
updated_at: String,
author: Option<String>,
owner_map: String,
},
Presence {
users: Vec<PresenceUser>,
},
Chat {
sender: String,
text: String,
},
Pong {
nonce: u64,
},
Error {
message: String,
},
}
+20
View File
@@ -0,0 +1,20 @@
use crate::{
auth, db,
state::{AppState, NoteUpdate, PresenceUser, RoomEvent, SharedState},
};
use axum::{
extract::{
Path, State, WebSocketUpgrade,
ws::{Message, WebSocket},
},
response::Response,
};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
use tracing::{debug, info, warn};
include!("messages.rs");
include!("note.rs");
include!("pad.rs");
+236
View File
@@ -0,0 +1,236 @@
pub async fn upgrade(
ws: WebSocketUpgrade,
Path((workspace_slug, note_slug)): Path<(String, String)>,
State(state): State<SharedState>,
) -> Response {
ws.on_upgrade(move |socket| handle_socket(socket, state, workspace_slug, note_slug))
}
async fn handle_socket(
mut socket: WebSocket,
state: SharedState,
workspace_slug: String,
note_slug: String,
) {
info!(%workspace_slug, %note_slug, "note websocket connected");
let Some(workspace) = db::find_workspace(&state.db, &workspace_slug)
.await
.ok()
.flatten()
else {
warn!(%workspace_slug, %note_slug, "note websocket rejected: workspace not found");
let _ = send_error(&mut socket, "Workspace not found").await;
return;
};
let Some(note) = db::find_note(&state.db, workspace.id, &note_slug)
.await
.ok()
.flatten()
else {
warn!(%workspace_slug, %note_slug, "note websocket rejected: note not found");
let _ = send_error(&mut socket, "Note not found").await;
return;
};
let (password, access_token, nickname, session_token, guest_id, color) =
match socket.recv().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Authenticate {
password,
access_token,
nickname,
session_token,
guest_id,
color,
}) => (
password,
access_token,
clean_nickname(nickname),
session_token,
clean_guest_id(guest_id),
clean_color(color),
),
_ => {
let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await;
return;
}
},
_ => return,
};
let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await {
Ok(value) => value,
Err(message) => {
let _ = send_error(&mut socket, &message).await;
return;
}
};
let presence_identity = match session_token.as_deref() {
Some(token) => auth::user_from_token(&state, token)
.await
.ok()
.flatten()
.map(|user| format!("user:{}", user.id)),
None => guest_id.as_ref().and_then(|id| {
nickname
.as_ref()
.map(|name| format!("guest:{id}:{}", name.to_lowercase()))
}),
};
let supplied_token = session_token.as_deref().or(access_token.as_deref());
let permission = auth::resource_permission(
&state,
"workspace",
&workspace_slug,
supplied_token,
)
.await
.ok()
.flatten();
let anonymous_token_ok = permission.is_none()
&& crate::api::verify_resource_access_token(&state, "workspace", &workspace_slug, supplied_token)
.await
.unwrap_or(false);
let password_ok = db::verify_workspace_password(&workspace, password.as_deref());
if workspace.is_private != 0 && permission.is_none() && !anonymous_token_ok {
let _ = send_error(&mut socket, "This workspace is private").await;
return;
}
if workspace.password_hash.is_some() && !password_ok && permission.is_none() && !anonymous_token_ok {
warn!(
workspace_id = workspace.id,
note_id = note.id,
"note websocket rejected: invalid workspace password"
);
let _ = send_error(&mut socket, "Invalid password").await;
return;
}
let write_allowed = permission.as_deref() == Some("rw")
|| anonymous_token_ok
|| password_ok
|| (workspace.is_private == 0 && workspace.password_hash.is_none() && permission.is_none());
info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated");
if send(
&mut socket,
&ServerMessage::Authenticated {
workspace_title: workspace.title.clone(),
note_title: note.title.clone(),
content: note.content.clone(),
owner_map: note.owner_map.clone(),
access_level: if write_allowed { "full".into() } else { "read_only".into() },
},
)
.await
.is_err()
{
return;
}
let room_key = AppState::note_room_key(&workspace_slug, &note_slug);
let channel = state.note_channel(&workspace_slug, &note_slug).await;
let mut updates = channel.subscribe();
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
let (connection_id, users) = state
.join_room(&room_key, display_name.clone(), color, presence_identity)
.await;
let _ = channel.send(RoomEvent::Presence(users));
let mut last_chat = Instant::now() - Duration::from_secs(1);
let (mut sender, mut receiver) = socket.split();
loop {
tokio::select! {
incoming=receiver.next()=>match incoming {
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Update{content,owner_map})=>{
if !write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; }
if content.len()>2_000_000 { let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await; continue; }
let owner_map=owner_map.unwrap_or_else(||"[]".into());
match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await {
Ok((revision_id,updated_at))=>{let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}));}
Err(error)=>warn!(%error, workspace_id = workspace.id, note_id = note.id, "failed to save revision"),
}
}
Ok(ClientMessage::Ping{nonce})=>{ let _=send_split(&mut sender,&ServerMessage::Pong{nonce}).await; },
Ok(ClientMessage::Chat{text})=>{
let text=clean_chat(text);
if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); }
}
Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); },
Ok(ClientMessage::Authenticate{..})=>{}, Err(error)=>warn!(%error,"invalid websocket message"),
},
Some(Ok(Message::Close(_)))|None=>break, Some(Ok(_))=>{}, Some(Err(error))=>{debug!(%error,"websocket receive error");break;}
},
update=updates.recv()=>match update {
Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;},
Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;},
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,&note_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
}
}
}
let users = state.leave_room(&room_key, connection_id).await;
let _ = channel.send(RoomEvent::Presence(users));
info!(
workspace_id = workspace.id,
note_id = note.id,
"note websocket disconnected"
);
}
fn clean_nickname(value: Option<String>) -> Option<String> {
value
.map(|v| v.trim().chars().take(40).collect::<String>())
.filter(|v| !v.is_empty())
}
fn clean_guest_id(value: Option<String>) -> Option<String> {
value
.map(|v| v.trim().chars().take(64).collect::<String>())
.filter(|v| {
v.len() >= 16
&& v.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
})
}
fn clean_color(value: Option<String>) -> Option<String> {
value.map(|v| v.trim().to_ascii_lowercase()).filter(|v| {
v.len() == 7 && v.starts_with('#') && v[1..].chars().all(|c| c.is_ascii_hexdigit())
})
}
fn clean_chat(value: String) -> String {
value
.chars()
.map(|c| {
if matches!(c, '\r' | '\n' | '\0') {
' '
} else {
c
}
})
.collect::<String>()
.trim()
.chars()
.take(1000)
.collect()
}
async fn send_error(socket: &mut WebSocket, message: &str) -> Result<(), axum::Error> {
send(
socket,
&ServerMessage::Error {
message: message.into(),
},
)
.await
}
async fn send(socket: &mut WebSocket, message: &ServerMessage) -> Result<(), axum::Error> {
socket
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
}
async fn send_split(
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
message: &ServerMessage,
) -> Result<(), axum::Error> {
sender
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
}
+219
View File
@@ -0,0 +1,219 @@
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum PadServerMessage {
Authenticated {
title: String,
content: String,
owner_map: String,
access_level: String,
},
Document {
content: String,
revision_id: i64,
updated_at: String,
author: Option<String>,
owner_map: String,
},
Presence {
users: Vec<PresenceUser>,
},
Chat {
sender: String,
text: String,
},
Pong {
nonce: u64,
},
Error {
message: String,
},
}
pub async fn upgrade_pad(
ws: WebSocketUpgrade,
Path(slug): Path<String>,
State(state): State<SharedState>,
) -> Response {
ws.on_upgrade(move |socket| handle_pad_socket(socket, state, slug))
}
async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: String) {
info!(%slug, "pad websocket connected");
let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else {
warn!(%slug, "pad websocket rejected: pad not found");
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: "Note not found".into(),
},
)
.await;
return;
};
let (password, access_token, nickname, session_token, guest_id, color) =
match socket.recv().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Authenticate {
password,
access_token,
nickname,
session_token,
guest_id,
color,
}) => (
password,
access_token,
clean_nickname(nickname),
session_token,
clean_guest_id(guest_id),
clean_color(color),
),
_ => {
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: "Wymagane uwierzytelnienie".into(),
},
)
.await;
return;
}
},
_ => return,
};
let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await {
Ok(value) => value,
Err(message) => {
let _ = send_pad(&mut socket, &PadServerMessage::Error { message }).await;
return;
}
};
let presence_identity = match session_token.as_deref() {
Some(token) => auth::user_from_token(&state, token)
.await
.ok()
.flatten()
.map(|user| format!("user:{}", user.id)),
None => guest_id.as_ref().and_then(|id| {
nickname
.as_ref()
.map(|name| format!("guest:{id}:{}", name.to_lowercase()))
}),
};
let supplied_token = session_token.as_deref().or(access_token.as_deref());
let permission = auth::resource_permission(
&state,
"pad",
&slug,
supplied_token,
)
.await
.ok()
.flatten();
let anonymous_token_ok = permission.is_none()
&& crate::api::verify_resource_access_token(&state, "pad", &slug, supplied_token)
.await
.unwrap_or(false);
let password_ok = db::verify_pad_password(&pad, password.as_deref());
if pad.is_private != 0 && permission.is_none() && !anonymous_token_ok {
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: "This note is private".into(),
},
)
.await;
return;
}
if pad.password_hash.is_some() && !password_ok && permission.is_none() && !anonymous_token_ok {
warn!(pad_id = pad.id, "pad websocket rejected: invalid password");
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: "Invalid password".into(),
},
)
.await;
return;
}
let write_allowed = permission.as_deref() == Some("rw")
|| anonymous_token_ok
|| password_ok
|| (pad.is_private == 0 && pad.password_hash.is_none() && permission.is_none());
info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated");
if send_pad(
&mut socket,
&PadServerMessage::Authenticated {
title: pad.title.clone(),
content: pad.content.clone(),
owner_map: pad.owner_map.clone(),
access_level: if write_allowed { "full".into() } else { "read_only".into() },
},
)
.await
.is_err()
{
return;
}
let room_key = AppState::pad_room_key(&slug);
let channel = state.pad_channel(&slug).await;
let mut updates = channel.subscribe();
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
let (connection_id, users) = state
.join_room(&room_key, display_name.clone(), color, presence_identity)
.await;
let _ = channel.send(RoomEvent::Presence(users));
let mut last_chat = Instant::now() - Duration::from_secs(1);
let (mut sender, mut receiver) = socket.split();
loop {
tokio::select! {
incoming=receiver.next()=>match incoming{
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text){
Ok(ClientMessage::Update{content,owner_map})=>{if !write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;}
if content.len()>2_000_000 { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await; continue; }
let owner_map=owner_map.unwrap_or_else(||"[]".into());
if let Ok((revision_id,updated_at))=db::save_pad_revision(&state.db,pad.id,&content,nickname.as_deref(),&owner_map).await{
let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}));
}
}
Ok(ClientMessage::Ping{nonce})=>{ let _=send_pad_split(&mut sender,&PadServerMessage::Pong{nonce}).await; },
Ok(ClientMessage::Chat{text})=>{
let text=clean_chat(text);
if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); }
}
Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); },
Ok(ClientMessage::Authenticate{..})=>{},
Err(error)=>warn!(%error,"invalid pad websocket message"),
},
Some(Ok(Message::Close(_)))|None=>break,
Some(Ok(_))=>{},
Some(Err(error))=>{debug!(%error,"pad websocket receive error");break;}
},
update=updates.recv()=>match update{
Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{content:u.content,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,owner_map:u.owner_map}).await.is_err(){break;},
Ok(RoomEvent::Presence(users))=>if send_pad_split(&mut sender,&PadServerMessage::Presence{users}).await.is_err(){break;},
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_pad_split(&mut sender,&PadServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_pad(&state.db,&slug).await { if send_pad_split(&mut sender,&PadServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
}
}
}
let users = state.leave_room(&room_key, connection_id).await;
let _ = channel.send(RoomEvent::Presence(users));
info!(pad_id = pad.id, "pad websocket disconnected");
}
async fn send_pad(socket: &mut WebSocket, message: &PadServerMessage) -> Result<(), axum::Error> {
socket
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
}
async fn send_pad_split(
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
message: &PadServerMessage,
) -> Result<(), axum::Error> {
sender
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
}