diff options
author | 2023-10-17 12:46:06 +0200 | |
---|---|---|
committer | 2023-10-17 12:46:06 +0200 | |
commit | 637f188ebec71fe4b0b80bbab4592d4c269d7d93 (patch) | |
tree | 6e1136dee4d854af021e0a571a67038d32083e4b /web/source/settings/lib/query | |
parent | [chore]: Bump github.com/microcosm-cc/bluemonday from 1.0.25 to 1.0.26 (#2266) (diff) | |
download | gotosocial-637f188ebec71fe4b0b80bbab4592d4c269d7d93.tar.xz |
[feature] Allow import/export/creation of domain allows via admin panel (#2264)v0.12.0-rc1
* it's happening!
* aaa
* fix silly whoopsie
* it's working pa! it's working ma!
* model report parameters
* shuffle some more stuff around
* getting there
* oo hoo
* finish tidying up for now
* aaa
* fix use form submit errors
* peepee poo poo
* aaaaa
* ffff
* they see me typin', they hatin'
* boop
* aaa
* oooo
* typing typing tappa tappa
* almost done typing
* weee
* alright
* push it push it real good doo doo doo doo doo doo
* thingy no worky
* almost done
* mutation modifers not quite right
* hmm
* it works
* view blocks + allows nicely
* it works!
* typia install
* the old linterino
* linter plz
Diffstat (limited to 'web/source/settings/lib/query')
18 files changed, 1410 insertions, 768 deletions
diff --git a/web/source/settings/lib/query/admin/custom-emoji.js b/web/source/settings/lib/query/admin/custom-emoji.js deleted file mode 100644 index 6e7c772a2..000000000 --- a/web/source/settings/lib/query/admin/custom-emoji.js +++ /dev/null @@ -1,194 +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/>. -*/ - -const Promise = require("bluebird"); - -const { unwrapRes } = require("../lib"); - -module.exports = (build) => ({ - listEmoji: build.query({ - query: (params = {}) => ({ - url: "/api/v1/admin/custom_emojis", - params: { - limit: 0, - ...params - } - }), - providesTags: (res) => - res - ? [...res.map((emoji) => ({ type: "Emoji", id: emoji.id })), { type: "Emoji", id: "LIST" }] - : [{ type: "Emoji", id: "LIST" }] - }), - - getEmoji: build.query({ - query: (id) => ({ - url: `/api/v1/admin/custom_emojis/${id}` - }), - providesTags: (res, error, id) => [{ type: "Emoji", id }] - }), - - addEmoji: build.mutation({ - query: (form) => { - return { - method: "POST", - url: `/api/v1/admin/custom_emojis`, - asForm: true, - body: form, - discardEmpty: true - }; - }, - invalidatesTags: (res) => - res - ? [{ type: "Emoji", id: "LIST" }, { type: "Emoji", id: res.id }] - : [{ type: "Emoji", id: "LIST" }] - }), - - editEmoji: build.mutation({ - query: ({ id, ...patch }) => { - return { - method: "PATCH", - url: `/api/v1/admin/custom_emojis/${id}`, - asForm: true, - body: { - type: "modify", - ...patch - } - }; - }, - invalidatesTags: (res) => - res - ? [{ type: "Emoji", id: "LIST" }, { type: "Emoji", id: res.id }] - : [{ type: "Emoji", id: "LIST" }] - }), - - deleteEmoji: build.mutation({ - query: (id) => ({ - method: "DELETE", - url: `/api/v1/admin/custom_emojis/${id}` - }), - invalidatesTags: (res, error, id) => [{ type: "Emoji", id }] - }), - - searchStatusForEmoji: build.mutation({ - queryFn: (url, api, _extraOpts, baseQuery) => { - return Promise.try(() => { - return baseQuery({ - url: `/api/v2/search?q=${encodeURIComponent(url)}&resolve=true&limit=1` - }).then(unwrapRes); - }).then((searchRes) => { - return emojiFromSearchResult(searchRes); - }).then(({ type, domain, list }) => { - const state = api.getState(); - if (domain == new URL(state.oauth.instance).host) { - throw "LOCAL_INSTANCE"; - } - - // search for every mentioned emoji with the admin api to get their ID - return Promise.map(list, (emoji) => { - return baseQuery({ - url: `/api/v1/admin/custom_emojis`, - params: { - filter: `domain:${domain},shortcode:${emoji.shortcode}`, - limit: 1 - } - }).then((unwrapRes)).then((list) => list[0]); - }, { concurrency: 5 }).then((listWithIDs) => { - return { - data: { - type, - domain, - list: listWithIDs - } - }; - }); - }).catch((e) => { - return { error: e }; - }); - } - }), - - patchRemoteEmojis: build.mutation({ - queryFn: ({ action, ...formData }, _api, _extraOpts, baseQuery) => { - const data = []; - const errors = []; - - return Promise.each(formData.selectedEmoji, (emoji) => { - return Promise.try(() => { - let body = { - type: action - }; - - if (action == "copy") { - body.shortcode = emoji.shortcode; - if (formData.category.trim().length != 0) { - body.category = formData.category; - } - } - - return baseQuery({ - method: "PATCH", - url: `/api/v1/admin/custom_emojis/${emoji.id}`, - asForm: true, - body: body - }).then(unwrapRes); - }).then((res) => { - data.push([emoji.id, res]); - }).catch((e) => { - let msg = e.message ?? e; - if (e.data.error) { - msg = e.data.error; - } - errors.push([emoji.shortcode, msg]); - }); - }).then(() => { - if (errors.length == 0) { - return { data }; - } else { - return { - error: errors - }; - } - }); - }, - invalidatesTags: () => [{ type: "Emoji", id: "LIST" }] - }) -}); - -function emojiFromSearchResult(searchRes) { - /* Parses the search response, prioritizing a toot result, - and returns referenced custom emoji - */ - let type; - - if (searchRes.statuses.length > 0) { - type = "statuses"; - } else if (searchRes.accounts.length > 0) { - type = "accounts"; - } else { - throw "NONE_FOUND"; - } - - let data = searchRes[type][0]; - - return { - type, - domain: (new URL(data.url)).host, // to get WEB_DOMAIN, see https://github.com/superseriousbusiness/gotosocial/issues/1225 - list: data.emojis - }; -}
\ No newline at end of file diff --git a/web/source/settings/lib/query/admin/custom-emoji/index.ts b/web/source/settings/lib/query/admin/custom-emoji/index.ts new file mode 100644 index 000000000..d624b0580 --- /dev/null +++ b/web/source/settings/lib/query/admin/custom-emoji/index.ts @@ -0,0 +1,307 @@ +/* + 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 { gtsApi } from "../../gts-api"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; +import { RootState } from "../../../../redux/store"; + +import type { CustomEmoji, EmojisFromItem, ListEmojiParams } from "../../../types/custom-emoji"; + +/** + * Parses the search response, prioritizing a status + * result, and returns any referenced custom emoji. + * + * Due to current API constraints, the returned emojis + * will not have their ID property set, so further + * processing is required to retrieve the IDs. + * + * @param searchRes + * @returns + */ +function emojisFromSearchResult(searchRes): EmojisFromItem { + // We don't know in advance whether a searched URL + // is the URL for a status, or the URL for an account, + // but we can derive this by looking at which search + // result field actually has entries in it (if any). + let type: "statuses" | "accounts"; + if (searchRes.statuses.length > 0) { + // We had status results, + // so this was a status URL. + type = "statuses"; + } else if (searchRes.accounts.length > 0) { + // We had account results, + // so this was an account URL. + type = "accounts"; + } else { + // Nada, zilch, we can't do + // anything with this. + throw "NONE_FOUND"; + } + + // Narrow type to discard all the other + // data on the result that we don't need. + const data: { + url: string; + emojis: CustomEmoji[]; + } = searchRes[type][0]; + + return { + type, + // Workaround to get host rather than account domain. + // See https://github.com/superseriousbusiness/gotosocial/issues/1225. + domain: (new URL(data.url)).host, + list: data.emojis, + }; +} + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + listEmoji: build.query<CustomEmoji[], ListEmojiParams | void>({ + query: (params = {}) => ({ + url: "/api/v1/admin/custom_emojis", + params: { + limit: 0, + ...params + } + }), + providesTags: (res, _error, _arg) => + res + ? [ + ...res.map((emoji) => ({ type: "Emoji" as const, id: emoji.id })), + { type: "Emoji", id: "LIST" } + ] + : [{ type: "Emoji", id: "LIST" }] + }), + + getEmoji: build.query<CustomEmoji, string>({ + query: (id) => ({ + url: `/api/v1/admin/custom_emojis/${id}` + }), + providesTags: (_res, _error, id) => [{ type: "Emoji", id }] + }), + + addEmoji: build.mutation<CustomEmoji, Object>({ + query: (form) => { + return { + method: "POST", + url: `/api/v1/admin/custom_emojis`, + asForm: true, + body: form, + discardEmpty: true + }; + }, + invalidatesTags: (res) => + res + ? [{ type: "Emoji", id: "LIST" }, { type: "Emoji", id: res.id }] + : [{ type: "Emoji", id: "LIST" }] + }), + + editEmoji: build.mutation<CustomEmoji, any>({ + query: ({ id, ...patch }) => { + return { + method: "PATCH", + url: `/api/v1/admin/custom_emojis/${id}`, + asForm: true, + body: { + type: "modify", + ...patch + } + }; + }, + invalidatesTags: (res) => + res + ? [{ type: "Emoji", id: "LIST" }, { type: "Emoji", id: res.id }] + : [{ type: "Emoji", id: "LIST" }] + }), + + deleteEmoji: build.mutation<any, string>({ + query: (id) => ({ + method: "DELETE", + url: `/api/v1/admin/custom_emojis/${id}` + }), + invalidatesTags: (_res, _error, id) => [{ type: "Emoji", id }] + }), + + searchItemForEmoji: build.mutation<EmojisFromItem, string>({ + async queryFn(url, api, _extraOpts, fetchWithBQ) { + const state = api.getState() as RootState; + const oauthState = state.oauth; + + // First search for given url. + const searchRes = await fetchWithBQ({ + url: `/api/v2/search?q=${encodeURIComponent(url)}&resolve=true&limit=1` + }); + if (searchRes.error) { + return { error: searchRes.error as FetchBaseQueryError }; + } + + // Parse initial results of search. + // These emojis will not have IDs set. + const { + type, + domain, + list: withoutIDs, + } = emojisFromSearchResult(searchRes.data); + + // 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) { + throw "LOCAL_INSTANCE"; + } + } + + // Search for each listed emoji with the admin + // api to get the version that includes an ID. + const withIDs: CustomEmoji[] = []; + const errors: FetchBaseQueryError[] = []; + + withoutIDs.forEach(async(emoji) => { + // Request admin view of this emoji. + const emojiRes = await fetchWithBQ({ + url: `/api/v1/admin/custom_emojis`, + params: { + filter: `domain:${domain},shortcode:${emoji.shortcode}`, + limit: 1 + } + }); + if (emojiRes.error) { + errors.push(emojiRes.error); + } else { + // Got it! + withIDs.push(emojiRes.data as CustomEmoji); + } + }); + + if (errors.length !== 0) { + return { + error: { + status: 400, + statusText: 'Bad Request', + data: {"error":`One or more errors fetching custom emojis: ${errors}`}, + }, + }; + } + + // Return our ID'd + // emojis list. + return { + data: { + type, + domain, + list: withIDs, + } + }; + } + }), + + patchRemoteEmojis: build.mutation({ + async queryFn({ action, ...formData }, _api, _extraOpts, fetchWithBQ) { + const data: CustomEmoji[] = []; + const errors: FetchBaseQueryError[] = []; + + formData.selectEmoji.forEach(async(emoji: CustomEmoji) => { + let body = { + type: action, + shortcode: "", + category: "", + }; + + if (action == "copy") { + body.shortcode = emoji.shortcode; + if (formData.category.trim().length != 0) { + body.category = formData.category; + } + } + + const emojiRes = await fetchWithBQ({ + method: "PATCH", + url: `/api/v1/admin/custom_emojis/${emoji.id}`, + asForm: true, + body: body + }); + if (emojiRes.error) { + errors.push(emojiRes.error); + } else { + // Got it! + data.push(emojiRes.data as CustomEmoji); + } + }); + + if (errors.length !== 0) { + return { + error: { + status: 400, + statusText: 'Bad Request', + data: {"error":`One or more errors patching custom emojis: ${errors}`}, + }, + }; + } + + return { data }; + }, + invalidatesTags: () => [{ type: "Emoji", id: "LIST" }] + }) + }) +}); + +/** + * List all custom emojis uploaded on our local instance. + */ +const useListEmojiQuery = extended.useListEmojiQuery; + +/** + * Get a single custom emoji uploaded on our local instance, by its ID. + */ +const useGetEmojiQuery = extended.useGetEmojiQuery; + +/** + * Add a new custom emoji by uploading it to our local instance. + */ +const useAddEmojiMutation = extended.useAddEmojiMutation; + +/** + * Edit an existing custom emoji that's already been uploaded to our local instance. + */ +const useEditEmojiMutation = extended.useEditEmojiMutation; + +/** + * Delete a single custom emoji from our local instance using its id. + */ +const useDeleteEmojiMutation = extended.useDeleteEmojiMutation; + +/** + * "Steal this look" function for selecting remote emoji from a status or account. + */ +const useSearchItemForEmojiMutation = extended.useSearchItemForEmojiMutation; + +/** + * Update/patch a bunch of remote emojis. + */ +const usePatchRemoteEmojisMutation = extended.usePatchRemoteEmojisMutation; + +export { + useListEmojiQuery, + useGetEmojiQuery, + useAddEmojiMutation, + useEditEmojiMutation, + useDeleteEmojiMutation, + useSearchItemForEmojiMutation, + usePatchRemoteEmojisMutation, +}; diff --git a/web/source/settings/lib/query/admin/domain-permissions/export.ts b/web/source/settings/lib/query/admin/domain-permissions/export.ts new file mode 100644 index 000000000..b6ef560f4 --- /dev/null +++ b/web/source/settings/lib/query/admin/domain-permissions/export.ts @@ -0,0 +1,155 @@ +/* + 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 fileDownload from "js-file-download"; +import { unparse as csvUnparse } from "papaparse"; + +import { gtsApi } from "../../gts-api"; +import { RootState } from "../../../../redux/store"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; +import { DomainPerm, ExportDomainPermsParams } from "../../../types/domain-permission"; + +interface _exportProcess { + transformEntry: (_entry: DomainPerm) => any; + stringify: (_list: any[]) => string; + extension: string; + mime: string; +} + +/** + * Derive process functions and metadata + * from provided export request form. + * + * @param formData + * @returns + */ +function exportProcess(formData: ExportDomainPermsParams): _exportProcess { + if (formData.exportType == "json") { + return { + transformEntry: (entry) => ({ + domain: entry.domain, + public_comment: entry.public_comment, + obfuscate: entry.obfuscate + }), + stringify: (list) => JSON.stringify(list), + extension: ".json", + mime: "application/json" + }; + } + + if (formData.exportType == "csv") { + return { + transformEntry: (entry) => [ + entry.domain, // #domain + "suspend", // #severity + false, // #reject_media + false, // #reject_reports + entry.public_comment, // #public_comment + entry.obfuscate ?? false // #obfuscate + ], + stringify: (list) => csvUnparse({ + fields: [ + "#domain", + "#severity", + "#reject_media", + "#reject_reports", + "#public_comment", + "#obfuscate", + ], + data: list + }), + extension: ".csv", + mime: "text/csv" + }; + } + + // Fall back to plain text export. + return { + transformEntry: (entry) => entry.domain, + stringify: (list) => list.join("\n"), + extension: ".txt", + mime: "text/plain" + }; +} + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + exportDomainList: build.mutation<string | null, ExportDomainPermsParams>({ + async queryFn(formData, api, _extraOpts, fetchWithBQ) { + // Fetch domain perms from relevant endpoint. + // We could have used 'useDomainBlocksQuery' + // or 'useDomainAllowsQuery' for this, but + // we want the untransformed array version. + const permsRes = await fetchWithBQ({ url: `/api/v1/admin/domain_${formData.permType}s` }); + if (permsRes.error) { + return { error: permsRes.error as FetchBaseQueryError }; + } + + // Process perms into desired export format. + const process = exportProcess(formData); + const transformed = (permsRes.data as DomainPerm[]).map(process.transformEntry); + const exportAsString = process.stringify(transformed); + + if (formData.action == "export") { + // Data will just be exported + // to the domains text field. + return { data: exportAsString }; + } + + // File export has been requested. + // 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 domain = new URL(instanceUrl).host; + const date = new Date(); + const filename = [ + domain, + "blocklist", + date.getFullYear(), + (date.getMonth() + 1).toString().padStart(2, "0"), + date.getDate().toString().padStart(2, "0"), + ].join("-"); + + fileDownload( + exportAsString, + filename + process.extension, + process.mime + ); + + // js-file-download handles the + // nitty gritty for us, so we can + // just return null data. + return { data: null }; + } + }), + }) +}); + +/** + * Makes a GET to `/api/v1/admin/domain_{perm_type}s` + * and exports the result in the requested format. + * + * Return type will be string if `action` is "export", + * else it will be null, since the file downloader handles + * the rest of the request then. + */ +const useExportDomainListMutation = extended.useExportDomainListMutation; + +export { useExportDomainListMutation }; diff --git a/web/source/settings/lib/query/admin/domain-permissions/get.ts b/web/source/settings/lib/query/admin/domain-permissions/get.ts new file mode 100644 index 000000000..3e27742d4 --- /dev/null +++ b/web/source/settings/lib/query/admin/domain-permissions/get.ts @@ -0,0 +1,56 @@ +/* + 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 { gtsApi } from "../../gts-api"; + +import type { DomainPerm, MappedDomainPerms } from "../../../types/domain-permission"; +import { listToKeyedObject } from "../../transforms"; + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + domainBlocks: build.query<MappedDomainPerms, void>({ + query: () => ({ + url: `/api/v1/admin/domain_blocks` + }), + transformResponse: listToKeyedObject<DomainPerm>("domain"), + }), + + domainAllows: build.query<MappedDomainPerms, void>({ + query: () => ({ + url: `/api/v1/admin/domain_allows` + }), + transformResponse: listToKeyedObject<DomainPerm>("domain"), + }), + }), +}); + +/** + * Get admin view of all explicitly blocked domains. + */ +const useDomainBlocksQuery = extended.useDomainBlocksQuery; + +/** + * Get admin view of all explicitly allowed domains. + */ +const useDomainAllowsQuery = extended.useDomainAllowsQuery; + +export { + useDomainBlocksQuery, + useDomainAllowsQuery, +}; diff --git a/web/source/settings/lib/query/admin/domain-permissions/import.ts b/web/source/settings/lib/query/admin/domain-permissions/import.ts new file mode 100644 index 000000000..dde488625 --- /dev/null +++ b/web/source/settings/lib/query/admin/domain-permissions/import.ts @@ -0,0 +1,140 @@ +/* + 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 { replaceCacheOnMutation } from "../../query-modifiers"; +import { gtsApi } from "../../gts-api"; + +import { + type DomainPerm, + type ImportDomainPermsParams, + type MappedDomainPerms, + isDomainPermInternalKey, +} from "../../../types/domain-permission"; +import { listToKeyedObject } from "../../transforms"; + +/** + * Builds up a map function that can be applied to a + * list of DomainPermission entries in order to normalize + * them before submission to the API. + * @param formData + * @returns + */ +function importEntriesProcessor(formData: ImportDomainPermsParams): (_entry: DomainPerm) => DomainPerm { + let processingFuncs: { (_entry: DomainPerm): void; }[] = []; + + // Override each obfuscate entry if necessary. + if (formData.obfuscate !== undefined) { + const obfuscateEntry = (entry: DomainPerm) => { + entry.obfuscate = formData.obfuscate; + }; + processingFuncs.push(obfuscateEntry); + } + + // Check whether we need to append or replace + // private_comment and public_comment. + ["private_comment","public_comment"].forEach((commentType) => { + let text = formData.commentType?.trim(); + if (!text) { + return; + } + + switch(formData[`${commentType}_behavior`]) { + case "append": + const appendComment = (entry: DomainPerm) => { + if (entry.commentType == undefined) { + entry.commentType = text; + } else { + entry.commentType = [entry.commentType, text].join("\n"); + } + }; + + processingFuncs.push(appendComment); + break; + case "replace": + const replaceComment = (entry: DomainPerm) => { + entry.commentType = text; + }; + + processingFuncs.push(replaceComment); + break; + } + }); + + return function process(entry) { + // Call all the assembled processing functions. + processingFuncs.forEach((f) => f(entry)); + + // Unset all internal processing keys + // and any undefined keys on this entry. + Object.entries(entry).forEach(([key, val]: [keyof DomainPerm, any]) => { + if (val == undefined || isDomainPermInternalKey(key)) { + delete entry[key]; + } + }); + + return entry; + }; +} + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + importDomainPerms: build.mutation<MappedDomainPerms, ImportDomainPermsParams>({ + query: (formData) => { + // Add/replace comments, remove internal keys. + const process = importEntriesProcessor(formData); + const domains = formData.domains.map(process); + + return { + method: "POST", + url: `/api/v1/admin/domain_${formData.permType}s?import=true`, + asForm: true, + discardEmpty: true, + body: { + import: true, + domains: new Blob( + [JSON.stringify(domains)], + { type: "application/json" }, + ), + } + }; + }, + transformResponse: listToKeyedObject<DomainPerm>("domain"), + ...replaceCacheOnMutation((formData: ImportDomainPermsParams) => { + // Query names for blocks and allows are like + // `domainBlocks` and `domainAllows`, so we need + // to convert `block` -> `Block` or `allow` -> `Allow` + // to do proper cache invalidation. + const permType = + formData.permType.charAt(0).toUpperCase() + + formData.permType.slice(1); + return `domain${permType}s`; + }), + }) + }) +}); + +/** + * POST domain permissions to /api/v1/admin/domain_{permType}s. + * Returns the newly created permissions. + */ +const useImportDomainPermsMutation = extended.useImportDomainPermsMutation; + +export { + useImportDomainPermsMutation, +}; diff --git a/web/source/settings/lib/query/admin/domain-permissions/process.ts b/web/source/settings/lib/query/admin/domain-permissions/process.ts new file mode 100644 index 000000000..017d02bb4 --- /dev/null +++ b/web/source/settings/lib/query/admin/domain-permissions/process.ts @@ -0,0 +1,163 @@ +/* + 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 { + ParseConfig as CSVParseConfig, + parse as csvParse +} from "papaparse"; +import { nanoid } from "nanoid"; + +import { isValidDomainPermission, hasBetterScope } from "../../../util/domain-permission"; +import { gtsApi } from "../../gts-api"; + +import { + isDomainPerms, + type DomainPerm, +} from "../../../types/domain-permission"; + +/** + * Parse the given string of domain permissions and return it as an array. + * Accepts input as a JSON array string, a CSV, or newline-separated domain names. + * Will throw an error if input is invalid. + * @param list + * @returns + * @throws + */ +function parseDomainList(list: string): DomainPerm[] { + if (list.startsWith("[")) { + // Assume JSON array. + const data = JSON.parse(list); + if (!isDomainPerms(data)) { + throw "parsed JSON was not array of DomainPermission"; + } + + return data; + } else if (list.startsWith("#domain") || list.startsWith("domain,severity")) { + // Assume Mastodon-style CSV. + const csvParseCfg: CSVParseConfig = { + header: true, + // Remove leading '#' if present. + transformHeader: (header) => header.startsWith("#") ? header.slice(1) : header, + skipEmptyLines: true, + dynamicTyping: true + }; + + const { data, errors } = csvParse(list, csvParseCfg); + if (errors.length > 0) { + let error = ""; + errors.forEach((err) => { + error += `${err.message} (line ${err.row})`; + }); + throw error; + } + + if (!isDomainPerms(data)) { + throw "parsed CSV was not array of DomainPermission"; + } + + return data; + } else { + // Fallback: assume newline-separated + // list of simple domain strings. + const data: DomainPerm[] = []; + list.split("\n").forEach((line) => { + let domain = line.trim(); + let valid = true; + + if (domain.startsWith("http")) { + try { + domain = new URL(domain).hostname; + } catch (e) { + valid = false; + } + } + + if (domain.length > 0) { + data.push({ domain, valid }); + } + }); + + return data; + } +} + +function deduplicateDomainList(list: DomainPerm[]): DomainPerm[] { + let domains = new Set(); + return list.filter((entry) => { + if (domains.has(entry.domain)) { + return false; + } else { + domains.add(entry.domain); + return true; + } + }); +} + +function validateDomainList(list: DomainPerm[]) { + list.forEach((entry) => { + if (entry.domain.startsWith("*.")) { + // A domain permission always includes + // all subdomains, wildcard is meaningless here + entry.domain = entry.domain.slice(2); + } + + entry.valid = (entry.valid !== false) && isValidDomainPermission(entry.domain); + if (entry.valid) { + entry.suggest = hasBetterScope(entry.domain); + } + entry.checked = entry.valid; + }); + + return list; +} + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + processDomainPermissions: build.mutation<DomainPerm[], any>({ + async queryFn(formData, _api, _extraOpts, _fetchWithBQ) { + if (formData.domains == undefined || formData.domains.length == 0) { + throw "No domains entered"; + } + + // Parse + tidy up the form data. + const permissions = parseDomainList(formData.domains); + const deduped = deduplicateDomainList(permissions); + const validated = validateDomainList(deduped); + + validated.forEach((entry) => { + // Set unique key that stays stable + // even if domain gets modified by user. + entry.key = nanoid(); + }); + + return { data: validated }; + } + }) + }) +}); + +/** + * useProcessDomainPermissionsMutation uses the RTK Query API without actually + * hitting the GtS API, it's purely an internal function for our own convenience. + * + * It returns the validated and deduplicated domain permission list. + */ +const useProcessDomainPermissionsMutation = extended.useProcessDomainPermissionsMutation; + +export { useProcessDomainPermissionsMutation }; diff --git a/web/source/settings/lib/query/admin/domain-permissions/update.ts b/web/source/settings/lib/query/admin/domain-permissions/update.ts new file mode 100644 index 000000000..a6b4b2039 --- /dev/null +++ b/web/source/settings/lib/query/admin/domain-permissions/update.ts @@ -0,0 +1,109 @@ +/* + 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 { gtsApi } from "../../gts-api"; + +import { + replaceCacheOnMutation, + removeFromCacheOnMutation, +} from "../../query-modifiers"; +import { listToKeyedObject } from "../../transforms"; +import type { + DomainPerm, + MappedDomainPerms +} from "../../../types/domain-permission"; + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + addDomainBlock: build.mutation<MappedDomainPerms, any>({ + query: (formData) => ({ + method: "POST", + url: `/api/v1/admin/domain_blocks`, + asForm: true, + body: formData, + discardEmpty: true + }), + transformResponse: listToKeyedObject<DomainPerm>("domain"), + ...replaceCacheOnMutation("domainBlocks"), + }), + + addDomainAllow: build.mutation<MappedDomainPerms, any>({ + query: (formData) => ({ + method: "POST", + url: `/api/v1/admin/domain_allows`, + asForm: true, + body: formData, + discardEmpty: true + }), + transformResponse: listToKeyedObject<DomainPerm>("domain"), + ...replaceCacheOnMutation("domainAllows") + }), + + removeDomainBlock: build.mutation<DomainPerm, string>({ + query: (id) => ({ + method: "DELETE", + url: `/api/v1/admin/domain_blocks/${id}`, + }), + ...removeFromCacheOnMutation("domainBlocks", { + key: (_draft, newData) => { + return newData.domain; + } + }) + }), + + removeDomainAllow: build.mutation<DomainPerm, string>({ + query: (id) => ({ + method: "DELETE", + url: `/api/v1/admin/domain_allows/${id}`, + }), + ...removeFromCacheOnMutation("domainAllows", { + key: (_draft, newData) => { + return newData.domain; + } + }) + }), + }), +}); + +/** + * Add a single domain permission (block) by POSTing to `/api/v1/admin/domain_blocks`. + */ +const useAddDomainBlockMutation = extended.useAddDomainBlockMutation; + +/** + * Add a single domain permission (allow) by POSTing to `/api/v1/admin/domain_allows`. + */ +const useAddDomainAllowMutation = extended.useAddDomainAllowMutation; + +/** + * Remove a single domain permission (block) by DELETEing to `/api/v1/admin/domain_blocks/{id}`. + */ +const useRemoveDomainBlockMutation = extended.useRemoveDomainBlockMutation; + +/** + * Remove a single domain permission (allow) by DELETEing to `/api/v1/admin/domain_allows/{id}`. + */ +const useRemoveDomainAllowMutation = extended.useRemoveDomainAllowMutation; + +export { + useAddDomainBlockMutation, + useAddDomainAllowMutation, + useRemoveDomainBlockMutation, + useRemoveDomainAllowMutation +}; diff --git a/web/source/settings/lib/query/admin/import-export.js b/web/source/settings/lib/query/admin/import-export.js deleted file mode 100644 index 9a04438c2..000000000 --- a/web/source/settings/lib/query/admin/import-export.js +++ /dev/null @@ -1,264 +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/>. -*/ - -const Promise = require("bluebird"); -const fileDownload = require("js-file-download"); -const csv = require("papaparse"); -const { nanoid } = require("nanoid"); - -const { isValidDomainBlock, hasBetterScope } = require("../../domain-block"); - -const { - replaceCacheOnMutation, - domainListToObject, - unwrapRes -} = require("../lib"); - -function parseDomainList(list) { - if (list[0] == "[") { - return JSON.parse(list); - } else if (list.startsWith("#domain")) { // Mastodon CSV - const { data, errors } = csv.parse(list, { - header: true, - transformHeader: (header) => header.slice(1), // removes starting '#' - skipEmptyLines: true, - dynamicTyping: true - }); - - if (errors.length > 0) { - let error = ""; - errors.forEach((err) => { - error += `${err.message} (line ${err.row})`; - }); - throw error; - } - - return data; - } else { - return list.split("\n").map((line) => { - let domain = line.trim(); - let valid = true; - if (domain.startsWith("http")) { - try { - domain = new URL(domain).hostname; - } catch (e) { - valid = false; - } - } - return domain.length > 0 - ? { domain, valid } - : null; - }).filter((a) => a); // not `null` - } -} - -function validateDomainList(list) { - list.forEach((entry) => { - if (entry.domain.startsWith("*.")) { - // domain block always includes all subdomains, wildcard is meaningless here - entry.domain = entry.domain.slice(2); - } - - entry.valid = (entry.valid !== false) && isValidDomainBlock(entry.domain); - if (entry.valid) { - entry.suggest = hasBetterScope(entry.domain); - } - entry.checked = entry.valid; - }); - - return list; -} - -function deduplicateDomainList(list) { - let domains = new Set(); - return list.filter((entry) => { - if (domains.has(entry.domain)) { - return false; - } else { - domains.add(entry.domain); - return true; - } - }); -} - -module.exports = (build) => ({ - processDomainList: build.mutation({ - queryFn: (formData) => { - return Promise.try(() => { - if (formData.domains == undefined || formData.domains.length == 0) { - throw "No domains entered"; - } - return parseDomainList(formData.domains); - }).then((parsed) => { - return deduplicateDomainList(parsed); - }).then((deduped) => { - return validateDomainList(deduped); - }).then((data) => { - data.forEach((entry) => { - entry.key = nanoid(); // unique id that stays stable even if domain gets modified by user - }); - return { data }; - }).catch((e) => { - return { error: e.toString() }; - }); - } - }), - exportDomainList: build.mutation({ - queryFn: (formData, api, _extraOpts, baseQuery) => { - let process; - - if (formData.exportType == "json") { - process = { - transformEntry: (entry) => ({ - domain: entry.domain, - public_comment: entry.public_comment, - obfuscate: entry.obfuscate - }), - stringify: (list) => JSON.stringify(list), - extension: ".json", - mime: "application/json" - }; - } else if (formData.exportType == "csv") { - process = { - transformEntry: (entry) => [ - entry.domain, - "suspend", // severity - false, // reject_media - false, // reject_reports - entry.public_comment, - entry.obfuscate ?? false - ], - stringify: (list) => csv.unparse({ - fields: "#domain,#severity,#reject_media,#reject_reports,#public_comment,#obfuscate".split(","), - data: list - }), - extension: ".csv", - mime: "text/csv" - }; - } else { - process = { - transformEntry: (entry) => entry.domain, - stringify: (list) => list.join("\n"), - extension: ".txt", - mime: "text/plain" - }; - } - - return Promise.try(() => { - return baseQuery({ - url: `/api/v1/admin/domain_blocks` - }); - }).then(unwrapRes).then((blockedInstances) => { - return blockedInstances.map(process.transformEntry); - }).then((exportList) => { - return process.stringify(exportList); - }).then((exportAsString) => { - if (formData.action == "export") { - return { - data: exportAsString - }; - } else if (formData.action == "export-file") { - let domain = new URL(api.getState().oauth.instance).host; - let date = new Date(); - - let filename = [ - domain, - "blocklist", - date.getFullYear(), - (date.getMonth() + 1).toString().padStart(2, "0"), - date.getDate().toString().padStart(2, "0"), - ].join("-"); - - fileDownload( - exportAsString, - filename + process.extension, - process.mime - ); - } - return { data: null }; - }).catch((e) => { - return { error: e }; - }); - } - }), - importDomainList: build.mutation({ - query: (formData) => { - const { domains } = formData; - - // add/replace comments, obfuscation data - let process = entryProcessor(formData); - domains.forEach((entry) => { - process(entry); - }); - - return { - method: "POST", - url: `/api/v1/admin/domain_blocks?import=true`, - asForm: true, - discardEmpty: true, - body: { - domains: new Blob([JSON.stringify(domains)], { type: "application/json" }) - } - }; - }, - transformResponse: domainListToObject, - ...replaceCacheOnMutation("instanceBlocks") - }) -}); - -const internalKeys = new Set("key,suggest,valid,checked".split(",")); -function entryProcessor(formData) { - let funcs = []; - - ["private_comment", "public_comment"].forEach((type) => { - let text = formData[type].trim(); - - if (text.length > 0) { - let behavior = formData[`${type}_behavior`]; - - if (behavior == "append") { - funcs.push(function appendComment(entry) { - if (entry[type] == undefined) { - entry[type] = text; - } else { - entry[type] = [entry[type], text].join("\n"); - } - }); - } else if (behavior == "replace") { - funcs.push(function replaceComment(entry) { - entry[type] = text; - }); - } - } - }); - - return function process(entry) { - funcs.forEach((func) => { - func(entry); - }); - - entry.obfuscate = formData.obfuscate; - - Object.entries(entry).forEach(([key, val]) => { - if (internalKeys.has(key) || val == undefined) { - delete entry[key]; - } - }); - }; -}
\ No newline at end of file diff --git a/web/source/settings/lib/query/admin/index.js b/web/source/settings/lib/query/admin/index.js deleted file mode 100644 index 7a55389d3..000000000 --- a/web/source/settings/lib/query/admin/index.js +++ /dev/null @@ -1,165 +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/>. -*/ - -const { - replaceCacheOnMutation, - removeFromCacheOnMutation, - domainListToObject, - idListToObject -} = require("../lib"); -const { gtsApi } = require("../gts-api"); - -const endpoints = (build) => ({ - updateInstance: build.mutation({ - query: (formData) => ({ - method: "PATCH", - url: `/api/v1/instance`, - asForm: true, - body: formData, - discardEmpty: true - }), - ...replaceCacheOnMutation("instance") - }), - mediaCleanup: build.mutation({ - query: (days) => ({ - method: "POST", - url: `/api/v1/admin/media_cleanup`, - params: { - remote_cache_days: days - } - }) - }), - instanceKeysExpire: build.mutation({ - query: (domain) => ({ - method: "POST", - url: `/api/v1/admin/domain_keys_expire`, - params: { - domain: domain - } - }) - }), - instanceBlocks: build.query({ - query: () => ({ - url: `/api/v1/admin/domain_blocks` - }), - transformResponse: domainListToObject - }), - addInstanceBlock: build.mutation({ - query: (formData) => ({ - method: "POST", - url: `/api/v1/admin/domain_blocks`, - asForm: true, - body: formData, - discardEmpty: true - }), - transformResponse: (data) => { - return { - [data.domain]: data - }; - }, - ...replaceCacheOnMutation("instanceBlocks") - }), - removeInstanceBlock: build.mutation({ - query: (id) => ({ - method: "DELETE", - url: `/api/v1/admin/domain_blocks/${id}`, - }), - ...removeFromCacheOnMutation("instanceBlocks", { - findKey: (_draft, newData) => { - return newData.domain; - } - }) - }), - getAccount: build.query({ - query: (id) => ({ - url: `/api/v1/accounts/${id}` - }), - providesTags: (_, __, id) => [{ type: "Account", id }] - }), - actionAccount: build.mutation({ - query: ({ id, action, reason }) => ({ - method: "POST", - url: `/api/v1/admin/accounts/${id}/action`, - asForm: true, - body: { - type: action, - text: reason - } - }), - invalidatesTags: (_, __, { id }) => [{ type: "Account", id }] - }), - searchAccount: build.mutation({ - query: (username) => ({ - url: `/api/v2/search?q=${encodeURIComponent(username)}&resolve=true` - }), - transformResponse: (res) => { - return res.accounts ?? []; - } - }), - instanceRules: build.query({ - query: () => ({ - url: `/api/v1/admin/instance/rules` - }), - transformResponse: idListToObject - }), - addInstanceRule: build.mutation({ - query: (formData) => ({ - method: "POST", - url: `/api/v1/admin/instance/rules`, - asForm: true, - body: formData, - discardEmpty: true - }), - transformResponse: (data) => { - return { - [data.id]: data - }; - }, - ...replaceCacheOnMutation("instanceRules") - }), - updateInstanceRule: build.mutation({ - query: ({ id, ...edit }) => ({ - method: "PATCH", - url: `/api/v1/admin/instance/rules/${id}`, - asForm: true, - body: edit, - discardEmpty: true - }), - transformResponse: (data) => { - return { - [data.id]: data - }; - }, - ...replaceCacheOnMutation("instanceRules") - }), - deleteInstanceRule: build.mutation({ - query: (id) => ({ - method: "DELETE", - url: `/api/v1/admin/instance/rules/${id}` - }), - ...removeFromCacheOnMutation("instanceRules", { - findKey: (_draft, rule) => rule.id - }) - }), - ...require("./import-export")(build), - ...require("./custom-emoji")(build), - ...require("./reports")(build) -}); - -module.exports = gtsApi.injectEndpoints({ endpoints });
\ No newline at end of file diff --git a/web/source/settings/lib/query/admin/index.ts b/web/source/settings/lib/query/admin/index.ts new file mode 100644 index 000000000..e61179216 --- /dev/null +++ b/web/source/settings/lib/query/admin/index.ts @@ -0,0 +1,148 @@ +/* + 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 { replaceCacheOnMutation, removeFromCacheOnMutation } from "../query-modifiers"; +import { gtsApi } from "../gts-api"; +import { listToKeyedObject } from "../transforms"; + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + updateInstance: build.mutation({ + query: (formData) => ({ + method: "PATCH", + url: `/api/v1/instance`, + asForm: true, + body: formData, + discardEmpty: true + }), + ...replaceCacheOnMutation("instanceV1"), + }), + + mediaCleanup: build.mutation({ + query: (days) => ({ + method: "POST", + url: `/api/v1/admin/media_cleanup`, + params: { + remote_cache_days: days + } + }) + }), + + instanceKeysExpire: build.mutation({ + query: (domain) => ({ + method: "POST", + url: `/api/v1/admin/domain_keys_expire`, + params: { + domain: domain + } + }) + }), + + getAccount: build.query({ + query: (id) => ({ + url: `/api/v1/accounts/${id}` + }), + providesTags: (_, __, id) => [{ type: "Account", id }] + }), + + actionAccount: build.mutation({ + query: ({ id, action, reason }) => ({ + method: "POST", + url: `/api/v1/admin/accounts/${id}/action`, + asForm: true, + body: { + type: action, + text: reason + } + }), + invalidatesTags: (_, __, { id }) => [{ type: "Account", id }] + }), + + searchAccount: build.mutation({ + query: (username) => ({ + url: `/api/v2/search?q=${encodeURIComponent(username)}&resolve=true` + }), + transformResponse: (res) => { + return res.accounts ?? []; + } + }), + + instanceRules: build.query({ + query: () => ({ + url: `/api/v1/admin/instance/rules` + }), + transformResponse: listToKeyedObject<any>("id") + }), + + addInstanceRule: build.mutation({ + query: (formData) => ({ + method: "POST", + url: `/api/v1/admin/instance/rules`, + asForm: true, + body: formData, + discardEmpty: true + }), + transformResponse: (data) => { + return { + [data.id]: data + }; + }, + ...replaceCacheOnMutation("instanceRules"), + }), + + updateInstanceRule: build.mutation({ + query: ({ id, ...edit }) => ({ + method: "PATCH", + url: `/api/v1/admin/instance/rules/${id}`, + asForm: true, + body: edit, + discardEmpty: true + }), + transformResponse: (data) => { + return { + [data.id]: data + }; + }, + ...replaceCacheOnMutation("instanceRules"), + }), + + deleteInstanceRule: build.mutation({ + query: (id) => ({ + method: "DELETE", + url: `/api/v1/admin/instance/rules/${id}` + }), + ...removeFromCacheOnMutation("instanceRules", { + key: (_draft, rule) => rule.id, + }) + }) + }) +}); + +export const { + useUpdateInstanceMutation, + useMediaCleanupMutation, + useInstanceKeysExpireMutation, + useGetAccountQuery, + useActionAccountMutation, + useSearchAccountMutation, + useInstanceRulesQuery, + useAddInstanceRuleMutation, + useUpdateInstanceRuleMutation, + useDeleteInstanceRuleMutation, +} = extended; diff --git a/web/source/settings/lib/query/admin/reports.js b/web/source/settings/lib/query/admin/reports.js deleted file mode 100644 index 1c45bb7bc..000000000 --- a/web/source/settings/lib/query/admin/reports.js +++ /dev/null @@ -1,51 +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/>. -*/ - -module.exports = (build) => ({ - listReports: build.query({ - query: (params = {}) => ({ - url: "/api/v1/admin/reports", - params: { - limit: 100, - ...params - } - }), - providesTags: ["Reports"] - }), - - getReport: build.query({ - query: (id) => ({ - url: `/api/v1/admin/reports/${id}` - }), - providesTags: (res, error, id) => [{ type: "Reports", id }] - }), - - resolveReport: build.mutation({ - query: (formData) => ({ - url: `/api/v1/admin/reports/${formData.id}/resolve`, - method: "POST", - asForm: true, - body: formData - }), - invalidatesTags: (res) => - res - ? [{ type: "Reports", id: "LIST" }, { type: "Reports", id: res.id }] - : [{ type: "Reports", id: "LIST" }] - }) -});
\ No newline at end of file diff --git a/web/source/settings/lib/query/admin/reports/index.ts b/web/source/settings/lib/query/admin/reports/index.ts new file mode 100644 index 000000000..253e8238c --- /dev/null +++ b/web/source/settings/lib/query/admin/reports/index.ts @@ -0,0 +1,83 @@ +/* + 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 { gtsApi } from "../../gts-api"; + +import type { + AdminReport, + AdminReportListParams, + AdminReportResolveParams, +} from "../../../types/report"; + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + listReports: build.query<AdminReport[], AdminReportListParams | void>({ + query: (params) => ({ + url: "/api/v1/admin/reports", + params: { + // Override provided limit. + limit: 100, + ...params + } + }), + providesTags: ["Reports"] + }), + + getReport: build.query<AdminReport, string>({ + query: (id) => ({ + url: `/api/v1/admin/reports/${id}` + }), + providesTags: (_res, _error, id) => [{ type: "Reports", id }] + }), + + resolveReport: build.mutation<AdminReport, AdminReportResolveParams>({ + query: (formData) => ({ + url: `/api/v1/admin/reports/${formData.id}/resolve`, + method: "POST", + asForm: true, + body: formData + }), + invalidatesTags: (res) => + res + ? [{ type: "Reports", id: "LIST" }, { type: "Reports", id: res.id }] + : [{ type: "Reports", id: "LIST" }] + }) + }) +}); + +/** + * List reports received on this instance, filtered using given parameters. + */ +const useListReportsQuery = extended.useListReportsQuery; + +/** + * Get a single report by its ID. + */ +const useGetReportQuery = extended.useGetReportQuery; + +/** + * Mark an open report as resolved. + */ +const useResolveReportMutation = extended.useResolveReportMutation; + +export { + useListReportsQuery, + useGetReportQuery, + useResolveReportMutation, +}; diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts index 9e043137c..a07f5ff1e 100644 --- a/web/source/settings/lib/query/gts-api.ts +++ b/web/source/settings/lib/query/gts-api.ts @@ -26,6 +26,7 @@ import type { import { serialize as serializeForm } from "object-to-formdata"; import type { RootState } from '../../redux/store'; +import { InstanceV1 } from '../types/instance'; /** * GTSFetchArgs extends standard FetchArgs used by @@ -72,7 +73,7 @@ const gtsBaseQuery: BaseQueryFn< const { instanceUrl, token } = state.oauth; // Derive baseUrl dynamically. - let baseUrl: string; + let baseUrl: string | undefined; // Check if simple string baseUrl provided // as args, or if more complex args provided. @@ -137,8 +138,8 @@ export const gtsApi = createApi({ "Account", "InstanceRules", ], - endpoints: (builder) => ({ - instance: builder.query<any, void>({ + endpoints: (build) => ({ + instanceV1: build.query<InstanceV1, void>({ query: () => ({ url: `/api/v1/instance` }) @@ -146,4 +147,11 @@ export const gtsApi = createApi({ }) }); -export const { useInstanceQuery } = gtsApi; +/** + * Query /api/v1/instance to retrieve basic instance information. + * This endpoint does not require authentication/authorization. + * TODO: move this to ./instance. + */ +const useInstanceV1Query = gtsApi.useInstanceV1Query; + +export { useInstanceV1Query }; diff --git a/web/source/settings/lib/query/lib.js b/web/source/settings/lib/query/lib.js deleted file mode 100644 index 1025ca3a7..000000000 --- a/web/source/settings/lib/query/lib.js +++ /dev/null @@ -1,81 +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/>. -*/ - -const syncpipe = require("syncpipe"); -const { gtsApi } = require("./gts-api"); - -module.exports = { - unwrapRes(res) { - if (res.error != undefined) { - throw res.error; - } else { - return res.data; - } - }, - domainListToObject: (data) => { - // Turn flat Array into Object keyed by block's domain - return syncpipe(data, [ - (_) => _.map((entry) => [entry.domain, entry]), - (_) => Object.fromEntries(_) - ]); - }, - idListToObject: (data) => { - // Turn flat Array into Object keyed by entry id field - return syncpipe(data, [ - (_) => _.map((entry) => [entry.id, entry]), - (_) => Object.fromEntries(_) - ]); - }, - replaceCacheOnMutation: makeCacheMutation((draft, newData) => { - Object.assign(draft, newData); - }), - appendCacheOnMutation: makeCacheMutation((draft, newData) => { - draft.push(newData); - }), - spliceCacheOnMutation: makeCacheMutation((draft, newData, { key }) => { - draft.splice(key, 1); - }), - updateCacheOnMutation: makeCacheMutation((draft, newData, { key }) => { - draft[key] = newData; - }), - removeFromCacheOnMutation: makeCacheMutation((draft, newData, { key }) => { - delete draft[key]; - }), - editCacheOnMutation: makeCacheMutation((draft, newData, { update }) => { - update(draft, newData); - }) -}; - -// https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#pessimistic-updates -function makeCacheMutation(action) { - return function cacheMutation(queryName, { key, findKey, arg, ...opts } = {}) { - return { - onQueryStarted: (_, { dispatch, queryFulfilled }) => { - queryFulfilled.then(({ data: newData }) => { - dispatch(gtsApi.util.updateQueryData(queryName, arg, (draft) => { - if (findKey != undefined) { - key = findKey(draft, newData); - } - action(draft, newData, { key, ...opts }); - })); - }); - } - }; - }; -}
\ No newline at end of file diff --git a/web/source/settings/lib/query/oauth/index.ts b/web/source/settings/lib/query/oauth/index.ts index 9af2dd5fb..f62a29596 100644 --- a/web/source/settings/lib/query/oauth/index.ts +++ b/web/source/settings/lib/query/oauth/index.ts @@ -57,8 +57,8 @@ const SETTINGS_URL = (getSettingsURL()); // // 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>({ + endpoints: (build) => ({ + verifyCredentials: build.query<any, void>({ providesTags: (_res, error) => error == undefined ? ["Auth"] : [], async queryFn(_arg, api, _extraOpts, fetchWithBQ) { @@ -135,7 +135,7 @@ const extended = gtsApi.injectEndpoints({ } }), - authorizeFlow: builder.mutation({ + authorizeFlow: build.mutation({ async queryFn(formData, api, _extraOpts, fetchWithBQ) { const state = api.getState() as RootState; const oauthState = state.oauth; @@ -187,7 +187,7 @@ const extended = gtsApi.injectEndpoints({ return { data: null }; }, }), - logout: builder.mutation({ + logout: build.mutation({ queryFn: (_arg, api) => { api.dispatch(oauthRemove()); return { data: null }; @@ -201,4 +201,4 @@ export const { useVerifyCredentialsQuery, useAuthorizeFlowMutation, useLogoutMutation, -} = extended;
\ No newline at end of file +} = extended; diff --git a/web/source/settings/lib/query/query-modifiers.ts b/web/source/settings/lib/query/query-modifiers.ts new file mode 100644 index 000000000..d6bf0b6ae --- /dev/null +++ b/web/source/settings/lib/query/query-modifiers.ts @@ -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 { gtsApi } from "./gts-api"; + +import type { + Action, + CacheMutation, +} from "../types/query"; + +import { NoArg } from "../types/query"; + +/** + * Cache mutation creator for pessimistic updates. + * + * Feed it a function that you want to perform on the + * given draft and updated data, using the given parameters. + * + * https://redux-toolkit.js.org/rtk-query/api/createApi#onquerystarted + * https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#pessimistic-updates + */ +function makeCacheMutation(action: Action): CacheMutation { + return function cacheMutation( + queryName: string | ((_arg: any) => string), + { key } = {}, + ) { + return { + onQueryStarted: async(mutationData, { dispatch, queryFulfilled }) => { + // queryName might be a function that returns + // a query name; trigger it if so. The returned + // queryName has to match one of the API endpoints + // we've defined. So if we have endpoints called + // (for example) `instanceV1` and `getPosts` then + // the queryName provided here has to line up with + // one of those in order to actually do anything. + if (typeof queryName !== "string") { + queryName = queryName(mutationData); + } + + if (queryName == "") { + throw ( + "provided queryName resolved to an empty string;" + + "double check your mutation definition!" + ); + } + + try { + // Wait for the mutation to finish (this + // is why it's a pessimistic update). + const { data: newData } = await queryFulfilled; + + // In order for `gtsApi.util.updateQueryData` to + // actually do something within a dispatch, the + // first two arguments passed into it have to line + // up with arguments that were used earlier to + // fetch the data whose cached version we're now + // trying to modify. + // + // So, if we earlier fetched all reports with + // queryName `getReports`, and arg `undefined`, + // then we now need match those parameters in + // `updateQueryData` in order to modify the cache. + // + // If you pass something like `null` or `""` here + // instead, then the cache will not get modified! + // Redux will just quietly discard the thunk action. + dispatch( + gtsApi.util.updateQueryData(queryName as any, NoArg, (draft) => { + if (key != undefined && typeof key !== "string") { + key = key(draft, newData); + } + action(draft, newData, { key }); + }) + ); + } catch (e) { + // eslint-disable-next-line no-console + console.error(`rolling back pessimistic update of ${queryName}: ${e}`); + } + } + }; + }; +} + +/** + * + */ +const replaceCacheOnMutation: CacheMutation = makeCacheMutation((draft, newData, _params) => { + Object.assign(draft, newData); +}); + +const appendCacheOnMutation: CacheMutation = makeCacheMutation((draft, newData, _params) => { + draft.push(newData); +}); + +const spliceCacheOnMutation: CacheMutation = makeCacheMutation((draft, _newData, { key }) => { + if (key === undefined) { + throw ("key undefined"); + } + + draft.splice(key, 1); +}); + +const updateCacheOnMutation: CacheMutation = makeCacheMutation((draft, newData, { key }) => { + if (key === undefined) { + throw ("key undefined"); + } + + if (typeof key !== "string") { + key = key(draft, newData); + } + + draft[key] = newData; +}); + +const removeFromCacheOnMutation: CacheMutation = makeCacheMutation((draft, newData, { key }) => { + if (key === undefined) { + throw ("key undefined"); + } + + if (typeof key !== "string") { + key = key(draft, newData); + } + + delete draft[key]; +}); + + +export { + replaceCacheOnMutation, + appendCacheOnMutation, + spliceCacheOnMutation, + updateCacheOnMutation, + removeFromCacheOnMutation, +}; diff --git a/web/source/settings/lib/query/transforms.ts b/web/source/settings/lib/query/transforms.ts new file mode 100644 index 000000000..d915e0b13 --- /dev/null +++ b/web/source/settings/lib/query/transforms.ts @@ -0,0 +1,78 @@ +/* + 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/>. +*/ + +/** + * Map a list of items into an object. + * + * In the following example, a list of DomainPerms like the following: + * + * ```json + * [ + * { + * "domain": "example.org", + * "public_comment": "aaaaa!!" + * }, + * { + * "domain": "another.domain", + * "public_comment": "they are poo" + * } + * ] + * ``` + * + * Would be converted into an Object like the following: + * + * ```json + * { + * "example.org": { + * "domain": "example.org", + * "public_comment": "aaaaa!!" + * }, + * "another.domain": { + * "domain": "another.domain", + * "public_comment": "they are poo" + * }, + * } + * ``` + * + * If you pass a non-array type into this function it + * will be converted into an array first, as a treat. + * + * @example + * const extended = gtsApi.injectEndpoints({ + * endpoints: (build) => ({ + * getDomainBlocks: build.query<MappedDomainPerms, void>({ + * query: () => ({ + * url: `/api/v1/admin/domain_blocks` + * }), + * transformResponse: listToKeyedObject<DomainPerm>("domain"), + * }), + * }); + */ +export function listToKeyedObject<T>(key: keyof T) { + return (list: T[] | T): { [_ in keyof T]: T } => { + // Ensure we're actually + // dealing with an array. + if (!Array.isArray(list)) { + list = [list]; + } + + const entries = list.map((entry) => [entry[key], entry]); + return Object.fromEntries(entries); + }; +} diff --git a/web/source/settings/lib/query/user/index.ts b/web/source/settings/lib/query/user/index.ts index 751e38e5b..a7cdad2fd 100644 --- a/web/source/settings/lib/query/user/index.ts +++ b/web/source/settings/lib/query/user/index.ts @@ -17,12 +17,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -import { replaceCacheOnMutation } from "../lib"; +import { replaceCacheOnMutation } from "../query-modifiers"; import { gtsApi } from "../gts-api"; const extended = gtsApi.injectEndpoints({ - endpoints: (builder) => ({ - updateCredentials: builder.mutation({ + endpoints: (build) => ({ + updateCredentials: build.mutation({ query: (formData) => ({ method: "PATCH", url: `/api/v1/accounts/update_credentials`, @@ -32,7 +32,7 @@ const extended = gtsApi.injectEndpoints({ }), ...replaceCacheOnMutation("verifyCredentials") }), - passwordChange: builder.mutation({ + passwordChange: build.mutation({ query: (data) => ({ method: "POST", url: `/api/v1/user/password_change`, |