diff --git a/.gitignore b/.gitignore index 13bbb79..b199ffd 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,5 @@ out/ /data/ /backend/src/main/resources/static/ /docker/docker-compose.yml -/.gameyfin/ \ No newline at end of file +/.gameyfin/ +/frontend/ diff --git a/build.gradle.kts b/build.gradle.kts index 87099df..480b1d3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,63 +1,74 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - id("org.springframework.boot") version "3.2.2" - id("io.spring.dependency-management") version "1.1.4" - id("com.vaadin") version "24.3.3" - kotlin("jvm") version "1.9.22" - kotlin("plugin.spring") version "1.9.22" - kotlin("plugin.jpa") version "1.9.22" + id("org.springframework.boot") version "3.2.2" + id("io.spring.dependency-management") version "1.1.4" + id("com.vaadin") version "24.3.3" + kotlin("jvm") version "1.9.22" + kotlin("plugin.spring") version "1.9.22" + kotlin("plugin.jpa") version "1.9.22" } group = "de.grimsi" -version = "0.0.1-SNAPSHOT" +version = "2.0.0-SNAPSHOT" java { - sourceCompatibility = JavaVersion.VERSION_21 + sourceCompatibility = JavaVersion.VERSION_21 } configurations { - compileOnly { - extendsFrom(configurations.annotationProcessor.get()) - } + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } } repositories { - mavenCentral() + mavenCentral() + maven { + name = "Vaadin Addons" + url = uri("https://maven.vaadin.com/vaadin-addons") + } } extra["vaadinVersion"] = "24.3.3" dependencies { - implementation("org.springframework.boot:spring-boot-starter-actuator") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-oauth2-client") - implementation("org.springframework.boot:spring-boot-starter-security") - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin") - implementation("com.vaadin:vaadin-spring-boot-starter") - implementation("org.jetbrains.kotlin:kotlin-reflect") - developmentOnly("org.springframework.boot:spring-boot-devtools") - runtimeOnly("com.h2database:h2") - runtimeOnly("io.micrometer:micrometer-registry-prometheus") - annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.springframework.security:spring-security-test") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") + + // Frontend + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("com.vaadin:vaadin-spring-boot-starter") + implementation("com.github.mvysny.karibudsl:karibu-dsl-v23:2.1.0") + implementation("com.github.mvysny.karibu-tools:karibu-tools-23:0.19") + implementation("com.flowingcode.addons:font-awesome-iron-iconset:5.2.2") + implementation("org.jetbrains.kotlin:kotlin-reflect") + + // Development + developmentOnly("org.springframework.boot:spring-boot-devtools") + runtimeOnly("com.h2database:h2") + runtimeOnly("io.micrometer:micrometer-registry-prometheus") + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") } dependencyManagement { - imports { - mavenBom("com.vaadin:vaadin-bom:${property("vaadinVersion")}") - } + imports { + mavenBom("com.vaadin:vaadin-bom:${property("vaadinVersion")}") + } } tasks.withType { - kotlinOptions { - freeCompilerArgs += "-Xjsr305=strict" - jvmTarget = "21" - } + kotlinOptions { + freeCompilerArgs += "-Xjsr305=strict" + jvmTarget = "21" + } } tasks.withType { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000..44a1abe --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,31 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# +version: "1.0" + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +projectJDK: "21" #(Applied in CI/CD pipeline) + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +#Specify Qodana linter for analysis (Applied in CI/CD pipeline) +linter: jetbrains/qodana-jvm:latest diff --git a/src/main/bundles/README.md b/src/main/bundles/README.md new file mode 100644 index 0000000..581b703 --- /dev/null +++ b/src/main/bundles/README.md @@ -0,0 +1,32 @@ +This directory is automatically generated by Vaadin and contains the pre-compiled +frontend files/resources for your project (frontend development bundle). + +It should be added to Version Control System and committed, so that other developers +do not have to compile it again. + +Frontend development bundle is automatically updated when needed: +- an npm/pnpm package is added with @NpmPackage or directly into package.json +- CSS, JavaScript or TypeScript files are added with @CssImport, @JsModule or @JavaScript +- Vaadin add-on with front-end customizations is added +- Custom theme imports/assets added into 'theme.json' file +- Exported web component is added. + +If your project development needs a hot deployment of the frontend changes, +you can switch Flow to use Vite development server (default in Vaadin 23.3 and earlier versions): +- set `vaadin.frontend.hotdeploy=true` in `application.properties` +- configure `vaadin-maven-plugin`: +``` + + true + +``` +- configure `jetty-maven-plugin`: +``` + + + true + + +``` + +Read more [about Vaadin development mode](https://vaadin.com/docs/next/configuration/development-mode/#pre-compiled-front-end-bundle-for-faster-start-up). \ No newline at end of file diff --git a/src/main/bundles/dev.bundle b/src/main/bundles/dev.bundle new file mode 100644 index 0000000..8e8d4b3 Binary files /dev/null and b/src/main/bundles/dev.bundle differ diff --git a/src/main/kotlin/de/grimsi/gameyfin/GameyfinApplication.kt b/src/main/kotlin/de/grimsi/gameyfin/GameyfinApplication.kt index 06b8efa..0c15054 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/GameyfinApplication.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/GameyfinApplication.kt @@ -3,9 +3,10 @@ package de.grimsi.gameyfin import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication + @SpringBootApplication class GameyfinApplication fun main(args: Array) { runApplication(*args) -} +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/security/SecurityConfiguration.kt b/src/main/kotlin/de/grimsi/gameyfin/security/SecurityConfiguration.kt new file mode 100644 index 0000000..29e8a22 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/security/SecurityConfiguration.kt @@ -0,0 +1,59 @@ +package de.grimsi.gameyfin.security + +import com.vaadin.flow.spring.security.VaadinWebSecurity +import de.grimsi.gameyfin.ui.views.LoginView +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.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.provisioning.UserDetailsManager +import org.springframework.security.web.util.matcher.AntPathRequestMatcher + + +@EnableWebSecurity +@Configuration +class SecurityConfiguration : VaadinWebSecurity() { + @Throws(Exception::class) + override fun configure(http: HttpSecurity) { + // Configure your static resources with public access before calling super.configure(HttpSecurity) as it adds final anyRequest matcher + http.authorizeHttpRequests { auth: AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry -> + auth.requestMatchers(AntPathRequestMatcher("/public/**")).permitAll() + } + + // Configure your static resources with public access before calling + // super.configure(HttpSecurity) as it adds final anyRequest matcher + + super.configure(http) + + // This is important to register your login view to the navigation access control mechanism: + setLoginView(http, LoginView::class.java) + } + + @Throws(Exception::class) + public override fun configure(web: WebSecurity) { + super.configure(web) + } + + /** + * TODO: Just for testing + */ + @Bean + fun userDetailsService(): UserDetailsManager { + val user: UserDetails = + User.withUsername("user") + .password("{noop}user") + .roles("USER") + .build() + val admin: UserDetails = + User.withUsername("admin") + .password("{noop}admin") + .roles("ADMIN") + .build() + return InMemoryUserDetailsManager(user, admin) + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/security/UserDetailsExtensions.kt b/src/main/kotlin/de/grimsi/gameyfin/security/UserDetailsExtensions.kt new file mode 100644 index 0000000..a4758fd --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/security/UserDetailsExtensions.kt @@ -0,0 +1,11 @@ +package de.grimsi.gameyfin.security + +import org.springframework.security.core.userdetails.UserDetails + +fun UserDetails.hasRole(role: String): Boolean { + return this.authorities.map { a -> a.authority }.contains("ROLE_".plus(role)) +} + +fun UserDetails.isAdmin(): Boolean { + return hasRole("ADMIN") +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/ui/layouts/MainLayout.kt b/src/main/kotlin/de/grimsi/gameyfin/ui/layouts/MainLayout.kt new file mode 100644 index 0000000..1c8ffe5 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/ui/layouts/MainLayout.kt @@ -0,0 +1,87 @@ +package de.grimsi.gameyfin.ui.layouts + +import com.flowingcode.vaadin.addons.fontawesome.FontAwesome +import com.github.mvysny.karibudsl.v10.* +import com.github.mvysny.kaributools.tooltip +import com.vaadin.flow.component.ClickEvent +import com.vaadin.flow.component.applayout.AppLayout +import com.vaadin.flow.component.menubar.MenuBarVariant +import com.vaadin.flow.component.orderedlayout.FlexComponent +import com.vaadin.flow.component.orderedlayout.HorizontalLayout +import com.vaadin.flow.router.RouterLayout +import com.vaadin.flow.spring.security.AuthenticationContext +import de.grimsi.gameyfin.security.isAdmin +import de.grimsi.gameyfin.ui.resources.PublicResources +import org.springframework.security.core.userdetails.UserDetails + + +class MainLayout(@field:Transient private val authContext: AuthenticationContext) : KComposite(), RouterLayout { + + + private val appLayout: AppLayout + + init { + val user = authContext.getAuthenticatedUser(UserDetails::class.java).get() + + appLayout = ui { + + appLayout { + navbar { + flexLayout { + setWidthFull() + alignItems = FlexComponent.Alignment.CENTER + + image(PublicResources.GAMEYFIN_LOGO_WHITE_BORDER.path) { + setWidthFull() + height = "40px" + className = "header-logo" + } + + horizontalLayout { + alignItems = FlexComponent.Alignment.CENTER + + val a = avatar(user.username) { + tooltip = user.username + abbreviation = user.username.take(2).uppercase() + colorIndex = user.username[0].code.toByte().mod(6) + } + + menuBar { + addThemeVariants(MenuBarVariant.LUMO_ICON) + item(a) { + item(menuItem(FontAwesome.Solid.USER, "Profile")) + if (user.isAdmin()) item(menuItem(FontAwesome.Solid.COG, "Administration")) + item(menuItem(FontAwesome.Solid.QUESTION_CIRCLE, "Help")) + item(menuItem(FontAwesome.Solid.SIGN_OUT, "Sign out") { _ -> authContext.logout() }) + } + } + } + } + } + } + } + } + + private fun menuItem(icon: FontAwesome.Solid, title: String): HorizontalLayout { + return HorizontalLayout().apply { + alignItems = FlexComponent.Alignment.CENTER + justifyContentMode = FlexComponent.JustifyContentMode.START + + val faIcon = icon.create() + faIcon.setSize("var(--lumo-icon-size-s)") + add(faIcon) + + text(title) + } + } + + private fun menuItem( + icon: FontAwesome.Solid, + title: String, + action: (ClickEvent) -> Unit + ): HorizontalLayout { + return menuItem(icon, title).apply { + onLeftClick(action) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/ui/resources/PublicResources.kt b/src/main/kotlin/de/grimsi/gameyfin/ui/resources/PublicResources.kt new file mode 100644 index 0000000..8a85dcb --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/ui/resources/PublicResources.kt @@ -0,0 +1,6 @@ +package de.grimsi.gameyfin.ui.resources + +enum class PublicResources(val path: String) { + GAMEYFIN_LOGO_WHITE("public/images/Gameyfin_Logo_White.svg"), + GAMEYFIN_LOGO_WHITE_BORDER("public/images/Gameyfin_Logo_White_BORDER.svg") +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/ui/views/LoginView.kt b/src/main/kotlin/de/grimsi/gameyfin/ui/views/LoginView.kt new file mode 100644 index 0000000..6b6d06e --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/ui/views/LoginView.kt @@ -0,0 +1,50 @@ +package de.grimsi.gameyfin.ui.views + +import com.github.mvysny.karibudsl.v10.image +import com.github.mvysny.karibudsl.v10.loginForm +import com.vaadin.flow.component.login.LoginForm +import com.vaadin.flow.component.orderedlayout.FlexComponent +import com.vaadin.flow.component.orderedlayout.VerticalLayout +import com.vaadin.flow.router.BeforeEnterEvent +import com.vaadin.flow.router.BeforeEnterObserver +import com.vaadin.flow.router.PageTitle +import com.vaadin.flow.router.Route +import com.vaadin.flow.server.auth.AnonymousAllowed +import de.grimsi.gameyfin.ui.resources.PublicResources + +@Route("login") +@PageTitle("Login") +@AnonymousAllowed +class LoginView : VerticalLayout(), BeforeEnterObserver { + + private var login: LoginForm + + init { + setSizeFull() + justifyContentMode = FlexComponent.JustifyContentMode.CENTER + alignItems = FlexComponent.Alignment.CENTER + + image { + height = "100px" + src = PublicResources.GAMEYFIN_LOGO_WHITE_BORDER.path + setAlt("Gameyfin") + } + + login = loginForm { + addClassName("login-view") + action = "login" + } + } + + override fun beforeEnter(event: BeforeEnterEvent?) { + if (event != null) { + if (event.location + .queryParameters + .parameters + .containsKey("error")) { + login.isError = true + } + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/ui/views/MainView.kt b/src/main/kotlin/de/grimsi/gameyfin/ui/views/MainView.kt new file mode 100644 index 0000000..c2ba575 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/ui/views/MainView.kt @@ -0,0 +1,22 @@ +package de.grimsi.gameyfin.ui.views + +import com.github.mvysny.karibudsl.v10.h1 +import com.github.mvysny.karibudsl.v10.pre +import com.github.mvysny.karibudsl.v10.verticalLayout +import com.vaadin.flow.component.orderedlayout.VerticalLayout +import com.vaadin.flow.router.Route +import de.grimsi.gameyfin.ui.layouts.MainLayout +import jakarta.annotation.security.PermitAll + + +@Route("", layout = MainLayout::class) +@PermitAll +class MainView : VerticalLayout() { + + init { + verticalLayout { + h1 { text = "Gameyfin main page" } + pre { text = "Work in progress" } + } + } +} \ No newline at end of file diff --git a/src/main/resources/META-INF/resources/public/images/Gameyfin_Logo_White.svg b/src/main/resources/META-INF/resources/public/images/Gameyfin_Logo_White.svg new file mode 100644 index 0000000..290ccab --- /dev/null +++ b/src/main/resources/META-INF/resources/public/images/Gameyfin_Logo_White.svg @@ -0,0 +1,16 @@ + + + + + Element 11 + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/META-INF/resources/public/images/Gameyfin_Logo_White_Border.svg b/src/main/resources/META-INF/resources/public/images/Gameyfin_Logo_White_Border.svg new file mode 100644 index 0000000..82aaabc --- /dev/null +++ b/src/main/resources/META-INF/resources/public/images/Gameyfin_Logo_White_Border.svg @@ -0,0 +1,18 @@ + + + + + Element 10 + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b13789..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..84cceed --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,5 @@ +vaadin.whitelisted-packages: + - com.vaadin + - org.vaadin + - dev.hilla + - com.flowingcode \ No newline at end of file