package sessionimport import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path" "path/filepath" "strings" "github.com/gotd/td/session" ) // PyrogramMeta is the parsed content of .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/.json OriginDir string // sessions/.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/.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 { cleanedRel := safeRel(f.RelPath) if cleanedRel == "" { continue } rel := cleanedRel switch { case filepath.Ext(rel) == ".json" && !strings.ContainsAny(rel, "/"): metaPath = f.AbsPath case filepath.Ext(rel) == ".session": sessionPath = f.AbsPath case filepath.Base(rel) == "2fa_password.txt": twoFAPath = f.AbsPath case strings.HasPrefix(rel, "tdata/"): // Compute tdata dir from AbsPath + RelPath to avoid an infinite // walk-up loop at filesystem root (e.g. filepath.Dir("C:\\") == "C:\\"). absSlash := filepath.ToSlash(f.AbsPath) if strings.HasSuffix(absSlash, rel) { stagingRoot := strings.TrimSuffix(absSlash, rel) tdataDir = filepath.FromSlash(stagingRoot + "tdata") } } } if metaPath == "" { return nil, errors.New("missing .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(".json missing phone field") } if meta.APIID == 0 || meta.APIHash == "" { return nil, errors.New(".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 { cleanedRel := safeRel(f.RelPath) if cleanedRel == "" { continue } dst := filepath.Join(res.OriginDir, cleanedRel) 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 } // safeRel returns the cleaned RelPath if it stays inside the staging area, // or "" if the path tries to escape via "../", is absolute, or contains // suspicious tokens. Always skip the file when this returns "". func safeRel(p string) string { // Normalize to forward slashes for consistent inspection. p = filepath.ToSlash(p) cleaned := path.Clean(p) // forward-slash aware if cleaned == "." || cleaned == "" { return "" } if strings.HasPrefix(cleaned, "../") || cleaned == ".." { return "" } if filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "/") { return "" } return cleaned } 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 }