This commit is contained in:
Mateusz Gruszczyński
2026-07-24 23:16:03 +02:00
parent 041f586261
commit 3576877ace
17 changed files with 629 additions and 148 deletions
+122 -36
View File
@@ -129,6 +129,7 @@ pub struct NoteInfo {
allow_public_task_updates: bool,
created_at: String,
updated_at: String,
can_delete_files: bool,
}
pub async fn create_workspace(
@@ -258,6 +259,7 @@ pub async fn create_note(
pub async fn note_info(
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug)): Path<(String, String)>,
) -> Result<Json<NoteInfo>, ApiError> {
let workspace = db::find_workspace(&state.db, &workspace_slug)
@@ -277,6 +279,27 @@ pub async fn note_info(
allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?,
created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at),
can_delete_files: {
let workspace_owner = crate::auth::is_resource_owner(
&state,
"workspace",
&workspace_slug,
bearer_token(&headers),
)
.await
.unwrap_or(false);
let note_owner = crate::auth::optional_user(&state, &headers)
.await
.ok()
.flatten()
.and_then(|user| {
note.created_by
.as_deref()
.map(|creator| creator == user.nickname)
})
.unwrap_or(false);
workspace_owner || note_owner
},
}))
}
@@ -483,6 +506,7 @@ pub struct PadInfo {
allow_public_task_updates: bool,
created_at: String,
updated_at: String,
can_delete_files: bool,
}
pub async fn create_pad(
@@ -521,6 +545,7 @@ pub async fn create_pad(
pub async fn pad_info(
State(state): State<SharedState>,
headers: HeaderMap,
Path(slug): Path<String>,
) -> Result<Json<PadInfo>, ApiError> {
let pad = db::find_pad(&state.db, &slug)
@@ -533,6 +558,14 @@ pub async fn pad_info(
allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?,
created_at: db::normalize_timestamp(&pad.created_at),
updated_at: db::normalize_timestamp(&pad.updated_at),
can_delete_files: crate::auth::is_resource_owner(
&state,
"pad",
&slug,
bearer_token(&headers),
)
.await
.unwrap_or(false),
}))
}
@@ -817,6 +850,35 @@ pub async fn pad_files(
Ok(Json(files))
}
pub async fn delete_pad_file(
State(state): State<SharedState>,
headers: HeaderMap,
Path((slug, file_id)): Path<(String, i64)>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let pad = authorized_pad(
&state,
&slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
)
.await?;
if !crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers))
.await
.unwrap_or(false)
{
return Err(ApiError::forbidden("Only the note owner can delete files"));
}
let file = db::find_pad_file(&state.db, pad.id, file_id)
.await?
.ok_or_else(ApiError::not_found_file)?;
crate::storage::delete_url_file(&state.storage, "pads", pad.id, &file.url)
.await
.map_err(|_| ApiError::internal("Failed to delete the file"))?;
db::delete_pad_file(&state.db, pad.id, file_id).await?;
Ok(Json(serde_json::json!({"ok": true})))
}
pub async fn upload_note_file(
State(state): State<SharedState>,
Path((workspace_slug, note_slug)): Path<(String, String)>,
@@ -920,6 +982,11 @@ pub async fn delete_note(
"This note is protected and cannot be deleted",
));
}
for file in db::list_note_files(&state.db, note.id).await? {
crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url)
.await
.map_err(|_| ApiError::internal("Failed to delete note files"))?;
}
db::delete_note(&state.db, note.id).await?;
Ok(Json(serde_json::json!({"ok": true})))
}
@@ -956,6 +1023,7 @@ pub async fn note_files(
pub async fn delete_note_file(
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug, file_id)): Path<(String, String, i64)>,
Json(payload): Json<PasswordRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
@@ -967,32 +1035,35 @@ pub async fn delete_note_file(
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());
let workspace_owner = crate::auth::is_resource_owner(
&state,
"workspace",
&workspace.slug,
bearer_token(&headers),
)
.await
.unwrap_or(false);
let note_owner = crate::auth::optional_user(&state, &headers)
.await
.ok()
.flatten()
.and_then(|user| {
note.created_by
.as_deref()
.map(|creator| creator == user.nickname)
})
.unwrap_or(false);
if !workspace_owner && !note_owner {
return Err(ApiError::forbidden(
"Only the note owner or workspace owner can delete files",
));
}
let file = db::find_note_file(&state.db, note.id, file_id)
.await?
.ok_or_else(ApiError::not_found_file)?;
let relative = file
.url
.trim_start_matches('/')
.split('/')
.collect::<Vec<_>>();
if relative.len() == 3 && relative[0] == "f" {
let key = crate::storage::object_key(
"notes",
note.id,
relative[1],
&sanitize_filename(relative[2]),
);
state
.storage
.delete(&key)
.await
.map_err(|_| ApiError::internal("Failed to delete the file"))?;
}
crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url)
.await
.map_err(|_| ApiError::internal("Failed to delete the file"))?;
db::delete_note_file(&state.db, note.id, file_id).await?;
Ok(Json(serde_json::json!({"ok": true})))
}
@@ -1066,6 +1137,15 @@ async fn serve_token_file(
Ok(response)
}
fn bearer_token(headers: &HeaderMap) -> Option<&str> {
headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn sanitize_filename(value: &str) -> String {
let name = std::path::Path::new(value)
.file_name()
@@ -1132,13 +1212,16 @@ pub async fn create_resource_access_token(
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(), queries::RESOURCE_ACCESS_TOKENS_INSERT))
.bind(hash_access_token(&token))
.bind(kind)
.bind(slug)
.bind(&expires_at)
.execute(state.db.pool())
.await?;
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_ACCESS_TOKENS_INSERT,
))
.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,
@@ -1161,13 +1244,16 @@ pub async fn verify_resource_access_token(
{
return Ok(true);
}
let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT))
.bind(hash_access_token(token))
.bind(kind)
.bind(slug)
.bind(Utc::now().to_rfc3339())
.fetch_one(state.db.pool())
.await?;
let count: i64 = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT,
))
.bind(hash_access_token(token))
.bind(kind)
.bind(slug)
.bind(Utc::now().to_rfc3339())
.fetch_one(state.db.pool())
.await?;
Ok(count > 0)
}
+4
View File
@@ -99,6 +99,10 @@ pub fn router(
"/api/pads/{slug}/files",
post(api::upload_pad_file).put(api::pad_files),
)
.route(
"/api/pads/{slug}/files/{file_id}",
axum::routing::delete(api::delete_pad_file),
)
.route("/api/workspaces", post(api::create_workspace))
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
.route(
+275 -44
View File
@@ -240,6 +240,15 @@ pub async fn register(
let token = random_confirmation_token();
if state.account_confirmation_required {
let expires = (Utc::now() + Duration::hours(24)).to_rfc3339();
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER,
))
.bind(user.id)
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::AUTH_INSERT_CONFIRMATION_TOKEN,
@@ -247,9 +256,10 @@ pub async fn register(
.bind(hash_token(&token))
.bind(user.id)
.bind(expires)
.execute(state.db.pool())
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
tx.commit().await.map_err(AuthError::database)?;
confirmation_token = Some(token.as_str());
}
if let Err(error) =
@@ -362,19 +372,36 @@ pub async fn confirm_account(
));
}
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), queries::AUTH_CONFIRM_USER))
let consumed = sqlx::query(queries::get(
state.db.kind(),
queries::AUTH_CONSUME_CONFIRMATION_TOKEN,
))
.bind(&now)
.bind(&token_hash)
.bind(&now)
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
if consumed.rows_affected() != 1 {
return Err(AuthError::bad_request(
"The confirmation link is invalid or has expired.",
));
}
let confirmed = sqlx::query(queries::get(state.db.kind(), queries::AUTH_CONFIRM_USER))
.bind(&now)
.bind(&now)
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
if confirmed.rows_affected() != 1 {
return Err(AuthError::internal("The account could not be confirmed."));
}
sqlx::query(queries::get(
state.db.kind(),
queries::AUTH_MARK_CONFIRMATION_TOKEN_USED,
queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER,
))
.bind(&now)
.bind(&token_hash)
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
@@ -484,8 +511,48 @@ pub async fn delete_resource(
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,
"workspace" => {
if let Some(workspace) = crate::db::find_workspace(&state.db, req.slug.trim())
.await
.map_err(AuthError::database)?
{
let notes = crate::db::list_notes(&state.db, workspace.id)
.await
.map_err(AuthError::database)?;
for note in notes {
let files = crate::db::list_note_files(&state.db, note.id)
.await
.map_err(AuthError::database)?;
for file in files {
crate::storage::delete_url_file(
&state.storage,
"notes",
note.id,
&file.url,
)
.await
.map_err(|_| AuthError::internal("Failed to delete workspace files."))?;
}
}
}
queries::USER_DELETE_WORKSPACE
}
"pad" => {
if let Some(pad) = crate::db::find_pad(&state.db, req.slug.trim())
.await
.map_err(AuthError::database)?
{
let files = crate::db::list_pad_files(&state.db, pad.id)
.await
.map_err(AuthError::database)?;
for file in files {
crate::storage::delete_url_file(&state.storage, "pads", pad.id, &file.url)
.await
.map_err(|_| AuthError::internal("Failed to delete pad files."))?;
}
}
queries::USER_DELETE_PAD
}
_ => return Err(AuthError::bad_request("Unknown resource type.")),
};
sqlx::query(queries::get(
@@ -602,18 +669,45 @@ pub async fn share_resource_users(
continue;
}
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER))
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_DELETE_USER))
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_PERMISSION_DELETE_USER,
))
.bind(&req.kind)
.bind(req.slug.trim())
.bind(user.id)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::SHARE_INVITATION_DELETE_USER,
))
.bind(&req.kind)
.bind(req.slug.trim())
.bind(user.id)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
if state.share_confirmation_required {
let token = random_token();
let token_hash = hash_token(&token);
let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339();
sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_INSERT))
.bind(&token_hash).bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).bind(owner.id).bind(&expires_at)
.execute(state.db.pool()).await.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::SHARE_INVITATION_INSERT,
))
.bind(&token_hash)
.bind(&req.kind)
.bind(req.slug.trim())
.bind(user.id)
.bind(permission)
.bind(owner.id)
.bind(&expires_at)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
if let Err(error) = send_share_invitation(
state.smtp.as_ref().unwrap(),
&owner,
@@ -635,8 +729,17 @@ pub async fn share_resource_users(
return Err(error);
}
} else {
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_INSERT))
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).execute(state.db.pool()).await.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_PERMISSION_INSERT,
))
.bind(&req.kind)
.bind(req.slug.trim())
.bind(user.id)
.bind(permission)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
}
}
if !missing.is_empty() {
@@ -655,8 +758,13 @@ pub async fn accept_share_invitation(
AxumPath(token): AxumPath<String>,
) -> Result<Redirect, AuthError> {
let token_hash = hash_token(token.trim());
let row: Option<(String, String, i64, String, String, Option<String>)> = sqlx::query_as(queries::get(state.db.kind(), queries::SHARE_INVITATION_FIND_TOKEN))
.bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
let row: Option<(String, String, i64, String, String, Option<String>)> = sqlx::query_as(
queries::get(state.db.kind(), queries::SHARE_INVITATION_FIND_TOKEN),
)
.bind(&token_hash)
.fetch_optional(state.db.pool())
.await
.map_err(AuthError::database)?;
let (kind, slug, user_id, permission, expires_at, accepted_at) = row.ok_or_else(|| {
AuthError::bad_request("The sharing invitation is invalid or has expired.")
})?;
@@ -670,10 +778,27 @@ pub async fn accept_share_invitation(
));
}
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER))
.bind(&kind).bind(&slug).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_INSERT))
.bind(&kind).bind(&slug).bind(user_id).bind(&permission).execute(&mut *tx).await.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_PERMISSION_DELETE_USER,
))
.bind(&kind)
.bind(&slug)
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_PERMISSION_INSERT,
))
.bind(&kind)
.bind(&slug)
.bind(user_id)
.bind(&permission)
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::SHARE_INVITATION_ACCEPT,
@@ -702,10 +827,26 @@ pub async fn remove_resource_user(
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
let email = normalize(&req.email);
if let Some(user) = find_user_by_email(&state, &email).await? {
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER))
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_DELETE_USER))
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_PERMISSION_DELETE_USER,
))
.bind(&req.kind)
.bind(req.slug.trim())
.bind(user.id)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::SHARE_INVITATION_DELETE_USER,
))
.bind(&req.kind)
.bind(req.slug.trim())
.bind(user.id)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
}
Ok(Json(serde_json::json!({"ok":true})))
}
@@ -723,12 +864,33 @@ pub async fn resource_sharing(
.get("slug")
.ok_or_else(|| AuthError::bad_request("Missing slug."))?;
ensure_owner(&state, owner.id, kind, slug).await?;
let users: Vec<(String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), queries::RESOURCE_SHARING_USERS))
.bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
let links: Vec<(String,String,Option<String>,String)> = sqlx::query_as(queries::get(state.db.kind(), queries::RESOURCE_SHARING_LINKS))
.bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
let pending: Vec<(String,String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), queries::RESOURCE_SHARING_PENDING))
.bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
let users: Vec<(String, String, String)> = sqlx::query_as(queries::get(
state.db.kind(),
queries::RESOURCE_SHARING_USERS,
))
.bind(kind)
.bind(slug)
.fetch_all(state.db.pool())
.await
.map_err(AuthError::database)?;
let links: Vec<(String, String, Option<String>, String)> = sqlx::query_as(queries::get(
state.db.kind(),
queries::RESOURCE_SHARING_LINKS,
))
.bind(kind)
.bind(slug)
.fetch_all(state.db.pool())
.await
.map_err(AuthError::database)?;
let pending: Vec<(String, String, String, String)> = sqlx::query_as(queries::get(
state.db.kind(),
queries::RESOURCE_SHARING_PENDING,
))
.bind(kind)
.bind(slug)
.fetch_all(state.db.pool())
.await
.map_err(AuthError::database)?;
Ok(Json(
serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::<Vec<_>>(), "pending":pending.into_iter().map(|(email,nickname,permission,expires_at)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission,"expires_at":expires_at})).collect::<Vec<_>>(), "links":links.into_iter().map(|(token,permission,expires_at,created_at)|serde_json::json!({"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::<Vec<_>>() }),
))
@@ -746,7 +908,15 @@ pub async fn create_share_link(
let token = random_token();
let token_hash = hash_token(&token);
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT))
.bind(token_hash).bind(&req.kind).bind(req.slug.trim()).bind(permission).bind(&req.expires_at).bind(owner.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
.bind(token_hash)
.bind(&req.kind)
.bind(req.slug.trim())
.bind(permission)
.bind(&req.expires_at)
.bind(owner.id)
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
let base = if req.kind == "workspace" {
format!("/w/{}", req.slug.trim())
} else {
@@ -767,7 +937,14 @@ pub async fn update_share_link(
let permission = validate_permission(&req.permission)?;
validate_share_expiration(req.expires_at.as_deref())?;
let result = sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_UPDATE))
.bind(permission).bind(&req.expires_at).bind(req.token.trim()).bind(&req.kind).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
.bind(permission)
.bind(&req.expires_at)
.bind(req.token.trim())
.bind(&req.kind)
.bind(req.slug.trim())
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
if result.rows_affected() == 0 {
return Err(AuthError::bad_request(
"Share link was not found or is already revoked.",
@@ -786,7 +963,13 @@ pub async fn revoke_share_link(
let owner = require_user(&state, &headers).await?;
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_REVOKE))
.bind(Utc::now().to_rfc3339()).bind(req.token.trim()).bind(&req.kind).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
.bind(Utc::now().to_rfc3339())
.bind(req.token.trim())
.bind(&req.kind)
.bind(req.slug.trim())
.execute(state.db.pool())
.await
.map_err(AuthError::database)?;
Ok(Json(serde_json::json!({"ok":true})))
}
@@ -810,6 +993,21 @@ fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> {
Ok(())
}
pub async fn is_resource_owner(
state: &SharedState,
kind: &str,
slug: &str,
token: Option<&str>,
) -> Result<bool, AuthError> {
let Some(token) = token.filter(|v| !v.is_empty()) else {
return Ok(false);
};
let Some(user) = user_from_token(state, token).await? else {
return Ok(false);
};
Ok(ensure_owner(state, user.id, kind, slug).await.is_ok())
}
pub async fn resource_permission(
state: &SharedState,
kind: &str,
@@ -824,13 +1022,30 @@ pub async fn resource_permission(
if owns {
return Ok(Some("rw".into()));
}
let permission: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_BY_USER))
.bind(kind).bind(slug).bind(user.id).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
let permission: Option<String> = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::RESOURCE_PERMISSION_BY_USER,
))
.bind(kind)
.bind(slug)
.bind(user.id)
.fetch_optional(state.db.pool())
.await
.map_err(AuthError::database)?;
return Ok(permission);
}
let now = Utc::now().to_rfc3339();
let permission: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::SHARE_LINK_PERMISSION))
.bind(hash_token(token)).bind(kind).bind(slug).bind(now).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
let permission: Option<String> = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::SHARE_LINK_PERMISSION,
))
.bind(hash_token(token))
.bind(kind)
.bind(slug)
.bind(now)
.fetch_optional(state.db.pool())
.await
.map_err(AuthError::database)?;
Ok(permission)
}
@@ -866,12 +1081,13 @@ pub async fn request_reset(
if let Some(user) = find_user_by_email(&state, &email).await? {
let token = random_token();
let expires = (Utc::now() + Duration::minutes(30)).to_rfc3339();
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
sqlx::query(queries::get(
state.db.kind(),
queries::AUTH_DELETE_RESET_TOKENS_BY_USER,
))
.bind(user.id)
.execute(state.db.pool())
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
sqlx::query(queries::get(
@@ -881,9 +1097,10 @@ pub async fn request_reset(
.bind(hash_token(&token))
.bind(user.id)
.bind(expires)
.execute(state.db.pool())
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
tx.commit().await.map_err(AuthError::database)?;
send_reset(smtp, &user, &token).await?;
info!(user_id = user.id, "password reset e-mail sent");
} else {
@@ -929,6 +1146,21 @@ pub async fn confirm_reset(
}
let password_hash = hash_password(&req.password)?;
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
let consumed = sqlx::query(queries::get(
state.db.kind(),
queries::AUTH_CONSUME_RESET_TOKEN,
))
.bind(&now)
.bind(&token_hash)
.bind(&now)
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
if consumed.rows_affected() != 1 {
return Err(AuthError::bad_request(
"The reset link is invalid or has expired.",
));
}
let updated = sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_PASSWORD))
.bind(password_hash)
.bind(&now)
@@ -941,10 +1173,9 @@ pub async fn confirm_reset(
}
sqlx::query(queries::get(
state.db.kind(),
queries::AUTH_MARK_RESET_TOKEN_USED,
queries::AUTH_DELETE_RESET_TOKENS_BY_USER,
))
.bind(&now)
.bind(&token_hash)
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
+33
View File
@@ -902,3 +902,36 @@ pub async fn delete_note_file(
.await?;
Ok(())
}
pub async fn find_pad_file(
pool: &Database,
pad_id: i64,
file_id: i64,
) -> Result<Option<NoteFile>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::Q046)
.bind(file_id)
.bind(pad_id)
.fetch_optional(pool.pool())
.await?
.map(NoteFile::from));
}
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q046))
.bind(file_id)
.bind(pad_id)
.fetch_optional(pool.pool())
.await
}
pub async fn delete_pad_file(
pool: &Database,
pad_id: i64,
file_id: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q047))
.bind(file_id)
.bind(pad_id)
.execute(pool.pool())
.await?;
Ok(())
}
+6 -4
View File
@@ -28,8 +28,8 @@ pub const AUTH_FIND_CONFIRMATION_TOKEN: &str =
"SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = ?";
pub const AUTH_CONFIRM_USER: &str =
"UPDATE users SET confirmed_at = ?, updated_at = ? WHERE id = ?";
pub const AUTH_MARK_CONFIRMATION_TOKEN_USED: &str =
"UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ?";
pub const AUTH_CONSUME_CONFIRMATION_TOKEN: &str =
"UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL AND expires_at > ?";
pub const AUTH_DELETE_RESET_TOKENS_BY_USER: &str =
"DELETE FROM password_reset_tokens WHERE user_id = ?";
pub const AUTH_INSERT_RESET_TOKEN: &str =
@@ -38,8 +38,8 @@ pub const AUTH_FIND_RESET_TOKEN: &str =
"SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?";
pub const AUTH_UPDATE_PASSWORD: &str =
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?";
pub const AUTH_MARK_RESET_TOKEN_USED: &str =
"UPDATE password_reset_tokens SET used_at = ? WHERE token = ?";
pub const AUTH_CONSUME_RESET_TOKEN: &str =
"UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL AND expires_at > ?";
pub const AUTH_DELETE_SESSIONS_BY_USER: &str = "DELETE FROM user_sessions WHERE user_id = ?";
pub const AUTH_USER_BY_SESSION: &str = "SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?";
pub const AUTH_INSERT_SESSION: &str =
@@ -148,6 +148,8 @@ pub const Q036: &str = "SELECT id, filename, url, mime_type, size_bytes, created
pub const Q037: &str = "UPDATE pad_files SET is_attached = ?, detached_at = ? WHERE id = ?";
pub const Q038: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE id = ? AND note_id = ?";
pub const Q039: &str = "DELETE FROM note_files WHERE id = ? AND note_id = ?";
pub const Q046: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM pad_files WHERE id = ? AND pad_id = ?";
pub const Q047: &str = "DELETE FROM pad_files WHERE id = ? AND pad_id = ?";
static POSTGRES_QUERIES: OnceLock<Mutex<HashMap<&'static str, &'static str>>> = OnceLock::new();
+29 -8
View File
@@ -36,6 +36,12 @@ pub struct PresenceUser {
pub color: Option<String>,
}
#[derive(Debug, Clone)]
struct PresenceConnection {
identity: String,
user: PresenceUser,
}
#[derive(Debug, Clone)]
pub enum RoomEvent {
Document(NoteUpdate),
@@ -58,7 +64,7 @@ pub struct AppState {
pub anonymous_access_token_ttl_days: i64,
pub user_session_ttl_days: i64,
channels: RwLock<HashMap<String, broadcast::Sender<RoomEvent>>>,
presence: RwLock<HashMap<String, HashMap<u64, PresenceUser>>>,
presence: RwLock<HashMap<String, HashMap<u64, PresenceConnection>>>,
next_connection_id: AtomicU64,
}
@@ -127,15 +133,20 @@ impl AppState {
key: &str,
nickname: String,
color: Option<String>,
identity: Option<String>,
) -> (u64, Vec<PresenceUser>) {
let id = self.next_connection_id.fetch_add(1, Ordering::Relaxed);
let identity = identity.unwrap_or_else(|| format!("connection:{id}"));
let mut presence = self.presence.write().await;
let room = presence.entry(key.to_owned()).or_default();
room.insert(
id,
PresenceUser {
name: nickname,
color,
PresenceConnection {
identity,
user: PresenceUser {
name: nickname,
color,
},
},
);
(id, sorted_users(room))
@@ -148,8 +159,12 @@ impl AppState {
) -> Vec<PresenceUser> {
let mut presence = self.presence.write().await;
if let Some(room) = presence.get_mut(key) {
if let Some(user) = room.get_mut(&id) {
user.color = color;
if let Some(identity) = room.get(&id).map(|connection| connection.identity.clone()) {
for connection in room.values_mut() {
if connection.identity == identity {
connection.user.color = color.clone();
}
}
}
sorted_users(room)
} else {
@@ -172,8 +187,14 @@ impl AppState {
}
}
fn sorted_users(room: &HashMap<u64, PresenceUser>) -> Vec<PresenceUser> {
let mut users: Vec<PresenceUser> = room.values().cloned().collect();
fn sorted_users(room: &HashMap<u64, PresenceConnection>) -> Vec<PresenceUser> {
let mut by_identity: HashMap<&str, PresenceUser> = HashMap::new();
for connection in room.values() {
by_identity
.entry(&connection.identity)
.or_insert_with(|| connection.user.clone());
}
let mut users: Vec<PresenceUser> = by_identity.into_values().collect();
users.sort_by_key(|value| value.name.to_lowercase());
users
}
+23
View File
@@ -216,6 +216,29 @@ impl From<std::io::Error> for StorageError {
}
}
pub async fn delete_url_file(
storage: &Storage,
kind: &str,
owner_id: i64,
url: &str,
) -> Result<(), StorageError> {
let parts: Vec<&str> = url.trim_start_matches('/').split('/').collect();
if parts.len() != 3 || parts[0] != "f" {
return Ok(());
}
let filename = sanitize_storage_filename(parts[2]);
storage
.delete(&object_key(kind, owner_id, parts[1], &filename))
.await
}
fn sanitize_storage_filename(value: &str) -> String {
value
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
.collect::<String>()
}
pub fn object_key(kind: &str, owner_id: i64, token: &str, filename: &str) -> String {
format!("{kind}/{owner_id}_{token}/{filename}")
}
+18 -2
View File
@@ -132,6 +132,14 @@ async fn handle_socket(
return;
}
};
let presence_identity = match session_token.as_deref() {
Some(token) => auth::user_from_token(&state, token)
.await
.ok()
.flatten()
.map(|user| format!("user:{}", user.id)),
None => None,
};
let permission = auth::resource_permission(
&state,
"workspace",
@@ -176,7 +184,7 @@ async fn handle_socket(
let mut updates = channel.subscribe();
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
let (connection_id, users) = state
.join_room(&room_key, display_name.clone(), color)
.join_room(&room_key, display_name.clone(), color, presence_identity)
.await;
let _ = channel.send(RoomEvent::Presence(users));
let mut last_chat = Instant::now() - Duration::from_secs(1);
@@ -358,6 +366,14 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
return;
}
};
let presence_identity = match session_token.as_deref() {
Some(token) => auth::user_from_token(&state, token)
.await
.ok()
.flatten()
.map(|user| format!("user:{}", user.id)),
None => None,
};
let permission = auth::resource_permission(
&state,
"pad",
@@ -409,7 +425,7 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
let mut updates = channel.subscribe();
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
let (connection_id, users) = state
.join_room(&room_key, display_name.clone(), color)
.join_room(&room_key, display_name.clone(), color, presence_identity)
.await;
let _ = channel.send(RoomEvent::Presence(users));
let mut last_chat = Instant::now() - Duration::from_secs(1);