Merge branch 'export'

This commit is contained in:
Hubert Van De Walle 2020-08-19 02:37:00 +02:00
parent 8ccd7f6058
commit 88b6eb56ae
19 changed files with 300 additions and 124 deletions

View File

@ -42,7 +42,6 @@
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-runtime</artifactId>
<version>0.20.0</version>
</dependency>
<dependency>

View File

@ -2,6 +2,7 @@ package be.simplenotes.app
import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.SettingsController
import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.AuthFilter
import be.simplenotes.app.filters.AuthType
@ -10,6 +11,7 @@ import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.utils.StaticFileResolverImpl
import be.simplenotes.app.views.BaseView
import be.simplenotes.app.views.NoteView
import be.simplenotes.app.views.SettingView
import be.simplenotes.app.views.UserView
import be.simplenotes.domain.domainModule
import be.simplenotes.persistance.DbMigrations
@ -33,6 +35,7 @@ fun main() {
userModule,
baseModule,
noteModule,
settingsModule,
)
}.koin
@ -58,6 +61,7 @@ val serverModule = module {
get(),
get(),
get(),
get(),
requiredAuth = get(AuthType.Required.qualifier),
optionalAuth = get(AuthType.Optional.qualifier),
get()
@ -84,6 +88,11 @@ val noteModule = module {
single { NoteView(get()) }
}
val settingsModule = module {
single { SettingsController(get(), get()) }
single { SettingView(get()) }
}
val configModule = module {
single { Config.dataSourceConfig }
single { Config.jwtConfig }

View File

@ -0,0 +1,59 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect
import be.simplenotes.app.views.SettingView
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.usecases.UserService
import be.simplenotes.domain.usecases.users.delete.DeleteError
import be.simplenotes.domain.usecases.users.delete.DeleteForm
import org.http4k.core.Method
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status
import org.http4k.core.body.form
import org.http4k.core.cookie.invalidateCookie
class SettingsController(
private val userService: UserService,
private val settingView: SettingView,
) {
fun settings(request: Request, jwtPayload: JwtPayload): Response {
if (request.method == Method.GET)
return Response(Status.OK).html(settingView.settings(jwtPayload))
val deleteForm = request.deleteForm(jwtPayload)
val result = userService.delete(deleteForm)
return result.fold(
{
when (it) {
DeleteError.Unregistered -> Response.redirect("/").invalidateCookie("Authorization")
DeleteError.WrongPassword -> Response(Status.OK).html(
settingView.settings(
jwtPayload,
error = "Wrong password"
)
)
is DeleteError.InvalidForm -> Response(Status.OK).html(
settingView.settings(
jwtPayload,
validationErrors = it.validationErrors
)
)
}
},
{
Response.redirect("/").invalidateCookie("Authorization")
}
)
}
fun export(request: Request, jwtPayload: JwtPayload): Response {
val json = userService.export(jwtPayload.userId)
return Response(Status.OK).body(json).header("Content-Type", "application/json")
}
private fun Request.deleteForm(jwtPayload: JwtPayload) =
DeleteForm(jwtPayload.username, form("password"), form("checked") != null)
}

View File

@ -6,8 +6,6 @@ import be.simplenotes.app.extensions.redirect
import be.simplenotes.app.views.UserView
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.usecases.UserService
import be.simplenotes.domain.usecases.users.delete.DeleteError
import be.simplenotes.domain.usecases.users.delete.DeleteForm
import be.simplenotes.domain.usecases.users.login.*
import be.simplenotes.domain.usecases.users.register.InvalidRegisterForm
import be.simplenotes.domain.usecases.users.register.RegisterForm
@ -112,38 +110,4 @@ class UserController(
fun logout(@Suppress("UNUSED_PARAMETER") request: Request) = Response.redirect("/")
.invalidateCookie("Authorization")
private fun Request.deleteForm(jwtPayload: JwtPayload) =
DeleteForm(jwtPayload.username, form("password"), form("checked") != null)
fun settings(request: Request, jwtPayload: JwtPayload): Response {
if (request.method == GET)
return Response(OK).html(userView.settings(jwtPayload))
val deleteForm = request.deleteForm(jwtPayload)
val result = userService.delete(deleteForm)
return result.fold(
{
when (it) {
DeleteError.Unregistered -> Response.redirect("/").invalidateCookie("Authorization")
DeleteError.WrongPassword -> Response(OK).html(
userView.settings(
jwtPayload,
error = "Wrong password"
)
)
is DeleteError.InvalidForm -> Response(OK).html(
userView.settings(
jwtPayload,
validationErrors = it.validationErrors
)
)
}
},
{
Response.redirect("/").invalidateCookie("Authorization")
}
)
}
}

View File

@ -0,0 +1,15 @@
package be.simplenotes.app.extensions
import kotlinx.html.*
class SUMMARY(consumer: TagConsumer<*>) :
HTMLTag(
"summary", consumer, emptyMap(),
inlineTag = true,
emptyTag = false
),
HtmlInlineTag
fun DETAILS.summary(block: SUMMARY.() -> Unit = {}) {
SUMMARY(consumer).visit(block)
}

View File

@ -2,6 +2,7 @@ package be.simplenotes.app.routes
import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.SettingsController
import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.ErrorFilter
import be.simplenotes.app.filters.ImmutableFilter
@ -19,6 +20,7 @@ class Router(
private val baseController: BaseController,
private val userController: UserController,
private val noteController: NoteController,
private val settingsController: SettingsController,
private val requiredAuth: Filter,
private val optionalAuth: Filter,
private val contexts: RequestContexts,
@ -42,9 +44,9 @@ class Router(
)
val protectedRoutes = routes(
"/settings" bind GET to { protected(it, userController::settings) },
"/settings" bind POST to { protected(it, userController::settings) },
"/export" bind POST to { TODO() },
"/settings" bind GET to { protected(it, settingsController::settings) },
"/settings" bind POST to { protected(it, settingsController::settings) },
"/export" bind POST to { protected(it, settingsController::export) },
"/notes" bind GET to { protected(it, noteController::list) },
"/notes/new" bind GET to { protected(it, noteController::new) },
"/notes/new" bind POST to { protected(it, noteController::new) },

View File

@ -1,7 +1,6 @@
package be.simplenotes.app.utils
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonConfiguration
import kotlinx.serialization.json.*
interface StaticFileResolver {
fun resolve(name: String): String?
@ -11,12 +10,12 @@ class StaticFileResolverImpl : StaticFileResolver {
private val mappings: Map<String, String>
init {
val json = Json(JsonConfiguration.Stable)
val json = Json {}
val manifest = javaClass.getResource("/css-manifest.json").readText()
val manifestObject = json.parseJson(manifest).jsonObject
val manifestObject = json.parseToJsonElement(manifest).jsonObject
val keys = manifestObject.keys
mappings = keys.map {
it to "/${manifestObject[it]!!.primitive.content}"
it to "/${manifestObject[it]!!.jsonPrimitive.content}"
}.toMap()
}

View File

@ -0,0 +1,81 @@
package be.simplenotes.app.views
import be.simplenotes.app.extensions.summary
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.views.components.Alert
import be.simplenotes.app.views.components.alert
import be.simplenotes.app.views.components.input
import be.simplenotes.domain.security.JwtPayload
import io.konform.validation.ValidationError
import kotlinx.html.*
import kotlinx.html.ButtonType.submit
class SettingView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
fun settings(
jwtPayload: JwtPayload,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
) = renderPage("Settings", jwtPayload = jwtPayload) {
div("container mx-auto") {
section("m-4 p-4 bg-gray-800 rounded") {
h1("text-xl") {
+"Welcome "
span("text-teal-200 font-semibold") { +jwtPayload.username }
}
}
section("m-4 p-4 bg-gray-800 rounded") {
p(classes = "mb-4") {
+"Export all my data"
}
form(method = FormMethod.post, action = "/export") {
button(classes = "btn btn-teal block", type = submit) { +"Export my data" }
}
}
section(classes = "m-4 p-4 bg-gray-800 rounded") {
h2(classes = "mb-4 text-red-600 text-lg font-semibold") {
+"Delete my account"
}
error?.let { alert(Alert.Warning, error) }
details {
if (error != null || validationErrors.isNotEmpty()) {
attributes["open"] = ""
}
summary {
span(classes = "mb-4 font-semibold underline") {
+"Are you sure? "
+"You are about to delete this user, and this process is irreversible !"
}
}
form(classes = "mt-4", method = FormMethod.post) {
input(
id = "password",
placeholder = "Password",
autoComplete = "off",
type = InputType.password,
error = validationErrors.find { it.dataPath == ".password" }?.message
)
checkBoxInput(name = "checked") {
attributes["required"] = ""
+" Do you want to proceed ?"
}
button(
type = submit,
classes = "block mt-4 btn btn-red",
name = "delete"
) { +"I'm sure" }
}
}
}
}
}
}

View File

@ -71,79 +71,4 @@ class UserView(staticFileResolver: StaticFileResolver) : View(staticFileResolver
}
}
}
fun settings(
jwtPayload: JwtPayload,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
) = renderPage("Settings", jwtPayload = jwtPayload) {
div("container mx-auto") {
section("m-4 p-4 bg-gray-800 rounded") {
h1("text-xl") {
+"Welcome "
span("text-teal-200 font-semibold") { +jwtPayload.username }
}
}
section("m-4 p-4 bg-gray-800 rounded") {
p(classes = "mb-4") {
+"Export all my data in a zip file"
}
form {
button(classes = "btn btn-teal block") { +"Export my data" }
}
}
section(classes = "m-4 p-4 bg-gray-800 rounded") {
h2(classes = "mb-4 text-red-600 text-lg font-semibold") {
+"Delete my account"
}
error?.let { alert(Alert.Warning, error) }
details {
if (error != null || validationErrors.isNotEmpty()) {
attributes["open"] = ""
}
summary {
span(classes = "mb-4 font-semibold underline") {
+"Are you sure? "
+"You are about to delete this user, and this process is irreversible !"
}
}
form(classes = "mt-4", method = FormMethod.post) {
input(
id = "password",
placeholder = "Password",
autoComplete = "off",
type = InputType.password,
error = validationErrors.find { it.dataPath == ".password" }?.message
)
checkBoxInput(name = "checked") {
attributes["required"] = ""
+" Do you want to proceed ?"
}
button(type = submit, classes = "block mt-4 btn btn-red", name = "delete") { +"I'm sure" }
}
}
}
}
}
}
class SUMMARY(consumer: TagConsumer<*>) :
HTMLTag(
"summary", consumer, emptyMap(),
inlineTag = true,
emptyTag = false
),
HtmlInlineTag
fun DETAILS.summary(block: SUMMARY.() -> Unit = {}) {
SUMMARY(consumer).visit(block)
}

View File

@ -4,5 +4,5 @@ rm app/src/main/resources/css-manifest.json
rm app/src/main/resources/static/styles*
yarn --cwd css run css-purge \
&& docker build -t hubv/simplenotes . \
&& docker push hubv/simplenotes
&& docker build -t hubv/simplenotes:latest . \
&& docker push hubv/simplenotes:latest

View File

@ -57,6 +57,10 @@
<artifactId>owasp-java-html-sanitizer</artifactId>
<version>20200615.1</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-runtime</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -6,6 +6,8 @@ import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.usecases.NoteService
import be.simplenotes.domain.usecases.UserService
import be.simplenotes.domain.usecases.export.ExportUseCase
import be.simplenotes.domain.usecases.export.ExportUseCaseImpl
import be.simplenotes.domain.usecases.markdown.MarkdownConverter
import be.simplenotes.domain.usecases.markdown.MarkdownConverterImpl
import be.simplenotes.domain.usecases.users.delete.DeleteUseCase
@ -20,10 +22,11 @@ val domainModule = module {
single<LoginUseCase> { LoginUseCaseImpl(get(), get(), get()) }
single<RegisterUseCase> { RegisterUseCaseImpl(get(), get()) }
single<DeleteUseCase> { DeleteUseCaseImpl(get(), get()) }
single { UserService(get(), get(), get()) }
single { UserService(get(), get(), get(), get()) }
single<PasswordHash> { BcryptPasswordHash() }
single { SimpleJwt(get()) }
single { JwtPayloadExtractor(get()) }
single { NoteService(get(), get()) }
single<MarkdownConverter> { MarkdownConverterImpl() }
single<ExportUseCase> { ExportUseCaseImpl(get()) }
}

View File

@ -1,5 +1,7 @@
package be.simplenotes.domain.model
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import java.time.LocalDateTime
import java.util.*
@ -28,3 +30,13 @@ data class PersistedNote(
val updatedAt: LocalDateTime,
val uuid: UUID,
)
@Serializable
data class ExportedNote(
val title: String,
val tags: List<String>,
val markdown: String,
val html: String,
@Contextual val updatedAt: LocalDateTime,
val trash: Boolean,
)

View File

@ -1,5 +1,6 @@
package be.simplenotes.domain.usecases
import be.simplenotes.domain.usecases.export.ExportUseCase
import be.simplenotes.domain.usecases.users.delete.DeleteUseCase
import be.simplenotes.domain.usecases.users.login.LoginUseCase
import be.simplenotes.domain.usecases.users.register.RegisterUseCase
@ -8,4 +9,8 @@ class UserService(
loginUseCase: LoginUseCase,
registerUseCase: RegisterUseCase,
deleteUseCase: DeleteUseCase,
) : LoginUseCase by loginUseCase, RegisterUseCase by registerUseCase, DeleteUseCase by deleteUseCase
exportUseCase: ExportUseCase,
) : LoginUseCase by loginUseCase,
RegisterUseCase by registerUseCase,
DeleteUseCase by deleteUseCase,
ExportUseCase by exportUseCase

View File

@ -0,0 +1,5 @@
package be.simplenotes.domain.usecases.export
interface ExportUseCase {
fun export(userId: Int): String
}

View File

@ -0,0 +1,43 @@
package be.simplenotes.domain.usecases.export
import be.simplenotes.domain.model.ExportedNote
import be.simplenotes.domain.usecases.repositories.NoteRepository
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import java.time.LocalDateTime
internal class ExportUseCaseImpl(private val noteRepository: NoteRepository) : ExportUseCase {
override fun export(userId: Int): String {
val module = SerializersModule {
contextual(LocalDateTime::class, LocalDateTimeSerializer)
}
val json = Json {
prettyPrint = true
serializersModule = module
}
val notes = noteRepository.export(userId)
return json.encodeToString(ListSerializer(ExportedNote.serializer()), notes)
}
}
internal object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDateTime) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): LocalDateTime {
TODO("Not implemented, isn't needed")
}
}

View File

@ -1,5 +1,6 @@
package be.simplenotes.domain.usecases.repositories
import be.simplenotes.domain.model.ExportedNote
import be.simplenotes.domain.model.Note
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
@ -25,4 +26,5 @@ interface NoteRepository {
fun create(userId: Int, note: Note): PersistedNote
fun find(userId: Int, uuid: UUID): PersistedNote?
fun update(userId: Int, uuid: UUID, note: Note): PersistedNote?
fun export(userId: Int): List<ExportedNote>
}

View File

@ -1,5 +1,6 @@
package be.simplenotes.persistance.notes
import be.simplenotes.domain.model.ExportedNote
import be.simplenotes.domain.model.Note
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
@ -167,4 +168,33 @@ internal class NoteRepositoryImpl(private val db: Database) : NoteRepository {
(it.name eq tag) and (it.note.userId eq userId) and (it.note.deleted eq deleted)
}
}
override fun export(userId: Int): List<ExportedNote> {
val notes = db.notes
.filterColumns { it.columns - it.userId }
.filter { it.userId eq userId }
.sortedByDescending { it.updatedAt }
.toList()
if (notes.isEmpty()) return emptyList()
val uuids = notes.map { note -> note.uuid }
val tagsByUuid = db.tags
.filterColumns { listOf(it.noteUuid, it.name) }
.filter { it.noteUuid inList uuids }
.groupByTo(HashMap(), { it.note.uuid }, { it.name })
return notes.map { note ->
ExportedNote(
title = note.title,
tags = tagsByUuid[note.uuid] ?: emptyList(),
markdown = note.markdown,
html = note.html,
updatedAt = note.updatedAt,
trash = note.deleted,
)
}
}
}

19
pom.xml
View File

@ -149,7 +149,17 @@
<arg>-Xno-call-assertions</arg>
<arg>-Xno-receiver-assertions</arg>
</args>
<compilerPlugins>
<plugin>kotlinx-serialization</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-serialization</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
@ -182,6 +192,11 @@
<artifactId>kotlin-reflect</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-runtime</artifactId>
<version>1.0-M1-1.4.0-rc</version>
</dependency>
</dependencies>
</dependencyManagement>
@ -194,6 +209,10 @@
<id>arrow</id>
<url>https://dl.bintray.com/arrow-kt/arrow-kt/</url>
</repository>
<repository>
<id>kotlinx</id>
<url>https://kotlin.bintray.com/kotlinx</url>
</repository>
</repositories>
</project>