From ecdc8563ba922abe006bbc6582b9c80f4815d0d9 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Fri, 8 Oct 2021 10:13:36 +0200 Subject: [PATCH 01/92] Error reporting if bot added with wrong scope --- .../tjbot/commands/system/CommandSystem.java | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/system/CommandSystem.java b/application/src/main/java/org/togetherjava/tjbot/commands/system/CommandSystem.java index 4a5a0db0d7..de122d64a6 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/system/CommandSystem.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/system/CommandSystem.java @@ -1,19 +1,24 @@ package org.togetherjava.tjbot.commands.system; import com.fasterxml.jackson.core.JsonProcessingException; +import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.TextChannel; import net.dv8tion.jda.api.events.ReadyEvent; import net.dv8tion.jda.api.events.interaction.ButtonClickEvent; import net.dv8tion.jda.api.events.interaction.SelectionMenuEvent; import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.exceptions.ErrorHandler; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.interactions.commands.Command; import net.dv8tion.jda.api.interactions.components.ComponentInteraction; +import net.dv8tion.jda.api.requests.ErrorResponse; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.commands.Commands; import org.togetherjava.tjbot.commands.SlashCommand; +import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.db.Database; import java.util.*; @@ -116,7 +121,7 @@ private void registerReloadCommand(@NotNull Guild guild) { guild.upsertCommand(reloadCommand.getData()) .queue(command -> logger.debug("Registered '{}' for guild '{}'", RELOAD_COMMAND, guild.getName())); - }); + }, ex -> handleRegisterErrors(ex, guild)); } /** @@ -168,6 +173,36 @@ private void forwardComponentCommand(@NotNull T return Objects.requireNonNull(nameToSlashCommands.get(name)); } + private static void handleRegisterErrors(Throwable ex, Guild guild) { + new ErrorHandler().handle(ErrorResponse.MISSING_ACCESS, errorResponse -> { + // Find a channel that we have permissions to write to + // NOTE Unfortunately, there is no better accurate way to find a proper channel + // where we can report the setup problems other than simply iterating all of them. + Optional channelToReportTo = guild.getTextChannelCache() + .stream() + .filter(channel -> guild.getPublicRole() + .hasPermission(channel, Permission.MESSAGE_WRITE)) + .findAny(); + + // Report the problem to the guild + Config config = Config.getInstance(); + channelToReportTo.ifPresent(textChannel -> textChannel + .sendMessage("I need the commands scope, please invite me correctly." + + " You can join '%s' or visit '%s' for more info, I will leave your guild now." + .formatted(config.getDiscordGuildInvite(), config.getProjectWebsite())) + .queue()); + + guild.leave().queue(); + + String unableToReportText = channelToReportTo.isPresent() ? "" + : " Did not find any public text channel to report the issue to, unable to inform the guild."; + logger.warn( + "Guild '{}' does not have the required command scope, unable to register, leaving it.{}", + guild.getName(), unableToReportText, ex); + }).accept(ex); + + } + /** * Extension of {@link java.util.function.BiConsumer} but for 3 elements. *

From b4e971e80bd9ad0bb059ebf7cb84a574b2f5c7e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Oct 2021 16:29:12 +0000 Subject: [PATCH 02/92] Bump JDA from 4.3.0_331 to 4.3.0_333 Bumps JDA from 4.3.0_331 to 4.3.0_333. --- updated-dependencies: - dependency-name: net.dv8tion:JDA dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- application/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/build.gradle b/application/build.gradle index cb3aaaad35..7f55176ee0 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -40,7 +40,7 @@ shadowJar { dependencies { implementation project(':database') - implementation 'net.dv8tion:JDA:4.3.0_331' + implementation 'net.dv8tion:JDA:4.3.0_333' implementation 'org.apache.logging.log4j:log4j-api:2.14.1' implementation 'org.apache.logging.log4j:log4j-core:2.14.1' From e71af63f10fdce165b85e5b60dd4cbe5fe1edef0 Mon Sep 17 00:00:00 2001 From: Daniel Tischner Date: Fri, 8 Oct 2021 13:01:21 +0200 Subject: [PATCH 03/92] Multithreading for command system --- .../togetherjava/tjbot/commands/SlashCommand.java | 12 ++++++++++++ .../tjbot/commands/system/CommandSystem.java | 14 ++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommand.java index 6aca0e5bc3..0960b223e7 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommand.java @@ -92,6 +92,10 @@ public interface SlashCommand { * Triggered by the command system when a slash command corresponding to this implementation * (based on {@link #getData()} has been triggered. *

+ * This method may be called multi-threaded. In particular, there are no guarantees that it will + * be executed on the same thread repeatedly or on the same thread that other event methods have + * been called on. + *

* Details are available in the given event and the event also enables implementations to * respond to it. *

@@ -134,6 +138,10 @@ public interface SlashCommand { * Triggered by the command system when a button corresponding to this implementation (based on * {@link #getData()} has been clicked. *

+ * This method may be called multi-threaded. In particular, there are no guarantees that it will + * be executed on the same thread repeatedly or on the same thread that other event methods have + * been called on. + *

* Details are available in the given event and the event also enables implementations to * respond to it. *

@@ -150,6 +158,10 @@ public interface SlashCommand { * Triggered by the command system when a selection menu corresponding to this implementation * (based on {@link #getData()} has been clicked. *

+ * This method may be called multi-threaded. In particular, there are no guarantees that it will + * be executed on the same thread repeatedly or on the same thread that other event methods have + * been called on. + *

* Details are available in the given event and the event also enables implementations to * respond to it. *

diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/system/CommandSystem.java b/application/src/main/java/org/togetherjava/tjbot/commands/system/CommandSystem.java index de122d64a6..fd60e4c283 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/system/CommandSystem.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/system/CommandSystem.java @@ -22,6 +22,8 @@ import org.togetherjava.tjbot.db.Database; import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.function.Function; import java.util.stream.Collectors; @@ -40,6 +42,7 @@ public final class CommandSystem extends ListenerAdapter implements SlashCommandProvider { private static final Logger logger = LoggerFactory.getLogger(CommandSystem.class); private static final String RELOAD_COMMAND = "reload"; + private static final ExecutorService COMMAND_SERVICE = Executors.newCachedThreadPool(); private final Map nameToSlashCommands; /** @@ -80,7 +83,9 @@ public CommandSystem(@NotNull Database database) { public void onReady(@NotNull ReadyEvent event) { // Register reload on all guilds logger.debug("JDA is ready, registering reload command"); - event.getJDA().getGuildCache().forEach(this::registerReloadCommand); + event.getJDA() + .getGuildCache() + .forEach(guild -> COMMAND_SERVICE.execute(() -> registerReloadCommand(guild))); // NOTE We do not have to wait for reload to complete for the command system to be ready // itself logger.debug("Command system is now ready"); @@ -90,21 +95,22 @@ public void onReady(@NotNull ReadyEvent event) { public void onSlashCommand(@NotNull SlashCommandEvent event) { logger.debug("Received slash command '{}' (#{}) on guild '{}'", event.getName(), event.getId(), event.getGuild()); - requireSlashCommand(event.getName()).onSlashCommand(event); + COMMAND_SERVICE.execute(() -> requireSlashCommand(event.getName()).onSlashCommand(event)); } @Override public void onButtonClick(@NotNull ButtonClickEvent event) { logger.debug("Received button click '{}' (#{}) on guild '{}'", event.getComponentId(), event.getId(), event.getGuild()); - forwardComponentCommand(event, SlashCommand::onButtonClick); + COMMAND_SERVICE.execute(() -> forwardComponentCommand(event, SlashCommand::onButtonClick)); } @Override public void onSelectionMenu(@NotNull SelectionMenuEvent event) { logger.debug("Received selection menu event '{}' (#{}) on guild '{}'", event.getComponentId(), event.getId(), event.getGuild()); - forwardComponentCommand(event, SlashCommand::onSelectionMenu); + COMMAND_SERVICE + .execute(() -> forwardComponentCommand(event, SlashCommand::onSelectionMenu)); } private void registerReloadCommand(@NotNull Guild guild) { From ddf6875560374e2667c8ffb76de90bedb1b98d23 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Oct 2021 00:45:12 +0000 Subject: [PATCH 04/92] Bump jackson-dataformat-csv from 2.12.5 to 2.13.0 Bumps [jackson-dataformat-csv](https://github.com/FasterXML/jackson-dataformats-text) from 2.12.5 to 2.13.0. - [Release notes](https://github.com/FasterXML/jackson-dataformats-text/releases) - [Commits](https://github.com/FasterXML/jackson-dataformats-text/compare/jackson-dataformats-text-2.12.5...jackson-dataformats-text-2.13.0) --- updated-dependencies: - dependency-name: com.fasterxml.jackson.dataformat:jackson-dataformat-csv dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- application/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/build.gradle b/application/build.gradle index 7f55176ee0..b53c2af629 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -48,7 +48,7 @@ dependencies { implementation 'org.jooq:jooq:3.15.3' - implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.12.5' + implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.13.0' implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.0' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1' From 509d47e58bda2e107b62f31eb58cd2dcd3082b45 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Oct 2021 01:17:20 +0000 Subject: [PATCH 05/92] Bump com.diffplug.spotless from 5.15.2 to 5.16.0 Bumps com.diffplug.spotless from 5.15.2 to 5.16.0. --- updated-dependencies: - dependency-name: com.diffplug.spotless dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 27aa57ef00..b52d9b9ada 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id "com.diffplug.spotless" version "5.15.2" + id "com.diffplug.spotless" version "5.16.0" id "org.sonarqube" version "3.3" id "name.remal.sonarlint" version "1.5.0" } From ebf44fbb0c0410524f1b9ff8c3d4dfaa0d0746fe Mon Sep 17 00:00:00 2001 From: krankkk <59603675+krankkkk@users.noreply.github.com> Date: Mon, 11 Oct 2021 09:44:39 +0200 Subject: [PATCH 06/92] Added Logviewer project * Add Logviewer project * Remove JPA and Hibernate, switch to Database-project * Apply suggestions from code review --- application/build.gradle | 4 + .../resources/db/V1__Database_Listener.sql | 0 application/src/main/resources/log4j2.xml | 15 +- buildSrc/build.gradle | 12 ++ .../src/main/groovy/database-settings.gradle | 72 +++++++ database/build.gradle | 63 +----- docker-compose.yml.example | 20 ++ logviewer/build.gradle | 95 ++++++++ .../frontend/themes/myapp/main-layout.css | 36 ++++ logviewer/frontend/themes/myapp/styles.css | 3 + logviewer/frontend/themes/myapp/theme.json | 1 + .../themes/myapp/views/hello-world-view.css | 4 + .../togetherjava/logwatcher/Application.java | 46 ++++ .../logwatcher/DatabaseProvider.java | 63 ++++++ .../accesscontrol/AllowedRoles.java | 22 ++ .../logwatcher/accesscontrol/Role.java | 53 +++++ .../logwatcher/config/Config.java | 122 +++++++++++ .../constants/LogEventsConstants.java | 24 +++ .../logwatcher/constants/UserConstants.java | 13 ++ .../logwatcher/entities/InstantWrapper.java | 68 ++++++ .../logwatcher/entities/LogEvent.java | 131 +++++++++++ .../logwatcher/entities/UserWrapper.java | 72 +++++++ .../togetherjava/logwatcher/logs/LogREST.java | 37 ++++ .../logwatcher/logs/LogRepository.java | 22 ++ .../logwatcher/logs/LogRepositoryImpl.java | 61 ++++++ .../logwatcher/oauth/OAuth2LoginConfig.java | 91 ++++++++ .../logwatcher/users/AuthenticatedUser.java | 123 +++++++++++ .../users/UserDetailsServiceImpl.java | 65 ++++++ .../logwatcher/users/UserRepository.java | 71 ++++++ .../logwatcher/users/UserRepositoryImpl.java | 108 ++++++++++ .../logwatcher/util/LogReader.java | 51 +++++ .../logwatcher/util/NotificationUtils.java | 30 +++ .../logwatcher/views/MainLayout.java | 172 +++++++++++++++ .../logwatcher/views/logs/LogsView.java | 125 +++++++++++ .../logwatcher/views/logs/StreamedView.java | 153 +++++++++++++ .../views/usermanagement/UserManagement.java | 203 ++++++++++++++++++ .../logwatcher/watcher/StreamWatcher.java | 54 +++++ .../META-INF/resources/icons/icon.png | Bin 0 -> 15994 bytes .../src/main/resources/application.properties | 7 + .../src/main/resources/db/V1__Logviewer.sql | 32 +++ logviewer/src/main/resources/log4j2.xml | 23 ++ settings.gradle | 2 + 42 files changed, 2305 insertions(+), 64 deletions(-) rename {database => application}/src/main/resources/db/V1__Database_Listener.sql (100%) create mode 100644 buildSrc/build.gradle create mode 100644 buildSrc/src/main/groovy/database-settings.gradle create mode 100644 docker-compose.yml.example create mode 100644 logviewer/build.gradle create mode 100644 logviewer/frontend/themes/myapp/main-layout.css create mode 100644 logviewer/frontend/themes/myapp/styles.css create mode 100644 logviewer/frontend/themes/myapp/theme.json create mode 100644 logviewer/frontend/themes/myapp/views/hello-world-view.css create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/Application.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/DatabaseProvider.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/accesscontrol/AllowedRoles.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/accesscontrol/Role.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/config/Config.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/constants/LogEventsConstants.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/constants/UserConstants.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/entities/InstantWrapper.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/entities/LogEvent.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/entities/UserWrapper.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/logs/LogREST.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/logs/LogRepository.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/logs/LogRepositoryImpl.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/oauth/OAuth2LoginConfig.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/users/AuthenticatedUser.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/users/UserDetailsServiceImpl.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/users/UserRepository.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/users/UserRepositoryImpl.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/util/LogReader.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/util/NotificationUtils.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/views/MainLayout.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/views/logs/LogsView.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/views/logs/StreamedView.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/views/usermanagement/UserManagement.java create mode 100644 logviewer/src/main/java/org/togetherjava/logwatcher/watcher/StreamWatcher.java create mode 100644 logviewer/src/main/resources/META-INF/resources/icons/icon.png create mode 100644 logviewer/src/main/resources/application.properties create mode 100644 logviewer/src/main/resources/db/V1__Logviewer.sql create mode 100644 logviewer/src/main/resources/log4j2.xml diff --git a/application/build.gradle b/application/build.gradle index b53c2af629..f4749ad94e 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -8,6 +8,7 @@ plugins { id 'application' id 'com.google.cloud.tools.jib' version '3.1.4' id 'com.github.johnrengelman.shadow' version '7.1.0' + id 'database-settings' } repositories { @@ -29,6 +30,9 @@ jib { password = System.getenv('REGISTRY_PASSWORD') ?: '' } } + container{ + setCreationTime(java.time.Instant.now().toString()) + } } shadowJar { diff --git a/database/src/main/resources/db/V1__Database_Listener.sql b/application/src/main/resources/db/V1__Database_Listener.sql similarity index 100% rename from database/src/main/resources/db/V1__Database_Listener.sql rename to application/src/main/resources/db/V1__Database_Listener.sql diff --git a/application/src/main/resources/log4j2.xml b/application/src/main/resources/log4j2.xml index c5ab4854d4..d82af3842e 100644 --- a/application/src/main/resources/log4j2.xml +++ b/application/src/main/resources/log4j2.xml @@ -4,18 +4,27 @@ - - + + + + + + + + - \ No newline at end of file + diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle new file mode 100644 index 0000000000..68007940e1 --- /dev/null +++ b/buildSrc/build.gradle @@ -0,0 +1,12 @@ +plugins { + id 'groovy-gradle-plugin' +} + +repositories { + gradlePluginPortal() // so that external plugins can be resolved in dependencies section +} + +dependencies { + implementation "gradle.plugin.org.flywaydb:gradle-plugin-publishing:7.15.0" + implementation 'nu.studer:gradle-jooq-plugin:6.0.1' +} diff --git a/buildSrc/src/main/groovy/database-settings.gradle b/buildSrc/src/main/groovy/database-settings.gradle new file mode 100644 index 0000000000..a79edc65d3 --- /dev/null +++ b/buildSrc/src/main/groovy/database-settings.gradle @@ -0,0 +1,72 @@ +plugins { + id "org.flywaydb.flyway" + id 'nu.studer.jooq' +} + +//This cannot be rootDir.buildDir since then different Modules would share 1 DB +//And Only 1 of them would work and every other would fail because it contains unknown Tables/Entities +var databaseFile = new File(buildDir, "database.db") +var databaseUrl = "jdbc:sqlite:${databaseFile}" + +tasks.register("createBuildDirectoryIfNeeded") { + doLast { + if (!databaseFile.parentFile.exists()) { + databaseFile.parentFile.mkdirs() + } + } + // https://github.com/gradle/gradle/issues/2488 + mustRunAfter(project.tasks.findByPath('clean')) +} + +flyway { + url = databaseUrl + locations = ["filesystem:src/main/resources/db"] +} + +tasks.flywayMigrate { + dependsOn("createBuildDirectoryIfNeeded") +} + +jooq { + version = "3.15.2" + + configurations { + main { + generationTool { + jdbc { + driver = 'org.sqlite.JDBC' + url = databaseUrl + } + generator { + name = 'org.jooq.codegen.DefaultGenerator' + database { + name = 'org.jooq.meta.sqlite.SQLiteDatabase' + excludes = 'flyway_schema_history|sqlite_sequence' + } + generate { + records = true + immutablePojos = true + fluentSetters = true + } + target { + packageName = 'org.togetherjava.tjbot.db.generated' + } + } + } + } + } +} + +tasks.generateJooq { + dependsOn("flywayMigrate") +} + +var sqliteVersion = "3.36.0.3" + +dependencies { + implementation "org.xerial:sqlite-jdbc:${sqliteVersion}" + implementation 'org.flywaydb:flyway-core:8.0.0' + implementation 'org.jooq:jooq:3.15.3' + + jooqGenerator "org.xerial:sqlite-jdbc:${sqliteVersion}" +} diff --git a/database/build.gradle b/database/build.gradle index c90ac55e5f..3319d33ef0 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -1,62 +1,5 @@ plugins { - id "org.flywaydb.flyway" version "8.0.0" - id 'nu.studer.jooq' version '6.0.1' -} - -var databaseFile = new File(rootProject.buildDir, "database.db") -var databaseUrl = "jdbc:sqlite:${databaseFile}" - -tasks.register("createBuildDirectoryIfNeeded") { - doLast { - if (!databaseFile.parentFile.exists()) { - databaseFile.parentFile.mkdirs() - } - } - // https://github.com/gradle/gradle/issues/2488 - mustRunAfter(project.tasks.findByPath('clean')) -} - -flyway { - url = databaseUrl - locations = ["filesystem:src/main/resources/db"] -} - -tasks.flywayMigrate { - dependsOn("createBuildDirectoryIfNeeded") -} - -jooq { - version = "3.15.2" - - configurations { - main { - generationTool { - jdbc { - driver = 'org.sqlite.JDBC' - url = databaseUrl - } - generator { - name = 'org.jooq.codegen.DefaultGenerator' - database { - name = 'org.jooq.meta.sqlite.SQLiteDatabase' - excludes = 'flyway_schema_history|sqlite_sequence' - } - generate { - records = true - immutablePojos = true - fluentSetters = true - } - target { - packageName = 'org.togetherjava.tjbot.db.generated' - } - } - } - } - } -} - -tasks.generateJooq { - dependsOn("flywayMigrate") + id 'java' } var sqliteVersion = "3.36.0.3" @@ -65,10 +8,8 @@ dependencies { implementation "org.xerial:sqlite-jdbc:${sqliteVersion}" implementation 'org.flywaydb:flyway-core:8.0.0' implementation 'org.jooq:jooq:3.15.3' - - jooqGenerator "org.xerial:sqlite-jdbc:${sqliteVersion}" } test { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/docker-compose.yml.example b/docker-compose.yml.example new file mode 100644 index 0000000000..74465edf74 --- /dev/null +++ b/docker-compose.yml.example @@ -0,0 +1,20 @@ +version: "3.9" +services: + bot: + image: togetherjava/tjbot:latest + volumes: + - "./application/config.json:/config.json" + - "./application/logs/:/logs" + links: + - logs + environment: + - TJ_LOG_TARGET=logs + logs: + image: togetherjava/tjlogs:latest + ports: + - "5050:5050" + volumes: + - "./logviewer/config.json:/logviewer/config.json" + - "./application/logs:/application/logs" + - "./logviewer/logs:/logviewer/logs" + - "./logviewer/db:/logviewer/db" diff --git a/logviewer/build.gradle b/logviewer/build.gradle new file mode 100644 index 0000000000..81e51299d8 --- /dev/null +++ b/logviewer/build.gradle @@ -0,0 +1,95 @@ +plugins { + id "com.google.cloud.tools.jib" version "3.1.4" + id "org.springframework.boot" version "2.5.5" + id "io.spring.dependency-management" version "1.0.11.RELEASE" + id "com.vaadin" version "21.0.2" + id 'java' + id 'database-settings' +} + +defaultTasks("clean", "bootRun") + +repositories { + mavenCentral() + maven { + url = uri("https://maven.vaadin.com/vaadin-addons") + } +} + +configurations { + developmentOnly + runtimeClasspath.extendsFrom(developmentOnly) +} + +jooq { + configurations { + main { + generationTool { + generator { + target { + packageName = 'org.togetherjava.tjbot.db.generated' + } + } + } + } + } +} + +dependencies { + compileOnly 'org.apache.logging.log4j:log4j-api:2.14.1' + runtimeOnly 'org.apache.logging.log4j:log4j-jul:2.14.1' + runtimeOnly 'org.apache.logging.log4j:log4j-slf4j-impl:2.14.1' + + + implementation(project(":database")) + implementation 'org.jooq:jooq:3.15.3' + + implementation 'com.vaadin:vaadin-core:21.0.2' + implementation ('com.vaadin:vaadin-spring:18.0.0') + implementation 'org.vaadin.artur:a-vaadin-helper:1.7.1' + implementation 'org.vaadin.crudui:crudui:4.6.0' + implementation 'com.vaadin.componentfactory:enhanced-dialog:21.0.0' + + + implementation ('org.springframework.boot:spring-boot-starter-web:2.5.5'){ + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' + } + implementation ('org.springframework.boot:spring-boot-starter-security:2.5.5'){ + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' + } + implementation ('org.springframework.boot:spring-boot-starter-oauth2-client:2.5.5'){ + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' + } + + developmentOnly ('org.springframework.boot:spring-boot-starter-actuator:2.5.5'){ + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' + } + developmentOnly ('org.springframework.boot:spring-boot-devtools:2.5.5'){ + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' + } +} + +tasks.jib.dependsOn(tasks.vaadinBuildFrontend) +jib { + from.image = 'eclipse-temurin:17' + to { + image = 'togetherjava.duckdns.org:5001/togetherjava/tjlogs:' + System.getenv('BRANCH_NAME') ?: 'latest' + auth { + username = System.getenv('REGISTRY_USER') ?: '' + password = System.getenv('REGISTRY_PASSWORD') ?: '' + } + } + container{ + setPorts(["5050"].asList()) + setCreationTime(Instant.now().toString()) + } +} + +tasks.test { + useJUnitPlatform() +} + +vaadin { + pnpmEnable = true + productionMode = true +} diff --git a/logviewer/frontend/themes/myapp/main-layout.css b/logviewer/frontend/themes/myapp/main-layout.css new file mode 100644 index 0000000000..c69ed2c859 --- /dev/null +++ b/logviewer/frontend/themes/myapp/main-layout.css @@ -0,0 +1,36 @@ +[slot='drawer'] { + background-image: linear-gradient(0deg, var(--lumo-shade-5pct), var(--lumo-shade-5pct)); +} + +[slot='drawer'] nav a { + text-decoration: none; + transition: color 140ms; +} + +[slot='drawer'] nav a .la { + margin-top: calc(var(--lumo-space-xs) * 0.5); +} + +[slot='drawer'] nav a::before { + border-radius: var(--lumo-border-radius); + bottom: calc(var(--lumo-space-xs) * 0.5); + content: ''; + left: 0; + position: absolute; + right: 0; + top: calc(var(--lumo-space-xs) * 0.5); + transition: background-color 140ms; +} + +[slot='drawer'] nav a[highlight] { + color: var(--lumo-primary-text-color); +} + +[slot='drawer'] nav a[highlight]::before { + background-color: var(--lumo-primary-color-10pct); +} + +[slot='drawer'] footer vaadin-context-menu { + align-items: center; + display: flex; +} diff --git a/logviewer/frontend/themes/myapp/styles.css b/logviewer/frontend/themes/myapp/styles.css new file mode 100644 index 0000000000..9d00368c7b --- /dev/null +++ b/logviewer/frontend/themes/myapp/styles.css @@ -0,0 +1,3 @@ +@import url('./main-layout.css'); +@import url('./views/hello-world-view.css'); +@import url('line-awesome/dist/line-awesome/css/line-awesome.min.css'); \ No newline at end of file diff --git a/logviewer/frontend/themes/myapp/theme.json b/logviewer/frontend/themes/myapp/theme.json new file mode 100644 index 0000000000..88c4f9aae2 --- /dev/null +++ b/logviewer/frontend/themes/myapp/theme.json @@ -0,0 +1 @@ +{"lumoImports":["typography","color","spacing","badge","utility"]} \ No newline at end of file diff --git a/logviewer/frontend/themes/myapp/views/hello-world-view.css b/logviewer/frontend/themes/myapp/views/hello-world-view.css new file mode 100644 index 0000000000..c61de2b2ef --- /dev/null +++ b/logviewer/frontend/themes/myapp/views/hello-world-view.css @@ -0,0 +1,4 @@ +.hello-world-view { + display: block; + padding: 1em; +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/Application.java b/logviewer/src/main/java/org/togetherjava/logwatcher/Application.java new file mode 100644 index 0000000000..81f34b1bc0 --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/Application.java @@ -0,0 +1,46 @@ +package org.togetherjava.logwatcher; + +import com.vaadin.flow.component.dependency.NpmPackage; +import com.vaadin.flow.component.page.AppShellConfigurator; +import com.vaadin.flow.component.page.Push; +import com.vaadin.flow.server.PWA; +import com.vaadin.flow.theme.Theme; +import org.slf4j.LoggerFactory; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.r2dbc.R2dbcAutoConfiguration; +import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; +import org.togetherjava.logwatcher.config.Config; +import org.vaadin.artur.helpers.LaunchUtil; + +/** + * This is a small Spring-Vaadin Webserver, which main purpose is to show Log-Event's generated from + * the Bot. + *

+ * You can see the Views you can see in your Browser at {@link org.togetherjava.logwatcher.views} + *

+ * Log-Event's are captured by the REST-API at {@link org.togetherjava.logwatcher.logs.LogREST} + *

+ * Security/OAuth2 Config at {@link org.togetherjava.logwatcher.oauth.OAuth2LoginConfig} + *

+ * And the initial Config at {@link org.togetherjava.logwatcher.config.Config} + */ +@SpringBootApplication(exclude = {R2dbcAutoConfiguration.class}) +@Theme(value = "myapp") +@PWA(name = "LogViewer", shortName = "Logs", offlineResources = {"images/logo.png"}) +@NpmPackage(value = "line-awesome", version = "1.3.0") +@Push +public class Application extends SpringBootServletInitializer implements AppShellConfigurator { + + public static void main(String[] args) { + if (args.length > 1) { + LoggerFactory.getLogger(Application.class) + .error("Usage: Provide a single Argument, containing the Path to the Config-File"); + System.exit(1); + } + + Config.init(args.length == 1 ? args[0] : "./logviewer/config.json"); + LaunchUtil.launchBrowserInDevelopmentMode(SpringApplication.run(Application.class, args)); + } + +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/DatabaseProvider.java b/logviewer/src/main/java/org/togetherjava/logwatcher/DatabaseProvider.java new file mode 100644 index 0000000000..3be226037e --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/DatabaseProvider.java @@ -0,0 +1,63 @@ +package org.togetherjava.logwatcher; + +import org.slf4j.LoggerFactory; +import org.springframework.beans.FatalBeanException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; +import org.togetherjava.logwatcher.config.Config; +import org.togetherjava.tjbot.db.Database; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; + +@Configuration +@Scope(BeanDefinition.SCOPE_SINGLETON) +public class DatabaseProvider { + + private final Database db; + private final Config config; + + public DatabaseProvider(final Config config) { + this.config = config; + this.db = createDB(); + } + + @SuppressWarnings({"java:S2139"}) // At this point there is nothing we can do about a + // SQLException + private Database createDB() { + final Path dbPath = getDBPath(); + + try { + return new Database("jdbc:sqlite:%s".formatted(dbPath.toAbsolutePath())); + } catch (final SQLException e) { + LoggerFactory.getLogger(DatabaseProvider.class) + .error("Exception while creating Database.", e); + throw new FatalBeanException("Could not create Database.", e); + } + } + + private Path getDBPath() { + final Path dbPath = Path.of(this.config.getDatabasePath()); + + try { + Files.createDirectories(dbPath.getParent()); + } catch (final IOException e) { + LoggerFactory.getLogger(DatabaseProvider.class) + .error("Exception while creating Database-Path.", e); + } + + return dbPath; + } + + + @Bean + public Database getDb() { + return this.db; + } + + +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/accesscontrol/AllowedRoles.java b/logviewer/src/main/java/org/togetherjava/logwatcher/accesscontrol/AllowedRoles.java new file mode 100644 index 0000000000..ab1ec4eccc --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/accesscontrol/AllowedRoles.java @@ -0,0 +1,22 @@ +package org.togetherjava.logwatcher.accesscontrol; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + + +/** + * Annotation for handling Access Control on Views + */ +@Retention(RUNTIME) +@Target({TYPE}) +public @interface AllowedRoles { + + + /** + * Roles to permit access to the View {@link Role} + */ + Role[] roles(); +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/accesscontrol/Role.java b/logviewer/src/main/java/org/togetherjava/logwatcher/accesscontrol/Role.java new file mode 100644 index 0000000000..29ab843411 --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/accesscontrol/Role.java @@ -0,0 +1,53 @@ +package org.togetherjava.logwatcher.accesscontrol; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.Set; + +/** + * Basic Roles for Access Control on Views + */ +public enum Role { + /** + * Role for when Stuff goes wrong + */ + UNKNOWN(0, "unknown"), + + /** + * Base Role + */ + USER(1, "user"), + + /** + * Role for Views that should require more permissions + */ + ADMIN(2, "admin"); + + private final int id; + private final String roleName; + + Role(int id, String roleName) { + this.id = id; + this.roleName = roleName; + } + + public int getId() { + return id; + } + + public String getRoleName() { + return roleName; + } + + public static Role forID(final int id) { + return Arrays.stream(values()) + .filter(r -> r.id == id) + .findAny() + .orElseThrow(() -> new IllegalArgumentException("Unknown RoleID: %d".formatted(id))); + } + + public static Set getDisplayableRoles() { + return EnumSet.of(USER, ADMIN); + } + +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/config/Config.java b/logviewer/src/main/java/org/togetherjava/logwatcher/config/Config.java new file mode 100644 index 0000000000..2a4c33b1b0 --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/config/Config.java @@ -0,0 +1,122 @@ +package org.togetherjava.logwatcher.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Configuration; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Basic Class for accumulating Information which is required to start this application + */ +@Configuration +public class Config { + + private static final AtomicReference CONFIG_PATH = new AtomicReference<>(); + + public static void init(final String pathToConfig) { + if (!CONFIG_PATH.compareAndSet(null, pathToConfig)) { + throw new IllegalStateException( + "Config Path already set to %s".formatted(CONFIG_PATH.get())); + } + } + + + /** + * Client-Name of the OAuth2-Application + */ + private final String clientName; + + /** + * Client-ID of the OAuth2-Application + */ + private final String clientId; + + /** + * Client-Secret of the OAuth2-Application + */ + private final String clientSecret; + + /** + * Discord-Username of the User to add as 1. User + */ + private final String rootUserName; + + /** + * Discord-ID of the User to add as 1. User + */ + private final String rootDiscordID; + + /** + * Path of the Log directory + */ + private final String logPath; + + /** + * Redirect Path for OAuth2 + */ + private final String redirectPath; + + /** + * Path for this Database + */ + private final String databasePath; + + public Config(final ObjectMapper mapper) { + final JsonNode jsonNode = getJsonNode(mapper); + + this.clientName = jsonNode.get("clientName").asText(); + this.clientId = jsonNode.get("clientId").asText(); + this.clientSecret = jsonNode.get("clientSecret").asText(); + this.rootUserName = jsonNode.get("rootUserName").asText(); + this.rootDiscordID = jsonNode.get("rootDiscordID").asText(); + this.logPath = jsonNode.get("logPath").asText(); + this.redirectPath = jsonNode.get("redirectPath").asText(); + this.databasePath = jsonNode.get("databasePath").asText(); + } + + private JsonNode getJsonNode(final ObjectMapper mapper) { + final String pathToConfig = Objects.requireNonNull(CONFIG_PATH.get(), "Path not Set"); + try { + return mapper.readTree(Path.of(pathToConfig).toFile()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public String getRedirectPath() { + return redirectPath; + } + + public String getLogPath() { + return logPath; + } + + public String getRootUserName() { + return rootUserName; + } + + public String getRootDiscordID() { + return rootDiscordID; + } + + public String getClientName() { + return clientName; + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public String getDatabasePath() { + return this.databasePath; + } +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/constants/LogEventsConstants.java b/logviewer/src/main/java/org/togetherjava/logwatcher/constants/LogEventsConstants.java new file mode 100644 index 0000000000..b6e5becb9b --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/constants/LogEventsConstants.java @@ -0,0 +1,24 @@ +package org.togetherjava.logwatcher.constants; + +/** + * Constant Class for Logevents + */ +public final class LogEventsConstants { + + public static final String FIELD_INSTANT = "time"; + public static final String FIELD_END_OF_BATCH = "endOfbatch"; + public static final String FIELD_LOGGER_NAME = "loggername"; + public static final String FIELD_LOGGER_LEVEL = "level"; + public static final String FIELD_LOGGER_FQCN = "loggerfqcn"; + public static final String FIELD_MESSAGE = "message"; + public static final String FIELD_THREAD = "thread"; + public static final String FIELD_THREAD_ID = "threadid"; + public static final String FIELD_THREAD_PRIORITY = "threadpriority"; + + + + /** + * Contestants class, nothing to instantiate + */ + private LogEventsConstants() {} +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/constants/UserConstants.java b/logviewer/src/main/java/org/togetherjava/logwatcher/constants/UserConstants.java new file mode 100644 index 0000000000..859a15ae9a --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/constants/UserConstants.java @@ -0,0 +1,13 @@ +package org.togetherjava.logwatcher.constants; + +/** + * Gathers all static Values regarding Users + */ +public final class UserConstants { + + public static final String FIELD_USERNAME = "username"; + public static final String FIELD_DISCORD_ID = "discordid"; + + + private UserConstants() {} +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/entities/InstantWrapper.java b/logviewer/src/main/java/org/togetherjava/logwatcher/entities/InstantWrapper.java new file mode 100644 index 0000000000..824774e73f --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/entities/InstantWrapper.java @@ -0,0 +1,68 @@ +package org.togetherjava.logwatcher.entities; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.util.Date; + +public class InstantWrapper { + + private long epochSecond; + private long nanoOfSecond; + + /** + * @param epochSecond + * @param nanoOfSecond + */ + @JsonCreator + public InstantWrapper(@JsonProperty("epochSecond") long epochSecond, + @JsonProperty("nanoOfSecond") long nanoOfSecond) { + super(); + this.epochSecond = epochSecond; + this.nanoOfSecond = nanoOfSecond; + } + + public long getEpochSecond() { + return epochSecond; + } + + public void setEpochSecond(long epochSecond) { + this.epochSecond = epochSecond; + } + + public long getNanoOfSecond() { + return nanoOfSecond; + } + + public void setNanoOfSecond(long nanoOfSecond) { + this.nanoOfSecond = nanoOfSecond; + } + + @Override + public String toString() { + final var instant = toInstant(); + + return new SimpleDateFormat("yyy-MM-dd HH:mm:ss.SSS").format(Date.from(instant)); + } + + public Instant toInstant() { + return Instant.ofEpochSecond(this.epochSecond, this.nanoOfSecond); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof InstantWrapper other)) + return false; + + return epochSecond == other.epochSecond && nanoOfSecond != other.nanoOfSecond; + } + + @Override + public int hashCode() { + int result = (int) (epochSecond ^ (epochSecond >>> 32)); + result = 31 * result + (int) (nanoOfSecond ^ (nanoOfSecond >>> 32)); + return result; + } +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/entities/LogEvent.java b/logviewer/src/main/java/org/togetherjava/logwatcher/entities/LogEvent.java new file mode 100644 index 0000000000..49c7ebb3e0 --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/entities/LogEvent.java @@ -0,0 +1,131 @@ +package org.togetherjava.logwatcher.entities; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.Instant; + + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class LogEvent { + + private Instant instant; + + private String thread; + + private String level; + + private String loggerName; + + private String message; + + private Boolean endOfBatch; + + private String loggerFqcn; + + private Integer threadId; + + private Integer threadPriority; + + @JsonCreator + public LogEvent(@JsonProperty("instant") InstantWrapper wrapper, + @JsonProperty("thread") String thread, @JsonProperty("level") String level, + @JsonProperty("loggerName") String loggerName, @JsonProperty("message") String message, + @JsonProperty("endOfBatch") Boolean endOfBatch, + @JsonProperty("loggerFqcn") String loggerFqcn, + @JsonProperty("threadId") Integer threadId, + @JsonProperty("threadPriority") Integer threadPriority) { + this.instant = wrapper.toInstant(); + this.thread = thread; + this.level = level; + this.loggerName = loggerName; + this.message = message; + this.endOfBatch = endOfBatch; + this.loggerFqcn = loggerFqcn; + this.threadId = threadId; + this.threadPriority = threadPriority; + } + + + public Instant getInstant() { + return instant; + } + + public void setInstant(Instant instant) { + this.instant = instant; + } + + public String getThread() { + return thread; + } + + public void setThread(String thread) { + this.thread = thread; + } + + public String getLevel() { + return level; + } + + public void setLevel(String level) { + this.level = level; + } + + public String getLoggerName() { + return loggerName; + } + + public void setLoggerName(String loggerName) { + this.loggerName = loggerName; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public Boolean getEndOfBatch() { + return endOfBatch; + } + + public void setEndOfBatch(Boolean endOfBatch) { + this.endOfBatch = endOfBatch; + } + + public String getLoggerFqcn() { + return loggerFqcn; + } + + public void setLoggerFqcn(String loggerFqcn) { + this.loggerFqcn = loggerFqcn; + } + + public Integer getThreadId() { + return threadId; + } + + public void setThreadId(Integer threadId) { + this.threadId = threadId; + } + + public Integer getThreadPriority() { + return threadPriority; + } + + public void setThreadPriority(Integer threadPriority) { + this.threadPriority = threadPriority; + } + + @Override + public String toString() { + return "LogDTO{" + "instant=" + instant + ", thread='" + thread + '\'' + ", level='" + level + + '\'' + ", loggerName='" + loggerName + '\'' + ", message='" + message + '\'' + + ", endOfBatch=" + endOfBatch + ", loggerFqcn='" + loggerFqcn + '\'' + + ", threadId=" + threadId + ", threadPriority=" + threadPriority + '}'; + } + +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/entities/UserWrapper.java b/logviewer/src/main/java/org/togetherjava/logwatcher/entities/UserWrapper.java new file mode 100644 index 0000000000..e5b6731ba4 --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/entities/UserWrapper.java @@ -0,0 +1,72 @@ +package org.togetherjava.logwatcher.entities; + +import org.togetherjava.logwatcher.accesscontrol.Role; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; + +public final class UserWrapper implements Serializable { + @Serial + private static final long serialVersionUID = -3701246411434315431L; + + private long discordID; + private String userName; + private Set roles = Collections.emptySet(); + + public UserWrapper() {} + + public UserWrapper(long discordID, String userName, Set roles) { + this.discordID = discordID; + this.userName = userName; + this.roles = roles; + } + + public void setDiscordID(long discordID) { + this.discordID = discordID; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public void setRoles(Set roles) { + this.roles = roles; + } + + public long getDiscordID() { + return discordID; + } + + public String getUserName() { + return userName; + } + + public Set getRoles() { + return roles; + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof UserWrapper other)) { + return true; + } + return this.discordID == other.discordID && Objects.equals(this.userName, other.userName) + && Objects.equals(this.roles, other.roles); + } + + @Override + public int hashCode() { + return Objects.hash(discordID, userName, roles); + } + + @Override + public String toString() { + return "UserDTO[" + "discordID=" + discordID + ", " + "userName=" + userName + ", " + + "roles=" + roles + ']'; + } + + +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/logs/LogREST.java b/logviewer/src/main/java/org/togetherjava/logwatcher/logs/LogREST.java new file mode 100644 index 0000000000..e98f1a34b3 --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/logs/LogREST.java @@ -0,0 +1,37 @@ +package org.togetherjava.logwatcher.logs; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import org.togetherjava.logwatcher.entities.LogEvent; +import org.togetherjava.logwatcher.watcher.StreamWatcher; +import org.togetherjava.tjbot.db.generated.tables.pojos.Logevents; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +@RestController +public class LogREST { + + private final LogRepository logs; + + public LogREST(final LogRepository logs) { + this.logs = logs; + } + + @PostMapping(path = "/rest/api/logs", consumes = "application/json") + public ResponseEntity logEvent(@RequestBody final LogEvent body) { + this.logs.save(mapToLogevents(body)); + StreamWatcher.notifyOfEvent(); + return ResponseEntity.ok().build(); + } + + private Logevents mapToLogevents(final LogEvent body) { + return new Logevents(Integer.MIN_VALUE, + LocalDateTime.ofInstant(body.getInstant(), ZoneId.systemDefault()), + body.getThread(), body.getLevel(), body.getLoggerName(), body.getMessage(), + body.getEndOfBatch(), body.getLoggerFqcn(), body.getThreadId(), + body.getThreadPriority()); + } +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/logs/LogRepository.java b/logviewer/src/main/java/org/togetherjava/logwatcher/logs/LogRepository.java new file mode 100644 index 0000000000..ad1a88a7e6 --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/logs/LogRepository.java @@ -0,0 +1,22 @@ +package org.togetherjava.logwatcher.logs; + +import org.togetherjava.tjbot.db.generated.tables.pojos.Logevents; + +import java.util.List; + +public interface LogRepository { + + /** + * Saves the given event to the DB, does not update or merge + * + * @param event Event to Insert + */ + void save(Logevents event); + + /** + * Fetches all Events from the DB + * + * @return List of LogEvents + */ + List findAll(); +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/logs/LogRepositoryImpl.java b/logviewer/src/main/java/org/togetherjava/logwatcher/logs/LogRepositoryImpl.java new file mode 100644 index 0000000000..340266cd25 --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/logs/LogRepositoryImpl.java @@ -0,0 +1,61 @@ +package org.togetherjava.logwatcher.logs; + +import org.springframework.stereotype.Repository; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.pojos.Logevents; +import org.togetherjava.tjbot.db.generated.tables.records.LogeventsRecord; + +import java.util.List; + +import static org.togetherjava.tjbot.db.generated.tables.Logevents.LOGEVENTS; + +@Repository +public class LogRepositoryImpl implements LogRepository { + + private final Database db; + + public LogRepositoryImpl(final Database db) { + this.db = db; + } + + + @Override + public void save(Logevents event) { + this.db.writeTransaction(ctx -> { + LogeventsRecord toInsert = ctx.newRecord(LOGEVENTS) + .setEndofbatch(event.getEndofbatch()) + .setLevel(event.getLevel()) + .setLoggername(event.getLoggername()) + .setLoggerfqcn(event.getLoggerfqcn()) + .setMessage(event.getMessage()) + .setTime(event.getTime()) + .setThread(event.getThread()) + .setThreadid(event.getThreadid()) + .setThreadpriority(event.getThreadpriority()); + + if (event.getId() != Integer.MIN_VALUE) { + toInsert.setId(event.getId()); + } + + // No merge or Update here, Logs are not supposed to be updated + toInsert.insert(); + }); + } + + @Override + @SuppressWarnings("java:S1602") // Curly Braces are necessary here + public List findAll() { + return this.db.read(ctx -> { + return ctx.selectFrom(LOGEVENTS).fetch(this::recordToPojo); + }); + } + + private Logevents recordToPojo(final LogeventsRecord logRecord) { + return new Logevents(logRecord.getId(), logRecord.getTime(), logRecord.getThread(), + logRecord.getLevel(), logRecord.getLoggername(), logRecord.getMessage(), + logRecord.getEndofbatch(), logRecord.getLoggerfqcn(), logRecord.getThreadid(), + logRecord.getThreadpriority()); + } + + +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/oauth/OAuth2LoginConfig.java b/logviewer/src/main/java/org/togetherjava/logwatcher/oauth/OAuth2LoginConfig.java new file mode 100644 index 0000000000..9d9d5a5f33 --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/oauth/OAuth2LoginConfig.java @@ -0,0 +1,91 @@ +package org.togetherjava.logwatcher.oauth; + +import com.vaadin.flow.spring.security.VaadinWebSecurityConfigurerAdapter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.builders.WebSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.togetherjava.logwatcher.config.Config; + +/** + * Configures Spring Security so that we use Discord-OAuth2 as the identity provider + */ +@Configuration +public class OAuth2LoginConfig { + + @Bean + public ClientRegistrationRepository clientRegistrationRepository(Config config) { + return new InMemoryClientRegistrationRepository(googleClientRegistration(config)); + } + + @Bean + public OAuth2AuthorizedClientService authorizedClientService( + ClientRegistrationRepository clientRegistrationRepository) { + return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository); + } + + @Bean + public OAuth2AuthorizedClientRepository authorizedClientRepository( + OAuth2AuthorizedClientService authorizedClientService) { + return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService); + } + + /** + * This is where we actually configure the Security-API to talk to Discord + * + * @return The ClientRegistration for Discord + */ + private ClientRegistration googleClientRegistration(Config config) { + return ClientRegistration.withRegistrationId("Discord") + .clientName(config.getClientName()) + .clientId(config.getClientId()) + .clientSecret(config.getClientSecret()) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .scope("identify") + .authorizationUri("https://discord.com/api/oauth2/authorize") + .userInfoUri("https://discordapp.com/api/users/@me") + .userNameAttributeName("username") + .tokenUri("https://discordapp.com/api/oauth2/token") + .redirectUri(config.getRedirectPath()) + .build(); + } + + /** + * Configures the Security-API which path's should be protected and generally what to do + */ + @EnableWebSecurity + public static class OAuth2LoginSecurityConfig extends VaadinWebSecurityConfigurerAdapter { + + @Override + public void configure(WebSecurity web) throws Exception { + super.configure(web); + web.ignoring().antMatchers("/rest/api/**"); + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.authorizeRequests().antMatchers("/rest/api/**").anonymous(); + http.oauth2Login(); + http.logout() + .logoutRequestMatcher(new AntPathRequestMatcher("/logout")) + .deleteCookies("JSESSIONID") + .invalidateHttpSession(true) + .clearAuthentication(true); + + // Enables Vaadin to load Server Resources + super.configure(http); + } + } +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/users/AuthenticatedUser.java b/logviewer/src/main/java/org/togetherjava/logwatcher/users/AuthenticatedUser.java new file mode 100644 index 0000000000..472f560b9f --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/users/AuthenticatedUser.java @@ -0,0 +1,123 @@ +package org.togetherjava.logwatcher.users; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.server.VaadinServletRequest; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.stereotype.Component; +import org.togetherjava.logwatcher.accesscontrol.Role; +import org.togetherjava.tjbot.db.generated.tables.pojos.Users; + +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Wrapper for accessing the current User + */ +@Component +public class AuthenticatedUser { + + private final UserRepository userRepository; + + public AuthenticatedUser(UserRepository userRepository) { + this.userRepository = userRepository; + } + + /** + * Check's if you could register the current User + * + * @return true, if the Current user is Authenticated + */ + public boolean canRegister() { + return getAuthenticatedUser().isPresent(); + } + + /** + * Check's if the current User is already registered + * + * @return true, if the User is already in the repository + */ + public boolean isRegistered() { + if (!canRegister()) { + return false; + } + + return getAuthenticatedUser().map(this::extractID) + .map(userRepository::findByDiscordID) + .isPresent(); + } + + /** + * Attempts to register the current User, if he is not yet registered + */ + public void register() { + if (!canRegister() || isRegistered()) { + throw new IllegalStateException("Can not register an already registered User"); + } + + getAuthenticatedUser().map(this::toUser).ifPresent(userRepository::save); + } + + /** + * Get's the current User Object from the repository + * + * @return The Optional User, should in most cases not be empty + */ + public Users get() { + return getAuthenticatedUser().map(this::extractID) + .map(userRepository::findByDiscordID) + .orElseThrow(() -> new IllegalArgumentException("No authenticated User present.")); + } + + public Set getRoles() { + return this.userRepository.fetchRolesForUser(get()); + } + + /** + * Performs a logout on the current User + */ + public void logout() { + UI.getCurrent().getPage().setLocation("/"); + SecurityContextLogoutHandler logoutHandler = new SecurityContextLogoutHandler(); + logoutHandler.logout(VaadinServletRequest.getCurrent().getHttpServletRequest(), null, null); + } + + /** + * Extracts the principal from the current SessionContext of the user + * + * @return The Optional Principal of the current User + */ + private Optional getAuthenticatedUser() { + SecurityContext context = SecurityContextHolder.getContext(); + Object principal = context.getAuthentication().getPrincipal(); + + return principal instanceof OAuth2User ? Optional.of(principal).map(OAuth2User.class::cast) + : Optional.empty(); + } + + /** + * Maps the principal to the User object + * + * @param oAuth2User Principal to map + * @return User-Object derived from the Principal + */ + private Users toUser(OAuth2User oAuth2User) { + return new Users(extractID(oAuth2User), oAuth2User.getName()); + } + + /** + * Extracts the discord-ID from the Principal + * + * @param oAuth2User Principal with the ID + * @return Discord-ID from the given Principal + */ + private long extractID(OAuth2User oAuth2User) { + final String id = oAuth2User.getAttribute("id"); + return Long.parseLong( + Objects.requireNonNull(id, "ID from OAuth-User is null, this should never happen")); + } + +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/users/UserDetailsServiceImpl.java b/logviewer/src/main/java/org/togetherjava/logwatcher/users/UserDetailsServiceImpl.java new file mode 100644 index 0000000000..2aac79f95c --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/users/UserDetailsServiceImpl.java @@ -0,0 +1,65 @@ +package org.togetherjava.logwatcher.users; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.togetherjava.logwatcher.accesscontrol.Role; +import org.togetherjava.logwatcher.config.Config; +import org.togetherjava.tjbot.db.generated.tables.pojos.Users; + +import java.util.List; +import java.util.Set; + +/** + * Service to load a spring UserDetail-Object from the userRepository, currently only used for + * db-initialisation + */ +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + + private final UserRepository userRepository; + + public UserDetailsServiceImpl(UserRepository userRepository, Config config) { + this.userRepository = userRepository; + + if (this.userRepository.count() == 0) { + final Users defaultUser = + new Users(Long.parseLong(config.getRootDiscordID()), config.getRootUserName()); + + this.userRepository.save(defaultUser); + this.userRepository.saveRolesForUser(defaultUser, Set.of(Role.ADMIN, Role.USER)); + } + } + + private List getAuthorities(Users user) { + return this.userRepository.fetchRolesForUser(user) + .stream() + .map(Role::getRoleName) + .map(name -> "ROLE_" + name) + .map(SimpleGrantedAuthority::new) + .map(GrantedAuthority.class::cast) + .toList(); + + } + + /** + * Loads the user from the userRepository and maps it to the Spring-Object UserDetails + * + * @param username Username of the User to Load + * @return The UserDetail-Object that is associated with the discordID, or else throws an + * {@link UsernameNotFoundException} + */ + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Users user = userRepository.findByUsername(username); + if (user == null) { + throw new UsernameNotFoundException("No user present with username: " + username); + } else { + return new org.springframework.security.core.userdetails.User(user.getUsername(), null, + getAuthorities(user)); + } + } +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/users/UserRepository.java b/logviewer/src/main/java/org/togetherjava/logwatcher/users/UserRepository.java new file mode 100644 index 0000000000..7135b4c2c5 --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/users/UserRepository.java @@ -0,0 +1,71 @@ +package org.togetherjava.logwatcher.users; + +import org.togetherjava.logwatcher.accesscontrol.Role; +import org.togetherjava.tjbot.db.generated.tables.pojos.Users; + +import java.util.List; +import java.util.Set; + +/** + * Basic JPA-Repository for loading Users from the DB + */ +public interface UserRepository { + + /** + * Load's the User from the DB, that matches the discordID + * + * @param discordID Discord-ID of the User + * @return User-Object of the user where the discordID matches, else null + */ + Users findByDiscordID(long discordID); + + /** + * Load's the User from the DB, that matches the username + * + * @param username Username of the User to load + * @return User-Object of the user where the username matches, else null + */ + Users findByUsername(final String username); + + /** + * Fetches all saved User + * + * @return List of Users from the DB, never null + */ + List findAll(); + + /** + * Counts the amount of Users saved in the DB + * + * @return Count of Users in the db >=0 + */ + int count(); + + /** + * Merges the given user in the DB + * + * @param user User to Save in the DB + */ + void save(Users user); + + /** + * Removes this User and all referencing Entities + */ + void delete(Users user); + + /** + * Fetches the Roles the User has, see {@link Role} + * + * @param user User to fetch the Roles for + * @return Set of Roles, never null + */ + Set fetchRolesForUser(Users user); + + /** + * Updates/Saves the Roles for the User + * + * @param user User to update the Role's + * @param roles All Roles the User should have + */ + void saveRolesForUser(Users user, Set roles); +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/users/UserRepositoryImpl.java b/logviewer/src/main/java/org/togetherjava/logwatcher/users/UserRepositoryImpl.java new file mode 100644 index 0000000000..44b18e4d51 --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/users/UserRepositoryImpl.java @@ -0,0 +1,108 @@ +package org.togetherjava.logwatcher.users; + +import org.springframework.stereotype.Component; +import org.togetherjava.logwatcher.accesscontrol.Role; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.Userroles; +import org.togetherjava.tjbot.db.generated.tables.pojos.Users; +import org.togetherjava.tjbot.db.generated.tables.records.UserrolesRecord; +import org.togetherjava.tjbot.db.generated.tables.records.UsersRecord; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.togetherjava.tjbot.db.generated.tables.Users.USERS; + +@Component +@SuppressWarnings("java:S1602") // Curly Braces are necessary here +public class UserRepositoryImpl implements UserRepository { + + private final Database db; + + public UserRepositoryImpl(Database db) { + this.db = db; + } + + @Override + public Users findByDiscordID(long discordID) { + return this.db.readTransaction(ctx -> { + return ctx.selectFrom(USERS) + .where(USERS.DISCORDID.eq(discordID)) + .fetchOne(this::recordToRole); + }); + } + + @Override + public Users findByUsername(String username) { + return this.db.readTransaction(ctx -> { + return ctx.selectFrom(USERS) + .where(USERS.USERNAME.eq(username)) + .fetchOne(this::recordToRole); + }); + } + + @Override + public List findAll() { + return this.db.readTransaction(ctx -> { + return ctx.selectFrom(USERS).fetch(this::recordToRole); + }); + } + + @Override + public int count() { + return this.db.readTransaction(ctx -> { + return ctx.fetchCount(USERS); + }); + } + + @Override + public void save(Users user) { + this.db.writeTransaction(ctx -> { + ctx.newRecord(USERS) + .setDiscordid(user.getDiscordid()) + .setUsername(user.getUsername()) + .merge(); + }); + } + + @Override + public void delete(Users user) { + this.db.writeTransaction(ctx -> { + ctx.deleteFrom(USERS).where(USERS.DISCORDID.eq(user.getDiscordid())).execute(); + }); + } + + @Override + public Set fetchRolesForUser(Users user) { + return new HashSet<>(this.db.readTransaction(ctx -> { + return ctx.selectFrom(Userroles.USERROLES) + .where(Userroles.USERROLES.USERID.eq(user.getDiscordid())) + .fetch(this::recordToRole); + })); + } + + @Override + public void saveRolesForUser(Users user, Set roles) { + this.db.writeTransaction(ctx -> { + ctx.deleteFrom(Userroles.USERROLES) + .where(Userroles.USERROLES.USERID.eq(user.getDiscordid())) + .execute(); + + for (final Role role : roles) { + ctx.newRecord(Userroles.USERROLES) + .setRoleid(role.getId()) + .setUserid(user.getDiscordid()) + .insert(); + } + }); + } + + private Users recordToRole(UsersRecord usersRecord) { + return new Users(usersRecord.getDiscordid(), usersRecord.getUsername()); + } + + private Role recordToRole(UserrolesRecord rolesRecord) { + return Role.forID(rolesRecord.getRoleid()); + } +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/util/LogReader.java b/logviewer/src/main/java/org/togetherjava/logwatcher/util/LogReader.java new file mode 100644 index 0000000000..bbc4b2b329 --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/util/LogReader.java @@ -0,0 +1,51 @@ +package org.togetherjava.logwatcher.util; + +import org.springframework.stereotype.Component; +import org.togetherjava.logwatcher.config.Config; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +@Component +public final class LogReader { + + private final Path logPath; + + public LogReader(Config config) { + this.logPath = Path.of(config.getLogPath()); + } + + /** + * Returns all log Files in the configured Path {@link Config#logPath} + * + * @return Names of the Logfiles + */ + public List getLogs() { + try (final Stream stream = Files.list(this.logPath)) { + return stream.filter(Files::isRegularFile) + .filter(s -> s.toString().endsWith(".log") || s.toString().endsWith(".log.gz")) + .toList(); + } catch (final IOException e) { + throw new UncheckedIOException(e); + } + } + + /** + * Read's the content of the given Logfile in the configured Logging path + * + * @param log Name of the Logfile + * @return The Content of the Log + */ + public List readLog(final Path log) { + try { + return Files.readAllLines(log); + } catch (final IOException e) { + throw new UncheckedIOException(e); + } + } + +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/util/NotificationUtils.java b/logviewer/src/main/java/org/togetherjava/logwatcher/util/NotificationUtils.java new file mode 100644 index 0000000000..aa03e57607 --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/util/NotificationUtils.java @@ -0,0 +1,30 @@ +package org.togetherjava.logwatcher.util; + +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.notification.NotificationVariant; + +import java.util.concurrent.TimeUnit; + +/** + * Util class for handy Methods regarding Notifications for the Client + */ +public final class NotificationUtils { + + + private NotificationUtils() {} + + /** + * Prepares a little Notification to display an Errormessage, in case anything goes wrong + * + * @param e Exception that occurred + * @return Notification for the user + */ + public static Notification getNotificationForError(final Exception e) { + final Notification not = new Notification(); + not.setDuration((int) TimeUnit.SECONDS.toMillis(6)); + not.setPosition(Notification.Position.MIDDLE); + not.setText("Exception occurred while saving. %s".formatted(e.getMessage())); + not.addThemeVariants(NotificationVariant.LUMO_ERROR); + return not; + } +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/views/MainLayout.java b/logviewer/src/main/java/org/togetherjava/logwatcher/views/MainLayout.java new file mode 100644 index 0000000000..03f1e4d1b3 --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/views/MainLayout.java @@ -0,0 +1,172 @@ +package org.togetherjava.logwatcher.views; + +import com.google.common.collect.Sets; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.applayout.AppLayout; +import com.vaadin.flow.component.applayout.DrawerToggle; +import com.vaadin.flow.component.avatar.Avatar; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.contextmenu.ContextMenu; +import com.vaadin.flow.component.html.*; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.RouterLink; +import org.slf4j.LoggerFactory; +import org.togetherjava.logwatcher.accesscontrol.AllowedRoles; +import org.togetherjava.logwatcher.accesscontrol.Role; +import org.togetherjava.logwatcher.users.AuthenticatedUser; +import org.togetherjava.logwatcher.views.logs.LogsView; +import org.togetherjava.logwatcher.views.logs.StreamedView; +import org.togetherjava.logwatcher.views.usermanagement.UserManagement; +import org.togetherjava.tjbot.db.generated.tables.pojos.Users; + +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +/** + * The main view is a top-level placeholder for other views. + */ +@PageTitle("Main") +@SuppressWarnings({"java:S1192"}) +// Those are css names, and those don't need an extra Constants Class +public class MainLayout extends AppLayout { + + private final transient AuthenticatedUser authenticatedUser; + private H1 viewTitle; + + public MainLayout(AuthenticatedUser authUser) { + this.authenticatedUser = authUser; + setPrimarySection(Section.DRAWER); + addToNavbar(true, createHeaderContent()); + addToDrawer(createDrawerContent()); + + if (authUser.canRegister() && !authUser.isRegistered()) { + authUser.register(); + } + + + if (this.authenticatedUser.getRoles().isEmpty()) { + authUser.logout(); + } + + } + + private static RouterLink createLink(MenuItemInfo menuItemInfo) { + RouterLink link = new RouterLink(); + link.addClassNames("flex", "mx-s", "p-s", "relative", "text-secondary"); + link.setRoute(menuItemInfo.view()); + + Span icon = new Span(); + icon.addClassNames("me-s", "text-l"); + if (!menuItemInfo.iconClass().isEmpty()) { + icon.addClassNames(menuItemInfo.iconClass()); + } + + Span text = new Span(menuItemInfo.text()); + text.addClassNames("font-medium", "text-s"); + + link.add(icon, text); + return link; + } + + private Component createHeaderContent() { + DrawerToggle toggle = new DrawerToggle(); + toggle.addClassName("text-secondary"); + toggle.addThemeVariants(ButtonVariant.LUMO_CONTRAST); + toggle.getElement().setAttribute("aria-label", "Menu toggle"); + + viewTitle = new H1(); + viewTitle.addClassNames("m-0", "text-l"); + + Header header = new Header(toggle, viewTitle); + header.addClassNames("bg-base", "border-b", "border-contrast-10", "box-border", "flex", + "h-xl", "items-center", "w-full"); + return header; + } + + private Component createDrawerContent() { + H2 appName = new H2("Logviewer"); + appName.addClassNames("flex", "items-center", "h-xl", "m-0", "px-m", "text-m"); + + com.vaadin.flow.component.html.Section section = new com.vaadin.flow.component.html.Section( + appName, createNavigation(), createFooter()); + section.addClassNames("flex", "flex-col", "items-stretch", "max-h-full", "min-h-full"); + return section; + } + + private Nav createNavigation() { + Nav nav = new Nav(); + nav.addClassNames("border-b", "border-contrast-10", "flex-grow", "overflow-auto"); + nav.getElement().setAttribute("aria-labelledby", "views"); + + H3 views = new H3("Views"); + views.addClassNames("flex", "h-m", "items-center", "mx-m", "my-0", "text-s", + "text-tertiary"); + views.setId("views"); + + createLinks().forEach(nav::add); + + return nav; + } + + private List createLinks() { + return Stream + .of(new MenuItemInfo("Logs", "la la-globe", LogsView.class), + new MenuItemInfo("Streamed", "la la-globe", StreamedView.class), + new MenuItemInfo("User Management", "la la-file", UserManagement.class)) + .filter(this::checkAccess) + .map(MainLayout::createLink) + .toList(); + } + + private boolean checkAccess(MenuItemInfo menuItemInfo) { + final Class view = menuItemInfo.view; + final AllowedRoles annotation = view.getAnnotation(AllowedRoles.class); + + if (annotation == null) { + LoggerFactory.getLogger(MainLayout.class) + .warn("Class {} not properly secured with Annotation", view); + return false; + } + + final Set roles = Set.of(annotation.roles()); + + return !Sets.intersection(this.authenticatedUser.getRoles(), roles).isEmpty(); + } + + private Footer createFooter() { + Footer layout = new Footer(); + layout.addClassNames("flex", "items-center", "my-s", "px-m", "py-xs"); + + Users user = this.authenticatedUser.get(); + + Avatar avatar = new Avatar(user.getUsername()); + avatar.addClassNames("me-xs"); + + ContextMenu userMenu = new ContextMenu(avatar); + userMenu.setOpenOnClick(true); + userMenu.addItem("Logout", e -> authenticatedUser.logout()); + + Span name = new Span(user.getUsername()); + name.addClassNames("font-medium", "text-s", "text-secondary"); + + layout.add(avatar, name); + + return layout; + } + + @Override + protected void afterNavigation() { + super.afterNavigation(); + viewTitle.setText(getCurrentPageTitle()); + } + + private String getCurrentPageTitle() { + PageTitle title = getContent().getClass().getAnnotation(PageTitle.class); + return title == null ? "" : title.value(); + } + + + private record MenuItemInfo(String text, String iconClass, Class view) { + } +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/views/logs/LogsView.java b/logviewer/src/main/java/org/togetherjava/logwatcher/views/logs/LogsView.java new file mode 100644 index 0000000000..f9931bcd8a --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/views/logs/LogsView.java @@ -0,0 +1,125 @@ +package org.togetherjava.logwatcher.views.logs; + +import com.vaadin.flow.component.HasValue; +import com.vaadin.flow.component.combobox.ComboBox; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.textfield.TextArea; +import com.vaadin.flow.data.provider.DataProvider; +import com.vaadin.flow.data.renderer.TextRenderer; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.router.RouteAlias; +import org.slf4j.LoggerFactory; +import org.togetherjava.logwatcher.accesscontrol.AllowedRoles; +import org.togetherjava.logwatcher.accesscontrol.Role; +import org.togetherjava.logwatcher.util.LogReader; +import org.togetherjava.logwatcher.util.NotificationUtils; +import org.togetherjava.logwatcher.views.MainLayout; + +import javax.annotation.security.PermitAll; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * The Logs View in the Browser + */ + +@PageTitle("Logs") +@Route(value = "logs", layout = MainLayout.class) +@RouteAlias(value = "", layout = MainLayout.class) +@AllowedRoles(roles = {Role.USER}) +@PermitAll +public class LogsView extends HorizontalLayout { + + /** + * Field where the events are displayed + */ + private final TextArea text = new TextArea(); + + private final transient LogReader watcher; + + public LogsView(LogReader watcher) { + this.watcher = watcher; + addClassName("hello-world-view"); + + final ComboBox logs = createComboBox(); + logs.getOptionalValue().ifPresent(this::fillTextField); + + this.text.setWidthFull(); + add(logs, this.text); + } + + /** + * Creates the Combobox of Logfile-Names where the User can choose what File to view + * + * @return The created Combobox + */ + private ComboBox createComboBox() { + final ComboBox logs = new ComboBox<>("Log-Files"); + logs.setAllowCustomValue(false); + logs.setRenderer(new TextRenderer<>(p -> p.getFileName().toString())); + + final List logFiles = getLogFiles(); + logs.setItems(DataProvider.ofCollection(logFiles)); + logFiles.stream().findFirst().ifPresent(logs::setValue); + + logs.addValueChangeListener(this::onValueChange); + return logs; + } + + /** + * When User chooses another Logfile, reload the Log and set it in the textField + * + * @param event Generated Event, containing old and new Value + */ + private void onValueChange(HasValue.ValueChangeEvent event) { + if (Objects.equals(event.getOldValue(), event.getValue())) { + return; + } + + fillTextField(event.getValue()); + } + + /** + * Reload the Log and set it in the textField + * + * @param logFileName Name of the Logfile + */ + private void fillTextField(final Path logFileName) { + this.text.setValue(String.join("\n", getLogEntries(logFileName))); + } + + /** + * Gathers all available Logfiles + * + * @return Names of available Logfiles + */ + private List getLogFiles() { + try { + return this.watcher.getLogs(); + } catch (final UncheckedIOException e) { + LoggerFactory.getLogger(LogsView.class).error("Exception while gathering LogFiles", e); + NotificationUtils.getNotificationForError(e).open(); + return Collections.emptyList(); + } + } + + /** + * Reads the log for the given Logfile + * + * @param logFile Name of the log to read + * @return Contents of the LogFile + */ + private List getLogEntries(final Path logFile) { + try { + return this.watcher.readLog(logFile); + } catch (final UncheckedIOException e) { + LoggerFactory.getLogger(LogsView.class).error("Exception while gathering LogFiles", e); + NotificationUtils.getNotificationForError(e).open(); + return Collections.emptyList(); + } + } +} diff --git a/logviewer/src/main/java/org/togetherjava/logwatcher/views/logs/StreamedView.java b/logviewer/src/main/java/org/togetherjava/logwatcher/views/logs/StreamedView.java new file mode 100644 index 0000000000..cbba4409e2 --- /dev/null +++ b/logviewer/src/main/java/org/togetherjava/logwatcher/views/logs/StreamedView.java @@ -0,0 +1,153 @@ +package org.togetherjava.logwatcher.views.logs; + +import com.vaadin.componentfactory.EnhancedDialog; +import com.vaadin.flow.component.*; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.data.renderer.LocalDateTimeRenderer; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.SessionDestroyEvent; +import com.vaadin.flow.server.VaadinService; +import org.togetherjava.logwatcher.accesscontrol.AllowedRoles; +import org.togetherjava.logwatcher.accesscontrol.Role; +import org.togetherjava.logwatcher.constants.LogEventsConstants; +import org.togetherjava.logwatcher.logs.LogRepository; +import org.togetherjava.logwatcher.views.MainLayout; +import org.togetherjava.logwatcher.watcher.StreamWatcher; +import org.togetherjava.tjbot.db.generated.tables.pojos.Logevents; +import org.vaadin.crudui.crud.impl.GridCrud; + +import javax.annotation.security.PermitAll; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +/** + * The Logs View in the Browser + */ + +@PageTitle("Streamed") +@Route(value = "streamed", layout = MainLayout.class) +@AllowedRoles(roles = {Role.USER}) +@PermitAll +public class StreamedView extends HorizontalLayout { + + private final GridCrud grid = new GridCrud<>(Logevents.class); + private final UUID uuid = UUID.randomUUID(); + + public StreamedView(LogRepository logs) { + addClassName("hello-world-view"); + + add(new Button("Change Columns", this::onChangeColumns), this.grid); + + this.grid.setOperations(logs::findAll, null, null, null); + this.grid.setAddOperationVisible(false); + this.grid.setDeleteOperationVisible(false); + this.grid.setUpdateOperationVisible(false); + + + this.grid.getGrid() + .setColumns(LogEventsConstants.FIELD_INSTANT, LogEventsConstants.FIELD_LOGGER_NAME, + LogEventsConstants.FIELD_MESSAGE); + setInstantFormatter(); + this.grid.getGrid().getColumns().forEach(c -> c.setAutoWidth(true)); + this.grid.getGrid().getColumns().forEach(c -> c.setResizable(true)); + this.grid.getGrid().recalculateColumnWidths(); + this.grid.getGrid().setColumnReorderingAllowed(true); + + VaadinService.getCurrent().addSessionDestroyListener(this::onDestroy); + final UI ui = UI.getCurrent(); + StreamWatcher.addSubscription(this.uuid, () -> ui.access(this.grid::refreshGrid)); + } + + + private void onDestroy(SessionDestroyEvent event) { + removeHook(); + } + + @Override + protected void onDetach(DetachEvent detachEvent) { + removeHook(); + super.onDetach(detachEvent); + } + + private void removeHook() { + StreamWatcher.removeSubscription(this.uuid); + } + + private void setInstantFormatter() { + final Grid innerGrid = this.grid.getGrid(); + final Optional> column = + Optional.ofNullable(innerGrid.getColumnByKey(LogEventsConstants.FIELD_INSTANT)); + if (column.isEmpty()) { + return; + } + + final Grid.Column instant = column.orElseThrow(); + innerGrid.removeColumn(instant); + + final String[] keys = + innerGrid.getColumns().stream().map(Grid.Column::getKey).toArray(String[]::new); + innerGrid.removeAllColumns(); + + + innerGrid + .addColumn(new LocalDateTimeRenderer<>(Logevents::getTime, + DateTimeFormatter.ofPattern("yyy-MM-dd HH:mm:ss.SSS"))) + .setHeader("Instant") + .setComparator(Comparator.comparing(Logevents::getTime)) + .setKey(LogEventsConstants.FIELD_INSTANT); + + innerGrid.addColumns(keys); + } + + private void onChangeColumns(ClickEvent