logging
This commit is contained in:
+3
-1
@@ -20,7 +20,9 @@ DATABASE_MAX_CONNECTIONS=8
|
|||||||
|
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
RUST_LOG=rustpad=debug,tower_http=info
|
# available: warn, debug, info
|
||||||
|
FRONTEND_DEBUG=false
|
||||||
|
RUST_LOG=rustpad=info,tower_http=warn
|
||||||
|
|
||||||
# Maximum upload size
|
# Maximum upload size
|
||||||
UPLOAD_MAX_SIZE_MB=20
|
UPLOAD_MAX_SIZE_MB=20
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.0.3"
|
version = "0.0.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.94"
|
rust-version = "1.94"
|
||||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ The maximum size of a single file is configured with `UPLOAD_MAX_SIZE_MB` w `.en
|
|||||||
|
|
||||||
## Wybór bazy danych
|
## Wybór bazy danych
|
||||||
|
|
||||||
RustPad wybiera silnik na podstawie `DATABASE_URL`:
|
RustPad use db engine via `DATABASE_URL`:
|
||||||
|
|
||||||
- SQLite: `sqlite:///data/db/rustpad.db?mode=rwc&journal_mode=WAL&busy_timeout=5000`
|
- SQLite: `sqlite:///data/db/rustpad.db?mode=rwc&journal_mode=WAL&busy_timeout=5000`
|
||||||
- PostgreSQL: `postgres://rustpad:rustpad@postgres:5432/rustpad`
|
- PostgreSQL: `postgres://rustpad:rustpad@postgres:5432/rustpad`
|
||||||
@@ -47,7 +47,7 @@ RustPad wybiera silnik na podstawie `DATABASE_URL`:
|
|||||||
|
|
||||||
SQLite pozostaje domyślną bazą dla developmentu i małych instalacji. Tryb WAL pozwala czytać podczas zapisu, ale SQLite nadal wykonuje tylko jeden zapis naraz. `busy_timeout=5000` powoduje krótkie oczekiwanie zamiast natychmiastowego błędu `database is locked`. Przy wielu równoczesnych edytorach lub wielu instancjach aplikacji zalecany jest PostgreSQL albo MySQL.
|
SQLite pozostaje domyślną bazą dla developmentu i małych instalacji. Tryb WAL pozwala czytać podczas zapisu, ale SQLite nadal wykonuje tylko jeden zapis naraz. `busy_timeout=5000` powoduje krótkie oczekiwanie zamiast natychmiastowego błędu `database is locked`. Przy wielu równoczesnych edytorach lub wielu instancjach aplikacji zalecany jest PostgreSQL albo MySQL.
|
||||||
|
|
||||||
Opcjonalne bazy w Docker Compose:
|
Opcjonalne db inDocker Compose:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
@@ -70,3 +70,15 @@ Nicknames can still be used anonymously while they remain unregistered. Register
|
|||||||
|
|
||||||
All runtime SQL statements are centralized in `src/queries.rs`. Backend modules reference named constants, which keeps database-specific debugging and query review in one place.
|
All runtime SQL statements are centralized in `src/queries.rs`. Backend modules reference named constants, which keeps database-specific debugging and query review in one place.
|
||||||
|
|
||||||
|
|
||||||
|
## Diagnostics and logging
|
||||||
|
|
||||||
|
Server logs use `tracing`. Configure verbosity with `RUST_LOG`, for example:
|
||||||
|
|
||||||
|
```env
|
||||||
|
RUST_LOG=rustpad=debug,tower_http=info
|
||||||
|
```
|
||||||
|
|
||||||
|
Important lifecycle, database, authentication, password-reset and WebSocket events are logged. Passwords, session tokens, reset tokens, SMTP credentials and authorization headers are never logged.
|
||||||
|
|
||||||
|
Browser diagnostics are configured separately from backend logs with `FRONTEND_LOG_LEVEL`. Supported values are `off`, `error`, `warn`, `info`, and `debug`; the default is `warn`. URL parameters cannot enable diagnostics. Use `debug` only in trusted development environments. Production should normally use `warn` or `error`.
|
||||||
|
|||||||
+2
-1
@@ -15,7 +15,8 @@ services:
|
|||||||
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB:-20}
|
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB:-20}
|
||||||
ASSET_VERSION: ${ASSET_VERSION:-dev}
|
ASSET_VERSION: ${ASSET_VERSION:-dev}
|
||||||
REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-false}
|
REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-false}
|
||||||
RUST_LOG: ${RUST_LOG:-rustpad=info,tower_http=info}
|
RUST_LOG: ${RUST_LOG:-rustpad=info,tower_http=warn}
|
||||||
|
FRONTEND_LOG_LEVEL: ${FRONTEND_LOG_LEVEL:-warn}
|
||||||
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000}
|
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
SMTP_PORT: ${SMTP_PORT:-587}
|
||||||
|
|||||||
+16
-7
@@ -91,7 +91,7 @@ async fn health() -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn home(State(state): State<SharedState>) -> Response {
|
async fn home(State(state): State<SharedState>) -> Response {
|
||||||
versioned_html(include_str!("../static/home.html"), &state.asset_version, state.registration_enabled)
|
versioned_html(include_str!("../static/home.html"), &state.asset_version, state.registration_enabled, &state.frontend_log_level)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn pad(
|
async fn pad(
|
||||||
@@ -102,7 +102,7 @@ async fn pad(
|
|||||||
Ok(Some(pad)) => {
|
Ok(Some(pad)) => {
|
||||||
let html = include_str!("../static/pad.html")
|
let html = include_str!("../static/pad.html")
|
||||||
.replace("__PAD_TITLE__", &escape_html(&pad.title));
|
.replace("__PAD_TITLE__", &escape_html(&pad.title));
|
||||||
versioned_html(&html, &state.asset_version, state.registration_enabled)
|
versioned_html(&html, &state.asset_version, state.registration_enabled, &state.frontend_log_level)
|
||||||
},
|
},
|
||||||
Ok(None) => error_response(
|
Ok(None) => error_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
@@ -125,7 +125,7 @@ async fn public_page(
|
|||||||
Path(token): Path<String>,
|
Path(token): Path<String>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
match db::find_published_page(&state.db, &token).await {
|
match db::find_published_page(&state.db, &token).await {
|
||||||
Ok(Some(_)) => versioned_html(include_str!("../static/public.html"), &state.asset_version, state.registration_enabled),
|
Ok(Some(_)) => versioned_html(include_str!("../static/public.html"), &state.asset_version, state.registration_enabled, &state.frontend_log_level),
|
||||||
Ok(None) => error_response(
|
Ok(None) => error_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"404",
|
"404",
|
||||||
@@ -150,7 +150,7 @@ async fn workspace(
|
|||||||
Ok(Some(workspace)) => {
|
Ok(Some(workspace)) => {
|
||||||
let html = include_str!("../static/workspace.html")
|
let html = include_str!("../static/workspace.html")
|
||||||
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title));
|
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title));
|
||||||
versioned_html(&html, &state.asset_version, state.registration_enabled)
|
versioned_html(&html, &state.asset_version, state.registration_enabled, &state.frontend_log_level)
|
||||||
},
|
},
|
||||||
Ok(None) => error_response(
|
Ok(None) => error_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
@@ -197,7 +197,7 @@ async fn note(
|
|||||||
.replace("__NOTE_TITLE__", &escape_html(¬e.title))
|
.replace("__NOTE_TITLE__", &escape_html(¬e.title))
|
||||||
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title))
|
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title))
|
||||||
.replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug));
|
.replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug));
|
||||||
versioned_html(&html, &state.asset_version, state.registration_enabled)
|
versioned_html(&html, &state.asset_version, state.registration_enabled, &state.frontend_log_level)
|
||||||
},
|
},
|
||||||
Ok(None) => error_response(
|
Ok(None) => error_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
@@ -285,15 +285,24 @@ fn error_response(
|
|||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
fn versioned_html(template: &str, asset_version: &str, registration_enabled: bool) -> Response {
|
fn versioned_html(template: &str, asset_version: &str, registration_enabled: bool, frontend_log_level: &str) -> Response {
|
||||||
|
let frontend_config = format!(
|
||||||
|
r#"<script>window.__RUSTPAD_CONFIG__=Object.freeze({{frontendLogLevel:"{}"}});</script>"#,
|
||||||
|
escape_js_string(frontend_log_level),
|
||||||
|
);
|
||||||
let html = template
|
let html = template
|
||||||
.replace("__ASSET_VERSION__", asset_version)
|
.replace("__ASSET_VERSION__", asset_version)
|
||||||
.replace("__REGISTRATION_ENABLED__", if registration_enabled { "true" } else { "false" });
|
.replace("__REGISTRATION_ENABLED__", if registration_enabled { "true" } else { "false" })
|
||||||
|
.replace("</head>", &format!("{frontend_config}</head>"));
|
||||||
let mut response = Html(html).into_response();
|
let mut response = Html(html).into_response();
|
||||||
no_store(&mut response);
|
no_store(&mut response);
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn escape_js_string(value: &str) -> String {
|
||||||
|
value.replace('\\', "\\\\").replace('"', "\\\"").replace('<', "\\u003c")
|
||||||
|
}
|
||||||
|
|
||||||
fn no_store(response: &mut Response) {
|
fn no_store(response: &mut Response) {
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
header::CACHE_CONTROL,
|
header::CACHE_CONTROL,
|
||||||
|
|||||||
+29
-6
@@ -6,6 +6,7 @@ use rand_core::{OsRng, RngCore};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::{queries, state::{SharedState, SmtpConfig}};
|
use crate::{queries, state::{SharedState, SmtpConfig}};
|
||||||
|
|
||||||
@@ -26,21 +27,24 @@ pub struct User { pub id: i64, pub nickname: String, pub email: String, pub pass
|
|||||||
|
|
||||||
pub async fn identity(State(state): State<SharedState>, Json(req): Json<IdentityRequest>) -> Result<Json<IdentityResponse>, AuthError> {
|
pub async fn identity(State(state): State<SharedState>, Json(req): Json<IdentityRequest>) -> Result<Json<IdentityResponse>, AuthError> {
|
||||||
let nickname = validate_nickname(&req.nickname)?;
|
let nickname = validate_nickname(&req.nickname)?;
|
||||||
|
debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested");
|
||||||
match find_user_by_nickname(&state, &nickname).await? {
|
match find_user_by_nickname(&state, &nickname).await? {
|
||||||
None => Ok(Json(IdentityResponse { nickname, registered: false })),
|
None => { debug!(nickname = %nickname, "nickname is available for guest use"); Ok(Json(IdentityResponse { nickname, registered: false })) },
|
||||||
Some(user) => {
|
Some(user) => {
|
||||||
let token = req.session_token.as_deref().ok_or_else(|| AuthError::unauthorized("This nickname is registered. Log in to use it."))?;
|
let token = req.session_token.as_deref().ok_or_else(|| AuthError::unauthorized("This nickname is registered. Log in to use it."))?;
|
||||||
let current = user_from_token(&state, token).await?.ok_or_else(|| AuthError::unauthorized("Your session has expired. Log in again."))?;
|
let current = user_from_token(&state, token).await?.ok_or_else(|| AuthError::unauthorized("Your session has expired. Log in again."))?;
|
||||||
if current.id != user.id { return Err(AuthError::unauthorized("This nickname belongs to another account.")); }
|
if current.id != user.id { return Err(AuthError::unauthorized("This nickname belongs to another account.")); }
|
||||||
|
info!(user_id = user.id, nickname = %user.nickname, "registered identity authorized");
|
||||||
Ok(Json(IdentityResponse { nickname: user.nickname, registered: true }))
|
Ok(Json(IdentityResponse { nickname: user.nickname, registered: true }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn register(State(state): State<SharedState>, Json(req): Json<RegisterRequest>) -> Result<(StatusCode, Json<SessionResponse>), AuthError> {
|
pub async fn register(State(state): State<SharedState>, Json(req): Json<RegisterRequest>) -> Result<(StatusCode, Json<SessionResponse>), AuthError> {
|
||||||
if !state.registration_enabled { return Err(AuthError::forbidden("Registration is disabled.")); }
|
if !state.registration_enabled { warn!("registration attempt rejected because registration is disabled"); return Err(AuthError::forbidden("Registration is disabled.")); }
|
||||||
let nickname = validate_nickname(&req.nickname)?;
|
let nickname = validate_nickname(&req.nickname)?;
|
||||||
let email = validate_email(&req.email)?;
|
let email = validate_email(&req.email)?;
|
||||||
|
info!(nickname = %nickname, email_domain = %email_domain(&email), "registration requested");
|
||||||
validate_password(&req.password)?;
|
validate_password(&req.password)?;
|
||||||
let nickname_key = normalize(&nickname);
|
let nickname_key = normalize(&nickname);
|
||||||
let email_key = normalize(&email);
|
let email_key = normalize(&email);
|
||||||
@@ -51,31 +55,41 @@ pub async fn register(State(state): State<SharedState>, Json(req): Json<Register
|
|||||||
.bind(&nickname).bind(nickname_key).bind(&email).bind(email_key).bind(hash).execute(state.db.pool()).await
|
.bind(&nickname).bind(nickname_key).bind(&email).bind(email_key).bind(hash).execute(state.db.pool()).await
|
||||||
.map_err(AuthError::database)?;
|
.map_err(AuthError::database)?;
|
||||||
let user = find_user_by_nickname(&state, &nickname).await?.ok_or_else(|| AuthError::internal("Failed to create the account."))?;
|
let user = find_user_by_nickname(&state, &nickname).await?.ok_or_else(|| AuthError::internal("Failed to create the account."))?;
|
||||||
Ok((StatusCode::CREATED, Json(create_session(&state, &user).await?)))
|
let session = create_session(&state, &user).await?;
|
||||||
|
info!(user_id = user.id, nickname = %user.nickname, "account registered and session created");
|
||||||
|
Ok((StatusCode::CREATED, Json(session)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(State(state): State<SharedState>, Json(req): Json<LoginRequest>) -> Result<Json<SessionResponse>, AuthError> {
|
pub async fn login(State(state): State<SharedState>, Json(req): Json<LoginRequest>) -> Result<Json<SessionResponse>, AuthError> {
|
||||||
let email = validate_email(&req.email)?;
|
let email = validate_email(&req.email)?;
|
||||||
|
debug!(email_domain = %email_domain(&email), "login requested");
|
||||||
let user = find_user_by_email(&state, &email).await?.ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?;
|
let user = find_user_by_email(&state, &email).await?.ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?;
|
||||||
if !verify_password(&user.password_hash, &req.password) { return Err(AuthError::unauthorized("Invalid e-mail address or password.")); }
|
if !verify_password(&user.password_hash, &req.password) { warn!(user_id = user.id, "login rejected: invalid password"); return Err(AuthError::unauthorized("Invalid e-mail address or password.")); }
|
||||||
Ok(Json(create_session(&state, &user).await?))
|
let session = create_session(&state, &user).await?;
|
||||||
|
info!(user_id = user.id, nickname = %user.nickname, "login successful");
|
||||||
|
Ok(Json(session))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn me(State(state): State<SharedState>, headers: HeaderMap) -> Result<Json<SessionResponse>, AuthError> {
|
pub async fn me(State(state): State<SharedState>, headers: HeaderMap) -> Result<Json<SessionResponse>, AuthError> {
|
||||||
let token = bearer(&headers).ok_or_else(|| AuthError::unauthorized("Not logged in."))?;
|
let token = bearer(&headers).ok_or_else(|| AuthError::unauthorized("Not logged in."))?;
|
||||||
let user = user_from_token(&state, token).await?.ok_or_else(|| AuthError::unauthorized("Your session has expired."))?;
|
let user = user_from_token(&state, token).await?.ok_or_else(|| AuthError::unauthorized("Your session has expired."))?;
|
||||||
|
debug!(user_id = user.id, "session validation successful");
|
||||||
let expires_at: String = sqlx::query_scalar(queries::get(state.db.kind(), queries::AUTH_SESSION_EXPIRES_AT))
|
let expires_at: String = sqlx::query_scalar(queries::get(state.db.kind(), queries::AUTH_SESSION_EXPIRES_AT))
|
||||||
.bind(token).fetch_one(state.db.pool()).await.map_err(AuthError::database)?;
|
.bind(token).fetch_one(state.db.pool()).await.map_err(AuthError::database)?;
|
||||||
Ok(Json(SessionResponse { token: token.into(), nickname: user.nickname, email: user.email, expires_at }))
|
Ok(Json(SessionResponse { token: token.into(), nickname: user.nickname, email: user.email, expires_at }))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn logout(State(state): State<SharedState>, headers: HeaderMap) -> Result<Json<serde_json::Value>, AuthError> {
|
pub async fn logout(State(state): State<SharedState>, headers: HeaderMap) -> Result<Json<serde_json::Value>, AuthError> {
|
||||||
if let Some(token) = bearer(&headers) { sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_SESSION_BY_TOKEN)).bind(token).execute(state.db.pool()).await.map_err(AuthError::database)?; }
|
if let Some(token) = bearer(&headers) {
|
||||||
|
let result = sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_SESSION_BY_TOKEN)).bind(token).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||||
|
info!(rows_affected = result.rows_affected(), "logout processed");
|
||||||
|
} else { debug!("logout requested without an active session"); }
|
||||||
Ok(Json(serde_json::json!({"ok": true})))
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn request_reset(State(state): State<SharedState>, Json(req): Json<ResetRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
pub async fn request_reset(State(state): State<SharedState>, Json(req): Json<ResetRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
||||||
let email = validate_email(&req.email)?;
|
let email = validate_email(&req.email)?;
|
||||||
|
info!(email_domain = %email_domain(&email), "password reset requested");
|
||||||
let smtp = state.smtp.as_ref().ok_or_else(|| AuthError::service_unavailable("Password reset is not configured on this server."))?;
|
let smtp = state.smtp.as_ref().ok_or_else(|| AuthError::service_unavailable("Password reset is not configured on this server."))?;
|
||||||
if let Some(user) = find_user_by_email(&state, &email).await? {
|
if let Some(user) = find_user_by_email(&state, &email).await? {
|
||||||
let token = random_token();
|
let token = random_token();
|
||||||
@@ -84,12 +98,16 @@ pub async fn request_reset(State(state): State<SharedState>, Json(req): Json<Res
|
|||||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_RESET_TOKEN))
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_RESET_TOKEN))
|
||||||
.bind(hash_token(&token)).bind(user.id).bind(expires).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
.bind(hash_token(&token)).bind(user.id).bind(expires).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||||
send_reset(smtp, &user, &token).await?;
|
send_reset(smtp, &user, &token).await?;
|
||||||
|
info!(user_id = user.id, "password reset e-mail sent");
|
||||||
|
} else {
|
||||||
|
debug!(email_domain = %email_domain(&email), "password reset requested for unknown account");
|
||||||
}
|
}
|
||||||
Ok(Json(serde_json::json!({"ok": true, "message": "If the account exists, a reset link has been sent."})))
|
Ok(Json(serde_json::json!({"ok": true, "message": "If the account exists, a reset link has been sent."})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn confirm_reset(State(state): State<SharedState>, Json(req): Json<ResetConfirmRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
pub async fn confirm_reset(State(state): State<SharedState>, Json(req): Json<ResetConfirmRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
||||||
validate_password(&req.password)?;
|
validate_password(&req.password)?;
|
||||||
|
info!("password reset confirmation requested");
|
||||||
let now_time = Utc::now();
|
let now_time = Utc::now();
|
||||||
let now = now_time.to_rfc3339();
|
let now = now_time.to_rfc3339();
|
||||||
let token_hash = hash_token(req.token.trim());
|
let token_hash = hash_token(req.token.trim());
|
||||||
@@ -100,6 +118,7 @@ pub async fn confirm_reset(State(state): State<SharedState>, Json(req): Json<Res
|
|||||||
.map_err(|_| AuthError::bad_request("The reset link is invalid or has expired."))?
|
.map_err(|_| AuthError::bad_request("The reset link is invalid or has expired."))?
|
||||||
.with_timezone(&Utc);
|
.with_timezone(&Utc);
|
||||||
if used_at.is_some() || expires_at <= now_time {
|
if used_at.is_some() || expires_at <= now_time {
|
||||||
|
warn!(user_id, used = used_at.is_some(), expired = expires_at <= now_time, "password reset token rejected");
|
||||||
return Err(AuthError::bad_request("The reset link is invalid or has expired."));
|
return Err(AuthError::bad_request("The reset link is invalid or has expired."));
|
||||||
}
|
}
|
||||||
let password_hash = hash_password(&req.password)?;
|
let password_hash = hash_password(&req.password)?;
|
||||||
@@ -112,6 +131,7 @@ pub async fn confirm_reset(State(state): State<SharedState>, Json(req): Json<Res
|
|||||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_SESSIONS_BY_USER))
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_SESSIONS_BY_USER))
|
||||||
.bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
|
.bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
|
||||||
tx.commit().await.map_err(AuthError::database)?;
|
tx.commit().await.map_err(AuthError::database)?;
|
||||||
|
info!(user_id, "password reset completed and existing sessions revoked");
|
||||||
Ok(Json(serde_json::json!({"ok": true})))
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +160,7 @@ async fn create_session(state: &SharedState, user: &User) -> Result<SessionRespo
|
|||||||
let expires_at = (Utc::now() + Duration::days(30)).to_rfc3339();
|
let expires_at = (Utc::now() + Duration::days(30)).to_rfc3339();
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_SESSION))
|
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_SESSION))
|
||||||
.bind(&token).bind(user.id).bind(&expires_at).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
.bind(&token).bind(user.id).bind(&expires_at).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||||
|
debug!(user_id = user.id, expires_at = %expires_at, "authentication session created");
|
||||||
Ok(SessionResponse { token, nickname: user.nickname.clone(), email: user.email.clone(), expires_at })
|
Ok(SessionResponse { token, nickname: user.nickname.clone(), email: user.email.clone(), expires_at })
|
||||||
}
|
}
|
||||||
async fn find_user_by_nickname(state: &SharedState, nickname: &str) -> Result<Option<User>, AuthError> {
|
async fn find_user_by_nickname(state: &SharedState, nickname: &str) -> Result<Option<User>, AuthError> {
|
||||||
@@ -185,4 +206,6 @@ async fn send_reset(smtp:&SmtpConfig,user:&User,token:&str)->Result<(),AuthError
|
|||||||
|
|
||||||
pub struct AuthError { status: StatusCode, pub message: String }
|
pub struct AuthError { status: StatusCode, pub message: String }
|
||||||
impl AuthError { fn bad_request(m:&str)->Self{Self{status:StatusCode::BAD_REQUEST,message:m.into()}} fn unauthorized(m:&str)->Self{Self{status:StatusCode::UNAUTHORIZED,message:m.into()}} fn forbidden(m:&str)->Self{Self{status:StatusCode::FORBIDDEN,message:m.into()}} fn conflict(m:&str)->Self{Self{status:StatusCode::CONFLICT,message:m.into()}} fn internal(m:&str)->Self{Self{status:StatusCode::INTERNAL_SERVER_ERROR,message:m.into()}} fn service_unavailable(m:&str)->Self{Self{status:StatusCode::SERVICE_UNAVAILABLE,message:m.into()}} fn database(e:sqlx::Error)->Self{tracing::error!(error=%e,"authentication database error");Self::internal("Database error.")} }
|
impl AuthError { fn bad_request(m:&str)->Self{Self{status:StatusCode::BAD_REQUEST,message:m.into()}} fn unauthorized(m:&str)->Self{Self{status:StatusCode::UNAUTHORIZED,message:m.into()}} fn forbidden(m:&str)->Self{Self{status:StatusCode::FORBIDDEN,message:m.into()}} fn conflict(m:&str)->Self{Self{status:StatusCode::CONFLICT,message:m.into()}} fn internal(m:&str)->Self{Self{status:StatusCode::INTERNAL_SERVER_ERROR,message:m.into()}} fn service_unavailable(m:&str)->Self{Self{status:StatusCode::SERVICE_UNAVAILABLE,message:m.into()}} fn database(e:sqlx::Error)->Self{tracing::error!(error=%e,"authentication database error");Self::internal("Database error.")} }
|
||||||
|
|
||||||
|
fn email_domain(email: &str) -> &str { email.rsplit_once('@').map(|(_, domain)| domain).unwrap_or("invalid") }
|
||||||
impl axum::response::IntoResponse for AuthError { fn into_response(self)->axum::response::Response{(self.status,Json(serde_json::json!({"error":self.message}))).into_response()} }
|
impl axum::response::IntoResponse for AuthError { fn into_response(self)->axum::response::Response{(self.status,Json(serde_json::json!({"error":self.message}))).into_response()} }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub struct Config {
|
|||||||
pub asset_version: String,
|
pub asset_version: String,
|
||||||
pub smtp: Option<crate::state::SmtpConfig>,
|
pub smtp: Option<crate::state::SmtpConfig>,
|
||||||
pub registration_enabled: bool,
|
pub registration_enabled: bool,
|
||||||
|
pub frontend_log_level: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -56,6 +57,7 @@ impl Config {
|
|||||||
asset_version: env_var("ASSET_VERSION", env!("CARGO_PKG_VERSION")),
|
asset_version: env_var("ASSET_VERSION", env!("CARGO_PKG_VERSION")),
|
||||||
smtp,
|
smtp,
|
||||||
registration_enabled: env_bool("REGISTRATION_ENABLED", false)?,
|
registration_enabled: env_bool("REGISTRATION_ENABLED", false)?,
|
||||||
|
frontend_log_level: env_log_level("FRONTEND_LOG_LEVEL", "warn")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,3 +76,11 @@ fn env_bool(name: &str, default: bool) -> Result<bool, Box<dyn std::error::Error
|
|||||||
Err(_) => Ok(default),
|
Err(_) => Ok(default),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn env_log_level(name: &str, default: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
let value = env_var(name, default).trim().to_ascii_lowercase();
|
||||||
|
match value.as_str() {
|
||||||
|
"off" | "error" | "warn" | "info" | "debug" => Ok(value),
|
||||||
|
_ => Err(format!("{name} must be one of: off, error, warn, info, debug").into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::queries;
|
use crate::queries;
|
||||||
use sqlx::{any::AnyPoolOptions, AnyPool};
|
use sqlx::{any::AnyPoolOptions, AnyPool};
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum DatabaseKind {
|
pub enum DatabaseKind {
|
||||||
@@ -18,15 +19,18 @@ impl Database {
|
|||||||
pub async fn connect(url: &str, max_connections: u32) -> Result<Self, sqlx::Error> {
|
pub async fn connect(url: &str, max_connections: u32) -> Result<Self, sqlx::Error> {
|
||||||
sqlx::any::install_default_drivers();
|
sqlx::any::install_default_drivers();
|
||||||
let kind = DatabaseKind::from_url(url)?;
|
let kind = DatabaseKind::from_url(url)?;
|
||||||
|
debug!(?kind, max_connections, "initializing database pool");
|
||||||
let pool = AnyPoolOptions::new()
|
let pool = AnyPoolOptions::new()
|
||||||
.max_connections(max_connections)
|
.max_connections(max_connections)
|
||||||
.connect(url)
|
.connect(url)
|
||||||
.await?;
|
.await?;
|
||||||
if kind == DatabaseKind::Sqlite {
|
if kind == DatabaseKind::Sqlite {
|
||||||
|
debug!("applying SQLite connection pragmas");
|
||||||
sqlx::query(queries::SQLITE_FOREIGN_KEYS_ON).execute(&pool).await?;
|
sqlx::query(queries::SQLITE_FOREIGN_KEYS_ON).execute(&pool).await?;
|
||||||
sqlx::query(queries::SQLITE_JOURNAL_WAL).execute(&pool).await?;
|
sqlx::query(queries::SQLITE_JOURNAL_WAL).execute(&pool).await?;
|
||||||
sqlx::query(queries::SQLITE_BUSY_TIMEOUT).execute(&pool).await?;
|
sqlx::query(queries::SQLITE_BUSY_TIMEOUT).execute(&pool).await?;
|
||||||
}
|
}
|
||||||
|
info!(?kind, max_connections, "database pool ready");
|
||||||
Ok(Self { pool, kind })
|
Ok(Self { pool, kind })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-2
@@ -14,7 +14,7 @@ use config::Config;
|
|||||||
use database::{Database, DatabaseKind};
|
use database::{Database, DatabaseKind};
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tracing::info;
|
use tracing::{info, warn};
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -23,13 +23,31 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
init_tracing();
|
init_tracing();
|
||||||
|
|
||||||
let config = Config::from_env()?;
|
let config = Config::from_env()?;
|
||||||
|
info!(
|
||||||
|
host = %config.host,
|
||||||
|
port = config.port,
|
||||||
|
database_kind = %database_kind_label(&config.database_url),
|
||||||
|
database_max_connections = config.database_max_connections,
|
||||||
|
static_dir = %config.static_dir,
|
||||||
|
files_dir = %config.files_dir,
|
||||||
|
upload_max_size_bytes = config.upload_max_size_bytes,
|
||||||
|
registration_enabled = config.registration_enabled,
|
||||||
|
frontend_log_level = %config.frontend_log_level,
|
||||||
|
smtp_configured = config.smtp.is_some(),
|
||||||
|
asset_version = %config.asset_version,
|
||||||
|
"configuration loaded"
|
||||||
|
);
|
||||||
if let Some(path) = config.database_url.strip_prefix("sqlite://").and_then(|v| v.split('?').next()) {
|
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)?; }
|
if let Some(parent) = std::path::Path::new(path).parent() { std::fs::create_dir_all(parent)?; }
|
||||||
}
|
}
|
||||||
|
info!("connecting to database");
|
||||||
let db = Database::connect(&config.database_url, config.database_max_connections).await?;
|
let db = Database::connect(&config.database_url, config.database_max_connections).await?;
|
||||||
|
info!(database_kind = ?db.kind(), "database connection established");
|
||||||
run_migrations(&db).await?;
|
run_migrations(&db).await?;
|
||||||
|
info!(database_kind = ?db.kind(), "database migrations completed");
|
||||||
|
|
||||||
std::fs::create_dir_all(&config.files_dir)?;
|
std::fs::create_dir_all(&config.files_dir)?;
|
||||||
|
info!(files_dir = %config.files_dir, "file storage ready");
|
||||||
let state = Arc::new(AppState::new(
|
let state = Arc::new(AppState::new(
|
||||||
db,
|
db,
|
||||||
config.asset_version.clone(),
|
config.asset_version.clone(),
|
||||||
@@ -37,6 +55,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
config.upload_max_size_bytes,
|
config.upload_max_size_bytes,
|
||||||
config.smtp.clone(),
|
config.smtp.clone(),
|
||||||
config.registration_enabled,
|
config.registration_enabled,
|
||||||
|
config.frontend_log_level.clone(),
|
||||||
));
|
));
|
||||||
let app = app::router(
|
let app = app::router(
|
||||||
state,
|
state,
|
||||||
@@ -50,6 +69,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
axum::serve(listener, app)
|
axum::serve(listener, app)
|
||||||
.with_graceful_shutdown(shutdown_signal())
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
.await?;
|
.await?;
|
||||||
|
info!("RustPad stopped cleanly");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +98,10 @@ async fn shutdown_signal() {
|
|||||||
};
|
};
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
let terminate = std::future::pending::<()>();
|
let terminate = std::future::pending::<()>();
|
||||||
tokio::select! { () = ctrl_c => {}, () = terminate => {} }
|
tokio::select! {
|
||||||
|
() = ctrl_c => warn!("shutdown requested by Ctrl+C"),
|
||||||
|
() = terminate => warn!("shutdown requested by SIGTERM"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError> {
|
async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError> {
|
||||||
@@ -89,3 +112,10 @@ async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError
|
|||||||
};
|
};
|
||||||
sqlx::migrate::Migrator::new(path).await?.run(db.pool()).await
|
sqlx::migrate::Migrator::new(path).await?.run(db.pool()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn database_kind_label(url: &str) -> &'static str {
|
||||||
|
if url.starts_with("sqlite:") { "sqlite" }
|
||||||
|
else if url.starts_with("postgres:") || url.starts_with("postgresql:") { "postgres" }
|
||||||
|
else if url.starts_with("mysql:") { "mysql" }
|
||||||
|
else { "unknown" }
|
||||||
|
}
|
||||||
|
|||||||
+3
-2
@@ -24,12 +24,13 @@ pub struct AppState {
|
|||||||
pub upload_max_size_bytes: usize,
|
pub upload_max_size_bytes: usize,
|
||||||
pub smtp: Option<SmtpConfig>,
|
pub smtp: Option<SmtpConfig>,
|
||||||
pub registration_enabled: bool,
|
pub registration_enabled: bool,
|
||||||
|
pub frontend_log_level: String,
|
||||||
channels: RwLock<HashMap<String, broadcast::Sender<NoteUpdate>>>,
|
channels: RwLock<HashMap<String, broadcast::Sender<NoteUpdate>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, smtp: Option<SmtpConfig>, registration_enabled: bool) -> Self {
|
pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, smtp: Option<SmtpConfig>, registration_enabled: bool, frontend_log_level: String) -> Self {
|
||||||
Self { db, asset_version, files_dir, upload_max_size_bytes, smtp, registration_enabled, channels: RwLock::new(HashMap::new()) }
|
Self { db, asset_version, files_dir, upload_max_size_bytes, smtp, registration_enabled, frontend_log_level, channels: RwLock::new(HashMap::new()) }
|
||||||
}
|
}
|
||||||
async fn channel_for_key(&self, key: String) -> broadcast::Sender<NoteUpdate> {
|
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(); }
|
||||||
|
|||||||
+13
-7
@@ -1,7 +1,7 @@
|
|||||||
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 futures_util::{SinkExt, StreamExt};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, info, warn};
|
||||||
use crate::{auth, db, state::{NoteUpdate, SharedState}};
|
use crate::{auth, db, state::{NoteUpdate, SharedState}};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -24,8 +24,9 @@ pub async fn upgrade(ws: WebSocketUpgrade, Path((workspace_slug, note_slug)): Pa
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug: String, note_slug: String) {
|
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; };
|
info!(%workspace_slug, %note_slug, "note websocket connected");
|
||||||
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 Some(workspace) = db::find_workspace(&state.db, &workspace_slug).await.ok().flatten() else { warn!(%workspace_slug, %note_slug, "note websocket rejected: workspace not found"); let _=send_error(&mut socket,"Workspace not found").await; return; };
|
||||||
|
let Some(note) = db::find_note(&state.db, workspace.id, ¬e_slug).await.ok().flatten() else { warn!(%workspace_slug, %note_slug, "note websocket rejected: note not found"); let _=send_error(&mut socket,"Note not found").await; return; };
|
||||||
let (password, nickname, session_token) = match socket.recv().await {
|
let (password, nickname, session_token) = match socket.recv().await {
|
||||||
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
||||||
Ok(ClientMessage::Authenticate { password, nickname, session_token }) => (password, clean_nickname(nickname), session_token),
|
Ok(ClientMessage::Authenticate { password, nickname, session_token }) => (password, clean_nickname(nickname), session_token),
|
||||||
@@ -33,7 +34,8 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug
|
|||||||
}, _ => return
|
}, _ => return
|
||||||
};
|
};
|
||||||
let nickname = match auth::authorize_nickname(&state, nickname, session_token).await { Ok(value) => value, Err(message) => { let _=send_error(&mut socket,&message).await; return; } };
|
let nickname = match auth::authorize_nickname(&state, nickname, session_token).await { Ok(value) => value, Err(message) => { let _=send_error(&mut socket,&message).await; return; } };
|
||||||
if !db::verify_workspace_password(&workspace, password.as_deref()) { let _=send_error(&mut socket,"Invalid password").await; return; }
|
if !db::verify_workspace_password(&workspace, password.as_deref()) { warn!(workspace_id = workspace.id, note_id = note.id, "note websocket rejected: invalid workspace password"); let _=send_error(&mut socket,"Invalid password").await; return; }
|
||||||
|
info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated");
|
||||||
if send(&mut socket,&ServerMessage::Authenticated { workspace_title:workspace.title.clone(), note_title:note.title.clone(), content:note.content.clone(), owner_map:note.owner_map.clone() }).await.is_err(){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 channel=state.note_channel(&workspace_slug,¬e_slug).await;
|
||||||
let mut updates=channel.subscribe();
|
let mut updates=channel.subscribe();
|
||||||
@@ -46,7 +48,7 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug
|
|||||||
let owner_map=owner_map.unwrap_or_else(||"[]".into());
|
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 {
|
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});}
|
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"),
|
Err(error)=>warn!(%error, workspace_id = workspace.id, note_id = note.id, "failed to save revision"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(ClientMessage::Authenticate{..})=>{}, Err(error)=>warn!(%error,"invalid websocket message"),
|
Ok(ClientMessage::Authenticate{..})=>{}, Err(error)=>warn!(%error,"invalid websocket message"),
|
||||||
@@ -59,6 +61,7 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug
|
|||||||
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
|
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
info!(workspace_id = workspace.id, note_id = note.id, "note websocket disconnected");
|
||||||
}
|
}
|
||||||
fn clean_nickname(value: Option<String>)->Option<String>{value.map(|v|v.trim().chars().take(40).collect::<String>()).filter(|v|!v.is_empty())}
|
fn clean_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_error(socket:&mut WebSocket,message:&str)->Result<(),axum::Error>{send(socket,&ServerMessage::Error{message:message.into()}).await}
|
||||||
@@ -76,7 +79,8 @@ pub async fn upgrade_pad(ws:WebSocketUpgrade,Path(slug):Path<String>,State(state
|
|||||||
ws.on_upgrade(move|socket|handle_pad_socket(socket,state,slug))
|
ws.on_upgrade(move|socket|handle_pad_socket(socket,state,slug))
|
||||||
}
|
}
|
||||||
async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){
|
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;};
|
info!(%slug, "pad websocket connected");
|
||||||
|
let Some(pad)=db::find_pad(&state.db,&slug).await.ok().flatten() else {warn!(%slug, "pad websocket rejected: pad not found");let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Note not found".into()}).await;return;};
|
||||||
let (password,nickname,session_token)=match socket.recv().await{
|
let (password,nickname,session_token)=match socket.recv().await{
|
||||||
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text){
|
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text){
|
||||||
Ok(ClientMessage::Authenticate{password,nickname,session_token})=>(password,clean_nickname(nickname),session_token),
|
Ok(ClientMessage::Authenticate{password,nickname,session_token})=>(password,clean_nickname(nickname),session_token),
|
||||||
@@ -84,7 +88,8 @@ async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){
|
|||||||
},_=>return
|
},_=>return
|
||||||
};
|
};
|
||||||
let nickname=match auth::authorize_nickname(&state,nickname,session_token).await{Ok(value)=>value,Err(message)=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message}).await;return;}};
|
let nickname=match auth::authorize_nickname(&state,nickname,session_token).await{Ok(value)=>value,Err(message)=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message}).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 !db::verify_pad_password(&pad,password.as_deref()){warn!(pad_id = pad.id, "pad websocket rejected: invalid password");let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Invalid password".into()}).await;return;}
|
||||||
|
info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated");
|
||||||
if send_pad(&mut socket,&PadServerMessage::Authenticated{title:pad.title.clone(),content:pad.content.clone(),owner_map:pad.owner_map.clone()}).await.is_err(){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 channel=state.pad_channel(&slug).await;
|
||||||
let mut updates=channel.subscribe();
|
let mut updates=channel.subscribe();
|
||||||
@@ -112,6 +117,7 @@ async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){
|
|||||||
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
|
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
info!(pad_id = pad.id, "pad websocket disconnected");
|
||||||
}
|
}
|
||||||
async fn send_pad(socket:&mut WebSocket,message:&PadServerMessage)->Result<(),axum::Error>{socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await}
|
async fn send_pad(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}
|
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}
|
||||||
|
|||||||
+11
-2
@@ -1,19 +1,28 @@
|
|||||||
|
import { logDebug, logError, logWarn } from "./logger.js";
|
||||||
|
|
||||||
export async function api(path, options = {}) {
|
export async function api(path, options = {}) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 12000);
|
const timeout = setTimeout(() => controller.abort(), 12000);
|
||||||
try {
|
try {
|
||||||
const headers = new Headers(options.headers || {});
|
const headers = new Headers(options.headers || {});
|
||||||
if (!(options.body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
|
if (!(options.body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||||
|
const started = performance.now();
|
||||||
|
logDebug("api.request", { method: options.method || "GET", path });
|
||||||
const response = await fetch(path, { ...options, headers, signal: controller.signal });
|
const response = await fetch(path, { ...options, headers, signal: controller.signal });
|
||||||
|
const durationMs = Math.round(performance.now() - started);
|
||||||
|
logDebug("api.response", { method: options.method || "GET", path, status: response.status, durationMs });
|
||||||
const contentType = response.headers.get("content-type") || "";
|
const contentType = response.headers.get("content-type") || "";
|
||||||
const data = contentType.includes("application/json") ? await response.json().catch(() => ({})) : {};
|
const data = contentType.includes("application/json") ? await response.json().catch(() => ({})) : {};
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const defaults = { 400: "Invalid request.", 401: "Authentication required.", 403: "Access denied.", 404: "The requested resource was not found.", 405: "This operation is not allowed.", 409: "The requested change conflicts with existing data.", 413: "The uploaded data is too large.", 429: "Too many requests. Try again later.", 500: "Server error. Try again later.", 503: "Service temporarily unavailable." };
|
const defaults = { 400: "Invalid request.", 401: "Authentication required.", 403: "Access denied.", 404: "The requested resource was not found.", 405: "This operation is not allowed.", 409: "The requested change conflicts with existing data.", 413: "The uploaded data is too large.", 429: "Too many requests. Try again later.", 500: "Server error. Try again later.", 503: "Service temporarily unavailable." };
|
||||||
throw new Error(data.error || defaults[response.status] || `Request failed (${response.status}).`);
|
const requestError = new Error(data.error || defaults[response.status] || `Request failed (${response.status}).`);
|
||||||
|
logWarn("api.failed", { method: options.method || "GET", path, status: response.status, message: requestError.message });
|
||||||
|
throw requestError;
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === "AbortError") throw new Error("Timed out");
|
if (error.name === "AbortError") { logWarn("api.timeout", { method: options.method || "GET", path }); throw new Error("Timed out"); }
|
||||||
|
logError("api.network_error", error, { method: options.method || "GET", path });
|
||||||
throw error;
|
throw error;
|
||||||
} finally { clearTimeout(timeout); }
|
} finally { clearTimeout(timeout); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { installGlobalDiagnostics, logInfo } from "./logger.js";
|
||||||
|
installGlobalDiagnostics();
|
||||||
|
|
||||||
import { bindIdentityDialog, handleResetToken, logoutCurrentSession, validateCurrentSession } from "./auth-ui.js";
|
import { bindIdentityDialog, handleResetToken, logoutCurrentSession, validateCurrentSession } from "./auth-ui.js";
|
||||||
import { api } from "@rustpad/api";
|
import { api } from "@rustpad/api";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const PREFIX = "[RustPad]";
|
||||||
|
const LEVELS = Object.freeze({ off: 0, error: 1, warn: 2, info: 3, debug: 4 });
|
||||||
|
|
||||||
|
function configuredLevel() {
|
||||||
|
const configured = window.__RUSTPAD_CONFIG__?.frontendLogLevel;
|
||||||
|
return Object.prototype.hasOwnProperty.call(LEVELS, configured) ? configured : "warn";
|
||||||
|
}
|
||||||
|
|
||||||
|
function enabled(level) {
|
||||||
|
return LEVELS[configuredLevel()] >= LEVELS[level];
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDetails(details) {
|
||||||
|
if (!details || typeof details !== "object") return details;
|
||||||
|
const blocked = /password|token|secret|authorization|cookie|email|content/i;
|
||||||
|
return Object.fromEntries(Object.entries(details).map(([key, value]) => [key, blocked.test(key) ? "[redacted]" : value]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logInfo(event, details = {}) { if (enabled("info")) console.info(PREFIX, event, safeDetails(details)); }
|
||||||
|
export function logWarn(event, details = {}) { if (enabled("warn")) console.warn(PREFIX, event, safeDetails(details)); }
|
||||||
|
export function logError(event, error, details = {}) {
|
||||||
|
if (enabled("error")) console.error(PREFIX, event, { ...safeDetails(details), error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
export function logDebug(event, details = {}) { if (enabled("debug")) console.debug(PREFIX, event, safeDetails(details)); }
|
||||||
|
|
||||||
|
export function installGlobalDiagnostics() {
|
||||||
|
logInfo("frontend.initialized", { path: location.pathname, assetVersion: document.body?.dataset.assetVersion || "unknown", logLevel: configuredLevel() });
|
||||||
|
window.addEventListener("error", (event) => logError("frontend.uncaught_error", event.error || event.message, { file: event.filename, line: event.lineno, column: event.colno }));
|
||||||
|
window.addEventListener("unhandledrejection", (event) => logError("frontend.unhandled_rejection", event.reason));
|
||||||
|
window.addEventListener("online", () => logInfo("network.online"));
|
||||||
|
window.addEventListener("offline", () => logWarn("network.offline"));
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { installGlobalDiagnostics, logInfo } from "./logger.js";
|
||||||
|
installGlobalDiagnostics();
|
||||||
|
|
||||||
import { api } from "@rustpad/api";
|
import { api } from "@rustpad/api";
|
||||||
import { copyText } from "@rustpad/clipboard";
|
import { copyText } from "@rustpad/clipboard";
|
||||||
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
|
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { installGlobalDiagnostics, logInfo } from "./logger.js";
|
||||||
|
installGlobalDiagnostics();
|
||||||
|
|
||||||
import { api } from "@rustpad/api";
|
import { api } from "@rustpad/api";
|
||||||
import { copyText } from "@rustpad/clipboard";
|
import { copyText } from "@rustpad/clipboard";
|
||||||
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
|
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { installGlobalDiagnostics, logInfo } from "./logger.js";
|
||||||
|
installGlobalDiagnostics();
|
||||||
|
|
||||||
import { api } from "@rustpad/api";
|
import { api } from "@rustpad/api";
|
||||||
import { copyText } from "@rustpad/clipboard";
|
import { copyText } from "@rustpad/clipboard";
|
||||||
import { renderMarkdown } from "@rustpad/markdown";
|
import { renderMarkdown } from "@rustpad/markdown";
|
||||||
|
|||||||
+4
-2
@@ -1,11 +1,13 @@
|
|||||||
|
import { logDebug, logError, logInfo, logWarn } from "./logger.js";
|
||||||
|
|
||||||
export class NoteSocket {
|
export class NoteSocket {
|
||||||
constructor({ workspaceSlug, noteSlug, password, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }) { Object.assign(this, { workspaceSlug, noteSlug, password, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }); this.socket=null; this.timer=null; this.closed=false; }
|
constructor({ workspaceSlug, noteSlug, password, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }) { Object.assign(this, { workspaceSlug, noteSlug, password, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }); this.socket=null; this.timer=null; this.closed=false; }
|
||||||
connect() { clearTimeout(this.timer); this.closed=false; this.onStatus?.("connecting"); const protocol=location.protocol==="https:"?"wss:":"ws:"; this.socket=new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`); this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null,session_token:this.sessionToken||null}))); this.socket.addEventListener("message",event=>{const m=JSON.parse(event.data); if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();} if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);} if(m.type==="document")this.onDocument?.(m);}); this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}}); this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();}); }
|
connect() { clearTimeout(this.timer); this.closed=false; this.onStatus?.("connecting"); const protocol=location.protocol==="https:"?"wss:":"ws:"; this.socket=new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`); this.socket.addEventListener("open",()=>{logInfo("websocket.open",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null,session_token:this.sessionToken||null}));}); this.socket.addEventListener("message",event=>{const m=JSON.parse(event.data); if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();} if(m.type==="authenticated"){logInfo("websocket.authenticated",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.onStatus?.("online");this.onAuthenticated?.(m);} if(m.type==="document")this.onDocument?.(m);}); this.socket.addEventListener("close",event=>{logWarn("websocket.close",{kind:"note",code:event.code,reason:event.reason||"",intentional:this.closed});if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}}); this.socket.addEventListener("error",event=>{logError("websocket.error",event,{kind:"note"});this.onError?.("Failed to connect to the WebSocket server");this.socket.close();}); }
|
||||||
update(content, ownerMap="[]") { if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap})); }
|
update(content, ownerMap="[]") { if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap})); }
|
||||||
stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();}
|
stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();}
|
||||||
}
|
}
|
||||||
export class PadSocket {
|
export class PadSocket {
|
||||||
constructor({slug,password,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError}){Object.assign(this,{slug,password,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError});this.socket=null;this.timer=null;this.closed=false;}
|
constructor({slug,password,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError}){Object.assign(this,{slug,password,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError});this.socket=null;this.timer=null;this.closed=false;}
|
||||||
connect(){clearTimeout(this.timer);this.closed=false;this.onStatus?.("connecting");const protocol=location.protocol==="https:"?"wss:":"ws:";this.socket=new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`);this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null,session_token:this.sessionToken||null})));this.socket.addEventListener("message",e=>{const m=JSON.parse(e.data);if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();}if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);}if(m.type==="document")this.onDocument?.(m);});this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}});this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();});}
|
connect(){clearTimeout(this.timer);this.closed=false;this.onStatus?.("connecting");const protocol=location.protocol==="https:"?"wss:":"ws:";this.socket=new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`);this.socket.addEventListener("open",()=>{logInfo("websocket.open",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null,session_token:this.sessionToken||null}));});this.socket.addEventListener("message",e=>{const m=JSON.parse(e.data);if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();}if(m.type==="authenticated"){logInfo("websocket.authenticated",{kind:"note",workspace:this.workspaceSlug,note:this.noteSlug});this.onStatus?.("online");this.onAuthenticated?.(m);}if(m.type==="document")this.onDocument?.(m);});this.socket.addEventListener("close",event=>{logWarn("websocket.close",{kind:"note",code:event.code,reason:event.reason||"",intentional:this.closed});if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}});this.socket.addEventListener("error",event=>{logError("websocket.error",event,{kind:"note"});this.onError?.("Failed to connect to the WebSocket server");this.socket.close();});}
|
||||||
update(content,ownerMap="[]"){if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap}));} stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();}
|
update(content,ownerMap="[]"){if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap}));} stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { installGlobalDiagnostics, logInfo } from "./logger.js";
|
||||||
|
installGlobalDiagnostics();
|
||||||
|
|
||||||
import { api } from "@rustpad/api";
|
import { api } from "@rustpad/api";
|
||||||
import { copyText } from "@rustpad/clipboard";
|
import { copyText } from "@rustpad/clipboard";
|
||||||
import { getNickname, getPassword, setPassword } from "@rustpad/session";
|
import { getNickname, getPassword, setPassword } from "@rustpad/session";
|
||||||
|
|||||||
Reference in New Issue
Block a user