fixes and my notes function

This commit is contained in:
Mateusz Gruszczyński
2026-07-22 13:04:21 +02:00
parent 8bf45938ea
commit ec9f002b81
13 changed files with 224 additions and 14 deletions
+11 -3
View File
@@ -1,6 +1,6 @@
use axum::{
extract::{Multipart, Path, State},
http::{header, HeaderValue, StatusCode},
http::{header, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
Json,
};
@@ -122,13 +122,17 @@ pub struct NoteInfo {
pub async fn create_workspace(
State(state): State<SharedState>,
headers: HeaderMap,
Json(payload): Json<CreateWorkspaceRequest>,
) -> Result<(StatusCode, Json<CreateWorkspaceResponse>), ApiError> {
let title = validate_name(&payload.name, "Workspace name")?;
let password = validate_password(payload.password.as_deref())?;
let slug = unique_workspace_slug(&state, title).await?;
db::create_workspace(&state.db, &slug, title, password).await?;
let workspace = db::create_workspace(&state.db, &slug, title, password).await?;
if let Some(user) = crate::auth::optional_user(&state, &headers).await.map_err(|e| ApiError::forbidden(&e.message))? {
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_WORKSPACE)).bind(user.id).bind(&workspace.slug).execute(state.db.pool()).await?;
}
Ok((
StatusCode::CREATED,
@@ -398,6 +402,7 @@ pub struct PadInfo {
pub async fn create_pad(
State(state): State<SharedState>,
headers: HeaderMap,
Json(payload): Json<CreatePadRequest>,
) -> Result<(StatusCode, Json<CreatePadResponse>), ApiError> {
let title = validate_name(&payload.name, "Note name")?;
@@ -407,7 +412,10 @@ pub async fn create_pad(
return Err(ApiError::bad_request("The name cannot be converted into a valid address"));
}
let slug = unique_pad_slug(&state, &base).await?;
db::create_pad(&state.db, &slug, title, password).await?;
let pad = db::create_pad(&state.db, &slug, title, password).await?;
if let Some(user) = crate::auth::optional_user(&state, &headers).await.map_err(|e| ApiError::forbidden(&e.message))? {
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD)).bind(user.id).bind(&pad.slug).execute(state.db.pool()).await?;
}
Ok((
StatusCode::CREATED,
Json(CreatePadResponse {
+1
View File
@@ -25,6 +25,7 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
.route("/api/auth/login", post(auth::login))
.route("/api/auth/me", get(auth::me))
.route("/api/auth/logout", post(auth::logout))
.route("/api/auth/resources", get(auth::resources).put(auth::update_resource).delete(auth::delete_resource))
.route("/api/auth/password-reset", post(auth::request_reset))
.route("/api/auth/password-reset/confirm", post(auth::confirm_reset))
.route("/api/public/{token}", get(api::public_page))
+42
View File
@@ -22,6 +22,9 @@ pub struct User { pub id: i64, pub nickname: String, pub email: String, pub pass
#[derive(Deserialize)] pub struct LoginRequest { email: String, password: String }
#[derive(Deserialize)] pub struct ResetRequest { email: String }
#[derive(Deserialize)] pub struct ResetConfirmRequest { token: String, password: String }
#[derive(Deserialize)] pub struct ResourceActionRequest { kind: String, slug: String, #[serde(default)] password: Option<String> }
#[derive(Serialize, FromRow)] pub struct ResourceItem { slug: String, title: String, protected: i64, updated_at: String }
#[derive(Serialize)] pub struct ResourceList { workspaces: Vec<ResourceItem>, pads: Vec<ResourceItem> }
#[derive(Serialize)] pub struct SessionResponse { token: String, nickname: String, email: String, expires_at: String }
#[derive(Serialize)] pub struct IdentityResponse { nickname: String, registered: bool }
@@ -79,6 +82,45 @@ pub async fn me(State(state): State<SharedState>, headers: HeaderMap) -> Result<
Ok(Json(SessionResponse { token: token.into(), nickname: user.nickname, email: user.email, expires_at }))
}
pub async fn resources(State(state): State<SharedState>, headers: HeaderMap) -> Result<Json<ResourceList>, AuthError> {
let user = require_user(&state, &headers).await?;
let workspaces = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_WORKSPACES)).bind(user.id).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
let pads = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_PADS)).bind(user.id).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
Ok(Json(ResourceList { workspaces, pads }))
}
pub async fn update_resource(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<ResourceActionRequest>) -> Result<Json<serde_json::Value>, AuthError> {
let user = require_user(&state, &headers).await?;
let hash = match req.password.as_deref().map(str::trim).filter(|v| !v.is_empty()) { Some(v) => { validate_password(v)?; Some(hash_password(v)?) }, None => None };
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)?;
Ok(Json(serde_json::json!({"ok":true})))
}
pub async fn delete_resource(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<ResourceActionRequest>) -> Result<Json<serde_json::Value>, AuthError> {
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(), query)).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
Ok(Json(serde_json::json!({"ok":true})))
}
pub async fn optional_user(state: &SharedState, headers: &HeaderMap) -> Result<Option<User>, AuthError> {
match bearer(headers) { Some(token) => user_from_token(state, token).await, None => Ok(None) }
}
async fn require_user(state: &SharedState, headers: &HeaderMap) -> Result<User, AuthError> {
optional_user(state, headers).await?.ok_or_else(|| AuthError::unauthorized("Log in first."))
}
async fn ensure_owner(state: &SharedState, user_id: i64, kind: &str, slug: &str) -> Result<(), AuthError> {
let query = match kind { "workspace" => queries::USER_OWNS_WORKSPACE, "pad" => queries::USER_OWNS_PAD, _ => return Err(AuthError::bad_request("Unknown resource type.")) };
let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), query)).bind(user_id).bind(slug.trim()).fetch_one(state.db.pool()).await.map_err(AuthError::database)?;
if count == 0 { return Err(AuthError::forbidden("This item does not belong to your account.")); }
Ok(())
}
pub async fn logout(State(state): State<SharedState>, headers: HeaderMap) -> Result<Json<serde_json::Value>, AuthError> {
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)?;
+11
View File
@@ -25,6 +25,17 @@ pub const AUTH_USER_BY_SESSION: &str = "SELECT u.id, u.nickname, u.email, u.pass
pub const AUTH_INSERT_SESSION: &str = "INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)";
pub const AUTH_USER_BY_NICKNAME: &str = "SELECT id, nickname, email, password_hash FROM users WHERE nickname_key = ?";
pub const AUTH_USER_BY_EMAIL: &str = "SELECT id, nickname, email, password_hash FROM users WHERE email_key = ?";
pub const USER_ATTACH_WORKSPACE: &str = "INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?";
pub const USER_ATTACH_PAD: &str = "INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?";
pub const USER_LIST_WORKSPACES: &str = "SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? ORDER BY w.updated_at DESC";
pub const USER_LIST_PADS: &str = "SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, p.updated_at FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? ORDER BY p.updated_at DESC";
pub const USER_OWNS_WORKSPACE: &str = "SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?";
pub const USER_OWNS_PAD: &str = "SELECT COUNT(*) FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? AND p.slug = ?";
pub const USER_DELETE_WORKSPACE: &str = "DELETE FROM workspaces WHERE slug = ?";
pub const USER_DELETE_PAD: &str = "DELETE FROM pads WHERE slug = ?";
pub const USER_SET_WORKSPACE_PASSWORD: &str = "UPDATE workspaces SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?";
pub const USER_SET_PAD_PASSWORD: &str = "UPDATE pads SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?";
pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?";
pub const Q002: &str = "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)";