security upgrade
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustpad"
|
||||
version = "0.2.4"
|
||||
version = "0.2.5"
|
||||
edition = "2024"
|
||||
rust-version = "1.94"
|
||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# RustPad — analiza i lista poprawek
|
||||
|
||||
## Zrealizowane zmiany
|
||||
|
||||
### 1. Kontrola `Origin` dla WebSocketów
|
||||
|
||||
- Żądanie upgrade jest odrzucane kodem `403`, gdy brakuje nagłówka `Origin`, ma on wartość `null`, jest nieprawidłowy albo wskazuje inną domenę.
|
||||
- Porównywane są schemat i authority z rzeczywistym nagłówkiem `Host`.
|
||||
- `X-Forwarded-Host` nie jest używany do autoryzacji, aby nie dopuścić do obejścia przez sfałszowany nagłówek.
|
||||
- Gdy reverse proxy przekazuje `X-Forwarded-Proto`, schemat `Origin` musi być z nim zgodny.
|
||||
- Kontrola obejmuje WebSockety workspace i pojedynczej notatki.
|
||||
|
||||
### 2. Tokeny konta poza `localStorage`
|
||||
|
||||
- Token konta jest wydawany wyłącznie jako ciasteczko `__Host-rustpad_session` z flagami:
|
||||
- `HttpOnly`
|
||||
- `Secure`
|
||||
- `SameSite=Lax`
|
||||
- `Path=/`
|
||||
- Token konta nie jest zwracany w JSON, wysyłany w wiadomości WebSocket ani przyjmowany jako token konta z `Authorization`.
|
||||
- Frontend usuwa stare wartości `rustpad:auth-token` i `rustpad:access:*` zawierające sekrety.
|
||||
- `localStorage` zawiera jedynie niesekretne znaczniki stanu.
|
||||
- Token dostępu po poprawnym haśle zasobu również trafia do ciasteczka `HttpOnly; Secure; SameSite=Lax`.
|
||||
- Hasło strony publicznej nie jest już zapisywane w `sessionStorage`.
|
||||
|
||||
### 3. Limitowanie prób
|
||||
|
||||
Wprowadzono odpowiedzi `429 Too Many Requests` dla:
|
||||
|
||||
| Operacja | Limit szczegółowy | Limit klienta | Okno |
|
||||
|---|---:|---:|---:|
|
||||
| Logowanie | 5 prób na login | 30 prób | 15 min |
|
||||
| Żądanie resetu hasła | 3 próby na e-mail | 10 prób | 60 min |
|
||||
| Potwierdzenie resetu | 10 prób na token | 20 prób | 15 min |
|
||||
| Hasło workspace/notatki | 10 prób na zasób | 50 prób | 15 min |
|
||||
|
||||
- Limity haseł zasobów działają w endpointach HTTP, endpointach wydających token dostępu oraz w obu kanałach WebSocket.
|
||||
- Poprawna próba zeruje wyłącznie licznik konkretnego loginu, tokenu lub zasobu; nie zeruje limitu globalnego klienta.
|
||||
- Klucz klienta korzysta z poprawnego adresu IP przekazanego przez zaufane proxy, z bezpiecznym fallbackiem, gdy IP nie jest dostępne.
|
||||
|
||||
### 4. Bezpieczne serwowanie plików
|
||||
|
||||
- Obrazy rastrowe PNG, JPEG, GIF, WebP, AVIF, BMP i ICO nadal mogą być wyświetlane inline.
|
||||
- HTML, SVG, XML, PDF oraz wszystkie pozostałe typy są zwracane jako `application/octet-stream` z `Content-Disposition: attachment`.
|
||||
- Dodano `X-Content-Type-Options: nosniff` oraz restrykcyjny `Content-Security-Policy`.
|
||||
- Nazwa pliku w `Content-Disposition` jest sanityzowana.
|
||||
|
||||
### 5. Upload tylko dla zalogowanych kont
|
||||
|
||||
- Serwer wymaga ważnej sesji konta przed przyjęciem multipart uploadu do workspace lub notatki.
|
||||
- Gość korzystający z publicznego zasobu, hasła zasobu albo linku udostępnienia nie może wgrywać plików.
|
||||
- Frontend wcześniej blokuje przycisk i wyświetla komunikat, ale kontrola serwerowa pozostaje źródłem prawdy.
|
||||
|
||||
### 6. Widok mobilny
|
||||
|
||||
- Opcje edytora mobilnego używają tego samego stylu checkboxów co konfiguracja strony.
|
||||
- Usunięto zduplikowane i nieużywane reguły CSS pozostałe po poprzedniej implementacji.
|
||||
|
||||
## Kontrola wykonania
|
||||
|
||||
- Sprawdzenie składni wszystkich plików JavaScript: zaliczone.
|
||||
- Sprawdzenie pozostałości sekretów zapisywanych przez frontend: zaliczone.
|
||||
- Sprawdzenie obecności zabezpieczeń cookies, WebSocketów, uploadu, plików i odpowiedzi `429`: zaliczone.
|
||||
- Sprawdzenie nawiasów i zgodności zmienionych wywołań Rust: zaliczone statycznie.
|
||||
- Sprawdzenie końcowego diffu pod kątem białych znaków: zaliczone.
|
||||
- Kompilacja, `cargo fmt`, `cargo check` i testy Rust nie zostały uruchomione, ponieważ środowisko nie zawiera toolchainu Rust.
|
||||
|
||||
## Lista przed wdrożeniem
|
||||
|
||||
1. Wdrożyć wyłącznie przez HTTPS — ciasteczka z flagą `Secure` nie będą działać przez zwykły HTTP.
|
||||
2. Reverse proxy musi zachowywać prawidłowy `Host` oraz nadpisywać, a nie ufać nagłówkom klienta: `CF-Connecting-IP`, `X-Real-IP`, `X-Forwarded-For` i `X-Forwarded-Proto`.
|
||||
3. Przy wdrożeniu unieważnić istniejące sesje kont i istniejące tokeny dostępu do zasobów. Sekret wcześniej zapisany w przeglądarce mógł zostać skopiowany przed aktualizacją.
|
||||
4. Dla wielu instancji aplikacji przenieść limiter do współdzielonego magazynu, np. Redis. Obecny limiter działa w pamięci pojedynczego procesu i zeruje się po restarcie.
|
||||
5. W CI uruchomić co najmniej: `cargo fmt --check`, `cargo check`, `cargo test` oraz testy integracyjne logowania, uploadu, plików i WebSocketów za docelowym reverse proxy.
|
||||
6. Zweryfikować ustawienia domen i subdomen. Jeżeli niezaufana treść może działać na subdomenie tej samej witryny, warto dodatkowo wdrożyć tokeny CSRF dla operacji modyfikujących dane.
|
||||
@@ -18,16 +18,33 @@ pub struct AccessTokenRequest {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AccessTokenResponse {
|
||||
access_token: String,
|
||||
granted: bool,
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
pub async fn create_resource_access_token(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<AccessTokenRequest>,
|
||||
) -> Result<Json<AccessTokenResponse>, ApiError> {
|
||||
) -> Result<Response, ApiError> {
|
||||
let kind = payload.kind.trim();
|
||||
let slug = payload.slug.trim();
|
||||
let client_key = crate::security::client_key(&headers);
|
||||
let client_limit_key = format!("resource-password-client:{client_key}");
|
||||
let limit_key = format!("resource-password:{client_key}:{kind}:{slug}");
|
||||
let window = std::time::Duration::from_secs(15 * 60);
|
||||
state
|
||||
.check_rate_limit(client_limit_key, 50, window)
|
||||
.await
|
||||
.map_err(|seconds| ApiError::rate_limited(&format!(
|
||||
"Too many password attempts. Try again in {seconds} seconds."
|
||||
)))?;
|
||||
state
|
||||
.check_rate_limit(limit_key.clone(), 10, window)
|
||||
.await
|
||||
.map_err(|seconds| ApiError::rate_limited(&format!(
|
||||
"Too many password attempts. Try again in {seconds} seconds."
|
||||
)))?;
|
||||
match kind {
|
||||
"workspace" => {
|
||||
let workspace = db::find_workspace(&state.db, slug)
|
||||
@@ -63,10 +80,20 @@ pub async fn create_resource_access_token(
|
||||
.bind(&expires_at)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(AccessTokenResponse {
|
||||
access_token: token,
|
||||
state.clear_rate_limit(&limit_key).await;
|
||||
let cookie = crate::security::resource_cookie(
|
||||
kind,
|
||||
slug,
|
||||
&token,
|
||||
state.anonymous_access_token_ttl_days,
|
||||
);
|
||||
let mut response = Json(AccessTokenResponse {
|
||||
granted: true,
|
||||
expires_at,
|
||||
}))
|
||||
})
|
||||
.into_response();
|
||||
response.headers_mut().insert(header::SET_COOKIE, cookie);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn verify_resource_access_token(
|
||||
@@ -78,7 +105,7 @@ pub async fn verify_resource_access_token(
|
||||
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if crate::auth::resource_permission(state, kind, slug, Some(token))
|
||||
if crate::auth::share_link_permission(state, kind, slug, Some(token))
|
||||
.await
|
||||
.map_err(|error| ApiError::forbidden(&error.message))?
|
||||
.is_some()
|
||||
|
||||
@@ -25,6 +25,12 @@ impl ApiError {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
pub(crate) fn rate_limited(message: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::TOO_MANY_REQUESTS,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
pub(crate) fn payload_too_large(max_bytes: usize) -> Self {
|
||||
let max_mb = max_bytes / (1024 * 1024);
|
||||
Self {
|
||||
|
||||
+77
-12
@@ -16,6 +16,7 @@ pub async fn upload_pad_file(
|
||||
Path(slug): Path<String>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
require_authenticated_upload(&state, &headers).await?;
|
||||
let mut password: Option<String> = None;
|
||||
let mut access_token: Option<String> = None;
|
||||
let mut file: Option<(String, Vec<u8>)> = None;
|
||||
@@ -55,8 +56,9 @@ pub async fn upload_pad_file(
|
||||
&state,
|
||||
&slug,
|
||||
password.as_deref(),
|
||||
access_token.as_deref(),
|
||||
resource_request_token(&headers, "pad", &slug, access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
let level = if db::verify_pad_password(&pad, password.as_deref())
|
||||
@@ -68,7 +70,7 @@ pub async fn upload_pad_file(
|
||||
&state,
|
||||
"pad",
|
||||
&slug,
|
||||
access_token.as_deref(),
|
||||
resource_request_token(&headers, "pad", &slug, access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
)
|
||||
.await?
|
||||
@@ -144,8 +146,9 @@ pub async fn pad_files(
|
||||
&state,
|
||||
&slug,
|
||||
payload.password.as_deref(),
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
let mut files = db::list_pad_files(&state.db, pad.id).await?;
|
||||
@@ -175,8 +178,9 @@ pub async fn delete_pad_file(
|
||||
&state,
|
||||
&slug,
|
||||
payload.password.as_deref(),
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
if !crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers))
|
||||
@@ -201,6 +205,7 @@ pub async fn upload_note_file(
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
require_authenticated_upload(&state, &headers).await?;
|
||||
let mut password: Option<String> = None;
|
||||
let mut access_token: Option<String> = None;
|
||||
let mut file: Option<(String, Vec<u8>)> = None;
|
||||
@@ -241,8 +246,9 @@ pub async fn upload_note_file(
|
||||
&workspace_slug,
|
||||
¬e_slug,
|
||||
password.as_deref(),
|
||||
access_token.as_deref(),
|
||||
resource_request_token(&headers, "workspace", &workspace_slug, access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -255,7 +261,7 @@ pub async fn upload_note_file(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
access_token.as_deref(),
|
||||
resource_request_token(&headers, "workspace", &workspace_slug, access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
)
|
||||
.await?
|
||||
@@ -309,8 +315,9 @@ pub async fn delete_note(
|
||||
&workspace_slug,
|
||||
¬e_slug,
|
||||
payload.password.as_deref(),
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -323,7 +330,7 @@ pub async fn delete_note(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
)
|
||||
.await?
|
||||
@@ -360,8 +367,9 @@ pub async fn note_files(
|
||||
&workspace_slug,
|
||||
¬e_slug,
|
||||
payload.password.as_deref(),
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
let mut files = db::list_note_files(&state.db, note.id).await?;
|
||||
@@ -392,8 +400,9 @@ pub async fn delete_note_file(
|
||||
&workspace_slug,
|
||||
¬e_slug,
|
||||
payload.password.as_deref(),
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
let workspace_owner = crate::auth::is_resource_owner(
|
||||
@@ -429,6 +438,19 @@ pub async fn delete_note_file(
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn require_authenticated_upload(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<(), ApiError> {
|
||||
let user = crate::auth::optional_user(state, headers)
|
||||
.await
|
||||
.map_err(|error| ApiError::forbidden(&error.message))?;
|
||||
if user.is_none() {
|
||||
return Err(ApiError::forbidden("Log in to upload files."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn download_file(
|
||||
State(state): State<SharedState>,
|
||||
Path((token, filename)): Path<(String, String)>,
|
||||
@@ -476,13 +498,29 @@ async fn serve_token_file(
|
||||
.get_local_with_legacy(&key, &legacy_key)
|
||||
.await
|
||||
.map_err(|_| ApiError::not_found_file())?;
|
||||
let mime = mime_guess::from_path(&safe).first_or_octet_stream();
|
||||
let guessed_mime = mime_guess::from_path(&safe).first_or_octet_stream();
|
||||
let inline_image = is_safe_inline_image_mime(guessed_mime.essence_str());
|
||||
let served_mime = if inline_image {
|
||||
guessed_mime.as_ref()
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
};
|
||||
let disposition = if inline_image { "inline" } else { "attachment" };
|
||||
let mut response = bytes.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_str(mime.as_ref())
|
||||
HeaderValue::from_str(served_mime)
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
HeaderValue::from_str(&format!("{disposition}; filename=\"{safe}\""))
|
||||
.expect("sanitized attachment filename"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
HeaderName::from_static("content-security-policy"),
|
||||
HeaderValue::from_static("default-src 'none'; sandbox"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::X_CONTENT_TYPE_OPTIONS,
|
||||
HeaderValue::from_static("nosniff"),
|
||||
@@ -501,6 +539,19 @@ async fn serve_token_file(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn is_safe_inline_image_mime(value: &str) -> bool {
|
||||
matches!(
|
||||
value,
|
||||
"image/png"
|
||||
| "image/jpeg"
|
||||
| "image/gif"
|
||||
| "image/webp"
|
||||
| "image/avif"
|
||||
| "image/bmp"
|
||||
| "image/x-icon"
|
||||
)
|
||||
}
|
||||
|
||||
fn sanitize_filename(value: &str) -> String {
|
||||
let name = std::path::Path::new(value)
|
||||
.file_name()
|
||||
@@ -522,3 +573,17 @@ fn sanitize_filename(value: &str) -> String {
|
||||
clean.chars().take(160).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_safe_inline_image_mime;
|
||||
|
||||
#[test]
|
||||
fn only_raster_images_are_inline() {
|
||||
assert!(is_safe_inline_image_mime("image/png"));
|
||||
assert!(is_safe_inline_image_mime("image/jpeg"));
|
||||
assert!(!is_safe_inline_image_mime("image/svg+xml"));
|
||||
assert!(!is_safe_inline_image_mime("text/html"));
|
||||
assert!(!is_safe_inline_image_mime("application/xml"));
|
||||
}
|
||||
}
|
||||
|
||||
+138
-39
@@ -40,21 +40,28 @@ const MAX_PASSWORD_LENGTH: usize = 128;
|
||||
const MIN_WORKSPACE_SLUG_LENGTH: usize = 6;
|
||||
|
||||
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())
|
||||
crate::security::session_token(headers)
|
||||
}
|
||||
|
||||
fn authorization_token(headers: &HeaderMap) -> Option<&str> {
|
||||
crate::security::bearer_token(headers)
|
||||
}
|
||||
|
||||
fn user_session_token(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get("x-rustpad-user-token")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
crate::security::session_token(headers)
|
||||
}
|
||||
|
||||
fn resource_request_token<'a>(
|
||||
headers: &'a HeaderMap,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
supplied: Option<&'a str>,
|
||||
) -> Option<&'a str> {
|
||||
supplied
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| bearer_token(headers))
|
||||
.filter(|value| !value.is_empty() && *value != "cookie")
|
||||
.or_else(|| crate::security::resource_token(headers, kind, slug))
|
||||
.or_else(|| authorization_token(headers))
|
||||
}
|
||||
|
||||
async fn session_user(
|
||||
@@ -75,14 +82,19 @@ async fn has_write_permission(
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
) -> Result<bool, ApiError> {
|
||||
let bearer = bearer_token(headers);
|
||||
if token_access_level(state, kind, slug, bearer).await? >= AccessLevel::Write {
|
||||
let resource = crate::security::resource_token(headers, kind, slug);
|
||||
if external_token_access_level(state, kind, slug, resource).await? >= AccessLevel::Write {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let session = user_session_token(headers);
|
||||
if session.is_some() && session != bearer {
|
||||
return Ok(token_access_level(state, kind, slug, session).await? >= AccessLevel::Write);
|
||||
let authorization = authorization_token(headers);
|
||||
if authorization != resource
|
||||
&& external_token_access_level(state, kind, slug, authorization).await? >= AccessLevel::Write
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
let session = crate::security::session_cookie_token(headers);
|
||||
if session != resource && session != authorization {
|
||||
return Ok(account_token_access_level(state, kind, slug, session).await? >= AccessLevel::Write);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
@@ -476,10 +488,10 @@ pub async fn workspace_info(
|
||||
.ok_or_else(ApiError::not_found_workspace)?;
|
||||
ensure_private_resource_access(
|
||||
&state,
|
||||
&headers,
|
||||
"workspace",
|
||||
&workspace.slug,
|
||||
workspace.is_private,
|
||||
bearer_token(&headers),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(workspace_info_from(&workspace)))
|
||||
@@ -495,8 +507,9 @@ pub async fn open_workspace(
|
||||
&state,
|
||||
&workspace_slug,
|
||||
payload.password.as_deref(),
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
let stats = db::list_note_stats(&state.db, workspace.id)
|
||||
@@ -541,8 +554,9 @@ pub async fn create_note(
|
||||
&state,
|
||||
&workspace_slug,
|
||||
payload.password.as_deref(),
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|
||||
@@ -554,7 +568,7 @@ pub async fn create_note(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
)
|
||||
.await?
|
||||
@@ -692,10 +706,10 @@ pub async fn note_info(
|
||||
.ok_or_else(ApiError::not_found_workspace)?;
|
||||
ensure_private_resource_access(
|
||||
&state,
|
||||
&headers,
|
||||
"workspace",
|
||||
&workspace.slug,
|
||||
workspace.is_private,
|
||||
bearer_token(&headers),
|
||||
)
|
||||
.await?;
|
||||
let note = db::find_note(&state.db, workspace.id, ¬e_slug)
|
||||
@@ -807,8 +821,9 @@ pub async fn history(
|
||||
&workspace_slug,
|
||||
¬e_slug,
|
||||
payload.password.as_deref(),
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
let _ = workspace;
|
||||
@@ -834,8 +849,9 @@ pub async fn restore(
|
||||
&workspace_slug,
|
||||
¬e_slug,
|
||||
payload.password.as_deref(),
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|
||||
@@ -847,7 +863,7 @@ pub async fn restore(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
)
|
||||
.await?
|
||||
@@ -919,13 +935,13 @@ async fn anonymous_access_token_valid(
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn token_access_level(
|
||||
async fn external_token_access_level(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<AccessLevel, ApiError> {
|
||||
let permission = crate::auth::resource_permission(state, kind, slug, token)
|
||||
let permission = crate::auth::share_link_permission(state, kind, slug, token)
|
||||
.await
|
||||
.map_err(|error| ApiError::forbidden(&error.message))?;
|
||||
let level = permission_level(permission.as_deref());
|
||||
@@ -940,16 +956,28 @@ async fn token_access_level(
|
||||
Ok(AccessLevel::None)
|
||||
}
|
||||
|
||||
async fn account_token_access_level(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<AccessLevel, ApiError> {
|
||||
let permission = crate::auth::account_resource_permission(state, kind, slug, token)
|
||||
.await
|
||||
.map_err(|error| ApiError::forbidden(&error.message))?;
|
||||
Ok(permission_level(permission.as_deref()))
|
||||
}
|
||||
|
||||
async fn combined_token_access_level(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
access_token: Option<&str>,
|
||||
bearer: Option<&str>,
|
||||
account_token: Option<&str>,
|
||||
) -> Result<AccessLevel, ApiError> {
|
||||
Ok(std::cmp::max(
|
||||
token_access_level(state, kind, slug, access_token).await?,
|
||||
token_access_level(state, kind, slug, bearer).await?,
|
||||
external_token_access_level(state, kind, slug, access_token).await?,
|
||||
account_token_access_level(state, kind, slug, account_token).await?,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -961,28 +989,89 @@ fn require_write(level: AccessLevel) -> Result<(), ApiError> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn has_header_resource_access(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
) -> Result<bool, ApiError> {
|
||||
for token in [
|
||||
crate::security::resource_token(headers, kind, slug),
|
||||
authorization_token(headers),
|
||||
] {
|
||||
if external_token_access_level(state, kind, slug, token).await? != AccessLevel::None {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(account_token_access_level(
|
||||
state,
|
||||
kind,
|
||||
slug,
|
||||
crate::security::session_cookie_token(headers),
|
||||
)
|
||||
.await?
|
||||
!= AccessLevel::None)
|
||||
}
|
||||
|
||||
async fn ensure_private_resource_access(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
is_private: i64,
|
||||
token: Option<&str>,
|
||||
) -> Result<(), ApiError> {
|
||||
if is_private == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if verify_resource_access_token(state, kind, slug, token).await? {
|
||||
if has_header_resource_access(state, headers, kind, slug).await? {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ApiError::not_found_workspace())
|
||||
}
|
||||
|
||||
async fn check_resource_password_attempt(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
password: Option<&str>,
|
||||
password_ok: bool,
|
||||
) -> Result<(), ApiError> {
|
||||
let Some(_) = password.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let client_key = crate::security::client_key(headers);
|
||||
let window = std::time::Duration::from_secs(15 * 60);
|
||||
state
|
||||
.check_rate_limit(format!("resource-password-client:{client_key}"), 50, window)
|
||||
.await
|
||||
.map_err(|seconds| {
|
||||
ApiError::rate_limited(&format!(
|
||||
"Too many password attempts. Try again in {seconds} seconds."
|
||||
))
|
||||
})?;
|
||||
let limit_key = format!("resource-password:{client_key}:{kind}:{slug}");
|
||||
state
|
||||
.check_rate_limit(limit_key.clone(), 10, window)
|
||||
.await
|
||||
.map_err(|seconds| {
|
||||
ApiError::rate_limited(&format!(
|
||||
"Too many password attempts. Try again in {seconds} seconds."
|
||||
))
|
||||
})?;
|
||||
if password_ok {
|
||||
state.clear_rate_limit(&limit_key).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn authorized_workspace(
|
||||
state: &SharedState,
|
||||
slug: &str,
|
||||
password: Option<&str>,
|
||||
access_token: Option<&str>,
|
||||
bearer: Option<&str>,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<db::Workspace, ApiError> {
|
||||
let workspace = db::find_workspace(&state.db, slug)
|
||||
.await?
|
||||
@@ -992,11 +1081,13 @@ pub async fn authorized_workspace(
|
||||
if workspace.is_private != 0 && token_level == AccessLevel::None {
|
||||
return Err(ApiError::not_found_workspace());
|
||||
}
|
||||
if workspace.password_hash.is_some()
|
||||
&& !db::verify_workspace_password(&workspace, password)
|
||||
&& token_level == AccessLevel::None
|
||||
{
|
||||
return Err(ApiError::unauthorized());
|
||||
if workspace.password_hash.is_some() && token_level < AccessLevel::Write {
|
||||
let password_ok = db::verify_workspace_password(&workspace, password);
|
||||
check_resource_password_attempt(state, headers, "workspace", slug, password, password_ok)
|
||||
.await?;
|
||||
if token_level == AccessLevel::None && !password_ok {
|
||||
return Err(ApiError::unauthorized());
|
||||
}
|
||||
}
|
||||
Ok(workspace)
|
||||
}
|
||||
@@ -1008,9 +1099,17 @@ async fn authorized_note(
|
||||
password: Option<&str>,
|
||||
access_token: Option<&str>,
|
||||
bearer: Option<&str>,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<(db::Workspace, db::Note), ApiError> {
|
||||
let workspace =
|
||||
authorized_workspace(state, workspace_slug, password, access_token, bearer).await?;
|
||||
let workspace = authorized_workspace(
|
||||
state,
|
||||
workspace_slug,
|
||||
password,
|
||||
access_token,
|
||||
bearer,
|
||||
headers,
|
||||
)
|
||||
.await?;
|
||||
let note = db::find_note(&state.db, workspace.id, note_slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
|
||||
+47
-18
@@ -94,10 +94,10 @@ pub async fn pad_info(
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
ensure_private_resource_access(
|
||||
&state,
|
||||
&headers,
|
||||
"pad",
|
||||
&pad.slug,
|
||||
pad.is_private,
|
||||
bearer_token(&headers),
|
||||
)
|
||||
.await?;
|
||||
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
|
||||
@@ -238,8 +238,9 @@ pub async fn publish_pad_page(
|
||||
&state,
|
||||
&slug,
|
||||
payload.password.as_deref(),
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
let level = if db::verify_pad_password(&pad, payload.password.as_deref())
|
||||
@@ -251,7 +252,7 @@ pub async fn publish_pad_page(
|
||||
&state,
|
||||
"pad",
|
||||
&slug,
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
)
|
||||
.await?
|
||||
@@ -287,8 +288,14 @@ pub async fn publish_note_page(
|
||||
&workspace_slug,
|
||||
¬e_slug,
|
||||
payload.password.as_deref(),
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(
|
||||
&headers,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
payload.access_token.as_deref(),
|
||||
),
|
||||
bearer_token(&headers),
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
let level = if db::verify_workspace_password(&workspace, payload.password.as_deref())
|
||||
@@ -300,7 +307,12 @@ pub async fn publish_note_page(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(
|
||||
&headers,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
payload.access_token.as_deref(),
|
||||
),
|
||||
bearer_token(&headers),
|
||||
)
|
||||
.await?
|
||||
@@ -339,7 +351,6 @@ async fn ensure_public_page_access(
|
||||
page: &db::PublishedPage,
|
||||
) -> Result<(), ApiError> {
|
||||
let password = page_password(headers);
|
||||
let bearer = bearer_token(headers);
|
||||
if let Some(pad_id) = page.pad_id {
|
||||
if db::pad_public_page_unprotected(&state.db, pad_id).await? {
|
||||
return Ok(());
|
||||
@@ -358,10 +369,13 @@ async fn ensure_public_page_access(
|
||||
let pad = db::find_pad(&state.db, &slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
if db::verify_pad_password(&pad, password) {
|
||||
if has_header_resource_access(state, headers, "pad", &slug).await? {
|
||||
return Ok(());
|
||||
}
|
||||
if verify_resource_access_token(state, "pad", &slug, bearer).await? {
|
||||
let password_ok = db::verify_pad_password(&pad, password);
|
||||
check_resource_password_attempt(state, headers, "pad", &slug, password, password_ok)
|
||||
.await?;
|
||||
if password_ok {
|
||||
return Ok(());
|
||||
}
|
||||
return if pad.password_hash.is_some() {
|
||||
@@ -392,10 +406,20 @@ async fn ensure_public_page_access(
|
||||
let workspace = db::find_workspace(&state.db, &slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_workspace)?;
|
||||
if db::verify_workspace_password(&workspace, password) {
|
||||
if has_header_resource_access(state, headers, "workspace", &slug).await? {
|
||||
return Ok(());
|
||||
}
|
||||
if verify_resource_access_token(state, "workspace", &slug, bearer).await? {
|
||||
let password_ok = db::verify_workspace_password(&workspace, password);
|
||||
check_resource_password_attempt(
|
||||
state,
|
||||
headers,
|
||||
"workspace",
|
||||
&slug,
|
||||
password,
|
||||
password_ok,
|
||||
)
|
||||
.await?;
|
||||
if password_ok {
|
||||
return Ok(());
|
||||
}
|
||||
return if workspace.password_hash.is_some() {
|
||||
@@ -476,8 +500,9 @@ pub async fn pad_history(
|
||||
&state,
|
||||
&slug,
|
||||
payload.password.as_deref(),
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
let revisions = db::list_pad_revisions(&state.db, pad.id)
|
||||
@@ -501,8 +526,9 @@ pub async fn pad_restore(
|
||||
&state,
|
||||
&slug,
|
||||
payload.password.as_deref(),
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
let level = if db::verify_pad_password(&pad, payload.password.as_deref())
|
||||
@@ -514,7 +540,7 @@ pub async fn pad_restore(
|
||||
&state,
|
||||
"pad",
|
||||
&slug,
|
||||
payload.access_token.as_deref(),
|
||||
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
||||
bearer_token(&headers),
|
||||
)
|
||||
.await?
|
||||
@@ -555,6 +581,7 @@ pub(super) async fn authorized_pad(
|
||||
password: Option<&str>,
|
||||
access_token: Option<&str>,
|
||||
bearer: Option<&str>,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<db::Pad, ApiError> {
|
||||
let pad = db::find_pad(&state.db, slug)
|
||||
.await?
|
||||
@@ -563,11 +590,13 @@ pub(super) async fn authorized_pad(
|
||||
if pad.is_private != 0 && token_level == AccessLevel::None {
|
||||
return Err(ApiError::not_found_note());
|
||||
}
|
||||
if pad.password_hash.is_some()
|
||||
&& !db::verify_pad_password(&pad, password)
|
||||
&& token_level == AccessLevel::None
|
||||
{
|
||||
return Err(ApiError::forbidden("Password required or incorrect."));
|
||||
if pad.password_hash.is_some() && token_level < AccessLevel::Write {
|
||||
let password_ok = db::verify_pad_password(&pad, password);
|
||||
check_resource_password_attempt(state, headers, "pad", slug, password, password_ok)
|
||||
.await?;
|
||||
if token_level == AccessLevel::None && !password_ok {
|
||||
return Err(ApiError::forbidden("Password required or incorrect."));
|
||||
}
|
||||
}
|
||||
Ok(pad)
|
||||
}
|
||||
|
||||
+161
-65
@@ -17,7 +17,8 @@ use argon2::{
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path as AxumPath, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use lettre::{
|
||||
@@ -53,8 +54,6 @@ pub struct User {
|
||||
#[derive(Deserialize)]
|
||||
pub struct IdentityRequest {
|
||||
nickname: String,
|
||||
#[serde(default)]
|
||||
session_token: Option<String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
@@ -259,6 +258,7 @@ pub struct ResourceList {
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct SessionResponse {
|
||||
#[serde(skip_serializing)]
|
||||
token: String,
|
||||
nickname: String,
|
||||
email: String,
|
||||
@@ -276,6 +276,7 @@ pub struct IdentityResponse {
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct RegisterResponse {
|
||||
#[serde(skip_serializing)]
|
||||
token: Option<String>,
|
||||
nickname: String,
|
||||
email: String,
|
||||
@@ -286,10 +287,12 @@ pub struct RegisterResponse {
|
||||
|
||||
pub async fn identity(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<IdentityRequest>,
|
||||
) -> Result<Json<IdentityResponse>, AuthError> {
|
||||
let nickname = validate_nickname(&req.nickname)?;
|
||||
debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested");
|
||||
let request_token = crate::security::session_token(&headers);
|
||||
debug!(nickname = %nickname, has_session = request_token.is_some(), "identity check requested");
|
||||
match find_user_by_nickname(&state, &nickname).await? {
|
||||
None => {
|
||||
debug!(nickname = %nickname, "nickname is available for guest use");
|
||||
@@ -299,7 +302,7 @@ pub async fn identity(
|
||||
}))
|
||||
}
|
||||
Some(user) => {
|
||||
let token = req.session_token.as_deref().ok_or_else(|| {
|
||||
let token = request_token.ok_or_else(|| {
|
||||
AuthError::unauthorized("This nickname is registered. Log in to use it.")
|
||||
})?;
|
||||
let current = user_from_token(&state, token).await?.ok_or_else(|| {
|
||||
@@ -322,7 +325,7 @@ pub async fn identity(
|
||||
pub async fn register(
|
||||
State(state): State<SharedState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<(StatusCode, Json<RegisterResponse>), AuthError> {
|
||||
) -> Result<Response, AuthError> {
|
||||
if state.ldap.is_some() {
|
||||
return Err(AuthError::forbidden(
|
||||
"Local registration is disabled while LDAP authentication is enabled.",
|
||||
@@ -434,12 +437,13 @@ pub async fn register(
|
||||
"Account created. Check your e-mail and confirm the account before logging in."
|
||||
.into(),
|
||||
}),
|
||||
));
|
||||
).into_response());
|
||||
}
|
||||
|
||||
let session = create_session(&state, &user).await?;
|
||||
info!(user_id = user.id, nickname = %user.nickname, "account registered and session created");
|
||||
Ok((
|
||||
let cookie = crate::security::session_cookie(&session.token, state.user_session_ttl_days);
|
||||
let mut response = (
|
||||
StatusCode::CREATED,
|
||||
Json(RegisterResponse {
|
||||
token: Some(session.token),
|
||||
@@ -449,22 +453,44 @@ pub async fn register(
|
||||
confirmation_required: false,
|
||||
message: "Account created.".into(),
|
||||
}),
|
||||
))
|
||||
).into_response();
|
||||
response.headers_mut().insert(header::SET_COOKIE, cookie);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<SessionResponse>, AuthError> {
|
||||
if state.ldap.is_some() {
|
||||
ldap::login(&state, &req.email, &req.password)
|
||||
.await
|
||||
.map(Json)
|
||||
) -> Result<Response, AuthError> {
|
||||
let client_key = crate::security::client_key(&headers);
|
||||
let login_key: String = normalize(&req.email).chars().take(320).collect();
|
||||
let client_limit_key = format!("login-client:{client_key}");
|
||||
let limit_key = format!("login:{client_key}:{login_key}");
|
||||
let window = std::time::Duration::from_secs(15 * 60);
|
||||
state
|
||||
.check_rate_limit(client_limit_key.clone(), 30, window)
|
||||
.await
|
||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
||||
"Too many login attempts. Try again in {seconds} seconds."
|
||||
)))?;
|
||||
state
|
||||
.check_rate_limit(limit_key.clone(), 5, window)
|
||||
.await
|
||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
||||
"Too many login attempts. Try again in {seconds} seconds."
|
||||
)))?;
|
||||
let session = if state.ldap.is_some() {
|
||||
ldap::login(&state, &req.email, &req.password).await?
|
||||
} else {
|
||||
local::login(&state, &req.email, &req.password)
|
||||
.await
|
||||
.map(Json)
|
||||
}
|
||||
local::login(&state, &req.email, &req.password).await?
|
||||
};
|
||||
state.clear_rate_limit(&limit_key).await;
|
||||
Ok(session_json_response(
|
||||
StatusCode::OK,
|
||||
session,
|
||||
state.user_session_ttl_days,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn resend_confirmation(
|
||||
@@ -673,11 +699,16 @@ fn suggested_directory_nickname(display_name: Option<&str>, email: &str) -> Opti
|
||||
pub async fn me(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<SessionResponse>, AuthError> {
|
||||
let token = bearer(&headers).ok_or_else(|| AuthError::unauthorized("Not logged in."))?;
|
||||
let user = user_from_token(&state, token)
|
||||
.await?
|
||||
.ok_or_else(|| AuthError::unauthorized("Your session has expired."))?;
|
||||
) -> Result<Response, AuthError> {
|
||||
let token = crate::security::session_token(&headers)
|
||||
.ok_or_else(|| AuthError::unauthorized("Not logged in."))?;
|
||||
let Some(user) = user_from_token(&state, token).await? else {
|
||||
let mut response = AuthError::unauthorized("Your session has expired.").into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::SET_COOKIE, crate::security::clear_session_cookie());
|
||||
return Ok(response);
|
||||
};
|
||||
debug!(user_id = user.id, "session validation successful");
|
||||
let expires_at: String = sqlx::query_scalar(queries::get(
|
||||
state.db.kind(),
|
||||
@@ -697,17 +728,21 @@ pub async fn me(
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
Ok(Json(SessionResponse {
|
||||
token: token.into(),
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
expires_at,
|
||||
directory_managed,
|
||||
directory_display_name,
|
||||
directory_organization,
|
||||
suggested_nickname,
|
||||
editor_color,
|
||||
}))
|
||||
Ok(session_json_response(
|
||||
StatusCode::OK,
|
||||
SessionResponse {
|
||||
token: token.into(),
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
expires_at,
|
||||
directory_managed,
|
||||
directory_display_name,
|
||||
directory_organization,
|
||||
suggested_nickname,
|
||||
editor_color,
|
||||
},
|
||||
state.user_session_ttl_days,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update_profile(
|
||||
@@ -1225,7 +1260,7 @@ pub async fn optional_user(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Option<User>, AuthError> {
|
||||
match bearer(headers) {
|
||||
match crate::security::session_token(headers) {
|
||||
Some(token) => user_from_token(state, token).await,
|
||||
None => Ok(None),
|
||||
}
|
||||
@@ -1672,32 +1707,43 @@ pub async fn is_resource_owner(
|
||||
Ok(ensure_owner(state, user.id, kind, slug).await.is_ok())
|
||||
}
|
||||
|
||||
pub async fn resource_permission(
|
||||
pub async fn account_resource_permission(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<Option<String>, AuthError> {
|
||||
let Some(token) = token.filter(|v| !v.is_empty()) else {
|
||||
let Some(token) = token.filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(user) = user_from_token(state, token).await? {
|
||||
let owns = ensure_owner(state, user.id, kind, slug).await.is_ok();
|
||||
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)?;
|
||||
return Ok(permission);
|
||||
let Some(user) = user_from_token(state, token).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if ensure_owner(state, user.id, kind, slug).await.is_ok() {
|
||||
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)?;
|
||||
Ok(permission)
|
||||
}
|
||||
|
||||
pub async fn share_link_permission(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<Option<String>, AuthError> {
|
||||
let Some(token) = token.filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let permission: Option<String> = sqlx::query_scalar(queries::get(
|
||||
state.db.kind(),
|
||||
@@ -1716,8 +1762,8 @@ pub async fn resource_permission(
|
||||
pub async fn logout(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
if let Some(token) = bearer(&headers) {
|
||||
) -> Result<Response, AuthError> {
|
||||
if let Some(token) = crate::security::session_token(&headers) {
|
||||
let result = sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_DELETE_SESSION_BY_TOKEN,
|
||||
@@ -1730,14 +1776,37 @@ pub async fn logout(
|
||||
} else {
|
||||
debug!("logout requested without an active session");
|
||||
}
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
let mut response = Json(serde_json::json!({"ok": true})).into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::SET_COOKIE, crate::security::clear_session_cookie());
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn request_reset(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ResetRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
let email = validate_email(&req.email)?;
|
||||
let client_key = crate::security::client_key(&headers);
|
||||
let window = std::time::Duration::from_secs(60 * 60);
|
||||
state
|
||||
.check_rate_limit(format!("password-reset-client:{client_key}"), 10, window)
|
||||
.await
|
||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
||||
"Too many password reset requests. Try again in {seconds} seconds."
|
||||
)))?;
|
||||
state
|
||||
.check_rate_limit(
|
||||
format!("password-reset:{client_key}:{}", normalize(&email)),
|
||||
3,
|
||||
window,
|
||||
)
|
||||
.await
|
||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
||||
"Too many password reset requests. Try again in {seconds} seconds."
|
||||
)))?;
|
||||
info!(email_domain = %email_domain(&email), "password reset requested");
|
||||
let smtp = state.smtp.as_ref().ok_or_else(|| {
|
||||
AuthError::service_unavailable("Password reset is not configured on this server.")
|
||||
@@ -1777,13 +1846,34 @@ pub async fn request_reset(
|
||||
|
||||
pub async fn confirm_reset(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ResetConfirmRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
validate_password(&req.password)?;
|
||||
let token_fingerprint = hash_token(req.token.trim());
|
||||
let client_key = crate::security::client_key(&headers);
|
||||
let client_limit_key = format!("password-reset-confirm-client:{client_key}");
|
||||
let limit_key = format!(
|
||||
"password-reset-confirm:{client_key}:{}",
|
||||
&token_fingerprint[..16.min(token_fingerprint.len())]
|
||||
);
|
||||
let window = std::time::Duration::from_secs(15 * 60);
|
||||
state
|
||||
.check_rate_limit(client_limit_key.clone(), 20, window)
|
||||
.await
|
||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
||||
"Too many reset attempts. Try again in {seconds} seconds."
|
||||
)))?;
|
||||
state
|
||||
.check_rate_limit(limit_key.clone(), 10, window)
|
||||
.await
|
||||
.map_err(|seconds| AuthError::rate_limited(&format!(
|
||||
"Too many reset attempts. Try again in {seconds} seconds."
|
||||
)))?;
|
||||
info!("password reset confirmation requested");
|
||||
let now_time = Utc::now();
|
||||
let now = now_time.to_rfc3339();
|
||||
let token_hash = hash_token(req.token.trim());
|
||||
let token_hash = token_fingerprint;
|
||||
let token_row = sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_FIND_RESET_TOKEN,
|
||||
@@ -1855,6 +1945,7 @@ pub async fn confirm_reset(
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
tx.commit().await.map_err(AuthError::database)?;
|
||||
state.clear_rate_limit(&limit_key).await;
|
||||
info!(
|
||||
user_id,
|
||||
"password reset completed and existing sessions revoked"
|
||||
@@ -2103,14 +2194,6 @@ fn hash_token(value: &str) -> String {
|
||||
format!("{:x}", Sha256::digest(value.as_bytes()))
|
||||
}
|
||||
|
||||
fn bearer(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get("authorization")?
|
||||
.to_str()
|
||||
.ok()?
|
||||
.strip_prefix("Bearer ")
|
||||
}
|
||||
|
||||
async fn send_registration_email(
|
||||
smtp: &SmtpConfig,
|
||||
user: &User,
|
||||
@@ -2407,6 +2490,12 @@ impl AuthError {
|
||||
message: m.into(),
|
||||
}
|
||||
}
|
||||
fn rate_limited(m: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::TOO_MANY_REQUESTS,
|
||||
message: m.into(),
|
||||
}
|
||||
}
|
||||
fn service_unavailable(m: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -2419,6 +2508,13 @@ impl AuthError {
|
||||
}
|
||||
}
|
||||
|
||||
fn session_json_response(status: StatusCode, session: SessionResponse, ttl_days: i64) -> Response {
|
||||
let cookie = crate::security::session_cookie(&session.token, ttl_days);
|
||||
let mut response = (status, Json(session)).into_response();
|
||||
response.headers_mut().insert(header::SET_COOKIE, cookie);
|
||||
response
|
||||
}
|
||||
|
||||
fn email_domain(email: &str) -> &str {
|
||||
email
|
||||
.rsplit_once('@')
|
||||
|
||||
@@ -17,6 +17,7 @@ mod database;
|
||||
mod db;
|
||||
mod queries;
|
||||
mod row_decode;
|
||||
mod security;
|
||||
mod state;
|
||||
mod storage;
|
||||
mod websocket;
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
use axum::http::{HeaderMap, HeaderValue, Uri, header};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub const SESSION_COOKIE: &str = "__Host-rustpad_session";
|
||||
|
||||
pub fn session_token(headers: &HeaderMap) -> Option<&str> {
|
||||
session_cookie_token(headers)
|
||||
}
|
||||
|
||||
pub fn session_cookie_token(headers: &HeaderMap) -> Option<&str> {
|
||||
cookie_value(headers, SESSION_COOKIE)
|
||||
}
|
||||
|
||||
pub 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())
|
||||
}
|
||||
|
||||
pub fn resource_token<'a>(headers: &'a HeaderMap, kind: &str, slug: &str) -> Option<&'a str> {
|
||||
let name = resource_cookie_name(kind, slug);
|
||||
cookie_value(headers, &name)
|
||||
}
|
||||
|
||||
pub fn session_cookie(token: &str, ttl_days: i64) -> HeaderValue {
|
||||
secure_cookie(SESSION_COOKIE, token, ttl_days.saturating_mul(86_400))
|
||||
}
|
||||
|
||||
pub fn clear_session_cookie() -> HeaderValue {
|
||||
clear_cookie(SESSION_COOKIE)
|
||||
}
|
||||
|
||||
pub fn resource_cookie(kind: &str, slug: &str, token: &str, ttl_days: i64) -> HeaderValue {
|
||||
secure_cookie(
|
||||
&resource_cookie_name(kind, slug),
|
||||
token,
|
||||
ttl_days.saturating_mul(86_400),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn client_key(headers: &HeaderMap) -> String {
|
||||
let forwarded_ip = header_ip(headers, "cf-connecting-ip")
|
||||
.or_else(|| header_ip(headers, "x-real-ip"))
|
||||
.or_else(|| header_ip(headers, "x-forwarded-for"));
|
||||
if let Some(value) = forwarded_ip {
|
||||
return format!("ip:{value}");
|
||||
}
|
||||
|
||||
let user_agent = first_header_value(headers, header::USER_AGENT.as_str()).unwrap_or("");
|
||||
let language = first_header_value(headers, header::ACCEPT_LANGUAGE.as_str()).unwrap_or("");
|
||||
if user_agent.is_empty() && language.is_empty() {
|
||||
return "unknown".into();
|
||||
}
|
||||
let digest = Sha256::digest(format!("{user_agent}|{language}").as_bytes());
|
||||
format!("browser:{}", hex::encode(&digest[..12]))
|
||||
}
|
||||
|
||||
pub fn websocket_origin_allowed(headers: &HeaderMap) -> bool {
|
||||
let Some(origin) = headers
|
||||
.get(header::ORIGIN)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && *value != "null")
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Ok(uri) = origin.parse::<Uri>() else {
|
||||
return false;
|
||||
};
|
||||
let Some(origin_authority) = uri.authority().map(|value| value.as_str()) else {
|
||||
return false;
|
||||
};
|
||||
if uri.path() != "/" || uri.query().is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(expected_authority) = first_header_value(headers, header::HOST.as_str()) else {
|
||||
return false;
|
||||
};
|
||||
if !origin_authority.eq_ignore_ascii_case(expected_authority) {
|
||||
return false;
|
||||
}
|
||||
if let Some(expected_scheme) = first_header_value(headers, "x-forwarded-proto") {
|
||||
let Some(origin_scheme) = uri.scheme_str() else {
|
||||
return false;
|
||||
};
|
||||
if !origin_scheme.eq_ignore_ascii_case(expected_scheme) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
matches!(uri.scheme_str(), Some("http" | "https"))
|
||||
}
|
||||
|
||||
fn resource_cookie_name(kind: &str, slug: &str) -> String {
|
||||
let digest = Sha256::digest(format!("{kind}:{slug}").as_bytes());
|
||||
format!("__Host-rustpad_access_{}", hex::encode(&digest[..12]))
|
||||
}
|
||||
|
||||
fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
headers
|
||||
.get(header::COOKIE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|cookies| {
|
||||
cookies.split(';').find_map(|part| {
|
||||
let (cookie_name, cookie_value) = part.trim().split_once('=')?;
|
||||
(cookie_name == name && !cookie_value.is_empty()).then_some(cookie_value)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn secure_cookie(name: &str, value: &str, max_age: i64) -> HeaderValue {
|
||||
HeaderValue::from_str(&format!(
|
||||
"{name}={value}; Path=/; Max-Age={}; HttpOnly; Secure; SameSite=Lax",
|
||||
max_age.max(1)
|
||||
))
|
||||
.expect("valid secure cookie")
|
||||
}
|
||||
|
||||
fn clear_cookie(name: &str) -> HeaderValue {
|
||||
HeaderValue::from_str(&format!(
|
||||
"{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"
|
||||
))
|
||||
.expect("valid clear cookie")
|
||||
}
|
||||
|
||||
fn header_ip(headers: &HeaderMap, name: &str) -> Option<std::net::IpAddr> {
|
||||
first_header_value(headers, name)?.parse().ok()
|
||||
}
|
||||
|
||||
fn first_header_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.split(',').next())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn websocket_headers(origin: &'static str, host: &'static str) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::ORIGIN, HeaderValue::from_static(origin));
|
||||
headers.insert(header::HOST, HeaderValue::from_static(host));
|
||||
headers
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_sessions_are_cookie_only() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer legacy-account-token"),
|
||||
);
|
||||
assert_eq!(session_token(&headers), None);
|
||||
assert_eq!(bearer_token(&headers), Some("legacy-account-token"));
|
||||
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
HeaderValue::from_static("__Host-rustpad_session=cookie-token"),
|
||||
);
|
||||
assert_eq!(session_token(&headers), Some("cookie-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_proxy_controlled_real_ip() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static("x-forwarded-for"),
|
||||
HeaderValue::from_static("203.0.113.10"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static("x-real-ip"),
|
||||
HeaderValue::from_static("198.51.100.20"),
|
||||
);
|
||||
assert_eq!(client_key(&headers), "ip:198.51.100.20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_same_origin_websocket() {
|
||||
let headers = websocket_headers("https://pad.example.com", "pad.example.com");
|
||||
assert!(websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_cross_origin_websocket() {
|
||||
let headers = websocket_headers("https://evil.example", "pad.example.com");
|
||||
assert!(!websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_trust_forwarded_host_for_websocket_origin() {
|
||||
let mut headers = websocket_headers("https://evil.example", "pad.example.com");
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static("x-forwarded-host"),
|
||||
HeaderValue::from_static("evil.example"),
|
||||
);
|
||||
assert!(!websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_origin_with_path() {
|
||||
let headers = websocket_headers("https://pad.example.com/other", "pad.example.com");
|
||||
assert!(!websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_websocket_origin() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::HOST, HeaderValue::from_static("pad.example.com"));
|
||||
assert!(!websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_cookies_are_not_script_readable() {
|
||||
let value = session_cookie("abc123", 7).to_str().unwrap();
|
||||
assert!(value.contains("HttpOnly"));
|
||||
assert!(value.contains("Secure"));
|
||||
assert!(value.contains("SameSite=Lax"));
|
||||
assert!(value.starts_with("__Host-rustpad_session=abc123;"));
|
||||
}
|
||||
}
|
||||
+56
-1
@@ -15,10 +15,19 @@ use std::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::sync::{RwLock, broadcast};
|
||||
use tokio::sync::{Mutex, RwLock, broadcast};
|
||||
|
||||
const CHANNEL_CAPACITY: usize = 256;
|
||||
const MAX_RATE_LIMIT_ENTRIES: usize = 50_000;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RateLimitEntry {
|
||||
started_at: Instant,
|
||||
attempts: u32,
|
||||
window: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SmtpSecurity {
|
||||
@@ -100,6 +109,7 @@ pub struct AppState {
|
||||
channels: RwLock<HashMap<String, broadcast::Sender<RoomEvent>>>,
|
||||
presence: RwLock<HashMap<String, HashMap<u64, PresenceConnection>>>,
|
||||
next_connection_id: AtomicU64,
|
||||
rate_limits: Mutex<HashMap<String, RateLimitEntry>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -137,8 +147,53 @@ impl AppState {
|
||||
channels: RwLock::new(HashMap::new()),
|
||||
presence: RwLock::new(HashMap::new()),
|
||||
next_connection_id: AtomicU64::new(1),
|
||||
rate_limits: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
pub async fn check_rate_limit(
|
||||
&self,
|
||||
key: String,
|
||||
max_attempts: u32,
|
||||
window: Duration,
|
||||
) -> Result<(), u64> {
|
||||
let now = Instant::now();
|
||||
let mut limits = self.rate_limits.lock().await;
|
||||
if !limits.contains_key(&key) && limits.len() >= MAX_RATE_LIMIT_ENTRIES {
|
||||
limits.retain(|_, entry| now.duration_since(entry.started_at) < entry.window);
|
||||
if limits.len() >= MAX_RATE_LIMIT_ENTRIES {
|
||||
if let Some(oldest_key) = limits
|
||||
.iter()
|
||||
.min_by_key(|(_, entry)| entry.started_at)
|
||||
.map(|(key, _)| key.clone())
|
||||
{
|
||||
limits.remove(&oldest_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
let entry = limits.entry(key).or_insert(RateLimitEntry {
|
||||
started_at: now,
|
||||
attempts: 0,
|
||||
window,
|
||||
});
|
||||
if now.duration_since(entry.started_at) >= entry.window {
|
||||
entry.started_at = now;
|
||||
entry.attempts = 0;
|
||||
}
|
||||
entry.window = window;
|
||||
if entry.attempts >= max_attempts {
|
||||
let remaining = entry
|
||||
.window
|
||||
.saturating_sub(now.duration_since(entry.started_at));
|
||||
return Err(remaining.as_secs().max(1));
|
||||
}
|
||||
entry.attempts += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_rate_limit(&self, key: &str) {
|
||||
self.rate_limits.lock().await.remove(key);
|
||||
}
|
||||
|
||||
async fn channel_for_key(&self, key: String) -> broadcast::Sender<RoomEvent> {
|
||||
if let Some(sender) = self.channels.read().await.get(&key) {
|
||||
return sender.clone();
|
||||
|
||||
+114
-16
@@ -7,7 +7,8 @@ use axum::{
|
||||
Path, State, WebSocketUpgrade,
|
||||
ws::{Message, WebSocket},
|
||||
},
|
||||
response::Response,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -25,7 +26,6 @@ enum ClientMessage {
|
||||
password: Option<String>,
|
||||
access_token: Option<String>,
|
||||
nickname: Option<String>,
|
||||
session_token: Option<String>,
|
||||
guest_id: Option<String>,
|
||||
color: Option<String>,
|
||||
},
|
||||
@@ -76,13 +76,69 @@ enum ServerMessage {
|
||||
},
|
||||
}
|
||||
|
||||
async fn resource_permission_from_tokens(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
access_token: Option<&str>,
|
||||
session_token: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let access_permission = auth::share_link_permission(state, kind, slug, access_token)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let session_permission = auth::account_resource_permission(state, kind, slug, session_token)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
if access_permission.as_deref() == Some("rw") || session_permission.as_deref() == Some("rw") {
|
||||
Some("rw".into())
|
||||
} else if access_permission.as_deref() == Some("ro")
|
||||
|| session_permission.as_deref() == Some("ro")
|
||||
{
|
||||
Some("ro".into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn anonymous_access_from_tokens(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
access_token: Option<&str>,
|
||||
) -> bool {
|
||||
crate::api::verify_resource_access_token(state, kind, slug, access_token)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// Merged from note.rs
|
||||
pub async fn upgrade(
|
||||
ws: WebSocketUpgrade,
|
||||
headers: HeaderMap,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
State(state): State<SharedState>,
|
||||
) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state, workspace_slug, note_slug))
|
||||
if !crate::security::websocket_origin_allowed(&headers) {
|
||||
warn!(%workspace_slug, %note_slug, "note websocket rejected: invalid origin");
|
||||
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
|
||||
}
|
||||
let account_token = crate::security::session_token(&headers).map(str::to_owned);
|
||||
let resource_token = crate::security::resource_token(&headers, "workspace", &workspace_slug)
|
||||
.map(str::to_owned);
|
||||
let client_key = crate::security::client_key(&headers);
|
||||
ws.on_upgrade(move |socket| {
|
||||
handle_socket(
|
||||
socket,
|
||||
state,
|
||||
workspace_slug,
|
||||
note_slug,
|
||||
account_token,
|
||||
resource_token,
|
||||
client_key,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_socket(
|
||||
@@ -90,6 +146,9 @@ async fn handle_socket(
|
||||
state: SharedState,
|
||||
workspace_slug: String,
|
||||
note_slug: String,
|
||||
cookie_session_token: Option<String>,
|
||||
cookie_access_token: Option<String>,
|
||||
client_key: String,
|
||||
) {
|
||||
info!(%workspace_slug, %note_slug, "note websocket connected");
|
||||
let Some(workspace) = db::find_workspace(&state.db, &workspace_slug)
|
||||
@@ -110,21 +169,19 @@ async fn handle_socket(
|
||||
let _ = send_error(&mut socket, "Note not found").await;
|
||||
return;
|
||||
};
|
||||
let (password, access_token, nickname, session_token, guest_id, color) =
|
||||
let (password, access_token, nickname, guest_id, color) =
|
||||
match socket.recv().await {
|
||||
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(ClientMessage::Authenticate {
|
||||
password,
|
||||
access_token,
|
||||
nickname,
|
||||
session_token,
|
||||
guest_id,
|
||||
color,
|
||||
}) => (
|
||||
password,
|
||||
access_token,
|
||||
clean_nickname(nickname),
|
||||
session_token,
|
||||
clean_guest_id(guest_id),
|
||||
clean_color(color),
|
||||
),
|
||||
@@ -135,6 +192,13 @@ async fn handle_socket(
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
let session_token = cookie_session_token;
|
||||
let explicit_access_token = access_token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && *value != "cookie")
|
||||
.map(str::to_owned);
|
||||
let access_token = explicit_access_token.or(cookie_access_token);
|
||||
let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await {
|
||||
Ok(value) => value,
|
||||
Err(message) => {
|
||||
@@ -154,22 +218,56 @@ async fn handle_socket(
|
||||
.map(|name| format!("guest:{id}:{}", name.to_lowercase()))
|
||||
}),
|
||||
};
|
||||
let supplied_token = session_token.as_deref().or(access_token.as_deref());
|
||||
let permission =
|
||||
auth::resource_permission(&state, "workspace", &workspace_slug, supplied_token)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let permission = resource_permission_from_tokens(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
access_token.as_deref(),
|
||||
session_token.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let anonymous_token_ok = permission.is_none()
|
||||
&& crate::api::verify_resource_access_token(
|
||||
&& anonymous_access_from_tokens(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
supplied_token,
|
||||
access_token.as_deref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
.await;
|
||||
let password_limit_key = format!("resource-password:{client_key}:workspace:{workspace_slug}");
|
||||
let password_attempted = password
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
if workspace.password_hash.is_some()
|
||||
&& permission.as_deref() != Some("rw")
|
||||
&& !anonymous_token_ok
|
||||
&& password_attempted
|
||||
{
|
||||
let window = std::time::Duration::from_secs(15 * 60);
|
||||
if let Err(seconds) = state
|
||||
.check_rate_limit(format!("resource-password-client:{client_key}"), 50, window)
|
||||
.await
|
||||
{
|
||||
let _ = send_error(&mut socket, &format!("Too many password attempts. Try again in {seconds} seconds.")).await;
|
||||
return;
|
||||
}
|
||||
if let Err(seconds) = state
|
||||
.check_rate_limit(password_limit_key.clone(), 10, window)
|
||||
.await
|
||||
{
|
||||
let _ = send_error(
|
||||
&mut socket,
|
||||
&format!("Too many password attempts. Try again in {seconds} seconds."),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let password_ok = db::verify_workspace_password(&workspace, password.as_deref());
|
||||
if password_ok {
|
||||
state.clear_rate_limit(&password_limit_key).await;
|
||||
}
|
||||
if workspace.is_private != 0 && permission.is_none() && !anonymous_token_ok {
|
||||
let _ = send_error(&mut socket, "Workspace not found").await;
|
||||
return;
|
||||
|
||||
+87
-12
@@ -32,12 +32,30 @@ enum PadServerMessage {
|
||||
}
|
||||
pub async fn upgrade_pad(
|
||||
ws: WebSocketUpgrade,
|
||||
headers: HeaderMap,
|
||||
Path(slug): Path<String>,
|
||||
State(state): State<SharedState>,
|
||||
) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_pad_socket(socket, state, slug))
|
||||
if !crate::security::websocket_origin_allowed(&headers) {
|
||||
warn!(%slug, "pad websocket rejected: invalid origin");
|
||||
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
|
||||
}
|
||||
let account_token = crate::security::session_token(&headers).map(str::to_owned);
|
||||
let resource_token = crate::security::resource_token(&headers, "pad", &slug)
|
||||
.map(str::to_owned);
|
||||
let client_key = crate::security::client_key(&headers);
|
||||
ws.on_upgrade(move |socket| {
|
||||
handle_pad_socket(socket, state, slug, account_token, resource_token, client_key)
|
||||
})
|
||||
}
|
||||
async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: String) {
|
||||
async fn handle_pad_socket(
|
||||
mut socket: WebSocket,
|
||||
state: SharedState,
|
||||
slug: String,
|
||||
cookie_session_token: Option<String>,
|
||||
cookie_access_token: Option<String>,
|
||||
client_key: String,
|
||||
) {
|
||||
info!(%slug, "pad websocket connected");
|
||||
let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else {
|
||||
warn!(%slug, "pad websocket rejected: pad not found");
|
||||
@@ -50,21 +68,19 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
let (password, access_token, nickname, session_token, guest_id, color) =
|
||||
let (password, access_token, nickname, guest_id, color) =
|
||||
match socket.recv().await {
|
||||
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(ClientMessage::Authenticate {
|
||||
password,
|
||||
access_token,
|
||||
nickname,
|
||||
session_token,
|
||||
guest_id,
|
||||
color,
|
||||
}) => (
|
||||
password,
|
||||
access_token,
|
||||
clean_nickname(nickname),
|
||||
session_token,
|
||||
clean_guest_id(guest_id),
|
||||
clean_color(color),
|
||||
),
|
||||
@@ -81,6 +97,13 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
let session_token = cookie_session_token;
|
||||
let explicit_access_token = access_token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && *value != "cookie")
|
||||
.map(str::to_owned);
|
||||
let access_token = explicit_access_token.or(cookie_access_token);
|
||||
let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await {
|
||||
Ok(value) => value,
|
||||
Err(message) => {
|
||||
@@ -100,16 +123,68 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
|
||||
.map(|name| format!("guest:{id}:{}", name.to_lowercase()))
|
||||
}),
|
||||
};
|
||||
let supplied_token = session_token.as_deref().or(access_token.as_deref());
|
||||
let permission = auth::resource_permission(&state, "pad", &slug, supplied_token)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let permission = resource_permission_from_tokens(
|
||||
&state,
|
||||
"pad",
|
||||
&slug,
|
||||
access_token.as_deref(),
|
||||
session_token.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let anonymous_token_ok = permission.is_none()
|
||||
&& crate::api::verify_resource_access_token(&state, "pad", &slug, supplied_token)
|
||||
&& anonymous_access_from_tokens(
|
||||
&state,
|
||||
"pad",
|
||||
&slug,
|
||||
access_token.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let password_limit_key = format!("resource-password:{client_key}:pad:{slug}");
|
||||
let password_attempted = password
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
if pad.password_hash.is_some()
|
||||
&& permission.as_deref() != Some("rw")
|
||||
&& !anonymous_token_ok
|
||||
&& password_attempted
|
||||
{
|
||||
let window = std::time::Duration::from_secs(15 * 60);
|
||||
if let Err(seconds) = state
|
||||
.check_rate_limit(format!("resource-password-client:{client_key}"), 50, window)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
{
|
||||
let _ = send_pad(
|
||||
&mut socket,
|
||||
&PadServerMessage::Error {
|
||||
message: format!(
|
||||
"Too many password attempts. Try again in {seconds} seconds."
|
||||
),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if let Err(seconds) = state
|
||||
.check_rate_limit(password_limit_key.clone(), 10, window)
|
||||
.await
|
||||
{
|
||||
let _ = send_pad(
|
||||
&mut socket,
|
||||
&PadServerMessage::Error {
|
||||
message: format!(
|
||||
"Too many password attempts. Try again in {seconds} seconds."
|
||||
),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let password_ok = db::verify_pad_password(&pad, password.as_deref());
|
||||
if password_ok {
|
||||
state.clear_rate_limit(&password_limit_key).await;
|
||||
}
|
||||
if pad.is_private != 0 && permission.is_none() && !anonymous_token_ok {
|
||||
let _ = send_pad(
|
||||
&mut socket,
|
||||
|
||||
+7
-27
@@ -4935,14 +4935,17 @@ dialog::backdrop {
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, .28);
|
||||
}
|
||||
|
||||
.page-settings-menu .public-task-toggle {
|
||||
.page-settings-menu .public-task-toggle,
|
||||
.mobile-editor-options__panel .mobile-option-check {
|
||||
min-height: 30px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 6px;
|
||||
background: #171c24;
|
||||
}
|
||||
|
||||
.page-settings-menu .public-task-toggle:hover {
|
||||
background: color-mix(in srgb, var(--surface-strong, #262b35) 72%, transparent);
|
||||
.page-settings-menu .public-task-toggle:hover,
|
||||
.mobile-editor-options__panel .mobile-option-check:hover {
|
||||
background: #242b36;
|
||||
}
|
||||
|
||||
#publish-page:disabled {
|
||||
@@ -5066,14 +5069,6 @@ dialog::backdrop {
|
||||
}
|
||||
}
|
||||
|
||||
.page-settings-menu .public-task-toggle {
|
||||
background: #171c24;
|
||||
}
|
||||
|
||||
.page-settings-menu .public-task-toggle:hover {
|
||||
background: #242b36;
|
||||
}
|
||||
|
||||
/* Shared editor display settings */
|
||||
.authorship-controls {
|
||||
display: flex;
|
||||
@@ -5437,20 +5432,5 @@ dialog::backdrop {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.mobile-option-check {
|
||||
display: flex;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--text);
|
||||
font-size: .8rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.mobile-option-check input {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -261,10 +261,10 @@
|
||||
<option value="20">20</option>
|
||||
<option value="22">22</option>
|
||||
</select></label>
|
||||
<label class="mobile-option-check"><input id="mobile-line-numbers-toggle" type="checkbox"> Editor lines</label>
|
||||
<label class="mobile-option-check"><input id="mobile-preview-line-numbers-toggle" type="checkbox"> Preview lines</label>
|
||||
<label class="mobile-option-check"><input id="mobile-compact-toggle" type="checkbox"> Compact view</label>
|
||||
<label class="mobile-option-check"><input id="mobile-line-links-toggle" type="checkbox"> Line links</label>
|
||||
<label class="public-task-toggle mobile-option-check"><input id="mobile-line-numbers-toggle" type="checkbox"> Editor lines</label>
|
||||
<label class="public-task-toggle mobile-option-check"><input id="mobile-preview-line-numbers-toggle" type="checkbox"> Preview lines</label>
|
||||
<label class="public-task-toggle mobile-option-check"><input id="mobile-compact-toggle" type="checkbox"> Compact view</label>
|
||||
<label class="public-task-toggle mobile-option-check"><input id="mobile-line-links-toggle" type="checkbox"> Line links</label>
|
||||
</div>
|
||||
</details>
|
||||
<label id="mobile-color-button" class="mobile-color-button" title="Editor color"
|
||||
|
||||
+3
-5
@@ -33,6 +33,7 @@ function formatBytes(bytes) {
|
||||
}
|
||||
|
||||
function clearExpiredSession() {
|
||||
localStorage.removeItem("rustpad:auth-state");
|
||||
localStorage.removeItem("rustpad:auth-token");
|
||||
sessionStorage.removeItem("rustpad:auth-token");
|
||||
localStorage.removeItem("rustpad:nickname");
|
||||
@@ -42,11 +43,9 @@ function clearExpiredSession() {
|
||||
}
|
||||
|
||||
async function clearSessionIfInvalid() {
|
||||
const token = localStorage.getItem("rustpad:auth-token") || sessionStorage.getItem("rustpad:auth-token");
|
||||
if (!token) return;
|
||||
if (!localStorage.getItem("rustpad:auth-state")) return;
|
||||
try {
|
||||
const response = await fetch("/api/auth/me", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (response.status === 401) clearExpiredSession();
|
||||
@@ -70,8 +69,7 @@ function validateUploadSize(body) {
|
||||
|
||||
function requestHeaders(options, body) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
const authToken = localStorage.getItem("rustpad:auth-token") || sessionStorage.getItem("rustpad:auth-token");
|
||||
if (authToken && !headers.has("authorization")) headers.set("authorization", `Bearer ${authToken}`);
|
||||
headers.delete("x-rustpad-user-token");
|
||||
if (!(body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||
return headers;
|
||||
}
|
||||
|
||||
+9
-11
@@ -13,8 +13,10 @@ import { askConfirm, askInput, showMessage } from "@rustpad/modal";
|
||||
|
||||
const { getAuthToken, setAuthSession, setNickname } = sessionStore;
|
||||
const clearAuthSession = sessionStore.clearAuthSession || (() => {
|
||||
localStorage.removeItem("rustpad:auth-state");
|
||||
localStorage.removeItem("rustpad:auth-token");
|
||||
sessionStorage.removeItem("rustpad:auth-token");
|
||||
localStorage.removeItem("rustpad:nickname");
|
||||
sessionStorage.removeItem("rustpad:nickname");
|
||||
});
|
||||
|
||||
@@ -287,7 +289,7 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
|
||||
return;
|
||||
}
|
||||
nickname.disabled = false;
|
||||
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname: name, session_token: getAuthToken() || null }) });
|
||||
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname: name }) });
|
||||
setNickname(result.nickname);
|
||||
await onIdentity(result.nickname, null);
|
||||
dialog.close();
|
||||
@@ -305,25 +307,21 @@ function bindLegacyIdentityDialog({ dialog, onIdentity }) {
|
||||
}
|
||||
|
||||
export async function validateCurrentSession() {
|
||||
const token = getAuthToken();
|
||||
if (!token) return null;
|
||||
const expectedSession = Boolean(getAuthToken());
|
||||
try {
|
||||
const session = await api("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } });
|
||||
const session = await api("/api/auth/me");
|
||||
setAuthSession(session);
|
||||
return session;
|
||||
} catch {
|
||||
clearAuthSession();
|
||||
if (expectedSession) clearAuthSession();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutCurrentSession() {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
try {
|
||||
await api("/api/auth/logout", { method: "POST", headers: { Authorization: `Bearer ${token}` } });
|
||||
} catch { }
|
||||
}
|
||||
try {
|
||||
await api("/api/auth/logout", { method: "POST" });
|
||||
} catch { }
|
||||
clearAuthSession();
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -62,7 +62,7 @@ document.querySelector("#pad-form").addEventListener("submit", async (event) =>
|
||||
const payload = { name: name.value.trim() };
|
||||
if (password.value) payload.password = password.value;
|
||||
const result = await api("/api/pads", { method: "POST", headers: authHeaders(), body: JSON.stringify(payload) });
|
||||
if (password.value) { const grant = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "pad", slug: result.slug, password: password.value }) }); setAccessToken("pad", result.slug, grant.access_token); }
|
||||
if (password.value) { const grant = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "pad", slug: result.slug, password: password.value }) }); setAccessToken("pad", result.slug, grant.granted); }
|
||||
window.location.assign(safeAppUrl(`${result.url}?view=split&mode=markdown`));
|
||||
} catch (requestError) {
|
||||
error.textContent = requestError.message;
|
||||
@@ -83,7 +83,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even
|
||||
const payload = { name: name.value.trim() };
|
||||
if (password.value) payload.password = password.value;
|
||||
const result = await api("/api/workspaces", { method: "POST", headers: authHeaders(), body: JSON.stringify(payload) });
|
||||
if (password.value) { const grant = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "workspace", slug: result.slug, password: password.value }) }); setAccessToken("workspace", result.slug, grant.access_token); }
|
||||
if (password.value) { const grant = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "workspace", slug: result.slug, password: password.value }) }); setAccessToken("workspace", result.slug, grant.granted); }
|
||||
window.location.assign(safeAppUrl(result.url));
|
||||
} catch (requestError) {
|
||||
error.textContent = requestError.message;
|
||||
@@ -110,7 +110,7 @@ const profileDialog = document.querySelector("#profile-dialog");
|
||||
const profileForm = document.querySelector("#profile-form");
|
||||
let currentSession = null;
|
||||
|
||||
function authHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
|
||||
function authHeaders() { return {}; }
|
||||
function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; }
|
||||
function shareExpiry(hours, forever) { if (forever) return null; const value = Number(hours); if (!Number.isFinite(value) || value <= 0 || value > 87600) throw new Error("Enter a validity between 1 and 87600 hours."); return new Date(Date.now() + value * 3600000).toISOString(); }
|
||||
function formatShareExpiry(value) { if (!value) return "Never expires"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Expires ${date.toLocaleString()}`; }
|
||||
|
||||
+12
-16
@@ -29,7 +29,7 @@ export function startNoteEditor(adapter) {
|
||||
let unreadChat = 0;
|
||||
const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
|
||||
const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle");
|
||||
const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken);
|
||||
const shareToken = new URLSearchParams(location.search).get("share");
|
||||
const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`;
|
||||
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "";
|
||||
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false;
|
||||
@@ -113,13 +113,11 @@ export function startNoteEditor(adapter) {
|
||||
}
|
||||
function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); const pickerColor = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; const overridden = Boolean(noteUserColor()); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); currentUser.title = overridden ? "Note color override" : "Global profile color"; userColorPicker.value = pickerColor; if (mobileColorPicker) mobileColorPicker.value = pickerColor; useGlobalColorButton.hidden = !overridden; document.querySelector(".mobile-editor-bubble")?.style.setProperty("--owner", color); }
|
||||
function sessionHeaders() {
|
||||
const userToken = getAuthToken();
|
||||
const resourceToken = accessToken || userToken;
|
||||
const headers = resourceToken ? { Authorization: `Bearer ${resourceToken}` } : {};
|
||||
if (userToken && userToken !== resourceToken) headers["X-RustPad-User-Token"] = userToken;
|
||||
return headers;
|
||||
return accessToken && accessToken !== "cookie"
|
||||
? { Authorization: `Bearer ${accessToken}` }
|
||||
: {};
|
||||
}
|
||||
function accountHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
|
||||
function accountHeaders() { return {}; }
|
||||
async function loadNoteInfo() {
|
||||
info = await adapter.loadInfo(sessionHeaders());
|
||||
globalColor = info.global_color || ""; noteColor = info.note_color || "";
|
||||
@@ -619,8 +617,8 @@ export function startNoteEditor(adapter) {
|
||||
onFilesChanged: files => updateMarkdownFiles(files, { rerender: true }),
|
||||
});
|
||||
refreshFilesForAliases = () => loadFiles();
|
||||
function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { resourceUnlocked = true; if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) 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 => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
|
||||
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key) || getAuthToken(); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
|
||||
function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: null, guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { resourceUnlocked = true; if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) 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 => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
|
||||
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken && !getAuthToken()) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
|
||||
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
|
||||
async function showSystemNotFound() {
|
||||
try {
|
||||
@@ -639,23 +637,21 @@ export function startNoteEditor(adapter) {
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
if (getAuthToken()) {
|
||||
const session = await validateCurrentSession();
|
||||
nickname = session?.nickname || getNickname();
|
||||
}
|
||||
const session = await validateCurrentSession();
|
||||
nickname = session?.nickname || getNickname();
|
||||
if (!nickname) {
|
||||
if (!identityDialog.open) identityDialog.showModal();
|
||||
return;
|
||||
}
|
||||
|
||||
accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key) || getAuthToken();
|
||||
accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key);
|
||||
await loadNoteInfo();
|
||||
document.title = adapter.title(info);
|
||||
publicPageEnabled.checked = Boolean(info.public_page_enabled); publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected); updatePageControls();
|
||||
adapter.configureView?.(info);
|
||||
applyUi({ write: true, replace: true });
|
||||
updateCurrentUser();
|
||||
if (info.protected && !accessToken) passwordDialog.showModal();
|
||||
if (info.protected && !accessToken && !getAuthToken()) passwordDialog.showModal();
|
||||
else { loadFiles(); connect(); }
|
||||
} catch (e) {
|
||||
if (e.status === 403 || e.status === 404) {
|
||||
@@ -1145,7 +1141,7 @@ export function startNoteEditor(adapter) {
|
||||
document.querySelector("#open-password")?.focus();
|
||||
}
|
||||
});
|
||||
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); accessToken = result.access_token; setAccessToken(adapter.access.kind, adapter.access.key, 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 adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); 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 () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } 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 adapter.loadHistory(accessToken); 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 adapter.restoreRevision(r.id, accessToken); 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 deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast(error.message); } });
|
||||
|
||||
@@ -74,7 +74,10 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector("#upload-button").addEventListener("click", () => input.click());
|
||||
document.querySelector("#upload-button").addEventListener("click", () => {
|
||||
if (!getAuthToken()) { toast("Log in to upload files."); return; }
|
||||
input.click();
|
||||
});
|
||||
input.addEventListener("change", async event => {
|
||||
let file = event.target.files[0];
|
||||
if (!file) return;
|
||||
@@ -154,7 +157,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
|
||||
if (!deleteButton) return;
|
||||
if (!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`, { title: "Delete file", confirmText: "Delete", danger: true })) return;
|
||||
try {
|
||||
await api(endpoints.remove(deleteButton.dataset.deleteFile), { method: "DELETE", headers: getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {}, body: JSON.stringify({ access_token: getAccessToken() || null }) });
|
||||
await api(endpoints.remove(deleteButton.dataset.deleteFile), { method: "DELETE", headers: {}, body: JSON.stringify({ access_token: getAccessToken() || null }) });
|
||||
toast("File deleted");
|
||||
await loadFiles();
|
||||
} catch (error) { toast(error.message); }
|
||||
|
||||
+2
-3
@@ -19,8 +19,7 @@ const token = location.pathname.split("/").filter(Boolean)[1];
|
||||
const content = document.querySelector("#public-content");
|
||||
const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle");
|
||||
const passwordDialog = document.querySelector("#public-password-dialog"), passwordForm = document.querySelector("#public-password-form"), passwordInput = document.querySelector("#public-password"), passwordError = document.querySelector("#public-password-error");
|
||||
const passwordKey = `rustpad:public-page-password:${token}`;
|
||||
let pagePassword = sessionStorage.getItem(passwordKey) || "";
|
||||
let pagePassword = "";
|
||||
function pageHeaders() { return pagePassword ? { "X-RustPad-Page-Password": pagePassword } : {}; }
|
||||
async function renderMermaid() { const nodes = content.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
|
||||
async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } }
|
||||
@@ -51,7 +50,7 @@ content.addEventListener("click", event => {
|
||||
window.addEventListener("hashchange", () => scrollToPublicAnchor(location.hash, "smooth"));
|
||||
content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", headers: pageHeaders(), body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")} · tasks can be updated`; toast("Task saved"); } catch (error) { box.checked = previous; toast(error.message); } finally { box.disabled = false; } });
|
||||
lineNumbersToggle.addEventListener("change", () => { document.body.classList.toggle("hide-preview-line-numbers", !lineNumbersToggle.checked); });
|
||||
passwordForm.addEventListener("submit", async event => { event.preventDefault(); pagePassword = passwordInput.value; sessionStorage.setItem(passwordKey, pagePassword); await initialize(); });
|
||||
passwordForm.addEventListener("submit", async event => { event.preventDefault(); pagePassword = passwordInput.value; await initialize(); });
|
||||
passwordDialog.addEventListener("cancel", event => event.preventDefault());
|
||||
document.querySelector("#copy-public-link").addEventListener("click", async () => { try { await copyText(location.href); toast("Link copied"); } catch (error) { toast(error.message); } });
|
||||
initialize();
|
||||
|
||||
+24
-18
@@ -7,13 +7,23 @@
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
const ACCESS_STORAGE_VERSION_KEY = "rustpad:access-storage-version";
|
||||
const ACCESS_STORAGE_VERSION = "http-only-cookie-v1";
|
||||
|
||||
export function accessTokenKey(resourceKind, resourceSlug) { return `rustpad:access:${resourceKind}:${resourceSlug}`; }
|
||||
export function getAccessToken(resourceKind, resourceSlug) {
|
||||
return localStorage.getItem(accessTokenKey(resourceKind, resourceSlug)) || "";
|
||||
if (localStorage.getItem(ACCESS_STORAGE_VERSION_KEY) !== ACCESS_STORAGE_VERSION) {
|
||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith("rustpad:access:")) localStorage.removeItem(key);
|
||||
}
|
||||
localStorage.setItem(ACCESS_STORAGE_VERSION_KEY, ACCESS_STORAGE_VERSION);
|
||||
}
|
||||
export function setAccessToken(resourceKind, resourceSlug, token) {
|
||||
export function getAccessToken(resourceKind, resourceSlug) {
|
||||
return localStorage.getItem(accessTokenKey(resourceKind, resourceSlug)) ? "cookie" : "";
|
||||
}
|
||||
export function setAccessToken(resourceKind, resourceSlug, granted) {
|
||||
const key = accessTokenKey(resourceKind, resourceSlug);
|
||||
if (token) localStorage.setItem(key, token);
|
||||
if (granted) localStorage.setItem(key, "1");
|
||||
else localStorage.removeItem(key);
|
||||
}
|
||||
// Remove plaintext passwords saved by the previous frontend version.
|
||||
@@ -59,28 +69,24 @@ export function getGuestId() {
|
||||
return guestId;
|
||||
}
|
||||
|
||||
const AUTH_TOKEN_KEY = "rustpad:auth-token";
|
||||
// Account sessions must be shared by every tab on this origin. Keep a
|
||||
// compatibility fallback for sessions created by older frontend versions.
|
||||
const legacySessionToken = sessionStorage.getItem(AUTH_TOKEN_KEY);
|
||||
if (legacySessionToken && !localStorage.getItem(AUTH_TOKEN_KEY)) {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, legacySessionToken);
|
||||
}
|
||||
sessionStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
export function getAuthToken() { return localStorage.getItem(AUTH_TOKEN_KEY) || ""; }
|
||||
const AUTH_STATE_KEY = "rustpad:auth-state";
|
||||
// Remove browser-readable account tokens created by older frontend versions.
|
||||
localStorage.removeItem("rustpad:auth-token");
|
||||
sessionStorage.removeItem("rustpad:auth-token");
|
||||
export function getAuthToken() { return localStorage.getItem(AUTH_STATE_KEY) ? "cookie" : ""; }
|
||||
export function setAuthSession(session) {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, session.token);
|
||||
sessionStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
localStorage.setItem(AUTH_STATE_KEY, "1");
|
||||
setNickname(session.nickname);
|
||||
}
|
||||
export function clearAuthSession() {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
sessionStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
localStorage.removeItem(AUTH_STATE_KEY);
|
||||
localStorage.removeItem("rustpad:auth-token");
|
||||
sessionStorage.removeItem("rustpad:auth-token");
|
||||
localStorage.removeItem(NICKNAME_KEY);
|
||||
sessionStorage.removeItem(NICKNAME_KEY);
|
||||
setNicknameCookie("");
|
||||
}
|
||||
export async function resolveIdentity(api, nickname) {
|
||||
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname, session_token: getAuthToken() || null }) });
|
||||
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname }) });
|
||||
setNickname(result.nickname); return result;
|
||||
}
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ class RoomSocket {
|
||||
this.socket = new WebSocket(this.url);
|
||||
this.socket.addEventListener("open", () => {
|
||||
logInfo("websocket.open", { kind: this.kind });
|
||||
this.send({ type: "authenticate", password: this.password || null, access_token: this.accessToken || null, nickname: this.nickname || null, session_token: this.sessionToken || null, guest_id: this.guestId || null, color: this.color || null });
|
||||
this.send({ type: "authenticate", password: this.password || null, access_token: this.accessToken || null, nickname: this.nickname || null, guest_id: this.guestId || null, color: this.color || null });
|
||||
});
|
||||
this.socket.addEventListener("message", event => {
|
||||
let message;
|
||||
|
||||
+8
-12
@@ -24,7 +24,6 @@ let info;
|
||||
const shareToken = new URLSearchParams(location.search).get("share");
|
||||
let accessToken = shareToken || getAccessToken("workspace", slug);
|
||||
let nickname = getNickname();
|
||||
if (shareToken) setAccessToken("workspace", slug, shareToken);
|
||||
const dialog = document.querySelector("#password-dialog");
|
||||
const identityDialog = document.querySelector("#identity-dialog");
|
||||
const workspaceContent = document.querySelector("#workspace-content");
|
||||
@@ -136,11 +135,11 @@ async function openWorkspace() {
|
||||
}
|
||||
async function init() {
|
||||
try {
|
||||
const headers = accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
|
||||
const headers = accessToken && accessToken !== "cookie" ? { Authorization: `Bearer ${accessToken}` } : {};
|
||||
info = await api(`/api/workspaces/${encodeURIComponent(slug)}`, { headers });
|
||||
document.querySelector("#workspace-title").textContent = info.title;
|
||||
document.querySelector("#workspace-url").textContent = location.pathname;
|
||||
if (info.protected && !accessToken) dialog.showModal(); else openWorkspace();
|
||||
if (info.protected && !accessToken && !getAuthToken()) dialog.showModal(); else openWorkspace();
|
||||
} catch (e) {
|
||||
if (e.status === 403 || e.status === 404) await showSystemNotFound();
|
||||
else document.querySelector("#workspace-error").textContent = e.message;
|
||||
@@ -151,8 +150,8 @@ document.querySelector("#password-form").addEventListener("submit", async e => {
|
||||
try {
|
||||
const password = document.querySelector("#open-password").value;
|
||||
const result = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "workspace", slug, password }) });
|
||||
accessToken = result.access_token;
|
||||
setAccessToken("workspace", slug, accessToken);
|
||||
setAccessToken("workspace", slug, result.granted);
|
||||
accessToken = getAccessToken("workspace", slug);
|
||||
document.querySelector("#open-password").value = "";
|
||||
document.querySelector("#password-error").textContent = "";
|
||||
openWorkspace();
|
||||
@@ -195,11 +194,8 @@ document.querySelector("#copy-workspace-link").addEventListener("click", async (
|
||||
catch (e) { toast(e.message); }
|
||||
});
|
||||
async function startAuthorizedWorkspace() {
|
||||
const hadAccountToken = Boolean(getAuthToken());
|
||||
if (hadAccountToken) {
|
||||
const session = await validateCurrentSession();
|
||||
nickname = session?.nickname || getNickname();
|
||||
}
|
||||
const session = await validateCurrentSession();
|
||||
nickname = session?.nickname || getNickname();
|
||||
|
||||
if (!nickname) {
|
||||
workspaceContent.hidden = true;
|
||||
@@ -207,7 +203,7 @@ async function startAuthorizedWorkspace() {
|
||||
return;
|
||||
}
|
||||
|
||||
accessToken = shareToken || getAuthToken() || getAccessToken("workspace", slug);
|
||||
accessToken = shareToken || getAccessToken("workspace", slug);
|
||||
workspaceContent.hidden = false;
|
||||
await init();
|
||||
}
|
||||
@@ -216,7 +212,7 @@ bindIdentityDialog({
|
||||
dialog: identityDialog,
|
||||
onIdentity: async value => {
|
||||
nickname = value;
|
||||
accessToken = shareToken || getAuthToken() || getAccessToken("workspace", slug);
|
||||
accessToken = shareToken || getAccessToken("workspace", slug);
|
||||
workspaceContent.hidden = false;
|
||||
await init();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user