work
This commit is contained in:
+222
-20
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
extract::{Multipart, Path, State},
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
@@ -17,6 +17,18 @@ const MIN_PASSWORD_LENGTH: usize = 8;
|
||||
const MAX_PASSWORD_LENGTH: usize = 128;
|
||||
const MIN_WORKSPACE_SLUG_LENGTH: usize = 6;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PublishResponse {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PublicPageResponse {
|
||||
title: String,
|
||||
content: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateWorkspaceRequest {
|
||||
name: String,
|
||||
@@ -89,7 +101,7 @@ pub async fn create_workspace(
|
||||
State(state): State<SharedState>,
|
||||
Json(payload): Json<CreateWorkspaceRequest>,
|
||||
) -> Result<(StatusCode, Json<CreateWorkspaceResponse>), ApiError> {
|
||||
let title = validate_name(&payload.name, "Nazwa workspace")?;
|
||||
let title = validate_name(&payload.name, "Workspace name")?;
|
||||
let password = validate_password(payload.password.as_deref())?;
|
||||
let slug = unique_workspace_slug(&state, title).await?;
|
||||
|
||||
@@ -144,10 +156,10 @@ pub async fn create_note(
|
||||
Json(payload): Json<CreateNoteRequest>,
|
||||
) -> Result<(StatusCode, Json<NoteListItem>), ApiError> {
|
||||
let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref()).await?;
|
||||
let title = validate_name(&payload.name, "Nazwa notatki")?;
|
||||
let title = validate_name(&payload.name, "Note name")?;
|
||||
let base = slugify(title);
|
||||
if base.is_empty() {
|
||||
return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu"));
|
||||
return Err(ApiError::bad_request("The name cannot be converted into a valid address"));
|
||||
}
|
||||
|
||||
let slug = unique_note_slug(&state, workspace.id, &base).await?;
|
||||
@@ -211,11 +223,13 @@ pub async fn restore(
|
||||
.await?;
|
||||
let content = content.ok_or_else(ApiError::not_found_revision)?;
|
||||
let (revision_id, updated_at) =
|
||||
db::save_revision(&state.db, note.id, workspace.id, &content).await?;
|
||||
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(update);
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
@@ -262,7 +276,7 @@ fn validate_name<'a>(value: &'a str, field: &str) -> Result<&'a str, ApiError> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.chars().count() > MAX_NAME_LENGTH {
|
||||
return Err(ApiError::bad_request(&format!(
|
||||
"{field} musi mieć od 1 do {MAX_NAME_LENGTH} znaków"
|
||||
"{field} must contain between 1 and {MAX_NAME_LENGTH} characters"
|
||||
)));
|
||||
}
|
||||
Ok(value)
|
||||
@@ -275,7 +289,7 @@ fn validate_password(password: Option<&str>) -> Result<Option<&str>, ApiError> {
|
||||
let length = password.chars().count();
|
||||
if !(MIN_PASSWORD_LENGTH..=MAX_PASSWORD_LENGTH).contains(&length) {
|
||||
return Err(ApiError::bad_request(
|
||||
"Hasło musi mieć od 8 do 128 znaków",
|
||||
"Password must contain between 8 and 128 characters",
|
||||
));
|
||||
}
|
||||
Ok(Some(password))
|
||||
@@ -284,7 +298,7 @@ fn validate_password(password: Option<&str>) -> Result<Option<&str>, ApiError> {
|
||||
async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result<String, ApiError> {
|
||||
let base = slugify(title);
|
||||
if base.is_empty() {
|
||||
return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu"));
|
||||
return Err(ApiError::bad_request("The name cannot be converted into a valid address"));
|
||||
}
|
||||
|
||||
let needs_suffix = base.chars().count() < MIN_WORKSPACE_SLUG_LENGTH
|
||||
@@ -299,7 +313,7 @@ async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result<Strin
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu"))
|
||||
Err(ApiError::internal("Failed to create a unique address"))
|
||||
}
|
||||
|
||||
async fn unique_note_slug(
|
||||
@@ -319,7 +333,7 @@ async fn unique_note_slug(
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu"))
|
||||
Err(ApiError::internal("Failed to create a unique address"))
|
||||
}
|
||||
|
||||
|
||||
@@ -349,11 +363,11 @@ pub async fn create_pad(
|
||||
State(state): State<SharedState>,
|
||||
Json(payload): Json<CreatePadRequest>,
|
||||
) -> Result<(StatusCode, Json<CreatePadResponse>), ApiError> {
|
||||
let title = validate_name(&payload.name, "Nazwa notatki")?;
|
||||
let title = validate_name(&payload.name, "Note name")?;
|
||||
let password = validate_password(payload.password.as_deref())?;
|
||||
let base = slugify(title);
|
||||
if base.is_empty() {
|
||||
return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu"));
|
||||
return Err(ApiError::bad_request("The name cannot be converted into a valid address"));
|
||||
}
|
||||
let slug = unique_pad_slug(&state, &base).await?;
|
||||
db::create_pad(&state.db, &slug, title, password).await?;
|
||||
@@ -382,6 +396,40 @@ pub async fn pad_info(
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn publish_pad_page(
|
||||
State(state): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<PublishResponse>, ApiError> {
|
||||
let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?;
|
||||
let token = db::publish_pad(&state.db, pad.id).await?;
|
||||
Ok(Json(PublishResponse { url: format!("/s/{token}") }))
|
||||
}
|
||||
|
||||
pub async fn publish_note_page(
|
||||
State(state): State<SharedState>,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<PublishResponse>, ApiError> {
|
||||
let (_, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?;
|
||||
let token = db::publish_note(&state.db, note.id).await?;
|
||||
Ok(Json(PublishResponse { url: format!("/s/{token}") }))
|
||||
}
|
||||
|
||||
pub async fn public_page(
|
||||
State(state): State<SharedState>,
|
||||
Path(token): Path<String>,
|
||||
) -> Result<Json<PublicPageResponse>, ApiError> {
|
||||
let page = db::find_published_page(&state.db, &token)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
Ok(Json(PublicPageResponse {
|
||||
title: page.title,
|
||||
content: page.content,
|
||||
updated_at: db::normalize_timestamp(&page.updated_at),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn pad_history(
|
||||
State(state): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
@@ -405,11 +453,19 @@ pub async fn pad_restore(
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let content = content.ok_or_else(ApiError::not_found_revision)?;
|
||||
let (revision_id, updated_at) = db::save_pad_revision(&state.db, pad.id, &content).await?;
|
||||
let owner_map: Option<String> = sqlx::query_scalar("SELECT owner_map FROM revisions WHERE id = ? AND pad_id = ?")
|
||||
.bind(payload.revision_id)
|
||||
.bind(pad.id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let owner_map = owner_map.unwrap_or_else(|| "[]".into());
|
||||
let (revision_id, updated_at) = db::save_pad_revision(&state.db, pad.id, &content, Some("restore"), &owner_map).await?;
|
||||
let update = NoteUpdate {
|
||||
content,
|
||||
revision_id,
|
||||
updated_at,
|
||||
author: Some("restore".into()),
|
||||
owner_map,
|
||||
};
|
||||
let _ = state.pad_channel(&slug).await.send(update);
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
@@ -439,7 +495,143 @@ async fn unique_pad_slug(state: &SharedState, base: &str) -> Result<String, ApiE
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu"))
|
||||
Err(ApiError::internal("Failed to create a unique address"))
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub async fn upload_pad_file(
|
||||
State(state): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let mut password: Option<String> = None;
|
||||
let mut file: Option<(String, Vec<u8>)> = None;
|
||||
while let Some(field) = multipart.next_field().await.map_err(|_| ApiError::bad_request("Invalid form data"))? {
|
||||
let name = field.name().unwrap_or_default().to_owned();
|
||||
if name == "password" {
|
||||
password = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid password"))?);
|
||||
} else if name == "file" {
|
||||
let filename = field.file_name().unwrap_or("plik").to_owned();
|
||||
let bytes = field.bytes().await.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
|
||||
if bytes.len() > state.upload_max_size_bytes {
|
||||
return Err(ApiError::payload_too_large(state.upload_max_size_bytes));
|
||||
}
|
||||
file = Some((filename, bytes.to_vec()));
|
||||
}
|
||||
}
|
||||
let pad = authorized_pad(&state, &slug, password.as_deref()).await?;
|
||||
let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?;
|
||||
let safe = sanitize_filename(&original);
|
||||
let file_token = db::pad_file_token(&state.db, pad.id).await?;
|
||||
let directory = format!("{}_{}", pad.id, file_token);
|
||||
let dir = std::path::Path::new(&state.files_dir).join("pads").join(&directory);
|
||||
tokio::fs::create_dir_all(&dir).await.map_err(|_| ApiError::internal("Failed to create the files directory"))?;
|
||||
let mut stored = safe.clone();
|
||||
let mut path = dir.join(&stored);
|
||||
if path.exists() {
|
||||
let stem = std::path::Path::new(&safe).file_stem().and_then(|v| v.to_str()).unwrap_or("plik");
|
||||
let ext = std::path::Path::new(&safe).extension().and_then(|v| v.to_str()).map(|v| format!(".{v}")).unwrap_or_default();
|
||||
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
|
||||
path = dir.join(&stored);
|
||||
}
|
||||
tokio::fs::write(&path, bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?;
|
||||
Ok(Json(serde_json::json!({"name": stored, "url": format!("/f/{}/{}", file_token, stored)})))
|
||||
}
|
||||
|
||||
pub async fn upload_note_file(
|
||||
State(state): State<SharedState>,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let mut password: Option<String> = None;
|
||||
let mut file: Option<(String, Vec<u8>)> = None;
|
||||
while let Some(field) = multipart.next_field().await.map_err(|_| ApiError::bad_request("Invalid form data"))? {
|
||||
let name = field.name().unwrap_or_default().to_owned();
|
||||
if name == "password" {
|
||||
password = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid password"))?);
|
||||
} else if name == "file" {
|
||||
let filename = field.file_name().unwrap_or("plik").to_owned();
|
||||
let bytes = field.bytes().await.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
|
||||
if bytes.len() > state.upload_max_size_bytes {
|
||||
return Err(ApiError::payload_too_large(state.upload_max_size_bytes));
|
||||
}
|
||||
file = Some((filename, bytes.to_vec()));
|
||||
}
|
||||
}
|
||||
let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, password.as_deref()).await?;
|
||||
let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?;
|
||||
let safe = sanitize_filename(&original);
|
||||
let file_token = db::note_file_token(&state.db, note.id).await?;
|
||||
let directory = format!("{}_{}", note.id, file_token);
|
||||
let dir = std::path::Path::new(&state.files_dir).join("notes").join(&directory);
|
||||
tokio::fs::create_dir_all(&dir).await.map_err(|_| ApiError::internal("Failed to create the files directory"))?;
|
||||
let mut stored = safe.clone();
|
||||
let mut path = dir.join(&stored);
|
||||
if path.exists() {
|
||||
let stem = std::path::Path::new(&safe).file_stem().and_then(|v| v.to_str()).unwrap_or("plik");
|
||||
let ext = std::path::Path::new(&safe).extension().and_then(|v| v.to_str()).map(|v| format!(".{v}")).unwrap_or_default();
|
||||
stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
|
||||
path = dir.join(&stored);
|
||||
}
|
||||
tokio::fs::write(&path, bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?;
|
||||
Ok(Json(serde_json::json!({"name": stored, "url": format!("/f/{}/{}", file_token, stored)})))
|
||||
}
|
||||
|
||||
|
||||
pub async fn download_file(
|
||||
State(state): State<SharedState>,
|
||||
Path((token, filename)): Path<(String, String)>,
|
||||
) -> Result<Response, ApiError> {
|
||||
serve_token_file(&state, &token, &filename).await
|
||||
}
|
||||
|
||||
pub async fn download_legacy_file(
|
||||
State(state): State<SharedState>,
|
||||
Path((directory, filename)): Path<(String, String)>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let Some((id_part, token)) = directory.split_once('_') else {
|
||||
return Err(ApiError::not_found_file());
|
||||
};
|
||||
let id: i64 = id_part.parse().map_err(|_| ApiError::not_found_file())?;
|
||||
let owner = db::find_file_owner(&state.db, token).await?
|
||||
.ok_or_else(ApiError::not_found_file)?;
|
||||
if owner.id != id {
|
||||
return Err(ApiError::not_found_file());
|
||||
}
|
||||
serve_token_file(&state, token, &filename).await
|
||||
}
|
||||
|
||||
async fn serve_token_file(state: &SharedState, token: &str, filename: &str) -> Result<Response, ApiError> {
|
||||
let safe = sanitize_filename(filename);
|
||||
if safe != filename {
|
||||
return Err(ApiError::not_found_file());
|
||||
}
|
||||
let owner = db::find_file_owner(&state.db, token).await?
|
||||
.ok_or_else(ApiError::not_found_file)?;
|
||||
let kind = match owner.kind {
|
||||
db::FileOwnerKind::Pad => "pads",
|
||||
db::FileOwnerKind::Note => "notes",
|
||||
};
|
||||
let directory = format!("{}_{}", owner.id, token);
|
||||
let canonical = std::path::Path::new(&state.files_dir).join(kind).join(&directory).join(&safe);
|
||||
let legacy = std::path::Path::new(&state.files_dir).join(&directory).join(&safe);
|
||||
let path = if canonical.is_file() { canonical } else { legacy };
|
||||
let bytes = tokio::fs::read(&path).await.map_err(|_| ApiError::not_found_file())?;
|
||||
let mime = mime_guess::from_path(&safe).first_or_octet_stream();
|
||||
let mut response = bytes.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_str(mime.as_ref()).unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
|
||||
);
|
||||
response.headers_mut().insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn sanitize_filename(value: &str) -> String {
|
||||
let name = std::path::Path::new(value).file_name().and_then(|v| v.to_str()).unwrap_or("plik");
|
||||
let clean: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' }).collect();
|
||||
if clean.is_empty() || clean == "." || clean == ".." { "plik".into() } else { clean.chars().take(160).collect() }
|
||||
}
|
||||
|
||||
pub struct ApiError {
|
||||
@@ -454,28 +646,38 @@ impl ApiError {
|
||||
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: "Nieprawidłowe hasło".into(),
|
||||
message: "Invalid password".into(),
|
||||
}
|
||||
}
|
||||
fn not_found_workspace() -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "Nie znaleziono workspace".into(),
|
||||
message: "Workspace not found".into(),
|
||||
}
|
||||
}
|
||||
fn not_found_note() -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "Nie znaleziono notatki".into(),
|
||||
message: "Note not found".into(),
|
||||
}
|
||||
}
|
||||
fn not_found_revision() -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "Nie znaleziono wersji".into(),
|
||||
message: "Revision not found".into(),
|
||||
}
|
||||
}
|
||||
fn internal(message: &str) -> Self {
|
||||
@@ -489,7 +691,7 @@ impl ApiError {
|
||||
impl From<sqlx::Error> for ApiError {
|
||||
fn from(error: sqlx::Error) -> Self {
|
||||
tracing::error!(%error, "database error");
|
||||
Self::internal("Błąd bazy danych")
|
||||
Self::internal("Database error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+58
-21
@@ -1,5 +1,5 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{DefaultBodyLimit, Path, State},
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
@@ -9,17 +9,23 @@ use tower_http::{services::ServeDir, trace::TraceLayer};
|
||||
|
||||
use crate::{api, db, state::SharedState, websocket};
|
||||
|
||||
pub fn router(state: SharedState, static_dir: &str) -> Router {
|
||||
pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(home))
|
||||
.route("/p/{slug}", get(pad))
|
||||
.route("/s/{token}", get(public_page))
|
||||
.route("/w/{workspace_slug}", get(workspace))
|
||||
.route("/w/{workspace_slug}/n/{note_slug}", get(note))
|
||||
.route("/health", get(health))
|
||||
.route("/f/{token}/{filename}", get(api::download_file))
|
||||
.route("/files/{directory}/{filename}", get(api::download_legacy_file))
|
||||
.route("/api/public/{token}", get(api::public_page))
|
||||
.route("/api/pads", post(api::create_pad))
|
||||
.route("/api/pads/{slug}", get(api::pad_info))
|
||||
.route("/api/pads/{slug}/history", post(api::pad_history))
|
||||
.route("/api/pads/{slug}/publish", post(api::publish_pad_page))
|
||||
.route("/api/pads/{slug}/restore", post(api::pad_restore))
|
||||
.route("/api/pads/{slug}/files", post(api::upload_pad_file))
|
||||
.route("/api/workspaces", post(api::create_workspace))
|
||||
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
|
||||
.route("/api/workspaces/{workspace_slug}/open", post(api::open_workspace))
|
||||
@@ -28,6 +34,10 @@ pub fn router(state: SharedState, static_dir: &str) -> Router {
|
||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}",
|
||||
get(api::note_info),
|
||||
)
|
||||
.route(
|
||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}/publish",
|
||||
post(api::publish_note_page),
|
||||
)
|
||||
.route(
|
||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}/history",
|
||||
post(api::history),
|
||||
@@ -36,6 +46,10 @@ pub fn router(state: SharedState, static_dir: &str) -> Router {
|
||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}/restore",
|
||||
post(api::restore),
|
||||
)
|
||||
.route(
|
||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}/files",
|
||||
post(api::upload_note_file),
|
||||
)
|
||||
.route("/ws/p/{slug}", get(websocket::upgrade_pad))
|
||||
.route(
|
||||
"/ws/{workspace_slug}/{note_slug}",
|
||||
@@ -43,6 +57,7 @@ pub fn router(state: SharedState, static_dir: &str) -> Router {
|
||||
)
|
||||
.nest_service("/assets", ServeDir::new(static_dir))
|
||||
.fallback(not_found)
|
||||
.layer(DefaultBodyLimit::max(upload_max_size_bytes.saturating_add(1024 * 1024)))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -64,10 +79,10 @@ async fn pad(
|
||||
Ok(None) => error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Nie znaleziono notatki",
|
||||
"Ta notatka nie istnieje albo została usunięta.",
|
||||
"Note not found",
|
||||
"This note does not exist or has been deleted.",
|
||||
"/",
|
||||
"Strona główna",
|
||||
"Home page",
|
||||
&state.asset_version,
|
||||
),
|
||||
Err(error) => {
|
||||
@@ -77,6 +92,28 @@ async fn pad(
|
||||
}
|
||||
}
|
||||
|
||||
async fn public_page(
|
||||
State(state): State<SharedState>,
|
||||
Path(token): Path<String>,
|
||||
) -> Response {
|
||||
match db::find_published_page(&state.db, &token).await {
|
||||
Ok(Some(_)) => versioned_html(include_str!("../static/public.html"), &state.asset_version),
|
||||
Ok(None) => error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Published page not found",
|
||||
"The link is invalid or the published page has been removed.",
|
||||
"/",
|
||||
"Home page",
|
||||
&state.asset_version,
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::error!(%error, %token, "failed to load published page");
|
||||
internal_error(&state.asset_version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn workspace(
|
||||
State(state): State<SharedState>,
|
||||
Path(workspace_slug): Path<String>,
|
||||
@@ -89,10 +126,10 @@ async fn workspace(
|
||||
Ok(None) => error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Nie znaleziono workspace",
|
||||
"Ten workspace nie istnieje albo został usunięty.",
|
||||
"Workspace not found",
|
||||
"This workspace does not exist or has been deleted.",
|
||||
"/",
|
||||
"Strona główna",
|
||||
"Home page",
|
||||
&state.asset_version,
|
||||
),
|
||||
Err(error) => {
|
||||
@@ -112,10 +149,10 @@ async fn note(
|
||||
return error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Nie znaleziono workspace",
|
||||
"Workspace tej notatki nie istnieje albo został usunięty.",
|
||||
"Workspace not found",
|
||||
"The workspace for this note does not exist or has been deleted.",
|
||||
"/",
|
||||
"Strona główna",
|
||||
"Home page",
|
||||
&state.asset_version,
|
||||
);
|
||||
}
|
||||
@@ -130,10 +167,10 @@ async fn note(
|
||||
Ok(None) => error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Nie znaleziono notatki",
|
||||
"Ta notatka nie istnieje albo została usunięta.",
|
||||
"Note not found",
|
||||
"This note does not exist or has been deleted.",
|
||||
&format!("/w/{workspace_slug}"),
|
||||
"Wróć do workspace",
|
||||
"Back do workspace",
|
||||
&state.asset_version,
|
||||
),
|
||||
Err(error) => {
|
||||
@@ -147,10 +184,10 @@ async fn not_found(State(state): State<SharedState>) -> Response {
|
||||
error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Nie znaleziono strony",
|
||||
"Sprawdź adres albo wróć na stronę główną.",
|
||||
"Page not found",
|
||||
"Check the address or return to the home page.",
|
||||
"/",
|
||||
"Strona główna",
|
||||
"Home page",
|
||||
&state.asset_version,
|
||||
)
|
||||
}
|
||||
@@ -159,10 +196,10 @@ fn internal_error(asset_version: &str) -> Response {
|
||||
error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"500",
|
||||
"Błąd serwera",
|
||||
"Nie udało się wczytać strony. Spróbuj ponownie za chwilę.",
|
||||
"Server error",
|
||||
"The page could not be loaded. Please try again shortly.",
|
||||
"/",
|
||||
"Strona główna",
|
||||
"Home page",
|
||||
asset_version,
|
||||
)
|
||||
}
|
||||
@@ -199,7 +236,7 @@ fn versioned_html(template: &str, asset_version: &str) -> Response {
|
||||
fn no_store(response: &mut Response) {
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache, no-store, must-revalidate"),
|
||||
HeaderValue::from_static("private, no-store"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -7,6 +7,8 @@ pub struct Config {
|
||||
pub database_url: String,
|
||||
pub database_max_connections: u32,
|
||||
pub static_dir: String,
|
||||
pub files_dir: String,
|
||||
pub upload_max_size_bytes: usize,
|
||||
pub asset_version: String,
|
||||
}
|
||||
|
||||
@@ -15,13 +17,21 @@ impl Config {
|
||||
let host = env_var("APP_HOST", "127.0.0.1").parse()?;
|
||||
let port = env_var("APP_PORT", "3000").parse()?;
|
||||
let database_max_connections = env_var("DATABASE_MAX_CONNECTIONS", "8").parse()?;
|
||||
let upload_max_size_mb: usize = env_var("UPLOAD_MAX_SIZE_MB", "20").parse()?;
|
||||
if upload_max_size_mb == 0 {
|
||||
return Err("UPLOAD_MAX_SIZE_MB > 0".into());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
host,
|
||||
port,
|
||||
database_url: env_var("DATABASE_URL", "sqlite://rustpad.db?mode=rwc"),
|
||||
database_url: env_var("DATABASE_URL", "sqlite:///data/db/rustpad.db?mode=rwc"),
|
||||
database_max_connections,
|
||||
static_dir: env_var("STATIC_DIR", "static"),
|
||||
files_dir: env_var("FILES_DIR", "data/files"),
|
||||
upload_max_size_bytes: upload_max_size_mb
|
||||
.checked_mul(1024 * 1024)
|
||||
.ok_or("UPLOAD_MAX_SIZE_MB to big")?,
|
||||
asset_version: env::var("ASSET_VERSION")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
|
||||
@@ -27,6 +27,7 @@ pub struct Note {
|
||||
pub content: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub owner_map: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
@@ -34,6 +35,8 @@ 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: &SqlitePool, slug: &str) -> Result<Option<Workspace>, sqlx::Error> {
|
||||
@@ -86,7 +89,7 @@ pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>)
|
||||
|
||||
pub async fn list_notes(pool: &SqlitePool, workspace_id: i64) -> Result<Vec<Note>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Note>(
|
||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC",
|
||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.fetch_all(pool)
|
||||
@@ -99,7 +102,7 @@ pub async fn find_note(
|
||||
slug: &str,
|
||||
) -> Result<Option<Note>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Note>(
|
||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE workspace_id = ? AND slug = ?",
|
||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? AND slug = ?",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(slug)
|
||||
@@ -123,7 +126,7 @@ pub async fn create_note(
|
||||
.await?;
|
||||
|
||||
sqlx::query_as::<_, Note>(
|
||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE id = ?",
|
||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE id = ?",
|
||||
)
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(pool)
|
||||
@@ -135,10 +138,13 @@ pub async fn save_revision(
|
||||
note_id: i64,
|
||||
workspace_id: i64,
|
||||
content: &str,
|
||||
author: Option<&str>,
|
||||
owner_map: &str,
|
||||
) -> Result<(i64, String), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
sqlx::query("UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
sqlx::query("UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(content)
|
||||
.bind(owner_map)
|
||||
.bind(note_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -146,9 +152,11 @@ pub async fn save_revision(
|
||||
.bind(workspace_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let result = sqlx::query("INSERT INTO note_revisions (note_id, content) VALUES (?, ?)")
|
||||
let result = sqlx::query("INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)")
|
||||
.bind(note_id)
|
||||
.bind(content)
|
||||
.bind(author)
|
||||
.bind(owner_map)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM notes WHERE id = ?")
|
||||
@@ -161,7 +169,7 @@ pub async fn save_revision(
|
||||
|
||||
pub async fn list_revisions(pool: &SqlitePool, note_id: i64) -> Result<Vec<Revision>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Revision>(
|
||||
"SELECT id, content, created_at FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100",
|
||||
"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100",
|
||||
)
|
||||
.bind(note_id)
|
||||
.fetch_all(pool)
|
||||
@@ -202,11 +210,12 @@ pub struct Pad {
|
||||
pub password_hash: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub owner_map: String,
|
||||
}
|
||||
|
||||
pub async fn find_pad(pool: &SqlitePool, slug: &str) -> Result<Option<Pad>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Pad>(
|
||||
"SELECT id, slug, title, content, password_hash, created_at, updated_at FROM pads WHERE slug = ?",
|
||||
"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map FROM pads WHERE slug = ?",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
@@ -230,7 +239,7 @@ pub async fn create_pad(
|
||||
.await?;
|
||||
|
||||
sqlx::query_as::<_, Pad>(
|
||||
"SELECT id, slug, title, content, password_hash, created_at, updated_at FROM pads WHERE id = ?",
|
||||
"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map FROM pads WHERE id = ?",
|
||||
)
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(pool)
|
||||
@@ -256,16 +265,21 @@ pub async fn save_pad_revision(
|
||||
pool: &SqlitePool,
|
||||
pad_id: i64,
|
||||
content: &str,
|
||||
author: Option<&str>,
|
||||
owner_map: &str,
|
||||
) -> Result<(i64, String), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
sqlx::query("UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
sqlx::query("UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(content)
|
||||
.bind(owner_map)
|
||||
.bind(pad_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let result = sqlx::query("INSERT INTO revisions (pad_id, content) VALUES (?, ?)")
|
||||
let result = sqlx::query("INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)")
|
||||
.bind(pad_id)
|
||||
.bind(content)
|
||||
.bind(author)
|
||||
.bind(owner_map)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM pads WHERE id = ?")
|
||||
@@ -281,9 +295,135 @@ pub async fn list_pad_revisions(
|
||||
pad_id: i64,
|
||||
) -> Result<Vec<Revision>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Revision>(
|
||||
"SELECT id, content, created_at FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100",
|
||||
"SELECT id, content, created_at, author, owner_map FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100",
|
||||
)
|
||||
.bind(pad_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct PublishedPage {
|
||||
pub token: String,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
pub async fn publish_pad(pool: &SqlitePool, pad_id: i64) -> Result<String, sqlx::Error> {
|
||||
if let Some(token) = sqlx::query_scalar::<_, String>("SELECT token FROM published_pages WHERE pad_id = ?")
|
||||
.bind(pad_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
{
|
||||
return Ok(token);
|
||||
}
|
||||
let token = random_suffix(18);
|
||||
sqlx::query("INSERT INTO published_pages (token, pad_id) VALUES (?, ?)")
|
||||
.bind(&token)
|
||||
.bind(pad_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub async fn publish_note(pool: &SqlitePool, note_id: i64) -> Result<String, sqlx::Error> {
|
||||
if let Some(token) = sqlx::query_scalar::<_, String>("SELECT token FROM published_pages WHERE note_id = ?")
|
||||
.bind(note_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
{
|
||||
return Ok(token);
|
||||
}
|
||||
let token = random_suffix(18);
|
||||
sqlx::query("INSERT INTO published_pages (token, note_id) VALUES (?, ?)")
|
||||
.bind(&token)
|
||||
.bind(note_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub async fn find_published_page(pool: &SqlitePool, token: &str) -> Result<Option<PublishedPage>, sqlx::Error> {
|
||||
sqlx::query_as::<_, PublishedPage>(
|
||||
"SELECT pp.token, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = ?",
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn pad_file_token(pool: &SqlitePool, pad_id: i64) -> Result<String, sqlx::Error> {
|
||||
if let Some(token) = sqlx::query_scalar::<_, Option<String>>("SELECT file_token FROM pads WHERE id = ?")
|
||||
.bind(pad_id)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
{
|
||||
return Ok(token);
|
||||
}
|
||||
|
||||
let token = format!("p_{}", random_suffix(24));
|
||||
sqlx::query("UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL")
|
||||
.bind(&token)
|
||||
.bind(pad_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query_scalar::<_, String>("SELECT file_token FROM pads WHERE id = ?")
|
||||
.bind(pad_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn note_file_token(pool: &SqlitePool, note_id: i64) -> Result<String, sqlx::Error> {
|
||||
if let Some(token) = sqlx::query_scalar::<_, Option<String>>("SELECT file_token FROM notes WHERE id = ?")
|
||||
.bind(note_id)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
{
|
||||
return Ok(token);
|
||||
}
|
||||
|
||||
let token = format!("n_{}", random_suffix(24));
|
||||
sqlx::query("UPDATE notes SET file_token = ? WHERE id = ? AND file_token IS NULL")
|
||||
.bind(&token)
|
||||
.bind(note_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query_scalar::<_, String>("SELECT file_token FROM notes WHERE id = ?")
|
||||
.bind(note_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum FileOwnerKind {
|
||||
Pad,
|
||||
Note,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FileOwner {
|
||||
pub kind: FileOwnerKind,
|
||||
pub id: i64,
|
||||
}
|
||||
|
||||
pub async fn find_file_owner(pool: &SqlitePool, token: &str) -> Result<Option<FileOwner>, sqlx::Error> {
|
||||
if let Some(id) = sqlx::query_scalar::<_, i64>("SELECT id FROM pads WHERE file_token = ?")
|
||||
.bind(token)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(FileOwner { kind: FileOwnerKind::Pad, id }));
|
||||
}
|
||||
if let Some(id) = sqlx::query_scalar::<_, i64>("SELECT id FROM notes WHERE file_token = ?")
|
||||
.bind(token)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(FileOwner { kind: FileOwnerKind::Note, id }));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
+15
-2
@@ -20,14 +20,27 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
init_tracing();
|
||||
|
||||
let config = Config::from_env()?;
|
||||
if let Some(path) = config.database_url.strip_prefix("sqlite://").and_then(|v| v.split('?').next()) {
|
||||
if let Some(parent) = std::path::Path::new(path).parent() { std::fs::create_dir_all(parent)?; }
|
||||
}
|
||||
let db = SqlitePoolOptions::new()
|
||||
.max_connections(config.database_max_connections)
|
||||
.connect(&config.database_url)
|
||||
.await?;
|
||||
sqlx::migrate!().run(&db).await?;
|
||||
|
||||
let state = Arc::new(AppState::new(db, config.asset_version.clone()));
|
||||
let app = app::router(state, &config.static_dir);
|
||||
std::fs::create_dir_all(&config.files_dir)?;
|
||||
let state = Arc::new(AppState::new(
|
||||
db,
|
||||
config.asset_version.clone(),
|
||||
config.files_dir.clone(),
|
||||
config.upload_max_size_bytes,
|
||||
));
|
||||
let app = app::router(
|
||||
state,
|
||||
&config.static_dir,
|
||||
config.upload_max_size_bytes,
|
||||
);
|
||||
let address = SocketAddr::new(config.host, config.port);
|
||||
let listener = TcpListener::bind(address).await?;
|
||||
|
||||
|
||||
+8
-18
@@ -1,5 +1,4 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
|
||||
@@ -10,40 +9,31 @@ pub struct NoteUpdate {
|
||||
pub content: String,
|
||||
pub revision_id: i64,
|
||||
pub updated_at: String,
|
||||
pub author: Option<String>,
|
||||
pub owner_map: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AppState {
|
||||
pub db: SqlitePool,
|
||||
pub asset_version: String,
|
||||
pub files_dir: String,
|
||||
pub upload_max_size_bytes: usize,
|
||||
channels: RwLock<HashMap<String, broadcast::Sender<NoteUpdate>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(db: SqlitePool, asset_version: String) -> Self {
|
||||
Self {
|
||||
db,
|
||||
asset_version,
|
||||
channels: RwLock::new(HashMap::new()),
|
||||
}
|
||||
pub fn new(db: SqlitePool, asset_version: String, files_dir: String, upload_max_size_bytes: usize) -> Self {
|
||||
Self { db, asset_version, files_dir, upload_max_size_bytes, channels: RwLock::new(HashMap::new()) }
|
||||
}
|
||||
|
||||
async fn channel_for_key(&self, key: String) -> broadcast::Sender<NoteUpdate> {
|
||||
if let Some(sender) = self.channels.read().await.get(&key) {
|
||||
return sender.clone();
|
||||
}
|
||||
|
||||
if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); }
|
||||
let mut channels = self.channels.write().await;
|
||||
channels
|
||||
.entry(key)
|
||||
.or_insert_with(|| broadcast::channel(CHANNEL_CAPACITY).0)
|
||||
.clone()
|
||||
channels.entry(key).or_insert_with(|| broadcast::channel(CHANNEL_CAPACITY).0).clone()
|
||||
}
|
||||
|
||||
pub async fn note_channel(&self, workspace_slug: &str, note_slug: &str) -> broadcast::Sender<NoteUpdate> {
|
||||
self.channel_for_key(format!("workspace:{workspace_slug}/{note_slug}")).await
|
||||
}
|
||||
|
||||
pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender<NoteUpdate> {
|
||||
self.channel_for_key(format!("pad:{slug}")).await
|
||||
}
|
||||
|
||||
+82
-259
@@ -1,292 +1,115 @@
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket},
|
||||
Path, State, WebSocketUpgrade,
|
||||
},
|
||||
response::Response,
|
||||
};
|
||||
use axum::{extract::{ws::{Message, WebSocket}, Path, State, WebSocketUpgrade}, response::Response};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::{
|
||||
db,
|
||||
state::{NoteUpdate, SharedState},
|
||||
};
|
||||
use crate::{db, state::{NoteUpdate, SharedState}};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ClientMessage {
|
||||
Authenticate { password: Option<String> },
|
||||
Update { content: String },
|
||||
Authenticate { password: Option<String>, nickname: Option<String> },
|
||||
Update { content: String, owner_map: Option<String> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ServerMessage {
|
||||
Authenticated {
|
||||
workspace_title: String,
|
||||
note_title: String,
|
||||
content: String,
|
||||
},
|
||||
Document {
|
||||
content: String,
|
||||
revision_id: i64,
|
||||
updated_at: String,
|
||||
},
|
||||
Authenticated { workspace_title: String, note_title: String, content: String, owner_map: String },
|
||||
Document { content: String, revision_id: i64, updated_at: String, author: Option<String>, owner_map: String },
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
pub async fn upgrade(
|
||||
ws: WebSocketUpgrade,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
State(state): State<SharedState>,
|
||||
) -> Response {
|
||||
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,
|
||||
) {
|
||||
let Some(workspace) = db::find_workspace(&state.db, &workspace_slug)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
let _ = send_error(&mut socket, "Nie znaleziono workspace").await;
|
||||
return;
|
||||
};
|
||||
let Some(note) = db::find_note(&state.db, workspace.id, ¬e_slug)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
let _ = send_error(&mut socket, "Nie znaleziono notatki").await;
|
||||
return;
|
||||
};
|
||||
|
||||
let password = match socket.recv().await {
|
||||
async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug: String, note_slug: String) {
|
||||
let Some(workspace) = db::find_workspace(&state.db, &workspace_slug).await.ok().flatten() else { 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 { let _=send_error(&mut socket,"Note not found").await; return; };
|
||||
let (password, nickname) = match socket.recv().await {
|
||||
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(ClientMessage::Authenticate { password }) => password,
|
||||
_ => {
|
||||
let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
_ => return,
|
||||
Ok(ClientMessage::Authenticate { password, nickname }) => (password, clean_nickname(nickname)),
|
||||
_ => { let _=send_error(&mut socket,"Wymagane uwierzytelnienie").await; return; }
|
||||
}, _ => return
|
||||
};
|
||||
|
||||
if !db::verify_workspace_password(&workspace, password.as_deref()) {
|
||||
let _ = send_error(&mut socket, "Nieprawidłowe hasło").await;
|
||||
return;
|
||||
}
|
||||
|
||||
if send(
|
||||
&mut socket,
|
||||
&ServerMessage::Authenticated {
|
||||
workspace_title: workspace.title.clone(),
|
||||
note_title: note.title.clone(),
|
||||
content: note.content.clone(),
|
||||
if !db::verify_workspace_password(&workspace, password.as_deref()) { let _=send_error(&mut socket,"Invalid password").await; return; }
|
||||
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() }).await.is_err(){return;}
|
||||
let channel=state.note_channel(&workspace_slug,¬e_slug).await;
|
||||
let mut updates=channel.subscribe();
|
||||
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 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(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map});}
|
||||
Err(error)=>warn!(%error,"failed to save revision"),
|
||||
}
|
||||
}
|
||||
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;}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let channel = state.note_channel(&workspace_slug, ¬e_slug).await;
|
||||
let mut updates = channel.subscribe();
|
||||
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 }) => {
|
||||
if content.len() > 2_000_000 {
|
||||
let _ = send_split(&mut sender, &ServerMessage::Error { message: "Dokument jest zbyt duży".into() }).await;
|
||||
continue;
|
||||
}
|
||||
match db::save_revision(&state.db, note.id, workspace.id, &content).await {
|
||||
Ok((revision_id, updated_at)) => {
|
||||
let _ = channel.send(NoteUpdate { content, revision_id, updated_at });
|
||||
}
|
||||
Err(error) => warn!(%error, "failed to save revision"),
|
||||
}
|
||||
}
|
||||
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(update) => {
|
||||
if send_split(&mut sender, &ServerMessage::Document {
|
||||
content: update.content,
|
||||
revision_id: update.revision_id,
|
||||
updated_at: update.updated_at,
|
||||
}).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,
|
||||
}).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
update=updates.recv()=>match update {
|
||||
Ok(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;},
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
let payload = serde_json::to_string(message).expect("serializing server message cannot fail");
|
||||
socket.send(Message::Text(payload.into())).await
|
||||
}
|
||||
|
||||
async fn send_split(
|
||||
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
||||
message: &ServerMessage,
|
||||
) -> Result<(), axum::Error> {
|
||||
let payload = serde_json::to_string(message).expect("serializing server message cannot fail");
|
||||
sender.send(Message::Text(payload.into())).await
|
||||
}}
|
||||
}
|
||||
fn clean_nickname(value: Option<String>)->Option<String>{value.map(|v|v.trim().chars().take(40).collect::<String>()).filter(|v|!v.is_empty())}
|
||||
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")]
|
||||
#[serde(tag="type",rename_all="snake_case")]
|
||||
enum PadServerMessage {
|
||||
Authenticated { title: String, content: String },
|
||||
Document { content: String, revision_id: i64, updated_at: String },
|
||||
Authenticated { title: String, content: String, owner_map: String },
|
||||
Document { content: String, revision_id: i64, updated_at: String, author: Option<String>, owner_map: String },
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
pub async fn upgrade_pad(
|
||||
ws: WebSocketUpgrade,
|
||||
Path(slug): Path<String>,
|
||||
State(state): State<SharedState>,
|
||||
) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_pad_socket(socket, state, slug))
|
||||
pub async fn upgrade_pad(ws:WebSocketUpgrade,Path(slug):Path<String>,State(state):State<SharedState>)->Response{
|
||||
ws.on_upgrade(move|socket|handle_pad_socket(socket,state,slug))
|
||||
}
|
||||
|
||||
async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: String) {
|
||||
let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else {
|
||||
let _ = send_pad(&mut socket, &PadServerMessage::Error { message: "Nie znaleziono notatki".into() }).await;
|
||||
return;
|
||||
async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){
|
||||
let Some(pad)=db::find_pad(&state.db,&slug).await.ok().flatten() else {let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Note not found".into()}).await;return;};
|
||||
let (password,nickname)=match socket.recv().await{
|
||||
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text){
|
||||
Ok(ClientMessage::Authenticate{password,nickname})=>(password,clean_nickname(nickname)),
|
||||
_=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Wymagane uwierzytelnienie".into()}).await;return;}
|
||||
},_=>return
|
||||
};
|
||||
|
||||
let password = match socket.recv().await {
|
||||
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(ClientMessage::Authenticate { password }) => password,
|
||||
_ => {
|
||||
let _ = send_pad(&mut socket, &PadServerMessage::Error { message: "Wymagane uwierzytelnienie".into() }).await;
|
||||
return;
|
||||
}
|
||||
if !db::verify_pad_password(&pad,password.as_deref()){let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Invalid password".into()}).await;return;}
|
||||
if send_pad(&mut socket,&PadServerMessage::Authenticated{title:pad.title.clone(),content:pad.content.clone(),owner_map:pad.owner_map.clone()}).await.is_err(){return;}
|
||||
let channel=state.pad_channel(&slug).await;
|
||||
let mut updates=channel.subscribe();
|
||||
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 content.len()>2_000_000 { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await; continue; }
|
||||
let owner_map=owner_map.unwrap_or_else(||"[]".into());
|
||||
if let Ok((revision_id,updated_at))=db::save_pad_revision(&state.db,pad.id,&content,nickname.as_deref(),&owner_map).await{
|
||||
let _=channel.send(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map});
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::Authenticate{..})=>{},
|
||||
Err(error)=>warn!(%error,"invalid pad websocket message"),
|
||||
},
|
||||
Some(Ok(Message::Close(_)))|None=>break,
|
||||
Some(Ok(_))=>{},
|
||||
Some(Err(error))=>{debug!(%error,"pad websocket receive error");break;}
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
|
||||
if !db::verify_pad_password(&pad, password.as_deref()) {
|
||||
let _ = send_pad(&mut socket, &PadServerMessage::Error { message: "Nieprawidłowe hasło".into() }).await;
|
||||
return;
|
||||
}
|
||||
|
||||
if send_pad(&mut socket, &PadServerMessage::Authenticated {
|
||||
title: pad.title.clone(),
|
||||
content: pad.content.clone(),
|
||||
}).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let channel = state.pad_channel(&slug).await;
|
||||
let mut updates = channel.subscribe();
|
||||
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 }) => {
|
||||
if content.len() > 2_000_000 {
|
||||
let _ = send_pad_split(&mut sender, &PadServerMessage::Error { message: "Dokument jest zbyt duży".into() }).await;
|
||||
continue;
|
||||
}
|
||||
match db::save_pad_revision(&state.db, pad.id, &content).await {
|
||||
Ok((revision_id, updated_at)) => {
|
||||
let _ = channel.send(NoteUpdate { content, revision_id, updated_at });
|
||||
}
|
||||
Err(error) => warn!(%error, "failed to save pad revision"),
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::Authenticate { .. }) => {}
|
||||
Err(error) => warn!(%error, "invalid pad WebSocket message"),
|
||||
},
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => {
|
||||
debug!(%error, "pad WebSocket receive error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
update = updates.recv() => match update {
|
||||
Ok(update) => {
|
||||
if send_pad_split(&mut sender, &PadServerMessage::Document {
|
||||
content: update.content,
|
||||
revision_id: update.revision_id,
|
||||
updated_at: update.updated_at,
|
||||
}).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
if let Ok(Some(current)) = db::find_pad(&state.db, &slug).await {
|
||||
if send_pad_split(&mut sender, &PadServerMessage::Document {
|
||||
content: current.content,
|
||||
revision_id: 0,
|
||||
updated_at: current.updated_at,
|
||||
}).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
update=updates.recv()=>match update{
|
||||
Ok(u)=>if send_pad_split(&mut sender,&PadServerMessage::Document{content:u.content,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,owner_map:u.owner_map}).await.is_err(){break;},
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_pad(&state.db,&slug).await { if send_pad_split(&mut sender,&PadServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_pad(socket: &mut WebSocket, message: &PadServerMessage) -> Result<(), axum::Error> {
|
||||
let payload = serde_json::to_string(message).expect("serializing pad server message cannot fail");
|
||||
socket.send(Message::Text(payload.into())).await
|
||||
}
|
||||
|
||||
async fn send_pad_split(
|
||||
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
||||
message: &PadServerMessage,
|
||||
) -> Result<(), axum::Error> {
|
||||
let payload = serde_json::to_string(message).expect("serializing pad server message cannot fail");
|
||||
sender.send(Message::Text(payload.into())).await
|
||||
}}
|
||||
}
|
||||
async fn send_pad(socket:&mut WebSocket,message:&PadServerMessage)->Result<(),axum::Error>{socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await}
|
||||
async fn send_pad_split(sender:&mut futures_util::stream::SplitSink<WebSocket,Message>,message:&PadServerMessage)->Result<(),axum::Error>{sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await}
|
||||
|
||||
Reference in New Issue
Block a user