[chore] Convert some settings / admin panel JS to TypeScript (#2247)

* initial conversion of STUFF to typescript

* more stuff

* update babel deps, include commonjs transform

* update bundler & eslint configuration

* eslint --fix

* upgrade deps

* update docs, build stuff, peripheral stuff

---------

Co-authored-by: f0x <f0x@cthu.lu>
This commit is contained in:
tobi
2023-10-05 16:06:19 +02:00
committed by GitHub
parent 6e508830e1
commit d173fcdfa3
84 changed files with 2365 additions and 1621 deletions

View File

@ -17,8 +17,6 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const Promise = require("bluebird");
const { unwrapRes } = require("../lib");

View File

@ -17,8 +17,6 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const Promise = require("bluebird");
const fileDownload = require("js-file-download");
const csv = require("papaparse");

View File

@ -17,15 +17,13 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const {
replaceCacheOnMutation,
removeFromCacheOnMutation,
domainListToObject,
idListToObject
} = require("../lib");
const base = require("../base");
const { gtsApi } = require("../gts-api");
const endpoints = (build) => ({
updateInstance: build.mutation({
@ -164,4 +162,4 @@ const endpoints = (build) => ({
...require("./reports")(build)
});
module.exports = base.injectEndpoints({ endpoints });
module.exports = gtsApi.injectEndpoints({ endpoints });

View File

@ -17,8 +17,6 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
module.exports = (build) => ({
listReports: build.query({
query: (params = {}) => ({

View File

@ -1,70 +0,0 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const { createApi, fetchBaseQuery } = require("@reduxjs/toolkit/query/react");
const { serialize: serializeForm } = require("object-to-formdata");
function instanceBasedQuery(args, api, extraOptions) {
const state = api.getState();
const { instance, token } = state.oauth;
if (args.baseUrl == undefined) {
args.baseUrl = instance;
}
if (args.discardEmpty) {
if (args.body == undefined || Object.keys(args.body).length == 0) {
return { data: null };
}
delete args.discardEmpty;
}
if (args.asForm) {
delete args.asForm;
args.body = serializeForm(args.body, {
indices: true, // Array indices, for profile fields
});
}
return fetchBaseQuery({
baseUrl: args.baseUrl,
prepareHeaders: (headers) => {
if (token != undefined) {
headers.set('Authorization', token);
}
headers.set("Accept", "application/json");
return headers;
},
})(args, api, extraOptions);
}
module.exports = createApi({
reducerPath: "api",
baseQuery: instanceBasedQuery,
tagTypes: ["Auth", "Emoji", "Reports", "Account", "InstanceRules"],
endpoints: (build) => ({
instance: build.query({
query: () => ({
url: `/api/v1/instance`
})
})
})
});

View File

@ -0,0 +1,149 @@
/*
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 { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import type {
BaseQueryFn,
FetchArgs,
FetchBaseQueryError,
} from '@reduxjs/toolkit/query/react';
import { serialize as serializeForm } from "object-to-formdata";
import type { RootState } from '../../redux/store';
/**
* GTSFetchArgs extends standard FetchArgs used by
* RTK Query with a couple helpers of our own.
*/
export interface GTSFetchArgs extends FetchArgs {
/**
* If provided, will be used as base URL. Else,
* will fall back to authorized instance as baseUrl.
*/
baseUrl?: string;
/**
* If true, and no args.body is set, or args.body is empty,
* then a null response will be returned from the API call.
*/
discardEmpty?: boolean;
/**
* If true, then args.body will be serialized
* as FormData before submission.
*/
asForm?: boolean;
}
/**
* gtsBaseQuery wraps the redux toolkit fetchBaseQuery with some helper functionality.
*
* For an explainer of what's happening in this function, see:
* - https://redux-toolkit.js.org/rtk-query/usage/customizing-queries#customizing-queries-with-basequery
* - https://redux-toolkit.js.org/rtk-query/usage/customizing-queries#constructing-a-dynamic-base-url-using-redux-state
*
* @param args
* @param api
* @param extraOptions
* @returns
*/
const gtsBaseQuery: BaseQueryFn<
string | GTSFetchArgs,
any,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
// Retrieve state at the moment
// this function was called.
const state = api.getState() as RootState;
const { instanceUrl, token } = state.oauth;
// Derive baseUrl dynamically.
let baseUrl: string;
// Check if simple string baseUrl provided
// as args, or if more complex args provided.
if (typeof args === "string") {
baseUrl = args;
} else {
if (args.baseUrl != undefined) {
baseUrl = args.baseUrl;
} else {
baseUrl = instanceUrl;
}
if (args.discardEmpty) {
if (args.body == undefined || Object.keys(args.body).length == 0) {
return { data: null };
}
}
if (args.asForm) {
args.body = serializeForm(args.body, {
// Array indices, for profile fields.
indices: true,
});
}
// Delete any of our extended arguments
// to avoid confusing fetchBaseQuery.
delete args.baseUrl;
delete args.discardEmpty;
delete args.asForm;
}
if (!baseUrl) {
return {
error: {
status: 400,
statusText: 'Bad Request',
data: {"error":"No baseUrl set for request"},
},
};
}
return fetchBaseQuery({
baseUrl: baseUrl,
prepareHeaders: (headers) => {
if (token != undefined) {
headers.set('Authorization', token);
}
headers.set("Accept", "application/json");
return headers;
},
})(args, api, extraOptions);
};
export const gtsApi = createApi({
reducerPath: "api",
baseQuery: gtsBaseQuery,
tagTypes: [
"Auth",
"Emoji",
"Reports",
"Account",
"InstanceRules",
],
endpoints: (builder) => ({
instance: builder.query<any, void>({
query: () => ({
url: `/api/v1/instance`
})
})
})
});
export const { useInstanceQuery } = gtsApi;

View File

@ -17,11 +17,9 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
module.exports = {
...require("./base"),
...require("./gts-api"),
...require("./oauth"),
...require("./user"),
...require("./admin")
};
};

View File

@ -17,10 +17,8 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const syncpipe = require("syncpipe");
const base = require("./base");
const { gtsApi } = require("./gts-api");
module.exports = {
unwrapRes(res) {
@ -70,7 +68,7 @@ function makeCacheMutation(action) {
return {
onQueryStarted: (_, { dispatch, queryFulfilled }) => {
queryFulfilled.then(({ data: newData }) => {
dispatch(base.util.updateQueryData(queryName, arg, (draft) => {
dispatch(gtsApi.util.updateQueryData(queryName, arg, (draft) => {
if (findKey != undefined) {
key = findKey(draft, newData);
}

View File

@ -1,160 +0,0 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const Promise = require("bluebird");
const base = require("./base");
const { unwrapRes } = require("./lib");
const oauth = require("../../redux/oauth").actions;
function getSettingsURL() {
/* needed in case the settings interface isn't hosted at /settings but
some subpath like /gotosocial/settings. Other parts of the code don't
take this into account yet so mostly future-proofing.
Also drops anything past /settings/, because authorization urls that are too long
get rejected by GTS.
*/
let [pre, _past] = window.location.pathname.split("/settings");
return `${window.location.origin}${pre}/settings`;
}
const SETTINGS_URL = getSettingsURL();
const endpoints = (build) => ({
verifyCredentials: build.query({
providesTags: (_res, error) =>
error == undefined
? ["Auth"]
: [],
queryFn: (_arg, api, _extraOpts, baseQuery) => {
const state = api.getState();
return Promise.try(() => {
// Process callback code first, if available
if (state.oauth.loginState == "callback") {
let urlParams = new URLSearchParams(window.location.search);
let code = urlParams.get("code");
if (code == undefined) {
throw {
message: "Waiting for callback, but no ?code= provided in url."
};
} else {
let app = state.oauth.registration;
if (app == undefined || app.client_id == undefined) {
throw {
message: "No stored registration data, can't finish login flow."
};
}
return baseQuery({
method: "POST",
url: "/oauth/token",
body: {
client_id: app.client_id,
client_secret: app.client_secret,
redirect_uri: SETTINGS_URL,
grant_type: "authorization_code",
code: code
}
}).then(unwrapRes).then((token) => {
// remove ?code= from url
window.history.replaceState({}, document.title, window.location.pathname);
api.dispatch(oauth.setToken(token));
});
}
}
}).then(() => {
return baseQuery({
url: `/api/v1/accounts/verify_credentials`
});
}).catch((e) => {
return { error: e };
});
}
}),
authorizeFlow: build.mutation({
queryFn: (formData, api, _extraOpts, baseQuery) => {
let instance;
const state = api.getState();
return Promise.try(() => {
if (!formData.instance.startsWith("http")) {
formData.instance = `https://${formData.instance}`;
}
instance = new URL(formData.instance).origin;
const stored = state.oauth.instance;
if (stored?.instance == instance && stored.registration) {
return stored.registration;
}
return baseQuery({
method: "POST",
baseUrl: instance,
url: "/api/v1/apps",
body: {
client_name: "GoToSocial Settings",
scopes: formData.scopes,
redirect_uris: SETTINGS_URL,
website: SETTINGS_URL
}
}).then(unwrapRes).then((app) => {
app.scopes = formData.scopes;
api.dispatch(oauth.authorize({
instance: instance,
registration: app,
loginState: "callback",
expectingRedirect: true
}));
return app;
});
}).then((app) => {
let url = new URL(instance);
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();
window.location.assign(redirectURL);
return { data: null };
}).catch((e) => {
return { error: e };
});
},
}),
logout: build.mutation({
queryFn: (_arg, api) => {
api.dispatch(oauth.remove());
return { data: null };
},
invalidatesTags: ["Auth"]
})
});
module.exports = base.injectEndpoints({ endpoints });

View File

@ -0,0 +1,204 @@
/*
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 type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { gtsApi } from "../gts-api";
import {
setToken as oauthSetToken,
remove as oauthRemove,
authorize as oauthAuthorize,
} from "../../../redux/oauth";
import { RootState } from '../../../redux/store';
export interface OauthTokenRequestBody {
client_id: string;
client_secret: string;
redirect_uri: string;
grant_type: string;
code: string;
}
function getSettingsURL() {
/*
needed in case the settings interface isn't hosted at /settings but
some subpath like /gotosocial/settings. Other parts of the code don't
take this into account yet so mostly future-proofing.
Also drops anything past /settings/, because authorization urls that are too long
get rejected by GTS.
*/
let [pre, _past] = window.location.pathname.split("/settings");
return `${window.location.origin}${pre}/settings`;
}
const SETTINGS_URL = (getSettingsURL());
// Couple auth functions here require multiple requests as
// part of an OAuth token 'flow'. To keep things simple for
// callers of these query functions, the multiple requests
// are chained within one query.
//
// https://redux-toolkit.js.org/rtk-query/usage/customizing-queries#performing-multiple-requests-with-a-single-query
const extended = gtsApi.injectEndpoints({
endpoints: (builder) => ({
verifyCredentials: builder.query<any, void>({
providesTags: (_res, error) =>
error == undefined ? ["Auth"] : [],
async queryFn(_arg, api, _extraOpts, fetchWithBQ) {
const state = api.getState() as RootState;
const oauthState = state.oauth;
// 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') {
return fetchWithBQ({
url: `/api/v1/accounts/verify_credentials`
});
}
// 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");
if (code == undefined) {
return {
error: {
status: 400,
statusText: 'Bad Request',
data: {"error":"Waiting for callback, but no ?code= provided in url."},
},
};
}
// Retrieve app with which the
// callback code was generated.
let app = oauthState.app;
if (app == undefined || app.client_id == undefined) {
return {
error: {
status: 400,
statusText: 'Bad Request',
data: {"error":"No stored app registration data, can't finish login flow."},
},
};
}
// Use the provided code and app
// secret to request an auth token.
const tokenReqBody: OauthTokenRequestBody = {
client_id: app.client_id,
client_secret: app.client_secret,
redirect_uri: SETTINGS_URL,
grant_type: "authorization_code",
code: code
};
const tokenResult = await fetchWithBQ({
method: "POST",
url: "/oauth/token",
body: tokenReqBody,
});
if (tokenResult.error) {
return { error: tokenResult.error as FetchBaseQueryError };
}
// Remove ?code= query param from
// url, we don't want it anymore.
window.history.replaceState({}, document.title, window.location.pathname);
// Store returned token in redux.
api.dispatch(oauthSetToken(tokenResult.data));
// We're now authed! So return
// standard verify_credentials query.
return fetchWithBQ({
url: `/api/v1/accounts/verify_credentials`
});
}
}),
authorizeFlow: builder.mutation({
async queryFn(formData, api, _extraOpts, fetchWithBQ) {
const state = api.getState() as RootState;
const oauthState = state.oauth;
let instanceUrl: string;
if (!formData.instance.startsWith("http")) {
formData.instance = `https://${formData.instance}`;
}
instanceUrl = new URL(formData.instance).origin;
if (oauthState?.instanceUrl == instanceUrl && oauthState.app) {
return { data: oauthState.app };
}
const appResult = await fetchWithBQ({
method: "POST",
baseUrl: instanceUrl,
url: "/api/v1/apps",
body: {
client_name: "GoToSocial Settings",
scopes: formData.scopes,
redirect_uris: SETTINGS_URL,
website: SETTINGS_URL
}
});
if (appResult.error) {
return { error: appResult.error as FetchBaseQueryError };
}
let app = appResult.data as any;
app.scopes = formData.scopes;
api.dispatch(oauthAuthorize({
instanceUrl: instanceUrl,
app: app,
loginState: "callback",
expectingRedirect: true
}));
let 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();
window.location.assign(redirectURL);
return { data: null };
},
}),
logout: builder.mutation({
queryFn: (_arg, api) => {
api.dispatch(oauthRemove());
return { data: null };
},
invalidatesTags: ["Auth"]
})
})
});
export const {
useVerifyCredentialsQuery,
useAuthorizeFlowMutation,
useLogoutMutation,
} = extended;

View File

@ -17,29 +17,32 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
import { replaceCacheOnMutation } from "../lib";
import { gtsApi } from "../gts-api";
const { replaceCacheOnMutation } = require("./lib");
const base = require("./base");
const endpoints = (build) => ({
updateCredentials: build.mutation({
query: (formData) => ({
method: "PATCH",
url: `/api/v1/accounts/update_credentials`,
asForm: true,
body: formData,
discardEmpty: true
const extended = gtsApi.injectEndpoints({
endpoints: (builder) => ({
updateCredentials: builder.mutation({
query: (formData) => ({
method: "PATCH",
url: `/api/v1/accounts/update_credentials`,
asForm: true,
body: formData,
discardEmpty: true
}),
...replaceCacheOnMutation("verifyCredentials")
}),
...replaceCacheOnMutation("verifyCredentials")
}),
passwordChange: build.mutation({
query: (data) => ({
method: "POST",
url: `/api/v1/user/password_change`,
body: data
passwordChange: builder.mutation({
query: (data) => ({
method: "POST",
url: `/api/v1/user/password_change`,
body: data
})
})
})
});
module.exports = base.injectEndpoints({ endpoints });
export const {
useUpdateCredentialsMutation,
usePasswordChangeMutation,
} = extended;