├── 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 |
5 |
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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
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 | [](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