Rebrand CommGate to gsmnode and adopt new design system
Rename the whole project from CommGate to gsmnode and re-skin every surface onto the new signal-green + ink design system (Space Grotesk / IBM Plex Sans / JetBrains Mono, flat surfaces, two-arrow routing mark, lowercase gsm+node wordmark). - Web App + API panel: rewrite token layer, gn-* utilities, data-gsm-theme, new logo/favicon assets; both builds verified. - Phone App (Flutter): green theme, new mark/wordmark widgets, adaptive launcher icons; rename Android applicationId/package to app.gsmnode.phone; bump AGP to 8.6.0 and google_fonts to 8.1.0; debug APK build verified. - API Server (Go): panel routes, User-Agent, health service id, branding. - Home Assistant Plugin: rename integration domain sms_gateway to gsmnode (folder, DOMAIN, services, entity ids, classes); py_compile verified. - Update all READMEs; add .gitignore (excludes Design/, build artifacts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"smsgateway/apiserver/internal/auth"
|
||||
"smsgateway/apiserver/internal/pb"
|
||||
)
|
||||
|
||||
type deviceDTO struct {
|
||||
ID string `json:"id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
AppVersion string `json:"app_version"`
|
||||
Status string `json:"status"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
// onlineWindow is how recently a device must have pinged to count as online.
|
||||
// The phone pings every ~60s, so this tolerates a couple of missed pings.
|
||||
const onlineWindow = 3 * time.Minute
|
||||
|
||||
func recordToDevice(rec pb.Record) deviceDTO {
|
||||
lastSeen := asString(rec["last_seen_at"])
|
||||
status := "offline"
|
||||
if deviceOnline(lastSeen) {
|
||||
status = "online"
|
||||
}
|
||||
return deviceDTO{
|
||||
ID: asString(rec["id"]),
|
||||
DeviceID: asString(rec["device_id"]),
|
||||
Name: asString(rec["name"]),
|
||||
Platform: asString(rec["platform"]),
|
||||
AppVersion: asString(rec["app_version"]),
|
||||
Status: status,
|
||||
LastSeenAt: lastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
// deviceOnline reports whether last_seen_at is within the online window. It
|
||||
// accepts the PocketBase datetime format as well as RFC3339.
|
||||
func deviceOnline(lastSeenAt string) bool {
|
||||
if lastSeenAt == "" {
|
||||
return false
|
||||
}
|
||||
for _, layout := range []string{
|
||||
"2006-01-02 15:04:05.999Z07:00",
|
||||
"2006-01-02 15:04:05.999Z",
|
||||
time.RFC3339,
|
||||
} {
|
||||
if t, err := time.Parse(layout, lastSeenAt); err == nil {
|
||||
return time.Since(t) < onlineWindow
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleListDevices returns the authenticated user's devices.
|
||||
func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
||||
uid, _ := userFromCtx(r.Context())
|
||||
res, err := s.pb.List(r.Context(), colDevices, pb.ListOptions{
|
||||
Filter: "owner = " + pbQuote(uid),
|
||||
Sort: "-created",
|
||||
PerPage: 200,
|
||||
})
|
||||
if err != nil {
|
||||
writeUpstreamError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]deviceDTO, 0, len(res.Items))
|
||||
for _, rec := range res.Items {
|
||||
out = append(out, recordToDevice(rec))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": out, "total": res.TotalItems})
|
||||
}
|
||||
|
||||
// handleDeleteDevice removes a device owned by the user.
|
||||
func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) {
|
||||
uid, _ := userFromCtx(r.Context())
|
||||
id := r.PathValue("id")
|
||||
|
||||
rec, err := s.pb.GetOne(r.Context(), colDevices, id)
|
||||
if err != nil {
|
||||
writeUpstreamError(w, err)
|
||||
return
|
||||
}
|
||||
if asString(rec["owner"]) != uid {
|
||||
writeError(w, http.StatusForbidden, "not your device")
|
||||
return
|
||||
}
|
||||
if err := s.pb.Delete(r.Context(), colDevices, id); err != nil {
|
||||
writeUpstreamError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type registerDeviceRequest struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
AppVersion string `json:"app_version"`
|
||||
PushToken string `json:"push_token"`
|
||||
}
|
||||
|
||||
type registerDeviceResponse struct {
|
||||
deviceDTO
|
||||
AuthToken string `json:"auth_token"`
|
||||
}
|
||||
|
||||
// handleRegisterDevice registers (or re-registers) a mobile device for the
|
||||
// authenticated user and returns an opaque device token. Re-registering with
|
||||
// the same device_id rotates the token and updates metadata.
|
||||
func (s *Server) handleRegisterDevice(w http.ResponseWriter, r *http.Request) {
|
||||
uid, _ := userFromCtx(r.Context())
|
||||
|
||||
var req registerDeviceRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
req.DeviceID = strings.TrimSpace(req.DeviceID)
|
||||
if req.DeviceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "device_id is required")
|
||||
return
|
||||
}
|
||||
if req.Platform == "" {
|
||||
req.Platform = "android"
|
||||
}
|
||||
|
||||
token, err := auth.NewDeviceToken()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not generate token")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format("2006-01-02 15:04:05.000Z")
|
||||
fields := pb.Record{
|
||||
"device_id": req.DeviceID,
|
||||
"name": req.Name,
|
||||
"platform": req.Platform,
|
||||
"app_version": req.AppVersion,
|
||||
"push_token": req.PushToken,
|
||||
"status": "online",
|
||||
"last_seen_at": now,
|
||||
"auth_token": token,
|
||||
"owner": uid,
|
||||
}
|
||||
|
||||
// Upsert by (owner, device_id).
|
||||
existing, err := s.pb.FindFirst(r.Context(), colDevices,
|
||||
"owner = "+pbQuote(uid)+" && device_id = "+pbQuote(req.DeviceID), "")
|
||||
if err != nil {
|
||||
writeUpstreamError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var rec pb.Record
|
||||
if existing != nil {
|
||||
rec, err = s.pb.Update(r.Context(), colDevices, asString(existing["id"]), fields)
|
||||
} else {
|
||||
rec, err = s.pb.Create(r.Context(), colDevices, fields)
|
||||
}
|
||||
if err != nil {
|
||||
writeUpstreamError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, registerDeviceResponse{
|
||||
deviceDTO: recordToDevice(rec),
|
||||
AuthToken: token,
|
||||
})
|
||||
}
|
||||
|
||||
// handlePing updates a device heartbeat (last_seen_at + online status).
|
||||
func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) {
|
||||
device := deviceFromCtx(r.Context())
|
||||
now := time.Now().UTC().Format("2006-01-02 15:04:05.000Z")
|
||||
_, err := s.pb.Update(r.Context(), colDevices, asString(device["id"]), pb.Record{
|
||||
"status": "online",
|
||||
"last_seen_at": now,
|
||||
})
|
||||
if err != nil {
|
||||
writeUpstreamError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||
}
|
||||
Reference in New Issue
Block a user