diff --git a/cmd/web/flags.go b/cmd/web/flags.go new file mode 100644 index 00000000..f894eb43 --- /dev/null +++ b/cmd/web/flags.go @@ -0,0 +1,77 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" +) + +type args struct { + Address *string `json:"address"` + Port *int `json:"port"` + Username *string `json:"username"` + Password *string `json:"password"` + ApiToken *string `json:"apiToken"` + + configPath *string +} + +func parse() *args { + var cliArgs args + cliArgs.Address = flag.String("A", "0.0.0.0", "Bind Address") + cliArgs.Port = flag.Int("P", 9999, "Bind Port") + cliArgs.Username = flag.String("u", "gopeed", "HTTP Basic Auth Username") + cliArgs.Password = flag.String("p", "", "HTTP Basic Auth Password") + cliArgs.ApiToken = flag.String("T", "", "API token, that can only be used when basic authentication is enabled.") + cliArgs.configPath = flag.String("c", "./config.json", "Config file path") + flag.Parse() + + fmt.Println("parse") + + // args priority: config file > cli args + cfgArgs := loadConfig(*cliArgs.configPath) + if cfgArgs.Address == nil { + cfgArgs.Address = cliArgs.Address + } + if cfgArgs.Port == nil { + cfgArgs.Port = cliArgs.Port + } + if cfgArgs.Username == nil { + cfgArgs.Username = cliArgs.Username + } + if cfgArgs.Password == nil { + cfgArgs.Password = cliArgs.Password + } + if cfgArgs.ApiToken == nil { + cfgArgs.ApiToken = cliArgs.ApiToken + } + return cfgArgs +} + +func loadConfig(path string) *args { + var args args + + if !filepath.IsAbs(path) { + dir, err := filepath.Abs(filepath.Dir(os.Args[0])) + if err != nil { + return &args + } + path = filepath.Join(dir, path) + } + file, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &args + } + fmt.Println("config file read failed, reason:" + err.Error()) + return &args + } + if err = json.Unmarshal(file, &args); err != nil { + fmt.Println("config file parse failed, reason:" + err.Error()) + return &args + } + fmt.Printf("config file loaded: %s\n", path) + return &args +} diff --git a/cmd/web/main.go b/cmd/web/main.go index 78dd322e..3b01870d 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -5,6 +5,7 @@ package main import ( "embed" + "fmt" "github.com/GopeedLab/gopeed/cmd" "github.com/GopeedLab/gopeed/pkg/rest/model" "io/fs" @@ -18,12 +19,28 @@ func main() { if err != nil { panic(err) } + + args := parse() + var webBasicAuth *model.WebBasicAuth + if isNotBlank(args.Username) && isNotBlank(args.Password) { + webBasicAuth = &model.WebBasicAuth{ + Username: *args.Username, + Password: *args.Password, + } + } + cfg := &model.StartConfig{ - Network: "tcp", - Address: "0.0.0.0:9999", - Storage: model.StorageBolt, - WebEnable: true, - WebFS: sub, + Network: "tcp", + Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port), + Storage: model.StorageBolt, + ApiToken: *args.ApiToken, + WebEnable: true, + WebFS: sub, + WebBasicAuth: webBasicAuth, } cmd.Start(cfg) } + +func isNotBlank(str *string) bool { + return str != nil && *str != "" +} diff --git a/pkg/rest/model/server.go b/pkg/rest/model/server.go index cad94fe1..6eb6e87d 100644 --- a/pkg/rest/model/server.go +++ b/pkg/rest/model/server.go @@ -1,6 +1,9 @@ package model -import "io/fs" +import ( + "encoding/base64" + "io/fs" +) type Storage string @@ -17,8 +20,9 @@ type StartConfig struct { StorageDir string `json:"storageDir"` ApiToken string `json:"apiToken"` - WebEnable bool - WebFS fs.FS + WebEnable bool + WebFS fs.FS + WebBasicAuth *WebBasicAuth } func (cfg *StartConfig) Init() *StartConfig { @@ -39,3 +43,14 @@ func (cfg *StartConfig) Init() *StartConfig { } return cfg } + +type WebBasicAuth struct { + Username string + Password string +} + +// Authorization returns the value of the Authorization header to be used in HTTP requests. +func (cfg *WebBasicAuth) Authorization() string { + userId := cfg.Username + ":" + cfg.Password + return "Basic " + base64.StdEncoding.EncodeToString([]byte(userId)) +} diff --git a/pkg/rest/server.go b/pkg/rest/server.go index e1b92ab8..cbcaccf8 100644 --- a/pkg/rest/server.go +++ b/pkg/rest/server.go @@ -98,14 +98,21 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error r.PathPrefix("/").Handler(http.FileServer(http.FS(startCfg.WebFS))) } - if startCfg.ApiToken != "" { + if startCfg.ApiToken != "" || (startCfg.WebEnable && startCfg.WebBasicAuth != nil) { r.Use(func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("X-Api-Token") != startCfg.ApiToken { - WriteJson(w, model.NewErrorResult("invalid token", model.CodeUnauthorized)) + if startCfg.ApiToken != "" && r.Header.Get("X-Api-Token") == startCfg.ApiToken { + h.ServeHTTP(w, r) return } - h.ServeHTTP(w, r) + if startCfg.WebEnable && startCfg.WebBasicAuth != nil { + if r.Header.Get("Authorization") == startCfg.WebBasicAuth.Authorization() { + h.ServeHTTP(w, r) + return + } + w.Header().Set("WWW-Authenticate", "Basic realm=\"gopeed web\"") + } + WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized)) }) }) } @@ -127,7 +134,11 @@ func ReadJson(r *http.Request, w http.ResponseWriter, v any) bool { } func WriteJson(w http.ResponseWriter, v any) { + WriteStatusJson(w, http.StatusOK, v) +} + +func WriteStatusJson(w http.ResponseWriter, statusCode int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusOK) + w.WriteHeader(statusCode) json.NewEncoder(w).Encode(v) } diff --git a/pkg/rest/server_test.go b/pkg/rest/server_test.go index 1c4fd251..9235cdc0 100644 --- a/pkg/rest/server_test.go +++ b/pkg/rest/server_test.go @@ -273,7 +273,7 @@ func TestDoProxy(t *testing.T) { }) } -func TestAuthorization(t *testing.T) { +func TestApiToken(t *testing.T) { var cfg = &model.StartConfig{} cfg.Init() cfg.ApiToken = "123456" @@ -285,16 +285,57 @@ func TestAuthorization(t *testing.T) { Stop() }() - code, _ := doHttpRequest[any](http.MethodGet, "/api/v1/config", nil, nil) - checkCode(code, model.CodeUnauthorized) + status, _ := doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil) + if status != http.StatusUnauthorized { + t.Errorf("TestApiToken() got = %v, want %v", status, http.StatusUnauthorized) + } - code, _ = doHttpRequest[any](http.MethodGet, "/api/v1/config", map[string]string{ + status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ "X-Api-Token": cfg.ApiToken, }, nil) - checkCode(code, model.CodeOk) + if status != http.StatusOK { + t.Errorf("TestApiToken() got = %v, want %v", status, http.StatusOK) + } } +func TestAuthorization(t *testing.T) { + var cfg = &model.StartConfig{} + cfg.Init() + cfg.ApiToken = "123456" + cfg.WebEnable = true + cfg.WebBasicAuth = &model.WebBasicAuth{ + Username: "admin", + Password: "123456", + } + fileListener := doStart(cfg) + defer func() { + if err := fileListener.Close(); err != nil { + panic(err) + } + Stop() + }() + + status, _ := doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil) + if status != http.StatusUnauthorized { + t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) + } + + status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ + "Authorization": cfg.WebBasicAuth.Authorization(), + }, nil) + if status != http.StatusOK { + t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK) + } + + status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ + "X-Api-Token": cfg.ApiToken, + }, nil) + if status != http.StatusOK { + t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK) + } +} + func doTest(handler func()) { testFunc := func(storage model.Storage) { var cfg = &model.StartConfig{}