2
0
mirror of https://github.com/VinylDNS/vinyldns synced 2025-08-30 13:58:15 +00:00

Merge branch 'master' into disallow_ipv4_in_cname

This commit is contained in:
Jay
2022-09-22 12:39:39 +05:30
committed by GitHub
23 changed files with 360 additions and 18 deletions

View File

@@ -28,6 +28,10 @@ import scala.util.matching.Regex
*/
object DomainValidations {
val validReverseZoneFQDNRegex: Regex =
"""^(?:([0-9a-zA-Z\-\/_]{1,63}|[0-9a-zA-Z\-\/_]{1}[0-9a-zA-Z\-\/_]{0,61}[0-9a-zA-Z\-\/_]{1}|[*.]{2}[0-9a-zA-Z\-\/_]{0,60}[0-9a-zA-Z\-\/_]{1})\.)*$""".r
val validForwardZoneFQDNRegex: Regex =
"""^(?:([0-9a-zA-Z_]{1,63}|[0-9a-zA-Z_]{1}[0-9a-zA-Z\-_]{0,61}[0-9a-zA-Z_]{1}|[*.]{2}[0-9a-zA-Z\-_]{0,60}[0-9a-zA-Z_]{1})\.)*$""".r
val validFQDNRegex: Regex =
"""^(?:([0-9a-zA-Z_]{1,63}|[0-9a-zA-Z_]{1}[0-9a-zA-Z\-\/_]{0,61}[0-9a-zA-Z_]{1}|[*.]{2}[0-9a-zA-Z\-\/_]{0,60}[0-9a-zA-Z_]{1})\.)*$""".r
val validIpv4Regex: Regex =
@@ -68,6 +72,30 @@ object DomainValidations {
def validateHostName(name: Fqdn): ValidatedNel[DomainValidationError, Fqdn] =
validateHostName(name.fqdn).map(_ => name)
def validateCname(name: Fqdn, isReverse: Boolean): ValidatedNel[DomainValidationError, Fqdn] =
validateCname(name.fqdn, isReverse).map(_ => name)
def validateCname(name: String, isReverse: Boolean): ValidatedNel[DomainValidationError, String] = {
isReverse match {
case true =>
val checkRegex = validReverseZoneFQDNRegex
.findFirstIn(name)
.map(_.validNel)
.getOrElse(InvalidCname(name,isReverse).invalidNel)
val checkLength = validateStringLength(name, Some(HOST_MIN_LENGTH), HOST_MAX_LENGTH)
checkRegex.combine(checkLength).map(_ => name)
case false =>
val checkRegex = validForwardZoneFQDNRegex
.findFirstIn(name)
.map(_.validNel)
.getOrElse(InvalidCname(name,isReverse).invalidNel)
val checkLength = validateStringLength(name, Some(HOST_MIN_LENGTH), HOST_MAX_LENGTH)
checkRegex.combine(checkLength).map(_ => name)
}
}
def validateHostName(name: String): ValidatedNel[DomainValidationError, String] = {
/*
Label rules are as follows (from RFC 952; detailed in RFC 1034):
@@ -93,6 +121,8 @@ object DomainValidations {
checkRegex.combine(checkLength).map(_ => name)
}
def validateIpv4Address(address: String): ValidatedNel[DomainValidationError, String] =
validIpv4Regex
.findFirstIn(address)

View File

@@ -211,7 +211,7 @@ class BatchChangeValidations(
isApproved: Boolean
): SingleValidation[Unit] = {
val validTTL = addChangeInput.ttl.map(validateTTL(_).asUnit).getOrElse(().valid)
val validRecord = validateRecordData(addChangeInput.record)
val validRecord = validateRecordData(addChangeInput.record, addChangeInput)
val validInput = validateInputName(addChangeInput, isApproved)
validTTL |+| validRecord |+| validInput
@@ -222,7 +222,7 @@ class BatchChangeValidations(
isApproved: Boolean
): SingleValidation[Unit] = {
val validRecord = deleteRRSetChangeInput.record match {
case Some(recordData) => validateRecordData(recordData)
case Some(recordData) => validateRecordData(recordData, deleteRRSetChangeInput)
case None => ().validNel
}
val validInput = validateInputName(deleteRRSetChangeInput, isApproved)
@@ -230,11 +230,18 @@ class BatchChangeValidations(
validRecord |+| validInput
}
def validateRecordData(record: RecordData): SingleValidation[Unit] =
def validateRecordData(record: RecordData,change: ChangeInput): SingleValidation[Unit] =
record match {
case a: AData => validateIpv4Address(a.address).asUnit
case aaaa: AAAAData => validateIpv6Address(aaaa.address).asUnit
case cname: CNAMEData => validateCName(cname.cname).asUnit
case cname: CNAMEData =>
/*
To validate the zone is reverse
*/
val isIPv4: Boolean = change.inputName.toLowerCase.endsWith("in-addr.arpa.")
val isIPv6: Boolean = change.inputName.toLowerCase.endsWith("ip6.arpa.")
val isReverse: Boolean = isIPv4 || isIPv6
validateCname(cname.cname,isReverse).asUnit
case ptr: PTRData => validateHostName(ptr.ptrdname).asUnit
case txt: TXTData => validateTxtTextLength(txt.text).asUnit
case mx: MXData =>

View File

@@ -171,6 +171,8 @@ final case class GroupNotFoundError(msg: String) extends Throwable(msg)
final case class GroupAlreadyExistsError(msg: String) extends Throwable(msg)
final case class GroupValidationError(msg: String) extends Throwable(msg)
final case class UserNotFoundError(msg: String) extends Throwable(msg)
final case class InvalidGroupError(msg: String) extends Throwable(msg)

View File

@@ -57,6 +57,7 @@ class MembershipService(
val adminMembers = inputGroup.adminUserIds
val nonAdminMembers = inputGroup.memberIds.diff(adminMembers)
for {
_ <- groupValidation(newGroup)
_ <- hasMembersAndAdmins(newGroup).toResult
_ <- groupWithSameNameDoesNotExist(newGroup.name)
_ <- usersExist(newGroup.memberIds)
@@ -76,6 +77,7 @@ class MembershipService(
for {
existingGroup <- getExistingGroup(groupId)
newGroup = existingGroup.withUpdates(name, email, description, memberIds, adminUserIds)
_ <- groupValidation(newGroup)
_ <- canEditGroup(existingGroup, authPrincipal).toResult
addedAdmins = newGroup.adminUserIds.diff(existingGroup.adminUserIds)
// new non-admin members ++ admins converted to non-admins
@@ -218,7 +220,7 @@ class MembershipService(
.map(x => GroupInfo.fromGroup(x, abridged, Some(authPrincipal)))
val filtered = allMyGroups
.filter(grp => groupNameFilter.forall(grp.name.contains(_)))
.filter(grp => groupNameFilter.map(_.toLowerCase).forall(grp.name.toLowerCase.contains(_)))
.filter(grp => startFrom.forall(grp.id > _))
val nextId = if (filtered.length > maxItems) Some(filtered(maxItems - 1).id) else None
@@ -277,6 +279,16 @@ class MembershipService(
.orFail(GroupNotFoundError(s"Group with ID $groupId was not found"))
.toResult[Group]
// Validate group details. Group name and email cannot be empty
def groupValidation(group: Group): Result[Unit] = {
Option(group) match {
case Some(value) if Option(value.name).forall(_.trim.isEmpty) || Option(value.email).forall(_.trim.isEmpty) =>
GroupValidationError(GroupValidationErrorMsg).asLeft
case _ =>
().asRight
}
}.toResult
def groupWithSameNameDoesNotExist(name: String): Result[Unit] =
groupRepo
.getGroupByName(name)

View File

@@ -45,6 +45,7 @@ class MembershipRoute(
case GroupNotFoundError(msg) => complete(StatusCodes.NotFound, msg)
case NotAuthorizedError(msg) => complete(StatusCodes.Forbidden, msg)
case GroupAlreadyExistsError(msg) => complete(StatusCodes.Conflict, msg)
case GroupValidationError(msg) => complete(StatusCodes.BadRequest, msg)
case InvalidGroupError(msg) => complete(StatusCodes.BadRequest, msg)
case UserNotFoundError(msg) => complete(StatusCodes.NotFound, msg)
case InvalidGroupRequestError(msg) => complete(StatusCodes.BadRequest, msg)

View File

@@ -1975,7 +1975,7 @@ def test_cname_recordtype_add_checks(shared_zone_test_context):
error_messages=['Invalid TTL: "29", must be a number between 30 and 2147483647.',
f'Invalid domain name: "bad-ttl-and-invalid-name$.{parent_zone_name}", '
"valid domain names must be letters, numbers, underscores, and hyphens, joined by dots, and terminated with a dot.",
'Invalid domain name: "also$bad.name.", valid domain names must be letters, numbers, underscores, and hyphens, '
'Invalid Cname: "also$bad.name.", valid cnames must be letters, numbers, underscores, and hyphens, '
"joined by dots, and terminated with a dot."])
# zone discovery failure
assert_failed_change_in_error_response(response[7], input_name="no.zone.com.", record_type="CNAME",
@@ -2138,7 +2138,7 @@ def test_cname_recordtype_update_delete_checks(shared_zone_test_context):
error_messages=['Invalid TTL: "20", must be a number between 30 and 2147483647.',
'Invalid domain name: "$another.invalid.host.name.", valid domain names must be letters, numbers, '
'underscores, and hyphens, joined by dots, and terminated with a dot.',
'Invalid domain name: "$another.invalid.cname.", valid domain names must be letters, numbers, '
'Invalid Cname: "$another.invalid.cname.", valid cnames must be letters, numbers, '
'underscores, and hyphens, joined by dots, and terminated with a dot.'])
# zone discovery failure

View File

@@ -19,11 +19,10 @@ package vinyldns.api.domain
import cats.scalatest.ValidatedMatchers
import org.scalacheck._
import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks
import org.scalatest._
import org.scalatest.propspec.AnyPropSpec
import org.scalatest.matchers.should.Matchers
import vinyldns.api.ValidationTestImprovements._
import vinyldns.core.domain.{Fqdn, InvalidDomainName, InvalidLength}
import vinyldns.core.domain.{InvalidDomainName, Fqdn, InvalidCname, InvalidLength}
class DomainValidationsSpec
extends AnyPropSpec
@@ -160,4 +159,52 @@ class DomainValidationsSpec
val invalidDesc = "a" * 256
validateStringLength(Some(invalidDesc), None, 255).failWith[InvalidLength]
}
property("Shortest cname should be valid") {
validateCname("a.",true) shouldBe valid
validateCname("a.",false) shouldBe valid
}
property("Longest cname should be valid") {
val name = ("a" * 50 + ".") * 5
validateCname(name,true) shouldBe valid
validateCname(name,false) shouldBe valid
}
property("Cnames with underscores should pass property-based testing") {
validateCname("_underscore.domain.name.",true).isValid
validateCname("under_score.domain.name.",true).isValid
validateCname("underscore._domain.name.",true).isValid
validateCname("_underscore.domain.name.",false).isValid
validateCname("under_score.domain.name.",false).isValid
validateCname("underscore._domain.name.",false).isValid
}
// For wildcard records. '*' can only be in the beginning followed by '.' and domain name
property("Cnames beginning with asterisk should pass property-based testing") {
validateCname("*.domain.name.",true) shouldBe valid
validateCname("aste*risk.domain.name.",true) shouldBe invalid
validateCname("*asterisk.domain.name.",true) shouldBe invalid
validateCname("asterisk*.domain.name.",true) shouldBe invalid
validateCname("asterisk.*domain.name.",true) shouldBe invalid
validateCname("asterisk.domain*.name.",true) shouldBe invalid
validateCname("*.domain.name.",false) shouldBe valid
validateCname("aste*risk.domain.name.",false) shouldBe invalid
validateCname("*asterisk.domain.name.",false) shouldBe invalid
validateCname("asterisk*.domain.name.",false) shouldBe invalid
validateCname("asterisk.*domain.name.",false) shouldBe invalid
validateCname("asterisk.domain*.name.",false) shouldBe invalid
}
property("Cname names with forward slash should pass with reverse zone") {
validateCname("/slash.cname.name.",true).isValid
validateCname("slash./cname.name.",true).isValid
validateCname("slash.cname./name.",true).isValid
}
property("Cname names with forward slash should fail with forward zone") {
validateCname("/slash.cname.name.",false).failWith[InvalidCname]
validateCname("slash./cname.name.",false).failWith[InvalidCname]
validateCname("slash.cname./name.",false).failWith[InvalidCname]
}
}

View File

@@ -712,7 +712,7 @@ class BatchChangeValidationsSpec
)
val result = validateAddChangeInput(change, false)
result should haveInvalid[DomainValidationError](InvalidDomainName(s"$invalidCNAMERecordData."))
result should haveInvalid[DomainValidationError](InvalidCname(s"$invalidCNAMERecordData.",false))
}
property("""validateAddChangeInput: should fail with Invalid CNAME
@@ -2681,4 +2681,21 @@ class BatchChangeValidationsSpec
result(3) shouldBe valid
result(4) shouldBe valid
}
property("validateAddChangeInput: should fail for a CNAME addChangeInput with forward slash for forward zone") {
val cnameWithForwardSlash = AddChangeInput("cname.ok.", RecordType.CNAME, ttl, CNAMEData(Fqdn("cname/")))
val result = validateAddChangeInput(cnameWithForwardSlash, false)
result should haveInvalid[DomainValidationError](InvalidCname("cname/.",false))
}
property("validateAddChangeInput: should succeed for a valid CNAME addChangeInput without forward slash for forward zone") {
val cname = AddChangeInput("cname.ok.", RecordType.CNAME, ttl, CNAMEData(Fqdn("cname")))
val result = validateAddChangeInput(cname, false)
result shouldBe valid
}
property("validateAddChangeInput: should succeed for a valid CNAME addChangeInput with forward slash for reverse zone") {
val cnameWithForwardSlash = AddChangeInput("2.0.192.in-addr.arpa.", RecordType.CNAME, ttl, CNAMEData(Fqdn("cname/")))
val result = validateAddChangeInput(cnameWithForwardSlash, true)
result shouldBe valid
}
}

View File

@@ -114,6 +114,7 @@ class MembershipServiceSpec
"create a new group" should {
"save the group and add the members when the group is valid" in {
doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok")
doReturn(().toResult).when(underTest).groupValidation(groupInfo)
doReturn(().toResult).when(underTest).groupWithSameNameDoesNotExist(groupInfo.name)
doReturn(().toResult).when(underTest).usersExist(groupInfo.memberIds)
doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group])
@@ -141,6 +142,7 @@ class MembershipServiceSpec
"save the groupChange in the groupChangeRepo" in {
doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok")
doReturn(().toResult).when(underTest).groupValidation(groupInfo)
doReturn(().toResult).when(underTest).groupWithSameNameDoesNotExist(groupInfo.name)
doReturn(().toResult).when(underTest).usersExist(groupInfo.memberIds)
doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group])
@@ -168,7 +170,7 @@ class MembershipServiceSpec
adminUserIds = Set(okUserInfo.id, dummyUserInfo.id)
)
val expectedMembersAdded = Set(okUserInfo.id, dummyUserInfo.id)
doReturn(().toResult).when(underTest).groupValidation(info)
doReturn(().toResult).when(underTest).groupWithSameNameDoesNotExist(info.name)
doReturn(().toResult).when(underTest).usersExist(any[Set[String]])
doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group])
@@ -196,6 +198,7 @@ class MembershipServiceSpec
"set the current user as a member" in {
val info = groupInfo.copy(memberIds = Set.empty, adminUserIds = Set.empty)
doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok")
doReturn(().toResult).when(underTest).groupValidation(info)
doReturn(().toResult).when(underTest).groupWithSameNameDoesNotExist(info.name)
doReturn(().toResult).when(underTest).usersExist(Set(okAuth.userId))
doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group])
@@ -224,6 +227,7 @@ class MembershipServiceSpec
"return an error if users do not exist" in {
doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok")
doReturn(().toResult).when(underTest).groupValidation(groupInfo)
doReturn(().toResult).when(underTest).groupWithSameNameDoesNotExist(groupInfo.name)
doReturn(result(UserNotFoundError("fail")))
.when(underTest)
@@ -239,6 +243,7 @@ class MembershipServiceSpec
"return an error if fail while saving the group" in {
doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok")
doReturn(().toResult).when(underTest).groupValidation(groupInfo)
doReturn(().toResult).when(underTest).groupWithSameNameDoesNotExist(groupInfo.name)
doReturn(().toResult).when(underTest).usersExist(groupInfo.memberIds)
doReturn(IO.raiseError(new RuntimeException("fail"))).when(mockGroupRepo).save(any[DB], any[Group])
@@ -253,6 +258,7 @@ class MembershipServiceSpec
"return an error if fail while adding the members" in {
doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok")
doReturn(().toResult).when(underTest).groupValidation(groupInfo)
doReturn(().toResult).when(underTest).groupWithSameNameDoesNotExist(groupInfo.name)
doReturn(().toResult).when(underTest).usersExist(groupInfo.memberIds)
doReturn(IO.pure(okGroup)).when(mockGroupRepo).save(any[DB], any[Group])
@@ -264,6 +270,20 @@ class MembershipServiceSpec
val error = leftResultOf(underTest.createGroup(groupInfo, okAuth).value)
error shouldBe a[RuntimeException]
}
"return an error if group name and/or email is empty" in {
doReturn(IO.pure(Some(okUser))).when(mockUserRepo).getUser("ok")
doReturn(result(GroupValidationError("fail")))
.when(underTest)
.groupValidation(groupInfo.copy(name = "", email = ""))
val error = leftResultOf(underTest.createGroup(groupInfo.copy(name = "", email = ""), okAuth).value)
error shouldBe a[GroupValidationError]
verify(mockGroupRepo, never()).save(any[DB], any[Group])
verify(mockMembershipRepo, never())
.saveMembers(any[DB], anyString, any[Set[String]], isAdmin = anyBoolean)
}
}
"update an existing group" should {
@@ -388,6 +408,31 @@ class MembershipServiceSpec
error shouldBe a[GroupAlreadyExistsError]
}
"return an error if group name and/or email is empty" in {
doReturn(IO.pure(Some(existingGroup)))
.when(mockGroupRepo)
.getGroup(existingGroup.id)
doReturn(().toResult).when(underTest).usersExist(any[Set[String]])
doReturn(result(GroupValidationError("fail")))
.when(underTest)
.groupValidation(existingGroup.copy(name = "", email = ""))
val error = leftResultOf(
underTest
.updateGroup(
updatedInfo.id,
name = "",
email = "",
updatedInfo.description,
updatedInfo.memberIds,
updatedInfo.adminUserIds,
okAuth
)
.value
)
error shouldBe a[GroupValidationError]
}
"return an error if the group is not found" in {
doReturn(IO.pure(None)).when(mockGroupRepo).getGroup(existingGroup.id)
@@ -597,6 +642,30 @@ class MembershipServiceSpec
ignoreAccess = false
)
}
"return only return groups whose name matches the filter, regardless of case" in {
doReturn(IO.pure(listOfDummyGroups.toSet))
.when(mockGroupRepo)
.getGroups(any[Set[String]])
val result: ListMyGroupsResponse = rightResultOf(
underTest
.listMyGroups(
groupNameFilter = Some("Name-Dummy01"),
startFrom = None,
maxItems = 100,
listOfDummyGroupsAuth,
false
)
.value
)
result shouldBe ListMyGroupsResponse(
groups = listOfDummyGroupInfo.slice(10, 20),
groupNameFilter = Some("Name-Dummy01"),
startFrom = None,
nextId = None,
maxItems = 100,
ignoreAccess = false
)
}
"return only return groups after startFrom" in {
doReturn(IO.pure(listOfDummyGroups.toSet))
.when(mockGroupRepo)

View File

@@ -79,4 +79,6 @@ object Messages {
val NotAuthorizedErrorMsg =
"User \"%s\" is not authorized. Contact %s owner group: %s at %s to make DNS changes."
// Error displayed when group name or email is empty
val GroupValidationErrorMsg = "Group name and email cannot be empty."
}

View File

@@ -52,11 +52,23 @@ final case class InvalidDomainName(param: String) extends DomainValidationError
"joined by dots, and terminated with a dot."
}
final case class InvalidCName(param: String) extends DomainValidationError {
final case class InvalidIPv4CName(param: String) extends DomainValidationError {
def message: String =
s"""Invalid Cname: "$param", valid cname should not be IP address"""
}
final case class InvalidCname(param: String, isReverseZone: Boolean) extends DomainValidationError {
def message: String =
isReverseZone match {
case true =>
s"""Invalid Cname: "$param", valid cnames must be letters, numbers, slashes, underscores, and hyphens, """ +
"joined by dots, and terminated with a dot."
case false =>
s"""Invalid Cname: "$param", valid cnames must be letters, numbers, underscores, and hyphens, """ +
"joined by dots, and terminated with a dot."
}
}
final case class InvalidLength(param: String, minLengthInclusive: Int, maxLengthInclusive: Int)
extends DomainValidationError {
def message: String =

View File

@@ -30,7 +30,7 @@ object DomainValidationErrorType extends Enumeration {
type DomainValidationErrorType = Value
// NOTE: once defined, an error code type cannot be changed!
val ChangeLimitExceeded, BatchChangeIsEmpty, GroupDoesNotExist, NotAMemberOfOwnerGroup,
InvalidDomainName, InvalidLength, InvalidEmail, InvalidRecordType, InvalidPortNumber,
InvalidDomainName, InvalidCname, InvalidLength, InvalidEmail, InvalidRecordType, InvalidPortNumber,
InvalidIpv4Address, InvalidIpv6Address, InvalidIPAddress, InvalidTTL, InvalidMxPreference,
InvalidBatchRecordType, ZoneDiscoveryError, RecordAlreadyExists, RecordDoesNotExist,
CnameIsNotUniqueError, UserIsNotAuthorized, UserIsNotAuthorizedError, RecordNameNotUniqueInBatch,
@@ -46,6 +46,7 @@ object DomainValidationErrorType extends Enumeration {
case _: GroupDoesNotExist => GroupDoesNotExist
case _: NotAMemberOfOwnerGroup => NotAMemberOfOwnerGroup
case _: InvalidDomainName => InvalidDomainName
case _: InvalidCname => InvalidCname
case _: InvalidLength => InvalidLength
case _: InvalidEmail => InvalidEmail
case _: InvalidRecordType => InvalidRecordType

View File

@@ -468,6 +468,27 @@ class MySqlZoneRepositoryIntegrationSpec
(f.unsafeRunSync().zones should contain).theSameElementsInOrderAs(expectedZones)
}
"support case insensitivity in the zone filter" in {
val testZones = Seq(
testZone("system-test.", adminGroupId = "foo"),
testZone("system-temp.", adminGroupId = "foo"),
testZone("system-nomatch.", adminGroupId = "bar")
)
val expectedZones = Seq(testZones(0), testZones(1)).sortBy(_.name)
val auth = AuthPrincipal(dummyUser, Seq("foo"))
val f =
for {
_ <- saveZones(testZones)
retrieved <- repo.listZones(auth, zoneNameFilter = Some("SyStEm*"))
} yield retrieved
(f.unsafeRunSync().zones should contain).theSameElementsInOrderAs(expectedZones)
}
"support starts with wildcard" in {
val testZones = Seq(

View File

@@ -34,9 +34,11 @@ module.exports = function(grunt) {
{expand: true, flatten: true, src: ['node_modules/bootstrap/dist/js/bootstrap.min.js'], dest: 'public/js'},
{expand: true, flatten: true, src: ['node_modules/jquery/dist/jquery.min.js'], dest: 'public/js'},
{expand: true, flatten: true, src: ['node_modules/moment/min/moment.min.js'], dest: 'public/js'},
{expand: true, flatten: true, src: ['node_modules/jquery-ui-dist/jquery-ui.js'], dest: 'public/js'},
{expand: true, flatten: true, src: ['node_modules/bootstrap/dist/css/bootstrap.min.css'], dest: 'public/css'},
{expand: true, flatten: true, src: ['node_modules/font-awesome/css/font-awesome.min.css'], dest: 'public/css'},
{expand: true, flatten: true, src: ['node_modules/jquery-ui-dist/jquery-ui.css'], dest: 'public/css'},
// We're picking just the resources we need from the gentelella UI framework and temporarily storing them in mapped/ui/
{expand: true, flatten: true, cwd: 'node_modules/gentelella', dest: 'mapped/ui', src: '**/jquery.{smartWizard,dataTables.min,mousewheel.min}.js'},

View File

@@ -16,10 +16,12 @@
<!-- CSS INCLUDE -->
<link rel="stylesheet" type="text/css" id="theme" href="/public/css/bootstrap.min.css"/>
<link rel="stylesheet" type="text/css" href="/public/css/jquery-ui.css">
<link rel="stylesheet" type="text/css" href="/public/css/font-awesome.min.css">
<link rel="stylesheet" type="text/css" href="/public/css/ui.css" />
<link rel="stylesheet" type="text/css" id="custom" href="/public/css/theme-overrides.css"/>
<link rel="stylesheet" type="text/css" id="custom" href="/public/css/vinyldns.css"/>
<link rel="stylesheet" type="text/css" href="/public/css/jquery-ui.css">
<!-- EOF CSS INCLUDE -->
</head>
@@ -149,6 +151,7 @@
<script src="/public/js/moment.min.js"></script>
<script src="/public/js/jquery.min.js"></script>
<script src="/public/js/jquery-ui.js"></script>
<script src="/public/js/bootstrap.min.js"></script>
<script src="/public/js/angular.min.js"></script>
<script src="/public/js/ui.js"></script>

View File

@@ -144,6 +144,9 @@
</div>
</div>
</div>
<div class="modal fade" id="loader" tabindex="-1" role="dialog" >
<div class="spinner" ></div>
</div>
<div class="tab-pane" id="allZones">
<div class="row">
<div class="col-md-12">

View File

@@ -15,6 +15,8 @@ module.exports = function(config) {
// list of files / patterns to load in the browser
files: [
'js/jquery.min.js',
'js/jquery-ui-dist.js',
'js/jquery-ui.js',
'js/bootstrap.min.js',
'js/angular.min.js',
'js/moment.min.js',

View File

@@ -24,6 +24,7 @@
"jasmine-core": "^2.99.1",
"jasmine-jquery": "2.1.1",
"jquery": "^3.6.0",
"jquery-ui-dist": "^1.13.1",
"karma": "^6.3.17",
"karma-chrome-launcher": "^2.2.0",
"karma-jasmine": "^1.0.2",

View File

@@ -104,7 +104,7 @@ a.action-link {
top: 3px;
left: 50%;
width: 40%;
z-index: 100;
z-index: 2000;
}
.dns-connection-form {
@@ -492,6 +492,26 @@ input[type="file"] {
opacity: 0.75;
}
.ui-menu .ui-menu-item div {
background: white;
width: 100%;
font-size: 14px;
color: black;
border: none;
border-left: 1px;
}
.ui-menu .ui-menu-item div:hover {
background: #f5f5f5;
}
.ui-autocomplete {
max-height: 200px;
width:150px;
overflow-y: auto;
overflow-x: hidden;
}
#fixed-side-menu {
position: fixed;
top: 0;

View File

@@ -64,6 +64,44 @@ angular.module('controller.groups', []).controller('GroupsController', function
return true;
};
// Autocomplete for group search
$("#group-search-text").autocomplete({
source: function( request, response ) {
$.ajax({
url: "/api/groups?maxItems=1500&abridged=true",
dataType: "json",
data: {groupNameFilter: request.term, ignoreAccess: $scope.ignoreAccess},
success: function(data) {
const search = JSON.parse(JSON.stringify(data));
response($.map(search.groups, function(group) {
return {value: group.name, label: group.name}
}))
}
});
},
minLength: 1,
select: function (event, ui) {
$scope.query = ui.item.value;
$("#group-search-text").val(ui.item.value);
return false;
},
open: function() {
$(this).removeClass("ui-corner-all").addClass("ui-corner-top");
},
close: function() {
$(this).removeClass("ui-corner-top").addClass("ui-corner-all");
}
});
// Autocomplete text-highlight
$.ui.autocomplete.prototype._renderItem = function(ul, item) {
let txt = String(item.label).replace(new RegExp(this.term, "gi"),"<b>$&</b>");
return $("<li></li>")
.data("ui-autocomplete-item", item.value)
.append("<div>" + txt + "</div>")
.appendTo(ul);
};
$scope.createGroup = function (name, email, description) {
//prevent user executing service call multiple times
//if true prevent, if false allow for execution of rest of code

View File

@@ -31,15 +31,57 @@
// paging status for recordsets
var recordsPaging = pagingService.getNewPagingParams(100);
var recordType = [];
var recordName = [];
$( "#record-search-text" ).autocomplete({
source: function( request, response ) {
$.ajax({
url: "/api/recordsets?maxItems=100",
dataType: "json",
data: "recordNameFilter="+request.term+"%25&nameSort=asc",
success: function( data ) {
const recordSearch = JSON.parse(JSON.stringify(data));
response($.map(recordSearch.recordSets, function(item) {
return {value: item.fqdn +' | '+ item.type , label: 'name: ' + item.fqdn + ' | type: ' + item.type }}))}
});
},
minLength: 2,
select: function (event, ui) {
$scope.query = ui.item.value;
$("#record-search-text").val(ui.item.value);
return false;
},
open: function() {
$( this ).removeClass( "ui-corner-all" ).addClass( "ui-corner-top" );
},
close: function() {
$( this ).removeClass( "ui-corner-top" ).addClass( "ui-corner-all" );
}
});
$.ui.autocomplete.prototype._renderItem = function( ul, item ) {
let recordSet = String(item.label).replace(new RegExp(this.term, "gi"),"<b>$&</b>");
return $("<li></li>")
.data("ui-autocomplete-item", item.value)
.append("<div>" + recordSet + "</div>")
.appendTo(ul); };
$scope.refreshRecords = function() {
recordsPaging = pagingService.resetPaging(recordsPaging);
if($scope.query.includes("|")) {
const queryRecord = $scope.query.split('|');
recordName = queryRecord[0].trim();
recordType = queryRecord[1].trim(); }
else { recordName = $scope.query;
recordType = $scope.selectedRecordTypes.toString(); }
recordsPaging = pagingService.resetPaging(recordsPaging);
function success(response) {
recordsPaging.next = response.data.nextId;
updateRecordDisplay(response.data['recordSets']);
}
return recordsService
.listRecordSetData(recordsPaging.maxItems, undefined, $scope.query, $scope.selectedRecordTypes.toString(), $scope.nameSort, $scope.ownerGroupFilter)
.listRecordSetData(recordsPaging.maxItems, undefined, recordName, recordType, $scope.nameSort, $scope.ownerGroupFilter)
.then(success)
.catch(function (error) {
handleError(error, 'dnsChangesService::getRecordSet-failure');
@@ -79,6 +121,7 @@
}
};
function updateRecordDisplay(records) {
var newRecords = [];
angular.forEach(records, function(record) {

View File

@@ -30,7 +30,16 @@ angular.module('service.zones', [])
"ignoreAccess": ignoreAccess
};
var url = groupsService.urlBuilder("/api/zones", params);
return $http.get(url);
let loader = $("#loader");
loader.modal({
backdrop: "static", //remove ability to close modal with click
keyboard: false, //remove option to close with keyboard
show: true //Display loader!
})
let promis = $http.get(url);
// Hide loader when api gets response
promis.then(()=>loader.modal("hide"), ()=>loader.modal("hide"))
return promis
};
this.getBackendIds = function() {

View File

@@ -1 +1 @@
version in ThisBuild := "0.14.0"
version in ThisBuild := "0.14.1"