tg_account.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. package handler
  2. import (
  3. "context"
  4. "fmt"
  5. "io"
  6. "mime/multipart"
  7. "os"
  8. "path"
  9. "path/filepath"
  10. "strconv"
  11. "strings"
  12. "time"
  13. "spider/internal/model"
  14. "spider/internal/store"
  15. "spider/internal/telegram"
  16. "spider/internal/telegram/sessionimport"
  17. "github.com/gin-gonic/gin"
  18. "github.com/google/uuid"
  19. )
  20. // TgAccountHandler handles Telegram account management.
  21. type TgAccountHandler struct {
  22. store *store.Store
  23. tgManager *telegram.AccountManager
  24. crypto *telegram.Crypto
  25. sessionsDir string
  26. tmpDir string // <sessionsDir>/.tmp — staging area for multipart uploads
  27. }
  28. // NewTgAccountHandler builds the handler with dependencies injected from main.
  29. func NewTgAccountHandler(s *store.Store, tgMgr *telegram.AccountManager, crypto *telegram.Crypto, sessionsDir string) *TgAccountHandler {
  30. tmp := filepath.Join(sessionsDir, ".tmp")
  31. _ = os.MkdirAll(tmp, 0o755)
  32. h := &TgAccountHandler{
  33. store: s,
  34. tgManager: tgMgr,
  35. crypto: crypto,
  36. sessionsDir: sessionsDir,
  37. tmpDir: tmp,
  38. }
  39. go h.tmpCleanupLoop()
  40. return h
  41. }
  42. // tmpCleanupLoop deletes tmp subdirs older than 30 minutes every 10 minutes.
  43. func (h *TgAccountHandler) tmpCleanupLoop() {
  44. t := time.NewTicker(10 * time.Minute)
  45. defer t.Stop()
  46. for range t.C {
  47. entries, err := os.ReadDir(h.tmpDir)
  48. if err != nil {
  49. continue
  50. }
  51. cutoff := time.Now().Add(-30 * time.Minute)
  52. for _, e := range entries {
  53. if !e.IsDir() {
  54. continue
  55. }
  56. info, err := e.Info()
  57. if err != nil || info.ModTime().After(cutoff) {
  58. continue
  59. }
  60. _ = os.RemoveAll(filepath.Join(h.tmpDir, e.Name()))
  61. }
  62. }
  63. }
  64. // List handles GET /tg-accounts
  65. func (h *TgAccountHandler) List(c *gin.Context) {
  66. var accounts []model.TgAccount
  67. h.store.DB.Order("id ASC").Find(&accounts)
  68. // Enrich with live status from account manager
  69. statuses := h.tgManager.GetStatuses()
  70. for i := range accounts {
  71. if s, ok := statuses[accounts[i].Phone]; ok {
  72. accounts[i].Status = s
  73. }
  74. }
  75. OK(c, accounts)
  76. }
  77. // Create handles POST /tg-accounts
  78. func (h *TgAccountHandler) Create(c *gin.Context) {
  79. var req struct {
  80. Phone string `json:"phone" binding:"required"`
  81. SessionFile string `json:"session_file" binding:"required"`
  82. AppID int `json:"app_id" binding:"required"`
  83. AppHash string `json:"app_hash" binding:"required"`
  84. Remark string `json:"remark"`
  85. }
  86. if err := c.ShouldBindJSON(&req); err != nil {
  87. Fail(c, 400, err.Error())
  88. return
  89. }
  90. // Check duplicate
  91. var count int64
  92. h.store.DB.Model(&model.TgAccount{}).Where("phone = ?", req.Phone).Count(&count)
  93. if count > 0 {
  94. Fail(c, 409, "该手机号已存在")
  95. return
  96. }
  97. acc := model.TgAccount{
  98. Phone: req.Phone,
  99. SessionFile: req.SessionFile,
  100. AppID: req.AppID,
  101. AppHash: req.AppHash,
  102. Remark: req.Remark,
  103. Enabled: true,
  104. Status: "idle",
  105. }
  106. if err := h.store.DB.Create(&acc).Error; err != nil {
  107. Fail(c, 500, err.Error())
  108. return
  109. }
  110. // Reload account manager
  111. h.reloadAccounts()
  112. LogAudit(h.store, c, "create", "tg_account", fmt.Sprintf("%d", acc.ID), gin.H{"phone": acc.Phone})
  113. OK(c, acc)
  114. }
  115. // Update handles PUT /tg-accounts/:id
  116. func (h *TgAccountHandler) Update(c *gin.Context) {
  117. id, err := strconv.ParseUint(c.Param("id"), 10, 64)
  118. if err != nil {
  119. Fail(c, 400, "invalid id")
  120. return
  121. }
  122. var acc model.TgAccount
  123. if err := h.store.DB.First(&acc, id).Error; err != nil {
  124. Fail(c, 404, "账号不存在")
  125. return
  126. }
  127. var req struct {
  128. SessionFile *string `json:"session_file"`
  129. AppID *int `json:"app_id"`
  130. AppHash *string `json:"app_hash"`
  131. Remark *string `json:"remark"`
  132. Enabled *bool `json:"enabled"`
  133. }
  134. if err := c.ShouldBindJSON(&req); err != nil {
  135. Fail(c, 400, err.Error())
  136. return
  137. }
  138. updates := map[string]any{}
  139. if req.SessionFile != nil {
  140. updates["session_file"] = *req.SessionFile
  141. }
  142. if req.AppID != nil {
  143. updates["app_id"] = *req.AppID
  144. }
  145. if req.AppHash != nil {
  146. updates["app_hash"] = *req.AppHash
  147. }
  148. if req.Remark != nil {
  149. updates["remark"] = *req.Remark
  150. }
  151. if req.Enabled != nil {
  152. updates["enabled"] = *req.Enabled
  153. }
  154. h.store.DB.Model(&acc).Updates(updates)
  155. h.store.DB.First(&acc, id)
  156. h.reloadAccounts()
  157. LogAudit(h.store, c, "update", "tg_account", fmt.Sprintf("%d", id), updates)
  158. OK(c, acc)
  159. }
  160. // Delete handles DELETE /tg-accounts/:id
  161. func (h *TgAccountHandler) Delete(c *gin.Context) {
  162. id, err := strconv.ParseUint(c.Param("id"), 10, 64)
  163. if err != nil {
  164. Fail(c, 400, "invalid id")
  165. return
  166. }
  167. if err := h.store.DB.Delete(&model.TgAccount{}, id).Error; err != nil {
  168. Fail(c, 500, err.Error())
  169. return
  170. }
  171. h.reloadAccounts()
  172. LogAudit(h.store, c, "delete", "tg_account", fmt.Sprintf("%d", id), nil)
  173. OK(c, gin.H{"message": "已删除"})
  174. }
  175. // Test handles POST /tg-accounts/:id/test — tests if the account can connect.
  176. func (h *TgAccountHandler) Test(c *gin.Context) {
  177. id, err := strconv.ParseUint(c.Param("id"), 10, 64)
  178. if err != nil {
  179. Fail(c, 400, "invalid id")
  180. return
  181. }
  182. var acc model.TgAccount
  183. if err := h.store.DB.First(&acc, id).Error; err != nil {
  184. Fail(c, 404, "账号不存在")
  185. return
  186. }
  187. client := telegram.New(telegram.Account{
  188. Phone: acc.Phone,
  189. SessionFile: acc.SessionFile,
  190. AppID: acc.AppID,
  191. AppHash: acc.AppHash,
  192. })
  193. ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
  194. defer cancel()
  195. if err := client.Connect(ctx); err != nil {
  196. if fwe, ok := err.(*telegram.FloodWaitError); ok {
  197. OK(c, gin.H{"status": "cooling", "message": fmt.Sprintf("FloodWait: 需等待 %d 秒", fwe.Seconds)})
  198. return
  199. }
  200. OK(c, gin.H{"status": "fail", "error": err.Error()})
  201. return
  202. }
  203. client.Disconnect()
  204. OK(c, gin.H{"status": "ok", "message": "连接成功"})
  205. }
  206. // Import handles POST /tg-accounts/import — admin uploads a 协议号 folder.
  207. func (h *TgAccountHandler) Import(c *gin.Context) {
  208. form, err := c.MultipartForm()
  209. if err != nil {
  210. Fail(c, 400, "invalid multipart form: "+err.Error())
  211. return
  212. }
  213. files := form.File["files"]
  214. if len(files) == 0 {
  215. Fail(c, 400, "no files uploaded (expected field name: files)")
  216. return
  217. }
  218. // Stage uploaded files to tmp/<uuid>/.
  219. stageID := uuid.NewString()
  220. stageDir := filepath.Join(h.tmpDir, stageID)
  221. if err := os.MkdirAll(stageDir, 0o755); err != nil {
  222. Fail(c, 500, "create stage dir: "+err.Error())
  223. return
  224. }
  225. // Top-level folder name in webkitRelativePath (e.g. "13252753163"). Used to
  226. // strip the prefix so RelPath inside the Result is relative to the folder.
  227. topPrefix := detectTopPrefix(files)
  228. staged := make([]sessionimport.StagedFile, 0, len(files))
  229. for _, fh := range files {
  230. // Gin stores the relative path in fh.Filename (browsers send it as the
  231. // form part filename when webkitdirectory is used).
  232. rel := normalizeRel(fh.Filename, topPrefix)
  233. if rel == "" {
  234. continue
  235. }
  236. abs := filepath.Join(stageDir, rel)
  237. if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
  238. Fail(c, 500, "mkdir stage: "+err.Error())
  239. return
  240. }
  241. if err := saveUpload(fh, abs); err != nil {
  242. Fail(c, 500, "save upload: "+err.Error())
  243. return
  244. }
  245. staged = append(staged, sessionimport.StagedFile{RelPath: rel, AbsPath: abs})
  246. }
  247. ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
  248. defer cancel()
  249. res, err := sessionimport.Import(ctx, h.sessionsDir, staged)
  250. if err != nil {
  251. detail := gin.H{"error": err.Error()}
  252. if res != nil {
  253. detail["pyrogram_err"] = res.PyrogramErr
  254. detail["tdata_err"] = res.TdataErr
  255. detail["tmp_dir"] = stageDir
  256. }
  257. c.JSON(422, gin.H{
  258. "code": 422,
  259. "message": err.Error(),
  260. "data": detail,
  261. })
  262. return
  263. }
  264. cleanupOnFail := func() {
  265. if res != nil {
  266. if res.SessionFile != "" {
  267. _ = os.Remove(res.SessionFile)
  268. }
  269. if res.OriginDir != "" {
  270. _ = os.RemoveAll(res.OriginDir)
  271. }
  272. }
  273. }
  274. // Duplicate check.
  275. var count int64
  276. h.store.DB.Model(&model.TgAccount{}).Where("phone = ?", res.Meta.Phone).Count(&count)
  277. if count > 0 {
  278. cleanupOnFail()
  279. Fail(c, 409, "该手机号已存在")
  280. return
  281. }
  282. // Encrypt 2FA if present.
  283. var twoFAEnc []byte
  284. if res.TwoFAPlaintext != "" && h.crypto != nil {
  285. twoFAEnc, err = h.crypto.Encrypt(res.TwoFAPlaintext)
  286. if err != nil {
  287. cleanupOnFail()
  288. Fail(c, 500, "encrypt 2fa: "+err.Error())
  289. return
  290. }
  291. }
  292. acc := model.TgAccount{
  293. Phone: res.Meta.Phone,
  294. SessionFile: res.SessionFile,
  295. AppID: res.Meta.APIID,
  296. AppHash: res.Meta.APIHash,
  297. Enabled: true,
  298. Status: "idle",
  299. TwoFAEnc: twoFAEnc,
  300. Device: res.Meta.Device,
  301. AppVersion: res.Meta.AppVersion,
  302. SDK: res.Meta.SDK,
  303. LangPack: res.Meta.LangPack,
  304. LangCode: res.Meta.LangCode,
  305. SystemLangCode: res.Meta.SystemLangCode,
  306. FirstName: res.Meta.FirstName,
  307. LastName: res.Meta.LastName,
  308. TgUsername: res.Meta.Username,
  309. OriginDir: res.OriginDir,
  310. ImportStatus: "ok",
  311. Source: "protocol",
  312. }
  313. if err := h.store.DB.Create(&acc).Error; err != nil {
  314. if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
  315. cleanupOnFail()
  316. Fail(c, 409, "该手机号已存在")
  317. return
  318. }
  319. cleanupOnFail()
  320. Fail(c, 500, "db create: "+err.Error())
  321. return
  322. }
  323. // Rebuild account manager so new account is Acquireable.
  324. h.reloadAccounts()
  325. // Auto-test — detach from the HTTP request context so a client disconnect
  326. // cannot falsely mark a freshly imported account as dead.
  327. bgCtx, bgCancel := context.WithTimeout(context.Background(), 45*time.Second)
  328. defer bgCancel()
  329. testResult := h.runTest(bgCtx, &acc)
  330. if testResult["status"] == "fail" {
  331. h.store.DB.Model(&acc).Updates(map[string]any{
  332. "enabled": false,
  333. "import_status": "dead",
  334. })
  335. }
  336. LogAudit(h.store, c, "import", "tg_account", fmt.Sprintf("%d", acc.ID), gin.H{
  337. "phone": acc.Phone,
  338. "source": res.UsedSource,
  339. })
  340. // Reload original so JSON response reflects updates.
  341. h.store.DB.First(&acc, acc.ID)
  342. // Cleanup stage dir on success.
  343. _ = os.RemoveAll(stageDir)
  344. OK(c, gin.H{"account": acc, "test_result": testResult})
  345. }
  346. // runTest calls Client.Connect + waits for ready — no GetMe, kept minimal.
  347. func (h *TgAccountHandler) runTest(ctx context.Context, acc *model.TgAccount) gin.H {
  348. client := telegram.New(telegram.Account{
  349. Phone: acc.Phone,
  350. SessionFile: acc.SessionFile,
  351. AppID: acc.AppID,
  352. AppHash: acc.AppHash,
  353. Device: acc.Device,
  354. AppVersion: acc.AppVersion,
  355. SystemVersion: acc.SDK,
  356. LangPack: acc.LangPack,
  357. LangCode: acc.LangCode,
  358. SystemLangCode: acc.SystemLangCode,
  359. })
  360. tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
  361. defer cancel()
  362. if err := client.Connect(tctx); err != nil {
  363. if fwe, ok := err.(*telegram.FloodWaitError); ok {
  364. return gin.H{"status": "cooling", "message": fmt.Sprintf("FloodWait: 需等待 %d 秒", fwe.Seconds)}
  365. }
  366. return gin.H{"status": "fail", "error": err.Error()}
  367. }
  368. client.Disconnect()
  369. return gin.H{"status": "ok", "message": "连接成功"}
  370. }
  371. func detectTopPrefix(files []*multipart.FileHeader) string {
  372. if len(files) == 0 {
  373. return ""
  374. }
  375. first := files[0].Filename
  376. idx := strings.IndexAny(first, "/\\")
  377. if idx <= 0 {
  378. return ""
  379. }
  380. return first[:idx]
  381. }
  382. func normalizeRel(filename, topPrefix string) string {
  383. // Convert backslashes to forward slashes for consistent path handling.
  384. p := strings.ReplaceAll(filename, "\\", "/")
  385. // Strip the top-level directory name so e.g. "13252753163/tdata/key_datas"
  386. // becomes "tdata/key_datas".
  387. if topPrefix != "" && strings.HasPrefix(p, topPrefix+"/") {
  388. p = strings.TrimPrefix(p, topPrefix+"/")
  389. }
  390. // Use path.Clean (forward-slash aware) to resolve . and .. components,
  391. // then reject any result that escapes the staging root.
  392. cleaned := path.Clean(p)
  393. if cleaned == "." || cleaned == ".." {
  394. return ""
  395. }
  396. if strings.HasPrefix(cleaned, "../") {
  397. return ""
  398. }
  399. if filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "/") {
  400. return ""
  401. }
  402. return cleaned
  403. }
  404. func saveUpload(fh *multipart.FileHeader, dst string) error {
  405. src, err := fh.Open()
  406. if err != nil {
  407. return err
  408. }
  409. defer src.Close()
  410. out, err := os.Create(dst)
  411. if err != nil {
  412. return err
  413. }
  414. defer out.Close()
  415. _, err = io.Copy(out, src)
  416. return err
  417. }
  418. // reloadAccounts loads enabled TG accounts from DB and reinitializes the account manager.
  419. func (h *TgAccountHandler) reloadAccounts() {
  420. var dbAccounts []model.TgAccount
  421. h.store.DB.Where("enabled = ?", true).Find(&dbAccounts)
  422. accounts := make([]telegram.Account, 0, len(dbAccounts))
  423. for _, a := range dbAccounts {
  424. accounts = append(accounts, telegram.Account{
  425. Phone: a.Phone,
  426. SessionFile: a.SessionFile,
  427. AppID: a.AppID,
  428. AppHash: a.AppHash,
  429. Device: a.Device,
  430. AppVersion: a.AppVersion,
  431. SystemVersion: a.SDK,
  432. LangPack: a.LangPack,
  433. LangCode: a.LangCode,
  434. SystemLangCode: a.SystemLangCode,
  435. })
  436. }
  437. h.tgManager.Init(accounts)
  438. }
  439. // RevealTwoFA returns the decrypted 2FA password for an account.
  440. // Admin-only; every call is audit-logged.
  441. func (h *TgAccountHandler) RevealTwoFA(c *gin.Context) {
  442. id, err := strconv.ParseUint(c.Param("id"), 10, 64)
  443. if err != nil {
  444. Fail(c, 400, "invalid id")
  445. return
  446. }
  447. var acc model.TgAccount
  448. if err := h.store.DB.First(&acc, id).Error; err != nil {
  449. Fail(c, 404, "账号不存在")
  450. return
  451. }
  452. if len(acc.TwoFAEnc) == 0 {
  453. Fail(c, 404, "该账号未存储 2FA")
  454. return
  455. }
  456. if h.crypto == nil {
  457. Fail(c, 500, "crypto not configured")
  458. return
  459. }
  460. plain, err := h.crypto.Decrypt(acc.TwoFAEnc)
  461. if err != nil {
  462. Fail(c, 500, "decrypt: "+err.Error())
  463. return
  464. }
  465. LogAudit(h.store, c, "reveal_2fa", "tg_account", fmt.Sprintf("%d", id), nil)
  466. OK(c, gin.H{"password": plain})
  467. }