Author: Manos Pitsidianakis [manos@pitsidianak.is]
Hash: 62b8e3b99036fe3bfb9e6741ac052313f385151f
Timestamp: Fri, 09 Jun 2023 13:34:23 +0000 (1 year ago)

+975 -64 +/-13 browse
core: add list_settings_json and settings_json_schema tables
1diff --git a/Cargo.lock b/Cargo.lock
2index 95f65b9..4d1ed8e 100644
3--- a/Cargo.lock
4+++ b/Cargo.lock
5 @@ -14,6 +14,19 @@ dependencies = [
6 ]
7
8 [[package]]
9+ name = "ahash"
10+ version = "0.8.3"
11+ source = "registry+https://github.com/rust-lang/crates.io-index"
12+ checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f"
13+ dependencies = [
14+ "cfg-if 1.0.0",
15+ "getrandom",
16+ "once_cell",
17+ "serde",
18+ "version_check",
19+ ]
20+
21+ [[package]]
22 name = "aho-corasick"
23 version = "1.0.1"
24 source = "registry+https://github.com/rust-lang/crates.io-index"
25 @@ -459,6 +472,21 @@ dependencies = [
26 ]
27
28 [[package]]
29+ name = "bit-set"
30+ version = "0.5.3"
31+ source = "registry+https://github.com/rust-lang/crates.io-index"
32+ checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
33+ dependencies = [
34+ "bit-vec",
35+ ]
36+
37+ [[package]]
38+ name = "bit-vec"
39+ version = "0.6.3"
40+ source = "registry+https://github.com/rust-lang/crates.io-index"
41+ checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
42+
43+ [[package]]
44 name = "bitflags"
45 version = "1.3.2"
46 source = "registry+https://github.com/rust-lang/crates.io-index"
47 @@ -633,6 +661,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
48 checksum = "9b1ce199063694f33ffb7dd4e0ee620741495c32833cde5aa08f02a0bf96f0c8"
49
50 [[package]]
51+ name = "bytecount"
52+ version = "0.6.3"
53+ source = "registry+https://github.com/rust-lang/crates.io-index"
54+ checksum = "2c676a478f63e9fa2dd5368a42f28bba0d6c560b775f38583c8bbaa7fcd67c9c"
55+
56+ [[package]]
57 name = "byteorder"
58 version = "1.4.3"
59 source = "registry+https://github.com/rust-lang/crates.io-index"
60 @@ -1169,6 +1203,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
61 checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
62
63 [[package]]
64+ name = "fancy-regex"
65+ version = "0.11.0"
66+ source = "registry+https://github.com/rust-lang/crates.io-index"
67+ checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
68+ dependencies = [
69+ "bit-set",
70+ "regex",
71+ ]
72+
73+ [[package]]
74 name = "fastrand"
75 version = "1.9.0"
76 source = "registry+https://github.com/rust-lang/crates.io-index"
77 @@ -1229,6 +1273,16 @@ dependencies = [
78 ]
79
80 [[package]]
81+ name = "fraction"
82+ version = "0.13.1"
83+ source = "registry+https://github.com/rust-lang/crates.io-index"
84+ checksum = "3027ae1df8d41b4bed2241c8fdad4acc1e7af60c8e17743534b545e77182d678"
85+ dependencies = [
86+ "lazy_static",
87+ "num",
88+ ]
89+
90+ [[package]]
91 name = "fsevent"
92 version = "0.4.0"
93 source = "registry+https://github.com/rust-lang/crates.io-index"
94 @@ -1384,8 +1438,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
95 checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4"
96 dependencies = [
97 "cfg-if 1.0.0",
98+ "js-sys",
99 "libc",
100 "wasi 0.11.0+wasi-snapshot-preview1",
101+ "wasm-bindgen",
102 ]
103
104 [[package]]
105 @@ -1432,7 +1488,7 @@ version = "0.12.3"
106 source = "registry+https://github.com/rust-lang/crates.io-index"
107 checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
108 dependencies = [
109- "ahash",
110+ "ahash 0.7.6",
111 ]
112
113 [[package]]
114 @@ -1720,6 +1776,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
115 checksum = "12b6ee2129af8d4fb011108c73d99a1b83a85977f23b82460c0ae2e25bb4b57f"
116
117 [[package]]
118+ name = "iso8601"
119+ version = "0.6.1"
120+ source = "registry+https://github.com/rust-lang/crates.io-index"
121+ checksum = "924e5d73ea28f59011fec52a0d12185d496a9b075d360657aed2a5707f701153"
122+ dependencies = [
123+ "nom",
124+ ]
125+
126+ [[package]]
127 name = "itertools"
128 version = "0.10.5"
129 source = "registry+https://github.com/rust-lang/crates.io-index"
130 @@ -1764,6 +1829,34 @@ dependencies = [
131 ]
132
133 [[package]]
134+ name = "jsonschema"
135+ version = "0.17.0"
136+ source = "registry+https://github.com/rust-lang/crates.io-index"
137+ checksum = "e48354c4c4f088714424ddf090de1ff84acc82b2f08c192d46d226ae2529a465"
138+ dependencies = [
139+ "ahash 0.8.3",
140+ "anyhow",
141+ "base64 0.21.0",
142+ "bytecount",
143+ "fancy-regex",
144+ "fraction",
145+ "getrandom",
146+ "iso8601",
147+ "itoa",
148+ "memchr",
149+ "num-cmp",
150+ "once_cell",
151+ "parking_lot",
152+ "percent-encoding",
153+ "regex",
154+ "serde",
155+ "serde_json",
156+ "time 0.3.21",
157+ "url",
158+ "uuid",
159+ ]
160+
161+ [[package]]
162 name = "kernel32-sys"
163 version = "0.2.2"
164 source = "registry+https://github.com/rust-lang/crates.io-index"
165 @@ -1922,6 +2015,7 @@ dependencies = [
166 "anyhow",
167 "chrono",
168 "error-chain",
169+ "jsonschema",
170 "log",
171 "mailpot-tests",
172 "melib",
173 @@ -2255,6 +2349,20 @@ dependencies = [
174 ]
175
176 [[package]]
177+ name = "num"
178+ version = "0.4.0"
179+ source = "registry+https://github.com/rust-lang/crates.io-index"
180+ checksum = "43db66d1170d347f9a065114077f7dccb00c1b9478c89384490a3425279a4606"
181+ dependencies = [
182+ "num-bigint",
183+ "num-complex",
184+ "num-integer",
185+ "num-iter",
186+ "num-rational",
187+ "num-traits",
188+ ]
189+
190+ [[package]]
191 name = "num-bigint"
192 version = "0.4.3"
193 source = "registry+https://github.com/rust-lang/crates.io-index"
194 @@ -2266,6 +2374,21 @@ dependencies = [
195 ]
196
197 [[package]]
198+ name = "num-cmp"
199+ version = "0.1.0"
200+ source = "registry+https://github.com/rust-lang/crates.io-index"
201+ checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
202+
203+ [[package]]
204+ name = "num-complex"
205+ version = "0.4.3"
206+ source = "registry+https://github.com/rust-lang/crates.io-index"
207+ checksum = "02e0d21255c828d6f128a1e41534206671e8c3ea0c62f32291e808dc82cff17d"
208+ dependencies = [
209+ "num-traits",
210+ ]
211+
212+ [[package]]
213 name = "num-integer"
214 version = "0.1.45"
215 source = "registry+https://github.com/rust-lang/crates.io-index"
216 @@ -2276,6 +2399,29 @@ dependencies = [
217 ]
218
219 [[package]]
220+ name = "num-iter"
221+ version = "0.1.43"
222+ source = "registry+https://github.com/rust-lang/crates.io-index"
223+ checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252"
224+ dependencies = [
225+ "autocfg",
226+ "num-integer",
227+ "num-traits",
228+ ]
229+
230+ [[package]]
231+ name = "num-rational"
232+ version = "0.4.1"
233+ source = "registry+https://github.com/rust-lang/crates.io-index"
234+ checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0"
235+ dependencies = [
236+ "autocfg",
237+ "num-bigint",
238+ "num-integer",
239+ "num-traits",
240+ ]
241+
242+ [[package]]
243 name = "num-traits"
244 version = "0.2.15"
245 source = "registry+https://github.com/rust-lang/crates.io-index"
246 diff --git a/Makefile b/Makefile
247index d8135c9..b58141c 100644
248--- a/Makefile
249+++ b/Makefile
250 @@ -1,31 +1,38 @@
251 .POSIX:
252 .SUFFIXES:
253+ CARGOBIN = cargo
254+ CARGOSORTBIN = cargo-sort
255+ DJHTMLBIN = djhtml
256+ BLACKBIN = black
257+ PRINTF = /usr/bin/printf
258
259- HTML_FILES := $(shell find web/src/templates -type f -print0 | tr '\0' ' ')
260+ HTML_FILES := $(shell find web/src/templates -type f -print0 | tr '\0' ' ')
261+ PY_FILES := $(shell find . -type f -name '*.py' -print0 | tr '\0' ' ')
262
263 .PHONY: check
264 check:
265- @cargo check --all-features --all --tests --examples --benches --bins
266+ @$(CARGOBIN) check --all-features --all --tests --examples --benches --bins
267
268 .PHONY: fmt
269 fmt:
270- @cargo +nightly fmt --all || cargo fmt --all
271- @cargo sort -w || printf "cargo-sort binary not found in PATH.\n"
272- @djhtml $(HTML_FILES) || printf "djhtml binary not found in PATH.\n"
273+ @$(CARGOBIN) +nightly fmt --all || $(CARGOBIN) fmt --all
274+ @OUT=$$($(CARGOSORTBIN) -w 2>&1) || $(PRINTF) "ERROR: %s cargo-sort failed or binary not found in PATH.\n" "$$OUT"
275+ @OUT=$$($(DJHTMLBIN) $(HTML_FILES) 2>&1) || $(PRINTF) "ERROR: %s djhtml failed or binary not found in PATH.\n" "$$OUT"
276+ @OUT=$$($(BLACKBIN) -q $(PY_FILES) 2>&1) || $(PRINTF) "ERROR: %s black failed or binary not found in PATH.\n" "$$OUT"
277
278 .PHONY: lint
279 lint:
280- @cargo clippy --no-deps --all-features --all --tests --examples --benches --bins
281+ @$(CARGOBIN) clippy --no-deps --all-features --all --tests --examples --benches --bins
282
283
284 .PHONY: test
285 test: check lint
286- @cargo test --all --no-fail-fast --all-features
287+ @$(CARGOBIN) test --all --no-fail-fast --all-features
288
289 .PHONY: rustdoc
290 rustdoc:
291- @RUSTDOCFLAGS="--html-before-content ./.github/doc_extra.html" cargo doc --workspace --all-features --no-deps --document-private-items
292+ @RUSTDOCFLAGS="--html-before-content ./.github/doc_extra.html" $(CARGOBIN) doc --workspace --all-features --no-deps --document-private-items
293
294 .PHONY: rustdoc-open
295 rustdoc-open:
296- @RUSTDOCFLAGS="--html-before-content ./.github/doc_extra.html" cargo doc --workspace --all-features --no-deps --document-private-items --open
297+ @RUSTDOCFLAGS="--html-before-content ./.github/doc_extra.html" $(CARGOBIN) doc --workspace --all-features --no-deps --document-private-items --open
298 diff --git a/core/Cargo.toml b/core/Cargo.toml
299index ba0c794..92bd33d 100644
300--- a/core/Cargo.toml
301+++ b/core/Cargo.toml
302 @@ -14,10 +14,11 @@ categories = ["email"]
303 anyhow = "1.0.58"
304 chrono = { version = "^0.4", features = ["serde", ] }
305 error-chain = { version = "0.12.4", default-features = false }
306+ jsonschema = { version = "0.17", default-features = false }
307 log = "0.4"
308 melib = { version = "*", default-features = false, features = ["smtp", "unicode_algorithms", "maildir_backend"], git = "https://github.com/meli/meli", rev = "2447a2c" }
309 minijinja = { version = "0.31.0", features = ["source", ] }
310- rusqlite = { version = "^0.28", features = ["bundled", "trace", "hooks", "serde_json", "array", "chrono", "unlock_notify"] }
311+ rusqlite = { version = "^0.28", features = ["bundled", "functions", "trace", "hooks", "serde_json", "array", "chrono", "unlock_notify"] }
312 serde = { version = "^1", features = ["derive", ] }
313 serde_json = "^1"
314 toml = "^0.5"
315 diff --git a/core/migrations/004.sql b/core/migrations/004.sql
316new file mode 100644
317index 0000000..95aff47
318--- /dev/null
319+++ b/core/migrations/004.sql
320 @@ -0,0 +1,167 @@
321+ CREATE TABLE IF NOT EXISTS settings_json_schema (
322+ pk INTEGER PRIMARY KEY NOT NULL,
323+ id TEXT NOT NULL UNIQUE,
324+ value JSON NOT NULL CHECK (json_type(value) = 'object'),
325+ created INTEGER NOT NULL DEFAULT (unixepoch()),
326+ last_modified INTEGER NOT NULL DEFAULT (unixepoch())
327+ );
328+
329+ CREATE TABLE IF NOT EXISTS list_settings_json (
330+ pk INTEGER PRIMARY KEY NOT NULL,
331+ name TEXT NOT NULL,
332+ list INTEGER,
333+ value JSON NOT NULL CHECK (json_type(value) = 'object'),
334+ is_valid BOOLEAN CHECK (is_valid IN (0, 1)) NOT NULL DEFAULT 0, -- BOOLEAN_FALSE-> 0, BOOLEAN_TRUE-> 1
335+ created INTEGER NOT NULL DEFAULT (unixepoch()),
336+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
337+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
338+ FOREIGN KEY (name) REFERENCES settings_json_schema(id) ON DELETE CASCADE,
339+ UNIQUE (list, name) ON CONFLICT ROLLBACK
340+ );
341+
342+ CREATE TRIGGER
343+ IF NOT EXISTS is_valid_settings_json_on_update
344+ AFTER UPDATE OF value, name, is_valid ON list_settings_json
345+ FOR EACH ROW
346+ BEGIN
347+ SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
348+ UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
349+ END;
350+
351+ CREATE TRIGGER
352+ IF NOT EXISTS is_valid_settings_json_on_insert
353+ AFTER INSERT ON list_settings_json
354+ FOR EACH ROW
355+ BEGIN
356+ SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
357+ UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
358+ END;
359+
360+ CREATE TRIGGER
361+ IF NOT EXISTS invalidate_settings_json_on_schema_update
362+ AFTER UPDATE OF value, id ON settings_json_schema
363+ FOR EACH ROW
364+ BEGIN
365+ UPDATE list_settings_json SET name = NEW.id, is_valid = 0 WHERE name = OLD.id;
366+ END;
367+
368+ DROP TRIGGER IF EXISTS last_modified_list;
369+ DROP TRIGGER IF EXISTS last_modified_owner;
370+ DROP TRIGGER IF EXISTS last_modified_post_policy;
371+ DROP TRIGGER IF EXISTS last_modified_subscription_policy;
372+ DROP TRIGGER IF EXISTS last_modified_subscription;
373+ DROP TRIGGER IF EXISTS last_modified_account;
374+ DROP TRIGGER IF EXISTS last_modified_candidate_subscription;
375+ DROP TRIGGER IF EXISTS last_modified_template;
376+ DROP TRIGGER IF EXISTS last_modified_settings_json_schema;
377+ DROP TRIGGER IF EXISTS last_modified_list_settings_json;
378+
379+ -- [tag:last_modified_list]: update last_modified on every change.
380+ CREATE TRIGGER
381+ IF NOT EXISTS last_modified_list
382+ AFTER UPDATE ON list
383+ FOR EACH ROW
384+ WHEN NEW.last_modified == OLD.last_modified
385+ BEGIN
386+ UPDATE list SET last_modified = unixepoch()
387+ WHERE pk = NEW.pk;
388+ END;
389+
390+ -- [tag:last_modified_owner]: update last_modified on every change.
391+ CREATE TRIGGER
392+ IF NOT EXISTS last_modified_owner
393+ AFTER UPDATE ON owner
394+ FOR EACH ROW
395+ WHEN NEW.last_modified == OLD.last_modified
396+ BEGIN
397+ UPDATE owner SET last_modified = unixepoch()
398+ WHERE pk = NEW.pk;
399+ END;
400+
401+ -- [tag:last_modified_post_policy]: update last_modified on every change.
402+ CREATE TRIGGER
403+ IF NOT EXISTS last_modified_post_policy
404+ AFTER UPDATE ON post_policy
405+ FOR EACH ROW
406+ WHEN NEW.last_modified == OLD.last_modified
407+ BEGIN
408+ UPDATE post_policy SET last_modified = unixepoch()
409+ WHERE pk = NEW.pk;
410+ END;
411+
412+ -- [tag:last_modified_subscription_policy]: update last_modified on every change.
413+ CREATE TRIGGER
414+ IF NOT EXISTS last_modified_subscription_policy
415+ AFTER UPDATE ON subscription_policy
416+ FOR EACH ROW
417+ WHEN NEW.last_modified == OLD.last_modified
418+ BEGIN
419+ UPDATE subscription_policy SET last_modified = unixepoch()
420+ WHERE pk = NEW.pk;
421+ END;
422+
423+ -- [tag:last_modified_subscription]: update last_modified on every change.
424+ CREATE TRIGGER
425+ IF NOT EXISTS last_modified_subscription
426+ AFTER UPDATE ON subscription
427+ FOR EACH ROW
428+ WHEN NEW.last_modified == OLD.last_modified
429+ BEGIN
430+ UPDATE subscription SET last_modified = unixepoch()
431+ WHERE pk = NEW.pk;
432+ END;
433+
434+ -- [tag:last_modified_account]: update last_modified on every change.
435+ CREATE TRIGGER
436+ IF NOT EXISTS last_modified_account
437+ AFTER UPDATE ON account
438+ FOR EACH ROW
439+ WHEN NEW.last_modified == OLD.last_modified
440+ BEGIN
441+ UPDATE account SET last_modified = unixepoch()
442+ WHERE pk = NEW.pk;
443+ END;
444+
445+ -- [tag:last_modified_candidate_subscription]: update last_modified on every change.
446+ CREATE TRIGGER
447+ IF NOT EXISTS last_modified_candidate_subscription
448+ AFTER UPDATE ON candidate_subscription
449+ FOR EACH ROW
450+ WHEN NEW.last_modified == OLD.last_modified
451+ BEGIN
452+ UPDATE candidate_subscription SET last_modified = unixepoch()
453+ WHERE pk = NEW.pk;
454+ END;
455+
456+ -- [tag:last_modified_template]: update last_modified on every change.
457+ CREATE TRIGGER
458+ IF NOT EXISTS last_modified_template
459+ AFTER UPDATE ON template
460+ FOR EACH ROW
461+ WHEN NEW.last_modified == OLD.last_modified
462+ BEGIN
463+ UPDATE template SET last_modified = unixepoch()
464+ WHERE pk = NEW.pk;
465+ END;
466+
467+ -- [tag:last_modified_settings_json_schema]: update last_modified on every change.
468+ CREATE TRIGGER
469+ IF NOT EXISTS last_modified_settings_json_schema
470+ AFTER UPDATE ON settings_json_schema
471+ FOR EACH ROW
472+ WHEN NEW.last_modified == OLD.last_modified
473+ BEGIN
474+ UPDATE settings_json_schema SET last_modified = unixepoch()
475+ WHERE pk = NEW.pk;
476+ END;
477+
478+ -- [tag:last_modified_list_settings_json]: update last_modified on every change.
479+ CREATE TRIGGER
480+ IF NOT EXISTS last_modified_list_settings_json
481+ AFTER UPDATE ON list_settings_json
482+ FOR EACH ROW
483+ WHEN NEW.last_modified == OLD.last_modified
484+ BEGIN
485+ UPDATE list_settings_json SET last_modified = unixepoch()
486+ WHERE pk = NEW.pk;
487+ END;
488 diff --git a/core/migrations/004.undo.sql b/core/migrations/004.undo.sql
489new file mode 100644
490index 0000000..b780b5c
491--- /dev/null
492+++ b/core/migrations/004.undo.sql
493 @@ -0,0 +1,2 @@
494+ DROP TABLE settings_json_schema;
495+ DROP TABLE list_settings_json;
496 diff --git a/core/settings_json_schemas/archivedatlink.json b/core/settings_json_schemas/archivedatlink.json
497new file mode 100644
498index 0000000..2b832fe
499--- /dev/null
500+++ b/core/settings_json_schemas/archivedatlink.json
501 @@ -0,0 +1,31 @@
502+ {
503+ "$schema": "http://json-schema.org/draft-07/schema",
504+ "$ref": "#/$defs/ArchivedAtLinkSettings",
505+ "$defs": {
506+ "ArchivedAtLinkSettings": {
507+ "title": "ArchivedAtLinkSettings",
508+ "description": "Settings for ArchivedAtLink message filter",
509+ "type": "object",
510+ "properties": {
511+ "template": {
512+ "title": "Jinja template for header value",
513+ "description": "Template for `Archived-At` header value, as described in RFC 5064 \"The Archived-At Message Header Field\". The template receives only one string variable with the value of the mailing list post `Message-ID` header.\n\nFor example, if:\n\n- the template is `http://www.example.com/mid/{{msg_id}}`\n- the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`\n\nThe full header will be generated as:\n\n`Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>\n\nNote: Surrounding carets in the `Message-ID` value are not required. If you wish to preserve them in the URL, set option `preserve-carets` to true.",
514+ "examples": [
515+ "https://www.example.com/{{msg_id}}",
516+ "https://www.example.com/{{msg_id}}.html"
517+ ],
518+ "type": "string",
519+ "pattern": ".+[{][{]msg_id[}][}].*"
520+ },
521+ "preserve_carets": {
522+ "title": "Preserve carets of `Message-ID` in generated value",
523+ "type": "boolean",
524+ "default": false
525+ }
526+ },
527+ "required": [
528+ "template"
529+ ]
530+ }
531+ }
532+ }
533 diff --git a/core/src/connection.rs b/core/src/connection.rs
534index e6d6907..f875fb0 100644
535--- a/core/src/connection.rs
536+++ b/core/src/connection.rs
537 @@ -24,8 +24,9 @@ use std::{
538 process::{Command, Stdio},
539 };
540
541+ use jsonschema::JSONSchema;
542 use log::{info, trace};
543- use rusqlite::{Connection as DbConnection, OptionalExtension};
544+ use rusqlite::{functions::FunctionFlags, Connection as DbConnection, OptionalExtension};
545
546 use crate::{
547 config::Configuration,
548 @@ -65,9 +66,9 @@ impl Drop for Connection {
549
550 fn log_callback(error_code: std::ffi::c_int, message: &str) {
551 match error_code {
552+ rusqlite::ffi::SQLITE_NOTICE => log::trace!("{}", message),
553 rusqlite::ffi::SQLITE_OK
554 | rusqlite::ffi::SQLITE_DONE
555- | rusqlite::ffi::SQLITE_NOTICE
556 | rusqlite::ffi::SQLITE_NOTICE_RECOVER_WAL
557 | rusqlite::ffi::SQLITE_NOTICE_RECOVER_ROLLBACK => log::info!("{}", message),
558 rusqlite::ffi::SQLITE_WARNING | rusqlite::ffi::SQLITE_WARNING_AUTOINDEX => {
559 @@ -198,6 +199,44 @@ impl Connection {
560 conn.set_db_config(DbConfig::SQLITE_DBCONFIG_TRUSTED_SCHEMA, true)?;
561 conn.busy_timeout(core::time::Duration::from_millis(500))?;
562 conn.busy_handler(Some(|times: i32| -> bool { times < 5 }))?;
563+ conn.create_scalar_function(
564+ "validate_json_schema",
565+ 2,
566+ FunctionFlags::SQLITE_INNOCUOUS
567+ | FunctionFlags::SQLITE_UTF8
568+ | FunctionFlags::SQLITE_DETERMINISTIC,
569+ |ctx| {
570+ if log::log_enabled!(log::Level::Trace) {
571+ rusqlite::trace::log(
572+ rusqlite::ffi::SQLITE_NOTICE,
573+ "validate_json_schema RUNNING",
574+ );
575+ }
576+ let map_err = rusqlite::Error::UserFunctionError;
577+ let schema = ctx.get::<String>(0)?;
578+ let value = ctx.get::<String>(1)?;
579+ let schema_val: serde_json::Value = serde_json::from_str(&schema)
580+ .map_err(Into::into)
581+ .map_err(map_err)?;
582+ let value: serde_json::Value = serde_json::from_str(&value)
583+ .map_err(Into::into)
584+ .map_err(map_err)?;
585+ let compiled = JSONSchema::compile(&schema_val)
586+ .map_err(|err| err.to_string())
587+ .map_err(Into::into)
588+ .map_err(map_err)?;
589+ let x = if let Err(errors) = compiled.validate(&value) {
590+ for err in errors {
591+ rusqlite::trace::log(rusqlite::ffi::SQLITE_WARNING, &err.to_string());
592+ drop(err);
593+ }
594+ Ok(false)
595+ } else {
596+ Ok(true)
597+ };
598+ x
599+ },
600+ )?;
601
602 let ret = Self {
603 conf,
604 diff --git a/core/src/migrations.rs.inc b/core/src/migrations.rs.inc
605index cca184e..c70fe37 100644
606--- a/core/src/migrations.rs.inc
607+++ b/core/src/migrations.rs.inc
608 @@ -32,4 +32,173 @@ END;
609
610 DROP TRIGGER sort_topics_update_trigger;
611 DROP TRIGGER sort_topics_new_trigger;
612+ "),(4,"CREATE TABLE IF NOT EXISTS settings_json_schema (
613+ pk INTEGER PRIMARY KEY NOT NULL,
614+ id TEXT NOT NULL UNIQUE,
615+ value JSON NOT NULL CHECK (json_type(value) = 'object'),
616+ created INTEGER NOT NULL DEFAULT (unixepoch()),
617+ last_modified INTEGER NOT NULL DEFAULT (unixepoch())
618+ );
619+
620+ CREATE TABLE IF NOT EXISTS list_settings_json (
621+ pk INTEGER PRIMARY KEY NOT NULL,
622+ name TEXT NOT NULL,
623+ list INTEGER,
624+ value JSON NOT NULL CHECK (json_type(value) = 'object'),
625+ is_valid BOOLEAN CHECK (is_valid IN (0, 1)) NOT NULL DEFAULT 0, -- BOOLEAN_FALSE-> 0, BOOLEAN_TRUE-> 1
626+ created INTEGER NOT NULL DEFAULT (unixepoch()),
627+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
628+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
629+ FOREIGN KEY (name) REFERENCES settings_json_schema(id) ON DELETE CASCADE,
630+ UNIQUE (list, name) ON CONFLICT ROLLBACK
631+ );
632+
633+ CREATE TRIGGER
634+ IF NOT EXISTS is_valid_settings_json_on_update
635+ AFTER UPDATE OF value, name, is_valid ON list_settings_json
636+ FOR EACH ROW
637+ BEGIN
638+ SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
639+ UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
640+ END;
641+
642+ CREATE TRIGGER
643+ IF NOT EXISTS is_valid_settings_json_on_insert
644+ AFTER INSERT ON list_settings_json
645+ FOR EACH ROW
646+ BEGIN
647+ SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
648+ UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
649+ END;
650+
651+ CREATE TRIGGER
652+ IF NOT EXISTS invalidate_settings_json_on_schema_update
653+ AFTER UPDATE OF value, id ON settings_json_schema
654+ FOR EACH ROW
655+ BEGIN
656+ UPDATE list_settings_json SET name = NEW.id, is_valid = 0 WHERE name = OLD.id;
657+ END;
658+
659+ DROP TRIGGER IF EXISTS last_modified_list;
660+ DROP TRIGGER IF EXISTS last_modified_owner;
661+ DROP TRIGGER IF EXISTS last_modified_post_policy;
662+ DROP TRIGGER IF EXISTS last_modified_subscription_policy;
663+ DROP TRIGGER IF EXISTS last_modified_subscription;
664+ DROP TRIGGER IF EXISTS last_modified_account;
665+ DROP TRIGGER IF EXISTS last_modified_candidate_subscription;
666+ DROP TRIGGER IF EXISTS last_modified_template;
667+ DROP TRIGGER IF EXISTS last_modified_settings_json_schema;
668+ DROP TRIGGER IF EXISTS last_modified_list_settings_json;
669+
670+ -- [tag:last_modified_list]: update last_modified on every change.
671+ CREATE TRIGGER
672+ IF NOT EXISTS last_modified_list
673+ AFTER UPDATE ON list
674+ FOR EACH ROW
675+ WHEN NEW.last_modified == OLD.last_modified
676+ BEGIN
677+ UPDATE list SET last_modified = unixepoch()
678+ WHERE pk = NEW.pk;
679+ END;
680+
681+ -- [tag:last_modified_owner]: update last_modified on every change.
682+ CREATE TRIGGER
683+ IF NOT EXISTS last_modified_owner
684+ AFTER UPDATE ON owner
685+ FOR EACH ROW
686+ WHEN NEW.last_modified == OLD.last_modified
687+ BEGIN
688+ UPDATE owner SET last_modified = unixepoch()
689+ WHERE pk = NEW.pk;
690+ END;
691+
692+ -- [tag:last_modified_post_policy]: update last_modified on every change.
693+ CREATE TRIGGER
694+ IF NOT EXISTS last_modified_post_policy
695+ AFTER UPDATE ON post_policy
696+ FOR EACH ROW
697+ WHEN NEW.last_modified == OLD.last_modified
698+ BEGIN
699+ UPDATE post_policy SET last_modified = unixepoch()
700+ WHERE pk = NEW.pk;
701+ END;
702+
703+ -- [tag:last_modified_subscription_policy]: update last_modified on every change.
704+ CREATE TRIGGER
705+ IF NOT EXISTS last_modified_subscription_policy
706+ AFTER UPDATE ON subscription_policy
707+ FOR EACH ROW
708+ WHEN NEW.last_modified == OLD.last_modified
709+ BEGIN
710+ UPDATE subscription_policy SET last_modified = unixepoch()
711+ WHERE pk = NEW.pk;
712+ END;
713+
714+ -- [tag:last_modified_subscription]: update last_modified on every change.
715+ CREATE TRIGGER
716+ IF NOT EXISTS last_modified_subscription
717+ AFTER UPDATE ON subscription
718+ FOR EACH ROW
719+ WHEN NEW.last_modified == OLD.last_modified
720+ BEGIN
721+ UPDATE subscription SET last_modified = unixepoch()
722+ WHERE pk = NEW.pk;
723+ END;
724+
725+ -- [tag:last_modified_account]: update last_modified on every change.
726+ CREATE TRIGGER
727+ IF NOT EXISTS last_modified_account
728+ AFTER UPDATE ON account
729+ FOR EACH ROW
730+ WHEN NEW.last_modified == OLD.last_modified
731+ BEGIN
732+ UPDATE account SET last_modified = unixepoch()
733+ WHERE pk = NEW.pk;
734+ END;
735+
736+ -- [tag:last_modified_candidate_subscription]: update last_modified on every change.
737+ CREATE TRIGGER
738+ IF NOT EXISTS last_modified_candidate_subscription
739+ AFTER UPDATE ON candidate_subscription
740+ FOR EACH ROW
741+ WHEN NEW.last_modified == OLD.last_modified
742+ BEGIN
743+ UPDATE candidate_subscription SET last_modified = unixepoch()
744+ WHERE pk = NEW.pk;
745+ END;
746+
747+ -- [tag:last_modified_template]: update last_modified on every change.
748+ CREATE TRIGGER
749+ IF NOT EXISTS last_modified_template
750+ AFTER UPDATE ON template
751+ FOR EACH ROW
752+ WHEN NEW.last_modified == OLD.last_modified
753+ BEGIN
754+ UPDATE template SET last_modified = unixepoch()
755+ WHERE pk = NEW.pk;
756+ END;
757+
758+ -- [tag:last_modified_settings_json_schema]: update last_modified on every change.
759+ CREATE TRIGGER
760+ IF NOT EXISTS last_modified_settings_json_schema
761+ AFTER UPDATE ON settings_json_schema
762+ FOR EACH ROW
763+ WHEN NEW.last_modified == OLD.last_modified
764+ BEGIN
765+ UPDATE settings_json_schema SET last_modified = unixepoch()
766+ WHERE pk = NEW.pk;
767+ END;
768+
769+ -- [tag:last_modified_list_settings_json]: update last_modified on every change.
770+ CREATE TRIGGER
771+ IF NOT EXISTS last_modified_list_settings_json
772+ AFTER UPDATE ON list_settings_json
773+ FOR EACH ROW
774+ WHEN NEW.last_modified == OLD.last_modified
775+ BEGIN
776+ UPDATE list_settings_json SET last_modified = unixepoch()
777+ WHERE pk = NEW.pk;
778+ END;
779+ ","DROP TABLE settings_json_schema;
780+ DROP TABLE list_settings_json;
781 "),]
782\ No newline at end of file
783 diff --git a/core/src/schema.sql b/core/src/schema.sql
784index 4db9f82..a983117 100644
785--- a/core/src/schema.sql
786+++ b/core/src/schema.sql
787 @@ -13,7 +13,7 @@ CREATE TABLE IF NOT EXISTS list (
788 topics JSON NOT NULL CHECK (json_type(topics) = 'array') DEFAULT '[]',
789 created INTEGER NOT NULL DEFAULT (unixepoch()),
790 last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
791- verify BOOLEAN CHECK (verify IN (0, 1)) NOT NULL DEFAULT 1,
792+ verify BOOLEAN CHECK (verify IN (0, 1)) NOT NULL DEFAULT 1, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
793 hidden BOOLEAN CHECK (hidden IN (0, 1)) NOT NULL DEFAULT 0,
794 enabled BOOLEAN CHECK (enabled IN (0, 1)) NOT NULL DEFAULT 1
795 );
796 @@ -32,7 +32,7 @@ CREATE TABLE IF NOT EXISTS post_policy (
797 pk INTEGER PRIMARY KEY NOT NULL,
798 list INTEGER NOT NULL UNIQUE,
799 announce_only BOOLEAN CHECK (announce_only IN (0, 1)) NOT NULL
800- DEFAULT 0,
801+ DEFAULT 0, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
802 subscription_only BOOLEAN CHECK (subscription_only IN (0, 1)) NOT NULL
803 DEFAULT 0,
804 approval_needed BOOLEAN CHECK (approval_needed IN (0, 1)) NOT NULL
805 @@ -139,7 +139,7 @@ CREATE TABLE IF NOT EXISTS subscription_policy (
806 pk INTEGER PRIMARY KEY NOT NULL,
807 list INTEGER NOT NULL UNIQUE,
808 send_confirmation BOOLEAN CHECK (send_confirmation IN (0, 1)) NOT NULL
809- DEFAULT 1,
810+ DEFAULT 1, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
811 open BOOLEAN CHECK (open IN (0, 1)) NOT NULL DEFAULT 0,
812 manual BOOLEAN CHECK (manual IN (0, 1)) NOT NULL DEFAULT 0,
813 request BOOLEAN CHECK (request IN (0, 1)) NOT NULL DEFAULT 0,
814 @@ -199,7 +199,7 @@ CREATE TABLE IF NOT EXISTS subscription (
815 name TEXT,
816 account INTEGER,
817 enabled BOOLEAN CHECK (enabled IN (0, 1)) NOT NULL
818- DEFAULT 1,
819+ DEFAULT 1, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
820 verified BOOLEAN CHECK (verified IN (0, 1)) NOT NULL
821 DEFAULT 1,
822 digest BOOLEAN CHECK (digest IN (0, 1)) NOT NULL
823 @@ -226,7 +226,7 @@ CREATE TABLE IF NOT EXISTS account (
824 address TEXT NOT NULL UNIQUE,
825 public_key TEXT,
826 password TEXT NOT NULL,
827- enabled BOOLEAN CHECK (enabled IN (0, 1)) NOT NULL DEFAULT 1,
828+ enabled BOOLEAN CHECK (enabled IN (0, 1)) NOT NULL DEFAULT 1, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
829 created INTEGER NOT NULL DEFAULT (unixepoch()),
830 last_modified INTEGER NOT NULL DEFAULT (unixepoch())
831 );
832 @@ -270,6 +270,53 @@ CREATE TABLE IF NOT EXISTS template (
833 UNIQUE (list, name) ON CONFLICT ROLLBACK
834 );
835
836+ CREATE TABLE IF NOT EXISTS settings_json_schema (
837+ pk INTEGER PRIMARY KEY NOT NULL,
838+ id TEXT NOT NULL UNIQUE,
839+ value JSON NOT NULL CHECK (json_type(value) = 'object'),
840+ created INTEGER NOT NULL DEFAULT (unixepoch()),
841+ last_modified INTEGER NOT NULL DEFAULT (unixepoch())
842+ );
843+
844+ CREATE TABLE IF NOT EXISTS list_settings_json (
845+ pk INTEGER PRIMARY KEY NOT NULL,
846+ name TEXT NOT NULL,
847+ list INTEGER,
848+ value JSON NOT NULL CHECK (json_type(value) = 'object'),
849+ is_valid BOOLEAN CHECK (is_valid IN (0, 1)) NOT NULL DEFAULT 0, -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1
850+ created INTEGER NOT NULL DEFAULT (unixepoch()),
851+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
852+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
853+ FOREIGN KEY (name) REFERENCES settings_json_schema(id) ON DELETE CASCADE,
854+ UNIQUE (list, name) ON CONFLICT ROLLBACK
855+ );
856+
857+ CREATE TRIGGER
858+ IF NOT EXISTS is_valid_settings_json_on_update
859+ AFTER UPDATE OF value, name, is_valid ON list_settings_json
860+ FOR EACH ROW
861+ BEGIN
862+ SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
863+ UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
864+ END;
865+
866+ CREATE TRIGGER
867+ IF NOT EXISTS is_valid_settings_json_on_insert
868+ AFTER INSERT ON list_settings_json
869+ FOR EACH ROW
870+ BEGIN
871+ SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
872+ UPDATE list_settings_json SET is_valid = 1 WHERE pk = NEW.pk;
873+ END;
874+
875+ CREATE TRIGGER
876+ IF NOT EXISTS invalidate_settings_json_on_schema_update
877+ AFTER UPDATE OF value, id ON settings_json_schema
878+ FOR EACH ROW
879+ BEGIN
880+ UPDATE list_settings_json SET name = NEW.id, is_valid = 0 WHERE name = OLD.id;
881+ END;
882+
883 -- # Queues
884 --
885 -- ## The "maildrop" queue
886 @@ -386,7 +433,7 @@ CREATE TRIGGER
887 IF NOT EXISTS last_modified_list
888 AFTER UPDATE ON list
889 FOR EACH ROW
890- WHEN NEW.last_modified != OLD.last_modified
891+ WHEN NEW.last_modified == OLD.last_modified
892 BEGIN
893 UPDATE list SET last_modified = unixepoch()
894 WHERE pk = NEW.pk;
895 @@ -397,7 +444,7 @@ CREATE TRIGGER
896 IF NOT EXISTS last_modified_owner
897 AFTER UPDATE ON owner
898 FOR EACH ROW
899- WHEN NEW.last_modified != OLD.last_modified
900+ WHEN NEW.last_modified == OLD.last_modified
901 BEGIN
902 UPDATE owner SET last_modified = unixepoch()
903 WHERE pk = NEW.pk;
904 @@ -408,7 +455,7 @@ CREATE TRIGGER
905 IF NOT EXISTS last_modified_post_policy
906 AFTER UPDATE ON post_policy
907 FOR EACH ROW
908- WHEN NEW.last_modified != OLD.last_modified
909+ WHEN NEW.last_modified == OLD.last_modified
910 BEGIN
911 UPDATE post_policy SET last_modified = unixepoch()
912 WHERE pk = NEW.pk;
913 @@ -419,7 +466,7 @@ CREATE TRIGGER
914 IF NOT EXISTS last_modified_subscription_policy
915 AFTER UPDATE ON subscription_policy
916 FOR EACH ROW
917- WHEN NEW.last_modified != OLD.last_modified
918+ WHEN NEW.last_modified == OLD.last_modified
919 BEGIN
920 UPDATE subscription_policy SET last_modified = unixepoch()
921 WHERE pk = NEW.pk;
922 @@ -430,7 +477,7 @@ CREATE TRIGGER
923 IF NOT EXISTS last_modified_subscription
924 AFTER UPDATE ON subscription
925 FOR EACH ROW
926- WHEN NEW.last_modified != OLD.last_modified
927+ WHEN NEW.last_modified == OLD.last_modified
928 BEGIN
929 UPDATE subscription SET last_modified = unixepoch()
930 WHERE pk = NEW.pk;
931 @@ -441,7 +488,7 @@ CREATE TRIGGER
932 IF NOT EXISTS last_modified_account
933 AFTER UPDATE ON account
934 FOR EACH ROW
935- WHEN NEW.last_modified != OLD.last_modified
936+ WHEN NEW.last_modified == OLD.last_modified
937 BEGIN
938 UPDATE account SET last_modified = unixepoch()
939 WHERE pk = NEW.pk;
940 @@ -452,7 +499,7 @@ CREATE TRIGGER
941 IF NOT EXISTS last_modified_candidate_subscription
942 AFTER UPDATE ON candidate_subscription
943 FOR EACH ROW
944- WHEN NEW.last_modified != OLD.last_modified
945+ WHEN NEW.last_modified == OLD.last_modified
946 BEGIN
947 UPDATE candidate_subscription SET last_modified = unixepoch()
948 WHERE pk = NEW.pk;
949 @@ -463,12 +510,34 @@ CREATE TRIGGER
950 IF NOT EXISTS last_modified_template
951 AFTER UPDATE ON template
952 FOR EACH ROW
953- WHEN NEW.last_modified != OLD.last_modified
954+ WHEN NEW.last_modified == OLD.last_modified
955 BEGIN
956 UPDATE template SET last_modified = unixepoch()
957 WHERE pk = NEW.pk;
958 END;
959
960+ -- [tag:last_modified_settings_json_schema]: update last_modified on every change.
961+ CREATE TRIGGER
962+ IF NOT EXISTS last_modified_settings_json_schema
963+ AFTER UPDATE ON settings_json_schema
964+ FOR EACH ROW
965+ WHEN NEW.last_modified == OLD.last_modified
966+ BEGIN
967+ UPDATE settings_json_schema SET last_modified = unixepoch()
968+ WHERE pk = NEW.pk;
969+ END;
970+
971+ -- [tag:last_modified_list_settings_json]: update last_modified on every change.
972+ CREATE TRIGGER
973+ IF NOT EXISTS last_modified_list_settings_json
974+ AFTER UPDATE ON list_settings_json
975+ FOR EACH ROW
976+ WHEN NEW.last_modified == OLD.last_modified
977+ BEGIN
978+ UPDATE list_settings_json SET last_modified = unixepoch()
979+ WHERE pk = NEW.pk;
980+ END;
981+
982 CREATE TRIGGER
983 IF NOT EXISTS sort_topics_update_trigger
984 AFTER UPDATE ON list
985 diff --git a/core/src/schema.sql.m4 b/core/src/schema.sql.m4
986index e09b8f7..c89fa8f 100644
987--- a/core/src/schema.sql.m4
988+++ b/core/src/schema.sql.m4
989 @@ -11,6 +11,7 @@ dnl # Define boolean column types and defaults
990 define(BOOLEAN_TYPE, `BOOLEAN CHECK ($1 IN (0, 1)) NOT NULL')dnl
991 define(BOOLEAN_FALSE, `0')dnl
992 define(BOOLEAN_TRUE, `1')dnl
993+ define(BOOLEAN_DOCS, ` -- BOOLEAN FALSE == 0, BOOLEAN TRUE == 1')dnl
994 dnl
995 dnl # defile comment functions
996 dnl
997 @@ -26,7 +27,7 @@ CREATE TRIGGER
998 IF NOT EXISTS last_modified_$1
999 AFTER UPDATE ON $1
1000 FOR EACH ROW
1001- WHEN NEW.last_modified != OLD.last_modified
1002+ WHEN NEW.last_modified == OLD.last_modified
1003 BEGIN
1004 UPDATE $1 SET last_modified = unixepoch()
1005 WHERE pk = NEW.pk;
1006 @@ -47,7 +48,7 @@ CREATE TABLE IF NOT EXISTS list (
1007 topics JSON NOT NULL CHECK (json_type(topics) = 'array') DEFAULT '[]',
1008 created INTEGER NOT NULL DEFAULT (unixepoch()),
1009 last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
1010- verify BOOLEAN_TYPE(verify) DEFAULT BOOLEAN_TRUE(),
1011+ verify BOOLEAN_TYPE(verify) DEFAULT BOOLEAN_TRUE(),BOOLEAN_DOCS()
1012 hidden BOOLEAN_TYPE(hidden) DEFAULT BOOLEAN_FALSE(),
1013 enabled BOOLEAN_TYPE(enabled) DEFAULT BOOLEAN_TRUE()
1014 );
1015 @@ -66,7 +67,7 @@ CREATE TABLE IF NOT EXISTS post_policy (
1016 pk INTEGER PRIMARY KEY NOT NULL,
1017 list INTEGER NOT NULL UNIQUE,
1018 announce_only BOOLEAN_TYPE(announce_only)
1019- DEFAULT BOOLEAN_FALSE(),
1020+ DEFAULT BOOLEAN_FALSE(),BOOLEAN_DOCS()
1021 subscription_only BOOLEAN_TYPE(subscription_only)
1022 DEFAULT BOOLEAN_FALSE(),
1023 approval_needed BOOLEAN_TYPE(approval_needed)
1024 @@ -83,7 +84,7 @@ CREATE TABLE IF NOT EXISTS subscription_policy (
1025 pk INTEGER PRIMARY KEY NOT NULL,
1026 list INTEGER NOT NULL UNIQUE,
1027 send_confirmation BOOLEAN_TYPE(send_confirmation)
1028- DEFAULT BOOLEAN_TRUE(),
1029+ DEFAULT BOOLEAN_TRUE(),BOOLEAN_DOCS()
1030 open BOOLEAN_TYPE(open) DEFAULT BOOLEAN_FALSE(),
1031 manual BOOLEAN_TYPE(manual) DEFAULT BOOLEAN_FALSE(),
1032 request BOOLEAN_TYPE(request) DEFAULT BOOLEAN_FALSE(),
1033 @@ -101,7 +102,7 @@ CREATE TABLE IF NOT EXISTS subscription (
1034 name TEXT,
1035 account INTEGER,
1036 enabled BOOLEAN_TYPE(enabled)
1037- DEFAULT BOOLEAN_TRUE(),
1038+ DEFAULT BOOLEAN_TRUE(),BOOLEAN_DOCS()
1039 verified BOOLEAN_TYPE(verified)
1040 DEFAULT BOOLEAN_TRUE(),
1041 digest BOOLEAN_TYPE(digest)
1042 @@ -128,7 +129,7 @@ CREATE TABLE IF NOT EXISTS account (
1043 address TEXT NOT NULL UNIQUE,
1044 public_key TEXT,
1045 password TEXT NOT NULL,
1046- enabled BOOLEAN_TYPE(enabled) DEFAULT BOOLEAN_TRUE(),
1047+ enabled BOOLEAN_TYPE(enabled) DEFAULT BOOLEAN_TRUE(),BOOLEAN_DOCS()
1048 created INTEGER NOT NULL DEFAULT (unixepoch()),
1049 last_modified INTEGER NOT NULL DEFAULT (unixepoch())
1050 );
1051 @@ -172,6 +173,53 @@ CREATE TABLE IF NOT EXISTS template (
1052 UNIQUE (list, name) ON CONFLICT ROLLBACK
1053 );
1054
1055+ CREATE TABLE IF NOT EXISTS settings_json_schema (
1056+ pk INTEGER PRIMARY KEY NOT NULL,
1057+ id TEXT NOT NULL UNIQUE,
1058+ value JSON NOT NULL CHECK (json_type(value) = 'object'),
1059+ created INTEGER NOT NULL DEFAULT (unixepoch()),
1060+ last_modified INTEGER NOT NULL DEFAULT (unixepoch())
1061+ );
1062+
1063+ CREATE TABLE IF NOT EXISTS list_settings_json (
1064+ pk INTEGER PRIMARY KEY NOT NULL,
1065+ name TEXT NOT NULL,
1066+ list INTEGER,
1067+ value JSON NOT NULL CHECK (json_type(value) = 'object'),
1068+ is_valid BOOLEAN_TYPE(is_valid) DEFAULT BOOLEAN_FALSE(),BOOLEAN_DOCS()
1069+ created INTEGER NOT NULL DEFAULT (unixepoch()),
1070+ last_modified INTEGER NOT NULL DEFAULT (unixepoch()),
1071+ FOREIGN KEY (list) REFERENCES list(pk) ON DELETE CASCADE,
1072+ FOREIGN KEY (name) REFERENCES settings_json_schema(id) ON DELETE CASCADE,
1073+ UNIQUE (list, name) ON CONFLICT ROLLBACK
1074+ );
1075+
1076+ CREATE TRIGGER
1077+ IF NOT EXISTS is_valid_settings_json_on_update
1078+ AFTER UPDATE OF value, name, is_valid ON list_settings_json
1079+ FOR EACH ROW
1080+ BEGIN
1081+ SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
1082+ UPDATE list_settings_json SET is_valid = BOOLEAN_TRUE() WHERE pk = NEW.pk;
1083+ END;
1084+
1085+ CREATE TRIGGER
1086+ IF NOT EXISTS is_valid_settings_json_on_insert
1087+ AFTER INSERT ON list_settings_json
1088+ FOR EACH ROW
1089+ BEGIN
1090+ SELECT RAISE(ROLLBACK, 'new settings value is not valid according to the json schema. Rolling back transaction.') FROM settings_json_schema AS schema WHERE schema.id = NEW.name AND NOT validate_json_schema(schema.value, NEW.value);
1091+ UPDATE list_settings_json SET is_valid = BOOLEAN_TRUE() WHERE pk = NEW.pk;
1092+ END;
1093+
1094+ CREATE TRIGGER
1095+ IF NOT EXISTS invalidate_settings_json_on_schema_update
1096+ AFTER UPDATE OF value, id ON settings_json_schema
1097+ FOR EACH ROW
1098+ BEGIN
1099+ UPDATE list_settings_json SET name = NEW.id, is_valid = BOOLEAN_FALSE() WHERE name = OLD.id;
1100+ END;
1101+
1102 -- # Queues
1103 --
1104 -- ## The "maildrop" queue
1105 @@ -290,6 +338,8 @@ update_last_modified(`subscription')
1106 update_last_modified(`account')
1107 update_last_modified(`candidate_subscription')
1108 update_last_modified(`template')
1109+ update_last_modified(`settings_json_schema')
1110+ update_last_modified(`list_settings_json')
1111
1112 CREATE TRIGGER
1113 IF NOT EXISTS sort_topics_update_trigger
1114 diff --git a/core/tests/migrations.rs b/core/tests/migrations.rs
1115index 841baf7..e05f9e7 100644
1116--- a/core/tests/migrations.rs
1117+++ b/core/tests/migrations.rs
1118 @@ -144,6 +144,38 @@ impl ConnectionExt for rusqlite::Connection {
1119 }
1120 }
1121
1122+ const FIRST_SCHEMA: &str = r#"
1123+ PRAGMA foreign_keys = true;
1124+ PRAGMA encoding = 'UTF-8';
1125+ PRAGMA schema_version = 0;
1126+
1127+ CREATE TABLE IF NOT EXISTS person (
1128+ pk INTEGER PRIMARY KEY NOT NULL,
1129+ name TEXT,
1130+ address TEXT NOT NULL,
1131+ created INTEGER NOT NULL DEFAULT (unixepoch()),
1132+ last_modified INTEGER NOT NULL DEFAULT (unixepoch())
1133+ );
1134+ "#;
1135+
1136+ const MIGRATIONS: &[(u32, &str, &str)] = &[
1137+ (
1138+ 1,
1139+ "ALTER TABLE PERSON ADD COLUMN interests TEXT;",
1140+ "ALTER TABLE PERSON DROP COLUMN interests;",
1141+ ),
1142+ (
1143+ 2,
1144+ "CREATE TABLE hobby ( pk INTEGER PRIMARY KEY NOT NULL,title TEXT NOT NULL);",
1145+ "DROP TABLE hobby;",
1146+ ),
1147+ (
1148+ 3,
1149+ "ALTER TABLE PERSON ADD COLUMN main_hobby INTEGER REFERENCES hobby(pk) ON DELETE SET NULL;",
1150+ "ALTER TABLE PERSON DROP COLUMN main_hobby;",
1151+ ),
1152+ ];
1153+
1154 #[test]
1155 fn test_migration_gen() {
1156 init_stderr_logging();
1157 @@ -329,35 +361,3 @@ CREATE TABLE person (
1158 )
1159 );
1160 }
1161-
1162- const FIRST_SCHEMA: &str = r#"
1163- PRAGMA foreign_keys = true;
1164- PRAGMA encoding = 'UTF-8';
1165- PRAGMA schema_version = 0;
1166-
1167- CREATE TABLE IF NOT EXISTS person (
1168- pk INTEGER PRIMARY KEY NOT NULL,
1169- name TEXT,
1170- address TEXT NOT NULL,
1171- created INTEGER NOT NULL DEFAULT (unixepoch()),
1172- last_modified INTEGER NOT NULL DEFAULT (unixepoch())
1173- );
1174- "#;
1175-
1176- const MIGRATIONS: &[(u32, &str, &str)] = &[
1177- (
1178- 1,
1179- "ALTER TABLE PERSON ADD COLUMN interests TEXT;",
1180- "ALTER TABLE PERSON DROP COLUMN interests;",
1181- ),
1182- (
1183- 2,
1184- "CREATE TABLE hobby ( pk INTEGER PRIMARY KEY NOT NULL,title TEXT NOT NULL);",
1185- "DROP TABLE hobby;",
1186- ),
1187- (
1188- 3,
1189- "ALTER TABLE PERSON ADD COLUMN main_hobby INTEGER REFERENCES hobby(pk) ON DELETE SET NULL;",
1190- "ALTER TABLE PERSON DROP COLUMN main_hobby;",
1191- ),
1192- ];
1193 diff --git a/core/tests/settings_json.rs b/core/tests/settings_json.rs
1194new file mode 100644
1195index 0000000..e1600b0
1196--- /dev/null
1197+++ b/core/tests/settings_json.rs
1198 @@ -0,0 +1,178 @@
1199+ /*
1200+ * This file is part of mailpot
1201+ *
1202+ * Copyright 2023 - Manos Pitsidianakis
1203+ *
1204+ * This program is free software: you can redistribute it and/or modify
1205+ * it under the terms of the GNU Affero General Public License as
1206+ * published by the Free Software Foundation, either version 3 of the
1207+ * License, or (at your option) any later version.
1208+ *
1209+ * This program is distributed in the hope that it will be useful,
1210+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
1211+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1212+ * GNU Affero General Public License for more details.
1213+ *
1214+ * You should have received a copy of the GNU Affero General Public License
1215+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
1216+ */
1217+
1218+ use jsonschema::JSONSchema;
1219+ use mailpot::{Configuration, Connection, SendMail};
1220+ use mailpot_tests::init_stderr_logging;
1221+ use serde_json::{json, Value};
1222+ use tempfile::TempDir;
1223+
1224+ #[test]
1225+ fn test_settings_json() {
1226+ init_stderr_logging();
1227+ let tmp_dir = TempDir::new().unwrap();
1228+
1229+ let db_path = tmp_dir.path().join("mpot.db");
1230+ std::fs::copy("../mailpot-tests/for_testing.db", &db_path).unwrap();
1231+ let mut perms = std::fs::metadata(&db_path).unwrap().permissions();
1232+ #[allow(clippy::permissions_set_readonly_false)]
1233+ perms.set_readonly(false);
1234+ std::fs::set_permissions(&db_path, perms).unwrap();
1235+
1236+ let config = Configuration {
1237+ send_mail: SendMail::ShellCommand("/usr/bin/false".to_string()),
1238+ db_path,
1239+ data_path: tmp_dir.path().to_path_buf(),
1240+ administrators: vec![],
1241+ };
1242+ let db = Connection::open_or_create_db(config).unwrap().trusted();
1243+ let list = db.lists().unwrap().remove(0);
1244+
1245+ let archived_at_link_settings_schema =
1246+ std::fs::read_to_string("./settings_json_schemas/archivedatlink.json").unwrap();
1247+
1248+ println!("Testing that inserting settings works…");
1249+ let (settings_pk, settings_val, last_modified): (i64, Value, i64) = {
1250+ let mut stmt = db
1251+ .connection
1252+ .prepare(
1253+ "INSERT INTO list_settings_json(name, list, value) \
1254+ VALUES('ArchivedAtLinkSettings', ?, ?) RETURNING pk, value, last_modified;",
1255+ )
1256+ .unwrap();
1257+ stmt.query_row(
1258+ rusqlite::params![
1259+ &list.pk(),
1260+ &json!({
1261+ "template": "https://www.example.com/{msg-id}.html"
1262+ }),
1263+ ],
1264+ |row| {
1265+ let pk: i64 = row.get("pk")?;
1266+ let value: Value = row.get("value")?;
1267+ let last_modified: i64 = row.get("last_modified")?;
1268+ Ok((pk, value, last_modified))
1269+ },
1270+ )
1271+ .unwrap()
1272+ };
1273+ db.connection
1274+ .execute_batch("UPDATE list_settings_json SET is_valid = 1;")
1275+ .unwrap();
1276+
1277+ println!("Testing that schema is actually valid…");
1278+ let schema: Value = serde_json::from_str(&archived_at_link_settings_schema).unwrap();
1279+ let compiled = JSONSchema::compile(&schema).expect("A valid schema");
1280+ if let Err(errors) = compiled.validate(&settings_val) {
1281+ for err in errors {
1282+ eprintln!("Error: {err}");
1283+ }
1284+ panic!("Could not validate settings.");
1285+ };
1286+
1287+ println!("Testing that inserting invalid settings aborts…");
1288+ {
1289+ let mut stmt = db
1290+ .connection
1291+ .prepare(
1292+ "INSERT OR REPLACE INTO list_settings_json(name, list, value) \
1293+ VALUES('ArchivedAtLinkSettings', ?, ?) RETURNING pk, value;",
1294+ )
1295+ .unwrap();
1296+ assert_eq!(
1297+ "new settings value is not valid according to the json schema. Rolling back \
1298+ transaction.",
1299+ &stmt
1300+ .query_row(
1301+ rusqlite::params![
1302+ &list.pk(),
1303+ &json!({
1304+ "template": "https://www.example.com/msg-id}.html"
1305+ }),
1306+ ],
1307+ |row| {
1308+ let pk: i64 = row.get("pk")?;
1309+ let value: Value = row.get("value")?;
1310+ Ok((pk, value))
1311+ },
1312+ )
1313+ .unwrap_err()
1314+ .to_string()
1315+ );
1316+ };
1317+
1318+ println!("Testing that updating settings with invalid value aborts…");
1319+ {
1320+ let mut stmt = db
1321+ .connection
1322+ .prepare(
1323+ "UPDATE list_settings_json SET value = ? WHERE name = 'ArchivedAtLinkSettings' \
1324+ RETURNING pk, value;",
1325+ )
1326+ .unwrap();
1327+ assert_eq!(
1328+ "new settings value is not valid according to the json schema. Rolling back \
1329+ transaction.",
1330+ &stmt
1331+ .query_row(
1332+ rusqlite::params![&json!({
1333+ "template": "https://www.example.com/msg-id}.html"
1334+ }),],
1335+ |row| {
1336+ let pk: i64 = row.get("pk")?;
1337+ let value: Value = row.get("value")?;
1338+ Ok((pk, value))
1339+ },
1340+ )
1341+ .unwrap_err()
1342+ .to_string()
1343+ );
1344+ };
1345+
1346+ std::thread::sleep(std::time::Duration::from_millis(1000));
1347+ println!("Finally, testing that updating schema reverifies settings…");
1348+ {
1349+ let mut stmt = db
1350+ .connection
1351+ .prepare(
1352+ "UPDATE settings_json_schema SET id = ? WHERE id = 'ArchivedAtLinkSettings' \
1353+ RETURNING pk;",
1354+ )
1355+ .unwrap();
1356+ stmt.query_row([&"ArchivedAtLinkSettingsv2"], |_| Ok(()))
1357+ .unwrap();
1358+ };
1359+ let (new_name, is_valid, new_last_modified): (String, bool, i64) = {
1360+ let mut stmt = db
1361+ .connection
1362+ .prepare("SELECT name, is_valid, last_modified from list_settings_json WHERE pk = ?;")
1363+ .unwrap();
1364+ stmt.query_row([&settings_pk], |row| {
1365+ Ok((
1366+ row.get("name")?,
1367+ row.get("is_valid")?,
1368+ row.get("last_modified")?,
1369+ ))
1370+ })
1371+ .unwrap()
1372+ };
1373+ assert_eq!(&new_name, "ArchivedAtLinkSettingsv2");
1374+ assert!(is_valid);
1375+ assert!(new_last_modified != last_modified);
1376+ }
1377 diff --git a/core/tools/generate_configuration_json_schema.py b/core/tools/generate_configuration_json_schema.py
1378new file mode 100755
1379index 0000000..e12fae1
1380--- /dev/null
1381+++ b/core/tools/generate_configuration_json_schema.py
1382 @@ -0,0 +1,52 @@
1383+ #!/usr/bin/env python3
1384+ """
1385+ Example taken from https://jcristharif.com/msgspec/jsonschema.html
1386+ """
1387+ import msgspec
1388+ from msgspec import Struct, Meta
1389+ from typing import Annotated, Optional
1390+
1391+ Template = Annotated[
1392+ str,
1393+ Meta(
1394+ pattern=".+[{]msg-id[}].*",
1395+ description="""Template for \
1396+ `Archived-At` header value, as described in RFC 5064 "The Archived-At \
1397+ Message Header Field". The template receives only one string variable \
1398+ with the value of the mailing list post `Message-ID` header.
1399+
1400+ For example, if:
1401+
1402+ - the template is `http://www.example.com/mid/{msg-id}`
1403+ - the `Message-ID` is `<0C2U00F01DFGCR@mailsj-v3.example.com>`
1404+
1405+ The full header will be generated as:
1406+
1407+ `Archived-At: <http://www.example.com/mid/0C2U00F01DFGCR@mailsj-v3.example.com>
1408+
1409+ Note: Surrounding carets in the `Message-ID` value are not required. If \
1410+ you wish to preserve them in the URL, set option `preserve-carets` to \
1411+ true.""",
1412+ title="Jinja template for header value",
1413+ examples=[
1414+ "https://www.example.com/{msg-id}",
1415+ "https://www.example.com/{msg-id}.html",
1416+ ],
1417+ ),
1418+ ]
1419+
1420+ PreserveCarets = Annotated[
1421+ bool, Meta(title="Preserve carets of `Message-ID` in generated value")
1422+ ]
1423+
1424+
1425+ class ArchivedAtLinkSettings(Struct):
1426+ """Settings for ArchivedAtLink message filter"""
1427+
1428+ template: Template
1429+ preserve_carets: PreserveCarets = False
1430+
1431+
1432+ schema = {"$schema": "http://json-schema.org/draft-07/schema"}
1433+ schema.update(msgspec.json.schema(ArchivedAtLinkSettings))
1434+ print(msgspec.json.format(msgspec.json.encode(schema)).decode("utf-8"))