package main import ( "bufio" "context" "flag" "fmt" "os" "strings" "time" ) func runLogin(args []string) error { fs := flag.NewFlagSet("login", flag.ExitOnError) g := addGlobalFlags(fs) user := fs.String("user", "", "username (prompted if omitted)") pass := fs.String("password", "", "password (insecure; prefer $TINYFORGE_PASSWORD or the prompt)") fs.Usage = func() { fmt.Fprint(os.Stderr, "Usage: tinyforge login [--user U] [--password P] [--base-url URL]\n\n"+ "Authenticate against the server and cache the token.\n") fs.PrintDefaults() } if err := fs.Parse(args); err != nil { return err } sess, err := newSession(g) if err != nil { return err } username := *user if username == "" { username, err = promptLine("Username: ") if err != nil { return err } } password := *pass if password == "" { password = os.Getenv("TINYFORGE_PASSWORD") } if password == "" { password, err = promptPassword("Password: ") if err != nil { return err } } if username == "" || password == "" { return fmt.Errorf("username and password are required") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() var tok SessionToken body := map[string]string{"username": username, "password": password} if err := sess.client.doJSON(ctx, "POST", "/api/auth/login", body, &tok); err != nil { return err } // Persist the resolved base URL alongside the token so later commands need // no flags. The token file is written 0600 by saveConfig. sess.cfg.BaseURL = sess.client.baseURL sess.cfg.Token = tok.Token sess.cfg.ExpiresAt = tok.ExpiresAt if err := saveConfig(sess.configPath, sess.cfg); err != nil { return err } fmt.Printf("Logged in to %s as %s.\n", sess.client.baseURL, username) if exp := friendlyExpiry(tok.ExpiresAt); exp != "" { fmt.Printf("Token valid until %s.\n", exp) } return nil } func runLogout(args []string) error { fs := flag.NewFlagSet("logout", flag.ExitOnError) g := addGlobalFlags(fs) if err := fs.Parse(args); err != nil { return err } sess, err := newSession(g) if err != nil { return err } if sess.client.token == "" { fmt.Println("Not logged in.") return nil } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() // Best-effort server-side revocation; clear the local token regardless. revokeErr := sess.client.doJSON(ctx, "POST", "/api/auth/logout", nil, nil) sess.cfg.Token = "" sess.cfg.ExpiresAt = "" if err := saveConfig(sess.configPath, sess.cfg); err != nil { return err } if revokeErr != nil { fmt.Printf("Cleared local token (server revocation skipped: %v).\n", revokeErr) return nil } fmt.Println("Logged out.") return nil } // promptLine reads a single trimmed line from stdin. func promptLine(label string) (string, error) { fmt.Fprint(os.Stderr, label) r := bufio.NewReader(os.Stdin) line, err := r.ReadString('\n') if err != nil && line == "" { return "", fmt.Errorf("read input: %w", err) } return strings.TrimSpace(line), nil } // friendlyExpiry formats an RFC3339 expiry as a local time, best-effort. func friendlyExpiry(s string) string { if s == "" { return "" } t, err := time.Parse(time.RFC3339, s) if err != nil { return s } return t.Local().Format("2006-01-02 15:04 MST") }