Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad97ba029e | |||
| 31f538c7f5 | |||
| c7cf71441f | |||
| b015f3a97e | |||
| b8e9d4e96e | |||
| c5f9a1d6e0 | |||
| 1cb6c731d8 | |||
| f6e33ed3b4 | |||
| 815770303c | |||
| d6ab257473 | |||
| cbc3a017e8 | |||
| d70663b898 | |||
| 4ffa565626 | |||
| ea732325e5 | |||
| f73a9d0b96 | |||
| 5573dd45d6 | |||
| eeae982a71 | |||
| bad5322abd | |||
| 39fe7a5ab6 | |||
| c800d22ccf | |||
| 2ac02688ab | |||
| 7d833e48e1 | |||
| 97ea331c51 | |||
| 4c4ca2dd98 | |||
| 204f2fb002 | |||
| 87a6fdc94a | |||
| fa21c85a13 | |||
| c367d5b613 | |||
| 2c967ebd8c | |||
| 381d935875 | |||
| d575773f73 | |||
| 36600bb1f4 | |||
| b27fd29230 | |||
| c02f7c039a | |||
| 8ba89d3e05 | |||
| 372652d332 | |||
| f12947acbd | |||
| 7305fb47c7 | |||
| a440199006 | |||
| 1432fbb395 | |||
| 08c804ccb5 | |||
| 68109f8666 | |||
| 315a01ea18 | |||
| 12619f6550 | |||
| ab3766b8b8 | |||
| 3861fb6b97 | |||
| 88b6eb56ae | |||
| 8ccd7f6058 | |||
| a98d6e8e64 | |||
| ab7cfd8147 | |||
| 5d9ca85b22 | |||
| 15de81394c | |||
| 4e2fe463e0 | |||
| 0e72547e95 | |||
| 845ca2acb8 | |||
| 25e29afcbb | |||
| 5295e32d86 | |||
| 8021814c31 | |||
| b5beca8661 | |||
| 56e742e39f | |||
| 48897c8b90 | |||
| 9d9ec013f5 | |||
| 01ba7cbd7d | |||
| 4984e488ae | |||
| 662d6c706b | |||
| 00dafe1da9 | |||
| a9bbfcf82c | |||
| 4c38512038 | |||
| fb49d45677 | |||
| 90f6709885 | |||
| b90df61020 | |||
| 1b79635ffa | |||
| 934820274b | |||
| 24ac5cf4fc | |||
| 29e445ff41 | |||
| e65a4e10d6 | |||
| 24aabd494e | |||
| b41b2103f0 | |||
| a11450cbcf | |||
| d419b4c72a | |||
| 9216696b1a | |||
| cdfe1d14ef | |||
| fda355a690 | |||
| fc883373d0 | |||
| 50020c2f91 | |||
| 44b463d9d5 |
@@ -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
@@ -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__
|
||||
|
||||
@@ -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
@@ -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", "-Xms256m", "-Xmx1g", "-XX:+UseG1GC", "-XX:MaxGCPauseMillis=100", "-XX:+UseStringDeduplication", "-jar", "app.jar"]
|
||||
@@ -1,28 +0,0 @@
|
||||
FROM maven:3.6.3-jdk-14 as builder
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
# Cache dependencies
|
||||
COPY 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"]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,8 +0,0 @@
|
||||
FORMAT: 1A
|
||||
HOST: http://localhost:5000
|
||||
|
||||
# Notes API
|
||||
|
||||
<!-- include(./users/index.apib) -->
|
||||
<!-- include(./notes/index.apib) -->
|
||||
<!-- include(./tags/index.apib) -->
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -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
@@ -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
@@ -1 +0,0 @@
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.5.2/apache-maven-3.5.2-bin.zip
|
||||
@@ -1,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 "$@"
|
||||
Vendored
-161
@@ -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
@@ -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>
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package be.vandewalleh.factories
|
||||
|
||||
import me.liuwj.ktorm.database.*
|
||||
import javax.sql.DataSource
|
||||
|
||||
fun databaseFactory(dataSource: DataSource) = Database.connect(dataSource)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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("??")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
package utils
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
fun JSONObject.keyList(): List<Any?> = keys().asSequence().toList()
|
||||
@@ -1,6 +0,0 @@
|
||||
package utils
|
||||
|
||||
import am.ik.yavi.core.ConstraintViolations
|
||||
|
||||
val ConstraintViolations.firstInvalid: Any?
|
||||
get() = this.violations().firstOrNull()?.name()
|
||||
+155
@@ -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>
|
||||
@@ -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
|
||||
|
||||
object Config {
|
||||
//region Config loading
|
||||
private val properties: Properties = javaClass
|
||||
.getResource("/application.properties")
|
||||
.openStream()
|
||||
.use {
|
||||
Properties().apply { load(it) }
|
||||
}
|
||||
|
||||
private val env = System.getenv()
|
||||
|
||||
private fun value(key: String): String =
|
||||
env[key.toUpperCase().replace(".", "_")]
|
||||
?: properties.getProperty(key)
|
||||
?: error("Missing config key $key")
|
||||
//endregion
|
||||
|
||||
val jwtConfig
|
||||
get() = JwtConfig(
|
||||
secret = value("jwt.secret"),
|
||||
validity = value("jwt.validity").toLong(),
|
||||
timeUnit = TimeUnit.HOURS,
|
||||
)
|
||||
|
||||
val dataSourceConfig
|
||||
get() = DataSourceConfig(
|
||||
jdbcUrl = value("jdbcUrl"),
|
||||
driverClassName = value("driverClassName"),
|
||||
username = value("username"),
|
||||
password = value("password"),
|
||||
maximumPoolSize = value("maximumPoolSize").toInt(),
|
||||
connectionTimeout = value("connectionTimeout").toLong()
|
||||
)
|
||||
|
||||
val serverConfig
|
||||
get() = ServerConfig(
|
||||
host = value("host"),
|
||||
port = value("port").toInt(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package be.simplenotes.app
|
||||
|
||||
import org.eclipse.jetty.server.Server
|
||||
import org.eclipse.jetty.server.ServerConnector
|
||||
import org.http4k.routing.RoutingHttpHandler
|
||||
import org.http4k.server.ConnectorBuilder
|
||||
import org.http4k.server.Jetty
|
||||
import org.http4k.server.ServerConfig
|
||||
import org.http4k.server.asServer
|
||||
import org.slf4j.LoggerFactory
|
||||
import be.simplenotes.shared.config.ServerConfig as SimpleNotesServeConfig
|
||||
|
||||
class Server(
|
||||
private val config: SimpleNotesServeConfig,
|
||||
private val serverConfig: ServerConfig,
|
||||
private val router: RoutingHttpHandler,
|
||||
) {
|
||||
fun start() {
|
||||
router.asServer(serverConfig).start()
|
||||
LoggerFactory.getLogger(javaClass).info("Listening on http://${config.host}:${config.port}")
|
||||
}
|
||||
}
|
||||
|
||||
fun serverConfig(config: SimpleNotesServeConfig): ServerConfig {
|
||||
val builder: ConnectorBuilder = { server: Server ->
|
||||
ServerConnector(server).apply {
|
||||
port = config.port
|
||||
host = config.host
|
||||
}
|
||||
}
|
||||
return Jetty(config.port, builder)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package be.simplenotes.app
|
||||
|
||||
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.AuthFilter
|
||||
import be.simplenotes.app.filters.AuthType
|
||||
import be.simplenotes.app.filters.ErrorFilter
|
||||
import be.simplenotes.app.filters.JwtSource
|
||||
import be.simplenotes.app.routes.Router
|
||||
import be.simplenotes.app.utils.StaticFileResolver
|
||||
import be.simplenotes.app.utils.StaticFileResolverImpl
|
||||
import be.simplenotes.app.views.*
|
||||
import be.simplenotes.domain.domainModule
|
||||
import be.simplenotes.domain.usecases.NoteService
|
||||
import be.simplenotes.persistance.DbMigrations
|
||||
import be.simplenotes.persistance.persistanceModule
|
||||
import be.simplenotes.search.searchModule
|
||||
import be.simplenotes.shared.config.DataSourceConfig
|
||||
import be.simplenotes.shared.config.JwtConfig
|
||||
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 kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import org.http4k.core.RequestContexts
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.core.qualifier.qualifier
|
||||
import org.koin.dsl.module
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.time.LocalDateTime
|
||||
import java.util.*
|
||||
import be.simplenotes.shared.config.ServerConfig as SimpleNotesServeConfig
|
||||
|
||||
fun main() {
|
||||
val koin = startKoin {
|
||||
modules(
|
||||
persistanceModule,
|
||||
configModule,
|
||||
domainModule,
|
||||
serverModule,
|
||||
userModule,
|
||||
baseModule,
|
||||
noteModule,
|
||||
settingsModule,
|
||||
searchModule,
|
||||
apiModule,
|
||||
)
|
||||
}.koin
|
||||
|
||||
val dataSourceConfig = koin.get<DataSourceConfig>()
|
||||
val jwtConfig = koin.get<JwtConfig>()
|
||||
val serverConfig = koin.get<SimpleNotesServeConfig>()
|
||||
val logger = LoggerFactory.getLogger("SimpleNotes")
|
||||
logger.info("datasource: $dataSourceConfig")
|
||||
logger.info("jwt: $jwtConfig")
|
||||
logger.info("server: $serverConfig")
|
||||
|
||||
val migrations = koin.get<DbMigrations>()
|
||||
migrations.migrate()
|
||||
|
||||
val noteService = koin.get<NoteService>()
|
||||
noteService.dropAllIndexes()
|
||||
noteService.indexAll()
|
||||
|
||||
koin.get<Server>().start()
|
||||
}
|
||||
|
||||
val serverModule = module {
|
||||
single { Server(get(), get(), get()) }
|
||||
single<StaticFileResolver> { StaticFileResolverImpl() }
|
||||
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 { serverConfig(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()) }
|
||||
}
|
||||
|
||||
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()) }
|
||||
}
|
||||
|
||||
val configModule = module {
|
||||
single { Config.dataSourceConfig }
|
||||
single { Config.jwtConfig }
|
||||
single { Config.serverConfig }
|
||||
}
|
||||
|
||||
val apiModule = module {
|
||||
single { ApiUserController(get(), get()) }
|
||||
single { ApiNoteController(get(), get()) }
|
||||
single {
|
||||
Json {
|
||||
prettyPrint = true
|
||||
serializersModule = get()
|
||||
}
|
||||
}
|
||||
single {
|
||||
SerializersModule {
|
||||
contextual(LocalDateTime::class, LocalDateTimeSerializer)
|
||||
contextual(UUID::class, UuidSerializer)
|
||||
}
|
||||
}
|
||||
single(named("apiAuthFilter")) {
|
||||
AuthFilter(
|
||||
extractor = get(),
|
||||
authType = AuthType.Required,
|
||||
ctx = get(),
|
||||
source = JwtSource.Header,
|
||||
redirect = false
|
||||
)()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
internal object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
|
||||
override val descriptor: SerialDescriptor
|
||||
get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: LocalDateTime) {
|
||||
encoder.encodeString(value.toString())
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): LocalDateTime {
|
||||
TODO("Not implemented, isn't needed")
|
||||
}
|
||||
}
|
||||
|
||||
internal object 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,67 @@
|
||||
package be.simplenotes.app.controllers
|
||||
|
||||
import be.simplenotes.app.extensions.html
|
||||
import be.simplenotes.app.extensions.redirect
|
||||
import be.simplenotes.app.views.SettingView
|
||||
import be.simplenotes.domain.security.JwtPayload
|
||||
import be.simplenotes.domain.usecases.UserService
|
||||
import be.simplenotes.domain.usecases.users.delete.DeleteError
|
||||
import be.simplenotes.domain.usecases.users.delete.DeleteForm
|
||||
import org.http4k.core.Method
|
||||
import org.http4k.core.Request
|
||||
import org.http4k.core.Response
|
||||
import org.http4k.core.Status
|
||||
import org.http4k.core.body.form
|
||||
import org.http4k.core.cookie.invalidateCookie
|
||||
|
||||
class SettingsController(
|
||||
private val userService: UserService,
|
||||
private val settingView: SettingView,
|
||||
) {
|
||||
fun settings(request: Request, jwtPayload: JwtPayload): Response {
|
||||
if (request.method == Method.GET)
|
||||
return Response(Status.OK).html(settingView.settings(jwtPayload))
|
||||
|
||||
val deleteForm = request.deleteForm(jwtPayload)
|
||||
val result = userService.delete(deleteForm)
|
||||
|
||||
return result.fold(
|
||||
{
|
||||
when (it) {
|
||||
DeleteError.Unregistered -> Response.redirect("/").invalidateCookie("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")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun export(request: Request, jwtPayload: JwtPayload): Response {
|
||||
val isDownload = request.form("download") != null
|
||||
val json = userService.export(jwtPayload.userId)
|
||||
val res = Response(Status.OK).body(json).header("Content-Type", "application/json")
|
||||
return if (isDownload) res.header(
|
||||
"Content-Disposition",
|
||||
"attachment; filename=\"simplenotes-export-${sanitizeFilename(jwtPayload.username)}.json\""
|
||||
)
|
||||
else res
|
||||
}
|
||||
|
||||
private fun sanitizeFilename(inputName: String): String = inputName.replace("[^a-zA-Z0-9-_.]".toRegex(), "_")
|
||||
|
||||
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,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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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,23 @@
|
||||
package be.simplenotes.app.utils
|
||||
|
||||
import kotlinx.serialization.json.*
|
||||
|
||||
interface StaticFileResolver {
|
||||
fun resolve(name: String): String?
|
||||
}
|
||||
|
||||
class StaticFileResolverImpl : StaticFileResolver {
|
||||
private val mappings: Map<String, String>
|
||||
|
||||
init {
|
||||
val json = Json {}
|
||||
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]
|
||||
}
|
||||
@@ -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:"demo"">
|
||||
<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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package be.simplenotes.app.views
|
||||
|
||||
import be.simplenotes.app.extensions.summary
|
||||
import be.simplenotes.app.utils.StaticFileResolver
|
||||
import be.simplenotes.app.views.components.Alert
|
||||
import be.simplenotes.app.views.components.alert
|
||||
import be.simplenotes.app.views.components.input
|
||||
import be.simplenotes.domain.security.JwtPayload
|
||||
import io.konform.validation.ValidationError
|
||||
import kotlinx.html.*
|
||||
import kotlinx.html.ButtonType.submit
|
||||
|
||||
class SettingView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
|
||||
|
||||
fun settings(
|
||||
jwtPayload: JwtPayload,
|
||||
error: String? = null,
|
||||
validationErrors: List<ValidationError> = emptyList(),
|
||||
) = renderPage("Settings", jwtPayload = jwtPayload) {
|
||||
div("container mx-auto") {
|
||||
|
||||
section("m-4 p-4 bg-gray-800 rounded") {
|
||||
h1("text-xl") {
|
||||
+"Welcome "
|
||||
span("text-teal-200 font-semibold") { +jwtPayload.username }
|
||||
}
|
||||
}
|
||||
|
||||
section("m-4 p-4 bg-gray-800 rounded") {
|
||||
p(classes = "mb-4") {
|
||||
+"Export all my data"
|
||||
}
|
||||
|
||||
form(method = FormMethod.post, action = "/export") {
|
||||
button(name = "display", classes = "inline btn btn-teal block", type = submit) { +"Display my data" }
|
||||
button(name = "download", classes = "inline btn btn-green block ml-2 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package be.simplenotes.app.views.components
|
||||
|
||||
import kotlinx.html.*
|
||||
import kotlinx.html.ButtonType.submit
|
||||
|
||||
fun FlowContent.input(
|
||||
type: InputType = InputType.text,
|
||||
placeholder: String,
|
||||
id: String,
|
||||
autoComplete: String? = null,
|
||||
error: String? = null
|
||||
) {
|
||||
val colors = "bg-gray-800 border-gray-700 focus:border-teal-500 text-white"
|
||||
div("mb-8") {
|
||||
input(
|
||||
type = type,
|
||||
classes = "$colors rounded w-full border appearance-none focus:outline-none text-base p-2"
|
||||
) {
|
||||
attributes["placeholder"] = placeholder
|
||||
attributes["aria-label"] = placeholder
|
||||
attributes["name"] = id
|
||||
attributes["id"] = id
|
||||
autoComplete?.let { attributes["autocomplete"] = it }
|
||||
}
|
||||
error?.let { p("mt-2 text-red-500 text-sm italic") { +"$placeholder $error" } }
|
||||
}
|
||||
}
|
||||
|
||||
fun FlowContent.submitButton(text: String) {
|
||||
div("flex items-center mt-6") {
|
||||
button(
|
||||
type = submit,
|
||||
classes = "btn btn-teal w-full"
|
||||
) { +text }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package be.simplenotes.app.views.components
|
||||
|
||||
import be.simplenotes.domain.security.JwtPayload
|
||||
import kotlinx.html.*
|
||||
|
||||
fun BODY.navbar(jwtPayload: JwtPayload?) {
|
||||
nav {
|
||||
id = "navbar"
|
||||
a("/") {
|
||||
id = "home"
|
||||
+"SimpleNotes"
|
||||
}
|
||||
ul("space-x-2") {
|
||||
id = "navigation"
|
||||
if (jwtPayload != null) {
|
||||
val links = listOf(
|
||||
"/notes" to "Notes",
|
||||
"/settings" to "Settings",
|
||||
)
|
||||
links.forEach { (href, name) ->
|
||||
li("txt") {
|
||||
a(href = href) { +name }
|
||||
}
|
||||
}
|
||||
li {
|
||||
form(action = "/logout", method = FormMethod.post) {
|
||||
button(type = ButtonType.submit, classes = "btn btn-green") { +"Logout" }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
li {
|
||||
a(href = "/login", classes = "btn btn-green") { +"Sign In" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package be.simplenotes.app.views.components
|
||||
|
||||
import kotlinx.html.*
|
||||
import kotlinx.html.ButtonType.submit
|
||||
import kotlinx.html.FormMethod.post
|
||||
|
||||
fun DIV.noteListHeader(numberOfDeletedNotes: Int, query: String = "") {
|
||||
div("flex justify-between mb-4") {
|
||||
h1("text-2xl underline") { +"Notes" }
|
||||
span {
|
||||
a(
|
||||
href = "/notes/trash",
|
||||
classes = "btn btn-teal"
|
||||
) { +"Trash ($numberOfDeletedNotes)" }
|
||||
a(
|
||||
href = "/notes/new",
|
||||
classes = "ml-2 btn btn-green"
|
||||
) { +"New" }
|
||||
}
|
||||
}
|
||||
form(method = post, classes = "md:space-x-2") {
|
||||
id = "search"
|
||||
input(name = "search") {
|
||||
attributes["value"] = query
|
||||
attributes["aria-label"] = "search"
|
||||
}
|
||||
span {
|
||||
id = "buttons"
|
||||
button(type = submit, classes = "btn btn-green") {
|
||||
+"search"
|
||||
}
|
||||
a(href = "/notes", classes = "btn btn-red") {
|
||||
+"clear"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package be.simplenotes.app.views.components
|
||||
|
||||
import be.simplenotes.app.utils.toTimeAgo
|
||||
import be.simplenotes.domain.model.PersistedNoteMetadata
|
||||
import kotlinx.html.*
|
||||
import kotlinx.html.ThScope.col
|
||||
|
||||
fun FlowContent.noteTable(notes: List<PersistedNoteMetadata>) = div("overflow-x-auto") {
|
||||
table {
|
||||
id = "notes"
|
||||
thead {
|
||||
tr {
|
||||
th(col, "w-1/2") { +"Title" }
|
||||
th(col, "w-1/4") { +"Updated" }
|
||||
th(col, "w-1/4") { +"Tags" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
notes.forEach { (title, tags, updatedAt, uuid) ->
|
||||
tr {
|
||||
td {
|
||||
a(classes = "text-blue-200 font-semibold underline", href = "/notes/$uuid") { +title }
|
||||
}
|
||||
td("text-center") {
|
||||
+updatedAt.toTimeAgo()
|
||||
}
|
||||
td { tags(tags) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun FlowContent.tags(tags: List<String>) {
|
||||
ul("inline flex flex-wrap justify-center") {
|
||||
tags.forEach { tag ->
|
||||
li("mx-2 my-1") {
|
||||
a(href = "?tag=$tag", classes = "tag") { +"#$tag" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
host=localhost
|
||||
port=8080
|
||||
#
|
||||
jdbcUrl=jdbc:h2:./notes-db;
|
||||
driverClassName=org.h2.Driver
|
||||
username=h2
|
||||
password=
|
||||
maximumPoolSize=10
|
||||
connectionTimeout=3000
|
||||
#
|
||||
jwt.secret=PliLvfk7l4WF+cZJk66LR5Mpnh+ocbvJ2wfUCK2UCms=
|
||||
jwt.validity=24
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('#note pre code').forEach((b) => {
|
||||
hljs.highlightBlock(b);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user