Compare commits
2 Commits
master
..
94b61f3de5
| Author | SHA1 | Date | |
|---|---|---|---|
| 94b61f3de5 | |||
| dcc70aab3a |
@@ -13,6 +13,5 @@ insert_final_newline = true
|
|||||||
indent_size = 4
|
indent_size = 4
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
max_line_length = 120
|
max_line_length = 120
|
||||||
ktlint_standard_no-wildcard-imports = disabled
|
disabled_rules = no-wildcard-imports
|
||||||
ktlint_standard_import-ordering = disabled
|
kotlin_imports_layout = idea
|
||||||
ktlint_standard_multiline-if-else = disabled
|
|
||||||
|
|||||||
@@ -1 +1,10 @@
|
|||||||
|
## can be generated with `openssl rand -base64 32`
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
|
#
|
||||||
|
## 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=
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
# Gradle
|
# Java
|
||||||
build/
|
.mtj.tmp/
|
||||||
.gradle
|
*.class
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
*.ear
|
||||||
|
*.nar
|
||||||
|
hs_err_pid*
|
||||||
|
|
||||||
|
# Maven
|
||||||
|
target/
|
||||||
|
pom.xml.tag
|
||||||
|
pom.xml.releaseBackup
|
||||||
|
pom.xml.versionsBackup
|
||||||
|
pom.xml.next
|
||||||
|
pom.xml.bak
|
||||||
|
release.properties
|
||||||
|
dependency-reduced-pom.xml
|
||||||
|
buildNumber.properties
|
||||||
|
.mvn/timing.properties
|
||||||
|
.mvn/wrapper/maven-wrapper.jar
|
||||||
|
|
||||||
# IntelliJ
|
# IntelliJ
|
||||||
out/
|
out/
|
||||||
@@ -10,8 +28,11 @@ out/
|
|||||||
*.ipr
|
*.ipr
|
||||||
*.iws
|
*.iws
|
||||||
|
|
||||||
|
# Vue
|
||||||
|
node_modules
|
||||||
|
/dist
|
||||||
|
|
||||||
# Local env files
|
# Local env files
|
||||||
.env
|
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
@@ -28,13 +49,85 @@ pids
|
|||||||
*.seed
|
*.seed
|
||||||
*.pid.lock
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
# Dependency directories
|
# Dependency directories
|
||||||
node_modules/
|
node_modules/
|
||||||
jspm_packages/
|
jspm_packages/
|
||||||
|
|
||||||
|
# TypeScript v1 declaration files
|
||||||
|
typings/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
# Yarn Integrity file
|
# Yarn Integrity file
|
||||||
.yarn-integrity
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
# nuxt.js build output
|
||||||
|
.nuxt
|
||||||
|
|
||||||
|
# Nuxt generate
|
||||||
|
dist
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless
|
||||||
|
|
||||||
|
# IDE / Editor
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Service worker
|
||||||
|
sw.*
|
||||||
|
*.private.env.json
|
||||||
|
|
||||||
|
# Certificates
|
||||||
|
data/
|
||||||
|
letsencrypt/
|
||||||
|
|
||||||
|
# generated resources
|
||||||
|
app/src/main/resources/css-manifest.json
|
||||||
|
app/src/main/resources/static/styles*
|
||||||
|
|
||||||
# h2 db
|
# h2 db
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
@@ -43,4 +136,3 @@ jspm_packages/
|
|||||||
|
|
||||||
# python
|
# python
|
||||||
__pycache__
|
__pycache__
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
# Enable auto-env through the sdkman_auto_env config
|
|
||||||
# Add key=value pairs of SDKs to use below
|
|
||||||
java=15-open
|
|
||||||
gradle=6.8-rc-1
|
|
||||||
kotlin=1.4.20
|
|
||||||
@@ -1,8 +1,30 @@
|
|||||||
FROM eclipse-temurin:19-alpine as jdkbuilder
|
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
|
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,jdk.zipfs
|
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 jlink --output /myjdk --module-path $JAVA_HOME/jmods --add-modules $MODULES --no-header-files --no-man-pages --strip-debug --compress=2
|
||||||
|
|
||||||
@@ -10,30 +32,16 @@ RUN strip -p --strip-unneeded /myjdk/lib/server/libjvm.so
|
|||||||
|
|
||||||
FROM alpine
|
FROM alpine
|
||||||
|
|
||||||
RUN apk add --no-cache curl
|
ENV APPLICATION_USER simplenotes
|
||||||
|
RUN adduser -D -g '' $APPLICATION_USER
|
||||||
|
|
||||||
RUN mkdir /app
|
RUN mkdir /app
|
||||||
RUN mkdir /app/data
|
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
|
COPY --from=jdkbuilder /myjdk /myjdk
|
||||||
COPY app/build/libs/app-with-dependencies*.jar /app/simplenotes.jar
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
VOLUME /app/data
|
CMD ["/myjdk/bin/java", "-server", "-XX:+UnlockExperimentalVMOptions", "-Xms64m", "-Xmx256m", "-XX:+UseG1GC", "-XX:MaxGCPauseMillis=100", "-XX:+UseStringDeduplication", "-jar", "app.jar"]
|
||||||
|
|
||||||
ENV SERVER_HOST 0.0.0.0
|
|
||||||
|
|
||||||
CMD [ \
|
|
||||||
"/myjdk/bin/java", \
|
|
||||||
"--add-opens", \
|
|
||||||
"java.base/java.nio=ALL-UNNAMED", \
|
|
||||||
"-server", \
|
|
||||||
"-XX:+UnlockExperimentalVMOptions", \
|
|
||||||
"-Xms64m", \
|
|
||||||
"-Xmx256m", \
|
|
||||||
"-XX:+UseG1GC", \
|
|
||||||
"-XX:MaxGCPauseMillis=100", \
|
|
||||||
"-XX:+UseStringDeduplication", \
|
|
||||||
"-jar", \
|
|
||||||
"simplenotes.jar" \
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -15,4 +15,5 @@
|
|||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The app is configured with environments variables.
|
The app is configured with environments variables.
|
||||||
If no match is found within the env, a default value is read from a yaml file in simplenotes-app/src/main/resources/application.yaml.
|
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,33 +0,0 @@
|
|||||||
import be.simplenotes.micronaut
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id("be.simplenotes.base")
|
|
||||||
id("be.simplenotes.kotlinx-serialization")
|
|
||||||
id("be.simplenotes.app-shadow")
|
|
||||||
id("be.simplenotes.docker")
|
|
||||||
id("be.simplenotes.micronaut")
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(project(":domain"))
|
|
||||||
implementation(project(":views"))
|
|
||||||
implementation(project(":css"))
|
|
||||||
|
|
||||||
implementation(libs.http4k.core)
|
|
||||||
implementation(libs.http4k.multipart)
|
|
||||||
implementation(libs.bundles.jetty)
|
|
||||||
implementation(libs.kotlinx.serialization.json)
|
|
||||||
|
|
||||||
implementation(libs.slf4j.api)
|
|
||||||
runtimeOnly(libs.slf4j.logback)
|
|
||||||
|
|
||||||
micronaut()
|
|
||||||
|
|
||||||
testImplementation(libs.bundles.test)
|
|
||||||
testImplementation(libs.http4k.testing.hamkrest)
|
|
||||||
}
|
|
||||||
|
|
||||||
docker {
|
|
||||||
image = "hubv/simplenotes"
|
|
||||||
tag = "latest"
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<browserconfig>
|
|
||||||
<msapplication>
|
|
||||||
<tile>
|
|
||||||
<square150x150logo src="/mstile-150x150.png"/>
|
|
||||||
<TileColor>#00aba9</TileColor>
|
|
||||||
</tile>
|
|
||||||
</msapplication>
|
|
||||||
</browserconfig>
|
|
||||||
|
Before Width: | Height: | Size: 814 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,33 +0,0 @@
|
|||||||
<?xml version="1.0" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
|
||||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
|
||||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
|
|
||||||
preserveAspectRatio="xMidYMid meet">
|
|
||||||
<metadata>
|
|
||||||
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
|
||||||
</metadata>
|
|
||||||
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
|
|
||||||
fill="#000000" stroke="none">
|
|
||||||
<path d="M1255 6993 c-179 -23 -313 -62 -461 -133 -396 -187 -665 -533 -766
|
|
||||||
-981 l-23 -104 0 -2275 0 -2275 23 -102 c125 -565 530 -970 1095 -1095 l102
|
|
||||||
-23 2275 0 2275 0 102 23 c565 125 970 530 1095 1095 l23 102 0 2275 0 2275
|
|
||||||
-23 102 c-125 566 -521 964 -1090 1095 l-97 22 -2250 2 c-1237 1 -2263 -1
|
|
||||||
-2280 -3z m1024 -1979 c128 -18 287 -70 394 -127 262 -139 448 -395 472 -649
|
|
||||||
3 -34 8 -78 11 -95 l5 -33 -255 0 -254 0 -6 28 c-2 15 -7 44 -11 65 -27 168
|
|
||||||
-204 335 -416 393 -94 26 -317 24 -421 -4 -218 -59 -345 -196 -356 -384 -6
|
|
||||||
-105 16 -173 80 -243 81 -88 227 -148 563 -230 509 -123 742 -228 916 -412
|
|
||||||
123 -131 170 -248 176 -448 9 -245 -55 -420 -212 -580 -162 -165 -387 -270
|
|
||||||
-667 -311 -154 -22 -461 -15 -595 15 -280 62 -513 193 -662 373 -72 85 -162
|
|
||||||
262 -185 358 -8 36 -18 95 -22 133 l-6 67 254 0 255 0 6 -53 c22 -179 135
|
|
||||||
-332 310 -417 127 -61 195 -74 387 -75 195 0 279 16 399 76 179 89 260 234
|
|
||||||
231 415 -22 137 -98 231 -243 302 -109 55 -202 84 -447 142 -237 57 -306 76
|
|
||||||
-427 120 -340 125 -535 303 -600 550 -23 87 -23 271 0 360 87 335 409 595 827
|
|
||||||
665 108 19 371 18 499 -1z m3170 0 c202 -35 325 -95 453 -224 77 -77 93 -100
|
|
||||||
141 -201 30 -63 63 -143 72 -179 49 -183 48 -159 52 -1307 l4 -1083 -256 0
|
|
||||||
-255 0 0 1034 c0 1160 1 1133 -70 1282 -92 192 -259 271 -548 262 -117 -4
|
|
||||||
-149 -9 -221 -33 -177 -60 -340 -200 -440 -376 l-31 -56 0 -1056 0 -1057 -255
|
|
||||||
0 -255 0 0 1480 0 1480 239 0 238 0 6 -82 c4 -46 7 -120 7 -165 0 -45 3 -88 6
|
|
||||||
-97 4 -11 28 8 92 72 163 164 356 267 575 307 103 19 334 18 446 -1z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "SimpleNotes",
|
|
||||||
"short_name": "SimpleNotes",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/android-chrome-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/android-chrome-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"theme_color": "#ffffff",
|
|
||||||
"background_color": "#ffffff",
|
|
||||||
"display": "standalone"
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package be.simplenotes.app
|
|
||||||
|
|
||||||
import io.micronaut.context.ApplicationContext
|
|
||||||
import java.lang.Runtime.getRuntime
|
|
||||||
|
|
||||||
fun main() {
|
|
||||||
val env = if (System.getenv("ENV") == "dev") "dev" else "prod"
|
|
||||||
val ctx = ApplicationContext.builder()
|
|
||||||
.deduceEnvironment(false)
|
|
||||||
.environments(env)
|
|
||||||
.start()
|
|
||||||
ctx.createBean(Server::class.java)
|
|
||||||
getRuntime().addShutdownHook(Thread { ctx.stop() })
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package be.simplenotes.app.api
|
|
||||||
|
|
||||||
import be.simplenotes.app.extensions.auto
|
|
||||||
import be.simplenotes.domain.NoteService
|
|
||||||
import be.simplenotes.types.LoggedInUser
|
|
||||||
import be.simplenotes.types.PersistedNote
|
|
||||||
import be.simplenotes.types.PersistedNoteMetadata
|
|
||||||
import kotlinx.serialization.Contextual
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
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.lens.Path
|
|
||||||
import org.http4k.lens.uuid
|
|
||||||
import java.util.*
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class ApiNoteController(
|
|
||||||
json: Json,
|
|
||||||
private val noteService: NoteService,
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun createNote(request: Request, loggedInUser: LoggedInUser): Response {
|
|
||||||
val content = noteContentLens(request)
|
|
||||||
return noteService.create(loggedInUser, content).fold(
|
|
||||||
{ Response(BAD_REQUEST) },
|
|
||||||
{ uuidContentLens(UuidContent(it.uuid), Response(OK)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun notes(@Suppress("UNUSED_PARAMETER") request: Request, loggedInUser: LoggedInUser): Response {
|
|
||||||
val notes = noteService.paginatedNotes(loggedInUser.userId, page = 1).notes
|
|
||||||
return persistedNotesMetadataLens(notes, Response(OK))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun note(request: Request, loggedInUser: LoggedInUser): Response =
|
|
||||||
noteService.find(loggedInUser.userId, uuidLens(request))
|
|
||||||
?.let { persistedNoteLens(it, Response(OK)) }
|
|
||||||
?: Response(NOT_FOUND)
|
|
||||||
|
|
||||||
fun update(request: Request, loggedInUser: LoggedInUser): Response {
|
|
||||||
val content = noteContentLens(request)
|
|
||||||
return noteService.update(loggedInUser, uuidLens(request), content).fold(
|
|
||||||
{
|
|
||||||
Response(BAD_REQUEST)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
if (it == null) Response(NOT_FOUND)
|
|
||||||
else Response(OK)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun search(request: Request, loggedInUser: LoggedInUser): Response {
|
|
||||||
val query = searchContentLens(request)
|
|
||||||
val notes = noteService.search(loggedInUser.userId, query)
|
|
||||||
return persistedNotesMetadataLens(notes, Response(OK))
|
|
||||||
}
|
|
||||||
|
|
||||||
private val uuidContentLens = json.auto<UuidContent>().toLens()
|
|
||||||
private val noteContentLens = json.auto<NoteContent>().map { it.content }.toLens()
|
|
||||||
private val searchContentLens = json.auto<SearchContent>().map { it.query }.toLens()
|
|
||||||
private val persistedNotesMetadataLens = json.auto<List<PersistedNoteMetadata>>().toLens()
|
|
||||||
private val persistedNoteLens = json.auto<PersistedNote>().toLens()
|
|
||||||
private val uuidLens = Path.uuid().of("uuid")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class NoteContent(val content: String)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UuidContent(@Contextual val uuid: UUID)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class SearchContent(val query: String)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package be.simplenotes.app.api
|
|
||||||
|
|
||||||
import be.simplenotes.app.extensions.auto
|
|
||||||
import be.simplenotes.domain.LoginForm
|
|
||||||
import be.simplenotes.domain.UserService
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
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.OK
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class ApiUserController(
|
|
||||||
json: Json,
|
|
||||||
private val userService: UserService,
|
|
||||||
) {
|
|
||||||
private val tokenLens = json.auto<Token>().toLens()
|
|
||||||
private val loginFormLens = json.auto<LoginForm>().toLens()
|
|
||||||
|
|
||||||
fun login(request: Request) = userService
|
|
||||||
.login(loginFormLens(request))
|
|
||||||
.fold(
|
|
||||||
{ Response(BAD_REQUEST) },
|
|
||||||
{ tokenLens(Token(it), Response(OK)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Token(val token: String)
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package be.simplenotes.app.controllers
|
|
||||||
|
|
||||||
import be.simplenotes.app.extensions.html
|
|
||||||
import be.simplenotes.types.LoggedInUser
|
|
||||||
import be.simplenotes.views.BaseView
|
|
||||||
import org.http4k.core.Request
|
|
||||||
import org.http4k.core.Response
|
|
||||||
import org.http4k.core.Status.Companion.OK
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class BaseController(private val view: BaseView) {
|
|
||||||
fun index(@Suppress("UNUSED_PARAMETER") request: Request, loggedInUser: LoggedInUser?) =
|
|
||||||
Response(OK).html(view.renderHome(loggedInUser))
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
package be.simplenotes.app.controllers
|
|
||||||
|
|
||||||
import be.simplenotes.app.extensions.html
|
|
||||||
import be.simplenotes.app.extensions.redirect
|
|
||||||
import be.simplenotes.domain.MarkdownParsingError
|
|
||||||
import be.simplenotes.domain.NoteService
|
|
||||||
import be.simplenotes.types.LoggedInUser
|
|
||||||
import be.simplenotes.views.NoteView
|
|
||||||
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 jakarta.inject.Singleton
|
|
||||||
import kotlin.math.abs
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class NoteController(
|
|
||||||
private val view: NoteView,
|
|
||||||
private val noteService: NoteService,
|
|
||||||
) {
|
|
||||||
|
|
||||||
fun new(request: Request, loggedInUser: LoggedInUser): Response {
|
|
||||||
if (request.method == Method.GET) return Response(OK).html(view.noteEditor(loggedInUser))
|
|
||||||
|
|
||||||
val markdownForm = request.form("markdown") ?: ""
|
|
||||||
|
|
||||||
return noteService.create(loggedInUser, markdownForm).fold(
|
|
||||||
{
|
|
||||||
val html = when (it) {
|
|
||||||
MarkdownParsingError.MissingMeta -> view.noteEditor(
|
|
||||||
loggedInUser,
|
|
||||||
error = "Missing note metadata",
|
|
||||||
textarea = markdownForm,
|
|
||||||
)
|
|
||||||
MarkdownParsingError.InvalidMeta -> view.noteEditor(
|
|
||||||
loggedInUser,
|
|
||||||
error = "Invalid note metadata",
|
|
||||||
textarea = markdownForm,
|
|
||||||
)
|
|
||||||
is MarkdownParsingError.ValidationError -> view.noteEditor(
|
|
||||||
loggedInUser,
|
|
||||||
validationErrors = it.validationErrors,
|
|
||||||
textarea = markdownForm,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Response(BAD_REQUEST).html(html)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Response.redirect("/notes/${it.uuid}")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun list(request: Request, loggedInUser: LoggedInUser): Response {
|
|
||||||
val currentPage = request.query("page")?.toIntOrNull()?.let(::abs) ?: 1
|
|
||||||
val tag = request.query("tag")
|
|
||||||
val (pages, notes) = noteService.paginatedNotes(loggedInUser.userId, currentPage, tag = tag)
|
|
||||||
val deletedCount = noteService.countDeleted(loggedInUser.userId)
|
|
||||||
return Response(OK).html(view.notes(loggedInUser, notes, currentPage, pages, deletedCount, tag = tag))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun search(request: Request, loggedInUser: LoggedInUser): Response {
|
|
||||||
val query = request.form("search") ?: ""
|
|
||||||
val notes = noteService.search(loggedInUser.userId, query)
|
|
||||||
val deletedCount = noteService.countDeleted(loggedInUser.userId)
|
|
||||||
return Response(OK).html(view.search(loggedInUser, notes, query, deletedCount))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun note(request: Request, loggedInUser: LoggedInUser): Response {
|
|
||||||
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
|
|
||||||
|
|
||||||
if (request.method == Method.POST) {
|
|
||||||
if (request.form("delete") != null) {
|
|
||||||
return if (noteService.trash(loggedInUser.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(loggedInUser.userId, noteUuid)) return Response(NOT_FOUND)
|
|
||||||
} else if (request.form("private") != null) {
|
|
||||||
if (!noteService.makePrivate(loggedInUser.userId, noteUuid)) return Response(NOT_FOUND)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val note = noteService.find(loggedInUser.userId, noteUuid) ?: return Response(NOT_FOUND)
|
|
||||||
return Response(OK).html(view.renderedNote(loggedInUser, note, shared = false))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun public(request: Request, loggedInUser: LoggedInUser?): Response {
|
|
||||||
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
|
|
||||||
val note = noteService.findPublic(noteUuid) ?: return Response(NOT_FOUND)
|
|
||||||
return Response(OK).html(view.renderedNote(loggedInUser, note, shared = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun edit(request: Request, loggedInUser: LoggedInUser): Response {
|
|
||||||
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
|
|
||||||
val note = noteService.find(loggedInUser.userId, noteUuid) ?: return Response(NOT_FOUND)
|
|
||||||
|
|
||||||
if (request.method == Method.GET) {
|
|
||||||
return Response(OK).html(view.noteEditor(loggedInUser, textarea = note.markdown))
|
|
||||||
}
|
|
||||||
|
|
||||||
val markdownForm = request.form("markdown") ?: ""
|
|
||||||
|
|
||||||
return noteService.update(loggedInUser, note.uuid, markdownForm).fold(
|
|
||||||
{
|
|
||||||
val html = when (it) {
|
|
||||||
MarkdownParsingError.MissingMeta -> view.noteEditor(
|
|
||||||
loggedInUser,
|
|
||||||
error = "Missing note metadata",
|
|
||||||
textarea = markdownForm,
|
|
||||||
)
|
|
||||||
MarkdownParsingError.InvalidMeta -> view.noteEditor(
|
|
||||||
loggedInUser,
|
|
||||||
error = "Invalid note metadata",
|
|
||||||
textarea = markdownForm,
|
|
||||||
)
|
|
||||||
is MarkdownParsingError.ValidationError -> view.noteEditor(
|
|
||||||
loggedInUser,
|
|
||||||
validationErrors = it.validationErrors,
|
|
||||||
textarea = markdownForm,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Response(BAD_REQUEST).html(html)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Response.redirect("/notes/${note.uuid}")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun trash(request: Request, loggedInUser: LoggedInUser): Response {
|
|
||||||
val currentPage = request.query("page")?.toIntOrNull()?.let(::abs) ?: 1
|
|
||||||
val tag = request.query("tag")
|
|
||||||
val (pages, notes) = noteService.paginatedNotes(loggedInUser.userId, currentPage, tag = tag, deleted = true)
|
|
||||||
return Response(OK).html(view.trash(loggedInUser, notes, currentPage, pages))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleted(request: Request, loggedInUser: LoggedInUser): Response {
|
|
||||||
val uuid = request.uuidPath() ?: return Response(NOT_FOUND)
|
|
||||||
return if (request.form("delete") != null)
|
|
||||||
if (noteService.delete(loggedInUser.userId, uuid))
|
|
||||||
Response.redirect("/notes/trash")
|
|
||||||
else
|
|
||||||
Response(NOT_FOUND)
|
|
||||||
else if (noteService.restore(loggedInUser.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package be.simplenotes.app.extensions
|
|
||||||
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.http4k.asString
|
|
||||||
import org.http4k.core.Body
|
|
||||||
import org.http4k.core.ContentType
|
|
||||||
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
|
|
||||||
import org.http4k.lens.*
|
|
||||||
|
|
||||||
fun Response.html(html: String) = body(html)
|
|
||||||
.header("Content-Type", "text/html; charset=utf-8")
|
|
||||||
.header("Cache-Control", "no-cache")
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
val bodyLens = httpBodyRoot(
|
|
||||||
listOf(Meta(true, "body", ParamMeta.ObjectParam, "body")),
|
|
||||||
ContentType.APPLICATION_JSON.withNoDirectives(),
|
|
||||||
ContentNegotiation.StrictNoDirective,
|
|
||||||
).map(
|
|
||||||
{ it.payload.asString() },
|
|
||||||
{ Body(it) },
|
|
||||||
)
|
|
||||||
|
|
||||||
inline fun <reified T> Json.auto(): BiDiBodyLensSpec<T> = bodyLens.map(
|
|
||||||
{ decodeFromString(it) },
|
|
||||||
{ encodeToString(it) },
|
|
||||||
)
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package be.simplenotes.app.filters
|
|
||||||
|
|
||||||
import be.simplenotes.app.extensions.html
|
|
||||||
import be.simplenotes.views.ErrorView
|
|
||||||
import be.simplenotes.views.ErrorView.Type.*
|
|
||||||
import org.http4k.core.*
|
|
||||||
import org.http4k.core.Status.Companion.INTERNAL_SERVER_ERROR
|
|
||||||
import org.http4k.core.Status.Companion.NOT_FOUND
|
|
||||||
import org.http4k.core.Status.Companion.NOT_IMPLEMENTED
|
|
||||||
import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE
|
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import java.sql.SQLTransientException
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class ErrorFilter(private val errorView: ErrorView) : Filter {
|
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(javaClass)
|
|
||||||
|
|
||||||
private fun errorResponse(status: Status): Response {
|
|
||||||
val type = when (status) {
|
|
||||||
SERVICE_UNAVAILABLE -> SqlTransientError
|
|
||||||
NOT_FOUND -> NotFound
|
|
||||||
NOT_IMPLEMENTED -> Other
|
|
||||||
else -> Other
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response(status).html(errorView.error(type)).noCache()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun invoke(next: HttpHandler): HttpHandler = { request ->
|
|
||||||
try {
|
|
||||||
val response = next(request)
|
|
||||||
if (response.status == NOT_FOUND) errorResponse(NOT_FOUND)
|
|
||||||
else response
|
|
||||||
} catch (e: SQLTransientException) {
|
|
||||||
logger.error(e.stackTraceToString())
|
|
||||||
errorResponse(SERVICE_UNAVAILABLE)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.error(e.stackTraceToString())
|
|
||||||
errorResponse(INTERNAL_SERVER_ERROR)
|
|
||||||
} catch (e: NotImplementedError) {
|
|
||||||
logger.error(e.stackTraceToString())
|
|
||||||
errorResponse(NOT_IMPLEMENTED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package be.simplenotes.app.filters
|
|
||||||
|
|
||||||
import org.http4k.core.Filter
|
|
||||||
import org.http4k.core.HttpHandler
|
|
||||||
import org.http4k.core.Request
|
|
||||||
import org.http4k.core.Status.Companion.OK
|
|
||||||
|
|
||||||
object ImmutableFilter : Filter {
|
|
||||||
override fun invoke(next: HttpHandler) = { request: Request ->
|
|
||||||
val res = next(request)
|
|
||||||
if (res.status == OK)
|
|
||||||
res.header("Cache-Control", "public, max-age=31536000, immutable")
|
|
||||||
else res
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package be.simplenotes.app.filters
|
|
||||||
|
|
||||||
import org.http4k.core.Filter
|
|
||||||
import org.http4k.core.HttpHandler
|
|
||||||
import org.http4k.core.Request
|
|
||||||
|
|
||||||
object SecurityFilter : Filter {
|
|
||||||
override fun invoke(next: HttpHandler): 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package be.simplenotes.app.filters.auth
|
|
||||||
|
|
||||||
import be.simplenotes.types.LoggedInUser
|
|
||||||
import org.http4k.core.Request
|
|
||||||
import org.http4k.core.cookie.cookie
|
|
||||||
import org.http4k.lens.BiDiLens
|
|
||||||
|
|
||||||
typealias OptionalAuthLens = BiDiLens<@JvmSuppressWildcards Request, @JvmSuppressWildcards LoggedInUser?>
|
|
||||||
typealias RequiredAuthLens = BiDiLens<@JvmSuppressWildcards Request, @JvmSuppressWildcards LoggedInUser>
|
|
||||||
|
|
||||||
enum class JwtSource {
|
|
||||||
Header, Cookie
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Request.bearerTokenCookie(): String? = cookie("Bearer")
|
|
||||||
?.value
|
|
||||||
?.trim()
|
|
||||||
|
|
||||||
fun Request.bearerTokenHeader(): String? =
|
|
||||||
header("Authorization")
|
|
||||||
?.trim()
|
|
||||||
?.takeIf { it.startsWith("Bearer") }
|
|
||||||
?.substringAfter("Bearer")
|
|
||||||
?.trim()
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package be.simplenotes.app.filters.auth
|
|
||||||
|
|
||||||
import be.simplenotes.app.filters.auth.JwtSource.Cookie
|
|
||||||
import be.simplenotes.domain.security.SimpleJwt
|
|
||||||
import be.simplenotes.types.LoggedInUser
|
|
||||||
import org.http4k.core.Filter
|
|
||||||
import org.http4k.core.HttpHandler
|
|
||||||
import org.http4k.core.with
|
|
||||||
|
|
||||||
class OptionalAuthFilter(
|
|
||||||
private val simpleJwt: SimpleJwt<LoggedInUser>,
|
|
||||||
private val lens: OptionalAuthLens,
|
|
||||||
private val source: JwtSource = Cookie,
|
|
||||||
) : Filter {
|
|
||||||
override fun invoke(next: HttpHandler): HttpHandler = {
|
|
||||||
val token = when (source) {
|
|
||||||
JwtSource.Header -> it.bearerTokenHeader()
|
|
||||||
Cookie -> it.bearerTokenCookie()
|
|
||||||
}
|
|
||||||
|
|
||||||
next(it.with(lens of token?.let { simpleJwt.extract(it) }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package be.simplenotes.app.filters.auth
|
|
||||||
|
|
||||||
import be.simplenotes.app.extensions.redirect
|
|
||||||
import be.simplenotes.domain.security.SimpleJwt
|
|
||||||
import be.simplenotes.types.LoggedInUser
|
|
||||||
import org.http4k.core.Filter
|
|
||||||
import org.http4k.core.HttpHandler
|
|
||||||
import org.http4k.core.Response
|
|
||||||
import org.http4k.core.Status.Companion.UNAUTHORIZED
|
|
||||||
import org.http4k.core.with
|
|
||||||
|
|
||||||
class RequiredAuthFilter(
|
|
||||||
private val simpleJwt: SimpleJwt<LoggedInUser>,
|
|
||||||
private val lens: RequiredAuthLens,
|
|
||||||
private val source: JwtSource = JwtSource.Cookie,
|
|
||||||
private val redirect: Boolean = true,
|
|
||||||
) : Filter {
|
|
||||||
override fun invoke(next: HttpHandler): HttpHandler = {
|
|
||||||
val token = when (source) {
|
|
||||||
JwtSource.Header -> it.bearerTokenHeader()
|
|
||||||
JwtSource.Cookie -> it.bearerTokenCookie()
|
|
||||||
}
|
|
||||||
val jwtPayload = token?.let { simpleJwt.extract(token) }
|
|
||||||
|
|
||||||
if (jwtPayload != null) next(it.with(lens of jwtPayload))
|
|
||||||
else {
|
|
||||||
if (redirect) Response.redirect("/login")
|
|
||||||
else Response(UNAUTHORIZED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package be.simplenotes.app.jetty
|
|
||||||
|
|
||||||
import org.eclipse.jetty.server.Server
|
|
||||||
import org.eclipse.jetty.server.ServerConnector
|
|
||||||
import org.eclipse.jetty.servlet.ServletContextHandler
|
|
||||||
import org.eclipse.jetty.servlet.ServletContextHandler.SESSIONS
|
|
||||||
import org.eclipse.jetty.servlet.ServletHolder
|
|
||||||
import org.http4k.core.HttpHandler
|
|
||||||
import org.http4k.server.Http4kServer
|
|
||||||
import org.http4k.server.ServerConfig
|
|
||||||
import org.http4k.servlet.jakarta.asServlet
|
|
||||||
|
|
||||||
class Jetty(private val port: Int, private val server: Server) : ServerConfig {
|
|
||||||
constructor(port: Int, vararg inConnectors: ConnectorBuilder) : this(
|
|
||||||
port,
|
|
||||||
Server().apply {
|
|
||||||
inConnectors.forEach { addConnector(it(this)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun toServer(http: HttpHandler): Http4kServer {
|
|
||||||
server.insertHandler(http.toJettyHandler())
|
|
||||||
|
|
||||||
return object : Http4kServer {
|
|
||||||
override fun start(): Http4kServer = apply {
|
|
||||||
server.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop(): Http4kServer = apply { server.stop() }
|
|
||||||
|
|
||||||
override fun port(): Int = if (port > 0) port else server.uri.port
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun HttpHandler.toJettyHandler() = ServletContextHandler(SESSIONS).apply {
|
|
||||||
addServlet(ServletHolder(this@toJettyHandler.asServlet()), "/*")
|
|
||||||
}
|
|
||||||
|
|
||||||
typealias ConnectorBuilder = (Server) -> ServerConnector
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package be.simplenotes.app
|
||||||
|
|
||||||
|
import be.simplenotes.shared.config.DataSourceConfig
|
||||||
|
import be.simplenotes.shared.config.JwtConfig
|
||||||
|
import be.simplenotes.shared.config.ServerConfig
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class Config {
|
||||||
|
//region Config loading
|
||||||
|
private val properties: Properties = javaClass
|
||||||
|
.getResource("/application.properties")
|
||||||
|
.openStream()
|
||||||
|
.use {
|
||||||
|
Properties().apply { load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val env = System.getenv()
|
||||||
|
|
||||||
|
private fun value(key: String): String =
|
||||||
|
env[key.toUpperCase().replace(".", "_")]
|
||||||
|
?: properties.getProperty(key)
|
||||||
|
?: error("Missing config key $key")
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
val jwtConfig
|
||||||
|
get() = JwtConfig(
|
||||||
|
secret = value("jwt.secret"),
|
||||||
|
validity = value("jwt.validity").toLong(),
|
||||||
|
timeUnit = TimeUnit.HOURS,
|
||||||
|
)
|
||||||
|
|
||||||
|
val dataSourceConfig
|
||||||
|
get() = DataSourceConfig(
|
||||||
|
jdbcUrl = value("jdbcUrl"),
|
||||||
|
driverClassName = value("driverClassName"),
|
||||||
|
username = value("username"),
|
||||||
|
password = value("password"),
|
||||||
|
maximumPoolSize = value("maximumPoolSize").toInt(),
|
||||||
|
connectionTimeout = value("connectionTimeout").toLong()
|
||||||
|
)
|
||||||
|
|
||||||
|
val serverConfig
|
||||||
|
get() = ServerConfig(
|
||||||
|
host = value("host"),
|
||||||
|
port = value("port").toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,27 +1,21 @@
|
|||||||
package be.simplenotes.app
|
package be.simplenotes.app
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct
|
|
||||||
import jakarta.annotation.PreDestroy
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
import org.http4k.server.Http4kServer
|
import org.http4k.server.Http4kServer
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import be.simplenotes.config.ServerConfig as SimpleNotesServerConfig
|
import be.simplenotes.shared.config.ServerConfig as SimpleNotesServerConfig
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class Server(
|
class Server(
|
||||||
private val config: SimpleNotesServerConfig,
|
private val config: SimpleNotesServerConfig,
|
||||||
private val http4kServer: Http4kServer,
|
private val http4kServer: Http4kServer,
|
||||||
) {
|
) {
|
||||||
private val logger = LoggerFactory.getLogger(javaClass)
|
private val logger = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
fun start(): Server {
|
fun start(): Server {
|
||||||
http4kServer.start()
|
http4kServer.start()
|
||||||
logger.info("Listening on http://${config.host}:${http4kServer.port()}")
|
logger.info("Listening on http://${config.host}:${config.port}")
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
logger.info("Stopping server")
|
logger.info("Stopping server")
|
||||||
http4kServer.close()
|
http4kServer.close()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package be.simplenotes.app
|
||||||
|
|
||||||
|
import be.simplenotes.app.extensions.addShutdownHook
|
||||||
|
import be.simplenotes.app.modules.*
|
||||||
|
import be.simplenotes.domain.domainModule
|
||||||
|
import be.simplenotes.persistance.migrationModule
|
||||||
|
import be.simplenotes.persistance.persistanceModule
|
||||||
|
import be.simplenotes.search.searchModule
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
import org.koin.core.context.unloadKoinModules
|
||||||
|
|
||||||
|
fun main() {
|
||||||
|
startKoin {
|
||||||
|
modules(
|
||||||
|
serverModule,
|
||||||
|
persistanceModule,
|
||||||
|
migrationModule,
|
||||||
|
configModule,
|
||||||
|
baseModule,
|
||||||
|
userModule,
|
||||||
|
noteModule,
|
||||||
|
settingsModule,
|
||||||
|
domainModule,
|
||||||
|
searchModule,
|
||||||
|
apiModule,
|
||||||
|
jsonModule
|
||||||
|
)
|
||||||
|
}.addShutdownHook()
|
||||||
|
|
||||||
|
unloadKoinModules(listOf(migrationModule, configModule))
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package be.simplenotes.app.api
|
||||||
|
|
||||||
|
import be.simplenotes.app.extensions.json
|
||||||
|
import be.simplenotes.app.utils.parseSearchTerms
|
||||||
|
import be.simplenotes.domain.model.PersistedNote
|
||||||
|
import be.simplenotes.domain.model.PersistedNoteMetadata
|
||||||
|
import be.simplenotes.domain.security.JwtPayload
|
||||||
|
import be.simplenotes.domain.usecases.NoteService
|
||||||
|
import kotlinx.serialization.Contextual
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.http4k.core.Request
|
||||||
|
import org.http4k.core.Response
|
||||||
|
import org.http4k.core.Status.Companion.BAD_REQUEST
|
||||||
|
import org.http4k.core.Status.Companion.NOT_FOUND
|
||||||
|
import org.http4k.core.Status.Companion.OK
|
||||||
|
import org.http4k.routing.path
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class ApiNoteController(private val noteService: NoteService, private val json: Json) {
|
||||||
|
|
||||||
|
fun createNote(request: Request, jwtPayload: JwtPayload): Response {
|
||||||
|
val content = json.decodeFromString(NoteContent.serializer(), request.bodyString()).content
|
||||||
|
return noteService.create(jwtPayload.userId, content).fold(
|
||||||
|
{
|
||||||
|
Response(BAD_REQUEST)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Response(OK).json(json.encodeToString(UuidContent.serializer(), UuidContent(it.uuid)))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notes(request: Request, jwtPayload: JwtPayload): Response {
|
||||||
|
val notes = noteService.paginatedNotes(jwtPayload.userId, page = 1).notes
|
||||||
|
val json = json.encodeToString(ListSerializer(PersistedNoteMetadata.serializer()), notes)
|
||||||
|
return Response(OK).json(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun note(request: Request, jwtPayload: JwtPayload): Response {
|
||||||
|
val uuid = request.path("uuid")!!
|
||||||
|
|
||||||
|
return noteService.find(jwtPayload.userId, UUID.fromString(uuid))
|
||||||
|
?.let { Response(OK).json(json.encodeToString(PersistedNote.serializer(), it)) }
|
||||||
|
?: Response(NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(request: Request, jwtPayload: JwtPayload): Response {
|
||||||
|
val uuid = UUID.fromString(request.path("uuid")!!)
|
||||||
|
val content = json.decodeFromString(NoteContent.serializer(), request.bodyString()).content
|
||||||
|
return noteService.update(jwtPayload.userId, uuid, content).fold({
|
||||||
|
Response(BAD_REQUEST)
|
||||||
|
}, {
|
||||||
|
if (it == null) Response(NOT_FOUND)
|
||||||
|
else Response(OK)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fun search(request: Request, jwtPayload: JwtPayload): Response {
|
||||||
|
val query = json.decodeFromString(SearchContent.serializer(), request.bodyString()).query
|
||||||
|
val terms = parseSearchTerms(query)
|
||||||
|
val notes = noteService.search(jwtPayload.userId, terms)
|
||||||
|
val json = json.encodeToString(ListSerializer(PersistedNoteMetadata.serializer()), notes)
|
||||||
|
return Response(OK).json(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NoteContent(val content: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UuidContent(@Contextual val uuid: UUID)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SearchContent(@Contextual val query: String)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package be.simplenotes.app.api
|
||||||
|
|
||||||
|
import be.simplenotes.app.extensions.json
|
||||||
|
import be.simplenotes.domain.usecases.UserService
|
||||||
|
import be.simplenotes.domain.usecases.users.login.LoginForm
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.http4k.core.Request
|
||||||
|
import org.http4k.core.Response
|
||||||
|
import org.http4k.core.Status
|
||||||
|
|
||||||
|
class ApiUserController(private val userService: UserService, private val json: Json) {
|
||||||
|
|
||||||
|
fun login(request: Request): Response {
|
||||||
|
val form = json.decodeFromString(LoginForm.serializer(), request.bodyString())
|
||||||
|
val result = userService.login(form)
|
||||||
|
return result.fold({
|
||||||
|
Response(Status.BAD_REQUEST)
|
||||||
|
}, {
|
||||||
|
Response(Status.OK).json(json.encodeToString(Token.serializer(), Token(it)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Token(val token: String)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package be.simplenotes.app.controllers
|
||||||
|
|
||||||
|
import be.simplenotes.app.extensions.html
|
||||||
|
import be.simplenotes.app.views.BaseView
|
||||||
|
import be.simplenotes.domain.security.JwtPayload
|
||||||
|
import org.http4k.core.Request
|
||||||
|
import org.http4k.core.Response
|
||||||
|
import org.http4k.core.Status.Companion.OK
|
||||||
|
|
||||||
|
class BaseController(private val view: BaseView) {
|
||||||
|
fun index(@Suppress("UNUSED_PARAMETER") request: Request, jwtPayload: JwtPayload?) =
|
||||||
|
Response(OK).html(view.renderHome(jwtPayload))
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package be.simplenotes.app.controllers
|
||||||
|
|
||||||
|
import be.simplenotes.app.extensions.html
|
||||||
|
import be.simplenotes.app.extensions.redirect
|
||||||
|
import be.simplenotes.app.utils.parseSearchTerms
|
||||||
|
import be.simplenotes.app.views.NoteView
|
||||||
|
import be.simplenotes.domain.security.JwtPayload
|
||||||
|
import be.simplenotes.domain.usecases.NoteService
|
||||||
|
import be.simplenotes.domain.usecases.markdown.InvalidMeta
|
||||||
|
import be.simplenotes.domain.usecases.markdown.MissingMeta
|
||||||
|
import be.simplenotes.domain.usecases.markdown.ValidationError
|
||||||
|
import org.http4k.core.Method
|
||||||
|
import org.http4k.core.Request
|
||||||
|
import org.http4k.core.Response
|
||||||
|
import org.http4k.core.Status.Companion.BAD_REQUEST
|
||||||
|
import org.http4k.core.Status.Companion.NOT_FOUND
|
||||||
|
import org.http4k.core.Status.Companion.OK
|
||||||
|
import org.http4k.core.body.form
|
||||||
|
import org.http4k.routing.path
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
class NoteController(
|
||||||
|
private val view: NoteView,
|
||||||
|
private val noteService: NoteService,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun new(request: Request, jwtPayload: JwtPayload): Response {
|
||||||
|
if (request.method == Method.GET) return Response(OK).html(view.noteEditor(jwtPayload))
|
||||||
|
|
||||||
|
val markdownForm = request.form("markdown") ?: ""
|
||||||
|
|
||||||
|
return noteService.create(jwtPayload.userId, markdownForm).fold(
|
||||||
|
{
|
||||||
|
val html = when (it) {
|
||||||
|
MissingMeta -> view.noteEditor(jwtPayload, error = "Missing note metadata", textarea = markdownForm)
|
||||||
|
InvalidMeta -> view.noteEditor(jwtPayload, error = "Invalid note metadata", textarea = markdownForm)
|
||||||
|
is ValidationError -> view.noteEditor(
|
||||||
|
jwtPayload,
|
||||||
|
validationErrors = it.validationErrors,
|
||||||
|
textarea = markdownForm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Response(BAD_REQUEST).html(html)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Response.redirect("/notes/${it.uuid}")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun list(request: Request, jwtPayload: JwtPayload): Response {
|
||||||
|
val currentPage = request.query("page")?.toIntOrNull()?.let(::abs) ?: 1
|
||||||
|
val tag = request.query("tag")
|
||||||
|
val (pages, notes) = noteService.paginatedNotes(jwtPayload.userId, currentPage, tag = tag)
|
||||||
|
val deletedCount = noteService.countDeleted(jwtPayload.userId)
|
||||||
|
return Response(OK).html(view.notes(jwtPayload, notes, currentPage, pages, deletedCount, tag = tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun search(request: Request, jwtPayload: JwtPayload): Response {
|
||||||
|
val query = request.form("search") ?: ""
|
||||||
|
val terms = parseSearchTerms(query)
|
||||||
|
val notes = noteService.search(jwtPayload.userId, terms)
|
||||||
|
val deletedCount = noteService.countDeleted(jwtPayload.userId)
|
||||||
|
return Response(OK).html(view.search(jwtPayload, notes, query, deletedCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun note(request: Request, jwtPayload: JwtPayload): Response {
|
||||||
|
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
|
||||||
|
|
||||||
|
if (request.method == Method.POST) {
|
||||||
|
if (request.form("delete") != null) {
|
||||||
|
return if (noteService.trash(jwtPayload.userId, noteUuid))
|
||||||
|
Response.redirect("/notes") // TODO: flash cookie to show success ?
|
||||||
|
else
|
||||||
|
Response(NOT_FOUND) // TODO: show an error
|
||||||
|
}
|
||||||
|
if (request.form("public") != null) {
|
||||||
|
if (!noteService.makePublic(jwtPayload.userId, noteUuid)) return Response(NOT_FOUND)
|
||||||
|
} else if (request.form("private") != null) {
|
||||||
|
if (!noteService.makePrivate(jwtPayload.userId, noteUuid)) return Response(NOT_FOUND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val note = noteService.find(jwtPayload.userId, noteUuid) ?: return Response(NOT_FOUND)
|
||||||
|
return Response(OK).html(view.renderedNote(jwtPayload, note, shared = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun public(request: Request, jwtPayload: JwtPayload?): Response {
|
||||||
|
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
|
||||||
|
val note = noteService.findPublic(noteUuid) ?: return Response(NOT_FOUND)
|
||||||
|
return Response(OK).html(view.renderedNote(jwtPayload, note, shared = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun edit(request: Request, jwtPayload: JwtPayload): Response {
|
||||||
|
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
|
||||||
|
val note = noteService.find(jwtPayload.userId, noteUuid) ?: return Response(NOT_FOUND)
|
||||||
|
|
||||||
|
if (request.method == Method.GET) {
|
||||||
|
return Response(OK).html(view.noteEditor(jwtPayload, textarea = note.markdown))
|
||||||
|
}
|
||||||
|
|
||||||
|
val markdownForm = request.form("markdown") ?: ""
|
||||||
|
|
||||||
|
return noteService.update(jwtPayload.userId, note.uuid, markdownForm).fold(
|
||||||
|
{
|
||||||
|
val html = when (it) {
|
||||||
|
MissingMeta -> view.noteEditor(jwtPayload, error = "Missing note metadata", textarea = markdownForm)
|
||||||
|
InvalidMeta -> view.noteEditor(jwtPayload, error = "Invalid note metadata", textarea = markdownForm)
|
||||||
|
is ValidationError -> view.noteEditor(
|
||||||
|
jwtPayload,
|
||||||
|
validationErrors = it.validationErrors,
|
||||||
|
textarea = markdownForm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Response(BAD_REQUEST).html(html)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Response.redirect("/notes/${note.uuid}")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun trash(request: Request, jwtPayload: JwtPayload): Response {
|
||||||
|
val currentPage = request.query("page")?.toIntOrNull()?.let(::abs) ?: 1
|
||||||
|
val tag = request.query("tag")
|
||||||
|
val (pages, notes) = noteService.paginatedNotes(jwtPayload.userId, currentPage, tag = tag, deleted = true)
|
||||||
|
return Response(OK).html(view.trash(jwtPayload, notes, currentPage, pages))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleted(request: Request, jwtPayload: JwtPayload): Response {
|
||||||
|
val uuid = request.uuidPath() ?: return Response(NOT_FOUND)
|
||||||
|
return if (request.form("delete") != null)
|
||||||
|
if (noteService.delete(jwtPayload.userId, uuid))
|
||||||
|
Response.redirect("/notes/trash")
|
||||||
|
else
|
||||||
|
Response(NOT_FOUND)
|
||||||
|
else if (noteService.restore(jwtPayload.userId, uuid))
|
||||||
|
Response.redirect("/notes/$uuid")
|
||||||
|
else
|
||||||
|
Response(NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Request.uuidPath(): UUID? {
|
||||||
|
val uuidPath = path("uuid")!!
|
||||||
|
return try {
|
||||||
|
UUID.fromString(uuidPath)!!
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,26 +2,24 @@ package be.simplenotes.app.controllers
|
|||||||
|
|
||||||
import be.simplenotes.app.extensions.html
|
import be.simplenotes.app.extensions.html
|
||||||
import be.simplenotes.app.extensions.redirect
|
import be.simplenotes.app.extensions.redirect
|
||||||
import be.simplenotes.domain.*
|
import be.simplenotes.app.views.SettingView
|
||||||
import be.simplenotes.types.LoggedInUser
|
import be.simplenotes.domain.security.JwtPayload
|
||||||
import be.simplenotes.views.SettingView
|
import be.simplenotes.domain.usecases.UserService
|
||||||
import jakarta.inject.Singleton
|
import be.simplenotes.domain.usecases.users.delete.DeleteError
|
||||||
|
import be.simplenotes.domain.usecases.users.delete.DeleteForm
|
||||||
import org.http4k.core.*
|
import org.http4k.core.*
|
||||||
import org.http4k.core.body.form
|
import org.http4k.core.body.form
|
||||||
import org.http4k.core.cookie.invalidateCookie
|
import org.http4k.core.cookie.invalidateCookie
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class SettingsController(
|
class SettingsController(
|
||||||
private val userService: UserService,
|
private val userService: UserService,
|
||||||
private val exportService: ExportService,
|
|
||||||
private val importService: ImportService,
|
|
||||||
private val settingView: SettingView,
|
private val settingView: SettingView,
|
||||||
) {
|
) {
|
||||||
fun settings(request: Request, loggedInUser: LoggedInUser): Response {
|
fun settings(request: Request, jwtPayload: JwtPayload): Response {
|
||||||
if (request.method == Method.GET)
|
if (request.method == Method.GET)
|
||||||
return Response(Status.OK).html(settingView.settings(loggedInUser))
|
return Response(Status.OK).html(settingView.settings(jwtPayload))
|
||||||
|
|
||||||
val deleteForm = request.deleteForm(loggedInUser)
|
val deleteForm = request.deleteForm(jwtPayload)
|
||||||
val result = userService.delete(deleteForm)
|
val result = userService.delete(deleteForm)
|
||||||
|
|
||||||
return result.fold(
|
return result.fold(
|
||||||
@@ -30,22 +28,21 @@ class SettingsController(
|
|||||||
DeleteError.Unregistered -> Response.redirect("/").invalidateCookie("Bearer")
|
DeleteError.Unregistered -> Response.redirect("/").invalidateCookie("Bearer")
|
||||||
DeleteError.WrongPassword -> Response(Status.OK).html(
|
DeleteError.WrongPassword -> Response(Status.OK).html(
|
||||||
settingView.settings(
|
settingView.settings(
|
||||||
loggedInUser,
|
jwtPayload,
|
||||||
error = "Wrong password",
|
error = "Wrong password"
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
is DeleteError.InvalidForm -> Response(Status.OK).html(
|
is DeleteError.InvalidForm -> Response(Status.OK).html(
|
||||||
settingView.settings(
|
settingView.settings(
|
||||||
loggedInUser,
|
jwtPayload,
|
||||||
validationErrors = it.validationErrors,
|
validationErrors = it.validationErrors
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Response.redirect("/").invalidateCookie("Bearer")
|
Response.redirect("/").invalidateCookie("Bearer")
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,34 +53,23 @@ class SettingsController(
|
|||||||
.header("Content-Type", contentType)
|
.header("Content-Type", contentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun export(request: Request, loggedInUser: LoggedInUser): Response {
|
fun export(request: Request, jwtPayload: JwtPayload): Response {
|
||||||
val isDownload = request.form("download") != null
|
val isDownload = request.form("download") != null
|
||||||
|
|
||||||
return if (isDownload) {
|
return if (isDownload) {
|
||||||
val filename = "simplenotes-export-${loggedInUser.username}"
|
val filename = "simplenotes-export-${jwtPayload.username}"
|
||||||
if (request.form("format") == "zip") {
|
if (request.form("format") == "zip") {
|
||||||
val zip = exportService.exportAsZip(loggedInUser.userId)
|
val zip = userService.exportAsZip(jwtPayload.userId)
|
||||||
Response(Status.OK)
|
Response(Status.OK)
|
||||||
.with(attachment("$filename.zip", "application/zip"))
|
.with(attachment("$filename.zip", "application/zip"))
|
||||||
.body(zip)
|
.body(zip)
|
||||||
} else
|
} else
|
||||||
Response(Status.OK)
|
Response(Status.OK)
|
||||||
.with(attachment("$filename.json", "application/json"))
|
.with(attachment("$filename.json", "application/json"))
|
||||||
.body(exportService.exportAsJson(loggedInUser.userId))
|
.body(userService.exportAsJson(jwtPayload.userId))
|
||||||
} else Response(Status.OK).body(exportService.exportAsJson(loggedInUser.userId)).header(
|
} else Response(Status.OK).body(userService.exportAsJson(jwtPayload.userId)).header("Content-Type", "application/json")
|
||||||
"Content-Type",
|
|
||||||
"application/json",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Request.deleteForm(loggedInUser: LoggedInUser) =
|
private fun Request.deleteForm(jwtPayload: JwtPayload) =
|
||||||
DeleteForm(loggedInUser.username, form("password"), form("checked") != null)
|
DeleteForm(jwtPayload.username, form("password"), form("checked") != null)
|
||||||
|
|
||||||
fun import(request: Request, loggedInUser: LoggedInUser): Response {
|
|
||||||
val form = MultipartFormBody.from(request)
|
|
||||||
val file = form.file("file") ?: return Response(Status.BAD_REQUEST)
|
|
||||||
val json = file.content.bufferedReader().readText()
|
|
||||||
importService.importJson(loggedInUser, json)
|
|
||||||
return Response.redirect("/notes")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,10 +3,14 @@ package be.simplenotes.app.controllers
|
|||||||
import be.simplenotes.app.extensions.html
|
import be.simplenotes.app.extensions.html
|
||||||
import be.simplenotes.app.extensions.isSecure
|
import be.simplenotes.app.extensions.isSecure
|
||||||
import be.simplenotes.app.extensions.redirect
|
import be.simplenotes.app.extensions.redirect
|
||||||
import be.simplenotes.config.JwtConfig
|
import be.simplenotes.app.views.UserView
|
||||||
import be.simplenotes.domain.*
|
import be.simplenotes.domain.security.JwtPayload
|
||||||
import be.simplenotes.types.LoggedInUser
|
import be.simplenotes.domain.usecases.UserService
|
||||||
import be.simplenotes.views.UserView
|
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.Method.GET
|
||||||
import org.http4k.core.Request
|
import org.http4k.core.Request
|
||||||
import org.http4k.core.Response
|
import org.http4k.core.Response
|
||||||
@@ -17,17 +21,15 @@ import org.http4k.core.cookie.SameSite
|
|||||||
import org.http4k.core.cookie.cookie
|
import org.http4k.core.cookie.cookie
|
||||||
import org.http4k.core.cookie.invalidateCookie
|
import org.http4k.core.cookie.invalidateCookie
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class UserController(
|
class UserController(
|
||||||
private val userService: UserService,
|
private val userService: UserService,
|
||||||
private val userView: UserView,
|
private val userView: UserView,
|
||||||
private val jwtConfig: JwtConfig,
|
private val jwtConfig: JwtConfig,
|
||||||
) {
|
) {
|
||||||
fun register(request: Request, loggedInUser: LoggedInUser?): Response {
|
fun register(request: Request, jwtPayload: JwtPayload?): Response {
|
||||||
if (request.method == GET) return Response(OK).html(
|
if (request.method == GET) return Response(OK).html(
|
||||||
userView.register(loggedInUser),
|
userView.register(jwtPayload)
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = userService.register(request.registerForm())
|
val result = userService.register(request.registerForm())
|
||||||
@@ -35,30 +37,30 @@ class UserController(
|
|||||||
return result.fold(
|
return result.fold(
|
||||||
{
|
{
|
||||||
val html = when (it) {
|
val html = when (it) {
|
||||||
RegisterError.UserExists -> userView.register(
|
UserExists -> userView.register(
|
||||||
loggedInUser,
|
jwtPayload,
|
||||||
error = "User already exists",
|
error = "User already exists"
|
||||||
)
|
)
|
||||||
is RegisterError.InvalidRegisterForm ->
|
is InvalidRegisterForm ->
|
||||||
userView.register(
|
userView.register(
|
||||||
loggedInUser,
|
jwtPayload,
|
||||||
validationErrors = it.validationErrors,
|
validationErrors = it.validationErrors
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Response(OK).html(html)
|
Response(OK).html(html)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Response.redirect("/login")
|
Response.redirect("/login")
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Request.registerForm() = RegisterForm(form("username"), form("password"))
|
private fun Request.registerForm() = RegisterForm(form("username"), form("password"))
|
||||||
private fun Request.loginForm(): LoginForm = registerForm()
|
private fun Request.loginForm(): LoginForm = registerForm()
|
||||||
|
|
||||||
fun login(request: Request, loggedInUser: LoggedInUser?): Response {
|
fun login(request: Request, jwtPayload: JwtPayload?): Response {
|
||||||
if (request.method == GET) return Response(OK).html(
|
if (request.method == GET) return Response(OK).html(
|
||||||
userView.login(loggedInUser),
|
userView.login(jwtPayload)
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = userService.login(request.loginForm())
|
val result = userService.login(request.loginForm())
|
||||||
@@ -66,27 +68,27 @@ class UserController(
|
|||||||
return result.fold(
|
return result.fold(
|
||||||
{
|
{
|
||||||
val html = when (it) {
|
val html = when (it) {
|
||||||
LoginError.Unregistered ->
|
Unregistered ->
|
||||||
userView.login(
|
userView.login(
|
||||||
loggedInUser,
|
jwtPayload,
|
||||||
error = "User does not exist",
|
error = "User does not exist"
|
||||||
)
|
)
|
||||||
LoginError.WrongPassword ->
|
WrongPassword ->
|
||||||
userView.login(
|
userView.login(
|
||||||
loggedInUser,
|
jwtPayload,
|
||||||
error = "Wrong password",
|
error = "Wrong password"
|
||||||
)
|
)
|
||||||
is LoginError.InvalidLoginForm ->
|
is InvalidLoginForm ->
|
||||||
userView.login(
|
userView.login(
|
||||||
loggedInUser,
|
jwtPayload,
|
||||||
validationErrors = it.validationErrors,
|
validationErrors = it.validationErrors
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Response(OK).html(html)
|
Response(OK).html(html)
|
||||||
},
|
},
|
||||||
{ token ->
|
{ token ->
|
||||||
Response.redirect("/notes").loginCookie(token, request.isSecure())
|
Response.redirect("/notes").loginCookie(token, request.isSecure())
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,8 +103,8 @@ class UserController(
|
|||||||
httpOnly = true,
|
httpOnly = true,
|
||||||
sameSite = SameSite.Lax,
|
sameSite = SameSite.Lax,
|
||||||
maxAge = validityInSeconds,
|
maxAge = validityInSeconds,
|
||||||
secure = secure,
|
secure = secure
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package be.simplenotes.app.extensions
|
||||||
|
|
||||||
|
import org.http4k.core.Request
|
||||||
|
import org.http4k.core.Response
|
||||||
|
import org.http4k.core.Status.Companion.FOUND
|
||||||
|
import org.http4k.core.Status.Companion.MOVED_PERMANENTLY
|
||||||
|
|
||||||
|
fun Response.html(html: String) = body(html)
|
||||||
|
.header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
.header("Cache-Control", "no-cache")
|
||||||
|
|
||||||
|
fun Response.json(json: String) = body(json).header("Content-Type", "application/json")
|
||||||
|
|
||||||
|
fun Response.Companion.redirect(url: String, permanent: Boolean = false) =
|
||||||
|
Response(if (permanent) MOVED_PERMANENTLY else FOUND).header("Location", url)
|
||||||
|
|
||||||
|
fun Request.isSecure() = header("X-Forwarded-Proto")?.contains("https") ?: false
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package be.simplenotes.app.extensions
|
||||||
|
|
||||||
|
import org.koin.core.KoinApplication
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
fun KoinApplication.addShutdownHook() {
|
||||||
|
Runtime.getRuntime().addShutdownHook(
|
||||||
|
thread(start = false) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package be.simplenotes.app.extensions
|
||||||
|
|
||||||
|
import kotlinx.html.*
|
||||||
|
|
||||||
|
class SUMMARY(consumer: TagConsumer<*>) :
|
||||||
|
HTMLTag(
|
||||||
|
"summary", consumer, emptyMap(),
|
||||||
|
inlineTag = true,
|
||||||
|
emptyTag = false
|
||||||
|
),
|
||||||
|
HtmlInlineTag
|
||||||
|
|
||||||
|
fun DETAILS.summary(block: SUMMARY.() -> Unit = {}) {
|
||||||
|
SUMMARY(consumer).visit(block)
|
||||||
|
}
|
||||||
@@ -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,23 @@
|
|||||||
|
package be.simplenotes.app.modules
|
||||||
|
|
||||||
|
import be.simplenotes.app.api.ApiNoteController
|
||||||
|
import be.simplenotes.app.api.ApiUserController
|
||||||
|
import be.simplenotes.app.filters.AuthFilter
|
||||||
|
import be.simplenotes.app.filters.AuthType
|
||||||
|
import be.simplenotes.app.filters.JwtSource
|
||||||
|
import org.koin.core.qualifier.named
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val apiModule = module {
|
||||||
|
single { ApiUserController(get(), get()) }
|
||||||
|
single { ApiNoteController(get(), get()) }
|
||||||
|
single(named("apiAuthFilter")) {
|
||||||
|
AuthFilter(
|
||||||
|
extractor = get(),
|
||||||
|
authType = AuthType.Required,
|
||||||
|
ctx = get(),
|
||||||
|
source = JwtSource.Header,
|
||||||
|
redirect = false
|
||||||
|
)()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package be.simplenotes.app.modules
|
||||||
|
|
||||||
|
import be.simplenotes.app.Config
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import org.koin.dsl.onClose
|
||||||
|
|
||||||
|
val configModule = module {
|
||||||
|
single { Config() } onClose {
|
||||||
|
println("Unloaded config")
|
||||||
|
println("Unloaded config")
|
||||||
|
println("Unloaded config")
|
||||||
|
println("Unloaded config")
|
||||||
|
}
|
||||||
|
|
||||||
|
single { get<Config>().dataSourceConfig }
|
||||||
|
single { get<Config>().jwtConfig }
|
||||||
|
single { get<Config>().serverConfig }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package be.simplenotes.app.modules
|
||||||
|
|
||||||
|
import be.simplenotes.app.controllers.BaseController
|
||||||
|
import be.simplenotes.app.controllers.NoteController
|
||||||
|
import be.simplenotes.app.controllers.SettingsController
|
||||||
|
import be.simplenotes.app.controllers.UserController
|
||||||
|
import be.simplenotes.app.views.BaseView
|
||||||
|
import be.simplenotes.app.views.NoteView
|
||||||
|
import be.simplenotes.app.views.SettingView
|
||||||
|
import be.simplenotes.app.views.UserView
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val userModule = module {
|
||||||
|
single { UserController(get(), get(), get()) }
|
||||||
|
single { UserView(get()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val baseModule = module {
|
||||||
|
single { BaseController(get()) }
|
||||||
|
single { BaseView(get()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val noteModule = module {
|
||||||
|
single { NoteController(get(), get()) }
|
||||||
|
single { NoteView(get()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val settingsModule = module {
|
||||||
|
single { SettingsController(get(), get()) }
|
||||||
|
single { SettingView(get()) }
|
||||||
|
}
|
||||||
@@ -2,20 +2,21 @@ package be.simplenotes.app.modules
|
|||||||
|
|
||||||
import be.simplenotes.app.serialization.LocalDateTimeSerializer
|
import be.simplenotes.app.serialization.LocalDateTimeSerializer
|
||||||
import be.simplenotes.app.serialization.UuidSerializer
|
import be.simplenotes.app.serialization.UuidSerializer
|
||||||
import io.micronaut.context.annotation.Factory
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.modules.SerializersModule
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
|
import org.koin.dsl.module
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
@Factory
|
val jsonModule = module {
|
||||||
class JsonModule {
|
single {
|
||||||
|
Json {
|
||||||
@Singleton
|
|
||||||
fun json() = Json {
|
|
||||||
prettyPrint = true
|
prettyPrint = true
|
||||||
serializersModule = SerializersModule {
|
serializersModule = get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
single {
|
||||||
|
SerializersModule {
|
||||||
contextual(LocalDateTime::class, LocalDateTimeSerializer())
|
contextual(LocalDateTime::class, LocalDateTimeSerializer())
|
||||||
contextual(UUID::class, UuidSerializer())
|
contextual(UUID::class, UuidSerializer())
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package be.simplenotes.app.modules
|
||||||
|
|
||||||
|
import be.simplenotes.app.Server
|
||||||
|
import be.simplenotes.app.filters.AuthFilter
|
||||||
|
import be.simplenotes.app.filters.AuthType
|
||||||
|
import be.simplenotes.app.filters.ErrorFilter
|
||||||
|
import be.simplenotes.app.routes.Router
|
||||||
|
import be.simplenotes.app.utils.StaticFileResolver
|
||||||
|
import be.simplenotes.app.utils.StaticFileResolverImpl
|
||||||
|
import be.simplenotes.app.views.ErrorView
|
||||||
|
import be.simplenotes.shared.config.ServerConfig
|
||||||
|
import org.eclipse.jetty.server.ServerConnector
|
||||||
|
import org.http4k.core.RequestContexts
|
||||||
|
import org.http4k.routing.RoutingHttpHandler
|
||||||
|
import org.http4k.server.ConnectorBuilder
|
||||||
|
import org.http4k.server.Jetty
|
||||||
|
import org.http4k.server.asServer
|
||||||
|
import org.koin.core.qualifier.named
|
||||||
|
import org.koin.core.qualifier.qualifier
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import org.koin.dsl.onClose
|
||||||
|
import org.http4k.server.ServerConfig as Http4kServerConfig
|
||||||
|
|
||||||
|
val serverModule = module {
|
||||||
|
single(createdAtStart = true) { Server(get(), get()).start() } onClose { it?.stop() }
|
||||||
|
single { get<RoutingHttpHandler>().asServer(get()) }
|
||||||
|
single<Http4kServerConfig> {
|
||||||
|
val config = get<ServerConfig>()
|
||||||
|
val builder: ConnectorBuilder = { server: org.eclipse.jetty.server.Server ->
|
||||||
|
ServerConnector(server).apply {
|
||||||
|
port = config.port
|
||||||
|
host = config.host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Jetty(config.port, builder)
|
||||||
|
}
|
||||||
|
single<StaticFileResolver> { StaticFileResolverImpl(get()) }
|
||||||
|
single {
|
||||||
|
Router(
|
||||||
|
get(),
|
||||||
|
get(),
|
||||||
|
get(),
|
||||||
|
get(),
|
||||||
|
get(),
|
||||||
|
get(),
|
||||||
|
requiredAuth = get(AuthType.Required.qualifier),
|
||||||
|
optionalAuth = get(AuthType.Optional.qualifier),
|
||||||
|
errorFilter = get(named("ErrorFilter")),
|
||||||
|
apiAuth = get(named("apiAuthFilter")),
|
||||||
|
get()
|
||||||
|
)()
|
||||||
|
}
|
||||||
|
single { RequestContexts() }
|
||||||
|
single(AuthType.Optional.qualifier) { AuthFilter(get(), AuthType.Optional, get())() }
|
||||||
|
single(AuthType.Required.qualifier) { AuthFilter(get(), AuthType.Required, get())() }
|
||||||
|
single(named("ErrorFilter")) { ErrorFilter(get())() }
|
||||||
|
single { ErrorView(get()) }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -17,6 +17,6 @@ internal class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun deserialize(decoder: Decoder): LocalDateTime {
|
override fun deserialize(decoder: Decoder): LocalDateTime {
|
||||||
return LocalDateTime.parse(decoder.decodeString())
|
TODO("Not implemented, isn't needed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ import java.util.*
|
|||||||
|
|
||||||
internal class UuidSerializer : KSerializer<UUID> {
|
internal class UuidSerializer : KSerializer<UUID> {
|
||||||
override val descriptor: SerialDescriptor
|
override val descriptor: SerialDescriptor
|
||||||
get() = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
|
get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
|
||||||
|
|
||||||
override fun serialize(encoder: Encoder, value: UUID) {
|
override fun serialize(encoder: Encoder, value: UUID) {
|
||||||
encoder.encodeString(value.toString())
|
encoder.encodeString(value.toString())
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,13 +3,11 @@ package be.simplenotes.app.utils
|
|||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
interface StaticFileResolver {
|
interface StaticFileResolver {
|
||||||
fun resolve(name: String): String?
|
fun resolve(name: String): String?
|
||||||
}
|
}
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class StaticFileResolverImpl(json: Json) : StaticFileResolver {
|
class StaticFileResolverImpl(json: Json) : StaticFileResolver {
|
||||||
private val mappings: Map<String, String>
|
private val mappings: Map<String, String>
|
||||||
|
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
package be.simplenotes.views
|
package be.simplenotes.app.views
|
||||||
|
|
||||||
import be.simplenotes.views.components.Alert
|
import be.simplenotes.app.utils.StaticFileResolver
|
||||||
import be.simplenotes.views.components.alert
|
import be.simplenotes.app.views.components.Alert
|
||||||
|
import be.simplenotes.app.views.components.alert
|
||||||
import kotlinx.html.a
|
import kotlinx.html.a
|
||||||
import kotlinx.html.div
|
import kotlinx.html.div
|
||||||
import jakarta.inject.Named
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
class ErrorView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
|
||||||
class ErrorView(@Named("styles") styles: String) : View(styles) {
|
|
||||||
|
|
||||||
enum class Type(val title: String) {
|
enum class Type(val title: String) {
|
||||||
SqlTransientError("Database unavailable"),
|
SqlTransientError("Database unavailable"),
|
||||||
@@ -16,14 +14,14 @@ class ErrorView(@Named("styles") styles: String) : View(styles) {
|
|||||||
Other("Error"),
|
Other("Error"),
|
||||||
}
|
}
|
||||||
|
|
||||||
fun error(errorType: Type) = renderPage(errorType.title, loggedInUser = null) {
|
fun error(errorType: Type) = renderPage(errorType.title, jwtPayload = null) {
|
||||||
div("container mx-auto p-4") {
|
div("container mx-auto p-4") {
|
||||||
when (errorType) {
|
when (errorType) {
|
||||||
Type.SqlTransientError -> alert(
|
Type.SqlTransientError -> alert(
|
||||||
Alert.Warning,
|
Alert.Warning,
|
||||||
errorType.title,
|
errorType.title,
|
||||||
"Please try again later",
|
"Please try again later",
|
||||||
multiline = true,
|
multiline = true
|
||||||
)
|
)
|
||||||
Type.NotFound -> alert(Alert.Warning, errorType.title, "Page not found", multiline = true)
|
Type.NotFound -> alert(Alert.Warning, errorType.title, "Page not found", multiline = true)
|
||||||
Type.Other -> alert(Alert.Warning, errorType.title)
|
Type.Other -> alert(Alert.Warning, errorType.title)
|
||||||
@@ -1,23 +1,21 @@
|
|||||||
package be.simplenotes.views
|
package be.simplenotes.app.views
|
||||||
|
|
||||||
import be.simplenotes.types.LoggedInUser
|
import be.simplenotes.app.utils.StaticFileResolver
|
||||||
import be.simplenotes.types.PersistedNote
|
import be.simplenotes.app.views.components.*
|
||||||
import be.simplenotes.types.PersistedNoteMetadata
|
import be.simplenotes.domain.model.PersistedNote
|
||||||
import be.simplenotes.views.components.*
|
import be.simplenotes.domain.model.PersistedNoteMetadata
|
||||||
|
import be.simplenotes.domain.security.JwtPayload
|
||||||
import io.konform.validation.ValidationError
|
import io.konform.validation.ValidationError
|
||||||
import kotlinx.html.*
|
import kotlinx.html.*
|
||||||
import jakarta.inject.Named
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
class NoteView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
|
||||||
class NoteView(@Named("styles") styles: String) : View(styles) {
|
|
||||||
|
|
||||||
fun noteEditor(
|
fun noteEditor(
|
||||||
loggedInUser: LoggedInUser,
|
jwtPayload: JwtPayload,
|
||||||
error: String? = null,
|
error: String? = null,
|
||||||
textarea: String? = null,
|
textarea: String? = null,
|
||||||
validationErrors: List<ValidationError> = emptyList(),
|
validationErrors: List<ValidationError> = emptyList(),
|
||||||
) = renderPage(title = "New note", loggedInUser = loggedInUser) {
|
) = renderPage(title = "New note", jwtPayload = jwtPayload) {
|
||||||
div("container mx-auto p-4") {
|
div("container mx-auto p-4") {
|
||||||
error?.let { alert(Alert.Warning, error) }
|
error?.let { alert(Alert.Warning, error) }
|
||||||
validationErrors.forEach {
|
validationErrors.forEach {
|
||||||
@@ -40,9 +38,7 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
|
|||||||
|tags: []
|
|tags: []
|
||||||
|---
|
|---
|
||||||
|
|
|
|
||||||
""".trimMargin(
|
""".trimMargin("|")
|
||||||
"|",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
submitButton("Save")
|
submitButton("Save")
|
||||||
}
|
}
|
||||||
@@ -50,13 +46,13 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun notes(
|
fun notes(
|
||||||
loggedInUser: LoggedInUser,
|
jwtPayload: JwtPayload,
|
||||||
notes: List<PersistedNoteMetadata>,
|
notes: List<PersistedNoteMetadata>,
|
||||||
currentPage: Int,
|
currentPage: Int,
|
||||||
numberOfPages: Int,
|
numberOfPages: Int,
|
||||||
numberOfDeletedNotes: Int,
|
numberOfDeletedNotes: Int,
|
||||||
tag: String?,
|
tag: String?,
|
||||||
) = renderPage(title = "Notes", loggedInUser = loggedInUser) {
|
) = renderPage(title = "Notes", jwtPayload = jwtPayload) {
|
||||||
div("container mx-auto p-4") {
|
div("container mx-auto p-4") {
|
||||||
noteListHeader(numberOfDeletedNotes)
|
noteListHeader(numberOfDeletedNotes)
|
||||||
if (notes.isNotEmpty())
|
if (notes.isNotEmpty())
|
||||||
@@ -72,11 +68,11 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun search(
|
fun search(
|
||||||
loggedInUser: LoggedInUser,
|
jwtPayload: JwtPayload,
|
||||||
notes: List<PersistedNoteMetadata>,
|
notes: List<PersistedNoteMetadata>,
|
||||||
query: String,
|
query: String,
|
||||||
numberOfDeletedNotes: Int,
|
numberOfDeletedNotes: Int,
|
||||||
) = renderPage("Notes", loggedInUser = loggedInUser) {
|
) = renderPage("Notes", jwtPayload = jwtPayload) {
|
||||||
div("container mx-auto p-4") {
|
div("container mx-auto p-4") {
|
||||||
noteListHeader(numberOfDeletedNotes, query)
|
noteListHeader(numberOfDeletedNotes, query)
|
||||||
noteTable(notes)
|
noteTable(notes)
|
||||||
@@ -84,11 +80,11 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun trash(
|
fun trash(
|
||||||
loggedInUser: LoggedInUser,
|
jwtPayload: JwtPayload,
|
||||||
notes: List<PersistedNoteMetadata>,
|
notes: List<PersistedNoteMetadata>,
|
||||||
currentPage: Int,
|
currentPage: Int,
|
||||||
numberOfPages: Int,
|
numberOfPages: Int,
|
||||||
) = renderPage(title = "Notes", loggedInUser = loggedInUser) {
|
) = renderPage(title = "Notes", jwtPayload = jwtPayload) {
|
||||||
div("container mx-auto p-4") {
|
div("container mx-auto p-4") {
|
||||||
div("flex justify-between mb-4") {
|
div("flex justify-between mb-4") {
|
||||||
h1("text-2xl underline") { +"Deleted notes" }
|
h1("text-2xl underline") { +"Deleted notes" }
|
||||||
@@ -120,12 +116,13 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun renderedNote(loggedInUser: LoggedInUser?, note: PersistedNote, shared: Boolean) = renderPage(
|
fun renderedNote(jwtPayload: JwtPayload?, note: PersistedNote, shared: Boolean) = renderPage(
|
||||||
note.title,
|
note.meta.title,
|
||||||
loggedInUser = loggedInUser,
|
jwtPayload = jwtPayload,
|
||||||
scripts = listOf("/highlight.10.1.2.js", "/init-highlight.0.0.1.js"),
|
scripts = listOf("/highlight.10.1.2.js", "/init-highlight.0.0.1.js")
|
||||||
) {
|
) {
|
||||||
div("container mx-auto p-4") {
|
div("container mx-auto p-4") {
|
||||||
|
|
||||||
if (shared) {
|
if (shared) {
|
||||||
p("p-4 bg-gray-800") {
|
p("p-4 bg-gray-800") {
|
||||||
+"You are viewing a public note "
|
+"You are viewing a public note "
|
||||||
@@ -135,9 +132,9 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
div("flex items-center justify-between mb-4") {
|
div("flex items-center justify-between mb-4") {
|
||||||
h1("text-3xl fond-bold underline") { +note.title }
|
h1("text-3xl fond-bold underline") { +note.meta.title }
|
||||||
span("space-x-2") {
|
span("space-x-2") {
|
||||||
note.tags.forEach {
|
note.meta.tags.forEach {
|
||||||
a(href = "/notes?tag=$it", classes = "tag") {
|
a(href = "/notes?tag=$it", classes = "tag") {
|
||||||
+"#$it"
|
+"#$it"
|
||||||
}
|
}
|
||||||
@@ -146,6 +143,7 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
|
|||||||
}
|
}
|
||||||
if (!shared) {
|
if (!shared) {
|
||||||
noteActionForm(note)
|
noteActionForm(note)
|
||||||
|
publicPrivateForm(note)
|
||||||
|
|
||||||
if (note.public) {
|
if (note.public) {
|
||||||
p("my-4") {
|
p("my-4") {
|
||||||
@@ -168,35 +166,36 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun DIV.noteActionForm(note: PersistedNote) {
|
private fun DIV.noteActionForm(note: PersistedNote) {
|
||||||
form(method = FormMethod.post, classes = "inline flex space-x-2 justify-end mb-4") {
|
span("flex space-x-2 justify-end mb-4") {
|
||||||
a(
|
a(
|
||||||
href = "/notes/${note.uuid}/edit",
|
href = "/notes/${note.uuid}/edit",
|
||||||
classes = "btn btn-green",
|
classes = "btn btn-teal"
|
||||||
) { +"Edit" }
|
) { +"Edit" }
|
||||||
span {
|
form(method = FormMethod.post, classes = "inline") {
|
||||||
button(
|
|
||||||
type = ButtonType.submit,
|
|
||||||
name = if (note.public) "private" else "public",
|
|
||||||
classes = "font-semibold border-b-4 ${if (note.public) "border-teal-200" else "border-green-500"}" +
|
|
||||||
" p-2 rounded-l bg-teal-200 text-gray-800",
|
|
||||||
) {
|
|
||||||
+"Private"
|
|
||||||
}
|
|
||||||
button(
|
|
||||||
type = ButtonType.submit,
|
|
||||||
name = if (note.public) "private" else "public",
|
|
||||||
classes = "font-semibold border-b-4 " +
|
|
||||||
(if (!note.public) "border-teal-200" else "border-green-500") +
|
|
||||||
" p-2 rounded-r bg-teal-200 text-gray-800",
|
|
||||||
) {
|
|
||||||
+"Public"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
button(
|
button(
|
||||||
type = ButtonType.submit,
|
type = ButtonType.submit,
|
||||||
name = "delete",
|
name = "delete",
|
||||||
classes = "btn btn-red",
|
classes = "btn btn-red"
|
||||||
) { +"Delete" }
|
) { +"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,105 @@
|
|||||||
|
package be.simplenotes.app.views
|
||||||
|
|
||||||
|
import be.simplenotes.app.extensions.summary
|
||||||
|
import be.simplenotes.app.utils.StaticFileResolver
|
||||||
|
import be.simplenotes.app.views.components.Alert
|
||||||
|
import be.simplenotes.app.views.components.alert
|
||||||
|
import be.simplenotes.app.views.components.input
|
||||||
|
import be.simplenotes.domain.security.JwtPayload
|
||||||
|
import io.konform.validation.ValidationError
|
||||||
|
import kotlinx.html.*
|
||||||
|
import kotlinx.html.ButtonType.submit
|
||||||
|
|
||||||
|
class SettingView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
|
||||||
|
|
||||||
|
fun settings(
|
||||||
|
jwtPayload: JwtPayload,
|
||||||
|
error: String? = null,
|
||||||
|
validationErrors: List<ValidationError> = emptyList(),
|
||||||
|
) = renderPage("Settings", jwtPayload = jwtPayload) {
|
||||||
|
div("container mx-auto") {
|
||||||
|
|
||||||
|
section("m-4 p-4 bg-gray-800 rounded") {
|
||||||
|
h1("text-xl") {
|
||||||
|
+"Welcome "
|
||||||
|
span("text-teal-200 font-semibold") { +jwtPayload.username }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section("m-4 p-4 bg-gray-800 rounded flex justify-around") {
|
||||||
|
|
||||||
|
form(method = FormMethod.post, action = "/export") {
|
||||||
|
button(name = "display",
|
||||||
|
classes = "inline btn btn-teal block",
|
||||||
|
type = submit) { +"Display my data" }
|
||||||
|
}
|
||||||
|
|
||||||
|
form(method = FormMethod.post, action = "/export") {
|
||||||
|
|
||||||
|
listOf("json", "zip").forEach { format ->
|
||||||
|
div {
|
||||||
|
radioInput(name = "format") {
|
||||||
|
id = format
|
||||||
|
attributes["value"] = format
|
||||||
|
if(format == "json") attributes["checked"] = ""
|
||||||
|
}
|
||||||
|
label(classes = "ml-2") {
|
||||||
|
attributes["for"] = format
|
||||||
|
+format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button(name = "download", classes = "inline btn btn-green block mt-2", type = submit) {
|
||||||
|
+"Download my data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section(classes = "m-4 p-4 bg-gray-800 rounded") {
|
||||||
|
h2(classes = "mb-4 text-red-400 text-lg font-semibold") {
|
||||||
|
+"Delete my account"
|
||||||
|
}
|
||||||
|
|
||||||
|
error?.let { alert(Alert.Warning, error) }
|
||||||
|
|
||||||
|
details {
|
||||||
|
|
||||||
|
if (error != null || validationErrors.isNotEmpty()) {
|
||||||
|
attributes["open"] = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
span(classes = "mb-4 font-semibold underline") {
|
||||||
|
+"Are you sure? "
|
||||||
|
+"You are about to delete this user, and this process is irreversible !"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form(classes = "mt-4", method = FormMethod.post) {
|
||||||
|
input(
|
||||||
|
id = "password",
|
||||||
|
placeholder = "Password",
|
||||||
|
autoComplete = "off",
|
||||||
|
type = InputType.password,
|
||||||
|
error = validationErrors.find { it.dataPath == ".password" }?.message
|
||||||
|
)
|
||||||
|
checkBoxInput(name = "checked") {
|
||||||
|
id = "checked"
|
||||||
|
attributes["required"] = ""
|
||||||
|
label {
|
||||||
|
attributes["for"] = "checked"
|
||||||
|
+" Do you want to proceed ?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button(
|
||||||
|
type = submit,
|
||||||
|
classes = "block mt-4 btn btn-red",
|
||||||
|
name = "delete"
|
||||||
|
) { +"I'm sure" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +1,38 @@
|
|||||||
package be.simplenotes.views
|
package be.simplenotes.app.views
|
||||||
|
|
||||||
import be.simplenotes.types.LoggedInUser
|
import be.simplenotes.app.utils.StaticFileResolver
|
||||||
import be.simplenotes.views.components.Alert
|
import be.simplenotes.app.views.components.Alert
|
||||||
import be.simplenotes.views.components.alert
|
import be.simplenotes.app.views.components.alert
|
||||||
import be.simplenotes.views.components.input
|
import be.simplenotes.app.views.components.input
|
||||||
import be.simplenotes.views.components.submitButton
|
import be.simplenotes.app.views.components.submitButton
|
||||||
|
import be.simplenotes.domain.security.JwtPayload
|
||||||
import io.konform.validation.ValidationError
|
import io.konform.validation.ValidationError
|
||||||
import kotlinx.html.*
|
import kotlinx.html.*
|
||||||
import jakarta.inject.Named
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
class UserView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
|
||||||
class UserView(@Named("styles") styles: String) : View(styles) {
|
|
||||||
fun register(
|
fun register(
|
||||||
loggedInUser: LoggedInUser?,
|
jwtPayload: JwtPayload?,
|
||||||
error: String? = null,
|
error: String? = null,
|
||||||
validationErrors: List<ValidationError> = emptyList(),
|
validationErrors: List<ValidationError> = emptyList(),
|
||||||
) = accountForm(
|
) = accountForm(
|
||||||
"Register",
|
"Register",
|
||||||
"Registration page",
|
"Registration page",
|
||||||
loggedInUser,
|
jwtPayload,
|
||||||
error,
|
error,
|
||||||
validationErrors,
|
validationErrors,
|
||||||
"Create an account",
|
"Create an account",
|
||||||
"Register",
|
"Register"
|
||||||
) {
|
) {
|
||||||
+"Already have an account? "
|
+"Already have an account? "
|
||||||
a(href = "/login", classes = "no-underline text-blue-500 hover:text-blue-400 font-bold") { +"Sign In" }
|
a(href = "/login", classes = "no-underline text-blue-500 hover:text-blue-400 font-bold") { +"Sign In" }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun login(
|
fun login(
|
||||||
loggedInUser: LoggedInUser?,
|
jwtPayload: JwtPayload?,
|
||||||
error: String? = null,
|
error: String? = null,
|
||||||
validationErrors: List<ValidationError> = emptyList(),
|
validationErrors: List<ValidationError> = emptyList(),
|
||||||
new: Boolean = false,
|
new: Boolean = false,
|
||||||
) = accountForm("Login", "Login page", loggedInUser, error, validationErrors, "Sign In", "Sign In", new) {
|
) = accountForm("Login", "Login page", jwtPayload, error, validationErrors, "Sign In", "Sign In", new) {
|
||||||
+"Don't have an account yet? "
|
+"Don't have an account yet? "
|
||||||
a(href = "/register", classes = "no-underline text-blue-500 hover:text-blue-400 font-bold") {
|
a(href = "/register", classes = "no-underline text-blue-500 hover:text-blue-400 font-bold") {
|
||||||
+"Create an account"
|
+"Create an account"
|
||||||
@@ -44,14 +42,14 @@ class UserView(@Named("styles") styles: String) : View(styles) {
|
|||||||
private fun accountForm(
|
private fun accountForm(
|
||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String,
|
||||||
loggedInUser: LoggedInUser?,
|
jwtPayload: JwtPayload?,
|
||||||
error: String? = null,
|
error: String? = null,
|
||||||
validationErrors: List<ValidationError> = emptyList(),
|
validationErrors: List<ValidationError> = emptyList(),
|
||||||
h1: String,
|
h1: String,
|
||||||
submit: String,
|
submit: String,
|
||||||
new: Boolean = false,
|
new: Boolean = false,
|
||||||
footer: FlowContent.() -> Unit,
|
footer: FlowContent.() -> Unit,
|
||||||
) = renderPage(title = title, description, loggedInUser = loggedInUser) {
|
) = renderPage(title = title, description, jwtPayload = jwtPayload) {
|
||||||
div("centered container mx-auto flex justify-center items-center") {
|
div("centered container mx-auto flex justify-center items-center") {
|
||||||
div("w-full md:w-1/2 lg:w-1/3 m-4") {
|
div("w-full md:w-1/2 lg:w-1/3 m-4") {
|
||||||
div("p-8 mb-6") {
|
div("p-8 mb-6") {
|
||||||
@@ -63,14 +61,14 @@ class UserView(@Named("styles") styles: String) : View(styles) {
|
|||||||
id = "username",
|
id = "username",
|
||||||
placeholder = "Username",
|
placeholder = "Username",
|
||||||
autoComplete = "username",
|
autoComplete = "username",
|
||||||
error = validationErrors.find { it.dataPath == ".username" }?.message,
|
error = validationErrors.find { it.dataPath == ".username" }?.message
|
||||||
)
|
)
|
||||||
input(
|
input(
|
||||||
id = "password",
|
id = "password",
|
||||||
placeholder = "Password",
|
placeholder = "Password",
|
||||||
autoComplete = "new-password",
|
autoComplete = "new-password",
|
||||||
type = InputType.password,
|
type = InputType.password,
|
||||||
error = validationErrors.find { it.dataPath == ".password" }?.message,
|
error = validationErrors.find { it.dataPath == ".password" }?.message
|
||||||
)
|
)
|
||||||
submitButton(submit)
|
submitButton(submit)
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
package be.simplenotes.views
|
package be.simplenotes.app.views
|
||||||
|
|
||||||
import be.simplenotes.types.LoggedInUser
|
import be.simplenotes.app.utils.StaticFileResolver
|
||||||
import be.simplenotes.views.components.navbar
|
import be.simplenotes.app.views.components.navbar
|
||||||
|
import be.simplenotes.domain.security.JwtPayload
|
||||||
import kotlinx.html.*
|
import kotlinx.html.*
|
||||||
import kotlinx.html.stream.appendHTML
|
import kotlinx.html.stream.appendHTML
|
||||||
|
|
||||||
abstract class View(private val styles: String) {
|
abstract class View(staticFileResolver: StaticFileResolver) {
|
||||||
|
|
||||||
|
private val styles = staticFileResolver.resolve("styles.css")!!
|
||||||
|
|
||||||
fun renderPage(
|
fun renderPage(
|
||||||
title: String,
|
title: String,
|
||||||
description: String? = null,
|
description: String? = null,
|
||||||
loggedInUser: LoggedInUser?,
|
jwtPayload: JwtPayload?,
|
||||||
scripts: List<String> = emptyList(),
|
scripts: List<String> = emptyList(),
|
||||||
body: MAIN.() -> Unit = {},
|
body: MAIN.() -> Unit = {},
|
||||||
) = buildString {
|
) = buildString {
|
||||||
@@ -28,26 +31,15 @@ abstract class View(private val styles: String) {
|
|||||||
attributes["crossorigin"] = "anonymous"
|
attributes["crossorigin"] = "anonymous"
|
||||||
}
|
}
|
||||||
link(rel = "stylesheet", href = styles)
|
link(rel = "stylesheet", href = styles)
|
||||||
icons()
|
link(rel = "shortcut icon", href = "/favicon.ico", type = "image/x-icon")
|
||||||
scripts.forEach { src ->
|
scripts.forEach { src ->
|
||||||
script(src = src) {}
|
script(src = src) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
body("bg-gray-900 text-white") {
|
body("bg-gray-900 text-white") {
|
||||||
navbar(loggedInUser)
|
navbar(jwtPayload)
|
||||||
main { body() }
|
main { body() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
private inline fun HEAD.icons() {
|
|
||||||
link(rel = "apple-touch-icon", href = "/apple-touch-icon.png") { attributes["sizes"] = "180x180" }
|
|
||||||
link(rel = "icon", href = "/favicon-32x32.png", type = "image/png") { attributes["sizes"] = "32x32" }
|
|
||||||
link(rel = "icon", href = "/favicon-16x16.png", type = "image/png") { attributes["sizes"] = "16x16" }
|
|
||||||
link(rel = "manifest", href = "/site.webmanifest")
|
|
||||||
link(rel = "mask-icon", href = "/safari-pinned-tab.svg") { attributes["color"] = "#2c7a7b" }
|
|
||||||
meta(name = "msapplication-TileColor", content = "#00aba9")
|
|
||||||
meta(name = "theme-color", content = "#2c7a7b")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package be.simplenotes.views.components
|
package be.simplenotes.app.views.components
|
||||||
|
|
||||||
import kotlinx.html.*
|
import kotlinx.html.*
|
||||||
|
|
||||||
internal fun FlowContent.alert(type: Alert, title: String, details: String? = null, multiline: Boolean = false) {
|
fun FlowContent.alert(type: Alert, title: String, details: String? = null, multiline: Boolean = false) {
|
||||||
val colors = when (type) {
|
val colors = when (type) {
|
||||||
Alert.Success -> "bg-green-500 border border-green-400 text-gray-800"
|
Alert.Success -> "bg-green-500 border border-green-400 text-gray-800"
|
||||||
Alert.Warning -> "bg-red-500 border border-red-400 text-red-200"
|
Alert.Warning -> "bg-red-500 border border-red-400 text-red-200"
|
||||||
@@ -17,6 +17,6 @@ internal fun FlowContent.alert(type: Alert, title: String, details: String? = nu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal enum class Alert {
|
enum class Alert {
|
||||||
Success, Warning
|
Success, Warning
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
package be.simplenotes.views.components
|
package be.simplenotes.app.views.components
|
||||||
|
|
||||||
import be.simplenotes.types.PersistedNoteMetadata
|
import be.simplenotes.app.utils.toTimeAgo
|
||||||
import be.simplenotes.views.utils.toTimeAgo
|
import be.simplenotes.domain.model.PersistedNoteMetadata
|
||||||
import kotlinx.html.*
|
import kotlinx.html.*
|
||||||
import kotlinx.html.ButtonType.submit
|
import kotlinx.html.ButtonType.submit
|
||||||
import kotlinx.html.FormMethod.post
|
import kotlinx.html.FormMethod.post
|
||||||
import kotlinx.html.ThScope.col
|
import kotlinx.html.ThScope.col
|
||||||
|
|
||||||
internal fun FlowContent.deletedNoteTable(notes: List<PersistedNoteMetadata>) = div("overflow-x-auto") {
|
fun FlowContent.deletedNoteTable(notes: List<PersistedNoteMetadata>) = div("overflow-x-auto") {
|
||||||
table {
|
table {
|
||||||
id = "notes"
|
id = "notes"
|
||||||
thead {
|
thead {
|
||||||
@@ -25,8 +25,8 @@ internal fun FlowContent.deletedNoteTable(notes: List<PersistedNoteMetadata>) =
|
|||||||
td("text-center") { +updatedAt.toTimeAgo() }
|
td("text-center") { +updatedAt.toTimeAgo() }
|
||||||
td { tags(tags) }
|
td { tags(tags) }
|
||||||
td("text-center") {
|
td("text-center") {
|
||||||
form(method = post, action = "/notes/deleted/$uuid") {
|
form(classes = "inline", method = post, action = "/notes/deleted/$uuid") {
|
||||||
button(classes = "btn btn-red mb-2", type = submit, name = "delete") {
|
button(classes = "btn btn-red", type = submit, name = "delete") {
|
||||||
+"Delete permanently"
|
+"Delete permanently"
|
||||||
}
|
}
|
||||||
button(classes = "ml-2 btn btn-green", type = submit, name = "restore") {
|
button(classes = "ml-2 btn btn-green", type = submit, name = "restore") {
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
package be.simplenotes.views.components
|
package be.simplenotes.app.views.components
|
||||||
|
|
||||||
import kotlinx.html.*
|
import kotlinx.html.*
|
||||||
import kotlinx.html.ButtonType.submit
|
import kotlinx.html.ButtonType.submit
|
||||||
|
|
||||||
internal fun FlowContent.input(
|
fun FlowContent.input(
|
||||||
type: InputType = InputType.text,
|
type: InputType = InputType.text,
|
||||||
placeholder: String,
|
placeholder: String,
|
||||||
id: String,
|
id: String,
|
||||||
autoComplete: String? = null,
|
autoComplete: String? = null,
|
||||||
error: String? = null,
|
error: String? = null
|
||||||
) {
|
) {
|
||||||
val colors = "bg-gray-800 border-gray-700 focus:border-teal-500 text-white"
|
val colors = "bg-gray-800 border-gray-700 focus:border-teal-500 text-white"
|
||||||
div("mb-8") {
|
div("mb-8") {
|
||||||
input(
|
input(
|
||||||
type = type,
|
type = type,
|
||||||
classes = "$colors rounded w-full border appearance-none focus:outline-none text-base p-2",
|
classes = "$colors rounded w-full border appearance-none focus:outline-none text-base p-2"
|
||||||
) {
|
) {
|
||||||
attributes["placeholder"] = placeholder
|
attributes["placeholder"] = placeholder
|
||||||
attributes["aria-label"] = placeholder
|
attributes["aria-label"] = placeholder
|
||||||
@@ -26,11 +26,11 @@ internal fun FlowContent.input(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun FlowContent.submitButton(text: String) {
|
fun FlowContent.submitButton(text: String) {
|
||||||
div("flex items-center mt-6") {
|
div("flex items-center mt-6") {
|
||||||
button(
|
button(
|
||||||
type = submit,
|
type = submit,
|
||||||
classes = "btn btn-teal w-full",
|
classes = "btn btn-teal w-full"
|
||||||
) { +text }
|
) { +text }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package be.simplenotes.views.components
|
package be.simplenotes.app.views.components
|
||||||
|
|
||||||
import be.simplenotes.types.LoggedInUser
|
import be.simplenotes.domain.security.JwtPayload
|
||||||
import kotlinx.html.*
|
import kotlinx.html.*
|
||||||
|
|
||||||
internal fun BODY.navbar(loggedInUser: LoggedInUser?) {
|
fun BODY.navbar(jwtPayload: JwtPayload?) {
|
||||||
nav {
|
nav {
|
||||||
id = "navbar"
|
id = "navbar"
|
||||||
a("/") {
|
a("/") {
|
||||||
@@ -12,7 +12,7 @@ internal fun BODY.navbar(loggedInUser: LoggedInUser?) {
|
|||||||
}
|
}
|
||||||
ul("space-x-2") {
|
ul("space-x-2") {
|
||||||
id = "navigation"
|
id = "navigation"
|
||||||
if (loggedInUser != null) {
|
if (jwtPayload != null) {
|
||||||
val links = listOf(
|
val links = listOf(
|
||||||
"/notes" to "Notes",
|
"/notes" to "Notes",
|
||||||
"/settings" to "Settings",
|
"/settings" to "Settings",
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
package be.simplenotes.views.components
|
package be.simplenotes.app.views.components
|
||||||
|
|
||||||
import kotlinx.html.*
|
import kotlinx.html.*
|
||||||
import kotlinx.html.ButtonType.submit
|
import kotlinx.html.ButtonType.submit
|
||||||
import kotlinx.html.FormMethod.post
|
import kotlinx.html.FormMethod.post
|
||||||
|
|
||||||
internal fun DIV.noteListHeader(numberOfDeletedNotes: Int, query: String = "") {
|
fun DIV.noteListHeader(numberOfDeletedNotes: Int, query: String = "") {
|
||||||
div("flex justify-between mb-4") {
|
div("flex justify-between mb-4") {
|
||||||
h1("text-2xl underline") { +"Notes" }
|
h1("text-2xl underline") { +"Notes" }
|
||||||
span {
|
span {
|
||||||
a(
|
a(
|
||||||
href = "/notes/trash",
|
href = "/notes/trash",
|
||||||
classes = "btn btn-teal",
|
classes = "btn btn-teal"
|
||||||
) { +"Trash ($numberOfDeletedNotes)" }
|
) { +"Trash ($numberOfDeletedNotes)" }
|
||||||
a(
|
a(
|
||||||
href = "/notes/new",
|
href = "/notes/new",
|
||||||
classes = "ml-2 btn btn-green",
|
classes = "ml-2 btn btn-green"
|
||||||
) { +"New" }
|
) { +"New" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package be.simplenotes.views.components
|
package be.simplenotes.app.views.components
|
||||||
|
|
||||||
import be.simplenotes.types.PersistedNoteMetadata
|
import be.simplenotes.app.utils.toTimeAgo
|
||||||
import be.simplenotes.views.utils.toTimeAgo
|
import be.simplenotes.domain.model.PersistedNoteMetadata
|
||||||
import kotlinx.html.*
|
import kotlinx.html.*
|
||||||
import kotlinx.html.ThScope.col
|
import kotlinx.html.ThScope.col
|
||||||
|
|
||||||
internal fun FlowContent.noteTable(notes: List<PersistedNoteMetadata>) = div("overflow-x-auto") {
|
fun FlowContent.noteTable(notes: List<PersistedNoteMetadata>) = div("overflow-x-auto") {
|
||||||
table {
|
table {
|
||||||
id = "notes"
|
id = "notes"
|
||||||
thead {
|
thead {
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -1,47 +0,0 @@
|
|||||||
package be.simplenotes.app.modules
|
|
||||||
|
|
||||||
import be.simplenotes.app.filters.auth.*
|
|
||||||
import be.simplenotes.domain.security.SimpleJwt
|
|
||||||
import be.simplenotes.types.LoggedInUser
|
|
||||||
import io.micronaut.context.annotation.Factory
|
|
||||||
import io.micronaut.context.annotation.Primary
|
|
||||||
import org.http4k.core.RequestContexts
|
|
||||||
import org.http4k.lens.RequestContextKey
|
|
||||||
import jakarta.inject.Named
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
@Factory
|
|
||||||
class AuthModule {
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
@Named("optional")
|
|
||||||
fun optionalAuthLens(ctx: RequestContexts): OptionalAuthLens = RequestContextKey.optional(ctx)
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
@Named("required")
|
|
||||||
fun requiredAuthLens(ctx: RequestContexts): RequiredAuthLens = RequestContextKey.required(ctx)
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
fun optionalAuth(simpleJwt: SimpleJwt<LoggedInUser>, @Named("optional") lens: OptionalAuthLens) =
|
|
||||||
OptionalAuthFilter(simpleJwt, lens)
|
|
||||||
|
|
||||||
@Primary
|
|
||||||
@Singleton
|
|
||||||
fun requiredAuth(simpleJwt: SimpleJwt<LoggedInUser>, @Named("required") lens: RequiredAuthLens) =
|
|
||||||
RequiredAuthFilter(simpleJwt, lens)
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
@Named("api")
|
|
||||||
internal fun apiAuthFilter(
|
|
||||||
simpleJwt: SimpleJwt<LoggedInUser>,
|
|
||||||
@Named("required") lens: RequiredAuthLens,
|
|
||||||
) = RequiredAuthFilter(
|
|
||||||
simpleJwt = simpleJwt,
|
|
||||||
lens = lens,
|
|
||||||
source = JwtSource.Header,
|
|
||||||
redirect = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
fun requestContexts() = RequestContexts()
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package be.simplenotes.app.modules
|
|
||||||
|
|
||||||
import be.simplenotes.app.jetty.ConnectorBuilder
|
|
||||||
import be.simplenotes.app.jetty.Jetty
|
|
||||||
import be.simplenotes.app.routes.Router
|
|
||||||
import be.simplenotes.app.utils.StaticFileResolver
|
|
||||||
import be.simplenotes.config.ServerConfig
|
|
||||||
import io.micronaut.context.annotation.Factory
|
|
||||||
import org.eclipse.jetty.server.ServerConnector
|
|
||||||
import org.http4k.server.Http4kServer
|
|
||||||
import org.http4k.server.asServer
|
|
||||||
import jakarta.inject.Named
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
import org.eclipse.jetty.server.Server as JettyServer
|
|
||||||
import org.http4k.server.ServerConfig as Http4kServerConfig
|
|
||||||
|
|
||||||
@Factory
|
|
||||||
class ServerModule {
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
@Named("styles")
|
|
||||||
fun styles(resolver: StaticFileResolver) = resolver.resolve("styles.css")!!
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
fun http4kServer(router: Router, serverConfig: Http4kServerConfig): Http4kServer =
|
|
||||||
router().asServer(serverConfig)
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
fun http4kServerConfig(config: ServerConfig): Http4kServerConfig {
|
|
||||||
val builder: ConnectorBuilder = { server: JettyServer ->
|
|
||||||
ServerConnector(server).apply {
|
|
||||||
port = config.port
|
|
||||||
host = config.host
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Jetty(config.port, builder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package be.simplenotes.app.routes
|
|
||||||
|
|
||||||
import be.simplenotes.app.api.ApiNoteController
|
|
||||||
import be.simplenotes.app.api.ApiUserController
|
|
||||||
import be.simplenotes.app.filters.auth.RequiredAuthFilter
|
|
||||||
import be.simplenotes.app.filters.auth.RequiredAuthLens
|
|
||||||
import org.http4k.core.Method.*
|
|
||||||
import org.http4k.core.Request
|
|
||||||
import org.http4k.core.then
|
|
||||||
import org.http4k.routing.PathMethod
|
|
||||||
import org.http4k.routing.RoutingHttpHandler
|
|
||||||
import org.http4k.routing.bind
|
|
||||||
import org.http4k.routing.routes
|
|
||||||
import java.util.function.Supplier
|
|
||||||
import jakarta.inject.Named
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class ApiRoutes(
|
|
||||||
private val apiUserController: ApiUserController,
|
|
||||||
private val apiNoteController: ApiNoteController,
|
|
||||||
@Named("api") private val auth: RequiredAuthFilter,
|
|
||||||
@Named("required") private val authLens: RequiredAuthLens,
|
|
||||||
) : Supplier<RoutingHttpHandler> {
|
|
||||||
override fun get(): RoutingHttpHandler {
|
|
||||||
infix fun PathMethod.to(action: ProtectedHandler) =
|
|
||||||
this to { req: Request -> action(req, authLens(req)) }
|
|
||||||
|
|
||||||
return routes(
|
|
||||||
"/login" bind POST to apiUserController::login,
|
|
||||||
|
|
||||||
with(apiNoteController) {
|
|
||||||
auth.then(
|
|
||||||
routes(
|
|
||||||
"/" bind GET to ::notes,
|
|
||||||
"/" bind POST to ::createNote,
|
|
||||||
"/search" bind POST to ::search,
|
|
||||||
"/{uuid}" bind GET to ::note,
|
|
||||||
"/{uuid}" bind PUT to ::update,
|
|
||||||
),
|
|
||||||
).withBasePath("/notes")
|
|
||||||
},
|
|
||||||
|
|
||||||
).withBasePath("/api")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package be.simplenotes.app.routes
|
|
||||||
|
|
||||||
import be.simplenotes.app.controllers.BaseController
|
|
||||||
import be.simplenotes.app.controllers.NoteController
|
|
||||||
import be.simplenotes.app.controllers.UserController
|
|
||||||
import be.simplenotes.app.filters.ImmutableFilter
|
|
||||||
import be.simplenotes.app.filters.auth.OptionalAuthFilter
|
|
||||||
import be.simplenotes.app.filters.auth.OptionalAuthLens
|
|
||||||
import org.http4k.core.ContentType
|
|
||||||
import org.http4k.core.Method.GET
|
|
||||||
import org.http4k.core.Method.POST
|
|
||||||
import org.http4k.core.Request
|
|
||||||
import org.http4k.core.then
|
|
||||||
import org.http4k.routing.*
|
|
||||||
import java.util.function.Supplier
|
|
||||||
import jakarta.inject.Named
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class BasicRoutes(
|
|
||||||
private val baseCtrl: BaseController,
|
|
||||||
private val userCtrl: UserController,
|
|
||||||
private val noteCtrl: NoteController,
|
|
||||||
@Named("optional") private val authLens: OptionalAuthLens,
|
|
||||||
private val auth: OptionalAuthFilter,
|
|
||||||
) : Supplier<RoutingHttpHandler> {
|
|
||||||
|
|
||||||
override fun get(): RoutingHttpHandler {
|
|
||||||
infix fun PathMethod.to(action: PublicHandler) =
|
|
||||||
this to { req: Request -> action(req, authLens(req)) }
|
|
||||||
|
|
||||||
val staticHandler = ImmutableFilter.then(
|
|
||||||
static(
|
|
||||||
ResourceLoader.Classpath("/static"),
|
|
||||||
"woff2" to ContentType("font/woff2"),
|
|
||||||
"webmanifest" to ContentType("application/manifest+json"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return routes(
|
|
||||||
auth.then(
|
|
||||||
routes(
|
|
||||||
"/" bind GET to baseCtrl::index,
|
|
||||||
"/register" bind GET to userCtrl::register,
|
|
||||||
"/register" bind POST to userCtrl::register,
|
|
||||||
"/login" bind GET to userCtrl::login,
|
|
||||||
"/login" bind POST to userCtrl::login,
|
|
||||||
"/logout" bind POST to userCtrl::logout,
|
|
||||||
"/notes/public/{uuid}" bind GET to noteCtrl::public,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
staticHandler,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package be.simplenotes.app.routes
|
|
||||||
|
|
||||||
import be.simplenotes.app.controllers.NoteController
|
|
||||||
import be.simplenotes.app.filters.auth.RequiredAuthFilter
|
|
||||||
import be.simplenotes.app.filters.auth.RequiredAuthLens
|
|
||||||
import org.http4k.core.Method.GET
|
|
||||||
import org.http4k.core.Method.POST
|
|
||||||
import org.http4k.core.Request
|
|
||||||
import org.http4k.core.then
|
|
||||||
import org.http4k.routing.PathMethod
|
|
||||||
import org.http4k.routing.RoutingHttpHandler
|
|
||||||
import org.http4k.routing.bind
|
|
||||||
import org.http4k.routing.routes
|
|
||||||
import java.util.function.Supplier
|
|
||||||
import jakarta.inject.Named
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class NoteRoutes(
|
|
||||||
private val noteCtrl: NoteController,
|
|
||||||
private val auth: RequiredAuthFilter,
|
|
||||||
@Named("required") private val authLens: RequiredAuthLens,
|
|
||||||
) : Supplier<RoutingHttpHandler> {
|
|
||||||
override fun get(): RoutingHttpHandler {
|
|
||||||
infix fun PathMethod.to(action: ProtectedHandler) =
|
|
||||||
this to { req: Request -> action(req, authLens(req)) }
|
|
||||||
|
|
||||||
return auth.then(
|
|
||||||
with(noteCtrl) {
|
|
||||||
routes(
|
|
||||||
"/" bind GET to ::list,
|
|
||||||
"/" bind POST to ::search,
|
|
||||||
"/new" bind GET to ::new,
|
|
||||||
"/new" bind POST to ::new,
|
|
||||||
"/trash" bind GET to ::trash,
|
|
||||||
"/{uuid}" bind GET to ::note,
|
|
||||||
"/{uuid}" bind POST to ::note,
|
|
||||||
"/{uuid}/edit" bind GET to ::edit,
|
|
||||||
"/{uuid}/edit" bind POST to ::edit,
|
|
||||||
"/deleted/{uuid}" bind POST to ::deleted,
|
|
||||||
).withBasePath("/notes")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package be.simplenotes.app.routes
|
|
||||||
|
|
||||||
import be.simplenotes.types.LoggedInUser
|
|
||||||
import org.http4k.core.Request
|
|
||||||
import org.http4k.core.Response
|
|
||||||
|
|
||||||
internal typealias PublicHandler = (Request, LoggedInUser?) -> Response
|
|
||||||
internal typealias ProtectedHandler = (Request, LoggedInUser) -> Response
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package be.simplenotes.app.routes
|
|
||||||
|
|
||||||
import be.simplenotes.app.filters.ErrorFilter
|
|
||||||
import be.simplenotes.app.filters.SecurityFilter
|
|
||||||
import org.http4k.core.RequestContexts
|
|
||||||
import org.http4k.core.then
|
|
||||||
import org.http4k.filter.ResponseFilters.GZip
|
|
||||||
import org.http4k.filter.ServerFilters.InitialiseRequestContext
|
|
||||||
import org.http4k.routing.RoutingHttpHandler
|
|
||||||
import org.http4k.routing.routes
|
|
||||||
import java.util.function.Supplier
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class Router(
|
|
||||||
private val errorFilter: ErrorFilter,
|
|
||||||
private val contexts: RequestContexts,
|
|
||||||
private val subRouters: List<Supplier<RoutingHttpHandler>>,
|
|
||||||
) {
|
|
||||||
operator fun invoke(): RoutingHttpHandler {
|
|
||||||
val routes = routes(
|
|
||||||
*subRouters.map { it.get() }.toTypedArray(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return errorFilter
|
|
||||||
.then(InitialiseRequestContext(contexts))
|
|
||||||
.then(SecurityFilter)
|
|
||||||
.then(GZip())
|
|
||||||
.then(routes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package be.simplenotes.app.routes
|
|
||||||
|
|
||||||
import be.simplenotes.app.controllers.SettingsController
|
|
||||||
import be.simplenotes.app.filters.auth.RequiredAuthFilter
|
|
||||||
import be.simplenotes.app.filters.auth.RequiredAuthLens
|
|
||||||
import org.http4k.core.Method.GET
|
|
||||||
import org.http4k.core.Method.POST
|
|
||||||
import org.http4k.core.Request
|
|
||||||
import org.http4k.core.then
|
|
||||||
import org.http4k.routing.PathMethod
|
|
||||||
import org.http4k.routing.RoutingHttpHandler
|
|
||||||
import org.http4k.routing.bind
|
|
||||||
import org.http4k.routing.routes
|
|
||||||
import java.util.function.Supplier
|
|
||||||
import jakarta.inject.Named
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class SettingsRoutes(
|
|
||||||
private val settingsController: SettingsController,
|
|
||||||
private val auth: RequiredAuthFilter,
|
|
||||||
@Named("required") private val authLens: RequiredAuthLens,
|
|
||||||
) : Supplier<RoutingHttpHandler> {
|
|
||||||
override fun get(): RoutingHttpHandler {
|
|
||||||
infix fun PathMethod.to(action: ProtectedHandler) =
|
|
||||||
this to { req: Request -> action(req, authLens(req)) }
|
|
||||||
|
|
||||||
return auth.then(
|
|
||||||
routes(
|
|
||||||
"/settings" bind GET to settingsController::settings,
|
|
||||||
"/settings" bind POST to settingsController::settings,
|
|
||||||
"/export" bind POST to settingsController::export,
|
|
||||||
"/import" bind POST to settingsController::import,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,15 @@
|
|||||||
package be.simplenotes.app.filters
|
package be.simplenotes.app.filters
|
||||||
|
|
||||||
import be.simplenotes.app.filters.auth.OptionalAuthFilter
|
import be.simplenotes.domain.security.JwtPayload
|
||||||
import be.simplenotes.app.filters.auth.OptionalAuthLens
|
import be.simplenotes.domain.security.JwtPayloadExtractor
|
||||||
import be.simplenotes.app.filters.auth.RequiredAuthFilter
|
|
||||||
import be.simplenotes.app.filters.auth.RequiredAuthLens
|
|
||||||
import be.simplenotes.config.JwtConfig
|
|
||||||
import be.simplenotes.domain.security.SimpleJwt
|
import be.simplenotes.domain.security.SimpleJwt
|
||||||
import be.simplenotes.domain.security.UserJwtMapper
|
import be.simplenotes.shared.config.JwtConfig
|
||||||
import be.simplenotes.types.LoggedInUser
|
|
||||||
import com.natpryce.hamkrest.assertion.assertThat
|
import com.natpryce.hamkrest.assertion.assertThat
|
||||||
import io.micronaut.context.BeanContext
|
import org.http4k.core.*
|
||||||
import io.micronaut.inject.qualifiers.Qualifiers
|
|
||||||
import org.http4k.core.Method.GET
|
import org.http4k.core.Method.GET
|
||||||
import org.http4k.core.Request
|
|
||||||
import org.http4k.core.RequestContexts
|
|
||||||
import org.http4k.core.Response
|
|
||||||
import org.http4k.core.Status.Companion.FOUND
|
import org.http4k.core.Status.Companion.FOUND
|
||||||
import org.http4k.core.Status.Companion.OK
|
import org.http4k.core.Status.Companion.OK
|
||||||
import org.http4k.core.cookie.cookie
|
import org.http4k.core.cookie.cookie
|
||||||
import org.http4k.core.then
|
|
||||||
import org.http4k.filter.ServerFilters
|
import org.http4k.filter.ServerFilters
|
||||||
import org.http4k.hamkrest.hasBody
|
import org.http4k.hamkrest.hasBody
|
||||||
import org.http4k.hamkrest.hasHeader
|
import org.http4k.hamkrest.hasHeader
|
||||||
@@ -29,37 +20,23 @@ import org.junit.jupiter.api.Nested
|
|||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
internal class RequiredAuthFilterTest {
|
internal class AuthFilterTest {
|
||||||
|
|
||||||
// region setup
|
// region setup
|
||||||
private val jwtConfig = JwtConfig("secret", 1, TimeUnit.HOURS)
|
private val jwtConfig = JwtConfig("secret", 1, TimeUnit.HOURS)
|
||||||
private val simpleJwt = SimpleJwt(jwtConfig, UserJwtMapper())
|
private val simpleJwt = SimpleJwt(jwtConfig)
|
||||||
|
private val extractor = JwtPayloadExtractor(simpleJwt)
|
||||||
|
private val ctx = RequestContexts()
|
||||||
|
private val requiredAuth = AuthFilter(extractor, AuthType.Required, ctx)()
|
||||||
|
private val optionalAuth = AuthFilter(extractor, AuthType.Optional, ctx)()
|
||||||
|
|
||||||
private val beanCtx = BeanContext.build()
|
private val echoJwtPayloadHandler = { request: Request -> Response(OK).body(request.jwtPayload(ctx).toString()) }
|
||||||
.registerSingleton(jwtConfig)
|
|
||||||
.start()
|
|
||||||
|
|
||||||
private inline fun <reified T> BeanContext.getBean(): T = getBean(T::class.java)
|
|
||||||
private inline fun <reified T> BeanContext.getBean(name: String): T =
|
|
||||||
getBean(T::class.java, Qualifiers.byName(name))
|
|
||||||
|
|
||||||
private val requiredAuth = beanCtx.getBean<RequiredAuthFilter>()
|
|
||||||
private val requiredLens = beanCtx.getBean<RequiredAuthLens>("required")
|
|
||||||
|
|
||||||
private val optionalAuth = beanCtx.getBean<OptionalAuthFilter>()
|
|
||||||
private val optionalLens = beanCtx.getBean<OptionalAuthLens>("optional")
|
|
||||||
|
|
||||||
private val ctx = beanCtx.getBean<RequestContexts>()
|
|
||||||
|
|
||||||
private val app = ServerFilters.InitialiseRequestContext(ctx).then(
|
private val app = ServerFilters.InitialiseRequestContext(ctx).then(
|
||||||
routes(
|
routes(
|
||||||
"/optional" bind GET to optionalAuth.then { request: Request ->
|
"/optional" bind GET to optionalAuth.then(echoJwtPayloadHandler),
|
||||||
Response(OK).body(optionalLens(request).toString())
|
"/protected" bind GET to requiredAuth.then(echoJwtPayloadHandler)
|
||||||
},
|
)
|
||||||
"/protected" bind GET to requiredAuth.then { request: Request ->
|
|
||||||
Response(OK).body(requiredLens(request).toString())
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
@@ -81,7 +58,7 @@ internal class RequiredAuthFilterTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `it should allow a valid token`() {
|
fun `it should allow a valid token`() {
|
||||||
val jwtPayload = LoggedInUser(1, "user")
|
val jwtPayload = JwtPayload(1, "user")
|
||||||
val token = simpleJwt.sign(jwtPayload)
|
val token = simpleJwt.sign(jwtPayload)
|
||||||
val response = app(Request(GET, "/optional").cookie("Bearer", token))
|
val response = app(Request(GET, "/optional").cookie("Bearer", token))
|
||||||
assertThat(response, hasStatus(OK))
|
assertThat(response, hasStatus(OK))
|
||||||
@@ -106,8 +83,8 @@ internal class RequiredAuthFilterTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `it should allow a valid token`() {
|
fun `it should allow a valid token"`() {
|
||||||
val jwtPayload = LoggedInUser(1, "user")
|
val jwtPayload = JwtPayload(1, "user")
|
||||||
val token = simpleJwt.sign(jwtPayload)
|
val token = simpleJwt.sign(jwtPayload)
|
||||||
val response = app(Request(GET, "/protected").cookie("Bearer", token))
|
val response = app(Request(GET, "/protected").cookie("Bearer", token))
|
||||||
assertThat(response, hasStatus(OK))
|
assertThat(response, hasStatus(OK))
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
package be.simplenotes.domain.utils
|
package be.simplenotes.app.utils
|
||||||
|
|
||||||
import be.simplenotes.search.SearchTerms
|
import be.simplenotes.domain.usecases.search.SearchTerms
|
||||||
import com.natpryce.hamkrest.assertion.assertThat
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
import com.natpryce.hamkrest.equalTo
|
|
||||||
import org.junit.jupiter.params.ParameterizedTest
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
import org.junit.jupiter.params.provider.MethodSource
|
import org.junit.jupiter.params.provider.MethodSource
|
||||||
import java.util.stream.Stream
|
import java.util.stream.Stream
|
||||||
@@ -14,7 +13,7 @@ internal class SearchTermsParserKtTest {
|
|||||||
title: String? = null,
|
title: String? = null,
|
||||||
tag: String? = null,
|
tag: String? = null,
|
||||||
content: String? = null,
|
content: String? = null,
|
||||||
all: String? = null,
|
all: String? = null
|
||||||
): Pair<String, SearchTerms> = input to SearchTerms(title, tag, content, all)
|
): Pair<String, SearchTerms> = input to SearchTerms(title, tag, content, all)
|
||||||
|
|
||||||
@Suppress("Unused")
|
@Suppress("Unused")
|
||||||
@@ -31,23 +30,13 @@ internal class SearchTermsParserKtTest {
|
|||||||
createResult("tag:'example' title:'other' end", title = "other", tag = "example", all = "end"),
|
createResult("tag:'example' title:'other' end", title = "other", tag = "example", all = "end"),
|
||||||
createResult(
|
createResult(
|
||||||
"tag:'example abc' title:'other with words' this is the end ",
|
"tag:'example abc' title:'other with words' this is the end ",
|
||||||
title = "other with words",
|
title = "other with words", tag = "example abc", all = "this is the end"
|
||||||
tag = "example abc",
|
|
||||||
all = "this is the end",
|
|
||||||
),
|
|
||||||
createResult("tag:blah", tag = "blah"),
|
|
||||||
createResult("tag:'some words'", tag = "some words"),
|
|
||||||
createResult("tag:'some words ' global", tag = "some words ", all = "global"),
|
|
||||||
createResult(
|
|
||||||
"tag:'double quote inside single \" ' global",
|
|
||||||
tag = "double quote inside single \" ",
|
|
||||||
all = "global",
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@MethodSource("results")
|
@MethodSource("results")
|
||||||
fun `valid search parser`(case: Pair<String, SearchTerms>) {
|
fun `valid search parser`(case: Pair<String, SearchTerms>) {
|
||||||
assertThat(parseSearchTerms(case.first), equalTo(case.second))
|
assertThat(parseSearchTerms(case.first)).isEqualTo(case.second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("be.simplenotes.versions")
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
plugins {
|
|
||||||
`kotlin-dsl`
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.21")
|
|
||||||
implementation("org.jetbrains.kotlin:kotlin-serialization:1.8.21")
|
|
||||||
implementation("com.github.johnrengelman:shadow:8.1.1")
|
|
||||||
implementation("com.diffplug.spotless:spotless-plugin-gradle:6.13.0")
|
|
||||||
implementation("com.github.ben-manes:gradle-versions-plugin:0.46.0")
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package be.simplenotes
|
|
||||||
|
|
||||||
import org.gradle.api.Plugin
|
|
||||||
import org.gradle.api.Project
|
|
||||||
import org.gradle.kotlin.dsl.create
|
|
||||||
|
|
||||||
open class DockerPluginExtension {
|
|
||||||
var image: String? = null
|
|
||||||
var tag = "latest"
|
|
||||||
}
|
|
||||||
|
|
||||||
class DockerPlugin : Plugin<Project> {
|
|
||||||
override fun apply(project: Project) {
|
|
||||||
val extension = project.extensions.create<DockerPluginExtension>("docker")
|
|
||||||
|
|
||||||
project.task("dockerBuild") {
|
|
||||||
dependsOn("package")
|
|
||||||
|
|
||||||
group = "docker"
|
|
||||||
description = "Build a docker image"
|
|
||||||
|
|
||||||
doLast {
|
|
||||||
project.exec {
|
|
||||||
commandLine("docker", "build", "-t", "${extension.image}:${extension.tag}", ".")
|
|
||||||
workingDir(project.rootProject.projectDir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
project.task("dockerPush") {
|
|
||||||
dependsOn("dockerBuild")
|
|
||||||
|
|
||||||
group = "docker"
|
|
||||||
description = "Push a docker image"
|
|
||||||
|
|
||||||
doLast {
|
|
||||||
project.exec {
|
|
||||||
commandLine("docker", "push", "${extension.image}:${extension.tag}")
|
|
||||||
workingDir(project.rootProject.projectDir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
@file:Suppress("SpellCheckingInspection")
|
|
||||||
|
|
||||||
package be.simplenotes
|
|
||||||
|
|
||||||
object Libs {
|
|
||||||
|
|
||||||
object Micronaut {
|
|
||||||
private const val version = "4.0.0-M2"
|
|
||||||
const val inject = "io.micronaut:micronaut-inject:$version"
|
|
||||||
const val processor = "io.micronaut:micronaut-inject-java:$version"
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package be.simplenotes
|
|
||||||
|
|
||||||
import org.gradle.api.Plugin
|
|
||||||
import org.gradle.api.Project
|
|
||||||
import org.gradle.api.artifacts.dsl.DependencyHandler
|
|
||||||
import org.jetbrains.kotlin.gradle.plugin.KaptExtension
|
|
||||||
|
|
||||||
class MicronautPlugin : Plugin<Project> {
|
|
||||||
override fun apply(target: Project) {
|
|
||||||
target.plugins.apply("org.jetbrains.kotlin.kapt")
|
|
||||||
target.extensions.configure<KaptExtension>("kapt") {
|
|
||||||
arguments {
|
|
||||||
arg("micronaut.processing.incremental", true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun DependencyHandler.micronaut() {
|
|
||||||
add("kapt", Libs.Micronaut.processor)
|
|
||||||
add("implementation", Libs.Micronaut.inject)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun DependencyHandler.micronautTest() {
|
|
||||||
add("kaptTest", Libs.Micronaut.processor)
|
|
||||||
add("testImplementation", Libs.Micronaut.inject)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun DependencyHandler.micronautFixtures() {
|
|
||||||
add("kaptTestFixtures", Libs.Micronaut.inject)
|
|
||||||
add("testFixturesImplementation", Libs.Micronaut.processor)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package be.simplenotes
|
|
||||||
|
|
||||||
import org.gradle.api.Plugin
|
|
||||||
import org.gradle.api.Project
|
|
||||||
import org.gradle.api.plugins.JavaPluginExtension
|
|
||||||
import org.gradle.api.tasks.SourceSetContainer
|
|
||||||
import org.gradle.kotlin.dsl.get
|
|
||||||
import org.gradle.kotlin.dsl.getByType
|
|
||||||
import org.gradle.kotlin.dsl.register
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class PostcssPlugin : Plugin<Project> {
|
|
||||||
override fun apply(project: Project) {
|
|
||||||
with(project.tasks) {
|
|
||||||
register<PostcssTask>("postcss") {
|
|
||||||
group = "postcss"
|
|
||||||
description = "generate postcss resources"
|
|
||||||
}
|
|
||||||
getByName("processResources").dependsOn("postcss")
|
|
||||||
}
|
|
||||||
|
|
||||||
val sourceSets = project.extensions.getByType<SourceSetContainer>()
|
|
||||||
val root = File("${project.buildDir}/generated-resources/css")
|
|
||||||
sourceSets["main"].resources.srcDir(root)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package be.simplenotes
|
|
||||||
|
|
||||||
import org.gradle.api.DefaultTask
|
|
||||||
import org.gradle.api.GradleException
|
|
||||||
import org.gradle.api.tasks.*
|
|
||||||
import org.gradle.kotlin.dsl.getByType
|
|
||||||
import java.io.File
|
|
||||||
import java.lang.ProcessBuilder.Redirect.PIPE
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlin.concurrent.thread
|
|
||||||
|
|
||||||
open class PostcssTask : DefaultTask() {
|
|
||||||
|
|
||||||
private val viewsProject = project
|
|
||||||
.parent
|
|
||||||
?.project(":views")
|
|
||||||
?: error("Missing :views")
|
|
||||||
|
|
||||||
@get:InputDirectory
|
|
||||||
val templatesDir = viewsProject.extensions
|
|
||||||
.getByType<SourceSetContainer>()
|
|
||||||
.asMap.getOrElse("main") { error("main sources not found") }
|
|
||||||
.allSource.srcDirs
|
|
||||||
.find { it.endsWith("src") }
|
|
||||||
?: error("kotlin sources not found")
|
|
||||||
|
|
||||||
private val yarnRoot = File(project.rootDir, "css")
|
|
||||||
|
|
||||||
@get:InputDirectory
|
|
||||||
val postCssDir = File(project.rootDir, "css/src")
|
|
||||||
|
|
||||||
@get:InputFiles
|
|
||||||
val postCssConfig = listOf(
|
|
||||||
"tailwind.config.js",
|
|
||||||
"postcss.config.js",
|
|
||||||
"package.json"
|
|
||||||
).map { File(yarnRoot, it) }
|
|
||||||
|
|
||||||
@get:OutputDirectory
|
|
||||||
val outputRootDir = File(project.buildDir, "generated-resources/css")
|
|
||||||
|
|
||||||
private val cssIndex = File(postCssDir, "styles.pcss")
|
|
||||||
|
|
||||||
private val cssOutput = File(outputRootDir, "static/styles.css")
|
|
||||||
private val manifestOutput = File(outputRootDir, "css-manifest.json")
|
|
||||||
|
|
||||||
private val purgeGlob = "$templatesDir/**/*.kt"
|
|
||||||
|
|
||||||
@TaskAction
|
|
||||||
fun generateCss() {
|
|
||||||
// TODO: auto yarn install ?
|
|
||||||
|
|
||||||
outputRootDir.deleteRecursively()
|
|
||||||
|
|
||||||
ProcessBuilder("yarn", "run", "postcss", "$cssIndex", "--output", "$cssOutput")
|
|
||||||
.apply {
|
|
||||||
environment().let {
|
|
||||||
it["MANIFEST"] = "$manifestOutput"
|
|
||||||
it["NODE_ENV"] = "production"
|
|
||||||
it["PURGE"] = purgeGlob
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.redirectOutput(PIPE)
|
|
||||||
.redirectError(PIPE)
|
|
||||||
.directory(yarnRoot)
|
|
||||||
.start()
|
|
||||||
.apply {
|
|
||||||
thread { inputStream.use { it.copyTo(System.out) } }
|
|
||||||
thread { errorStream.use { it.copyTo(System.out) } }
|
|
||||||
waitFor(30, TimeUnit.SECONDS)
|
|
||||||
if (exitValue() != 0) throw GradleException(":/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package be.simplenotes
|
|
||||||
|
|
||||||
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id("com.github.johnrengelman.shadow")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<ShadowJar> {
|
|
||||||
|
|
||||||
archiveAppendix.set("with-dependencies")
|
|
||||||
manifest.attributes["Main-Class"] = "be.simplenotes.app.SimpleNotesKt"
|
|
||||||
|
|
||||||
// johnrengelman/shadow#449
|
|
||||||
// we need this for lucene-core
|
|
||||||
manifest.attributes["Multi-Release"] = "true"
|
|
||||||
|
|
||||||
mergeServiceFiles()
|
|
||||||
|
|
||||||
File(rootProject.projectDir, "buildSrc/src/main/resources/exclusions")
|
|
||||||
.listFiles()!!
|
|
||||||
.flatMap {
|
|
||||||
it.readLines()
|
|
||||||
.asSequence()
|
|
||||||
.map { it.trim() }
|
|
||||||
.filterNot { it.isBlank() }
|
|
||||||
.filterNot { it.startsWith("#") }
|
|
||||||
.asIterable()
|
|
||||||
}.forEach { exclude(it) }
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.create("package") {
|
|
||||||
tasks.getByName("build").dependsOn("package")
|
|
||||||
|
|
||||||
dependsOn("shadowJar")
|
|
||||||
|
|
||||||
doLast {
|
|
||||||
println("SimpleNotes Packaged !")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package be.simplenotes
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id("be.simplenotes.java-convention")
|
|
||||||
id("be.simplenotes.kotlin-convention")
|
|
||||||
id("be.simplenotes.junit-convention")
|
|
||||||
id("com.diffplug.spotless")
|
|
||||||
}
|
|
||||||
|
|
||||||
spotless {
|
|
||||||
kotlin {
|
|
||||||
ktlint("0.48.0").setEditorConfigPath(project.rootProject.file(".editorconfig"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||