Skip to content

Commit 3b23920

Browse files
committed
1 parent 14133db commit 3b23920

File tree

6 files changed

+60
-13
lines changed

6 files changed

+60
-13
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- Fixed a bug in the cookie component where removing a cookie from a subdirectory would not work.
1111
- [Updated SQL parser](https://github.com/sqlparser-rs/sqlparser-rs/blob/main/CHANGELOG.md#0470-2024-06-01). Fixes support for `AT TIME ZONE` in postgres. Fixes `GROUP_CONCAT()` in MySQL.
1212
- Add a new warning message in the logs when trying to use `SET $x = ` when there is already a form field named `x`.
13+
- **Empty Uploaded files**: when a form contains an optional file upload field, and the user does not upload a file, the field used to still be accessible to SQLPage file-related functions such as `sqlpage.uploaded_file_path` and `sqlpage.uploaded_file_mime_type`. This is now fixed, and these functions will return `NULL` when the user does not upload a file. `sqlpage.persist_uploaded_file` will not create an empty file in the target directory when the user does not upload a file, instead it will do nothing and return `NULL`.
1314

1415
## 0.22.0 (2024-05-29)
1516
- **Important Security Fix:** The behavior of `SET $x` has been modified to match `SELECT $x`.

examples/official-site/sqlpage/migrations/39_persist_uploaded_file.sql

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ VALUES (
99
'0.20.1',
1010
'device-floppy',
1111
'Persists an uploaded file to the local filesystem, and returns its path.
12+
If the file input field is empty, the function returns NULL.
1213
1314
### Example
1415

src/webserver/database/sqlpage_functions/functions.rs

+7-12
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use super::RequestInfo;
22
use crate::webserver::{database::execute_queries::DbConn, http::SingleOrVec, ErrorWithStatus};
33
use anyhow::{anyhow, Context};
44
use futures_util::StreamExt;
5+
use mime_guess::mime;
56
use std::{borrow::Cow, ffi::OsStr, str::FromStr};
67

78
super::function_definition_macro::sqlpage_functions! {
@@ -194,20 +195,14 @@ async fn persist_uploaded_file<'a>(
194195
field_name: Cow<'a, str>,
195196
folder: Option<Cow<'a, str>>,
196197
allowed_extensions: Option<Cow<'a, str>>,
197-
) -> anyhow::Result<String> {
198+
) -> anyhow::Result<Option<String>> {
198199
let folder = folder.unwrap_or(Cow::Borrowed("uploads"));
199200
let allowed_extensions_str =
200201
allowed_extensions.unwrap_or(Cow::Borrowed(DEFAULT_ALLOWED_EXTENSIONS));
201202
let allowed_extensions = allowed_extensions_str.split(',');
202-
let uploaded_file = request
203-
.uploaded_files
204-
.get(&field_name.to_string())
205-
.ok_or_else(|| {
206-
anyhow!(
207-
"no file uploaded with field name {field_name}. Uploaded files: {:?}",
208-
request.uploaded_files.keys()
209-
)
210-
})?;
203+
let Some(uploaded_file) = request.uploaded_files.get(&field_name.to_string()) else {
204+
return Ok(None);
205+
};
211206
let file_name = uploaded_file.file_name.as_deref().unwrap_or_default();
212207
let extension = file_name.split('.').last().unwrap_or_default();
213208
if !allowed_extensions
@@ -239,7 +234,7 @@ async fn persist_uploaded_file<'a>(
239234
.strip_prefix(web_root)?
240235
.to_str()
241236
.with_context(|| format!("unable to convert path {target_path:?} to a string"))?;
242-
Ok(path)
237+
Ok(Some(path))
243238
}
244239

245240
/// Returns the protocol of the current request (http or https).
@@ -339,7 +334,7 @@ fn mime_from_upload_path<'a>(request: &'a RequestInfo, path: &str) -> Option<&'a
339334

340335
fn mime_guess_from_filename(filename: &str) -> mime_guess::Mime {
341336
let maybe_mime = mime_guess::from_path(filename).first();
342-
maybe_mime.unwrap_or(mime_guess::mime::APPLICATION_OCTET_STREAM)
337+
maybe_mime.unwrap_or(mime::APPLICATION_OCTET_STREAM)
343338
}
344339

345340
async fn request_method(request: &RequestInfo) -> String {

src/webserver/http_request_info.rs

+20
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@ async fn extract_multipart_post_data(
176176
)
177177
})?;
178178
log::trace!("Extracted file {field_name} to {:?}", extracted.file.path());
179+
if is_file_field_empty(&extracted).await? {
180+
log::debug!("Ignoring empty file field: {field_name}");
181+
continue;
182+
}
179183
uploaded_files.push((field_name, extracted));
180184
} else {
181185
let text_contents = extract_text(http_req, field, &mut limits).await?;
@@ -211,6 +215,22 @@ async fn extract_file(
211215
Ok(file)
212216
}
213217

218+
/// file upload form fields that are left blank result in the browser sending an empty file, with a mime type of application/octet-stream.
219+
/// We don't want to treat this the same as actual empty files, so we check for this case.
220+
async fn is_file_field_empty(
221+
uploaded_file: &actix_multipart::form::tempfile::TempFile,
222+
) -> anyhow::Result<bool> {
223+
Ok(
224+
uploaded_file.content_type == Some(mime_guess::mime::APPLICATION_OCTET_STREAM)
225+
&& uploaded_file
226+
.file_name
227+
.as_ref()
228+
.filter(|x| !x.is_empty())
229+
.is_none()
230+
&& tokio::fs::metadata(&uploaded_file.file.path()).await?.len() == 0,
231+
)
232+
}
233+
214234
#[cfg(test)]
215235
mod test {
216236
use super::super::http::SingleOrVec;

tests/index.rs

+27
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,33 @@ async fn test_file_upload_through_runsql() -> actix_web::Result<()> {
231231
test_file_upload("/tests/upload_file_runsql_test.sql").await
232232
}
233233

234+
// Diabled because of
235+
#[actix_web::test]
236+
async fn test_blank_file_upload_field() -> actix_web::Result<()> {
237+
let req = get_request_to("/tests/upload_file_test.sql")
238+
.await?
239+
.insert_header(("content-type", "multipart/form-data; boundary=1234567890"))
240+
.set_payload(
241+
"--1234567890\r\n\
242+
Content-Disposition: form-data; name=\"my_file\"; filename=\"\"\r\n\
243+
Content-Type: application/octet-stream\r\n\
244+
\r\n\
245+
\r\n\
246+
--1234567890--\r\n",
247+
)
248+
.to_srv_request();
249+
let resp = main_handler(req).await?;
250+
251+
assert_eq!(resp.status(), StatusCode::OK);
252+
let body = test::read_body(resp).await;
253+
let body_str = String::from_utf8(body.to_vec()).unwrap();
254+
assert!(
255+
body_str.contains("No file uploaded"),
256+
"{body_str}\nexpected to contain: No file uploaded"
257+
);
258+
Ok(())
259+
}
260+
234261
#[actix_web::test]
235262
async fn test_file_upload_too_large() -> actix_web::Result<()> {
236263
// Files larger than 12345 bytes should be rejected as per the test_config

tests/upload_file_test.sql

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
select 'text' as component,
2-
sqlpage.read_file_as_text(sqlpage.uploaded_file_path('my_file')) as contents;
2+
COALESCE(
3+
sqlpage.read_file_as_text(sqlpage.uploaded_file_path('my_file')),
4+
'No file uploaded'
5+
) as contents;

0 commit comments

Comments
 (0)