Initial public release of birdwatch-relay
This commit is contained in:
commit
c176f2ad24
17 changed files with 2025 additions and 0 deletions
47
internal/config/config.go
Normal file
47
internal/config/config.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
DBPath string
|
||||
FirebaseKeyPath string
|
||||
PairingCode string
|
||||
FrigateURL string
|
||||
FrigateUser string
|
||||
FrigatePassword string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
c := &Config{
|
||||
Port: getenv("RELAY_PORT", "8080"),
|
||||
DBPath: getenv("RELAY_DB_PATH", "/data/relay.db"),
|
||||
FirebaseKeyPath: os.Getenv("RELAY_FIREBASE_KEY_PATH"),
|
||||
PairingCode: os.Getenv("RELAY_PAIRING_CODE"),
|
||||
FrigateURL: os.Getenv("FRIGATE_URL"),
|
||||
FrigateUser: os.Getenv("FRIGATE_USER"),
|
||||
FrigatePassword: os.Getenv("FRIGATE_PASSWORD"),
|
||||
}
|
||||
if c.FirebaseKeyPath == "" {
|
||||
return nil, errors.New("RELAY_FIREBASE_KEY_PATH is required")
|
||||
}
|
||||
if c.PairingCode == "" || len(c.PairingCode) < 6 {
|
||||
return nil, errors.New("RELAY_PAIRING_CODE must be at least 6 chars (use: openssl rand -hex 3)")
|
||||
}
|
||||
if c.FrigateURL == "" {
|
||||
return nil, errors.New("FRIGATE_URL is required (e.g. http://localhost:5000)")
|
||||
}
|
||||
// FRIGATE_USER + FRIGATE_PASSWORD optional — empty means Frigate has auth disabled.
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
31
internal/crypto/sealedbox.go
Normal file
31
internal/crypto/sealedbox.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// Sealed-box encryption: anonymous public-key encryption. Anyone holding the
|
||||
// recipient's public key can produce a ciphertext; only the holder of the
|
||||
// recipient's private key can decrypt it. Built on X25519 + XSalsa20-Poly1305.
|
||||
//
|
||||
// Wire format: ephemeral_pubkey (32 bytes) || box_ciphertext.
|
||||
// See https://pkg.go.dev/golang.org/x/crypto/nacl/box#SealAnonymous.
|
||||
//
|
||||
// On the Android side, the matching primitive is libsodium's
|
||||
// crypto_box_seal / crypto_box_seal_open. Lazysodium-android is a
|
||||
// drop-in dependency.
|
||||
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
// Seal encrypts plaintext to recipientPub. recipientPub must be 32 bytes (X25519).
|
||||
func Seal(plaintext []byte, recipientPub []byte) ([]byte, error) {
|
||||
if len(recipientPub) != 32 {
|
||||
return nil, errors.New("recipient public key must be 32 bytes")
|
||||
}
|
||||
var pub [32]byte
|
||||
copy(pub[:], recipientPub)
|
||||
return box.SealAnonymous(nil, plaintext, &pub, rand.Reader)
|
||||
}
|
||||
44
internal/fcm/sender.go
Normal file
44
internal/fcm/sender.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package fcm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
|
||||
firebase "firebase.google.com/go/v4"
|
||||
"firebase.google.com/go/v4/messaging"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
type Sender struct {
|
||||
client *messaging.Client
|
||||
}
|
||||
|
||||
func New(ctx context.Context, keyPath string) (*Sender, error) {
|
||||
app, err := firebase.NewApp(ctx, nil, option.WithCredentialsFile(keyPath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mc, err := app.Messaging(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Sender{client: mc}, nil
|
||||
}
|
||||
|
||||
// SendCiphertext delivers a ciphertext to a single FCM token as a data-only
|
||||
// push (no `notification` field) so the phone decrypts and renders locally —
|
||||
// Google never sees plaintext.
|
||||
func (s *Sender) SendCiphertext(ctx context.Context, fcmToken string, ciphertext []byte) error {
|
||||
msg := &messaging.Message{
|
||||
Token: fcmToken,
|
||||
Data: map[string]string{
|
||||
"ciphertext": base64.StdEncoding.EncodeToString(ciphertext),
|
||||
},
|
||||
Android: &messaging.AndroidConfig{
|
||||
Priority: "high",
|
||||
},
|
||||
}
|
||||
_, err := s.client.Send(ctx, msg)
|
||||
return err
|
||||
}
|
||||
227
internal/frigate/client.go
Normal file
227
internal/frigate/client.go
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// Client subscribes to Frigate's /ws WebSocket as a long-lived event consumer.
|
||||
// The wire format is taken verbatim from BirdWatch's existing
|
||||
// EventWebSocketService: login via POST /api/login (Gson body
|
||||
// `{"user":"...","password":"..."}`), then dial /ws with the resulting cookies,
|
||||
// and parse incoming JSON frames whose `topic` field is "events". The frame's
|
||||
// `payload` field is itself a JSON-encoded string; we forward it as-is to the
|
||||
// handler. Reconnect is exponential backoff capped at 5 minutes — same as the
|
||||
// Android client, with the same heartbeat (20s ping) so connection drops are
|
||||
// detected promptly.
|
||||
|
||||
package frigate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type EventHandler func(payload []byte)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
username string
|
||||
password string
|
||||
handler EventHandler
|
||||
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// frame is the outer envelope Frigate sends on /ws.
|
||||
type frame struct {
|
||||
Topic string `json:"topic"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
func NewClient(baseURL, username, password string, handler EventHandler) (*Client, error) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
username: username,
|
||||
password: password,
|
||||
handler: handler,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run blocks until ctx is canceled, reconnecting on every failure with
|
||||
// exponential backoff (1s → 5min cap).
|
||||
func (c *Client) Run(ctx context.Context) {
|
||||
const (
|
||||
initialBackoff = time.Second
|
||||
maxBackoff = 5 * time.Minute
|
||||
)
|
||||
backoff := initialBackoff
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
err := c.runOnce(ctx)
|
||||
if err == nil || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
slog.Warn("frigate connection ended, will retry",
|
||||
"err", err, "next_retry_in", backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) runOnce(ctx context.Context) error {
|
||||
if err := c.login(ctx); err != nil {
|
||||
return fmt.Errorf("login: %w", err)
|
||||
}
|
||||
return c.subscribe(ctx)
|
||||
}
|
||||
|
||||
func (c *Client) login(ctx context.Context) error {
|
||||
if c.username == "" {
|
||||
// Auth-disabled Frigate. Skip login.
|
||||
return nil
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"user": c.username,
|
||||
"password": c.password,
|
||||
})
|
||||
loginURL := c.baseURL + "/api/login"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, loginURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("frigate login returned %d: %s", resp.StatusCode, truncate(string(b), 200))
|
||||
}
|
||||
slog.Info("frigate login ok", "url", loginURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) subscribe(ctx context.Context) error {
|
||||
wsURL, err := wsURLFromBase(c.baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
if cookies := c.httpClient.Jar.Cookies(wsURL); len(cookies) > 0 {
|
||||
var sb strings.Builder
|
||||
for i, ck := range cookies {
|
||||
if i > 0 {
|
||||
sb.WriteString("; ")
|
||||
}
|
||||
sb.WriteString(ck.Name + "=" + ck.Value)
|
||||
}
|
||||
headers.Set("Cookie", sb.String())
|
||||
}
|
||||
|
||||
dialer := websocket.Dialer{HandshakeTimeout: 15 * time.Second}
|
||||
conn, _, err := dialer.DialContext(ctx, wsURL.String(), headers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ws dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
slog.Info("frigate ws connected", "url", wsURL.String())
|
||||
|
||||
// Read deadline refreshed by every pong; gorilla auto-replies to server pings.
|
||||
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
})
|
||||
|
||||
// Local pinger to keep NAT/proxy paths alive.
|
||||
pingerCtx, cancelPinger := context.WithCancel(ctx)
|
||||
defer cancelPinger()
|
||||
go func() {
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-pingerCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ws read: %w", err)
|
||||
}
|
||||
c.dispatch(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) dispatch(raw []byte) {
|
||||
var f frame
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
slog.Debug("ws frame unparseable, ignoring", "err", err)
|
||||
return
|
||||
}
|
||||
if f.Topic != "events" {
|
||||
// Frigate sends other topics (stats, etc.) — we only forward "events".
|
||||
return
|
||||
}
|
||||
// payload is itself a JSON-encoded string per Frigate's wire format.
|
||||
c.handler([]byte(f.Payload))
|
||||
}
|
||||
|
||||
func wsURLFromBase(base string) (*url.URL, error) {
|
||||
u, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http":
|
||||
u.Scheme = "ws"
|
||||
case "https":
|
||||
u.Scheme = "wss"
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported scheme %q (want http or https)", u.Scheme)
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/") + "/ws"
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
114
internal/handlers/handlers.go
Normal file
114
internal/handlers/handlers.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cyrilinait/birdwatch-relay/internal/config"
|
||||
"github.com/cyrilinait/birdwatch-relay/internal/storage"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
store *storage.Store
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func Register(mux *http.ServeMux, store *storage.Store, cfg *config.Config) {
|
||||
h := &Handlers{store: store, cfg: cfg}
|
||||
mux.HandleFunc("/v1/devices", h.registerDevice)
|
||||
mux.HandleFunc("/v1/health", h.health)
|
||||
}
|
||||
|
||||
// --- /v1/health -----------------------------------------------------------
|
||||
|
||||
func (h *Handlers) health(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// --- /v1/devices ----------------------------------------------------------
|
||||
|
||||
type registerRequest struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
FCMToken string `json:"fcm_token"`
|
||||
PublicKey string `json:"public_key"` // base64 X25519, 32 bytes raw
|
||||
}
|
||||
|
||||
func (h *Handlers) registerDevice(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !h.checkBearer(r, h.cfg.PairingCode) {
|
||||
http.Error(w, "unauthorized — invalid pairing code", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var req registerRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.DeviceID == "" || req.FCMToken == "" || req.PublicKey == "" {
|
||||
http.Error(w, "missing fields", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pub, err := base64.StdEncoding.DecodeString(req.PublicKey)
|
||||
if err != nil || len(pub) != 32 {
|
||||
http.Error(w, "public_key must be base64-encoded 32-byte X25519", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Anti-hijack: existing device_id must keep the same public key.
|
||||
existing, getErr := h.store.Get(req.DeviceID)
|
||||
switch {
|
||||
case getErr == nil:
|
||||
if !bytes.Equal(existing.PublicKey, pub) {
|
||||
http.Error(w,
|
||||
"device_id already registered with a different public key — operator must delete the existing record to allow re-registration",
|
||||
http.StatusConflict)
|
||||
return
|
||||
}
|
||||
case errors.Is(getErr, storage.ErrNotFound):
|
||||
// fall through; new device.
|
||||
default:
|
||||
slog.Error("store get failed", "err", getErr, "device_id", req.DeviceID)
|
||||
http.Error(w, "storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := h.store.Upsert(storage.Device{
|
||||
ID: req.DeviceID,
|
||||
FCMToken: req.FCMToken,
|
||||
PublicKey: pub,
|
||||
CreatedAt: now,
|
||||
LastSeenAt: now,
|
||||
}); err != nil {
|
||||
slog.Error("device upsert failed", "err", err, "device_id", req.DeviceID)
|
||||
http.Error(w, "storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handlers) checkBearer(r *http.Request, want string) bool {
|
||||
got := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(got, prefix) {
|
||||
return false
|
||||
}
|
||||
have := strings.TrimPrefix(got, prefix)
|
||||
if len(have) != len(want) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(have), []byte(want)) == 1
|
||||
}
|
||||
57
internal/notify/dispatcher.go
Normal file
57
internal/notify/dispatcher.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// Dispatcher fan-outs an event payload to every registered device, encrypting
|
||||
// the payload to each device's X25519 public key (NaCl sealed box) and sending
|
||||
// the ciphertext via Firebase Cloud Messaging.
|
||||
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/cyrilinait/birdwatch-relay/internal/crypto"
|
||||
"github.com/cyrilinait/birdwatch-relay/internal/fcm"
|
||||
"github.com/cyrilinait/birdwatch-relay/internal/storage"
|
||||
)
|
||||
|
||||
type Dispatcher struct {
|
||||
store *storage.Store
|
||||
sender *fcm.Sender
|
||||
}
|
||||
|
||||
func NewDispatcher(store *storage.Store, sender *fcm.Sender) *Dispatcher {
|
||||
return &Dispatcher{store: store, sender: sender}
|
||||
}
|
||||
|
||||
// Send fans the payload out to every registered device. The payload is sealed
|
||||
// to each device's public key independently — Google FCM only ever sees
|
||||
// ciphertext + the device token. Errors per device are logged but do not stop
|
||||
// the rest of the fanout.
|
||||
func (d *Dispatcher) Send(ctx context.Context, payload []byte) {
|
||||
devices, err := d.store.ListAll()
|
||||
if err != nil {
|
||||
slog.Error("dispatcher: list devices failed", "err", err)
|
||||
return
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
slog.Debug("dispatcher: no registered devices, dropping event")
|
||||
return
|
||||
}
|
||||
var sent, failed int
|
||||
for _, dev := range devices {
|
||||
ct, err := crypto.Seal(payload, dev.PublicKey)
|
||||
if err != nil {
|
||||
slog.Error("dispatcher: seal failed", "err", err, "device_id", dev.ID)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
if err := d.sender.SendCiphertext(ctx, dev.FCMToken, ct); err != nil {
|
||||
slog.Warn("dispatcher: fcm send failed", "err", err, "device_id", dev.ID)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
}
|
||||
slog.Info("dispatcher fanout", "sent", sent, "failed", failed, "total", len(devices))
|
||||
}
|
||||
118
internal/storage/sqlite.go
Normal file
118
internal/storage/sqlite.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned by Get when no device row matches the given id.
|
||||
var ErrNotFound = errors.New("device not found")
|
||||
|
||||
type Device struct {
|
||||
ID string
|
||||
FCMToken string
|
||||
PublicKey []byte // 32-byte X25519
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
fcm_token TEXT NOT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
// Get returns the device row for id, or ErrNotFound if no row exists.
|
||||
func (s *Store) Get(id string) (*Device, error) {
|
||||
var d Device
|
||||
var created, lastSeen int64
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, fcm_token, public_key, created_at, last_seen_at FROM devices WHERE id = ?`,
|
||||
id,
|
||||
).Scan(&d.ID, &d.FCMToken, &d.PublicKey, &created, &lastSeen)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.CreatedAt = time.Unix(created, 0)
|
||||
d.LastSeenAt = time.Unix(lastSeen, 0)
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func (s *Store) Upsert(d Device) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO devices (id, fcm_token, public_key, created_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
fcm_token = excluded.fcm_token,
|
||||
public_key = excluded.public_key,
|
||||
last_seen_at = excluded.last_seen_at`,
|
||||
d.ID, d.FCMToken, d.PublicKey, d.CreatedAt.Unix(), d.LastSeenAt.Unix(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListAll() ([]Device, error) {
|
||||
rows, err := s.db.Query(`SELECT id, fcm_token, public_key, created_at, last_seen_at FROM devices`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var devices []Device
|
||||
for rows.Next() {
|
||||
var d Device
|
||||
var created, lastSeen int64
|
||||
if err := rows.Scan(&d.ID, &d.FCMToken, &d.PublicKey, &created, &lastSeen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.CreatedAt = time.Unix(created, 0)
|
||||
d.LastSeenAt = time.Unix(lastSeen, 0)
|
||||
devices = append(devices, d)
|
||||
}
|
||||
return devices, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) error {
|
||||
res, err := s.db.Exec(`DELETE FROM devices WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue