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=
|
KEYDB_PASSWORD=
|
||||||
MEILISEARCH_API_KEY=changeme
|
MEILISEARCH_API_KEY=changeme
|
||||||
TYPESENSE_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
|
# General
|
||||||
@ -207,6 +210,8 @@ SKYNET_WHISPER_MODEL=tiny
|
|||||||
|
|
||||||
JICOFO_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
|
JICOFO_AUTH_PASSWORD={{JITSI_INTERNAL_AUTH_PASSWORD}}
|
||||||
JVB_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
|
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 (optional env fallback; prefer admin Settings > File policies)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# VIRUSTOTAL_API_KEY=
|
# 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}
|
JWT_APP_SECRET: ${JITSI_APP_SECRET:-changeme-jwt-secret}
|
||||||
JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD:-changeme}
|
JICOFO_AUTH_PASSWORD: ${JICOFO_AUTH_PASSWORD:-changeme}
|
||||||
JVB_AUTH_PASSWORD: ${JVB_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
|
TZ: Europe/Paris
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@ -19,6 +22,7 @@ services:
|
|||||||
XMPP_DOMAIN: meet.jitsi
|
XMPP_DOMAIN: meet.jitsi
|
||||||
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||||
XMPP_BOSH_URL_BASE: http://jitsi-prosody:5280
|
XMPP_BOSH_URL_BASE: http://jitsi-prosody:5280
|
||||||
|
ENABLE_TRANSCRIPTIONS: "1"
|
||||||
networks:
|
networks:
|
||||||
- ulti-net
|
- ulti-net
|
||||||
depends_on:
|
depends_on:
|
||||||
@ -37,6 +41,7 @@ services:
|
|||||||
XMPP_DOMAIN: meet.jitsi
|
XMPP_DOMAIN: meet.jitsi
|
||||||
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||||
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
||||||
|
ENABLE_TRANSCRIPTIONS: "1"
|
||||||
networks:
|
networks:
|
||||||
- ulti-net
|
- ulti-net
|
||||||
|
|
||||||
@ -48,6 +53,7 @@ services:
|
|||||||
XMPP_DOMAIN: meet.jitsi
|
XMPP_DOMAIN: meet.jitsi
|
||||||
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||||
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
XMPP_INTERNAL_MUC_DOMAIN: internal-muc.meet.jitsi
|
||||||
|
ENABLE_TRANSCRIPTIONS: "1"
|
||||||
networks:
|
networks:
|
||||||
- ulti-net
|
- ulti-net
|
||||||
depends_on:
|
depends_on:
|
||||||
@ -72,7 +78,7 @@ services:
|
|||||||
|
|
||||||
skynet:
|
skynet:
|
||||||
build:
|
build:
|
||||||
context: ./skynet
|
context: jitsi/skynet
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@ -94,15 +100,14 @@ services:
|
|||||||
XMPP_DOMAIN: meet.jitsi
|
XMPP_DOMAIN: meet.jitsi
|
||||||
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
XMPP_MUC_DOMAIN: muc.meet.jitsi
|
||||||
XMPP_INTERNAL_MUC_DOMAIN: internal-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"
|
JIGASI_ENABLE_SDES_SRTP: "0"
|
||||||
ENABLE_TRANSCRIPTIONS: "1"
|
ENABLE_TRANSCRIPTIONS: "1"
|
||||||
JIGASI_TRANSCRIBER_CUSTOM_SERVICE: org.jitsi.jigasi.transcription.WhisperTranscriptionService
|
JIGASI_TRANSCRIBER_CUSTOM_SERVICE: org.jitsi.jigasi.transcription.WhisperTranscriptionService
|
||||||
JIGASI_TRANSCRIBER_WHISPER_URL: ws://skynet:8000/streaming-whisper/ws
|
JIGASI_TRANSCRIBER_WHISPER_URL: ws://skynet:8000/streaming-whisper/ws
|
||||||
JIGASI_TRANSCRIBER_SEND_JSON: "true"
|
JIGASI_TRANSCRIBER_SEND_JSON: "true"
|
||||||
JIGASI_TRANSCRIBER_BASE_URL: http://ultid:8080/api/v1/meet/transcripts/
|
JIGASI_TRANSCRIBER_BASE_URL: http://ultid:8080/api/v1/meet/transcripts/
|
||||||
volumes:
|
|
||||||
- ./jigasi:/config:ro
|
|
||||||
networks:
|
networks:
|
||||||
- ulti-net
|
- ulti-net
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@ -62,6 +62,7 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
r.With(read).Get("/interactions", h.GetInteractionsByEmail)
|
r.With(read).Get("/interactions", h.GetInteractionsByEmail)
|
||||||
r.With(read).Get("/*", h.GetContact)
|
r.With(read).Get("/*", h.GetContact)
|
||||||
r.With(write).Post("/books/{bookID}", h.CreateContact)
|
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("/books/{bookID}/merge-duplicates", h.MergeDuplicateContacts)
|
||||||
r.With(write).Post("/improve", h.ImproveContact)
|
r.With(write).Post("/improve", h.ImproveContact)
|
||||||
r.With(write).Put("/*", h.UpdateContact)
|
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)
|
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) {
|
func (h *Handler) GetContact(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
ncUser, ok := h.nextcloudUser(w, r, claims)
|
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)
|
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) {
|
func (s *Service) GetContact(ctx context.Context, userID, contactPath string) (*nextcloud.Contact, error) {
|
||||||
return s.nc.GetContact(ctx, userID, contactPath)
|
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)
|
// VirusTotal (optional env fallback for org file_policies.virustotal_api_key)
|
||||||
VirusTotalAPIKey string
|
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
|
// Observability
|
||||||
HealthNextcloudURL string
|
HealthNextcloudURL string
|
||||||
HealthImmichURL string
|
HealthImmichURL string
|
||||||
@ -315,6 +325,14 @@ func Load() (*Config, error) {
|
|||||||
|
|
||||||
VirusTotalAPIKey: secrets.Env("VIRUSTOTAL_API_KEY"),
|
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")),
|
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")),
|
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"))),
|
HealthJitsiURL: envOrDefault("HEALTH_JITSI_URL", defaultHealthJitsiURL(envOrDefault("JITSI_PUBLIC_URL", "https://localhost/meet"))),
|
||||||
|
|||||||
@ -4,12 +4,19 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/ultisuite/ulti-backend/internal/mail/rules"
|
"github.com/ultisuite/ulti-backend/internal/mail/rules"
|
||||||
"github.com/ultisuite/ulti-backend/internal/realtime"
|
"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 {
|
type postSyncEvent struct {
|
||||||
userID string
|
userID string
|
||||||
accountID string
|
accountID string
|
||||||
@ -24,15 +31,17 @@ type syncPipeline struct {
|
|||||||
rules *rules.Engine
|
rules *rules.Engine
|
||||||
automation MailAutomation
|
automation MailAutomation
|
||||||
hub *realtime.Hub
|
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{
|
return &syncPipeline{
|
||||||
db: db,
|
db: db,
|
||||||
logger: slog.Default().With("component", "imap-pipeline"),
|
logger: slog.Default().With("component", "imap-pipeline"),
|
||||||
rules: rulesEngine,
|
rules: rulesEngine,
|
||||||
automation: automation,
|
automation: automation,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
|
push: pusher,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,10 +67,72 @@ func (p *syncPipeline) handle(ctx context.Context, ev postSyncEvent) {
|
|||||||
event := realtime.NewMailUpdatedEvent(ev.messageID, ev.accountID)
|
event := realtime.NewMailUpdatedEvent(ev.messageID, ev.accountID)
|
||||||
if ev.kind == "created" {
|
if ev.kind == "created" {
|
||||||
event = realtime.NewMailCreatedEvent(ev.messageID, ev.accountID)
|
event = realtime.NewMailCreatedEvent(ev.messageID, ev.accountID)
|
||||||
|
p.maybePush(ctx, ev)
|
||||||
}
|
}
|
||||||
p.broadcast(ev, event)
|
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) {
|
func (p *syncPipeline) broadcast(ev postSyncEvent, event realtime.Event) {
|
||||||
if p.hub == nil || ev.userID == "" {
|
if p.hub == nil || ev.userID == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
@ -40,6 +40,7 @@ type SyncDeps struct {
|
|||||||
Automation MailAutomation
|
Automation MailAutomation
|
||||||
Hub *realtime.Hub
|
Hub *realtime.Hub
|
||||||
FileScanner *filescan.Scanner
|
FileScanner *filescan.Scanner
|
||||||
|
Push Pusher
|
||||||
}
|
}
|
||||||
|
|
||||||
type SyncWorker struct {
|
type SyncWorker struct {
|
||||||
@ -64,7 +65,7 @@ func NewSyncWorker(db *pgxpool.Pool, interval time.Duration, credManager *creden
|
|||||||
storage: deps.Storage,
|
storage: deps.Storage,
|
||||||
attachBucket: deps.AttachBucket,
|
attachBucket: deps.AttachBucket,
|
||||||
scanner: deps.FileScanner,
|
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/admin"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/calendar"
|
"github.com/ultisuite/ulti-backend/internal/api/calendar"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/contacts"
|
"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/docs"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/drive"
|
"github.com/ultisuite/ulti-backend/internal/api/drive"
|
||||||
mailapi "github.com/ultisuite/ulti-backend/internal/api/mail"
|
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/observability"
|
||||||
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
|
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
|
||||||
"github.com/ultisuite/ulti-backend/internal/photos"
|
"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/realtime"
|
||||||
"github.com/ultisuite/ulti-backend/internal/search"
|
"github.com/ultisuite/ulti-backend/internal/search"
|
||||||
"github.com/ultisuite/ulti-backend/internal/securityaudit"
|
"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)
|
hub := realtime.NewHub(verifierHolder, pool)
|
||||||
healthChecker := observability.NewHealthChecker(cfg, pool, rdb)
|
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)
|
hookExec := webhooks.NewExecutor(pool)
|
||||||
rulesEngine := rules.NewEngineWithWebhooks(pool, hookExec)
|
rulesEngine := rules.NewEngineWithWebhooks(pool, hookExec)
|
||||||
autoDispatcher := automation.NewDispatcher(pool, rulesEngine, 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,
|
Automation: autoDispatcher,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
FileScanner: fileScanner,
|
FileScanner: fileScanner,
|
||||||
|
Push: pushDispatcher,
|
||||||
})
|
})
|
||||||
go syncWorker.Start(workerCtx)
|
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.Use(middleware.EnforceApiTokenPolicy())
|
||||||
|
|
||||||
r.Mount("/api/v1/users", usersapi.NewHandler(pool, cfg).Routes())
|
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 := admin.NewHandler(pool, auditLogger, cfg, ncClient)
|
||||||
adminHandler.SetHostedService(hostedSvc)
|
adminHandler.SetHostedService(hostedSvc)
|
||||||
adminHandler.SetMigrationService(migrationSvc)
|
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