mirror of
1
Fork 0
This commit is contained in:
tobi 2025-03-12 18:52:49 +01:00
parent 9880babe7c
commit ad3988a3d5
22 changed files with 839 additions and 103 deletions

View File

@ -19,18 +19,15 @@ curl \
The string `urn:ietf:wg:oauth:2.0:oob` is an indication of what is known as out-of-band authentication - a technique used in multi-factor authentication to reduce the number of ways that a bad actor can intrude on the authentication process. In this instance, it allows us to view and manually copy the tokens created to use further in this process.
Note that `scopes` can be any space-separated combination of:
- `read`
- `write`
- `admin`
!!! tip "Scopes"
It is always good practice to grant your application the lowest tier permissions it needs to do its job. e.g. If your application won't be making posts, use `scope=read` or even a subscope of that.
In this spirit, "read" is used in the example above, which means that the application will be restricted to only being able to do "read" actions.
For a list of available scopes, see https://docs.gotosocial.org/en/latest/api/swagger/
!!! warning
GoToSocial does not currently support scoped authorization tokens, so any token you obtain in this process will be able to perform all actions on your behalf, including admin actions if your account has admin permissions. Nevertheless, it is always good practice to grant your application the lowest tier permissions it needs to do its job. e.g. If your application won't be making posts, use scope=read.
In this spirit, "read" is used in the example above, which means that in the future when scoped tokens are supported, the application will be restricted to only being able to do "read" actions.
You can read more about additional planned OAuth security features [right here](https://github.com/superseriousbusiness/gotosocial/issues/2232).
GoToSocial did not support scoped authorization tokens before version 0.19.0, so if you are using a version of GoToSocial below that, then any token you obtain in this process will be able to perform all actions on your behalf, including admin actions if your account has admin permissions.
A successful call returns a response with a `client_id` and `client_secret`, which we are going need to use in the rest of the process. It looks something like this:

View File

@ -828,6 +828,11 @@ definitions:
description: Client secret associated with this application.
type: string
x-go-name: ClientSecret
created_at:
description: When the application was created. (ISO 8601 Datetime)
example: "2021-07-30T09:20:25+00:00"
type: string
x-go-name: CreatedAt
id:
description: The ID of the application.
example: 01FBVD42CQ3ZEEVMW180SBX03B
@ -3649,6 +3654,54 @@ info:
contact:
email: admin@gotosocial.org
name: GoToSocial Authors
description: |-
This document describes the GoToSocial HTTP API.
For information on how to authenticate with the API using an OAuth access token, see the documentation here: https://docs.gotosocial.org/en/latest/api/authentication/.
Available scopes are:
read: grants read access to everything
write: grants write access to everything
push: grants read/write access to push
profile: grants read access to verify_credentials
read:accounts: grants read access to accounts
write:accounts: grants write access to accounts
read:applications: grants read access to user-managed applications
write:applications: grants write access to user-managed applications
read:blocks: grants read access to blocks
write:blocks: grants write access to blocks
read:bookmarks: grants read access to bookmarks
write:bookmarks: grants write access to bookmarks
write:conversations: grants write access to conversations
read:favourites: grants read access to accounts
write:favourites: grants write access to favourites
read:filters: grants read access to filters
write:filters: grants write access to filters
read:follows: grants read access to follows
write:follows: grants write access to follows
read:lists: grants read access to lists
write:lists: grants write access to lists
write:media: grants write access to media
read:mutes: grants read access to mutes
write:mutes: grants write access to mutes
read:notifications: grants read access to notifications
write:notifications: grants write access to notifications
write:reports: grants write access to reports
read:search: grants read access to search
read:statuses: grants read access to statuses
write:statuses: grants write access to statuses
admin: grants admin access to everything
admin:read: grants admin read access to everything
admin:write: grants admin write access to everything
admin:read:accounts: grants admin read access to accounts
admin:write:accounts: grants write read access to accounts
admin:read:reports: grants admin read access to reports
admin:write:reports: grants admin write access to reports
admin:read:domain_allows: grants admin read access to domain_allows
admin:write:domain_allows: grants admin write access to domain_allows
admin:read:domain_blocks: grants admin read access to domain_blocks
admin:write:domain_blocks: grants write read access to domain_blocks
license:
name: AGPL3
url: https://www.gnu.org/licenses/agpl-3.0.en.html
@ -7484,6 +7537,63 @@ paths:
tags:
- announcements
/api/v1/apps:
get:
description: |-
The next and previous queries can be parsed from the returned Link header.
Example:
```
<https://example.org/api/v1/apps?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/apps?limit=80&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
````
operationId: appsGet
parameters:
- description: Return only items *OLDER* than the given max item 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 item 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 item 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: ""
headers:
Link:
description: Links to the next and previous queries.
type: string
schema:
items:
$ref: '#/definitions/application'
type: array
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- read:applications
summary: Get an array of applications that are managed by the requester.
tags:
- apps
post:
consumes:
- application/json
@ -7493,8 +7603,10 @@ paths:
The registered application can be used to obtain an application token.
This can then be used to register a new account, or (through user auth) obtain an access token.
The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'.
The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'.
If the application was registered with a Bearer token passed in the Authorization header, the created application will be managed by the authenticated user (must have scope write:applications).
Parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'.
Parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'.
operationId: appCreate
parameters:
- description: The name of the application.
@ -7548,6 +7660,38 @@ paths:
summary: Register a new application on this instance.
tags:
- apps
/api/v1/apps/{id}:
get:
operationId: appGet
parameters:
- description: The id of the requested application.
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: The requested application.
schema:
$ref: '#/definitions/application'
"400":
description: bad request
"401":
description: unauthorized
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- read:applications
summary: Get a single application managed by the requester.
tags:
- apps
/api/v1/blocks:
get:
description: |-
@ -11705,15 +11849,15 @@ paths:
````
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.
- description: Return only items *OLDER* than the given max item 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.
- description: Return only items *newer* than the given since item 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.
- description: Return only items *immediately newer* than the given since item ID. The item with the specified ID will not be included in the response.
in: query
name: min_id
type: string
@ -12927,6 +13071,7 @@ securityDefinitions:
push: grants read/write access to push
read: grants read access to everything
read:accounts: grants read access to accounts
read:applications: grants read access to user-managed applications
read:blocks: grants read access to blocks
read:bookmarks: grants read access to bookmarks
read:favourites: grants read access to accounts
@ -12939,6 +13084,7 @@ securityDefinitions:
read:statuses: grants read access to statuses
write: grants write access to everything
write:accounts: grants write access to accounts
write:applications: grants write access to user-managed applications
write:blocks: grants write access to blocks
write:bookmarks: grants write access to bookmarks
write:conversations: grants write access to conversations

View File

@ -17,6 +17,56 @@
// GoToSocial Swagger documentation.
//
// This document describes the GoToSocial HTTP API.
//
// For information on how to authenticate with the API using an OAuth access token, see the documentation here: https://docs.gotosocial.org/en/latest/api/authentication/.
//
// Available scopes are:
//
// - read: grants read access to everything
// - write: grants write access to everything
// - push: grants read/write access to push
// - profile: grants read access to verify_credentials
// - read:accounts: grants read access to accounts
// - write:accounts: grants write access to accounts
// - read:applications: grants read access to user-managed applications
// - write:applications: grants write access to user-managed applications
// - read:blocks: grants read access to blocks
// - write:blocks: grants write access to blocks
// - read:bookmarks: grants read access to bookmarks
// - write:bookmarks: grants write access to bookmarks
// - write:conversations: grants write access to conversations
// - read:favourites: grants read access to accounts
// - write:favourites: grants write access to favourites
// - read:filters: grants read access to filters
// - write:filters: grants write access to filters
// - read:follows: grants read access to follows
// - write:follows: grants write access to follows
// - read:lists: grants read access to lists
// - write:lists: grants write access to lists
// - write:media: grants write access to media
// - read:mutes: grants read access to mutes
// - write:mutes: grants write access to mutes
// - read:notifications: grants read access to notifications
// - write:notifications: grants write access to notifications
// - write:reports: grants write access to reports
// - read:search: grants read access to search
// - read:statuses: grants read access to statuses
// - write:statuses: grants write access to statuses
// - admin: grants admin access to everything
// - admin:read: grants admin read access to everything
// - admin:write: grants admin write access to everything
// - admin:read:accounts: grants admin read access to accounts
// - admin:write:accounts: grants write read access to accounts
// - admin:read:reports: grants admin read access to reports
// - admin:write:reports: grants admin write access to reports
// - admin:read:domain_allows: grants admin read access to domain_allows
// - admin:write:domain_allows: grants admin write access to domain_allows
// - admin:read:domain_blocks: grants admin read access to domain_blocks
// - admin:write:domain_blocks: grants write read access to domain_blocks
//
// ---
//
// Schemes: https, http
// BasePath: /
// Version: REPLACE_ME

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 apps
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
// AppDELETEHandler swagger:operation DELETE /api/v1/apps/{id} appDelete
//
// Delete a single application managed by the requester.
//
// ---
// tags:
// - apps
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: The id of the application to delete.
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - write:applications
//
// responses:
// '200':
// description: The deleted application.
// schema:
// "$ref": "#/definitions/application"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) AppDELETEHandler(c *gin.Context) {
authed, errWithCode := apiutil.TokenAuth(c,
true, true, true, true,
apiutil.ScopeWriteApplications,
)
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
}
appID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
app, errWithCode := m.processor.Application().Delete(
c.Request.Context(),
authed.User.ID,
appID,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, app)
}

View File

@ -44,4 +44,5 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
attachHandler(http.MethodPost, BasePath, m.AppsPOSTHandler)
attachHandler(http.MethodGet, BasePath, m.AppsGETHandler)
attachHandler(http.MethodGet, BasePathWithID, m.AppGETHandler)
attachHandler(http.MethodDelete, BasePathWithID, m.AppDELETEHandler)
}

View File

@ -37,8 +37,8 @@ type Application interface {
// PutApplication places the new application in the database, erroring on non-unique ID or client_id.
PutApplication(ctx context.Context, app *gtsmodel.Application) error
// DeleteApplicationByClientID deletes the application with corresponding client_id value from the database.
DeleteApplicationByClientID(ctx context.Context, clientID string) error
// DeleteApplicationByID deletes the application with corresponding id from the database.
DeleteApplicationByID(ctx context.Context, id string) error
// GetAllTokens fetches all client oauth tokens from database.
GetAllTokens(ctx context.Context) ([]*gtsmodel.Token, error)
@ -75,4 +75,8 @@ type Application interface {
// DeleteTokenByRefresh deletes client oauth token from database with refresh code.
DeleteTokenByRefresh(ctx context.Context, refresh string) error
// DeleteTokensByClientID deletes all tokens
// with the given clientID from the database.
DeleteTokensByClientID(ctx context.Context, clientID string) error
}

View File

@ -174,27 +174,16 @@ func (a *applicationDB) PutApplication(ctx context.Context, app *gtsmodel.Applic
})
}
func (a *applicationDB) DeleteApplicationByClientID(ctx context.Context, clientID string) error {
// Attempt to delete application.
if _, err := a.db.NewDelete().
func (a *applicationDB) DeleteApplicationByID(ctx context.Context, id string) error {
_, err := a.db.NewDelete().
Table("applications").
Where("? = ?", bun.Ident("client_id"), clientID).
Exec(ctx); err != nil {
Where("? = ?", bun.Ident("id"), id).
Exec(ctx)
if err != nil {
return err
}
// NOTE about further side effects:
//
// We don't need to handle updating any statuses or users
// (both of which may contain refs to applications), as
// DeleteApplication__() is only ever called during an
// account deletion, which handles deletion of the user
// and all their statuses already.
//
// Clear application from the cache.
a.state.Caches.DB.Application.Invalidate("ClientID", clientID)
a.state.Caches.DB.Application.Invalidate("ID", id)
return nil
}
@ -461,3 +450,27 @@ func (a *applicationDB) DeleteTokenByRefresh(ctx context.Context, refresh string
a.state.Caches.DB.Token.Invalidate("Refresh", refresh)
return nil
}
func (a *applicationDB) DeleteTokensByClientID(ctx context.Context, clientID string) error {
// Delete tokens owned by
// clientID and gather token IDs.
var tokenIDs []string
if _, err := a.db.
NewDelete().
Table("tokens").
Where("? = ?", bun.Ident("client_id"), clientID).
Returning("id").
Exec(ctx, &tokenIDs); err != nil {
return err
}
if len(tokenIDs) == 0 {
// Nothing was deleted,
// nothing to invalidate.
return nil
}
// Invalidate all deleted tokens.
a.state.Caches.DB.Token.InvalidateIDs("ID", tokenIDs)
return nil
}

View File

@ -92,7 +92,7 @@ func (suite *ApplicationTestSuite) TestDeleteApplicationBy() {
for _, app := range suite.testApplications {
for lookup, dbfunc := range map[string]func() error{
"client_id": func() error {
return suite.db.DeleteApplicationByClientID(ctx, app.ClientID)
return suite.db.DeleteApplicationByID(ctx, app.ID)
},
} {
// Clear database caches.
@ -124,6 +124,25 @@ func (suite *ApplicationTestSuite) TestGetAllTokens() {
suite.NotEmpty(tokens)
}
func (suite *ApplicationTestSuite) TestDeleteTokensByClientID() {
ctx := context.Background()
// Delete tokens by each app.
for _, app := range suite.testApplications {
if err := suite.state.DB.DeleteTokensByClientID(ctx, app.ClientID); err != nil {
suite.FailNow(err.Error())
}
}
// Ensure all tokens deleted.
for _, token := range suite.testTokens {
_, err := suite.db.GetTokenByID(ctx, token.ID)
if !errors.Is(err, db.ErrNoEntries) {
suite.FailNow("", "token %s not deleted", token.ID)
}
}
}
func TestApplicationTestSuite(t *testing.T) {
suite.Run(t, new(ApplicationTestSuite))
}

View File

@ -27,6 +27,18 @@ import (
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Add client_id index to token table,
// needed for invalidation if/when the
// token's app is deleted.
if _, err := tx.
NewCreateIndex().
Table("tokens").
Index("tokens_client_id_idx").
Column("client_id").
IfNotExists().
Exec(ctx); err != nil {
return err
}
// Update users to set all "created_by_application_id"
// values to the instance application, to correct some

View File

@ -299,11 +299,12 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
if status.CreatedWithApplicationID != "" && status.CreatedWithApplication == nil {
// Populate the status' expected CreatedWithApplication (not always set).
// Don't error if this can't be populated, as the application may have been cleaned up.
status.CreatedWithApplication, err = s.state.DB.GetApplicationByID(
gtscontext.SetBarebones(ctx),
status.CreatedWithApplicationID,
)
if err != nil {
if err != nil && !errors.Is(err, db.ErrNoEntries) {
errs.Appendf("error populating status application: %w", err)
}
}

View File

@ -27,7 +27,7 @@ type Token struct {
ClientID string `bun:"type:CHAR(26),nullzero,notnull"` // ID of the client who owns this token
UserID string `bun:"type:CHAR(26),nullzero"` // ID of the user who owns this token
RedirectURI string `bun:",nullzero,notnull"` // Oauth redirect URI for this token
Scope string `bun:",nullzero,notnull,default:'read'"` // Oauth scope // Oauth scope
Scope string `bun:",nullzero,notnull,default:'read'"` // Oauth scope
Code string `bun:",pk,nullzero,notnull,default:''"` // Code, if present
CodeChallenge string `bun:",nullzero"` // Code challenge, if code present
CodeChallengeMethod string `bun:",nullzero"` // Code challenge method, if code present

View File

@ -96,7 +96,7 @@ func (p *Processor) Delete(
}
// deleteUserAndTokensForAccount deletes the gtsmodel.User and
// any OAuth tokens, applications, and Web Push subscriptions for the given account.
// any OAuth tokens and Web Push subscriptions for the given account.
//
// Callers to this function should already have checked that
// this is a local account, or else it won't have a user associated
@ -107,19 +107,33 @@ func (p *Processor) deleteUserAndTokensForAccount(ctx context.Context, account *
return gtserror.Newf("db error getting user: %w", err)
}
tokens := []*gtsmodel.Token{}
if err := p.state.DB.GetWhere(ctx, []db.Where{{Key: "user_id", Value: user.ID}}, &tokens); err != nil {
// Get all applications owned by user.
apps, err := p.state.DB.GetApplicationsManagedByUserID(ctx, user.ID, nil)
if err != nil {
return gtserror.Newf("db error getting apps: %w", err)
}
// Delete each app and any tokens it had created
// (not necessarily owned by deleted account).
for _, a := range apps {
if err := p.state.DB.DeleteApplicationByID(ctx, a.ID); err != nil {
return gtserror.Newf("db error deleting app: %w", err)
}
if err := p.state.DB.DeleteTokensByClientID(ctx, a.ClientID); err != nil {
return gtserror.Newf("db error deleting tokens for app: %w", err)
}
}
// Get any remaining access tokens owned by user.
tokens, err := p.state.DB.GetAccessTokens(ctx, user.ID, nil)
if err != nil {
return gtserror.Newf("db error getting tokens: %w", err)
}
// Delete each token.
for _, t := range tokens {
// Delete any OAuth applications associated with this token.
if err := p.state.DB.DeleteApplicationByClientID(ctx, t.ClientID); err != nil {
return gtserror.Newf("db error deleting application: %w", err)
}
// Delete the token itself.
if err := p.state.DB.DeleteByID(ctx, t.ID, t); err != nil {
if err := p.state.DB.DeleteTokenByID(ctx, t.ID); err != nil {
return gtserror.Newf("db error deleting token: %w", err)
}
}

View File

@ -0,0 +1,70 @@
// 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 application
import (
"context"
"errors"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
func (p *Processor) Delete(
ctx context.Context,
userID string,
appID string,
) (*apimodel.Application, gtserror.WithCode) {
app, err := p.state.DB.GetApplicationByID(ctx, appID)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting app %s: %w", appID, err)
return nil, gtserror.NewErrorInternalError(err)
}
if app == nil {
err := gtserror.Newf("app %s not found in the db", appID)
return nil, gtserror.NewErrorNotFound(err)
}
if app.ManagedByUserID != userID {
err := gtserror.Newf("app %s not managed by user %s", appID, userID)
return nil, gtserror.NewErrorNotFound(err)
}
// Convert app before deletion.
apiApp, err := p.converter.AppToAPIAppSensitive(ctx, app)
if err != nil {
err := gtserror.Newf("error converting app to api app: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Delete app itself.
if err := p.state.DB.DeleteApplicationByID(ctx, appID); err != nil {
err := gtserror.Newf("db error deleting app %s: %w", appID, err)
return nil, gtserror.NewErrorInternalError(err)
}
// Delete all tokens owned by app.
if err := p.state.DB.DeleteTokensByClientID(ctx, app.ClientID); err != nil {
err := gtserror.Newf("db error deleting tokens for app %s: %w", appID, err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiApp, nil
}

View File

@ -1418,14 +1418,28 @@ func (c *Converter) baseStatusToFrontend(
apiStatus.InReplyToAccountID = util.PtrIf(s.InReplyToAccountID)
apiStatus.Language = util.PtrIf(s.Language)
if app := s.CreatedWithApplication; app != nil {
apiStatus.Application, err = c.AppToAPIAppPublic(ctx, app)
switch {
case s.CreatedWithApplication != nil:
// App exists for this status and is set.
apiStatus.Application, err = c.AppToAPIAppPublic(ctx, s.CreatedWithApplication)
if err != nil {
return nil, gtserror.Newf(
"error converting application %s: %w",
s.CreatedWithApplicationID, err,
)
}
case s.CreatedWithApplicationID != "":
// App existed for this status but not
// anymore, it's probably been cleaned up.
// Set a dummy application.
apiStatus.Application = &apimodel.Application{
Name: "Unknown/deleted application",
}
default:
// No app stored for this (probably remote)
// status, so nothing to do (app is optional).
}
if s.Poll != nil {

View File

@ -753,6 +753,156 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendHTMLContentWarning
}`, string(b))
}
func (suite *InternalToFrontendTestSuite) TestStatusToFrontendApplicationDeleted() {
ctx := context.Background()
testStatus := suite.testStatuses["admin_account_status_1"]
// Delete the application this status was created with.
if err := suite.state.DB.DeleteApplicationByID(ctx, testStatus.CreatedWithApplicationID); err != nil {
suite.FailNow(err.Error())
}
requestingAccount := suite.testAccounts["local_account_1"]
apiStatus, err := suite.typeconverter.StatusToAPIStatus(ctx, testStatus, requestingAccount, statusfilter.FilterContextNone, nil, nil)
suite.NoError(err)
b, err := json.MarshalIndent(apiStatus, "", " ")
suite.NoError(err)
suite.Equal(`{
"id": "01F8MH75CBF9JFX4ZAD54N0W0R",
"created_at": "2021-10-20T11:36:45.000Z",
"edited_at": null,
"in_reply_to_id": null,
"in_reply_to_account_id": null,
"sensitive": false,
"spoiler_text": "",
"visibility": "public",
"language": "en",
"uri": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
"url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
"replies_count": 1,
"reblogs_count": 0,
"favourites_count": 1,
"favourited": true,
"reblogged": false,
"muted": false,
"bookmarked": true,
"pinned": false,
"content": "\u003cp\u003ehello world! \u003ca href=\"http://localhost:8080/tags/welcome\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\"\u003e#\u003cspan\u003ewelcome\u003c/span\u003e\u003c/a\u003e ! first post on the instance :rainbow: !\u003c/p\u003e",
"reblog": null,
"application": {
"name": "Unknown/deleted application"
},
"account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"acct": "admin",
"display_name": "",
"locked": false,
"discoverable": true,
"bot": false,
"created_at": "2022-05-17T13:10:59.000Z",
"note": "",
"url": "http://localhost:8080/@admin",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.webp",
"header_static": "http://localhost:8080/assets/default_header.webp",
"header_description": "Flat gray background (default header).",
"followers_count": 1,
"following_count": 1,
"statuses_count": 4,
"last_status_at": "2021-10-20",
"emojis": [],
"fields": [],
"enable_rss": true,
"roles": [
{
"id": "admin",
"name": "admin",
"color": ""
}
],
"group": false
},
"media_attachments": [
{
"id": "01F8MH6NEM8D7527KZAECTCR76",
"type": "image",
"url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg",
"text_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg",
"preview_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.webp",
"remote_url": null,
"preview_remote_url": null,
"meta": {
"original": {
"width": 1200,
"height": 630,
"size": "1200x630",
"aspect": 1.9047619
},
"small": {
"width": 512,
"height": 268,
"size": "512x268",
"aspect": 1.9104477
},
"focus": {
"x": 0,
"y": 0
}
},
"description": "Black and white image of some 50's style text saying: Welcome On Board",
"blurhash": "LIIE|gRj00WB-;j[t7j[4nWBj[Rj"
}
],
"mentions": [],
"tags": [
{
"name": "welcome",
"url": "http://localhost:8080/tags/welcome"
}
],
"emojis": [
{
"shortcode": "rainbow",
"url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
"static_url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
"visible_in_picker": true,
"category": "reactions"
}
],
"card": null,
"poll": null,
"text": "hello world! #welcome ! first post on the instance :rainbow: !",
"content_type": "text/plain",
"interaction_policy": {
"can_favourite": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reply": {
"always": [
"public",
"me"
],
"with_approval": []
},
"can_reblog": {
"always": [
"public",
"me"
],
"with_approval": []
}
}
}`, string(b))
}
// Modify a fixture status into a status that should be filtered,
// and then filter it, returning the API status or any error from converting it.
func (suite *InternalToFrontendTestSuite) filteredStatusToFrontend(action gtsmodel.FilterAction, boost bool) (*apimodel.Status, error) {

View File

@ -66,7 +66,7 @@ export function App({ account }: AppProps) {
Ensure user ends up somewhere
if they just open /settings.
*/}
<Route path="/"><Redirect to="/user" /></Route>
<Route path="/"><Redirect to="/user/profile" /></Route>
</ErrorBoundary>
</Router>
</section>

View File

@ -79,6 +79,18 @@ const extended = gtsApi.injectEndpoints({
invalidatesTags: [{ type: "Application", id: "TRANSFORMED" }],
}),
deleteApp: build.mutation<App, string>({
query: (id) => ({
method: "DELETE",
url: `/api/v1/apps/${id}`
}),
invalidatesTags: (_result, _error, id) => [
{ type: 'Application', id },
{ type: "Application", id: "TRANSFORMED" },
{ type: "TokenInfo", id: "TRANSFORMED" },
],
}),
getOOBAuthCode: build.mutation<null, { app: App, scope: string, redirectURI: string }>({
async queryFn({ app, scope, redirectURI }, api, _extraOpts, _fetchWithBQ) {
// Fetch the instance URL string from
@ -130,4 +142,5 @@ export const {
useGetAppQuery,
useGetOOBAuthCodeMutation,
useGetAccessTokenForAppMutation,
useDeleteAppMutation,
} = extended;

View File

@ -1495,6 +1495,24 @@ button.tab-button {
}
}
.access-token-receive-form {
> .access-token-frame {
background-color: $gray2;
width: 100%;
padding: 0.25rem;
border-radius: $br-inner;
white-space: pre;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
font-family: monospace;
font-size: large;
}
.closed {
text-align: center;
}
}
.applications-view {
.application {
.info-list {
@ -1517,9 +1535,15 @@ button.tab-button {
.info-list {
margin-top: 1rem;
> .info-list-entry > .monospace {
> .info-list-entry .monospace {
font-size: large;
}
> .info-list-entry > dd > button {
font-size: medium;
padding-top: 0;
padding-bottom: 0;
}
}
}

View File

@ -17,27 +17,39 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useEffect } from "react";
import React from "react";
import { useSearch } from "wouter";
import { Error as ErrorCmp } from "../../../components/error";
import { useGetAccessTokenForAppMutation, useGetAppQuery } from "../../../lib/query/user/applications";
import Loading from "../../../components/loading";
import { useCallbackURL } from "./common";
import useFormSubmit from "../../../lib/form/submit";
import { useValue } from "../../../lib/form";
import MutationButton from "../../../components/form/mutation-button";
import FormWithData from "../../../lib/form/form-with-data";
import { App } from "../../../lib/types/application";
import { OAuthAccessToken } from "../../../lib/types/oauth";
export function AppTokenCallback({}) {
export function AppTokenCallback({}) {
// Read the callback authorization
// code + state from the search params.
// information from the search params.
const search = useSearch();
const urlQueryParams = new URLSearchParams(search);
const code = urlQueryParams.get("code");
const appId = urlQueryParams.get("state");
const error = urlQueryParams.get("error");
const errorDescription = urlQueryParams.get("error_description");
if (error) {
let errString = error;
if (errorDescription) {
errString += ": " + errorDescription;
}
if (error === "invalid_scope") {
errString += ". You probably requested a token (sub-)scope that wasn't contained in the scopes of your application.";
}
const err = Error(errString);
return <ErrorCmp error={err} />;
}
if (!code || !appId) {
const err = Error("code or app id not defined");
@ -46,7 +58,6 @@ export function AppTokenCallback({}) {
return(
<>
<h2>Access Token</h2>
<FormWithData
dataQuery={useGetAppQuery}
queryArg={appId}
@ -74,12 +85,37 @@ function AccessForAppForm({ data: app, code }: { data: App, code: string }) {
const [ submit, result ] = useFormSubmit(form, useGetAccessTokenForAppMutation());
return (
<form onSubmit={submit}>
<form
className="access-token-receive-form"
onSubmit={submit}
>
<div className="form-section-docs">
<h2>Receive Access Token</h2>
<p>
To receive your user-level access token for application<b>{app.name}</b>, click on the button below.
<br/>Your access token will be shown once and only once.
<br/><strong>Your access token provides access to your account; store it as carefully as you would store a password!</strong>
</p>
<a
href="https://docs.gotosocial.org/en/latest/api/authentication/#verifying"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about how to use your access token (opens in a new tab)
</a>
</div>
{ result.data
? <div className="access-token-frame">{(result.data as OAuthAccessToken).access_token}</div>
: <div className="access-token-frame closed"><i className="fa fa-eye-slash" aria-hidden={true}></i></div>
}
<MutationButton
label="Gimme!"
label="I understand, show me the token!"
result={result}
disabled={false}
disabled={result.data || result.isError}
/>
</form>
);
}
}

View File

@ -17,12 +17,12 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { useParams } from "wouter";
import React, { useState } from "react";
import { useLocation, useParams } from "wouter";
import FormWithData from "../../../lib/form/form-with-data";
import BackButton from "../../../components/back-button";
import { useBaseUrl } from "../../../lib/navigation/util";
import { useGetAppQuery, useGetOOBAuthCodeMutation } from "../../../lib/query/user/applications";
import { useDeleteAppMutation, useGetAppQuery, useGetOOBAuthCodeMutation } from "../../../lib/query/user/applications";
import { App } from "../../../lib/types/application";
import { useAppWebsite, useCallbackURL, useCreated, useRedirectURIs } from "./common";
import MutationButton from "../../../components/form/mutation-button";
@ -51,7 +51,8 @@ function AppDetailForm({ data: app, backLocation }: { data: App, backLocation: s
return (
<>
<AppBasicInfo app={app} />
<AppTokenForm app={app} />
<AccessTokenForm app={app} />
<DeleteAppForm app={app} backLocation={backLocation} />
</>
);
}
@ -60,7 +61,8 @@ function AppBasicInfo({ app }: { app: App }) {
const appWebsite = useAppWebsite(app);
const created = useCreated(app);
const redirectURIs = useRedirectURIs(app);
const [ showSecret, setShowSecret ] = useState(false);
return (
<dl className="info-list">
<div className="info-list-entry">
@ -89,17 +91,37 @@ function AppBasicInfo({ app }: { app: App }) {
<dt>Redirect URI(s):</dt>
<dd className="monospace">{redirectURIs}</dd>
</div>
<div className="info-list-entry">
<dt>Vapid key:</dt>
<dd className="monospace">{app.vapid_key}</dd>
</div>
<div className="info-list-entry">
<dt>Client ID:</dt>
<dd className="monospace">{app.client_id}</dd>
</div>
<div className="info-list-entry">
<dt>Client secret:</dt>
{ showSecret
? <dd className="monospace">{app.client_secret}</dd>
: <dd><button onClick={() => setShowSecret(true)}>Show secret</button></dd>
}
</div>
</dl>
);
}
function AppTokenForm({ app }: { app: App }) {
function AccessTokenForm({ app }: { app: App }) {
const callbackURL = useCallbackURL();
const [ getOOBAuthCode, result ] = useGetOOBAuthCodeMutation();
const scope = useTextInput("scope", {});
const scope = useTextInput("scope", { defaultValue: app.scopes.join(" ") });
const disabled = !app.redirect_uris.includes(callbackURL);
return (
<form
autoComplete="off"
onSubmit={(e) => {
e.preventDefault();
getOOBAuthCode({
@ -109,22 +131,72 @@ function AppTokenForm({ app }: { app: App }) {
});
}}
>
<h2>Get An Access Token</h2>
<div className="form-section-docs">
<h2>Request An API Access Token</h2>
<p>
If your application redirect URIs includes the callback URL <code>{callbackURL}</code>,
you can use this section to request an access token that you can use to make API calls.
<br/>The token scopes specified below must be equal to, or a subset of, the scopes
you provided when you created the application.
<br/>After clicking "Request access token", you will be redirected to the sign in
page for your instance, where you must provide your credentials in order to authorize
your application to act on your behalf.<br/>You will then be redirected again to a page
where you can view your new access token.
</p>
<a
href="https://docs.gotosocial.org/en/latest/api/authentication/"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about the OAuth authentication flow (opens in a new tab)
</a>
</div>
<TextInput
field={scope}
label="Scope (space-separated list of scopes)"
label="Token scopes (space-separated list)"
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
/>
<MutationButton
disabled={false}
label="Resolve"
disabled={disabled}
label="Request access token"
result={result}
/>
</form>
);
}
function DeleteAppForm({ app, backLocation }: { app: App, backLocation: string }) {
const [ _location, setLocation ] = useLocation();
const [ deleteApp, result ] = useDeleteAppMutation();
return (
<form>
<div className="form-section-docs">
<h2>Delete Application</h2>
<p>
You can use this button to delete the application.
<br/>Any tokens created by the application will also be deleted.
</p>
</div>
<MutationButton
label={`Delete`}
title={`Delete`}
type="button"
className="button danger"
onClick={(e) => {
e.preventDefault();
deleteApp(app.id);
setLocation(backLocation);
}}
disabled={false}
showError={false}
result={result}
/>
</form>
);
}

View File

@ -79,7 +79,7 @@ export default function NewApp() {
<p>
On this page you can create a new managed OAuth application, with the specified redirect URIs and scopes.
<br/>Application name is required, but other fields are optional.
<br/>If you want to obtain an access token right here in the settings panel, be sure to include <code>{callbackURL}</code> in your redirect URIs.
<br/>If you want to obtain an access token right here in the settings panel, be sure to include the callback URL <code>{callbackURL}</code> in your redirect URIs.
<br/>If not specified, redirect URIs defaults to <code>urn:ietf:wg:oauth:2.0:oob</code>, and scopes defaults to <code>read</code>.
</p>
<a

View File

@ -41,7 +41,8 @@ import { AppTokenCallback } from "./applications/callback";
* - /settings/user/migration
* - /settings/user/export-import
* - /settings/user/tokens
* - /settings/users/interaction_requests
* - /settings/user/interaction_requests
* - /settings/user/applications
*/
export default function UserRouter() {
const baseUrl = useBaseUrl();
@ -51,27 +52,24 @@ export default function UserRouter() {
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<ErrorBoundary>
<Switch>
<Route path="/profile" component={UserProfile} />
<Route path="/posts" component={PostSettings} />
<Route path="/emailpassword" component={EmailPassword} />
<Route path="/migration" component={UserMigration} />
<Route path="/export-import" component={ExportImport} />
<Route path="/tokens" component={Tokens} />
<ApplicationsRouter />
<InteractionRequestsRouter />
<Route><Redirect to="/profile" /></Route>
</Switch>
</ErrorBoundary>
<Switch>
<Route path="/profile" component={UserProfile} />
<Route path="/posts" component={PostSettings} />
<Route path="/emailpassword" component={EmailPassword} />
<Route path="/migration" component={UserMigration} />
<Route path="/export-import" component={ExportImport} />
<Route path="/tokens" component={Tokens} />
</Switch>
<InteractionRequestsRouter />
<ApplicationsRouter />
</Router>
</BaseUrlContext.Provider>
);
}
/**
* - /settings/users/applications/search
* - /settings/users/applications/{appID}
* - /settings/user/applications/search
* - /settings/user/applications/{appID}
*/
function ApplicationsRouter() {
const parentUrl = useBaseUrl();
@ -81,13 +79,15 @@ function ApplicationsRouter() {
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<Switch>
<Route path="/search" component={Applications} />
<Route path="/new" component={NewApp} />
<Route path="/callback" component={AppTokenCallback} />
<Route path="/:appId" component={AppDetail} />
<Route><Redirect to="/search"/></Route>
</Switch>
<ErrorBoundary>
<Switch>
<Route path="/search" component={Applications} />
<Route path="/new" component={NewApp} />
<Route path="/callback" component={AppTokenCallback} />
<Route path="/:appId" component={AppDetail} />
<Route><Redirect to="/search"/></Route>
</Switch>
</ErrorBoundary>
</Router>
</BaseUrlContext.Provider>
);
@ -105,11 +105,13 @@ function InteractionRequestsRouter() {
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<Switch>
<Route path="/search" component={InteractionRequests} />
<Route path="/:reqId" component={InteractionRequestDetail} />
<Route><Redirect to="/search"/></Route>
</Switch>
<ErrorBoundary>
<Switch>
<Route path="/search" component={InteractionRequests} />
<Route path="/:reqId" component={InteractionRequestDetail} />
<Route><Redirect to="/search"/></Route>
</Switch>
</ErrorBoundary>
</Router>
</BaseUrlContext.Provider>
);