diff --git a/pom.xml b/pom.xml index ff30608..f0530a2 100644 --- a/pom.xml +++ b/pom.xml @@ -80,11 +80,6 @@ metafy 0.1.2 - - org.apache.commons - commons-text - 1.9 - diff --git a/src/main/kotlin/br/ufsc/bridge/platform/validation/rules/RichTextMaxLengthRule.kt b/src/main/kotlin/br/ufsc/bridge/platform/validation/rules/RichTextMaxLengthRule.kt index bf0d644..f36f61a 100644 --- a/src/main/kotlin/br/ufsc/bridge/platform/validation/rules/RichTextMaxLengthRule.kt +++ b/src/main/kotlin/br/ufsc/bridge/platform/validation/rules/RichTextMaxLengthRule.kt @@ -1,14 +1,14 @@ package br.ufsc.bridge.platform.validation.rules import br.ufsc.bridge.platform.validation.engine.Rule +import br.ufsc.bridge.platform.validation.util.HtmlUtil import br.ufsc.bridge.platform.validation.util.RegexUtil import br.ufsc.bridge.platform.validation.util.Util -import org.apache.commons.text.StringEscapeUtils class RichTextMaxLengthRule(private val maxLength: Int) : Rule { override fun validate(value: String?): String? { - val text = StringEscapeUtils.unescapeHtml4(value?.replace(RegexUtil.HTML_TAG_PATTERN, "")) - return if (!Util.isEmpty(text) && text.length > maxLength) "Deve possuir no máximo $maxLength caractere(s)" else null + val text = HtmlUtil.unescapeHtml(value?.replace(RegexUtil.HTML_TAG_PATTERN, "")) + return if (!Util.isEmpty(text) && text!!.length > maxLength) "Deve possuir no máximo $maxLength caractere(s)" else null } } diff --git a/src/main/kotlin/br/ufsc/bridge/platform/validation/rules/RichTextMinLengthRule.kt b/src/main/kotlin/br/ufsc/bridge/platform/validation/rules/RichTextMinLengthRule.kt index 2e5c1ab..bd64645 100644 --- a/src/main/kotlin/br/ufsc/bridge/platform/validation/rules/RichTextMinLengthRule.kt +++ b/src/main/kotlin/br/ufsc/bridge/platform/validation/rules/RichTextMinLengthRule.kt @@ -1,15 +1,15 @@ package br.ufsc.bridge.platform.validation.rules import br.ufsc.bridge.platform.validation.engine.Rule +import br.ufsc.bridge.platform.validation.util.HtmlUtil import br.ufsc.bridge.platform.validation.util.RegexUtil import br.ufsc.bridge.platform.validation.util.Util -import org.apache.commons.text.StringEscapeUtils class RichTextMinLengthRule(private val minLength: Int) : Rule { override fun validate(value: String?): String? { - val text = StringEscapeUtils.unescapeHtml4(value?.replace(RegexUtil.HTML_TAG_PATTERN, "")) - return if (Util.isEmpty(text) || text.length < minLength) { + val text = HtmlUtil.unescapeHtml(value?.replace(RegexUtil.HTML_TAG_PATTERN, "")) + return if (Util.isEmpty(text) || text!!.length < minLength) { "Deve possuir ao menos $minLength caractere(s)" } else null } diff --git a/src/main/kotlin/br/ufsc/bridge/platform/validation/util/HtmlUtil.kt b/src/main/kotlin/br/ufsc/bridge/platform/validation/util/HtmlUtil.kt new file mode 100644 index 0000000..c4236e0 --- /dev/null +++ b/src/main/kotlin/br/ufsc/bridge/platform/validation/util/HtmlUtil.kt @@ -0,0 +1,105 @@ +package br.ufsc.bridge.platform.validation.util + +object HtmlUtil { + + fun unescapeHtml(value: String?): String? { + if (value == null || value.indexOf('&') < 0) return value + val result = StringBuilder(value.length) + var index = 0 + while (index < value.length) { + val char = value[index] + if (char != '&') { + result.append(char) + index++ + continue + } + val semicolonIndex = value.indexOf(';', index + 1) + if (semicolonIndex < 0) { + result.append(value, index, value.length) + break + } + val unescaped = unescapeEntity(value.substring(index + 1, semicolonIndex)) + if (unescaped != null) { + result.append(unescaped) + index = semicolonIndex + 1 + } else { + result.append('&') + index++ + } + } + return result.toString() + } + + private fun unescapeEntity(entity: String): String? { + if (entity.isEmpty()) return null + if (entity[0] != '#') return NAMED_ENTITIES[entity]?.toString() + val isHex = entity.length > 1 && (entity[1] == 'x' || entity[1] == 'X') + val codePoint = if (isHex) entity.substring(2).toIntOrNull(16) else entity.substring(1).toIntOrNull() + return if (codePoint != null && codePoint in 0..0x10FFFF) String(Character.toChars(codePoint)) else null + } + + // Entidades definidas pelo HTML 4.0 ("apos" nao faz parte do HTML 4 e por isso nao eh tratada) + private val NAMED_ENTITIES: Map = HashMap().apply { + put("quot", '"') + put("amp", '&') + put("lt", '<') + put("gt", '>') + + // Entidades ISO-8859-1, com codigos sequenciais a partir de 160 + listOf( + "nbsp", "iexcl", "cent", "pound", "curren", "yen", "brvbar", "sect", + "uml", "copy", "ordf", "laquo", "not", "shy", "reg", "macr", + "deg", "plusmn", "sup2", "sup3", "acute", "micro", "para", "middot", + "cedil", "sup1", "ordm", "raquo", "frac14", "frac12", "frac34", "iquest", + "Agrave", "Aacute", "Acirc", "Atilde", "Auml", "Aring", "AElig", "Ccedil", + "Egrave", "Eacute", "Ecirc", "Euml", "Igrave", "Iacute", "Icirc", "Iuml", + "ETH", "Ntilde", "Ograve", "Oacute", "Ocirc", "Otilde", "Ouml", "times", + "Oslash", "Ugrave", "Uacute", "Ucirc", "Uuml", "Yacute", "THORN", "szlig", + "agrave", "aacute", "acirc", "atilde", "auml", "aring", "aelig", "ccedil", + "egrave", "eacute", "ecirc", "euml", "igrave", "iacute", "icirc", "iuml", + "eth", "ntilde", "ograve", "oacute", "ocirc", "otilde", "ouml", "divide", + "oslash", "ugrave", "uacute", "ucirc", "uuml", "yacute", "thorn", "yuml" + ).forEachIndexed { offset, name -> put(name, (160 + offset).toChar()) } + + // Entidades de simbolos, letras gregas e pontuacao + listOf( + "fnof" to 402, + "Alpha" to 913, "Beta" to 914, "Gamma" to 915, "Delta" to 916, "Epsilon" to 917, + "Zeta" to 918, "Eta" to 919, "Theta" to 920, "Iota" to 921, "Kappa" to 922, + "Lambda" to 923, "Mu" to 924, "Nu" to 925, "Xi" to 926, "Omicron" to 927, + "Pi" to 928, "Rho" to 929, "Sigma" to 931, "Tau" to 932, "Upsilon" to 933, + "Phi" to 934, "Chi" to 935, "Psi" to 936, "Omega" to 937, + "alpha" to 945, "beta" to 946, "gamma" to 947, "delta" to 948, "epsilon" to 949, + "zeta" to 950, "eta" to 951, "theta" to 952, "iota" to 953, "kappa" to 954, + "lambda" to 955, "mu" to 956, "nu" to 957, "xi" to 958, "omicron" to 959, + "pi" to 960, "rho" to 961, "sigmaf" to 962, "sigma" to 963, "tau" to 964, + "upsilon" to 965, "phi" to 966, "chi" to 967, "psi" to 968, "omega" to 969, + "thetasym" to 977, "upsih" to 978, "piv" to 982, + "bull" to 8226, "hellip" to 8230, "prime" to 8242, "Prime" to 8243, "oline" to 8254, + "frasl" to 8260, "weierp" to 8472, "image" to 8465, "real" to 8476, "trade" to 8482, + "alefsym" to 8501, + "larr" to 8592, "uarr" to 8593, "rarr" to 8594, "darr" to 8595, "harr" to 8596, + "crarr" to 8629, "lArr" to 8656, "uArr" to 8657, "rArr" to 8658, "dArr" to 8659, + "hArr" to 8660, + "forall" to 8704, "part" to 8706, "exist" to 8707, "empty" to 8709, "nabla" to 8711, + "isin" to 8712, "notin" to 8713, "ni" to 8715, "prod" to 8719, "sum" to 8721, + "minus" to 8722, "lowast" to 8727, "radic" to 8730, "prop" to 8733, "infin" to 8734, + "ang" to 8736, "and" to 8743, "or" to 8744, "cap" to 8745, "cup" to 8746, + "int" to 8747, "there4" to 8756, "sim" to 8764, "cong" to 8773, "asymp" to 8776, + "ne" to 8800, "equiv" to 8801, "le" to 8804, "ge" to 8805, "sub" to 8834, + "sup" to 8835, "nsub" to 8836, "sube" to 8838, "supe" to 8839, "oplus" to 8853, + "otimes" to 8855, "perp" to 8869, "sdot" to 8901, + "lceil" to 8968, "rceil" to 8969, "lfloor" to 8970, "rfloor" to 8971, + "lang" to 9001, "rang" to 9002, "loz" to 9674, + "spades" to 9824, "clubs" to 9827, "hearts" to 9829, "diams" to 9830, + "OElig" to 338, "oelig" to 339, "Scaron" to 352, "scaron" to 353, "Yuml" to 376, + "circ" to 710, "tilde" to 732, + "ensp" to 8194, "emsp" to 8195, "thinsp" to 8201, "zwnj" to 8204, "zwj" to 8205, + "lrm" to 8206, "rlm" to 8207, "ndash" to 8211, "mdash" to 8212, + "lsquo" to 8216, "rsquo" to 8217, "sbquo" to 8218, + "ldquo" to 8220, "rdquo" to 8221, "bdquo" to 8222, + "dagger" to 8224, "Dagger" to 8225, "permil" to 8240, + "lsaquo" to 8249, "rsaquo" to 8250, "euro" to 8364 + ).forEach { (name, code) -> put(name, code.toChar()) } + } +} diff --git a/src/test/kotlin/br/ufsc/bridge/platform/validation/rules/RichTextMaxLengthTest.kt b/src/test/kotlin/br/ufsc/bridge/platform/validation/rules/RichTextMaxLengthTest.kt index d717115..faad2b8 100644 --- a/src/test/kotlin/br/ufsc/bridge/platform/validation/rules/RichTextMaxLengthTest.kt +++ b/src/test/kotlin/br/ufsc/bridge/platform/validation/rules/RichTextMaxLengthTest.kt @@ -21,4 +21,40 @@ class RichTextMaxLengthTest : ValidationTest() { fun `Atributo com length menor que o max eh valido`(){ assertNull(validate("Lorem dolor sit ipsum.", richTextMaxLength(25))) } + + @Test + fun `Atributo com length igual ao max eh valido`() { + assertNull(validate("

12345

", richTextMaxLength(5))) + } + + @Test + fun `Atributo apenas com tags eh valido`() { + assertNull(validate("


", richTextMaxLength(0))) + } + + @Test + fun `Entidade conta como um unico caractere`() { + // "<ab>" representa "", 4 caracteres + assertNull(validate("

<ab>

", richTextMaxLength(4))) + assertNotNull(validate("

<ab>

", richTextMaxLength(3))) + } + + @Test + fun `Entidade numerica conta como um unico caractere`() { + // "d'agua" representa "d'agua", 6 caracteres + assertNull(validate("

d'agua

", richTextMaxLength(6))) + assertNotNull(validate("

d'agua

", richTextMaxLength(5))) + } + + @Test + fun `Texto com acentos escapados eh medido pelo texto convertido`() { + // "validação" representa "validação", 9 caracteres + assertNull(validate("validação", richTextMaxLength(9))) + assertNotNull(validate("validação", richTextMaxLength(8))) + } + + @Test + fun `Quebras de linha sao removidas da contagem`() { + assertNull(validate("

abc

\n

def

", richTextMaxLength(6))) + } } diff --git a/src/test/kotlin/br/ufsc/bridge/platform/validation/rules/RichTextMinLengthTest.kt b/src/test/kotlin/br/ufsc/bridge/platform/validation/rules/RichTextMinLengthTest.kt index b233f84..3b33e7c 100644 --- a/src/test/kotlin/br/ufsc/bridge/platform/validation/rules/RichTextMinLengthTest.kt +++ b/src/test/kotlin/br/ufsc/bridge/platform/validation/rules/RichTextMinLengthTest.kt @@ -30,4 +30,40 @@ class RichTextMinLengthTest : ValidationTest() { fun `Atributo com length maior que o min eh valido`(){ assertNull(validate("Lorem dolor sit ipsum.", Rules.richTextMinLength(10))) } + + @Test + fun `Atributo com length igual ao min eh valido`() { + assertNull(validate("

12345

", Rules.richTextMinLength(5))) + } + + @Test + fun `Atributo vazio eh invalido`() { + assertNotNull(validate("", Rules.richTextMinLength(1))) + } + + @Test + fun `Entidade conta como um unico caractere`() { + // "<ab>" representa "", 4 caracteres + assertNull(validate("

<ab>

", Rules.richTextMinLength(4))) + assertNotNull(validate("

<ab>

", Rules.richTextMinLength(5))) + } + + @Test + fun `Entidade numerica conta como um unico caractere`() { + // "d'agua" representa "d'agua", 6 caracteres + assertNull(validate("

d'agua

", Rules.richTextMinLength(6))) + assertNotNull(validate("

d'agua

", Rules.richTextMinLength(7))) + } + + @Test + fun `Texto com acentos escapados eh medido pelo texto convertido`() { + // "validação" representa "validação", 9 caracteres + assertNull(validate("validação", Rules.richTextMinLength(9))) + assertNotNull(validate("validação", Rules.richTextMinLength(10))) + } + + @Test + fun `Quebras de linha nao contam para o tamanho minimo`() { + assertNotNull(validate("

ab

\n

cd

", Rules.richTextMinLength(5))) + } } \ No newline at end of file diff --git a/src/test/kotlin/br/ufsc/bridge/platform/validation/util/HtmlUtilTest.kt b/src/test/kotlin/br/ufsc/bridge/platform/validation/util/HtmlUtilTest.kt new file mode 100644 index 0000000..9d88931 --- /dev/null +++ b/src/test/kotlin/br/ufsc/bridge/platform/validation/util/HtmlUtilTest.kt @@ -0,0 +1,115 @@ +package br.ufsc.bridge.platform.validation.util + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class HtmlUtilTest { + + @Test + fun `Null retorna null`() { + assertNull(HtmlUtil.unescapeHtml(null)) + } + + @Test + fun `String vazia retorna vazia`() { + assertEquals("", HtmlUtil.unescapeHtml("")) + } + + @Test + fun `String sem entidades eh mantida`() { + assertEquals("Lorem ipsum dolor", HtmlUtil.unescapeHtml("Lorem ipsum dolor")) + } + + @Test + fun `Entidades basicas sao convertidas`() { + assertEquals("", HtmlUtil.unescapeHtml("<teste>")) + assertEquals("a & b", HtmlUtil.unescapeHtml("a & b")) + assertEquals("\"aspas\"", HtmlUtil.unescapeHtml(""aspas"")) + } + + @Test + fun `Entidades ISO-8859-1 sao convertidas`() { + assertEquals(" ", HtmlUtil.unescapeHtml(" ")) + assertEquals("ação", HtmlUtil.unescapeHtml("ação")) + assertEquals("é É à Ü ñ ÿ", HtmlUtil.unescapeHtml("é É à Ü ñ ÿ")) + assertEquals("© ® ° ÷ ×", HtmlUtil.unescapeHtml("© ® ° ÷ ×")) + } + + @Test + fun `Entidades de simbolos e letras gregas sao convertidas`() { + assertEquals("α β Ω π", HtmlUtil.unescapeHtml("α β Ω π")) + assertEquals("• … ™ €", HtmlUtil.unescapeHtml("• … ™ €")) + assertEquals("← → ∑ ≠ ≤", HtmlUtil.unescapeHtml("← → ∑ ≠ ≤")) + assertEquals("‘aspas’ “duplas” – —", HtmlUtil.unescapeHtml("‘aspas’ “duplas” – —")) + } + + @Test + fun `Entidades numericas decimais sao convertidas`() { + assertEquals("A", HtmlUtil.unescapeHtml("A")) + assertEquals("d'agua", HtmlUtil.unescapeHtml("d'agua")) + assertEquals("ç", HtmlUtil.unescapeHtml("ç")) + } + + @Test + fun `Entidades numericas hexadecimais sao convertidas`() { + assertEquals("A", HtmlUtil.unescapeHtml("A")) + assertEquals("A", HtmlUtil.unescapeHtml("A")) + assertEquals("'", HtmlUtil.unescapeHtml("'")) + } + + @Test + fun `Entidade numerica fora do BMP gera par substituto`() { + assertEquals("💩", HtmlUtil.unescapeHtml("💩")) + assertEquals("💩", HtmlUtil.unescapeHtml("💩")) + } + + @Test + fun `Entidade desconhecida eh mantida`() { + assertEquals("&desconhecida;", HtmlUtil.unescapeHtml("&desconhecida;")) + assertEquals("'", HtmlUtil.unescapeHtml("'")) + } + + @Test + fun `Entidade sem ponto e virgula eh mantida`() { + assertEquals("&", HtmlUtil.unescapeHtml("&")) + assertEquals("A", HtmlUtil.unescapeHtml("A")) + assertEquals("a & b", HtmlUtil.unescapeHtml("a & b")) + } + + @Test + fun `Entidade malformada eh mantida`() { + assertEquals("&;", HtmlUtil.unescapeHtml("&;")) + assertEquals("&#;", HtmlUtil.unescapeHtml("&#;")) + assertEquals("&#x;", HtmlUtil.unescapeHtml("&#x;")) + assertEquals(" ab;", HtmlUtil.unescapeHtml(" ab;")) + assertEquals("&#-12;", HtmlUtil.unescapeHtml("&#-12;")) + assertEquals("�", HtmlUtil.unescapeHtml("�")) + } + + @Test + fun `Escape duplo eh convertido apenas uma vez`() { + assertEquals("&", HtmlUtil.unescapeHtml("&amp;")) + assertEquals("<", HtmlUtil.unescapeHtml("&lt;")) + } + + @Test + fun `Entidades sao sensiveis a maiusculas e minusculas`() { + assertEquals("Σ σ", HtmlUtil.unescapeHtml("Σ σ")) + assertEquals("&", HtmlUtil.unescapeHtml("&")) + } + + @Test + fun `Texto com varias entidades misturadas eh convertido`() { + assertEquals( + "São José <1 & >2  fim", + HtmlUtil.unescapeHtml("São José <1 & >2  fim") + ) + } + + @Test + fun `E comercial seguido de entidade valida converte apenas a entidade`() { + assertEquals("&<", HtmlUtil.unescapeHtml("&<")) + assertEquals("&foo<", HtmlUtil.unescapeHtml("&foo<")) + } +}