~netlandish/links-dev

links: Support specifying organization when using the Pinboard API bridge. v1 APPLIED

Peter Sanchez: 1
 Support specifying organization when using the Pinboard API bridge.

 4 files changed, 220 insertions(+), 27 deletions(-)
Export patchset (mbox)
How do I use this?

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 -3
Learn more about email & git

[PATCH links] Support specifying organization when using the Pinboard API bridge. Export this patch

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