mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-11-03 17:42:25 -06:00 
			
		
		
		
	[feature/frogend] (Mastodon) domain block CSV import (#1390)
* checkbox-list styling with taller <p> element * CSV import/export, UI/UX improvements to import-export interface * minor styling tweaks * csv export, clean up export type branching * abstract domain block entry validation * foundation for PSL check + suggestions * Squashed commit of the following: commite3655ba4fbAuthor: f0x <f0x@cthu.lu> Date: Tue Jan 31 15:19:10 2023 +0100 let debug depend on env (prod/debug) again commit79c792b832Author: f0x <f0x@cthu.lu> Date: Tue Jan 31 00:34:01 2023 +0100 update checklist components commit4367960fe4Author: f0x <f0x@cthu.lu> Date: Mon Jan 30 23:46:20 2023 +0100 checklist performance improvements commit204a4c02d1Author: f0x <f0x@cthu.lu> Date: Mon Jan 30 20:05:34 2023 +0100 checklist field: use reducer for state * remove debug logging * show and use domain block suggestion * restructure import/export buttons * updating suggestions * suggestion overview * restructure check-list behavior, domain import/export
This commit is contained in:
		
					parent
					
						
							
								49beb17a8f
							
						
					
				
			
			
				commit
				
					
						a59dc855d9
					
				
			
		
					 17 changed files with 1077 additions and 484 deletions
				
			
		| 
						 | 
				
			
			@ -1,307 +0,0 @@
 | 
			
		|||
/*
 | 
			
		||||
	GoToSocial
 | 
			
		||||
	Copyright (C) 2021-2023 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 React = require("react");
 | 
			
		||||
const { Switch, Route, Redirect, useLocation } = require("wouter");
 | 
			
		||||
 | 
			
		||||
const query = require("../../lib/query");
 | 
			
		||||
 | 
			
		||||
const {
 | 
			
		||||
	useTextInput,
 | 
			
		||||
	useBoolInput,
 | 
			
		||||
	useRadioInput,
 | 
			
		||||
	useCheckListInput
 | 
			
		||||
} = require("../../lib/form");
 | 
			
		||||
 | 
			
		||||
const useFormSubmit = require("../../lib/form/submit");
 | 
			
		||||
 | 
			
		||||
const {
 | 
			
		||||
	TextInput,
 | 
			
		||||
	TextArea,
 | 
			
		||||
	Checkbox,
 | 
			
		||||
	Select,
 | 
			
		||||
	RadioGroup
 | 
			
		||||
} = require("../../components/form/inputs");
 | 
			
		||||
 | 
			
		||||
const CheckList = require("../../components/check-list");
 | 
			
		||||
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() {
 | 
			
		||||
	const [updateFromFile, setUpdateFromFile] = React.useState(false);
 | 
			
		||||
	const form = {
 | 
			
		||||
		domains: useTextInput("domains"),
 | 
			
		||||
		exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true })
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const [submitParse, parseResult] = useFormSubmit(form, query.useProcessDomainListMutation());
 | 
			
		||||
	const [submitExport, exportResult] = useFormSubmit(form, query.useExportDomainListMutation());
 | 
			
		||||
 | 
			
		||||
	function fileChanged(e) {
 | 
			
		||||
		const reader = new FileReader();
 | 
			
		||||
		reader.onload = function (read) {
 | 
			
		||||
			form.domains.setter(read.target.result);
 | 
			
		||||
			setUpdateFromFile(true);
 | 
			
		||||
		};
 | 
			
		||||
		reader.readAsText(e.target.files[0]);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	React.useEffect(() => {
 | 
			
		||||
		if (exportResult.isSuccess) {
 | 
			
		||||
			form.domains.setter(exportResult.data);
 | 
			
		||||
		}
 | 
			
		||||
		/* eslint-disable-next-line react-hooks/exhaustive-deps */
 | 
			
		||||
	}, [exportResult]);
 | 
			
		||||
 | 
			
		||||
	const [_location, setLocation] = useLocation();
 | 
			
		||||
 | 
			
		||||
	if (updateFromFile) {
 | 
			
		||||
		setUpdateFromFile(false);
 | 
			
		||||
		submitParse();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<Switch>
 | 
			
		||||
			<Route path={`${baseUrl}/list`}>
 | 
			
		||||
				{!parseResult.isSuccess && <Redirect to={baseUrl} />}
 | 
			
		||||
 | 
			
		||||
				<h1>
 | 
			
		||||
					<span className="button" onClick={() => {
 | 
			
		||||
						parseResult.reset();
 | 
			
		||||
						setLocation(baseUrl);
 | 
			
		||||
					}}>
 | 
			
		||||
						< back
 | 
			
		||||
					</span> Confirm import:
 | 
			
		||||
				</h1>
 | 
			
		||||
				<FormWithData
 | 
			
		||||
					dataQuery={query.useInstanceBlocksQuery}
 | 
			
		||||
					DataForm={ImportList}
 | 
			
		||||
					list={parseResult.data}
 | 
			
		||||
				/>
 | 
			
		||||
			</Route>
 | 
			
		||||
 | 
			
		||||
			<Route>
 | 
			
		||||
				{parseResult.isSuccess && <Redirect to={`${baseUrl}/list`} />}
 | 
			
		||||
				<h2>Import / Export suspended domains</h2>
 | 
			
		||||
 | 
			
		||||
				<div>
 | 
			
		||||
					<form onSubmit={submitParse}>
 | 
			
		||||
						<TextArea
 | 
			
		||||
							field={form.domains}
 | 
			
		||||
							label="Domains, one per line (plaintext) or JSON"
 | 
			
		||||
							placeholder={`google.com\nfacebook.com`}
 | 
			
		||||
							rows={8}
 | 
			
		||||
						/>
 | 
			
		||||
 | 
			
		||||
						<div className="row">
 | 
			
		||||
							<MutationButton label="Import" result={parseResult} showError={false} />
 | 
			
		||||
							<button type="button" className="with-padding">
 | 
			
		||||
								<label>
 | 
			
		||||
									Import file
 | 
			
		||||
									<input className="hidden" type="file" onChange={fileChanged} accept="application/json,text/plain" />
 | 
			
		||||
								</label>
 | 
			
		||||
							</button>
 | 
			
		||||
						</div>
 | 
			
		||||
					</form>
 | 
			
		||||
					<form onSubmit={submitExport}>
 | 
			
		||||
						<div className="row">
 | 
			
		||||
							<MutationButton name="export" label="Export" result={exportResult} showError={false} />
 | 
			
		||||
							<MutationButton 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>
 | 
			
		||||
					</form>
 | 
			
		||||
					{parseResult.error && <Error error={parseResult.error} />}
 | 
			
		||||
					{exportResult.error && <Error error={exportResult.error} />}
 | 
			
		||||
				</div>
 | 
			
		||||
			</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
 | 
			
		||||
					field={form.privateComment}
 | 
			
		||||
					label="Private comment"
 | 
			
		||||
					rows={3}
 | 
			
		||||
				/>
 | 
			
		||||
				<RadioGroup
 | 
			
		||||
					field={form.privateCommentBehavior}
 | 
			
		||||
					label="imported private comment"
 | 
			
		||||
				/>
 | 
			
		||||
 | 
			
		||||
				<TextArea
 | 
			
		||||
					field={form.publicComment}
 | 
			
		||||
					label="Public comment"
 | 
			
		||||
					rows={3}
 | 
			
		||||
				/>
 | 
			
		||||
				<RadioGroup
 | 
			
		||||
					field={form.publicCommentBehavior}
 | 
			
		||||
					label="imported public comment"
 | 
			
		||||
				/>
 | 
			
		||||
 | 
			
		||||
				<Checkbox
 | 
			
		||||
					field={form.obfuscate}
 | 
			
		||||
					label="Obfuscate domains in public lists"
 | 
			
		||||
				/>
 | 
			
		||||
 | 
			
		||||
				<MutationButton label="Import" result={importResult} />
 | 
			
		||||
			</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>
 | 
			
		||||
		</>
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,64 @@
 | 
			
		|||
/*
 | 
			
		||||
	GoToSocial
 | 
			
		||||
	Copyright (C) 2021-2023 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 React = require("react");
 | 
			
		||||
 | 
			
		||||
module.exports = function ExportFormatTable() {
 | 
			
		||||
	return (
 | 
			
		||||
		<table className="export-format-table">
 | 
			
		||||
			<thead>
 | 
			
		||||
				<tr>
 | 
			
		||||
					<th rowSpan={2} />
 | 
			
		||||
					<th colSpan={2}>Includes</th>
 | 
			
		||||
					<th colSpan={2}>Importable by</th>
 | 
			
		||||
				</tr>
 | 
			
		||||
				<tr>
 | 
			
		||||
					<th>Domain</th>
 | 
			
		||||
					<th>Public comment</th>
 | 
			
		||||
					<th>GoToSocial</th>
 | 
			
		||||
					<th>Mastodon</th>
 | 
			
		||||
				</tr>
 | 
			
		||||
			</thead>
 | 
			
		||||
			<tbody>
 | 
			
		||||
				<Format name="Text" info={[true, false, true, false]} />
 | 
			
		||||
				<Format name="JSON" info={[true, true, true, false]} />
 | 
			
		||||
				<Format name="CSV" info={[true, true, true, true]} />
 | 
			
		||||
			</tbody>
 | 
			
		||||
		</table>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function Format({ name, info }) {
 | 
			
		||||
	return (
 | 
			
		||||
		<tr>
 | 
			
		||||
			<td><b>{name}</b></td>
 | 
			
		||||
			{info.map((b, key) => <td key={key} className="bool">{bool(b)}</td>)}
 | 
			
		||||
		</tr>
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function bool(val) {
 | 
			
		||||
	return (
 | 
			
		||||
		<>
 | 
			
		||||
			<i className={`fa fa-${val ? "check" : "times"}`} aria-hidden="true"></i>
 | 
			
		||||
			<span className="sr-only">{val ? "Yes" : "No"}</span>
 | 
			
		||||
		</>
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										123
									
								
								web/source/settings/admin/federation/import-export/form.jsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								web/source/settings/admin/federation/import-export/form.jsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,123 @@
 | 
			
		|||
/*
 | 
			
		||||
	GoToSocial
 | 
			
		||||
	Copyright (C) 2021-2023 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 React = require("react");
 | 
			
		||||
 | 
			
		||||
const query = require("../../../lib/query");
 | 
			
		||||
const useFormSubmit = require("../../../lib/form/submit");
 | 
			
		||||
 | 
			
		||||
const {
 | 
			
		||||
	TextArea,
 | 
			
		||||
	Select,
 | 
			
		||||
} = require("../../../components/form/inputs");
 | 
			
		||||
 | 
			
		||||
const MutationButton = require("../../../components/form/mutation-button");
 | 
			
		||||
 | 
			
		||||
const { Error } = require("../../../components/error");
 | 
			
		||||
const ExportFormatTable = require("./export-format-table");
 | 
			
		||||
 | 
			
		||||
module.exports = function ImportExportForm({ form, submitParse, parseResult }) {
 | 
			
		||||
	const [submitExport, exportResult] = useFormSubmit(form, query.useExportDomainListMutation());
 | 
			
		||||
 | 
			
		||||
	const [updateFromFile, setUpdateFromFile] = React.useState(false);
 | 
			
		||||
 | 
			
		||||
	function fileChanged(e) {
 | 
			
		||||
		const reader = new FileReader();
 | 
			
		||||
		reader.onload = function (read) {
 | 
			
		||||
			form.domains.setter(read.target.result);
 | 
			
		||||
			setUpdateFromFile(true);
 | 
			
		||||
		};
 | 
			
		||||
		reader.readAsText(e.target.files[0]);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	React.useEffect(() => {
 | 
			
		||||
		if (exportResult.isSuccess) {
 | 
			
		||||
			form.domains.setter(exportResult.data);
 | 
			
		||||
		}
 | 
			
		||||
		/* eslint-disable-next-line react-hooks/exhaustive-deps */
 | 
			
		||||
	}, [exportResult]);
 | 
			
		||||
 | 
			
		||||
	if (updateFromFile) {
 | 
			
		||||
		setUpdateFromFile(false);
 | 
			
		||||
		submitParse();
 | 
			
		||||
	}
 | 
			
		||||
	return (
 | 
			
		||||
		<>
 | 
			
		||||
			<h1>Import / Export suspended domains</h1>
 | 
			
		||||
			<p>
 | 
			
		||||
				This page can be used to import and export lists of domains to suspend.
 | 
			
		||||
				Exports can be done in various formats, with varying functionality and support in other software.
 | 
			
		||||
				Imports will automatically detect what format is being processed.
 | 
			
		||||
			</p>
 | 
			
		||||
			<ExportFormatTable />
 | 
			
		||||
			<div className="import-export">
 | 
			
		||||
				<TextArea
 | 
			
		||||
					field={form.domains}
 | 
			
		||||
					label="Domains"
 | 
			
		||||
					placeholder={`google.com\nfacebook.com`}
 | 
			
		||||
					rows={8}
 | 
			
		||||
				/>
 | 
			
		||||
 | 
			
		||||
				<div className="button-grid">
 | 
			
		||||
					<MutationButton
 | 
			
		||||
						label="Import"
 | 
			
		||||
						type="button"
 | 
			
		||||
						onClick={() => submitParse()}
 | 
			
		||||
						result={parseResult}
 | 
			
		||||
						showError={false}
 | 
			
		||||
					/>
 | 
			
		||||
					<label className="button">
 | 
			
		||||
						Import file
 | 
			
		||||
						<input
 | 
			
		||||
							type="file"
 | 
			
		||||
							className="hidden"
 | 
			
		||||
							onChange={fileChanged}
 | 
			
		||||
							accept="application/json,text/plain,text/csv"
 | 
			
		||||
						/>
 | 
			
		||||
					</label>
 | 
			
		||||
					<b /> {/* grid filler */}
 | 
			
		||||
					<MutationButton
 | 
			
		||||
						label="Export"
 | 
			
		||||
						type="button"
 | 
			
		||||
						onClick={() => submitExport("export")}
 | 
			
		||||
						result={exportResult} showError={false}
 | 
			
		||||
					/>
 | 
			
		||||
					<MutationButton label="Export to file" type="button" onClick={() => submitExport("export-file")} result={exportResult} showError={false} />
 | 
			
		||||
					<div className="export-file">
 | 
			
		||||
						<span>
 | 
			
		||||
							as
 | 
			
		||||
						</span>
 | 
			
		||||
						<Select
 | 
			
		||||
							field={form.exportType}
 | 
			
		||||
							options={<>
 | 
			
		||||
								<option value="plain">Text</option>
 | 
			
		||||
								<option value="json">JSON</option>
 | 
			
		||||
								<option value="csv">CSV</option>
 | 
			
		||||
							</>}
 | 
			
		||||
						/>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{parseResult.error && <Error error={parseResult.error} />}
 | 
			
		||||
				{exportResult.error && <Error error={exportResult.error} />}
 | 
			
		||||
			</div>
 | 
			
		||||
		</>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										78
									
								
								web/source/settings/admin/federation/import-export/index.jsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								web/source/settings/admin/federation/import-export/index.jsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,78 @@
 | 
			
		|||
/*
 | 
			
		||||
	GoToSocial
 | 
			
		||||
	Copyright (C) 2021-2023 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 React = require("react");
 | 
			
		||||
const { Switch, Route, Redirect, useLocation } = require("wouter");
 | 
			
		||||
 | 
			
		||||
const query = require("../../../lib/query");
 | 
			
		||||
 | 
			
		||||
const {
 | 
			
		||||
	useTextInput,
 | 
			
		||||
} = require("../../../lib/form");
 | 
			
		||||
 | 
			
		||||
const useFormSubmit = require("../../../lib/form/submit");
 | 
			
		||||
 | 
			
		||||
const ProcessImport = require("./process");
 | 
			
		||||
const ImportExportForm = require("./form");
 | 
			
		||||
 | 
			
		||||
const baseUrl = "/settings/admin/federation/import-export";
 | 
			
		||||
 | 
			
		||||
module.exports = function ImportExport() {
 | 
			
		||||
	const form = {
 | 
			
		||||
		domains: useTextInput("domains"),
 | 
			
		||||
		exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true })
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const [submitParse, parseResult] = useFormSubmit(form, query.useProcessDomainListMutation());
 | 
			
		||||
 | 
			
		||||
	const [_location, setLocation] = useLocation();
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<Switch>
 | 
			
		||||
			<Route path={`${baseUrl}/process`}>
 | 
			
		||||
				{parseResult.isSuccess ? (
 | 
			
		||||
					<>
 | 
			
		||||
						<h1>
 | 
			
		||||
							<span className="button" onClick={() => {
 | 
			
		||||
								parseResult.reset();
 | 
			
		||||
								setLocation(baseUrl);
 | 
			
		||||
							}}>
 | 
			
		||||
								< back
 | 
			
		||||
							</span> Confirm import:
 | 
			
		||||
						</h1>
 | 
			
		||||
						<ProcessImport
 | 
			
		||||
							list={parseResult.data}
 | 
			
		||||
						/>
 | 
			
		||||
					</>
 | 
			
		||||
				) : <Redirect to={baseUrl} />}
 | 
			
		||||
			</Route>
 | 
			
		||||
 | 
			
		||||
			<Route>
 | 
			
		||||
				{!parseResult.isSuccess ? (
 | 
			
		||||
					<ImportExportForm
 | 
			
		||||
						form={form}
 | 
			
		||||
						submitParse={submitParse}
 | 
			
		||||
						parseResult={parseResult}
 | 
			
		||||
					/>
 | 
			
		||||
				) : <Redirect to={`${baseUrl}/process`} />}
 | 
			
		||||
			</Route>
 | 
			
		||||
		</Switch>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										327
									
								
								web/source/settings/admin/federation/import-export/process.jsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										327
									
								
								web/source/settings/admin/federation/import-export/process.jsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,327 @@
 | 
			
		|||
/*
 | 
			
		||||
	GoToSocial
 | 
			
		||||
	Copyright (C) 2021-2023 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 React = require("react");
 | 
			
		||||
 | 
			
		||||
const query = require("../../../lib/query");
 | 
			
		||||
const { isValidDomainBlock, hasBetterScope } = require("../../../lib/domain-block");
 | 
			
		||||
 | 
			
		||||
const {
 | 
			
		||||
	useTextInput,
 | 
			
		||||
	useBoolInput,
 | 
			
		||||
	useRadioInput,
 | 
			
		||||
	useCheckListInput
 | 
			
		||||
} = require("../../../lib/form");
 | 
			
		||||
 | 
			
		||||
const useFormSubmit = require("../../../lib/form/submit");
 | 
			
		||||
 | 
			
		||||
const {
 | 
			
		||||
	TextInput,
 | 
			
		||||
	TextArea,
 | 
			
		||||
	Checkbox,
 | 
			
		||||
	Select,
 | 
			
		||||
	RadioGroup
 | 
			
		||||
} = require("../../../components/form/inputs");
 | 
			
		||||
 | 
			
		||||
const CheckList = require("../../../components/check-list");
 | 
			
		||||
const MutationButton = require("../../../components/form/mutation-button");
 | 
			
		||||
const FormWithData = require("../../../lib/form/form-with-data");
 | 
			
		||||
 | 
			
		||||
module.exports = React.memo(
 | 
			
		||||
	function ProcessImport({ list }) {
 | 
			
		||||
		return (
 | 
			
		||||
			<div className="without-border">
 | 
			
		||||
				<FormWithData
 | 
			
		||||
					dataQuery={query.useInstanceBlocksQuery}
 | 
			
		||||
					DataForm={ImportList}
 | 
			
		||||
					list={list}
 | 
			
		||||
				/>
 | 
			
		||||
			</div>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
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" });
 | 
			
		||||
 | 
			
		||||
	const form = {
 | 
			
		||||
		domains: useCheckListInput("domains", { entries: list }),
 | 
			
		||||
		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>
 | 
			
		||||
						</>
 | 
			
		||||
					} />
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				<DomainCheckList
 | 
			
		||||
					field={form.domains}
 | 
			
		||||
					blockedInstances={blockedInstances}
 | 
			
		||||
					commentType={showComment.value}
 | 
			
		||||
				/>
 | 
			
		||||
 | 
			
		||||
				<TextArea
 | 
			
		||||
					field={form.privateComment}
 | 
			
		||||
					label="Private comment"
 | 
			
		||||
					rows={3}
 | 
			
		||||
				/>
 | 
			
		||||
				<RadioGroup
 | 
			
		||||
					field={form.privateCommentBehavior}
 | 
			
		||||
					label="imported private comment"
 | 
			
		||||
				/>
 | 
			
		||||
 | 
			
		||||
				<TextArea
 | 
			
		||||
					field={form.publicComment}
 | 
			
		||||
					label="Public comment"
 | 
			
		||||
					rows={3}
 | 
			
		||||
				/>
 | 
			
		||||
				<RadioGroup
 | 
			
		||||
					field={form.publicCommentBehavior}
 | 
			
		||||
					label="imported public comment"
 | 
			
		||||
				/>
 | 
			
		||||
 | 
			
		||||
				<Checkbox
 | 
			
		||||
					field={form.obfuscate}
 | 
			
		||||
					label="Obfuscate domains in public lists"
 | 
			
		||||
				/>
 | 
			
		||||
 | 
			
		||||
				<MutationButton label="Import" result={importResult} />
 | 
			
		||||
			</form>
 | 
			
		||||
		</>
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DomainCheckList({ field, blockedInstances, commentType }) {
 | 
			
		||||
	const getExtraProps = React.useCallback((entry) => {
 | 
			
		||||
		return {
 | 
			
		||||
			comment: entry[commentType],
 | 
			
		||||
			alreadyExists: blockedInstances[entry.domain] != undefined
 | 
			
		||||
		};
 | 
			
		||||
	}, [blockedInstances, commentType]);
 | 
			
		||||
 | 
			
		||||
	const entriesWithSuggestions = React.useMemo(() => (
 | 
			
		||||
		Object.values(field.value).filter((entry) => entry.suggest)
 | 
			
		||||
	), [field.value]);
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<>
 | 
			
		||||
			<CheckList
 | 
			
		||||
				field={field}
 | 
			
		||||
				header={<>
 | 
			
		||||
					<b>Domain</b>
 | 
			
		||||
					<b></b>
 | 
			
		||||
					<b>
 | 
			
		||||
						{commentType == "public_comment" && "Public comment"}
 | 
			
		||||
						{commentType == "private_comment" && "Private comment"}
 | 
			
		||||
					</b>
 | 
			
		||||
				</>}
 | 
			
		||||
				EntryComponent={DomainEntry}
 | 
			
		||||
				getExtraProps={getExtraProps}
 | 
			
		||||
			/>
 | 
			
		||||
			<UpdateHint
 | 
			
		||||
				entries={entriesWithSuggestions}
 | 
			
		||||
				updateEntry={field.onChange}
 | 
			
		||||
				updateMultiple={field.updateMultiple}
 | 
			
		||||
			/>
 | 
			
		||||
		</>
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const UpdateHint = React.memo(
 | 
			
		||||
	function UpdateHint({ entries, updateEntry, updateMultiple }) {
 | 
			
		||||
		if (entries.length == 0) {
 | 
			
		||||
			return null;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		function changeAll() {
 | 
			
		||||
			updateMultiple(
 | 
			
		||||
				entries.map((entry) => [entry.key, { domain: entry.suggest, suggest: null }])
 | 
			
		||||
			);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return (
 | 
			
		||||
			<div className="update-hints">
 | 
			
		||||
				<p>
 | 
			
		||||
					{entries.length} {entries.length == 1 ? "entry uses" : "entries use"} a specific subdomain,
 | 
			
		||||
					which you might want to change to the main domain, as that includes all it's (future) subdomains.
 | 
			
		||||
				</p>
 | 
			
		||||
				<div className="hints">
 | 
			
		||||
					{entries.map((entry) => (
 | 
			
		||||
						<UpdateableEntry key={entry.key} entry={entry} updateEntry={updateEntry} />
 | 
			
		||||
					))}
 | 
			
		||||
				</div>
 | 
			
		||||
				{entries.length > 0 && <a onClick={changeAll}>change all</a>}
 | 
			
		||||
			</div>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const UpdateableEntry = React.memo(
 | 
			
		||||
	function UpdateableEntry({ entry, updateEntry }) {
 | 
			
		||||
		return (
 | 
			
		||||
			<>
 | 
			
		||||
				<span className="text-cutoff">{entry.domain}</span>
 | 
			
		||||
				<i class="fa fa-long-arrow-right" aria-hidden="true"></i>
 | 
			
		||||
				<span>{entry.suggest}</span>
 | 
			
		||||
				<a role="button" onClick={() =>
 | 
			
		||||
					updateEntry(entry.key, { domain: entry.suggest, suggest: null })
 | 
			
		||||
				}>change</a>
 | 
			
		||||
			</>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
function domainValidationError(isValid) {
 | 
			
		||||
	return isValid ? "" : "Invalid domain";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DomainEntry({ entry, onChange, extraProps: { alreadyExists, comment } }) {
 | 
			
		||||
	const domainField = useTextInput("domain", {
 | 
			
		||||
		defaultValue: entry.domain,
 | 
			
		||||
		showValidation: entry.checked,
 | 
			
		||||
		initValidation: domainValidationError(entry.valid),
 | 
			
		||||
		validator: (value) => domainValidationError(isValidDomainBlock(value))
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	React.useEffect(() => {
 | 
			
		||||
		if (entry.valid != domainField.valid) {
 | 
			
		||||
			onChange({ valid: domainField.valid });
 | 
			
		||||
		}
 | 
			
		||||
	}, [onChange, entry.valid, domainField.valid]);
 | 
			
		||||
 | 
			
		||||
	React.useEffect(() => {
 | 
			
		||||
		if (entry.domain != domainField.value) {
 | 
			
		||||
			domainField.setter(entry.domain);
 | 
			
		||||
		}
 | 
			
		||||
		// domainField.setter is enough, eslint wants domainField
 | 
			
		||||
		// eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
	}, [entry.domain, domainField.setter]);
 | 
			
		||||
 | 
			
		||||
	React.useEffect(() => {
 | 
			
		||||
		onChange({ suggest: hasBetterScope(domainField.value) });
 | 
			
		||||
		// only need this update if it's the entry.checked that updated, not onChange
 | 
			
		||||
		// eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
	}, [domainField.value]);
 | 
			
		||||
 | 
			
		||||
	function clickIcon(e) {
 | 
			
		||||
		if (entry.suggest) {
 | 
			
		||||
			e.stopPropagation();
 | 
			
		||||
			e.preventDefault();
 | 
			
		||||
			domainField.setter(entry.suggest);
 | 
			
		||||
			onChange({ domain: entry.suggest, checked: true });
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<>
 | 
			
		||||
			<TextInput
 | 
			
		||||
				field={domainField}
 | 
			
		||||
				onChange={(e) => {
 | 
			
		||||
					domainField.onChange(e);
 | 
			
		||||
					onChange({ domain: e.target.value, checked: true });
 | 
			
		||||
				}}
 | 
			
		||||
			/>
 | 
			
		||||
			<span id="icon" onClick={clickIcon}>
 | 
			
		||||
				<DomainEntryIcon alreadyExists={alreadyExists} suggestion={entry.suggest} onChange={onChange} />
 | 
			
		||||
			</span>
 | 
			
		||||
			<p>{comment}</p>
 | 
			
		||||
		</>
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DomainEntryIcon({ alreadyExists, suggestion }) {
 | 
			
		||||
	let icon;
 | 
			
		||||
	let text;
 | 
			
		||||
 | 
			
		||||
	if (suggestion) {
 | 
			
		||||
		icon = "fa-info-circle suggest-changes";
 | 
			
		||||
		text = `Entry targets a specific subdomain, consider changing it to '${suggestion}'.`;
 | 
			
		||||
	} else if (alreadyExists) {
 | 
			
		||||
		icon = "fa-history already-blocked";
 | 
			
		||||
		text = "Domain block already exists.";
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (!icon) {
 | 
			
		||||
		return null;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<>
 | 
			
		||||
			<i className={`fa ${icon}`} aria-hidden="true" title={text}></i>
 | 
			
		||||
			<span className="sr-only">{text}</span>
 | 
			
		||||
		</>
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue