mirror of https://github.com/micromata/borgbackup-butler.git

Kai Reinhard
17.59.2021 c6e77f6fa462e292db5f693a33e7c483b5a6e19e
Release 0.5 started: SpringBoot instead of Jetty, Jersey etc.
19 files deleted
23 files added
17 files modified
3880 ■■■■ changed files
borgbutler-core/build.gradle 16 ●●●● patch | view | raw | blame | history
borgbutler-core/src/main/java/de/micromata/borgbutler/config/BorgRepoConfig.java 6 ●●●●● patch | view | raw | blame | history
borgbutler-core/src/main/java/de/micromata/borgbutler/json/JsonUtils.java 71 ●●●●● patch | view | raw | blame | history
borgbutler-core/src/main/kotlin/de/micromata/borgbutler/BorgJob.kt 4 ●●● patch | view | raw | blame | history
borgbutler-core/src/main/kotlin/de/micromata/borgbutler/config/Configuration.kt 4 ●●● patch | view | raw | blame | history
borgbutler-core/src/main/kotlin/de/micromata/borgbutler/config/ConfigurationHandler.kt 8 ●●●● patch | view | raw | blame | history
borgbutler-core/src/main/kotlin/de/micromata/borgbutler/json/JsonUtils.kt 69 ●●●●● patch | view | raw | blame | history
borgbutler-docker/app/Dockerfile 2 ●●● patch | view | raw | blame | history
borgbutler-docker/app/entrypoint.sh 2 ●●● patch | view | raw | blame | history
borgbutler-server/build.gradle 22 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/Main.java 189 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/ServerConfiguration.java 63 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/jetty/JettyServer.java 188 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/logging/Log4jMemoryAppender.java 148 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/logging/LogLevel.java 8 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/logging/LoggingEventData.java 26 ●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/ArchivesRest.java 220 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/BorgRepoConfigsRest.java 92 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/ConfigurationInfo.java 27 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/ConfigurationRest.java 88 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/FilesystemBrowserRest.java 126 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/I18nRest.java 46 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/JobsRest.java 200 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/LoggingRest.java 66 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/ReposRest.java 73 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/RestUtils.java 58 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/SystemInfo.java 44 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/SystemInfoRest.java 34 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/VersionRest.java 88 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/user/SingleUserManager.java 4 ●●●● patch | view | raw | blame | history
borgbutler-server/src/main/java/de/micromata/borgbutler/server/user/UserFilter.java 5 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/BorgButlerApplication.kt 179 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/ServerConfiguration.kt 50 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/WebConfig.kt 30 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/logging/LoggerMemoryAppender.kt 134 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/ArchivesRest.kt 221 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/BorgRepoConfigsRest.kt 82 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/ConfigurationInfo.kt 9 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/ConfigurationRest.kt 76 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/ExceptionStackTracePrinter.kt 72 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/FilesystemBrowserRest.kt 141 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/GlobalDefaultExceptionHandling.kt 57 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/I18nRest.kt 42 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/JobsRest.kt 201 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/LoggingRest.kt 67 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/ReposRest.kt 66 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/RequestLog.kt 138 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/RestUtils.kt 112 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/SystemInfo.kt 33 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/SystemInfoRest.kt 26 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/VersionRest.kt 66 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/resources/application.properties 2 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/resources/log4j.properties 21 ●●●●● patch | view | raw | blame | history
borgbutler-server/src/main/resources/logback-spring.xml 35 ●●●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/views/archives/ArchiveView.jsx 2 ●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/views/archives/FileListPanel.jsx 2 ●●● patch | view | raw | blame | history
borgbutler-webapp/src/components/views/repos/RepoArchiveListView.jsx 2 ●●● patch | view | raw | blame | history
build.gradle 13 ●●●● patch | view | raw | blame | history
doc/Development.adoc 4 ●●●● patch | view | raw | blame | history
borgbutler-core/build.gradle
@@ -1,6 +1,5 @@
plugins {
    id 'java'
        id "org.jetbrains.kotlin.jvm" version "1.4.32"
}
description = 'borgbutler-core'
@@ -10,10 +9,10 @@
    implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.8.1'
    implementation group: 'org.apache.commons', name: 'commons-exec', version: '1.3'
    implementation group: 'org.apache.commons', name: 'commons-collections4', version: '4.2'
    implementation group: 'org.apache.commons', name: 'commons-compress', version: '1.18'
    implementation group: 'org.apache.commons', name: 'commons-compress', version: '1.20'
    implementation group: 'org.apache.commons', name: 'commons-jcs-core', version: '2.2.1'
    // https://mvnrepository.com/artifact/com.esotericsoftware/kryo
    implementation group: 'com.esotericsoftware', name: 'kryo', version: '5.0.0-RC1'
    implementation group: 'com.esotericsoftware', name: 'kryo', version: '5.1.0'
    // Serialization (faster than Java built-in)
    implementation 'io.github.microutils:kotlin-logging-jvm:2.0.6'
@@ -32,7 +31,6 @@
}
sourceSets {
    main.kotlin.srcDirs += 'src/main/kotlin'
    main.java.srcDirs += 'src/main/java'
}
@@ -41,13 +39,3 @@
    minHeapSize = "128m"
    maxHeapSize = "1500m"
}
compileKotlin {
    kotlinOptions {
        jvmTarget = "1.8"
    }
}
compileTestKotlin {
    kotlinOptions {
        jvmTarget = "1.9"
    }
}
borgbutler-core/src/main/java/de/micromata/borgbutler/config/BorgRepoConfig.java
@@ -1,6 +1,7 @@
package de.micromata.borgbutler.config;
import com.fasterxml.jackson.annotation.JsonIgnore;
import de.micromata.borgbutler.json.JsonUtils;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
@@ -93,4 +94,9 @@
    public void setId(String id) {
        this.id = id;
    }
    @Override
    public String toString() {
        return JsonUtils.toJson(this);
    }
}
borgbutler-core/src/main/java/de/micromata/borgbutler/json/JsonUtils.java
File was deleted
borgbutler-core/src/main/kotlin/de/micromata/borgbutler/BorgJob.kt
@@ -6,18 +6,20 @@
import de.micromata.borgbutler.jobs.JobResult
import de.micromata.borgbutler.json.JsonUtils
import de.micromata.borgbutler.json.borg.ProgressInfo
import mu.KotlinLogging
import org.apache.commons.exec.CommandLine
import org.apache.commons.exec.environment.EnvironmentUtils
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import java.io.IOException
private val log = KotlinLogging.logger {}
/**
 * A queue is important because Borg doesn't support parallel calls for one repository.
 * For each repository one single queue is allocated.
 */
open class BorgJob<T> : AbstractCommandLineJob, Cloneable {
    private val log = LoggerFactory.getLogger(BorgJob::class.java)
    var command: BorgCommand? = null
        private set
borgbutler-core/src/main/kotlin/de/micromata/borgbutler/config/Configuration.kt
@@ -4,15 +4,17 @@
import com.fasterxml.jackson.annotation.JsonProperty
import de.micromata.borgbutler.config.BorgRepoConfig
import de.micromata.borgbutler.demo.DemoRepos
import mu.KotlinLogging
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import java.io.File
private val log = KotlinLogging.logger {}
/**
 * Representation of ~/.borgbutler/borgbutler-config.json.
 */
open class Configuration {
    private val log = LoggerFactory.getLogger(Configuration::class.java)
    @JsonIgnore
    private var workingDir: File? = null
borgbutler-core/src/main/kotlin/de/micromata/borgbutler/config/ConfigurationHandler.kt
@@ -101,14 +101,14 @@
        }
        @kotlin.jvm.JvmStatic
        fun getInstance(): ConfigurationHandler? {
        fun getInstance(): ConfigurationHandler {
            if (instance == null) instance = ConfigurationHandler()
            return instance
            return instance!!
        }
        @kotlin.jvm.JvmStatic
        fun getConfiguration(): Configuration? {
            return getInstance()!!.configuration
        fun getConfiguration(): Configuration {
            return getInstance().configuration!!
        }
        @kotlin.jvm.JvmStatic
borgbutler-core/src/main/kotlin/de/micromata/borgbutler/json/JsonUtils.kt
New file
@@ -0,0 +1,69 @@
package de.micromata.borgbutler.json
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.core.io.JsonStringEncoder
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import mu.KotlinLogging
import org.slf4j.LoggerFactory
import java.io.IOException
import java.io.StringWriter
private val log = KotlinLogging.logger {}
object JsonUtils {
    /**
     * @param obj
     * @param prettyPrinter If true, the json output will be pretty printed (human readable with new lines and indenting).
     * @return
     */
    @JvmOverloads
    @JvmStatic
    fun toJson(obj: Any?, prettyPrinter: Boolean? = false): String {
        if (obj == null) {
            return ""
        }
        val objectMapper = ObjectMapper()
        objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL)
        return try {
            if (prettyPrinter == true) {
                objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(obj)
            } else {
                val writer = StringWriter()
                objectMapper.writeValue(writer, obj)
                writer.toString()
            }
        } catch (ex: IOException) {
            log.error(ex.message, ex)
            ""
        }
    }
    @JvmStatic
    fun toJson(str: String?): String {
        return if (str == null) "" else String(JsonStringEncoder.getInstance().quoteAsString(str))
    }
    @JvmStatic
    fun <T> fromJson(clazz: Class<T>?, json: String?): T? {
        val objectMapper = ObjectMapper()
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
        return try {
            objectMapper.readValue(json, clazz)
        } catch (ex: IOException) {
            log.error(ex.message, ex)
            null
        }
    }
    @JvmStatic
    fun <T> fromJson(type: TypeReference<T>?, json: String): T? {
        try {
            return ObjectMapper().readValue(json, type)
        } catch (ex: Exception) {
            log.error("Json: '" + json + "': " + ex.message, ex)
        }
        return null
    }
}
borgbutler-docker/app/Dockerfile
@@ -16,7 +16,7 @@
USER borgbutler:borgbutler
# Don't put fat jar files in docker images: https://phauer.com/2019/no-fat-jar-in-docker-image/
ARG DEPENDENCY=target/dependency/borgbutler-server-0.4-SNAPSHOT
ARG DEPENDENCY=target/dependency/borgbutler-server-0.5-SNAPSHOT
COPY ${DEPENDENCY}/lib /app/lib
COPY ${DEPENDENCY}/web /app/web
#COPY ${DEPENDENCY}/META-INF /app/META-INF
borgbutler-docker/app/entrypoint.sh
@@ -49,7 +49,7 @@
echo "Starting java ${JAVA_OPTS} -cp app/web/*:app/lib/* -DborgbutlerHome=/BorgButler/ -DapplicationHome=/app -DbindAddress=0.0.0.0 -DallowedClientIps=172.17. de.micromata.borgbutler.server.Main -q ${JAVA_ARGS}"
java ${JAVA_OPTS} -cp app/web/*:app/lib/* -DborgbutlerHome=/BorgButler/ -DapplicationHome=/app -DbindAddress=0.0.0.0 -DallowedClientIps=172.17. de.micromata.borgbutler.server.Main -q ${JAVA_ARGS} &
java $JAVA_OPTS -cp app/web/*:app/lib/* -DborgbutlerHome=/BorgButler/ -DapplicationHome=/app -DbindAddress=0.0.0.0 -DallowedClientIps=172.17. de.micromata.borgbutler.server.Main -q $JAVA_ARGS &
CHILD=$!
wait $CHILD
borgbutler-server/build.gradle
@@ -10,6 +10,7 @@
plugins {
    id 'java'
    id 'org.springframework.boot' version '2.4.5'
}
description = 'borgbutler-server'
@@ -21,20 +22,13 @@
    implementation group: 'org.apache.commons', name: 'commons-collections4', version: '4.2'
    implementation group: 'org.apache.commons', name: 'commons-compress', version: '1.18'
    implementation group: 'commons-io', name: 'commons-io', version: '2.8.0'
    implementation group: 'org.eclipse.jetty', name: 'jetty-server', version: '9.4.12.v20180830'
    implementation group: 'org.eclipse.jetty', name: 'jetty-servlet', version: '9.4.12.v20180830'
    implementation group: 'org.eclipse.jetty', name: 'jetty-servlets', version: '9.4.12.v20180830'
    implementation group: 'org.glassfish.jaxb', name: 'jaxb-core', version: '2.3.0.1'
    implementation group: 'org.glassfish.jaxb', name: 'jaxb-runtime', version: '2.3.1'
    implementation group: 'org.glassfish.jersey.containers', name: 'jersey-container-servlet', version: '2.27'
    implementation group: 'org.glassfish.jersey.media', name: 'jersey-media-multipart', version: '2.27'
    implementation group: 'org.glassfish.jersey.media', name: 'jersey-media-json-jackson', version: '2.27'
    implementation group: 'org.glassfish.jersey.inject', name: 'jersey-hk2', version: '2.27'
    implementation group: 'javax.xml.bind', name: 'jaxb-api', version: '2.3.1'
    implementation group: 'javax.xml.ws', name: 'jaxws-api', version: '2.3.1'
    implementation group: 'org.slf4j', name: 'slf4j-log4j12', version: '1.7.25'
    // https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-web
    implementation group: 'org.springframework.boot', name: 'spring-boot-starter-web', version: '2.4.5'
    implementation 'io.github.microutils:kotlin-logging-jvm:2.0.6'
    // https://mvnrepository.com/artifact/org.apache.httpcomponents/httpclient
    implementation group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.5.6'
    implementation group: 'org.apache.httpcomponents', name: 'httpclient', version: '4.5.13'
    // https://mvnrepository.com/artifact/commons-cli/commons-cli
    implementation group: 'commons-cli', name: 'commons-cli', version: '1.4'
@@ -49,7 +43,7 @@
apply plugin: 'application'
apply plugin: 'kotlin'
mainClassName = "de.micromata.borgbutler.server.Main"
mainClassName = "de.micromata.borgbutler.server.BorgButlerApplication"
run() {
    doFirst {
borgbutler-server/src/main/java/de/micromata/borgbutler/server/Main.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/ServerConfiguration.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/jetty/JettyServer.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/logging/Log4jMemoryAppender.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/logging/LogLevel.java
@@ -1,8 +1,8 @@
package de.micromata.borgbutler.server.logging;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import org.apache.commons.lang3.StringUtils;
import org.apache.log4j.Level;
import org.apache.log4j.spi.LoggingEvent;
public enum LogLevel {
    ERROR, WARN, INFO, DEBUG, TRACE;
@@ -18,10 +18,8 @@
        return this.ordinal() <= treshold.ordinal();
    }
    public static LogLevel getLevel(LoggingEvent event) {
    public static LogLevel getLevel(ILoggingEvent event) {
        switch (event.getLevel().toInt()) {
            case Level.ERROR_INT:
                return LogLevel.ERROR;
            case Level.INFO_INT:
                return LogLevel.INFO;
            case Level.DEBUG_INT:
borgbutler-server/src/main/java/de/micromata/borgbutler/server/logging/LoggingEventData.java
@@ -1,8 +1,10 @@
package de.micromata.borgbutler.server.logging;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.classic.spi.IThrowableProxy;
import ch.qos.logback.classic.spi.ThrowableProxyUtil;
import ch.qos.logback.core.CoreConstants;
import org.apache.commons.lang3.ClassUtils;
import org.apache.log4j.spi.LocationInfo;
import org.apache.log4j.spi.LoggingEvent;
import java.io.PrintWriter;
import java.io.StringWriter;
@@ -23,26 +25,26 @@
    private String logDate;
    String javaClass;
    private String javaClassSimpleName;
    private String lineNumber;
    private int lineNumber;
    private String methodName;
    private String stackTrace;
    LoggingEventData() {
    }
    public LoggingEventData(LoggingEvent event) {
    public LoggingEventData(ILoggingEvent event) {
        level = LogLevel.getLevel(event);
        message = event.getRenderedMessage();
        message = event.getFormattedMessage();
        messageObjectClass = event.getMessage().getClass().toString();
        loggerName = event.getLoggerName();
        logDate = getIsoLogDate(event.timeStamp);
        LocationInfo info = event.getLocationInformation();
        Throwable throwable = event.getThrowableInformation() != null ? event.getThrowableInformation().getThrowable() : null;
        if (throwable != null) {
        logDate = getIsoLogDate(event.getTimeStamp());
        StackTraceElement info = event.getCallerData()[0];
        IThrowableProxy throwableProxy = event.getThrowableProxy();
        if (throwableProxy != null) {
            StringWriter writer = new StringWriter();
            PrintWriter printWriter = new PrintWriter(writer);
            throwable.printStackTrace(printWriter);
            printWriter.append(ThrowableProxyUtil.asString(throwableProxy));
            printWriter.append(CoreConstants.LINE_SEPARATOR);
            stackTrace = writer.toString();
        }
        if (info != null) {
@@ -81,7 +83,7 @@
        return javaClassSimpleName;
    }
    public String getLineNumber() {
    public int getLineNumber() {
        return lineNumber;
    }
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/ArchivesRest.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/BorgRepoConfigsRest.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/ConfigurationInfo.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/ConfigurationRest.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/FilesystemBrowserRest.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/I18nRest.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/JobsRest.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/LoggingRest.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/ReposRest.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/RestUtils.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/SystemInfo.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/SystemInfoRest.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/rest/VersionRest.java
File was deleted
borgbutler-server/src/main/java/de/micromata/borgbutler/server/user/SingleUserManager.java
@@ -48,7 +48,11 @@
        String dateFormat = userData.getDateFormat();
        this.singleUser.setDateFormat(dateFormat);
        String lang = Languages.asString(locale);
        if (lang != null) {
        preferences.put(USER_LOCAL_PREF_KEY, lang);
        } else {
            preferences.remove(USER_LOCAL_PREF_KEY);
        }
        try {
            preferences.flush();
        } catch (BackingStoreException ex) {
borgbutler-server/src/main/java/de/micromata/borgbutler/server/user/UserFilter.java
@@ -1,9 +1,12 @@
package de.micromata.borgbutler.server.user;
import de.micromata.borgbutler.server.rest.RequestLog;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
@@ -12,6 +15,7 @@
 * <br>
 * For requests from remote (not localhost) an exception is thrown due to security reasons.
 */
@Component
public class UserFilter implements Filter {
    private Logger log = LoggerFactory.getLogger(UserFilter.class);
@@ -41,6 +45,7 @@
            userData = UserManager.instance().getUser("dummy");
            UserUtils.setUser(userData, request.getLocale());
            if (log.isDebugEnabled()) log.debug("Request for user: " + userData);
            log.info("Request for user: " + userData + ": " + RequestLog.asString((HttpServletRequest) request));
            chain.doFilter(request, response);
        } finally {
            UserUtils.removeUser();
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/BorgButlerApplication.kt
New file
@@ -0,0 +1,179 @@
package de.micromata.borgbutler.server
import de.micromata.borgbutler.cache.ButlerCache
import de.micromata.borgbutler.config.ConfigurationHandler.Companion.init
import de.micromata.borgbutler.config.ConfigurationHandler.Companion.setConfigClazz
import de.micromata.borgbutler.server.user.SingleUserManager
import de.micromata.borgbutler.server.user.UserManager
import mu.KotlinLogging
import org.apache.commons.cli.*
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream
import org.apache.commons.io.FileUtils
import org.apache.commons.io.FilenameUtils
import org.apache.commons.lang3.StringUtils
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.event.EventListener
import java.awt.Desktop
import java.io.*
import java.net.URI
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import javax.annotation.PreDestroy
private val log = KotlinLogging.logger {}
@SpringBootApplication
open class BorgButlerApplication {
    @Value("\${server.address}")
    private var serverAddress: String = "127.0.0.1"
    @Value("\${server.port}")
    private var serverPort = 9042
    private fun _start(args: Array<out String>) {
        setConfigClazz(ServerConfiguration::class.java)
        // create Options object
        val options = Options()
        options.addOption(
            "e",
            "extract-archive-content",
            true,
            "Extracts the content of an archive cache file only (doesn't start the server). A complete file list of the archive will be extracted to stdout."
        )
        options.addOption("p", "port", true, "The default port for the web server.")
        options.addOption("q", "quiet", false, "Don't open browser automatically.")
        options.addOption("h", "help", false, "Print this help screen.")
        //options.addOption("homeDir", true, "Specify own home directory of butler. Default is $HOME/.borgbutler");
        val parser: CommandLineParser = DefaultParser()
        try {
            // parse the command line arguments
            val line = parser.parse(options, args)
            if (line.hasOption('h')) {
                printHelp(options)
                return
            }
            if (line.hasOption('e')) {
                val file = line.getOptionValue("e")
                printArchiveContent(file)
                return
            }
            if (line.hasOption('p')) {
                // initialise the member variable
                val portString = line.getOptionValue("p")
                try {
                    val port = portString.toInt()
                    if (port < 1 || port > 65535) {
                        System.err.println("Port outside range.")
                        return
                    }
                    ServerConfiguration.get().port = port
                } catch (ex: NumberFormatException) {
                    printHelp(options)
                    return
                }
            }
            val applicationHome = System.getProperty("borgbutlerHome")
            if (applicationHome != null) {
                init(applicationHome)
            }
            if (Desktop.isDesktopSupported()) {
                RunningMode.setServerType(RunningMode.ServerType.DESKTOP)
            } else {
                RunningMode.setServerType(RunningMode.ServerType.SERVER)
            }
            RunningMode.logMode()
            UserManager.setUserManager(SingleUserManager())
            BorgInstallation.getInstance().initialize()
            // 0.0.0.0 for Docker installations.
            val url = "http://$serverAddress:$serverPort/".replace("0.0.0.0", "127.0.0.1")
            if (!line.hasOption('q')) {
                try {
                    Desktop.getDesktop().browse(URI.create(url))
                } catch (ex: Exception) {
                    log.info("Can't open web browser: " + ex.message)
                }
            } else {
                log.info("Please open your browser: $url")
            }
        } catch (ex: ParseException) {
            // oops, something went wrong
            System.err.println("Parsing failed.  Reason: " + ex.message)
            printHelp(options)
        }
    }
    @EventListener(ApplicationReadyEvent::class)
    open fun startApp() {
    }
    @PreDestroy
    open fun shutdownApp() {
        log.info("Shutting down BorgButler web server...")
        ButlerCache.getInstance().shutdown()
    }
    companion object {
        private val main = BorgButlerApplication()
        @JvmStatic
        fun main(vararg args: String) {
            main._start(args)
            SpringApplication.run(BorgButlerApplication::class.java, *args)
        }
        private fun printHelp(options: Options) {
            val formatter = HelpFormatter()
            formatter.printHelp("borgbutler-server", options)
        }
        private fun printArchiveContent(fileName: String) {
            val file = File(fileName)
            val fileList = ButlerCache.getInstance().getArchiveContent(file)
            var parseFormatExceptionPrinted = false
            if (fileList != null && fileList.size > 0) {
                val tz = TimeZone.getTimeZone("UTC")
                val iso: DateFormat =
                    SimpleDateFormat("yyyy-MM-dd HH:mm:ss'Z'") // Quoted "Z" to indicate UTC, no timezone offset
                iso.timeZone = tz
                val df: DateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.S")
                val out = File(FilenameUtils.getBaseName(fileName) + ".txt.gz")
                log.info("Writing file list to: " + out.absolutePath)
                try {
                    PrintWriter(BufferedOutputStream(GzipCompressorOutputStream(FileOutputStream(out)))).use { writer ->
                        for (item in fileList) {
                            var time = item.mtime
                            if (time.indexOf('T') > 0) {
                                try {
                                    val date = df.parse(item.mtime)
                                    time = iso.format(date)
                                } catch (ex: java.text.ParseException) {
                                    if (!parseFormatExceptionPrinted) {
                                        parseFormatExceptionPrinted = true
                                        log.error("Can't parse date: " + item.mtime)
                                    }
                                }
                            }
                            writer.write(
                                item.mode + " " + item.user + " "
                                        + StringUtils.rightPad(FileUtils.byteCountToDisplaySize(item.size), 10)
                                        + " " + time + " " + item.path
                            )
                            writer.write("\n")
                        }
                    }
                } catch (ex: IOException) {
                    log.error("Can't write file '" + out.absolutePath + "': " + ex.message)
                }
            }
            // 2018-12-04T22:44:58.924642
        }
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/ServerConfiguration.kt
New file
@@ -0,0 +1,50 @@
package de.micromata.borgbutler.server
import de.micromata.borgbutler.config.Configuration
import de.micromata.borgbutler.config.ConfigurationHandler.Companion.getConfiguration
import mu.KotlinLogging
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
private val log = KotlinLogging.logger {}
class ServerConfiguration : Configuration() {
    var port = WEBSERVER_PORT_DEFAULT
    /**
     * If true, CrossOriginFilter will be set.
     */
    var isWebDevelopmentMode = WEB_DEVELOPMENT_MODE_PREF_DEFAULT
    fun copyFrom(other: ServerConfiguration) {
        super.copyFrom(other)
        port = other.port
        isWebDevelopmentMode = other.isWebDevelopmentMode
    }
    companion object {
        val supportedLanguages = arrayOf("en", "de")
        const val WEBSERVER_PORT_DEFAULT = 9042
        private const val WEB_DEVELOPMENT_MODE_PREF_DEFAULT = false
        @JvmStatic
        var applicationHome: String? = null
            get() {
                if (field == null) {
                    field = System.getProperty("applicationHome")
                    if (StringUtils.isBlank(field)) {
                        field = System.getProperty("user.dir")
                        log.info("applicationHome is not given as JVM   parameter. Using current working dir (OK for start in IDE): $field")
                    }
                }
                return field
            }
            private set
        @JvmStatic
        fun get(): ServerConfiguration {
            return getConfiguration() as ServerConfiguration
        }
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/WebConfig.kt
New file
@@ -0,0 +1,30 @@
package de.micromata.borgbutler.server
import mu.KotlinLogging
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.EnableWebMvc
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
private val log = KotlinLogging.logger {}
@Configuration
@EnableWebMvc
open class WebConfig : WebMvcConfigurer {
    override fun addCorsMappings(registry: CorsRegistry) {
        if (ServerConfiguration.get().isWebDevelopmentMode) {
            log.warn("*********************************")
            log.warn("***********            **********")
            log.warn("*********** ATTENTION! **********")
            log.warn("***********            **********")
            log.warn("*********** Running in **********")
            log.warn("*********** dev mode!  **********")
            log.warn("***********            **********")
            log.warn("*********************************")
            log.warn("Don't deliver this app in dev mode due to security reasons (CrossOriginFilter is set)!")
            registry.addMapping("/**")
        }
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/logging/LoggerMemoryAppender.kt
New file
@@ -0,0 +1,134 @@
package de.micromata.borgbutler.server.logging
import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.core.AppenderBase
import mu.KotlinLogging
import org.apache.commons.lang3.StringUtils
import java.util.*
private val log = KotlinLogging.logger {}
class LoggerMemoryAppender : AppenderBase<ILoggingEvent?>() {
    private var lastLogEntryOrderNumber = -1
    var queue = FiFoBuffer<LoggingEventData>(QUEUE_SIZE)
    override fun append(event: ILoggingEvent?) {
        val eventData = LoggingEventData(event)
        eventData.orderNumber = ++lastLogEntryOrderNumber
        queue.add(eventData)
    }
    /**
     * For testing purposes.
     *
     * @param event
     */
    fun append(event: LoggingEventData) {
        queue.add(event)
    }
    fun query(filter: LogFilter?, locale: Locale?): List<LoggingEventData> {
        val result: MutableList<LoggingEventData> = ArrayList()
        if (filter == null) {
            return result
        }
        var maxSize = if (filter.maxSize != null) filter.maxSize else MAX_RESULT_SIZE
        if (maxSize > MAX_RESULT_SIZE) {
            maxSize = MAX_RESULT_SIZE
        }
        var counter = 0
        //I18n i18n = CoreI18n.getDefault().get(locale);
        if (filter.isAscendingOrder) {
            for (i in 0 until queue.size) {
                val resultEvent = getResultEvent(filter, queue[i], locale) ?: continue
                result.add(resultEvent)
                if (++counter > maxSize) break
            }
        } else {
            for (i in queue.size downTo 0) {
                val resultEvent = getResultEvent(filter, queue[i], locale) ?: continue
                result.add(resultEvent)
                if (++counter > maxSize) break
            }
        }
        return result
    }
    private fun getResultEvent(filter: LogFilter, event: LoggingEventData?, locale: Locale?): LoggingEventData? {
        if (event == null) {
            return null
        }
        if (!event.getLevel().matches(filter.threshold)) {
            return null
        }
        if (filter.lastReceivedLogOrderNumber != null) {
            if (event.getOrderNumber() <= filter.lastReceivedLogOrderNumber) {
                return null
            }
        }
        var logString: String? = null
        val message = event.getMessage()
        val localizedMessage = false
        /*if (message != null && message.startsWith("i18n=")) {
                I18nLogEntry i18nLogEntry = I18nLogEntry.parse(message);
                message = i18n.formatMessage(i18nLogEntry.getI18nKey(), (Object[])i18nLogEntry.getArgs());
                localizedMessage = true;
            }*/if (StringUtils.isNotBlank(filter.search)) {
            val sb = StringBuilder()
            sb.append(event.logDate)
            append(sb, event.getLevel(), true)
            append(sb, message, true)
            append(sb, event.getJavaClass(), true)
            append(sb, event.stackTrace, filter.isShowStackTraces)
            logString = sb.toString()
        }
        if (logString == null || matches(logString, filter.search)) {
            var resultEvent: LoggingEventData = event
            if (localizedMessage) {
                // Need a clone
                resultEvent = event.clone()
                resultEvent.setMessage(message)
            }
            return resultEvent
        }
        return null
    }
    private fun append(sb: StringBuilder, value: Any?, append: Boolean) {
        if (!append || value == null) {
            return
        }
        sb.append("|#|").append(value)
    }
    private fun matches(str: String, searchString: String): Boolean {
        if (StringUtils.isBlank(str)) {
            return StringUtils.isBlank(searchString)
        }
        return if (StringUtils.isBlank(searchString)) {
            true
        } else str.toLowerCase().contains(searchString.toLowerCase())
    }
    companion object {
        private const val MAX_RESULT_SIZE = 1000
        private const val QUEUE_SIZE = 10000
        private var instance: LoggerMemoryAppender? = null
        fun getInstance(): LoggerMemoryAppender {
            return instance!!
        }
    }
    /**
     * Initialized by logback on start-up (see logback-spring.xml).
     */
    init {
        if (instance != null) {
            log.warn { "*** LoggerMemoryAppender instantiated twice! Shouldn't occur. ***" }
        } else {
            instance = this
        }
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/ArchivesRest.kt
New file
@@ -0,0 +1,221 @@
package de.micromata.borgbutler.server.rest
import de.micromata.borgbutler.BorgCommands
import de.micromata.borgbutler.cache.ButlerCache
import de.micromata.borgbutler.config.BorgRepoConfig
import de.micromata.borgbutler.config.ConfigurationHandler
import de.micromata.borgbutler.data.Archive
import de.micromata.borgbutler.data.DiffFileSystemFilter
import de.micromata.borgbutler.data.FileSystemFilter
import de.micromata.borgbutler.json.JsonUtils
import de.micromata.borgbutler.json.borg.BorgFilesystemItem
import de.micromata.borgbutler.utils.DirUtils
import mu.KotlinLogging
import org.apache.commons.collections4.CollectionUtils
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.math.NumberUtils
import org.apache.coyote.Response
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.awt.Desktop
import java.io.File
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
private val log = KotlinLogging.logger {}
@RestController
@RequestMapping("/rest/archives")
class ArchivesRest {
    /**
     * @param repoName      Name of repository ([Repository.getName].
     * @param archiveId     Id or name of archive.
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @return Repository (including list of archives) as json string.
     * @see JsonUtils.toJson
     */
    @GetMapping
    fun getArchive(
        @RequestParam("repo") repoName: String,
        @RequestParam("archiveId") archiveId: String,
        @RequestParam("force", required = false) force: Boolean,
        @RequestParam("prettyPrinter", required = false) prettyPrinter: Boolean
    ): String {
        val archive: Archive = ButlerCache.getInstance().getArchive(repoName, archiveId, force == true)
        if (force == true) {
            ButlerCache.getInstance().deleteCachedArchiveContent(repoName, archiveId)
        }
        return JsonUtils.toJson(archive, prettyPrinter == true)
    }
    /**
     * @param archiveId                     Id or name of archive.
     * @param searchString                  The string to search for (key words separated by white chars, trailing ! char represents exclude).
     * @param mode                          Flat (default) or tree.
     * @param currentDirectory              The current displayed directory (only files and directories contained will be returned).
     * @param maxResultSize                 maximum number of file items to return (default is 50).
     * @param diffArchiveId                 If given, the differences between archiveId and diffArchiveId will be returned.
     * @param autoChangeDirectoryToLeafItem If given, this method will step automatically into single sub directories.
     * @param force                         If false (default), non cached file lists will not be loaded by borg.
     * @param prettyPrinter                 If true then the json output will be in pretty format.
     * @return Repository (including list of archives) as json string.
     * @see JsonUtils.toJson
     */
    @GetMapping("filelist")
    fun getArchiveFileList(
        @RequestParam("archiveId") archiveId: String,
        @RequestParam("searchString", required = false) searchString: String?,
        @RequestParam("mode", required = false) mode: String?,
        @RequestParam("currentDirectory", required = false) currentDirectory: String?,
        @RequestParam("maxResultSize", required = false) maxResultSize: String?,
        @RequestParam("diffArchiveId", required = false) diffArchiveId: String?,
        @RequestParam("autoChangeDirectoryToLeafItem", required = false) autoChangeDirectoryToLeafItem: Boolean?,
        @RequestParam("force", required = false) force: Boolean?,
        @RequestParam("prettyPrinter", required = false) prettyPrinter: Boolean?
    ): String {
        val diffMode = StringUtils.isNotBlank(diffArchiveId)
        val maxSize = NumberUtils.toInt(maxResultSize, 50)
        val filter = if (diffMode) DiffFileSystemFilter() else FileSystemFilter()
        filter.setSearchString(searchString)
            .setCurrentDirectory(currentDirectory)
            .setAutoChangeDirectoryToLeafItem(autoChangeDirectoryToLeafItem == true)
        var items: List<BorgFilesystemItem>?
        if (diffMode) {
            filter.setMode(FileSystemFilter.Mode.FLAT)
            items = ButlerCache.getInstance().getArchiveContent(archiveId, true, filter)
            val diffItems: List<BorgFilesystemItem> = ButlerCache.getInstance().getArchiveContent(
                diffArchiveId, true,
                filter
            )
            filter.setMaxResultSize(maxSize)
                .setMode(mode)
            items = (filter as DiffFileSystemFilter).extractDifferences(items, diffItems)
            items = filter.reduce(items)
        } else {
            filter.setMode(mode)
                .setMaxResultSize(maxSize)
            // Get file list (without running diff).
            items = ButlerCache.getInstance().getArchiveContent(
                archiveId, force == true,
                filter
            )
            if (items == null) {
                return "[{\"mode\": \"notLoaded\"}]"
            }
        }
        return JsonUtils.toJson(items, prettyPrinter == true)
    }
    /**
     * @param archiveId
     * @param openDownloads
     * @param fileNumber    The fileNumber of the file or directory in the archive served by BorgButler's
     */
    @GetMapping("/restore")
    fun restore(
        @RequestParam("archiveId") archiveId: String,
        @RequestParam("openDownloads", required = false) openDownloads: Boolean?,
        @RequestParam("fileNumber") fileNumber: Int?
    ): ResponseEntity<*> {
        log.info("Requesting file #$fileNumber of archive '$archiveId'.")
        val filter: FileSystemFilter = FileSystemFilter().setFileNumber(fileNumber)
        val items: List<BorgFilesystemItem> = ButlerCache.getInstance().getArchiveContent(
            archiveId, false,
            filter
        )
        if (CollectionUtils.isEmpty(items)) {
            log.error(
                "Requested file #" + fileNumber + " not found in archive '" + archiveId
                        + ". (May-be the archive content isn't yet loaded to the cache."
            )
            return RestUtils.notFound()
        }
        if (items.size != 1) {
            log.error(
                "Requested file #" + fileNumber + " found multiple times (" + items.size + ") in archive '" + archiveId
                        + "! Please remove the archive files (may-be corrupted)."
            )
            return RestUtils.notFound()
        }
        val archive: Archive = ButlerCache.getInstance().getArchive(archiveId) ?: return RestUtils.notFound()
        val repoConfig = ConfigurationHandler.getConfiguration().getRepoConfig(archive.repoId)
        try {
            val item: BorgFilesystemItem = items[0]
            val restoreHomeDir: File = ConfigurationHandler.getConfiguration().getRestoreHomeDir()
            val restoreDir: File = BorgCommands.extractFiles(restoreHomeDir, repoConfig, archive, item.getPath())
            val files: List<Path?> = DirUtils.listFiles(restoreDir.toPath())
            if (CollectionUtils.isEmpty(files)) {
                log.error("No files extracted.")
                return RestUtils.notFound()
            }
            if (openDownloads == true) openFileBrowser(File(restoreDir, item.getPath()))
            return ResponseEntity.ok("OK")
        } catch (ex: IOException) {
            log.error("No file extracted: " + ex.message, ex)
            return RestUtils.notFound()
        }
    }
    private fun openFileBrowser(fileDirectory: File) {
        if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE_FILE_DIR)) {
            var file: File? = fileDirectory
            if (!fileDirectory.exists() || Files.isSymbolicLink(fileDirectory.toPath())) {
                // Open parent.
                file = fileDirectory.parentFile
            }
            Desktop.getDesktop().browseFileDirectory(file)
        }
    }
    private fun handleRestoredFiles(repoConfig: BorgRepoConfig, archive: Archive): Response? {
        // Todo: Handle download of single files as well as download of zip archive (if BorgButler runs remote).
        return null
        /* File file = path.toFile();
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try {
            FileUtils.copyFile(file, baos);
        } catch (IOException ex) {
            log.error(ex.getMessage(), ex);
        }
        BorgFilesystemItem item = items.get(0);
        file = new File(item.getPath());
        byte[] byteArray = baos.toByteArray();//result.getAsByteArrayOutputStream().toByteArray();
        Response.ResponseBuilder builder = Response.ok(byteArray);
        builder.header("Content-Disposition", "attachment; filename=" + file.getName());
        // Needed to get the Content-Disposition by client:
        builder.header("Access-Control-Expose-Headers", "Content-Disposition");
        Response response = builder.build();
        return response;
        try {
            //java.nio.file.Path tempDirWithPrefix = Files.createTempDirectory("borgbutler-extract-");
            File restoreHomeDir = ConfigurationHandler.getConfiguration().getRestoreHomeDir();
            File restoreDir = BorgCommands.extractFiles(restoreHomeDir, repoConfig, archive.getName(), item.getPath());
            openFileBrowser(restoreDir);
            List<java.nio.file.Path> files = DirUtils.listFiles(tempDir);
            if (CollectionUtils.isEmpty(files)) {
                log.error("No file extracted.");
                Response.ResponseBuilder builder = Response.status(404);
                return builder.build();
            }
            path = files.get(0);
        } catch (IOException ex) {
            log.error("No file extracted: " + ex.getMessage(), ex);
            Response.ResponseBuilder builder = Response.status(404);
            return builder.build();
        } finally {
           if (tempDir != null) {
                try {
                    FileUtils.deleteDirectory(tempDir.toFile());
                } catch (IOException ex) {
                    log.error("Error while trying to delete temporary directory '" + tempDir.toString() + "': " + ex.getMessage(), ex);
                }
            }
        }*/
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/BorgRepoConfigsRest.kt
New file
@@ -0,0 +1,82 @@
package de.micromata.borgbutler.server.rest
import de.micromata.borgbutler.BorgCommandResult
import de.micromata.borgbutler.BorgCommands
import de.micromata.borgbutler.cache.ButlerCache
import de.micromata.borgbutler.config.BorgRepoConfig
import de.micromata.borgbutler.config.ConfigurationHandler
import de.micromata.borgbutler.data.Repository
import de.micromata.borgbutler.jobs.JobResult
import de.micromata.borgbutler.json.JsonUtils
import mu.KotlinLogging
import org.springframework.web.bind.annotation.*
private val log = KotlinLogging.logger {}
@RestController
@RequestMapping("/rest/repoConfig")
class BorgRepoConfigsRest {
    /**
     * @param id            id or name of repo.
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @return [BorgRepoConfig] as json string.
     * @see JsonUtils.toJson
     */
    @GetMapping
    fun getRepoConfig(
        @RequestParam("id") id: String,
        @RequestParam("prettyPrinter", required = false) prettyPrinter: Boolean?
    ): String {
        val repoConfig = ConfigurationHandler.getConfiguration().getRepoConfig(id)
        return JsonUtils.toJson(repoConfig, prettyPrinter == true)
    }
    @PostMapping
    fun setRepoConfig(@RequestBody newRepoConfig: BorgRepoConfig) {
        if ("new" == newRepoConfig.getId()) {
            newRepoConfig.setId(null)
            ConfigurationHandler.getConfiguration().add(newRepoConfig)
        } else if ("init" == newRepoConfig.getId()) {
            newRepoConfig.setId(null)
            ConfigurationHandler.getConfiguration().add(newRepoConfig)
        } else {
            val repoConfig: BorgRepoConfig? =
                ConfigurationHandler.getConfiguration().getRepoConfig(newRepoConfig.getId())
            if (repoConfig == null) {
                log.error("Can't find repo config '" + newRepoConfig.getId() + "'. Can't save new settings.")
                return
            }
            ButlerCache.getInstance().clearRepoCacheAccess(repoConfig.getRepo())
            ButlerCache.getInstance().clearRepoCacheAccess(newRepoConfig.getRepo())
            repoConfig.copyFrom(newRepoConfig)
        }
        ConfigurationHandler.getInstance().save()
    }
    /**
     * @param idOrName id or name of repo to remove from BorgButler.
     * @return "OK" if removed or error string.
     */
    @GetMapping("remove")
    fun removeRepoConfig(@RequestParam("id") idOrName: String): String {
        val result: Boolean = ConfigurationHandler.getConfiguration().remove(idOrName)
        if (!result) {
            val error = "Repo config with id or name '$idOrName' not found. Can't remove the repo."
            log.error(error)
            return error
        }
        ConfigurationHandler.getInstance().save()
        return "OK"
    }
    /**
     * @param jsonRepoConfig All configuration value of the repo to check.
     * @return Result of borg (tbd.).
     */
    @PostMapping("check")
    fun checkConfig(@RequestBody repoConfig: BorgRepoConfig): String {
        log.info("Testing repo config: $repoConfig")
        val result: BorgCommandResult<Repository> = BorgCommands.info(repoConfig)
        return if (result.getStatus() == JobResult.Status.OK) "OK" else result.getError()
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/ConfigurationInfo.kt
New file
@@ -0,0 +1,9 @@
package de.micromata.borgbutler.server.rest
import de.micromata.borgbutler.server.BorgVersion
import de.micromata.borgbutler.server.ServerConfiguration
class ConfigurationInfo(
    var serverConfiguration: ServerConfiguration? = null,
    var borgVersion: BorgVersion? = null
)
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/ConfigurationRest.kt
New file
@@ -0,0 +1,76 @@
package de.micromata.borgbutler.server.rest
import de.micromata.borgbutler.cache.ButlerCache
import de.micromata.borgbutler.config.ConfigurationHandler
import de.micromata.borgbutler.json.JsonUtils
import de.micromata.borgbutler.server.BorgInstallation
import de.micromata.borgbutler.server.ServerConfiguration
import de.micromata.borgbutler.server.user.UserData
import de.micromata.borgbutler.server.user.UserManager
import mu.KotlinLogging
import org.apache.commons.lang3.StringUtils
import org.springframework.web.bind.annotation.*
private val log = KotlinLogging.logger {}
@RestController
@RequestMapping("/rest/configuration")
class ConfigurationRest {
    /**
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @see JsonUtils.toJson
     */
    @GetMapping("config")
    fun getConfig(@RequestParam("prettyPrinter", required = false) prettyPrinter: Boolean?): String {
        val configurationInfo = ConfigurationInfo()
        configurationInfo.serverConfiguration = ServerConfiguration.get()
        configurationInfo.borgVersion = BorgInstallation.getInstance().getBorgVersion()
        return JsonUtils.toJson(configurationInfo, prettyPrinter)
    }
    @PostMapping("config")
    fun setConfig(@RequestBody configurationInfo: ConfigurationInfo) {
        val configurationHandler = ConfigurationHandler.getInstance()
        BorgInstallation.getInstance()
            .configure(configurationInfo.serverConfiguration, configurationInfo.borgVersion?.borgBinary)
        val configuration: ServerConfiguration = ServerConfiguration.get()
        configurationInfo.serverConfiguration?.let {
            configuration.copyFrom(it)
        }
        configurationHandler.save()
    }
    /**
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @see JsonUtils.toJson
     */
    @GetMapping("user")
    fun getUser(@RequestParam("prettyPrinter", required = false) prettyPrinter: Boolean?): String {
        val user: UserData = RestUtils.getUser()
        return JsonUtils.toJson(user, prettyPrinter)
    }
    @PostMapping("user")
    fun setUser(@RequestBody user: UserData) {
        if (user.getLocale() != null && StringUtils.isBlank(user.getLocale().getLanguage())) {
            // Don't set locale with "" as language.
            user.setLocale(null)
        }
        if (StringUtils.isBlank(user.getDateFormat())) {
            // Don't set dateFormat as "".
            user.setDateFormat(null)
        }
        UserManager.instance().saveUser(user)
    }
    /**
     * Resets the settings to default values (deletes all settings).
     */
    @GetMapping("clearAllCaches")
    fun clearAllCaches(): String {
        log.info("Clear all caches called...")
        ButlerCache.getInstance().clearAllCaches()
        return "OK"
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/ExceptionStackTracePrinter.kt
New file
@@ -0,0 +1,72 @@
/////////////////////////////////////////////////////////////////////////////
//
// Project ProjectForge Community Edition
//         www.projectforge.org
//
// Copyright (C) 2001-2021 Micromata GmbH, Germany (www.micromata.com)
//
// ProjectForge is dual-licensed.
//
// This community edition is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License as published
// by the Free Software Foundation; version 3 of the License.
//
// This community edition is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
// Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, see http://www.gnu.org/licenses/.
//
/////////////////////////////////////////////////////////////////////////////
package de.micromata.borgbutler.server.rest
/**
 * Prints stack-trace without foreign packages in much shorter form than log.error(ex.message, ex) does.
 * @author Kai Reinhard (k.reinhard@micromata.de)
 */
object ExceptionStackTracePrinter {
    /**
     * @param showExceptionMessage If true, the exception message itself will be prepended to stack trace.
     * @param stopBeforeForeignPackages If true, after showing stack trace elements of own package 'org.projectforge' any further element is hidden if foreign stack element is reached.
     * @param depth Maximum depth of stack trace elements to show (default is 10).
     * @param showPackagesOnly Only stack elements started with one of these string is classified as own package to print. If nothing given, all 'org.projectforge' classes are
     * classified as own packages.
     */
    @JvmStatic
    @JvmOverloads
    fun toString(ex: Exception, showExceptionMessage: Boolean = true, stopBeforeForeignPackages: Boolean = true, depth: Int = 10, vararg showPackagesOnly: String): String {
        val sb = StringBuilder()
        if (showExceptionMessage) {
            sb.append(ex::class.java.name).append(":").append(ex.message).append("\n")
        }
        var counter = 0
        val showPackages = if (showPackagesOnly.isEmpty()) OWN_PACKAGES else showPackagesOnly
        var placeHolderPrinted = false
        var ownStackelementsPrinted = false
        for (element in ex.stackTrace) {
            if (!showPackages.any { element.className.startsWith(it) }) {
                if (ownStackelementsPrinted && stopBeforeForeignPackages) {
                    sb.append("...following foreign packages are hidden...\n")
                    break
                }
                if (!placeHolderPrinted) {
                    sb.append("...(foreign packages are hidden)...\n")
                    placeHolderPrinted = true
                }
                continue // Don't show foreign class entries.
            }
            ownStackelementsPrinted = true
            sb.append("at ${element.className}.${element.methodName} (${element.fileName}:${element.lineNumber})\n")
            if (++counter >= depth) {
                break
            }
        }
        return sb.toString()
    }
    val OWN_PACKAGES = arrayOf("de.micromata.borgbutler")
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/FilesystemBrowserRest.kt
New file
@@ -0,0 +1,141 @@
package de.micromata.borgbutler.server.rest
import de.micromata.borgbutler.json.JsonUtils
import de.micromata.borgbutler.server.RunningMode
import mu.KotlinLogging
import org.apache.commons.lang3.StringUtils
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.awt.Color
import java.awt.FileDialog
import java.io.File
import javax.servlet.http.HttpServletRequest
import javax.swing.JFileChooser
import javax.swing.JFrame
import javax.swing.JLabel
import javax.swing.SwingConstants
private val log = KotlinLogging.logger {}
@RestController
@RequestMapping("/rest/files")
class FilesystemBrowserRest {
    /**
     * Opens a directory browser or file browser on the desktop app and returns the chosen dir/file. Works only if Browser and Desktop app are running
     * on the same host.
     *
     * @param current The current path of file. If not given the directory/file browser starts with the last used directory or user.home.
     * @return The chosen directory path (absolute path).
     */
    @GetMapping("/browse-local-filesystem")
    fun browseLocalFilesystem(
        request: HttpServletRequest,
        @RequestParam("current", required = false) current: String?
    ): String {
        val msg = RestUtils.checkLocalDesktopAvailable(request)
        if (msg != null) {
            log.info(msg)
            return msg
        }
        if (fileDialog != null || fileChooser != null) {
            log.warn("Cannot call already opened file choose twice. Close file chooser first.")
            return "{\"directory\": \"\"}"
        }
        var file: File? = null
        synchronized(FilesystemBrowserRest::class.java) {
            if (frame == null) {
                val fr = JFrame("BorgButler")
                frame = fr
                fr.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE)
                fr.setSize(300, 100)
                fr.setResizable(false)
                fr.setLocationRelativeTo(null)
                fr.setBackground(Color.WHITE)
                fr.getContentPane().setBackground(Color.WHITE)
                val label = JLabel("Click for choosing directory...", SwingConstants.CENTER)
                fr.add(label)
            }
            if (RunningMode.getOSType() == RunningMode.OSType.MAC_OS) {
                // The JFileChooser will hang after several calls, use AWT file dialog instead for Mac OS:
                System.setProperty("apple.awt.fileDialogForDirectories", "true")
                frame?.let {
                    it.setAlwaysOnTop(true)
                    it.setVisible(true)
                }
                try {
                    val dialog =
                        FileDialog(frame, "Choose a directory", FileDialog.LOAD)
                    fileDialog = dialog
                    if (StringUtils.isNotBlank(current)) {
                        dialog.setDirectory(current)
                    }
                    dialog.toFront()
                    dialog.setVisible(true)
                    val filename: String? = dialog.getFile()
                    val directory: String? = dialog.getDirectory()
                    dialog.setVisible(false)
                    if (filename == null) {
                        return ""
                    }
                    file = File(directory, filename)
                    if (file?.isDirectory != true) {
                        file = File(directory)
                    }
                } finally {
                    fileDialog = null
                }
            } else {
                try {
                    val chooser = if (StringUtils.isNotBlank(current)) {
                        JFileChooser(current)
                    } else {
                        JFileChooser()
                    }
                    fileChooser = chooser
                    chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY)
                    frame?.let {
                        it.setVisible(true)
                        it.setAlwaysOnTop(true)
                    }
                    val returnCode: Int = chooser.showDialog(
                        frame,
                        "Choose"
                    )
                    frame?.let {
                        it.setVisible(false)
                        it.setAlwaysOnTop(false)
                    }
                    if (returnCode == JFileChooser.APPROVE_OPTION) {
                        file = chooser.getSelectedFile()
                    }
                } finally {
                    fileChooser = null
                }
            }
        }
        val filename = if (file != null) JsonUtils.toJson(file!!.absolutePath) else ""
        return "{\"directory\":\"$filename\"}"
    }
    /**
     * @return OK, if the local desktop services such as open file browser etc. are available.
     */
    @GetMapping("/local-fileservices-available")
    fun browseLocalFilesystem(request: HttpServletRequest): String {
        val msg = RestUtils.checkLocalDesktopAvailable(request)
        if (msg != null) {
            log.info(msg)
            return msg
        }
        return "OK"
    }
    companion object {
        private var frame: JFrame? = null
        private var fileDialog: FileDialog? = null
        private var fileChooser: JFileChooser? = null
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/GlobalDefaultExceptionHandling.kt
New file
@@ -0,0 +1,57 @@
/////////////////////////////////////////////////////////////////////////////
//
// Project ProjectForge Community Edition
//         www.projectforge.org
//
// Copyright (C) 2001-2021 Micromata GmbH, Germany (www.micromata.com)
//
// ProjectForge is dual-licensed.
//
// This community edition is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License as published
// by the Free Software Foundation; version 3 of the License.
//
// This community edition is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
// Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, see http://www.gnu.org/licenses/.
//
/////////////////////////////////////////////////////////////////////////////
package de.micromata.borgbutler.server.rest
import mu.KotlinLogging
import org.springframework.core.annotation.AnnotationUtils
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus
import javax.servlet.http.HttpServletRequest
private val log = KotlinLogging.logger {}
@ControllerAdvice
internal class GlobalDefaultExceptionHandler {
    @ExceptionHandler(value = [(Exception::class)])
    @Throws(Exception::class)
    fun defaultErrorHandler(request: HttpServletRequest, ex: Exception): Any {
        // If the exception is annotated with @ResponseStatus rethrow it and let
        // the framework handle it.
        if (AnnotationUtils.findAnnotation(ex.javaClass, ResponseStatus::class.java) != null) {
            throw ex
        }
        log.error(
            "Exception while processing request: ${ex.message} Request: ${RequestLog.asJson(request)},\nexception=${ex.message}\n${
                ExceptionStackTracePrinter.toString(
                    ex,
                    false
                )
            }"
        )
        return ResponseEntity("Internal error.", HttpStatus.BAD_REQUEST)
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/I18nRest.kt
New file
@@ -0,0 +1,42 @@
package de.micromata.borgbutler.server.rest
import de.micromata.borgbutler.json.JsonUtils
import de.micromata.borgbutler.server.I18nClientMessages
import org.apache.commons.lang3.StringUtils
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.util.*
import javax.servlet.http.HttpServletRequest
@RestController
@RequestMapping("/rest/i18n")
class I18nRest {
    /**
     *
     * @param request For detecting the user's client locale.
     * @param locale If not given, the client's language (browser) will be used.
     * @param keysOnly If true, only the keys will be returned. Default is false.
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @see JsonUtils.toJson
     */
    @GetMapping("list")
    fun getList(
        request: HttpServletRequest,
        @RequestParam("prettyPrinter", required = false) prettyPrinter: Boolean?,
        @RequestParam("keysOnly", required = false) keysOnly: Boolean?,
        @RequestParam("locale", required = false) locale: String?
    ): String {
        val localeObject: Locale?
        if (StringUtils.isNotBlank(locale)) {
            localeObject = Locale(locale)
        } else {
            localeObject = RestUtils.getUserLocale(request)
        }
        val translations: Map<String, String> =
            I18nClientMessages.getInstance().getAllMessages(localeObject, keysOnly == true)
        return JsonUtils.toJson(translations, prettyPrinter)
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/JobsRest.kt
New file
@@ -0,0 +1,201 @@
package de.micromata.borgbutler.server.rest
import de.micromata.borgbutler.BorgQueueExecutor
import de.micromata.borgbutler.config.ConfigurationHandler
import de.micromata.borgbutler.jobs.AbstractJob
import de.micromata.borgbutler.json.JsonUtils
import de.micromata.borgbutler.json.borg.ProgressInfo
import de.micromata.borgbutler.server.rest.queue.JsonJob
import de.micromata.borgbutler.server.rest.queue.JsonJobQueue
import mu.KotlinLogging
import org.apache.commons.collections4.CollectionUtils
import org.apache.commons.lang3.StringUtils
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
private val log = KotlinLogging.logger {}
@RestController
@RequestMapping("/rest/jobs")
class JobsRest {
    /**
     * @param repo If given, only the job queue of the given repo will be returned.
     * @param testMode If true, then a test job list is created.
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @return Job queues as json string.
     * @see JsonUtils.toJson
     */
    @GetMapping
    fun getJobs(
        @RequestParam("repo", required = false) repo: String?,
        @RequestParam("testMode", required = false) testMode: Boolean?,
        @RequestParam("oldJobs", required = false) oldJobs: Boolean?,
        @RequestParam("prettyPrinter", required = false) prettyPrinter: Boolean?
    ): String {
        log.debug("getJobs repo=$repo, oldJobs=$oldJobs")
        if (testMode == true) {
            // Return dynamic test queue:
            return returnTestList(oldJobs, prettyPrinter)
        }
        var validRepo = false
        if (StringUtils.isNotBlank(repo) && "null" != repo && "undefined" != repo) {
            validRepo = true
        }
        val borgQueueExecutor = BorgQueueExecutor.getInstance()
        val queueList = mutableListOf<JsonJobQueue>()
        if (validRepo) { // Get only the queue of the given repo:
            val queue = getQueue(repo, oldJobs)
            if (queue != null) {
                queueList.add(queue)
            }
        } else { // Get all the queues (of all repos).
            for (rep in borgQueueExecutor.getRepos()) {
                val queue = getQueue(rep, oldJobs)
                if (queue != null) {
                    queueList.add(queue)
                }
            }
        }
        return JsonUtils.toJson(queueList, prettyPrinter)
    }
    private fun getQueue(repo: String?, oldJobs: Boolean?): JsonJobQueue? {
        val borgQueueExecutor: BorgQueueExecutor = BorgQueueExecutor.getInstance()
        val repoConfig = ConfigurationHandler.getConfiguration().getRepoConfig(repo) ?: return null
        val borgJobList = borgQueueExecutor.getJobListCopy(repoConfig, oldJobs == true)
        if (CollectionUtils.isEmpty(borgJobList)) return null
        val queue: JsonJobQueue = JsonJobQueue().setRepo(repoConfig.getDisplayName())
        queue.setJobs(mutableListOf())
        for (borgJob in borgJobList) {
            val job = JsonJob(borgJob)
            queue.getJobs().add(job)
        }
        return queue
    }
    /**
     * @param uniqueJobNumberString The id of the job to cancel.
     */
    @GetMapping("/cancel")
    fun cancelJob(@RequestParam("uniqueJobNumber") uniqueJobNumberString: String) {
        val uniqueJobNumber =
            try {
                uniqueJobNumberString.toLong()
            } catch (ex: NumberFormatException) {
                log.error("Can't cancel job, because unique job number couln't be parsed (long value expected): $uniqueJobNumberString")
                return
            }
        BorgQueueExecutor.getInstance().cancelJob(uniqueJobNumber)
    }
    /**
     * Only for test purposes and development.
     *
     * @param oldJobs
     * @param prettyPrinter
     * @return
     */
    private fun returnTestList(oldJobs: Boolean?, prettyPrinter: Boolean?): String {
        var list = if (oldJobs == true) oldJobsTestList else testList
        if (list == null) {
            list = mutableListOf()
            var uniqueJobNumber: Long = 100000
            var queue: JsonJobQueue = JsonJobQueue().setRepo("My Computer")
            addTestJob(queue, "info", "my-macbook", 0, 2342, uniqueJobNumber++, oldJobs == true)
            addTestJob(queue, "list", "my-macbook", -1, -1, uniqueJobNumber++, oldJobs == true)
            list.add(queue)
            queue = JsonJobQueue().setRepo("My Server")
            addTestJob(queue, "list", "my-server", 0, 1135821, uniqueJobNumber++, oldJobs == true)
            addTestJob(queue, "info", "my-server", -1, -1, uniqueJobNumber++, oldJobs == true)
            list.add(queue)
            if (oldJobs == true) {
                oldJobsTestList = list
            } else {
                testList = list
            }
        } else if (oldJobs != true) {
            for (jobQueue in list) {
                for (job in jobQueue.getJobs()) {
                    if (job.getStatus() != AbstractJob.Status.RUNNING) continue
                    var current: Long = job.getProgressInfo().getCurrent()
                    val total: Long = job.getProgressInfo().getTotal()
                    current += if (StringUtils.startsWith(job.getProgressInfo().getMessage(), "Calculating")) {
                        // Info is a faster operation:
                        (Math.random() * total / 5).toLong()
                    } else {
                        // than get the complete archive file list:
                        (Math.random() * total / 30).toLong()
                    }
                    if (current > total) {
                        current = 0 // Reset to beginning.
                    }
                    job.getProgressInfo().setCurrent(current)
                    if (job.getProgressText().startsWith("Calculating")) {
                        job.getProgressInfo()
                            .setMessage("Calculating statistics...  " + Math.round((100 * current / total).toFloat()) + "%")
                    }
                    job.buildProgressText()
                }
            }
        }
        return JsonUtils.toJson(list, prettyPrinter)
    }
    /**
     * Only for test purposes and development.
     *
     * @param queue
     * @param operation
     * @param host
     * @param current
     * @param total
     * @return
     */
    private fun addTestJob(
        queue: JsonJobQueue,
        operation: String,
        host: String,
        current: Long,
        total: Long,
        uniqueNumber: Long,
        oldJobs: Boolean
    ): JsonJob {
        val progressInfo = ProgressInfo()
            .setCurrent(current)
            .setTotal(total)
        val job: JsonJob = JsonJob()
            .setProgressInfo(progressInfo)
            .setStatus(AbstractJob.Status.QUEUED)
        if ("info" == operation) {
            progressInfo.setMessage("Calculating statistics... ")
            job.setDescription("Loading info of archive '" + host + "-2018-12-05T23:10:33' of repo '" + queue.getRepo() + "'.")
                .setCommandLineAsString("borg info --json --log-json --progress ssh://...:23/./backups/$host::$host-2018-12-05T23:10:33")
        } else {
            progressInfo.setMessage("Getting file list... ")
            job.setDescription("Loading list of files of archive '" + host + "-2018-12-05T17:30:38' of repo '" + queue.getRepo() + "'.")
                .setCommandLineAsString("borg list --json-lines ssh://...:23/./backups/$host::$host-2018-12-05T17:30:38")
        }
        job.buildProgressText()
        if (current >= 0) {
            job.setStatus(AbstractJob.Status.RUNNING)
        } else {
            job.setStatus(AbstractJob.Status.QUEUED)
        }
        if (queue.getJobs() == null) {
            queue.setJobs(ArrayList<JsonJob>())
        }
        job.setUniqueJobNumber(uniqueNumber)
        if (oldJobs) {
            job.setStatus(if (uniqueNumber % 2 == 0L) AbstractJob.Status.CANCELLED else AbstractJob.Status.DONE)
        }
        queue.getJobs().add(job)
        return job
    }
    companion object {
        private var testList: MutableList<JsonJobQueue>? = null
        private var oldJobsTestList: MutableList<JsonJobQueue>? = null
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/LoggingRest.kt
New file
@@ -0,0 +1,67 @@
package de.micromata.borgbutler.server.rest
import de.micromata.borgbutler.json.JsonUtils
import de.micromata.borgbutler.server.logging.LoggerMemoryAppender
import de.micromata.borgbutler.server.logging.LogFilter
import de.micromata.borgbutler.server.logging.LogLevel
import mu.KotlinLogging
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import javax.servlet.http.HttpServletRequest
private val log = KotlinLogging.logger {}
@RestController
@RequestMapping("/rest/logging")
class LoggingRest {
    /**
     * @param request
     * @param search
     * @param logLevelTreshold fatal, error, warn, info, debug or trace (case insensitive).
     * @param maxSize          Max size of the result list.
     * @param ascendingOrder   Default is false (default is descending order).
     * @param lastReceivedOrderNumber The last received order number for updating log entries (preventing querying all entries again).
     * @param prettyPrinter
     * @return
     */
    @GetMapping("query")
    fun query(
        request: HttpServletRequest?,
        @RequestParam("search", required = false) search: String?,
        @RequestParam("treshold", required = false) logLevelTreshold: String?,
        @RequestParam("maxSize", required = false) maxSize: Int?,
        @RequestParam("ascendingOrder", required = false) ascendingOrder: Boolean?,
        @RequestParam("lastReceivedOrderNumber", required = false) lastReceivedOrderNumber: Int?,
        @RequestParam("prettyPrinter", required = false) prettyPrinter: Boolean?
    ): String {
        val filter = LogFilter()
        filter.setSearch(search)
        if (logLevelTreshold != null) {
            try {
                val treshold =
                    LogLevel.valueOf(logLevelTreshold.trim { it <= ' ' }
                        .toUpperCase())
                filter.setThreshold(treshold)
            } catch (ex: IllegalArgumentException) {
                log.error("Can't parse log level treshold: " + logLevelTreshold + ". Supported values (case insensitive): " + LogLevel.getSupportedValues())
            }
        }
        if (filter.getThreshold() == null) {
            filter.setThreshold(LogLevel.INFO)
        }
        if (maxSize != null) {
            filter.setMaxSize(maxSize)
        }
        if (ascendingOrder != null && ascendingOrder == true) {
            filter.setAscendingOrder(true)
        }
        if (lastReceivedOrderNumber != null) {
            filter.setLastReceivedLogOrderNumber(lastReceivedOrderNumber)
        }
        val loggerMemoryAppender = LoggerMemoryAppender.getInstance()
        return JsonUtils.toJson(loggerMemoryAppender.query(filter, RestUtils.getUserLocale(request!!)), prettyPrinter)
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/ReposRest.kt
New file
@@ -0,0 +1,66 @@
package de.micromata.borgbutler.server.rest
import de.micromata.borgbutler.cache.ButlerCache
import de.micromata.borgbutler.data.Repository
import de.micromata.borgbutler.json.JsonUtils
import mu.KotlinLogging
import org.apache.commons.collections4.CollectionUtils
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
private val log = KotlinLogging.logger {}
@RestController
@RequestMapping("/rest/repos")
class ReposRest {
    /**
     *
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @return A list of repositories of type [BorgRepository].
     * @see JsonUtils.toJson
     */
    @GetMapping("list")
    fun getList(@RequestParam("prettyPrinter", required = false) prettyPrinter: Boolean?): String {
        val repositories: List<Repository?> = ButlerCache.getInstance().getAllRepositories()
        return if (CollectionUtils.isEmpty(repositories)) {
            "[]"
        } else JsonUtils.toJson(repositories, prettyPrinter)
    }
    /**
     *
     * @param id id or name of repo.
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @return [Repository] (without list of archives) as json string.
     * @see JsonUtils.toJson
     */
    @GetMapping("repo")
    fun getRepo(@RequestParam("id") id: String,
                @RequestParam("prettyPrinter", required = false) prettyPrinter: Boolean?): String {
        val repository: Repository = ButlerCache.getInstance().getRepository(id)
        return JsonUtils.toJson(repository, prettyPrinter)
    }
    /**
     *
     * @param id id or name of repo.
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @return [Repository] (including list of archives) as json string.
     * @see JsonUtils.toJson
     */
    @GetMapping("repoArchiveList")
    fun getRepoArchiveList(
        @RequestParam("id") id: String,
        @RequestParam("force", required = false) force: Boolean?,
        @RequestParam("prettyPrinter", required = false) prettyPrinter: Boolean?
    ): String {
        if (force == true) {
            val repo: Repository = ButlerCache.getInstance().getRepository(id)
            ButlerCache.getInstance().clearRepoCacheAccess(repo)
        }
        val repository: Repository = ButlerCache.getInstance().getRepositoryArchives(id)
        return JsonUtils.toJson(repository, prettyPrinter)
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/RequestLog.kt
New file
@@ -0,0 +1,138 @@
/////////////////////////////////////////////////////////////////////////////
//
// Project ProjectForge Community Edition
//         www.projectforge.org
//
// Copyright (C) 2001-2021 Micromata GmbH, Germany (www.micromata.com)
//
// ProjectForge is dual-licensed.
//
// This community edition is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License as published
// by the Free Software Foundation; version 3 of the License.
//
// This community edition is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
// Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, see http://www.gnu.org/licenses/.
//
/////////////////////////////////////////////////////////////////////////////
package de.micromata.borgbutler.server.rest
import de.micromata.borgbutler.json.JsonUtils
import java.security.Principal
import java.util.*
import javax.servlet.http.Cookie
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.Part
/**
 * Helper class for debugging requests. Converts a given request to json.
 */
object RequestLog {
    @JvmStatic
    fun asJson(request: HttpServletRequest, longForm: Boolean = false): String {
        val data = RequestData(request, longForm)
        return JsonUtils.toJson(data)
    }
    @JvmStatic
    fun asString(request: HttpServletRequest): String {
        return request.requestURI
    }
}
class RequestData(request: HttpServletRequest, longForm: Boolean = false) {
    val attributes = mutableMapOf<String, Any?>()
    val parameters = mutableMapOf<String, String?>()
    val headers = mutableMapOf<String, String?>()
    var locales: MutableList<Locale>? = null
    //val parts = mutableListOf<PartInfo>()
    val authType: String? = request.authType
    val characterEncoding: String? = request.characterEncoding
    val contentLength: Int? = if (longForm) request.contentLength else null
    val contentType: String? = if (longForm) request.contentType else null
    val contextPath: String? = if (longForm) request.contextPath else null
    val cookies: Array<Cookie>? = if (longForm) request.cookies else null
    val isAsyncStarted: Boolean? = if (longForm) request.isAsyncStarted else null
    val isRequestedSessionIdFromCookie: Boolean? = if (longForm) request.isRequestedSessionIdFromCookie else null
    val isRequestedSessionIdFromURL: Boolean? = if (longForm) request.isRequestedSessionIdFromURL else null
    val isRequestedSessionIdValid: Boolean? = if (longForm) request.isRequestedSessionIdValid else null
    val isSecure: Boolean? = if (longForm) request.isSecure else null
    val isTrailerFieldsReady: Boolean? = if (longForm) request.isTrailerFieldsReady else null
    val localAddr: String? = if (longForm) request.localAddr else null
    val localName: String? = if (longForm) request.localName else null
    val localPort: Int? = if (longForm) request.localPort else null
    val locale: Locale? = if (longForm) request.locale else null
    val method: String? = request.method
    val pathInfo: String? = if (longForm) request.pathInfo else null
    val pathTranslated: String? = if (longForm) request.pathTranslated else null
    val protocol: String? = if (longForm) request.protocol else null
    val queryString: String? = request.queryString
    val remoteAddr: String? = request.remoteAddr
    val remoteHost: String? = if (longForm) request.remoteHost else null
    val remotePort: Int? = if (longForm) request.remotePort else null
    val remoteUser: String? = request.remoteUser
    val requestedSessionId: String? = if (longForm) request.requestedSessionId else null
    val requestUri: String? = request.requestURI
    val scheme: String? = if (longForm) request.scheme else null
    val serverName = if (longForm) request.serverName else null
    val serverPort: Int? = if (longForm) request.serverPort else null
    val servletPath: String? = if (longForm) request.servletPath else null
    val sessionId: String? = request.session?.id
    val userPrincipal: Principal? = request.userPrincipal
    init {
        for (attribute in request.attributeNames) {
            attributes[attribute] = handleSecret(request, attribute, request.getAttribute(attribute)?.toString())
        }
        for (header in request.headerNames) {
            headers[header] = handleSecret(request, header, request.getHeader(header)) as String
        }
        if (longForm) {
            locales = mutableListOf()
            locales?.let {
                for (locale in request.locales) {
                    it.add(locale)
                }
            }
        }
        for (parameter in request.parameterNames) {
            parameters[parameter] = handleSecret(request, parameter, request.getParameter(parameter)) as String
        }
        /*
        for (part in request.parts) {
            parts.add(PartInfo(part))
        }*/
    }
    private fun <T> handleSecret(request: HttpServletRequest, name: String?, value: T?): Any? {
        name ?: return null
        value ?: return null
        if (name.toLowerCase() == "authorization") {
            return "authorization=******"
        }
        return value
    }
}
class PartInfo(part: Part) {
    val headers = mutableMapOf<String, String>()
    // Can't get inputstream
    val contentType: String? = part.contentType
    val name: String? = part.name
    val size = part.size
    init {
        for (header in part.headerNames) {
            headers[header] = part.getHeader(header)
        }
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/RestUtils.kt
New file
@@ -0,0 +1,112 @@
package de.micromata.borgbutler.server.rest
import de.micromata.borgbutler.server.RunningMode
import de.micromata.borgbutler.server.user.UserData
import de.micromata.borgbutler.server.user.UserUtils
import org.slf4j.Logger
import org.springframework.core.io.ByteArrayResource
import org.springframework.core.io.InputStreamResource
import org.springframework.core.io.Resource
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import java.io.InputStream
import java.net.InetAddress
import java.net.UnknownHostException
import java.util.*
import javax.servlet.ServletRequest
import javax.servlet.http.HttpServletRequest
object RestUtils {
    /**
     * @return null, if the local app (JavaFX) is running and the request is from localhost. Otherwise message, why local
     * service isn't available.
     */
    fun checkLocalDesktopAvailable(requestContext: HttpServletRequest): String? {
        if (RunningMode.getServerType() != RunningMode.ServerType.DESKTOP) {
            return "Service unavailable. No desktop app on localhost available."
        }
        val remoteAddr = requestContext.remoteAddr
        return if (remoteAddr == null || remoteAddr != "127.0.0.1") {
            "Service not available. Can't call this service remote. Run this service on localhost of the running desktop app."
        } else null
    }
    /**
     * @return Returns the user put by the UserFilter.
     * @see UserUtils.getUser
     * @see de.micromata.borgbutler.server.user.UserFilter
     */
    fun getUser(): UserData {
        val user = UserUtils.getUser() ?: throw IllegalStateException("No user given in rest call.")
        return UserUtils.getUser()
    }
    fun getUserLocale(requestContext: HttpServletRequest): Locale? {
        val user = getUser()
        var locale = user.locale
        if (locale == null) {
            locale = requestContext.locale
        }
        return locale
    }
    @JvmStatic
    fun getClientIp(request: ServletRequest): String? {
        var remoteAddr: String? = null
        if (request is HttpServletRequest) {
            remoteAddr = request.getHeader("X-Forwarded-For")
        }
        if (remoteAddr != null) {
            if (remoteAddr.contains(",")) {
                // sometimes the header is of form client ip,proxy 1 ip,proxy 2 ip,...,proxy n ip,
                // we just want the client
                remoteAddr = remoteAddr.split(',')[0].trim({ it <= ' ' })
            }
            try {
                // If ip4/6 address string handed over, simply does pattern validation.
                InetAddress.getByName(remoteAddr)
            } catch (e: UnknownHostException) {
                remoteAddr = request.remoteAddr
            }
        } else {
            remoteAddr = request.remoteAddr
        }
        return remoteAddr
    }
    fun downloadFile(filename: String, inputStream: InputStream): ResponseEntity<InputStreamResource> {
        return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType("application/octet-stream"))
            .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"${filename.replace('"', '_')}\"")
            .body(InputStreamResource(inputStream))
    }
    fun downloadFile(filename: String, content: String): ResponseEntity<String> {
        return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType("application/octet-stream"))
            .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"${filename.replace('"', '_')}\"")
            .body(content)
    }
    fun downloadFile(filename: String, resource: ByteArrayResource): ResponseEntity<Resource> {
        return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType("application/octet-stream"))
            .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"${filename.replace('"', '_')}\"")
            .body(resource)
    }
    fun badRequest(message: String): ResponseEntity<String> {
        return ResponseEntity.badRequest().body(message)
    }
    fun notFound(): ResponseEntity<String> {
        return ResponseEntity.badRequest().body("Not found.")
    }
    fun notFound(log: Logger, errorMessage: String?): ResponseEntity<String> {
        log.error(errorMessage)
        return ResponseEntity.badRequest().body(errorMessage)
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/SystemInfo.kt
New file
@@ -0,0 +1,33 @@
package de.micromata.borgbutler.server.rest
import de.micromata.borgbutler.BorgQueueStatistics
import de.micromata.borgbutler.server.BorgVersion
/**
 * Statistics of all the job queues, especially the number of total queued and running jobs.
 * This is used e. g. by the client for showing a badge near to the menu entry "job monitor" with the number
 * of Jobs in the queues.
 */
class SystemInfo {
    var queueStatistics: BorgQueueStatistics? = null
        private set
    var isConfigurationOK = false
        private set
    var borgVersion: BorgVersion? = null
        private set
    fun setQueueStatistics(queueStatistics: BorgQueueStatistics?): SystemInfo {
        this.queueStatistics = queueStatistics
        return this
    }
    fun setConfigurationOK(configurationOK: Boolean): SystemInfo {
        isConfigurationOK = configurationOK
        return this
    }
    fun setBorgVersion(borgVersion: BorgVersion?): SystemInfo {
        this.borgVersion = borgVersion
        return this
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/SystemInfoRest.kt
New file
@@ -0,0 +1,26 @@
package de.micromata.borgbutler.server.rest
import de.micromata.borgbutler.BorgQueueExecutor
import de.micromata.borgbutler.json.JsonUtils
import de.micromata.borgbutler.server.BorgInstallation
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/rest/system")
class SystemInfoRest {
    /**
     * @return The total number of jobs queued or running (and other statistics): [de.micromata.borgbutler.BorgQueueStatistics].
     * @see JsonUtils.toJson
     */
    @GetMapping("info")
    fun statistics(): SystemInfo {
        val borgVersion = BorgInstallation.getInstance().getBorgVersion()
        val systemInfonfo = SystemInfo()
            .setQueueStatistics(BorgQueueExecutor.getInstance().getStatistics())
            .setConfigurationOK(borgVersion.isVersionOK())
            .setBorgVersion(borgVersion)
        return systemInfonfo
    }
}
borgbutler-server/src/main/kotlin/de/micromata/borgbutler/server/rest/VersionRest.kt
New file
@@ -0,0 +1,66 @@
package de.micromata.borgbutler.server.rest
import de.micromata.borgbutler.json.JsonUtils
import de.micromata.borgbutler.server.Languages
import de.micromata.borgbutler.server.Version
import org.apache.commons.lang3.StringUtils
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.util.*
import javax.servlet.http.HttpServletRequest
@RestController
@RequestMapping("/rest")
class VersionRest {
    /**
     *
     * @param request For detecting the user's client locale.
     * @param prettyPrinter If true then the json output will be in pretty format.
     * @see JsonUtils.toJson
     */
    @GetMapping("version")
    fun getVersion(
        request: HttpServletRequest,
        @RequestParam("prettyPrinter", required = false) prettyPrinter: Boolean?
    ): String {
        val user = RestUtils.getUser()
        var language = Languages.asString(user.getLocale())
        if (StringUtils.isBlank(language)) {
            val locale: Locale = request.locale
            language = locale.getLanguage()
        }
        val version = MyVersion(language, RestUtils.checkLocalDesktopAvailable(request) == null)
        return JsonUtils.toJson(version, prettyPrinter)
    }
    inner class MyVersion(language: String, localDesktopAvailable: Boolean) {
        private val version: Version
        val language: String
        val isLocalDesktopAvailable: Boolean
        val appName: String
            get() = version.appName
        fun getVersion(): String {
            return version.version
        }
        val buildDateUTC: String
            get() = version.buildDateUTC
        val buildDate: Date
            get() = version.buildDate
        /**
         * @return Version of the available update, if exist. Otherwise null.
         */
        val updateVersion: String?
            get() = version.updateVersion
        init {
            version = Version.getInstance()
            this.language = language
            isLocalDesktopAvailable = localDesktopAvailable
        }
    }
}
borgbutler-server/src/main/resources/application.properties
New file
@@ -0,0 +1,2 @@
server.address=127.0.0.1
server.port=9042
borgbutler-server/src/main/resources/log4j.properties
File was deleted
borgbutler-server/src/main/resources/logback-spring.xml
New file
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <property name="LOG_HOME" value="${borgbutlerHome:-${LOG_PATH:-${LOG_TEMP:-${java.io.tmpdir:-/tmp}}/}}"/>
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
    <appender name="ROLLING-FILE-ALL"
              class="ch.qos.logback.core.rolling.RollingFileAppender">
        <encoder>
            <pattern>%d{MM-dd-yy HH:mm:ss} %-5level %mdc %logger{60}::%M:%line -
                %msg%n
            </pattern>
        </encoder>
        <file>${LOG_HOME}borgbutler.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
            <fileNamePattern>${LOG_HOME}/borgbutler.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <minIndex>1</minIndex>
            <maxIndex>10</maxIndex>
        </rollingPolicy>
        <triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
            <maxFileSize>10MB</maxFileSize>
        </triggeringPolicy>
    </appender>
    <appender name="ROLLING-MEMORY" class="de.micromata.borgbutler.server.logging.LoggerMemoryAppender" />
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="ROLLING-FILE-ALL"/>
        <appender-ref ref="ROLLING-MEMORY"/>
    </root>
    <!-- custom logging levels -->
    <!--logger name="de.micromata.merlin.excel" level="DEBUG" /-->
</configuration>
borgbutler-webapp/src/components/views/archives/ArchiveView.jsx
@@ -25,7 +25,7 @@
        fetch(getRestServiceUrl('archives', {
            repo: this.state.repoId,
            archiveId: this.state.archiveId,
            force: force
            force: force === true
        }), {
            method: 'GET',
            headers: {
borgbutler-webapp/src/components/views/archives/FileListPanel.jsx
@@ -137,7 +137,7 @@
        fetch(getRestServiceUrl('archives/filelist', {
            archiveId: this.props.archive.id,
            diffArchiveId: this.state.filter.diffArchiveId,
            force: force,
            force: force === true,
            searchString: this.state.filter.search,
            mode: this.state.filter.mode,
            currentDirectory: this.state.filter.currentDirectory,
borgbutler-webapp/src/components/views/repos/RepoArchiveListView.jsx
@@ -31,7 +31,7 @@
        });
        fetch(getRestServiceUrl('repos/repoArchiveList', {
            id: this.state.id,
            force: force
            force: force === true,
        }), {
            method: 'GET',
            headers: {
build.gradle
@@ -6,15 +6,20 @@
 * user guide available at https://docs.gradle.org/5.0/userguide/tutorial_java_projects.html
 */
plugins {
    id "org.jetbrains.kotlin.jvm" version "1.4.32" apply false
}
allprojects {
    apply plugin: 'maven'
    group = 'de.micromata.borgbutler'
    version = '0.4-SNAPSHOT'
    version = '0.5-SNAPSHOT'
}
subprojects {
    apply plugin: 'java'
    apply plugin: 'org.jetbrains.kotlin.jvm'
    sourceCompatibility = 1.9 // Needed: since 1.9 i18n properties in UTF-8 format.
    targetCompatibility = 1.9
@@ -31,8 +36,6 @@
    }
    dependencies {
        compile group: 'org.slf4j', name: 'slf4j-api', version: '1.7.25'
        testCompile group: 'org.slf4j', name: 'slf4j-log4j12', version: '1.7.25'
        testImplementation(
                'org.junit.jupiter:junit-jupiter-api:5.3.0'
        )
@@ -42,10 +45,6 @@
        )
    }
    subprojects {
        ext.depVersions = ['commonsio': '2.8.0', dep2: '2.0']
    }
    test {
        useJUnitPlatform()
    }
doc/Development.adoc
@@ -28,7 +28,7 @@
1. `cd borgbutler-webapp`
2. `npm install`
3. `gradle npmBuild` (builds the web archive)
4. Start `de.micromata.borgbutler.server.Main`
4. Start `BorgButlerApplication`
=== Start borgbutler-server for web development
For using hot code replacement of your web files, you should use `npm start` or `yarn start`:
@@ -36,7 +36,7 @@
1. `cd borgbutler-webapp`
2. `npm install`
3. `npm start` (opens the web browser on port 3000)
4. Start `de.micromata.borgbutler.server.Main` (ignore the opened browser window for port 9042)
4. Start `BorgButlerApplication` (ignore the opened browser window for port 9042)
=== Profiling heap, cpu and everything using JProfiler
JProfiler is an excellent tool for analysing your software. BorgButler was optimized regarding heap memory and CPU usage by