Merge http4k

This commit is contained in:
Hubert Van De Walle 2020-08-13 19:37:39 +02:00
parent b41b2103f0
commit 24aabd494e
176 changed files with 4965 additions and 8607 deletions

View File

@ -1,11 +1,9 @@
MYSQL_ROOT_PASSWORD=
MYSQL_HOST=db
MYSQL_DATABASE=
MYSQL_USER=
MYSQL_PASSWORD=
## can be generated with `openssl rand -base64 32`
JWT_SECRET=
JWT_REFRESH_SECRET=
CORS=false
PORT=8081
HOST=
SECURE_COOKIES=
#
## can be generated with `openssl rand -base64 32`
MYSQL_ROOT_PASSWORD=
#
## can be generated with `openssl rand -base64 32`
MYSQL_PASSWORD=
PASSWORD=${MYSQL_PASSWORD}

9
.gitignore vendored
View File

@ -124,8 +124,9 @@ sw.*
data/
letsencrypt/
# resources
# generated resources
app/src/main/resources/css-manifest.json
app/src/main/resources/static/styles*
resources/css-manifest.json
resources/docs/index.html
resources/static/*.css
# h2 db
*.db

View File

@ -1,110 +0,0 @@
/*
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
*/
import java.net.*;
import java.io.*;
import java.nio.channels.*;
import java.util.Properties;
public class MavenWrapperDownloader {
/**
* Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided.
*/
private static final String DEFAULT_DOWNLOAD_URL =
"https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.0/maven-wrapper-0.4.0.jar";
/**
* Path to the maven-wrapper.properties file, which might contain a downloadUrl property to
* use instead of the default one.
*/
private static final String MAVEN_WRAPPER_PROPERTIES_PATH =
".mvn/wrapper/maven-wrapper.properties";
/**
* Path where the maven-wrapper.jar will be saved to.
*/
private static final String MAVEN_WRAPPER_JAR_PATH =
".mvn/wrapper/maven-wrapper.jar";
/**
* Name of the property which should be used to override the default download url for the wrapper.
*/
private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl";
public static void main(String args[]) {
System.out.println("- Downloader started");
File baseDirectory = new File(args[0]);
System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath());
// If the maven-wrapper.properties exists, read it and check if it contains a custom
// wrapperUrl parameter.
File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH);
String url = DEFAULT_DOWNLOAD_URL;
if(mavenWrapperPropertyFile.exists()) {
FileInputStream mavenWrapperPropertyFileInputStream = null;
try {
mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile);
Properties mavenWrapperProperties = new Properties();
mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream);
url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url);
} catch (IOException e) {
System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'");
} finally {
try {
if(mavenWrapperPropertyFileInputStream != null) {
mavenWrapperPropertyFileInputStream.close();
}
} catch (IOException e) {
// Ignore ...
}
}
}
System.out.println("- Downloading from: : " + url);
File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH);
if(!outputFile.getParentFile().exists()) {
if(!outputFile.getParentFile().mkdirs()) {
System.out.println(
"- ERROR creating output direcrory '" + outputFile.getParentFile().getAbsolutePath() + "'");
}
}
System.out.println("- Downloading to: " + outputFile.getAbsolutePath());
try {
downloadFileFromURL(url, outputFile);
System.out.println("Done");
System.exit(0);
} catch (Throwable e) {
System.out.println("- Error downloading");
e.printStackTrace();
System.exit(1);
}
}
private static void downloadFileFromURL(String urlString, File destination) throws Exception {
URL website = new URL(urlString);
ReadableByteChannel rbc;
rbc = Channels.newChannel(website.openStream());
FileOutputStream fos = new FileOutputStream(destination);
fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE);
fos.close();
rbc.close();
}
}

View File

@ -1 +0,0 @@
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.5.2/apache-maven-3.5.2-bin.zip

View File

@ -1,8 +0,0 @@
simplenotes.be {
reverse_proxy http://localhost:8081
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
www.simplenotes.be {
redir * https://simplenotes.be{path}
}

43
Dockerfile Normal file
View File

@ -0,0 +1,43 @@
FROM maven:3.6.3-jdk-14 as builder
WORKDIR /tmp
# Cache dependencies
COPY pom.xml .
COPY app/pom.xml app/pom.xml
COPY domain/pom.xml domain/pom.xml
COPY persistance/pom.xml persistance/pom.xml
COPY shared/pom.xml shared/pom.xml
RUN mvn verify clean --fail-never
COPY app/src app/src
COPY domain/src domain/src
COPY persistance/src persistance/src
COPY shared/src shared/src
RUN mvn -Dstyle.color=always package
FROM openjdk:14-alpine as jdkbuilder
COPY --from=builder /tmp/app/target/app-*.jar /app/app.jar
ENV MODULES java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.scripting,java.security.jgss,java.sql,java.sql.rowset,java.transaction.xa,java.xml,jdk.net
RUN jlink --output /myjdk --module-path $JAVA_HOME/jmods --add-modules $MODULES
FROM alpine
ENV APPLICATION_USER simplenotes
RUN adduser -D -g '' $APPLICATION_USER
RUN mkdir /app
RUN chown -R $APPLICATION_USER /app
USER $APPLICATION_USER
COPY --from=builder /tmp/app/target/app-*.jar /app/app.jar
COPY --from=jdkbuilder /myjdk /myjdk
WORKDIR /app
CMD ["/myjdk/bin/java", "-server", "-XX:+UnlockExperimentalVMOptions", "-Xms256m", "-Xmx1g", "-XX:+UseG1GC", "-XX:MaxGCPauseMillis=100", "-XX:+UseStringDeduplication", "-jar", "app.jar"]

View File

@ -1,28 +0,0 @@
FROM maven:3.6.3-jdk-14 as builder
WORKDIR /tmp
# Cache dependencies
COPY pom.xml .
RUN mvn verify clean --fail-never
COPY resources resources
COPY src src
COPY test test
RUN mvn -Dstyle.color=always package -DskipTests
FROM openjdk:14-alpine
ENV APPLICATION_USER ktor
RUN adduser -D -g '' $APPLICATION_USER
RUN mkdir /app
RUN chown -R $APPLICATION_USER /app
USER $APPLICATION_USER
COPY --from=builder /tmp/target/api-*.jar /app/notes-api.jar
WORKDIR /app
CMD ["java", "-server", "-XX:+UnlockExperimentalVMOptions", "-XX:InitialRAMFraction=2", "-XX:MinRAMFraction=2", "-XX:MaxRAMFraction=2", "-XX:+UseG1GC", "-XX:MaxGCPauseMillis=100", "-XX:+UseStringDeduplication", "-jar", "notes-api.jar"]

View File

@ -1,8 +0,0 @@
FORMAT: 1A
HOST: https://simplenotes.be/api
# Notes API
<!-- include(./users/index.apib) -->
<!-- include(./notes/index.apib) -->
<!-- include(./tags/index.apib) -->

View File

@ -1,130 +0,0 @@
# Group Notes
## Notes [/notes]
### Create a Note [POST]
+ Request (text/markdown; charset=UTF-8)
+ Headers
Authorization: Bearer <token>
Accept: application/json
+ Body
---
title: example
tags: ["some", "tags"]
---
# A story
- a
- b
+ Response 201 (application/json)
{
"title": "example",
"tags": [
"some",
"tags"
],
"markdown": "---\ntitle: example\ntags: [\"some\", \"tags\"]\n---\n# A story\n\n- a\n- b",
"html": "<h1>A story</h1>\n<ul><li>a</li><li>b</li></ul>\n",
"uuid": "42aa1078-130e-47ee-b82d-b1d62f3ea054",
"updatedAt": "2020-07-16T01:03:46.7766"
}
## Notes [/notes{?limit,after}]
+ Parameters
+ limit: 10 (number, optional) - The number of notes to return
+ Default: `20`
+ after: `9bd36653-6397-4c5b-b8b7-158d9de208ef` (string, optional) - The UUID of the note before the requested ones
### Get All Notes [GET]
+ Request
+ Headers
Authorization: Bearer <token>
Accept: application/json
+ Response 200 (application/json)
+ Body
[
{
"uuid": "42aa1078-130e-47ee-b82d-b1d62f3ea054",
"title": "example",
"updatedAt": "2020-07-16T01:03:46",
"tags": [
"some",
"tags"
]
},
{
"uuid": "e61271e9-ba86-4428-a788-49946d1954c5",
"title": "test",
"updatedAt": "2020-07-16T00:22:02",
"tags": [
"babar",
"fait",
"du",
"ski"
]
},
]
## Note [/notes/{uuid}]
+ Parameters
+ uuid: `123e4567-e89b-12d3-a456-426614174000` (required, string) - The note UUID.
### Get a Note [GET]
+ Request
+ Headers
Authorization: Bearer <token>
Accept: application/json
+ Response 200 (application/json)
+ Body
{
"uuid": "8ba68c64-11f1-4424-a0cb-cba54a65298f",
"title": "example",
"markdown": "---\ntitle: example\ntags: [\"some\", \"tags\"]\n---\n# A story\n\n- a\n- b",
"html": "<h1>A story</h1>\n<ul><li>a</li><li>b</li></ul>\n",
"updatedAt": "2020-07-16T01:13:37",
"tags": [
"some",
"tags"
]
}
+ Response 404
### Update a Note [PUT]
#### TODO
### Delete a Note [DELETE]
+ Request
+ Headers
Authorization: Bearer <token>
Accept: application/json
+ Response 200
+ Response 404

View File

@ -1,21 +0,0 @@
# Group Tags
## Tags [/tags]
### Get all tags [GET]
+ Request
+ Headers
Authorization: Bearer <token>
Accept: application/json
+ Response 200 (application/json)
+ Body
[
"markdown",
"md",
"code",
"java"
]

View File

@ -1,111 +0,0 @@
# Group Accounts
## Account [/user]
### Create an account [POST]
+ Request (application/json)
+ Headers
Accept: application/json
+ Body
{
"username": "user",
"password": "apassword"
}
+ Response 200
+ Response 409
### Delete a user [DELETE]
+ Request
+ Headers
Authorization: Bearer <token>
Accept: application/json
+ Response 200
+ Response 404
## Authentication [/user/login]
Authenticate one user to access protected routing.
### Authenticate a user [POST]
+ Request (application/json)
+ Headers
Accept: application/json
+ Body
{
"username": "user",
"password": "myrealpassword"
}
+ Response 200 (application/json)
+ Body
{
"token": "<token>",
"refreshToken": "<token>"
}
+ Response 401
## Token refresh [/user/refresh_token]
### Refresh JWT token [POST]
+ Request (application/json)
+ Headers
Accept: application/json
+ Body
{
"refreshToken": "<refresh-token>"
}
+ Response 200 (application/json)
+ Body
{
"token": "<token>",
"refreshToken": "<refresh-token>"
}
+ Response 401
## User Info [/user/me]
Receive the username and email from the currently logged in user
### Get User Info [GET]
+ Request
+ Headers
Authorization: Bearer <token>
Accept: application/json
+ Response 200 (application/json)
+ Body
{
"user": {
"username": "user"
}
}
+ Response 401

123
app/pom.xml Normal file
View File

@ -0,0 +1,123 @@
<project>
<parent>
<artifactId>parent</artifactId>
<groupId>be.simplenotes</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>app</artifactId>
<dependencies>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>persistance</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>domain</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>shared</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.http4k</groupId>
<artifactId>http4k-core</artifactId>
<version>3.254.0</version>
</dependency>
<dependency>
<groupId>org.http4k</groupId>
<artifactId>http4k-server-jetty</artifactId>
<version>3.254.0</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-html-jvm</artifactId>
<version>0.7.1</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-runtime</artifactId>
<version>0.20.0</version>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>shared</artifactId>
<version>1.0-SNAPSHOT</version>
<type>test-jar</type>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.http4k</groupId>
<artifactId>http4k-testing-hamkrest</artifactId>
<version>3.254.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<minimizeJar>true</minimizeJar>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>be.simplenotes.app.SimpleNotesKt</mainClass>
</transformer>
</transformers>
<filters>
<filter>
<artifact>com.h2database:h2</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>org.mariadb.jdbc:mariadb-java-client</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>org.jetbrains.kotlin:kotlin-reflect</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/maven/**</exclude>
<exclude>META-INF/proguard/**</exclude>
<exclude>META-INF/*.kotlin_module</exclude>
<exclude>META-INF/DEPENDENCIES*</exclude>
<exclude>META-INF/NOTICE*</exclude>
<exclude>META-INF/LICENSE*</exclude>
<exclude>LICENSE*</exclude>
<exclude>META-INF/README*</exclude>
<exclude>META-INF/native-image/**</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,49 @@
package be.simplenotes.app
import be.simplenotes.shared.config.DataSourceConfig
import be.simplenotes.shared.config.JwtConfig
import be.simplenotes.shared.config.ServerConfig
import java.util.*
import java.util.concurrent.TimeUnit
object Config {
//region Config loading
private val properties: Properties = javaClass
.getResource("/application.properties")
.openStream()
.use {
Properties().apply { load(it) }
}
private val env = System.getenv()
private fun value(key: String): String =
env[key.toUpperCase().replace(".", "_")]
?: properties.getProperty(key)
?: error("Missing config key $key")
//endregion
val jwtConfig
get() = JwtConfig(
secret = value("jwt.secret"),
validity = value("jwt.validity").toLong(),
timeUnit = TimeUnit.HOURS,
)
val dataSourceConfig
get() = DataSourceConfig(
jdbcUrl = value("jdbcUrl"),
driverClassName = value("driverClassName"),
username = value("username"),
password = value("password"),
maximumPoolSize = value("maximumPoolSize").toInt(),
connectionTimeout = value("connectionTimeout").toLong()
)
val serverConfig
get() = ServerConfig(
host = value("host"),
port = value("port").toInt(),
)
}

View File

@ -0,0 +1,32 @@
package be.simplenotes.app
import org.eclipse.jetty.server.Server
import org.eclipse.jetty.server.ServerConnector
import org.http4k.routing.RoutingHttpHandler
import org.http4k.server.ConnectorBuilder
import org.http4k.server.Jetty
import org.http4k.server.ServerConfig
import org.http4k.server.asServer
import org.slf4j.LoggerFactory
import be.simplenotes.shared.config.ServerConfig as SimpleNotesServeConfig
class Server(
private val config: SimpleNotesServeConfig,
private val serverConfig: ServerConfig,
private val router: RoutingHttpHandler,
) {
fun start() {
router.asServer(serverConfig).start()
LoggerFactory.getLogger(javaClass).info("Listening on http://${config.host}:${config.port}")
}
}
fun serverConfig(config: SimpleNotesServeConfig): ServerConfig {
val builder: ConnectorBuilder = { server: Server ->
ServerConnector(server).apply {
port = config.port
host = config.host
}
}
return Jetty(config.port, builder)
}

View File

@ -0,0 +1,92 @@
package be.simplenotes.app
import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.AuthFilter
import be.simplenotes.app.filters.AuthType
import be.simplenotes.app.routes.Router
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.UserView
import be.simplenotes.domain.domainModule
import be.simplenotes.persistance.DbMigrations
import be.simplenotes.persistance.persistanceModule
import be.simplenotes.shared.config.DataSourceConfig
import be.simplenotes.shared.config.JwtConfig
import org.http4k.core.RequestContexts
import org.koin.core.context.startKoin
import org.koin.core.qualifier.qualifier
import org.koin.dsl.module
import org.slf4j.LoggerFactory
import be.simplenotes.shared.config.ServerConfig as SimpleNotesServeConfig
fun main() {
val koin = startKoin {
modules(
persistanceModule,
configModule,
domainModule,
serverModule,
userModule,
baseModule,
noteModule,
)
}.koin
val dataSourceConfig = koin.get<DataSourceConfig>()
val jwtConfig = koin.get<JwtConfig>()
val serverConfig = koin.get<SimpleNotesServeConfig>()
val logger = LoggerFactory.getLogger("SimpleNotes")
logger.info("datasource: $dataSourceConfig")
logger.info("jwt: $jwtConfig")
logger.info("server: $serverConfig")
val migrations = koin.get<DbMigrations>()
migrations.migrate()
koin.get<Server>().start()
}
val serverModule = module {
single { Server(get(), get(), get()) }
single<StaticFileResolver> { StaticFileResolverImpl() }
single {
Router(
get(),
get(),
get(),
requiredAuth = get(AuthType.Required.qualifier),
optionalAuth = get(AuthType.Optional.qualifier),
get()
)()
}
single { serverConfig(get()) }
single { RequestContexts() }
single(AuthType.Optional.qualifier) { AuthFilter(get(), AuthType.Optional, get())() }
single(AuthType.Required.qualifier) { AuthFilter(get(), AuthType.Required, get())() }
}
val userModule = module {
single { UserController(get(), get()) }
single { UserView(get()) }
}
val baseModule = module {
single { BaseController(get()) }
single { BaseView(get()) }
}
val noteModule = module {
single { NoteController(get(), get()) }
single { NoteView(get()) }
}
val configModule = module {
single { Config.dataSourceConfig }
single { Config.jwtConfig }
single { Config.serverConfig }
}

View File

@ -0,0 +1,13 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.views.BaseView
import be.simplenotes.domain.security.JwtPayload
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
class BaseController(private val view: BaseView) {
fun index(@Suppress("UNUSED_PARAMETER") request: Request, jwtPayload: JwtPayload?) =
Response(OK).html(view.renderHome(jwtPayload))
}

View File

@ -0,0 +1,96 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect
import be.simplenotes.app.views.NoteView
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.usecases.NoteService
import be.simplenotes.domain.usecases.markdown.InvalidMeta
import be.simplenotes.domain.usecases.markdown.MissingMeta
import be.simplenotes.domain.usecases.markdown.ValidationError
import org.http4k.core.Method
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.NOT_FOUND
import org.http4k.core.Status.Companion.OK
import org.http4k.core.body.form
import org.http4k.routing.path
import java.util.*
import kotlin.math.abs
class NoteController(
private val view: NoteView,
private val noteService: NoteService,
) {
fun new(request: Request, jwtPayload: JwtPayload): Response {
if (request.method == Method.GET) return Response(OK).html(view.noteEditor(jwtPayload))
val markdownForm = request.form("markdown") ?: ""
return noteService.create(jwtPayload.userId, markdownForm).fold({
val html = when (it) {
MissingMeta -> view.noteEditor(jwtPayload, error = "Missing note metadata", textarea = markdownForm)
InvalidMeta -> view.noteEditor(jwtPayload, error = "Invalid note metadata", textarea = markdownForm)
is ValidationError -> view.noteEditor(jwtPayload, validationErrors = it.validationErrors, textarea = markdownForm)
}
Response(BAD_REQUEST).html(html)
}, {
Response.redirect("/notes/${it.uuid}")
})
}
fun list(request: Request, jwtPayload: JwtPayload): Response {
val currentPage = request.query("page")?.toIntOrNull()?.let(::abs) ?: 1
val (pages, notes) = noteService.paginatedNotes(jwtPayload.userId, currentPage)
return Response(OK).html(view.notes(jwtPayload, notes, currentPage, pages))
}
fun note(request: Request, jwtPayload: JwtPayload): Response {
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
if (request.method == Method.POST && request.form("delete") != null) {
return if (noteService.delete(jwtPayload.userId, noteUuid))
Response.redirect("/notes") // FIXME: flash cookie to show success ?
else
Response(NOT_FOUND) // FIXME: show an error
}
val note = noteService.find(jwtPayload.userId, noteUuid) ?: return Response(NOT_FOUND)
return Response(OK).html(view.renderedNote(jwtPayload, note))
}
fun edit(request: Request, jwtPayload: JwtPayload): Response {
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
val note = noteService.find(jwtPayload.userId, noteUuid) ?: return Response(NOT_FOUND)
if (request.method == Method.GET) {
return Response(OK).html(view.noteEditor(jwtPayload, textarea = note.markdown))
}
val markdownForm = request.form("markdown") ?: ""
return noteService.update(jwtPayload.userId, note.uuid, markdownForm).fold({
val html = when (it) {
MissingMeta -> view.noteEditor(jwtPayload, error = "Missing note metadata", textarea = markdownForm)
InvalidMeta -> view.noteEditor(jwtPayload, error = "Invalid note metadata", textarea = markdownForm)
is ValidationError -> view.noteEditor(jwtPayload, validationErrors = it.validationErrors, textarea = markdownForm)
}
Response(BAD_REQUEST).html(html)
}, {
Response.redirect("/notes/${note.uuid}")
})
}
private fun Request.uuidPath(): UUID? {
val uuidPath = path("uuid")!!
return try {
UUID.fromString(uuidPath)!!
} catch (e: IllegalArgumentException) {
null
}
}
}

View File

@ -0,0 +1,112 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.isSecure
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.login.*
import be.simplenotes.domain.usecases.register.InvalidRegisterForm
import be.simplenotes.domain.usecases.register.RegisterForm
import be.simplenotes.domain.usecases.register.UserExists
import org.http4k.core.Method.GET
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
import org.http4k.core.body.form
import org.http4k.core.cookie.Cookie
import org.http4k.core.cookie.SameSite
import org.http4k.core.cookie.cookie
import org.http4k.core.cookie.invalidateCookie
class UserController(
private val userService: UserService,
private val userView: UserView,
) {
fun register(request: Request, jwtPayload: JwtPayload?): Response {
if (request.method == GET) return Response(OK).html(
userView.register(jwtPayload)
)
val result = userService.register(request.registerForm())
return result.fold(
{
val html = when (it) {
UserExists -> userView.register(
jwtPayload,
error = "User already exists"
)
is InvalidRegisterForm ->
userView.register(
jwtPayload,
validationErrors = it.validationErrors
)
}
Response(OK).html(html)
},
{
Response.redirect("/login")
}
)
}
private fun Request.registerForm() = RegisterForm(form("username"), form("password"))
private fun Request.loginForm(): LoginForm = registerForm()
fun login(request: Request, jwtPayload: JwtPayload?): Response {
if (request.method == GET) return Response(OK).html(
userView.login(jwtPayload)
)
val result = userService.login(request.loginForm())
return result.fold(
{
val html = when (it) {
Unregistered ->
userView.login(
jwtPayload,
error = "User does not exist"
)
WrongPassword ->
userView.login(
jwtPayload,
error = "Wrong password"
)
is InvalidLoginForm ->
userView.login(
jwtPayload,
validationErrors = it.validationErrors
)
}
Response(OK).html(html)
},
{ token ->
Response.redirect("/").loginCookie(token, request.isSecure())
}
)
}
private fun Response.loginCookie(token: Token, secure: Boolean): Response {
// FIXME: expires
// val expiresAt = JWT.decode(token).expiresAt
// LocalDateTime.ofEpochSecond(expiresAt.time, 0)
return this.cookie(
Cookie(
name = "Authorization",
value = "Bearer $token",
path = "/",
httpOnly = true,
sameSite = SameSite.Lax,
secure = secure
)
)
}
fun logout(@Suppress("UNUSED_PARAMETER") request: Request) = Response.redirect("/")
.invalidateCookie("Authorization")
}

View File

@ -0,0 +1,13 @@
package be.simplenotes.app.extensions
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.FOUND
import org.http4k.core.Status.Companion.MOVED_PERMANENTLY
fun Response.html(html: String) = body(html).header("Content-Type", "text/html; charset=utf-8")
fun Response.Companion.redirect(url: String, permanent: Boolean = false) =
Response(if (permanent) MOVED_PERMANENTLY else FOUND).header("Location", url)
fun Request.isSecure() = header("X-Forwarded-Proto")?.contains("https") ?: false

View File

@ -0,0 +1,45 @@
package be.simplenotes.app.filters
import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.security.JwtPayloadExtractor
import org.http4k.core.Filter
import org.http4k.core.Request
import org.http4k.core.RequestContexts
import org.http4k.core.Response
import org.http4k.core.cookie.cookie
enum class AuthType {
Optional, Required
}
private const val authKey = "auth"
class AuthFilter(
private val extractor: JwtPayloadExtractor,
private val authType: AuthType,
private val ctx: RequestContexts
) {
operator fun invoke() = Filter { next ->
{
val jwtPayload = it.bearerToken()?.let { token -> extractor(token) }
when {
jwtPayload != null -> {
ctx[it][authKey] = jwtPayload
next(it)
}
authType == AuthType.Required -> Response.redirect("/login")
else -> next(it)
}
}
}
}
fun Request.jwtPayload(ctx: RequestContexts): JwtPayload? = ctx[this][authKey]
private fun Request.bearerToken(): String? = cookie("Authorization")
?.value
?.trim()
?.takeIf { it.startsWith("Bearer") }
?.substringAfter("Bearer")
?.trim()

View File

@ -0,0 +1,26 @@
package be.simplenotes.app.filters
import org.http4k.core.Filter
import org.http4k.core.Response
import org.http4k.core.Status
import org.slf4j.LoggerFactory
object ErrorFilter {
private val logger = LoggerFactory.getLogger(javaClass)
operator fun invoke(): Filter = Filter { next ->
{
try {
val response = next(it)
if (response.status == Status.NOT_FOUND) Response(Status.NOT_FOUND).body("TODO(NOT_FOUND)")
else response
} catch (e: Exception) {
logger.error(e.stackTraceToString())
Response(Status.INTERNAL_SERVER_ERROR).body("TODO(INTERNAL_SERVER_ERROR)")
} catch (e: NotImplementedError) {
logger.error(e.stackTraceToString())
Response(Status.INTERNAL_SERVER_ERROR).body("TODO(NotImplementedError)")
}
}
}
}

View File

@ -0,0 +1,19 @@
package be.simplenotes.app.filters
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Method
import org.http4k.core.Request
object ImmutableFilter {
operator fun invoke(): Filter {
return Filter { next: HttpHandler ->
{ request: Request ->
val response = next(request)
if (request.method == Method.GET)
response.header("Cache-Control", "public, max-age=31536000, immutable")
else response
}
}
}
}

View File

@ -0,0 +1,20 @@
package be.simplenotes.app.filters
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Request
object SecurityFilter {
operator fun invoke(): Filter {
return Filter { next: HttpHandler ->
{ request: Request ->
val response = next(request)
.header("X-Content-Type-Options", "nosniff")
if (response.header("Content-Type")?.contains("text/html") == true)
response.header("Content-Security-Policy", "default-src 'self'")
else response
}
}
}
}

View File

@ -0,0 +1,72 @@
package be.simplenotes.app.routes
import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.ErrorFilter
import be.simplenotes.app.filters.ImmutableFilter
import be.simplenotes.app.filters.SecurityFilter
import be.simplenotes.app.filters.jwtPayload
import be.simplenotes.domain.security.JwtPayload
import org.http4k.core.*
import org.http4k.core.Method.GET
import org.http4k.core.Method.POST
import org.http4k.filter.ResponseFilters
import org.http4k.filter.ServerFilters.InitialiseRequestContext
import org.http4k.routing.*
class Router(
private val baseController: BaseController,
private val userController: UserController,
private val noteController: NoteController,
private val requiredAuth: Filter,
private val optionalAuth: Filter,
private val contexts: RequestContexts,
) {
operator fun invoke(): RoutingHttpHandler {
val basicRoutes = routes(
ImmutableFilter().then(static(ResourceLoader.Classpath(("/static")))),
)
fun public(request: Request, handler: PublicHandler) = handler(request, request.jwtPayload(contexts))
fun protected(request: Request, handler: ProtectedHandler) = handler(request, request.jwtPayload(contexts)!!)
val publicRoutes: RoutingHttpHandler = routes(
"/" bind GET to { public(it, baseController::index) },
"/register" bind GET to { public(it, userController::register) },
"/register" bind POST to { public(it, userController::register) },
"/login" bind GET to { public(it, userController::login) },
"/login" bind POST to { public(it, userController::login) },
"/logout" bind POST to userController::logout,
)
val protectedRoutes = routes(
"/account" bind GET to { TODO() },
"/export" bind POST to { TODO() },
"/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) },
"/notes/{uuid}" bind GET to { protected(it, noteController::note) },
"/notes/{uuid}" bind POST to { protected(it, noteController::note) },
"/notes/{uuid}/edit" bind GET to { protected(it, noteController::edit) },
"/notes/{uuid}/edit" bind POST to { protected(it, noteController::edit) },
)
val routes = routes(
basicRoutes,
optionalAuth.then(publicRoutes),
requiredAuth.then(protectedRoutes),
)
val globalFilters = ErrorFilter()
.then(InitialiseRequestContext(contexts))
.then(SecurityFilter())
.then(ResponseFilters.GZip())
return globalFilters.then(routes)
}
}
private typealias PublicHandler = (Request, JwtPayload?) -> Response
private typealias ProtectedHandler = (Request, JwtPayload) -> Response

View File

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

View File

@ -0,0 +1,18 @@
package be.simplenotes.app.views
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.domain.security.JwtPayload
import kotlinx.html.div
import kotlinx.html.h1
class BaseView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
fun renderHome(jwtPayload: JwtPayload?) = renderPage(title = "Home", jwtPayload = jwtPayload) {
div("centered container mx-auto flex justify-center items-center") {
div("bg-gray-800 md:w-1/3 w-full rounded-lg m-4 p-6 text-center") {
h1("text-3xl") { +"SimpleNotes" }
div("text-teal-400") { +"Welcome" }
div("text-gray-200") { +"TODO" }
}
}
}
}

View File

@ -0,0 +1,131 @@
package be.simplenotes.app.views
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.submitButton
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import be.simplenotes.domain.security.JwtPayload
import io.konform.validation.ValidationError
import kotlinx.html.*
class NoteView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
fun noteEditor(
jwtPayload: JwtPayload,
error: String? = null,
textarea: String? = null,
validationErrors: List<ValidationError> = emptyList(),
) = renderPage(title = "New note", jwtPayload = jwtPayload) {
div("container mx-auto p-4") {
// TODO: error
error?.let { alert(Alert.Warning, error) }
validationErrors.forEach {
alert(Alert.Warning, it.dataPath.substringAfter('.') + ": " + it.message)
}
form(method = FormMethod.post) {
textArea(classes = "w-full bg-gray-800 p-5 outline-none font-mono") {
attributes.also {
it["rows"] = "20"
it["id"] = "markdown"
it["name"] = "markdown"
it["aria-label"] = "markdown text area"
it["spellcheck"] = "false"
}
textarea?.let {
+it
} ?: +"""
|---
|title: ''
|tags: []
|---
|
""".trimMargin("|")
}
submitButton("Save")
}
}
}
fun notes(jwtPayload: JwtPayload, notes: List<PersistedNoteMetadata>, currentPage: Int, numberOfPages: Int) =
renderPage(title = "Notes", jwtPayload = jwtPayload) {
div("container mx-auto p-4") {
div("flex justify-between mb-4") {
h1("text-2xl underline") { +"Notes" }
a(
href = "/notes/new",
classes = "text-gray-800 bg-green-500 hover:bg-green-700 " +
"inline ml-2 text-md font-semibold rounded px-4 py-2"
) { +"New" }
}
if (notes.isNotEmpty()) {
ul {
notes.forEach { (title, tags, _, uuid) ->
li("flex justify-between") {
a(classes = "text-blue-200 text-xl hover:underline", href = "/notes/${uuid}") {
+title
}
span {
tags.forEach {
span("tag ml-2") { +"#$it" }
}
}
}
}
}
if (numberOfPages > 1)
pagination(currentPage, numberOfPages)
} else
span { +"No notes yet" } // FIXME if too far in pagination, it it displayed
}
}
private fun DIV.pagination(currentPage: Int, numberOfPages: Int) {
val links = mutableListOf<Pair<String, String>>()
//if (currentPage > 1) links += "Previous" to "?page=${currentPage - 1}"
links += (1..numberOfPages).map { "$it" to "?page=$it" }
//if (currentPage < numberOfPages) links += "Next" to "?page=${currentPage + 1}"
nav("pages") {
links.forEach { (name, href) ->
a(href, classes = if (name == currentPage.toString()) "active" else null) { +name }
}
}
}
fun renderedNote(jwtPayload: JwtPayload, note: PersistedNote) = renderPage(note.meta.title, jwtPayload = jwtPayload) {
div("container mx-auto p-4") {
div("flex items-center justify-between mb-4") {
h1("text-3xl fond-bold underline") { +note.meta.title }
span {
note.meta.tags.forEach {
span("tag ml-2") { +"#$it" }
}
}
}
span("flex justify-end mb-4") {
a(
href = "/notes/${note.uuid}/edit",
classes = "mx-2 bg-teal-500 hover:bg-teal-600 focus:bg-teal-600" +
" focus:outline-none text-white font-bold py-2 px-4 rounded"
) { +"Edit" }
form(method = FormMethod.post, classes = "inline") {
button(
type = ButtonType.submit,
name = "delete",
classes = "mx-2 bg-red-500 hover:bg-red-600 focus:bg-red-600" +
" focus:outline-none text-white font-bold py-2 px-4 rounded"
) { +"Delete" }
}
}
div {
attributes["id"] = "note"
unsafe {
+note.html
}
}
}
}
}

View File

@ -0,0 +1,73 @@
package be.simplenotes.app.views
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.app.views.components.submitButton
import be.simplenotes.domain.security.JwtPayload
import io.konform.validation.ValidationError
import kotlinx.html.*
class UserView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
fun register(
jwtPayload: JwtPayload?,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
) = accountForm("Register", jwtPayload, error, validationErrors, "Create an account", "Register") {
+"Already have an account? "
a(href = "/login", classes = "no-underline text-blue-500 font-bold") { +"Sign In" }
}
fun login(
jwtPayload: JwtPayload?,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
new: Boolean = false,
) = accountForm("Login", jwtPayload, error, validationErrors, "Sign In", "Sign In", new) {
+"Don't have an account yet? "
a(href = "/register", classes = "no-underline text-blue-500 font-bold") { +"Create an account" }
}
private fun accountForm(
title: String,
jwtPayload: JwtPayload?,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
h1: String,
submit: String,
new: Boolean = false,
footer: FlowContent.() -> Unit,
) = renderPage(title = title, jwtPayload = jwtPayload) {
div("centered container mx-auto flex justify-center items-center") {
div("w-full md:w-1/2 lg:w-1/3 m-4") {
h1("font-semibold text-lg mb-6 text-center") { +h1 }
div("p-8 mb-6") {
if (new) alert(Alert.Success, "Your account has been created")
error?.let { alert(Alert.Warning, error) }
form(method = FormMethod.post) {
input(
id = "username",
placeholder = "Username",
autoComplete = "username",
error = validationErrors.find { it.dataPath == ".username" }?.message
)
input(
id = "password",
placeholder = "Password",
autoComplete = "new-password",
type = InputType.password,
error = validationErrors.find { it.dataPath == ".password" }?.message
)
submitButton(submit)
}
}
div("text-center") {
p("text-gray-200 text-sm") {
footer()
}
}
}
}
}
}

View File

@ -0,0 +1,36 @@
package be.simplenotes.app.views
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.views.components.navbar
import be.simplenotes.domain.security.JwtPayload
import kotlinx.html.*
import kotlinx.html.stream.appendHTML
abstract class View(private val staticFileResolver: StaticFileResolver) {
private val styles = staticFileResolver.resolve("styles.css")!!
fun renderPage(
title: String,
description: String? = null,
jwtPayload: JwtPayload?,
body: BODY.() -> Unit = {},
) = buildString {
appendLine("<!DOCTYPE html>")
appendHTML().html {
attributes["lang"] = "en"
head {
meta(charset = "UTF-8")
meta(name = "viewport", content = "width=device-width, initial-scale=1")
title("$title - SimpleNotes")
description?.let { meta(name = "description", content = it) }
link(rel = "stylesheet", href = styles)
link(rel = "shortcut icon", href="/favicon.ico", type = "image/x-icon")
}
body("bg-gray-900 text-white") {
navbar(jwtPayload)
main { this@body.body() }
}
}
}
}

View File

@ -0,0 +1,19 @@
package be.simplenotes.app.views.components
import kotlinx.html.*
fun FlowContent.alert(type: Alert, title: String, details: String? = null) {
val colors = when (type) {
Alert.Success -> "bg-green-500 border border-green-400 text-gray-800"
Alert.Warning -> "bg-red-500 border border-red-400 text-red-200"
}
div("$colors px-4 py-3 mb-4 rounded relative") {
attributes["role"] = "alert"
strong("font-bold") { +title }
details?.let { span("block sm:inline") { +details } }
}
}
enum class Alert {
Success, Warning
}

View File

@ -0,0 +1,37 @@
package be.simplenotes.app.views.components
import kotlinx.html.*
import kotlinx.html.ButtonType.submit
fun FlowContent.input(
type: InputType = InputType.text,
placeholder: String,
id: String,
autoComplete: String? = null,
error: String? = null
) {
val colors = "bg-gray-800 border-gray-700 focus:border-teal-500 text-white"
div("mb-8") {
input(
type = type,
classes = "$colors rounded w-full border appearance-none focus:outline-none text-base p-2"
) {
attributes["placeholder"] = placeholder
attributes["aria-label"] = placeholder
attributes["name"] = id
attributes["id"] = id
autoComplete?.let { attributes["autocomplete"] = it }
}
error?.let { p("mt-2 text-red-500 text-sm italic") { +"$placeholder $error" } }
}
}
fun FlowContent.submitButton(text: String) {
div("flex items-center mt-6") {
button(
type = submit,
classes = "bg-teal-500 hover:bg-teal-600 focus:bg-teal-600" +
" w-full focus:outline-none text-white font-bold py-2 px-4 rounded"
) { +text }
}
}

View File

@ -0,0 +1,29 @@
package be.simplenotes.app.views.components
import be.simplenotes.domain.security.JwtPayload
import kotlinx.html.*
fun BODY.navbar(jwtPayload: JwtPayload?) {
nav("nav bg-teal-700 shadow-md flex items-center justify-between px-4") {
a(href = "/", classes = "text-2xl text-gray-100 font-bold") { +"SimpleNotes" }
ul {
if (jwtPayload != null) {
li("inline text-gray-100 ml-2 text-md font-semibold") {
a(href = "/notes") { +"Notes" }
}
li(
"text-gray-800 bg-green-500 hover:bg-green-700" +
" inline ml-2 text-md font-semibold rounded px-4 py-2"
) {
form(classes = "inline", action = "/logout", method = FormMethod.post) {
button(type = ButtonType.submit) { +"Logout" }
}
}
} else {
li("inline text-gray-100 pl-2 text-md font-semibold") {
a(href = "/login") { +"Sign In" }
}
}
}
}
}

View File

@ -0,0 +1,12 @@
host=localhost
port=8080
#
jdbcUrl=jdbc:h2:./notes-db;
driverClassName=org.h2.Driver
username=h2
password=
maximumPoolSize=10
connectionTimeout=3000
#
jwt.secret=PliLvfk7l4WF+cZJk66LR5Mpnh+ocbvJ2wfUCK2UCms=
jwt.validity=24

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1 @@
package be.simplenotes.app

View File

@ -0,0 +1,94 @@
package be.simplenotes.app.filters
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.security.JwtPayloadExtractor
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.shared.config.JwtConfig
import com.natpryce.hamkrest.assertion.assertThat
import org.http4k.core.*
import org.http4k.core.Method.GET
import org.http4k.core.Status.Companion.FOUND
import org.http4k.core.Status.Companion.OK
import org.http4k.core.cookie.cookie
import org.http4k.filter.ServerFilters
import org.http4k.hamkrest.hasBody
import org.http4k.hamkrest.hasHeader
import org.http4k.hamkrest.hasStatus
import org.http4k.routing.bind
import org.http4k.routing.routes
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.util.concurrent.TimeUnit
internal class AuthFilterTest {
// region setup
private val jwtConfig = JwtConfig("secret", 1, TimeUnit.HOURS)
private val simpleJwt = SimpleJwt(jwtConfig)
private val extractor = JwtPayloadExtractor(simpleJwt)
private val ctx = RequestContexts()
private val requiredAuth = AuthFilter(extractor, AuthType.Required, ctx)()
private val optionalAuth = AuthFilter(extractor, AuthType.Optional, ctx)()
private val echoJwtPayloadHandler = { request: Request -> Response(OK).body(request.jwtPayload(ctx).toString()) }
private val app = ServerFilters.InitialiseRequestContext(ctx).then(
routes(
"/optional" bind GET to optionalAuth.then(echoJwtPayloadHandler),
"/protected" bind GET to requiredAuth.then(echoJwtPayloadHandler)
)
)
// endregion
@Nested
inner class OptionalAuth {
@Test
fun `it should allow no token`() {
val response = app(Request(GET, "/optional"))
assertThat(response, hasStatus(OK))
assertThat(response, hasBody("null"))
}
@Test
fun `it should allow an invalid token`() {
val response = app(Request(GET, "/optional").cookie("Authorization", "Bearer nnkjnkjnk"))
assertThat(response, hasStatus(OK))
assertThat(response, hasBody("null"))
}
@Test
fun `it should allow a valid token`() {
val jwtPayload = JwtPayload(1, "user")
val token = simpleJwt.sign(jwtPayload)
val response = app(Request(GET, "/optional").cookie("Authorization", "Bearer $token"))
assertThat(response, hasStatus(OK))
assertThat(response, hasBody("$jwtPayload"))
}
}
@Nested
inner class RequiredAuth {
@Test
fun `it shouldn't allow a missing token`() {
val response = app(Request(GET, "/protected"))
assertThat(response, hasStatus(FOUND))
assertThat(response, hasHeader("Location"))
}
@Test
fun `it shouldn't allow an invalid token`() {
val response = app(Request(GET, "/protected").cookie("Authorization", "Bearer nnkjnkjnk"))
assertThat(response, hasStatus(FOUND))
assertThat(response, hasHeader("Location"))
}
@Test
fun `it should allow a valid token"`() {
val jwtPayload = JwtPayload(1, "user")
val token = simpleJwt.sign(jwtPayload)
val response = app(Request(GET, "/protected").cookie("Authorization", "Bearer $token"))
assertThat(response, hasStatus(OK))
assertThat(response, hasBody("$jwtPayload"))
}
}
}

15
css/package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "css",
"version": "1.0.0",
"scripts": {
"css": "NODE_ENV=dev MANIFEST=../app/src/main/resources/css-manifest.json postcss build styles.css --output ../app/src/main/resources/static/styles.css",
"css-purge": "NODE_ENV=production MANIFEST=../app/src/main/resources/css-manifest.json postcss build styles.css --output ../app/src/main/resources/static/styles.css"
},
"dependencies": {
"autoprefixer": "^9.8.6",
"cssnano": "^4.1.10",
"postcss-cli": "^7.1.1",
"postcss-hash": "^2.0.0",
"tailwindcss": "^1.5.1"
}
}

View File

@ -12,7 +12,7 @@ module.exports = {
require('postcss-hash')({
algorithm: 'sha256',
trim: 20,
manifest: 'resources/css-manifest.json'
manifest: process.env.MANIFEST
}),
]
}

View File

@ -10,15 +10,54 @@
min-height: calc(100vh - 64px);
}
.tag {
@apply italic font-semibold text-sm bg-teal-500 text-gray-900 rounded-full py-1 px-2 align-middle;
}
nav.pages {
@apply flex pl-0 list-none rounded my-2 flex justify-center;
}
nav.pages :first-child {
@apply rounded-l;
}
nav.pages :last-child {
@apply rounded-r;
}
nav.pages :not(:last-child) {
@apply border-r-0;
}
nav.pages a {
@apply relative block py-2 px-3 leading-tight border bg-gray-800 border-gray-700 text-teal-300;
}
nav.pages a:hover {
@apply bg-gray-700;
}
nav.pages a.active {
@apply bg-teal-800 border-gray-700 text-white;
}
nav.pages a.active:hover {
@apply bg-gray-700;
}
#note a {
@apply text-blue-700 underline; }
@apply text-blue-700 underline;
}
#note p {
@apply my-4; }
@apply my-4;
}
#note blockquote,
#note figure {
@apply my-4 mx-10; }
@apply my-4 mx-10;
}
#note hr {
@apply border; }

View File

@ -1,8 +1,7 @@
module.exports = {
purge: {
enabled: true,
content: [
'resources/templates/**/*.html'
'../app/src/main/kotlin/views/**/*.kt'
]
},
theme: {

1851
css/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

8
deploy-docker-hub.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/sh
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:latest

View File

@ -1,7 +0,0 @@
version: '2.2'
services:
db:
ports:
- 127.0.0.1:3306:3306

View File

@ -1,23 +0,0 @@
version: '2.2'
services:
api:
build:
dockerfile: Dockerfile.api
context: .
container_name: notes-api
env_file:
- .env
environment:
- TZ=Europe/Brussels
- MYSQL_HOST=db
ports:
- 127.0.0.1:8081:8081
depends_on:
db:
condition: service_healthy
volumes:
notes-caddy-data:
notes-caddy-config:

View File

@ -4,19 +4,47 @@ services:
db:
image: mariadb
container_name: notes-mariadb
container_name: simplenotes-mariadb
env_file:
- .env
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Brussels
- MYSQL_DATABASE=simplenotes
- MYSQL_USER=simplenotes
# .env:
# - MYSQL_ROOT_PASSWORD
# - MYSQL_PASSWORD
volumes:
- notes-db-volume:/var/lib/mysql
ports:
- 127.0.0.1:3306:3306
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
timeout: 10s
retries: 10
simplenotes:
image: hubv/simplenotes
container_name: simplenotes
env_file:
- .env
environment:
- TZ=Europe/Brussels
- HOST=0.0.0.0
- JDBCURL=jdbc:mariadb://db:3306/simplenotes
- DRIVERCLASSNAME=org.mariadb.jdbc.Driver
- USERNAME=simplenotes
# .env:
# - JWT_SECRET
# - PASSWORD
ports:
- 127.0.0.1:8080:8080
depends_on:
db:
condition: service_healthy
volumes:
notes-db-volume:

57
domain/pom.xml Normal file
View File

@ -0,0 +1,57 @@
<project>
<parent>
<artifactId>parent</artifactId>
<groupId>be.simplenotes</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>domain</artifactId>
<dependencies>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>shared</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>shared</artifactId>
<version>1.0-SNAPSHOT</version>
<type>test-jar</type>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.konform</groupId>
<artifactId>konform-jvm</artifactId>
<version>0.2.0</version>
</dependency>
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
<dependency>
<groupId>com.vladsch.flexmark</groupId>
<artifactId>flexmark</artifactId>
<version>0.62.2</version>
</dependency>
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>1.26</version>
</dependency>
<dependency>
<groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
<artifactId>owasp-java-html-sanitizer</artifactId>
<version>20200615.1</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,26 @@
package be.simplenotes.domain
import be.simplenotes.domain.security.BcryptPasswordHash
import be.simplenotes.domain.security.JwtPayloadExtractor
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.login.LoginUseCase
import be.simplenotes.domain.usecases.login.LoginUseCaseImpl
import be.simplenotes.domain.usecases.markdown.MarkdownConverter
import be.simplenotes.domain.usecases.markdown.MarkdownConverterImpl
import be.simplenotes.domain.usecases.register.RegisterUseCase
import be.simplenotes.domain.usecases.register.RegisterUseCaseImpl
import org.koin.dsl.module
val domainModule = module {
single<LoginUseCase> { LoginUseCaseImpl(get(), get(), get()) }
single<RegisterUseCase> { RegisterUseCaseImpl(get(), get()) }
single { UserService(get(), get()) }
single<PasswordHash> { BcryptPasswordHash() }
single { SimpleJwt(get()) }
single { JwtPayloadExtractor(get()) }
single { NoteService(get(), get()) }
single<MarkdownConverter> { MarkdownConverterImpl() }
}

View File

@ -0,0 +1,30 @@
package be.simplenotes.domain.model
import java.time.LocalDateTime
import java.util.*
data class NoteMetadata(
val title: String,
val tags: List<String>,
)
data class PersistedNoteMetadata(
val title: String,
val tags: List<String>,
val updatedAt: LocalDateTime,
val uuid: UUID,
)
data class Note(
val meta: NoteMetadata,
val markdown: String,
val html: String,
)
data class PersistedNote(
val meta: NoteMetadata,
val markdown: String,
val html: String,
val updatedAt: LocalDateTime,
val uuid: UUID,
)

View File

@ -0,0 +1,4 @@
package be.simplenotes.domain.model
data class User(val username: String, val password: String)
data class PersistedUser(val username: String, val password: String, val id: Int)

View File

@ -0,0 +1,18 @@
package be.simplenotes.domain.security
import org.owasp.html.HtmlPolicyBuilder
object HtmlSanitizer {
private val htmlPolicy = HtmlPolicyBuilder()
.allowElements("a")
.allowCommonBlockElements()
.allowCommonInlineFormattingElements()
.allowElements("pre")
.allowAttributes("class").onElements("code")
.allowUrlProtocols("http", "https")
.allowAttributes("href").onElements("a")
.requireRelNofollowOnLinks()
.toFactory()!!
fun sanitize(unsafeHtml: String) = htmlPolicy.sanitize(unsafeHtml)!!
}

View File

@ -0,0 +1,21 @@
package be.simplenotes.domain.security
import be.simplenotes.domain.model.PersistedUser
import com.auth0.jwt.exceptions.JWTVerificationException
data class JwtPayload(val userId: Int, val username: String) {
constructor(user: PersistedUser) : this(user.id, user.username)
}
class JwtPayloadExtractor(private val jwt: SimpleJwt) {
operator fun invoke(token: String): JwtPayload? = try {
val decodedJWT = jwt.verifier.verify(token)
val id = decodedJWT.getClaim("id").asInt() ?: null
val username = decodedJWT.getClaim("username").asString() ?: null
id?.let { username?.let { JwtPayload(id, username) } }
} catch (e: JWTVerificationException) {
null
} catch (e: IllegalArgumentException) {
null
}
}

View File

@ -1,13 +1,14 @@
package be.vandewalleh.features
package be.simplenotes.domain.security
import org.mindrot.jbcrypt.BCrypt
interface PasswordHash {
internal interface PasswordHash {
fun crypt(password: String): String
fun verify(password: String, hashedPassword: String): Boolean
}
class BcryptPasswordHash : PasswordHash {
override fun crypt(password: String) = BCrypt.hashpw(password, BCrypt.gensalt())!!
internal class BcryptPasswordHash(test: Boolean = false) : PasswordHash {
private val rounds = if (test) 4 else 10
override fun crypt(password: String) = BCrypt.hashpw(password, BCrypt.gensalt(rounds))!!
override fun verify(password: String, hashedPassword: String) = BCrypt.checkpw(password, hashedPassword)
}

View File

@ -0,0 +1,22 @@
package be.simplenotes.domain.security
import be.simplenotes.shared.config.JwtConfig
import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import java.util.*
import java.util.concurrent.TimeUnit
class SimpleJwt(jwtConfig: JwtConfig) {
private val validityInMs = TimeUnit.MILLISECONDS.convert(jwtConfig.validity, jwtConfig.timeUnit)
private val algorithm = Algorithm.HMAC256(jwtConfig.secret)
val verifier: JWTVerifier = JWT.require(algorithm).build()
fun sign(jwtPayload: JwtPayload): String = JWT.create()
.withClaim("id", jwtPayload.userId)
.withClaim("username", jwtPayload.username)
.withExpiresAt(getExpiration())
.sign(algorithm)
private fun getExpiration() = Date(System.currentTimeMillis() + validityInMs)
}

View File

@ -0,0 +1,45 @@
package be.simplenotes.domain.usecases
import arrow.core.Either
import be.simplenotes.domain.model.Note
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import be.simplenotes.domain.security.HtmlSanitizer
import be.simplenotes.domain.usecases.markdown.MarkdownConverter
import be.simplenotes.domain.usecases.markdown.MarkdownParsingError
import be.simplenotes.domain.usecases.repositories.NoteRepository
import java.util.*
class NoteService(
private val markdownConverter: MarkdownConverter,
private val noteRepository: NoteRepository,
) {
fun create(userId: Int, markdownText: String): Either<MarkdownParsingError, PersistedNote> =
markdownConverter
.renderDocument(markdownText)
.map { it.copy(html = HtmlSanitizer.sanitize(it.html)) }
.map { Note(it.metadata, markdown = markdownText, html = it.html) }
.map { noteRepository.create(userId, it) }
fun update(userId: Int, uuid: UUID, markdownText: String): Either<MarkdownParsingError, PersistedNote?> =
markdownConverter
.renderDocument(markdownText)
.map { it.copy(html = HtmlSanitizer.sanitize(it.html)) }
.map { Note(it.metadata, markdown = markdownText, html = it.html) }
.map { noteRepository.update(userId, uuid, it) }
fun paginatedNotes(userId: Int, page: Int, itemsPerPage: Int = 20): PaginatedNotes {
val count = noteRepository.count(userId)
val offset = (page - 1) * itemsPerPage
val numberOfPages = (count / itemsPerPage) + 1
val notes = if (count == 0) emptyList() else noteRepository.findAll(userId, itemsPerPage, offset)
return PaginatedNotes(numberOfPages, notes)
}
fun find(userId: Int, uuid: UUID) = noteRepository.find(userId, uuid)
fun delete(userId: Int, uuid: UUID) = noteRepository.delete(userId, uuid)
}
data class PaginatedNotes(val pages: Int, val notes: List<PersistedNoteMetadata>)

View File

@ -0,0 +1,9 @@
package be.simplenotes.domain.usecases
import be.simplenotes.domain.usecases.login.LoginUseCase
import be.simplenotes.domain.usecases.register.RegisterUseCase
class UserService(
loginUseCase: LoginUseCase,
registerUseCase: RegisterUseCase
) : LoginUseCase by loginUseCase, RegisterUseCase by registerUseCase

View File

@ -0,0 +1,25 @@
package be.simplenotes.domain.usecases.login
import arrow.core.Either
import arrow.core.extensions.fx
import arrow.core.filterOrElse
import arrow.core.rightIfNotNull
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.domain.validation.UserValidations
internal class LoginUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash,
private val jwt: SimpleJwt
) : LoginUseCase {
override fun login(form: LoginForm) = Either.fx<LoginError, Token> {
val user = !UserValidations.validateLogin(form)
!userRepository.find(user.username)
.rightIfNotNull { Unregistered }
.filterOrElse({ passwordHash.verify(form.password!!, it.password) }, { WrongPassword })
.map { jwt.sign(JwtPayload(it)) }
}
}

View File

@ -0,0 +1,17 @@
package be.simplenotes.domain.usecases.login
import arrow.core.Either
import io.konform.validation.ValidationErrors
sealed class LoginError
object Unregistered : LoginError()
object WrongPassword : LoginError()
class InvalidLoginForm(val validationErrors: ValidationErrors) : LoginError()
typealias Token = String
data class LoginForm(val username: String?, val password: String?)
interface LoginUseCase {
fun login(form: LoginForm): Either<LoginError, Token>
}

View File

@ -0,0 +1,74 @@
package be.simplenotes.domain.usecases.markdown
import arrow.core.Either
import arrow.core.Try
import arrow.core.extensions.fx
import arrow.core.left
import arrow.core.right
import be.simplenotes.domain.model.NoteMetadata
import be.simplenotes.domain.validation.NoteValidations
import com.auth0.jwt.JWT
import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser
import io.konform.validation.ValidationErrors
import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.parser.ParserException
import org.yaml.snakeyaml.scanner.ScannerException
sealed class MarkdownParsingError
object MissingMeta : MarkdownParsingError()
object InvalidMeta : MarkdownParsingError()
class ValidationError(val validationErrors: ValidationErrors) : MarkdownParsingError()
data class Document(val metadata: NoteMetadata, val html: String)
typealias MetaMdPair = Pair<String, String>
interface MarkdownConverter {
fun renderDocument(input: String): Either<MarkdownParsingError, Document>
}
internal class MarkdownConverterImpl : MarkdownConverter {
private val yamlBoundPattern = "-{3}".toRegex()
private fun splitMetaFromDocument(input: String): Either<MissingMeta, MetaMdPair> {
val split = input.split(yamlBoundPattern, 3)
if (split.size < 3) return MissingMeta.left()
return (split[1].trim() to split[2].trim()).right()
}
private val yaml = Yaml()
private fun parseMeta(input: String): Either<InvalidMeta, NoteMetadata> {
val load: Map<String, Any> = try {
yaml.load(input)
} catch (e: ParserException) {
return InvalidMeta.left()
} catch (e: ScannerException) {
return InvalidMeta.left()
}
val title = when (val titleNode = load["title"]) {
is String, is Number -> titleNode.toString()
else -> return InvalidMeta.left()
}
val tagsNode = load["tags"]
val tags = if (tagsNode !is List<*>)
emptyList()
else
tagsNode.map { it.toString() }
return NoteMetadata(title, tags).right()
}
private val parser = Parser.builder().build()
private val renderer = HtmlRenderer.builder().build()
private fun renderMarkdown(markdown: String) = parser.parse(markdown).run(renderer::render)
override fun renderDocument(input: String) = Either.fx<MarkdownParsingError, Document> {
val (meta, md) = !splitMetaFromDocument(input)
val parsedMeta = !parseMeta(meta)
!NoteValidations.validateMetadata(parsedMeta).toEither { }.swap()
val html = renderMarkdown(md)
Document(parsedMeta, html)
}
}

View File

@ -0,0 +1,22 @@
package be.simplenotes.domain.usecases.register
import arrow.core.Either
import arrow.core.filterOrElse
import arrow.core.leftIfNull
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.domain.validation.UserValidations
internal class RegisterUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash
) : RegisterUseCase {
override fun register(form: RegisterForm): Either<RegisterError, PersistedUser> {
return UserValidations.validateRegister(form)
.filterOrElse({ !userRepository.exists(it.username) }, { UserExists })
.map { it.copy(password = passwordHash.crypt(it.password)) }
.map { userRepository.create(it) }
.leftIfNull { UserExists }
}
}

View File

@ -0,0 +1,16 @@
package be.simplenotes.domain.usecases.register
import arrow.core.Either
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.usecases.login.LoginForm
import io.konform.validation.ValidationErrors
sealed class RegisterError
object UserExists : RegisterError()
class InvalidRegisterForm(val validationErrors: ValidationErrors) : RegisterError()
typealias RegisterForm = LoginForm
interface RegisterUseCase {
fun register(form: RegisterForm): Either<RegisterError, PersistedUser>
}

View File

@ -0,0 +1,17 @@
package be.simplenotes.domain.usecases.repositories
import be.simplenotes.domain.model.Note
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import java.util.*
interface NoteRepository {
fun findAll(userId: Int, limit: Int = 20, offset: Int = 0): List<PersistedNoteMetadata>
fun exists(userId: Int, uuid: UUID): Boolean
fun create(userId: Int, note: Note): PersistedNote
fun find(userId: Int, uuid: UUID): PersistedNote?
fun update(userId: Int, uuid: UUID, note: Note): PersistedNote?
fun delete(userId: Int, uuid: UUID): Boolean
fun getTags(userId: Int): List<String>
fun count(userId: Int): Int
}

View File

@ -0,0 +1,13 @@
package be.simplenotes.domain.usecases.repositories
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.model.User
interface UserRepository {
fun create(user: User): PersistedUser?
fun find(username: String): PersistedUser?
fun find(id: Int): PersistedUser?
fun exists(username: String): Boolean
fun exists(id: Int): Boolean
fun delete(id: Int): Boolean
}

View File

@ -0,0 +1,36 @@
package be.simplenotes.domain.validation
import arrow.core.*
import be.simplenotes.domain.model.NoteMetadata
import be.simplenotes.domain.model.User
import be.simplenotes.domain.usecases.login.InvalidLoginForm
import be.simplenotes.domain.usecases.markdown.ValidationError
import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxItems
import io.konform.validation.jsonschema.maxLength
import io.konform.validation.jsonschema.uniqueItems
internal object NoteValidations {
private val metaValidator = Validation<NoteMetadata> {
NoteMetadata::title required {
addConstraint("must not be blank") { it.isNotBlank() }
maxLength(50)
}
NoteMetadata::tags required {
maxItems(5)
uniqueItems(true)
}
NoteMetadata::tags onEach {
maxLength(15)
addConstraint("must not be blank") { it.isNotBlank() }
}
}
fun validateMetadata(meta: NoteMetadata): Option<ValidationError> {
val errors = metaValidator.validate(meta).errors
return if (errors.isEmpty()) none()
else return ValidationError(errors).some()
}
}

View File

@ -0,0 +1,38 @@
package be.simplenotes.domain.validation
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import be.simplenotes.domain.model.User
import be.simplenotes.domain.usecases.login.InvalidLoginForm
import be.simplenotes.domain.usecases.login.LoginForm
import be.simplenotes.domain.usecases.register.InvalidRegisterForm
import be.simplenotes.domain.usecases.register.RegisterForm
import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxLength
import io.konform.validation.jsonschema.minLength
internal object UserValidations {
private val loginValidator = Validation<LoginForm> {
LoginForm::username required {
minLength(3)
maxLength(50)
}
LoginForm::password required {
minLength(8)
maxLength(72) // jbcrypt limit, see https://security.stackexchange.com/a/39851
}
}
fun validateLogin(form: LoginForm): Either<InvalidLoginForm, User> {
val errors = loginValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right()
else return InvalidLoginForm(errors).left()
}
fun validateRegister(form: RegisterForm): Either<InvalidRegisterForm, User> {
val errors = loginValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right()
else return InvalidRegisterForm(errors).left()
}
}

View File

@ -0,0 +1,5 @@
package be.simplenotes.domain
/**
* Empty file @see [root-package-declaration](https://discuss.kotlinlang.org/t/root-package-declaration-to-reduce-folder-clutter/2247/4)
*/

View File

@ -0,0 +1,50 @@
package be.simplenotes.domain.security
import be.simplenotes.domain.usecases.login.Token
import be.simplenotes.shared.config.JwtConfig
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import com.natpryce.hamkrest.absent
import com.natpryce.hamkrest.assertion.assertThat
import com.natpryce.hamkrest.equalTo
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.util.concurrent.TimeUnit
import java.util.stream.Stream
internal class JwtPayloadExtractorTest {
private val jwtConfig = JwtConfig("a secret", 1, TimeUnit.HOURS)
private val simpleJwt = SimpleJwt(jwtConfig)
private val jwtPayloadExtractor = JwtPayloadExtractor(simpleJwt)
private fun createToken(username: String? = null, id: Int? = null, secret: String = jwtConfig.secret): Token {
val algo = Algorithm.HMAC256(secret)
return JWT.create().apply {
username?.let { withClaim("username", it) }
id?.let { withClaim("id", it) }
}.sign(algo)
}
@Suppress("Unused")
private fun invalidTokens() = Stream.of(
createToken(id = 1),
createToken(username = "user"),
createToken(),
createToken(username = "user", id = 1, secret = "not the correct secret"),
createToken(username = "user", id = 1) + "\"efesfsef",
"something that is not even a token"
)
@ParameterizedTest(name = "[{index}] token `{0}` should be invalid")
@MethodSource("invalidTokens")
fun `parse invalid tokens`(token: String) {
assertThat(jwtPayloadExtractor(token), absent())
}
@Test
fun `parse valid token`() {
val token = createToken(username = "someone", id = 1)
assertThat(jwtPayloadExtractor(token), equalTo(JwtPayload(1, "someone")))
}
}

View File

@ -0,0 +1,64 @@
package be.simplenotes.domain.usecases.login
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.security.BcryptPasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.shared.config.JwtConfig
import be.simplenotes.shared.testutils.assertions.isLeftOfType
import be.simplenotes.shared.testutils.assertions.isRight
import com.natpryce.hamkrest.assertion.assertThat
import io.mockk.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.concurrent.TimeUnit
internal class LoginUseCaseImplTest {
// region setup
private val mockUserRepository = mockk<UserRepository>()
private val passwordHash = BcryptPasswordHash(test = true)
private val jwtConfig = JwtConfig("a secret", 1, TimeUnit.HOURS)
private val simpleJwt = SimpleJwt(jwtConfig)
private val loginUseCase = LoginUseCaseImpl(mockUserRepository, passwordHash, simpleJwt)
@BeforeEach
fun resetMocks() {
clearMocks(mockUserRepository)
}
// endregion
@Test
fun `Login should fail with invalid form`() {
val form = LoginForm("", "a")
assertThat(loginUseCase.login(form), isLeftOfType<InvalidLoginForm>())
verify { mockUserRepository wasNot called }
}
@Test
fun `Login should fail with non existing user`() {
val form = LoginForm("someusername", "somepassword")
every { mockUserRepository.find(form.username!!) } returns null
assertThat(loginUseCase.login(form), isLeftOfType<Unregistered>())
}
@Test
fun `Login should fail with wrong password`() {
val form = LoginForm("someusername", "wrongpassword")
every { mockUserRepository.find(form.username!!) } returns
PersistedUser(form.username!!, passwordHash.crypt("right password"), 1)
assertThat(loginUseCase.login(form), isLeftOfType<WrongPassword>())
}
@Test
fun `Login should succeed with existing user and correct password`() {
val loginForm = LoginForm("someusername", "somepassword")
every { mockUserRepository.find(loginForm.username!!) } returns
PersistedUser(loginForm.username!!, passwordHash.crypt(loginForm.password!!), 1)
val res = loginUseCase.login(loginForm)
assertThat(res, isRight())
}
}

View File

@ -0,0 +1,50 @@
package be.simplenotes.domain.usecases.register
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.security.BcryptPasswordHash
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.shared.testutils.assertions.isLeftOfType
import be.simplenotes.shared.testutils.assertions.isRight
import com.natpryce.hamkrest.assertion.assertThat
import com.natpryce.hamkrest.equalTo
import io.mockk.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class RegisterUseCaseImplTest {
// region setup
private val mockUserRepository = mockk<UserRepository>()
private val passwordHash = BcryptPasswordHash(test = true)
private val registerUseCase = RegisterUseCaseImpl(mockUserRepository, passwordHash)
@BeforeEach
fun resetMocks() {
clearMocks(mockUserRepository)
}
// endregion
@Test
fun `register should fail with invalid form`() {
val form = RegisterForm("", "a".repeat(10))
assertThat(registerUseCase.register(form), isLeftOfType<InvalidRegisterForm>())
verify { mockUserRepository wasNot called }
}
@Test
fun `Register should fail with existing username`() {
val form = RegisterForm("someuser", "somepassword")
every { mockUserRepository.exists(form.username!!) } returns true
assertThat(registerUseCase.register(form), isLeftOfType<UserExists>())
}
@Test
fun `Register should succeed with new user`() {
val form = RegisterForm("someuser", "somepassword")
every { mockUserRepository.exists(form.username!!) } returns false
every { mockUserRepository.create(any()) } returns PersistedUser(form.username!!, form.password!!, 1)
val res = registerUseCase.register(form)
assertThat(res, isRight())
res.map { assertThat(it.username, equalTo(form.username)) }
}
}

View File

@ -0,0 +1,77 @@
package be.simplenotes.domain.validation
import be.simplenotes.domain.usecases.login.InvalidLoginForm
import be.simplenotes.domain.usecases.login.LoginForm
import be.simplenotes.domain.usecases.register.RegisterForm
import be.simplenotes.shared.testutils.assertions.isLeftOfType
import be.simplenotes.shared.testutils.assertions.isRight
import com.natpryce.hamkrest.assertion.assertThat
import org.junit.jupiter.api.Nested
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.util.stream.Stream
internal class UserValidationsTest {
@Nested
inner class Login {
@Suppress("Unused")
fun invalidLoginForms(): Stream<LoginForm> = Stream.of(
LoginForm(username = null, password = null),
LoginForm(username = "", password = ""),
LoginForm(username = "a", password = "aaaa"),
LoginForm(username = "a".repeat(51), password = "a".repeat(8)),
LoginForm(username = "a".repeat(10), password = "a".repeat(7))
)
@ParameterizedTest
@MethodSource("invalidLoginForms")
fun `validate invalid logins`(form: LoginForm) {
assertThat(UserValidations.validateLogin(form), isLeftOfType<InvalidLoginForm>())
}
@Suppress("Unused")
fun validLoginForms(): Stream<LoginForm> = Stream.of(
LoginForm(username = "a".repeat(50), password = "a".repeat(72)),
LoginForm(username = "a".repeat(3), password = "a".repeat(8))
)
@ParameterizedTest
@MethodSource("validLoginForms")
fun `validate valid logins`(form: LoginForm) {
assertThat(UserValidations.validateLogin(form), isRight())
}
}
@Nested
inner class Register {
@Suppress("Unused")
fun invalidRegisterForms(): Stream<RegisterForm> = Stream.of(
RegisterForm(username = null, password = null),
RegisterForm(username = "", password = ""),
RegisterForm(username = "a", password = "aaaa"),
RegisterForm(username = "a".repeat(51), password = "a".repeat(8)),
RegisterForm(username = "a".repeat(10), password = "a".repeat(7))
)
@ParameterizedTest
@MethodSource("invalidRegisterForms")
fun `validate invalid register`(form: LoginForm) {
assertThat(UserValidations.validateLogin(form), isLeftOfType<InvalidLoginForm>())
}
@Suppress("Unused")
fun validRegisterForms(): Stream<RegisterForm> = Stream.of(
RegisterForm(username = "a".repeat(50), password = "a".repeat(72)),
RegisterForm(username = "a".repeat(3), password = "a".repeat(8))
)
@ParameterizedTest
@MethodSource("validRegisterForms")
fun `validate valid register`(form: LoginForm) {
assertThat(UserValidations.validateLogin(form), isRight())
}
}
}

286
mvnw vendored
View File

@ -1,286 +0,0 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Maven2 Start Up Batch script
#
# Required ENV vars:
# ------------------
# JAVA_HOME - location of a JDK home dir
#
# Optional ENV vars
# -----------------
# M2_HOME - location of maven2's installed home dir
# MAVEN_OPTS - parameters passed to the Java VM when running Maven
# e.g. to debug Maven itself, use
# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
# MAVEN_SKIP_RC - flag to disable loading of mavenrc files
# ----------------------------------------------------------------------------
if [ -z "$MAVEN_SKIP_RC" ] ; then
if [ -f /etc/mavenrc ] ; then
. /etc/mavenrc
fi
if [ -f "$HOME/.mavenrc" ] ; then
. "$HOME/.mavenrc"
fi
fi
# OS specific support. $var _must_ be set to either true or false.
cygwin=false;
darwin=false;
mingw=false
case "`uname`" in
CYGWIN*) cygwin=true ;;
MINGW*) mingw=true;;
Darwin*) darwin=true
# Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
# See https://developer.apple.com/library/mac/qa/qa1170/_index.html
if [ -z "$JAVA_HOME" ]; then
if [ -x "/usr/libexec/java_home" ]; then
export JAVA_HOME="`/usr/libexec/java_home`"
else
export JAVA_HOME="/Library/Java/Home"
fi
fi
;;
esac
if [ -z "$JAVA_HOME" ] ; then
if [ -r /etc/gentoo-release ] ; then
JAVA_HOME=`java-config --jre-home`
fi
fi
if [ -z "$M2_HOME" ] ; then
## resolve links - $0 may be a link to maven's home
PRG="$0"
# need this for relative symlinks
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG="`dirname "$PRG"`/$link"
fi
done
saveddir=`pwd`
M2_HOME=`dirname "$PRG"`/..
# make it fully qualified
M2_HOME=`cd "$M2_HOME" && pwd`
cd "$saveddir"
# echo Using m2 at $M2_HOME
fi
# For Cygwin, ensure paths are in UNIX format before anything is touched
if $cygwin ; then
[ -n "$M2_HOME" ] &&
M2_HOME=`cygpath --unix "$M2_HOME"`
[ -n "$JAVA_HOME" ] &&
JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
[ -n "$CLASSPATH" ] &&
CLASSPATH=`cygpath --path --unix "$CLASSPATH"`
fi
# For Mingw, ensure paths are in UNIX format before anything is touched
if $mingw ; then
[ -n "$M2_HOME" ] &&
M2_HOME="`(cd "$M2_HOME"; pwd)`"
[ -n "$JAVA_HOME" ] &&
JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`"
# TODO classpath?
fi
if [ -z "$JAVA_HOME" ]; then
javaExecutable="`which javac`"
if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then
# readlink(1) is not available as standard on Solaris 10.
readLink=`which readlink`
if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then
if $darwin ; then
javaHome="`dirname \"$javaExecutable\"`"
javaExecutable="`cd \"$javaHome\" && pwd -P`/javac"
else
javaExecutable="`readlink -f \"$javaExecutable\"`"
fi
javaHome="`dirname \"$javaExecutable\"`"
javaHome=`expr "$javaHome" : '\(.*\)/bin'`
JAVA_HOME="$javaHome"
export JAVA_HOME
fi
fi
fi
if [ -z "$JAVACMD" ] ; then
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
else
JAVACMD="`which java`"
fi
fi
if [ ! -x "$JAVACMD" ] ; then
echo "Error: JAVA_HOME is not defined correctly." >&2
echo " We cannot execute $JAVACMD" >&2
exit 1
fi
if [ -z "$JAVA_HOME" ] ; then
echo "Warning: JAVA_HOME environment variable is not set."
fi
CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher
# traverses directory structure from process work directory to filesystem root
# first directory with .mvn subdirectory is considered project base directory
find_maven_basedir() {
if [ -z "$1" ]
then
echo "Path not specified to find_maven_basedir"
return 1
fi
basedir="$1"
wdir="$1"
while [ "$wdir" != '/' ] ; do
if [ -d "$wdir"/.mvn ] ; then
basedir=$wdir
break
fi
# workaround for JBEAP-8937 (on Solaris 10/Sparc)
if [ -d "${wdir}" ]; then
wdir=`cd "$wdir/.."; pwd`
fi
# end of workaround
done
echo "${basedir}"
}
# concatenates all lines of a file
concat_lines() {
if [ -f "$1" ]; then
echo "$(tr -s '\n' ' ' < "$1")"
fi
}
BASE_DIR=`find_maven_basedir "$(pwd)"`
if [ -z "$BASE_DIR" ]; then
exit 1;
fi
##########################################################################################
# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
# This allows using the maven wrapper in projects that prohibit checking in binary data.
##########################################################################################
if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then
if [ "$MVNW_VERBOSE" = true ]; then
echo "Found .mvn/wrapper/maven-wrapper.jar"
fi
else
if [ "$MVNW_VERBOSE" = true ]; then
echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..."
fi
jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.0/maven-wrapper-0.4.0.jar"
while IFS="=" read key value; do
case "$key" in (wrapperUrl) jarUrl="$value"; break ;;
esac
done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties"
if [ "$MVNW_VERBOSE" = true ]; then
echo "Downloading from: $jarUrl"
fi
wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar"
if command -v wget > /dev/null; then
if [ "$MVNW_VERBOSE" = true ]; then
echo "Found wget ... using wget"
fi
wget "$jarUrl" -O "$wrapperJarPath"
elif command -v curl > /dev/null; then
if [ "$MVNW_VERBOSE" = true ]; then
echo "Found curl ... using curl"
fi
curl -o "$wrapperJarPath" "$jarUrl"
else
if [ "$MVNW_VERBOSE" = true ]; then
echo "Falling back to using Java to download"
fi
javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java"
if [ -e "$javaClass" ]; then
if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
if [ "$MVNW_VERBOSE" = true ]; then
echo " - Compiling MavenWrapperDownloader.java ..."
fi
# Compiling the Java class
("$JAVA_HOME/bin/javac" "$javaClass")
fi
if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
# Running the downloader
if [ "$MVNW_VERBOSE" = true ]; then
echo " - Running MavenWrapperDownloader.java ..."
fi
("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR")
fi
fi
fi
fi
##########################################################################################
# End of extension
##########################################################################################
export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}
if [ "$MVNW_VERBOSE" = true ]; then
echo $MAVEN_PROJECTBASEDIR
fi
MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
# For Cygwin, switch paths to Windows format before running java
if $cygwin; then
[ -n "$M2_HOME" ] &&
M2_HOME=`cygpath --path --windows "$M2_HOME"`
[ -n "$JAVA_HOME" ] &&
JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"`
[ -n "$CLASSPATH" ] &&
CLASSPATH=`cygpath --path --windows "$CLASSPATH"`
[ -n "$MAVEN_PROJECTBASEDIR" ] &&
MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"`
fi
WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
exec "$JAVACMD" \
$MAVEN_OPTS \
-classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
"-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"

161
mvnw.cmd vendored
View File

@ -1,161 +0,0 @@
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Maven2 Start Up Batch script
@REM
@REM Required ENV vars:
@REM JAVA_HOME - location of a JDK home dir
@REM
@REM Optional ENV vars
@REM M2_HOME - location of maven2's installed home dir
@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending
@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
@REM e.g. to debug Maven itself, use
@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
@REM ----------------------------------------------------------------------------
@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
@echo off
@REM set title of command window
title %0
@REM enable echoing my setting MAVEN_BATCH_ECHO to 'on'
@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO%
@REM set %HOME% to equivalent of $HOME
if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
@REM Execute a user defined script before this one
if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
@REM check for pre script, once with legacy .bat ending and once with .cmd ending
if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat"
if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd"
:skipRcPre
@setlocal
set ERROR_CODE=0
@REM To isolate internal variables from possible post scripts, we use another setlocal
@setlocal
@REM ==== START VALIDATION ====
if not "%JAVA_HOME%" == "" goto OkJHome
echo.
echo Error: JAVA_HOME not found in your environment. >&2
echo Please set the JAVA_HOME variable in your environment to match the >&2
echo location of your Java installation. >&2
echo.
goto error
:OkJHome
if exist "%JAVA_HOME%\bin\java.exe" goto init
echo.
echo Error: JAVA_HOME is set to an invalid directory. >&2
echo JAVA_HOME = "%JAVA_HOME%" >&2
echo Please set the JAVA_HOME variable in your environment to match the >&2
echo location of your Java installation. >&2
echo.
goto error
@REM ==== END VALIDATION ====
:init
@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
@REM Fallback to current working directory if not found.
set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
set EXEC_DIR=%CD%
set WDIR=%EXEC_DIR%
:findBaseDir
IF EXIST "%WDIR%"\.mvn goto baseDirFound
cd ..
IF "%WDIR%"=="%CD%" goto baseDirNotFound
set WDIR=%CD%
goto findBaseDir
:baseDirFound
set MAVEN_PROJECTBASEDIR=%WDIR%
cd "%EXEC_DIR%"
goto endDetectBaseDir
:baseDirNotFound
set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
cd "%EXEC_DIR%"
:endDetectBaseDir
IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
@setlocal EnableExtensions EnableDelayedExpansion
for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
:endReadAdditionalConfig
SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.0/maven-wrapper-0.4.0.jar"
FOR /F "tokens=1,2 delims==" %%A IN (%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties) DO (
IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B
)
@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
if exist %WRAPPER_JAR% (
echo Found %WRAPPER_JAR%
) else (
echo Couldn't find %WRAPPER_JAR%, downloading it ...
echo Downloading from: %DOWNLOAD_URL%
powershell -Command "(New-Object Net.WebClient).DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"
echo Finished downloading %WRAPPER_JAR%
)
@REM End of extension
%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
if ERRORLEVEL 1 goto error
goto end
:error
set ERROR_CODE=1
:end
@endlocal & set ERROR_CODE=%ERROR_CODE%
if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost
@REM check for post script, once with legacy .bat ending and once with .cmd ending
if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat"
if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd"
:skipRcPost
@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
if "%MAVEN_BATCH_PAUSE%" == "on" pause
if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE%
exit /B %ERROR_CODE%

View File

@ -1,15 +0,0 @@
{
"name": "css",
"version": "1.0.0",
"scripts": {
"css": "postcss build css/styles.css --output resources/static/styles.css",
"doc": "aglio -i api-doc/api.apib -o resources/docs/index.html --theme-variables slate"
},
"dependencies": {
"aglio": "^2.3.0",
"cssnano": "^4.1.10",
"postcss-cli": "^7.1.1",
"postcss-hash": "^2.0.0",
"tailwindcss": "^1.5.1"
}
}

62
persistance/pom.xml Normal file
View File

@ -0,0 +1,62 @@
<project>
<parent>
<artifactId>parent</artifactId>
<groupId>be.simplenotes</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>persistance</artifactId>
<dependencies>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>domain</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>shared</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>shared</artifactId>
<version>1.0-SNAPSHOT</version>
<type>test-jar</type>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>2.6.1</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.200</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<version>6.5.2</version>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>3.4.5</version>
</dependency>
<dependency>
<groupId>me.liuwj.ktorm</groupId>
<artifactId>ktorm-core</artifactId>
<version>3.0.0</version>
</dependency>
<dependency>
<groupId>me.liuwj.ktorm</groupId>
<artifactId>ktorm-support-mysql</artifactId>
<version>3.0.0</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,13 @@
package be.simplenotes.persistance
import org.flywaydb.core.Flyway
import javax.sql.DataSource
internal class DbMigrationsImpl(private val dataSource: DataSource) : DbMigrations {
override fun migrate() {
Flyway.configure()
.dataSource(dataSource)
.load()
.migrate()
}
}

View File

@ -0,0 +1,36 @@
package be.simplenotes.persistance
import be.simplenotes.domain.usecases.repositories.NoteRepository
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.persistance.notes.NoteRepositoryImpl
import be.simplenotes.persistance.users.UserRepositoryImpl
import be.simplenotes.shared.config.DataSourceConfig
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import me.liuwj.ktorm.database.*
import org.koin.dsl.module
import javax.sql.DataSource
interface DbMigrations {
fun migrate()
}
private fun hikariDataSource(conf: DataSourceConfig): HikariDataSource {
val hikariConfig = HikariConfig().also {
it.jdbcUrl = conf.jdbcUrl
it.driverClassName = conf.driverClassName
it.username = conf.username
it.password = conf.password
it.maximumPoolSize = conf.maximumPoolSize
it.connectionTimeout = conf.connectionTimeout
}
return HikariDataSource(hikariConfig)
}
val persistanceModule = module {
single<UserRepository> { UserRepositoryImpl(get()) }
single<NoteRepository> { NoteRepositoryImpl(get()) }
single<DbMigrations> { DbMigrationsImpl(get()) }
single<DataSource> { hikariDataSource(get()) }
single { Database.connect(get<DataSource>()) }
}

View File

@ -0,0 +1,25 @@
package be.simplenotes.persistance.extensions
import me.liuwj.ktorm.schema.*
import java.nio.ByteBuffer
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.sql.Types
import java.util.*
internal class UuidBinarySqlType : SqlType<UUID>(Types.BINARY, typeName = "uuidBinary") {
override fun doGetResult(rs: ResultSet, index: Int): UUID? {
val value = rs.getBytes(index) ?: return null
return ByteBuffer.wrap(value).let { b -> UUID(b.long, b.long) }
}
override fun doSetParameter(ps: PreparedStatement, index: Int, parameter: UUID) {
val bytes = ByteBuffer.allocate(16)
.putLong(parameter.mostSignificantBits)
.putLong(parameter.leastSignificantBits)
.array()
ps.setBytes(index, bytes)
}
}
internal fun <E : Any> BaseTable<E>.uuidBinary(name: String) = registerColumn(name, UuidBinarySqlType())

View File

@ -0,0 +1,123 @@
package be.simplenotes.persistance.notes
import be.simplenotes.domain.model.Note
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import be.simplenotes.domain.usecases.repositories.NoteRepository
import me.liuwj.ktorm.database.*
import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.*
import java.time.LocalDateTime
import java.util.*
import kotlin.collections.HashMap
internal class NoteRepositoryImpl(private val db: Database) : NoteRepository {
@Throws(IllegalArgumentException::class)
override fun findAll(userId: Int, limit: Int, offset: Int): List<PersistedNoteMetadata> {
require(limit > 0) { "limit should be positive" }
require(offset >= 0) { "offset should not be negative" }
val notes = db.notes
.filterColumns { listOf(it.uuid, it.title, it.updatedAt) }
.filter { it.userId eq userId }
.sortedByDescending { it.updatedAt }
.take(limit)
.drop(offset)
.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 ->
val tags = tagsByUuid[note.uuid] ?: emptyList()
note.toPersistedMetadata(tags)
}
}
override fun exists(userId: Int, uuid: UUID): Boolean {
return db.notes.any { (it.userId eq userId) and (it.uuid eq uuid) }
}
override fun create(userId: Int, note: Note): PersistedNote {
val uuid = UUID.randomUUID()
val entity = note.toEntity(uuid, userId).apply {
this.updatedAt = LocalDateTime.now()
}
db.useTransaction {
db.notes.add(entity)
db.batchInsert(Tags) {
note.meta.tags.forEach { tagName ->
item {
it.noteUuid to uuid
it.name to tagName
}
}
}
}
return entity.toPersistedNote(note.meta.tags)
}
@Suppress("UNCHECKED_CAST")
override fun find(userId: Int, uuid: UUID): PersistedNote? {
val note = db.notes
.filterColumns { it.columns - it.userId }
.filter { it.uuid eq uuid }
.find { it.userId eq userId }
?: return null
val tags = db.tags
.filter { it.noteUuid eq uuid }
.mapColumns { it.name } as List<String>
return note.toPersistedNote(tags)
}
override fun update(userId: Int, uuid: UUID, note: Note): PersistedNote? {
db.useTransaction {
val currentNote = db.notes
.find { it.uuid eq uuid and (it.userId eq userId) }
?: return null
currentNote.title = note.meta.title
currentNote.markdown = note.markdown
currentNote.html = note.html
currentNote.updatedAt = LocalDateTime.now()
currentNote.flushChanges()
// delete all tags
db.delete(Tags) {
it.noteUuid eq uuid
}
// put new ones
note.meta.tags.forEach { tagName ->
db.insert(Tags) {
it.name to tagName
it.noteUuid to uuid
}
}
return currentNote.toPersistedNote(note.meta.tags)
}
}
override fun delete(userId: Int, uuid: UUID): Boolean = db.useTransaction {
db.delete(Notes) { it.uuid eq uuid and (it.userId eq userId) } == 1
}
@Suppress("UNCHECKED_CAST")
override fun getTags(userId: Int): List<String> {
return db.sequenceOf(Tags)
.filter { it.note.userId eq userId }
.mapColumns(isDistinct = true) { it.name } as List<String>
}
override fun count(userId: Int) = db.notes.count { it.userId eq userId }
}

View File

@ -0,0 +1,58 @@
package be.simplenotes.persistance.notes
import be.simplenotes.domain.model.Note
import be.simplenotes.domain.model.NoteMetadata
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import be.simplenotes.persistance.extensions.uuidBinary
import be.simplenotes.persistance.users.UserEntity
import be.simplenotes.persistance.users.Users
import me.liuwj.ktorm.database.*
import me.liuwj.ktorm.entity.*
import me.liuwj.ktorm.schema.*
import java.time.Instant
import java.time.LocalDateTime
import java.util.*
internal open class Notes(alias: String?) : Table<NoteEntity>("Notes", alias) {
companion object : Notes(null)
override fun aliased(alias: String) = Notes(alias)
val uuid = uuidBinary("uuid").primaryKey().bindTo { it.uuid }
val title = varchar("title").bindTo { it.title }
val markdown = text("markdown").bindTo { it.markdown }
val html = text("html").bindTo { it.html }
val userId = int("user_id").references(Users) { it.user }
val updatedAt = datetime("updated_at").bindTo { it.updatedAt }
val user get() = userId.referenceTable as Users
}
internal val Database.notes get() = this.sequenceOf(Notes, withReferences = false)
internal interface NoteEntity : Entity<NoteEntity> {
companion object : Entity.Factory<NoteEntity>()
var uuid: UUID
var title: String
var markdown: String
var html: String
var updatedAt: LocalDateTime
var user: UserEntity
}
internal fun NoteEntity.toPersistedMetadata(tags: List<String>) = PersistedNoteMetadata(title, tags, updatedAt, uuid)
internal fun NoteEntity.toPersistedNote(tags: List<String>) = PersistedNote(NoteMetadata(title, tags), markdown, html, updatedAt, uuid)
internal fun Note.toEntity(uuid: UUID, userId: Int): NoteEntity {
val note = this
return NoteEntity {
this.title = note.meta.title
this.markdown = note.markdown
this.html = note.html
this.uuid = uuid
this.user["id"] = userId
}
}

View File

@ -0,0 +1,27 @@
package be.simplenotes.persistance.notes
import be.simplenotes.persistance.extensions.uuidBinary
import me.liuwj.ktorm.database.*
import me.liuwj.ktorm.entity.*
import me.liuwj.ktorm.schema.*
internal open class Tags(alias: String?) : Table<TagEntity>("Tags", alias) {
companion object : Tags(null)
override fun aliased(alias: String) = Tags(alias)
val id = int("id").primaryKey().bindTo { it.id }
val name = varchar("name").bindTo { it.name }
val noteUuid = uuidBinary("note_uuid").references(Notes) { it.note }
val note get() = noteUuid.referenceTable as Notes
}
internal val Database.tags get() = this.sequenceOf(Tags, withReferences = false)
internal interface TagEntity : Entity<TagEntity> {
companion object : Entity.Factory<TagEntity>()
val id: Int
var name: String
var note: NoteEntity
}

View File

@ -0,0 +1,26 @@
package be.simplenotes.persistance.users
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.model.User
import be.simplenotes.domain.usecases.repositories.UserRepository
import me.liuwj.ktorm.database.*
import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.*
import java.sql.SQLIntegrityConstraintViolationException
internal class UserRepositoryImpl(private val db: Database) : UserRepository {
override fun create(user: User): PersistedUser? {
return try {
db.useTransaction { db.users.add(user.toEntity()) }
find(user.username)
} catch (e: SQLIntegrityConstraintViolationException) {
null
}
}
override fun find(username: String) = db.users.find { it.username eq username }?.toPersistedUser()
override fun find(id: Int) = db.users.find { it.id eq id }?.toPersistedUser()
override fun exists(username: String) = db.users.any { it.username eq username }
override fun exists(id: Int) = db.users.any { it.id eq id }
override fun delete(id: Int) = db.useTransaction { db.users.find { it.id eq id }?.delete() == 1 }
}

View File

@ -0,0 +1,45 @@
package be.simplenotes.persistance.users
import be.simplenotes.domain.model.PersistedUser
import be.simplenotes.domain.model.User
import me.liuwj.ktorm.database.*
import me.liuwj.ktorm.entity.*
import me.liuwj.ktorm.schema.*
internal open class Users(alias: String?) : Table<UserEntity>("Users", alias) {
companion object : Users(null)
override fun aliased(alias: String) = Users(alias)
val id = int("id").primaryKey().bindTo { it.id }
val username = varchar("username").bindTo { it.username }
val password = varchar("password").bindTo { it.password }
}
internal interface UserEntity : Entity<UserEntity> {
companion object : Entity.Factory<UserEntity>()
val id: Int
var username: String
var password: String
}
internal fun UserEntity.toPersistedUser() = PersistedUser(username, password, id)
internal fun PersistedUser.toEntity(): UserEntity {
val user = this
return UserEntity {
this.username = user.username
this.password = user.password
}.apply { this["id"] = user.id }
}
internal fun User.toEntity(): UserEntity {
val user = this
return UserEntity {
this.username = user.username
this.password = user.password
}
}
internal val Database.users get() = this.sequenceOf(Users, withReferences = false)

View File

@ -5,8 +5,7 @@ create table Users
password varchar(255) not null,
constraint username unique (username)
) character set 'utf8mb4'
collate 'utf8mb4_general_ci';
);
create table Notes
(
@ -19,8 +18,7 @@ create table Notes
constraint Notes_fk_user foreign key (user_id) references Users (id) on delete cascade
) character set 'utf8mb4'
collate 'utf8mb4_general_ci';
);
create index user_id on Notes (user_id);
@ -30,7 +28,6 @@ create table Tags
name varchar(50) not null,
note_uuid binary(16) not null,
constraint Tags_fk_note foreign key (note_uuid) references Notes (uuid) on delete cascade
) character set 'utf8mb4'
collate 'utf8mb4_general_ci';
);
create index note_uuid on Tags (note_uuid);

View File

@ -2,19 +2,15 @@
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<withJansi>true</withJansi>
<encoder>
<pattern>%cyan(%d{YYYY-MM-dd HH:mm:ss.SSS}) [%thread] %highlight(%-5level) %magenta(%logger{36}) - %msg%n
<pattern>%cyan(%d{YYYY-MM-dd HH:mm:ss.SSS}) [%thread] %highlight(%-5level) %green(%logger{36}) - %msg%n
</pattern>
</encoder>
</appender>
<root level="INFO">
<root level="TRACE">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="WARN"/>
<logger name="me.liuwj.ktorm.database" level="INFO"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
<logger name="org.flywaydb.core" level="INFO"/>
<logger name="org.testcontainers" level="INFO"/>
<logger name="com.github.dockerjava" level="WARN"/>
<logger name="🐳 [mariadb:10.3.6]" level="WARN"/>
</configuration>

View File

@ -0,0 +1 @@
package be.simplenotes.persistance

View File

@ -0,0 +1,272 @@
package be.simplenotes.persistance.notes
import be.simplenotes.domain.model.*
import be.simplenotes.domain.usecases.repositories.NoteRepository
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.persistance.DbMigrations
import be.simplenotes.persistance.persistanceModule
import be.simplenotes.shared.config.DataSourceConfig
import me.liuwj.ktorm.database.*
import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.*
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.flywaydb.core.Flyway
import org.junit.jupiter.api.*
import org.junit.jupiter.api.parallel.ResourceLock
import org.koin.dsl.koinApplication
import org.koin.dsl.module
import java.sql.SQLIntegrityConstraintViolationException
import java.util.*
import javax.sql.DataSource
@ResourceLock("h2")
internal class NoteRepositoryImplTest {
private val testModule = module {
single { dataSourceConfig() }
}
private val koinApp = koinApplication {
modules(persistanceModule, testModule)
}
private fun dataSourceConfig() = DataSourceConfig(
jdbcUrl = "jdbc:h2:mem:regular;DB_CLOSE_DELAY=-1;",
driverClassName = "org.h2.Driver",
username = "h2",
password = "",
maximumPoolSize = 2,
connectionTimeout = 3000
)
private val koin = koinApp.koin
@AfterAll
fun afterAll() = koinApp.close()
private val migration = koin.get<DbMigrations>()
private val dataSource = koin.get<DataSource>()
private val noteRepo = koin.get<NoteRepository>()
private val userRepo = koin.get<UserRepository>()
private val db = koin.get<Database>()
private lateinit var user1: PersistedUser
private lateinit var user2: PersistedUser
@BeforeEach
fun beforeEach() {
Flyway.configure()
.dataSource(dataSource)
.load()
.clean()
migration.migrate()
user1 = userRepo.create(User("1", "1"))!!
user2 = userRepo.create(User("2", "2"))!!
}
private fun createNote(
userId: Int,
title: String,
tags: List<String> = emptyList(),
md: String = "md",
html: String = "html",
): PersistedNote = noteRepo.create(userId, Note(NoteMetadata(title, tags), md, html))
private fun PersistedNote.toPersistedMeta() = PersistedNoteMetadata(meta.title, meta.tags, updatedAt, uuid)
@Nested
@DisplayName("create()")
inner class Create {
@Test
fun `create note for non existing user`() {
val note = Note(NoteMetadata("title", emptyList()), "md", "html")
assertThatThrownBy {
noteRepo.create(1000, note)
}.isInstanceOf(SQLIntegrityConstraintViolationException::class.java)
}
@Test
fun `create note for existing user`() {
val note = Note(NoteMetadata("title", emptyList()), "md", "html")
assertThat(noteRepo.create(user1.id, note))
.isEqualToIgnoringGivenFields(note, "uuid", "updatedAt")
.hasNoNullFieldsOrProperties()
assertThat(db.notes.toList())
.hasSize(1)
.first()
.isEqualToIgnoringGivenFields(note, "uuid", "updatedAt")
}
}
@Nested
@DisplayName("findAll()")
inner class FindAll {
@Test
fun `find all notes`() {
val notes1 = listOf(
createNote(user1.id, "1", listOf("a", "b")),
createNote(user1.id, "2"),
createNote(user1.id, "3", listOf("c"))
)
val notes2 = listOf(
createNote(user2.id, "4")
)
assertThat(noteRepo.findAll(user1.id))
.hasSize(3)
.usingElementComparatorIgnoringFields("updatedAt")
.containsExactlyInAnyOrderElementsOf(
notes1.map { it.toPersistedMeta() }
)
assertThat(noteRepo.findAll(user2.id))
.hasSize(1)
.usingElementComparatorIgnoringFields("updatedAt")
.containsExactlyInAnyOrderElementsOf(
notes2.map { it.toPersistedMeta() }
)
assertThat(noteRepo.findAll(1000)).isEmpty()
}
@Test
fun pagination() {
(50 downTo 1).forEach {
createNote(user1.id, "$it")
}
assertThat(noteRepo.findAll(user1.id, limit = 20, offset = 0))
.hasSize(20)
.allMatch { it.title.toInt() in 1..20 }
assertThat(noteRepo.findAll(user1.id, limit = 20, offset = 20))
.hasSize(20)
.allMatch { it.title.toInt() in 21..40 }
assertThat(noteRepo.findAll(user1.id, limit = 20, offset = 40))
.hasSize(10)
.allMatch { it.title.toInt() in 41..50 }
}
}
@Nested
@DisplayName("find() | exists()")
inner class FindExists {
@Test
@Suppress("UNCHECKED_CAST")
fun `find an existing note`() {
createNote(user1.id, "1", listOf("a", "b"))
val note = db.notes.find { it.title eq "1" }!!
.let { entity ->
val tags = db.tags.filter { it.noteUuid eq entity.uuid }.mapColumns { it.name } as List<String>
entity.toPersistedNote(tags)
}
assertThat(noteRepo.find(user1.id, note.uuid))
.isEqualTo(note)
assertThat(noteRepo.exists(user1.id, note.uuid))
.isTrue
}
@Test
fun `find an existing note from the wrong user`() {
val note = createNote(user1.id, "1", listOf("a", "b"))
assertThat(noteRepo.find(user2.id, note.uuid)).isNull()
assertThat(noteRepo.exists(user2.id, note.uuid)).isFalse
}
@Test
fun `find a non existing note`() {
createNote(user1.id, "1", listOf("a", "b"))
val uuid = UUID.randomUUID()
assertThat(noteRepo.find(user1.id, uuid)).isNull()
assertThat(noteRepo.exists(user2.id, uuid)).isFalse
}
}
@Nested
@DisplayName("delete()")
inner class Delete {
@Test
fun `delete an existing note for a user should succeed and then fail`() {
val note = createNote(user1.id, "1", listOf("a", "b"))
assertThat(noteRepo.delete(user1.id, note.uuid))
.isTrue
assertThat(noteRepo.delete(user1.id, note.uuid))
.isFalse
}
@Test
fun `delete an existing note for the wrong user`() {
val note = createNote(user1.id, "1", listOf("a", "b"))
assertThat(noteRepo.delete(1000, note.uuid))
.isFalse
}
}
@Nested
@DisplayName("getTags()")
inner class Tags {
@Test
fun getTags() {
val notes1 = listOf(
createNote(user1.id, "1", listOf("a", "b")),
createNote(user1.id, "2"),
createNote(user1.id, "3", listOf("c", "a"))
)
val notes2 = listOf(
createNote(user2.id, "4", listOf("a"))
)
val user1Tags = notes1.flatMap { it.meta.tags }.toSet()
assertThat(noteRepo.getTags(user1.id))
.containsExactlyInAnyOrderElementsOf(user1Tags)
val user2Tags = notes2.flatMap { it.meta.tags }.toSet()
assertThat(noteRepo.getTags(user2.id))
.containsExactlyInAnyOrderElementsOf(user2Tags)
assertThat(noteRepo.getTags(1000))
.isEmpty()
}
}
@Nested
@DisplayName("update()")
inner class Update {
@Test
fun getTags() {
val note1 = createNote(user1.id, "1", listOf("a", "b"))
val newNote1 = Note(meta = note1.meta, markdown = "new", "new")
assertThat(noteRepo.update(user1.id, note1.uuid, newNote1))
.isNotNull
assertThat(noteRepo.find(user1.id, note1.uuid))
.isEqualToComparingOnlyGivenFields(newNote1, "meta", "markdown", "html")
val note2 = createNote(user1.id, "2")
val newNote2 = Note(meta = note1.meta.copy(tags = listOf("a")), markdown = "new", "new")
assertThat(noteRepo.update(user1.id, note2.uuid, newNote2))
.isNotNull
assertThat(noteRepo.find(user1.id, note2.uuid))
.isEqualToComparingOnlyGivenFields(newNote2, "meta", "markdown", "html")
}
}
}

View File

@ -0,0 +1,116 @@
package be.simplenotes.persistance.users
import be.simplenotes.domain.model.User
import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.persistance.DbMigrations
import be.simplenotes.persistance.persistanceModule
import be.simplenotes.shared.config.DataSourceConfig
import me.liuwj.ktorm.database.*
import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.*
import org.assertj.core.api.Assertions.assertThat
import org.flywaydb.core.Flyway
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.parallel.ResourceLock
import org.koin.dsl.koinApplication
import org.koin.dsl.module
import javax.sql.DataSource
@ResourceLock("h2")
internal class UserRepositoryImplTest {
// region setup
private val testModule = module {
single { dataSourceConfig() }
}
private val koinApp = koinApplication {
modules(persistanceModule, testModule)
}
private fun dataSourceConfig() = DataSourceConfig(
jdbcUrl = "jdbc:h2:mem:regular;DB_CLOSE_DELAY=-1;",
driverClassName = "org.h2.Driver",
username = "h2",
password = "",
maximumPoolSize = 2,
connectionTimeout = 3000
)
private val koin = koinApp.koin
@AfterAll
fun afterAll() = koinApp.close()
private val migration = koin.get<DbMigrations>()
private val dataSource = koin.get<DataSource>()
private val userRepo = koin.get<UserRepository>()
private val db = koin.get<Database>()
@BeforeEach
fun beforeEach() {
Flyway.configure()
.dataSource(dataSource)
.load()
.clean()
migration.migrate()
}
// endregion setup
@Test
fun `insert user`() {
val user = User("username", "test")
assertThat(userRepo.create(user)).isNotNull
assertThat(db.users.find { it.username eq user.username }).isNotNull
assertThat(db.users.toList()).hasSize(1)
assertThat(userRepo.create(user)).isNull()
}
@Nested
inner class Query {
@Test
fun `query existing user`() {
val user = User("username", "test")
userRepo.create(user)
val foundUserMaybe = userRepo.find(user.username)
assertThat(foundUserMaybe).isNotNull
val foundUser = foundUserMaybe!!
assertThat(foundUser).isEqualToIgnoringGivenFields(user, "id")
assertThat(userRepo.exists(user.username)).isTrue
assertThat(userRepo.find(foundUser.id)).isEqualTo(foundUser)
assertThat(userRepo.exists(foundUser.id)).isTrue
}
@Test
fun `query non existing user`() {
assertThat(userRepo.find("I don't exist")).isNull()
assertThat(userRepo.find(1)).isNull()
assertThat(userRepo.exists(1)).isFalse
assertThat(userRepo.exists("I don't exist")).isFalse
}
}
@Nested
inner class Delete {
@Test
fun `delete existing user`() {
val user = User("username", "test")
userRepo.create(user)
val foundUser = userRepo.find(user.username)!!
assertThat(userRepo.delete(foundUser.id)).isTrue
assertThat(db.users.toList()).isEmpty()
}
@Test
fun `delete non existing user`() {
assertThat(userRepo.delete(1)).isFalse
}
}
}

395
pom.xml
View File

@ -1,235 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>be.vandewalleh</groupId>
<artifactId>api</artifactId>
<version>0.0.1</version>
<name>api</name>
<groupId>be.simplenotes</groupId>
<artifactId>parent</artifactId>
<version>1.0-SNAPSHOT</version>
<modules>
<module>persistance</module>
<module>app</module>
<module>domain</module>
<module>shared</module>
</modules>
<packaging>pom</packaging>
<properties>
<!-- versions -->
<ktor_version>1.3.2</ktor_version>
<kotlin_version>1.3.70</kotlin_version>
<logback_version>1.2.1</logback_version>
<junit_version>5.6.2</junit_version>
<ktorm_version>3.0.0</ktorm_version>
<mariadb_version>2.6.0</mariadb_version>
<kodein_version>7.0.0</kodein_version>
<flyway_version>6.3.3</flyway_version>
<javajwt_version>3.10.2</javajwt_version>
<jbcrypt_version>0.4</jbcrypt_version>
<hoplite_version>1.2.2</hoplite_version>
<java.version>14</java.version>
<kotlin.version>1.4.0-rc</kotlin.version>
<junit.version>5.6.2</junit.version>
<kotlin.code.style>official</kotlin.code.style>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<kotlin.compiler.incremental>true</kotlin.compiler.incremental>
<kotlin.compiler.jvmTarget>12</kotlin.compiler.jvmTarget>
<main.class>be.vandewalleh.NotesApplicationKt</main.class>
<java.version>14</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<kotlin.compiler.jvmTarget>${java.version}</kotlin.compiler.jvmTarget>
</properties>
<repositories>
<repository>
<id>jcenter</id>
<url>https://jcenter.bintray.com</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
<repository>
<id>ktor</id>
<url>https://kotlin.bintray.com/ktor</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
<repository>
<id>korlibs</id>
<url>https://dl.bintray.com/korlibs/korlibs</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
<repository>
<id>jitpack</id>
<url>https://jitpack.io</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>com.soywiz.korlibs.korte</groupId>
<artifactId>korte-jvm</artifactId>
<version>1.10.14</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
<version>${kotlin_version}</version>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-server-netty</artifactId>
<version>${ktor_version}</version>
<groupId>org.koin</groupId>
<artifactId>koin-core</artifactId>
<version>2.1.6</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>${logback_version}</version>
<version>1.2.3</version>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-server-core</artifactId>
<version>${ktor_version}</version>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-jackson</artifactId>
<version>${ktor_version}</version>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-auth-jwt</artifactId>
<version>${ktor_version}</version>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-client-core</artifactId>
<version>${ktor_version}</version>
</dependency>
<dependency>
<groupId>org.kodein.di</groupId>
<artifactId>kodein-di-jvm</artifactId>
<version>${kodein_version}</version>
<groupId>io.arrow-kt</groupId>
<artifactId>arrow-core</artifactId>
<version>0.10.5</version>
</dependency>
<!-- region tests -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit_version}</version>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>${junit_version}</version>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>${mariadb_version}</version>
</dependency>
<dependency>
<groupId>me.liuwj.ktorm</groupId>
<artifactId>ktorm-core</artifactId>
<version>${ktorm_version}</version>
</dependency>
<dependency>
<groupId>me.liuwj.ktorm</groupId>
<artifactId>ktorm-support-mysql</artifactId>
<version>${ktorm_version}</version>
</dependency>
<dependency>
<groupId>me.liuwj.ktorm</groupId>
<artifactId>ktorm-jackson</artifactId>
<version>${ktorm_version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.11.1</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
<version>2.11.1</version>
</dependency>
<dependency>
<groupId>com.github.hekeki</groupId>
<artifactId>huckleberry</artifactId>
<version>0.0.2-beta</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<version>${flyway_version}</version>
</dependency>
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>${jbcrypt_version}</version>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>3.4.2</version>
</dependency>
<dependency>
<groupId>com.sksamuel.hoplite</groupId>
<artifactId>hoplite-yaml</artifactId>
<version>${hoplite_version}</version>
</dependency>
<dependency>
<groupId>am.ik.yavi</groupId>
<artifactId>yavi</artifactId>
<version>0.4.0</version>
</dependency>
<dependency>
<groupId>com.vladsch.flexmark</groupId>
<artifactId>flexmark-all</artifactId>
<version>0.62.2</version>
</dependency>
<dependency>
<groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
<artifactId>owasp-java-html-sanitizer</artifactId>
<version>20200615.1</version>
</dependency>
<!-- tests -->
<dependency>
<groupId>com.github.javafaker</groupId>
<artifactId>javafaker</artifactId>
<version>1.0.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-server-tests</artifactId>
<version>${ktor_version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mariadb</artifactId>
<version>1.14.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.amshove.kluent</groupId>
<artifactId>kluent</artifactId>
<version>1.61</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.skyscreamer</groupId>
<artifactId>jsonassert</artifactId>
<version>1.5.0</version>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test-junit</artifactId>
<version>${kotlin.version}</version>
<scope>test</scope>
</dependency>
<dependency>
@ -238,45 +75,58 @@
<version>1.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.koin</groupId>
<artifactId>koin-test</artifactId>
<version>2.1.6</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.natpryce</groupId>
<artifactId>hamkrest</artifactId>
<version>1.7.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.16.1</version>
<scope>test</scope>
</dependency>
<!-- endregion -->
</dependencies>
<build>
<sourceDirectory>${project.basedir}/src</sourceDirectory>
<testSourceDirectory>${project.basedir}/test</testSourceDirectory>
<resources>
<resource>
<directory>${project.basedir}/resources</directory>
</resource>
</resources>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.0.0</version>
<configuration>
<mainClass>${main.class}</mainClass>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M4</version>
<dependencies>
<dependency>
<groupId>org.apache.maven.surefire</groupId>
<artifactId>surefire-junit-platform</artifactId>
<version>3.0.0-M4</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>14</source>
<target>14</target>
</configuration>
</plugin>
<plugin>
<artifactId>kotlin-maven-plugin</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
<version>${kotlin_version}</version>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>process-sources</phase>
<goals>
<goal>compile</goal>
</goals>
@ -286,64 +136,59 @@
<goals>
<goal>test-compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/test</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<minimizeJar>true</minimizeJar>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${main.class}</mainClass>
</transformer>
</transformers>
<artifactSet>
<excludes>
<exclude>io.ktor:ktor-client-*</exclude>
</excludes>
</artifactSet>
<filters>
<filter>
<artifact>org.jetbrains.kotlin:kotlin-reflect</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>org.mariadb.jdbc:mariadb-java-client</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/maven/**</exclude>
<exclude>META-INF/proguard/**</exclude>
<exclude>META-INF/native-image/**</exclude>
<exclude>META-INF/*.kotlin_module</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
<configuration>
<args>
<arg>-Xno-param-assertions</arg>
<arg>-Xno-call-assertions</arg>
<arg>-Xno-receiver-assertions</arg>
</args>
</configuration>
</plugin>
</plugins>
</build>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk7</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-common</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<repositories>
<repository>
<id>jcenter</id>
<url>https://jcenter.bintray.com</url>
</repository>
<repository>
<id>arrow</id>
<url>https://dl.bintray.com/arrow-kt/arrow-kt/</url>
</repository>
</repositories>
</project>

View File

@ -1,24 +0,0 @@
database:
host: ${MYSQL_HOST:-localhost}
port: ${MYSQL_PORT:-3306}
name: ${MYSQL_DATABASE:-notes}
username: ${MYSQL_USER:-notes}
password: ${MYSQL_PASSWORD:-notes}
server:
host: ${HOST:-127.0.0.1}
port: ${PORT:-8081}
cors: ${CORS:-false}
jwt:
auth:
secret: ${JWT_SECRET:-uiqzRNiMYwbObn/Ps5xTasYVeu/63ZuI+1oB98Ez+lY=} # Can be generated with `openssl rand -base64 32`
validity: 24
unit: HOURS
refresh:
secret: ${JWT_REFRESH_SECRET=-wWchkx44YGig4Q5Z7b7+E/3ymGEGd6PS7UGedMul3bg=} # Can be generated with `openssl rand -base64 32`
validity: 15
unit: DAYS
cookies:
secure: ${SECURE_COOKIES:-false}

View File

@ -1,16 +0,0 @@
{% import "__macros__.html" as macros %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="description" content="new note"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{{ title }} - SimpleNotes</title>
{{ macros.stylesheet("styles.css") }}
</head>
<body class="bg-gray-900 text-white">
{% include "components/navbar.html" %}
{% block content %}default content{% endblock %}
</body>
</html>

View File

@ -1,5 +0,0 @@
{% macro stylesheet(path) %}
{% set path = styles(path) %}
<link rel="preload" href="{{ path }}" as="style">
<link rel="stylesheet" href="{{ path }}">
{% endmacro %}

View File

@ -1,8 +0,0 @@
{% set title = note.title %} {% extends "__base__.html" %} {% block content %}
<div class="container mx-auto p-4">
<h1 class="text-3xl underline mb-4">{{ note.title }}</h1>
<div id="note">{{ note.html | raw }}</div>
</div>
{% endblock %}

View File

@ -1,6 +0,0 @@
{% macro warning(title, warning) %}
<div class="bg-red-500 border border-red-400 text-red-200 px-4 py-3 mb-4 rounded relative" role="alert">
<strong class="font-bold">{{ title }}</strong>
<span class="block sm:inline">{{ warning }}</span>
</div>
{% endmacro %}

View File

@ -1,28 +0,0 @@
<!-- {id, label, type?, placeholder?, autocomplete?} -->
{% macro input(args) %}
<div class="mb-4">
<label
for="{{ args.id }}"
class="font-bold text-grey-darker block mb-2"
>{{ args.label }}</label>
<input
id="{{ args.id }}"
name="{{ args.id }}"
{% if args.type %}type="{{ args.type }}"{% endif %}
{% if args.autocomplete %}autocomplete="{{ args.autocomplete }}"{% endif %}
{% if args.placeholder %}placeholder="{{ args.placeholder }}"{% endif %}
class="shadow focus:shadow-outline block appearance-none w-full bg-gray-700 border-gray-500 hover:border-gray-500 px-2 py-2 rounded shadow"
/>
</div>
{% endmacro %}
{% macro submit(value) %}
<div class="flex items-center mt-6">
<button
type="submit"
class="w-full bg-teal-500 hover:bg-teal-400 text-white font-bold py-2 px-4 rounded"
>
{{ value }}
</button>
</div>
{% endmacro %}

View File

@ -1,21 +0,0 @@
<nav
class="nav bg-teal-700 shadow-md flex items-center justify-between px-4"
>
<a href="/" class="text-2xl text-gray-800 font-bold">SimpleNotes</a>
<ul>
{% if user %}
<li class="inline text-gray-800 ml-2 text-md font-semibold">
<a href="/notes">Notes</a>
</li>
<li class="inline text-gray-800 ml-2 text-md font-semibold bg-green-500 hover:bg-green-700 rounded px-4 py-2">
<form class="inline" action="/logout" method="post">
<button type="submit">Logout</button>
</form>
</li>
{% else %}
<li class="inline text-gray-800 pl-2 text-md font-semibold">
<a href="/login">Sign In</a>
</li>
{% endif %}
</ul>
</nav>

View File

@ -1,11 +0,0 @@
{% set title = status %}
{% extends "__base__.html" %}
{% block content %}
<div class="centered container mx-auto flex justify-center items-center">
<div class="bg-gray-800 md:w-1/3 w-full rounded-lg m-4 p-6 text-center">
<h1 class="text-3xl">Error</h1>
<div class="text-red-400">{{ status }}</div>
</div>
</div>
{% endblock %}

View File

@ -1,12 +0,0 @@
{% set title = "Notes" %}
{% extends "__base__.html" %}
{% block content %}
<div class="centered container mx-auto flex justify-center items-center">
<div class="bg-gray-800 md:w-1/3 w-full rounded-lg m-4 p-6 text-center">
<h1 class="text-3xl">SimpleNotes</h1>
<div class="text-teal-400">Welcome</div>
<div class="text-gray-200">TODO</div>
</div>
</div>
{% endblock %}

View File

@ -1,24 +0,0 @@
{% set title = "Notes" %}
{% extends "__base__.html" %}
{% block content %}
<div class="container mx-auto p-4">
<div class="flex justify-between">
<h1 class="text-2xl underline">Notes</h1>
<a
href="/notes/new"
class="inline text-gray-800 ml-2 text-md font-semibold bg-green-500 hover:bg-green-700 rounded px-4 py-2">New</a>
</div>
{% if notes.size() > 0 -%}
<ul>
{% for note in notes -%}
<li class="text-blue-200 hover:underline">
<a href="/notes/{{ note.uuid }}">{{ note.title }}</a>
</li>
{% endfor %}
</ul>
{% else %}
<span>No notes :c</span>
{% endif %}
</div>
{% endblock %}

View File

@ -1,40 +0,0 @@
{% set title = "Sign In" %}
{% extends "__base__.html" %}
{% block content %}
<div class="centered container mx-auto flex justify-center items-center">
<div class="w-full md:w-1/2 lg:w-1/3 m-4">
<h1 class="font-semibold text-lg mb-6 text-center">Sign In</h1>
<div
class="bg-gray-800 border-teal-500 p-8 border-t-8 bg-white mb-6 rounded-lg shadow-lg"
>
{%- if error %}
{% import "components/alert.html" as alerts %}
{{ alerts.warning("Error", error) }}
{% endif -%}
<form method="post">
{% import "components/forms.html" as forms %}
{{ forms.input({
"id": "username", "label": "Username",
"placeholder": "Your Username", "autocomplete": "username"
}) }}
{{ forms.input({
"id": "password", "label": "Password", "type": "password",
"placeholder": "Your Password", "autocomplete": "current-password"
}) }}
{{ forms.submit("Sign In") }}
</form>
</div>
<div class="text-center">
<p class="text-gray-200 text-sm">
Don't have an account?
<a href="/register" class="no-underline text-blue-500 font-bold">Create an Account</a>
</p>
</div>
</div>
</div>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More