diff --git a/internal/api/v1/config_file.go b/internal/api/v1/config_file.go index d05c1db..7bb4f2f 100644 --- a/internal/api/v1/config_file.go +++ b/internal/api/v1/config_file.go @@ -1,6 +1,7 @@ package v1 import ( + "fmt" "io" "net/http" "os" @@ -51,12 +52,12 @@ func (t FileType) GetPath(filename string) string { func getArgs(r *http.Request) (fileType FileType, filename string, err error) { fileType = FileType(r.PathValue("type")) if !fileType.IsValid() { - err = gphttp.ErrInvalidKey("type") + err = fmt.Errorf("invalid file type: %s", fileType) return } filename = r.PathValue("filename") if filename == "" { - err = gphttp.ErrMissingKey("filename") + err = fmt.Errorf("missing filename") } return } diff --git a/internal/api/v1/favicon/favicon.go b/internal/api/v1/favicon/favicon.go index 13de01f..cb03fa8 100644 --- a/internal/api/v1/favicon/favicon.go +++ b/internal/api/v1/favicon/favicon.go @@ -1,10 +1,8 @@ package favicon import ( - "errors" "net/http" - "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/homepage" "github.com/yusing/go-proxy/internal/net/gphttp" "github.com/yusing/go-proxy/internal/route/routes" @@ -21,11 +19,11 @@ import ( func GetFavIcon(w http.ResponseWriter, req *http.Request) { url, alias := req.FormValue("url"), req.FormValue("alias") if url == "" && alias == "" { - gphttp.ClientError(w, gphttp.ErrMissingKey("url or alias"), http.StatusBadRequest) + gphttp.MissingKey(w, "url or alias") return } if url != "" && alias != "" { - gphttp.ClientError(w, gperr.New("url and alias are mutually exclusive"), http.StatusBadRequest) + gphttp.BadRequest(w, "url and alias are mutually exclusive") return } @@ -33,7 +31,7 @@ func GetFavIcon(w http.ResponseWriter, req *http.Request) { if url != "" { var iconURL homepage.IconURL if err := iconURL.Parse(url); err != nil { - gphttp.ClientError(w, err, http.StatusBadRequest) + gphttp.ClientError(w, req, err, http.StatusBadRequest) return } fetchResult := homepage.FetchFavIconFromURL(req.Context(), &iconURL) @@ -49,7 +47,7 @@ func GetFavIcon(w http.ResponseWriter, req *http.Request) { // try with route.Icon r, ok := routes.HTTP.Get(alias) if !ok { - gphttp.ClientError(w, errors.New("no such route"), http.StatusNotFound) + gphttp.ValueNotFound(w, "route", alias) return } diff --git a/internal/api/v1/list.go b/internal/api/v1/list.go index 77c23f8..c7339f5 100644 --- a/internal/api/v1/list.go +++ b/internal/api/v1/list.go @@ -69,7 +69,7 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { } icons, err := homepage.SearchIcons(r.FormValue("keyword"), limit) if err != nil { - gphttp.ClientError(w, err) + gphttp.ClientError(w, r, err) return } gphttp.RespondJSON(w, r, icons) diff --git a/internal/api/v1/new_agent.go b/internal/api/v1/new_agent.go index bf30dbd..46a1f7b 100644 --- a/internal/api/v1/new_agent.go +++ b/internal/api/v1/new_agent.go @@ -20,27 +20,27 @@ func NewAgent(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() name := q.Get("name") if name == "" { - gphttp.ClientError(w, gphttp.ErrMissingKey("name")) + gphttp.MissingKey(w, "name") return } host := q.Get("host") if host == "" { - gphttp.ClientError(w, gphttp.ErrMissingKey("host")) + gphttp.MissingKey(w, "host") return } portStr := q.Get("port") if portStr == "" { - gphttp.ClientError(w, gphttp.ErrMissingKey("port")) + gphttp.MissingKey(w, "port") return } port, err := strconv.Atoi(portStr) if err != nil || port < 1 || port > 65535 { - gphttp.ClientError(w, gphttp.ErrInvalidKey("port")) + gphttp.InvalidKey(w, "port") return } hostport := fmt.Sprintf("%s:%d", host, port) if _, ok := config.GetInstance().GetAgent(hostport); ok { - gphttp.ClientError(w, gphttp.ErrAlreadyExists("agent", hostport), http.StatusConflict) + gphttp.KeyAlreadyExists(w, "agent", hostport) return } t := q.Get("type") @@ -48,10 +48,10 @@ func NewAgent(w http.ResponseWriter, r *http.Request) { case "docker", "system": break case "": - gphttp.ClientError(w, gphttp.ErrMissingKey("type")) + gphttp.MissingKey(w, "type") return default: - gphttp.ClientError(w, gphttp.ErrInvalidKey("type")) + gphttp.InvalidKey(w, "type") return } @@ -109,13 +109,13 @@ func VerifyNewAgent(w http.ResponseWriter, r *http.Request) { } if err := json.Unmarshal(clientPEMData, &data); err != nil { - gphttp.ClientError(w, err, http.StatusBadRequest) + gphttp.ClientError(w, r, err) return } nRoutesAdded, err := config.GetInstance().VerifyNewAgent(data.Host, data.CA, data.Client) if err != nil { - gphttp.ClientError(w, err) + gphttp.ClientError(w, r, err) return } @@ -127,7 +127,7 @@ func VerifyNewAgent(w http.ResponseWriter, r *http.Request) { filename, ok := certs.AgentCertsFilepath(data.Host) if !ok { - gphttp.ClientError(w, gphttp.ErrInvalidKey("host")) + gphttp.InvalidKey(w, "host") return } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 14c7dbf..95e7c41 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -53,7 +53,7 @@ func RequireAuth(next http.HandlerFunc) http.HandlerFunc { r = r.WithContext(context.WithValue(r.Context(), nextHandlerContextKey, next)) defaultAuth.LoginHandler(w, r) } else { - gphttp.ClientError(w, err, http.StatusUnauthorized) + gphttp.Unauthorized(w, err.Error()) } return } diff --git a/internal/autocert/config.go b/internal/autocert/config.go index 4c1babf..0934d96 100644 --- a/internal/autocert/config.go +++ b/internal/autocert/config.go @@ -13,7 +13,6 @@ import ( "github.com/yusing/go-proxy/internal/gperr" "github.com/yusing/go-proxy/internal/logging" "github.com/yusing/go-proxy/internal/utils" - "github.com/yusing/go-proxy/internal/utils/strutils" ) type Config struct { @@ -70,7 +69,7 @@ func (cfg *Config) Validate() gperr.Error { if !ok { b.Add(ErrUnknownProvider. Subject(cfg.Provider). - Withf(strutils.DoYouMean(utils.NearestField(cfg.Provider, Providers)))) + With(gperr.DoYouMean(utils.NearestField(cfg.Provider, Providers)))) } else { _, err := providerConstructor(cfg.Options) if err != nil { diff --git a/internal/gperr/base.go b/internal/gperr/base.go index 96a36b3..82616c1 100644 --- a/internal/gperr/base.go +++ b/internal/gperr/base.go @@ -37,11 +37,11 @@ func (err *baseError) Subjectf(format string, args ...any) Error { } func (err baseError) With(extra error) Error { - return &nestedError{&err, []error{extra}} + return &nestedError{err.Err, []error{extra}} } func (err baseError) Withf(format string, args ...any) Error { - return &nestedError{&err, []error{fmt.Errorf(format, args...)}} + return &nestedError{err.Err, []error{fmt.Errorf(format, args...)}} } func (err *baseError) Error() string { @@ -62,3 +62,11 @@ func (err *baseError) MarshalJSON() ([]byte, error) { return json.Marshal(err.Error()) } } + +func (err *baseError) Plain() []byte { + return Plain(err.Err) +} + +func (err *baseError) Markdown() []byte { + return Markdown(err.Err) +} diff --git a/internal/gperr/builder_test.go b/internal/gperr/builder_test.go index 04aa326..ba1c443 100644 --- a/internal/gperr/builder_test.go +++ b/internal/gperr/builder_test.go @@ -50,6 +50,7 @@ func TestBuilderNested(t *testing.T) { • Inner: 1 • Inner: 2 • Action 2 - • Inner: 3` + • Inner: 3 +` ExpectEqual(t, got, expected) } diff --git a/internal/gperr/error.go b/internal/gperr/error.go index 6d3e70c..df94c92 100644 --- a/internal/gperr/error.go +++ b/internal/gperr/error.go @@ -20,6 +20,16 @@ type Error interface { Subject(subject string) Error // Subjectf is a wrapper for Subject(fmt.Sprintf(format, args...)). Subjectf(format string, args ...any) Error + PlainError + MarkdownError +} + +type PlainError interface { + Plain() []byte +} + +type MarkdownError interface { + Markdown() []byte } // this makes JSON marshaling work, diff --git a/internal/gperr/error_test.go b/internal/gperr/error_test.go index a14527f..5594bc4 100644 --- a/internal/gperr/error_test.go +++ b/internal/gperr/error_test.go @@ -153,6 +153,7 @@ func TestErrorStringNested(t *testing.T) { • 2 • action 3 > inner3: generic failure • 3 - • 3` + • 3 +` expect.Equal(t, ansi.StripANSI(ne.Error()), want) } diff --git a/internal/gperr/hint.go b/internal/gperr/hint.go new file mode 100644 index 0000000..95c87d1 --- /dev/null +++ b/internal/gperr/hint.go @@ -0,0 +1,43 @@ +package gperr + +import "github.com/yusing/go-proxy/internal/utils/strutils/ansi" + +type Hint struct { + Prefix string + Message string + Suffix string +} + +var _ PlainError = (*Hint)(nil) +var _ MarkdownError = (*Hint)(nil) + +func (h *Hint) Error() string { + return h.Prefix + ansi.Info(h.Message) + h.Suffix +} + +func (h *Hint) Plain() []byte { + return []byte(h.Prefix + h.Message + h.Suffix) +} + +func (h *Hint) Markdown() []byte { + return []byte(h.Prefix + "**" + h.Message + "**" + h.Suffix) +} + +func (h *Hint) MarshalText() ([]byte, error) { + return h.Plain(), nil +} + +func (h *Hint) String() string { + return h.Error() +} + +func DoYouMean(s string) *Hint { + if s == "" { + return nil + } + return &Hint{ + Prefix: "Do you mean ", + Message: s, + Suffix: "?", + } +} diff --git a/internal/gperr/log.go b/internal/gperr/log.go index 9e6f7fe..5be1bce 100644 --- a/internal/gperr/log.go +++ b/internal/gperr/log.go @@ -15,7 +15,7 @@ func log(msg string, err error, level zerolog.Level, logger ...*zerolog.Logger) } else { l = logging.GetLogger() } - l.WithLevel(level).Msg(New(highlight(msg)).With(err).Error()) + l.WithLevel(level).Msg(New(highlightANSI(msg)).With(err).Error()) switch level { case zerolog.FatalLevel: os.Exit(1) diff --git a/internal/gperr/nested_error.go b/internal/gperr/nested_error.go index 12dbc19..4e6b201 100644 --- a/internal/gperr/nested_error.go +++ b/internal/gperr/nested_error.go @@ -3,8 +3,6 @@ package gperr import ( "errors" "fmt" - - "github.com/yusing/go-proxy/internal/utils/strutils" ) //nolint:recvcheck @@ -67,48 +65,98 @@ func (err *nestedError) Is(other error) bool { return false } +var nilError = newError("") +var bulletPrefix = []byte("• ") +var markdownBulletPrefix = []byte("- ") +var spaces = []byte(" ") + +type appendLineFunc func(buf []byte, err error, level int) []byte + func (err *nestedError) Error() string { if err == nil { - return makeLine("", 0) + return nilError.Error() } - - if err.Err != nil { - lines := make([]string, 0, 1+len(err.Extras)) - lines = append(lines, makeLine(err.Err.Error(), 0)) - lines = append(lines, makeLines(err.Extras, 1)...) - return strutils.JoinLines(lines) + buf := appendLineNormal(nil, err.Err, 0) + if len(err.Extras) > 0 { + buf = append(buf, '\n') + buf = appendLines(buf, err.Extras, 1, appendLineNormal) } - return strutils.JoinLines(makeLines(err.Extras, 0)) + return string(buf) } -//go:inline -func makeLine(err string, level int) string { - const bulletPrefix = "• " - const spaces = " " +func (err *nestedError) Plain() []byte { + if err == nil { + return appendLinePlain(nil, nilError, 0) + } + buf := appendLinePlain(nil, err.Err, 0) + if len(err.Extras) > 0 { + buf = append(buf, '\n') + buf = appendLines(buf, err.Extras, 1, appendLinePlain) + } + return buf +} +func (err *nestedError) Markdown() []byte { + if err == nil { + return appendLineMd(nil, nilError, 0) + } + + buf := appendLineMd(nil, err.Err, 0) + if len(err.Extras) > 0 { + buf = append(buf, '\n') + buf = appendLines(buf, err.Extras, 1, appendLineMd) + } + return buf +} + +func appendLineNormal(buf []byte, err error, level int) []byte { if level == 0 { - return err + return append(buf, err.Error()...) } - return spaces[:2*level] + bulletPrefix + err + buf = append(buf, spaces[:2*level]...) + buf = append(buf, bulletPrefix...) + buf = append(buf, err.Error()...) + return buf } -func makeLines(errs []error, level int) []string { - if len(errs) == 0 { - return nil +func appendLinePlain(buf []byte, err error, level int) []byte { + if level == 0 { + return append(buf, Plain(err)...) + } + buf = append(buf, spaces[:2*level]...) + buf = append(buf, bulletPrefix...) + buf = append(buf, Plain(err)...) + return buf +} + +func appendLineMd(buf []byte, err error, level int) []byte { + if level == 0 { + return append(buf, Markdown(err)...) + } + buf = append(buf, spaces[:2*level]...) + buf = append(buf, markdownBulletPrefix...) + buf = append(buf, Markdown(err)...) + return buf +} + +func appendLines(buf []byte, errs []error, level int, appendLine appendLineFunc) []byte { + if len(errs) == 0 { + return buf } - lines := make([]string, 0, len(errs)) for _, err := range errs { switch err := wrap(err).(type) { case *nestedError: if err.Err != nil { - lines = append(lines, makeLine(err.Err.Error(), level)) - lines = append(lines, makeLines(err.Extras, level+1)...) + buf = appendLine(buf, err.Err, level) + buf = append(buf, '\n') + buf = appendLines(buf, err.Extras, level+1, appendLine) } else { - lines = append(lines, makeLines(err.Extras, level)...) + buf = appendLines(buf, err.Extras, level, appendLine) } default: - lines = append(lines, makeLine(err.Error(), level)) + buf = appendLine(buf, err, level) + buf = append(buf, '\n') } } - return lines + return buf } diff --git a/internal/gperr/subject.go b/internal/gperr/subject.go index 1ef550c..bed2b72 100644 --- a/internal/gperr/subject.go +++ b/internal/gperr/subject.go @@ -1,10 +1,10 @@ package gperr import ( + "bytes" "encoding/json" "errors" "slices" - "strings" "github.com/yusing/go-proxy/internal/utils/strutils/ansi" ) @@ -19,10 +19,23 @@ type withSubject struct { const subjectSep = " > " -func highlight(subject string) string { +type highlightFunc func(subject string) string + +var _ PlainError = (*withSubject)(nil) +var _ MarkdownError = (*withSubject)(nil) + +func highlightANSI(subject string) string { return ansi.HighlightRed + subject + ansi.Reset } +func highlightMarkdown(subject string) string { + return "**" + subject + "**" +} + +func noHighlight(subject string) string { + return subject +} + func PrependSubject(subject string, err error) error { if err == nil { return nil @@ -69,24 +82,38 @@ func (err *withSubject) Unwrap() error { } func (err *withSubject) Error() string { + return string(err.fmtError(highlightANSI)) +} + +func (err *withSubject) Plain() []byte { + return err.fmtError(noHighlight) +} + +func (err *withSubject) Markdown() []byte { + return err.fmtError(highlightMarkdown) +} + +func (err *withSubject) fmtError(highlight highlightFunc) []byte { // subject is in reversed order n := len(err.Subjects) size := 0 errStr := err.Err.Error() - var sb strings.Builder + var buf bytes.Buffer for _, s := range err.Subjects { size += len(s) } - sb.Grow(size + 2 + n*len(subjectSep) + len(errStr) + len(highlight(""))) + buf.Grow(size + 2 + n*len(subjectSep) + len(errStr) + len(highlight(""))) for i := n - 1; i > 0; i-- { - sb.WriteString(err.Subjects[i]) - sb.WriteString(subjectSep) + buf.WriteString(err.Subjects[i]) + buf.WriteString(subjectSep) } - sb.WriteString(highlight(err.Subjects[0])) - sb.WriteString(": ") - sb.WriteString(errStr) - return sb.String() + buf.WriteString(highlight(err.Subjects[0])) + if errStr != "" { + buf.WriteString(": ") + buf.WriteString(errStr) + } + return buf.Bytes() } // MarshalJSON implements the json.Marshaler interface. diff --git a/internal/gperr/utils.go b/internal/gperr/utils.go index fb98d6e..b3f91d1 100644 --- a/internal/gperr/utils.go +++ b/internal/gperr/utils.go @@ -1,8 +1,6 @@ package gperr import ( - "encoding/json" - "errors" "fmt" ) @@ -29,16 +27,17 @@ func Wrap(err error, message ...string) Error { if len(message) == 0 || message[0] == "" { return wrap(err) } + wrapped := &wrappedError{err, message[0]} //nolint:errorlint switch err := err.(type) { case *baseError: - err.Err = fmt.Errorf("%s: %w", message[0], err.Err) + err.Err = wrapped return err case *nestedError: - err.Err = fmt.Errorf("%s: %w", message[0], err.Err) + err.Err = wrapped return err } - return &baseError{fmt.Errorf("%s: %w", message[0], err)} + return &baseError{wrapped} } func Unwrap(err error) Error { @@ -65,18 +64,6 @@ func wrap(err error) Error { return &baseError{err} } -func IsJSONMarshallable(err error) bool { - switch err := err.(type) { - case *nestedError, *withSubject: - return true - case *baseError: - return IsJSONMarshallable(err.Err) - default: - var v json.Marshaler - return errors.As(err, &v) - } -} - func Join(errors ...error) Error { n := 0 for _, err := range errors { @@ -103,3 +90,27 @@ func Collect[T any, Err error, Arg any, Func func(Arg) (T, Err)](eb *Builder, fn eb.Add(err) return result } + +func Plain(err error) []byte { + if err == nil { + return nil + } + if p, ok := err.(PlainError); ok { + return p.Plain() + } + return []byte(err.Error()) +} + +func Markdown(err error) []byte { + if err == nil { + return nil + } + switch err := err.(type) { + case MarkdownError: + return err.Markdown() + case interface{ Unwrap() []error }: + return appendLines(nil, err.Unwrap(), 0, appendLineMd) + default: + return []byte(err.Error()) + } +} diff --git a/internal/gperr/utils_test.go b/internal/gperr/utils_test.go index 4fe4422..d9393ac 100644 --- a/internal/gperr/utils_test.go +++ b/internal/gperr/utils_test.go @@ -1,63 +1,55 @@ package gperr import ( - "errors" "testing" ) type testErr struct{} -func (e *testErr) Error() string { +func (e testErr) Error() string { return "test error" } -func (e *testErr) MarshalJSON() ([]byte, error) { - return nil, nil +func (e testErr) Plain() []byte { + return []byte("test error") } -func TestIsJSONMarshallable(t *testing.T) { - tests := []struct { - name string - err error - want bool - }{ - { - name: "testErr", - err: &testErr{}, - want: true, - }, - { - name: "baseError", - err: &baseError{}, - want: false, - }, - { - name: "baseError with json marshallable error", - err: &baseError{&testErr{}}, - want: true, - }, - { - name: "nestedError", - err: &nestedError{}, - want: true, - }, - { - name: "withSubject", - err: &withSubject{}, - want: true, - }, - { - name: "standard error", - err: errors.New("test error"), - want: false, - }, - } +func (e testErr) Markdown() []byte { + return []byte("**test error**") +} - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if got := IsJSONMarshallable(test.err); got != test.want { - t.Errorf("IsJSONMarshallable(%v) = %v, want %v", test.err, got, test.want) - } - }) +type testMultiErr struct { + errors []error +} + +func (e testMultiErr) Error() string { + return Join(e.errors...).Error() +} + +func (e testMultiErr) Unwrap() []error { + return e.errors +} + +func TestFormatting(t *testing.T) { + err := testErr{} + plain := Plain(err) + if string(plain) != "test error" { + t.Errorf("expected test error, got %s", string(plain)) + } + md := Markdown(err) + if string(md) != "**test error**" { + t.Errorf("expected test error, got %s", string(md)) + } +} + +func TestMultiError(t *testing.T) { + err := testMultiErr{[]error{testErr{}, testErr{}}} + plain := Plain(err) + if string(plain) != "test error\ntest error" { + t.Errorf("expected test error, got %s", string(plain)) + } + md := Markdown(err) + if string(md) != "**test error**\n**test error**" { + t.Errorf("expected test error, got %s", string(md)) } } diff --git a/internal/gperr/wrapped.go b/internal/gperr/wrapped.go new file mode 100644 index 0000000..810d54a --- /dev/null +++ b/internal/gperr/wrapped.go @@ -0,0 +1,34 @@ +package gperr + +import ( + "errors" + "fmt" +) + +type wrappedError struct { + Err error + Message string +} + +var _ PlainError = (*wrappedError)(nil) +var _ MarkdownError = (*wrappedError)(nil) + +func (e *wrappedError) Error() string { + return fmt.Sprintf("%s: %s", e.Message, e.Err.Error()) +} + +func (e *wrappedError) Plain() []byte { + return fmt.Appendf(nil, "%s: %s", e.Message, e.Err.Error()) +} + +func (e *wrappedError) Markdown() []byte { + return fmt.Appendf(nil, "**%s**: %s", e.Message, e.Err.Error()) +} + +func (e *wrappedError) Unwrap() error { + return e.Err +} + +func (e *wrappedError) Is(target error) bool { + return errors.Is(e.Err, target) +} diff --git a/internal/homepage/homepage.go b/internal/homepage/homepage.go index 9ad1d81..de58514 100644 --- a/internal/homepage/homepage.go +++ b/internal/homepage/homepage.go @@ -2,9 +2,8 @@ package homepage import ( "encoding/json" - "strings" - config "github.com/yusing/go-proxy/internal/config/types" + "github.com/yusing/go-proxy/internal/homepage/widgets" "github.com/yusing/go-proxy/internal/utils" ) @@ -13,20 +12,21 @@ type ( Category []*Item ItemConfig struct { - Show bool `json:"show"` - Name string `json:"name"` // display name - Icon *IconURL `json:"icon"` - Category string `json:"category"` - Description string `json:"description" aliases:"desc"` - SortOrder int `json:"sort_order"` - WidgetConfig map[string]any `json:"widget_config" aliases:"widget"` + Show bool `json:"show"` + Name string `json:"name"` // display name + Icon *IconURL `json:"icon"` + Category string `json:"category"` + Description string `json:"description" aliases:"desc"` + SortOrder int `json:"sort_order"` } Item struct { *ItemConfig + WidgetConfig *widgets.Config `json:"widget_config,omitempty" aliases:"widget"` - Alias string - Provider string + Alias string + Provider string + OriginURL string } ) @@ -43,23 +43,10 @@ func (cfg *ItemConfig) GetOverride(alias string) *ItemConfig { } func (item *Item) MarshalJSON() ([]byte, error) { - var url *string - if !strings.ContainsRune(item.Alias, '.') { - godoxyCfg := config.GetInstance().Value() - // use first domain as base domain - domains := godoxyCfg.MatchDomains - if len(domains) > 0 { - url = new(string) - *url = item.Alias + domains[0] - } - } else { - url = &item.Alias - } return json.Marshal(map[string]any{ "show": item.Show, "alias": item.Alias, "provider": item.Provider, - "url": url, "name": item.Name, "icon": item.Icon, "category": item.Category, diff --git a/internal/homepage/integrations/qbittorrent/client.go b/internal/homepage/integrations/qbittorrent/client.go new file mode 100644 index 0000000..2e85322 --- /dev/null +++ b/internal/homepage/integrations/qbittorrent/client.go @@ -0,0 +1,68 @@ +package qbittorrent + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + + "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/homepage/widgets" +) + +type Client struct { + URL string + Username string + Password string +} + +func (c *Client) Initialize(ctx context.Context, url string, cfg map[string]any) error { + c.URL = url + c.Username = cfg["username"].(string) + c.Password = cfg["password"].(string) + + _, err := c.Version(ctx) + if err != nil { + return err + } + + return nil +} + +func (c *Client) doRequest(ctx context.Context, method, endpoint string, query url.Values, body io.Reader) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, c.URL+endpoint+query.Encode(), body) + if err != nil { + return nil, err + } + + if c.Username != "" && c.Password != "" { + req.SetBasicAuth(c.Username, c.Password) + } + + resp, err := widgets.HTTPClient.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, gperr.Errorf("%w: %d %s", widgets.ErrHTTPStatus, resp.StatusCode, resp.Status) + } + + return resp, nil +} + +func jsonRequest[T any](ctx context.Context, client *Client, endpoint string, query url.Values) (result T, err error) { + resp, err := client.doRequest(ctx, http.MethodGet, endpoint, query, nil) + if err != nil { + return result, err + } + defer resp.Body.Close() + + err = json.NewDecoder(resp.Body).Decode(&result) + if err != nil { + return result, err + } + + return result, nil +} diff --git a/internal/homepage/integrations/qbittorrent/logs.go b/internal/homepage/integrations/qbittorrent/logs.go new file mode 100644 index 0000000..408d0b7 --- /dev/null +++ b/internal/homepage/integrations/qbittorrent/logs.go @@ -0,0 +1,102 @@ +package qbittorrent + +import ( + "context" + "encoding/json" + "net/url" + "strconv" + "time" +) + +const endpointLogs = "/api/v2/log/main" + +type LogEntry struct { + ID int `json:"id"` + Timestamp int `json:"timestamp"` + Type int `json:"type"` + Message string `json:"message"` +} + +const ( + LogSeverityNormal = 1 << iota + LogSeverityInfo + LogSeverityWarning + LogSeverityCritical +) + +func (l *LogEntry) Time() time.Time { + return time.Unix(int64(l.Timestamp), 0) +} + +func (l *LogEntry) Level() string { + switch l.Type { + case LogSeverityNormal: + return "Normal" + case LogSeverityInfo: + return "Info" + case LogSeverityWarning: + return "Warning" + case LogSeverityCritical: + return "Critical" + default: + return "Unknown" + } +} + +func (l *LogEntry) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]any{ + "id": l.ID, + "timestamp": l.Timestamp, + "level": l.Level(), + "message": l.Message, + }) +} + +// params: +// +// normal: bool +// info: bool +// warning: bool +// critical: bool +// last_known_id: int +func (c *Client) GetLogs(ctx context.Context, lastKnownID int) ([]*LogEntry, error) { + return jsonRequest[[]*LogEntry](ctx, c, endpointLogs, url.Values{ + "last_known_id": {strconv.Itoa(lastKnownID)}, + }) +} + +func (c *Client) WatchLogs(ctx context.Context) (<-chan *LogEntry, <-chan error) { + ch := make(chan *LogEntry) + errCh := make(chan error) + + lastKnownID := -1 + + go func() { + defer close(ch) + defer close(errCh) + + for { + select { + case <-ctx.Done(): + return + default: + logs, err := c.GetLogs(ctx, lastKnownID) + if err != nil { + errCh <- err + } + + if len(logs) == 0 { + time.Sleep(1 * time.Second) + continue + } + + for _, log := range logs { + ch <- log + } + lastKnownID = logs[len(logs)-1].ID + } + } + }() + + return ch, errCh +} diff --git a/internal/homepage/integrations/qbittorrent/transfer_info.go b/internal/homepage/integrations/qbittorrent/transfer_info.go new file mode 100644 index 0000000..899edba --- /dev/null +++ b/internal/homepage/integrations/qbittorrent/transfer_info.go @@ -0,0 +1,32 @@ +package qbittorrent + +import ( + "context" + + "github.com/yusing/go-proxy/internal/homepage/widgets" + "github.com/yusing/go-proxy/internal/utils/strutils" +) + +const endpointTransferInfo = "/api/v2/transfer/info" + +type TransferInfo struct { + ConnectionStatus string `json:"connection_status"` + SessionDownloads uint64 `json:"dl_info_data"` + SessionUploads uint64 `json:"up_info_data"` + DownloadSpeed uint64 `json:"dl_info_speed"` + UploadSpeed uint64 `json:"up_info_speed"` +} + +func (c *Client) Data(ctx context.Context) ([]widgets.NameValue, error) { + info, err := jsonRequest[TransferInfo](ctx, c, endpointTransferInfo, nil) + if err != nil { + return nil, err + } + return []widgets.NameValue{ + {Name: "Status", Value: info.ConnectionStatus}, + {Name: "Download", Value: strutils.FormatByteSize(info.SessionDownloads)}, + {Name: "Upload", Value: strutils.FormatByteSize(info.SessionUploads)}, + {Name: "Download Speed", Value: strutils.FormatByteSize(info.DownloadSpeed) + "/s"}, + {Name: "Upload Speed", Value: strutils.FormatByteSize(info.UploadSpeed) + "/s"}, + }, nil +} diff --git a/internal/homepage/integrations/qbittorrent/version.go b/internal/homepage/integrations/qbittorrent/version.go new file mode 100644 index 0000000..fc17e47 --- /dev/null +++ b/internal/homepage/integrations/qbittorrent/version.go @@ -0,0 +1,21 @@ +package qbittorrent + +import ( + "context" + "io" +) + +func (c *Client) Version(ctx context.Context) (string, error) { + resp, err := c.doRequest(ctx, "GET", "/api/v2/app/version", nil, nil) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + return string(body), nil +} diff --git a/internal/homepage/widgets/http.go b/internal/homepage/widgets/http.go new file mode 100644 index 0000000..307c3c4 --- /dev/null +++ b/internal/homepage/widgets/http.go @@ -0,0 +1,14 @@ +package widgets + +import ( + "net/http" + "time" + + "github.com/yusing/go-proxy/internal/gperr" +) + +var HTTPClient = &http.Client{ + Timeout: 10 * time.Second, +} + +var ErrHTTPStatus = gperr.New("http status") diff --git a/internal/homepage/widgets/widgets.go b/internal/homepage/widgets/widgets.go new file mode 100644 index 0000000..e653b69 --- /dev/null +++ b/internal/homepage/widgets/widgets.go @@ -0,0 +1,49 @@ +package widgets + +import ( + "context" + + "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/utils" +) + +type ( + Config struct { + Provider string `json:"provider"` + Config Widget `json:"config"` + } + Widget interface { + Initialize(ctx context.Context, url string, cfg map[string]any) error + Data(ctx context.Context) ([]NameValue, error) + } + NameValue struct { + Name string `json:"name"` + Value string `json:"value"` + } +) + +const ( + WidgetProviderQbittorrent = "qbittorrent" +) + +var widgetProviders = map[string]struct{}{ + WidgetProviderQbittorrent: {}, +} + +var ErrInvalidProvider = gperr.New("invalid provider") + +func (cfg *Config) UnmarshalMap(m map[string]any) error { + cfg.Provider = m["provider"].(string) + if _, ok := widgetProviders[cfg.Provider]; !ok { + return ErrInvalidProvider.Subject(cfg.Provider) + } + delete(m, "provider") + m, ok := m["config"].(map[string]any) + if !ok { + return gperr.New("invalid config") + } + if err := utils.MapUnmarshalValidate(m, &cfg.Config); err != nil { + return err + } + return nil +} diff --git a/internal/net/gphttp/content_type.go b/internal/net/gphttp/content_type.go index dee78ff..5f30d9e 100644 --- a/internal/net/gphttp/content_type.go +++ b/internal/net/gphttp/content_type.go @@ -10,6 +10,15 @@ type ( AcceptContentType []ContentType ) +const ( + ContentTypeJSON = ContentType("application/json") + ContentTypeTextPlain = ContentType("text/plain") + ContentTypeTextHTML = ContentType("text/html") + ContentTypeTextMarkdown = ContentType("text/markdown") + ContentTypeTextXML = ContentType("text/xml") + ContentTypeXHTML = ContentType("application/xhtml+xml") +) + func GetContentType(h http.Header) ContentType { ct := h.Get("Content-Type") if ct == "" { @@ -35,15 +44,15 @@ func GetAccept(h http.Header) AcceptContentType { } func (ct ContentType) IsHTML() bool { - return ct == "text/html" || ct == "application/xhtml+xml" + return ct == ContentTypeTextHTML || ct == ContentTypeXHTML } func (ct ContentType) IsJSON() bool { - return ct == "application/json" + return ct == ContentTypeJSON } func (ct ContentType) IsPlainText() bool { - return ct == "text/plain" + return ct == ContentTypeTextPlain } func (act AcceptContentType) IsEmpty() bool { @@ -68,6 +77,15 @@ func (act AcceptContentType) AcceptJSON() bool { return false } +func (act AcceptContentType) AcceptMarkdown() bool { + for _, v := range act { + if v == ContentTypeTextMarkdown || v == "*/*" { + return true + } + } + return false +} + func (act AcceptContentType) AcceptPlainText() bool { for _, v := range act { if v.IsPlainText() || v == "text/*" || v == "*/*" { diff --git a/internal/net/gphttp/error.go b/internal/net/gphttp/error.go index f269e3f..0e1a440 100644 --- a/internal/net/gphttp/error.go +++ b/internal/net/gphttp/error.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "syscall" @@ -40,15 +41,22 @@ func ServerError(w http.ResponseWriter, r *http.Request, err error, code ...int) // // For JSON marshallable errors (e.g. gperr.Error), it returns the error details as JSON. // Otherwise, it returns the error details as plain text. -func ClientError(w http.ResponseWriter, err error, code ...int) { +func ClientError(w http.ResponseWriter, r *http.Request, err error, code ...int) { if len(code) == 0 { code = []int{http.StatusBadRequest} } - if gperr.IsJSONMarshallable(err) { + w.WriteHeader(code[0]) + accept := GetAccept(r.Header) + switch { + case accept.AcceptJSON(): w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(err) - } else { - http.Error(w, err.Error(), code[0]) + case accept.AcceptMarkdown(): + w.Header().Set("Content-Type", "text/markdown") + w.Write(gperr.Markdown(err)) + default: + w.Header().Set("Content-Type", "text/plain") + w.Write(gperr.Plain(err)) } } @@ -83,18 +91,18 @@ func NotFound(w http.ResponseWriter, err string) { BadRequest(w, err, http.StatusNotFound) } -func ErrMissingKey(k string) error { - return gperr.New(k + " is required") +func MissingKey(w http.ResponseWriter, k string) { + BadRequest(w, k+" is required", http.StatusBadRequest) } -func ErrInvalidKey(k string) error { - return gperr.New(k + " is invalid") +func InvalidKey(w http.ResponseWriter, k string) { + BadRequest(w, k+" is invalid", http.StatusBadRequest) } -func ErrAlreadyExists(k, v string) error { - return gperr.Errorf("%s %q already exists", k, v) +func KeyAlreadyExists(w http.ResponseWriter, k, v string) { + BadRequest(w, fmt.Sprintf("%s %q already exists", k, v), http.StatusBadRequest) } -func ErrNotFound(k, v string) error { - return gperr.Errorf("%s %q not found", k, v) +func ValueNotFound(w http.ResponseWriter, k, v string) { + BadRequest(w, fmt.Sprintf("%s %q not found", k, v), http.StatusNotFound) } diff --git a/internal/net/gphttp/middleware/middlewares.go b/internal/net/gphttp/middleware/middlewares.go index a59cda2..3b090e5 100644 --- a/internal/net/gphttp/middleware/middlewares.go +++ b/internal/net/gphttp/middleware/middlewares.go @@ -44,7 +44,7 @@ func Get(name string) (*Middleware, Error) { if !ok { return nil, ErrUnknownMiddleware. Subject(name). - Withf(strutils.DoYouMean(utils.NearestField(name, allMiddlewares))) + With(gperr.DoYouMean(utils.NearestField(name, allMiddlewares))) } return middleware, nil } diff --git a/internal/notif/body.go b/internal/notif/body.go index bbbdb82..a01afee 100644 --- a/internal/notif/body.go +++ b/internal/notif/body.go @@ -26,6 +26,9 @@ type ( FieldsBody []LogField ListBody []string MessageBody string + ErrorBody struct { + Error error + } ) var ( @@ -113,3 +116,15 @@ func (m MessageBody) Format(format *LogFormat) ([]byte, error) { } return nil, fmt.Errorf("unknown format: %v", format) } + +func (e ErrorBody) Format(format *LogFormat) ([]byte, error) { + switch format { + case LogFormatRawJSON: + return json.Marshal(e) + case LogFormatPlain: + return gperr.Plain(e.Error), nil + case LogFormatMarkdown: + return gperr.Markdown(e.Error), nil + } + return nil, fmt.Errorf("unknown format: %v", format) +} diff --git a/internal/utils/serialization.go b/internal/utils/serialization.go index f5b5a20..cecb193 100644 --- a/internal/utils/serialization.go +++ b/internal/utils/serialization.go @@ -259,7 +259,7 @@ func mapUnmarshalValidate(src SerializedObject, dst any, checkValidateTag bool) errs.Add(err) } } else { - errs.Add(ErrUnknownField.Subject(k).Withf(strutils.DoYouMean(NearestField(k, mapping)))) + errs.Add(ErrUnknownField.Subject(k).With(gperr.DoYouMean(NearestField(k, mapping)))) } } if hasValidateTag && checkValidateTag { diff --git a/internal/utils/strutils/ansi/ansi.go b/internal/utils/strutils/ansi/ansi.go index eb11f46..f450eab 100644 --- a/internal/utils/strutils/ansi/ansi.go +++ b/internal/utils/strutils/ansi/ansi.go @@ -1,6 +1,8 @@ package ansi -import "regexp" +import ( + "regexp" +) var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`) @@ -43,11 +45,3 @@ func WithANSI(s string, ansi string) string { func StripANSI(s string) string { return ansiRegexp.ReplaceAllString(s, "") } - -var ToHTMLClass = map[string]string{ - "[91": "log-red", - "[92": "log-green", - "[93": "log-yellow", - "[96": "log-cyan", - "[97": "log-white", -} diff --git a/internal/utils/strutils/format.go b/internal/utils/strutils/format.go index 45e21e7..88aa35b 100644 --- a/internal/utils/strutils/format.go +++ b/internal/utils/strutils/format.go @@ -5,8 +5,6 @@ import ( "math" "strconv" "time" - - "github.com/yusing/go-proxy/internal/utils/strutils/ansi" ) // AppendDuration appends a duration to a buffer with the following format: @@ -215,13 +213,6 @@ func AppendByteSize[T ~int | ~uint | ~int64 | ~uint64 | ~float64](size T, buf [] return buf } -func DoYouMean(s string) string { - if s == "" { - return "" - } - return "Did you mean " + ansi.Info(s) + "?" -} - func Pluralize(n int64) string { if n > 1 { return "s"