fixes
This commit is contained in:
+122
-36
@@ -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(¬e.created_at),
|
||||
updated_at: db::normalize_timestamp(¬e.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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)?;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user