sqlpage 0.42.0

Build data user interfaces entirely in SQL. A web server that takes .sql files and formats the query result using pre-made configurable professional-looking components.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
use crate::webserver::database::SupportedDatabase;
use crate::webserver::ErrorWithStatus;
use crate::webserver::{make_placeholder, Database};
use crate::{AppState, TEMPLATES_DIR};
use anyhow::Context;
use chrono::{DateTime, Utc};
use sqlx::any::{AnyStatement, AnyTypeInfo};
use sqlx::postgres::types::PgTimeTz;
use sqlx::{Postgres, Statement, Type};
use std::fmt::Write;
use std::io::ErrorKind;
use std::path::{Component, Path, PathBuf};

pub(crate) struct FileSystem {
    local_root: PathBuf,
    db_fs_queries: Option<DbFsQueries>,
}

impl FileSystem {
    pub async fn init(local_root: impl Into<PathBuf>, db: &Database) -> Self {
        Self {
            local_root: local_root.into(),
            db_fs_queries: match DbFsQueries::init(db).await {
                Ok(q) => Some(q),
                Err(e) => {
                    log::debug!(
                        "Using local filesystem only, could not initialize on-database filesystem. \
                        You can host sql files directly in your database by creating the following table: \n\
                        {} \n\
                        The error while trying to use the database file system is: {e:#}",
                        DbFsQueries::get_create_table_sql(db.info.database_type)
                    );
                    None
                }
            },
        }
    }

    pub async fn modified_since(
        &self,
        app_state: &AppState,
        path: &Path,
        since: DateTime<Utc>,
        priviledged: bool,
    ) -> anyhow::Result<bool> {
        let local_path = self.safe_local_path(app_state, path, priviledged)?;
        let local_result = file_modified_since_local(&local_path, since).await;
        log::trace!(
            "Local file {} modified since {since:?} ? {local_result:?}",
            local_path.display()
        );
        match (local_result, &self.db_fs_queries) {
            (Ok(modified), _) => Ok(modified),
            (Err(e), Some(db_fs)) if e.kind() == ErrorKind::NotFound => {
                // no local file, try the database
                db_fs
                    .file_modified_since_in_db(app_state, path, since)
                    .await
            }
            (Err(e), _) => Err(e).with_context(|| {
                format!("Unable to read local file metadata for {}", path.display())
            }),
        }
    }

    pub async fn read_to_string(
        &self,
        app_state: &AppState,
        path: &Path,
        priviledged: bool,
    ) -> anyhow::Result<String> {
        let bytes = self.read_file(app_state, path, priviledged).await?;
        String::from_utf8(bytes).map_err(|utf8_err| {
            let invalid_idx = utf8_err.utf8_error().valid_up_to();
            let bytes = utf8_err.into_bytes();
            let valid_prefix = String::from_utf8_lossy(&bytes[..invalid_idx]);
            let line_num = valid_prefix.lines().count();
            let mut bad_seq = valid_prefix.lines().last().unwrap_or_default().to_string();
            let bad_char_idx = bad_seq.len() + 1;
            for b in bytes[invalid_idx..].iter().take(8) {
                write!(&mut bad_seq, "\\x{b:02X}").unwrap();
            }

            let display_path = path.display();
            anyhow::format_err!(
                "SQLPage expects all sql files to be encoded in UTF-8. \n\
                In \"{display_path}\", around line {line_num} character {bad_char_idx}, the following invalid UTF-8 byte sequence was found: \n\
                \"{bad_seq}\". \n\
                Please convert the file to UTF-8.",
            )
        })
    }

    /**
     * Priviledged files are the ones that are in sqlpage's config directory.
     */
    pub async fn read_file(
        &self,
        app_state: &AppState,
        path: &Path,
        priviledged: bool,
    ) -> anyhow::Result<Vec<u8>> {
        let local_path = self.safe_local_path(app_state, path, priviledged)?;
        log::debug!(
            "Reading file {} from {}",
            path.display(),
            local_path.display()
        );
        let local_result = tokio::fs::read(&local_path).await;
        match (local_result, &self.db_fs_queries) {
            (Ok(f), _) => Ok(f),
            (Err(e), Some(db_fs)) if e.kind() == ErrorKind::NotFound => {
                // no local file, try the database
                db_fs.read_file(app_state, path.as_ref()).await
            }
            (Err(e), None) if e.kind() == ErrorKind::NotFound => Err(ErrorWithStatus {
                status: actix_web::http::StatusCode::NOT_FOUND,
            }
            .into()),
            (Err(e), _) => {
                Err(e).with_context(|| format!("Unable to read local file {}", path.display()))
            }
        }
    }

    fn safe_local_path(
        &self,
        app_state: &AppState,
        path: &Path,
        priviledged: bool,
    ) -> anyhow::Result<PathBuf> {
        if priviledged {
            // Templates requests are always made to the static TEMPLATES_DIR, because this is where they are stored in the database
            // but when serving them from the filesystem, we need to serve them from the `SQLPAGE_CONFIGURATION_DIRECTORY/templates` directory
            if let Ok(template_path) = path.strip_prefix(TEMPLATES_DIR) {
                let normalized = app_state
                    .config
                    .configuration_directory
                    .join("templates")
                    .join(template_path);
                log::trace!(
                    "Normalizing template path {} to {}",
                    path.display(),
                    normalized.display()
                );
                return Ok(normalized);
            }
        } else {
            for (i, component) in path.components().enumerate() {
                if let Component::Normal(c) = component {
                    if i == 0 && c.eq_ignore_ascii_case("sqlpage") {
                        return Err(ErrorWithStatus {
                            status: actix_web::http::StatusCode::FORBIDDEN,
                        })
                        .with_context(|| {
                            "The /sqlpage/ path prefix is reserved for internal use. It is not public."
                        });
                    }
                    if c.as_encoded_bytes().starts_with(b".") {
                        return Err(ErrorWithStatus {
                            status: actix_web::http::StatusCode::FORBIDDEN,
                        })
                        .with_context(|| "Directory traversal is not allowed");
                    }
                } else {
                    anyhow::bail!(
                        "Unsupported path: {}. Path component '{component:?}' is not allowed.",
                        path.display()
                    );
                }
            }
        }
        Ok(self.local_root.join(path))
    }

    pub(crate) async fn file_exists(
        &self,
        app_state: &AppState,
        path: &Path,
    ) -> anyhow::Result<bool> {
        let local_exists = match self.safe_local_path(app_state, path, false) {
            Ok(safe_path) => tokio::fs::try_exists(safe_path).await?,
            Err(e) => return Err(e),
        };

        // If not in local fs and we have db_fs, check database
        if !local_exists {
            log::debug!(
                "File {} not found in local filesystem, checking database",
                path.display()
            );
            if let Some(db_fs) = &self.db_fs_queries {
                return db_fs.file_exists(app_state, path).await;
            }
        }
        Ok(local_exists)
    }
}

async fn file_modified_since_local(path: &Path, since: DateTime<Utc>) -> tokio::io::Result<bool> {
    tokio::fs::metadata(path)
        .await
        .and_then(|m| m.modified())
        .map(|modified_at| DateTime::<Utc>::from(modified_at) > since)
}

pub struct DbFsQueries {
    was_modified: AnyStatement<'static>,
    read_file: AnyStatement<'static>,
    exists: AnyStatement<'static>,
}

impl DbFsQueries {
    #[must_use]
    pub fn get_create_table_sql(dbms: SupportedDatabase) -> &'static str {
        match dbms {
            SupportedDatabase::Mssql => "CREATE TABLE sqlpage_files(path NVARCHAR(255) NOT NULL PRIMARY KEY, contents VARBINARY(MAX), last_modified DATETIME2(3) NOT NULL DEFAULT CURRENT_TIMESTAMP);",
            SupportedDatabase::Postgres => "CREATE TABLE IF NOT EXISTS sqlpage_files(path VARCHAR(255) NOT NULL PRIMARY KEY, contents BYTEA, last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP);",
            SupportedDatabase::Snowflake => "CREATE TABLE IF NOT EXISTS sqlpage_files(path VARCHAR(255) NOT NULL PRIMARY KEY, contents VARBINARY, last_modified TIMESTAMP_TZ DEFAULT CONVERT_TIMEZONE('UTC', CURRENT_TIMESTAMP()));",
            _ => "CREATE TABLE IF NOT EXISTS sqlpage_files(path VARCHAR(255) NOT NULL PRIMARY KEY, contents BLOB, last_modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP);",
        }
    }

    async fn init(db: &Database) -> anyhow::Result<Self> {
        log::debug!("Initializing database filesystem queries");
        Ok(Self {
            was_modified: Self::make_was_modified_query(db).await?,
            read_file: Self::make_read_file_query(db).await?,
            exists: Self::make_exists_query(db).await?,
        })
    }

    async fn make_was_modified_query(db: &Database) -> anyhow::Result<AnyStatement<'static>> {
        let was_modified_query = format!(
            "SELECT 1 from sqlpage_files WHERE last_modified >= {} AND path = {}",
            make_placeholder(db.info.kind, 1),
            make_placeholder(db.info.kind, 2)
        );
        let param_types: &[AnyTypeInfo; 2] = &[
            PgTimeTz::type_info().into(),
            <str as Type<Postgres>>::type_info().into(),
        ];
        log::debug!("Preparing the database filesystem was_modified_query: {was_modified_query}");
        db.prepare_with(&was_modified_query, param_types).await
    }

    async fn make_read_file_query(db: &Database) -> anyhow::Result<AnyStatement<'static>> {
        let read_file_query = format!(
            "SELECT contents from sqlpage_files WHERE path = {}",
            make_placeholder(db.info.kind, 1),
        );
        let param_types: &[AnyTypeInfo; 1] = &[<str as Type<Postgres>>::type_info().into()];
        log::debug!("Preparing the database filesystem read_file_query: {read_file_query}");
        db.prepare_with(&read_file_query, param_types).await
    }

    async fn make_exists_query(db: &Database) -> anyhow::Result<AnyStatement<'static>> {
        let exists_query = format!(
            "SELECT 1 from sqlpage_files WHERE path = {}",
            make_placeholder(db.info.kind, 1),
        );
        let param_types: &[AnyTypeInfo; 1] = &[<str as Type<Postgres>>::type_info().into()];
        db.prepare_with(&exists_query, param_types).await
    }

    async fn file_modified_since_in_db(
        &self,
        app_state: &AppState,
        path: &Path,
        since: DateTime<Utc>,
    ) -> anyhow::Result<bool> {
        let query = self
            .was_modified
            .query_as::<(i32,)>()
            .bind(since)
            .bind(path.display().to_string());
        log::trace!(
            "Checking if file {} was modified since {} by executing query: \n\
            {}\n\
            with parameters: {:?}",
            path.display(),
            since,
            self.was_modified.sql(),
            (since, path)
        );
        let was_modified_i32 = query
            .fetch_optional(&app_state.db.connection)
            .await
            .with_context(|| {
                format!(
                    "Unable to check when {} was last modified in the database",
                    path.display()
                )
            })?;
        log::trace!(
            "DB File {} was modified result: {was_modified_i32:?}",
            path.display()
        );
        Ok(was_modified_i32 == Some((1,)))
    }

    async fn read_file(&self, app_state: &AppState, path: &Path) -> anyhow::Result<Vec<u8>> {
        log::debug!("Reading file {} from the database", path.display());
        self.read_file
            .query_as::<(Vec<u8>,)>()
            .bind(path.display().to_string())
            .fetch_optional(&app_state.db.connection)
            .await
            .map_err(anyhow::Error::from)
            .and_then(|modified| {
                if let Some((modified,)) = modified {
                    Ok(modified)
                } else {
                    Err(ErrorWithStatus {
                        status: actix_web::http::StatusCode::NOT_FOUND,
                    }
                    .into())
                }
            })
            .with_context(|| format!("Unable to read {} from the database", path.display()))
    }

    async fn file_exists(&self, app_state: &AppState, path: &Path) -> anyhow::Result<bool> {
        let query = self
            .exists
            .query_as::<(i32,)>()
            .bind(path.display().to_string());
        log::trace!(
            "Checking if file {} exists by executing query: \n\
            {}\n\
            with parameters: {:?}",
            path.display(),
            self.exists.sql(),
            (path,)
        );
        let result = query.fetch_optional(&app_state.db.connection).await;
        log::debug!("DB File exists result: {result:?}");
        result.map(|result| result.is_some()).with_context(|| {
            format!(
                "Unable to check if {} exists in the database",
                path.display()
            )
        })
    }
}

#[actix_web::test]
async fn test_sql_file_read_utf8() -> anyhow::Result<()> {
    use crate::app_config;
    use sqlx::Executor;
    let config = app_config::tests::test_config();
    let state = AppState::init(&config).await?;

    // Oracle has specific issues with implicit timestamp conversions and empty strings in this test setup
    // so we skip it for Oracle to avoid complex workarounds in the main codebase.
    if config.database_url.contains("Oracle") {
        log::warn!("Skipping test_sql_file_read_utf8 for Oracle due to date format/implicit conversion issues");
        return Ok(());
    }

    let create_table_sql = DbFsQueries::get_create_table_sql(state.db.info.database_type);
    let db = &state.db;
    let conn = &db.connection;
    conn.execute("DROP TABLE IF EXISTS sqlpage_files").await?;
    log::debug!("Creating table sqlpage_files: {create_table_sql}");
    conn.execute(create_table_sql).await?;

    let dbms = db.info.kind;
    let insert_sql = format!(
        "INSERT INTO sqlpage_files(path, contents) VALUES ({}, {})",
        make_placeholder(dbms, 1),
        make_placeholder(dbms, 2)
    );
    sqlx::query(&insert_sql)
        .bind("unit test file.txt")
        .bind("Héllö world! 😀".as_bytes())
        .execute(conn)
        .await?;

    let fs = FileSystem::init("/", db).await;
    let actual = fs
        .read_to_string(&state, "unit test file.txt".as_ref(), false)
        .await?;
    assert_eq!(actual, "Héllö world! 😀");

    let one_hour_ago = Utc::now() - chrono::Duration::hours(1);
    let one_hour_future = Utc::now() + chrono::Duration::hours(1);

    let was_modified = fs
        .modified_since(&state, "unit test file.txt".as_ref(), one_hour_ago, false)
        .await?;

    assert!(was_modified, "File should be modified since one hour ago");

    let was_modified = fs
        .modified_since(
            &state,
            "unit test file.txt".as_ref(),
            one_hour_future,
            false,
        )
        .await?;
    assert!(
        !was_modified,
        "File should not be modified since one hour in the future"
    );

    Ok(())
}