Remove nuxt + 100 other things..

This commit is contained in:
Hubert Van De Walle 2020-07-08 19:46:04 +02:00
parent 3b80ae051d
commit 44b463d9d5
132 changed files with 6202 additions and 10961 deletions

6
.gitignore vendored
View File

@ -123,3 +123,9 @@ sw.*
# Certificates
data/
letsencrypt/
# resources
resources/css-manifest.json
resources/docs/index.html
resources/static/*.css

View File

@ -1,104 +1,7 @@
(security) {
header * {
-Server
-Date
Strict-Transport-Security "max-age=31536000; includeSubDomains"
Feature-Policy "geolocation none; midi none; notifications none; push none; sync-xhr none; microphone none; camera none; magnetometer none; gyroscope none; speaker self; vibrate none; fullscreen self; payment none"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
X-XSS-Protection "1; mode=block"
Referrer-Policy "no-referrer-when-downgrade"
}
}
(common) {
@ignore {
path *.php
}
respond @ignore "no" 404
try_files {path} {path}/ {path}/index.html
encode gzip
}
(nuxt) {
@nuxt {
path /_nuxt/*
}
header @nuxt Cache-Control "public, max-age=31536000" # 1 year
}
simplenotes.be {
import security
import nuxt
import common
@404 {
expression {http.error.status_code} == 404
}
handle_errors {
rewrite @404 /404.html
file_server
import security
}
route /* {
file_server
}
route /api/* {
uri strip_prefix /api
reverse_proxy http://localhost:8081
}
header Content-Security-Policy "default-src 'self' 'unsafe-inline';"
root * /var/www/simplenotes.be
log {
output file /var/log/www/simplenotes.be.json
format json
}
reverse_proxy http://localhost:8081
}
www.simplenotes.be {
redir * https://simplenotes.be{path}
}
docs.simplenotes.be {
import security
import common
file_server
root * /var/www/docs.simplenotes.be
log {
output file /var/log/www/docs.simplenotes.be.json
format json
}
header Content-Security-Policy "default-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;"
}
portfolio.simplenotes.be {
import security
import common
import nuxt
file_server
root * /var/www/portfolio.simplenotes.be
log {
output file /var/log/www/portfolio.simplenotes.be.json
format json
}
# header Content-Security-Policy "default-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;"
}

View File

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

View File

@ -1,162 +1,129 @@
# Data Structures
## Chapter (object)
+ title: Chapter 1 (string)
+ content: ... (string)
# Group Notes
## Notes [/notes]
### Create a Note [POST]
+ Request (application/json)
+ Request (text/markdown; charset=UTF-8)
+ Headers
Authorization: Bearer <token>
+ Attributes (object)
+ title: `This is a title` (string)
+ tags: Dev, Server (array[string])
+ chapters (array)
+ (object)
+ title: `Chapter 1` (string)
+ content: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.` (string)
Authorization: Bearer <token>
Accept: application/json
+ Body
---
title: example
tags: ["some", "tags"]
---
# A story
- a
- b
+ Response 201 (application/json)
+ Attributes (object)
+ uuid: `107c90ae-a41e-4c8e-b5e3-1a269cfe044b` (string)
### Get Notes [GET]
{
"title": "example",
"tags": [
"some",
"tags"
],
"markdown": "---\ntitle: example\ntags: [\"some\", \"tags\"]\n---\n# A story\n\n- a\n- b",
"html": "<h1>A story</h1>\n<ul><li>a</li><li>b</li></ul>\n",
"uuid": "42aa1078-130e-47ee-b82d-b1d62f3ea054",
"updatedAt": "2020-07-16T01:03:46.7766"
}
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Response 200 (application/json)
+ Attributes (array)
+ (object)
+ uuid: `123e4567-e89b-12d3-a456-426614174000` (string)
+ title: Kotlin (string)
+ tags: Dev, Server (array[string])
+ updatedAt: `2020-01-20T00:00:00` (string)
+ (object)
+ uuid: `107c90ae-a41e-4c8e-b5e3-1a269cfe044b` (string)
+ title: Java (string)
+ tags: Dev (array[string])
+ updatedAt: `2018-01-20T00:00:00` (string)
## Note [/notes/{noteUuid}]
## Notes [/notes{?limit,after}]
+ Parameters
+ noteUuid: `123e4567-e89b-12d3-a456-426614174000` (string) - The note UUID.
+ limit: 10 (number, optional) - The number of notes to return
+ Default: `20`
+ after: `9bd36653-6397-4c5b-b8b7-158d9de208ef` (string, optional) - The UUID of the note before the requested ones
### Get All Notes [GET]
+ Request
+ Headers
Authorization: Bearer <token>
Accept: application/json
+ Response 200 (application/json)
+ Body
[
{
"uuid": "42aa1078-130e-47ee-b82d-b1d62f3ea054",
"title": "example",
"updatedAt": "2020-07-16T01:03:46",
"tags": [
"some",
"tags"
]
},
{
"uuid": "e61271e9-ba86-4428-a788-49946d1954c5",
"title": "test",
"updatedAt": "2020-07-16T00:22:02",
"tags": [
"babar",
"fait",
"du",
"ski"
]
},
]
## Note [/notes/{uuid}]
+ Parameters
+ uuid: `123e4567-e89b-12d3-a456-426614174000` (required, string) - The note UUID.
### Get a Note [GET]
+ Request (application/json)
+ Request
+ Headers
Authorization: Bearer <token>
Authorization: Bearer <token>
Accept: application/json
+ Response 200 (application/json)
+ Attributes (object)
+ uuid: `123e4567-e89b-12d3-a456-426614174000` (string)
+ title: `This is a title` (string)
+ updatedAt: `2020-05-08T11:56:01` (string)
+ tags: Dev, Server (array[string])
+ chapters (array)
+ (Chapter)
+ title: Introduction
+ content: ...
+ (Chapter)
+ title: Objects
+ content: ...
+ Response 404
+ Body
### Update a Note [PATCH]
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Attributes (object)
+ title: NewTitle (string)
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Attributes (object)
+ tags: new, tags (array[string])
+ Response 200
{
"uuid": "8ba68c64-11f1-4424-a0cb-cba54a65298f",
"title": "example",
"markdown": "---\ntitle: example\ntags: [\"some\", \"tags\"]\n---\n# A story\n\n- a\n- b",
"html": "<h1>A story</h1>\n<ul><li>a</li><li>b</li></ul>\n",
"updatedAt": "2020-07-16T01:13:37",
"tags": [
"some",
"tags"
]
}
+ Response 404
### Update a Note [PUT]
#### TODO
### Delete a Note [DELETE]
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Response 200
+ Response 404
## Chapters [/notes/{noteTitle}/chapters/{chapterNumber}]
+ Parameters
+ noteTitle: `Kotlin` (string) - The title of the Note.
+ chapterNumber: `Kotlin` (number) - The chapter number.
### Post a chapter [POST]
+ Request (application/json)
+ Request
+ Headers
Authorization: Bearer <token>
+ Attributes (Chapter)
+ title: Chapter 1 (string)
+ content: ... (string)
+ Response 201
+ Response 404
### Patch a chapter [PATCH]
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Attributes (object)
+ title: new title (string)
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
+ Attributes (object)
+ content: ... (string)
+ Response 200
+ Response 404
### Delete a chapter [DELETE]
+ Request (application/json)
+ Headers
Authorization: Bearer <token>
Authorization: Bearer <token>
Accept: application/json
+ Response 200

View File

@ -4,11 +4,18 @@
### Get all tags [GET]
+ Request (application/json)
+ Request
+ Headers
Authorization: Bearer <token>
Authorization: Bearer <token>
Accept: application/json
+ Response 200 (application/json)
+ Attributes
+ tags: Dev, Server (array[string])
+ Body
[
"markdown",
"md",
"code",
"java"
]

View File

@ -1,41 +1,37 @@
# Data Structures
## Login (object)
+ username: babar (string)
+ password: tortue (string)
## InvalidCredentials (object)
+ description: Invalid credentials (string),
+ error: Bad Request (string),
+ status_code: 401 (number)
# Group Accounts
## Account [/user]
### Register a new user [POST]
### Create an account [POST]
+ Request (application/json)
+ Attributes (object)
+ username: babar (string)
+ password: tortue (string)
+ Response 200 (application/json)
+ Attributes (object)
+ message: Created (string)
+ Headers
Accept: application/json
+ Body
{
"username": "user",
"password": "apassword"
}
+ Response 200
+ Response 409
+ Response 409 (application/json)
+ Attributes (object)
+ message: User already exists (string)
### Delete a user [DELETE]
+ Request
+ Headers
Authorization: Bearer <token>
+ Response 200 (application/json)
Authorization: Bearer <token>
Accept: application/json
+ Response 200
+ Response 404
## Authentication [/user/login]
@ -44,43 +40,72 @@ Authenticate one user to access protected routing.
### Authenticate a user [POST]
+ Request (application/json)
+ Attributes (Login)
+ Headers
Accept: application/json
+ Body
{
"username": "user",
"password": "myrealpassword"
}
+ Response 200 (application/json)
+ Attributes
+ token: <token>
+ refreshToken: `<refresh-token>`
+ Body
+ Response 401 (application/json)
+ Attributes (InvalidCredentials)
{
"token": "<token>",
"refreshToken": "<token>"
}
+ Response 401
## Token refresh [/user/refresh_token]
### Refresh JWT token [POST]
+ Request (application/json)
+ Attributes
+ refreshToken: `<refresh-token>`
+ Headers
Accept: application/json
+ Body
{
"refreshToken": "<refresh-token>"
}
+ Response 200 (application/json)
+ Attributes
+ token: <token>
+ refreshToken: `<refresh-token>`
+ Body
{
"token": "<token>",
"refreshToken": "<refresh-token>"
}
+ Response 401
+ Response 401 (application/json)
+ Attributes (InvalidCredentials)
## User Info [/user/me]
Receive the username and email from the currently logged in user
### Get User Info [GET]
+ Request (application/json)
+ Request
+ Headers
Authorization: Bearer <token>
Authorization: Bearer <token>
Accept: application/json
+ Response 200 (application/json)
+ Attributes
+ user: (object)
+ username: babar (string)
+ Body
{
"user": {
"username": "user"
}
}
+ Response 401

View File

@ -1,56 +0,0 @@
package be.vandewalleh
import be.vandewalleh.auth.AuthenticationModule
import be.vandewalleh.auth.SimpleJWT
import be.vandewalleh.extensions.ApplicationBuilder
import be.vandewalleh.extensions.RoutingBuilder
import be.vandewalleh.factories.configurationFactory
import be.vandewalleh.factories.dataSourceFactory
import be.vandewalleh.factories.databaseFactory
import be.vandewalleh.factories.simpleJwtFactory
import be.vandewalleh.features.*
import be.vandewalleh.routing.NoteRoutes
import be.vandewalleh.routing.TagRoutes
import be.vandewalleh.routing.UserRoutes
import be.vandewalleh.services.NoteService
import be.vandewalleh.services.UserService
import org.kodein.di.*
import org.kodein.di.DI
import org.slf4j.LoggerFactory
val mainModule = DI.Module("main") {
bind() from singleton { NoteService(instance()) }
bind() from singleton { UserService(instance(), instance()) }
bind() from singleton { configurationFactory() }
bind() from setBinding<ApplicationBuilder>()
bind<ApplicationBuilder>().inSet() with singleton { ErrorHandler() }
bind<ApplicationBuilder>().inSet() with singleton { ContentNegotiationFeature() }
bind<ApplicationBuilder>().inSet() with singleton { CorsFeature(instance<Config>().server.cors) }
bind<ApplicationBuilder>().inSet() with singleton { AuthenticationModule(instance(tag = "auth")) }
bind<ApplicationBuilder>().inSet() with singleton { MigrationHook(instance()) }
bind<ApplicationBuilder>().inSet() with singleton { ShutdownDatabaseConnection(instance()) }
bind() from setBinding<RoutingBuilder>()
bind<RoutingBuilder>().inSet() with singleton { NoteRoutes(instance()) }
bind<RoutingBuilder>().inSet() with singleton { TagRoutes(instance()) }
bind<RoutingBuilder>().inSet() with singleton {
UserRoutes(
instance(tag = "auth"),
instance(tag = "refresh"),
instance(),
instance()
)
}
bind<SimpleJWT>(tag = "auth") with singleton { simpleJwtFactory(instance<Config>().jwt.auth) }
bind<SimpleJWT>(tag = "refresh") with singleton { simpleJwtFactory(instance<Config>().jwt.refresh) }
bind() from singleton { LoggerFactory.getLogger("Application") }
bind() from singleton { dataSourceFactory(instance<Config>().database) }
bind() from singleton { databaseFactory(instance()) }
bind() from singleton { Migration(instance()) }
bind<PasswordHash>() with singleton { BcryptPasswordHash() }
}

View File

@ -1,17 +0,0 @@
package be.vandewalleh.auth
import be.vandewalleh.extensions.ApplicationBuilder
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.auth.jwt.*
class AuthenticationModule(authJwt: SimpleJWT) : ApplicationBuilder({
install(Authentication) {
jwt {
verifier(authJwt.verifier)
validate {
UserIdPrincipal(it.payload.getClaim("id").asInt())
}
}
}
})

View File

@ -1,9 +0,0 @@
package be.vandewalleh.auth
import io.ktor.auth.*
/**
* Represents a simple user's principal identified by [id]
* @property id of the user
*/
data class UserIdPrincipal(val id: Int) : Principal

View File

@ -1,20 +0,0 @@
package be.vandewalleh.extensions
import be.vandewalleh.auth.UserIdPrincipal
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.http.*
import io.ktor.response.*
suspend fun ApplicationCall.respondStatus(status: HttpStatusCode) {
respondText(
"""{"status": "${status.description}"}""",
status = status,
contentType = ContentType.Application.Json
)
}
/**
* @return the userId for the currently authenticated user
*/
fun ApplicationCall.authenticatedUserId() = principal<UserIdPrincipal>()!!.id

View File

@ -1,95 +0,0 @@
package be.vandewalleh.routing
import be.vandewalleh.extensions.RoutingBuilder
import be.vandewalleh.extensions.authenticatedUserId
import be.vandewalleh.extensions.respondStatus
import be.vandewalleh.features.ValidationException
import be.vandewalleh.services.NoteService
import be.vandewalleh.validation.noteValidator
import be.vandewalleh.validation.receiveValidated
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.http.*
import io.ktor.response.*
import io.ktor.routing.*
import java.util.*
class NoteRoutes(noteService: NoteService) : RoutingBuilder({
authenticate {
route("/notes") {
createNote(noteService)
getAllNotes(noteService)
route("/{uuid}") {
getNote(noteService)
updateNote(noteService)
deleteNote(noteService)
}
}
}
})
private fun Route.createNote(noteService: NoteService) {
post {
val userId = call.authenticatedUserId()
val note = call.receiveValidated(noteValidator)
val createdNote = noteService.create(userId, note)
call.respond(HttpStatusCode.Created, createdNote)
}
}
private fun Route.getAllNotes(noteService: NoteService) {
get {
val userId = call.authenticatedUserId()
val limit = call.parameters["limit"]?.toInt() ?: 20 // FIXME validate
val after = call.parameters["after"]?.let { UUID.fromString(it) } // FIXME validate
val notes = noteService.findAll(userId, limit, after)
call.respond(notes)
}
}
private fun Route.getNote(noteService: NoteService) {
get {
val userId = call.authenticatedUserId()
val noteUuid = call.noteUuid()
val response = noteService.find(userId, noteUuid)
?: return@get call.respondStatus(HttpStatusCode.NotFound)
call.respond(response)
}
}
private fun Route.updateNote(noteService: NoteService) {
put {
val userId = call.authenticatedUserId()
val noteUuid = call.noteUuid()
val note = call.receiveValidated(noteValidator)
note.uuid = noteUuid
if (noteService.updateNote(userId, note))
call.respondStatus(HttpStatusCode.OK)
else call.respondStatus(HttpStatusCode.NotFound)
}
}
private fun Route.deleteNote(noteService: NoteService) {
delete {
val userId = call.authenticatedUserId()
val noteUuid = call.noteUuid()
if (noteService.delete(userId, noteUuid))
call.respondStatus(HttpStatusCode.OK)
else
call.respondStatus(HttpStatusCode.NotFound)
}
}
private fun ApplicationCall.noteUuid(): UUID {
val uuid = parameters["uuid"]
return try {
UUID.fromString(uuid)
} catch (e: IllegalArgumentException) {
throw ValidationException("`$uuid` is not a valid UUID")
}
}

View File

@ -1,17 +0,0 @@
package be.vandewalleh.routing
import be.vandewalleh.extensions.RoutingBuilder
import be.vandewalleh.extensions.authenticatedUserId
import be.vandewalleh.services.NoteService
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.response.*
import io.ktor.routing.*
class TagRoutes(noteService: NoteService) : RoutingBuilder({
authenticate {
get("/tags") {
call.respond(noteService.getTags(call.authenticatedUserId()))
}
}
})

View File

@ -1,123 +0,0 @@
package be.vandewalleh.routing
import be.vandewalleh.auth.SimpleJWT
import be.vandewalleh.auth.UsernamePasswordCredential
import be.vandewalleh.extensions.RoutingBuilder
import be.vandewalleh.extensions.authenticatedUserId
import be.vandewalleh.extensions.respondStatus
import be.vandewalleh.features.PasswordHash
import be.vandewalleh.services.UserService
import be.vandewalleh.validation.receiveValidated
import be.vandewalleh.validation.registerValidator
import com.auth0.jwt.exceptions.JWTVerificationException
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.http.*
import io.ktor.request.*
import io.ktor.response.*
import io.ktor.routing.*
class UserRoutes(
authJWT: SimpleJWT,
refreshJWT: SimpleJWT,
userService: UserService,
passwordHash: PasswordHash
) : RoutingBuilder({
route("/user") {
createUser(userService)
route("/login") {
login(userService, passwordHash, authJWT, refreshJWT)
}
route("/refresh_token") {
refreshToken(userService, authJWT, refreshJWT)
}
authenticate {
deleteUser(userService)
route("/me") {
userInfo(userService)
}
}
}
})
private fun Route.userInfo(userService: UserService) {
get {
val id = call.authenticatedUserId()
val info = userService.find(id)
if (info != null) call.respond(mapOf("user" to info))
else call.respondStatus(HttpStatusCode.Unauthorized)
}
}
private fun Route.deleteUser(userService: UserService) {
delete {
val userId = call.authenticatedUserId()
call.respondStatus(
if (userService.delete(userId)) HttpStatusCode.OK
else HttpStatusCode.NotFound
)
}
}
private fun Route.createUser(userService: UserService) {
post {
val user = call.receiveValidated(registerValidator)
if (userService.exists(user.username))
return@post call.respondStatus(HttpStatusCode.Conflict)
val newUser = userService.create(user.username, user.password)
?: return@post call.respondStatus(HttpStatusCode.Conflict)
call.respond(HttpStatusCode.Created, newUser)
}
}
private fun Route.login(
userService: UserService,
passwordHash: PasswordHash,
authJWT: SimpleJWT,
refreshJWT: SimpleJWT
) {
post {
val credential = call.receive<UsernamePasswordCredential>()
val user = userService.find(credential.username)
?: return@post call.respondStatus(HttpStatusCode.Unauthorized)
if (!passwordHash.verify(credential.password, user.password)) {
return@post call.respondStatus(HttpStatusCode.Unauthorized)
}
val response = DualToken(
token = authJWT.sign(user.id),
refreshToken = refreshJWT.sign(user.id)
)
return@post call.respond(response)
}
}
private fun Route.refreshToken(userService: UserService, authJWT: SimpleJWT, refreshJWT: SimpleJWT) {
post {
val token = call.receive<RefreshToken>().refreshToken
val id = try {
val decodedJWT = refreshJWT.verifier.verify(token)
decodedJWT.getClaim("id").asInt()
} catch (e: JWTVerificationException) {
return@post call.respondStatus(HttpStatusCode.Unauthorized)
}
if (!userService.exists(id))
return@post call.respondStatus(HttpStatusCode.Unauthorized)
val response = DualToken(
token = authJWT.sign(id),
refreshToken = refreshJWT.sign(id)
)
return@post call.respond(response)
}
}
private data class RefreshToken(val refreshToken: String)
private data class DualToken(val token: String, val refreshToken: String)

98
css/styles.css Normal file
View File

@ -0,0 +1,98 @@
@tailwind base;
@tailwind components;
.nav {
height: 64px;
}
.centered {
min-height: calc(100vh - 64px);
}
#note a {
@apply text-blue-700 underline; }
#note p {
@apply my-4; }
#note blockquote,
#note figure {
@apply my-4 mx-10; }
#note hr {
@apply border; }
#note h1 {
@apply text-4xl font-bold my-2; }
#note h2 {
@apply text-2xl font-bold my-3; }
#note h3 {
@apply text-lg font-bold my-4; }
#note h4 {
@apply text-base font-bold my-5; }
#note h5 {
@apply text-sm font-bold my-6; }
#note h6 {
@apply text-xs font-bold my-10; }
#note ul,
#note menu {
@apply list-disc my-1 pl-10; }
#note ol {
@apply list-decimal my-4 pl-10; }
#note ul ul,
#note ol ul {
list-style-type: circle; }
#note ul ul ul,
#note ul ol ul,
#note ol ul ul,
#note ol ol ul {
list-style-type: square; }
#note dd {
@apply pl-10; }
#note dl {
@apply my-4; }
#note ul ul,
#note ul ol,
#note ul menu,
#note ul dl,
#note ol ul,
#note ol ol,
#note ol menu,
#note ol dl,
#note menu ul,
#note menu ol,
#note menu menu,
#note menu dl,
#note dl ul,
#note dl ol,
#note dl menu,
#note dl dl {
margin: 0; }
#note legend {
@apply py-0 px-1; }
#note fieldset {
@apply my-0 mx-1 pt-0 px-1 pb-2; }
#note b,
#note strong {
font-weight: bold; }
#note pre {
@apply my-4 rounded-md overflow-x-auto p-2 block font-mono bg-gray-700; }
@tailwind utilities;

View File

@ -1,13 +0,0 @@
# editorconfig.org
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

View File

@ -1,22 +0,0 @@
module.exports = {
root: true,
env: {
browser: true,
node: true
},
parserOptions: {
parser: 'babel-eslint'
},
extends: [
'@nuxtjs',
'prettier',
'prettier/vue',
'plugin:prettier/recommended',
'plugin:nuxt/recommended'
],
plugins: [
'prettier'
],
// add your custom rules here
rules: {}
}

90
frontend/.gitignore vendored
View File

@ -1,90 +0,0 @@
# Created by .ignore support plugin (hsz.mobi)
### Node template
# Logs
/logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.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
node_modules/
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
# 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.*
# macOS
.DS_Store
# Vim swap files
*.swp

View File

@ -1,6 +0,0 @@
{
"semi": false,
"arrowParens": "always",
"singleQuote": true,
"tabWidth": 4
}

View File

@ -1,18 +0,0 @@
:7000
log {
format single_field common_log
}
encode gzip
root * dist
file_server
try_files {path} /200.html
@nuxt {
path /_nuxt/*
}
header @nuxt Cache-Control "public,max-age=31536000,immutable" # 1 year

View File

@ -1,3 +0,0 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

View File

@ -1,6 +0,0 @@
.page-enter-active, .page-leave-active {
transition: opacity .2s;
}
.page-enter, .page-leave-to {
opacity: 0;
}

View File

@ -1,11 +0,0 @@
<template>
<div class="h-screen font-sans text-gray-100">
<nuxt />
</div>
</template>
<style>
html {
@apply bg-gray-900;
}
</style>

View File

@ -1,3 +0,0 @@
<template>
<nuxt />
</template>

View File

@ -1,50 +0,0 @@
<template>
<div
class="h-screen container mx-auto h-full flex justify-center items-center"
>
<div
class="text-gray-200 w-full md:w-1/2 lg:w-1/3 bg-gray-800 border-teal-500 p-8 border-t-8 bg-white mb-6 rounded-lg shadow-lg"
>
<h1 v-if="error.statusCode === 404">
{{ pageNotFound }}
</h1>
<h1 v-else>
{{ otherError }}
</h1>
<NuxtLink to="/" class="text-blue-200 underline">
Home page
</NuxtLink>
</div>
</div>
</template>
<script>
export default {
layout: 'empty',
props: {
error: {
type: Object,
default: null,
},
},
data() {
return {
pageNotFound: '404 Not Found',
otherError: 'An error occurred',
}
},
head() {
const title =
this.error.statusCode === 404 ? this.pageNotFound : this.otherError
return {
title,
}
},
}
</script>
<style scoped>
h1 {
font-size: 20px;
}
</style>

View File

@ -1,153 +0,0 @@
export default ({ command }) => ({
mode: 'universal',
/*
** Headers of the page
*/
head: {
titleTemplate: '%s - ' + 'SimpleNotes',
title: 'SimpleNotes' || '',
htmlAttrs: {
lang: 'en',
},
meta: [
{ charset: 'utf-8' },
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
hid: 'description',
name: 'description',
content: process.env.npm_package_description || '',
},
],
link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
},
/*
** Customize the progress-bar color
*/
loading: { color: '#fff' },
/*
** Global CSS
*/
css: ['~/assets/main.css'],
/*
** Plugins to load before mounting the App
*/
plugins: [],
/*
** Nuxt.js dev-modules
*/
buildModules: [
// Doc: https://github.com/nuxt-community/eslint-module
'@nuxtjs/eslint-module',
'@nuxtjs/tailwindcss',
],
/*
** Nuxt.js modules
*/
modules: [
// Doc: https://axios.nuxtjs.org/usage
'@nuxtjs/axios',
// Doc: https://github.com/nuxt-community/dotenv-module
'@nuxtjs/auth',
// Doc: https://github.com/nuxt-community/robots-module
'@nuxtjs/robots',
],
/*
** Axios module configuration
** See https://axios.nuxtjs.org/options
*/
axios: {},
publicRuntimeConfig: {
API_HOST: process.env.API_HOST,
},
auth: {
redirect: {
login: '/signin',
logout: '/',
home: '/notes',
},
watchLoggedIn: true,
cookie: !command.includes('generate'),
strategies: {
_scheme: 'local',
_name: 'local',
local: {
endpoints: {
login: {
url: '/user/login',
method: 'post',
propertyName: 'token',
},
user: {
url: '/user/me',
method: 'get',
propertyName: 'user',
},
logout: false,
},
autoFetchUser: true,
},
},
},
router: {
middleware: ['auth'],
},
/*
** Build configuration
*/
build: {
extractCSS: false,
terser: {
parallel: true,
cache: false,
sourceMap: false,
extractComments: false,
terserOptions: {},
},
html: {
minify: {
collapseBooleanAttributes: true,
decodeEntities: true,
minifyCSS: true,
minifyJS: true,
processConditionalComments: true,
removeEmptyAttributes: true,
removeRedundantAttributes: true,
trimCustomFragments: true,
useShortDoctype: true,
collapseWhitespace: true,
removeComments: true,
},
},
/*
** You can extend webpack config here
*/
extend(config, ctx) {},
},
components: true,
telemetry: false,
robots: {
UserAgent: '*',
Disallow: '/',
},
render: {
bundleRenderer: {
shouldPrefetch: () => false,
shouldPreload: (_, asType) =>
['font', 'script', 'style'].includes(asType),
},
},
})

View File

@ -1,41 +0,0 @@
{
"name": "Notes",
"version": "1.0.0",
"description": "Simple note taking and sharing website",
"author": "Hubert Van De Walle",
"private": true,
"scripts": {
"dev": "nuxt",
"build": "nuxt build",
"start": "nuxt start",
"generate": "nuxt generate --modern",
"serve": "caddy run",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore ."
},
"dependencies": {
"@nuxtjs/auth": "^4.9.1",
"@nuxtjs/axios": "^5.3.6",
"@nuxtjs/robots": "^2.4.2",
"@starptech/prettyhtml-hast-to-html": "^0.10.0",
"nuxt": "^2.13.0",
"remark": "^12.0.0",
"remark-breaks": "^1.0.5",
"remark-parse": "^8.0.2",
"remark-rehype": "^7.0.0",
"timeago.js": "^4.0.2",
"unified": "^9.0.0"
},
"devDependencies": {
"@mdi/js": "^5.1.45",
"@nuxtjs/eslint-config": "^2.0.2",
"@nuxtjs/eslint-module": "^1.1.0",
"@nuxtjs/tailwindcss": "^2.0.0",
"babel-eslint": "^10.1.0",
"dotenv": "^8.2.0",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-nuxt": "^0.5.2",
"eslint-plugin-prettier": "^3.1.3",
"prettier": "^2.0.5"
}
}

View File

@ -1,50 +0,0 @@
<template>
<div>
<nav>
<ul>
<li
v-for="link in links"
:key="link.name"
class="text-blue-200"
>
<nuxt-link :to="link.url" class="underline">{{
link.name
}}</nuxt-link>
</li>
<li class="text-blue-200">
<button class="underline" @click="logout">
Sign Out
</button>
</li>
</ul>
</nav>
<client-only>
<div v-if="$auth.$state.loggedIn">
User: {{ $auth.$state.user }}
</div>
</client-only>
</div>
</template>
<script>
export default {
name: 'Home',
data: () => ({
links: [
{ url: '/signin', name: 'Sign In' },
{ url: '/register', name: 'Register' },
{ url: '/notes', name: 'Notes' },
{ url: '/404', name: '404' },
],
}),
options: {
auth: false,
},
methods: {
async logout() {
this.$store.commit('notes/clear')
await this.$auth.logout()
},
},
}
</script>

View File

@ -1,52 +0,0 @@
<template>
<div>
<div>{{ uuid }}</div>
<div v-if="note">{{ note }}</div>
<!-- html is sanitized -->
<!-- eslint-disable-next-line -->
<div class="container mx-auto" id="note" v-html="html"></div>
</div>
</template>
<script>
import { mapState, mapActions, mapGetters } from 'vuex'
import renderMarkdown from '@/utils/markdown'
export default {
name: 'Note',
data: () => ({
note: {},
html: '',
}),
computed: {
...mapState('notes', ['notes', 'isInitialized']),
...mapGetters('notes', ['find']),
uuid() {
return this.$route.params.uuid
},
},
mounted() {
if (!this.isInitialized) {
this.load().then(() => {
this.note = this.find(this.uuid)
this.render()
})
} else {
this.note = this.find(this.uuid)
this.render()
}
},
methods: {
...mapActions('notes', ['load']),
render() {
this.html = renderMarkdown(this.note.content).contents
},
},
}
</script>
<style>
#note h1 {
@apply text-5xl font-bold;
}
</style>

View File

@ -1,49 +0,0 @@
<template>
<div class="container p-4 mx-auto bg-gray-800">
<h1 class="text-center">My Notes</h1>
<nuxt-link
to="/notes/new"
class="bg-blue-700 hover:bg-blue-500 text-white font-bold py-2 px-4 rounded"
>New note</nuxt-link
>
<input
id="search"
name="search"
class="block p-2 my-2 appearance-none w-full bg-transparent border-white-200 border-b focus:border-red-500"
placeholder="search"
aria-label="search"
/>
<div v-for="note in notes" :key="note.uuid">
<div class="flex justify-between items-center">
<nuxt-link :to="'/notes/' + note.uuid" class="text-lg">{{
note.title
}}</nuxt-link>
<div class="py-2">
<span
v-for="(tag, index) in note.tags"
:key="index"
class="inline-block text-sm bg-gray-500 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 ml-2"
>{{ tag }}</span
>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapActions, mapState } from 'vuex'
export default {
name: 'Notes',
computed: {
...mapState('notes', ['notes', 'isInitialized']),
},
mounted() {
if (!this.isInitialized) this.load()
},
methods: {
...mapActions('notes', ['load']),
},
}
</script>

View File

@ -1,44 +0,0 @@
<template>
<div class="container mx-auto">
<form @submit.prevent="submit">
<textarea
v-model="content"
aria-label="markdown input"
name="content"
rows="30"
style="outline: none !important;"
class="w-full bg-gray-800 p-5"
spellcheck="false"
></textarea>
<button
class="block bg-blue-700 hover:bg-blue-500 text-white font-bold py-2 px-4 rounded"
type="submit"
>
Save
</button>
</form>
</div>
</template>
<script>
import { mapActions } from 'vuex'
export default {
name: 'New',
data: () => ({
title: 'Test',
tags: [],
content: '',
}),
methods: {
...mapActions('notes', ['create']),
submit() {
this.create({
title: this.title,
tags: this.tags,
content: this.content,
})
},
},
}
</script>

View File

@ -1,127 +0,0 @@
<template>
<div
class="h-screen container mx-auto h-full flex justify-center items-center"
>
<div class="w-full md:w-1/2 lg:w-1/3">
<h1 class="font-semibold text-lg mb-6 text-center">
Create an Account
</h1>
<div
class="bg-gray-800 border-teal-500 p-8 border-t-8 bg-white mb-6 rounded-lg shadow-lg"
>
<form @submit.prevent="register">
<div
v-if="showError"
class="bg-red-500 border-l-4 border-red-700 text-red-200 p-4 mb-4"
role="alert"
>
<p class="font-bold">Error</p>
<p>{{ error }}</p>
</div>
<div class="mb-4">
<label
for="username"
class="font-bold text-grey-darker block mb-2"
>
Username
</label>
<input
id="username"
v-model="form.username"
name="username"
class="shadow focus:shadow-outline block appearance-none w-full bg-gray-700 border-gray-500 hover:border-gray-500 px-2 py-2 rounded shadow"
placeholder="Your Username"
/>
</div>
<div class="mb-4">
<label
for="password"
class="font-bold text-grey-darker block mb-2"
>
Password
</label>
<input
id="password"
v-model="form.password"
name="password"
type="password"
class="shadow focus:shadow-outline block appearance-none w-full bg-gray-700 border-gray-500 hover:border-gray-500 px-2 py-2 rounded shadow"
placeholder="Your Password"
/>
</div>
<div class="mb-4">
<label
for="confirm"
class="font-bold text-grey-darker block mb-2"
>
Confirm your password
</label>
<input
id="confirm"
v-model="confirm"
name="confirm"
type="password"
class="shadow focus:shadow-outline block appearance-none w-full bg-gray-700 border-gray-500 hover:border-gray-500 px-2 py-2 rounded shadow"
placeholder="Confirm your Password"
/>
</div>
<div class="flex items-center mt-6">
<button
type="submit"
class="w-full bg-teal-500 hover:bg-teal-400 text-white font-bold py-2 px-4 rounded"
>
Sign In
</button>
</div>
</form>
</div>
<div class="text-center">
<p class="text-gray-200 text-sm">
Already have an account?
<nuxt-link
to="/signin"
class="no-underline text-blue-500 font-bold"
>Sign in
</nuxt-link>
</p>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'SignIn',
options: {
auth: false, // FIXME: auth: 'guest'
},
data: () => ({
confirm: '',
form: {
username: '',
password: '',
},
error: '',
showError: false,
}),
methods: {
register() {
this.$axios
.post('/user', this.form)
.then(() => this.$router.push('/signin'))
.catch((e) => {
if (e.response) {
this.error = e.response.data.error
this.showError = true
}
})
},
},
head: () => ({
title: 'Sign in',
}),
}
</script>

View File

@ -1,93 +0,0 @@
<template>
<div
class="h-screen container mx-auto h-full flex justify-center items-center"
>
<div class="w-full md:w-1/2 lg:w-1/3">
<h1 class="font-semibold text-lg mb-6 text-center">Sign In</h1>
<div
class="bg-gray-800 border-teal-500 p-8 border-t-8 bg-white mb-6 rounded-lg shadow-lg"
>
<form @submit.prevent="userLogin">
<div class="mb-4">
<label
for="username"
class="font-bold text-grey-darker block mb-2"
>
Username
</label>
<input
id="username"
v-model="form.username"
name="username"
class="shadow focus:shadow-outline block appearance-none w-full bg-gray-700 border-gray-500 hover:border-gray-500 px-2 py-2 rounded shadow"
placeholder="Your Username"
/>
</div>
<div class="mb-4">
<label
for="password"
class="font-bold text-grey-darker block mb-2"
>
Password
</label>
<input
id="password"
v-model="form.password"
name="password"
type="password"
class="shadow focus:shadow-outline block appearance-none w-full bg-gray-700 border-gray-500 hover:border-gray-500 px-2 py-2 rounded shadow"
placeholder="Your Password"
/>
</div>
<div class="flex items-center mt-6">
<button
type="submit"
class="w-full bg-teal-500 hover:bg-teal-400 text-white font-bold py-2 px-4 rounded"
>
Sign In
</button>
</div>
</form>
</div>
<div class="text-center">
<p class="text-gray-200 text-sm">
Don't have an account?
<nuxt-link
to="/register"
class="no-underline text-blue-500 font-bold"
>Create an Account</nuxt-link
>
</p>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'SignIn',
options: {
auth: false, // FIXME: auth: 'guest'
},
data: () => ({
form: {
username: '',
password: '',
},
}),
methods: {
userLogin() {
this.$auth
.loginWith('local', {
data: this.form,
})
.then(() => this.$router.push('/'))
},
},
head: () => ({
title: 'Sign in',
}),
}
</script>

View File

@ -1,7 +0,0 @@
export const state = () => ({})
export const mutations = {}
export const actions = {}
export const getters = {}

View File

@ -1,53 +0,0 @@
export const state = () => ({
notes: [],
isInitialized: false,
})
export const mutations = {
setInitialized(state) {
state.isInitialized = true
},
set(state, notes) {
state.notes = notes
},
add(state, note) {
state.notes.unshift(note)
},
delete(state, uuid) {
state.notes = state.notes.filter((e) => e.uuid !== uuid)
},
// used when logging out
clear(state) {
state.notes = []
state.initialized = false
},
}
export const actions = {
load({ commit }) {
return this.$axios.get('/notes').then(({ data }) => {
commit('set', data)
commit('setInitialized')
})
},
create({ commit }, note) {
this.$axios.post('/notes', note).then(({ data }) =>
commit('add', {
uuid: data.uuid,
title: data.title,
updatedAt: data.updatedAt,
tags: data.tags,
})
)
},
delete({ commit }, note) {
const result = this.$axios.delete(`/notes/${note.uuid}`)
commit('delete', note.uuid)
result.catch(() => commit('add', note))
},
}
export const getters = {
isEmpty: (state) => state.isInitialized && state.notes.length === 0,
find: (state) => (uuid) => state.notes.find((note) => note.uuid === uuid),
}

View File

@ -1,38 +0,0 @@
/*
** TailwindCSS Configuration File
**
** Docs: https://tailwindcss.com/docs/configuration
** Default: https://github.com/tailwindcss/tailwindcss/blob/master/stubs/defaultConfig.stub.js
*/
module.exports = {
theme: {
extend: {
colors: {
gray: {
100: '#F8F9FA',
200: '#EBEBEB',
300: '#DEE2E6',
400: '#CED4DA',
500: '#ADB5BD',
600: '#888',
700: '#444',
800: '#303030',
900: '#222',
},
},
},
},
variants: {},
plugins: [],
purge: {
// Learn more on https://tailwindcss.com/docs/controlling-file-size/#removing-unused-css
enabled: process.env.NODE_ENV === 'production',
content: [
'components/**/*.vue',
'layouts/**/*.vue',
'pages/**/*.vue',
'plugins/**/*.js',
'nuxt.config.js',
],
},
}

View File

@ -1,19 +0,0 @@
import markdown from 'remark-parse'
import remark2rehype from 'remark-rehype'
import remarkBreaks from 'remark-breaks'
import unified from 'unified'
import hast2html from '@starptech/prettyhtml-hast-to-html'
function stringify(options = {}) {
this.Compiler = (tree) => hast2html(tree, options)
}
const compiler = unified()
.use(markdown)
.use(remark2rehype)
.use(remarkBreaks)
.use(stringify)
export default function (input) {
return compiler.processSync(input)
}

View File

@ -1,11 +0,0 @@
module.exports = {
resolve: {
// for IntelliJ
alias: {
// eslint-disable-next-line no-undef
'@': path.resolve(__dirname),
// eslint-disable-next-line no-undef
'~': path.resolve(__dirname),
},
},
}

File diff suppressed because it is too large Load Diff

0
api/mvnw → mvnw vendored
View File

View File

15
package.json Normal file
View File

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

View File

@ -50,6 +50,16 @@
<enabled>true</enabled>
</snapshots>
</repository>
<repository>
<id>korlibs</id>
<url>https://dl.bintray.com/korlibs/korlibs</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
<repository>
<id>jitpack</id>
<url>https://jitpack.io</url>
@ -62,6 +72,11 @@
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>com.soywiz.korlibs.korte</groupId>
<artifactId>korte-jvm</artifactId>
<version>1.10.14</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
@ -137,7 +152,12 @@
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.10.4</version>
<version>2.11.1</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
<version>2.11.1</version>
</dependency>
<dependency>
<groupId>com.github.hekeki</groupId>
@ -170,6 +190,18 @@
<artifactId>yavi</artifactId>
<version>0.4.0</version>
</dependency>
<dependency>
<groupId>com.vladsch.flexmark</groupId>
<artifactId>flexmark-all</artifactId>
<version>0.62.2</version>
</dependency>
<dependency>
<groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
<artifactId>owasp-java-html-sanitizer</artifactId>
<version>20200615.1</version>
</dependency>
<!-- tests -->
<dependency>
<groupId>com.github.javafaker</groupId>
<artifactId>javafaker</artifactId>

18
postcss.config.js Normal file
View File

@ -0,0 +1,18 @@
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
require('cssnano')({
preset: ['default', {
discardComments: {
removeAll: true,
},
}]
}),
require('postcss-hash')({
algorithm: 'sha256',
trim: 20,
manifest: 'resources/css-manifest.json'
}),
]
}

View File

@ -8,7 +8,7 @@ database:
server:
host: ${HOST:-127.0.0.1}
port: ${PORT:-8081}
cors: ${CORS:-true}
cors: ${CORS:-false}
jwt:
auth:

View File

@ -12,7 +12,8 @@ create table Notes
(
uuid binary(16) not null primary key,
title varchar(50) not null,
content text not null,
markdown mediumtext not null,
html mediumtext not null,
user_id int not null,
updated_at datetime null,

View File

@ -9,7 +9,7 @@
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
<logger name="me.liuwj.ktorm.database" level="DEBUG"/>
<logger name="me.liuwj.ktorm.database" level="INFO"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,42 @@
{% set title = "Notes" %} {% extends "__base__.html" %} {% block content %}
<div class="container mx-auto">
<h1 class="text-2xl">{{ method }}</h1>
{%- if error %}
{% import "components/alert.html" as alerts %}
<div class="mt-4">
{{ alerts.warning("Error", error) }}
</div>
{% endif -%}
<form method="post" enctype="multipart/form-data">
<textarea
id="markdown"
name="markdown"
aria-label="markdown input"
rows="20"
class="w-full bg-gray-800 p-5 outline-none font-mono"
spellcheck="false"
>
{%- if value %}{{ value }}{% else %}
---
title: ''
---
{% endif -%}</textarea
>
<div class="mt-2">
<button
class="bg-green-500 hover:bg-green-700 rounded px-4 py-2"
type="submit"
>
Submit
</button>
</div>
</form>
<p>{{ value }}</p>
</div>
{% endblock %}

View File

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

100
src/Dependencies.kt Normal file
View File

@ -0,0 +1,100 @@
package be.vandewalleh
import be.vandewalleh.auth.AuthenticationModule
import be.vandewalleh.auth.SimpleJWT
import be.vandewalleh.controllers.api.ApiNoteController
import be.vandewalleh.controllers.api.ApiTagController
import be.vandewalleh.controllers.api.ApiUserController
import be.vandewalleh.controllers.web.BaseController
import be.vandewalleh.controllers.web.NoteController
import be.vandewalleh.controllers.web.UserController
import be.vandewalleh.extensions.ApplicationBuilder
import be.vandewalleh.extensions.RoutingBuilder
import be.vandewalleh.factories.*
import be.vandewalleh.features.*
import be.vandewalleh.markdown.Markdown
import be.vandewalleh.repositories.NoteRepository
import be.vandewalleh.repositories.UserRepository
import be.vandewalleh.routing.api.ApiDocRoutes
import be.vandewalleh.routing.api.ApiNoteRoutes
import be.vandewalleh.routing.api.ApiTagRoutes
import be.vandewalleh.routing.api.ApiUserRoutes
import be.vandewalleh.routing.web.BaseRoutes
import be.vandewalleh.routing.web.NoteRoutes
import be.vandewalleh.routing.web.StaticRoutes
import be.vandewalleh.routing.web.UserRoutes
import com.soywiz.korte.TemplateProvider
import com.soywiz.korte.Templates
import io.ktor.features.*
import org.kodein.di.*
import org.kodein.di.DI
import org.slf4j.LoggerFactory
val mainModule = DI.Module("main") {
bind() from singleton { NoteRepository(instance()) }
bind() from singleton { UserRepository(instance(), instance()) }
bind() from singleton { configurationFactory() }
bind() from setBinding<ApplicationBuilder>()
bind<ApplicationBuilder>().inSet() with singleton { ErrorHandler(instance()) }
bind<ApplicationBuilder>().inSet() with singleton { ContentNegotiationFeature() }
bind<ApplicationBuilder>().inSet() with singleton { CorsFeature(instance<Config>().server.cors) }
bind<ApplicationBuilder>().inSet() with singleton { AuthenticationModule(instance(tag = "auth")) }
bind<ApplicationBuilder>().inSet() with singleton { MigrationHook(instance()) }
bind<ApplicationBuilder>().inSet() with singleton { ShutdownDatabaseConnection(instance()) }
bind<ApplicationBuilder>().inSet() with singleton { SecurityReport() }
bind<ApplicationBuilder>().inSet() with singleton { CachingHeadersFeature() }
bind<ApplicationBuilder>().inSet() with singleton { CompressionFeature() }
bind<ApplicationBuilder>().inSet() with singleton { CallLoggingFeature() }
bind() from setBinding<RoutingBuilder>()
bind<RoutingBuilder>().inSet() with singleton { ApiDocRoutes() }
bind<RoutingBuilder>().inSet() with singleton { ApiNoteRoutes(instance()) }
bind<RoutingBuilder>().inSet() with singleton { ApiTagRoutes(instance()) }
bind<RoutingBuilder>().inSet() with singleton { ApiUserRoutes(instance()) }
// API
bind() from singleton { ApiNoteController(instance(), instance()) }
bind() from singleton { ApiTagController(instance()) }
bind() from singleton {
ApiUserController(
userRepository = instance(),
authJWT = instance(tag = "auth"),
refreshJWT = instance(tag = "refresh"),
passwordHash = instance()
)
}
// web
bind<RoutingBuilder>().inSet() with singleton { BaseRoutes(instance()) }
bind<RoutingBuilder>().inSet() with singleton { UserRoutes(instance()) }
bind<RoutingBuilder>().inSet() with singleton { NoteRoutes(instance()) }
bind<RoutingBuilder>().inSet() with singleton { StaticRoutes() }
bind() from singleton { NoteController(instance(), instance(), instance()) }
bind() from singleton { BaseController(instance()) }
bind() from singleton {
UserController(
userRepository = instance(),
authJWT = instance(tag = "auth"),
templates = instance(),
passwordHash = instance()
)
}
bind<TemplateProvider>() with singleton { ResourceTemplateProvider() }
bind<Templates>() with singleton { templatesFactory(instance()) }
bind<SimpleJWT>(tag = "auth") with singleton { simpleJwtFactory(instance<Config>().jwt.auth) }
bind<SimpleJWT>(tag = "refresh") with singleton { simpleJwtFactory(instance<Config>().jwt.refresh) }
bind() from singleton { LoggerFactory.getLogger("Application") }
bind() from singleton { dataSourceFactory(instance<Config>().database) }
bind() from singleton { databaseFactory(instance()) }
bind() from singleton { Migration(instance()) }
bind<PasswordHash>() with singleton { BcryptPasswordHash() }
bind() from singleton { Markdown() }
}

View File

@ -2,10 +2,11 @@ package be.vandewalleh
import be.vandewalleh.extensions.ApplicationBuilder
import be.vandewalleh.extensions.RoutingBuilder
import io.ktor.application.*
import io.ktor.routing.*
import io.ktor.application.Application
import io.ktor.application.log
import io.ktor.routing.routing
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.netty.Netty
import org.kodein.di.DI
import org.kodein.di.description
import org.kodein.di.instance
@ -47,6 +48,10 @@ fun Application.module(di: DI) {
it.builder(this)
}
routing {
trace { application.log.trace(it.buildText()) }
}
val routingBuilders: Set<RoutingBuilder> by di.instance()
routingBuilders.forEach {
routing(it.builder)

View File

@ -0,0 +1,54 @@
package be.vandewalleh.auth
import be.vandewalleh.extensions.ApplicationBuilder
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.auth.jwt.*
import io.ktor.http.*
import io.ktor.http.auth.*
import io.ktor.request.*
import io.ktor.response.*
import io.ktor.util.pipeline.*
class AuthenticationModule(authJwt: SimpleJWT) : ApplicationBuilder({
install(Authentication) {
jwt {
verifier(authJwt.verifier)
authHeader { call ->
val token = call.request.header(HttpHeaders.Authorization)
?: call.request.cookies["Authorization"]
token?.let {
try {
parseAuthorizationHeader(it)
} catch (e: IllegalArgumentException) {
null
}
}
}
validate {
UserPrincipal(
id = it.payload.getClaim("id").asInt(),
username = it.payload.getClaim("username").asString()
)
}
challenge { scheme, realm ->
authChallenge(scheme, realm)
}
}
}
})
private suspend fun PipelineContext<*, ApplicationCall>.authChallenge(scheme: String, realm: String) {
if (call.request.uri.startsWith("/api"))
call.respond(
UnauthorizedResponse(
HttpAuthHeader.Parameterized(
scheme,
mapOf(HttpAuthHeader.Parameters.Realm to realm)
)
)
)
else {
call.respondRedirect("/login")
}
}

View File

@ -11,8 +11,9 @@ class SimpleJWT(secret: String, validity: Long, unit: TimeUnit) {
private val algorithm = Algorithm.HMAC256(secret)
val verifier: JWTVerifier = JWT.require(algorithm).build()
fun sign(id: Int): String = JWT.create()
fun sign(id: Int, username: String): String = JWT.create()
.withClaim("id", id)
.withClaim("username", username)
.withExpiresAt(getExpiration())
.sign(algorithm)

10
src/auth/UserPrincipal.kt Normal file
View File

@ -0,0 +1,10 @@
package be.vandewalleh.auth
import io.ktor.auth.*
/**
* Represents a simple user's principal identified by it's [id] and [username]
* @property id
* @property username
*/
data class UserPrincipal(val id: Int, val username: String) : Principal

View File

@ -0,0 +1,78 @@
package be.vandewalleh.controllers.api
import be.vandewalleh.entities.Note
import be.vandewalleh.extensions.authenticatedUser
import be.vandewalleh.features.ValidationException
import be.vandewalleh.markdown.Markdown
import be.vandewalleh.markdown.MarkdownDocument
import be.vandewalleh.repositories.NoteRepository
import be.vandewalleh.utils.Either.Error
import be.vandewalleh.utils.Either.Success
import io.ktor.application.*
import io.ktor.http.*
import io.ktor.request.*
import io.ktor.response.*
import java.util.*
class ApiNoteController(private val noteRepository: NoteRepository, private val md: Markdown) {
suspend fun create(call: ApplicationCall) {
val userId = call.authenticatedUser().id
val txt = call.receiveText()
val doc: MarkdownDocument
when (val result = md.renderDocument(txt)) {
is Error -> return call.respond(HttpStatusCode.BadRequest, result.error)
is Success -> doc = result.value
}
val note = Note {
this.title = doc.meta.title
this.tags = doc.meta.tags
this.markdown = txt
this.html = doc.html
}
val createdNote = noteRepository.create(userId, note)
call.respond(HttpStatusCode.Created, createdNote)
}
suspend fun getAll(call: ApplicationCall) {
val userId = call.authenticatedUser().id
val limit = call.parameters["limit"]?.toInt() ?: 20 // FIXME validate
val after = call.parameters["after"]?.let { UUID.fromString(it) } // FIXME validate
val notes = noteRepository.findAll(userId, limit, after)
call.respond(notes)
}
suspend fun getOne(call: ApplicationCall) {
val userId = call.authenticatedUser().id
val noteUuid = call.noteUuid()
val response = noteRepository.find(userId, noteUuid) ?: return call.response.status(HttpStatusCode.NotFound)
call.respond(response)
}
suspend fun update(call: ApplicationCall) {
TODO("Not implemented")
}
suspend fun delete(call: ApplicationCall) {
val userId = call.authenticatedUser().id
val noteUuid = call.noteUuid()
val success = noteRepository.delete(userId, noteUuid)
if (success) call.response.status(HttpStatusCode.NoContent)
else call.response.status(HttpStatusCode.NotFound)
}
}
private fun ApplicationCall.noteUuid(): UUID {
val uuid = parameters["uuid"]
return try {
UUID.fromString(uuid)
} catch (e: IllegalArgumentException) {
throw ValidationException("`$uuid` is not a valid UUID")
}
}

View File

@ -0,0 +1,13 @@
package be.vandewalleh.controllers.api
import be.vandewalleh.extensions.authenticatedUser
import be.vandewalleh.repositories.NoteRepository
import io.ktor.application.*
import io.ktor.response.*
class ApiTagController(private val noteRepository: NoteRepository) {
suspend fun getAll(call: ApplicationCall) {
call.respond(noteRepository.getTags(call.authenticatedUser().id))
}
}

View File

@ -0,0 +1,87 @@
package be.vandewalleh.controllers.api
import be.vandewalleh.auth.SimpleJWT
import be.vandewalleh.auth.UsernamePasswordCredential
import be.vandewalleh.extensions.authenticatedUser
import be.vandewalleh.features.PasswordHash
import be.vandewalleh.repositories.UserRepository
import be.vandewalleh.validation.receiveValidated
import be.vandewalleh.validation.registerValidator
import com.auth0.jwt.exceptions.JWTVerificationException
import io.ktor.application.*
import io.ktor.http.*
import io.ktor.request.*
import io.ktor.response.*
class ApiUserController(
private val authJWT: SimpleJWT,
private val refreshJWT: SimpleJWT,
private val userRepository: UserRepository,
private val passwordHash: PasswordHash
) {
suspend fun create(call: ApplicationCall) {
val user = call.receiveValidated(registerValidator)
if (userRepository.exists(user.username))
return call.response.status(HttpStatusCode.Conflict)
userRepository.create(user.username, user.password)
?: return call.response.status(HttpStatusCode.Conflict)
call.response.status(HttpStatusCode.Created)
}
suspend fun login(call: ApplicationCall) {
val credential = call.receive<UsernamePasswordCredential>()
val user = userRepository.find(credential.username)
?: return call.response.status(HttpStatusCode.Unauthorized)
if (!passwordHash.verify(credential.password, user.password)) {
return call.response.status(HttpStatusCode.Unauthorized)
}
val response = DualToken(
token = authJWT.sign(user.id, user.username),
refreshToken = refreshJWT.sign(user.id, user.username)
)
return call.respond(response)
}
suspend fun refreshToken(call: ApplicationCall) {
val token = call.receive<RefreshToken>().refreshToken
val id = try {
val decodedJWT = refreshJWT.verifier.verify(token)
decodedJWT.getClaim("id").asInt()
} catch (e: JWTVerificationException) {
return call.response.status(HttpStatusCode.Unauthorized)
}
val user = userRepository.find(id) ?: return call.response.status(HttpStatusCode.Unauthorized)
val response = DualToken(
token = authJWT.sign(user.id, user.username),
refreshToken = refreshJWT.sign(user.id, user.username)
)
return call.respond(response)
}
suspend fun delete(call: ApplicationCall) {
val userId = call.authenticatedUser().id
val success = userRepository.delete(userId)
if (success) call.response.status(HttpStatusCode.OK)
else call.response.status(HttpStatusCode.NotFound)
}
suspend fun info(call: ApplicationCall) {
val id = call.authenticatedUser().id
val info = userRepository.find(id)
if (info != null) call.respond(mapOf("user" to info))
else call.response.status(HttpStatusCode.Unauthorized)
}
}
private data class RefreshToken(val refreshToken: String)
private data class DualToken(val token: String, val refreshToken: String)

View File

@ -0,0 +1,13 @@
package be.vandewalleh.controllers.web
import be.vandewalleh.extensions.respondKorte
import com.soywiz.korte.Templates
import io.ktor.application.*
class BaseController(private val templates: Templates) {
suspend fun index(call: ApplicationCall) {
val template = templates.get("index.html")
call.respondKorte(template)
}
}

View File

@ -0,0 +1,83 @@
package be.vandewalleh.controllers.web
import be.vandewalleh.entities.Note
import be.vandewalleh.extensions.authenticatedUser
import be.vandewalleh.extensions.respondKorte
import be.vandewalleh.markdown.Markdown
import be.vandewalleh.repositories.NoteRepository
import be.vandewalleh.utils.Either.Error
import be.vandewalleh.utils.Either.Success
import com.soywiz.korte.Templates
import io.ktor.application.*
import io.ktor.http.*
import io.ktor.http.ContentType.*
import io.ktor.http.HttpStatusCode.Companion.BadRequest
import io.ktor.http.content.*
import io.ktor.request.*
import io.ktor.response.*
import java.util.*
class NoteController(
private val noteRepository: NoteRepository,
private val templates: Templates,
private val md: Markdown
) {
suspend fun renderList(call: ApplicationCall) {
val userId = call.authenticatedUser().id
val notes = noteRepository.findAll(userId)
val template = templates.get("list.html")
call.respondKorte(template, "notes" to notes)
}
suspend fun renderOne(call: ApplicationCall) {
val uuidParam = call.parameters["uuid"]
val uuid = UUID.fromString(uuidParam)
val note = noteRepository.find(userId = 1, noteUuid = uuid)
val template = templates.get("_uuid.html")
call.respondKorte(template, "note" to note)
}
suspend fun new(call: ApplicationCall) {
val template = templates.get("new.html")
if (call.request.httpMethod == HttpMethod.Get ||
!call.request.contentType().withoutParameters().match(MultiPart.FormData)
) {
call.respondKorte(template)
return
}
val multipart = call.receiveMultipart()
val part = multipart.readPart()
if (part == null || part !is PartData.FormItem || part.name != "markdown") {
call.respondKorte(templates.get("error.html"), "error" to "null", statusCode = BadRequest)
return
}
val textAreaValue = part.value
val result = md.renderDocument(textAreaValue)
val doc = when (result) {
is Error -> return call.respondKorte(
template,
"error" to result.error.msg,
statusCode = BadRequest
)
is Success -> result.value
}
val note = Note {
this.title = doc.meta.title
this.tags = doc.meta.tags
this.markdown = textAreaValue
this.html = doc.html
}
noteRepository.create(call.authenticatedUser().id, note)
call.respondRedirect("/notes")
}
}

View File

@ -0,0 +1,108 @@
package be.vandewalleh.controllers.web
import be.vandewalleh.auth.SimpleJWT
import be.vandewalleh.entities.User
import be.vandewalleh.extensions.respondKorte
import be.vandewalleh.features.PasswordHash
import be.vandewalleh.repositories.UserRepository
import be.vandewalleh.validation.registerValidator
import com.soywiz.korte.Templates
import io.ktor.application.*
import io.ktor.http.*
import io.ktor.http.HttpStatusCode.Companion.Unauthorized
import io.ktor.http.content.*
import io.ktor.request.*
import io.ktor.response.*
class UserController(
private val userRepository: UserRepository,
private val templates: Templates,
private val authJWT: SimpleJWT,
private val passwordHash: PasswordHash
) {
suspend fun login(call: ApplicationCall) {
val template = templates.get("login.html")
if (call.request.httpMethod == HttpMethod.Get) {
call.respondKorte(template)
return
}
val parts = call.receiveMultipart().readAllParts()
val username = (parts.find { it.name == "username" } as PartData.FormItem).value
val password = (parts.find { it.name == "password" } as PartData.FormItem).value
val user = userRepository.find(username)
if (user == null) {
call.respondKorte(
template,
"error" to "Invalid credentials",
statusCode = Unauthorized
)
return
}
val verify = passwordHash.verify(password, user.password)
if (!verify) {
call.respondKorte(
template,
"error" to "Invalid credentials",
statusCode = Unauthorized
)
return
}
val token = authJWT.sign(user.id, user.username)
call.response.cookies.append("Authorization", "Bearer $token", path = "/")
call.respondRedirect("/notes")
}
suspend fun register(call: ApplicationCall) {
val template = templates.get("register.html")
if (call.request.httpMethod == HttpMethod.Get) {
call.respondKorte(template)
return
}
val parts = call.receiveMultipart().readAllParts()
val username = (parts.find { it.name == "username" } as PartData.FormItem).value
val password = (parts.find { it.name == "password" } as PartData.FormItem).value
val validation = registerValidator.validate(
User {
this.username = username
this.password = password
}
)
if (!validation.isValid) {
call.respondKorte(template, "error" to validation.details().map { it.defaultMessage })
return
}
if (userRepository.exists(username)) {
call.respondKorte(template, "error" to "Please choose another username")
return
}
if (userRepository.create(username, password) == null) {
// still need a check, for race conditions
call.respondKorte(template, "error" to "Please choose another username")
return
}
call.respondRedirect("/login")
}
suspend fun logout(call: ApplicationCall) {
call.response.cookies.appendExpired("Authorization", path = "/")
call.respondRedirect("/")
}
}

View File

@ -10,7 +10,8 @@ interface Note : Entity<Note> {
var uuid: UUID
var title: String
var content: String
var markdown: String
var html: String
var updatedAt: LocalDateTime
@get:JsonIgnore

View File

@ -0,0 +1,37 @@
package be.vandewalleh.extensions
import be.vandewalleh.auth.UserPrincipal
import com.soywiz.korte.Template
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.http.*
import io.ktor.http.ContentType.*
import io.ktor.request.*
import io.ktor.response.*
/**
* @return the userId for the currently authenticated user
*/
fun ApplicationCall.authenticatedUser() = principal<UserPrincipal>()!!
/**
* @return the userId for the currently authenticated user or null
*/
fun ApplicationCall.authenticatedUserOrNull() = principal<UserPrincipal>()
suspend fun ApplicationCall.respondKorte(
template: Template,
vararg args: Pair<String, Any?>,
statusCode: HttpStatusCode = HttpStatusCode.OK
) {
val uri = request.path().trimEnd('/')
respondText(Text.Html.withCharset(Charsets.UTF_8), statusCode) {
template(
mapOf(
*args,
"url" to uri,
"user" to authenticatedUserOrNull()
)
)
}
}

View File

@ -1,8 +1,12 @@
package be.vandewalleh.extensions
import io.ktor.application.*
import io.ktor.http.*
import io.ktor.routing.*
abstract class RoutingBuilder(val builder: Routing.() -> Unit)
abstract class ApplicationBuilder(val builder: Application.() -> Unit)
val ContentType.Text.Markdown: ContentType
get() = ContentType("text", "markdown")

View File

@ -0,0 +1,10 @@
package be.vandewalleh.factories
import com.soywiz.korte.TemplateProvider
class ResourceTemplateProvider : TemplateProvider {
override suspend fun get(template: String): String? {
val resource = "/templates/$template"
return this.javaClass.getResource(resource)?.readText()
}
}

View File

@ -0,0 +1,28 @@
package be.vandewalleh.factories
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import com.fasterxml.jackson.module.kotlin.readValue
import com.soywiz.korte.TeFunction
import com.soywiz.korte.TemplateConfig
import com.soywiz.korte.TemplateProvider
import com.soywiz.korte.Templates
fun getResourceAsText(path: String): String {
return object {}.javaClass.getResource(path).readText()
}
fun templatesFactory(templateProvider: TemplateProvider): Templates {
val manifest = getResourceAsText("/css-manifest.json")
val json = ObjectMapper().registerModule(KotlinModule()).readValue<Map<String, String>>(manifest)
val fn = TeFunction("styles") { args ->
val path = args.firstOrNull()
json[path]?.let { "/$it" }
}
val config = TemplateConfig(extraFunctions = listOf(fn))
return Templates(templateProvider, config = config, cache = true)
}

View File

@ -0,0 +1,23 @@
package be.vandewalleh.features
import be.vandewalleh.extensions.ApplicationBuilder
import io.ktor.application.*
import io.ktor.features.*
import io.ktor.http.*
import io.ktor.http.content.*
// FIXME: replace with custom features, since immutable is not present
class CachingHeadersFeature : ApplicationBuilder({
install(CachingHeaders) {
options { outgoingContent ->
when (outgoingContent.contentType?.withoutParameters()) {
ContentType.Text.CSS -> CachingOptions(
CacheControl.MaxAge(
maxAgeSeconds = 31557600
)
)
else -> null
}
}
}
})

View File

@ -0,0 +1,12 @@
package be.vandewalleh.features
import be.vandewalleh.extensions.ApplicationBuilder
import io.ktor.application.*
import io.ktor.features.*
import org.slf4j.event.Level
class CallLoggingFeature : ApplicationBuilder({
install(CallLogging) {
this.level = Level.INFO
}
})

View File

@ -0,0 +1,11 @@
package be.vandewalleh.features
import be.vandewalleh.extensions.ApplicationBuilder
import io.ktor.application.*
import io.ktor.features.*
class CompressionFeature : ApplicationBuilder({
install(Compression) {
default()
}
})

View File

@ -1,18 +1,26 @@
package be.vandewalleh.features
import be.vandewalleh.extensions.ApplicationBuilder
import be.vandewalleh.extensions.respondKorte
import com.soywiz.korte.Templates
import io.ktor.application.*
import io.ktor.features.*
import io.ktor.http.*
import io.ktor.request.*
import io.ktor.response.*
import io.ktor.utils.io.errors.*
import java.sql.SQLTransientConnectionException
class ErrorHandler : ApplicationBuilder({
class ErrorHandler(templates: Templates) : ApplicationBuilder({
install(StatusPages) {
jacksonErrors()
status(HttpStatusCode.NotFound, HttpStatusCode.InternalServerError) {
if (call.request.path().startsWith("/api")) return@status
call.respondKorte(templates.get("error.html"), "status" to it.value)
}
exception<IOException> {
call.respond(HttpStatusCode.BadRequest)
}

View File

@ -0,0 +1,111 @@
package be.vandewalleh.features
import be.vandewalleh.extensions.ApplicationBuilder
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import com.fasterxml.jackson.module.kotlin.readValue
import io.ktor.application.*
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.request.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.util.*
import org.slf4j.LoggerFactory
import kotlin.collections.HashMap
class SecurityReport : ApplicationBuilder({
val logger = LoggerFactory.getLogger(SecurityReport::class.java)
routing {
post("/csp-reports") {
val txt = call.receiveText()
val json = ObjectMapper().registerModule(KotlinModule()).readValue<JsonNode>(txt)
logger.info(json.toPrettyString())
call.response.status(HttpStatusCode.OK)
}
}
install(SecurityHeaders) {
reportOnly = false
reportUri = "/csp-reports"
cspValue = "default-src 'self'"
}
})
class SecurityHeaders(configuration: Configuration) {
private val logger = LoggerFactory.getLogger(key.name)
private val headers = buildHeaders(configuration)
class Configuration {
var reportOnly = true
var reportUri = "/csp-reports"
var csp = true
var cspValue = "default-src 'self'"
var noSniff = true
var referrerPolicy = true
var xssFilter = true
var frameguard = true
var featurePolicy = false
}
companion object Feature :
ApplicationFeature<ApplicationCallPipeline, SecurityHeaders.Configuration, SecurityHeaders> {
override val key = AttributeKey<SecurityHeaders>("SecurityHeaders")
override fun install(pipeline: ApplicationCallPipeline, configure: Configuration.() -> Unit): SecurityHeaders {
val configuration = SecurityHeaders.Configuration().apply(configure)
val feature = SecurityHeaders(configuration)
pipeline.sendPipeline.intercept(ApplicationSendPipeline.After) { subject ->
if (subject !is OutgoingContent) return@intercept
val contentType = subject.contentType ?: return@intercept
if (!ContentType.Text.Html.match(contentType.withoutParameters())) return@intercept
if (call.request.path().startsWith("/api")) return@intercept
feature.process(call, contentType)
}
return feature
}
}
fun process(call: ApplicationCall, contentType: ContentType) {
headers.forEach { (name, value) ->
call.response.headers.append(name, value)
}
logger.debug("Added security headers")
}
private fun buildHeaders(cfg: Configuration): HashMap<String, String> {
val headers = HashMap<String, String>()
if (cfg.noSniff) headers["X-Content-Type-Options"] = "nosniff"
if (cfg.referrerPolicy) headers["Referrer-Policy"] = "no-referrer-when-downgrade"
if (cfg.xssFilter) headers["X-XSS-Protection"] = "1; mode=block"
if (cfg.frameguard) headers["X-Frame-Options"] = "DENY"
if (cfg.csp) {
val cspValue = if (cfg.reportOnly) cfg.cspValue + "; report-uri ${cfg.reportUri}" else cfg.cspValue
val cspKey = if (cfg.reportOnly) "Content-Security-Policy-Report-Only" else "Content-Security-Policy"
headers[cspKey] = cspValue
}
if (cfg.featurePolicy) {
// really ?? https://github.com/w3c/webappsec-feature-policy/issues/189
headers["Feature-Policy"] =
"accelerometer 'none'; ambient-light-sensor 'none'; autoplay 'none'; battery 'none'; camera 'none'; display-capture 'none'; document-domain 'none'; encrypted-media 'none'; fullscreen 'none'; geolocation 'none'; gyroscope 'none'; legacy-image-formats 'none'; magnetometer 'none'; microphone 'none'; midi 'none'; oversized-images 'none'; payment 'none'; picture-in-picture 'none'; publickey-credentials-get 'none'; sync-xhr 'none'; unoptimized-images 'none'; unsized-media 'none'; usb 'none'; wake-lock 'none'; xr-spatial-tracking 'none'"
}
// TODO: Strict-Transport-Security: "max-age=31536000; includeSubDomains"
return headers
}
}

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