fixes
This commit is contained in:
Generated
+1
-1
@@ -2433,7 +2433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.0.17"
|
||||
version = "0.0.19"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustpad"
|
||||
version = "0.0.17"
|
||||
version = "0.0.19"
|
||||
edition = "2024"
|
||||
rust-version = "1.94"
|
||||
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,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
can_delete_files: bool,
|
||||
}
|
||||
|
||||
pub async fn create_workspace(
|
||||
@@ -258,6 +259,7 @@ pub async fn create_note(
|
||||
|
||||
pub async fn note_info(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
) -> Result<Json<NoteInfo>, ApiError> {
|
||||
let workspace = db::find_workspace(&state.db, &workspace_slug)
|
||||
@@ -277,6 +279,27 @@ pub async fn note_info(
|
||||
allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?,
|
||||
created_at: db::normalize_timestamp(¬e.created_at),
|
||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||
can_delete_files: {
|
||||
let workspace_owner = crate::auth::is_resource_owner(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
bearer_token(&headers),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let note_owner = crate::auth::optional_user(&state, &headers)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|user| {
|
||||
note.created_by
|
||||
.as_deref()
|
||||
.map(|creator| creator == user.nickname)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
workspace_owner || note_owner
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -483,6 +506,7 @@ pub struct PadInfo {
|
||||
allow_public_task_updates: bool,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
can_delete_files: bool,
|
||||
}
|
||||
|
||||
pub async fn create_pad(
|
||||
@@ -521,6 +545,7 @@ pub async fn create_pad(
|
||||
|
||||
pub async fn pad_info(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<PadInfo>, ApiError> {
|
||||
let pad = db::find_pad(&state.db, &slug)
|
||||
@@ -533,6 +558,14 @@ pub async fn pad_info(
|
||||
allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?,
|
||||
created_at: db::normalize_timestamp(&pad.created_at),
|
||||
updated_at: db::normalize_timestamp(&pad.updated_at),
|
||||
can_delete_files: crate::auth::is_resource_owner(
|
||||
&state,
|
||||
"pad",
|
||||
&slug,
|
||||
bearer_token(&headers),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -817,6 +850,35 @@ pub async fn pad_files(
|
||||
Ok(Json(files))
|
||||
}
|
||||
|
||||
pub async fn delete_pad_file(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Path((slug, file_id)): Path<(String, i64)>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let pad = authorized_pad(
|
||||
&state,
|
||||
&slug,
|
||||
payload.password.as_deref(),
|
||||
payload.access_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
if !crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers))
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(ApiError::forbidden("Only the note owner can delete files"));
|
||||
}
|
||||
let file = db::find_pad_file(&state.db, pad.id, file_id)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_file)?;
|
||||
crate::storage::delete_url_file(&state.storage, "pads", pad.id, &file.url)
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("Failed to delete the file"))?;
|
||||
db::delete_pad_file(&state.db, pad.id, file_id).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
pub async fn upload_note_file(
|
||||
State(state): State<SharedState>,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
@@ -920,6 +982,11 @@ pub async fn delete_note(
|
||||
"This note is protected and cannot be deleted",
|
||||
));
|
||||
}
|
||||
for file in db::list_note_files(&state.db, note.id).await? {
|
||||
crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url)
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("Failed to delete note files"))?;
|
||||
}
|
||||
db::delete_note(&state.db, note.id).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
@@ -956,6 +1023,7 @@ pub async fn note_files(
|
||||
|
||||
pub async fn delete_note_file(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Path((workspace_slug, note_slug, file_id)): Path<(String, String, i64)>,
|
||||
Json(payload): Json<PasswordRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
@@ -967,32 +1035,35 @@ pub async fn delete_note_file(
|
||||
payload.access_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
if workspace.password_hash.is_none()
|
||||
|| payload.password.as_deref().unwrap_or_default().is_empty()
|
||||
{
|
||||
return Err(ApiError::unauthorized());
|
||||
let workspace_owner = crate::auth::is_resource_owner(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace.slug,
|
||||
bearer_token(&headers),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let note_owner = crate::auth::optional_user(&state, &headers)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|user| {
|
||||
note.created_by
|
||||
.as_deref()
|
||||
.map(|creator| creator == user.nickname)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !workspace_owner && !note_owner {
|
||||
return Err(ApiError::forbidden(
|
||||
"Only the note owner or workspace owner can delete files",
|
||||
));
|
||||
}
|
||||
let file = db::find_note_file(&state.db, note.id, file_id)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_file)?;
|
||||
let relative = file
|
||||
.url
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.collect::<Vec<_>>();
|
||||
if relative.len() == 3 && relative[0] == "f" {
|
||||
let key = crate::storage::object_key(
|
||||
"notes",
|
||||
note.id,
|
||||
relative[1],
|
||||
&sanitize_filename(relative[2]),
|
||||
);
|
||||
state
|
||||
.storage
|
||||
.delete(&key)
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("Failed to delete the file"))?;
|
||||
}
|
||||
crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url)
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("Failed to delete the file"))?;
|
||||
db::delete_note_file(&state.db, note.id, file_id).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
@@ -1066,6 +1137,15 @@ async fn serve_token_file(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn sanitize_filename(value: &str) -> String {
|
||||
let name = std::path::Path::new(value)
|
||||
.file_name()
|
||||
@@ -1132,13 +1212,16 @@ pub async fn create_resource_access_token(
|
||||
let token = hex::encode(bytes);
|
||||
let expires_at =
|
||||
(Utc::now() + Duration::days(state.anonymous_access_token_ttl_days)).to_rfc3339();
|
||||
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_INSERT))
|
||||
.bind(hash_access_token(&token))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.bind(&expires_at)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_ACCESS_TOKENS_INSERT,
|
||||
))
|
||||
.bind(hash_access_token(&token))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.bind(&expires_at)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(AccessTokenResponse {
|
||||
access_token: token,
|
||||
expires_at,
|
||||
@@ -1161,13 +1244,16 @@ pub async fn verify_resource_access_token(
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT))
|
||||
.bind(hash_access_token(token))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.fetch_one(state.db.pool())
|
||||
.await?;
|
||||
let count: i64 = sqlx::query_scalar(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT,
|
||||
))
|
||||
.bind(hash_access_token(token))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.fetch_one(state.db.pool())
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,10 @@ pub fn router(
|
||||
"/api/pads/{slug}/files",
|
||||
post(api::upload_pad_file).put(api::pad_files),
|
||||
)
|
||||
.route(
|
||||
"/api/pads/{slug}/files/{file_id}",
|
||||
axum::routing::delete(api::delete_pad_file),
|
||||
)
|
||||
.route("/api/workspaces", post(api::create_workspace))
|
||||
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
|
||||
.route(
|
||||
|
||||
+275
-44
@@ -240,6 +240,15 @@ pub async fn register(
|
||||
let token = random_confirmation_token();
|
||||
if state.account_confirmation_required {
|
||||
let expires = (Utc::now() + Duration::hours(24)).to_rfc3339();
|
||||
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER,
|
||||
))
|
||||
.bind(user.id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_INSERT_CONFIRMATION_TOKEN,
|
||||
@@ -247,9 +256,10 @@ pub async fn register(
|
||||
.bind(hash_token(&token))
|
||||
.bind(user.id)
|
||||
.bind(expires)
|
||||
.execute(state.db.pool())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
tx.commit().await.map_err(AuthError::database)?;
|
||||
confirmation_token = Some(token.as_str());
|
||||
}
|
||||
if let Err(error) =
|
||||
@@ -362,19 +372,36 @@ pub async fn confirm_account(
|
||||
));
|
||||
}
|
||||
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_CONFIRM_USER))
|
||||
let consumed = sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_CONSUME_CONFIRMATION_TOKEN,
|
||||
))
|
||||
.bind(&now)
|
||||
.bind(&token_hash)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
if consumed.rows_affected() != 1 {
|
||||
return Err(AuthError::bad_request(
|
||||
"The confirmation link is invalid or has expired.",
|
||||
));
|
||||
}
|
||||
let confirmed = sqlx::query(queries::get(state.db.kind(), queries::AUTH_CONFIRM_USER))
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
if confirmed.rows_affected() != 1 {
|
||||
return Err(AuthError::internal("The account could not be confirmed."));
|
||||
}
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_MARK_CONFIRMATION_TOKEN_USED,
|
||||
queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER,
|
||||
))
|
||||
.bind(&now)
|
||||
.bind(&token_hash)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
@@ -484,8 +511,48 @@ pub async fn delete_resource(
|
||||
let user = require_user(&state, &headers).await?;
|
||||
ensure_owner(&state, user.id, &req.kind, &req.slug).await?;
|
||||
let query = match req.kind.as_str() {
|
||||
"workspace" => queries::USER_DELETE_WORKSPACE,
|
||||
"pad" => queries::USER_DELETE_PAD,
|
||||
"workspace" => {
|
||||
if let Some(workspace) = crate::db::find_workspace(&state.db, req.slug.trim())
|
||||
.await
|
||||
.map_err(AuthError::database)?
|
||||
{
|
||||
let notes = crate::db::list_notes(&state.db, workspace.id)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
for note in notes {
|
||||
let files = crate::db::list_note_files(&state.db, note.id)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
for file in files {
|
||||
crate::storage::delete_url_file(
|
||||
&state.storage,
|
||||
"notes",
|
||||
note.id,
|
||||
&file.url,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| AuthError::internal("Failed to delete workspace files."))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
queries::USER_DELETE_WORKSPACE
|
||||
}
|
||||
"pad" => {
|
||||
if let Some(pad) = crate::db::find_pad(&state.db, req.slug.trim())
|
||||
.await
|
||||
.map_err(AuthError::database)?
|
||||
{
|
||||
let files = crate::db::list_pad_files(&state.db, pad.id)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
for file in files {
|
||||
crate::storage::delete_url_file(&state.storage, "pads", pad.id, &file.url)
|
||||
.await
|
||||
.map_err(|_| AuthError::internal("Failed to delete pad files."))?;
|
||||
}
|
||||
}
|
||||
queries::USER_DELETE_PAD
|
||||
}
|
||||
_ => return Err(AuthError::bad_request("Unknown resource type.")),
|
||||
};
|
||||
sqlx::query(queries::get(
|
||||
@@ -602,18 +669,45 @@ pub async fn share_resource_users(
|
||||
continue;
|
||||
}
|
||||
|
||||
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER))
|
||||
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_DELETE_USER))
|
||||
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_PERMISSION_DELETE_USER,
|
||||
))
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.bind(user.id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_INVITATION_DELETE_USER,
|
||||
))
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.bind(user.id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
|
||||
if state.share_confirmation_required {
|
||||
let token = random_token();
|
||||
let token_hash = hash_token(&token);
|
||||
let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339();
|
||||
sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_INSERT))
|
||||
.bind(&token_hash).bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).bind(owner.id).bind(&expires_at)
|
||||
.execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_INVITATION_INSERT,
|
||||
))
|
||||
.bind(&token_hash)
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.bind(user.id)
|
||||
.bind(permission)
|
||||
.bind(owner.id)
|
||||
.bind(&expires_at)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
if let Err(error) = send_share_invitation(
|
||||
state.smtp.as_ref().unwrap(),
|
||||
&owner,
|
||||
@@ -635,8 +729,17 @@ pub async fn share_resource_users(
|
||||
return Err(error);
|
||||
}
|
||||
} else {
|
||||
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_INSERT))
|
||||
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_PERMISSION_INSERT,
|
||||
))
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.bind(user.id)
|
||||
.bind(permission)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
}
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
@@ -655,8 +758,13 @@ pub async fn accept_share_invitation(
|
||||
AxumPath(token): AxumPath<String>,
|
||||
) -> Result<Redirect, AuthError> {
|
||||
let token_hash = hash_token(token.trim());
|
||||
let row: Option<(String, String, i64, String, String, Option<String>)> = sqlx::query_as(queries::get(state.db.kind(), queries::SHARE_INVITATION_FIND_TOKEN))
|
||||
.bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
let row: Option<(String, String, i64, String, String, Option<String>)> = sqlx::query_as(
|
||||
queries::get(state.db.kind(), queries::SHARE_INVITATION_FIND_TOKEN),
|
||||
)
|
||||
.bind(&token_hash)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
let (kind, slug, user_id, permission, expires_at, accepted_at) = row.ok_or_else(|| {
|
||||
AuthError::bad_request("The sharing invitation is invalid or has expired.")
|
||||
})?;
|
||||
@@ -670,10 +778,27 @@ pub async fn accept_share_invitation(
|
||||
));
|
||||
}
|
||||
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER))
|
||||
.bind(&kind).bind(&slug).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_INSERT))
|
||||
.bind(&kind).bind(&slug).bind(user_id).bind(&permission).execute(&mut *tx).await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_PERMISSION_DELETE_USER,
|
||||
))
|
||||
.bind(&kind)
|
||||
.bind(&slug)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_PERMISSION_INSERT,
|
||||
))
|
||||
.bind(&kind)
|
||||
.bind(&slug)
|
||||
.bind(user_id)
|
||||
.bind(&permission)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_INVITATION_ACCEPT,
|
||||
@@ -702,10 +827,26 @@ pub async fn remove_resource_user(
|
||||
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
||||
let email = normalize(&req.email);
|
||||
if let Some(user) = find_user_by_email(&state, &email).await? {
|
||||
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER))
|
||||
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_DELETE_USER))
|
||||
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_PERMISSION_DELETE_USER,
|
||||
))
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.bind(user.id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_INVITATION_DELETE_USER,
|
||||
))
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.bind(user.id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
}
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
@@ -723,12 +864,33 @@ pub async fn resource_sharing(
|
||||
.get("slug")
|
||||
.ok_or_else(|| AuthError::bad_request("Missing slug."))?;
|
||||
ensure_owner(&state, owner.id, kind, slug).await?;
|
||||
let users: Vec<(String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), queries::RESOURCE_SHARING_USERS))
|
||||
.bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
let links: Vec<(String,String,Option<String>,String)> = sqlx::query_as(queries::get(state.db.kind(), queries::RESOURCE_SHARING_LINKS))
|
||||
.bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
let pending: Vec<(String,String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), queries::RESOURCE_SHARING_PENDING))
|
||||
.bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
let users: Vec<(String, String, String)> = sqlx::query_as(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_SHARING_USERS,
|
||||
))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
let links: Vec<(String, String, Option<String>, String)> = sqlx::query_as(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_SHARING_LINKS,
|
||||
))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
let pending: Vec<(String, String, String, String)> = sqlx::query_as(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_SHARING_PENDING,
|
||||
))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
Ok(Json(
|
||||
serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::<Vec<_>>(), "pending":pending.into_iter().map(|(email,nickname,permission,expires_at)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission,"expires_at":expires_at})).collect::<Vec<_>>(), "links":links.into_iter().map(|(token,permission,expires_at,created_at)|serde_json::json!({"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::<Vec<_>>() }),
|
||||
))
|
||||
@@ -746,7 +908,15 @@ pub async fn create_share_link(
|
||||
let token = random_token();
|
||||
let token_hash = hash_token(&token);
|
||||
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT))
|
||||
.bind(token_hash).bind(&req.kind).bind(req.slug.trim()).bind(permission).bind(&req.expires_at).bind(owner.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
.bind(token_hash)
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.bind(permission)
|
||||
.bind(&req.expires_at)
|
||||
.bind(owner.id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
let base = if req.kind == "workspace" {
|
||||
format!("/w/{}", req.slug.trim())
|
||||
} else {
|
||||
@@ -767,7 +937,14 @@ pub async fn update_share_link(
|
||||
let permission = validate_permission(&req.permission)?;
|
||||
validate_share_expiration(req.expires_at.as_deref())?;
|
||||
let result = sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_UPDATE))
|
||||
.bind(permission).bind(&req.expires_at).bind(req.token.trim()).bind(&req.kind).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
.bind(permission)
|
||||
.bind(&req.expires_at)
|
||||
.bind(req.token.trim())
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AuthError::bad_request(
|
||||
"Share link was not found or is already revoked.",
|
||||
@@ -786,7 +963,13 @@ pub async fn revoke_share_link(
|
||||
let owner = require_user(&state, &headers).await?;
|
||||
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
||||
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_REVOKE))
|
||||
.bind(Utc::now().to_rfc3339()).bind(req.token.trim()).bind(&req.kind).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.bind(req.token.trim())
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
@@ -810,6 +993,21 @@ fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn is_resource_owner(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<bool, AuthError> {
|
||||
let Some(token) = token.filter(|v| !v.is_empty()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(user) = user_from_token(state, token).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(ensure_owner(state, user.id, kind, slug).await.is_ok())
|
||||
}
|
||||
|
||||
pub async fn resource_permission(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
@@ -824,13 +1022,30 @@ pub async fn resource_permission(
|
||||
if owns {
|
||||
return Ok(Some("rw".into()));
|
||||
}
|
||||
let permission: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_BY_USER))
|
||||
.bind(kind).bind(slug).bind(user.id).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
let permission: Option<String> = sqlx::query_scalar(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_PERMISSION_BY_USER,
|
||||
))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.bind(user.id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
return Ok(permission);
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let permission: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::SHARE_LINK_PERMISSION))
|
||||
.bind(hash_token(token)).bind(kind).bind(slug).bind(now).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
let permission: Option<String> = sqlx::query_scalar(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_LINK_PERMISSION,
|
||||
))
|
||||
.bind(hash_token(token))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.bind(now)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
Ok(permission)
|
||||
}
|
||||
|
||||
@@ -866,12 +1081,13 @@ pub async fn request_reset(
|
||||
if let Some(user) = find_user_by_email(&state, &email).await? {
|
||||
let token = random_token();
|
||||
let expires = (Utc::now() + Duration::minutes(30)).to_rfc3339();
|
||||
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_DELETE_RESET_TOKENS_BY_USER,
|
||||
))
|
||||
.bind(user.id)
|
||||
.execute(state.db.pool())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
@@ -881,9 +1097,10 @@ pub async fn request_reset(
|
||||
.bind(hash_token(&token))
|
||||
.bind(user.id)
|
||||
.bind(expires)
|
||||
.execute(state.db.pool())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
tx.commit().await.map_err(AuthError::database)?;
|
||||
send_reset(smtp, &user, &token).await?;
|
||||
info!(user_id = user.id, "password reset e-mail sent");
|
||||
} else {
|
||||
@@ -929,6 +1146,21 @@ pub async fn confirm_reset(
|
||||
}
|
||||
let password_hash = hash_password(&req.password)?;
|
||||
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
|
||||
let consumed = sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_CONSUME_RESET_TOKEN,
|
||||
))
|
||||
.bind(&now)
|
||||
.bind(&token_hash)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
if consumed.rows_affected() != 1 {
|
||||
return Err(AuthError::bad_request(
|
||||
"The reset link is invalid or has expired.",
|
||||
));
|
||||
}
|
||||
let updated = sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_PASSWORD))
|
||||
.bind(password_hash)
|
||||
.bind(&now)
|
||||
@@ -941,10 +1173,9 @@ pub async fn confirm_reset(
|
||||
}
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_MARK_RESET_TOKEN_USED,
|
||||
queries::AUTH_DELETE_RESET_TOKENS_BY_USER,
|
||||
))
|
||||
.bind(&now)
|
||||
.bind(&token_hash)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
|
||||
@@ -902,3 +902,36 @@ pub async fn delete_note_file(
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn find_pad_file(
|
||||
pool: &Database,
|
||||
pad_id: i64,
|
||||
file_id: i64,
|
||||
) -> Result<Option<NoteFile>, sqlx::Error> {
|
||||
if pool.kind() == DatabaseKind::Sqlite {
|
||||
return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::Q046)
|
||||
.bind(file_id)
|
||||
.bind(pad_id)
|
||||
.fetch_optional(pool.pool())
|
||||
.await?
|
||||
.map(NoteFile::from));
|
||||
}
|
||||
sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q046))
|
||||
.bind(file_id)
|
||||
.bind(pad_id)
|
||||
.fetch_optional(pool.pool())
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_pad_file(
|
||||
pool: &Database,
|
||||
pad_id: i64,
|
||||
file_id: i64,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(queries::get(pool.kind(), queries::Q047))
|
||||
.bind(file_id)
|
||||
.bind(pad_id)
|
||||
.execute(pool.pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+6
-4
@@ -28,8 +28,8 @@ pub const AUTH_FIND_CONFIRMATION_TOKEN: &str =
|
||||
"SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = ?";
|
||||
pub const AUTH_CONFIRM_USER: &str =
|
||||
"UPDATE users SET confirmed_at = ?, updated_at = ? WHERE id = ?";
|
||||
pub const AUTH_MARK_CONFIRMATION_TOKEN_USED: &str =
|
||||
"UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ?";
|
||||
pub const AUTH_CONSUME_CONFIRMATION_TOKEN: &str =
|
||||
"UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL AND expires_at > ?";
|
||||
pub const AUTH_DELETE_RESET_TOKENS_BY_USER: &str =
|
||||
"DELETE FROM password_reset_tokens WHERE user_id = ?";
|
||||
pub const AUTH_INSERT_RESET_TOKEN: &str =
|
||||
@@ -38,8 +38,8 @@ pub const AUTH_FIND_RESET_TOKEN: &str =
|
||||
"SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?";
|
||||
pub const AUTH_UPDATE_PASSWORD: &str =
|
||||
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?";
|
||||
pub const AUTH_MARK_RESET_TOKEN_USED: &str =
|
||||
"UPDATE password_reset_tokens SET used_at = ? WHERE token = ?";
|
||||
pub const AUTH_CONSUME_RESET_TOKEN: &str =
|
||||
"UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL AND expires_at > ?";
|
||||
pub const AUTH_DELETE_SESSIONS_BY_USER: &str = "DELETE FROM user_sessions WHERE user_id = ?";
|
||||
pub const AUTH_USER_BY_SESSION: &str = "SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?";
|
||||
pub const AUTH_INSERT_SESSION: &str =
|
||||
@@ -148,6 +148,8 @@ pub const Q036: &str = "SELECT id, filename, url, mime_type, size_bytes, created
|
||||
pub const Q037: &str = "UPDATE pad_files SET is_attached = ?, detached_at = ? WHERE id = ?";
|
||||
pub const Q038: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE id = ? AND note_id = ?";
|
||||
pub const Q039: &str = "DELETE FROM note_files WHERE id = ? AND note_id = ?";
|
||||
pub const Q046: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM pad_files WHERE id = ? AND pad_id = ?";
|
||||
pub const Q047: &str = "DELETE FROM pad_files WHERE id = ? AND pad_id = ?";
|
||||
|
||||
static POSTGRES_QUERIES: OnceLock<Mutex<HashMap<&'static str, &'static str>>> = OnceLock::new();
|
||||
|
||||
|
||||
+29
-8
@@ -36,6 +36,12 @@ pub struct PresenceUser {
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PresenceConnection {
|
||||
identity: String,
|
||||
user: PresenceUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RoomEvent {
|
||||
Document(NoteUpdate),
|
||||
@@ -58,7 +64,7 @@ pub struct AppState {
|
||||
pub anonymous_access_token_ttl_days: i64,
|
||||
pub user_session_ttl_days: i64,
|
||||
channels: RwLock<HashMap<String, broadcast::Sender<RoomEvent>>>,
|
||||
presence: RwLock<HashMap<String, HashMap<u64, PresenceUser>>>,
|
||||
presence: RwLock<HashMap<String, HashMap<u64, PresenceConnection>>>,
|
||||
next_connection_id: AtomicU64,
|
||||
}
|
||||
|
||||
@@ -127,15 +133,20 @@ impl AppState {
|
||||
key: &str,
|
||||
nickname: String,
|
||||
color: Option<String>,
|
||||
identity: Option<String>,
|
||||
) -> (u64, Vec<PresenceUser>) {
|
||||
let id = self.next_connection_id.fetch_add(1, Ordering::Relaxed);
|
||||
let identity = identity.unwrap_or_else(|| format!("connection:{id}"));
|
||||
let mut presence = self.presence.write().await;
|
||||
let room = presence.entry(key.to_owned()).or_default();
|
||||
room.insert(
|
||||
id,
|
||||
PresenceUser {
|
||||
name: nickname,
|
||||
color,
|
||||
PresenceConnection {
|
||||
identity,
|
||||
user: PresenceUser {
|
||||
name: nickname,
|
||||
color,
|
||||
},
|
||||
},
|
||||
);
|
||||
(id, sorted_users(room))
|
||||
@@ -148,8 +159,12 @@ impl AppState {
|
||||
) -> Vec<PresenceUser> {
|
||||
let mut presence = self.presence.write().await;
|
||||
if let Some(room) = presence.get_mut(key) {
|
||||
if let Some(user) = room.get_mut(&id) {
|
||||
user.color = color;
|
||||
if let Some(identity) = room.get(&id).map(|connection| connection.identity.clone()) {
|
||||
for connection in room.values_mut() {
|
||||
if connection.identity == identity {
|
||||
connection.user.color = color.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
sorted_users(room)
|
||||
} else {
|
||||
@@ -172,8 +187,14 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
fn sorted_users(room: &HashMap<u64, PresenceUser>) -> Vec<PresenceUser> {
|
||||
let mut users: Vec<PresenceUser> = room.values().cloned().collect();
|
||||
fn sorted_users(room: &HashMap<u64, PresenceConnection>) -> Vec<PresenceUser> {
|
||||
let mut by_identity: HashMap<&str, PresenceUser> = HashMap::new();
|
||||
for connection in room.values() {
|
||||
by_identity
|
||||
.entry(&connection.identity)
|
||||
.or_insert_with(|| connection.user.clone());
|
||||
}
|
||||
let mut users: Vec<PresenceUser> = by_identity.into_values().collect();
|
||||
users.sort_by_key(|value| value.name.to_lowercase());
|
||||
users
|
||||
}
|
||||
|
||||
@@ -216,6 +216,29 @@ impl From<std::io::Error> for StorageError {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_url_file(
|
||||
storage: &Storage,
|
||||
kind: &str,
|
||||
owner_id: i64,
|
||||
url: &str,
|
||||
) -> Result<(), StorageError> {
|
||||
let parts: Vec<&str> = url.trim_start_matches('/').split('/').collect();
|
||||
if parts.len() != 3 || parts[0] != "f" {
|
||||
return Ok(());
|
||||
}
|
||||
let filename = sanitize_storage_filename(parts[2]);
|
||||
storage
|
||||
.delete(&object_key(kind, owner_id, parts[1], &filename))
|
||||
.await
|
||||
}
|
||||
|
||||
fn sanitize_storage_filename(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
pub fn object_key(kind: &str, owner_id: i64, token: &str, filename: &str) -> String {
|
||||
format!("{kind}/{owner_id}_{token}/{filename}")
|
||||
}
|
||||
|
||||
+18
-2
@@ -132,6 +132,14 @@ async fn handle_socket(
|
||||
return;
|
||||
}
|
||||
};
|
||||
let presence_identity = match session_token.as_deref() {
|
||||
Some(token) => auth::user_from_token(&state, token)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|user| format!("user:{}", user.id)),
|
||||
None => None,
|
||||
};
|
||||
let permission = auth::resource_permission(
|
||||
&state,
|
||||
"workspace",
|
||||
@@ -176,7 +184,7 @@ async fn handle_socket(
|
||||
let mut updates = channel.subscribe();
|
||||
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
|
||||
let (connection_id, users) = state
|
||||
.join_room(&room_key, display_name.clone(), color)
|
||||
.join_room(&room_key, display_name.clone(), color, presence_identity)
|
||||
.await;
|
||||
let _ = channel.send(RoomEvent::Presence(users));
|
||||
let mut last_chat = Instant::now() - Duration::from_secs(1);
|
||||
@@ -358,6 +366,14 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
|
||||
return;
|
||||
}
|
||||
};
|
||||
let presence_identity = match session_token.as_deref() {
|
||||
Some(token) => auth::user_from_token(&state, token)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|user| format!("user:{}", user.id)),
|
||||
None => None,
|
||||
};
|
||||
let permission = auth::resource_permission(
|
||||
&state,
|
||||
"pad",
|
||||
@@ -409,7 +425,7 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
|
||||
let mut updates = channel.subscribe();
|
||||
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
|
||||
let (connection_id, users) = state
|
||||
.join_room(&room_key, display_name.clone(), color)
|
||||
.join_room(&room_key, display_name.clone(), color, presence_identity)
|
||||
.await;
|
||||
let _ = channel.send(RoomEvent::Presence(users));
|
||||
let mut last_chat = Instant::now() - Duration::from_secs(1);
|
||||
|
||||
+64
-31
@@ -2304,14 +2304,10 @@ dialog::backdrop {
|
||||
color: #b9c2cf;
|
||||
}
|
||||
|
||||
/* Task-list layout. */
|
||||
/* Task-list layout. Keep the checkbox in the same marker gutter as a bullet. */
|
||||
.markdown-body .task-list-item {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1em minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: .45em;
|
||||
align-items: start;
|
||||
display: block;
|
||||
min-height: 1.32em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -2320,12 +2316,11 @@ dialog::backdrop {
|
||||
}
|
||||
|
||||
.markdown-body .task-checkbox {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
align-self: start;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -1.45em;
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 1em;
|
||||
width: 1em;
|
||||
min-width: 1em;
|
||||
max-width: 1em;
|
||||
@@ -2358,9 +2353,7 @@ dialog::backdrop {
|
||||
box-shadow: 0 0 0 2px rgba(124, 104, 238, .3);
|
||||
}
|
||||
|
||||
.markdown-body .task-list-item > .list-item-content {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
.markdown-body .task-list-item>.list-item-content {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
@@ -2368,10 +2361,8 @@ dialog::backdrop {
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.markdown-body .task-list-item > ul,
|
||||
.markdown-body .task-list-item > ol {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
.markdown-body .task-list-item>ul,
|
||||
.markdown-body .task-list-item>ol {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -3764,6 +3755,7 @@ dialog::backdrop {
|
||||
border-radius: 0 0 14px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Markdown alerts. */
|
||||
.markdown-body .markdown-alert {
|
||||
margin: 1em 0;
|
||||
@@ -3772,15 +3764,40 @@ dialog::backdrop {
|
||||
border-left-width: 4px;
|
||||
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--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); }
|
||||
|
||||
.markdown-body .markdown-alert> :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.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. */
|
||||
.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 {
|
||||
display: block;
|
||||
min-height: 1.35em;
|
||||
@@ -3788,6 +3805,7 @@ dialog::backdrop {
|
||||
position: relative;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.markdown-body pre.code-with-lines .code-line::before {
|
||||
content: attr(data-line);
|
||||
position: absolute;
|
||||
@@ -3808,13 +3826,29 @@ dialog::backdrop {
|
||||
border-radius: 8px;
|
||||
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 .toc-level-2 { margin-left: 1em; }
|
||||
.markdown-body .markdown-toc .toc-level-3 { margin-left: 2em; }
|
||||
|
||||
.markdown-body .markdown-toc ol {
|
||||
margin: 0;
|
||||
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-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. */
|
||||
.markdown-body ul,
|
||||
@@ -3832,7 +3866,6 @@ dialog::backdrop {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.markdown-body .contains-task-items > .task-list-item {
|
||||
.markdown-body .contains-task-items>.task-list-item {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ function inline(value) {
|
||||
html = html.replace(/`([^`]+)`/g, (_, code) => stash(`<code>${code}</code>`));
|
||||
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, 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) => {
|
||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
||||
|
||||
+20
-5
@@ -79,6 +79,12 @@ function markdownFromPreview(node){
|
||||
if(tag==="sub")return `~${body}~`;
|
||||
if(tag==="sup"&&!current.classList.contains("footnote-ref"))return `^${body}^`;
|
||||
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 " ";
|
||||
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 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}={}){
|
||||
try{
|
||||
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");
|
||||
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();
|
||||
}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();}}});
|
||||
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();});
|
||||
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);
|
||||
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);});
|
||||
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");});
|
||||
@@ -189,7 +204,7 @@ document.querySelector("#files-list").addEventListener("click",async event=>{
|
||||
const deleteButton=event.target.closest("[data-delete-file]");
|
||||
if(deleteButton){
|
||||
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);}});
|
||||
|
||||
+16
-2
@@ -75,6 +75,12 @@ function markdownFromPreview(node){
|
||||
if(tag==="sub")return `~${body}~`;
|
||||
if(tag==="sup"&&!current.classList.contains("footnote-ref"))return `^${body}^`;
|
||||
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 " ";
|
||||
return body;
|
||||
};
|
||||
@@ -122,6 +128,8 @@ function continueIndentation(event){
|
||||
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){
|
||||
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);
|
||||
@@ -135,8 +143,9 @@ function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null
|
||||
async function loadFiles({open=false}={}){
|
||||
try{
|
||||
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"}`;
|
||||
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>';
|
||||
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)}`;
|
||||
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();
|
||||
}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]");
|
||||
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();
|
||||
|
||||
+9
-7
@@ -55,8 +55,8 @@
|
||||
code</button><button type="button" data-format="codeblock">Code block</button><button
|
||||
type="button" data-format="codeblock-lines">Code block with line
|
||||
numbers</button><button type="button" data-format="mermaid">Mermaid
|
||||
diagram</button><button type="button" data-format="table">Table</button><button type="button"
|
||||
data-format="footnote">Footnote</button><button type="button"
|
||||
diagram</button><button type="button" data-format="table">Table</button><button
|
||||
type="button" data-format="footnote">Footnote</button><button type="button"
|
||||
data-format="definition">Definition</button><button type="button"
|
||||
data-format="highlight">Highlight</button><button type="button"
|
||||
data-format="subscript">Subscript</button><button type="button"
|
||||
@@ -78,9 +78,10 @@
|
||||
<option value="22">22</option>
|
||||
</select></label></div><button id="upload-button"
|
||||
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="preview-line-numbers-toggle" type="checkbox"> Preview lines</label><label
|
||||
class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label>
|
||||
class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Editor
|
||||
lines</label><label class="line-toggle"><input id="preview-line-numbers-toggle" type="checkbox">
|
||||
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"
|
||||
aria-pressed="true">Markdown</button>
|
||||
<div class="view-switch"><button data-view="edit">Edit</button><button data-view="split"
|
||||
@@ -148,7 +149,8 @@
|
||||
<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
|
||||
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>
|
||||
</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"
|
||||
required placeholder="Password">
|
||||
<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>
|
||||
</dialog>
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
+5
-4
@@ -54,8 +54,8 @@
|
||||
code</button><button type="button" data-format="codeblock">Code block</button><button
|
||||
type="button" data-format="codeblock-lines">Code block with line
|
||||
numbers</button><button type="button" data-format="mermaid">Mermaid
|
||||
diagram</button><button type="button" data-format="table">Table</button><button type="button"
|
||||
data-format="footnote">Footnote</button><button type="button"
|
||||
diagram</button><button type="button" data-format="table">Table</button><button
|
||||
type="button" data-format="footnote">Footnote</button><button type="button"
|
||||
data-format="definition">Definition</button><button type="button"
|
||||
data-format="highlight">Highlight</button><button type="button"
|
||||
data-format="subscript">Subscript</button><button type="button"
|
||||
@@ -146,7 +146,8 @@
|
||||
<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
|
||||
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>
|
||||
</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
|
||||
placeholder="Password">
|
||||
<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>
|
||||
</dialog>
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<dialog id="note-dialog">
|
||||
<form id="note-form" class="dialog-panel">
|
||||
<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>
|
||||
<p id="note-error" class="form-message error"></p>
|
||||
<div class="dialog-actions"><button type="button" id="cancel-note"
|
||||
|
||||
Reference in New Issue
Block a user