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