321 lines
11 KiB
Go
321 lines
11 KiB
Go
package sub2api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
managedSubscriptionBalance = 10.0
|
|
managedSubscriptionValidityDays = 30
|
|
)
|
|
|
|
type adminUserRecord struct {
|
|
ID int64 `json:"id"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
type adminAPIKeyRecord struct {
|
|
ID int64 `json:"id"`
|
|
Key string `json:"key"`
|
|
Name string `json:"name"`
|
|
Group *struct {
|
|
ID int64 `json:"id"`
|
|
} `json:"group,omitempty"`
|
|
GroupID *int64 `json:"group_id,omitempty"`
|
|
}
|
|
|
|
type authTokenPair struct {
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
|
|
func (c *Client) EnsureSubscriptionAccess(ctx context.Context, req EnsureSubscriptionAccessRequest) (SubscriptionAccessRef, error) {
|
|
if c == nil {
|
|
return SubscriptionAccessRef{}, fmt.Errorf("client is required")
|
|
}
|
|
selector := strings.TrimSpace(req.UserSelector)
|
|
groupID := strings.TrimSpace(req.GroupID)
|
|
if selector == "" {
|
|
return SubscriptionAccessRef{}, fmt.Errorf("user selector is required")
|
|
}
|
|
if groupID == "" {
|
|
return SubscriptionAccessRef{}, fmt.Errorf("group id is required")
|
|
}
|
|
groupInt, err := strconv.ParseInt(groupID, 10, 64)
|
|
if err != nil {
|
|
return SubscriptionAccessRef{}, fmt.Errorf("parse group id %q: %w", groupID, err)
|
|
}
|
|
|
|
identity := buildManagedSubscriptionIdentity(selector, groupID)
|
|
user, err := c.findManagedSubscriptionUser(ctx, identity.Email)
|
|
if err != nil {
|
|
return SubscriptionAccessRef{}, err
|
|
}
|
|
if user == nil {
|
|
user, err = c.createManagedSubscriptionUser(ctx, identity, groupInt)
|
|
if err != nil {
|
|
return SubscriptionAccessRef{}, err
|
|
}
|
|
}
|
|
if err := c.updateManagedSubscriptionUser(ctx, user.ID, groupInt); err != nil {
|
|
return SubscriptionAccessRef{}, err
|
|
}
|
|
if err := c.setManagedSubscriptionBalance(ctx, user.ID); err != nil {
|
|
return SubscriptionAccessRef{}, err
|
|
}
|
|
if err := c.ensureManagedSubscriptionAssignment(ctx, user.ID, groupID); err != nil {
|
|
return SubscriptionAccessRef{}, err
|
|
}
|
|
|
|
userClient, err := c.loginAsManagedSubscriptionUser(ctx, identity.Email, identity.Password)
|
|
if err != nil {
|
|
return SubscriptionAccessRef{}, err
|
|
}
|
|
keyRecord, err := c.ensureManagedSubscriptionAPIKey(ctx, userClient, user.ID, identity)
|
|
if err != nil {
|
|
return SubscriptionAccessRef{}, err
|
|
}
|
|
if err := c.bindManagedSubscriptionAPIKey(ctx, keyRecord.ID, groupInt); err != nil {
|
|
return SubscriptionAccessRef{}, err
|
|
}
|
|
return SubscriptionAccessRef{UserID: strconv.FormatInt(user.ID, 10), APIKey: identity.CustomKey}, nil
|
|
}
|
|
|
|
type managedSubscriptionIdentity struct {
|
|
Email string
|
|
Username string
|
|
Password string
|
|
CustomKey string
|
|
KeyName string
|
|
}
|
|
|
|
func buildManagedSubscriptionIdentity(selector, groupID string) managedSubscriptionIdentity {
|
|
normalizedSelector := strings.TrimSpace(selector)
|
|
seedMaterial := strings.ToLower(normalizedSelector) + "|" + strings.TrimSpace(groupID)
|
|
sum := sha256.Sum256([]byte(seedMaterial))
|
|
hash := hex.EncodeToString(sum[:])
|
|
prefix := sanitizeManagedSubscriptionPrefix(normalizedSelector)
|
|
if prefix == "" {
|
|
prefix = "relay-sub"
|
|
}
|
|
prefix = truncateManagedSubscriptionToken(prefix, 24)
|
|
shortHash := hash[:16]
|
|
keyHash := hash[:32]
|
|
username := truncateManagedSubscriptionToken(prefix+"-"+shortHash[:8], 32)
|
|
return managedSubscriptionIdentity{
|
|
Email: fmt.Sprintf("%s-%s@sub2api.local", prefix, shortHash),
|
|
Username: username,
|
|
Password: "RelayPwd!" + hash[:12],
|
|
CustomKey: "sk-relay-" + keyHash,
|
|
KeyName: truncateManagedSubscriptionToken(username+"-key", 48),
|
|
}
|
|
}
|
|
|
|
func sanitizeManagedSubscriptionPrefix(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
var b strings.Builder
|
|
lastDash := false
|
|
for _, r := range value {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
|
b.WriteRune(r)
|
|
lastDash = false
|
|
case !lastDash:
|
|
b.WriteByte('-')
|
|
lastDash = true
|
|
}
|
|
}
|
|
return strings.Trim(b.String(), "-")
|
|
}
|
|
|
|
func truncateManagedSubscriptionToken(value string, max int) string {
|
|
if len(value) <= max {
|
|
return value
|
|
}
|
|
return strings.Trim(value[:max], "-")
|
|
}
|
|
|
|
func (c *Client) findManagedSubscriptionUser(ctx context.Context, email string) (*adminUserRecord, error) {
|
|
statusCode, _, body, err := c.perform(ctx, http.MethodGet, "/api/v1/admin/users?search="+url.QueryEscape(email)+"&page=1&page_size=20&sort_by=created_at&sort_order=desc", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list admin users: %w", err)
|
|
}
|
|
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
|
|
return nil, newHTTPError(http.MethodGet, "/api/v1/admin/users", statusCode, body)
|
|
}
|
|
var envelope struct {
|
|
Data struct {
|
|
Items []adminUserRecord `json:"items"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
|
return nil, fmt.Errorf("decode admin users response: %w", err)
|
|
}
|
|
for _, item := range envelope.Data.Items {
|
|
if strings.EqualFold(strings.TrimSpace(item.Email), email) {
|
|
user := item
|
|
return &user, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (c *Client) createManagedSubscriptionUser(ctx context.Context, identity managedSubscriptionIdentity, groupID int64) (*adminUserRecord, error) {
|
|
payload := map[string]any{
|
|
"email": identity.Email,
|
|
"password": identity.Password,
|
|
"username": identity.Username,
|
|
"notes": "managed by sub2api-cn-relay-manager",
|
|
"balance": managedSubscriptionBalance,
|
|
"concurrency": 5,
|
|
"allowed_groups": []int64{groupID},
|
|
}
|
|
statusCode, _, body, err := c.perform(ctx, http.MethodPost, "/api/v1/admin/users", payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create admin user: %w", err)
|
|
}
|
|
if statusCode == http.StatusConflict {
|
|
return c.findManagedSubscriptionUser(ctx, identity.Email)
|
|
}
|
|
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
|
|
return nil, newHTTPError(http.MethodPost, "/api/v1/admin/users", statusCode, body)
|
|
}
|
|
var user adminUserRecord
|
|
if err := decodeEnvelopeObject(body, &user); err != nil {
|
|
return nil, fmt.Errorf("decode created admin user: %w", err)
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
func (c *Client) updateManagedSubscriptionUser(ctx context.Context, userID, groupID int64) error {
|
|
payload := map[string]any{"allowed_groups": []int64{groupID}}
|
|
statusCode, _, body, err := c.perform(ctx, http.MethodPut, fmt.Sprintf("/api/v1/admin/users/%d", userID), payload)
|
|
if err != nil {
|
|
return fmt.Errorf("update admin user groups: %w", err)
|
|
}
|
|
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
|
|
return newHTTPError(http.MethodPut, fmt.Sprintf("/api/v1/admin/users/%d", userID), statusCode, body)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) setManagedSubscriptionBalance(ctx context.Context, userID int64) error {
|
|
payload := map[string]any{"balance": managedSubscriptionBalance, "operation": "set", "notes": "managed by sub2api-cn-relay-manager"}
|
|
statusCode, _, body, err := c.perform(ctx, http.MethodPost, fmt.Sprintf("/api/v1/admin/users/%d/balance", userID), payload)
|
|
if err != nil {
|
|
return fmt.Errorf("set admin user balance: %w", err)
|
|
}
|
|
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
|
|
return newHTTPError(http.MethodPost, fmt.Sprintf("/api/v1/admin/users/%d/balance", userID), statusCode, body)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) ensureManagedSubscriptionAssignment(ctx context.Context, userID int64, groupID string) error {
|
|
_, err := c.AssignSubscription(ctx, AssignSubscriptionRequest{
|
|
UserID: strconv.FormatInt(userID, 10),
|
|
GroupID: groupID,
|
|
DurationDays: managedSubscriptionValidityDays,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("assign managed subscription: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) loginAsManagedSubscriptionUser(ctx context.Context, email, password string) (*Client, error) {
|
|
anon := c.cloneWithAuth("", "")
|
|
payload := map[string]any{"email": email, "password": password, "turnstile_token": ""}
|
|
statusCode, _, body, err := anon.perform(ctx, http.MethodPost, "/api/v1/auth/login", payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("login managed subscription user: %w", err)
|
|
}
|
|
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
|
|
return nil, newHTTPError(http.MethodPost, "/api/v1/auth/login", statusCode, body)
|
|
}
|
|
var tokenPair authTokenPair
|
|
if err := decodeEnvelopeObject(body, &tokenPair); err != nil {
|
|
return nil, fmt.Errorf("decode managed user login response: %w", err)
|
|
}
|
|
if strings.TrimSpace(tokenPair.AccessToken) == "" {
|
|
return nil, fmt.Errorf("managed user login returned empty access token")
|
|
}
|
|
return c.cloneWithAuth("", tokenPair.AccessToken), nil
|
|
}
|
|
|
|
func (c *Client) ensureManagedSubscriptionAPIKey(ctx context.Context, userClient *Client, userID int64, identity managedSubscriptionIdentity) (*adminAPIKeyRecord, error) {
|
|
payload := map[string]any{
|
|
"name": identity.KeyName,
|
|
"custom_key": identity.CustomKey,
|
|
}
|
|
statusCode, _, body, err := userClient.perform(ctx, http.MethodPost, "/api/v1/keys", payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create managed api key: %w", err)
|
|
}
|
|
if statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices {
|
|
var key adminAPIKeyRecord
|
|
if err := decodeEnvelopeObject(body, &key); err != nil {
|
|
return nil, fmt.Errorf("decode created api key: %w", err)
|
|
}
|
|
return &key, nil
|
|
}
|
|
if statusCode != http.StatusConflict && statusCode != http.StatusBadRequest {
|
|
return nil, newHTTPError(http.MethodPost, "/api/v1/keys", statusCode, body)
|
|
}
|
|
return c.findManagedSubscriptionAPIKey(ctx, userID, identity)
|
|
}
|
|
|
|
func (c *Client) findManagedSubscriptionAPIKey(ctx context.Context, userID int64, identity managedSubscriptionIdentity) (*adminAPIKeyRecord, error) {
|
|
statusCode, _, body, err := c.perform(ctx, http.MethodGet, fmt.Sprintf("/api/v1/admin/users/%d/api-keys?page=1&page_size=100&sort_by=created_at&sort_order=desc", userID), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list managed api keys: %w", err)
|
|
}
|
|
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
|
|
return nil, newHTTPError(http.MethodGet, fmt.Sprintf("/api/v1/admin/users/%d/api-keys", userID), statusCode, body)
|
|
}
|
|
var envelope struct {
|
|
Data struct {
|
|
Items []adminAPIKeyRecord `json:"items"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
|
return nil, fmt.Errorf("decode admin api keys response: %w", err)
|
|
}
|
|
for _, item := range envelope.Data.Items {
|
|
if strings.TrimSpace(item.Key) == identity.CustomKey || strings.TrimSpace(item.Name) == identity.KeyName {
|
|
key := item
|
|
return &key, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("managed api key %q not found for user %d", identity.KeyName, userID)
|
|
}
|
|
|
|
func (c *Client) bindManagedSubscriptionAPIKey(ctx context.Context, keyID, groupID int64) error {
|
|
payload := map[string]any{"group_id": groupID}
|
|
statusCode, _, body, err := c.perform(ctx, http.MethodPut, fmt.Sprintf("/api/v1/admin/api-keys/%d", keyID), payload)
|
|
if err != nil {
|
|
return fmt.Errorf("bind managed api key group: %w", err)
|
|
}
|
|
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
|
|
return newHTTPError(http.MethodPut, fmt.Sprintf("/api/v1/admin/api-keys/%d", keyID), statusCode, body)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) cloneWithAuth(apiKey, bearerToken string) *Client {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
clone := *c
|
|
clone.apiKey = strings.TrimSpace(apiKey)
|
|
clone.bearerToken = strings.TrimSpace(bearerToken)
|
|
return &clone
|
|
}
|