92 Commits

Author SHA1 Message Date
hubert dd08763161 We don't need that much RAM 2020-09-30 18:57:46 +02:00
hubert e0b1514965 Unload bootstraping modules after startup 2020-09-30 18:56:52 +02:00
hubert 69c91ec86a Proper shutdown 2020-09-30 00:18:09 +02:00
hubert 1bc45461c3 Small refactor 2020-09-30 00:10:37 +02:00
hubert 0dfb2a7e03 Add zip export 2020-09-28 19:21:53 +02:00
hubert a7c8e63b11 Add radio input in order to select the export format 2020-09-28 18:29:07 +02:00
hubert ad97ba029e Fix small bug 2020-09-02 22:13:08 +02:00
hubert 31f538c7f5 Add python cli 2020-09-02 21:45:17 +02:00
hubert c7cf71441f Add API 2020-09-02 21:41:57 +02:00
hubert b015f3a97e Add instructions 2020-08-25 08:18:34 +02:00
hubert b8e9d4e96e Fix a test 2020-08-25 07:12:58 +02:00
hubert c5f9a1d6e0 Add possibility to share notes 2020-08-25 07:04:29 +02:00
hubert 1cb6c731d8 Css 2020-08-24 19:16:21 +02:00
hubert f6e33ed3b4 Markdown improvements 2020-08-24 18:50:54 +02:00
hubert 815770303c Add caddy config 2020-08-24 00:53:58 +02:00
hubert d6ab257473 Set nocache for templates 2020-08-24 00:53:00 +02:00
hubert cbc3a017e8 More UI improvements 2020-08-23 01:10:06 +02:00
hubert d70663b898 UI improvements 2020-08-23 00:38:20 +02:00
hubert 4ffa565626 Fix cookie invalidation when deleting account 2020-08-22 23:32:19 +02:00
hubert ea732325e5 Css + fonts.. 2020-08-22 23:17:57 +02:00
hubert f73a9d0b96 Html + css improvements 2020-08-22 18:57:33 +02:00
hubert 5573dd45d6 Reduce cookie size 2020-08-22 17:46:32 +02:00
hubert eeae982a71 Fix linting warnings 2020-08-22 05:12:09 +02:00
hubert bad5322abd Seo 2020-08-22 05:12:09 +02:00
hubert 39fe7a5ab6 Add LICENSE 2020-08-22 03:00:13 +00:00
hubert c800d22ccf Accessibility stuff 2020-08-22 04:29:41 +02:00
hubert 2ac02688ab More buttons 2020-08-22 03:23:24 +02:00
hubert 7d833e48e1 css.. 2020-08-22 03:10:04 +02:00
hubert 97ea331c51 Landing page + css.. 2020-08-22 02:46:54 +02:00
hubert 4c4ca2dd98 Navbar css 2020-08-22 00:14:10 +02:00
hubert 204f2fb002 Better code css 2020-08-21 23:40:10 +02:00
hubert 87a6fdc94a Change code bg color 2020-08-21 22:47:25 +02:00
hubert fa21c85a13 Add highlight js 2020-08-21 22:20:36 +02:00
hubert c367d5b613 Add time ago 2020-08-21 20:59:49 +02:00
hubert 2c967ebd8c Fix a bug where no tags would return an empty tag while searching 2020-08-21 20:38:59 +02:00
hubert 381d935875 add clear search button 2020-08-21 20:21:47 +02:00
hubert d575773f73 Update dependencies + pin mariadb version 2020-08-21 19:55:02 +02:00
hubert 36600bb1f4 Better error handling 2020-08-21 19:31:24 +02:00
hubert b27fd29230 Index md instead of html 2020-08-21 18:03:33 +02:00
hubert c02f7c039a Keep search query on reload 2020-08-21 17:44:43 +02:00
hubert 8ba89d3e05 Search now apply to all fields by default 2020-08-21 17:04:14 +02:00
hubert 372652d332 Improve dsl 2020-08-21 16:28:03 +02:00
hubert f12947acbd Create lucene dsl 2020-08-21 16:02:10 +02:00
hubert 7305fb47c7 Merge branch 'lucene-search' 2020-08-19 19:32:37 +02:00
hubert a440199006 Fix unsupported method on jdk backend 2020-08-19 19:31:56 +02:00
hubert 1432fbb395 Update docker + pom 2020-08-19 19:17:06 +02:00
hubert 08c804ccb5 Fix circular dependency 2020-08-19 19:02:15 +02:00
hubert 68109f8666 Drop indexes + view 2020-08-19 18:47:19 +02:00
hubert 315a01ea18 Add search terms parser + tests 2020-08-19 18:21:52 +02:00
hubert 12619f6550 Index all notes at start 2020-08-19 16:45:55 +02:00
hubert ab3766b8b8 Add searcher to note service 2020-08-19 05:20:17 +02:00
hubert 3861fb6b97 Add search module 2020-08-19 05:08:27 +02:00
hubert 88b6eb56ae Merge branch 'export' 2020-08-19 02:37:29 +02:00
hubert 8ccd7f6058 Show deleted notes count 2020-08-19 00:16:00 +02:00
hubert a98d6e8e64 Change docker tag 2020-08-18 19:52:04 +02:00
hubert ab7cfd8147 Merge branch 'trash' 2020-08-18 19:51:20 +02:00
hubert 5d9ca85b22 Trash feature done 2020-08-18 19:51:13 +02:00
hubert 15de81394c Persistance: Note can now be put in the trash 2020-08-17 18:51:33 +02:00
hubert 4e2fe463e0 Merge branch 'list-by-tags' 2020-08-17 17:21:52 +02:00
hubert 0e72547e95 tag class style improvements 2020-08-17 17:21:11 +02:00
hubert 845ca2acb8 Click on tag from a single note 2020-08-17 17:18:05 +02:00
hubert 25e29afcbb Validate tags with regex 2020-08-17 17:15:14 +02:00
hubert 5295e32d86 Find notes by tags in notes list 2020-08-17 17:09:48 +02:00
hubert 8021814c31 Find notes by tags 2020-08-17 16:44:16 +02:00
hubert b5beca8661 Update kotlin version 2020-08-17 16:01:29 +02:00
hubert 56e742e39f Improve sql 2020-08-17 16:01:15 +02:00
hubert 48897c8b90 Remove docker compose port binding 2020-08-16 19:24:03 +02:00
hubert 9d9ec013f5 Keep details open in /settings if refresh 2020-08-14 23:03:33 +02:00
hubert 01ba7cbd7d Fix redundant padding 2020-08-14 22:29:39 +02:00
hubert 4984e488ae Add /settings link 2020-08-14 21:24:50 +02:00
hubert 662d6c706b Accounts can now be deleted 2020-08-14 21:15:28 +02:00
hubert 00dafe1da9 Oops 2020-08-14 17:26:19 +02:00
hubert a9bbfcf82c Moved css files 2020-08-14 17:23:38 +02:00
hubert 4c38512038 Split postcss files 2020-08-14 17:19:23 +02:00
hubert fb49d45677 More buttons 2020-08-14 16:12:17 +02:00
hubert 90f6709885 Use btn css class 2020-08-14 15:56:41 +02:00
hubert b90df61020 Lint 2020-08-14 15:35:45 +02:00
hubert 1b79635ffa Persists login cookie between browser restarts 2020-08-14 15:29:47 +02:00
hubert 934820274b Leaner docker image 2020-08-14 14:56:34 +02:00
hubert 24ac5cf4fc Add tasklist markdown extension 2020-08-14 02:32:05 +02:00
hubert 29e445ff41 Add Referrer-Policy 2020-08-13 23:58:54 +02:00
hubert e65a4e10d6 Fix mariadb utf8 encoding.. 2020-08-13 20:39:29 +02:00
hubert 24aabd494e Merge http4k 2020-08-13 19:39:41 +02:00
hubert b41b2103f0 That too 2020-07-19 01:54:20 +02:00
hubert a11450cbcf Simplify form handling 2020-07-19 01:53:18 +02:00
hubert d419b4c72a Fix a bug for secure cookies 2020-07-18 20:52:33 +02:00
hubert 9216696b1a Secure cookies 2020-07-18 20:41:42 +02:00
hubert cdfe1d14ef Rename templates to get syntax highlighting 2020-07-18 17:21:52 +02:00
hubert fda355a690 Handle 404 for notes 2020-07-18 17:05:03 +02:00
hubert fc883373d0 Fix password input type 2020-07-18 15:54:22 +02:00
hubert 50020c2f91 Small fixes 2020-07-18 15:42:54 +02:00
hubert 44b463d9d5 Remove nuxt + 100 other things.. 2020-07-18 15:02:09 +02:00
232 changed files with 7889 additions and 13528 deletions
View File
+9 -8
View File
@@ -1,9 +1,10 @@
MYSQL_ROOT_PASSWORD=
MYSQL_HOST=db
MYSQL_DATABASE=
MYSQL_USER=
MYSQL_PASSWORD=
## can be generated with `openssl rand -base64 32`
JWT_SECRET=
JWT_REFRESH_SECRET=
CORS=false
PORT=8081
#
## can be generated with `openssl rand -base64 32`
MYSQL_ROOT_PASSWORD=
#
## can be generated with `openssl rand -base64 32`
MYSQL_PASSWORD=
# password should be the same as mysql_password
PASSWORD=
+13
View File
@@ -123,3 +123,16 @@ sw.*
# Certificates
data/
letsencrypt/
# generated resources
app/src/main/resources/css-manifest.json
app/src/main/resources/static/styles*
# h2 db
*.db
# lucene index
.lucene/
# python
__pycache__
-104
View File
@@ -1,104 +0,0 @@
(security) {
header * {
-Server
-Date
Strict-Transport-Security "max-age=31536000; includeSubDomains"
Feature-Policy "geolocation none; midi none; notifications none; push none; sync-xhr none; microphone none; camera none; magnetometer none; gyroscope none; speaker self; vibrate none; fullscreen self; payment none"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
X-XSS-Protection "1; mode=block"
Referrer-Policy "no-referrer-when-downgrade"
}
}
(common) {
@ignore {
path *.php
}
respond @ignore "no" 404
try_files {path} {path}/ {path}/index.html
encode gzip
}
(nuxt) {
@nuxt {
path /_nuxt/*
}
header @nuxt Cache-Control "public, max-age=31536000" # 1 year
}
simplenotes.be {
import security
import nuxt
import common
@404 {
expression {http.error.status_code} == 404
}
handle_errors {
rewrite @404 /404.html
file_server
import security
}
route /* {
file_server
}
route /api/* {
uri strip_prefix /api
reverse_proxy http://localhost:8081
}
header Content-Security-Policy "default-src 'self' 'unsafe-inline';"
root * /var/www/simplenotes.be
log {
output file /var/log/www/simplenotes.be.json
format json
}
}
www.simplenotes.be {
redir * https://simplenotes.be{path}
}
docs.simplenotes.be {
import security
import common
file_server
root * /var/www/docs.simplenotes.be
log {
output file /var/log/www/docs.simplenotes.be.json
format json
}
header Content-Security-Policy "default-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;"
}
portfolio.simplenotes.be {
import security
import common
import nuxt
file_server
root * /var/www/portfolio.simplenotes.be
log {
output file /var/log/www/portfolio.simplenotes.be.json
format json
}
# header Content-Security-Policy "default-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;"
}
+47
View File
@@ -0,0 +1,47 @@
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
COPY search/pom.xml search/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
COPY search/src search/src
RUN mvn -Dstyle.color=always package
FROM openjdk:14-alpine as jdkbuilder
RUN apk add --no-cache binutils
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 --no-header-files --no-man-pages --strip-debug --compress=2
RUN strip -p --strip-unneeded /myjdk/lib/server/libjvm.so
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", "-Xms64m", "-Xmx256m", "-XX:+UseG1GC", "-XX:MaxGCPauseMillis=100", "-XX:+UseStringDeduplication", "-jar", "app.jar"]
-28
View File
@@ -1,28 +0,0 @@
FROM maven:3.6.3-jdk-14 as builder
WORKDIR /tmp
# Cache dependencies
COPY api/pom.xml .
RUN mvn verify clean --fail-never
COPY api/resources resources
COPY api/src src
COPY api/test test
RUN mvn 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-with-dependencies.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"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Hubert Van De Walle
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+19
View File
@@ -0,0 +1,19 @@
# SimpleNotes, a simple markdown note taking website
## Requirements
- Docker
- docker-compose
## How to run
- Copy the docker-compose.yml somewhere
- In the same directory, copy the *.env.dist* file and rename it to *.env*
- Edit the variables inside *.env* (see below)
- Run it with `docker-compose up -d`
## Configuration
The app is configured with environments variables.
If no match is found within the env, a default value is read from a properties file in /app/src/main/resources/application.properties.
Don't use the default values for secrets ! Every value inside *.env.dist* should be changed.
-8
View File
@@ -1,8 +0,0 @@
FORMAT: 1A
HOST: http://localhost:5000
# Notes API
<!-- include(./users/index.apib) -->
<!-- include(./notes/index.apib) -->
<!-- include(./tags/index.apib) -->
-163
View File
@@ -1,163 +0,0 @@
# Data Structures
## Chapter (object)
+ title: Chapter 1 (string)
+ content: ... (string)
# Group Notes
## Notes [/notes]
### Create a Note [POST]
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Attributes (object)
+ title: `This is a title` (string)
+ tags: Dev, Server (array[string])
+ chapters (array)
+ (object)
+ title: `Chapter 1` (string)
+ content: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.` (string)
+ Response 201 (application/json)
+ Attributes (object)
+ uuid: `107c90ae-a41e-4c8e-b5e3-1a269cfe044b` (string)
### Get Notes [GET]
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Response 200 (application/json)
+ Attributes (array)
+ (object)
+ uuid: `123e4567-e89b-12d3-a456-426614174000` (string)
+ title: Kotlin (string)
+ tags: Dev, Server (array[string])
+ updatedAt: `2020-01-20T00:00:00` (string)
+ (object)
+ uuid: `107c90ae-a41e-4c8e-b5e3-1a269cfe044b` (string)
+ title: Java (string)
+ tags: Dev (array[string])
+ updatedAt: `2018-01-20T00:00:00` (string)
## Note [/notes/{noteUuid}]
+ Parameters
+ noteUuid: `123e4567-e89b-12d3-a456-426614174000` (string) - The note UUID.
### Get a Note [GET]
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Response 200 (application/json)
+ Attributes (object)
+ uuid: `123e4567-e89b-12d3-a456-426614174000` (string)
+ title: `This is a title` (string)
+ updatedAt: `2020-05-08T11:56:01` (string)
+ tags: Dev, Server (array[string])
+ chapters (array)
+ (Chapter)
+ title: Introduction
+ content: ...
+ (Chapter)
+ title: Objects
+ content: ...
+ Response 404
### Update a Note [PATCH]
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Attributes (object)
+ title: NewTitle (string)
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Attributes (object)
+ tags: new, tags (array[string])
+ Response 200
+ Response 404
### Delete a Note [DELETE]
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Response 200
+ Response 404
## Chapters [/notes/{noteTitle}/chapters/{chapterNumber}]
+ Parameters
+ noteTitle: `Kotlin` (string) - The title of the Note.
+ chapterNumber: `Kotlin` (number) - The chapter number.
### Post a chapter [POST]
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Attributes (Chapter)
+ title: Chapter 1 (string)
+ content: ... (string)
+ Response 201
+ Response 404
### Patch a chapter [PATCH]
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Attributes (object)
+ title: new title (string)
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Attributes (object)
+ content: ... (string)
+ Response 200
+ Response 404
### Delete a chapter [DELETE]
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Response 200
+ Response 404
-14
View File
@@ -1,14 +0,0 @@
# Group Tags
## Tags [/tags]
### Get all tags [GET]
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Response 200 (application/json)
+ Attributes
+ tags: Dev, Server (array[string])
-86
View File
@@ -1,86 +0,0 @@
# Data Structures
## Login (object)
+ username: babar (string)
+ password: tortue (string)
## InvalidCredentials (object)
+ description: Invalid credentials (string),
+ error: Bad Request (string),
+ status_code: 401 (number)
# Group Accounts
## Account [/user]
### Register a new user [POST]
+ Request (application/json)
+ Attributes (object)
+ username: babar (string)
+ password: tortue (string)
+ Response 200 (application/json)
+ Attributes (object)
+ message: Created (string)
+ Response 409 (application/json)
+ Attributes (object)
+ message: User already exists (string)
### Delete a user [DELETE]
+ Request
+ Headers
Authorization: Bearer <token>
+ Response 200 (application/json)
## Authentication [/user/login]
Authenticate one user to access protected routing.
### Authenticate a user [POST]
+ Request (application/json)
+ Attributes (Login)
+ Response 200 (application/json)
+ Attributes
+ token: <token>
+ refreshToken: `<refresh-token>`
+ Response 401 (application/json)
+ Attributes (InvalidCredentials)
## Token refresh [/user/refresh_token]
### Refresh JWT token [POST]
+ Request (application/json)
+ Attributes
+ refreshToken: `<refresh-token>`
+ Response 200 (application/json)
+ Attributes
+ token: <token>
+ refreshToken: `<refresh-token>`
+ Response 401 (application/json)
+ Attributes (InvalidCredentials)
## User Info [/user/me]
Receive the username and email from the currently logged in user
### Get User Info [GET]
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Response 200 (application/json)
+ Attributes
+ user: (object)
+ username: babar (string)
-110
View File
@@ -1,110 +0,0 @@
/*
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
*/
import java.net.*;
import java.io.*;
import java.nio.channels.*;
import java.util.Properties;
public class MavenWrapperDownloader {
/**
* Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided.
*/
private static final String DEFAULT_DOWNLOAD_URL =
"https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.0/maven-wrapper-0.4.0.jar";
/**
* Path to the maven-wrapper.properties file, which might contain a downloadUrl property to
* use instead of the default one.
*/
private static final String MAVEN_WRAPPER_PROPERTIES_PATH =
".mvn/wrapper/maven-wrapper.properties";
/**
* Path where the maven-wrapper.jar will be saved to.
*/
private static final String MAVEN_WRAPPER_JAR_PATH =
".mvn/wrapper/maven-wrapper.jar";
/**
* Name of the property which should be used to override the default download url for the wrapper.
*/
private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl";
public static void main(String args[]) {
System.out.println("- Downloader started");
File baseDirectory = new File(args[0]);
System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath());
// If the maven-wrapper.properties exists, read it and check if it contains a custom
// wrapperUrl parameter.
File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH);
String url = DEFAULT_DOWNLOAD_URL;
if(mavenWrapperPropertyFile.exists()) {
FileInputStream mavenWrapperPropertyFileInputStream = null;
try {
mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile);
Properties mavenWrapperProperties = new Properties();
mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream);
url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url);
} catch (IOException e) {
System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'");
} finally {
try {
if(mavenWrapperPropertyFileInputStream != null) {
mavenWrapperPropertyFileInputStream.close();
}
} catch (IOException e) {
// Ignore ...
}
}
}
System.out.println("- Downloading from: : " + url);
File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH);
if(!outputFile.getParentFile().exists()) {
if(!outputFile.getParentFile().mkdirs()) {
System.out.println(
"- ERROR creating output direcrory '" + outputFile.getParentFile().getAbsolutePath() + "'");
}
}
System.out.println("- Downloading to: " + outputFile.getAbsolutePath());
try {
downloadFileFromURL(url, outputFile);
System.out.println("Done");
System.exit(0);
} catch (Throwable e) {
System.out.println("- Error downloading");
e.printStackTrace();
System.exit(1);
}
}
private static void downloadFileFromURL(String urlString, File destination) throws Exception {
URL website = new URL(urlString);
ReadableByteChannel rbc;
rbc = Channels.newChannel(website.openStream());
FileOutputStream fos = new FileOutputStream(destination);
fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE);
fos.close();
rbc.close();
}
}
-1
View File
@@ -1 +0,0 @@
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.5.2/apache-maven-3.5.2-bin.zip
Vendored
-286
View File
@@ -1,286 +0,0 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Maven2 Start Up Batch script
#
# Required ENV vars:
# ------------------
# JAVA_HOME - location of a JDK home dir
#
# Optional ENV vars
# -----------------
# M2_HOME - location of maven2's installed home dir
# MAVEN_OPTS - parameters passed to the Java VM when running Maven
# e.g. to debug Maven itself, use
# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
# MAVEN_SKIP_RC - flag to disable loading of mavenrc files
# ----------------------------------------------------------------------------
if [ -z "$MAVEN_SKIP_RC" ] ; then
if [ -f /etc/mavenrc ] ; then
. /etc/mavenrc
fi
if [ -f "$HOME/.mavenrc" ] ; then
. "$HOME/.mavenrc"
fi
fi
# OS specific support. $var _must_ be set to either true or false.
cygwin=false;
darwin=false;
mingw=false
case "`uname`" in
CYGWIN*) cygwin=true ;;
MINGW*) mingw=true;;
Darwin*) darwin=true
# Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
# See https://developer.apple.com/library/mac/qa/qa1170/_index.html
if [ -z "$JAVA_HOME" ]; then
if [ -x "/usr/libexec/java_home" ]; then
export JAVA_HOME="`/usr/libexec/java_home`"
else
export JAVA_HOME="/Library/Java/Home"
fi
fi
;;
esac
if [ -z "$JAVA_HOME" ] ; then
if [ -r /etc/gentoo-release ] ; then
JAVA_HOME=`java-config --jre-home`
fi
fi
if [ -z "$M2_HOME" ] ; then
## resolve links - $0 may be a link to maven's home
PRG="$0"
# need this for relative symlinks
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG="`dirname "$PRG"`/$link"
fi
done
saveddir=`pwd`
M2_HOME=`dirname "$PRG"`/..
# make it fully qualified
M2_HOME=`cd "$M2_HOME" && pwd`
cd "$saveddir"
# echo Using m2 at $M2_HOME
fi
# For Cygwin, ensure paths are in UNIX format before anything is touched
if $cygwin ; then
[ -n "$M2_HOME" ] &&
M2_HOME=`cygpath --unix "$M2_HOME"`
[ -n "$JAVA_HOME" ] &&
JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
[ -n "$CLASSPATH" ] &&
CLASSPATH=`cygpath --path --unix "$CLASSPATH"`
fi
# For Mingw, ensure paths are in UNIX format before anything is touched
if $mingw ; then
[ -n "$M2_HOME" ] &&
M2_HOME="`(cd "$M2_HOME"; pwd)`"
[ -n "$JAVA_HOME" ] &&
JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`"
# TODO classpath?
fi
if [ -z "$JAVA_HOME" ]; then
javaExecutable="`which javac`"
if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then
# readlink(1) is not available as standard on Solaris 10.
readLink=`which readlink`
if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then
if $darwin ; then
javaHome="`dirname \"$javaExecutable\"`"
javaExecutable="`cd \"$javaHome\" && pwd -P`/javac"
else
javaExecutable="`readlink -f \"$javaExecutable\"`"
fi
javaHome="`dirname \"$javaExecutable\"`"
javaHome=`expr "$javaHome" : '\(.*\)/bin'`
JAVA_HOME="$javaHome"
export JAVA_HOME
fi
fi
fi
if [ -z "$JAVACMD" ] ; then
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
else
JAVACMD="`which java`"
fi
fi
if [ ! -x "$JAVACMD" ] ; then
echo "Error: JAVA_HOME is not defined correctly." >&2
echo " We cannot execute $JAVACMD" >&2
exit 1
fi
if [ -z "$JAVA_HOME" ] ; then
echo "Warning: JAVA_HOME environment variable is not set."
fi
CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher
# traverses directory structure from process work directory to filesystem root
# first directory with .mvn subdirectory is considered project base directory
find_maven_basedir() {
if [ -z "$1" ]
then
echo "Path not specified to find_maven_basedir"
return 1
fi
basedir="$1"
wdir="$1"
while [ "$wdir" != '/' ] ; do
if [ -d "$wdir"/.mvn ] ; then
basedir=$wdir
break
fi
# workaround for JBEAP-8937 (on Solaris 10/Sparc)
if [ -d "${wdir}" ]; then
wdir=`cd "$wdir/.."; pwd`
fi
# end of workaround
done
echo "${basedir}"
}
# concatenates all lines of a file
concat_lines() {
if [ -f "$1" ]; then
echo "$(tr -s '\n' ' ' < "$1")"
fi
}
BASE_DIR=`find_maven_basedir "$(pwd)"`
if [ -z "$BASE_DIR" ]; then
exit 1;
fi
##########################################################################################
# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
# This allows using the maven wrapper in projects that prohibit checking in binary data.
##########################################################################################
if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then
if [ "$MVNW_VERBOSE" = true ]; then
echo "Found .mvn/wrapper/maven-wrapper.jar"
fi
else
if [ "$MVNW_VERBOSE" = true ]; then
echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..."
fi
jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.0/maven-wrapper-0.4.0.jar"
while IFS="=" read key value; do
case "$key" in (wrapperUrl) jarUrl="$value"; break ;;
esac
done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties"
if [ "$MVNW_VERBOSE" = true ]; then
echo "Downloading from: $jarUrl"
fi
wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar"
if command -v wget > /dev/null; then
if [ "$MVNW_VERBOSE" = true ]; then
echo "Found wget ... using wget"
fi
wget "$jarUrl" -O "$wrapperJarPath"
elif command -v curl > /dev/null; then
if [ "$MVNW_VERBOSE" = true ]; then
echo "Found curl ... using curl"
fi
curl -o "$wrapperJarPath" "$jarUrl"
else
if [ "$MVNW_VERBOSE" = true ]; then
echo "Falling back to using Java to download"
fi
javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java"
if [ -e "$javaClass" ]; then
if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
if [ "$MVNW_VERBOSE" = true ]; then
echo " - Compiling MavenWrapperDownloader.java ..."
fi
# Compiling the Java class
("$JAVA_HOME/bin/javac" "$javaClass")
fi
if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then
# Running the downloader
if [ "$MVNW_VERBOSE" = true ]; then
echo " - Running MavenWrapperDownloader.java ..."
fi
("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR")
fi
fi
fi
fi
##########################################################################################
# End of extension
##########################################################################################
export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}
if [ "$MVNW_VERBOSE" = true ]; then
echo $MAVEN_PROJECTBASEDIR
fi
MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
# For Cygwin, switch paths to Windows format before running java
if $cygwin; then
[ -n "$M2_HOME" ] &&
M2_HOME=`cygpath --path --windows "$M2_HOME"`
[ -n "$JAVA_HOME" ] &&
JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"`
[ -n "$CLASSPATH" ] &&
CLASSPATH=`cygpath --path --windows "$CLASSPATH"`
[ -n "$MAVEN_PROJECTBASEDIR" ] &&
MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"`
fi
WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
exec "$JAVACMD" \
$MAVEN_OPTS \
-classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
"-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"
-161
View File
@@ -1,161 +0,0 @@
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Maven2 Start Up Batch script
@REM
@REM Required ENV vars:
@REM JAVA_HOME - location of a JDK home dir
@REM
@REM Optional ENV vars
@REM M2_HOME - location of maven2's installed home dir
@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending
@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
@REM e.g. to debug Maven itself, use
@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
@REM ----------------------------------------------------------------------------
@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
@echo off
@REM set title of command window
title %0
@REM enable echoing my setting MAVEN_BATCH_ECHO to 'on'
@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO%
@REM set %HOME% to equivalent of $HOME
if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
@REM Execute a user defined script before this one
if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
@REM check for pre script, once with legacy .bat ending and once with .cmd ending
if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat"
if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd"
:skipRcPre
@setlocal
set ERROR_CODE=0
@REM To isolate internal variables from possible post scripts, we use another setlocal
@setlocal
@REM ==== START VALIDATION ====
if not "%JAVA_HOME%" == "" goto OkJHome
echo.
echo Error: JAVA_HOME not found in your environment. >&2
echo Please set the JAVA_HOME variable in your environment to match the >&2
echo location of your Java installation. >&2
echo.
goto error
:OkJHome
if exist "%JAVA_HOME%\bin\java.exe" goto init
echo.
echo Error: JAVA_HOME is set to an invalid directory. >&2
echo JAVA_HOME = "%JAVA_HOME%" >&2
echo Please set the JAVA_HOME variable in your environment to match the >&2
echo location of your Java installation. >&2
echo.
goto error
@REM ==== END VALIDATION ====
:init
@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
@REM Fallback to current working directory if not found.
set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
set EXEC_DIR=%CD%
set WDIR=%EXEC_DIR%
:findBaseDir
IF EXIST "%WDIR%"\.mvn goto baseDirFound
cd ..
IF "%WDIR%"=="%CD%" goto baseDirNotFound
set WDIR=%CD%
goto findBaseDir
:baseDirFound
set MAVEN_PROJECTBASEDIR=%WDIR%
cd "%EXEC_DIR%"
goto endDetectBaseDir
:baseDirNotFound
set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
cd "%EXEC_DIR%"
:endDetectBaseDir
IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
@setlocal EnableExtensions EnableDelayedExpansion
for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
:endReadAdditionalConfig
SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.0/maven-wrapper-0.4.0.jar"
FOR /F "tokens=1,2 delims==" %%A IN (%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties) DO (
IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B
)
@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
if exist %WRAPPER_JAR% (
echo Found %WRAPPER_JAR%
) else (
echo Couldn't find %WRAPPER_JAR%, downloading it ...
echo Downloading from: %DOWNLOAD_URL%
powershell -Command "(New-Object Net.WebClient).DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"
echo Finished downloading %WRAPPER_JAR%
)
@REM End of extension
%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
if ERRORLEVEL 1 goto error
goto end
:error
set ERROR_CODE=1
:end
@endlocal & set ERROR_CODE=%ERROR_CODE%
if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost
@REM check for post script, once with legacy .bat ending and once with .cmd ending
if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat"
if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd"
:skipRcPost
@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
if "%MAVEN_BATCH_PAUSE%" == "on" pause
if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE%
exit /B %ERROR_CODE%
-317
View File
@@ -1,317 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>be.vandewalleh</groupId>
<artifactId>api</artifactId>
<version>0.0.1</version>
<name>api</name>
<properties>
<!-- versions -->
<ktor_version>1.3.2</ktor_version>
<kotlin_version>1.3.70</kotlin_version>
<logback_version>1.2.1</logback_version>
<junit_version>5.6.2</junit_version>
<ktorm_version>3.0.0</ktorm_version>
<mariadb_version>2.6.0</mariadb_version>
<kodein_version>7.0.0</kodein_version>
<flyway_version>6.3.3</flyway_version>
<javajwt_version>3.10.2</javajwt_version>
<jbcrypt_version>0.4</jbcrypt_version>
<hoplite_version>1.2.2</hoplite_version>
<kotlin.code.style>official</kotlin.code.style>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<kotlin.compiler.incremental>true</kotlin.compiler.incremental>
<kotlin.compiler.jvmTarget>12</kotlin.compiler.jvmTarget>
<main.class>be.vandewalleh.NotesApplicationKt</main.class>
<java.version>14</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
</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>jitpack</id>
<url>https://jitpack.io</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
<version>${kotlin_version}</version>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-server-netty</artifactId>
<version>${ktor_version}</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>${logback_version}</version>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-server-core</artifactId>
<version>${ktor_version}</version>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-jackson</artifactId>
<version>${ktor_version}</version>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-auth-jwt</artifactId>
<version>${ktor_version}</version>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-client-core</artifactId>
<version>${ktor_version}</version>
</dependency>
<dependency>
<groupId>org.kodein.di</groupId>
<artifactId>kodein-di-jvm</artifactId>
<version>${kodein_version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit_version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>${junit_version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>${mariadb_version}</version>
</dependency>
<dependency>
<groupId>me.liuwj.ktorm</groupId>
<artifactId>ktorm-core</artifactId>
<version>${ktorm_version}</version>
</dependency>
<dependency>
<groupId>me.liuwj.ktorm</groupId>
<artifactId>ktorm-support-mysql</artifactId>
<version>${ktorm_version}</version>
</dependency>
<dependency>
<groupId>me.liuwj.ktorm</groupId>
<artifactId>ktorm-jackson</artifactId>
<version>${ktorm_version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.10.4</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.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>
</dependency>
<dependency>
<groupId>io.mockk</groupId>
<artifactId>mockk</artifactId>
<version>1.10.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<sourceDirectory>${project.basedir}/src</sourceDirectory>
<testSourceDirectory>${project.basedir}/test</testSourceDirectory>
<resources>
<resource>
<directory>${project.basedir}/resources</directory>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.0.0</version>
<configuration>
<mainClass>${main.class}</mainClass>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M4</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>14</source>
<target>14</target>
</configuration>
</plugin>
<plugin>
<artifactId>kotlin-maven-plugin</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
<version>${kotlin_version}</version>
<executions>
<execution>
<id>compile</id>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>test-compile</id>
<goals>
<goal>test-compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/test</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<minimizeJar>true</minimizeJar>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${main.class}</mainClass>
</transformer>
</transformers>
<artifactSet>
<excludes>
<exclude>io.ktor:ktor-client-*</exclude>
</excludes>
</artifactSet>
<filters>
<filter>
<artifact>org.jetbrains.kotlin:kotlin-reflect</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>org.mariadb.jdbc:mariadb-java-client</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/maven/**</exclude>
<exclude>META-INF/proguard/**</exclude>
<exclude>META-INF/native-image/**</exclude>
<exclude>META-INF/*.kotlin_module</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
-21
View File
@@ -1,21 +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:-true}
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
-21
View File
@@ -1,21 +0,0 @@
package be.vandewalleh
import com.sksamuel.hoplite.Masked
import java.util.concurrent.TimeUnit
data class Config(val database: DatabaseConfig, val server: ServerConfig, val jwt: JwtConfig) {
override fun toString(): String {
return """
Config(
database=$database,
server=$server,
jwt=$jwt
)
""".trimIndent()
}
}
data class DatabaseConfig(val host: String, val port: Int, val name: String, val username: String, val password: Masked)
data class ServerConfig(val host: String, val port: Int, val cors: Boolean)
data class JwtConfig(val auth: Jwt, val refresh: Jwt)
data class Jwt(val validity: Long, val unit: TimeUnit, val secret: Masked)
-56
View File
@@ -1,56 +0,0 @@
package be.vandewalleh
import be.vandewalleh.auth.AuthenticationModule
import be.vandewalleh.auth.SimpleJWT
import be.vandewalleh.extensions.ApplicationBuilder
import be.vandewalleh.extensions.RoutingBuilder
import be.vandewalleh.factories.configurationFactory
import be.vandewalleh.factories.dataSourceFactory
import be.vandewalleh.factories.databaseFactory
import be.vandewalleh.factories.simpleJwtFactory
import be.vandewalleh.features.*
import be.vandewalleh.routing.NoteRoutes
import be.vandewalleh.routing.TagRoutes
import be.vandewalleh.routing.UserRoutes
import be.vandewalleh.services.NoteService
import be.vandewalleh.services.UserService
import org.kodein.di.*
import org.kodein.di.DI
import org.slf4j.LoggerFactory
val mainModule = DI.Module("main") {
bind() from singleton { NoteService(instance()) }
bind() from singleton { UserService(instance(), instance()) }
bind() from singleton { configurationFactory() }
bind() from setBinding<ApplicationBuilder>()
bind<ApplicationBuilder>().inSet() with singleton { ErrorHandler() }
bind<ApplicationBuilder>().inSet() with singleton { ContentNegotiationFeature() }
bind<ApplicationBuilder>().inSet() with singleton { CorsFeature(instance<Config>().server.cors) }
bind<ApplicationBuilder>().inSet() with singleton { AuthenticationModule(instance(tag = "auth")) }
bind<ApplicationBuilder>().inSet() with singleton { MigrationHook(instance()) }
bind<ApplicationBuilder>().inSet() with singleton { ShutdownDatabaseConnection(instance()) }
bind() from setBinding<RoutingBuilder>()
bind<RoutingBuilder>().inSet() with singleton { NoteRoutes(instance()) }
bind<RoutingBuilder>().inSet() with singleton { TagRoutes(instance()) }
bind<RoutingBuilder>().inSet() with singleton {
UserRoutes(
instance(tag = "auth"),
instance(tag = "refresh"),
instance(),
instance()
)
}
bind<SimpleJWT>(tag = "auth") with singleton { simpleJwtFactory(instance<Config>().jwt.auth) }
bind<SimpleJWT>(tag = "refresh") with singleton { simpleJwtFactory(instance<Config>().jwt.refresh) }
bind() from singleton { LoggerFactory.getLogger("Application") }
bind() from singleton { dataSourceFactory(instance<Config>().database) }
bind() from singleton { databaseFactory(instance()) }
bind() from singleton { Migration(instance()) }
bind<PasswordHash>() with singleton { BcryptPasswordHash() }
}
-54
View File
@@ -1,54 +0,0 @@
package be.vandewalleh
import be.vandewalleh.extensions.ApplicationBuilder
import be.vandewalleh.extensions.RoutingBuilder
import io.ktor.application.*
import io.ktor.routing.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import org.kodein.di.DI
import org.kodein.di.description
import org.kodein.di.instance
import org.slf4j.Logger
import java.util.concurrent.TimeUnit
fun main() {
val di = DI { import(mainModule) }
val config by di.instance<Config>()
val logger by di.instance<Logger>()
logger.info("Running application with configuration $config")
logger.debug("Kodein bindings\n${di.container.tree.bindings.description()}")
serve(di)
}
fun serve(di: DI) {
val config by di.instance<Config>()
val logger by di.instance<Logger>()
val env = applicationEngineEnvironment {
module {
module(di)
}
log = logger
connector {
host = config.server.host
port = config.server.port
}
}
with(embeddedServer(Netty, env)) {
addShutdownHook { stop(1, 5, TimeUnit.SECONDS) }
start(wait = true)
}
}
fun Application.module(di: DI) {
val builders: Set<ApplicationBuilder> by di.instance()
builders.forEach {
it.builder(this)
}
val routingBuilders: Set<RoutingBuilder> by di.instance()
routingBuilders.forEach {
routing(it.builder)
}
}
-17
View File
@@ -1,17 +0,0 @@
package be.vandewalleh.auth
import be.vandewalleh.extensions.ApplicationBuilder
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.auth.jwt.*
class AuthenticationModule(authJwt: SimpleJWT) : ApplicationBuilder({
install(Authentication) {
jwt {
verifier(authJwt.verifier)
validate {
UserIdPrincipal(it.payload.getClaim("id").asInt())
}
}
}
})
-20
View File
@@ -1,20 +0,0 @@
package be.vandewalleh.auth
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(secret: String, validity: Long, unit: TimeUnit) {
private val validityInMs = TimeUnit.MILLISECONDS.convert(validity, unit)
private val algorithm = Algorithm.HMAC256(secret)
val verifier: JWTVerifier = JWT.require(algorithm).build()
fun sign(id: Int): String = JWT.create()
.withClaim("id", id)
.withExpiresAt(getExpiration())
.sign(algorithm)
private fun getExpiration() = Date(System.currentTimeMillis() + validityInMs)
}
-9
View File
@@ -1,9 +0,0 @@
package be.vandewalleh.auth
import io.ktor.auth.*
/**
* Represents a simple user's principal identified by [id]
* @property id of the user
*/
data class UserIdPrincipal(val id: Int) : Principal
@@ -1,10 +0,0 @@
package be.vandewalleh.auth
import io.ktor.auth.*
/**
* Represents a simple user [username] and [password] credential pair
* @property username
* @property password
*/
data class UsernamePasswordCredential(val username: String, val password: String) : Credential
-21
View File
@@ -1,21 +0,0 @@
package be.vandewalleh.entities
import com.fasterxml.jackson.annotation.JsonIgnore
import me.liuwj.ktorm.entity.*
import java.time.LocalDateTime
import java.util.*
interface Note : Entity<Note> {
companion object : Entity.Factory<Note>()
var uuid: UUID
var title: String
var content: String
var updatedAt: LocalDateTime
@get:JsonIgnore
var user: User
// Not part of the Notes table
var tags: List<String>
}
-11
View File
@@ -1,11 +0,0 @@
package be.vandewalleh.entities
import me.liuwj.ktorm.entity.*
interface Tag : Entity<Tag> {
companion object : Entity.Factory<Tag>()
val id: Int
var name: String
var note: Note
}
-17
View File
@@ -1,17 +0,0 @@
package be.vandewalleh.entities
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import me.liuwj.ktorm.entity.*
interface User : Entity<User> {
companion object : Entity.Factory<User>()
@get:JsonIgnore
val id: Int
var username: String
@get:JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
var password: String
}
@@ -1,20 +0,0 @@
package be.vandewalleh.extensions
import be.vandewalleh.auth.UserIdPrincipal
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.http.*
import io.ktor.response.*
suspend fun ApplicationCall.respondStatus(status: HttpStatusCode) {
respondText(
"""{"status": "${status.description}"}""",
status = status,
contentType = ContentType.Application.Json
)
}
/**
* @return the userId for the currently authenticated user
*/
fun ApplicationCall.authenticatedUserId() = principal<UserIdPrincipal>()!!.id
-9
View File
@@ -1,9 +0,0 @@
package be.vandewalleh.extensions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend inline fun <T> launchIo(crossinline block: () -> T): T =
withContext(Dispatchers.IO) {
block()
}
-8
View File
@@ -1,8 +0,0 @@
package be.vandewalleh.extensions
import io.ktor.application.*
import io.ktor.routing.*
abstract class RoutingBuilder(val builder: Routing.() -> Unit)
abstract class ApplicationBuilder(val builder: Application.() -> Unit)
-36
View File
@@ -1,36 +0,0 @@
package be.vandewalleh.extensions
import be.vandewalleh.tables.Notes
import be.vandewalleh.tables.Tags
import be.vandewalleh.tables.Users
import me.liuwj.ktorm.database.*
import me.liuwj.ktorm.entity.*
import me.liuwj.ktorm.schema.*
import java.nio.ByteBuffer
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.sql.Types
import java.util.UUID as JavaUUID
class UuidBinarySqlType : SqlType<JavaUUID>(Types.BINARY, typeName = "uuidBinary") {
override fun doGetResult(rs: ResultSet, index: Int): JavaUUID? {
val value = rs.getBytes(index) ?: return null
return ByteBuffer.wrap(value).let { b -> JavaUUID(b.long, b.long) }
}
override fun doSetParameter(ps: PreparedStatement, index: Int, parameter: JavaUUID) {
val bytes = ByteBuffer.allocate(16)
.putLong(parameter.mostSignificantBits)
.putLong(parameter.leastSignificantBits)
.array()
ps.setBytes(index, bytes)
}
}
fun <E : Any> BaseTable<E>.uuidBinary(name: String): Column<JavaUUID> {
return registerColumn(name, UuidBinarySqlType())
}
val Database.users get() = this.sequenceOf(Users, withReferences = false)
val Database.notes get() = this.sequenceOf(Notes, withReferences = false)
val Database.tags get() = this.sequenceOf(Tags, withReferences = false)
@@ -1,7 +0,0 @@
package be.vandewalleh.factories
import be.vandewalleh.Config
import com.sksamuel.hoplite.ConfigLoader
fun configurationFactory() =
ConfigLoader().loadConfigOrThrow<Config>("/application.yaml")
-20
View File
@@ -1,20 +0,0 @@
package be.vandewalleh.factories
import be.vandewalleh.DatabaseConfig
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
fun dataSourceFactory(config: DatabaseConfig): HikariDataSource {
val host = config.host
val port = config.port
val name = config.name
val hikariConfig = HikariConfig().apply {
jdbcUrl = "jdbc:mariadb://$host:$port/$name"
username = config.username
password = config.password.value
connectionTimeout = 3000 // 3 seconds
}
return HikariDataSource(hikariConfig)
}
-6
View File
@@ -1,6 +0,0 @@
package be.vandewalleh.factories
import me.liuwj.ktorm.database.*
import javax.sql.DataSource
fun databaseFactory(dataSource: DataSource) = Database.connect(dataSource)
-6
View File
@@ -1,6 +0,0 @@
package be.vandewalleh.factories
import be.vandewalleh.Jwt
import be.vandewalleh.auth.SimpleJWT
fun simpleJwtFactory(jwt: Jwt) = SimpleJWT(jwt.secret.value, jwt.validity, jwt.unit)
@@ -1,21 +0,0 @@
package be.vandewalleh.features
import be.vandewalleh.extensions.ApplicationBuilder
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.util.StdDateFormat
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import io.ktor.application.*
import io.ktor.features.*
import io.ktor.jackson.*
import me.liuwj.ktorm.jackson.*
class ContentNegotiationFeature : ApplicationBuilder({
install(ContentNegotiation) {
jackson {
registerModule(KtormModule())
registerModule(JavaTimeModule())
disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
dateFormat = StdDateFormat()
}
}
})
-17
View File
@@ -1,17 +0,0 @@
package be.vandewalleh.features
import be.vandewalleh.extensions.ApplicationBuilder
import io.ktor.application.*
import io.ktor.features.*
import io.ktor.http.*
class CorsFeature(enabled: Boolean) : ApplicationBuilder({
if (enabled) {
install(CORS) {
anyHost()
header(HttpHeaders.ContentType)
header(HttpHeaders.Authorization)
methods.add(HttpMethod.Delete)
}
}
})
-31
View File
@@ -1,31 +0,0 @@
package be.vandewalleh.features
import be.vandewalleh.extensions.ApplicationBuilder
import io.ktor.application.*
import io.ktor.features.*
import io.ktor.http.*
import io.ktor.response.*
import io.ktor.utils.io.errors.*
import java.sql.SQLTransientConnectionException
class ErrorHandler : ApplicationBuilder({
install(StatusPages) {
jacksonErrors()
exception<IOException> {
call.respond(HttpStatusCode.BadRequest)
}
exception<ValidationException> {
call.respond(HttpStatusCode.BadRequest, ErrorResponse(it.error))
}
exception<SQLTransientConnectionException> {
val error = mapOf("error" to "It seems the server can't connect to the database")
call.respond(HttpStatusCode.InternalServerError, error)
}
}
})
class ValidationException(val error: String) : RuntimeException()
class ErrorResponse(val error: String)
-66
View File
@@ -1,66 +0,0 @@
package be.vandewalleh.features
import com.fasterxml.jackson.core.JsonParseException
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.core.exc.InputCoercionException
import com.fasterxml.jackson.databind.JsonMappingException
import com.fasterxml.jackson.databind.exc.MismatchedInputException
import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException
import io.ktor.application.*
import io.ktor.features.*
import io.ktor.http.*
import io.ktor.response.*
fun StatusPages.Configuration.jacksonErrors() {
exception<MismatchedInputException> {
val error = InvalidFormatError(it.path.firstOrNull()?.fieldName, it.targetType)
call.respond(HttpStatusCode.BadRequest, error)
}
exception<JsonParseException> {
val error = JsonParseError()
call.respond(HttpStatusCode.BadRequest, error)
}
exception<UnrecognizedPropertyException> {
val error = UnrecognizedPropertyError(it.path[0].fieldName)
call.respond(HttpStatusCode.BadRequest, error)
}
exception<MissingKotlinParameterException> {
val error = MissingKotlinParameterError(it.path[0].fieldName)
call.respond(HttpStatusCode.BadRequest, error)
}
exception<JsonProcessingException> {
call.respond(HttpStatusCode.BadRequest, JsonProcessingError())
}
exception<JsonMappingException> {
if (it.cause is InputCoercionException) {
return@exception call.respond(HttpStatusCode.BadRequest, OutOfRangeError(it.path[0].fieldName))
}
call.respond(HttpStatusCode.BadRequest, JsonProcessingError())
}
}
class InvalidFormatError(val value: Any?, targetType: Class<*>) {
val msg = "Wrong type"
val required = targetType.simpleName
}
class UnrecognizedPropertyError(val field: Any?) {
val msg = "Unrecognized field"
}
class MissingKotlinParameterError(val field: String) {
val msg = "Missing field"
}
class JsonProcessingError {
val msg = "An error occurred while processing JSON"
}
class JsonParseError {
val msg = "Invalid JSON"
}
class OutOfRangeError(val field: String) {
val msg = "Numeric value out of range"
}
-27
View File
@@ -1,27 +0,0 @@
package be.vandewalleh.features
import be.vandewalleh.extensions.ApplicationBuilder
import io.ktor.application.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.flywaydb.core.Flyway
import javax.sql.DataSource
class MigrationHook(migration: Migration) : ApplicationBuilder({
environment.monitor.subscribe(ApplicationStarted) {
CoroutineScope(Dispatchers.IO).launch {
migration.migrate()
}
}
})
class Migration(private val dataSource: DataSource) {
fun migrate() {
Flyway.configure()
.dataSource(dataSource)
.baselineOnMigrate(true)
.load()
.migrate()
}
}
@@ -1,13 +0,0 @@
package be.vandewalleh.features
import be.vandewalleh.extensions.ApplicationBuilder
import com.zaxxer.hikari.HikariDataSource
import io.ktor.application.*
class ShutdownDatabaseConnection(hikariDataSource: HikariDataSource) : ApplicationBuilder({
environment.monitor.subscribe(ApplicationStopPreparing) {
if (!hikariDataSource.isClosed) {
hikariDataSource.close()
}
}
})
-95
View File
@@ -1,95 +0,0 @@
package be.vandewalleh.routing
import be.vandewalleh.extensions.RoutingBuilder
import be.vandewalleh.extensions.authenticatedUserId
import be.vandewalleh.extensions.respondStatus
import be.vandewalleh.features.ValidationException
import be.vandewalleh.services.NoteService
import be.vandewalleh.validation.noteValidator
import be.vandewalleh.validation.receiveValidated
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.http.*
import io.ktor.response.*
import io.ktor.routing.*
import java.util.*
class NoteRoutes(noteService: NoteService) : RoutingBuilder({
authenticate {
route("/notes") {
createNote(noteService)
getAllNotes(noteService)
route("/{uuid}") {
getNote(noteService)
updateNote(noteService)
deleteNote(noteService)
}
}
}
})
private fun Route.createNote(noteService: NoteService) {
post {
val userId = call.authenticatedUserId()
val note = call.receiveValidated(noteValidator)
val createdNote = noteService.create(userId, note)
call.respond(HttpStatusCode.Created, createdNote)
}
}
private fun Route.getAllNotes(noteService: NoteService) {
get {
val userId = call.authenticatedUserId()
val limit = call.parameters["limit"]?.toInt() ?: 20 // FIXME validate
val after = call.parameters["after"]?.let { UUID.fromString(it) } // FIXME validate
val notes = noteService.findAll(userId, limit, after)
call.respond(notes)
}
}
private fun Route.getNote(noteService: NoteService) {
get {
val userId = call.authenticatedUserId()
val noteUuid = call.noteUuid()
val response = noteService.find(userId, noteUuid)
?: return@get call.respondStatus(HttpStatusCode.NotFound)
call.respond(response)
}
}
private fun Route.updateNote(noteService: NoteService) {
put {
val userId = call.authenticatedUserId()
val noteUuid = call.noteUuid()
val note = call.receiveValidated(noteValidator)
note.uuid = noteUuid
if (noteService.updateNote(userId, note))
call.respondStatus(HttpStatusCode.OK)
else call.respondStatus(HttpStatusCode.NotFound)
}
}
private fun Route.deleteNote(noteService: NoteService) {
delete {
val userId = call.authenticatedUserId()
val noteUuid = call.noteUuid()
if (noteService.delete(userId, noteUuid))
call.respondStatus(HttpStatusCode.OK)
else
call.respondStatus(HttpStatusCode.NotFound)
}
}
private fun ApplicationCall.noteUuid(): UUID {
val uuid = parameters["uuid"]
return try {
UUID.fromString(uuid)
} catch (e: IllegalArgumentException) {
throw ValidationException("`$uuid` is not a valid UUID")
}
}
-17
View File
@@ -1,17 +0,0 @@
package be.vandewalleh.routing
import be.vandewalleh.extensions.RoutingBuilder
import be.vandewalleh.extensions.authenticatedUserId
import be.vandewalleh.services.NoteService
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.response.*
import io.ktor.routing.*
class TagRoutes(noteService: NoteService) : RoutingBuilder({
authenticate {
get("/tags") {
call.respond(noteService.getTags(call.authenticatedUserId()))
}
}
})
-123
View File
@@ -1,123 +0,0 @@
package be.vandewalleh.routing
import be.vandewalleh.auth.SimpleJWT
import be.vandewalleh.auth.UsernamePasswordCredential
import be.vandewalleh.extensions.RoutingBuilder
import be.vandewalleh.extensions.authenticatedUserId
import be.vandewalleh.extensions.respondStatus
import be.vandewalleh.features.PasswordHash
import be.vandewalleh.services.UserService
import be.vandewalleh.validation.receiveValidated
import be.vandewalleh.validation.registerValidator
import com.auth0.jwt.exceptions.JWTVerificationException
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.http.*
import io.ktor.request.*
import io.ktor.response.*
import io.ktor.routing.*
class UserRoutes(
authJWT: SimpleJWT,
refreshJWT: SimpleJWT,
userService: UserService,
passwordHash: PasswordHash
) : RoutingBuilder({
route("/user") {
createUser(userService)
route("/login") {
login(userService, passwordHash, authJWT, refreshJWT)
}
route("/refresh_token") {
refreshToken(userService, authJWT, refreshJWT)
}
authenticate {
deleteUser(userService)
route("/me") {
userInfo(userService)
}
}
}
})
private fun Route.userInfo(userService: UserService) {
get {
val id = call.authenticatedUserId()
val info = userService.find(id)
if (info != null) call.respond(mapOf("user" to info))
else call.respondStatus(HttpStatusCode.Unauthorized)
}
}
private fun Route.deleteUser(userService: UserService) {
delete {
val userId = call.authenticatedUserId()
call.respondStatus(
if (userService.delete(userId)) HttpStatusCode.OK
else HttpStatusCode.NotFound
)
}
}
private fun Route.createUser(userService: UserService) {
post {
val user = call.receiveValidated(registerValidator)
if (userService.exists(user.username))
return@post call.respondStatus(HttpStatusCode.Conflict)
val newUser = userService.create(user.username, user.password)
?: return@post call.respondStatus(HttpStatusCode.Conflict)
call.respond(HttpStatusCode.Created, newUser)
}
}
private fun Route.login(
userService: UserService,
passwordHash: PasswordHash,
authJWT: SimpleJWT,
refreshJWT: SimpleJWT
) {
post {
val credential = call.receive<UsernamePasswordCredential>()
val user = userService.find(credential.username)
?: return@post call.respondStatus(HttpStatusCode.Unauthorized)
if (!passwordHash.verify(credential.password, user.password)) {
return@post call.respondStatus(HttpStatusCode.Unauthorized)
}
val response = DualToken(
token = authJWT.sign(user.id),
refreshToken = refreshJWT.sign(user.id)
)
return@post call.respond(response)
}
}
private fun Route.refreshToken(userService: UserService, authJWT: SimpleJWT, refreshJWT: SimpleJWT) {
post {
val token = call.receive<RefreshToken>().refreshToken
val id = try {
val decodedJWT = refreshJWT.verifier.verify(token)
decodedJWT.getClaim("id").asInt()
} catch (e: JWTVerificationException) {
return@post call.respondStatus(HttpStatusCode.Unauthorized)
}
if (!userService.exists(id))
return@post call.respondStatus(HttpStatusCode.Unauthorized)
val response = DualToken(
token = authJWT.sign(id),
refreshToken = refreshJWT.sign(id)
)
return@post call.respond(response)
}
}
private data class RefreshToken(val refreshToken: String)
private data class DualToken(val token: String, val refreshToken: String)
-144
View File
@@ -1,144 +0,0 @@
package be.vandewalleh.services
import be.vandewalleh.entities.Note
import be.vandewalleh.extensions.launchIo
import be.vandewalleh.extensions.notes
import be.vandewalleh.extensions.tags
import be.vandewalleh.tables.Notes
import be.vandewalleh.tables.Tags
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
/**
* service to handle database queries at the Notes level.
*/
class NoteService(private val db: Database) {
/**
* returns a list of [Note] associated with the userId
*/
suspend fun findAll(userId: Int, limit: Int = 20, after: UUID? = null): List<Note> = launchIo {
var previous: LocalDateTime? = null
if (after != null) {
previous = db.notes
.filter { it.userId eq userId and (it.uuid eq after) }
.mapColumns { it.updatedAt }
.firstOrNull() ?: return@launchIo emptyList()
}
val notes = db.notes
.filterColumns { it.columns - it.userId }
.filter {
if (previous == null) it.userId eq userId
else (it.userId eq userId) and (it.updatedAt less previous)
}
.sortedByDescending { it.updatedAt }
.take(limit)
.toList()
if (notes.isEmpty()) return@launchIo emptyList()
val tagsByUuid =
db.tags
.filterColumns { listOf(it.noteUuid, it.name) }
.filter { it.noteUuid inList notes.map { note -> note.uuid } }
.groupByTo(HashMap(), { it.note.uuid }, { it.name })
notes.forEach {
val tags = tagsByUuid[it.uuid]
if (tags != null) it.tags = tags
}
notes
}
suspend fun exists(userId: Int, uuid: UUID) = launchIo {
db.notes.any { (it.userId eq userId) and (it.uuid eq uuid) }
}
suspend fun create(userId: Int, note: Note): Note = launchIo {
val uuid = UUID.randomUUID()
val newNote = note.copy().apply {
this["uuid"] = uuid
this.user["id"] = userId
this.updatedAt = LocalDateTime.now()
}
db.useTransaction {
db.notes.add(newNote)
db.batchInsert(Tags) {
note.tags.forEach { tagName ->
item {
it.noteUuid to uuid
it.name to tagName
}
}
}
}
newNote
}
@Suppress("UNCHECKED_CAST")
suspend fun find(userId: Int, noteUuid: UUID): Note? = launchIo {
val note =
db.notes
.filterColumns { it.columns - it.userId }
.filter { it.uuid eq noteUuid }
.find { it.userId eq userId }
?: return@launchIo null
val tags =
db.sequenceOf(Tags, withReferences = false)
.filter { it.noteUuid eq noteUuid }
.mapColumns { it.name } as List<String>
note.also { it.tags = tags }
}
suspend fun updateNote(userId: Int, note: Note): Boolean = launchIo {
if (note["uuid"] == null) error("UUID is required")
db.useTransaction {
val currentNote = db.notes
.find { it.uuid eq note.uuid and (it.userId eq userId) }
?: return@launchIo false
currentNote.title = note.title
currentNote.content = note.content
currentNote.updatedAt = LocalDateTime.now()
currentNote.flushChanges()
// delete all tags
db.delete(Tags) {
it.noteUuid eq note.uuid
}
// put new ones
note.tags.forEach { tagName ->
db.insert(Tags) {
it.name to tagName
it.noteUuid to note.uuid
}
}
}
true
}
suspend fun delete(userId: Int, noteUuid: UUID): Boolean = launchIo {
db.useTransaction {
db.delete(Notes) { it.uuid eq noteUuid and (it.userId eq userId) } == 1
}
}
@Suppress("UNCHECKED_CAST")
suspend fun getTags(userId: Int): List<String> = launchIo {
db.sequenceOf(Tags)
.filter { it.note.userId eq userId }
.mapColumns(isDistinct = true) { it.name } as List<String>
}
}
-65
View File
@@ -1,65 +0,0 @@
package be.vandewalleh.services
import be.vandewalleh.entities.User
import be.vandewalleh.extensions.launchIo
import be.vandewalleh.extensions.users
import be.vandewalleh.features.PasswordHash
import be.vandewalleh.tables.Users
import me.liuwj.ktorm.database.*
import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.*
import java.sql.SQLIntegrityConstraintViolationException
/**
* service to handle database queries for users.
*/
class UserService(private val db: Database, private val passwordHash: PasswordHash) {
/**
* returns a user from it's username if found or null
*/
suspend fun find(username: String): User? = launchIo {
db.users.find { it.username eq username }
}
suspend fun find(id: Int): User? = launchIo {
db.users.find { it.id eq id }
}
suspend fun exists(username: String) = launchIo {
db.users.any { it.username eq username }
}
suspend fun exists(userId: Int) = launchIo {
db.users.any { it.id eq userId }
}
suspend fun create(username: String, password: String): User? {
val newUser = User {
this.username = username
this.password = passwordHash.crypt(password)
}
return try {
launchIo {
db.useTransaction {
db.users.add(newUser)
newUser
}
}
} catch (e: SQLIntegrityConstraintViolationException) {
null
}
}
suspend fun delete(userId: Int): Boolean = launchIo {
val updateCount = db.useTransaction {
db.delete(Users) { it.id eq userId }
}
when (updateCount) {
1 -> true
0 -> false
else -> error("??")
}
}
}
-18
View File
@@ -1,18 +0,0 @@
package be.vandewalleh.tables
import be.vandewalleh.entities.Note
import be.vandewalleh.extensions.uuidBinary
import me.liuwj.ktorm.schema.*
open class Notes(alias: String?) : Table<Note>("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 content = varchar("content").bindTo { it.content }
val userId = int("user_id").references(Users) { it.user }
val updatedAt = datetime("updated_at").bindTo { it.updatedAt }
val user get() = userId.referenceTable as Users
}
-16
View File
@@ -1,16 +0,0 @@
package be.vandewalleh.tables
import be.vandewalleh.entities.Tag
import be.vandewalleh.extensions.uuidBinary
import me.liuwj.ktorm.schema.*
open class Tags(alias: String?) : Table<Tag>("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
}
-14
View File
@@ -1,14 +0,0 @@
package be.vandewalleh.tables
import be.vandewalleh.entities.User
import me.liuwj.ktorm.schema.*
open class Users(alias: String?) : Table<User>("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 }
}
-18
View File
@@ -1,18 +0,0 @@
package be.vandewalleh.validation
import am.ik.yavi.builder.ValidatorBuilder
import am.ik.yavi.builder.konstraint
import am.ik.yavi.core.Validator
import be.vandewalleh.entities.Note
val noteValidator: Validator<Note> = ValidatorBuilder.of<Note>()
.konstraint(Note::title) {
notNull().notBlank().lessThanOrEqual(50)
}
.konstraint(Note::tags) {
lessThanOrEqual(10)
}
.konstraint(Note::content) {
notNull().notBlank()
}
.build()
-15
View File
@@ -1,15 +0,0 @@
package be.vandewalleh.validation
import am.ik.yavi.builder.ValidatorBuilder
import am.ik.yavi.builder.konstraint
import am.ik.yavi.core.Validator
import be.vandewalleh.entities.User
val registerValidator: Validator<User> = ValidatorBuilder.of<User>()
.konstraint(User::username) {
notNull().lessThanOrEqual(50).greaterThanOrEqual(3)
}
.konstraint(User::password) {
notNull().greaterThanOrEqual(6)
}
.build()
@@ -1,12 +0,0 @@
package be.vandewalleh.validation
import am.ik.yavi.core.Validator
import be.vandewalleh.features.ValidationException
import io.ktor.application.*
import io.ktor.request.*
suspend inline fun <reified T : Any> ApplicationCall.receiveValidated(validator: Validator<T>): T {
val value: T = receive()
validator.validate(value).throwIfInvalid { ValidationException(it.details()[0].defaultMessage) }
return value
}
@@ -1,223 +0,0 @@
package integration.routing
import be.vandewalleh.Config
import be.vandewalleh.auth.SimpleJWT
import be.vandewalleh.entities.User
import be.vandewalleh.features.PasswordHash
import be.vandewalleh.mainModule
import be.vandewalleh.module
import be.vandewalleh.services.UserService
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.http.*
import io.ktor.server.testing.*
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import org.amshove.kluent.*
import org.json.JSONObject
import org.junit.jupiter.api.*
import org.kodein.di.DI
import org.kodein.di.bind
import org.kodein.di.instance
import utils.*
import java.util.*
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class AuthControllerKtTest {
private val userService = mockk<UserService>()
private val kodein = DI {
import(mainModule, allowOverride = true)
bind<UserService>(overrides = true) with instance(userService)
}
private val passwordHash by kodein.instance<PasswordHash>()
init {
val user = User {
password = passwordHash.crypt("password")
username = "existing"
}
user["id"] = 1
coEvery { userService.find("existing") } returns user
coEvery { userService.exists(1) } returns true
coEvery { userService.find(1) } returns User {
username = "existing"
}
val user2 = User {
password = passwordHash.crypt("right password")
username = "wrong"
}
user["id"] = 2
coEvery { userService.find("wrong") } returns user2
coEvery { userService.find("notExisting") } returns null
coEvery { userService.exists(3) } returns false
coEvery { userService.find(3) } returns null
}
private val testEngine = TestApplicationEngine().apply {
start()
application.module(kodein)
}
@Nested
inner class Login {
@Test
fun `login existing user with valid password`() {
val res = testEngine.post("/user/login") {
json {
it["username"] = "existing"
it["password"] = "password"
}
}
coVerify { userService.find("existing") }
res.status() `should be equal to` HttpStatusCode.OK
val jsonObject = JSONObject(res.content)
val hasToken = jsonObject.has("token")
hasToken `should be equal to` true
jsonObject.keyList() `should be equal to` listOf("token", "refreshToken")
val authJwt by kodein.instance<SimpleJWT>(tag = "auth")
val token = jsonObject.getString("token")
authJwt.verifier.verify(token)
val refreshJwt by kodein.instance<SimpleJWT>(tag = "refresh")
val refreshToken = jsonObject.getString("refreshToken")
refreshJwt.verifier.verify(refreshToken)
}
@Test
fun `login existing user with invalid password`() {
val res = testEngine.post("/user/login") {
json {
it["username"] = "wrong"
it["password"] = "not this"
}
}
coVerify { userService.find("wrong") }
res.status() `should be equal to` HttpStatusCode.Unauthorized
res.content `should strictly be equal to json` """{msg: "Unauthorized"}"""
}
@Test
fun `login not existing user`() {
val res = testEngine.post("/user/login") {
json {
it["username"] = "notExisting"
it["password"] = "babababa"
}
}
coVerify { userService.find("notExisting") }
res.status() `should be equal to` HttpStatusCode.Unauthorized
res.content `should strictly be equal to json` """{msg: "Unauthorized"}"""
}
@Test
fun `login without body`() {
val res = testEngine.post("/user/login") {
addHeader(HttpHeaders.ContentType, "application/json")
}
res.status() `should be equal to` HttpStatusCode.BadRequest
}
}
@Nested
inner class Refresh {
@Test
fun `test valid refresh token`() {
val refreshJwt by kodein.instance<SimpleJWT>(tag = "refresh")
val refreshToken = refreshJwt.sign(1)
val res = testEngine.post("/user/refresh_token") {
json {
it["refreshToken"] = refreshToken
}
}
val jsonObject = JSONObject(res.content)
jsonObject.keyList() `should be equal to` listOf("token", "refreshToken")
coVerify { userService.exists(1) }
res.status() `should be equal to` HttpStatusCode.OK
}
@Test
fun `test valid refresh token for deleted user`() {
val refreshJwt by kodein.instance<SimpleJWT>(tag = "refresh")
val refreshToken = refreshJwt.sign(3)
val res = testEngine.post("/user/refresh_token") {
json {
it["refreshToken"] = refreshToken
}
}
coVerify { userService.exists(3) }
res.status() `should be equal to` HttpStatusCode.Unauthorized
res.content `should strictly be equal to json` """{msg: "Unauthorized"}"""
}
@Test
fun `test expired refresh token for existing user`() {
val config by kodein.instance<Config>()
val algorithm = Algorithm.HMAC256(config.jwt.refresh.secret.value)
val expiredToken = JWT.create()
.withClaim("id", 1)
.withExpiresAt(Date(0)) // January 1, 1970, 00:00:00 GMT
.sign(algorithm)
val res = testEngine.post("/user/refresh_token") {
json {
it["refreshToken"] = expiredToken
}
}
res.status() `should be equal to` HttpStatusCode.Unauthorized
res.content `should strictly be equal to json` """{msg: "Unauthorized"}"""
}
}
@Nested
inner class UserInfo {
@Test
fun `test user info for existing user`() {
val authJwt by kodein.instance<SimpleJWT>(tag = "auth")
val token = authJwt.sign(1)
val res = testEngine.get("/user/me") {
setToken(token)
}
res.content `should strictly be equal to json` """{user:{username:"existing"}}"""
res.status() `should be equal to` HttpStatusCode.OK
}
@Test
fun `test user info on deleted user`() {
val authJwt by kodein.instance<SimpleJWT>(tag = "auth")
val token = authJwt.sign(3)
val res = testEngine.get("/user/me") {
setToken(token)
}
res.status()!!.value `should not be in range` (200..299)
val jsonObject = JSONObject(res.content)
jsonObject.keyList() `should be equal to` listOf("msg")
}
}
}
@@ -1,102 +0,0 @@
package integration.routing
import be.vandewalleh.auth.SimpleJWT
import be.vandewalleh.entities.User
import be.vandewalleh.mainModule
import be.vandewalleh.module
import be.vandewalleh.services.UserService
import io.ktor.http.*
import io.ktor.server.testing.*
import io.mockk.coEvery
import io.mockk.mockk
import org.amshove.kluent.*
import org.junit.jupiter.api.*
import org.kodein.di.DI
import org.kodein.di.bind
import org.kodein.di.instance
import utils.*
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserControllerKtTest {
private val userService = mockk<UserService>()
init {
// new user
coEvery { userService.exists("new") } returns false
coEvery { userService.create("new", any()) } returns User {
this.username = "new"
}
// existing user
coEvery { userService.exists("existing") } returns true
coEvery { userService.create("existing", any()) } returns null
coEvery { userService.delete(1) } returns true andThen false
// modified user
coEvery { userService.exists("modified") } returns true
coEvery { userService.exists(and(not("modified"), not("existing"))) } returns false
coEvery { userService.exists(1) } returns true
coEvery { userService.create("modified", any()) } returns null
}
private val kodein = DI {
import(mainModule, allowOverride = true)
bind<UserService>(overrides = true) with instance(userService)
}
private val testEngine = TestApplicationEngine().apply {
start()
application.module(kodein)
}
@Nested
inner class CreateUser {
@Test
fun `create a new user`() {
val res = testEngine.post("/user") {
json {
it["username"] = "new"
it["password"] = "test123abc"
}
}
res.status() `should be equal to` HttpStatusCode.Created
res.content `should strictly be equal to json` """{username:"new"}"""
}
@Test
fun `create an existing user`() {
val res = testEngine.post("/user") {
json {
it["username"] = "existing"
it["password"] = "test123abc"
}
}
res.status() `should be equal to` HttpStatusCode.Conflict
res.content `should be equal to json` """{msg:"Conflict"}"""
}
}
@Nested
inner class DeleteUser {
@Test
fun `delete an existing user`() {
val authJwt by kodein.instance<SimpleJWT>("auth")
val token = authJwt.sign(1)
val res = testEngine.delete("/user") {
addHeader(HttpHeaders.Authorization, "Bearer $token")
}
res.status() `should be equal to` HttpStatusCode.OK
res.content `should be equal to json` """{msg:"OK"}"""
// try again
val res2 = testEngine.delete("/user") {
setToken(token)
}
res2.status() `should be equal to` HttpStatusCode.NotFound
res2.content `should be equal to json` """{msg:"Not Found"}"""
}
}
}
@@ -1,257 +0,0 @@
package integration.services
import am.ik.yavi.builder.ValidatorBuilder
import am.ik.yavi.core.CustomConstraint
import am.ik.yavi.core.Validator
import be.vandewalleh.entities.Note
import be.vandewalleh.features.Migration
import be.vandewalleh.mainModule
import be.vandewalleh.services.NoteService
import be.vandewalleh.services.UserService
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.util.StdDateFormat
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.readValue
import com.github.javafaker.Faker
import kotlinx.coroutines.runBlocking
import me.liuwj.ktorm.jackson.*
import org.amshove.kluent.*
import org.junit.jupiter.api.*
import org.kodein.di.DI
import org.kodein.di.bind
import org.kodein.di.instance
import org.kodein.di.singleton
import utils.KMariadbContainer
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class NoteServiceTest {
@Nested
inner class DB {
private val mariadb = KMariadbContainer().apply { start() }
private val kodein = DI {
import(mainModule, allowOverride = true)
bind(overrides = true) from singleton { mariadb.datasource() }
}
init {
val migration by kodein.instance<Migration>()
migration.migrate()
}
@Test
fun run() {
val userService by kodein.instance<UserService>()
val user = runBlocking { userService.create("test", "test")!! }
val noteService by kodein.instance<NoteService>()
val note = runBlocking {
noteService.create(
user.id,
Note {
this.title = "a note"
this.content =
"""
|# Title
|
|😝😝😝😝
|another line
""".trimMargin()
this.tags = listOf("a", "tag")
}
)
}
println(note)
val objectMapper = ObjectMapper().apply {
registerModule(JavaTimeModule())
registerModule(KtormModule())
disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
dateFormat = StdDateFormat()
}
val json = objectMapper.writeValueAsString(note)
println(json)
}
@Test
fun `test tag list`() {
val userService by kodein.instance<UserService>()
val user = runBlocking { userService.create("test", "test")!! }
val user2 = runBlocking { userService.create("user2", "test")!! }
val noteService by kodein.instance<NoteService>()
runBlocking {
noteService.create(
user.id,
Note {
title = "test"
content = ""
tags = listOf("same")
}
)
noteService.create(
user.id,
Note {
title = "test2"
content = ""
tags = listOf("same")
}
)
noteService.create(
user.id,
Note {
title = "test3"
content = ""
tags = listOf("another")
}
)
noteService.create(
user2.id,
Note {
title = "test"
content = ""
tags = listOf("user2")
}
)
}
val user1Tags = runBlocking { noteService.getTags(user.id) }
user1Tags `should be equal to` listOf("same", "another")
val user2Tags = runBlocking { noteService.getTags(user2.id) }
user2Tags `should be equal to` listOf("user2")
}
@Test
fun `test patch note`() {
val noteService by kodein.instance<NoteService>()
val userService by kodein.instance<UserService>()
val user = runBlocking { userService.create(Faker().name().username(), "test") }!!
val note = runBlocking {
noteService.create(
user.id,
Note {
this.title = "title"
this.content = "old content"
this.tags = emptyList()
}
)
}
val get = runBlocking { noteService.find(user.id, note.uuid) }
runBlocking {
noteService.updateNote(
user.id,
Note {
uuid = note.uuid
title = "new title"
}
)
}
val updated = runBlocking { noteService.find(user.id, note.uuid) }
println("updated: $updated")
}
}
@Nested
inner class NoteValidation {
@Test
fun `test update constraints`() {
val fieldPresentConstraint = object : CustomConstraint<Note> {
override fun defaultMessageFormat() = "fmt {0} {1} {2}"
override fun messageKey() = "title|content|tags"
override fun test(note: Note): Boolean {
val hasTitle = note["title"] != null
val hasContent = note["content"] != null
val hasTags = note["tags"] != null && note.tags.isNotEmpty()
return hasTitle || hasContent || hasTags
}
}
val userValidator: Validator<Note> = ValidatorBuilder<Note>()
.constraintOnTarget(fieldPresentConstraint, "present")
.build()
userValidator.validate(
Note {
title = "this is a title"
}
).isValid `should be equal to` true
userValidator.validate(
Note {
content = "this is a title"
}
).isValid `should be equal to` true
userValidator.validate(
Note {
tags = emptyList()
}
).isValid `should be equal to` false
userValidator.validate(
Note {
tags = listOf("tags")
}
).isValid `should be equal to` true
userValidator.validate(
Note {
tags = listOf("tags")
title = "This is a title"
}
).isValid `should be equal to` true
userValidator.validate(
Note {
tags = listOf("tags")
title = "This is a title"
content =
"""
|# This is
|
|some markdown content
""".trimMargin()
}
).isValid `should be equal to` true
userValidator.validate(
Note {
tags = listOf("tags")
title = "This is a title"
content =
"""
|# This is
|
|some markdown content
""".trimMargin()
}
).isValid `should be equal to` true
userValidator.validate(Note()).isValid `should be equal to` false
}
}
@Nested
inner class NoteEntity {
@Test
fun `test entity`() {
val objectMapper = ObjectMapper().apply {
registerModule(JavaTimeModule())
registerModule(KtormModule())
disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
dateFormat = StdDateFormat()
}
val note: Note = objectMapper.readValue("""{"uuid": "2007e4d7-2986-4188-bde1-b99916d94bad"}""")
println(note.uuid)
println(note.uuid::class.qualifiedName)
println(note.uuid.leastSignificantBits)
println(note.uuid.mostSignificantBits)
}
}
}
@@ -1,66 +0,0 @@
package integration.services
import be.vandewalleh.features.Migration
import be.vandewalleh.mainModule
import be.vandewalleh.services.UserService
import kotlinx.coroutines.runBlocking
import org.amshove.kluent.*
import org.junit.jupiter.api.*
import org.kodein.di.DI
import org.kodein.di.bind
import org.kodein.di.instance
import org.kodein.di.singleton
import utils.KMariadbContainer
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
class UserServiceTest {
private val mariadb = KMariadbContainer().apply { start() }
private val kodein = DI {
import(mainModule, allowOverride = true)
bind(overrides = true) from singleton { mariadb.datasource() }
}
private val userService by kodein.instance<UserService>()
init {
val migration by kodein.instance<Migration>()
migration.migrate()
}
@Test
@Order(1)
fun `test create user`() {
runBlocking {
val username = "hubert"
val password = "password"
userService.create(username, password)
val user = userService.find(username)
user `should not be` null
user?.username `should be equal to` username
}
}
@Test
@Order(2)
fun `test create same user`() {
runBlocking {
userService.create(username = "hubert", password = "password") `should be` null
}
}
@Test
@Order(3)
fun `test delete user`() {
runBlocking {
val id = userService.find("hubert")!!.id
userService.delete(id)
userService.find("hubert") `should be` null
userService.find(id) `should be` null
}
}
}
@@ -1,36 +0,0 @@
package unit.validation
import be.vandewalleh.entities.User
import be.vandewalleh.validation.registerValidator
import org.amshove.kluent.*
import org.junit.jupiter.api.*
import utils.firstInvalid
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class RegisterValidationTest {
@Test
fun `valid register test`() {
val violations = registerValidator.validate(
User {
username = "hubert"
password = "definitelyNotMyPassword"
}
)
violations.isValid `should be equal to` true
}
@Test
fun `username too long test`() {
val violations = registerValidator.validate(
User {
username = "6X9iboWmEOWjVjkO328ReTJ1gGPTTmB/ZGgBLhB6EzAJoWkJht8"
password = "definitelyNotMyPassword"
}
)
violations.isValid `should be equal to` false
violations.firstInvalid `should be equal to` "username"
}
}
-19
View File
@@ -1,19 +0,0 @@
package utils
import org.skyscreamer.jsonassert.JSONAssert
infix fun String?.shouldBeEqualToJson(expected: String?) = JSONAssert.assertEquals(expected, this, false)
infix fun String?.`should be equal to json`(expected: String?) = shouldBeEqualToJson(expected)
infix fun String?.shouldStrictlyBeEqualToJson(expected: String?) = JSONAssert.assertEquals(expected, this, true)
infix fun String?.`should strictly be equal to json`(expected: String?) = shouldStrictlyBeEqualToJson(expected)
infix fun String?.shouldNotStrictlyBeEqualToJson(expected: String?) = JSONAssert.assertNotEquals(expected, this, true)
infix fun String?.`should not strictly be equal to json`(expected: String?) = shouldNotStrictlyBeEqualToJson(expected)
infix fun String?.shouldNotBeEqualToJson(expected: String?) = JSONAssert.assertNotEquals(expected, this, false)
infix fun String?.`should not be equal to json`(expected: String?) = shouldNotBeEqualToJson(expected)
-23
View File
@@ -1,23 +0,0 @@
package utils
import org.json.JSONObject
operator fun JSONObject.set(name: String, value: String) {
this.put(name, value)
}
operator fun JSONObject.set(name: String, value: Double) {
this.put(name, value)
}
operator fun JSONObject.set(name: String, value: Long) {
this.put(name, value)
}
operator fun JSONObject.set(name: String, value: Int) {
this.put(name, value)
}
operator fun JSONObject.set(name: String, value: Boolean) {
this.put(name, value)
}
-17
View File
@@ -1,17 +0,0 @@
package utils
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import org.testcontainers.containers.MariaDBContainer
class KMariadbContainer : MariaDBContainer<KMariadbContainer>() {
fun datasource(): HikariDataSource {
val hikariConfig = HikariConfig().apply {
jdbcUrl = this@KMariadbContainer.jdbcUrl
username = this@KMariadbContainer.username
password = this@KMariadbContainer.password
}
return HikariDataSource(hikariConfig)
}
}
-51
View File
@@ -1,51 +0,0 @@
package utils
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.server.testing.*
import org.json.JSONObject
fun TestApplicationRequest.json(block: (JSONObject) -> Unit) {
addHeader(HttpHeaders.ContentType, "application/json")
setBody(JSONObject().apply(block).toString())
}
fun TestApplicationRequest.setToken(token: String) {
addHeader(HttpHeaders.Authorization, "Bearer $token")
}
fun TestApplicationEngine.post(
uri: String,
setup: TestApplicationRequest.() -> Unit = {}
): TestApplicationResponse = handleRequest {
this.uri = uri
this.method = HttpMethod.Post
setup()
}.response
fun TestApplicationEngine.get(
uri: String,
setup: TestApplicationRequest.() -> Unit = {}
): TestApplicationResponse = handleRequest {
this.uri = uri
this.method = HttpMethod.Get
setup()
}.response
fun TestApplicationEngine.delete(
uri: String,
setup: TestApplicationRequest.() -> Unit = {}
): TestApplicationResponse = handleRequest {
this.uri = uri
this.method = HttpMethod.Delete
setup()
}.response
fun TestApplicationEngine.put(
uri: String,
setup: TestApplicationRequest.() -> Unit = {}
): TestApplicationResponse = handleRequest {
this.uri = uri
this.method = HttpMethod.Put
setup()
}.response
-5
View File
@@ -1,5 +0,0 @@
package utils
import org.json.JSONObject
fun JSONObject.keyList(): List<Any?> = keys().asSequence().toList()
-6
View File
@@ -1,6 +0,0 @@
package utils
import am.ik.yavi.core.ConstraintViolations
val ConstraintViolations.firstInvalid: Any?
get() = this.violations().firstOrNull()?.name()
+155
View File
@@ -0,0 +1,155 @@
<project>
<parent>
<artifactId>parent</artifactId>
<groupId>be.simplenotes</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>app</artifactId>
<properties>
<http4k.version>3.258.0</http4k.version>
</properties>
<dependencies>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>persistance</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>search</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>${http4k.version}</version>
</dependency>
<dependency>
<groupId>org.http4k</groupId>
<artifactId>http4k-server-jetty</artifactId>
<version>${http4k.version}</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>
</dependency>
<dependency>
<groupId>org.ocpsoft.prettytime</groupId>
<artifactId>prettytime</artifactId>
<version>4.0.5.Final</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>${http4k.version}</version>
<scope>test</scope>
</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>org.eclipse.jetty:*</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>org.apache.lucene:*</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>org.ocpsoft.prettytime:prettytime</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>
+48
View File
@@ -0,0 +1,48 @@
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
class 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(),
)
}
+23
View File
@@ -0,0 +1,23 @@
package be.simplenotes.app
import org.http4k.server.Http4kServer
import org.slf4j.LoggerFactory
import be.simplenotes.shared.config.ServerConfig as SimpleNotesServerConfig
class Server(
private val config: SimpleNotesServerConfig,
private val http4kServer: Http4kServer,
) {
private val logger = LoggerFactory.getLogger(javaClass)
fun start(): Server {
http4kServer.start()
logger.info("Listening on http://${config.host}:${config.port}")
return this
}
fun stop() {
logger.info("Stopping server")
http4kServer.close()
}
}
+31
View File
@@ -0,0 +1,31 @@
package be.simplenotes.app
import be.simplenotes.app.extensions.addShutdownHook
import be.simplenotes.app.modules.*
import be.simplenotes.domain.domainModule
import be.simplenotes.persistance.migrationModule
import be.simplenotes.persistance.persistanceModule
import be.simplenotes.search.searchModule
import org.koin.core.context.startKoin
import org.koin.core.context.unloadKoinModules
fun main() {
startKoin {
modules(
serverModule,
persistanceModule,
migrationModule,
configModule,
baseModule,
userModule,
noteModule,
settingsModule,
domainModule,
searchModule,
apiModule,
jsonModule
)
}.addShutdownHook()
unloadKoinModules(listOf(migrationModule, configModule))
}
@@ -0,0 +1,77 @@
package be.simplenotes.app.api
import be.simplenotes.app.extensions.json
import be.simplenotes.app.utils.parseSearchTerms
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.usecases.NoteService
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
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.routing.path
import java.util.*
class ApiNoteController(private val noteService: NoteService, private val json: Json) {
fun createNote(request: Request, jwtPayload: JwtPayload): Response {
val content = json.decodeFromString(NoteContent.serializer(), request.bodyString()).content
return noteService.create(jwtPayload.userId, content).fold(
{
Response(BAD_REQUEST)
},
{
Response(OK).json(json.encodeToString(UuidContent.serializer(), UuidContent(it.uuid)))
}
)
}
fun notes(request: Request, jwtPayload: JwtPayload): Response {
val notes = noteService.paginatedNotes(jwtPayload.userId, page = 1).notes
val json = json.encodeToString(ListSerializer(PersistedNoteMetadata.serializer()), notes)
return Response(OK).json(json)
}
fun note(request: Request, jwtPayload: JwtPayload): Response {
val uuid = request.path("uuid")!!
return noteService.find(jwtPayload.userId, UUID.fromString(uuid))
?.let { Response(OK).json(json.encodeToString(PersistedNote.serializer(), it)) }
?: Response(NOT_FOUND)
}
fun update(request: Request, jwtPayload: JwtPayload): Response {
val uuid = UUID.fromString(request.path("uuid")!!)
val content = json.decodeFromString(NoteContent.serializer(), request.bodyString()).content
return noteService.update(jwtPayload.userId, uuid, content).fold({
Response(BAD_REQUEST)
}, {
if (it == null) Response(NOT_FOUND)
else Response(OK)
})
}
fun search(request: Request, jwtPayload: JwtPayload): Response {
val query = json.decodeFromString(SearchContent.serializer(), request.bodyString()).query
val terms = parseSearchTerms(query)
val notes = noteService.search(jwtPayload.userId, terms)
val json = json.encodeToString(ListSerializer(PersistedNoteMetadata.serializer()), notes)
return Response(OK).json(json)
}
}
@Serializable
data class NoteContent(val content: String)
@Serializable
data class UuidContent(@Contextual val uuid: UUID)
@Serializable
data class SearchContent(@Contextual val query: String)
@@ -0,0 +1,26 @@
package be.simplenotes.app.api
import be.simplenotes.app.extensions.json
import be.simplenotes.domain.usecases.UserService
import be.simplenotes.domain.usecases.users.login.LoginForm
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status
class ApiUserController(private val userService: UserService, private val json: Json) {
fun login(request: Request): Response {
val form = json.decodeFromString(LoginForm.serializer(), request.bodyString())
val result = userService.login(form)
return result.fold({
Response(Status.BAD_REQUEST)
}, {
Response(Status.OK).json(json.encodeToString(Token.serializer(), Token(it)))
})
}
}
@Serializable
data class Token(val token: String)
@@ -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))
}
@@ -0,0 +1,152 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect
import be.simplenotes.app.utils.parseSearchTerms
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 tag = request.query("tag")
val (pages, notes) = noteService.paginatedNotes(jwtPayload.userId, currentPage, tag = tag)
val deletedCount = noteService.countDeleted(jwtPayload.userId)
return Response(OK).html(view.notes(jwtPayload, notes, currentPage, pages, deletedCount, tag = tag))
}
fun search(request: Request, jwtPayload: JwtPayload): Response {
val query = request.form("search") ?: ""
val terms = parseSearchTerms(query)
val notes = noteService.search(jwtPayload.userId, terms)
val deletedCount = noteService.countDeleted(jwtPayload.userId)
return Response(OK).html(view.search(jwtPayload, notes, query, deletedCount))
}
fun note(request: Request, jwtPayload: JwtPayload): Response {
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
if (request.method == Method.POST) {
if (request.form("delete") != null) {
return if (noteService.trash(jwtPayload.userId, noteUuid))
Response.redirect("/notes") // TODO: flash cookie to show success ?
else
Response(NOT_FOUND) // TODO: show an error
}
if (request.form("public") != null) {
if (!noteService.makePublic(jwtPayload.userId, noteUuid)) return Response(NOT_FOUND)
} else if (request.form("private") != null) {
if (!noteService.makePrivate(jwtPayload.userId, noteUuid)) return Response(NOT_FOUND)
}
}
val note = noteService.find(jwtPayload.userId, noteUuid) ?: return Response(NOT_FOUND)
return Response(OK).html(view.renderedNote(jwtPayload, note, shared = false))
}
fun public(request: Request, jwtPayload: JwtPayload?): Response {
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
val note = noteService.findPublic(noteUuid) ?: return Response(NOT_FOUND)
return Response(OK).html(view.renderedNote(jwtPayload, note, shared = true))
}
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}")
}
)
}
fun trash(request: Request, jwtPayload: JwtPayload): Response {
val currentPage = request.query("page")?.toIntOrNull()?.let(::abs) ?: 1
val tag = request.query("tag")
val (pages, notes) = noteService.paginatedNotes(jwtPayload.userId, currentPage, tag = tag, deleted = true)
return Response(OK).html(view.trash(jwtPayload, notes, currentPage, pages))
}
fun deleted(request: Request, jwtPayload: JwtPayload): Response {
val uuid = request.uuidPath() ?: return Response(NOT_FOUND)
return if (request.form("delete") != null)
if (noteService.delete(jwtPayload.userId, uuid))
Response.redirect("/notes/trash")
else
Response(NOT_FOUND)
else if (noteService.restore(jwtPayload.userId, uuid))
Response.redirect("/notes/$uuid")
else
Response(NOT_FOUND)
}
private fun Request.uuidPath(): UUID? {
val uuidPath = path("uuid")!!
return try {
UUID.fromString(uuidPath)!!
} catch (e: IllegalArgumentException) {
null
}
}
}
@@ -0,0 +1,75 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect
import be.simplenotes.app.views.SettingView
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.usecases.UserService
import be.simplenotes.domain.usecases.users.delete.DeleteError
import be.simplenotes.domain.usecases.users.delete.DeleteForm
import org.http4k.core.*
import org.http4k.core.body.form
import org.http4k.core.cookie.invalidateCookie
class SettingsController(
private val userService: UserService,
private val settingView: SettingView,
) {
fun settings(request: Request, jwtPayload: JwtPayload): Response {
if (request.method == Method.GET)
return Response(Status.OK).html(settingView.settings(jwtPayload))
val deleteForm = request.deleteForm(jwtPayload)
val result = userService.delete(deleteForm)
return result.fold(
{
when (it) {
DeleteError.Unregistered -> Response.redirect("/").invalidateCookie("Bearer")
DeleteError.WrongPassword -> Response(Status.OK).html(
settingView.settings(
jwtPayload,
error = "Wrong password"
)
)
is DeleteError.InvalidForm -> Response(Status.OK).html(
settingView.settings(
jwtPayload,
validationErrors = it.validationErrors
)
)
}
},
{
Response.redirect("/").invalidateCookie("Bearer")
}
)
}
private fun attachment(filename: String, contentType: String) = { response: Response ->
val name = filename.replace("[^a-zA-Z0-9-_.]".toRegex(), "_")
response
.header("Content-Disposition", "attachment; filename=\"$name\"")
.header("Content-Type", contentType)
}
fun export(request: Request, jwtPayload: JwtPayload): Response {
val isDownload = request.form("download") != null
return if (isDownload) {
val filename = "simplenotes-export-${jwtPayload.username}"
if (request.form("format") == "zip") {
val zip = userService.exportAsZip(jwtPayload.userId)
Response(Status.OK)
.with(attachment("$filename.zip", "application/zip"))
.body(zip)
} else
Response(Status.OK)
.with(attachment("$filename.json", "application/json"))
.body(userService.exportAsJson(jwtPayload.userId))
} else Response(Status.OK).body(userService.exportAsJson(jwtPayload.userId)).header("Content-Type", "application/json")
}
private fun Request.deleteForm(jwtPayload: JwtPayload) =
DeleteForm(jwtPayload.username, form("password"), form("checked") != null)
}
@@ -0,0 +1,113 @@
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.users.login.*
import be.simplenotes.domain.usecases.users.register.InvalidRegisterForm
import be.simplenotes.domain.usecases.users.register.RegisterForm
import be.simplenotes.domain.usecases.users.register.UserExists
import be.simplenotes.shared.config.JwtConfig
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
import java.util.concurrent.TimeUnit
class UserController(
private val userService: UserService,
private val userView: UserView,
private val jwtConfig: JwtConfig,
) {
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("/notes").loginCookie(token, request.isSecure())
}
)
}
private fun Response.loginCookie(token: Token, secure: Boolean): Response {
val validityInSeconds = TimeUnit.SECONDS.convert(jwtConfig.validity, jwtConfig.timeUnit)
return this.cookie(
Cookie(
name = "Bearer",
value = token,
path = "/",
httpOnly = true,
sameSite = SameSite.Lax,
maxAge = validityInSeconds,
secure = secure
)
)
}
fun logout(@Suppress("UNUSED_PARAMETER") request: Request) = Response.redirect("/")
.invalidateCookie("Bearer")
}
@@ -0,0 +1,17 @@
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")
.header("Cache-Control", "no-cache")
fun Response.json(json: String) = body(json).header("Content-Type", "application/json")
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
@@ -0,0 +1,12 @@
package be.simplenotes.app.extensions
import org.koin.core.KoinApplication
import kotlin.concurrent.thread
fun KoinApplication.addShutdownHook() {
Runtime.getRuntime().addShutdownHook(
thread(start = false) {
close()
}
)
}
@@ -0,0 +1,15 @@
package be.simplenotes.app.extensions
import kotlinx.html.*
class SUMMARY(consumer: TagConsumer<*>) :
HTMLTag(
"summary", consumer, emptyMap(),
inlineTag = true,
emptyTag = false
),
HtmlInlineTag
fun DETAILS.summary(block: SUMMARY.() -> Unit = {}) {
SUMMARY(consumer).visit(block)
}
+60
View File
@@ -0,0 +1,60 @@
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.*
import org.http4k.core.Status.Companion.UNAUTHORIZED
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,
private val source: JwtSource = JwtSource.Cookie,
private val redirect: Boolean = true,
) {
operator fun invoke() = Filter { next ->
{
val token = when (source) {
JwtSource.Header -> it.bearerTokenHeader()
JwtSource.Cookie -> it.bearerTokenCookie()
}
val jwtPayload = token?.let { token -> extractor(token) }
when {
jwtPayload != null -> {
ctx[it][authKey] = jwtPayload
next(it)
}
authType == AuthType.Required -> {
if (redirect) Response.redirect("/login")
else Response(UNAUTHORIZED)
}
else -> next(it)
}
}
}
}
fun Request.jwtPayload(ctx: RequestContexts): JwtPayload? = ctx[this][authKey]
enum class JwtSource {
Header, Cookie
}
private fun Request.bearerTokenCookie(): String? = cookie("Bearer")
?.value
?.trim()
private fun Request.bearerTokenHeader(): String? =
header("Authorization")
?.trim()
?.takeIf { it.startsWith("Bearer") }
?.substringAfter("Bearer")
?.trim()
@@ -0,0 +1,33 @@
package be.simplenotes.app.filters
import be.simplenotes.app.extensions.html
import be.simplenotes.app.views.ErrorView
import org.http4k.core.*
import org.slf4j.LoggerFactory
import java.sql.SQLTransientException
class ErrorFilter(private val errorView: ErrorView) {
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)
.html(errorView.error(ErrorView.Type.NotFound))
else response
} catch (e: Exception) {
logger.error(e.stackTraceToString())
if (e is SQLTransientException)
Response(Status.SERVICE_UNAVAILABLE).html(errorView.error(ErrorView.Type.SqlTransientError))
.noCache()
else
Response(Status.INTERNAL_SERVER_ERROR).html(errorView.error(ErrorView.Type.Other)).noCache()
} catch (e: NotImplementedError) {
logger.error(e.stackTraceToString())
Response(Status.NOT_IMPLEMENTED).html(errorView.error(ErrorView.Type.Other)).noCache()
}
}
}
}
@@ -0,0 +1,14 @@
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 { next: HttpHandler ->
{ request: Request ->
next(request).header("Cache-Control", "public, max-age=31536000, immutable")
}
}
}
@@ -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 { 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'")
.header("Referrer-Policy", "no-referrer")
} else response
}
}
}
+23
View File
@@ -0,0 +1,23 @@
package be.simplenotes.app.modules
import be.simplenotes.app.api.ApiNoteController
import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.filters.AuthFilter
import be.simplenotes.app.filters.AuthType
import be.simplenotes.app.filters.JwtSource
import org.koin.core.qualifier.named
import org.koin.dsl.module
val apiModule = module {
single { ApiUserController(get(), get()) }
single { ApiNoteController(get(), get()) }
single(named("apiAuthFilter")) {
AuthFilter(
extractor = get(),
authType = AuthType.Required,
ctx = get(),
source = JwtSource.Header,
redirect = false
)()
}
}
@@ -0,0 +1,11 @@
package be.simplenotes.app.modules
import be.simplenotes.app.Config
import org.koin.dsl.module
val configModule = module {
single { Config() }
single { get<Config>().dataSourceConfig }
single { get<Config>().jwtConfig }
single { get<Config>().serverConfig }
}
@@ -0,0 +1,31 @@
package be.simplenotes.app.modules
import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.SettingsController
import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.views.BaseView
import be.simplenotes.app.views.NoteView
import be.simplenotes.app.views.SettingView
import be.simplenotes.app.views.UserView
import org.koin.dsl.module
val userModule = module {
single { UserController(get(), 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 settingsModule = module {
single { SettingsController(get(), get()) }
single { SettingView(get()) }
}
+24
View File
@@ -0,0 +1,24 @@
package be.simplenotes.app.modules
import be.simplenotes.app.serialization.LocalDateTimeSerializer
import be.simplenotes.app.serialization.UuidSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import org.koin.dsl.module
import java.time.LocalDateTime
import java.util.*
val jsonModule = module {
single {
Json {
prettyPrint = true
serializersModule = get()
}
}
single {
SerializersModule {
contextual(LocalDateTime::class, LocalDateTimeSerializer())
contextual(UUID::class, UuidSerializer())
}
}
}
@@ -0,0 +1,58 @@
package be.simplenotes.app.modules
import be.simplenotes.app.Server
import be.simplenotes.app.filters.AuthFilter
import be.simplenotes.app.filters.AuthType
import be.simplenotes.app.filters.ErrorFilter
import be.simplenotes.app.routes.Router
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.utils.StaticFileResolverImpl
import be.simplenotes.app.views.ErrorView
import be.simplenotes.shared.config.ServerConfig
import org.eclipse.jetty.server.ServerConnector
import org.http4k.core.RequestContexts
import org.http4k.routing.RoutingHttpHandler
import org.http4k.server.ConnectorBuilder
import org.http4k.server.Jetty
import org.http4k.server.asServer
import org.koin.core.qualifier.named
import org.koin.core.qualifier.qualifier
import org.koin.dsl.module
import org.koin.dsl.onClose
import org.http4k.server.ServerConfig as Http4kServerConfig
val serverModule = module {
single(createdAtStart = true) { Server(get(), get()).start() } onClose { it?.stop() }
single { get<RoutingHttpHandler>().asServer(get()) }
single<Http4kServerConfig> {
val config = get<ServerConfig>()
val builder: ConnectorBuilder = { server: org.eclipse.jetty.server.Server ->
ServerConnector(server).apply {
port = config.port
host = config.host
}
}
Jetty(config.port, builder)
}
single<StaticFileResolver> { StaticFileResolverImpl(get()) }
single {
Router(
get(),
get(),
get(),
get(),
get(),
get(),
requiredAuth = get(AuthType.Required.qualifier),
optionalAuth = get(AuthType.Optional.qualifier),
errorFilter = get(named("ErrorFilter")),
apiAuth = get(named("apiAuthFilter")),
get()
)()
}
single { RequestContexts() }
single(AuthType.Optional.qualifier) { AuthFilter(get(), AuthType.Optional, get())() }
single(AuthType.Required.qualifier) { AuthFilter(get(), AuthType.Required, get())() }
single(named("ErrorFilter")) { ErrorFilter(get())() }
single { ErrorView(get()) }
}
+98
View File
@@ -0,0 +1,98 @@
package be.simplenotes.app.routes
import be.simplenotes.app.api.ApiNoteController
import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.SettingsController
import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.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.*
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 settingsController: SettingsController,
private val apiUserController: ApiUserController,
private val apiNoteController: ApiNoteController,
private val requiredAuth: Filter,
private val optionalAuth: Filter,
private val errorFilter: Filter,
private val apiAuth: Filter,
private val contexts: RequestContexts,
) {
operator fun invoke(): RoutingHttpHandler {
val resourceLoader = ResourceLoader.Classpath(("/static"))
val basicRoutes = routes(
ImmutableFilter().then(static(resourceLoader, "woff2" to ContentType("font/woff2"))),
)
infix fun PathMethod.public(handler: PublicHandler) = this to { handler(it, it.jwtPayload(contexts)) }
infix fun PathMethod.protected(handler: ProtectedHandler) = this to { handler(it, it.jwtPayload(contexts)!!) }
val publicRoutes: RoutingHttpHandler = routes(
"/" bind GET public baseController::index,
"/register" bind GET public userController::register,
"/register" bind POST public userController::register,
"/login" bind GET public userController::login,
"/login" bind POST public userController::login,
"/logout" bind POST to userController::logout,
"/notes/public/{uuid}" bind GET public noteController::public,
)
val protectedRoutes = routes(
"/settings" bind GET protected settingsController::settings,
"/settings" bind POST protected settingsController::settings,
"/export" bind POST protected settingsController::export,
"/notes" bind GET protected noteController::list,
"/notes" bind POST protected noteController::search,
"/notes/new" bind GET protected noteController::new,
"/notes/new" bind POST protected noteController::new,
"/notes/trash" bind GET protected noteController::trash,
"/notes/{uuid}" bind GET protected noteController::note,
"/notes/{uuid}" bind POST protected noteController::note,
"/notes/{uuid}/edit" bind GET protected noteController::edit,
"/notes/{uuid}/edit" bind POST protected noteController::edit,
"/notes/deleted/{uuid}" bind POST protected noteController::deleted,
)
val apiRoutes = routes(
"/api/login" bind POST to apiUserController::login,
)
val protectedApiRoutes = routes(
"/api/notes" bind GET protected apiNoteController::notes,
"/api/notes" bind POST protected apiNoteController::createNote,
"/api/notes/search" bind POST protected apiNoteController::search,
"/api/notes/{uuid}" bind GET protected apiNoteController::note,
"/api/notes/{uuid}" bind PUT protected apiNoteController::update,
)
val routes = routes(
basicRoutes,
optionalAuth.then(publicRoutes),
requiredAuth.then(protectedRoutes),
apiAuth.then(protectedApiRoutes),
apiRoutes,
)
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
@@ -0,0 +1,22 @@
package be.simplenotes.app.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.LocalDateTime
internal class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDateTime) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): LocalDateTime {
TODO("Not implemented, isn't needed")
}
}
@@ -0,0 +1,22 @@
package be.simplenotes.app.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.util.*
internal class UuidSerializer : KSerializer<UUID> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UUID) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): UUID {
TODO()
}
}
+10
View File
@@ -0,0 +1,10 @@
package be.simplenotes.app.utils
import org.ocpsoft.prettytime.PrettyTime
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.*
private val prettyTime = PrettyTime()
fun LocalDateTime.toTimeAgo(): String = prettyTime.format(Date.from(atZone(ZoneId.systemDefault()).toInstant()))
@@ -0,0 +1,43 @@
package be.simplenotes.app.utils
import be.simplenotes.domain.usecases.search.SearchTerms
private fun innerRegex(name: String) =
"""$name:['"](.*?)['"]""".toRegex()
private fun outerRegex(name: String) =
"""($name:['"].*?['"])""".toRegex()
private val titleRe = innerRegex("title")
private val outerTitleRe = outerRegex("title")
private val tagRe = innerRegex("tag")
private val outerTagRe = outerRegex("tag")
private val contentRe = innerRegex("content")
private val outerContentRe = outerRegex("content")
fun parseSearchTerms(input: String): SearchTerms {
var c: String = input
fun extract(innerRegex: Regex, outerRegex: Regex): String? {
val match = innerRegex.find(input)?.groups?.get(1)?.value
if (match != null) {
val group = outerRegex.find(input)?.groups?.get(1)?.value
group?.let { c = c.replace(it, "") }
}
return match
}
val title: String? = extract(titleRe, outerTitleRe)
val tag: String? = extract(tagRe, outerTagRe)
val content: String? = extract(contentRe, outerContentRe)
val all = c.trim().ifEmpty { null }
return SearchTerms(
title = title,
tag = tag,
content = content,
all = all
)
}
@@ -0,0 +1,24 @@
package be.simplenotes.app.utils
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
interface StaticFileResolver {
fun resolve(name: String): String?
}
class StaticFileResolverImpl(json: Json) : StaticFileResolver {
private val mappings: Map<String, String>
init {
val manifest = javaClass.getResource("/css-manifest.json").readText()
val manifestObject = json.parseToJsonElement(manifest).jsonObject
val keys = manifestObject.keys
mappings = keys.map {
it to "/${manifestObject[it]!!.jsonPrimitive.content}"
}.toMap()
}
override fun resolve(name: String) = mappings[name]
}
+115
View File
@@ -0,0 +1,115 @@
package be.simplenotes.app.views
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.domain.security.JwtPayload
import kotlinx.html.*
import kotlinx.html.div
import org.intellij.lang.annotations.Language
class BaseView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
fun renderHome(jwtPayload: JwtPayload?) = renderPage(
title = "Home",
description = "A fast and simple note taking website",
jwtPayload = jwtPayload
) {
section("text-center my-2 p-2") {
h1("text-5xl casual") {
span("text-teal-300") { +"Simplenotes " }
+"- access your notes anywhere"
}
}
div("container mx-auto flex flex-wrap justify-center content-center") {
unsafe {
@Language("html")
val html =
"""
<div aria-label="demo" class="md:order-1 order-2 flipped p-4 my-10 w-full md:w-1/2">
<div class="flex justify-between mb-4">
<h1 class="text-2xl underline">Notes</h1>
<span>
<span class="btn btn-teal pointer-events-none">Trash (3)</span>
<span class="ml-2 btn btn-green pointer-events-none">New</span>
</span>
</div>
<form class="md:space-x-2" id="search">
<input aria-label="demo-search" name="search" disabled="" value="tag:&quot;demo&quot;">
<span id="buttons">
<button type="button" disabled="" class="btn btn-green pointer-events-none">search</button>
<span class="btn btn-red pointer-events-none">clear</span>
</span>
</form>
<div class="overflow-x-auto">
<table id="notes">
<thead>
<tr>
<th scope="col" class="w-1/2">Title</th>
<th scope="col" class="w-1/4">Updated</th>
<th scope="col" class="w-1/4">Tags</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="text-blue-200 font-semibold underline">Formula 1</span></td>
<td class="text-center">moments ago</td>
<td>
<ul class="inline flex flex-wrap justify-center">
<li class="mx-2 my-1"><span class="tag disabled">#demo</span ></li>
</ul>
</td>
</tr>
<tr>
<td><span class="text-blue-200 font-semibold underline">Syntax highlighting</span></td>
<td class="text-center">2 hours ago</td>
<td>
<ul class="inline flex flex-wrap justify-center">
<li class="mx-2 my-1"><span class="tag disabled">#features</span></li>
<li class="mx-2 my-1"><span class="tag disabled">#demo</span></li>
</ul>
</td>
</tr>
<tr>
<td><span class="text-blue-200 font-semibold underline">report</span></td>
<td class="text-center">5 days ago</td>
<td>
<ul class="inline flex flex-wrap justify-center">
<li class="mx-2 my-1"><span class="tag disabled">#study</span></li>
<li class="mx-2 my-1"><span class="tag disabled">#demo</span></li>
</ul>
</td>
</tr>
</tbody>
</table>
</div>
</div>
""".trimIndent()
+html
}
welcome()
}
}
@Suppress("NOTHING_TO_INLINE")
private inline fun DIV.welcome() {
div("w-full my-auto md:w-1/2 md:order-2 order-1 text-center") {
div("m-4 rounded-lg p-6") {
p("text-teal-400") {
h2("text-3xl text-teal-400 underline") { +"Features:" }
ul("list-disc text-lg list-inside") {
li { +"Markdown support" }
li { +"Full text search" }
li { +"Structured search" }
li { +"Code highlighting" }
li { +"Fast and lightweight" }
li { +"No tracking" }
li { +"Works without javascript" }
li { +"Data export" }
}
}
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
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 kotlinx.html.a
import kotlinx.html.div
class ErrorView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
enum class Type(val title: String) {
SqlTransientError("Database unavailable"),
NotFound("Not Found"),
Other("Error"),
}
fun error(errorType: Type) = renderPage(errorType.title, jwtPayload = null) {
div("container mx-auto p-4") {
when (errorType) {
Type.SqlTransientError -> alert(
Alert.Warning,
errorType.title,
"Please try again later",
multiline = true
)
Type.NotFound -> alert(Alert.Warning, errorType.title, "Page not found", multiline = true)
Type.Other -> alert(Alert.Warning, errorType.title)
}
div {
a(href = "/", classes = "btn btn-green") { +"Go back to the homepage" }
}
}
}
}
+201
View File
@@ -0,0 +1,201 @@
package be.simplenotes.app.views
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.views.components.*
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") {
error?.let { alert(Alert.Warning, error) }
validationErrors.forEach {
alert(Alert.Warning, it.dataPath.substringAfter('.') + ": " + it.message)
}
form(method = FormMethod.post) {
textArea {
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,
numberOfDeletedNotes: Int,
tag: String?,
) = renderPage(title = "Notes", jwtPayload = jwtPayload) {
div("container mx-auto p-4") {
noteListHeader(numberOfDeletedNotes)
if (notes.isNotEmpty())
noteTable(notes)
else
span {
if (numberOfPages > 1) +"You went too far"
else +"No notes yet"
}
if (numberOfPages > 1) pagination(currentPage, numberOfPages, tag)
}
}
fun search(
jwtPayload: JwtPayload,
notes: List<PersistedNoteMetadata>,
query: String,
numberOfDeletedNotes: Int,
) = renderPage("Notes", jwtPayload = jwtPayload) {
div("container mx-auto p-4") {
noteListHeader(numberOfDeletedNotes, query)
noteTable(notes)
}
}
fun trash(
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") { +"Deleted notes" }
}
if (notes.isNotEmpty())
deletedNoteTable(notes)
else
span {
if (numberOfPages > 1) +"You went too far"
else +"No deleted notes"
}
if (numberOfPages > 1) pagination(currentPage, numberOfPages, null)
}
}
private fun DIV.pagination(currentPage: Int, numberOfPages: Int, tag: String?) {
val links = mutableListOf<Pair<String, String>>()
// if (currentPage > 1) links += "Previous" to "?page=${currentPage - 1}"
links += (1..numberOfPages).map { page ->
"$page" to (tag?.let { "?page=$page&tag=$it" } ?: "?page=$page")
}
// 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, shared: Boolean) = renderPage(
note.meta.title,
jwtPayload = jwtPayload,
scripts = listOf("/highlight.10.1.2.js", "/init-highlight.0.0.1.js")
) {
div("container mx-auto p-4") {
if (shared) {
p("p-4 bg-gray-800") {
+"You are viewing a public note "
}
hr { }
}
div("flex items-center justify-between mb-4") {
h1("text-3xl fond-bold underline") { +note.meta.title }
span("space-x-2") {
note.meta.tags.forEach {
a(href = "/notes?tag=$it", classes = "tag") {
+"#$it"
}
}
}
}
if (!shared) {
noteActionForm(note)
publicPrivateForm(note)
if (note.public) {
p("my-4") {
+"You can share this link : "
a(href = "/notes/public/${note.uuid}", classes = "text-blue-300 underline") {
+"/notes/public/${note.uuid}"
}
}
hr { }
}
}
div {
attributes["id"] = "note"
unsafe {
+note.html
}
}
}
}
private fun DIV.noteActionForm(note: PersistedNote) {
span("flex space-x-2 justify-end mb-4") {
a(
href = "/notes/${note.uuid}/edit",
classes = "btn btn-teal"
) { +"Edit" }
form(method = FormMethod.post, classes = "inline") {
button(
type = ButtonType.submit,
name = "delete",
classes = "btn btn-red"
) { +"Delete" }
}
}
}
private fun DIV.publicPrivateForm(note: PersistedNote) {
span("flex space-x-2 justify-end mb-4") {
form(method = FormMethod.post, classes = "ml-auto ") {
button(
type = ButtonType.submit,
name = if (note.public) "private" else "public",
classes = "btn btn-teal"
) {
if (note.public)
+"This note is public, do you want to make it private ?"
else
+"This note is private, do you want to make it public ?"
}
}
}
}
}
+105
View File
@@ -0,0 +1,105 @@
package be.simplenotes.app.views
import be.simplenotes.app.extensions.summary
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.views.components.Alert
import be.simplenotes.app.views.components.alert
import be.simplenotes.app.views.components.input
import be.simplenotes.domain.security.JwtPayload
import io.konform.validation.ValidationError
import kotlinx.html.*
import kotlinx.html.ButtonType.submit
class SettingView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
fun settings(
jwtPayload: JwtPayload,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
) = renderPage("Settings", jwtPayload = jwtPayload) {
div("container mx-auto") {
section("m-4 p-4 bg-gray-800 rounded") {
h1("text-xl") {
+"Welcome "
span("text-teal-200 font-semibold") { +jwtPayload.username }
}
}
section("m-4 p-4 bg-gray-800 rounded flex justify-around") {
form(method = FormMethod.post, action = "/export") {
button(name = "display",
classes = "inline btn btn-teal block",
type = submit) { +"Display my data" }
}
form(method = FormMethod.post, action = "/export") {
listOf("json", "zip").forEach { format ->
div {
radioInput(name = "format") {
id = format
attributes["value"] = format
if(format == "json") attributes["checked"] = ""
}
label(classes = "ml-2") {
attributes["for"] = format
+format
}
}
}
button(name = "download", classes = "inline btn btn-green block mt-2", type = submit) {
+"Download my data"
}
}
}
section(classes = "m-4 p-4 bg-gray-800 rounded") {
h2(classes = "mb-4 text-red-400 text-lg font-semibold") {
+"Delete my account"
}
error?.let { alert(Alert.Warning, error) }
details {
if (error != null || validationErrors.isNotEmpty()) {
attributes["open"] = ""
}
summary {
span(classes = "mb-4 font-semibold underline") {
+"Are you sure? "
+"You are about to delete this user, and this process is irreversible !"
}
}
form(classes = "mt-4", method = FormMethod.post) {
input(
id = "password",
placeholder = "Password",
autoComplete = "off",
type = InputType.password,
error = validationErrors.find { it.dataPath == ".password" }?.message
)
checkBoxInput(name = "checked") {
id = "checked"
attributes["required"] = ""
label {
attributes["for"] = "checked"
+" Do you want to proceed ?"
}
}
button(
type = submit,
classes = "block mt-4 btn btn-red",
name = "delete"
) { +"I'm sure" }
}
}
}
}
}
}
+84
View File
@@ -0,0 +1,84 @@
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",
"Registration page",
jwtPayload,
error,
validationErrors,
"Create an account",
"Register"
) {
+"Already have an account? "
a(href = "/login", classes = "no-underline text-blue-500 hover:text-blue-400 font-bold") { +"Sign In" }
}
fun login(
jwtPayload: JwtPayload?,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
new: Boolean = false,
) = accountForm("Login", "Login page", jwtPayload, error, validationErrors, "Sign In", "Sign In", new) {
+"Don't have an account yet? "
a(href = "/register", classes = "no-underline text-blue-500 hover:text-blue-400 font-bold") {
+"Create an account"
}
}
private fun accountForm(
title: String,
description: String,
jwtPayload: JwtPayload?,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
h1: String,
submit: String,
new: Boolean = false,
footer: FlowContent.() -> Unit,
) = renderPage(title = title, description, 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") {
div("p-8 mb-6") {
h1("font-semibold text-lg mb-6 text-center") { +h1 }
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()
}
}
}
}
}
}
+45
View File
@@ -0,0 +1,45 @@
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(staticFileResolver: StaticFileResolver) {
private val styles = staticFileResolver.resolve("styles.css")!!
fun renderPage(
title: String,
description: String? = null,
jwtPayload: JwtPayload?,
scripts: List<String> = emptyList(),
body: MAIN.() -> 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 = "preload", href = "/recursive-0.0.1.woff2"){
attributes["as"] = "font"
attributes["type"] = "font/woff2"
attributes["crossorigin"] = "anonymous"
}
link(rel = "stylesheet", href = styles)
link(rel = "shortcut icon", href = "/favicon.ico", type = "image/x-icon")
scripts.forEach { src ->
script(src = src) {}
}
}
body("bg-gray-900 text-white") {
navbar(jwtPayload)
main { body() }
}
}
}
}
@@ -0,0 +1,22 @@
package be.simplenotes.app.views.components
import kotlinx.html.*
fun FlowContent.alert(type: Alert, title: String, details: String? = null, multiline: Boolean = false) {
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 {
if (multiline) p { +details }
else span("block sm:inline") { +details }
}
}
}
enum class Alert {
Success, Warning
}
@@ -0,0 +1,51 @@
package be.simplenotes.app.views.components
import be.simplenotes.app.utils.toTimeAgo
import be.simplenotes.domain.model.PersistedNoteMetadata
import kotlinx.html.*
import kotlinx.html.ButtonType.submit
import kotlinx.html.FormMethod.post
import kotlinx.html.ThScope.col
fun FlowContent.deletedNoteTable(notes: List<PersistedNoteMetadata>) = div("overflow-x-auto") {
table {
id = "notes"
thead {
tr {
th(col, "w-1/4") { +"Title" }
th(col, "w-1/4") { +"Updated" }
th(col, "w-1/4") { +"Tags" }
th(col, "w-1/4") { +"Restore" }
}
}
tbody {
notes.forEach { (title, tags, updatedAt, uuid) ->
tr {
td { +title }
td("text-center") { +updatedAt.toTimeAgo() }
td { tags(tags) }
td("text-center") {
form(classes = "inline", method = post, action = "/notes/deleted/$uuid") {
button(classes = "btn btn-red", type = submit, name = "delete") {
+"Delete permanently"
}
button(classes = "ml-2 btn btn-green", type = submit, name = "restore") {
+"Restore"
}
}
}
}
}
}
}
}
private fun FlowContent.tags(tags: List<String>) {
ul("inline flex flex-wrap justify-center") {
tags.forEach { tag ->
li("mx-2 my-1") {
span("tag disabled") { +"#$tag" }
}
}
}
}

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