not using jetty

This commit is contained in:
xis 2023-10-11 17:18:26 +02:00
parent 2b212b9607
commit c3e8bf240d
11 changed files with 277 additions and 218 deletions

View file

@ -1,47 +1,51 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id 'org.springframework.boot' version '3.1.4'
id 'io.spring.dependency-management' version '1.1.3'
id 'org.jetbrains.kotlin.jvm' version '1.8.22'
id 'org.jetbrains.kotlin.plugin.spring' version '1.8.22'
id "org.jetbrains.kotlin.plugin.jpa" version '1.8.22'
id 'org.springframework.boot' version '3.1.4'
id 'io.spring.dependency-management' version '1.1.3'
id 'org.jetbrains.kotlin.jvm' version '1.8.22'
id 'org.jetbrains.kotlin.plugin.spring' version '1.8.22'
id "org.jetbrains.kotlin.plugin.jpa" version '1.8.22'
}
group = 'net.schowek'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
sourceCompatibility = '17'
}
repositories {
mavenCentral()
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'
implementation 'org.jetbrains.kotlin:kotlin-reflect'
implementation 'io.github.microutils:kotlin-logging:3.0.5'
implementation('org.springframework.boot:spring-boot-starter-web') {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
}
implementation 'org.springframework.boot:spring-boot-starter-undertow'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.mariadb.jdbc:mariadb-java-client:3.2.0'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'
implementation 'org.jetbrains.kotlin:kotlin-reflect'
implementation 'io.github.microutils:kotlin-logging:3.0.5'
implementation 'org.jupnp:org.jupnp:2.7.1'
implementation 'org.jupnp:org.jupnp.support:2.7.1'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.mariadb.jdbc:mariadb-java-client:3.2.0'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.jupnp:org.jupnp:2.7.1'
implementation 'org.jupnp:org.jupnp.support:2.7.1'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.withType(KotlinCompile) {
kotlinOptions {
freeCompilerArgs += '-Xjsr305=strict'
jvmTarget = '17'
}
kotlinOptions {
freeCompilerArgs += '-Xjsr305=strict'
jvmTarget = '17'
}
}
tasks.named('test') {
useJUnitPlatform()
useJUnitPlatform()
}

View file

@ -8,9 +8,9 @@ import org.springframework.stereotype.Component
@Component
class NextcloudDLNA(
private val dlnaService: DlnaService
dlnaService: DlnaService
) {
private val upnpService: UpnpService = dlnaService.start()
val upnpService: UpnpService = dlnaService.start()
@PreDestroy
fun destroy() {

View file

@ -2,8 +2,10 @@ package net.schowek.nextclouddlna
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.web.servlet.config.annotation.EnableWebMvc
@SpringBootApplication
@EnableWebMvc
class NextcloudDLNAApp
fun main(args: Array<String>) {

View file

@ -0,0 +1,81 @@
package net.schowek.nextclouddlna.controller
import UpnpStreamProcessor
import jakarta.servlet.http.HttpServletRequest
import mu.KLogging
import net.schowek.nextclouddlna.NextcloudDLNA
import net.schowek.nextclouddlna.dlna.media.MediaServer
import org.springframework.core.io.InputStreamResource
import org.springframework.core.io.Resource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatusCode
import org.springframework.http.MediaType.APPLICATION_OCTET_STREAM_VALUE
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller
import org.springframework.util.MultiValueMap
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestMethod.*
import org.springframework.web.bind.annotation.ResponseBody
@Controller
class DLNAController(
private val dlna: NextcloudDLNA,
private val streamRequestMapper: StreamRequestMapper
) {
@RequestMapping(
method = [GET, HEAD], value = ["/dev/{uid}/icon.png"],
produces = [APPLICATION_OCTET_STREAM_VALUE]
)
@ResponseBody
fun handleGetIcon(
@PathVariable("uid") uid: String,
request: HttpServletRequest
): Resource {
logger.info { "GET ICON request from ${request.remoteAddr}: ${request.requestURI}" }
return InputStreamResource(MediaServer.iconResource());
}
@RequestMapping(
method = [GET, HEAD], value = [
"/dev/{uid}/desc",
"/dev/{uid}/svc/upnp-org/ContentDirectory/desc",
"/dev/{uid}/svc/upnp-org/ConnectionManager/desc"
],
produces = ["application/xml;charset=utf8", "text/xml;charset=utf8"]
)
fun handleGet(
@PathVariable("uid") uid: String,
request: HttpServletRequest
): ResponseEntity<Any> {
logger.info { "GET request from ${request.remoteAddr}: ${request.requestURI}" }
val r = UpnpStreamProcessor(dlna).processMessage(streamRequestMapper.map(request))
return ResponseEntity(
r.body,
HttpHeaders().also { h -> r.headers.entries.forEach { h.add(it.key, it.value.joinToString { it }) } },
HttpStatusCode.valueOf(r.operation.statusCode)
)
}
@RequestMapping(
method = [POST], value = [
"/dev/{uid}/svc/upnp-org/ContentDirectory/action"
],
produces = ["application/xml;charset=utf8", "text/xml;charset=utf8"]
)
fun handlePost(
@PathVariable("uid") uid: String,
request: HttpServletRequest
): ResponseEntity<Any> {
logger.info { "POST request from ${request.remoteAddr}: ${request.requestURI}" }
val r = UpnpStreamProcessor(dlna).processMessage(streamRequestMapper.map(request))
return ResponseEntity(
r.body,
HttpHeaders().also { h -> r.headers.entries.forEach { h.add(it.key, it.value.joinToString { it }) } },
HttpStatusCode.valueOf(r.operation.statusCode)
)
}
companion object : KLogging()
}

View file

@ -0,0 +1,26 @@
package net.schowek.nextclouddlna.controller
import mu.KLogging
import org.springframework.http.HttpStatus.NOT_FOUND
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.context.request.WebRequest
import org.springframework.web.servlet.NoHandlerFoundException
@ControllerAdvice
class ErrorHandler {
@ExceptionHandler(NoHandlerFoundException::class)
@ResponseStatus(NOT_FOUND)
@ResponseBody
fun handleNoHandlerFound(e: NoHandlerFoundException, request: WebRequest): HashMap<String, String> {
val response = HashMap<String, String>()
response["status"] = "fail"
response["message"] = e.localizedMessage
logger.info { "404 from $request (${e.requestURL})" }
return response
}
companion object : KLogging()
}

View file

@ -0,0 +1,79 @@
package net.schowek.nextclouddlna.controller
import jakarta.servlet.http.HttpServletRequest
import mu.KLogging
import org.jupnp.model.message.*
import org.springframework.stereotype.Component
import java.net.InetAddress
import java.net.URI
@Component
class StreamRequestMapper {
fun map(request: HttpServletRequest): StreamRequestMessage {
val requestMessage = StreamRequestMessage(
UpnpRequest.Method.getByHttpName(request.method),
URI(request.requestURI)
// TODO put request.inputStream.readBytes() here
)
if (requestMessage.operation.method == UpnpRequest.Method.UNKNOWN) {
logger.warn("Method not supported by UPnP stack: {}", request.method)
throw RuntimeException("Method not supported: {}" + request.method)
}
requestMessage.connection = MyHttpServerConnection(request)
requestMessage.headers = createHeaders(request)
setBody(request, requestMessage)
return requestMessage
}
private fun setBody(
request: HttpServletRequest,
requestMessage: StreamRequestMessage
) {
val bodyBytes = request.inputStream.readBytes()
logger.debug(" Reading request body bytes: " + bodyBytes.size)
if (bodyBytes.isNotEmpty() && requestMessage.isContentTypeMissingOrText) {
logger.debug("Request contains textual entity body, converting then setting string on message")
requestMessage.setBodyCharacters(bodyBytes)
} else if (bodyBytes.isNotEmpty()) {
logger.debug("Request contains binary entity body, setting bytes on message")
requestMessage.setBody(UpnpMessage.BodyType.BYTES, bodyBytes)
} else {
logger.debug("Request did not contain entity body")
}
}
private fun createHeaders(request: HttpServletRequest): UpnpHeaders {
val headers = mutableMapOf<String, List<String>>()
with(request.headerNames) {
if (this != null) {
while (hasMoreElements()) {
with(nextElement()) {
headers[this] = listOf(request.getHeader(this))
}
}
}
}
return UpnpHeaders(headers)
}
inner class MyHttpServerConnection(
private val request: HttpServletRequest
) : Connection {
override fun isOpen(): Boolean {
return true
}
override fun getRemoteAddress(): InetAddress? {
return if (request.remoteAddr != null) InetAddress.getByName(request.remoteAddr) else null
}
override fun getLocalAddress(): InetAddress? {
return if (request.localAddr != null) InetAddress.getByName(request.localAddr) else null
}
}
companion object : KLogging()
}

View file

@ -0,0 +1,26 @@
import mu.KLogging
import net.schowek.nextclouddlna.NextcloudDLNA
import org.jupnp.model.message.StreamRequestMessage
import org.jupnp.model.message.StreamResponseMessage
import org.jupnp.model.message.UpnpResponse
import org.jupnp.transport.spi.UpnpStream
class UpnpStreamProcessor(
dlna: NextcloudDLNA
) : UpnpStream(dlna.upnpService.protocolFactory) {
fun processMessage(requestMsg: StreamRequestMessage): StreamResponseMessage {
logger.debug { "Processing $requestMsg" }
var response = super.process(requestMsg)
if (response == null) {
response = StreamResponseMessage(UpnpResponse.Status.NOT_FOUND)
}
return response
}
override fun run() {
}
companion object : KLogging()
}

View file

@ -1,5 +1,6 @@
package net.schowek.nextclouddlna.dlna
import mu.KLogging
import net.schowek.nextclouddlna.dlna.media.MediaServer
import net.schowek.nextclouddlna.dlna.transport.ApacheStreamClient
import net.schowek.nextclouddlna.dlna.transport.ApacheStreamClientConfiguration
@ -7,9 +8,13 @@ import net.schowek.nextclouddlna.dlna.transport.MyStreamServerConfiguration
import net.schowek.nextclouddlna.dlna.transport.MyStreamServerImpl
import net.schowek.nextclouddlna.util.ServerInfoProvider
import org.jupnp.DefaultUpnpServiceConfiguration
import org.jupnp.UpnpService
import org.jupnp.UpnpServiceConfiguration
import org.jupnp.UpnpServiceImpl
import org.jupnp.model.meta.LocalDevice
import org.jupnp.protocol.ProtocolFactory
import org.jupnp.protocol.ProtocolFactoryImpl
import org.jupnp.protocol.async.SendingNotificationAlive
import org.jupnp.registry.Registry
import org.jupnp.transport.impl.NetworkAddressFactoryImpl
import org.jupnp.transport.spi.NetworkAddressFactory
@ -39,9 +44,13 @@ class DlnaService(
override fun createRegistry(pf: ProtocolFactory): Registry {
return RegistryImplWithOverrides(this)
}
override fun createProtocolFactory(): ProtocolFactory? {
return MyProtocolFactory(this)
}
}
private inner class MyUpnpServiceConfiguration : DefaultUpnpServiceConfiguration(8081) {
private inner class MyUpnpServiceConfiguration : DefaultUpnpServiceConfiguration(8080) {
override fun createStreamClient(): StreamClient<*> {
return ApacheStreamClient(
ApacheStreamClientConfiguration(syncProtocolExecutorService)
@ -72,3 +81,14 @@ class DlnaService(
}
}
class MyProtocolFactory(
upnpService: UpnpService
) : ProtocolFactoryImpl(upnpService) {
override fun createSendingNotificationAlive(localDevice: LocalDevice): SendingNotificationAlive {
logger.info { "SENDING ALIVE $localDevice" }
return SendingNotificationAlive(upnpService, localDevice)
}
companion object : KLogging()
}

View file

@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.io.IOException
import java.io.InputStream
@Component
@ -20,17 +21,17 @@ class MediaServer(
private val connectionManagerService: LocalService<*>,
@Value("\${server.friendlyName}")
private val friendlyName: String,
private val externalUrls: ExternalUrls
externalUrls: ExternalUrls
) {
val device = LocalDevice(
DeviceIdentity(uniqueSystemIdentifier("DLNAtoad-MediaServer"), 300),
final val device = LocalDevice(
DeviceIdentity(uniqueSystemIdentifier("Nextcloud-DLNA-MediaServer"), 300),
UDADeviceType(DEVICE_TYPE, VERSION),
DeviceDetails(friendlyName, externalUrls.selfURI),
createDeviceIcon(), arrayOf(contentDirectoryService, connectionManagerService)
createDeviceIcon(),
arrayOf(contentDirectoryService, connectionManagerService)
)
@PostConstruct
fun init() {
init {
logger.info("uniqueSystemIdentifier: {} ({})", device.identity.udn, friendlyName)
}
@ -41,14 +42,18 @@ class MediaServer(
@Throws(IOException::class)
fun createDeviceIcon(): Icon {
val resource = MediaServer::class.java.getResourceAsStream("/$ICON_FILENAME")
?: throw IllegalStateException("Icon not found.")
val resource = iconResource()
return resource.use { res ->
Icon("image/png", 48, 48, 8, ICON_FILENAME, res).also {
it.validate()
}
}
}
fun iconResource(): InputStream {
return MediaServer::class.java.getResourceAsStream("/$ICON_FILENAME")
?: throw IllegalStateException("Icon not found.")
}
}
}

View file

@ -1,117 +0,0 @@
package net.schowek.nextclouddlna.dlna.transport
import com.sun.net.httpserver.HttpExchange
import mu.KLogging
import org.jupnp.model.message.*
import org.jupnp.protocol.ProtocolFactory
import org.jupnp.transport.spi.UpnpStream
import org.jupnp.util.io.IO
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection
abstract class MyHttpExchangeUpnpStream(
protocolFactory: ProtocolFactory,
private val httpExchange: HttpExchange
) : UpnpStream(protocolFactory) {
override fun run() {
try {
// Status
val requestMessage = StreamRequestMessage(
UpnpRequest.Method.getByHttpName(httpExchange.requestMethod),
httpExchange.requestURI
)
if (requestMessage.operation.method == UpnpRequest.Method.UNKNOWN) {
logger.warn("Method not supported by UPnP stack: {}", httpExchange.requestMethod)
throw RuntimeException("Method not supported: {}" + httpExchange.requestMethod)
}
// Protocol
requestMessage.operation.httpMinorVersion = if (httpExchange.protocol.uppercase() == "HTTP/1.1") 1 else 0
// Connection wrapper
requestMessage.connection = createConnection()
// Headers
requestMessage.headers = UpnpHeaders(httpExchange.requestHeaders)
// Body
val bodyBytes: ByteArray
var inputStream: InputStream? = null
try {
inputStream = httpExchange.requestBody
bodyBytes = IO.readBytes(inputStream)
} finally {
inputStream?.close()
}
logger.info(" Reading request body bytes: " + bodyBytes.size)
if (bodyBytes.isNotEmpty() && requestMessage.isContentTypeMissingOrText) {
logger.debug("Request contains textual entity body, converting then setting string on message")
requestMessage.setBodyCharacters(bodyBytes)
} else if (bodyBytes.isNotEmpty()) {
logger.debug("Request contains binary entity body, setting bytes on message")
requestMessage.setBody(UpnpMessage.BodyType.BYTES, bodyBytes)
} else {
logger.debug("Request did not contain entity body")
}
if (bodyBytes.isNotEmpty()) {
logger.debug(" Request body: " + requestMessage.body)
}
val responseMessage = process(requestMessage)
// Return the response
if (responseMessage != null) {
// Headers
httpExchange.responseHeaders.putAll(responseMessage.headers)
// Body
val responseBodyBytes = if (responseMessage.hasBody()) responseMessage.bodyBytes else null
val contentLength = responseBodyBytes?.size ?: -1
logger.info("Sending HTTP response message: $responseMessage with content length: $contentLength")
httpExchange.sendResponseHeaders(responseMessage.operation.statusCode, contentLength.toLong())
if (responseBodyBytes!!.isNotEmpty()) {
logger.debug(" Response body: " + responseMessage.body)
}
if (contentLength > 0) {
logger.debug("Response message has body, writing bytes to stream...")
var outputStream: OutputStream? = null
try {
outputStream = httpExchange.responseBody
IO.writeBytes(outputStream, responseBodyBytes)
outputStream.flush()
} finally {
outputStream?.close()
}
}
} else {
// If it's null, it's 404, everything else needs a proper httpResponse
logger.info("Sending HTTP response status: " + HttpURLConnection.HTTP_NOT_FOUND)
httpExchange.sendResponseHeaders(HttpURLConnection.HTTP_NOT_FOUND, -1)
}
responseSent(responseMessage)
} catch (t: Throwable) {
// You definitely want to catch all Exceptions here, otherwise the server will
// simply close the socket and you get an "unexpected end of file" on the client.
// The same is true if you just rethrow an IOException - it is a mystery why it
// is declared then on the HttpHandler interface if it isn't handled in any
// way... so we always do error handling here.
// TODO: We should only send an error if the problem was on our side
// You don't have to catch Throwable unless, like we do here in unit tests,
// you might run into Errors as well (assertions).
logger.warn("Exception occurred during UPnP stream processing:", t)
try {
httpExchange.sendResponseHeaders(HttpURLConnection.HTTP_INTERNAL_ERROR, -1)
} catch (ex: IOException) {
logger.warn("Couldn't send error response: ", ex)
}
responseException(t)
}
}
protected abstract fun createConnection(): Connection?
companion object: KLogging()
}

View file

@ -1,104 +1,37 @@
package net.schowek.nextclouddlna.dlna.transport
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpHandler
import com.sun.net.httpserver.HttpServer
import mu.KLogging
import org.jupnp.model.message.Connection
import org.jupnp.transport.Router
import org.jupnp.transport.spi.InitializationException
import org.jupnp.transport.spi.StreamServer
import java.io.IOException
import java.net.InetAddress
import java.net.InetSocketAddress
class MyStreamServerImpl(
private val configuration: MyStreamServerConfiguration
) : StreamServer<MyStreamServerConfiguration> {
private var server: HttpServer? = null
@Synchronized
@Throws(InitializationException::class)
override fun init(bindAddress: InetAddress, router: Router) {
try {
val socketAddress = InetSocketAddress(bindAddress, configuration.listenPort)
server = HttpServer.create(socketAddress, configuration.tcpConnectionBacklog)
server!!.createContext("/", MyRequestHttpHandler(router))
logger.info("* * * * * * * * * * * * * * * * * * * * * * * * * * * * * *")
logger.info("Created server (for receiving TCP streams) on: " + server!!.address)
logger.info("* * * * * * * * * * * * * * * * * * * * * * * * * * * * * *")
} catch (ex: Exception) {
throw InitializationException("Could not initialize " + javaClass.simpleName + ": " + ex.toString(), ex)
}
}
@Synchronized
override fun getPort(): Int {
return server!!.address.port
return configuration.listenPort;
}
override fun getConfiguration(): MyStreamServerConfiguration {
return configuration
}
@Synchronized
override fun run() {
logger.info("Starting StreamServer...")
// Starts a new thread but inherits the properties of the calling thread
server!!.start()
}
@Synchronized
override fun stop() {
logger.info("Stopping StreamServer...")
if (server != null) {
server!!.stop(1)
}
}
inner class MyRequestHttpHandler(private val router: Router) : HttpHandler {
// This is executed in the request receiving thread!
@Throws(IOException::class)
override fun handle(httpExchange: HttpExchange) {
// And we pass control to the service, which will (hopefully) start a new thread immediately so we can
// continue the receiving thread ASAP
logger.info("Received HTTP exchange: " + httpExchange.requestMethod + " " + httpExchange.requestURI + " from " + httpExchange.remoteAddress)
router.received(
object : MyHttpExchangeUpnpStream(router.protocolFactory, httpExchange) {
override fun createConnection(): Connection {
return MyHttpServerConnection(httpExchange)
}
}
)
}
}
/**
* Logs a warning and returns `true`, we can't access the socket using the awful JDK webserver API.
* Override this method if you know how to do it.
*/
private fun isConnectionOpen(exchange: HttpExchange?): Boolean {
logger.warn("Can't check client connection, socket access impossible on JDK webserver!")
return true
}
private inner class MyHttpServerConnection(
private val exchange: HttpExchange
) : Connection {
override fun isOpen(): Boolean {
return isConnectionOpen(exchange)
}
override fun getRemoteAddress(): InetAddress? {
return if (exchange.remoteAddress != null) exchange.remoteAddress.address else null
}
override fun getLocalAddress(): InetAddress? {
return if (exchange.localAddress != null) exchange.localAddress.address else null
}
}
companion object: KLogging()
}