Server: Implement temporary links

pull/699/head
Alinson S. Xavier 5 years ago
parent fc4cbe84f0
commit 2fcba80f3a

@ -80,5 +80,6 @@ dockerRun {
name = 'uhabits-server' name = 'uhabits-server'
image "uhabits-server:$version" image "uhabits-server:$version"
ports '8080:8080' ports '8080:8080'
arguments '--restart=always' daemonize false
clean true
} }

@ -0,0 +1,55 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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<LinkRegisterRequestData>()
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)
}
}
}

@ -46,6 +46,7 @@ class SyncApplication(
routing { routing {
registration(this@SyncApplication) registration(this@SyncApplication)
storage(this@SyncApplication) storage(this@SyncApplication)
links(this@SyncApplication)
} }
} }
} }

@ -0,0 +1,37 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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)

@ -0,0 +1,49 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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<String, Link>()
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
}
}

@ -20,6 +20,7 @@
package org.isoron.uhabits.sync.server package org.isoron.uhabits.sync.server
import org.isoron.uhabits.sync.* import org.isoron.uhabits.sync.*
import org.isoron.uhabits.sync.links.*
interface AbstractSyncServer { interface AbstractSyncServer {
/** /**
@ -59,4 +60,22 @@ interface AbstractSyncServer {
* to insufficient server resources or network problems. * to insufficient server resources or network problems.
*/ */
suspend fun getDataVersion(key: String): Long 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
} }

@ -20,15 +20,16 @@
package org.isoron.uhabits.sync.server package org.isoron.uhabits.sync.server
import org.isoron.uhabits.sync.* import org.isoron.uhabits.sync.*
import org.isoron.uhabits.sync.links.*
import org.isoron.uhabits.sync.repository.* import org.isoron.uhabits.sync.repository.*
import java.util.* import org.isoron.uhabits.sync.utils.*
import kotlin.streams.*
/** /**
* An AbstractSyncServer that stores all data in a [Repository]. * An AbstractSyncServer that stores all data in a [Repository].
*/ */
class RepositorySyncServer( class RepositorySyncServer(
private val repo: Repository, private val repo: Repository,
private val linkManager: LinkManager = LinkManager(),
) : AbstractSyncServer { ) : AbstractSyncServer {
override suspend fun register(): String { override suspend fun register(): String {
@ -62,16 +63,19 @@ class RepositorySyncServer(
return repo.get(key).version 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 { private suspend fun generateKey(): String {
val chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
while (true) { while (true) {
val key = Random().ints(64, 0, chars.length) val key = randomString(64)
.asSequence()
.map(chars::get)
.joinToString("")
if (!repo.contains(key)) if (!repo.contains(key))
return key return key
} }
} }
} }

@ -0,0 +1,31 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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("")
}

@ -0,0 +1,81 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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)
}
}

@ -0,0 +1,44 @@
/*
* Copyright (C) 2016-2020 Alinson Santos Xavier <git@axavier.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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<KeyNotFoundException> {
manager.get(originalLink.id)
}
assertFailsWith<KeyNotFoundException> {
manager.get("INVALID")
}
}
}

@ -17,11 +17,11 @@
* with this program. If not, see <http://www.gnu.org/licenses/>. * with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package org.isoron.uhabits.sync package org.isoron.uhabits.sync.server
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.isoron.uhabits.sync.*
import org.isoron.uhabits.sync.repository.* import org.isoron.uhabits.sync.repository.*
import org.isoron.uhabits.sync.server.*
import org.junit.Test import org.junit.Test
import java.nio.file.* import java.nio.file.*
import kotlin.test.* import kotlin.test.*
Loading…
Cancel
Save