feat(admin-message): simplify scripts, add inbox route, fix notification chat switch
This commit is contained in:
parent
0a4d9fc20e
commit
6fd8528577
9 changed files with 675 additions and 38 deletions
67
.github/skills/admin-message-e2ee/SKILL.md
vendored
67
.github/skills/admin-message-e2ee/SKILL.md
vendored
|
|
@ -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=<username>&userB=<username>[&since=<epochMs>]`
|
||||
- `GET /api/admin/send-message/inbox?username=<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/<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
|
||||
|
||||
- `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
|
||||
- `400 Encryption failed`: Empfaenger hat keinen gueltigen E2EE Public Key
|
||||
- `404 Receiver not found`: falsche `receiverId`
|
||||
- `400 Query param username is required`: Inbox-Request unvollstaendig
|
||||
|
||||
## 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 3 Nachrichten an Admin-Konversation mit `senderUsername = "alice"`
|
||||
- Dokumentiere klar, welcher `senderUsername` je Request verwendet wurde
|
||||
- Sende 1 Nachricht mit `--sender bob --receiver admin`
|
||||
- Sende mehrere Nachrichten mit `--sender alice --receiver admin`
|
||||
- Verifiziere mit `receive_admin_messages.py --username admin`
|
||||
|
||||
## Security-Hinweise
|
||||
|
||||
- Secrets nie im Chat ausgeben
|
||||
- Tokens nur maskiert loggen
|
||||
- Keine Passwoerter fuer diesen Endpunkt erforderlich
|
||||
- Keine automatischen Key-Mutationen in den Minimal-Skripten
|
||||
|
|
|
|||
146
.github/skills/admin-message-e2ee/generaltest_admin_message.py
vendored
Normal file
146
.github/skills/admin-message-e2ee/generaltest_admin_message.py
vendored
Normal 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())
|
||||
84
.github/skills/admin-message-e2ee/receive_admin_messages.py
vendored
Normal file
84
.github/skills/admin-message-e2ee/receive_admin_messages.py
vendored
Normal 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())
|
||||
91
.github/skills/admin-message-e2ee/send_admin_messages.py
vendored
Normal file
91
.github/skills/admin-message-e2ee/send_admin_messages.py
vendored
Normal 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())
|
||||
|
|
@ -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<Int>()
|
||||
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).
|
||||
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
|
||||
|
|
|
|||
|
|
@ -67,25 +67,26 @@ internal fun MainScreen(notificationNavigationEvents: SharedFlow<NotificationNav
|
|||
}
|
||||
}
|
||||
|
||||
val isInMessagingArea =
|
||||
currentDestination?.hasRoute(Screen.UserList::class) == true ||
|
||||
currentDestination?.hasRoute(Screen.Chat::class) == true
|
||||
LaunchedEffect(isInMessagingArea) {
|
||||
mainViewModel.setMessagingAreaVisible(isInMessagingArea)
|
||||
// Suppress push notifications only while the chat screen is open.
|
||||
// The user list should still receive notifications for new incoming messages.
|
||||
val isInActiveChat = currentDestination?.hasRoute(Screen.Chat::class) == true
|
||||
LaunchedEffect(isInActiveChat) {
|
||||
mainViewModel.setMessagingAreaVisible(isInActiveChat)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
notificationNavigationEvents.collect { event ->
|
||||
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 -> {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
fun getUndeliveredStorageBytes(receiverId: String): Long = transaction {
|
||||
Messages.selectAll()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
route("/api/admin/send-message") {
|
||||
post {
|
||||
suspend fun ApplicationCall.requireAdminToken(): Boolean {
|
||||
if (adminToken.isBlank()) {
|
||||
return@post call.respond(
|
||||
respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
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 ")) {
|
||||
return@post call.respond(
|
||||
respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
ErrorResponse(status = 401, message = "Unauthorized")
|
||||
)
|
||||
return false
|
||||
}
|
||||
val token = authHeader.removePrefix("Bearer ").trim()
|
||||
if (token != adminToken) {
|
||||
return@post call.respond(
|
||||
respond(
|
||||
HttpStatusCode.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>()
|
||||
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<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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ErrorResponse>(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<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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue