From d5847e2d2b68a1eb41d43be170cd4ddff9003cff Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Mon, 17 Mar 2025 15:06:17 +0100 Subject: [feature] Application creation + management via API + settings panel (#3906) * [feature] Application creation + management via API + settings panel * fix docs links * add errnorows test * use known application as shorter * add comment about side effects --- internal/processing/account/delete.go | 32 +++++-- internal/processing/app.go | 92 -------------------- internal/processing/application/application.go | 38 ++++++++ internal/processing/application/create.go | 116 +++++++++++++++++++++++++ internal/processing/application/delete.go | 70 +++++++++++++++ internal/processing/application/get.go | 104 ++++++++++++++++++++++ internal/processing/processor.go | 7 ++ 7 files changed, 358 insertions(+), 101 deletions(-) delete mode 100644 internal/processing/app.go create mode 100644 internal/processing/application/application.go create mode 100644 internal/processing/application/create.go create mode 100644 internal/processing/application/delete.go create mode 100644 internal/processing/application/get.go (limited to 'internal/processing') diff --git a/internal/processing/account/delete.go b/internal/processing/account/delete.go index 0064d7eb4..ab64c3270 100644 --- a/internal/processing/account/delete.go +++ b/internal/processing/account/delete.go @@ -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 { - return gtserror.Newf("db error getting tokens: %w", err) + // 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) } - 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 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) } - // Delete the token itself. - if err := p.state.DB.DeleteByID(ctx, t.ID, t); err != nil { + 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 { + if err := p.state.DB.DeleteTokenByID(ctx, t.ID); err != nil { return gtserror.Newf("db error deleting token: %w", err) } } diff --git a/internal/processing/app.go b/internal/processing/app.go deleted file mode 100644 index c9bd4eb68..000000000 --- a/internal/processing/app.go +++ /dev/null @@ -1,92 +0,0 @@ -// 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 processing - -import ( - "context" - "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) { - // Set default 'read' for - // scopes if it's not set. - var scopes string - if form.Scopes == "" { - scopes = "read" - } else { - scopes = form.Scopes - } - - // Normalize + parse requested redirect URIs. - form.RedirectURIs = strings.TrimSpace(form.RedirectURIs) - var redirectURIs []string - if form.RedirectURIs != "" { - // 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") { - 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()) - } - } else { - // No redirect URI(s) provided, just set default oob. - redirectURIs = append(redirectURIs, oauth.OOBURI) - } - - // Generate random client ID. - clientID, err := id.NewRandomULID() - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - // 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, - } - if err := p.state.DB.PutApplication(ctx, app); err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - apiApp, err := p.converter.AppToAPIAppSensitive(ctx, app) - if err != nil { - return nil, gtserror.NewErrorInternalError(err) - } - - return apiApp, nil -} 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/application/create.go b/internal/processing/application/create.go new file mode 100644 index 000000000..d1340a39f --- /dev/null +++ b/internal/processing/application/create.go @@ -0,0 +1,116 @@ +// 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" + "fmt" + "net/url" + "strings" + + "github.com/google/uuid" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "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) 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 + if form.Scopes == "" { + scopes = "read" + } else { + scopes = form.Scopes + } + + // Normalize + parse requested redirect URIs. + form.RedirectURIs = strings.TrimSpace(form.RedirectURIs) + var redirectURIs []string + if form.RedirectURIs != "" { + // 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. + // 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) + } + + 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. + redirectURIs = append(redirectURIs, oauth.OOBURI) + } + + // Generate random client ID. + clientID, err := id.NewRandomULID() + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + // 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, + ManagedByUserID: managedByUserID, + } + if err := p.state.DB.PutApplication(ctx, app); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + apiApp, err := p.converter.AppToAPIAppSensitive(ctx, app) + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + return apiApp, nil +} diff --git a/internal/processing/application/delete.go b/internal/processing/application/delete.go new file mode 100644 index 000000000..02f5e4bfa --- /dev/null +++ b/internal/processing/application/delete.go @@ -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 . + +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 +} 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) -- cgit v1.2.3