├── gradle.properties ├── src ├── main │ └── kotlin │ │ └── eu │ │ └── darken │ │ └── octi │ │ └── kserver │ │ ├── common │ │ ├── StorageExtensions.kt │ │ ├── debug │ │ │ └── logging │ │ │ │ ├── LogExtensions.kt │ │ │ │ ├── ConsoleLogger.kt │ │ │ │ └── Logging.kt │ │ ├── KeyTool.kt │ │ ├── AppScope.kt │ │ ├── PayloadLimiter.kt │ │ ├── CallLogging.kt │ │ ├── serialization │ │ │ ├── UUIDSerializer.kt │ │ │ ├── InstantSerializer.kt │ │ │ └── SerializationModule.kt │ │ ├── HttpExtensions.kt │ │ └── RateLimiter.kt │ │ ├── device │ │ ├── ResetRequest.kt │ │ ├── DevicesResponse.kt │ │ ├── DeviceCredentials.kt │ │ ├── Device.kt │ │ ├── DeviceRoute.kt │ │ └── DeviceRepo.kt │ │ ├── account │ │ ├── share │ │ │ ├── ShareResponse.kt │ │ │ ├── Share.kt │ │ │ ├── ShareRoute.kt │ │ │ └── ShareRepo.kt │ │ ├── RegisterResponse.kt │ │ ├── Account.kt │ │ ├── AccountRoute.kt │ │ └── AccountRepo.kt │ │ ├── status │ │ └── StatusRoute.kt │ │ ├── AppComponent.kt │ │ ├── module │ │ ├── Module.kt │ │ ├── ModuleRoute.kt │ │ └── ModuleRepo.kt │ │ ├── Server.kt │ │ └── App.kt └── test │ └── kotlin │ └── eu │ └── darken │ └── octi │ ├── kserver │ ├── status │ │ └── StatusFlowTest.kt │ ├── AppTest.kt │ ├── module │ │ ├── ModuleRepoTest.kt │ │ └── ModuleFlowTest.kt │ ├── device │ │ ├── DeviceRepoTest.kt │ │ └── DeviceFlowTest.kt │ ├── account │ │ ├── AccountFlowTest.kt │ │ ├── ShareRepoTest.kt │ │ ├── AccountRepoTest.kt │ │ └── AccountShareFlowTest.kt │ └── common │ │ └── RateLimiterTest.kt │ ├── TestRunner.kt │ └── TestRunnerExtensions.kt ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .github ├── FUNDING.yml ├── release.yml ├── workflows │ ├── gradle-wrapper-validation.yml │ ├── code-checks.yml │ └── release-tag.yml └── actions │ └── common-setup │ └── action.yml ├── .idea ├── kotlinc.xml ├── .gitignore ├── codeInsightSettings.xml ├── misc.xml ├── runConfigurations │ └── App___TestData___Debug.xml ├── gradle.xml └── inspectionProfiles │ └── Project_Default.xml ├── settings.gradle.kts ├── .gitignore ├── docker-entrypoint.sh ├── README.md ├── Dockerfile ├── gradlew.bat └── gradlew /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/common/StorageExtensions.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.common 2 | 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d4rken/octi-sync-server-kotlin/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | github: 3 | - d4rken 4 | custom: 5 | - "https://www.buymeacoffee.com/tydarken" -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists -------------------------------------------------------------------------------- /.idea/codeInsightSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | sun.awt 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/device/ResetRequest.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.device 2 | 3 | import kotlinx.serialization.Contextual 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class ResetRequest( 8 | val targets: Set<@Contextual DeviceId>, 9 | ) -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | mavenCentral() 4 | gradlePluginPortal() 5 | } 6 | } 7 | 8 | plugins { 9 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" 10 | } 11 | 12 | rootProject.name = "octi-sync-server-kotlin" -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - changelog-ignore 5 | categories: 6 | - title: ":rocket: Enhancements" 7 | labels: 8 | - enhancement 9 | - title: ":lady_beetle: Bug fixes" 10 | labels: 11 | - bug 12 | - title: ":shrug: Other changes" 13 | labels: 14 | - "*" -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/common/debug/logging/LogExtensions.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.common.debug.logging 2 | 3 | fun logTag(vararg tags: String): String { 4 | val sb = StringBuilder("\uD83D\uDC19:") 5 | for (i in tags.indices) { 6 | sb.append(tags[i]) 7 | if (i < tags.size - 1) sb.append(":") 8 | } 9 | return sb.toString() 10 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/account/share/ShareResponse.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.account.share 2 | 3 | import eu.darken.octi.kserver.common.generateRandomKey 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class ShareResponse( 9 | @SerialName("code") val code: ShareCode = generateRandomKey(), 10 | ) -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/common/KeyTool.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.common 2 | 3 | import java.security.SecureRandom 4 | 5 | 6 | @OptIn(ExperimentalStdlibApi::class) 7 | fun generateRandomKey(length: Int = 64): String { 8 | val random = SecureRandom() 9 | val keyBytes = ByteArray(length) 10 | random.nextBytes(keyBytes) 11 | return keyBytes.toHexString() 12 | } -------------------------------------------------------------------------------- /.github/workflows/gradle-wrapper-validation.yml: -------------------------------------------------------------------------------- 1 | name: "Validate Gradle Wrapper" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | validation: 13 | name: "Validation" 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: gradle/wrapper-validation-action@v1 18 | -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/account/RegisterResponse.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.account 2 | 3 | import kotlinx.serialization.Contextual 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class RegisterResponse( 9 | @Contextual @SerialName("account") val accountID: AccountId, 10 | @SerialName("password") val password: String 11 | ) -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/common/AppScope.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.common 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.SupervisorJob 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | import kotlin.coroutines.CoroutineContext 9 | 10 | @Singleton 11 | class AppScope @Inject constructor() : CoroutineScope { 12 | override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Default 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/common/PayloadLimiter.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.common 2 | 3 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.INFO 4 | import eu.darken.octi.kserver.common.debug.logging.log 5 | import io.ktor.server.application.* 6 | import io.ktor.server.plugins.bodylimit.* 7 | 8 | fun Application.installPayloadLimit(limit: Long) { 9 | log(INFO) { "Payload limit is set to $limit" } 10 | install(RequestBodyLimit) { 11 | bodyLimit { limit } 12 | } 13 | } -------------------------------------------------------------------------------- /src/test/kotlin/eu/darken/octi/kserver/status/StatusFlowTest.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.status 2 | 3 | import eu.darken.octi.TestRunner 4 | import io.kotest.matchers.shouldBe 5 | import io.ktor.client.request.* 6 | import io.ktor.http.* 7 | import org.junit.jupiter.api.Test 8 | 9 | class StatusFlowTest : TestRunner() { 10 | 11 | @Test 12 | fun `get status`() = runTest2 { 13 | http.get("/v1/status") { 14 | 15 | }.apply { 16 | status shouldBe HttpStatusCode.OK 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/device/DevicesResponse.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.device 2 | 3 | import kotlinx.serialization.Contextual 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class DevicesResponse( 9 | @SerialName("devices") val devices: List, 10 | ) { 11 | @Serializable 12 | data class Device( 13 | @Contextual @SerialName("id") val id: DeviceId, 14 | @SerialName("version") val version: String?, 15 | ) 16 | } -------------------------------------------------------------------------------- /.idea/runConfigurations/App___TestData___Debug.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/status/StatusRoute.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.status 2 | 3 | import io.ktor.http.* 4 | import io.ktor.server.response.* 5 | import io.ktor.server.routing.* 6 | import javax.inject.Inject 7 | 8 | class StatusRoute @Inject constructor( 9 | 10 | ) { 11 | 12 | fun setup(rootRoute: Routing) { 13 | rootRoute.get("/v1/status") { 14 | call.respondText( 15 | "Status: ${call.application.developmentMode}", 16 | ContentType.Application.Json 17 | ) 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 16 | -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/AppComponent.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver 2 | 3 | import dagger.BindsInstance 4 | import dagger.Component 5 | import eu.darken.octi.kserver.common.serialization.SerializationModule 6 | import javax.inject.Singleton 7 | 8 | @Singleton 9 | @Component( 10 | modules = [ 11 | SerializationModule::class 12 | ] 13 | ) 14 | interface AppComponent { 15 | fun application(): App 16 | 17 | @Component.Builder 18 | interface Builder { 19 | @BindsInstance 20 | fun config(config: App.Config): Builder 21 | 22 | fun build(): AppComponent 23 | } 24 | } -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | -------------------------------------------------------------------------------- /src/test/kotlin/eu/darken/octi/kserver/AppTest.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver 2 | 3 | import eu.darken.octi.TestRunner 4 | import io.kotest.matchers.shouldBe 5 | import org.junit.jupiter.api.Test 6 | import java.time.Duration 7 | import kotlin.io.path.Path 8 | 9 | class AppTest : TestRunner() { 10 | 11 | @Test 12 | fun `sane config values`() { 13 | App.Config( 14 | dataPath = Path("./build/tmp/testdatapath"), 15 | port = 8080, 16 | ).apply { 17 | shareExpiration shouldBe Duration.ofMinutes(60) 18 | deviceExpiration shouldBe Duration.ofDays(90) 19 | moduleExpiration shouldBe Duration.ofDays(90) 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/main/**/build/ 5 | !**/src/test/**/build/ 6 | 7 | ### IntelliJ IDEA ### 8 | .idea/modules.xml 9 | .idea/jarRepositories.xml 10 | .idea/compiler.xml 11 | .idea/vcs.xml 12 | .idea/uiDesigner.xml 13 | .idea/libraries/ 14 | *.iws 15 | *.iml 16 | *.ipr 17 | out/ 18 | !**/src/main/**/out/ 19 | !**/src/test/**/out/ 20 | 21 | ### Eclipse ### 22 | .apt_generated 23 | .classpath 24 | .factorypath 25 | .project 26 | .settings 27 | .springBeans 28 | .sts4-cache 29 | bin/ 30 | !**/src/main/**/bin/ 31 | !**/src/test/**/bin/ 32 | 33 | ### NetBeans ### 34 | /nbproject/private/ 35 | /nbbuild/ 36 | /dist/ 37 | /nbdist/ 38 | /.nb-gradle/ 39 | 40 | ### VS Code ### 41 | .vscode/ 42 | 43 | ### Mac OS ### 44 | .DS_Store 45 | 46 | zdatapath* -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/common/CallLogging.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.common 2 | 3 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.VERBOSE 4 | import eu.darken.octi.kserver.common.debug.logging.log 5 | import io.ktor.server.application.* 6 | import io.ktor.server.application.ApplicationCallPipeline.ApplicationPhase.Plugins 7 | import io.ktor.server.request.* 8 | 9 | fun Application.installCallLogging() { 10 | intercept(Plugins) { 11 | val method = call.request.httpMethod.value 12 | val uri = call.request.uri 13 | val userAgent = call.request.userAgent() ?: "Unknown" 14 | val ip = call.request.header("X-Forwarded-For") ?: call.request.local.remoteHost 15 | log("HTTP", VERBOSE) { "$ip($userAgent): $method - $uri" } 16 | } 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/common/serialization/UUIDSerializer.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.common.serialization 2 | 3 | import kotlinx.serialization.KSerializer 4 | import kotlinx.serialization.descriptors.PrimitiveKind 5 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 6 | import kotlinx.serialization.descriptors.SerialDescriptor 7 | import kotlinx.serialization.encoding.Decoder 8 | import kotlinx.serialization.encoding.Encoder 9 | import java.util.* 10 | 11 | object UUIDSerializer : KSerializer { 12 | override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OctiUUID", PrimitiveKind.STRING) 13 | 14 | override fun serialize(encoder: Encoder, value: UUID) = encoder.encodeString(value.toString()) 15 | 16 | override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString()) 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/common/serialization/InstantSerializer.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.common.serialization 2 | 3 | import kotlinx.serialization.KSerializer 4 | import kotlinx.serialization.descriptors.PrimitiveKind 5 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 6 | import kotlinx.serialization.descriptors.SerialDescriptor 7 | import kotlinx.serialization.encoding.Decoder 8 | import kotlinx.serialization.encoding.Encoder 9 | import java.time.Instant 10 | 11 | object InstantSerializer : KSerializer { 12 | override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING) 13 | 14 | override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString()) 15 | 16 | override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString()) 17 | } -------------------------------------------------------------------------------- /.github/workflows/code-checks.yml: -------------------------------------------------------------------------------- 1 | name: Code tests & eval 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build-modules: 11 | name: Build apps 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout source code 15 | uses: actions/checkout@v4 16 | - name: Setup project and build environment 17 | uses: ./.github/actions/common-setup 18 | - name: Build modules 19 | run: ./gradlew assemble 20 | 21 | run-tests: 22 | name: Run tests 23 | runs-on: ubuntu-latest 24 | container: ubuntu 25 | steps: 26 | - name: Checkout source code 27 | uses: actions/checkout@v4 28 | - name: Setup project and build environment 29 | uses: ./.github/actions/common-setup 30 | - name: Check code and run tests 31 | run: ./gradlew check -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/account/Account.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.account 2 | 3 | import kotlinx.coroutines.sync.Mutex 4 | import kotlinx.serialization.Contextual 5 | import kotlinx.serialization.Serializable 6 | import java.nio.file.Path 7 | import java.time.Instant 8 | import java.util.* 9 | 10 | data class Account( 11 | val data: Data, 12 | val path: Path, 13 | val sync: Mutex = Mutex(), 14 | ) { 15 | val id: AccountId 16 | get() = data.id 17 | 18 | val createdAt: Instant 19 | get() = data.createdAt 20 | 21 | @Serializable 22 | data class Data( 23 | @Contextual val id: AccountId = UUID.randomUUID(), 24 | @Contextual val createdAt: Instant = Instant.now(), 25 | ) { 26 | override fun toString(): String = "Account.Data(created=$createdAt, $id)" 27 | } 28 | } 29 | 30 | typealias AccountId = UUID -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/common/serialization/SerializationModule.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.common.serialization 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import kotlinx.serialization.json.Json 6 | import kotlinx.serialization.modules.SerializersModule 7 | import java.time.Instant 8 | import java.util.* 9 | import javax.inject.Singleton 10 | 11 | @Module 12 | object SerializationModule { 13 | @Provides 14 | @Singleton 15 | fun provideJson(serializersModule: SerializersModule): Json = Json { 16 | ignoreUnknownKeys = true 17 | prettyPrint = true 18 | this.serializersModule = serializersModule 19 | } 20 | 21 | @Provides 22 | @Singleton 23 | fun provideSerializerModule(): SerializersModule = SerializersModule { 24 | contextual(Instant::class, InstantSerializer) 25 | contextual(UUID::class, UUIDSerializer) 26 | } 27 | } -------------------------------------------------------------------------------- /src/test/kotlin/eu/darken/octi/kserver/module/ModuleRepoTest.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.module 2 | 3 | import eu.darken.octi.TestRunner 4 | import eu.darken.octi.createDevice 5 | import eu.darken.octi.readModule 6 | import eu.darken.octi.writeModule 7 | import io.kotest.matchers.shouldBe 8 | import org.junit.jupiter.api.Test 9 | import java.time.Duration 10 | 11 | class ModuleRepoTest : TestRunner() { 12 | 13 | @Test 14 | fun `module data can expire`() = runTest2( 15 | appConfig = baseConfig.copy( 16 | moduleExpiration = Duration.ofSeconds(2), 17 | moduleGCInterval = Duration.ofSeconds(1), 18 | ), 19 | ) { 20 | val creds = createDevice() 21 | writeModule(creds, "abc", data = "test") 22 | readModule(creds, "abc") shouldBe "test" 23 | Thread.sleep(config.moduleExpiration.toMillis() + 1000) 24 | readModule(creds, "abc") shouldBe "" 25 | } 26 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/module/Module.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.module 2 | 3 | import eu.darken.octi.kserver.device.DeviceId 4 | import kotlinx.serialization.Contextual 5 | import kotlinx.serialization.SerialName 6 | import kotlinx.serialization.Serializable 7 | import java.time.Instant 8 | 9 | interface Module { 10 | 11 | @Serializable 12 | data class Info( 13 | @Contextual @SerialName("id") val id: ModuleId, 14 | @Contextual @SerialName("source") val source: DeviceId, 15 | ) 16 | 17 | data class Read( 18 | val modifiedAt: Instant? = null, 19 | val payload: ByteArray = ByteArray(0), 20 | ) { 21 | val size: Int 22 | get() = payload.size 23 | } 24 | 25 | data class Write( 26 | val payload: ByteArray, 27 | ) { 28 | val size: Int 29 | get() = payload.size 30 | } 31 | } 32 | 33 | typealias ModuleId = String 34 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Octi Sync Server Entrypoint Script 4 | # 5 | # Environment variables: 6 | # - OCTI_PORT: Server port (default: 8080) 7 | # - OCTI_DEBUG: Enable debug mode (default: false) 8 | # 9 | # Fixed configuration: 10 | # - Data Path: /etc/octi-sync-server 11 | # 12 | 13 | set -e 14 | 15 | # Process environment variables 16 | OCTI_PORT=${OCTI_PORT:-8080} 17 | OCTI_DEBUG=${OCTI_DEBUG:-false} 18 | 19 | # Validate port 20 | if ! [[ "$OCTI_PORT" =~ ^[0-9]+$ ]] || [ "$OCTI_PORT" -lt 1 ] || [ "$OCTI_PORT" -gt 65535 ]; then 21 | echo "Error: Invalid port number. Must be between 1-65535" 22 | exit 1 23 | fi 24 | 25 | # Build command arguments 26 | CMD_ARGS="--datapath=/etc/octi-sync-server --port=$OCTI_PORT" 27 | 28 | # Add debug flag if enabled 29 | if [ "$OCTI_DEBUG" = "true" ]; then 30 | CMD_ARGS="$CMD_ARGS --debug" 31 | fi 32 | 33 | # Execute the application 34 | exec ./bin/octi-sync-server-kotlin $CMD_ARGS 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Octi Sync Server 2 | 3 | [![Code tests & eval](https://img.shields.io/github/actions/workflow/status/d4rken/octi-sync-server-kotlin/code-checks.yml?logo=githubactions&label=Code%20tests)](https://github.com/d4rken/octi-sync-server-kotlin/actions) 4 | 5 | This is a synchronization server for [Octi](https://github.com/d4rken-org/octi) 6 | 7 | ## Setup 8 | 9 | ### Build server 10 | 11 | ```bash 12 | ./gradlew clean installDist 13 | ``` 14 | 15 | The binaries you can copy to a server will be placed under `./build/install/octi-sync-server-kotlin`. 16 | More details [here](https://ktor.io/docs/server-packaging.html). 17 | 18 | ### Run server 19 | 20 | ```bash 21 | ./build/install/octi-sync-server-kotlin/bin/octi-sync-server-kotlin --datapath=./octi-data 22 | ``` 23 | 24 | The following flags are available: 25 | 26 | * `--datapath` (required) where the server should store its data 27 | * `--debug` to enable additional log output 28 | * `--port` to change the default port (8080) 29 | -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/account/share/Share.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.account.share 2 | 3 | import eu.darken.octi.kserver.account.AccountId 4 | import eu.darken.octi.kserver.common.generateRandomKey 5 | import kotlinx.serialization.Contextual 6 | import kotlinx.serialization.Serializable 7 | import java.nio.file.Path 8 | import java.time.Instant 9 | import java.util.* 10 | 11 | 12 | data class Share( 13 | val data: Data, 14 | val accountId: AccountId, 15 | val path: Path, 16 | ) { 17 | 18 | val id: ShareId 19 | get() = data.id 20 | 21 | val code: ShareCode 22 | get() = data.code 23 | 24 | val createdAt: Instant 25 | get() = data.createdAt 26 | 27 | @Serializable 28 | data class Data( 29 | @Contextual val createdAt: Instant = Instant.now(), 30 | @Contextual val id: ShareId = UUID.randomUUID(), 31 | val code: ShareCode = generateRandomKey(), 32 | ) { 33 | override fun toString(): String = "Share.Data($createdAt, $id, ${code.take(16)})" 34 | } 35 | } 36 | 37 | 38 | typealias ShareCode = String 39 | typealias ShareId = UUID -------------------------------------------------------------------------------- /.github/actions/common-setup/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Common project setup' 2 | description: 'Setup the base project' 3 | runs: 4 | using: "composite" 5 | steps: 6 | - name: Set up JDK 17 7 | uses: actions/setup-java@v3 8 | with: 9 | java-version: '17' 10 | distribution: 'adopt' 11 | 12 | - name: Grant execute permission for gradlew 13 | shell: bash 14 | run: chmod +x gradlew 15 | 16 | - name: Cache Gradle Wrapper 17 | uses: actions/cache@v3 18 | with: 19 | path: | 20 | ~/.gradle/wrapper 21 | !~/.gradle/wrapper/dists/**/gradle*.zip 22 | key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle-wrapper.properties') }} 23 | restore-keys: | 24 | ${{ runner.os }}-gradle-wrapper- 25 | 26 | - name: Cache Gradle Dependencies 27 | uses: actions/cache@v3 28 | with: 29 | path: | 30 | ~/.gradle/caches 31 | key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/*.kt') }} 32 | restore-keys: | 33 | ${{ runner.os }}-gradle-caches- 34 | -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/device/DeviceCredentials.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.device 2 | 3 | import eu.darken.octi.kserver.account.AccountId 4 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.WARN 5 | import eu.darken.octi.kserver.common.debug.logging.log 6 | import io.ktor.server.routing.* 7 | import java.nio.charset.StandardCharsets 8 | import java.util.* 9 | 10 | data class DeviceCredentials( 11 | val accountId: AccountId, 12 | val devicePassword: String, 13 | ) 14 | 15 | 16 | val RoutingContext.deviceCredentials: DeviceCredentials? 17 | get() { 18 | val authHeader = call.request.headers["Authorization"] ?: return null 19 | 20 | if (!authHeader.startsWith("Basic ")) { 21 | log(WARN) { "Invalid Authorization header: $authHeader" } 22 | return null 23 | } 24 | 25 | val base64Credentials = authHeader.removePrefix("Basic ") 26 | val credentials = Base64.getDecoder().decode(base64Credentials).toString(StandardCharsets.UTF_8) 27 | val (username, password) = credentials.split(":", limit = 2) 28 | 29 | return DeviceCredentials( 30 | accountId = UUID.fromString(username), 31 | devicePassword = password, 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/test/kotlin/eu/darken/octi/kserver/device/DeviceRepoTest.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.device 2 | 3 | import eu.darken.octi.TestRunner 4 | import eu.darken.octi.createDevice 5 | import eu.darken.octi.getDevices 6 | import eu.darken.octi.getDevicesRaw 7 | import io.kotest.matchers.shouldBe 8 | import io.kotest.matchers.shouldNotBe 9 | import io.kotest.matchers.string.shouldStartWith 10 | import io.ktor.client.statement.* 11 | import io.ktor.http.* 12 | import org.junit.jupiter.api.Test 13 | import java.time.Duration 14 | 15 | class DeviceRepoTest : TestRunner() { 16 | 17 | @Test 18 | fun `old devices are deleted`() = runTest2( 19 | appConfig = baseConfig.copy( 20 | deviceExpiration = Duration.ofSeconds(2), 21 | deviceGCInterval = Duration.ofSeconds(1), 22 | ), 23 | ) { 24 | val creds1 = createDevice() 25 | getDevices(creds1) shouldNotBe null 26 | Thread.sleep(config.deviceExpiration.toMillis() - 100) 27 | getDevices(creds1) shouldNotBe null 28 | Thread.sleep(config.deviceExpiration.toMillis() + 1000) 29 | getDevicesRaw(creds1).apply { 30 | status shouldBe HttpStatusCode.NotFound 31 | bodyAsText() shouldStartWith "Unknown device" 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/device/Device.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.device 2 | 3 | import eu.darken.octi.kserver.account.AccountId 4 | import eu.darken.octi.kserver.common.generateRandomKey 5 | import kotlinx.coroutines.sync.Mutex 6 | import kotlinx.serialization.Contextual 7 | import kotlinx.serialization.Serializable 8 | import java.nio.file.Path 9 | import java.time.Instant 10 | import java.util.* 11 | 12 | 13 | data class Device( 14 | val data: Data, 15 | val path: Path, 16 | val accountId: AccountId, 17 | val sync: Mutex = Mutex(), 18 | ) { 19 | fun isAuthorized(credentials: DeviceCredentials): Boolean { 20 | return accountId == credentials.accountId && data.password == credentials.devicePassword 21 | } 22 | 23 | val id: DeviceId 24 | get() = data.id 25 | 26 | val password: String 27 | get() = data.password 28 | 29 | val version: String? 30 | get() = data.version 31 | 32 | val lastSeen: Instant 33 | get() = data.lastSeen 34 | 35 | @Serializable 36 | data class Data( 37 | @Contextual val id: DeviceId, 38 | val password: String = generateRandomKey(), 39 | val version: String?, 40 | @Contextual val addedAt: Instant = Instant.now(), 41 | @Contextual val lastSeen: Instant = Instant.now(), 42 | ) { 43 | override fun toString(): String = "Device.Data(added=$addedAt, seen=$lastSeen, $id, ${password.take(8)}...)" 44 | } 45 | } 46 | 47 | typealias DeviceId = UUID 48 | -------------------------------------------------------------------------------- /.github/workflows/release-tag.yml: -------------------------------------------------------------------------------- 1 | name: Tagged releases 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release-github: 10 | name: Create GitHub release 11 | permissions: 12 | contents: write 13 | runs-on: ubuntu-latest 14 | environment: foss-production 15 | steps: 16 | - name: Checkout source code 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Setup project and build environment 22 | uses: ./.github/actions/common-setup 23 | 24 | - name: Get the version 25 | id: tagger 26 | uses: jimschubert/query-tag-action@v2 27 | with: 28 | skip-unshallow: 'true' 29 | abbrev: false 30 | commit-ish: HEAD 31 | 32 | - name: Package application 33 | run: ./gradlew installDist 34 | 35 | - name: Create ZIP file from the directory 36 | run: zip -r octi-sync-server.zip ./build/install/octi-sync-server-kotlin 37 | 38 | - name: Create pre-release 39 | if: contains(steps.tagger.outputs.tag, '-beta') 40 | uses: softprops/action-gh-release@v1 41 | with: 42 | prerelease: true 43 | tag_name: ${{ steps.tagger.outputs.tag }} 44 | name: ${{ steps.tagger.outputs.tag }} 45 | generate_release_notes: true 46 | files: octi-sync-server.zip 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | 50 | - name: Create release 51 | if: "!contains(steps.tagger.outputs.tag, '-beta')" 52 | uses: softprops/action-gh-release@v1 53 | with: 54 | prerelease: false 55 | tag_name: ${{ steps.tagger.outputs.tag }} 56 | name: ${{ steps.tagger.outputs.tag }} 57 | generate_release_notes: true 58 | files: octi-sync-server.zip 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gradle:9.0 AS builder 2 | # ^ 2 Critical, 4 High, 23 Medium, 4 Low vulnerabilities (04.12.2025) 3 | # gradle 8.9, 8.10, 8.11, 8.12, 8.13 and 9.0 work 4 | # gradle 8.14 does nothing(doesn't build at all, no binary, no logs) 5 | # gradle 9.1 Fatal Errors due to some incompatability with jdk25 6 | # gradle 9.x with jdk21 does nothing, much like 8.14 7 | WORKDIR /octi-sync-server 8 | 9 | # Copy Gradle wrapper files first for better caching 10 | COPY gradlew ./ 11 | COPY gradlew.bat ./ 12 | COPY gradle/ ./gradle/ 13 | 14 | # Convert line endings and make gradlew executable (fixes Windows CRLF issues) 15 | RUN sed -i 's/\r$//' ./gradlew && chmod +x ./gradlew 16 | 17 | # Copy build files for dependency resolution 18 | COPY build.gradle.kts ./ 19 | COPY settings.gradle.kts ./ 20 | COPY gradle.properties ./ 21 | 22 | # Download dependencies (cached layer) 23 | RUN ./gradlew dependencies --no-daemon 24 | 25 | # Copy source code 26 | COPY src/ ./src/ 27 | 28 | # Build the application 29 | RUN ./gradlew clean installDist --no-daemon 30 | 31 | FROM eclipse-temurin:24-jre 32 | # ^ 3 Medium, 2 Low vulnerabilities (04.12.2025) 33 | WORKDIR /octi-sync-server 34 | 35 | # Create non-root user for security (let system assign UID) 36 | RUN useradd -r -s /bin/bash -m octi-user 37 | 38 | # Copy built application 39 | COPY --from=builder /octi-sync-server/build/install/octi-sync-server-kotlin/ . 40 | 41 | # Copy entrypoint script 42 | COPY docker-entrypoint.sh . 43 | 44 | # Create data directory and set permissions 45 | RUN mkdir -p /etc/octi-sync-server && \ 46 | sed -i 's/\r$//' ./docker-entrypoint.sh && \ 47 | chown -R octi-user:octi-user /octi-sync-server /etc/octi-sync-server && \ 48 | chmod +x ./bin/octi-sync-server-kotlin && \ 49 | chmod +x ./docker-entrypoint.sh 50 | 51 | # Switch to non-root user 52 | USER octi-user 53 | 54 | # Expose the application port 55 | EXPOSE 8080 56 | 57 | # Declare volume for data persistence 58 | VOLUME ["/etc/octi-sync-server"] 59 | 60 | # Use the entrypoint script 61 | ENTRYPOINT ["./docker-entrypoint.sh"] 62 | -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/account/share/ShareRoute.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.account.share 2 | 3 | import eu.darken.octi.kserver.account.AccountRepo 4 | import eu.darken.octi.kserver.common.callInfo 5 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.ERROR 6 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.INFO 7 | import eu.darken.octi.kserver.common.debug.logging.asLog 8 | import eu.darken.octi.kserver.common.debug.logging.log 9 | import eu.darken.octi.kserver.common.debug.logging.logTag 10 | import eu.darken.octi.kserver.common.verifyCaller 11 | import eu.darken.octi.kserver.device.DeviceRepo 12 | import io.ktor.http.* 13 | import io.ktor.server.response.* 14 | import io.ktor.server.routing.* 15 | import javax.inject.Inject 16 | import javax.inject.Singleton 17 | 18 | @Singleton 19 | class ShareRoute @Inject constructor( 20 | private val accountRepo: AccountRepo, 21 | private val deviceRepo: DeviceRepo, 22 | private val shareRepo: ShareRepo, 23 | ) { 24 | 25 | fun setup(rootRoute: Routing) { 26 | rootRoute.post("/v1/account/share") { 27 | try { 28 | createShare() 29 | } catch (e: Exception) { 30 | log(TAG, ERROR) { "createShare failed: ${e.asLog()}" } 31 | call.respond(HttpStatusCode.InternalServerError, "Share code creation failed") 32 | } 33 | } 34 | } 35 | 36 | private suspend fun RoutingContext.createShare() { 37 | val callerDevice = verifyCaller(TAG, deviceRepo) ?: return 38 | 39 | val account = accountRepo.getAccount(callerDevice.accountId) 40 | ?: throw IllegalStateException("Account not found for $callerDevice") 41 | 42 | val share = shareRepo.createShare(account) 43 | val response = ShareResponse(code = share.code) 44 | call.respond(response).also { log(TAG, INFO) { "createShare($callInfo): Share created: $share" } } 45 | } 46 | 47 | companion object { 48 | private val TAG = logTag("Account", "Share", "Route") 49 | } 50 | } -------------------------------------------------------------------------------- /src/test/kotlin/eu/darken/octi/kserver/account/AccountFlowTest.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.account 2 | 3 | import eu.darken.octi.* 4 | import io.kotest.matchers.ints.shouldBeGreaterThan 5 | import io.kotest.matchers.shouldBe 6 | import io.kotest.matchers.shouldNotBe 7 | import io.ktor.client.request.* 8 | import io.ktor.client.statement.* 9 | import io.ktor.http.* 10 | import org.junit.jupiter.api.Test 11 | import java.util.* 12 | 13 | class AccountFlowTest : TestRunner() { 14 | 15 | private val endpoint = "/v1/account" 16 | 17 | @Test 18 | fun `creating a new account`() = runTest2 { 19 | createDeviceRaw().apply { 20 | status shouldBe HttpStatusCode.OK 21 | asAuth().apply { 22 | UUID.fromString(account) 23 | password.length shouldBeGreaterThan 64 24 | } 25 | } 26 | } 27 | 28 | @Test 29 | fun `create account requires device ID header`() = runTest2 { 30 | http.post(endpoint).apply { 31 | status shouldBe HttpStatusCode.BadRequest 32 | bodyAsText() shouldBe "X-Device-ID header is missing" 33 | } 34 | 35 | http.post(endpoint) { 36 | headers { 37 | append("X-Device-ID", "something") 38 | } 39 | }.apply { 40 | status shouldBe HttpStatusCode.BadRequest 41 | bodyAsText() shouldBe "X-Device-ID header is missing" 42 | } 43 | } 44 | 45 | @Test 46 | fun `no double creation`() = runTest2 { 47 | val deviceId = UUID.randomUUID() 48 | http.post(endpoint) { 49 | addDeviceId(deviceId) 50 | } 51 | http.post(endpoint) { 52 | addDeviceId(deviceId) 53 | }.apply { 54 | status shouldBe HttpStatusCode.BadRequest 55 | bodyAsText() shouldBe "Device is already registered" 56 | } 57 | } 58 | 59 | @Test 60 | fun `deleting an account`() = runTest2 { 61 | val creds1 = createDevice() 62 | 63 | http.delete(endpoint) { 64 | addCredentials(creds1) 65 | }.apply { 66 | status shouldBe HttpStatusCode.OK 67 | bodyAsText() shouldBe "" 68 | } 69 | 70 | val creds2 = createDevice(creds1.deviceId) 71 | 72 | creds1 shouldNotBe creds2 73 | } 74 | } -------------------------------------------------------------------------------- /src/test/kotlin/eu/darken/octi/kserver/account/ShareRepoTest.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.account 2 | 3 | import eu.darken.octi.* 4 | import io.kotest.matchers.shouldBe 5 | import io.kotest.matchers.shouldNotBe 6 | import io.kotest.matchers.string.shouldContain 7 | import io.ktor.client.statement.* 8 | import io.ktor.http.* 9 | import org.junit.jupiter.api.Test 10 | import java.time.Duration 11 | import kotlin.io.path.exists 12 | import kotlin.io.path.listDirectoryEntries 13 | import kotlin.io.path.readText 14 | 15 | class ShareRepoTest : TestRunner() { 16 | 17 | @Test 18 | fun `shares expire`() = runTest2( 19 | appConfig = baseConfig.copy( 20 | shareExpiration = Duration.ofSeconds(2), 21 | shareGCInterval = Duration.ofSeconds(2), 22 | ) 23 | ) { 24 | val creds1 = createDevice() 25 | val share1 = createShareCode(creds1) 26 | Thread.sleep(config.shareExpiration.toMillis() + 1000) 27 | createDeviceRaw(shareCode = share1).apply { 28 | status shouldBe HttpStatusCode.Forbidden 29 | bodyAsText() shouldBe "Invalid ShareCode" 30 | } 31 | val share2 = createShareCode(creds1) 32 | createDevice(shareCode = share2) shouldNotBe null 33 | } 34 | 35 | @Test 36 | fun `shares is saved to disk`() = runTest2 { 37 | val creds1 = createDevice() 38 | val shareCode = createShareCode(creds1) 39 | getSharesPath(creds1).apply { 40 | exists() shouldBe true 41 | listDirectoryEntries().first().readText() shouldContain shareCode 42 | } 43 | } 44 | 45 | @Test 46 | fun `shares are restored on reboot`() { 47 | var creds1: Credentials? = null 48 | var shareCode: String? = null 49 | runTest2(keepData = true) { 50 | creds1 = createDevice() 51 | shareCode = createShareCode(creds1!!) 52 | } 53 | runTest2 { 54 | val creds2 = createDevice(shareCode = shareCode!!) 55 | creds1!!.account shouldBe creds2.account 56 | } 57 | } 58 | 59 | @Test 60 | fun `shares consumption deletes the file`() = runTest2 { 61 | val creds1 = createDevice() 62 | val share1 = createShareCode(creds1) 63 | createDevice(shareCode = share1) 64 | getSharesPath(creds1).listDirectoryEntries().isEmpty() shouldBe true 65 | } 66 | } -------------------------------------------------------------------------------- /src/test/kotlin/eu/darken/octi/kserver/account/AccountRepoTest.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.account 2 | 3 | import eu.darken.octi.* 4 | import io.kotest.matchers.longs.shouldBeGreaterThan 5 | import io.kotest.matchers.shouldBe 6 | import io.kotest.matchers.shouldNotBe 7 | import io.kotest.matchers.string.shouldStartWith 8 | import io.ktor.client.statement.* 9 | import io.ktor.http.* 10 | import org.junit.jupiter.api.Test 11 | import kotlin.io.path.exists 12 | import kotlin.io.path.fileSize 13 | 14 | class AccountRepoTest : TestRunner() { 15 | 16 | @Test 17 | fun `creating a new account`() = runTest2 { 18 | val creds = createDevice() 19 | getAccountPath(creds).apply { 20 | exists() shouldBe true 21 | resolve("account.json").apply { 22 | exists() shouldBe true 23 | fileSize() shouldBeGreaterThan 64L 24 | } 25 | } 26 | } 27 | 28 | @Test 29 | fun `deleting an account`() = runTest2 { 30 | val creds1 = createDevice() 31 | val creds2 = createDevice() 32 | writeModule(creds2, "abc", data = "test") 33 | getAccountPath(creds1).exists() shouldBe true 34 | deleteAccount(creds1) 35 | getAccountPath(creds1).exists() shouldBe false 36 | readModule(creds2, "abc") shouldBe "test" 37 | getAccountPath(creds2).exists() shouldBe true 38 | } 39 | 40 | @Test 41 | fun `accounts are restored`() { 42 | var creds1: Credentials? = null 43 | var creds2: Credentials? = null 44 | runTest2(keepData = true) { 45 | creds1 = createDevice() 46 | creds2 = createDevice(creds1!!) 47 | getDevices(creds1!!) shouldBe TestDevices( 48 | setOf( 49 | TestDevices.Device(creds1!!.deviceId), 50 | TestDevices.Device(creds2!!.deviceId), 51 | ) 52 | ) 53 | } 54 | runTest2 { 55 | getDevices(creds1!!) shouldBe TestDevices( 56 | setOf( 57 | TestDevices.Device(creds1!!.deviceId), 58 | TestDevices.Device(creds2!!.deviceId), 59 | ) 60 | ) 61 | } 62 | } 63 | 64 | @Test 65 | fun `empty accounts are deleted`() = runTest2 { 66 | val creds1 = createDevice() 67 | getDevices(creds1) shouldNotBe null 68 | deleteDevice(creds1) 69 | getDevicesRaw(creds1).apply { 70 | status shouldBe HttpStatusCode.NotFound 71 | bodyAsText() shouldStartWith "Unknown device" 72 | } 73 | } 74 | 75 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/common/HttpExtensions.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.common 2 | 3 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.VERBOSE 4 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.WARN 5 | import eu.darken.octi.kserver.common.debug.logging.log 6 | import eu.darken.octi.kserver.device.Device 7 | import eu.darken.octi.kserver.device.DeviceId 8 | import eu.darken.octi.kserver.device.DeviceRepo 9 | import eu.darken.octi.kserver.device.deviceCredentials 10 | import io.ktor.http.* 11 | import io.ktor.server.plugins.* 12 | import io.ktor.server.request.* 13 | import io.ktor.server.response.* 14 | import io.ktor.server.routing.* 15 | import java.time.Instant 16 | import java.util.* 17 | 18 | val RoutingContext.callInfo: String 19 | get() { 20 | val ipAddress = call.request.origin.remoteHost 21 | val userAgent = call.request.headers["User-Agent"] 22 | return "$ipAddress ($userAgent)" 23 | } 24 | 25 | val RoutingCall.headerDeviceId: DeviceId? 26 | get() = request.header("X-Device-ID") 27 | ?.takeIf { it.isNotBlank() } 28 | ?.let { 29 | try { 30 | UUID.fromString(it) 31 | } catch (e: IllegalArgumentException) { 32 | log(WARN) { "Invalid device ID" } 33 | null 34 | } 35 | } 36 | 37 | suspend fun RoutingContext.verifyCaller(tag: String, deviceRepo: DeviceRepo): Device? { 38 | val deviceId = call.headerDeviceId 39 | log(tag, VERBOSE) { "verifyAuth($callInfo): deviceId=$deviceId" } 40 | 41 | if (deviceId == null) { 42 | log(tag, WARN) { "verifyAuth($callInfo): 400 Bad request, missing header ID" } 43 | call.respond(HttpStatusCode.BadRequest, "X-Device-ID header is missing") 44 | return null 45 | } 46 | 47 | val creds = deviceCredentials 48 | if (creds == null) { 49 | log(tag, WARN) { "verifyAuth($callInfo): deviceId=$deviceId credentials missing" } 50 | call.respond(HttpStatusCode.BadRequest, "Device credentials are missing") 51 | return null 52 | } 53 | 54 | // Check credentials 55 | val device = deviceRepo.getDevice(deviceId) 56 | if (device == null) { 57 | log(tag, WARN) { "verifyAuth($callInfo): deviceId=$deviceId not found" } 58 | call.respond(HttpStatusCode.NotFound, "Unknown device: $deviceId") 59 | return null 60 | } 61 | 62 | if (!device.isAuthorized(creds)) { 63 | log(tag, WARN) { "verifyAuth($callInfo): deviceId=$deviceId credentials not authorized" } 64 | call.respond(HttpStatusCode.Unauthorized, "Device credentials not found or insufficient") 65 | return null 66 | } 67 | 68 | deviceRepo.updateDevice(device.id) { 69 | it.copy(lastSeen = Instant.now()) 70 | } 71 | 72 | return device 73 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/common/debug/logging/ConsoleLogger.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.common.debug.logging 2 | 3 | import ch.qos.logback.classic.Level 4 | import ch.qos.logback.classic.LoggerContext 5 | import ch.qos.logback.classic.PatternLayout 6 | import ch.qos.logback.classic.spi.ILoggingEvent 7 | import ch.qos.logback.core.ConsoleAppender 8 | import ch.qos.logback.core.encoder.LayoutWrappingEncoder 9 | import org.slf4j.LoggerFactory 10 | import kotlin.math.min 11 | 12 | object ConsoleLogger : Logging.Logger { 13 | private const val MAX_LOG_LENGTH = 4000 14 | private val logger by lazy { 15 | val loggerContext = LoggerFactory.getILoggerFactory() as LoggerContext 16 | 17 | loggerContext.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME).apply { 18 | detachAndStopAllAppenders() 19 | } 20 | 21 | val patternLayout = PatternLayout().apply { 22 | context = loggerContext 23 | pattern = "%d{HH:mm:ss.SSS} %-5level %msg%n" 24 | start() 25 | } 26 | 27 | val consoleAppender = ConsoleAppender().apply { 28 | context = loggerContext 29 | encoder = LayoutWrappingEncoder().apply { 30 | layout = patternLayout 31 | } 32 | start() 33 | } 34 | 35 | loggerContext.getLogger("Octi").apply { 36 | level = Level.TRACE 37 | addAppender(consoleAppender) 38 | } 39 | LoggerFactory.getLogger("Octi") 40 | } 41 | var logLevel = Logging.Priority.INFO 42 | 43 | override fun isLoggable(priority: Logging.Priority): Boolean = priority.code >= logLevel.code 44 | 45 | override fun log(priority: Logging.Priority, tag: String, message: String, metaData: Map?) { 46 | var i = 0 47 | val length = message.length 48 | while (i < length) { 49 | var newline = message.indexOf('\n', i) 50 | newline = if (newline != -1) newline else length 51 | do { 52 | val end = min(newline, i + MAX_LOG_LENGTH) 53 | val part = message.substring(i, end) 54 | writeToLog(priority, tag, part) 55 | i = end 56 | } while (i < newline) 57 | i++ 58 | } 59 | } 60 | 61 | private fun writeToLog(priority: Logging.Priority, tag: String, part: String) { 62 | val line = "$tag - $part" 63 | when (priority) { 64 | Logging.Priority.VERBOSE -> logger.trace(line) 65 | Logging.Priority.DEBUG -> logger.debug(line) 66 | Logging.Priority.INFO -> logger.info(line) 67 | Logging.Priority.WARN -> logger.warn(line) 68 | Logging.Priority.ERROR -> logger.error(line) 69 | Logging.Priority.ASSERT -> logger.error(line) 70 | } 71 | } 72 | 73 | 74 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/common/RateLimiter.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.common 2 | 3 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.* 4 | import eu.darken.octi.kserver.common.debug.logging.log 5 | import eu.darken.octi.kserver.common.debug.logging.logTag 6 | import io.ktor.http.* 7 | import io.ktor.server.application.* 8 | import io.ktor.server.plugins.* 9 | import io.ktor.server.request.* 10 | import io.ktor.server.response.* 11 | import kotlinx.coroutines.* 12 | import java.time.Duration 13 | import java.time.Instant 14 | import java.util.concurrent.ConcurrentHashMap 15 | 16 | data class RateLimitConfig( 17 | val limit: Int = 512, 18 | val resetTime: Duration = Duration.ofSeconds(60), 19 | ) 20 | 21 | private val TAG = logTag("RateLimiter") 22 | 23 | private data class ClientRateState( 24 | val id: String, 25 | val requests: Int = 0, 26 | val resetAt: Instant, 27 | ) 28 | 29 | fun Application.installRateLimit(config: RateLimitConfig) { 30 | log(TAG, INFO) { "Rate limits are set to $config" } 31 | val rateLimitCache = ConcurrentHashMap() 32 | 33 | launch(Dispatchers.IO) { 34 | while (currentCoroutineContext().isActive) { 35 | log(TAG) { "Checking for stale rate limit entries (${rateLimitCache.size} entries)..." } 36 | val top10 = rateLimitCache.values.sortedByDescending { it.requests }.take(10) 37 | log(TAG, VERBOSE) { "Rate limit top 10 by requests: $top10" } 38 | val now = Instant.now() 39 | val staleEntries = rateLimitCache.filterValues { state -> now.isAfter(state.resetAt) } 40 | if (staleEntries.isNotEmpty()) { 41 | log(TAG) { "Removing ${staleEntries.size} stale rate limit entries: $staleEntries" } 42 | staleEntries.keys.forEach { rateLimitCache.remove(it) } 43 | } 44 | delay(config.resetTime.toMillis() / 2) 45 | } 46 | } 47 | 48 | intercept(ApplicationCallPipeline.Plugins) { 49 | val clientIp = call.request.run { 50 | headers["X-Forwarded-For"]?.split(",")?.firstOrNull()?.trim() ?: origin.remoteAddress 51 | } 52 | val now = Instant.now() 53 | val calldetails = "${call.request.httpMethod.value} ${call.request.uri}" 54 | 55 | val rateState = rateLimitCache[clientIp] ?: ClientRateState(id = clientIp, resetAt = now + config.resetTime) 56 | 57 | if (now.isAfter(rateState.resetAt)) { 58 | rateLimitCache[clientIp] = ClientRateState(clientIp, 1, now + config.resetTime) 59 | } else if (rateState.requests >= config.limit) { 60 | log(TAG, WARN) { "Rate limits exceeded by $rateState -- $calldetails" } 61 | call.respond(HttpStatusCode.TooManyRequests, "Rate limit exceeded. Try again later.") 62 | finish() 63 | return@intercept 64 | } else { 65 | rateLimitCache[clientIp] = rateState.copy(requests = rateState.requests + 1) 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/Server.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver 2 | 3 | import eu.darken.octi.kserver.account.AccountRoute 4 | import eu.darken.octi.kserver.account.share.ShareRoute 5 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.INFO 6 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.WARN 7 | import eu.darken.octi.kserver.common.debug.logging.log 8 | import eu.darken.octi.kserver.common.debug.logging.logTag 9 | import eu.darken.octi.kserver.common.installCallLogging 10 | import eu.darken.octi.kserver.common.installPayloadLimit 11 | import eu.darken.octi.kserver.common.installRateLimit 12 | import eu.darken.octi.kserver.device.DeviceRoute 13 | import eu.darken.octi.kserver.module.ModuleRoute 14 | import eu.darken.octi.kserver.status.StatusRoute 15 | import io.ktor.serialization.kotlinx.json.* 16 | import io.ktor.server.application.* 17 | import io.ktor.server.engine.* 18 | import io.ktor.server.netty.* 19 | import io.ktor.server.plugins.contentnegotiation.* 20 | import io.ktor.server.routing.* 21 | import kotlinx.serialization.json.Json 22 | import kotlinx.serialization.modules.SerializersModule 23 | import javax.inject.Inject 24 | 25 | class Server @Inject constructor( 26 | private val config: App.Config, 27 | private val statusRoute: StatusRoute, 28 | private val accountRoute: AccountRoute, 29 | private val shareRoute: ShareRoute, 30 | private val deviceRoute: DeviceRoute, 31 | private val moduleRoute: ModuleRoute, 32 | private val serializers: SerializersModule, 33 | ) { 34 | 35 | @Suppress("ExtractKtorModule") 36 | private val server by lazy { 37 | embeddedServer(Netty, config.port) { 38 | installCallLogging() 39 | install(ContentNegotiation) { 40 | json(Json { 41 | prettyPrint = true 42 | isLenient = true 43 | serializersModule = serializers 44 | }) 45 | } 46 | 47 | config.rateLimit 48 | ?.let { installRateLimit(it) } 49 | ?: log(TAG, WARN) { "rateLimit is not configured" } 50 | 51 | config.payloadLimit 52 | ?.let { installPayloadLimit(it) } 53 | ?: log(TAG, WARN) { "payloadLimit is not configured" } 54 | 55 | routing { 56 | statusRoute.setup(this) 57 | accountRoute.setup(this) 58 | shareRoute.setup(this) 59 | deviceRoute.setup(this) 60 | moduleRoute.setup(this) 61 | } 62 | } 63 | } 64 | private var isRunning = false 65 | 66 | fun start() { 67 | log(TAG, INFO) { "Server is starting..." } 68 | server.monitor.apply { 69 | subscribe(ApplicationStarted) { 70 | log(TAG, INFO) { "Server is ready" } 71 | isRunning = true 72 | } 73 | subscribe(ApplicationStopping) { 74 | log(TAG, INFO) { "Server is stopping..." } 75 | isRunning = false 76 | } 77 | } 78 | server.start(wait = true) 79 | } 80 | 81 | fun stop() { 82 | log(TAG, INFO) { "Server is stopping..." } 83 | server.stop(gracePeriodMillis = 1000, timeoutMillis = 5000) 84 | log(TAG, INFO) { "Server stopped" } 85 | } 86 | 87 | fun isRunning(): Boolean = isRunning 88 | 89 | companion object { 90 | private val TAG = logTag("Server") 91 | } 92 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/App.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver 2 | 3 | import eu.darken.octi.kserver.common.AppScope 4 | import eu.darken.octi.kserver.common.RateLimitConfig 5 | import eu.darken.octi.kserver.common.debug.logging.ConsoleLogger 6 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.* 7 | import eu.darken.octi.kserver.common.debug.logging.log 8 | import eu.darken.octi.kserver.common.debug.logging.logTag 9 | import java.nio.file.Path 10 | import java.time.Duration 11 | import javax.inject.Inject 12 | import kotlin.io.path.Path 13 | import kotlin.io.path.absolute 14 | import kotlin.reflect.full.memberProperties 15 | 16 | 17 | class App @Inject constructor( 18 | val appScope: AppScope, 19 | private val server: Server, 20 | ) { 21 | 22 | fun launch() { 23 | server.start() 24 | } 25 | 26 | fun isRunning(): Boolean { 27 | return server.isRunning() 28 | } 29 | 30 | fun shutdown() { 31 | server.stop() 32 | } 33 | 34 | data class Config( 35 | val isDebug: Boolean = false, 36 | val port: Int, 37 | val dataPath: Path, 38 | val rateLimit: RateLimitConfig? = RateLimitConfig(), 39 | val payloadLimit: Long? = 128 * 1024L, 40 | val accountGCInterval: Duration = Duration.ofMinutes(10), 41 | val shareExpiration: Duration = Duration.ofMinutes(60), 42 | val shareGCInterval: Duration = Duration.ofMinutes(10), 43 | val deviceExpiration: Duration = Duration.ofDays(90), 44 | val deviceGCInterval: Duration = Duration.ofMinutes(10), 45 | val moduleExpiration: Duration = Duration.ofDays(90), 46 | val moduleGCInterval: Duration = Duration.ofMinutes(10), 47 | ) 48 | 49 | companion object { 50 | 51 | @JvmStatic 52 | fun main(args: Array) { 53 | log(TAG, INFO) { "Program arguments: ${args.joinToString()}" } 54 | 55 | val config = Config( 56 | isDebug = args.any { it.startsWith("--debug") }, 57 | port = args 58 | .singleOrNull { it.startsWith("--port") } 59 | ?.substringAfter('=')?.toInt() 60 | ?: 8080, 61 | dataPath = args 62 | .single { it.startsWith("--datapath") } 63 | .let { Path(it.substringAfter('=')) } 64 | .absolute(), 65 | rateLimit = if (args.any { it.startsWith("--disable-rate-limits") }) { 66 | null 67 | } else { 68 | RateLimitConfig() 69 | }, 70 | ) 71 | 72 | createComponent(config).application().launch() 73 | } 74 | 75 | fun createComponent(config: Config): AppComponent { 76 | log(TAG, INFO) { "SERVER BUILD: ${BuildInfo.GIT_SHA} (${BuildInfo.GIT_DATE})" } 77 | 78 | log(TAG, INFO) { "App config is\n---" } 79 | Config::class.memberProperties.forEach { prop -> log(TAG, INFO) { "${prop.name}: ${prop.get(config)}" } } 80 | log(TAG, INFO) { "---" } 81 | 82 | if (config.isDebug) { 83 | ConsoleLogger.logLevel = VERBOSE 84 | log(TAG, VERBOSE) { "Debug mode is active" } 85 | log(TAG, DEBUG) { "Debug mode is active" } 86 | log(TAG, INFO) { "Debug mode is active" } 87 | } else { 88 | ConsoleLogger.logLevel = INFO 89 | log(TAG, INFO) { "Debug mode disabled" } 90 | } 91 | 92 | return DaggerAppComponent.builder().config(config).build() 93 | } 94 | 95 | private val TAG = logTag("App") 96 | } 97 | } -------------------------------------------------------------------------------- /src/test/kotlin/eu/darken/octi/TestRunner.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi 2 | 3 | import eu.darken.octi.kserver.App 4 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.VERBOSE 5 | import eu.darken.octi.kserver.common.debug.logging.log 6 | import io.ktor.client.* 7 | import io.ktor.client.engine.cio.* 8 | import io.ktor.client.plugins.* 9 | import io.ktor.client.plugins.contentnegotiation.* 10 | import io.ktor.http.* 11 | import io.ktor.serialization.kotlinx.json.* 12 | import kotlinx.coroutines.test.runTest 13 | import kotlinx.serialization.json.Json 14 | import java.nio.file.Files 15 | import java.nio.file.Path 16 | import java.util.* 17 | import kotlin.concurrent.thread 18 | import kotlin.io.path.ExperimentalPathApi 19 | import kotlin.io.path.Path 20 | import kotlin.io.path.deleteRecursively 21 | 22 | @OptIn(ExperimentalPathApi::class) 23 | abstract class TestRunner { 24 | 25 | val baseConfig = App.Config( 26 | dataPath = Path("./build/tmp/testdatapath/${UUID.randomUUID()}"), 27 | port = 16023, 28 | isDebug = true, 29 | rateLimit = null, 30 | ) 31 | 32 | data class TestEnvironment( 33 | val config: App.Config, 34 | val app: App, 35 | val http: HttpClient, 36 | ) 37 | 38 | fun runTest2( 39 | appConfig: App.Config = baseConfig, 40 | keepData: Boolean = false, 41 | before: (App.Config) -> TestEnvironment = { 42 | Files.createDirectories(it.dataPath) 43 | 44 | val app = App.createComponent(it).application() 45 | thread { app.launch() } 46 | 47 | while (!app.isRunning()) Thread.sleep(100) 48 | 49 | val client = HttpClient(CIO) { 50 | defaultRequest { 51 | url { 52 | protocol = URLProtocol.HTTP 53 | host = "127.0.0.1" 54 | port = appConfig.port 55 | } 56 | } 57 | install(ContentNegotiation) { 58 | json(Json { 59 | prettyPrint = true 60 | isLenient = true 61 | ignoreUnknownKeys = true 62 | }) 63 | } 64 | } 65 | 66 | TestEnvironment(appConfig, app, client) 67 | }, 68 | after: TestEnvironment.() -> Unit = { 69 | http.close() 70 | app.shutdown() 71 | if (!keepData) { 72 | log(VERBOSE) { "Cleaning up stored data" } 73 | appConfig.dataPath.deleteRecursively() 74 | } 75 | }, 76 | test: suspend TestEnvironment.() -> Unit 77 | ) { 78 | val env = before(appConfig) 79 | log(VERBOSE) { "Running test with environment $env" } 80 | try { 81 | runTest { test(env) } 82 | } finally { 83 | after(env) 84 | log(VERBOSE) { "Test is done $env" } 85 | } 86 | } 87 | 88 | fun TestEnvironment.getAccountPath(credentials: Credentials): Path { 89 | return config.dataPath.resolve("accounts").resolve(credentials.account) 90 | } 91 | 92 | fun TestEnvironment.getSharesPath(credentials: Credentials): Path { 93 | return getAccountPath(credentials).resolve("shares") 94 | } 95 | 96 | fun TestEnvironment.getDevicePath(credentials: Credentials): Path { 97 | return getAccountPath(credentials).resolve("devices").resolve(credentials.deviceId.toString()) 98 | } 99 | 100 | fun TestEnvironment.getModulesPath(credentials: Credentials): Path { 101 | return getDevicePath(credentials).resolve("modules") 102 | } 103 | } -------------------------------------------------------------------------------- /src/test/kotlin/eu/darken/octi/kserver/account/AccountShareFlowTest.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.account 2 | 3 | import eu.darken.octi.* 4 | import io.kotest.matchers.shouldBe 5 | import io.kotest.matchers.shouldNotBe 6 | import io.kotest.matchers.string.shouldContain 7 | import io.ktor.client.request.* 8 | import io.ktor.client.statement.* 9 | import io.ktor.http.* 10 | import org.junit.jupiter.api.Test 11 | import java.util.* 12 | 13 | class AccountShareFlowTest : TestRunner() { 14 | 15 | private val endpointAcc = "/v1/account" 16 | private val endpointShare = "$endpointAcc/share" 17 | 18 | @Test 19 | fun `linking via sharecode`() = runTest2 { 20 | val creds1 = createDevice() 21 | val shareCode = createShareCode(creds1) 22 | val creds2 = createDevice(shareCode = shareCode) 23 | creds1.account shouldBe creds2.account 24 | } 25 | 26 | @Test 27 | fun `no cross use`() = runTest2 { 28 | val creds1 = createDevice() 29 | 30 | val shareCode = createShareCode(creds1) 31 | 32 | val creds2 = createDevice() 33 | val creds3 = createDevice(shareCode = shareCode) 34 | 35 | creds1.account shouldNotBe creds2.account 36 | creds2.account shouldNotBe creds3.account 37 | } 38 | 39 | @Test 40 | fun `creating share requires matching auth`() = runTest2 { 41 | val creds1 = createDevice() 42 | 43 | http.post(endpointShare) { 44 | addDeviceId(UUID.randomUUID()) 45 | addAuth(creds1.auth) 46 | }.apply { 47 | status shouldBe HttpStatusCode.NotFound 48 | bodyAsText() shouldContain "Unknown device" 49 | } 50 | } 51 | 52 | @Test 53 | fun `creating share requires valid auth`() = runTest2 { 54 | val creds1 = createDevice() 55 | 56 | http.post(endpointShare) { 57 | addDeviceId(creds1.deviceId) 58 | addAuth(creds1.auth.copy(password = "abc")) 59 | }.apply { 60 | status shouldBe HttpStatusCode.Unauthorized 61 | bodyAsText() shouldContain "Device credentials not found or insufficient" 62 | } 63 | } 64 | 65 | @Test 66 | fun `no double register`() = runTest2 { 67 | val creds1 = createDevice() 68 | val shareCode = createShareCode(creds1) 69 | 70 | val creds2 = createDevice(shareCode = shareCode) 71 | 72 | creds1.account shouldBe creds2.account 73 | 74 | http.post { 75 | url { 76 | takeFrom(endpointAcc) 77 | parameters.append("share", shareCode) 78 | } 79 | addDeviceId(creds2.deviceId) 80 | }.apply { 81 | status shouldBe HttpStatusCode.BadRequest 82 | bodyAsText() shouldContain "Device is already registered" 83 | } 84 | } 85 | 86 | @Test 87 | fun `no double use`() = runTest2 { 88 | val creds1 = createDevice() 89 | val shareCode1 = createShareCode(creds1) 90 | 91 | val creds2 = http.post { 92 | url { 93 | takeFrom(endpointAcc) 94 | parameters.append("share", shareCode1) 95 | } 96 | addDeviceId(UUID.randomUUID()) 97 | }.asAuth() 98 | 99 | creds1.account shouldBe creds2.account 100 | 101 | http.post { 102 | url { 103 | takeFrom(endpointAcc) 104 | parameters.append("share", shareCode1) 105 | } 106 | addDeviceId(UUID.randomUUID()) 107 | }.apply { 108 | status shouldBe HttpStatusCode.Forbidden 109 | bodyAsText() shouldContain "Invalid ShareCode" 110 | } 111 | } 112 | } -------------------------------------------------------------------------------- /src/test/kotlin/eu/darken/octi/kserver/device/DeviceFlowTest.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.device 2 | 3 | import eu.darken.octi.* 4 | import io.kotest.matchers.shouldBe 5 | import io.ktor.client.request.* 6 | import io.ktor.http.* 7 | import org.junit.jupiter.api.Test 8 | import java.util.* 9 | import kotlin.io.path.exists 10 | 11 | class DeviceFlowTest : TestRunner() { 12 | 13 | private val endPoint = "/v1/devices" 14 | 15 | @Test 16 | fun `get devices`() = runTest2 { 17 | val creds1 = createDevice() 18 | val creds2 = createDevice(creds1) 19 | getDevices(creds1) shouldBe TestDevices( 20 | setOf( 21 | TestDevices.Device(creds1.deviceId), 22 | TestDevices.Device(creds2.deviceId), 23 | ) 24 | ) 25 | } 26 | 27 | @Test 28 | fun `get devices - requires valid auth`() = runTest2 { 29 | val creds1 = createDevice() 30 | http.get(endPoint) { 31 | addDeviceId(creds1.deviceId) 32 | }.apply { 33 | status shouldBe HttpStatusCode.BadRequest 34 | } 35 | } 36 | 37 | @Test 38 | fun `deleting ourselves`() = runTest2 { 39 | val creds1 = createDevice() 40 | deleteDevice(creds1) 41 | http.get(endPoint) { 42 | addCredentials(creds1) 43 | }.apply { 44 | status shouldBe HttpStatusCode.NotFound 45 | } 46 | } 47 | 48 | @Test 49 | fun `delete other device`() = runTest2 { 50 | val creds1 = createDevice() 51 | val creds2 = createDevice(creds1) 52 | getDevices(creds1).devices.size shouldBe 2 53 | 54 | deleteDevice(creds1, creds2.deviceId) 55 | 56 | getDevices(creds1).devices.size shouldBe 1 57 | } 58 | 59 | @Test 60 | fun `delete devices - requires valid auth`() = runTest2 { 61 | val creds1 = createDevice() 62 | http.delete("$endPoint/${UUID.randomUUID()}") { 63 | addDeviceId(creds1.deviceId) 64 | }.apply { 65 | status shouldBe HttpStatusCode.BadRequest 66 | } 67 | } 68 | 69 | @Test 70 | fun `deleting device wipes module data`() = runTest2 { 71 | val creds1 = createDevice() 72 | 73 | getModulesPath(creds1).exists() shouldBe false 74 | writeModule(creds1, "abc", data = "test") 75 | getModulesPath(creds1).exists() shouldBe true 76 | 77 | deleteDevice(creds1) 78 | getModulesPath(creds1).exists() shouldBe false 79 | } 80 | 81 | @Test 82 | fun `resetting devices`() = runTest2 { 83 | val creds1 = createDevice() 84 | val creds2 = createDevice(creds1) 85 | writeModule(creds1, "abc", data = "test") 86 | writeModule(creds2, "abc", data = "test") 87 | http.post("$endPoint/reset") { 88 | addCredentials(creds1) 89 | contentType(ContentType.Application.Json) 90 | setBody(setOf(creds1.deviceId.toString(), creds2.deviceId.toString())) 91 | setBody("{targets: [${creds1.deviceId}, ${creds2.deviceId}]}") 92 | } 93 | readModule(creds1, "abc") shouldBe "" 94 | readModule(creds2, "abc") shouldBe "" 95 | } 96 | 97 | @Test 98 | fun `resetting devices without specific targets`() = runTest2 { 99 | val creds1 = createDevice() 100 | val creds2 = createDevice(creds1) 101 | writeModule(creds1, "abc", data = "test") 102 | writeModule(creds2, "abc", data = "test") 103 | http.post("$endPoint/reset") { 104 | addCredentials(creds1) 105 | contentType(ContentType.Application.Json) 106 | setBody("{targets: []}") 107 | } 108 | readModule(creds1, "abc") shouldBe "" 109 | readModule(creds2, "abc") shouldBe "" 110 | } 111 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/common/debug/logging/Logging.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.common.debug.logging 2 | 3 | import java.io.PrintWriter 4 | import java.io.StringWriter 5 | 6 | /** 7 | * Inspired by 8 | * https://github.com/PaulWoitaschek/Slimber 9 | * https://github.com/square/logcat 10 | * https://github.com/JakeWharton/timber 11 | */ 12 | 13 | object Logging { 14 | enum class Priority { 15 | VERBOSE, 16 | DEBUG, 17 | INFO, 18 | WARN, 19 | ERROR, 20 | ASSERT; 21 | 22 | val code: Int 23 | get() = Priority.entries.indexOf(this) 24 | } 25 | 26 | interface Logger { 27 | fun isLoggable(priority: Priority): Boolean = true 28 | 29 | fun log( 30 | priority: Priority, 31 | tag: String, 32 | message: String, 33 | metaData: Map? 34 | ) 35 | } 36 | 37 | private val internalLoggers = mutableListOf(ConsoleLogger) 38 | 39 | val loggers: List 40 | get() = synchronized(internalLoggers) { internalLoggers.toList() } 41 | 42 | val hasReceivers: Boolean 43 | get() = synchronized(internalLoggers) { 44 | internalLoggers.isNotEmpty() 45 | } 46 | 47 | fun install(logger: Logger) { 48 | synchronized(internalLoggers) { internalLoggers.add(logger) } 49 | log { "Was installed $logger" } 50 | } 51 | 52 | fun remove(logger: Logger) { 53 | log { "Removing: $logger" } 54 | synchronized(internalLoggers) { internalLoggers.remove(logger) } 55 | } 56 | 57 | fun logInternal( 58 | tag: String, 59 | priority: Priority, 60 | metaData: Map?, 61 | message: String 62 | ) { 63 | val snapshot = synchronized(internalLoggers) { internalLoggers.toList() } 64 | snapshot 65 | .filter { it.isLoggable(priority) } 66 | .forEach { 67 | it.log( 68 | priority = priority, 69 | tag = tag, 70 | metaData = metaData, 71 | message = message 72 | ) 73 | } 74 | } 75 | 76 | fun clearAll() { 77 | log { "Clearing all loggers" } 78 | synchronized(internalLoggers) { internalLoggers.clear() } 79 | } 80 | } 81 | 82 | inline fun Any.log( 83 | priority: Logging.Priority = Logging.Priority.DEBUG, 84 | metaData: Map? = null, 85 | message: () -> String, 86 | ) { 87 | if (Logging.hasReceivers) { 88 | Logging.logInternal( 89 | tag = logTag(logTagViaCallSite()), 90 | priority = priority, 91 | metaData = metaData, 92 | message = message(), 93 | ) 94 | } 95 | } 96 | 97 | inline fun log( 98 | tag: String, 99 | priority: Logging.Priority = Logging.Priority.DEBUG, 100 | metaData: Map? = null, 101 | message: () -> String, 102 | ) { 103 | if (Logging.hasReceivers) { 104 | Logging.logInternal( 105 | tag = tag, 106 | priority = priority, 107 | metaData = metaData, 108 | message = message(), 109 | ) 110 | } 111 | } 112 | 113 | fun Throwable.asLog(): String { 114 | val stringWriter = StringWriter(256) 115 | val printWriter = PrintWriter(stringWriter, false) 116 | printStackTrace(printWriter) 117 | printWriter.flush() 118 | return stringWriter.toString() 119 | } 120 | 121 | @PublishedApi 122 | internal fun Any.logTagViaCallSite(): String { 123 | val javaClass = this::class.java 124 | val fullClassName = javaClass.name 125 | val outerClassName = fullClassName.substringBefore('$') 126 | val simplerOuterClassName = outerClassName.substringAfterLast('.') 127 | return if (simplerOuterClassName.isEmpty()) { 128 | fullClassName 129 | } else { 130 | simplerOuterClassName.removeSuffix("Kt") 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/test/kotlin/eu/darken/octi/kserver/common/RateLimiterTest.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.common 2 | 3 | import eu.darken.octi.TestRunner 4 | import io.kotest.matchers.shouldBe 5 | import io.ktor.client.request.* 6 | import io.ktor.client.statement.* 7 | import io.ktor.http.* 8 | import org.junit.jupiter.api.Test 9 | import java.time.Duration 10 | 11 | class RateLimiterTest : TestRunner() { 12 | 13 | @Test 14 | fun `test rate limit allows requests within limit`() = runTest2( 15 | appConfig = baseConfig.copy( 16 | rateLimit = RateLimitConfig(limit = 2, resetTime = Duration.ofSeconds(5)) 17 | ) 18 | ) { 19 | http.get("/v1/status").apply { 20 | status shouldBe HttpStatusCode.OK 21 | } 22 | Thread.sleep(100) 23 | 24 | http.get("/v1/status").apply { 25 | status shouldBe HttpStatusCode.OK 26 | } 27 | Thread.sleep(100) 28 | 29 | http.get("/v1/status").apply { 30 | status shouldBe HttpStatusCode.TooManyRequests 31 | bodyAsText() shouldBe "Rate limit exceeded. Try again later." 32 | } 33 | } 34 | 35 | @Test 36 | fun `test rate limit resets after time window`() = runTest2( 37 | appConfig = baseConfig.copy( 38 | rateLimit = RateLimitConfig(limit = 2, resetTime = Duration.ofSeconds(5)) 39 | ) 40 | ) { 41 | repeat(2) { 42 | http.get("/v1/status").apply { 43 | status shouldBe HttpStatusCode.OK 44 | } 45 | Thread.sleep(100) 46 | } 47 | 48 | http.get("/v1/status").apply { 49 | status shouldBe HttpStatusCode.TooManyRequests 50 | } 51 | Thread.sleep(100) 52 | 53 | Thread.sleep(5100) 54 | 55 | http.get("/v1/status").apply { 56 | status shouldBe HttpStatusCode.OK 57 | } 58 | } 59 | 60 | @Test 61 | fun `test different IPs have separate rate limits`() = runTest2( 62 | appConfig = baseConfig.copy( 63 | rateLimit = RateLimitConfig(limit = 2, resetTime = Duration.ofSeconds(5)) 64 | ) 65 | ) { 66 | repeat(2) { 67 | http.get("/v1/status") { 68 | header("X-Forwarded-For", "192.168.1.1") 69 | }.apply { 70 | status shouldBe HttpStatusCode.OK 71 | } 72 | Thread.sleep(100) 73 | } 74 | 75 | repeat(2) { 76 | http.get("/v1/status") { 77 | header("X-Forwarded-For", "192.168.1.2") 78 | }.apply { 79 | status shouldBe HttpStatusCode.OK 80 | } 81 | Thread.sleep(100) 82 | } 83 | 84 | http.get("/v1/status") { 85 | header("X-Forwarded-For", "192.168.1.1") 86 | }.apply { 87 | status shouldBe HttpStatusCode.TooManyRequests 88 | } 89 | Thread.sleep(100) 90 | 91 | http.get("/v1/status") { 92 | header("X-Forwarded-For", "192.168.1.2") 93 | }.apply { 94 | status shouldBe HttpStatusCode.TooManyRequests 95 | } 96 | } 97 | 98 | @Test 99 | fun `test stale rate limit entries are cleaned up`() = runTest2( 100 | appConfig = baseConfig.copy( 101 | rateLimit = RateLimitConfig(limit = 2, resetTime = Duration.ofSeconds(2)) 102 | ) 103 | ) { 104 | // Make requests from two different IPs 105 | http.get("/v1/status") { 106 | header("X-Forwarded-For", "192.168.1.1") 107 | }.apply { 108 | status shouldBe HttpStatusCode.OK 109 | } 110 | Thread.sleep(100) 111 | 112 | http.get("/v1/status") { 113 | header("X-Forwarded-For", "192.168.1.2") 114 | }.apply { 115 | status shouldBe HttpStatusCode.OK 116 | } 117 | Thread.sleep(100) 118 | 119 | // Wait for entries to become stale (2.5 seconds > resetTime of 2 seconds) 120 | Thread.sleep(2500) 121 | 122 | // Make new requests from both IPs 123 | http.get("/v1/status") { 124 | header("X-Forwarded-For", "192.168.1.1") 125 | }.apply { 126 | status shouldBe HttpStatusCode.OK 127 | } 128 | Thread.sleep(100) 129 | 130 | http.get("/v1/status") { 131 | header("X-Forwarded-For", "192.168.1.2") 132 | }.apply { 133 | status shouldBe HttpStatusCode.OK 134 | } 135 | Thread.sleep(100) 136 | 137 | // Both IPs should be able to make requests again since their entries were cleaned up 138 | http.get("/v1/status") { 139 | header("X-Forwarded-For", "192.168.1.1") 140 | }.apply { 141 | status shouldBe HttpStatusCode.OK 142 | } 143 | Thread.sleep(100) 144 | 145 | http.get("/v1/status") { 146 | header("X-Forwarded-For", "192.168.1.2") 147 | }.apply { 148 | status shouldBe HttpStatusCode.OK 149 | } 150 | } 151 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/device/DeviceRoute.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.device 2 | 3 | import eu.darken.octi.kserver.common.callInfo 4 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.ERROR 5 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.INFO 6 | import eu.darken.octi.kserver.common.debug.logging.asLog 7 | import eu.darken.octi.kserver.common.debug.logging.log 8 | import eu.darken.octi.kserver.common.debug.logging.logTag 9 | import eu.darken.octi.kserver.common.verifyCaller 10 | import eu.darken.octi.kserver.module.ModuleRepo 11 | import io.ktor.http.* 12 | import io.ktor.server.request.* 13 | import io.ktor.server.response.* 14 | import io.ktor.server.routing.* 15 | import java.util.* 16 | import javax.inject.Inject 17 | import javax.inject.Singleton 18 | 19 | @Singleton 20 | class DeviceRoute @Inject constructor( 21 | private val deviceRepo: DeviceRepo, 22 | private val moduleRepo: ModuleRepo, 23 | ) { 24 | 25 | fun setup(rootRoute: Routing) { 26 | rootRoute.route("/v1/devices") { 27 | get { 28 | try { 29 | getDevices() 30 | } catch (e: Exception) { 31 | log(TAG, ERROR) { "getDevices() failed: ${e.asLog()}" } 32 | call.respond(HttpStatusCode.InternalServerError, "Failed to list devices") 33 | } 34 | } 35 | delete("/{deviceId}") { 36 | val deviceId: DeviceId? = call.parameters["deviceId"]?.let { UUID.fromString(it) } 37 | if (deviceId == null) { 38 | call.respond(HttpStatusCode.BadRequest, "Missing deviceId") 39 | return@delete 40 | } 41 | try { 42 | deleteDevice(deviceId) 43 | } catch (e: Exception) { 44 | log(TAG, ERROR) { "deleteDevice($deviceId) failed: ${e.asLog()}" } 45 | call.respond(HttpStatusCode.InternalServerError, "Failed to delete device") 46 | } 47 | } 48 | post("/reset") { 49 | try { 50 | resetDevices() 51 | } catch (e: Exception) { 52 | log(TAG, ERROR) { "resetDevices() failed: ${e.asLog()}" } 53 | call.respond(HttpStatusCode.InternalServerError, "Failed to reset devices") 54 | } 55 | } 56 | } 57 | } 58 | 59 | private suspend fun RoutingContext.getDevices() { 60 | val callerDevice = verifyCaller(TAG, deviceRepo) ?: return 61 | 62 | val devices = deviceRepo.getDevices(callerDevice.accountId) 63 | val response = DevicesResponse( 64 | devices = devices.map { 65 | DevicesResponse.Device( 66 | id = it.id, 67 | version = it.version, 68 | ) 69 | } 70 | ) 71 | call.respond(response).also { log(TAG) { "getDevices($callInfo): -> $response" } } 72 | } 73 | 74 | private suspend fun RoutingContext.deleteDevice(deviceId: DeviceId) { 75 | val callerDevice = verifyCaller(TAG, deviceRepo) ?: return 76 | val targetDevice = deviceRepo.getDevice(deviceId) 77 | if (targetDevice == null) { 78 | call.respond(HttpStatusCode.NotFound) { "Device not found $deviceId" } 79 | return 80 | } 81 | if (targetDevice.accountId != callerDevice.accountId) { 82 | call.respond(HttpStatusCode.Unauthorized) { "Device does not belong to your account" } 83 | return 84 | } 85 | 86 | deviceRepo.deleteDevice(deviceId) 87 | moduleRepo.clear(callerDevice, setOf(targetDevice)) 88 | 89 | call.respond(HttpStatusCode.OK).also { 90 | log(TAG, INFO) { "delete($callInfo): Device was deleted: $deviceId" } 91 | } 92 | } 93 | 94 | private suspend fun RoutingContext.resetDevices() { 95 | val callerDevice = verifyCaller(TAG, deviceRepo) ?: return 96 | 97 | var targetDevices = call.receive().targets 98 | .map { deviceRepo.getDevice(it)!! } 99 | .toSet() 100 | 101 | 102 | if (targetDevices.any { it.accountId != callerDevice.accountId }) { 103 | call.respond(HttpStatusCode.Unauthorized) { "Devices do not belong to your account" } 104 | return 105 | } 106 | 107 | if (targetDevices.isEmpty()) { 108 | log(TAG) { "No explicit targets provided, targeting all devices of this account." } 109 | targetDevices = deviceRepo.getDevices(callerDevice.accountId).toSet() 110 | } 111 | 112 | log(TAG, INFO) { "resetDevices(${callInfo}): Resetting devices ${targetDevices.map { it.id }}" } 113 | 114 | moduleRepo.clear(callerDevice, targetDevices) 115 | 116 | call.respond(HttpStatusCode.OK).also { 117 | log(TAG, INFO) { "resetDevices($callInfo): Devices were reset" } 118 | } 119 | } 120 | 121 | companion object { 122 | private val TAG = logTag("Devices", "Route") 123 | } 124 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/account/AccountRoute.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.account 2 | 3 | import eu.darken.octi.kserver.account.share.ShareRepo 4 | import eu.darken.octi.kserver.common.callInfo 5 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.* 6 | import eu.darken.octi.kserver.common.debug.logging.asLog 7 | import eu.darken.octi.kserver.common.debug.logging.log 8 | import eu.darken.octi.kserver.common.debug.logging.logTag 9 | import eu.darken.octi.kserver.common.headerDeviceId 10 | import eu.darken.octi.kserver.common.verifyCaller 11 | import eu.darken.octi.kserver.device.DeviceRepo 12 | import eu.darken.octi.kserver.device.deviceCredentials 13 | import io.ktor.http.* 14 | import io.ktor.server.response.* 15 | import io.ktor.server.routing.* 16 | import kotlinx.coroutines.NonCancellable 17 | import kotlinx.coroutines.withContext 18 | import javax.inject.Inject 19 | import javax.inject.Singleton 20 | 21 | @Singleton 22 | class AccountRoute @Inject constructor( 23 | private val accountRepo: AccountRepo, 24 | private val deviceRepo: DeviceRepo, 25 | private val shareRepo: ShareRepo, 26 | ) { 27 | 28 | fun setup(rootRoute: Routing) { 29 | rootRoute.route("/v1/account") { 30 | post { 31 | try { 32 | create() 33 | } catch (e: Exception) { 34 | log(TAG, ERROR) { "create() failed: ${e.asLog()}" } 35 | call.respond(HttpStatusCode.InternalServerError, "Account creation failed") 36 | } 37 | } 38 | delete { 39 | try { 40 | delete() 41 | } catch (e: Exception) { 42 | log(TAG, ERROR) { "delete() failed: ${e.asLog()}" } 43 | call.respond(HttpStatusCode.InternalServerError, "Account deletion failed") 44 | } 45 | } 46 | } 47 | } 48 | 49 | private suspend fun RoutingContext.create() { 50 | val deviceId = call.headerDeviceId 51 | val shareCode = call.request.queryParameters["share"] 52 | 53 | log(TAG) { "create($callInfo): deviceId=$deviceId, shareCode=$shareCode" } 54 | 55 | if (deviceId == null) { 56 | log(TAG, WARN) { "create($callInfo): Missing header ID" } 57 | call.respond(HttpStatusCode.BadRequest, "X-Device-ID header is missing") 58 | return 59 | } 60 | 61 | // Check if this device is already registered 62 | var device = deviceRepo.getDevice(deviceId) 63 | if (device != null) { 64 | log(TAG, WARN) { "create($callInfo): Device is already known: $device" } 65 | call.respond(HttpStatusCode.BadRequest, "Device is already registered") 66 | return 67 | } 68 | 69 | if (deviceCredentials != null) { 70 | log(TAG, WARN) { "create($callInfo): Credentials were unexpectedly provided" } 71 | call.respond(HttpStatusCode.BadRequest, "Don't provide credentials during action creation or linking") 72 | return 73 | } 74 | 75 | // Try linking device to account 76 | val account = if (shareCode != null) { 77 | val share = shareRepo.getShare(shareCode) 78 | if (share == null) { 79 | log(TAG, WARN) { "create($callInfo): Could not resolve ShareCode" } 80 | call.respond(HttpStatusCode.Forbidden, "Invalid ShareCode") 81 | return 82 | } 83 | 84 | if (!shareRepo.consumeShare(shareCode)) { 85 | log(TAG, ERROR) { "create($callInfo): Failed to consume Share" } 86 | call.respond(HttpStatusCode.InternalServerError, "ShareCode was already consumed") 87 | return 88 | } 89 | log(TAG, INFO) { "create($callInfo): Share was valid, let's add the device" } 90 | accountRepo.getAccount(share.accountId)!! 91 | } else { 92 | // Normal account creation 93 | log(TAG, INFO) { "create($callInfo): Creating new account" } 94 | accountRepo.createAccount() 95 | } 96 | // TODO can share be consumed and then error prevents creation? 97 | device = deviceRepo.createDevice( 98 | deviceId = deviceId, 99 | account = account, 100 | version = call.request.headers["User-Agent"], 101 | ) 102 | 103 | val response = RegisterResponse( 104 | accountID = device.accountId, 105 | password = device.password, 106 | ) 107 | call.respond(response).also { 108 | log(TAG, INFO) { "create($callInfo): Device registered $device to $account" } 109 | } 110 | } 111 | 112 | private suspend fun RoutingContext.delete() { 113 | val callerDevice = verifyCaller(TAG, deviceRepo) ?: return 114 | log(TAG, INFO) { "delete(${callInfo}): Deleting account ${callerDevice.accountId}" } 115 | 116 | withContext(NonCancellable) { 117 | deviceRepo.deleteDevices(callerDevice.accountId) 118 | shareRepo.removeSharesForAccount(callerDevice.accountId) 119 | accountRepo.deleteAccounts(listOf(callerDevice.accountId)) 120 | } 121 | 122 | call.respond(HttpStatusCode.OK).also { 123 | log(TAG, INFO) { "delete($callInfo): Account was deleted: ${callerDevice.accountId}" } 124 | } 125 | } 126 | 127 | companion object { 128 | private val TAG = logTag("Account", "Route") 129 | } 130 | } -------------------------------------------------------------------------------- /src/test/kotlin/eu/darken/octi/TestRunnerExtensions.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi 2 | 3 | import eu.darken.octi.TestRunner.TestEnvironment 4 | import eu.darken.octi.kserver.common.serialization.UUIDSerializer 5 | import io.kotest.matchers.shouldBe 6 | import io.ktor.client.request.* 7 | import io.ktor.client.statement.* 8 | import io.ktor.http.* 9 | import kotlinx.serialization.Serializable 10 | import kotlinx.serialization.json.Json 11 | import java.util.* 12 | 13 | suspend fun HttpResponse.asMap() = Json.decodeFromString>(bodyAsText()) 14 | 15 | @Serializable 16 | data class Auth( 17 | val account: String, 18 | val password: String 19 | ) 20 | 21 | data class Credentials( 22 | val deviceId: UUID, 23 | val auth: Auth, 24 | ) { 25 | val account: String 26 | get() = auth.account 27 | val password: String 28 | get() = auth.password 29 | } 30 | 31 | suspend fun HttpResponse.asAuth() = Json.decodeFromString(bodyAsText()) 32 | 33 | fun Auth.toBearerToken(): String { 34 | val credentials = "$account:$password" 35 | val encodedCredentials = Base64.getEncoder().encodeToString(credentials.toByteArray()) 36 | return "Basic $encodedCredentials" 37 | } 38 | 39 | fun HttpRequestBuilder.addDeviceId(id: UUID) { 40 | headers { 41 | append("X-Device-ID", id.toString()) 42 | } 43 | } 44 | 45 | fun HttpRequestBuilder.addAuth(auth: Auth) { 46 | headers { 47 | append("Authorization", auth.toBearerToken()) 48 | } 49 | } 50 | 51 | fun HttpRequestBuilder.addCredentials(credentials: Credentials) { 52 | addDeviceId(credentials.deviceId) 53 | addAuth(credentials.auth) 54 | } 55 | 56 | suspend fun TestEnvironment.createDeviceRaw( 57 | deviceId: UUID = UUID.randomUUID(), 58 | shareCode: String? = null, 59 | ): HttpResponse = this.http.run { 60 | if (shareCode != null) { 61 | post { 62 | url { 63 | takeFrom("/v1/account") 64 | parameters.append("share", shareCode) 65 | } 66 | addDeviceId(deviceId) 67 | } 68 | } else { 69 | post("/v1/account") { 70 | addDeviceId(deviceId) 71 | } 72 | } 73 | } 74 | 75 | suspend fun TestEnvironment.createDevice( 76 | deviceId: UUID = UUID.randomUUID(), 77 | shareCode: String? = null, 78 | ): Credentials { 79 | val credentials = createDeviceRaw(deviceId, shareCode).asAuth() 80 | return Credentials(deviceId, credentials) 81 | } 82 | 83 | suspend fun TestEnvironment.createDevice( 84 | credentials: Credentials, 85 | ): Credentials { 86 | val shareCode = createShareCode(credentials) 87 | 88 | return createDevice(shareCode = shareCode) 89 | } 90 | 91 | suspend fun TestEnvironment.createShareCode( 92 | credentials: Credentials 93 | ): String = http.run { 94 | val shareCode = post("/v1/account/share") { 95 | addDeviceId(credentials.deviceId) 96 | addAuth(credentials.auth) 97 | }.asMap()["code"]!! 98 | 99 | return shareCode 100 | } 101 | 102 | @Serializable 103 | data class TestDevices( 104 | val devices: Set, 105 | ) { 106 | @Serializable 107 | data class Device( 108 | @Serializable(with = UUIDSerializer::class) val id: UUID, 109 | val version: String = "Ktor client", 110 | ) 111 | } 112 | 113 | suspend fun TestEnvironment.getDevicesRaw( 114 | credentials: Credentials 115 | ): HttpResponse = this.http.run { 116 | http.get("/v1/devices") { 117 | addCredentials(credentials) 118 | } 119 | } 120 | 121 | suspend fun TestEnvironment.getDevices( 122 | credentials: Credentials 123 | ): TestDevices = this.http.run { 124 | val response = getDevicesRaw(credentials) 125 | Json.decodeFromString(response.bodyAsText()) 126 | } 127 | 128 | suspend fun TestEnvironment.deleteAccount( 129 | credentials: Credentials, 130 | ) = this.http.run { 131 | delete("/v1/account") { 132 | addCredentials(credentials) 133 | } 134 | } 135 | 136 | suspend fun TestEnvironment.deleteDevice( 137 | credentials: Credentials, 138 | target: UUID = credentials.deviceId, 139 | ) = this.http.run { 140 | delete("/v1/devices/$target") { 141 | addCredentials(credentials) 142 | }.apply { 143 | status shouldBe HttpStatusCode.OK 144 | } 145 | } 146 | 147 | fun HttpRequestBuilder.targetModule(moduleId: String, device: UUID?) { 148 | url { 149 | takeFrom("/v1/module/$moduleId") 150 | if (device != null) parameters.append("device-id", device.toString()) 151 | } 152 | } 153 | 154 | suspend fun TestEnvironment.readModuleRaw( 155 | creds: Credentials, 156 | moduleId: String, 157 | deviceId: UUID? = creds.deviceId, 158 | ) = http.get { 159 | targetModule(moduleId, deviceId) 160 | addCredentials(creds) 161 | } 162 | 163 | suspend fun TestEnvironment.readModule( 164 | creds: Credentials, 165 | moduleId: String, 166 | deviceId: UUID? = creds.deviceId, 167 | ) = http.get { 168 | targetModule(moduleId, deviceId) 169 | addCredentials(creds) 170 | }.bodyAsText() 171 | 172 | suspend fun TestEnvironment.writeModule( 173 | creds: Credentials, 174 | moduleId: String, 175 | deviceId: UUID? = creds.deviceId, 176 | data: String, 177 | ) = http.post { 178 | targetModule(moduleId, deviceId) 179 | addCredentials(creds) 180 | contentType(ContentType.Application.OctetStream) 181 | setBody(data) 182 | } 183 | 184 | suspend fun TestEnvironment.deleteModuleRaw( 185 | creds: Credentials, 186 | moduleId: String, 187 | deviceId: UUID? = creds.deviceId, 188 | ) = http.delete { 189 | targetModule(moduleId, deviceId) 190 | addCredentials(creds) 191 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/module/ModuleRoute.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.module 2 | 3 | import eu.darken.octi.kserver.common.callInfo 4 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.ERROR 5 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.WARN 6 | import eu.darken.octi.kserver.common.debug.logging.asLog 7 | import eu.darken.octi.kserver.common.debug.logging.log 8 | import eu.darken.octi.kserver.common.debug.logging.logTag 9 | import eu.darken.octi.kserver.common.verifyCaller 10 | import eu.darken.octi.kserver.device.Device 11 | import eu.darken.octi.kserver.device.DeviceId 12 | import eu.darken.octi.kserver.device.DeviceRepo 13 | import io.ktor.http.* 14 | import io.ktor.server.http.* 15 | import io.ktor.server.request.* 16 | import io.ktor.server.response.* 17 | import io.ktor.server.routing.* 18 | import java.util.* 19 | import javax.inject.Inject 20 | import javax.inject.Singleton 21 | 22 | @Singleton 23 | class ModuleRoute @Inject constructor( 24 | private val deviceRepo: DeviceRepo, 25 | private val moduleRepo: ModuleRepo, 26 | ) { 27 | 28 | private suspend fun RoutingContext.requireModuleId(): ModuleId? { 29 | val moduleId = call.parameters["moduleId"] 30 | if (moduleId == null) { 31 | call.respond(HttpStatusCode.BadRequest, "Missing moduleId") 32 | return null 33 | } 34 | if (moduleId.length > 1024) { 35 | call.respond(HttpStatusCode.BadRequest, "Invalid moduleId") 36 | return null 37 | } 38 | if (!MODULE_ID_REGEX.matches(moduleId)) { 39 | call.respond(HttpStatusCode.BadRequest, "Invalid moduleId") 40 | return null 41 | } 42 | return moduleId 43 | } 44 | 45 | private suspend fun RoutingContext.catchError(action: suspend RoutingContext.() -> Unit) { 46 | try { 47 | action() 48 | } catch (e: Exception) { 49 | log(TAG, ERROR) { "$call ${e.asLog()}" } 50 | call.respond(HttpStatusCode.InternalServerError, "Request failed") 51 | } 52 | } 53 | 54 | fun setup(rootRoute: Routing) { 55 | rootRoute.route("/v1/module") { 56 | get("/{moduleId}") { catchError { readModule() } } 57 | post("/{moduleId}") { catchError { writeModule() } } 58 | delete("/{moduleId}") { catchError { deleteModule() } } 59 | } 60 | } 61 | 62 | private suspend fun RoutingContext.verifyTarget(callerDevice: Device): Device? { 63 | val targetDeviceId: DeviceId? = call.request.queryParameters["device-id"]?.let { UUID.fromString(it) } 64 | if (targetDeviceId == null) { 65 | log(TAG, WARN) { "Caller did not supply target device: $callerDevice" } 66 | call.respond(HttpStatusCode.BadRequest, "Target device id not supplied") 67 | return null 68 | } 69 | val target = deviceRepo.getDevice(targetDeviceId) 70 | if (target == null) { 71 | log(TAG, WARN) { "Target device was not found for $targetDeviceId" } 72 | call.respond(HttpStatusCode.NotFound, "Target device not found") 73 | return null 74 | } 75 | 76 | if (callerDevice.accountId != target.accountId) { 77 | log(TAG, ERROR) { "Devices don't share the same account: $callerDevice and $target" } 78 | call.respond(HttpStatusCode.Unauthorized, "Devices don't share the same account") 79 | return null 80 | } 81 | 82 | return target 83 | } 84 | 85 | private suspend fun RoutingContext.readModule() { 86 | val moduleId = requireModuleId() ?: return 87 | val callerDevice = verifyCaller(TAG, deviceRepo) ?: return 88 | val targetDevice = verifyTarget(callerDevice) ?: return 89 | 90 | val read = moduleRepo.read(callerDevice, targetDevice, moduleId) 91 | 92 | if (read.modifiedAt == null) { 93 | call.respond(HttpStatusCode.NoContent) 94 | } else { 95 | call.response.header("X-Modified-At", read.modifiedAt.toHttpDateString()) 96 | call.respondBytes( 97 | read.payload, 98 | contentType = ContentType.Application.OctetStream 99 | ) 100 | }.also { 101 | log(TAG) { "readModule($callInfo): ${read.size}B was read from $moduleId" } 102 | } 103 | } 104 | 105 | private suspend fun RoutingContext.writeModule() { 106 | val moduleId = requireModuleId() ?: return 107 | val callerDevice = verifyCaller(TAG, deviceRepo) ?: return 108 | val targetDevice = verifyTarget(callerDevice) ?: return 109 | 110 | val write = Module.Write( 111 | payload = call.receive() 112 | ) 113 | 114 | moduleRepo.write(callerDevice, targetDevice, moduleId, write) 115 | call.respond(HttpStatusCode.OK).also { 116 | log(TAG) { "writeModule($callInfo): ${write.size}B was written to $moduleId" } 117 | } 118 | } 119 | 120 | private suspend fun RoutingContext.deleteModule() { 121 | val moduleId = requireModuleId() ?: return 122 | val callerDevice = verifyCaller(TAG, deviceRepo) ?: return 123 | val targetDevice = verifyTarget(callerDevice) ?: return 124 | 125 | moduleRepo.delete(callerDevice, targetDevice, moduleId) 126 | 127 | call.respond(HttpStatusCode.OK).also { 128 | log(TAG) { "deleteModule($callInfo): $moduleId was deleted" } 129 | } 130 | } 131 | 132 | companion object { 133 | private val MODULE_ID_REGEX = "^[a-z]+(\\.[a-z0-9_]+)*$".toRegex() 134 | private val TAG = logTag("Module", "Route") 135 | } 136 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/module/ModuleRepo.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.module 2 | 3 | import eu.darken.octi.kserver.App 4 | import eu.darken.octi.kserver.common.AppScope 5 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.ERROR 6 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.VERBOSE 7 | import eu.darken.octi.kserver.common.debug.logging.asLog 8 | import eu.darken.octi.kserver.common.debug.logging.log 9 | import eu.darken.octi.kserver.common.debug.logging.logTag 10 | import eu.darken.octi.kserver.device.Device 11 | import eu.darken.octi.kserver.device.DeviceRepo 12 | import kotlinx.coroutines.* 13 | import kotlinx.coroutines.sync.withLock 14 | import kotlinx.serialization.encodeToString 15 | import kotlinx.serialization.json.Json 16 | import java.io.IOException 17 | import java.nio.file.Path 18 | import java.security.MessageDigest 19 | import java.time.Duration 20 | import java.time.Instant 21 | import javax.inject.Inject 22 | import javax.inject.Singleton 23 | import kotlin.io.path.* 24 | 25 | @OptIn(ExperimentalPathApi::class) 26 | @Singleton 27 | class ModuleRepo @Inject constructor( 28 | appScope: AppScope, 29 | private val config: App.Config, 30 | private val serializer: Json, 31 | private val deviceRepo: DeviceRepo, 32 | ) { 33 | 34 | init { 35 | appScope.launch(Dispatchers.IO) { 36 | delay(config.moduleGCInterval.toMillis() / 10) 37 | while (currentCoroutineContext().isActive) { 38 | log(TAG) { "Checking for stale modules..." } 39 | deviceRepo.allDevices().forEach { device: Device -> 40 | device.sync.withLock { 41 | try { 42 | if (!device.modulesPath.exists()) return@withLock 43 | 44 | val now = Instant.now() 45 | val staleModules = device.modulesPath.listDirectoryEntries().filter { path -> 46 | val metaFile = path.resolve(META_FILENAME) 47 | val lastAccessed = metaFile.getLastModifiedTime().toInstant() 48 | Duration.between(lastAccessed, now) > config.moduleExpiration 49 | } 50 | if (staleModules.isNotEmpty()) { 51 | log(TAG) { "Deleting ${staleModules.size} stale modules for ${device.id}" } 52 | staleModules.forEach { it.deleteRecursively() } 53 | } 54 | } catch (e: IOException) { 55 | log(TAG, ERROR) { "Module expiration check failed for $device\n${e.asLog()}" } 56 | } 57 | } 58 | } 59 | delay(config.moduleGCInterval.toMillis()) 60 | } 61 | } 62 | } 63 | 64 | private fun Device.getModulePath(moduleId: ModuleId): Path { 65 | val digest = MessageDigest.getInstance("SHA-1") 66 | val hashBytes = digest.digest(moduleId.toByteArray()) 67 | val safeName = hashBytes.joinToString("") { "%02x".format(it) } 68 | return modulesPath.resolve(safeName) 69 | } 70 | 71 | suspend fun read(caller: Device, target: Device, moduleId: ModuleId): Module.Read { 72 | log(TAG, VERBOSE) { "read(${caller.id}, ${target.id}, $moduleId) reading..." } 73 | val modulePath = target.getModulePath(moduleId) 74 | 75 | return target.sync.withLock { 76 | if (!modulePath.exists()) { 77 | Module.Read() 78 | } else { 79 | Module.Read( 80 | modifiedAt = modulePath.resolve(BLOB_FILENAME).getLastModifiedTime().toInstant(), 81 | payload = modulePath.resolve(BLOB_FILENAME).readBytes(), 82 | ) 83 | } 84 | }.also { 85 | log(TAG, VERBOSE) { "read(${caller.id}, ${target.id}, $moduleId) ${it.size}B read" } 86 | } 87 | } 88 | 89 | suspend fun write(caller: Device, target: Device, moduleId: ModuleId, write: Module.Write) { 90 | log(TAG, VERBOSE) { "write(${caller.id}, ${target.id}, $moduleId) payload is ${write.size}B ..." } 91 | val modulePath = target.getModulePath(moduleId) 92 | val info = Module.Info( 93 | id = moduleId, 94 | source = caller.id, 95 | ) 96 | target.sync.withLock { 97 | modulePath.apply { 98 | if (!parent.exists()) parent.createDirectory() // modules dir 99 | if (!exists()) createDirectory() // specific module dir 100 | resolve(BLOB_FILENAME).writeBytes(write.payload) 101 | resolve(META_FILENAME).writeText(serializer.encodeToString(info)) 102 | } 103 | } 104 | log(TAG, VERBOSE) { "write(${caller.id}, ${target.id}, $moduleId) payload written" } 105 | } 106 | 107 | suspend fun delete(caller: Device, target: Device, moduleId: ModuleId) { 108 | log(TAG, VERBOSE) { "delete(${caller.id}, ${target.id}, $moduleId): Deleting module..." } 109 | val modulePath = target.getModulePath(moduleId) 110 | 111 | target.sync.withLock { 112 | if (!modulePath.exists()) { 113 | log(TAG) { "delete(${caller.id}, ${target.id}, $moduleId): Module didn't exist" } 114 | return 115 | } 116 | val info: Module.Info = serializer.decodeFromString(modulePath.resolve(META_FILENAME).readText()) 117 | modulePath.deleteRecursively() 118 | log(TAG) { "delete(${caller.id}, ${target.id}, $moduleId): Module deleted $info" } 119 | } 120 | } 121 | 122 | suspend fun clear(caller: Device, targets: Set) { 123 | log(TAG, VERBOSE) { "clear(${caller.id}}): Wiping ${targets.size} targets" } 124 | targets.forEach { target -> 125 | target.sync.withLock { 126 | if (target.modulesPath.exists()) { 127 | log(TAG, VERBOSE) { "clear(${caller.id}}): Wiping ${target.id}" } 128 | target.modulesPath.deleteRecursively() 129 | } else { 130 | log(TAG, VERBOSE) { "clear(${caller.id}}): No data stored for ${target.id}" } 131 | } 132 | } 133 | } 134 | } 135 | 136 | private val Device.modulesPath: Path 137 | get() = path.resolve(MODULES_DIR) 138 | 139 | companion object { 140 | private const val MODULES_DIR = "modules" 141 | private const val META_FILENAME = "module.json" 142 | private const val BLOB_FILENAME = "payload.blob" 143 | private val TAG = logTag("Module", "Repo") 144 | } 145 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/account/AccountRepo.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalPathApi::class) 2 | 3 | package eu.darken.octi.kserver.account 4 | 5 | import eu.darken.octi.kserver.App 6 | import eu.darken.octi.kserver.common.AppScope 7 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.* 8 | import eu.darken.octi.kserver.common.debug.logging.asLog 9 | import eu.darken.octi.kserver.common.debug.logging.log 10 | import eu.darken.octi.kserver.common.debug.logging.logTag 11 | import eu.darken.octi.kserver.device.DeviceRepo 12 | import kotlinx.coroutines.* 13 | import kotlinx.coroutines.sync.Mutex 14 | import kotlinx.coroutines.sync.withLock 15 | import kotlinx.serialization.encodeToString 16 | import kotlinx.serialization.json.Json 17 | import java.io.IOException 18 | import java.nio.file.Files 19 | import java.time.Duration 20 | import java.time.Instant 21 | import java.util.* 22 | import java.util.concurrent.ConcurrentHashMap 23 | import javax.inject.Inject 24 | import javax.inject.Singleton 25 | import kotlin.io.path.* 26 | 27 | @OptIn(ExperimentalPathApi::class) 28 | @Singleton 29 | class AccountRepo @Inject constructor( 30 | private val config: App.Config, 31 | private val serializer: Json, 32 | private val appScope: AppScope, 33 | ) { 34 | private val accountsPath = config.dataPath.resolve("accounts").apply { 35 | if (!exists()) { 36 | Files.createDirectories(this) 37 | log(TAG) { "Created $this" } 38 | } 39 | } 40 | private val accounts = ConcurrentHashMap() 41 | private val mutex = Mutex() 42 | 43 | init { 44 | runBlocking { 45 | Files.newDirectoryStream(accountsPath).use { stream -> 46 | stream.asSequence() 47 | .filter { 48 | log(TAG, VERBOSE) { "Reading $it" } 49 | if (it.isDirectory()) { 50 | true 51 | } else { 52 | log(TAG, WARN) { "Not a directory: $it" } 53 | false 54 | } 55 | } 56 | .map { it to it.resolve(ACC_FILENAME) } 57 | .forEach { (accDir, configPath) -> 58 | if (configPath.exists()) { 59 | val accData = try { 60 | serializer.decodeFromString(configPath.readText()) 61 | } catch (e: IOException) { 62 | log(TAG, ERROR) { "Failed to read $accDir: ${e.asLog()}" } 63 | return@forEach 64 | } 65 | log(TAG) { "Account info loaded: $accData" } 66 | accounts[accData.id] = Account( 67 | data = accData, 68 | path = accDir, 69 | ) 70 | } else { 71 | log(TAG, WARN) { "Missing account config for $accDir, cleaning up..." } 72 | accDir.deleteRecursively() 73 | } 74 | } 75 | } 76 | 77 | log(TAG, INFO) { "${accounts.size} accounts loaded into memory" } 78 | } 79 | 80 | appScope.launch(Dispatchers.IO) { 81 | delay((config.accountGCInterval.toMillis() / 10)) 82 | while (currentCoroutineContext().isActive) { 83 | val now = Instant.now() 84 | log(TAG) { "Checking for orphaned accounts..." } 85 | val orphaned = accounts.filterValues { 86 | // We don't lock the mutex, skip accounts that are currently in creation 87 | if (Duration.between(it.createdAt, now) < config.accountGCInterval) { 88 | return@filterValues false 89 | } 90 | it.path.resolve(DeviceRepo.DEVICES_DIR).listDirectoryEntries().isEmpty() 91 | } 92 | if (orphaned.isNotEmpty()) { 93 | log(TAG, INFO) { "Deleting ${orphaned.size} accounts without devices" } 94 | deleteAccounts(orphaned.map { it.value.id }) 95 | } 96 | delay(config.accountGCInterval.toMillis()) 97 | } 98 | } 99 | } 100 | 101 | suspend fun createAccount(): Account = mutex.withLock { 102 | log(TAG) { "createAccount(): Creating account..." } 103 | 104 | val accData = Account.Data() 105 | if (accounts.containsKey(accData.id)) throw IllegalStateException("Account ID collision???") 106 | 107 | val account = Account( 108 | data = accData, 109 | path = accountsPath.resolve(accData.id.toString()), 110 | ) 111 | 112 | account.path.run { 113 | if (!exists()) { 114 | createDirectory() 115 | log(TAG) { "createAccount(): Dirs created: $this" } 116 | } 117 | resolve(ACC_FILENAME).writeText(serializer.encodeToString(accData)) 118 | log(TAG, VERBOSE) { "createAccount(): Account written to $this" } 119 | } 120 | accounts[accData.id] = account 121 | account.also { log(TAG) { "createAccount(): Account created: $accData" } } 122 | } 123 | 124 | suspend fun getAccount(id: AccountId): Account? { 125 | return accounts[id].also { log(TAG, VERBOSE) { "getAccount($id) -> $it" } } 126 | } 127 | 128 | suspend fun getAccounts(): List { 129 | return accounts.values.toList() 130 | } 131 | 132 | suspend fun deleteAccounts(ids: Collection) { 133 | log(TAG) { "deleteAccount($ids)..." } 134 | val accounts = mutex.withLock { 135 | ids.map { accounts.remove(it) ?: throw IllegalArgumentException("Unknown account") } 136 | } 137 | accounts.forEach { account -> 138 | val deleted = try { 139 | account.path.deleteRecursively() 140 | true 141 | } catch (e: IOException) { 142 | log(TAG, ERROR) { "Failed to delete account directory: $account: ${e.asLog()}" } 143 | false 144 | } 145 | if (!deleted) { 146 | val accountConfig = account.path.resolve(ACC_FILENAME) 147 | if (accountConfig.deleteIfExists()) { 148 | log(TAG, WARN) { "Deleted account file, will clean up on next restart." } 149 | } 150 | } 151 | log(TAG) { "deleteAccount($ids): Account deleted: $account" } 152 | } 153 | } 154 | 155 | companion object { 156 | private const val ACC_FILENAME = "account.json" 157 | private val TAG = logTag("Account", "Repo") 158 | } 159 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/account/share/ShareRepo.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.account.share 2 | 3 | import eu.darken.octi.kserver.App 4 | import eu.darken.octi.kserver.account.Account 5 | import eu.darken.octi.kserver.account.AccountId 6 | import eu.darken.octi.kserver.account.AccountRepo 7 | import eu.darken.octi.kserver.common.AppScope 8 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.* 9 | import eu.darken.octi.kserver.common.debug.logging.asLog 10 | import eu.darken.octi.kserver.common.debug.logging.log 11 | import eu.darken.octi.kserver.common.debug.logging.logTag 12 | import kotlinx.coroutines.* 13 | import kotlinx.coroutines.sync.Mutex 14 | import kotlinx.coroutines.sync.withLock 15 | import kotlinx.serialization.encodeToString 16 | import kotlinx.serialization.json.Json 17 | import java.io.IOException 18 | import java.nio.file.Files 19 | import java.time.Duration 20 | import java.time.Instant 21 | import java.util.concurrent.ConcurrentHashMap 22 | import javax.inject.Inject 23 | import javax.inject.Singleton 24 | import kotlin.io.path.* 25 | import kotlin.time.Duration.Companion.minutes 26 | import kotlin.time.Duration.Companion.seconds 27 | 28 | 29 | @Singleton 30 | class ShareRepo @Inject constructor( 31 | appScope: AppScope, 32 | private val serializer: Json, 33 | private val accountsRepo: AccountRepo, 34 | private val config: App.Config, 35 | ) { 36 | 37 | private val shares = ConcurrentHashMap() 38 | private val mutex = Mutex() 39 | 40 | init { 41 | runBlocking { 42 | accountsRepo.getAccounts() 43 | .asSequence() 44 | .mapNotNull { account -> 45 | try { 46 | account.path.resolve(SHARES_DIR) 47 | .takeIf { it.exists() } 48 | ?.listDirectoryEntries() 49 | ?.takeIf { it.isNotEmpty() } 50 | ?.map { account to it } 51 | ?.toList() 52 | ?.also { log(TAG) { "Loading ${it.size} shares from account ${account.id}" } } 53 | } catch (e: IOException) { 54 | log(TAG, ERROR) { "Failed to list shares for $account\n${e.asLog()}" } 55 | null 56 | } 57 | } 58 | .flatten() 59 | .forEach { (account, path) -> 60 | val data = try { 61 | serializer.decodeFromString(path.readText()) 62 | } catch (e: IOException) { 63 | log(TAG, ERROR) { "Failed to read share $path: ${e.asLog()}" } 64 | return@forEach 65 | } 66 | log(TAG) { "Share info loaded: $data" } 67 | shares[data.id] = Share( 68 | data = data, 69 | path = path, 70 | accountId = account.id, 71 | ) 72 | } 73 | log(TAG, INFO) { "${shares.size} shares loaded into memory" } 74 | } 75 | 76 | appScope.launch(Dispatchers.IO) { 77 | val expirationTime = config.shareExpiration 78 | 79 | while (currentCoroutineContext().isActive) { 80 | log(TAG) { "Checking for expired shares..." } 81 | val now = Instant.now() 82 | val expiredShares = shares.filterValues { share -> 83 | Duration.between(share.createdAt, now) > expirationTime 84 | } 85 | if (expiredShares.isNotEmpty()) { 86 | log(TAG, INFO) { "Deleting ${expiredShares.size} expired shares" } 87 | removeShares(expiredShares.map { it.value.id }) 88 | } 89 | delay(expirationTime.toMillis() / 2) 90 | } 91 | } 92 | 93 | appScope.launch(Dispatchers.IO) { 94 | delay(15.seconds) 95 | while (currentCoroutineContext().isActive) { 96 | log(TAG) { "Checking for stale share data..." } 97 | val staleShares = shares.values.filter { !it.path.exists() } 98 | if (staleShares.isNotEmpty()) { 99 | log(TAG, INFO) { "Removing ${staleShares.size} stale shares" } 100 | removeShares(staleShares.map { it.id }) 101 | } 102 | delay(10.minutes) 103 | } 104 | } 105 | } 106 | 107 | suspend fun createShare(account: Account): Share = mutex.withLock { 108 | log(TAG) { "createShare(${account.id}): Creating share..." } 109 | 110 | val data = Share.Data() 111 | val share = Share( 112 | data = data, 113 | path = account.path.resolve("shares/${data.id}.json"), 114 | accountId = account.id, 115 | ) 116 | if (shares.containsKey(share.id)) throw IllegalStateException("Share ID collision???") 117 | 118 | share.path.run { 119 | if (!parent.exists()) { 120 | Files.createDirectory(parent) 121 | log(TAG) { "createShare(${account.id}): Parent created for $this" } 122 | } 123 | writeText(serializer.encodeToString(share.data)) 124 | log(TAG, VERBOSE) { "createShare(${account.id}): Written to $this" } 125 | } 126 | shares[share.id] = share 127 | share.also { log(TAG) { "createShare(${account.id}): Share created created: $it" } } 128 | } 129 | 130 | suspend fun getShare(code: ShareCode): Share? { 131 | log(TAG, VERBOSE) { "getShare($code)" } 132 | return shares.values.find { it.code == code } 133 | } 134 | 135 | suspend fun consumeShare(code: ShareCode): Boolean { 136 | log(TAG, VERBOSE) { "consumeShare($code)" } 137 | val share = getShare(code) ?: return false 138 | removeShares(listOf(share.id)) 139 | log(TAG) { "Share was consumed: $share" } 140 | return true 141 | } 142 | 143 | suspend fun removeShares(ids: Collection) = mutex.withLock { 144 | log(TAG) { "removeShares($ids)..." } 145 | val toRemove = ids.mapNotNull { shares.remove(it) } 146 | log(TAG) { "removeShares($ids): Deleting ${toRemove.size} shares" } 147 | toRemove.forEach { 148 | it.path.deleteIfExists() 149 | log(TAG, VERBOSE) { "removeShares($ids): Share deleted $it" } 150 | } 151 | } 152 | 153 | suspend fun removeSharesForAccount(accountId: AccountId) { 154 | log(TAG) { "removeSharesForAccount($accountId)..." } 155 | val toRemove = shares.filter { it.value.accountId == accountId }.map { it.key } 156 | log(TAG) { "removeSharesForAccount($accountId): Deleting ${toRemove.size} shares" } 157 | removeShares(toRemove) 158 | } 159 | 160 | companion object { 161 | private const val SHARES_DIR = "shares" 162 | private val TAG = logTag("Account", "Share", "Repo") 163 | } 164 | } -------------------------------------------------------------------------------- /src/main/kotlin/eu/darken/octi/kserver/device/DeviceRepo.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.device 2 | 3 | import eu.darken.octi.kserver.App 4 | import eu.darken.octi.kserver.account.Account 5 | import eu.darken.octi.kserver.account.AccountId 6 | import eu.darken.octi.kserver.account.AccountRepo 7 | import eu.darken.octi.kserver.common.AppScope 8 | import eu.darken.octi.kserver.common.debug.logging.Logging.Priority.* 9 | import eu.darken.octi.kserver.common.debug.logging.asLog 10 | import eu.darken.octi.kserver.common.debug.logging.log 11 | import eu.darken.octi.kserver.common.debug.logging.logTag 12 | import kotlinx.coroutines.* 13 | import kotlinx.coroutines.sync.Mutex 14 | import kotlinx.coroutines.sync.withLock 15 | import kotlinx.serialization.encodeToString 16 | import kotlinx.serialization.json.Json 17 | import java.io.IOException 18 | import java.time.Duration 19 | import java.time.Instant 20 | import java.util.concurrent.ConcurrentHashMap 21 | import javax.inject.Inject 22 | import javax.inject.Singleton 23 | import kotlin.io.path.* 24 | 25 | @OptIn(ExperimentalPathApi::class) 26 | @Singleton 27 | class DeviceRepo @Inject constructor( 28 | appScope: AppScope, 29 | private val config: App.Config, 30 | private val serializer: Json, 31 | private val accountsRepo: AccountRepo, 32 | ) { 33 | 34 | private val devices = ConcurrentHashMap() 35 | private val mutex = Mutex() 36 | 37 | init { 38 | runBlocking { 39 | accountsRepo.getAccounts() 40 | .asSequence() 41 | .mapNotNull { account -> 42 | try { 43 | account.path.resolve(DEVICES_DIR) 44 | .listDirectoryEntries() 45 | .map { account to it } 46 | .also { log(TAG, VERBOSE) { "Listing ${it.size} device(s) for account ${account.id}" } } 47 | } catch (e: IOException) { 48 | log(TAG, ERROR) { "Failed to list devices for $account" } 49 | null 50 | } 51 | } 52 | .flatten() 53 | .forEach { (account, deviceDir) -> 54 | log(TAG, VERBOSE) { "Reading $deviceDir" } 55 | val deviceData = try { 56 | serializer.decodeFromString(deviceDir.resolve(DEVICE_FILENAME).readText()) 57 | } catch (e: IOException) { 58 | log(TAG, ERROR) { "Failed to read $deviceDir: ${e.asLog()}" } 59 | return@forEach 60 | } 61 | log(TAG) { "Device info loaded: $deviceData" } 62 | devices[deviceData.id] = Device( 63 | data = deviceData, 64 | path = deviceDir, 65 | accountId = account.id, 66 | ) 67 | } 68 | log(TAG, INFO) { "${devices.size} devices loaded into memory" } 69 | } 70 | appScope.launch(Dispatchers.IO) { 71 | delay(config.deviceGCInterval.toMillis() / 10) 72 | while (currentCoroutineContext().isActive) { 73 | val now = Instant.now() 74 | log(TAG) { "Checking for stale devices..." } 75 | devices.forEach { (id, device) -> 76 | if (Duration.between(device.lastSeen, now) < config.deviceExpiration) return@forEach 77 | log(TAG, WARN) { "Deleting stale device $id" } 78 | deleteDevice(id) 79 | } 80 | delay(config.deviceGCInterval.toMillis()) 81 | } 82 | } 83 | } 84 | 85 | suspend fun allDevices(): Collection = devices.values.toList() 86 | 87 | private fun Device.writeDevice() { 88 | path.resolve(DEVICE_FILENAME).writeText(serializer.encodeToString(data)) 89 | } 90 | 91 | suspend fun createDevice( 92 | account: Account, 93 | deviceId: DeviceId, 94 | version: String?, 95 | ): Device { 96 | val data = Device.Data( 97 | id = deviceId, 98 | version = version, 99 | ) 100 | val device = Device( 101 | data = data, 102 | accountId = account.id, 103 | path = account.path.resolve("$DEVICES_DIR/${data.id}") 104 | ) 105 | mutex.withLock { 106 | if (devices[device.id] != null) throw IllegalStateException("Device already exists: ${device.id}") 107 | 108 | device.path.run { 109 | if (!parent.exists()) { 110 | parent.createDirectory() 111 | log(TAG) { "Created parent dir for $this" } 112 | } 113 | if (!exists()) { 114 | createDirectory() 115 | log(TAG) { "Created dir for $this" } 116 | } 117 | } 118 | device.writeDevice() 119 | log(TAG, VERBOSE) { "Device written: $this" } 120 | devices[device.id] = device 121 | } 122 | log(TAG) { "createDevice(): Device created $device" } 123 | return device 124 | } 125 | 126 | suspend fun getDevice(deviceId: DeviceId): Device? { 127 | return devices[deviceId] 128 | } 129 | 130 | suspend fun getDevices(accountId: AccountId): Collection { 131 | val accountDevices = mutableSetOf() 132 | devices.forEach { 133 | if (it.value.accountId == accountId) { 134 | accountDevices.add(it.value) 135 | } 136 | } 137 | return accountDevices 138 | } 139 | 140 | suspend fun deleteDevice(deviceId: DeviceId) { 141 | log(TAG, VERBOSE) { "deleteDevice($deviceId)..." } 142 | val toDelete = mutex.withLock { 143 | devices.remove(deviceId) ?: throw IllegalArgumentException("$deviceId not found") 144 | } 145 | toDelete.sync.withLock { 146 | toDelete.path.deleteRecursively() 147 | log(TAG) { "deleteDevice($deviceId): Device deleted: $toDelete" } 148 | } 149 | } 150 | 151 | suspend fun deleteDevices(accountId: AccountId) { 152 | log(TAG, VERBOSE) { "deleteDevices($accountId)..." } 153 | val toDelete = mutex.withLock { 154 | devices 155 | .filter { it.value.accountId == accountId } 156 | .map { devices.remove(it.key)!! } 157 | } 158 | log(TAG) { "deleteDevices($accountId): Deleting ${toDelete.size} devices" } 159 | toDelete.forEach { device -> 160 | device.sync.withLock { 161 | device.path.deleteRecursively() 162 | log(TAG) { "deleteDevices($accountId): Device deleted: $device" } 163 | } 164 | } 165 | } 166 | 167 | suspend fun updateDevice(id: DeviceId, action: (Device.Data) -> Device.Data) { 168 | log(TAG, VERBOSE) { "updateDevice($id)..." } 169 | val device = devices.values.find { it.id == id } ?: return 170 | device.sync.withLock { 171 | val newDevice = device.copy(data = action(device.data)) 172 | newDevice.writeDevice() 173 | devices[id] = newDevice 174 | } 175 | } 176 | 177 | companion object { 178 | const val DEVICES_DIR = "devices" 179 | private const val DEVICE_FILENAME = "device.json" 180 | private val TAG = logTag("Device", "Repo") 181 | } 182 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Use "xargs" to parse quoted args. 209 | # 210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 211 | # 212 | # In Bash we could simply go: 213 | # 214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 215 | # set -- "${ARGS[@]}" "$@" 216 | # 217 | # but POSIX shell has neither arrays nor command substitution, so instead we 218 | # post-process each arg (as a line of input to sed) to backslash-escape any 219 | # character that might be a shell metacharacter, then use eval to reverse 220 | # that process (while maintaining the separation between arguments), and wrap 221 | # the whole thing up as a single "set" statement. 222 | # 223 | # This will of course break if any of these variables contains a newline or 224 | # an unmatched quote. 225 | # 226 | 227 | eval "set -- $( 228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 229 | xargs -n1 | 230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 231 | tr '\n' ' ' 232 | )" '"$@"' 233 | 234 | exec "$JAVACMD" "$@" 235 | -------------------------------------------------------------------------------- /src/test/kotlin/eu/darken/octi/kserver/module/ModuleFlowTest.kt: -------------------------------------------------------------------------------- 1 | package eu.darken.octi.kserver.module 2 | 3 | import eu.darken.octi.* 4 | import io.kotest.matchers.shouldBe 5 | import io.kotest.matchers.shouldNotBe 6 | import io.ktor.client.request.* 7 | import io.ktor.client.statement.* 8 | import io.ktor.http.* 9 | import org.junit.jupiter.api.Test 10 | import java.time.ZonedDateTime 11 | import java.time.format.DateTimeFormatter 12 | import java.util.* 13 | 14 | class ModuleFlowTest : TestRunner() { 15 | 16 | private val endpointModules = "/v1/module" 17 | 18 | @Test 19 | fun `module id format needs to match`() = runTest2 { 20 | val creds = createDevice() 21 | readModuleRaw(creds, "123").apply { 22 | status shouldBe HttpStatusCode.BadRequest 23 | bodyAsText() shouldBe "Invalid moduleId" 24 | } 25 | readModuleRaw(creds, "abc...").apply { 26 | status shouldBe HttpStatusCode.BadRequest 27 | bodyAsText() shouldBe "Invalid moduleId" 28 | } 29 | readModuleRaw(creds, "eu.darken.octi.module.core.meta").apply { 30 | status shouldBe HttpStatusCode.NoContent 31 | } 32 | } 33 | 34 | @Test 35 | fun `module ids have size limits`() = runTest2 { 36 | val creds = createDevice() 37 | readModuleRaw(creds, "a".repeat(1025)).apply { 38 | status shouldBe HttpStatusCode.BadRequest 39 | bodyAsText() shouldBe "Invalid moduleId" 40 | } 41 | readModuleRaw(creds, "a".repeat(1024)).apply { 42 | status shouldBe HttpStatusCode.NoContent 43 | } 44 | } 45 | 46 | @Test 47 | fun `get module requires target device id`() = runTest2 { 48 | val creds = createDevice() 49 | http.get { 50 | url { 51 | takeFrom("$endpointModules/abc") 52 | } 53 | addCredentials(creds) 54 | }.apply { 55 | status shouldBe HttpStatusCode.BadRequest 56 | bodyAsText() shouldBe "Target device id not supplied" 57 | } 58 | } 59 | 60 | @Test 61 | fun `get module - target device needs to exist`() = runTest2 { 62 | val creds = createDevice() 63 | readModuleRaw(creds, "abc", deviceId = UUID.randomUUID()).apply { 64 | status shouldBe HttpStatusCode.NotFound 65 | bodyAsText() shouldBe "Target device not found" 66 | } 67 | } 68 | 69 | @Test 70 | fun `get module - target device needs to be on the same account`() = runTest2 { 71 | val creds1 = createDevice() 72 | val creds2 = createDevice() 73 | readModuleRaw(creds1, "abc", creds2.deviceId).apply { 74 | status shouldBe HttpStatusCode.Unauthorized 75 | bodyAsText() shouldBe "Devices don't share the same account" 76 | } 77 | } 78 | 79 | @Test 80 | fun `get module - no data`() = runTest2 { 81 | val creds = createDevice() 82 | readModuleRaw(creds, "abc").apply { 83 | status shouldBe HttpStatusCode.NoContent 84 | bodyAsText() shouldBe "" 85 | } 86 | } 87 | 88 | @Test 89 | fun `write and read module`() = runTest2 { 90 | val creds = createDevice() 91 | val testData = UUID.randomUUID().toString() 92 | writeModule(creds, "abc", data = testData).apply { 93 | status shouldBe HttpStatusCode.OK 94 | bodyAsText() shouldBe "" 95 | } 96 | readModuleRaw(creds, "abc").apply { 97 | status shouldBe HttpStatusCode.OK 98 | headers["X-Modified-At"]!!.let { 99 | ZonedDateTime.parse(it, DateTimeFormatter.RFC_1123_DATE_TIME) 100 | } shouldNotBe null 101 | bodyAsText() shouldBe testData 102 | } 103 | } 104 | 105 | @Test 106 | fun `get data from other devices modules`() = runTest2 { 107 | val creds1 = createDevice() 108 | writeModule(creds1, "abc", creds1.deviceId, "test") 109 | val creds2 = createDevice(creds1) 110 | readModuleRaw(creds2, "abc", creds1.deviceId).bodyAsText() shouldBe "test" 111 | } 112 | 113 | @Test 114 | fun `set module - target id is required`() = runTest2 { 115 | val creds = createDevice() 116 | writeModule(creds, "abc", deviceId = null, "test").apply { 117 | status shouldBe HttpStatusCode.BadRequest 118 | bodyAsText() shouldBe "Target device id not supplied" 119 | } 120 | } 121 | 122 | @Test 123 | fun `set module - target device needs to exist`() = runTest2 { 124 | val creds = createDevice() 125 | writeModule(creds, "abc", UUID.randomUUID(), "test").apply { 126 | status shouldBe HttpStatusCode.NotFound 127 | bodyAsText() shouldBe "Target device not found" 128 | } 129 | } 130 | 131 | @Test 132 | fun `set module - target device needs to be on the same account`() = runTest2 { 133 | val creds1 = createDevice() 134 | val creds2 = createDevice() 135 | writeModule(creds2, "abc", creds1.deviceId, "test").apply { 136 | status shouldBe HttpStatusCode.Unauthorized 137 | bodyAsText() shouldBe "Devices don't share the same account" 138 | } 139 | writeModule(creds2, "abc", creds1.deviceId, "test") 140 | } 141 | 142 | @Test 143 | fun `set module - overwrite data`() = runTest2 { 144 | val creds = createDevice() 145 | writeModule(creds, "abc", data = "test1") 146 | readModuleRaw(creds, "abc").bodyAsText() shouldBe "test1" 147 | writeModule(creds, "abc", data = "test2") 148 | readModuleRaw(creds, "abc").bodyAsText() shouldBe "test2" 149 | } 150 | 151 | @Test 152 | fun `set module - can overwrite other devices data`() = runTest2 { 153 | val creds1 = createDevice() 154 | val creds2 = createDevice(creds1) 155 | writeModule(creds1, "abc", data = "test1") 156 | writeModule(creds2, "abc", creds1.deviceId, data = "test2") 157 | readModule(creds1, "abc") shouldBe "test2" 158 | } 159 | 160 | @Test 161 | fun `set module - payload limit`() = runTest2 { 162 | val creds = createDevice() 163 | writeModule(creds, "abc", data = "a".repeat((128 * 1024) + 1)).apply { 164 | status shouldBe HttpStatusCode.PayloadTooLarge 165 | } 166 | writeModule(creds, "abc", data = "a".repeat(128 * 1024)).apply { 167 | status shouldBe HttpStatusCode.OK 168 | } 169 | } 170 | 171 | @Test 172 | fun `delete module - target id is required`() = runTest2 { 173 | val creds = createDevice() 174 | deleteModuleRaw(creds, "abc", deviceId = null).apply { 175 | status shouldBe HttpStatusCode.BadRequest 176 | bodyAsText() shouldBe "Target device id not supplied" 177 | } 178 | } 179 | 180 | @Test 181 | fun `delete module - target device needs to exist`() = runTest2 { 182 | val creds = createDevice() 183 | deleteModuleRaw(creds, "abc", deviceId = UUID.randomUUID()).apply { 184 | status shouldBe HttpStatusCode.NotFound 185 | bodyAsText() shouldBe "Target device not found" 186 | } 187 | } 188 | 189 | @Test 190 | fun `delete module - target device needs to be on the same account`() = runTest2 { 191 | val creds1 = createDevice() 192 | val creds2 = createDevice() 193 | deleteModuleRaw(creds2, "abc", creds1.deviceId).apply { 194 | status shouldBe HttpStatusCode.Unauthorized 195 | bodyAsText() shouldBe "Devices don't share the same account" 196 | } 197 | } 198 | 199 | @Test 200 | fun `delete module`() = runTest2 { 201 | val creds = createDevice() 202 | writeModule(creds, "abc", data = "test") 203 | readModuleRaw(creds, "abc").bodyAsText() shouldBe "test" 204 | deleteModuleRaw(creds, "abc") 205 | readModuleRaw(creds, "abc").bodyAsText() shouldBe "" 206 | } 207 | 208 | @Test 209 | fun `delete from other devices`() = runTest2 { 210 | val creds1 = createDevice() 211 | val creds2 = createDevice(creds1) 212 | val creds3 = createDevice(creds1) 213 | writeModule(creds1, "abc", creds1.deviceId, data = "test") 214 | writeModule(creds1, "abc", creds2.deviceId, data = "test") 215 | writeModule(creds1, "abc", creds3.deviceId, data = "test") 216 | readModule(creds1, "abc") shouldBe "test" 217 | readModule(creds2, "abc") shouldBe "test" 218 | readModule(creds3, "abc") shouldBe "test" 219 | deleteModuleRaw(creds1, "abc", creds1.deviceId).apply { 220 | status shouldBe HttpStatusCode.OK 221 | } 222 | deleteModuleRaw(creds1, "abc", creds2.deviceId).apply { 223 | status shouldBe HttpStatusCode.OK 224 | } 225 | deleteModuleRaw(creds1, "abc", creds3.deviceId).apply { 226 | status shouldBe HttpStatusCode.OK 227 | } 228 | readModule(creds1, "abc") shouldBe "" 229 | readModule(creds2, "abc") shouldBe "" 230 | readModule(creds3, "abc") shouldBe "" 231 | } 232 | } --------------------------------------------------------------------------------