SHA256
feat(dm): implement signed direct messaging with web push fallback
This commit is contained in:
@@ -23,6 +23,7 @@ dependencies {
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' // json
|
||||
|
||||
implementation "org.slf4j:slf4j-api:2.0.16" // вызов логгера
|
||||
implementation 'nl.martijndwars:web-push:5.1.1'
|
||||
|
||||
implementation project(':shine-server-config') // модуль с настройками
|
||||
implementation project(":shine-server-log") // модуль логирования и уведомления админов
|
||||
@@ -40,4 +41,3 @@ java {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
|
||||
|
||||
+153
-75
@@ -9,18 +9,24 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Response;
|
||||
import server.logic.ws_protocol.JSON.push.FcmPushSender;
|
||||
import server.logic.ws_protocol.JSON.push.WebPushSender;
|
||||
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.dao.ActiveSessionsDAO;
|
||||
import shine.db.dao.DirectMessagesDAO;
|
||||
import shine.db.dao.PushTokensDAO;
|
||||
import shine.db.dao.SignedDirectMessagesHistoryDAO;
|
||||
import shine.db.dao.SignedDmReplayDAO;
|
||||
import shine.db.dao.SolanaUsersDAO;
|
||||
import shine.db.entities.ActiveSessionEntry;
|
||||
import shine.db.entities.DirectMessageEntry;
|
||||
import shine.db.entities.PushTokenEntry;
|
||||
import shine.db.entities.SignedDirectMessageHistoryEntry;
|
||||
import shine.db.entities.SolanaUserEntry;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
@@ -29,102 +35,174 @@ import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class Net_SendDirectMessage_Handler implements JsonMessageHandler {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final long REPLAY_TTL_MS = 15L * 60L * 1000L;
|
||||
private static final int MAX_MESSAGE_BYTES = 3000;
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||
Net_SendDirectMessage_Request req = (Net_SendDirectMessage_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
if (req.getToLogin() == null || req.getToLogin().isBlank() || req.getText() == null || req.getText().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin и text обязательны");
|
||||
if (req.getBlobB64() == null || req.getBlobB64().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "blobB64 обязателен");
|
||||
}
|
||||
|
||||
String from = ctx.getLogin();
|
||||
String toRequest = req.getToLogin().trim();
|
||||
String text = req.getText().trim();
|
||||
|
||||
SolanaUserEntry targetUser = SolanaUsersDAO.getInstance().getByLogin(toRequest);
|
||||
if (targetUser == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
||||
final byte[] raw;
|
||||
final SignedDirectMessagePacket packet;
|
||||
try {
|
||||
raw = Base64.getDecoder().decode(req.getBlobB64().trim());
|
||||
packet = SignedDirectMessagePacket.parse(raw, MAX_MESSAGE_BYTES);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат пакета");
|
||||
}
|
||||
String to = targetUser.getLogin();
|
||||
|
||||
if (!canSend(from, to)) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NO_PERMISSION", "Можно писать только контактам или тем, кто уже писал вам");
|
||||
SolanaUserEntry fromUser = SolanaUsersDAO.getInstance().getByLogin(packet.fromLogin);
|
||||
SolanaUserEntry toUser = SolanaUsersDAO.getInstance().getByLogin(packet.toLogin);
|
||||
if (fromUser == null || toUser == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "from/to пользователь не найден");
|
||||
}
|
||||
|
||||
byte[] publicKey32;
|
||||
try {
|
||||
publicKey32 = Ed25519Util.keyFromBase64(fromUser.getDeviceKey());
|
||||
} catch (Exception e) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNPROCESSABLE, "BAD_DEVICE_KEY", "Некорректный deviceKey отправителя");
|
||||
}
|
||||
if (!Ed25519Util.verify(packet.signedBody, packet.signature64, publicKey32)) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNPROCESSABLE, "BAD_SIGNATURE", "Подпись не прошла проверку");
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (Math.abs(now - packet.timeMs) > REPLAY_TTL_MS) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNPROCESSABLE, "BAD_TIME_WINDOW", "Время сообщения вышло за окно 15 минут");
|
||||
}
|
||||
|
||||
boolean replayOk = SignedDmReplayDAO.getInstance().registerUnique(packet.fromLogin, packet.timeMs, packet.nonce, now);
|
||||
if (!replayOk) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNPROCESSABLE, "REPLAY", "Повторное сообщение заблокировано");
|
||||
}
|
||||
|
||||
String messageId = NetIdGenerator.eventId("msg");
|
||||
String textForUi = new String(packet.messageBytes, StandardCharsets.UTF_8);
|
||||
|
||||
DirectMessageEntry entry = new DirectMessageEntry();
|
||||
entry.setMessageId(messageId);
|
||||
entry.setFromLogin(from);
|
||||
entry.setToLogin(to);
|
||||
entry.setText(text);
|
||||
entry.setCreatedAtMs(System.currentTimeMillis());
|
||||
entry.setFromLogin(packet.fromLogin);
|
||||
entry.setToLogin(packet.toLogin);
|
||||
entry.setText(textForUi);
|
||||
entry.setCreatedAtMs(now);
|
||||
DirectMessagesDAO.getInstance().insert(entry);
|
||||
|
||||
Set<ConnectionContext> activeSessions = ActiveConnectionsRegistry.getInstance().getByLogin(to);
|
||||
List<PushTokenEntry> tokens = PushTokensDAO.getInstance().listByLogin(to);
|
||||
SignedDirectMessageHistoryEntry history = new SignedDirectMessageHistoryEntry();
|
||||
history.setMessageId(messageId);
|
||||
history.setFromLogin(packet.fromLogin);
|
||||
history.setToLogin(packet.toLogin);
|
||||
history.setTargetMode(packet.targetMode);
|
||||
history.setTargetSessionId(packet.targetSessionId);
|
||||
history.setMessageType(packet.messageType);
|
||||
history.setTimeMs(packet.timeMs);
|
||||
history.setNonce(packet.nonce);
|
||||
history.setRawPacket(packet.rawPacket);
|
||||
history.setCreatedAtMs(now);
|
||||
SignedDirectMessagesHistoryDAO.getInstance().insert(history);
|
||||
|
||||
int wsDelivered = 0;
|
||||
int fcmDelivered = 0;
|
||||
|
||||
Set<String> activeSessionIds = new HashSet<>();
|
||||
for (ConnectionContext targetCtx : activeSessions) {
|
||||
activeSessionIds.add(targetCtx.getSessionId());
|
||||
String eventId = NetIdGenerator.eventId("evt");
|
||||
CompletableFuture<Boolean> waiter = DeliveryTracker.getInstance().register(eventId);
|
||||
|
||||
ObjectNode payload = MAPPER.createObjectNode();
|
||||
payload.put("eventId", eventId);
|
||||
payload.put("messageId", messageId);
|
||||
payload.put("fromLogin", from);
|
||||
payload.put("toLogin", to);
|
||||
payload.put("text", text);
|
||||
payload.put("timeMs", entry.getCreatedAtMs());
|
||||
|
||||
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingDirectMessage", eventId, payload);
|
||||
boolean acked = false;
|
||||
if (sent) {
|
||||
try {
|
||||
acked = waiter.get(1200, TimeUnit.MILLISECONDS);
|
||||
} catch (Exception ignored) {
|
||||
acked = false;
|
||||
}
|
||||
}
|
||||
DeliveryTracker.getInstance().remove(eventId);
|
||||
if (acked) {
|
||||
wsDelivered++;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (PushTokenEntry token : tokens) {
|
||||
if (!targetCtx.getSessionId().equals(token.getSessionId())) continue;
|
||||
boolean pushed = FcmPushSender.sendNotification(token.getToken(), "Новое сообщение", text, messageId);
|
||||
if (pushed) {
|
||||
fcmDelivered++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (PushTokenEntry token : tokens) {
|
||||
if (activeSessionIds.contains(token.getSessionId())) continue;
|
||||
boolean pushed = FcmPushSender.sendNotification(token.getToken(), "Новое сообщение", text, messageId);
|
||||
if (pushed) fcmDelivered++;
|
||||
}
|
||||
DeliveryResult delivery = deliver(packet, req.getBlobB64().trim(), messageId, now);
|
||||
|
||||
Net_SendDirectMessage_Response resp = new Net_SendDirectMessage_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setMessageId(messageId);
|
||||
resp.setDeliveredWsSessions(wsDelivered);
|
||||
resp.setDeliveredFcmSessions(fcmDelivered);
|
||||
resp.setDeliveredWsSessions(delivery.wsDelivered);
|
||||
resp.setDeliveredWebPushSessions(delivery.webPushDelivered);
|
||||
resp.setSessionNotFound(delivery.sessionNotFound);
|
||||
return resp;
|
||||
}
|
||||
|
||||
private boolean canSend(String from, String to) {
|
||||
return from != null && !from.isBlank() && to != null && !to.isBlank();
|
||||
private DeliveryResult deliver(SignedDirectMessagePacket packet, String blobB64, String messageId, long createdAtMs) throws Exception {
|
||||
DeliveryResult result = new DeliveryResult();
|
||||
|
||||
Set<String> selectedSessionIds = new HashSet<>();
|
||||
if (packet.targetMode == SignedDirectMessagePacket.TARGET_ONE_SESSION) {
|
||||
ActiveSessionEntry byId = ActiveSessionsDAO.getInstance().getBySessionId(packet.targetSessionId);
|
||||
if (byId == null || !packet.toLogin.equalsIgnoreCase(byId.getLogin())) {
|
||||
result.sessionNotFound = true;
|
||||
return result;
|
||||
}
|
||||
selectedSessionIds.add(byId.getSessionId());
|
||||
deliverToSession(packet, blobB64, messageId, createdAtMs, byId.getSessionId(), result);
|
||||
return result;
|
||||
}
|
||||
|
||||
List<ActiveSessionEntry> sessions = ActiveSessionsDAO.getInstance().getByLogin(packet.toLogin);
|
||||
for (ActiveSessionEntry s : sessions) {
|
||||
selectedSessionIds.add(s.getSessionId());
|
||||
deliverToSession(packet, blobB64, messageId, createdAtMs, s.getSessionId(), result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void deliverToSession(
|
||||
SignedDirectMessagePacket packet,
|
||||
String blobB64,
|
||||
String messageId,
|
||||
long createdAtMs,
|
||||
String sessionId,
|
||||
DeliveryResult result
|
||||
) {
|
||||
ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId);
|
||||
boolean wsDelivered = false;
|
||||
if (targetCtx != null) {
|
||||
String eventId = NetIdGenerator.eventId("evt");
|
||||
CompletableFuture<Boolean> waiter = DeliveryTracker.getInstance().register(eventId);
|
||||
ObjectNode payload = MAPPER.createObjectNode();
|
||||
payload.put("eventId", eventId);
|
||||
payload.put("messageId", messageId);
|
||||
payload.put("fromLogin", packet.fromLogin);
|
||||
payload.put("toLogin", packet.toLogin);
|
||||
payload.put("blobB64", blobB64);
|
||||
payload.put("text", new String(packet.messageBytes, StandardCharsets.UTF_8));
|
||||
payload.put("timeMs", createdAtMs);
|
||||
|
||||
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingDirectMessage", eventId, payload);
|
||||
if (sent) {
|
||||
try {
|
||||
wsDelivered = waiter.get(1200, TimeUnit.MILLISECONDS);
|
||||
} catch (Exception ignored) {
|
||||
wsDelivered = false;
|
||||
}
|
||||
}
|
||||
DeliveryTracker.getInstance().remove(eventId);
|
||||
}
|
||||
|
||||
if (wsDelivered) {
|
||||
result.wsDelivered++;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ActiveSessionEntry targetSession = ActiveSessionsDAO.getInstance().getBySessionId(sessionId);
|
||||
if (targetSession == null) return;
|
||||
if (isBlank(targetSession.getPushEndpoint()) || isBlank(targetSession.getPushP256dhKey()) || isBlank(targetSession.getPushAuthKey())) {
|
||||
return;
|
||||
}
|
||||
boolean pushed = WebPushSender.sendBase64Payload(
|
||||
targetSession.getPushEndpoint(),
|
||||
targetSession.getPushP256dhKey(),
|
||||
targetSession.getPushAuthKey(),
|
||||
blobB64
|
||||
);
|
||||
if (pushed) result.webPushDelivered++;
|
||||
} catch (Exception ignored) {
|
||||
// ignore per-session push errors
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBlank(String s) {
|
||||
return s == null || s.isBlank();
|
||||
}
|
||||
|
||||
private static final class DeliveryResult {
|
||||
int wsDelivered;
|
||||
int webPushDelivered;
|
||||
boolean sessionNotFound;
|
||||
}
|
||||
}
|
||||
|
||||
+15
-16
@@ -8,8 +8,7 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Reque
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.dao.PushTokensDAO;
|
||||
import shine.db.entities.PushTokenEntry;
|
||||
import shine.db.dao.ActiveSessionsDAO;
|
||||
|
||||
public class Net_UpsertPushToken_Handler implements JsonMessageHandler {
|
||||
@Override
|
||||
@@ -18,27 +17,27 @@ public class Net_UpsertPushToken_Handler implements JsonMessageHandler {
|
||||
if (ctx == null || !ctx.isAuthenticatedUser()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
if (req.getTokenId() == null || req.getTokenId().isBlank() || req.getToken() == null || req.getToken().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "tokenId и token обязательны");
|
||||
if (req.getEndpoint() == null || req.getEndpoint().isBlank()
|
||||
|| req.getP256dhKey() == null || req.getP256dhKey().isBlank()
|
||||
|| req.getAuthKey() == null || req.getAuthKey().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "endpoint/p256dhKey/authKey обязательны");
|
||||
}
|
||||
|
||||
PushTokenEntry e = new PushTokenEntry();
|
||||
e.setTokenId(req.getTokenId().trim());
|
||||
e.setLogin(ctx.getLogin());
|
||||
e.setSessionId((req.getSessionId() == null || req.getSessionId().isBlank()) ? ctx.getSessionId() : req.getSessionId().trim());
|
||||
e.setProvider(req.getProvider() == null || req.getProvider().isBlank() ? "fcm" : req.getProvider().trim());
|
||||
e.setToken(req.getToken().trim());
|
||||
e.setPlatform(req.getPlatform());
|
||||
e.setUserAgent(req.getUserAgent());
|
||||
e.setUpdatedAtMs(System.currentTimeMillis());
|
||||
PushTokensDAO.getInstance().upsert(e);
|
||||
String sessionId = (req.getSessionId() == null || req.getSessionId().isBlank()) ? ctx.getSessionId() : req.getSessionId().trim();
|
||||
long now = System.currentTimeMillis();
|
||||
ActiveSessionsDAO.getInstance().updatePushSubscription(
|
||||
sessionId,
|
||||
req.getEndpoint().trim(),
|
||||
req.getP256dhKey().trim(),
|
||||
req.getAuthKey().trim()
|
||||
);
|
||||
|
||||
Net_UpsertPushToken_Response resp = new Net_UpsertPushToken_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setTokenId(e.getTokenId());
|
||||
resp.setUpdatedAtMs(e.getUpdatedAtMs());
|
||||
resp.setTokenId(sessionId);
|
||||
resp.setUpdatedAtMs(now);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package server.logic.ws_protocol.JSON.messages;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
final class SignedDirectMessagePacket {
|
||||
static final byte[] PREFIX = "SHiNE_msg".getBytes(StandardCharsets.US_ASCII);
|
||||
static final int VERSION = 1;
|
||||
static final int MESSAGE_TYPE_DIRECT = 1;
|
||||
static final int TARGET_ALL_SESSIONS = 0;
|
||||
static final int TARGET_ONE_SESSION = 1;
|
||||
|
||||
final int version;
|
||||
final String toLogin;
|
||||
final String fromLogin;
|
||||
final long timeMs;
|
||||
final long nonce;
|
||||
final int messageType;
|
||||
final int targetMode;
|
||||
final String targetSessionId;
|
||||
final byte[] messageBytes;
|
||||
final byte[] signedBody;
|
||||
final byte[] signature64;
|
||||
final byte[] rawPacket;
|
||||
|
||||
private SignedDirectMessagePacket(
|
||||
int version,
|
||||
String toLogin,
|
||||
String fromLogin,
|
||||
long timeMs,
|
||||
long nonce,
|
||||
int messageType,
|
||||
int targetMode,
|
||||
String targetSessionId,
|
||||
byte[] messageBytes,
|
||||
byte[] signedBody,
|
||||
byte[] signature64,
|
||||
byte[] rawPacket
|
||||
) {
|
||||
this.version = version;
|
||||
this.toLogin = toLogin;
|
||||
this.fromLogin = fromLogin;
|
||||
this.timeMs = timeMs;
|
||||
this.nonce = nonce;
|
||||
this.messageType = messageType;
|
||||
this.targetMode = targetMode;
|
||||
this.targetSessionId = targetSessionId;
|
||||
this.messageBytes = messageBytes;
|
||||
this.signedBody = signedBody;
|
||||
this.signature64 = signature64;
|
||||
this.rawPacket = rawPacket;
|
||||
}
|
||||
|
||||
static SignedDirectMessagePacket parse(byte[] raw, int maxMessageBytes) {
|
||||
if (raw == null || raw.length < PREFIX.length + 1 + 1 + 1 + 8 + 4 + 2 + 1 + 2 + 64) {
|
||||
throw new IllegalArgumentException("BAD_LEN");
|
||||
}
|
||||
if (raw.length > 4096) {
|
||||
throw new IllegalArgumentException("PAYLOAD_TOO_LARGE");
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN);
|
||||
byte[] prefixBytes = new byte[PREFIX.length];
|
||||
bb.get(prefixBytes);
|
||||
if (!Arrays.equals(prefixBytes, PREFIX)) {
|
||||
throw new IllegalArgumentException("BAD_PREFIX");
|
||||
}
|
||||
|
||||
int version = Byte.toUnsignedInt(bb.get());
|
||||
if (version != VERSION) {
|
||||
throw new IllegalArgumentException("BAD_VERSION");
|
||||
}
|
||||
|
||||
String toLogin = readAscii(bb, 1, 30, "BAD_TO_LOGIN");
|
||||
String fromLogin = readAscii(bb, 1, 30, "BAD_FROM_LOGIN");
|
||||
|
||||
long timeMs = bb.getLong();
|
||||
if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME");
|
||||
|
||||
long nonce = Integer.toUnsignedLong(bb.getInt());
|
||||
|
||||
int messageType = Short.toUnsignedInt(bb.getShort());
|
||||
if (messageType != MESSAGE_TYPE_DIRECT) {
|
||||
throw new IllegalArgumentException("BAD_MESSAGE_TYPE");
|
||||
}
|
||||
|
||||
int targetMode = Byte.toUnsignedInt(bb.get());
|
||||
if (targetMode != TARGET_ALL_SESSIONS && targetMode != TARGET_ONE_SESSION) {
|
||||
throw new IllegalArgumentException("BAD_TARGET_MODE");
|
||||
}
|
||||
|
||||
String targetSessionId = null;
|
||||
if (targetMode == TARGET_ONE_SESSION) {
|
||||
targetSessionId = readAscii(bb, 1, 255, "BAD_SESSION_ID");
|
||||
}
|
||||
|
||||
int msgLen = Short.toUnsignedInt(bb.getShort());
|
||||
if (msgLen < 1 || msgLen > maxMessageBytes) {
|
||||
throw new IllegalArgumentException("BAD_MESSAGE_LEN");
|
||||
}
|
||||
if (bb.remaining() != msgLen + 64) {
|
||||
throw new IllegalArgumentException("BAD_LEN");
|
||||
}
|
||||
|
||||
byte[] messageBytes = new byte[msgLen];
|
||||
bb.get(messageBytes);
|
||||
|
||||
byte[] signature64 = new byte[64];
|
||||
bb.get(signature64);
|
||||
|
||||
byte[] signedBody = Arrays.copyOf(raw, raw.length - 64);
|
||||
|
||||
return new SignedDirectMessagePacket(
|
||||
version, toLogin, fromLogin, timeMs, nonce, messageType, targetMode,
|
||||
targetSessionId, messageBytes, signedBody, signature64, raw
|
||||
);
|
||||
}
|
||||
|
||||
private static String readAscii(ByteBuffer bb, int minLen, int maxLen, String code) {
|
||||
if (!bb.hasRemaining()) throw new IllegalArgumentException(code);
|
||||
int len = Byte.toUnsignedInt(bb.get());
|
||||
if (len < minLen || len > maxLen || bb.remaining() < len) {
|
||||
throw new IllegalArgumentException(code);
|
||||
}
|
||||
byte[] bytes = new byte[len];
|
||||
bb.get(bytes);
|
||||
for (byte b : bytes) {
|
||||
if (b < 0x20 || b > 0x7E) throw new IllegalArgumentException(code);
|
||||
}
|
||||
return new String(bytes, StandardCharsets.US_ASCII);
|
||||
}
|
||||
}
|
||||
+3
-6
@@ -3,11 +3,8 @@ package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_SendDirectMessage_Request extends Net_Request {
|
||||
private String toLogin;
|
||||
private String text;
|
||||
private String blobB64;
|
||||
|
||||
public String getToLogin() { return toLogin; }
|
||||
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||
public String getText() { return text; }
|
||||
public void setText(String text) { this.text = text; }
|
||||
public String getBlobB64() { return blobB64; }
|
||||
public void setBlobB64(String blobB64) { this.blobB64 = blobB64; }
|
||||
}
|
||||
|
||||
+6
-3
@@ -5,12 +5,15 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
public class Net_SendDirectMessage_Response extends Net_Response {
|
||||
private String messageId;
|
||||
private int deliveredWsSessions;
|
||||
private int deliveredFcmSessions;
|
||||
private int deliveredWebPushSessions;
|
||||
private boolean sessionNotFound;
|
||||
|
||||
public String getMessageId() { return messageId; }
|
||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
||||
public int getDeliveredWsSessions() { return deliveredWsSessions; }
|
||||
public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; }
|
||||
public int getDeliveredFcmSessions() { return deliveredFcmSessions; }
|
||||
public void setDeliveredFcmSessions(int deliveredFcmSessions) { this.deliveredFcmSessions = deliveredFcmSessions; }
|
||||
public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; }
|
||||
public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; }
|
||||
public boolean isSessionNotFound() { return sessionNotFound; }
|
||||
public void setSessionNotFound(boolean sessionNotFound) { this.sessionNotFound = sessionNotFound; }
|
||||
}
|
||||
|
||||
+9
-9
@@ -3,21 +3,21 @@ package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_UpsertPushToken_Request extends Net_Request {
|
||||
private String tokenId;
|
||||
private String sessionId;
|
||||
private String provider;
|
||||
private String token;
|
||||
private String endpoint;
|
||||
private String p256dhKey;
|
||||
private String authKey;
|
||||
private String platform;
|
||||
private String userAgent;
|
||||
|
||||
public String getTokenId() { return tokenId; }
|
||||
public void setTokenId(String tokenId) { this.tokenId = tokenId; }
|
||||
public String getSessionId() { return sessionId; }
|
||||
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
|
||||
public String getProvider() { return provider; }
|
||||
public void setProvider(String provider) { this.provider = provider; }
|
||||
public String getToken() { return token; }
|
||||
public void setToken(String token) { this.token = token; }
|
||||
public String getEndpoint() { return endpoint; }
|
||||
public void setEndpoint(String endpoint) { this.endpoint = endpoint; }
|
||||
public String getP256dhKey() { return p256dhKey; }
|
||||
public void setP256dhKey(String p256dhKey) { this.p256dhKey = p256dhKey; }
|
||||
public String getAuthKey() { return authKey; }
|
||||
public void setAuthKey(String authKey) { this.authKey = authKey; }
|
||||
public String getPlatform() { return platform; }
|
||||
public void setPlatform(String platform) { this.platform = platform; }
|
||||
public String getUserAgent() { return userAgent; }
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package server.logic.ws_protocol.JSON.push;
|
||||
|
||||
import nl.martijndwars.webpush.Notification;
|
||||
import nl.martijndwars.webpush.PushService;
|
||||
import nl.martijndwars.webpush.Subscription;
|
||||
import org.jose4j.lang.JoseException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
|
||||
public final class WebPushSender {
|
||||
private static final Logger log = LoggerFactory.getLogger(WebPushSender.class);
|
||||
private static volatile PushService service;
|
||||
|
||||
private WebPushSender() {}
|
||||
|
||||
private static PushService service() throws GeneralSecurityException, JoseException {
|
||||
if (service != null) return service;
|
||||
synchronized (WebPushSender.class) {
|
||||
if (service != null) return service;
|
||||
AppConfig cfg = AppConfig.getInstance();
|
||||
String pub = cfg.getStringOrEmpty("webpush.vapid.public");
|
||||
String priv = cfg.getStringOrEmpty("webpush.vapid.private");
|
||||
String subject = cfg.getStringOrEmpty("webpush.vapid.subject");
|
||||
if (pub.isBlank() || priv.isBlank() || subject.isBlank()) {
|
||||
throw new IllegalStateException("webpush.vapid.* is not configured");
|
||||
}
|
||||
service = new PushService(pub, priv, subject);
|
||||
return service;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean sendBase64Payload(String endpoint, String p256dhKey, String authKey, String payloadB64) {
|
||||
try {
|
||||
Subscription subscription = new Subscription(
|
||||
endpoint,
|
||||
new Subscription.Keys(p256dhKey, authKey)
|
||||
);
|
||||
byte[] payloadBytes = Base64.getDecoder().decode(payloadB64);
|
||||
Notification notification = new Notification(subscription, payloadBytes);
|
||||
var response = service().send(notification);
|
||||
int code = response.getStatusLine().getStatusCode();
|
||||
return code >= 200 && code < 300;
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
log.warn("WebPush crypto unsupported", e);
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
log.warn("WebPush send failed: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user