diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index c8b263afe..25b23770c 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -3369,6 +3369,37 @@ definitions: type: object x-go-name: ThreadContext x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + tokenInfo: + description: The actual access token itself will never be sent via the API. + properties: + application: + $ref: '#/definitions/application' + created_at: + description: When the token was created (ISO 8601 Datetime). + example: "2021-07-30T09:20:25+00:00" + type: string + x-go-name: CreatedAt + id: + description: Database ID of this token. + example: 01JMW7QBAZYZ8T8H73PCEX12XG + type: string + x-go-name: ID + last_used: + description: |- + 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" + type: string + x-go-name: LastUsed + scope: + description: OAuth scopes granted by the token, space-separated. + example: read write admin + type: string + x-go-name: Scope + title: TokenInfo represents metadata about one user-level access token. + type: object + x-go-name: TokenInfo + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model user: properties: admin: @@ -11642,6 +11673,124 @@ paths: summary: See public statuses that use the given hashtag (case insensitive). tags: - timelines + /api/v1/tokens: + get: + description: |- + 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: + + ``` + ; rel="next", ; rel="prev" + ```` + operationId: tokensInfoGet + parameters: + - 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 + name: max_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: since_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 + name: min_id + type: string + - default: 20 + description: Number of items to return. + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: Array of token info entries. + headers: + Link: + description: Links to the next and previous queries. + type: string + schema: + items: + $ref: '#/definitions/tokenInfo' + type: array + "400": + description: bad request + "401": + description: unauthorized + security: + - OAuth2 Bearer: + - read:accounts + summary: See info about tokens created for/by your account. + tags: + - tokens + /api/v1/tokens/{id}: + get: + operationId: tokenInfoGet + parameters: + - description: The id of the requested token. + in: path + name: id + required: true + type: string + produces: + - application/json + 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 + security: + - OAuth2 Bearer: + - read:accounts + summary: Get information about a single token. + tags: + - tokens + /api/v1/tokens/{id}/invalidate: + post: + operationId: tokenInvalidatePost + parameters: + - description: The id of the target token. + in: path + name: id + required: true + type: string + produces: + - application/json + 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 + security: + - OAuth2 Bearer: + - write:accounts + summary: Invalidate the target token, removing it from the database and making it unusable. + tags: + - tokens /api/v1/user: get: operationId: getUser diff --git a/docs/overrides/public/user-settings-access-tokens.png b/docs/overrides/public/user-settings-access-tokens.png new file mode 100644 index 000000000..a710bdd23 Binary files /dev/null and b/docs/overrides/public/user-settings-access-tokens.png differ diff --git a/docs/user_guide/settings.md b/docs/user_guide/settings.md index 691181cb9..7e85e0ed8 100644 --- a/docs/user_guide/settings.md +++ b/docs/user_guide/settings.md @@ -269,3 +269,21 @@ Both merge and overwrite operations are idempotent, which basically means that d !!! warning The CSV format for mutes does not contain expiration data, so temporary mutes are exported (and imported) as permanent mutes. + +## Access Tokens + +In the access tokens section, you can review and invalidate [OAuth access tokens](https://www.oauth.com/oauth2-servers/access-tokens/) owned by applications that you have authorized to access your account and/or perform actions on your behalf. + +![The access tokens page.](../public/user-settings-access-tokens.png) + +In cases where you've logged in with an application multiple times, or logged in with multiple devices or browsers, you may see multiple tokens with the same application name. This is normal! For example, say you have logged in with Pinafore on both your phone and your laptop browser, you will see two different tokens owned by Pinafore. + +You can invalidate a token by clicking on the "Invalidate token" button under a token. This will remove the token from the database. The application that was authorized to access your account with that token will then no longer be authorized to do so, and you will need to log out and/or log in again with that application. + +Logging out of an application does not necessarily remove the token from the GoToSocial database, so old tokens may linger from applications you used a long time ago. So, feel free to invalidate tokens that have never been used, or haven't been used in a long time; it's good security practice to keep only the tokens that you need, and it's fun to click the big red button. + +!!! danger + If you see any tokens from applications that you do not recognize, or do not remember authorizing to access your account, then you should invalidate them, and consider changing your password as soon as possible. + +!!! note + Token "Last used" time is approximate and may be off by an hour in either direction. 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 @@ "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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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 . + +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: +// +// ``` +// ; rel="next", ; 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 . + +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 @@ "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 @@ 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(uncached []string) ([]*gtsmodel.Token, error) { 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(t *gtsmodel.Token) error { ) } +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 . + +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 +} diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts index 34b66913a..401423766 100644 --- a/web/source/settings/lib/query/gts-api.ts +++ b/web/source/settings/lib/query/gts-api.ts @@ -171,7 +171,8 @@ export const gtsApi = createApi({ "InteractionRequest", "DomainPermissionDraft", "DomainPermissionExclude", - "DomainPermissionSubscription" + "DomainPermissionSubscription", + "TokenInfo", ], endpoints: (build) => ({ instanceV1: build.query({ diff --git a/web/source/settings/lib/query/user/tokens.ts b/web/source/settings/lib/query/user/tokens.ts new file mode 100644 index 000000000..5ba4d1355 --- /dev/null +++ b/web/source/settings/lib/query/user/tokens.ts @@ -0,0 +1,73 @@ +/* + 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 . +*/ + +import { + SearchTokenInfoParams, + SearchTokenInfoResp, + TokenInfo, +} from "../../types/tokeninfo"; +import { gtsApi } from "../gts-api"; +import parse from "parse-link-header"; + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + searchTokenInfo: build.query({ + query: (form) => { + const params = new(URLSearchParams); + Object.entries(form).forEach(([k, v]) => { + if (v !== undefined) { + params.append(k, v); + } + }); + + let query = ""; + if (params.size !== 0) { + query = `?${params.toString()}`; + } + + return { + url: `/api/v1/tokens${query}` + }; + }, + // Headers required for paging. + transformResponse: (apiResp: TokenInfo[], meta) => { + const tokens = apiResp; + const linksStr = meta?.response?.headers.get("Link"); + const links = parse(linksStr); + return { tokens, links }; + }, + providesTags: [{ type: "TokenInfo", id: "TRANSFORMED" }] + }), + invalidateToken: build.mutation({ + query: (id) => ({ + method: "POST", + url: `/api/v1/tokens/${id}/invalidate`, + }), + invalidatesTags: (res) => + res + ? [{ type: "TokenInfo", id: "TRANSFORMED" }, { type: "InteractionRequest", id: res.id }] + : [{ type: "TokenInfo", id: "TRANSFORMED" }] + }), + }) +}); + +export const { + useLazySearchTokenInfoQuery, + useInvalidateTokenMutation, +} = extended; diff --git a/web/source/settings/lib/types/tokeninfo.ts b/web/source/settings/lib/types/tokeninfo.ts new file mode 100644 index 000000000..989ed67be --- /dev/null +++ b/web/source/settings/lib/types/tokeninfo.ts @@ -0,0 +1,62 @@ +/* + 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 . +*/ + +import { Links } from "parse-link-header"; + +export interface TokenInfo { + id: string; + created_at: string; + last_used?: string; + scope: string; + application: { + name: string; + website?: string; + }; +} + +/** + * Parameters for GET to /api/v1/tokens. + */ +export interface SearchTokenInfoParams { + /** + * If set, show only items older (ie., lower) than the given ID. + * Item with the given ID will not be included in response. + */ + max_id?: string; + /** + * If set, show only items newer (ie., higher) than the given ID. + * Item with the given ID will not be included in response. + */ + since_id?: string; + /** + * If set, show only items *immediately newer* than the given ID. + * Item with the given ID will not be included in response. + */ + min_id?: string; + /** + * If set, limit returned items to this number. + * Else, fall back to GtS API defaults. + */ + limit?: number; +} + +export interface SearchTokenInfoResp { + tokens: TokenInfo[]; + links: Links | null; +} diff --git a/web/source/settings/style.css b/web/source/settings/style.css index 75e7e7e3f..5a85f370e 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -1468,6 +1468,33 @@ button.tab-button { gap: 1rem; } +.tokens-view { + .token-info { + .info-list { + border: none; + width: 100%; + + .info-list-entry { + background: none; + padding: 0; + } + + > .info-list-entry > .monospace { + font-size: large; + } + } + + .action-buttons { + margin-top: 0.5rem; + > .mutation-button + > button { + font-size: 1rem; + line-height: 1rem; + } + } + } +} + .instance-rules { list-style-position: inside; margin: 0; diff --git a/web/source/settings/views/user/menu.tsx b/web/source/settings/views/user/menu.tsx index 85734ae52..570912ef2 100644 --- a/web/source/settings/views/user/menu.tsx +++ b/web/source/settings/views/user/menu.tsx @@ -63,6 +63,11 @@ export default function UserMenu() { itemUrl="export-import" icon="fa-floppy-o" /> + ); } diff --git a/web/source/settings/views/user/router.tsx b/web/source/settings/views/user/router.tsx index 091dd40ae..be1fa4434 100644 --- a/web/source/settings/views/user/router.tsx +++ b/web/source/settings/views/user/router.tsx @@ -28,6 +28,7 @@ import EmailPassword from "./emailpassword"; import ExportImport from "./export-import"; import InteractionRequests from "./interactions"; import InteractionRequestDetail from "./interactions/detail"; +import Tokens from "./tokens"; /** * - /settings/user/profile @@ -35,6 +36,7 @@ import InteractionRequestDetail from "./interactions/detail"; * - /settings/user/emailpassword * - /settings/user/migration * - /settings/user/export-import + * - /settings/user/tokens * - /settings/users/interaction_requests */ export default function UserRouter() { @@ -52,6 +54,7 @@ export default function UserRouter() { + diff --git a/web/source/settings/views/user/tokens/index.tsx b/web/source/settings/views/user/tokens/index.tsx new file mode 100644 index 000000000..c8a8b8e38 --- /dev/null +++ b/web/source/settings/views/user/tokens/index.tsx @@ -0,0 +1,50 @@ +/* + 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 . +*/ + +import React from "react"; +import TokensSearchForm from "./search"; + +export default function Tokens() { + return ( +
+
+

Access Tokens

+

+ On this page you can search through access tokens owned by applications that you have authorized to + access your account and/or perform actions on your behalf. You can invalidate a token by clicking on + the invalidate button under a token. This will remove the token from the database. +

+ + If you see any tokens from applications that you do not recognize, or do not remember authorizing to access + your account, then you should invalidate them, and consider changing your password as soon as possible. + +

+ + Learn more about managing your access tokens (opens in a new tab) + +
+ +
+ ); +} diff --git a/web/source/settings/views/user/tokens/search.tsx b/web/source/settings/views/user/tokens/search.tsx new file mode 100644 index 000000000..87080cc8f --- /dev/null +++ b/web/source/settings/views/user/tokens/search.tsx @@ -0,0 +1,214 @@ +/* + 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 . +*/ + +import React, { ReactNode, useEffect, useMemo } from "react"; + +import { useTextInput } from "../../../lib/form"; +import { PageableList } from "../../../components/pageable-list"; +import MutationButton from "../../../components/form/mutation-button"; +import { useLocation, useSearch } from "wouter"; +import { Select } from "../../../components/form/inputs"; +import { useInvalidateTokenMutation, useLazySearchTokenInfoQuery } from "../../../lib/query/user/tokens"; +import { TokenInfo } from "../../../lib/types/tokeninfo"; + +export default function TokensSearchForm() { + const [ location, setLocation ] = useLocation(); + const search = useSearch(); + const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]); + const [ searchTokenInfo, searchRes ] = useLazySearchTokenInfoQuery(); + + // Populate search form using values from + // urlQueryParams, to allow paging. + const form = { + limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" }) + }; + + // On mount, trigger search. + useEffect(() => { + searchTokenInfo(Object.fromEntries(urlQueryParams), true); + }, [urlQueryParams, searchTokenInfo]); + + // Rather than triggering the search directly, + // the "submit" button changes the location + // based on form field params, and lets the + // useEffect hook above actually do the search. + function submitQuery(e) { + e.preventDefault(); + + // Parse query parameters. + const entries = Object.entries(form).map(([k, v]) => { + // Take only defined form fields. + if (v.value === undefined) { + return null; + } else if (typeof v.value === "string" && v.value.length === 0) { + return null; + } + + return [[k, v.value.toString()]]; + }).flatMap(kv => { + // Remove any nulls. + return kv !== null ? kv : []; + }); + + const searchParams = new URLSearchParams(entries); + setLocation(location + "?" + searchParams.toString()); + } + + // Function to map an item to a list entry. + function itemToEntry(tokenInfo: TokenInfo): ReactNode { + return ( + + ); + } + + return ( + <> +
+ + + + No tokens found.} + prevNextLinks={searchRes.data?.links} + /> + + ); +} + +interface TokenInfoListEntryProps { + tokenInfo: TokenInfo; +} + +function TokenInfoListEntry({ tokenInfo }: TokenInfoListEntryProps) { + const appWebsite = useMemo(() => { + if (!tokenInfo.application.website) { + return ""; + } + + try { + // Try to parse nicely and return link. + const websiteURL = new URL(tokenInfo.application.website); + const websiteURLStr = websiteURL.toString(); + return ( + {websiteURLStr} + ); + } catch { + // Fall back to returning string. + return tokenInfo.application.website; + } + }, [tokenInfo.application.website]); + + const created = useMemo(() => { + const createdAt = new Date(tokenInfo.created_at); + return ; + }, [tokenInfo.created_at]); + + const lastUsed = useMemo(() => { + if (!tokenInfo.last_used) { + return "unknown/never"; + } + + const lastUsed = new Date(tokenInfo.last_used); + return ; + }, [tokenInfo.last_used]); + + const [ invalidate, invalidateResult ] = useInvalidateTokenMutation(); + + return ( + +
+
+
App name:
+
{tokenInfo.application.name}
+
+ { appWebsite && +
+
App website:
+
{appWebsite}
+
+ } +
+
Scope:
+
{tokenInfo.scope}
+
+
+
Created:
+
{created}
+
+
+
Last used:
+
{lastUsed}
+
+
+
+ { + e.preventDefault(); + e.stopPropagation(); + invalidate(tokenInfo.id); + }} + disabled={false} + showError={true} + result={invalidateResult} + /> +
+
+ ); +}