Compare commits

..

3 Commits

Author SHA1 Message Date
b015f3a97e Add instructions 2020-08-25 08:18:34 +02:00
b8e9d4e96e Fix a test 2020-08-25 07:12:58 +02:00
c5f9a1d6e0 Add possibility to share notes 2020-08-25 07:04:29 +02:00
14 changed files with 151 additions and 24 deletions

View File

@ -6,4 +6,5 @@ MYSQL_ROOT_PASSWORD=
# #
## can be generated with `openssl rand -base64 32` ## can be generated with `openssl rand -base64 32`
MYSQL_PASSWORD= MYSQL_PASSWORD=
PASSWORD=${MYSQL_PASSWORD} # password should be the same as mysql_password
PASSWORD=

19
README.md Normal file
View File

@ -0,0 +1,19 @@
# SimpleNotes, a simple markdown note taking website
## Requirements
- Docker
- docker-compose
## How to run
- Copy the docker-compose.yml somewhere
- In the same directory, copy the *.env.dist* file and rename it to *.env*
- Edit the variables inside *.env* (see below)
- Run it with `docker-compose up -d`
## Configuration
The app is configured with environments variables.
If no match is found within the env, a default value is read from a properties file in /app/src/main/resources/application.properties.
Don't use the default values for secrets ! Every value inside *.env.dist* should be changed.

View File

@ -68,15 +68,28 @@ class NoteController(
fun note(request: Request, jwtPayload: JwtPayload): Response { fun note(request: Request, jwtPayload: JwtPayload): Response {
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND) val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
if (request.method == Method.POST && request.form("delete") != null) { if (request.method == Method.POST) {
return if (noteService.trash(jwtPayload.userId, noteUuid)) if (request.form("delete") != null) {
Response.redirect("/notes") // TODO: flash cookie to show success ? return if (noteService.trash(jwtPayload.userId, noteUuid))
else Response.redirect("/notes") // TODO: flash cookie to show success ?
Response(NOT_FOUND) // TODO: show an error 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) val note = noteService.find(jwtPayload.userId, noteUuid) ?: return Response(NOT_FOUND)
return Response(OK).html(view.renderedNote(jwtPayload, note)) 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 { fun edit(request: Request, jwtPayload: JwtPayload): Response {

View File

@ -42,6 +42,7 @@ class Router(
"/login" bind GET public userController::login, "/login" bind GET public userController::login,
"/login" bind POST public userController::login, "/login" bind POST public userController::login,
"/logout" bind POST to userController::logout, "/logout" bind POST to userController::logout,
"/notes/public/{uuid}" bind GET public noteController::public,
) )
val protectedRoutes = routes( val protectedRoutes = routes(

View File

@ -116,12 +116,21 @@ class NoteView(staticFileResolver: StaticFileResolver) : View(staticFileResolver
} }
} }
fun renderedNote(jwtPayload: JwtPayload, note: PersistedNote) = renderPage( fun renderedNote(jwtPayload: JwtPayload?, note: PersistedNote, shared: Boolean) = renderPage(
note.meta.title, note.meta.title,
jwtPayload = jwtPayload, 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) {
p("p-4 bg-gray-800") {
+"You are viewing a public note "
}
hr { }
}
div("flex items-center justify-between mb-4") { div("flex items-center justify-between mb-4") {
h1("text-3xl fond-bold underline") { +note.meta.title } h1("text-3xl fond-bold underline") { +note.meta.title }
span("space-x-2") { span("space-x-2") {
@ -132,19 +141,21 @@ class NoteView(staticFileResolver: StaticFileResolver) : View(staticFileResolver
} }
} }
} }
span("flex space-x-2 justify-end mb-4") { if (!shared) {
a( noteActionForm(note)
href = "/notes/${note.uuid}/edit", publicPrivateForm(note)
classes = "btn btn-teal"
) { +"Edit" } if (note.public) {
form(method = FormMethod.post, classes = "inline") { p("my-4") {
button( +"You can share this link : "
type = ButtonType.submit, a(href = "/notes/public/${note.uuid}", classes = "text-blue-300 underline") {
name = "delete", +"/notes/public/${note.uuid}"
classes = "btn btn-red" }
) { +"Delete" } }
hr { }
} }
} }
div { div {
attributes["id"] = "note" attributes["id"] = "note"
unsafe { unsafe {
@ -153,4 +164,38 @@ class NoteView(staticFileResolver: StaticFileResolver) : View(staticFileResolver
} }
} }
} }
private fun DIV.noteActionForm(note: PersistedNote) {
span("flex space-x-2 justify-end mb-4") {
a(
href = "/notes/${note.uuid}/edit",
classes = "btn btn-teal"
) { +"Edit" }
form(method = FormMethod.post, classes = "inline") {
button(
type = ButtonType.submit,
name = "delete",
classes = "btn btn-red"
) { +"Delete" }
}
}
}
private fun DIV.publicPrivateForm(note: PersistedNote) {
span("flex space-x-2 justify-end mb-4") {
form(method = FormMethod.post, classes = "ml-auto ") {
button(
type = ButtonType.submit,
name = if (note.public) "private" else "public",
classes = "btn btn-teal"
) {
if (note.public)
+"This note is public, do you want to make it private ?"
else
+"This note is private, do you want to make it public ?"
}
}
}
}
} }

View File

@ -29,6 +29,7 @@ data class PersistedNote(
val html: String, val html: String,
val updatedAt: LocalDateTime, val updatedAt: LocalDateTime,
val uuid: UUID, val uuid: UUID,
val public: Boolean,
) )
@Serializable @Serializable

View File

@ -88,6 +88,10 @@ class NoteService(
fun search(userId: Int, searchTerms: SearchTerms) = searcher.search(userId, searchTerms) fun search(userId: Int, searchTerms: SearchTerms) = searcher.search(userId, searchTerms)
fun dropAllIndexes() = searcher.dropAll() fun dropAllIndexes() = searcher.dropAll()
fun makePublic(userId: Int, uuid: UUID) = noteRepository.makePublic(userId, uuid)
fun makePrivate(userId: Int, uuid: UUID) = noteRepository.makePrivate(userId, uuid)
fun findPublic(uuid: UUID) = noteRepository.findPublic(uuid)
} }
data class PaginatedNotes(val pages: Int, val notes: List<PersistedNoteMetadata>) data class PaginatedNotes(val pages: Int, val notes: List<PersistedNoteMetadata>)

View File

@ -28,4 +28,8 @@ interface NoteRepository {
fun update(userId: Int, uuid: UUID, note: Note): PersistedNote? fun update(userId: Int, uuid: UUID, note: Note): PersistedNote?
fun export(userId: Int): List<ExportedNote> fun export(userId: Int): List<ExportedNote>
fun findAllDetails(userId: Int): List<PersistedNote> fun findAllDetails(userId: Int): List<PersistedNote>
fun makePublic(userId: Int, uuid: UUID): Boolean
fun makePrivate(userId: Int, uuid: UUID): Boolean
fun findPublic(uuid: UUID): PersistedNote?
} }

View File

@ -121,7 +121,8 @@ internal class NoteRepositoryImpl(private val db: Database) : NoteRepository {
markdown = note.markdown, markdown = note.markdown,
html = note.html, html = note.html,
updatedAt = now, updatedAt = now,
uuid = uuid uuid = uuid,
public = false, // TODO
) )
} }
} }
@ -199,6 +200,32 @@ internal class NoteRepositoryImpl(private val db: Database) : NoteRepository {
} }
} }
override fun makePublic(userId: Int, uuid: UUID) = db.update(Notes) {
it.public to true
where { Notes.userId eq userId and (Notes.uuid eq uuid) and (it.deleted eq false) }
} == 1
override fun makePrivate(userId: Int, uuid: UUID) = db.update(Notes) {
it.public to false
where { Notes.userId eq userId and (Notes.uuid eq uuid) and (it.deleted eq false) }
} == 1
override fun findPublic(uuid: UUID): PersistedNote? {
val note = db.notes
.filterColumns { it.columns - it.userId }
.filter { it.uuid eq uuid }
.filter { it.public eq true }
.find { it.deleted eq false }
?: return null
val tags = db.from(Tags)
.select(Tags.name)
.where { Tags.noteUuid eq uuid }
.map { it[Tags.name]!! }
return note.toPersistedNote(tags)
}
private fun List<NoteEntity>.tagsByUuid(): Map<UUID, List<String>> { private fun List<NoteEntity>.tagsByUuid(): Map<UUID, List<String>> {
return if (isEmpty()) emptyMap() return if (isEmpty()) emptyMap()
else db.tags else db.tags

View File

@ -27,6 +27,7 @@ internal open class Notes(alias: String?) : Table<NoteEntity>("Notes", alias) {
val userId = int("user_id").references(Users) { it.user } val userId = int("user_id").references(Users) { it.user }
val updatedAt = datetime("updated_at").bindTo { it.updatedAt } val updatedAt = datetime("updated_at").bindTo { it.updatedAt }
val deleted = boolean("deleted").bindTo { it.deleted } val deleted = boolean("deleted").bindTo { it.deleted }
val public = boolean("public").bindTo { it.public }
val user get() = userId.referenceTable as Users val user get() = userId.referenceTable as Users
} }
@ -41,6 +42,7 @@ internal interface NoteEntity : Entity<NoteEntity> {
var html: String var html: String
var updatedAt: LocalDateTime var updatedAt: LocalDateTime
var deleted: Boolean var deleted: Boolean
var public: Boolean
var user: UserEntity var user: UserEntity
} }
@ -48,7 +50,7 @@ internal interface NoteEntity : Entity<NoteEntity> {
internal fun NoteEntity.toPersistedMetadata(tags: List<String>) = PersistedNoteMetadata(title, tags, updatedAt, uuid) internal fun NoteEntity.toPersistedMetadata(tags: List<String>) = PersistedNoteMetadata(title, tags, updatedAt, uuid)
internal fun NoteEntity.toPersistedNote(tags: List<String>) = internal fun NoteEntity.toPersistedNote(tags: List<String>) =
PersistedNote(NoteMetadata(title, tags), markdown, html, updatedAt, uuid) PersistedNote(NoteMetadata(title, tags), markdown, html, updatedAt, uuid, public)
internal fun Note.toEntity(uuid: UUID, userId: Int): NoteEntity { internal fun Note.toEntity(uuid: UUID, userId: Int): NoteEntity {
val note = this val note = this
@ -58,6 +60,7 @@ internal fun Note.toEntity(uuid: UUID, userId: Int): NoteEntity {
this.html = note.html this.html = note.html
this.uuid = uuid this.uuid = uuid
this.deleted = false this.deleted = false
this.public = false
this.user["id"] = userId this.user["id"] = userId
} }
} }

View File

@ -0,0 +1,2 @@
alter table Notes
add column public bool not null default false

View File

@ -0,0 +1,2 @@
alter table Notes
add column public bool not null default false

View File

@ -94,13 +94,13 @@ internal class NoteRepositoryImplTest {
val note = Note(NoteMetadata("title", emptyList()), "md", "html") val note = Note(NoteMetadata("title", emptyList()), "md", "html")
assertThat(noteRepo.create(user1.id, note)) assertThat(noteRepo.create(user1.id, note))
.isEqualToIgnoringGivenFields(note, "uuid", "updatedAt") .isEqualToIgnoringGivenFields(note, "uuid", "updatedAt", "public")
.hasNoNullFieldsOrProperties() .hasNoNullFieldsOrProperties()
assertThat(db.notes.toList()) assertThat(db.notes.toList())
.hasSize(1) .hasSize(1)
.first() .first()
.isEqualToIgnoringGivenFields(note, "uuid", "updatedAt") .isEqualToIgnoringGivenFields(note, "uuid", "updatedAt", "public")
} }
} }

View File

@ -25,7 +25,12 @@ internal class NoteSearcherImplTest {
content: String = "", content: String = "",
uuid: UUID = UUID.randomUUID(), uuid: UUID = UUID.randomUUID(),
): PersistedNote { ): PersistedNote {
val note = PersistedNote(NoteMetadata(title, tags), markdown = content, html = "", LocalDateTime.MIN, uuid) val note = PersistedNote(NoteMetadata(title, tags),
markdown = content,
html = "",
LocalDateTime.MIN,
uuid,
public = false)
searcher.indexNote(1, note) searcher.indexNote(1, note)
return note return note
} }