fixes
This commit is contained in:
Generated
+1
-1
@@ -2433,7 +2433,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.0.17"
|
version = "0.0.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.0.17"
|
version = "0.0.19"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.94"
|
rust-version = "1.94"
|
||||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||||
|
|||||||
+122
-36
@@ -129,6 +129,7 @@ pub struct NoteInfo {
|
|||||||
allow_public_task_updates: bool,
|
allow_public_task_updates: bool,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
|
can_delete_files: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_workspace(
|
pub async fn create_workspace(
|
||||||
@@ -258,6 +259,7 @@ pub async fn create_note(
|
|||||||
|
|
||||||
pub async fn note_info(
|
pub async fn note_info(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||||
) -> Result<Json<NoteInfo>, ApiError> {
|
) -> Result<Json<NoteInfo>, ApiError> {
|
||||||
let workspace = db::find_workspace(&state.db, &workspace_slug)
|
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?,
|
allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?,
|
||||||
created_at: db::normalize_timestamp(¬e.created_at),
|
created_at: db::normalize_timestamp(¬e.created_at),
|
||||||
updated_at: db::normalize_timestamp(¬e.updated_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,
|
allow_public_task_updates: bool,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
updated_at: String,
|
updated_at: String,
|
||||||
|
can_delete_files: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_pad(
|
pub async fn create_pad(
|
||||||
@@ -521,6 +545,7 @@ pub async fn create_pad(
|
|||||||
|
|
||||||
pub async fn pad_info(
|
pub async fn pad_info(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Path(slug): Path<String>,
|
Path(slug): Path<String>,
|
||||||
) -> Result<Json<PadInfo>, ApiError> {
|
) -> Result<Json<PadInfo>, ApiError> {
|
||||||
let pad = db::find_pad(&state.db, &slug)
|
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?,
|
allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?,
|
||||||
created_at: db::normalize_timestamp(&pad.created_at),
|
created_at: db::normalize_timestamp(&pad.created_at),
|
||||||
updated_at: db::normalize_timestamp(&pad.updated_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))
|
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(
|
pub async fn upload_note_file(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
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",
|
"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?;
|
db::delete_note(&state.db, note.id).await?;
|
||||||
Ok(Json(serde_json::json!({"ok": true})))
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
}
|
}
|
||||||
@@ -956,6 +1023,7 @@ pub async fn note_files(
|
|||||||
|
|
||||||
pub async fn delete_note_file(
|
pub async fn delete_note_file(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Path((workspace_slug, note_slug, file_id)): Path<(String, String, i64)>,
|
Path((workspace_slug, note_slug, file_id)): Path<(String, String, i64)>,
|
||||||
Json(payload): Json<PasswordRequest>,
|
Json(payload): Json<PasswordRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
@@ -967,32 +1035,35 @@ pub async fn delete_note_file(
|
|||||||
payload.access_token.as_deref(),
|
payload.access_token.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if workspace.password_hash.is_none()
|
let workspace_owner = crate::auth::is_resource_owner(
|
||||||
|| payload.password.as_deref().unwrap_or_default().is_empty()
|
&state,
|
||||||
{
|
"workspace",
|
||||||
return Err(ApiError::unauthorized());
|
&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)
|
let file = db::find_note_file(&state.db, note.id, file_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(ApiError::not_found_file)?;
|
.ok_or_else(ApiError::not_found_file)?;
|
||||||
let relative = file
|
crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url)
|
||||||
.url
|
.await
|
||||||
.trim_start_matches('/')
|
.map_err(|_| ApiError::internal("Failed to delete the file"))?;
|
||||||
.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"))?;
|
|
||||||
}
|
|
||||||
db::delete_note_file(&state.db, note.id, file_id).await?;
|
db::delete_note_file(&state.db, note.id, file_id).await?;
|
||||||
Ok(Json(serde_json::json!({"ok": true})))
|
Ok(Json(serde_json::json!({"ok": true})))
|
||||||
}
|
}
|
||||||
@@ -1066,6 +1137,15 @@ async fn serve_token_file(
|
|||||||
Ok(response)
|
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 {
|
fn sanitize_filename(value: &str) -> String {
|
||||||
let name = std::path::Path::new(value)
|
let name = std::path::Path::new(value)
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -1132,13 +1212,16 @@ pub async fn create_resource_access_token(
|
|||||||
let token = hex::encode(bytes);
|
let token = hex::encode(bytes);
|
||||||
let expires_at =
|
let expires_at =
|
||||||
(Utc::now() + Duration::days(state.anonymous_access_token_ttl_days)).to_rfc3339();
|
(Utc::now() + Duration::days(state.anonymous_access_token_ttl_days)).to_rfc3339();
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_INSERT))
|
sqlx::query(queries::get(
|
||||||
.bind(hash_access_token(&token))
|
state.db.kind(),
|
||||||
.bind(kind)
|
queries::RESOURCE_ACCESS_TOKENS_INSERT,
|
||||||
.bind(slug)
|
))
|
||||||
.bind(&expires_at)
|
.bind(hash_access_token(&token))
|
||||||
.execute(state.db.pool())
|
.bind(kind)
|
||||||
.await?;
|
.bind(slug)
|
||||||
|
.bind(&expires_at)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await?;
|
||||||
Ok(Json(AccessTokenResponse {
|
Ok(Json(AccessTokenResponse {
|
||||||
access_token: token,
|
access_token: token,
|
||||||
expires_at,
|
expires_at,
|
||||||
@@ -1161,13 +1244,16 @@ pub async fn verify_resource_access_token(
|
|||||||
{
|
{
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT))
|
let count: i64 = sqlx::query_scalar(queries::get(
|
||||||
.bind(hash_access_token(token))
|
state.db.kind(),
|
||||||
.bind(kind)
|
queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT,
|
||||||
.bind(slug)
|
))
|
||||||
.bind(Utc::now().to_rfc3339())
|
.bind(hash_access_token(token))
|
||||||
.fetch_one(state.db.pool())
|
.bind(kind)
|
||||||
.await?;
|
.bind(slug)
|
||||||
|
.bind(Utc::now().to_rfc3339())
|
||||||
|
.fetch_one(state.db.pool())
|
||||||
|
.await?;
|
||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ pub fn router(
|
|||||||
"/api/pads/{slug}/files",
|
"/api/pads/{slug}/files",
|
||||||
post(api::upload_pad_file).put(api::pad_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", post(api::create_workspace))
|
||||||
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
|
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
+275
-44
@@ -240,6 +240,15 @@ pub async fn register(
|
|||||||
let token = random_confirmation_token();
|
let token = random_confirmation_token();
|
||||||
if state.account_confirmation_required {
|
if state.account_confirmation_required {
|
||||||
let expires = (Utc::now() + Duration::hours(24)).to_rfc3339();
|
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(
|
sqlx::query(queries::get(
|
||||||
state.db.kind(),
|
state.db.kind(),
|
||||||
queries::AUTH_INSERT_CONFIRMATION_TOKEN,
|
queries::AUTH_INSERT_CONFIRMATION_TOKEN,
|
||||||
@@ -247,9 +256,10 @@ pub async fn register(
|
|||||||
.bind(hash_token(&token))
|
.bind(hash_token(&token))
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.bind(expires)
|
.bind(expires)
|
||||||
.execute(state.db.pool())
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(AuthError::database)?;
|
.map_err(AuthError::database)?;
|
||||||
|
tx.commit().await.map_err(AuthError::database)?;
|
||||||
confirmation_token = Some(token.as_str());
|
confirmation_token = Some(token.as_str());
|
||||||
}
|
}
|
||||||
if let Err(error) =
|
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)?;
|
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(&now)
|
.bind(&now)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(AuthError::database)?;
|
.map_err(AuthError::database)?;
|
||||||
|
if confirmed.rows_affected() != 1 {
|
||||||
|
return Err(AuthError::internal("The account could not be confirmed."));
|
||||||
|
}
|
||||||
sqlx::query(queries::get(
|
sqlx::query(queries::get(
|
||||||
state.db.kind(),
|
state.db.kind(),
|
||||||
queries::AUTH_MARK_CONFIRMATION_TOKEN_USED,
|
queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER,
|
||||||
))
|
))
|
||||||
.bind(&now)
|
.bind(user_id)
|
||||||
.bind(&token_hash)
|
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(AuthError::database)?;
|
.map_err(AuthError::database)?;
|
||||||
@@ -484,8 +511,48 @@ pub async fn delete_resource(
|
|||||||
let user = require_user(&state, &headers).await?;
|
let user = require_user(&state, &headers).await?;
|
||||||
ensure_owner(&state, user.id, &req.kind, &req.slug).await?;
|
ensure_owner(&state, user.id, &req.kind, &req.slug).await?;
|
||||||
let query = match req.kind.as_str() {
|
let query = match req.kind.as_str() {
|
||||||
"workspace" => queries::USER_DELETE_WORKSPACE,
|
"workspace" => {
|
||||||
"pad" => queries::USER_DELETE_PAD,
|
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.")),
|
_ => return Err(AuthError::bad_request("Unknown resource type.")),
|
||||||
};
|
};
|
||||||
sqlx::query(queries::get(
|
sqlx::query(queries::get(
|
||||||
@@ -602,18 +669,45 @@ pub async fn share_resource_users(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER))
|
sqlx::query(queries::get(
|
||||||
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
state.db.kind(),
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_DELETE_USER))
|
queries::RESOURCE_PERMISSION_DELETE_USER,
|
||||||
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
))
|
||||||
|
.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 {
|
if state.share_confirmation_required {
|
||||||
let token = random_token();
|
let token = random_token();
|
||||||
let token_hash = hash_token(&token);
|
let token_hash = hash_token(&token);
|
||||||
let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339();
|
let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339();
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_INSERT))
|
sqlx::query(queries::get(
|
||||||
.bind(&token_hash).bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).bind(owner.id).bind(&expires_at)
|
state.db.kind(),
|
||||||
.execute(state.db.pool()).await.map_err(AuthError::database)?;
|
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(
|
if let Err(error) = send_share_invitation(
|
||||||
state.smtp.as_ref().unwrap(),
|
state.smtp.as_ref().unwrap(),
|
||||||
&owner,
|
&owner,
|
||||||
@@ -635,8 +729,17 @@ pub async fn share_resource_users(
|
|||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_INSERT))
|
sqlx::query(queries::get(
|
||||||
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
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() {
|
if !missing.is_empty() {
|
||||||
@@ -655,8 +758,13 @@ pub async fn accept_share_invitation(
|
|||||||
AxumPath(token): AxumPath<String>,
|
AxumPath(token): AxumPath<String>,
|
||||||
) -> Result<Redirect, AuthError> {
|
) -> Result<Redirect, AuthError> {
|
||||||
let token_hash = hash_token(token.trim());
|
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))
|
let row: Option<(String, String, i64, String, String, Option<String>)> = sqlx::query_as(
|
||||||
.bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
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(|| {
|
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.")
|
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)?;
|
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER))
|
sqlx::query(queries::get(
|
||||||
.bind(&kind).bind(&slug).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
|
state.db.kind(),
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_INSERT))
|
queries::RESOURCE_PERMISSION_DELETE_USER,
|
||||||
.bind(&kind).bind(&slug).bind(user_id).bind(&permission).execute(&mut *tx).await.map_err(AuthError::database)?;
|
))
|
||||||
|
.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(
|
sqlx::query(queries::get(
|
||||||
state.db.kind(),
|
state.db.kind(),
|
||||||
queries::SHARE_INVITATION_ACCEPT,
|
queries::SHARE_INVITATION_ACCEPT,
|
||||||
@@ -702,10 +827,26 @@ pub async fn remove_resource_user(
|
|||||||
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
||||||
let email = normalize(&req.email);
|
let email = normalize(&req.email);
|
||||||
if let Some(user) = find_user_by_email(&state, &email).await? {
|
if let Some(user) = find_user_by_email(&state, &email).await? {
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER))
|
sqlx::query(queries::get(
|
||||||
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
state.db.kind(),
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_DELETE_USER))
|
queries::RESOURCE_PERMISSION_DELETE_USER,
|
||||||
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
))
|
||||||
|
.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})))
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
}
|
}
|
||||||
@@ -723,12 +864,33 @@ pub async fn resource_sharing(
|
|||||||
.get("slug")
|
.get("slug")
|
||||||
.ok_or_else(|| AuthError::bad_request("Missing slug."))?;
|
.ok_or_else(|| AuthError::bad_request("Missing slug."))?;
|
||||||
ensure_owner(&state, owner.id, kind, slug).await?;
|
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))
|
let users: Vec<(String, String, String)> = sqlx::query_as(queries::get(
|
||||||
.bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
|
state.db.kind(),
|
||||||
let links: Vec<(String,String,Option<String>,String)> = sqlx::query_as(queries::get(state.db.kind(), queries::RESOURCE_SHARING_LINKS))
|
queries::RESOURCE_SHARING_USERS,
|
||||||
.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(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
|
.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(
|
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<_>>() }),
|
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 = random_token();
|
||||||
let token_hash = hash_token(&token);
|
let token_hash = hash_token(&token);
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT))
|
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" {
|
let base = if req.kind == "workspace" {
|
||||||
format!("/w/{}", req.slug.trim())
|
format!("/w/{}", req.slug.trim())
|
||||||
} else {
|
} else {
|
||||||
@@ -767,7 +937,14 @@ pub async fn update_share_link(
|
|||||||
let permission = validate_permission(&req.permission)?;
|
let permission = validate_permission(&req.permission)?;
|
||||||
validate_share_expiration(req.expires_at.as_deref())?;
|
validate_share_expiration(req.expires_at.as_deref())?;
|
||||||
let result = sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_UPDATE))
|
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 {
|
if result.rows_affected() == 0 {
|
||||||
return Err(AuthError::bad_request(
|
return Err(AuthError::bad_request(
|
||||||
"Share link was not found or is already revoked.",
|
"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?;
|
let owner = require_user(&state, &headers).await?;
|
||||||
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_REVOKE))
|
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})))
|
Ok(Json(serde_json::json!({"ok":true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,6 +993,21 @@ fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> {
|
|||||||
Ok(())
|
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(
|
pub async fn resource_permission(
|
||||||
state: &SharedState,
|
state: &SharedState,
|
||||||
kind: &str,
|
kind: &str,
|
||||||
@@ -824,13 +1022,30 @@ pub async fn resource_permission(
|
|||||||
if owns {
|
if owns {
|
||||||
return Ok(Some("rw".into()));
|
return Ok(Some("rw".into()));
|
||||||
}
|
}
|
||||||
let permission: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_BY_USER))
|
let permission: Option<String> = sqlx::query_scalar(queries::get(
|
||||||
.bind(kind).bind(slug).bind(user.id).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
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);
|
return Ok(permission);
|
||||||
}
|
}
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
let permission: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::SHARE_LINK_PERMISSION))
|
let permission: Option<String> = sqlx::query_scalar(queries::get(
|
||||||
.bind(hash_token(token)).bind(kind).bind(slug).bind(now).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
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)
|
Ok(permission)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,12 +1081,13 @@ pub async fn request_reset(
|
|||||||
if let Some(user) = find_user_by_email(&state, &email).await? {
|
if let Some(user) = find_user_by_email(&state, &email).await? {
|
||||||
let token = random_token();
|
let token = random_token();
|
||||||
let expires = (Utc::now() + Duration::minutes(30)).to_rfc3339();
|
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(
|
sqlx::query(queries::get(
|
||||||
state.db.kind(),
|
state.db.kind(),
|
||||||
queries::AUTH_DELETE_RESET_TOKENS_BY_USER,
|
queries::AUTH_DELETE_RESET_TOKENS_BY_USER,
|
||||||
))
|
))
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.execute(state.db.pool())
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(AuthError::database)?;
|
.map_err(AuthError::database)?;
|
||||||
sqlx::query(queries::get(
|
sqlx::query(queries::get(
|
||||||
@@ -881,9 +1097,10 @@ pub async fn request_reset(
|
|||||||
.bind(hash_token(&token))
|
.bind(hash_token(&token))
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.bind(expires)
|
.bind(expires)
|
||||||
.execute(state.db.pool())
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(AuthError::database)?;
|
.map_err(AuthError::database)?;
|
||||||
|
tx.commit().await.map_err(AuthError::database)?;
|
||||||
send_reset(smtp, &user, &token).await?;
|
send_reset(smtp, &user, &token).await?;
|
||||||
info!(user_id = user.id, "password reset e-mail sent");
|
info!(user_id = user.id, "password reset e-mail sent");
|
||||||
} else {
|
} else {
|
||||||
@@ -929,6 +1146,21 @@ pub async fn confirm_reset(
|
|||||||
}
|
}
|
||||||
let password_hash = hash_password(&req.password)?;
|
let password_hash = hash_password(&req.password)?;
|
||||||
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
|
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))
|
let updated = sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_PASSWORD))
|
||||||
.bind(password_hash)
|
.bind(password_hash)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
@@ -941,10 +1173,9 @@ pub async fn confirm_reset(
|
|||||||
}
|
}
|
||||||
sqlx::query(queries::get(
|
sqlx::query(queries::get(
|
||||||
state.db.kind(),
|
state.db.kind(),
|
||||||
queries::AUTH_MARK_RESET_TOKEN_USED,
|
queries::AUTH_DELETE_RESET_TOKENS_BY_USER,
|
||||||
))
|
))
|
||||||
.bind(&now)
|
.bind(user_id)
|
||||||
.bind(&token_hash)
|
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(AuthError::database)?;
|
.map_err(AuthError::database)?;
|
||||||
|
|||||||
@@ -902,3 +902,36 @@ pub async fn delete_note_file(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
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 = ?";
|
"SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = ?";
|
||||||
pub const AUTH_CONFIRM_USER: &str =
|
pub const AUTH_CONFIRM_USER: &str =
|
||||||
"UPDATE users SET confirmed_at = ?, updated_at = ? WHERE id = ?";
|
"UPDATE users SET confirmed_at = ?, updated_at = ? WHERE id = ?";
|
||||||
pub const AUTH_MARK_CONFIRMATION_TOKEN_USED: &str =
|
pub const AUTH_CONSUME_CONFIRMATION_TOKEN: &str =
|
||||||
"UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ?";
|
"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 =
|
pub const AUTH_DELETE_RESET_TOKENS_BY_USER: &str =
|
||||||
"DELETE FROM password_reset_tokens WHERE user_id = ?";
|
"DELETE FROM password_reset_tokens WHERE user_id = ?";
|
||||||
pub const AUTH_INSERT_RESET_TOKEN: &str =
|
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 = ?";
|
"SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?";
|
||||||
pub const AUTH_UPDATE_PASSWORD: &str =
|
pub const AUTH_UPDATE_PASSWORD: &str =
|
||||||
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?";
|
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?";
|
||||||
pub const AUTH_MARK_RESET_TOKEN_USED: &str =
|
pub const AUTH_CONSUME_RESET_TOKEN: &str =
|
||||||
"UPDATE password_reset_tokens SET used_at = ? WHERE token = ?";
|
"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_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_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 =
|
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 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 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 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();
|
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>,
|
pub color: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct PresenceConnection {
|
||||||
|
identity: String,
|
||||||
|
user: PresenceUser,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum RoomEvent {
|
pub enum RoomEvent {
|
||||||
Document(NoteUpdate),
|
Document(NoteUpdate),
|
||||||
@@ -58,7 +64,7 @@ pub struct AppState {
|
|||||||
pub anonymous_access_token_ttl_days: i64,
|
pub anonymous_access_token_ttl_days: i64,
|
||||||
pub user_session_ttl_days: i64,
|
pub user_session_ttl_days: i64,
|
||||||
channels: RwLock<HashMap<String, broadcast::Sender<RoomEvent>>>,
|
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,
|
next_connection_id: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,15 +133,20 @@ impl AppState {
|
|||||||
key: &str,
|
key: &str,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
color: Option<String>,
|
color: Option<String>,
|
||||||
|
identity: Option<String>,
|
||||||
) -> (u64, Vec<PresenceUser>) {
|
) -> (u64, Vec<PresenceUser>) {
|
||||||
let id = self.next_connection_id.fetch_add(1, Ordering::Relaxed);
|
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 mut presence = self.presence.write().await;
|
||||||
let room = presence.entry(key.to_owned()).or_default();
|
let room = presence.entry(key.to_owned()).or_default();
|
||||||
room.insert(
|
room.insert(
|
||||||
id,
|
id,
|
||||||
PresenceUser {
|
PresenceConnection {
|
||||||
name: nickname,
|
identity,
|
||||||
color,
|
user: PresenceUser {
|
||||||
|
name: nickname,
|
||||||
|
color,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
(id, sorted_users(room))
|
(id, sorted_users(room))
|
||||||
@@ -148,8 +159,12 @@ impl AppState {
|
|||||||
) -> Vec<PresenceUser> {
|
) -> Vec<PresenceUser> {
|
||||||
let mut presence = self.presence.write().await;
|
let mut presence = self.presence.write().await;
|
||||||
if let Some(room) = presence.get_mut(key) {
|
if let Some(room) = presence.get_mut(key) {
|
||||||
if let Some(user) = room.get_mut(&id) {
|
if let Some(identity) = room.get(&id).map(|connection| connection.identity.clone()) {
|
||||||
user.color = color;
|
for connection in room.values_mut() {
|
||||||
|
if connection.identity == identity {
|
||||||
|
connection.user.color = color.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sorted_users(room)
|
sorted_users(room)
|
||||||
} else {
|
} else {
|
||||||
@@ -172,8 +187,14 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sorted_users(room: &HashMap<u64, PresenceUser>) -> Vec<PresenceUser> {
|
fn sorted_users(room: &HashMap<u64, PresenceConnection>) -> Vec<PresenceUser> {
|
||||||
let mut users: Vec<PresenceUser> = room.values().cloned().collect();
|
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.sort_by_key(|value| value.name.to_lowercase());
|
||||||
users
|
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 {
|
pub fn object_key(kind: &str, owner_id: i64, token: &str, filename: &str) -> String {
|
||||||
format!("{kind}/{owner_id}_{token}/{filename}")
|
format!("{kind}/{owner_id}_{token}/{filename}")
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -132,6 +132,14 @@ async fn handle_socket(
|
|||||||
return;
|
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(
|
let permission = auth::resource_permission(
|
||||||
&state,
|
&state,
|
||||||
"workspace",
|
"workspace",
|
||||||
@@ -176,7 +184,7 @@ async fn handle_socket(
|
|||||||
let mut updates = channel.subscribe();
|
let mut updates = channel.subscribe();
|
||||||
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
|
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
|
||||||
let (connection_id, users) = state
|
let (connection_id, users) = state
|
||||||
.join_room(&room_key, display_name.clone(), color)
|
.join_room(&room_key, display_name.clone(), color, presence_identity)
|
||||||
.await;
|
.await;
|
||||||
let _ = channel.send(RoomEvent::Presence(users));
|
let _ = channel.send(RoomEvent::Presence(users));
|
||||||
let mut last_chat = Instant::now() - Duration::from_secs(1);
|
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;
|
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(
|
let permission = auth::resource_permission(
|
||||||
&state,
|
&state,
|
||||||
"pad",
|
"pad",
|
||||||
@@ -409,7 +425,7 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
|
|||||||
let mut updates = channel.subscribe();
|
let mut updates = channel.subscribe();
|
||||||
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
|
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
|
||||||
let (connection_id, users) = state
|
let (connection_id, users) = state
|
||||||
.join_room(&room_key, display_name.clone(), color)
|
.join_room(&room_key, display_name.clone(), color, presence_identity)
|
||||||
.await;
|
.await;
|
||||||
let _ = channel.send(RoomEvent::Presence(users));
|
let _ = channel.send(RoomEvent::Presence(users));
|
||||||
let mut last_chat = Instant::now() - Duration::from_secs(1);
|
let mut last_chat = Instant::now() - Duration::from_secs(1);
|
||||||
|
|||||||
+65
-32
@@ -2304,14 +2304,10 @@ dialog::backdrop {
|
|||||||
color: #b9c2cf;
|
color: #b9c2cf;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Task-list layout. */
|
/* Task-list layout. Keep the checkbox in the same marker gutter as a bullet. */
|
||||||
.markdown-body .task-list-item {
|
.markdown-body .task-list-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: block;
|
||||||
grid-template-columns: 1em minmax(0, 1fr);
|
|
||||||
grid-template-rows: auto auto;
|
|
||||||
column-gap: .45em;
|
|
||||||
align-items: start;
|
|
||||||
min-height: 1.32em;
|
min-height: 1.32em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -2320,12 +2316,11 @@ dialog::backdrop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body .task-checkbox {
|
.markdown-body .task-checkbox {
|
||||||
grid-column: 1;
|
position: absolute;
|
||||||
grid-row: 1;
|
top: 0;
|
||||||
align-self: start;
|
left: -1.45em;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
flex: 0 0 1em;
|
|
||||||
width: 1em;
|
width: 1em;
|
||||||
min-width: 1em;
|
min-width: 1em;
|
||||||
max-width: 1em;
|
max-width: 1em;
|
||||||
@@ -2358,9 +2353,7 @@ dialog::backdrop {
|
|||||||
box-shadow: 0 0 0 2px rgba(124, 104, 238, .3);
|
box-shadow: 0 0 0 2px rgba(124, 104, 238, .3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body .task-list-item > .list-item-content {
|
.markdown-body .task-list-item>.list-item-content {
|
||||||
grid-column: 2;
|
|
||||||
grid-row: 1;
|
|
||||||
display: block;
|
display: block;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -2368,10 +2361,8 @@ dialog::backdrop {
|
|||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body .task-list-item > ul,
|
.markdown-body .task-list-item>ul,
|
||||||
.markdown-body .task-list-item > ol {
|
.markdown-body .task-list-item>ol {
|
||||||
grid-column: 2;
|
|
||||||
grid-row: 2;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3764,6 +3755,7 @@ dialog::backdrop {
|
|||||||
border-radius: 0 0 14px 14px;
|
border-radius: 0 0 14px 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Markdown alerts. */
|
/* Markdown alerts. */
|
||||||
.markdown-body .markdown-alert {
|
.markdown-body .markdown-alert {
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
@@ -3772,15 +3764,40 @@ dialog::backdrop {
|
|||||||
border-left-width: 4px;
|
border-left-width: 4px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.markdown-body .markdown-alert > :first-child { margin-top: 0; }
|
|
||||||
.markdown-body .markdown-alert > :last-child { margin-bottom: 0; }
|
.markdown-body .markdown-alert> :first-child {
|
||||||
.markdown-body .markdown-alert--success { border-color: #2f855a; background: rgba(47,133,90,.14); }
|
margin-top: 0;
|
||||||
.markdown-body .markdown-alert--info { border-color: #3182ce; background: rgba(49,130,206,.14); }
|
}
|
||||||
.markdown-body .markdown-alert--warning { border-color: #d69e2e; background: rgba(214,158,46,.14); }
|
|
||||||
.markdown-body .markdown-alert--danger { border-color: #c53030; background: rgba(197,48,48,.14); }
|
.markdown-body .markdown-alert> :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .markdown-alert--success {
|
||||||
|
border-color: #2f855a;
|
||||||
|
background: rgba(47, 133, 90, .14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .markdown-alert--info {
|
||||||
|
border-color: #3182ce;
|
||||||
|
background: rgba(49, 130, 206, .14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .markdown-alert--warning {
|
||||||
|
border-color: #d69e2e;
|
||||||
|
background: rgba(214, 158, 46, .14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .markdown-alert--danger {
|
||||||
|
border-color: #c53030;
|
||||||
|
background: rgba(197, 48, 48, .14);
|
||||||
|
}
|
||||||
|
|
||||||
/* Fenced code line numbers for every language: ```lang=, ```lang=101, ```= or ```=101. */
|
/* Fenced code line numbers for every language: ```lang=, ```lang=101, ```= or ```=101. */
|
||||||
.markdown-body pre.code-with-lines code { counter-reset: none; }
|
.markdown-body pre.code-with-lines code {
|
||||||
|
counter-reset: none;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-body pre.code-with-lines .code-line {
|
.markdown-body pre.code-with-lines .code-line {
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 1.35em;
|
min-height: 1.35em;
|
||||||
@@ -3788,6 +3805,7 @@ dialog::backdrop {
|
|||||||
position: relative;
|
position: relative;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body pre.code-with-lines .code-line::before {
|
.markdown-body pre.code-with-lines .code-line::before {
|
||||||
content: attr(data-line);
|
content: attr(data-line);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -3808,13 +3826,29 @@ dialog::backdrop {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
.markdown-body .markdown-toc ol { margin: 0; padding-left: 1.4em; }
|
|
||||||
.markdown-body .markdown-toc li { margin: .25em 0; }
|
.markdown-body .markdown-toc ol {
|
||||||
.markdown-body .markdown-toc .toc-level-2 { margin-left: 1em; }
|
margin: 0;
|
||||||
.markdown-body .markdown-toc .toc-level-3 { margin-left: 2em; }
|
padding-left: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .markdown-toc li {
|
||||||
|
margin: .25em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .markdown-toc .toc-level-2 {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body .markdown-toc .toc-level-3 {
|
||||||
|
margin-left: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-body .markdown-toc .toc-level-4,
|
.markdown-body .markdown-toc .toc-level-4,
|
||||||
.markdown-body .markdown-toc .toc-level-5,
|
.markdown-body .markdown-toc .toc-level-5,
|
||||||
.markdown-body .markdown-toc .toc-level-6 { margin-left: 3em; }
|
.markdown-body .markdown-toc .toc-level-6 {
|
||||||
|
margin-left: 3em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Nested Markdown lists keep markers and source-line numbers in separate gutters. */
|
/* Nested Markdown lists keep markers and source-line numbers in separate gutters. */
|
||||||
.markdown-body ul,
|
.markdown-body ul,
|
||||||
@@ -3832,7 +3866,6 @@ dialog::backdrop {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body .contains-task-items > .task-list-item {
|
.markdown-body .contains-task-items>.task-list-item {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ function inline(value) {
|
|||||||
html = html.replace(/`([^`]+)`/g, (_, code) => stash(`<code>${code}</code>`));
|
html = html.replace(/`([^`]+)`/g, (_, code) => stash(`<code>${code}</code>`));
|
||||||
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
|
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
|
||||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
||||||
return stash(`<img src="${safeUrl(url)}" alt="${alt}" loading="lazy" decoding="async"${titleAttr}>`);
|
return stash(`<img src="${safeUrl(url)}" alt="${alt}" loading="lazy" decoding="async" draggable="false" contenteditable="false"${titleAttr}>`);
|
||||||
});
|
});
|
||||||
html = html.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => {
|
html = html.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => {
|
||||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
||||||
|
|||||||
+20
-5
@@ -79,6 +79,12 @@ function markdownFromPreview(node){
|
|||||||
if(tag==="sub")return `~${body}~`;
|
if(tag==="sub")return `~${body}~`;
|
||||||
if(tag==="sup"&&!current.classList.contains("footnote-ref"))return `^${body}^`;
|
if(tag==="sup"&&!current.classList.contains("footnote-ref"))return `^${body}^`;
|
||||||
if(tag==="a")return `[${body}](${current.getAttribute("href")||"#"})`;
|
if(tag==="a")return `[${body}](${current.getAttribute("href")||"#"})`;
|
||||||
|
if(tag==="img"){
|
||||||
|
const src=current.getAttribute("src")||"";
|
||||||
|
const alt=current.getAttribute("alt")||"";
|
||||||
|
const title=current.getAttribute("title");
|
||||||
|
return `}"`:""})`;
|
||||||
|
}
|
||||||
if(tag==="br")return " ";
|
if(tag==="br")return " ";
|
||||||
return body;
|
return body;
|
||||||
};
|
};
|
||||||
@@ -137,19 +143,20 @@ function applyUi({write=false,replace=false}={}){editorWorkspace.className=`work
|
|||||||
function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();}
|
function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();}
|
||||||
function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,accessToken,nickname,color:currentUserColor()||null,sessionToken:getAuthToken(),onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onPresence:updatePresence,onLatency:updateLatency,onChat:appendChatMessage,onError:m=>{document.querySelector("#password-error").textContent=m;if(/nickname|session|account/i.test(m)){if(!identityDialog.open)identityDialog.showModal();}else if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();}
|
function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,accessToken,nickname,color:currentUserColor()||null,sessionToken:getAuthToken(),onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onPresence:updatePresence,onLatency:updateLatency,onChat:appendChatMessage,onError:m=>{document.querySelector("#password-error").textContent=m;if(/nickname|session|account/i.test(m)){if(!identityDialog.open)identityDialog.showModal();}else if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();}
|
||||||
|
|
||||||
function formatBytes(bytes){const value=Number(bytes)||0;if(value<1024)return `${value} B`;if(value<1024*1024)return `${(value/1024).toFixed(1)} KB`;return `${(value/1024/1024).toFixed(1)} MB`;}
|
function formatBytes(bytes){const value=Math.max(0,Number(bytes)||0),units=["B","KB","MB","GB","TB"];let size=value,index=0;while(size>=1024&&index<units.length-1){size/=1024;index++;}return `${index===0?Math.round(size):size.toFixed(size>=10?1:2)} ${units[index]}`;}
|
||||||
async function loadFiles({open=false}={}){
|
async function loadFiles({open=false}={}){
|
||||||
try{
|
try{
|
||||||
const files=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"PUT",body:JSON.stringify({access_token:accessToken||null})});
|
const files=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"PUT",body:JSON.stringify({access_token:accessToken||null})});
|
||||||
document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"}`;
|
const totalSize=files.reduce((sum,file)=>sum+(Number(file.size_bytes)||0),0);
|
||||||
|
document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"} · ${formatBytes(totalSize)}`;
|
||||||
const list=document.querySelector("#files-list");
|
const list=document.querySelector("#files-list");
|
||||||
list.innerHTML=files.length?files.map(file=>`<div class="file-row" data-file-row="${file.id}"><div class="file-row-main"><div class="file-name">${escapeHtml(file.filename)}</div><div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · <span class="file-flag ${file.is_attached?"":"detached"}">${file.is_attached?"in note":"removed from content"}</span></div></div><div class="file-actions"><button data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button><button data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button>${info?.protected&&accessToken?`<button class="file-delete" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>`:""}</div><div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button data-copy-generated>Copy</button></div></div>`).join(""):'<p class="empty">No files uploaded.</p>';
|
list.innerHTML=files.length?files.map(file=>`<div class="file-row" data-file-row="${file.id}"><div class="file-row-main"><div class="file-name">${escapeHtml(file.filename)}</div><div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · <span class="file-flag ${file.is_attached?"":"detached"}">${file.is_attached?"in note":"removed from content"}</span></div></div><div class="file-actions"><button data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button><button data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button>${info?.can_delete_files?`<button class="file-delete" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>`:""}</div><div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button data-copy-generated>Copy</button></div></div>`).join(""):'<p class="empty">No files uploaded.</p>';
|
||||||
if(open&&!document.querySelector("#files-dialog").open)document.querySelector("#files-dialog").showModal();
|
if(open&&!document.querySelector("#files-dialog").open)document.querySelector("#files-dialog").showModal();
|
||||||
}catch(error){toast(error.message);}
|
}catch(error){toast(error.message);}
|
||||||
}
|
}
|
||||||
bindIdentityDialog({dialog:identityDialog,onIdentity:async value=>{nickname=value;identityDialog.close();updateCurrentUser();if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}});
|
bindIdentityDialog({dialog:identityDialog,onIdentity:async value=>{nickname=value;identityDialog.close();updateCurrentUser();if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}});
|
||||||
identityDialog.addEventListener("close",()=>{if(!nickname)queueMicrotask(()=>{if(!identityDialog.open)identityDialog.showModal();});});
|
identityDialog.addEventListener("close",()=>{if(!nickname)queueMicrotask(()=>{if(!identityDialog.open)identityDialog.showModal();});});
|
||||||
async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}updateCurrentUser();document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
|
async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`,{headers:getAuthToken()?{Authorization:`Bearer ${getAuthToken()}`}:{}});document.title=`${info.title} · ${info.workspace_title}`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}updateCurrentUser();document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
|
||||||
|
|
||||||
document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});previewLineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:preview-line-numbers",previewLineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();});
|
document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});previewLineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:preview-line-numbers",previewLineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();});
|
||||||
window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>{applyFormat(editor,b.dataset.format);b.closest("details")?.removeAttribute("open");}));bindFormatShortcuts(editor);document.querySelector("#shortcuts-button").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").showModal());document.querySelector("#close-shortcuts").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").close());preview.addEventListener("change",event=>{const checkbox=event.target.closest(".task-checkbox");if(!checkbox)return;const lineIndex=Number(checkbox.dataset.sourceLine)-1;const lines=editor.value.split("\n");if(lineIndex<0||lineIndex>=lines.length)return;lines[lineIndex]=lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/,`$1${checkbox.checked?"x":" "}$2`);editor.value=lines.join("\n");editor.dispatchEvent(new Event("input",{bubbles:true}));});preview.addEventListener("keydown",event=>{const target=event.target.closest(".preview-editable");if(!target)return;if(event.key==="Enter"){event.preventDefault();target.blur();return;}if(event.key==="ArrowUp"||event.key==="ArrowDown"){if(movePreviewCaret(target,event.key==="ArrowUp"?-1:1))event.preventDefault();}});preview.addEventListener("blur",event=>{const target=event.target.closest(".preview-editable");if(!target)return;const lineIndex=Number(target.dataset.sourceLine)-1;if(lineIndex<0)return;const lines=editor.value.split("\n");const value=markdownFromPreview(target);let next;if(target.dataset.tableCell!==undefined)next=replaceTableCell(lines[lineIndex],Number(target.dataset.tableCell),value);else{const prefix=target.dataset.sourcePrefix||"",suffix=target.dataset.sourceSuffix||"";next=prefix+value+suffix;}if(lines[lineIndex]===next)return;lines[lineIndex]=next;editor.value=lines.join("\n");editor.setSelectionRange(editor.value.length,editor.value.length);editor.dispatchEvent(new Event("input",{bubbles:true}));},{capture:true});
|
window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>{applyFormat(editor,b.dataset.format);b.closest("details")?.removeAttribute("open");}));bindFormatShortcuts(editor);document.querySelector("#shortcuts-button").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").showModal());document.querySelector("#close-shortcuts").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").close());preview.addEventListener("change",event=>{const checkbox=event.target.closest(".task-checkbox");if(!checkbox)return;const lineIndex=Number(checkbox.dataset.sourceLine)-1;const lines=editor.value.split("\n");if(lineIndex<0||lineIndex>=lines.length)return;lines[lineIndex]=lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/,`$1${checkbox.checked?"x":" "}$2`);editor.value=lines.join("\n");editor.dispatchEvent(new Event("input",{bubbles:true}));});preview.addEventListener("keydown",event=>{const target=event.target.closest(".preview-editable");if(!target)return;if(event.key==="Enter"){event.preventDefault();target.blur();return;}if(event.key==="ArrowUp"||event.key==="ArrowDown"){if(movePreviewCaret(target,event.key==="ArrowUp"?-1:1))event.preventDefault();}});preview.addEventListener("blur",event=>{const target=event.target.closest(".preview-editable");if(!target)return;const lineIndex=Number(target.dataset.sourceLine)-1;if(lineIndex<0)return;const lines=editor.value.split("\n");const value=markdownFromPreview(target);let next;if(target.dataset.tableCell!==undefined)next=replaceTableCell(lines[lineIndex],Number(target.dataset.tableCell),value);else{const prefix=target.dataset.sourcePrefix||"",suffix=target.dataset.sourceSuffix||"";next=prefix+value+suffix;}if(lines[lineIndex]===next)return;lines[lineIndex]=next;editor.value=lines.join("\n");editor.setSelectionRange(editor.value.length,editor.value.length);editor.dispatchEvent(new Event("input",{bubbles:true}));},{capture:true});
|
||||||
@@ -167,6 +174,14 @@ userColorPicker.addEventListener("input",()=>{
|
|||||||
socket?.setColor(userColorPicker.value);
|
socket?.setColor(userColorPicker.value);
|
||||||
if(socket)socket.update(editor.value,JSON.stringify(owners));
|
if(socket)socket.update(editor.value,JSON.stringify(owners));
|
||||||
});
|
});
|
||||||
|
window.addEventListener("storage",event=>{
|
||||||
|
if(event.key!==storedColorKey(nickname))return;
|
||||||
|
const replacement=currentOwner();
|
||||||
|
owners=owners.map(owner=>ownerName(owner)===nickname?replacement:owner);
|
||||||
|
updateCurrentUser();render();
|
||||||
|
socket?.setColor(currentUserColor()||null);
|
||||||
|
if(socket)socket.update(editor.value,JSON.stringify(owners));
|
||||||
|
});
|
||||||
editor.addEventListener("keydown",continueIndentation);editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.length<newLines)owners.push(currentOwner());owners=owners.slice(0,newLines);owners[cursorLine]=currentOwner();render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
|
editor.addEventListener("keydown",continueIndentation);editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.length<newLines)owners.push(currentOwner());owners=owners.slice(0,newLines);owners[cursorLine]=currentOwner();render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
|
||||||
document.querySelector("#password-form").addEventListener("submit",async e=>{e.preventDefault();try{password=document.querySelector("#open-password").value;const result=await api("/api/access-token",{method:"POST",body:JSON.stringify({kind:"workspace",slug:workspaceSlug,password})});accessToken=result.access_token;setAccessToken("workspace",workspaceSlug,accessToken);password="";document.querySelector("#open-password").value="";document.querySelector("#password-error").textContent="";loadFiles();connect();}catch(error){document.querySelector("#password-error").textContent=error.message;}});
|
document.querySelector("#password-form").addEventListener("submit",async e=>{e.preventDefault();try{password=document.querySelector("#open-password").value;const result=await api("/api/access-token",{method:"POST",body:JSON.stringify({kind:"workspace",slug:workspaceSlug,password})});accessToken=result.access_token;setAccessToken("workspace",workspaceSlug,accessToken);password="";document.querySelector("#open-password").value="";document.querySelector("#password-error").textContent="";loadFiles();connect();}catch(error){document.querySelector("#password-error").textContent=error.message;}});
|
||||||
const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='<p class="empty">Loading…</p>';try{const revisions=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`,{method:"POST",body:JSON.stringify({access_token:accessToken||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`;}).join(""):'<p class="empty">No history yet.</p>';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`<p class="error">${escapeHtml(e.message)}</p>`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");});
|
const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='<p class="empty">Loading…</p>';try{const revisions=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`,{method:"POST",body:JSON.stringify({access_token:accessToken||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`;}).join(""):'<p class="empty">No history yet.</p>';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`<p class="error">${escapeHtml(e.message)}</p>`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");});
|
||||||
@@ -189,7 +204,7 @@ document.querySelector("#files-list").addEventListener("click",async event=>{
|
|||||||
const deleteButton=event.target.closest("[data-delete-file]");
|
const deleteButton=event.target.closest("[data-delete-file]");
|
||||||
if(deleteButton){
|
if(deleteButton){
|
||||||
if(!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`,{title:"Delete file",confirmText:"Delete",danger:true}))return;
|
if(!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`,{title:"Delete file",confirmText:"Delete",danger:true}))return;
|
||||||
try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",body:JSON.stringify({access_token:accessToken||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return;
|
try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",headers:getAuthToken()?{Authorization:`Bearer ${getAuthToken()}`}:{},body:JSON.stringify({access_token:accessToken||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.querySelector("#delete-note").addEventListener("click",async()=>{if(!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`,{title:"Delete note",confirmText:"Delete",danger:true}))return;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`,{method:"DELETE",body:JSON.stringify({access_token:accessToken||null})});location.assign(`/w/${encodeURIComponent(workspaceSlug)}`);}catch(error){toast(error.message);}});
|
document.querySelector("#delete-note").addEventListener("click",async()=>{if(!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`,{title:"Delete note",confirmText:"Delete",danger:true}))return;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`,{method:"DELETE",body:JSON.stringify({access_token:accessToken||null})});location.assign(`/w/${encodeURIComponent(workspaceSlug)}`);}catch(error){toast(error.message);}});
|
||||||
|
|||||||
+16
-2
@@ -75,6 +75,12 @@ function markdownFromPreview(node){
|
|||||||
if(tag==="sub")return `~${body}~`;
|
if(tag==="sub")return `~${body}~`;
|
||||||
if(tag==="sup"&&!current.classList.contains("footnote-ref"))return `^${body}^`;
|
if(tag==="sup"&&!current.classList.contains("footnote-ref"))return `^${body}^`;
|
||||||
if(tag==="a")return `[${body}](${current.getAttribute("href")||"#"})`;
|
if(tag==="a")return `[${body}](${current.getAttribute("href")||"#"})`;
|
||||||
|
if(tag==="img"){
|
||||||
|
const src=current.getAttribute("src")||"";
|
||||||
|
const alt=current.getAttribute("alt")||"";
|
||||||
|
const title=current.getAttribute("title");
|
||||||
|
return `}"`:""})`;
|
||||||
|
}
|
||||||
if(tag==="br")return " ";
|
if(tag==="br")return " ";
|
||||||
return body;
|
return body;
|
||||||
};
|
};
|
||||||
@@ -122,6 +128,8 @@ function continueIndentation(event){
|
|||||||
editor.dispatchEvent(new Event("input",{bubbles:true}));
|
editor.dispatchEvent(new Event("input",{bubbles:true}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes){const value=Math.max(0,Number(bytes)||0),units=["B","KB","MB","GB","TB"];let size=value,index=0;while(size>=1024&&index<units.length-1){size/=1024;index++;}return `${index===0?Math.round(size):size.toFixed(size>=10?1:2)} ${units[index]}`;}
|
||||||
|
|
||||||
function replaceTableCell(line,index,value){
|
function replaceTableCell(line,index,value){
|
||||||
const leading=line.trimStart().startsWith("|"),trailing=line.trimEnd().endsWith("|");
|
const leading=line.trimStart().startsWith("|"),trailing=line.trimEnd().endsWith("|");
|
||||||
let body=line.trim();if(leading)body=body.slice(1);if(trailing)body=body.slice(0,-1);
|
let body=line.trim();if(leading)body=body.slice(1);if(trailing)body=body.slice(0,-1);
|
||||||
@@ -135,8 +143,9 @@ function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null
|
|||||||
async function loadFiles({open=false}={}){
|
async function loadFiles({open=false}={}){
|
||||||
try{
|
try{
|
||||||
const files=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"PUT",body:JSON.stringify({access_token:accessToken||null})});
|
const files=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"PUT",body:JSON.stringify({access_token:accessToken||null})});
|
||||||
document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"}`;
|
const totalSize=files.reduce((sum,file)=>sum+(Number(file.size_bytes)||0),0);
|
||||||
document.querySelector("#files-list").innerHTML=files.length?files.map(file=>`<div class="file-row" data-file-row="${file.id}"><div class="file-row-main"><div class="file-name">${escapeHtml(file.filename)}</div><div class="file-meta">${escapeHtml(file.mime_type)} · ${Math.max(1,Math.round(file.size_bytes/1024))} KB · ${formatDate(file.created_at)} · <span class="file-flag${file.is_attached?"":" detached"}">${file.is_attached?"in note":"removed from content"}</span></div></div><div class="file-actions"><button data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button><button data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button><button data-show-file-code="html" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">HTML</button></div><div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button data-copy-generated>Copy</button></div></div>`).join(""):'<p class="dialog-copy">No files uploaded.</p>';
|
document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"} · ${formatBytes(totalSize)}`;
|
||||||
|
document.querySelector("#files-list").innerHTML=files.length?files.map(file=>`<div class="file-row" data-file-row="${file.id}"><div class="file-row-main"><div class="file-name">${escapeHtml(file.filename)}</div><div class="file-meta">${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · ${formatDate(file.created_at)} · <span class="file-flag${file.is_attached?"":" detached"}">${file.is_attached?"in note":"removed from content"}</span></div></div><div class="file-actions"><button data-show-file-code="link" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Link</button><button data-show-file-code="markdown" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">Markdown</button><button data-show-file-code="html" data-url="${escapeHtml(file.url)}" data-name="${escapeHtml(file.filename)}" data-mime="${escapeHtml(file.mime_type)}">HTML</button>${info?.can_delete_files?`<button class="file-delete" data-delete-file="${file.id}" data-file-name="${escapeHtml(file.filename)}">Delete</button>`:""}</div><div class="file-code" hidden><textarea readonly aria-label="Generated file code"></textarea><button data-copy-generated>Copy</button></div></div>`).join(""):'<p class="dialog-copy">No files uploaded.</p>';
|
||||||
if(open)document.querySelector("#files-dialog").showModal();
|
if(open)document.querySelector("#files-dialog").showModal();
|
||||||
}catch(error){if(open)toast(error.message);}
|
}catch(error){if(open)toast(error.message);}
|
||||||
}
|
}
|
||||||
@@ -181,5 +190,10 @@ document.querySelector("#files-list").addEventListener("click",async event=>{
|
|||||||
}
|
}
|
||||||
const copyButton=event.target.closest("[data-copy-generated]");
|
const copyButton=event.target.closest("[data-copy-generated]");
|
||||||
if(copyButton){try{await copyText(copyButton.closest(".file-code").querySelector("textarea").value);toast("Copied");}catch(error){toast(error.message);}return;}
|
if(copyButton){try{await copyText(copyButton.closest(".file-code").querySelector("textarea").value);toast("Copied");}catch(error){toast(error.message);}return;}
|
||||||
|
const deleteButton=event.target.closest("[data-delete-file]");
|
||||||
|
if(deleteButton){
|
||||||
|
if(!confirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`))return;
|
||||||
|
try{await api(`/api/pads/${encodeURIComponent(slug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",headers:getAuthToken()?{Authorization:`Bearer ${getAuthToken()}`}:{},body:JSON.stringify({access_token:accessToken||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
initialize();
|
initialize();
|
||||||
|
|||||||
+9
-7
@@ -55,8 +55,8 @@
|
|||||||
code</button><button type="button" data-format="codeblock">Code block</button><button
|
code</button><button type="button" data-format="codeblock">Code block</button><button
|
||||||
type="button" data-format="codeblock-lines">Code block with line
|
type="button" data-format="codeblock-lines">Code block with line
|
||||||
numbers</button><button type="button" data-format="mermaid">Mermaid
|
numbers</button><button type="button" data-format="mermaid">Mermaid
|
||||||
diagram</button><button type="button" data-format="table">Table</button><button type="button"
|
diagram</button><button type="button" data-format="table">Table</button><button
|
||||||
data-format="footnote">Footnote</button><button type="button"
|
type="button" data-format="footnote">Footnote</button><button type="button"
|
||||||
data-format="definition">Definition</button><button type="button"
|
data-format="definition">Definition</button><button type="button"
|
||||||
data-format="highlight">Highlight</button><button type="button"
|
data-format="highlight">Highlight</button><button type="button"
|
||||||
data-format="subscript">Subscript</button><button type="button"
|
data-format="subscript">Subscript</button><button type="button"
|
||||||
@@ -78,9 +78,10 @@
|
|||||||
<option value="22">22</option>
|
<option value="22">22</option>
|
||||||
</select></label></div><button id="upload-button"
|
</select></label></div><button id="upload-button"
|
||||||
class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label
|
class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label
|
||||||
class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Editor lines</label><label
|
class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Editor
|
||||||
class="line-toggle"><input id="preview-line-numbers-toggle" type="checkbox"> Preview lines</label><label
|
lines</label><label class="line-toggle"><input id="preview-line-numbers-toggle" type="checkbox">
|
||||||
class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label>
|
Preview lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked>
|
||||||
|
Compact</label>
|
||||||
<div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active"
|
<div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active"
|
||||||
aria-pressed="true">Markdown</button>
|
aria-pressed="true">Markdown</button>
|
||||||
<div class="view-switch"><button data-view="edit">Edit</button><button data-view="split"
|
<div class="view-switch"><button data-view="edit">Edit</button><button data-view="split"
|
||||||
@@ -148,7 +149,8 @@
|
|||||||
<div class="shortcut-grid">
|
<div class="shortcut-grid">
|
||||||
<kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
|
<kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
|
||||||
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
|
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
|
||||||
list</span><kbd>Alt+1…4</kbd><span>Headings H1–H4</span></div>
|
list</span><kbd>Alt+1…4</kbd><span>Headings H1–H4</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="files-dialog" class="image-editor-dialog files-dialog">
|
<dialog id="files-dialog" class="image-editor-dialog files-dialog">
|
||||||
@@ -191,7 +193,7 @@
|
|||||||
<h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password"
|
<h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password"
|
||||||
required placeholder="Password">
|
required placeholder="Password">
|
||||||
<p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a
|
<p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a
|
||||||
id="back-workspace" class="dialog-link" href="/">Back</a>
|
class="dialog-link" href="/">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast"></div>
|
||||||
|
|||||||
+5
-4
@@ -54,8 +54,8 @@
|
|||||||
code</button><button type="button" data-format="codeblock">Code block</button><button
|
code</button><button type="button" data-format="codeblock">Code block</button><button
|
||||||
type="button" data-format="codeblock-lines">Code block with line
|
type="button" data-format="codeblock-lines">Code block with line
|
||||||
numbers</button><button type="button" data-format="mermaid">Mermaid
|
numbers</button><button type="button" data-format="mermaid">Mermaid
|
||||||
diagram</button><button type="button" data-format="table">Table</button><button type="button"
|
diagram</button><button type="button" data-format="table">Table</button><button
|
||||||
data-format="footnote">Footnote</button><button type="button"
|
type="button" data-format="footnote">Footnote</button><button type="button"
|
||||||
data-format="definition">Definition</button><button type="button"
|
data-format="definition">Definition</button><button type="button"
|
||||||
data-format="highlight">Highlight</button><button type="button"
|
data-format="highlight">Highlight</button><button type="button"
|
||||||
data-format="subscript">Subscript</button><button type="button"
|
data-format="subscript">Subscript</button><button type="button"
|
||||||
@@ -146,7 +146,8 @@
|
|||||||
<div class="shortcut-grid">
|
<div class="shortcut-grid">
|
||||||
<kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
|
<kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
|
||||||
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
|
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
|
||||||
list</span><kbd>Alt+1…4</kbd><span>Headings H1–H4</span></div>
|
list</span><kbd>Alt+1…4</kbd><span>Headings H1–H4</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<dialog id="files-dialog" class="image-editor-dialog files-dialog">
|
<dialog id="files-dialog" class="image-editor-dialog files-dialog">
|
||||||
@@ -189,7 +190,7 @@
|
|||||||
<h2>Protected note</h2><input id="open-password" type="password" autocomplete="current-password" required
|
<h2>Protected note</h2><input id="open-password" type="password" autocomplete="current-password" required
|
||||||
placeholder="Password">
|
placeholder="Password">
|
||||||
<p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a
|
<p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a
|
||||||
class="dialog-link" href="/">Back</a>
|
class="dialog-link" href="/">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast"></div>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
<dialog id="note-dialog">
|
<dialog id="note-dialog">
|
||||||
<form id="note-form" class="dialog-panel">
|
<form id="note-form" class="dialog-panel">
|
||||||
<h2>New note</h2><input id="note-name" maxlength="80" required placeholder="Note name"><label
|
<h2>New note</h2><input id="note-name" maxlength="80" required placeholder="Note name"><label
|
||||||
class="dialog-check"><input id="note-protect" type="checkbox" checked> Protect this note from
|
class="dialog-check"><input id="note-protect" type="checkbox"> Protect this note from
|
||||||
deletion</label>
|
deletion</label>
|
||||||
<p id="note-error" class="form-message error"></p>
|
<p id="note-error" class="form-message error"></p>
|
||||||
<div class="dialog-actions"><button type="button" id="cancel-note"
|
<div class="dialog-actions"><button type="button" id="cancel-note"
|
||||||
|
|||||||
Reference in New Issue
Block a user