Received: from mail.netlandish.com (mail.netlandish.com [174.136.98.166]) by code.netlandish.com (Postfix) with ESMTP id 212A711B4 for <~netlandish/links-dev@lists.code.netlandish.com>; Fri, 25 Jul 2025 01:19:16 +0000 (UTC) Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=209.85.221.180; helo=mail-vk1-f180.google.com; envelope-from=peter@netlandish.com; receiver= Authentication-Results: mail.netlandish.com; dkim=pass (1024-bit key; unprotected) header.d=netlandish.com header.i=@netlandish.com header.b=FW1l8JEs Received: from mail-vk1-f180.google.com (mail-vk1-f180.google.com [209.85.221.180]) by mail.netlandish.com (Postfix) with ESMTP id AA4681D640A for <~netlandish/links-dev@lists.code.netlandish.com>; Fri, 25 Jul 2025 01:19:59 +0000 (UTC) Received: by mail-vk1-f180.google.com with SMTP id 71dfb90a1353d-531b4da8189so584091e0c.1 for <~netlandish/links-dev@lists.code.netlandish.com>; Thu, 24 Jul 2025 18:19:59 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=netlandish.com; s=google; t=1753406399; x=1754011199; darn=lists.code.netlandish.com; h=content-transfer-encoding:mime-version:message-id:date:subject:cc :to:from:from:to:cc:subject:date:message-id:reply-to; bh=Dc80+HNmD2CM6+T+3yrc7G4lxJjLCJtlhs8nhH8IvjE=; b=FW1l8JEsRB3Ky9TEyRVKDcviWNnaELm+ba2kXqG/CppuTIlodi/GYfDlRdGPtcXBiH 0KUHfPm7zyL+67Whqgqjr8vgsWqGIgyIfpgQ32G5H1E/BookCRIiWx0RGZ64xYkmz2R1 vI12XFnnvjUb+xdbbWCTeW5jBDyhQMMmYYymE= X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1753406399; x=1754011199; h=content-transfer-encoding:mime-version:message-id:date:subject:cc :to:from:x-gm-message-state:from:to:cc:subject:date:message-id :reply-to; bh=Dc80+HNmD2CM6+T+3yrc7G4lxJjLCJtlhs8nhH8IvjE=; b=H7aA/p2AIYE7znYcwR0z03env35zj1n7rE/tCDO9F24Emul93H8uGlAVlrCSgp4orw dOU01d4rBA29kMdcfWIsqmgc/7LVku1izFkr078VOVa8JBLaufMOEWcWcOmdBpINhJu5 HKeGEA7NLT7anYKzy869Rs2uqwdNhFyOkHvw1dJBnvymD7dDEmR7QGxgrBO94ePw15Qv dqPdoDGPXVkFwL1s07kCBNFX2g1MaR6ENdP+E+vBMeexT2fB/nMuVjp4tMqvxmcixKIM gHLAVDUO++wj9xN1f5orXFslavJP5ERIuiocHCQZUtWIY7nUiBnVVB3oSNNnJ1pqXgf6 iHLA== X-Gm-Message-State: AOJu0YwMQd1lq9y49aHOSieyQeIz9lIbAOfSxHuuXdzSPLALffZ6SA8P S1SVqzvgGzx1LcutEqbejUtMKUm7wU/cXSoFGm6/nomquASWAdVJUSca1nB8mnL7v30c5Rf+sSi AjByCqvxsiQ== X-Gm-Gg: ASbGncsVO7alN2iIHUKn0GtkEpymDM9SQTPY+wAK31jHYwtz+voGdWAqHca+YJzCPZx VqBWAAKFj7KrsNcFDT39/xkZv7mF8l53E21950vnOfn++yYEk3UmPHaw+jADPrPacqU+xruo2Jc Azqu5dJaRv44HUJUfIuzcYGh/AyYr/U+a7FRtqIMH8KkChss7Rml/bCl0AhYDvnnzKSiz9VZ31g eVUxoUEsvnZjpSqClSQw91LsCqSem5aJaOjIouox5axZrlhjEcuYIdcNbinRJbQJUsjq55zGVnW dR//Z6MALjZRo3c8ignsNO34S1XYst0e5Eltt2ra5oGl++3XkrDHseY1rg807ZDw1c6UXaKDa8C x7qc9g3V9BlnvuzK08tVsQkU= X-Google-Smtp-Source: AGHT+IEFHztc9WJ8JxjQaWu01D1q/g8OGw66a3wL3XUrJ6n3Tv4xws0ufZLCKQWxXT/ukrtza/P6ig== X-Received: by 2002:a05:6122:c99:b0:530:63d9:115a with SMTP id 71dfb90a1353d-537af46f2bfmr5149261e0c.4.1753406398674; Thu, 24 Jul 2025 18:19:58 -0700 (PDT) Received: from localhost ([2803:2d60:1118:5ee:e143:ca03:7351:c358]) by smtp.gmail.com with UTF8SMTPSA id 71dfb90a1353d-537bf605885sm721922e0c.3.2025.07.24.18.19.57 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Thu, 24 Jul 2025 18:19:57 -0700 (PDT) From: Peter Sanchez To: ~netlandish/links-dev@lists.code.netlandish.com Cc: Peter Sanchez Subject: [PATCH links] Support specifying organization when using the Pinboard API bridge. Date: Thu, 24 Jul 2025 19:19:50 -0600 Message-ID: <20250725011954.26698-1-peter@netlandish.com> X-Mailer: git-send-email 2.49.1 MIME-Version: 1.0 Content-Transfer-Encoding: 8bit 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