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/api/client/apps/appcreate.go | 73 +++++++++++++----
internal/api/client/apps/appdelete.go | 98 +++++++++++++++++++++++
internal/api/client/apps/appget.go | 98 +++++++++++++++++++++++
internal/api/client/apps/apps.go | 10 ++-
internal/api/client/apps/appsget.go | 146 ++++++++++++++++++++++++++++++++++
5 files changed, 406 insertions(+), 19 deletions(-)
create mode 100644 internal/api/client/apps/appdelete.go
create mode 100644 internal/api/client/apps/appget.go
create mode 100644 internal/api/client/apps/appsget.go
(limited to 'internal/api/client/apps')
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/appdelete.go b/internal/api/client/apps/appdelete.go
new file mode 100644
index 000000000..301579929
--- /dev/null
+++ b/internal/api/client/apps/appdelete.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"
+)
+
+// 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)
+}
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..2071c08bd 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,7 @@ 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)
+ attachHandler(http.MethodDelete, BasePathWithID, m.AppDELETEHandler)
}
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)
+}
--
cgit v1.2.3