diff --git a/docs/swagger.go b/docs/swagger.go index ecd03e6b9..4e30b1f58 100644 --- a/docs/swagger.go +++ b/docs/swagger.go @@ -37,6 +37,8 @@ // 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 diff --git a/internal/api/client/apps/appcreate.go b/internal/api/client/apps/appcreate.go index 6a8208a20..062b2e13d 100644 --- a/internal/api/client/apps/appcreate.go +++ b/internal/api/client/apps/appcreate.go @@ -18,8 +18,11 @@ package apps import ( + "errors" "fmt" "net/http" + "slices" + "strings" "github.com/gin-gonic/gin" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" @@ -40,8 +43,10 @@ const ( // 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'. // // --- // tags: @@ -81,42 +86,66 @@ func (m *Module) AppsPOSTHandler(c *gin.Context) { return } + if authed.Token != nil { + // If a token has been passed, user + // needs write perm on applications. + if !slices.ContainsFunc( + strings.Split(authed.Token.GetScope(), " "), + func(hasScope string) bool { + return apiutil.Scope(hasScope).Permits(apiutil.ScopeWriteApplications) + }, + ) { + const errText = "token has insufficient scope permission" + errWithCode := gtserror.NewErrorForbidden(errors.New(errText), errText) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + } + + if authed.Account != nil && 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) + errWithCode := gtserror.NewErrorNotAcceptable(err, err.Error()) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return } form := &apimodel.ApplicationCreateRequest{} if err := c.ShouldBind(form); err != nil { - apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + errWithCode := gtserror.NewErrorBadRequest(err, err.Error()) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return } - if len([]rune(form.ClientName)) > formFieldLen { - err := fmt.Errorf("client_name must be less than %d characters", formFieldLen) - apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + if l := len([]rune(form.ClientName)); l > formFieldLen { + m.fieldTooLong(c, "client_name", formFieldLen, l) return } - if len([]rune(form.RedirectURIs)) > formRedirectLen { - err := fmt.Errorf("redirect_uris must be less than %d characters", formRedirectLen) - apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + if l := len([]rune(form.RedirectURIs)); l > formRedirectLen { + m.fieldTooLong(c, "redirect_uris", formRedirectLen, l) return } - if len([]rune(form.Scopes)) > formFieldLen { - err := fmt.Errorf("scopes must be less than %d characters", formFieldLen) - apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + if l := len([]rune(form.Scopes)); l > formFieldLen { + m.fieldTooLong(c, "scopes", formFieldLen, l) return } - if len([]rune(form.Website)) > formFieldLen { - err := fmt.Errorf("website must be less than %d characters", formFieldLen) - apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + if l := len([]rune(form.Website)); l > formFieldLen { + m.fieldTooLong(c, "website", formFieldLen, l) return } - apiApp, errWithCode := m.processor.AppCreate(c.Request.Context(), authed, form) + var managedByUserID string + if authed.User != nil { + managedByUserID = authed.User.ID + } + + apiApp, errWithCode := m.processor.Application().Create(c.Request.Context(), managedByUserID, form) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return @@ -124,3 +153,13 @@ func (m *Module) AppsPOSTHandler(c *gin.Context) { apiutil.JSON(c, http.StatusOK, apiApp) } + +func (m *Module) fieldTooLong(c *gin.Context, fieldName string, max int, actual int) { + errText := fmt.Sprintf( + "%s must be less than %d characters, provided %s was %d characters", + fieldName, max, fieldName, actual, + ) + + errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText) + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) +} diff --git a/internal/api/client/apps/appget.go b/internal/api/client/apps/appget.go new file mode 100644 index 000000000..f9d5050b4 --- /dev/null +++ b/internal/api/client/apps/appget.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 apps + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// AppGETHandler swagger:operation GET /api/v1/apps/{id} appGet +// +// Get a single application managed by the requester. +// +// --- +// tags: +// - apps +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: The id of the requested application. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:applications +// +// 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 +func (m *Module) AppGETHandler(c *gin.Context) { + authed, errWithCode := apiutil.TokenAuth(c, + true, true, true, true, + apiutil.ScopeReadApplications, + ) + 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().Get( + c.Request.Context(), + authed.User.ID, + appID, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, app) +} diff --git a/internal/api/client/apps/apps.go b/internal/api/client/apps/apps.go index 679b985b8..fbfd11975 100644 --- a/internal/api/client/apps/apps.go +++ b/internal/api/client/apps/apps.go @@ -21,11 +21,14 @@ import ( "net/http" "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/processing" ) -// BasePath is the base path for this api module, excluding the api prefix -const BasePath = "/v1/apps" +const ( + BasePath = "/v1/apps" + BasePathWithID = BasePath + "/:" + apiutil.IDKey +) type Module struct { processor *processing.Processor @@ -39,4 +42,6 @@ func New(processor *processing.Processor) *Module { func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { attachHandler(http.MethodPost, BasePath, m.AppsPOSTHandler) + attachHandler(http.MethodGet, BasePath, m.AppsGETHandler) + attachHandler(http.MethodGet, BasePathWithID, m.AppGETHandler) } diff --git a/internal/api/client/apps/appsget.go b/internal/api/client/apps/appsget.go new file mode 100644 index 000000000..6bbd4c752 --- /dev/null +++ b/internal/api/client/apps/appsget.go @@ -0,0 +1,146 @@ +// 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 apps + +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" +) + +// AppsGETHandler swagger:operation GET /api/v1/apps appsGet +// +// Get an array of applications that are managed by the requester. +// +// The next and previous queries can be parsed from the returned Link header. +// +// Example: +// +// ``` +// ; rel="next", ; rel="prev" +// ```` +// +// --- +// tags: +// - apps +// +// produces: +// - application/json +// +// parameters: +// - +// name: max_id +// type: string +// 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 +// required: false +// - +// name: since_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: min_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 +// 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:applications +// +// responses: +// '200': +// headers: +// Link: +// type: string +// description: Links to the next and previous queries. +// schema: +// type: array +// items: +// "$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) AppsGETHandler(c *gin.Context) { + authed, errWithCode := apiutil.TokenAuth(c, + true, true, true, true, + apiutil.ScopeReadApplications, + ) + 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.Application().GetPage( + c.Request.Context(), + authed.User.ID, + page, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + + apiutil.JSON(c, http.StatusOK, resp.Items) +} diff --git a/internal/api/client/tokens/tokensget.go b/internal/api/client/tokens/tokensget.go index 2ffc2afb9..15fc5511f 100644 --- a/internal/api/client/tokens/tokensget.go +++ b/internal/api/client/tokens/tokensget.go @@ -52,7 +52,7 @@ import ( // name: max_id // type: string // description: >- -// Return only items *OLDER* than the given max status ID. +// 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 // required: false @@ -60,14 +60,14 @@ import ( // name: since_id // type: string // description: >- -// Return only items *newer* than the given since status ID. +// 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: min_id // type: string // description: >- -// Return only items *immediately newer* than the given since status ID. +// 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 // required: false diff --git a/internal/api/model/application.go b/internal/api/model/application.go index 720674ad5..3f974683f 100644 --- a/internal/api/model/application.go +++ b/internal/api/model/application.go @@ -24,6 +24,9 @@ type Application struct { // The ID of the application. // example: 01FBVD42CQ3ZEEVMW180SBX03B ID string `json:"id,omitempty"` + // When the application was created. (ISO 8601 Datetime) + // example: 2021-07-30T09:20:25+00:00 + CreatedAt string `json:"created_at,omitempty"` // The name of the application. // example: Tusky Name string `json:"name"` diff --git a/internal/api/util/scopes.go b/internal/api/util/scopes.go index 8161de500..594a46ecd 100644 --- a/internal/api/util/scopes.go +++ b/internal/api/util/scopes.go @@ -27,6 +27,7 @@ const ( /* Sub-scopes / scope components */ scopeAccounts = "accounts" + scopeApplications = "applications" scopeBlocks = "blocks" scopeBookmarks = "bookmarks" scopeConversations = "conversations" @@ -57,6 +58,8 @@ const ( ScopeReadAccounts Scope = ScopeRead + ":" + scopeAccounts ScopeWriteAccounts Scope = ScopeWrite + ":" + scopeAccounts + ScopeReadApplications Scope = ScopeRead + ":" + scopeApplications + ScopeWriteApplications Scope = ScopeWrite + ":" + scopeApplications ScopeReadBlocks Scope = ScopeRead + ":" + scopeBlocks ScopeWriteBlocks Scope = ScopeWrite + ":" + scopeBlocks ScopeReadBookmarks Scope = ScopeRead + ":" + scopeBookmarks diff --git a/internal/db/application.go b/internal/db/application.go index a3061f028..3e163c810 100644 --- a/internal/db/application.go +++ b/internal/db/application.go @@ -31,6 +31,9 @@ type Application interface { // GetApplicationByClientID fetches the application from the database with corresponding client_id value. GetApplicationByClientID(ctx context.Context, clientID string) (*gtsmodel.Application, error) + // GetApplicationsManagedByUserID fetches a page of applications managed by the given userID. + GetApplicationsManagedByUserID(ctx context.Context, userID string, page *paging.Page) ([]*gtsmodel.Application, error) + // PutApplication places the new application in the database, erroring on non-unique ID or client_id. PutApplication(ctx context.Context, app *gtsmodel.Application) error diff --git a/internal/db/bundb/admin.go b/internal/db/bundb/admin.go index a311d2fc5..02f10f44f 100644 --- a/internal/db/bundb/admin.go +++ b/internal/db/bundb/admin.go @@ -194,6 +194,17 @@ func (a *adminDB) NewSignup(ctx context.Context, newSignup gtsmodel.NewSignup) ( return nil, err } + // If no app ID was set, + // use the instance app ID. + if newSignup.AppID == "" { + instanceApp, err := a.state.DB.GetInstanceApplication(ctx) + if err != nil { + err := gtserror.Newf("db error getting instance app: %w", err) + return nil, err + } + newSignup.AppID = instanceApp.ID + } + user = >smodel.User{ ID: newUserID, AccountID: account.ID, diff --git a/internal/db/bundb/application.go b/internal/db/bundb/application.go index c21221c9f..e7dd81e72 100644 --- a/internal/db/bundb/application.go +++ b/internal/db/bundb/application.go @@ -56,6 +56,73 @@ func (a *applicationDB) GetApplicationByClientID(ctx context.Context, clientID s ) } +func (a *applicationDB) GetApplicationsManagedByUserID( + ctx context.Context, + userID string, + page *paging.Page, +) ([]*gtsmodel.Application, error) { + var ( + // Get paging params. + minID = page.GetMin() + maxID = page.GetMax() + limit = page.GetLimit() + order = page.GetOrder() + + // Make educated guess for slice size. + appIDs = make([]string, 0, limit) + ) + + // Ensure user ID. + if userID == "" { + return nil, gtserror.New("userID not set") + } + + q := a.db. + NewSelect(). + TableExpr("? AS ?", bun.Ident("applications"), bun.Ident("application")). + Column("application.id"). + Where("? = ?", bun.Ident("application.managed_by_user_id"), userID) + + if maxID != "" { + // Return only apps LOWER (ie., older) than maxID. + q = q.Where("? < ?", bun.Ident("application.id"), maxID) + } + + if minID != "" { + // Return only apps HIGHER (ie., newer) than minID. + q = q.Where("? > ?", bun.Ident("application.id"), minID) + } + + if limit > 0 { + q = q.Limit(limit) + } + + if order == paging.OrderAscending { + // Page up. + q = q.Order("application.id ASC") + } else { + // Page down. + q = q.Order("application.id DESC") + } + + if err := q.Scan(ctx, &appIDs); err != nil { + return nil, err + } + + if len(appIDs) == 0 { + return nil, nil + } + + // If we're paging up, we still want apps + // to be sorted by ID desc (ie., newest to + // oldest), so reverse ids slice. + if order == paging.OrderAscending { + slices.Reverse(appIDs) + } + + return a.getApplicationsByIDs(ctx, appIDs) +} + func (a *applicationDB) getApplication(ctx context.Context, lookup string, dbQuery func(*gtsmodel.Application) error, keyParts ...any) (*gtsmodel.Application, error) { return a.state.Caches.DB.Application.LoadOne(lookup, func() (*gtsmodel.Application, error) { var app gtsmodel.Application @@ -69,6 +136,37 @@ func (a *applicationDB) getApplication(ctx context.Context, lookup string, dbQue }, keyParts...) } +func (a *applicationDB) getApplicationsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.Application, error) { + apps, err := a.state.Caches.DB.Application.LoadIDs("ID", + ids, + func(uncached []string) ([]*gtsmodel.Application, error) { + // Preallocate expected length of uncached apps. + apps := make([]*gtsmodel.Application, 0, len(uncached)) + + // Perform database query scanning + // the remaining (uncached) app IDs. + if err := a.db.NewSelect(). + Model(&apps). + Where("? IN (?)", bun.Ident("id"), bun.In(uncached)). + Scan(ctx); err != nil { + return nil, err + } + + return apps, nil + }, + ) + if err != nil { + return nil, err + } + + // Reorder the apps by their + // IDs to ensure in correct order. + getID := func(t *gtsmodel.Application) string { return t.ID } + xslices.OrderBy(apps, ids, getID) + + return apps, nil +} + func (a *applicationDB) PutApplication(ctx context.Context, app *gtsmodel.Application) error { return a.state.Caches.DB.Application.Store(app, func() error { _, err := a.db.NewInsert().Model(app).Exec(ctx) diff --git a/internal/db/bundb/migrations/20250310144102_application_management.go b/internal/db/bundb/migrations/20250310144102_application_management.go new file mode 100644 index 000000000..de24a9b07 --- /dev/null +++ b/internal/db/bundb/migrations/20250310144102_application_management.go @@ -0,0 +1,93 @@ +// 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 migrations + +import ( + "context" + + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + + // Update users to set all "created_by_application_id" + // values to the instance application, to correct some + // past issues where this wasn't set. Skip this if there's + // no users though, as in that case we probably don't even + // have an instance application yet. + usersLen, err := tx. + NewSelect(). + Table("users"). + Count(ctx) + if err != nil { + return err + } + + if usersLen == 0 { + // Nothing to do. + return nil + } + + // Get Instance account ID. + var instanceAcctID string + if err := tx. + NewSelect(). + Table("accounts"). + Column("id"). + Where("? = ?", bun.Ident("username"), config.GetHost()). + Where("? IS NULL", bun.Ident("domain")). + Scan(ctx, &instanceAcctID); err != nil { + return err + } + + // Get the instance app ID. + var instanceAppID string + if err := tx. + NewSelect(). + Table("applications"). + Column("id"). + Where("? = ?", bun.Ident("client_id"), instanceAcctID). + Scan(ctx, &instanceAppID); err != nil { + return err + } + + // Set instance app + // ID on all users. + if _, err := tx. + NewUpdate(). + Table("users"). + Set("? = ?", bun.Ident("created_by_application_id"), instanceAppID). + Exec(ctx); err != nil { + return err + } + + return nil + }) + } + + down := func(ctx context.Context, db *bun.DB) error { + return nil + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/processing/application/application.go b/internal/processing/application/application.go new file mode 100644 index 000000000..4ad35749e --- /dev/null +++ b/internal/processing/application/application.go @@ -0,0 +1,38 @@ +// 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 application + +import ( + "github.com/superseriousbusiness/gotosocial/internal/state" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +type Processor struct { + state *state.State + converter *typeutils.Converter +} + +func New( + state *state.State, + converter *typeutils.Converter, +) Processor { + return Processor{ + state: state, + converter: converter, + } +} diff --git a/internal/processing/app.go b/internal/processing/application/create.go similarity index 68% rename from internal/processing/app.go rename to internal/processing/application/create.go index c9bd4eb68..d1340a39f 100644 --- a/internal/processing/app.go +++ b/internal/processing/application/create.go @@ -15,24 +15,28 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package processing +package application import ( "context" + "errors" "fmt" "net/url" "strings" "github.com/google/uuid" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" - apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) -func (p *Processor) AppCreate(ctx context.Context, authed *apiutil.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, gtserror.WithCode) { +func (p *Processor) Create( + ctx context.Context, + managedByUserID string, + form *apimodel.ApplicationCreateRequest, +) (*apimodel.Application, gtserror.WithCode) { // Set default 'read' for // scopes if it's not set. var scopes string @@ -49,13 +53,32 @@ func (p *Processor) AppCreate(ctx context.Context, authed *apiutil.Auth, form *a // Redirect URIs can be just one value, or can be passed // as a newline-separated list of strings. Ensure each URI // is parseable + normalize it by reconstructing from *url.URL. - for _, redirectStr := range strings.Split(form.RedirectURIs, "\n") { + // Also ensure we don't add multiple copies of the same URI. + redirectStrs := strings.Split(form.RedirectURIs, "\n") + added := make(map[string]struct{}, len(redirectStrs)) + + for _, redirectStr := range redirectStrs { + redirectStr = strings.TrimSpace(redirectStr) + if redirectStr == "" { + continue + } + redirectURI, err := url.Parse(redirectStr) if err != nil { errText := fmt.Sprintf("error parsing redirect URI: %v", err) return nil, gtserror.NewErrorBadRequest(err, errText) } - redirectURIs = append(redirectURIs, redirectURI.String()) + + redirectURIStr := redirectURI.String() + if _, alreadyAdded := added[redirectURIStr]; !alreadyAdded { + redirectURIs = append(redirectURIs, redirectURIStr) + added[redirectURIStr] = struct{}{} + } + } + + if len(redirectURIs) == 0 { + errText := "no redirect URIs left after trimming space" + return nil, gtserror.NewErrorBadRequest(errors.New(errText), errText) } } else { // No redirect URI(s) provided, just set default oob. @@ -71,13 +94,14 @@ func (p *Processor) AppCreate(ctx context.Context, authed *apiutil.Auth, form *a // Generate + store app // to put in the database. app := >smodel.Application{ - ID: id.NewULID(), - Name: form.ClientName, - Website: form.Website, - RedirectURIs: redirectURIs, - ClientID: clientID, - ClientSecret: uuid.NewString(), - Scopes: scopes, + ID: id.NewULID(), + Name: form.ClientName, + Website: form.Website, + RedirectURIs: redirectURIs, + ClientID: clientID, + ClientSecret: uuid.NewString(), + Scopes: scopes, + ManagedByUserID: managedByUserID, } if err := p.state.DB.PutApplication(ctx, app); err != nil { return nil, gtserror.NewErrorInternalError(err) diff --git a/internal/processing/application/get.go b/internal/processing/application/get.go new file mode 100644 index 000000000..0a3eb8e04 --- /dev/null +++ b/internal/processing/application/get.go @@ -0,0 +1,104 @@ +// 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 application + +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) Get( + 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) + } + + 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) + } + + return apiApp, nil +} + +func (p *Processor) GetPage( + ctx context.Context, + userID string, + page *paging.Page, +) (*apimodel.PageableResponse, gtserror.WithCode) { + apps, err := p.state.DB.GetApplicationsManagedByUserID(ctx, userID, page) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("db error getting apps: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + count := len(apps) + if count == 0 { + return paging.EmptyResponse(), nil + } + + var ( + // Get the lowest and highest + // ID values, used for paging. + lo = apps[count-1].ID + hi = apps[0].ID + + // Best-guess items length. + items = make([]interface{}, 0, count) + ) + + for _, app := range apps { + apiApp, err := p.converter.AppToAPIAppSensitive(ctx, app) + if err != nil { + log.Errorf(ctx, "error converting app to api app: %v", err) + continue + } + + // Append req to return items. + items = append(items, apiApp) + } + + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/apps", + Next: page.Next(lo, hi), + Prev: page.Prev(lo, hi), + }), nil +} diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 0bba23089..0324f49cf 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -29,6 +29,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/processing/account" "github.com/superseriousbusiness/gotosocial/internal/processing/admin" "github.com/superseriousbusiness/gotosocial/internal/processing/advancedmigrations" + "github.com/superseriousbusiness/gotosocial/internal/processing/application" "github.com/superseriousbusiness/gotosocial/internal/processing/common" "github.com/superseriousbusiness/gotosocial/internal/processing/conversations" "github.com/superseriousbusiness/gotosocial/internal/processing/fedi" @@ -81,6 +82,7 @@ type Processor struct { account account.Processor admin admin.Processor advancedmigrations advancedmigrations.Processor + application application.Processor conversations conversations.Processor fedi fedi.Processor filtersv1 filtersv1.Processor @@ -113,6 +115,10 @@ func (p *Processor) AdvancedMigrations() *advancedmigrations.Processor { return &p.advancedmigrations } +func (p *Processor) Application() *application.Processor { + return &p.application +} + func (p *Processor) Conversations() *conversations.Processor { return &p.conversations } @@ -221,6 +227,7 @@ func NewProcessor( // processors + pin them to this struct. processor.account = account.New(&common, state, converter, mediaManager, federator, visFilter, parseMentionFunc) processor.admin = admin.New(&common, state, cleaner, subscriptions, federator, converter, mediaManager, federator.TransportController(), emailSender) + processor.application = application.New(state, converter) processor.conversations = conversations.New(state, converter, visFilter) processor.fedi = fedi.New(state, &common, converter, federator, visFilter) processor.filtersv1 = filtersv1.New(state, converter, &processor.stream) diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 537eeb6db..0365b6d78 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -623,8 +623,14 @@ func (c *Converter) AppToAPIAppSensitive(ctx context.Context, a *gtsmodel.Applic return nil, gtserror.Newf("error getting VAPID public key: %w", err) } + createdAt, err := id.TimeFromULID(a.ID) + if err != nil { + return nil, gtserror.Newf("error converting id to time: %w", err) + } + return &apimodel.Application{ ID: a.ID, + CreatedAt: util.FormatISO8601(createdAt), Name: a.Name, Website: a.Website, RedirectURI: strings.Join(a.RedirectURIs, "\n"), diff --git a/web/source/settings/components/authorization/index.tsx b/web/source/settings/components/authorization/index.tsx index 7c6373399..3eeeb393a 100644 --- a/web/source/settings/components/authorization/index.tsx +++ b/web/source/settings/components/authorization/index.tsx @@ -17,7 +17,7 @@ along with this program. If not, see . */ -import { useLogoutMutation, useVerifyCredentialsQuery } from "../../lib/query/oauth"; +import { useLogoutMutation, useVerifyCredentialsQuery } from "../../lib/query/login"; import { store } from "../../redux/store"; import React, { ReactNode } from "react"; @@ -27,8 +27,8 @@ import { Error } from "../error"; import { NoArg } from "../../lib/types/query"; export function Authorization({ App }) { - const { loginState, expectingRedirect } = store.getState().oauth; - const skip = (loginState == "none" || loginState == "logout" || expectingRedirect); + const { current: loginState, expectingRedirect } = store.getState().login; + const skip = (loginState == "none" || loginState == "loggedout" || expectingRedirect); const [ logoutQuery ] = useLogoutMutation(); const { @@ -46,9 +46,9 @@ export function Authorization({ App }) { showLogin = false; let loadingInfo = ""; - if (loginState == "callback") { + if (loginState == "awaitingcallback") { loadingInfo = "Processing OAUTH callback."; - } else if (loginState == "login") { + } else if (loginState == "loggedin") { loadingInfo = "Verifying stored login."; } @@ -70,7 +70,7 @@ export function Authorization({ App }) { ); } - if (loginState == "login" && isSuccess) { + if (loginState == "loggedin" && isSuccess) { return ; } else { return ( diff --git a/web/source/settings/components/authorization/login.tsx b/web/source/settings/components/authorization/login.tsx index 28ed7953c..c54125972 100644 --- a/web/source/settings/components/authorization/login.tsx +++ b/web/source/settings/components/authorization/login.tsx @@ -19,7 +19,7 @@ import React from "react"; -import { useAuthorizeFlowMutation } from "../../lib/query/oauth"; +import { useAuthorizeFlowMutation } from "../../lib/query/login"; import { useTextInput, useValue } from "../../lib/form"; import useFormSubmit from "../../lib/form/submit"; import MutationButton from "../form/mutation-button"; diff --git a/web/source/settings/components/status.tsx b/web/source/settings/components/status.tsx index ec7af3ad3..701a9f8b7 100644 --- a/web/source/settings/components/status.tsx +++ b/web/source/settings/components/status.tsx @@ -18,7 +18,7 @@ */ import React from "react"; -import { useVerifyCredentialsQuery } from "../lib/query/oauth"; +import { useVerifyCredentialsQuery } from "../lib/query/login"; import { MediaAttachment, Status as StatusType } from "../lib/types/status"; import sanitize from "sanitize-html"; diff --git a/web/source/settings/components/user-logout-card.tsx b/web/source/settings/components/user-logout-card.tsx index f9acc9698..e15a1ee6a 100644 --- a/web/source/settings/components/user-logout-card.tsx +++ b/web/source/settings/components/user-logout-card.tsx @@ -20,7 +20,7 @@ import React from "react"; import Loading from "./loading"; import { Error as ErrorC } from "./error"; -import { useVerifyCredentialsQuery, useLogoutMutation } from "../lib/query/oauth"; +import { useVerifyCredentialsQuery, useLogoutMutation } from "../lib/query/login"; import { useInstanceV1Query } from "../lib/query/gts-api"; export default function UserLogoutCard() { diff --git a/web/source/settings/lib/query/admin/custom-emoji/index.ts b/web/source/settings/lib/query/admin/custom-emoji/index.ts index 56684f03b..c5dd0a814 100644 --- a/web/source/settings/lib/query/admin/custom-emoji/index.ts +++ b/web/source/settings/lib/query/admin/custom-emoji/index.ts @@ -141,7 +141,7 @@ const extended = gtsApi.injectEndpoints({ searchItemForEmoji: build.mutation({ async queryFn(url, api, _extraOpts, fetchWithBQ) { const state = api.getState() as RootState; - const oauthState = state.oauth; + const loginState = state.login; // First search for given url. const searchRes = await fetchWithBQ({ @@ -161,8 +161,8 @@ const extended = gtsApi.injectEndpoints({ // Ensure emojis domain is not OUR domain. If it // is, we already have the emojis by definition. - if (oauthState.instanceUrl !== undefined) { - if (domain == new URL(oauthState.instanceUrl).host) { + if (loginState.instanceUrl !== undefined) { + if (domain == new URL(loginState.instanceUrl).host) { throw "LOCAL_INSTANCE"; } } diff --git a/web/source/settings/lib/query/admin/domain-permissions/export.ts b/web/source/settings/lib/query/admin/domain-permissions/export.ts index 868e3f7a4..f258991c6 100644 --- a/web/source/settings/lib/query/admin/domain-permissions/export.ts +++ b/web/source/settings/lib/query/admin/domain-permissions/export.ts @@ -116,7 +116,7 @@ const extended = gtsApi.injectEndpoints({ // Parse filename to something like: // `example.org-blocklist-2023-10-09.json`. const state = api.getState() as RootState; - const instanceUrl = state.oauth.instanceUrl?? "unknown"; + const instanceUrl = state.login.instanceUrl?? "unknown"; const domain = new URL(instanceUrl).host; const date = new Date(); const filename = [ diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts index 401423766..540191132 100644 --- a/web/source/settings/lib/query/gts-api.ts +++ b/web/source/settings/lib/query/gts-api.ts @@ -77,7 +77,7 @@ const gtsBaseQuery: BaseQueryFn< // Retrieve state at the moment // this function was called. const state = api.getState() as RootState; - const { instanceUrl, token } = state.oauth; + const { instanceUrl, token } = state.login; // Derive baseUrl dynamically. let baseUrl: string | undefined; @@ -160,6 +160,7 @@ export const gtsApi = createApi({ reducerPath: "api", baseQuery: gtsBaseQuery, tagTypes: [ + "Application", "Auth", "Emoji", "Report", diff --git a/web/source/settings/lib/query/oauth/index.ts b/web/source/settings/lib/query/login/index.ts similarity index 87% rename from web/source/settings/lib/query/oauth/index.ts rename to web/source/settings/lib/query/login/index.ts index e151b0aee..e3b3b94a1 100644 --- a/web/source/settings/lib/query/oauth/index.ts +++ b/web/source/settings/lib/query/login/index.ts @@ -24,17 +24,10 @@ import { setToken as oauthSetToken, remove as oauthRemove, authorize as oauthAuthorize, -} from "../../../redux/oauth"; +} from "../../../redux/login"; import { RootState } from '../../../redux/store'; import { Account } from '../../types/account'; - -export interface OauthTokenRequestBody { - client_id: string; - client_secret: string; - redirect_uri: string; - grant_type: string; - code: string; -} +import { OAuthAccessTokenRequestBody } from '../../types/oauth'; function getSettingsURL() { /* @@ -45,7 +38,7 @@ function getSettingsURL() { Also drops anything past /settings/, because authorization urls that are too long get rejected by GTS. */ - let [pre, _past] = window.location.pathname.split("/settings"); + const [pre, _past] = window.location.pathname.split("/settings"); return `${window.location.origin}${pre}/settings`; } @@ -64,12 +57,12 @@ const extended = gtsApi.injectEndpoints({ error == undefined ? ["Auth"] : [], async queryFn(_arg, api, _extraOpts, fetchWithBQ) { const state = api.getState() as RootState; - const oauthState = state.oauth; + const loginState = state.login; // If we're not in the middle of an auth/callback, // we may already have an auth token, so just // return a standard verify_credentials query. - if (oauthState.loginState != 'callback') { + if (loginState.current != 'awaitingcallback') { return fetchWithBQ({ url: `/api/v1/accounts/verify_credentials` }); @@ -77,8 +70,8 @@ const extended = gtsApi.injectEndpoints({ // We're in the middle of an auth/callback flow. // Try to retrieve callback code from URL query. - let urlParams = new URLSearchParams(window.location.search); - let code = urlParams.get("code"); + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get("code"); if (code == undefined) { return { error: { @@ -91,7 +84,7 @@ const extended = gtsApi.injectEndpoints({ // Retrieve app with which the // callback code was generated. - let app = oauthState.app; + const app = loginState.app; if (app == undefined || app.client_id == undefined) { return { error: { @@ -104,7 +97,7 @@ const extended = gtsApi.injectEndpoints({ // Use the provided code and app // secret to request an auth token. - const tokenReqBody: OauthTokenRequestBody = { + const tokenReqBody: OAuthAccessTokenRequestBody = { client_id: app.client_id, client_secret: app.client_secret, redirect_uri: SETTINGS_URL, @@ -139,7 +132,7 @@ const extended = gtsApi.injectEndpoints({ authorizeFlow: build.mutation({ async queryFn(formData, api, _extraOpts, fetchWithBQ) { const state = api.getState() as RootState; - const oauthState = state.oauth; + const loginState = state.login; let instanceUrl: string; if (!formData.instance.startsWith("http")) { @@ -147,8 +140,8 @@ const extended = gtsApi.injectEndpoints({ } instanceUrl = new URL(formData.instance).origin; - if (oauthState?.instanceUrl == instanceUrl && oauthState.app) { - return { data: oauthState.app }; + if (loginState?.instanceUrl == instanceUrl && loginState.app) { + return { data: loginState.app }; } const appResult = await fetchWithBQ({ @@ -166,24 +159,24 @@ const extended = gtsApi.injectEndpoints({ return { error: appResult.error as FetchBaseQueryError }; } - let app = appResult.data as any; + const app = appResult.data as any; app.scopes = formData.scopes; api.dispatch(oauthAuthorize({ instanceUrl: instanceUrl, app: app, - loginState: "callback", + current: "awaitingcallback", expectingRedirect: true })); - let url = new URL(instanceUrl); + const url = new URL(instanceUrl); url.pathname = "/oauth/authorize"; url.searchParams.set("client_id", app.client_id); url.searchParams.set("redirect_uri", SETTINGS_URL); url.searchParams.set("response_type", "code"); url.searchParams.set("scope", app.scopes); - let redirectURL = url.toString(); + const redirectURL = url.toString(); window.location.assign(redirectURL); return { data: null }; }, diff --git a/web/source/settings/lib/query/user/applications.ts b/web/source/settings/lib/query/user/applications.ts new file mode 100644 index 000000000..8c011f066 --- /dev/null +++ b/web/source/settings/lib/query/user/applications.ts @@ -0,0 +1,133 @@ +/* + 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 { RootState } from "../../../redux/store"; +import { + SearchAppParams, + SearchAppResp, + App, + AppCreateParams, +} from "../../types/application"; +import { OAuthAccessToken, OAuthAccessTokenRequestBody } from "../../types/oauth"; +import { gtsApi } from "../gts-api"; +import parse from "parse-link-header"; + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + searchApp: 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/apps${query}` + }; + }, + // Headers required for paging. + transformResponse: (apiResp: App[], meta) => { + const apps = apiResp; + const linksStr = meta?.response?.headers.get("Link"); + const links = parse(linksStr); + return { apps, links }; + }, + providesTags: [{ type: "Application", id: "TRANSFORMED" }] + }), + + getApp: build.query({ + query: (id) => ({ + method: "GET", + url: `/api/v1/apps/${id}`, + }), + providesTags: (_result, _error, id) => [ + { type: 'Application', id } + ], + }), + + createApp: build.mutation({ + query: (formData) => ({ + method: "POST", + url: `/api/v1/apps`, + asForm: true, + body: formData, + discardEmpty: true + }), + invalidatesTags: [{ type: "Application", id: "TRANSFORMED" }], + }), + + getOOBAuthCode: build.mutation({ + async queryFn({ app, scope, redirectURI }, api, _extraOpts, _fetchWithBQ) { + // Fetch the instance URL string from + // oauth state, eg., https://example.org. + const state = api.getState() as RootState; + if (!state.login.instanceUrl) { + return { + error: { + status: 'CUSTOM_ERROR', + error: "oauthState.instanceUrl undefined", + } + }; + } + const instanceUrl = state.login.instanceUrl; + + // Parse instance URL + set params on it. + const url = new URL(instanceUrl); + url.pathname = "/oauth/authorize"; + url.searchParams.set("client_id", app.client_id); + url.searchParams.set("redirect_uri", redirectURI); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", scope); + + // Set the app ID in state so we know which + // app to get out of our store after redirect. + url.searchParams.set("state", app.id); + + // Whisk the user away to the authorize page. + window.location.assign(url.toString()); + return { data: null }; + } + }), + + getAccessTokenForApp: build.mutation({ + query: (formData) => ({ + method: "POST", + url: `/oauth/token`, + asForm: true, + body: formData, + discardEmpty: true + }), + }), + }) +}); + +export const { + useLazySearchAppQuery, + useCreateAppMutation, + useGetAppQuery, + useGetOOBAuthCodeMutation, + useGetAccessTokenForAppMutation, +} = extended; diff --git a/web/source/settings/lib/types/application.ts b/web/source/settings/lib/types/application.ts new file mode 100644 index 000000000..125e82c95 --- /dev/null +++ b/web/source/settings/lib/types/application.ts @@ -0,0 +1,71 @@ +/* + 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 App { + id: string; + created_at: string; + name: string; + website?: string; + redirect_uris: string[]; + redirect_uri: string; + client_id: string; + client_secret: string; + vapid_key: string; + scopes: string[]; +} + +/** + * Parameters for GET to /api/v1/apps. + */ +export interface SearchAppParams { + /** + * 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 SearchAppResp { + apps: App[]; + links: Links | null; +} + +export interface AppCreateParams { + client_name: string; + redirect_uris: string; + scopes: string; + website: string; +} diff --git a/web/source/settings/lib/types/oauth.ts b/web/source/settings/lib/types/oauth.ts new file mode 100644 index 000000000..b077ed356 --- /dev/null +++ b/web/source/settings/lib/types/oauth.ts @@ -0,0 +1,49 @@ +/* + 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 . +*/ + +/** + * OAuthToken represents a response + * to an OAuth token request. + */ +export interface OAuthAccessToken { + /** + * Most likely to be 'Bearer' + * but may be something else. + */ + token_type: string; + /** + * The actual token. Can be passed in to + * authenticate further requests using the + * Authorization header and the token type. + */ + access_token: string; +} + +export interface OAuthApp { + client_id: string; + client_secret: string; +} + +export interface OAuthAccessTokenRequestBody { + client_id: string; + client_secret: string; + redirect_uri: string; + grant_type: string; + code: string; +} diff --git a/web/source/settings/lib/util/formvalidators.ts b/web/source/settings/lib/util/formvalidators.ts index 358db616c..7b18db1ed 100644 --- a/web/source/settings/lib/util/formvalidators.ts +++ b/web/source/settings/lib/util/formvalidators.ts @@ -29,6 +29,10 @@ export function formDomainValidator(domain: string): string { return ""; } + if (domain === "localhost") { + return ""; + } + if (domain[domain.length-1] === ".") { return "invalid domain"; } @@ -63,5 +67,5 @@ export function urlValidator(urlStr: string): string { return `invalid protocol, must be http or https`; } - return formDomainValidator(url.host); + return formDomainValidator(url.hostname); } diff --git a/web/source/settings/lib/util/index.ts b/web/source/settings/lib/util/index.ts index 4c8a90626..8bcf5ab5d 100644 --- a/web/source/settings/lib/util/index.ts +++ b/web/source/settings/lib/util/index.ts @@ -30,7 +30,7 @@ export function UseOurInstanceAccount(account: AdminAccount): boolean { // Pull our own URL out of storage so we can // tell if account is our instance account. const ourDomain = useMemo(() => { - const instanceUrlStr = store.getState().oauth.instanceUrl; + const instanceUrlStr = store.getState().login.instanceUrl; if (!instanceUrlStr) { return ""; } diff --git a/web/source/settings/redux/oauth.ts b/web/source/settings/redux/login.ts similarity index 59% rename from web/source/settings/redux/oauth.ts rename to web/source/settings/redux/login.ts index 1d6bf9bb1..2ba06dfff 100644 --- a/web/source/settings/redux/oauth.ts +++ b/web/source/settings/redux/login.ts @@ -18,33 +18,11 @@ */ import { PayloadAction, createSlice } from "@reduxjs/toolkit"; +import { OAuthApp, OAuthAccessToken } from "../lib/types/oauth"; -/** - * OAuthToken represents a response - * to an OAuth token request. - */ -export interface OAuthToken { - /** - * Most likely to be 'Bearer' - * but may be something else. - */ - token_type: string; - /** - * The actual token. Can be passed in to - * authenticate further requests using the - * Authorization header and the token type. - */ - access_token: string; -} - -export interface OAuthApp { - client_id: string; - client_secret: string; -} - -export interface OAuthState { +export interface LoginState { instanceUrl?: string; - loginState: "none" | "callback" | "login" | "logout"; + current: "none" | "awaitingcallback" | "loggedin" | "loggedout"; expectingRedirect: boolean; /** * Token stored in easy-to-use format. @@ -55,29 +33,31 @@ export interface OAuthState { app?: OAuthApp; } -const initialState: OAuthState = { - loginState: 'none', +const initialState: LoginState = { + current: 'none', expectingRedirect: false, }; -export const oauthSlice = createSlice({ - name: "oauth", +export const loginSlice = createSlice({ + name: "login", initialState: initialState, reducers: { - authorize: (_state, action: PayloadAction) => { + authorize: (_state, action: PayloadAction) => { // Overrides state with payload. return action.payload; }, - setToken: (state, action: PayloadAction) => { - // Mark us as logged in by storing token. + setToken: (state, action: PayloadAction) => { + // Mark us as logged + // in by storing token. state.token = `${action.payload.token_type} ${action.payload.access_token}`; - state.loginState = "login"; + state.current = "loggedin"; }, remove: (state) => { - // Mark us as logged out by clearing auth. + // Mark us as logged + // out by clearing auth. delete state.token; delete state.app; - state.loginState = "logout"; + state.current = "loggedout"; } } }); @@ -86,4 +66,4 @@ export const { authorize, setToken, remove, -} = oauthSlice.actions; +} = loginSlice.actions; diff --git a/web/source/settings/redux/store.ts b/web/source/settings/redux/store.ts index 0c1285187..076f5f88d 100644 --- a/web/source/settings/redux/store.ts +++ b/web/source/settings/redux/store.ts @@ -30,19 +30,19 @@ import { REGISTER, } from "redux-persist"; -import { oauthSlice } from "./oauth"; +import { loginSlice } from "./login"; import { gtsApi } from "../lib/query/gts-api"; const combinedReducers = combineReducers({ [gtsApi.reducerPath]: gtsApi.reducer, - oauth: oauthSlice.reducer, + login: loginSlice.reducer, }); const persistedReducer = persistReducer({ key: "gotosocial-settings", storage: require("redux-persist/lib/storage").default, stateReconciler: require("redux-persist/lib/stateReconciler/autoMergeLevel1").default, - whitelist: ["oauth"], + whitelist: ["login"], migrate: async (state) => { if (state == undefined) { return state; @@ -51,8 +51,8 @@ const persistedReducer = persistReducer({ // This is a cheeky workaround for // redux-persist being a stickler. let anyState = state as any; - if (anyState?.oauth != undefined) { - anyState.oauth.expectingRedirect = false; + if (anyState?.login != undefined) { + anyState.login.expectingRedirect = false; } return anyState; diff --git a/web/source/settings/style.css b/web/source/settings/style.css index 5a85f370e..8d47512a6 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -1495,6 +1495,34 @@ button.tab-button { } } +.applications-view { + .application { + .info-list { + border: none; + width: 100%; + + .info-list-entry { + background: none; + padding: 0; + } + + > .info-list-entry > .monospace { + font-size: large; + } + } + } +} + +.application-details { + .info-list { + margin-top: 1rem; + + > .info-list-entry > .monospace { + font-size: large; + } + } +} + .instance-rules { list-style-position: inside; margin: 0; diff --git a/web/source/settings/views/user/applications/callback.tsx b/web/source/settings/views/user/applications/callback.tsx new file mode 100644 index 000000000..57247fc71 --- /dev/null +++ b/web/source/settings/views/user/applications/callback.tsx @@ -0,0 +1,85 @@ +/* + 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, { useEffect } 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"; + +export function AppTokenCallback({}) { + + + // Read the callback authorization + // code + state from the search params. + const search = useSearch(); + const urlQueryParams = new URLSearchParams(search); + const code = urlQueryParams.get("code"); + const appId = urlQueryParams.get("state"); + + if (!code || !appId) { + const err = Error("code or app id not defined"); + return ; + } + + return( + <> +

Access Token

+ + + ); +} + + +function AccessForAppForm({ data: app, code }: { data: App, code: string }) { + const redirectURI = useCallbackURL(); + + // Prepare to call /oauth/token to + // exchange code for access token. + const form = { + client_id: useValue("client_id", app.client_id), + client_secret: useValue("client_secret", app.client_secret), + redirect_uri: useValue("redirect_uri", redirectURI), + code: useValue("code", code), + grant_type: useValue("grant_type", "authorization_code"), + + }; + const [ submit, result ] = useFormSubmit(form, useGetAccessTokenForAppMutation()); + + return ( +
+ + + ); +} diff --git a/web/source/settings/views/user/applications/common.tsx b/web/source/settings/views/user/applications/common.tsx new file mode 100644 index 000000000..393c2992b --- /dev/null +++ b/web/source/settings/views/user/applications/common.tsx @@ -0,0 +1,85 @@ +/* + 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, { useMemo } from "react"; +import { App } from "../../../lib/types/application"; +import { useStore } from "react-redux"; +import { RootState } from "../../../redux/store"; + +export const useAppWebsite = (app: App) => { + return useMemo(() => { + if (!app.website) { + return ""; + } + + try { + // Try to parse nicely and return link. + const websiteURL = new URL(app.website); + const websiteURLStr = websiteURL.toString(); + return ( + {websiteURLStr} + ); + } catch { + // Fall back to returning string. + return app.website; + } + }, [app.website]); +}; + +export const useCreated = (app: App) => { + return useMemo(() => { + const createdAt = new Date(app.created_at); + return ; + }, [app.created_at]); +}; + +export const useRedirectURIs= (app: App) => { + return useMemo(() => { + const length = app.redirect_uris.length; + if (length === 1) { + return app.redirect_uris[0]; + } + + return app.redirect_uris.map((redirectURI, i) => { + return i === 0 ? <>{redirectURI} : <>
{redirectURI}; + }); + + }, [app.redirect_uris]); +}; + +export const useCallbackURL = () => { + const state = useStore().getState() as RootState; + const instanceUrl = state.login.instanceUrl; + if (instanceUrl === undefined) { + throw "instanceUrl undefined"; + } + + return useMemo(() => { + const url = new URL(instanceUrl); + if (url === null) { + throw "redirectURI null"; + } + url.pathname = "/settings/user/applications/callback"; + return url.toString(); + }, [instanceUrl]); +}; diff --git a/web/source/settings/views/user/applications/detail.tsx b/web/source/settings/views/user/applications/detail.tsx new file mode 100644 index 000000000..663e01497 --- /dev/null +++ b/web/source/settings/views/user/applications/detail.tsx @@ -0,0 +1,130 @@ +/* + 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 { 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 { App } from "../../../lib/types/application"; +import { useAppWebsite, useCallbackURL, useCreated, useRedirectURIs } from "./common"; +import MutationButton from "../../../components/form/mutation-button"; +import { useTextInput } from "../../../lib/form"; +import { TextInput } from "../../../components/form/inputs"; + +export default function AppDetail({ }) { + const params: { appId: string } = useParams(); + const baseUrl = useBaseUrl(); + const backLocation: String = history.state?.backLocation ?? `~${baseUrl}`; + + return ( +
+

Application Details

+ +
+ ); +} + +function AppDetailForm({ data: app, backLocation }: { data: App, backLocation: string }) { + return ( + <> + + + + ); +} + +function AppBasicInfo({ app }: { app: App }) { + const appWebsite = useAppWebsite(app); + const created = useCreated(app); + const redirectURIs = useRedirectURIs(app); + + return ( +
+
+
Name:
+
{app.name}
+
+ + { appWebsite && +
+
Website:
+
{appWebsite}
+
+ } + +
+
Created:
+
{created}
+
+ +
+
Scopes:
+
{app.scopes.join(" ")}
+
+ +
+
Redirect URI(s):
+
{redirectURIs}
+
+
+ ); +} + +function AppTokenForm({ app }: { app: App }) { + const callbackURL = useCallbackURL(); + const [ getOOBAuthCode, result ] = useGetOOBAuthCodeMutation(); + const scope = useTextInput("scope", {}); + + return ( +
{ + e.preventDefault(); + getOOBAuthCode({ + app, + scope: scope.value ?? "", + redirectURI: callbackURL, + }); + }} + > +

Get An Access Token

+ + + + + + ); +} + + diff --git a/web/source/settings/views/user/applications/index.tsx b/web/source/settings/views/user/applications/index.tsx new file mode 100644 index 000000000..0a86adf16 --- /dev/null +++ b/web/source/settings/views/user/applications/index.tsx @@ -0,0 +1,44 @@ +/* + 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 AppsSearchForm from "./search"; + +export default function Applications() { + return ( +
+
+

Applications

+

+ On this page you can search through applications you've created. + To manage an application, click on it to go to the detailed view. +

+ + Learn more about managing your applications (opens in a new tab) + +
+ +
+ ); +} diff --git a/web/source/settings/views/user/applications/new.tsx b/web/source/settings/views/user/applications/new.tsx new file mode 100644 index 000000000..c3c77a97b --- /dev/null +++ b/web/source/settings/views/user/applications/new.tsx @@ -0,0 +1,137 @@ +/* + 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 useFormSubmit from "../../../lib/form/submit"; +import { useTextInput } from "../../../lib/form"; +import MutationButton from "../../../components/form/mutation-button"; +import { TextArea, TextInput } from "../../../components/form/inputs"; +import { useLocation } from "wouter"; +import { useCreateAppMutation } from "../../../lib/query/user/applications"; +import { urlValidator } from "../../../lib/util/formvalidators"; +import { useCallbackURL } from "./common"; + +export default function NewApp() { + const [ _location, setLocation ] = useLocation(); + const callbackURL = useCallbackURL(); + + const form = { + name: useTextInput("client_name"), + redirect_uris: useTextInput("redirect_uris", { + validator: (redirectURIs: string) => { + if (redirectURIs === "") { + return ""; + } + + const invalids = redirectURIs. + split("\n"). + map(redirectURI => redirectURI === "urn:ietf:wg:oauth:2.0:oob" ? "" : urlValidator(redirectURI)). + flatMap((invalid) => invalid || []); + + return invalids.join(", "); + } + }), + scopes: useTextInput("scopes"), + website: useTextInput("website", { + validator: urlValidator, + }), + }; + + const [formSubmit, result] = useFormSubmit( + form, + useCreateAppMutation(), + { + changedOnly: false, + onFinish: (res) => { + if (res.data) { + // Creation successful, + // redirect to apps overview. + setLocation(`/search`); + } + }, + }); + + return ( +
+
+

New Application

+

+ On this page you can create a new managed OAuth application, with the specified redirect URIs and scopes. +
Application name is required, but other fields are optional. +
If you want to obtain an access token right here in the settings panel, be sure to include {callbackURL} in your redirect URIs. +
If not specified, redirect URIs defaults to urn:ietf:wg:oauth:2.0:oob, and scopes defaults to read. +

+ + Learn more about application redirect URIs and scopes (opens in a new tab) + +
+ + + + + +