Compare commits
2
Commits
13c1aba161
...
e3ee6319b9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3ee6319b9 | ||
|
|
49dad1a5f4 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustpad"
|
||||
version = "0.2.21"
|
||||
version = "0.2.23"
|
||||
edition = "2024"
|
||||
rust-version = "1.94"
|
||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
UPDATE resource_share_links SET token = NULL;
|
||||
CREATE TABLE resource_share_sessions (
|
||||
session_token_hash VARCHAR(64) PRIMARY KEY,
|
||||
share_token_hash VARCHAR(64) NOT NULL,
|
||||
resource_kind VARCHAR(16) NOT NULL,
|
||||
resource_slug VARCHAR(255) NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
CONSTRAINT fk_resource_share_sessions_link FOREIGN KEY(share_token_hash) REFERENCES resource_share_links(token_hash) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_resource_share_sessions_link ON resource_share_sessions(share_token_hash);
|
||||
CREATE INDEX idx_resource_share_sessions_expiry ON resource_share_sessions(expires_at(32));
|
||||
@@ -0,0 +1,11 @@
|
||||
UPDATE resource_share_links SET token = NULL;
|
||||
CREATE TABLE resource_share_sessions (
|
||||
session_token_hash TEXT PRIMARY KEY,
|
||||
share_token_hash TEXT NOT NULL REFERENCES resource_share_links(token_hash) ON DELETE CASCADE,
|
||||
resource_kind TEXT NOT NULL,
|
||||
resource_slug TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text)
|
||||
);
|
||||
CREATE INDEX idx_resource_share_sessions_link ON resource_share_sessions(share_token_hash);
|
||||
CREATE INDEX idx_resource_share_sessions_expiry ON resource_share_sessions(expires_at);
|
||||
@@ -0,0 +1,11 @@
|
||||
UPDATE resource_share_links SET token = NULL;
|
||||
CREATE TABLE resource_share_sessions (
|
||||
session_token_hash TEXT PRIMARY KEY,
|
||||
share_token_hash TEXT NOT NULL REFERENCES resource_share_links(token_hash) ON DELETE CASCADE,
|
||||
resource_kind TEXT NOT NULL,
|
||||
resource_slug TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_resource_share_sessions_link ON resource_share_sessions(share_token_hash);
|
||||
CREATE INDEX idx_resource_share_sessions_expiry ON resource_share_sessions(expires_at);
|
||||
@@ -105,13 +105,25 @@ 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::share_link_permission(state, kind, slug, Some(token))
|
||||
if crate::auth::share_access_permission(state, kind, slug, Some(token))
|
||||
.await
|
||||
.map_err(|error| ApiError::forbidden(&error.message))?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
verify_password_access_token(state, kind, slug, Some(token)).await
|
||||
}
|
||||
|
||||
pub(crate) async fn verify_password_access_token(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<bool, ApiError> {
|
||||
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let count: i64 = sqlx::query_scalar(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT,
|
||||
|
||||
+6
-3
@@ -66,8 +66,9 @@ pub async fn upload_pad_file(
|
||||
{
|
||||
AccessLevel::Write
|
||||
} else {
|
||||
combined_token_access_level(
|
||||
request_access_level(
|
||||
&state,
|
||||
&headers,
|
||||
"pad",
|
||||
&slug,
|
||||
resource_request_token(&headers, "pad", &slug, access_token.as_deref()),
|
||||
@@ -306,8 +307,9 @@ pub async fn upload_note_file(
|
||||
{
|
||||
AccessLevel::Write
|
||||
} else {
|
||||
combined_token_access_level(
|
||||
request_access_level(
|
||||
&state,
|
||||
&headers,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
resource_request_token(&headers, "workspace", &workspace_slug, access_token.as_deref()),
|
||||
@@ -387,8 +389,9 @@ pub async fn delete_note(
|
||||
{
|
||||
AccessLevel::Write
|
||||
} else {
|
||||
combined_token_access_level(
|
||||
request_access_level(
|
||||
&state,
|
||||
&headers,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()),
|
||||
|
||||
+117
-35
@@ -119,23 +119,19 @@ async fn has_write_permission(
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
) -> Result<bool, ApiError> {
|
||||
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 authorization = authorization_token(headers);
|
||||
if authorization != resource
|
||||
&& external_token_access_level(state, kind, slug, authorization).await?
|
||||
if request_access_level(
|
||||
state,
|
||||
headers,
|
||||
kind,
|
||||
slug,
|
||||
None,
|
||||
crate::security::session_cookie_token(headers),
|
||||
)
|
||||
.await?
|
||||
>= AccessLevel::Write
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
let session = crate::security::session_cookie_token(headers);
|
||||
if session != resource && session != authorization {
|
||||
if account_token_access_level(state, kind, slug, session).await? >= AccessLevel::Write {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
match kind {
|
||||
"workspace" => Ok(db::find_workspace(&state.db, slug)
|
||||
.await?
|
||||
@@ -275,6 +271,7 @@ pub struct WorkspaceInfo {
|
||||
slug: String,
|
||||
title: String,
|
||||
protected: bool,
|
||||
access_level: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
@@ -332,6 +329,7 @@ pub struct NoteInfo {
|
||||
slug: String,
|
||||
title: String,
|
||||
protected: bool,
|
||||
access_level: String,
|
||||
note_protected: bool,
|
||||
allow_public_task_updates: bool,
|
||||
public_page_unprotected: bool,
|
||||
@@ -570,7 +568,16 @@ pub async fn workspace_info(
|
||||
workspace.is_private,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(workspace_info_from(&workspace)))
|
||||
let access_level = effective_header_access_level(
|
||||
&state,
|
||||
&headers,
|
||||
"workspace",
|
||||
&workspace.slug,
|
||||
workspace.is_private,
|
||||
workspace.password_hash.is_some(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(workspace_info_from(&workspace, access_level)))
|
||||
}
|
||||
|
||||
pub async fn open_workspace(
|
||||
@@ -636,8 +643,20 @@ pub async fn open_workspace(
|
||||
let start = (page - 1) * per_page;
|
||||
let notes = notes.into_iter().skip(start).take(per_page).collect();
|
||||
|
||||
let mut access_level = effective_header_access_level(
|
||||
&state,
|
||||
&headers,
|
||||
"workspace",
|
||||
&workspace.slug,
|
||||
workspace.is_private,
|
||||
workspace.password_hash.is_some(),
|
||||
)
|
||||
.await?;
|
||||
if db::verify_workspace_password(&workspace, payload.password.as_deref()) {
|
||||
access_level = AccessLevel::Write;
|
||||
}
|
||||
Ok(Json(WorkspaceOpenResponse {
|
||||
workspace: workspace_info_from(&workspace),
|
||||
workspace: workspace_info_from(&workspace, access_level),
|
||||
notes,
|
||||
pagination: ListPaginationMeta { page, per_page, total, total_pages },
|
||||
}))
|
||||
@@ -668,8 +687,9 @@ pub async fn create_note(
|
||||
{
|
||||
AccessLevel::Write
|
||||
} else {
|
||||
combined_token_access_level(
|
||||
request_access_level(
|
||||
&state,
|
||||
&headers,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
resource_request_token(
|
||||
@@ -845,6 +865,15 @@ pub async fn note_info(
|
||||
workspace.is_private,
|
||||
)
|
||||
.await?;
|
||||
let access_level = effective_header_access_level(
|
||||
&state,
|
||||
&headers,
|
||||
"workspace",
|
||||
&workspace.slug,
|
||||
workspace.is_private,
|
||||
workspace.password_hash.is_some(),
|
||||
)
|
||||
.await?;
|
||||
let note = db::find_note(&state.db, workspace.id, ¬e_slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
@@ -888,6 +917,7 @@ pub async fn note_info(
|
||||
slug: note.slug,
|
||||
title: note.title,
|
||||
protected: workspace.password_hash.is_some(),
|
||||
access_level: access_level_name(access_level).into(),
|
||||
note_protected: note.protected,
|
||||
allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?,
|
||||
public_page_unprotected: db::note_public_page_unprotected(&state.db, note.id).await?,
|
||||
@@ -1001,8 +1031,9 @@ pub async fn restore(
|
||||
{
|
||||
AccessLevel::Write
|
||||
} else {
|
||||
combined_token_access_level(
|
||||
request_access_level(
|
||||
&state,
|
||||
&headers,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
resource_request_token(
|
||||
@@ -1105,7 +1136,7 @@ async fn external_token_access_level(
|
||||
slug: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<AccessLevel, ApiError> {
|
||||
let permission = crate::auth::share_link_permission(state, kind, slug, token)
|
||||
let permission = crate::auth::share_access_permission(state, kind, slug, token)
|
||||
.await
|
||||
.map_err(|error| ApiError::forbidden(&error.message))?;
|
||||
let level = permission_level(permission.as_deref());
|
||||
@@ -1132,17 +1163,73 @@ async fn account_token_access_level(
|
||||
Ok(permission_level(permission.as_deref()))
|
||||
}
|
||||
|
||||
async fn combined_token_access_level(
|
||||
async fn request_access_level(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
access_token: Option<&str>,
|
||||
supplied_access_token: Option<&str>,
|
||||
account_token: Option<&str>,
|
||||
) -> Result<AccessLevel, ApiError> {
|
||||
Ok(std::cmp::max(
|
||||
external_token_access_level(state, kind, slug, access_token).await?,
|
||||
account_token_access_level(state, kind, slug, account_token).await?,
|
||||
))
|
||||
let mut level = account_token_access_level(state, kind, slug, account_token).await?;
|
||||
if level == AccessLevel::Write {
|
||||
return Ok(level);
|
||||
}
|
||||
|
||||
let mut checked_tokens = Vec::with_capacity(4);
|
||||
for token in [
|
||||
supplied_access_token,
|
||||
crate::security::share_session_token(headers, kind, slug),
|
||||
crate::security::resource_token(headers, kind, slug),
|
||||
authorization_token(headers),
|
||||
] {
|
||||
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
if checked_tokens.contains(&token) {
|
||||
continue;
|
||||
}
|
||||
checked_tokens.push(token);
|
||||
level = std::cmp::max(
|
||||
level,
|
||||
external_token_access_level(state, kind, slug, Some(token)).await?,
|
||||
);
|
||||
if level == AccessLevel::Write {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(level)
|
||||
}
|
||||
|
||||
fn access_level_name(level: AccessLevel) -> &'static str {
|
||||
match level {
|
||||
AccessLevel::None => "none",
|
||||
AccessLevel::Read => "read",
|
||||
AccessLevel::Write => "write",
|
||||
}
|
||||
}
|
||||
|
||||
async fn effective_header_access_level(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
is_private: i64,
|
||||
password_protected: bool,
|
||||
) -> Result<AccessLevel, ApiError> {
|
||||
let mut level = request_access_level(
|
||||
state,
|
||||
headers,
|
||||
kind,
|
||||
slug,
|
||||
None,
|
||||
bearer_token(headers),
|
||||
)
|
||||
.await?;
|
||||
if is_private == 0 && !password_protected {
|
||||
level = std::cmp::max(level, AccessLevel::Write);
|
||||
}
|
||||
Ok(level)
|
||||
}
|
||||
|
||||
fn require_write(level: AccessLevel) -> Result<(), ApiError> {
|
||||
@@ -1159,18 +1246,12 @@ async fn has_header_resource_access(
|
||||
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(
|
||||
Ok(request_access_level(
|
||||
state,
|
||||
headers,
|
||||
kind,
|
||||
slug,
|
||||
None,
|
||||
crate::security::session_cookie_token(headers),
|
||||
)
|
||||
.await?
|
||||
@@ -1241,7 +1322,7 @@ pub async fn authorized_workspace(
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_workspace)?;
|
||||
let token_level =
|
||||
combined_token_access_level(state, "workspace", slug, access_token, bearer).await?;
|
||||
request_access_level(state, headers, "workspace", slug, access_token, bearer).await?;
|
||||
if workspace.is_private != 0 && token_level == AccessLevel::None {
|
||||
return Err(ApiError::not_found_workspace());
|
||||
}
|
||||
@@ -1280,11 +1361,12 @@ async fn authorized_note(
|
||||
Ok((workspace, note))
|
||||
}
|
||||
|
||||
fn workspace_info_from(workspace: &db::Workspace) -> WorkspaceInfo {
|
||||
fn workspace_info_from(workspace: &db::Workspace, access_level: AccessLevel) -> WorkspaceInfo {
|
||||
WorkspaceInfo {
|
||||
slug: workspace.slug.clone(),
|
||||
title: workspace.title.clone(),
|
||||
protected: workspace.password_hash.is_some(),
|
||||
access_level: access_level_name(access_level).into(),
|
||||
created_at: db::normalize_timestamp(&workspace.created_at),
|
||||
updated_at: db::normalize_timestamp(&workspace.updated_at),
|
||||
}
|
||||
|
||||
+18
-4
@@ -29,6 +29,7 @@ pub struct PadInfo {
|
||||
slug: String,
|
||||
title: String,
|
||||
protected: bool,
|
||||
access_level: String,
|
||||
allow_public_task_updates: bool,
|
||||
public_page_unprotected: bool,
|
||||
public_page_enabled: bool,
|
||||
@@ -106,6 +107,15 @@ pub async fn pad_info(
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
ensure_private_resource_access(&state, &headers, "pad", &pad.slug, pad.is_private).await?;
|
||||
let access_level = effective_header_access_level(
|
||||
&state,
|
||||
&headers,
|
||||
"pad",
|
||||
&pad.slug,
|
||||
pad.is_private,
|
||||
pad.password_hash.is_some(),
|
||||
)
|
||||
.await?;
|
||||
let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?;
|
||||
let (editor_preferences, personal_editor_settings) =
|
||||
user_editor_preferences(&state, &headers, db::EditorPreferenceResource::Pad(pad.id))
|
||||
@@ -133,6 +143,7 @@ pub async fn pad_info(
|
||||
slug: pad.slug,
|
||||
title: pad.title,
|
||||
protected: pad.password_hash.is_some(),
|
||||
access_level: access_level_name(access_level).into(),
|
||||
allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?,
|
||||
public_page_unprotected: db::pad_public_page_unprotected(&state.db, pad.id).await?,
|
||||
public_page_enabled: db::pad_public_page_enabled(&state.db, pad.id).await?,
|
||||
@@ -256,8 +267,9 @@ pub async fn publish_pad_page(
|
||||
{
|
||||
AccessLevel::Write
|
||||
} else {
|
||||
combined_token_access_level(
|
||||
request_access_level(
|
||||
&state,
|
||||
&headers,
|
||||
"pad",
|
||||
&slug,
|
||||
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
||||
@@ -311,8 +323,9 @@ pub async fn publish_note_page(
|
||||
{
|
||||
AccessLevel::Write
|
||||
} else {
|
||||
combined_token_access_level(
|
||||
request_access_level(
|
||||
&state,
|
||||
&headers,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
resource_request_token(
|
||||
@@ -527,8 +540,9 @@ pub async fn pad_restore(
|
||||
{
|
||||
AccessLevel::Write
|
||||
} else {
|
||||
combined_token_access_level(
|
||||
request_access_level(
|
||||
&state,
|
||||
&headers,
|
||||
"pad",
|
||||
&slug,
|
||||
resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()),
|
||||
@@ -577,7 +591,7 @@ pub(super) async fn authorized_pad(
|
||||
let pad = db::find_pad(&state.db, slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
let token_level = combined_token_access_level(state, "pad", slug, access_token, bearer).await?;
|
||||
let token_level = request_access_level(state, headers, "pad", slug, access_token, bearer).await?;
|
||||
if pad.is_private != 0 && token_level == AccessLevel::None {
|
||||
return Err(ApiError::not_found_note());
|
||||
}
|
||||
|
||||
+21
-2
@@ -19,11 +19,30 @@ use axum::{
|
||||
};
|
||||
use pages::*;
|
||||
use tower::{ServiceBuilder, service_fn};
|
||||
use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer};
|
||||
use tower_http::{
|
||||
services::ServeDir,
|
||||
set_header::SetResponseHeaderLayer,
|
||||
trace::{MakeSpan, TraceLayer},
|
||||
};
|
||||
use tracing::Span;
|
||||
|
||||
use crate::{api, auth, state::SharedState, websocket};
|
||||
use std::convert::Infallible;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct PathOnlyMakeSpan;
|
||||
|
||||
impl<B> MakeSpan<B> for PathOnlyMakeSpan {
|
||||
fn make_span(&mut self, request: &axum::http::Request<B>) -> Span {
|
||||
tracing::info_span!(
|
||||
"http_request",
|
||||
method = %request.method(),
|
||||
path = %request.uri().path(),
|
||||
version = ?request.version(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router(
|
||||
state: SharedState,
|
||||
static_dir: &str,
|
||||
@@ -208,7 +227,7 @@ pub fn router(
|
||||
.layer(DefaultBodyLimit::max(
|
||||
upload_max_size_bytes.saturating_add(1024 * 1024),
|
||||
))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan))
|
||||
.layer(middleware::from_fn(require_csrf_token))
|
||||
.layer(middleware::from_fn(apply_response_header_policy))
|
||||
.with_state(state)
|
||||
|
||||
+196
-8
@@ -8,12 +8,123 @@
|
||||
*/
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderValue, StatusCode, header},
|
||||
extract::{OriginalUri, Path, RawQuery, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, Uri, header},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
|
||||
use crate::{assets, db, state::SharedState};
|
||||
use crate::{assets, auth, db, state::SharedState};
|
||||
|
||||
fn decode_query_component(value: &str) -> Option<String> {
|
||||
let bytes = value.as_bytes();
|
||||
let mut decoded = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
match bytes[index] {
|
||||
b'%' if index + 2 < bytes.len() => {
|
||||
let high = (bytes[index + 1] as char).to_digit(16)? as u8;
|
||||
let low = (bytes[index + 2] as char).to_digit(16)? as u8;
|
||||
decoded.push((high << 4) | low);
|
||||
index += 3;
|
||||
}
|
||||
b'%' => return None,
|
||||
b'+' => {
|
||||
decoded.push(b' ');
|
||||
index += 1;
|
||||
}
|
||||
byte => {
|
||||
decoded.push(byte);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8(decoded).ok()
|
||||
}
|
||||
|
||||
fn share_token_from_query(query: Option<&str>) -> (bool, Option<String>) {
|
||||
let Some(query) = query else {
|
||||
return (false, None);
|
||||
};
|
||||
for field in query.split('&') {
|
||||
let (name, value) = field.split_once('=').unwrap_or((field, ""));
|
||||
if decode_query_component(name).as_deref() == Some("share") {
|
||||
return (true, decode_query_component(value));
|
||||
}
|
||||
}
|
||||
(false, None)
|
||||
}
|
||||
|
||||
fn canonical_resource_url(uri: &Uri) -> String {
|
||||
let remaining_query = uri.query().map(|query| {
|
||||
query
|
||||
.split('&')
|
||||
.filter(|field| {
|
||||
let name = field.split_once('=').map_or(*field, |(name, _)| name);
|
||||
decode_query_component(name).as_deref() != Some("share")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
});
|
||||
match remaining_query.as_deref().filter(|query| !query.is_empty()) {
|
||||
Some(query) => format!("{}?{query}", uri.path()),
|
||||
None => uri.path().to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn share_session_redirect(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
uri: &Uri,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
share: Option<&str>,
|
||||
) -> Response {
|
||||
let client_key = crate::security::client_key(headers);
|
||||
let cookie = match share.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
Some(share) => match auth::create_share_session(
|
||||
state,
|
||||
kind,
|
||||
slug,
|
||||
share,
|
||||
crate::security::share_session_token(headers, kind, slug),
|
||||
&client_key,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(session)) => Some(crate::security::share_session_cookie(
|
||||
kind,
|
||||
slug,
|
||||
&session.token,
|
||||
session.max_age_seconds,
|
||||
)),
|
||||
Ok(None) => None,
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error.message, kind, slug, "failed to exchange share link for guest session");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut response = StatusCode::SEE_OTHER.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::LOCATION,
|
||||
HeaderValue::from_str(&canonical_resource_url(uri))
|
||||
.expect("request URI is a valid redirect location"),
|
||||
);
|
||||
if let Some(cookie) = cookie {
|
||||
response.headers_mut().insert(header::SET_COOKIE, cookie);
|
||||
}
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-store, max-age=0"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"referrer-policy",
|
||||
HeaderValue::from_static("no-referrer"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
fn render_editor_page(
|
||||
state: &SharedState,
|
||||
@@ -153,9 +264,28 @@ pub(super) async fn home(State(state): State<SharedState>) -> Response {
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn pad(State(state): State<SharedState>, Path(slug): Path<String>) -> Response {
|
||||
pub(super) async fn pad(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Path(slug): Path<String>,
|
||||
RawQuery(query): RawQuery,
|
||||
OriginalUri(uri): OriginalUri,
|
||||
) -> Response {
|
||||
match db::find_pad(&state.db, &slug).await {
|
||||
Ok(Some(pad)) => render_editor_page(
|
||||
Ok(Some(pad)) => {
|
||||
let (has_share, share) = share_token_from_query(query.as_deref());
|
||||
if has_share {
|
||||
return share_session_redirect(
|
||||
&state,
|
||||
&headers,
|
||||
&uri,
|
||||
"pad",
|
||||
&slug,
|
||||
share.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
render_editor_page(
|
||||
&state,
|
||||
"pad",
|
||||
"pad",
|
||||
@@ -165,7 +295,8 @@ pub(super) async fn pad(State(state): State<SharedState>, Path(slug): Path<Strin
|
||||
"home-brand",
|
||||
"note",
|
||||
"<kbd>Alt+Enter</kbd><span>New line while editing Preview</span><kbd>Esc</kbd><span>Edit raw Markdown of current Preview line</span>",
|
||||
),
|
||||
)
|
||||
}
|
||||
Ok(None) => error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
@@ -214,10 +345,25 @@ pub(super) async fn public_page(
|
||||
|
||||
pub(super) async fn workspace(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Path(workspace_slug): Path<String>,
|
||||
RawQuery(query): RawQuery,
|
||||
OriginalUri(uri): OriginalUri,
|
||||
) -> Response {
|
||||
match db::find_workspace(&state.db, &workspace_slug).await {
|
||||
Ok(Some(workspace)) => {
|
||||
let (has_share, share) = share_token_from_query(query.as_deref());
|
||||
if has_share {
|
||||
return share_session_redirect(
|
||||
&state,
|
||||
&headers,
|
||||
&uri,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
share.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let html = include_str!("../../static/workspace.html").replace(
|
||||
"__WORKSPACE_TITLE__",
|
||||
&escape_html(if workspace.is_private != 0 {
|
||||
@@ -254,7 +400,10 @@ pub(super) async fn workspace(
|
||||
|
||||
pub(super) async fn note(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Path((workspace_slug, note_slug)): Path<(String, String)>,
|
||||
RawQuery(query): RawQuery,
|
||||
OriginalUri(uri): OriginalUri,
|
||||
) -> Response {
|
||||
let workspace = match db::find_workspace(&state.db, &workspace_slug).await {
|
||||
Ok(Some(workspace)) => workspace,
|
||||
@@ -276,7 +425,20 @@ pub(super) async fn note(
|
||||
};
|
||||
|
||||
match db::find_note(&state.db, workspace.id, ¬e_slug).await {
|
||||
Ok(Some(note)) => render_editor_page(
|
||||
Ok(Some(note)) => {
|
||||
let (has_share, share) = share_token_from_query(query.as_deref());
|
||||
if has_share {
|
||||
return share_session_redirect(
|
||||
&state,
|
||||
&headers,
|
||||
&uri,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
share.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
render_editor_page(
|
||||
&state,
|
||||
"note",
|
||||
"note",
|
||||
@@ -294,7 +456,8 @@ pub(super) async fn note(
|
||||
"",
|
||||
"workspace",
|
||||
"",
|
||||
),
|
||||
)
|
||||
}
|
||||
Ok(None) => error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
@@ -400,3 +563,28 @@ fn escape_html(value: &str) -> String {
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn share_exchange_redirects_to_canonical_resource_path() {
|
||||
let uri: Uri = "/w/private?view=all&share=secret&page=2".parse().unwrap();
|
||||
assert_eq!(canonical_resource_url(&uri), "/w/private?view=all&page=2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_or_repeated_parameters_are_parsed_without_rejection() {
|
||||
let uri: Uri = "/w/private?%73hare=one&share=two".parse().unwrap();
|
||||
assert_eq!(share_token_from_query(uri.query()), (true, Some("one".into())));
|
||||
assert_eq!(canonical_resource_url(&uri), "/w/private");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_share_parameter_is_still_removed_from_the_url() {
|
||||
let uri: Uri = "/w/private?share=%ZZ&keep=no".parse().unwrap();
|
||||
assert_eq!(share_token_from_query(uri.query()), (true, None));
|
||||
assert_eq!(canonical_resource_url(&uri), "/w/private?keep=no");
|
||||
}
|
||||
}
|
||||
|
||||
+307
-23
@@ -149,7 +149,6 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for SharingUserRow {
|
||||
#[derive(Debug)]
|
||||
struct SharingLinkRow {
|
||||
token_hash: String,
|
||||
token: Option<String>,
|
||||
permission: String,
|
||||
expires_at: Option<String>,
|
||||
created_at: String,
|
||||
@@ -159,14 +158,68 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for SharingLinkRow {
|
||||
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
token_hash: crate::row_decode::text(row, 0)?,
|
||||
token: crate::row_decode::optional_text(row, 1)?,
|
||||
permission: crate::row_decode::text(row, 2)?,
|
||||
expires_at: crate::row_decode::optional_text(row, 3)?,
|
||||
created_at: crate::row_decode::text(row, 4)?,
|
||||
permission: crate::row_decode::text(row, 1)?,
|
||||
expires_at: crate::row_decode::optional_text(row, 2)?,
|
||||
created_at: crate::row_decode::text(row, 3)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ShareLinkSessionSource {
|
||||
token_hash: String,
|
||||
permission: String,
|
||||
expires_at: Option<String>,
|
||||
}
|
||||
|
||||
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareLinkSessionSource {
|
||||
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
token_hash: crate::row_decode::text(row, 0)?,
|
||||
permission: crate::row_decode::text(row, 1)?,
|
||||
expires_at: crate::row_decode::optional_text(row, 2)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ShareLinkPermissionRow {
|
||||
permission: String,
|
||||
expires_at: Option<String>,
|
||||
}
|
||||
|
||||
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareLinkPermissionRow {
|
||||
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
permission: crate::row_decode::text(row, 0)?,
|
||||
expires_at: crate::row_decode::optional_text(row, 1)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ShareSessionPermissionRow {
|
||||
permission: String,
|
||||
session_expires_at: String,
|
||||
link_expires_at: Option<String>,
|
||||
}
|
||||
|
||||
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareSessionPermissionRow {
|
||||
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
permission: crate::row_decode::text(row, 0)?,
|
||||
session_expires_at: crate::row_decode::text(row, 1)?,
|
||||
link_expires_at: crate::row_decode::optional_text(row, 2)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ShareSession {
|
||||
pub token: String,
|
||||
pub max_age_seconds: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PendingShareRow {
|
||||
email: String,
|
||||
@@ -1681,7 +1734,7 @@ pub async fn resource_sharing(
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
Ok(Json(
|
||||
serde_json::json!({"users":users.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission})).collect::<Vec<_>>(), "pending":pending.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission,"expires_at":row.expires_at})).collect::<Vec<_>>(), "links":links.into_iter().map(|row|serde_json::json!({"token_hash":row.token_hash,"token":row.token,"permission":row.permission,"expires_at":row.expires_at,"created_at":row.created_at})).collect::<Vec<_>>() }),
|
||||
serde_json::json!({"users":users.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission})).collect::<Vec<_>>(), "pending":pending.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission,"expires_at":row.expires_at})).collect::<Vec<_>>(), "links":links.into_iter().map(|row|serde_json::json!({"token_hash":row.token_hash,"permission":row.permission,"expires_at":row.expires_at,"created_at":row.created_at})).collect::<Vec<_>>() }),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1689,20 +1742,19 @@ pub async fn create_share_link(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreateShareLinkRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
) -> Result<Response, AuthError> {
|
||||
let owner = require_user(&state, &headers).await?;
|
||||
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
||||
let permission = validate_permission(&req.permission)?;
|
||||
validate_share_expiration(req.expires_at.as_deref())?;
|
||||
let expires_at = normalize_share_expiration(req.expires_at.as_deref())?;
|
||||
let token = random_token();
|
||||
let token_hash = hash_token(&token);
|
||||
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT))
|
||||
.bind(token_hash)
|
||||
.bind(&token)
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.bind(permission)
|
||||
.bind(&req.expires_at)
|
||||
.bind(&expires_at)
|
||||
.bind(owner.id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
@@ -1712,9 +1764,19 @@ pub async fn create_share_link(
|
||||
} else {
|
||||
format!("/p/{}", req.slug.trim())
|
||||
};
|
||||
Ok(Json(
|
||||
serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":req.expires_at}),
|
||||
))
|
||||
let mut response = Json(
|
||||
serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":expires_at}),
|
||||
)
|
||||
.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
"no-store, max-age=0".parse().expect("valid cache-control"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::PRAGMA,
|
||||
"no-cache".parse().expect("valid pragma"),
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn update_share_link(
|
||||
@@ -1725,10 +1787,10 @@ pub async fn update_share_link(
|
||||
let owner = require_user(&state, &headers).await?;
|
||||
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
||||
let permission = validate_permission(&req.permission)?;
|
||||
validate_share_expiration(req.expires_at.as_deref())?;
|
||||
let expires_at = normalize_share_expiration(req.expires_at.as_deref())?;
|
||||
let result = sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_UPDATE))
|
||||
.bind(permission)
|
||||
.bind(&req.expires_at)
|
||||
.bind(&expires_at)
|
||||
.bind(req.token.trim())
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
@@ -1741,7 +1803,7 @@ pub async fn update_share_link(
|
||||
));
|
||||
}
|
||||
Ok(Json(
|
||||
serde_json::json!({"ok":true,"permission":permission,"expires_at":req.expires_at}),
|
||||
serde_json::json!({"ok":true,"permission":permission,"expires_at":expires_at}),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1760,6 +1822,16 @@ pub async fn revoke_share_link(
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_SESSIONS_DELETE_BY_LINK,
|
||||
))
|
||||
.bind(req.token.trim())
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
@@ -1770,9 +1842,9 @@ fn validate_permission(value: &str) -> Result<&str, AuthError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> {
|
||||
fn normalize_share_expiration(value: Option<&str>) -> Result<Option<String>, AuthError> {
|
||||
let Some(value) = value else {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
};
|
||||
let expires = chrono::DateTime::parse_from_rfc3339(value)
|
||||
.map_err(|_| AuthError::bad_request("Invalid expiration date."))?
|
||||
@@ -1780,7 +1852,7 @@ fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> {
|
||||
if expires <= Utc::now() {
|
||||
return Err(AuthError::bad_request("Expiration must be in the future."));
|
||||
}
|
||||
Ok(())
|
||||
Ok(Some(expires.to_rfc3339()))
|
||||
}
|
||||
|
||||
pub async fn is_resource_owner(
|
||||
@@ -1826,6 +1898,194 @@ pub async fn account_resource_permission(
|
||||
Ok(permission)
|
||||
}
|
||||
|
||||
pub async fn create_share_session(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
share_token: &str,
|
||||
existing_session_token: Option<&str>,
|
||||
client_key: &str,
|
||||
) -> Result<Option<ShareSession>, AuthError> {
|
||||
let share_token = share_token.trim();
|
||||
if !valid_share_token(share_token) || !matches!(kind, "workspace" | "pad") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let now_text = now.to_rfc3339();
|
||||
let window = std::time::Duration::from_secs(15 * 60);
|
||||
state
|
||||
.check_rate_limit(format!("share-session-client:{client_key}"), 120, window)
|
||||
.await
|
||||
.map_err(|seconds| {
|
||||
AuthError::rate_limited(&format!(
|
||||
"Too many share-link attempts. Try again in {seconds} seconds."
|
||||
))
|
||||
})?;
|
||||
let source = sqlx::query_as::<_, ShareLinkSessionSource>(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_LINK_SESSION_SOURCE,
|
||||
))
|
||||
.bind(hash_token(share_token))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
let Some(source) = source else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !matches!(source.permission.as_str(), "ro" | "rw") {
|
||||
warn!(kind, slug, "invalid share link permission in database");
|
||||
return Ok(None);
|
||||
}
|
||||
if source.permission == "ro"
|
||||
&& share_session_permission(state, kind, slug, existing_session_token)
|
||||
.await?
|
||||
.as_deref()
|
||||
== Some("rw")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let session_limit = now + Duration::days(state.anonymous_access_token_ttl_days);
|
||||
let expires_at = match source.expires_at.as_deref() {
|
||||
Some(value) => match chrono::DateTime::parse_from_rfc3339(value) {
|
||||
Ok(value) => std::cmp::min(value.with_timezone(&Utc), session_limit),
|
||||
Err(error) => {
|
||||
warn!(%error, kind, slug, "invalid share link expiration in database");
|
||||
return Ok(None);
|
||||
}
|
||||
},
|
||||
None => session_limit,
|
||||
};
|
||||
let max_age_seconds = (expires_at - now).num_seconds();
|
||||
if max_age_seconds <= 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
state
|
||||
.check_rate_limit(
|
||||
format!("share-session-client:{client_key}:{}", source.token_hash),
|
||||
60,
|
||||
window,
|
||||
)
|
||||
.await
|
||||
.map_err(|seconds| {
|
||||
AuthError::rate_limited(&format!(
|
||||
"Too many share-link sessions. Try again in {seconds} seconds."
|
||||
))
|
||||
})?;
|
||||
state
|
||||
.check_rate_limit(
|
||||
format!("share-session-link:{}", source.token_hash),
|
||||
2_000,
|
||||
std::time::Duration::from_secs(60 * 60),
|
||||
)
|
||||
.await
|
||||
.map_err(|seconds| {
|
||||
AuthError::rate_limited(&format!(
|
||||
"Too many share-link sessions. Try again in {seconds} seconds."
|
||||
))
|
||||
})?;
|
||||
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_SESSIONS_DELETE_EXPIRED,
|
||||
))
|
||||
.bind(&now_text)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
|
||||
let token = random_token();
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_SESSION_INSERT,
|
||||
))
|
||||
.bind(hash_token(&token))
|
||||
.bind(source.token_hash)
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.bind(expires_at.to_rfc3339())
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
|
||||
Ok(Some(ShareSession {
|
||||
token,
|
||||
max_age_seconds,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn share_session_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);
|
||||
};
|
||||
if !valid_share_token(token) {
|
||||
return Ok(None);
|
||||
}
|
||||
let row = sqlx::query_as::<_, ShareSessionPermissionRow>(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_SESSION_PERMISSION,
|
||||
))
|
||||
.bind(hash_token(token))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !matches!(row.permission.as_str(), "ro" | "rw") {
|
||||
warn!(kind, slug, "invalid share session permission in database");
|
||||
return Ok(None);
|
||||
}
|
||||
let now = Utc::now();
|
||||
let session_expires = match chrono::DateTime::parse_from_rfc3339(&row.session_expires_at) {
|
||||
Ok(value) => value.with_timezone(&Utc),
|
||||
Err(error) => {
|
||||
warn!(%error, kind, slug, "invalid share session expiration in database");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
if session_expires <= now {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(value) = row.link_expires_at.as_deref() {
|
||||
let link_expires = match chrono::DateTime::parse_from_rfc3339(value) {
|
||||
Ok(value) => value.with_timezone(&Utc),
|
||||
Err(error) => {
|
||||
warn!(%error, kind, slug, "invalid share link expiration in database");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
if link_expires <= now {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Ok(Some(row.permission))
|
||||
}
|
||||
|
||||
pub async fn share_access_permission(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<Option<String>, AuthError> {
|
||||
let permission = share_session_permission(state, kind, slug, token).await?;
|
||||
if permission.is_some() {
|
||||
return Ok(permission);
|
||||
}
|
||||
share_link_permission(state, kind, slug, token).await
|
||||
}
|
||||
|
||||
pub async fn share_link_permission(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
@@ -1835,19 +2095,39 @@ pub async fn share_link_permission(
|
||||
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(
|
||||
if !valid_share_token(token) {
|
||||
return Ok(None);
|
||||
}
|
||||
let row = sqlx::query_as::<_, ShareLinkPermissionRow>(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_LINK_PERMISSION,
|
||||
))
|
||||
.bind(hash_token(token))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.bind(now)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
Ok(permission)
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !matches!(row.permission.as_str(), "ro" | "rw") {
|
||||
warn!(kind, slug, "invalid share link permission in database");
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(value) = row.expires_at.as_deref() {
|
||||
let expires = match chrono::DateTime::parse_from_rfc3339(value) {
|
||||
Ok(value) => value.with_timezone(&Utc),
|
||||
Err(error) => {
|
||||
warn!(%error, kind, slug, "invalid share link expiration in database");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
if expires <= Utc::now() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Ok(Some(row.permission))
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
@@ -2286,6 +2566,10 @@ fn random_token() -> String {
|
||||
random_hex_token::<32>()
|
||||
}
|
||||
|
||||
fn valid_share_token(value: &str) -> bool {
|
||||
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn random_confirmation_token() -> String {
|
||||
random_hex_token::<32>()
|
||||
}
|
||||
|
||||
+18
@@ -142,6 +142,24 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing::error!(%error, "failed to remove expired unconfirmed accounts")
|
||||
}
|
||||
}
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
match sqlx::query(crate::queries::get(
|
||||
cleanup_state.db.kind(),
|
||||
crate::queries::SHARE_SESSIONS_DELETE_EXPIRED,
|
||||
))
|
||||
.bind(now)
|
||||
.execute(cleanup_state.db.pool())
|
||||
.await
|
||||
{
|
||||
Ok(result) if result.rows_affected() > 0 => info!(
|
||||
deleted = result.rows_affected(),
|
||||
"removed expired share-link sessions"
|
||||
),
|
||||
Ok(_) => {}
|
||||
Err(error) => {
|
||||
tracing::error!(%error, "failed to remove expired share-link sessions")
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let app = app::router(
|
||||
|
||||
@@ -105,6 +105,11 @@ pub enum Query {
|
||||
SHARE_LINK_REVOKE,
|
||||
RESOURCE_PERMISSION_BY_USER,
|
||||
SHARE_LINK_PERMISSION,
|
||||
SHARE_LINK_SESSION_SOURCE,
|
||||
SHARE_SESSION_INSERT,
|
||||
SHARE_SESSION_PERMISSION,
|
||||
SHARE_SESSIONS_DELETE_BY_LINK,
|
||||
SHARE_SESSIONS_DELETE_EXPIRED,
|
||||
Q001,
|
||||
Q002,
|
||||
Q003,
|
||||
@@ -259,6 +264,11 @@ pub const SHARE_LINK_UPDATE: Query = Query::SHARE_LINK_UPDATE;
|
||||
pub const SHARE_LINK_REVOKE: Query = Query::SHARE_LINK_REVOKE;
|
||||
pub const RESOURCE_PERMISSION_BY_USER: Query = Query::RESOURCE_PERMISSION_BY_USER;
|
||||
pub const SHARE_LINK_PERMISSION: Query = Query::SHARE_LINK_PERMISSION;
|
||||
pub const SHARE_LINK_SESSION_SOURCE: Query = Query::SHARE_LINK_SESSION_SOURCE;
|
||||
pub const SHARE_SESSION_INSERT: Query = Query::SHARE_SESSION_INSERT;
|
||||
pub const SHARE_SESSION_PERMISSION: Query = Query::SHARE_SESSION_PERMISSION;
|
||||
pub const SHARE_SESSIONS_DELETE_BY_LINK: Query = Query::SHARE_SESSIONS_DELETE_BY_LINK;
|
||||
pub const SHARE_SESSIONS_DELETE_EXPIRED: Query = Query::SHARE_SESSIONS_DELETE_EXPIRED;
|
||||
pub const Q001: Query = Query::Q001;
|
||||
pub const Q002: Query = Query::Q002;
|
||||
pub const Q003: Query = Query::Q003;
|
||||
@@ -325,6 +335,11 @@ mod tests {
|
||||
Q033,
|
||||
USER_LIST_WORKSPACES,
|
||||
USER_LIST_PADS,
|
||||
SHARE_LINK_SESSION_SOURCE,
|
||||
SHARE_SESSION_INSERT,
|
||||
SHARE_SESSION_PERMISSION,
|
||||
SHARE_SESSIONS_DELETE_BY_LINK,
|
||||
SHARE_SESSIONS_DELETE_EXPIRED,
|
||||
] {
|
||||
assert!(!get(DatabaseKind::Sqlite, query).is_empty());
|
||||
assert!(!get(DatabaseKind::Postgres, query).is_empty());
|
||||
|
||||
+18
-3
@@ -233,13 +233,13 @@ pub fn get(query: Query) -> &'static str {
|
||||
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"#
|
||||
}
|
||||
Query::RESOURCE_SHARING_LINKS => {
|
||||
r#"SELECT token_hash, CAST(token AS CHAR CHARACTER SET utf8mb4) AS token, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(created_at AS CHAR CHARACTER SET utf8mb4) AS created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
|
||||
r#"SELECT token_hash, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(created_at AS CHAR CHARACTER SET utf8mb4) AS created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
|
||||
}
|
||||
Query::RESOURCE_SHARING_PENDING => {
|
||||
r#"SELECT u.email, u.nickname, i.permission, CAST(i.expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"#
|
||||
}
|
||||
Query::SHARE_LINK_INSERT => {
|
||||
r#"INSERT INTO resource_share_links (token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"#
|
||||
r#"INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)"#
|
||||
}
|
||||
Query::SHARE_LINK_UPDATE => {
|
||||
r#"UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
||||
@@ -251,7 +251,22 @@ pub fn get(query: Query) -> &'static str {
|
||||
r#"SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"#
|
||||
}
|
||||
Query::SHARE_LINK_PERMISSION => {
|
||||
r#"SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)"#
|
||||
r#"SELECT permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
||||
}
|
||||
Query::SHARE_LINK_SESSION_SOURCE => {
|
||||
r#"SELECT token_hash, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
||||
}
|
||||
Query::SHARE_SESSION_INSERT => {
|
||||
r#"INSERT INTO resource_share_sessions (session_token_hash, share_token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?, ?)"#
|
||||
}
|
||||
Query::SHARE_SESSION_PERMISSION => {
|
||||
r#"SELECT l.permission, CAST(s.expires_at AS CHAR CHARACTER SET utf8mb4) AS session_expires_at, CAST(l.expires_at AS CHAR CHARACTER SET utf8mb4) AS link_expires_at FROM resource_share_sessions s JOIN resource_share_links l ON l.token_hash = s.share_token_hash AND l.resource_kind = s.resource_kind AND l.resource_slug = s.resource_slug WHERE s.session_token_hash = ? AND s.resource_kind = ? AND s.resource_slug = ? AND l.revoked_at IS NULL"#
|
||||
}
|
||||
Query::SHARE_SESSIONS_DELETE_BY_LINK => {
|
||||
r#"DELETE FROM resource_share_sessions WHERE share_token_hash = ? AND resource_kind = ? AND resource_slug = ?"#
|
||||
}
|
||||
Query::SHARE_SESSIONS_DELETE_EXPIRED => {
|
||||
r#"DELETE FROM resource_share_sessions WHERE expires_at <= ?"#
|
||||
}
|
||||
Query::Q001 => {
|
||||
r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private FROM workspaces WHERE slug = ?"#
|
||||
|
||||
+18
-3
@@ -235,13 +235,13 @@ pub fn get(query: Query) -> &'static str {
|
||||
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = $1 AND rp.resource_slug = $2 ORDER BY u.email"#
|
||||
}
|
||||
Query::RESOURCE_SHARING_LINKS => {
|
||||
r#"SELECT token_hash, token, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = $1 AND resource_slug = $2 AND revoked_at IS NULL ORDER BY created_at DESC"#
|
||||
r#"SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = $1 AND resource_slug = $2 AND revoked_at IS NULL ORDER BY created_at DESC"#
|
||||
}
|
||||
Query::RESOURCE_SHARING_PENDING => {
|
||||
r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = $1 AND i.resource_slug = $2 AND i.accepted_at IS NULL ORDER BY u.email"#
|
||||
}
|
||||
Query::SHARE_LINK_INSERT => {
|
||||
r#"INSERT INTO resource_share_links (token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7)"#
|
||||
r#"INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6)"#
|
||||
}
|
||||
Query::SHARE_LINK_UPDATE => {
|
||||
r#"UPDATE resource_share_links SET permission = $1, expires_at = $2 WHERE token_hash = $3 AND resource_kind = $4 AND resource_slug = $5 AND revoked_at IS NULL"#
|
||||
@@ -253,7 +253,22 @@ pub fn get(query: Query) -> &'static str {
|
||||
r#"SELECT permission FROM resource_permissions WHERE resource_kind = $1 AND resource_slug = $2 AND user_id = $3"#
|
||||
}
|
||||
Query::SHARE_LINK_PERMISSION => {
|
||||
r#"SELECT permission FROM resource_share_links WHERE token_hash = $1 AND resource_kind = $2 AND resource_slug = $3 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > $4)"#
|
||||
r#"SELECT permission, expires_at FROM resource_share_links WHERE token_hash = $1 AND resource_kind = $2 AND resource_slug = $3 AND revoked_at IS NULL"#
|
||||
}
|
||||
Query::SHARE_LINK_SESSION_SOURCE => {
|
||||
r#"SELECT token_hash, permission, expires_at FROM resource_share_links WHERE token_hash = $1 AND resource_kind = $2 AND resource_slug = $3 AND revoked_at IS NULL"#
|
||||
}
|
||||
Query::SHARE_SESSION_INSERT => {
|
||||
r#"INSERT INTO resource_share_sessions (session_token_hash, share_token_hash, resource_kind, resource_slug, expires_at) VALUES ($1, $2, $3, $4, $5)"#
|
||||
}
|
||||
Query::SHARE_SESSION_PERMISSION => {
|
||||
r#"SELECT l.permission, s.expires_at, l.expires_at FROM resource_share_sessions s JOIN resource_share_links l ON l.token_hash = s.share_token_hash AND l.resource_kind = s.resource_kind AND l.resource_slug = s.resource_slug WHERE s.session_token_hash = $1 AND s.resource_kind = $2 AND s.resource_slug = $3 AND l.revoked_at IS NULL"#
|
||||
}
|
||||
Query::SHARE_SESSIONS_DELETE_BY_LINK => {
|
||||
r#"DELETE FROM resource_share_sessions WHERE share_token_hash = $1 AND resource_kind = $2 AND resource_slug = $3"#
|
||||
}
|
||||
Query::SHARE_SESSIONS_DELETE_EXPIRED => {
|
||||
r#"DELETE FROM resource_share_sessions WHERE expires_at <= $1"#
|
||||
}
|
||||
Query::Q001 => {
|
||||
r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM workspaces WHERE slug = $1"#
|
||||
|
||||
+18
-3
@@ -233,13 +233,13 @@ pub fn get(query: Query) -> &'static str {
|
||||
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"#
|
||||
}
|
||||
Query::RESOURCE_SHARING_LINKS => {
|
||||
r#"SELECT token_hash, token, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
|
||||
r#"SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
|
||||
}
|
||||
Query::RESOURCE_SHARING_PENDING => {
|
||||
r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"#
|
||||
}
|
||||
Query::SHARE_LINK_INSERT => {
|
||||
r#"INSERT INTO resource_share_links (token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"#
|
||||
r#"INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)"#
|
||||
}
|
||||
Query::SHARE_LINK_UPDATE => {
|
||||
r#"UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
||||
@@ -251,7 +251,22 @@ pub fn get(query: Query) -> &'static str {
|
||||
r#"SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"#
|
||||
}
|
||||
Query::SHARE_LINK_PERMISSION => {
|
||||
r#"SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)"#
|
||||
r#"SELECT permission, expires_at FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
||||
}
|
||||
Query::SHARE_LINK_SESSION_SOURCE => {
|
||||
r#"SELECT token_hash, permission, expires_at FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
||||
}
|
||||
Query::SHARE_SESSION_INSERT => {
|
||||
r#"INSERT INTO resource_share_sessions (session_token_hash, share_token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?, ?)"#
|
||||
}
|
||||
Query::SHARE_SESSION_PERMISSION => {
|
||||
r#"SELECT l.permission, s.expires_at, l.expires_at FROM resource_share_sessions s JOIN resource_share_links l ON l.token_hash = s.share_token_hash AND l.resource_kind = s.resource_kind AND l.resource_slug = s.resource_slug WHERE s.session_token_hash = ? AND s.resource_kind = ? AND s.resource_slug = ? AND l.revoked_at IS NULL"#
|
||||
}
|
||||
Query::SHARE_SESSIONS_DELETE_BY_LINK => {
|
||||
r#"DELETE FROM resource_share_sessions WHERE share_token_hash = ? AND resource_kind = ? AND resource_slug = ?"#
|
||||
}
|
||||
Query::SHARE_SESSIONS_DELETE_EXPIRED => {
|
||||
r#"DELETE FROM resource_share_sessions WHERE expires_at <= ?"#
|
||||
}
|
||||
Query::Q001 => {
|
||||
r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private FROM workspaces WHERE slug = ?"#
|
||||
|
||||
@@ -50,6 +50,15 @@ pub fn resource_token<'a>(headers: &'a HeaderMap, kind: &str, slug: &str) -> Opt
|
||||
cookie_value(headers, &name)
|
||||
}
|
||||
|
||||
pub fn share_session_token<'a>(
|
||||
headers: &'a HeaderMap,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
) -> Option<&'a str> {
|
||||
let name = share_session_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))
|
||||
}
|
||||
@@ -104,6 +113,23 @@ pub fn resource_cookie(kind: &str, slug: &str, token: &str, ttl_days: i64) -> He
|
||||
)
|
||||
}
|
||||
|
||||
pub fn share_session_cookie(
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
token: &str,
|
||||
max_age_seconds: i64,
|
||||
) -> HeaderValue {
|
||||
secure_cookie(
|
||||
&share_session_cookie_name(kind, slug),
|
||||
token,
|
||||
max_age_seconds,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn clear_share_session_cookie(kind: &str, slug: &str) -> HeaderValue {
|
||||
clear_cookie(&share_session_cookie_name(kind, slug))
|
||||
}
|
||||
|
||||
pub fn client_key(headers: &HeaderMap) -> String {
|
||||
let forwarded_ip = header_ip(headers, "cf-connecting-ip")
|
||||
.or_else(|| header_ip(headers, "x-real-ip"))
|
||||
@@ -161,6 +187,11 @@ fn resource_cookie_name(kind: &str, slug: &str) -> String {
|
||||
format!("__Host-rustpad_access_{}", hex::encode(&digest[..12]))
|
||||
}
|
||||
|
||||
fn share_session_cookie_name(kind: &str, slug: &str) -> String {
|
||||
let digest = Sha256::digest(format!("{kind}:{slug}").as_bytes());
|
||||
format!("__Host-rustpad_share_{}", hex::encode(&digest[..12]))
|
||||
}
|
||||
|
||||
pub fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
headers
|
||||
.get(header::COOKIE)
|
||||
@@ -318,6 +349,24 @@ mod tests {
|
||||
assert!(value.starts_with("__Host-rustpad_session=abc123;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_sessions_use_separate_scoped_opaque_cookies() {
|
||||
let value = share_session_cookie("workspace", "private-space", "opaque", 600)
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(value.starts_with("__Host-rustpad_share_"));
|
||||
assert!(value.contains("=opaque;"));
|
||||
assert!(value.contains("Max-Age=600"));
|
||||
assert!(value.contains("HttpOnly"));
|
||||
assert!(value.contains("Secure"));
|
||||
assert!(value.contains("SameSite=Lax"));
|
||||
|
||||
let cleared = clear_share_session_cookie("workspace", "private-space")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(cleared.contains("Max-Age=0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csrf_requires_matching_cookie_and_header() {
|
||||
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
|
||||
|
||||
+120
-29
@@ -196,37 +196,70 @@ async fn resource_permission_from_tokens(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
access_token: Option<&str>,
|
||||
access_tokens: &[Option<&str>],
|
||||
session_token: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let access_permission = auth::share_link_permission(state, kind, slug, access_token)
|
||||
let mut read_allowed = false;
|
||||
for token in access_tokens {
|
||||
match auth::share_access_permission(state, kind, slug, *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")
|
||||
.flatten()
|
||||
.as_deref()
|
||||
{
|
||||
Some("ro".into())
|
||||
} else {
|
||||
None
|
||||
Some("rw") => return Some("rw".into()),
|
||||
Some("ro") => read_allowed = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match auth::account_resource_permission(state, kind, slug, session_token)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.as_deref()
|
||||
{
|
||||
Some("rw") => Some("rw".into()),
|
||||
Some("ro") if !read_allowed => Some("ro".into()),
|
||||
_ if read_allowed => Some("ro".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn anonymous_access_from_tokens(
|
||||
async fn password_access_from_tokens(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
access_token: Option<&str>,
|
||||
access_tokens: &[Option<&str>],
|
||||
) -> bool {
|
||||
crate::api::verify_resource_access_token(state, kind, slug, access_token)
|
||||
for token in access_tokens {
|
||||
if crate::api::verify_password_access_token(state, kind, slug, *token)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn current_resource_access(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
access_tokens: &[Option<&str>],
|
||||
session_token: Option<&str>,
|
||||
password_ok: bool,
|
||||
public_unprotected: bool,
|
||||
) -> (bool, bool) {
|
||||
let permission =
|
||||
resource_permission_from_tokens(state, kind, slug, access_tokens, session_token).await;
|
||||
let password_token_ok = password_access_from_tokens(state, kind, slug, access_tokens).await;
|
||||
let write_allowed = public_unprotected
|
||||
|| password_ok
|
||||
|| password_token_ok
|
||||
|| permission.as_deref() == Some("rw");
|
||||
let read_allowed = write_allowed || permission.as_deref() == Some("ro");
|
||||
(read_allowed, write_allowed)
|
||||
}
|
||||
|
||||
// Merged from note.rs
|
||||
@@ -241,6 +274,9 @@ pub async fn upgrade(
|
||||
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
|
||||
}
|
||||
let account_token = crate::security::session_token(&headers).map(str::to_owned);
|
||||
let share_session_token =
|
||||
crate::security::share_session_token(&headers, "workspace", &workspace_slug)
|
||||
.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);
|
||||
@@ -252,6 +288,7 @@ pub async fn upgrade(
|
||||
workspace_slug,
|
||||
note_slug,
|
||||
account_token,
|
||||
share_session_token,
|
||||
resource_token,
|
||||
client_key,
|
||||
client_context,
|
||||
@@ -265,7 +302,8 @@ async fn handle_socket(
|
||||
workspace_slug: String,
|
||||
note_slug: String,
|
||||
cookie_session_token: Option<String>,
|
||||
cookie_access_token: Option<String>,
|
||||
cookie_share_session_token: Option<String>,
|
||||
cookie_password_token: Option<String>,
|
||||
client_key: String,
|
||||
client_context: RequestClientContext,
|
||||
) {
|
||||
@@ -319,7 +357,11 @@ async fn handle_socket(
|
||||
.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 external_tokens = [
|
||||
explicit_access_token.as_deref(),
|
||||
cookie_share_session_token.as_deref(),
|
||||
cookie_password_token.as_deref(),
|
||||
];
|
||||
let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await {
|
||||
Ok(value) => value,
|
||||
Err(message) => {
|
||||
@@ -343,16 +385,15 @@ async fn handle_socket(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
access_token.as_deref(),
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let anonymous_token_ok = permission.is_none()
|
||||
&& anonymous_access_from_tokens(
|
||||
let anonymous_token_ok = password_access_from_tokens(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
access_token.as_deref(),
|
||||
&external_tokens,
|
||||
)
|
||||
.await;
|
||||
let password_limit_key = format!("resource-password:{client_key}:workspace:{workspace_slug}");
|
||||
@@ -410,10 +451,17 @@ async fn handle_socket(
|
||||
let _ = send_error(&mut socket, "Invalid password").await;
|
||||
return;
|
||||
}
|
||||
let write_allowed = permission.as_deref() == Some("rw")
|
||||
|| anonymous_token_ok
|
||||
|| password_ok
|
||||
|| (workspace.is_private == 0 && workspace.password_hash.is_none() && permission.is_none());
|
||||
let public_unprotected = workspace.is_private == 0 && workspace.password_hash.is_none();
|
||||
let (_, write_allowed) = current_resource_access(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
password_ok,
|
||||
public_unprotected,
|
||||
)
|
||||
.await;
|
||||
info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated");
|
||||
if send(
|
||||
&mut socket,
|
||||
@@ -443,6 +491,9 @@ async fn handle_socket(
|
||||
.await;
|
||||
let _ = channel.send(RoomEvent::Presence(users));
|
||||
let mut last_chat = Instant::now() - Duration::from_secs(1);
|
||||
let mut access_refresh = tokio::time::interval(Duration::from_secs(10));
|
||||
access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
access_refresh.tick().await;
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
if send_split(
|
||||
&mut sender,
|
||||
@@ -462,7 +513,17 @@ async fn handle_socket(
|
||||
incoming=receiver.next()=>match incoming {
|
||||
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text) {
|
||||
Ok(ClientMessage::Update{content,owner_map})=>{
|
||||
if !write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; }
|
||||
let (read_allowed, current_write_allowed) = current_resource_access(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
password_ok,
|
||||
public_unprotected,
|
||||
).await;
|
||||
if !read_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await; break; }
|
||||
if !current_write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; }
|
||||
if content.len()>2_000_000 { let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await; continue; }
|
||||
let owner_map=owner_map.unwrap_or_else(||"[]".into());
|
||||
match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await {
|
||||
@@ -480,7 +541,36 @@ async fn handle_socket(
|
||||
},
|
||||
Some(Ok(Message::Close(_)))|None=>break, Some(Ok(_))=>{}, Some(Err(error))=>{debug!(%error,"websocket receive error");break;}
|
||||
},
|
||||
update=updates.recv()=>match update {
|
||||
_=access_refresh.tick()=>{
|
||||
let (read_allowed, _) = current_resource_access(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
password_ok,
|
||||
public_unprotected,
|
||||
).await;
|
||||
if !read_allowed {
|
||||
let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
||||
break;
|
||||
}
|
||||
},
|
||||
update=updates.recv()=>{
|
||||
let (read_allowed, _) = current_resource_access(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
password_ok,
|
||||
public_unprotected,
|
||||
).await;
|
||||
if !read_allowed {
|
||||
let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
||||
break;
|
||||
}
|
||||
match update {
|
||||
Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;},
|
||||
Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;},
|
||||
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
|
||||
@@ -489,6 +579,7 @@ async fn handle_socket(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let users = state.leave_room(&room_key, connection_id).await;
|
||||
let _ = channel.send(RoomEvent::Presence(users));
|
||||
info!(
|
||||
|
||||
+70
-11
@@ -44,6 +44,8 @@ pub async fn upgrade_pad(
|
||||
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
|
||||
}
|
||||
let account_token = crate::security::session_token(&headers).map(str::to_owned);
|
||||
let share_session_token =
|
||||
crate::security::share_session_token(&headers, "pad", &slug).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);
|
||||
let client_context = RequestClientContext::from_headers(&headers, &client_key);
|
||||
@@ -53,6 +55,7 @@ pub async fn upgrade_pad(
|
||||
state,
|
||||
slug,
|
||||
account_token,
|
||||
share_session_token,
|
||||
resource_token,
|
||||
client_key,
|
||||
client_context,
|
||||
@@ -64,7 +67,8 @@ async fn handle_pad_socket(
|
||||
state: SharedState,
|
||||
slug: String,
|
||||
cookie_session_token: Option<String>,
|
||||
cookie_access_token: Option<String>,
|
||||
cookie_share_session_token: Option<String>,
|
||||
cookie_password_token: Option<String>,
|
||||
client_key: String,
|
||||
client_context: RequestClientContext,
|
||||
) {
|
||||
@@ -117,7 +121,11 @@ async fn handle_pad_socket(
|
||||
.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 external_tokens = [
|
||||
explicit_access_token.as_deref(),
|
||||
cookie_share_session_token.as_deref(),
|
||||
cookie_password_token.as_deref(),
|
||||
];
|
||||
let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await {
|
||||
Ok(value) => value,
|
||||
Err(message) => {
|
||||
@@ -141,12 +149,12 @@ async fn handle_pad_socket(
|
||||
&state,
|
||||
"pad",
|
||||
&slug,
|
||||
access_token.as_deref(),
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let anonymous_token_ok = permission.is_none()
|
||||
&& anonymous_access_from_tokens(&state, "pad", &slug, access_token.as_deref()).await;
|
||||
let anonymous_token_ok =
|
||||
password_access_from_tokens(&state, "pad", &slug, &external_tokens).await;
|
||||
let password_limit_key = format!("resource-password:{client_key}:pad:{slug}");
|
||||
let password_attempted = password
|
||||
.as_deref()
|
||||
@@ -210,10 +218,17 @@ async fn handle_pad_socket(
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let write_allowed = permission.as_deref() == Some("rw")
|
||||
|| anonymous_token_ok
|
||||
|| password_ok
|
||||
|| (pad.is_private == 0 && pad.password_hash.is_none() && permission.is_none());
|
||||
let public_unprotected = pad.is_private == 0 && pad.password_hash.is_none();
|
||||
let (_, write_allowed) = current_resource_access(
|
||||
&state,
|
||||
"pad",
|
||||
&slug,
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
password_ok,
|
||||
public_unprotected,
|
||||
)
|
||||
.await;
|
||||
info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated");
|
||||
if send_pad(
|
||||
&mut socket,
|
||||
@@ -242,6 +257,9 @@ async fn handle_pad_socket(
|
||||
.await;
|
||||
let _ = channel.send(RoomEvent::Presence(users));
|
||||
let mut last_chat = Instant::now() - Duration::from_secs(1);
|
||||
let mut access_refresh = tokio::time::interval(Duration::from_secs(10));
|
||||
access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
access_refresh.tick().await;
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
if send_pad_split(
|
||||
&mut sender,
|
||||
@@ -260,7 +278,18 @@ async fn handle_pad_socket(
|
||||
tokio::select! {
|
||||
incoming=receiver.next()=>match incoming{
|
||||
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text){
|
||||
Ok(ClientMessage::Update{content,owner_map})=>{if !write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;}
|
||||
Ok(ClientMessage::Update{content,owner_map})=>{
|
||||
let (read_allowed, current_write_allowed) = current_resource_access(
|
||||
&state,
|
||||
"pad",
|
||||
&slug,
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
password_ok,
|
||||
public_unprotected,
|
||||
).await;
|
||||
if !read_allowed { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;break; }
|
||||
if !current_write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;}
|
||||
if content.len()>2_000_000 { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await; continue; }
|
||||
let owner_map=owner_map.unwrap_or_else(||"[]".into());
|
||||
if let Ok((revision_id,updated_at))=db::save_pad_revision(&state.db,pad.id,&content,nickname.as_deref(),&owner_map).await{
|
||||
@@ -280,7 +309,36 @@ async fn handle_pad_socket(
|
||||
Some(Ok(_))=>{},
|
||||
Some(Err(error))=>{debug!(%error,"pad websocket receive error");break;}
|
||||
},
|
||||
update=updates.recv()=>match update{
|
||||
_=access_refresh.tick()=>{
|
||||
let (read_allowed, _) = current_resource_access(
|
||||
&state,
|
||||
"pad",
|
||||
&slug,
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
password_ok,
|
||||
public_unprotected,
|
||||
).await;
|
||||
if !read_allowed {
|
||||
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
||||
break;
|
||||
}
|
||||
},
|
||||
update=updates.recv()=>{
|
||||
let (read_allowed, _) = current_resource_access(
|
||||
&state,
|
||||
"pad",
|
||||
&slug,
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
password_ok,
|
||||
public_unprotected,
|
||||
).await;
|
||||
if !read_allowed {
|
||||
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
||||
break;
|
||||
}
|
||||
match update {
|
||||
Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{content:u.content,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,owner_map:u.owner_map}).await.is_err(){break;},
|
||||
Ok(RoomEvent::Presence(users))=>if send_pad_split(&mut sender,&PadServerMessage::Presence{users}).await.is_err(){break;},
|
||||
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_pad_split(&mut sender,&PadServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
|
||||
@@ -289,6 +347,7 @@ async fn handle_pad_socket(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let users = state.leave_room(&room_key, connection_id).await;
|
||||
let _ = channel.send(RoomEvent::Presence(users));
|
||||
info!(pad_id = pad.id, "pad websocket disconnected");
|
||||
|
||||
+2
-4
@@ -206,20 +206,18 @@ async function loadResources() {
|
||||
const d = await api(`/api/auth/resources/sharing?kind=${encodeURIComponent(item.kind)}&slug=${encodeURIComponent(item.slug)}`, { headers: authHeaders() });
|
||||
userList.innerHTML = d.users.length ? d.users.map(u => `<div class="share-list-row"><div class="share-list-identity"><span class="share-avatar">${escapeHtml((u.nickname || u.email || "?").slice(0, 1).toUpperCase())}</span><div><strong>${escapeHtml(u.nickname)}</strong><small>${escapeHtml(u.email)}</small></div></div><span class="share-role">${u.permission === "rw" ? "Read and write" : "Read only"}</span><button class="secondary-button compact-button" type="button" data-remove-user="${escapeHtml(u.email)}">Remove</button></div>`).join("") : '<p class="share-empty">No users have access.</p>';
|
||||
linkList.innerHTML = d.links.length ? d.links.map(link => {
|
||||
const directUrl = link.token ? new URL(`${item.url}?share=${encodeURIComponent(link.token)}`, location.origin).href : "";
|
||||
const linkPreview = link.token ? `<div class="share-link-inline"><input type="text" readonly value="${escapeHtml(directUrl)}" aria-label="Direct access link"><button class="secondary-button compact-button" type="button" data-copy-link>Copy</button></div>` : '<small class="share-link-legacy">Link value unavailable. Recreate this legacy link to display it.</small>';
|
||||
const linkPreview = '<small class="share-link-legacy">For security, link values are shown only when created. Create a new link to copy it again.</small>';
|
||||
return `<form class="share-list-row share-link-row" data-link-token="${escapeHtml(link.token_hash)}"><div class="share-link-info"><strong>Individual link</strong><small>${escapeHtml(formatShareExpiry(link.expires_at))}</small>${linkPreview}</div><label><span class="sr-only">Permission</span><select name="permission" aria-label="Link permission"><option value="ro" ${link.permission === "ro" ? "selected" : ""}>Read only</option><option value="rw" ${link.permission === "rw" ? "selected" : ""}>Read and write</option></select></label><label><span class="sr-only">Validity in hours</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" aria-label="New validity in hours"><span>h</span></div></label><label class="share-forever"><input name="forever" type="checkbox" ${link.expires_at ? "" : "checked"}><span>Never</span></label><div class="share-row-actions"><button class="secondary-button compact-button" type="submit">Update</button><button class="danger-button compact-button" type="button" data-revoke-link>Revoke</button></div></form>`;
|
||||
}).join("") : '<p class="share-empty">No active links.</p>';
|
||||
userList.querySelectorAll("[data-remove-user]").forEach(button => button.addEventListener("click", async () => { try { button.disabled = true; await api("/api/auth/resources/sharing", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, email: button.dataset.removeUser }) }); setDialogMessage("Access removed.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); button.disabled = false; } }));
|
||||
linkList.querySelectorAll("[data-link-token]").forEach(linkRow => {
|
||||
const forever = linkRow.elements.forever, hours = linkRow.elements.hours; const sync = () => { hours.disabled = forever.checked; }; forever.addEventListener("change", sync); sync();
|
||||
linkRow.querySelector("[data-copy-link]")?.addEventListener("click", async () => { try { await copyText(linkRow.querySelector(".share-link-inline input").value); setDialogMessage("Link copied.", "success"); } catch (err) { setDialogMessage(err.message, "error"); } });
|
||||
linkRow.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(hours.value, forever.checked); await api("/api/auth/resources/share-links", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token: linkRow.dataset.linkToken, permission: linkRow.elements.permission.value, expires_at }) }); setDialogMessage("Link updated.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
||||
linkRow.querySelector("[data-revoke-link]").addEventListener("click", async () => { try { await api("/api/auth/resources/share-links", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token: linkRow.dataset.linkToken }) }); setDialogMessage("Link revoked.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
||||
});
|
||||
};
|
||||
userForm.addEventListener("submit", async event => { event.preventDefault(); try { const result = await api("/api/auth/resources/sharing", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, recipients: userForm.recipients.value, permission: userForm.permission.value }) }); userForm.recipients.value = ""; setDialogMessage(result.confirmation_required ? "Invitation sent. Access will appear after the recipient accepts it." : "Access granted.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
||||
linkForm.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(linkForm.hours.value, linkForm.forever.checked); const result = await api("/api/auth/resources/share-links", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, permission: linkForm.permission.value, expires_at }) }); const absolute = new URL(result.url, location.origin).href; await copyText(absolute); setDialogMessage("Link created and copied. It remains visible below.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
||||
linkForm.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(linkForm.hours.value, linkForm.forever.checked); const result = await api("/api/auth/resources/share-links", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, permission: linkForm.permission.value, expires_at }) }); const absolute = new URL(result.url, location.origin).href; await copyText(absolute); setDialogMessage("Link created and copied. For security, it is shown only once.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
||||
dialog.showModal();
|
||||
try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); }
|
||||
});
|
||||
|
||||
@@ -841,7 +841,7 @@ export function startNoteEditor(adapter) {
|
||||
});
|
||||
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(); } } });
|
||||
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 && info.access_level === "none") passwordDialog.showModal(); else { loadFiles(); connect(); } } });
|
||||
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
|
||||
async function showSystemNotFound() {
|
||||
try {
|
||||
@@ -874,7 +874,7 @@ export function startNoteEditor(adapter) {
|
||||
adapter.configureView?.(info);
|
||||
applyUi({ write: true, replace: true });
|
||||
updateCurrentUser();
|
||||
if (info.protected && !accessToken && !getAuthToken()) passwordDialog.showModal();
|
||||
if (info.protected && info.access_level === "none") passwordDialog.showModal();
|
||||
else { loadFiles(); connect(); }
|
||||
} catch (e) {
|
||||
if (e.status === 403 || e.status === 404) {
|
||||
|
||||
@@ -151,7 +151,7 @@ async function init() {
|
||||
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 && !getAuthToken()) dialog.showModal(); else openWorkspace();
|
||||
if (info.protected && info.access_level === "none") dialog.showModal(); else openWorkspace();
|
||||
} catch (e) {
|
||||
if (e.status === 403 || e.status === 404) await showSystemNotFound();
|
||||
else document.querySelector("#workspace-error").textContent = e.message;
|
||||
|
||||
Executable
+207
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MIGRATIONS = ROOT / "migrations" / "sqlite"
|
||||
SQLITE_QUERIES = ROOT / "src" / "queries" / "sqlite.rs"
|
||||
|
||||
|
||||
def query(name: str) -> str:
|
||||
source = SQLITE_QUERIES.read_text()
|
||||
pattern = rf'Query::{re.escape(name)}\s*=>\s*\{{\s*r#"(.*?)"#\s*\}}'
|
||||
match = re.search(pattern, source, re.S)
|
||||
if not match:
|
||||
raise AssertionError(f"Query::{name} not found")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def parse_rfc3339(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("timestamp must include timezone")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def active_permission(row: tuple[str, str, str | None] | None, now: datetime) -> str | None:
|
||||
if row is None:
|
||||
return None
|
||||
permission, session_expires_at, link_expires_at = row
|
||||
try:
|
||||
if parse_rfc3339(session_expires_at) <= now:
|
||||
return None
|
||||
if link_expires_at is not None and parse_rfc3339(link_expires_at) <= now:
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
return permission if permission in {"ro", "rw"} else None
|
||||
|
||||
|
||||
def link_active(expires_at: str | None, now: datetime) -> bool:
|
||||
if expires_at is None:
|
||||
return True
|
||||
try:
|
||||
return parse_rfc3339(expires_at) > now
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def apply(connection: sqlite3.Connection, paths: list[Path]) -> None:
|
||||
for path in paths:
|
||||
connection.executescript(path.read_text())
|
||||
|
||||
|
||||
def main() -> None:
|
||||
migrations = sorted(MIGRATIONS.glob("*.sql"))
|
||||
before_sessions = [path for path in migrations if path.name < "0026_share_link_sessions.sql"]
|
||||
session_migration = MIGRATIONS / "0026_share_link_sessions.sql"
|
||||
|
||||
db = sqlite3.connect(":memory:")
|
||||
db.execute("PRAGMA foreign_keys = ON")
|
||||
apply(db, before_sessions)
|
||||
db.execute(
|
||||
"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
("Owner", "owner", "owner@example.test", "owner@example.test", "hash"),
|
||||
)
|
||||
|
||||
raw_link = "legacy-share-token"
|
||||
share_hash = hashlib.sha256(raw_link.encode()).hexdigest()
|
||||
db.execute(
|
||||
"INSERT INTO resource_share_links "
|
||||
"(token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) "
|
||||
"VALUES (?, ?, 'workspace', 'private-space', 'ro', NULL, 1)",
|
||||
(share_hash, raw_link),
|
||||
)
|
||||
db.executescript(session_migration.read_text())
|
||||
assert db.execute(
|
||||
"SELECT token FROM resource_share_links WHERE token_hash = ?", (share_hash,)
|
||||
).fetchone() == (None,), "migration must erase persisted plaintext share tokens"
|
||||
|
||||
source_sql = query("SHARE_LINK_SESSION_SOURCE")
|
||||
insert_sql = query("SHARE_SESSION_INSERT")
|
||||
permission_sql = query("SHARE_SESSION_PERMISSION")
|
||||
revoke_sessions_sql = query("SHARE_SESSIONS_DELETE_BY_LINK")
|
||||
delete_expired_sql = query("SHARE_SESSIONS_DELETE_EXPIRED")
|
||||
link_insert_sql = query("SHARE_LINK_INSERT")
|
||||
sharing_list_sql = query("RESOURCE_SHARING_LINKS")
|
||||
|
||||
now = datetime(2026, 8, 2, 23, 0, tzinfo=timezone.utc)
|
||||
source = db.execute(source_sql, (share_hash, "workspace", "private-space")).fetchone()
|
||||
assert source == (share_hash, "ro", None)
|
||||
|
||||
session_raw = "opaque-browser-session"
|
||||
session_hash = hashlib.sha256(session_raw.encode()).hexdigest()
|
||||
db.execute(
|
||||
insert_sql,
|
||||
(
|
||||
session_hash,
|
||||
share_hash,
|
||||
"workspace",
|
||||
"private-space",
|
||||
"2026-08-03T00:00:00+00:00",
|
||||
),
|
||||
)
|
||||
|
||||
def permission(slug: str = "private-space") -> str | None:
|
||||
row = db.execute(permission_sql, (session_hash, "workspace", slug)).fetchone()
|
||||
return active_permission(row, now)
|
||||
|
||||
assert permission() == "ro", "ro share session must remain read-only"
|
||||
assert permission("another-space") is None, "session must be scoped to one resource"
|
||||
|
||||
db.execute(
|
||||
"UPDATE resource_share_links SET permission = 'rw' WHERE token_hash = ?", (share_hash,)
|
||||
)
|
||||
assert permission() == "rw", "ro -> rw change must affect active sessions immediately"
|
||||
db.execute(
|
||||
"UPDATE resource_share_links SET permission = 'ro' WHERE token_hash = ?", (share_hash,)
|
||||
)
|
||||
assert permission() == "ro", "rw -> ro change must remove write access immediately"
|
||||
|
||||
db.execute(
|
||||
"UPDATE resource_share_sessions SET expires_at = ? WHERE session_token_hash = ?",
|
||||
("2026-08-02T22:59:59+00:00", session_hash),
|
||||
)
|
||||
assert permission() is None, "expired browser session must be denied"
|
||||
db.execute(
|
||||
"UPDATE resource_share_sessions SET expires_at = ? WHERE session_token_hash = ?",
|
||||
("2026-08-03T00:00:00+00:00", session_hash),
|
||||
)
|
||||
|
||||
# Offset timestamps are parsed chronologically rather than compared as text.
|
||||
db.execute(
|
||||
"UPDATE resource_share_links SET expires_at = ? WHERE token_hash = ?",
|
||||
("2026-08-03T01:30:00+02:00", share_hash),
|
||||
)
|
||||
row = db.execute(permission_sql, (session_hash, "workspace", "private-space")).fetchone()
|
||||
assert active_permission(row, now) == "ro"
|
||||
later = datetime(2026, 8, 2, 23, 31, tzinfo=timezone.utc)
|
||||
assert active_permission(row, later) is None, "expired link must invalidate its sessions"
|
||||
|
||||
db.execute(
|
||||
"UPDATE resource_share_links SET expires_at = 'not-a-date' WHERE token_hash = ?",
|
||||
(share_hash,),
|
||||
)
|
||||
row = db.execute(permission_sql, (session_hash, "workspace", "private-space")).fetchone()
|
||||
assert active_permission(row, now) is None, "malformed expiration must fail closed"
|
||||
assert not link_active("not-a-date", now)
|
||||
|
||||
db.execute(
|
||||
"UPDATE resource_share_links SET expires_at = NULL, revoked_at = ? WHERE token_hash = ?",
|
||||
("2026-08-02T23:05:00+00:00", share_hash),
|
||||
)
|
||||
assert db.execute(permission_sql, (session_hash, "workspace", "private-space")).fetchone() is None
|
||||
deleted = db.execute(
|
||||
revoke_sessions_sql, (share_hash, "workspace", "private-space")
|
||||
).rowcount
|
||||
assert deleted == 1, "revoking a link must remove all derived sessions"
|
||||
|
||||
# Newly created links persist only the hash; the plaintext column stays NULL.
|
||||
second_raw = "one-time-returned-token"
|
||||
second_hash = hashlib.sha256(second_raw.encode()).hexdigest()
|
||||
db.execute(
|
||||
link_insert_sql,
|
||||
(second_hash, "workspace", "second-space", "rw", None, 1),
|
||||
)
|
||||
assert db.execute(
|
||||
"SELECT token, permission FROM resource_share_links WHERE token_hash = ?", (second_hash,)
|
||||
).fetchone() == (None, "rw")
|
||||
listed_link = db.execute(sharing_list_sql, ("workspace", "second-space")).fetchone()
|
||||
assert listed_link == (
|
||||
second_hash,
|
||||
"rw",
|
||||
None,
|
||||
listed_link[3],
|
||||
), "management listing must never return the plaintext share token"
|
||||
|
||||
db.execute(
|
||||
insert_sql,
|
||||
(
|
||||
hashlib.sha256(b"expired-row").hexdigest(),
|
||||
second_hash,
|
||||
"workspace",
|
||||
"second-space",
|
||||
"2026-08-02T22:00:00+00:00",
|
||||
),
|
||||
)
|
||||
db.execute(delete_expired_sql, ("2026-08-02T23:00:00+00:00",))
|
||||
assert db.execute(
|
||||
"SELECT COUNT(*) FROM resource_share_sessions WHERE share_token_hash = ?", (second_hash,)
|
||||
).fetchone() == (0,), "expired session cleanup must remove stale rows"
|
||||
|
||||
db.execute("DELETE FROM resource_share_links WHERE token_hash = ?", (second_hash,))
|
||||
assert db.execute(
|
||||
"SELECT COUNT(*) FROM resource_share_sessions WHERE share_token_hash = ?", (second_hash,)
|
||||
).fetchone() == (0,), "link deletion must cascade to sessions"
|
||||
|
||||
print("share session SQL regression tests: 17 passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text()
|
||||
|
||||
|
||||
def query_sql(backend: str, name: str) -> str:
|
||||
source = read(f"src/queries/{backend}.rs")
|
||||
match = re.search(
|
||||
rf'Query::{re.escape(name)}\s*=>\s*\{{\s*r#"(.*?)"#\s*\}}',
|
||||
source,
|
||||
re.S,
|
||||
)
|
||||
if not match:
|
||||
raise AssertionError(f"{backend}: Query::{name} missing")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
names = {
|
||||
"SHARE_LINK_INSERT": 6,
|
||||
"SHARE_LINK_PERMISSION": 3,
|
||||
"SHARE_LINK_SESSION_SOURCE": 3,
|
||||
"SHARE_SESSION_INSERT": 5,
|
||||
"SHARE_SESSION_PERMISSION": 3,
|
||||
"SHARE_SESSIONS_DELETE_BY_LINK": 3,
|
||||
"SHARE_SESSIONS_DELETE_EXPIRED": 1,
|
||||
}
|
||||
for backend in ("sqlite", "mysql"):
|
||||
for name, expected in names.items():
|
||||
sql = query_sql(backend, name)
|
||||
assert sql.count("?") == expected, (backend, name, sql)
|
||||
for name, expected in names.items():
|
||||
sql = query_sql("postgres", name)
|
||||
parameters = [int(value) for value in re.findall(r"\$(\d+)", sql)]
|
||||
assert sorted(set(parameters)) == list(range(1, expected + 1)), (name, sql)
|
||||
|
||||
auth = read("src/auth/mod.rs")
|
||||
assert ".bind(&token)" not in auth[auth.index("pub async fn create_share_link"):auth.index("pub async fn update_share_link")]
|
||||
assert '"token":row.token' not in auth
|
||||
assert "token: Option<String>" not in auth[auth.index("struct SharingLinkRow"):auth.index("struct ShareLinkSessionSource")]
|
||||
assert "normalize_share_expiration" in auth
|
||||
assert "SHARE_SESSIONS_DELETE_BY_LINK" in auth
|
||||
assert "share_session_permission" in auth
|
||||
assert "invalid share session expiration in database" in auth
|
||||
assert "invalid share session permission in database" in auth
|
||||
assert "valid_share_token" in auth
|
||||
assert 'format!("share-session-client:{client_key}")' in auth
|
||||
create_link = auth[auth.index("pub async fn create_share_link"):auth.index("pub async fn update_share_link")]
|
||||
assert "no-store, max-age=0" in create_link
|
||||
create_session = auth[auth.index("pub async fn create_share_session"):auth.index("async fn share_session_permission")]
|
||||
assert "existing_session_token" in create_session
|
||||
assert 'source.permission == "ro"' in create_session
|
||||
assert '== Some("rw")' in create_session
|
||||
|
||||
api = read("src/api/mod.rs")
|
||||
assert "combined_token_access_level" not in api
|
||||
request_access = api[api.index("async fn request_access_level"):api.index("fn access_level_name")]
|
||||
for required in ("share_session_token", "resource_token", "authorization_token", "account_token_access_level"):
|
||||
assert required in request_access
|
||||
|
||||
access_tokens = read("src/api/access_tokens.rs")
|
||||
assert "verify_password_access_token" in access_tokens
|
||||
assert "share_access_permission" in access_tokens
|
||||
|
||||
websocket = read("src/websocket/mod.rs") + read("src/websocket/pad.rs")
|
||||
assert "cookie_share_session_token" in websocket
|
||||
assert "cookie_password_token" in websocket
|
||||
assert "explicit_access_token.or(" not in websocket
|
||||
assert "verify_password_access_token" in websocket
|
||||
assert "Access expired or revoked" in websocket
|
||||
assert "access_refresh" in websocket
|
||||
assert websocket.count("update=updates.recv()=>{") == 2
|
||||
assert websocket.count('message:"Access expired or revoked"') >= 6
|
||||
|
||||
pages = read("src/app/pages.rs")
|
||||
assert "canonical_resource_url" in pages
|
||||
assert "RawQuery" in pages
|
||||
assert "share_token_from_query" in pages
|
||||
assert "decode_query_component" in pages
|
||||
assert "share_session_cookie" in pages
|
||||
assert "Ok(None) => None" in pages
|
||||
assert "clear_share_session_cookie" not in pages
|
||||
assert "no-store, max-age=0" in pages
|
||||
assert "no-referrer" in pages
|
||||
assert 'decode_query_component(name).as_deref() != Some("share")' in pages
|
||||
|
||||
security = read("src/security.rs")
|
||||
assert "__Host-rustpad_share_" in security
|
||||
assert "HttpOnly; Secure; SameSite=Lax" in security
|
||||
|
||||
for backend in ("sqlite", "postgres", "mysql"):
|
||||
sharing_list = query_sql(backend, "RESOURCE_SHARING_LINKS")
|
||||
assert not re.search(r"(?:^|,)\s*(?:CAST\()?token\b", sharing_list)
|
||||
migration = read(f"migrations/{backend}/0026_share_link_sessions.sql")
|
||||
assert "UPDATE resource_share_links SET token = NULL" in migration
|
||||
assert "session_token_hash" in migration
|
||||
assert "ON DELETE CASCADE" in migration
|
||||
|
||||
workspace_js = read("static/js/workspace.js")
|
||||
note_js = read("static/js/note-editor.js")
|
||||
assert 'info.access_level === "none"' in workspace_js
|
||||
assert note_js.count('info.access_level === "none"') >= 2
|
||||
|
||||
app = read("src/app/mod.rs")
|
||||
assert "PathOnlyMakeSpan" in app
|
||||
assert "request.uri().path()" in app
|
||||
assert "TraceLayer::new_for_http().make_span_with(PathOnlyMakeSpan)" in app
|
||||
|
||||
print("share session static regression tests: passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
globalThis.location = { origin: "https://pad.example" };
|
||||
|
||||
const source = await readFile(new URL("../static/js/url-state.js", import.meta.url), "utf8");
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
const { editorResourceUrl, withShareToken } = await import(moduleUrl);
|
||||
|
||||
function parsed(path) {
|
||||
return new URL(path, "https://pad.example");
|
||||
}
|
||||
|
||||
test("workspace share token is preserved when opening a note", () => {
|
||||
const url = parsed(editorResourceUrl("/w/private/n/first", {
|
||||
shareToken: "share-token-123",
|
||||
view: "split",
|
||||
mode: "markdown",
|
||||
}));
|
||||
|
||||
assert.equal(url.pathname, "/w/private/n/first");
|
||||
assert.equal(url.searchParams.get("share"), "share-token-123");
|
||||
assert.equal(url.searchParams.get("view"), "split");
|
||||
assert.equal(url.searchParams.get("mode"), "markdown");
|
||||
});
|
||||
|
||||
test("share token is preserved when returning to the workspace", () => {
|
||||
const url = parsed(withShareToken("/w/private", "share-token-123"));
|
||||
|
||||
assert.equal(url.pathname, "/w/private");
|
||||
assert.equal(url.searchParams.get("share"), "share-token-123");
|
||||
});
|
||||
|
||||
test("existing query and hash survive share-aware navigation", () => {
|
||||
const url = parsed(editorResourceUrl("/w/private/n/first?mode=text#section", {
|
||||
shareToken: "new-token",
|
||||
view: "preview",
|
||||
mode: "markdown",
|
||||
}));
|
||||
|
||||
assert.equal(url.searchParams.get("share"), "new-token");
|
||||
assert.equal(url.searchParams.get("view"), "preview");
|
||||
assert.equal(url.searchParams.get("mode"), "markdown");
|
||||
assert.equal(url.hash, "#section");
|
||||
});
|
||||
|
||||
test("ordinary workspace navigation does not gain or retain a share token", () => {
|
||||
const url = parsed(editorResourceUrl("/w/public/n/first?share=stale-token", {
|
||||
view: "split",
|
||||
mode: "markdown",
|
||||
}));
|
||||
|
||||
assert.equal(url.searchParams.has("share"), false);
|
||||
});
|
||||
|
||||
|
||||
test("share-aware helpers reject external application URLs", () => {
|
||||
assert.equal(withShareToken("https://example.com/steal", "secret"), "/");
|
||||
});
|
||||
|
||||
|
||||
test("share-aware helpers accept same-origin absolute URLs", () => {
|
||||
const url = parsed(withShareToken("https://pad.example/w/private", "share-token-123"));
|
||||
assert.equal(url.pathname, "/w/private");
|
||||
assert.equal(url.searchParams.get("share"), "share-token-123");
|
||||
});
|
||||
Reference in New Issue
Block a user