diff --git a/.gitignore b/.gitignore index 6cf3619..e928f15 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ scripts/*.txt # Generated by scripts/update_browser_libs.py /static/libs/mermaid/ /static/libs/highlight/ +dist diff --git a/build-release.sh b/build-release.sh new file mode 100755 index 0000000..a912d81 --- /dev/null +++ b/build-release.sh @@ -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" < "$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" diff --git a/src/api/access_tokens.rs b/src/api/access_tokens.rs index 13d01c0..bd3fa27 100644 --- a/src/api/access_tokens.rs +++ b/src/api/access_tokens.rs @@ -36,15 +36,19 @@ pub async fn create_resource_access_token( state .check_rate_limit(client_limit_key, 50, window) .await - .map_err(|seconds| ApiError::rate_limited(&format!( - "Too many password attempts. Try again in {seconds} seconds." - )))?; + .map_err(|seconds| { + ApiError::rate_limited(&format!( + "Too many password attempts. Try again in {seconds} seconds." + )) + })?; state .check_rate_limit(limit_key.clone(), 10, window) .await - .map_err(|seconds| ApiError::rate_limited(&format!( - "Too many password attempts. Try again in {seconds} seconds." - )))?; + .map_err(|seconds| { + ApiError::rate_limited(&format!( + "Too many password attempts. Try again in {seconds} seconds." + )) + })?; match kind { "workspace" => { let workspace = db::find_workspace(&state.db, slug) @@ -81,12 +85,8 @@ pub async fn create_resource_access_token( .execute(state.db.pool()) .await?; state.clear_rate_limit(&limit_key).await; - let cookie = crate::security::resource_cookie( - kind, - slug, - &token, - state.anonymous_access_token_ttl_days, - ); + let cookie = + crate::security::resource_cookie(kind, slug, &token, state.anonymous_access_token_ttl_days); let mut response = Json(AccessTokenResponse { granted: true, expires_at, diff --git a/src/api/error.rs b/src/api/error.rs index 8480105..4fb122a 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -13,6 +13,7 @@ use axum::{ response::{IntoResponse, Response}, }; +#[derive(Debug)] pub struct ApiError { status: StatusCode, message: String, diff --git a/src/api/favorites.rs b/src/api/favorites.rs index cb449ee..42b7c31 100644 --- a/src/api/favorites.rs +++ b/src/api/favorites.rs @@ -485,7 +485,7 @@ mod tests { .await .unwrap(); - add_favorite( + let _ = add_favorite( State(state.clone()), headers.clone(), Json(FavoriteTarget { @@ -580,7 +580,7 @@ mod tests { .await .unwrap(); - add_favorite( + let _ = add_favorite( State(state.clone()), headers.clone(), Json(FavoriteTarget { @@ -677,7 +677,7 @@ mod tests { HeaderValue::from_str(&format!("Bearer {share_token}")).unwrap(), ); - add_favorite( + let _ = add_favorite( State(state.clone()), headers.clone(), Json(FavoriteTarget { @@ -755,7 +755,7 @@ mod tests { HeaderValue::from_str(&format!("Bearer {access_token}")).unwrap(), ); - add_favorite( + let _ = add_favorite( State(state.clone()), headers.clone(), Json(FavoriteTarget { diff --git a/src/api/files.rs b/src/api/files.rs index 984d1de..87f685b 100644 --- a/src/api/files.rs +++ b/src/api/files.rs @@ -101,10 +101,8 @@ pub async fn upload_pad_file( key = crate::storage::object_key("pads", pad.id, &file_token, &stored); } let stored_url = crate::file_urls::stored_file_path(&file_token, &stored); - let public_url = crate::file_urls::public_file_url( - state.files_public_url.as_deref(), - &stored_url, - ); + let public_url = + crate::file_urls::public_file_url(state.files_public_url.as_deref(), &stored_url); let mime = mime_guess::from_path(&stored) .first_or_octet_stream() .to_string(); @@ -123,7 +121,9 @@ pub async fn upload_pad_file( bytes.len() as i64, ) .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 { @@ -134,9 +134,7 @@ pub(super) fn content_references_file(content: &str, filename: &str, url: &str) let mut remaining = content; while let Some(index) = remaining.find(marker) { let after = &remaining[index + marker.len()..]; - let end = after - .find(|character| character == ',' || character == ']') - .unwrap_or(after.len()); + let end = after.find([',', ']']).unwrap_or(after.len()); if after[..end].trim() == filename { return true; } @@ -202,10 +200,7 @@ pub async fn pad_files( }; } file.created_at = db::normalize_timestamp(&file.created_at); - file.url = crate::file_urls::public_file_url( - state.files_public_url.as_deref(), - &file.url, - ); + file.url = crate::file_urls::public_file_url(state.files_public_url.as_deref(), &file.url); } Ok(Json(files)) } @@ -225,14 +220,10 @@ pub async fn delete_pad_file( &headers, ) .await?; - let account_owner = crate::auth::is_resource_owner( - &state, - "pad", - &pad.slug, - bearer_token(&headers), - ) - .await - .unwrap_or(false); + let account_owner = + crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers)) + .await + .unwrap_or(false); let guest_owner = pad_creator_is_requester(&headers, &pad); let password_write_access = has_password_write_access(&state, &headers, "pad", &pad.slug).await?; @@ -296,7 +287,12 @@ pub async fn upload_note_file( &workspace_slug, ¬e_slug, 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), &headers, ) @@ -312,7 +308,12 @@ pub async fn upload_note_file( &headers, "workspace", &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), ) .await? @@ -342,10 +343,8 @@ pub async fn upload_note_file( key = crate::storage::object_key("notes", note.id, &file_token, &stored); } let stored_url = crate::file_urls::stored_file_path(&file_token, &stored); - let public_url = crate::file_urls::public_file_url( - state.files_public_url.as_deref(), - &stored_url, - ); + let public_url = + crate::file_urls::public_file_url(state.files_public_url.as_deref(), &stored_url); let mime = mime_guess::from_path(&stored) .first_or_octet_stream() .to_string(); @@ -364,7 +363,9 @@ pub async fn upload_note_file( bytes.len() as i64, ) .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( @@ -378,7 +379,12 @@ pub async fn delete_note( &workspace_slug, ¬e_slug, 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), &headers, ) @@ -394,7 +400,12 @@ pub async fn delete_note( &headers, "workspace", &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), ) .await? @@ -415,12 +426,8 @@ pub async fn delete_note( .await .map_err(|_| ApiError::internal("Failed to delete note files"))?; } - db::delete_resource_editor_state( - &state.db, - "note", - &format!("{workspace_slug}/{note_slug}"), - ) - .await?; + db::delete_resource_editor_state(&state.db, "note", &format!("{workspace_slug}/{note_slug}")) + .await?; db::delete_note(&state.db, note.id).await?; Ok(Json(serde_json::json!({"ok": true, "archived": false}))) } @@ -436,7 +443,12 @@ pub async fn note_files( &workspace_slug, ¬e_slug, 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), &headers, ) @@ -459,10 +471,7 @@ pub async fn note_files( }; } file.created_at = db::normalize_timestamp(&file.created_at); - file.url = crate::file_urls::public_file_url( - state.files_public_url.as_deref(), - &file.url, - ); + file.url = crate::file_urls::public_file_url(state.files_public_url.as_deref(), &file.url); } Ok(Json(files)) } @@ -478,7 +487,12 @@ pub async fn delete_note_file( &workspace_slug, ¬e_slug, 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), &headers, ) @@ -627,10 +641,9 @@ async fn serve_token_file( HeaderValue::from_str(&format!("{disposition}; filename=\"{safe}\"")) .expect("sanitized attachment filename"), ); - response.headers_mut().insert( - header::ACCEPT_RANGES, - HeaderValue::from_static("bytes"), - ); + response + .headers_mut() + .insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")); response.headers_mut().insert( header::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, HeaderValue::from_str(&format!("bytes */{total_len}")).expect("valid content range"), ); - response.headers_mut().insert( - header::ACCEPT_RANGES, - HeaderValue::from_static("bytes"), - ); + response + .headers_mut() + .insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")); response } diff --git a/src/api/mod.rs b/src/api/mod.rs index c3906e9..9d02c2f 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -583,6 +583,7 @@ async fn user_editor_preferences( )) } +#[allow(clippy::too_many_arguments)] async fn save_editor_settings( state: &SharedState, headers: &HeaderMap, @@ -1043,7 +1044,7 @@ pub async fn open_workspace( let page = query.page.max(1); let per_page = normalize_list_per_page(query.per_page); 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 start = (page - 1) * per_page; let notes = notes.into_iter().skip(start).take(per_page).collect(); @@ -1142,7 +1143,7 @@ pub async fn create_note( title, payload.protect, created_by.as_deref(), - created_by_guest_id.as_deref(), + created_by_guest_id, ) .await?; if let Some(content) = initial_content { diff --git a/src/app/pages.rs b/src/app/pages.rs index 1aa6484..6da68d4 100644 --- a/src/app/pages.rs +++ b/src/app/pages.rs @@ -118,6 +118,7 @@ async fn share_session_redirect( response } +#[allow(clippy::too_many_arguments)] fn render_editor_page( state: &SharedState, entrypoint: &str, diff --git a/src/auth/ldap.rs b/src/auth/ldap.rs index 75db200..889c685 100644 --- a/src/auth/ldap.rs +++ b/src/auth/ldap.rs @@ -233,7 +233,7 @@ fn escape_filter(value: &str) -> String { b')' => result.push_str("\\29"), b'\\' => result.push_str("\\5c"), 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), } } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 64b5a72..2d74272 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -480,7 +480,7 @@ pub async fn register( .ok_or_else(|| AuthError::internal("Failed to create the account."))?; let mut confirmation_token = None; - if state.smtp.is_some() { + if let Some(smtp) = &state.smtp { let token = random_confirmation_token(); if state.account_confirmation_required { let expires = (Utc::now() + Duration::hours(24)).to_rfc3339(); @@ -506,9 +506,7 @@ pub async fn register( tx.commit().await.map_err(AuthError::database)?; confirmation_token = Some(token.as_str()); } - if let Err(error) = - send_registration_email(state.smtp.as_ref().unwrap(), &user, confirmation_token).await - { + if let Err(error) = send_registration_email(smtp, &user, confirmation_token).await { if state.account_confirmation_required { if let Err(delete_error) = 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()) .await .map_err(AuthError::database)?; - if let Some(value) = last_created { - if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&value) { - let available = created.with_timezone(&Utc) + Duration::minutes(10); - if available > Utc::now() { - let seconds = (available - Utc::now()).num_seconds().max(1); - return Err(AuthError::bad_request(&format!( - "A new confirmation e-mail can be sent in {} minute(s).", - (seconds + 59) / 60 - ))); - } + if let Some(value) = last_created + && let Ok(created) = chrono::DateTime::parse_from_rfc3339(&value) + { + let available = created.with_timezone(&Utc) + Duration::minutes(10); + if available > Utc::now() { + let seconds = (available - Utc::now()).num_seconds().max(1); + return Err(AuthError::bad_request(&format!( + "A new confirmation e-mail can be sent in {} minute(s).", + (seconds + 59) / 60 + ))); } } sqlx::query(queries::get( @@ -1345,7 +1343,7 @@ pub async fn resources( let page = query.page.max(1); let per_page = normalized_per_page(query.per_page); 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 start = (page - 1) * per_page; let items = items diff --git a/src/config/mod.rs b/src/config/mod.rs index 7a27f29..bf57d4b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -219,11 +219,10 @@ impl Config { if self.static_dir.trim().is_empty() || self.files_dir.trim().is_empty() { return Err("STATIC_DIR and FILES_DIR cannot be empty".into()); } - if let Some(smtp) = &self.smtp { - if !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://")) - { - return Err("PUBLIC_URL must start with http:// or https://".into()); - } + if let Some(smtp) = &self.smtp + && !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://")) + { + return Err("PUBLIC_URL must start with http:// or https://".into()); } Ok(()) } diff --git a/src/db/mod.rs b/src/db/mod.rs index a487ca7..40564ab 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -317,14 +317,11 @@ pub async fn note_slug_exists_any( workspace_id: i64, slug: &str, ) -> Result { - let count: i64 = sqlx::query_scalar(queries::get( - pool.kind(), - queries::NOTE_SLUG_EXISTS_ANY, - )) - .bind(workspace_id) - .bind(slug) - .fetch_one(pool.pool()) - .await?; + let count: i64 = sqlx::query_scalar(queries::get(pool.kind(), queries::NOTE_SLUG_EXISTS_ANY)) + .bind(workspace_id) + .bind(slug) + .fetch_one(pool.pool()) + .await?; Ok(count > 0) } @@ -507,6 +504,7 @@ pub async fn save_revision( Ok((revision_id, updated_at)) } +#[allow(clippy::too_many_arguments)] pub async fn save_collaborative_revision( pool: &Database, note_id: i64, @@ -680,16 +678,14 @@ pub async fn create_pad( .await } -pub async fn rename_pad( - pool: &Database, - pad_id: i64, - title: &str, -) -> Result<(), sqlx::Error> { +pub async fn rename_pad(pool: &Database, pad_id: i64, title: &str) -> Result<(), sqlx::Error> { let query = match pool.kind() { - DatabaseKind::Postgres => - "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::Postgres => { + "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 = ?" + } }; sqlx::query(query) .bind(title) diff --git a/src/db/public_pages.rs b/src/db/public_pages.rs index 006bd68..17ecc84 100644 --- a/src/db/public_pages.rs +++ b/src/db/public_pages.rs @@ -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 { let query = queries::get(pool.kind(), queries::PAD_PUBLIC_PAGE_DISABLED); if pool.kind() == DatabaseKind::Postgres { - return Ok(sqlx::query_scalar::<_, bool>(query) + return sqlx::query_scalar::<_, bool>(query) .bind(pad_id) .fetch_one(pool.pool()) - .await?); + .await; } Ok(sqlx::query_scalar::<_, i64>(query) .bind(pad_id) @@ -462,10 +462,10 @@ pub async fn pad_public_page_disabled(pool: &Database, pad_id: i64) -> Result Result { let query = queries::get(pool.kind(), queries::NOTE_PUBLIC_PAGE_DISABLED); if pool.kind() == DatabaseKind::Postgres { - return Ok(sqlx::query_scalar::<_, bool>(query) + return sqlx::query_scalar::<_, bool>(query) .bind(note_id) .fetch_one(pool.pool()) - .await?); + .await; } Ok(sqlx::query_scalar::<_, i64>(query) .bind(note_id) diff --git a/src/file_urls.rs b/src/file_urls.rs index 822fad3..8083de1 100644 --- a/src/file_urls.rs +++ b/src/file_urls.rs @@ -53,10 +53,7 @@ pub fn canonical_file_path(value: &str) -> Option { let path_start = after_scheme.find('/')?; &after_scheme[path_start..] }; - let path = path - .split(|character| matches!(character, '?' | '#')) - .next() - .unwrap_or(path); + let path = path.split(['?', '#']).next().unwrap_or(path); if !path.starts_with(FILE_ROUTE_PREFIX) { return None; } diff --git a/src/main.rs b/src/main.rs index 593abaa..4f2b776 100644 --- a/src/main.rs +++ b/src/main.rs @@ -86,10 +86,9 @@ async fn main() -> Result<(), Box> { .database_url .strip_prefix("sqlite://") .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"); let db = Database::connect(&config.database_url, config.database_max_connections).await?; diff --git a/src/security.rs b/src/security.rs index cbe977b..559003e 100644 --- a/src/security.rs +++ b/src/security.rs @@ -203,7 +203,10 @@ pub fn websocket_origin_allowed(headers: &HeaderMap) -> bool { fn resource_cookie_name(kind: &str, slug: &str) -> String { 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 { diff --git a/src/state.rs b/src/state.rs index 77611d0..ad91b4f 100644 --- a/src/state.rs +++ b/src/state.rs @@ -129,6 +129,7 @@ pub struct AppState { } impl AppState { + #[allow(clippy::too_many_arguments)] pub fn new( db: Database, asset_version: String, @@ -183,14 +184,13 @@ impl AppState { let mut limits = self.rate_limits.lock().await; if !limits.contains_key(&key) && limits.len() >= MAX_RATE_LIMIT_ENTRIES { limits.retain(|_, entry| now.duration_since(entry.started_at) < entry.window); - if limits.len() >= MAX_RATE_LIMIT_ENTRIES { - if let Some(oldest_key) = limits + if limits.len() >= MAX_RATE_LIMIT_ENTRIES + && let Some(oldest_key) = limits .iter() .min_by_key(|(_, entry)| entry.started_at) .map(|(key, _)| key.clone()) - { - limits.remove(&oldest_key); - } + { + limits.remove(&oldest_key); } } let entry = limits.entry(key).or_insert(RateLimitEntry { @@ -283,11 +283,7 @@ impl AppState { pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender { self.channel_for_key(Self::pad_room_key(slug)).await } - pub async fn notify_pad_password_required( - &self, - slug: &str, - except_client_id: Option, - ) { + pub async fn notify_pad_password_required(&self, slug: &str, except_client_id: Option) { let key = Self::pad_room_key(slug); let sender = self.channels.read().await.get(&key).cloned(); if let Some(sender) = sender { diff --git a/src/tests/api_files.rs b/src/tests/api_files.rs index 6f81ace..839fcb2 100644 --- a/src/tests/api_files.rs +++ b/src/tests/api_files.rs @@ -72,9 +72,18 @@ fn video_alias_is_still_attached() { #[test] 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=500-", 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=500-", 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=0-1,4-5", 1_000), Err(())); } diff --git a/src/tests/queries_mysql.rs b/src/tests/queries_mysql.rs index f0e0f0e..54ed575 100644 --- a/src/tests/queries_mysql.rs +++ b/src/tests/queries_mysql.rs @@ -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. for (query, expected_casts) in [ (Query::RESOURCE_EDITOR_SETTINGS_SELECT, 1), - (Query::EDITOR_PREFERENCES_SELECT_PAD, 5), - (Query::EDITOR_PREFERENCES_SELECT_NOTE, 5), + (Query::EDITOR_PREFERENCES_SELECT_PAD, 6), + (Query::EDITOR_PREFERENCES_SELECT_NOTE, 6), (Query::AUTH_USER_BY_EXTERNAL_ID, 1), (Query::AUTH_USER_BY_SESSION, 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::PAD_PUBLIC_PAGE_DISABLED, 1), (Query::NOTE_PUBLIC_PAGE_DISABLED, 1), - (Query::Q001, 1), + (Query::Q001, 2), (Query::Q003, 1), (Query::Q004, 1), (Query::Q011, 1), diff --git a/src/tests/security.rs b/src/tests/security.rs index dfa4687..5fcfb42 100644 --- a/src/tests/security.rs +++ b/src/tests/security.rs @@ -84,7 +84,8 @@ fn rejects_missing_websocket_origin() { #[test] 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("Secure")); assert!(value.contains("SameSite=Lax")); @@ -93,9 +94,8 @@ fn secure_cookies_are_not_script_readable() { #[test] fn share_sessions_use_separate_scoped_opaque_cookies() { - let value = share_session_cookie("workspace", "private-space", "opaque", 600) - .to_str() - .unwrap(); + let cookie = share_session_cookie("workspace", "private-space", "opaque", 600); + let value = cookie.to_str().unwrap(); assert!(value.starts_with("__Host-rustpad_share_")); assert!(value.contains("=opaque;")); assert!(value.contains("Max-Age=600")); @@ -166,7 +166,8 @@ fn csrf_requires_matching_cookie_and_header() { #[test] fn csrf_cookie_is_strict_and_script_readable() { 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("SameSite=Strict")); assert!(!value.contains("HttpOnly")); diff --git a/src/websocket/mod.rs b/src/websocket/mod.rs index 1d8304d..4454916 100644 --- a/src/websocket/mod.rs +++ b/src/websocket/mod.rs @@ -594,6 +594,7 @@ pub async fn upgrade( }) } +#[allow(clippy::too_many_arguments)] async fn handle_socket( mut socket: WebSocket, state: SharedState, diff --git a/src/websocket/pad.rs b/src/websocket/pad.rs index a2431b7..cfb9938 100644 --- a/src/websocket/pad.rs +++ b/src/websocket/pad.rs @@ -77,6 +77,8 @@ pub async fn upgrade_pad( ) }) } + +#[allow(clippy::too_many_arguments)] async fn handle_pad_socket( mut socket: WebSocket, state: SharedState,