|
@@ -0,0 +1,1839 @@
|
|
|
|
|
+# TG Protocol Number Import — Implementation Plan
|
|
|
|
|
+
|
|
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
|
+
|
|
|
|
|
+**Goal:** Let admins upload a Chinese "协议号" folder (`<phone>.json` + `<phone>.session` Pyrogram SQLite + `tdata/` + `2fa_password.txt`) through the management UI, auto-convert it to gotd's native session format, and persist device fingerprint + encrypted 2FA so the existing TG client can use it without modification.
|
|
|
|
|
+
|
|
|
|
|
+**Architecture:** One-shot conversion at upload time. Pyrogram SQLite → extract `dc_id` + 256-byte `auth_key` → build `gotd/td` `session.Data` → write to `sessions/<phone>.json`. Fallback to `tdata/` via `gotd/td/session/tdesktop` if Pyrogram fails. Metadata (api_id, api_hash, device, 2FA) parsed from `<phone>.json` and stored in `tg_accounts` table; 2FA encrypted with AES-GCM. Runtime `telegram.Client` only reads the converted gotd JSON — unaware of Pyrogram/tdata.
|
|
|
|
|
+
|
|
|
|
|
+**Tech Stack:** Go 1.26 + `github.com/gotd/td v0.143.0` + `modernc.org/sqlite` (new, CGO-free) + Gin + GORM + MySQL + Redis; React 18 + Ant Design + axios.
|
|
|
|
|
+
|
|
|
|
|
+**Spec:** `docs/superpowers/specs/2026-04-20-tg-protocol-number-import-design.md`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## File Structure
|
|
|
|
|
+
|
|
|
|
|
+**New files:**
|
|
|
|
|
+- `internal/telegram/crypto.go` — AES-GCM encrypt/decrypt helper
|
|
|
|
|
+- `internal/telegram/crypto_test.go`
|
|
|
|
|
+- `internal/telegram/sessionimport/dcaddr.go` — `dc_id` → IP:port mapping
|
|
|
|
|
+- `internal/telegram/sessionimport/dcaddr_test.go`
|
|
|
|
|
+- `internal/telegram/sessionimport/pyrogram.go` — Pyrogram SQLite → `session.Data`
|
|
|
|
|
+- `internal/telegram/sessionimport/pyrogram_test.go`
|
|
|
|
|
+- `internal/telegram/sessionimport/tdesktop.go` — tdata fallback
|
|
|
|
|
+- `internal/telegram/sessionimport/importer.go` — orchestration, parses `<phone>.json`, writes session + backup
|
|
|
|
|
+- `deploy/.env.example` — `TG_SECRET_KEY` template
|
|
|
|
|
+
|
|
|
|
|
+**Modified files:**
|
|
|
|
|
+- `go.mod` / `go.sum` — add `modernc.org/sqlite`
|
|
|
|
|
+- `internal/model/user.go` — `TgAccount` new columns
|
|
|
|
|
+- `internal/telegram/types.go` — `Account` struct device fields
|
|
|
|
|
+- `internal/telegram/client.go` — `Connect` passes `telegram.DeviceConfig`
|
|
|
|
|
+- `internal/config/config.go` — `TelegramConfig.SessionsDir` + `SecretKey`
|
|
|
|
|
+- `configs/config.yaml` — two new keys
|
|
|
|
|
+- `internal/handler/tg_account.go` — `Import`, `RevealTwoFA`, `reloadAccounts` enriched
|
|
|
|
|
+- `internal/handler/router.go` — register new routes
|
|
|
|
|
+- `cmd/server/main.go` — construct `*telegram.Crypto`, ensure `sessions_dir` exists, inject into handler
|
|
|
|
|
+- `web/src/pages/TgAccounts.tsx` — Import button, upload modal, new columns
|
|
|
|
|
+
|
|
|
|
|
+**Not modified (already good):**
|
|
|
|
|
+- `deploy/docker-compose.yml` — `sessions/` volume already mounted at `/app/sessions`
|
|
|
|
|
+- `internal/telegram/account_manager.go` — no changes needed (new fields flow through `Account`)
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 1: Add `modernc.org/sqlite` dependency
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `go.mod`, `go.sum` (auto)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Add dependency**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+```bash
|
|
|
|
|
+cd D:/spider && go get modernc.org/sqlite@latest
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: `go.mod` gains `modernc.org/sqlite vX.Y.Z` in the require block; `go.sum` updated.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Verify no CGO requirement**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+```bash
|
|
|
|
|
+cd D:/spider && CGO_ENABLED=0 go build ./... 2>&1 | head -20
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: build succeeds (no `cgo` errors). `modernc.org/sqlite` is pure Go, must not break CGO_ENABLED=0 builds (our Docker image uses CGO_ENABLED=0).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add go.mod go.sum
|
|
|
|
|
+git commit -m "deps: add modernc.org/sqlite for Pyrogram session import"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 2: DC address mapping
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `internal/telegram/sessionimport/dcaddr.go`
|
|
|
|
|
+- Test: `internal/telegram/sessionimport/dcaddr_test.go`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing test**
|
|
|
|
|
+
|
|
|
|
|
+Create `internal/telegram/sessionimport/dcaddr_test.go`:
|
|
|
|
|
+```go
|
|
|
|
|
+package sessionimport
|
|
|
|
|
+
|
|
|
|
|
+import "testing"
|
|
|
|
|
+
|
|
|
|
|
+func TestTGDCAddr(t *testing.T) {
|
|
|
|
|
+ cases := []struct {
|
|
|
|
|
+ dc int
|
|
|
|
|
+ want string
|
|
|
|
|
+ }{
|
|
|
|
|
+ {1, "149.154.175.53:443"},
|
|
|
|
|
+ {2, "149.154.167.51:443"},
|
|
|
|
|
+ {3, "149.154.175.100:443"},
|
|
|
|
|
+ {4, "149.154.167.91:443"},
|
|
|
|
|
+ {5, "91.108.56.130:443"},
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, tc := range cases {
|
|
|
|
|
+ got, err := TGDCAddr(tc.dc)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("dc=%d unexpected err: %v", tc.dc, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if got != tc.want {
|
|
|
|
|
+ t.Errorf("dc=%d got %q want %q", tc.dc, got, tc.want)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if _, err := TGDCAddr(99); err == nil {
|
|
|
|
|
+ t.Error("expected err for unknown dc=99")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd D:/spider && go test ./internal/telegram/sessionimport/ -run TestTGDCAddr -v`
|
|
|
|
|
+Expected: FAIL — "undefined: TGDCAddr".
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Write minimal implementation**
|
|
|
|
|
+
|
|
|
|
|
+Create `internal/telegram/sessionimport/dcaddr.go`:
|
|
|
|
|
+```go
|
|
|
|
|
+// Package sessionimport converts third-party Telegram session formats
|
|
|
|
|
+// (Pyrogram SQLite, Telegram Desktop tdata) to gotd/td's native session.Data.
|
|
|
|
|
+package sessionimport
|
|
|
|
|
+
|
|
|
|
|
+import "fmt"
|
|
|
|
|
+
|
|
|
|
|
+// TGDCAddr maps a Telegram data-center id to its production IP:port.
|
|
|
|
|
+// Source: https://core.telegram.org/api/datacenter
|
|
|
|
|
+func TGDCAddr(dc int) (string, error) {
|
|
|
|
|
+ switch dc {
|
|
|
|
|
+ case 1:
|
|
|
|
|
+ return "149.154.175.53:443", nil
|
|
|
|
|
+ case 2:
|
|
|
|
|
+ return "149.154.167.51:443", nil
|
|
|
|
|
+ case 3:
|
|
|
|
|
+ return "149.154.175.100:443", nil
|
|
|
|
|
+ case 4:
|
|
|
|
|
+ return "149.154.167.91:443", nil
|
|
|
|
|
+ case 5:
|
|
|
|
|
+ return "91.108.56.130:443", nil
|
|
|
|
|
+ }
|
|
|
|
|
+ return "", fmt.Errorf("unsupported telegram dc_id: %d", dc)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd D:/spider && go test ./internal/telegram/sessionimport/ -run TestTGDCAddr -v`
|
|
|
|
|
+Expected: PASS.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add internal/telegram/sessionimport/dcaddr.go internal/telegram/sessionimport/dcaddr_test.go
|
|
|
|
|
+git commit -m "feat(sessionimport): add Telegram DC address mapping"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 3: Pyrogram SQLite → gotd session
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `internal/telegram/sessionimport/pyrogram.go`
|
|
|
|
|
+- Test: `internal/telegram/sessionimport/pyrogram_test.go`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing test**
|
|
|
|
|
+
|
|
|
|
|
+Create `internal/telegram/sessionimport/pyrogram_test.go`:
|
|
|
|
|
+```go
|
|
|
|
|
+package sessionimport
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "os"
|
|
|
|
|
+ "path/filepath"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// Real protocol-number session file lives at D:\spider\tgs\13252753163\13252753163.session
|
|
|
|
|
+// Test skips when that file is missing so CI environments don't break.
|
|
|
|
|
+func TestConvertPyrogramSession_RealFile(t *testing.T) {
|
|
|
|
|
+ path := `D:\spider\tgs\13252753163\13252753163.session`
|
|
|
|
|
+ if _, err := os.Stat(path); err != nil {
|
|
|
|
|
+ t.Skipf("real session file absent: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ data, err := ConvertPyrogramSession(path)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("convert failed: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if data.DC < 1 || data.DC > 5 {
|
|
|
|
|
+ t.Errorf("unexpected dc: %d", data.DC)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(data.AuthKey) != 256 {
|
|
|
|
|
+ t.Errorf("auth_key length = %d, want 256", len(data.AuthKey))
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(data.AuthKeyID) != 8 {
|
|
|
|
|
+ t.Errorf("auth_key_id length = %d, want 8", len(data.AuthKeyID))
|
|
|
|
|
+ }
|
|
|
|
|
+ if data.Addr == "" {
|
|
|
|
|
+ t.Error("addr empty")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestConvertPyrogramSession_MissingFile(t *testing.T) {
|
|
|
|
|
+ missing := filepath.Join(t.TempDir(), "does-not-exist.session")
|
|
|
|
|
+ if _, err := ConvertPyrogramSession(missing); err == nil {
|
|
|
|
|
+ t.Error("expected error for missing file")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd D:/spider && go test ./internal/telegram/sessionimport/ -run TestConvertPyrogramSession -v`
|
|
|
|
|
+Expected: FAIL — "undefined: ConvertPyrogramSession".
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Write minimal implementation**
|
|
|
|
|
+
|
|
|
|
|
+Create `internal/telegram/sessionimport/pyrogram.go`:
|
|
|
|
|
+```go
|
|
|
|
|
+package sessionimport
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "crypto/sha1"
|
|
|
|
|
+ "database/sql"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+
|
|
|
|
|
+ "github.com/gotd/td/session"
|
|
|
|
|
+ _ "modernc.org/sqlite"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// ConvertPyrogramSession reads a Pyrogram/Telethon SQLite session file and
|
|
|
|
|
+// builds a gotd/td session.Data representing the same authorization.
|
|
|
|
|
+//
|
|
|
|
|
+// Pyrogram schema (single row in `sessions` table):
|
|
|
|
|
+// CREATE TABLE sessions (
|
|
|
|
|
+// dc_id INTEGER PRIMARY KEY,
|
|
|
|
|
+// api_id INTEGER,
|
|
|
|
|
+// test_mode INTEGER,
|
|
|
|
|
+// auth_key BLOB, -- 256 bytes
|
|
|
|
|
+// date INTEGER NOT NULL,
|
|
|
|
|
+// user_id INTEGER,
|
|
|
|
|
+// is_bot INTEGER);
|
|
|
|
|
+func ConvertPyrogramSession(sqlitePath string) (*session.Data, error) {
|
|
|
|
|
+ db, err := sql.Open("sqlite", sqlitePath)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("open pyrogram sqlite: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ defer db.Close()
|
|
|
|
|
+
|
|
|
|
|
+ var dcID int
|
|
|
|
|
+ var authKey []byte
|
|
|
|
|
+ err = db.QueryRow(`SELECT dc_id, auth_key FROM sessions LIMIT 1`).Scan(&dcID, &authKey)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("read pyrogram sessions row: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(authKey) != 256 {
|
|
|
|
|
+ return nil, fmt.Errorf("invalid auth_key length: %d (want 256)", len(authKey))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ addr, err := TGDCAddr(dcID)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // auth_key_id = SHA-1(auth_key)[12:20] — lower 64 bits of the SHA-1 digest.
|
|
|
|
|
+ sum := sha1.Sum(authKey)
|
|
|
|
|
+ keyID := make([]byte, 8)
|
|
|
|
|
+ copy(keyID, sum[12:20])
|
|
|
|
|
+
|
|
|
|
|
+ return &session.Data{
|
|
|
|
|
+ DC: dcID,
|
|
|
|
|
+ Addr: addr,
|
|
|
|
|
+ AuthKey: authKey,
|
|
|
|
|
+ AuthKeyID: keyID,
|
|
|
|
|
+ }, nil
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd D:/spider && go test ./internal/telegram/sessionimport/ -run TestConvertPyrogramSession -v`
|
|
|
|
|
+Expected: PASS — both the real-file test and the missing-file test pass. If the real-file test is skipped, that is acceptable; the missing-file test must pass.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add internal/telegram/sessionimport/pyrogram.go internal/telegram/sessionimport/pyrogram_test.go
|
|
|
|
|
+git commit -m "feat(sessionimport): convert Pyrogram SQLite session to gotd session.Data"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 4: tdata fallback converter
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `internal/telegram/sessionimport/tdesktop.go`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write implementation**
|
|
|
|
|
+
|
|
|
|
|
+Create `internal/telegram/sessionimport/tdesktop.go`:
|
|
|
|
|
+```go
|
|
|
|
|
+package sessionimport
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+
|
|
|
|
|
+ "github.com/gotd/td/session"
|
|
|
|
|
+ "github.com/gotd/td/session/tdesktop"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// ConvertTdataSession reads a Telegram Desktop tdata/ folder and builds a
|
|
|
|
|
+// gotd session.Data. localPassword is the local-storage password used to decrypt
|
|
|
|
|
+// the tdata blob; pass an empty string when the desktop client did not set a
|
|
|
|
|
+// local password (the common case for 协议号).
|
|
|
|
|
+func ConvertTdataSession(tdataDir, localPassword string) (*session.Data, error) {
|
|
|
|
|
+ acc, err := tdesktop.Read(tdataDir, []byte(localPassword))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("read tdata: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ data, err := session.TDesktopSession(acc)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("convert tdesktop account: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return data, nil
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Verify build**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd D:/spider && go build ./internal/telegram/sessionimport/`
|
|
|
|
|
+Expected: build succeeds. No new test file — this thin wrapper is exercised by Task 9's importer tests.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add internal/telegram/sessionimport/tdesktop.go
|
|
|
|
|
+git commit -m "feat(sessionimport): add tdata fallback via gotd tdesktop reader"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 5: AES-GCM Crypto helper for 2FA
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `internal/telegram/crypto.go`
|
|
|
|
|
+- Test: `internal/telegram/crypto_test.go`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write the failing test**
|
|
|
|
|
+
|
|
|
|
|
+Create `internal/telegram/crypto_test.go`:
|
|
|
|
|
+```go
|
|
|
|
|
+package telegram
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "encoding/base64"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+func mustKey(t *testing.T) string {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ // 32 zero-bytes → stable base64 for test determinism.
|
|
|
|
|
+ return base64.StdEncoding.EncodeToString(make([]byte, 32))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestCryptoRoundTrip(t *testing.T) {
|
|
|
|
|
+ c, err := NewCrypto(mustKey(t))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ ct, err := c.Encrypt("xing")
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ pt, err := c.Decrypt(ct)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if pt != "xing" {
|
|
|
|
|
+ t.Errorf("roundtrip got %q want %q", pt, "xing")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestCryptoRejectsShortKey(t *testing.T) {
|
|
|
|
|
+ short := base64.StdEncoding.EncodeToString(make([]byte, 16))
|
|
|
|
|
+ if _, err := NewCrypto(short); err == nil {
|
|
|
|
|
+ t.Error("expected err for 16-byte key")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestCryptoRejectsTamperedCiphertext(t *testing.T) {
|
|
|
|
|
+ c, _ := NewCrypto(mustKey(t))
|
|
|
|
|
+ ct, _ := c.Encrypt("secret")
|
|
|
|
|
+ ct[len(ct)-1] ^= 0xFF
|
|
|
|
|
+ if _, err := c.Decrypt(ct); err == nil {
|
|
|
|
|
+ t.Error("expected tamper detection")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestCryptoNonceRandomness(t *testing.T) {
|
|
|
|
|
+ c, _ := NewCrypto(mustKey(t))
|
|
|
|
|
+ a, _ := c.Encrypt("same")
|
|
|
|
|
+ b, _ := c.Encrypt("same")
|
|
|
|
|
+ if string(a) == string(b) {
|
|
|
|
|
+ t.Error("identical ciphertexts imply nonce reuse")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd D:/spider && go test ./internal/telegram/ -run TestCrypto -v`
|
|
|
|
|
+Expected: FAIL — "undefined: NewCrypto".
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Write implementation**
|
|
|
|
|
+
|
|
|
|
|
+Create `internal/telegram/crypto.go`:
|
|
|
|
|
+```go
|
|
|
|
|
+package telegram
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "crypto/aes"
|
|
|
|
|
+ "crypto/cipher"
|
|
|
|
|
+ "crypto/rand"
|
|
|
|
|
+ "encoding/base64"
|
|
|
|
|
+ "errors"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// Crypto wraps AES-GCM with a fixed 32-byte key and random 12-byte nonces.
|
|
|
|
|
+// Used for encrypting 2FA passwords at rest.
|
|
|
|
|
+type Crypto struct {
|
|
|
|
|
+ gcm cipher.AEAD
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// NewCrypto builds a Crypto from a base64-encoded 32-byte key.
|
|
|
|
|
+func NewCrypto(b64Key string) (*Crypto, error) {
|
|
|
|
|
+ key, err := base64.StdEncoding.DecodeString(b64Key)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("decode secret key: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(key) != 32 {
|
|
|
|
|
+ return nil, fmt.Errorf("secret key must be 32 bytes, got %d", len(key))
|
|
|
|
|
+ }
|
|
|
|
|
+ block, err := aes.NewCipher(key)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ gcm, err := cipher.NewGCM(block)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ return &Crypto{gcm: gcm}, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Encrypt returns [nonce || ciphertext || tag].
|
|
|
|
|
+func (c *Crypto) Encrypt(plain string) ([]byte, error) {
|
|
|
|
|
+ nonce := make([]byte, c.gcm.NonceSize())
|
|
|
|
|
+ if _, err := rand.Read(nonce); err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ return c.gcm.Seal(nonce, nonce, []byte(plain), nil), nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Decrypt expects the [nonce || ciphertext || tag] layout produced by Encrypt.
|
|
|
|
|
+func (c *Crypto) Decrypt(blob []byte) (string, error) {
|
|
|
|
|
+ ns := c.gcm.NonceSize()
|
|
|
|
|
+ if len(blob) < ns {
|
|
|
|
|
+ return "", errors.New("ciphertext too short")
|
|
|
|
|
+ }
|
|
|
|
|
+ nonce, ct := blob[:ns], blob[ns:]
|
|
|
|
|
+ pt, err := c.gcm.Open(nil, nonce, ct, nil)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return "", err
|
|
|
|
|
+ }
|
|
|
|
|
+ return string(pt), nil
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd D:/spider && go test ./internal/telegram/ -run TestCrypto -v`
|
|
|
|
|
+Expected: PASS on all four test cases.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add internal/telegram/crypto.go internal/telegram/crypto_test.go
|
|
|
|
|
+git commit -m "feat(telegram): AES-GCM crypto helper for 2FA at-rest encryption"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 6: Config — `sessions_dir` + `secret_key`
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `internal/config/config.go`
|
|
|
|
|
+- Modify: `configs/config.yaml`
|
|
|
|
|
+- Create: `deploy/.env.example`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Extend `TelegramConfig`**
|
|
|
|
|
+
|
|
|
|
|
+In `internal/config/config.go`, replace the `TelegramConfig` struct:
|
|
|
|
|
+```go
|
|
|
|
|
+type TelegramConfig struct {
|
|
|
|
|
+ AppID int `mapstructure:"app_id"`
|
|
|
|
|
+ AppHash string `mapstructure:"app_hash"`
|
|
|
|
|
+ Accounts []TGAccount
|
|
|
|
|
+ SessionsDir string `mapstructure:"sessions_dir"` // absolute path, e.g. /app/sessions
|
|
|
|
|
+ SecretKey string `mapstructure:"secret_key"` // base64 32-byte, or literal ${TG_SECRET_KEY}
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Expand `${VAR}` from environment in `Load`**
|
|
|
|
|
+
|
|
|
|
|
+Viper does not auto-expand environment variables inside YAML. At the end of `Load`, before `global = cfg`, add:
|
|
|
|
|
+```go
|
|
|
|
|
+ // Resolve ${ENV_VAR} placeholders for secret fields.
|
|
|
|
|
+ cfg.Telegram.SecretKey = expandEnvPlaceholder(cfg.Telegram.SecretKey)
|
|
|
|
|
+ cfg.Security.JWTSecret = expandEnvPlaceholder(cfg.Security.JWTSecret)
|
|
|
|
|
+
|
|
|
|
|
+ if cfg.Telegram.SessionsDir == "" {
|
|
|
|
|
+ cfg.Telegram.SessionsDir = "/app/sessions"
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Add at the bottom of the file:
|
|
|
|
|
+```go
|
|
|
|
|
+// expandEnvPlaceholder replaces a value of the form "${VAR}" with os.Getenv("VAR").
|
|
|
|
|
+// A non-placeholder value is returned unchanged.
|
|
|
|
|
+func expandEnvPlaceholder(v string) string {
|
|
|
|
|
+ if len(v) > 3 && v[0] == '$' && v[1] == '{' && v[len(v)-1] == '}' {
|
|
|
|
|
+ return os.Getenv(v[2 : len(v)-1])
|
|
|
|
|
+ }
|
|
|
|
|
+ return v
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Add `"os"` to the import block.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Update `configs/config.yaml`**
|
|
|
|
|
+
|
|
|
|
|
+Replace the `telegram:` block with:
|
|
|
|
|
+```yaml
|
|
|
|
|
+telegram:
|
|
|
|
|
+ app_id: 0
|
|
|
|
|
+ app_hash: ""
|
|
|
|
|
+ accounts: []
|
|
|
|
|
+ sessions_dir: "/app/sessions"
|
|
|
|
|
+ secret_key: "${TG_SECRET_KEY}" # 32-byte base64 loaded from env
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Create `deploy/.env.example`**
|
|
|
|
|
+
|
|
|
|
|
+Create `deploy/.env.example`:
|
|
|
|
|
+```
|
|
|
|
|
+# 32-byte AES-GCM master key for 2FA encryption. Generate with:
|
|
|
|
|
+# openssl rand -base64 32
|
|
|
|
|
+TG_SECRET_KEY=CHANGE_ME_TO_32_RANDOM_BASE64_BYTES_XXXXXXXXXXX=
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Verify config loads**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+```bash
|
|
|
|
|
+cd D:/spider && TG_SECRET_KEY=$(openssl rand -base64 32) go run ./cmd/server 2>&1 | head -5
|
|
|
|
|
+```
|
|
|
|
|
+Expected: server starts (or MySQL connect error if DB not running — that is OK here). The important check is no config-parsing panic.
|
|
|
|
|
+
|
|
|
|
|
+Stop the server with Ctrl+C.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add internal/config/config.go configs/config.yaml deploy/.env.example
|
|
|
|
|
+git commit -m "feat(config): add telegram.sessions_dir and telegram.secret_key with env expansion"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 7: Extend `TgAccount` model
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `internal/model/user.go`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Extend the struct**
|
|
|
|
|
+
|
|
|
|
|
+Replace the `TgAccount` struct in `internal/model/user.go` with:
|
|
|
|
|
+```go
|
|
|
|
|
+// TgAccount represents a Telegram account managed via the admin panel.
|
|
|
|
|
+type TgAccount struct {
|
|
|
|
|
+ ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
|
|
|
|
+ Phone string `gorm:"uniqueIndex;size:50;not null" json:"phone"`
|
|
|
|
|
+ SessionFile string `gorm:"size:500;not null" json:"session_file"`
|
|
|
|
|
+ AppID int `gorm:"not null" json:"app_id"`
|
|
|
|
|
+ AppHash string `gorm:"size:100;not null" json:"app_hash"`
|
|
|
|
|
+ Remark string `gorm:"size:255" json:"remark"`
|
|
|
|
|
+ Enabled bool `gorm:"default:true" json:"enabled"`
|
|
|
|
|
+ Status string `gorm:"size:20;default:'idle'" json:"status"` // idle / online / cooling
|
|
|
|
|
+
|
|
|
|
|
+ // Protocol-number import additions (all nullable to keep legacy rows valid).
|
|
|
|
|
+ TwoFAEnc []byte `gorm:"type:varbinary(255)" json:"-"` // AES-GCM ciphertext; never emitted in JSON
|
|
|
|
|
+ Device string `gorm:"size:100" json:"device"` // e.g. "Xiaomi Mix 4"
|
|
|
|
|
+ AppVersion string `gorm:"size:50" json:"app_version"` // e.g. "12.0.0 (6163)"
|
|
|
|
|
+ SDK string `gorm:"size:100" json:"sdk"` // e.g. "Android 13 (33)"
|
|
|
|
|
+ LangPack string `gorm:"size:50" json:"lang_pack"` // e.g. "android"
|
|
|
|
|
+ LangCode string `gorm:"size:20" json:"lang_code"` // e.g. "en"
|
|
|
|
|
+ SystemLangCode string `gorm:"size:20" json:"system_lang_code"` // e.g. "en-US"
|
|
|
|
|
+ FirstName string `gorm:"size:100" json:"first_name"`
|
|
|
|
|
+ LastName string `gorm:"size:100" json:"last_name"`
|
|
|
|
|
+ TgUsername string `gorm:"size:100;column:tg_username" json:"tg_username"`
|
|
|
|
|
+ OriginDir string `gorm:"size:500" json:"origin_dir"` // sessions/<phone>.origin/ absolute path
|
|
|
|
|
+ ImportStatus string `gorm:"size:20" json:"import_status"` // ok / session_invalid / dead
|
|
|
|
|
+ Source string `gorm:"size:20" json:"source"` // protocol / manual
|
|
|
|
|
+
|
|
|
|
|
+ CreatedAt time.Time `json:"created_at"`
|
|
|
|
|
+ UpdatedAt time.Time `json:"updated_at"`
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Note: column name `tg_username` (not `username`) to avoid colliding with the `User.Username` column concept if tables are ever joined; field name `TgUsername` distinguishes from other `Username`-ish fields in the codebase.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Verify auto-migrate adds columns**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+```bash
|
|
|
|
|
+cd D:/spider && TG_SECRET_KEY=$(openssl rand -base64 32) go run ./cmd/server 2>&1 | head -20
|
|
|
|
|
+```
|
|
|
|
|
+Expected: `MySQL tables migrated` log line; no error. If MySQL is not running this step can be skipped — `go build ./...` alone is enough to confirm the model compiles.
|
|
|
|
|
+
|
|
|
|
|
+If MySQL is reachable, verify the columns exist:
|
|
|
|
|
+```bash
|
|
|
|
|
+docker exec -i im_mysql mysql -uroot -proot123 spider -e "DESCRIBE tg_accounts;" 2>&1 | grep -E 'two_fa_enc|device|app_version|sdk|lang_pack|origin_dir|import_status|source'
|
|
|
|
|
+```
|
|
|
|
|
+Expected: all new columns listed.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add internal/model/user.go
|
|
|
|
|
+git commit -m "feat(model): add protocol-number fields to TgAccount (device, 2fa, origin_dir...)"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 8: Extend `telegram.Account` + `Client.Connect` device fingerprint
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `internal/telegram/types.go`
|
|
|
|
|
+- Modify: `internal/telegram/client.go`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Extend `Account` struct**
|
|
|
|
|
+
|
|
|
|
|
+Replace the `Account` struct in `internal/telegram/types.go`:
|
|
|
|
|
+```go
|
|
|
|
|
+// Account TG 账号信息
|
|
|
|
|
+type Account struct {
|
|
|
|
|
+ Phone string
|
|
|
|
|
+ SessionFile string
|
|
|
|
|
+ AppID int
|
|
|
|
|
+ AppHash string
|
|
|
|
|
+ Device string // DeviceModel e.g. "Xiaomi Mix 4" — empty = gotd default
|
|
|
|
|
+ AppVersion string // e.g. "12.0.0 (6163)"
|
|
|
|
|
+ SystemVersion string // e.g. "Android 13 (33)" — mapped from DB.sdk
|
|
|
|
|
+ LangPack string // e.g. "android"
|
|
|
|
|
+ LangCode string // e.g. "en"
|
|
|
|
|
+ SystemLangCode string // e.g. "en-US"
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Pass device config into gotd**
|
|
|
|
|
+
|
|
|
|
|
+In `internal/telegram/client.go`, inside `Connect`, change the `opts := telegram.Options{...}` block from:
|
|
|
|
|
+```go
|
|
|
|
|
+ opts := telegram.Options{
|
|
|
|
|
+ SessionStorage: storage,
|
|
|
|
|
+ NoUpdates: true,
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+to:
|
|
|
|
|
+```go
|
|
|
|
|
+ opts := telegram.Options{
|
|
|
|
|
+ SessionStorage: storage,
|
|
|
|
|
+ NoUpdates: true,
|
|
|
|
|
+ Device: telegram.DeviceConfig{
|
|
|
|
|
+ DeviceModel: c.account.Device,
|
|
|
|
|
+ AppVersion: c.account.AppVersion,
|
|
|
|
|
+ SystemVersion: c.account.SystemVersion,
|
|
|
|
|
+ LangPack: c.account.LangPack,
|
|
|
|
|
+ SystemLangCode: c.account.SystemLangCode,
|
|
|
|
|
+ LangCode: c.account.LangCode,
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Per gotd docs, empty fields inside `DeviceConfig` fall back to gotd's default values — so legacy accounts (no device info stored) behave exactly as before.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Verify build**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd D:/spider && go build ./...`
|
|
|
|
|
+Expected: succeeds.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add internal/telegram/types.go internal/telegram/client.go
|
|
|
|
|
+git commit -m "feat(telegram): thread device fingerprint into Client.Connect InitConnection"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 9: Importer orchestration
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `internal/telegram/sessionimport/importer.go`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Write implementation**
|
|
|
|
|
+
|
|
|
|
|
+Create `internal/telegram/sessionimport/importer.go`:
|
|
|
|
|
+```go
|
|
|
|
|
+package sessionimport
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "context"
|
|
|
|
|
+ "encoding/json"
|
|
|
|
|
+ "errors"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "io"
|
|
|
|
|
+ "os"
|
|
|
|
|
+ "path/filepath"
|
|
|
|
|
+
|
|
|
|
|
+ "github.com/gotd/td/session"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// PyrogramMeta is the parsed content of <phone>.json inside a 协议号 folder.
|
|
|
|
|
+// Unknown fields are ignored.
|
|
|
|
|
+type PyrogramMeta struct {
|
|
|
|
|
+ Phone string `json:"phone"`
|
|
|
|
|
+ APIID int `json:"api_id"`
|
|
|
|
|
+ APIHash string `json:"api_hash"`
|
|
|
|
|
+ AppID int `json:"app_id"` // some generators use app_id/app_hash instead
|
|
|
|
|
+ AppHash string `json:"app_hash"`
|
|
|
|
|
+ Device string `json:"device"`
|
|
|
|
|
+ AppVersion string `json:"app_version"`
|
|
|
|
|
+ SDK string `json:"sdk"`
|
|
|
|
|
+ LangPack string `json:"lang_pack"`
|
|
|
|
|
+ LangCode string `json:"lang_code"`
|
|
|
|
|
+ SystemLangCode string `json:"system_lang_code"`
|
|
|
|
|
+ FirstName string `json:"first_name"`
|
|
|
|
|
+ LastName string `json:"last_name"`
|
|
|
|
|
+ Username string `json:"username"`
|
|
|
|
|
+ TwoFA string `json:"twoFA"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Normalize resolves API credential aliasing. 协议号 JSON files use api_id/api_hash
|
|
|
|
|
+// or app_id/app_hash interchangeably; we coalesce them.
|
|
|
|
|
+func (m *PyrogramMeta) Normalize() {
|
|
|
|
|
+ if m.APIID == 0 && m.AppID != 0 {
|
|
|
|
|
+ m.APIID = m.AppID
|
|
|
|
|
+ }
|
|
|
|
|
+ if m.APIHash == "" && m.AppHash != "" {
|
|
|
|
|
+ m.APIHash = m.AppHash
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Result is the outcome of importing one protocol-number folder.
|
|
|
|
|
+type Result struct {
|
|
|
|
|
+ Meta PyrogramMeta
|
|
|
|
|
+ TwoFAPlaintext string // from 2fa_password.txt if Meta.TwoFA empty
|
|
|
|
|
+ SessionData *session.Data
|
|
|
|
|
+ UsedSource string // "pyrogram" or "tdata"
|
|
|
|
|
+ SessionFile string // sessions/<phone>.json
|
|
|
|
|
+ OriginDir string // sessions/<phone>.origin/
|
|
|
|
|
+ PyrogramErr string // empty on success
|
|
|
|
|
+ TdataErr string // empty on success or if tdata absent
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// StagedFile is one file extracted from a multipart upload, already written to a
|
|
|
|
|
+// temp directory. RelPath preserves the browser's webkitRelativePath minus the
|
|
|
|
|
+// top-level folder name (so e.g. "13252753163/tdata/key_datas" becomes
|
|
|
|
|
+// "tdata/key_datas").
|
|
|
|
|
+type StagedFile struct {
|
|
|
|
|
+ RelPath string
|
|
|
|
|
+ AbsPath string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Import parses staged files from a 协议号 folder, converts the session, and
|
|
|
|
|
+// materializes sessions/<phone>.json + origin/ backup under sessionsDir.
|
|
|
|
|
+// The caller (HTTP handler) persists the returned metadata to the DB.
|
|
|
|
|
+//
|
|
|
|
|
+// Precondition: all StagedFile.AbsPath must exist on disk. stagedFiles is the
|
|
|
|
|
+// output of the handler's multipart parser.
|
|
|
|
|
+func Import(ctx context.Context, sessionsDir string, stagedFiles []StagedFile) (*Result, error) {
|
|
|
|
|
+ if len(stagedFiles) == 0 {
|
|
|
|
|
+ return nil, errors.New("no files uploaded")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var metaPath, sessionPath, twoFAPath, tdataDir string
|
|
|
|
|
+ for _, f := range stagedFiles {
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case filepath.Ext(f.RelPath) == ".json" && !hasSep(f.RelPath):
|
|
|
|
|
+ metaPath = f.AbsPath
|
|
|
|
|
+ case filepath.Ext(f.RelPath) == ".session":
|
|
|
|
|
+ sessionPath = f.AbsPath
|
|
|
|
|
+ case filepath.Base(f.RelPath) == "2fa_password.txt":
|
|
|
|
|
+ twoFAPath = f.AbsPath
|
|
|
|
|
+ case hasTdataPrefix(f.RelPath):
|
|
|
|
|
+ // Remember the parent dir containing tdata/
|
|
|
|
|
+ dir := filepath.Dir(f.AbsPath)
|
|
|
|
|
+ // Walk up until we find a folder literally named "tdata".
|
|
|
|
|
+ for dir != "" && filepath.Base(dir) != "tdata" {
|
|
|
|
|
+ dir = filepath.Dir(dir)
|
|
|
|
|
+ }
|
|
|
|
|
+ if dir != "" {
|
|
|
|
|
+ tdataDir = dir
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if metaPath == "" {
|
|
|
|
|
+ return nil, errors.New("missing <phone>.json in upload")
|
|
|
|
|
+ }
|
|
|
|
|
+ if sessionPath == "" && tdataDir == "" {
|
|
|
|
|
+ return nil, errors.New("upload has neither .session nor tdata/")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ meta, err := readMeta(metaPath)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, err
|
|
|
|
|
+ }
|
|
|
|
|
+ meta.Normalize()
|
|
|
|
|
+ if meta.Phone == "" {
|
|
|
|
|
+ return nil, errors.New("<phone>.json missing phone field")
|
|
|
|
|
+ }
|
|
|
|
|
+ if meta.APIID == 0 || meta.APIHash == "" {
|
|
|
|
|
+ return nil, errors.New("<phone>.json missing api_id / api_hash")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ res := &Result{Meta: meta}
|
|
|
|
|
+
|
|
|
|
|
+ // Prefer 2FA from JSON; fall back to 2fa_password.txt.
|
|
|
|
|
+ if meta.TwoFA == "" && twoFAPath != "" {
|
|
|
|
|
+ b, err := os.ReadFile(twoFAPath)
|
|
|
|
|
+ if err == nil {
|
|
|
|
|
+ res.TwoFAPlaintext = sanitizeTwoFA(string(b))
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ res.TwoFAPlaintext = meta.TwoFA
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Try Pyrogram first.
|
|
|
|
|
+ if sessionPath != "" {
|
|
|
|
|
+ data, err := ConvertPyrogramSession(sessionPath)
|
|
|
|
|
+ if err == nil {
|
|
|
|
|
+ res.SessionData = data
|
|
|
|
|
+ res.UsedSource = "pyrogram"
|
|
|
|
|
+ } else {
|
|
|
|
|
+ res.PyrogramErr = err.Error()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Fallback to tdata.
|
|
|
|
|
+ if res.SessionData == nil && tdataDir != "" {
|
|
|
|
|
+ data, err := ConvertTdataSession(tdataDir, res.TwoFAPlaintext)
|
|
|
|
|
+ if err == nil {
|
|
|
|
|
+ res.SessionData = data
|
|
|
|
|
+ res.UsedSource = "tdata"
|
|
|
|
|
+ } else {
|
|
|
|
|
+ res.TdataErr = err.Error()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if res.SessionData == nil {
|
|
|
|
|
+ return res, fmt.Errorf("session conversion failed (pyrogram: %s; tdata: %s)",
|
|
|
|
|
+ nonempty(res.PyrogramErr, "n/a"), nonempty(res.TdataErr, "n/a"))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Materialize session JSON.
|
|
|
|
|
+ if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
|
|
|
|
|
+ return res, fmt.Errorf("create sessions dir: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ res.SessionFile = filepath.Join(sessionsDir, meta.Phone+".json")
|
|
|
|
|
+ storage := &session.FileStorage{Path: res.SessionFile}
|
|
|
|
|
+ loader := session.Loader{Storage: storage}
|
|
|
|
|
+ if err := loader.Save(ctx, res.SessionData); err != nil {
|
|
|
|
|
+ return res, fmt.Errorf("save gotd session: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Materialize origin backup (best-effort; non-fatal).
|
|
|
|
|
+ res.OriginDir = filepath.Join(sessionsDir, meta.Phone+".origin")
|
|
|
|
|
+ _ = os.RemoveAll(res.OriginDir)
|
|
|
|
|
+ if err := os.MkdirAll(res.OriginDir, 0o755); err == nil {
|
|
|
|
|
+ for _, f := range stagedFiles {
|
|
|
|
|
+ dst := filepath.Join(res.OriginDir, f.RelPath)
|
|
|
|
|
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err == nil {
|
|
|
|
|
+ _ = copyFile(f.AbsPath, dst)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return res, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func readMeta(path string) (PyrogramMeta, error) {
|
|
|
|
|
+ var m PyrogramMeta
|
|
|
|
|
+ b, err := os.ReadFile(path)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return m, fmt.Errorf("read meta json: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal(b, &m); err != nil {
|
|
|
|
|
+ return m, fmt.Errorf("parse meta json: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return m, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func sanitizeTwoFA(s string) string {
|
|
|
|
|
+ // Trim CR/LF/space — 2fa_password.txt often has a trailing newline.
|
|
|
|
|
+ for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r' || s[len(s)-1] == ' ') {
|
|
|
|
|
+ s = s[:len(s)-1]
|
|
|
|
|
+ }
|
|
|
|
|
+ return s
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func hasSep(p string) bool {
|
|
|
|
|
+ for i := 0; i < len(p); i++ {
|
|
|
|
|
+ if p[i] == '/' || p[i] == '\\' {
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func hasTdataPrefix(p string) bool {
|
|
|
|
|
+ // "tdata/..." or "tdata\..."
|
|
|
|
|
+ if len(p) < 6 {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ return (p[:5] == "tdata" && (p[5] == '/' || p[5] == '\\'))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func nonempty(s, alt string) string {
|
|
|
|
|
+ if s == "" {
|
|
|
|
|
+ return alt
|
|
|
|
|
+ }
|
|
|
|
|
+ return s
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func copyFile(src, dst string) error {
|
|
|
|
|
+ in, err := os.Open(src)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer in.Close()
|
|
|
|
|
+ out, err := os.Create(dst)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer out.Close()
|
|
|
|
|
+ _, err = io.Copy(out, in)
|
|
|
|
|
+ return err
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Verify build**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd D:/spider && go build ./internal/telegram/sessionimport/`
|
|
|
|
|
+Expected: succeeds.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add internal/telegram/sessionimport/importer.go
|
|
|
|
|
+git commit -m "feat(sessionimport): importer orchestration (pyrogram→tdata fallback + backup)"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 10: Import HTTP handler
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `internal/handler/tg_account.go`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Add dependencies & fields**
|
|
|
|
|
+
|
|
|
|
|
+In `internal/handler/tg_account.go`, replace the existing imports + struct with:
|
|
|
|
|
+```go
|
|
|
|
|
+package handler
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "context"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "io"
|
|
|
|
|
+ "mime/multipart"
|
|
|
|
|
+ "os"
|
|
|
|
|
+ "path/filepath"
|
|
|
|
|
+ "strconv"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "time"
|
|
|
|
|
+
|
|
|
|
|
+ "spider/internal/model"
|
|
|
|
|
+ "spider/internal/store"
|
|
|
|
|
+ "spider/internal/telegram"
|
|
|
|
|
+ "spider/internal/telegram/sessionimport"
|
|
|
|
|
+
|
|
|
|
|
+ "github.com/gin-gonic/gin"
|
|
|
|
|
+ "github.com/google/uuid"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// TgAccountHandler handles Telegram account management.
|
|
|
|
|
+type TgAccountHandler struct {
|
|
|
|
|
+ store *store.Store
|
|
|
|
|
+ tgManager *telegram.AccountManager
|
|
|
|
|
+ crypto *telegram.Crypto
|
|
|
|
|
+ sessionsDir string
|
|
|
|
|
+ tmpDir string // <sessionsDir>/.tmp — staging area for multipart uploads
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`github.com/google/uuid` is already an indirect dependency (from gotd/td). If `go build` later complains, promote it with `go get github.com/google/uuid`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Add a constructor**
|
|
|
|
|
+
|
|
|
|
|
+Append this constructor to `internal/handler/tg_account.go`:
|
|
|
|
|
+```go
|
|
|
|
|
+// NewTgAccountHandler builds the handler with dependencies injected from main.
|
|
|
|
|
+func NewTgAccountHandler(s *store.Store, tgMgr *telegram.AccountManager, crypto *telegram.Crypto, sessionsDir string) *TgAccountHandler {
|
|
|
|
|
+ tmp := filepath.Join(sessionsDir, ".tmp")
|
|
|
|
|
+ _ = os.MkdirAll(tmp, 0o755)
|
|
|
|
|
+ h := &TgAccountHandler{
|
|
|
|
|
+ store: s,
|
|
|
|
|
+ tgManager: tgMgr,
|
|
|
|
|
+ crypto: crypto,
|
|
|
|
|
+ sessionsDir: sessionsDir,
|
|
|
|
|
+ tmpDir: tmp,
|
|
|
|
|
+ }
|
|
|
|
|
+ go h.tmpCleanupLoop()
|
|
|
|
|
+ return h
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// tmpCleanupLoop deletes tmp subdirs older than 30 minutes every 10 minutes.
|
|
|
|
|
+func (h *TgAccountHandler) tmpCleanupLoop() {
|
|
|
|
|
+ t := time.NewTicker(10 * time.Minute)
|
|
|
|
|
+ defer t.Stop()
|
|
|
|
|
+ for range t.C {
|
|
|
|
|
+ entries, err := os.ReadDir(h.tmpDir)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ cutoff := time.Now().Add(-30 * time.Minute)
|
|
|
|
|
+ for _, e := range entries {
|
|
|
|
|
+ if !e.IsDir() {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ info, err := e.Info()
|
|
|
|
|
+ if err != nil || info.ModTime().After(cutoff) {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ _ = os.RemoveAll(filepath.Join(h.tmpDir, e.Name()))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Add the Import method**
|
|
|
|
|
+
|
|
|
|
|
+Append to `internal/handler/tg_account.go`:
|
|
|
|
|
+```go
|
|
|
|
|
+// Import handles POST /tg-accounts/import — admin uploads a 协议号 folder.
|
|
|
|
|
+func (h *TgAccountHandler) Import(c *gin.Context) {
|
|
|
|
|
+ form, err := c.MultipartForm()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ Fail(c, 400, "invalid multipart form: "+err.Error())
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ files := form.File["files"]
|
|
|
|
|
+ if len(files) == 0 {
|
|
|
|
|
+ Fail(c, 400, "no files uploaded (expected field name: files)")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Stage uploaded files to tmp/<uuid>/.
|
|
|
|
|
+ stageID := uuid.NewString()
|
|
|
|
|
+ stageDir := filepath.Join(h.tmpDir, stageID)
|
|
|
|
|
+ if err := os.MkdirAll(stageDir, 0o755); err != nil {
|
|
|
|
|
+ Fail(c, 500, "create stage dir: "+err.Error())
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Top-level folder name in webkitRelativePath (e.g. "13252753163"). Used to
|
|
|
|
|
+ // strip the prefix so RelPath inside the Result is relative to the folder.
|
|
|
|
|
+ topPrefix := detectTopPrefix(files)
|
|
|
|
|
+
|
|
|
|
|
+ staged := make([]sessionimport.StagedFile, 0, len(files))
|
|
|
|
|
+ for _, fh := range files {
|
|
|
|
|
+ // Gin stores the relative path in fh.Filename (browsers send it as the
|
|
|
|
|
+ // form part filename when webkitdirectory is used).
|
|
|
|
|
+ rel := normalizeRel(fh.Filename, topPrefix)
|
|
|
|
|
+ if rel == "" {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ abs := filepath.Join(stageDir, rel)
|
|
|
|
|
+ if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
|
|
|
+ Fail(c, 500, "mkdir stage: "+err.Error())
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := saveUpload(fh, abs); err != nil {
|
|
|
|
|
+ Fail(c, 500, "save upload: "+err.Error())
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ staged = append(staged, sessionimport.StagedFile{RelPath: rel, AbsPath: abs})
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
|
|
|
|
|
+ defer cancel()
|
|
|
|
|
+
|
|
|
|
|
+ res, err := sessionimport.Import(ctx, h.sessionsDir, staged)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ Fail(c, 422, err.Error())
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Duplicate check.
|
|
|
|
|
+ var count int64
|
|
|
|
|
+ h.store.DB.Model(&model.TgAccount{}).Where("phone = ?", res.Meta.Phone).Count(&count)
|
|
|
|
|
+ if count > 0 {
|
|
|
|
|
+ Fail(c, 409, "该手机号已存在")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Encrypt 2FA if present.
|
|
|
|
|
+ var twoFAEnc []byte
|
|
|
|
|
+ if res.TwoFAPlaintext != "" && h.crypto != nil {
|
|
|
|
|
+ twoFAEnc, err = h.crypto.Encrypt(res.TwoFAPlaintext)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ Fail(c, 500, "encrypt 2fa: "+err.Error())
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ acc := model.TgAccount{
|
|
|
|
|
+ Phone: res.Meta.Phone,
|
|
|
|
|
+ SessionFile: res.SessionFile,
|
|
|
|
|
+ AppID: res.Meta.APIID,
|
|
|
|
|
+ AppHash: res.Meta.APIHash,
|
|
|
|
|
+ Enabled: true,
|
|
|
|
|
+ Status: "idle",
|
|
|
|
|
+ TwoFAEnc: twoFAEnc,
|
|
|
|
|
+ Device: res.Meta.Device,
|
|
|
|
|
+ AppVersion: res.Meta.AppVersion,
|
|
|
|
|
+ SDK: res.Meta.SDK,
|
|
|
|
|
+ LangPack: res.Meta.LangPack,
|
|
|
|
|
+ LangCode: res.Meta.LangCode,
|
|
|
|
|
+ SystemLangCode: res.Meta.SystemLangCode,
|
|
|
|
|
+ FirstName: res.Meta.FirstName,
|
|
|
|
|
+ LastName: res.Meta.LastName,
|
|
|
|
|
+ TgUsername: res.Meta.Username,
|
|
|
|
|
+ OriginDir: res.OriginDir,
|
|
|
|
|
+ ImportStatus: "ok",
|
|
|
|
|
+ Source: "protocol",
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := h.store.DB.Create(&acc).Error; err != nil {
|
|
|
|
|
+ if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
|
|
|
|
+ Fail(c, 409, "该手机号已存在")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ Fail(c, 500, "db create: "+err.Error())
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Rebuild account manager so new account is Acquireable.
|
|
|
|
|
+ h.reloadAccounts()
|
|
|
|
|
+
|
|
|
|
|
+ // Auto-test.
|
|
|
|
|
+ testResult := h.runTest(c.Request.Context(), &acc)
|
|
|
|
|
+ if testResult["status"] == "fail" {
|
|
|
|
|
+ h.store.DB.Model(&acc).Updates(map[string]any{
|
|
|
|
|
+ "enabled": false,
|
|
|
|
|
+ "import_status": "dead",
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ LogAudit(h.store, c, "import", "tg_account", fmt.Sprintf("%d", acc.ID), gin.H{
|
|
|
|
|
+ "phone": acc.Phone,
|
|
|
|
|
+ "source": res.UsedSource,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // Reload original so JSON response reflects updates.
|
|
|
|
|
+ h.store.DB.First(&acc, acc.ID)
|
|
|
|
|
+
|
|
|
|
|
+ // Cleanup stage dir on success.
|
|
|
|
|
+ _ = os.RemoveAll(stageDir)
|
|
|
|
|
+
|
|
|
|
|
+ OK(c, gin.H{"account": acc, "test_result": testResult})
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// runTest calls Client.Connect + waits for ready — no GetMe, kept minimal.
|
|
|
|
|
+func (h *TgAccountHandler) runTest(ctx context.Context, acc *model.TgAccount) gin.H {
|
|
|
|
|
+ client := telegram.New(telegram.Account{
|
|
|
|
|
+ Phone: acc.Phone,
|
|
|
|
|
+ SessionFile: acc.SessionFile,
|
|
|
|
|
+ AppID: acc.AppID,
|
|
|
|
|
+ AppHash: acc.AppHash,
|
|
|
|
|
+ Device: acc.Device,
|
|
|
|
|
+ AppVersion: acc.AppVersion,
|
|
|
|
|
+ SystemVersion: acc.SDK,
|
|
|
|
|
+ LangPack: acc.LangPack,
|
|
|
|
|
+ LangCode: acc.LangCode,
|
|
|
|
|
+ SystemLangCode: acc.SystemLangCode,
|
|
|
|
|
+ })
|
|
|
|
|
+ tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
|
|
|
+ defer cancel()
|
|
|
|
|
+ if err := client.Connect(tctx); err != nil {
|
|
|
|
|
+ if fwe, ok := err.(*telegram.FloodWaitError); ok {
|
|
|
|
|
+ return gin.H{"status": "cooling", "message": fmt.Sprintf("FloodWait: 需等待 %d 秒", fwe.Seconds)}
|
|
|
|
|
+ }
|
|
|
|
|
+ return gin.H{"status": "fail", "error": err.Error()}
|
|
|
|
|
+ }
|
|
|
|
|
+ client.Disconnect()
|
|
|
|
|
+ return gin.H{"status": "ok", "message": "连接成功"}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func detectTopPrefix(files []*multipart.FileHeader) string {
|
|
|
|
|
+ if len(files) == 0 {
|
|
|
|
|
+ return ""
|
|
|
|
|
+ }
|
|
|
|
|
+ first := files[0].Filename
|
|
|
|
|
+ idx := strings.IndexAny(first, "/\\")
|
|
|
|
|
+ if idx <= 0 {
|
|
|
|
|
+ return ""
|
|
|
|
|
+ }
|
|
|
|
|
+ return first[:idx]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func normalizeRel(filename, topPrefix string) string {
|
|
|
|
|
+ // Convert backslashes to forward slashes for consistent path handling.
|
|
|
|
|
+ p := strings.ReplaceAll(filename, "\\", "/")
|
|
|
|
|
+ // Strip the top-level directory name so e.g. "13252753163/tdata/key_datas"
|
|
|
|
|
+ // becomes "tdata/key_datas".
|
|
|
|
|
+ if topPrefix != "" && strings.HasPrefix(p, topPrefix+"/") {
|
|
|
|
|
+ p = strings.TrimPrefix(p, topPrefix+"/")
|
|
|
|
|
+ }
|
|
|
|
|
+ // Reject any path that tries to escape.
|
|
|
|
|
+ if strings.Contains(p, "../") || strings.HasPrefix(p, "/") {
|
|
|
|
|
+ return ""
|
|
|
|
|
+ }
|
|
|
|
|
+ return p
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func saveUpload(fh *multipart.FileHeader, dst string) error {
|
|
|
|
|
+ src, err := fh.Open()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer src.Close()
|
|
|
|
|
+ out, err := os.Create(dst)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ defer out.Close()
|
|
|
|
|
+ _, err = io.Copy(out, src)
|
|
|
|
|
+ return err
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Update `reloadAccounts` to populate new fields**
|
|
|
|
|
+
|
|
|
|
|
+Replace the existing `reloadAccounts` method body in `internal/handler/tg_account.go` with:
|
|
|
|
|
+```go
|
|
|
|
|
+// reloadAccounts loads enabled TG accounts from DB and reinitializes the account manager.
|
|
|
|
|
+func (h *TgAccountHandler) reloadAccounts() {
|
|
|
|
|
+ var dbAccounts []model.TgAccount
|
|
|
|
|
+ h.store.DB.Where("enabled = ?", true).Find(&dbAccounts)
|
|
|
|
|
+
|
|
|
|
|
+ accounts := make([]telegram.Account, 0, len(dbAccounts))
|
|
|
|
|
+ for _, a := range dbAccounts {
|
|
|
|
|
+ accounts = append(accounts, telegram.Account{
|
|
|
|
|
+ Phone: a.Phone,
|
|
|
|
|
+ SessionFile: a.SessionFile,
|
|
|
|
|
+ AppID: a.AppID,
|
|
|
|
|
+ AppHash: a.AppHash,
|
|
|
|
|
+ Device: a.Device,
|
|
|
|
|
+ AppVersion: a.AppVersion,
|
|
|
|
|
+ SystemVersion: a.SDK,
|
|
|
|
|
+ LangPack: a.LangPack,
|
|
|
|
|
+ LangCode: a.LangCode,
|
|
|
|
|
+ SystemLangCode: a.SystemLangCode,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ h.tgManager.Init(accounts)
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Verify build**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd D:/spider && go build ./...`
|
|
|
|
|
+Expected: succeeds. If `uuid` import fails, run `go get github.com/google/uuid` then retry.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add internal/handler/tg_account.go
|
|
|
|
|
+git commit -m "feat(handler): TG protocol-number import endpoint with device/2FA fields"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 11: RevealTwoFA handler
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `internal/handler/tg_account.go`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Append RevealTwoFA method**
|
|
|
|
|
+
|
|
|
|
|
+Append to `internal/handler/tg_account.go`:
|
|
|
|
|
+```go
|
|
|
|
|
+// RevealTwoFA returns the decrypted 2FA password for an account.
|
|
|
|
|
+// Admin-only; every call is audit-logged.
|
|
|
|
|
+func (h *TgAccountHandler) RevealTwoFA(c *gin.Context) {
|
|
|
|
|
+ id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ Fail(c, 400, "invalid id")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ var acc model.TgAccount
|
|
|
|
|
+ if err := h.store.DB.First(&acc, id).Error; err != nil {
|
|
|
|
|
+ Fail(c, 404, "账号不存在")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(acc.TwoFAEnc) == 0 {
|
|
|
|
|
+ Fail(c, 404, "该账号未存储 2FA")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if h.crypto == nil {
|
|
|
|
|
+ Fail(c, 500, "crypto not configured")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ plain, err := h.crypto.Decrypt(acc.TwoFAEnc)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ Fail(c, 500, "decrypt: "+err.Error())
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ LogAudit(h.store, c, "reveal_2fa", "tg_account", fmt.Sprintf("%d", id), nil)
|
|
|
|
|
+ OK(c, gin.H{"password": plain})
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Verify build**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd D:/spider && go build ./...`
|
|
|
|
|
+Expected: succeeds.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add internal/handler/tg_account.go
|
|
|
|
|
+git commit -m "feat(handler): POST /tg-accounts/:id/reveal-2fa with audit logging"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 12: Wire Crypto + routes + main.go
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `cmd/server/main.go`
|
|
|
|
|
+- Modify: `internal/handler/router.go`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Update `SetupRouter` signature**
|
|
|
|
|
+
|
|
|
|
|
+In `internal/handler/router.go`, change the signature of `SetupRouter` to accept the handler instance rather than building it inline. Replace the existing line:
|
|
|
|
|
+```go
|
|
|
|
|
+func SetupRouter(s *store.Store, taskMgr *task.Manager, rdb *redis.Client, tgMgr *telegram.AccountManager) *gin.Engine {
|
|
|
|
|
+```
|
|
|
|
|
+with:
|
|
|
|
|
+```go
|
|
|
|
|
+func SetupRouter(s *store.Store, taskMgr *task.Manager, rdb *redis.Client, tgMgr *telegram.AccountManager, tgAccountHandler *TgAccountHandler) *gin.Engine {
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Then inside `SetupRouter`, replace the block:
|
|
|
|
|
+```go
|
|
|
|
|
+ // TG account management
|
|
|
|
|
+ ta := &TgAccountHandler{store: s, tgManager: tgMgr}
|
|
|
|
|
+ protected.GET("/tg-accounts", ta.List) // all logged-in users can view
|
|
|
|
|
+ adminOnly.POST("/tg-accounts", ta.Create)
|
|
|
|
|
+ adminOnly.PUT("/tg-accounts/:id", ta.Update)
|
|
|
|
|
+ adminOnly.POST("/tg-accounts/:id/test", ta.Test)
|
|
|
|
|
+ adminOnly.DELETE("/tg-accounts/:id", ta.Delete)
|
|
|
|
|
+```
|
|
|
|
|
+with:
|
|
|
|
|
+```go
|
|
|
|
|
+ // TG account management
|
|
|
|
|
+ ta := tgAccountHandler
|
|
|
|
|
+ protected.GET("/tg-accounts", ta.List) // all logged-in users can view
|
|
|
|
|
+ adminOnly.POST("/tg-accounts", ta.Create)
|
|
|
|
|
+ adminOnly.POST("/tg-accounts/import", ta.Import)
|
|
|
|
|
+ adminOnly.PUT("/tg-accounts/:id", ta.Update)
|
|
|
|
|
+ adminOnly.POST("/tg-accounts/:id/test", ta.Test)
|
|
|
|
|
+ adminOnly.POST("/tg-accounts/:id/reveal-2fa", ta.RevealTwoFA)
|
|
|
|
|
+ adminOnly.DELETE("/tg-accounts/:id", ta.Delete)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Update main.go to construct and pass the handler**
|
|
|
|
|
+
|
|
|
|
|
+In `cmd/server/main.go`, after step 8 ("Initialize external clients") and before step 9 ("Load TG accounts from DB"), insert:
|
|
|
|
|
+```go
|
|
|
|
|
+ // 8b. Construct TG crypto helper (required, fails fast on missing/invalid key).
|
|
|
|
|
+ tgCrypto, err := telegram.NewCrypto(cfg.Telegram.SecretKey)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ log.Fatalf("TG_SECRET_KEY invalid: %v — set a 32-byte base64 value in env", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ sessionsDir := cfg.Telegram.SessionsDir
|
|
|
|
|
+ if sessionsDir == "" {
|
|
|
|
|
+ sessionsDir = "/app/sessions"
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
|
|
|
|
|
+ log.Fatalf("create sessions dir %s: %v", sessionsDir, err)
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Then replace the existing line:
|
|
|
|
|
+```go
|
|
|
|
|
+ r := handler.SetupRouter(s, taskMgr, rdb, tgManager)
|
|
|
|
|
+```
|
|
|
|
|
+with:
|
|
|
|
|
+```go
|
|
|
|
|
+ tgAccountHandler := handler.NewTgAccountHandler(s, tgManager, tgCrypto, sessionsDir)
|
|
|
|
|
+ r := handler.SetupRouter(s, taskMgr, rdb, tgManager, tgAccountHandler)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Verify build and missing-key fail-fast**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd D:/spider && go build ./...`
|
|
|
|
|
+Expected: succeeds.
|
|
|
|
|
+
|
|
|
|
|
+Run without the env var set:
|
|
|
|
|
+```bash
|
|
|
|
|
+cd D:/spider && unset TG_SECRET_KEY && go run ./cmd/server 2>&1 | head -3
|
|
|
|
|
+```
|
|
|
|
|
+Expected: process exits with log line `TG_SECRET_KEY invalid: secret key must be 32 bytes...`.
|
|
|
|
|
+
|
|
|
|
|
+Run with a valid key:
|
|
|
|
|
+```bash
|
|
|
|
|
+cd D:/spider && TG_SECRET_KEY=$(openssl rand -base64 32) go run ./cmd/server 2>&1 | head -10
|
|
|
|
|
+```
|
|
|
|
|
+Expected: `MySQL tables migrated` + `Server starting on :8080` (or a DB/Redis connection error, which is fine here — we only need to confirm config + route wiring didn't crash). Stop with Ctrl+C.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add cmd/server/main.go internal/handler/router.go
|
|
|
|
|
+git commit -m "feat: wire TG crypto + import/reveal-2fa routes into server boot"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 13: Frontend — TgAccounts import UI
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `web/src/pages/TgAccounts.tsx`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Replace the entire file**
|
|
|
|
|
+
|
|
|
|
|
+Overwrite `web/src/pages/TgAccounts.tsx` with:
|
|
|
|
|
+```tsx
|
|
|
|
|
+import { useEffect, useState, useCallback, useRef } from 'react'
|
|
|
|
|
+import { Table, Tag, Button, Modal, Form, Input, InputNumber, Switch, Space, message, Popconfirm, Tooltip } from 'antd'
|
|
|
|
|
+import { PlusOutlined, DeleteOutlined, CheckCircleOutlined, CloudUploadOutlined, EyeOutlined } from '@ant-design/icons'
|
|
|
|
|
+import api from '../api/client'
|
|
|
|
|
+
|
|
|
|
|
+interface TgAccount {
|
|
|
|
|
+ id: number
|
|
|
|
|
+ phone: string
|
|
|
|
|
+ session_file: string
|
|
|
|
|
+ app_id: number
|
|
|
|
|
+ app_hash: string
|
|
|
|
|
+ remark: string
|
|
|
|
|
+ enabled: boolean
|
|
|
|
|
+ status: string
|
|
|
|
|
+ device?: string
|
|
|
|
|
+ app_version?: string
|
|
|
|
|
+ sdk?: string
|
|
|
|
|
+ import_status?: string
|
|
|
|
|
+ source?: string
|
|
|
|
|
+ first_name?: string
|
|
|
|
|
+ last_name?: string
|
|
|
|
|
+ tg_username?: string
|
|
|
|
|
+ created_at: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const statusColors: Record<string, string> = {
|
|
|
|
|
+ idle: 'default',
|
|
|
|
|
+ online: 'green',
|
|
|
|
|
+ cooling: 'orange',
|
|
|
|
|
+ dead: 'red',
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const sourceColors: Record<string, string> = {
|
|
|
|
|
+ protocol: 'purple',
|
|
|
|
|
+ manual: 'default',
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const importStatusColors: Record<string, string> = {
|
|
|
|
|
+ ok: 'green',
|
|
|
|
|
+ session_invalid: 'red',
|
|
|
|
|
+ dead: 'orange',
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export default function TgAccounts() {
|
|
|
|
|
+ const [accounts, setAccounts] = useState<TgAccount[]>([])
|
|
|
|
|
+ const [loading, setLoading] = useState(false)
|
|
|
|
|
+ const [manualOpen, setManualOpen] = useState(false)
|
|
|
|
|
+ const [importOpen, setImportOpen] = useState(false)
|
|
|
|
|
+ const [importing, setImporting] = useState(false)
|
|
|
|
|
+ const [form] = Form.useForm()
|
|
|
|
|
+ const dirInputRef = useRef<HTMLInputElement | null>(null)
|
|
|
|
|
+
|
|
|
|
|
+ const fetchAccounts = useCallback(async () => {
|
|
|
|
|
+ setLoading(true)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await api.get('/tg-accounts')
|
|
|
|
|
+ setAccounts(res.data || [])
|
|
|
|
|
+ } catch { message.error('获取TG账号列表失败') }
|
|
|
|
|
+ finally { setLoading(false) }
|
|
|
|
|
+ }, [])
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => { fetchAccounts() }, [fetchAccounts])
|
|
|
|
|
+
|
|
|
|
|
+ const handleManualCreate = async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const values = await form.validateFields()
|
|
|
|
|
+ await api.post('/tg-accounts', values)
|
|
|
|
|
+ message.success('TG账号已添加')
|
|
|
|
|
+ setManualOpen(false)
|
|
|
|
|
+ fetchAccounts()
|
|
|
|
|
+ } catch (err: any) {
|
|
|
|
|
+ if (err?.errorFields) return
|
|
|
|
|
+ message.error(err?.response?.data?.message || '添加失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const handleImport = async () => {
|
|
|
|
|
+ const files = dirInputRef.current?.files
|
|
|
|
|
+ if (!files || files.length === 0) {
|
|
|
|
|
+ message.warning('请先选择协议号文件夹')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const fd = new FormData()
|
|
|
|
|
+ for (let i = 0; i < files.length; i++) {
|
|
|
|
|
+ const f = files[i]
|
|
|
|
|
+ // The third arg preserves webkitRelativePath as the multipart filename.
|
|
|
|
|
+ fd.append('files', f, (f as any).webkitRelativePath || f.name)
|
|
|
|
|
+ }
|
|
|
|
|
+ setImporting(true)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await api.post('/tg-accounts/import', fd, {
|
|
|
|
|
+ headers: { 'Content-Type': 'multipart/form-data' },
|
|
|
|
|
+ })
|
|
|
|
|
+ const tr = res.data?.test_result
|
|
|
|
|
+ if (tr?.status === 'ok') message.success('导入成功并测试通过')
|
|
|
|
|
+ else if (tr?.status === 'cooling') message.warning(`导入成功,但账号冷却中:${tr.message}`)
|
|
|
|
|
+ else message.error(`导入成功但连接失败:${tr?.error || '未知错误'}(账号已禁用)`)
|
|
|
|
|
+ setImportOpen(false)
|
|
|
|
|
+ fetchAccounts()
|
|
|
|
|
+ } catch (err: any) {
|
|
|
|
|
+ message.error(err?.response?.data?.message || '导入失败')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setImporting(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const handleToggle = async (acc: TgAccount, enabled: boolean) => {
|
|
|
|
|
+ await api.put(`/tg-accounts/${acc.id}`, { enabled })
|
|
|
|
|
+ message.success('状态已更新')
|
|
|
|
|
+ fetchAccounts()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const [testingId, setTestingId] = useState<number | null>(null)
|
|
|
|
|
+
|
|
|
|
|
+ const handleTest = async (id: number) => {
|
|
|
|
|
+ setTestingId(id)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await api.post(`/tg-accounts/${id}/test`)
|
|
|
|
|
+ const data = res.data as { status: string; message?: string; error?: string }
|
|
|
|
|
+ if (data.status === 'ok') message.success(data.message || '连接成功')
|
|
|
|
|
+ else if (data.status === 'cooling') message.warning(data.message || 'FloodWait 冷却中')
|
|
|
|
|
+ else message.error(`连接失败: ${data.error || '未知错误'}`)
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ message.error('测试请求失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ setTestingId(null)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const handleReveal2FA = async (id: number) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await api.post(`/tg-accounts/${id}/reveal-2fa`)
|
|
|
|
|
+ Modal.info({
|
|
|
|
|
+ title: '2FA 密码',
|
|
|
|
|
+ content: res.data?.password ?? '(空)',
|
|
|
|
|
+ })
|
|
|
|
|
+ } catch (err: any) {
|
|
|
|
|
+ message.error(err?.response?.data?.message || '无法获取 2FA')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const handleDelete = async (id: number) => {
|
|
|
|
|
+ await api.delete(`/tg-accounts/${id}`)
|
|
|
|
|
+ message.success('已删除')
|
|
|
|
|
+ fetchAccounts()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const columns = [
|
|
|
|
|
+ { title: 'ID', dataIndex: 'id', width: 60 },
|
|
|
|
|
+ { title: '手机号', dataIndex: 'phone' },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '来源',
|
|
|
|
|
+ dataIndex: 'source',
|
|
|
|
|
+ width: 80,
|
|
|
|
|
+ render: (v: string) => v ? <Tag color={sourceColors[v] ?? 'default'}>{v === 'protocol' ? '协议号' : '手填'}</Tag> : '-',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '设备',
|
|
|
|
|
+ dataIndex: 'device',
|
|
|
|
|
+ width: 160,
|
|
|
|
|
+ ellipsis: true,
|
|
|
|
|
+ render: (v: string) => v ? <Tooltip title={v}>{v}</Tooltip> : '-',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '状态',
|
|
|
|
|
+ dataIndex: 'status',
|
|
|
|
|
+ render: (v: string) => <Tag color={statusColors[v] ?? 'default'}>{v}</Tag>,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '导入状态',
|
|
|
|
|
+ dataIndex: 'import_status',
|
|
|
|
|
+ render: (v: string) => v ? <Tag color={importStatusColors[v] ?? 'default'}>{v}</Tag> : '-',
|
|
|
|
|
+ },
|
|
|
|
|
+ { title: '备注', dataIndex: 'remark', render: (v: string) => v || '-' },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '启用',
|
|
|
|
|
+ dataIndex: 'enabled',
|
|
|
|
|
+ render: (v: boolean, record: TgAccount) => (
|
|
|
|
|
+ <Switch checked={v} onChange={(c) => handleToggle(record, c)} checkedChildren="启用" unCheckedChildren="禁用" />
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '操作',
|
|
|
|
|
+ key: 'action',
|
|
|
|
|
+ width: 260,
|
|
|
|
|
+ render: (_: unknown, record: TgAccount) => (
|
|
|
|
|
+ <Space>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ icon={<CheckCircleOutlined />}
|
|
|
|
|
+ loading={testingId === record.id}
|
|
|
|
|
+ onClick={() => handleTest(record.id)}
|
|
|
|
|
+ >
|
|
|
|
|
+ 测试
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button size="small" icon={<EyeOutlined />} onClick={() => handleReveal2FA(record.id)}>2FA</Button>
|
|
|
|
|
+ <Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
|
|
|
|
+ <Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
|
|
|
|
+ </Popconfirm>
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <Space style={{ marginBottom: 16 }}>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ type="primary"
|
|
|
|
|
+ icon={<CloudUploadOutlined />}
|
|
|
|
|
+ onClick={() => setImportOpen(true)}
|
|
|
|
|
+ >
|
|
|
|
|
+ 导入协议号
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ icon={<PlusOutlined />}
|
|
|
|
|
+ onClick={() => { form.resetFields(); setManualOpen(true) }}
|
|
|
|
|
+ >
|
|
|
|
|
+ 手填添加
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Space>
|
|
|
|
|
+
|
|
|
|
|
+ <Table dataSource={accounts} columns={columns} rowKey="id" loading={loading} pagination={false} />
|
|
|
|
|
+
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ title="导入协议号"
|
|
|
|
|
+ open={importOpen}
|
|
|
|
|
+ onOk={handleImport}
|
|
|
|
|
+ confirmLoading={importing}
|
|
|
|
|
+ onCancel={() => setImportOpen(false)}
|
|
|
|
|
+ okText="导入"
|
|
|
|
|
+ width={500}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div style={{ marginTop: 16 }}>
|
|
|
|
|
+ <p>选择一个协议号目录(如 <code>D:\spider\tgs\13252753163\</code>),目录下应包含 <code><phone>.json</code>、<code><phone>.session</code>、<code>2fa_password.txt</code> 和(可选) <code>tdata/</code>。</p>
|
|
|
|
|
+ <input
|
|
|
|
|
+ ref={dirInputRef}
|
|
|
|
|
+ type="file"
|
|
|
|
|
+ multiple
|
|
|
|
|
+ /* @ts-expect-error non-standard HTML5 folder picker */
|
|
|
|
|
+ webkitdirectory=""
|
|
|
|
|
+ directory=""
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ title="手填 TG 账号"
|
|
|
|
|
+ open={manualOpen}
|
|
|
|
|
+ onOk={handleManualCreate}
|
|
|
|
|
+ onCancel={() => setManualOpen(false)}
|
|
|
|
|
+ okText="添加"
|
|
|
|
|
+ width={500}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
|
|
|
|
+ <Form.Item name="phone" label="手机号" rules={[{ required: true }]}>
|
|
|
|
|
+ <Input placeholder="+86xxxxxxxxx" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item name="session_file" label="Session文件路径" rules={[{ required: true }]}>
|
|
|
|
|
+ <Input placeholder="sessions/+86xxxxxxxxx.session" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item name="app_id" label="App ID" rules={[{ required: true }]}>
|
|
|
|
|
+ <InputNumber style={{ width: '100%' }} placeholder="从 my.telegram.org 获取" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item name="app_hash" label="App Hash" rules={[{ required: true }]}>
|
|
|
|
|
+ <Input placeholder="从 my.telegram.org 获取" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item name="remark" label="备注">
|
|
|
|
|
+ <Input placeholder="可选" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Build frontend**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+```bash
|
|
|
|
|
+cd D:/spider/web && npm run build 2>&1 | tail -10
|
|
|
|
|
+```
|
|
|
|
|
+Expected: build succeeds (no TS errors).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add web/src/pages/TgAccounts.tsx
|
|
|
|
|
+git commit -m "feat(web): TG protocol-number import UI with webkitdirectory upload"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 14: End-to-end smoke test
|
|
|
|
|
+
|
|
|
|
|
+**Files:** none (manual verification)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Rebuild and start the Docker stack**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+```bash
|
|
|
|
|
+cd D:/spider/deploy && docker compose build api web && docker compose up -d
|
|
|
|
|
+```
|
|
|
|
|
+Expected: both containers start; `docker compose ps` shows `api` healthy after ~30s.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Ensure `TG_SECRET_KEY` is set in the API container**
|
|
|
|
|
+
|
|
|
|
|
+Edit `deploy/docker-compose.yml` → `api.environment` (if not already set), add:
|
|
|
|
|
+```yaml
|
|
|
|
|
+ - TG_SECRET_KEY=${TG_SECRET_KEY}
|
|
|
|
|
+```
|
|
|
|
|
+Create `deploy/.env` (NOT committed) with a real key:
|
|
|
|
|
+```bash
|
|
|
|
|
+cd D:/spider/deploy && echo "TG_SECRET_KEY=$(openssl rand -base64 32)" > .env
|
|
|
|
|
+docker compose up -d --force-recreate api
|
|
|
|
|
+```
|
|
|
|
|
+Expected: api healthy; no `TG_SECRET_KEY invalid` log.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Import a real protocol number via UI**
|
|
|
|
|
+
|
|
|
|
|
+1. Open `http://localhost:8300` in a browser, log in as admin.
|
|
|
|
|
+2. Navigate to TG 账号 page.
|
|
|
|
|
+3. Click "导入协议号" → in the file picker, select the folder `D:\spider\tgs\13252753163\`.
|
|
|
|
|
+4. Click 导入.
|
|
|
|
|
+
|
|
|
|
|
+Expected: toast "导入成功并测试通过" (or "冷却中" if the account is flood-waited — still counts as import success). New row appears with `来源=协议号`, `设备=Xiaomi Mix 4`, `状态=idle`|`online`, `导入状态=ok`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Verify filesystem artifacts**
|
|
|
|
|
+
|
|
|
|
|
+Run on host:
|
|
|
|
|
+```bash
|
|
|
|
|
+ls D:/spider/sessions/
|
|
|
|
|
+```
|
|
|
|
|
+Expected: `13252753163.json` + `13252753163.origin/` (containing copies of the uploaded files).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: Verify the account is usable**
|
|
|
|
|
+
|
|
|
|
|
+In the UI, go to Groups page → pick an existing group → click "克隆成员". The clone should succeed (or return a meaningful TG error like `USERNAME_NOT_OCCUPIED` — anything *other than* `no TG accounts configured` proves the account was acquired).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Verify duplicate protection**
|
|
|
|
|
+
|
|
|
|
|
+Re-import the same folder. Expected: toast `该手机号已存在` (HTTP 409).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 7: Verify 2FA reveal**
|
|
|
|
|
+
|
|
|
|
|
+In the TG 账号 row, click `2FA`. Modal opens showing `xing` (or whatever the folder's `2fa_password.txt` contained).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 8: Commit verification notes**
|
|
|
|
|
+
|
|
|
|
|
+If any small follow-up fixes were needed in prior tasks, commit them here. Otherwise this task has no commit.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Self-Review Notes
|
|
|
|
|
+
|
|
|
|
|
+**Spec coverage check:**
|
|
|
|
|
+- §1 data model → Task 7
|
|
|
|
|
+- §2 file layout + docker + config → Tasks 6 (config), 12 (main.go mkdir); docker-compose already mounts sessions/ per existing config
|
|
|
|
|
+- §3 Pyrogram conversion → Tasks 1 (dep), 2 (dc addr), 3 (conversion), 4 (tdata fallback), 9 (orchestration)
|
|
|
|
|
+- §4 2FA crypto → Task 5; reveal endpoint Task 11
|
|
|
|
|
+- §5 device fingerprint → Task 8; plumbing in reloadAccounts Task 10; import insert Task 10
|
|
|
|
|
+- §6 frontend + API → Tasks 10 (import), 11 (reveal-2fa), 12 (routes), 13 (UI)
|
|
|
|
|
+- §7 error handling → built into Tasks 9–10 (pyrogram err, tdata err, FloodWait, duplicate, tmp dir retention via `tmpCleanupLoop`)
|
|
|
|
|
+- Non-goals → respected (no auto-scan, no re-login, no Pyrogram writeback, no tdata export, no batch)
|
|
|
|
|
+
|
|
|
|
|
+**Type consistency:**
|
|
|
|
|
+- `TgAccount.SDK` (DB field) → `Account.SystemVersion` (runtime) — mapping performed identically in Task 10's `reloadAccounts` and Task 10's `runTest`
|
|
|
|
|
+- `session.Data` fields used in Task 3 match what Task 9 writes via `session.Loader{}.Save`
|
|
|
|
|
+- HTTP status codes match spec: 400 (form error), 409 (dup), 422 (conversion fail), 500 (io/server)
|