feat(admin-message): simplify scripts, add inbox route, fix notification chat switch

This commit is contained in:
Jens Reinemann 2026-05-18 17:45:19 +02:00
parent 0a4d9fc20e
commit 6fd8528577
9 changed files with 675 additions and 38 deletions

View file

@ -11,13 +11,15 @@ Ziel:
- E2EE-Nachrichten serverseitig erzeugen und an reale User zustellen - E2EE-Nachrichten serverseitig erzeugen und an reale User zustellen
- Endpunkt korrekt mit Bearer-Admin-Token verwenden - Endpunkt korrekt mit Bearer-Admin-Token verwenden
- Zustellung anschliessend ueber Conversation-API verifizieren - Zustellung anschliessend ueber Inbox-API verifizieren
## Technischer Hintergrund ## Technischer Hintergrund
Der Endpunkt ist: Der Endpunkt ist:
- `POST /api/admin/send-message` - `POST /api/admin/send-message`
- `GET /api/admin/send-message?userA=<username>&userB=<username>[&since=<epochMs>]`
- `GET /api/admin/send-message/inbox?username=<username>`
Pflichtbedingungen: Pflichtbedingungen:
@ -65,12 +67,61 @@ $payload = @{
Invoke-RestMethod -Method POST -Uri "https://bollwerk.online/api/admin/send-message" -Headers $headers -Body $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 ```powershell
Invoke-RestMethod -Method GET -Uri "https://bollwerk.online/api/messages/<OTHER_USER_ID>" -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 ## Erwartete Fehlerbilder
- `401 Unauthorized`: Bearer-Token fehlt - `401 Unauthorized`: Bearer-Token fehlt
@ -78,17 +129,19 @@ Invoke-RestMethod -Method GET -Uri "https://bollwerk.online/api/messages/<OTHER_
- `404 Sender not found`: `senderUsername` existiert nicht - `404 Sender not found`: `senderUsername` existiert nicht
- `400 Encryption failed`: Empfaenger hat keinen gueltigen E2EE Public Key - `400 Encryption failed`: Empfaenger hat keinen gueltigen E2EE Public Key
- `404 Receiver not found`: falsche `receiverId` - `404 Receiver not found`: falsche `receiverId`
- `400 Query param username is required`: Inbox-Request unvollstaendig
## Generaltest-Standardfall ## Generaltest-Standardfall
Wenn User explizit "1x Bob, mehrere x Alice" wuenscht und Admin-Route genutzt wird: Wenn User explizit "1x Bob, mehrere x Alice" wuenscht:
- Sende 1 Nachricht an Admin-Konversation mit Prefix `[bob] ...` - Sende 1 Nachricht mit `--sender bob --receiver admin`
- Sende 3 Nachrichten an Admin-Konversation mit `senderUsername = "alice"` - Sende mehrere Nachrichten mit `--sender alice --receiver admin`
- Dokumentiere klar, welcher `senderUsername` je Request verwendet wurde - Verifiziere mit `receive_admin_messages.py --username admin`
## Security-Hinweise ## Security-Hinweise
- Secrets nie im Chat ausgeben - Secrets nie im Chat ausgeben
- Tokens nur maskiert loggen - Tokens nur maskiert loggen
- Keine Passwoerter fuer diesen Endpunkt erforderlich - Keine Passwoerter fuer diesen Endpunkt erforderlich
- Keine automatischen Key-Mutationen in den Minimal-Skripten

View file

@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Generaltest fuer stoerungsreiche bob/alice-Kommunikation.
Szenario:
1) bob sendet an alice waehrend alice offline ist.
2) bob geht offline, alice kommt online, empfaengt und antwortet.
3) alice geht offline, bob kommt online, empfaengt und antwortet.
Hinweis zur Integritaet:
- Die Admin-API liefert E2EE-Ciphertext, nicht Klartext.
- Geprueft wird deshalb Transport-Integritaet ueber IDs, Reihenfolge,
Sender/Empfaenger, UTF-8/Emoji-Sendeerfolg und Inbox-Sichtbarkeit.
"""
from __future__ import annotations
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, List
BASE_URL = "https://bollwerk.online"
def request_json(
url: str,
method: str,
token: str,
payload: Dict[str, Any] | None,
timeout: int = 15,
retries: int = 3,
) -> 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())

View file

@ -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())

View file

@ -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())

View file

@ -3,10 +3,13 @@ package de.bollwerk.app.notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Activity
import android.app.Application
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.RingtoneManager import android.media.RingtoneManager
import android.os.Bundle
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -24,9 +27,36 @@ internal class NotificationHelper @Inject constructor(
private var activeChatPartnerId: String? = null private var activeChatPartnerId: String? = null
@Volatile @Volatile
private var isMessagingAreaVisible: Boolean = false private var isMessagingAreaVisible: Boolean = false
@Volatile
private var isAppInForeground: Boolean = false
private val activeSenderNotificationIds = mutableSetOf<Int>() private val activeSenderNotificationIds = mutableSetOf<Int>()
private val activeSenderNamesById = mutableMapOf<Int, String>() private val activeSenderNamesById = mutableMapOf<Int, String>()
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). /// Erstellt den Notification Channel (ab API 26 erforderlich).
fun createNotificationChannel() { fun createNotificationChannel() {
val soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) val soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
@ -54,7 +84,7 @@ internal class NotificationHelper @Inject constructor(
activeChatPartnerId = partnerId 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) { fun setMessagingAreaVisible(isVisible: Boolean) {
isMessagingAreaVisible = isVisible isMessagingAreaVisible = isVisible
} }
@ -65,7 +95,8 @@ internal class NotificationHelper @Inject constructor(
senderId: String, senderId: String,
senderUsername: String senderUsername: String
): Boolean { ): 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 { val chatIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP

View file

@ -67,25 +67,26 @@ internal fun MainScreen(notificationNavigationEvents: SharedFlow<NotificationNav
} }
} }
val isInMessagingArea = // Suppress push notifications only while the chat screen is open.
currentDestination?.hasRoute(Screen.UserList::class) == true || // The user list should still receive notifications for new incoming messages.
currentDestination?.hasRoute(Screen.Chat::class) == true val isInActiveChat = currentDestination?.hasRoute(Screen.Chat::class) == true
LaunchedEffect(isInMessagingArea) { LaunchedEffect(isInActiveChat) {
mainViewModel.setMessagingAreaVisible(isInMessagingArea) mainViewModel.setMessagingAreaVisible(isInActiveChat)
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
notificationNavigationEvents.collect { event -> notificationNavigationEvents.collect { event ->
when (event) { when (event) {
is NotificationNavigationEvent.OpenChat -> { is NotificationNavigationEvent.OpenChat -> {
if (navController.currentDestination?.hasRoute(Screen.Chat::class) == true) {
navController.popBackStack()
}
navController.navigate( navController.navigate(
Screen.Chat( Screen.Chat(
recipientId = event.recipientId, recipientId = event.recipientId,
recipientUsername = event.recipientUsername recipientUsername = event.recipientUsername
) )
) { )
launchSingleTop = true
}
} }
NotificationNavigationEvent.OpenMessages -> { NotificationNavigationEvent.OpenMessages -> {

View file

@ -104,6 +104,24 @@ internal class MessageRepository {
} }
} }
fun getInboxForReceiver(receiverId: String): List<MessageDto> = 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. /// Returns the total storage size (in bytes) of undelivered messages for a receiver.
fun getUndeliveredStorageBytes(receiverId: String): Long = transaction { fun getUndeliveredStorageBytes(receiverId: String): Long = transaction {
Messages.selectAll() Messages.selectAll()

View file

@ -5,8 +5,10 @@ import de.bollwerk.server.repository.MessageRepository
import de.bollwerk.server.repository.UserRepository import de.bollwerk.server.repository.UserRepository
import de.bollwerk.server.service.AdminMessageService import de.bollwerk.server.service.AdminMessageService
import de.bollwerk.server.websocket.WebSocketManager import de.bollwerk.server.websocket.WebSocketManager
import de.bollwerk.shared.model.MessageDto
import de.bollwerk.shared.model.SendMessageResponse import de.bollwerk.shared.model.SendMessageResponse
import io.ktor.http.* import io.ktor.http.*
import io.ktor.server.application.*
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.*
@ -23,6 +25,19 @@ internal data class AdminSendMessageRequest(
val sentAt: Long 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( internal fun Route.adminMessageRoutes(
messageRepository: MessageRepository, messageRepository: MessageRepository,
userRepository: UserRepository, userRepository: UserRepository,
@ -30,29 +45,37 @@ internal fun Route.adminMessageRoutes(
adminMessageService: AdminMessageService, adminMessageService: AdminMessageService,
adminToken: String adminToken: String
) { ) {
route("/api/admin/send-message") { suspend fun ApplicationCall.requireAdminToken(): Boolean {
post {
if (adminToken.isBlank()) { if (adminToken.isBlank()) {
return@post call.respond( respond(
HttpStatusCode.InternalServerError, HttpStatusCode.InternalServerError,
ErrorResponse(status = 500, message = "Server not configured for admin messages") ErrorResponse(status = 500, message = "Server not configured for admin messages")
) )
return false
} }
val authHeader = call.request.headers[HttpHeaders.Authorization] val authHeader = request.headers[HttpHeaders.Authorization]
if (authHeader == null || !authHeader.startsWith("Bearer ")) { if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return@post call.respond( respond(
HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized,
ErrorResponse(status = 401, message = "Unauthorized") ErrorResponse(status = 401, message = "Unauthorized")
) )
return false
} }
val token = authHeader.removePrefix("Bearer ").trim() val token = authHeader.removePrefix("Bearer ").trim()
if (token != adminToken) { if (token != adminToken) {
return@post call.respond( respond(
HttpStatusCode.Forbidden, HttpStatusCode.Forbidden,
ErrorResponse(status = 403, message = "Forbidden") ErrorResponse(status = 403, message = "Forbidden")
) )
return false
} }
return true
}
route("/api/admin/send-message") {
post {
if (!call.requireAdminToken()) return@post
val request = call.receive<AdminSendMessageRequest>() val request = call.receive<AdminSendMessageRequest>()
if (request.body.isBlank()) { if (request.body.isBlank()) {
@ -112,5 +135,121 @@ internal fun Route.adminMessageRoutes(
} }
call.respond(HttpStatusCode.Created, SendMessageResponse(message = message)) 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<MessageDto> = 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<AdminPublicKeyRequest>()
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)
} }
} }

View file

@ -2,6 +2,8 @@ package de.bollwerk.server
import de.bollwerk.server.db.DatabaseFactory import de.bollwerk.server.db.DatabaseFactory
import de.bollwerk.server.model.ErrorResponse 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.request.*
import io.ktor.client.statement.* import io.ktor.client.statement.*
import io.ktor.http.* import io.ktor.http.*
@ -114,4 +116,76 @@ class AdminMessageApiTest {
val error = json.decodeFromString<ErrorResponse>(response.bodyAsText()) val error = json.decodeFromString<ErrorResponse>(response.bodyAsText())
assertEquals(404, error.status) 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<List<MessageDto>>(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<List<MessageDto>>(response.bodyAsText())
assertEquals(1, messages.size)
}
} }