Explorar el Código

feat(sessionimport): importer orchestration (pyrogram->tdata fallback + backup)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dot hace 2 semanas
padre
commit
953a98819c
Se han modificado 1 ficheros con 241 adiciones y 0 borrados
  1. 241 0
      internal/telegram/sessionimport/importer.go

+ 241 - 0
internal/telegram/sessionimport/importer.go

@@ -0,0 +1,241 @@
+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
+}