From ee8c8423e7df094f5b1839f6aa896bcb41232d6b Mon Sep 17 00:00:00 2001 From: "nise.moe" Date: Sat, 15 Jun 2024 01:49:36 +0200 Subject: [PATCH] metabase integration --- .../nise/controller/MetabaseController.kt | 69 ++++++++ .../nise/integrations/MetabaseService.kt | 164 ++++++++++++++++++ nise-frontend/src/app/app-routing.module.ts | 3 + .../src/app/metabase/metabase.component.css | 0 .../src/app/metabase/metabase.component.html | 23 +++ .../src/app/metabase/metabase.component.ts | 76 ++++++++ 6 files changed, 335 insertions(+) create mode 100644 nise-backend/src/main/kotlin/com/nisemoe/nise/controller/MetabaseController.kt create mode 100644 nise-backend/src/main/kotlin/com/nisemoe/nise/integrations/MetabaseService.kt create mode 100644 nise-frontend/src/app/metabase/metabase.component.css create mode 100644 nise-frontend/src/app/metabase/metabase.component.html create mode 100644 nise-frontend/src/app/metabase/metabase.component.ts diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/MetabaseController.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/MetabaseController.kt new file mode 100644 index 0000000..9f707fa --- /dev/null +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/MetabaseController.kt @@ -0,0 +1,69 @@ +package com.nisemoe.nise.controller + +import com.nisemoe.nise.integrations.MetabaseService +import com.nisemoe.nise.service.AuthService +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("metabase") +class MetabaseController( + private val authService: AuthService, + private val metabaseService: MetabaseService +) { + + data class GetMetabaseUserResponse( + val email: String + ) + + @GetMapping("status") + fun getMetabaseStatus(): ResponseEntity { + if(!this.authService.isLoggedIn() || !this.authService.isAdmin()) + return ResponseEntity.badRequest().build() + + val currentUser = this.authService.getCurrentUser() + + val metabaseUser = this.metabaseService.getUser(currentUser.username) + ?: return ResponseEntity.notFound().build() + + return ResponseEntity.ok(GetMetabaseUserResponse(metabaseUser)) + } + + data class CreateMetabaseUserResponse( + val email: String, + val password: String + ) + + @GetMapping("create") + fun createMetabaseUser(): ResponseEntity { + if(!this.authService.isLoggedIn() || !this.authService.isAdmin()) + return ResponseEntity.badRequest().build() + + val currentUser = this.authService.getCurrentUser() + + try { + val newUser = this.metabaseService.createNewUser(currentUser.username) + return ResponseEntity.ok(CreateMetabaseUserResponse(newUser.email, newUser.password)) + } catch (e: Exception) { + return ResponseEntity.badRequest().build() + } + } + + @GetMapping("reset") + fun resetMetabaseUser(): ResponseEntity { + if(!this.authService.isLoggedIn() || !this.authService.isAdmin()) + return ResponseEntity.badRequest().build() + + val currentUser = this.authService.getCurrentUser() + + try { + val newUser = this.metabaseService.resetPassword(currentUser.username) + return ResponseEntity.ok(CreateMetabaseUserResponse(newUser.email, newUser.password)) + } catch (e: Exception) { + return ResponseEntity.badRequest().build() + } + } + +} \ No newline at end of file diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/integrations/MetabaseService.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/integrations/MetabaseService.kt new file mode 100644 index 0000000..d6773e5 --- /dev/null +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/integrations/MetabaseService.kt @@ -0,0 +1,164 @@ +package com.nisemoe.nise.integrations + +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.springframework.beans.factory.InitializingBean +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.security.SecureRandom +import java.util.* + +@Service +class MetabaseService : InitializingBean { + + @Value("\${METABASE_API_KEY}") + private lateinit var metabaseApiKey: String + + @Value("\${METABASE_URL:https://neko.nise.moe}") + private lateinit var metabaseUrl: String + + override fun afterPropertiesSet() { + if (this.metabaseApiKey.isBlank()) + throw IllegalArgumentException("METABASE_API_KEY is not set") + } + + private val httpClient: HttpClient = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .build() + + data class CreateNewUserResponse( + val email: String, + val password: String + ) + + private fun getUserDataFromUsername(name: String): UserData? { + val email = usernameToEmail(name) + + val request = HttpRequest.newBuilder() + .uri(URI.create("$metabaseUrl/api/user/?query=$email")) + .GET() + .header("Content-type", "application/json") + .header("x-api-key", this.metabaseApiKey) + .build() + + val response = HttpClient.newBuilder().build().send(request, HttpResponse.BodyHandlers.ofString()) + + return if (response.statusCode() == 200) { + val json = Json { ignoreUnknownKeys = true } + val responseBody = json.decodeFromString(response.body()) + responseBody.data.firstOrNull { it.email == email } + } else { + null + } + } + + private fun generateRandomPassword(length: Int = 16): String { + val charPool = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + val secureRandom = SecureRandom() + return (1..length) + .map { secureRandom.nextInt(charPool.length) } + .map(charPool::get) + .joinToString("") + } + + fun createNewUser(name: String): CreateNewUserResponse { + val email = usernameToEmail(name) + val password = generateRandomPassword() + + val requestBody = buildJsonObject { + put("email", email) + put("password", password) + } + + val request = HttpRequest.newBuilder() + .uri(URI.create("$metabaseUrl/api/user/")) + .POST(HttpRequest.BodyPublishers.ofString(Json.encodeToString(requestBody))) + .header("Content-type", "application/json") + .header("x-api-key", this.metabaseApiKey) + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + + if (response.statusCode() == 200) { + val responseBody = Json.decodeFromString>(response.body()) + if (responseBody["email"] == email && + responseBody["is_active"] == true && + responseBody["is_superuser"] == false + ) { + return CreateNewUserResponse( + email = email, + password = password + ) + } else { + throw RuntimeException("Failed to validate user creation response: ${response.body()}") + } + } else if (response.statusCode() == 400) { + throw IllegalArgumentException("User already exists.") + } else { + throw RuntimeException("Failed to create user: ${response.body()}") + } + } + + private fun usernameToEmail(name: String): String { + val cleanedName = name.replace(" ", "_").lowercase(Locale.getDefault()) + val email = "$cleanedName@nise.moe" + return email + } + + @Serializable + data class GetUserResponse( + val data: List + ) + + @Serializable + data class UserData( + val id: Int, + val email: String + ) + + /** + * Fetches a user from Metabase by their username. + * @param name The username to fetch. + * @return The user's email if found, null otherwise. + */ + fun getUser(name: String): String? { + val user = getUserDataFromUsername(name) + return user?.email + } + + fun resetPassword(name: String): CreateNewUserResponse { + val user = getUserDataFromUsername(name) ?: throw IllegalArgumentException("User not found.") + val password = generateRandomPassword() + + val requestBody = buildJsonObject { + put("password", password) + } + + val request = HttpRequest.newBuilder() + .uri(URI.create("$metabaseUrl/api/user/${user.id}/password")) + .PUT(HttpRequest.BodyPublishers.ofString(Json.encodeToString(requestBody))) + .header("Content-type", "application/json") + .header("x-api-key", this.metabaseApiKey) + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + + if (response.statusCode() == 204) { + return CreateNewUserResponse( + email = user.email, + password = password + ) + } else { + throw RuntimeException("Failed to reset password: ${response.body()}") + } + } + + +} diff --git a/nise-frontend/src/app/app-routing.module.ts b/nise-frontend/src/app/app-routing.module.ts index 474969e..4b2d303 100644 --- a/nise-frontend/src/app/app-routing.module.ts +++ b/nise-frontend/src/app/app-routing.module.ts @@ -12,6 +12,7 @@ import {BanlistComponent} from "./banlist/banlist.component"; import {ProfileComponent} from "./profile/profile.component"; import {ApiComponent} from "./api/api.component"; import {ApiPythonComponent} from "./api-python/api-python.component"; +import {MetabaseComponent} from "./metabase/metabase.component"; const routes: Routes = [ {path: 'sus/:f', component: ViewSuspiciousScoresComponent, title: '/sus/'}, @@ -36,6 +37,8 @@ const routes: Routes = [ {path: 'docs', component: ApiComponent, title: '/api/ explanation'}, {path: 'docs/py', component: ApiPythonComponent, title: 'python lib'}, + {path: 'neko', component: MetabaseComponent, title: 'metabase integration'}, + {path: '**', component: HomeComponent, title: '/nise.moe/'}, ]; diff --git a/nise-frontend/src/app/metabase/metabase.component.css b/nise-frontend/src/app/metabase/metabase.component.css new file mode 100644 index 0000000..e69de29 diff --git a/nise-frontend/src/app/metabase/metabase.component.html b/nise-frontend/src/app/metabase/metabase.component.html new file mode 100644 index 0000000..d8bd5c1 --- /dev/null +++ b/nise-frontend/src/app/metabase/metabase.component.html @@ -0,0 +1,23 @@ +
+

# /neko/ integration

+ + + +

it looks look you already have a neko.nise.moe user. feel + free to use it any time.

+ +
+ +

it looks like you don't have a neko.nise.moe user. you can + create one here.

+ +
+ +

email: {{ this.createResponse.email }}

+

password: {{ this.createResponse.password }} save, wont be shown again. +

+

^ use the above credentials to login @ neko.nise.moe (feel free to change the password afterwards)

+
+
+
diff --git a/nise-frontend/src/app/metabase/metabase.component.ts b/nise-frontend/src/app/metabase/metabase.component.ts new file mode 100644 index 0000000..1d55c8a --- /dev/null +++ b/nise-frontend/src/app/metabase/metabase.component.ts @@ -0,0 +1,76 @@ +import { Component } from '@angular/core'; +import {HttpClient} from "@angular/common/http"; +import {environment} from "../../environments/environment"; +import {NgIf} from "@angular/common"; +import {CuteLoadingComponent} from "../../corelib/components/cute-loading/cute-loading.component"; + +interface CreateMetabaseUserResponse { + email: string; + password: string; +} + +interface GetMetabaseUserResponse { + email: string; +} + +@Component({ + selector: 'app-metabase', + standalone: true, + imports: [ + NgIf, + CuteLoadingComponent + ], + templateUrl: './metabase.component.html', + styleUrl: './metabase.component.css' +}) +export class MetabaseComponent { + + isLoading: boolean = true; + user: GetMetabaseUserResponse | null = null; + createResponse: CreateMetabaseUserResponse | null = null; + + constructor(private httpClient: HttpClient) { } + + ngOnInit() { + this.getUser(); + } + + resetPassword() { + this.httpClient.get(`${environment.apiUrl}/metabase/reset`, { withCredentials: true }) + .subscribe({ + next: (data) => { + this.createResponse = data; + }, + error: () => { + this.createResponse = null; + } + }); + } + + getUser() { + this.httpClient.get(`${environment.apiUrl}/metabase/status`, { withCredentials: true }) + .subscribe({ + next: (data) => { + this.user = data; + this.isLoading = false; + }, + error: () => { + this.user = null; + this.isLoading = false; + } + }); + } + + createNewUser() { + this.httpClient.get(`${environment.apiUrl}/metabase/create`, { withCredentials: true }) + .subscribe({ + next: (data) => { + this.createResponse = data; + }, + error: () => { + this.createResponse = null; + } + }); + } + +}