feat(dm): implement signed direct messaging with web push fallback

This commit is contained in:
ai5590
2026-04-12 19:34:55 +03:00
parent 1ee2a1cf62
commit 62e55dbaec
21 changed files with 875 additions and 189 deletions
+1 -1
View File
@@ -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
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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,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; }
}
@@ -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; }
}
@@ -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; }
@@ -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;
}
}
}