fix in smtp and split rs files
This commit is contained in:
@@ -72,6 +72,7 @@ SHARE_CONFIRMATION_REQUIRED=true
|
||||
# smtp mailing
|
||||
# port 465 → tls
|
||||
# port 587 → starttls
|
||||
# SMTP_SECURITY not req.
|
||||
PUBLIC_URL=https://pad.example.com
|
||||
# SMTP_HOST=smtp.example.com
|
||||
#SMTP_SECURITY=none
|
||||
|
||||
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustpad"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
edition = "2024"
|
||||
rust-version = "1.94"
|
||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||
|
||||
@@ -121,7 +121,7 @@ Migrations are stored in `migrations/sqlite`, `migrations/postgres`, and `migrat
|
||||
|
||||
Nicknames can be used anonymously while they remain unregistered. Registering a nickname reserves it and requires a valid login session before it can be used in editor WebSocket connections.
|
||||
|
||||
Configure `PUBLIC_URL`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURITY`, `SMTP_USERNAME`, `SMTP_PASSWORD`, and `SMTP_FROM` to enable password-reset emails. `SMTP_FROM` accepts both `RustPad <no-reply@example.com>` and a value wrapped in one matching pair of single or double quotes, as may be passed literally by container env-file implementations. `SMTP_SECURITY` accepts `none` (plain SMTP, typically an internal relay on port 25), `starttls`, or `tls` (implicit TLS, commonly port 465). When omitted, it defaults to `tls` for port 465 and `starttls` for other ports. Reset links expire after 30 minutes and can be used only once.
|
||||
Configure `PUBLIC_URL`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURITY`, `SMTP_USERNAME`, `SMTP_PASSWORD`, and `SMTP_FROM` to enable password-reset emails. `SMTP_FROM` accepts both `RustPad <no-reply@example.com>` and a value wrapped in one matching pair of single or double quotes, as may be passed literally by container env-file implementations. `SMTP_SECURITY` accepts `none` (plain SMTP, typically an internal relay on port 25), `starttls`, or `tls` (implicit TLS, commonly port 465). When omitted, it defaults to `tls` for port 465, `starttls` for port 587, and `none` for port 25 or any other port. SMTP authentication is enabled only when both `SMTP_USERNAME` and `SMTP_PASSWORD` are non-empty. Reset links expire after 30 minutes and can be used only once.
|
||||
|
||||
## Diagnostics and logging
|
||||
|
||||
|
||||
-1787
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
¬e_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,
|
||||
¬e_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,
|
||||
¬e_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,
|
||||
¬e_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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
@@ -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,
|
||||
¬e_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"))
|
||||
}
|
||||
|
||||
@@ -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(¬e.created_at),
|
||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||
protected: note.protected,
|
||||
created_by: note.created_by,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(WorkspaceOpenResponse {
|
||||
workspace: workspace_info_from(&workspace),
|
||||
notes,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn create_note(
|
||||
State(state): State<SharedState>,
|
||||
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(¬e.created_at),
|
||||
updated_at: db::normalize_timestamp(¬e.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, ¬e_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(¬e.created_at),
|
||||
updated_at: db::normalize_timestamp(¬e.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,
|
||||
¬e_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,
|
||||
¬e_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, ¬e_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
@@ -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, ¬e_slug).await {
|
||||
Ok(Some(note)) => {
|
||||
let html = include_str!("../static/note.html")
|
||||
.replace("__NOTE_TITLE__", &escape_html(¬e.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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+223
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, ¬e_slug).await {
|
||||
Ok(Some(note)) => {
|
||||
let html = include_str!("../../static/note.html")
|
||||
.replace("__NOTE_TITLE__", &escape_html(¬e.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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
+2
-2
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")? }) }
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬e_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, ¬e_slug);
|
||||
let channel = state.note_channel(&workspace_slug, ¬e_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,¬e_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
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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, ¬e_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, ¬e_slug);
|
||||
let channel = state.note_channel(&workspace_slug, ¬e_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,¬e_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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user