mirror of
https://github.com/superseriousbusiness/gotosocial
synced 2025-06-05 21:59:39 +02:00
[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:
@@ -17,11 +17,19 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
BoolFormInputHook,
|
||||
CreateHookNames,
|
||||
HookOpts,
|
||||
} from "./types";
|
||||
|
||||
const _default = false;
|
||||
module.exports = function useBoolInput({ name, Name }, { initialValue = _default }) {
|
||||
const [value, setValue] = React.useState(initialValue);
|
||||
export default function useBoolInput(
|
||||
{ name, Name }: CreateHookNames,
|
||||
{ initialValue = _default }: HookOpts<boolean>
|
||||
): BoolFormInputHook {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
function onChange(e) {
|
||||
setValue(e.target.checked);
|
||||
@@ -41,6 +49,7 @@ module.exports = function useBoolInput({ name, Name }, { initialValue = _default
|
||||
}
|
||||
], {
|
||||
name,
|
||||
Name: "",
|
||||
onChange,
|
||||
reset,
|
||||
value,
|
||||
@@ -48,4 +57,4 @@ module.exports = function useBoolInput({ name, Name }, { initialValue = _default
|
||||
hasChanged: () => value != initialValue,
|
||||
_default
|
||||
});
|
||||
};
|
||||
}
|
@@ -17,37 +17,58 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const syncpipe = require("syncpipe");
|
||||
const { createSlice } = require("@reduxjs/toolkit");
|
||||
const { enableMapSet } = require("immer");
|
||||
import {
|
||||
useReducer,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
enableMapSet(); // for use in reducers
|
||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||
|
||||
import type {
|
||||
Checkable,
|
||||
ChecklistInputHook,
|
||||
CreateHookNames,
|
||||
HookOpts,
|
||||
} from "./types";
|
||||
|
||||
// https://immerjs.github.io/immer/installation#pick-your-immer-version
|
||||
import { enableMapSet } from "immer";
|
||||
enableMapSet();
|
||||
|
||||
interface ChecklistState {
|
||||
entries: { [k: string]: Checkable },
|
||||
selectedEntries: Set<string>,
|
||||
}
|
||||
|
||||
const initialState: ChecklistState = {
|
||||
entries: {},
|
||||
selectedEntries: new Set(),
|
||||
};
|
||||
|
||||
const { reducer, actions } = createSlice({
|
||||
name: "checklist",
|
||||
initialState: {}, // not handled by slice itself
|
||||
initialState, // not handled by slice itself
|
||||
reducers: {
|
||||
updateAll: (state, { payload: checked }) => {
|
||||
const selectedEntries = new Set();
|
||||
return {
|
||||
entries: syncpipe(state.entries, [
|
||||
(_) => Object.values(_),
|
||||
(_) => _.map((entry) => {
|
||||
if (checked) {
|
||||
selectedEntries.add(entry.key);
|
||||
}
|
||||
return [entry.key, {
|
||||
...entry,
|
||||
checked
|
||||
}];
|
||||
}),
|
||||
(_) => Object.fromEntries(_)
|
||||
]),
|
||||
selectedEntries
|
||||
};
|
||||
updateAll: (state, { payload: checked }: PayloadAction<boolean>) => {
|
||||
const selectedEntries = new Set<string>();
|
||||
const entries = Object.fromEntries(
|
||||
Object.values(state.entries).map((entry) => {
|
||||
if (checked) {
|
||||
// Cheekily add this to selected
|
||||
// entries while we're here.
|
||||
selectedEntries.add(entry.key);
|
||||
}
|
||||
|
||||
return [entry.key, { ...entry, checked } ];
|
||||
})
|
||||
);
|
||||
|
||||
return { entries, selectedEntries };
|
||||
},
|
||||
update: (state, { payload: { key, value } }) => {
|
||||
update: (state, { payload: { key, value } }: PayloadAction<{key: string, value: Checkable}>) => {
|
||||
if (value.checked !== undefined) {
|
||||
if (value.checked === true) {
|
||||
state.selectedEntries.add(key);
|
||||
@@ -61,7 +82,7 @@ const { reducer, actions } = createSlice({
|
||||
...value
|
||||
};
|
||||
},
|
||||
updateMultiple: (state, { payload }) => {
|
||||
updateMultiple: (state, { payload }: PayloadAction<Array<[key: string, value: Checkable]>>) => {
|
||||
payload.forEach(([key, value]) => {
|
||||
if (value.checked !== undefined) {
|
||||
if (value.checked === true) {
|
||||
@@ -80,43 +101,57 @@ const { reducer, actions } = createSlice({
|
||||
}
|
||||
});
|
||||
|
||||
function initialState({ entries, uniqueKey, initialValue }) {
|
||||
const selectedEntries = new Set();
|
||||
function initialHookState({
|
||||
entries,
|
||||
uniqueKey,
|
||||
initialValue,
|
||||
}: {
|
||||
entries: Checkable[],
|
||||
uniqueKey: string,
|
||||
initialValue: boolean,
|
||||
}): ChecklistState {
|
||||
const selectedEntries = new Set<string>();
|
||||
const mappedEntries = Object.fromEntries(
|
||||
entries.map((entry) => {
|
||||
const key = entry[uniqueKey];
|
||||
const checked = entry.checked ?? initialValue;
|
||||
|
||||
if (checked) {
|
||||
selectedEntries.add(key);
|
||||
} else {
|
||||
selectedEntries.delete(key);
|
||||
}
|
||||
|
||||
return [ key, { ...entry, key, checked } ];
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
entries: syncpipe(entries, [
|
||||
(_) => _.map((entry) => {
|
||||
let key = entry[uniqueKey];
|
||||
let checked = entry.checked ?? initialValue;
|
||||
|
||||
if (checked) {
|
||||
selectedEntries.add(key);
|
||||
} else {
|
||||
selectedEntries.delete(key);
|
||||
}
|
||||
|
||||
return [
|
||||
key,
|
||||
{
|
||||
...entry,
|
||||
key,
|
||||
checked
|
||||
}
|
||||
];
|
||||
}),
|
||||
(_) => Object.fromEntries(_)
|
||||
]),
|
||||
entries: mappedEntries,
|
||||
selectedEntries
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "key", initialValue = false }) {
|
||||
const [state, dispatch] = React.useReducer(reducer, null,
|
||||
() => initialState({ entries, uniqueKey, initialValue }) // initial state
|
||||
const _default: { [k: string]: Checkable } = {};
|
||||
|
||||
export default function useCheckListInput(
|
||||
/* eslint-disable no-unused-vars */
|
||||
{ name, Name }: CreateHookNames,
|
||||
{
|
||||
entries = [],
|
||||
uniqueKey = "key",
|
||||
initialValue = false,
|
||||
}: HookOpts<boolean>
|
||||
): ChecklistInputHook {
|
||||
const [state, dispatch] = useReducer(
|
||||
reducer,
|
||||
initialState,
|
||||
(_) => initialHookState({ entries, uniqueKey, initialValue }) // initial state
|
||||
);
|
||||
|
||||
const toggleAllRef = React.useRef(null);
|
||||
const toggleAllRef = useRef<any>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (toggleAllRef.current != null) {
|
||||
let some = state.selectedEntries.size > 0;
|
||||
let all = false;
|
||||
@@ -130,22 +165,22 @@ module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "ke
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.selectedEntries]);
|
||||
|
||||
const reset = React.useCallback(
|
||||
const reset = useCallback(
|
||||
() => dispatch(actions.updateAll(initialValue)),
|
||||
[initialValue]
|
||||
);
|
||||
|
||||
const onChange = React.useCallback(
|
||||
const onChange = useCallback(
|
||||
(key, value) => dispatch(actions.update({ key, value })),
|
||||
[]
|
||||
);
|
||||
|
||||
const updateMultiple = React.useCallback(
|
||||
const updateMultiple = useCallback(
|
||||
(entries) => dispatch(actions.updateMultiple(entries)),
|
||||
[]
|
||||
);
|
||||
|
||||
return React.useMemo(() => {
|
||||
return useMemo(() => {
|
||||
function toggleAll(e) {
|
||||
let checked = e.target.checked;
|
||||
if (e.target.indeterminate) {
|
||||
@@ -165,7 +200,10 @@ module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "ke
|
||||
reset,
|
||||
{ name }
|
||||
], {
|
||||
_default,
|
||||
hasChanged: () => true,
|
||||
name,
|
||||
Name: "",
|
||||
value: state.entries,
|
||||
onChange,
|
||||
selectedValues,
|
||||
@@ -178,4 +216,4 @@ module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "ke
|
||||
}
|
||||
});
|
||||
}, [state, reset, name, onChange, updateMultiple]);
|
||||
};
|
||||
}
|
@@ -17,13 +17,21 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
import { useState } from "react";
|
||||
|
||||
const { useComboboxState } = require("ariakit/combobox");
|
||||
import { useComboboxState } from "ariakit/combobox";
|
||||
import {
|
||||
ComboboxFormInputHook,
|
||||
CreateHookNames,
|
||||
HookOpts,
|
||||
} from "./types";
|
||||
|
||||
const _default = "";
|
||||
module.exports = function useComboBoxInput({ name, Name }, { initialValue = _default }) {
|
||||
const [isNew, setIsNew] = React.useState(false);
|
||||
export default function useComboBoxInput(
|
||||
{ name, Name }: CreateHookNames,
|
||||
{ initialValue = _default }: HookOpts<string>
|
||||
): ComboboxFormInputHook {
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
|
||||
const state = useComboboxState({
|
||||
defaultValue: initialValue,
|
||||
@@ -45,14 +53,15 @@ module.exports = function useComboBoxInput({ name, Name }, { initialValue = _def
|
||||
[`set${Name}IsNew`]: setIsNew
|
||||
}
|
||||
], {
|
||||
reset,
|
||||
name,
|
||||
Name: "", // Will be set by inputHook function.
|
||||
state,
|
||||
value: state.value,
|
||||
setter: (val) => state.setValue(val),
|
||||
setter: (val: string) => state.setValue(val),
|
||||
hasChanged: () => state.value != initialValue,
|
||||
isNew,
|
||||
setIsNew,
|
||||
reset,
|
||||
_default
|
||||
});
|
||||
};
|
||||
}
|
@@ -17,12 +17,19 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
import { useRef, useMemo } from "react";
|
||||
|
||||
const getFormMutations = require("./get-form-mutations");
|
||||
import getFormMutations from "./get-form-mutations";
|
||||
|
||||
function parseFields(entries, length) {
|
||||
const fields = [];
|
||||
import type {
|
||||
CreateHookNames,
|
||||
HookOpts,
|
||||
FieldArrayInputHook,
|
||||
HookedForm,
|
||||
} from "./types";
|
||||
|
||||
function parseFields(entries: HookedForm[], length: number): HookedForm[] {
|
||||
const fields: HookedForm[] = [];
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (entries[i] != undefined) {
|
||||
@@ -35,23 +42,38 @@ function parseFields(entries, length) {
|
||||
return fields;
|
||||
}
|
||||
|
||||
module.exports = function useArrayInput({ name, _Name }, { initialValue, length = 0 }) {
|
||||
const fields = React.useRef({});
|
||||
export default function useArrayInput(
|
||||
{ name }: CreateHookNames,
|
||||
{
|
||||
initialValue,
|
||||
length = 0,
|
||||
}: HookOpts,
|
||||
): FieldArrayInputHook {
|
||||
const _default: HookedForm[] = Array(length);
|
||||
const fields = useRef<HookedForm[]>(_default);
|
||||
|
||||
const value = React.useMemo(() => parseFields(initialValue, length), [initialValue, length]);
|
||||
const value = useMemo(
|
||||
() => parseFields(initialValue, length),
|
||||
[initialValue, length],
|
||||
);
|
||||
|
||||
function hasUpdate() {
|
||||
return Object.values(fields.current).some((fieldSet) => {
|
||||
const { updatedFields } = getFormMutations(fieldSet, { changedOnly: true });
|
||||
return updatedFields.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
_default,
|
||||
name,
|
||||
Name: "",
|
||||
value,
|
||||
ctx: fields.current,
|
||||
maxLength: length,
|
||||
hasChanged: hasUpdate,
|
||||
selectedValues() {
|
||||
// if any form field changed, we need to re-send everything
|
||||
const hasUpdate = Object.values(fields.current).some((fieldSet) => {
|
||||
const { updatedFields } = getFormMutations(fieldSet, { changedOnly: true });
|
||||
return updatedFields.length > 0;
|
||||
});
|
||||
if (hasUpdate) {
|
||||
if (hasUpdate()) {
|
||||
return Object.values(fields.current).map((fieldSet) => {
|
||||
return getFormMutations(fieldSet, { changedOnly: false }).mutationData;
|
||||
});
|
||||
@@ -60,4 +82,4 @@ module.exports = function useArrayInput({ name, _Name }, { initialValue, length
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
@@ -17,47 +17,67 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const prettierBytes = require("prettier-bytes");
|
||||
import React from "react";
|
||||
|
||||
module.exports = function useFileInput({ name, _Name }, {
|
||||
withPreview,
|
||||
maxSize,
|
||||
initialInfo = "no file selected"
|
||||
} = {}) {
|
||||
const [file, setFile] = React.useState();
|
||||
const [imageURL, setImageURL] = React.useState();
|
||||
const [info, setInfo] = React.useState();
|
||||
import { useState } from "react";
|
||||
import prettierBytes from "prettier-bytes";
|
||||
|
||||
function onChange(e) {
|
||||
let file = e.target.files[0];
|
||||
import type {
|
||||
CreateHookNames,
|
||||
HookOpts,
|
||||
FileFormInputHook,
|
||||
} from "./types";
|
||||
|
||||
const _default = undefined;
|
||||
export default function useFileInput(
|
||||
{ name }: CreateHookNames,
|
||||
{
|
||||
withPreview,
|
||||
maxSize,
|
||||
initialInfo = "no file selected"
|
||||
}: HookOpts<File>
|
||||
): FileFormInputHook {
|
||||
const [file, setFile] = useState<File>();
|
||||
const [imageURL, setImageURL] = useState<string>();
|
||||
const [info, setInfo] = useState<React.JSX.Element>();
|
||||
|
||||
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = e.target.files;
|
||||
if (!files) {
|
||||
setInfo(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
let file = files[0];
|
||||
setFile(file);
|
||||
|
||||
URL.revokeObjectURL(imageURL);
|
||||
|
||||
if (file != undefined) {
|
||||
if (withPreview) {
|
||||
setImageURL(URL.createObjectURL(file));
|
||||
}
|
||||
|
||||
let size = prettierBytes(file.size);
|
||||
if (maxSize && file.size > maxSize) {
|
||||
size = <span className="error-text">{size}</span>;
|
||||
}
|
||||
|
||||
setInfo(<>
|
||||
{file.name} ({size})
|
||||
</>);
|
||||
} else {
|
||||
setInfo();
|
||||
if (imageURL) {
|
||||
URL.revokeObjectURL(imageURL);
|
||||
}
|
||||
|
||||
if (withPreview) {
|
||||
setImageURL(URL.createObjectURL(file));
|
||||
}
|
||||
|
||||
let size = prettierBytes(file.size);
|
||||
if (maxSize && file.size > maxSize) {
|
||||
size = <span className="error-text">{size}</span>;
|
||||
}
|
||||
|
||||
setInfo(
|
||||
<>
|
||||
{file.name} ({size})
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
URL.revokeObjectURL(imageURL);
|
||||
setImageURL();
|
||||
setFile();
|
||||
setInfo();
|
||||
if (imageURL) {
|
||||
URL.revokeObjectURL(imageURL);
|
||||
}
|
||||
setImageURL(undefined);
|
||||
setFile(undefined);
|
||||
setInfo(undefined);
|
||||
}
|
||||
|
||||
const infoComponent = (
|
||||
@@ -82,9 +102,11 @@ module.exports = function useFileInput({ name, _Name }, {
|
||||
onChange,
|
||||
reset,
|
||||
name,
|
||||
Name: "", // Will be set by inputHook function.
|
||||
value: file,
|
||||
previewValue: imageURL,
|
||||
hasChanged: () => file != undefined,
|
||||
infoComponent
|
||||
infoComponent,
|
||||
_default,
|
||||
});
|
||||
};
|
||||
}
|
@@ -17,14 +17,31 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
const { Error } = require("../../components/error");
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
const Loading = require("../../components/loading");
|
||||
import React from "react";
|
||||
|
||||
// Wrap Form component inside component that fires the RTK Query call,
|
||||
// so Form will only be rendered when data is available to generate form-fields for
|
||||
module.exports = function FormWithData({ dataQuery, DataForm, queryArg, ...formProps }) {
|
||||
import { Error } from "../../components/error";
|
||||
import Loading from "../../components/loading";
|
||||
import { NoArg } from "../types/query";
|
||||
import { FormWithDataQuery } from "./types";
|
||||
|
||||
export interface FormWithDataProps {
|
||||
dataQuery: FormWithDataQuery,
|
||||
DataForm: ({ data, ...props }) => React.JSX.Element,
|
||||
queryArg?: any,
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap Form component inside component that fires the RTK Query call, so Form
|
||||
* will only be rendered when data is available to generate form-fields for.
|
||||
*/
|
||||
export default function FormWithData({ dataQuery, DataForm, queryArg, ...props }: FormWithDataProps) {
|
||||
if (!queryArg) {
|
||||
queryArg = NoArg;
|
||||
}
|
||||
|
||||
// Trigger provided query.
|
||||
const { data, isLoading, isError, error } = dataQuery(queryArg);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -38,6 +55,6 @@ module.exports = function FormWithData({ dataQuery, DataForm, queryArg, ...formP
|
||||
<Error error={error} />
|
||||
);
|
||||
} else {
|
||||
return <DataForm data={data} {...formProps} />;
|
||||
return <DataForm data={data} {...props} />;
|
||||
}
|
||||
};
|
||||
}
|
@@ -17,29 +17,31 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const syncpipe = require("syncpipe");
|
||||
import { FormInputHook, HookedForm } from "./types";
|
||||
|
||||
export default function getFormMutations(
|
||||
form: HookedForm,
|
||||
{ changedOnly }: { changedOnly: boolean },
|
||||
) {
|
||||
const updatedFields: FormInputHook[] = [];
|
||||
const mutationData: Array<[string, any]> = [];
|
||||
|
||||
Object.values(form).forEach((field) => {
|
||||
if ("selectedValues" in field) {
|
||||
// FieldArrayInputHook.
|
||||
const selected = field.selectedValues();
|
||||
if (!changedOnly || selected.length > 0) {
|
||||
updatedFields.push(field);
|
||||
mutationData.push([field.name, selected]);
|
||||
}
|
||||
} else if (!changedOnly || field.hasChanged()) {
|
||||
updatedFields.push(field);
|
||||
mutationData.push([field.name, field.value]);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = function getFormMutations(form, { changedOnly }) {
|
||||
let updatedFields = [];
|
||||
return {
|
||||
updatedFields,
|
||||
mutationData: syncpipe(form, [
|
||||
(_) => Object.values(_),
|
||||
(_) => _.map((field) => {
|
||||
if (field.selectedValues != undefined) {
|
||||
let selected = field.selectedValues();
|
||||
if (!changedOnly || selected.length > 0) {
|
||||
updatedFields.push(field);
|
||||
return [field.name, selected];
|
||||
}
|
||||
} else if (!changedOnly || field.hasChanged()) {
|
||||
updatedFields.push(field);
|
||||
return [field.name, field.value];
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
(_) => _.filter((value) => value != null),
|
||||
(_) => Object.fromEntries(_)
|
||||
])
|
||||
mutationData: Object.fromEntries(mutationData),
|
||||
};
|
||||
};
|
||||
}
|
@@ -1,83 +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 React = require("react");
|
||||
const getByDot = require("get-by-dot").default;
|
||||
|
||||
function capitalizeFirst(str) {
|
||||
return str.slice(0, 1).toUpperCase + str.slice(1);
|
||||
}
|
||||
|
||||
function selectorByKey(key) {
|
||||
if (key.includes("[")) {
|
||||
// get-by-dot does not support 'nested[deeper][key]' notation, convert to 'nested.deeper.key'
|
||||
key = key
|
||||
.replace(/\[/g, ".") // nested.deeper].key]
|
||||
.replace(/\]/g, ""); // nested.deeper.key
|
||||
}
|
||||
|
||||
return function selector(obj) {
|
||||
if (obj == undefined) {
|
||||
return undefined;
|
||||
} else {
|
||||
return getByDot(obj, key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function makeHook(hookFunction) {
|
||||
return function (name, opts = {}) {
|
||||
// for dynamically generating attributes like 'setName'
|
||||
const Name = React.useMemo(() => capitalizeFirst(name), [name]);
|
||||
|
||||
const selector = React.useMemo(() => selectorByKey(name), [name]);
|
||||
const valueSelector = opts.valueSelector ?? selector;
|
||||
|
||||
opts.initialValue = React.useMemo(() => {
|
||||
if (opts.source == undefined) {
|
||||
return opts.defaultValue;
|
||||
} else {
|
||||
return valueSelector(opts.source) ?? opts.defaultValue;
|
||||
}
|
||||
}, [opts.source, opts.defaultValue, valueSelector]);
|
||||
|
||||
const hook = hookFunction({ name, Name }, opts);
|
||||
|
||||
return Object.assign(hook, {
|
||||
name, Name,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
useTextInput: makeHook(require("./text")),
|
||||
useFileInput: makeHook(require("./file")),
|
||||
useBoolInput: makeHook(require("./bool")),
|
||||
useRadioInput: makeHook(require("./radio")),
|
||||
useComboBoxInput: makeHook(require("./combo-box")),
|
||||
useCheckListInput: makeHook(require("./check-list")),
|
||||
useFieldArrayInput: makeHook(require("./field-array")),
|
||||
useValue: function (name, value) {
|
||||
return {
|
||||
name,
|
||||
value,
|
||||
hasChanged: () => true // always included
|
||||
};
|
||||
}
|
||||
};
|
114
web/source/settings/lib/form/index.ts
Normal file
114
web/source/settings/lib/form/index.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
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 { useMemo } from "react";
|
||||
import getByDot from "get-by-dot";
|
||||
|
||||
import text from "./text";
|
||||
import file from "./file";
|
||||
import bool from "./bool";
|
||||
import radio from "./radio";
|
||||
import combobox from "./combo-box";
|
||||
import checklist from "./check-list";
|
||||
import fieldarray from "./field-array";
|
||||
|
||||
import type {
|
||||
CreateHook,
|
||||
FormInputHook,
|
||||
HookOpts,
|
||||
TextFormInputHook,
|
||||
RadioFormInputHook,
|
||||
FileFormInputHook,
|
||||
BoolFormInputHook,
|
||||
ComboboxFormInputHook,
|
||||
FieldArrayInputHook,
|
||||
ChecklistInputHook,
|
||||
} from "./types";
|
||||
|
||||
function capitalizeFirst(str: string) {
|
||||
return str.slice(0, 1).toUpperCase + str.slice(1);
|
||||
}
|
||||
|
||||
function selectorByKey(key: string) {
|
||||
if (key.includes("[")) {
|
||||
// get-by-dot does not support 'nested[deeper][key]' notation, convert to 'nested.deeper.key'
|
||||
key = key
|
||||
.replace(/\[/g, ".") // nested.deeper].key]
|
||||
.replace(/\]/g, ""); // nested.deeper.key
|
||||
}
|
||||
|
||||
return function selector(obj) {
|
||||
if (obj == undefined) {
|
||||
return undefined;
|
||||
} else {
|
||||
return getByDot(obj, key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized hook generator function. Take a createHook
|
||||
* function and use it to return a new FormInputHook function.
|
||||
*
|
||||
* @param createHook
|
||||
* @returns
|
||||
*/
|
||||
function inputHook(createHook: CreateHook): (_name: string, _opts: HookOpts) => FormInputHook {
|
||||
return (name: string, opts?: HookOpts): FormInputHook => {
|
||||
// for dynamically generating attributes like 'setName'
|
||||
const Name = useMemo(() => capitalizeFirst(name), [name]);
|
||||
const selector = useMemo(() => selectorByKey(name), [name]);
|
||||
const valueSelector = opts?.valueSelector?? selector;
|
||||
|
||||
if (opts) {
|
||||
opts.initialValue = useMemo(() => {
|
||||
if (opts.source == undefined) {
|
||||
return opts.defaultValue;
|
||||
} else {
|
||||
return valueSelector(opts.source) ?? opts.defaultValue;
|
||||
}
|
||||
}, [opts.source, opts.defaultValue, valueSelector]);
|
||||
}
|
||||
|
||||
const hook = createHook({ name, Name }, opts ?? {});
|
||||
return Object.assign(hook, { name, Name });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplest form hook type in town.
|
||||
*/
|
||||
function value<T>(name: string, initialValue: T) {
|
||||
return {
|
||||
_default: initialValue,
|
||||
name,
|
||||
Name: "",
|
||||
value: initialValue,
|
||||
hasChanged: () => true, // always included
|
||||
};
|
||||
}
|
||||
|
||||
export const useTextInput = inputHook(text) as (_name: string, _opts?: HookOpts<string>) => TextFormInputHook;
|
||||
export const useFileInput = inputHook(file) as (_name: string, _opts?: HookOpts<File>) => FileFormInputHook;
|
||||
export const useBoolInput = inputHook(bool) as (_name: string, _opts?: HookOpts<boolean>) => BoolFormInputHook;
|
||||
export const useRadioInput = inputHook(radio) as (_name: string, _opts?: HookOpts<string>) => RadioFormInputHook;
|
||||
export const useComboBoxInput = inputHook(combobox) as (_name: string, _opts?: HookOpts<string>) => ComboboxFormInputHook;
|
||||
export const useCheckListInput = inputHook(checklist) as (_name: string, _opts?: HookOpts<boolean>) => ChecklistInputHook;
|
||||
export const useFieldArrayInput = inputHook(fieldarray) as (_name: string, _opts?: HookOpts<string>) => FieldArrayInputHook;
|
||||
export const useValue = value as <T>(_name: string, _initialValue: T) => FormInputHook<T>;
|
@@ -17,11 +17,18 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
import { useState } from "react";
|
||||
import { CreateHookNames, HookOpts, RadioFormInputHook } from "./types";
|
||||
|
||||
const _default = "";
|
||||
module.exports = function useRadioInput({ name, Name }, { initialValue = _default, options }) {
|
||||
const [value, setValue] = React.useState(initialValue);
|
||||
export default function useRadioInput(
|
||||
{ name, Name }: CreateHookNames,
|
||||
{
|
||||
initialValue = _default,
|
||||
options = {},
|
||||
}: HookOpts<string>
|
||||
): RadioFormInputHook {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
function onChange(e) {
|
||||
setValue(e.target.value);
|
||||
@@ -40,13 +47,14 @@ module.exports = function useRadioInput({ name, Name }, { initialValue = _defaul
|
||||
[`set${Name}`]: setValue
|
||||
}
|
||||
], {
|
||||
name,
|
||||
onChange,
|
||||
reset,
|
||||
name,
|
||||
Name: "",
|
||||
value,
|
||||
setter: setValue,
|
||||
options,
|
||||
hasChanged: () => value != initialValue,
|
||||
_default
|
||||
});
|
||||
};
|
||||
}
|
@@ -1,67 +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 React = require("react");
|
||||
const getFormMutations = require("./get-form-mutations");
|
||||
|
||||
module.exports = function useFormSubmit(form, mutationQuery, { changedOnly = true, onFinish } = {}) {
|
||||
if (!Array.isArray(mutationQuery)) {
|
||||
throw new ("useFormSubmit: mutationQuery was not an Array. Is a valid useMutation RTK Query provided?");
|
||||
}
|
||||
const [runMutation, result] = mutationQuery;
|
||||
const usedAction = React.useRef(null);
|
||||
return [
|
||||
function submitForm(e) {
|
||||
let action;
|
||||
if (e?.preventDefault) {
|
||||
e.preventDefault();
|
||||
action = e.nativeEvent.submitter.name;
|
||||
} else {
|
||||
action = e;
|
||||
}
|
||||
|
||||
if (action == "") {
|
||||
action = undefined;
|
||||
}
|
||||
usedAction.current = action;
|
||||
// transform the field definitions into an object with just their values
|
||||
|
||||
const { mutationData, updatedFields } = getFormMutations(form, { changedOnly });
|
||||
|
||||
if (updatedFields.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
mutationData.action = action;
|
||||
|
||||
return Promise.try(() => {
|
||||
return runMutation(mutationData);
|
||||
}).then((res) => {
|
||||
if (onFinish) {
|
||||
return onFinish(res);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
...result,
|
||||
action: usedAction.current
|
||||
}
|
||||
];
|
||||
};
|
140
web/source/settings/lib/form/submit.ts
Normal file
140
web/source/settings/lib/form/submit.ts
Normal 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 getFormMutations from "./get-form-mutations";
|
||||
|
||||
import { useRef } from "react";
|
||||
|
||||
import type {
|
||||
MutationTrigger,
|
||||
UseMutationStateResult,
|
||||
} from "@reduxjs/toolkit/dist/query/react/buildHooks";
|
||||
|
||||
import type {
|
||||
FormSubmitEvent,
|
||||
FormSubmitFunction,
|
||||
FormSubmitResult,
|
||||
HookedForm,
|
||||
} from "./types";
|
||||
|
||||
interface UseFormSubmitOptions {
|
||||
changedOnly: boolean;
|
||||
onFinish?: ((_res: any) => void);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse changed values from the hooked form into a request
|
||||
* body, and submit it using the given mutation trigger.
|
||||
*
|
||||
* This function basically wraps RTK Query's submit methods to
|
||||
* work with our hooked form interface.
|
||||
*
|
||||
* An `onFinish` callback function can be provided, which will
|
||||
* be executed on a **successful** run of the given MutationTrigger,
|
||||
* with the mutation result passed into it.
|
||||
*
|
||||
* If `changedOnly` is false, then **all** fields of the given HookedForm
|
||||
* will be submitted to the mutation endpoint, not just changed ones.
|
||||
*
|
||||
* The returned function and result can be triggered and read
|
||||
* from just like an RTK Query mutation hook result would be.
|
||||
*
|
||||
* See: https://redux-toolkit.js.org/rtk-query/usage/mutations#mutation-hook-behavior
|
||||
*/
|
||||
export default function useFormSubmit(
|
||||
form: HookedForm,
|
||||
mutationQuery: readonly [MutationTrigger<any>, UseMutationStateResult<any, any>],
|
||||
opts: UseFormSubmitOptions = { changedOnly: true }
|
||||
): [ FormSubmitFunction, FormSubmitResult ] {
|
||||
if (!Array.isArray(mutationQuery)) {
|
||||
throw "useFormSubmit: mutationQuery was not an Array. Is a valid useMutation RTK Query provided?";
|
||||
}
|
||||
|
||||
const { changedOnly, onFinish } = opts;
|
||||
const [runMutation, mutationResult] = mutationQuery;
|
||||
const usedAction = useRef<FormSubmitEvent>(undefined);
|
||||
|
||||
const submitForm = async(e: FormSubmitEvent) => {
|
||||
let action: FormSubmitEvent;
|
||||
|
||||
if (typeof e === "string") {
|
||||
if (e !== "") {
|
||||
// String action name was provided.
|
||||
action = e;
|
||||
} else {
|
||||
// Empty string action name was provided.
|
||||
action = undefined;
|
||||
}
|
||||
} else if (e) {
|
||||
// Submit event action was provided.
|
||||
e.preventDefault();
|
||||
if (e.nativeEvent.submitter) {
|
||||
// We want the name of the element that was invoked to submit this form,
|
||||
// which will be something that extends HTMLElement, though we don't know
|
||||
// what at this point.
|
||||
//
|
||||
// See: https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent/submitter
|
||||
action = (e.nativeEvent.submitter as Object as { name: string }).name;
|
||||
} else {
|
||||
// No submitter defined. Fall back
|
||||
// to just use the FormSubmitEvent.
|
||||
action = e;
|
||||
}
|
||||
} else {
|
||||
// Void or null or something
|
||||
// else was provided.
|
||||
action = undefined;
|
||||
}
|
||||
|
||||
usedAction.current = action;
|
||||
|
||||
// Transform the hooked form into an object.
|
||||
const {
|
||||
mutationData,
|
||||
updatedFields,
|
||||
} = getFormMutations(form, { changedOnly });
|
||||
|
||||
// If there were no updated fields according to
|
||||
// the form parsing then there's nothing for us
|
||||
// to do, since remote and desired state match.
|
||||
if (updatedFields.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
mutationData.action = action;
|
||||
|
||||
try {
|
||||
const res = await runMutation(mutationData);
|
||||
if (onFinish) {
|
||||
onFinish(res);
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`caught error running mutation: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
submitForm,
|
||||
{
|
||||
...mutationResult,
|
||||
action: usedAction.current
|
||||
}
|
||||
];
|
||||
}
|
@@ -17,26 +17,40 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const React = require("react");
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useTransition,
|
||||
useEffect,
|
||||
} from "react";
|
||||
|
||||
import type {
|
||||
CreateHookNames,
|
||||
HookOpts,
|
||||
TextFormInputHook,
|
||||
} from "./types";
|
||||
|
||||
const _default = "";
|
||||
module.exports = function useTextInput({ name, Name }, {
|
||||
initialValue = _default,
|
||||
dontReset = false,
|
||||
validator,
|
||||
showValidation = true,
|
||||
initValidation
|
||||
} = {}) {
|
||||
|
||||
const [text, setText] = React.useState(initialValue);
|
||||
const textRef = React.useRef(null);
|
||||
export default function useTextInput(
|
||||
{ name, Name }: CreateHookNames,
|
||||
{
|
||||
initialValue = _default,
|
||||
dontReset = false,
|
||||
validator,
|
||||
showValidation = true,
|
||||
initValidation
|
||||
}: HookOpts<string>
|
||||
): TextFormInputHook {
|
||||
const [text, setText] = useState(initialValue);
|
||||
const textRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [validation, setValidation] = React.useState(initValidation ?? "");
|
||||
const [_isValidating, startValidation] = React.useTransition();
|
||||
let valid = validation == "";
|
||||
const [validation, setValidation] = useState(initValidation ?? "");
|
||||
const [_isValidating, startValidation] = useTransition();
|
||||
const valid = validation == "";
|
||||
|
||||
function onChange(e) {
|
||||
let input = e.target.value;
|
||||
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const input = e.target.value;
|
||||
setText(input);
|
||||
|
||||
if (validator) {
|
||||
@@ -52,7 +66,7 @@ module.exports = function useTextInput({ name, Name }, {
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (validator && textRef.current) {
|
||||
if (showValidation) {
|
||||
textRef.current.setCustomValidity(validation);
|
||||
@@ -76,12 +90,13 @@ module.exports = function useTextInput({ name, Name }, {
|
||||
onChange,
|
||||
reset,
|
||||
name,
|
||||
Name: "", // Will be set by inputHook function.
|
||||
value: text,
|
||||
ref: textRef,
|
||||
setter: setText,
|
||||
valid,
|
||||
validate: () => setValidation(validator(text)),
|
||||
validate: () => setValidation(validator ? validator(text): ""),
|
||||
hasChanged: () => text != initialValue,
|
||||
_default
|
||||
});
|
||||
};
|
||||
}
|
264
web/source/settings/lib/form/types.ts
Normal file
264
web/source/settings/lib/form/types.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
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/>.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
import { ComboboxState } from "ariakit";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
Dispatch,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
SyntheticEvent,
|
||||
} from "react";
|
||||
|
||||
export interface CreateHookNames {
|
||||
name: string;
|
||||
Name: string;
|
||||
}
|
||||
|
||||
export interface HookOpts<T = any> {
|
||||
initialValue?: T,
|
||||
defaultValue?: T,
|
||||
|
||||
dontReset?: boolean,
|
||||
validator?,
|
||||
showValidation?: boolean,
|
||||
initValidation?: string,
|
||||
length?: number;
|
||||
options?: { [_: string]: string },
|
||||
withPreview?: boolean,
|
||||
maxSize?,
|
||||
initialInfo?: string;
|
||||
valueSelector?: Function,
|
||||
source?,
|
||||
|
||||
// checklist input types
|
||||
entries?: any[];
|
||||
uniqueKey?: string;
|
||||
}
|
||||
|
||||
export type CreateHook = (
|
||||
name: CreateHookNames,
|
||||
opts: HookOpts,
|
||||
) => FormInputHook;
|
||||
|
||||
export interface FormInputHook<T = any> {
|
||||
/**
|
||||
* Name of this FormInputHook, as provided
|
||||
* in the UseFormInputHook options.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* `name` with first letter capitalized.
|
||||
*/
|
||||
Name: string;
|
||||
|
||||
/**
|
||||
* Current value of this FormInputHook.
|
||||
*/
|
||||
value?: T;
|
||||
|
||||
/**
|
||||
* Default value of this FormInputHook.
|
||||
*/
|
||||
_default: T;
|
||||
|
||||
/**
|
||||
* Return true if the values of this hook is considered
|
||||
* to have been changed from the default / initial value.
|
||||
*/
|
||||
hasChanged: () => boolean;
|
||||
}
|
||||
|
||||
interface _withReset {
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
interface _withOnChange {
|
||||
onChange: ChangeEventHandler;
|
||||
}
|
||||
|
||||
interface _withSetter<T> {
|
||||
setter: Dispatch<SetStateAction<T>>;
|
||||
}
|
||||
|
||||
interface _withValidate {
|
||||
valid: boolean;
|
||||
validate: () => void;
|
||||
}
|
||||
|
||||
interface _withRef {
|
||||
ref: RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
interface _withFile {
|
||||
previewValue?: string;
|
||||
infoComponent: React.JSX.Element;
|
||||
}
|
||||
|
||||
interface _withComboboxState {
|
||||
state: ComboboxState;
|
||||
}
|
||||
|
||||
interface _withNew {
|
||||
isNew: boolean;
|
||||
setIsNew: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
interface _withSelectedValues {
|
||||
selectedValues: () => {
|
||||
[_: string]: any;
|
||||
}[]
|
||||
}
|
||||
|
||||
interface _withCtx {
|
||||
ctx
|
||||
}
|
||||
|
||||
interface _withMaxLength {
|
||||
maxLength: number;
|
||||
}
|
||||
|
||||
interface _withOptions {
|
||||
options: { [_: string]: string };
|
||||
}
|
||||
|
||||
interface _withToggleAll {
|
||||
toggleAll: _withRef & _withOnChange
|
||||
}
|
||||
|
||||
interface _withSomeSelected {
|
||||
someSelected: boolean;
|
||||
}
|
||||
|
||||
interface _withUpdateMultiple {
|
||||
updateMultiple: (_entries: any) => void;
|
||||
}
|
||||
|
||||
export interface TextFormInputHook extends FormInputHook<string>,
|
||||
_withSetter<string>,
|
||||
_withOnChange,
|
||||
_withReset,
|
||||
_withValidate,
|
||||
_withRef {}
|
||||
|
||||
export interface RadioFormInputHook extends FormInputHook<string>,
|
||||
_withSetter<string>,
|
||||
_withOnChange,
|
||||
_withOptions,
|
||||
_withReset {}
|
||||
|
||||
export interface FileFormInputHook extends FormInputHook<File | undefined>,
|
||||
_withOnChange,
|
||||
_withReset,
|
||||
Partial<_withRef>,
|
||||
_withFile {}
|
||||
|
||||
export interface BoolFormInputHook extends FormInputHook<boolean>,
|
||||
_withSetter<boolean>,
|
||||
_withOnChange,
|
||||
_withReset {}
|
||||
|
||||
export interface ComboboxFormInputHook extends FormInputHook<string>,
|
||||
_withSetter<string>,
|
||||
_withComboboxState,
|
||||
_withNew,
|
||||
_withReset {}
|
||||
|
||||
export interface FieldArrayInputHook extends FormInputHook<HookedForm[]>,
|
||||
_withSelectedValues,
|
||||
_withMaxLength,
|
||||
_withCtx {}
|
||||
|
||||
export interface Checkable {
|
||||
key: string;
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
export interface ChecklistInputHook<T = Checkable> extends FormInputHook<{[k: string]: T}>,
|
||||
_withReset,
|
||||
_withToggleAll,
|
||||
_withSelectedValues,
|
||||
_withSomeSelected,
|
||||
_withUpdateMultiple {
|
||||
// Uses its own funky onChange handler.
|
||||
onChange: (key: any, value: any) => void
|
||||
}
|
||||
|
||||
export type AnyFormInputHook =
|
||||
FormInputHook |
|
||||
TextFormInputHook |
|
||||
RadioFormInputHook |
|
||||
FileFormInputHook |
|
||||
BoolFormInputHook |
|
||||
ComboboxFormInputHook |
|
||||
FieldArrayInputHook |
|
||||
ChecklistInputHook;
|
||||
|
||||
export interface HookedForm {
|
||||
[_: string]: AnyFormInputHook
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for FormSubmitFunction.
|
||||
*/
|
||||
export type FormSubmitEvent = (string | SyntheticEvent<HTMLFormElement, Partial<SubmitEvent>> | undefined | void)
|
||||
|
||||
|
||||
/**
|
||||
* Shadows "trigger" function for useMutation, but can also
|
||||
* be passed to onSubmit property of forms as a handler.
|
||||
*
|
||||
* See: https://redux-toolkit.js.org/rtk-query/usage/mutations#mutation-hook-behavior
|
||||
*/
|
||||
export type FormSubmitFunction = ((_e: FormSubmitEvent) => void)
|
||||
|
||||
/**
|
||||
* Shadows redux mutation hook return values.
|
||||
*
|
||||
* See: https://redux-toolkit.js.org/rtk-query/usage/mutations#frequently-used-mutation-hook-return-values
|
||||
*/
|
||||
export interface FormSubmitResult {
|
||||
/**
|
||||
* Action used to submit the form, if any.
|
||||
*/
|
||||
action: FormSubmitEvent;
|
||||
data: any;
|
||||
error: any;
|
||||
isLoading: boolean;
|
||||
isSuccess: boolean;
|
||||
isError: boolean;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shadows redux query hook return values.
|
||||
*
|
||||
* See: https://redux-toolkit.js.org/rtk-query/usage/queries#frequently-used-query-hook-return-values
|
||||
*/
|
||||
export type FormWithDataQuery = (_queryArg: any) => {
|
||||
data?: any;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error?: any;
|
||||
}
|
@@ -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
|
||||
};
|
||||
}
|
307
web/source/settings/lib/query/admin/custom-emoji/index.ts
Normal file
307
web/source/settings/lib/query/admin/custom-emoji/index.ts
Normal 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,
|
||||
};
|
155
web/source/settings/lib/query/admin/domain-permissions/export.ts
Normal file
155
web/source/settings/lib/query/admin/domain-permissions/export.ts
Normal 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 };
|
@@ -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,
|
||||
};
|
140
web/source/settings/lib/query/admin/domain-permissions/import.ts
Normal file
140
web/source/settings/lib/query/admin/domain-permissions/import.ts
Normal 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,
|
||||
};
|
@@ -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 };
|
109
web/source/settings/lib/query/admin/domain-permissions/update.ts
Normal file
109
web/source/settings/lib/query/admin/domain-permissions/update.ts
Normal 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
|
||||
};
|
@@ -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];
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
@@ -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 });
|
148
web/source/settings/lib/query/admin/index.ts
Normal file
148
web/source/settings/lib/query/admin/index.ts
Normal 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;
|
83
web/source/settings/lib/query/admin/reports/index.ts
Normal file
83
web/source/settings/lib/query/admin/reports/index.ts
Normal 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,
|
||||
};
|
@@ -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 };
|
||||
|
@@ -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 });
|
||||
}));
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
@@ -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;
|
||||
|
150
web/source/settings/lib/query/query-modifiers.ts
Normal file
150
web/source/settings/lib/query/query-modifiers.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { 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,
|
||||
};
|
78
web/source/settings/lib/query/transforms.ts
Normal file
78
web/source/settings/lib/query/transforms.ts
Normal 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);
|
||||
};
|
||||
}
|
@@ -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`,
|
||||
|
@@ -17,35 +17,33 @@
|
||||
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"]
|
||||
}),
|
||||
export interface CustomEmoji {
|
||||
id?: string;
|
||||
shortcode: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
getReport: build.query({
|
||||
query: (id) => ({
|
||||
url: `/api/v1/admin/reports/${id}`
|
||||
}),
|
||||
providesTags: (res, error, id) => [{ type: "Reports", id }]
|
||||
}),
|
||||
/**
|
||||
* Query parameters for GET to /api/v1/admin/custom_emojis.
|
||||
*/
|
||||
export interface ListEmojiParams {
|
||||
|
||||
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" }]
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of searchItemForEmoji mutation.
|
||||
*/
|
||||
export interface EmojisFromItem {
|
||||
/**
|
||||
* Type of the search item result.
|
||||
*/
|
||||
type: "statuses" | "accounts";
|
||||
/**
|
||||
* Domain of the returned emojis.
|
||||
*/
|
||||
domain: string;
|
||||
/**
|
||||
* Discovered emojis.
|
||||
*/
|
||||
list: CustomEmoji[];
|
||||
}
|
97
web/source/settings/lib/types/domain-permission.ts
Normal file
97
web/source/settings/lib/types/domain-permission.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
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 typia from "typia";
|
||||
|
||||
export const isDomainPerms = typia.createIs<DomainPerm[]>();
|
||||
|
||||
export type PermType = "block" | "allow";
|
||||
|
||||
/**
|
||||
* A single domain permission entry (block or allow).
|
||||
*/
|
||||
export interface DomainPerm {
|
||||
id?: string;
|
||||
domain: string;
|
||||
obfuscate?: boolean;
|
||||
private_comment?: string;
|
||||
public_comment?: string;
|
||||
created_at?: string;
|
||||
|
||||
// Internal processing keys; remove
|
||||
// before serdes of domain perm.
|
||||
key?: string;
|
||||
permType?: PermType;
|
||||
suggest?: string;
|
||||
valid?: boolean;
|
||||
checked?: boolean;
|
||||
commentType?: string;
|
||||
private_comment_behavior?: "append" | "replace";
|
||||
public_comment_behavior?: "append" | "replace";
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain permissions mapped to an Object where the Object
|
||||
* keys are the "domain" value of each DomainPerm.
|
||||
*/
|
||||
export interface MappedDomainPerms {
|
||||
[key: string]: DomainPerm;
|
||||
}
|
||||
|
||||
const domainPermInternalKeys: Set<keyof DomainPerm> = new Set([
|
||||
"key",
|
||||
"permType",
|
||||
"suggest",
|
||||
"valid",
|
||||
"checked",
|
||||
"commentType",
|
||||
"private_comment_behavior",
|
||||
"public_comment_behavior",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns true if provided DomainPerm Object key is
|
||||
* "internal"; ie., it's just for our use, and it shouldn't
|
||||
* be serialized to or deserialized from the GtS API.
|
||||
*
|
||||
* @param key
|
||||
* @returns
|
||||
*/
|
||||
export function isDomainPermInternalKey(key: keyof DomainPerm) {
|
||||
return domainPermInternalKeys.has(key);
|
||||
}
|
||||
|
||||
export interface ImportDomainPermsParams {
|
||||
domains: DomainPerm[];
|
||||
|
||||
// Internal processing keys;
|
||||
// remove before serdes of form.
|
||||
obfuscate?: boolean;
|
||||
commentType?: string;
|
||||
permType: PermType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model domain permissions bulk export params.
|
||||
*/
|
||||
export interface ExportDomainPermsParams {
|
||||
permType: PermType;
|
||||
action: "export" | "export-file";
|
||||
exportType: "json" | "csv" | "plain";
|
||||
}
|
91
web/source/settings/lib/types/instance.ts
Normal file
91
web/source/settings/lib/types/instance.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
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/>.
|
||||
*/
|
||||
|
||||
export interface InstanceV1 {
|
||||
uri: string;
|
||||
account_domain: string;
|
||||
title: string;
|
||||
description: string;
|
||||
short_description: string;
|
||||
email: string;
|
||||
version: string;
|
||||
languages: any[]; // TODO: define this
|
||||
registrations: boolean;
|
||||
approval_required: boolean;
|
||||
invites_enabled: boolean;
|
||||
configuration: InstanceConfiguration;
|
||||
urls: InstanceUrls;
|
||||
stats: InstanceStats;
|
||||
thumbnail: string;
|
||||
contact_account: Object; // TODO: define this.
|
||||
max_toot_chars: number;
|
||||
rules: any[]; // TODO: define this
|
||||
}
|
||||
|
||||
export interface InstanceConfiguration {
|
||||
statuses: InstanceStatuses;
|
||||
media_attachments: InstanceMediaAttachments;
|
||||
polls: InstancePolls;
|
||||
accounts: InstanceAccounts;
|
||||
emojis: InstanceEmojis;
|
||||
}
|
||||
|
||||
export interface InstanceAccounts {
|
||||
allow_custom_css: boolean;
|
||||
max_featured_tags: number;
|
||||
max_profile_fields: number;
|
||||
}
|
||||
|
||||
export interface InstanceEmojis {
|
||||
emoji_size_limit: number;
|
||||
}
|
||||
|
||||
export interface InstanceMediaAttachments {
|
||||
supported_mime_types: string[];
|
||||
image_size_limit: number;
|
||||
image_matrix_limit: number;
|
||||
video_size_limit: number;
|
||||
video_frame_rate_limit: number;
|
||||
video_matrix_limit: number;
|
||||
}
|
||||
|
||||
export interface InstancePolls {
|
||||
max_options: number;
|
||||
max_characters_per_option: number;
|
||||
min_expiration: number;
|
||||
max_expiration: number;
|
||||
}
|
||||
|
||||
export interface InstanceStatuses {
|
||||
max_characters: number;
|
||||
max_media_attachments: number;
|
||||
characters_reserved_per_url: number;
|
||||
supported_mime_types: string[];
|
||||
}
|
||||
|
||||
export interface InstanceStats {
|
||||
domain_count: number;
|
||||
status_count: number;
|
||||
user_count: number;
|
||||
}
|
||||
|
||||
export interface InstanceUrls {
|
||||
streaming_api: string;
|
||||
}
|
||||
|
95
web/source/settings/lib/types/query.ts
Normal file
95
web/source/settings/lib/types/query.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
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 { Draft } from "@reduxjs/toolkit";
|
||||
|
||||
/**
|
||||
* Pass into a query when you don't
|
||||
* want to provide an argument to it.
|
||||
*/
|
||||
export const NoArg = undefined;
|
||||
|
||||
/**
|
||||
* Shadow the redux onQueryStarted function for mutations.
|
||||
* https://redux-toolkit.js.org/rtk-query/api/createApi#onquerystarted
|
||||
*/
|
||||
type OnMutationStarted = (
|
||||
_arg: any,
|
||||
_params: MutationStartedParams
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Shadow the redux onQueryStarted function parameters for mutations.
|
||||
* https://redux-toolkit.js.org/rtk-query/api/createApi#onquerystarted
|
||||
*/
|
||||
interface MutationStartedParams {
|
||||
/**
|
||||
* The dispatch method for the store.
|
||||
*/
|
||||
dispatch,
|
||||
/**
|
||||
* A method to get the current state for the store.
|
||||
*/
|
||||
getState,
|
||||
/**
|
||||
* extra as provided as thunk.extraArgument to the configureStore getDefaultMiddleware option.
|
||||
*/
|
||||
extra,
|
||||
/**
|
||||
* A unique ID generated for the query/mutation.
|
||||
*/
|
||||
requestId,
|
||||
/**
|
||||
* A Promise that will resolve with a data property (the transformed query result), and a
|
||||
* meta property (meta returned by the baseQuery). If the query fails, this Promise will
|
||||
* reject with the error. This allows you to await for the query to finish.
|
||||
*/
|
||||
queryFulfilled,
|
||||
/**
|
||||
* A function that gets the current value of the cache entry.
|
||||
*/
|
||||
getCacheEntry,
|
||||
}
|
||||
|
||||
export type Action = (
|
||||
_draft: Draft<any>,
|
||||
_updated: any,
|
||||
_params: ActionParams,
|
||||
) => void;
|
||||
|
||||
export interface ActionParams {
|
||||
/**
|
||||
* Either a normal old string, or a custom
|
||||
* function to derive the key to change based
|
||||
* on the draft and updated data.
|
||||
*
|
||||
* @param _draft
|
||||
* @param _updated
|
||||
* @returns
|
||||
*/
|
||||
key?: string | ((_draft: Draft<any>, _updated: any) => string),
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom cache mutation.
|
||||
*/
|
||||
export type CacheMutation = (
|
||||
_queryName: string | ((_arg: any) => string),
|
||||
_params?: ActionParams,
|
||||
) => { onQueryStarted: OnMutationStarted }
|
144
web/source/settings/lib/types/report.ts
Normal file
144
web/source/settings/lib/types/report.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Admin model of a report. Differs from the client
|
||||
* model, which contains less detailed information.
|
||||
*/
|
||||
export interface AdminReport {
|
||||
/**
|
||||
* ID of the report.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Whether an action has been taken by an admin in response to this report.
|
||||
*/
|
||||
action_taken: boolean;
|
||||
/**
|
||||
* Time action was taken, if at all.
|
||||
*/
|
||||
action_taken_at?: string;
|
||||
/**
|
||||
* Category under which this report was created.
|
||||
*/
|
||||
category: string;
|
||||
/**
|
||||
* Comment submitted by the report creator.
|
||||
*/
|
||||
comment: string;
|
||||
/**
|
||||
* Report was/should be federated to remote instance.
|
||||
*/
|
||||
forwarded: boolean;
|
||||
/**
|
||||
* Time when the report was created.
|
||||
*/
|
||||
created_at: string;
|
||||
/**
|
||||
* Time when the report was last updated.
|
||||
*/
|
||||
updated_at: string;
|
||||
/**
|
||||
* Account that created the report.
|
||||
* TODO: model this properly.
|
||||
*/
|
||||
account: Object;
|
||||
/**
|
||||
* Reported account.
|
||||
* TODO: model this properly.
|
||||
*/
|
||||
target_account: Object;
|
||||
/**
|
||||
* Admin account assigned to handle this report, if any.
|
||||
* TODO: model this properly.
|
||||
*/
|
||||
assigned_account?: Object;
|
||||
/**
|
||||
* Admin account that has taken action on this report, if any.
|
||||
* TODO: model this properly.
|
||||
*/
|
||||
action_taken_by_account?: Object;
|
||||
/**
|
||||
* Statuses cited by this report, if any.
|
||||
* TODO: model this properly.
|
||||
*/
|
||||
statuses: Object[];
|
||||
/**
|
||||
* Rules broken according to the reporter, if any.
|
||||
* TODO: model this properly.
|
||||
*/
|
||||
rules: Object[];
|
||||
/**
|
||||
* Comment stored about what action (if any) was taken.
|
||||
*/
|
||||
action_taken_comment?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for POST to /api/v1/admin/reports/{id}/resolve.
|
||||
*/
|
||||
export interface AdminReportResolveParams {
|
||||
/**
|
||||
* The ID of the report to resolve.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Comment to store about what action (if any) was taken.
|
||||
* Will be shown to the user who created the report (if local).
|
||||
*/
|
||||
action_taken_comment?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for GET to /api/v1/admin/reports.
|
||||
*/
|
||||
export interface AdminReportListParams {
|
||||
/**
|
||||
* If set, show only resolved (true) or only unresolved (false) reports.
|
||||
*/
|
||||
resolved?: boolean;
|
||||
/**
|
||||
* If set, show only reports created by the given account ID.
|
||||
*/
|
||||
account_id?: string;
|
||||
/**
|
||||
* If set, show only reports that target the given account ID.
|
||||
*/
|
||||
target_account_id?: string;
|
||||
/**
|
||||
* If set, show only reports older (ie., lower) than the given ID.
|
||||
* Report with the given ID will not be included in response.
|
||||
*/
|
||||
max_id?: string;
|
||||
/**
|
||||
* If set, show only reports newer (ie., higher) than the given ID.
|
||||
* Report with the given ID will not be included in response.
|
||||
*/
|
||||
since_id?: string;
|
||||
/**
|
||||
* If set, show only reports *immediately newer* than the given ID.
|
||||
* Report with the given ID will not be included in response.
|
||||
*/
|
||||
min_id?: string;
|
||||
/**
|
||||
* If set, limit returned reports to this number.
|
||||
* Else, fall back to GtS API defaults.
|
||||
*/
|
||||
limit?: number;
|
||||
}
|
@@ -17,33 +17,32 @@
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const isValidDomain = require("is-valid-domain");
|
||||
const psl = require("psl");
|
||||
import isValidDomain from "is-valid-domain";
|
||||
import { get } from "psl";
|
||||
|
||||
function isValidDomainBlock(domain) {
|
||||
/**
|
||||
* Check the input string to ensure it's a valid
|
||||
* domain that doesn't include a wildcard ("*").
|
||||
* @param domain
|
||||
* @returns
|
||||
*/
|
||||
export function isValidDomainPermission(domain: string): boolean {
|
||||
return isValidDomain(domain, {
|
||||
/*
|
||||
Wildcard prefix *. can be stripped since it's equivalent to not having it,
|
||||
but wildcard anywhere else in the domain is not handled by the backend so it's invalid.
|
||||
*/
|
||||
wildcard: false,
|
||||
allowUnicode: true
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
Still can't think of a better function name for this,
|
||||
but we're checking a domain against the Public Suffix List <https://publicsuffix.org/>
|
||||
to see if we should suggest removing subdomain(s) since they're likely owned/ran by the same party
|
||||
social.example.com -> suggests example.com
|
||||
*/
|
||||
function hasBetterScope(domain) {
|
||||
const lookup = psl.get(domain);
|
||||
/**
|
||||
* Checks a domain against the Public Suffix List <https://publicsuffix.org/> to see if we
|
||||
* should suggest removing subdomain(s), since they're likely owned/ran by the same party.
|
||||
* Eg., "social.example.com" suggests "example.com".
|
||||
* @param domain
|
||||
* @returns
|
||||
*/
|
||||
export function hasBetterScope(domain: string): string | undefined {
|
||||
const lookup = get(domain);
|
||||
if (lookup && lookup != domain) {
|
||||
return lookup;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { isValidDomainBlock, hasBetterScope };
|
Reference in New Issue
Block a user