diff --git a/server/Dockerfile b/server/Dockerfile index 33977a5d5..34d77779a 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,6 +1,7 @@ FROM openjdk:8-jre-alpine RUN mkdir /app COPY uhabits-server.jar /app/uhabits-server.jar +ENV LOOP_REPO_PATH /data/ WORKDIR /app CMD ["java", \ "-server", \ diff --git a/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt b/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt index 92798b58b..16fc71106 100644 --- a/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt +++ b/server/src/org/isoron/uhabits/sync/app/SyncApplication.kt @@ -24,11 +24,18 @@ import io.ktor.features.* import io.ktor.jackson.* import io.ktor.routing.* import org.isoron.uhabits.sync.* +import org.isoron.uhabits.sync.repository.* +import org.isoron.uhabits.sync.server.* +import java.nio.file.* fun Application.main() = SyncApplication().apply { main() } +val REPOSITORY_PATH: Path = Paths.get(System.getenv("LOOP_REPO_PATH")!!) + class SyncApplication( - val server: AbstractSyncServer = MemorySyncServer(), + val server: AbstractSyncServer = RepositorySyncServer( + FileRepository(REPOSITORY_PATH), + ), ) { fun Application.main() { install(DefaultHeaders) diff --git a/server/src/org/isoron/uhabits/sync/repository/FileRepository.kt b/server/src/org/isoron/uhabits/sync/repository/FileRepository.kt new file mode 100644 index 000000000..c9ea5b500 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/repository/FileRepository.kt @@ -0,0 +1,73 @@ +/* + * 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.repository + +import org.isoron.uhabits.sync.* +import java.io.* +import java.nio.file.* + +class FileRepository( + private val basepath: Path, +) : Repository { + + override suspend fun put(key: String, data: SyncData) { + // Create directory + val dataPath = key.toDataPath() + val dataDir = dataPath.toFile() + dataDir.mkdirs() + + // Create metadata + val metadataFile = dataPath.resolve("version").toFile() + metadataFile.outputStream().use { outputStream -> + PrintWriter(outputStream).use { printWriter -> + printWriter.print(data.version) + } + } + + // Create data file + val dataFile = dataPath.resolve("content").toFile() + dataFile.outputStream().use { outputStream -> + PrintWriter(outputStream).use { printWriter -> + printWriter.print(data.content) + } + } + } + + override suspend fun get(key: String): SyncData { + val dataPath = key.toDataPath() + val contentFile = dataPath.resolve("content").toFile() + val versionFile = dataPath.resolve("version").toFile() + if (!contentFile.exists() || !versionFile.exists()) { + throw KeyNotFoundException() + } + val version = versionFile.readText().trim().toLong() + return SyncData(version, contentFile.readText()) + } + + override suspend fun contains(key: String): Boolean { + val dataPath = key.toDataPath() + val versionFile = dataPath.resolve("version").toFile() + return versionFile.exists() + } + + private fun String.toDataPath(): Path { + return basepath.resolve("${this.substring(0..1)}/${this.substring(2..3)}/$this") + } +} \ No newline at end of file diff --git a/server/src/org/isoron/uhabits/sync/repository/Repository.kt b/server/src/org/isoron/uhabits/sync/repository/Repository.kt new file mode 100644 index 000000000..aee4ee535 --- /dev/null +++ b/server/src/org/isoron/uhabits/sync/repository/Repository.kt @@ -0,0 +1,46 @@ +/* + * 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.repository + +import com.sun.org.apache.xpath.internal.operations.* +import org.isoron.uhabits.sync.* + +/** + * A class that knows how to store and retrieve a large number of [SyncData] items. + */ +interface Repository { + /** + * Stores a data item, under the provided key. The item can be later retrieved with [get]. + * Replaces existing items silently. + */ + suspend fun put(key: String, data: SyncData) + + /** + * Retrieves a data item that was previously stored using [put]. + * @throws KeyNotFoundException If no such key exists. + */ + suspend fun get(key: String): SyncData + + /** + * Returns true if the repository contains a given key. + */ + suspend fun contains(key: String): Boolean +} + diff --git a/server/src/org/isoron/uhabits/sync/AbstractSyncServer.kt b/server/src/org/isoron/uhabits/sync/server/AbstractSyncServer.kt similarity index 96% rename from server/src/org/isoron/uhabits/sync/AbstractSyncServer.kt rename to server/src/org/isoron/uhabits/sync/server/AbstractSyncServer.kt index d436eb635..3aac2ee02 100644 --- a/server/src/org/isoron/uhabits/sync/AbstractSyncServer.kt +++ b/server/src/org/isoron/uhabits/sync/server/AbstractSyncServer.kt @@ -17,7 +17,9 @@ * with this program. If not, see . */ -package org.isoron.uhabits.sync +package org.isoron.uhabits.sync.server + +import org.isoron.uhabits.sync.* interface AbstractSyncServer { /** diff --git a/server/src/org/isoron/uhabits/sync/MemorySyncServer.kt b/server/src/org/isoron/uhabits/sync/server/RepositorySyncServer.kt similarity index 58% rename from server/src/org/isoron/uhabits/sync/MemorySyncServer.kt rename to server/src/org/isoron/uhabits/sync/server/RepositorySyncServer.kt index 3ad6defd0..fa75b7d22 100644 --- a/server/src/org/isoron/uhabits/sync/MemorySyncServer.kt +++ b/server/src/org/isoron/uhabits/sync/server/RepositorySyncServer.kt @@ -17,64 +17,59 @@ * with this program. If not, see . */ -package org.isoron.uhabits.sync +package org.isoron.uhabits.sync.server +import org.isoron.uhabits.sync.* +import org.isoron.uhabits.sync.repository.* import java.util.* import kotlin.streams.* /** - * An AbstractSyncServer that stores all data in memory. + * An AbstractSyncServer that stores all data in a [Repository]. */ -class MemorySyncServer : AbstractSyncServer { - private val db = mutableMapOf() +class RepositorySyncServer( + private val repo: Repository, +) : AbstractSyncServer { override suspend fun register(): String { - synchronized(db) { - val key = generateKey() - db[key] = SyncData(0, "") - return key - } + val key = generateKey() + repo.put(key, SyncData(0, "")) + return key } override suspend fun put(key: String, newData: SyncData) { - synchronized(db) { - if (!db.containsKey(key)) { - throw KeyNotFoundException() - } - val prevData = db.getValue(key) - if (newData.version != prevData.version + 1) { - throw EditConflictException() - } - db[key] = newData + if (!repo.contains(key)) { + throw KeyNotFoundException() + } + val prevData = repo.get(key) + if (newData.version != prevData.version + 1) { + throw EditConflictException() } + repo.put(key, newData) } override suspend fun getData(key: String): SyncData { - synchronized(db) { - if (!db.containsKey(key)) { - throw KeyNotFoundException() - } - return db.getValue(key) + if (!repo.contains(key)) { + throw KeyNotFoundException() } + return repo.get(key) } override suspend fun getDataVersion(key: String): Long { - synchronized(db) { - if (!db.containsKey(key)) { - throw KeyNotFoundException() - } - return db.getValue(key).version + if (!repo.contains(key)) { + throw KeyNotFoundException() } + return repo.get(key).version } - private fun generateKey(): String { + private suspend fun generateKey(): String { val chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" while (true) { val key = Random().ints(64, 0, chars.length) .asSequence() .map(chars::get) .joinToString("") - if (!db.containsKey(key)) + if (!repo.contains(key)) return key } diff --git a/server/test/org/isoron/uhabits/sync/MemorySyncServerTest.kt b/server/test/org/isoron/uhabits/sync/RepositorySyncServerTest.kt similarity index 85% rename from server/test/org/isoron/uhabits/sync/MemorySyncServerTest.kt rename to server/test/org/isoron/uhabits/sync/RepositorySyncServerTest.kt index f53fa02b9..cae1291ee 100644 --- a/server/test/org/isoron/uhabits/sync/MemorySyncServerTest.kt +++ b/server/test/org/isoron/uhabits/sync/RepositorySyncServerTest.kt @@ -20,12 +20,16 @@ package org.isoron.uhabits.sync import kotlinx.coroutines.* +import org.isoron.uhabits.sync.repository.* +import org.isoron.uhabits.sync.server.* import org.junit.Test +import java.nio.file.* import kotlin.test.* -class MemorySyncServerTest { +class RepositorySyncServerTest { - private val server = MemorySyncServer() + private val tempdir = Files.createTempDirectory("db") + private val server = RepositorySyncServer(FileRepository(tempdir)) private val key = runBlocking { server.register() } @Test diff --git a/server/test/org/isoron/uhabits/sync/app/BaseApplicationTest.kt b/server/test/org/isoron/uhabits/sync/app/BaseApplicationTest.kt index 190fc6fcf..abcc5f0f5 100644 --- a/server/test/org/isoron/uhabits/sync/app/BaseApplicationTest.kt +++ b/server/test/org/isoron/uhabits/sync/app/BaseApplicationTest.kt @@ -20,7 +20,7 @@ package org.isoron.uhabits.sync.app import io.ktor.application.* -import org.isoron.uhabits.sync.* +import org.isoron.uhabits.sync.server.* import org.mockito.Mockito.* open class BaseApplicationTest { diff --git a/server/test/org/isoron/uhabits/sync/repository/FileRepositoryTest.kt b/server/test/org/isoron/uhabits/sync/repository/FileRepositoryTest.kt new file mode 100644 index 000000000..c9d097778 --- /dev/null +++ b/server/test/org/isoron/uhabits/sync/repository/FileRepositoryTest.kt @@ -0,0 +1,53 @@ +/* + * 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 . + */ + + +@file:Suppress("BlockingMethodInNonBlockingContext") + +package org.isoron.uhabits.sync.repository + +import kotlinx.coroutines.* +import org.hamcrest.CoreMatchers.* +import org.isoron.uhabits.sync.* +import org.junit.* +import org.junit.Assert.* +import java.nio.file.* + +class FileRepositoryTest { + + @Test + fun testUsage() = runBlocking { + val tempdir = Files.createTempDirectory("db")!! + val repo = FileRepository(tempdir) + + val original = SyncData(10, "Hello world") + repo.put("abcdefg", original) + + val metaPath = tempdir.resolve("ab/cd/abcdefg/version") + assertTrue("$metaPath should exist", Files.exists(metaPath)) + assertEquals("10", metaPath.toFile().readText()) + + val dataPath = tempdir.resolve("ab/cd/abcdefg/content") + assertTrue("$dataPath should exist", Files.exists(dataPath)) + assertEquals("Hello world", dataPath.toFile().readText()) + + val retrieved = repo.get("abcdefg") + assertThat(retrieved, equalTo(original)) + } +} \ No newline at end of file