[feature] Add token review / delete to backend + settings panel (#3845)

This commit is contained in:
tobi 2025-03-04 11:01:25 +01:00 committed by GitHub
commit 829143d263
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1637 additions and 1 deletions

View file

@ -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),
}
}

View file

@ -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)
}

View file

@ -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))
}

View file

@ -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)
}

View file

@ -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))
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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))
}

View file

@ -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"`
}

View file

@ -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)

View file

@ -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",

View file

@ -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
}

View file

@ -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
}