287 lines
8.9 KiB
Go
287 lines
8.9 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"clawreef/internal/models"
|
|
"clawreef/internal/repository"
|
|
)
|
|
|
|
const (
|
|
ExternalAccessModeShareLink = "share_link"
|
|
ExternalAccessModePassword = "password"
|
|
|
|
ExternalAccessExpirationPreset = "preset"
|
|
ExternalAccessExpirationCustom = "custom"
|
|
ExternalAccessExpirationPermanent = "permanent"
|
|
|
|
ExternalAccessPreset1Hour = "1h"
|
|
ExternalAccessPreset24Hours = "24h"
|
|
ExternalAccessPreset7Days = "7d"
|
|
ExternalAccessPreset30Days = "30d"
|
|
)
|
|
|
|
type ExternalAccessExpirationRequest struct {
|
|
Mode string `json:"expires_mode,omitempty"`
|
|
Preset string `json:"expires_preset,omitempty"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
type EnableShareLinkResult struct {
|
|
Access *models.InstanceExternalAccess `json:"access"`
|
|
ShareURL string `json:"share_url,omitempty"`
|
|
}
|
|
|
|
type PasswordExternalAccessResult struct {
|
|
Access *models.InstanceExternalAccess `json:"access"`
|
|
Password string `json:"password"`
|
|
ShareURL string `json:"share_url,omitempty"`
|
|
}
|
|
|
|
type InstanceExternalAccessService interface {
|
|
Get(ctx context.Context, instanceID int) (*models.InstanceExternalAccess, error)
|
|
EnableShareLink(ctx context.Context, instanceID, createdBy int, expiration ExternalAccessExpirationRequest) (*EnableShareLinkResult, error)
|
|
CreatePassword(ctx context.Context, instanceID, createdBy int, expiration ExternalAccessExpirationRequest) (*PasswordExternalAccessResult, error)
|
|
Disable(ctx context.Context, instanceID int) error
|
|
ResolveShortLink(ctx context.Context, code string) (*models.InstanceExternalAccess, error)
|
|
ValidateShortLink(ctx context.Context, code, password string) (*models.InstanceExternalAccess, error)
|
|
}
|
|
|
|
type instanceExternalAccessService struct {
|
|
repo repository.InstanceExternalAccessRepository
|
|
}
|
|
|
|
func NewInstanceExternalAccessService(repo repository.InstanceExternalAccessRepository) InstanceExternalAccessService {
|
|
return &instanceExternalAccessService{repo: repo}
|
|
}
|
|
|
|
func (s *instanceExternalAccessService) Get(ctx context.Context, instanceID int) (*models.InstanceExternalAccess, error) {
|
|
if s == nil || s.repo == nil {
|
|
return nil, fmt.Errorf("instance external access repository is not configured")
|
|
}
|
|
return s.repo.GetByInstanceID(ctx, instanceID)
|
|
}
|
|
|
|
func (s *instanceExternalAccessService) EnableShareLink(ctx context.Context, instanceID, createdBy int, expiration ExternalAccessExpirationRequest) (*EnableShareLinkResult, error) {
|
|
if s == nil || s.repo == nil {
|
|
return nil, fmt.Errorf("instance external access repository is not configured")
|
|
}
|
|
code, codeHash, err := generateShortCode()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
expiresAt, err := resolveExternalAccessExpiresAt(expiration)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
access := &models.InstanceExternalAccess{
|
|
InstanceID: instanceID,
|
|
Enabled: true,
|
|
AuthMode: ExternalAccessModeShareLink,
|
|
ShortCodeHash: &codeHash,
|
|
PublicSlug: &code,
|
|
PublicTokenHash: nil,
|
|
ExpiresAt: expiresAt,
|
|
CreatedBy: &createdBy,
|
|
PasswordHash: nil,
|
|
PasswordValue: nil,
|
|
PasswordHint: nil,
|
|
LastUsedAt: nil,
|
|
}
|
|
if err := s.repo.Upsert(ctx, access); err != nil {
|
|
return nil, err
|
|
}
|
|
saved, err := s.repo.GetByInstanceID(ctx, instanceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &EnableShareLinkResult{
|
|
Access: saved,
|
|
ShareURL: shortExternalAccessURL(code),
|
|
}, nil
|
|
}
|
|
|
|
func (s *instanceExternalAccessService) CreatePassword(ctx context.Context, instanceID, createdBy int, expiration ExternalAccessExpirationRequest) (*PasswordExternalAccessResult, error) {
|
|
if s == nil || s.repo == nil {
|
|
return nil, fmt.Errorf("instance external access repository is not configured")
|
|
}
|
|
code, codeHash, err := generateShortCode()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
password, err := randomToken("pwd", 16)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
expiresAt, err := resolveExternalAccessExpiresAt(expiration)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
passwordHash := hashExternalSecret(password)
|
|
hint := password
|
|
if len(hint) > 12 {
|
|
hint = hint[:12]
|
|
}
|
|
access := &models.InstanceExternalAccess{
|
|
InstanceID: instanceID,
|
|
Enabled: true,
|
|
AuthMode: ExternalAccessModePassword,
|
|
PublicSlug: &code,
|
|
ShortCodeHash: &codeHash,
|
|
PasswordHash: &passwordHash,
|
|
PasswordValue: &password,
|
|
PasswordHint: &hint,
|
|
ExpiresAt: expiresAt,
|
|
CreatedBy: &createdBy,
|
|
PublicTokenHash: nil,
|
|
LastUsedAt: nil,
|
|
}
|
|
if err := s.repo.Upsert(ctx, access); err != nil {
|
|
return nil, err
|
|
}
|
|
saved, err := s.repo.GetByInstanceID(ctx, instanceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &PasswordExternalAccessResult{Access: saved, Password: password, ShareURL: shortExternalAccessURL(code)}, nil
|
|
}
|
|
|
|
func (s *instanceExternalAccessService) Disable(ctx context.Context, instanceID int) error {
|
|
if s == nil || s.repo == nil {
|
|
return fmt.Errorf("instance external access repository is not configured")
|
|
}
|
|
return s.repo.Disable(ctx, instanceID)
|
|
}
|
|
|
|
func (s *instanceExternalAccessService) ResolveShortLink(ctx context.Context, code string) (*models.InstanceExternalAccess, error) {
|
|
if s == nil || s.repo == nil {
|
|
return nil, fmt.Errorf("instance external access repository is not configured")
|
|
}
|
|
code = strings.TrimSpace(code)
|
|
if code == "" {
|
|
return nil, fmt.Errorf("short link code is required")
|
|
}
|
|
access, err := s.repo.GetByShortCodeHash(ctx, hashExternalSecret(code))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if access == nil || !access.Enabled {
|
|
return nil, fmt.Errorf("external access is not enabled")
|
|
}
|
|
if access.ExpiresAt != nil && time.Now().UTC().After(*access.ExpiresAt) {
|
|
return nil, fmt.Errorf("external access has expired")
|
|
}
|
|
return access, nil
|
|
}
|
|
|
|
func (s *instanceExternalAccessService) ValidateShortLink(ctx context.Context, code, password string) (*models.InstanceExternalAccess, error) {
|
|
access, err := s.ResolveShortLink(ctx, code)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch access.AuthMode {
|
|
case ExternalAccessModeShareLink:
|
|
// The short code itself is the share-link capability. A matching hash is enough.
|
|
case ExternalAccessModePassword:
|
|
if access.PasswordHash == nil || *access.PasswordHash != hashExternalSecret(strings.TrimSpace(password)) {
|
|
return nil, fmt.Errorf("invalid external access password")
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported external access mode")
|
|
}
|
|
if err := s.repo.MarkUsed(ctx, access.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
return access, nil
|
|
}
|
|
|
|
func resolveExternalAccessExpiresAt(expiration ExternalAccessExpirationRequest) (*time.Time, error) {
|
|
mode := strings.TrimSpace(expiration.Mode)
|
|
if mode == "" {
|
|
mode = ExternalAccessExpirationPreset
|
|
}
|
|
switch mode {
|
|
case ExternalAccessExpirationPermanent:
|
|
return nil, nil
|
|
case ExternalAccessExpirationCustom:
|
|
if expiration.ExpiresAt == nil {
|
|
return nil, fmt.Errorf("custom external access expiration requires expires_at")
|
|
}
|
|
value := expiration.ExpiresAt.UTC()
|
|
return &value, nil
|
|
case ExternalAccessExpirationPreset:
|
|
preset := strings.TrimSpace(expiration.Preset)
|
|
if preset == "" {
|
|
preset = ExternalAccessPreset24Hours
|
|
}
|
|
duration, err := externalAccessPresetDuration(preset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
value := time.Now().UTC().Add(duration)
|
|
return &value, nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported external access expiration mode")
|
|
}
|
|
}
|
|
|
|
func externalAccessPresetDuration(preset string) (time.Duration, error) {
|
|
switch strings.TrimSpace(preset) {
|
|
case ExternalAccessPreset1Hour:
|
|
return time.Hour, nil
|
|
case ExternalAccessPreset24Hours:
|
|
return 24 * time.Hour, nil
|
|
case ExternalAccessPreset7Days:
|
|
return 7 * 24 * time.Hour, nil
|
|
case ExternalAccessPreset30Days:
|
|
return 30 * 24 * time.Hour, nil
|
|
default:
|
|
return 0, fmt.Errorf("unsupported external access expiration preset")
|
|
}
|
|
}
|
|
|
|
func generateShortCode() (code, codeHash string, err error) {
|
|
code, err = randomToken("sl", 12)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return code, hashExternalSecret(code), nil
|
|
}
|
|
|
|
func shortExternalAccessURL(code string) string {
|
|
return fmt.Sprintf("/s/%s/", strings.Trim(strings.TrimSpace(code), "/"))
|
|
}
|
|
|
|
func ExternalAccessShareURL(access *models.InstanceExternalAccess) string {
|
|
if access == nil || !access.Enabled || access.PublicSlug == nil {
|
|
return ""
|
|
}
|
|
return shortExternalAccessURL(*access.PublicSlug)
|
|
}
|
|
|
|
func ExternalAccessPassword(access *models.InstanceExternalAccess) string {
|
|
if access == nil || !access.Enabled || access.AuthMode != ExternalAccessModePassword || access.PasswordValue == nil {
|
|
return ""
|
|
}
|
|
return *access.PasswordValue
|
|
}
|
|
|
|
func randomToken(prefix string, bytesLen int) (string, error) {
|
|
buf := make([]byte, bytesLen)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", fmt.Errorf("failed to generate token: %w", err)
|
|
}
|
|
return prefix + "_" + hex.EncodeToString(buf), nil
|
|
}
|
|
|
|
func hashExternalSecret(value string) string {
|
|
sum := sha256.Sum256([]byte(strings.TrimSpace(value)))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|