Skip to content

Commit b29899e

Browse files
committed
add support for large form submissions
fixes #705
1 parent f2e7e9e commit b29899e

File tree

6 files changed

+114
-38
lines changed

6 files changed

+114
-38
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@
4747
- Encryption is supported on both sides, but disabled on either side. You can enable this mode in this library by setting `encrypt=off` in the connection string. In this mode, the login phase will be encrypted, but data packets will be sent in plaintext.
4848
- Encryption is supported and enabled on both sides. You can enable this mode in this library by setting `encrypt=strict` in the connection string. In this mode, both the login phase and data packets will be encrypted.
4949
- Improved logging in the mssql driver login phase
50+
- Improved handling of very large form submissions
51+
- The was a fixed 16kB limit on the size of form submissions.
52+
- The size is now limited by the `max_uploaded_file_size` configuration option, which defaults to 5MB.
53+
-
5054

5155
## 0.30.1 (2024-10-31)
5256
- fix a bug where table sorting would break if table search was not also enabled.

configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Here are the available configuration options and their default values:
2323
| `site_prefix` | `/` | Base path of the site. If you want to host SQLPage at `https://example.com/sqlpage/`, set this to `/sqlpage/`. When using a reverse proxy, this allows hosting SQLPage together with other applications on the same subdomain. |
2424
| `configuration_directory` | `./sqlpage/` | The directory where the `sqlpage.json` file is located. This is used to find the path to [`templates/`](https://sql.datapage.app/custom_components.sql), [`migrations/`](https://sql.datapage.app/your-first-sql-website/migrations.sql), and `on_connect.sql`. Obviously, this configuration parameter can be set only through environment variables, not through the `sqlpage.json` file itself in order to find the `sqlpage.json` file. Be careful not to use a path that is accessible from the public WEB_ROOT |
2525
| `allow_exec` | false | Allow usage of the `sqlpage.exec` function. Do this only if all users with write access to sqlpage query files and to the optional `sqlpage_files` table on the database are trusted. |
26-
| `max_uploaded_file_size` | 5242880 | Maximum size of uploaded files in bytes. Defaults to 5 MiB. |
26+
| `max_uploaded_file_size` | 5242880 | Maximum size of forms and uploaded files in bytes. Defaults to 5 MiB. |
2727
| `max_pending_rows` | 256 | Maximum number of rendered rows that can be queued up in memory when a client is slow to receive them. |
2828
| `compress_responses` | true | When the client supports it, compress the http response body. This can save bandwidth and speed up page loading on slow connections, but can also increase CPU usage and cause rendering delays on pages that take time to render (because streaming responses are buffered for longer than necessary). |
2929
| `https_domain` | | Domain name to request a certificate for. Setting this parameter will automatically make SQLPage listen on port 443 and request an SSL certificate. The server will take a little bit longer to start the first time it has to request a certificate. |

src/webserver/http.rs

Lines changed: 63 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ async fn render_sql(
160160

161161
let mut req_param = extract_request_info(srv_req, Arc::clone(&app_state))
162162
.await
163-
.map_err(anyhow_err_to_actix)?;
163+
.map_err(|e| anyhow_err_to_actix(e, app_state.config.environment))?;
164164
log::debug!("Received a request with the following parameters: {req_param:?}");
165165

166166
let (resp_send, resp_recv) = tokio::sync::oneshot::channel::<HttpResponse>();
@@ -203,14 +203,12 @@ async fn render_sql(
203203
resp_recv.await.map_err(ErrorInternalServerError)
204204
}
205205

206-
fn send_anyhow_error(
207-
e: &anyhow::Error,
208-
resp_send: tokio::sync::oneshot::Sender<HttpResponse>,
209-
env: app_config::DevOrProd,
210-
) {
211-
log::error!("An error occurred before starting to send the response body: {e:#}");
206+
fn anyhow_err_to_actix_resp(e: &anyhow::Error, env: app_config::DevOrProd) -> HttpResponse {
212207
let mut resp = HttpResponseBuilder::new(StatusCode::INTERNAL_SERVER_ERROR);
213-
let mut body = "Sorry, but we were not able to process your request. \n\n".to_owned();
208+
let mut body = "Sorry, but we were not able to process your request. \n\n\
209+
Below are detailed debugging information which may contain sensitive data. \
210+
Set environment to \"prod\" in the configuration file to hide this information. \n\n"
211+
.to_owned();
214212
if env.is_prod() {
215213
body.push_str("Contact the administrator for more information. A detailed error message has been logged.");
216214
} else {
@@ -221,23 +219,41 @@ fn send_anyhow_error(
221219
header::CONTENT_TYPE,
222220
header::HeaderValue::from_static("text/plain"),
223221
));
224-
let resp = if let Some(e @ &ErrorWithStatus { .. }) = e.downcast_ref() {
225-
e.error_response()
222+
223+
if let Some(status_err @ &ErrorWithStatus { .. }) = e.downcast_ref() {
224+
status_err
225+
.error_response()
226+
.set_body(actix_web::body::BoxBody::new(body))
226227
} else if let Some(sqlx::Error::PoolTimedOut) = e.downcast_ref() {
227-
// People are HTTP connections faster than we can open SQL connections. Ask them to slow down politely.
228228
use rand::Rng;
229-
resp.status(StatusCode::SERVICE_UNAVAILABLE).insert_header((
230-
header::RETRY_AFTER,
231-
header::HeaderValue::from(rand::thread_rng().gen_range(1..=15)),
232-
)).body("The database is currently too busy to handle your request. Please try again later.\n\n".to_owned() + &body)
229+
resp.status(StatusCode::TOO_MANY_REQUESTS)
230+
.insert_header((
231+
header::RETRY_AFTER,
232+
header::HeaderValue::from(rand::thread_rng().gen_range(1..=15)),
233+
))
234+
.body("The database is currently too busy to handle your request. Please try again later.\n\n".to_owned() + &body)
233235
} else {
234236
resp.body(body)
235-
};
237+
}
238+
}
239+
240+
fn send_anyhow_error(
241+
e: &anyhow::Error,
242+
resp_send: tokio::sync::oneshot::Sender<HttpResponse>,
243+
env: app_config::DevOrProd,
244+
) {
245+
log::error!("An error occurred before starting to send the response body: {e:#}");
236246
resp_send
237-
.send(resp)
247+
.send(anyhow_err_to_actix_resp(e, env))
238248
.unwrap_or_else(|_| log::error!("could not send headers"));
239249
}
240250

251+
fn anyhow_err_to_actix(e: anyhow::Error, env: app_config::DevOrProd) -> actix_web::Error {
252+
log::error!("{e:#}");
253+
let resp = anyhow_err_to_actix_resp(&e, env);
254+
actix_web::error::InternalError::from_response(e, resp).into()
255+
}
256+
241257
#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Clone)]
242258
#[serde(untagged)]
243259
pub enum SingleOrVec {
@@ -295,20 +311,10 @@ async fn process_sql_request(
295311
.get_with_privilege(app_state, &sql_path, false)
296312
.await
297313
.with_context(|| format!("Unable to get SQL file {sql_path:?}"))
298-
.map_err(anyhow_err_to_actix)?;
314+
.map_err(|e| anyhow_err_to_actix(e, app_state.config.environment))?;
299315
render_sql(req, sql_file).await
300316
}
301317

302-
fn anyhow_err_to_actix(e: anyhow::Error) -> actix_web::Error {
303-
log::error!("{e:#}");
304-
match e.downcast::<ErrorWithStatus>() {
305-
Ok(err) => actix_web::Error::from(err),
306-
Err(e) => ErrorInternalServerError(format!(
307-
"An error occurred while trying to handle your request: {e:#}"
308-
)),
309-
}
310-
}
311-
312318
async fn serve_file(
313319
path: &str,
314320
state: &AppState,
@@ -322,7 +328,7 @@ async fn serve_file(
322328
.modified_since(state, path.as_ref(), since, false)
323329
.await
324330
.with_context(|| format!("Unable to get modification time of file {path:?}"))
325-
.map_err(anyhow_err_to_actix)?;
331+
.map_err(|e| anyhow_err_to_actix(e, state.config.environment))?;
326332
if !modified {
327333
return Ok(HttpResponse::NotModified().finish());
328334
}
@@ -332,7 +338,7 @@ async fn serve_file(
332338
.read_file(state, path.as_ref(), false)
333339
.await
334340
.with_context(|| format!("Unable to read file {path:?}"))
335-
.map_err(anyhow_err_to_actix)
341+
.map_err(|e| anyhow_err_to_actix(e, state.config.environment))
336342
.map(|b| {
337343
HttpResponse::Ok()
338344
.insert_header(
@@ -392,7 +398,7 @@ async fn serve_fallback(
392398
return render_sql(service_request, sql_file).await;
393399
}
394400
Err(e) => {
395-
let actix_web_err = anyhow_err_to_actix(e);
401+
let actix_web_err = anyhow_err_to_actix(e, app_state.config.environment);
396402

397403
// `maybe_fallback_path` does not exist, continue search in parent dir.
398404
if actix_web_err.as_response_error().status_code() == StatusCode::NOT_FOUND {
@@ -553,10 +559,35 @@ pub fn create_app(
553559
.wrap(middleware::NormalizePath::new(
554560
middleware::TrailingSlash::MergeOnly,
555561
))
556-
.app_data(PayloadConfig::default().limit(app_state.config.max_uploaded_file_size * 2))
562+
.app_data(payload_config(&app_state))
563+
.app_data(form_config(&app_state))
557564
.app_data(app_state)
558565
}
559566

567+
#[must_use]
568+
pub fn form_config(app_state: &web::Data<AppState>) -> web::FormConfig {
569+
web::FormConfig::default()
570+
.limit(app_state.config.max_uploaded_file_size)
571+
.error_handler(|decode_err, _req| {
572+
match decode_err {
573+
actix_web::error::UrlencodedError::Overflow { size, limit } => {
574+
actix_web::error::ErrorPayloadTooLarge(
575+
format!(
576+
"The submitted form data size ({size} bytes) exceeds the maximum allowed upload size ({limit} bytes). \
577+
You can increase this limit by setting max_uploaded_file_size in the configuration file.",
578+
),
579+
)
580+
}
581+
_ => actix_web::Error::from(decode_err),
582+
}
583+
})
584+
}
585+
586+
#[must_use]
587+
pub fn payload_config(app_state: &web::Data<AppState>) -> PayloadConfig {
588+
PayloadConfig::default().limit(app_state.config.max_uploaded_file_size * 2)
589+
}
590+
560591
fn default_headers(app_state: &web::Data<AppState>) -> middleware::DefaultHeaders {
561592
let server_header = format!("{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
562593
let mut headers = middleware::DefaultHeaders::new().add(("Server", server_header));

src/webserver/http_request_info.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,14 @@ async fn extract_urlencoded_post_variables(
144144
Form::<Vec<(String, String)>>::from_request(http_req, payload)
145145
.await
146146
.map(Form::into_inner)
147-
.map_err(|e| anyhow!("could not parse request as urlencoded form data: {e}"))
147+
.map_err(|e| {
148+
anyhow!(super::ErrorWithStatus {
149+
status: actix_web::http::StatusCode::BAD_REQUEST,
150+
})
151+
.context(format!(
152+
"could not parse request as urlencoded form data: {e}"
153+
))
154+
})
148155
}
149156

150157
async fn extract_multipart_post_data(

tests/display_form_field.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- This test checks that the size of the form field can successfully roundtrip,
2+
-- from POST variable to sqlpage variable to handlebars, back to the client
3+
set x = :x;
4+
select 'text' as component, $x as contents;

tests/index.rs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ use actix_web::{
1313
};
1414
use sqlpage::{
1515
app_config::{test_database_url, AppConfig},
16-
webserver::{self, http::main_handler},
16+
webserver::{
17+
self,
18+
http::{form_config, main_handler, payload_config},
19+
},
1720
AppState,
1821
};
1922

@@ -341,7 +344,7 @@ async fn test_blank_file_upload_field() -> actix_web::Result<()> {
341344

342345
#[actix_web::test]
343346
async fn test_file_upload_too_large() -> actix_web::Result<()> {
344-
// Files larger than 12345 bytes should be rejected as per the test_config
347+
// Files larger than 123456 bytes should be rejected as per the test_config
345348
let req = get_request_to("/tests/upload_file_test.sql")
346349
.await?
347350
.insert_header(("content-type", "multipart/form-data; boundary=1234567890"))
@@ -352,7 +355,7 @@ async fn test_file_upload_too_large() -> actix_web::Result<()> {
352355
\r\n\
353356
"
354357
.to_string()
355-
+ "a".repeat(12346).as_str()
358+
+ "a".repeat(123457).as_str()
356359
+ "\r\n\
357360
--1234567890--\r\n",
358361
)
@@ -368,6 +371,30 @@ async fn test_file_upload_too_large() -> actix_web::Result<()> {
368371
Ok(())
369372
}
370373

374+
#[actix_web::test]
375+
async fn test_large_form_field_roundtrip() -> actix_web::Result<()> {
376+
// POST payloads smaller than 123456 bytes should be accepted
377+
let long_string = "a".repeat(123454);
378+
let req = get_request_to("/tests/display_form_field.sql")
379+
.await?
380+
.insert_header(("content-type", "application/x-www-form-urlencoded"))
381+
.set_payload(["x=", &long_string].concat()) // total size is 123454 + 2 = 123456 bytes
382+
.to_srv_request();
383+
let resp = main_handler(req).await?;
384+
assert_eq!(resp.status(), StatusCode::OK);
385+
let body = test::read_body(resp).await;
386+
let body_str = String::from_utf8(body.to_vec()).unwrap();
387+
assert!(
388+
!body_str.contains("error"),
389+
"{body_str}\nshouldn't have errors"
390+
);
391+
assert!(
392+
body_str.contains(&long_string),
393+
"{body_str}\nexpected to contain long string submitted"
394+
);
395+
Ok(())
396+
}
397+
371398
#[actix_web::test]
372399
async fn test_upload_file_data_url() -> actix_web::Result<()> {
373400
let req = get_request_to("/tests/upload_file_data_url_test.sql")
@@ -539,8 +566,11 @@ async fn get_request_to(path: &str) -> actix_web::Result<TestRequest> {
539566
Ok(test::TestRequest::get()
540567
.uri(path)
541568
.insert_header(ContentType::plaintext())
569+
.app_data(payload_config(&data))
570+
.app_data(form_config(&data))
542571
.app_data(data))
543572
}
573+
544574
async fn make_app_data_from_config(config: AppConfig) -> actix_web::web::Data<AppState> {
545575
let state = AppState::init(&config).await.unwrap();
546576
actix_web::web::Data::new(state)
@@ -587,7 +617,7 @@ pub fn test_config() -> AppConfig {
587617
"database_connection_retries": 3,
588618
"database_connection_acquire_timeout_seconds": 15,
589619
"allow_exec": true,
590-
"max_uploaded_file_size": 12345,
620+
"max_uploaded_file_size": 123456,
591621
"listen_on": "111.111.111.111:1",
592622
"system_root_ca_certificates" : false
593623
}}"#,

0 commit comments

Comments
 (0)