Skip to content

Commit b0dadea

Browse files
committed
feat: add download component
- Introduce a new download component to facilitate file downloads. - Implement download handling in the header context, supporting data URLs. - Add a test for the download functionality to ensure correct behavior. see #996
1 parent f318966 commit b0dadea

File tree

8 files changed

+189
-2
lines changed

8 files changed

+189
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- This allows you to trigger modals from any other component, including tables, maps, forms, lists and more.
1111
- Since modals have their own url inside the page, you can now link to a modal from another page, and if you refresh a page while the modal is open, the modal will stay open.
1212
- modals now have an `open` parameter to open the modal automatically when the page is loaded.
13+
- New [download](https://sql-page.com/component.sql?component=download) component to let the user download files. The files may be stored as BLOBs in the database, local files on the server, or may be fetched from a different server.
1314

1415
## v0.36.1
1516
- Fix regression introduced in v0.36.0: PostgreSQL money values showed as 0.0

examples/official-site/sqlpage/migrations/24_read_file_as_data_url.sql

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ VALUES (
1212
'Returns a [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs)
1313
containing the contents of the given file.
1414
15-
The file path is relative to the `web root` directory, which is the directory from which your website is served
16-
(not necessarily the directory SQLPage is launched from).
15+
The file path is relative to the `web root` directory, which is the directory from which your website is served.
16+
By default, this is the directory SQLPage is launched from, but you can change it
17+
with the `web_root` [configuration option](https://github.com/sqlpage/SQLPage/blob/main/configuration.md).
1718
1819
If the given argument is null, the function will return null.
1920
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
-- Insert the download component into the component table
2+
INSERT INTO
3+
component (name, description, icon, introduced_in_version)
4+
VALUES
5+
(
6+
'download',
7+
'
8+
The *download* component lets a page immediately return a file to the visitor.
9+
10+
Instead of showing a web page, it sends the file''s bytes as the whole response,
11+
so it should be used **at the very top of your SQL page** (before the shell or any other page contents).
12+
It is an error to use this component after another component that would display content.
13+
14+
How it works in simple terms:
15+
- You provide the file content using a [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs).
16+
A data URL is just a text string that contains both the file type and the actual data.
17+
- Optionally, you provide a "filename" so the browser shows a proper Save As name.
18+
If you do not provide a filename, many browsers will try to display the file inline (for example images or JSON), depending on the content type.
19+
- You link to the page that uses the download component from another page, using the [button](/components?component=button) component for example.
20+
21+
What is a data URL?
22+
- It looks like this: `data:[content-type][;base64],DATA`
23+
- Examples:
24+
- Plain text (URL-encoded): `data:text/plain,Hello%20world`
25+
- JSON (URL-encoded): `data:application/json,%7B%22message%22%3A%22Hi%22%7D`
26+
- Binary data (Base64): `data:application/octet-stream;base64,SGVsbG8h`
27+
28+
Tips:
29+
- Use URL encoding when you have textual data. You can use [`sqlpage.url_encode(source_text)`](/functions?function=url_encode) to encode the data.
30+
- Use Base64 when you have binary data (images, PDFs, or content that may include special characters).
31+
- Use [`sqlpage.read_file_as_data_url(file_path)`](/functions?function=read_file_as_data_url) to read a file from the server and return it as a data URL.
32+
33+
> Keep in mind that large files are better served from disk or object storage. Data URLs are best for small to medium files.
34+
There is a big performance penalty for loading large files as data URLs, so it is not recommended.
35+
',
36+
'download',
37+
'0.37.0'
38+
);
39+
40+
-- Insert the parameters for the download component into the parameter table
41+
INSERT INTO
42+
parameter (
43+
component,
44+
name,
45+
description,
46+
type,
47+
top_level,
48+
optional
49+
)
50+
VALUES
51+
(
52+
'download',
53+
'data_url',
54+
'The file content to send, written as a data URL (for example: data:text/plain,Hello%20world or data:application/octet-stream;base64,SGVsbG8h). The part before the comma declares the content type and whether the data is base64-encoded. The part after the comma is the actual data.',
55+
'TEXT',
56+
TRUE,
57+
FALSE
58+
),
59+
(
60+
'download',
61+
'filename',
62+
'The suggested name of the file to save (for example: report.csv). When set, the browser will download the file as an attachment with this name. When omitted, many browsers may try to display the file inline depending on its content type.',
63+
'TEXT',
64+
TRUE,
65+
TRUE
66+
);
67+
68+
-- Insert usage examples of the download component into the example table
69+
INSERT INTO
70+
example (component, description)
71+
VALUES
72+
(
73+
'download',
74+
'
75+
## Simple plain text file
76+
Download a small text file. The content is URL-encoded (spaces become %20).
77+
78+
```sql
79+
select
80+
''download'' as component,
81+
''data:text/plain,Hello%20SQLPage%20world!'' as data_url,
82+
''hello.txt'' as filename;
83+
```
84+
'
85+
),
86+
(
87+
'download',
88+
'
89+
## Download a PDF file from the server
90+
91+
Download a PDF file with the proper content type so PDF readers recognize it.
92+
Uses [`sqlpage.read_file_as_data_url(file_path)`](/functions?function=read_file_as_data_url) to read the file from the server.
93+
94+
```sql
95+
select
96+
''download'' as component,
97+
''report.pdf'' as filename,
98+
sqlpage.read_file_as_data_url(''report.pdf'') as data_url;
99+
```
100+
'
101+
),
102+
(
103+
'download',
104+
'
105+
## Serve an image stored as a BLOB in the database
106+
107+
In PostgreSQL, you can use the [encode(bytes, format)](https://www.postgresql.org/docs/current/functions-binarystring.html#FUNCTION-ENCODE) function to encode the file content as Base64.
108+
109+
```sql
110+
select
111+
''download'' as component,
112+
''data:'' || doc.mime_type || '';base64,'' || encode(doc.content::bytea, ''base64'') as data_url
113+
from document as doc
114+
where doc.id = $doc_id;
115+
```
116+
117+
- In Microsoft SQL Server, you can use the [BASE64_ENCODE(bytes)](https://learn.microsoft.com/en-us/sql/t-sql/functions/base64-encode-transact-sql) function to encode the file content as Base64.
118+
- In MySQL and MariaDB, you can use the [TO_BASE64(str)](https://mariadb.com/docs/server/reference/sql-functions/string-functions/to_base64) function.
119+
'
120+
);

src/render.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ impl HeaderContext {
118118
Some(HeaderComponent::Csv) => self.csv(&data).await,
119119
Some(HeaderComponent::Cookie) => self.add_cookie(&data).map(PageContext::Header),
120120
Some(HeaderComponent::Authentication) => self.authentication(data).await,
121+
Some(HeaderComponent::Download) => self.download(&data),
121122
None => self.start_body(data).await,
122123
}
123124
}
@@ -327,6 +328,38 @@ impl HeaderContext {
327328
Ok(PageContext::Close(http_response))
328329
}
329330

331+
fn download(mut self, options: &JsonValue) -> anyhow::Result<PageContext> {
332+
if let Some(filename) = get_object_str(options, "filename") {
333+
self.response.insert_header((
334+
header::CONTENT_DISPOSITION,
335+
format!("attachment; filename=\"{filename}\""),
336+
));
337+
}
338+
let data_url = get_object_str(options, "data_url")
339+
.with_context(|| "The download component requires a 'data_url' property")?;
340+
let rest = data_url
341+
.strip_prefix("data:")
342+
.with_context(|| "Invalid data URL: missing 'data:' prefix")?;
343+
let (mut content_type, data) = rest
344+
.split_once(',')
345+
.with_context(|| "Invalid data URL: missing comma")?;
346+
let mut body_bytes: Cow<[u8]> = percent_encoding::percent_decode(data.as_bytes()).into();
347+
if let Some(stripped) = content_type.strip_suffix(";base64") {
348+
content_type = stripped;
349+
body_bytes =
350+
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &body_bytes)
351+
.with_context(|| "Invalid base64 data in data URL")?
352+
.into();
353+
}
354+
if !content_type.is_empty() {
355+
self.response
356+
.insert_header((header::CONTENT_TYPE, content_type));
357+
}
358+
Ok(PageContext::Close(
359+
self.response.body(body_bytes.into_owned()),
360+
))
361+
}
362+
330363
async fn start_body(self, data: JsonValue) -> anyhow::Result<PageContext> {
331364
let html_renderer =
332365
HtmlRenderContext::new(self.app_state, self.request_context, self.writer, data)
@@ -1074,6 +1107,7 @@ enum HeaderComponent {
10741107
Csv,
10751108
Cookie,
10761109
Authentication,
1110+
Download,
10771111
}
10781112

10791113
impl TryFrom<&str> for HeaderComponent {
@@ -1087,6 +1121,7 @@ impl TryFrom<&str> for HeaderComponent {
10871121
"csv" => Ok(Self::Csv),
10881122
"cookie" => Ok(Self::Cookie),
10891123
"authentication" => Ok(Self::Authentication),
1124+
"download" => Ok(Self::Download),
10901125
_ => Err(()),
10911126
}
10921127
}

tests/requests/mod.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,26 @@ async fn test_request_body_base64() -> actix_web::Result<()> {
7777
Ok(())
7878
}
7979

80+
#[actix_web::test]
81+
async fn test_download_data_url() -> actix_web::Result<()> {
82+
let req = get_request_to("/tests/requests/request_download_test.sql")
83+
.await?
84+
.to_srv_request();
85+
let resp = main_handler(req).await?;
86+
87+
assert_eq!(resp.status(), StatusCode::OK);
88+
let ct = resp.headers().get("content-type").unwrap();
89+
assert_eq!(ct, "text/plain");
90+
let content_disposition = resp.headers().get("content-disposition").unwrap();
91+
assert_eq!(
92+
content_disposition,
93+
"attachment; filename=\"my text file.txt\""
94+
);
95+
let body = test::read_body(resp).await;
96+
assert_eq!(&body, &b"Hello download!"[..]);
97+
Ok(())
98+
}
99+
80100
#[actix_web::test]
81101
async fn test_large_form_field_roundtrip() -> actix_web::Result<()> {
82102
let long_string = "a".repeat(123454);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
select 'download' as component,
2+
'data:text/plain;base64,SGVsbG8gZG93bmxvYWQh' as data_url,
3+
'my text file.txt' as filename;
4+
5+
6+
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- "<!DOCTYPE html><body>It works !</body>" in base64
2+
select 'download' as component, 'data:text/html;base64,PCFET0NUWVBFIGh0bWw+PGJvZHk+SXQgd29ya3MgITwvYm9keT4K' as data_url;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- "<!DOCTYPE html><body>It works !</body>" percent encoded
2+
select 'download' as component, 'data:text/html,%3C!DOCTYPE%20html%3E%3Cbody%3EIt%20works%20!%3C%2Fbody%3E' as data_url;

0 commit comments

Comments
 (0)