Skip to content

Commit 99ad268

Browse files
Add optional mode argument to persist_uploaded_file (#1241)
* Add optional mode argument to persist_uploaded_file This change adds an optional `mode` argument to the `persist_uploaded_file` function, allowing users to specify the Unix file permissions in octal notation when saving uploaded files. - Updated `sqlpage.persist_uploaded_file` signature to include `mode`. - Implemented permission setting logic using `std::os::unix::fs::PermissionsExt` (on Unix platforms). - Default permission is set to "600" (octal `0o600`). - Added documentation for the new parameter in `examples/official-site/sqlpage/migrations/39_persist_uploaded_file.sql`, including an explanation of octal notation and a link to Wikipedia. - Added a unit test `test_set_file_mode` to verify the permission setting logic. Co-authored-by: lovasoa <552629+lovasoa@users.noreply.github.com> * Address PR feedback: Add integrated test for persist_uploaded_file mode - Removed unit test from `functions.rs` and added an integrated test in `tests/uploads/mod.rs`. - Created `tests/uploads/persist_with_mode.sql` for the integrated test. - Refactored `set_file_mode` to use `#[cfg(unix)]` and `#[cfg(not(unix))]` on the entire function. - Replied to PR comments. Co-authored-by: lovasoa <552629+lovasoa@users.noreply.github.com> * Fix Windows CI: Normalize paths and improve tests - Normalized `persist_uploaded_file` return path to use forward slashes for URL compatibility. - Updated `test_persist_uploaded_file_mode` to handle platform-specific path separators. - Fixed clippy warning `expect_fun_call` in tests. Co-authored-by: lovasoa <552629+lovasoa@users.noreply.github.com> * Address PR feedback: Assert file contents and ignore test uploads - Added assertion to verify persisted file contents in `test_persist_uploaded_file_mode`. - Removed accidental test file from git and added `tests_uploads/` to `.gitignore`. - Replied to PR comments. Co-authored-by: lovasoa <552629+lovasoa@users.noreply.github.com> * Address PR feedback: Revert breaking change to return value - Reverted normalization of `persist_uploaded_file` return value to avoid a breaking change. - Reverted corresponding test changes that relied on normalized paths. - Replied to PR comments. Co-authored-by: lovasoa <552629+lovasoa@users.noreply.github.com> * Address PR feedback: Query results as JSON in integrated test - Updated `test_persist_uploaded_file_mode` to directy request and verify JSON results. - Replied to PR comments. Co-authored-by: lovasoa <552629+lovasoa@users.noreply.github.com> * Fix Windows CI: Robust path handling in tests - Improved integrated test to correctly request JSON results. - Added platform-specific path normalization when verifying files on disk. - Ensured `persist_uploaded_file` return value remains OS-specific to avoid breaking changes. Co-authored-by: lovasoa <552629+lovasoa@users.noreply.github.com> * Address PR feedback: Delete leftover test file - Deleted accidental leftover test file `tests_uploads/2026-03-13_15h47m26s_6JaXODDK.txt`. - Replied to PR comments. Co-authored-by: lovasoa <552629+lovasoa@users.noreply.github.com> --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent 3eb5274 commit 99ad268

5 files changed

Lines changed: 92 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ xbed.sql
1111
**/sqlpage.bin
1212
node_modules/
1313
sqlpage/sqlpage.db
14+
tests_uploads/

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,14 @@ VALUES (
6262
'Optional. Comma-separated list of allowed file extensions. By default: jpg,jpeg,png,gif,bmp,webp,pdf,txt,doc,docx,xls,xlsx,csv,mp3,mp4,wav,avi,mov.
6363
Changing this may be dangerous ! If you add "sql", "svg" or "html" to the list, an attacker could execute arbitrary SQL queries on your database, or impersonate other users.',
6464
'TEXT'
65+
),
66+
(
67+
'persist_uploaded_file',
68+
4,
69+
'mode',
70+
'Optional. Unix permissions to set on the file, in octal notation. By default, the file will be saved with "600" (read/write for the owner only).
71+
Octal notation works by using three digits from 0 to 7: the first for the owner, the second for the group, and the third for others.
72+
For example, "644" means read/write for the owner, and read-only for others.
73+
[Learn more about numeric notation for file-system permissions on Wikipedia](https://en.wikipedia.org/wiki/File-system_permissions#Numeric_notation).',
74+
'TEXT'
6575
);

src/webserver/database/sqlpage_functions/functions.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ super::function_definition_macro::sqlpage_functions! {
4242
link(file: Cow<str>, parameters: Option<Cow<str>>, hash: Option<Cow<str>>);
4343

4444
path((&RequestInfo));
45-
persist_uploaded_file((&RequestInfo), field_name: Cow<str>, folder: Option<Cow<str>>, allowed_extensions: Option<Cow<str>>);
45+
persist_uploaded_file((&RequestInfo), field_name: Cow<str>, folder: Option<Cow<str>>, allowed_extensions: Option<Cow<str>>, mode: Option<Cow<str>>);
4646
protocol((&RequestInfo));
4747

4848
random_string(string_length: SqlPageFunctionParam<usize>);
@@ -420,6 +420,7 @@ async fn persist_uploaded_file<'a>(
420420
field_name: Cow<'a, str>,
421421
folder: Option<Cow<'a, str>>,
422422
allowed_extensions: Option<Cow<'a, str>>,
423+
mode: Option<Cow<'a, str>>,
423424
) -> anyhow::Result<Option<String>> {
424425
let folder = folder.unwrap_or(Cow::Borrowed("uploads"));
425426
let allowed_extensions_str =
@@ -456,6 +457,7 @@ async fn persist_uploaded_file<'a>(
456457
target_path.display()
457458
)
458459
})?;
460+
set_file_mode(&target_path, mode.as_deref()).await?;
459461
// remove the WEB_ROOT prefix from the path, but keep the leading slash
460462
let path = "/".to_string()
461463
+ target_path
@@ -475,6 +477,26 @@ async fn protocol(request: &RequestInfo) -> &str {
475477
&request.protocol
476478
}
477479

480+
#[cfg(unix)]
481+
async fn set_file_mode(path: &std::path::Path, mode: Option<&str>) -> anyhow::Result<()> {
482+
use std::os::unix::fs::PermissionsExt;
483+
let mode = if let Some(mode) = mode {
484+
u32::from_str_radix(mode, 8)
485+
.with_context(|| format!("unable to parse file mode {mode:?} as an octal number"))?
486+
} else {
487+
0o600
488+
};
489+
tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
490+
.await
491+
.with_context(|| format!("unable to set permissions on {}", path.display()))?;
492+
Ok(())
493+
}
494+
495+
#[cfg(not(unix))]
496+
async fn set_file_mode(_path: &std::path::Path, _mode: Option<&str>) -> anyhow::Result<()> {
497+
Ok(())
498+
}
499+
478500
/// Returns a random string of the specified length.
479501
pub(crate) async fn random_string(len: usize) -> anyhow::Result<String> {
480502
// OsRng can block on Linux, so we run this on a blocking thread.

tests/uploads/mod.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,62 @@ async fn test_file_upload(target: &str) -> actix_web::Result<()> {
2828
Ok(())
2929
}
3030

31+
#[actix_web::test]
32+
async fn test_persist_uploaded_file_mode() -> actix_web::Result<()> {
33+
let app_data = crate::common::make_app_data().await;
34+
let req = actix_web::test::TestRequest::get()
35+
.uri("/tests/uploads/persist_with_mode.sql?mode=644")
36+
.app_data(app_data.clone())
37+
.app_data(sqlpage::webserver::http::payload_config(&app_data))
38+
.app_data(sqlpage::webserver::http::form_config(&app_data))
39+
.insert_header((actix_web::http::header::ACCEPT, "application/json"))
40+
.insert_header(("content-type", "multipart/form-data; boundary=1234567890"))
41+
.set_payload(
42+
"--1234567890\r\n\
43+
Content-Disposition: form-data; name=\"my_file\"; filename=\"test.txt\"\r\n\
44+
Content-Type: text/plain\r\n\
45+
\r\n\
46+
Hello\r\n\
47+
--1234567890--\r\n",
48+
)
49+
.to_srv_request();
50+
let resp = main_handler(req).await?;
51+
52+
assert_eq!(resp.status(), StatusCode::OK);
53+
let body_json: serde_json::Value = test::read_body_json(resp).await;
54+
let persisted_path = body_json[0]["contents"]
55+
.as_str()
56+
.expect("Path not found in JSON response");
57+
58+
// The path is relative to web root, we need to find it on disk.
59+
// In tests, web root is the repo root.
60+
// We normalize the path separators to be platform-specific for the file system check.
61+
let normalized_path = persisted_path
62+
.replace('\\', "/")
63+
.trim_start_matches('/')
64+
.replace('/', std::path::MAIN_SEPARATOR_STR);
65+
let file_path = std::path::Path::new(&normalized_path);
66+
assert!(
67+
file_path.exists(),
68+
"Persisted file {} does not exist. Persisted path: {}, JSON: {}",
69+
file_path.display(),
70+
persisted_path,
71+
body_json
72+
);
73+
let contents = std::fs::read_to_string(file_path)?;
74+
assert_eq!(contents, "Hello");
75+
76+
#[cfg(unix)]
77+
{
78+
use std::os::unix::fs::PermissionsExt;
79+
let metadata = std::fs::metadata(file_path)?;
80+
assert_eq!(metadata.permissions().mode() & 0o777, 0o644);
81+
}
82+
83+
std::fs::remove_file(file_path)?;
84+
Ok(())
85+
}
86+
3187
#[actix_web::test]
3288
async fn test_file_upload_direct() -> actix_web::Result<()> {
3389
test_file_upload("/tests/uploads/upload_file_test.sql").await
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
set contents = sqlpage.persist_uploaded_file('my_file', 'tests_uploads', 'txt', $mode);
2+
select 'text' as component, $contents as contents;

0 commit comments

Comments
 (0)