diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..410a63a --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + kotlin("multiplatform") version "1.9.21" +} + +group = "com.github.gimlet2.kottpd" +version = "0.2.1" + +repositories { + mavenCentral() +} + +kotlin { + jvm("desktop") { + compilations.all { + kotlinOptions.jvmTarget = "20" + } + withJava() + } + val hostOs = System.getProperty("os.name") + val isMingwX64 = hostOs.startsWith("Windows") + + val nativeTarget = when { + hostOs == "Mac OS X" -> macosX64("native") + hostOs == "Linux" -> linuxX64("native") + isMingwX64 -> mingwX64("native") + else -> throw GradleException("Host OS is not supported in Kotlin/Native.") + } + + nativeTarget.apply { + binaries { + executable { + baseName = "kottpd" + } + staticLib{ + baseName = "kottpd" + } + } + } + sourceSets { + val commonMain by getting + val nativeMain by getting + val desktopMain by getting + val desktopTest by getting + + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..17655d0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/kotlin/com/github/gimlet2/kottpd/ClientThread.kt b/src/commonMain/kotlin/com/github/gimlet2/kottpd/ClientThread.kt similarity index 58% rename from src/main/kotlin/com/github/gimlet2/kottpd/ClientThread.kt rename to src/commonMain/kotlin/com/github/gimlet2/kottpd/ClientThread.kt index e3675a9..a2b63f2 100644 --- a/src/main/kotlin/com/github/gimlet2/kottpd/ClientThread.kt +++ b/src/commonMain/kotlin/com/github/gimlet2/kottpd/ClientThread.kt @@ -1,18 +1,16 @@ package com.github.gimlet2.kottpd -import java.io.BufferedReader -import java.io.InputStreamReader -import java.net.Socket -import java.util.* +/** + * Created by Andrei Chernyshev on 1/14/22. + */ +class ClientThread(val socket: Socket, val match: (HttpRequest) -> (HttpRequest, HttpResponse) -> Any) { -class ClientThread(val socket: Socket, val match: (HttpRequest) -> (HttpRequest, HttpResponse) -> Any) : Runnable { - - override fun run() { - val input = BufferedReader(InputStreamReader(socket.inputStream)) - val out = socket.outputStream + fun run() { + val input = socket.reader + val out = socket.writer val request = readRequest(input) { LinkedHashMap().apply { - input.lineSequence().takeWhile(String::isNotBlank).forEach { line -> + input.lines().takeWhile(String::isNotBlank).forEach { line: String -> line.split(":").let { put(it[0], it[1].trim()) } @@ -28,10 +26,9 @@ class ClientThread(val socket: Socket, val match: (HttpRequest) -> (HttpRequest, socket.close() } - fun readRequest(reader: BufferedReader, eval: () -> Map): HttpRequest { - reader.readLine().split(' ').let { + fun readRequest(reader: Reader, eval: () -> Map): HttpRequest { + reader.line().split(' ').let { return HttpRequest(HttpMethod.valueOf(it[0]), it[1], it[2], eval(), reader) } } } - diff --git a/src/commonMain/kotlin/com/github/gimlet2/kottpd/Closable.kt b/src/commonMain/kotlin/com/github/gimlet2/kottpd/Closable.kt new file mode 100644 index 0000000..54c2499 --- /dev/null +++ b/src/commonMain/kotlin/com/github/gimlet2/kottpd/Closable.kt @@ -0,0 +1,5 @@ +package com.github.gimlet2.kottpd + +interface Closable { + fun close() +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/gimlet2/kottpd/HttpMethod.kt b/src/commonMain/kotlin/com/github/gimlet2/kottpd/HttpMethod.kt similarity index 100% rename from src/main/kotlin/com/github/gimlet2/kottpd/HttpMethod.kt rename to src/commonMain/kotlin/com/github/gimlet2/kottpd/HttpMethod.kt diff --git a/src/commonMain/kotlin/com/github/gimlet2/kottpd/HttpRequest.kt b/src/commonMain/kotlin/com/github/gimlet2/kottpd/HttpRequest.kt new file mode 100644 index 0000000..774acc1 --- /dev/null +++ b/src/commonMain/kotlin/com/github/gimlet2/kottpd/HttpRequest.kt @@ -0,0 +1,16 @@ +package com.github.gimlet2.kottpd + +/** + * Created by Andrei Chernyshev on 1/14/22. + */ +data class HttpRequest( + val method: HttpMethod, + val url: String, + val httpVersion: String, + val headers: Map, + val stream: Reader +) { + val content: String by lazy { + (1..headers.getOrElse("Content-Length") { "0" }.toInt()).fold("") { a, b -> a + stream.read().toChar() } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/github/gimlet2/kottpd/HttpResponse.kt b/src/commonMain/kotlin/com/github/gimlet2/kottpd/HttpResponse.kt new file mode 100644 index 0000000..21f35f4 --- /dev/null +++ b/src/commonMain/kotlin/com/github/gimlet2/kottpd/HttpResponse.kt @@ -0,0 +1,29 @@ +package com.github.gimlet2.kottpd + +/** + * Created by Andrei Chernyshev on 1/14/22. + */ +data class HttpResponse( + var status: Status = Status.OK, + val stream: Writer +) { + + private var dirty = false + + fun send(content: String, status: Status = this.status, headers: Map = emptyMap()) { + if (!dirty) { + stream.println("HTTP/1.1 ${status.code} ${status.value}") + if (headers.isNotEmpty()) { + headers.forEach { stream.println("${it.key}: ${it.value}") } + stream.println() + } + stream.println() + dirty = true + } + stream.print(content) + } + + fun flush() { + stream.flush() + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/github/gimlet2/kottpd/Reader.kt b/src/commonMain/kotlin/com/github/gimlet2/kottpd/Reader.kt new file mode 100644 index 0000000..3ab52d0 --- /dev/null +++ b/src/commonMain/kotlin/com/github/gimlet2/kottpd/Reader.kt @@ -0,0 +1,10 @@ +package com.github.gimlet2.kottpd + +/** + * Created by Andrei Chernyshev on 1/7/22. + */ +expect class Reader { + fun lines(): Sequence + fun read(): Int + fun line(): String +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/github/gimlet2/kottpd/Server.kt b/src/commonMain/kotlin/com/github/gimlet2/kottpd/Server.kt new file mode 100644 index 0000000..71cab55 --- /dev/null +++ b/src/commonMain/kotlin/com/github/gimlet2/kottpd/Server.kt @@ -0,0 +1,172 @@ +package com.github.gimlet2.kottpd + +/** + * Created by Andrei Chernyshev on 1/14/22. + */ +class Server(val port: Int = "9001".toInt()) { +//class Server(val port: Int = (System.getProperty("server.port") ?: "9000").toInt()) { + + // val threadPool: ExecutorService = Executors.newCachedThreadPool() + val bindings: Map Any>> = mapOf( + Pair(HttpMethod.GET, mutableMapOf()), + Pair(HttpMethod.POST, mutableMapOf()), + Pair(HttpMethod.PUT, mutableMapOf()), + Pair(HttpMethod.DELETE, mutableMapOf()), + Pair(HttpMethod.OPTIONS, mutableMapOf()), + Pair(HttpMethod.CONNECT, mutableMapOf()), + Pair(HttpMethod.HEAD, mutableMapOf()), + Pair(HttpMethod.TRACE, mutableMapOf()) + ) + + val filtersBefore: MutableMap Any> = mutableMapOf() + val filtersAfter: MutableMap Any> = mutableMapOf() +// val exceptions: MutableMap, (HttpRequest, HttpResponse) -> Any> = mutableMapOf() + + fun start(port: Int = this.port, secure: Boolean = false, keyStoreFile: String = "", password: String = "") { + bindFilters() + println("Server start on port $port") + val socket = ServerSocket(port, secure, keyStoreFile, password) + try { + + while (true) { + ClientThread(socket.accept()) { matchRequest(it) }.run() + } + } catch (e: Exception) { + println(e.message) + socket.close() + } + } + + private fun bindFilters() { + for (binding in bindings) { + val iterator = binding.value.iterator() + while (iterator.hasNext()) { + var (key, action) = iterator.next() + for ((path, before) in filtersBefore) { + if (key == path || key.matches(path.toRegex())) { + action = chain(before, action) + binding.value[key] = action + } + } + for ((path, after) in filtersAfter) { + if (key == path || key.matches(path.toRegex())) { + action = chain(action, after) + binding.value[key] = action + } + } + val actionRef = action +// if (exceptions.isNotEmpty()) { +// action = { req: HttpRequest, res: HttpResponse -> +// try { +// actionRef(req, res) +// } catch (e: RuntimeException) { +// if (exceptions.contains(e.javaClass)) { +// exceptions[e.javaClass]!!.invoke(req, res) +// } else { +// res.send(e.message ?: "Error", Status.InternalServerError) +// } +// } +// } +// binding.value[key] = action +// } + } + } + } + + private fun chain( + a: (HttpRequest, HttpResponse) -> Any, + b: (HttpRequest, HttpResponse) -> Any + ): (HttpRequest, HttpResponse) -> Any { + return { req: HttpRequest, res: HttpResponse -> + a.invoke(req, res) + b.invoke(req, res) + } + } +// +// private fun secureSocket(port: Int, keyStoreFile: String, password: String): ServerSocket { +// +// /* Create keystore */ +// val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) +// keyStore.load(FileInputStream(keyStoreFile), password.toCharArray()) +// +// /* Get factory for the given keystore */ +// TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply { init(keyStore) } +// val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { init(keyStore, password.toCharArray()) } +// return SSLContext.getInstance("SSL").apply { init(kmf.keyManagers, null, null) } +// .serverSocketFactory.createServerSocket(port) +// +// } + + private fun matchRequest(request: HttpRequest): (HttpRequest, HttpResponse) -> Any { + return bindings[request.method]!!.let { routes -> + routes.getOrElse(request.url, { + routes.filter { + it.key.toRegex().matches(request.url) + }.values.firstOrElse { + { req: HttpRequest, res: HttpResponse -> res.send("Resource not found", Status.NotFound) } + } + } + ) + } + } + + fun bind(method: HttpMethod, path: String, call: (request: HttpRequest, response: HttpResponse) -> Any) { + bindings[method]?.put(path, call) + } + + fun get(path: String, call: (request: HttpRequest, response: HttpResponse) -> Any) { + bind(HttpMethod.GET, path, call) + } + + fun post(path: String, call: (request: HttpRequest, response: HttpResponse) -> Any) { + bind(HttpMethod.POST, path, call) + } + + fun put(path: String, call: (request: HttpRequest, response: HttpResponse) -> Any) { + bind(HttpMethod.PUT, path, call) + } + + fun delete(path: String, call: (request: HttpRequest, response: HttpResponse) -> Any) { + bind(HttpMethod.DELETE, path, call) + } + + fun before(path: String, call: (request: HttpRequest, response: HttpResponse) -> Any) { + filtersBefore.put(path, call) + } + + fun before(call: (request: HttpRequest, response: HttpResponse) -> Any) { + filtersBefore.put(".*", call) + } + + fun after(path: String, call: (request: HttpRequest, response: HttpResponse) -> Any) { + filtersAfter.put(path, call) + } + + fun after(call: (request: HttpRequest, response: HttpResponse) -> Any) { + filtersAfter.put(".*", call) + } +// +// fun exception(klass: KClass, call: (request: HttpRequest, response: HttpResponse) -> Any) { +// exceptions.put(klass.java, call) +// } + +// fun staticFiles(path: String) { +// val fullPath = javaClass.getResource(path).file +// File(fullPath) +// .walkTopDown() +// .forEach { +// if (!it.isDirectory) { +// val file = it.path.substring(fullPath.length) +// val call: (HttpRequest, HttpResponse) -> Unit = { req, res -> it.inputStream().copyTo(res.stream).let { } } +// get(file, call) +// if (file == "/index.html" || file == "/index.htm") { +// get("/", call) +// } +// } +// } +// } +} + +fun Iterable.firstOrElse(eval: () -> T): T { + return this.firstOrNull() ?: return eval.invoke() +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/github/gimlet2/kottpd/ServerSocket.kt b/src/commonMain/kotlin/com/github/gimlet2/kottpd/ServerSocket.kt new file mode 100644 index 0000000..1b9bdb9 --- /dev/null +++ b/src/commonMain/kotlin/com/github/gimlet2/kottpd/ServerSocket.kt @@ -0,0 +1,9 @@ +package com.github.gimlet2.kottpd + +/** + * Created by Andrei Chernyshev on 1/14/22. + */ +expect class ServerSocket(port: Int, secure: Boolean = false, keyStoreFile: String = "", password: String = "") { + fun accept(): Socket + fun close() +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/github/gimlet2/kottpd/Socket.kt b/src/commonMain/kotlin/com/github/gimlet2/kottpd/Socket.kt new file mode 100644 index 0000000..0913925 --- /dev/null +++ b/src/commonMain/kotlin/com/github/gimlet2/kottpd/Socket.kt @@ -0,0 +1,10 @@ +package com.github.gimlet2.kottpd + +/** + * Created by Andrei Chernyshev on 1/14/22. + */ +class Socket(val reader: Reader, val writer: Writer, val closeAction: () -> Unit) { + fun close() { + closeAction() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/gimlet2/kottpd/Status.kt b/src/commonMain/kotlin/com/github/gimlet2/kottpd/Status.kt similarity index 100% rename from src/main/kotlin/com/github/gimlet2/kottpd/Status.kt rename to src/commonMain/kotlin/com/github/gimlet2/kottpd/Status.kt diff --git a/src/commonMain/kotlin/com/github/gimlet2/kottpd/Writer.kt b/src/commonMain/kotlin/com/github/gimlet2/kottpd/Writer.kt new file mode 100644 index 0000000..4ad6b91 --- /dev/null +++ b/src/commonMain/kotlin/com/github/gimlet2/kottpd/Writer.kt @@ -0,0 +1,9 @@ +package com.github.gimlet2.kottpd + +expect class Writer { + fun println(str: String = "") + + fun print(str: String = "") + + fun flush() +} diff --git a/src/desktopMain/kotlin/com/github/gimlet2/kottpd/Reader.kt b/src/desktopMain/kotlin/com/github/gimlet2/kottpd/Reader.kt new file mode 100644 index 0000000..7f08e76 --- /dev/null +++ b/src/desktopMain/kotlin/com/github/gimlet2/kottpd/Reader.kt @@ -0,0 +1,23 @@ +package com.github.gimlet2.kottpd + +import java.io.Reader +import kotlin.streams.asSequence + +/** + * Created by Andrei Chernyshev on 1/7/22. + */ +actual class Reader(reader: Reader) { + private val bufferedReader = reader.buffered() + + actual fun lines(): Sequence { + return bufferedReader.lines().asSequence() + } + + actual fun read(): Int { + return bufferedReader.read() + } + + actual fun line(): String { + return bufferedReader.readLine() + } +} \ No newline at end of file diff --git a/src/desktopMain/kotlin/com/github/gimlet2/kottpd/ServerJvm.kt b/src/desktopMain/kotlin/com/github/gimlet2/kottpd/ServerJvm.kt new file mode 100644 index 0000000..a22379a --- /dev/null +++ b/src/desktopMain/kotlin/com/github/gimlet2/kottpd/ServerJvm.kt @@ -0,0 +1,19 @@ +package com.github.gimlet2.kottpd + +///*** Example: +fun main() { + Server().apply { +// staticFiles("/public") + get("/hello") { _, res -> res.send("Hello") } + get("/test") { _, _ -> throw IllegalStateException("AAA") } + get("/do/.*/smth") { _, res -> res.send("Hello world") } + post("/data") { req, res -> res.send(req.content, Status.Created) } + before("/hello") { _, res -> res.send("before\n") } + before { _, res -> res.send("ALL before\n") } + after("/hello") { _, res -> res.send("\nafter\n") } + after { _, res -> res.send("ALL after\n") } +// exception(IllegalStateException::class) { _, _ -> "Illegal State" } + }.start() +// server.start(9443, true, "./keystore.jks", "password") +} + diff --git a/src/desktopMain/kotlin/com/github/gimlet2/kottpd/ServerSocket.kt b/src/desktopMain/kotlin/com/github/gimlet2/kottpd/ServerSocket.kt new file mode 100644 index 0000000..210927a --- /dev/null +++ b/src/desktopMain/kotlin/com/github/gimlet2/kottpd/ServerSocket.kt @@ -0,0 +1,54 @@ +package com.github.gimlet2.kottpd + +import java.io.FileInputStream +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.ServerSocket +import java.security.KeyStore +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory + +/** + * Created by Andrei Chernyshev on 1/14/22. + */ +actual class ServerSocket actual constructor(port: Int, secure: Boolean, keyStoreFile: String, password: String) { + + private val serverSocket: ServerSocket by lazy { + if (secure) { + secureSocket(port, keyStoreFile, password) + } else { + ServerSocket(port) + } + } + + actual fun accept(): Socket { + return serverSocket.accept() + .let { + Socket( + Reader(InputStreamReader(it.getInputStream())), + Writer(OutputStreamWriter(it.getOutputStream())), + it::close + ) + } + } + + actual fun close() { + serverSocket.close() + } + + private fun secureSocket(port: Int, keyStoreFile: String, password: String): ServerSocket { + + /* Create keystore */ + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(FileInputStream(keyStoreFile), password.toCharArray()) + + /* Get factory for the given keystore */ + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply { init(keyStore) } + val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + .apply { init(keyStore, password.toCharArray()) } + return SSLContext.getInstance("SSL").apply { init(kmf.keyManagers, null, null) } + .serverSocketFactory.createServerSocket(port) + + } +} \ No newline at end of file diff --git a/src/desktopMain/kotlin/com/github/gimlet2/kottpd/Writer.kt b/src/desktopMain/kotlin/com/github/gimlet2/kottpd/Writer.kt new file mode 100644 index 0000000..814235c --- /dev/null +++ b/src/desktopMain/kotlin/com/github/gimlet2/kottpd/Writer.kt @@ -0,0 +1,21 @@ +package com.github.gimlet2.kottpd + +import java.io.PrintWriter +import java.io.Writer + +actual class Writer(writer: Writer) { + + private val printWriter = PrintWriter(writer) + actual fun println(str: String) { + printWriter.println(str) + } + + actual fun print(str: String) { + printWriter.print(str) + } + + actual fun flush() { + printWriter.flush() + } + +} \ No newline at end of file diff --git a/src/test/kotlin/com/github/gimlet2/kottpd/HelloTest.kt b/src/desktopTest/kotlin/com/github/gimlet2/kottpd/HelloTest.kt similarity index 100% rename from src/test/kotlin/com/github/gimlet2/kottpd/HelloTest.kt rename to src/desktopTest/kotlin/com/github/gimlet2/kottpd/HelloTest.kt diff --git a/src/main/kotlin/com/github/gimlet2/kottpd/HttpRequest.kt b/src/main/kotlin/com/github/gimlet2/kottpd/HttpRequest.kt deleted file mode 100644 index bee7127..0000000 --- a/src/main/kotlin/com/github/gimlet2/kottpd/HttpRequest.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.github.gimlet2.kottpd - -import java.io.BufferedReader - -data class HttpRequest(val method: HttpMethod, - val url: String, - val httpVersion: String, - val headers: Map, - val stream: BufferedReader) { - val content: String by lazy { - (1..headers.getOrElse("Content-Length", { "0" }).toInt()).fold("", { a, _ -> a + stream.read().toChar() }) - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/github/gimlet2/kottpd/HttpResponse.kt b/src/main/kotlin/com/github/gimlet2/kottpd/HttpResponse.kt deleted file mode 100644 index 5f7b372..0000000 --- a/src/main/kotlin/com/github/gimlet2/kottpd/HttpResponse.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.github.gimlet2.kottpd - -import java.io.OutputStream -import java.io.OutputStreamWriter -import java.io.PrintWriter - -data class HttpResponse(var status: Status = Status.OK, - val stream: OutputStream) { - private val printWriter: PrintWriter by lazy { - PrintWriter(OutputStreamWriter(stream)) - } - - private var dirty = false - - fun send(content: String, status: Status = this.status, headers: Map = emptyMap()) { - if (!dirty) { - printWriter.println("HTTP/1.1 ${status.code} ${status.value}") - if (headers.isNotEmpty()) { - headers.forEach { printWriter.println("${it.key}: ${it.value}") } - } - printWriter.println() - dirty = true - } - printWriter.print(content) - } - - fun flush() { - printWriter.flush() - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/github/gimlet2/kottpd/Server.kt b/src/main/kotlin/com/github/gimlet2/kottpd/Server.kt deleted file mode 100644 index 1ce0077..0000000 --- a/src/main/kotlin/com/github/gimlet2/kottpd/Server.kt +++ /dev/null @@ -1,197 +0,0 @@ -package com.github.gimlet2.kottpd - -import java.io.File -import java.io.FileInputStream -import java.io.IOException -import java.net.ServerSocket -import java.security.KeyStore -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import javax.net.ssl.KeyManagerFactory -import javax.net.ssl.SSLContext -import javax.net.ssl.TrustManagerFactory -import kotlin.reflect.KClass -import kotlin.system.exitProcess - -/*** Example: -fun main() { - Server().apply { - staticFiles("/public") - get("/hello") { _, res -> res.send("Hello") } - get("/test") { _, _ -> throw IllegalStateException("AAA") } - get("/do/.*/smth") { _, res -> res.send("Hello world") } - post("/data") { req, res -> res.send(req.content, Status.Created) } - before("/hello") { _, res -> res.send("before\n") } - before { _, res -> res.send("ALL before\n") } - after("/hello") { _, res -> res.send("\nafter\n") } - after { _, res -> res.send("ALL after\n") } - exception(IllegalStateException::class) { _, _ -> "Illegal State" } - }.start() -// server.start(9443, true, "./keystore.jks", "password") -} -***/ -class Server(val port: Int = (System.getProperty("server.port") ?: "9000").toInt()) { - - val threadPool: ExecutorService = Executors.newCachedThreadPool() - val bindings: Map Any>> = mapOf( - Pair(HttpMethod.GET, mutableMapOf()), - Pair(HttpMethod.POST, mutableMapOf()), - Pair(HttpMethod.PUT, mutableMapOf()), - Pair(HttpMethod.DELETE, mutableMapOf()), - Pair(HttpMethod.OPTIONS, mutableMapOf()), - Pair(HttpMethod.CONNECT, mutableMapOf()), - Pair(HttpMethod.HEAD, mutableMapOf()), - Pair(HttpMethod.TRACE, mutableMapOf()) - ) - - val filtersBefore: MutableMap Any> = mutableMapOf() - val filtersAfter: MutableMap Any> = mutableMapOf() - val exceptions: MutableMap, (HttpRequest, HttpResponse) -> Any> = mutableMapOf() - - fun start(port: Int = this.port, secure: Boolean = false, keyStoreFile: String = "", password: String = "") { - bindFilters() - threadPool.submit { - println("Server start on port $port") - try { - val socket = if (secure) secureSocket(port, keyStoreFile, password) else ServerSocket(port) - while (true) { - threadPool.submit(ClientThread(socket.accept()) { matchRequest(it) }) - } - } catch (e: IOException) { - println(e.message) - exitProcess(1) - } - } - } - - private fun bindFilters() { - for (binding in bindings) { - val iterator = binding.value.iterator() - while (iterator.hasNext()) { - var (key, action) = iterator.next() - for ((path, before) in filtersBefore) { - if (key == path || key.matches(path.toRegex())) { - action = chain(before, action) - binding.value[key] = action - } - } - for ((path, after) in filtersAfter) { - if (key == path || key.matches(path.toRegex())) { - action = chain(action, after) - binding.value[key] = action - } - } - val actionRef = action - if (exceptions.isNotEmpty()) { - action = { req: HttpRequest, res: HttpResponse -> - try { - actionRef(req, res) - } catch (e: RuntimeException) { - if (exceptions.contains(e.javaClass)) { - exceptions[e.javaClass]!!.invoke(req, res) - } else { - res.send(e.message ?: "Error", Status.InternalServerError) - } - } - } - binding.value[key] = action - } - } - } - } - - private fun chain(a: (HttpRequest, HttpResponse) -> Any, b: (HttpRequest, HttpResponse) -> Any): (HttpRequest, HttpResponse) -> Any { - return { req: HttpRequest, res: HttpResponse -> - a.invoke(req, res) - b.invoke(req, res) - } - } - - private fun secureSocket(port: Int, keyStoreFile: String, password: String): ServerSocket { - - /* Create keystore */ - val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) - keyStore.load(FileInputStream(keyStoreFile), password.toCharArray()) - - /* Get factory for the given keystore */ - TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply { init(keyStore) } - val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { init(keyStore, password.toCharArray()) } - return SSLContext.getInstance("SSL").apply { init(kmf.keyManagers, null, null) } - .serverSocketFactory.createServerSocket(port) - - } - - private fun matchRequest(request: HttpRequest): (HttpRequest, HttpResponse) -> Any { - return bindings[request.method]!!.let { routes -> - routes.getOrElse(request.url, { - routes.filter { - it.key.toRegex().matches(request.url) - }.values.firstOrElse { - { _: HttpRequest, res: HttpResponse -> res.send("Resource not found", Status.NotFound) } - } - } - ) - } - } - - fun bind(method: HttpMethod, path: String, call: (request: HttpRequest, response: HttpResponse) -> Any) { - bindings[method]?.put(path, call) - } - - fun get(path: String, call: (request: HttpRequest, response: HttpResponse) -> Any) { - bind(HttpMethod.GET, path, call) - } - - fun post(path: String, call: (request: HttpRequest, response: HttpResponse) -> Any) { - bind(HttpMethod.POST, path, call) - } - - fun put(path: String, call: (request: HttpRequest, response: HttpResponse) -> Any) { - bind(HttpMethod.PUT, path, call) - } - - fun delete(path: String, call: (request: HttpRequest, response: HttpResponse) -> Any) { - bind(HttpMethod.DELETE, path, call) - } - - fun before(path: String, call: (request: HttpRequest, response: HttpResponse) -> Any) { - filtersBefore[path] = call - } - - fun before(call: (request: HttpRequest, response: HttpResponse) -> Any) { - filtersBefore[".*"] = call - } - - fun after(path: String, call: (request: HttpRequest, response: HttpResponse) -> Any) { - filtersAfter[path] = call - } - - fun after(call: (request: HttpRequest, response: HttpResponse) -> Any) { - filtersAfter[".*"] = call - } - - fun exception(klass: KClass, call: (request: HttpRequest, response: HttpResponse) -> Any) { - exceptions[klass.java] = call - } - - fun staticFiles(path: String) { - val fullPath = javaClass.getResource(path).file - File(fullPath) - .walkTopDown() - .forEach { - if (!it.isDirectory) { - val file = it.path.substring(fullPath.length - 1) - val call: (HttpRequest, HttpResponse) -> Unit = { _, res -> it.inputStream().copyTo(res.stream).let { } } - get(file, call) - if (file == "/index.html" || file == "/index.htm") { - get("/", call) - } - } - } - } -} - -fun Iterable.firstOrElse(eval: () -> T): T { - return this.firstOrNull() ?: return eval.invoke() -} - diff --git a/src/nativeMain/kotlin/ServerNative.kt b/src/nativeMain/kotlin/ServerNative.kt new file mode 100644 index 0000000..d7ce88b --- /dev/null +++ b/src/nativeMain/kotlin/ServerNative.kt @@ -0,0 +1,21 @@ +import com.github.gimlet2.kottpd.Server +import com.github.gimlet2.kottpd.Status + + +///*** Example: +fun main() { + Server().apply { +// staticFiles("/public") + get("/hello") { _, res -> res.send("Hello") } + get("/test") { _, _ -> throw IllegalStateException("AAA") } + get("/do/.*/smth") { _, res -> res.send("Hello world") } + post("/data") { req, res -> res.send(req.content, Status.Created) } + before("/hello") { _, res -> res.send("before\n") } + before { _, res -> res.send("ALL before\n") } + after("/hello") { _, res -> res.send("\nafter\n") } + after { _, res -> res.send("ALL after\n") } +// exception(IllegalStateException::class) { _, _ -> "Illegal State" } + }.start() +// server.start(9443, true, "./keystore.jks", "password") +} + diff --git a/src/nativeMain/kotlin/com/github/gimlet2/kottpd/Reader.kt b/src/nativeMain/kotlin/com/github/gimlet2/kottpd/Reader.kt new file mode 100644 index 0000000..e2bf5de --- /dev/null +++ b/src/nativeMain/kotlin/com/github/gimlet2/kottpd/Reader.kt @@ -0,0 +1,63 @@ +package com.github.gimlet2.kottpd + +import kotlinx.cinterop.* +import platform.posix.read + +/** + * Created by Andrei Chernyshev on 1/7/22. + */ +actual class Reader(private val source: Int) { + + actual fun lines(): Sequence { + return LinesSequence(this).constrainOnce() + } + + @OptIn(ExperimentalForeignApi::class) + actual fun read(): Int { + memScoped { + val bufferLength = 1uL + val buffer = allocArray(bufferLength.toInt()) + read(source, buffer, bufferLength) + val kString: String = buffer.toKString() + if (kString.isEmpty()) return 0 + return kString[0].code + } + } + + actual fun line(): String { + var result = "" + var s = read() + while (s != 13) { + result += s.toChar() + s = read() + } + read() + return result + } +} + +private class LinesSequence(private val reader: Reader) : Sequence { + override fun iterator(): Iterator { + return object : Iterator { + private var nextValue: String? = null + private var done = false + + override fun hasNext(): Boolean { + if (nextValue == null && !done) { + nextValue = reader.line() + if (nextValue == null) done = true + } + return nextValue != null + } + + override fun next(): String { + if (!hasNext()) { + throw Error() + } + val answer = nextValue + nextValue = null + return answer!! + } + } + } +} \ No newline at end of file diff --git a/src/nativeMain/kotlin/com/github/gimlet2/kottpd/ServerSocket.kt b/src/nativeMain/kotlin/com/github/gimlet2/kottpd/ServerSocket.kt new file mode 100644 index 0000000..a16801a --- /dev/null +++ b/src/nativeMain/kotlin/com/github/gimlet2/kottpd/ServerSocket.kt @@ -0,0 +1,40 @@ +package com.github.gimlet2.kottpd + +import kotlinx.cinterop.* +import platform.posix.* + +/** + * Created by Andrei Chernyshev on 1/14/22. + */ +actual class ServerSocket actual constructor(port: Int, secure: Boolean, keyStoreFile: String, password: String) { + + @OptIn(ExperimentalForeignApi::class) + val serverSocket = memScoped { + val serverAddr = alloc() + + val result = socket(AF_INET, SOCK_STREAM, 0) + + with(serverAddr) { + memset(this.ptr, 0, sizeOf().toULong()) + sin_family = AF_INET.toUShort() + sin_addr.s_addr = htons(0u).toUInt() + sin_port = htons(port.toUShort()) + } + + bind(result, serverAddr.ptr.reinterpret(), sizeOf().toUInt()) + + listen(result, 100) + result + } + + + @OptIn(ExperimentalForeignApi::class) + actual fun accept(): Socket { + val accept: Int = accept(serverSocket, null, null) + return Socket(Reader(accept), Writer(accept)) { close(accept) } + } + + actual fun close() { + close(serverSocket) + } +} \ No newline at end of file diff --git a/src/nativeMain/kotlin/com/github/gimlet2/kottpd/Writer.kt b/src/nativeMain/kotlin/com/github/gimlet2/kottpd/Writer.kt new file mode 100644 index 0000000..edc8267 --- /dev/null +++ b/src/nativeMain/kotlin/com/github/gimlet2/kottpd/Writer.kt @@ -0,0 +1,29 @@ +package com.github.gimlet2.kottpd + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.cstr +import kotlinx.cinterop.memScoped +import platform.posix.fsync +import platform.posix.write + +actual class Writer(private val source: Int) { + actual fun println(str: String) { + if (str.isEmpty()) { + print("\n") + } else { + print(str + "\n") + } + } + + @OptIn(ExperimentalForeignApi::class) + actual fun print(str: String) { + memScoped { + write(source, str.cstr.getPointer(this), str.length.toULong()) + } + } + + actual fun flush() { + fsync(source) + } + +} \ No newline at end of file