From 5f737384650be293b936e6ed39fe9735949c74df Mon Sep 17 00:00:00 2001 From: Hannaeko Date: Mon, 9 Jun 2025 22:36:26 +0100 Subject: [PATCH] add record creation in ui --- assets/scripts/add-form-row.js | 49 ++++ assets/styles/main.css | 223 ++++++++++-------- locales/en/main.ftl | 1 + locales/fr/main.ftl | 3 +- src/form.rs | 8 +- src/proto/dns.rs | 1 - src/resources/dns/friendly/create.rs | 21 +- src/resources/dns/friendly/rdata.rs | 3 +- src/resources/dns/internal/record.rs | 3 + src/resources/zone.rs | 10 +- src/routes/ui/zones.rs | 44 ++-- templates/bases/app.html | 4 + .../pages/new_record/configure_record.html | 29 ++- 13 files changed, 253 insertions(+), 146 deletions(-) create mode 100644 assets/scripts/add-form-row.js diff --git a/assets/scripts/add-form-row.js b/assets/scripts/add-form-row.js new file mode 100644 index 0000000..fd3298e --- /dev/null +++ b/assets/scripts/add-form-row.js @@ -0,0 +1,49 @@ +function templateString(text, args) { + for (const argName in args) { + text = text.replace(`{${argName}}`, args[argName]); + } + + return text; +} + +function addFormRow(templateName) { + const allRows = document.querySelectorAll(`[data-new-item-template="${templateName}"]`); + const templateElement = allRows[0]; + const nextId = allRows.length + 1; + + const newItem = templateElement.cloneNode(true); + + newItem.querySelectorAll('[data-new-item-skip]') + .forEach(node => node.remove()); + + newItem.querySelectorAll('input') + .forEach(input => { input.value = '' }); + + const templatedAttrNodes = newItem.querySelectorAll('[data-new-item-template-attr]'); + for (const node of templatedAttrNodes) { + const attributes = node.dataset.newItemTemplateAttr.split(/\s/); + for (const attribute of attributes) { + const templatedString = node.getAttribute(`data-template-${attribute}`); + node.setAttribute(attribute, templateString(templatedString, {i: nextId})); + } + } + + const templatedNodes = newItem.querySelectorAll('[data-new-item-template-content]'); + for (const node of templatedNodes) { + const templatedString = node.dataset.newItemTemplateContent; + node.innerHTML = templateString(templatedString, {i: nextId}) + } + + allRows[allRows.length - 1].insertAdjacentElement('afterend', newItem); +} + +function setUpAddFormRow() { + const buttons = document.querySelectorAll('button[data-new-item]'); + + for (const button of buttons) { + button.addEventListener('click', () => addFormRow(button.dataset.newItem)) + } + +} + +document.addEventListener('DOMContentLoaded', setUpAddFormRow); diff --git a/assets/styles/main.css b/assets/styles/main.css index c947a75..a1eb21d 100644 --- a/assets/styles/main.css +++ b/assets/styles/main.css @@ -34,100 +34,106 @@ h2 { article.domain { margin-bottom: 2em; + + header { + display: flex; + align-items: center; + height: 3em; + + h3.folder-tab { + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + padding: 0 1rem; + border-top-left-radius: .3rem; + background-color: #f2e0fd; + margin: 0; + font-weight: inherit; + font-size: 1.3rem; + + ~ .sep { + content: ''; + width: 3em; + background-color: #f2e0fd; + height: 100%; + clip-path: url("#corner-folder-tab-right"); + flex-shrink: 0; + } + } + } } -article.domain header { - display: flex; - align-items: center; - height: 3em; -} - -article.domain header h3.folder-tab { - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - padding: 0 1rem; - border-top-left-radius: .3rem; - background-color: #f2e0fd; - margin: 0; - font-weight: inherit; - font-size: 1.3rem; -} - -article.domain header h3.folder-tab ~ .sep { - content: ''; - width: 3em; - background-color: #f2e0fd; - height: 100%; - clip-path: url("#corner-folder-tab-right"); - flex-shrink: 0; -} - -article.domain .records { +.records { background: #f2e0fd; padding: 1rem; border-radius: 0 .3rem .3rem .3rem; -} -article.domain .records h4 { - margin: 0; -} + button, + a.button { + background-color: #f2e0fd; + } -article.domain .records > ul { - margin: 0; - padding: 0; - list-style: none; -} + h4 { + margin: 0; + } -article.domain .records .rrset .rtype { - display: flex; - align-items: baseline; - gap: .5em; -} + > ul { + margin: 0; + padding: 0; + list-style: none; + } -article.domain .records .rrset ul { - padding: 1rem 0 1rem 2rem; - display: flex; - flex-direction: column; - gap: .5rem; -} + .rrset { + .rtype { + display: flex; + align-items: baseline; + gap: .5em; + } + ul { + padding: 1rem 0 1rem 2rem; + display: flex; + flex-direction: column; + gap: .5rem; + } -article.domain .records .rrset li { - align-items: baseline; - position: relative; - display: flex; - gap: .5rem; -} + li { + align-items: baseline; + position: relative; + display: flex; + gap: .5rem; + } -article.domain .records .rrset .rdata { - display: flex; - gap: .2rem; - flex-wrap: wrap; -} + .rdata { + display: flex; + gap: .2rem; + flex-wrap: wrap; + } -article.domain .records .rrset .rdata-main { - display: flex; - gap: .3rem; - margin-right: .1rem; -} + .rdata-main { + display: flex; + gap: .3rem; + margin-right: .1rem; -article.domain .records .rrset .rdata-main .pill { - background-color: white; -} + .pill { + background-color: white; + } + } -article.domain .records .rrset .rdata-complementary { - font-size: .9em; - gap: .2rem; - display: flex; -} + .rdata-complementary { + font-size: .9em; + gap: .2rem; + display: flex; + } -article.domain .records .rrset .action { - display: flex; - gap: .5rem; - position: relative; - top: .15rem; + .action { + display: flex; + gap: .5rem; + position: relative; + top: .15rem; + } + } } .pill { @@ -151,36 +157,27 @@ a.button { font-size: 1rem; cursor: pointer; transition: background-color .2s, color .2s; -} -button svg, -a.button svg { - height: 1em; - width: 1em; -} + svg { + height: 1em; + width: 1em; + } -.records button, -.records a.button { - background-color: #f2e0fd; -} + &.icon { + padding: 0; + width: 2em; + height: 2em; + } -button.icon, -a.button.icon { - padding: 0; - width: 2em; - height: 2em; -} - -button:hover, -button:focus-visible, -a.button:hover, -a.button:focus-visible { - color: white; - background-color: #850085; + &:hover, + &:focus-visible { + color: white; + background-color: #850085; + } } form h3 { - margin-top: 0; + margin: 0; } fieldset { @@ -208,7 +205,7 @@ textarea { flex-direction: column; gap: .5rem; flex-grow: 1; - margin-bottom: 1rem; + margin-top: 1rem; } .form-row { @@ -217,3 +214,23 @@ textarea { gap: 1rem; flex-wrap: wrap; } + +.form-action { + margin-top: 1rem; +} + +button.form-new-item { + background: none; + border: none; + padding: 0; + text-decoration: underline; + border-radius: 0; + color: #850085; + + &:hover, + &:focus-visible { + background: none; + text-decoration: none; + color: #850085; + } +} diff --git a/locales/en/main.ftl b/locales/en/main.ftl index 61d1f6f..cd5ef4e 100644 --- a/locales/en/main.ftl +++ b/locales/en/main.ftl @@ -37,3 +37,4 @@ record-input-addresses = address, like 2001:db8:cafe:bc68::2. button-save-configuration = Save configuration +button-add-address = Add an other address diff --git a/locales/fr/main.ftl b/locales/fr/main.ftl index d1147f2..756fd97 100644 --- a/locales/fr/main.ftl +++ b/locales/fr/main.ftl @@ -32,8 +32,9 @@ zone-content-new-record-button = Nouvel enregistrement record-input-addresses = .input-label = Adresse IP #{ $index } - .error-record-parse-ip = Format d'adresse IP inconnu. L'adresse IP doit être + .error-record-parse-ip = Format d’adresse IP inconnu. L’adresse IP doit être soit une adresse IPv4, comme 198.51.100.3, soit une adresse IPv6, comme 2001:db8:cafe:bc68::2. button-save-configuration = Sauvegarder la configuration +button-add-address = Ajouter une autre adresse diff --git a/src/form.rs b/src/form.rs index 20fc57c..ec541c3 100644 --- a/src/form.rs +++ b/src/form.rs @@ -111,19 +111,19 @@ impl Node { }).collect() } - pub fn to_json_value(self) -> serde_json::Value { + pub fn into_json_value(self) -> serde_json::Value { match self { Node::Value(value) => serde_json::Value::String(value), Node::Map(map) => { let map = map.into_iter() - .map(|(key, node)| (key, node.to_json_value())) + .map(|(key, node)| (key, node.into_json_value())) .collect(); serde_json::Value::Object(map) }, Node::Sequence(list) => { let array = list.to_vec() .into_iter() - .map(|node| node.to_json_value()) + .map(|node| node.into_json_value()) .collect(); serde_json::Value::Array(array) @@ -245,6 +245,6 @@ mod tests { ] }); - assert_eq!(parsed_form.to_json_value(), json_value); + assert_eq!(parsed_form.into_json_value(), json_value); } } diff --git a/src/proto/dns.rs b/src/proto/dns.rs index 8ac3c6b..148d55f 100644 --- a/src/proto/dns.rs +++ b/src/proto/dns.rs @@ -458,6 +458,5 @@ impl TryFrom for RecordImpl { let ttl = Ttl::from_secs(value.ttl); let data = value.rdata.try_into()?; Ok(Record::new(owner, Class::IN, ttl, data)) - } } diff --git a/src/resources/dns/friendly/create.rs b/src/resources/dns/friendly/create.rs index 37b1797..aeb42a3 100644 --- a/src/resources/dns/friendly/create.rs +++ b/src/resources/dns/friendly/create.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::validation; use crate::errors::Error; use crate::macros::{append_errors, check_type, get_object_value, push_error}; -use crate::resources::dns::internal::{self, Name}; +use crate::resources::dns::internal::{self, Name, Record}; use super::{rdata, FriendlyRType}; use super::FromValue; @@ -16,6 +16,21 @@ pub enum ConfigurationType { Web, } +impl ConfigurationType { + pub fn get_records(&self, input_data: serde_json::Value, default_ttl: u32, zone_name: Name) -> Result, Vec> { + match self { + ConfigurationType::Mail => { + NewSectionMail::from_value(input_data.clone()) + .map(|section| section.internal(default_ttl, zone_name)) + }, + ConfigurationType::Web => { + NewSectionWeb::from_value(input_data.clone()) + .map(|section| section.internal(default_ttl, zone_name)) + }, + } + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct NewRecordQuery { pub name: Option, @@ -59,7 +74,7 @@ pub struct NewRecord { } impl FromValue for NewRecord { - fn from_value(value: tera::Value) -> Result> { + fn from_value(value: serde_json::Value) -> Result> { let mut errors = Vec::new(); let object = check_type!(value, Object, errors); @@ -113,7 +128,7 @@ pub struct NewRequiredRecord { } impl FromValue for NewRequiredRecord { - fn from_value(value: tera::Value) -> Result> { + fn from_value(value: serde_json::Value) -> Result> { let mut errors = Vec::new(); let object = check_type!(value, Object, errors); diff --git a/src/resources/dns/friendly/rdata.rs b/src/resources/dns/friendly/rdata.rs index 8362b9b..d226190 100644 --- a/src/resources/dns/friendly/rdata.rs +++ b/src/resources/dns/friendly/rdata.rs @@ -304,11 +304,10 @@ impl FromValue for Spf { impl ToInternal for Spf { fn internal(self, ttl: u32, node_name: Name) -> Vec { - vec![ internal::Record { ttl, - name: Name::new(format!("_spf.{node_name}")), + name: node_name, rdata: internal::RData::Txt(internal::Txt::new(self.policy)) } ] diff --git a/src/resources/dns/internal/record.rs b/src/resources/dns/internal/record.rs index 0c8e6c8..c89687a 100644 --- a/src/resources/dns/internal/record.rs +++ b/src/resources/dns/internal/record.rs @@ -14,6 +14,9 @@ pub struct RecordList { } impl RecordList { + pub fn new(records: &[Record]) -> Self { + RecordList { records: records.into() } + } pub fn sort(&mut self) { self.records.sort_by(|r1, r2| { let key1 = (&r1.name, r1.rdata.rtype()); diff --git a/src/resources/zone.rs b/src/resources/zone.rs index e719b31..a1f900b 100644 --- a/src/resources/zone.rs +++ b/src/resources/zone.rs @@ -1,4 +1,6 @@ use async_trait::async_trait; +use axum::response::Response; +use domain::base::record; use serde::{Deserialize, Serialize}; use rusqlite::Error as RusqliteError; @@ -7,7 +9,7 @@ use crate::database::{BoxedDb, sqlite::SqliteDB}; use crate::dns::{BoxedZoneDriver, BoxedRecordDriver, DnsDriverError}; use crate::errors::Error; use crate::macros::push_error; -use crate::resources::dns::internal::RecordList; +use crate::resources::dns::internal::{self, RecordList}; use crate::validation; pub enum ZoneError { @@ -48,6 +50,12 @@ impl Zone { Ok(records) } + + pub async fn add_records(&self, record_driver: BoxedRecordDriver, records: internal::RecordList) -> Result<(), Error> { + record_driver.add_records(&self.name, records).await?; + + Ok(()) + } } #[derive(Deserialize)] diff --git a/src/routes/ui/zones.rs b/src/routes/ui/zones.rs index 46bf01a..fee8f6b 100644 --- a/src/routes/ui/zones.rs +++ b/src/routes/ui/zones.rs @@ -1,4 +1,6 @@ use axum::extract::{Query, Path, State, OriginalUri}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; use axum::Extension; use serde_json::{Value, json}; use unic_langid::LanguageIdentifier; @@ -8,7 +10,7 @@ use crate::macros::append_errors; use crate::AppState; use crate::errors::{Error, error_map}; use crate::template::Template; -use crate::resources::dns::friendly::{self, NewRecordQuery, ConfigurationType, FromValue, NewSectionMail, NewSectionWeb, ToInternal}; +use crate::resources::dns::friendly::{self, NewRecordQuery}; use crate::resources::dns::internal; pub async fn get_records_page( @@ -68,33 +70,21 @@ pub async fn post_new_record( OriginalUri(url): OriginalUri, Extension(lang): Extension, form: Node, -) -> Result, Error> { +) -> Result { let zone = app.db.get_zone_by_name(&zone_name).await?; let mut errors = Vec::new(); append_errors!(params.validate(&zone.name), errors); if !errors.is_empty() || params.name.is_none() || !(params.config.is_none() ^ params.config.is_some()) { - // TODO: return 404 - todo!() + return Ok(StatusCode::NOT_FOUND.into_response()); } - let name = params.name.clone().unwrap(); - let input_data = form.to_json_value(); - - let new_records = if errors.is_empty() { - let name = internal::Name::new(name); + let name = params.name.clone().map(internal::Name::new).unwrap(); + let input_data = form.into_json_value(); + let new_records = { let new_records = if let Some(config_type) = params.config.clone() { - match config_type { - ConfigurationType::Mail => { - NewSectionMail::from_value(input_data.clone()) - .map(|section| section.internal(3600, name)) - }, - ConfigurationType::Web => { - NewSectionWeb::from_value(input_data.clone()) - .map(|section| section.internal(3600, name)) - }, - } + config_type.get_records(input_data.clone(), 3600, name) } else if let Some(_rtype) = params.rtype { unimplemented!() } else { @@ -102,11 +92,16 @@ pub async fn post_new_record( }; append_errors!(new_records, errors) - } else { - None }; - if !errors.is_empty() { + if let Some(new_records) = new_records { + zone.add_records( + app.records, + internal::RecordList::new(&new_records) + ).await?; + + Ok(().into_response()) + } else { Ok(Template::new( "pages/new_record.html", app.template_engine, @@ -120,10 +115,7 @@ pub async fn post_new_record( "url": url.to_string(), "lang": lang.to_string(), }) - )) - } else { - println!("{:#?}", new_records); - todo!() + ).into_response()) } } diff --git a/templates/bases/app.html b/templates/bases/app.html index 5650668..38deab6 100644 --- a/templates/bases/app.html +++ b/templates/bases/app.html @@ -12,3 +12,7 @@ {% block main %}{% endblock main %} {% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/templates/pages/new_record/configure_record.html b/templates/pages/new_record/configure_record.html index 80143f4..0be6d47 100644 --- a/templates/pages/new_record/configure_record.html +++ b/templates/pages/new_record/configure_record.html @@ -14,19 +14,27 @@ {% for address in input_data.addresses.data.addresses | default(value=[""]) %} {% set address_error = errors | get(key="/addresses/data/addresses/" ~ loop.index0 ~ "/address", default="") %} -
-