pi-agent: Implement mature Profile editor
This commit is contained in:
+119
-41
@@ -37,11 +37,65 @@ type Store interface {
|
||||
Mutate(func(State) (State, error)) (State, error)
|
||||
}
|
||||
|
||||
func ValidateAndNormalize(input Profile) (Profile, error) {
|
||||
// Field identifies the Profile input responsible for a validation error.
|
||||
// Presentation layers use it to keep feedback attached to the field a user can
|
||||
// correct instead of parsing canonical error strings.
|
||||
type Field string
|
||||
|
||||
const (
|
||||
NameField Field = "name"
|
||||
ModeField Field = "mode"
|
||||
SingleURLField Field = "single-url"
|
||||
HTTPURLField Field = "http-url"
|
||||
HTTPSURLField Field = "https-url"
|
||||
AllURLField Field = "all-url"
|
||||
BypassField Field = "bypass"
|
||||
)
|
||||
|
||||
// BypassItemValidation describes one delimited Bypass item. Valid items retain
|
||||
// their canonical value even when another item is invalid, enabling a truthful
|
||||
// live preview without weakening save validation.
|
||||
type BypassItemValidation struct {
|
||||
Index int
|
||||
Input string
|
||||
Normalized string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Validation is the shared canonical validation result used by forms and save.
|
||||
// Normalized is complete only when Err returns nil; BypassItems always contains
|
||||
// the independently interpreted Bypass entries.
|
||||
type Validation struct {
|
||||
Normalized Profile
|
||||
FieldErrors map[Field]error
|
||||
BypassItems []BypassItemValidation
|
||||
firstError error
|
||||
}
|
||||
|
||||
func (v Validation) Err() error { return v.firstError }
|
||||
|
||||
func (v Validation) FieldError(field Field) error { return v.FieldErrors[field] }
|
||||
|
||||
// ValidateFields performs the same validation and normalization as
|
||||
// ValidateAndNormalize while retaining structured, field-specific feedback.
|
||||
func ValidateFields(input Profile) Validation {
|
||||
p := input
|
||||
validation := Validation{FieldErrors: make(map[Field]error)}
|
||||
setError := func(field Field, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if validation.FieldErrors[field] == nil {
|
||||
validation.FieldErrors[field] = err
|
||||
}
|
||||
if validation.firstError == nil {
|
||||
validation.firstError = err
|
||||
}
|
||||
}
|
||||
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
if p.Name == "" {
|
||||
return Profile{}, errors.New("Profile name is required")
|
||||
setError(NameField, errors.New("Profile name is required"))
|
||||
}
|
||||
if p.Mode == "" {
|
||||
p.Mode = SingleMode
|
||||
@@ -49,43 +103,65 @@ func ValidateAndNormalize(input Profile) (Profile, error) {
|
||||
switch p.Mode {
|
||||
case SingleMode:
|
||||
value, err := validateURL("Single proxy URL", p.SingleURL)
|
||||
if err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
p.SingleURL = value
|
||||
p.HTTPURL, p.HTTPSURL, p.AllURL = value, value, value
|
||||
case AdvancedMode:
|
||||
if p.HTTPURL == "" && p.HTTPSURL == "" && p.AllURL == "" && strings.TrimSpace(p.SingleURL) != "" {
|
||||
value, err := validateURL("Single proxy URL", p.SingleURL)
|
||||
if err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
setError(SingleURLField, err)
|
||||
if err == nil {
|
||||
p.SingleURL = value
|
||||
p.HTTPURL, p.HTTPSURL, p.AllURL = value, value, value
|
||||
}
|
||||
case AdvancedMode:
|
||||
if p.HTTPURL == "" && p.HTTPSURL == "" && p.AllURL == "" && strings.TrimSpace(p.SingleURL) != "" {
|
||||
value, err := validateURL("Single proxy URL", p.SingleURL)
|
||||
setError(HTTPURLField, err)
|
||||
if err == nil {
|
||||
p.SingleURL = value
|
||||
p.HTTPURL, p.HTTPSURL, p.AllURL = value, value, value
|
||||
}
|
||||
}
|
||||
var err error
|
||||
if p.HTTPURL, err = validateOptionalURL("HTTP proxy URL", p.HTTPURL); err != nil {
|
||||
return Profile{}, err
|
||||
setError(HTTPURLField, err)
|
||||
}
|
||||
if p.HTTPSURL, err = validateOptionalURL("HTTPS proxy URL", p.HTTPSURL); err != nil {
|
||||
return Profile{}, err
|
||||
setError(HTTPSURLField, err)
|
||||
}
|
||||
if p.AllURL, err = validateOptionalURL("ALL/SOCKS proxy URL", p.AllURL); err != nil {
|
||||
return Profile{}, err
|
||||
setError(AllURLField, err)
|
||||
}
|
||||
if p.HTTPURL == "" && p.HTTPSURL == "" && p.AllURL == "" {
|
||||
return Profile{}, errors.New("at least one advanced proxy URL is required")
|
||||
if p.HTTPURL == "" && p.HTTPSURL == "" && p.AllURL == "" && validation.FieldErrors[HTTPURLField] == nil && validation.FieldErrors[HTTPSURLField] == nil && validation.FieldErrors[AllURLField] == nil {
|
||||
setError(HTTPURLField, errors.New("at least one advanced proxy URL is required"))
|
||||
}
|
||||
default:
|
||||
return Profile{}, errors.New("proxy mode must be single or advanced")
|
||||
setError(ModeField, errors.New("proxy mode must be single or advanced"))
|
||||
}
|
||||
|
||||
bypass, err := normalizeBypass(p.Bypass)
|
||||
if err != nil {
|
||||
seen := make(map[string]bool)
|
||||
canonicalBypass := make([]string, 0, len(p.Bypass))
|
||||
for _, raw := range p.Bypass {
|
||||
for _, item := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' }) {
|
||||
entry := BypassItemValidation{Index: len(validation.BypassItems) + 1, Input: strings.TrimSpace(item)}
|
||||
entry.Normalized, entry.Err = normalizeBypassEntry(item)
|
||||
validation.BypassItems = append(validation.BypassItems, entry)
|
||||
if entry.Err != nil {
|
||||
setError(BypassField, entry.Err)
|
||||
continue
|
||||
}
|
||||
if !seen[entry.Normalized] {
|
||||
seen[entry.Normalized] = true
|
||||
canonicalBypass = append(canonicalBypass, entry.Normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.Bypass = canonicalBypass
|
||||
validation.Normalized = p
|
||||
return validation
|
||||
}
|
||||
|
||||
func ValidateAndNormalize(input Profile) (Profile, error) {
|
||||
validation := ValidateFields(input)
|
||||
if err := validation.Err(); err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
p.Bypass = bypass
|
||||
return p, nil
|
||||
return validation.Normalized, nil
|
||||
}
|
||||
|
||||
func validateOptionalURL(field, value string) (string, error) {
|
||||
@@ -165,24 +241,6 @@ func validateURL(field, value string) (string, error) {
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func normalizeBypass(values []string) ([]string, error) {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]bool)
|
||||
for _, raw := range values {
|
||||
for _, item := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' }) {
|
||||
value, err := normalizeBypassEntry(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !seen[value] {
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeBypassEntry(raw string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
@@ -225,6 +283,26 @@ func ObscureURL(value string) string {
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// ObscureURLForEditing masks only password runes while preserving the input's
|
||||
// rune count. A text input clone can therefore retain its real cursor and
|
||||
// horizontal viewport positions without ever rendering the credential.
|
||||
func ObscureURLForEditing(value string) string {
|
||||
matches := credentialPattern.FindAllStringSubmatchIndex(value, -1)
|
||||
if len(matches) == 0 {
|
||||
return value
|
||||
}
|
||||
var masked strings.Builder
|
||||
last := 0
|
||||
for _, match := range matches {
|
||||
passwordStart, passwordEnd := match[6], match[7]
|
||||
masked.WriteString(value[last:passwordStart])
|
||||
masked.WriteString(strings.Repeat("•", len([]rune(value[passwordStart:passwordEnd]))))
|
||||
last = passwordEnd
|
||||
}
|
||||
masked.WriteString(value[last:])
|
||||
return masked.String()
|
||||
}
|
||||
|
||||
func looksLikeIPv4(host string) bool {
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) != 4 {
|
||||
|
||||
Reference in New Issue
Block a user