tg_account.go 12 KB

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