Compare commits
9 commits
feature/au
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ac198a695 | ||
|
|
49c472f748 | ||
|
|
b8bfb0b68f | ||
|
|
71f81a97dd | ||
|
|
de33790206 | ||
|
|
57e91c6742 | ||
|
|
a1c01b5112 | ||
|
|
ad90c4c7d7 | ||
|
|
d1b25af722 |
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Gradle Build-Ordner
|
||||||
|
build/
|
||||||
|
.gradle/
|
||||||
|
|
||||||
|
# Build-Ordner von Submodulen (falls vorhanden)
|
||||||
|
*/build/
|
||||||
|
|
||||||
|
# IntelliJ / IDE Dateien
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.iws
|
||||||
|
*.ipr
|
||||||
|
out/
|
||||||
Binary file not shown.
Binary file not shown.
176
README.md
176
README.md
|
|
@ -1,33 +1,171 @@
|
||||||
# Kidio_backend
|
# Kidio Backend
|
||||||
|
|
||||||
This project was created using the [Ktor Project Generator](https://start.ktor.io).
|
Das Kidio-Backend ist eine gamifizierte Lösung zur Verwaltung von Bildschirmzeit für Kinder. Die Anwendung basiert auf dem **Ktor**-Framework in Kotlin und kombiniert **PostgreSQL** für die Persistenz mit **Redis** als Caching-Layer für schnelle Zugriffszeiten.
|
||||||
|
|
||||||
Here are some useful links to get you started:
|
Kinder können durch das Lösen von Lernaufgaben (z. B. Mathe-Aufgaben) zusätzliche Bildschirmzeit erwerben, während das System ihre verbrauchte Bildschirmzeit synchronisiert und bei Ablauf sperrt.
|
||||||
|
|
||||||
* [Ktor Documentation](https://ktor.io/docs/home.html)
|
---
|
||||||
* [Ktor GitHub page](https://github.com/ktorio/ktor)
|
|
||||||
* [Ktor Slack chat](https://app.slack.com/client/T09229ZC6/C0A974TJ9). [Request an invite](https://surveys.jetbrains.com/s3/kotlin-slack-sign-up).
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
Here's a list of features included in this project:
|
Hier ist eine Übersicht der implementierten Kernfunktionen des Kidio-Backends:
|
||||||
|
|
||||||
| Name | Description |
|
| Name | Beschreibung |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
|
| **JWT Authentifizierung** | Sichere API-Endpunkte für authentifizierte Benutzer über JSON Web Tokens (JWT). |
|
||||||
|
| **Lernaufgaben (Tasks)** | Abrufen von interaktiven Lernaufgaben mit vordefinierten Antwortmöglichkeiten und Belohnungen (z. B. Minuten). |
|
||||||
|
| **Antwort-Verifizierung** | Überprüfung der vom Kind ausgewählten Antworten. Bei korrekter Antwort wird die Belohnung direkt auf die verbleibende Bildschirmzeit aufgerechnet. |
|
||||||
|
| **Bildschirmzeit-Synchronisation** | Laufender Datenabgleich der genutzten Bildschirmzeit. Sinkt die Zeit auf oder unter Null, wird der Status automatisch auf `isBlocked = true` gesetzt. |
|
||||||
|
| **Zwei-Ebenen-Speicher** | Datenhaltung in **PostgreSQL** (via JetBrains Exposed ORM) kombiniert mit **Redis-Caching** (via Jedis) für optimale Performance bei der Screentime-Abfrage. |
|
||||||
|
| **Fallback-Mechanismus** | Ist die Redis-Instanz nicht erreichbar, fällt das System automatisch und transparent auf PostgreSQL zurück. |
|
||||||
|
|
||||||
## Building & Running
|
---
|
||||||
|
|
||||||
To build or run the project, use one of the following tasks:
|
## Technologiestack
|
||||||
|
|
||||||
| Task | Description |
|
- **Framework**: [Ktor](https://ktor.io/) (Kotlin-basiertes asynchrones Web-Framework)
|
||||||
|-------------------|-------------------|
|
- **Datenbank-ORM**: [JetBrains Exposed](https://github.com/JetBrains/Exposed)
|
||||||
| `./gradlew test` | Run the tests |
|
- **Persistenz**: PostgreSQL
|
||||||
| `./gradlew build` | Build the project |
|
- **Caching**: Redis (via Jedis Client)
|
||||||
| `./gradlew run` | Run the server |
|
- **Sicherheit & Auth**: JWT (Java JWT von auth0)
|
||||||
|
- **Serialization**: Kotlinx Serialization (JSON)
|
||||||
|
- **Testing**: Ktor Server Testing, JUnit
|
||||||
|
|
||||||
If the server starts successfully, you'll see the following output:
|
---
|
||||||
|
|
||||||
|
## API-Endpunkte
|
||||||
|
|
||||||
|
Alle Endpunkte befinden sich unter dem Präfix `/api/v1`.
|
||||||
|
|
||||||
|
### Öffentliche Endpunkte
|
||||||
|
|
||||||
|
#### 1. Health-Check
|
||||||
|
- **Methode**: `GET`
|
||||||
|
- **Pfad**: `/api/v1/health`
|
||||||
|
- **Beschreibung**: Prüft, ob der Backend-Server ordnungsgemäß läuft.
|
||||||
|
- **Antwort**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"message": "Kidio Backend läuft!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Benutzer-Login (Auth-Token abholen)
|
||||||
|
- **Methode**: `POST`
|
||||||
|
- **Pfad**: `/api/v1/auth/login`
|
||||||
|
- **Request-Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": "default_user"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Antwort**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIsIn..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Geschützte Endpunkte (Erfordern JWT im `Authorization: Bearer <Token>` Header)
|
||||||
|
|
||||||
|
#### 3. Alle verfügbaren Aufgaben abrufen
|
||||||
|
- **Methode**: `GET`
|
||||||
|
- **Pfad**: `/api/v1/tasks`
|
||||||
|
- **Beschreibung**: Liefert eine Liste aller verfügbaren Lernaufgaben.
|
||||||
|
- **Antwort**:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "t1",
|
||||||
|
"question": "Was ist 12 + 15?",
|
||||||
|
"options": ["25", "27", "30", "22"],
|
||||||
|
"rewardMinutes": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Antwort überprüfen & Belohnung verbuchen
|
||||||
|
- **Methode**: `POST`
|
||||||
|
- **Pfad**: `/api/v1/tasks/verify`
|
||||||
|
- **Request-Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"taskId": "t1",
|
||||||
|
"selectedAnswer": "27"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Antwort (Erfolg)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"isCorrect": true,
|
||||||
|
"earnedMinutes": 10,
|
||||||
|
"message": "Super gemacht! Du hast 10 Minuten gewonnen."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Antwort (Fehler)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"isCorrect": false,
|
||||||
|
"earnedMinutes": 0,
|
||||||
|
"message": "Schade, das war leider falsch. Versuche es nochmal!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Bildschirmzeit synchronisieren
|
||||||
|
- **Methode**: `POST`
|
||||||
|
- **Pfad**: `/api/v1/screentime/sync`
|
||||||
|
- **Beschreibung**: Zieht die seit dem letzten Abgleich verbrauchten Sekunden von der verfügbaren Bildschirmzeit ab.
|
||||||
|
- **Request-Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": "default_user",
|
||||||
|
"usedSecondsSinceLastSync": 200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Antwort**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"remainingSeconds": 1600,
|
||||||
|
"isBlocked": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Projektkonfiguration und Umgebungsvariablen
|
||||||
|
|
||||||
|
Das Projekt wird über die Datei `src/main/resources/application.yaml` konfiguriert. Folgende Umgebungsvariablen können zur flexiblen Docker- oder Deployment-Konfiguration übergeben werden:
|
||||||
|
|
||||||
|
| Variable | Beschreibung | Standardwert |
|
||||||
|
|----------|--------------|--------------|
|
||||||
|
| `DB_HOST` | Hostname der PostgreSQL-Datenbank | `localhost` / aus `application.yaml` |
|
||||||
|
| `DB_PORT` | Port der PostgreSQL-Datenbank | `5432` / aus `application.yaml` |
|
||||||
|
| `DB_NAME` | Name der PostgreSQL-Datenbank | `kidio_db` |
|
||||||
|
| `DB_USER` | PostgreSQL-Benutzername | `kidio_admin` |
|
||||||
|
| `DB_PASSWORD` | PostgreSQL-Passwort | `learning` |
|
||||||
|
| `REDIS_HOST` | Hostname der Redis-Instanz | `localhost` |
|
||||||
|
| `REDIS_PORT` | Port der Redis-Instanz | `6379` |
|
||||||
|
| `JWT_SECRET` | Geheimer Schlüssel zur Signierung der JWTs | `super-geheimes-secret-fuer-dev` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lokale Entwicklung, Bauen & Testen
|
||||||
|
|
||||||
|
Zur Ausführung des Projekts werden die folgenden Gradle-Tasks bereitgestellt:
|
||||||
|
|
||||||
|
| Task | Beschreibung |
|
||||||
|
|------|-------------|
|
||||||
|
| `./gradlew test` | Führt alle automatisierten Unit- und Integrationstests aus (nutzt die InMemory-Repository-Implementierung). |
|
||||||
|
| `./gradlew build` | Kompiliert das Projekt und baut das Artefakt. |
|
||||||
|
| `./gradlew run` | Startet den Ktor-Entwicklungsserver lokal auf Port `8080`. |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tests ausführen
|
||||||
|
./gradlew test
|
||||||
|
|
||||||
|
# Server starten
|
||||||
|
./gradlew run
|
||||||
```
|
```
|
||||||
2024-12-04 14:32:45.584 [main] INFO Application - Application started in 0.303 seconds.
|
|
||||||
2024-12-04 14:32:45.682 [main] INFO Application - Responding at http://0.0.0.0:8080
|
Sobald der Server erfolgreich gestartet ist, ist er unter `http://localhost:8080` erreichbar.
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -19,20 +19,29 @@ dependencies {
|
||||||
implementation(ktorLibs.server.config.yaml)
|
implementation(ktorLibs.server.config.yaml)
|
||||||
implementation(ktorLibs.server.core)
|
implementation(ktorLibs.server.core)
|
||||||
implementation(ktorLibs.server.netty)
|
implementation(ktorLibs.server.netty)
|
||||||
|
|
||||||
|
// JWT Authentication
|
||||||
|
implementation("io.ktor:ktor-server-auth")
|
||||||
|
implementation("io.ktor:ktor-server-auth-jwt")
|
||||||
|
|
||||||
implementation(libs.logback.classic)
|
implementation(libs.logback.classic)
|
||||||
|
|
||||||
|
// Bcrypt
|
||||||
|
implementation("org.mindrot:jbcrypt:0.4")
|
||||||
|
|
||||||
// JSON-Unterstützung und Serialization
|
// JSON-Unterstützung und Serialization
|
||||||
implementation("io.ktor:ktor-server-content-negotiation-jvm")
|
implementation("io.ktor:ktor-server-content-negotiation-jvm")
|
||||||
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
|
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
|
||||||
//postgres
|
|
||||||
|
// postgres
|
||||||
implementation("org.postgresql:postgresql:42.7.3")
|
implementation("org.postgresql:postgresql:42.7.3")
|
||||||
implementation("org.jetbrains.exposed:exposed-core:0.50.0")
|
implementation("org.jetbrains.exposed:exposed-core:0.50.0")
|
||||||
implementation("org.jetbrains.exposed:exposed-dao:0.50.0")
|
implementation("org.jetbrains.exposed:exposed-dao:0.50.0")
|
||||||
implementation("org.jetbrains.exposed:exposed-jdbc:0.50.0")
|
implementation("org.jetbrains.exposed:exposed-jdbc:0.50.0")
|
||||||
|
|
||||||
testImplementation(kotlin("test"))
|
testImplementation(kotlin("test"))
|
||||||
testImplementation(ktorLibs.server.testHost)
|
testImplementation(ktorLibs.server.testHost)
|
||||||
|
|
||||||
//redis
|
// redis
|
||||||
implementation("redis.clients:jedis:5.1.2")
|
implementation("redis.clients:jedis:5.1.2")
|
||||||
|
|
||||||
}
|
}
|
||||||
BIN
src/.DS_Store
vendored
Normal file
BIN
src/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
src/main/.DS_Store
vendored
Normal file
BIN
src/main/.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -9,7 +9,7 @@ import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
object TasksTable : Table("tasks") {
|
object TasksTable : Table("tasks") {
|
||||||
val id = varchar("id", 50)
|
val id = varchar("id", 50)
|
||||||
val question = text("question")
|
val question = text("question")
|
||||||
val options = text("options") // Kommagetrennte Liste der Antwortmöglichkeiten
|
val options = text("options")
|
||||||
val correctAnswer = varchar("correct_answer", 255)
|
val correctAnswer = varchar("correct_answer", 255)
|
||||||
val rewardMinutes = integer("reward_minutes")
|
val rewardMinutes = integer("reward_minutes")
|
||||||
|
|
||||||
|
|
@ -19,48 +19,43 @@ object TasksTable : Table("tasks") {
|
||||||
object ScreentimeTable : Table("user_screentime") {
|
object ScreentimeTable : Table("user_screentime") {
|
||||||
val userId = varchar("user_id", 50)
|
val userId = varchar("user_id", 50)
|
||||||
val remainingSeconds = integer("remaining_seconds")
|
val remainingSeconds = integer("remaining_seconds")
|
||||||
val parentPin = varchar("parent_pin", 10)
|
|
||||||
|
|
||||||
override val primaryKey = PrimaryKey(userId)
|
override val primaryKey = PrimaryKey(userId)
|
||||||
}
|
}
|
||||||
|
object UserTable : Table("users") {
|
||||||
|
val id = varchar("id", 36)
|
||||||
|
val email = varchar("email", 255).uniqueIndex()
|
||||||
|
val passwordHash = varchar("password_hash", 255)
|
||||||
|
val familyName = varchar("family_name", 100)
|
||||||
|
val childAge = integer("child_age")
|
||||||
|
val parentPin = varchar("parent_pin", 4)
|
||||||
|
|
||||||
|
|
||||||
|
override val primaryKey = PrimaryKey(id)
|
||||||
|
}
|
||||||
|
|
||||||
object DatabaseFactory {
|
object DatabaseFactory {
|
||||||
fun init(config: ApplicationConfig) {
|
fun init(config: ApplicationConfig) {
|
||||||
val driverClassName = config.propertyOrNull("storage.driverClassName")?.getString() ?: "org.postgresql.Driver"
|
val driverClassName = config.propertyOrNull("storage.driverClassName")?.getString() ?: "org.postgresql.Driver"
|
||||||
|
|
||||||
val host = System.getenv("DB_HOST")
|
|
||||||
?: config.propertyOrNull("storage.host")?.getString()
|
|
||||||
?: "localhost"
|
|
||||||
|
|
||||||
val port = System.getenv("DB_PORT")
|
|
||||||
?: config.propertyOrNull("storage.port")?.getString()
|
|
||||||
?: "5432"
|
|
||||||
|
|
||||||
val dbName = System.getenv("DB_NAME")
|
|
||||||
?: config.propertyOrNull("storage.database")?.getString()
|
|
||||||
?: "kidio_db"
|
|
||||||
|
|
||||||
val username = System.getenv("DB_USER")
|
val host = System.getenv("DB_HOST") ?: config.propertyOrNull("storage.host")?.getString() ?: "localhost"
|
||||||
?: config.propertyOrNull("storage.username")?.getString()
|
val port = System.getenv("DB_PORT") ?: config.propertyOrNull("storage.port")?.getString() ?: "5432"
|
||||||
?: "postgres"
|
val dbName = System.getenv("DB_NAME") ?: config.propertyOrNull("storage.database")?.getString() ?: "kidio_db"
|
||||||
|
val dbUsername = System.getenv("DB_USER") ?: config.propertyOrNull("storage.username")?.getString() ?: "postgres"
|
||||||
val password = System.getenv("DB_PASSWORD")
|
val password = System.getenv("DB_PASSWORD") ?: config.propertyOrNull("storage.password")?.getString() ?: "postgres"
|
||||||
?: config.propertyOrNull("storage.password")?.getString()
|
|
||||||
?: "postgres"
|
|
||||||
|
|
||||||
val jdbcURL = "jdbc:postgresql://$host:$port/$dbName"
|
val jdbcURL = "jdbc:postgresql://$host:$port/$dbName"
|
||||||
|
|
||||||
val database = Database.connect(
|
val database = Database.connect(
|
||||||
url = jdbcURL,
|
url = jdbcURL,
|
||||||
driver = driverClassName,
|
driver = driverClassName,
|
||||||
user = username,
|
user = dbUsername,
|
||||||
password = password
|
password = password
|
||||||
)
|
)
|
||||||
|
|
||||||
transaction(database) {
|
transaction(database) {
|
||||||
SchemaUtils.create(TasksTable, ScreentimeTable)
|
SchemaUtils.create(TasksTable, ScreentimeTable, UserTable)
|
||||||
|
|
||||||
// Standard aufgaben, sollte es keine mehr geben
|
|
||||||
if (TasksTable.selectAll().empty()) {
|
if (TasksTable.selectAll().empty()) {
|
||||||
TasksTable.insert {
|
TasksTable.insert {
|
||||||
it[id] = "t1"
|
it[id] = "t1"
|
||||||
|
|
@ -77,12 +72,21 @@ object DatabaseFactory {
|
||||||
it[rewardMinutes] = 15
|
it[rewardMinutes] = 15
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed a default user screentime if empty
|
|
||||||
if (ScreentimeTable.selectAll().empty()) {
|
if (ScreentimeTable.selectAll().empty()) {
|
||||||
ScreentimeTable.insert {
|
ScreentimeTable.insert {
|
||||||
it[userId] = "default_user"
|
it[userId] = "default_user"
|
||||||
it[remainingSeconds] = 1800
|
it[remainingSeconds] = 1800
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UserTable.selectAll().empty()) {
|
||||||
|
UserTable.insert {
|
||||||
|
it[id] = "default_user"
|
||||||
|
it[email] = "test@kidio.de"
|
||||||
|
it[passwordHash] = "1234" // Dummy-Hash für den Start
|
||||||
|
it[familyName] = "Familie Test"
|
||||||
|
it[childAge] = 8
|
||||||
it[parentPin] = "1234"
|
it[parentPin] = "1234"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -91,4 +95,4 @@ object DatabaseFactory {
|
||||||
|
|
||||||
suspend fun <T> dbQuery(block: suspend () -> T): T =
|
suspend fun <T> dbQuery(block: suspend () -> T): T =
|
||||||
newSuspendedTransaction(Dispatchers.IO) { block() }
|
newSuspendedTransaction(Dispatchers.IO) { block() }
|
||||||
}
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable
|
||||||
data class Task(
|
data class Task(
|
||||||
val id: String,
|
val id: String,
|
||||||
val question: String,
|
val question: String,
|
||||||
val options: List<String>, // Antwortmöglichkeiten
|
val options: List<String>,
|
||||||
val rewardMinutes: Int // Wie viel Belohnung es gibt
|
val rewardMinutes: Int // Wie viel Belohnung es gibt
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -37,4 +37,39 @@ data class SyncRequest(
|
||||||
data class SyncResponse(
|
data class SyncResponse(
|
||||||
val remainingSeconds: Int,
|
val remainingSeconds: Int,
|
||||||
val isBlocked: Boolean
|
val isBlocked: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- NEU: AUTHENTIFIZIERUNG ---
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RegisterRequest(
|
||||||
|
val email: String,
|
||||||
|
val passwordPlain: String,
|
||||||
|
val familyName: String,
|
||||||
|
val childAge: Int,
|
||||||
|
val parentPin: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LoginRequest(
|
||||||
|
val email: String,
|
||||||
|
val passwordPlain: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AuthResponse(
|
||||||
|
val token: String, // Der digitale "Ausweis" (JWT)
|
||||||
|
val userId: String, // Wichtig für die App, um Aufgaben zuzuordnen
|
||||||
|
val familyName: String, // Für "Hallo Familie Müller!"
|
||||||
|
val childAge: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
// Interne Backend-Klasse (Domain Model).
|
||||||
|
data class User(
|
||||||
|
val id: String,
|
||||||
|
val email: String,
|
||||||
|
val passwordHash: String,
|
||||||
|
val familyName: String,
|
||||||
|
val childAge: Int,
|
||||||
|
val parentPin: String
|
||||||
)
|
)
|
||||||
|
|
@ -1,17 +1,23 @@
|
||||||
package com.oliver.kidio.backend.domain.repository
|
package com.oliver.kidio.backend.domain.repository
|
||||||
|
|
||||||
import com.oliver.kidio.backend.Task
|
import com.oliver.kidio.backend.Task
|
||||||
|
import com.oliver.kidio.backend.User
|
||||||
import com.oliver.kidio.backend.data.database.DatabaseFactory
|
import com.oliver.kidio.backend.data.database.DatabaseFactory
|
||||||
import com.oliver.kidio.backend.data.database.RedisFactory
|
import com.oliver.kidio.backend.data.database.RedisFactory
|
||||||
import com.oliver.kidio.backend.data.database.ScreentimeTable
|
import com.oliver.kidio.backend.data.database.ScreentimeTable
|
||||||
import com.oliver.kidio.backend.data.database.TasksTable
|
import com.oliver.kidio.backend.data.database.TasksTable
|
||||||
|
import com.oliver.kidio.backend.data.database.UserTable
|
||||||
import org.jetbrains.exposed.sql.*
|
import org.jetbrains.exposed.sql.*
|
||||||
|
import org.mindrot.jbcrypt.BCrypt
|
||||||
|
|
||||||
interface KidioRepository {
|
interface KidioRepository {
|
||||||
suspend fun getAllTasks(): List<Task>
|
suspend fun getAllTasks(): List<Task>
|
||||||
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
|
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
|
||||||
suspend fun getScreentime(userId: String): Int
|
suspend fun getScreentime(userId: String): Int
|
||||||
suspend fun updateScreentime(userId: String, seconds: Int)
|
suspend fun updateScreentime(userId: String, seconds: Int)
|
||||||
|
suspend fun findByEmail(email: String): User?
|
||||||
|
suspend fun verifyPassword(password: String, passwordHash: String): Boolean
|
||||||
|
suspend fun registerUser(user: User): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
class ExposedKidioRepository : KidioRepository {
|
class ExposedKidioRepository : KidioRepository {
|
||||||
|
|
@ -26,6 +32,27 @@ class ExposedKidioRepository : KidioRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun registerUser(user: User): Boolean = DatabaseFactory.dbQuery {
|
||||||
|
try {
|
||||||
|
UserTable.insert {
|
||||||
|
it[id] = user.id
|
||||||
|
it[email] = user.email
|
||||||
|
it[passwordHash] = user.passwordHash
|
||||||
|
it[familyName] = user.familyName
|
||||||
|
it[childAge] = user.childAge
|
||||||
|
it[parentPin] = user.parentPin
|
||||||
|
}
|
||||||
|
ScreentimeTable.insert {
|
||||||
|
it[userId] = user.id
|
||||||
|
it[remainingSeconds] = 1800 // Startguthaben: 30 Minuten
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("FEHLER bei Registrierung: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = DatabaseFactory.dbQuery {
|
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = DatabaseFactory.dbQuery {
|
||||||
TasksTable.selectAll()
|
TasksTable.selectAll()
|
||||||
.where { TasksTable.id eq taskId }
|
.where { TasksTable.id eq taskId }
|
||||||
|
|
@ -34,7 +61,7 @@ class ExposedKidioRepository : KidioRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getScreentime(userId: String): Int {
|
override suspend fun getScreentime(userId: String): Int {
|
||||||
var redisKey = "screentime:$userId"
|
val redisKey = "screentime:$userId"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
RedisFactory.getResource().use { jedis ->
|
RedisFactory.getResource().use { jedis ->
|
||||||
|
|
@ -47,12 +74,14 @@ class ExposedKidioRepository : KidioRepository {
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("WARNUNG: Konnte nicht aus Redis lesen (Postgres-Fallback genutzt): ${e.message}")
|
println("WARNUNG: Konnte nicht aus Redis lesen (Postgres-Fallback genutzt): ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
val dbSeconds = DatabaseFactory.dbQuery {
|
val dbSeconds = DatabaseFactory.dbQuery {
|
||||||
ScreentimeTable.selectAll()
|
ScreentimeTable.selectAll()
|
||||||
.where { ScreentimeTable.userId eq userId }
|
.where { ScreentimeTable.userId eq userId }
|
||||||
.map { it[ScreentimeTable.remainingSeconds] }
|
.map { it[ScreentimeTable.remainingSeconds] } // Korrigiert: Abfrage der Sekunden!
|
||||||
.singleOrNull() ?: 1800
|
.singleOrNull() ?: 1800
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
RedisFactory.getResource().use { jedis ->
|
RedisFactory.getResource().use { jedis ->
|
||||||
jedis.setex(redisKey, 3600, dbSeconds.toString())
|
jedis.setex(redisKey, 3600, dbSeconds.toString())
|
||||||
|
|
@ -64,18 +93,37 @@ class ExposedKidioRepository : KidioRepository {
|
||||||
return dbSeconds
|
return dbSeconds
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateScreentime(userId: String, seconds: Int): Unit = DatabaseFactory.dbQuery {
|
override suspend fun updateScreentime(userId: String, seconds: Int) = DatabaseFactory.dbQuery {
|
||||||
val rowsUpdated = ScreentimeTable.update({ ScreentimeTable.userId eq userId }) {
|
val rowsUpdated = ScreentimeTable.update({ ScreentimeTable.userId eq userId }) {
|
||||||
it[remainingSeconds] = seconds
|
it[remainingSeconds] = seconds
|
||||||
}
|
}
|
||||||
if (rowsUpdated == 0) {
|
if (rowsUpdated == 0) {
|
||||||
ScreentimeTable.insert {
|
ScreentimeTable.insert {
|
||||||
it[ScreentimeTable.userId] = userId
|
it[ScreentimeTable.userId] = userId
|
||||||
it[remainingSeconds] = seconds
|
it[ScreentimeTable.remainingSeconds] = seconds // Korrigiert: Wert wird wieder mitgegeben!
|
||||||
it[parentPin] = "1234"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun findByEmail(email: String): User? = DatabaseFactory.dbQuery {
|
||||||
|
UserTable.selectAll()
|
||||||
|
.where { UserTable.email eq email }
|
||||||
|
.map {
|
||||||
|
User(
|
||||||
|
id = it[UserTable.id],
|
||||||
|
email = it[UserTable.email],
|
||||||
|
passwordHash = it[UserTable.passwordHash],
|
||||||
|
familyName = it[UserTable.familyName],
|
||||||
|
childAge = it[UserTable.childAge],
|
||||||
|
parentPin = it[UserTable.parentPin]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.singleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean {
|
||||||
|
return BCrypt.checkpw(password, passwordHash)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class InMemoryKidioRepository : KidioRepository {
|
class InMemoryKidioRepository : KidioRepository {
|
||||||
|
|
@ -90,6 +138,7 @@ class InMemoryKidioRepository : KidioRepository {
|
||||||
private val screentimes = mutableMapOf(
|
private val screentimes = mutableMapOf(
|
||||||
"default_user" to 1800
|
"default_user" to 1800
|
||||||
)
|
)
|
||||||
|
private val users = mutableListOf<User>()
|
||||||
|
|
||||||
override suspend fun getAllTasks(): List<Task> = tasks
|
override suspend fun getAllTasks(): List<Task> = tasks
|
||||||
|
|
||||||
|
|
@ -100,4 +149,18 @@ class InMemoryKidioRepository : KidioRepository {
|
||||||
override suspend fun updateScreentime(userId: String, seconds: Int) {
|
override suspend fun updateScreentime(userId: String, seconds: Int) {
|
||||||
screentimes[userId] = seconds
|
screentimes[userId] = seconds
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
override suspend fun findByEmail(email: String): User? {
|
||||||
|
return users.find { it.email == email }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean {
|
||||||
|
return BCrypt.checkpw(password, passwordHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun registerUser(user: User): Boolean {
|
||||||
|
users.add(user)
|
||||||
|
screentimes[user.id] = 1800
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,84 +2,158 @@ package com.oliver.kidio.backend.plugins
|
||||||
|
|
||||||
import com.oliver.kidio.backend.*
|
import com.oliver.kidio.backend.*
|
||||||
import com.oliver.kidio.backend.domain.repository.KidioRepository
|
import com.oliver.kidio.backend.domain.repository.KidioRepository
|
||||||
|
import com.oliver.kidio.backend.security.JwtService
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
|
import io.ktor.server.auth.authenticate
|
||||||
|
import io.ktor.server.auth.jwt.JWTPrincipal
|
||||||
|
import io.ktor.server.auth.principal
|
||||||
import io.ktor.server.request.*
|
import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
import io.ktor.server.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
|
import org.mindrot.jbcrypt.BCrypt
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
fun Application.configureRouting(repository: KidioRepository) {
|
fun Application.configureRouting(repository: KidioRepository, jwtService : JwtService) {
|
||||||
routing {
|
routing {
|
||||||
|
post("/api/v1/auth/register") {
|
||||||
|
val registerRequest = call.receive<RegisterRequest>()
|
||||||
|
if (registerRequest.email.isBlank() || registerRequest.passwordPlain.isBlank()) {
|
||||||
|
call.respond(HttpStatusCode.BadRequest, "Bitte füllen Sie alle Felder aus")
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
if (registerRequest.parentPin.length != 4) {
|
||||||
|
call.respond(HttpStatusCode.BadRequest, "Die Eltern-PIN muss genau 4 Ziffern lang sein")
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val existingUser = repository.findByEmail(registerRequest.email)
|
||||||
|
if (existingUser != null) {
|
||||||
|
call.respond(HttpStatusCode.Conflict, "Ein Benutzer mit dieser E-Mail-Adresse existiert bereits")
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend generiert die eindeutige User-ID
|
||||||
|
val userId = UUID.randomUUID().toString()
|
||||||
|
val passwordHash = BCrypt.hashpw(registerRequest.passwordPlain, BCrypt.gensalt())
|
||||||
|
|
||||||
|
//datenbank insert hinzufügen
|
||||||
|
repository.registerUser(User(userId, registerRequest.email, passwordHash, registerRequest.familyName, registerRequest.childAge, registerRequest.parentPin))
|
||||||
|
|
||||||
|
val token = jwtService.generateToken(userId)
|
||||||
|
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.Created,
|
||||||
|
mapOf(
|
||||||
|
"token" to token,
|
||||||
|
"userId" to userId,
|
||||||
|
"message" to "Registrierung erfolgreich"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
// Health-Check
|
// Health-Check
|
||||||
get("/api/v1/health") {
|
get("/api/v1/health") {
|
||||||
call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!"))
|
call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Alle verfügbaren Aufgaben abrufen
|
post ("/api/v1/auth/login" ) {
|
||||||
get("/api/v1/tasks") {
|
val loginRequest = call.receive<LoginRequest>()
|
||||||
val tasks = repository.getAllTasks()
|
if(loginRequest.email.isBlank() || loginRequest.passwordPlain.isBlank()){
|
||||||
call.respond(tasks)
|
call.respond(HttpStatusCode.BadRequest, "Bitte füllen Sie alle Felder aus")
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Antwort auf eine Aufgabe überprüfen
|
|
||||||
post("/api/v1/tasks/verify") {
|
|
||||||
val request = call.receive<TaskVerifyRequest>()
|
|
||||||
|
|
||||||
val taskInfo = repository.getTaskAnswerAndReward(request.taskId)
|
|
||||||
|
|
||||||
if (taskInfo == null) {
|
|
||||||
call.respond(
|
|
||||||
TaskVerifyResponse(
|
|
||||||
isCorrect = false,
|
|
||||||
earnedMinutes = 0,
|
|
||||||
message = "Aufgabe nicht gefunden!"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return@post
|
return@post
|
||||||
}
|
}
|
||||||
|
val getUser = repository.findByEmail(loginRequest.email)
|
||||||
|
if (getUser == null ||!repository.verifyPassword(loginRequest.passwordPlain, getUser.passwordHash)){
|
||||||
|
call.respond(HttpStatusCode.Unauthorized, "Ungültige Anmeldedaten")
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val token = jwtService.generateToken(getUser.id)
|
||||||
|
call.respond(HttpStatusCode.OK, AuthResponse(
|
||||||
|
token,
|
||||||
|
userId = getUser.id,
|
||||||
|
familyName = getUser.familyName,
|
||||||
|
childAge = getUser.childAge
|
||||||
|
))
|
||||||
|
return@post
|
||||||
|
|
||||||
val (correctAnswer, reward) = taskInfo
|
|
||||||
val isCorrect = request.selectedAnswer.trim() == correctAnswer.trim()
|
|
||||||
|
|
||||||
if (isCorrect) {
|
|
||||||
val currentScreentime = repository.getScreentime("default_user")
|
|
||||||
val newSeconds = currentScreentime + (reward * 60)
|
}
|
||||||
repository.updateScreentime("default_user", newSeconds)
|
|
||||||
|
authenticate("auth-jwt") {
|
||||||
|
|
||||||
|
// 1. Alle verfügbaren Aufgaben abrufen
|
||||||
|
get("/api/v1/tasks") {
|
||||||
|
val tasks = repository.getAllTasks()
|
||||||
|
call.respond(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Antwort auf eine Aufgabe überprüfen
|
||||||
|
post("/api/v1/tasks/verify") {
|
||||||
|
val request = call.receive<TaskVerifyRequest>()
|
||||||
|
val principal = call.principal<JWTPrincipal>()
|
||||||
|
val userId = principal?.payload?.getClaim("userId")?.asString()
|
||||||
|
|
||||||
|
if (userId == null){
|
||||||
|
call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiges oder abgelaufenes Token"))
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val taskInfo = repository.getTaskAnswerAndReward(request.taskId)
|
||||||
|
if (taskInfo == null) {
|
||||||
|
call.respond(
|
||||||
|
TaskVerifyResponse(
|
||||||
|
isCorrect = false,
|
||||||
|
earnedMinutes = 0,
|
||||||
|
message = "Aufgabe nicht gefunden!"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
|
||||||
|
val (correctAnswer, reward) = taskInfo
|
||||||
|
val isCorrect = request.selectedAnswer.trim() == correctAnswer.trim()
|
||||||
|
|
||||||
|
if (isCorrect) {
|
||||||
|
val currentScreentime = repository.getScreentime(userId)
|
||||||
|
val newSeconds = currentScreentime + (reward * 60)
|
||||||
|
repository.updateScreentime(userId, newSeconds)
|
||||||
|
|
||||||
|
call.respond(
|
||||||
|
TaskVerifyResponse(
|
||||||
|
isCorrect = true,
|
||||||
|
earnedMinutes = reward,
|
||||||
|
message = "Super gemacht! Du hast $reward Minuten gewonnen."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
call.respond(
|
||||||
|
TaskVerifyResponse(
|
||||||
|
isCorrect = false,
|
||||||
|
earnedMinutes = 0,
|
||||||
|
message = "Schade, das war leider falsch. Versuche es nochmal!"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Bildschirmzeit synchronisieren
|
||||||
|
post("/api/v1/screentime/sync") {
|
||||||
|
val request = call.receive<SyncRequest>()
|
||||||
|
|
||||||
|
val currentSeconds = repository.getScreentime(request.userId)
|
||||||
|
var newSeconds = currentSeconds - request.usedSecondsSinceLastSync
|
||||||
|
if (newSeconds < 0) newSeconds = 0
|
||||||
|
|
||||||
|
repository.updateScreentime(request.userId, newSeconds)
|
||||||
|
|
||||||
call.respond(
|
call.respond(
|
||||||
TaskVerifyResponse(
|
SyncResponse(
|
||||||
isCorrect = true,
|
remainingSeconds = newSeconds,
|
||||||
earnedMinutes = reward,
|
isBlocked = newSeconds <= 0
|
||||||
message = "Super gemacht! Du hast $reward Minuten gewonnen."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
call.respond(
|
|
||||||
TaskVerifyResponse(
|
|
||||||
isCorrect = false,
|
|
||||||
earnedMinutes = 0,
|
|
||||||
message = "Schade, das war leider falsch. Versuche es nochmal!"
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Bildschirmzeit synchronisieren
|
|
||||||
post("/api/v1/screentime/sync") {
|
|
||||||
val request = call.receive<SyncRequest>()
|
|
||||||
|
|
||||||
val currentSeconds = repository.getScreentime(request.userId)
|
|
||||||
var newSeconds = currentSeconds - request.usedSecondsSinceLastSync
|
|
||||||
if (newSeconds < 0) newSeconds = 0
|
|
||||||
|
|
||||||
repository.updateScreentime(request.userId, newSeconds)
|
|
||||||
|
|
||||||
call.respond(
|
|
||||||
SyncResponse(
|
|
||||||
remainingSeconds = newSeconds,
|
|
||||||
isBlocked = newSeconds <= 0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
29
src/main/kotlin/com/oliver/kidio/backend/plugins/Security.kt
Normal file
29
src/main/kotlin/com/oliver/kidio/backend/plugins/Security.kt
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
package com.oliver.kidio.backend.plugins
|
||||||
|
|
||||||
|
import com.oliver.kidio.backend.security.JwtService
|
||||||
|
import io.ktor.server.application.*
|
||||||
|
import io.ktor.server.auth.*
|
||||||
|
import io.ktor.server.auth.jwt.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repräsentiert den authentifizierten Benutzer im Request-Kontext.
|
||||||
|
*/
|
||||||
|
data class UserPrincipal(val userId: String)
|
||||||
|
|
||||||
|
fun Application.configureSecurity(jwtService: JwtService) {
|
||||||
|
install(Authentication) {
|
||||||
|
jwt("auth-jwt") {
|
||||||
|
realm = "Kidio Backend"
|
||||||
|
verifier(jwtService.verifier)
|
||||||
|
|
||||||
|
validate { credential ->
|
||||||
|
val userId = credential.payload.getClaim("userId").asString()
|
||||||
|
if (!userId.isNullOrEmpty()) {
|
||||||
|
UserPrincipal(userId)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.oliver.kidio.backend.security
|
||||||
|
|
||||||
|
import com.auth0.jwt.JWT
|
||||||
|
import com.auth0.jwt.JWTVerifier
|
||||||
|
import com.auth0.jwt.algorithms.Algorithm
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class JwtService(
|
||||||
|
private val secret: String = System.getenv("JWT_SECRET") ?: "super-geheimes-secret-fuer-dev",
|
||||||
|
private val issuer: String = "https://kidio.oliver.com/",
|
||||||
|
private val audience: String = "kidio-users",
|
||||||
|
private val expirationInMs: Long = 36_000_000 // 10 Stunden
|
||||||
|
) {
|
||||||
|
|
||||||
|
val verifier: JWTVerifier = JWT
|
||||||
|
.require(Algorithm.HMAC256(secret))
|
||||||
|
.withAudience(audience)
|
||||||
|
.withIssuer(issuer)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generiert ein JWT-Token für einen bestimmten Benutzer.
|
||||||
|
*/
|
||||||
|
fun generateToken(userId: String): String {
|
||||||
|
return JWT.create()
|
||||||
|
.withAudience(audience)
|
||||||
|
.withIssuer(issuer)
|
||||||
|
.withClaim("userId", userId)
|
||||||
|
.withExpiresAt(Date(System.currentTimeMillis() + expirationInMs))
|
||||||
|
.sign(Algorithm.HMAC256(secret))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@ import com.oliver.kidio.backend.plugins.configureRouting
|
||||||
import com.oliver.kidio.backend.data.database.DatabaseFactory
|
import com.oliver.kidio.backend.data.database.DatabaseFactory
|
||||||
import com.oliver.kidio.backend.data.database.RedisFactory
|
import com.oliver.kidio.backend.data.database.RedisFactory
|
||||||
import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository
|
import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository
|
||||||
|
import com.oliver.kidio.backend.plugins.configureSecurity
|
||||||
|
import com.oliver.kidio.backend.security.JwtService
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.plugins.contentnegotiation.*
|
import io.ktor.server.plugins.contentnegotiation.*
|
||||||
|
|
@ -16,6 +18,13 @@ fun Application.module() {
|
||||||
json()
|
json()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ruft die Funktion aus Routing.kt auf mit dem echten ExposedRepository
|
val jwtService = JwtService()
|
||||||
configureRouting(ExposedKidioRepository())
|
val repository = ExposedKidioRepository()
|
||||||
}
|
|
||||||
|
configureSecurity(jwtService)
|
||||||
|
|
||||||
|
configureRouting(
|
||||||
|
repository = repository,
|
||||||
|
jwtService = jwtService
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,5 @@ storage:
|
||||||
host: "82.165.11.162"
|
host: "82.165.11.162"
|
||||||
port: "5432"
|
port: "5432"
|
||||||
database: "kidio_db"
|
database: "kidio_db"
|
||||||
username: "postgres"
|
username: "kidio_admin"
|
||||||
password: "learning"
|
password: "learning"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue