mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-04-13 12:54:07 +00:00
[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
This commit is contained in:
parent
d3c3d34aae
commit
d5847e2d2b
61 changed files with 3036 additions and 252 deletions
|
@ -2,6 +2,9 @@
|
|||
|
||||
Using the client API requires authentication. This page documents the general flow for retrieving an authentication token with examples for doing this on the CLI using `curl`.
|
||||
|
||||
!!! tip
|
||||
If you want to get an API access token via the settings panel instead, without having to use the command line, see the [Application documentation](https://docs.gotosocial.org/en/latest/user_guide/settings/#applications).
|
||||
|
||||
## Create a new application
|
||||
|
||||
We need to register a new application, which we can then use to request an OAuth token. This is done by making a `POST` request to the `/api/v1/apps` endpoint. Replace `your_app_name` in the command below with the name you want to use for your application:
|
||||
|
@ -19,18 +22,15 @@ curl \
|
|||
|
||||
The string `urn:ietf:wg:oauth:2.0:oob` is an indication of what is known as out-of-band authentication - a technique used in multi-factor authentication to reduce the number of ways that a bad actor can intrude on the authentication process. In this instance, it allows us to view and manually copy the tokens created to use further in this process.
|
||||
|
||||
Note that `scopes` can be any space-separated combination of:
|
||||
|
||||
- `read`
|
||||
- `write`
|
||||
- `admin`
|
||||
!!! tip "Scopes"
|
||||
It is always good practice to grant your application the lowest tier permissions it needs to do its job. e.g. If your application won't be making posts, use `scope=read` or even a subscope of that.
|
||||
|
||||
In this spirit, "read" is used in the example above, which means that the application will be restricted to only being able to do "read" actions.
|
||||
|
||||
For a list of available scopes, see [the swagger docs](https://docs.gotosocial.org/en/latest/api/swagger/).
|
||||
|
||||
!!! warning
|
||||
GoToSocial does not currently support scoped authorization tokens, so any token you obtain in this process will be able to perform all actions on your behalf, including admin actions if your account has admin permissions. Nevertheless, it is always good practice to grant your application the lowest tier permissions it needs to do its job. e.g. If your application won't be making posts, use scope=read.
|
||||
|
||||
In this spirit, "read" is used in the example above, which means that in the future when scoped tokens are supported, the application will be restricted to only being able to do "read" actions.
|
||||
|
||||
You can read more about additional planned OAuth security features [right here](https://github.com/superseriousbusiness/gotosocial/issues/2232).
|
||||
GoToSocial did not support scoped authorization tokens before version 0.19.0, so if you are using a version of GoToSocial below that, then any token you obtain in this process will be able to perform all actions on your behalf, including admin actions if your account has admin permissions.
|
||||
|
||||
A successful call returns a response with a `client_id` and `client_secret`, which we are going need to use in the rest of the process. It looks something like this:
|
||||
|
||||
|
@ -126,7 +126,6 @@ See this example:
|
|||
|
||||
```bash
|
||||
curl \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
|
||||
'https://example.org/api/v1/accounts/verify_credentials'
|
||||
```
|
||||
|
@ -140,7 +139,6 @@ For example, you can issue another `GET` request to the API using the same acces
|
|||
|
||||
```bash
|
||||
curl \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
|
||||
'https://example.org/api/v1/notifications'
|
||||
```
|
||||
|
|
|
@ -828,6 +828,11 @@ definitions:
|
|||
description: Client secret associated with this application.
|
||||
type: string
|
||||
x-go-name: ClientSecret
|
||||
created_at:
|
||||
description: When the application was created. (ISO 8601 Datetime)
|
||||
example: "2021-07-30T09:20:25+00:00"
|
||||
type: string
|
||||
x-go-name: CreatedAt
|
||||
id:
|
||||
description: The ID of the application.
|
||||
example: 01FBVD42CQ3ZEEVMW180SBX03B
|
||||
|
@ -3649,6 +3654,54 @@ info:
|
|||
contact:
|
||||
email: admin@gotosocial.org
|
||||
name: GoToSocial Authors
|
||||
description: |-
|
||||
This document describes the GoToSocial HTTP API.
|
||||
|
||||
For information on how to authenticate with the API using an OAuth access token, see the documentation here: https://docs.gotosocial.org/en/latest/api/authentication/.
|
||||
|
||||
Available scopes are:
|
||||
|
||||
admin: grants admin access to everything
|
||||
admin:read: grants admin read access to everything
|
||||
admin:read:accounts: grants admin read access to accounts
|
||||
admin:read:domain_allows: grants admin read access to domain_allows
|
||||
admin:read:domain_blocks: grants admin read access to domain_blocks
|
||||
admin:read:reports: grants admin read access to reports
|
||||
admin:write: grants admin write access to everything
|
||||
admin:write:accounts: grants write read access to accounts
|
||||
admin:write:domain_allows: grants admin write access to domain_allows
|
||||
admin:write:domain_blocks: grants write read access to domain_blocks
|
||||
admin:write:reports: grants admin write access to reports
|
||||
profile: grants read access to verify_credentials
|
||||
push: grants read/write access to push
|
||||
read: grants read access to everything
|
||||
read:accounts: grants read access to accounts
|
||||
read:applications: grants read access to user-managed applications
|
||||
read:blocks: grants read access to blocks
|
||||
read:bookmarks: grants read access to bookmarks
|
||||
read:favourites: grants read access to accounts
|
||||
read:filters: grants read access to filters
|
||||
read:follows: grants read access to follows
|
||||
read:lists: grants read access to lists
|
||||
read:mutes: grants read access to mutes
|
||||
read:notifications: grants read access to notifications
|
||||
read:search: grants read access to search
|
||||
read:statuses: grants read access to statuses
|
||||
write: grants write access to everything
|
||||
write:accounts: grants write access to accounts
|
||||
write:applications: grants write access to user-managed applications
|
||||
write:blocks: grants write access to blocks
|
||||
write:bookmarks: grants write access to bookmarks
|
||||
write:conversations: grants write access to conversations
|
||||
write:favourites: grants write access to favourites
|
||||
write:filters: grants write access to filters
|
||||
write:follows: grants write access to follows
|
||||
write:lists: grants write access to lists
|
||||
write:media: grants write access to media
|
||||
write:mutes: grants write access to mutes
|
||||
write:notifications: grants write access to notifications
|
||||
write:reports: grants write access to reports
|
||||
write:statuses: grants write access to statuses
|
||||
license:
|
||||
name: AGPL3
|
||||
url: https://www.gnu.org/licenses/agpl-3.0.en.html
|
||||
|
@ -7484,6 +7537,63 @@ paths:
|
|||
tags:
|
||||
- announcements
|
||||
/api/v1/apps:
|
||||
get:
|
||||
description: |-
|
||||
The next and previous queries can be parsed from the returned Link header.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
<https://example.org/api/v1/apps?limit=80&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/apps?limit=80&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
|
||||
````
|
||||
operationId: appsGet
|
||||
parameters:
|
||||
- description: Return only items *OLDER* than the given max item ID. The item with the specified ID will not be included in the response.
|
||||
in: query
|
||||
name: max_id
|
||||
type: string
|
||||
- description: Return only items *newer* than the given since item ID. The item with the specified ID will not be included in the response.
|
||||
in: query
|
||||
name: since_id
|
||||
type: string
|
||||
- description: Return only items *immediately newer* than the given since item ID. The item with the specified ID will not be included in the response.
|
||||
in: query
|
||||
name: min_id
|
||||
type: string
|
||||
- default: 20
|
||||
description: Number of items to return.
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: ""
|
||||
headers:
|
||||
Link:
|
||||
description: Links to the next and previous queries.
|
||||
type: string
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/application'
|
||||
type: array
|
||||
"400":
|
||||
description: bad request
|
||||
"401":
|
||||
description: unauthorized
|
||||
"404":
|
||||
description: not found
|
||||
"406":
|
||||
description: not acceptable
|
||||
"500":
|
||||
description: internal server error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- read:applications
|
||||
summary: Get an array of applications that are managed by the requester.
|
||||
tags:
|
||||
- apps
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
|
@ -7493,8 +7603,10 @@ paths:
|
|||
The registered application can be used to obtain an application token.
|
||||
This can then be used to register a new account, or (through user auth) obtain an access token.
|
||||
|
||||
The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'.
|
||||
The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'.
|
||||
If the application was registered with a Bearer token passed in the Authorization header, the created application will be managed by the authenticated user (must have scope write:applications).
|
||||
|
||||
Parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'.
|
||||
Parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'.
|
||||
operationId: appCreate
|
||||
parameters:
|
||||
- description: The name of the application.
|
||||
|
@ -7548,6 +7660,69 @@ paths:
|
|||
summary: Register a new application on this instance.
|
||||
tags:
|
||||
- apps
|
||||
/api/v1/apps/{id}:
|
||||
delete:
|
||||
operationId: appDelete
|
||||
parameters:
|
||||
- description: The id of the application to delete.
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
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
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- write:applications
|
||||
summary: Delete a single application managed by the requester.
|
||||
tags:
|
||||
- apps
|
||||
get:
|
||||
operationId: appGet
|
||||
parameters:
|
||||
- description: The id of the requested application.
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: The requested application.
|
||||
schema:
|
||||
$ref: '#/definitions/application'
|
||||
"400":
|
||||
description: bad request
|
||||
"401":
|
||||
description: unauthorized
|
||||
"404":
|
||||
description: not found
|
||||
"406":
|
||||
description: not acceptable
|
||||
"500":
|
||||
description: internal server error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- read:applications
|
||||
summary: Get a single application managed by the requester.
|
||||
tags:
|
||||
- apps
|
||||
/api/v1/blocks:
|
||||
get:
|
||||
description: |-
|
||||
|
@ -11705,15 +11880,15 @@ paths:
|
|||
````
|
||||
operationId: tokensInfoGet
|
||||
parameters:
|
||||
- description: Return only items *OLDER* than the given max status ID. The item with the specified ID will not be included in the response.
|
||||
- description: Return only items *OLDER* than the given max item ID. The item with the specified ID will not be included in the response.
|
||||
in: query
|
||||
name: max_id
|
||||
type: string
|
||||
- description: Return only items *newer* than the given since status ID. The item with the specified ID will not be included in the response.
|
||||
- description: Return only items *newer* than the given since item ID. The item with the specified ID will not be included in the response.
|
||||
in: query
|
||||
name: since_id
|
||||
type: string
|
||||
- description: Return only items *immediately newer* than the given since status ID. The item with the specified ID will not be included in the response.
|
||||
- description: Return only items *immediately newer* than the given since item ID. The item with the specified ID will not be included in the response.
|
||||
in: query
|
||||
name: min_id
|
||||
type: string
|
||||
|
@ -12927,6 +13102,7 @@ securityDefinitions:
|
|||
push: grants read/write access to push
|
||||
read: grants read access to everything
|
||||
read:accounts: grants read access to accounts
|
||||
read:applications: grants read access to user-managed applications
|
||||
read:blocks: grants read access to blocks
|
||||
read:bookmarks: grants read access to bookmarks
|
||||
read:favourites: grants read access to accounts
|
||||
|
@ -12939,6 +13115,7 @@ securityDefinitions:
|
|||
read:statuses: grants read access to statuses
|
||||
write: grants write access to everything
|
||||
write:accounts: grants write access to accounts
|
||||
write:applications: grants write access to user-managed applications
|
||||
write:blocks: grants write access to blocks
|
||||
write:bookmarks: grants write access to bookmarks
|
||||
write:conversations: grants write access to conversations
|
||||
|
|
BIN
docs/overrides/public/user-settings-applications-details.png
Normal file
BIN
docs/overrides/public/user-settings-applications-details.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 245 KiB |
BIN
docs/overrides/public/user-settings-applications-new.png
Normal file
BIN
docs/overrides/public/user-settings-applications-new.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 161 KiB |
118
docs/swagger.go
118
docs/swagger.go
|
@ -17,6 +17,56 @@
|
|||
|
||||
// GoToSocial Swagger documentation.
|
||||
//
|
||||
// This document describes the GoToSocial HTTP API.
|
||||
//
|
||||
// For information on how to authenticate with the API using an OAuth access token, see the documentation here: https://docs.gotosocial.org/en/latest/api/authentication/.
|
||||
//
|
||||
// Available scopes are:
|
||||
//
|
||||
// - admin: grants admin access to everything
|
||||
// - admin:read: grants admin read access to everything
|
||||
// - admin:read:accounts: grants admin read access to accounts
|
||||
// - admin:read:domain_allows: grants admin read access to domain_allows
|
||||
// - admin:read:domain_blocks: grants admin read access to domain_blocks
|
||||
// - admin:read:reports: grants admin read access to reports
|
||||
// - admin:write: grants admin write access to everything
|
||||
// - admin:write:accounts: grants write read access to accounts
|
||||
// - admin:write:domain_allows: grants admin write access to domain_allows
|
||||
// - admin:write:domain_blocks: grants write read access to domain_blocks
|
||||
// - admin:write:reports: grants admin write access to reports
|
||||
// - profile: grants read access to verify_credentials
|
||||
// - push: grants read/write access to push
|
||||
// - read: grants read access to everything
|
||||
// - read:accounts: grants read access to accounts
|
||||
// - read:applications: grants read access to user-managed applications
|
||||
// - read:blocks: grants read access to blocks
|
||||
// - read:bookmarks: grants read access to bookmarks
|
||||
// - read:favourites: grants read access to accounts
|
||||
// - read:filters: grants read access to filters
|
||||
// - read:follows: grants read access to follows
|
||||
// - read:lists: grants read access to lists
|
||||
// - read:mutes: grants read access to mutes
|
||||
// - read:notifications: grants read access to notifications
|
||||
// - read:search: grants read access to search
|
||||
// - read:statuses: grants read access to statuses
|
||||
// - write: grants write access to everything
|
||||
// - write:accounts: grants write access to accounts
|
||||
// - write:applications: grants write access to user-managed applications
|
||||
// - write:blocks: grants write access to blocks
|
||||
// - write:bookmarks: grants write access to bookmarks
|
||||
// - write:conversations: grants write access to conversations
|
||||
// - write:favourites: grants write access to favourites
|
||||
// - write:filters: grants write access to filters
|
||||
// - write:follows: grants write access to follows
|
||||
// - write:lists: grants write access to lists
|
||||
// - write:media: grants write access to media
|
||||
// - write:mutes: grants write access to mutes
|
||||
// - write:notifications: grants write access to notifications
|
||||
// - write:reports: grants write access to reports
|
||||
// - write:statuses: grants write access to statuses
|
||||
//
|
||||
// ---
|
||||
//
|
||||
// Schemes: https, http
|
||||
// BasePath: /
|
||||
// Version: REPLACE_ME
|
||||
|
@ -31,45 +81,47 @@
|
|||
// authorizationUrl: https://example.org/oauth/authorize
|
||||
// tokenUrl: https://example.org/oauth/token
|
||||
// scopes:
|
||||
// read: grants read access to everything
|
||||
// write: grants write access to everything
|
||||
// push: grants read/write access to push
|
||||
// profile: grants read access to verify_credentials
|
||||
// read:accounts: grants read access to accounts
|
||||
// write:accounts: grants write access to accounts
|
||||
// read:blocks: grants read access to blocks
|
||||
// write:blocks: grants write access to blocks
|
||||
// read:bookmarks: grants read access to bookmarks
|
||||
// write:bookmarks: grants write access to bookmarks
|
||||
// write:conversations: grants write access to conversations
|
||||
// read:favourites: grants read access to accounts
|
||||
// write:favourites: grants write access to favourites
|
||||
// read:filters: grants read access to filters
|
||||
// write:filters: grants write access to filters
|
||||
// read:follows: grants read access to follows
|
||||
// write:follows: grants write access to follows
|
||||
// read:lists: grants read access to lists
|
||||
// write:lists: grants write access to lists
|
||||
// write:media: grants write access to media
|
||||
// read:mutes: grants read access to mutes
|
||||
// write:mutes: grants write access to mutes
|
||||
// read:notifications: grants read access to notifications
|
||||
// write:notifications: grants write access to notifications
|
||||
// write:reports: grants write access to reports
|
||||
// read:search: grants read access to search
|
||||
// read:statuses: grants read access to statuses
|
||||
// write:statuses: grants write access to statuses
|
||||
// admin: grants admin access to everything
|
||||
// admin:read: grants admin read access to everything
|
||||
// admin:write: grants admin write access to everything
|
||||
// admin:read:accounts: grants admin read access to accounts
|
||||
// admin:write:accounts: grants write read access to accounts
|
||||
// admin:read:reports: grants admin read access to reports
|
||||
// admin:write:reports: grants admin write access to reports
|
||||
// admin:read:domain_allows: grants admin read access to domain_allows
|
||||
// admin:write:domain_allows: grants admin write access to domain_allows
|
||||
// admin:read:domain_blocks: grants admin read access to domain_blocks
|
||||
// admin:read:reports: grants admin read access to reports
|
||||
// admin:write: grants admin write access to everything
|
||||
// admin:write:accounts: grants write read access to accounts
|
||||
// admin:write:domain_allows: grants admin write access to domain_allows
|
||||
// admin:write:domain_blocks: grants write read access to domain_blocks
|
||||
// admin:write:reports: grants admin write access to reports
|
||||
// profile: grants read access to verify_credentials
|
||||
// push: grants read/write access to push
|
||||
// read: grants read access to everything
|
||||
// read:accounts: grants read access to accounts
|
||||
// read:applications: grants read access to user-managed applications
|
||||
// read:blocks: grants read access to blocks
|
||||
// read:bookmarks: grants read access to bookmarks
|
||||
// read:favourites: grants read access to accounts
|
||||
// read:filters: grants read access to filters
|
||||
// read:follows: grants read access to follows
|
||||
// read:lists: grants read access to lists
|
||||
// read:mutes: grants read access to mutes
|
||||
// read:notifications: grants read access to notifications
|
||||
// read:search: grants read access to search
|
||||
// read:statuses: grants read access to statuses
|
||||
// write: grants write access to everything
|
||||
// write:accounts: grants write access to accounts
|
||||
// write:applications: grants write access to user-managed applications
|
||||
// write:blocks: grants write access to blocks
|
||||
// write:bookmarks: grants write access to bookmarks
|
||||
// write:conversations: grants write access to conversations
|
||||
// write:favourites: grants write access to favourites
|
||||
// write:filters: grants write access to filters
|
||||
// write:follows: grants write access to follows
|
||||
// write:lists: grants write access to lists
|
||||
// write:media: grants write access to media
|
||||
// write:mutes: grants write access to mutes
|
||||
// write:notifications: grants write access to notifications
|
||||
// write:reports: grants write access to reports
|
||||
// write:statuses: grants write access to statuses
|
||||
// OAuth2 Application:
|
||||
// type: oauth2
|
||||
// flow: application
|
||||
|
|
|
@ -287,3 +287,70 @@ Logging out of an application does not necessarily remove the token from the GoT
|
|||
|
||||
!!! note
|
||||
Token "Last used" time is approximate and may be off by an hour in either direction.
|
||||
|
||||
## Applications
|
||||
|
||||
In the applications section, you can create a new managed OAuth client application, and search through applications that you've created.
|
||||
|
||||
### What is an OAuth client application?
|
||||
|
||||
A GoToSocial OAuth client application is equivalent to an OAuth 2.0 client as described in [the Auth0 roles docs](https://auth0.com/intro-to-iam/what-is-oauth-2#oauth20-roles).
|
||||
|
||||
When you create an application, you can then, as the "Resource Owner" of your account, give the application access to your account via an access token, which the application can use to interact with the GoToSocial client API "as you", or "on your behalf".
|
||||
|
||||
For example, when you log in to your GoToSocial account using Tusky, Tusky first registers itself with your instance as an OAuth client application, and then requests the instance to redirect you to a page where you can sign in with your GoToSocial email address and password in order to authorize Tusky to get an access code. Tusky then stores and uses that access code in all further requests, to do what you tell it to do: post statuses, read timelines, etc.
|
||||
|
||||
The advantage of OAuth client applications is that they never store (or even see) your password: they only ever act as you using their access token, which can be invalidated so that the application cannot access your account anymore, without you having to change your password (see [Access Tokens](#access-tokens)).
|
||||
|
||||
!!! note "Managed vs unmanaged applications"
|
||||
A *managed* application is one that you created yourself via the settings panel, and have the ability to request tokens for, and delete. This differs somewhat from applications like Tusky, which are not considered to be *managed* applications, as they were not created by a user in the settings panel.
|
||||
|
||||
### What is a redirect URI?
|
||||
|
||||
Redirect URIs offer a security measure that prevents applications from being to redirect to malicious addresses after authorization. This is best explained in the OAuth 2.0 documentation, see:
|
||||
|
||||
- [Redirect URIs](https://www.oauth.com/oauth2-servers/redirect-uris/)
|
||||
- [Redirect URL Registration](https://www.oauth.com/oauth2-servers/redirect-uris/redirect-uri-registration/)
|
||||
|
||||
Whatever service you are trying to create an application for will usually tell you what redirect URI(s) you need to enter when creating an application.
|
||||
|
||||
### What is a scope?
|
||||
|
||||
Scopes are a space-separated list of identifiers that can be specified when creating an application (or getting a token for that application) in order to prevent the application or its access token from accessing more data than it needs to do its job.
|
||||
|
||||
For example, if you create an application with only scope `read`, then any tokens owned by that application will have only `read` access to your account: they will not be able to post, delete, or take other *write*-type actions as you.
|
||||
|
||||
GoToSocial offers a range of scopes very similar to what the Mastodon API offers. You can see a list of scopes (and what they do) here: https://docs.gotosocial.org/en/latest/api/swagger/.
|
||||
|
||||
Like Mastodon, GoToSocial allows you to specify scopes both when you create an application, *and* when you subsequently request a token. So you could create an application with scope `read write`, but request a token with only `read` scope, or with an even narrower scope like `read:accounts`. Any scopes specified when requesting a token must be covered by the scopes permitted to the application. For example, you cannot request a token with scope `write` if your application only has scope `read`.
|
||||
|
||||
For more information on scopes in general, see the OAuth 2.0 docs:
|
||||
|
||||
- [Scope](https://www.oauth.com/oauth2-servers/scope/)
|
||||
- [Defining Scopes](https://www.oauth.com/oauth2-servers/scope/defining-scopes/)
|
||||
|
||||
### Search Applications
|
||||
|
||||
Using this section, you can see an overview of applications that you've created via the settings panel, and click on an application to go to the details view for that application.
|
||||
|
||||
### New Application
|
||||
|
||||

|
||||
|
||||
To create a new managed OAuth application, you must provide at least a name for your application. If you want, you can provide a website too.
|
||||
|
||||
If you don't provide any scopes, then the application will have scope `read` by default.
|
||||
|
||||
If you don't specify any redirect URIs, then the application will have the out-of-band redirect URI `urn:ietf:wg:oauth:2.0:oob` by default.
|
||||
|
||||
If you want to be able to use the settings panel to easily get an access token for the application that you create, then you must include the given settings panel callback URL in your redirect URIs list. This will be in the form `https://[your_instance_host]/settings/user/applications/callback`.
|
||||
|
||||
### Application Details
|
||||
|
||||

|
||||
|
||||
On the details page for an application, you can view the application's client ID and client secret, which you can use in a command-line tool like `curl` to manually get an authorization code + access token for the application.
|
||||
|
||||
If you included the settings panel callback URL in your redirect URIs list, you can also use this page to request an access token for your account. This will redirect you to the sign in page for your instance, where you must provide your credentials in order to authorize your application. You will then be redirected again to the settings panel callback URL, where you can receive your access token.
|
||||
|
||||
You can also use this page to delete your application. When a managed application is deleted, all tokens that were created via that application will also be deleted, so ensure you only do this when your application is not being used.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
98
internal/api/client/apps/appdelete.go
Normal file
98
internal/api/client/apps/appdelete.go
Normal file
|
@ -0,0 +1,98 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
)
|
||||
|
||||
// AppDELETEHandler swagger:operation DELETE /api/v1/apps/{id} appDelete
|
||||
//
|
||||
// Delete a single application managed by the requester.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - apps
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: id
|
||||
// type: string
|
||||
// description: The id of the application to delete.
|
||||
// in: path
|
||||
// required: true
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - write:applications
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: The deleted application.
|
||||
// schema:
|
||||
// "$ref": "#/definitions/application"
|
||||
// '400':
|
||||
// description: bad request
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '404':
|
||||
// description: not found
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '500':
|
||||
// description: internal server error
|
||||
func (m *Module) AppDELETEHandler(c *gin.Context) {
|
||||
authed, errWithCode := apiutil.TokenAuth(c,
|
||||
true, true, true, true,
|
||||
apiutil.ScopeWriteApplications,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
appID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
app, errWithCode := m.processor.Application().Delete(
|
||||
c.Request.Context(),
|
||||
authed.User.ID,
|
||||
appID,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.JSON(c, http.StatusOK, app)
|
||||
}
|
98
internal/api/client/apps/appget.go
Normal file
98
internal/api/client/apps/appget.go
Normal file
|
@ -0,0 +1,98 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
)
|
||||
|
||||
// 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,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)
|
||||
}
|
||||
|
|
146
internal/api/client/apps/appsget.go
Normal file
146
internal/api/client/apps/appsget.go
Normal file
|
@ -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,11 +31,14 @@ 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
|
||||
|
||||
// DeleteApplicationByClientID deletes the application with corresponding client_id value from the database.
|
||||
DeleteApplicationByClientID(ctx context.Context, clientID string) error
|
||||
// DeleteApplicationByID deletes the application with corresponding id from the database.
|
||||
DeleteApplicationByID(ctx context.Context, id string) error
|
||||
|
||||
// GetAllTokens fetches all client oauth tokens from database.
|
||||
GetAllTokens(ctx context.Context) ([]*gtsmodel.Token, error)
|
||||
|
@ -72,4 +75,8 @@ type Application interface {
|
|||
|
||||
// DeleteTokenByRefresh deletes client oauth token from database with refresh code.
|
||||
DeleteTokenByRefresh(ctx context.Context, refresh string) error
|
||||
|
||||
// DeleteTokensByClientID deletes all tokens
|
||||
// with the given clientID from the database.
|
||||
DeleteTokensByClientID(ctx context.Context, clientID string) error
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
@ -76,27 +174,25 @@ func (a *applicationDB) PutApplication(ctx context.Context, app *gtsmodel.Applic
|
|||
})
|
||||
}
|
||||
|
||||
func (a *applicationDB) DeleteApplicationByClientID(ctx context.Context, clientID string) error {
|
||||
// Attempt to delete application.
|
||||
if _, err := a.db.NewDelete().
|
||||
// DeleteApplicationByID deletes application with the given ID.
|
||||
//
|
||||
// The function does not delete tokens owned by the application
|
||||
// or update statuses/accounts that used the application, since
|
||||
// the latter can be extremely expensive given the size of the
|
||||
// statuses table.
|
||||
//
|
||||
// Callers to this function should ensure that they do side
|
||||
// effects themselves (if required) before or after calling.
|
||||
func (a *applicationDB) DeleteApplicationByID(ctx context.Context, id string) error {
|
||||
_, err := a.db.NewDelete().
|
||||
Table("applications").
|
||||
Where("? = ?", bun.Ident("client_id"), clientID).
|
||||
Exec(ctx); err != nil {
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// NOTE about further side effects:
|
||||
//
|
||||
// We don't need to handle updating any statuses or users
|
||||
// (both of which may contain refs to applications), as
|
||||
// DeleteApplication__() is only ever called during an
|
||||
// account deletion, which handles deletion of the user
|
||||
// and all their statuses already.
|
||||
//
|
||||
|
||||
// Clear application from the cache.
|
||||
a.state.Caches.DB.Application.Invalidate("ClientID", clientID)
|
||||
|
||||
a.state.Caches.DB.Application.Invalidate("ID", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -363,3 +459,27 @@ func (a *applicationDB) DeleteTokenByRefresh(ctx context.Context, refresh string
|
|||
a.state.Caches.DB.Token.Invalidate("Refresh", refresh)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *applicationDB) DeleteTokensByClientID(ctx context.Context, clientID string) error {
|
||||
// Delete tokens owned by
|
||||
// clientID and gather token IDs.
|
||||
var tokenIDs []string
|
||||
if _, err := a.db.
|
||||
NewDelete().
|
||||
Table("tokens").
|
||||
Where("? = ?", bun.Ident("client_id"), clientID).
|
||||
Returning("id").
|
||||
Exec(ctx, &tokenIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(tokenIDs) == 0 {
|
||||
// Nothing was deleted,
|
||||
// nothing to invalidate.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Invalidate all deleted tokens.
|
||||
a.state.Caches.DB.Token.InvalidateIDs("ID", tokenIDs)
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -92,7 +92,7 @@ func (suite *ApplicationTestSuite) TestDeleteApplicationBy() {
|
|||
for _, app := range suite.testApplications {
|
||||
for lookup, dbfunc := range map[string]func() error{
|
||||
"client_id": func() error {
|
||||
return suite.db.DeleteApplicationByClientID(ctx, app.ClientID)
|
||||
return suite.db.DeleteApplicationByID(ctx, app.ID)
|
||||
},
|
||||
} {
|
||||
// Clear database caches.
|
||||
|
@ -124,6 +124,36 @@ func (suite *ApplicationTestSuite) TestGetAllTokens() {
|
|||
suite.NotEmpty(tokens)
|
||||
}
|
||||
|
||||
func (suite *ApplicationTestSuite) TestDeleteTokensByClientID() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Delete tokens by each app.
|
||||
for _, app := range suite.testApplications {
|
||||
if err := suite.state.DB.DeleteTokensByClientID(ctx, app.ClientID); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all tokens deleted.
|
||||
for _, token := range suite.testTokens {
|
||||
_, err := suite.db.GetTokenByID(ctx, token.ID)
|
||||
if !errors.Is(err, db.ErrNoEntries) {
|
||||
suite.FailNow("", "token %s not deleted", token.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *ApplicationTestSuite) TestDeleteTokensByUnknownClientID() {
|
||||
// Should not return ErrNoRows even though
|
||||
// the client with given ID doesn't exist.
|
||||
if err := suite.state.DB.DeleteTokensByClientID(
|
||||
context.Background(),
|
||||
"01JPJ4NCGH6GHY7ZVYBHNP55XS",
|
||||
); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(ApplicationTestSuite))
|
||||
}
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
// 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 {
|
||||
// Add client_id index to token table,
|
||||
// needed for invalidation if/when the
|
||||
// token's app is deleted.
|
||||
if _, err := tx.
|
||||
NewCreateIndex().
|
||||
Table("tokens").
|
||||
Index("tokens_client_id_idx").
|
||||
Column("client_id").
|
||||
IfNotExists().
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update users to set all "created_by_application_id"
|
||||
// values to the instance application, to correct some
|
||||
// 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)
|
||||
}
|
||||
}
|
|
@ -299,11 +299,12 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status)
|
|||
|
||||
if status.CreatedWithApplicationID != "" && status.CreatedWithApplication == nil {
|
||||
// Populate the status' expected CreatedWithApplication (not always set).
|
||||
// Don't error on ErrNoEntries, as the application may have been cleaned up.
|
||||
status.CreatedWithApplication, err = s.state.DB.GetApplicationByID(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
status.CreatedWithApplicationID,
|
||||
)
|
||||
if err != nil {
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
errs.Appendf("error populating status application: %w", err)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ type Token struct {
|
|||
ClientID string `bun:"type:CHAR(26),nullzero,notnull"` // ID of the client who owns this token
|
||||
UserID string `bun:"type:CHAR(26),nullzero"` // ID of the user who owns this token
|
||||
RedirectURI string `bun:",nullzero,notnull"` // Oauth redirect URI for this token
|
||||
Scope string `bun:",nullzero,notnull,default:'read'"` // Oauth scope // Oauth scope
|
||||
Scope string `bun:",nullzero,notnull,default:'read'"` // Oauth scope
|
||||
Code string `bun:",pk,nullzero,notnull,default:''"` // Code, if present
|
||||
CodeChallenge string `bun:",nullzero"` // Code challenge, if code present
|
||||
CodeChallengeMethod string `bun:",nullzero"` // Code challenge method, if code present
|
||||
|
|
|
@ -107,19 +107,33 @@ func (p *Processor) deleteUserAndTokensForAccount(ctx context.Context, account *
|
|||
return gtserror.Newf("db error getting user: %w", err)
|
||||
}
|
||||
|
||||
tokens := []*gtsmodel.Token{}
|
||||
if err := p.state.DB.GetWhere(ctx, []db.Where{{Key: "user_id", Value: user.ID}}, &tokens); err != nil {
|
||||
// Get all applications owned by user.
|
||||
apps, err := p.state.DB.GetApplicationsManagedByUserID(ctx, user.ID, nil)
|
||||
if err != nil {
|
||||
return gtserror.Newf("db error getting apps: %w", err)
|
||||
}
|
||||
|
||||
// Delete each app and any tokens it had created
|
||||
// (not necessarily owned by deleted account).
|
||||
for _, a := range apps {
|
||||
if err := p.state.DB.DeleteApplicationByID(ctx, a.ID); err != nil {
|
||||
return gtserror.Newf("db error deleting app: %w", err)
|
||||
}
|
||||
|
||||
if err := p.state.DB.DeleteTokensByClientID(ctx, a.ClientID); err != nil {
|
||||
return gtserror.Newf("db error deleting tokens for app: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get any remaining access tokens owned by user.
|
||||
tokens, err := p.state.DB.GetAccessTokens(ctx, user.ID, nil)
|
||||
if err != nil {
|
||||
return gtserror.Newf("db error getting tokens: %w", err)
|
||||
}
|
||||
|
||||
// Delete each token.
|
||||
for _, t := range tokens {
|
||||
// Delete any OAuth applications associated with this token.
|
||||
if err := p.state.DB.DeleteApplicationByClientID(ctx, t.ClientID); err != nil {
|
||||
return gtserror.Newf("db error deleting application: %w", err)
|
||||
}
|
||||
|
||||
// Delete the token itself.
|
||||
if err := p.state.DB.DeleteByID(ctx, t.ID, t); err != nil {
|
||||
if err := p.state.DB.DeleteTokenByID(ctx, t.ID); err != nil {
|
||||
return gtserror.Newf("db error deleting token: %w", err)
|
||||
}
|
||||
}
|
||||
|
|
38
internal/processing/application/application.go
Normal file
38
internal/processing/application/application.go
Normal file
|
@ -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)
|
70
internal/processing/application/delete.go
Normal file
70
internal/processing/application/delete.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
)
|
||||
|
||||
func (p *Processor) Delete(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
appID string,
|
||||
) (*apimodel.Application, gtserror.WithCode) {
|
||||
app, err := p.state.DB.GetApplicationByID(ctx, appID)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err := gtserror.Newf("db error getting app %s: %w", appID, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if app == nil {
|
||||
err := gtserror.Newf("app %s not found in the db", appID)
|
||||
return nil, gtserror.NewErrorNotFound(err)
|
||||
}
|
||||
|
||||
if app.ManagedByUserID != userID {
|
||||
err := gtserror.Newf("app %s not managed by user %s", appID, userID)
|
||||
return nil, gtserror.NewErrorNotFound(err)
|
||||
}
|
||||
|
||||
// Convert app before deletion.
|
||||
apiApp, err := p.converter.AppToAPIAppSensitive(ctx, app)
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error converting app to api app: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// Delete app itself.
|
||||
if err := p.state.DB.DeleteApplicationByID(ctx, appID); err != nil {
|
||||
err := gtserror.Newf("db error deleting app %s: %w", appID, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// Delete all tokens owned by app.
|
||||
if err := p.state.DB.DeleteTokensByClientID(ctx, app.ClientID); err != nil {
|
||||
err := gtserror.Newf("db error deleting tokens for app %s: %w", appID, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return apiApp, nil
|
||||
}
|
104
internal/processing/application/get.go
Normal file
104
internal/processing/application/get.go
Normal file
|
@ -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"),
|
||||
|
@ -1412,14 +1418,28 @@ func (c *Converter) baseStatusToFrontend(
|
|||
apiStatus.InReplyToAccountID = util.PtrIf(s.InReplyToAccountID)
|
||||
apiStatus.Language = util.PtrIf(s.Language)
|
||||
|
||||
if app := s.CreatedWithApplication; app != nil {
|
||||
apiStatus.Application, err = c.AppToAPIAppPublic(ctx, app)
|
||||
switch {
|
||||
case s.CreatedWithApplication != nil:
|
||||
// App exists for this status and is set.
|
||||
apiStatus.Application, err = c.AppToAPIAppPublic(ctx, s.CreatedWithApplication)
|
||||
if err != nil {
|
||||
return nil, gtserror.Newf(
|
||||
"error converting application %s: %w",
|
||||
s.CreatedWithApplicationID, err,
|
||||
)
|
||||
}
|
||||
|
||||
case s.CreatedWithApplicationID != "":
|
||||
// App existed for this status but not
|
||||
// anymore, it's probably been cleaned up.
|
||||
// Set a dummy application.
|
||||
apiStatus.Application = &apimodel.Application{
|
||||
Name: "unknown application",
|
||||
}
|
||||
|
||||
default:
|
||||
// No app stored for this (probably remote)
|
||||
// status, so nothing to do (app is optional).
|
||||
}
|
||||
|
||||
if s.Poll != nil {
|
||||
|
|
|
@ -753,6 +753,156 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendHTMLContentWarning
|
|||
}`, string(b))
|
||||
}
|
||||
|
||||
func (suite *InternalToFrontendTestSuite) TestStatusToFrontendApplicationDeleted() {
|
||||
ctx := context.Background()
|
||||
testStatus := suite.testStatuses["admin_account_status_1"]
|
||||
|
||||
// Delete the application this status was created with.
|
||||
if err := suite.state.DB.DeleteApplicationByID(ctx, testStatus.CreatedWithApplicationID); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
requestingAccount := suite.testAccounts["local_account_1"]
|
||||
apiStatus, err := suite.typeconverter.StatusToAPIStatus(ctx, testStatus, requestingAccount, statusfilter.FilterContextNone, nil, nil)
|
||||
suite.NoError(err)
|
||||
|
||||
b, err := json.MarshalIndent(apiStatus, "", " ")
|
||||
suite.NoError(err)
|
||||
|
||||
suite.Equal(`{
|
||||
"id": "01F8MH75CBF9JFX4ZAD54N0W0R",
|
||||
"created_at": "2021-10-20T11:36:45.000Z",
|
||||
"edited_at": null,
|
||||
"in_reply_to_id": null,
|
||||
"in_reply_to_account_id": null,
|
||||
"sensitive": false,
|
||||
"spoiler_text": "",
|
||||
"visibility": "public",
|
||||
"language": "en",
|
||||
"uri": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
|
||||
"url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
|
||||
"replies_count": 1,
|
||||
"reblogs_count": 0,
|
||||
"favourites_count": 1,
|
||||
"favourited": true,
|
||||
"reblogged": false,
|
||||
"muted": false,
|
||||
"bookmarked": true,
|
||||
"pinned": false,
|
||||
"content": "\u003cp\u003ehello world! \u003ca href=\"http://localhost:8080/tags/welcome\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\"\u003e#\u003cspan\u003ewelcome\u003c/span\u003e\u003c/a\u003e ! first post on the instance :rainbow: !\u003c/p\u003e",
|
||||
"reblog": null,
|
||||
"application": {
|
||||
"name": "unknown application"
|
||||
},
|
||||
"account": {
|
||||
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
|
||||
"username": "admin",
|
||||
"acct": "admin",
|
||||
"display_name": "",
|
||||
"locked": false,
|
||||
"discoverable": true,
|
||||
"bot": false,
|
||||
"created_at": "2022-05-17T13:10:59.000Z",
|
||||
"note": "",
|
||||
"url": "http://localhost:8080/@admin",
|
||||
"avatar": "",
|
||||
"avatar_static": "",
|
||||
"header": "http://localhost:8080/assets/default_header.webp",
|
||||
"header_static": "http://localhost:8080/assets/default_header.webp",
|
||||
"header_description": "Flat gray background (default header).",
|
||||
"followers_count": 1,
|
||||
"following_count": 1,
|
||||
"statuses_count": 4,
|
||||
"last_status_at": "2021-10-20",
|
||||
"emojis": [],
|
||||
"fields": [],
|
||||
"enable_rss": true,
|
||||
"roles": [
|
||||
{
|
||||
"id": "admin",
|
||||
"name": "admin",
|
||||
"color": ""
|
||||
}
|
||||
],
|
||||
"group": false
|
||||
},
|
||||
"media_attachments": [
|
||||
{
|
||||
"id": "01F8MH6NEM8D7527KZAECTCR76",
|
||||
"type": "image",
|
||||
"url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg",
|
||||
"text_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg",
|
||||
"preview_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.webp",
|
||||
"remote_url": null,
|
||||
"preview_remote_url": null,
|
||||
"meta": {
|
||||
"original": {
|
||||
"width": 1200,
|
||||
"height": 630,
|
||||
"size": "1200x630",
|
||||
"aspect": 1.9047619
|
||||
},
|
||||
"small": {
|
||||
"width": 512,
|
||||
"height": 268,
|
||||
"size": "512x268",
|
||||
"aspect": 1.9104477
|
||||
},
|
||||
"focus": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
"description": "Black and white image of some 50's style text saying: Welcome On Board",
|
||||
"blurhash": "LIIE|gRj00WB-;j[t7j[4nWBj[Rj"
|
||||
}
|
||||
],
|
||||
"mentions": [],
|
||||
"tags": [
|
||||
{
|
||||
"name": "welcome",
|
||||
"url": "http://localhost:8080/tags/welcome"
|
||||
}
|
||||
],
|
||||
"emojis": [
|
||||
{
|
||||
"shortcode": "rainbow",
|
||||
"url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
|
||||
"static_url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
|
||||
"visible_in_picker": true,
|
||||
"category": "reactions"
|
||||
}
|
||||
],
|
||||
"card": null,
|
||||
"poll": null,
|
||||
"text": "hello world! #welcome ! first post on the instance :rainbow: !",
|
||||
"content_type": "text/plain",
|
||||
"interaction_policy": {
|
||||
"can_favourite": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reply": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reblog": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
}
|
||||
}
|
||||
}`, string(b))
|
||||
}
|
||||
|
||||
// Modify a fixture status into a status that should be filtered,
|
||||
// and then filter it, returning the API status or any error from converting it.
|
||||
func (suite *InternalToFrontendTestSuite) filteredStatusToFrontend(action gtsmodel.FilterAction, boost bool) (*apimodel.Status, error) {
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useLogoutMutation, useVerifyCredentialsQuery } from "../../lib/query/oauth";
|
||||
import { useLogoutMutation, useVerifyCredentialsQuery } from "../../lib/query/login";
|
||||
import { store } from "../../redux/store";
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
|
@ -27,8 +27,8 @@ import { Error } from "../error";
|
|||
import { NoArg } from "../../lib/types/query";
|
||||
|
||||
export function Authorization({ App }) {
|
||||
const { loginState, expectingRedirect } = store.getState().oauth;
|
||||
const skip = (loginState == "none" || loginState == "logout" || expectingRedirect);
|
||||
const { current: loginState, expectingRedirect } = store.getState().login;
|
||||
const skip = (loginState == "none" || loginState == "loggedout" || expectingRedirect);
|
||||
const [ logoutQuery ] = useLogoutMutation();
|
||||
|
||||
const {
|
||||
|
@ -46,9 +46,9 @@ export function Authorization({ App }) {
|
|||
showLogin = false;
|
||||
|
||||
let loadingInfo = "";
|
||||
if (loginState == "callback") {
|
||||
if (loginState == "awaitingcallback") {
|
||||
loadingInfo = "Processing OAUTH callback.";
|
||||
} else if (loginState == "login") {
|
||||
} else if (loginState == "loggedin") {
|
||||
loadingInfo = "Verifying stored login.";
|
||||
}
|
||||
|
||||
|
@ -70,7 +70,7 @@ export function Authorization({ App }) {
|
|||
);
|
||||
}
|
||||
|
||||
if (loginState == "login" && isSuccess) {
|
||||
if (loginState == "loggedin" && isSuccess) {
|
||||
return <App account={account} />;
|
||||
} else {
|
||||
return (
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
import React from "react";
|
||||
|
||||
import { useAuthorizeFlowMutation } from "../../lib/query/oauth";
|
||||
import { useAuthorizeFlowMutation } from "../../lib/query/login";
|
||||
import { useTextInput, useValue } from "../../lib/form";
|
||||
import useFormSubmit from "../../lib/form/submit";
|
||||
import MutationButton from "../form/mutation-button";
|
||||
|
|
44
web/source/settings/components/highlightedcode.tsx
Normal file
44
web/source/settings/components/highlightedcode.tsx
Normal file
|
@ -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, { useEffect, useRef } from "react";
|
||||
|
||||
// Used for syntax highlighting of json result.
|
||||
import Prism from "../../frontend/prism";
|
||||
|
||||
export function HighlightedCode({ code, lang }: { code: string, lang: string }) {
|
||||
const ref = useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
Prism.highlightElement(ref.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Prism takes control of the `pre` so wrap
|
||||
// the whole thing in a div that we control.
|
||||
return (
|
||||
<div className="prism-highlighted">
|
||||
<pre>
|
||||
<code ref={ref} className={`language-${lang}`}>
|
||||
{code}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -18,7 +18,7 @@
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useVerifyCredentialsQuery } from "../lib/query/oauth";
|
||||
import { useVerifyCredentialsQuery } from "../lib/query/login";
|
||||
import { MediaAttachment, Status as StatusType } from "../lib/types/status";
|
||||
import sanitize from "sanitize-html";
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
import React from "react";
|
||||
import Loading from "./loading";
|
||||
import { Error as ErrorC } from "./error";
|
||||
import { useVerifyCredentialsQuery, useLogoutMutation } from "../lib/query/oauth";
|
||||
import { useVerifyCredentialsQuery, useLogoutMutation } from "../lib/query/login";
|
||||
import { useInstanceV1Query } from "../lib/query/gts-api";
|
||||
|
||||
export default function UserLogoutCard() {
|
||||
|
|
|
@ -66,7 +66,7 @@ export function App({ account }: AppProps) {
|
|||
Ensure user ends up somewhere
|
||||
if they just open /settings.
|
||||
*/}
|
||||
<Route path="/"><Redirect to="/user" /></Route>
|
||||
<Route path="/"><Redirect to="/user/profile" /></Route>
|
||||
</ErrorBoundary>
|
||||
</Router>
|
||||
</section>
|
||||
|
|
|
@ -141,7 +141,7 @@ const extended = gtsApi.injectEndpoints({
|
|||
searchItemForEmoji: build.mutation<EmojisFromItem, string>({
|
||||
async queryFn(url, api, _extraOpts, fetchWithBQ) {
|
||||
const state = api.getState() as RootState;
|
||||
const oauthState = state.oauth;
|
||||
const loginState = state.login;
|
||||
|
||||
// First search for given url.
|
||||
const searchRes = await fetchWithBQ({
|
||||
|
@ -161,8 +161,8 @@ const extended = gtsApi.injectEndpoints({
|
|||
|
||||
// Ensure emojis domain is not OUR domain. If it
|
||||
// is, we already have the emojis by definition.
|
||||
if (oauthState.instanceUrl !== undefined) {
|
||||
if (domain == new URL(oauthState.instanceUrl).host) {
|
||||
if (loginState.instanceUrl !== undefined) {
|
||||
if (domain == new URL(loginState.instanceUrl).host) {
|
||||
throw "LOCAL_INSTANCE";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -116,7 +116,7 @@ const extended = gtsApi.injectEndpoints({
|
|||
// Parse filename to something like:
|
||||
// `example.org-blocklist-2023-10-09.json`.
|
||||
const state = api.getState() as RootState;
|
||||
const instanceUrl = state.oauth.instanceUrl?? "unknown";
|
||||
const instanceUrl = state.login.instanceUrl?? "unknown";
|
||||
const domain = new URL(instanceUrl).host;
|
||||
const date = new Date();
|
||||
const filename = [
|
||||
|
|
|
@ -77,7 +77,7 @@ const gtsBaseQuery: BaseQueryFn<
|
|||
// Retrieve state at the moment
|
||||
// this function was called.
|
||||
const state = api.getState() as RootState;
|
||||
const { instanceUrl, token } = state.oauth;
|
||||
const { instanceUrl, token } = state.login;
|
||||
|
||||
// Derive baseUrl dynamically.
|
||||
let baseUrl: string | undefined;
|
||||
|
@ -160,6 +160,7 @@ export const gtsApi = createApi({
|
|||
reducerPath: "api",
|
||||
baseQuery: gtsBaseQuery,
|
||||
tagTypes: [
|
||||
"Application",
|
||||
"Auth",
|
||||
"Emoji",
|
||||
"Report",
|
||||
|
|
|
@ -24,17 +24,10 @@ import {
|
|||
setToken as oauthSetToken,
|
||||
remove as oauthRemove,
|
||||
authorize as oauthAuthorize,
|
||||
} from "../../../redux/oauth";
|
||||
} from "../../../redux/login";
|
||||
import { RootState } from '../../../redux/store';
|
||||
import { Account } from '../../types/account';
|
||||
|
||||
export interface OauthTokenRequestBody {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
redirect_uri: string;
|
||||
grant_type: string;
|
||||
code: string;
|
||||
}
|
||||
import { OAuthAccessTokenRequestBody } from '../../types/oauth';
|
||||
|
||||
function getSettingsURL() {
|
||||
/*
|
||||
|
@ -45,7 +38,7 @@ function getSettingsURL() {
|
|||
Also drops anything past /settings/, because authorization urls that are too long
|
||||
get rejected by GTS.
|
||||
*/
|
||||
let [pre, _past] = window.location.pathname.split("/settings");
|
||||
const [pre, _past] = window.location.pathname.split("/settings");
|
||||
return `${window.location.origin}${pre}/settings`;
|
||||
}
|
||||
|
||||
|
@ -64,12 +57,12 @@ const extended = gtsApi.injectEndpoints({
|
|||
error == undefined ? ["Auth"] : [],
|
||||
async queryFn(_arg, api, _extraOpts, fetchWithBQ) {
|
||||
const state = api.getState() as RootState;
|
||||
const oauthState = state.oauth;
|
||||
const loginState = state.login;
|
||||
|
||||
// If we're not in the middle of an auth/callback,
|
||||
// we may already have an auth token, so just
|
||||
// return a standard verify_credentials query.
|
||||
if (oauthState.loginState != 'callback') {
|
||||
if (loginState.current != 'awaitingcallback') {
|
||||
return fetchWithBQ({
|
||||
url: `/api/v1/accounts/verify_credentials`
|
||||
});
|
||||
|
@ -77,8 +70,8 @@ const extended = gtsApi.injectEndpoints({
|
|||
|
||||
// We're in the middle of an auth/callback flow.
|
||||
// Try to retrieve callback code from URL query.
|
||||
let urlParams = new URLSearchParams(window.location.search);
|
||||
let code = urlParams.get("code");
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get("code");
|
||||
if (code == undefined) {
|
||||
return {
|
||||
error: {
|
||||
|
@ -91,7 +84,7 @@ const extended = gtsApi.injectEndpoints({
|
|||
|
||||
// Retrieve app with which the
|
||||
// callback code was generated.
|
||||
let app = oauthState.app;
|
||||
const app = loginState.app;
|
||||
if (app == undefined || app.client_id == undefined) {
|
||||
return {
|
||||
error: {
|
||||
|
@ -104,7 +97,7 @@ const extended = gtsApi.injectEndpoints({
|
|||
|
||||
// Use the provided code and app
|
||||
// secret to request an auth token.
|
||||
const tokenReqBody: OauthTokenRequestBody = {
|
||||
const tokenReqBody: OAuthAccessTokenRequestBody = {
|
||||
client_id: app.client_id,
|
||||
client_secret: app.client_secret,
|
||||
redirect_uri: SETTINGS_URL,
|
||||
|
@ -139,7 +132,7 @@ const extended = gtsApi.injectEndpoints({
|
|||
authorizeFlow: build.mutation({
|
||||
async queryFn(formData, api, _extraOpts, fetchWithBQ) {
|
||||
const state = api.getState() as RootState;
|
||||
const oauthState = state.oauth;
|
||||
const loginState = state.login;
|
||||
|
||||
let instanceUrl: string;
|
||||
if (!formData.instance.startsWith("http")) {
|
||||
|
@ -147,8 +140,8 @@ const extended = gtsApi.injectEndpoints({
|
|||
}
|
||||
|
||||
instanceUrl = new URL(formData.instance).origin;
|
||||
if (oauthState?.instanceUrl == instanceUrl && oauthState.app) {
|
||||
return { data: oauthState.app };
|
||||
if (loginState?.instanceUrl == instanceUrl && loginState.app) {
|
||||
return { data: loginState.app };
|
||||
}
|
||||
|
||||
const appResult = await fetchWithBQ({
|
||||
|
@ -166,24 +159,24 @@ const extended = gtsApi.injectEndpoints({
|
|||
return { error: appResult.error as FetchBaseQueryError };
|
||||
}
|
||||
|
||||
let app = appResult.data as any;
|
||||
const app = appResult.data as any;
|
||||
|
||||
app.scopes = formData.scopes;
|
||||
api.dispatch(oauthAuthorize({
|
||||
instanceUrl: instanceUrl,
|
||||
app: app,
|
||||
loginState: "callback",
|
||||
current: "awaitingcallback",
|
||||
expectingRedirect: true
|
||||
}));
|
||||
|
||||
let url = new URL(instanceUrl);
|
||||
const url = new URL(instanceUrl);
|
||||
url.pathname = "/oauth/authorize";
|
||||
url.searchParams.set("client_id", app.client_id);
|
||||
url.searchParams.set("redirect_uri", SETTINGS_URL);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", app.scopes);
|
||||
|
||||
let redirectURL = url.toString();
|
||||
const redirectURL = url.toString();
|
||||
window.location.assign(redirectURL);
|
||||
return { data: null };
|
||||
},
|
146
web/source/settings/lib/query/user/applications.ts
Normal file
146
web/source/settings/lib/query/user/applications.ts
Normal file
|
@ -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/>.
|
||||
*/
|
||||
|
||||
import { RootState } from "../../../redux/store";
|
||||
import {
|
||||
SearchAppParams,
|
||||
SearchAppResp,
|
||||
App,
|
||||
AppCreateParams,
|
||||
} from "../../types/application";
|
||||
import { OAuthAccessToken, OAuthAccessTokenRequestBody } from "../../types/oauth";
|
||||
import { gtsApi } from "../gts-api";
|
||||
import parse from "parse-link-header";
|
||||
|
||||
const extended = gtsApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
searchApp: build.query<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" }],
|
||||
}),
|
||||
|
||||
deleteApp: build.mutation<App, string>({
|
||||
query: (id) => ({
|
||||
method: "DELETE",
|
||||
url: `/api/v1/apps/${id}`
|
||||
}),
|
||||
invalidatesTags: (_result, _error, id) => [
|
||||
{ type: 'Application', id },
|
||||
{ type: "Application", id: "TRANSFORMED" },
|
||||
{ type: "TokenInfo", id: "TRANSFORMED" },
|
||||
],
|
||||
}),
|
||||
|
||||
getOOBAuthCode: build.mutation<null, { app: App, scope: string, redirectURI: string }>({
|
||||
async queryFn({ app, scope, redirectURI }, api, _extraOpts, _fetchWithBQ) {
|
||||
// Fetch the instance URL string from
|
||||
// oauth state, eg., https://example.org.
|
||||
const state = api.getState() as RootState;
|
||||
if (!state.login.instanceUrl) {
|
||||
return {
|
||||
error: {
|
||||
status: 'CUSTOM_ERROR',
|
||||
error: "oauthState.instanceUrl undefined",
|
||||
}
|
||||
};
|
||||
}
|
||||
const instanceUrl = state.login.instanceUrl;
|
||||
|
||||
// Parse instance URL + set params on it.
|
||||
const url = new URL(instanceUrl);
|
||||
url.pathname = "/oauth/authorize";
|
||||
url.searchParams.set("client_id", app.client_id);
|
||||
url.searchParams.set("redirect_uri", redirectURI);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", scope);
|
||||
|
||||
// Set the app ID in state so we know which
|
||||
// app to get out of our store after redirect.
|
||||
url.searchParams.set("state", app.id);
|
||||
|
||||
// Whisk the user away to the authorize page.
|
||||
window.location.assign(url.toString());
|
||||
return { data: null };
|
||||
}
|
||||
}),
|
||||
|
||||
getAccessTokenForApp: build.mutation<OAuthAccessToken, OAuthAccessTokenRequestBody>({
|
||||
query: (formData) => ({
|
||||
method: "POST",
|
||||
url: `/oauth/token`,
|
||||
asForm: true,
|
||||
body: formData,
|
||||
discardEmpty: true
|
||||
}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
export const {
|
||||
useLazySearchAppQuery,
|
||||
useCreateAppMutation,
|
||||
useGetAppQuery,
|
||||
useGetOOBAuthCodeMutation,
|
||||
useGetAccessTokenForAppMutation,
|
||||
useDeleteAppMutation,
|
||||
} = extended;
|
71
web/source/settings/lib/types/application.ts
Normal file
71
web/source/settings/lib/types/application.ts
Normal file
|
@ -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;
|
||||
}
|
49
web/source/settings/lib/types/oauth.ts
Normal file
49
web/source/settings/lib/types/oauth.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* OAuthToken represents a response
|
||||
* to an OAuth token request.
|
||||
*/
|
||||
export interface OAuthAccessToken {
|
||||
/**
|
||||
* Most likely to be 'Bearer'
|
||||
* but may be something else.
|
||||
*/
|
||||
token_type: string;
|
||||
/**
|
||||
* The actual token. Can be passed in to
|
||||
* authenticate further requests using the
|
||||
* Authorization header and the token type.
|
||||
*/
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
export interface OAuthApp {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
}
|
||||
|
||||
export interface OAuthAccessTokenRequestBody {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
redirect_uri: string;
|
||||
grant_type: string;
|
||||
code: string;
|
||||
}
|
139
web/source/settings/lib/types/scopes.ts
Normal file
139
web/source/settings/lib/types/scopes.ts
Normal file
|
@ -0,0 +1,139 @@
|
|||
/*
|
||||
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/>.
|
||||
*/
|
||||
|
||||
/* Sub-scopes / scope components */
|
||||
|
||||
const scopeAccounts = "accounts";
|
||||
const scopeApplications = "applications";
|
||||
const scopeBlocks = "blocks";
|
||||
const scopeBookmarks = "bookmarks";
|
||||
const scopeConversations = "conversations";
|
||||
const scopeDomainAllows = "domain_allows";
|
||||
const scopeDomainBlocks = "domain_blocks";
|
||||
const scopeFavourites = "favourites";
|
||||
const scopeFilters = "filters";
|
||||
const scopeFollows = "follows";
|
||||
const scopeLists = "lists";
|
||||
const scopeMedia = "media";
|
||||
const scopeMutes = "mutes";
|
||||
const scopeNotifications = "notifications";
|
||||
const scopeReports = "reports";
|
||||
const scopeSearch = "search";
|
||||
const scopeStatuses = "statuses";
|
||||
|
||||
/* Top-level scopes */
|
||||
|
||||
export const ScopeProfile = "profile";
|
||||
export const ScopePush = "push";
|
||||
export const ScopeRead = "read";
|
||||
export const ScopeWrite = "write";
|
||||
export const ScopeAdmin = "admin";
|
||||
export const ScopeAdminRead = ScopeAdmin + ":" + ScopeRead;
|
||||
export const ScopeAdminWrite = ScopeAdmin + ":" + ScopeWrite;
|
||||
|
||||
/* Granular scopes */
|
||||
|
||||
export const ScopeReadAccounts = ScopeRead + ":" + scopeAccounts;
|
||||
export const ScopeWriteAccounts = ScopeWrite + ":" + scopeAccounts;
|
||||
export const ScopeReadApplications = ScopeRead + ":" + scopeApplications;
|
||||
export const ScopeWriteApplications = ScopeWrite + ":" + scopeApplications;
|
||||
export const ScopeReadBlocks = ScopeRead + ":" + scopeBlocks;
|
||||
export const ScopeWriteBlocks = ScopeWrite + ":" + scopeBlocks;
|
||||
export const ScopeReadBookmarks = ScopeRead + ":" + scopeBookmarks;
|
||||
export const ScopeWriteBookmarks = ScopeWrite + ":" + scopeBookmarks;
|
||||
export const ScopeWriteConversations = ScopeWrite + ":" + scopeConversations;
|
||||
export const ScopeReadFavourites = ScopeRead + ":" + scopeFavourites;
|
||||
export const ScopeWriteFavourites = ScopeWrite + ":" + scopeFavourites;
|
||||
export const ScopeReadFilters = ScopeRead + ":" + scopeFilters;
|
||||
export const ScopeWriteFilters = ScopeWrite + ":" + scopeFilters;
|
||||
export const ScopeReadFollows = ScopeRead + ":" + scopeFollows;
|
||||
export const ScopeWriteFollows = ScopeWrite + ":" + scopeFollows;
|
||||
export const ScopeReadLists = ScopeRead + ":" + scopeLists;
|
||||
export const ScopeWriteLists = ScopeWrite + ":" + scopeLists;
|
||||
export const ScopeWriteMedia = ScopeWrite + ":" + scopeMedia;
|
||||
export const ScopeReadMutes = ScopeRead + ":" + scopeMutes;
|
||||
export const ScopeWriteMutes = ScopeWrite + ":" + scopeMutes;
|
||||
export const ScopeReadNotifications = ScopeRead + ":" + scopeNotifications;
|
||||
export const ScopeWriteNotifications = ScopeWrite + ":" + scopeNotifications;
|
||||
export const ScopeWriteReports = ScopeWrite + ":" + scopeReports;
|
||||
export const ScopeReadSearch = ScopeRead + ":" + scopeSearch;
|
||||
export const ScopeReadStatuses = ScopeRead + ":" + scopeStatuses;
|
||||
export const ScopeWriteStatuses = ScopeWrite + ":" + scopeStatuses;
|
||||
export const ScopeAdminReadAccounts = ScopeAdminRead + ":" + scopeAccounts;
|
||||
export const ScopeAdminWriteAccounts = ScopeAdminWrite + ":" + scopeAccounts;
|
||||
export const ScopeAdminReadReports = ScopeAdminRead + ":" + scopeReports;
|
||||
export const ScopeAdminWriteReports = ScopeAdminWrite + ":" + scopeReports;
|
||||
export const ScopeAdminReadDomainAllows = ScopeAdminRead + ":" + scopeDomainAllows;
|
||||
export const ScopeAdminWriteDomainAllows = ScopeAdminWrite + ":" + scopeDomainAllows;
|
||||
export const ScopeAdminReadDomainBlocks = ScopeAdminRead + ":" + scopeDomainBlocks;
|
||||
export const ScopeAdminWriteDomainBlocks = ScopeAdminWrite + ":" + scopeDomainBlocks;
|
||||
|
||||
export const ValidScopes = [
|
||||
ScopeProfile,
|
||||
ScopePush,
|
||||
ScopeRead,
|
||||
ScopeWrite,
|
||||
ScopeAdmin,
|
||||
ScopeAdminRead,
|
||||
ScopeAdminWrite,
|
||||
ScopeReadAccounts,
|
||||
ScopeWriteAccounts,
|
||||
ScopeReadApplications,
|
||||
ScopeWriteApplications,
|
||||
ScopeReadBlocks,
|
||||
ScopeWriteBlocks,
|
||||
ScopeReadBookmarks,
|
||||
ScopeWriteBookmarks,
|
||||
ScopeWriteConversations,
|
||||
ScopeReadFavourites,
|
||||
ScopeWriteFavourites,
|
||||
ScopeReadFilters,
|
||||
ScopeWriteFilters,
|
||||
ScopeReadFollows,
|
||||
ScopeWriteFollows,
|
||||
ScopeReadLists,
|
||||
ScopeWriteLists,
|
||||
ScopeWriteMedia,
|
||||
ScopeReadMutes,
|
||||
ScopeWriteMutes,
|
||||
ScopeReadNotifications,
|
||||
ScopeWriteNotifications,
|
||||
ScopeWriteReports,
|
||||
ScopeReadSearch,
|
||||
ScopeReadStatuses,
|
||||
ScopeWriteStatuses,
|
||||
ScopeAdminReadAccounts,
|
||||
ScopeAdminWriteAccounts,
|
||||
ScopeAdminReadReports,
|
||||
ScopeAdminWriteReports,
|
||||
ScopeAdminReadDomainAllows,
|
||||
ScopeAdminWriteDomainAllows,
|
||||
ScopeAdminReadDomainBlocks,
|
||||
ScopeAdminWriteDomainBlocks,
|
||||
];
|
||||
|
||||
export const ValidTopLevelScopes = [
|
||||
ScopeProfile,
|
||||
ScopePush,
|
||||
ScopeRead,
|
||||
ScopeWrite,
|
||||
ScopeAdmin,
|
||||
ScopeAdminRead,
|
||||
ScopeAdminWrite,
|
||||
];
|
|
@ -18,6 +18,8 @@
|
|||
*/
|
||||
|
||||
import isValidDomain from "is-valid-domain";
|
||||
import { useCallback } from "react";
|
||||
import { ValidScopes, ValidTopLevelScopes } from "../types/scopes";
|
||||
|
||||
/**
|
||||
* Validate the "domain" field of a form.
|
||||
|
@ -29,6 +31,11 @@ export function formDomainValidator(domain: string): string {
|
|||
return "";
|
||||
}
|
||||
|
||||
// Allow localhost for testing.
|
||||
if (domain === "localhost") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (domain[domain.length-1] === ".") {
|
||||
return "invalid domain";
|
||||
}
|
||||
|
@ -63,5 +70,67 @@ export function urlValidator(urlStr: string): string {
|
|||
return `invalid protocol, must be http or https`;
|
||||
}
|
||||
|
||||
return formDomainValidator(url.host);
|
||||
return formDomainValidator(url.hostname);
|
||||
}
|
||||
|
||||
export function useScopesValidator(): (_scopes: string[]) => string {
|
||||
return useCallback((scopes) => {
|
||||
return scopes.
|
||||
map((scope) => validateScope(scope)).
|
||||
flatMap((msg) => msg || []).
|
||||
join(", ");
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useScopeValidator(): (_scope: string) => string {
|
||||
return useCallback((scope) => validateScope(scope), []);
|
||||
}
|
||||
|
||||
const validateScope = (scope: string) => {
|
||||
if (!ValidScopes.includes(scope)) {
|
||||
return scope + " is not a recognized scope";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
export function useScopesPermittedBy(): (_hasScopes: string[], _wantScopes: string[]) => string {
|
||||
return useCallback((hasScopes, wantsScopes) => {
|
||||
return wantsScopes.
|
||||
map((wanted) => scopePermittedByScopes(hasScopes, wanted)).
|
||||
flatMap((msg) => msg || []).
|
||||
join(", ");
|
||||
}, []);
|
||||
}
|
||||
|
||||
const scopePermittedByScopes = (hasScopes: string[], wanted: string) => {
|
||||
if (hasScopes.some((hasScope) => scopePermittedByScope(hasScope, wanted) === "")) {
|
||||
return "";
|
||||
}
|
||||
return `scopes [${hasScopes}] do not permit ${wanted}`;
|
||||
};
|
||||
|
||||
const scopePermittedByScope = (has: string, wanted: string) => {
|
||||
if (has === wanted) {
|
||||
// Exact match on either a
|
||||
// top-level or granular scope.
|
||||
return "";
|
||||
}
|
||||
|
||||
// Ensure we have a
|
||||
// known top-level scope.
|
||||
switch (true) {
|
||||
case (ValidTopLevelScopes.includes(has)):
|
||||
// Check if top-level includes wanted,
|
||||
// eg., have "admin", want "admin:read".
|
||||
if (wanted.startsWith(has + ":")) {
|
||||
return "";
|
||||
} else {
|
||||
return `scope ${has} does not permit ${wanted}`;
|
||||
}
|
||||
|
||||
default:
|
||||
// Unknown top-level scope,
|
||||
// can't permit anything.
|
||||
return `unrecognized scope ${has}`;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -30,7 +30,7 @@ export function UseOurInstanceAccount(account: AdminAccount): boolean {
|
|||
// Pull our own URL out of storage so we can
|
||||
// tell if account is our instance account.
|
||||
const ourDomain = useMemo(() => {
|
||||
const instanceUrlStr = store.getState().oauth.instanceUrl;
|
||||
const instanceUrlStr = store.getState().login.instanceUrl;
|
||||
if (!instanceUrlStr) {
|
||||
return "";
|
||||
}
|
||||
|
|
|
@ -18,33 +18,11 @@
|
|||
*/
|
||||
|
||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||
import { OAuthApp, OAuthAccessToken } from "../lib/types/oauth";
|
||||
|
||||
/**
|
||||
* OAuthToken represents a response
|
||||
* to an OAuth token request.
|
||||
*/
|
||||
export interface OAuthToken {
|
||||
/**
|
||||
* Most likely to be 'Bearer'
|
||||
* but may be something else.
|
||||
*/
|
||||
token_type: string;
|
||||
/**
|
||||
* The actual token. Can be passed in to
|
||||
* authenticate further requests using the
|
||||
* Authorization header and the token type.
|
||||
*/
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
export interface OAuthApp {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
}
|
||||
|
||||
export interface OAuthState {
|
||||
export interface LoginState {
|
||||
instanceUrl?: string;
|
||||
loginState: "none" | "callback" | "login" | "logout";
|
||||
current: "none" | "awaitingcallback" | "loggedin" | "loggedout";
|
||||
expectingRedirect: boolean;
|
||||
/**
|
||||
* Token stored in easy-to-use format.
|
||||
|
@ -55,29 +33,31 @@ export interface OAuthState {
|
|||
app?: OAuthApp;
|
||||
}
|
||||
|
||||
const initialState: OAuthState = {
|
||||
loginState: 'none',
|
||||
const initialState: LoginState = {
|
||||
current: 'none',
|
||||
expectingRedirect: false,
|
||||
};
|
||||
|
||||
export const oauthSlice = createSlice({
|
||||
name: "oauth",
|
||||
export const loginSlice = createSlice({
|
||||
name: "login",
|
||||
initialState: initialState,
|
||||
reducers: {
|
||||
authorize: (_state, action: PayloadAction<OAuthState>) => {
|
||||
authorize: (_state, action: PayloadAction<LoginState>) => {
|
||||
// Overrides state with payload.
|
||||
return action.payload;
|
||||
},
|
||||
setToken: (state, action: PayloadAction<OAuthToken>) => {
|
||||
// Mark us as logged in by storing token.
|
||||
setToken: (state, action: PayloadAction<OAuthAccessToken>) => {
|
||||
// Mark us as logged
|
||||
// in by storing token.
|
||||
state.token = `${action.payload.token_type} ${action.payload.access_token}`;
|
||||
state.loginState = "login";
|
||||
state.current = "loggedin";
|
||||
},
|
||||
remove: (state) => {
|
||||
// Mark us as logged out by clearing auth.
|
||||
// Mark us as logged
|
||||
// out by clearing auth.
|
||||
delete state.token;
|
||||
delete state.app;
|
||||
state.loginState = "logout";
|
||||
state.current = "loggedout";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -86,4 +66,4 @@ export const {
|
|||
authorize,
|
||||
setToken,
|
||||
remove,
|
||||
} = oauthSlice.actions;
|
||||
} = loginSlice.actions;
|
|
@ -30,19 +30,19 @@ import {
|
|||
REGISTER,
|
||||
} from "redux-persist";
|
||||
|
||||
import { oauthSlice } from "./oauth";
|
||||
import { loginSlice } from "./login";
|
||||
import { gtsApi } from "../lib/query/gts-api";
|
||||
|
||||
const combinedReducers = combineReducers({
|
||||
[gtsApi.reducerPath]: gtsApi.reducer,
|
||||
oauth: oauthSlice.reducer,
|
||||
login: loginSlice.reducer,
|
||||
});
|
||||
|
||||
const persistedReducer = persistReducer({
|
||||
key: "gotosocial-settings",
|
||||
storage: require("redux-persist/lib/storage").default,
|
||||
stateReconciler: require("redux-persist/lib/stateReconciler/autoMergeLevel1").default,
|
||||
whitelist: ["oauth"],
|
||||
whitelist: ["login"],
|
||||
migrate: async (state) => {
|
||||
if (state == undefined) {
|
||||
return state;
|
||||
|
@ -51,8 +51,8 @@ const persistedReducer = persistReducer({
|
|||
// This is a cheeky workaround for
|
||||
// redux-persist being a stickler.
|
||||
let anyState = state as any;
|
||||
if (anyState?.oauth != undefined) {
|
||||
anyState.oauth.expectingRedirect = false;
|
||||
if (anyState?.login != undefined) {
|
||||
anyState.login.expectingRedirect = false;
|
||||
}
|
||||
|
||||
return anyState;
|
||||
|
|
|
@ -1495,6 +1495,62 @@ button.tab-button {
|
|||
}
|
||||
}
|
||||
|
||||
.access-token-receive-form {
|
||||
> .access-token-frame {
|
||||
background-color: $gray2;
|
||||
width: 100%;
|
||||
padding: 0.25rem;
|
||||
border-radius: $br-inner;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
font-family: monospace;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
.closed {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.applications-view {
|
||||
.application {
|
||||
.info-list {
|
||||
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;
|
||||
}
|
||||
|
||||
> .info-list-entry > dd > button {
|
||||
font-size: medium;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.application-new > .form-section-docs > p > .monospace {
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
.instance-rules {
|
||||
list-style-position: inside;
|
||||
margin: 0;
|
||||
|
|
|
@ -17,16 +17,14 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React from "react";
|
||||
import { useTextInput } from "../../../../lib/form";
|
||||
import { useLazyApURLQuery } from "../../../../lib/query/admin/debug";
|
||||
import { TextInput } from "../../../../components/form/inputs";
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
import { ApURLResponse } from "../../../../lib/types/debug";
|
||||
import Loading from "../../../../components/loading";
|
||||
|
||||
// Used for syntax highlighting of json result.
|
||||
import Prism from "../../../../../frontend/prism";
|
||||
import { HighlightedCode } from "../../../../components/highlightedcode";
|
||||
|
||||
export default function ApURL() {
|
||||
const urlField = useTextInput("url");
|
||||
|
@ -102,26 +100,5 @@ function ApURLResult({
|
|||
};
|
||||
|
||||
const jsonStr = JSON.stringify(jsonObj, null, 2);
|
||||
return <Highlighted jsonStr={jsonStr} />;
|
||||
}
|
||||
|
||||
function Highlighted({ jsonStr }: { jsonStr: string }) {
|
||||
const ref = useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
Prism.highlightElement(ref.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Prism takes control of the `pre` so wrap
|
||||
// the whole thing in a div that we control.
|
||||
return (
|
||||
<div className="prism-highlighted">
|
||||
<pre>
|
||||
<code ref={ref} className="language-json">
|
||||
{jsonStr}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
return <HighlightedCode code={jsonStr} lang="json" />;
|
||||
}
|
||||
|
|
121
web/source/settings/views/user/applications/callback.tsx
Normal file
121
web/source/settings/views/user/applications/callback.tsx
Normal file
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
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";
|
||||
import { Error as ErrorCmp } from "../../../components/error";
|
||||
import { useGetAccessTokenForAppMutation, useGetAppQuery } from "../../../lib/query/user/applications";
|
||||
import { useCallbackURL } from "./common";
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
import { useValue } from "../../../lib/form";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import FormWithData from "../../../lib/form/form-with-data";
|
||||
import { App } from "../../../lib/types/application";
|
||||
import { OAuthAccessToken } from "../../../lib/types/oauth";
|
||||
|
||||
export function AppTokenCallback({}) {
|
||||
// Read the callback authorization
|
||||
// information from the search params.
|
||||
const search = useSearch();
|
||||
const urlQueryParams = new URLSearchParams(search);
|
||||
const code = urlQueryParams.get("code");
|
||||
const appId = urlQueryParams.get("state");
|
||||
const error = urlQueryParams.get("error");
|
||||
const errorDescription = urlQueryParams.get("error_description");
|
||||
|
||||
if (error) {
|
||||
let errString = error;
|
||||
if (errorDescription) {
|
||||
errString += ": " + errorDescription;
|
||||
}
|
||||
if (error === "invalid_scope") {
|
||||
errString += ". You probably requested a token (sub-)scope that wasn't contained in the scopes of your application.";
|
||||
}
|
||||
const err = Error(errString);
|
||||
return <ErrorCmp error={err} />;
|
||||
}
|
||||
|
||||
if (!code || !appId) {
|
||||
const err = Error("code or app id not defined");
|
||||
return <ErrorCmp error={err} />;
|
||||
}
|
||||
|
||||
return(
|
||||
<>
|
||||
<FormWithData
|
||||
dataQuery={useGetAppQuery}
|
||||
queryArg={appId}
|
||||
DataForm={AccessForAppForm}
|
||||
{...{ code: code }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function AccessForAppForm({ data: app, code }: { data: App, code: string }) {
|
||||
const redirectURI = useCallbackURL();
|
||||
|
||||
// Prepare to call /oauth/token to
|
||||
// exchange code for access token.
|
||||
const form = {
|
||||
client_id: useValue("client_id", app.client_id),
|
||||
client_secret: useValue("client_secret", app.client_secret),
|
||||
redirect_uri: useValue("redirect_uri", redirectURI),
|
||||
code: useValue("code", code),
|
||||
grant_type: useValue("grant_type", "authorization_code"),
|
||||
|
||||
};
|
||||
const [ submit, result ] = useFormSubmit(form, useGetAccessTokenForAppMutation());
|
||||
|
||||
return (
|
||||
<form
|
||||
className="access-token-receive-form"
|
||||
onSubmit={submit}
|
||||
>
|
||||
<div className="form-section-docs">
|
||||
<h2>Receive Access Token</h2>
|
||||
<p>
|
||||
To receive your user-level access token for application<b>{app.name}</b>, click on the button below.
|
||||
<br/>Your access token will be shown once and only once.
|
||||
<br/><strong>Your access token provides access to your account; store it as carefully as you would store a password!</strong>
|
||||
</p>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/api/authentication/#verifying"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about how to use your access token (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{ result.data
|
||||
? <div className="access-token-frame">{(result.data as OAuthAccessToken).access_token}</div>
|
||||
: <div className="access-token-frame closed"><i className="fa fa-eye-slash" aria-hidden={true}></i></div>
|
||||
}
|
||||
|
||||
<MutationButton
|
||||
label="I understand, show me the token!"
|
||||
result={result}
|
||||
disabled={result.data || result.isError}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
85
web/source/settings/views/user/applications/common.tsx
Normal file
85
web/source/settings/views/user/applications/common.tsx
Normal file
|
@ -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.login.instanceUrl;
|
||||
if (instanceUrl === undefined) {
|
||||
throw "instanceUrl undefined";
|
||||
}
|
||||
|
||||
return useMemo(() => {
|
||||
const url = new URL(instanceUrl);
|
||||
if (url === null) {
|
||||
throw "redirectURI null";
|
||||
}
|
||||
url.pathname = "/settings/user/applications/callback";
|
||||
return url.toString();
|
||||
}, [instanceUrl]);
|
||||
};
|
226
web/source/settings/views/user/applications/detail.tsx
Normal file
226
web/source/settings/views/user/applications/detail.tsx
Normal file
|
@ -0,0 +1,226 @@
|
|||
/*
|
||||
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, { useState } from "react";
|
||||
import { useLocation, useParams } from "wouter";
|
||||
import FormWithData from "../../../lib/form/form-with-data";
|
||||
import BackButton from "../../../components/back-button";
|
||||
import { useBaseUrl } from "../../../lib/navigation/util";
|
||||
import { useDeleteAppMutation, useGetAppQuery, useGetOOBAuthCodeMutation } from "../../../lib/query/user/applications";
|
||||
import { App } from "../../../lib/types/application";
|
||||
import { useAppWebsite, useCallbackURL, useCreated, useRedirectURIs } from "./common";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import { useTextInput } from "../../../lib/form";
|
||||
import { TextInput } from "../../../components/form/inputs";
|
||||
import { useScopesPermittedBy, useScopesValidator } from "../../../lib/util/formvalidators";
|
||||
|
||||
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} />
|
||||
<AccessTokenForm app={app} />
|
||||
<DeleteAppForm app={app} backLocation={backLocation} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppBasicInfo({ app }: { app: App }) {
|
||||
const appWebsite = useAppWebsite(app);
|
||||
const created = useCreated(app);
|
||||
const redirectURIs = useRedirectURIs(app);
|
||||
const [ showClient, setShowClient ] = useState(false);
|
||||
const [ showSecret, setShowSecret ] = useState(false);
|
||||
|
||||
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>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Vapid key:</dt>
|
||||
<dd className="monospace">{app.vapid_key}</dd>
|
||||
</div>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Client ID:</dt>
|
||||
{ showClient
|
||||
? <dd className="monospace">{app.client_id}</dd>
|
||||
: <dd><button onClick={() => setShowClient(true)}>Show client ID</button></dd>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Client secret:</dt>
|
||||
{ showSecret
|
||||
? <dd className="monospace">{app.client_secret}</dd>
|
||||
: <dd><button onClick={() => setShowSecret(true)}>Show secret</button></dd>
|
||||
}
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessTokenForm({ app }: { app: App }) {
|
||||
const [ getOOBAuthCode, result ] = useGetOOBAuthCodeMutation();
|
||||
const permittedScopes = useScopesPermittedBy();
|
||||
const validateScopes = useScopesValidator();
|
||||
const scope = useTextInput("scope", {
|
||||
defaultValue: app.scopes.join(" "),
|
||||
validator: (wantsScopesStr: string) => {
|
||||
if (wantsScopesStr === "") {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Check requested scopes are valid scopes.
|
||||
const wantsScopes = wantsScopesStr.split(" ");
|
||||
const invalidScopesMsg = validateScopes(wantsScopes);
|
||||
if (invalidScopesMsg !== "") {
|
||||
return invalidScopesMsg;
|
||||
}
|
||||
|
||||
// Check requested scopes are permitted by the app.
|
||||
return permittedScopes(app.scopes, wantsScopes);
|
||||
}
|
||||
});
|
||||
|
||||
const callbackURL = useCallbackURL();
|
||||
const disabled = !app.redirect_uris.includes(callbackURL);
|
||||
return (
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
getOOBAuthCode({
|
||||
app,
|
||||
scope: scope.value ?? "",
|
||||
redirectURI: callbackURL,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="form-section-docs">
|
||||
<h2>Request An API Access Token</h2>
|
||||
<p>
|
||||
If your application redirect URIs includes the settings panel callback URL,
|
||||
you can use this section to request an access token that you can use to make API calls.
|
||||
<br/>The token scopes specified below must be equal to, or a subset of, the scopes
|
||||
you provided when you created the application.
|
||||
<br/>After clicking "Request access token", you will be redirected to the sign in
|
||||
page for your instance, where you must provide your credentials in order to authorize
|
||||
your application to act on your behalf. You will then be redirected again to a page
|
||||
where you can view your new access token.
|
||||
</p>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/api/authentication/"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about the OAuth authentication flow (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
field={scope}
|
||||
label="Token scopes (space-separated list)"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<MutationButton
|
||||
disabled={disabled}
|
||||
label="Request access token"
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAppForm({ app, backLocation }: { app: App, backLocation: string }) {
|
||||
const [ _location, setLocation ] = useLocation();
|
||||
const [ deleteApp, result ] = useDeleteAppMutation();
|
||||
|
||||
return (
|
||||
<form>
|
||||
<div className="form-section-docs">
|
||||
<h2>Delete Application</h2>
|
||||
<p>
|
||||
You can use this button to delete the application.
|
||||
<br/>Any tokens created by the application will also be deleted.
|
||||
</p>
|
||||
</div>
|
||||
<MutationButton
|
||||
label={`Delete`}
|
||||
title={`Delete`}
|
||||
type="button"
|
||||
className="button danger"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
deleteApp(app.id);
|
||||
setLocation(backLocation);
|
||||
}}
|
||||
disabled={false}
|
||||
showError={false}
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
44
web/source/settings/views/user/applications/index.tsx
Normal file
44
web/source/settings/views/user/applications/index.tsx
Normal file
|
@ -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>
|
||||
);
|
||||
}
|
150
web/source/settings/views/user/applications/new.tsx
Normal file
150
web/source/settings/views/user/applications/new.tsx
Normal file
|
@ -0,0 +1,150 @@
|
|||
/*
|
||||
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, useScopesValidator } from "../../../lib/util/formvalidators";
|
||||
import { useCallbackURL } from "./common";
|
||||
import { HighlightedCode } from "../../../components/highlightedcode";
|
||||
|
||||
export default function NewApp() {
|
||||
const [ _location, setLocation ] = useLocation();
|
||||
const callbackURL = useCallbackURL();
|
||||
const scopesValidator = useScopesValidator();
|
||||
|
||||
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", {
|
||||
validator: (scopesStr: string) => {
|
||||
if (scopesStr === "") {
|
||||
return "";
|
||||
}
|
||||
return scopesValidator(scopesStr.split(" "));
|
||||
}
|
||||
}),
|
||||
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
|
||||
className="application-new"
|
||||
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 client application, with the specified redirect URIs and scopes.
|
||||
<br/>If not specified, redirect URIs defaults to <span className="monospace">urn:ietf:wg:oauth:2.0:oob</span>, and scopes defaults to <span className="monospace">read</span>.
|
||||
<br/>If you want to obtain an access token for your application here in the settings panel, include this settings panel callback URL in your redirect URIs:
|
||||
<HighlightedCode code={callbackURL} lang="url" />
|
||||
</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 (required)"
|
||||
placeholder="My Cool Application"
|
||||
autoCapitalize="words"
|
||||
spellCheck="false"
|
||||
maxLength={1024}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
field={form.website}
|
||||
label="Application website (optional)"
|
||||
placeholder="https://example.org/my_cool_application"
|
||||
autoCapitalize="none"
|
||||
spellCheck="false"
|
||||
type="url"
|
||||
maxLength={1024}
|
||||
/>
|
||||
|
||||
<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}
|
||||
maxLength={2056}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
field={form.scopes}
|
||||
label="Scopes (optional, space-separated entries)"
|
||||
placeholder={`read write push`}
|
||||
autoCapitalize="none"
|
||||
spellCheck="false"
|
||||
maxLength={1024}
|
||||
/>
|
||||
|
||||
<MutationButton
|
||||
label="Create"
|
||||
result={result}
|
||||
disabled={!form.name.value}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
190
web/source/settings/views/user/applications/search.tsx
Normal file
190
web/source/settings/views/user/applications/search.tsx
Normal file
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ import React from "react";
|
|||
|
||||
import FormWithData from "../../lib/form/form-with-data";
|
||||
|
||||
import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
|
||||
import { useVerifyCredentialsQuery } from "../../lib/query/login";
|
||||
import { useArrayInput, useTextInput } from "../../lib/form";
|
||||
import { TextInput } from "../../components/form/inputs";
|
||||
import useFormSubmit from "../../lib/form/submit";
|
||||
|
@ -142,7 +142,7 @@ function AlsoKnownAsURI({ index, data }) {
|
|||
}
|
||||
|
||||
function MoveForm({ data: profile }) {
|
||||
let urlStr = store.getState().oauth.instanceUrl ?? "";
|
||||
let urlStr = store.getState().login.instanceUrl ?? "";
|
||||
let url = new URL(urlStr);
|
||||
|
||||
const form = {
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useVerifyCredentialsQuery } from "../../../lib/query/oauth";
|
||||
import { useVerifyCredentialsQuery } from "../../../lib/query/login";
|
||||
import Loading from "../../../components/loading";
|
||||
import { Error as ErrorC } from "../../../components/error";
|
||||
import BasicSettings from "./basic-settings";
|
||||
|
|
|
@ -43,7 +43,7 @@ import MutationButton from "../../components/form/mutation-button";
|
|||
|
||||
import { useAccountThemesQuery } from "../../lib/query/user";
|
||||
import { useUpdateCredentialsMutation } from "../../lib/query/user";
|
||||
import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
|
||||
import { useVerifyCredentialsQuery } from "../../lib/query/login";
|
||||
import { useInstanceV1Query } from "../../lib/query/gts-api";
|
||||
import { Account } from "../../lib/types/account";
|
||||
|
||||
|
|
|
@ -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
|
||||
|
@ -37,26 +41,51 @@ import Tokens from "./tokens";
|
|||
* - /settings/user/migration
|
||||
* - /settings/user/export-import
|
||||
* - /settings/user/tokens
|
||||
* - /settings/users/interaction_requests
|
||||
* - /settings/user/interaction_requests
|
||||
* - /settings/user/applications
|
||||
*/
|
||||
export default function UserRouter() {
|
||||
const baseUrl = useBaseUrl();
|
||||
const thisBase = "/user";
|
||||
const absBase = baseUrl + thisBase;
|
||||
|
||||
return (
|
||||
<BaseUrlContext.Provider value={absBase}>
|
||||
<Router base={thisBase}>
|
||||
<Switch>
|
||||
<Route path="/profile" component={UserProfile} />
|
||||
<Route path="/posts" component={PostSettings} />
|
||||
<Route path="/emailpassword" component={EmailPassword} />
|
||||
<Route path="/migration" component={UserMigration} />
|
||||
<Route path="/export-import" component={ExportImport} />
|
||||
<Route path="/tokens" component={Tokens} />
|
||||
</Switch>
|
||||
<InteractionRequestsRouter />
|
||||
<ApplicationsRouter />
|
||||
</Router>
|
||||
</BaseUrlContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* - /settings/user/applications/search
|
||||
* - /settings/user/applications/{appID}
|
||||
*/
|
||||
function ApplicationsRouter() {
|
||||
const parentUrl = useBaseUrl();
|
||||
const thisBase = "/applications";
|
||||
const absBase = parentUrl + thisBase;
|
||||
|
||||
return (
|
||||
<BaseUrlContext.Provider value={absBase}>
|
||||
<Router base={thisBase}>
|
||||
<ErrorBoundary>
|
||||
<Switch>
|
||||
<Route path="/profile" component={UserProfile} />
|
||||
<Route path="/posts" component={PostSettings} />
|
||||
<Route path="/emailpassword" component={EmailPassword} />
|
||||
<Route path="/migration" component={UserMigration} />
|
||||
<Route path="/export-import" component={ExportImport} />
|
||||
<Route path="/tokens" component={Tokens} />
|
||||
<InteractionRequestsRouter />
|
||||
<Route><Redirect to="/profile" /></Route>
|
||||
<Route path="/search" component={Applications} />
|
||||
<Route path="/new" component={NewApp} />
|
||||
<Route path="/callback" component={AppTokenCallback} />
|
||||
<Route path="/:appId" component={AppDetail} />
|
||||
<Route><Redirect to="/search"/></Route>
|
||||
</Switch>
|
||||
</ErrorBoundary>
|
||||
</Router>
|
||||
|
@ -76,11 +105,13 @@ function InteractionRequestsRouter() {
|
|||
return (
|
||||
<BaseUrlContext.Provider value={absBase}>
|
||||
<Router base={thisBase}>
|
||||
<Switch>
|
||||
<Route path="/search" component={InteractionRequests} />
|
||||
<Route path="/:reqId" component={InteractionRequestDetail} />
|
||||
<Route><Redirect to="/search"/></Route>
|
||||
</Switch>
|
||||
<ErrorBoundary>
|
||||
<Switch>
|
||||
<Route path="/search" component={InteractionRequests} />
|
||||
<Route path="/:reqId" component={InteractionRequestDetail} />
|
||||
<Route><Redirect to="/search"/></Route>
|
||||
</Switch>
|
||||
</ErrorBoundary>
|
||||
</Router>
|
||||
</BaseUrlContext.Provider>
|
||||
);
|
||||
|
|
|
@ -1140,7 +1140,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==
|
||||
|
@ -1435,9 +1442,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"
|
||||
|
@ -1473,9 +1480,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"
|
||||
|
@ -5673,9 +5680,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 a new issue