Compare commits

..
2 Commits
Author SHA1 Message Date
Mateusz Gruszczyński e3ee6319b9 fix2 tokens 2026-08-03 01:35:05 +02:00
Mateusz Gruszczyński 49dad1a5f4 fix1 tokens 2026-08-03 01:34:13 +02:00
25 changed files with 1483 additions and 177 deletions
+1 -1
View File
@@ -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);
+13 -1
View File
@@ -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
View File
@@ -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()),
+118 -36
View File
@@ -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?
>= AccessLevel::Write
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, &note_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
View File
@@ -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
View File
@@ -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)
+222 -34
View File
@@ -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,19 +264,39 @@ 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(
&state,
"pad",
"pad",
&pad.title,
"RustPad",
"/",
"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(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",
&pad.title,
"RustPad",
"/",
"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,25 +425,39 @@ pub(super) async fn note(
};
match db::find_note(&state.db, workspace.id, &note_slug).await {
Ok(Some(note)) => render_editor_page(
&state,
"note",
"note",
if workspace.is_private != 0 {
"Note"
} else {
&note.title
},
if workspace.is_private != 0 {
"Workspace"
} else {
&workspace.title
},
&format!("/w/{workspace_slug}"),
"",
"workspace",
"",
),
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",
if workspace.is_private != 0 {
"Note"
} else {
&note.title
},
if workspace.is_private != 0 {
"Workspace"
} else {
&workspace.title
},
&format!("/w/{workspace_slug}"),
"",
"workspace",
"",
)
}
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
@@ -400,3 +563,28 @@ fn escape_html(value: &str) -> String {
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
#[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
View File
@@ -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
View File
@@ -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(
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 = ?"#
+49
View File
@@ -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);
+132 -41
View File
@@ -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()
.as_deref()
{
Some("rw") => return Some("rw".into()),
Some("ro") => read_allowed = true,
_ => {}
}
}
match auth::account_resource_permission(state, kind, slug, session_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") => 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)
.await
.unwrap_or(false)
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,18 +385,17 @@ 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(
&state,
"workspace",
&workspace_slug,
access_token.as_deref(),
)
.await;
let anonymous_token_ok = password_access_from_tokens(
&state,
"workspace",
&workspace_slug,
&external_tokens,
)
.await;
let password_limit_key = format!("resource-password:{client_key}:workspace:{workspace_slug}");
let password_attempted = password
.as_deref()
@@ -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,12 +541,42 @@ 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 {
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;},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,&note_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
_=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;},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,&note_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
}
}
}
}
+75 -16
View File
@@ -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,12 +309,42 @@ async fn handle_pad_socket(
Some(Ok(_))=>{},
Some(Err(error))=>{debug!(%error,"pad websocket receive error");break;}
},
update=updates.recv()=>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;},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_pad(&state.db,&slug).await { if send_pad_split(&mut sender,&PadServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
_=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;},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_pad(&state.db,&slug).await { if send_pad_split(&mut sender,&PadServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
}
}
}
}
+2 -4
View File
@@ -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"); }
});
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -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;
+207
View File
@@ -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()
+121
View File
@@ -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()
+67
View File
@@ -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");
});