temporary commit
This commit is contained in:
16
build.gradle
16
build.gradle
@@ -1,5 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias catalog.plugins.kotlin
|
alias catalog.plugins.kotlin.jvm
|
||||||
alias catalog.plugins.envelope
|
alias catalog.plugins.envelope
|
||||||
id 'maven-publish'
|
id 'maven-publish'
|
||||||
}
|
}
|
||||||
@@ -55,11 +55,19 @@ Provider<EnvelopeJarTask> envelopeJarTaskProvider = tasks.named('envelopeJar', E
|
|||||||
mainClass = 'net.woggioni.gbcs.GradleBuildCacheServer'
|
mainClass = 'net.woggioni.gbcs.GradleBuildCacheServer'
|
||||||
systemProperty 'java.util.logging.config.class', 'net.woggioni.gbcs.LoggingConfig'
|
systemProperty 'java.util.logging.config.class', 'net.woggioni.gbcs.LoggingConfig'
|
||||||
systemProperty 'log.config.source', 'logging.properties'
|
systemProperty 'log.config.source', 'logging.properties'
|
||||||
|
|
||||||
|
manifest {
|
||||||
|
attributes([
|
||||||
|
'Add-Exports' : 'java.base/sun.security.x509'
|
||||||
|
])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapper {
|
envelopeRun {
|
||||||
distributionType = Wrapper.DistributionType.BIN
|
|
||||||
gradleVersion = getProperty('gradle.version')
|
mainModule = 'net.woggioni.envelope'
|
||||||
|
modularity.inferModulePath = true
|
||||||
|
jvmArgs('--add-exports=java.base/sun.security.x509=io.netty.handler')
|
||||||
}
|
}
|
||||||
|
|
||||||
def envelopeJarArtifact = artifacts.add('archives', envelopeJarTaskProvider.get().archiveFile.get().asFile) {
|
def envelopeJarArtifact = artifacts.add('archives', envelopeJarTaskProvider.get().archiveFile.get().asFile) {
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
gbcs.version = 0.1-SNAPSHOT
|
gbcs.version = 0.1-SNAPSHOT
|
||||||
|
|
||||||
gradle.version = 7.5.1
|
lys.version = 0.2-SNAPSHOT
|
||||||
lys.version = 0.1-SNAPSHOT
|
|
||||||
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,5 +1,5 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
@@ -11,6 +11,7 @@ pluginManagement {
|
|||||||
}
|
}
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
|
includeBuild('../envelope')
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
@@ -30,3 +31,4 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = 'gbcs'
|
rootProject.name = 'gbcs'
|
||||||
|
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
import java.net.URLStreamHandlerFactory;
|
||||||
|
|
||||||
module net.woggioni.gbcs {
|
module net.woggioni.gbcs {
|
||||||
requires java.xml;
|
requires java.xml;
|
||||||
requires java.logging;
|
requires java.logging;
|
||||||
@@ -12,4 +14,6 @@ module net.woggioni.gbcs {
|
|||||||
requires net.woggioni.jwo;
|
requires net.woggioni.jwo;
|
||||||
|
|
||||||
exports net.woggioni.gbcs;
|
exports net.woggioni.gbcs;
|
||||||
|
opens net.woggioni.gbcs to net.woggioni.envelope;
|
||||||
|
uses java.net.URLStreamHandlerFactory;
|
||||||
}
|
}
|
@@ -0,0 +1,70 @@
|
|||||||
|
package net.woggioni.gbcs
|
||||||
|
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.cert.CertPathValidator
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import java.security.cert.PKIXParameters
|
||||||
|
import java.security.cert.PKIXRevocationChecker
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.util.EnumSet
|
||||||
|
import io.netty.channel.ChannelHandlerContext
|
||||||
|
import io.netty.channel.ChannelInboundHandlerAdapter
|
||||||
|
import io.netty.handler.ssl.SslHandler
|
||||||
|
import io.netty.handler.ssl.SslHandshakeCompletionEvent
|
||||||
|
import javax.net.ssl.SSLSession
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
|
||||||
|
class ClientCertificateValidator private constructor(private val sslHandler : SslHandler, private val x509TrustManager: X509TrustManager) : ChannelInboundHandlerAdapter() {
|
||||||
|
override fun userEventTriggered(ctx: ChannelHandlerContext, evt: Any) {
|
||||||
|
if (evt is SslHandshakeCompletionEvent) {
|
||||||
|
if (evt.isSuccess) {
|
||||||
|
val session: SSLSession = sslHandler.engine().session
|
||||||
|
val clientCertificateChain = session.peerCertificates as Array<X509Certificate>
|
||||||
|
val authType: String = clientCertificateChain[0].publicKey.algorithm
|
||||||
|
x509TrustManager.checkClientTrusted(clientCertificateChain, authType)
|
||||||
|
} else {
|
||||||
|
// Handle the failure, for example by closing the channel.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.userEventTriggered(ctx, evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun of(sslHandler : SslHandler, trustStore : KeyStore?) : ClientCertificateValidator {
|
||||||
|
val certificateFactory = CertificateFactory.getInstance("X.509")
|
||||||
|
|
||||||
|
val validator = CertPathValidator.getInstance("PKIX").apply {
|
||||||
|
val rc = revocationChecker as PKIXRevocationChecker
|
||||||
|
rc.options = EnumSet.of(
|
||||||
|
PKIXRevocationChecker.Option.NO_FALLBACK,
|
||||||
|
PKIXRevocationChecker.Option.SOFT_FAIL,
|
||||||
|
PKIXRevocationChecker.Option.PREFER_CRLS)
|
||||||
|
}
|
||||||
|
|
||||||
|
val manager = if(trustStore != null) {
|
||||||
|
val params = PKIXParameters(trustStore)
|
||||||
|
object : X509TrustManager {
|
||||||
|
override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String) {
|
||||||
|
val clientCertificateChain = certificateFactory.generateCertPath(chain.toList())
|
||||||
|
validator.validate(clientCertificateChain, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkServerTrusted(chain: Array<out X509Certificate>, authType: String) {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> {
|
||||||
|
throw NotImplementedError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
trustManagerFactory.trustManagers.asSequence().filter { it is X509TrustManager }.single() as X509TrustManager
|
||||||
|
}
|
||||||
|
return ClientCertificateValidator(sslHandler, manager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,10 +1,95 @@
|
|||||||
package net.woggioni.gbcs
|
package net.woggioni.gbcs
|
||||||
|
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import org.w3c.dom.Document
|
||||||
|
import net.woggioni.gbcs.Xml.asIterable
|
||||||
|
import org.w3c.dom.Element
|
||||||
|
|
||||||
|
data class HostAndPort(val host: String, val port : Integer) {
|
||||||
|
override fun toString() = "$host:$port"
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TlsConfiguration(val keyStore: KeyStore?, val trustStore: TrustStore?, val verifyClients : Boolean)
|
||||||
|
data class KeyStore(
|
||||||
|
val file : Path,
|
||||||
|
val password : String?,
|
||||||
|
val keyAlias: String,
|
||||||
|
val keyPassword : String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TrustStore(
|
||||||
|
val file : Path,
|
||||||
|
val password : String?,
|
||||||
|
)
|
||||||
|
|
||||||
data class Configuration(
|
data class Configuration(
|
||||||
val cacheFolder : Path,
|
val cacheFolder : Path,
|
||||||
val host : String,
|
val host : String,
|
||||||
val port : Int,
|
val port : Int,
|
||||||
val users : Map<String, Set<Role>>
|
val users : Map<String, Set<Role>>,
|
||||||
)
|
val tlsConfiguration: TlsConfiguration?,
|
||||||
|
val serverPath : String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun parse(document : Element) : Configuration {
|
||||||
|
|
||||||
|
var cacheFolder = Paths.get(System.getProperty("user.home")).resolve(".gbcs")
|
||||||
|
var host : String = "127.0.0.1"
|
||||||
|
var port : Int = 11080
|
||||||
|
var users = emptyMap<String, Set<Role>>()
|
||||||
|
var tlsConfiguration : TlsConfiguration? = null
|
||||||
|
var serverPath = "/"
|
||||||
|
|
||||||
|
for(child in document.asIterable()) {
|
||||||
|
when(child.nodeName) {
|
||||||
|
"bind" -> {
|
||||||
|
host = child.getAttribute("host")
|
||||||
|
port = Integer.parseInt(child.getAttribute("port"))
|
||||||
|
}
|
||||||
|
"cache" -> {
|
||||||
|
cacheFolder = Paths.get(child.textContent)
|
||||||
|
}
|
||||||
|
"tls" -> {
|
||||||
|
val verifyClients = child.getAttribute("verify-clients")
|
||||||
|
.takeIf(String::isNotEmpty)
|
||||||
|
?.let(String::toBoolean) ?: false
|
||||||
|
var keyStore : KeyStore? = null
|
||||||
|
var trustStore : TrustStore? = null
|
||||||
|
for(granChild in child.asIterable()) {
|
||||||
|
when(granChild.nodeName) {
|
||||||
|
"keystore" -> {
|
||||||
|
val trustStoreFile = Paths.get(granChild.getAttribute("file"))
|
||||||
|
val trustStorePassword = granChild.getAttribute("password")
|
||||||
|
.takeIf(String::isNotEmpty)
|
||||||
|
val keyAlias = granChild.getAttribute("server-key-alias")
|
||||||
|
val keyPasswordPassword = granChild.getAttribute("server-key-password")
|
||||||
|
.takeIf(String::isNotEmpty)
|
||||||
|
keyStore = KeyStore(
|
||||||
|
trustStoreFile,
|
||||||
|
trustStorePassword,
|
||||||
|
keyAlias,
|
||||||
|
keyPasswordPassword
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"truststore" -> {
|
||||||
|
val trustStoreFile = Paths.get(granChild.getAttribute("file"))
|
||||||
|
val trustStorePassword = granChild.getAttribute("password")
|
||||||
|
.takeIf(String::isNotEmpty)
|
||||||
|
trustStore = TrustStore(
|
||||||
|
trustStoreFile,
|
||||||
|
trustStorePassword
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tlsConfiguration = TlsConfiguration(keyStore, trustStore, verifyClients)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return Configuration(cacheFolder, host, port, users, tlsConfiguration, serverPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -1,5 +1,20 @@
|
|||||||
package net.woggioni.gbcs
|
package net.woggioni.gbcs
|
||||||
|
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLStreamHandlerFactory
|
||||||
|
import java.nio.channels.FileChannel
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import java.nio.file.StandardOpenOption
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.util.AbstractMap.SimpleEntry
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.ServiceLoader
|
||||||
import io.netty.bootstrap.ServerBootstrap
|
import io.netty.bootstrap.ServerBootstrap
|
||||||
import io.netty.buffer.ByteBuf
|
import io.netty.buffer.ByteBuf
|
||||||
import io.netty.buffer.Unpooled
|
import io.netty.buffer.Unpooled
|
||||||
@@ -30,25 +45,18 @@ import io.netty.handler.codec.http.HttpResponseStatus
|
|||||||
import io.netty.handler.codec.http.HttpServerCodec
|
import io.netty.handler.codec.http.HttpServerCodec
|
||||||
import io.netty.handler.codec.http.HttpUtil
|
import io.netty.handler.codec.http.HttpUtil
|
||||||
import io.netty.handler.codec.http.LastHttpContent
|
import io.netty.handler.codec.http.LastHttpContent
|
||||||
|
import io.netty.handler.ssl.SslContextBuilder
|
||||||
|
import io.netty.handler.ssl.util.SelfSignedCertificate
|
||||||
import io.netty.handler.stream.ChunkedNioFile
|
import io.netty.handler.stream.ChunkedNioFile
|
||||||
import io.netty.handler.stream.ChunkedWriteHandler
|
import io.netty.handler.stream.ChunkedWriteHandler
|
||||||
import io.netty.util.concurrent.DefaultEventExecutorGroup
|
import io.netty.util.concurrent.DefaultEventExecutorGroup
|
||||||
import io.netty.util.concurrent.EventExecutorGroup
|
import io.netty.util.concurrent.EventExecutorGroup
|
||||||
|
import net.woggioni.jwo.Application
|
||||||
import net.woggioni.jwo.JWO
|
import net.woggioni.jwo.JWO
|
||||||
import java.nio.channels.FileChannel
|
import net.woggioni.jwo.Tuple2
|
||||||
import java.nio.file.Files
|
|
||||||
import java.nio.file.Path
|
|
||||||
import java.nio.file.Paths
|
|
||||||
import java.nio.file.StandardCopyOption
|
|
||||||
import java.nio.file.StandardOpenOption
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.util.AbstractMap.SimpleEntry
|
|
||||||
import java.util.Base64
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import javax.net.ssl.SSLEngine
|
|
||||||
|
|
||||||
|
|
||||||
class GradleBuildCacheServer {
|
class GradleBuildCacheServer(private val cfg : Configuration) {
|
||||||
|
|
||||||
internal class HttpChunkContentCompressor(threshold : Int, vararg compressionOptions: CompressionOptions = emptyArray())
|
internal class HttpChunkContentCompressor(threshold : Int, vararg compressionOptions: CompressionOptions = emptyArray())
|
||||||
: HttpContentCompressor(threshold, *compressionOptions) {
|
: HttpContentCompressor(threshold, *compressionOptions) {
|
||||||
@@ -115,25 +123,62 @@ class GradleBuildCacheServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class ServerInitializer(private val cacheDir: Path) : ChannelInitializer<Channel>() {
|
private class ServerInitializer(private val cfg : Configuration) : ChannelInitializer<Channel>() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val group: EventExecutorGroup = DefaultEventExecutorGroup(Runtime.getRuntime().availableProcessors())
|
||||||
|
fun loadKeystore(file : Path, password : String?) : KeyStore {
|
||||||
|
val ext = JWO.splitExtension(file)
|
||||||
|
.map(Tuple2<String, String>::get_2)
|
||||||
|
.orElseThrow {
|
||||||
|
IllegalArgumentException(
|
||||||
|
"Keystore file '${file}' must have .jks or p12 extension")
|
||||||
|
}
|
||||||
|
val keystore = when(ext.lowercase()) {
|
||||||
|
"jks" -> KeyStore.getInstance("JKS")
|
||||||
|
"p12", "pfx" -> KeyStore.getInstance("PKCS12")
|
||||||
|
else -> throw IllegalArgumentException(
|
||||||
|
"Keystore file '${file}' must have .jks or p12 extension")
|
||||||
|
}
|
||||||
|
Files.newInputStream(file).use {
|
||||||
|
keystore.load(it, password?.let(String::toCharArray))
|
||||||
|
}
|
||||||
|
return keystore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun initChannel(ch: Channel) {
|
override fun initChannel(ch: Channel) {
|
||||||
val sslEngine: SSLEngine = SSLContext.getDefault().createSSLEngine()
|
|
||||||
sslEngine.useClientMode = false
|
|
||||||
val pipeline = ch.pipeline()
|
val pipeline = ch.pipeline()
|
||||||
// pipeline.addLast(SslHandler(sslEngine))
|
val tlsConfiguration = cfg.tlsConfiguration
|
||||||
|
if(tlsConfiguration != null) {
|
||||||
|
val ssc = SelfSignedCertificate()
|
||||||
|
val keyStore = tlsConfiguration.keyStore
|
||||||
|
val sslCtx = if(keyStore == null) {
|
||||||
|
SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()).build()
|
||||||
|
} else {
|
||||||
|
val javaKeyStore = loadKeystore(keyStore.file, keyStore.password)
|
||||||
|
val serverKey = javaKeyStore.getKey(
|
||||||
|
keyStore.keyAlias, keyStore.keyPassword?.let(String::toCharArray)) as PrivateKey
|
||||||
|
val serverCert = javaKeyStore.getCertificateChain(keyStore.keyAlias) as Array<X509Certificate>
|
||||||
|
SslContextBuilder.forServer(serverKey, *serverCert).build()
|
||||||
|
}
|
||||||
|
val sslHandler = sslCtx.newHandler(ch.alloc())
|
||||||
|
pipeline.addLast(sslHandler)
|
||||||
|
if(tlsConfiguration.verifyClients) {
|
||||||
|
val trustStore = tlsConfiguration.trustStore?.let {
|
||||||
|
loadKeystore(it.file, it.password)
|
||||||
|
}
|
||||||
|
pipeline.addLast(ClientCertificateValidator.of(sslHandler, trustStore))
|
||||||
|
}
|
||||||
|
}
|
||||||
pipeline.addLast(HttpServerCodec())
|
pipeline.addLast(HttpServerCodec())
|
||||||
pipeline.addLast(HttpChunkContentCompressor(1024))
|
pipeline.addLast(HttpChunkContentCompressor(1024))
|
||||||
pipeline.addLast(ChunkedWriteHandler())
|
pipeline.addLast(ChunkedWriteHandler())
|
||||||
pipeline.addLast(HttpObjectAggregator(Int.MAX_VALUE))
|
pipeline.addLast(HttpObjectAggregator(Int.MAX_VALUE))
|
||||||
pipeline.addLast(NettyHttpBasicAuthenticator(mapOf("user" to "password")) { user, _ -> user == "user" })
|
// pipeline.addLast(NettyHttpBasicAuthenticator(mapOf("user" to "password")) { user, _ -> user == "user" })
|
||||||
pipeline.addLast(group, ServerHandler(cacheDir, "/cache"))
|
pipeline.addLast(group, ServerHandler(cfg.cacheFolder, cfg.serverPath))
|
||||||
pipeline.addLast(ExceptionHandler())
|
pipeline.addLast(ExceptionHandler())
|
||||||
Files.createDirectories(cacheDir)
|
Files.createDirectories(cfg.cacheFolder)
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val group: EventExecutorGroup = DefaultEventExecutorGroup(Runtime.getRuntime().availableProcessors())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,12 +299,13 @@ class GradleBuildCacheServer {
|
|||||||
// Configure the server
|
// Configure the server
|
||||||
httpBootstrap.group(bossGroup, workerGroup)
|
httpBootstrap.group(bossGroup, workerGroup)
|
||||||
.channel(NioServerSocketChannel::class.java)
|
.channel(NioServerSocketChannel::class.java)
|
||||||
.childHandler(ServerInitializer(Paths.get("/tmp/gbcs"))) // <-- Our handler created here
|
.childHandler(ServerInitializer(cfg))
|
||||||
.option(ChannelOption.SO_BACKLOG, 128)
|
.option(ChannelOption.SO_BACKLOG, 128)
|
||||||
.childOption(ChannelOption.SO_KEEPALIVE, true)
|
.childOption(ChannelOption.SO_KEEPALIVE, true)
|
||||||
|
|
||||||
// Bind and start to accept incoming connections.
|
// Bind and start to accept incoming connections.
|
||||||
val httpChannel = httpBootstrap.bind(HTTP_PORT).sync()
|
val bindAddress = InetSocketAddress(cfg.host, cfg.port)
|
||||||
|
val httpChannel = httpBootstrap.bind(bindAddress).sync()
|
||||||
|
|
||||||
// Wait until server socket is closed
|
// Wait until server socket is closed
|
||||||
httpChannel.channel().closeFuture().sync()
|
httpChannel.channel().closeFuture().sync()
|
||||||
@@ -270,11 +316,69 @@ class GradleBuildCacheServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val HTTP_PORT = 8080
|
|
||||||
|
private const val PROTOCOL_HANDLER = "java.protocol.handler.pkgs"
|
||||||
|
private const val HANDLERS_PACKAGE = "net.woggioni.gbcs.url"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset any cached handlers just in case a jar protocol has already been used. We
|
||||||
|
* reset the handler by trying to set a null [URLStreamHandlerFactory] which
|
||||||
|
* should have no effect other than clearing the handlers cache.
|
||||||
|
*/
|
||||||
|
private fun resetCachedUrlHandlers() {
|
||||||
|
try {
|
||||||
|
URL.setURLStreamHandlerFactory(null)
|
||||||
|
} catch (ex: Error) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun registerUrlProtocolHandler() {
|
||||||
|
val handlers = System.getProperty(PROTOCOL_HANDLER, "")
|
||||||
|
System.setProperty(
|
||||||
|
PROTOCOL_HANDLER,
|
||||||
|
if (handlers == null || handlers.isEmpty()) HANDLERS_PACKAGE else "$handlers|$HANDLERS_PACKAGE"
|
||||||
|
)
|
||||||
|
resetCachedUrlHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
|
SelfSignedCertificate()
|
||||||
|
ServiceLoader.load(javaClass.module.layer, URLStreamHandlerFactory::class.java).stream().forEach {
|
||||||
|
println(it.type())
|
||||||
|
}
|
||||||
|
// registerUrlProtocolHandler()
|
||||||
Thread.currentThread().contextClassLoader = GradleBuildCacheServer::class.java.classLoader
|
Thread.currentThread().contextClassLoader = GradleBuildCacheServer::class.java.classLoader
|
||||||
GradleBuildCacheServer().run()
|
val app = Application.builder("gbcs")
|
||||||
|
.configurationDirectoryEnvVar("GBCS_CONFIGURATION_DIR")
|
||||||
|
.configurationDirectoryPropertyKey("net.woggioni.gbcs.conf.dir")
|
||||||
|
.build()
|
||||||
|
val confDir = app.computeConfigurationDirectory()
|
||||||
|
val configurationFile = confDir.resolve("gbcs.xml")
|
||||||
|
|
||||||
|
if(!Files.exists(configurationFile)) {
|
||||||
|
Files.createDirectories(confDir)
|
||||||
|
val defaultConfigurationFileResourcePath = "net/woggioni/gbcs/gbcs-default.xml"
|
||||||
|
val defaultConfigurationFileResource = GradleBuildCacheServer.javaClass.classLoader
|
||||||
|
.getResource(defaultConfigurationFileResourcePath)
|
||||||
|
?: throw IllegalStateException(
|
||||||
|
"Missing default configuration file 'classpath:$defaultConfigurationFileResourcePath'")
|
||||||
|
Files.newOutputStream(configurationFile).use { outputStream ->
|
||||||
|
defaultConfigurationFileResource.openStream().use { inputStream ->
|
||||||
|
JWO.copy(inputStream, outputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val schemaResource = "net/woggioni/gbcs/gbcs.xsd"
|
||||||
|
val schemaUrl = URL("classpath:net/woggioni/gbcs/gbcs.xsd")
|
||||||
|
// val schemaUrl = GradleBuildCacheServer::class.java.classLoader.getResource(schemaResource)
|
||||||
|
// ?: throw IllegalStateException("Missing configuration schema '$schemaResource'")
|
||||||
|
val schemaUrl2 = URL(schemaUrl.toString())
|
||||||
|
val dbf = Xml.newDocumentBuilderFactory()
|
||||||
|
dbf.schema = Xml.getSchema(schemaUrl)
|
||||||
|
val doc = Files.newInputStream(configurationFile)
|
||||||
|
.use(dbf.newDocumentBuilder()::parse)
|
||||||
|
GradleBuildCacheServer(Configuration.parse(doc.documentElement)).run()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun digest(data : ByteArray,
|
fun digest(data : ByteArray,
|
||||||
|
@@ -1,11 +1,5 @@
|
|||||||
package net.woggioni.gbcs
|
package net.woggioni.gbcs
|
||||||
|
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import org.w3c.dom.Document
|
|
||||||
import org.xml.sax.ErrorHandler
|
|
||||||
import org.xml.sax.SAXNotRecognizedException
|
|
||||||
import org.xml.sax.SAXNotSupportedException
|
|
||||||
import org.xml.sax.SAXParseException
|
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import javax.xml.XMLConstants.ACCESS_EXTERNAL_DTD
|
import javax.xml.XMLConstants.ACCESS_EXTERNAL_DTD
|
||||||
import javax.xml.XMLConstants.ACCESS_EXTERNAL_SCHEMA
|
import javax.xml.XMLConstants.ACCESS_EXTERNAL_SCHEMA
|
||||||
@@ -15,7 +9,60 @@ import javax.xml.parsers.DocumentBuilder
|
|||||||
import javax.xml.parsers.DocumentBuilderFactory
|
import javax.xml.parsers.DocumentBuilderFactory
|
||||||
import javax.xml.validation.Schema
|
import javax.xml.validation.Schema
|
||||||
import javax.xml.validation.SchemaFactory
|
import javax.xml.validation.SchemaFactory
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.w3c.dom.Document
|
||||||
|
import org.w3c.dom.Element
|
||||||
|
import org.w3c.dom.Node
|
||||||
|
import org.w3c.dom.NodeList
|
||||||
|
import org.xml.sax.ErrorHandler
|
||||||
|
import org.xml.sax.SAXNotRecognizedException
|
||||||
|
import org.xml.sax.SAXNotSupportedException
|
||||||
|
import org.xml.sax.SAXParseException
|
||||||
|
|
||||||
|
class NodeListIterator(private val nodeList: NodeList) : Iterator<Node> {
|
||||||
|
private var cursor : Int = 0
|
||||||
|
override fun hasNext(): Boolean {
|
||||||
|
return cursor < nodeList.length
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun next(): Node {
|
||||||
|
return if (hasNext()) nodeList.item(cursor++) else throw NoSuchElementException()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ElementIterator(parent: Element, name: String? = null) : Iterator<Element> {
|
||||||
|
private val it: NodeListIterator
|
||||||
|
private val name: String?
|
||||||
|
private var next: Element?
|
||||||
|
|
||||||
|
init {
|
||||||
|
it = NodeListIterator(parent.childNodes)
|
||||||
|
this.name = name
|
||||||
|
next = getNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hasNext(): Boolean {
|
||||||
|
return next != null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun next(): Element {
|
||||||
|
val result = next ?: throw NoSuchElementException()
|
||||||
|
next = getNext()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getNext(): Element? {
|
||||||
|
var result: Element? = null
|
||||||
|
while (it.hasNext()) {
|
||||||
|
val node: Node = it.next()
|
||||||
|
if (node is Element && (name == null || name == node.tagName)) {
|
||||||
|
result = node
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
object Xml {
|
object Xml {
|
||||||
|
|
||||||
@@ -66,17 +113,15 @@ object Xml {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSchema(schemaResourceURL: String): Schema {
|
fun getSchema(schema: URL): Schema {
|
||||||
val sf = SchemaFactory.newInstance(W3C_XML_SCHEMA_NS_URI)
|
val sf = SchemaFactory.newInstance(W3C_XML_SCHEMA_NS_URI)
|
||||||
sf.setFeature(FEATURE_SECURE_PROCESSING, true)
|
sf.setFeature(FEATURE_SECURE_PROCESSING, true)
|
||||||
disableProperty(sf, ACCESS_EXTERNAL_SCHEMA)
|
// disableProperty(sf, ACCESS_EXTERNAL_SCHEMA)
|
||||||
disableProperty(sf, ACCESS_EXTERNAL_DTD)
|
// disableProperty(sf, ACCESS_EXTERNAL_DTD)
|
||||||
val schemaUrl: URL = Xml::class.java.classLoader.getResource(schemaResourceURL)
|
return sf.newSchema(schema)
|
||||||
?: throw IllegalStateException(String.format("Missing configuration schema '%s'", schemaResourceURL))
|
|
||||||
return sf.newSchema(schemaUrl)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun newDocumentBuilderFactory(schemaResourceURL: String?): DocumentBuilderFactory {
|
fun newDocumentBuilderFactory(): DocumentBuilderFactory {
|
||||||
val dbf = DocumentBuilderFactory.newInstance()
|
val dbf = DocumentBuilderFactory.newInstance()
|
||||||
dbf.setFeature(FEATURE_SECURE_PROCESSING, true)
|
dbf.setFeature(FEATURE_SECURE_PROCESSING, true)
|
||||||
disableProperty(dbf, ACCESS_EXTERNAL_SCHEMA)
|
disableProperty(dbf, ACCESS_EXTERNAL_SCHEMA)
|
||||||
@@ -84,35 +129,31 @@ object Xml {
|
|||||||
dbf.isExpandEntityReferences = false
|
dbf.isExpandEntityReferences = false
|
||||||
dbf.isIgnoringComments = true
|
dbf.isIgnoringComments = true
|
||||||
dbf.isNamespaceAware = true
|
dbf.isNamespaceAware = true
|
||||||
val sf = SchemaFactory.newInstance(W3C_XML_SCHEMA_NS_URI)
|
|
||||||
sf.setFeature(FEATURE_SECURE_PROCESSING, true)
|
|
||||||
disableProperty(sf, ACCESS_EXTERNAL_SCHEMA)
|
|
||||||
disableProperty(sf, ACCESS_EXTERNAL_DTD)
|
|
||||||
if (schemaResourceURL != null) {
|
|
||||||
dbf.schema = getSchema(schemaResourceURL)
|
|
||||||
}
|
|
||||||
return dbf
|
return dbf
|
||||||
}
|
}
|
||||||
|
|
||||||
fun newDocumentBuilder(resource: URL, schemaResourceURL: String?): DocumentBuilder {
|
// fun newDocumentBuilder(resource: URL, schemaResourceURL: String?): DocumentBuilder {
|
||||||
val db = newDocumentBuilderFactory(schemaResourceURL).newDocumentBuilder()
|
// val db = newDocumentBuilderFactory(schemaResourceURL).newDocumentBuilder()
|
||||||
db.setErrorHandler(XmlErrorHandler(resource))
|
// db.setErrorHandler(XmlErrorHandler(resource))
|
||||||
return db
|
// return db
|
||||||
}
|
// }
|
||||||
|
|
||||||
fun parseXmlResource(resource: URL, schemaResourceURL: String?): Document {
|
// fun parseXmlResource(resource: URL, schemaResourceURL: String?): Document {
|
||||||
val db = newDocumentBuilder(resource, schemaResourceURL)
|
// val db = newDocumentBuilder(resource, schemaResourceURL)
|
||||||
return resource.openStream().use(db::parse)
|
// return resource.openStream().use(db::parse)
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
|
// fun newDocumentBuilder(resource: URL): DocumentBuilder {
|
||||||
|
// val db = newDocumentBuilderFactory(null).newDocumentBuilder()
|
||||||
|
// db.setErrorHandler(XmlErrorHandler(resource))
|
||||||
|
// return db
|
||||||
|
// }
|
||||||
|
|
||||||
fun newDocumentBuilder(resource: URL): DocumentBuilder {
|
// fun parseXmlResource(resource: URL): Document {
|
||||||
val db = newDocumentBuilderFactory(null).newDocumentBuilder()
|
// val db = newDocumentBuilder(resource, null)
|
||||||
db.setErrorHandler(XmlErrorHandler(resource))
|
// return resource.openStream().use(db::parse)
|
||||||
return db
|
// }
|
||||||
}
|
|
||||||
|
|
||||||
fun parseXmlResource(resource: URL): Document {
|
fun Element.asIterable() = Iterable { ElementIterator(this, null) }
|
||||||
val db = newDocumentBuilder(resource, null)
|
fun NodeList.asIterable() = Iterable { NodeListIterator(this) }
|
||||||
return resource.openStream().use(db::parse)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<server xmlns="urn:gradle-build-cache-server">
|
<server xmlns="urn:gradle-build-cache-server" path="/cache">
|
||||||
<bind host="127.0.0.1" port="5680"/>
|
<bind host="127.0.0.1" port="11443"/>
|
||||||
<cache path="/tmp/gbcs"/>
|
<cache path="/tmp/gbcs"/>
|
||||||
<groups>
|
<groups>
|
||||||
<readers>
|
<readers>
|
||||||
@@ -10,5 +10,5 @@
|
|||||||
<!-- <user name="writer"/>-->
|
<!-- <user name="writer"/>-->
|
||||||
</writers>
|
</writers>
|
||||||
</groups>
|
</groups>
|
||||||
<tls name=""/>
|
<tls verify-clients="true"/>
|
||||||
</server>
|
</server>
|
@@ -1,8 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<xs:schema elementFormDefault="qualified" targetNamespace="urn:gradle-build-cache-server" version="1.0"
|
<xs:schema elementFormDefault="qualified" targetNamespace="urn:gradle-build-cache-server" version="1.0"
|
||||||
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||||
xmlns:gbcs="urn:gradle-build-cache-server">
|
xmlns:gbcs="urn:gradle-build-cache-server">
|
||||||
<xs:element name="server" type="gbcs:serverType"/>
|
<xs:element name="server" type="gbcs:serverType"/>
|
||||||
|
|
||||||
<xs:complexType name="serverType">
|
<xs:complexType name="serverType">
|
||||||
<xs:sequence minOccurs="0">
|
<xs:sequence minOccurs="0">
|
||||||
<xs:element name="bind" type="gbcs:bindType"/>
|
<xs:element name="bind" type="gbcs:bindType"/>
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
<xs:element name="groups" type="gbcs:groupsType"/>
|
<xs:element name="groups" type="gbcs:groupsType"/>
|
||||||
<xs:element name="tls" type="gbcs:tlsType"/>
|
<xs:element name="tls" type="gbcs:tlsType"/>
|
||||||
</xs:sequence>
|
</xs:sequence>
|
||||||
|
<xs:attribute name="path" type="xs:string"/>
|
||||||
</xs:complexType>
|
</xs:complexType>
|
||||||
|
|
||||||
<xs:complexType name="bindType">
|
<xs:complexType name="bindType">
|
||||||
@@ -39,28 +41,28 @@
|
|||||||
</xs:complexType>
|
</xs:complexType>
|
||||||
|
|
||||||
<xs:complexType name="tlsType">
|
<xs:complexType name="tlsType">
|
||||||
<xs:attribute name="name" type="xs:string" use="required"/>
|
|
||||||
</xs:complexType>
|
|
||||||
|
|
||||||
|
|
||||||
<xs:complexType name="instancesType">
|
|
||||||
<xs:sequence>
|
|
||||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="instance" type="contour:instanceType"/>
|
|
||||||
</xs:sequence>
|
|
||||||
</xs:complexType>
|
|
||||||
|
|
||||||
<xs:complexType name="instanceType">
|
|
||||||
<xs:all>
|
<xs:all>
|
||||||
<xs:element name="database" type="contour:databaseType"/>
|
<xs:element name="keystore" type="gbcs:keyStoreType" minOccurs="0"/>
|
||||||
<xs:element name="application-properties" type="contour:propertiesType"/>
|
<xs:element name="truststore" type="gbcs:trustStoreType" minOccurs="0"/>
|
||||||
<xs:element name="corda-node" type="contour:cordaNodeType" minOccurs="0" maxOccurs="1"/>
|
|
||||||
</xs:all>
|
</xs:all>
|
||||||
<xs:attribute name="name" type="xs:token" use="required"/>
|
<xs:attribute name="verify-clients" type="xs:boolean" use="optional"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="keyStoreType">
|
||||||
|
<xs:attribute name="file" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="password" type="xs:string"/>
|
||||||
|
<xs:attribute name="key-alias" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="key-password" type="xs:string"/>
|
||||||
|
</xs:complexType>
|
||||||
|
|
||||||
|
<xs:complexType name="trustStoreType">
|
||||||
|
<xs:attribute name="file" type="xs:string" use="required"/>
|
||||||
|
<xs:attribute name="password" type="xs:string"/>
|
||||||
</xs:complexType>
|
</xs:complexType>
|
||||||
|
|
||||||
<xs:complexType name="propertiesType">
|
<xs:complexType name="propertiesType">
|
||||||
<xs:sequence>
|
<xs:sequence>
|
||||||
<xs:element maxOccurs="unbounded" minOccurs="0" name="property" type="contour:propertyType"/>
|
<xs:element maxOccurs="unbounded" minOccurs="0" name="property" type="gbcs:propertyType"/>
|
||||||
</xs:sequence>
|
</xs:sequence>
|
||||||
</xs:complexType>
|
</xs:complexType>
|
||||||
|
|
||||||
@@ -72,114 +74,10 @@
|
|||||||
</xs:simpleContent>
|
</xs:simpleContent>
|
||||||
</xs:complexType>
|
</xs:complexType>
|
||||||
|
|
||||||
<xs:complexType name="servicesType">
|
|
||||||
<xs:all>
|
|
||||||
<xs:element name="postgresDatabase" type="contour:postgresDatabaseType"/>
|
|
||||||
<xs:element name="mailhogServer" type="contour:mailhogServerType"/>
|
|
||||||
</xs:all>
|
|
||||||
</xs:complexType>
|
|
||||||
|
|
||||||
<xs:complexType name="hostAndPortType">
|
<xs:complexType name="hostAndPortType">
|
||||||
<xs:attribute name="host" type="xs:string" use="required"/>
|
<xs:attribute name="host" type="xs:string" use="required"/>
|
||||||
<xs:attribute name="port" type="xs:unsignedShort" use="required"/>
|
<xs:attribute name="port" type="xs:unsignedShort" use="required"/>
|
||||||
</xs:complexType>
|
</xs:complexType>
|
||||||
|
|
||||||
<xs:complexType name="postgresDatabaseType">
|
|
||||||
<xs:all>
|
|
||||||
<xs:element name="container-name" type="xs:token"/>
|
|
||||||
<xs:element name="port" type="xs:unsignedShort"/>
|
|
||||||
<xs:element name="password" type="xs:token"/>
|
|
||||||
<xs:element name="image" type="xs:token"/>
|
|
||||||
</xs:all>
|
|
||||||
</xs:complexType>
|
|
||||||
|
|
||||||
<xs:complexType name="mailhogServerType">
|
|
||||||
<xs:all>
|
|
||||||
<xs:element name="container-name" type="xs:token"/>
|
|
||||||
<xs:element name="http-port" type="xs:unsignedShort"/>
|
|
||||||
<xs:element name="smtp-port" type="xs:unsignedShort"/>
|
|
||||||
<xs:element name="image" type="xs:token"/>
|
|
||||||
</xs:all>
|
|
||||||
</xs:complexType>
|
|
||||||
|
|
||||||
<xs:complexType name="codeType">
|
|
||||||
<xs:all>
|
|
||||||
<xs:element name="front-end" type="contour:codeRepositoryType"/>
|
|
||||||
<xs:element name="back-end" type="contour:codeRepositoryType"/>
|
|
||||||
<xs:element name="cordapps" type="contour:codeRepositoryType"/>
|
|
||||||
</xs:all>
|
|
||||||
</xs:complexType>
|
|
||||||
|
|
||||||
<xs:complexType name="codeRepositoryType">
|
|
||||||
<xs:attribute name="location" type="xs:token" use="required"/>
|
|
||||||
</xs:complexType>
|
|
||||||
|
|
||||||
<xs:complexType name="databaseType">
|
|
||||||
<xs:all>
|
|
||||||
<xs:element name="url" type="xs:string"/>
|
|
||||||
<xs:element name="name">
|
|
||||||
<xs:complexType>
|
|
||||||
<xs:simpleContent>
|
|
||||||
<xs:extension base="xs:string"/>
|
|
||||||
</xs:simpleContent>
|
|
||||||
</xs:complexType>
|
|
||||||
</xs:element>
|
|
||||||
</xs:all>
|
|
||||||
</xs:complexType>
|
|
||||||
|
|
||||||
<xs:complexType name="urlType">
|
|
||||||
<xs:simpleContent>
|
|
||||||
<xs:extension base="xs:string"/>
|
|
||||||
</xs:simpleContent>
|
|
||||||
</xs:complexType>
|
|
||||||
|
|
||||||
<xs:complexType name="jvmType">
|
|
||||||
<xs:attribute name="location" type="xs:token" use="required"/>
|
|
||||||
</xs:complexType>
|
|
||||||
|
|
||||||
<xs:complexType name="artifactsType">
|
|
||||||
<xs:all>
|
|
||||||
<xs:element name="contract-cordapp" type="contour:mavenArtifactType"/>
|
|
||||||
<xs:element name="workflow-cordapp" type="contour:mavenArtifactType"/>
|
|
||||||
<xs:element name="business-tool-cordapp" type="contour:mavenArtifactType"/>
|
|
||||||
<xs:element name="spring-backend" type="contour:mavenArtifactType"/>
|
|
||||||
</xs:all>
|
|
||||||
</xs:complexType>
|
|
||||||
|
|
||||||
<xs:complexType name="mavenArtifactType">
|
|
||||||
<xs:attribute name="groupId" type="xs:string" use="required"/>
|
|
||||||
<xs:attribute name="artifactId" type="xs:string" use="required"/>
|
|
||||||
<xs:attribute name="version" type="xs:string" use="required"/>
|
|
||||||
<xs:attribute name="ext" type="xs:string" use="required"/>
|
|
||||||
</xs:complexType>
|
|
||||||
|
|
||||||
<xs:complexType name="cordaNodeType">
|
|
||||||
<xs:choice>
|
|
||||||
<xs:element name="config" type="contour:simpleCordaConfigType"/>
|
|
||||||
<xs:element name="configFile" type="xs:string"/>
|
|
||||||
</xs:choice>
|
|
||||||
<xs:attribute name="x500Name" type="xs:string" use="required"/>
|
|
||||||
</xs:complexType>
|
|
||||||
|
|
||||||
<xs:complexType name="rpcUsersType">
|
|
||||||
<xs:sequence>
|
|
||||||
<xs:element name="user" type="contour:rpcUserType" minOccurs="0" maxOccurs="unbounded"/>
|
|
||||||
</xs:sequence>
|
|
||||||
</xs:complexType>
|
|
||||||
|
|
||||||
<xs:complexType name="rpcUserType">
|
|
||||||
<xs:attribute name="name" type="xs:string" use="required"/>
|
|
||||||
<xs:attribute name="password" type="xs:string" use="required"/>
|
|
||||||
</xs:complexType>
|
|
||||||
|
|
||||||
<xs:complexType name="simpleCordaConfigType">
|
|
||||||
<xs:all>
|
|
||||||
<xs:element name="h2Port" type="xs:unsignedShort"/>
|
|
||||||
<xs:element name="devMode" type="xs:boolean"/>
|
|
||||||
<xs:element name="p2p-address" type="contour:hostAndPortType"/>
|
|
||||||
<xs:element name="rpc-address" type="contour:hostAndPortType"/>
|
|
||||||
<xs:element name="rpc-admin-address" type="contour:hostAndPortType"/>
|
|
||||||
<xs:element name="rpc-users" type="contour:rpcUsersType"/>
|
|
||||||
</xs:all>
|
|
||||||
</xs:complexType>
|
|
||||||
</xs:schema>
|
</xs:schema>
|
||||||
|
Reference in New Issue
Block a user