diff --git a/.woodpecker.yml b/.woodpecker.yml index 12d7148..1f90caf 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -2,8 +2,8 @@ pipeline: test: image: golang:latest commands: - - go build ./cmd/client/client.go - - go build ./cmd/server/server.go + - go build -o gpaste-client ./cmd/client/client.go + - go build -o gpaste-server ./cmd/server/server.go - go test -v ./... - go vet ./... when: diff --git a/api/http.go b/api/http.go index e8245bc..b6e8cf8 100644 --- a/api/http.go +++ b/api/http.go @@ -119,13 +119,8 @@ func (s *HTTPServer) HandlerAPIFileGet(w http.ResponseWriter, r *http.Request) { func (s *HTTPServer) processMultiPartFormUpload(w http.ResponseWriter, r *http.Request) { reqID := middleware.GetReqID(r.Context()) - type resp struct { - Message string `json:"message"` - ID string `json:"id"` - URL string `json:"url"` - } - var responses []resp + var responses []ResponseAPIFilePost if err := r.ParseMultipartForm(1024 * 1024 * 10); err != nil { s.Logger.Warnw("Error parsing multipart form.", "req_id", reqID, "err", err) @@ -149,7 +144,7 @@ func (s *HTTPServer) processMultiPartFormUpload(w http.ResponseWriter, r *http.R } s.Logger.Infow("Stored file.", "req_id", reqID, "id", f.ID, "filename", f.OriginalFilename, "remote_addr", r.RemoteAddr) - responses = append(responses, resp{Message: "OK", ID: f.ID, URL: "TODO"}) + responses = append(responses, ResponseAPIFilePost{Message: "OK", ID: f.ID, URL: "TODO"}) } @@ -162,10 +157,7 @@ func (s *HTTPServer) processMultiPartFormUpload(w http.ResponseWriter, r *http.R func (s *HTTPServer) HandlerAPILogin(w http.ResponseWriter, r *http.Request) { reqID := middleware.GetReqID(r.Context()) - expectedRequest := struct { - Username string `json:"username"` - Password string `json:"password"` - }{} + var expectedRequest RequestAPILogin decoder := json.NewDecoder(r.Body) defer r.Body.Close() if err := decoder.Decode(&expectedRequest); err != nil { @@ -179,9 +171,7 @@ func (s *HTTPServer) HandlerAPILogin(w http.ResponseWriter, r *http.Request) { return } - response := struct { - Token string `json:"token"` - }{ + response := ResponseAPILogin{ Token: token, } @@ -193,17 +183,12 @@ func (s *HTTPServer) HandlerAPILogin(w http.ResponseWriter, r *http.Request) { } } -type RequestAPIUserCreate struct { - Username string `json:"username"` - Password string `json:"password"` -} - func (s *HTTPServer) HandlerAPIUserCreate(w http.ResponseWriter, r *http.Request) { reqID := middleware.GetReqID(r.Context()) defer r.Body.Close() - level, err := AuthLevelFromRequest(r) - if err != nil || level < gpaste.AuthLevelAdmin { + role, err := RoleFromRequest(r) + if err != nil || role != users.RoleAdmin { w.WriteHeader(http.StatusUnauthorized) return } @@ -229,5 +214,6 @@ func (s *HTTPServer) HandlerAPIUserCreate(w http.ResponseWriter, r *http.Request w.WriteHeader(http.StatusInternalServerError) return } + w.WriteHeader(http.StatusAccepted) s.Logger.Infow("Created user.", "req_id", reqID, "remote_addr", r.RemoteAddr, "username", req.Username) } diff --git a/api/json.go b/api/json.go new file mode 100644 index 0000000..ea7ad17 --- /dev/null +++ b/api/json.go @@ -0,0 +1,20 @@ +package api + +type RequestAPIUserCreate struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type RequestAPILogin struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type ResponseAPILogin struct { + Token string `json:"token"` +} +type ResponseAPIFilePost struct { + Message string `json:"message"` + ID string `json:"id"` + URL string `json:"url"` +} diff --git a/api/middleware.go b/api/middleware.go index eb7f835..26e4822 100644 --- a/api/middleware.go +++ b/api/middleware.go @@ -8,6 +8,7 @@ import ( "time" "git.t-juice.club/torjus/gpaste" + "git.t-juice.club/torjus/gpaste/users" "github.com/go-chi/chi/v5/middleware" ) @@ -90,14 +91,14 @@ func UsernameFromRequest(r *http.Request) (string, error) { return username, nil } -func AuthLevelFromRequest(r *http.Request) (gpaste.AuthLevel, error) { +func RoleFromRequest(r *http.Request) (users.Role, error) { rawLevel := r.Context().Value(authCtxAuthLevel) if rawLevel == nil { - return gpaste.AuthLevelUnset, fmt.Errorf("no username") + return users.RoleUnset, fmt.Errorf("no username") } - level, ok := rawLevel.(gpaste.AuthLevel) + level, ok := rawLevel.(users.Role) if !ok { - return gpaste.AuthLevelUnset, fmt.Errorf("no username") + return users.RoleUnset, fmt.Errorf("no username") } return level, nil } diff --git a/auth.go b/auth.go index 04aa080..4b6cab6 100644 --- a/auth.go +++ b/auth.go @@ -9,14 +9,6 @@ import ( "github.com/google/uuid" ) -type AuthLevel int - -const ( - AuthLevelUnset AuthLevel = iota - AuthLevelUser - AuthLevelAdmin -) - type AuthService struct { users users.UserStore hmacSecret []byte diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..99f261d --- /dev/null +++ b/client/client.go @@ -0,0 +1,167 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "time" + + "git.t-juice.club/torjus/gpaste/api" + "git.t-juice.club/torjus/gpaste/files" + "github.com/google/uuid" +) + +type Client struct { + BaseURL string + AuthToken string + + httpClient http.Client +} + +func (c *Client) Login(ctx context.Context, username, password string) error { + url := fmt.Sprintf("%s/api/login", c.BaseURL) + // TODO: Change timeout + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + body := new(bytes.Buffer) + requestData := api.RequestAPILogin{ + Username: username, + Password: password, + } + encoder := json.NewEncoder(body) + if err := encoder.Encode(&requestData); err != nil { + return fmt.Errorf("error encoding response: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) + if err != nil { + return fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("unable to perform request: %s", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("got non-ok response from server: %s", resp.Status) + } + + var responseData api.ResponseAPILogin + + decoder := json.NewDecoder(resp.Body) + if err := decoder.Decode(&responseData); err != nil { + return fmt.Errorf("unable to parse response: %s", err) + } + c.AuthToken = responseData.Token + + return nil +} + +func (c *Client) UserCreate(ctx context.Context, username, password string) error { + url := fmt.Sprintf("%s/api/user", c.BaseURL) + body := new(bytes.Buffer) + + requestData := &api.RequestAPIUserCreate{ + Username: username, + Password: password, + } + encoder := json.NewEncoder(body) + if err := encoder.Encode(requestData); err != nil { + return fmt.Errorf("error encoding response: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) + if err != nil { + return fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.AuthToken)) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("unable to perform request: %s", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusAccepted { + return fmt.Errorf("got non-ok response from server: %s", resp.Status) + } + + return nil +} + +func (c *Client) Download(ctx context.Context, id string) (io.ReadCloser, error) { + url := fmt.Sprintf("%s/api/file/%s", c.BaseURL, id) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.AuthToken)) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("unable to perform request: %s", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("got non-ok response from server: %s", resp.Status) + } + + return resp.Body, nil +} + +func (c *Client) Upload(ctx context.Context, files ...*files.File) ([]api.ResponseAPIFilePost, error) { + url := fmt.Sprintf("%s/api/file", c.BaseURL) + client := &http.Client{} + + // TODO: Change timeout + ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + + // TODO: Improve buffering + buf := &bytes.Buffer{} + mw := multipart.NewWriter(buf) + + for _, file := range files { + fw, err := mw.CreateFormFile(uuid.Must(uuid.NewRandom()).String(), file.OriginalFilename) + if err != nil { + return nil, err + } + if _, err := io.Copy(fw, file.Body); err != nil { + return nil, err + } + file.Body.Close() + } + mw.Close() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, buf) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", mw.FormDataContentType()) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var expectedResp []api.ResponseAPIFilePost + + decoder := json.NewDecoder(resp.Body) + if err := decoder.Decode(&expectedResp); err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + for _, r := range expectedResp { + fmt.Printf("Uploaded file %s\n", r.ID) + } + return expectedResp, nil +} diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 0000000..6df4a41 --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,165 @@ +package client_test + +import ( + "context" + "fmt" + "io" + "net" + "strings" + "testing" + "time" + + "git.t-juice.club/torjus/gpaste" + "git.t-juice.club/torjus/gpaste/api" + "git.t-juice.club/torjus/gpaste/client" + "git.t-juice.club/torjus/gpaste/files" + "git.t-juice.club/torjus/gpaste/users" + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" +) + +func TestClient(t *testing.T) { + listener, err := net.Listen("tcp", ":0") + if err != nil { + panic(err) + } + + port := listener.Addr().(*net.TCPAddr).Port + cfg := &gpaste.ServerConfig{ + LogLevel: "ERROR", + URL: fmt.Sprintf("http://localhost:%d", port), + SigningSecret: "TEST", + Store: &gpaste.ServerStoreConfig{Type: "memory"}, + } + + srv := api.NewHTTPServer(cfg) + + go func() { + srv.Serve(listener) + }() + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + srv.Shutdown(ctx) + listener.Close() + }) + + // Add users + username := "admin" + password := "admin" + user := &users.User{ + Username: username, + Role: users.RoleAdmin, + } + if err := user.SetPassword(password); err != nil { + t.Fatalf("Error setting password: %s", err) + } + if err := srv.Users.Store(user); err != nil { + t.Fatalf("Error storing user: %s", err) + } + + t.Run("Login", func(t *testing.T) { + client := client.Client{BaseURL: cfg.URL} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := client.Login(ctx, username, password); err != nil { + t.Fatalf("Error logging in: %s", err) + } + + claims, err := srv.Auth.ValidateToken(client.AuthToken) + if err != nil { + t.Errorf("unable to get claims from token: %s", err) + } + + if claims.Role != user.Role { + t.Errorf("Claims have wrong role: %s", cmp.Diff(claims.Role, user.Role)) + } + if claims.Subject != username { + t.Errorf("Claims have wrong role: %s", cmp.Diff(claims.Subject, username)) + } + + t.Run("UserCreate", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + username := "user" + password := "user" + + if err := client.UserCreate(ctx, username, password); err != nil { + t.Fatalf("Error creating user: %s", err) + } + + user, err := srv.Users.Get(username) + if err != nil { + t.Fatalf("Error getting new user: %s", err) + } + + if user.Username != username { + t.Errorf("Username does not match.") + } + if err := user.ValidatePassword(password); err != nil { + t.Errorf("Unable to validate password: %s", err) + } + }) + }) + t.Run("Upload", func(t *testing.T) { + client := client.Client{BaseURL: cfg.URL} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + fileContents := "this is the test file" + fileBody := io.NopCloser(strings.NewReader(fileContents)) + file := &files.File{ + OriginalFilename: "filename.txt", + Body: fileBody, + } + + resp, err := client.Upload(ctx, file) + if err != nil { + t.Fatalf("Error uploading: %s", err) + } + + retrieved, err := srv.Files.Get(resp[0].ID) + if err != nil { + t.Fatalf("Error getting uploaded file from store: %s", err) + } + defer retrieved.Body.Close() + + buf := new(strings.Builder) + if _, err := io.Copy(buf, retrieved.Body); err != nil { + t.Fatalf("error reading body from store: %s", err) + } + if buf.String() != fileContents { + t.Errorf("File contents does not match: %s", cmp.Diff(buf.String(), fileContents)) + } + }) + t.Run("Download", func(t *testing.T) { + client := client.Client{BaseURL: cfg.URL} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + fileContents := "this is the test file" + fileBody := io.NopCloser(strings.NewReader(fileContents)) + file := &files.File{ + ID: uuid.NewString(), + OriginalFilename: "filename.txt", + Body: fileBody, + } + + if err := srv.Files.Store(file); err != nil { + t.Fatalf("Error putting file in store: %s", err) + } + + body, err := client.Download(ctx, file.ID) + if err != nil { + t.Fatalf("Error uploading: %s", err) + } + defer body.Close() + + buf := new(strings.Builder) + if _, err := io.Copy(buf, body); err != nil { + t.Fatalf("error reading body from store: %s", err) + } + if buf.String() != fileContents { + t.Errorf("File contents does not match: %s", cmp.Diff(buf.String(), fileContents)) + } + }) +}