Skip to content

Commit b2f646a

Browse files
authored
Merge pull request #311 from djyotta/main
support site prefix for use with reverse proxy
2 parents b803368 + 73b3854 commit b2f646a

File tree

10 files changed

+157
-27
lines changed

10 files changed

+157
-27
lines changed

configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Here are the available configuration options and their default values:
1818
| `database_connection_acquire_timeout_seconds` | 10 | How long to wait when acquiring a database connection from the pool before giving up and returning an error. |
1919
| `sqlite_extensions` | | An array of SQLite extensions to load, such as `mod_spatialite` |
2020
| `web_root` | `.` | The root directory of the web server, where the `index.sql` file is located. |
21+
| `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. |
2122
| `configuration_directory` | `./sqlpage/` | The directory where the `sqlpage.json` file is located. This is used to find the path to `templates/`, `migrations/`, 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 |
2223
| `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. |
2324
| `max_uploaded_file_size` | 5242880 | Maximum size of uploaded files in bytes. Defaults to 5 MiB. |

sqlpage/templates/chart.handlebars

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{{#if id}}id="{{id}}"{{/if}}
33
class="card my-2 {{class}}"
44
data-pre-init="chart"
5-
data-sqlpage-js="/{{static_path 'apexcharts.js'}}"
5+
data-sqlpage-js="{{static_path 'apexcharts.js'}}"
66
>
77
<div class="card-body">
88
<div class="d-flex">

sqlpage/templates/form.handlebars

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
{{~#if multiple}} multiple {{/if~}}
6666
{{~#if (or dropdown searchable)}}
6767
data-pre-init="select-dropdown"
68-
data-sqlpage-js="/{{static_path 'tomselect.js'}}"
68+
data-sqlpage-js="{{static_path 'tomselect.js'}}"
6969
{{/if~}}
7070
{{~#if placeholder}} placeholder="{{placeholder}}" {{/if~}}
7171
{{~#if create_new}} data-create_new={{create_new}} {{/if~}}

sqlpage/templates/shell.handlebars

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
<head>
44
<meta charset="utf-8"/>
55
<title>{{default title "SQLPage"}}</title>
6-
7-
<link rel="stylesheet" href="/{{static_path 'sqlpage.css'}}">
6+
<link rel="stylesheet" href="{{static_path 'sqlpage.css'}}">
87
{{#each (to_array css)}}
98
{{#if this}}
109
<link rel="stylesheet" href="{{this}}">
@@ -18,7 +17,7 @@
1817
<style>:root { --tblr-font-sans-serif: '{{font}}', Arial, sans;}</style>
1918
{{/if}}
2019

21-
<script src="/{{static_path 'sqlpage.js'}}" defer></script>
20+
<script src="{{static_path 'sqlpage.js'}}" defer></script>
2221
{{#each (to_array javascript)}}
2322
{{#if this}}
2423
<script src="{{this}}" defer></script>

src/app_config.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use anyhow::Context;
22
use config::Config;
3+
use percent_encoding::AsciiSet;
34
use serde::de::Error;
45
use serde::{Deserialize, Deserializer};
56
use std::net::{SocketAddr, ToSocketAddrs};
@@ -77,6 +78,16 @@ pub struct AppConfig {
7778
/// whether to show error messages to the user.
7879
#[serde(default)]
7980
pub environment: DevOrProd,
81+
82+
/// Serve the website from a sub path. For example, if you set this to `/sqlpage/`, the website will be
83+
/// served from `https://yourdomain.com/sqlpage/`. Defaults to `/`.
84+
/// This is useful if you want to serve the website on the same domain as other content, and
85+
/// you are using a reverse proxy to route requests to the correct server.
86+
#[serde(
87+
deserialize_with = "deserialize_site_prefix",
88+
default = "default_site_prefix"
89+
)]
90+
pub site_prefix: String,
8091
}
8192

8293
impl AppConfig {
@@ -108,6 +119,8 @@ fn cannonicalize_if_possible(path: &std::path::Path) -> PathBuf {
108119
path.canonicalize().unwrap_or_else(|_| path.to_owned())
109120
}
110121

122+
/// Parses and loads the configuration from the `sqlpage.json` file in the current directory.
123+
/// This should be called only once at the start of the program.
111124
pub fn load() -> anyhow::Result<AppConfig> {
112125
let configuration_directory = &configuration_directory();
113126
log::debug!(
@@ -140,6 +153,47 @@ fn deserialize_socket_addr<'de, D: Deserializer<'de>>(
140153
.transpose()
141154
}
142155

156+
fn deserialize_site_prefix<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
157+
let prefix: String = Deserialize::deserialize(deserializer)?;
158+
Ok(normalize_site_prefix(prefix.as_str()))
159+
}
160+
161+
/// We standardize the site prefix to always be stored with both leading and trailing slashes.
162+
/// We also percent-encode special characters in the prefix, but allow it to contain slashes (to allow
163+
/// hosting on a sub-sub-path).
164+
fn normalize_site_prefix(prefix: &str) -> String {
165+
const TO_ENCODE: AsciiSet = percent_encoding::NON_ALPHANUMERIC.remove(b'/');
166+
167+
let prefix = prefix.trim_start_matches('/').trim_end_matches('/');
168+
if prefix.is_empty() {
169+
return default_site_prefix();
170+
}
171+
let encoded_prefix = percent_encoding::percent_encode(prefix.as_bytes(), &TO_ENCODE);
172+
173+
std::iter::once("/")
174+
.chain(encoded_prefix)
175+
.chain(std::iter::once("/"))
176+
.collect::<String>()
177+
}
178+
179+
#[test]
180+
fn test_normalize_site_prefix() {
181+
assert_eq!(normalize_site_prefix(""), "/");
182+
assert_eq!(normalize_site_prefix("/"), "/");
183+
assert_eq!(normalize_site_prefix("a"), "/a/");
184+
assert_eq!(normalize_site_prefix("a/"), "/a/");
185+
assert_eq!(normalize_site_prefix("/a"), "/a/");
186+
assert_eq!(normalize_site_prefix("a/b"), "/a/b/");
187+
assert_eq!(normalize_site_prefix("a/b/"), "/a/b/");
188+
assert_eq!(normalize_site_prefix("a/b/c"), "/a/b/c/");
189+
assert_eq!(normalize_site_prefix("a b"), "/a%20b/");
190+
assert_eq!(normalize_site_prefix("a b/c"), "/a%20b/c/");
191+
}
192+
193+
fn default_site_prefix() -> String {
194+
'/'.to_string()
195+
}
196+
143197
fn parse_socket_addr(host_str: &str) -> anyhow::Result<SocketAddr> {
144198
host_str
145199
.to_socket_addrs()?

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ impl AppState {
4040
pub async fn init(config: &AppConfig) -> anyhow::Result<Self> {
4141
// Connect to the database
4242
let db = Database::init(config).await?;
43-
let all_templates = AllTemplates::init()?;
43+
let all_templates = AllTemplates::init(config)?;
4444
let mut sql_file_cache = FileCache::new();
4545
let file_system = FileSystem::init(&config.web_root, &db).await;
4646
sql_file_cache.add_static(

src/template_helpers.rs

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
use std::borrow::Cow;
22

3+
use crate::{app_config::AppConfig, utils::static_filename};
34
use anyhow::Context as _;
45
use handlebars::{
56
handlebars_helper, Context, Handlebars, PathAndJson, RenderError, RenderErrorReason,
67
Renderable, ScopedJson,
78
};
89
use serde_json::Value as JsonValue;
910

10-
use crate::utils::static_filename;
11-
1211
/// Simple json to json helper
1312
type H = fn(&JsonValue) -> JsonValue;
1413
/// Simple json to json helper with error handling
1514
type EH = fn(&JsonValue) -> anyhow::Result<JsonValue>;
1615
/// Helper that takes two arguments
1716
type HH = fn(&JsonValue, &JsonValue) -> JsonValue;
1817

19-
pub fn register_all_helpers(h: &mut Handlebars<'_>) {
18+
pub fn register_all_helpers(h: &mut Handlebars<'_>, config: &AppConfig) {
2019
register_helper(h, "stringify", stringify_helper as H);
2120
register_helper(h, "parse_json", parse_json_helper as EH);
2221
register_helper(h, "default", default_helper as HH);
@@ -40,7 +39,10 @@ pub fn register_all_helpers(h: &mut Handlebars<'_>) {
4039
h.register_helper("array_contains", Box::new(array_contains));
4140

4241
// static_path helper: generate a path to a static file. Replaces sqpage.js by sqlpage.<hash>.js
43-
register_helper(h, "static_path", static_path_helper as EH);
42+
let static_path_helper = StaticPathHelper {
43+
site_prefix: config.site_prefix.clone(),
44+
};
45+
register_helper(h, "static_path", static_path_helper);
4446

4547
// icon helper: generate an image with the specified icon
4648
h.register_helper("icon_img", Box::new(icon_img_helper));
@@ -131,13 +133,27 @@ fn to_array_helper(v: &JsonValue) -> JsonValue {
131133
.into()
132134
}
133135

134-
fn static_path_helper(v: &JsonValue) -> anyhow::Result<JsonValue> {
135-
match v.as_str().with_context(|| "static_path: not a string")? {
136-
"sqlpage.js" => Ok(static_filename!("sqlpage.js").into()),
137-
"sqlpage.css" => Ok(static_filename!("sqlpage.css").into()),
138-
"apexcharts.js" => Ok(static_filename!("apexcharts.js").into()),
139-
"tomselect.js" => Ok(static_filename!("tomselect.js").into()),
140-
other => Err(anyhow::anyhow!("unknown static file: {other:?}")),
136+
struct StaticPathHelper {
137+
site_prefix: String,
138+
}
139+
140+
impl CanHelp for StaticPathHelper {
141+
fn call(&self, args: &[PathAndJson]) -> Result<JsonValue, String> {
142+
let static_file = match args {
143+
[v] => v.value(),
144+
_ => return Err("expected one argument".to_string()),
145+
};
146+
let name = static_file
147+
.as_str()
148+
.ok_or_else(|| format!("static_path: not a string: {static_file}"))?;
149+
let path = match name {
150+
"sqlpage.js" => static_filename!("sqlpage.js"),
151+
"sqlpage.css" => static_filename!("sqlpage.css"),
152+
"apexcharts.js" => static_filename!("apexcharts.js"),
153+
"tomselect.js" => static_filename!("tomselect.js"),
154+
other => return Err(format!("unknown static file: {other:?}")),
155+
};
156+
Ok(format!("{}{}", self.site_prefix, path).into())
141157
}
142158
}
143159

src/templates.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::app_config::AppConfig;
12
use crate::file_cache::AsyncFromStrWithState;
23
use crate::template_helpers::register_all_helpers;
34
use crate::{AppState, FileCache, TEMPLATES_DIR};
@@ -76,9 +77,9 @@ pub struct AllTemplates {
7677
const STATIC_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/sqlpage/templates");
7778

7879
impl AllTemplates {
79-
pub fn init() -> anyhow::Result<Self> {
80+
pub fn init(config: &AppConfig) -> anyhow::Result<Self> {
8081
let mut handlebars = Handlebars::new();
81-
register_all_helpers(&mut handlebars);
82+
register_all_helpers(&mut handlebars, config);
8283
let mut this = Self {
8384
handlebars,
8485
split_templates: FileCache::new(),

src/webserver/http.rs

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ impl SingleOrVec {
344344

345345
/// Resolves the path in a query to the path to a local SQL file if there is one that matches
346346
fn path_to_sql_file(path: &str) -> Option<PathBuf> {
347-
let mut path = PathBuf::from(path.strip_prefix('/').unwrap_or(path));
347+
let mut path = PathBuf::from(path);
348348
match path.extension() {
349349
None => {
350350
path.push("index.sql");
@@ -385,7 +385,7 @@ async fn serve_file(
385385
state: &AppState,
386386
if_modified_since: Option<IfModifiedSince>,
387387
) -> actix_web::Result<HttpResponse> {
388-
let path = path.strip_prefix('/').unwrap_or(path);
388+
let path = path.strip_prefix(&state.config.site_prefix).unwrap_or(path);
389389
if let Some(IfModifiedSince(date)) = if_modified_since {
390390
let since = DateTime::<Utc>::from(SystemTime::from(date));
391391
let modified = state
@@ -440,6 +440,10 @@ pub async fn main_handler(
440440
/// Extracts the path from a request and percent-decodes it
441441
fn req_path(req: &ServiceRequest) -> Cow<'_, str> {
442442
let encoded_path = req.path();
443+
let app_state: &web::Data<AppState> = req.app_data().expect("app_state");
444+
let encoded_path = encoded_path
445+
.strip_prefix(&app_state.config.site_prefix)
446+
.unwrap_or(encoded_path);
443447
percent_encoding::percent_decode_str(encoded_path).decode_utf8_lossy()
444448
}
445449

@@ -467,6 +471,24 @@ fn redirect_missing_trailing_slash(uri: &Uri) -> Option<HttpResponse> {
467471
}
468472
}
469473

474+
/// called when a request is made to a path outside of the sub-path we are serving the site from
475+
async fn default_prefix_redirect(
476+
service_request: ServiceRequest,
477+
) -> actix_web::Result<ServiceResponse> {
478+
let app_state: &web::Data<AppState> = service_request.app_data().expect("app_state");
479+
let redirect_path = app_state
480+
.config
481+
.site_prefix
482+
.trim_end_matches('/')
483+
.to_string()
484+
+ service_request.path();
485+
Ok(service_request.into_response(
486+
HttpResponse::PermanentRedirect()
487+
.insert_header((header::LOCATION, redirect_path))
488+
.finish(),
489+
))
490+
}
491+
470492
pub fn create_app(
471493
app_state: web::Data<AppState>,
472494
) -> App<
@@ -480,13 +502,20 @@ pub fn create_app(
480502
InitError = (),
481503
>,
482504
> {
505+
let encoded_scope: &str = app_state.config.site_prefix.trim_end_matches('/');
506+
let decoded_scope = percent_encoding::percent_decode_str(encoded_scope).decode_utf8_lossy();
483507
App::new()
484-
.service(static_content::js())
485-
.service(static_content::apexcharts_js())
486-
.service(static_content::tomselect_js())
487-
.service(static_content::css())
488-
.service(static_content::icons())
489-
.default_service(fn_service(main_handler))
508+
.service(
509+
web::scope(&decoded_scope)
510+
.service(static_content::js())
511+
.service(static_content::apexcharts_js())
512+
.service(static_content::tomselect_js())
513+
.service(static_content::css())
514+
.service(static_content::icons())
515+
.default_service(fn_service(main_handler)),
516+
)
517+
// when receiving a request outside of the prefix, redirect to the prefix
518+
.default_service(fn_service(default_prefix_redirect))
490519
.wrap(Logger::default())
491520
.wrap(
492521
middleware::DefaultHeaders::new()

tests/index.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,36 @@ async fn privileged_paths_are_not_accessible() {
292292
);
293293
}
294294

295+
#[actix_web::test]
296+
async fn test_static_files() {
297+
let resp = req_path("/tests/it_works.txt").await.unwrap();
298+
assert_eq!(resp.status(), http::StatusCode::OK);
299+
let body = test::read_body(resp).await;
300+
assert_eq!(&body, &b"It works !"[..]);
301+
}
302+
303+
#[actix_web::test]
304+
async fn test_with_site_prefix() {
305+
let mut config = test_config();
306+
config.site_prefix = "/xxx/".to_string();
307+
let state = AppState::init(&config).await.unwrap();
308+
let app_data = actix_web::web::Data::new(state);
309+
let resp = req_path_with_app_data("/xxx/tests/sql_test_files/it_works_simple.sql", app_data)
310+
.await
311+
.unwrap();
312+
assert_eq!(resp.status(), http::StatusCode::OK);
313+
let body = test::read_body(resp).await;
314+
let body_str = String::from_utf8(body.to_vec()).unwrap();
315+
assert!(
316+
body_str.contains("It works !"),
317+
"{body_str}\nexpected to contain: It works !"
318+
);
319+
assert!(
320+
body_str.contains("href=\"/xxx/"),
321+
"{body_str}\nexpected to contain stylesheet link with site prefix"
322+
);
323+
}
324+
295325
async fn get_request_to(path: &str) -> actix_web::Result<TestRequest> {
296326
let data = make_app_data().await;
297327
Ok(test::TestRequest::get()

0 commit comments

Comments
 (0)