diff options
Diffstat (limited to 'web/source/panels/admin/blocks.js')
-rw-r--r-- | web/source/panels/admin/blocks.js | 318 |
1 files changed, 318 insertions, 0 deletions
diff --git a/web/source/panels/admin/blocks.js b/web/source/panels/admin/blocks.js new file mode 100644 index 000000000..b12eb50a9 --- /dev/null +++ b/web/source/panels/admin/blocks.js @@ -0,0 +1,318 @@ +/* + 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 React = require("react"); +const fileDownload = require("js-file-download"); + +function sortBlocks(blocks) { + return blocks.sort((a, b) => { // alphabetical sort + return a.domain.localeCompare(b.domain); + }); +} + +function deduplicateBlocks(blocks) { + let a = new Map(); + blocks.forEach((block) => { + a.set(block.id, block); + }); + return Array.from(a.values()); +} + +module.exports = function Blocks({oauth}) { + const [blocks, setBlocks] = React.useState([]); + const [info, setInfo] = React.useState("Fetching blocks"); + const [errorMsg, setError] = React.useState(""); + const [checked, setChecked] = React.useState(new Set()); + + React.useEffect(() => { + Promise.try(() => { + return oauth.apiRequest("/api/v1/admin/domain_blocks", undefined, undefined, "GET"); + }).then((json) => { + setInfo(""); + setError(""); + setBlocks(sortBlocks(json)); + }).catch((e) => { + setError(e.message); + setInfo(""); + }); + }, []); + + let blockList = blocks.map((block) => { + function update(e) { + let newChecked = new Set(checked.values()); + if (e.target.checked) { + newChecked.add(block.id); + } else { + newChecked.delete(block.id); + } + setChecked(newChecked); + } + + return ( + <React.Fragment key={block.id}> + <div><input type="checkbox" onChange={update} checked={checked.has(block.id)}></input></div> + <div>{block.domain}</div> + <div>{(new Date(block.created_at)).toLocaleString()}</div> + </React.Fragment> + ); + }); + + function clearChecked() { + setChecked(new Set()); + } + + function undoChecked() { + let amount = checked.size; + if(confirm(`Are you sure you want to remove ${amount} block(s)?`)) { + setInfo(""); + Promise.map(Array.from(checked.values()), (block) => { + console.log("deleting", block); + return oauth.apiRequest(`/api/v1/admin/domain_blocks/${block}`, "DELETE"); + }).then((res) => { + console.log(res); + setInfo(`Deleted ${amount} blocks: ${res.map((a) => a.domain).join(", ")}`); + }).catch((e) => { + setError(e); + }); + + let newBlocks = blocks.filter((block) => { + if (checked.size > 0 && checked.has(block.id)) { + checked.delete(block.id); + return false; + } else { + return true; + } + }); + setBlocks(newBlocks); + clearChecked(); + } + } + + return ( + <section className="blocks"> + <h1>Blocks</h1> + <div className="error accent">{errorMsg}</div> + <div>{info}</div> + <AddBlock oauth={oauth} blocks={blocks} setBlocks={setBlocks} /> + <h3>Blocks:</h3> + <div style={{display: "grid", gridTemplateColumns: "1fr auto"}}> + <span onClick={clearChecked} className="accent" style={{alignSelf: "end"}}>uncheck all</span> + <button onClick={undoChecked}>Unblock selected</button> + </div> + <div className="blocklist overflow"> + {blockList} + </div> + <BulkBlocking oauth={oauth} blocks={blocks} setBlocks={setBlocks}/> + </section> + ); +}; + +function BulkBlocking({oauth, blocks, setBlocks}) { + const [bulk, setBulk] = React.useState(""); + const [blockMap, setBlockMap] = React.useState(new Map()); + const [output, setOutput] = React.useState(); + + React.useEffect(() => { + let newBlockMap = new Map(); + blocks.forEach((block) => { + newBlockMap.set(block.domain, block); + }); + setBlockMap(newBlockMap); + }, [blocks]); + + const fileRef = React.useRef(); + + function error(e) { + setOutput(<div className="error accent">{e}</div>); + throw e; + } + + function fileUpload() { + let reader = new FileReader(); + reader.addEventListener("load", (e) => { + try { + // TODO: use validatem? + let json = JSON.parse(e.target.result); + json.forEach((block) => { + console.log("block:", block); + }); + } catch(e) { + error(e.message); + } + }); + reader.readAsText(fileRef.current.files[0]); + } + + React.useEffect(() => { + if (fileRef && fileRef.current) { + fileRef.current.addEventListener("change", fileUpload); + } + return function cleanup() { + fileRef.current.removeEventListener("change", fileUpload); + }; + }); + + function textImport() { + Promise.try(() => { + if (bulk[0] == "[") { + // assume it's json + return JSON.parse(bulk); + } else { + return bulk.split("\n").map((val) => { + return { + domain: val.trim() + }; + }); + } + }).then((domains) => { + console.log(domains); + let before = domains.length; + setOutput(`Importing ${before} domain(s)`); + domains = domains.filter(({domain}) => { + return (domain != "" && !blockMap.has(domain)); + }); + setOutput(<span>{output}<br/>{`Deduplicated ${before - domains.length}/${before} with existing blocks, adding ${domains.length} block(s)`}</span>); + if (domains.length > 0) { + let data = new FormData(); + data.append("domains", new Blob([JSON.stringify(domains)], {type: "application/json"}), "import.json"); + return oauth.apiRequest("/api/v1/admin/domain_blocks?import=true", "POST", data, "form"); + } + }).then((json) => { + console.log("bulk import result:", json); + setBlocks(sortBlocks(deduplicateBlocks([...json, ...blocks]))); + }).catch((e) => { + error(e.message); + }); + } + + function textExport() { + setBulk(blocks.reduce((str, val) => { + if (typeof str == "object") { + return str.domain; + } else { + return str + "\n" + val.domain; + } + })); + } + + function jsonExport() { + Promise.try(() => { + return oauth.apiRequest("/api/v1/admin/domain_blocks?export=true", "GET"); + }).then((json) => { + fileDownload(JSON.stringify(json), "block-export.json"); + }).catch((e) => { + error(e); + }); + } + + function textAreaUpdate(e) { + setBulk(e.target.value); + } + + return ( + <React.Fragment> + <h3>Bulk import/export</h3> + <label htmlFor="bulk">Domains, one per line:</label> + <textarea value={bulk} rows={20} onChange={textAreaUpdate}></textarea> + <div className="controls"> + <button onClick={textImport}>Import All From Field</button> + <button onClick={textExport}>Export To Field</button> + <label className="button" htmlFor="upload">Upload .json</label> + <button onClick={jsonExport}>Download .json</button> + </div> + {output} + <input type="file" id="upload" className="hidden" ref={fileRef}></input> + </React.Fragment> + ); +} + +function AddBlock({oauth, blocks, setBlocks}) { + const [domain, setDomain] = React.useState(""); + const [type, setType] = React.useState("suspend"); + const [obfuscated, setObfuscated] = React.useState(false); + const [privateDescription, setPrivateDescription] = React.useState(""); + const [publicDescription, setPublicDescription] = React.useState(""); + + function addBlock() { + console.log(`${type}ing`, domain); + Promise.try(() => { + return oauth.apiRequest("/api/v1/admin/domain_blocks", "POST", { + domain: domain, + obfuscate: obfuscated, + private_comment: privateDescription, + public_comment: publicDescription + }, "json"); + }).then((json) => { + setDomain(""); + setPrivateDescription(""); + setPublicDescription(""); + setBlocks([json, ...blocks]); + }); + } + + function onDomainChange(e) { + setDomain(e.target.value); + } + + function onTypeChange(e) { + setType(e.target.value); + } + + function onKeyDown(e) { + if (e.key == "Enter") { + addBlock(); + } + } + + return ( + <React.Fragment> + <h3>Add Block:</h3> + <div className="addblock"> + <input id="domain" placeholder="instance" onChange={onDomainChange} value={domain} onKeyDown={onKeyDown} /> + <select value={type} onChange={onTypeChange}> + <option id="suspend">Suspend</option> + <option id="silence">Silence</option> + </select> + <button onClick={addBlock}>Add</button> + <div> + <label htmlFor="private">Private description:</label><br/> + <textarea id="private" value={privateDescription} onChange={(e) => setPrivateDescription(e.target.value)}></textarea> + </div> + <div> + <label htmlFor="public">Public description:</label><br/> + <textarea id="public" value={publicDescription} onChange={(e) => setPublicDescription(e.target.value)}></textarea> + </div> + <div className="single"> + <label htmlFor="obfuscate">Obfuscate:</label> + <input id="obfuscate" type="checkbox" value={obfuscated} onChange={(e) => setObfuscated(e.target.checked)}/> + </div> + </div> + </React.Fragment> + ); +} + +// function Blocklist() { +// return ( +// <section className="blocklists"> +// <h1>Blocklists</h1> +// </section> +// ); +// }
\ No newline at end of file |