Separate field inputs from fields + other small fixes

This commit is contained in:
Reimar 2026-01-16 11:54:09 +01:00
parent f8e10f32d0
commit 8730b7c199
Signed by: Reimar
GPG Key ID: 93549FA07F0AE268
17 changed files with 167 additions and 86 deletions

View File

@ -1,16 +1,22 @@
import { FieldInput } from "./FieldInput.js";
/**
* Defines any key-value pair in any type of DNS-record
* Used for input fields on DNS creator pages
* Represents any key-value pair in any type of DNS-record
* Used for validation and to create input fields on DNS creator pages
*/
export class Field {
displayName = null;
description = null;
categoryName = null;
isHidden = false;
isDisabled = false;
constructor(key) {
this.key = key;
this.id = "field-" + key;
}
createInput(parentElem) {
return new FieldInput(this, parentElem);
}
// Virtual methods
@ -44,4 +50,9 @@ export class Field {
this.isHidden = true;
return this;
}
disabled() {
this.isDisabled = true;
return this;
}
}

View File

@ -0,0 +1,29 @@
/**
* Represents the actual input element on the creator tool
* A field may have multiple inputs if it allows multiple values
*/
export class FieldInput {
constructor(field, parentElem) {
this.field = field;
this.id = "field-" + field.key;
if (!field.isHidden)
parentElem.innerHTML += `
<label for="${field.key}">${field.displayName}</label>
<p class="description">${field.description ?? ""}</p>
${field.getInputHtml(this.id)}
`;
}
isValid() {
return this.field.isValidInput(this.id);
}
getValue() {
return this.field.getInputValue(this.id);
}
toString() {
return this.field.inputToString(this.id);
}
}

View File

@ -9,6 +9,7 @@ export class DmarcRecord extends TagListRecord {
static fields = [
new ConstantTag("v", "DMARC1")
.required()
.hidden()
.pos(0),
new EnumTag("p", ["none", "quarantine", "reject"])
@ -77,7 +78,9 @@ export class DmarcRecord extends TagListRecord {
.default(86400)
.pos(2),
new Tag("rf").default("afrf"), // Other values not supported
new Tag("rf")
.disabled()
.default("afrf"), // Other values not supported
];
static categories = {

View File

@ -11,46 +11,47 @@ export class SpfRecord {
static fields = [
new VersionTerm("v", "spf1")
.required()
.hidden()
.pos(0),
new DomainMechanism("include")
.label("Include")
.desc("Also apply the SPF records from these domains")
.multiple()
.pos(1),
new DomainMechanism("a")
.label("Domains")
.desc("Select the IP address from these domains (or current domain if none specified)")
.desc("Match the IP addresses from the A records of these domains (or current domain if none specified)")
.value(ValueRequirement.OPTIONAL)
.multiple()
.pos(1),
new DomainMechanism("mx")
.label("MX Records")
.desc("Select the IP addresses from the MX records of these domains (or current domain if none specified)")
.desc("Match the IP addresses from the MX records of these domains (or current domain if none specified)")
.value(ValueRequirement.OPTIONAL)
.multiple()
.pos(2),
new DomainMechanism("ptr")
.hidden()
.disabled()
.value(ValueRequirement.OPTIONAL)
.multiple()
.pos(2),
new IPv4Mechanism("ipv4")
new IPv4Mechanism("ip4")
.label("IPv4 addresses")
.desc("Select these IP addresses")
.desc("Match these IP addresses")
.multiple()
.pos(2),
new IPv6Mechanism("ipv6")
new IPv6Mechanism("ip6")
.label("IPv6 addresses")
.desc("Select these IP addresses")
.desc("Match these IP addresses")
.multiple()
.pos(2),
new DomainMechanism("include")
.label("Include")
.desc("Check the SPF record of another domain. If it passes, return with the selected result")
.multiple()
.pos(1),
new DomainMechanism("exists")
.label("Exists")
.desc("Apply only if this domain exists (can be used with macro expansions)")
@ -66,7 +67,7 @@ export class SpfRecord {
new Modifier("redirect")
.label("Redirect")
.desc("Redirect to the SPF record of this domain if no IP addresses matched")
.desc("Redirect to the SPF record of this domain if no matches were found")
.category("advanced")
.pos(4),
@ -85,6 +86,16 @@ export class SpfRecord {
this.text = text;
}
static createFromFieldInputs(fieldInputs) {
const tokens = fieldInputs
.filter(input => !input.field.isDisabled && input.isValid())
.map(input => input.toString());
const text = tokens.join(" ");
return new this(text);
}
tokenize() {
return this.text.trim().split(/\s+/);
}
@ -150,17 +161,4 @@ export class SpfRecord {
lastPos = term.position;
}
}
static fieldsToString() {
const tokens = this.fields
.filter(field => !field.isHidden)
.filter(field => field.valueRequirement === ValueRequirement.REQUIRED ? field.getInputValue() : field.getInputQualifier())
.map(field =>
field.getInputQualifier()
+ field.key
+ (field.getInputValue() ? field.separator + field.getInputValue() : "")
);
return tokens.join(" ");
}
}

View File

@ -8,6 +8,17 @@ export class TagListRecord {
this.text = text;
}
static createFromFieldInputs(fieldInputs) {
const tokens = fieldInputs
.filter(input => !input.field.isDisabled && input.isValid())
.filter(input => !input.field.defaultValue || input.getValue() !== input.field.defaultValue)
.map(input => input.toString());
const text = tokens.join("; ");
return new this(text);
}
tokenize() {
return this.text
.replace(/;\s*$/, "")
@ -61,14 +72,4 @@ export class TagListRecord {
return true;
}
static fieldsToString() {
const tokens = this.fields
.filter(field => !field.isHidden)
.filter(field => !field.defaultValue || field.getInputValue() !== field.defaultValue)
.filter(field => field.getInputValue())
.map(field => field.key + "=" + field.getInputValue());
return tokens.join("; ");
}
}

View File

@ -9,6 +9,6 @@ export class IPv6Mechanism extends Mechanism {
validate(value) {
// TODO validate
return true;
return !!value;
}
}

View File

@ -9,28 +9,28 @@ export class Mechanism extends Term {
super(key);
}
getInputQualifier() {
return document.getElementById(this.id + "-qualifier").value;
getInputQualifier(id) {
return document.getElementById(id + "-qualifier").value;
}
getInputValue() {
return document.getElementById(this.id + "-value")?.value;
getInputValue(id) {
return document.getElementById(id + "-value")?.value;
}
getInputHtml() {
getInputHtml(id) {
const noValue = this.valueRequirement === ValueRequirement.PROHIBITED;
const placeholder = this.placeholder
+ (this.valueRequirement === ValueRequirement.OPTIONAL ? " (Optional)" : "");
return `
<select id="${this.id}-qualifier">
<select id="${id}-qualifier">
${this.isRequired ? "" : `<option value="">&lt;not set&gt;</option>`}
<option value="+">Pass</option>
<option value="-">Fail</option>
<option value="~">Soft fail</option>
<option value="?">Neutral</option>
</select>
${noValue ? "" : `<input id="${this.id}-value" type="text" placeholder="${placeholder}">`}
${noValue ? "" : `<input id="${id}-value" type="text" placeholder="${placeholder}">`}
`;
}
}

View File

@ -15,11 +15,11 @@ export class Modifier extends Term {
return true;
}
getInputValue() {
return document.getElementById(this.id).value;
getInputValue(id) {
return document.getElementById(id).value;
}
getInputHtml() {
return `<input id="${this.id}" type="text" placeholder="example.com">`;
getInputHtml(id) {
return `<input id="${id}" type="text" placeholder="example.com">`;
}
}

View File

@ -12,9 +12,31 @@ export class Term extends Field {
super(key)
}
inputToString(fieldInputId) {
const input = this.getInputValue(fieldInputId);
const qualifier = this.getInputQualifier(fieldInputId);
return qualifier + this.key + (input ? this.separator + input : "");
}
isValidInput(fieldInputId) {
const input = this.getInputValue(fieldInputId);
const qualifier = this.getInputQualifier(fieldInputId);
if (this.valueRequirement !== ValueRequirement.REQUIRED && qualifier)
return true;
try {
return this.validate(input);
} catch(e) {
return false;
}
}
// Virtual methods
getInputQualifier() {
getInputQualifier(fieldId) {
return "";
}

View File

@ -15,7 +15,7 @@ export class VersionTerm extends Term {
return true;
}
getInputValue() {
getInputValue(id) {
return this.version;
}
}

View File

@ -8,10 +8,12 @@ export class ConstantTag extends Tag {
}
validate(value) {
if (this.value !== value) throw new ValidationError(`Field ${this.key} must be "${this.value}"`)
if (this.value !== value) throw new ValidationError(`Field ${this.key} must be "${this.value}"`);
return true;
}
getInputValue() {
getInputValue(id) {
return this.value;
}
}

View File

@ -22,15 +22,13 @@ export class DmarcUriListTag extends Tag {
return true;
}
getInputHtml() {
return `<input id="${this.id}" type="email" name="${this.key}" placeholder="mail@example.com">`;
getInputHtml(id) {
return `<input id="${id}" type="email" name="${this.key}" placeholder="mail@example.com">`;
}
getInputValue() {
if (!document.getElementById(this.id).value) {
return null;
}
getInputValue(id) {
if (!document.getElementById(id).value) return null;
return "mailto:" + document.getElementById(this.id).value;
return "mailto:" + document.getElementById(id).value;
}
}

View File

@ -15,8 +15,8 @@ export class EnumTag extends Tag {
throw new ValidationError(`Invalid value for tag "${this.key}" - must be one of: ${this.values.join(", ")}`);
}
getInputHtml() {
return `<select id="${this.id}" name="${this.key}" ${this.isRequired ? "required" : ""}>` +
getInputHtml(id) {
return `<select id="${id}" name="${this.key}" ${this.isRequired ? "required" : ""}>` +
(this.isRequired || this.defaultValue ? "" : `<option value="" selected>&lt;not set&gt;</option>`) +
this.values.map((value, i) =>
`<option value="${value}" ${this.defaultValue === value ? "selected" : ""}>
@ -26,8 +26,8 @@ export class EnumTag extends Tag {
`</select>`;
}
getInputValue() {
return document.getElementById(this.id).value;
getInputValue(id) {
return document.getElementById(id).value;
}
options(options) {

View File

@ -22,11 +22,11 @@ export class IntTag extends Tag {
return true;
}
getInputHtml() {
return `<input id="${this.id}" type="number" name="${this.key}" min="${this.min}" max="${this.max}" ${this.isRequired ? "required" : ""} placeholder="${this.defaultValue ?? ""}">`;
getInputHtml(id) {
return `<input id="${id}" type="number" name="${this.key}" min="${this.min}" max="${this.max}" ${this.isRequired ? "required" : ""} placeholder="${this.defaultValue ?? ""}">`;
}
getInputValue() {
return document.getElementById(this.id).value;
getInputValue(id) {
return document.getElementById(id).value;
}
}

View File

@ -10,6 +10,22 @@ export class Tag extends Field {
super(key);
}
inputToString(fieldInputId) {
const input = this.getInputValue(fieldInputId);
return `${this.key}=${input}`;
}
isValidInput(fieldInputId) {
const input = this.getInputValue(fieldInputId);
try {
return this.validate(input);
} catch(e) {
return false;
}
}
// Virtual methods
validate() {

View File

@ -8,7 +8,9 @@ const records = {
const Record = records[location.pathname];
addFields(document.getElementById("form"), Record.fields.filter(field => !field.categoryName));
const inputs = [];
addInputs(document.getElementById("form"), Record.fields.filter(field => !field.categoryName));
const categories = Record.fields.map(field => field.categoryName).filter(isUnique).filter(val => val);
@ -18,26 +20,25 @@ for (const category of categories) {
document.getElementById("form").appendChild(details);
addFields(details, Record.fields.filter(field => field.categoryName === category));
addInputs(details, Record.fields.filter(field => field.categoryName === category));
}
function addFields(elem, fields) {
function addInputs(elem, fields) {
for (const field of fields) {
if (field.isHidden || !field.getInputHtml()) continue;
if (field.isDisabled) continue;
elem.innerHTML += `
<label for="${field.key}">${field.displayName}</label>
<p class="description">${field.description ?? ""}</p>
${field.getInputHtml()}
`;
const input = field.createInput(elem);
inputs.push(input);
}
}
document.getElementById("form").onchange = generate;
generate();
function generate(event) {
document.getElementById("record").value = Record.fieldsToString();
function generate() {
const record = Record.createFromFieldInputs(inputs);
document.getElementById("record").value = record.text;
}
document.getElementById("record").onclick = (e) => {

View File

@ -22,9 +22,9 @@
</p>
<p>
<b>Pass</b> (Default): Selected IP addresses are authorized to send emails from this domain<br>
<b>Fail</b>: Selected IP addresses are NOT authorized to send emails from this domain<br>
<b>Soft fail</b>: Selected IP addresses might not be authorized to send emails from this domain (actual behavior differs)<br>
<b>Pass</b> (Default): Matching IP addresses are authorized to send emails from this domain<br>
<b>Fail</b>: Matching IP addresses are NOT authorized to send emails from this domain<br>
<b>Soft fail</b>: Matching IP addresses might not be authorized to send emails from this domain (actual behavior differs)<br>
<b>Neutral</b>: Do not explicitly state whether the IP addresses are authorized or not (can be used for overriding other qualifiers)
</p>