pee pee poo poo
This commit is contained in:
parent
6c5d369b05
commit
28fdde95d6
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"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:
|
||||
//
|
||||
// ```
|
||||
// <https://example.org/api/v1/apps?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/apps?limit=80&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
|
||||
// ````
|
||||
//
|
||||
// ---
|
||||
// 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)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
|
@ -15,24 +15,28 @@
|
|||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package 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)
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"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
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -160,6 +160,7 @@ export const gtsApi = createApi({
|
|||
reducerPath: "api",
|
||||
baseQuery: gtsBaseQuery,
|
||||
tagTypes: [
|
||||
"Application",
|
||||
"Auth",
|
||||
"Emoji",
|
||||
"Report",
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { RootState } from "../../../redux/store";
|
||||
import {
|
||||
SearchAppParams,
|
||||
SearchAppResp,
|
||||
App,
|
||||
AppCreateParams,
|
||||
} from "../../types/application";
|
||||
import { gtsApi } from "../gts-api";
|
||||
import parse from "parse-link-header";
|
||||
|
||||
const extended = gtsApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
searchApp: build.query<SearchAppResp, SearchAppParams>({
|
||||
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<App, string>({
|
||||
query: (id) => ({
|
||||
method: "GET",
|
||||
url: `/api/v1/apps/${id}`,
|
||||
}),
|
||||
providesTags: (_result, _error, id) => [
|
||||
{ type: 'Application', id }
|
||||
],
|
||||
}),
|
||||
|
||||
createApp: build.mutation<App, AppCreateParams>({
|
||||
query: (formData) => ({
|
||||
method: "POST",
|
||||
url: `/api/v1/apps`,
|
||||
asForm: true,
|
||||
body: formData,
|
||||
discardEmpty: true
|
||||
}),
|
||||
invalidatesTags: [{ type: "Application", id: "TRANSFORMED" }],
|
||||
}),
|
||||
|
||||
getOOBAppToken: build.mutation<null, { app: App, scope: string, redirectURI: string }>({
|
||||
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.oauth.instanceUrl) {
|
||||
return {
|
||||
error: {
|
||||
status: 'CUSTOM_ERROR',
|
||||
error: "oauthState.instanceUrl undefined",
|
||||
}
|
||||
};
|
||||
}
|
||||
const instanceUrl = state.oauth.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);
|
||||
|
||||
// Whisk the user away to the authorize page.
|
||||
window.location.assign(url.toString());
|
||||
return { data: null };
|
||||
}
|
||||
}),
|
||||
|
||||
getAccessTokenFromCode: build.mutation<>
|
||||
})
|
||||
});
|
||||
|
||||
export const {
|
||||
useLazySearchAppQuery,
|
||||
useCreateAppMutation,
|
||||
useGetAppQuery,
|
||||
useGetOOBAppTokenMutation,
|
||||
} = extended;
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useSearch } from "wouter";
|
||||
|
||||
export function AppTokenCallback({}) {
|
||||
const search = useSearch();
|
||||
const urlQueryParams = new URLSearchParams(search);
|
||||
const code = urlQueryParams.get("code");
|
||||
|
||||
|
||||
if (!code) {
|
||||
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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 (
|
||||
<a
|
||||
href={websiteURLStr}
|
||||
target="_blank"
|
||||
rel="nofollow noreferrer noopener"
|
||||
>{websiteURLStr}</a>
|
||||
);
|
||||
} 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 <time dateTime={app.created_at}>{createdAt.toDateString()}</time>;
|
||||
}, [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}</> : <><br/>{redirectURI}</>;
|
||||
});
|
||||
|
||||
}, [app.redirect_uris]);
|
||||
};
|
||||
|
||||
export const useCallbackURL = () => {
|
||||
const state = useStore().getState() as RootState;
|
||||
const instanceUrl = state.oauth.instanceUrl;
|
||||
if (instanceUrl === undefined) {
|
||||
throw "instanceUrl undefined";
|
||||
}
|
||||
|
||||
return useMemo(() => {
|
||||
const url = new URL(instanceUrl);
|
||||
if (url === null) {
|
||||
throw "redirectURI null";
|
||||
}
|
||||
url.pathname = "/settings/applications/callback";
|
||||
return url.toString();
|
||||
}, [instanceUrl]);
|
||||
};
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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, useGetOOBAppTokenMutation } 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 (
|
||||
<div className="application-details">
|
||||
<h1><BackButton to={backLocation}/> Application Details</h1>
|
||||
<FormWithData
|
||||
dataQuery={useGetAppQuery}
|
||||
queryArg={params.appId}
|
||||
DataForm={AppDetailForm}
|
||||
{...{ backLocation: backLocation }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppDetailForm({ data: app, backLocation }: { data: App, backLocation: string }) {
|
||||
return (
|
||||
<>
|
||||
<AppBasicInfo app={app} />
|
||||
<AppTokenForm app={app} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppBasicInfo({ app }: { app: App }) {
|
||||
const appWebsite = useAppWebsite(app);
|
||||
const created = useCreated(app);
|
||||
const redirectURIs = useRedirectURIs(app);
|
||||
|
||||
return (
|
||||
<dl className="info-list">
|
||||
<div className="info-list-entry">
|
||||
<dt>Name:</dt>
|
||||
<dd className="text-cutoff">{app.name}</dd>
|
||||
</div>
|
||||
|
||||
{ appWebsite &&
|
||||
<div className="info-list-entry">
|
||||
<dt>Website:</dt>
|
||||
<dd>{appWebsite}</dd>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Created:</dt>
|
||||
<dd>{created}</dd>
|
||||
</div>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Scopes:</dt>
|
||||
<dd className="monospace">{app.scopes.join(" ")}</dd>
|
||||
</div>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Redirect URI(s):</dt>
|
||||
<dd className="monospace">{redirectURIs}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function AppTokenForm({ app }: { app: App }) {
|
||||
const callbackURL = useCallbackURL();
|
||||
const [ getOOBAppToken, result ] = useGetOOBAppTokenMutation();
|
||||
const scope = useTextInput("scope", {});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
getOOBAppToken({
|
||||
app,
|
||||
scope: scope.value ?? "",
|
||||
redirectURI: callbackURL,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<h2>Get An Access Token</h2>
|
||||
|
||||
<TextInput
|
||||
field={scope}
|
||||
label="Scope (space-separated list of scopes)"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label="Resolve"
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import AppsSearchForm from "./search";
|
||||
|
||||
export default function Applications() {
|
||||
return (
|
||||
<div className="applications-view">
|
||||
<div className="form-section-docs">
|
||||
<h1>Applications</h1>
|
||||
<p>
|
||||
On this page you can search through applications you've created.
|
||||
To manage an application, click on it to go to the detailed view.
|
||||
</p>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#applications"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about managing your applications (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
<AppsSearchForm />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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 (
|
||||
<form
|
||||
onSubmit={formSubmit}
|
||||
// Prevent password managers
|
||||
// trying to fill in fields.
|
||||
autoComplete="off"
|
||||
>
|
||||
<div className="form-section-docs">
|
||||
<h2>New Application</h2>
|
||||
<p>
|
||||
On this page you can create a new managed OAuth application, with the specified redirect URIs and scopes.
|
||||
<br/>Application name is required, but other fields are optional.
|
||||
<br/>If you want to obtain an access token right here in the settings panel, be sure to include <code>{callbackURL}</code> in your redirect URIs.
|
||||
<br/>If not specified, redirect URIs defaults to <code>urn:ietf:wg:oauth:2.0:oob</code>, and scopes defaults to <code>read</code>.
|
||||
</p>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#applications"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about application redirect URIs and scopes (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
field={form.name}
|
||||
label="Application name"
|
||||
placeholder="My Cool Application"
|
||||
autoCapitalize="words"
|
||||
spellCheck="false"
|
||||
required={true}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
field={form.website}
|
||||
label="Application website (optional)"
|
||||
placeholder="https://example.org/my_cool_application"
|
||||
autoCapitalize="none"
|
||||
spellCheck="false"
|
||||
type="url"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
field={form.redirect_uris}
|
||||
label="Redirect URIs (optional, newline-separated entries)"
|
||||
placeholder={`https://example.org/my_cool_application`}
|
||||
autoCapitalize="none"
|
||||
spellCheck="false"
|
||||
rows={5}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
field={form.scopes}
|
||||
label="Scopes (optional, space-separated entries)"
|
||||
placeholder={`read write push`}
|
||||
autoCapitalize="none"
|
||||
spellCheck="false"
|
||||
/>
|
||||
|
||||
<MutationButton
|
||||
label="Create"
|
||||
result={result}
|
||||
disabled={!form.name.value}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,190 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { ReactNode, useEffect, useMemo } from "react";
|
||||
|
||||
import { useTextInput } from "../../../lib/form";
|
||||
import { PageableList } from "../../../components/pageable-list";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
import { Select } from "../../../components/form/inputs";
|
||||
import { useLazySearchAppQuery } from "../../../lib/query/user/applications";
|
||||
import { App } from "../../../lib/types/application";
|
||||
import { useAppWebsite, useCreated, useRedirectURIs } from "./common";
|
||||
|
||||
export default function ApplicationsSearchForm() {
|
||||
const [ location, setLocation ] = useLocation();
|
||||
const search = useSearch();
|
||||
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
|
||||
const [ searchApps, searchRes ] = useLazySearchAppQuery();
|
||||
|
||||
// Populate search form using values from
|
||||
// urlQueryParams, to allow paging.
|
||||
const form = {
|
||||
limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" })
|
||||
};
|
||||
|
||||
// On mount, trigger search.
|
||||
useEffect(() => {
|
||||
searchApps(Object.fromEntries(urlQueryParams), true);
|
||||
}, [urlQueryParams, searchApps]);
|
||||
|
||||
// Rather than triggering the search directly,
|
||||
// the "submit" button changes the location
|
||||
// based on form field params, and lets the
|
||||
// useEffect hook above actually do the search.
|
||||
function submitQuery(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Parse query parameters.
|
||||
const entries = Object.entries(form).map(([k, v]) => {
|
||||
// Take only defined form fields.
|
||||
if (v.value === undefined) {
|
||||
return null;
|
||||
} else if (typeof v.value === "string" && v.value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [[k, v.value.toString()]];
|
||||
}).flatMap(kv => {
|
||||
// Remove any nulls.
|
||||
return kv !== null ? kv : [];
|
||||
});
|
||||
|
||||
const searchParams = new URLSearchParams(entries);
|
||||
setLocation(location + "?" + searchParams.toString());
|
||||
}
|
||||
|
||||
// Location to return to when user clicks
|
||||
// "back" on the application detail view.
|
||||
const backLocation = location + (urlQueryParams.size > 0 ? `?${urlQueryParams}` : "");
|
||||
|
||||
// Function to map an item to a list entry.
|
||||
function itemToEntry(application: App): ReactNode {
|
||||
return (
|
||||
<ApplicationListEntry
|
||||
key={application.id}
|
||||
app={application}
|
||||
linkTo={`/${application.id}`}
|
||||
backLocation={backLocation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
onSubmit={submitQuery}
|
||||
// Prevent password managers
|
||||
// trying to fill in fields.
|
||||
autoComplete="off"
|
||||
>
|
||||
<Select
|
||||
field={form.limit}
|
||||
label="Items per page"
|
||||
options={
|
||||
<>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="0">No limit / show all</option>
|
||||
</>
|
||||
}
|
||||
></Select>
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label={"Search"}
|
||||
result={searchRes}
|
||||
/>
|
||||
</form>
|
||||
<PageableList
|
||||
isLoading={searchRes.isLoading}
|
||||
isFetching={searchRes.isFetching}
|
||||
isSuccess={searchRes.isSuccess}
|
||||
items={searchRes.data?.apps}
|
||||
itemToEntry={itemToEntry}
|
||||
isError={searchRes.isError}
|
||||
error={searchRes.error}
|
||||
emptyMessage={<b>No applications found.</b>}
|
||||
prevNextLinks={searchRes.data?.links}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ApplicationListEntryProps {
|
||||
app: App;
|
||||
linkTo: string;
|
||||
backLocation: string;
|
||||
}
|
||||
|
||||
function ApplicationListEntry({ app, linkTo, backLocation }: ApplicationListEntryProps) {
|
||||
const [ _location, setLocation ] = useLocation();
|
||||
const appWebsite = useAppWebsite(app);
|
||||
const created = useCreated(app);
|
||||
const redirectURIs = useRedirectURIs(app);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`pseudolink application entry`}
|
||||
aria-label={`${app.name}`}
|
||||
title={`${app.name}`}
|
||||
onClick={() => {
|
||||
// When clicking on an app, direct
|
||||
// to the detail view for that app.
|
||||
setLocation(linkTo, {
|
||||
// Store the back location in history so
|
||||
// the detail view can use it to return to
|
||||
// this page (including query parameters).
|
||||
state: { backLocation: backLocation }
|
||||
});
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
>
|
||||
<dl className="info-list">
|
||||
<div className="info-list-entry">
|
||||
<dt>Name:</dt>
|
||||
<dd className="text-cutoff">{app.name}</dd>
|
||||
</div>
|
||||
|
||||
{ appWebsite &&
|
||||
<div className="info-list-entry">
|
||||
<dt>Website:</dt>
|
||||
<dd className="text-cutoff">{appWebsite}</dd>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Created:</dt>
|
||||
<dd className="text-cutoff">{created}</dd>
|
||||
</div>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Scopes:</dt>
|
||||
<dd className="text-cutoff monospace">{app.scopes.join(" ")}</dd>
|
||||
</div>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Redirect URI(s):</dt>
|
||||
<dd className="text-cutoff monospace">{redirectURIs}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -68,6 +68,23 @@ export default function UserMenu() {
|
|||
itemUrl="tokens"
|
||||
icon="fa-certificate"
|
||||
/>
|
||||
<MenuItem
|
||||
name="Applications"
|
||||
itemUrl="applications"
|
||||
defaultChild="search"
|
||||
icon="fa-plug"
|
||||
>
|
||||
<MenuItem
|
||||
name="Search"
|
||||
itemUrl="search"
|
||||
icon="fa-list"
|
||||
/>
|
||||
<MenuItem
|
||||
name="New Application"
|
||||
itemUrl="new"
|
||||
icon="fa-plus"
|
||||
/>
|
||||
</MenuItem>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -29,6 +29,10 @@ import ExportImport from "./export-import";
|
|||
import InteractionRequests from "./interactions";
|
||||
import InteractionRequestDetail from "./interactions/detail";
|
||||
import Tokens from "./tokens";
|
||||
import Applications from "./applications";
|
||||
import NewApp from "./applications/new";
|
||||
import AppDetail from "./applications/detail";
|
||||
import { AppTokenCallback } from "./applications/callback";
|
||||
|
||||
/**
|
||||
* - /settings/user/profile
|
||||
|
@ -55,6 +59,7 @@ export default function UserRouter() {
|
|||
<Route path="/migration" component={UserMigration} />
|
||||
<Route path="/export-import" component={ExportImport} />
|
||||
<Route path="/tokens" component={Tokens} />
|
||||
<ApplicationsRouter />
|
||||
<InteractionRequestsRouter />
|
||||
<Route><Redirect to="/profile" /></Route>
|
||||
</Switch>
|
||||
|
@ -64,6 +69,30 @@ export default function UserRouter() {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* - /settings/users/applications/search
|
||||
* - /settings/users/applications/{appID}
|
||||
*/
|
||||
function ApplicationsRouter() {
|
||||
const parentUrl = useBaseUrl();
|
||||
const thisBase = "/applications";
|
||||
const absBase = parentUrl + thisBase;
|
||||
|
||||
return (
|
||||
<BaseUrlContext.Provider value={absBase}>
|
||||
<Router base={thisBase}>
|
||||
<Switch>
|
||||
<Route path="/search" component={Applications} />
|
||||
<Route path="/new" component={NewApp} />
|
||||
<Route path="/callback" component={AppTokenCallback} />
|
||||
<Route path="/:appId" component={AppDetail} />
|
||||
<Route><Redirect to="/search"/></Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
</BaseUrlContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* - /settings/users/interaction_requests/search
|
||||
* - /settings/users/interaction_requests/{reqId}
|
||||
|
|
|
@ -1124,7 +1124,14 @@
|
|||
resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310"
|
||||
integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
|
||||
|
||||
"@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
|
||||
"@babel/runtime@^7.12.1":
|
||||
version "7.26.9"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.9.tgz#aa4c6facc65b9cb3f87d75125ffd47781b475433"
|
||||
integrity sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/runtime@^7.12.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
|
||||
version "7.23.1"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.1.tgz#72741dc4d413338a91dcb044a86f3c0bc402646d"
|
||||
integrity sha512-hC2v6p8ZSI/W0HUzh3V8C5g+NwSKzKPtJwSpTjwl0o297GP9+ZLQSkdvHz46CM3LqyoXxq+5G9komY+eSqSO0g==
|
||||
|
@ -1402,9 +1409,9 @@
|
|||
fastq "^1.6.0"
|
||||
|
||||
"@reduxjs/toolkit@^1.8.6":
|
||||
version "1.9.6"
|
||||
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.6.tgz#fc968b45fe5b17ff90932c4556960d9c1078365a"
|
||||
integrity sha512-Gc4ikl90ORF4viIdAkY06JNUnODjKfGxZRwATM30EdHq8hLSVoSrwXne5dd739yenP5bJxAX7tLuOWK5RPGtrw==
|
||||
version "1.9.7"
|
||||
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.7.tgz#7fc07c0b0ebec52043f8cb43510cf346405f78a6"
|
||||
integrity sha512-t7v8ZPxhhKgOKtU+uyJT13lu4vL7az5aFi4IdoDs/eS548edn2M8Ik9h8fxgvMjGoAUVFSt6ZC1P5cWmQ014QQ==
|
||||
dependencies:
|
||||
immer "^9.0.21"
|
||||
redux "^4.2.1"
|
||||
|
@ -1440,9 +1447,9 @@
|
|||
integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==
|
||||
|
||||
"@types/hoist-non-react-statics@^3.3.1":
|
||||
version "3.3.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#dc1e9ded53375d37603c479cc12c693b0878aa2a"
|
||||
integrity sha512-YIQtIg4PKr7ZyqNPZObpxfHsHEmuB8dXCxd6qVcGuQVDK2bpsF7bYNnBJ4Nn7giuACZg+WewExgrtAJ3XnA4Xw==
|
||||
version "3.3.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz#6bba74383cdab98e8db4e20ce5b4a6b98caed010"
|
||||
integrity sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
hoist-non-react-statics "^3.3.0"
|
||||
|
@ -5640,9 +5647,9 @@ react-is@^16.13.1, react-is@^16.7.0:
|
|||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
|
||||
react-is@^18.0.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
||||
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
|
||||
version "18.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
|
||||
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
||||
|
||||
react-redux@^8.1.3:
|
||||
version "8.1.3"
|
||||
|
|
Loading…
Reference in New Issue