package app import ( "bytes" "crypto/rand" "database/sql" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" "time" "github.com/PuerkitoBio/goquery" ) const lodestoneCharacterURLPrefix = "https://na.finalfantasyxiv.com/lodestone/character/" const lodestoneProcessInterval = 30 const avatarImageExt = "jpg" const ( characterStatusSyncPending = "sync_pending" characterStatusSyncFailed = "sync_failed" characterStatusUnverified = "unverified" characterStatusVerified = "verified" ) var lodestoneSyncQueue = make([]int64, 0) func sanitizeLodestoneID(lodestoneID string) string { if strings.HasPrefix(lodestoneID, lodestoneCharacterURLPrefix) { lodestoneID = strings.ReplaceAll(lodestoneID, lodestoneCharacterURLPrefix, "") } lodestoneID = strings.TrimRight(lodestoneID, "/") return lodestoneID } func getAddCharacterURL(characterID int64) string { if characterID == 0 { return "/c/new" } return fmt.Sprintf("/c/%s/verify", encodeIDToBase36(characterID)) } func getDeleteCharacterURL(characterID int64) string { return fmt.Sprintf("/c/%s/delete", encodeIDToBase36(characterID)) } func fetchLodestoneCharacterBytes(lodestoneID string) ([]byte, error) { resp, err := http.Get(lodestoneCharacterURLPrefix + lodestoneID) if err != nil { return nil, err } defer resp.Body.Close() return io.ReadAll(resp.Body) } func parseLodestoneCharacterBytes(data []byte, character *CharacterRecord) error { // parse html reader := bytes.NewReader(data) doc, err := goquery.NewDocumentFromReader(reader) if err != nil { return err } // fetch name name := strings.TrimSpace(doc.Find(".frame__chara__name").First().Text()) if name == "" { return errLodestoneError } nameSplit := strings.Split(name, " ") if len(nameSplit) <= 1 { return errLodestoneError } character.FirstName = nameSplit[0] character.LastName = nameSplit[1] // fetch world world := strings.TrimSpace(doc.Find(".frame__chara__world").First().Text()) if world == "" { return errLodestoneError } character.World = strings.Split(world, " ")[0] // fetch avatar avatarURL, _ := doc.Find(".frame__chara__face img").First().Attr("src") if avatarURL == "" { return errLodestoneError } character.AvatarURL = avatarURL // check verify code character.Status = characterStatusVerified if character.VerifyCode != "" { character.Status = characterStatusUnverified if bytes.Contains(data, []byte(character.VerifyCode)) { character.Status = characterStatusVerified character.VerifyCode = "" } } return nil } func syncCharacterWithLodestone(ID int64) error { // fetch character character, err := fetchCharacterRecordByID(db, ID) if err != nil { return err } if character.LodestoneID == "" { return errLodestoneError } // sync from lodestone data, err := fetchLodestoneCharacterBytes(character.LodestoneID) if err != nil { character.Status = characterStatusSyncFailed updateCharacterRecord(db, &character) return err } if err := parseLodestoneCharacterBytes(data, &character); err != nil { character.Status = characterStatusSyncFailed updateCharacterRecord(db, &character) return err } // save update if err := updateCharacterRecord(db, &character); err != nil { return err } return nil } func addCharacter(userID int64, lodestoneID string, verifyCode string) (CharacterRecord, error) { // lodestone id can be provided as url which contains the id, so strip it out lodestoneID = sanitizeLodestoneID(lodestoneID) log.Printf(" >> add character %s for user %d", lodestoneID, userID) // fetch existing character character, err := fetchCharacterRecordByLodestoneID(db, lodestoneID) if err != nil && err != sql.ErrNoRows { return CharacterRecord{}, err } // if character already has user id that doesn't match then it is already claimed if character.UserID > 0 && character.UserID != userID { return CharacterRecord{}, errCharacterClaimed } // set values character.VerifyCode = verifyCode character.Status = characterStatusUnverified character.LodestoneID = lodestoneID character.UserID = userID // save character if err := updateCharacterRecord(db, &character); err != nil { return CharacterRecord{}, err } return character, nil } func addCharacterToLodestoneQueue(ID int64) error { log.Printf(" >> add character %d to lodestone sync queue", ID) character, err := fetchCharacterRecordByID(db, ID) if err != nil { return err } if character.LodestoneID == "" { return errLodestoneError } character.Status = characterStatusSyncPending if err := updateCharacterRecord(db, &character); err != nil { return err } lodestoneSyncQueue = append(lodestoneSyncQueue, ID) return nil } func startLodestoneQueue() { ticker := time.NewTicker(lodestoneProcessInterval * time.Second) quit := make(chan struct{}) go func() { var characterID int64 for { select { case <-ticker.C: if len(lodestoneSyncQueue) > 0 { characterID, lodestoneSyncQueue = lodestoneSyncQueue[0], lodestoneSyncQueue[1:] log.Printf("QUEUE: Sync character %d from lodestone", characterID) if err := syncCharacterWithLodestone(characterID); err != nil { log.Println(" >> ERROR: ", err) } } case <-quit: ticker.Stop() return } } }() } func formSubmitAddCharacterSubmit(w http.ResponseWriter, r *http.Request, form *Form) error { user, err := getCurrentUserFromSession(r) if err != nil { return err } verifyCodeField := form.GetFieldByName("verify_code") if verifyCodeField == nil || verifyCodeField.IsEmpty() { return errFormInvalid } verifyCode := verifyCodeField.Values[0] lodestoneIDField := form.GetFieldByName("lodestone_id") if lodestoneIDField == nil || lodestoneIDField.IsEmpty() { return errFormInvalid } lodestoneID := lodestoneIDField.Values[0] character, err := addCharacter(user.ID, lodestoneID, verifyCode) if err != nil { return err } if err := addCharacterToLodestoneQueue(character.ID); err != nil { return err } http.Redirect(w, r, "/user", http.StatusTemporaryRedirect) return nil } func httpHandleAddCharacter(w http.ResponseWriter, r *http.Request, characterID int64) { // must be logged in user, err := getCurrentUserFromSession(r) if err != nil { httpHandleError(w, err) return } // prepopulate lodestone ID if character ID is set lodestoneURL := "" if characterID > 0 { character, err := fetchCharacterRecordByID(db, characterID) if err != nil { httpHandleError(w, err) return } lodestoneURL = lodestoneCharacterURLPrefix + character.LodestoneID } // build form addCharacterForm := Form{ Name: "add_character", Title: "Add Character", Header: ` Add the following to your Lodestone character profile: {verifyCode} Then copy your Lodestone character URL below. `, SubmitHandlers: []string{"add_character"}, Fields: []*FormField{ {ID: "verify_code", Type: "hidden", Values: []string{}}, {ID: "lodestone_id", Label: "Lodestone URL", Type: "text", Required: true, Values: []string{lodestoneURL}, Placeholder: lodestoneCharacterURLPrefix + "123456", Validators: []string{"lodestone_url"}}}, } // generate verify code prior to submit if r.Method != "POST" { verifyCode := hashString(fmt.Sprintf("--%d-%s-%s", user.ID, getUserClientID(r), rand.Text())) addCharacterForm.GetFieldByName("verify_code").Values = []string{verifyCode} } if _, err := addCharacterForm.Submit(w, r); err != nil { httpHandleError(w, err) return } // inject verify code in to form header text verifyCodeField := addCharacterForm.GetFieldByName("verify_code") if verifyCodeField == nil || verifyCodeField.IsEmpty() { httpHandleError(w, errFormInvalid) return } verifyCode := verifyCodeField.Values[0] addCharacterForm.Header = strings.ReplaceAll(addCharacterForm.Header, "{verifyCode}", verifyCode) addCharacterForm.Render(w, r) } func getCharacterAvatarPathByID(ID int64) string { return filepath.Join(avatarImagePath, fmt.Sprintf("%d.%s", ID, avatarImageExt)) } func getCharacterAvatarURL(ID int64) string { return fmt.Sprintf("/c/%s.%s", encodeIDToBase36(ID), avatarImageExt) } func saveCharacterAvatar(ID int64, avatarURL string) error { log.Printf(" >> download avatar for character %d", ID) os.MkdirAll(avatarImagePath, 0777) c := http.Client{} resp, err := c.Get(avatarURL) if err != nil { return err } defer resp.Body.Close() file, err := os.Create(getCharacterAvatarPathByID(ID)) if err != nil { return err } defer file.Close() log.Println(getCharacterAvatarPathByID(ID)) if _, err := io.Copy(file, resp.Body); err != nil { return err } return nil } func hasCharacterAvatar(ID int64) bool { _, err := os.Stat(getCharacterAvatarPathByID(ID)) return !os.IsNotExist(err) } func httpHandleCharacterAvatar(w http.ResponseWriter, r *http.Request, ID int64) { if !hasCharacterAvatar(ID) { character, err := fetchCharacterRecordByID(db, ID) if err != nil { httpHandleError(w, err) return } if character.AvatarURL == "" { httpHandleError(w, errHttpNotFound) return } if err := saveCharacterAvatar(ID, character.AvatarURL); err != nil { httpHandleError(w, err) return } } pathTo := getCharacterAvatarPathByID(ID) log.Printf(" >> serve character avatar image %s", pathTo) w.Header().Set("Content-Type", fmt.Sprintf("image/%s", avatarImageExt)) w.Header().Set("Cache-Control", "public, max-age=86400") http.ServeFile(w, r, pathTo) } /* Handle character path. */ func httpBaseHandleCharacter(w http.ResponseWriter, r *http.Request, path []string) bool { if path[0] == "c" || path[0] == "character" { if len(path) < 2 { httpHandleError(w, errHttpNotFound) return true } if path[1] == "new" { httpHandleAddCharacter(w, r, 0) return true } characterID := getIDFromBase36(strings.TrimSuffix(path[1], "."+avatarImageExt)) if len(path) > 2 && path[2] == "verify" { httpHandleAddCharacter(w, r, characterID) return true } if len(path) > 2 && path[2] == "delete" { httpHandleRecordDelete(w, r, CharacterRecord{}, characterID) return true } // character avatar image if strings.HasSuffix(path[1], "."+avatarImageExt) { httpHandleCharacterAvatar(w, r, characterID) return true } httpHandleError(w, errHttpNotFound) return true } return false }