session lifetime

This commit is contained in:
Mateusz Gruszczyński
2026-07-22 23:49:16 +02:00
parent e998a38e49
commit 3ed74b1eac
20 changed files with 262 additions and 74 deletions
+113 -17
View File
@@ -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, &note_slug, payload.password.as_deref()).await?;
let (workspace, note) = authorized_note(&state, &workspace_slug, &note_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, &note_slug, payload.password.as_deref()).await?;
let (workspace, note) = authorized_note(&state, &workspace_slug, &note_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, &note_slug, payload.password.as_deref()).await?;
let (_, note) = authorized_note(&state, &workspace_slug, &note_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, &note_slug, password.as_deref()).await?;
let (_workspace, note) = authorized_note(&state, &workspace_slug, &note_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, &note_slug, payload.password.as_deref()).await?;
let (_workspace, note) = authorized_note(&state, &workspace_slug, &note_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, &note_slug, payload.password.as_deref()).await?;
let (_workspace, note) = authorized_note(&state, &workspace_slug, &note_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, &note_slug, payload.password.as_deref()).await?;
let (workspace, note) = authorized_note(&state, &workspace_slug, &note_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,