diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7bffa1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,169 @@ +# Created by https://www.toptal.com/developers/gitignore/api/kotlin,intellij,maven +# Edit at https://www.toptal.com/developers/gitignore?templates=kotlin,intellij,maven + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Kotlin ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### Maven ### +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +# https://github.com/takari/maven-wrapper#usage-without-binary-jar +.mvn/wrapper/maven-wrapper.jar + +# Eclipse m2e generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +# End of https://www.toptal.com/developers/gitignore/api/kotlin,intellij,maven,dotenv + + + + +.idea/ +data.db +*.iml \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ed0302 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# An alternative API to Google Keep + +# Authentication + +The api use two means of authentication: +- A configurable `Authorization` that is set in a env variable, and that will only be used to create the second mean of authentication +- Tokens used to do everything else. + + +## HOW TO USE + +- Do a maven install (cli or GUI) +- Create a env variable `LDC_AUTH` containing the Auth header you want to use to create authentication keys. +- java -jar liste-de-courses-XXX-jar-with-dependencies.jar +- The jar is now running on localhost:7000 \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..a1e7506 --- /dev/null +++ b/pom.xml @@ -0,0 +1,149 @@ + + + 4.0.0 + + liste-de-courses + fr.louveau-amine + 1.2-SNAPSHOT + jar + + liste-de-courses + + + UTF-8 + official + 1.8 + true + ServerKt + + + + + mavenCentral + https://repo1.maven.org/maven2/ + + + jcenter + jcenter + https://jcenter.bintray.com + + + + + src/main/kotlin + src/test/kotlin + + + org.jetbrains.kotlin + kotlin-maven-plugin + 1.4.32 + + + compile + compile + + compile + + + + test-compile + test-compile + + test-compile + + + + + + maven-surefire-plugin + 2.22.2 + + + maven-failsafe-plugin + 2.22.2 + + + org.apache.maven.plugins + maven-assembly-plugin + 2.6 + + + make-assembly + package + single + + + + ${main.class} + + + + jar-with-dependencies + + + + + + + + + + + org.jetbrains.kotlin + kotlin-test-junit + 1.4.32 + test + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + 1.4.32 + + + + org.slf4j + slf4j-simple + 1.7.30 + + + + io.javalin + javalin + 3.13.9 + + + com.fasterxml.jackson.core + jackson-databind + 2.10.5 + + + org.xerial + sqlite-jdbc + 3.31.1 + + + + org.jetbrains.exposed + exposed-core + 0.31.1 + + + org.jetbrains.exposed + exposed-dao + 0.31.1 + + + org.jetbrains.exposed + exposed-jdbc + 0.31.1 + + + com.fasterxml.jackson.module + jackson-module-kotlin + 2.10.5 + + + + \ No newline at end of file diff --git a/src/main/kotlin/KeysController.kt b/src/main/kotlin/KeysController.kt new file mode 100644 index 0000000..0fba188 --- /dev/null +++ b/src/main/kotlin/KeysController.kt @@ -0,0 +1,34 @@ +import dao.DB +import dao.ItemView +import dao.KeyView +import io.javalin.apibuilder.CrudHandler +import io.javalin.http.Context + +class KeysController : CrudHandler { + private val db: DB = DB() + + override fun create(ctx: Context) { + db.createKey(ctx) + } + + override fun delete(ctx: Context, resourceId: String) { + db.deleteKey(ctx) + } + + override fun getAll(ctx: Context) { + ctx.status(404) + } + + override fun getOne(ctx: Context, resourceId: String) { + ctx.status(404) + } + + override fun update(ctx: Context, resourceId: String) { + ctx.status(404) + } + + fun checkKey(ctx: Context) { + db.checkKey(ctx) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/ListItemsController.kt b/src/main/kotlin/ListItemsController.kt new file mode 100644 index 0000000..4d0bde4 --- /dev/null +++ b/src/main/kotlin/ListItemsController.kt @@ -0,0 +1,24 @@ +import dao.DB +import dao.ItemPatchView +import dao.ItemView +import io.javalin.http.Context + +class ListItemsController { + private val db: DB = DB() + + fun create(listId: String, ctx: Context) { + db.createItem(listId.toInt(), ctx.body(), ctx) + } + + fun update(listId: String, itemId: String, ctx: Context) { + db.updateItem(listId.toInt(), itemId.toInt(), ctx.body(), ctx) + } + + fun delete(listId: String, itemId: String, ctx: Context) { + db.deleteItem(listId.toInt(), itemId.toInt(), ctx) + } + + fun getListItems(ctx: Context, resourceId: String) { + db.findListItems(resourceId.toInt(), ctx) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ListsController.kt b/src/main/kotlin/ListsController.kt new file mode 100644 index 0000000..2244903 --- /dev/null +++ b/src/main/kotlin/ListsController.kt @@ -0,0 +1,29 @@ +import dao.DB +import dao.ListView +import io.javalin.apibuilder.CrudHandler +import io.javalin.http.Context + +class ListsController : CrudHandler { + private val db: DB = DB() + + override fun create(ctx: Context) { + db.createList(ctx.body(), ctx) + } + + override fun delete(ctx: Context, resourceId: String) { + db.deleteList(resourceId.toInt(), ctx) + } + + override fun getAll(ctx: Context) { + db.getAllLists(ctx) + } + + override fun getOne(ctx: Context, resourceId: String) { + db.findList(resourceId.toInt(), ctx) + } + + override fun update(ctx: Context, resourceId: String) { + db.updateList(resourceId.toInt(), ctx.body(), ctx) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/dao/DB.kt b/src/main/kotlin/dao/DB.kt new file mode 100644 index 0000000..d832fce --- /dev/null +++ b/src/main/kotlin/dao/DB.kt @@ -0,0 +1,195 @@ +package dao + +import io.javalin.http.Context +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.transactions.transaction + +class DB { + + /* + * Lists + */ + fun createList(c: ListView, ctx: Context) { + transaction { + var i = List.new { + name = c.name + } + ctx.json(i.toView()) + } + + } + + fun deleteList(resourceId: Int, ctx: Context) { + transaction { + val list = List.findById(resourceId) + + if (list != null) { + Item.find { Items.list eq list.id.value}.forEach { i -> i.delete() } + } + + list?.delete() ?: ctx.status(404) + } + } + + fun getAllLists(ctx: Context) { + transaction { + ctx.json(List.all().map { it.toView() }) + } + } + + fun findList(resourceID: Int, ctx: Context) { + transaction { + val listOpt = List.findById(resourceID) + if (listOpt != null) { + ctx.json(listOpt.toView()) + } else { + ctx.status(404) + } + } + } + + fun updateList(resourceID: Int, body: ListView, ctx: Context) { + transaction { + val list = List.findById(resourceID) + if (list != null) { + if (body.name != null) { + list?.name = body.name + } + ctx.json(list.toView()) + } else { + ctx.status(404) + } + } + } + + /* + * Items + */ + fun findListItems(resourceID: Int, ctx: Context) { + transaction { + val listOpt = List.findById(resourceID) + if (listOpt != null) { + val items = Item.find { Items.list eq resourceID } + .sortedBy { it.position } + + ctx.json(listOpt.toViewWithItems(items)) + } else { + ctx.status(404) + } + } + } + + fun createItem(listId: Int, c: ItemView, ctx: Context) { + transaction { + val listOpt = List.findById(listId) + if (listOpt != null) { + var i = Item.new { + list = listId + content = c.content + position = Item.all().count().toInt() + checked = false + } + + ctx.json(i.toView()) + } else { + ctx.status(404) + } + } + + } + + fun deleteItem(listId: Int, itemId: Int, ctx: Context) { + transaction { + val listOpt = List.findById(listId) + if (listOpt != null) { + val item = Item.find { Items.list eq listId and (Items.id eq itemId) }.limit(1).firstOrNull() + if (item != null) { + item.delete() + var i = 0 + Item.all() + .sortedBy { it.position } + .forEach { + it.position = i + i++ + } + } else { + ctx.status(404) + } + } else { + ctx.status(404) + } + } + } + + fun updateItem(listId: Int, itemId: Int, body: ItemPatchView, ctx: Context) { + transaction { + val listOpt = List.findById(listId) + if (listOpt != null) { + val item = Item.find { Items.list eq listId and (Items.id eq itemId) }.limit(1).firstOrNull() + if (item != null) { + var p = body.position + if (body.checked != null) { + val allITems = Item.find { Items.list eq listId }.sortedBy { it.position } + val firstChecked = allITems.firstOrNull { it.checked } + p = firstChecked?.position ?: allITems.lastOrNull()?.position + + item?.checked = body.checked + } + if (p != null) { + var i = 0 + Item.find { Items.list eq listId and (Items.id neq itemId) } + .sortedBy { it.position } + .forEach { + if (i == p) { + i++ + } + it.position = i + i++ + } + item?.position = p + } + if (body.content != null) { + item?.content = body.content + } + ctx.json(item.toView()) + } else { + ctx.status(404) + } + } else { + ctx.status(404) + } + } + } + + /* + * Api keys + */ + fun createKey(ctx: Context) { + transaction { + val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') + val k = (1..256) + .map { allowedChars.random() } + .joinToString("") + var key = Key.new { + value = k + } + ctx.json(key.toView()) + } + } + + fun checkKey(ctx: Context) { + transaction { + val key = ctx.body() + if (Key.find { Keys.value eq key }.count() > 0L) ctx.status(301) else ctx.status(404) + + } + } + + fun deleteKey(ctx: Context) { + transaction { + val k = ctx.body() + val key = Key.find { Keys.value eq k.value }.firstOrNull() + key?.delete() ?: ctx.status(404) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/dao/Items.kt b/src/main/kotlin/dao/Items.kt new file mode 100644 index 0000000..d9b8aef --- /dev/null +++ b/src/main/kotlin/dao/Items.kt @@ -0,0 +1,31 @@ +package dao + +import org.jetbrains.exposed.dao.IntEntity +import org.jetbrains.exposed.dao.IntEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.IntIdTable + +object Items : IntIdTable() { + val list = integer("list") + val content = varchar("content", 256) + val position = integer("position") + val checked = bool("checked").default(false) +} + +class Item(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Items) + + var list by Items.list + var content by Items.content + var position by Items.position + var checked by Items.checked + + +} +fun Item.toView(): ItemView { + return ItemView(this.id.value, this.content, this.checked, this.position) +} + +data class ItemView(val id: Int, val content: String, val checked: Boolean = false, val position: Int?) + +data class ItemPatchView(val id: Int, val content: String?, val checked: Boolean?, val position: Int?) \ No newline at end of file diff --git a/src/main/kotlin/dao/Keys.kt b/src/main/kotlin/dao/Keys.kt new file mode 100644 index 0000000..7328680 --- /dev/null +++ b/src/main/kotlin/dao/Keys.kt @@ -0,0 +1,23 @@ +package dao + +import org.jetbrains.exposed.dao.IntEntity +import org.jetbrains.exposed.dao.IntEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.IntIdTable + +object Keys : IntIdTable() { + val value = varchar("content", 256) +} + +class Key(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Keys) + + var value by Keys.value + + +} +fun Key.toView(): KeyView { + return KeyView(this.value) +} + +data class KeyView(val value: String) \ No newline at end of file diff --git a/src/main/kotlin/dao/Lists.kt b/src/main/kotlin/dao/Lists.kt new file mode 100644 index 0000000..d3c3c9e --- /dev/null +++ b/src/main/kotlin/dao/Lists.kt @@ -0,0 +1,29 @@ +package dao + +import org.jetbrains.exposed.dao.IntEntity +import org.jetbrains.exposed.dao.IntEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.IntIdTable + +object Lists : IntIdTable() { + val name = varchar("content", 256) +} + +class List(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(Lists) + + var name by Lists.name + + +} +fun List.toView(): ListView { + return ListView(this.id.value, this.name) +} + +fun List.toViewWithItems(items: kotlin.collections.List): ListViewWithItems { + return ListViewWithItems(this.id.value, this.name, items.map { it.toView() }) +} + +data class ListView(val id: Int, val name: String) + +data class ListViewWithItems(val id: Int, val name: String, val items: kotlin.collections.List) \ No newline at end of file diff --git a/src/main/kotlin/server.kt b/src/main/kotlin/server.kt new file mode 100644 index 0000000..4002370 --- /dev/null +++ b/src/main/kotlin/server.kt @@ -0,0 +1,70 @@ +import dao.Items +import dao.Key +import dao.Keys +import dao.Lists +import io.javalin.Javalin +import io.javalin.apibuilder.ApiBuilder.* +import io.javalin.core.security.Role +import io.javalin.core.security.SecurityUtil.roles +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.StdOutSqlLogger +import org.jetbrains.exposed.sql.addLogger +import org.jetbrains.exposed.sql.transactions.transaction + +enum class ApiRole : Role { PUBLIC, BASIC_AUTHED, API_AUTHED } + +fun main() { + val app = Javalin.create { + it.accessManager { handler, ctx, permittedRoles -> + transaction { + val k = ctx.header("X-API-KEY").orEmpty() + Key.find { + Keys.value eq k + }.firstOrNull() + val auth = ctx.header("Authorization").orEmpty() + val isBasicAuthed = auth == System.getenv("LDC_AUTH") + when { + permittedRoles.contains(ApiRole.PUBLIC) -> handler.handle(ctx) + permittedRoles.contains(ApiRole.BASIC_AUTHED) && isBasicAuthed -> handler.handle(ctx) + permittedRoles.contains(ApiRole.API_AUTHED) && k != null -> handler.handle(ctx) + else -> ctx.status(401).json("Unauthorized") + } + } + } + }.start(7000) + Database.connect("jdbc:sqlite:data.db", "org.sqlite.JDBC") + transaction { + addLogger(StdOutSqlLogger) + SchemaUtils.create (Lists) + SchemaUtils.create (Items) + SchemaUtils.create (Keys) + } + + app.routes { + path("api") { + crud("keys/:key-id", KeysController(), roles(ApiRole.BASIC_AUTHED)) + crud("lists/:list-id", ListsController(), roles(ApiRole.API_AUTHED)) + } + + post("/api/keys:check", { ctx -> + KeysController().checkKey(ctx) + }, roles(ApiRole.PUBLIC)) + + get("/api/lists/:list-id/items", { ctx -> + ListItemsController().getListItems(ctx, ctx.pathParam("list-id")) + }, roles(ApiRole.API_AUTHED)) + + post("/api/lists/:list-id/items", { ctx -> + ListItemsController().create(ctx.pathParam("list-id"), ctx) + }, roles(ApiRole.API_AUTHED)) + + delete("/api/lists/:list-id/items/:item-id", { ctx -> + ListItemsController().delete(ctx.pathParam("list-id"), ctx.pathParam("item-id"), ctx) + }, roles(ApiRole.API_AUTHED)) + + patch("/api/lists/:list-id/items/:item-id", { ctx -> + ListItemsController().update(ctx.pathParam("list-id"), ctx.pathParam("item-id"), ctx) + }, roles(ApiRole.API_AUTHED)) + } +} \ No newline at end of file