Subject: [PATCH links] Support specifying organization when using the Pinboard API bridge.

From: Peter Sanchez <[email-masked]>
Date: Thu, 24 Jul 2025 19:19:50 -0600

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