feat(devices): implement mobile device token management and push notifications
- Added device token management API for mobile devices, including registration, unregistration, and listing of devices. - Implemented push notification functionality using FCM for Android and APNS for iOS. - Introduced new endpoints for device registration and management in the devices API. - Enhanced the configuration to support mobile push notifications with optional credentials for FCM and APNS. - Updated database schema to include a new table for storing device tokens. - Added integration tests for device management and push notification features.
This commit is contained in:
parent
38c0534012
commit
f97988b51f
28
.env.example
28
.env.example
@ -28,6 +28,9 @@ JITSI_INTERNAL_AUTH_PASSWORD=changeme
|
||||
KEYDB_PASSWORD=
|
||||
MEILISEARCH_API_KEY=changeme
|
||||
TYPESENSE_API_KEY=changeme
|
||||
# Cloudflare Tunnel — dev local exposé publiquement (npm run expose)
|
||||
# CLOUDFLARE_TUNNEL_TOKEN=
|
||||
# CLOUDFLARE_TUNNEL_PUBLIC_URL=https://dev.ultispace.fr
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# General
|
||||
@ -207,6 +210,8 @@ SKYNET_WHISPER_MODEL=tiny
|
||||
|
||||
JICOFO_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
|
||||
JVB_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
|
||||
JIGASI_XMPP_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
|
||||
JIGASI_TRANSCRIBER_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
|
||||
JVB_STUN_SERVERS=stun.l.google.com:19302
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@ -343,3 +348,26 @@ SEARCH_ENGINE=postgres
|
||||
# VirusTotal (optional env fallback; prefer admin Settings > File policies)
|
||||
# -----------------------------------------------------------------------------
|
||||
# VIRUSTOTAL_API_KEY=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Mobile push notifications (FCM Android + APNS iOS) — optional
|
||||
# When unset, the push dispatcher logs and skips (local dev works without creds).
|
||||
# Tokens are registered by mobile apps via POST /api/v1/devices/register and a
|
||||
# new incoming mail fans out a push alongside the existing WS broadcast.
|
||||
# -----------------------------------------------------------------------------
|
||||
# --- FCM (Android), HTTP v1 API ---
|
||||
# Service account JSON (single line, e.g. from Firebase console > Service accounts).
|
||||
# project_id is read from the JSON unless FCM_PROJECT_ID overrides it.
|
||||
# Runtime secret supported via FCM_SERVICE_ACCOUNT_JSON_FILE.
|
||||
FCM_SERVICE_ACCOUNT_JSON=
|
||||
# FCM_PROJECT_ID=
|
||||
|
||||
# --- APNS (iOS), token-based .p8 auth ---
|
||||
# PEM contents of the AuthKey_XXXXXXXXXX.p8 file (multi-line OK with quotes, or via APNS_PRIVATE_KEY_FILE).
|
||||
APNS_PRIVATE_KEY=
|
||||
# 10-char Key ID from the Apple Developer key, the Apple Team ID, and the app bundle id (apns-topic).
|
||||
APNS_KEY_ID=
|
||||
APNS_TEAM_ID=
|
||||
APNS_BUNDLE_ID=
|
||||
# false = sandbox gateway (development builds); true = production gateway.
|
||||
APNS_PRODUCTION=false
|
||||
|
||||
28
deploy/expose.sh
Executable file
28
deploy/expose.sh
Executable file
@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Expose local nginx (:80) via Cloudflare Tunnel (e.g. https://dev.ultispace.fr).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
echo "Missing .env — run: cp .env.example .env" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source .env
|
||||
set +a
|
||||
|
||||
if [[ -z "${CLOUDFLARE_TUNNEL_TOKEN:-}" ]]; then
|
||||
echo "CLOUDFLARE_TUNNEL_TOKEN is not set in .env" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PUBLIC_URL="${CLOUDFLARE_TUNNEL_PUBLIC_URL:-https://dev.ultispace.fr}"
|
||||
echo "Cloudflare tunnel → local stack (nginx :80)"
|
||||
echo "Public URL: ${PUBLIC_URL}"
|
||||
echo "Start stack first: ./deploy/compose-up.sh up -d"
|
||||
|
||||
exec cloudflared tunnel --loglevel error run --token "$CLOUDFLARE_TUNNEL_TOKEN"
|
||||
@ -3,6 +3,9 @@ x-jitsi-env: &jitsi-env
|
||||
JWT_APP_SECRET: ${JITSI_APP_SECRET:-changeme-jwt-secret}
|
||||
JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD:-changeme}
|
||||
JVB_AUTH_PASSWORD: ${JVB_AUTH_PASSWORD:-changeme}
|
||||
JIGASI_XMPP_PASSWORD: ${JIGASI_XMPP_PASSWORD:-changeme}
|
||||
JIGASI_TRANSCRIBER_PASSWORD: ${JIGASI_TRANSCRIBER_PASSWORD:-changeme}
|
||||
XMPP_SERVER: jitsi-prosody
|
||||
TZ: Europe/Paris
|
||||
|
||||
services:
|
||||
@ -19,6 +22,7 @@ services:
|
||||
XMPP_DOMAIN: meet.jitsi
|
||||
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||
XMPP_BOSH_URL_BASE: http://jitsi-prosody:5280
|
||||
ENABLE_TRANSCRIPTIONS: "1"
|
||||
networks:
|
||||
- ulti-net
|
||||
depends_on:
|
||||
@ -37,6 +41,7 @@ services:
|
||||
XMPP_DOMAIN: meet.jitsi
|
||||
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
||||
ENABLE_TRANSCRIPTIONS: "1"
|
||||
networks:
|
||||
- ulti-net
|
||||
|
||||
@ -48,6 +53,7 @@ services:
|
||||
XMPP_DOMAIN: meet.jitsi
|
||||
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
||||
ENABLE_TRANSCRIPTIONS: "1"
|
||||
networks:
|
||||
- ulti-net
|
||||
depends_on:
|
||||
@ -72,7 +78,7 @@ services:
|
||||
|
||||
skynet:
|
||||
build:
|
||||
context: ./skynet
|
||||
context: jitsi/skynet
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@ -94,15 +100,14 @@ services:
|
||||
XMPP_DOMAIN: meet.jitsi
|
||||
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
||||
JIGASI_BREWERY_MUC: jigasibrewery@internal-muc.meet.jitsi
|
||||
JIGASI_MODE: transcriber
|
||||
JIGASI_BREWERY_MUC: jigasibrewery
|
||||
JIGASI_ENABLE_SDES_SRTP: "0"
|
||||
ENABLE_TRANSCRIPTIONS: "1"
|
||||
JIGASI_TRANSCRIBER_CUSTOM_SERVICE: org.jitsi.jigasi.transcription.WhisperTranscriptionService
|
||||
JIGASI_TRANSCRIBER_WHISPER_URL: ws://skynet:8000/streaming-whisper/ws
|
||||
JIGASI_TRANSCRIBER_SEND_JSON: "true"
|
||||
JIGASI_TRANSCRIBER_BASE_URL: http://ultid:8080/api/v1/meet/transcripts/
|
||||
volumes:
|
||||
- ./jigasi:/config:ro
|
||||
networks:
|
||||
- ulti-net
|
||||
depends_on:
|
||||
|
||||
@ -62,6 +62,7 @@ func (h *Handler) Routes() chi.Router {
|
||||
r.With(read).Get("/interactions", h.GetInteractionsByEmail)
|
||||
r.With(read).Get("/*", h.GetContact)
|
||||
r.With(write).Post("/books/{bookID}", h.CreateContact)
|
||||
r.With(write).Post("/books/{bookID}/import", h.ImportContacts)
|
||||
r.With(write).Post("/books/{bookID}/merge-duplicates", h.MergeDuplicateContacts)
|
||||
r.With(write).Post("/improve", h.ImproveContact)
|
||||
r.With(write).Put("/*", h.UpdateContact)
|
||||
@ -235,6 +236,48 @@ func (h *Handler) CreateContact(w http.ResponseWriter, r *http.Request) {
|
||||
apiresponse.WriteJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func (h *Handler) ImportContacts(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req importRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxImportBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
contacts, preFailures, verr := parseImportContacts(req.Contacts)
|
||||
if verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
bookID := chi.URLParam(r, "bookID")
|
||||
var result ImportResult
|
||||
err := h.retryOnDAVMissing(r.Context(), claims, ncUser, func(userID string) error {
|
||||
var importErr error
|
||||
result, importErr = h.svc.ImportContacts(r.Context(), userID, bookID, contacts.contacts)
|
||||
return importErr
|
||||
})
|
||||
if err != nil {
|
||||
h.writeContactServiceError(w, r, "import contacts", err)
|
||||
return
|
||||
}
|
||||
|
||||
if h.automation != nil {
|
||||
for i := range result.Contacts {
|
||||
h.automation.OnContactEvent(r.Context(), claims.Sub, rules.TriggerContactCreated, contactPayloadFrom(bookID, &result.Contacts[i]))
|
||||
}
|
||||
}
|
||||
|
||||
failed := mergeImportFailures(preFailures, result.Failed, contacts.originalIndex)
|
||||
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"created": result.Created,
|
||||
"failed": failed,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) GetContact(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
||||
|
||||
110
internal/api/contacts/import.go
Normal file
110
internal/api/contacts/import.go
Normal file
@ -0,0 +1,110 @@
|
||||
package contacts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
)
|
||||
|
||||
const (
|
||||
maxImportBody = 8 << 20 // 8 MiB
|
||||
maxContactsPerImport = 5000
|
||||
)
|
||||
|
||||
// importRequest is the bulk import body. Each element of contacts is either a
|
||||
// raw vCard string ("BEGIN:VCARD...") or a structured contact object matching
|
||||
// the single-create shape (full_name/email/phone/org or raw_vcard).
|
||||
type importRequest struct {
|
||||
Contacts []json.RawMessage `json:"contacts"`
|
||||
}
|
||||
|
||||
// parsedImport holds the valid contacts to send and a mapping back to the
|
||||
// caller's original array index (so failures can be reported precisely).
|
||||
type parsedImport struct {
|
||||
contacts []nextcloud.Contact
|
||||
originalIndex []int
|
||||
}
|
||||
|
||||
func parseImportContacts(items []json.RawMessage) (parsedImport, []ImportFailure, *apivalidate.ValidationError) {
|
||||
if len(items) == 0 {
|
||||
return parsedImport{}, nil, apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "contacts", Message: "at least one contact is required",
|
||||
})
|
||||
}
|
||||
if len(items) > maxContactsPerImport {
|
||||
return parsedImport{}, nil, apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "contacts", Message: "too many contacts in a single import",
|
||||
})
|
||||
}
|
||||
|
||||
out := parsedImport{
|
||||
contacts: make([]nextcloud.Contact, 0, len(items)),
|
||||
originalIndex: make([]int, 0, len(items)),
|
||||
}
|
||||
preFailures := make([]ImportFailure, 0)
|
||||
|
||||
for i, raw := range items {
|
||||
contact, err := decodeImportItem(raw)
|
||||
if err != "" {
|
||||
preFailures = append(preFailures, ImportFailure{Index: i, Error: err})
|
||||
continue
|
||||
}
|
||||
out.contacts = append(out.contacts, contact)
|
||||
out.originalIndex = append(out.originalIndex, i)
|
||||
}
|
||||
|
||||
return out, preFailures, nil
|
||||
}
|
||||
|
||||
// decodeImportItem turns a single array element into a Contact, returning a
|
||||
// non-empty error string when the element is unusable.
|
||||
func decodeImportItem(raw json.RawMessage) (nextcloud.Contact, string) {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return nextcloud.Contact{}, "empty contact"
|
||||
}
|
||||
|
||||
// A JSON string element is treated as a raw vCard.
|
||||
if trimmed[0] == '"' {
|
||||
var vcard string
|
||||
if err := json.Unmarshal(raw, &vcard); err != nil {
|
||||
return nextcloud.Contact{}, "invalid vcard string"
|
||||
}
|
||||
vcard = strings.TrimSpace(vcard)
|
||||
if vcard == "" {
|
||||
return nextcloud.Contact{}, "empty vcard"
|
||||
}
|
||||
return nextcloud.Contact{RawVCard: vcard}, ""
|
||||
}
|
||||
|
||||
var contact nextcloud.Contact
|
||||
if err := json.Unmarshal(raw, &contact); err != nil {
|
||||
return nextcloud.Contact{}, "invalid contact object"
|
||||
}
|
||||
if strings.TrimSpace(contact.RawVCard) == "" && strings.TrimSpace(contact.FullName) == "" {
|
||||
return nextcloud.Contact{}, "full_name or raw_vcard required"
|
||||
}
|
||||
return contact, ""
|
||||
}
|
||||
|
||||
// mergeImportFailures combines pre-send validation failures with service send
|
||||
// failures, remapping service indices (relative to the valid slice) back to the
|
||||
// caller's original array indices, sorted ascending.
|
||||
func mergeImportFailures(pre []ImportFailure, svc []ImportFailure, originalIndex []int) []ImportFailure {
|
||||
merged := make([]ImportFailure, 0, len(pre)+len(svc))
|
||||
merged = append(merged, pre...)
|
||||
for _, f := range svc {
|
||||
idx := f.Index
|
||||
if idx >= 0 && idx < len(originalIndex) {
|
||||
idx = originalIndex[idx]
|
||||
}
|
||||
merged = append(merged, ImportFailure{Index: idx, Error: f.Error})
|
||||
}
|
||||
sort.SliceStable(merged, func(i, j int) bool {
|
||||
return merged[i].Index < merged[j].Index
|
||||
})
|
||||
return merged
|
||||
}
|
||||
@ -99,6 +99,39 @@ func (s *Service) CreateContact(ctx context.Context, userID, bookID string, cont
|
||||
return s.nc.CreateContact(ctx, userID, bookPath(userID, bookID), contact)
|
||||
}
|
||||
|
||||
// ImportFailure describes a single contact that could not be imported.
|
||||
type ImportFailure struct {
|
||||
Index int `json:"index"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// ImportResult is the outcome of a bulk import.
|
||||
type ImportResult struct {
|
||||
Created int `json:"created"`
|
||||
Failed []ImportFailure `json:"failed"`
|
||||
Contacts []nextcloud.Contact `json:"contacts,omitempty"`
|
||||
}
|
||||
|
||||
// ImportContacts creates many contacts in a single address book. Each item is
|
||||
// created independently; failures are collected and reported without aborting
|
||||
// the rest of the batch.
|
||||
func (s *Service) ImportContacts(ctx context.Context, userID, bookID string, contacts []nextcloud.Contact) (ImportResult, error) {
|
||||
result := ImportResult{Failed: make([]ImportFailure, 0), Contacts: make([]nextcloud.Contact, 0, len(contacts))}
|
||||
path := bookPath(userID, bookID)
|
||||
for i := range contacts {
|
||||
created, err := s.nc.CreateContact(ctx, userID, path, &contacts[i])
|
||||
if err != nil {
|
||||
result.Failed = append(result.Failed, ImportFailure{Index: i, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
result.Created++
|
||||
if created != nil {
|
||||
result.Contacts = append(result.Contacts, *created)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetContact(ctx context.Context, userID, contactPath string) (*nextcloud.Contact, error) {
|
||||
return s.nc.GetContact(ctx, userID, contactPath)
|
||||
}
|
||||
|
||||
180
internal/api/devices/handlers.go
Normal file
180
internal/api/devices/handlers.go
Normal file
@ -0,0 +1,180 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||
)
|
||||
|
||||
const maxRequestBody = 16 << 10
|
||||
|
||||
// Handler exposes mobile device token registration endpoints.
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHandler(db *pgxpool.Pool) *Handler {
|
||||
return &Handler{
|
||||
svc: NewService(db),
|
||||
logger: slog.Default().With("component", "devices-api"),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", h.List)
|
||||
r.Post("/register", h.Register)
|
||||
r.Post("/unregister", h.Unregister)
|
||||
r.Delete("/{id}", h.Delete)
|
||||
return r
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Platform string `json:"platform"`
|
||||
App string `json:"app"`
|
||||
PushToken string `json:"push_token"`
|
||||
DeviceID string `json:"device_id"`
|
||||
}
|
||||
|
||||
type unregisterRequest struct {
|
||||
PushToken string `json:"push_token"`
|
||||
}
|
||||
|
||||
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req registerRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
platform := strings.TrimSpace(strings.ToLower(req.Platform))
|
||||
app := strings.TrimSpace(req.App)
|
||||
pushToken := strings.TrimSpace(req.PushToken)
|
||||
deviceID := strings.TrimSpace(req.DeviceID)
|
||||
|
||||
if verr := validateRegister(platform, app, pushToken); verr != nil {
|
||||
apivalidate.WriteValidationError(w, r, verr)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := h.svc.Register(r.Context(), claims.Sub, platform, app, pushToken, deviceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
apiresponse.WriteError(w, r, http.StatusNotFound, apiresponse.CodeNotFound, "user not found", nil)
|
||||
return
|
||||
}
|
||||
h.logger.Error("register device", "error", err, "sub", claims.Sub)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (h *Handler) Unregister(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req unregisterRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil {
|
||||
return
|
||||
}
|
||||
pushToken := strings.TrimSpace(req.PushToken)
|
||||
if pushToken == "" {
|
||||
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "push_token", Message: "required",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.UnregisterByToken(r.Context(), claims.Sub, pushToken); err != nil {
|
||||
if errors.Is(err, ErrDeviceNotFound) || errors.Is(err, ErrUserNotFound) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
h.logger.Error("unregister device", "error", err, "sub", claims.Sub)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "id", Message: "required",
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.UnregisterByID(r.Context(), claims.Sub, id); err != nil {
|
||||
if errors.Is(err, ErrDeviceNotFound) || errors.Is(err, ErrUserNotFound) {
|
||||
apivalidate.WriteNotFound(w, r, "device token not found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("delete device", "error", err, "sub", claims.Sub)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil)
|
||||
return
|
||||
}
|
||||
list, err := h.svc.List(r.Context(), claims.Sub)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"devices": []Device{}})
|
||||
return
|
||||
}
|
||||
h.logger.Error("list devices", "error", err, "sub", claims.Sub)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"devices": list})
|
||||
}
|
||||
|
||||
func validateRegister(platform, app, pushToken string) *apivalidate.ValidationError {
|
||||
if platform != "ios" && platform != "android" {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "platform", Message: "must be 'ios' or 'android'",
|
||||
})
|
||||
}
|
||||
if app == "" {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "app", Message: "required",
|
||||
})
|
||||
}
|
||||
if pushToken == "" {
|
||||
return apivalidate.NewValidationError(apivalidate.FieldDetail{
|
||||
Field: "push_token", Message: "required",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
145
internal/api/devices/service.go
Normal file
145
internal/api/devices/service.go
Normal file
@ -0,0 +1,145 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/users"
|
||||
)
|
||||
|
||||
// ErrUserNotFound is returned when the authenticated user has no provisioned row.
|
||||
var ErrUserNotFound = errors.New("user not found")
|
||||
|
||||
// ErrDeviceNotFound is returned when a delete affects no rows.
|
||||
var ErrDeviceNotFound = errors.New("device token not found")
|
||||
|
||||
// Device is a registered mobile device push token.
|
||||
type Device struct {
|
||||
ID string `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
App string `json:"app"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Service persists device tokens keyed by the internal user id.
|
||||
type Service struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewService(db *pgxpool.Pool) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
// Register upserts a device token for the given external user id (OIDC sub),
|
||||
// returning the row id. Re-registering an identical (user, app, push_token)
|
||||
// refreshes platform/device_id/updated_at.
|
||||
func (s *Service) Register(ctx context.Context, externalID, platform, app, pushToken, deviceID string) (string, error) {
|
||||
userID, err := s.resolveUser(ctx, externalID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var devicePtr *string
|
||||
if deviceID != "" {
|
||||
devicePtr = &deviceID
|
||||
}
|
||||
|
||||
var id string
|
||||
err = s.db.QueryRow(ctx, `
|
||||
INSERT INTO device_tokens (user_id, platform, app, push_token, device_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id, app, push_token)
|
||||
DO UPDATE SET platform = EXCLUDED.platform,
|
||||
device_id = EXCLUDED.device_id,
|
||||
updated_at = now()
|
||||
RETURNING id::text
|
||||
`, userID, platform, app, pushToken, devicePtr).Scan(&id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// UnregisterByID removes a device token owned by the user.
|
||||
func (s *Service) UnregisterByID(ctx context.Context, externalID, id string) error {
|
||||
userID, err := s.resolveUser(ctx, externalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
DELETE FROM device_tokens WHERE id = $1 AND user_id = $2
|
||||
`, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrDeviceNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnregisterByToken removes a device token by its push token for the user.
|
||||
func (s *Service) UnregisterByToken(ctx context.Context, externalID, pushToken string) error {
|
||||
userID, err := s.resolveUser(ctx, externalID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
DELETE FROM device_tokens WHERE push_token = $1 AND user_id = $2
|
||||
`, pushToken, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrDeviceNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns the user's registered devices, most recently updated first.
|
||||
func (s *Service) List(ctx context.Context, externalID string) ([]Device, error) {
|
||||
userID, err := s.resolveUser(ctx, externalID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id::text, platform, app, coalesce(device_id, ''), created_at, updated_at
|
||||
FROM device_tokens
|
||||
WHERE user_id = $1
|
||||
ORDER BY updated_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]Device, 0)
|
||||
for rows.Next() {
|
||||
var d Device
|
||||
if err := rows.Scan(&d.ID, &d.Platform, &d.App, &d.DeviceID, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) resolveUser(ctx context.Context, externalID string) (string, error) {
|
||||
userID, err := users.LookupUserID(ctx, s.db, externalID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrUserNotFound
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if userID == "" {
|
||||
return "", ErrUserNotFound
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
@ -166,6 +166,16 @@ type Config struct {
|
||||
// VirusTotal (optional env fallback for org file_policies.virustotal_api_key)
|
||||
VirusTotalAPIKey string
|
||||
|
||||
// Mobile push notifications (FCM Android + APNS iOS).
|
||||
// All optional; when unset the dispatcher no-ops so local dev works.
|
||||
FCMServiceAccountJSON string
|
||||
FCMProjectID string
|
||||
APNSPrivateKey string
|
||||
APNSKeyID string
|
||||
APNSTeamID string
|
||||
APNSBundleID string
|
||||
APNSProduction bool
|
||||
|
||||
// Observability
|
||||
HealthNextcloudURL string
|
||||
HealthImmichURL string
|
||||
@ -315,6 +325,14 @@ func Load() (*Config, error) {
|
||||
|
||||
VirusTotalAPIKey: secrets.Env("VIRUSTOTAL_API_KEY"),
|
||||
|
||||
FCMServiceAccountJSON: secrets.Env("FCM_SERVICE_ACCOUNT_JSON"),
|
||||
FCMProjectID: os.Getenv("FCM_PROJECT_ID"),
|
||||
APNSPrivateKey: secrets.Env("APNS_PRIVATE_KEY"),
|
||||
APNSKeyID: os.Getenv("APNS_KEY_ID"),
|
||||
APNSTeamID: os.Getenv("APNS_TEAM_ID"),
|
||||
APNSBundleID: os.Getenv("APNS_BUNDLE_ID"),
|
||||
APNSProduction: envBool("APNS_PRODUCTION", false),
|
||||
|
||||
HealthNextcloudURL: envOrDefault("HEALTH_NEXTCLOUD_URL", joinURL(envOrDefault("NEXTCLOUD_URL", "http://nextcloud:80"), "/status.php")),
|
||||
HealthImmichURL: envOrDefault("HEALTH_IMMICH_URL", joinURL(envOrDefault("IMMICH_API_URL", "http://immich-server:2283/api"), "/server-info/ping")),
|
||||
HealthJitsiURL: envOrDefault("HEALTH_JITSI_URL", defaultHealthJitsiURL(envOrDefault("JITSI_PUBLIC_URL", "https://localhost/meet"))),
|
||||
|
||||
@ -4,12 +4,19 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/ultisuite/ulti-backend/internal/mail/rules"
|
||||
"github.com/ultisuite/ulti-backend/internal/realtime"
|
||||
)
|
||||
|
||||
// Pusher fans a mobile push notification out to a user's registered devices on
|
||||
// a newly received inbox message. Implemented by *push.Dispatcher.
|
||||
type Pusher interface {
|
||||
NotifyNewMail(ctx context.Context, userID, messageID, accountID, sender, subject string)
|
||||
}
|
||||
|
||||
type postSyncEvent struct {
|
||||
userID string
|
||||
accountID string
|
||||
@ -24,15 +31,17 @@ type syncPipeline struct {
|
||||
rules *rules.Engine
|
||||
automation MailAutomation
|
||||
hub *realtime.Hub
|
||||
push Pusher
|
||||
}
|
||||
|
||||
func newSyncPipeline(db *pgxpool.Pool, rulesEngine *rules.Engine, automation MailAutomation, hub *realtime.Hub) *syncPipeline {
|
||||
func newSyncPipeline(db *pgxpool.Pool, rulesEngine *rules.Engine, automation MailAutomation, hub *realtime.Hub, pusher Pusher) *syncPipeline {
|
||||
return &syncPipeline{
|
||||
db: db,
|
||||
logger: slog.Default().With("component", "imap-pipeline"),
|
||||
rules: rulesEngine,
|
||||
automation: automation,
|
||||
hub: hub,
|
||||
push: pusher,
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,10 +67,72 @@ func (p *syncPipeline) handle(ctx context.Context, ev postSyncEvent) {
|
||||
event := realtime.NewMailUpdatedEvent(ev.messageID, ev.accountID)
|
||||
if ev.kind == "created" {
|
||||
event = realtime.NewMailCreatedEvent(ev.messageID, ev.accountID)
|
||||
p.maybePush(ctx, ev)
|
||||
}
|
||||
p.broadcast(ev, event)
|
||||
}
|
||||
|
||||
// maybePush fires a best-effort mobile push for a newly received inbox message.
|
||||
// It skips non-inbox folders, already-read messages, and stale backfilled mail
|
||||
// (older than 24h) to avoid notification storms during initial account sync.
|
||||
func (p *syncPipeline) maybePush(ctx context.Context, ev postSyncEvent) {
|
||||
if p.push == nil || ev.userID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fromJSON []byte
|
||||
subject string
|
||||
date time.Time
|
||||
folderType string
|
||||
flags []string
|
||||
)
|
||||
err := p.db.QueryRow(ctx, `
|
||||
SELECT m.from_addr, m.subject, m.date, COALESCE(mf.folder_type, ''), COALESCE(m.flags, '{}')
|
||||
FROM messages m
|
||||
LEFT JOIN mail_folders mf ON m.folder_id = mf.id
|
||||
WHERE m.id = $1
|
||||
`, ev.messageID).Scan(&fromJSON, &subject, &date, &folderType, &flags)
|
||||
if err != nil {
|
||||
p.logger.Error("load message for push", "message_id", ev.messageID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if folderType != "inbox" {
|
||||
return
|
||||
}
|
||||
for _, f := range flags {
|
||||
if f == `\Seen` {
|
||||
return
|
||||
}
|
||||
}
|
||||
if !date.IsZero() && time.Since(date) > 24*time.Hour {
|
||||
return
|
||||
}
|
||||
|
||||
sender := firstAddressDisplay(fromJSON)
|
||||
if subject == "" {
|
||||
subject = "(no subject)"
|
||||
}
|
||||
|
||||
go p.push.NotifyNewMail(context.Background(), ev.userID, ev.messageID, ev.accountID, sender, subject)
|
||||
}
|
||||
|
||||
func firstAddressDisplay(fromJSON []byte) string {
|
||||
var addrs []EmailAddress
|
||||
if err := json.Unmarshal(fromJSON, &addrs); err != nil || len(addrs) == 0 {
|
||||
return "New mail"
|
||||
}
|
||||
a := addrs[0]
|
||||
if a.Name != "" {
|
||||
return a.Name
|
||||
}
|
||||
if a.Address != "" {
|
||||
return a.Address
|
||||
}
|
||||
return "New mail"
|
||||
}
|
||||
|
||||
func (p *syncPipeline) broadcast(ev postSyncEvent, event realtime.Event) {
|
||||
if p.hub == nil || ev.userID == "" {
|
||||
return
|
||||
|
||||
@ -40,6 +40,7 @@ type SyncDeps struct {
|
||||
Automation MailAutomation
|
||||
Hub *realtime.Hub
|
||||
FileScanner *filescan.Scanner
|
||||
Push Pusher
|
||||
}
|
||||
|
||||
type SyncWorker struct {
|
||||
@ -64,7 +65,7 @@ func NewSyncWorker(db *pgxpool.Pool, interval time.Duration, credManager *creden
|
||||
storage: deps.Storage,
|
||||
attachBucket: deps.AttachBucket,
|
||||
scanner: deps.FileScanner,
|
||||
pipeline: newSyncPipeline(db, deps.Rules, deps.Automation, deps.Hub),
|
||||
pipeline: newSyncPipeline(db, deps.Rules, deps.Automation, deps.Hub, deps.Push),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
204
internal/push/apns.go
Normal file
204
internal/push/apns.go
Normal file
@ -0,0 +1,204 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// apnsClient sends notifications through the APNS HTTP/2 provider API using
|
||||
// token-based (.p8) authentication.
|
||||
type apnsClient struct {
|
||||
key *ecdsa.PrivateKey
|
||||
keyID string
|
||||
teamID string
|
||||
topic string
|
||||
host string
|
||||
http *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
cachedJWT string
|
||||
jwtIssued time.Time
|
||||
}
|
||||
|
||||
// newAPNSClient returns a configured client, or nil (no error) when APNS is not
|
||||
// configured. An error is returned only when provided credentials are invalid.
|
||||
func newAPNSClient(cfg Config) (*apnsClient, error) {
|
||||
pemKey := strings.TrimSpace(cfg.APNSPrivateKey)
|
||||
if pemKey == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.TrimSpace(cfg.APNSKeyID) == "" ||
|
||||
strings.TrimSpace(cfg.APNSTeamID) == "" ||
|
||||
strings.TrimSpace(cfg.APNSBundleID) == "" {
|
||||
return nil, fmt.Errorf("apns requires APNS_KEY_ID, APNS_TEAM_ID and APNS_BUNDLE_ID")
|
||||
}
|
||||
|
||||
key, err := parseAPNSKey(pemKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
host := "https://api.sandbox.push.apple.com"
|
||||
if cfg.APNSProduction {
|
||||
host = "https://api.push.apple.com"
|
||||
}
|
||||
|
||||
return &apnsClient{
|
||||
key: key,
|
||||
keyID: strings.TrimSpace(cfg.APNSKeyID),
|
||||
teamID: strings.TrimSpace(cfg.APNSTeamID),
|
||||
topic: strings.TrimSpace(cfg.APNSBundleID),
|
||||
host: host,
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseAPNSKey(pemKey string) (*ecdsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(pemKey))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("apns private key is not valid PEM")
|
||||
}
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse apns private key: %w", err)
|
||||
}
|
||||
key, ok := parsed.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("apns private key is not an ECDSA key")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// jwt returns a cached provider authentication token, regenerating it when it
|
||||
// is older than 40 minutes (Apple allows reuse for up to 60 minutes).
|
||||
func (c *apnsClient) jwt() (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.cachedJWT != "" && time.Since(c.jwtIssued) < 40*time.Minute {
|
||||
return c.cachedJWT, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
header := map[string]string{"alg": "ES256", "kid": c.keyID}
|
||||
claims := map[string]any{"iss": c.teamID, "iat": now.Unix()}
|
||||
|
||||
headerJSON, _ := json.Marshal(header)
|
||||
claimsJSON, _ := json.Marshal(claims)
|
||||
|
||||
signingInput := base64URL(headerJSON) + "." + base64URL(claimsJSON)
|
||||
|
||||
digest := sha256.Sum256([]byte(signingInput))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, c.key, digest[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign apns jwt: %w", err)
|
||||
}
|
||||
signature := ecdsaSignatureBytes(r, s)
|
||||
|
||||
token := signingInput + "." + base64URL(signature)
|
||||
c.cachedJWT = token
|
||||
c.jwtIssued = now
|
||||
return token, nil
|
||||
}
|
||||
|
||||
type apnsPayload struct {
|
||||
APS apnsAPS `json:"aps"`
|
||||
Data map[string]string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type apnsAPS struct {
|
||||
Alert apnsAlert `json:"alert"`
|
||||
Sound string `json:"sound,omitempty"`
|
||||
}
|
||||
|
||||
type apnsAlert struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
func (c *apnsClient) send(ctx context.Context, deviceToken string, n Notification) error {
|
||||
token, err := c.jwt()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload := apnsPayload{
|
||||
APS: apnsAPS{
|
||||
Alert: apnsAlert{Title: n.Title, Body: n.Body},
|
||||
Sound: "default",
|
||||
},
|
||||
Data: n.Data,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
url := c.host + "/3/device/" + deviceToken
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("authorization", "bearer "+token)
|
||||
req.Header.Set("apns-topic", c.topic)
|
||||
req.Header.Set("apns-push-type", "alert")
|
||||
req.Header.Set("content-type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
// 410 Gone (and 400 BadDeviceToken/DeviceTokenNotForTopic) means prune.
|
||||
if resp.StatusCode == http.StatusGone || apnsIsBadToken(respBody) {
|
||||
return errTokenUnregistered
|
||||
}
|
||||
return fmt.Errorf("apns send status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
|
||||
func apnsIsBadToken(body []byte) bool {
|
||||
var parsed struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return false
|
||||
}
|
||||
switch parsed.Reason {
|
||||
case "Unregistered", "BadDeviceToken", "DeviceTokenNotForTopic":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func base64URL(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// ecdsaSignatureBytes encodes an ES256 signature as the fixed-width R||S form
|
||||
// required by JWS (each integer left-padded to 32 bytes).
|
||||
func ecdsaSignatureBytes(r, s *big.Int) []byte {
|
||||
const size = 32
|
||||
out := make([]byte, size*2)
|
||||
r.FillBytes(out[:size])
|
||||
s.FillBytes(out[size:])
|
||||
return out
|
||||
}
|
||||
145
internal/push/fcm.go
Normal file
145
internal/push/fcm.go
Normal file
@ -0,0 +1,145 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
)
|
||||
|
||||
const fcmMessagingScope = "https://www.googleapis.com/auth/firebase.messaging"
|
||||
|
||||
// fcmClient sends notifications through the FCM HTTP v1 API.
|
||||
type fcmClient struct {
|
||||
projectID string
|
||||
tokenSource oauth2.TokenSource
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// newFCMClient returns a configured client, or nil (no error) when FCM is not
|
||||
// configured. An error is returned only when provided credentials are invalid.
|
||||
func newFCMClient(cfg Config) (*fcmClient, error) {
|
||||
raw := strings.TrimSpace(cfg.FCMServiceAccountJSON)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
creds, err := google.CredentialsFromJSON(context.Background(), []byte(raw), fcmMessagingScope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse fcm service account: %w", err)
|
||||
}
|
||||
|
||||
projectID := strings.TrimSpace(cfg.FCMProjectID)
|
||||
if projectID == "" {
|
||||
projectID = creds.ProjectID
|
||||
}
|
||||
if projectID == "" {
|
||||
// Fall back to parsing the JSON directly for project_id.
|
||||
var sa struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(raw), &sa)
|
||||
projectID = strings.TrimSpace(sa.ProjectID)
|
||||
}
|
||||
if projectID == "" {
|
||||
return nil, fmt.Errorf("fcm project id missing (set FCM_PROJECT_ID or include project_id in service account)")
|
||||
}
|
||||
|
||||
return &fcmClient{
|
||||
projectID: projectID,
|
||||
tokenSource: creds.TokenSource,
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type fcmMessage struct {
|
||||
Message fcmMessageBody `json:"message"`
|
||||
}
|
||||
|
||||
type fcmMessageBody struct {
|
||||
Token string `json:"token"`
|
||||
Notification fcmNotification `json:"notification"`
|
||||
Data map[string]string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type fcmNotification struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
func (c *fcmClient) send(ctx context.Context, deviceToken string, n Notification) error {
|
||||
accessToken, err := c.tokenSource.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fcm access token: %w", err)
|
||||
}
|
||||
|
||||
payload := fcmMessage{
|
||||
Message: fcmMessageBody{
|
||||
Token: deviceToken,
|
||||
Notification: fcmNotification{
|
||||
Title: n.Title,
|
||||
Body: n.Body,
|
||||
},
|
||||
Data: n.Data,
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", c.projectID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken.AccessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
// A 404 (NOT_FOUND) or UNREGISTERED error means the token is dead.
|
||||
if resp.StatusCode == http.StatusNotFound || fcmIsUnregistered(respBody) {
|
||||
return errTokenUnregistered
|
||||
}
|
||||
return fmt.Errorf("fcm send status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
|
||||
func fcmIsUnregistered(body []byte) bool {
|
||||
var parsed struct {
|
||||
Error struct {
|
||||
Status string `json:"status"`
|
||||
Details []struct {
|
||||
ErrorCode string `json:"errorCode"`
|
||||
} `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return false
|
||||
}
|
||||
if parsed.Error.Status == "NOT_FOUND" {
|
||||
return true
|
||||
}
|
||||
for _, d := range parsed.Error.Details {
|
||||
if d.ErrorCode == "UNREGISTERED" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
183
internal/push/push.go
Normal file
183
internal/push/push.go
Normal file
@ -0,0 +1,183 @@
|
||||
// Package push delivers mobile push notifications to registered device tokens
|
||||
// via FCM (Android, HTTP v1) and APNS (iOS, HTTP/2). When credentials are not
|
||||
// configured the dispatcher degrades to a no-op so local development keeps
|
||||
// working without Firebase/Apple secrets.
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Config holds FCM/APNS credentials. All fields are optional; a provider is only
|
||||
// enabled when its required fields are present.
|
||||
type Config struct {
|
||||
// FCM (Android) — service account JSON (HTTP v1 API).
|
||||
FCMServiceAccountJSON string
|
||||
FCMProjectID string // optional; derived from the service account JSON when empty
|
||||
|
||||
// APNS (iOS) — token-based auth (.p8 key).
|
||||
APNSPrivateKey string // PEM contents of the AuthKey_XXXX.p8 file
|
||||
APNSKeyID string
|
||||
APNSTeamID string
|
||||
APNSBundleID string // apns-topic (app bundle identifier)
|
||||
APNSProduction bool // false -> sandbox gateway
|
||||
}
|
||||
|
||||
// Notification is the platform-agnostic payload fanned out to devices.
|
||||
type Notification struct {
|
||||
Title string
|
||||
Body string
|
||||
// Data is delivered as the FCM data map / APNS custom keys. Values must be strings.
|
||||
Data map[string]string
|
||||
}
|
||||
|
||||
// deviceToken is a single registered device row.
|
||||
type deviceToken struct {
|
||||
id string
|
||||
platform string
|
||||
app string
|
||||
pushToken string
|
||||
}
|
||||
|
||||
// Dispatcher loads device tokens and sends notifications best-effort.
|
||||
type Dispatcher struct {
|
||||
db *pgxpool.Pool
|
||||
fcm *fcmClient
|
||||
apns *apnsClient
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewDispatcher builds a dispatcher. Missing credentials disable the relevant
|
||||
// provider; an entirely unconfigured dispatcher silently skips all sends.
|
||||
func NewDispatcher(db *pgxpool.Pool, cfg Config) *Dispatcher {
|
||||
logger := slog.Default().With("component", "push")
|
||||
|
||||
d := &Dispatcher{db: db, logger: logger}
|
||||
|
||||
if fcm, err := newFCMClient(cfg); err != nil {
|
||||
logger.Warn("fcm push disabled", "error", err)
|
||||
} else if fcm != nil {
|
||||
d.fcm = fcm
|
||||
logger.Info("fcm push enabled", "project_id", fcm.projectID)
|
||||
}
|
||||
|
||||
if apns, err := newAPNSClient(cfg); err != nil {
|
||||
logger.Warn("apns push disabled", "error", err)
|
||||
} else if apns != nil {
|
||||
d.apns = apns
|
||||
logger.Info("apns push enabled", "bundle_id", apns.topic, "production", cfg.APNSProduction)
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// Enabled reports whether at least one provider is configured.
|
||||
func (d *Dispatcher) Enabled() bool {
|
||||
return d != nil && (d.fcm != nil || d.apns != nil)
|
||||
}
|
||||
|
||||
// NotifyUser fans a notification out to every registered device of the given
|
||||
// internal user id (users.id UUID). It is best-effort: failures are logged and
|
||||
// stale tokens are pruned, but no error is returned.
|
||||
func (d *Dispatcher) NotifyUser(ctx context.Context, userID string, n Notification) {
|
||||
if d == nil || d.db == nil || userID == "" {
|
||||
return
|
||||
}
|
||||
if !d.Enabled() {
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := d.loadTokens(ctx, userID)
|
||||
if err != nil {
|
||||
d.logger.Error("load device tokens", "user_id", userID, "error", err)
|
||||
return
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, t := range tokens {
|
||||
var sendErr error
|
||||
switch t.platform {
|
||||
case "android":
|
||||
if d.fcm == nil {
|
||||
continue
|
||||
}
|
||||
sendErr = d.fcm.send(ctx, t.pushToken, n)
|
||||
case "ios":
|
||||
if d.apns == nil {
|
||||
continue
|
||||
}
|
||||
sendErr = d.apns.send(ctx, t.pushToken, n)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if sendErr == nil {
|
||||
continue
|
||||
}
|
||||
if errors.Is(sendErr, errTokenUnregistered) {
|
||||
d.deleteToken(ctx, t.id)
|
||||
d.logger.Info("pruned unregistered device token", "platform", t.platform, "app", t.app)
|
||||
continue
|
||||
}
|
||||
d.logger.Warn("push send failed", "platform", t.platform, "app", t.app, "error", sendErr)
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyNewMail sends a "new mail" notification to the user's devices. It
|
||||
// satisfies the imap.Pusher interface used by the mail sync pipeline.
|
||||
func (d *Dispatcher) NotifyNewMail(ctx context.Context, userID, messageID, accountID, sender, subject string) {
|
||||
if d == nil || !d.Enabled() {
|
||||
return
|
||||
}
|
||||
title := sender
|
||||
if title == "" {
|
||||
title = "New mail"
|
||||
}
|
||||
d.NotifyUser(ctx, userID, Notification{
|
||||
Title: title,
|
||||
Body: subject,
|
||||
Data: map[string]string{
|
||||
"type": "mail.created",
|
||||
"message_id": messageID,
|
||||
"account_id": accountID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Dispatcher) loadTokens(ctx context.Context, userID string) ([]deviceToken, error) {
|
||||
rows, err := d.db.Query(ctx, `
|
||||
SELECT id::text, platform, app, push_token
|
||||
FROM device_tokens
|
||||
WHERE user_id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []deviceToken
|
||||
for rows.Next() {
|
||||
var t deviceToken
|
||||
if err := rows.Scan(&t.id, &t.platform, &t.app, &t.pushToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *Dispatcher) deleteToken(ctx context.Context, id string) {
|
||||
if _, err := d.db.Exec(ctx, `DELETE FROM device_tokens WHERE id = $1`, id); err != nil {
|
||||
d.logger.Warn("delete stale device token", "id", id, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// errTokenUnregistered signals that a device token is no longer valid and
|
||||
// should be removed from storage.
|
||||
var errTokenUnregistered = errors.New("push: device token unregistered")
|
||||
@ -20,6 +20,7 @@ import (
|
||||
"github.com/ultisuite/ulti-backend/internal/api/admin"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/calendar"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/contacts"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/devices"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/docs"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/drive"
|
||||
mailapi "github.com/ultisuite/ulti-backend/internal/api/mail"
|
||||
@ -56,6 +57,7 @@ import (
|
||||
"github.com/ultisuite/ulti-backend/internal/observability"
|
||||
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
|
||||
"github.com/ultisuite/ulti-backend/internal/photos"
|
||||
"github.com/ultisuite/ulti-backend/internal/push"
|
||||
"github.com/ultisuite/ulti-backend/internal/realtime"
|
||||
"github.com/ultisuite/ulti-backend/internal/search"
|
||||
"github.com/ultisuite/ulti-backend/internal/securityaudit"
|
||||
@ -195,6 +197,16 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
|
||||
hub := realtime.NewHub(verifierHolder, pool)
|
||||
healthChecker := observability.NewHealthChecker(cfg, pool, rdb)
|
||||
|
||||
pushDispatcher := push.NewDispatcher(pool, push.Config{
|
||||
FCMServiceAccountJSON: cfg.FCMServiceAccountJSON,
|
||||
FCMProjectID: cfg.FCMProjectID,
|
||||
APNSPrivateKey: cfg.APNSPrivateKey,
|
||||
APNSKeyID: cfg.APNSKeyID,
|
||||
APNSTeamID: cfg.APNSTeamID,
|
||||
APNSBundleID: cfg.APNSBundleID,
|
||||
APNSProduction: cfg.APNSProduction,
|
||||
})
|
||||
|
||||
hookExec := webhooks.NewExecutor(pool)
|
||||
rulesEngine := rules.NewEngineWithWebhooks(pool, hookExec)
|
||||
autoDispatcher := automation.NewDispatcher(pool, rulesEngine, hookExec)
|
||||
@ -300,6 +312,7 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
|
||||
Automation: autoDispatcher,
|
||||
Hub: hub,
|
||||
FileScanner: fileScanner,
|
||||
Push: pushDispatcher,
|
||||
})
|
||||
go syncWorker.Start(workerCtx)
|
||||
}
|
||||
@ -414,6 +427,7 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
|
||||
r.Use(middleware.EnforceApiTokenPolicy())
|
||||
|
||||
r.Mount("/api/v1/users", usersapi.NewHandler(pool, cfg).Routes())
|
||||
r.Mount("/api/v1/devices", devices.NewHandler(pool).Routes())
|
||||
adminHandler := admin.NewHandler(pool, auditLogger, cfg, ncClient)
|
||||
adminHandler.SetHostedService(hostedSvc)
|
||||
adminHandler.SetMigrationService(migrationSvc)
|
||||
|
||||
1
migrations/000054_device_tokens.down.sql
Normal file
1
migrations/000054_device_tokens.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS device_tokens;
|
||||
13
migrations/000054_device_tokens.up.sql
Normal file
13
migrations/000054_device_tokens.up.sql
Normal file
@ -0,0 +1,13 @@
|
||||
CREATE TABLE device_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')),
|
||||
app TEXT NOT NULL,
|
||||
push_token TEXT NOT NULL,
|
||||
device_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_device_tokens_user_app_token ON device_tokens(user_id, app, push_token);
|
||||
CREATE INDEX idx_device_tokens_user ON device_tokens(user_id);
|
||||
7
package.json
Normal file
7
package.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "ulti-backend",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"expose": "bash deploy/expose.sh"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user