v0.2.97
This commit is contained in:
1 parent
99a5700caa
commit
b752f52fd8
22 files changed
+292
-117
No files matched your search
@@ -19,3 +19,4 @@ scripts/*.txt
|
||||
# Generated by scripts/update_browser_libs.py
|
||||
/static/libs/mermaid/
|
||||
/static/libs/highlight/
|
||||
dist
|
||||
Executable
+158
@@ -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"
|
||||
+10
-10
@@ -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!(
|
||||
.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!(
|
||||
.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,
|
||||
|
||||
@@ -13,6 +13,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+58
-46
@@ -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,12 +220,8 @@ pub async fn delete_pad_file(
|
||||
&headers,
|
||||
)
|
||||
.await?;
|
||||
let account_owner = crate::auth::is_resource_owner(
|
||||
&state,
|
||||
"pad",
|
||||
&pad.slug,
|
||||
bearer_token(&headers),
|
||||
)
|
||||
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);
|
||||
@@ -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,11 +426,7 @@ 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}"),
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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 {
|
||||
|
||||
@@ -118,6 +118,7 @@ async fn share_session_redirect(
|
||||
response
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_editor_page(
|
||||
state: &SharedState,
|
||||
entrypoint: &str,
|
||||
|
||||
+1
-1
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
+6
-8
@@ -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,8 +636,9 @@ 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) {
|
||||
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);
|
||||
@@ -649,7 +648,6 @@ pub async fn resend_confirmation(
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER,
|
||||
@@ -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
|
||||
|
||||
+2
-3
@@ -219,12 +219,11 @@ 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://"))
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
+9
-13
@@ -317,10 +317,7 @@ pub async fn note_slug_exists_any(
|
||||
workspace_id: i64,
|
||||
slug: &str,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let count: i64 = sqlx::query_scalar(queries::get(
|
||||
pool.kind(),
|
||||
queries::NOTE_SLUG_EXISTS_ANY,
|
||||
))
|
||||
let count: i64 = sqlx::query_scalar(queries::get(pool.kind(), queries::NOTE_SLUG_EXISTS_ANY))
|
||||
.bind(workspace_id)
|
||||
.bind(slug)
|
||||
.fetch_one(pool.pool())
|
||||
@@ -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)
|
||||
|
||||
@@ -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> {
|
||||
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<bo
|
||||
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);
|
||||
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)
|
||||
|
||||
+1
-4
@@ -53,10 +53,7 @@ pub fn canonical_file_path(value: &str) -> Option<String> {
|
||||
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;
|
||||
}
|
||||
|
||||
+1
-2
@@ -86,11 +86,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.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)?;
|
||||
}
|
||||
}
|
||||
info!("connecting to database");
|
||||
let db = Database::connect(&config.database_url, config.database_max_connections).await?;
|
||||
info!(database_kind = ?db.kind(), "database connection established");
|
||||
|
||||
+4
-1
@@ -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 {
|
||||
|
||||
+4
-8
@@ -129,6 +129,7 @@ pub struct AppState {
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
db: Database,
|
||||
asset_version: String,
|
||||
@@ -183,8 +184,8 @@ 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())
|
||||
@@ -192,7 +193,6 @@ impl AppState {
|
||||
limits.remove(&oldest_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
let entry = limits.entry(key).or_insert(RateLimitEntry {
|
||||
started_at: now,
|
||||
attempts: 0,
|
||||
@@ -283,11 +283,7 @@ impl AppState {
|
||||
pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender<RoomEvent> {
|
||||
self.channel_for_key(Self::pad_room_key(slug)).await
|
||||
}
|
||||
pub async fn notify_pad_password_required(
|
||||
&self,
|
||||
slug: &str,
|
||||
except_client_id: Option<String>,
|
||||
) {
|
||||
pub async fn notify_pad_password_required(&self, slug: &str, except_client_id: Option<String>) {
|
||||
let key = Self::pad_room_key(slug);
|
||||
let sender = self.channels.read().await.get(&key).cloned();
|
||||
if let Some(sender) = sender {
|
||||
|
||||
+12
-3
@@ -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(()));
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -594,6 +594,7 @@ pub async fn upgrade(
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_socket(
|
||||
mut socket: WebSocket,
|
||||
state: SharedState,
|
||||
|
||||
@@ -77,6 +77,8 @@ pub async fn upgrade_pad(
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_pad_socket(
|
||||
mut socket: WebSocket,
|
||||
state: SharedState,
|
||||
|
||||
Reference in new issue
Block a user