Files
wehub-resource-sync 6d978fe483
Build / Frontend Build (push) Has been cancelled
Build / Backend Build (push) Has been cancelled
Build / Docker Build (push) Has been cancelled
Build / Kubernetes Deploy Smoke Test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:24 +08:00

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[:])
}