split rs files
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct AccessTokenRequest {
|
pub struct AccessTokenRequest {
|
||||||
kind: String,
|
kind: String,
|
||||||
@@ -87,86 +89,7 @@ pub async fn verify_resource_access_token(
|
|||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hash_access_token(token: &str) -> String {
|
pub(super) fn hash_access_token(token: &str) -> String {
|
||||||
hex::encode(Sha256::digest(token.as_bytes()))
|
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,85 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct ApiError {
|
||||||
|
status: StatusCode,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiError {
|
||||||
|
pub(crate) fn bad_request(message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn not_found_file() -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
message: "File not found".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn unauthorized() -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::UNAUTHORIZED,
|
||||||
|
message: "Invalid password".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn forbidden(message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::FORBIDDEN,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn not_found_workspace() -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
message: "Workspace not found".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn not_found_note() -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
message: "Note not found".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn not_found_revision() -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
message: "Revision not found".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-9
@@ -1,3 +1,6 @@
|
|||||||
|
use super::*;
|
||||||
|
use super::pads_public::authorized_pad;
|
||||||
|
|
||||||
pub async fn upload_pad_file(
|
pub async fn upload_pad_file(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -465,15 +468,6 @@ async fn serve_token_file(
|
|||||||
Ok(response)
|
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 {
|
fn sanitize_filename(value: &str) -> String {
|
||||||
let name = std::path::Path::new(value)
|
let name = std::path::Path::new(value)
|
||||||
.file_name()
|
.file_name()
|
||||||
|
|||||||
+732
-4
@@ -1,3 +1,13 @@
|
|||||||
|
mod access_tokens;
|
||||||
|
mod error;
|
||||||
|
mod files;
|
||||||
|
mod pads_public;
|
||||||
|
|
||||||
|
pub use access_tokens::*;
|
||||||
|
pub use error::ApiError;
|
||||||
|
pub use files::*;
|
||||||
|
pub use pads_public::*;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Multipart, Path, State},
|
extract::{Multipart, Path, State},
|
||||||
@@ -20,8 +30,726 @@ const MIN_PASSWORD_LENGTH: usize = 8;
|
|||||||
const MAX_PASSWORD_LENGTH: usize = 128;
|
const MAX_PASSWORD_LENGTH: usize = 128;
|
||||||
const MIN_WORKSPACE_SLUG_LENGTH: usize = 6;
|
const MIN_WORKSPACE_SLUG_LENGTH: usize = 6;
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(access_tokens::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"))
|
||||||
|
}
|
||||||
|
|
||||||
include!("workspace_notes.rs");
|
|
||||||
include!("pads_public.rs");
|
|
||||||
include!("files.rs");
|
|
||||||
include!("access_tokens.rs");
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreatePadRequest {
|
pub struct CreatePadRequest {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -343,7 +345,7 @@ pub async fn pad_restore(
|
|||||||
Ok(Json(serde_json::json!({"ok": true})))
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn authorized_pad(
|
pub(super) async fn authorized_pad(
|
||||||
state: &SharedState,
|
state: &SharedState,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
|
|||||||
@@ -1,714 +0,0 @@
|
|||||||
#[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"))
|
|
||||||
}
|
|
||||||
|
|
||||||
+40
-47
@@ -1,3 +1,5 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum FileOwnerKind {
|
pub enum FileOwnerKind {
|
||||||
Pad,
|
Pad,
|
||||||
@@ -113,13 +115,15 @@ async fn list_files(
|
|||||||
owner_id: i64,
|
owner_id: i64,
|
||||||
) -> Result<Vec<NoteFile>, sqlx::Error> {
|
) -> Result<Vec<NoteFile>, sqlx::Error> {
|
||||||
if pool.kind() == DatabaseKind::Sqlite {
|
if pool.kind() == DatabaseKind::Sqlite {
|
||||||
return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), query))
|
return Ok(
|
||||||
.bind(owner_id)
|
sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), query))
|
||||||
.fetch_all(pool.pool())
|
.bind(owner_id)
|
||||||
.await?
|
.fetch_all(pool.pool())
|
||||||
.into_iter()
|
.await?
|
||||||
.map(NoteFile::from)
|
.into_iter()
|
||||||
.collect());
|
.map(NoteFile::from)
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), query))
|
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), query))
|
||||||
.bind(owner_id)
|
.bind(owner_id)
|
||||||
@@ -194,12 +198,14 @@ pub async fn find_note_file(
|
|||||||
file_id: i64,
|
file_id: i64,
|
||||||
) -> Result<Option<NoteFile>, sqlx::Error> {
|
) -> Result<Option<NoteFile>, sqlx::Error> {
|
||||||
if pool.kind() == DatabaseKind::Sqlite {
|
if pool.kind() == DatabaseKind::Sqlite {
|
||||||
return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), queries::Q038))
|
return Ok(
|
||||||
.bind(file_id)
|
sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), queries::Q038))
|
||||||
.bind(note_id)
|
.bind(file_id)
|
||||||
.fetch_optional(pool.pool())
|
.bind(note_id)
|
||||||
.await?
|
.fetch_optional(pool.pool())
|
||||||
.map(NoteFile::from));
|
.await?
|
||||||
|
.map(NoteFile::from),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q038))
|
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q038))
|
||||||
.bind(file_id)
|
.bind(file_id)
|
||||||
@@ -227,12 +233,14 @@ pub async fn find_pad_file(
|
|||||||
file_id: i64,
|
file_id: i64,
|
||||||
) -> Result<Option<NoteFile>, sqlx::Error> {
|
) -> Result<Option<NoteFile>, sqlx::Error> {
|
||||||
if pool.kind() == DatabaseKind::Sqlite {
|
if pool.kind() == DatabaseKind::Sqlite {
|
||||||
return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), queries::Q046))
|
return Ok(
|
||||||
.bind(file_id)
|
sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), queries::Q046))
|
||||||
.bind(pad_id)
|
.bind(file_id)
|
||||||
.fetch_optional(pool.pool())
|
.bind(pad_id)
|
||||||
.await?
|
.fetch_optional(pool.pool())
|
||||||
.map(NoteFile::from));
|
.await?
|
||||||
|
.map(NoteFile::from),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q046))
|
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q046))
|
||||||
.bind(file_id)
|
.bind(file_id)
|
||||||
@@ -255,33 +263,18 @@ pub async fn delete_pad_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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 {
|
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")? }) }
|
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")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+458
-4
@@ -7,9 +7,463 @@ use chrono::{DateTime, NaiveDateTime, Utc};
|
|||||||
use rand_core::{OsRng, RngCore};
|
use rand_core::{OsRng, RngCore};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
use sqlx::{any::AnyRow, Any, Row, Transaction};
|
use sqlx::{Any, Row, Transaction, any::AnyRow};
|
||||||
|
|
||||||
|
mod files;
|
||||||
|
mod public_pages;
|
||||||
|
|
||||||
|
pub use files::*;
|
||||||
|
pub use public_pages::*;
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
include!("workspace_notes.rs");
|
impl<'r> sqlx::FromRow<'r, AnyRow> for Workspace {
|
||||||
include!("pads_public.rs");
|
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
||||||
include!("files.rs");
|
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")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,102 +1,4 @@
|
|||||||
#[derive(Debug, Clone)]
|
use super::*;
|
||||||
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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct PublishedPage {
|
pub struct PublishedPage {
|
||||||
@@ -197,13 +99,14 @@ pub async fn find_published_page(
|
|||||||
token: &str,
|
token: &str,
|
||||||
) -> Result<Option<PublishedPage>, sqlx::Error> {
|
) -> Result<Option<PublishedPage>, sqlx::Error> {
|
||||||
if pool.kind() == DatabaseKind::Postgres {
|
if pool.kind() == DatabaseKind::Postgres {
|
||||||
return Ok(
|
return Ok(sqlx::query_as::<_, PostgresPublishedPageRow>(queries::get(
|
||||||
sqlx::query_as::<_, PostgresPublishedPageRow>(queries::get(pool.kind(), queries::Q021))
|
pool.kind(),
|
||||||
.bind(token)
|
queries::Q021,
|
||||||
.fetch_optional(pool.pool())
|
))
|
||||||
.await?
|
.bind(token)
|
||||||
.map(Into::into),
|
.fetch_optional(pool.pool())
|
||||||
);
|
.await?
|
||||||
|
.map(Into::into));
|
||||||
}
|
}
|
||||||
Ok(
|
Ok(
|
||||||
sqlx::query_as::<_, PublishedPageRow>(queries::get(pool.kind(), queries::Q021))
|
sqlx::query_as::<_, PublishedPageRow>(queries::get(pool.kind(), queries::Q021))
|
||||||
@@ -216,11 +119,13 @@ pub async fn find_published_page(
|
|||||||
|
|
||||||
pub async fn pad_public_task_updates(pool: &Database, pad_id: i64) -> Result<bool, sqlx::Error> {
|
pub async fn pad_public_task_updates(pool: &Database, pad_id: i64) -> Result<bool, sqlx::Error> {
|
||||||
if pool.kind() == DatabaseKind::Postgres {
|
if pool.kind() == DatabaseKind::Postgres {
|
||||||
return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q044))
|
return Ok(
|
||||||
.bind(pad_id)
|
sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q044))
|
||||||
.fetch_optional(pool.pool())
|
.bind(pad_id)
|
||||||
.await?
|
.fetch_optional(pool.pool())
|
||||||
.unwrap_or(false));
|
.await?
|
||||||
|
.unwrap_or(false),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q044))
|
let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q044))
|
||||||
.bind(pad_id)
|
.bind(pad_id)
|
||||||
@@ -232,11 +137,13 @@ pub async fn pad_public_task_updates(pool: &Database, pad_id: i64) -> Result<boo
|
|||||||
|
|
||||||
pub async fn note_public_task_updates(pool: &Database, note_id: i64) -> Result<bool, sqlx::Error> {
|
pub async fn note_public_task_updates(pool: &Database, note_id: i64) -> Result<bool, sqlx::Error> {
|
||||||
if pool.kind() == DatabaseKind::Postgres {
|
if pool.kind() == DatabaseKind::Postgres {
|
||||||
return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q045))
|
return Ok(
|
||||||
.bind(note_id)
|
sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q045))
|
||||||
.fetch_optional(pool.pool())
|
.bind(note_id)
|
||||||
.await?
|
.fetch_optional(pool.pool())
|
||||||
.unwrap_or(false));
|
.await?
|
||||||
|
.unwrap_or(false),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q045))
|
let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q045))
|
||||||
.bind(note_id)
|
.bind(note_id)
|
||||||
@@ -377,3 +284,30 @@ pub async fn note_file_token(pool: &Database, note_id: i64) -> Result<String, sq
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
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,73 +0,0 @@
|
|||||||
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 {
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
#[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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
+309
-3
@@ -14,7 +14,313 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
mod pad;
|
||||||
|
|
||||||
include!("messages.rs");
|
pub use pad::upgrade_pad;
|
||||||
include!("note.rs");
|
|
||||||
include!("pad.rs");
|
#[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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merged from note.rs
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merged from pad.rs
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
+11
-10
@@ -1,3 +1,5 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
enum PadServerMessage {
|
enum PadServerMessage {
|
||||||
@@ -99,15 +101,10 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
let supplied_token = session_token.as_deref().or(access_token.as_deref());
|
let supplied_token = session_token.as_deref().or(access_token.as_deref());
|
||||||
let permission = auth::resource_permission(
|
let permission = auth::resource_permission(&state, "pad", &slug, supplied_token)
|
||||||
&state,
|
.await
|
||||||
"pad",
|
.ok()
|
||||||
&slug,
|
.flatten();
|
||||||
supplied_token,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
let anonymous_token_ok = permission.is_none()
|
let anonymous_token_ok = permission.is_none()
|
||||||
&& crate::api::verify_resource_access_token(&state, "pad", &slug, supplied_token)
|
&& crate::api::verify_resource_access_token(&state, "pad", &slug, supplied_token)
|
||||||
.await
|
.await
|
||||||
@@ -145,7 +142,11 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
|
|||||||
title: pad.title.clone(),
|
title: pad.title.clone(),
|
||||||
content: pad.content.clone(),
|
content: pad.content.clone(),
|
||||||
owner_map: pad.owner_map.clone(),
|
owner_map: pad.owner_map.clone(),
|
||||||
access_level: if write_allowed { "full".into() } else { "read_only".into() },
|
access_level: if write_allowed {
|
||||||
|
"full".into()
|
||||||
|
} else {
|
||||||
|
"read_only".into()
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
Reference in New Issue
Block a user