package auth import ( "bytes" "crypto/sha1" "encoding/hex" "fmt" "io" "net/http" "os" "path/filepath" "strings" "ffxiv_tiny_launcher/sqex" ) // The OAuth login returns a session id that is only used to register the // client with the version server. The value the game actually presents to the // lobby (DEV.TestSID) is the X-Patch-Unique-Id header returned by that // registration — mirroring FFXIVQuickLauncher, which passes LoginResult.UniqueId // as the TestSID argument and keeps OAuth session ids out of the launch args. const patcherUserAgent = "FFXIV PATCH CLIENT" var bootFilesToHash = []string{ "ffxivboot.exe", "ffxivboot64.exe", "ffxivlauncher64.exe", "ffxivupdater64.exe", } // RegisterSession posts the version report to patch-gamever for the given // OAuth session id and returns the unique id the game must use as its TestSID. // A non-empty response body is SE's list of pending patches; it is parsed and // returned so the caller can tell the user their client needs updating (we do // not apply patches ourselves — that job belongs to the official launcher). func RegisterSession(sessionId, gamePath string, maxExpansion int) (string, []sqex.PatchEntry, error) { gameVer := readTrimmed(filepath.Join(gamePath, "game", "ffxivgame.ver")) if gameVer == "" { return "", nil, fmt.Errorf("cannot read %s/game/ffxivgame.ver (empty or missing)", gamePath) } report := buildVersionReport(gamePath, maxExpansion) req, err := http.NewRequest(http.MethodPost, "https://patch-gamever.ffxiv.com/http/win32/ffxivneo_release_game/"+gameVer+"/"+sessionId, bytes.NewReader([]byte(report))) if err != nil { return "", nil, fmt.Errorf("building version request: %w", err) } req.Header.Set("Connection", "Keep-Alive") req.Header.Set("User-Agent", patcherUserAgent) req.Header.Set("X-Hash-Check", "enabled") resp, err := httpClient.Do(req) if err != nil { return "", nil, fmt.Errorf("version registration request: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) switch { case resp.StatusCode == http.StatusConflict: return "", nil, fmt.Errorf("server rejected the boot version (409); update the launcher/patcher files in game/boot") case resp.StatusCode == http.StatusGone: return "", nil, fmt.Errorf("game version %s is no longer serviced by the server (410)", gameVer) } if name := resp.Header.Get("X-Patch-Error-Name"); name != "" { return "", nil, fmt.Errorf("version registration rejected: %s (code %s, HTTP %d)", name, resp.Header.Get("X-Patch-Error-Code"), resp.StatusCode) } uid := resp.Header.Get("X-Patch-Unique-Id") if uid == "" { return "", nil, fmt.Errorf("version registration returned no X-Patch-Unique-Id (HTTP %d: %.200s)", resp.StatusCode, body) } var patches []sqex.PatchEntry if bodyText := strings.TrimSpace(string(body)); bodyText != "" { patches, err = sqex.ParsePatchList(bodyText) if err != nil { return "", nil, fmt.Errorf("server reported pending patches but the list could not be parsed (HTTP %d): %v; raw:\n%s", resp.StatusCode, err, body) } } return uid, patches, nil } // buildVersionReport mirrors Launcher.GetVersionReport: the boot version hash // on line one, then "exN\t" lines for each entitled expansion (max 5). func buildVersionReport(gamePath string, maxExpansion int) string { var b bytes.Buffer fmt.Fprintf(&b, "%s\n", bootVersionHash(gamePath)) for n := 1; n <= 5 && n <= maxExpansion; n++ { p := filepath.Join(gamePath, "game", "sqpack", fmt.Sprintf("ex%d", n), fmt.Sprintf("ex%d.ver", n)) v := readTrimmed(p) if v == "" { fmt.Fprintf(os.Stderr, "[ffxiv-launcher] warning: %s missing/empty; reporting base version for ex%d\n", p, n) v = "2012.01.01.0000.0000" // BASE_GAME_VERSION; matches QL's forceBaseVersion fallback } fmt.Fprintf(&b, "ex%d\t%s\n", n, v) } return b.String() } // bootVersionHash mirrors Launcher.GetBootVersionHash: "=" followed by // comma-joined "//" entries for the boot executables. func bootVersionHash(gamePath string) string { bootVer := readTrimmed(filepath.Join(gamePath, "boot", "ffxivboot.ver")) var parts []string for _, name := range bootFilesToHash { p := filepath.Join(gamePath, "boot", name) data, err := os.ReadFile(p) if err != nil { fmt.Fprintf(os.Stderr, "[ffxiv-launcher] warning: cannot hash %s (%v); omitting from version report\n", p, err) continue } sum := sha1.Sum(data) parts = append(parts, fmt.Sprintf("%s/%d/%s", name, len(data), hex.EncodeToString(sum[:]))) } return bootVer + "=" + strings.Join(parts, ",") } func readTrimmed(path string) string { b, err := os.ReadFile(path) if err != nil { return "" } s := string(b) for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { s = s[:len(s)-1] } return s }