Compare commits

...

11 Commits

48 changed files with 21596 additions and 595 deletions

View File

@@ -79,6 +79,14 @@
android:value=".activities.habits.list.ListHabitsActivity" /> android:value=".activities.habits.list.ListHabitsActivity" />
</activity> </activity>
<activity
android:name=".activities.sync.SyncActivity"
android:label="@string/device_sync">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".activities.sync.SyncActivity" />
</activity>
<activity <activity
android:name=".activities.intro.IntroActivity" android:name=".activities.intro.IntroActivity"
android:label="" android:label=""

View File

@@ -0,0 +1,100 @@
/*
* Copyright (C) 2016-2021 Álinson 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.activities.sync
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import org.isoron.uhabits.HabitsApplication
import org.isoron.uhabits.R
import org.isoron.uhabits.activities.AndroidThemeSwitcher
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.databinding.SyncActivityBinding
import org.isoron.uhabits.utils.setupToolbar
class SyncActivity : AppCompatActivity() {
private lateinit var binding: SyncActivityBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val component = (application as HabitsApplication).component
val themeSwitcher = AndroidThemeSwitcher(this, component.preferences)
themeSwitcher.apply()
binding = SyncActivityBinding.inflate(LayoutInflater.from(this))
binding.root.setupToolbar(
toolbar = binding.toolbar,
title = resources.getString(R.string.device_sync),
color = PaletteColor(11),
theme = themeSwitcher.currentTheme,
)
binding.generateButton.setOnClickListener { onGenerateCode() }
binding.enterButton.setOnClickListener {
val et = EditText(this)
AlertDialog.Builder(this)
.setTitle(R.string.sync_code)
.setView(et)
.setPositiveButton(R.string.save) { _, _ ->
onEnterCode(et.text.toString())
}
.show()
}
binding.disableButton.setOnClickListener {
AlertDialog.Builder(this)
.setTitle(R.string.disable_sync)
.setMessage(R.string.disable_sync_description)
.setPositiveButton(R.string.disable) { _, _ ->
onDisableSync()
}
.setNegativeButton(R.string.keep_enabled) { dialog, _ ->
dialog.dismiss()
}
.show()
}
setContentView(binding.root)
}
private fun onGenerateCode() {
showCodeScreen()
}
private fun onEnterCode(code: String) {
showCodeScreen()
}
private fun onDisableSync() {
showIntroScreen()
}
private fun showCodeScreen() {
binding.introGroup.visibility = View.GONE
binding.codeGroup.visibility = View.VISIBLE
}
private fun showIntroScreen() {
binding.introGroup.visibility = View.VISIBLE
binding.codeGroup.visibility = View.GONE
}
}

View File

@@ -0,0 +1,55 @@
<!--
~ Copyright (C) 2016-2021 Álinson 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/>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="243dp"
android:height="85dp"
android:viewportWidth="243"
android:viewportHeight="85">
<path
android:pathData="M44.354,0H7.827C3.506,0 0,3.569 0,7.969V77.031C0,81.431 3.506,85 7.827,85H44.354C48.676,85 52.182,81.431 52.182,77.031V7.969C52.182,3.569 48.676,0 44.354,0ZM26.091,79.688C23.205,79.688 20.873,77.313 20.873,74.375C20.873,71.437 23.205,69.063 26.091,69.063C28.977,69.063 31.309,71.437 31.309,74.375C31.309,77.313 28.977,79.688 26.091,79.688ZM44.354,61.758C44.354,62.854 43.474,63.75 42.398,63.75H9.784C8.708,63.75 7.827,62.854 7.827,61.758V9.961C7.827,8.865 8.708,7.969 9.784,7.969H42.398C43.474,7.969 44.354,8.865 44.354,9.961V61.758Z"
android:fillColor="#ffffff" />
<path
android:pathData="M234.536,0H198.009C193.688,0 190.182,3.569 190.182,7.969V77.031C190.182,81.431 193.688,85 198.009,85H234.536C238.858,85 242.364,81.431 242.364,77.031V7.969C242.364,3.569 238.858,0 234.536,0ZM216.273,79.688C213.386,79.688 211.055,77.313 211.055,74.375C211.055,71.437 213.386,69.063 216.273,69.063C219.159,69.063 221.491,71.437 221.491,74.375C221.491,77.313 219.159,79.688 216.273,79.688ZM234.536,61.758C234.536,62.854 233.656,63.75 232.58,63.75H199.966C198.89,63.75 198.009,62.854 198.009,61.758V9.961C198.009,8.865 198.89,7.969 199.966,7.969H232.58C233.656,7.969 234.536,8.865 234.536,9.961V61.758Z"
android:fillColor="#ffffff" />
<path
android:pathData="M68.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#ffffff" />
<path
android:pathData="M110.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#ffffff" />
<path
android:pathData="M96.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#ffffff" />
<path
android:pathData="M82.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#ffffff" />
<path
android:pathData="M124.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#ffffff" />
<path
android:pathData="M166.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#ffffff" />
<path
android:pathData="M152.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#ffffff" />
<path
android:pathData="M138.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#ffffff" />
</vector>

View File

@@ -0,0 +1,36 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="243dp"
android:height="85dp"
android:viewportWidth="243"
android:viewportHeight="85">
<path
android:pathData="M44.354,0H7.827C3.506,0 0,3.569 0,7.969V77.031C0,81.431 3.506,85 7.827,85H44.354C48.676,85 52.182,81.431 52.182,77.031V7.969C52.182,3.569 48.676,0 44.354,0ZM26.091,79.688C23.205,79.688 20.873,77.313 20.873,74.375C20.873,71.437 23.205,69.063 26.091,69.063C28.977,69.063 31.309,71.437 31.309,74.375C31.309,77.313 28.977,79.688 26.091,79.688ZM44.354,61.758C44.354,62.854 43.474,63.75 42.398,63.75H9.784C8.708,63.75 7.827,62.854 7.827,61.758V9.961C7.827,8.865 8.708,7.969 9.784,7.969H42.398C43.474,7.969 44.354,8.865 44.354,9.961V61.758Z"
android:fillColor="#000000" />
<path
android:pathData="M234.536,0H198.009C193.688,0 190.182,3.569 190.182,7.969V77.031C190.182,81.431 193.688,85 198.009,85H234.536C238.858,85 242.364,81.431 242.364,77.031V7.969C242.364,3.569 238.858,0 234.536,0ZM216.273,79.688C213.386,79.688 211.055,77.313 211.055,74.375C211.055,71.437 213.386,69.063 216.273,69.063C219.159,69.063 221.491,71.437 221.491,74.375C221.491,77.313 219.159,79.688 216.273,79.688ZM234.536,61.758C234.536,62.854 233.656,63.75 232.58,63.75H199.966C198.89,63.75 198.009,62.854 198.009,61.758V9.961C198.009,8.865 198.89,7.969 199.966,7.969H232.58C233.656,7.969 234.536,8.865 234.536,9.961V61.758Z"
android:fillColor="#000000" />
<path
android:pathData="M68.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#000000" />
<path
android:pathData="M110.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#000000" />
<path
android:pathData="M96.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#000000" />
<path
android:pathData="M82.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#000000" />
<path
android:pathData="M124.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#000000" />
<path
android:pathData="M166.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#000000" />
<path
android:pathData="M152.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#000000" />
<path
android:pathData="M138.182,42.5a4,4.048 0,1 0,8 0a4,4.048 0,1 0,-8 0z"
android:fillColor="#000000" />
</vector>

View File

@@ -0,0 +1,162 @@
<!--
~ Copyright (C) 2016-2021 Álinson 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/>.
-->
<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?attr/contrast0"
android:gravity="top|center">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
app:popupTheme="?toolbarPopupTheme"
style="@style/Toolbar" />
<ScrollView
android:id="@+id/introGroup"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="top|center">
<androidx.appcompat.widget.AppCompatImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:srcCompat="?attr/iconSync"
android:layout_margin="32dp"
android:alpha="0.25" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/device_sync_description_1"
android:layout_marginBottom="16dp"
android:layout_marginTop="0dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/device_sync_description_2"
android:layout_marginBottom="16dp"
android:layout_marginTop="0dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/generate_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/generate_new_code"
app:backgroundTint="?attr/aboutScreenColor"
android:textColor="?attr/contrast0"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp" />
<com.google.android.material.button.MaterialButton
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:id="@+id/enter_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/enter_existing_code"
android:textColor="?attr/aboutScreenColor"
app:rippleColor="?attr/aboutScreenColor"
app:strokeColor="?attr/aboutScreenColor"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp" />
</androidx.appcompat.widget.LinearLayoutCompat>
</ScrollView>
<ScrollView
android:id="@+id/codeGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sync_is_enabled"
android:layout_margin="16dp" />
<FrameLayout
style="@style/FormOuterBox"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp">
<LinearLayout style="@style/FormInnerBox">
<TextView
style="@style/FormLabel"
android:text="@string/sync_code" />
<TextView
style="@style/FormInput"
android:id="@+id/sync_code_tv"
android:fontFamily="monospace"
android:text="gravity trophy suspect shrimp sheriff avocado label trust tragic dove pitch title network myself task spell protect smooth diary sword brain blossom bulb under" />
</LinearLayout>
</FrameLayout>
<FrameLayout
style="@style/FormOuterBox"
android:layout_marginLeft="12dp"
android:layout_marginRight="12dp">
<LinearLayout style="@style/FormInnerBox">
<TextView
style="@style/FormLabel"
android:text="@string/last_sync" />
<TextView
style="@style/FormInput"
android:id="@+id/last_sync_tv"
android:text="Jan 10, 2022 4:45:00 PM" />
</LinearLayout>
</FrameLayout>
<com.google.android.material.button.MaterialButton
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:id="@+id/disable_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/disable_sync"
android:textColor="?attr/aboutScreenColor"
app:rippleColor="?attr/aboutScreenColor"
app:strokeColor="?attr/aboutScreenColor"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp" />
</LinearLayout>
</ScrollView>
</androidx.appcompat.widget.LinearLayoutCompat>

View File

@@ -43,6 +43,7 @@
<attr name="iconFilter" format="reference"/> <attr name="iconFilter" format="reference"/>
<attr name="iconArrowUp" format="reference"/> <attr name="iconArrowUp" format="reference"/>
<attr name="iconArrowDown" format="reference"/> <attr name="iconArrowDown" format="reference"/>
<attr name="iconSync" format="reference"/>
<attr name="dialogFormLabelColor" format="reference"/> <attr name="dialogFormLabelColor" format="reference"/>
<attr name="toolbarPopupTheme" format="reference"/> <attr name="toolbarPopupTheme" format="reference"/>

View File

@@ -233,4 +233,18 @@
<string name="activity_not_found">No app was found to support this action</string> <string name="activity_not_found">No app was found to support this action</string>
<string name="pref_midnight_delay_title">Extend day a few hours past midnight</string> <string name="pref_midnight_delay_title">Extend day a few hours past midnight</string>
<string name="pref_midnight_delay_description">Wait until 3:00 AM to show a new day. Useful if you typically go to sleep after midnight. Requires app restart.</string> <string name="pref_midnight_delay_description">Wait until 3:00 AM to show a new day. Useful if you typically go to sleep after midnight. Requires app restart.</string>
<string name="device_sync">Device sync</string>
<string name="config_sync">Configure device sync</string>
<string name="config_sync_summary">Synchronize data across multiple devices. When enabled, an end-to-end encrypted copy of your data will be uploaded to Loop Habit Tracker servers.</string>
<string name="device_sync_description_1">Device sync allows you to keep your data synchronized across multiple devices. To get started, generate a new sync code below, install Loop Habit Tracker in another device, then type the generated code there.</string>
<string name="device_sync_description_2">When sync is enabled, an end-to-end encrypted copy of your data will be uploaded to Loop Habit Tracker servers. See privacy policy for more details.</string>
<string name="generate_new_code">Generate new code</string>
<string name="enter_existing_code">Enter existing code</string>
<string name="sync_is_enabled">Device sync is enabled. To get started, install Loop in another device, then type the following code there.</string>
<string name="sync_code">Sync code</string>
<string name="disable_sync">Disable sync</string>
<string name="disable_sync_description">Are you sure you want to disable sync on this device? This will not delete any data from any of your devices, but the current device will no longer be kept in sync with the others. If you disable sync from all devices, your data will be deleted from our servers in 30 days.</string>
<string name="disable">Disable</string>
<string name="keep_enabled">Keep enabled</string>
<string name="last_sync">Last sync</string>
</resources> </resources>

View File

@@ -45,6 +45,7 @@
<item name="iconUnarchive">@drawable/ic_action_unarchive_dark</item> <item name="iconUnarchive">@drawable/ic_action_unarchive_dark</item>
<item name="iconArrowUp">@drawable/ic_arrow_up_light</item> <item name="iconArrowUp">@drawable/ic_arrow_up_light</item>
<item name="iconArrowDown">@drawable/ic_arrow_down_light</item> <item name="iconArrowDown">@drawable/ic_arrow_down_light</item>
<item name="iconSync">@drawable/ic_sync_light</item>
<item name="contrast0">@color/white</item> <item name="contrast0">@color/white</item>
<item name="contrast20">@color/grey_300</item> <item name="contrast20">@color/grey_300</item>
<item name="contrast40">@color/grey_350</item> <item name="contrast40">@color/grey_350</item>
@@ -89,6 +90,7 @@
<item name="iconUnarchive">@drawable/ic_action_unarchive_dark</item> <item name="iconUnarchive">@drawable/ic_action_unarchive_dark</item>
<item name="iconArrowUp">@drawable/ic_arrow_up_dark</item> <item name="iconArrowUp">@drawable/ic_arrow_up_dark</item>
<item name="iconArrowDown">@drawable/ic_arrow_down_dark</item> <item name="iconArrowDown">@drawable/ic_arrow_down_dark</item>
<item name="iconSync">@drawable/ic_sync_dark</item>
<item name="contrast0">@color/grey_900</item> <item name="contrast0">@color/grey_900</item>
<item name="contrast20">@color/grey_800</item> <item name="contrast20">@color/grey_800</item>
<item name="contrast40">@color/grey_750</item> <item name="contrast40">@color/grey_750</item>

View File

@@ -107,6 +107,24 @@
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory
android:key="syncCategory"
android:title="@string/device_sync">
<Preference
android:key="configSync"
android:summary="@string/config_sync_summary"
android:title="@string/config_sync"
app:iconSpaceReserved="false">
<intent
android:action="android.intent.action.VIEW"
android:targetClass="org.isoron.uhabits.activities.sync.SyncActivity"
android:targetPackage="org.isoron.uhabits" />
</Preference>
</PreferenceCategory>
<PreferenceCategory <PreferenceCategory
android:key="databaseCategory" android:key="databaseCategory"
android:title="@string/database"> android:title="@string/database">

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
/*
* Copyright (C) 2016-2021 Álinson 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.platform.crypto
class Bip39(private val wordlist: List<String>, private val crypto: Crypto) {
private fun computeChecksum(entropy: List<Boolean>): List<Boolean> {
val sha256 = crypto.sha256()
var byte = 0
entropy.forEachIndexed { i, bit ->
byte = byte shl 1
if (bit) byte += 1
if (i.rem(8) == 7) {
sha256.update(byte.toByte())
byte = 0
}
}
return sha256.finalize().toBits().subList(0, entropy.size / 32)
}
fun encode(entropy: ByteArray): List<String> {
val entropyBits = entropy.toBits()
val msg = entropyBits + computeChecksum(entropyBits)
var wordIndex = 0
val mnemonic = mutableListOf<String>()
msg.forEachIndexed { i, bit ->
wordIndex = wordIndex shl 1
if (bit) wordIndex += 1
if (i.rem(11) == 10) {
mnemonic.add(wordlist[wordIndex])
wordIndex = 0
}
}
return mnemonic
}
fun decode(mnemonic: List<String>): ByteArray {
val bits = mutableListOf<Boolean>()
mnemonic.forEach { word ->
val wordBits = mutableListOf<Boolean>()
var wordIndex = wordlist.binarySearch(word)
if (wordIndex < 0) throw InvalidWordException(word)
for (it in 0..10) {
wordBits.add(wordIndex.rem(2) == 1)
wordIndex = wordIndex shr 1
}
bits.addAll(wordBits.reversed())
}
if (bits.size.rem(33) != 0) throw InvalidMnemonicLength()
val checksumSize = bits.size / 33
val checksum = bits.subList(bits.size - checksumSize, bits.size)
val entropy = bits.subList(0, bits.size - checksumSize)
if (computeChecksum(entropy) != checksum) throw InvalidChecksumException()
return byteArray(entropy)
}
}
class InvalidChecksumException : Exception()
class InvalidWordException(word: String) : Exception(word)
class InvalidMnemonicLength : Exception()

View File

@@ -0,0 +1,78 @@
/*
* Copyright (C) 2016-2021 Álinson 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.platform.crypto
class Key(val bytes: ByteArray)
interface Crypto {
fun sha256(): Sha256
fun hmacSha256(): HmacSha256
fun aesGcm(key: Key): AesGcm
fun secureRandomBytes(numBytes: Int): ByteArray
fun generateKey(): Key {
return Key(secureRandomBytes(32))
}
fun deriveKey(master: Key, subkeyName: String): Key {
val mac = hmacSha256()
mac.init(master.bytes)
mac.update(subkeyName)
return Key(mac.finalize())
}
}
interface Sha256 {
fun update(byte: Byte)
fun finalize(): ByteArray
}
interface HmacSha256 {
fun init(key: ByteArray)
fun update(byte: Byte)
fun finalize(): ByteArray
fun update(msg: String) {
for (b in msg.encodeToByteArray()) {
update(b)
}
}
}
interface AesGcm {
fun encrypt(msg: ByteArray, iv: ByteArray): ByteArray
fun decrypt(cipherText: ByteArray): ByteArray
}
fun Byte.toBits(): List<Boolean> = (7 downTo 0).map { (toInt() and (1 shl it)) != 0 }
fun ByteArray.toBits(): List<Boolean> = flatMap { it.toBits() }
fun byteArrayOfInts(vararg b: Int) = b.map { it.toByte() }.toByteArray()
fun byteArray(bits: List<Boolean>): ByteArray {
var byte = 0
val bytes = ByteArray(bits.size / 8)
bits.forEachIndexed { i, b ->
byte = byte shl 1
if (b) byte += 1
if (i.rem(8) == 7) {
bytes[i / 8] = byte.toByte()
}
}
return bytes
}

View File

@@ -17,21 +17,9 @@
* 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.server package org.isoron.uhabits.core.sync
import org.isoron.uhabits.sync.*
import org.isoron.uhabits.sync.links.*
interface AbstractSyncServer { interface AbstractSyncServer {
/**
* Generates and returns a new sync key, which can be used to store and retrive
* data.
*
* @throws ServiceUnavailable If key cannot be generated at this time, for example,
* due to insufficient server resources, temporary server maintenance or network problems.
*/
suspend fun register(): String
/** /**
* Replaces data for a given sync key. * Replaces data for a given sync key.
* *
@@ -60,22 +48,4 @@ 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
} }

View File

@@ -17,9 +17,7 @@
* 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.core.sync
import com.fasterxml.jackson.databind.*
data class SyncData( data class SyncData(
val version: Long, val version: Long,
@@ -29,7 +27,3 @@ data class SyncData(
data class RegisterReponse(val key: String) data class RegisterReponse(val key: String)
data class GetDataVersionResponse(val version: Long) data class GetDataVersionResponse(val version: Long)
val defaultMapper = ObjectMapper()
fun SyncData.toJson(): String = defaultMapper.writeValueAsString(this)
fun GetDataVersionResponse.toJson(): String = defaultMapper.writeValueAsString(this)

View File

@@ -17,12 +17,12 @@
* 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.core.sync
open class SyncException: RuntimeException() open class SyncException : RuntimeException()
class KeyNotFoundException: SyncException() class KeyNotFoundException : SyncException()
class ServiceUnavailable: SyncException() class ServiceUnavailable : SyncException()
class EditConflictException: SyncException() class EditConflictException : SyncException()

View File

@@ -0,0 +1,83 @@
/*
* Copyright (C) 2016-2021 Álinson 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.platform.crypto
import java.nio.ByteBuffer
import java.security.MessageDigest
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
class JavaCrypto : Crypto {
override fun sha256() = JavaSha256()
override fun hmacSha256() = JavaHmacSha256()
override fun aesGcm(key: Key) = JavaAesGcm(key)
override fun secureRandomBytes(numBytes: Int): ByteArray {
val sr = SecureRandom()
val bytes = ByteArray(numBytes)
sr.nextBytes(bytes)
return bytes
}
}
class JavaSha256 : Sha256 {
private val md = MessageDigest.getInstance("SHA-256")
override fun update(byte: Byte) = md.update(byte)
override fun finalize(): ByteArray = md.digest()
}
class JavaHmacSha256 : HmacSha256 {
private val mac = Mac.getInstance("HmacSHA256")
override fun init(key: ByteArray) = mac.init(SecretKeySpec(key, "HmacSHA256"))
override fun update(byte: Byte) = mac.update(byte)
override fun finalize(): ByteArray = mac.doFinal()
}
class JavaAesGcm(val key: Key) : AesGcm {
override fun encrypt(msg: ByteArray, iv: ByteArray): ByteArray {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key.bytes, "AES"), GCMParameterSpec(128, iv))
val encrypted = cipher.doFinal(msg)
return ByteBuffer
.allocate(iv.size + encrypted.size)
.put(iv)
.put(encrypted)
.array()
}
override fun decrypt(cipherText: ByteArray): ByteArray {
val buffer = ByteBuffer.wrap(cipherText)
val iv = ByteArray(12)
buffer.get(iv)
val encrypted = ByteArray(buffer.remaining())
buffer.get(encrypted)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key.bytes, "AES"), GCMParameterSpec(128, iv))
return cipher.doFinal(encrypted)
}
}
fun ByteArray.toHexString(): String {
val sb = StringBuilder()
for (b in this) sb.append(String.format("%02x", b))
return sb.toString()
}

View File

@@ -0,0 +1,126 @@
/*
* Copyright (C) 2016-2021 Álinson 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.platform.crypto
import kotlinx.coroutines.runBlocking
import org.isoron.platform.io.JavaFileOpener
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import kotlin.test.assertFailsWith
class Bip39Test {
private lateinit var bip39: Bip39
private val phrases = listOf(
listOf(
"gather",
"capable",
"since",
),
listOf(
"exit",
"churn",
"hazard",
"garage",
"hint",
"great",
),
listOf(
"exile",
"blouse",
"athlete",
"dinner",
"chef",
"home",
"destroy",
"disagree",
"select",
"eight",
"slim",
"talent",
),
)
private val entropies = listOf(
byteArrayOfInts(0x60, 0x64, 0x3f, 0x24),
byteArrayOfInts(0x4f, 0xe5, 0x19, 0xa7, 0xaf, 0xb6, 0xbc, 0xcc),
byteArrayOfInts(
0x4f,
0xa3,
0x04,
0x38,
0x9f,
0x22,
0x74,
0xda,
0x0f,
0x09,
0xf6,
0xc3,
0x48,
0xdf,
0x2f,
0x6e,
)
)
@Before
fun setUp() = runBlocking {
bip39 = Bip39(JavaFileOpener().openResourceFile("bip39/en_US.txt").lines(), JavaCrypto())
}
@Test
fun test_encode_decode() {
phrases.zip(entropies).forEach { (phrase, entropy) ->
assertEquals(phrase, bip39.encode(entropy))
assertEquals(entropy.toHexString(), bip39.decode(phrase).toHexString())
}
}
@Test
fun test_decode_invalid_checksum() {
assertFailsWith<InvalidChecksumException> {
bip39.decode(
listOf(
"lawn",
"dirt",
"work",
"mountain",
"depth",
"loyal",
"citizen",
"theory",
"cram",
"trip",
"boil",
"about",
)
)
}
}
@Test
fun test_decode_invalid_word() {
assertFailsWith<InvalidWordException> {
bip39.decode(listOf("dirt", "bee", "work"))
}
}
}

View File

@@ -0,0 +1,234 @@
/*
* Copyright (C) 2016-2021 Álinson 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.platform.crypto
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
class CryptoTest {
private val crypto = JavaCrypto()
@Test
fun test_sha256() {
val sha256 = crypto.sha256()
sha256.update(0x10.toByte())
sha256.update(0x20.toByte())
sha256.update(0x30.toByte())
val digest = sha256.finalize()
assertEquals(32, digest.size)
assertEquals(0x8e.toByte(), digest[0])
assertEquals(0x13.toByte(), digest[1])
assertEquals(0x36.toByte(), digest[2])
assertEquals(0xb9.toByte(), digest[31])
}
@Test
fun test_hmacsha256() {
val hmac = crypto.hmacSha256()
hmac.init(byteArrayOfInts(0x01, 0x02, 0x03))
hmac.update(0x40.toByte())
hmac.update("AB")
val checksum = hmac.finalize()
assertEquals(32, checksum.size)
assertEquals(0x6d.toByte(), checksum[0])
assertEquals(0xc9.toByte(), checksum[1])
assertEquals(0x05.toByte(), checksum[2])
assertEquals(0xa1.toByte(), checksum[31])
}
@Test
fun test_aes_gcm() {
val msg = byteArrayOfInts(
0x2f,
0xdc,
0xaa,
0x41,
0xfa,
0xb8,
0x5e,
0xe8,
0xa3,
0x12,
0x69,
0x68,
0x14,
0x31,
0xd8,
0x59,
0x74,
0x29,
0x2e,
0xae,
0xed,
0x76,
0x0a,
0x56,
0x46,
0x90,
0xb6,
0xcb,
0x9f,
0x37,
0xbe,
0xae,
)
val key = Key(
byteArrayOfInts(
0xed,
0xa8,
0xc3,
0xc6,
0x44,
0x1e,
0xa1,
0xd5,
0x71,
0x8c,
0x71,
0x45,
0xbe,
0x2d,
0xf7,
0xa4,
0x81,
0x2e,
0x0a,
0x0b,
0xa8,
0xe4,
0x20,
0x49,
0x94,
0x8a,
0x71,
0x1a,
0x15,
0xf5,
0x29,
0x78,
)
)
val iv = byteArrayOfInts(
0xa7,
0xef,
0xe1,
0xba,
0xdf,
0x4f,
0x85,
0xca,
0xc3,
0x81,
0xc1,
0x93,
)
val expected = byteArrayOfInts(
// iv
0xa7,
0xef,
0xe1,
0xba,
0xdf,
0x4f,
0x85,
0xca,
0xc3,
0x81,
0xc1,
0x93,
// msg
0x24,
0xe7,
0x26,
0x9b,
0xb8,
0x59,
0xf0,
0xe0,
0x4f,
0xda,
0xc0,
0x85,
0xc6,
0x23,
0x21,
0x61,
0x80,
0x59,
0xd6,
0x18,
0xee,
0xa0,
0xd8,
0x00,
0xe3,
0xdf,
0x6e,
0xcf,
0x89,
0x82,
0xfd,
0x63,
// verification tag
0xe9,
0xe9,
0xac,
0x92,
0xdc,
0xb1,
0x7c,
0x2d,
0x9a,
0x73,
0xda,
0x25,
0x6d,
0xda,
0xc0,
0x83,
)
val cipher = crypto.aesGcm(key)
val actual = cipher.encrypt(msg, iv)
assertEquals(actual.toHexString(), expected.toHexString())
val recovered = cipher.decrypt(actual)
assertEquals(msg.toHexString(), recovered.toHexString())
}
@Test
fun test_rand() {
val r1 = crypto.secureRandomBytes(8)
val r2 = crypto.secureRandomBytes(8)
assertEquals(8, r1.size)
assertNotEquals(r1.toBits(), r2.toBits())
}
@Test
fun test_derive_key() {
val k1 = Key(byteArrayOfInts(0x01, 0x02, 0x03))
val k2 = crypto.deriveKey(k1, "TEST")
assertEquals(0x44.toByte(), k2.bytes[0])
assertEquals(0xd3.toByte(), k2.bytes[31])
}
}

View File

@@ -1,6 +1,6 @@
FROM openjdk:8-jre-alpine FROM openjdk:8-jre-alpine
RUN mkdir /app RUN mkdir /app
COPY uhabits-server.jar /app/uhabits-server.jar COPY build/libs/uhabits-server.jar /app/uhabits-server.jar
ENV LOOP_REPO_PATH /data/ ENV LOOP_REPO_PATH /data/
WORKDIR /app WORKDIR /app
CMD ["java", \ CMD ["java", \

View File

@@ -23,9 +23,9 @@ plugins {
application application
id("kotlin") id("kotlin")
id("com.github.johnrengelman.shadow") version "7.1.2" id("com.github.johnrengelman.shadow") version "7.1.2"
id("org.jlleitschuh.gradle.ktlint")
} }
application { application {
group = "org.isoron.uhabits" group = "org.isoron.uhabits"
version = "0.0.1" version = "0.0.1"
@@ -36,6 +36,7 @@ dependencies {
val ktorVersion = "1.6.8" val ktorVersion = "1.6.8"
val kotlinVersion = "1.7.10" val kotlinVersion = "1.7.10"
val logbackVersion = "1.4.0" val logbackVersion = "1.4.0"
implementation(project(":uhabits-core"))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
implementation("io.ktor:ktor-server-netty:$ktorVersion") implementation("io.ktor:ktor-server-netty:$ktorVersion")
implementation("ch.qos.logback:logback-classic:$logbackVersion") implementation("ch.qos.logback:logback-classic:$logbackVersion")

View File

@@ -17,17 +17,17 @@
* 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.app package org.isoron.uhabits.server.app
import io.ktor.application.*
import io.ktor.http.*
import io.ktor.response.*
import io.ktor.routing.*
import io.prometheus.client.*
import io.prometheus.client.exporter.common.*
import io.prometheus.client.hotspot.*
import java.io.*
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
import io.ktor.response.respond
import io.ktor.routing.Routing
import io.ktor.routing.get
import io.prometheus.client.CollectorRegistry
import io.prometheus.client.exporter.common.TextFormat
import io.prometheus.client.hotspot.DefaultExports
import java.io.StringWriter
fun Routing.metrics(app: SyncApplication) { fun Routing.metrics(app: SyncApplication) {
// Register JVM metrics // Register JVM metrics

View File

@@ -17,14 +17,20 @@
* 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.app package org.isoron.uhabits.server.app
import io.ktor.application.* import io.ktor.application.call
import io.ktor.http.* import io.ktor.http.HttpStatusCode
import io.ktor.request.* import io.ktor.request.receive
import io.ktor.response.* import io.ktor.response.respond
import io.ktor.routing.* import io.ktor.routing.Routing
import org.isoron.uhabits.sync.* import io.ktor.routing.get
import io.ktor.routing.put
import io.ktor.routing.route
import org.isoron.uhabits.core.sync.EditConflictException
import org.isoron.uhabits.core.sync.GetDataVersionResponse
import org.isoron.uhabits.core.sync.KeyNotFoundException
import org.isoron.uhabits.core.sync.SyncData
fun Routing.storage(app: SyncApplication) { fun Routing.storage(app: SyncApplication) {
route("/db/{key}") { route("/db/{key}") {
@@ -33,7 +39,7 @@ fun Routing.storage(app: SyncApplication) {
try { try {
val data = app.server.getData(key) val data = app.server.getData(key)
call.respond(HttpStatusCode.OK, data) call.respond(HttpStatusCode.OK, data)
} catch(e: KeyNotFoundException) { } catch (e: KeyNotFoundException) {
call.respond(HttpStatusCode.NotFound) call.respond(HttpStatusCode.NotFound)
} }
} }
@@ -43,8 +49,6 @@ fun Routing.storage(app: SyncApplication) {
try { try {
app.server.put(key, data) app.server.put(key, data)
call.respond(HttpStatusCode.OK) call.respond(HttpStatusCode.OK)
} catch (e: KeyNotFoundException) {
call.respond(HttpStatusCode.NotFound)
} catch (e: EditConflictException) { } catch (e: EditConflictException) {
call.respond(HttpStatusCode.Conflict) call.respond(HttpStatusCode.Conflict)
} }
@@ -54,9 +58,9 @@ fun Routing.storage(app: SyncApplication) {
try { try {
val version = app.server.getDataVersion(key) val version = app.server.getDataVersion(key)
call.respond(HttpStatusCode.OK, GetDataVersionResponse(version)) call.respond(HttpStatusCode.OK, GetDataVersionResponse(version))
} catch(e: KeyNotFoundException) { } catch (e: KeyNotFoundException) {
call.respond(HttpStatusCode.NotFound) call.respond(HttpStatusCode.NotFound)
} }
} }
} }
} }

View File

@@ -17,16 +17,20 @@
* 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.app package org.isoron.uhabits.server.app
import io.ktor.application.* import io.ktor.application.Application
import io.ktor.features.* import io.ktor.application.install
import io.ktor.jackson.* import io.ktor.features.CallLogging
import io.ktor.routing.* import io.ktor.features.ContentNegotiation
import org.isoron.uhabits.sync.* import io.ktor.features.DefaultHeaders
import org.isoron.uhabits.sync.repository.* import io.ktor.jackson.jackson
import org.isoron.uhabits.sync.server.* import io.ktor.routing.routing
import java.nio.file.* import org.isoron.uhabits.core.sync.AbstractSyncServer
import org.isoron.uhabits.server.sync.Repository
import org.isoron.uhabits.server.sync.RepositorySyncServer
import java.nio.file.Path
import java.nio.file.Paths
fun Application.main() = SyncApplication().apply { main() } fun Application.main() = SyncApplication().apply { main() }
@@ -34,7 +38,7 @@ val REPOSITORY_PATH: Path = Paths.get(System.getenv("LOOP_REPO_PATH")!!)
class SyncApplication( class SyncApplication(
val server: AbstractSyncServer = RepositorySyncServer( val server: AbstractSyncServer = RepositorySyncServer(
FileRepository(REPOSITORY_PATH), Repository(REPOSITORY_PATH),
), ),
) { ) {
fun Application.main() { fun Application.main() {
@@ -44,9 +48,7 @@ class SyncApplication(
jackson { } jackson { }
} }
routing { routing {
registration(this@SyncApplication)
storage(this@SyncApplication) storage(this@SyncApplication)
links(this@SyncApplication)
metrics(this@SyncApplication) metrics(this@SyncApplication)
} }
} }

View File

@@ -17,17 +17,17 @@
* 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.repository package org.isoron.uhabits.server.sync
import org.isoron.uhabits.sync.* import org.isoron.uhabits.core.sync.KeyNotFoundException
import java.io.* import org.isoron.uhabits.core.sync.SyncData
import java.nio.file.* import java.io.PrintWriter
import java.nio.file.Path
class FileRepository( class Repository(
private val basepath: Path, private val basepath: Path,
) : Repository { ) {
fun put(key: String, data: SyncData) {
override suspend fun put(key: String, data: SyncData) {
// Create directory // Create directory
val dataPath = key.toDataPath() val dataPath = key.toDataPath()
val dataDir = dataPath.toFile() val dataDir = dataPath.toFile()
@@ -50,7 +50,7 @@ class FileRepository(
} }
} }
override suspend fun get(key: String): SyncData { fun get(key: String): SyncData {
val dataPath = key.toDataPath() val dataPath = key.toDataPath()
val contentFile = dataPath.resolve("content").toFile() val contentFile = dataPath.resolve("content").toFile()
val versionFile = dataPath.resolve("version").toFile() val versionFile = dataPath.resolve("version").toFile()
@@ -61,7 +61,7 @@ class FileRepository(
return SyncData(version, contentFile.readText()) return SyncData(version, contentFile.readText())
} }
override suspend fun contains(key: String): Boolean { fun contains(key: String): Boolean {
val dataPath = key.toDataPath() val dataPath = key.toDataPath()
val versionFile = dataPath.resolve("version").toFile() val versionFile = dataPath.resolve("version").toFile()
return versionFile.exists() return versionFile.exists()
@@ -70,4 +70,4 @@ class FileRepository(
private fun String.toDataPath(): Path { private fun String.toDataPath(): Path {
return basepath.resolve("${this[0]}/${this[1]}/${this[2]}/${this[3]}/$this") return basepath.resolve("${this[0]}/${this[1]}/${this[2]}/${this[3]}/$this")
} }
} }

View File

@@ -17,20 +17,19 @@
* 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.server package org.isoron.uhabits.server.sync
import io.prometheus.client.* import io.prometheus.client.Counter
import org.isoron.uhabits.sync.* import org.isoron.uhabits.core.sync.AbstractSyncServer
import org.isoron.uhabits.sync.links.* import org.isoron.uhabits.core.sync.EditConflictException
import org.isoron.uhabits.sync.repository.* import org.isoron.uhabits.core.sync.KeyNotFoundException
import org.isoron.uhabits.sync.utils.* import org.isoron.uhabits.core.sync.SyncData
/** /**
* 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 {
private val requestsCounter: Counter = Counter.build() private val requestsCounter: Counter = Counter.build()
@@ -39,21 +38,13 @@ class RepositorySyncServer(
.labelNames("method") .labelNames("method")
.register() .register()
override suspend fun register(): String {
requestsCounter.labels("register").inc()
val key = generateKey()
repo.put(key, SyncData(0, ""))
return key
}
override suspend fun put(key: String, newData: SyncData) { override suspend fun put(key: String, newData: SyncData) {
requestsCounter.labels("put").inc() requestsCounter.labels("put").inc()
if (!repo.contains(key)) { if (repo.contains(key)) {
throw KeyNotFoundException() val prevData = repo.get(key)
} if (newData.version != prevData.version + 1) {
val prevData = repo.get(key) throw EditConflictException()
if (newData.version != prevData.version + 1) { }
throw EditConflictException()
} }
repo.put(key, newData) repo.put(key, newData)
} }
@@ -73,22 +64,4 @@ class RepositorySyncServer(
} }
return repo.get(key).version return repo.get(key).version
} }
override suspend fun registerLink(syncKey: String): Link {
requestsCounter.labels("registerLink").inc()
return linkManager.register(syncKey)
}
override suspend fun getLink(id: String): Link {
requestsCounter.labels("getLink").inc()
return linkManager.get(id)
}
private suspend fun generateKey(): String {
while (true) {
val key = randomString(64)
if (!repo.contains(key))
return key
}
}
} }

View File

@@ -17,15 +17,12 @@
* 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.utils package org.isoron.uhabits.server.sync
import java.util.* import com.fasterxml.jackson.databind.ObjectMapper
import kotlin.streams.* import org.isoron.uhabits.core.sync.GetDataVersionResponse
import org.isoron.uhabits.core.sync.SyncData
fun randomString(length: Long): String { val defaultMapper = ObjectMapper()
val chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" fun SyncData.toJson(): String = defaultMapper.writeValueAsString(this)
return Random().ints(length, 0, chars.length) fun GetDataVersionResponse.toJson(): String = defaultMapper.writeValueAsString(this)
.asSequence()
.map(chars::get)
.joinToString("")
}

View File

@@ -1,55 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson 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)
}
}
}

View File

@@ -1,37 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson 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.response.*
import io.ktor.routing.*
import org.isoron.uhabits.sync.*
fun Routing.registration(app: SyncApplication) {
post("/register") {
try {
val key = app.server.register()
call.respond(HttpStatusCode.OK, RegisterReponse(key))
} catch (e: ServiceUnavailable) {
call.respond(HttpStatusCode.ServiceUnavailable)
}
}
}

View File

@@ -1,36 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson 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.defaultMapper
/**
* 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)

View File

@@ -1,49 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson 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
}
}

View File

@@ -1,46 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson 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.repository
import org.isoron.uhabits.sync.KeyNotFoundException
import org.isoron.uhabits.sync.SyncData
/**
* 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
}

View File

@@ -4,6 +4,6 @@ ktor {
port = ${?PORT} port = ${?PORT}
} }
application { application {
modules = [ org.isoron.uhabits.sync.app.SyncApplicationKt.main ] modules = [ org.isoron.uhabits.server.app.SyncApplicationKt.main ]
} }
} }

View File

@@ -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.app package org.isoron.uhabits.server.app
import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.mock
import io.ktor.application.* import io.ktor.application.Application
import org.isoron.uhabits.sync.server.* import org.isoron.uhabits.core.sync.AbstractSyncServer
open class BaseApplicationTest { open class BaseApplicationTest {

View File

@@ -17,16 +17,27 @@
* 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.app package org.isoron.uhabits.server.app
import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever import com.nhaarman.mockitokotlin2.whenever
import io.ktor.http.* import io.ktor.http.ContentType
import io.ktor.server.testing.* import io.ktor.http.HttpHeaders
import kotlinx.coroutines.* import io.ktor.http.HttpMethod
import org.isoron.uhabits.sync.* import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.TestApplicationCall
import io.ktor.server.testing.TestApplicationEngine
import io.ktor.server.testing.handleRequest
import io.ktor.server.testing.setBody
import io.ktor.server.testing.withTestApplication
import kotlinx.coroutines.runBlocking
import org.isoron.uhabits.core.sync.EditConflictException
import org.isoron.uhabits.core.sync.GetDataVersionResponse
import org.isoron.uhabits.core.sync.KeyNotFoundException
import org.isoron.uhabits.core.sync.SyncData
import org.isoron.uhabits.server.sync.toJson
import org.junit.Test import org.junit.Test
import kotlin.test.* import kotlin.test.assertEquals
class StorageModuleTest : BaseApplicationTest() { class StorageModuleTest : BaseApplicationTest() {
private val data1 = SyncData(1, "Hello world") private val data1 = SyncData(1, "Hello world")
@@ -64,7 +75,6 @@ class StorageModuleTest : BaseApplicationTest() {
} }
} }
@Test @Test
fun `when put succeeds should return OK`(): Unit = runBlocking { fun `when put succeeds should return OK`(): Unit = runBlocking {
withTestApplication(app()) { withTestApplication(app()) {
@@ -77,16 +87,6 @@ class StorageModuleTest : BaseApplicationTest() {
} }
} }
@Test
fun `when put with invalid key should return 404`(): Unit = runBlocking {
whenever(server.put("k1", data1)).thenThrow(KeyNotFoundException())
withTestApplication(app()) {
handlePut("/db/k1", data1).apply {
assertEquals(HttpStatusCode.NotFound, response.status())
}
}
}
@Test @Test
fun `when put with invalid version should return 409 and current data`(): Unit = runBlocking { fun `when put with invalid version should return 409 and current data`(): Unit = runBlocking {
whenever(server.put("k1", data1)).thenThrow(EditConflictException()) whenever(server.put("k1", data1)).thenThrow(EditConflictException())

View File

@@ -17,26 +17,25 @@
* 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.server package org.isoron.uhabits.server.sync
import kotlinx.coroutines.* import kotlinx.coroutines.runBlocking
import org.isoron.uhabits.sync.* import org.isoron.uhabits.core.sync.EditConflictException
import org.isoron.uhabits.sync.repository.* import org.isoron.uhabits.core.sync.KeyNotFoundException
import org.isoron.uhabits.core.sync.SyncData
import org.junit.Test import org.junit.Test
import java.nio.file.* import java.nio.file.Files
import kotlin.test.* import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class RepositorySyncServerTest { class RepositorySyncServerTest {
private val tempdir = Files.createTempDirectory("db") private val tempdir = Files.createTempDirectory("db")
private val server = RepositorySyncServer(FileRepository(tempdir)) private val server = RepositorySyncServer(Repository(tempdir))
private val key = runBlocking { server.register() } private val key = "abcdefgh"
@Test @Test
fun testUsage(): Unit = runBlocking { fun testUsage(): Unit = runBlocking {
val data0 = SyncData(0, "")
assertEquals(server.getData(key), data0)
val data1 = SyncData(1, "Hello world") val data1 = SyncData(1, "Hello world")
server.put(key, data1) server.put(key, data1)
assertEquals(server.getData(key), data1) assertEquals(server.getData(key), data1)
@@ -52,9 +51,5 @@ class RepositorySyncServerTest {
assertFailsWith<KeyNotFoundException> { assertFailsWith<KeyNotFoundException> {
server.getData("INVALID") server.getData("INVALID")
} }
assertFailsWith<KeyNotFoundException> {
server.put("INVALID", data0)
}
} }
} }

View File

@@ -17,24 +17,25 @@
* with this program. If not, see <http://www.gnu.org/licenses/>. * with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
@file:Suppress("BlockingMethodInNonBlockingContext") @file:Suppress("BlockingMethodInNonBlockingContext")
package org.isoron.uhabits.sync.repository package org.isoron.uhabits.server.sync
import kotlinx.coroutines.* import kotlinx.coroutines.runBlocking
import org.hamcrest.CoreMatchers.* import org.hamcrest.CoreMatchers.equalTo
import org.isoron.uhabits.sync.* import org.isoron.uhabits.core.sync.SyncData
import org.junit.* import org.junit.Assert.assertEquals
import org.junit.Assert.* import org.junit.Assert.assertThat
import java.nio.file.* import org.junit.Assert.assertTrue
import org.junit.Test
import java.nio.file.Files
class FileRepositoryTest { class RepositoryTest {
@Test @Test
fun testUsage() = runBlocking { fun testUsage() = runBlocking {
val tempdir = Files.createTempDirectory("db")!! val tempdir = Files.createTempDirectory("db")!!
val repo = FileRepository(tempdir) val repo = Repository(tempdir)
val original = SyncData(10, "Hello world") val original = SyncData(10, "Hello world")
repo.put("abcdefg", original) repo.put("abcdefg", original)
@@ -50,4 +51,4 @@ class FileRepositoryTest {
val retrieved = repo.get("abcdefg") val retrieved = repo.get("abcdefg")
assertThat(retrieved, equalTo(original)) assertThat(retrieved, equalTo(original))
} }
} }

View File

@@ -1,91 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson 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 com.nhaarman.mockitokotlin2.whenever
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.TestApplicationCall
import io.ktor.server.testing.TestApplicationEngine
import io.ktor.server.testing.handleRequest
import io.ktor.server.testing.setBody
import io.ktor.server.testing.withTestApplication
import kotlinx.coroutines.runBlocking
import org.isoron.uhabits.sync.KeyNotFoundException
import org.isoron.uhabits.sync.links.Link
import org.isoron.uhabits.sync.links.toJson
import org.junit.Test
import kotlin.test.assertEquals
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 {
whenever(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 {
whenever(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 {
whenever(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)
}
}

View File

@@ -1,51 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson 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 com.nhaarman.mockitokotlin2.whenever
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.handleRequest
import io.ktor.server.testing.withTestApplication
import kotlinx.coroutines.runBlocking
import org.isoron.uhabits.sync.ServiceUnavailable
import org.junit.Test
import kotlin.test.assertEquals
class RegistrationModuleTest : BaseApplicationTest() {
@Test
fun `when register succeeds should return generated key`(): Unit = runBlocking {
whenever(server.register()).thenReturn("ABCDEF")
withTestApplication(app()) {
val call = handleRequest(HttpMethod.Post, "/register")
assertEquals(HttpStatusCode.OK, call.response.status())
assertEquals("{\"key\":\"ABCDEF\"}", call.response.content)
}
}
@Test
fun `when registration is unavailable should return 503`(): Unit = runBlocking {
whenever(server.register()).thenThrow(ServiceUnavailable())
withTestApplication(app()) {
val call = handleRequest(HttpMethod.Post, "/register")
assertEquals(HttpStatusCode.ServiceUnavailable, call.response.status())
}
}
}

View File

@@ -1,44 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson 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")
}
}
}