This commit is contained in:
Mateusz Gruszczyński committed 2026-11-01 00:25:29 +01:00
1 parent 99a5700caa
commit b752f52fd8
22 files changed
+313 -138

No files matched your search

+1
View File
@@ -19,3 +19,4 @@ scripts/*.txt
# Generated by scripts/update_browser_libs.py # Generated by scripts/update_browser_libs.py
/static/libs/mermaid/ /static/libs/mermaid/
/static/libs/highlight/ /static/libs/highlight/
dist
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
DIST="$ROOT/dist"
CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
export CARGO_TARGET_DIR
fail() {
printf 'error: %s\n' "$*" >&2
exit 1
}
need() {
command -v "$1" >/dev/null 2>&1 || fail "$1 is required"
}
need cargo
need rustc
need python3
need sha256sum
need tar
need git
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3,10) else 1)' \
|| fail 'Python 3.10+ is required'
cargo fmt --version >/dev/null 2>&1 \
|| fail 'rustfmt component is required (rustup component add rustfmt)'
cargo clippy --version >/dev/null 2>&1 \
|| fail 'clippy component is required (rustup component add clippy)'
[[ -f Cargo.toml ]] || fail 'run this script from the RustPad project root'
[[ -f Cargo.lock ]] || fail 'Cargo.lock is required for a reproducible release'
[[ -f make_zip.py ]] || fail 'make_zip.py is missing'
[[ -f scripts/update_browser_libs.py ]] || fail 'scripts/update_browser_libs.py is missing'
[[ -f scripts/browser-libs.lock.json ]] || fail 'scripts/browser-libs.lock.json is missing'
[[ -d static ]] || fail 'static directory is missing'
[[ -d migrations ]] || fail 'migrations directory is missing'
git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|| fail 'release source archive requires a Git working tree'
METADATA="$(cargo metadata --locked --no-deps --format-version 1)"
read -r PACKAGE VERSION REQUIRED_RUST < <(
printf '%s' "$METADATA" | python3 -c '
import json, sys
data = json.load(sys.stdin)
packages = data.get("packages", [])
pkg = next((p for p in packages if p.get("name") == "rustpad"), None)
if pkg is None:
raise SystemExit("rustpad package not found in Cargo metadata")
print(pkg["name"], pkg["version"], pkg.get("rust_version") or "0")
'
)
[[ "$PACKAGE" == "rustpad" ]] || fail 'Cargo package rustpad was not found'
[[ -n "$VERSION" ]] || fail 'could not determine project version'
RUSTC_VERSION="$(rustc -V | awk '{print $2}')"
HOST="$(rustc -vV | sed -n 's/^host: //p')"
[[ -n "$RUSTC_VERSION" ]] || fail 'could not determine rustc version'
[[ -n "$HOST" ]] || fail 'could not determine Rust host target'
python3 - "$RUSTC_VERSION" "$REQUIRED_RUST" <<'PY' \
|| fail "Rust $REQUIRED_RUST+ is required (found $RUSTC_VERSION)"
import re
import sys
def version_tuple(value: str) -> tuple[int, int, int]:
match = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?", value)
if not match:
raise SystemExit(1)
major, minor, patch = match.groups(default="0")
return int(major), int(minor), int(patch)
raise SystemExit(0 if version_tuple(sys.argv[1]) >= version_tuple(sys.argv[2]) else 1)
PY
printf 'RustPad %s release build for %s\n' "$VERSION" "$HOST"
printf 'Preparing locked browser libraries...\n'
python3 scripts/update_browser_libs.py --locked --strict
printf '\nChecking formatting...\n'
cargo fmt --all -- --check
printf '\nRunning tests...\n'
cargo test --locked --all-targets
printf '\nRunning clippy...\n'
cargo clippy --locked --all-targets -- -D warnings
printf '\nBuilding release binary...\n'
rm -rf "$CARGO_TARGET_DIR/release"
cargo build --locked --release --bin rustpad
BIN="$CARGO_TARGET_DIR/release/rustpad"
[[ -x "$BIN" ]] || fail "release binary was not created: $BIN"
ACTUAL_VERSION="$("$BIN" --version)"
EXPECTED_VERSION="rustpad $VERSION"
[[ "$ACTUAL_VERSION" == "$EXPECTED_VERSION" ]] \
|| fail "built binary reports '$ACTUAL_VERSION', expected '$EXPECTED_VERSION'"
printf 'Built: %s\n' "$ACTUAL_VERSION"
rm -rf "$DIST"
mkdir -p "$DIST"
# Source archive. dist/ is ignored by Git, so generated release files are not included.
SOURCE_ZIP="$DIST/RustPad-${VERSION}-source.zip"
python3 make_zip.py "$SOURCE_ZIP"
[[ -s "$SOURCE_ZIP" ]] || fail "source archive was not created: $SOURCE_ZIP"
sha256sum "$SOURCE_ZIP" > "$SOURCE_ZIP.sha256"
BINARY_ARTIFACT="rustpad-${VERSION}-${HOST}"
cp "$BIN" "$DIST/$BINARY_ARTIFACT"
chmod 0755 "$DIST/$BINARY_ARTIFACT"
sha256sum "$DIST/$BINARY_ARTIFACT" > "$DIST/$BINARY_ARTIFACT.sha256"
# RustPad needs static assets and SQL migrations at runtime.
BUNDLE_NAME="RustPad-${VERSION}-${HOST}"
BUNDLE_DIR="$DIST/$BUNDLE_NAME"
BUNDLE_ARCHIVE="$DIST/$BUNDLE_NAME.tar.gz"
mkdir -p "$BUNDLE_DIR/data/db" "$BUNDLE_DIR/data/files"
cp "$BIN" "$BUNDLE_DIR/rustpad"
chmod 0755 "$BUNDLE_DIR/rustpad"
cp -a static "$BUNDLE_DIR/static"
cp -a migrations "$BUNDLE_DIR/migrations"
[[ -d systemd ]] && cp -a systemd "$BUNDLE_DIR/systemd"
[[ -f README.md ]] && cp README.md "$BUNDLE_DIR/README.md"
[[ -f LICENSE.md ]] && cp LICENSE.md "$BUNDLE_DIR/LICENSE.md"
[[ -f .env.example ]] && cp .env.example "$BUNDLE_DIR/.env.example"
cat > "$BUNDLE_DIR/RELEASE.txt" <<EOF_RELEASE
RustPad $VERSION
Target: $HOST
Rust: $RUSTC_VERSION
Run RustPad from this directory, or configure STATIC_DIR, FILES_DIR and DATABASE_URL
with absolute paths appropriate for the installation.
EOF_RELEASE
tar -C "$DIST" -czf "$BUNDLE_ARCHIVE" "$BUNDLE_NAME"
[[ -s "$BUNDLE_ARCHIVE" ]] || fail "runtime archive was not created: $BUNDLE_ARCHIVE"
sha256sum "$BUNDLE_ARCHIVE" > "$BUNDLE_ARCHIVE.sha256"
rm -rf "$BUNDLE_DIR"
printf '\nRelease ready:\n'
printf ' binary: %s\n' "$DIST/$BINARY_ARTIFACT"
printf ' checksum: %s\n' "$DIST/$BINARY_ARTIFACT.sha256"
printf ' runtime: %s\n' "$BUNDLE_ARCHIVE"
printf ' checksum: %s\n' "$BUNDLE_ARCHIVE.sha256"
printf ' source zip: %s\n' "$SOURCE_ZIP"
printf ' checksum: %s\n' "$SOURCE_ZIP.sha256"
+12 -12
View File
@@ -36,15 +36,19 @@ pub async fn create_resource_access_token(
state state
.check_rate_limit(client_limit_key, 50, window) .check_rate_limit(client_limit_key, 50, window)
.await .await
.map_err(|seconds| ApiError::rate_limited(&format!( .map_err(|seconds| {
"Too many password attempts. Try again in {seconds} seconds." ApiError::rate_limited(&format!(
)))?; "Too many password attempts. Try again in {seconds} seconds."
))
})?;
state state
.check_rate_limit(limit_key.clone(), 10, window) .check_rate_limit(limit_key.clone(), 10, window)
.await .await
.map_err(|seconds| ApiError::rate_limited(&format!( .map_err(|seconds| {
"Too many password attempts. Try again in {seconds} seconds." ApiError::rate_limited(&format!(
)))?; "Too many password attempts. Try again in {seconds} seconds."
))
})?;
match kind { match kind {
"workspace" => { "workspace" => {
let workspace = db::find_workspace(&state.db, slug) let workspace = db::find_workspace(&state.db, slug)
@@ -81,12 +85,8 @@ pub async fn create_resource_access_token(
.execute(state.db.pool()) .execute(state.db.pool())
.await?; .await?;
state.clear_rate_limit(&limit_key).await; state.clear_rate_limit(&limit_key).await;
let cookie = crate::security::resource_cookie( let cookie =
kind, crate::security::resource_cookie(kind, slug, &token, state.anonymous_access_token_ttl_days);
slug,
&token,
state.anonymous_access_token_ttl_days,
);
let mut response = Json(AccessTokenResponse { let mut response = Json(AccessTokenResponse {
granted: true, granted: true,
expires_at, expires_at,
+1
View File
@@ -13,6 +13,7 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
#[derive(Debug)]
pub struct ApiError { pub struct ApiError {
status: StatusCode, status: StatusCode,
message: String, message: String,
+4 -4
View File
@@ -485,7 +485,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
add_favorite( let _ = add_favorite(
State(state.clone()), State(state.clone()),
headers.clone(), headers.clone(),
Json(FavoriteTarget { Json(FavoriteTarget {
@@ -580,7 +580,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
add_favorite( let _ = add_favorite(
State(state.clone()), State(state.clone()),
headers.clone(), headers.clone(),
Json(FavoriteTarget { Json(FavoriteTarget {
@@ -677,7 +677,7 @@ mod tests {
HeaderValue::from_str(&format!("Bearer {share_token}")).unwrap(), HeaderValue::from_str(&format!("Bearer {share_token}")).unwrap(),
); );
add_favorite( let _ = add_favorite(
State(state.clone()), State(state.clone()),
headers.clone(), headers.clone(),
Json(FavoriteTarget { Json(FavoriteTarget {
@@ -755,7 +755,7 @@ mod tests {
HeaderValue::from_str(&format!("Bearer {access_token}")).unwrap(), HeaderValue::from_str(&format!("Bearer {access_token}")).unwrap(),
); );
add_favorite( let _ = add_favorite(
State(state.clone()), State(state.clone()),
headers.clone(), headers.clone(),
Json(FavoriteTarget { Json(FavoriteTarget {
+61 -49
View File
@@ -101,10 +101,8 @@ pub async fn upload_pad_file(
key = crate::storage::object_key("pads", pad.id, &file_token, &stored); key = crate::storage::object_key("pads", pad.id, &file_token, &stored);
} }
let stored_url = crate::file_urls::stored_file_path(&file_token, &stored); let stored_url = crate::file_urls::stored_file_path(&file_token, &stored);
let public_url = crate::file_urls::public_file_url( let public_url =
state.files_public_url.as_deref(), crate::file_urls::public_file_url(state.files_public_url.as_deref(), &stored_url);
&stored_url,
);
let mime = mime_guess::from_path(&stored) let mime = mime_guess::from_path(&stored)
.first_or_octet_stream() .first_or_octet_stream()
.to_string(); .to_string();
@@ -123,7 +121,9 @@ pub async fn upload_pad_file(
bytes.len() as i64, bytes.len() as i64,
) )
.await?; .await?;
Ok(Json(serde_json::json!({"name": stored, "url": public_url, "mime_type": mime}))) Ok(Json(
serde_json::json!({"name": stored, "url": public_url, "mime_type": mime}),
))
} }
pub(super) fn content_references_file(content: &str, filename: &str, url: &str) -> bool { pub(super) fn content_references_file(content: &str, filename: &str, url: &str) -> bool {
@@ -134,9 +134,7 @@ pub(super) fn content_references_file(content: &str, filename: &str, url: &str)
let mut remaining = content; let mut remaining = content;
while let Some(index) = remaining.find(marker) { while let Some(index) = remaining.find(marker) {
let after = &remaining[index + marker.len()..]; let after = &remaining[index + marker.len()..];
let end = after let end = after.find([',', ']']).unwrap_or(after.len());
.find(|character| character == ',' || character == ']')
.unwrap_or(after.len());
if after[..end].trim() == filename { if after[..end].trim() == filename {
return true; return true;
} }
@@ -202,10 +200,7 @@ pub async fn pad_files(
}; };
} }
file.created_at = db::normalize_timestamp(&file.created_at); file.created_at = db::normalize_timestamp(&file.created_at);
file.url = crate::file_urls::public_file_url( file.url = crate::file_urls::public_file_url(state.files_public_url.as_deref(), &file.url);
state.files_public_url.as_deref(),
&file.url,
);
} }
Ok(Json(files)) Ok(Json(files))
} }
@@ -225,14 +220,10 @@ pub async fn delete_pad_file(
&headers, &headers,
) )
.await?; .await?;
let account_owner = crate::auth::is_resource_owner( let account_owner =
&state, crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers))
"pad", .await
&pad.slug, .unwrap_or(false);
bearer_token(&headers),
)
.await
.unwrap_or(false);
let guest_owner = pad_creator_is_requester(&headers, &pad); let guest_owner = pad_creator_is_requester(&headers, &pad);
let password_write_access = let password_write_access =
has_password_write_access(&state, &headers, "pad", &pad.slug).await?; has_password_write_access(&state, &headers, "pad", &pad.slug).await?;
@@ -296,7 +287,12 @@ pub async fn upload_note_file(
&workspace_slug, &workspace_slug,
&note_slug, &note_slug,
password.as_deref(), password.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, access_token.as_deref()), resource_request_token(
&headers,
"workspace",
&workspace_slug,
access_token.as_deref(),
),
bearer_token(&headers), bearer_token(&headers),
&headers, &headers,
) )
@@ -312,7 +308,12 @@ pub async fn upload_note_file(
&headers, &headers,
"workspace", "workspace",
&workspace_slug, &workspace_slug,
resource_request_token(&headers, "workspace", &workspace_slug, access_token.as_deref()), resource_request_token(
&headers,
"workspace",
&workspace_slug,
access_token.as_deref(),
),
bearer_token(&headers), bearer_token(&headers),
) )
.await? .await?
@@ -342,10 +343,8 @@ pub async fn upload_note_file(
key = crate::storage::object_key("notes", note.id, &file_token, &stored); key = crate::storage::object_key("notes", note.id, &file_token, &stored);
} }
let stored_url = crate::file_urls::stored_file_path(&file_token, &stored); let stored_url = crate::file_urls::stored_file_path(&file_token, &stored);
let public_url = crate::file_urls::public_file_url( let public_url =
state.files_public_url.as_deref(), crate::file_urls::public_file_url(state.files_public_url.as_deref(), &stored_url);
&stored_url,
);
let mime = mime_guess::from_path(&stored) let mime = mime_guess::from_path(&stored)
.first_or_octet_stream() .first_or_octet_stream()
.to_string(); .to_string();
@@ -364,7 +363,9 @@ pub async fn upload_note_file(
bytes.len() as i64, bytes.len() as i64,
) )
.await?; .await?;
Ok(Json(serde_json::json!({"name": stored, "url": public_url, "mime_type": mime}))) Ok(Json(
serde_json::json!({"name": stored, "url": public_url, "mime_type": mime}),
))
} }
pub async fn delete_note( pub async fn delete_note(
@@ -378,7 +379,12 @@ pub async fn delete_note(
&workspace_slug, &workspace_slug,
&note_slug, &note_slug,
payload.password.as_deref(), payload.password.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers), bearer_token(&headers),
&headers, &headers,
) )
@@ -394,7 +400,12 @@ pub async fn delete_note(
&headers, &headers,
"workspace", "workspace",
&workspace_slug, &workspace_slug,
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers), bearer_token(&headers),
) )
.await? .await?
@@ -415,12 +426,8 @@ pub async fn delete_note(
.await .await
.map_err(|_| ApiError::internal("Failed to delete note files"))?; .map_err(|_| ApiError::internal("Failed to delete note files"))?;
} }
db::delete_resource_editor_state( db::delete_resource_editor_state(&state.db, "note", &format!("{workspace_slug}/{note_slug}"))
&state.db, .await?;
"note",
&format!("{workspace_slug}/{note_slug}"),
)
.await?;
db::delete_note(&state.db, note.id).await?; db::delete_note(&state.db, note.id).await?;
Ok(Json(serde_json::json!({"ok": true, "archived": false}))) Ok(Json(serde_json::json!({"ok": true, "archived": false})))
} }
@@ -436,7 +443,12 @@ pub async fn note_files(
&workspace_slug, &workspace_slug,
&note_slug, &note_slug,
payload.password.as_deref(), payload.password.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers), bearer_token(&headers),
&headers, &headers,
) )
@@ -459,10 +471,7 @@ pub async fn note_files(
}; };
} }
file.created_at = db::normalize_timestamp(&file.created_at); file.created_at = db::normalize_timestamp(&file.created_at);
file.url = crate::file_urls::public_file_url( file.url = crate::file_urls::public_file_url(state.files_public_url.as_deref(), &file.url);
state.files_public_url.as_deref(),
&file.url,
);
} }
Ok(Json(files)) Ok(Json(files))
} }
@@ -478,7 +487,12 @@ pub async fn delete_note_file(
&workspace_slug, &workspace_slug,
&note_slug, &note_slug,
payload.password.as_deref(), payload.password.as_deref(),
resource_request_token(&headers, "workspace", &workspace_slug, payload.access_token.as_deref()), resource_request_token(
&headers,
"workspace",
&workspace_slug,
payload.access_token.as_deref(),
),
bearer_token(&headers), bearer_token(&headers),
&headers, &headers,
) )
@@ -627,10 +641,9 @@ async fn serve_token_file(
HeaderValue::from_str(&format!("{disposition}; filename=\"{safe}\"")) HeaderValue::from_str(&format!("{disposition}; filename=\"{safe}\""))
.expect("sanitized attachment filename"), .expect("sanitized attachment filename"),
); );
response.headers_mut().insert( response
header::ACCEPT_RANGES, .headers_mut()
HeaderValue::from_static("bytes"), .insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
);
response.headers_mut().insert( response.headers_mut().insert(
header::CONTENT_LENGTH, header::CONTENT_LENGTH,
HeaderValue::from_str(&body_len.to_string()).expect("valid content length"), HeaderValue::from_str(&body_len.to_string()).expect("valid content length"),
@@ -669,10 +682,9 @@ fn range_not_satisfiable(total_len: usize) -> Response {
header::CONTENT_RANGE, header::CONTENT_RANGE,
HeaderValue::from_str(&format!("bytes */{total_len}")).expect("valid content range"), HeaderValue::from_str(&format!("bytes */{total_len}")).expect("valid content range"),
); );
response.headers_mut().insert( response
header::ACCEPT_RANGES, .headers_mut()
HeaderValue::from_static("bytes"), .insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
);
response response
} }
+3 -2
View File
@@ -583,6 +583,7 @@ async fn user_editor_preferences(
)) ))
} }
#[allow(clippy::too_many_arguments)]
async fn save_editor_settings( async fn save_editor_settings(
state: &SharedState, state: &SharedState,
headers: &HeaderMap, headers: &HeaderMap,
@@ -1043,7 +1044,7 @@ pub async fn open_workspace(
let page = query.page.max(1); let page = query.page.max(1);
let per_page = normalize_list_per_page(query.per_page); let per_page = normalize_list_per_page(query.per_page);
let total = notes.len(); let total = notes.len();
let total_pages = ((total + per_page - 1) / per_page).max(1); let total_pages = total.div_ceil(per_page).max(1);
let page = page.min(total_pages); let page = page.min(total_pages);
let start = (page - 1) * per_page; let start = (page - 1) * per_page;
let notes = notes.into_iter().skip(start).take(per_page).collect(); let notes = notes.into_iter().skip(start).take(per_page).collect();
@@ -1142,7 +1143,7 @@ pub async fn create_note(
title, title,
payload.protect, payload.protect,
created_by.as_deref(), created_by.as_deref(),
created_by_guest_id.as_deref(), created_by_guest_id,
) )
.await?; .await?;
if let Some(content) = initial_content { if let Some(content) = initial_content {
+1
View File
@@ -118,6 +118,7 @@ async fn share_session_redirect(
response response
} }
#[allow(clippy::too_many_arguments)]
fn render_editor_page( fn render_editor_page(
state: &SharedState, state: &SharedState,
entrypoint: &str, entrypoint: &str,
+1 -1
View File
@@ -233,7 +233,7 @@ fn escape_filter(value: &str) -> String {
b')' => result.push_str("\\29"), b')' => result.push_str("\\29"),
b'\\' => result.push_str("\\5c"), b'\\' => result.push_str("\\5c"),
0 => result.push_str("\\00"), 0 => result.push_str("\\00"),
value if value < 0x20 || value >= 0x7f => result.push_str(&format!("\\{value:02x}")), value if !(0x20..0x7f).contains(&value) => result.push_str(&format!("\\{value:02x}")),
value => result.push(value as char), value => result.push(value as char),
} }
} }
+13 -15
View File
@@ -480,7 +480,7 @@ pub async fn register(
.ok_or_else(|| AuthError::internal("Failed to create the account."))?; .ok_or_else(|| AuthError::internal("Failed to create the account."))?;
let mut confirmation_token = None; let mut confirmation_token = None;
if state.smtp.is_some() { if let Some(smtp) = &state.smtp {
let token = random_confirmation_token(); let token = random_confirmation_token();
if state.account_confirmation_required { if state.account_confirmation_required {
let expires = (Utc::now() + Duration::hours(24)).to_rfc3339(); let expires = (Utc::now() + Duration::hours(24)).to_rfc3339();
@@ -506,9 +506,7 @@ pub async fn register(
tx.commit().await.map_err(AuthError::database)?; tx.commit().await.map_err(AuthError::database)?;
confirmation_token = Some(token.as_str()); confirmation_token = Some(token.as_str());
} }
if let Err(error) = if let Err(error) = send_registration_email(smtp, &user, confirmation_token).await {
send_registration_email(state.smtp.as_ref().unwrap(), &user, confirmation_token).await
{
if state.account_confirmation_required { if state.account_confirmation_required {
if let Err(delete_error) = if let Err(delete_error) =
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER)) sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER))
@@ -638,16 +636,16 @@ pub async fn resend_confirmation(
.fetch_optional(state.db.pool()) .fetch_optional(state.db.pool())
.await .await
.map_err(AuthError::database)?; .map_err(AuthError::database)?;
if let Some(value) = last_created { if let Some(value) = last_created
if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&value) { && let Ok(created) = chrono::DateTime::parse_from_rfc3339(&value)
let available = created.with_timezone(&Utc) + Duration::minutes(10); {
if available > Utc::now() { let available = created.with_timezone(&Utc) + Duration::minutes(10);
let seconds = (available - Utc::now()).num_seconds().max(1); if available > Utc::now() {
return Err(AuthError::bad_request(&format!( let seconds = (available - Utc::now()).num_seconds().max(1);
"A new confirmation e-mail can be sent in {} minute(s).", return Err(AuthError::bad_request(&format!(
(seconds + 59) / 60 "A new confirmation e-mail can be sent in {} minute(s).",
))); (seconds + 59) / 60
} )));
} }
} }
sqlx::query(queries::get( sqlx::query(queries::get(
@@ -1345,7 +1343,7 @@ pub async fn resources(
let page = query.page.max(1); let page = query.page.max(1);
let per_page = normalized_per_page(query.per_page); let per_page = normalized_per_page(query.per_page);
let total = items.len(); let total = items.len();
let total_pages = ((total + per_page - 1) / per_page).max(1); let total_pages = total.div_ceil(per_page).max(1);
let page = page.min(total_pages); let page = page.min(total_pages);
let start = (page - 1) * per_page; let start = (page - 1) * per_page;
let items = items let items = items
+4 -5
View File
@@ -219,11 +219,10 @@ impl Config {
if self.static_dir.trim().is_empty() || self.files_dir.trim().is_empty() { if self.static_dir.trim().is_empty() || self.files_dir.trim().is_empty() {
return Err("STATIC_DIR and FILES_DIR cannot be empty".into()); return Err("STATIC_DIR and FILES_DIR cannot be empty".into());
} }
if let Some(smtp) = &self.smtp { if let Some(smtp) = &self.smtp
if !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://")) && !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://"))
{ {
return Err("PUBLIC_URL must start with http:// or https://".into()); return Err("PUBLIC_URL must start with http:// or https://".into());
}
} }
Ok(()) Ok(())
} }
+13 -17
View File
@@ -317,14 +317,11 @@ pub async fn note_slug_exists_any(
workspace_id: i64, workspace_id: i64,
slug: &str, slug: &str,
) -> Result<bool, sqlx::Error> { ) -> Result<bool, sqlx::Error> {
let count: i64 = sqlx::query_scalar(queries::get( let count: i64 = sqlx::query_scalar(queries::get(pool.kind(), queries::NOTE_SLUG_EXISTS_ANY))
pool.kind(), .bind(workspace_id)
queries::NOTE_SLUG_EXISTS_ANY, .bind(slug)
)) .fetch_one(pool.pool())
.bind(workspace_id) .await?;
.bind(slug)
.fetch_one(pool.pool())
.await?;
Ok(count > 0) Ok(count > 0)
} }
@@ -507,6 +504,7 @@ pub async fn save_revision(
Ok((revision_id, updated_at)) Ok((revision_id, updated_at))
} }
#[allow(clippy::too_many_arguments)]
pub async fn save_collaborative_revision( pub async fn save_collaborative_revision(
pool: &Database, pool: &Database,
note_id: i64, note_id: i64,
@@ -680,16 +678,14 @@ pub async fn create_pad(
.await .await
} }
pub async fn rename_pad( pub async fn rename_pad(pool: &Database, pad_id: i64, title: &str) -> Result<(), sqlx::Error> {
pool: &Database,
pad_id: i64,
title: &str,
) -> Result<(), sqlx::Error> {
let query = match pool.kind() { let query = match pool.kind() {
DatabaseKind::Postgres => DatabaseKind::Postgres => {
"UPDATE pads SET title = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2", "UPDATE pads SET title = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2"
DatabaseKind::Sqlite | DatabaseKind::MySql => }
"UPDATE pads SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", DatabaseKind::Sqlite | DatabaseKind::MySql => {
"UPDATE pads SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"
}
}; };
sqlx::query(query) sqlx::query(query)
.bind(title) .bind(title)
+4 -4
View File
@@ -447,10 +447,10 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for PostgresPublishedPageRow {
pub async fn pad_public_page_disabled(pool: &Database, pad_id: i64) -> Result<bool, sqlx::Error> { pub async fn pad_public_page_disabled(pool: &Database, pad_id: i64) -> Result<bool, sqlx::Error> {
let query = queries::get(pool.kind(), queries::PAD_PUBLIC_PAGE_DISABLED); let query = queries::get(pool.kind(), queries::PAD_PUBLIC_PAGE_DISABLED);
if pool.kind() == DatabaseKind::Postgres { if pool.kind() == DatabaseKind::Postgres {
return Ok(sqlx::query_scalar::<_, bool>(query) return sqlx::query_scalar::<_, bool>(query)
.bind(pad_id) .bind(pad_id)
.fetch_one(pool.pool()) .fetch_one(pool.pool())
.await?); .await;
} }
Ok(sqlx::query_scalar::<_, i64>(query) Ok(sqlx::query_scalar::<_, i64>(query)
.bind(pad_id) .bind(pad_id)
@@ -462,10 +462,10 @@ pub async fn pad_public_page_disabled(pool: &Database, pad_id: i64) -> Result<bo
pub async fn note_public_page_disabled(pool: &Database, note_id: i64) -> Result<bool, sqlx::Error> { pub async fn note_public_page_disabled(pool: &Database, note_id: i64) -> Result<bool, sqlx::Error> {
let query = queries::get(pool.kind(), queries::NOTE_PUBLIC_PAGE_DISABLED); let query = queries::get(pool.kind(), queries::NOTE_PUBLIC_PAGE_DISABLED);
if pool.kind() == DatabaseKind::Postgres { if pool.kind() == DatabaseKind::Postgres {
return Ok(sqlx::query_scalar::<_, bool>(query) return sqlx::query_scalar::<_, bool>(query)
.bind(note_id) .bind(note_id)
.fetch_one(pool.pool()) .fetch_one(pool.pool())
.await?); .await;
} }
Ok(sqlx::query_scalar::<_, i64>(query) Ok(sqlx::query_scalar::<_, i64>(query)
.bind(note_id) .bind(note_id)
+1 -4
View File
@@ -53,10 +53,7 @@ pub fn canonical_file_path(value: &str) -> Option<String> {
let path_start = after_scheme.find('/')?; let path_start = after_scheme.find('/')?;
&after_scheme[path_start..] &after_scheme[path_start..]
}; };
let path = path let path = path.split(['?', '#']).next().unwrap_or(path);
.split(|character| matches!(character, '?' | '#'))
.next()
.unwrap_or(path);
if !path.starts_with(FILE_ROUTE_PREFIX) { if !path.starts_with(FILE_ROUTE_PREFIX) {
return None; return None;
} }
+2 -3
View File
@@ -86,10 +86,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.database_url .database_url
.strip_prefix("sqlite://") .strip_prefix("sqlite://")
.and_then(|v| v.split('?').next()) .and_then(|v| v.split('?').next())
&& let Some(parent) = std::path::Path::new(path).parent()
{ {
if let Some(parent) = std::path::Path::new(path).parent() { std::fs::create_dir_all(parent)?;
std::fs::create_dir_all(parent)?;
}
} }
info!("connecting to database"); info!("connecting to database");
let db = Database::connect(&config.database_url, config.database_max_connections).await?; let db = Database::connect(&config.database_url, config.database_max_connections).await?;
+4 -1
View File
@@ -203,7 +203,10 @@ pub fn websocket_origin_allowed(headers: &HeaderMap) -> bool {
fn resource_cookie_name(kind: &str, slug: &str) -> String { fn resource_cookie_name(kind: &str, slug: &str) -> String {
let digest = Sha256::digest(format!("{kind}:{slug}").as_bytes()); let digest = Sha256::digest(format!("{kind}:{slug}").as_bytes());
format!("{RESOURCE_ACCESS_COOKIE_PREFIX}{}", hex::encode(&digest[..12])) format!(
"{RESOURCE_ACCESS_COOKIE_PREFIX}{}",
hex::encode(&digest[..12])
)
} }
fn valid_resource_access_cookie_name(name: &str) -> bool { fn valid_resource_access_cookie_name(name: &str) -> bool {
+6 -10
View File
@@ -129,6 +129,7 @@ pub struct AppState {
} }
impl AppState { impl AppState {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
db: Database, db: Database,
asset_version: String, asset_version: String,
@@ -183,14 +184,13 @@ impl AppState {
let mut limits = self.rate_limits.lock().await; let mut limits = self.rate_limits.lock().await;
if !limits.contains_key(&key) && limits.len() >= MAX_RATE_LIMIT_ENTRIES { if !limits.contains_key(&key) && limits.len() >= MAX_RATE_LIMIT_ENTRIES {
limits.retain(|_, entry| now.duration_since(entry.started_at) < entry.window); limits.retain(|_, entry| now.duration_since(entry.started_at) < entry.window);
if limits.len() >= MAX_RATE_LIMIT_ENTRIES { if limits.len() >= MAX_RATE_LIMIT_ENTRIES
if let Some(oldest_key) = limits && let Some(oldest_key) = limits
.iter() .iter()
.min_by_key(|(_, entry)| entry.started_at) .min_by_key(|(_, entry)| entry.started_at)
.map(|(key, _)| key.clone()) .map(|(key, _)| key.clone())
{ {
limits.remove(&oldest_key); limits.remove(&oldest_key);
}
} }
} }
let entry = limits.entry(key).or_insert(RateLimitEntry { let entry = limits.entry(key).or_insert(RateLimitEntry {
@@ -283,11 +283,7 @@ impl AppState {
pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender<RoomEvent> { pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender<RoomEvent> {
self.channel_for_key(Self::pad_room_key(slug)).await self.channel_for_key(Self::pad_room_key(slug)).await
} }
pub async fn notify_pad_password_required( pub async fn notify_pad_password_required(&self, slug: &str, except_client_id: Option<String>) {
&self,
slug: &str,
except_client_id: Option<String>,
) {
let key = Self::pad_room_key(slug); let key = Self::pad_room_key(slug);
let sender = self.channels.read().await.get(&key).cloned(); let sender = self.channels.read().await.get(&key).cloned();
if let Some(sender) = sender { if let Some(sender) = sender {
+12 -3
View File
@@ -72,9 +72,18 @@ fn video_alias_is_still_attached() {
#[test] #[test]
fn byte_ranges_support_video_seeking() { fn byte_ranges_support_video_seeking() {
assert_eq!(parse_byte_range("bytes=0-99", 1_000), Ok(Some((0, 100)))); assert_eq!(parse_byte_range("bytes=0-99", 1_000), Ok(Some((0, 100))));
assert_eq!(parse_byte_range("bytes=500-", 1_000), Ok(Some((500, 1_000)))); assert_eq!(
assert_eq!(parse_byte_range("bytes=-100", 1_000), Ok(Some((900, 1_000)))); parse_byte_range("bytes=500-", 1_000),
assert_eq!(parse_byte_range("bytes=900-2000", 1_000), Ok(Some((900, 1_000)))); Ok(Some((500, 1_000)))
);
assert_eq!(
parse_byte_range("bytes=-100", 1_000),
Ok(Some((900, 1_000)))
);
assert_eq!(
parse_byte_range("bytes=900-2000", 1_000),
Ok(Some((900, 1_000)))
);
assert_eq!(parse_byte_range("bytes=1000-", 1_000), Err(())); assert_eq!(parse_byte_range("bytes=1000-", 1_000), Err(()));
assert_eq!(parse_byte_range("bytes=0-1,4-5", 1_000), Err(())); assert_eq!(parse_byte_range("bytes=0-1,4-5", 1_000), Err(()));
} }
+3 -3
View File
@@ -14,8 +14,8 @@ fn boolean_projections_are_normalized_for_sqlx_any() {
// MySQL BOOLEAN is TINYINT(1), which sqlx::Any 0.8 cannot map directly. // MySQL BOOLEAN is TINYINT(1), which sqlx::Any 0.8 cannot map directly.
for (query, expected_casts) in [ for (query, expected_casts) in [
(Query::RESOURCE_EDITOR_SETTINGS_SELECT, 1), (Query::RESOURCE_EDITOR_SETTINGS_SELECT, 1),
(Query::EDITOR_PREFERENCES_SELECT_PAD, 5), (Query::EDITOR_PREFERENCES_SELECT_PAD, 6),
(Query::EDITOR_PREFERENCES_SELECT_NOTE, 5), (Query::EDITOR_PREFERENCES_SELECT_NOTE, 6),
(Query::AUTH_USER_BY_EXTERNAL_ID, 1), (Query::AUTH_USER_BY_EXTERNAL_ID, 1),
(Query::AUTH_USER_BY_SESSION, 1), (Query::AUTH_USER_BY_SESSION, 1),
(Query::AUTH_USER_BY_NICKNAME, 1), (Query::AUTH_USER_BY_NICKNAME, 1),
@@ -27,7 +27,7 @@ fn boolean_projections_are_normalized_for_sqlx_any() {
(Query::USER_FAVORITE_NOTE_LIST, 2), (Query::USER_FAVORITE_NOTE_LIST, 2),
(Query::PAD_PUBLIC_PAGE_DISABLED, 1), (Query::PAD_PUBLIC_PAGE_DISABLED, 1),
(Query::NOTE_PUBLIC_PAGE_DISABLED, 1), (Query::NOTE_PUBLIC_PAGE_DISABLED, 1),
(Query::Q001, 1), (Query::Q001, 2),
(Query::Q003, 1), (Query::Q003, 1),
(Query::Q004, 1), (Query::Q004, 1),
(Query::Q011, 1), (Query::Q011, 1),
+6 -5
View File
@@ -84,7 +84,8 @@ fn rejects_missing_websocket_origin() {
#[test] #[test]
fn secure_cookies_are_not_script_readable() { fn secure_cookies_are_not_script_readable() {
let value = session_cookie("abc123", 7).to_str().unwrap(); let cookie = session_cookie("abc123", 7);
let value = cookie.to_str().unwrap();
assert!(value.contains("HttpOnly")); assert!(value.contains("HttpOnly"));
assert!(value.contains("Secure")); assert!(value.contains("Secure"));
assert!(value.contains("SameSite=Lax")); assert!(value.contains("SameSite=Lax"));
@@ -93,9 +94,8 @@ fn secure_cookies_are_not_script_readable() {
#[test] #[test]
fn share_sessions_use_separate_scoped_opaque_cookies() { fn share_sessions_use_separate_scoped_opaque_cookies() {
let value = share_session_cookie("workspace", "private-space", "opaque", 600) let cookie = share_session_cookie("workspace", "private-space", "opaque", 600);
.to_str() let value = cookie.to_str().unwrap();
.unwrap();
assert!(value.starts_with("__Host-rustpad_share_")); assert!(value.starts_with("__Host-rustpad_share_"));
assert!(value.contains("=opaque;")); assert!(value.contains("=opaque;"));
assert!(value.contains("Max-Age=600")); assert!(value.contains("Max-Age=600"));
@@ -166,7 +166,8 @@ fn csrf_requires_matching_cookie_and_header() {
#[test] #[test]
fn csrf_cookie_is_strict_and_script_readable() { fn csrf_cookie_is_strict_and_script_readable() {
let token = "a".repeat(CSRF_TOKEN_BYTES * 2); let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
let value = csrf_cookie(&token).to_str().unwrap(); let cookie = csrf_cookie(&token);
let value = cookie.to_str().unwrap();
assert!(value.contains("Secure")); assert!(value.contains("Secure"));
assert!(value.contains("SameSite=Strict")); assert!(value.contains("SameSite=Strict"));
assert!(!value.contains("HttpOnly")); assert!(!value.contains("HttpOnly"));
+1
View File
@@ -594,6 +594,7 @@ pub async fn upgrade(
}) })
} }
#[allow(clippy::too_many_arguments)]
async fn handle_socket( async fn handle_socket(
mut socket: WebSocket, mut socket: WebSocket,
state: SharedState, state: SharedState,
+2
View File
@@ -77,6 +77,8 @@ pub async fn upgrade_pad(
) )
}) })
} }
#[allow(clippy::too_many_arguments)]
async fn handle_pad_socket( async fn handle_pad_socket(
mut socket: WebSocket, mut socket: WebSocket,
state: SharedState, state: SharedState,