Merge branch 'main' into claude/implement-nip5a-rendering-5ioVq

This commit is contained in:
Vitor Pamplona
2026-03-29 20:43:02 -04:00
committed by GitHub
25 changed files with 1342 additions and 386 deletions
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip7DThreads
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
fun TagArrayBuilder<ThreadEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip7DThreads
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
fun TagArray.title() = firstNotNullOfOrNull(TitleTag::parse)
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip7DThreads
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class ThreadEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
RootScope,
SearchableEvent {
override fun indexableContent() = "title: " + title() + "\n" + content
fun title() = tags.title()
companion object {
const val KIND = 11
const val ALT_DESCRIPTION = "Thread"
fun build(
content: String,
title: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ThreadEvent>.() -> Unit = {},
) = eventTemplate(KIND, content, createdAt) {
alt(ALT_DESCRIPTION)
title(title)
initializer()
}
}
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip87Ecash
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class MintUrlTag {
companion object {
const val TAG_NAME = "u"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(url: String) = arrayOf(TAG_NAME, url)
fun assemble(urls: List<String>) = urls.map { assemble(it) }
}
}
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip87Ecash
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
enum class NetworkType(
val code: String,
) {
MAINNET("mainnet"),
TESTNET("testnet"),
SIGNET("signet"),
REGTEST("regtest"),
;
companion object {
fun fromCode(code: String) = entries.firstOrNull { it.code == code }
}
}
class NetworkTag {
companion object {
const val TAG_NAME = "n"
fun parse(tag: Array<String>): NetworkType? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return NetworkType.fromCode(tag[1])
}
fun assemble(network: NetworkType) = arrayOf(TAG_NAME, network.code)
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip87Ecash.cashu
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip87Ecash.NetworkType
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class CashuMintEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun mintUrl() = tags.mintUrl()
fun nuts() = tags.nuts()
fun network() = tags.network()
fun dTag() = tags.dTag()
companion object {
const val KIND = 38172
const val ALT_DESCRIPTION = "Cashu Mint Announcement"
fun build(
mintPubKey: String,
mintUrl: String,
nuts: List<String>,
network: NetworkType = NetworkType.MAINNET,
metadata: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CashuMintEvent>.() -> Unit = {},
) = eventTemplate(KIND, metadata, createdAt) {
alt(ALT_DESCRIPTION)
dTag(mintPubKey)
mintUrl(mintUrl)
nuts(nuts)
network(network)
initializer()
}
}
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip87Ecash.cashu
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip87Ecash.MintUrlTag
import com.vitorpamplona.quartz.nip87Ecash.NetworkTag
import com.vitorpamplona.quartz.nip87Ecash.NetworkType
import com.vitorpamplona.quartz.nip87Ecash.cashu.tags.NutsTag
fun TagArrayBuilder<CashuMintEvent>.dTag(mintPubKey: String) = addUnique(arrayOf("d", mintPubKey))
fun TagArrayBuilder<CashuMintEvent>.mintUrl(url: String) = addUnique(MintUrlTag.assemble(url))
fun TagArrayBuilder<CashuMintEvent>.nuts(nuts: List<String>) = add(NutsTag.assemble(nuts))
fun TagArrayBuilder<CashuMintEvent>.network(network: NetworkType) = addUnique(NetworkTag.assemble(network))
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip87Ecash.cashu
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip87Ecash.MintUrlTag
import com.vitorpamplona.quartz.nip87Ecash.NetworkTag
import com.vitorpamplona.quartz.nip87Ecash.NetworkType
import com.vitorpamplona.quartz.nip87Ecash.cashu.tags.NutsTag
fun TagArray.mintUrl(): String? = firstNotNullOfOrNull(MintUrlTag::parse)
fun TagArray.nuts(): List<String> = firstNotNullOfOrNull(NutsTag::parse) ?: emptyList()
fun TagArray.network(): NetworkType = firstNotNullOfOrNull(NetworkTag::parse) ?: NetworkType.MAINNET
fun TagArray.dTag(): String? = firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip87Ecash.cashu.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class NutsTag {
companion object {
const val TAG_NAME = "nuts"
fun parse(tag: Array<String>): List<String>? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag.drop(1)
}
fun assemble(nuts: List<String>) = arrayOf(TAG_NAME, *nuts.toTypedArray())
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip87Ecash.fedimint
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip87Ecash.NetworkType
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class FedimintEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun inviteCodes() = tags.inviteCodes()
fun modules() = tags.modules()
fun network() = tags.network()
fun dTag() = tags.dTag()
companion object {
const val KIND = 38173
const val ALT_DESCRIPTION = "Fedimint Announcement"
fun build(
federationId: String,
inviteCodes: List<String>,
modules: List<String>,
network: NetworkType = NetworkType.MAINNET,
metadata: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<FedimintEvent>.() -> Unit = {},
) = eventTemplate(KIND, metadata, createdAt) {
alt(ALT_DESCRIPTION)
dTag(federationId)
inviteCodes(inviteCodes)
modules(modules)
network(network)
initializer()
}
}
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip87Ecash.fedimint
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip87Ecash.MintUrlTag
import com.vitorpamplona.quartz.nip87Ecash.NetworkTag
import com.vitorpamplona.quartz.nip87Ecash.NetworkType
import com.vitorpamplona.quartz.nip87Ecash.fedimint.tags.ModulesTag
fun TagArrayBuilder<FedimintEvent>.dTag(federationId: String) = addUnique(arrayOf("d", federationId))
fun TagArrayBuilder<FedimintEvent>.inviteCodes(codes: List<String>) = addAll(MintUrlTag.assemble(codes))
fun TagArrayBuilder<FedimintEvent>.modules(modules: List<String>) = add(ModulesTag.assemble(modules))
fun TagArrayBuilder<FedimintEvent>.network(network: NetworkType) = addUnique(NetworkTag.assemble(network))
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip87Ecash.fedimint
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip87Ecash.MintUrlTag
import com.vitorpamplona.quartz.nip87Ecash.NetworkTag
import com.vitorpamplona.quartz.nip87Ecash.NetworkType
import com.vitorpamplona.quartz.nip87Ecash.fedimint.tags.ModulesTag
fun TagArray.inviteCodes(): List<String> = mapNotNull(MintUrlTag::parse)
fun TagArray.modules(): List<String> = firstNotNullOfOrNull(ModulesTag::parse) ?: emptyList()
fun TagArray.network(): NetworkType = firstNotNullOfOrNull(NetworkTag::parse) ?: NetworkType.MAINNET
fun TagArray.dTag(): String? = firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip87Ecash.fedimint.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class ModulesTag {
companion object {
const val TAG_NAME = "modules"
fun parse(tag: Array<String>): List<String>? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag.drop(1)
}
fun assemble(modules: List<String>) = arrayOf(TAG_NAME, *modules.toTypedArray())
}
}
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip87Ecash.recommendation
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class MintRecommendationEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun mintUrls() = tags.mintUrls()
fun mintEventKind() = tags.mintEventKind()
fun dTag() = tags.dTag()
fun mintEventAddresses() = tags.mintEventAddresses()
fun isCashuRecommendation() = mintEventKind() == CashuMintEvent.KIND
fun isFedimintRecommendation() = mintEventKind() == FedimintEvent.KIND
companion object {
const val KIND = 38000
const val ALT_DESCRIPTION = "Mint Recommendation"
fun build(
mintIdentifier: String,
mintKind: Int,
review: String = "",
mintUrls: List<String> = emptyList(),
mintEventAddress: String? = null,
mintEventRelay: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<MintRecommendationEvent>.() -> Unit = {},
) = eventTemplate(KIND, review, createdAt) {
alt(ALT_DESCRIPTION)
dTag(mintIdentifier)
kTag(mintKind)
mintUrls.forEach { mintUrl(it) }
if (mintEventAddress != null) {
aTag(mintEventAddress, mintEventRelay)
}
initializer()
}
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip87Ecash.recommendation
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip87Ecash.MintUrlTag
fun TagArrayBuilder<MintRecommendationEvent>.dTag(mintIdentifier: String) = addUnique(arrayOf("d", mintIdentifier))
fun TagArrayBuilder<MintRecommendationEvent>.kTag(kind: Int) = addUnique(arrayOf("k", kind.toString()))
fun TagArrayBuilder<MintRecommendationEvent>.mintUrl(url: String) = add(MintUrlTag.assemble(url))
fun TagArrayBuilder<MintRecommendationEvent>.aTag(
address: String,
relay: String? = null,
) = if (relay != null) {
add(arrayOf("a", address, relay))
} else {
add(arrayOf("a", address))
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip87Ecash.recommendation
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip87Ecash.MintUrlTag
fun TagArray.mintUrls(): List<String> = mapNotNull(MintUrlTag::parse)
fun TagArray.mintEventKind(): Int? = firstOrNull { it.size >= 2 && it[0] == "k" }?.get(1)?.toIntOrNull()
fun TagArray.dTag(): String? = firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
fun TagArray.mintEventAddresses(): List<String> = filter { it.size >= 2 && it[0] == "a" }.map { it[1] }
@@ -156,12 +156,16 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.addressables.AddressableAssertionEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.events.EventAssertionEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.externalIds.ExternalIdAssertionEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent
import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
@@ -230,6 +234,7 @@ class EventFactory {
CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig)
CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig)
CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig)
CashuMintEvent.KIND -> CashuMintEvent(id, pubKey, createdAt, tags, content, sig)
CashuMintQuoteEvent.KIND -> CashuMintQuoteEvent(id, pubKey, createdAt, tags, content, sig)
CashuTokenEvent.KIND -> CashuTokenEvent(id, pubKey, createdAt, tags, content, sig)
CashuSpendingHistoryEvent.KIND -> CashuSpendingHistoryEvent(id, pubKey, createdAt, tags, content, sig)
@@ -265,6 +270,7 @@ class EventFactory {
EphemeralChatEvent.KIND -> EphemeralChatEvent(id, pubKey, createdAt, tags, content, sig)
EphemeralChatListEvent.KIND -> EphemeralChatListEvent(id, pubKey, createdAt, tags, content, sig)
ExternalIdentitiesEvent.KIND -> ExternalIdentitiesEvent(id, pubKey, createdAt, tags, content, sig)
FedimintEvent.KIND -> FedimintEvent(id, pubKey, createdAt, tags, content, sig)
FileHeaderEvent.KIND -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
ProfileGalleryEntryEvent.KIND -> ProfileGalleryEntryEvent(id, pubKey, createdAt, tags, content, sig)
FileServersEvent.KIND -> FileServersEvent(id, pubKey, createdAt, tags, content, sig)
@@ -303,6 +309,7 @@ class EventFactory {
MeetingRoomEvent.KIND -> MeetingRoomEvent(id, pubKey, createdAt, tags, content, sig)
MeetingRoomPresenceEvent.KIND -> MeetingRoomPresenceEvent(id, pubKey, createdAt, tags, content, sig)
MeetingSpaceEvent.KIND -> MeetingSpaceEvent(id, pubKey, createdAt, tags, content, sig)
MintRecommendationEvent.KIND -> MintRecommendationEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
MuteListEvent.KIND -> MuteListEvent(id, pubKey, createdAt, tags, content, sig)
NamedSiteEvent.KIND -> NamedSiteEvent(id, pubKey, createdAt, tags, content, sig)
@@ -352,6 +359,7 @@ class EventFactory {
SearchRelayListEvent.KIND -> SearchRelayListEvent(id, pubKey, createdAt, tags, content, sig)
StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteEvent.KIND -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
ThreadEvent.KIND -> ThreadEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteModificationEvent.KIND -> TextNoteModificationEvent(id, pubKey, createdAt, tags, content, sig)
TokenEvent.KIND -> TokenEvent(id, pubKey, createdAt, tags, content, sig)
TorrentEvent.KIND -> TorrentEvent(id, pubKey, createdAt, tags, content, sig)
@@ -245,95 +245,74 @@ class Url(
}
/**
* Removes dot segments from the given path as stated in
* ["RFC 3986, 5.2.4. Remove Dot Segments"](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4).
*
* @param path
* The path from which dot segments are to be removed.
*
* @return
* The path from which dot segments are removed.
* Removes dot segments from the given path per
* [RFC 3986 §5.2.4](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4).
*/
fun removeDotSegments(path: String): String {
// Initialize the input with the no-appended path components and the output
// with the empty string.
var input = path
var output = ""
val output = StringBuilder()
// While the input is not empty, loop the following steps.
while (input.isNotEmpty()) {
// If the input begins with a prefix of "../" or "./", then
// remove that prefix from the input;
if (DOT_DOT_SLASH.find(input) != null) {
input = DOT_DOT_SLASH.replaceFirst(input, "")
continue
}
when {
// A: Remove leading "../" or "./"
input.startsWith("../") -> {
input = input.substring(3)
}
// If the input begins with a prefix of "/./" or "/.", where
// "." is a complete path segment, then replace that prefix
// with "/" in the input.
if (SLASH_DOT_SLASH.find(input) != null) {
input = SLASH_DOT_SLASH.replaceFirst(input, "/")
continue
}
input.startsWith("./") -> {
input = input.substring(2)
}
// If the input begins with a prefix of "/../" or "/..",
// where ".." is a complete path segment, then replace that
// prefix with "/" in the input and remove the last segment
// and its preceding "/" (if any) from the output.
if (SLASH_DOT_DOT_SLASH.find(input) != null) {
input = SLASH_DOT_DOT_SLASH.replaceFirst(input, "/")
output = dropLastSegment(output, true)
continue
}
// B: Replace leading "/./" or "/." (end) with "/"
input.startsWith("/./") -> {
input = "/" + input.substring(3)
}
// If the input consists only of "." or "..", then remove
// that from the input.
if (DOT_OR_DOT_DOT.find(input) != null) {
input = DOT_OR_DOT_DOT.replaceFirst(input, "")
continue
}
input == "/." -> {
input = "/"
}
// Move the first path segment in the input buffer to the
// end of the output, including the initial "/" character
// (if any) and any subsequent characters up to, but not
// including, the next "/" character or the end of the input.
val matchResult = MOVE_REGEX.find(input)
if (matchResult != null) {
input = matchResult.groups["remaining"]!!.value
output += matchResult.groups["firstsegment"]!!.value
continue
// C: Replace leading "/../" or "/.." (end) with "/" and drop last output segment
input.startsWith("/../") -> {
input = "/" + input.substring(4)
dropLastSegment(output)
}
input == "/.." -> {
input = "/"
dropLastSegment(output)
}
// D: Input is just "." or ".."
input == "." || input == ".." -> {
input = ""
}
// E: Move the first path segment to output
else -> {
val startIdx = if (input.startsWith("/")) 1 else 0
val idx = input.indexOf('/', startIdx)
val segEnd = if (idx == -1) input.length else idx
output.append(input, 0, segEnd)
input = input.substring(segEnd)
}
}
}
return output
return output.toString()
}
/**
* Drops the last segment (= characters after the last slash) of a path and
* optionally the last slash. If the path doesn't contain slash, an empty string
* is returned.
*
* @param path
* The path.
*
* @param dropLastSlash
* Whether or not to drop the last slash if present.
*
* @return The path from which the last segment is removed.
* Removes the last segment and its preceding "/" from the output buffer.
* For example, "/a/b" becomes "/a" and "/a" becomes "".
*/
fun dropLastSegment(
path: String,
dropLastSlash: Boolean,
): String {
// The regular expression for the target.
val m = if (dropLastSlash) DROP_LAST_SLASH_REGEX else DROP_LAST_SEGMENT_REGEX
// Find the target. (Any inputs matches the pattern.)
m.find(path)
// Drop the target.
return m.replaceFirst(path, "")
private fun dropLastSegment(output: StringBuilder) {
val lastSlash = output.lastIndexOf('/')
if (lastSlash >= 0) {
output.delete(lastSlash, output.length)
} else {
output.clear()
}
}
/**
@@ -416,34 +395,6 @@ class Url(
}
companion object {
val DROP_LAST_SLASH_REGEX = Regex("\\/?[^/]*$")
val DROP_LAST_SEGMENT_REGEX = Regex("[^/]*$")
// If the input begins with a prefix of "../" or "./", then
// remove that prefix from the input;
val DOT_DOT_SLASH = Regex("^\\.?\\./")
// If the input begins with a prefix of "/./" or "/.", where
// "." is a complete path segment, then replace that prefix
// with "/" in the input.
val SLASH_DOT_SLASH = Regex("^\\/\\.(\\/|$)")
// If the input begins with a prefix of "/../" or "/..",
// where ".." is a complete path segment, then replace that
// prefix with "/" in the input and remove the last segment
// and its preceding "/" (if any) from the output.
val SLASH_DOT_DOT_SLASH = Regex("^\\/\\.\\.(\\/|$)")
// If the input consists only of "." or "..", then remove
// that from the input.
val DOT_OR_DOT_DOT = Regex("^\\.?\\.$")
// Move the first path segment in the input buffer to the
// end of the output, including the initial "/" character
// (if any) and any subsequent characters up to, but not
// including, the next "/" character or the end of the input.
val MOVE_REGEX = Regex("^(?<firstsegment>\\/?[^/]*)(?<remaining>.*)$")
private const val DEFAULT_SCHEME = "https"
private val SCHEME_PORT_MAP: Map<String, Int> =
mapOf(
@@ -146,128 +146,132 @@ class DomainNameReader(
private set
/**
* Reads and parses the current string to make sure the domain name started where it was supposed to,
* and the current domain name is correct.
* @return The next state to use after reading the current.
* Validates the buffered domain-name prefix ([current]) that was accumulated before
* the caller started reading from the stream. Updates label/dot counters, detects
* hex-numeric IPs, brackets for IPv6, and ASCII/international char boundaries.
*
* If an invalid character is found mid-string the domain is restarted from that
* position (e.g. `asdf%asdf.google.com` restart at `asdf.google.com`).
*
* @return [ReaderNextState.ValidDomainName] when the prefix is acceptable,
* [ReaderNextState.InvalidDomainName] otherwise.
*/
private fun readCurrent(): ReaderNextState {
if (current != null) {
// Handles the case where the string is ".hello"
if (current.length == 1 && isDot(current[0])) {
return ReaderNextState.InvalidDomainName
} else if (current.length == 3 && current.isDotPercent()) {
if (current == null) {
startDomainName = buffer.length
return ReaderNextState.ValidDomainName
}
// A lone dot or percent-encoded dot is never a valid domain start.
if ((current.length == 1 && isDot(current[0])) ||
(current.length == 3 && current.isDotPercent())
) {
return ReaderNextState.InvalidDomainName
}
startDomainName = buffer.length - current.length
numeric = true
// Index into `current` where we'd restart the domain if we hit an invalid char.
var newStart = 0
val chars = current.toCharArray()
val length = chars.size
// Detect hex literal prefix (0x...)
var isAllHexSoFar = length > 2 && chars[0] == '0' && (chars[1] == 'x' || chars[1] == 'X')
var lastWasAscii = length > 0 && chars[0].code < INTERNATIONAL_CHAR_START
var index = if (isAllHexSoFar) 2 else 0
while (index < length) {
val ch = chars[index]
val isAscii = ch.code < INTERNATIONAL_CHAR_START
currentLabelLength++
topLevelLength = currentLabelLength
if (currentLabelLength > MAX_LABEL_LENGTH) {
return ReaderNextState.InvalidDomainName
}
// The location where the domain name started.
startDomainName = buffer.length - current.length
// flag that the domain is currently all numbers and/or dots.
numeric = true
// If an invalid char is found, we can just restart the domain from there.
var newStart = 0
val currArray = current.toCharArray()
val length = currArray.size
// hex special case
var isAllHexSoFar =
length > 2 && (currArray[0] == '0' && (currArray[1] == 'x' || currArray[1] == 'X'))
var lastWasAscii = length > 0 && currArray[0].code < INTERNATIONAL_CHAR_START
var index = if (isAllHexSoFar) 2 else 0
var done = false
var isAscii = false
while (index < length && !done) {
// get the current character and update length counts.
val curr = currArray[index]
isAscii = curr.code < INTERNATIONAL_CHAR_START
currentLabelLength++
topLevelLength = currentLabelLength
// Is the length of the last part > 64 (plus one since we just incremented)
if (currentLabelLength > MAX_LABEL_LENGTH) {
return ReaderNextState.InvalidDomainName
} else if (isDot(curr)) {
// found a dot. Increment dot count, and reset last length
when {
isDot(ch) -> {
dots++
currentLabelLength = 0
} else if (curr == '[') {
}
ch == '[' -> {
seenBracket = true
numeric = false
} else if (curr == '%' && index + 2 < length && isHex(currArray[index + 1]) && isHex(currArray[index + 2])) {
// handle url encoded dot
if (currArray[index + 1] == '2' && currArray[index + 2] == 'e') {
}
ch == '%' && index + 2 < length && isHex(chars[index + 1]) && isHex(chars[index + 2]) -> {
// Percent-encoded byte; check for encoded dot (%2e)
if (chars[index + 1] == '2' && chars[index + 2] == 'e') {
dots++
currentLabelLength = 0
} else {
numeric = false
}
index += 2
} else if (isAllHexSoFar) {
// if it's a valid character in the domain that is not numeric
if (!isHex(curr)) {
numeric = false
isAllHexSoFar = false
index-- // backtrack to rerun last character knowing it isn't hex.
}
} else if (isAscii == lastWasAscii && (isAlpha(curr) || curr == '-' || !isAscii)) {
// we don't allow mixed domains: doesn't come here if it changed form ascii to not ascii.
}
isAllHexSoFar && !isHex(ch) -> {
// Thought it was hex but this char isn't — reprocess as non-hex
numeric = false
isAllHexSoFar = false
index-- // backtrack to re-evaluate this char
}
isAscii == lastWasAscii && (isAlpha(ch) || ch == '-' || !isAscii) -> {
// Valid domain character (same script as previous)
numeric = false
lastWasAscii = isAscii
} else if (isAscii != lastWasAscii) {
// if its not _numeric and not alphabetical, then restart searching for a domain from this point.
}
isAscii != lastWasAscii -> {
// Script boundary (ASCII ↔ international) — restart domain from here
newStart = index
currentLabelLength = 0
topLevelLength = 0
numeric = true
dots = 0
// done = true
resetDomainCounters()
lastWasAscii = isAscii
} else if (index == 0) {
if (curr in UrlDetector.CANNOT_BEGIN_URLS_WITH) {
newStart = index + 1
currentLabelLength = 0
topLevelLength = 0
numeric = true
dots = 0
}
}
index++
}
// An invalid character for the domain was found somewhere in the current buffer.
// cut the first part of the domain out. For example:
// http://asdf%asdf.google.com <- asdf.google.com is still valid, so restart from the %
if (newStart > 0) {
// make sure the location is not at the end. Otherwise the thing is just invalid.
if (newStart < current.length) {
buffer.clear()
buffer.append(current.substring(newStart))
// cut out the previous part, so now the domain name has to be from here.
startDomainName = 0
}
// now after cutting if the buffer is just "." newStart > current (last character in current is invalid)
if (newStart >= current.length || buffer.toString() == ".") {
return ReaderNextState.InvalidDomainName
index == 0 && ch in UrlDetector.CANNOT_BEGIN_URLS_WITH -> {
// Invalid leading char — restart after it
newStart = index + 1
resetDomainCounters()
}
}
} else {
startDomainName = buffer.length
index++
}
// If we found an invalid region, trim the buffer to start after it.
if (newStart > 0) {
if (newStart < current.length) {
buffer.clear()
buffer.append(current.substring(newStart))
startDomainName = 0
}
if (newStart >= current.length || buffer.toString() == ".") {
return ReaderNextState.InvalidDomainName
}
}
// all else is good, return OK
return ReaderNextState.ValidDomainName
}
/**
* Resets domain tracking counters when the domain start is being moved forward.
*/
private fun resetDomainCounters() {
currentLabelLength = 0
topLevelLength = 0
numeric = true
dots = 0
}
/**
* Reads the Dns and returns the next state the state machine should take in throwing this out, or continue processing
* if this is a valid domain name.
@@ -527,111 +531,66 @@ class DomainNameReader(
* @return Returns true if it's a valid ipv4 address
*/
private fun isValidIpv4(testDomain: String): Boolean {
var valid = false
val length: Int = testDomain.length
if (length > 0) {
// handling format without dots. Ex: http://2123123123123/path/a, http://0x8242343/aksdjf
if (dots == 0) {
try {
val value: Long
if (length > 2 && testDomain[0] == '0' && testDomain[1] == 'x') { // hex
// digit must be within ['0', '9'] or ['A', 'F'] or ['a', 'f']
for (c in 2..<length) {
val d: Char = testDomain[c]
if ((d < '0' || (d in ':'..<'A') || (d in 'G'..<'a') || d > 'f')) {
return false
}
}
value = testDomain.substring(2).toLong(16)
} else if (testDomain[0] == '0') { // octal
// digit must be within ['0', '7']
for (c in 1..<length) {
val d: Char = testDomain[c]
if (d !in '0'..'7') {
return false
}
}
value = testDomain.substring(1).toLong(8)
} else { // decimal
// digit must be within ['0', '9']
for (c in 0..<length) {
val d: Char = testDomain[c]
if (d !in '0'..'9') {
return false
}
}
value = testDomain.toLong()
}
valid = value in MIN_NUMERIC_DOMAIN_VALUE..MAX_NUMERIC_DOMAIN_VALUE
} catch (_: NumberFormatException) {
valid = false
}
} else if (dots == 3) {
// Dotted decimal/hex/octal format
val parts: List<String> = splitByDot(testDomain)
valid = true
if (testDomain.isEmpty()) return false
// check each part of the ip and make sure its valid.
var i = 0
while (i < parts.size && valid) {
val part = parts[i]
val partLen: Int = part.length
if (partLen > 0) {
val parsedNum: String
val base: Int
if (partLen > 2 && part[0] == '0' && part[1] == 'x') { // dotted hex
// digit must be within ['0', '9'] or ['A', 'F'] or ['a', 'f']
for (c in 2..<partLen) {
val d: Char = part[c]
if ((d < '0' || (d in ':'..<'A') || (d in 'G'..<'a') || d > 'f')) {
return false
}
}
parsedNum = part.substring(2)
base = 16
} else if (part[0] == '0') { // dotted octal
// digit must be within ['0', '7']
for (c in 1..<partLen) {
val d: Char = part[c]
if (d !in '0'..'7') {
return false
}
}
parsedNum = part.substring(1)
base = 8
} else { // dotted decimal
// digit must be within ['0', '9']
for (c in 0..<partLen) {
val d: Char = part[c]
if (d !in '0'..'9') {
return false
}
}
parsedNum = part
base = 10
}
// Dotless format: http://2123123123123/path, http://0x8242343/aksdjf
if (dots == 0) {
val value = parseNumericLiteral(testDomain) ?: return false
return value in MIN_NUMERIC_DOMAIN_VALUE..MAX_NUMERIC_DOMAIN_VALUE
}
val section =
if (parsedNum.isEmpty()) {
0
} else {
try {
parsedNum.toInt(base)
} catch (_: NumberFormatException) {
return false
}
}
if (section !in MIN_IP_PART..MAX_IP_PART) {
valid = false
}
} else {
valid = false
}
i++
}
// Dotted format: must have exactly 4 parts (3 dots)
if (dots != 3) return false
val parts = splitByDot(testDomain)
for (part in parts) {
if (part.isEmpty()) return false
val section = parseNumericLiteral(part) ?: return false
if (section !in MIN_IP_PART..MAX_IP_PART) return false
}
return true
}
/**
* Parses a numeric literal that may be decimal, hexadecimal (0x prefix), or octal (0 prefix).
* Validates digit ranges before parsing to avoid exceptions.
* @return The parsed value as a Long, or null if the string is not a valid numeric literal.
*/
private fun parseNumericLiteral(s: String): Long? {
if (s.isEmpty()) return 0L
val digits: String
val base: Int
if (s.length > 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) {
// Hexadecimal
digits = s.substring(2)
base = 16
for (c in digits) {
if (!isHex(c)) return null
}
} else if (s[0] == '0') {
// Octal
digits = s.substring(1)
base = 8
for (c in digits) {
if (c !in '0'..'7') return null
}
} else {
// Decimal
digits = s
base = 10
for (c in digits) {
if (c !in '0'..'9') return null
}
}
return valid
if (digits.isEmpty()) return 0L
return try {
digits.toLong(base)
} catch (_: NumberFormatException) {
null
}
}
/**
@@ -315,81 +315,75 @@ class UrlDetector(
while (!reader.eof()) {
val curr = reader.read()
// if we match a slash, look for a second one.
if (curr == '/') {
buffer.append(curr)
if (numSlashes == 1) {
// return only if its an approved protocol. This can be expanded to allow others
val schemeStartIndex: Int = findValidSchemeStartIndex(buffer.toString())
// Two slashes found — check for a valid scheme like "http://"
val schemeStartIndex = findValidSchemeStartIndex(buffer.toString())
if (schemeStartIndex >= 0) {
buffer.deleteRange(0, schemeStartIndex)
currentUrlMarker.setIndex(UrlPart.SCHEME, 0)
return true
} else {
return false
}
return false
}
numSlashes++
} else if (curr == ' ') {
// if we find a space or end of input, then nothing found.
buffer.append(curr)
return false
} else if (curr == '[') { // if we're starting to see an ipv6 address
reader.goBack() // unread the '[', so that we can start looking for ipv6
return false
} else if (originalLength > 0 && numSlashes == 0 && CharUtils.isAlpha(curr)) {
// If we had already read something before the : and we are matching regardless of slashes, assume it's a scheme
// Add the slashes to the end of the scheme so it matches what's in the scheme list
val schemeStartIndex = findValidSchemeNoSlashesStartIndex(buffer.toString())
if (schemeStartIndex >= 0) {
if (schemeStartIndex > 0) {
buffer.deleteRange(0, schemeStartIndex)
}
currentUrlMarker.setIndex(UrlPart.SCHEME, 0)
reader.goBack()
return true
} else {
reader.goBack()
return readUserPass(0)
}
// If this didn't match a defined scheme, continue processing as usual
} else if (originalLength > 0 || numSlashes > 0 || !CharUtils.isAlpha(curr)) {
// if it's not a character a-z or A-Z then assume we aren't matching scheme, but instead
// matching username and password.
// Add the slashes to the end of the scheme so it matches what's in the scheme list
val schemeStartIndex = findValidSchemeNoSlashesStartIndex(buffer.toString())
if (schemeStartIndex >= 0) {
if (schemeStartIndex > 0) {
buffer.deleteRange(0, schemeStartIndex)
}
currentUrlMarker.setIndex(UrlPart.SCHEME, 0)
reader.goBack()
return true
}
} else if (curr == '[') {
// Start of IPv6 — unread and let the caller handle it
reader.goBack()
return readUserPass(0)
return false
} else if (originalLength > 0 || numSlashes > 0 || !CharUtils.isAlpha(curr)) {
// Not a plain alpha char continuing a potential scheme name, or we already
// had content before the colon / had slashes. Try matching a scheme without
// slashes (e.g. "nostr:npub1...") then fall back to username:password.
return trySchemeNoSlashesOrUserPass()
}
}
return false
}
private fun findValidSchemeStartIndex(optionalScheme: String): Int {
val optionalSchemeLowercase = optionalScheme.lowercase()
return VALID_SCHEMES
.filter(optionalSchemeLowercase::endsWith)
.map(optionalSchemeLowercase::lastIndexOf)
.firstOrNull() ?: -1
/**
* Attempts to match the buffer as a scheme without slashes (e.g. "nostr:").
* If that fails, treats the content as a potential username:password.
*/
private fun trySchemeNoSlashesOrUserPass(): Boolean {
val schemeStartIndex = findValidSchemeNoSlashesStartIndex(buffer.toString())
if (schemeStartIndex >= 0) {
if (schemeStartIndex > 0) {
buffer.deleteRange(0, schemeStartIndex)
}
currentUrlMarker.setIndex(UrlPart.SCHEME, 0)
reader.goBack()
return true
}
reader.goBack()
return readUserPass(0)
}
private fun findValidSchemeNoSlashesStartIndex(optionalScheme: String): Int {
val optionalSchemeLowercase = optionalScheme.lowercase()
return VALID_SCHEMES_NO_SLASHES
.filter(optionalSchemeLowercase::endsWith)
.map(optionalSchemeLowercase::lastIndexOf)
.firstOrNull() ?: -1
private fun findValidSchemeStartIndex(optionalScheme: String): Int = findSchemeSuffix(optionalScheme, VALID_SCHEMES)
private fun findValidSchemeNoSlashesStartIndex(optionalScheme: String): Int = findSchemeSuffix(optionalScheme, VALID_SCHEMES_NO_SLASHES)
/**
* Checks if [buffer] ends with any of the [schemes] (case-insensitive)
* and returns the start index of the match, or -1 if none match.
*/
private fun findSchemeSuffix(
buffer: String,
schemes: List<String>,
): Int {
val len = buffer.length
for (scheme in schemes) {
val schemeLen = scheme.length
if (len >= schemeLen && buffer.regionMatches(len - schemeLen, scheme, 0, schemeLen, ignoreCase = true)) {
return len - schemeLen
}
}
return -1
}
/**
@@ -618,57 +612,33 @@ class UrlDetector(
var endsOnASlash = true
while (!reader.eof()) {
// read the next char
val curr = reader.read()
if (curr == ' ') {
// if end of state and we got here, then the url is valid
// if it is not just a word/word
if (
currentUrlMarker.hasScheme() ||
currentUrlMarker.hasPort() ||
currentUrlMarker.hasUsernamePassword() ||
!isSingleLevelLabel ||
endsOnASlash
) {
return readEnd(ReadEndState.ValidUrl)
} else {
return readEnd(ReadEndState.InvalidUrl)
}
return readEnd(if (isPathValid(endsOnASlash)) ReadEndState.ValidUrl else ReadEndState.InvalidUrl)
}
// append the char
buffer.append(curr)
// now see if we move to another state.
if (curr == '?') {
// if ? read query string
return readQueryString()
} else if (curr == '#') {
// if # read the fragment
return readFragment()
}
if (curr == '?') return readQueryString()
if (curr == '#') return readFragment()
endsOnASlash = curr == '/'
}
// end of input then this url is good.
// if end of state and we got here, then the url is valid
// if it is not just a word/word
// no need to check for query and fragments
// here we accept urls that end in /
if (
currentUrlMarker.hasScheme() ||
return readEnd(if (isPathValid(endsOnASlash)) ReadEndState.ValidUrl else ReadEndState.InvalidUrl)
}
/**
* A path is valid if the URL has additional context (scheme, port, credentials)
* or if it's not an ambiguous single-level label like "word/word".
*/
private fun isPathValid(endsOnASlash: Boolean): Boolean =
currentUrlMarker.hasScheme() ||
currentUrlMarker.hasPort() ||
currentUrlMarker.hasUsernamePassword() ||
!isSingleLevelLabel ||
endsOnASlash
) {
return readEnd(ReadEndState.ValidUrl)
} else {
return readEnd(ReadEndState.InvalidUrl)
}
}
/**
* The url has been read to here. Remember the url if its valid, and reset state.