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/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..5a0cdaad4 --- /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) AccountGETHandler(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/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/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/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/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 e266a8ec6..8de4c14f5 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/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..d6a5b9f61 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..5882491fe --- /dev/null +++ b/web/source/settings/views/user/tokens/index.tsx @@ -0,0 +1,39 @@ +/* + 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 ( +
+
+

App 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. +
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 log in again with that application. +
In cases where you've logged into an application multiple times, or logged in with multiple devices or browsers, you may see multiple tokens for one application. This is normal! +
That said, feel free to invalidate old tokens that are never used, it's good security practice and it's fun to click the big red button. +

+
+ +
+ ); +} 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..26f69a2b7 --- /dev/null +++ b/web/source/settings/views/user/tokens/search.tsx @@ -0,0 +1,180 @@ +/* + 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 created = new Date(tokenInfo.created_at).toLocaleString(); + const lastUsed = tokenInfo.last_used ? new Date(tokenInfo.last_used).toDateString(): "unknown/never"; + const [ invalidate, invalidateResult ] = useInvalidateTokenMutation(); + + return ( + +
+
+
App name:
+
{tokenInfo.application.name}
+
+ { tokenInfo.application.website && +
+
App website:
+
{tokenInfo.application.website}
+
+ } +
+
Token scope:
+
{tokenInfo.scope}
+
+
+
Token created at:
+
{created}
+
+
+
Token last used:
+
{lastUsed}
+
+
+
+ { + e.preventDefault(); + e.stopPropagation(); + invalidate(tokenInfo.id); + }} + disabled={false} + showError={true} + result={invalidateResult} + /> +
+
+ ); +}