init
This commit is contained in:
commit
4dac778ae3
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 shinosaki
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# webpush-client-go
|
||||
|
||||
`webpush-client-go`は、Golangで実装されたWebPushを受信するためのクライアントおよびライブラリです。
|
||||
|
||||
## Features
|
||||
- RFC8188メッセージの暗号化・復号
|
||||
- `aes128gcm`のみのサポート
|
||||
- RFC8291メッセージの暗号化・復号
|
||||
- AutoPush (Mozilla Push Service) のクライアント
|
||||
- Application Serverごとのクライアント
|
||||
- NicoPush: https://www.nicovideo.jp
|
||||
|
||||
## Examples
|
||||
|
||||
[examples](./examples)を参照してください。
|
||||
|
||||
- [examples/ece](./examples/ece/main.go): 単純なRFC8291メッセージの暗号化・復号
|
||||
- [examples/nicopush](./examples/nicopush/main.go): ニコニコのWebPush通知を受信します(AutoPush)
|
||||
|
||||
### NicoPush
|
||||
`NICONICO_USER_SESSION_VALUE`に`nicovideo.jp`の`user_session`クッキーの値を指定してください。
|
||||
|
||||
```bash
|
||||
echo '{"user_session":"NICONICO_USER_SESSION_VALUE"}' > config.json
|
||||
|
||||
go run ./examples/nicopush/main.go
|
||||
```
|
||||
|
||||
## References
|
||||
- RFCs
|
||||
- [RFC8188](https://tools.ietf.org/html/rfc8188): HTTP用のコンテンツ暗号化の仕様
|
||||
- [Content-Encoding: aes128gcm とは (RFC8188) - ASnoKaze blog](https://asnokaze.hatenablog.com/entry/20170202/1486046514): RFC8188の要旨を日本語で解説している
|
||||
- [RFC8291](https://tools.ietf.org/html/rfc8291): RFC8188を拡張した、プッシュ通知の暗号化の仕様
|
||||
- Encrypt Content-Encoding
|
||||
- [web-push-libs/ecec](https://github.com/web-push-libs/ecec): RFC8188/RFC8291のC言語実装
|
||||
- [web-push-libs/encrypted-content-encoding](https://github.com/web-push-libs/encrypted-content-encoding): RFC8188/RFC8291のPython/Node.js実装(`http_ece`)
|
||||
- [web-push-libs/web-push-php](https://github.com/web-push-libs/web-push-php): ウェブプッシュサーバのPHP実装
|
||||
- [web-push-php/src/Encryption.php](https://github.com/web-push-libs/web-push-php/blob/7b6d1e9d202c31dd9d53929ae33be3f704df7034/src/Encryption.php): 暗号化ペイロード構築部分
|
||||
- AutoPush
|
||||
- [Design - Mozilla Push Service Documentation](https://mozilla-push-service.readthedocs.io/en/latest/design/): Firefoxで使用されるプッシュサーバ(Mozilla Push Service)のドキュメント
|
||||
- [Architecture - Mozilla AutoPush Server](https://mozilla-services.github.io/autopush-rs/architecture.html): Mozilla Push ServiceのサーバであるAutoPushのドキュメント
|
||||
- [Architecture - autopush documentation](https://autopush.readthedocs.io/en/latest/architecture.html): 廃止されたAutoPushのPython実装のドキュメント
|
||||
- WebPush
|
||||
- [Магия WebPush в Mozilla Firefox. Взгляд изнутри - Habr](https://habr.com/ru/articles/487494/): FirefoxのWebPush実装を解説している
|
||||
- [MANKAのBlog](https://blog.nest.moe)
|
||||
- [通过 Web Push 接收最新的推文](https://blog.nest.moe/posts/receive-latest-tweets-by-web-push): Twitterのプッシュ通知を例に、詳細を解説しているブログポスト
|
||||
- [解密来自 Web Push 的 AES-GCM 消息](https://blog.nest.moe/posts/decrypt-aesgcm-messages-from-web-push): 上記のポストの暗号化周りを詳細に解説している
|
||||
- [tomoyukilabs - Qiita](https://qiita.com/tomoyukilabs)
|
||||
- [Web Pushでブラウザにプッシュ通知を送ってみる](https://qiita.com/tomoyukilabs/items/217915676603fda73b0a)
|
||||
- [[改訂版] Web Pushでブラウザにプッシュ通知を送ってみる](https://qiita.com/tomoyukilabs/items/2ae4a0f708a1af75f13e)
|
||||
- [SherClockHolmes/webpush-go](https://github.com/SherClockHolmes/webpush-go): WebPush暗号化のGo実装
|
||||
- NicoPush
|
||||
- [ニコ生のプッシュ通知の受信の手順 - nicoLiveCheckTool/push.md](https://github.com/guest-nico/nicoLiveCheckTool/blob/master/push.md): C#でニコニコのWebPush通知を受信する手順を解説している
|
||||
|
||||
## LICENSE
|
||||
[MIT](./LICENSE)
|
||||
|
||||
## Author
|
||||
[shinosaki](https://shinosaki.com)
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
package autopush
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/shinosaki/webpush-client-go/rfc8291"
|
||||
"github.com/shinosaki/webpush-client-go/webpush"
|
||||
"github.com/shinosaki/websocket-client-go/websocket"
|
||||
)
|
||||
|
||||
const MOZILLA_PUSH_SERVICE = "wss://push.services.mozilla.com"
|
||||
|
||||
type AutoPushClient struct {
|
||||
*websocket.WebSocketClient
|
||||
ece *rfc8291.RFC8291
|
||||
helloChan chan HelloResponse
|
||||
notificationChan chan Notification
|
||||
registerChan chan RegisterResponse
|
||||
unregisterChan chan UnregisterResponse
|
||||
}
|
||||
|
||||
func request[T any](c *AutoPushClient, ch chan T, timeout time.Duration, timeoutErr string, payload any) (res T, err error) {
|
||||
if err := c.SendJSON(payload); err != nil {
|
||||
log.Println("websocket request failed", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case res := <-ch:
|
||||
return res, err
|
||||
case <-time.After(timeout * time.Second):
|
||||
return res, errors.New(timeoutErr)
|
||||
}
|
||||
}
|
||||
|
||||
func unmarshaler[T any](payload json.RawMessage, label MessageType) (data *T) {
|
||||
// log.Println("autopush: before unmarshal payload", payload)
|
||||
if err := json.Unmarshal(payload, &data); err != nil {
|
||||
log.Printf("AutoPush: failed to unmarshal %s payload: %v", label, err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (c *AutoPushClient) Hello(uaid string, channelIDs []string) (HelloResponse, error) {
|
||||
return request(c, c.helloChan, 5, "hello timeout", HelloRequest{
|
||||
Type: HELLO,
|
||||
UAID: uaid,
|
||||
ChannelIDs: channelIDs,
|
||||
UseWebPush: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *AutoPushClient) Register(channelID string, vapidKey string) (RegisterResponse, error) {
|
||||
return request(c, c.registerChan, 5, "register timeout", RegisterRequest{
|
||||
Type: REGISTER,
|
||||
ChannelID: channelID,
|
||||
Key: vapidKey,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *AutoPushClient) Unregister(channelID string) (UnregisterResponse, error) {
|
||||
return request(c, c.unregisterChan, 5, "unregister timeout", UnregisterRequest{
|
||||
Type: UNREGISTER,
|
||||
ChannelID: channelID,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *AutoPushClient) Decrypt(
|
||||
curve ecdh.Curve,
|
||||
authSecret []byte,
|
||||
useragentPrivateKey *ecdh.PrivateKey,
|
||||
notification Notification,
|
||||
) (*webpush.WebPushPayload, error) {
|
||||
data, err := base64.RawURLEncoding.DecodeString(notification.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64 decode error: %v", err)
|
||||
}
|
||||
|
||||
payload, err := rfc8291.Unmarshal(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rfc8291 decode error: %v", err)
|
||||
}
|
||||
|
||||
appserverPublicKey, err := curve.NewPublicKey(payload.KeyId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ecdh public key load error: %v", err)
|
||||
}
|
||||
|
||||
plaintext, err := c.ece.Decrypt(
|
||||
payload.CipherText,
|
||||
payload.Salt,
|
||||
authSecret,
|
||||
useragentPrivateKey,
|
||||
appserverPublicKey,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rfc8291 decrypt error: %v", err)
|
||||
}
|
||||
|
||||
var result webpush.WebPushPayload
|
||||
if err := json.Unmarshal(plaintext, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal json: %v", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func NewAutoPushClient() (ap *AutoPushClient, ch chan Notification) {
|
||||
ap = &AutoPushClient{
|
||||
ece: rfc8291.NewRFC8291(sha256.New),
|
||||
helloChan: make(chan HelloResponse),
|
||||
notificationChan: make(chan Notification),
|
||||
registerChan: make(chan RegisterResponse),
|
||||
unregisterChan: make(chan UnregisterResponse),
|
||||
WebSocketClient: websocket.NewWebSocketClient(
|
||||
nil,
|
||||
func(ws *websocket.WebSocketClient, isReconnecting bool) {
|
||||
if !isReconnecting {
|
||||
close(ap.helloChan)
|
||||
close(ap.notificationChan)
|
||||
close(ap.registerChan)
|
||||
close(ap.unregisterChan)
|
||||
}
|
||||
},
|
||||
func(ws *websocket.WebSocketClient, payload []byte) {
|
||||
// log.Println("AutoPush Received Message:", string(payload))
|
||||
var message Message
|
||||
if err := json.Unmarshal(payload, &message); err != nil {
|
||||
log.Println("AutoPush: failed to unmarshal payload", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch message.Type {
|
||||
case PING:
|
||||
ws.SendJSON("{}")
|
||||
|
||||
case HELLO:
|
||||
if data := unmarshaler[HelloResponse](payload, HELLO); data != nil {
|
||||
ap.helloChan <- *data
|
||||
}
|
||||
|
||||
case REGISTER:
|
||||
if data := unmarshaler[RegisterResponse](payload, REGISTER); data != nil {
|
||||
ap.registerChan <- *data
|
||||
}
|
||||
|
||||
case UNREGISTER:
|
||||
if data := unmarshaler[UnregisterResponse](payload, UNREGISTER); data != nil {
|
||||
ap.unregisterChan <- *data
|
||||
}
|
||||
|
||||
case NOTIFICATION:
|
||||
if data := unmarshaler[Notification](payload, NOTIFICATION); data != nil {
|
||||
ws.SendJSON(Ack{
|
||||
Type: ACK,
|
||||
Updates: []AckUpdate{
|
||||
{
|
||||
ChannelID: data.ChannelID,
|
||||
Version: data.Version,
|
||||
},
|
||||
},
|
||||
})
|
||||
ap.notificationChan <- *data
|
||||
}
|
||||
|
||||
default:
|
||||
log.Println("AutoPush: unknown data type", message.Type)
|
||||
}
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
return ap, ap.notificationChan
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package autopush
|
||||
|
||||
type Status int
|
||||
|
||||
const (
|
||||
OK Status = 200
|
||||
CONFLICT Status = 409
|
||||
SERVER_ERROR Status = 500
|
||||
)
|
||||
|
||||
type MessageType string
|
||||
|
||||
const (
|
||||
PING MessageType = "ping"
|
||||
ACK MessageType = "ack"
|
||||
HELLO MessageType = "hello"
|
||||
REGISTER MessageType = "register"
|
||||
UNREGISTER MessageType = "unregister"
|
||||
NOTIFICATION MessageType = "notification"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Type MessageType `json:"messageType"`
|
||||
// Data json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
type HelloRequest struct {
|
||||
Type MessageType `json:"messageType"`
|
||||
UAID string `json:"uaid"`
|
||||
ChannelIDs []string `json:"channelIDs"`
|
||||
UseWebPush bool `json:"use_webpush,omitempty"`
|
||||
}
|
||||
|
||||
type HelloResponse struct {
|
||||
Type MessageType `json:"messageType"`
|
||||
UAID string `json:"uaid"`
|
||||
Status Status `json:"status"`
|
||||
UseWebPush bool `json:"use_webpush,omitempty"`
|
||||
// Broadcasts map[string]any `json:"broadcasts"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Type MessageType `json:"messageType"`
|
||||
ChannelID string `json:"channelID"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type RegisterResponse struct {
|
||||
Type MessageType `json:"messageType"`
|
||||
ChannelID string `json:"channelID"`
|
||||
Status Status `json:"status"`
|
||||
PushEndpoint string `json:"pushEndpoint"`
|
||||
}
|
||||
|
||||
type UnregisterRequest struct {
|
||||
Type MessageType `json:"messageType"`
|
||||
ChannelID string `json:"channelID"`
|
||||
}
|
||||
|
||||
type UnregisterResponse struct {
|
||||
Type MessageType `json:"messageType"`
|
||||
ChannelID string `json:"channelID"`
|
||||
Status Status `json:"status"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
Type MessageType `json:"messageType"`
|
||||
ChannelID string `json:"channelID"`
|
||||
Version string `json:"version"`
|
||||
Data string `json:"data"`
|
||||
Headers NotificationHeaders `json:"headers"`
|
||||
}
|
||||
|
||||
type NotificationHeaders struct {
|
||||
Encryption string `json:"encryption"`
|
||||
CryptoKey string `json:"crypto_key"`
|
||||
Encoding string `json:"encoding"`
|
||||
}
|
||||
|
||||
type Ack struct {
|
||||
Type MessageType `json:"messageType"`
|
||||
Updates []AckUpdate `json:"updates"`
|
||||
}
|
||||
|
||||
type AckUpdate struct {
|
||||
ChannelID string `json:"channelID"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/sha256"
|
||||
"log"
|
||||
|
||||
"github.com/shinosaki/webpush-client-go/rfc8291"
|
||||
)
|
||||
|
||||
var (
|
||||
CURVE = ecdh.P256()
|
||||
HASH = sha256.New
|
||||
|
||||
PLAINTEXT = "Plain text message!!!"
|
||||
)
|
||||
|
||||
func appserver(authSecret []byte, useragentPublicKey *ecdh.PublicKey) []byte {
|
||||
_, salt, privateKey := rfc8291.NewSecrets(CURVE)
|
||||
ece := rfc8291.NewRFC8291(HASH)
|
||||
|
||||
encrypted, err := ece.Encrypt(
|
||||
[]byte(PLAINTEXT),
|
||||
salt,
|
||||
authSecret,
|
||||
useragentPublicKey,
|
||||
privateKey,
|
||||
)
|
||||
if err != nil {
|
||||
log.Panicln("Encryption Error:", err)
|
||||
}
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
func main() {
|
||||
// UserAgent
|
||||
authSecret, _, useragentPrivateKey := rfc8291.NewSecrets(CURVE)
|
||||
ece := rfc8291.NewRFC8291(HASH)
|
||||
|
||||
// AppServer
|
||||
encrypted := appserver(authSecret, useragentPrivateKey.PublicKey())
|
||||
|
||||
// UserAgent
|
||||
payload, err := rfc8291.Unmarshal(encrypted)
|
||||
if err != nil {
|
||||
log.Panicln("RFC8291 Unmarshal Error:", err)
|
||||
}
|
||||
|
||||
// In RFC8291, KeyID is the AppServer's Public Key
|
||||
appserverPublicKey, err := ecdh.P256().NewPublicKey(payload.KeyId)
|
||||
if err != nil {
|
||||
log.Panicln("Load PublicKey Error", err)
|
||||
}
|
||||
|
||||
plaintext, err := ece.Decrypt(
|
||||
payload.CipherText,
|
||||
payload.Salt,
|
||||
authSecret,
|
||||
useragentPrivateKey,
|
||||
appserverPublicKey,
|
||||
)
|
||||
if err != nil {
|
||||
log.Panicln("RFC8291 Decrypt Error", err)
|
||||
}
|
||||
|
||||
log.Println("Valid Message: ", PLAINTEXT)
|
||||
log.Println("Decrypted Text:", string(plaintext))
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/shinosaki/webpush-client-go/rfc8291"
|
||||
"github.com/shinosaki/webpush-client-go/sites/nicopush"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
UserSession string
|
||||
AuthSecret []byte
|
||||
PrivateKey *ecdh.PrivateKey
|
||||
UAID string
|
||||
ChannelIDs []string
|
||||
}
|
||||
|
||||
type SerializedConfig struct {
|
||||
UserSession string `json:"user_session"`
|
||||
AuthSecret string `json:"auth_secret"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
UAID string `json:"uaid"`
|
||||
ChannelIDs []string `json:"channel_ids"`
|
||||
}
|
||||
|
||||
func ConfigLoad(path string) (*Config, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
config := &Config{}
|
||||
serialized := &SerializedConfig{}
|
||||
|
||||
if err := json.NewDecoder(file).Decode(&serialized); err != nil {
|
||||
log.Println("failed to load config:", err)
|
||||
}
|
||||
|
||||
var (
|
||||
authSecret []byte
|
||||
privateKey *ecdh.PrivateKey
|
||||
)
|
||||
|
||||
authSecret, _, privateKey = rfc8291.NewSecrets(ecdh.P256())
|
||||
|
||||
if serialized.AuthSecret != "" {
|
||||
authSecret, _ = base64.RawURLEncoding.DecodeString(serialized.AuthSecret)
|
||||
}
|
||||
|
||||
if serialized.PrivateKey != "" {
|
||||
b, _ := base64.RawURLEncoding.DecodeString(serialized.PrivateKey)
|
||||
privateKey, _ = ecdh.P256().NewPrivateKey(b)
|
||||
}
|
||||
|
||||
config.UserSession = serialized.UserSession
|
||||
config.AuthSecret = authSecret
|
||||
config.PrivateKey = privateKey
|
||||
config.UAID = serialized.UAID
|
||||
config.ChannelIDs = serialized.ChannelIDs
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func ConfigSave(path string, config *Config) error {
|
||||
serialized := &SerializedConfig{
|
||||
UserSession: config.UserSession,
|
||||
UAID: config.UAID,
|
||||
ChannelIDs: config.ChannelIDs,
|
||||
AuthSecret: base64.RawURLEncoding.EncodeToString(config.AuthSecret),
|
||||
PrivateKey: base64.RawURLEncoding.EncodeToString(config.PrivateKey.Bytes()),
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(serialized, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
func main() {
|
||||
configPath := "config.json"
|
||||
|
||||
config, err := ConfigLoad(configPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if config.UserSession == "" {
|
||||
panic("user session is require")
|
||||
}
|
||||
|
||||
httpClient, err := nicopush.NewLoginSession(config.UserSession)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
nicoPushClient, notificationChan, err := nicopush.NewNicoPushClient(
|
||||
config.UAID,
|
||||
config.ChannelIDs,
|
||||
config.AuthSecret,
|
||||
config.PrivateKey,
|
||||
httpClient,
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
config.UAID, err = nicoPushClient.Handshake()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
log.Println("UAID:", config.UAID)
|
||||
ConfigSave(configPath, config)
|
||||
|
||||
if len(config.ChannelIDs) == 0 {
|
||||
channelID, err := nicoPushClient.Register()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
log.Println("ChannelID:", channelID)
|
||||
|
||||
config.ChannelIDs = append(config.ChannelIDs, channelID)
|
||||
ConfigSave(configPath, config)
|
||||
}
|
||||
|
||||
for data := range notificationChan {
|
||||
payload, err := nicoPushClient.Decrypt(data)
|
||||
if err != nil {
|
||||
log.Println("webpush error:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Println("Title:", payload.Title)
|
||||
log.Println("Body: ", payload.Body)
|
||||
log.Println("Icon: ", payload.Icon)
|
||||
|
||||
var pushData nicopush.PushData
|
||||
if err := json.Unmarshal(payload.Data, &pushData); err != nil {
|
||||
log.Println("push data parse error:", err)
|
||||
continue
|
||||
}
|
||||
log.Println("URL: ", pushData.OnClick)
|
||||
log.Println("CreatedAt: ", pushData.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
module github.com/shinosaki/webpush-client-go
|
||||
|
||||
go 1.23.6
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/shinosaki/websocket-client-go v1.0.0
|
||||
golang.org/x/crypto v0.35.0
|
||||
golang.org/x/net v0.35.0
|
||||
)
|
||||
|
||||
require golang.org/x/text v0.22.0 // indirect
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/shinosaki/websocket-client-go v1.0.0 h1:k70Nl86Cn1jjhBdYs77MyjWUR9xPHA94Mev8Ual1TiE=
|
||||
github.com/shinosaki/websocket-client-go v1.0.0/go.mod h1:fzgEpoLabqcgOjDVPo7dh6dTqhbCTgd3SdWO2a+fPxg=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package rfc8291
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
)
|
||||
|
||||
const (
|
||||
BASE_HEADER_LEN = 21
|
||||
)
|
||||
|
||||
type Payload struct {
|
||||
RS uint32
|
||||
Salt []byte
|
||||
KeyId []byte
|
||||
CipherText []byte
|
||||
}
|
||||
|
||||
func Marshal(p Payload) (data []byte) {
|
||||
rs := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(rs, p.RS)
|
||||
return bytes.Join([][]byte{
|
||||
p.Salt,
|
||||
rs,
|
||||
{uint8(len(p.KeyId))},
|
||||
p.KeyId,
|
||||
p.CipherText,
|
||||
}, []byte{})
|
||||
}
|
||||
|
||||
func Unmarshal(data []byte) (p Payload, err error) {
|
||||
err = errors.New("data is too short")
|
||||
|
||||
if len(data) < BASE_HEADER_LEN {
|
||||
return p, err
|
||||
}
|
||||
|
||||
p.Salt = data[:16]
|
||||
p.RS = binary.BigEndian.Uint32(data[16:20])
|
||||
|
||||
idlen := int(data[20])
|
||||
if len(data) < BASE_HEADER_LEN+idlen {
|
||||
return p, err
|
||||
}
|
||||
|
||||
if idlen > 0 {
|
||||
p.KeyId = data[BASE_HEADER_LEN : BASE_HEADER_LEN+idlen]
|
||||
}
|
||||
|
||||
p.CipherText = data[BASE_HEADER_LEN+idlen:]
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
package rfc8291
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"log"
|
||||
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
const (
|
||||
AUTH_SECRET_LEN = 16
|
||||
SALT_LEN = 16
|
||||
|
||||
AES_GCM_OVERHEAD = 16
|
||||
|
||||
HKDF_IKM_LEN = 32
|
||||
HKDF_CEK_LEN = 16
|
||||
HKDF_NONCE_LEN = 12
|
||||
)
|
||||
|
||||
type RFC8291 struct {
|
||||
hash func() hash.Hash
|
||||
}
|
||||
|
||||
// Default Hash is SHA256
|
||||
func NewRFC8291(hash func() hash.Hash) *RFC8291 {
|
||||
if hash == nil {
|
||||
hash = sha256.New
|
||||
}
|
||||
return &RFC8291{hash: hash}
|
||||
}
|
||||
|
||||
func NewSecrets(curve ecdh.Curve) (auth, salt []byte, key *ecdh.PrivateKey) {
|
||||
auth = make([]byte, AUTH_SECRET_LEN)
|
||||
salt = make([]byte, SALT_LEN)
|
||||
for _, b := range [][]byte{auth, salt} {
|
||||
_, err := io.ReadFull(rand.Reader, b)
|
||||
if err != nil {
|
||||
log.Panicln("failed to generate random secret", err)
|
||||
}
|
||||
}
|
||||
|
||||
key, err := curve.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
log.Panicln("failed to generate ecdh key", err)
|
||||
}
|
||||
|
||||
return auth, salt, key
|
||||
}
|
||||
|
||||
func (c *RFC8291) Encrypt(
|
||||
plaintext []byte,
|
||||
salt []byte,
|
||||
authSecret []byte,
|
||||
useragentPublicKey *ecdh.PublicKey,
|
||||
appserverPrivateKey *ecdh.PrivateKey,
|
||||
) ([]byte, error) {
|
||||
if len(authSecret) != AUTH_SECRET_LEN {
|
||||
return nil, fmt.Errorf("auth_secret must be %d bytes", AUTH_SECRET_LEN)
|
||||
}
|
||||
if len(salt) != SALT_LEN {
|
||||
return nil, fmt.Errorf("salt must be %d bytes", SALT_LEN)
|
||||
}
|
||||
|
||||
ecdhSecret, err := appserverPrivateKey.ECDH(useragentPublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("calculate ecdh_secret failed: %v", err)
|
||||
}
|
||||
|
||||
ikm, err := c.ikm(authSecret, ecdhSecret, useragentPublicKey, appserverPrivateKey.PublicKey())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cek, nonce, err := c.cekAndNonce(ikm, salt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := c.gcm(cek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
|
||||
rs := uint32(len(plaintext) + 1 + AES_GCM_OVERHEAD)
|
||||
|
||||
// RFC8188: 0x01 or 0x02 in tail of plaintext data
|
||||
// RFC8291: The push message plaintext has the padding delimiter octet (0x02) appended to produce
|
||||
// ciphertext = bytes.Join([][]byte{ciphertext, {0x02}}, []byte{})
|
||||
|
||||
return Marshal(Payload{
|
||||
RS: rs,
|
||||
Salt: salt,
|
||||
KeyId: appserverPrivateKey.PublicKey().Bytes(),
|
||||
CipherText: ciphertext,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (c *RFC8291) Decrypt(
|
||||
ciphertext []byte,
|
||||
salt []byte,
|
||||
authSecret []byte,
|
||||
useragentPrivateKey *ecdh.PrivateKey,
|
||||
appserverPublicKey *ecdh.PublicKey,
|
||||
) ([]byte, error) {
|
||||
if len(authSecret) != AUTH_SECRET_LEN {
|
||||
return nil, fmt.Errorf("auth_secret must be %d bytes", AUTH_SECRET_LEN)
|
||||
}
|
||||
if len(salt) != SALT_LEN {
|
||||
return nil, fmt.Errorf("salt must be %d bytes", SALT_LEN)
|
||||
}
|
||||
|
||||
ecdhSecret, err := useragentPrivateKey.ECDH(appserverPublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("calculate ecdh_secret failed: %v", err)
|
||||
}
|
||||
|
||||
ikm, err := c.ikm(authSecret, ecdhSecret, useragentPrivateKey.PublicKey(), appserverPublicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cek, nonce, err := c.cekAndNonce(ikm, salt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := c.gcm(cek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// RFC8188: 0x01 or 0x02 in tail of plaintext data
|
||||
// RFC8291: The push message plaintext has the padding delimiter octet (0x02) appended to produce
|
||||
if plaintext[len(plaintext)-1] == 0x01 || plaintext[len(plaintext)-1] == 0x02 {
|
||||
plaintext = plaintext[:len(plaintext)-1]
|
||||
}
|
||||
|
||||
return plaintext, err
|
||||
}
|
||||
|
||||
func (c *RFC8291) gcm(cek []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(cek)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create cipher block failed: %v", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create GCM failed: %v", err)
|
||||
}
|
||||
|
||||
return gcm, nil
|
||||
}
|
||||
|
||||
func (c *RFC8291) ikm(
|
||||
authSecret []byte,
|
||||
ecdhSecret []byte,
|
||||
useragentPublicKey *ecdh.PublicKey,
|
||||
appserverPublicKey *ecdh.PublicKey,
|
||||
) (ikm []byte, err error) {
|
||||
prkKey := hkdf.Extract(c.hash, ecdhSecret, authSecret)
|
||||
|
||||
keyInfo := bytes.Join([][]byte{
|
||||
[]byte("WebPush: info\000"),
|
||||
useragentPublicKey.Bytes(),
|
||||
appserverPublicKey.Bytes(),
|
||||
}, []byte{})
|
||||
|
||||
ikm = make([]byte, HKDF_IKM_LEN)
|
||||
if _, err := io.ReadFull(hkdf.Expand(c.hash, prkKey, keyInfo), ikm); err != nil {
|
||||
return nil, fmt.Errorf("read IKM failed: %v", err)
|
||||
}
|
||||
|
||||
return ikm, nil
|
||||
}
|
||||
|
||||
func (c *RFC8291) cekAndNonce(ikm []byte, salt []byte) (cek, nonce []byte, err error) {
|
||||
prk := hkdf.Extract(c.hash, ikm, salt)
|
||||
|
||||
cekInfo := []byte("Content-Encoding: aes128gcm\000")
|
||||
nonceInfo := []byte("Content-Encoding: nonce\000")
|
||||
|
||||
cek = make([]byte, HKDF_CEK_LEN)
|
||||
if _, err := io.ReadFull(hkdf.Expand(c.hash, prk, cekInfo), cek); err != nil {
|
||||
return nil, nil, fmt.Errorf("read CEK failed: %v", err)
|
||||
}
|
||||
|
||||
nonce = make([]byte, HKDF_NONCE_LEN)
|
||||
if _, err := io.ReadFull(hkdf.Expand(c.hash, prk, nonceInfo), nonce); err != nil {
|
||||
return nil, nil, fmt.Errorf("read Nonce failed: %v", err)
|
||||
}
|
||||
|
||||
return cek, nonce, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
package nicopush
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdh"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shinosaki/webpush-client-go/autopush"
|
||||
"github.com/shinosaki/webpush-client-go/rfc8291"
|
||||
"github.com/shinosaki/webpush-client-go/webpush"
|
||||
)
|
||||
|
||||
type NicoPushClient struct {
|
||||
autoPushClient *autopush.AutoPushClient
|
||||
httpClient *http.Client
|
||||
ece *rfc8291.RFC8291
|
||||
|
||||
vapidKey []byte
|
||||
nicoPushEndpoint string
|
||||
|
||||
uaid string
|
||||
channelIDs []string
|
||||
authSecret []byte
|
||||
privateKey *ecdh.PrivateKey
|
||||
publicKey *ecdh.PublicKey
|
||||
}
|
||||
|
||||
func NewNicoPushClient(
|
||||
uaid string,
|
||||
channelIDs []string,
|
||||
authSecret []byte,
|
||||
privateKey *ecdh.PrivateKey,
|
||||
httpClient *http.Client,
|
||||
) (*NicoPushClient, chan autopush.Notification, error) {
|
||||
vapidKey, nicoPushEndpoint, err := getEndpointAndVapidKey()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
ap, ch := autopush.NewAutoPushClient()
|
||||
|
||||
client := &NicoPushClient{
|
||||
autoPushClient: ap,
|
||||
httpClient: httpClient,
|
||||
ece: rfc8291.NewRFC8291(sha256.New),
|
||||
|
||||
vapidKey: vapidKey,
|
||||
nicoPushEndpoint: nicoPushEndpoint,
|
||||
|
||||
uaid: uaid,
|
||||
channelIDs: channelIDs,
|
||||
authSecret: authSecret,
|
||||
privateKey: privateKey,
|
||||
publicKey: privateKey.PublicKey(),
|
||||
}
|
||||
|
||||
return client, ch, nil
|
||||
}
|
||||
|
||||
// Handshake performs a handshake with the AutoPush server and retrieves a UAID.
|
||||
// The obtained UAID is used for client identification and should be saved.
|
||||
func (c *NicoPushClient) Handshake() (uaid string, err error) {
|
||||
if err := c.autoPushClient.Connect(autopush.MOZILLA_PUSH_SERVICE, 3, 2); err != nil {
|
||||
return "", fmt.Errorf("failed to connect autopush server: %v", err)
|
||||
}
|
||||
|
||||
data, err := c.autoPushClient.Hello(c.uaid, c.channelIDs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to handshake autopush server: %v", err)
|
||||
}
|
||||
|
||||
c.uaid = data.UAID
|
||||
return c.uaid, nil
|
||||
}
|
||||
|
||||
func (c *NicoPushClient) Register() (channelID string, err error) {
|
||||
uuid, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate UUIDv4: %v", err)
|
||||
}
|
||||
|
||||
data, err := c.autoPushClient.Register(uuid.String(), base64.StdEncoding.EncodeToString(c.vapidKey))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to register autopush: %v", err)
|
||||
}
|
||||
|
||||
if err := c.registerToAppServer(data.PushEndpoint); err != nil {
|
||||
return "", fmt.Errorf("failed to register nicopush: %v", err)
|
||||
}
|
||||
|
||||
return uuid.String(), nil
|
||||
}
|
||||
|
||||
func (c *NicoPushClient) Decrypt(data autopush.Notification) (*webpush.WebPushPayload, error) {
|
||||
return c.autoPushClient.Decrypt(
|
||||
ecdh.P256(),
|
||||
c.authSecret,
|
||||
c.privateKey,
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
// Registration AutoPush's push-endpoint to NicoPush (Application Server)
|
||||
func (c *NicoPushClient) registerToAppServer(pushEndpoint string) error {
|
||||
payload, err := json.Marshal(Register{
|
||||
DestApp: NICO_ACCOUNT_WEBPUSH,
|
||||
Endpoint: Endpoint{
|
||||
Endpoint: pushEndpoint,
|
||||
Auth: base64.StdEncoding.EncodeToString(c.authSecret),
|
||||
P256DH: base64.StdEncoding.EncodeToString(c.publicKey.Bytes()),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal register payload: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.nicoPushEndpoint, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to bulid register request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Request-With", "https://account.nicovideo.jp/my/account")
|
||||
req.Header.Set("X-Frontend-Id", "8")
|
||||
|
||||
res, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("nicopush register request failed: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("invalid http status: %d %s", res.StatusCode, res.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read error: %v", err)
|
||||
}
|
||||
|
||||
var data APIResponse
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal api response: %v", err)
|
||||
}
|
||||
|
||||
if data.Meta.Status != http.StatusOK {
|
||||
return fmt.Errorf("invalid response status: %d", data.Meta.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package nicopush
|
||||
|
||||
import "time"
|
||||
|
||||
type (
|
||||
DestApp string
|
||||
)
|
||||
|
||||
const (
|
||||
NICO_ACCOUNT_WEBPUSH DestApp = "nico_account_webpush"
|
||||
)
|
||||
|
||||
type APIResponse struct {
|
||||
Meta struct {
|
||||
Status int `json:"status"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
|
||||
type Register struct {
|
||||
DestApp DestApp `json:"destApp"`
|
||||
Endpoint Endpoint `json:"endpoint"`
|
||||
}
|
||||
|
||||
type Endpoint struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Auth string `json:"auth"`
|
||||
P256DH string `json:"p256dh"`
|
||||
}
|
||||
|
||||
// "data" property in WebPush Notification Message
|
||||
type PushData struct {
|
||||
TTL time.Duration `json:"ttl"` // e.g. 600
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
OnClick string `json:"on_click"` // Program URL (e.g. "https://live.nicovideo.jp/watch/lv123456?from=webpush&_topic=live_user_program_onairs")
|
||||
TrackingParameter string `json:"tracking_parameter"` // e.g. "live_onair-lv123456-webpush-nico_account_webpush"
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package nicopush
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
func NewLoginSession(userSession string) (*http.Client, error) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
origin, _ := url.Parse("https://nicovideo.jp/")
|
||||
jar.SetCookies(origin, []*http.Cookie{
|
||||
{
|
||||
Name: "user_session",
|
||||
Value: "user_session_125600936_50e1b073aa6a9b7ce4065265c9ef89b7a1b6f8aeec322cb748551e2515682e73",
|
||||
Path: "/",
|
||||
Domain: ".nicovideo.jp",
|
||||
},
|
||||
})
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http2.Transport{},
|
||||
Jar: jar,
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func getEndpointAndVapidKey() (vapidKey []byte, nicoPushUrl string, err error) {
|
||||
fetchString := func(url string) (string, error) {
|
||||
res, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("invalid http status %d %s", res.StatusCode, res.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
getServiceWorkerUrl := func() (string, error) {
|
||||
body, err := fetchString("https://account.nicovideo.jp/sw.js")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`importScripts\(['"](.*?)['"]\)`)
|
||||
match := re.FindStringSubmatch(string(body))
|
||||
if len(match) != 2 {
|
||||
return "", errors.New("importScript is not contain")
|
||||
}
|
||||
|
||||
return match[1], nil
|
||||
}
|
||||
|
||||
swUrl, err := getServiceWorkerUrl()
|
||||
if err != nil {
|
||||
return vapidKey, nicoPushUrl, err
|
||||
}
|
||||
log.Println("swURL:", swUrl)
|
||||
|
||||
swFile, err := fetchString(swUrl)
|
||||
if err != nil {
|
||||
return vapidKey, nicoPushUrl, err
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`Uint8Array\(\[([\d,]+)\]\);[\w.]+={URL:"(https:\/\/api\.push\.nicovideo\.jp.*?)"`)
|
||||
match := re.FindStringSubmatch(swFile)
|
||||
if len(match) != 3 {
|
||||
return vapidKey, nicoPushUrl, errors.New("VAPID key and NicoPush url is not contain")
|
||||
}
|
||||
|
||||
byteStrings := match[1]
|
||||
nicoPushUrl = match[2]
|
||||
|
||||
nums := strings.Split(byteStrings, ",")
|
||||
vapidKey = make([]byte, len(nums))
|
||||
for i, s := range nums {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return vapidKey, nicoPushUrl, fmt.Errorf("failed to convert byte arrays of Uint8Array strings: %v", err)
|
||||
}
|
||||
vapidKey[i] = byte(n)
|
||||
}
|
||||
|
||||
return vapidKey, nicoPushUrl, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package webpush
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// https://developer.mozilla.org/docs/Web/API/Notification
|
||||
type WebPushPayload struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Icon string `json:"icon"` // icon url
|
||||
Data json.RawMessage `json:"data"` // custom data field
|
||||
}
|
||||
Loading…
Reference in New Issue