session lifetime
This commit is contained in:
+113
-17
@@ -5,6 +5,9 @@ use axum::{
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{Duration, Utc};
|
||||
use rand_core::{OsRng, RngCore};
|
||||
use sha2::{Digest, Sha256};
|
||||
use slug::slugify;
|
||||
|
||||
use crate::{
|
||||
@@ -47,6 +50,8 @@ pub struct CreateWorkspaceResponse {
|
||||
pub struct PasswordRequest {
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
#[serde(default)]
|
||||
access_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -54,6 +59,8 @@ pub struct PublishRequest {
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
#[serde(default)]
|
||||
access_token: Option<String>,
|
||||
#[serde(default)]
|
||||
allow_task_updates: bool,
|
||||
}
|
||||
|
||||
@@ -69,6 +76,8 @@ pub struct CreateNoteRequest {
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
#[serde(default)]
|
||||
access_token: Option<String>,
|
||||
#[serde(default)]
|
||||
protect: bool,
|
||||
#[serde(default)]
|
||||
created_by: Option<String>,
|
||||
@@ -78,6 +87,8 @@ pub struct CreateNoteRequest {
|
||||
pub struct RestoreRequest {
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
#[serde(default)]
|
||||
access_token: Option<String>,
|
||||
revision_id: i64,
|
||||
}
|
||||
|
||||
@@ -158,7 +169,7 @@ pub async fn open_workspace(
|
||||
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 workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?;
|
||||
let notes = db::list_notes(&state.db, workspace.id)
|
||||
.await?
|
||||
.into_iter()
|
||||
@@ -184,7 +195,7 @@ pub async fn create_note(
|
||||
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 workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?;
|
||||
let title = validate_name(&payload.name, "Note name")?;
|
||||
let base = slugify(title);
|
||||
if base.is_empty() {
|
||||
@@ -237,7 +248,7 @@ pub async fn history(
|
||||
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, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?;
|
||||
let _ = workspace;
|
||||
let revisions = db::list_revisions(&state.db, note.id)
|
||||
.await?
|
||||
@@ -255,7 +266,7 @@ pub async fn restore(
|
||||
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 (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?;
|
||||
let content: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q028))
|
||||
.bind(payload.revision_id)
|
||||
.bind(note.id)
|
||||
@@ -279,11 +290,14 @@ pub async fn authorized_workspace(
|
||||
state: &SharedState,
|
||||
slug: &str,
|
||||
password: Option<&str>,
|
||||
access_token: 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) {
|
||||
if workspace.password_hash.is_some()
|
||||
&& !db::verify_workspace_password(&workspace, password)
|
||||
&& !verify_resource_access_token(state, "workspace", slug, access_token).await? {
|
||||
return Err(ApiError::unauthorized());
|
||||
}
|
||||
Ok(workspace)
|
||||
@@ -294,8 +308,9 @@ async fn authorized_note(
|
||||
workspace_slug: &str,
|
||||
note_slug: &str,
|
||||
password: Option<&str>,
|
||||
access_token: Option<&str>,
|
||||
) -> Result<(db::Workspace, db::Note), ApiError> {
|
||||
let workspace = authorized_workspace(state, workspace_slug, password).await?;
|
||||
let workspace = authorized_workspace(state, workspace_slug, password, access_token).await?;
|
||||
let note = db::find_note(&state.db, workspace.id, note_slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
@@ -447,7 +462,7 @@ pub async fn publish_pad_page(
|
||||
Path(slug): Path<String>,
|
||||
Json(payload): Json<PublishRequest>,
|
||||
) -> Result<Json<PublishResponse>, ApiError> {
|
||||
let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?;
|
||||
let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?;
|
||||
let token = db::publish_pad(&state.db, pad.id).await?;
|
||||
db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).await?;
|
||||
Ok(Json(PublishResponse { url: format!("/s/{token}") }))
|
||||
@@ -458,7 +473,7 @@ pub async fn publish_note_page(
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
Json(payload): Json<PublishRequest>,
|
||||
) -> Result<Json<PublishResponse>, ApiError> {
|
||||
let (_, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?;
|
||||
let (_, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?;
|
||||
let token = db::publish_note(&state.db, note.id).await?;
|
||||
db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).await?;
|
||||
Ok(Json(PublishResponse { url: format!("/s/{token}") }))
|
||||
@@ -500,7 +515,7 @@ pub async fn pad_history(
|
||||
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?;
|
||||
let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?;
|
||||
let revisions = db::list_pad_revisions(&state.db, pad.id)
|
||||
.await?
|
||||
.into_iter()
|
||||
@@ -517,7 +532,7 @@ pub async fn pad_restore(
|
||||
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 pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?;
|
||||
let content: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q029))
|
||||
.bind(payload.revision_id)
|
||||
.bind(pad.id)
|
||||
@@ -546,11 +561,14 @@ async fn authorized_pad(
|
||||
state: &SharedState,
|
||||
slug: &str,
|
||||
password: Option<&str>,
|
||||
access_token: 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) {
|
||||
if pad.password_hash.is_some()
|
||||
&& !db::verify_pad_password(&pad, password)
|
||||
&& !verify_resource_access_token(state, "pad", slug, access_token).await? {
|
||||
return Err(ApiError::unauthorized());
|
||||
}
|
||||
Ok(pad)
|
||||
@@ -577,11 +595,14 @@ pub async fn upload_pad_file(
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let mut password: Option<String> = None;
|
||||
let mut access_token: Option<String> = None;
|
||||
let mut file: Option<(String, Vec<u8>)> = None;
|
||||
while let Some(field) = multipart.next_field().await.map_err(|_| ApiError::bad_request("Invalid form data"))? {
|
||||
let name = field.name().unwrap_or_default().to_owned();
|
||||
if name == "password" {
|
||||
password = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid password"))?);
|
||||
} else if name == "access_token" {
|
||||
access_token = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid access token"))?);
|
||||
} else if name == "file" {
|
||||
let filename = field.file_name().unwrap_or("plik").to_owned();
|
||||
let bytes = field.bytes().await.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
|
||||
@@ -591,7 +612,7 @@ pub async fn upload_pad_file(
|
||||
file = Some((filename, bytes.to_vec()));
|
||||
}
|
||||
}
|
||||
let pad = authorized_pad(&state, &slug, password.as_deref()).await?;
|
||||
let pad = authorized_pad(&state, &slug, password.as_deref(), access_token.as_deref()).await?;
|
||||
let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?;
|
||||
let safe = sanitize_filename(&original);
|
||||
let file_token = db::pad_file_token(&state.db, pad.id).await?;
|
||||
@@ -618,7 +639,7 @@ pub async fn pad_files(
|
||||
Path(slug): Path<String>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<Vec<db::NoteFile>>, ApiError> {
|
||||
let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?;
|
||||
let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?;
|
||||
let mut files = db::list_pad_files(&state.db, pad.id).await?;
|
||||
for file in &mut files {
|
||||
let attached = pad.content.contains(&file.url);
|
||||
@@ -638,11 +659,14 @@ pub async fn upload_note_file(
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let mut password: Option<String> = None;
|
||||
let mut access_token: Option<String> = None;
|
||||
let mut file: Option<(String, Vec<u8>)> = None;
|
||||
while let Some(field) = multipart.next_field().await.map_err(|_| ApiError::bad_request("Invalid form data"))? {
|
||||
let name = field.name().unwrap_or_default().to_owned();
|
||||
if name == "password" {
|
||||
password = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid password"))?);
|
||||
} else if name == "access_token" {
|
||||
access_token = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid access token"))?);
|
||||
} else if name == "file" {
|
||||
let filename = field.file_name().unwrap_or("plik").to_owned();
|
||||
let bytes = field.bytes().await.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
|
||||
@@ -652,7 +676,7 @@ pub async fn upload_note_file(
|
||||
file = Some((filename, bytes.to_vec()));
|
||||
}
|
||||
}
|
||||
let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, password.as_deref()).await?;
|
||||
let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, password.as_deref(), access_token.as_deref()).await?;
|
||||
let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?;
|
||||
let safe = sanitize_filename(&original);
|
||||
let file_token = db::note_file_token(&state.db, note.id).await?;
|
||||
@@ -680,7 +704,7 @@ pub async fn delete_note(
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?;
|
||||
let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?;
|
||||
if note.protected { return Err(ApiError::bad_request("This note is protected and cannot be deleted")); }
|
||||
db::delete_note(&state.db, note.id).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
@@ -691,7 +715,7 @@ pub async fn note_files(
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<Vec<db::NoteFile>>, ApiError> {
|
||||
let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?;
|
||||
let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?;
|
||||
let mut files = db::list_note_files(&state.db, note.id).await?;
|
||||
for file in &mut files {
|
||||
let attached = note.content.contains(&file.url);
|
||||
@@ -710,7 +734,7 @@ pub async fn delete_note_file(
|
||||
Path((workspace_slug, note_slug, file_id)): Path<(String, String, i64)>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?;
|
||||
let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?;
|
||||
if workspace.password_hash.is_none() || payload.password.as_deref().unwrap_or_default().is_empty() {
|
||||
return Err(ApiError::unauthorized());
|
||||
}
|
||||
@@ -786,6 +810,78 @@ fn sanitize_filename(value: &str) -> String {
|
||||
if clean.is_empty() || clean == "." || clean == ".." { "plik".into() } else { clean.chars().take(160).collect() }
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AccessTokenRequest {
|
||||
kind: String,
|
||||
slug: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AccessTokenResponse {
|
||||
access_token: String,
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
pub async fn create_resource_access_token(
|
||||
State(state): State<SharedState>,
|
||||
Json(payload): Json<AccessTokenRequest>,
|
||||
) -> Result<Json<AccessTokenResponse>, ApiError> {
|
||||
let kind = payload.kind.trim();
|
||||
let slug = payload.slug.trim();
|
||||
match kind {
|
||||
"workspace" => {
|
||||
let workspace = db::find_workspace(&state.db, slug).await?.ok_or_else(ApiError::not_found_workspace)?;
|
||||
if !db::verify_workspace_password(&workspace, Some(payload.password.as_str())) {
|
||||
return Err(ApiError::unauthorized());
|
||||
}
|
||||
}
|
||||
"pad" => {
|
||||
let pad = db::find_pad(&state.db, slug).await?.ok_or_else(ApiError::not_found_note)?;
|
||||
if !db::verify_pad_password(&pad, Some(payload.password.as_str())) {
|
||||
return Err(ApiError::unauthorized());
|
||||
}
|
||||
}
|
||||
_ => return Err(ApiError::bad_request("Invalid resource kind")),
|
||||
}
|
||||
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let token = hex::encode(bytes);
|
||||
let expires_at = (Utc::now() + Duration::days(state.anonymous_access_token_ttl_days)).to_rfc3339();
|
||||
sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"))
|
||||
.bind(hash_access_token(&token))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.bind(&expires_at)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(AccessTokenResponse { access_token: token, expires_at }))
|
||||
}
|
||||
|
||||
pub async fn verify_resource_access_token(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<bool, ApiError> {
|
||||
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?"))
|
||||
.bind(hash_access_token(token))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.fetch_one(state.db.pool())
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
fn hash_access_token(token: &str) -> String {
|
||||
hex::encode(Sha256::digest(token.as_bytes()))
|
||||
}
|
||||
|
||||
pub struct ApiError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
|
||||
@@ -38,6 +38,7 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
|
||||
.route("/f/{token}/{filename}", get(api::download_file))
|
||||
.route("/files/{directory}/{filename}", get(api::download_legacy_file))
|
||||
.route("/api/auth/identity", post(auth::identity))
|
||||
.route("/api/access-token", post(api::create_resource_access_token))
|
||||
.route("/api/auth/register", post(auth::register))
|
||||
.route("/api/auth/login", post(auth::login))
|
||||
.route("/api/auth/confirm-account", post(auth::confirm_account))
|
||||
|
||||
+5
-1
@@ -172,6 +172,8 @@ pub async fn update_resource(State(state): State<SharedState>, headers: HeaderMa
|
||||
ensure_owner(&state, user.id, &req.kind, &req.slug).await?;
|
||||
let query = match req.kind.as_str() { "workspace" => queries::USER_SET_WORKSPACE_PASSWORD, "pad" => queries::USER_SET_PAD_PASSWORD, _ => return Err(AuthError::bad_request("Unknown resource type.")) };
|
||||
sqlx::query(queries::get(state.db.kind(), query)).bind(hash).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"))
|
||||
.bind(req.kind.as_str()).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
@@ -179,6 +181,8 @@ pub async fn delete_resource(State(state): State<SharedState>, headers: HeaderMa
|
||||
let user = require_user(&state, &headers).await?;
|
||||
ensure_owner(&state, user.id, &req.kind, &req.slug).await?;
|
||||
let query = match req.kind.as_str() { "workspace" => queries::USER_DELETE_WORKSPACE, "pad" => queries::USER_DELETE_PAD, _ => return Err(AuthError::bad_request("Unknown resource type.")) };
|
||||
sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"))
|
||||
.bind(req.kind.as_str()).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(state.db.kind(), query)).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
@@ -276,7 +280,7 @@ pub async fn authorize_nickname(state: &SharedState, nickname: Option<String>, t
|
||||
|
||||
async fn create_session(state: &SharedState, user: &User) -> Result<SessionResponse, AuthError> {
|
||||
let token = random_token();
|
||||
let expires_at = (Utc::now() + Duration::days(30)).to_rfc3339();
|
||||
let expires_at = (Utc::now() + Duration::days(state.user_session_ttl_days)).to_rfc3339();
|
||||
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)?;
|
||||
debug!(user_id = user.id, expires_at = %expires_at, "authentication session created");
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct Config {
|
||||
pub registration_enabled: bool,
|
||||
pub account_confirmation_required: bool,
|
||||
pub frontend_log_level: String,
|
||||
pub anonymous_access_token_ttl_days: i64,
|
||||
pub user_session_ttl_days: i64,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -25,6 +27,8 @@ impl Config {
|
||||
|
||||
let upload_max_size_mb: usize =
|
||||
env_var("UPLOAD_MAX_SIZE_MB", "20").parse()?;
|
||||
let anonymous_access_token_ttl_days = env_positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?;
|
||||
let user_session_ttl_days = env_positive_i64("USER_SESSION_TTL_DAYS", 30)?;
|
||||
|
||||
if upload_max_size_mb == 0 {
|
||||
return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into());
|
||||
@@ -60,6 +64,8 @@ impl Config {
|
||||
registration_enabled: env_bool("REGISTRATION_ENABLED", false)?,
|
||||
account_confirmation_required: env_bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
|
||||
frontend_log_level: env_log_level("FRONTEND_LOG_LEVEL", "warn")?,
|
||||
anonymous_access_token_ttl_days,
|
||||
user_session_ttl_days,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -86,3 +92,11 @@ fn env_log_level(name: &str, default: &str) -> Result<String, Box<dyn std::error
|
||||
_ => Err(format!("{name} must be one of: off, error, warn, info, debug").into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn env_positive_i64(name: &str, default: i64) -> Result<i64, Box<dyn std::error::Error>> {
|
||||
let value: i64 = env_var(name, &default.to_string()).parse()?;
|
||||
if value <= 0 {
|
||||
return Err(format!("{name} must be greater than 0").into());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
registration_enabled = config.registration_enabled,
|
||||
account_confirmation_required = config.account_confirmation_required,
|
||||
frontend_log_level = %config.frontend_log_level,
|
||||
anonymous_access_token_ttl_days = config.anonymous_access_token_ttl_days,
|
||||
user_session_ttl_days = config.user_session_ttl_days,
|
||||
smtp_configured = config.smtp.is_some(),
|
||||
asset_version = %config.asset_version,
|
||||
"configuration loaded"
|
||||
@@ -58,6 +60,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
config.registration_enabled,
|
||||
config.account_confirmation_required,
|
||||
config.frontend_log_level.clone(),
|
||||
config.anonymous_access_token_ttl_days,
|
||||
config.user_session_ttl_days,
|
||||
));
|
||||
let app = app::router(
|
||||
state,
|
||||
|
||||
+4
-2
@@ -28,12 +28,14 @@ pub struct AppState {
|
||||
pub registration_enabled: bool,
|
||||
pub account_confirmation_required: bool,
|
||||
pub frontend_log_level: String,
|
||||
pub anonymous_access_token_ttl_days: i64,
|
||||
pub user_session_ttl_days: i64,
|
||||
channels: RwLock<HashMap<String, broadcast::Sender<NoteUpdate>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, smtp: Option<SmtpConfig>, registration_enabled: bool, account_confirmation_required: bool, frontend_log_level: String) -> Self {
|
||||
Self { db, asset_version, files_dir, upload_max_size_bytes, smtp, registration_enabled, account_confirmation_required, frontend_log_level, channels: RwLock::new(HashMap::new()) }
|
||||
pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, smtp: Option<SmtpConfig>, registration_enabled: bool, account_confirmation_required: bool, frontend_log_level: String, anonymous_access_token_ttl_days: i64, user_session_ttl_days: i64) -> Self {
|
||||
Self { db, asset_version, files_dir, upload_max_size_bytes, smtp, registration_enabled, account_confirmation_required, frontend_log_level, anonymous_access_token_ttl_days, user_session_ttl_days, 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(); }
|
||||
|
||||
+7
-7
@@ -7,7 +7,7 @@ use crate::{auth, db, state::{NoteUpdate, SharedState}};
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ClientMessage {
|
||||
Authenticate { password: Option<String>, nickname: Option<String>, session_token: Option<String> },
|
||||
Authenticate { password: Option<String>, access_token: Option<String>, nickname: Option<String>, session_token: Option<String> },
|
||||
Update { content: String, owner_map: Option<String> },
|
||||
}
|
||||
|
||||
@@ -27,14 +27,14 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug
|
||||
info!(%workspace_slug, %note_slug, "note websocket connected");
|
||||
let Some(workspace) = db::find_workspace(&state.db, &workspace_slug).await.ok().flatten() else { warn!(%workspace_slug, %note_slug, "note websocket rejected: workspace not found"); let _=send_error(&mut socket,"Workspace not found").await; return; };
|
||||
let Some(note) = db::find_note(&state.db, workspace.id, ¬e_slug).await.ok().flatten() else { warn!(%workspace_slug, %note_slug, "note websocket rejected: note not found"); let _=send_error(&mut socket,"Note not found").await; return; };
|
||||
let (password, nickname, session_token) = match socket.recv().await {
|
||||
let (password, access_token, nickname, session_token) = match socket.recv().await {
|
||||
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, access_token, nickname, session_token }) => (password, access_token, clean_nickname(nickname), session_token),
|
||||
_ => { let _=send_error(&mut socket,"Wymagane uwierzytelnienie").await; 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; } };
|
||||
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; }
|
||||
if workspace.password_hash.is_some() && !db::verify_workspace_password(&workspace, password.as_deref()) && !crate::api::verify_resource_access_token(&state, "workspace", &workspace_slug, access_token.as_deref()).await.unwrap_or(false) { 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;}
|
||||
let channel=state.note_channel(&workspace_slug,¬e_slug).await;
|
||||
@@ -92,14 +92,14 @@ pub async fn upgrade_pad(ws:WebSocketUpgrade,Path(slug):Path<String>,State(state
|
||||
async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){
|
||||
info!(%slug, "pad websocket connected");
|
||||
let Some(pad)=db::find_pad(&state.db,&slug).await.ok().flatten() else {warn!(%slug, "pad websocket rejected: pad not found");let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Note not found".into()}).await;return;};
|
||||
let (password,nickname,session_token)=match socket.recv().await{
|
||||
let (password,access_token,nickname,session_token)=match socket.recv().await{
|
||||
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,access_token,nickname,session_token})=>(password,access_token,clean_nickname(nickname),session_token),
|
||||
_=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Wymagane uwierzytelnienie".into()}).await;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;}};
|
||||
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;}
|
||||
if pad.password_hash.is_some() && !db::verify_pad_password(&pad,password.as_deref()) && !crate::api::verify_resource_access_token(&state,"pad",&slug,access_token.as_deref()).await.unwrap_or(false){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;}
|
||||
let channel=state.pad_channel(&slug).await;
|
||||
|
||||
Reference in New Issue
Block a user