Peter Sanchez: 1 Support specifying organization when using the Pinboard API bridge. 4 files changed, 220 insertions(+), 27 deletions(-)
Copy & paste the following snippet into your terminal to import this patchset into git:
curl -s https://lists.code.netlandish.com/~netlandish/links-dev/patches/159/mbox | git am -3Learn more about email & git
Now you can now do the following to specify a specific organization: - Use the "use_org" query string parameter in your API calls - Give the organization slug as the username value when using Basic Authorization. See documention for more details. References: https://todo.code.netlandish.com/~netlandish/links/76 Changelog-added: Ability to specify organization in Pinboard API bridge calls --- pinboard/middleware.go | 2 + pinboard/responses.go | 40 +++++++---- pinboard/routes.go | 55 +++++++++++---- pinboard/routes_test.go | 150 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 27 deletions(-) diff --git a/pinboard/middleware.go b/pinboard/middleware.go index 8a77911..5675543 100644 --- a/pinboard/middleware.go +++ b/pinboard/middleware.go @@ -27,6 +27,8 @@ func PinboardAuthMiddleware() echo.MiddlewareFunc { if err == nil { parts := strings.SplitN(string(decoded), ":", 2) if len(parts) == 2 { + // Allow specification of the org to use in the username field + c.Set("use_org", parts[0]) // Use password as token (Pinboard uses username:token format) token = parts[1] } diff --git a/pinboard/responses.go b/pinboard/responses.go index e0e36cb..cdc1338 100644 --- a/pinboard/responses.go +++ b/pinboard/responses.go @@ -3,10 +3,14 @@ package pinboard import ( "encoding/json" "encoding/xml" + "links" + "links/internal/localizer" + "links/models" "net/http" "time" "github.com/labstack/echo/v4" + "netlandish.com/x/gobwebs/server" ) // PinboardPost represents a single post in Pinboard format @@ -37,10 +41,10 @@ type PinboardResult struct { // PinboardDates represents the dates response type PinboardDates struct { - XMLName xml.Name `xml:"dates" json:"-"` - User string `xml:"user,attr" json:"user"` - Tag string `xml:"tag,attr" json:"tag"` - Dates []PinboardDate `xml:"date" json:"dates"` + XMLName xml.Name `xml:"dates" json:"-"` + User string `xml:"user,attr" json:"user"` + Tag string `xml:"tag,attr" json:"tag"` + Dates []PinboardDate `xml:"date" json:"dates"` } // PinboardDate represents a single date entry @@ -51,12 +55,12 @@ type PinboardDate struct { // PinboardNote represents a note in Pinboard format type PinboardNote struct { - ID string `xml:"id,attr" json:"id"` - Title string `xml:"title,attr" json:"title"` - Hash string `xml:"hash,attr" json:"hash"` - CreatedAt string `xml:"created_at,attr" json:"created_at"` - UpdatedAt string `xml:"updated_at,attr" json:"updated_at"` - Length int `xml:"length,attr" json:"length"` + ID string `xml:"id,attr" json:"id"` + Title string `xml:"title,attr" json:"title"` + Hash string `xml:"hash,attr" json:"hash"` + CreatedAt string `xml:"created_at,attr" json:"created_at"` + UpdatedAt string `xml:"updated_at,attr" json:"updated_at"` + Length int `xml:"length,attr" json:"length"` } // PinboardNotes represents the notes list response @@ -87,7 +91,7 @@ func formatResponse(c echo.Context, data interface{}) error { if format == "" { format = c.FormValue("format") } - + if format == "json" { c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSON) return json.NewEncoder(c.Response()).Encode(data) @@ -101,8 +105,12 @@ func formatResponse(c echo.Context, data interface{}) error { // formatError returns an error response in Pinboard format func formatError(c echo.Context, message string) error { + gctx := c.(*server.Context) + user := gctx.User.(*models.User) + lang := links.GetLangFromRequest(c.Request(), user) + lt := localizer.GetLocalizer(lang) result := &PinboardResult{ - Code: "something went wrong: " + message, + Code: lt.Translate("something went wrong: %s", message), } c.Response().Status = http.StatusOK // Pinboard returns 200 even for errors return formatResponse(c, result) @@ -110,8 +118,12 @@ func formatError(c echo.Context, message string) error { // formatSuccess returns a success response in Pinboard format func formatSuccess(c echo.Context) error { + gctx := c.(*server.Context) + user := gctx.User.(*models.User) + lang := links.GetLangFromRequest(c.Request(), user) + lt := localizer.GetLocalizer(lang) result := &PinboardResult{ - Code: "done", + Code: lt.Translate("done"), } return formatResponse(c, result) } @@ -119,4 +131,4 @@ func formatSuccess(c echo.Context) error { // formatTime converts time to Pinboard format (RFC3339) func formatTime(t time.Time) string { return t.Format(time.RFC3339) -} \ No newline at end of file +} diff --git a/pinboard/routes.go b/pinboard/routes.go index 231fbd9..17b1d89 100644 --- a/pinboard/routes.go +++ b/pinboard/routes.go @@ -4,6 +4,7 @@ import ( "links" "links/accounts" "links/core" + "links/internal/localizer" "links/models" "net/http" "sort" @@ -57,21 +58,49 @@ func NewService(eg *echo.Group, render validate.TemplateRenderFunc) *Service { func (s *Service) getUserOrg(c echo.Context) (*models.Organization, error) { gctx := c.(*server.Context) user := gctx.User.(*models.User) + lang := links.GetLangFromRequest(c.Request(), user) + lt := localizer.GetLocalizer(lang) + var ( + org *models.Organization + orgSlug string + ) - orgs, err := models.GetOrganizations(c.Request().Context(), &database.FilterOptions{ - Filter: sq.And{ - sq.Eq{"o.owner_id": user.ID}, - sq.Eq{"o.org_type": models.OrgTypeUser}, - }, - Limit: 1, - }) - if err != nil { - return nil, err + orgSlug, ok := c.Get("use_org").(string) + if !ok || orgSlug == "" { + orgSlug = c.QueryParam("use_org") } - if len(orgs) == 0 { - return nil, echo.NewHTTPError(http.StatusNotFound, "No default organization found") + + if orgSlug != "" { + var err error + org, err = user.GetOrgsSlug(c.Request().Context(), models.OrgUserPermissionRead, orgSlug) + if err != nil || org == nil { + return nil, echo.NewHTTPError( + http.StatusNotFound, + lt.Translate("Invalid organization given"), + ) + } } - return orgs[0], nil + + if org == nil { + orgs, err := models.GetOrganizations(c.Request().Context(), &database.FilterOptions{ + Filter: sq.And{ + sq.Eq{"o.owner_id": user.ID}, + sq.Eq{"o.org_type": models.OrgTypeUser}, + }, + Limit: 1, + }) + if err != nil { + return nil, err + } + if len(orgs) == 0 { + return nil, echo.NewHTTPError( + http.StatusNotFound, + lt.Translate("No default organization found"), + ) + } + org = orgs[0] + } + return org, nil } // PostsAdd handles /v1/posts/add @@ -500,7 +529,7 @@ func (s *Service) PostsAll(c echo.Context) error { if err := c.Bind(&input); err != nil { return formatError(c, "Invalid input parameters") } - + // Workaround for test environment where c.Bind doesn't parse query params correctly // Manually parse if values are still zero/empty if input.Start == 0 && c.QueryParam("start") != "" { diff --git a/pinboard/routes_test.go b/pinboard/routes_test.go index a64d138..60b9e42 100644 --- a/pinboard/routes_test.go +++ b/pinboard/routes_test.go @@ -978,6 +978,156 @@ func TestResponseFormats(t *testing.T) { }) } +func TestOrganizationSelection(t *testing.T) { + c := require.New(t) + srv, e := test.NewWebTestServer(t) + cmd.RunMigrations(t, srv.DB) + + // User 1 has 'personal-org' (id=1) and 'business_org' (id=2) + user := test.NewTestUser(1, false, false, true, true) + + pinboardService := pinboard.NewService(e.Group("/pinboard"), links.Render) + defer srv.Shutdown() + go srv.Run() + + // Helper function to create authenticated context + createAuthContext := func(request *http.Request, recorder *httptest.ResponseRecorder) *server.Context { + ctx := &server.Context{ + Server: srv, + Context: e.NewContext(request, recorder), + User: user, + } + return ctx + } + + t.Run("valid organization via use_org query parameter", func(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + var capturedOrgSlug string + httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query", + func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + var gqlReq struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables"` + } + if err := json.Unmarshal(body, &gqlReq); err == nil { + if orgSlug, ok := gqlReq.Variables["orgSlug"]; ok { + if orgSlugStr, ok := orgSlug.(string); ok { + capturedOrgSlug = orgSlugStr + } + } + } + return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{ + "data": map[string]interface{}{ + "getOrgLinks": map[string]interface{}{ + "result": []map[string]interface{}{}, + }, + }, + }) + }) + + request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get?use_org=business_org", nil) + recorder := httptest.NewRecorder() + + ctx := createAuthContext(request, recorder) + ctx.SetPath("/pinboard/v1/posts/get") + + err := test.MakeRequest(srv, pinboardService.PostsGet, ctx) + c.NoError(err) + c.Equal(http.StatusOK, recorder.Code) + c.Equal("business_org", capturedOrgSlug) // Should use business_org instead of personal-org + }) + + t.Run("valid organization via Basic auth username", func(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + var capturedOrgSlug string + httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query", + func(req *http.Request) (*http.Response, error) { + body, _ := io.ReadAll(req.Body) + var gqlReq struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables"` + } + if err := json.Unmarshal(body, &gqlReq); err == nil { + if orgSlug, ok := gqlReq.Variables["orgSlug"]; ok { + if orgSlugStr, ok := orgSlug.(string); ok { + capturedOrgSlug = orgSlugStr + } + } + } + return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{ + "data": map[string]interface{}{ + "getOrgLinks": map[string]interface{}{ + "result": []map[string]interface{}{}, + }, + }, + }) + }) + + // Create a test group with Pinboard auth middleware + authGroup := e.Group("/test") + authGroup.Use(pinboard.PinboardAuthMiddleware()) + + authGroup.GET("/posts/get", func(ctx echo.Context) error { + // Forward to the actual handler + return pinboardService.PostsGet(ctx) + }) + + request := httptest.NewRequest(http.MethodGet, "/test/posts/get", nil) + auth := base64.StdEncoding.EncodeToString([]byte("business_org:testtoken")) + request.Header.Set("Authorization", "Basic "+auth) + recorder := httptest.NewRecorder() + + ctx := createAuthContext(request, recorder) + ctx.SetPath("/test/posts/get") + // Set the use_org from middleware + ctx.Set("use_org", "business_org") + + err := test.MakeRequest(srv, pinboardService.PostsGet, ctx) + c.NoError(err) + c.Equal(http.StatusOK, recorder.Code) + c.Equal("business_org", capturedOrgSlug) // Should use business_org instead of personal-org + }) + + t.Run("invalid organization via use_org query parameter", func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get?use_org=nonexistent-org", nil) + recorder := httptest.NewRecorder() + + ctx := createAuthContext(request, recorder) + ctx.SetPath("/pinboard/v1/posts/get") + + err := test.MakeRequest(srv, pinboardService.PostsGet, ctx) + c.NoError(err) + c.Equal(http.StatusOK, recorder.Code) + + body := recorder.Body.String() + c.True(strings.Contains(body, "something went wrong")) + c.True(strings.Contains(body, "Failed to get organization")) + }) + + t.Run("invalid organization via Basic auth username", func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get", nil) + recorder := httptest.NewRecorder() + + ctx := createAuthContext(request, recorder) + ctx.SetPath("/pinboard/v1/posts/get") + // Set invalid org from middleware + ctx.Set("use_org", "nonexistent-org") + + err := test.MakeRequest(srv, pinboardService.PostsGet, ctx) + c.NoError(err) + c.Equal(http.StatusOK, recorder.Code) + + body := recorder.Body.String() + c.True(strings.Contains(body, "something went wrong")) + c.True(strings.Contains(body, "Failed to get organization")) + }) +} + func TestEdgeCases(t *testing.T) { c := require.New(t) srv, e := test.NewWebTestServer(t) -- 2.49.1
Applied. To git@git.code.netlandish.com:~netlandish/links ec9ef1c..0b765b2 master -> master