mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-30 22:02:25 -05: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
				
			
		|  | @ -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; | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue