mirror of
1
Fork 0

refactor federation import-export interface

This commit is contained in:
f0x 2023-01-13 23:18:27 +00:00
parent c91c218a6e
commit 5a96f23ff7
21 changed files with 610 additions and 214 deletions

View File

@ -49,6 +49,8 @@ $error2: #ff9796; /* Error background text, can be used with $error1 (5.0), $gra
$error3: #dd2c2c; /* Error button background text, can be used with $white1 (4.51) */ $error3: #dd2c2c; /* Error button background text, can be used with $white1 (4.51) */
$error-link: #01318C; /* Error link text, can be used with $error2 (5.56) */ $error-link: #01318C; /* Error link text, can be used with $error2 (5.56) */
$green1: #94E749; /* Green for positive/confirmation, similar contrast (luminance) as $blue2 */
$info-fg: $gray1; $info-fg: $gray1;
$info-bg: #b3ddff; $info-bg: #b3ddff;
$info-link: $error-link; $info-link: $error-link;

View File

@ -45,7 +45,7 @@ module.exports = function EmojiDetailRoute() {
return ( return (
<div className="emoji-detail"> <div className="emoji-detail">
<Link to={base}><a>&lt; go back</a></Link> <Link to={base}><a>&lt; go back</a></Link>
<FormWithData dataQuery={query.useGetEmojiQuery} arg={params.emojiId} DataForm={EmojiDetailForm} /> <FormWithData dataQuery={query.useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} />
</div> </div>
); );
} }
@ -91,7 +91,7 @@ function EmojiDetailForm({ data: emoji }) {
label="Delete" label="Delete"
type="button" type="button"
onClick={() => deleteEmoji(emoji.id)} onClick={() => deleteEmoji(emoji.id)}
className="danger button-inline" className="danger"
showError={false} showError={false}
result={deleteResult} result={deleteResult}
/> />
@ -126,7 +126,6 @@ function EmojiDetailForm({ data: emoji }) {
name="image" name="image"
label="Replace image" label="Replace image"
showError={false} showError={false}
className="button-inline"
result={result} result={result}
/> />

View File

@ -28,13 +28,11 @@ const base = "/settings/custom-emoji/local";
module.exports = function CustomEmoji() { module.exports = function CustomEmoji() {
return ( return (
<>
<Switch> <Switch>
<Route path={`${base}/:emojiId`}> <Route path={`${base}/:emojiId`}>
<EmojiDetail baseUrl={base} /> <EmojiDetail baseUrl={base} />
</Route> </Route>
<EmojiOverview baseUrl={base} /> <EmojiOverview baseUrl={base} />
</Switch> </Switch>
</>
); );
}; };

View File

@ -65,7 +65,7 @@ module.exports = function ParseFromToot({ emojiCodes }) {
onChange={onURLChange} onChange={onURLChange}
value={url} value={url}
/> />
<button className="button-inline" disabled={result.isLoading}> <button disabled={result.isLoading}>
<i className={[ <i className={[
"fa fa-fw", "fa fa-fw",
(result.isLoading (result.isLoading
@ -121,7 +121,6 @@ function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
}; };
const [formSubmit, result] = useFormSubmit(form, query.usePatchRemoteEmojisMutation(), { changedOnly: false }); const [formSubmit, result] = useFormSubmit(form, query.usePatchRemoteEmojisMutation(), { changedOnly: false });
console.log("action:", result.action);
const buttonsInactive = form.selectedEmoji.someSelected const buttonsInactive = form.selectedEmoji.someSelected
? {} ? {}
@ -130,41 +129,6 @@ function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
title: "No emoji selected, cannot perform any actions" title: "No emoji selected, cannot perform any actions"
}; };
// function submit(action) {
// Promise.try(() => {
// setError(null);
// const selectedShortcodes = emojiCheckList.selectedValues.map(([shortcode, entry]) => {
// if (action == "copy" && !entry.valid) {
// throw `One or more selected emoji have non-unique shortcodes (${shortcode}), unselect them or pick a different local shortcode`;
// }
// return {
// shortcode,
// localShortcode: entry.shortcode
// };
// });
// return patchRemoteEmojis({
// action,
// domain,
// list: selectedShortcodes,
// category
// }).unwrap();
// }).then(() => {
// emojiCheckList.reset();
// resetCategory();
// }).catch((e) => {
// if (Array.isArray(e)) {
// setError(e.map(([shortcode, msg]) => (
// <div key={shortcode}>
// {shortcode}: <span style={{ fontWeight: "initial" }}>{msg}</span>
// </div>
// )));
// } else {
// setError(e);
// }
// });
// }
return ( return (
<div className="parsed"> <div className="parsed">
<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span> <span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>

View File

@ -34,7 +34,7 @@ const BackButton = require("../../components/back-button");
const MutationButton = require("../../components/form/mutation-button"); const MutationButton = require("../../components/form/mutation-button");
module.exports = function InstanceDetail({ baseUrl }) { module.exports = function InstanceDetail({ baseUrl }) {
const { data: blockedInstances = [], isLoading } = query.useInstanceBlocksQuery(); const { data: blockedInstances = {}, isLoading } = query.useInstanceBlocksQuery();
let [_match, { domain }] = useRoute(`${baseUrl}/:domain`); let [_match, { domain }] = useRoute(`${baseUrl}/:domain`);
@ -43,7 +43,7 @@ module.exports = function InstanceDetail({ baseUrl }) {
} }
const existingBlock = React.useMemo(() => { const existingBlock = React.useMemo(() => {
return blockedInstances.find((block) => block.domain == domain); return blockedInstances[domain];
}, [blockedInstances, domain]); }, [blockedInstances, domain]);
if (domain == undefined) { if (domain == undefined) {

View File

@ -19,14 +19,15 @@
"use strict"; "use strict";
const React = require("react"); const React = require("react");
const { Switch, Route, Redirect, useLocation } = require("wouter");
const query = require("../../lib/query"); const query = require("../../lib/query");
const processDomainList = require("../../lib/import-export");
const { const {
useTextInput, useTextInput,
useBoolInput, useBoolInput,
useFileInput useRadioInput,
useCheckListInput
} = require("../../lib/form"); } = require("../../lib/form");
const useFormSubmit = require("../../lib/form/submit"); const useFormSubmit = require("../../lib/form/submit");
@ -35,66 +36,69 @@ const {
TextInput, TextInput,
TextArea, TextArea,
Checkbox, Checkbox,
FileInput, Select,
useCheckListInput RadioGroup
} = require("../../components/form/inputs"); } = require("../../components/form/inputs");
const FormWithData = require("../../lib/form/form-with-data");
const CheckList = require("../../components/check-list"); const CheckList = require("../../components/check-list");
const MutationButton = require("../../components/form/mutation-button"); const MutationButton = require("../../components/form/mutation-button");
const isValidDomain = require("is-valid-domain");
const FormWithData = require("../../lib/form/form-with-data");
const { Error } = require("../../components/error");
const baseUrl = "/settings/admin/federation/import-export";
module.exports = function ImportExport() { module.exports = function ImportExport() {
const [parsedList, setParsedList] = React.useState(); const [updateFromFile, setUpdateFromFile] = React.useState(false);
const form = { const form = {
domains: useTextInput("domains"), domains: useTextInput("domains"),
obfuscate: useBoolInput("obfuscate"), exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true })
commentPrivate: useTextInput("private_comment"),
commentPublic: useTextInput("public_comment"),
// json: useFileInput("json")
}; };
function submitImport(e) { const [submitParse, parseResult] = useFormSubmit(form, query.useProcessDomainListMutation());
e.preventDefault(); const [submitExport, exportResult] = useFormSubmit(form, query.useExportDomainListMutation());
Promise.try(() => { function fileChanged(e) {
return processDomainList(form.domains.value); const reader = new FileReader();
}).then((processed) => { reader.onload = function (read) {
setParsedList(processed); form.domains.setter(read.target.result);
}).catch((e) => { setUpdateFromFile(true);
console.error(e); };
}); reader.readAsText(e.target.files[0]);
}
const [_location, setLocation] = useLocation();
if (updateFromFile) {
setUpdateFromFile(false);
submitParse();
} }
return ( return (
<div className="import-export"> <Switch>
<h2>Import / Export</h2> <Route path={`${baseUrl}/list`}>
<div> {!parseResult.isSuccess && <Redirect to={baseUrl} />}
{
parsedList
? <ImportExportList list={parsedList} />
: <ImportExportForm form={form} submitImport={submitImport} />
}
</div>
</div>
);
};
function ImportExportList({ list }) { <h1>
const entryCheckList = useCheckListInput("selectedDomains", { <span className="button" onClick={() => {
entries: list, parseResult.reset();
uniqueKey: "domain" setLocation(baseUrl);
}); }}>
&lt; back
return ( </span> Confirm import:
<CheckList </h1>
<FormWithData
dataQuery={query.useInstanceBlocksQuery}
DataForm={ImportList}
list={parseResult.data}
/> />
); </Route>
}
function ImportExportForm({ form, submitImport }) { <Route>
return ( {parseResult.isSuccess && <Redirect to={`${baseUrl}/list`} />}
<form onSubmit={submitImport}> <h2>Import / Export suspended domains</h2>
<form onSubmit={submitParse}>
<TextArea <TextArea
field={form.domains} field={form.domains}
label="Domains, one per line (plaintext) or JSON" label="Domains, one per line (plaintext) or JSON"
@ -102,26 +106,193 @@ function ImportExportForm({ form, submitImport }) {
rows={8} rows={8}
/> />
<TextArea <div className="row">
field={form.commentPrivate} <div className="row">
label="Private comment" <MutationButton label="Import" result={parseResult} showError={false} />
rows={3} <button type="button" className="with-padding">
<label>
Import file
<input className="hidden" type="file" onChange={fileChanged} accept="application/json,text/plain" />
</label>
</button>
</div>
<div className="row">
<MutationButton form="export-form" name="export" label="Export" result={exportResult} showError={false} />
<MutationButton form="export-form" name="export-file" label="Export file" result={exportResult} showError={false} />
<Select
field={form.exportType}
options={<>
<option value="plain">Text</option>
<option value="json">JSON</option>
</>}
/>
</div>
</div>
{parseResult.error && <Error error={parseResult.error} />}
</form>
<form id="export-form" onSubmit={submitExport} />
</Route>
</Switch>
);
};
function ImportList({ list, data: blockedInstances }) {
const hasComment = React.useMemo(() => {
let hasPublic = false;
let hasPrivate = false;
list.some((entry) => {
if (entry.public_comment?.length > 0) {
hasPublic = true;
}
if (entry.private_comment?.length > 0) {
hasPrivate = true;
}
return hasPublic && hasPrivate;
});
if (hasPublic && hasPrivate) {
return { both: true };
} else if (hasPublic) {
return { type: "public_comment" };
} else if (hasPrivate) {
return { type: "private_comment" };
} else {
return {};
}
}, [list]);
const showComment = useTextInput("showComment", { defaultValue: hasComment.type ?? "public_comment" });
let commentName = "";
if (showComment.value == "public_comment") { commentName = "Public comment"; }
if (showComment.value == "private_comment") { commentName = "Private comment"; }
const form = {
domains: useCheckListInput("domains", {
entries: list,
uniqueKey: "domain"
}),
obfuscate: useBoolInput("obfuscate"),
privateComment: useTextInput("private_comment", {
defaultValue: `Imported on ${new Date().toLocaleString()}`
}),
privateCommentBehavior: useRadioInput("private_comment_behavior", {
defaultValue: "append",
options: {
append: "Append to",
replace: "Replace"
}
}),
publicComment: useTextInput("public_comment"),
publicCommentBehavior: useRadioInput("public_comment_behavior", {
defaultValue: "append",
options: {
append: "Append to",
replace: "Replace"
}
}),
};
const [importDomains, importResult] = useFormSubmit(form, query.useImportDomainListMutation(), { changedOnly: false });
return (
<>
<form onSubmit={importDomains} className="suspend-import-list">
<span>{list.length} domain{list.length != 1 ? "s" : ""} in this list</span>
{hasComment.both &&
<Select field={showComment} options={
<>
<option value="public_comment">Show public comments</option>
<option value="private_comment">Show private comments</option>
</>
} />
}
<CheckList
field={form.domains}
Component={DomainEntry}
header={
<>
<b>Domain</b>
<b></b>
<b>{commentName}</b>
</>
}
blockedInstances={blockedInstances}
commentType={showComment.value}
/> />
<TextArea <TextArea
field={form.commentPublic} field={form.privateComment}
label="Private comment"
rows={3}
/>
<RadioGroup
field={form.privateCommentBehavior}
label="imported private comment"
/>
<TextArea
field={form.publicComment}
label="Public comment" label="Public comment"
rows={3} rows={3}
/> />
<RadioGroup
field={form.publicCommentBehavior}
label="imported public comment"
/>
<Checkbox <Checkbox
field={form.obfuscate} field={form.obfuscate}
label="Obfuscate domain in public lists" label="Obfuscate domains in public lists"
/> />
<div> <MutationButton label="Import" result={importResult} />
<MutationButton label="Import" result={importResult} /> {/* default form action */}
</div>
</form> </form>
</>
);
}
function DomainEntry({ entry, onChange, blockedInstances, commentType }) {
const domainField = useTextInput("domain", {
defaultValue: entry.domain,
validator: (value) => {
return (entry.checked && !isValidDomain(value, { wildcard: true, allowUnicode: true }))
? "Invalid domain"
: "";
}
});
React.useEffect(() => {
onChange({ valid: domainField.valid });
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [domainField.valid]);
let icon = null;
if (blockedInstances[domainField.value] != undefined) {
icon = (
<>
<i className="fa fa-history already-blocked" aria-hidden="true" title="Domain block already exists"></i>
<span className="sr-only">Domain block already exists.</span>
</>
);
}
return (
<>
<TextInput
field={domainField}
onChange={(e) => {
domainField.onChange(e);
onChange({ domain: e.target.value, checked: true });
}}
/>
<span id="icon">{icon}</span>
<p>{entry[commentType]}</p>
</>
); );
} }

View File

@ -30,7 +30,7 @@ const InstanceImportExport = require("./import-export");
module.exports = function Federation({ }) { module.exports = function Federation({ }) {
return ( return (
<Switch> <Switch>
<Route path={`${baseUrl}/import-export`}> <Route path={`${baseUrl}/import-export/:list?`}>
<InstanceImportExport /> <InstanceImportExport />
</Route> </Route>

View File

@ -29,7 +29,6 @@ const { TextInput } = require("../../components/form/inputs");
const query = require("../../lib/query"); const query = require("../../lib/query");
const Loading = require("../../components/loading"); const Loading = require("../../components/loading");
const ImportExport = require("./import-export");
module.exports = function InstanceOverview({ baseUrl }) { module.exports = function InstanceOverview({ baseUrl }) {
const { data: blockedInstances = [], isLoading } = query.useInstanceBlocksQuery(); const { data: blockedInstances = [], isLoading } = query.useInstanceBlocksQuery();
@ -39,11 +38,15 @@ module.exports = function InstanceOverview({ baseUrl }) {
const filterField = useTextInput("filter"); const filterField = useTextInput("filter");
const filter = filterField.value; const filter = filterField.value;
const filteredInstances = React.useMemo(() => { const blockedInstancesList = React.useMemo(() => {
return matchSorter(Object.values(blockedInstances), filter, { keys: ["domain"] }); return Object.values(blockedInstances);
}, [blockedInstances, filter]); }, [blockedInstances]);
let filtered = blockedInstances.length - filteredInstances.length; const filteredInstances = React.useMemo(() => {
return matchSorter(blockedInstancesList, filter, { keys: ["domain"] });
}, [blockedInstancesList, filter]);
let filtered = blockedInstancesList.length - filteredInstances.length;
function filterFormSubmit(e) { function filterFormSubmit(e) {
e.preventDefault(); e.preventDefault();
@ -70,7 +73,7 @@ module.exports = function InstanceOverview({ baseUrl }) {
</form> </form>
<div> <div>
<span> <span>
{blockedInstances.length} blocked instance{blockedInstances.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered)`} {blockedInstancesList.length} blocked instance{blockedInstancesList.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered by search)`}
</span> </span>
<div className="list scrolling"> <div className="list scrolling">
{filteredInstances.map((entry) => { {filteredInstances.map((entry) => {

View File

@ -20,7 +20,7 @@
const React = require("react"); const React = require("react");
module.exports = function CheckList({ field, Component, ...componentProps }) { module.exports = function CheckList({ field, Component, header = " All", ...componentProps }) {
return ( return (
<div className="checkbox-list list"> <div className="checkbox-list list">
<label className="header"> <label className="header">
@ -29,7 +29,7 @@ module.exports = function CheckList({ field, Component, ...componentProps }) {
type="checkbox" type="checkbox"
onChange={field.toggleAll.onChange} onChange={field.toggleAll.onChange}
checked={field.toggleAll.value === 1} checked={field.toggleAll.value === 1}
/> All /> {header}
</label> </label>
{Object.values(field.value).map((entry) => ( {Object.values(field.value).map((entry) => (
<CheckListEntry <CheckListEntry

View File

@ -110,10 +110,32 @@ function Select({ label, field, options, ...inputProps }) {
); );
} }
function RadioGroup({ field, label, ...inputProps }) {
return (
<div className="form-field radio">
{Object.entries(field.options).map(([value, radioLabel]) => (
<label key={value}>
<input
type="radio"
name={field.name}
value={value}
checked={field.value == value}
onChange={field.onChange}
{...inputProps}
/>
{radioLabel}
</label>
))}
{label}
</div>
);
}
module.exports = { module.exports = {
TextInput, TextInput,
TextArea, TextArea,
FileInput, FileInput,
Checkbox, Checkbox,
Select Select,
RadioGroup
}; };

View File

@ -6,8 +6,8 @@ const Loading = require("../../components/loading");
// Wrap Form component inside component that fires the RTK Query call, // 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 // so Form will only be rendered when data is available to generate form-fields for
module.exports = function FormWithData({ dataQuery, DataForm, arg }) { module.exports = function FormWithData({ dataQuery, DataForm, queryArg, ...formProps }) {
const { data, isLoading } = dataQuery(arg); const { data, isLoading } = dataQuery(queryArg);
if (isLoading) { if (isLoading) {
return ( return (
@ -16,6 +16,6 @@ module.exports = function FormWithData({ dataQuery, DataForm, arg }) {
</div> </div>
); );
} else { } else {
return <DataForm data={data} />; return <DataForm data={data} {...formProps} />;
} }
}; };

View File

@ -33,6 +33,7 @@ module.exports = {
useTextInput: makeHook(require("./text")), useTextInput: makeHook(require("./text")),
useFileInput: makeHook(require("./file")), useFileInput: makeHook(require("./file")),
useBoolInput: makeHook(require("./bool")), useBoolInput: makeHook(require("./bool")),
useRadioInput: makeHook(require("./radio")),
useComboBoxInput: makeHook(require("./combo-box")), useComboBoxInput: makeHook(require("./combo-box")),
useCheckListInput: makeHook(require("./check-list")), useCheckListInput: makeHook(require("./check-list")),
useValue: function (name, value) { useValue: function (name, value) {

View File

@ -18,7 +18,34 @@
"use strict"; "use strict";
module.exports = (build) => ({ const React = require("react");
// importInstanceBlocks: build.mutation({
// }) module.exports = function useRadioInput({ name, Name }, { defaultValue, options } = {}) {
const [value, setValue] = React.useState(defaultValue);
function onChange(e) {
setValue(e.target.value);
}
function reset() {
setValue(defaultValue);
}
// Array / Object hybrid, for easier access in different contexts
return Object.assign([
onChange,
reset,
{
[name]: value,
[`set${Name}`]: setValue
}
], {
name,
onChange,
reset,
value,
setter: setValue,
options,
hasChanged: () => value != defaultValue
}); });
};

View File

@ -33,6 +33,10 @@ module.exports = function useFormSubmit(form, [mutationQuery, result], { changed
} else { } else {
action = e; action = e;
} }
if (action == "") {
action = undefined;
}
setUsedAction(action); setUsedAction(action);
// transform the field definitions into an object with just their values // transform the field definitions into an object with just their values
let updatedFields = []; let updatedFields = [];
@ -42,6 +46,7 @@ module.exports = function useFormSubmit(form, [mutationQuery, result], { changed
if (field.selectedValues != undefined) { if (field.selectedValues != undefined) {
let selected = field.selectedValues(); let selected = field.selectedValues();
if (!changedOnly || selected.length > 0) { if (!changedOnly || selected.length > 0) {
updatedFields.push(field);
return [field.name, selected]; return [field.name, selected];
} }
} else if (!changedOnly || field.hasChanged()) { } else if (!changedOnly || field.hasChanged()) {

View File

@ -20,7 +20,7 @@
const React = require("react"); const React = require("react");
module.exports = function useTextInput({name, Name}, {validator, defaultValue=""} = {}) { module.exports = function useTextInput({ name, Name }, { validator, defaultValue = "", dontReset = false } = {}) {
const [text, setText] = React.useState(defaultValue); const [text, setText] = React.useState(defaultValue);
const [valid, setValid] = React.useState(true); const [valid, setValid] = React.useState(true);
const textRef = React.useRef(null); const textRef = React.useRef(null);
@ -31,7 +31,9 @@ module.exports = function useTextInput({name, Name}, {validator, defaultValue=""
} }
function reset() { function reset() {
setText(""); if (!dontReset) {
setText(defaultValue);
}
} }
React.useEffect(() => { React.useEffect(() => {

View File

@ -64,7 +64,7 @@ module.exports = function getViews(struct) {
} }
panelRouterEl.push(( panelRouterEl.push((
<Route path={`${url}/:page?`} key={url}> <Route path={`${url}/:page*`} key={url}>
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => { }}> <ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => { }}>
{/* FIXME: implement onReset */} {/* FIXME: implement onReset */}
<ViewComponent /> <ViewComponent />

View File

@ -1,65 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const Promise = require("bluebird");
const isValidDomain = require("is-valid-domain");
function parseDomainList(list) {
if (list[0] == "[") {
return JSON.parse(list);
} else {
return list.split("\n").map((line) => {
let trimmed = line.trim();
return trimmed.length > 0
? { domain: trimmed }
: null;
}).filter((a) => a); // not `null`
}
}
function validateDomainList(list) {
list.forEach((entry) => {
entry.valid = isValidDomain(entry.domain, { wildcard: true, allowUnicode: true });
});
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 = function processDomainList(data) {
return Promise.try(() => {
return parseDomainList(data);
}).then((parsed) => {
return deduplicateDomainList(parsed);
}).then((deduped) => {
return validateDomainList(deduped);
});
};

View File

@ -0,0 +1,212 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const Promise = require("bluebird");
const isValidDomain = require("is-valid-domain");
const fileDownload = require("js-file-download");
const {
replaceCacheOnMutation,
domainListToObject,
unwrapRes
} = require("../lib");
function parseDomainList(list) {
if (list[0] == "[") {
return JSON.parse(list);
} 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) => {
entry.valid = (entry.valid !== false) && isValidDomain(entry.domain, { wildcard: true, allowUnicode: true });
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) => {
return { data };
}).catch((e) => {
return { error: e.toString() };
});
}
}),
exportDomainList: build.mutation({
queryFn: (formData, api, _extraOpts, baseQuery) => {
return Promise.try(() => {
return baseQuery({
url: `/api/v1/admin/domain_blocks`
});
}).then(unwrapRes).then((blockedInstances) => {
return blockedInstances.map((entry) => {
if (formData.exportType == "json") {
return {
domain: entry.domain,
public_comment: entry.public_comment
};
} else {
return entry.domain;
}
});
}).then((exportList) => {
if (formData.exportType == "json") {
return JSON.stringify(exportList);
} else {
return exportList.join("\n");
}
}).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 mime;
let filename = [
domain,
"blocklist",
date.getFullYear(),
(date.getMonth() + 1).toString().padStart(2, "0"),
date.getDate().toString().padStart(2, "0"),
].join("-");
if (formData.exportType == "json") {
filename += ".json";
mime = "application/json";
} else {
filename += ".txt";
mime = "text/plain";
}
fileDownload(exportAsString, filename, 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")
})
});
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 (val == undefined) {
delete entry[key];
}
});
};
}

View File

@ -21,7 +21,8 @@
const { const {
replaceCacheOnMutation, replaceCacheOnMutation,
appendCacheOnMutation, appendCacheOnMutation,
spliceCacheOnMutation removeFromCacheOnMutation,
domainListToObject
} = require("../lib"); } = require("../lib");
const base = require("../base"); const base = require("../base");
@ -48,7 +49,8 @@ const endpoints = (build) => ({
instanceBlocks: build.query({ instanceBlocks: build.query({
query: () => ({ query: () => ({
url: `/api/v1/admin/domain_blocks` url: `/api/v1/admin/domain_blocks`
}) }),
transformResponse: domainListToObject
}), }),
addInstanceBlock: build.mutation({ addInstanceBlock: build.mutation({
query: (formData) => ({ query: (formData) => ({
@ -65,13 +67,13 @@ const endpoints = (build) => ({
method: "DELETE", method: "DELETE",
url: `/api/v1/admin/domain_blocks/${id}`, url: `/api/v1/admin/domain_blocks/${id}`,
}), }),
...spliceCacheOnMutation("instanceBlocks", { ...removeFromCacheOnMutation("instanceBlocks", {
findKey: (draft, newData) => { findKey: (_draft, newData) => {
return draft.findIndex((block) => block.id == newData.id); return newData.domain;
} }
}) })
}), }),
...require("./federation-bulk")(build) ...require("./import-export")(build)
}); });
module.exports = base.injectEndpoints({ endpoints }); module.exports = base.injectEndpoints({ endpoints });

View File

@ -18,6 +18,7 @@
"use strict"; "use strict";
const syncpipe = require("syncpipe");
const base = require("./base"); const base = require("./base");
module.exports = { module.exports = {
@ -28,26 +29,36 @@ module.exports = {
return res.data; 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(_)
]);
},
replaceCacheOnMutation: makeCacheMutation((draft, newData) => { replaceCacheOnMutation: makeCacheMutation((draft, newData) => {
Object.assign(draft, newData); Object.assign(draft, newData);
}), }),
appendCacheOnMutation: makeCacheMutation((draft, newData) => { appendCacheOnMutation: makeCacheMutation((draft, newData) => {
draft.push(newData); draft.push(newData);
}), }),
spliceCacheOnMutation: makeCacheMutation((draft, newData, key) => { spliceCacheOnMutation: makeCacheMutation((draft, newData, { key }) => {
draft.splice(key, 1); draft.splice(key, 1);
}), }),
updateCacheOnMutation: makeCacheMutation((draft, newData, key) => { updateCacheOnMutation: makeCacheMutation((draft, newData, { key }) => {
draft[key] = newData; draft[key] = newData;
}), }),
removeFromCacheOnMutation: makeCacheMutation((draft, newData, key) => { removeFromCacheOnMutation: makeCacheMutation((draft, newData, { key }) => {
delete draft[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 // https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#pessimistic-updates
function makeCacheMutation(action) { function makeCacheMutation(action) {
return function cacheMutation(queryName, { key, findKey, arg } = {}) { return function cacheMutation(queryName, { key, findKey, arg, ...opts } = {}) {
return { return {
onQueryStarted: (_, { dispatch, queryFulfilled }) => { onQueryStarted: (_, { dispatch, queryFulfilled }) => {
queryFulfilled.then(({ data: newData }) => { queryFulfilled.then(({ data: newData }) => {
@ -55,7 +66,7 @@ function makeCacheMutation(action) {
if (findKey != undefined) { if (findKey != undefined) {
key = findKey(draft, newData); key = findKey(draft, newData);
} }
action(draft, newData, key); action(draft, newData, { key, ...opts });
})); }));
}); });
} }

View File

@ -220,14 +220,15 @@ section.with-sidebar > div, section.with-sidebar > form {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
input, textarea, button { input, textarea {
width: 100%; width: 100%;
line-height: 1.5rem; line-height: 1.5rem;
} }
.button-inline { button {
width: auto; width: auto;
align-self: flex-start; align-self: flex-start;
line-height: 1.5rem;
} }
input[type=checkbox] { input[type=checkbox] {
@ -702,6 +703,10 @@ button.with-icon {
} }
} }
button.with-padding {
padding: 0.5rem calc(0.5rem + $fa-fw);
}
.loading-icon { .loading-icon {
align-self: flex-start; align-self: flex-start;
} }
@ -713,6 +718,43 @@ button.with-icon {
animation-fill-mode: forwards; animation-fill-mode: forwards;
} }
.suspend-import-list {
.checkbox-list {
.header, .entry {
display: grid;
grid-template-columns: auto 25ch auto 1fr;
}
}
.entry {
#icon {
margin-left: -0.5rem;
align-self: center;
}
#icon .already-blocked {
color: $green1;
}
p {
align-self: center;
margin: 0;
}
}
}
.form-field.radio {
&, label {
display: flex;
gap: 0.5rem;
}
input {
width: auto;
place-self: center;
}
}
@keyframes fadeout { @keyframes fadeout {
from { from {
opacity: 1; opacity: 1;