Skip to content

Commit ba4292b

Browse files
committed
add an easy was to persist uploaded files
fixes #199
1 parent 0f80ad2 commit ba4292b

File tree

11 files changed

+217
-62
lines changed

11 files changed

+217
-62
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## 0.20.1 (unreleased)
44

55
- More than 200 new icons, with [tabler icons v3](https://tabler.io/icons/changelog#3.0)
6+
- New [`sqlpage.persist_uploaded_file`](https://sql.ophir.dev/functions.sql?function=persist_uploaded_file#function) function to save uploaded files to a permanent location on the local filesystem (where SQLPage is running). This is useful to store files uploaded by users in a safe location, and to serve them back to users later.
7+
- Correct error handling for file uploads. SQLPage used to silently ignore file uploads that failed (because they exceeded [max_uploaded_file_size](./configuration.md), for instance), but now it displays a clear error message to the user.
68

79
## 0.20.0 (2024-03-12)
810

Cargo.lock

Lines changed: 17 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
images/
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"max_uploaded_file_size": 500000
2+
"max_uploaded_file_size": 5000000
33
}

examples/image gallery with user uploads/upload.sql

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ insert or ignore into image (title, description, image_url)
1111
values (
1212
:Title,
1313
:Description,
14-
sqlpage.read_file_as_data_url(sqlpage.uploaded_file_path('Image'))
14+
-- Persist the uploaded file to the local "images" folder at the root of the website and return the path
15+
sqlpage.persist_uploaded_file('Image', 'images', 'jpg,jpeg,png,gif')
16+
-- alternatively, if the images are small, you could store them in the database directly with the following line
17+
-- sqlpage.read_file_as_data_url(sqlpage.uploaded_file_path('Image'))
1518
)
1619
returning 'redirect' as component,
1720
format('/?created_id=%d', id) as link;

examples/official-site/sqlpage/migrations/23_uploaded_file_functions.sql

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -48,27 +48,12 @@ insert into text_documents (title, path) values (:title, sqlpage.read_file_as_te
4848
When the uploaded file is larger than a few megabytes, it is not recommended to store it in the database.
4949
Instead, one can save the file to a permanent location on the server, and store the path to the file in the database.
5050
51-
You can move the file to a permanent location using the [`sqlpage.exec`](?function=exec#function) function:
52-
53-
```sql
54-
set file_name = sqlpage.random_string(10);
55-
set exec_result = sqlpage.exec(''mv'', sqlpage.uploaded_file_path(''myfile''), ''/my_upload_directory/'' || $file_name);
56-
insert into uploaded_files (title, path) values (:title, $file_name);
57-
```
58-
59-
> *Notes*:
60-
> - The `sqlpage.exec` function is disabled by default, and you need to enable it in the [configuration file](https://github.com/lovasoa/SQLpage/blob/main/configuration.md).
61-
> - `mv` is specific to MacOS and Linux. On Windows, you can use [`move`](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/move) instead:
62-
> - ```sql
63-
> SET image_path = sqlpage.uploaded_file_path(''myfile'');
64-
> SET exec_result = sqlpage.exec(''cmd'', ''/C'', ''move'', $image_path, ''C:\MyUploadDirectory'');
65-
> ```
66-
51+
You can move the file to a permanent location using the [`sqlpage.persist_uploaded_file`](?function=persist_uploaded_file#function) function.
6752
### Advanced file handling
6853
6954
For more advanced file handling, such as uploading files to a cloud storage service,
7055
you can write a small script in your favorite programming language,
71-
and call it using the `sqlpage.exec` function.
56+
and call it using the [`sqlpage.exec`](?function=exec#function) function.
7257
7358
For instance, one could save the following small bash script to `/usr/local/bin/upload_to_s3`:
7459
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
INSERT INTO sqlpage_functions (
2+
"name",
3+
"introduced_in_version",
4+
"icon",
5+
"description_md"
6+
)
7+
VALUES (
8+
'persist_uploaded_file',
9+
'0.20.1',
10+
'device-floppy',
11+
'Persists an uploaded file to the local filesystem, and returns its path.
12+
13+
### Example
14+
15+
#### User profile picture
16+
17+
##### `upload_form.sql`
18+
19+
```sql
20+
select ''form'' as component, ''persist_uploaded_file.sql'' as action;
21+
select ''file'' as type, ''profile_picture'' as name, ''Upload your profile picture'' as label;
22+
```
23+
24+
##### `persist_uploaded_file.sql`
25+
26+
```sql
27+
update user
28+
set profile_picture = sqlpage.persist_uploaded_file(''profile_picture'', ''profile_pictures'', ''jpg,jpeg,png,gif,webp'')
29+
where id = (
30+
select user_id from session where session_id = sqlpage.cookie(''session_id'')
31+
);
32+
```
33+
34+
'
35+
);
36+
INSERT INTO sqlpage_function_parameters (
37+
"function",
38+
"index",
39+
"name",
40+
"description_md",
41+
"type"
42+
)
43+
VALUES (
44+
'persist_uploaded_file',
45+
1,
46+
'file',
47+
'Name of the form field containing the uploaded file. The current page must be referenced in the `action` property of a `form` component that contains a file input field.',
48+
'TEXT'
49+
),
50+
(
51+
'persist_uploaded_file',
52+
2,
53+
'destination_folder',
54+
'Optional. Path to the folder where the file will be saved, relative to the web root (the root folder of your website files). By default, the file will be saved in the `uploads` folder.',
55+
'TEXT'
56+
),
57+
(
58+
'persist_uploaded_file',
59+
3,
60+
'allowed_extensions',
61+
'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.
62+
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.',
63+
'TEXT'
64+
);

sqlpage/sqlpage.db

8 KB
Binary file not shown.

src/webserver/database/sql_pseudofunctions.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ pub(super) enum StmtParam {
4242
Literal(String),
4343
UploadedFilePath(String),
4444
UploadedFileMimeType(String),
45+
PersistUploadedFile {
46+
field_name: Box<StmtParam>,
47+
folder: Option<Box<StmtParam>>,
48+
allowed_extensions: Option<Box<StmtParam>>,
49+
},
4550
ReadFileAsText(Box<StmtParam>),
4651
ReadFileAsDataUrl(Box<StmtParam>),
4752
RunSql(Box<StmtParam>),
@@ -107,6 +112,25 @@ pub(super) fn func_call_to_param(func_name: &str, arguments: &mut [FunctionArg])
107112
extract_single_quoted_string("uploaded_file_mime_type", arguments)
108113
.map_or_else(StmtParam::Error, StmtParam::UploadedFileMimeType)
109114
}
115+
"persist_uploaded_file" => {
116+
let field_name = Box::new(extract_variable_argument(
117+
"persist_uploaded_file",
118+
arguments,
119+
));
120+
let folder = arguments
121+
.get_mut(1)
122+
.and_then(function_arg_to_stmt_param)
123+
.map(Box::new);
124+
let allowed_extensions = arguments
125+
.get_mut(2)
126+
.and_then(function_arg_to_stmt_param)
127+
.map(Box::new);
128+
StmtParam::PersistUploadedFile {
129+
field_name,
130+
folder,
131+
allowed_extensions,
132+
}
133+
}
110134
"read_file_as_text" => StmtParam::ReadFileAsText(Box::new(extract_variable_argument(
111135
"read_file_as_text",
112136
arguments,
@@ -135,10 +159,88 @@ pub(super) async fn extract_req_param<'a>(
135159
StmtParam::ReadFileAsText(inner) => read_file_as_text(inner, request).await?,
136160
StmtParam::ReadFileAsDataUrl(inner) => read_file_as_data_url(inner, request).await?,
137161
StmtParam::RunSql(inner) => run_sql(inner, request).await?,
162+
StmtParam::PersistUploadedFile {
163+
field_name,
164+
folder,
165+
allowed_extensions,
166+
} => {
167+
persist_uploaded_file(
168+
field_name,
169+
folder.as_deref(),
170+
allowed_extensions.as_deref(),
171+
request,
172+
)
173+
.await?
174+
}
138175
_ => extract_req_param_non_nested(param, request)?,
139176
})
140177
}
141178

179+
const DEFAULT_ALLOWED_EXTENSIONS: &str =
180+
"jpg,jpeg,png,gif,bmp,webp,pdf,txt,doc,docx,xls,xlsx,csv,mp3,mp4,wav,avi,mov";
181+
182+
async fn persist_uploaded_file<'a>(
183+
field_name: &StmtParam,
184+
folder: Option<&StmtParam>,
185+
allowed_extensions: Option<&StmtParam>,
186+
request: &'a RequestInfo,
187+
) -> anyhow::Result<Option<Cow<'a, str>>> {
188+
let field_name = extract_req_param_non_nested(field_name, request)?
189+
.ok_or_else(|| anyhow!("persist_uploaded_file: field_name is NULL"))?;
190+
let folder = folder
191+
.map_or_else(|| Ok(None), |x| extract_req_param_non_nested(x, request))?
192+
.unwrap_or(Cow::Borrowed("uploads"));
193+
let allowed_extensions_str = &allowed_extensions
194+
.map_or_else(|| Ok(None), |x| extract_req_param_non_nested(x, request))?
195+
.unwrap_or(Cow::Borrowed(DEFAULT_ALLOWED_EXTENSIONS));
196+
let allowed_extensions = allowed_extensions_str.split(',');
197+
let uploaded_file = request
198+
.uploaded_files
199+
.get(&field_name.to_string())
200+
.ok_or_else(|| {
201+
anyhow!("persist_uploaded_file: no file uploaded with field name {field_name}. Uploaded files: {:?}", request.uploaded_files.keys())
202+
})?;
203+
let file_name = &uploaded_file.file_name.as_deref().unwrap_or_default();
204+
let extension = file_name.split('.').last().unwrap_or_default();
205+
if !allowed_extensions
206+
.clone()
207+
.any(|x| x.eq_ignore_ascii_case(extension))
208+
{
209+
let exts = allowed_extensions.collect::<Vec<_>>().join(", ");
210+
bail!(
211+
"persist_uploaded_file: file extension {extension} is not allowed. Allowed extensions: {exts}"
212+
);
213+
}
214+
// resolve the folder path relative to the web root
215+
let web_root = &request.app_state.config.web_root;
216+
let target_folder = web_root.join(&*folder);
217+
// create the folder if it doesn't exist
218+
tokio::fs::create_dir_all(&target_folder)
219+
.await
220+
.with_context(|| {
221+
format!("persist_uploaded_file: unable to create folder {target_folder:?}")
222+
})?;
223+
let date = chrono::Utc::now().format("%Y-%m-%d %Hh%Mm%Ss");
224+
let random_part = random_string(8);
225+
let random_target_name = format!("{date} {random_part}.{extension}");
226+
let target_path = target_folder.join(&random_target_name);
227+
tokio::fs::copy(&uploaded_file.file.path(), &target_path)
228+
.await
229+
.with_context(|| {
230+
format!(
231+
"persist_uploaded_file: unable to copy uploaded file {field_name:?} to {target_path:?}"
232+
)
233+
})?;
234+
// remove the WEB_ROOT prefix from the path, but keep the leading slash
235+
let path = "/".to_string() + target_path
236+
.strip_prefix(web_root)?
237+
.to_str()
238+
.with_context(|| {
239+
format!("persist_uploaded_file: unable to convert path {target_path:?} to a string")
240+
})?;
241+
Ok(Some(Cow::Owned(path)))
242+
}
243+
142244
fn url_encode<'a>(
143245
inner: &StmtParam,
144246
request: &'a RequestInfo,
@@ -357,6 +459,9 @@ pub(super) fn extract_req_param_non_nested<'a>(
357459
.get(x)
358460
.and_then(|x| x.file.path().to_str())
359461
.map(Cow::Borrowed),
462+
StmtParam::PersistUploadedFile { .. } => {
463+
bail!("Nested persist_uploaded_file() function not allowed")
464+
}
360465
StmtParam::UploadedFileMimeType(x) => request
361466
.uploaded_files
362467
.get(x)

src/webserver/http.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,9 @@ async fn render_sql(
219219
.clone() // Cheap reference count increase
220220
.into_inner();
221221

222-
let mut req_param = extract_request_info(srv_req, Arc::clone(&app_state)).await;
222+
let mut req_param = extract_request_info(srv_req, Arc::clone(&app_state))
223+
.await
224+
.map_err(anyhow_err_to_actix)?;
223225
log::debug!("Received a request with the following parameters: {req_param:?}");
224226

225227
let (resp_send, resp_recv) = tokio::sync::oneshot::channel::<HttpResponse>();

0 commit comments

Comments
 (0)