@formseal/embed 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +172 -0
- package/cli/commands/__init__.py +0 -0
- package/cli/commands/about.py +24 -0
- package/cli/commands/configure.py +292 -0
- package/cli/commands/help.py +29 -0
- package/cli/commands/init.py +119 -0
- package/cli/fse.py +91 -0
- package/cli/logo.py +11 -0
- package/cli/shim.js +57 -0
- package/cli/ui/__init__.py +0 -0
- package/cli/ui/output.py +79 -0
- package/package.json +30 -0
- package/src/config/fse.config.js +69 -0
- package/src/globals.js +75 -0
- package/src/runtime/fse.crypto.js +59 -0
- package/src/runtime/fse.form.js +181 -0
- package/src/runtime/fse.payload.js +25 -0
- package/src/runtime/fse.validate.js +66 -0
- package/src/vendor/sodium.js +1 -0
package/cli/fse.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# fse.py
|
|
3
|
+
# Entry point. Parses args and routes to the correct command.
|
|
4
|
+
# Adding a new command:
|
|
5
|
+
# 1. Create commands/newcommand.py with a run() function
|
|
6
|
+
# 2. Import it below
|
|
7
|
+
# 3. Add a case to the router
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
if os.name == "nt":
|
|
13
|
+
try:
|
|
14
|
+
os.system("chcp 65001 >nul")
|
|
15
|
+
except:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
20
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
21
|
+
except:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
25
|
+
|
|
26
|
+
from ui.output import br, rule, cmd_line, link, fail, badge, C, G, Y, M, W, D, R, BOLD
|
|
27
|
+
from logo import LOGO
|
|
28
|
+
|
|
29
|
+
from commands import init as cmd_init
|
|
30
|
+
from commands import configure as cmd_configure
|
|
31
|
+
from commands import help as cmd_help
|
|
32
|
+
from commands import about as cmd_about
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# -- intro --
|
|
36
|
+
|
|
37
|
+
def intro():
|
|
38
|
+
br()
|
|
39
|
+
print(f"{C} \u250c\u2500 {R}{W}formseal-embed{R}")
|
|
40
|
+
print(G + " " + "\u2500" * 52 + R)
|
|
41
|
+
br()
|
|
42
|
+
print(f" {G}Start:{R}")
|
|
43
|
+
br()
|
|
44
|
+
print(f" {W}fse init{R}")
|
|
45
|
+
print(f" {D}scaffold project files{R}")
|
|
46
|
+
br()
|
|
47
|
+
print(f" {W}fse configure quick{R}")
|
|
48
|
+
print(f" {D}set endpoint + key{R}")
|
|
49
|
+
br()
|
|
50
|
+
print(f" {G}Help:{R}")
|
|
51
|
+
br()
|
|
52
|
+
print(f" {W}fse --help{R}")
|
|
53
|
+
link("https://github.com/grayguava/formseal-embed/docs")
|
|
54
|
+
br()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# -- router --
|
|
58
|
+
|
|
59
|
+
def main():
|
|
60
|
+
args = sys.argv[1:]
|
|
61
|
+
command = args[0] if args else None
|
|
62
|
+
|
|
63
|
+
match command:
|
|
64
|
+
case "init":
|
|
65
|
+
cmd_init.run()
|
|
66
|
+
|
|
67
|
+
case "configure":
|
|
68
|
+
sub = args[1] if len(args) > 1 else None
|
|
69
|
+
cmd_configure.run(sub, args[2:])
|
|
70
|
+
|
|
71
|
+
case "doctor":
|
|
72
|
+
fail("fse doctor is not yet available.")
|
|
73
|
+
|
|
74
|
+
case None | "fse":
|
|
75
|
+
intro()
|
|
76
|
+
|
|
77
|
+
case "--help" | "-h":
|
|
78
|
+
cmd_help.run()
|
|
79
|
+
|
|
80
|
+
case "--about":
|
|
81
|
+
cmd_about.run()
|
|
82
|
+
|
|
83
|
+
case _:
|
|
84
|
+
fail(
|
|
85
|
+
f"Unknown command: {command}\n"
|
|
86
|
+
f" Run {W}fse --help{R} for usage."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
main()
|
package/cli/logo.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# cli/logo.py
|
|
2
|
+
# ASCII art logo - edit this file to change the banner
|
|
3
|
+
|
|
4
|
+
LOGO = """
|
|
5
|
+
_____ .__ ___. .___
|
|
6
|
+
_╱ ____╲___________ _____ ______ ____ _____ │ │ ____ _____╲_ │__ ____ __│ _╱
|
|
7
|
+
╲ __╲╱ _ ╲_ __ ╲╱ ╲ ╱ ___╱╱ __ ╲╲__ ╲ │ │ ______ _╱ __ ╲ ╱ ╲│ __ ╲_╱ __ ╲ ╱ __ │
|
|
8
|
+
│ │ ( <_> ) │ ╲╱ Y Y ╲╲___ ╲╲ ___╱ ╱ __ ╲│ │__ ╱_____╱ ╲ ___╱│ Y Y ╲ ╲_╲ ╲ ___╱╱ ╱_╱ │
|
|
9
|
+
│__│ ╲____╱│__│ │__│_│ ╱____ >╲___ >____ ╱____╱ ╲___ >__│_│ ╱___ ╱╲___ >____ │
|
|
10
|
+
╲╱ ╲╱ ╲╱ ╲╱ ╲╱ ╲╱ ╲╱ ╲╱ ╲╱
|
|
11
|
+
"""
|
package/cli/shim.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { spawnSync } = require("child_process");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
|
|
7
|
+
const MIN_MAJOR = 3;
|
|
8
|
+
const MIN_MINOR = 8;
|
|
9
|
+
|
|
10
|
+
const SCRIPT = path.join(__dirname, "fse.py");
|
|
11
|
+
const ARGS = process.argv.slice(2);
|
|
12
|
+
|
|
13
|
+
function findPython() {
|
|
14
|
+
const candidates = ["py", "python", "python3"];
|
|
15
|
+
|
|
16
|
+
for (const cmd of candidates) {
|
|
17
|
+
const result = spawnSync(cmd, ["--version"], { encoding: "utf8" });
|
|
18
|
+
if (result.status === 0) {
|
|
19
|
+
const output = (result.stdout || result.stderr || "").trim();
|
|
20
|
+
const match = output.match(/Python (\d+)\.(\d+)/);
|
|
21
|
+
if (match) {
|
|
22
|
+
const major = parseInt(match[1], 10);
|
|
23
|
+
const minor = parseInt(match[2], 10);
|
|
24
|
+
if (major === MIN_MAJOR && minor >= MIN_MINOR) return { cmd, version: output };
|
|
25
|
+
if (major > MIN_MAJOR) return { cmd, version: output };
|
|
26
|
+
fail(
|
|
27
|
+
"Python " + MIN_MAJOR + "." + MIN_MINOR + "+ is required.\n" +
|
|
28
|
+
" Found: " + output + "\n" +
|
|
29
|
+
" Install from https://python.org"
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fail(msg) {
|
|
38
|
+
console.error("\x1b[31m\x1b[1m ERR \x1b[0m " + msg);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const python = findPython();
|
|
43
|
+
|
|
44
|
+
if (!python) {
|
|
45
|
+
fail(
|
|
46
|
+
"Python is required to run fse.\n" +
|
|
47
|
+
" Install from https://python.org\n" +
|
|
48
|
+
" Windows: winget install Python.Python.3"
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const result = spawnSync(python.cmd, [SCRIPT, ...ARGS], {
|
|
53
|
+
stdio: "inherit",
|
|
54
|
+
env: process.env,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
process.exit(result.status ?? 1);
|
|
File without changes
|
package/cli/ui/output.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# ui/output.py
|
|
2
|
+
# All terminal output primitives. No logic, no commands.
|
|
3
|
+
# Every print in fse goes through here.
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
if os.name == "nt":
|
|
9
|
+
try:
|
|
10
|
+
os.system("chcp 65001 >nul")
|
|
11
|
+
except:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
16
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
17
|
+
except:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
# -- ansi colors --
|
|
21
|
+
|
|
22
|
+
RESET = "\x1b[0m"
|
|
23
|
+
BOLD = "\x1b[1m"
|
|
24
|
+
DIM = "\x1b[2m"
|
|
25
|
+
|
|
26
|
+
RED = "\x1b[31m"
|
|
27
|
+
GREEN = "\x1b[32m"
|
|
28
|
+
YELLOW = "\x1b[33m"
|
|
29
|
+
BLUE = "\x1b[34m"
|
|
30
|
+
MAGENTA= "\x1b[35m"
|
|
31
|
+
CYAN = "\x1b[36m"
|
|
32
|
+
WHITE = "\x1b[37m"
|
|
33
|
+
GRAY = "\x1b[90m"
|
|
34
|
+
|
|
35
|
+
# Semantic palette
|
|
36
|
+
B = "\x1b[38;5;109m" # muted green - arguments/placeholders
|
|
37
|
+
C = "\x1b[38;5;117m" # soft cyan - title, links, script tag
|
|
38
|
+
M = "\x1b[38;5;141m" # muted magenta - experimental sections
|
|
39
|
+
G = "\x1b[38;5;244m" # dim gray - descriptions
|
|
40
|
+
Y = "\x1b[38;5;103m" # muted purple - section headers
|
|
41
|
+
S = "\x1b[38;5;112m" # soft green - success states
|
|
42
|
+
O = "\x1b[38;5;130m" # dim orange - logo
|
|
43
|
+
W = WHITE + BOLD # bright white - commands
|
|
44
|
+
D = DIM # dim - secondary
|
|
45
|
+
R = RESET # reset
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# -- primitives --
|
|
49
|
+
|
|
50
|
+
def br():
|
|
51
|
+
print()
|
|
52
|
+
|
|
53
|
+
def rule():
|
|
54
|
+
print(G + " " + "\u2500" * 52 + R)
|
|
55
|
+
|
|
56
|
+
def badge(label, color):
|
|
57
|
+
return f"{color}{BOLD} {label} {R}"
|
|
58
|
+
|
|
59
|
+
def fail(msg):
|
|
60
|
+
br()
|
|
61
|
+
print(f"{badge('ERR', RED)} {msg}")
|
|
62
|
+
br()
|
|
63
|
+
raise SystemExit(1)
|
|
64
|
+
|
|
65
|
+
def row(icon, label, value):
|
|
66
|
+
pad = 10
|
|
67
|
+
label = (label + " " * pad)[:pad]
|
|
68
|
+
print(f"{S}{icon}{R} {D}{label}{R} {W}{value}{R}")
|
|
69
|
+
|
|
70
|
+
def cmd_line(command, desc):
|
|
71
|
+
pad = 34
|
|
72
|
+
command = (command + " " * pad)[:pad]
|
|
73
|
+
print(f" {W}{command}{R}{G}{desc}{R}")
|
|
74
|
+
|
|
75
|
+
def code(msg):
|
|
76
|
+
print(f" {C}{msg}{R}")
|
|
77
|
+
|
|
78
|
+
def link(msg):
|
|
79
|
+
print(f" {C}{msg}{R}")
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@formseal/embed",
|
|
3
|
+
"description": "Drop-in client-side encrypted contact form.",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"bin": {
|
|
6
|
+
"fse": "./cli/shim.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"src/",
|
|
10
|
+
"cli/"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"formseal",
|
|
17
|
+
"fsembed",
|
|
18
|
+
"fse",
|
|
19
|
+
"encryption",
|
|
20
|
+
"curve25519",
|
|
21
|
+
"contact-form",
|
|
22
|
+
"libsodium",
|
|
23
|
+
"privacy"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/grayguava/formseal-embed.git"
|
|
28
|
+
},
|
|
29
|
+
"version": "3.1.0"
|
|
30
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// fse.config.js
|
|
2
|
+
// Unified configuration for formseal-embed.
|
|
3
|
+
//
|
|
4
|
+
// Edit this file or use the CLI:
|
|
5
|
+
// fse configure endpoint <url>
|
|
6
|
+
// fse configure key <base64url>
|
|
7
|
+
// fse configure field add <name> [options]
|
|
8
|
+
// fse configure field remove <name>
|
|
9
|
+
// fse configure field required <name> <true|false>
|
|
10
|
+
// fse configure field maxLength <name> <number>
|
|
11
|
+
|
|
12
|
+
var FSE = {
|
|
13
|
+
|
|
14
|
+
// -- Endpoint --
|
|
15
|
+
// POST target. Receives: { ciphertext: "<base64url>" }
|
|
16
|
+
endpoint: "https://your-api.example.com/submit",
|
|
17
|
+
|
|
18
|
+
// -- Origin --
|
|
19
|
+
// Identifier for this form deployment. Useful when you have multiple forms.
|
|
20
|
+
origin: "contact-form",
|
|
21
|
+
|
|
22
|
+
// -- Encryption --
|
|
23
|
+
// Recipient x25519 public key in base64url (32 bytes)
|
|
24
|
+
publicKey: "PASTE_YOUR_BASE64URL_PUBLIC_KEY_HERE",
|
|
25
|
+
|
|
26
|
+
// -- Form selectors --
|
|
27
|
+
form: "#contact-form",
|
|
28
|
+
submit: "#contact-submit",
|
|
29
|
+
status: "#contact-status",
|
|
30
|
+
|
|
31
|
+
// -- Submit button states --
|
|
32
|
+
submitStates: {
|
|
33
|
+
idle: "Send message",
|
|
34
|
+
sending: "Sending...",
|
|
35
|
+
sent: "Sent",
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// -- Success behaviour --
|
|
39
|
+
onSuccess: {
|
|
40
|
+
redirect: false,
|
|
41
|
+
redirectUrl: "/thank-you",
|
|
42
|
+
message: "Thanks! Your message has been sent.",
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
// -- Error behaviour --
|
|
46
|
+
onError: {
|
|
47
|
+
message: "Something went wrong. Please try again.",
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// -- Fields --
|
|
51
|
+
// Key = field name (matches [name] attribute in HTML)
|
|
52
|
+
// Value = validation rules
|
|
53
|
+
fields: {
|
|
54
|
+
name: {
|
|
55
|
+
required: true,
|
|
56
|
+
maxLength: 100,
|
|
57
|
+
},
|
|
58
|
+
email: {
|
|
59
|
+
type: "email",
|
|
60
|
+
required: true,
|
|
61
|
+
maxLength: 200,
|
|
62
|
+
},
|
|
63
|
+
message: {
|
|
64
|
+
required: true,
|
|
65
|
+
maxLength: 1000,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
};
|
package/src/globals.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// globals.js
|
|
2
|
+
// Single entry point. Drop this one script tag in your HTML.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// <script src="formseal-embed/globals.js"></script>
|
|
6
|
+
|
|
7
|
+
(function () {
|
|
8
|
+
"use strict";
|
|
9
|
+
|
|
10
|
+
var scripts = document.querySelectorAll("script[src]");
|
|
11
|
+
var selfSrc = "";
|
|
12
|
+
for (var i = 0; i < scripts.length; i++) {
|
|
13
|
+
if (scripts[i].src.indexOf("globals.js") !== -1) {
|
|
14
|
+
selfSrc = scripts[i].src;
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
var base = selfSrc.substring(0, selfSrc.lastIndexOf("/") + 1);
|
|
19
|
+
|
|
20
|
+
var FILES = [
|
|
21
|
+
"vendor/sodium.js",
|
|
22
|
+
"config/fse.config.js",
|
|
23
|
+
"runtime/fse.crypto.js",
|
|
24
|
+
"runtime/fse.payload.js",
|
|
25
|
+
"runtime/fse.validate.js",
|
|
26
|
+
"runtime/fse.form.js",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function loadNext(index) {
|
|
30
|
+
if (index >= FILES.length) {
|
|
31
|
+
try {
|
|
32
|
+
sodium.ready.then(function () {
|
|
33
|
+
try {
|
|
34
|
+
FSEForm.mount();
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error("[fse] Mount failed:", err);
|
|
37
|
+
}
|
|
38
|
+
}).catch(function (err) {
|
|
39
|
+
console.error("[fse] sodium.ready failed:", err);
|
|
40
|
+
});
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error("[fse] sodium is not available:", err);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
var url = base + FILES[index];
|
|
48
|
+
|
|
49
|
+
fetch(url)
|
|
50
|
+
.then(function (res) {
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
console.error("[fse] " + res.status + " loading " + url + ". Aborting.");
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return res.text();
|
|
56
|
+
})
|
|
57
|
+
.then(function (code) {
|
|
58
|
+
if (code === null || code === undefined) return;
|
|
59
|
+
var s = document.createElement("script");
|
|
60
|
+
s.textContent = code;
|
|
61
|
+
document.head.appendChild(s);
|
|
62
|
+
loadNext(index + 1);
|
|
63
|
+
})
|
|
64
|
+
.catch(function (err) {
|
|
65
|
+
console.error("[fse] Failed to load " + url + ":", err);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (document.readyState === "loading") {
|
|
70
|
+
document.addEventListener("DOMContentLoaded", function () { loadNext(0); });
|
|
71
|
+
} else {
|
|
72
|
+
loadNext(0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
})();
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// fse.crypto.js
|
|
2
|
+
// Pure crypto utilities. No DOM access. No config dependencies.
|
|
3
|
+
// Depends on: window.sodium (libsodium-wrappers, must be ready)
|
|
4
|
+
|
|
5
|
+
var FSECrypto = (function () {
|
|
6
|
+
|
|
7
|
+
// -- Encoding helpers --
|
|
8
|
+
|
|
9
|
+
function bytesToBase64url(bytes) {
|
|
10
|
+
return btoa(String.fromCharCode(...bytes))
|
|
11
|
+
.replace(/\+/g, "-")
|
|
12
|
+
.replace(/\//g, "_")
|
|
13
|
+
.replace(/=+$/, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function base64urlToBytes(b64url) {
|
|
17
|
+
b64url = b64url.replace(/-/g, "+").replace(/_/g, "/");
|
|
18
|
+
const pad = b64url.length % 4;
|
|
19
|
+
if (pad) b64url += "=".repeat(4 - pad);
|
|
20
|
+
const binary = atob(b64url);
|
|
21
|
+
return Uint8Array.from(binary, function (c) { return c.charCodeAt(0); });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// -- Encryption --
|
|
25
|
+
|
|
26
|
+
// Seals a JS object as JSON to a recipient's x25519 public key.
|
|
27
|
+
// Returns the ciphertext as a base64url string.
|
|
28
|
+
// recipientPublicKey: base64url-encoded x25519 public key (32 bytes).
|
|
29
|
+
async function sealJSON(obj, publicKey) {
|
|
30
|
+
if (!publicKey) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
"[fse/crypto] publicKey is not set in fse.config.js."
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await sodium.ready;
|
|
37
|
+
|
|
38
|
+
const pubKey = base64urlToBytes(publicKey);
|
|
39
|
+
if (pubKey.length !== 32) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"[fse/crypto] publicKey must decode to exactly 32 bytes."
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(obj));
|
|
46
|
+
const ciphertext = sodium.crypto_box_seal(plaintext, pubKey);
|
|
47
|
+
|
|
48
|
+
return bytesToBase64url(ciphertext);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// -- Public API --
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
sealJSON,
|
|
55
|
+
bytesToBase64url,
|
|
56
|
+
base64urlToBytes,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
})();
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// fse.form.js
|
|
2
|
+
// Submit pipeline controller. Touches the DOM only for:
|
|
3
|
+
// - reading field values (by name attribute)
|
|
4
|
+
// - managing submit button state
|
|
5
|
+
// - writing validation errors to [data-fse-error="fieldname"] elements
|
|
6
|
+
// - writing status messages to the statusSelector element
|
|
7
|
+
//
|
|
8
|
+
// The dev owns all form markup. Formseal-embed owns none of it.
|
|
9
|
+
//
|
|
10
|
+
// Depends on: FSE, FSECrypto, FSEPayload, FSEValidate (all globals).
|
|
11
|
+
|
|
12
|
+
var FSEForm = (function () {
|
|
13
|
+
|
|
14
|
+
function requireGlobal(name) {
|
|
15
|
+
if (typeof window[name] === "undefined") {
|
|
16
|
+
throw new Error("[fse/form] " + name + " is not defined.");
|
|
17
|
+
}
|
|
18
|
+
return window[name];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function collectData(formEl, fields) {
|
|
22
|
+
var data = {};
|
|
23
|
+
Object.keys(fields).forEach(function (name) {
|
|
24
|
+
var input = formEl.querySelector("[name='" + name + "']");
|
|
25
|
+
if (!input) return;
|
|
26
|
+
data[name] = input.value.trim();
|
|
27
|
+
});
|
|
28
|
+
return data;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function clearFieldErrors(fields) {
|
|
32
|
+
Object.keys(fields).forEach(function (name) {
|
|
33
|
+
var el = document.querySelector("[data-fse-error='" + name + "']");
|
|
34
|
+
if (el) el.textContent = "";
|
|
35
|
+
var input = document.querySelector("[name='" + name + "']");
|
|
36
|
+
if (input) input.removeAttribute("aria-invalid");
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function showFieldErrors(errors) {
|
|
41
|
+
var focused = false;
|
|
42
|
+
errors.forEach(function (err) {
|
|
43
|
+
var el = document.querySelector("[data-fse-error='" + err.name + "']");
|
|
44
|
+
var input = document.querySelector("[name='" + err.name + "']");
|
|
45
|
+
if (el) el.textContent = err.message;
|
|
46
|
+
if (input) {
|
|
47
|
+
input.setAttribute("aria-invalid", "true");
|
|
48
|
+
if (!focused) { input.focus(); focused = true; }
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function setStatus(cfg, message, isError, responseData) {
|
|
54
|
+
if (cfg.status) {
|
|
55
|
+
var el = document.querySelector(cfg.status);
|
|
56
|
+
if (el) {
|
|
57
|
+
el.textContent = message;
|
|
58
|
+
el.setAttribute("data-fse-status", isError ? "error" : "success");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
var cb = window.fseCallbacks;
|
|
63
|
+
if (cb) {
|
|
64
|
+
if (!isError && typeof cb.onSuccess === "function") {
|
|
65
|
+
cb.onSuccess(responseData);
|
|
66
|
+
}
|
|
67
|
+
if (isError && typeof cb.onError === "function") {
|
|
68
|
+
cb.onError(new Error(message));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function clearStatus(cfg) {
|
|
74
|
+
if (cfg.status) {
|
|
75
|
+
var el = document.querySelector(cfg.status);
|
|
76
|
+
if (el) {
|
|
77
|
+
el.textContent = "";
|
|
78
|
+
el.removeAttribute("data-fse-status");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function setButtonState(btn, state, cfg) {
|
|
84
|
+
var states = cfg.submitStates || {};
|
|
85
|
+
switch (state) {
|
|
86
|
+
case "sending":
|
|
87
|
+
btn.disabled = true;
|
|
88
|
+
btn.textContent = states.sending || "Sending...";
|
|
89
|
+
break;
|
|
90
|
+
case "sent":
|
|
91
|
+
btn.disabled = true;
|
|
92
|
+
btn.textContent = states.sent || "Sent";
|
|
93
|
+
break;
|
|
94
|
+
case "idle":
|
|
95
|
+
default:
|
|
96
|
+
btn.disabled = false;
|
|
97
|
+
btn.textContent = states.idle || "Send";
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function mount() {
|
|
103
|
+
var cfg = requireGlobal("FSE");
|
|
104
|
+
var fields = cfg.fields || {};
|
|
105
|
+
|
|
106
|
+
var formEl = document.querySelector(cfg.form);
|
|
107
|
+
if (!formEl) {
|
|
108
|
+
console.error("[fse/form] Form not found: " + cfg.form);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
var submitBtn = document.querySelector(cfg.submit);
|
|
113
|
+
if (!submitBtn) {
|
|
114
|
+
console.error("[fse/form] Submit button not found: " + cfg.submit);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
setButtonState(submitBtn, "idle", cfg);
|
|
119
|
+
|
|
120
|
+
formEl.addEventListener("submit", async function (e) {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
|
|
123
|
+
var hp = formEl.querySelector("[name='_hp']");
|
|
124
|
+
if (hp && hp.value) return;
|
|
125
|
+
|
|
126
|
+
clearFieldErrors(fields);
|
|
127
|
+
clearStatus(cfg);
|
|
128
|
+
|
|
129
|
+
var data = collectData(formEl, fields);
|
|
130
|
+
|
|
131
|
+
var result = FSEValidate.validate(data);
|
|
132
|
+
if (!result.valid) {
|
|
133
|
+
showFieldErrors(result.errors);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
setButtonState(submitBtn, "sending", cfg);
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
var payload = FSEPayload.build(data);
|
|
141
|
+
|
|
142
|
+
var ciphertext = await FSECrypto.sealJSON(
|
|
143
|
+
payload,
|
|
144
|
+
cfg.publicKey
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
var res = await fetch(cfg.endpoint, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { "Content-Type": "text/plain" },
|
|
150
|
+
credentials: "omit",
|
|
151
|
+
body: ciphertext,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (!res.ok) {
|
|
155
|
+
throw new Error("Server responded with " + res.status);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
var responseData = await res.json().catch(function () { return {}; });
|
|
159
|
+
|
|
160
|
+
setButtonState(submitBtn, "sent", cfg);
|
|
161
|
+
formEl.reset();
|
|
162
|
+
|
|
163
|
+
if (cfg.onSuccess.redirect) {
|
|
164
|
+
window.location.href = cfg.onSuccess.redirectUrl;
|
|
165
|
+
} else {
|
|
166
|
+
setStatus(cfg, cfg.onSuccess.message, false, responseData);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
} catch (err) {
|
|
170
|
+
console.error("[fse/form] Submit error:", err);
|
|
171
|
+
setButtonState(submitBtn, "idle", cfg);
|
|
172
|
+
setStatus(cfg, cfg.onError.message, true, null);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
mount,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
})();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// fse.payload.js
|
|
2
|
+
// Assembles the payload envelope { version, id, submitted_at, data }.
|
|
3
|
+
// No DOM access. No crypto. Depends on: FSE (global).
|
|
4
|
+
|
|
5
|
+
var FSEPayload = (function () {
|
|
6
|
+
|
|
7
|
+
function build(data) {
|
|
8
|
+
if (typeof FSE === "undefined") {
|
|
9
|
+
throw new Error("[fse/payload] FSE is not defined.");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
version: "fse.v1.0",
|
|
14
|
+
origin: FSE.origin || "contact-form",
|
|
15
|
+
id: crypto.randomUUID(),
|
|
16
|
+
submitted_at: new Date().toISOString(),
|
|
17
|
+
data: data,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
build,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
})();
|