359 lines
12 KiB
Go
359 lines
12 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
goredis "github.com/redis/go-redis/v9"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
|
)
|
|
|
|
const DesktopClientTokenTTL = 30 * 24 * time.Hour
|
|
|
|
type DesktopClientService struct {
|
|
repo repository.DesktopClientRepository
|
|
redis *goredis.Client
|
|
taskSvc *DesktopTaskService
|
|
}
|
|
|
|
func NewDesktopClientService(repo repository.DesktopClientRepository, redis *goredis.Client) *DesktopClientService {
|
|
return &DesktopClientService{repo: repo, redis: redis}
|
|
}
|
|
|
|
func (s *DesktopClientService) WithTaskService(taskSvc *DesktopTaskService) *DesktopClientService {
|
|
s.taskSvc = taskSvc
|
|
return s
|
|
}
|
|
|
|
type RegisterDesktopClientRequest struct {
|
|
ClientID string `json:"client_id" binding:"required"`
|
|
DeviceName string `json:"device_name" binding:"required"`
|
|
OS string `json:"os" binding:"required"`
|
|
CPUArch string `json:"cpu_arch"`
|
|
ClientVersion string `json:"client_version" binding:"required"`
|
|
Channel string `json:"channel"`
|
|
}
|
|
|
|
type HeartbeatDesktopClientRequest struct {
|
|
DeviceName *string `json:"device_name"`
|
|
OS *string `json:"os"`
|
|
CPUArch *string `json:"cpu_arch"`
|
|
ClientVersion *string `json:"client_version"`
|
|
Channel *string `json:"channel"`
|
|
AccountIDs []string `json:"account_ids"`
|
|
Startup bool `json:"startup"`
|
|
}
|
|
|
|
type DesktopClientView struct {
|
|
ID string `json:"id"`
|
|
TenantID int64 `json:"tenant_id"`
|
|
WorkspaceID int64 `json:"workspace_id"`
|
|
UserID int64 `json:"user_id"`
|
|
DeviceName *string `json:"device_name"`
|
|
OS *string `json:"os"`
|
|
CPUArch *string `json:"cpu_arch"`
|
|
ClientVersion *string `json:"client_version"`
|
|
Channel *string `json:"channel"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastSeenAt *time.Time `json:"last_seen_at"`
|
|
LastRotatedAt *time.Time `json:"last_rotated_at"`
|
|
RevokedAt *time.Time `json:"revoked_at"`
|
|
}
|
|
|
|
type RegisterDesktopClientResponse struct {
|
|
ClientID string `json:"client_id"`
|
|
ClientToken string `json:"client_token"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
Client DesktopClientView `json:"client"`
|
|
}
|
|
|
|
type RotateDesktopClientResponse struct {
|
|
ClientToken string `json:"client_token"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
Client DesktopClientView `json:"client"`
|
|
}
|
|
|
|
type HeartbeatDesktopClientResponse struct {
|
|
Client DesktopClientView `json:"client"`
|
|
ServerTime time.Time `json:"server_time"`
|
|
OnlineClientCount int `json:"online_client_count"`
|
|
CurrentUserOnlineClientCount int `json:"current_user_online_client_count"`
|
|
}
|
|
|
|
type OfflineDesktopClientResponse struct {
|
|
ServerTime time.Time `json:"server_time"`
|
|
}
|
|
|
|
func HashDesktopClientToken(raw string) []byte {
|
|
sum := sha256.Sum256([]byte(raw))
|
|
return sum[:]
|
|
}
|
|
|
|
func (s *DesktopClientService) Register(ctx context.Context, actor auth.Actor, req RegisterDesktopClientRequest) (*RegisterDesktopClientResponse, error) {
|
|
if actor.TenantID == 0 || actor.PrimaryWorkspaceID == 0 || actor.UserID == 0 {
|
|
return nil, response.ErrUnauthorized(40107, "desktop_register_requires_actor", "desktop client registration requires authenticated workspace context")
|
|
}
|
|
|
|
rawToken, tokenHash, err := newDesktopClientToken()
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50071, "desktop_client_token_failed", "failed to generate desktop client token")
|
|
}
|
|
|
|
clientID, err := parseRegisterClientID(req.ClientID)
|
|
if err != nil {
|
|
return nil, response.ErrBadRequest(40033, "invalid_desktop_client_id", "desktop client_id must be a non-empty uuid generated by the desktop client")
|
|
}
|
|
|
|
client, err := s.repo.Register(ctx, repository.RegisterDesktopClientParams{
|
|
ID: clientID,
|
|
TenantID: actor.TenantID,
|
|
WorkspaceID: actor.PrimaryWorkspaceID,
|
|
UserID: actor.UserID,
|
|
TokenHash: tokenHash,
|
|
DeviceName: requiredStringPtr(req.DeviceName),
|
|
OS: requiredStringPtr(req.OS),
|
|
CPUArch: trimmedStringPtr(req.CPUArch),
|
|
ClientVersion: requiredStringPtr(req.ClientVersion),
|
|
Channel: trimmedStringPtr(req.Channel),
|
|
})
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50072, "desktop_client_register_failed", "failed to persist desktop client")
|
|
}
|
|
|
|
markDesktopClientPresentForUser(ctx, s.redis, client.ID, client.TenantID, client.WorkspaceID, client.UserID)
|
|
|
|
return &RegisterDesktopClientResponse{
|
|
ClientID: client.ID.String(),
|
|
ClientToken: rawToken,
|
|
ExpiresAt: time.Now().Add(DesktopClientTokenTTL).Unix(),
|
|
Client: buildDesktopClientView(client),
|
|
}, nil
|
|
}
|
|
|
|
func (s *DesktopClientService) Rotate(ctx context.Context, client *repository.DesktopClient) (*RotateDesktopClientResponse, error) {
|
|
if client == nil {
|
|
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
|
}
|
|
|
|
rawToken, tokenHash, err := newDesktopClientToken()
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50073, "desktop_client_rotate_token_failed", "failed to generate next desktop client token")
|
|
}
|
|
|
|
updated, err := s.repo.RotateToken(ctx, repository.RotateDesktopClientTokenParams{
|
|
ID: client.ID,
|
|
WorkspaceID: client.WorkspaceID,
|
|
TokenHash: tokenHash,
|
|
})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, response.ErrUnauthorized(40109, "desktop_client_revoked", "desktop client is revoked")
|
|
}
|
|
return nil, response.ErrInternal(50074, "desktop_client_rotate_failed", "failed to rotate desktop client token")
|
|
}
|
|
|
|
markDesktopClientPresentForUser(ctx, s.redis, updated.ID, updated.TenantID, updated.WorkspaceID, updated.UserID)
|
|
|
|
return &RotateDesktopClientResponse{
|
|
ClientToken: rawToken,
|
|
ExpiresAt: time.Now().Add(DesktopClientTokenTTL).Unix(),
|
|
Client: buildDesktopClientView(updated),
|
|
}, nil
|
|
}
|
|
|
|
func (s *DesktopClientService) Heartbeat(ctx context.Context, client *repository.DesktopClient, req HeartbeatDesktopClientRequest) (*HeartbeatDesktopClientResponse, error) {
|
|
if client == nil {
|
|
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
|
}
|
|
|
|
updated, err := s.repo.Heartbeat(ctx, repository.HeartbeatDesktopClientParams{
|
|
ID: client.ID,
|
|
WorkspaceID: client.WorkspaceID,
|
|
DeviceName: req.DeviceName,
|
|
OS: req.OS,
|
|
CPUArch: req.CPUArch,
|
|
ClientVersion: req.ClientVersion,
|
|
Channel: req.Channel,
|
|
})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, response.ErrUnauthorized(40109, "desktop_client_revoked", "desktop client is revoked")
|
|
}
|
|
return nil, response.ErrInternal(50075, "desktop_client_heartbeat_failed", "failed to persist desktop client heartbeat")
|
|
}
|
|
|
|
markDesktopClientPresentForUser(ctx, s.redis, updated.ID, updated.TenantID, updated.WorkspaceID, updated.UserID)
|
|
markDesktopAccountsPresent(ctx, s.redis, updated.ID, parseDesktopAccountIDs(req.AccountIDs))
|
|
onlineClientCount, err := s.currentUserOnlineClientCount(ctx, updated)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if req.Startup && s.taskSvc != nil {
|
|
if err := s.taskSvc.recoverClientTasksOnStartup(ctx, updated); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return &HeartbeatDesktopClientResponse{
|
|
Client: buildDesktopClientView(updated),
|
|
ServerTime: time.Now().UTC(),
|
|
OnlineClientCount: onlineClientCount,
|
|
CurrentUserOnlineClientCount: onlineClientCount,
|
|
}, nil
|
|
}
|
|
|
|
func (s *DesktopClientService) Revoke(ctx context.Context, client *repository.DesktopClient) (*DesktopClientView, error) {
|
|
if client == nil {
|
|
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
|
}
|
|
|
|
updated, err := s.repo.Revoke(ctx, client.ID, client.WorkspaceID)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, response.ErrUnauthorized(40109, "desktop_client_revoked", "desktop client is revoked")
|
|
}
|
|
return nil, response.ErrInternal(50076, "desktop_client_revoke_failed", "failed to revoke desktop client")
|
|
}
|
|
|
|
clearDesktopClientPresenceStateForUser(ctx, s.redis, updated.ID, updated.TenantID, updated.WorkspaceID, updated.UserID)
|
|
if s.taskSvc != nil {
|
|
if err := s.taskSvc.recoverClientTasksOnDisconnect(ctx, updated); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
view := buildDesktopClientView(updated)
|
|
return &view, nil
|
|
}
|
|
|
|
func (s *DesktopClientService) Offline(ctx context.Context, client *repository.DesktopClient) (*OfflineDesktopClientResponse, error) {
|
|
if client == nil {
|
|
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
|
}
|
|
|
|
clearDesktopClientPresenceStateForUser(ctx, s.redis, client.ID, client.TenantID, client.WorkspaceID, client.UserID)
|
|
if s.taskSvc != nil {
|
|
if err := s.taskSvc.recoverClientTasksOnDisconnect(ctx, client); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return &OfflineDesktopClientResponse{
|
|
ServerTime: time.Now().UTC(),
|
|
}, nil
|
|
}
|
|
|
|
func (s *DesktopClientService) currentUserOnlineClientCount(ctx context.Context, client *repository.DesktopClient) (int, error) {
|
|
if count, ok := loadDesktopUserOnlineClientCount(ctx, s.redis, client.TenantID, client.WorkspaceID, client.UserID, time.Now().UTC()); ok {
|
|
return count, nil
|
|
}
|
|
|
|
clients, err := s.repo.ListByUser(ctx, client.TenantID, client.WorkspaceID, client.UserID)
|
|
if err != nil {
|
|
return 0, response.ErrInternal(50077, "desktop_client_online_count_failed", "failed to inspect online desktop clients")
|
|
}
|
|
|
|
return countOnlineDesktopClients(ctx, s.redis, clients, time.Now().UTC()), nil
|
|
}
|
|
|
|
func countOnlineDesktopClients(ctx context.Context, redis *goredis.Client, clients []repository.DesktopClient, now time.Time) int {
|
|
if len(clients) == 0 {
|
|
return 0
|
|
}
|
|
|
|
clientIDs := make([]uuid.UUID, 0, len(clients))
|
|
for _, client := range clients {
|
|
if client.ID == uuid.Nil || client.RevokedAt != nil {
|
|
continue
|
|
}
|
|
clientIDs = append(clientIDs, client.ID)
|
|
}
|
|
presence := loadDesktopClientPresence(ctx, redis, clientIDs)
|
|
|
|
onlineCount := 0
|
|
for _, client := range clients {
|
|
if client.ID == uuid.Nil || client.RevokedAt != nil {
|
|
continue
|
|
}
|
|
if resolveDesktopClientOnline(&client, presence[client.ID], presence != nil, now) {
|
|
onlineCount++
|
|
}
|
|
}
|
|
return onlineCount
|
|
}
|
|
|
|
func newDesktopClientToken() (string, []byte, error) {
|
|
raw := make([]byte, 32)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return "", nil, fmt.Errorf("generate desktop client token: %w", err)
|
|
}
|
|
token := hex.EncodeToString(raw)
|
|
return token, HashDesktopClientToken(token), nil
|
|
}
|
|
|
|
func buildDesktopClientView(client *repository.DesktopClient) DesktopClientView {
|
|
return DesktopClientView{
|
|
ID: client.ID.String(),
|
|
TenantID: client.TenantID,
|
|
WorkspaceID: client.WorkspaceID,
|
|
UserID: client.UserID,
|
|
DeviceName: client.DeviceName,
|
|
OS: client.OS,
|
|
CPUArch: client.CPUArch,
|
|
ClientVersion: client.ClientVersion,
|
|
Channel: client.Channel,
|
|
CreatedAt: client.CreatedAt,
|
|
LastSeenAt: client.LastSeenAt,
|
|
LastRotatedAt: client.LastRotatedAt,
|
|
RevokedAt: client.RevokedAt,
|
|
}
|
|
}
|
|
|
|
func requiredStringPtr(value string) *string {
|
|
trimmed := value
|
|
return &trimmed
|
|
}
|
|
|
|
func trimmedStringPtr(value string) *string {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
trimmed := value
|
|
return &trimmed
|
|
}
|
|
|
|
func parseDesktopAccountIDs(values []string) []uuid.UUID {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
|
|
accountIDs := make([]uuid.UUID, 0, len(values))
|
|
for _, value := range values {
|
|
parsed, err := uuid.Parse(strings.TrimSpace(value))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
accountIDs = append(accountIDs, parsed)
|
|
}
|
|
return accountIDs
|
|
}
|
|
|
|
func parseRegisterClientID(value string) (uuid.UUID, error) {
|
|
parsed, err := uuid.Parse(strings.TrimSpace(value))
|
|
if err != nil || parsed == uuid.Nil {
|
|
return uuid.Nil, fmt.Errorf("invalid desktop client id")
|
|
}
|
|
return parsed, nil
|
|
}
|