From 6fd8528577503109b38f8c2b1cbdd29cf18c2dde Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Mon, 18 May 2026 17:45:19 +0200 Subject: [PATCH] feat(admin-message): simplify scripts, add inbox route, fix notification chat switch --- .github/skills/admin-message-e2ee/SKILL.md | 67 ++++++- .../generaltest_admin_message.py | 146 ++++++++++++++ .../receive_admin_messages.py | 84 ++++++++ .../admin-message-e2ee/send_admin_messages.py | 91 +++++++++ .../app/notification/NotificationHelper.kt | 35 +++- .../java/de/bollwerk/app/ui/MainScreen.kt | 17 +- .../server/repository/MessageRepository.kt | 18 ++ .../server/routes/AdminMessageRoutes.kt | 181 ++++++++++++++++-- .../de/bollwerk/server/AdminMessageApiTest.kt | 74 +++++++ 9 files changed, 675 insertions(+), 38 deletions(-) create mode 100644 .github/skills/admin-message-e2ee/generaltest_admin_message.py create mode 100644 .github/skills/admin-message-e2ee/receive_admin_messages.py create mode 100644 .github/skills/admin-message-e2ee/send_admin_messages.py diff --git a/.github/skills/admin-message-e2ee/SKILL.md b/.github/skills/admin-message-e2ee/SKILL.md index f711da6..4cf2725 100644 --- a/.github/skills/admin-message-e2ee/SKILL.md +++ b/.github/skills/admin-message-e2ee/SKILL.md @@ -11,13 +11,15 @@ Ziel: - E2EE-Nachrichten serverseitig erzeugen und an reale User zustellen - Endpunkt korrekt mit Bearer-Admin-Token verwenden -- Zustellung anschliessend ueber Conversation-API verifizieren +- Zustellung anschliessend ueber Inbox-API verifizieren ## Technischer Hintergrund Der Endpunkt ist: - `POST /api/admin/send-message` +- `GET /api/admin/send-message?userA=&userB=[&since=]` +- `GET /api/admin/send-message/inbox?username=` Pflichtbedingungen: @@ -65,12 +67,61 @@ $payload = @{ Invoke-RestMethod -Method POST -Uri "https://bollwerk.online/api/admin/send-message" -Headers $headers -Body $payload ``` -4. Zustellung verifizieren +4. Inbox fuer einen User lesen (Admin-Read-Endpoint) ```powershell -Invoke-RestMethod -Method GET -Uri "https://bollwerk.online/api/messages/" -Headers @{ Authorization = "Bearer $env:BOLLWERK_ADMIN_TOKEN" } +Invoke-RestMethod -Method GET -Uri "https://bollwerk.online/api/admin/send-message/inbox?username=admin" -Headers @{ Authorization = "Bearer $env:BOLLWERK_ADMIN_TOKEN" } ``` +## Python-Skripte + +Dateien in diesem Skill-Ordner: + +- `send_admin_messages.py` +- `receive_admin_messages.py` + +### Senden + +```powershell +# Genau eine Nachricht senden (nur 3 Parameter) +python ".github/skills/admin-message-e2ee/send_admin_messages.py" ` + --sender bob ` + --receiver admin ` + --body "Hi admin, Bob hier" +``` + +### Empfangen + +```powershell +# Inbox fuer genau einen User (nur 1 Parameter) +python ".github/skills/admin-message-e2ee/receive_admin_messages.py" --username admin +``` + +Voraussetzung für beide Skripte: + +- `BOLLWERK_ADMIN_TOKEN` als ENV gesetzt. + +### Generaltest-Skript + +```powershell +python ".github/skills/admin-message-e2ee/generaltest_admin_message.py" +``` + +Dieses Skript fuehrt den Skill-Selbsttest aus: + +- Nur bob/alice-Kommunikation (keine Nachrichten an admin) +- Offline/Online-Phasen: + - bob sendet waehrend alice offline ist + - alice kommt online, empfaengt, antwortet + - bob kommt online, empfaengt, antwortet +- UTF-8/Emoji-Payloads werden aktiv gesendet +- Integritaetspruefung ueber Transport-Metadaten (ID, Reihenfolge, Sender/Empfaenger, Inbox-Sichtbarkeit) + +Wichtig zur Inhaltsintegritaet: + +- Die Admin-Inbox liefert E2EE-Ciphertext. +- Eine Klartext-Gleichheitspruefung des Message-Bodys ist ueber diesen Endpunkt nicht moeglich. + ## Erwartete Fehlerbilder - `401 Unauthorized`: Bearer-Token fehlt @@ -78,17 +129,19 @@ Invoke-RestMethod -Method GET -Uri "https://bollwerk.online/api/messages/ Any: + data = None if payload is None else json.dumps(payload).encode("utf-8") + headers = {"Authorization": f"Bearer {token}"} + if payload is not None: + headers["Content-Type"] = "application/json" + + last_err = "" + for attempt in range(1, retries + 1): + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read().decode("utf-8", errors="replace") + if not body.strip(): + return {} + return json.loads(body) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + last_err = f"HTTP {exc.code}: {body}" + if 500 <= exc.code < 600 and attempt < retries: + time.sleep(min(2 * attempt, 5)) + continue + raise RuntimeError(last_err) + except Exception as exc: # noqa: BLE001 + last_err = str(exc) + if attempt < retries: + time.sleep(min(2 * attempt, 5)) + continue + raise RuntimeError(last_err) + raise RuntimeError(last_err) + + +def send_one(token: str, sender: str, receiver: str, body: str) -> str: + url = f"{BASE_URL}/api/admin/send-message" + payload = { + "senderUsername": sender, + "receiverUsername": receiver, + "body": body, + "sentAt": int(time.time() * 1000), + } + response = request_json(url, "POST", token, payload) + return str(response.get("message", {}).get("id", "")) + + +def fetch_inbox(token: str, username: str) -> List[Dict[str, Any]]: + query = urllib.parse.urlencode({"username": username}) + url = f"{BASE_URL}/api/admin/send-message/inbox?{query}" + data = request_json(url, "GET", token, None) + return data if isinstance(data, list) else [] + + +def sent_after(messages: List[Dict[str, Any]], since_ms: int) -> List[Dict[str, Any]]: + return [m for m in messages if int(m.get("sentAt", 0)) >= since_ms] + + +def main() -> int: + token = os.environ.get("BOLLWERK_ADMIN_TOKEN", "").strip() + if not token: + print("ERROR: Missing env BOLLWERK_ADMIN_TOKEN.", file=sys.stderr) + return 1 + + scenario_start = int(time.time() * 1000) + ids: List[str] = [] + + # Phase 1: Alice offline, Bob sends two messages with UTF-8 + emoji. + ids.append(send_one(token, "bob", "alice", f"GT bob->alice 1 | UTF8 äöü ß | emoji 🚀 | {scenario_start}-1")) + ids.append(send_one(token, "bob", "alice", f"GT bob->alice 2 | UTF8 Grüße | emoji ✅ | {scenario_start}-2")) + + # Phase 2: Bob offline, Alice comes online and reads inbox. + alice_inbox = sent_after(fetch_inbox(token, "alice"), scenario_start) + alice_got_from_bob = [m for m in alice_inbox if m.get("senderUsername") == "bob"] + + # Alice replies while Bob is still offline. + ids.append(send_one(token, "alice", "bob", f"GT alice->bob 1 | UTF8 Fähre | emoji 📨 | {scenario_start}-3")) + ids.append(send_one(token, "alice", "bob", f"GT alice->bob 2 | UTF8 déjà-vu | emoji 🌍 | {scenario_start}-4")) + + # Phase 3: Alice offline, Bob comes online and reads inbox. + bob_inbox = sent_after(fetch_inbox(token, "bob"), scenario_start) + bob_got_from_alice = [m for m in bob_inbox if m.get("senderUsername") == "alice"] + + # Bob answers once more to complete the loop. + ids.append(send_one(token, "bob", "alice", f"GT bob->alice 3 | UTF8 München | emoji 🔁 | {scenario_start}-5")) + alice_inbox_after = sent_after(fetch_inbox(token, "alice"), scenario_start) + alice_got_after_reply = [m for m in alice_inbox_after if m.get("senderUsername") == "bob"] + + result = { + "scenario": "bob-alice-offline-online", + "sentCount": len(ids), + "messageIds": ids, + "aliceReceivedFromBob_initial": len(alice_got_from_bob), + "bobReceivedFromAlice": len(bob_got_from_alice), + "aliceReceivedFromBob_afterReply": len(alice_got_after_reply), + "integrityCheck": { + "mode": "transport-metadata", + "utf8EmojiPayloadsSent": True, + "note": "Admin inbox endpoint returns E2EE ciphertext; plaintext comparison is not possible here." + } + } + + ok = ( + len(ids) == 5 + and len(alice_got_from_bob) >= 2 + and len(bob_got_from_alice) >= 2 + and len(alice_got_after_reply) >= 3 + ) + + print(json.dumps(result, ensure_ascii=True)) + return 0 if ok else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/skills/admin-message-e2ee/receive_admin_messages.py b/.github/skills/admin-message-e2ee/receive_admin_messages.py new file mode 100644 index 0000000..027fe75 --- /dev/null +++ b/.github/skills/admin-message-e2ee/receive_admin_messages.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Empfaengt Inbox-Nachrichten fuer genau einen User. + +Pflichtparameter: +- --username +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from typing import Any, Dict, List + + +def request_json(url: str, token: str, timeout: int, retries: int) -> List[Dict[str, Any]]: + headers = {"Authorization": f"Bearer {token}"} + last_err = "" + for attempt in range(1, retries + 1): + req = urllib.request.Request(url, headers=headers, method="GET") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read().decode("utf-8", errors="replace") + if not body.strip(): + return [] + parsed = json.loads(body) + if isinstance(parsed, list): + return parsed + return [] + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + last_err = f"HTTP {exc.code}: {body}" + if 500 <= exc.code < 600 and attempt < retries: + time.sleep(min(2 * attempt, 5)) + continue + raise RuntimeError(last_err) + except Exception as exc: # noqa: BLE001 + last_err = str(exc) + if attempt < retries: + time.sleep(min(2 * attempt, 5)) + continue + raise RuntimeError(last_err) + raise RuntimeError(last_err) + + +def format_line(msg: Dict[str, Any]) -> str: + sent_at = msg.get("sentAt", "") + sender = msg.get("senderUsername", "?") + receiver = msg.get("receiverId", "?") + msg_id = msg.get("id", "") + body = str(msg.get("body", "")) + short_body = body if len(body) <= 80 else body[:77] + "..." + return f"{sent_at} id={msg_id} sender={sender} receiverId={receiver} body={short_body}" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Receive inbox messages for one user") + parser.add_argument("--username", required=True, help="Username of receiver") + args = parser.parse_args() + + token = os.environ.get("BOLLWERK_ADMIN_TOKEN", "").strip() + if not token: + print("ERROR: Missing env BOLLWERK_ADMIN_TOKEN.", file=sys.stderr) + return 1 + + params = {"username": args.username.strip()} + if not params["username"]: + print("ERROR: username must not be blank.", file=sys.stderr) + return 1 + + url = "https://bollwerk.online/api/admin/send-message/inbox?" + urllib.parse.urlencode(params) + messages = request_json(url, token, timeout=15, retries=3) + print(f"RECEIVE_DONE count={len(messages)}") + for msg in messages: + print(format_line(msg)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/skills/admin-message-e2ee/send_admin_messages.py b/.github/skills/admin-message-e2ee/send_admin_messages.py new file mode 100644 index 0000000..8a3b6db --- /dev/null +++ b/.github/skills/admin-message-e2ee/send_admin_messages.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Sendet genau eine Nachricht ueber die Admin-Message-API. + +Pflichtparameter: +- --sender +- --receiver +- --body +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +import urllib.error +import urllib.request +from typing import Any, Dict + + +def request_json( + url: str, + method: str, + token: str, + payload: Dict[str, Any] | None, + timeout: int, + retries: int, +) -> Dict[str, Any]: + data = None if payload is None else json.dumps(payload).encode("utf-8") + headers = {"Authorization": f"Bearer {token}"} + if payload is not None: + headers["Content-Type"] = "application/json" + + last_err = "" + for attempt in range(1, retries + 1): + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read().decode("utf-8", errors="replace") + if not body.strip(): + return {} + return json.loads(body) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + last_err = f"HTTP {exc.code}: {body}" + if 500 <= exc.code < 600 and attempt < retries: + time.sleep(min(2 * attempt, 5)) + continue + raise RuntimeError(last_err) + except Exception as exc: # noqa: BLE001 + last_err = str(exc) + if attempt < retries: + time.sleep(min(2 * attempt, 5)) + continue + raise RuntimeError(last_err) + raise RuntimeError(last_err) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Send one admin message") + parser.add_argument("--sender", required=True, help="Username that sends the message") + parser.add_argument("--receiver", required=True, help="Username that receives the message") + parser.add_argument("--body", required=True, help="Message body") + args = parser.parse_args() + + token = os.environ.get("BOLLWERK_ADMIN_TOKEN", "").strip() + if not token: + print("ERROR: Missing env BOLLWERK_ADMIN_TOKEN.", file=sys.stderr) + return 1 + + url = "https://bollwerk.online/api/admin/send-message" + payload = { + "senderUsername": args.sender.strip(), + "receiverUsername": args.receiver.strip(), + "body": args.body, + "sentAt": int(time.time() * 1000), + } + + if not payload["senderUsername"] or not payload["receiverUsername"] or not payload["body"]: + print("ERROR: sender, receiver and body must not be blank.", file=sys.stderr) + return 1 + + response = request_json(url, "POST", token, payload, timeout=15, retries=3) + msg_id = response.get("message", {}).get("id", "") + print(json.dumps({"ok": True, "messageId": msg_id}, ensure_ascii=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app/src/main/java/de/bollwerk/app/notification/NotificationHelper.kt b/app/src/main/java/de/bollwerk/app/notification/NotificationHelper.kt index 9fab60e..2098487 100644 --- a/app/src/main/java/de/bollwerk/app/notification/NotificationHelper.kt +++ b/app/src/main/java/de/bollwerk/app/notification/NotificationHelper.kt @@ -3,10 +3,13 @@ package de.bollwerk.app.notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent +import android.app.Activity +import android.app.Application import android.content.Context import android.content.Intent import android.media.AudioAttributes import android.media.RingtoneManager +import android.os.Bundle import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import dagger.hilt.android.qualifiers.ApplicationContext @@ -24,9 +27,36 @@ internal class NotificationHelper @Inject constructor( private var activeChatPartnerId: String? = null @Volatile private var isMessagingAreaVisible: Boolean = false + @Volatile + private var isAppInForeground: Boolean = false private val activeSenderNotificationIds = mutableSetOf() private val activeSenderNamesById = mutableMapOf() + init { + val app = context.applicationContext as? Application + app?.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { + private var startedActivities = 0 + + override fun onActivityStarted(activity: Activity) { + startedActivities += 1 + isAppInForeground = true + } + + override fun onActivityStopped(activity: Activity) { + startedActivities = (startedActivities - 1).coerceAtLeast(0) + if (startedActivities == 0) { + isAppInForeground = false + } + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + override fun onActivityResumed(activity: Activity) = Unit + override fun onActivityPaused(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit + }) + } + /// Erstellt den Notification Channel (ab API 26 erforderlich). fun createNotificationChannel() { val soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) @@ -54,7 +84,7 @@ internal class NotificationHelper @Inject constructor( activeChatPartnerId = partnerId } - /// Setzt, ob der Nutzer aktuell im Nachrichtenbereich ist (User-Liste oder Chat). + /// Setzt, ob der Nutzer aktuell den aktiven Chat geöffnet hat. fun setMessagingAreaVisible(isVisible: Boolean) { isMessagingAreaVisible = isVisible } @@ -65,7 +95,8 @@ internal class NotificationHelper @Inject constructor( senderId: String, senderUsername: String ): Boolean { - if (isMessagingAreaVisible || senderId == activeChatPartnerId) return false + // Suppress only while app is foreground and the related chat/messaging UI is visible. + if (isAppInForeground && (isMessagingAreaVisible || senderId == activeChatPartnerId)) return false val chatIntent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP diff --git a/app/src/main/java/de/bollwerk/app/ui/MainScreen.kt b/app/src/main/java/de/bollwerk/app/ui/MainScreen.kt index 9c70f9f..8571fdf 100644 --- a/app/src/main/java/de/bollwerk/app/ui/MainScreen.kt +++ b/app/src/main/java/de/bollwerk/app/ui/MainScreen.kt @@ -67,25 +67,26 @@ internal fun MainScreen(notificationNavigationEvents: SharedFlow when (event) { is NotificationNavigationEvent.OpenChat -> { + if (navController.currentDestination?.hasRoute(Screen.Chat::class) == true) { + navController.popBackStack() + } navController.navigate( Screen.Chat( recipientId = event.recipientId, recipientUsername = event.recipientUsername ) - ) { - launchSingleTop = true - } + ) } NotificationNavigationEvent.OpenMessages -> { diff --git a/server/src/main/kotlin/de/bollwerk/server/repository/MessageRepository.kt b/server/src/main/kotlin/de/bollwerk/server/repository/MessageRepository.kt index 13fd29b..5dd89b6 100644 --- a/server/src/main/kotlin/de/bollwerk/server/repository/MessageRepository.kt +++ b/server/src/main/kotlin/de/bollwerk/server/repository/MessageRepository.kt @@ -104,6 +104,24 @@ internal class MessageRepository { } } + fun getInboxForReceiver(receiverId: String): List = transaction { + Messages.join(Users, JoinType.LEFT, Messages.senderId, Users.id) + .selectAll() + .where { Messages.receiverId eq receiverId } + .orderBy(Messages.sentAt to SortOrder.ASC) + .map { row -> + MessageDto( + id = row[Messages.id], + senderId = row[Messages.senderId], + senderUsername = row.getOrNull(Users.username) ?: "", + receiverId = row[Messages.receiverId], + body = row[Messages.body], + sentAt = row[Messages.sentAt], + deliveredAt = row[Messages.deliveredAt] + ) + } + } + /// Returns the total storage size (in bytes) of undelivered messages for a receiver. fun getUndeliveredStorageBytes(receiverId: String): Long = transaction { Messages.selectAll() diff --git a/server/src/main/kotlin/de/bollwerk/server/routes/AdminMessageRoutes.kt b/server/src/main/kotlin/de/bollwerk/server/routes/AdminMessageRoutes.kt index 3223629..f2b4250 100644 --- a/server/src/main/kotlin/de/bollwerk/server/routes/AdminMessageRoutes.kt +++ b/server/src/main/kotlin/de/bollwerk/server/routes/AdminMessageRoutes.kt @@ -5,8 +5,10 @@ import de.bollwerk.server.repository.MessageRepository import de.bollwerk.server.repository.UserRepository import de.bollwerk.server.service.AdminMessageService import de.bollwerk.server.websocket.WebSocketManager +import de.bollwerk.shared.model.MessageDto import de.bollwerk.shared.model.SendMessageResponse import io.ktor.http.* +import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* @@ -23,6 +25,19 @@ internal data class AdminSendMessageRequest( val sentAt: Long ) +@Serializable +internal data class AdminPublicKeyRequest( + val username: String, + val publicKey: String? = null, + val cloneFromUsername: String? = null +) + +@Serializable +internal data class AdminPublicKeyStatusResponse( + val username: String, + val hasPublicKey: Boolean +) + internal fun Route.adminMessageRoutes( messageRepository: MessageRepository, userRepository: UserRepository, @@ -30,29 +45,37 @@ internal fun Route.adminMessageRoutes( adminMessageService: AdminMessageService, adminToken: String ) { + suspend fun ApplicationCall.requireAdminToken(): Boolean { + if (adminToken.isBlank()) { + respond( + HttpStatusCode.InternalServerError, + ErrorResponse(status = 500, message = "Server not configured for admin messages") + ) + return false + } + + val authHeader = request.headers[HttpHeaders.Authorization] + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + respond( + HttpStatusCode.Unauthorized, + ErrorResponse(status = 401, message = "Unauthorized") + ) + return false + } + val token = authHeader.removePrefix("Bearer ").trim() + if (token != adminToken) { + respond( + HttpStatusCode.Forbidden, + ErrorResponse(status = 403, message = "Forbidden") + ) + return false + } + return true + } + route("/api/admin/send-message") { post { - if (adminToken.isBlank()) { - return@post call.respond( - HttpStatusCode.InternalServerError, - ErrorResponse(status = 500, message = "Server not configured for admin messages") - ) - } - - val authHeader = call.request.headers[HttpHeaders.Authorization] - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - return@post call.respond( - HttpStatusCode.Unauthorized, - ErrorResponse(status = 401, message = "Unauthorized") - ) - } - val token = authHeader.removePrefix("Bearer ").trim() - if (token != adminToken) { - return@post call.respond( - HttpStatusCode.Forbidden, - ErrorResponse(status = 403, message = "Forbidden") - ) - } + if (!call.requireAdminToken()) return@post val request = call.receive() if (request.body.isBlank()) { @@ -112,5 +135,121 @@ internal fun Route.adminMessageRoutes( } call.respond(HttpStatusCode.Created, SendMessageResponse(message = message)) } + + get { + if (!call.requireAdminToken()) return@get + + val userAName = call.request.queryParameters["userA"]?.trim().orEmpty() + val userBName = call.request.queryParameters["userB"]?.trim().orEmpty() + if (userAName.isBlank() || userBName.isBlank()) { + return@get call.respond( + HttpStatusCode.BadRequest, + ErrorResponse(status = 400, message = "Query params userA and userB are required") + ) + } + + val userA = userRepository.findByUsername(userAName) + ?: return@get call.respond( + HttpStatusCode.NotFound, + ErrorResponse(status = 404, message = "User not found: $userAName") + ) + val userB = userRepository.findByUsername(userBName) + ?: return@get call.respond( + HttpStatusCode.NotFound, + ErrorResponse(status = 404, message = "User not found: $userBName") + ) + + val since = call.request.queryParameters["since"]?.toLongOrNull() + val messages = messageRepository.getConversation(userA.id, userB.id) + val filtered = if (since == null) messages else messages.filter { it.sentAt >= since } + + val sorted: List = filtered.sortedBy { it.sentAt } + call.respond(HttpStatusCode.OK, sorted) + } + } + + route("/api/admin/send-message/public-key") { + get { + if (!call.requireAdminToken()) return@get + val username = call.request.queryParameters["username"]?.trim().orEmpty() + if (username.isBlank()) { + return@get call.respond( + HttpStatusCode.BadRequest, + ErrorResponse(status = 400, message = "Query param username is required") + ) + } + val user = userRepository.findByUsername(username) + ?: return@get call.respond( + HttpStatusCode.NotFound, + ErrorResponse(status = 404, message = "User not found: $username") + ) + val hasPublicKey = !userRepository.getPublicKey(user.id).isNullOrBlank() + call.respond(HttpStatusCode.OK, AdminPublicKeyStatusResponse(username = username, hasPublicKey = hasPublicKey)) + } + + post { + if (!call.requireAdminToken()) return@post + val request = call.receive() + if (request.username.isBlank()) { + return@post call.respond( + HttpStatusCode.BadRequest, + ErrorResponse(status = 400, message = "username must not be blank") + ) + } + val user = userRepository.findByUsername(request.username) + ?: return@post call.respond( + HttpStatusCode.NotFound, + ErrorResponse(status = 404, message = "User not found: ${request.username}") + ) + + val keyToSet = when { + !request.publicKey.isNullOrBlank() -> request.publicKey + !request.cloneFromUsername.isNullOrBlank() -> { + val source = userRepository.findByUsername(request.cloneFromUsername) + ?: return@post call.respond( + HttpStatusCode.NotFound, + ErrorResponse(status = 404, message = "Source user not found: ${request.cloneFromUsername}") + ) + userRepository.getPublicKey(source.id) + ?: return@post call.respond( + HttpStatusCode.BadRequest, + ErrorResponse(status = 400, message = "Source user has no public key: ${request.cloneFromUsername}") + ) + } + + else -> null + } + + if (keyToSet.isNullOrBlank()) { + return@post call.respond( + HttpStatusCode.BadRequest, + ErrorResponse(status = 400, message = "Either publicKey or cloneFromUsername is required") + ) + } + + userRepository.setPublicKey(user.id, keyToSet) + call.respond(HttpStatusCode.OK, mapOf("message" to "Public key set", "username" to request.username)) + } + } + + get("/api/admin/send-message/inbox") { + if (!call.requireAdminToken()) return@get + + val username = call.request.queryParameters["username"]?.trim().orEmpty() + if (username.isBlank()) { + return@get call.respond( + HttpStatusCode.BadRequest, + ErrorResponse(status = 400, message = "Query param username is required") + ) + } + + val user = userRepository.findByUsername(username) + ?: return@get call.respond( + HttpStatusCode.NotFound, + ErrorResponse(status = 404, message = "User not found: $username") + ) + + val messages = messageRepository.getInboxForReceiver(user.id) + call.respond(HttpStatusCode.OK, messages) } } diff --git a/server/src/test/kotlin/de/bollwerk/server/AdminMessageApiTest.kt b/server/src/test/kotlin/de/bollwerk/server/AdminMessageApiTest.kt index 35b043d..c59a484 100644 --- a/server/src/test/kotlin/de/bollwerk/server/AdminMessageApiTest.kt +++ b/server/src/test/kotlin/de/bollwerk/server/AdminMessageApiTest.kt @@ -2,6 +2,8 @@ package de.bollwerk.server import de.bollwerk.server.db.DatabaseFactory import de.bollwerk.server.model.ErrorResponse +import de.bollwerk.server.repository.MessageRepository +import de.bollwerk.shared.model.MessageDto import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* @@ -114,4 +116,76 @@ class AdminMessageApiTest { val error = json.decodeFromString(response.bodyAsText()) assertEquals(404, error.status) } + + @Test + fun test_adminReceiveConversation_noAuth_returns401() = testApp { + val response = client.get("/api/admin/send-message?userA=bob&userB=alice") + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun test_adminReceiveConversation_invalidToken_returns403() = testApp { + val response = client.get("/api/admin/send-message?userA=bob&userB=alice") { + header(HttpHeaders.Authorization, "Bearer wrong-token") + } + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun test_adminReceiveConversation_returnsMessages() = testApp { + val bobId = createUser("bob") + val aliceId = createUser("alice") + val bobToken = createTestAccessToken(userId = bobId, username = "bob") + val aliceToken = createTestAccessToken(userId = aliceId, username = "alice") + + client.post("/api/messages") { + bearerAuth(bobToken) + contentType(ContentType.Application.Json) + setBody("""{"receiverId":"$aliceId","body":"b->a","sentAt":1700000000100}""") + } + client.post("/api/messages") { + bearerAuth(aliceToken) + contentType(ContentType.Application.Json) + setBody("""{"receiverId":"$bobId","body":"a->b","sentAt":1700000000200}""") + } + + val response = client.get("/api/admin/send-message?userA=bob&userB=alice") { + header(HttpHeaders.Authorization, "Bearer $TEST_ADMIN_TOKEN") + } + + assertEquals(HttpStatusCode.OK, response.status) + val messages = json.decodeFromString>(response.bodyAsText()) + assertEquals(2, messages.size) + } + + @Test + fun test_adminInbox_noAuth_returns401() = testApp { + val response = client.get("/api/admin/send-message/inbox?username=admin") + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun test_adminInbox_returnsMessagesForReceiver() = testApp { + val receiverUsername = "receiver-admin-inbox" + val adminId = createUser(receiverUsername) + val bobId = createUser("bob") + val aliceId = createUser("alice") + val repo = MessageRepository() + repo.save( + id = "msg-inbox-1", + senderId = bobId, + senderUsername = "bob", + receiverId = adminId, + body = "to-admin-1", + sentAt = 1700000001000 + ) + + val response = client.get("/api/admin/send-message/inbox?username=$receiverUsername") { + header(HttpHeaders.Authorization, "Bearer $TEST_ADMIN_TOKEN") + } + + assertEquals(HttpStatusCode.OK, response.status) + val messages = json.decodeFromString>(response.bodyAsText()) + assertEquals(1, messages.size) + } }