Merge http4k
This commit is contained in:
parent
b41b2103f0
commit
24aabd494e
18
.env.dist
18
.env.dist
@ -1,11 +1,9 @@
|
|||||||
MYSQL_ROOT_PASSWORD=
|
## can be generated with `openssl rand -base64 32`
|
||||||
MYSQL_HOST=db
|
|
||||||
MYSQL_DATABASE=
|
|
||||||
MYSQL_USER=
|
|
||||||
MYSQL_PASSWORD=
|
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
JWT_REFRESH_SECRET=
|
#
|
||||||
CORS=false
|
## can be generated with `openssl rand -base64 32`
|
||||||
PORT=8081
|
MYSQL_ROOT_PASSWORD=
|
||||||
HOST=
|
#
|
||||||
SECURE_COOKIES=
|
## can be generated with `openssl rand -base64 32`
|
||||||
|
MYSQL_PASSWORD=
|
||||||
|
PASSWORD=${MYSQL_PASSWORD}
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@ -124,8 +124,9 @@ sw.*
|
|||||||
data/
|
data/
|
||||||
letsencrypt/
|
letsencrypt/
|
||||||
|
|
||||||
# resources
|
# generated resources
|
||||||
|
app/src/main/resources/css-manifest.json
|
||||||
|
app/src/main/resources/static/styles*
|
||||||
|
|
||||||
resources/css-manifest.json
|
# h2 db
|
||||||
resources/docs/index.html
|
*.db
|
||||||
resources/static/*.css
|
|
||||||
|
|||||||
110
.mvn/wrapper/MavenWrapperDownloader.java
vendored
110
.mvn/wrapper/MavenWrapperDownloader.java
vendored
@ -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();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
1
.mvn/wrapper/maven-wrapper.properties
vendored
1
.mvn/wrapper/maven-wrapper.properties
vendored
@ -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
|
|
||||||
@ -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
43
Dockerfile
Normal 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"]
|
||||||
@ -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"]
|
|
||||||
@ -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) -->
|
|
||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
]
|
|
||||||
@ -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
123
app/pom.xml
Normal 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>
|
||||||
49
app/src/main/kotlin/Config.kt
Normal file
49
app/src/main/kotlin/Config.kt
Normal 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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
32
app/src/main/kotlin/Server.kt
Normal file
32
app/src/main/kotlin/Server.kt
Normal 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)
|
||||||
|
}
|
||||||
92
app/src/main/kotlin/SimpleNotes.kt
Normal file
92
app/src/main/kotlin/SimpleNotes.kt
Normal 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 }
|
||||||
|
}
|
||||||
13
app/src/main/kotlin/controllers/BaseController.kt
Normal file
13
app/src/main/kotlin/controllers/BaseController.kt
Normal 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))
|
||||||
|
}
|
||||||
96
app/src/main/kotlin/controllers/NoteController.kt
Normal file
96
app/src/main/kotlin/controllers/NoteController.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
112
app/src/main/kotlin/controllers/UserController.kt
Normal file
112
app/src/main/kotlin/controllers/UserController.kt
Normal 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")
|
||||||
|
|
||||||
|
}
|
||||||
13
app/src/main/kotlin/extensions/Http4kExtensions.kt
Normal file
13
app/src/main/kotlin/extensions/Http4kExtensions.kt
Normal 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
|
||||||
45
app/src/main/kotlin/filters/AuthFilter.kt
Normal file
45
app/src/main/kotlin/filters/AuthFilter.kt
Normal 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()
|
||||||
26
app/src/main/kotlin/filters/ErrorFilter.kt
Normal file
26
app/src/main/kotlin/filters/ErrorFilter.kt
Normal 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/src/main/kotlin/filters/ImmutableFilter.kt
Normal file
19
app/src/main/kotlin/filters/ImmutableFilter.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/src/main/kotlin/filters/SecurityFilter.kt
Normal file
20
app/src/main/kotlin/filters/SecurityFilter.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/src/main/kotlin/routes/Router.kt
Normal file
72
app/src/main/kotlin/routes/Router.kt
Normal 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
|
||||||
24
app/src/main/kotlin/utils/StaticFilesResolver.kt
Normal file
24
app/src/main/kotlin/utils/StaticFilesResolver.kt
Normal 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]
|
||||||
|
}
|
||||||
18
app/src/main/kotlin/views/BaseView.kt
Normal file
18
app/src/main/kotlin/views/BaseView.kt
Normal 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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
131
app/src/main/kotlin/views/NoteView.kt
Normal file
131
app/src/main/kotlin/views/NoteView.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/src/main/kotlin/views/UserView.kt
Normal file
73
app/src/main/kotlin/views/UserView.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/src/main/kotlin/views/View.kt
Normal file
36
app/src/main/kotlin/views/View.kt
Normal 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/src/main/kotlin/views/components/Alerts.kt
Normal file
19
app/src/main/kotlin/views/components/Alerts.kt
Normal 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
|
||||||
|
}
|
||||||
37
app/src/main/kotlin/views/components/Forms.kt
Normal file
37
app/src/main/kotlin/views/components/Forms.kt
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/src/main/kotlin/views/components/Navbar.kt
Normal file
29
app/src/main/kotlin/views/components/Navbar.kt
Normal 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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/src/main/resources/application.properties
Normal file
12
app/src/main/resources/application.properties
Normal 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
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
1
app/src/test/kotlin/Empty.kt
Normal file
1
app/src/test/kotlin/Empty.kt
Normal file
@ -0,0 +1 @@
|
|||||||
|
package be.simplenotes.app
|
||||||
94
app/src/test/kotlin/filters/AuthFilterTest.kt
Normal file
94
app/src/test/kotlin/filters/AuthFilterTest.kt
Normal 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
15
css/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ module.exports = {
|
|||||||
require('postcss-hash')({
|
require('postcss-hash')({
|
||||||
algorithm: 'sha256',
|
algorithm: 'sha256',
|
||||||
trim: 20,
|
trim: 20,
|
||||||
manifest: 'resources/css-manifest.json'
|
manifest: process.env.MANIFEST
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -10,15 +10,54 @@
|
|||||||
min-height: calc(100vh - 64px);
|
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 {
|
#note a {
|
||||||
@apply text-blue-700 underline; }
|
@apply text-blue-700 underline;
|
||||||
|
}
|
||||||
|
|
||||||
#note p {
|
#note p {
|
||||||
@apply my-4; }
|
@apply my-4;
|
||||||
|
}
|
||||||
|
|
||||||
#note blockquote,
|
#note blockquote,
|
||||||
#note figure {
|
#note figure {
|
||||||
@apply my-4 mx-10; }
|
@apply my-4 mx-10;
|
||||||
|
}
|
||||||
|
|
||||||
#note hr {
|
#note hr {
|
||||||
@apply border; }
|
@apply border; }
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
purge: {
|
purge: {
|
||||||
enabled: true,
|
|
||||||
content: [
|
content: [
|
||||||
'resources/templates/**/*.html'
|
'../app/src/main/kotlin/views/**/*.kt'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
1851
css/yarn.lock
Normal file
1851
css/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
8
deploy-docker-hub.sh
Executable file
8
deploy-docker-hub.sh
Executable 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
|
||||||
@ -1,7 +0,0 @@
|
|||||||
version: '2.2'
|
|
||||||
|
|
||||||
services:
|
|
||||||
|
|
||||||
db:
|
|
||||||
ports:
|
|
||||||
- 127.0.0.1:3306:3306
|
|
||||||
@ -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:
|
|
||||||
@ -4,19 +4,47 @@ services:
|
|||||||
|
|
||||||
db:
|
db:
|
||||||
image: mariadb
|
image: mariadb
|
||||||
container_name: notes-mariadb
|
container_name: simplenotes-mariadb
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- PUID=1000
|
- PUID=1000
|
||||||
- PGID=1000
|
- PGID=1000
|
||||||
- TZ=Europe/Brussels
|
- TZ=Europe/Brussels
|
||||||
|
- MYSQL_DATABASE=simplenotes
|
||||||
|
- MYSQL_USER=simplenotes
|
||||||
|
# .env:
|
||||||
|
# - MYSQL_ROOT_PASSWORD
|
||||||
|
# - MYSQL_PASSWORD
|
||||||
volumes:
|
volumes:
|
||||||
- notes-db-volume:/var/lib/mysql
|
- notes-db-volume:/var/lib/mysql
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:3306:3306
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 10
|
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:
|
volumes:
|
||||||
notes-db-volume:
|
notes-db-volume:
|
||||||
|
|||||||
57
domain/pom.xml
Normal file
57
domain/pom.xml
Normal 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>
|
||||||
26
domain/src/main/kotlin/DomainModule.kt
Normal file
26
domain/src/main/kotlin/DomainModule.kt
Normal 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() }
|
||||||
|
}
|
||||||
30
domain/src/main/kotlin/model/Note.kt
Normal file
30
domain/src/main/kotlin/model/Note.kt
Normal 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,
|
||||||
|
)
|
||||||
4
domain/src/main/kotlin/model/User.kt
Normal file
4
domain/src/main/kotlin/model/User.kt
Normal 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)
|
||||||
18
domain/src/main/kotlin/security/HtmlSanitizer.kt
Normal file
18
domain/src/main/kotlin/security/HtmlSanitizer.kt
Normal 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)!!
|
||||||
|
}
|
||||||
21
domain/src/main/kotlin/security/JwtPayload.kt
Normal file
21
domain/src/main/kotlin/security/JwtPayload.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,14 @@
|
|||||||
package be.vandewalleh.features
|
package be.simplenotes.domain.security
|
||||||
|
|
||||||
import org.mindrot.jbcrypt.BCrypt
|
import org.mindrot.jbcrypt.BCrypt
|
||||||
|
|
||||||
interface PasswordHash {
|
internal interface PasswordHash {
|
||||||
fun crypt(password: String): String
|
fun crypt(password: String): String
|
||||||
fun verify(password: String, hashedPassword: String): Boolean
|
fun verify(password: String, hashedPassword: String): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
class BcryptPasswordHash : PasswordHash {
|
internal class BcryptPasswordHash(test: Boolean = false) : PasswordHash {
|
||||||
override fun crypt(password: String) = BCrypt.hashpw(password, BCrypt.gensalt())!!
|
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)
|
override fun verify(password: String, hashedPassword: String) = BCrypt.checkpw(password, hashedPassword)
|
||||||
}
|
}
|
||||||
22
domain/src/main/kotlin/security/SimpleJwt.kt
Normal file
22
domain/src/main/kotlin/security/SimpleJwt.kt
Normal 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)
|
||||||
|
}
|
||||||
45
domain/src/main/kotlin/usecases/NoteService.kt
Normal file
45
domain/src/main/kotlin/usecases/NoteService.kt
Normal 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>)
|
||||||
|
|
||||||
9
domain/src/main/kotlin/usecases/UserService.kt
Normal file
9
domain/src/main/kotlin/usecases/UserService.kt
Normal 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
|
||||||
25
domain/src/main/kotlin/usecases/login/LoginUseCaseImpl.kt
Normal file
25
domain/src/main/kotlin/usecases/login/LoginUseCaseImpl.kt
Normal 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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
17
domain/src/main/kotlin/usecases/login/LoginUsecase.kt
Normal file
17
domain/src/main/kotlin/usecases/login/LoginUsecase.kt
Normal 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>
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
16
domain/src/main/kotlin/usecases/register/RegisterUsecase.kt
Normal file
16
domain/src/main/kotlin/usecases/register/RegisterUsecase.kt
Normal 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>
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
36
domain/src/main/kotlin/validation/NoteValidations.kt
Normal file
36
domain/src/main/kotlin/validation/NoteValidations.kt
Normal 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
38
domain/src/main/kotlin/validation/UserValidations.kt
Normal file
38
domain/src/main/kotlin/validation/UserValidations.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
5
domain/src/test/kotlin/Empty.kt
Normal file
5
domain/src/test/kotlin/Empty.kt
Normal 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)
|
||||||
|
*/
|
||||||
50
domain/src/test/kotlin/security/JwtPayloadExtractorTest.kt
Normal file
50
domain/src/test/kotlin/security/JwtPayloadExtractorTest.kt
Normal 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")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
77
domain/src/test/kotlin/validation/UserValidationsTest.kt
Normal file
77
domain/src/test/kotlin/validation/UserValidationsTest.kt
Normal 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
286
mvnw
vendored
@ -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
161
mvnw.cmd
vendored
@ -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%
|
|
||||||
15
package.json
15
package.json
@ -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
62
persistance/pom.xml
Normal 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>
|
||||||
13
persistance/src/main/kotlin/DbMigrationsImpl.kt
Normal file
13
persistance/src/main/kotlin/DbMigrationsImpl.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
36
persistance/src/main/kotlin/PersistanceModule.kt
Normal file
36
persistance/src/main/kotlin/PersistanceModule.kt
Normal 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>()) }
|
||||||
|
}
|
||||||
25
persistance/src/main/kotlin/extensions/KtormExtensions.kt
Normal file
25
persistance/src/main/kotlin/extensions/KtormExtensions.kt
Normal 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())
|
||||||
123
persistance/src/main/kotlin/notes/NoteRepositoryImpl.kt
Normal file
123
persistance/src/main/kotlin/notes/NoteRepositoryImpl.kt
Normal 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 }
|
||||||
|
}
|
||||||
58
persistance/src/main/kotlin/notes/Notes.kt
Normal file
58
persistance/src/main/kotlin/notes/Notes.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
27
persistance/src/main/kotlin/notes/Tags.kt
Normal file
27
persistance/src/main/kotlin/notes/Tags.kt
Normal 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
|
||||||
|
}
|
||||||
26
persistance/src/main/kotlin/users/UserRepositoryImpl.kt
Normal file
26
persistance/src/main/kotlin/users/UserRepositoryImpl.kt
Normal 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 }
|
||||||
|
}
|
||||||
45
persistance/src/main/kotlin/users/Users.kt
Normal file
45
persistance/src/main/kotlin/users/Users.kt
Normal 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)
|
||||||
@ -5,8 +5,7 @@ create table Users
|
|||||||
password varchar(255) not null,
|
password varchar(255) not null,
|
||||||
|
|
||||||
constraint username unique (username)
|
constraint username unique (username)
|
||||||
) character set 'utf8mb4'
|
);
|
||||||
collate 'utf8mb4_general_ci';
|
|
||||||
|
|
||||||
create table Notes
|
create table Notes
|
||||||
(
|
(
|
||||||
@ -19,8 +18,7 @@ create table Notes
|
|||||||
|
|
||||||
constraint Notes_fk_user foreign key (user_id) references Users (id) on delete cascade
|
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);
|
create index user_id on Notes (user_id);
|
||||||
|
|
||||||
@ -30,7 +28,6 @@ create table Tags
|
|||||||
name varchar(50) not null,
|
name varchar(50) not null,
|
||||||
note_uuid binary(16) not null,
|
note_uuid binary(16) not null,
|
||||||
constraint Tags_fk_note foreign key (note_uuid) references Notes (uuid) on delete cascade
|
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);
|
create index note_uuid on Tags (note_uuid);
|
||||||
@ -2,19 +2,15 @@
|
|||||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
<withJansi>true</withJansi>
|
<withJansi>true</withJansi>
|
||||||
<encoder>
|
<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>
|
</pattern>
|
||||||
</encoder>
|
</encoder>
|
||||||
</appender>
|
</appender>
|
||||||
<root level="INFO">
|
<root level="TRACE">
|
||||||
<appender-ref ref="STDOUT"/>
|
<appender-ref ref="STDOUT"/>
|
||||||
</root>
|
</root>
|
||||||
|
<logger name="org.eclipse.jetty" level="WARN"/>
|
||||||
<logger name="me.liuwj.ktorm.database" level="INFO"/>
|
<logger name="me.liuwj.ktorm.database" level="INFO"/>
|
||||||
<logger name="com.zaxxer.hikari" 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.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>
|
</configuration>
|
||||||
1
persistance/src/test/kotlin/Empty.kt
Normal file
1
persistance/src/test/kotlin/Empty.kt
Normal file
@ -0,0 +1 @@
|
|||||||
|
package be.simplenotes.persistance
|
||||||
272
persistance/src/test/kotlin/notes/NoteRepositoryImplTest.kt
Normal file
272
persistance/src/test/kotlin/notes/NoteRepositoryImplTest.kt
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
persistance/src/test/kotlin/users/UserRepositoryImplTest.kt
Normal file
116
persistance/src/test/kotlin/users/UserRepositoryImplTest.kt
Normal 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
395
pom.xml
@ -1,235 +1,72 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<project>
|
||||||
<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">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
<groupId>be.vandewalleh</groupId>
|
|
||||||
<artifactId>api</artifactId>
|
<groupId>be.simplenotes</groupId>
|
||||||
<version>0.0.1</version>
|
<artifactId>parent</artifactId>
|
||||||
<name>api</name>
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
|
||||||
|
<modules>
|
||||||
|
<module>persistance</module>
|
||||||
|
<module>app</module>
|
||||||
|
<module>domain</module>
|
||||||
|
<module>shared</module>
|
||||||
|
</modules>
|
||||||
|
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<!-- versions -->
|
<java.version>14</java.version>
|
||||||
<ktor_version>1.3.2</ktor_version>
|
<kotlin.version>1.4.0-rc</kotlin.version>
|
||||||
<kotlin_version>1.3.70</kotlin_version>
|
<junit.version>5.6.2</junit.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>
|
|
||||||
|
|
||||||
<kotlin.code.style>official</kotlin.code.style>
|
<kotlin.code.style>official</kotlin.code.style>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
|
||||||
<kotlin.compiler.incremental>true</kotlin.compiler.incremental>
|
<kotlin.compiler.incremental>true</kotlin.compiler.incremental>
|
||||||
<kotlin.compiler.jvmTarget>12</kotlin.compiler.jvmTarget>
|
|
||||||
<main.class>be.vandewalleh.NotesApplicationKt</main.class>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<java.version>14</java.version>
|
|
||||||
<maven.compiler.source>${java.version}</maven.compiler.source>
|
<maven.compiler.source>${java.version}</maven.compiler.source>
|
||||||
<maven.compiler.target>${java.version}</maven.compiler.target>
|
<maven.compiler.target>${java.version}</maven.compiler.target>
|
||||||
|
<kotlin.compiler.jvmTarget>${java.version}</kotlin.compiler.jvmTarget>
|
||||||
</properties>
|
</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>
|
<dependencies>
|
||||||
<dependency>
|
|
||||||
<groupId>com.soywiz.korlibs.korte</groupId>
|
|
||||||
<artifactId>korte-jvm</artifactId>
|
|
||||||
<version>1.10.14</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.jetbrains.kotlin</groupId>
|
<groupId>org.jetbrains.kotlin</groupId>
|
||||||
<artifactId>kotlin-stdlib-jdk8</artifactId>
|
<artifactId>kotlin-stdlib-jdk8</artifactId>
|
||||||
<version>${kotlin_version}</version>
|
<version>${kotlin.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.ktor</groupId>
|
<groupId>org.koin</groupId>
|
||||||
<artifactId>ktor-server-netty</artifactId>
|
<artifactId>koin-core</artifactId>
|
||||||
<version>${ktor_version}</version>
|
<version>2.1.6</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>ch.qos.logback</groupId>
|
<groupId>ch.qos.logback</groupId>
|
||||||
<artifactId>logback-classic</artifactId>
|
<artifactId>logback-classic</artifactId>
|
||||||
<version>${logback_version}</version>
|
<version>1.2.3</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.ktor</groupId>
|
<groupId>io.arrow-kt</groupId>
|
||||||
<artifactId>ktor-server-core</artifactId>
|
<artifactId>arrow-core</artifactId>
|
||||||
<version>${ktor_version}</version>
|
<version>0.10.5</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>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- region tests -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.junit.jupiter</groupId>
|
<groupId>org.junit.jupiter</groupId>
|
||||||
<artifactId>junit-jupiter</artifactId>
|
<artifactId>junit-jupiter</artifactId>
|
||||||
<version>${junit_version}</version>
|
<version>${junit.version}</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.junit.jupiter</groupId>
|
<groupId>org.junit.jupiter</groupId>
|
||||||
<artifactId>junit-jupiter-params</artifactId>
|
<artifactId>junit-jupiter-params</artifactId>
|
||||||
<version>${junit_version}</version>
|
<version>${junit.version}</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.mariadb.jdbc</groupId>
|
<groupId>org.jetbrains.kotlin</groupId>
|
||||||
<artifactId>mariadb-java-client</artifactId>
|
<artifactId>kotlin-test-junit</artifactId>
|
||||||
<version>${mariadb_version}</version>
|
<version>${kotlin.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>
|
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
@ -238,45 +75,58 @@
|
|||||||
<version>1.10.0</version>
|
<version>1.10.0</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</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>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<sourceDirectory>${project.basedir}/src</sourceDirectory>
|
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
|
||||||
<testSourceDirectory>${project.basedir}/test</testSourceDirectory>
|
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
|
||||||
<resources>
|
|
||||||
<resource>
|
|
||||||
<directory>${project.basedir}/resources</directory>
|
|
||||||
</resource>
|
|
||||||
</resources>
|
|
||||||
<plugins>
|
<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>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-surefire-plugin</artifactId>
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
<version>3.0.0-M4</version>
|
<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>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-compiler-plugin</artifactId>
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
<version>3.8.1</version>
|
<version>3.8.1</version>
|
||||||
<configuration>
|
|
||||||
<source>14</source>
|
|
||||||
<target>14</target>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<artifactId>kotlin-maven-plugin</artifactId>
|
<artifactId>kotlin-maven-plugin</artifactId>
|
||||||
<groupId>org.jetbrains.kotlin</groupId>
|
<groupId>org.jetbrains.kotlin</groupId>
|
||||||
<version>${kotlin_version}</version>
|
<version>${kotlin.version}</version>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
<id>compile</id>
|
<id>compile</id>
|
||||||
|
<phase>process-sources</phase>
|
||||||
<goals>
|
<goals>
|
||||||
<goal>compile</goal>
|
<goal>compile</goal>
|
||||||
</goals>
|
</goals>
|
||||||
@ -286,64 +136,59 @@
|
|||||||
<goals>
|
<goals>
|
||||||
<goal>test-compile</goal>
|
<goal>test-compile</goal>
|
||||||
</goals>
|
</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>
|
</execution>
|
||||||
</executions>
|
</executions>
|
||||||
|
<configuration>
|
||||||
|
<args>
|
||||||
|
<arg>-Xno-param-assertions</arg>
|
||||||
|
<arg>-Xno-call-assertions</arg>
|
||||||
|
<arg>-Xno-receiver-assertions</arg>
|
||||||
|
</args>
|
||||||
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</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>
|
</project>
|
||||||
|
|||||||
@ -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}
|
|
||||||
@ -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>
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
{% macro stylesheet(path) %}
|
|
||||||
{% set path = styles(path) %}
|
|
||||||
<link rel="preload" href="{{ path }}" as="style">
|
|
||||||
<link rel="stylesheet" href="{{ path }}">
|
|
||||||
{% endmacro %}
|
|
||||||
@ -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 %}
|
|
||||||
@ -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 %}
|
|
||||||
@ -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 %}
|
|
||||||
@ -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>
|
|
||||||
@ -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 %}
|
|
||||||
@ -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 %}
|
|
||||||
@ -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 %}
|
|
||||||
@ -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
Loading…
x
Reference in New Issue
Block a user