diff options
Diffstat (limited to 'web/source/settings/admin/federation')
| -rw-r--r-- | web/source/settings/admin/federation/detail.js | 146 | ||||
| -rw-r--r-- | web/source/settings/admin/federation/import-export.js | 307 | ||||
| -rw-r--r-- | web/source/settings/admin/federation/index.js | 44 | ||||
| -rw-r--r-- | web/source/settings/admin/federation/overview.js | 100 | 
4 files changed, 597 insertions, 0 deletions
diff --git a/web/source/settings/admin/federation/detail.js b/web/source/settings/admin/federation/detail.js new file mode 100644 index 000000000..7324a42a5 --- /dev/null +++ b/web/source/settings/admin/federation/detail.js @@ -0,0 +1,146 @@ +/* +	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 { useRoute, Redirect } = require("wouter"); + +const query = require("../../lib/query"); + +const { useTextInput, useBoolInput } = require("../../lib/form"); + +const useFormSubmit = require("../../lib/form/submit"); + +const { TextInput, Checkbox, TextArea } = require("../../components/form/inputs"); + +const Loading = require("../../components/loading"); +const BackButton = require("../../components/back-button"); +const MutationButton = require("../../components/form/mutation-button"); + +module.exports = function InstanceDetail({ baseUrl }) { +	const { data: blockedInstances = {}, isLoading } = query.useInstanceBlocksQuery(); + +	let [_match, { domain }] = useRoute(`${baseUrl}/:domain`); + +	if (domain == "view") { // from form field submission +		domain = (new URL(document.location)).searchParams.get("domain"); +	} + +	const existingBlock = React.useMemo(() => { +		return blockedInstances[domain]; +	}, [blockedInstances, domain]); + +	if (domain == undefined) { +		return <Redirect to={baseUrl} />; +	} + +	let infoContent = null; + +	if (isLoading) { +		infoContent = <Loading />; +	} else if (existingBlock == undefined) { +		infoContent = <span>No stored block yet, you can add one below:</span>; +	} else { +		infoContent = ( +			<div className="info"> +				<i className="fa fa-fw fa-exclamation-triangle" aria-hidden="true"></i> +				<b>Editing domain blocks isn't implemented yet, <a href="https://github.com/superseriousbusiness/gotosocial/issues/1198" target="_blank" rel="noopener noreferrer">check here for progress</a></b> +			</div> +		); +	} + +	return ( +		<div> +			<h1><BackButton to={baseUrl} /> Federation settings for: {domain}</h1> +			{infoContent} +			<DomainBlockForm defaultDomain={domain} block={existingBlock} /> +		</div> +	); +}; + +function DomainBlockForm({ defaultDomain, block = {} }) { +	const isExistingBlock = block.domain != undefined; + +	const disabledForm = isExistingBlock +		? { +			disabled: true, +			title: "Domain suspensions currently cannot be edited." +		} +		: {}; + +	const form = { +		domain: useTextInput("domain", { defaultValue: block.domain ?? defaultDomain }), +		obfuscate: useBoolInput("obfuscate", { defaultValue: block.obfuscate }), +		commentPrivate: useTextInput("private_comment", { defaultValue: block.private_comment }), +		commentPublic: useTextInput("public_comment", { defaultValue: block.public_comment }) +	}; + +	const [submitForm, addResult] = useFormSubmit(form, query.useAddInstanceBlockMutation(), { changedOnly: false }); + +	const [removeBlock, removeResult] = query.useRemoveInstanceBlockMutation({ fixedCacheKey: block.id }); + +	return ( +		<form onSubmit={submitForm}> +			<TextInput +				field={form.domain} +				label="Domain" +				placeholder="example.com" +				{...disabledForm} +			/> + +			<Checkbox +				field={form.obfuscate} +				label="Obfuscate domain in public lists" +				{...disabledForm} +			/> + +			<TextArea +				field={form.commentPrivate} +				label="Private comment" +				rows={3} +				{...disabledForm} +			/> + +			<TextArea +				field={form.commentPublic} +				label="Public comment" +				rows={3} +				{...disabledForm} +			/> + +			<MutationButton +				label="Suspend" +				result={addResult} +				{...disabledForm} +			/> + +			{ +				isExistingBlock && +				<MutationButton +					type="button" +					onClick={() => removeBlock(block.id)} +					label="Remove" +					result={removeResult} +					className="button danger" +				/> +			} + +		</form> +	); +}
\ No newline at end of file diff --git a/web/source/settings/admin/federation/import-export.js b/web/source/settings/admin/federation/import-export.js new file mode 100644 index 000000000..a11174b1f --- /dev/null +++ b/web/source/settings/admin/federation/import-export.js @@ -0,0 +1,307 @@ +/* +	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> +		</> +	); +}
\ No newline at end of file diff --git a/web/source/settings/admin/federation/index.js b/web/source/settings/admin/federation/index.js new file mode 100644 index 000000000..beaa6e1c5 --- /dev/null +++ b/web/source/settings/admin/federation/index.js @@ -0,0 +1,44 @@ +/* +	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 } = require("wouter"); + +const baseUrl = `/settings/admin/federation`; + +const InstanceOverview = require("./overview"); +const InstanceDetail = require("./detail"); +const InstanceImportExport = require("./import-export"); + +module.exports = function Federation({ }) { +	return ( +		<Switch> +			<Route path={`${baseUrl}/import-export/:list?`}> +				<InstanceImportExport /> +			</Route> + +			<Route path={`${baseUrl}/:domain`}> +				<InstanceDetail baseUrl={baseUrl} /> +			</Route> + +			<InstanceOverview baseUrl={baseUrl} /> +		</Switch> +	); +};
\ No newline at end of file diff --git a/web/source/settings/admin/federation/overview.js b/web/source/settings/admin/federation/overview.js new file mode 100644 index 000000000..b655423a4 --- /dev/null +++ b/web/source/settings/admin/federation/overview.js @@ -0,0 +1,100 @@ +/* +	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 { Link, useLocation } = require("wouter"); +const { matchSorter } = require("match-sorter"); + +const { useTextInput } = require("../../lib/form"); + +const { TextInput } = require("../../components/form/inputs"); + +const query = require("../../lib/query"); + +const Loading = require("../../components/loading"); + +module.exports = function InstanceOverview({ baseUrl }) { +	const { data: blockedInstances = [], isLoading } = query.useInstanceBlocksQuery(); + +	const [_location, setLocation] = useLocation(); + +	const filterField = useTextInput("filter"); +	const filter = filterField.value; + +	const blockedInstancesList = React.useMemo(() => { +		return Object.values(blockedInstances); +	}, [blockedInstances]); + +	const filteredInstances = React.useMemo(() => { +		return matchSorter(blockedInstancesList, filter, { keys: ["domain"] }); +	}, [blockedInstancesList, filter]); + +	let filtered = blockedInstancesList.length - filteredInstances.length; + +	function filterFormSubmit(e) { +		e.preventDefault(); +		setLocation(`${baseUrl}/${filter}`); +	} + +	if (isLoading) { +		return <Loading />; +	} + +	return ( +		<> +			<h1>Federation</h1> + +			<div className="instance-list"> +				<h2>Suspended instances</h2> +				<p> +					Suspending a domain blocks all current and future accounts on that instance. Stored content will be removed, +					and no more data is sent to the remote server.<br /> +					This extends to all subdomains as well, so blocking 'example.com' also includes 'social.example.com'. +				</p> +				<form className="filter" role="search" onSubmit={filterFormSubmit}> +					<TextInput field={filterField} placeholder="example.com" label="Search or add domain suspension" /> +					<Link to={`${baseUrl}/${filter}`}><a className="button">Suspend</a></Link> +				</form> +				<div> +					<span> +						{blockedInstancesList.length} blocked instance{blockedInstancesList.length != 1 ? "s" : ""} {filtered > 0 && `(${filtered} filtered by search)`} +					</span> +					<div className="list scrolling"> +						{filteredInstances.map((entry) => { +							return ( +								<Link key={entry.domain} to={`${baseUrl}/${entry.domain}`}> +									<a className="entry nounderline"> +										<span id="domain"> +											{entry.domain} +										</span> +										<span id="date"> +											{new Date(entry.created_at).toLocaleString()} +										</span> +									</a> +								</Link> +							); +						})} +					</div> +				</div> +			</div> +			<Link to={`${baseUrl}/import-export`}><a>Or use the bulk import/export interface</a></Link> +		</> +	); +};
\ No newline at end of file  | 
