diff --git a/app/src/main/kotlin/controllers/SettingsController.kt b/app/src/main/kotlin/controllers/SettingsController.kt index 971c354..51d5d6a 100644 --- a/app/src/main/kotlin/controllers/SettingsController.kt +++ b/app/src/main/kotlin/controllers/SettingsController.kt @@ -7,10 +7,7 @@ import be.simplenotes.domain.security.JwtPayload import be.simplenotes.domain.usecases.UserService import be.simplenotes.domain.usecases.users.delete.DeleteError import be.simplenotes.domain.usecases.users.delete.DeleteForm -import org.http4k.core.Method -import org.http4k.core.Request -import org.http4k.core.Response -import org.http4k.core.Status +import org.http4k.core.* import org.http4k.core.body.form import org.http4k.core.cookie.invalidateCookie @@ -49,18 +46,29 @@ class SettingsController( ) } - fun export(request: Request, jwtPayload: JwtPayload): Response { - val isDownload = request.form("download") != null - val json = userService.export(jwtPayload.userId) - val res = Response(Status.OK).body(json).header("Content-Type", "application/json") - return if (isDownload) res.header( - "Content-Disposition", - "attachment; filename=\"simplenotes-export-${sanitizeFilename(jwtPayload.username)}.json\"" - ) - else res + private fun attachment(filename: String, contentType: String) = { response: Response -> + val name = filename.replace("[^a-zA-Z0-9-_.]".toRegex(), "_") + response + .header("Content-Disposition", "attachment; filename=\"$name\"") + .header("Content-Type", contentType) } - private fun sanitizeFilename(inputName: String): String = inputName.replace("[^a-zA-Z0-9-_.]".toRegex(), "_") + fun export(request: Request, jwtPayload: JwtPayload): Response { + val isDownload = request.form("download") != null + + return if (isDownload) { + val filename = "simplenotes-export-${jwtPayload.username}" + if (request.form("format") == "zip") { + val zip = userService.exportAsZip(jwtPayload.userId) + Response(Status.OK) + .with(attachment("$filename.zip", "application/zip")) + .body(zip) + } else + Response(Status.OK) + .with(attachment("$filename.json", "application/json")) + .body(userService.exportAsJson(jwtPayload.userId)) + } else Response(Status.OK).body(userService.exportAsJson(jwtPayload.userId)).header("Content-Type", "application/json") + } private fun Request.deleteForm(jwtPayload: JwtPayload) = DeleteForm(jwtPayload.username, form("password"), form("checked") != null) diff --git a/domain/pom.xml b/domain/pom.xml index 0f8f353..43382b8 100644 --- a/domain/pom.xml +++ b/domain/pom.xml @@ -61,6 +61,11 @@ org.jetbrains.kotlinx kotlinx-serialization-runtime + + org.apache.commons + commons-compress + 1.20 + diff --git a/domain/src/main/kotlin/usecases/export/ExportUseCase.kt b/domain/src/main/kotlin/usecases/export/ExportUseCase.kt index 7b71d58..2655141 100644 --- a/domain/src/main/kotlin/usecases/export/ExportUseCase.kt +++ b/domain/src/main/kotlin/usecases/export/ExportUseCase.kt @@ -1,5 +1,8 @@ package be.simplenotes.domain.usecases.export +import java.io.InputStream + interface ExportUseCase { - fun export(userId: Int): String + fun exportAsJson(userId: Int): String + fun exportAsZip(userId: Int): InputStream } diff --git a/domain/src/main/kotlin/usecases/export/ExportUseCaseImpl.kt b/domain/src/main/kotlin/usecases/export/ExportUseCaseImpl.kt index 6071622..0e15e1a 100644 --- a/domain/src/main/kotlin/usecases/export/ExportUseCaseImpl.kt +++ b/domain/src/main/kotlin/usecases/export/ExportUseCaseImpl.kt @@ -11,10 +11,15 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream import java.time.LocalDateTime internal class ExportUseCaseImpl(private val noteRepository: NoteRepository) : ExportUseCase { - override fun export(userId: Int): String { + override fun exportAsJson(userId: Int): String { val module = SerializersModule { contextual(LocalDateTime::class, LocalDateTimeSerializer) } @@ -27,8 +32,41 @@ internal class ExportUseCaseImpl(private val noteRepository: NoteRepository) : E val notes = noteRepository.export(userId) return json.encodeToString(ListSerializer(ExportedNote.serializer()), notes) } + + private fun sanitizeFilename(inputName: String): String = inputName.replace("[^a-zA-Z0-9-_.]".toRegex(), "_") + + override fun exportAsZip(userId: Int): InputStream { + val notes = noteRepository.export(userId) + val zipOutput = ZipOutput() + zipOutput.use { zip -> + notes.forEach { + val name = sanitizeFilename(it.title) + zip.write("notes/$name.md", it.markdown) + } + } + return ByteArrayInputStream(zipOutput.outputStream.toByteArray()) + } } + +class ZipOutput : AutoCloseable { + val outputStream = ByteArrayOutputStream() + private val zipOutputStream = ZipArchiveOutputStream(outputStream) + + fun write(path: String, content: String) { + val entry = ZipArchiveEntry(path) + zipOutputStream.putArchiveEntry(entry) + zipOutputStream.write(content.toByteArray()) + zipOutputStream.closeArchiveEntry() + } + + override fun close() { + zipOutputStream.finish() + zipOutputStream.close() + } +} + + internal object LocalDateTimeSerializer : KSerializer { override val descriptor: SerialDescriptor get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)