From 2fcba80f3a1174007531f1c3e9403fb953f6ef6f Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Sun, 20 Dec 2020 13:04:38 -0600 Subject: [PATCH] Server: Implement temporary links --- server/build.gradle | 3 +- .../org/isoron/uhabits/sync/app/LinkModule.kt | 55 +++++++++++++ .../uhabits/sync/app/SyncApplication.kt | 1 + .../src/org/isoron/uhabits/sync/links/Link.kt | 37 +++++++++ .../isoron/uhabits/sync/links/LinkManager.kt | 49 +++++++++++ .../uhabits/sync/server/AbstractSyncServer.kt | 19 +++++ .../sync/server/RepositorySyncServer.kt | 20 +++-- .../org/isoron/uhabits/sync/utils/String.kt | 31 +++++++ .../uhabits/sync/app/LinksModuleTest.kt | 81 +++++++++++++++++++ .../uhabits/sync/links/LinkManagerTest.kt | 44 ++++++++++ .../{ => server}/RepositorySyncServerTest.kt | 4 +- 11 files changed, 333 insertions(+), 11 deletions(-) create mode 100644 server/src/org/isoron/uhabits/sync/app/LinkModule.kt create mode 100644 server/src/org/isoron/uhabits/sync/links/Link.kt create mode 100644 server/src/org/isoron/uhabits/sync/links/LinkManager.kt create mode 100644 server/src/org/isoron/uhabits/sync/utils/String.kt create mode 100644 server/test/org/isoron/uhabits/sync/app/LinksModuleTest.kt create mode 100644 server/test/org/isoron/uhabits/sync/links/LinkManagerTest.kt rename server/test/org/isoron/uhabits/sync/{ => server}/RepositorySyncServerTest.kt (96%) diff --git a/server/build.gradle b/server/build.gradle index b7f118308..2de1ec780 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -80,5 +80,6 @@ dockerRun { name = 'uhabits-server' image "uhabits-server:$version" ports '8080:8080' - arguments '--restart=always' + daemonize false + clean true } \ No newline at end of file diff --git a/server/src/org/isoron/uhabits/sync/app/LinkModule.kt b/server/src/org/isoron/uhabits/sync/app/LinkModule.kt new file mode 100644 index 000000000..70451729a --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/app/LinkModule.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync.app + +import io.ktor.application.* +import io.ktor.http.* +import io.ktor.request.* +import io.ktor.response.* +import io.ktor.routing.* +import org.isoron.uhabits.sync.* + +data class LinkRegisterRequestData( + val syncKey: String, +) +fun LinkRegisterRequestData.toJson(): String = defaultMapper.writeValueAsString(this) + +fun Routing.links(app: SyncApplication) { + post("/links") { + try { + val data = call.receive() + val link = app.server.registerLink(data.syncKey) + call.respond(HttpStatusCode.OK, link) + } catch (e: ServiceUnavailable) { + call.respond(HttpStatusCode.ServiceUnavailable) + } + } + get("/links/{id}") { + try { + val id = call.parameters["id"]!! + val link = app.server.getLink(id) + call.respond(HttpStatusCode.OK, link) + } catch (e: ServiceUnavailable) { + call.respond(HttpStatusCode.ServiceUnavailable) + } catch (e: KeyNotFoundException) { + call.respond(HttpStatusCode.NotFound) + } + } +} diff --git a/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt b/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt index 16fc71106..c07522e1f 100644 --- a/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt +++ b/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt @@ -46,6 +46,7 @@ class SyncApplication( routing { registration(this@SyncApplication) storage(this@SyncApplication) + links(this@SyncApplication) } } } diff --git a/server/src/org/isoron/uhabits/sync/links/Link.kt b/server/src/org/isoron/uhabits/sync/links/Link.kt new file mode 100644 index 000000000..9dd82b8fa --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/links/Link.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync.links + +import org.isoron.uhabits.sync.* +import java.time.* + +/** + * A Link maps a public URL (such as https://sync.loophabits.org/links/B752A6) + * to a synchronization key. They are used to transfer sync keys between devices + * without ever exposing the original sync key. Unlike sync keys, links expire + * after a few minutes. + */ +data class Link( + val id: String, + val syncKey: String, + val createdAt: Long, +) + +fun Link.toJson(): String = defaultMapper.writeValueAsString(this) diff --git a/server/src/org/isoron/uhabits/sync/links/LinkManager.kt b/server/src/org/isoron/uhabits/sync/links/LinkManager.kt new file mode 100644 index 000000000..4b0bd3240 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/links/LinkManager.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync.links + +import org.isoron.uhabits.sync.* +import org.isoron.uhabits.sync.utils.* + +class LinkManager( + private val timeoutInMillis: Long = 900_000, +) { + private val links = HashMap() + + fun register(syncKey: String): Link { + val link = Link( + id = randomString(64), + syncKey = syncKey, + createdAt = System.currentTimeMillis(), + ) + links[link.id] = link + return link + } + + fun get(id: String): Link { + val link = links[id] ?: throw KeyNotFoundException() + val ageInMillis = System.currentTimeMillis() - link.createdAt + if (ageInMillis > timeoutInMillis) { + links.remove(id) + throw KeyNotFoundException() + } + return link + } +} \ No newline at end of file diff --git a/server/src/org/isoron/uhabits/sync/server/AbstractSyncServer.kt b/server/src/org/isoron/uhabits/sync/server/AbstractSyncServer.kt index 3aac2ee02..150d27246 100644 --- a/server/src/org/isoron/uhabits/sync/server/AbstractSyncServer.kt +++ b/server/src/org/isoron/uhabits/sync/server/AbstractSyncServer.kt @@ -20,6 +20,7 @@ package org.isoron.uhabits.sync.server import org.isoron.uhabits.sync.* +import org.isoron.uhabits.sync.links.* interface AbstractSyncServer { /** @@ -59,4 +60,22 @@ interface AbstractSyncServer { * to insufficient server resources or network problems. */ suspend fun getDataVersion(key: String): Long + + /** + * Registers a new temporary link (mapping to the given sync key) and returns it. + * + * @throws ServiceUnavailable If the link cannot be generated at this time due to + * insufficient server resources. + */ + suspend fun registerLink(syncKey: String): Link + + /** + * Retrieves the syncKey associated with the given link id. + * + * @throws ServiceUnavailable If the link cannot be resolved at this time due to + * insufficient server resources. + * @throws KeyNotFoundException If the link id cannot be found, or if it has + * expired. + */ + suspend fun getLink(id: String): Link } diff --git a/server/src/org/isoron/uhabits/sync/server/RepositorySyncServer.kt b/server/src/org/isoron/uhabits/sync/server/RepositorySyncServer.kt index fa75b7d22..4e20b4725 100644 --- a/server/src/org/isoron/uhabits/sync/server/RepositorySyncServer.kt +++ b/server/src/org/isoron/uhabits/sync/server/RepositorySyncServer.kt @@ -20,15 +20,16 @@ package org.isoron.uhabits.sync.server import org.isoron.uhabits.sync.* +import org.isoron.uhabits.sync.links.* import org.isoron.uhabits.sync.repository.* -import java.util.* -import kotlin.streams.* +import org.isoron.uhabits.sync.utils.* /** * An AbstractSyncServer that stores all data in a [Repository]. */ class RepositorySyncServer( private val repo: Repository, + private val linkManager: LinkManager = LinkManager(), ) : AbstractSyncServer { override suspend fun register(): String { @@ -62,16 +63,19 @@ class RepositorySyncServer( return repo.get(key).version } + override suspend fun registerLink(syncKey: String): Link { + return linkManager.register(syncKey) + } + + override suspend fun getLink(id: String): Link { + return linkManager.get(id) + } + private suspend fun generateKey(): String { - val chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" while (true) { - val key = Random().ints(64, 0, chars.length) - .asSequence() - .map(chars::get) - .joinToString("") + val key = randomString(64) if (!repo.contains(key)) return key } - } } diff --git a/server/src/org/isoron/uhabits/sync/utils/String.kt b/server/src/org/isoron/uhabits/sync/utils/String.kt new file mode 100644 index 000000000..cb29cd7d8 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/utils/String.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync.utils + +import java.util.* +import kotlin.streams.* + +fun randomString(length: Long): String { + val chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + return Random().ints(length, 0, chars.length) + .asSequence() + .map(chars::get) + .joinToString("") +} \ No newline at end of file diff --git a/server/test/org/isoron/uhabits/sync/app/LinksModuleTest.kt b/server/test/org/isoron/uhabits/sync/app/LinksModuleTest.kt new file mode 100644 index 000000000..d9b739700 --- /dev/null +++ b/server/test/org/isoron/uhabits/sync/app/LinksModuleTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync.app + +import io.ktor.http.* +import io.ktor.server.testing.* +import kotlinx.coroutines.* +import org.isoron.uhabits.sync.* +import org.isoron.uhabits.sync.links.* +import org.junit.Test +import org.mockito.Mockito.* +import kotlin.test.* + +class LinksModuleTest : BaseApplicationTest() { + private val link = Link( + id = "ABC123", + syncKey = "SECRET", + createdAt = System.currentTimeMillis(), + ) + + @Test + fun `when POST is successful should return link`(): Unit = runBlocking { + `when`(server.registerLink("SECRET")).thenReturn(link) + withTestApplication(app()) { + handlePost("/links", LinkRegisterRequestData(syncKey = "SECRET")).apply { + assertEquals(HttpStatusCode.OK, response.status()) + assertEquals(link.toJson(), response.content) + } + } + } + + @Test + fun `when GET is successful should return link`(): Unit = runBlocking { + `when`(server.getLink("ABC123")).thenReturn(link) + withTestApplication(app()) { + handleGet("/links/ABC123").apply { + assertEquals(HttpStatusCode.OK, response.status()) + assertEquals(link.toJson(), response.content) + } + } + } + + @Test + fun `GET with invalid link id should return 404`(): Unit = runBlocking { + `when`(server.getLink("ABC123")).thenThrow(KeyNotFoundException()) + withTestApplication(app()) { + handleGet("/links/ABC123").apply { + assertEquals(HttpStatusCode.NotFound, response.status()) + } + } + } + + private fun TestApplicationEngine.handlePost(url: String, data: LinkRegisterRequestData): TestApplicationCall { + return handleRequest(HttpMethod.Post, url) { + addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(data.toJson()) + } + } + + private fun TestApplicationEngine.handleGet(url: String): TestApplicationCall { + return handleRequest(HttpMethod.Get, url) + } + +} \ No newline at end of file diff --git a/server/test/org/isoron/uhabits/sync/links/LinkManagerTest.kt b/server/test/org/isoron/uhabits/sync/links/LinkManagerTest.kt new file mode 100644 index 000000000..a36216430 --- /dev/null +++ b/server/test/org/isoron/uhabits/sync/links/LinkManagerTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2016-2020 Alinson Santos Xavier + * + * This file is part of Loop Habit Tracker. + * + * Loop Habit Tracker is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * Loop Habit Tracker is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +package org.isoron.uhabits.sync.links + +import org.isoron.uhabits.sync.* +import org.junit.Test +import kotlin.test.* + +class LinkManagerTest { + + @Test + fun testUsage() { + val manager = LinkManager(timeoutInMillis = 250) + val originalLink = manager.register(syncKey = "SECRET") + val retrievedLink = manager.get(originalLink.id) + assertEquals(originalLink, retrievedLink) + + Thread.sleep(260) // wait until expiration + assertFailsWith { + manager.get(originalLink.id) + } + + assertFailsWith { + manager.get("INVALID") + } + } +} \ No newline at end of file diff --git a/server/test/org/isoron/uhabits/sync/RepositorySyncServerTest.kt b/server/test/org/isoron/uhabits/sync/server/RepositorySyncServerTest.kt similarity index 96% rename from server/test/org/isoron/uhabits/sync/RepositorySyncServerTest.kt rename to server/test/org/isoron/uhabits/sync/server/RepositorySyncServerTest.kt index cae1291ee..2c7821102 100644 --- a/server/test/org/isoron/uhabits/sync/RepositorySyncServerTest.kt +++ b/server/test/org/isoron/uhabits/sync/server/RepositorySyncServerTest.kt @@ -17,11 +17,11 @@ * with this program. If not, see . */ -package org.isoron.uhabits.sync +package org.isoron.uhabits.sync.server import kotlinx.coroutines.* +import org.isoron.uhabits.sync.* import org.isoron.uhabits.sync.repository.* -import org.isoron.uhabits.sync.server.* import org.junit.Test import java.nio.file.* import kotlin.test.*