diff --git a/src/main/kotlin/com/open592/fileserver/buffer/XorExtensions.kt b/src/main/kotlin/com/open592/fileserver/buffer/XorExtensions.kt new file mode 100644 index 0000000..171ec7d --- /dev/null +++ b/src/main/kotlin/com/open592/fileserver/buffer/XorExtensions.kt @@ -0,0 +1,36 @@ +package com.open592.fileserver.buffer + +import io.netty.buffer.ByteBuf + +internal fun ByteBuf.xor(key: Int): ByteBuf { + if (key == 0) { + return retain() + } + + val buf = + if (refCnt() == 1) { + retain() + } else { + copy() + } + + if (buf.hasArray()) { + val array = buf.array() + + val off = buf.arrayOffset() + buf.readerIndex() + val len = buf.readableBytes() + + for (i in off until off + len) { + array[i] = (array[i].toInt() xor key).toByte() + } + } else { + val off = buf.readerIndex() + val len = buf.readableBytes() + + for (i in off until off + len) { + buf.setByte(i, buf.getByte(i).toInt() xor key) + } + } + + return buf +} diff --git a/src/main/kotlin/com/open592/fileserver/cache/CacheModule.kt b/src/main/kotlin/com/open592/fileserver/cache/CacheModule.kt index 22118c8..b134d17 100644 --- a/src/main/kotlin/com/open592/fileserver/cache/CacheModule.kt +++ b/src/main/kotlin/com/open592/fileserver/cache/CacheModule.kt @@ -2,12 +2,13 @@ package com.open592.fileserver.cache import com.displee.cache.CacheLibrary import com.google.inject.AbstractModule +import com.google.inject.Scopes import com.open592.fileserver.configuration.ServerConfigurationModule object CacheModule : AbstractModule() { override fun configure() { install(ServerConfigurationModule) - bind(CacheLibrary::class.java).toProvider(CacheProvider::class.java) + bind(CacheLibrary::class.java).toProvider(CacheProvider::class.java).`in`(Scopes.SINGLETON) } } diff --git a/src/main/kotlin/com/open592/fileserver/net/ExceptionHandler.kt b/src/main/kotlin/com/open592/fileserver/net/ExceptionHandler.kt new file mode 100644 index 0000000..72f9e1a --- /dev/null +++ b/src/main/kotlin/com/open592/fileserver/net/ExceptionHandler.kt @@ -0,0 +1,16 @@ +package com.open592.fileserver.net + +import com.github.michaelbull.logging.InlineLogger +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.ChannelInboundHandlerAdapter + +class ExceptionHandler : ChannelInboundHandlerAdapter() { + + override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) { + logger.error { "Caught exception: ${cause.message}" } + } + + private companion object { + private val logger = InlineLogger() + } +} diff --git a/src/main/kotlin/com/open592/fileserver/net/NetworkChannelInitializer.kt b/src/main/kotlin/com/open592/fileserver/net/NetworkChannelInitializer.kt index 0ead7ba..cdef07f 100644 --- a/src/main/kotlin/com/open592/fileserver/net/NetworkChannelInitializer.kt +++ b/src/main/kotlin/com/open592/fileserver/net/NetworkChannelInitializer.kt @@ -1,43 +1,41 @@ -package com.open592.fileserver.net - -import com.github.michaelbull.logging.InlineLogger -import com.open592.fileserver.protocol.inbound.Js5InboundChannelHandler -import com.open592.fileserver.protocol.inbound.Js5InboundMessageDecoder -import com.open592.fileserver.protocol.outbound.Js5OutboundGroupMessageEncoder -import com.open592.fileserver.protocol.outbound.Js5OutboundStatusMessageEncoder -import io.netty.channel.Channel -import io.netty.channel.ChannelHandlerContext -import io.netty.channel.ChannelInitializer -import io.netty.handler.timeout.IdleStateHandler -import jakarta.inject.Inject -import jakarta.inject.Provider -import jakarta.inject.Singleton -import java.util.concurrent.TimeUnit - -@Singleton -class NetworkChannelInitializer -@Inject -constructor(private val js5InboundChannelHandler: Provider) : - ChannelInitializer() { - override fun initChannel(channel: Channel) { - channel - .pipeline() - .addLast( - IdleStateHandler( - true, TIMEOUT_SECONDS, TIMEOUT_SECONDS, TIMEOUT_SECONDS, TimeUnit.SECONDS), - Js5InboundMessageDecoder(), - Js5OutboundStatusMessageEncoder(), - Js5OutboundGroupMessageEncoder(), - js5InboundChannelHandler.get(), - ) - } - - override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) { - logger.error { "Caught exception: ${cause.message}" } - } - - private companion object { - private const val TIMEOUT_SECONDS: Long = 30 - private val logger = InlineLogger() - } -} +package com.open592.fileserver.net + +import com.github.michaelbull.logging.InlineLogger +import com.open592.fileserver.protocol.inbound.Js5InboundChannelHandler +import com.open592.fileserver.protocol.inbound.Js5InboundMessageDecoder +import com.open592.fileserver.protocol.outbound.Js5OutboundGroupMessageEncoder +import com.open592.fileserver.protocol.outbound.Js5OutboundStatusMessageEncoder +import com.open592.fileserver.protocol.outbound.XorEncoder +import io.netty.channel.Channel +import io.netty.channel.ChannelInitializer +import io.netty.handler.timeout.IdleStateHandler +import jakarta.inject.Inject +import jakarta.inject.Provider +import jakarta.inject.Singleton +import java.util.concurrent.TimeUnit + +@Singleton +class NetworkChannelInitializer +@Inject +constructor(private val js5InboundChannelHandler: Provider) : + ChannelInitializer() { + override fun initChannel(channel: Channel) { + channel + .pipeline() + .addLast( + IdleStateHandler( + true, TIMEOUT_SECONDS, TIMEOUT_SECONDS, TIMEOUT_SECONDS, TimeUnit.SECONDS), + XorEncoder(), + Js5InboundMessageDecoder(), + Js5OutboundStatusMessageEncoder(), + Js5OutboundGroupMessageEncoder(), + js5InboundChannelHandler.get(), + ExceptionHandler(), + ) + } + + private companion object { + private const val TIMEOUT_SECONDS: Long = 30 + private val logger = InlineLogger() + } +} diff --git a/src/main/kotlin/com/open592/fileserver/net/js5/Js5Service.kt b/src/main/kotlin/com/open592/fileserver/net/js5/Js5Service.kt index c8e8f22..a72326e 100644 --- a/src/main/kotlin/com/open592/fileserver/net/js5/Js5Service.kt +++ b/src/main/kotlin/com/open592/fileserver/net/js5/Js5Service.kt @@ -72,10 +72,10 @@ constructor( } else { allocator.buffer().use { buffer -> val archiveSector = - if (request.group == 255) { - cacheLibrary.index255?.readArchiveSector(request.archive) + if (request.archive == ARCHIVE_SET) { + cacheLibrary.index255?.readArchiveSector(request.group) } else { - cacheLibrary.index(request.group).readArchiveSector(request.archive) + cacheLibrary.index(request.archive).readArchiveSector(request.group) } ?: return buffer.writeBytes(archiveSector.data) diff --git a/src/main/kotlin/com/open592/fileserver/protocol/inbound/Js5InboundChannelHandler.kt b/src/main/kotlin/com/open592/fileserver/protocol/inbound/Js5InboundChannelHandler.kt index 6b46bbd..05d5f13 100644 --- a/src/main/kotlin/com/open592/fileserver/protocol/inbound/Js5InboundChannelHandler.kt +++ b/src/main/kotlin/com/open592/fileserver/protocol/inbound/Js5InboundChannelHandler.kt @@ -1,71 +1,79 @@ -package com.open592.fileserver.protocol.inbound - -import com.github.michaelbull.logging.InlineLogger -import com.open592.fileserver.configuration.ServerConfiguration -import com.open592.fileserver.net.js5.Js5Client -import com.open592.fileserver.net.js5.Js5Service -import com.open592.fileserver.protocol.outbound.Js5OutboundStatusMessage -import io.netty.channel.ChannelHandlerContext -import io.netty.channel.SimpleChannelInboundHandler -import io.netty.handler.timeout.IdleStateEvent -import jakarta.inject.Inject - -class Js5InboundChannelHandler -@Inject -constructor( - private val service: Js5Service, - private val serverConfiguration: ServerConfiguration, -) : SimpleChannelInboundHandler(Js5InboundMessage::class.java) { - private lateinit var client: Js5Client - - override fun handlerAdded(ctx: ChannelHandlerContext) { - client = Js5Client(ctx.read()) - } - - override fun channelRead0(ctx: ChannelHandlerContext, message: Js5InboundMessage) { - when (message) { - is Js5InboundMessage.InitializeJs5RemoteConnection -> - handleInitializeJs5RemoteConnection(ctx, message) - is Js5InboundMessage.RequestGroup -> service.push(client, message) - is Js5InboundMessage.ExchangeObfuscationKey -> handleExchangeObfuscationKey(message) - is Js5InboundMessage.RequestConnectionDisconnect -> ctx.close() - else -> Unit - } - } - - private fun handleInitializeJs5RemoteConnection( - ctx: ChannelHandlerContext, - message: Js5InboundMessage.InitializeJs5RemoteConnection - ) { - if (message.build != serverConfiguration.getBuildNumber()) { - ctx.write(Js5OutboundStatusMessage.ClientIsOutOfDate) - } else { - ctx.write(Js5OutboundStatusMessage.Ok) - } - } - - private fun handleExchangeObfuscationKey(message: Js5InboundMessage.ExchangeObfuscationKey) { - logger.info { "Handle Exchange Obfuscation Key with value = ${message.key}" } - } - - override fun channelReadComplete(ctx: ChannelHandlerContext) { - service.readIfNotFull(client) - ctx.flush() - } - - override fun channelWritabilityChanged(ctx: ChannelHandlerContext) { - if (ctx.channel().isWritable) { - service.notifyIfNotEmpty(client) - } - } - - override fun userEventTriggered(ctx: ChannelHandlerContext, event: Any) { - if (event is IdleStateEvent) { - ctx.close() - } - } - - private companion object { - private val logger = InlineLogger() - } -} +package com.open592.fileserver.protocol.inbound + +import com.github.michaelbull.logging.InlineLogger +import com.open592.fileserver.configuration.ServerConfiguration +import com.open592.fileserver.net.js5.Js5Client +import com.open592.fileserver.net.js5.Js5Service +import com.open592.fileserver.protocol.outbound.Js5OutboundStatusMessage +import com.open592.fileserver.protocol.outbound.XorEncoder +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.SimpleChannelInboundHandler +import io.netty.handler.timeout.IdleStateEvent +import jakarta.inject.Inject + +class Js5InboundChannelHandler +@Inject +constructor( + private val service: Js5Service, + private val serverConfiguration: ServerConfiguration, +) : SimpleChannelInboundHandler(Js5InboundMessage::class.java) { + private lateinit var client: Js5Client + + override fun handlerAdded(ctx: ChannelHandlerContext) { + client = Js5Client(ctx.read()) + } + + override fun channelRead0(ctx: ChannelHandlerContext, message: Js5InboundMessage) { + when (message) { + is Js5InboundMessage.InitializeJs5RemoteConnection -> + handleInitializeJs5RemoteConnection(ctx, message) + is Js5InboundMessage.RequestGroup -> service.push(client, message) + is Js5InboundMessage.ExchangeObfuscationKey -> handleExchangeObfuscationKey(ctx, message) + is Js5InboundMessage.RequestConnectionDisconnect -> ctx.close() + else -> Unit + } + } + + private fun handleInitializeJs5RemoteConnection( + ctx: ChannelHandlerContext, + message: Js5InboundMessage.InitializeJs5RemoteConnection + ) { + if (message.build != serverConfiguration.getBuildNumber()) { + ctx.write(Js5OutboundStatusMessage.ClientIsOutOfDate) + } else { + ctx.write(Js5OutboundStatusMessage.Ok) + } + } + + private fun handleExchangeObfuscationKey( + ctx: ChannelHandlerContext, + message: Js5InboundMessage.ExchangeObfuscationKey + ) { + val encoder = ctx.pipeline().get(XorEncoder::class.java) + + encoder.key = message.key + + logger.info { "Setting key ${message.key}" } + } + + override fun channelReadComplete(ctx: ChannelHandlerContext) { + service.readIfNotFull(client) + ctx.flush() + } + + override fun channelWritabilityChanged(ctx: ChannelHandlerContext) { + if (ctx.channel().isWritable) { + service.notifyIfNotEmpty(client) + } + } + + override fun userEventTriggered(ctx: ChannelHandlerContext, event: Any) { + if (event is IdleStateEvent) { + ctx.close() + } + } + + private companion object { + private val logger = InlineLogger() + } +} diff --git a/src/main/kotlin/com/open592/fileserver/protocol/inbound/Js5InboundMessageDecoder.kt b/src/main/kotlin/com/open592/fileserver/protocol/inbound/Js5InboundMessageDecoder.kt index f1f1ed1..762269b 100644 --- a/src/main/kotlin/com/open592/fileserver/protocol/inbound/Js5InboundMessageDecoder.kt +++ b/src/main/kotlin/com/open592/fileserver/protocol/inbound/Js5InboundMessageDecoder.kt @@ -1,97 +1,99 @@ -package com.open592.fileserver.protocol.inbound - -import io.netty.buffer.ByteBuf -import io.netty.channel.ChannelHandlerContext -import io.netty.handler.codec.ByteToMessageDecoder -import io.netty.handler.codec.DecoderException - -class Js5InboundMessageDecoder : ByteToMessageDecoder() { - override fun decode(ctx: ChannelHandlerContext, input: ByteBuf, output: MutableList) { - if (input.readableBytes() < 4) { - return - } - - val opcode = input.readUnsignedByte().toInt() - val message = decodeOpcode(input, opcode) - - output += message - } - - private fun decodeOpcode(input: ByteBuf, opcode: Int): Js5InboundMessage { - return when (opcode) { - 0 -> decodeRequestGroupPacket(input, isPrefetch = true) - 1 -> decodeRequestGroupPacket(input, isPrefetch = false) - 2 -> decodeInformUserIsLoggedInPacket(input) - 3 -> decodeInformUserIsLoggedOutPacket(input) - 4 -> decodeExchangeObfuscationKeyPacket(input) - 6 -> decodeInformClientIsReadyPacket(input) - 7 -> decodeRequestConnectionDisconnectPacket(input) - 15 -> decodeInitializeJs5RemoteConnectionPacket(input) - else -> throw DecoderException("Unknown Js5 inbound message opcode: $opcode") - } - } - - private fun decodeRequestGroupPacket( - input: ByteBuf, - isPrefetch: Boolean - ): Js5InboundMessage.RequestGroup { - val archive = input.readUnsignedByte().toInt() - val group = input.readUnsignedShort().toInt() - - return Js5InboundMessage.RequestGroup(archive, group, isPrefetch) - } - - private fun decodeInformUserIsLoggedInPacket( - input: ByteBuf - ): Js5InboundMessage.InformUserIsLoggedIn { - // Skip padding bytes - input.skipBytes(3) - - return Js5InboundMessage.InformUserIsLoggedIn - } - - private fun decodeInformUserIsLoggedOutPacket( - input: ByteBuf - ): Js5InboundMessage.InformUserIsLoggedOut { - // Skip padding bytes - input.skipBytes(3) - - return Js5InboundMessage.InformUserIsLoggedOut - } - - private fun decodeRequestConnectionDisconnectPacket( - input: ByteBuf - ): Js5InboundMessage.RequestConnectionDisconnect { - // Skip padding bytes - input.skipBytes(3) - - return Js5InboundMessage.RequestConnectionDisconnect - } - - private fun decodeExchangeObfuscationKeyPacket( - input: ByteBuf - ): Js5InboundMessage.ExchangeObfuscationKey { - val key = input.readUnsignedByte().toInt() - - return Js5InboundMessage.ExchangeObfuscationKey(key) - } - - private fun decodeInformClientIsReadyPacket( - input: ByteBuf - ): Js5InboundMessage.InformClientIsReady { - // Skip padding bytes - // NOTE: The client usually sends along padding bytes with `0` as the value, - // but for this message it's using `p3(3)`. - input.skipBytes(3) - - return Js5InboundMessage.InformClientIsReady - } - - private fun decodeInitializeJs5RemoteConnectionPacket( - input: ByteBuf - ): Js5InboundMessage.InitializeJs5RemoteConnection { - val build = input.readInt() - - return Js5InboundMessage.InitializeJs5RemoteConnection(build) - } -} +package com.open592.fileserver.protocol.inbound + +import io.netty.buffer.ByteBuf +import io.netty.channel.ChannelHandlerContext +import io.netty.handler.codec.ByteToMessageDecoder +import io.netty.handler.codec.DecoderException + +class Js5InboundMessageDecoder : ByteToMessageDecoder() { + override fun decode(ctx: ChannelHandlerContext, input: ByteBuf, output: MutableList) { + if (input.readableBytes() < 4) { + return + } + + val opcode = input.readUnsignedByte().toInt() + val message = decodeOpcode(input, opcode) + + output += message + } + + private fun decodeOpcode(input: ByteBuf, opcode: Int): Js5InboundMessage { + return when (opcode) { + 0 -> decodeRequestGroupPacket(input, isPrefetch = true) + 1 -> decodeRequestGroupPacket(input, isPrefetch = false) + 2 -> decodeInformUserIsLoggedInPacket(input) + 3 -> decodeInformUserIsLoggedOutPacket(input) + 4 -> decodeExchangeObfuscationKeyPacket(input) + 6 -> decodeInformClientIsReadyPacket(input) + 7 -> decodeRequestConnectionDisconnectPacket(input) + 15 -> decodeInitializeJs5RemoteConnectionPacket(input) + else -> throw DecoderException("Unknown Js5 inbound message opcode: $opcode") + } + } + + private fun decodeRequestGroupPacket( + input: ByteBuf, + isPrefetch: Boolean + ): Js5InboundMessage.RequestGroup { + val archive = input.readUnsignedByte().toInt() + val group = input.readUnsignedShort() + + return Js5InboundMessage.RequestGroup(archive, group, isPrefetch) + } + + private fun decodeInformUserIsLoggedInPacket( + input: ByteBuf + ): Js5InboundMessage.InformUserIsLoggedIn { + // Skip padding bytes + input.skipBytes(3) + + return Js5InboundMessage.InformUserIsLoggedIn + } + + private fun decodeInformUserIsLoggedOutPacket( + input: ByteBuf + ): Js5InboundMessage.InformUserIsLoggedOut { + // Skip padding bytes + input.skipBytes(3) + + return Js5InboundMessage.InformUserIsLoggedOut + } + + private fun decodeRequestConnectionDisconnectPacket( + input: ByteBuf + ): Js5InboundMessage.RequestConnectionDisconnect { + // Skip padding bytes + input.skipBytes(3) + + return Js5InboundMessage.RequestConnectionDisconnect + } + + private fun decodeExchangeObfuscationKeyPacket( + input: ByteBuf + ): Js5InboundMessage.ExchangeObfuscationKey { + val key = input.readUnsignedByte().toInt() + + input.skipBytes(2) + + return Js5InboundMessage.ExchangeObfuscationKey(key) + } + + private fun decodeInformClientIsReadyPacket( + input: ByteBuf + ): Js5InboundMessage.InformClientIsReady { + // Skip padding bytes + // NOTE: The client usually sends along padding bytes with `0` as the value, + // but for this message it's using `p3(3)`. + input.skipBytes(3) + + return Js5InboundMessage.InformClientIsReady + } + + private fun decodeInitializeJs5RemoteConnectionPacket( + input: ByteBuf + ): Js5InboundMessage.InitializeJs5RemoteConnection { + val build = input.readInt() + + return Js5InboundMessage.InitializeJs5RemoteConnection(build) + } +} diff --git a/src/main/kotlin/com/open592/fileserver/protocol/outbound/Js5OutboundGroupMessageEncoder.kt b/src/main/kotlin/com/open592/fileserver/protocol/outbound/Js5OutboundGroupMessageEncoder.kt index a962ced..47c45d8 100644 --- a/src/main/kotlin/com/open592/fileserver/protocol/outbound/Js5OutboundGroupMessageEncoder.kt +++ b/src/main/kotlin/com/open592/fileserver/protocol/outbound/Js5OutboundGroupMessageEncoder.kt @@ -1,55 +1,61 @@ -package com.open592.fileserver.protocol.outbound - -import io.netty.buffer.ByteBuf -import io.netty.channel.ChannelHandler -import io.netty.channel.ChannelHandlerContext -import io.netty.handler.codec.EncoderException -import io.netty.handler.codec.MessageToByteEncoder -import kotlin.math.min - -@ChannelHandler.Sharable -class Js5OutboundGroupMessageEncoder : - MessageToByteEncoder(Js5OutboundGroupMessage::class.java) { - override fun encode( - ctx: ChannelHandlerContext, - message: Js5OutboundGroupMessage, - output: ByteBuf - ) { - output.writeByte(message.archive) - output.writeShort(message.group) - - if (!message.data.isReadable) { - throw EncoderException("Missing compression byte") - } - - var compression = message.data.readUnsignedByte().toInt() - - if (message.isPrefetch) { - compression = compression or 0x80 - } - - output.writeByte(compression) - - output.writeBytes(message.data, min(message.data.readableBytes(), 508)) - - while (message.data.isReadable) { - output.writeByte(0xFF) - output.writeBytes(message.data, min(message.data.readableBytes(), 511)) - } - } - - override fun allocateBuffer( - ctx: ChannelHandlerContext, - message: Js5OutboundGroupMessage, - preferDirect: Boolean - ): ByteBuf { - val dataLength = message.data.readableBytes() - val bufferLength = 2 + dataLength + (512 + dataLength) / 511 - - return if (preferDirect) { - ctx.alloc().ioBuffer(bufferLength, bufferLength) - } else { - ctx.alloc().heapBuffer(bufferLength, bufferLength) - } - } -} +package com.open592.fileserver.protocol.outbound + +import io.netty.buffer.ByteBuf +import io.netty.channel.ChannelHandler +import io.netty.channel.ChannelHandlerContext +import io.netty.handler.codec.EncoderException +import io.netty.handler.codec.MessageToByteEncoder +import kotlin.math.min + +@ChannelHandler.Sharable +class Js5OutboundGroupMessageEncoder : + MessageToByteEncoder(Js5OutboundGroupMessage::class.java) { + override fun encode( + ctx: ChannelHandlerContext, + message: Js5OutboundGroupMessage, + output: ByteBuf + ) { + output.writeByte(message.archive) + output.writeShort(message.group) + + if (!message.data.isReadable) { + throw EncoderException("Missing compression byte") + } + + // Initialize flags with the compression byte from the data buffer + var flags = message.data.readUnsignedByte().toInt() + + if (message.isPrefetch) { + flags = flags or 0x80 + } + + output.writeByte(flags) + + output.writeBytes(message.data, min(message.data.readableBytes(), BLOCK_SIZE - HEADER_SIZE)) + + while (message.data.isReadable) { + output.writeByte(0xFF) + output.writeBytes(message.data, min(message.data.readableBytes(), BLOCK_SIZE - 1)) + } + } + + override fun allocateBuffer( + ctx: ChannelHandlerContext, + message: Js5OutboundGroupMessage, + preferDirect: Boolean + ): ByteBuf { + val dataLength = message.data.readableBytes() + val bufferLength = 2 + dataLength + (BLOCK_SIZE + dataLength) / 511 + + return if (preferDirect) { + ctx.alloc().ioBuffer(bufferLength, bufferLength) + } else { + ctx.alloc().heapBuffer(bufferLength, bufferLength) + } + } + + private companion object { + private const val HEADER_SIZE = 4 + private const val BLOCK_SIZE = 512 + } +} diff --git a/src/main/kotlin/com/open592/fileserver/protocol/outbound/XorEncoder.kt b/src/main/kotlin/com/open592/fileserver/protocol/outbound/XorEncoder.kt new file mode 100644 index 0000000..898c785 --- /dev/null +++ b/src/main/kotlin/com/open592/fileserver/protocol/outbound/XorEncoder.kt @@ -0,0 +1,14 @@ +package com.open592.fileserver.protocol.outbound + +import com.open592.fileserver.buffer.xor +import io.netty.buffer.ByteBuf +import io.netty.channel.ChannelHandlerContext +import io.netty.handler.codec.MessageToMessageEncoder + +class XorEncoder : MessageToMessageEncoder(ByteBuf::class.java) { + var key: Int = 0 + + override fun encode(ctx: ChannelHandlerContext, msg: ByteBuf, out: MutableList) { + out += msg.xor(key) + } +}