first commit
This commit is contained in:
+500
@@ -0,0 +1,500 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use slug::slugify;
|
||||
|
||||
use crate::{
|
||||
db,
|
||||
state::{NoteUpdate, SharedState},
|
||||
};
|
||||
|
||||
const MAX_NAME_LENGTH: usize = 80;
|
||||
const MIN_PASSWORD_LENGTH: usize = 8;
|
||||
const MAX_PASSWORD_LENGTH: usize = 128;
|
||||
const MIN_WORKSPACE_SLUG_LENGTH: usize = 6;
|
||||
|
||||
#[derive(Debug, 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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateNoteRequest {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RestoreRequest {
|
||||
#[serde(default)]
|
||||
password: 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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NoteInfo {
|
||||
workspace_slug: String,
|
||||
workspace_title: String,
|
||||
slug: String,
|
||||
title: String,
|
||||
protected: bool,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
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 password = validate_password(payload.password.as_deref())?;
|
||||
let slug = unique_workspace_slug(&state, title).await?;
|
||||
|
||||
db::create_workspace(&state.db, &slug, title, password).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(CreateWorkspaceResponse {
|
||||
url: format!("/w/{slug}"),
|
||||
slug,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn workspace_info(
|
||||
State(state): State<SharedState>,
|
||||
Path(workspace_slug): Path<String>,
|
||||
) -> Result<Json<WorkspaceInfo>, ApiError> {
|
||||
let workspace = db::find_workspace(&state.db, &workspace_slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_workspace)?;
|
||||
Ok(Json(workspace_info_from(&workspace)))
|
||||
}
|
||||
|
||||
pub async fn open_workspace(
|
||||
State(state): State<SharedState>,
|
||||
Path(workspace_slug): Path<String>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<WorkspaceOpenResponse>, ApiError> {
|
||||
let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref()).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),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(WorkspaceOpenResponse {
|
||||
workspace: workspace_info_from(&workspace),
|
||||
notes,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn create_note(
|
||||
State(state): State<SharedState>,
|
||||
Path(workspace_slug): Path<String>,
|
||||
Json(payload): Json<CreateNoteRequest>,
|
||||
) -> Result<(StatusCode, Json<NoteListItem>), ApiError> {
|
||||
let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref()).await?;
|
||||
let title = validate_name(&payload.name, "Nazwa notatki")?;
|
||||
let base = slugify(title);
|
||||
if base.is_empty() {
|
||||
return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu"));
|
||||
}
|
||||
|
||||
let slug = unique_note_slug(&state, workspace.id, &base).await?;
|
||||
let note = db::create_note(&state.db, workspace.id, &slug, title).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),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn note_info(
|
||||
State(state): State<SharedState>,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
) -> Result<Json<NoteInfo>, ApiError> {
|
||||
let workspace = db::find_workspace(&state.db, &workspace_slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_workspace)?;
|
||||
let note = db::find_note(&state.db, workspace.id, ¬e_slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
|
||||
Ok(Json(NoteInfo {
|
||||
workspace_slug: workspace.slug,
|
||||
workspace_title: workspace.title,
|
||||
slug: note.slug,
|
||||
title: note.title,
|
||||
protected: workspace.password_hash.is_some(),
|
||||
created_at: db::normalize_timestamp(¬e.created_at),
|
||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn history(
|
||||
State(state): State<SharedState>,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<Vec<db::Revision>>, ApiError> {
|
||||
let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?;
|
||||
let _ = workspace;
|
||||
Ok(Json(db::list_revisions(&state.db, note.id).await?))
|
||||
}
|
||||
|
||||
pub async fn restore(
|
||||
State(state): State<SharedState>,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
Json(payload): Json<RestoreRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?;
|
||||
let content: Option<String> = sqlx::query_scalar(
|
||||
"SELECT content FROM note_revisions WHERE id = ? AND note_id = ?",
|
||||
)
|
||||
.bind(payload.revision_id)
|
||||
.bind(note.id)
|
||||
.fetch_optional(&state.db)
|
||||
.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?;
|
||||
let update = NoteUpdate {
|
||||
content,
|
||||
revision_id,
|
||||
updated_at,
|
||||
};
|
||||
let _ = state.note_channel(&workspace_slug, ¬e_slug).await.send(update);
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
pub async fn authorized_workspace(
|
||||
state: &SharedState,
|
||||
slug: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<db::Workspace, ApiError> {
|
||||
let workspace = db::find_workspace(&state.db, slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_workspace)?;
|
||||
if !db::verify_workspace_password(&workspace, password) {
|
||||
return Err(ApiError::unauthorized());
|
||||
}
|
||||
Ok(workspace)
|
||||
}
|
||||
|
||||
async fn authorized_note(
|
||||
state: &SharedState,
|
||||
workspace_slug: &str,
|
||||
note_slug: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<(db::Workspace, db::Note), ApiError> {
|
||||
let workspace = authorized_workspace(state, workspace_slug, password).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} musi mieć od 1 do {MAX_NAME_LENGTH} znaków"
|
||||
)));
|
||||
}
|
||||
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(
|
||||
"Hasło musi mieć od 8 do 128 znaków",
|
||||
));
|
||||
}
|
||||
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("Nazwa nie tworzy poprawnego adresu"));
|
||||
}
|
||||
|
||||
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("Nie udało się utworzyć unikalnego adresu"))
|
||||
}
|
||||
|
||||
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("Nie udało się utworzyć unikalnego adresu"))
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreatePadRequest {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreatePadResponse {
|
||||
slug: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PadInfo {
|
||||
slug: String,
|
||||
title: String,
|
||||
protected: bool,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
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 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"));
|
||||
}
|
||||
let slug = unique_pad_slug(&state, &base).await?;
|
||||
db::create_pad(&state.db, &slug, title, password).await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(CreatePadResponse {
|
||||
url: format!("/p/{slug}"),
|
||||
slug,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn pad_info(
|
||||
State(state): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<PadInfo>, ApiError> {
|
||||
let pad = db::find_pad(&state.db, &slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
Ok(Json(PadInfo {
|
||||
slug: pad.slug,
|
||||
title: pad.title,
|
||||
protected: pad.password_hash.is_some(),
|
||||
created_at: db::normalize_timestamp(&pad.created_at),
|
||||
updated_at: db::normalize_timestamp(&pad.updated_at),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn pad_history(
|
||||
State(state): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<Vec<db::Revision>>, ApiError> {
|
||||
let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?;
|
||||
Ok(Json(db::list_pad_revisions(&state.db, pad.id).await?))
|
||||
}
|
||||
|
||||
pub async fn pad_restore(
|
||||
State(state): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
Json(payload): Json<RestoreRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?;
|
||||
let content: Option<String> = sqlx::query_scalar(
|
||||
"SELECT content FROM revisions WHERE id = ? AND pad_id = ?",
|
||||
)
|
||||
.bind(payload.revision_id)
|
||||
.bind(pad.id)
|
||||
.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 update = NoteUpdate {
|
||||
content,
|
||||
revision_id,
|
||||
updated_at,
|
||||
};
|
||||
let _ = state.pad_channel(&slug).await.send(update);
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn authorized_pad(
|
||||
state: &SharedState,
|
||||
slug: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<db::Pad, ApiError> {
|
||||
let pad = db::find_pad(&state.db, slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
if !db::verify_pad_password(&pad, password) {
|
||||
return Err(ApiError::unauthorized());
|
||||
}
|
||||
Ok(pad)
|
||||
}
|
||||
|
||||
async fn unique_pad_slug(state: &SharedState, base: &str) -> Result<String, ApiError> {
|
||||
if db::find_pad(&state.db, base).await?.is_none() {
|
||||
return Ok(base.to_owned());
|
||||
}
|
||||
for _ in 0..8 {
|
||||
let candidate = format!("{base}-{}", db::random_suffix(6));
|
||||
if db::find_pad(&state.db, &candidate).await?.is_none() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(ApiError::internal("Nie udało się utworzyć unikalnego adresu"))
|
||||
}
|
||||
|
||||
pub struct ApiError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
fn bad_request(message: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
fn unauthorized() -> Self {
|
||||
Self {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
message: "Nieprawidłowe hasło".into(),
|
||||
}
|
||||
}
|
||||
fn not_found_workspace() -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "Nie znaleziono workspace".into(),
|
||||
}
|
||||
}
|
||||
fn not_found_note() -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "Nie znaleziono notatki".into(),
|
||||
}
|
||||
}
|
||||
fn not_found_revision() -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "Nie znaleziono wersji".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("Błąd bazy danych")
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.status, Json(serde_json::json!({"error": self.message}))).into_response()
|
||||
}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use tower_http::{services::ServeDir, trace::TraceLayer};
|
||||
|
||||
use crate::{api, db, state::SharedState, websocket};
|
||||
|
||||
pub fn router(state: SharedState, static_dir: &str) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(home))
|
||||
.route("/p/{slug}", get(pad))
|
||||
.route("/w/{workspace_slug}", get(workspace))
|
||||
.route("/w/{workspace_slug}/n/{note_slug}", get(note))
|
||||
.route("/health", get(health))
|
||||
.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}/restore", post(api::pad_restore))
|
||||
.route("/api/workspaces", post(api::create_workspace))
|
||||
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
|
||||
.route("/api/workspaces/{workspace_slug}/open", post(api::open_workspace))
|
||||
.route("/api/workspaces/{workspace_slug}/notes", post(api::create_note))
|
||||
.route(
|
||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}",
|
||||
get(api::note_info),
|
||||
)
|
||||
.route(
|
||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}/history",
|
||||
post(api::history),
|
||||
)
|
||||
.route(
|
||||
"/api/workspaces/{workspace_slug}/notes/{note_slug}/restore",
|
||||
post(api::restore),
|
||||
)
|
||||
.route("/ws/p/{slug}", get(websocket::upgrade_pad))
|
||||
.route(
|
||||
"/ws/{workspace_slug}/{note_slug}",
|
||||
get(websocket::upgrade),
|
||||
)
|
||||
.nest_service("/assets", ServeDir::new(static_dir))
|
||||
.fallback(not_found)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
async fn home(State(state): State<SharedState>) -> Response {
|
||||
versioned_html(include_str!("../static/home.html"), &state.asset_version)
|
||||
}
|
||||
|
||||
async fn pad(
|
||||
State(state): State<SharedState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Response {
|
||||
match db::find_pad(&state.db, &slug).await {
|
||||
Ok(Some(_)) => versioned_html(include_str!("../static/pad.html"), &state.asset_version),
|
||||
Ok(None) => error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Nie znaleziono notatki",
|
||||
"Ta notatka nie istnieje albo została usunięta.",
|
||||
"/",
|
||||
"Strona główna",
|
||||
&state.asset_version,
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::error!(%error, %slug, "failed to load standalone pad");
|
||||
internal_error(&state.asset_version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn workspace(
|
||||
State(state): State<SharedState>,
|
||||
Path(workspace_slug): Path<String>,
|
||||
) -> Response {
|
||||
match db::find_workspace(&state.db, &workspace_slug).await {
|
||||
Ok(Some(_)) => versioned_html(
|
||||
include_str!("../static/workspace.html"),
|
||||
&state.asset_version,
|
||||
),
|
||||
Ok(None) => error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Nie znaleziono workspace",
|
||||
"Ten workspace nie istnieje albo został usunięty.",
|
||||
"/",
|
||||
"Strona główna",
|
||||
&state.asset_version,
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::error!(%error, %workspace_slug, "failed to load workspace page");
|
||||
internal_error(&state.asset_version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn note(
|
||||
State(state): State<SharedState>,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
) -> Response {
|
||||
let workspace = match db::find_workspace(&state.db, &workspace_slug).await {
|
||||
Ok(Some(workspace)) => workspace,
|
||||
Ok(None) => {
|
||||
return error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Nie znaleziono workspace",
|
||||
"Workspace tej notatki nie istnieje albo został usunięty.",
|
||||
"/",
|
||||
"Strona główna",
|
||||
&state.asset_version,
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(%error, %workspace_slug, "failed to load note workspace");
|
||||
return internal_error(&state.asset_version);
|
||||
}
|
||||
};
|
||||
|
||||
match db::find_note(&state.db, workspace.id, ¬e_slug).await {
|
||||
Ok(Some(_)) => versioned_html(include_str!("../static/note.html"), &state.asset_version),
|
||||
Ok(None) => error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Nie znaleziono notatki",
|
||||
"Ta notatka nie istnieje albo została usunięta.",
|
||||
&format!("/w/{workspace_slug}"),
|
||||
"Wróć do workspace",
|
||||
&state.asset_version,
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::error!(%error, %workspace_slug, %note_slug, "failed to load note page");
|
||||
internal_error(&state.asset_version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn not_found(State(state): State<SharedState>) -> Response {
|
||||
error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"Nie znaleziono strony",
|
||||
"Sprawdź adres albo wróć na stronę główną.",
|
||||
"/",
|
||||
"Strona główna",
|
||||
&state.asset_version,
|
||||
)
|
||||
}
|
||||
|
||||
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ę.",
|
||||
"/",
|
||||
"Strona główna",
|
||||
asset_version,
|
||||
)
|
||||
}
|
||||
|
||||
fn error_response(
|
||||
status: StatusCode,
|
||||
code: &str,
|
||||
title: &str,
|
||||
message: &str,
|
||||
primary_url: &str,
|
||||
primary_label: &str,
|
||||
asset_version: &str,
|
||||
) -> Response {
|
||||
let html = include_str!("../static/error.html")
|
||||
.replace("__ASSET_VERSION__", &escape_html(asset_version))
|
||||
.replace("__ERROR_CODE__", &escape_html(code))
|
||||
.replace("__ERROR_TITLE__", &escape_html(title))
|
||||
.replace("__ERROR_MESSAGE__", &escape_html(message))
|
||||
.replace("__PRIMARY_URL__", &escape_html(primary_url))
|
||||
.replace("__PRIMARY_LABEL__", &escape_html(primary_label));
|
||||
|
||||
let mut response = (status, Html(html)).into_response();
|
||||
no_store(&mut response);
|
||||
response
|
||||
}
|
||||
|
||||
fn versioned_html(template: &str, asset_version: &str) -> Response {
|
||||
let html = template.replace("__ASSET_VERSION__", asset_version);
|
||||
let mut response = Html(html).into_response();
|
||||
no_store(&mut response);
|
||||
response
|
||||
}
|
||||
|
||||
fn no_store(response: &mut Response) {
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache, no-store, must-revalidate"),
|
||||
);
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use std::{env, net::IpAddr};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub host: IpAddr,
|
||||
pub port: u16,
|
||||
pub database_url: String,
|
||||
pub database_max_connections: u32,
|
||||
pub static_dir: String,
|
||||
pub asset_version: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
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()?;
|
||||
|
||||
Ok(Self {
|
||||
host,
|
||||
port,
|
||||
database_url: env_var("DATABASE_URL", "sqlite://rustpad.db?mode=rwc"),
|
||||
database_max_connections,
|
||||
static_dir: env_var("STATIC_DIR", "static"),
|
||||
asset_version: env::var("ASSET_VERSION")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_owned()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn env_var(name: &str, default: &str) -> String {
|
||||
env::var(name).unwrap_or_else(|_| default.to_owned())
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
use argon2::{
|
||||
password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rand_core::{OsRng, RngCore};
|
||||
use serde::Serialize;
|
||||
use sqlx::{FromRow, SqlitePool};
|
||||
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, FromRow)]
|
||||
pub struct Revision {
|
||||
pub id: i64,
|
||||
pub content: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
pub async fn find_workspace(pool: &SqlitePool, slug: &str) -> Result<Option<Workspace>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Workspace>(
|
||||
"SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_workspace(
|
||||
pool: &SqlitePool,
|
||||
slug: &str,
|
||||
title: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<Workspace, sqlx::Error> {
|
||||
let password_hash = password.filter(|value| !value.is_empty()).map(hash_password);
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(title)
|
||||
.bind(password_hash)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query_as::<_, Workspace>(
|
||||
"SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE id = ?",
|
||||
)
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(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: &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",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_note(
|
||||
pool: &SqlitePool,
|
||||
workspace_id: i64,
|
||||
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 = ?",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_note(
|
||||
pool: &SqlitePool,
|
||||
workspace_id: i64,
|
||||
slug: &str,
|
||||
title: &str,
|
||||
) -> Result<Note, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO notes (workspace_id, slug, title) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(slug)
|
||||
.bind(title)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query_as::<_, Note>(
|
||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE id = ?",
|
||||
)
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn save_revision(
|
||||
pool: &SqlitePool,
|
||||
note_id: i64,
|
||||
workspace_id: i64,
|
||||
content: &str,
|
||||
) -> Result<(i64, String), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
sqlx::query("UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(content)
|
||||
.bind(note_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(workspace_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let result = sqlx::query("INSERT INTO note_revisions (note_id, content) VALUES (?, ?)")
|
||||
.bind(note_id)
|
||||
.bind(content)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM notes WHERE id = ?")
|
||||
.bind(note_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok((result.last_insert_rowid(), updated_at))
|
||||
}
|
||||
|
||||
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",
|
||||
)
|
||||
.bind(note_id)
|
||||
.fetch_all(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 {
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.map(|dt| dt.with_timezone(&Utc).to_rfc3339())
|
||||
.unwrap_or_else(|_| value.replace(' ', "T") + "Z")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
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 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 = ?",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_pad(
|
||||
pool: &SqlitePool,
|
||||
slug: &str,
|
||||
title: &str,
|
||||
password: Option<&str>,
|
||||
) -> Result<Pad, sqlx::Error> {
|
||||
let password_hash = password.filter(|value| !value.is_empty()).map(hash_password);
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(title)
|
||||
.bind(password_hash)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query_as::<_, Pad>(
|
||||
"SELECT id, slug, title, content, password_hash, created_at, updated_at FROM pads WHERE id = ?",
|
||||
)
|
||||
.bind(result.last_insert_rowid())
|
||||
.fetch_one(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: &SqlitePool,
|
||||
pad_id: i64,
|
||||
content: &str,
|
||||
) -> Result<(i64, String), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
sqlx::query("UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.bind(content)
|
||||
.bind(pad_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let result = sqlx::query("INSERT INTO revisions (pad_id, content) VALUES (?, ?)")
|
||||
.bind(pad_id)
|
||||
.bind(content)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM pads WHERE id = ?")
|
||||
.bind(pad_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok((result.last_insert_rowid(), updated_at))
|
||||
}
|
||||
|
||||
pub async fn list_pad_revisions(
|
||||
pool: &SqlitePool,
|
||||
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",
|
||||
)
|
||||
.bind(pad_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
mod api;
|
||||
mod app;
|
||||
mod config;
|
||||
mod db;
|
||||
mod state;
|
||||
mod websocket;
|
||||
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use config::Config;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use state::AppState;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenvy::dotenv().ok();
|
||||
init_tracing();
|
||||
|
||||
let config = Config::from_env()?;
|
||||
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);
|
||||
let address = SocketAddr::new(config.host, config.port);
|
||||
let listener = TcpListener::bind(address).await?;
|
||||
|
||||
info!(%address, asset_version = %config.asset_version, "RustPad is running");
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "rustpad=debug,tower_http=info".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
tokio::select! { () = ctrl_c => {}, () = terminate => {} }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
|
||||
const CHANNEL_CAPACITY: usize = 256;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NoteUpdate {
|
||||
pub content: String,
|
||||
pub revision_id: i64,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AppState {
|
||||
pub db: SqlitePool,
|
||||
pub asset_version: String,
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn channel_for_key(&self, key: String) -> broadcast::Sender<NoteUpdate> {
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedState = Arc<AppState>;
|
||||
@@ -0,0 +1,292 @@
|
||||
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},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ClientMessage {
|
||||
Authenticate { password: Option<String> },
|
||||
Update { content: 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,
|
||||
},
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
pub async fn upgrade(
|
||||
ws: WebSocketUpgrade,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
State(state): State<SharedState>,
|
||||
) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state, workspace_slug, note_slug))
|
||||
}
|
||||
|
||||
async fn handle_socket(
|
||||
mut socket: WebSocket,
|
||||
state: SharedState,
|
||||
workspace_slug: String,
|
||||
note_slug: String,
|
||||
) {
|
||||
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 {
|
||||
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,
|
||||
};
|
||||
|
||||
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(),
|
||||
},
|
||||
)
|
||||
.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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum PadServerMessage {
|
||||
Authenticated { title: String, content: String },
|
||||
Document { content: String, revision_id: i64, updated_at: 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))
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
_ => 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user