Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/v2-2-2-posthog-and-speed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
### New Features
- Connect PostHog with a personal API key and browse or query your product analytics with HogQL, read-only
- Railway is available in the provider picker
- Right-click a Docker database to restart or stop it, or copy its connection URL
- Connecting shows a full loading screen with the database's logo, its host, elapsed time and Cancel

### Bug Fixes
- The expanded row JSON and the row panel update right after a cell edit
- MySQL 8 and 9 tables show their columns when empty, and inserted rows appear without a refresh
- Nile no longer drops its connection after every query
- The connect dialog footer names the host being dialled, and Resume only spins when you pressed it

### Changes
- Connecting to a far database costs one handshake instead of two (Prisma Postgres: 6.2s to 0.6s)
- Far databases keep a warm pool, so opening a table never waits on new connections
- Every query to a far database is one round trip shorter, and a table's first open skips a lookup
- Provider pickers answer sooner: the API connection opens with the dialog, and hovering a database starts building its connection
- PlanetScale lists databases and connects in fewer API calls
- MySQL tables open in one round trip
- Provider sign-in is sturdier across IPv4 and IPv6 callbacks
- A tidier menu bar, and the sidebar shows its Enter hint only while searching
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ Stroke is a Rust + Svelte app for browsing, editing and querying databases. It s
| Turso | libSQL |
| Cloudflare D1 | SQLite |
| Upstash | Redis |
| Railway | Postgres, MySQL, Redis |
| PostHog | HogQL, read-only |

Railway is next.

Stroke also finds databases already running on your machine (Docker containers, local Postgres and MySQL, the SQLite file your ORM points at), so a local connection is usually one click. Anything can go through an SSH tunnel.
Stroke also finds databases already running on your machine (Docker containers, local Postgres and MySQL, the SQLite file your ORM points at), so a local connection is usually one click, and a right-click restarts or stops a container. Anything can go through an SSH tunnel.

## What's inside

Expand Down
4 changes: 0 additions & 4 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,10 @@ opt-level = 3
opt-level = 3
[profile.dev.package.base64]
opt-level = 3

# sqlx 0.8.6 drivers with one change each: the pool's on-release ping is skipped
# for a connection with nothing pending (see the "Stroke patch" comments). That
# ping cost a round trip after every query to a far host and broke Nile.
[patch.crates-io]
sqlx-postgres = { path = "vendor/sqlx-postgres" }
sqlx-mysql = { path = "vendor/sqlx-mysql" }
127 changes: 17 additions & 110 deletions src-tauri/src/cloudflare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::sync::OnceLock;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;

// ── Cloudflare OAuth constants ────────────────────────────────────────────────

Expand Down Expand Up @@ -107,111 +105,7 @@ fn pkce_pair() -> (String, String) {

// ── Local callback server ─────────────────────────────────────────────────────

/// Try to bind to one of the pre-registered Cloudflare callback ports.
/// Returns (listener, redirect_uri) on success.
async fn bind_callback_listener() -> Result<(TcpListener, String), String> {
for &port in CF_CALLBACK_PORTS {
if let Ok(listener) = TcpListener::bind(format!("127.0.0.1:{port}")).await {
let redirect_uri = format!("http://localhost:{port}/oauth/callback");
return Ok((listener, redirect_uri));
}
}
Err(format!(
"Could not bind to any of the pre-registered callback ports ({}-{}). \
Close other Wrangler or Stroke processes and try again.",
CF_CALLBACK_PORTS[0],
CF_CALLBACK_PORTS[CF_CALLBACK_PORTS.len() - 1]
))
}

/// Wait for one OAuth callback on the listener and return the authorization code.
async fn await_oauth_callback(
listener: TcpListener,
expected_state: &str,
) -> Result<String, String> {
let success_html = crate::oauth_page::page(true, "Cloudflare");
let error_html = crate::oauth_page::page(false, "Cloudflare");

let send_html = |html: &str| -> String {
format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
)
};

let (mut stream, _) = listener
.accept()
.await
.map_err(|e| format!("Callback accept failed: {e}"))?;

let mut buf = vec![0u8; 8192];
let n = stream
.read(&mut buf)
.await
.map_err(|e| format!("Callback read failed: {e}"))?;
let req = String::from_utf8_lossy(&buf[..n]);

// Parse the first line: GET /oauth/callback?code=...&state=... HTTP/1.1
let first_line = req.lines().next().unwrap_or("");
let path = first_line.split_whitespace().nth(1).unwrap_or("");
let query = path.split('?').nth(1).unwrap_or("");

let mut code = None;
let mut state = None;
let mut error: Option<String> = None;

for pair in query.split('&') {
let mut kv = pair.splitn(2, '=');
let key = kv.next().unwrap_or("");
let val = kv
.next()
.map(|v| urlencoding::decode(v).unwrap_or_default().into_owned())
.unwrap_or_default();
match key {
"code" => code = Some(val),
"state" => state = Some(val),
"error" => error = Some(val),
"error_description" => {
if error.is_none() {
error = Some(val)
}
}
_ => {}
}
}

if let Some(err) = &error {
let _ = stream
.write_all(send_html(&error_html).as_bytes())
.await;
return Err(format!("Cloudflare denied authorization: {err}"));
}

let code = match code {
Some(c) if !c.is_empty() => c,
_ => {
let _ = stream
.write_all(send_html(&error_html).as_bytes())
.await;
return Err("No authorization code in callback".to_string());
}
};

if state.as_deref() != Some(expected_state) {
let _ = stream
.write_all(send_html(&error_html).as_bytes())
.await;
return Err("OAuth state mismatch - possible CSRF".to_string());
}

let _ = stream
.write_all(send_html(&success_html).as_bytes())
.await;
let _ = stream.flush().await;

Ok(code)
}

// ── Token exchange ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -381,7 +275,21 @@ pub async fn cloudflare_start_oauth(app: tauri::AppHandle) -> Result<CfOAuthStat
let (verifier, challenge) = pkce_pair();
let state = random_base64url(16);

let (listener, redirect_uri) = bind_callback_listener().await?;
// The shared callback in providers/mod.rs: both loopback addresses, stray
// requests ignored, and the branded page. Cloudflare's own copy took one
// connection on IPv4 only, so a favicon fetch or an IPv6-first `localhost`
// could break the sign-in.
let (listener, port) = crate::providers::bind_callback_listener(CF_CALLBACK_PORTS)
.await
.map_err(|_| {
format!(
"Could not bind to any of the pre-registered callback ports ({}-{}). \
Close other Wrangler or Stroke processes and try again.",
CF_CALLBACK_PORTS[0],
CF_CALLBACK_PORTS[CF_CALLBACK_PORTS.len() - 1]
)
})?;
let redirect_uri = format!("http://localhost:{port}/oauth/callback");

let auth_url = format!(
"{CF_AUTH_URL}?response_type=code&client_id={CF_CLIENT_ID}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
Expand All @@ -391,12 +299,11 @@ pub async fn cloudflare_start_oauth(app: tauri::AppHandle) -> Result<CfOAuthStat
challenge,
);

tauri_plugin_opener::open_url(&auth_url, None::<&str>)
.map_err(|e| format!("Failed to open browser: {e}"))?;
crate::providers::open_sign_in_page(&app, &auth_url);

let code = tokio::time::timeout(
std::time::Duration::from_secs(AUTH_TIMEOUT_SECS),
await_oauth_callback(listener, &state),
crate::providers::await_oauth_callback(listener, &state, "code", "Cloudflare"),
)
.await
.map_err(|_| "Authorization timed out - please try again.".to_string())??;
Expand Down
15 changes: 15 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,21 @@ pub async fn connect_libsql_db(state: State<'_, DbState>, config: LibSqlConfig)
connect_libsql(state, config).await
}

// ── PostHog ───────────────────────────────────────────────────────────────────

#[tauri::command]
pub async fn test_posthog(config: crate::db::connection::PosthogConfig) -> Result<(), String> {
crate::db::connection::test_posthog_connection(config).await
}

#[tauri::command]
pub async fn connect_posthog_db(
state: State<'_, DbState>,
config: crate::db::connection::PosthogConfig,
) -> Result<(), String> {
crate::db::connection::connect_posthog(state, config).await
}

// ── ClickHouse ────────────────────────────────────────────────────────────────

#[tauri::command]
Expand Down
10 changes: 6 additions & 4 deletions src-tauri/src/db/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ async fn export_one(
ActiveConnection::D1(cfg) => export_d1(app, &cfg, tables.as_deref(), opts).await,
ActiveConnection::LibSql(_) => Err("Backup export is not supported for LibSQL/Turso connections".to_string()),
ActiveConnection::Clickhouse(_) => Err("Backup export is not supported for ClickHouse connections".to_string()),
ActiveConnection::Posthog(_) => Err("Backup is not supported for PostHog".to_string()),
ActiveConnection::Redis(_) => Err("Backup is not supported on Redis".to_string()),
ActiveConnection::Duckdb(h) => export_duckdb(app, &h).await,
ActiveConnection::Mssql(h) => export_mssql(app, &h).await,
Expand All @@ -164,6 +165,7 @@ pub async fn backup_import(
ActiveConnection::D1(cfg) => import_d1(&app, &cfg, &sql).await,
ActiveConnection::LibSql(_) => Err("Backup import is not supported for LibSQL/Turso connections".to_string()),
ActiveConnection::Clickhouse(_) => Err("Backup import is not supported for ClickHouse connections".to_string()),
ActiveConnection::Posthog(_) => Err("Backup is not supported for PostHog".to_string()),
ActiveConnection::Redis(_) => Err("Backup is not supported on Redis".to_string()),
ActiveConnection::Duckdb(h) => import_duckdb(&app, &h, &sql).await,
ActiveConnection::Mssql(h) => import_mssql(&app, &h, &sql).await,
Expand Down Expand Up @@ -1069,7 +1071,7 @@ async fn export_mysql(
let create_row = sqlx::query(&format!("SHOW CREATE TABLE `{schema}`.`{table}`"))
.fetch_one(pool).await
.map_err(|e| format!("SHOW CREATE TABLE `{table}` failed: {e}"))?;
let create_sql: String = create_row.try_get(1).unwrap_or_default();
let create_sql = super::mysql::my_text(&create_row, 1).unwrap_or_default();
out.push_str(&create_sql.replace("CREATE TABLE ", "CREATE TABLE IF NOT EXISTS "));
out.push_str(";\n\n");

Expand Down Expand Up @@ -1110,7 +1112,7 @@ async fn export_mysql(
out.push_str(&format!("-- Views - {schema}\n"));
for view in &view_names {
if let Ok(row) = sqlx::query(&format!("SHOW CREATE VIEW `{schema}`.`{view}`")).fetch_one(pool).await {
let create: String = row.try_get(1).unwrap_or_default();
let create = super::mysql::my_text(&row, 1).unwrap_or_default();
out.push_str(&create.replace("CREATE ", "CREATE OR REPLACE "));
out.push_str(";\n");
}
Expand All @@ -1135,7 +1137,7 @@ async fn export_mysql(
let keyword = if rtype == "FUNCTION" { "FUNCTION" } else { "PROCEDURE" };
if let Ok(row) = sqlx::query(&format!("SHOW CREATE {keyword} `{schema}`.`{name}`")).fetch_one(pool).await {
let col_idx: usize = if rtype == "FUNCTION" { 2 } else { 2 };
let create: String = row.try_get(col_idx).unwrap_or_default();
let create = super::mysql::my_text(&row, col_idx).unwrap_or_default();
out.push_str(&create);
out.push_str("//\n\n");
}
Expand All @@ -1157,7 +1159,7 @@ async fn export_mysql(
out.push_str(&format!("-- Triggers - {schema}\nDELIMITER //\n"));
for trig in &trigger_names {
if let Ok(row) = sqlx::query(&format!("SHOW CREATE TRIGGER `{schema}`.`{trig}`")).fetch_one(pool).await {
let create: String = row.try_get(2).unwrap_or_default();
let create = super::mysql::my_text(&row, 2).unwrap_or_default();
out.push_str(&create);
out.push_str("//\n\n");
}
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/db/clickhouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ pub async fn get_table_rows(
/// Build a `WHERE` clause from the global search box + structured filters.
/// Values are escaped into single-quoted literals (ClickHouse HTTP has no bound
/// params here); identifiers are validated against the known column list.
fn build_where(cols: &[ColumnStructureRow], search: Option<&str>, filters: Option<&[RowFilter]>) -> String {
pub(crate) fn build_where(cols: &[ColumnStructureRow], search: Option<&str>, filters: Option<&[RowFilter]>) -> String {
let known: std::collections::HashSet<&str> = cols.iter().map(|c| c.name.as_str()).collect();
let mut clauses: Vec<String> = Vec::new();

Expand Down
Loading
Loading