diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/client.go | 4 | ||||
| -rw-r--r-- | internal/api/client/tokens/tokenget.go | 98 | ||||
| -rw-r--r-- | internal/api/client/tokens/tokenget_test.go | 78 | ||||
| -rw-r--r-- | internal/api/client/tokens/tokeninvalidate.go | 103 | ||||
| -rw-r--r-- | internal/api/client/tokens/tokeninvalidate_test.go | 87 | ||||
| -rw-r--r-- | internal/api/client/tokens/tokens.go | 48 | ||||
| -rw-r--r-- | internal/api/client/tokens/tokens_test.go | 117 | ||||
| -rw-r--r-- | internal/api/client/tokens/tokensget.go | 144 | ||||
| -rw-r--r-- | internal/api/client/tokens/tokensget_test.go | 69 | ||||
| -rw-r--r-- | internal/api/model/token.go | 22 | ||||
| -rw-r--r-- | internal/db/application.go | 4 | ||||
| -rw-r--r-- | internal/db/bundb/application.go | 102 | ||||
| -rw-r--r-- | internal/processing/account/tokens.go | 122 | ||||
| -rw-r--r-- | internal/typeutils/internaltofrontend.go | 36 |
14 files changed, 1034 insertions, 0 deletions
diff --git a/internal/api/client.go b/internal/api/client.go index 3112aeea5..a928176de 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -54,6 +54,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/streaming" "github.com/superseriousbusiness/gotosocial/internal/api/client/tags" "github.com/superseriousbusiness/gotosocial/internal/api/client/timelines" + "github.com/superseriousbusiness/gotosocial/internal/api/client/tokens" "github.com/superseriousbusiness/gotosocial/internal/api/client/user" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/middleware" @@ -99,6 +100,7 @@ type Client struct { streaming *streaming.Module // api/v1/streaming tags *tags.Module // api/v1/tags timelines *timelines.Module // api/v1/timelines + tokens *tokens.Module // api/v1/tokens user *user.Module // api/v1/user } @@ -152,6 +154,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) { c.streaming.Route(h) c.tags.Route(h) c.timelines.Route(h) + c.tokens.Route(h) c.user.Route(h) } @@ -193,6 +196,7 @@ func NewClient(state *state.State, p *processing.Processor) *Client { streaming: streaming.New(p, time.Second*30, 4096), tags: tags.New(p), timelines: timelines.New(p), + tokens: tokens.New(p), user: user.New(p), } } diff --git a/internal/api/client/tokens/tokenget.go b/internal/api/client/tokens/tokenget.go new file mode 100644 index 000000000..c88b78743 --- /dev/null +++ b/internal/api/client/tokens/tokenget.go @@ -0,0 +1,98 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tokens + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// TokenInfoGETHandler swagger:operation GET /api/v1/tokens/{id} tokenInfoGet +// +// Get information about a single token. +// +// --- +// tags: +// - tokens +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: The id of the requested token. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:accounts +// +// responses: +// '200': +// description: The requested token. +// schema: +// "$ref": "#/definitions/tokenInfo" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) TokenInfoGETHandler(c *gin.Context) { + authed, errWithCode := apiutil.TokenAuth(c, + true, true, true, true, + apiutil.ScopeReadAccounts, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + tokenID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + tokenInfo, errWithCode := m.processor.Account().TokenGet( + c.Request.Context(), + authed.User.ID, + tokenID, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, tokenInfo) +} diff --git a/internal/api/client/tokens/tokenget_test.go b/internal/api/client/tokens/tokenget_test.go new file mode 100644 index 000000000..c7cbf3022 --- /dev/null +++ b/internal/api/client/tokens/tokenget_test.go @@ -0,0 +1,78 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tokens_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/tokens" +) + +type TokenGetTestSuite struct { + TokensStandardTestSuite +} + +func (suite *TokenGetTestSuite) TestTokenGet() { + var ( + testToken = suite.testTokens["local_account_1"] + testPath = "/api" + tokens.BasePath + "/" + testToken.ID + ) + + out, code := suite.req( + http.MethodGet, + testPath, + suite.tokens.TokenInfoGETHandler, + map[string]string{"id": testToken.ID}, + ) + + suite.Equal(http.StatusOK, code) + suite.Equal(`{ + "id": "01F8MGTQW4DKTDF8SW5CT9HYGA", + "created_at": "2021-06-20T10:53:00.164Z", + "scope": "read write push", + "application": { + "name": "really cool gts application", + "website": "https://reallycool.app" + } +}`, out) +} + +func (suite *TokenGetTestSuite) TestTokenGetNotOurs() { + var ( + testToken = suite.testTokens["admin_account"] + testPath = "/api" + tokens.BasePath + "/" + testToken.ID + ) + + out, code := suite.req( + http.MethodGet, + testPath, + suite.tokens.TokenInfoGETHandler, + map[string]string{"id": testToken.ID}, + ) + + suite.Equal(http.StatusNotFound, code) + suite.Equal(`{ + "error": "Not Found" +}`, out) +} + +func TestTokenGetTestSuite(t *testing.T) { + suite.Run(t, new(TokenGetTestSuite)) +} diff --git a/internal/api/client/tokens/tokeninvalidate.go b/internal/api/client/tokens/tokeninvalidate.go new file mode 100644 index 000000000..192bbf33b --- /dev/null +++ b/internal/api/client/tokens/tokeninvalidate.go @@ -0,0 +1,103 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tokens + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// TokenInvalidatePOSTHandler swagger:operation POST /api/v1/tokens/{id}/invalidate tokenInvalidatePost +// +// Invalidate the target token, removing it from the database and making it unusable. +// +// --- +// tags: +// - tokens +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: The id of the target token. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - write:accounts +// +// responses: +// '200': +// description: Info about the invalidated token. +// schema: +// "$ref": "#/definitions/tokenInfo" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) TokenInvalidatePOSTHandler(c *gin.Context) { + authed, errWithCode := apiutil.TokenAuth(c, + true, true, true, true, + apiutil.ScopeWriteAccounts, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if authed.Account.IsMoving() { + apiutil.ForbiddenAfterMove(c) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + tokenID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + tokenInfo, errWithCode := m.processor.Account().TokenInvalidate( + c.Request.Context(), + authed.User.ID, + tokenID, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, tokenInfo) +} diff --git a/internal/api/client/tokens/tokeninvalidate_test.go b/internal/api/client/tokens/tokeninvalidate_test.go new file mode 100644 index 000000000..281f9b96d --- /dev/null +++ b/internal/api/client/tokens/tokeninvalidate_test.go @@ -0,0 +1,87 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tokens_test + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/tokens" + "github.com/superseriousbusiness/gotosocial/internal/db" +) + +type TokenInvalidateTestSuite struct { + TokensStandardTestSuite +} + +func (suite *TokenInvalidateTestSuite) TestTokenInvalidate() { + var ( + testToken = suite.testTokens["local_account_1"] + testPath = "/api" + tokens.BasePath + "/" + testToken.ID + "/invalidate" + ) + + out, code := suite.req( + http.MethodPost, + testPath, + suite.tokens.TokenInvalidatePOSTHandler, + map[string]string{"id": testToken.ID}, + ) + + suite.Equal(http.StatusOK, code) + suite.Equal(`{ + "id": "01F8MGTQW4DKTDF8SW5CT9HYGA", + "created_at": "2021-06-20T10:53:00.164Z", + "scope": "read write push", + "application": { + "name": "really cool gts application", + "website": "https://reallycool.app" + } +}`, out) + + // Check database for token we + // just invalidated, should be gone. + _, err := suite.testStructs.State.DB.GetTokenByID( + context.Background(), testToken.ID, + ) + suite.ErrorIs(err, db.ErrNoEntries) +} + +func (suite *TokenInvalidateTestSuite) TestTokenInvalidateNotOurs() { + var ( + testToken = suite.testTokens["admin_account"] + testPath = "/api" + tokens.BasePath + "/" + testToken.ID + "/invalidate" + ) + + out, code := suite.req( + http.MethodGet, + testPath, + suite.tokens.TokenInfoGETHandler, + map[string]string{"id": testToken.ID}, + ) + + suite.Equal(http.StatusNotFound, code) + suite.Equal(`{ + "error": "Not Found" +}`, out) +} + +func TestTokenInvalidateTestSuite(t *testing.T) { + suite.Run(t, new(TokenInvalidateTestSuite)) +} diff --git a/internal/api/client/tokens/tokens.go b/internal/api/client/tokens/tokens.go new file mode 100644 index 000000000..ce00a6459 --- /dev/null +++ b/internal/api/client/tokens/tokens.go @@ -0,0 +1,48 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tokens + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + BasePath = "/v1/tokens" + BasePathWithID = BasePath + "/:" + apiutil.IDKey + InvalidateTokenPath = BasePathWithID + "/invalidate" +) + +type Module struct { + processor *processing.Processor +} + +func New(processor *processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, BasePath, m.TokensInfoGETHandler) + attachHandler(http.MethodGet, BasePathWithID, m.TokensInfoGETHandler) + attachHandler(http.MethodPost, InvalidateTokenPath, m.TokenInvalidatePOSTHandler) +} diff --git a/internal/api/client/tokens/tokens_test.go b/internal/api/client/tokens/tokens_test.go new file mode 100644 index 000000000..bae140194 --- /dev/null +++ b/internal/api/client/tokens/tokens_test.go @@ -0,0 +1,117 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tokens_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http/httptest" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/tokens" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type TokensStandardTestSuite struct { + suite.Suite + + // standard suite models + testTokens map[string]*gtsmodel.Token + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testStructs *testrig.TestStructs + + // module being tested + tokens *tokens.Module +} + +func (suite *TokensStandardTestSuite) req( + httpMethod string, + requestPath string, + handler gin.HandlerFunc, + pathParams map[string]string, +) (string, int) { + var ( + recorder = httptest.NewRecorder() + ctx, _ = testrig.CreateGinTestContext(recorder, nil) + ) + + // Prepare test context. + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // Prepare test context request. + request := httptest.NewRequest(httpMethod, requestPath, nil) + request.Header.Set("accept", "application/json") + ctx.Request = request + + // Inject path parameters. + if pathParams != nil { + for k, v := range pathParams { + ctx.AddParam(k, v) + } + } + + // Trigger the handler + handler(ctx) + + // Read the response + result := recorder.Result() + defer result.Body.Close() + b, err := io.ReadAll(result.Body) + if err != nil { + suite.FailNow(err.Error()) + } + + // Format as nice indented json. + dst := &bytes.Buffer{} + if err := json.Indent(dst, b, "", " "); err != nil { + suite.FailNow(err.Error()) + } + + return dst.String(), recorder.Code +} + +func (suite *TokensStandardTestSuite) SetupSuite() { + testrig.InitTestConfig() + testrig.InitTestLog() + + suite.testTokens = testrig.NewTestTokens() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() +} + +func (suite *TokensStandardTestSuite) SetupTest() { + suite.testStructs = testrig.SetupTestStructs( + "../../../../testrig/media", + "../../../../web/template", + ) + suite.tokens = tokens.New(suite.testStructs.Processor) +} + +func (suite *TokensStandardTestSuite) TearDownTest() { + testrig.TearDownTestStructs(suite.testStructs) +} diff --git a/internal/api/client/tokens/tokensget.go b/internal/api/client/tokens/tokensget.go new file mode 100644 index 000000000..2ffc2afb9 --- /dev/null +++ b/internal/api/client/tokens/tokensget.go @@ -0,0 +1,144 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tokens + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +// TokensInfoGETHandler swagger:operation GET /api/v1/tokens tokensInfoGet +// +// See info about tokens created for/by your account. +// +// The items will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer). +// +// The returned Link header can be used to generate the previous and next queries when paging up or down. +// +// Example: +// +// ``` +// <https://example.org/api/v1/tokens?limit=20&max_id=01FC3GSQ8A3MMJ43BPZSGEG29M>; rel="next", <https://example.org/api/v1/tokens?limit=20&min_id=01FC3KJW2GYXSDDRA6RWNDM46M>; rel="prev" +// ```` +// +// --- +// tags: +// - tokens +// +// produces: +// - application/json +// +// parameters: +// - +// name: max_id +// type: string +// description: >- +// Return only items *OLDER* than the given max status ID. +// The item with the specified ID will not be included in the response. +// in: query +// required: false +// - +// name: since_id +// type: string +// description: >- +// Return only items *newer* than the given since status ID. +// The item with the specified ID will not be included in the response. +// in: query +// - +// name: min_id +// type: string +// description: >- +// Return only items *immediately newer* than the given since status ID. +// The item with the specified ID will not be included in the response. +// in: query +// required: false +// - +// name: limit +// type: integer +// description: Number of items to return. +// default: 20 +// in: query +// required: false +// max: 80 +// min: 0 +// +// security: +// - OAuth2 Bearer: +// - read:accounts +// +// responses: +// '200': +// name: tokens +// description: Array of token info entries. +// schema: +// type: array +// items: +// "$ref": "#/definitions/tokenInfo" +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// '401': +// description: unauthorized +// '400': +// description: bad request +func (m *Module) TokensInfoGETHandler(c *gin.Context) { + authed, errWithCode := apiutil.TokenAuth(c, + true, true, true, true, + apiutil.ScopeReadAccounts, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + page, errWithCode := paging.ParseIDPage(c, + 0, // min limit + 80, // max limit + 20, // default limit + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Account().TokensGet( + c.Request.Context(), + authed.User.ID, + page, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + + apiutil.JSON(c, http.StatusOK, resp.Items) +} diff --git a/internal/api/client/tokens/tokensget_test.go b/internal/api/client/tokens/tokensget_test.go new file mode 100644 index 000000000..0164c0379 --- /dev/null +++ b/internal/api/client/tokens/tokensget_test.go @@ -0,0 +1,69 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package tokens_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/tokens" +) + +type TokensGetTestSuite struct { + TokensStandardTestSuite +} + +func (suite *TokensGetTestSuite) TestTokensGet() { + var ( + testPath = "/api" + tokens.BasePath + ) + + out, code := suite.req( + http.MethodGet, + testPath, + suite.tokens.TokensInfoGETHandler, + nil, + ) + + suite.Equal(http.StatusOK, code) + suite.Equal(`[ + { + "id": "01JN0X2D9GJTZQ5KYPYFWN16QW", + "created_at": "2025-02-26T10:33:04.560Z", + "scope": "push", + "application": { + "name": "really cool gts application", + "website": "https://reallycool.app" + } + }, + { + "id": "01F8MGTQW4DKTDF8SW5CT9HYGA", + "created_at": "2021-06-20T10:53:00.164Z", + "scope": "read write push", + "application": { + "name": "really cool gts application", + "website": "https://reallycool.app" + } + } +]`, out) +} + +func TestTokensGetTestSuite(t *testing.T) { + suite.Run(t, new(TokensGetTestSuite)) +} diff --git a/internal/api/model/token.go b/internal/api/model/token.go index 5a1abe28f..3ad45e684 100644 --- a/internal/api/model/token.go +++ b/internal/api/model/token.go @@ -33,3 +33,25 @@ type Token struct { // example: 1627644520 CreatedAt int64 `json:"created_at"` } + +// TokenInfo represents metadata about one user-level access token. +// The actual access token itself will never be sent via the API. +// +// swagger:model tokenInfo +type TokenInfo struct { + // Database ID of this token. + // example: 01JMW7QBAZYZ8T8H73PCEX12XG + ID string `json:"id"` + // When the token was created (ISO 8601 Datetime). + // example: 2021-07-30T09:20:25+00:00 + CreatedAt string `json:"created_at"` + // Approximate time (accurate to within an hour) when the token was last used (ISO 8601 Datetime). + // Omitted if token has never been used, or it is not known when it was last used (eg., it was last used before tracking "last_used" became a thing). + // example: 2021-07-30T09:20:25+00:00 + LastUsed string `json:"last_used,omitempty"` + // OAuth scopes granted by the token, space-separated. + // example: read write admin + Scope string `json:"scope"` + // Application used to create this token. + Application *Application `json:"application"` +} diff --git a/internal/db/application.go b/internal/db/application.go index 9f0109d59..a3061f028 100644 --- a/internal/db/application.go +++ b/internal/db/application.go @@ -21,6 +21,7 @@ import ( "context" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/paging" ) type Application interface { @@ -39,6 +40,9 @@ type Application interface { // GetAllTokens fetches all client oauth tokens from database. GetAllTokens(ctx context.Context) ([]*gtsmodel.Token, error) + // GetAccessTokens allows paging through a user's access (ie., user-level) tokens. + GetAccessTokens(ctx context.Context, userID string, page *paging.Page) ([]*gtsmodel.Token, error) + // GetTokenByID fetches the client oauth token from database with ID. GetTokenByID(ctx context.Context, id string) (*gtsmodel.Token, error) diff --git a/internal/db/bundb/application.go b/internal/db/bundb/application.go index d94c984d0..c21221c9f 100644 --- a/internal/db/bundb/application.go +++ b/internal/db/bundb/application.go @@ -19,8 +19,11 @@ package bundb import ( "context" + "slices" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/paging" "github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/util/xslices" "github.com/uptrace/bun" @@ -139,6 +142,74 @@ func (a *applicationDB) GetAllTokens(ctx context.Context) ([]*gtsmodel.Token, er return tokens, nil } +func (a *applicationDB) GetAccessTokens( + ctx context.Context, + userID string, + page *paging.Page, +) ([]*gtsmodel.Token, error) { + var ( + // Get paging params. + minID = page.GetMin() + maxID = page.GetMax() + limit = page.GetLimit() + order = page.GetOrder() + + // Make educated guess for slice size. + tokenIDs = make([]string, 0, limit) + ) + + // Ensure user ID. + if userID == "" { + return nil, gtserror.New("userID not set") + } + + q := a.db. + NewSelect(). + TableExpr("? AS ?", bun.Ident("tokens"), bun.Ident("token")). + Column("token.id"). + Where("? = ?", bun.Ident("token.user_id"), userID). + Where("? != ?", bun.Ident("token.access"), "") + + if maxID != "" { + // Return only tokens LOWER (ie., older) than maxID. + q = q.Where("? < ?", bun.Ident("token.id"), maxID) + } + + if minID != "" { + // Return only tokens HIGHER (ie., newer) than minID. + q = q.Where("? > ?", bun.Ident("token.id"), minID) + } + + if limit > 0 { + q = q.Limit(limit) + } + + if order == paging.OrderAscending { + // Page up. + q = q.Order("token.id ASC") + } else { + // Page down. + q = q.Order("token.id DESC") + } + + if err := q.Scan(ctx, &tokenIDs); err != nil { + return nil, err + } + + if len(tokenIDs) == 0 { + return nil, nil + } + + // If we're paging up, we still want tokens + // to be sorted by ID desc (ie., newest to + // oldest), so reverse ids slice. + if order == paging.OrderAscending { + slices.Reverse(tokenIDs) + } + + return a.getTokensByIDs(ctx, tokenIDs) +} + func (a *applicationDB) GetTokenByID(ctx context.Context, code string) (*gtsmodel.Token, error) { return a.getTokenBy( "ID", @@ -149,6 +220,37 @@ func (a *applicationDB) GetTokenByID(ctx context.Context, code string) (*gtsmode ) } +func (a *applicationDB) getTokensByIDs(ctx context.Context, ids []string) ([]*gtsmodel.Token, error) { + tokens, err := a.state.Caches.DB.Token.LoadIDs("ID", + ids, + func(uncached []string) ([]*gtsmodel.Token, error) { + // Preallocate expected length of uncached tokens. + tokens := make([]*gtsmodel.Token, 0, len(uncached)) + + // Perform database query scanning + // the remaining (uncached) token IDs. + if err := a.db.NewSelect(). + Model(&tokens). + Where("? IN (?)", bun.Ident("id"), bun.In(uncached)). + Scan(ctx); err != nil { + return nil, err + } + + return tokens, nil + }, + ) + if err != nil { + return nil, err + } + + // Reorder the tokens by their + // IDs to ensure in correct order. + getID := func(t *gtsmodel.Token) string { return t.ID } + xslices.OrderBy(tokens, ids, getID) + + return tokens, nil +} + func (a *applicationDB) GetTokenByCode(ctx context.Context, code string) (*gtsmodel.Token, error) { return a.getTokenBy( "Code", diff --git a/internal/processing/account/tokens.go b/internal/processing/account/tokens.go new file mode 100644 index 000000000..dcd997839 --- /dev/null +++ b/internal/processing/account/tokens.go @@ -0,0 +1,122 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. + +package account + +import ( + "context" + "errors" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +func (p *Processor) TokensGet( + ctx context.Context, + userID string, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + tokens, err := p.state.DB.GetAccessTokens(ctx, userID, page) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting tokens: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + count := len(tokens) + if count == 0 { + return paging.EmptyResponse(), nil + } + + var ( + // Get the lowest and highest + // ID values, used for paging. + lo = tokens[count-1].ID + hi = tokens[0].ID + + // Best-guess items length. + items = make([]interface{}, 0, count) + ) + + for _, token := range tokens { + tokenInfo, err := p.converter.TokenToAPITokenInfo(ctx, token) + if err != nil { + log.Errorf(ctx, "error converting token to api token info: %v", err) + continue + } + + // Append req to return items. + items = append(items, tokenInfo) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/tokens", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + }), nil +} + +func (p *Processor) TokenGet( + ctx context.Context, + userID string, + tokenID string, +) (*apimodel.TokenInfo, gtserror.WithCode) { + token, err := p.state.DB.GetTokenByID(ctx, tokenID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting token %s: %w", tokenID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + if token == nil { + err := gtserror.Newf("token %s not found in the db", tokenID) + return nil, gtserror.NewErrorNotFound(err) + } + + if token.UserID != userID { + err := gtserror.Newf("token %s does not belong to user %s", tokenID, userID) + return nil, gtserror.NewErrorNotFound(err) + } + + tokenInfo, err := p.converter.TokenToAPITokenInfo(ctx, token) + if err != nil { + err := gtserror.Newf("error converting token to api token info: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return tokenInfo, nil +} + +func (p *Processor) TokenInvalidate( + ctx context.Context, + userID string, + tokenID string, +) (*apimodel.TokenInfo, gtserror.WithCode) { + tokenInfo, errWithCode := p.TokenGet(ctx, userID, tokenID) + if errWithCode != nil { + return nil, errWithCode + } + + if err := p.state.DB.DeleteTokenByID(ctx, tokenID); err != nil { + err := gtserror.Newf("db error deleting token %s: %w", tokenID, err) + return nil, gtserror.NewErrorInternalError(err) + } + + return tokenInfo, nil +} diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 510b165d1..8bd92512a 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -3068,3 +3068,39 @@ func (c *Converter) WebPushSubscriptionToAPIWebPushSubscription( Standard: true, }, nil } + +func (c *Converter) TokenToAPITokenInfo( + ctx context.Context, + token *gtsmodel.Token, +) (*apimodel.TokenInfo, error) { + createdAt, err := id.TimeFromULID(token.ID) + if err != nil { + err := gtserror.Newf("error parsing time from token id: %w", err) + return nil, err + } + + var lastUsed string + if !token.LastUsed.IsZero() { + lastUsed = util.FormatISO8601(token.LastUsed) + } + + application, err := c.state.DB.GetApplicationByClientID(ctx, token.ClientID) + if err != nil { + err := gtserror.Newf("db error getting application with client id %s: %w", token.ClientID, err) + return nil, err + } + + apiApplication, err := c.AppToAPIAppPublic(ctx, application) + if err != nil { + err := gtserror.Newf("error converting application to api application: %w", err) + return nil, err + } + + return &apimodel.TokenInfo{ + ID: token.ID, + CreatedAt: util.FormatISO8601(createdAt), + LastUsed: lastUsed, + Scope: token.Scope, + Application: apiApplication, + }, nil +} |
