importer.go 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. package sessionimport
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "os"
  9. "path"
  10. "path/filepath"
  11. "strings"
  12. "github.com/gotd/td/session"
  13. )
  14. // PyrogramMeta is the parsed content of <phone>.json inside a 协议号 folder.
  15. // Unknown fields are ignored.
  16. type PyrogramMeta struct {
  17. Phone string `json:"phone"`
  18. APIID int `json:"api_id"`
  19. APIHash string `json:"api_hash"`
  20. AppID int `json:"app_id"` // some generators use app_id/app_hash instead
  21. AppHash string `json:"app_hash"`
  22. Device string `json:"device"`
  23. AppVersion string `json:"app_version"`
  24. SDK string `json:"sdk"`
  25. LangPack string `json:"lang_pack"`
  26. LangCode string `json:"lang_code"`
  27. SystemLangCode string `json:"system_lang_code"`
  28. FirstName string `json:"first_name"`
  29. LastName string `json:"last_name"`
  30. Username string `json:"username"`
  31. TwoFA string `json:"twoFA"`
  32. }
  33. // Normalize resolves API credential aliasing. 协议号 JSON files use api_id/api_hash
  34. // or app_id/app_hash interchangeably; we coalesce them.
  35. func (m *PyrogramMeta) Normalize() {
  36. if m.APIID == 0 && m.AppID != 0 {
  37. m.APIID = m.AppID
  38. }
  39. if m.APIHash == "" && m.AppHash != "" {
  40. m.APIHash = m.AppHash
  41. }
  42. }
  43. // Result is the outcome of importing one protocol-number folder.
  44. type Result struct {
  45. Meta PyrogramMeta
  46. TwoFAPlaintext string // from 2fa_password.txt if Meta.TwoFA empty
  47. SessionData *session.Data
  48. UsedSource string // "pyrogram" or "tdata"
  49. SessionFile string // sessions/<phone>.json
  50. OriginDir string // sessions/<phone>.origin/
  51. PyrogramErr string // empty on success
  52. TdataErr string // empty on success or if tdata absent
  53. }
  54. // StagedFile is one file extracted from a multipart upload, already written to a
  55. // temp directory. RelPath preserves the browser's webkitRelativePath minus the
  56. // top-level folder name (so e.g. "13252753163/tdata/key_datas" becomes
  57. // "tdata/key_datas").
  58. type StagedFile struct {
  59. RelPath string
  60. AbsPath string
  61. }
  62. // Import parses staged files from a 协议号 folder, converts the session, and
  63. // materializes sessions/<phone>.json + origin/ backup under sessionsDir.
  64. // The caller (HTTP handler) persists the returned metadata to the DB.
  65. //
  66. // Precondition: all StagedFile.AbsPath must exist on disk. stagedFiles is the
  67. // output of the handler's multipart parser.
  68. func Import(ctx context.Context, sessionsDir string, stagedFiles []StagedFile) (*Result, error) {
  69. if len(stagedFiles) == 0 {
  70. return nil, errors.New("no files uploaded")
  71. }
  72. var metaPath, sessionPath, twoFAPath, tdataDir string
  73. for _, f := range stagedFiles {
  74. cleanedRel := safeRel(f.RelPath)
  75. if cleanedRel == "" {
  76. continue
  77. }
  78. rel := cleanedRel
  79. switch {
  80. case filepath.Ext(rel) == ".json" && !strings.ContainsAny(rel, "/"):
  81. metaPath = f.AbsPath
  82. case filepath.Ext(rel) == ".session":
  83. sessionPath = f.AbsPath
  84. case filepath.Base(rel) == "2fa_password.txt":
  85. twoFAPath = f.AbsPath
  86. case strings.HasPrefix(rel, "tdata/"):
  87. // Compute tdata dir from AbsPath + RelPath to avoid an infinite
  88. // walk-up loop at filesystem root (e.g. filepath.Dir("C:\\") == "C:\\").
  89. absSlash := filepath.ToSlash(f.AbsPath)
  90. if strings.HasSuffix(absSlash, rel) {
  91. stagingRoot := strings.TrimSuffix(absSlash, rel)
  92. tdataDir = filepath.FromSlash(stagingRoot + "tdata")
  93. }
  94. }
  95. }
  96. if metaPath == "" {
  97. return nil, errors.New("missing <phone>.json in upload")
  98. }
  99. if sessionPath == "" && tdataDir == "" {
  100. return nil, errors.New("upload has neither .session nor tdata/")
  101. }
  102. meta, err := readMeta(metaPath)
  103. if err != nil {
  104. return nil, err
  105. }
  106. meta.Normalize()
  107. if meta.Phone == "" {
  108. return nil, errors.New("<phone>.json missing phone field")
  109. }
  110. if meta.APIID == 0 || meta.APIHash == "" {
  111. return nil, errors.New("<phone>.json missing api_id / api_hash")
  112. }
  113. res := &Result{Meta: meta}
  114. // Prefer 2FA from JSON; fall back to 2fa_password.txt.
  115. if meta.TwoFA == "" && twoFAPath != "" {
  116. b, err := os.ReadFile(twoFAPath)
  117. if err == nil {
  118. res.TwoFAPlaintext = sanitizeTwoFA(string(b))
  119. }
  120. } else {
  121. res.TwoFAPlaintext = meta.TwoFA
  122. }
  123. // Try Pyrogram first.
  124. if sessionPath != "" {
  125. data, err := ConvertPyrogramSession(sessionPath)
  126. if err == nil {
  127. res.SessionData = data
  128. res.UsedSource = "pyrogram"
  129. } else {
  130. res.PyrogramErr = err.Error()
  131. }
  132. }
  133. // Fallback to tdata.
  134. if res.SessionData == nil && tdataDir != "" {
  135. data, err := ConvertTdataSession(tdataDir, res.TwoFAPlaintext)
  136. if err == nil {
  137. res.SessionData = data
  138. res.UsedSource = "tdata"
  139. } else {
  140. res.TdataErr = err.Error()
  141. }
  142. }
  143. if res.SessionData == nil {
  144. return res, fmt.Errorf("session conversion failed (pyrogram: %s; tdata: %s)",
  145. nonempty(res.PyrogramErr, "n/a"), nonempty(res.TdataErr, "n/a"))
  146. }
  147. // Materialize session JSON.
  148. if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
  149. return res, fmt.Errorf("create sessions dir: %w", err)
  150. }
  151. res.SessionFile = filepath.Join(sessionsDir, meta.Phone+".json")
  152. storage := &session.FileStorage{Path: res.SessionFile}
  153. loader := session.Loader{Storage: storage}
  154. if err := loader.Save(ctx, res.SessionData); err != nil {
  155. return res, fmt.Errorf("save gotd session: %w", err)
  156. }
  157. // Materialize origin backup (best-effort; non-fatal).
  158. res.OriginDir = filepath.Join(sessionsDir, meta.Phone+".origin")
  159. _ = os.RemoveAll(res.OriginDir)
  160. if err := os.MkdirAll(res.OriginDir, 0o755); err == nil {
  161. for _, f := range stagedFiles {
  162. cleanedRel := safeRel(f.RelPath)
  163. if cleanedRel == "" {
  164. continue
  165. }
  166. dst := filepath.Join(res.OriginDir, cleanedRel)
  167. if err := os.MkdirAll(filepath.Dir(dst), 0o755); err == nil {
  168. _ = copyFile(f.AbsPath, dst)
  169. }
  170. }
  171. }
  172. return res, nil
  173. }
  174. func readMeta(path string) (PyrogramMeta, error) {
  175. var m PyrogramMeta
  176. b, err := os.ReadFile(path)
  177. if err != nil {
  178. return m, fmt.Errorf("read meta json: %w", err)
  179. }
  180. if err := json.Unmarshal(b, &m); err != nil {
  181. return m, fmt.Errorf("parse meta json: %w", err)
  182. }
  183. return m, nil
  184. }
  185. func sanitizeTwoFA(s string) string {
  186. // Trim CR/LF/space — 2fa_password.txt often has a trailing newline.
  187. for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r' || s[len(s)-1] == ' ') {
  188. s = s[:len(s)-1]
  189. }
  190. return s
  191. }
  192. // safeRel returns the cleaned RelPath if it stays inside the staging area,
  193. // or "" if the path tries to escape via "../", is absolute, or contains
  194. // suspicious tokens. Always skip the file when this returns "".
  195. func safeRel(p string) string {
  196. // Normalize to forward slashes for consistent inspection.
  197. p = filepath.ToSlash(p)
  198. cleaned := path.Clean(p) // forward-slash aware
  199. if cleaned == "." || cleaned == "" {
  200. return ""
  201. }
  202. if strings.HasPrefix(cleaned, "../") || cleaned == ".." {
  203. return ""
  204. }
  205. if filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "/") {
  206. return ""
  207. }
  208. return cleaned
  209. }
  210. func nonempty(s, alt string) string {
  211. if s == "" {
  212. return alt
  213. }
  214. return s
  215. }
  216. func copyFile(src, dst string) error {
  217. in, err := os.Open(src)
  218. if err != nil {
  219. return err
  220. }
  221. defer in.Close()
  222. out, err := os.Create(dst)
  223. if err != nil {
  224. return err
  225. }
  226. defer out.Close()
  227. _, err = io.Copy(out, in)
  228. return err
  229. }