[feature] Allow import/export/creation of domain allows via admin panel (#2264)

* 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
This commit is contained in:
tobi
2023-10-17 12:46:06 +02:00
committed by GitHub
parent 48725f7228
commit 637f188ebe
77 changed files with 4154 additions and 1690 deletions

View File

@@ -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
};
}

View File

@@ -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,
};

View File

@@ -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 };

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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 };

View File

@@ -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
};

View File

@@ -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];
}
});
};
}

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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" }]
})
});

View File

@@ -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,
};

View File

@@ -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 };

View File

@@ -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 });
}));
});
}
};
};
}

View File

@@ -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;
} = extended;

View File

@@ -0,0 +1,150 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { 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,
};

View File

@@ -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);
};
}

View File

@@ -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`,