Merge branch 'main' into lazy-fn-eval-in-set

This commit is contained in:
Ophir LOJKINE
2026-07-15 18:11:18 +02:00
committed by GitHub
4 changed files with 181 additions and 3 deletions
+93 -3
View File
@@ -1,5 +1,95 @@
Core Concept: User writes .sql files, SQLPage executes queries, results mapped to handlebars UI components,
HTML streamed to client
# SQLPage architecture
SQLPage is an SQL-only web application builder and web server. An application is primarily a set of `.sql`
files: SQLPage routes an HTTP request to a file, executes its statements against a database, interprets rows
whose `component` column names a UI component, and streams the resulting HTML (or another response) to the
client. It is intended for fast, data-centric applications while still allowing custom HTML, CSS, and
JavaScript where needed.
## Features and repository layout
- **Application entry point and configuration** (`src/main.rs`, `src/lib.rs`, `src/app_config.rs`, `src/cli/`).
The executable starts the server; application state, configuration, environment variables, and command-line
handling are defined here. `configuration.md` documents the user-facing settings.
- **SQL semantics and execution** (`src/webserver/database/`). SQLPage uses the database's SQL for selects, joins, aggregation, inserts, updates,
deletes, transactions, JSON processing, and database-specific features. It parses SQL, recognizes SQLPage
extensions, binds request values safely, and sends ordinary SQL to the selected database. SQL files contain
sequential statements; result sets become component invocations in response order. `SET` assigns a value
to a mutable SQLPage variable and is useful for reusing query results or controlling later statements.
- **Request variables** (`src/webserver/request_variables.rs`, `src/webserver/http_request_info.rs`,
`src/webserver/database/syntax_tree.rs`). `?name` refers to a URL/GET parameter, `:name` explicitly refers to a form/POST
value, and `$name` is the compatibility shorthand that uses a POST value when present and otherwise a GET
value (a SET variable takes precedence where applicable). Values are passed as parameters, not interpolated
into SQL. GET and POST variables are request inputs; SET variables are mutable during request execution.
`sqlpage.variables()` exposes them as JSON, with SET > POST > GET precedence.
- **SQLPage functions** (`src/webserver/database/sqlpage_functions/`). Calls such as `sqlpage.fetch`, `sqlpage.run_sql`, `sqlpage.set_variable`, file
readers, hashing/HMAC helpers, request metadata, uploads, headers/cookies, URL helpers, OIDC user info,
and HTTP fetch are registered in `src/webserver/database/sqlpage_functions/functions.rs`. Functions can
return values, alter response/request state, include another SQL file, or raise an error. `sqlpage.exec`
is deliberately disabled by default because it runs server processes.
- **Database support and pooling** (`src/webserver/database/connect.rs`, `execute_queries.rs`, `migrations.rs`).
Native drivers support SQLite, PostgreSQL, MySQL, and Microsoft SQL
Server; the ODBC driver provides access to other ODBC-compatible databases. SQLPage uses `sqlx` and a
reusable connection pool, with configurable maximum connections, idle/lifetime timeouts, acquire timeout,
retries, and optional `on_connect.sql`/`on_reset.sql` hooks. Database-specific SQL should be isolated or
covered by the relevant database tests.
- **Rendering and components** (`src/render.rs`, `src/templates.rs`, `src/dynamic_component.rs`,
`src/template_helpers.rs`, `sqlpage/templates/`, `sqlpage/sqlpage.css`, `sqlpage/sqlpage.js`). Built-in components live in `sqlpage/templates/*.handlebars` and cover shells, text,
tables, lists, cards, charts, forms, navigation, modals, downloads, maps, and more. Query columns map to
component properties; nested/dynamic components and `sqlpage.run_sql` support composition and lazy loading.
Custom Handlebars components can be placed in the configured `sqlpage/templates` directory. Raw HTML and
custom assets are possible through the HTML/shell components. Rendering is streamed so the response can
start while later query results are still being processed.
- **Control flow and errors** (`src/webserver/error.rs`, `error_with_status.rs`, `routing.rs`,
`src/default_404.sql`). SQL remains declarative: use predicates, `CASE`, `SET`, component rows, and
the `redirect` component to conditionally continue, redirect, or implement guards/error pages. There is no
general SQLPage `IF` statement. Parse, database, function, component, and response errors are converted to
contextual HTTP errors; `default_404.sql` handles missing routes. Do not hide errors by changing unrelated
error handling or tests.
- **HTTP server and client** (`src/webserver/http.rs`, `http_client.rs`, `response_writer.rs`, `static_content.rs`,
`https.rs`, `content_security_policy.rs`, `server_timing.rs`). The server is built on Actix Web, supports normal HTTP request/response
handling, streaming, uploads, static assets, HTTP/2, HTTPS, and optional Unix sockets/serverless adapters.
The shared outbound client is used by HTTP-fetch and OIDC integrations and honors configured/native TLS
certificates and timeouts. Content-security-policy and response/header helpers are part of the request
pipeline.
- **OIDC** (`src/webserver/oidc.rs`, `src/webserver/database/sqlpage_functions/functions/user_info.rs`).
Optional OpenID Connect middleware protects configured path prefixes, performs provider discovery,
login/callback/logout, validates tokens, maintains an authenticated cookie, and exposes identity claims to
SQLPage functions. Configuration is in `AppConfig`/`configuration.md`; public paths can be excluded.
- **Caching and files** (`src/filesystem.rs`, `src/file_cache.rs`, `src/telemetry*.rs`). Parsed SQL files are cached. Files may come from the web root/filesystem or from the
database-backed `sqlpage_files` store, and templates/migrations are loaded from the configuration directory.
Telemetry, request timing, and debug logging help diagnose query, pool, and rendering performance.
- **Examples, tests, and project operations** (`examples/`, `tests/`, `configuration.md`, `CONTRIBUTING.md`,
`README.md`, `.github/workflows/ci.yml`). Examples include the official documentation site and its migrations;
tests cover SQL fixtures, database variants, uploads, OIDC, and server timing. The contribution guide and CI
workflow define the development and validation conventions.
- **Deployment and local infrastructure** (`Dockerfile`, `lambda.Dockerfile`, `docker-compose.yml`,
`sqlpage.service`). These provide container, serverless, local database-testing, and service deployment support.
## Documentation and release notes
The official documentation site is itself an SQLPage application in `examples/official-site/`. Its database
schema and documentation content are created by the SQL migrations in
`examples/official-site/sqlpage/migrations/`; the site is recreated from scratch during deployment. Existing
official-site migrations are editable source files: update the migration that already documents a component,
function, configuration option, or feature in place. Do not create a new migration merely to update existing
documentation. Add a new migration only for genuinely new documentation content when that is the established
pattern for the relevant area.
- Add or update a component's row, parameters, and examples in the component documentation migrations when
changing `sqlpage/templates/` or component behavior.
- Add or update a function's description, parameters, examples, and caveats in the function documentation
migrations when changing `sqlpage_functions` or any `sqlpage.*` function behavior.
- Update the relevant configuration documentation when adding or changing `AppConfig` settings, environment
variables, defaults, authentication behavior, HTTP/TLS behavior, database settings, or custom components.
- Document OIDC changes in the authentication/OIDC migrations, including configuration requirements, exposed
claims/functions, login/logout behavior, and security implications.
- Document other user-visible behavior—SQL syntax extensions, variables, control flow, errors, uploads,
rendering, HTTP endpoints, performance, or deployment—in the corresponding official-site SQL page or
migration. Follow nearby migrations and keep examples executable and database-portable where possible.
- Update `CHANGELOG.md` for user-visible changes, bug fixes, breaking changes, deprecations, and noteworthy
internal changes. Keep the entry concise and use the existing version/section conventions.
## Validation
@@ -52,4 +142,4 @@ official documentation website sql tables:
- [Configuration](./configuration.md): see [AppConfig](./src/app_config.rs)
- Routing: file-based in `src/webserver/routing.rs`. Missing paths use the nearest ancestor `404.sql`; without one, HTML uses `src/default_404.sql` and other formats receive a plain-text 404.
- Follow patterns from similar modules before introducing new abstractions.
- Frontend: see [css](./sqlpage/sqlpage.css) and [js](./sqlpage/sqlpage.js).
- frontend: see [css](./sqlpage/sqlpage.css) and [js](./sqlpage/sqlpage.js)
+1
View File
@@ -8,6 +8,7 @@
- `SELECT sqlpage.random_string(8) AS token FROM many_rows` now produces one token per row instead of one token reused across rows
- `SET result = (SELECT sqlpage.fetch($url) WHERE NOT $cached)` now works as expected and does not re-fetch a cached result. Apps relying on sqlite-specific `SET x = (SELECT y FROM t)` first-row behavior should add `LIMIT 1` and select exactly one column.
- **Access logs now go to stdout.** SQLPage now writes the single per-request completion log line to stdout with the target `sqlpage::access`, matching common application-server and container logging conventions. Diagnostic logs, warnings, and internal errors still go to stderr. If your `LOG_LEVEL` or `RUST_LOG` filter is scoped to a specific old target such as `sqlpage::webserver::http=info`, add `sqlpage::access=info` so request-completion logs are still emitted. If your log pipeline only collects stderr, update it to collect stdout too.
- **OIDC redirects are no longer cacheable.** Authorization redirects contain one-time state and post-login redirects set session cookies. SQLPage now sends `Cache-Control: no-store` for these responses, preventing a browser or intermediary from replaying an expired authorization redirect.
## v0.44.1
+4
View File
@@ -870,6 +870,9 @@ fn build_auth_provider_redirect_response(
.finish();
let mut response = HttpResponse::SeeOther();
response.append_header((header::LOCATION, url.to_string()));
// The location contains a one-time CSRF state. A cached redirect would
// replay it after its state cookie has been consumed.
response.append_header((header::CACHE_CONTROL, "no-store"));
if let Ok(cookies) = request.cookies() {
for mut cookie in get_tmp_login_flow_state_cookies_to_evict(&cookies).cloned() {
cookie.make_removal();
@@ -884,6 +887,7 @@ fn build_auth_provider_redirect_response(
fn build_redirect_response(target_url: String) -> HttpResponse {
HttpResponse::SeeOther()
.append_header(("Location", target_url))
.append_header((header::CACHE_CONTROL, "no-store"))
.body("Redirecting...")
}
+83
View File
@@ -264,6 +264,17 @@ fn get_query_param(url: &Url, name: &str) -> String {
.to_string()
}
fn permits_storage_after_proxy_adds_freshness(headers: &header::HeaderMap) -> bool {
// Reproduces https://github.com/sqlpage/SQLPage/issues/1341, where an
// intermediary added `Cache-Control: max-age=86400` to OIDC 303 responses.
// `no-store` must still prevent browser storage when both directives exist.
!headers
.get_all(header::CACHE_CONTROL)
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.any(|directive| directive.trim().eq_ignore_ascii_case("no-store"))
}
macro_rules! request_with_cookies {
($app:expr, $req:expr, $cookies:expr) => {{
let mut req = $req;
@@ -325,6 +336,68 @@ async fn setup_oidc_test(
(app, provider)
}
#[actix_web::test]
async fn test_oidc_cached_authorization_redirect_cannot_replay_consumed_state() {
let (app, provider) = setup_oidc_test(|_| {}).await;
let mut cookies: Vec<Cookie<'static>> = Vec::new();
// Reproduces https://github.com/sqlpage/SQLPage/issues/1341: Chrome's
// private cache can replay a cached 303 without reapplying Set-Cookie
// headers, causing the browser to retry an already-consumed OIDC state.
let initial_response = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
let initial_auth_url = Url::parse(
initial_response
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap(),
)
.unwrap();
let cached_authorization_url =
permits_storage_after_proxy_adds_freshness(initial_response.headers())
.then_some(initial_auth_url.clone());
let state = get_query_param(&initial_auth_url, "state");
let nonce = get_query_param(&initial_auth_url, "nonce");
let redirect_uri = get_query_param(&initial_auth_url, "redirect_uri");
let callback_path = Url::parse(&redirect_uri).unwrap().path().to_owned();
provider.store_auth_code("first-code".to_string(), nonce.clone());
let callback_uri = format!("{callback_path}?code=first-code&state={state}");
let callback_response =
request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies);
assert_eq!(callback_response.status(), StatusCode::SEE_OTHER);
if let Some(stale_authorization_url) = cached_authorization_url {
// A fresh Entra code is returned for the authorization URL cached by
// Chrome, but its state is the state that the successful callback just
// consumed. Before this fix, SQLPage restarted OIDC here, creating the
// observed redirect loop.
let stale_state = get_query_param(&stale_authorization_url, "state");
provider.store_auth_code("stale-code".to_string(), nonce);
let stale_callback_uri = format!("{callback_path}?code=stale-code&state={stale_state}");
let stale_callback_response = request_with_cookies!(
app,
test::TestRequest::get().uri(&stale_callback_uri),
cookies
);
panic!(
"a cacheable authorization redirect replays a consumed state; stale callback redirected to {}",
stale_callback_response
.headers()
.get(header::LOCATION)
.unwrap()
.to_str()
.unwrap()
);
}
// With `no-store`, Chrome re-requests `/` after login and sends its new
// auth cookie instead of following the original authorization redirect.
let final_response = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
assert_eq!(final_response.status(), StatusCode::OK);
}
#[actix_web::test]
async fn test_oidc_happy_path() {
let (app, provider) = setup_oidc_test(|_| {}).await;
@@ -332,6 +405,11 @@ async fn test_oidc_happy_path() {
let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers().get(header::CACHE_CONTROL).unwrap(),
"no-store",
"the authorization redirect contains a one-time state and must not be cached"
);
let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap();
let state = get_query_param(&auth_url, "state");
@@ -347,6 +425,11 @@ async fn test_oidc_happy_path() {
let callback_resp =
request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies);
assert_eq!(callback_resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
callback_resp.headers().get(header::CACHE_CONTROL).unwrap(),
"no-store",
"the post-login redirect must not re-enter a cached authorization redirect"
);
let final_resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies);
assert_eq!(final_resp.status(), StatusCode::OK);