1 parent
2c98b8f925
commit
e4aa787e06
27 files changed
+179
-110
No files matched your search
@@ -49,8 +49,6 @@ pub fn provision_user_data_with_search(
|
||||
.map_err(|e| format!("failed to create search directory: {e}"))?;
|
||||
}
|
||||
|
||||
// Ensure this account has its isolated tables in the configured RustBox database.
|
||||
// The same database backend stores sessions unless session_backend=redis.
|
||||
let conn = crate::db::pool::open_user_db(data_dir, user_hash)
|
||||
.map_err(|e| format!("failed to initialize user database: {e}"))?;
|
||||
drop(conn);
|
||||
|
||||
+2
-35
@@ -20,8 +20,6 @@ const SQLITE_SCHEMA: &str = include_str!("../../migrations/sqlite/schema.sql");
|
||||
const MYSQL_SCHEMA: &str = include_str!("../../migrations/mysql/schema.sql");
|
||||
const PGSQL_SCHEMA: &str = include_str!("../../migrations/pgsql/schema.sql");
|
||||
|
||||
// The squashed legacy baseline is V046. Runtime migrations start at V047 and
|
||||
// are stored as real files for every supported SQL engine.
|
||||
const SQLITE_MIGRATIONS: &[(u32, &str)] = &[(
|
||||
47,
|
||||
include_str!("../../migrations/sqlite/V047__blob_keys_and_search_outbox.sql"),
|
||||
@@ -65,7 +63,6 @@ const TENANT_TABLES: &[&str] = &[
|
||||
"user_meta",
|
||||
];
|
||||
|
||||
// Parent tables precede children so legacy SQLite data can be copied with foreign keys enabled.
|
||||
const IMPORT_TABLES: &[&str] = &[
|
||||
"calendar_events",
|
||||
"calendar_settings",
|
||||
@@ -229,12 +226,6 @@ pub async fn configure(config: &AppConfig) -> Result<(), String> {
|
||||
conn.execute_batch(&global_schema(SQLITE_SCHEMA)?)
|
||||
.map_err(|e| format!("SQLite global schema initialization failed: {e}"))?;
|
||||
|
||||
// Unit tests create many independent temporary data directories in one
|
||||
// process. Pinning the first SQLite path in the process-wide OnceLock
|
||||
// makes later tests reuse a TempDir that may already have been deleted,
|
||||
// causing a cascade of "unable to open database file" failures.
|
||||
// Initialize the requested test database, but let open_user_db() resolve
|
||||
// each test's own data_dir instead of publishing this path globally.
|
||||
#[cfg(test)]
|
||||
{
|
||||
return Ok(());
|
||||
@@ -287,9 +278,6 @@ pub async fn configure(config: &AppConfig) -> Result<(), String> {
|
||||
let schema = global_schema(PGSQL_SCHEMA)?;
|
||||
let init_pool = pool.clone();
|
||||
runtime_wait(&runtime, async move {
|
||||
// PostgreSQL emits NOTICE for every CREATE ... IF NOT EXISTS that
|
||||
// already exists. Keep expected idempotent startup DDL quiet without
|
||||
// changing application-wide logging or server settings.
|
||||
let mut tx = init_pool
|
||||
.begin()
|
||||
.await
|
||||
@@ -298,11 +286,6 @@ pub async fn configure(config: &AppConfig) -> Result<(), String> {
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| format!("failed to quiet PostgreSQL schema notices: {e}"))?;
|
||||
// `raw_sql(...).execute(&mut PgConnection)` can trigger SQLx's
|
||||
// "Executor is not general enough" lifetime error when this
|
||||
// future is spawned onto the dedicated database runtime. Execute
|
||||
// the unprepared multi-statement DDL directly through `Executor`
|
||||
// instead; this is SQLx's supported path for raw DDL batches.
|
||||
(&mut *tx)
|
||||
.execute(schema.as_str())
|
||||
.await
|
||||
@@ -460,7 +443,6 @@ pub trait ToDbValue {
|
||||
}
|
||||
}
|
||||
|
||||
// Kept explicit rather than clever: the macro above intentionally stays simple for rustfmt/parser compatibility.
|
||||
impl ToDbValue for i64 {
|
||||
fn to_db_value(&self) -> DbValue {
|
||||
DbValue::I64(Some(*self))
|
||||
@@ -601,10 +583,6 @@ impl IntoDbParams for [(); 0] {
|
||||
}
|
||||
}
|
||||
|
||||
// Do not use a const-generic array implementation here. For an empty array (`[]`)
|
||||
// Rust cannot infer the element type and sees both `[(); 0]` and `[T; 0]` as valid
|
||||
// `IntoDbParams` candidates. Implement only non-empty array lengths explicitly so
|
||||
// legacy/rusqlite-style `[]` call sites remain unambiguous.
|
||||
macro_rules! impl_into_db_params_array {
|
||||
($($n:expr),+ $(,)?) => {
|
||||
$(
|
||||
@@ -1457,8 +1435,6 @@ fn adapt_sql_params(
|
||||
return (sql, params);
|
||||
}
|
||||
|
||||
// Dynamic query builders in the application use SQLite-style ?N placeholders.
|
||||
// Normalize them here so the same higher-level logic can target all engines.
|
||||
let chars: Vec<char> = sql.chars().collect();
|
||||
let mut output = String::with_capacity(sql.len() + 16);
|
||||
let mut reordered = Vec::new();
|
||||
@@ -1522,8 +1498,6 @@ fn adapt_sql_params(
|
||||
|
||||
sql = output;
|
||||
if backend == DatabaseBackend::Mysql && !reordered.is_empty() {
|
||||
// If numbered placeholders were expanded, bind in occurrence order.
|
||||
// Plain MySQL catalog queries already use '?' and are handled the same way.
|
||||
return (adapt_dialect_sql(backend, sql), reordered);
|
||||
}
|
||||
(adapt_dialect_sql(backend, sql), params)
|
||||
@@ -1855,9 +1829,6 @@ fn tenant_prefix(user_hash: &str) -> Result<String, String> {
|
||||
return Err("empty user hash for database isolation".to_string());
|
||||
}
|
||||
|
||||
// Production user hashes are SHA-256 hex strings. Tests and legacy callers may
|
||||
// use symbolic identifiers; hash those first so table prefixes remain safe,
|
||||
// deterministic and restricted to ASCII hex without weakening isolation.
|
||||
let normalized = if user_hash.len() >= 24 && user_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
user_hash.to_ascii_lowercase()
|
||||
} else {
|
||||
@@ -2060,8 +2031,6 @@ fn ensure_server_tenant(
|
||||
let tenant = tenant_schema(MYSQL_SCHEMA, &prefix)?;
|
||||
let legacy_path = legacy_path.clone();
|
||||
runtime_wait(runtime, async move {
|
||||
// Serialize first-time provisioning per account across cluster nodes.
|
||||
// MySQL named locks are session-scoped and are released automatically if the connection dies.
|
||||
let lock_name = format!("rustbox:{prefix}");
|
||||
let mut lock_conn = pool
|
||||
.acquire()
|
||||
@@ -2210,10 +2179,8 @@ fn ensure_server_tenant(
|
||||
ConfiguredDatabase::Sqlite { .. } => false,
|
||||
};
|
||||
|
||||
if imported_legacy {
|
||||
if let Some(path) = legacy_path.as_ref() {
|
||||
backup_legacy_sqlite(path);
|
||||
}
|
||||
if imported_legacy && let Some(path) = legacy_path.as_ref() {
|
||||
backup_legacy_sqlite(path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Query catalogs intentionally expose parallel SQL dialects. Not every catalog
|
||||
// is used in a SQLite-only process, so dead-code suppression is scoped to the
|
||||
// dialect modules instead of the rest of the crate.
|
||||
#[allow(dead_code)]
|
||||
pub mod mysql;
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -194,8 +194,6 @@ pub async fn delete_attachment(
|
||||
return Err(AppError::NotFound("Attachment not found".to_string()));
|
||||
}
|
||||
if let Err(error) = storage.delete(&storage_key).await {
|
||||
// SQL is authoritative. A failed physical delete leaves an orphaned blob,
|
||||
// which is safer than leaving a DB row pointing at a missing attachment.
|
||||
tracing::warn!(error = %error, storage_key = %storage_key, "Failed to remove deleted attachment blob");
|
||||
}
|
||||
|
||||
|
||||
+68
-2
@@ -223,6 +223,7 @@ fn s3_storage(storage: &StorageConfig) -> Result<Arc<dyn BlobStorage>, String> {
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "storage.bucket is required for S3".to_string())?;
|
||||
let client_options = ClientOptions::new()
|
||||
.with_allow_http(storage.allow_http)
|
||||
.with_connect_timeout(Duration::from_secs(storage.connect_timeout_secs))
|
||||
.with_timeout(Duration::from_secs(storage.request_timeout_secs));
|
||||
let retry = RetryConfig {
|
||||
@@ -234,7 +235,6 @@ fn s3_storage(storage: &StorageConfig) -> Result<Arc<dyn BlobStorage>, String> {
|
||||
.with_bucket_name(bucket)
|
||||
.with_region(storage.region.trim())
|
||||
.with_virtual_hosted_style_request(!storage.path_style)
|
||||
.with_allow_http(storage.allow_http)
|
||||
.with_client_options(client_options)
|
||||
.with_retry(retry)
|
||||
.with_checksum_algorithm(Checksum::SHA256);
|
||||
@@ -470,7 +470,6 @@ pub async fn migrate_attachments(
|
||||
) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
// A logical DB key may already point at the configured destination after a previous run.
|
||||
if attachment.storage_key == target_key
|
||||
&& destination.exists(&target_key).await?
|
||||
{
|
||||
@@ -583,6 +582,13 @@ pub async fn migrate_attachments(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::AppConfig;
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{Response, StatusCode, header},
|
||||
};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -621,6 +627,66 @@ mod tests {
|
||||
assert!(!storage.exists(&key).await.expect("missing"));
|
||||
}
|
||||
|
||||
async fn s3_http_probe(State(hits): State<Arc<AtomicUsize>>) -> Response<Body> {
|
||||
hits.fetch_add(1, Ordering::SeqCst);
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.status_mut() = StatusCode::OK;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_LENGTH, "0".parse().expect("content length"));
|
||||
response.headers_mut().insert(
|
||||
header::LAST_MODIFIED,
|
||||
"Sat, 31 Oct 2026 20:00:00 GMT"
|
||||
.parse()
|
||||
.expect("last modified"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::ETAG, "\"rustbox-test\"".parse().expect("etag"));
|
||||
response
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn s3_http_endpoint_works_when_explicitly_allowed() {
|
||||
let hits = Arc::new(AtomicUsize::new(0));
|
||||
let app = Router::new()
|
||||
.fallback(s3_http_probe)
|
||||
.with_state(hits.clone());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind test S3 endpoint");
|
||||
let endpoint = format!(
|
||||
"http://{}",
|
||||
listener.local_addr().expect("test S3 endpoint address")
|
||||
);
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("serve test S3 endpoint");
|
||||
});
|
||||
|
||||
let storage = StorageConfig {
|
||||
backend: "s3".to_string(),
|
||||
bucket: Some("rustbox-test".to_string()),
|
||||
region: "us-east-1".to_string(),
|
||||
endpoint: Some(endpoint),
|
||||
path_style: true,
|
||||
allow_http: true,
|
||||
access_key_id: Some("test-access-key".to_string()),
|
||||
secret_access_key: Some("test-secret-key".to_string()),
|
||||
max_retries: 0,
|
||||
request_timeout_secs: 2,
|
||||
connect_timeout_secs: 2,
|
||||
retry_timeout_secs: 2,
|
||||
..StorageConfig::default()
|
||||
};
|
||||
|
||||
let backend = s3_storage(&storage).expect("S3 backend with HTTP enabled");
|
||||
assert!(backend.exists("probe").await.expect("HTTP S3 HEAD request"));
|
||||
assert_eq!(hits.load(Ordering::SeqCst), 1);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_keys_are_logical_and_path_safe() {
|
||||
assert_eq!(
|
||||
|
||||
+10
-5
@@ -567,7 +567,8 @@
|
||||
data-i18n="filters">Filters</button><button class="settings-tab" data-tab="diagnostics"
|
||||
type="button">Diagnostics</button><button class="settings-tab" data-tab="account"
|
||||
type="button">Account</button><button class="settings-tab" data-tab="about" type="button"
|
||||
data-i18n="about">About</button><button id="shortcuts-open" class="button quiet settings-shortcuts" type="button">Keyboard shortcuts</button></nav>
|
||||
data-i18n="about">About</button><button id="shortcuts-open" class="button quiet settings-shortcuts"
|
||||
type="button">Keyboard shortcuts</button></nav>
|
||||
<div class="settings-content">
|
||||
<section class="settings-panel" data-panel="appearance">
|
||||
<div class="settings-panel-heading">
|
||||
@@ -943,7 +944,8 @@
|
||||
</div><button id="identity-new" class="button primary" type="button">New identity</button>
|
||||
</div>
|
||||
<div class="identity-overview">
|
||||
<div class="identity-overview-hint"><strong>Configured identities</strong><span id="identity-overview-help" class="muted">Select an
|
||||
<div class="identity-overview-hint"><strong>Configured identities</strong><span
|
||||
id="identity-overview-help" class="muted">Select an
|
||||
identity to edit its sender data, text/HTML footer, image and OpenPGP defaults.</span></div>
|
||||
<div id="identity-list" class="identity-list"></div>
|
||||
</div>
|
||||
@@ -1165,8 +1167,10 @@
|
||||
<div class="settings-panel-heading account-session-heading">
|
||||
<div>
|
||||
<h3>Reset settings</h3>
|
||||
<p class="muted">Restore appearance, mailbox, notifications, folder, calendar and OpenPGP preferences. Messages, identities, keys and templates are kept.</p>
|
||||
</div><button id="reset-all-settings" class="button quiet danger" type="button">Reset all settings</button>
|
||||
<p class="muted">Restore appearance, mailbox, notifications, folder, calendar and OpenPGP preferences.
|
||||
Messages, identities, keys and templates are kept.</p>
|
||||
</div><button id="reset-all-settings" class="button quiet danger" type="button">Reset all
|
||||
settings</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-panel hidden" data-panel="about">
|
||||
@@ -1253,7 +1257,8 @@
|
||||
</div>
|
||||
</section>
|
||||
<section class="identity-modal-section identity-modal-options">
|
||||
<div class="identity-modal-heading"><strong>Defaults</strong><span id="identity-defaults-help" class="muted">Choose where this signature
|
||||
<div class="identity-modal-heading"><strong>Defaults</strong><span id="identity-defaults-help"
|
||||
class="muted">Choose where this signature
|
||||
is inserted and, when OpenPGP is configured, its default behavior.</span></div>
|
||||
<div class="identity-toggle-grid">
|
||||
<label class="checkbox-row"><input id="identity-signature-new" type="checkbox"><span>Signature on new
|
||||
|
||||
Reference in new issue
Block a user