package main import ( "encoding/json" "errors" "fmt" "os" "strings" "time" "github.com/fsnotify/fsnotify" lm "github.com/hrfee/jfa-go/logmessages" pollingWatcher "github.com/radovskyb/watcher" ) const ( RetryCount = 2 RetryInterval = time.Second ) // GenInternalReset generates a local password reset PIN, for use with the PWR option on the Admin page. func (app *appContext) GenInternalReset(userID string) (InternalPWR, error) { pin := genAuthToken() user, err := app.jf.UserByID(userID, false) if err != nil { return InternalPWR{}, err } pwr := InternalPWR{ PIN: pin, Username: user.Name, ID: userID, Expiry: time.Now().Add(30 * time.Minute), } return pwr, nil } // GenResetLink generates and returns a password reset link. func GenResetLink(pin string) (string, error) { url := ExternalURI(nil) var pinLink string if url == "" { return pinLink, errors.New(lm.NoExternalHost) } // Strip /invite from end of this URL, ik it's ugly. pinLink = fmt.Sprintf("%s/reset?pin=%s", url, pin) return pinLink, nil } func (app *appContext) StartPWR() { app.info.Printf(lm.StartDaemon, "PWR") path := app.config.Section("password_resets").Key("watch_directory").String() if _, err := os.Stat(path); os.IsNotExist(err) { app.err.Printf(lm.FailedStartDaemon, "PWR", fmt.Sprintf(lm.PathNotFound, path)) return } usePolling := app.config.Section("password_resets").Key("watch_polling").MustBool(false) if !messagesEnabled { return } if usePolling { watcher := pollingWatcher.New() watcher.FilterOps(pollingWatcher.Write) go monitorPolling(app, watcher) if err := watcher.Add(path); err != nil { app.err.Printf(lm.FailedStartDaemon, "PWR (polling)", err) } if err := watcher.Start(time.Second * 5); err != nil { app.err.Printf(lm.FailedStartDaemon, "PWR (polling)", err) } } else { watcher, err := fsnotify.NewWatcher() if err != nil { app.err.Printf(lm.FailedStartDaemon, "PWR", err) return } defer watcher.Close() go monitorFS(app, watcher) err = watcher.Add(path) if err != nil { app.err.Printf(lm.FailedStartDaemon, "PWR", err) } } waitForRestart() } // PasswordReset represents a passwordreset-xyz.json file generated by Jellyfin. type PasswordReset struct { Pin string `json:"Pin"` Username string `json:"UserName"` Expiry time.Time `json:"ExpirationDate"` Internal bool `json:"Internal,omitempty"` } func validatePWR(app *appContext, fname string, attempt int) { currentTime := time.Now() if !strings.Contains(fname, "passwordreset") { return } var pwr PasswordReset data, err := os.ReadFile(fname) if err != nil { app.debug.Printf(lm.FailedReading, fname, err) return } err = json.Unmarshal(data, &pwr) if len(pwr.Pin) == 0 || err != nil { app.debug.Printf(lm.FailedReading, fname, err) return } app.info.Printf(lm.NewPWRForUser, pwr.Username) if pwr.Expiry.Before(currentTime) { app.err.Printf(lm.PWRExpired, pwr.Username, pwr.Expiry) return } user, err := app.jf.UserByName(pwr.Username, false) if err != nil || user.ID == "" { app.err.Printf(lm.FailedGetUser, pwr.Username, lm.Jellyfin, err) return } name := app.getAddressOrName(user.ID) if name != "" { msg, err := app.email.constructReset(pwr, false) if err != nil { app.err.Printf(lm.FailedConstructPWRMessage, pwr.Username, err) } else if err := app.sendByID(msg, user.ID); err != nil { app.err.Printf(lm.FailedSendPWRMessage, pwr.Username, name, err) } else { app.err.Printf(lm.SentPWRMessage, pwr.Username, name) } } } func monitorFS(app *appContext, watcher *fsnotify.Watcher) { for { select { case event, ok := <-watcher.Events: if !ok { return } if event.Has(fsnotify.Write) { validatePWR(app, event.Name, 0) } case err, ok := <-watcher.Errors: if !ok { return } app.err.Printf(lm.FailedStartDaemon, "PWR", err) } } } func monitorPolling(app *appContext, watcher *pollingWatcher.Watcher) { for { select { case event := <-watcher.Event: validatePWR(app, event.Path, 0) case err := <-watcher.Error: app.err.Printf(lm.FailedStartDaemon, "PWR (polling)", err) return case <-watcher.Closed: return } } }