| package proxy |
|
|
| import ( |
| "bytes" |
| "encoding/json" |
| "fmt" |
| "io" |
| "log" |
| "net/http" |
| "os" |
| "path/filepath" |
| "strings" |
| "time" |
|
|
| "notion-manager/internal/securefile" |
| ) |
|
|
| |
| |
| type AccountDTO struct { |
| AccountID string `json:"account_id"` |
| UserEmail string `json:"user_email"` |
| UserName string `json:"user_name"` |
| SpaceName string `json:"space_name"` |
| SpaceIDShort string `json:"space_id_short"` |
| PlanType string `json:"plan_type"` |
| QuotaInfo *QuotaInfo `json:"quota_info,omitempty"` |
| IsExhausted bool `json:"is_exhausted"` |
| IsPermanent bool `json:"is_permanently_exhausted"` |
| SpaceCount int `json:"space_count"` |
| } |
|
|
| |
| func NewAccountDTO(acc *Account) AccountDTO { |
| acc.EnsureAccountID() |
| quota := acc.quotaSnapshot() |
| return AccountDTO{ |
| AccountID: acc.AccountID, |
| UserEmail: acc.UserEmail, |
| UserName: acc.UserName, |
| SpaceName: acc.SpaceName, |
| SpaceIDShort: acc.ShortSpaceID(), |
| PlanType: acc.PlanType, |
| QuotaInfo: quota.Info, |
| IsExhausted: quota.ExhaustedAt != nil, |
| IsPermanent: quota.PermanentlyExhausted, |
| SpaceCount: acc.SpaceCount, |
| } |
| } |
|
|
| |
| |
| func DiscoverAccountFromToken(tokenV2 string) (*Account, error) { |
| return DiscoverAccountFromTokenWithOptions(tokenV2, AccountDiscoveryOptions{}) |
| } |
|
|
| |
| |
| type AccountDiscoveryOptions struct { |
| ActiveUserID string |
| SpaceID string |
| ExpectedEmail string |
| } |
|
|
| var ( |
| discoveryHTTPClient = getChromeHTTPClient |
| discoveryFetchModels = FetchModels |
| discoveryCheckQuota = CheckQuota |
| ) |
|
|
| |
| |
| func DiscoverAccountFromTokenWithOptions(tokenV2 string, options AccountDiscoveryOptions) (*Account, error) { |
| client := discoveryHTTPClient(AppConfig.APITimeoutDuration()) |
|
|
| |
| req, err := http.NewRequest("POST", NotionAPIBase+"/loadUserContent", bytes.NewReader([]byte("{}"))) |
| if err != nil { |
| return nil, fmt.Errorf("create loadUserContent request: %w", err) |
| } |
| req.Header.Set("Content-Type", "application/json") |
| req.Header.Set("Accept", "application/json") |
| req.Header.Set("User-Agent", AppConfig.Browser.UserAgent) |
| activeUserID := strings.TrimSpace(options.ActiveUserID) |
| cookieHeader := "token_v2=" + tokenV2 |
| if activeUserID != "" { |
| cookieHeader = accountCookieHeader(&Account{TokenV2: tokenV2, UserID: activeUserID}) |
| req.Header.Set("x-notion-active-user-header", activeUserID) |
| } |
| req.Header.Set("Cookie", cookieHeader) |
|
|
| resp, err := client.Do(req) |
| if err != nil { |
| return nil, fmt.Errorf("loadUserContent request failed: %w", err) |
| } |
| defer resp.Body.Close() |
| body, _ := io.ReadAll(resp.Body) |
| if resp.StatusCode != 200 { |
| return nil, fmt.Errorf("loadUserContent API error %d: %s", resp.StatusCode, truncate(string(body), 300)) |
| } |
|
|
| |
| var userData struct { |
| RecordMap struct { |
| NotionUser map[string]json.RawMessage `json:"notion_user"` |
| UserRoot map[string]json.RawMessage `json:"user_root"` |
| Space map[string]json.RawMessage `json:"space"` |
| UserSetting map[string]json.RawMessage `json:"user_settings"` |
| } `json:"recordMap"` |
| } |
| if err := json.Unmarshal(body, &userData); err != nil { |
| return nil, fmt.Errorf("parse loadUserContent: %w", err) |
| } |
|
|
| type spaceViewPointer struct { |
| SpaceID string `json:"spaceId"` |
| ID string `json:"id"` |
| } |
| spaceViewPointersForUser := func(userID string) []spaceViewPointer { |
| var pointers []spaceViewPointer |
| raw, ok := userData.RecordMap.UserRoot[userID] |
| if !ok { |
| return pointers |
| } |
| var ur struct { |
| Value struct { |
| Value *struct { |
| SpaceViewPointers []spaceViewPointer `json:"space_view_pointers"` |
| } `json:"value"` |
| SpaceViewPointers []spaceViewPointer `json:"space_view_pointers"` |
| } `json:"value"` |
| } |
| if err := json.Unmarshal(raw, &ur); err != nil { |
| return pointers |
| } |
| if ur.Value.Value != nil { |
| return ur.Value.Value.SpaceViewPointers |
| } |
| return ur.Value.SpaceViewPointers |
| } |
|
|
| |
| expectedEmail := strings.TrimSpace(options.ExpectedEmail) |
| requestedSpaceID := strings.TrimSpace(options.SpaceID) |
| var userID, userName, userEmail string |
| for id, raw := range userData.RecordMap.NotionUser { |
| if activeUserID != "" && id != activeUserID { |
| continue |
| } |
| var u struct { |
| Value struct { |
| Value *struct { |
| Name string `json:"name"` |
| Email string `json:"email"` |
| } `json:"value"` |
| Name string `json:"name"` |
| Email string `json:"email"` |
| } `json:"value"` |
| } |
| if err := json.Unmarshal(raw, &u); err != nil { |
| continue |
| } |
| candidateName := u.Value.Name |
| candidateEmail := u.Value.Email |
| if u.Value.Value != nil { |
| candidateName = u.Value.Value.Name |
| candidateEmail = u.Value.Value.Email |
| } |
| if expectedEmail != "" && !strings.EqualFold(candidateEmail, expectedEmail) { |
| continue |
| } |
| if requestedSpaceID != "" { |
| matchedSpace := false |
| for _, ptr := range spaceViewPointersForUser(id) { |
| if ptr.SpaceID == requestedSpaceID { |
| matchedSpace = true |
| break |
| } |
| } |
| if !matchedSpace { |
| continue |
| } |
| } |
| userID = id |
| userName = candidateName |
| userEmail = candidateEmail |
| break |
| } |
| if userID == "" { |
| if activeUserID != "" || expectedEmail != "" || requestedSpaceID != "" { |
| return nil, fmt.Errorf("no user matched the configured Notion account selectors") |
| } |
| return nil, fmt.Errorf("no user found in loadUserContent response") |
| } |
|
|
| |
| spaceViewPointers := spaceViewPointersForUser(userID) |
|
|
| |
| type spaceInfo struct { |
| ID string |
| Name string |
| PlanType string |
| SpaceViewID string |
| AIEnabled bool |
| } |
| var bestSpace *spaceInfo |
| for _, ptr := range spaceViewPointers { |
| if requestedSpaceID != "" && ptr.SpaceID != requestedSpaceID { |
| continue |
| } |
| raw, ok := userData.RecordMap.Space[ptr.SpaceID] |
| if !ok { |
| continue |
| } |
| var s struct { |
| Value struct { |
| Value *struct { |
| ID string `json:"id"` |
| Name string `json:"name"` |
| PlanType string `json:"plan_type"` |
| Settings struct { |
| EnableAIFeature *bool `json:"enable_ai_feature"` |
| DisableAIFeature *bool `json:"disable_ai_feature"` |
| } `json:"settings"` |
| } `json:"value"` |
| ID string `json:"id"` |
| Name string `json:"name"` |
| PlanType string `json:"plan_type"` |
| Settings struct { |
| EnableAIFeature *bool `json:"enable_ai_feature"` |
| DisableAIFeature *bool `json:"disable_ai_feature"` |
| } `json:"settings"` |
| } `json:"value"` |
| } |
| if err := json.Unmarshal(raw, &s); err != nil { |
| continue |
| } |
| var si spaceInfo |
| si.SpaceViewID = ptr.ID |
| if s.Value.Value != nil { |
| si.ID = s.Value.Value.ID |
| si.Name = s.Value.Value.Name |
| si.PlanType = s.Value.Value.PlanType |
| aiOff := s.Value.Value.Settings.DisableAIFeature != nil && *s.Value.Value.Settings.DisableAIFeature |
| si.AIEnabled = !aiOff |
| } else { |
| si.ID = s.Value.ID |
| si.Name = s.Value.Name |
| si.PlanType = s.Value.PlanType |
| aiOff := s.Value.Settings.DisableAIFeature != nil && *s.Value.Settings.DisableAIFeature |
| si.AIEnabled = !aiOff |
| } |
| if si.ID == "" { |
| si.ID = ptr.SpaceID |
| } |
| if requestedSpaceID != "" { |
| if si.ID == requestedSpaceID { |
| bestSpace = &si |
| } |
| break |
| } |
| if bestSpace == nil || (si.AIEnabled && si.PlanType != "free") { |
| bestSpace = &si |
| } |
| } |
| if bestSpace == nil { |
| if requestedSpaceID != "" { |
| return nil, fmt.Errorf("requested NOTION_SPACE_ID was not found for the active account") |
| } |
| return nil, fmt.Errorf("no workspace found for this account") |
| } |
|
|
| |
| timezone := "UTC" |
| if raw, ok := userData.RecordMap.UserSetting[userID]; ok { |
| var us struct { |
| Value struct { |
| Value *struct { |
| Settings struct { |
| TimeZone string `json:"time_zone"` |
| } `json:"settings"` |
| } `json:"value"` |
| Settings struct { |
| TimeZone string `json:"time_zone"` |
| } `json:"settings"` |
| } `json:"value"` |
| } |
| if err := json.Unmarshal(raw, &us); err == nil { |
| if us.Value.Value != nil && us.Value.Value.Settings.TimeZone != "" { |
| timezone = us.Value.Value.Settings.TimeZone |
| } else if us.Value.Settings.TimeZone != "" { |
| timezone = us.Value.Settings.TimeZone |
| } |
| } |
| } |
|
|
| browserID := generateUUIDv4() |
| deviceID := generateUUIDv4() |
|
|
| acc := &Account{ |
| TokenV2: tokenV2, |
| UserID: userID, |
| UserName: userName, |
| UserEmail: userEmail, |
| SpaceID: bestSpace.ID, |
| SpaceName: bestSpace.Name, |
| SpaceViewID: bestSpace.SpaceViewID, |
| PlanType: bestSpace.PlanType, |
| Timezone: timezone, |
| ClientVersion: DefaultClientVersion, |
| BrowserID: browserID, |
| DeviceID: deviceID, |
| } |
|
|
| acc.EnsureAccountID() |
|
|
| |
| models, err := discoveryFetchModels(acc) |
| if err != nil { |
| log.Printf("[add-account] model fetch failed (non-fatal): %v", err) |
| } else { |
| acc.setModels(models) |
| } |
|
|
| |
| quota, err := discoveryCheckQuota(acc) |
| if err != nil { |
| log.Printf("[add-account] quota check failed (non-fatal): %v", err) |
| } else { |
| now := time.Now() |
| acc.setQuotaInfo(quota, &now) |
| } |
|
|
| return acc, nil |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| func findFileByAccountIDOnDisk(dir, accountID string) (string, error) { |
| entries, err := readPrivateAccountsDir(dir) |
| if err != nil { |
| return "", err |
| } |
| for _, entry := range entries { |
| if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { |
| continue |
| } |
| path := filepath.Join(dir, entry.Name()) |
| data, err := readPrivateAccountFile(path) |
| if err != nil { |
| return "", err |
| } |
| var m map[string]interface{} |
| if err := json.Unmarshal(data, &m); err != nil { |
| continue |
| } |
| aid, _ := m["account_id"].(string) |
| if aid == "" { |
| uid, _ := m["user_id"].(string) |
| sid, _ := m["space_id"].(string) |
| if uid != "" && sid != "" { |
| aid = ComputeAccountID(uid, sid) |
| } |
| } |
| if aid == accountID { |
| return path, nil |
| } |
| } |
| return "", nil |
| } |
|
|
| func SaveAccountToFile(acc *Account, dir string) (string, error) { |
| acc.EnsureAccountID() |
| if err := ensurePrivateAccountsDir(dir); err != nil { |
| return "", fmt.Errorf("create accounts dir: %w", err) |
| } |
|
|
| name := acc.UserEmail |
| if name == "" { |
| name = acc.UserName |
| } |
| if name == "" { |
| name = "unknown" |
| } |
| |
| name = strings.Map(func(r rune) rune { |
| if r == '@' || r == '.' || r == '-' || r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { |
| return r |
| } |
| return '_' |
| }, name) |
|
|
| newFilename := acc.AccountID + "__" + name + ".json" |
|
|
| |
| |
| |
| filename := newFilename |
| existingPath, err := findFileByAccountIDOnDisk(dir, acc.AccountID) |
| if err != nil { |
| return "", fmt.Errorf("find existing account file: %w", err) |
| } |
| if existingPath != "" { |
| filename = filepath.Base(existingPath) |
| } |
| path := filepath.Join(dir, filename) |
|
|
| |
| acc.EnsureAccountID() |
| data := map[string]interface{}{ |
| "account_id": acc.AccountID, |
| "token_v2": acc.TokenV2, |
| "user_id": acc.UserID, |
| "user_name": acc.UserName, |
| "user_email": acc.UserEmail, |
| "space_id": acc.SpaceID, |
| "space_name": acc.SpaceName, |
| "space_view_id": acc.SpaceViewID, |
| "plan_type": acc.PlanType, |
| "timezone": acc.Timezone, |
| "client_version": acc.ClientVersion, |
| "browser_id": acc.BrowserID, |
| "device_id": acc.DeviceID, |
| } |
| modelSnapshot := acc.modelsSnapshot() |
| quota := acc.quotaSnapshot() |
| if len(modelSnapshot) > 0 { |
| var models []map[string]string |
| for _, m := range modelSnapshot { |
| models = append(models, map[string]string{"id": m.ID, "name": m.Name}) |
| } |
| data["available_models"] = models |
| } |
| if quota.Info != nil { |
| data["quota_info"] = map[string]interface{}{ |
| "is_eligible": quota.Info.IsEligible, |
| "space_usage": quota.Info.SpaceUsage, |
| "space_limit": quota.Info.SpaceLimit, |
| "user_usage": quota.Info.UserUsage, |
| "user_limit": quota.Info.UserLimit, |
| "last_usage_at": quota.Info.LastUsageAtMs, |
| "research_mode_usage": quota.Info.ResearchModeUsage, |
| "has_premium": quota.Info.HasPremium, |
| "premium_balance": quota.Info.PremiumBalance, |
| "premium_usage": quota.Info.PremiumUsage, |
| "premium_limit": quota.Info.PremiumLimit, |
| "total_credit_balance": quota.Info.TotalCreditBalance, |
| "credits_in_overage": quota.Info.CreditsInOverage, |
| "monthly_allocated_usage": quota.Info.MonthlyAllocatedUsage, |
| "monthly_allocated_limit": quota.Info.MonthlyAllocatedLimit, |
| "monthly_committed_usage": quota.Info.MonthlyCommittedUsage, |
| "monthly_committed_limit": quota.Info.MonthlyCommittedLimit, |
| "yearly_elastic_usage": quota.Info.YearlyElasticUsage, |
| "yearly_elastic_limit": quota.Info.YearlyElasticLimit, |
| "v2_space_usage": quota.Info.V2SpaceUsage, |
| "v2_space_limit": quota.Info.V2SpaceLimit, |
| "v2_user_usage": quota.Info.V2UserUsage, |
| "v2_user_limit": quota.Info.V2UserLimit, |
| "v2_last_usage_at": quota.Info.V2LastUsageAtMs, |
| } |
| } |
| if quota.CheckedAt != nil { |
| data["quota_checked_at"] = quota.CheckedAt.Format(time.RFC3339) |
| } |
| data["extracted_at"] = time.Now().Format(time.RFC3339) |
|
|
| out, err := json.MarshalIndent(data, "", " ") |
| if err != nil { |
| return "", fmt.Errorf("marshal account JSON: %w", err) |
| } |
|
|
| |
| if err := ensurePrivateAccountsDir(dir); err != nil { |
| return "", fmt.Errorf("create accounts dir: %w", err) |
| } |
|
|
| if err := securefile.WriteFile(path, append(out, '\n')); err != nil { |
| return "", fmt.Errorf("write account file: %w", err) |
| } |
|
|
| return filename, nil |
| } |
|
|
| |
| func (p *AccountPool) AddAccount(acc *Account) { |
| p.mu.Lock() |
| |
| acc.EnsureAccountID() |
| for i, existing := range p.accounts { |
| existing.EnsureAccountID() |
| if existing.AccountID != "" && existing.AccountID == acc.AccountID { |
| |
| p.accounts[i] = acc |
| p.mu.Unlock() |
| log.Printf("[account] replaced: %s (%s) aid=%s", acc.UserName, acc.UserEmail, acc.ShortSpaceID()) |
| if quota := acc.quotaInfoSnapshot(); quota != nil { |
| logQuotaObservation(acc, nil, quota) |
| } |
| return |
| } |
| } |
| p.accounts = append(p.accounts, acc) |
| p.mu.Unlock() |
| log.Printf("[account] added: %s (%s) [%s]", acc.UserName, acc.UserEmail, acc.PlanType) |
| if quota := acc.quotaInfoSnapshot(); quota != nil { |
| logQuotaObservation(acc, nil, quota) |
| } |
| } |
|
|
| |
| |
| func DeleteAccountFile(accountID, dir string) error { |
| entries, err := readPrivateAccountsDir(dir) |
| if err != nil { |
| return err |
| } |
| for _, entry := range entries { |
| if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { |
| continue |
| } |
| path := filepath.Join(dir, entry.Name()) |
| data, err := readPrivateAccountFile(path) |
| if err != nil { |
| return err |
| } |
| var existing map[string]interface{} |
| if err := json.Unmarshal(data, &existing); err != nil { |
| continue |
| } |
| exAID, _ := existing["account_id"].(string) |
| if exAID == "" { |
| |
| exUID, _ := existing["user_id"].(string) |
| exSID, _ := existing["space_id"].(string) |
| if exUID != "" && exSID != "" { |
| exAID = ComputeAccountID(exUID, exSID) |
| } |
| } |
| if exAID == accountID { |
| if err := os.Remove(path); err != nil { |
| return fmt.Errorf("delete file %s: %w", entry.Name(), err) |
| } |
| log.Printf("[account] deleted file: %s", entry.Name()) |
| return nil |
| } |
| } |
| return fmt.Errorf("account file not found for account_id %s", accountID) |
| } |
|
|
| |
| |
| func DeleteAccountFileByEmail(email, dir string) error { |
| entries, err := readPrivateAccountsDir(dir) |
| if err != nil { |
| return err |
| } |
| var matches []string |
| for _, entry := range entries { |
| if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { |
| continue |
| } |
| path := filepath.Join(dir, entry.Name()) |
| data, err := readPrivateAccountFile(path) |
| if err != nil { |
| return err |
| } |
| var existing map[string]interface{} |
| if err := json.Unmarshal(data, &existing); err != nil { |
| continue |
| } |
| if e, _ := existing["user_email"].(string); strings.EqualFold(e, email) { |
| matches = append(matches, path) |
| } |
| } |
| switch len(matches) { |
| case 0: |
| return fmt.Errorf("account file not found for %s", email) |
| case 1: |
| if err := os.Remove(matches[0]); err != nil { |
| return fmt.Errorf("delete file: %w", err) |
| } |
| log.Printf("[account] deleted file: %s", filepath.Base(matches[0])) |
| return nil |
| default: |
| return &AmbiguousEmailError{Email: email, Count: len(matches)} |
| } |
| } |
|
|
| |
| |
| func HandleAddAccount(pool *AccountPool, accountsDir string, auth *DashboardAuth) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/json") |
|
|
| if r.Method != "POST" { |
| http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) |
| return |
| } |
|
|
| |
| if auth.HasAdminPassword() && !auth.ValidateSession(r) { |
| http.Error(w, `{"error":"unauthorized, dashboard login required"}`, http.StatusUnauthorized) |
| return |
| } |
|
|
| var body struct { |
| TokenV2 string `json:"token_v2"` |
| NotionUserID string `json:"notion_user_id"` |
| ExpectedEmail string `json:"expected_email"` |
| } |
| if err := json.NewDecoder(r.Body).Decode(&body); err != nil { |
| http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) |
| return |
| } |
| tokenV2 := strings.TrimSpace(body.TokenV2) |
| if tokenV2 == "" { |
| http.Error(w, `{"error":"token_v2 is required"}`, http.StatusBadRequest) |
| return |
| } |
|
|
| log.Printf("[add-account] discovering account from token_v2 (%d chars)...", len(tokenV2)) |
|
|
| |
| acc, err := DiscoverAccountFromTokenWithOptions(tokenV2, AccountDiscoveryOptions{ |
| ActiveUserID: strings.TrimSpace(body.NotionUserID), |
| ExpectedEmail: strings.TrimSpace(body.ExpectedEmail), |
| }) |
| if err != nil { |
| log.Printf("[add-account] discovery failed: %v", err) |
| w.WriteHeader(http.StatusBadRequest) |
| json.NewEncoder(w).Encode(map[string]string{ |
| "error": fmt.Sprintf("Failed to discover account: %v", err), |
| }) |
| return |
| } |
|
|
| |
| filename, err := SaveAccountToFile(acc, accountsDir) |
| if err != nil { |
| log.Printf("[add-account] save failed: %v", err) |
| w.WriteHeader(http.StatusInternalServerError) |
| json.NewEncoder(w).Encode(map[string]string{ |
| "error": fmt.Sprintf("Failed to save account: %v", err), |
| }) |
| return |
| } |
|
|
| |
| pool.AddAccount(acc) |
|
|
| log.Printf("[add-account] success: %s (%s) → %s", acc.UserName, acc.UserEmail, filename) |
|
|
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "status": "ok", |
| "filename": filename, |
| "account": map[string]string{ |
| "name": acc.UserName, |
| "email": acc.UserEmail, |
| "space": acc.SpaceName, |
| "plan_type": acc.PlanType, |
| }, |
| }) |
| } |
| } |
|
|
| |
| func HandleDeleteAccount(pool *AccountPool, accountsDir string, auth *DashboardAuth) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/json") |
|
|
| if r.Method != "POST" { |
| http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) |
| return |
| } |
|
|
| |
| if auth.HasAdminPassword() && !auth.ValidateSession(r) { |
| http.Error(w, `{"error":"unauthorized, dashboard login required"}`, http.StatusUnauthorized) |
| return |
| } |
|
|
| var body struct { |
| Email string `json:"email"` |
| } |
| if err := json.NewDecoder(r.Body).Decode(&body); err != nil { |
| http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) |
| return |
| } |
| email := strings.TrimSpace(body.Email) |
| if email == "" { |
| http.Error(w, `{"error":"email is required"}`, http.StatusBadRequest) |
| return |
| } |
|
|
| |
| acc := pool.GetByEmail(email) |
| if acc == nil { |
| w.WriteHeader(http.StatusNotFound) |
| json.NewEncoder(w).Encode(map[string]string{"error": "account not found in pool"}) |
| return |
| } |
| acc.EnsureAccountID() |
| accountID := acc.AccountID |
| pool.RemoveAccountByEmail(email) |
|
|
| |
| if accountID != "" { |
| if err := DeleteAccountFile(accountID, accountsDir); err != nil { |
| log.Printf("[delete-account] file deletion warning: %v", err) |
| } |
| } else { |
| |
| if err := DeleteAccountFileByEmail(email, accountsDir); err != nil { |
| log.Printf("[delete-account] file deletion warning (by email): %v", err) |
| } |
| } |
|
|
| log.Printf("[delete-account] removed: %s (account_id=%s)", email, accountID) |
| json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) |
| } |
| } |
|
|
| func truncate(s string, max int) string { |
| if len(s) <= max { |
| return s |
| } |
| return s[:max] |
| } |
|
|