Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
403 lines
14 KiB
Go
403 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
pkgvalidator "github.com/gochat/gochat/pkg/validator"
|
|
)
|
|
|
|
// PlatformAppService implements business logic for PlatformApp CRUD,
|
|
// AccessToken lifecycle, and Permissible management.
|
|
// Reference: Chatwoot app/controllers/api/v1/platform/apps_controller.rb
|
|
// + AccessTokenable concern + PlatformAppPermissible
|
|
type PlatformAppService struct {
|
|
platformAppRepo *repository.PlatformAppRepo
|
|
accessTokenRepo *repository.AccessTokenRepo
|
|
permissibleRepo *repository.PermissibleRepo
|
|
}
|
|
|
|
// NewPlatformAppService creates a new PlatformApp service.
|
|
func NewPlatformAppService(
|
|
platformAppRepo *repository.PlatformAppRepo,
|
|
accessTokenRepo *repository.AccessTokenRepo,
|
|
permissibleRepo *repository.PermissibleRepo,
|
|
) *PlatformAppService {
|
|
return &PlatformAppService{
|
|
platformAppRepo: platformAppRepo,
|
|
accessTokenRepo: accessTokenRepo,
|
|
permissibleRepo: permissibleRepo,
|
|
}
|
|
}
|
|
|
|
// --- DTOs for PlatformApp CRUD ---
|
|
|
|
// CreatePlatformAppRequest is the DTO for creating a platform app.
|
|
type CreatePlatformAppRequest struct {
|
|
Name string `json:"name" validate:"required,min=2"`
|
|
Description string `json:"description,omitempty" validate:"omitempty,max=500"`
|
|
Icon string `json:"icon,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
Config json.RawMessage `json:"config,omitempty"`
|
|
Type string `json:"type,omitempty" validate:"omitempty,oneof=api agent_bot integration"`
|
|
AccountID uint `json:"account_id,omitempty"` // 0 = platform-level (no account restriction)
|
|
}
|
|
|
|
// UpdatePlatformAppRequest is the DTO for updating a platform app.
|
|
type UpdatePlatformAppRequest struct {
|
|
Name string `json:"name,omitempty" validate:"omitempty,min=2"`
|
|
Description string `json:"description,omitempty" validate:"omitempty,max=500"`
|
|
Icon string `json:"icon,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
Config json.RawMessage `json:"config,omitempty"`
|
|
Type string `json:"type,omitempty" validate:"omitempty,oneof=api agent_bot integration"`
|
|
Status string `json:"status,omitempty" validate:"omitempty,oneof=active disabled"`
|
|
Active *bool `json:"active,omitempty"`
|
|
}
|
|
|
|
// --- PlatformApp CRUD methods ---
|
|
|
|
// Create inserts a new PlatformApp and auto-generates an AccessToken.
|
|
// Reference: Chatwoot PlatformAppsController#create — creates app + auto-creates AccessTokenable token.
|
|
// Returns the app and the plaintext token (shown only once, never stored).
|
|
func (s *PlatformAppService) Create(ctx context.Context, req CreatePlatformAppRequest) (*model.PlatformApp, string, error) {
|
|
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
// Generate plaintext token (prefix + random suffix, Chatwoot pattern)
|
|
plainToken, err := generatePlatformAccessToken()
|
|
if err != nil {
|
|
applogger.L().Errorf("Create platform app: generate token: %v", err)
|
|
return nil, "", errors.New("failed to generate access token")
|
|
}
|
|
|
|
// Default type to "api" if not specified
|
|
appType := req.Type
|
|
if appType == "" {
|
|
appType = "api"
|
|
}
|
|
|
|
// Default active to true
|
|
active := true
|
|
|
|
// AccountID — nil means platform-level (no account restriction)
|
|
var accountIDPtr *uint
|
|
if req.AccountID != 0 {
|
|
accountIDPtr = &req.AccountID
|
|
}
|
|
|
|
app := &model.PlatformApp{
|
|
Name: req.Name,
|
|
Description: req.Description,
|
|
Icon: req.Icon,
|
|
URL: req.URL,
|
|
Config: req.Config,
|
|
AccountID: accountIDPtr,
|
|
Type: appType,
|
|
Status: "active",
|
|
Active: &active,
|
|
}
|
|
|
|
if err := s.platformAppRepo.Create(ctx, app); err != nil {
|
|
applogger.L().Errorf("Create platform app: %v", err)
|
|
return nil, "", err
|
|
}
|
|
|
|
// Auto-create AccessToken for the new PlatformApp (AccessTokenable concern)
|
|
tokenHash := hashTokenSHA256(plainToken)
|
|
prefix := tokenPrefix(plainToken)
|
|
|
|
accessToken := &model.AccessToken{
|
|
OwnerType: model.AccessTokenOwnerTypePlatformApp,
|
|
OwnerID: app.ID,
|
|
Token: tokenHash,
|
|
TokenPrefix: prefix,
|
|
Name: fmt.Sprintf("PlatformApp: %s", app.Name),
|
|
}
|
|
|
|
if err := s.accessTokenRepo.Create(ctx, accessToken); err != nil {
|
|
applogger.L().Errorf("Create platform app: access token creation failed: %v", err)
|
|
// Rollback: delete the app we just created
|
|
if delErr := s.platformAppRepo.Delete(ctx, app.ID); delErr != nil {
|
|
applogger.L().Errorf("Create platform app: rollback delete failed: %v", delErr)
|
|
}
|
|
return nil, "", fmt.Errorf("failed to create access token: %w", err)
|
|
}
|
|
|
|
applogger.L().Infof("Create platform app: app %d created with access token", app.ID)
|
|
return app, plainToken, nil
|
|
}
|
|
|
|
// GetByID retrieves a single PlatformApp by ID.
|
|
func (s *PlatformAppService) GetByID(ctx context.Context, id uint) (*model.PlatformApp, error) {
|
|
app, err := s.platformAppRepo.GetByID(ctx, id)
|
|
if err != nil {
|
|
applogger.L().Errorf("Get platform app %d: %v", id, err)
|
|
return nil, err
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
// GetByIDWithRelations retrieves a PlatformApp with Permissibles and AccessToken loaded.
|
|
func (s *PlatformAppService) GetByIDWithRelations(ctx context.Context, id uint) (*model.PlatformApp, error) {
|
|
app, err := s.platformAppRepo.GetByIDWithRelations(ctx, id)
|
|
if err != nil {
|
|
applogger.L().Errorf("Get platform app %d with relations: %v", id, err)
|
|
return nil, err
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
// Update modifies an existing PlatformApp. Only non-empty fields are applied.
|
|
func (s *PlatformAppService) Update(ctx context.Context, id uint, req UpdatePlatformAppRequest) (*model.PlatformApp, error) {
|
|
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
app, err := s.platformAppRepo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Apply only non-empty fields
|
|
if req.Name != "" {
|
|
app.Name = req.Name
|
|
}
|
|
if req.Description != "" {
|
|
app.Description = req.Description
|
|
}
|
|
if req.Icon != "" {
|
|
app.Icon = req.Icon
|
|
}
|
|
if req.URL != "" {
|
|
app.URL = req.URL
|
|
}
|
|
if req.Config != nil {
|
|
app.Config = req.Config
|
|
}
|
|
if req.Type != "" {
|
|
app.Type = req.Type
|
|
}
|
|
if req.Status != "" {
|
|
app.Status = req.Status
|
|
}
|
|
if req.Active != nil {
|
|
app.Active = req.Active
|
|
}
|
|
|
|
if err := s.platformAppRepo.Update(ctx, app); err != nil {
|
|
applogger.L().Errorf("Update platform app %d: %v", id, err)
|
|
return nil, err
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
// Delete soft-deletes a PlatformApp by ID.
|
|
// Also soft-deletes associated AccessTokens and hard-deletes Permissibles.
|
|
func (s *PlatformAppService) Delete(ctx context.Context, id uint) error {
|
|
// Clean up AccessTokens (soft delete each)
|
|
tokens, err := s.accessTokenRepo.FindByOwner(ctx, model.AccessTokenOwnerTypePlatformApp, id)
|
|
if err != nil {
|
|
applogger.L().Warnf("Delete platform app %d: find access tokens: %v", id, err)
|
|
}
|
|
for _, token := range tokens {
|
|
if delErr := s.accessTokenRepo.Delete(ctx, token.ID); delErr != nil {
|
|
applogger.L().Warnf("Delete platform app %d: delete access token %d: %v", id, token.ID, delErr)
|
|
}
|
|
}
|
|
|
|
// Clean up Permissibles (hard delete — permission rules are ephemeral)
|
|
if delErr := s.permissibleRepo.DeleteByPlatformAppID(ctx, id); delErr != nil {
|
|
applogger.L().Warnf("Delete platform app %d: delete permissibles: %v", id, delErr)
|
|
}
|
|
|
|
// Soft-delete the app itself
|
|
if err := s.platformAppRepo.Delete(ctx, id); err != nil {
|
|
applogger.L().Errorf("Delete platform app %d: %v", id, err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListAll retrieves all PlatformApps with pagination (super-admin view).
|
|
func (s *PlatformAppService) ListAll(ctx context.Context, offset, limit int) ([]model.PlatformApp, int64, error) {
|
|
return s.platformAppRepo.FindAll(ctx, offset, limit)
|
|
}
|
|
|
|
// ListByAccount retrieves PlatformApps scoped to an account with pagination.
|
|
func (s *PlatformAppService) ListByAccount(ctx context.Context, accountID uint, offset, limit int) ([]model.PlatformApp, int64, error) {
|
|
return s.platformAppRepo.FindByAccountID(ctx, accountID, offset, limit)
|
|
}
|
|
|
|
// Search searches PlatformApps by name (platform-wide, super-admin).
|
|
// Uses LIKE (not ILIKE) for SQLite compatibility.
|
|
func (s *PlatformAppService) Search(ctx context.Context, nameQuery string, offset, limit int) ([]model.PlatformApp, int64, error) {
|
|
if nameQuery == "" {
|
|
return s.platformAppRepo.FindAll(ctx, offset, limit)
|
|
}
|
|
return s.platformAppRepo.SearchByName(ctx, nameQuery, offset, limit)
|
|
}
|
|
|
|
// SearchByAccount searches PlatformApps by name scoped to an account.
|
|
func (s *PlatformAppService) SearchByAccount(ctx context.Context, accountID uint, nameQuery string, offset, limit int) ([]model.PlatformApp, int64, error) {
|
|
if nameQuery == "" {
|
|
return s.platformAppRepo.FindByAccountID(ctx, accountID, offset, limit)
|
|
}
|
|
return s.platformAppRepo.SearchByNameByAccount(ctx, accountID, nameQuery, offset, limit)
|
|
}
|
|
|
|
// --- AccessToken lifecycle methods (Chatwoot AccessTokenable concern) ---
|
|
|
|
// RegenerateAccessToken generates a new AccessToken for an existing PlatformApp,
|
|
// soft-deleting the old one. This is the key rotation/refresh operation.
|
|
// Reference: Chatwoot PlatformAppsController#regenerate_api_key
|
|
// Returns the plaintext token (shown only once, never stored).
|
|
func (s *PlatformAppService) RegenerateAccessToken(ctx context.Context, id uint) (string, error) {
|
|
app, err := s.platformAppRepo.GetByID(ctx, id)
|
|
if err != nil {
|
|
applogger.L().Errorf("RegenerateAccessToken: get platform app %d: %v", id, err)
|
|
return "", err
|
|
}
|
|
|
|
// Generate new plaintext token
|
|
plainToken, err := generatePlatformAccessToken()
|
|
if err != nil {
|
|
applogger.L().Errorf("RegenerateAccessToken: generate token for app %d: %v", id, err)
|
|
return "", err
|
|
}
|
|
|
|
// Soft-delete existing tokens for this app
|
|
tokens, err := s.accessTokenRepo.FindByOwner(ctx, model.AccessTokenOwnerTypePlatformApp, app.ID)
|
|
if err != nil {
|
|
applogger.L().Warnf("RegenerateAccessToken: find old tokens: %v", err)
|
|
}
|
|
for _, token := range tokens {
|
|
if delErr := s.accessTokenRepo.Delete(ctx, token.ID); delErr != nil {
|
|
applogger.L().Warnf("RegenerateAccessToken: delete old token %d: %v", token.ID, delErr)
|
|
}
|
|
}
|
|
|
|
// Create new AccessToken
|
|
tokenHash := hashTokenSHA256(plainToken)
|
|
prefix := tokenPrefix(plainToken)
|
|
|
|
accessToken := &model.AccessToken{
|
|
OwnerType: model.AccessTokenOwnerTypePlatformApp,
|
|
OwnerID: app.ID,
|
|
Token: tokenHash,
|
|
TokenPrefix: prefix,
|
|
Name: fmt.Sprintf("PlatformApp: %s (regenerated)", app.Name),
|
|
}
|
|
|
|
if err := s.accessTokenRepo.Create(ctx, accessToken); err != nil {
|
|
applogger.L().Errorf("RegenerateAccessToken: create new token for app %d: %v", id, err)
|
|
return "", fmt.Errorf("failed to create new access token: %w", err)
|
|
}
|
|
|
|
applogger.L().Infof("RegenerateAccessToken: platform app %d token regenerated", id)
|
|
return plainToken, nil
|
|
}
|
|
|
|
// ListAccessTokens retrieves all AccessTokens for a PlatformApp.
|
|
func (s *PlatformAppService) ListAccessTokens(ctx context.Context, platformAppID uint) ([]model.AccessToken, error) {
|
|
return s.accessTokenRepo.FindByOwner(ctx, model.AccessTokenOwnerTypePlatformApp, platformAppID)
|
|
}
|
|
|
|
// --- Permissible management methods (Chatwoot PlatformAppPermissible) ---
|
|
|
|
// AddPermissible grants a PlatformApp access to a resource (Account, User, or AgentBot).
|
|
// Reference: Chatwoot PlatformAppsController#add_permissible
|
|
func (s *PlatformAppService) AddPermissible(ctx context.Context, platformAppID uint, permissibleType string, permissibleID uint) (*model.Permissible, error) {
|
|
// Validate permissibleType
|
|
if !isValidPermissibleType(permissibleType) {
|
|
return nil, fmt.Errorf("invalid permissible_type: %s (must be Account, User, or AgentBot)", permissibleType)
|
|
}
|
|
|
|
// Verify PlatformApp exists
|
|
app, err := s.platformAppRepo.GetByID(ctx, platformAppID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("platform app %d not found: %w", platformAppID, err)
|
|
}
|
|
|
|
// Check for duplicate permission
|
|
existing, err := s.permissibleRepo.FindByPlatformAppAndResource(ctx, app.ID, permissibleType, permissibleID)
|
|
if err == nil && existing != nil {
|
|
return nil, fmt.Errorf("permission already exists for platform_app %d on %s %d", app.ID, permissibleType, permissibleID)
|
|
}
|
|
|
|
permissible := &model.Permissible{
|
|
PlatformAppID: app.ID,
|
|
PermissibleType: permissibleType,
|
|
PermissibleID: permissibleID,
|
|
}
|
|
|
|
if err := s.permissibleRepo.Create(ctx, permissible); err != nil {
|
|
applogger.L().Errorf("AddPermissible: create failed: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
applogger.L().Infof("AddPermissible: platform app %d granted access to %s %d", app.ID, permissibleType, permissibleID)
|
|
return permissible, nil
|
|
}
|
|
|
|
// RemovePermissible revokes a PlatformApp's access to a resource.
|
|
// Reference: Chatwoot PlatformAppsController#remove_permissible
|
|
func (s *PlatformAppService) RemovePermissible(ctx context.Context, platformAppID uint, permissibleType string, permissibleID uint) error {
|
|
if !isValidPermissibleType(permissibleType) {
|
|
return fmt.Errorf("invalid permissible_type: %s (must be Account, User, or AgentBot)", permissibleType)
|
|
}
|
|
|
|
if err := s.permissibleRepo.DeleteByPlatformAppAndResource(ctx, platformAppID, permissibleType, permissibleID); err != nil {
|
|
applogger.L().Errorf("RemovePermissible: delete failed: %v", err)
|
|
return err
|
|
}
|
|
|
|
applogger.L().Infof("RemovePermissible: platform app %d revoked access to %s %d", platformAppID, permissibleType, permissibleID)
|
|
return nil
|
|
}
|
|
|
|
// ListPermissibles retrieves all Permissibles for a PlatformApp.
|
|
func (s *PlatformAppService) ListPermissibles(ctx context.Context, platformAppID uint) ([]model.Permissible, error) {
|
|
return s.permissibleRepo.FindByPlatformAppID(ctx, platformAppID)
|
|
}
|
|
|
|
// --- Helper functions ---
|
|
|
|
func isValidPermissibleType(t string) bool {
|
|
return t == model.PermissibleTypeAccount ||
|
|
t == model.PermissibleTypeUser ||
|
|
t == model.PermissibleTypeAgentBot
|
|
}
|
|
|
|
// hashTokenSHA256 hashes a plaintext token using SHA-256 for secure storage.
|
|
func hashTokenSHA256(plain string) string {
|
|
h := sha256.Sum256([]byte(plain))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
// tokenPrefix extracts the first 8 characters of a plaintext token for DB lookup.
|
|
func tokenPrefix(plain string) string {
|
|
if len(plain) >= 8 {
|
|
return plain[:8]
|
|
}
|
|
return plain
|
|
}
|
|
|
|
// generatePlatformAccessToken creates a random platform access token.
|
|
// Format: "gochat_pat_" + 32 random bytes as hex string (64 hex chars).
|
|
func generatePlatformAccessToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("crypto/rand failed: %w", err)
|
|
}
|
|
return "gochat_pat_" + hex.EncodeToString(b), nil
|
|
} |