From b43f9ceca9f7e02248f1d88245ede5267e8b72c8 Mon Sep 17 00:00:00 2001 From: f0x52 Date: Thu, 9 Jun 2022 12:51:19 +0200 Subject: [frontend] Restructure Frontend Sources (#634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐸restructure frontend stuff, include admin and future user panel in main repo, properly deduplicate bundles for css+js across uses * rename bundled to dist, caught by gitignore * re-include status.css for profile template * default to localhost * serve frontend panels * add todo message for abstraction * refactor oauth registration flow * oauth restructure * update footer template * change panel routes * remove superfluous css imports * write bundle to disk from test server, use forked budo-express * wrap all page content in container for robustness with addons etc injection other elements in body * update documentation, goreleaser, Dockerfile * update template meta tags * add AGPL-3.0+ license header everywhere * only attach update listener on EventEmitter * cleaner config for various frontend bundles * fix bundler script paths * Merge commit 'd191931932b9293ce1be44ed08a1e69b9fcc1e25' * fix up dockerfile, goreleaser * go mod tidy * add uglifyify * move status hide/show js to frontend bundle * fix stylesheet color( func regressions * update contributing docs for new build path * update goreleaser + docker building * resolve dependency paths properly * update package name * use api errorhandler Co-authored-by: tsmethurst --- web/source/panels/admin/README.md | 21 +++ web/source/panels/admin/auth.js | 96 +++++++++++ web/source/panels/admin/blocks.js | 318 ++++++++++++++++++++++++++++++++++++ web/source/panels/admin/index.js | 95 +++++++++++ web/source/panels/admin/settings.js | 175 ++++++++++++++++++++ web/source/panels/admin/style.css | 141 ++++++++++++++++ web/source/panels/user/index.js | 31 ++++ 7 files changed, 877 insertions(+) create mode 100644 web/source/panels/admin/README.md create mode 100644 web/source/panels/admin/auth.js create mode 100644 web/source/panels/admin/blocks.js create mode 100644 web/source/panels/admin/index.js create mode 100644 web/source/panels/admin/settings.js create mode 100644 web/source/panels/admin/style.css create mode 100644 web/source/panels/user/index.js (limited to 'web/source/panels') diff --git a/web/source/panels/admin/README.md b/web/source/panels/admin/README.md new file mode 100644 index 000000000..9a4572270 --- /dev/null +++ b/web/source/panels/admin/README.md @@ -0,0 +1,21 @@ +# GoToSocial Admin Panel + +Standalone web admin panel for [GoToSocial](https://github.com/superseriousbusiness/gotosocial). + +A public hosted instance is also available at https://gts.superseriousbusiness.org/admin/, so you can fill your own instance URL in there. + +## Installation +Build requirements: some version of Node.js with npm, +``` +git clone https://github.com/superseriousbusiness/gotosocial-admin.git && cd gotosocial-admin +npm install +node index.js +``` +All processed build output will now be in `public/`, which you can copy over to a folder in your GoToSocial installation like `web/assets/admin`, or serve elsewhere. +No further configuration is required, authentication happens through normal OAUTH flow. + +## Development +Follow the installation steps, but run `NODE_ENV=development node index.js` to start the livereloading dev server instead. + +## License, donations +[AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html). If you want to support my work, you can: Donate using Liberapay \ No newline at end of file diff --git a/web/source/panels/admin/auth.js b/web/source/panels/admin/auth.js new file mode 100644 index 000000000..e26ff06b9 --- /dev/null +++ b/web/source/panels/admin/auth.js @@ -0,0 +1,96 @@ +/* + 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 . +*/ + +"use strict"; + +const Promise = require("bluebird"); +const React = require("react"); +const oauthLib = require("../../lib/oauth"); + +module.exports = function Auth({setOauth}) { + const [ instance, setInstance ] = React.useState(""); + + React.useEffect(() => { + let isStillMounted = true; + // check if current domain runs an instance + let thisUrl = new URL(window.location.origin); + thisUrl.pathname = "/api/v1/instance"; + Promise.try(() => { + return fetch(thisUrl.href); + }).then((res) => { + if (res.status == 200) { + return res.json(); + } + }).then((json) => { + if (json && json.uri && isStillMounted) { + setInstance(json.uri); + } + }).catch((e) => { + console.log("error checking instance response:", e); + }); + + return () => { + // cleanup function + isStillMounted = false; + }; + }, []); + + function doAuth() { + return Promise.try(() => { + return new URL(instance); + }).catch(TypeError, () => { + return new URL(`https://${instance}`); + }).then((parsedURL) => { + let url = parsedURL.toString(); + let oauth = oauthLib({ + instance: url, + client_name: "GoToSocial Admin Panel", + scope: ["admin"], + website: window.location.href + }); + setOauth(oauth); + setInstance(url); + return oauth.register().then(() => { + return oauth; + }); + }).then((oauth) => { + return oauth.authorize(); + }).catch((e) => { + console.log("error authenticating:", e); + }); + } + + function updateInstance(e) { + if (e.key == "Enter") { + doAuth(); + } else { + setInstance(e.target.value); + } + } + + return ( +
+

OAUTH Login:

+
e.preventDefault()}> + + + +
+
+ ); +}; \ No newline at end of file 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 . +*/ + +"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 ( + +
+
{block.domain}
+
{(new Date(block.created_at)).toLocaleString()}
+
+ ); + }); + + 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 ( +
+

Blocks

+
{errorMsg}
+
{info}
+ +

Blocks:

+
+ uncheck all + +
+
+ {blockList} +
+ +
+ ); +}; + +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(
{e}
); + 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({output}
{`Deduplicated ${before - domains.length}/${before} with existing blocks, adding ${domains.length} block(s)`}
); + 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 ( + +

Bulk import/export

+ + +
+ + + + +
+ {output} + +
+ ); +} + +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 ( + +

Add Block:

+
+ + + +
+
+ +
+
+
+ +
+
+ + setObfuscated(e.target.checked)}/> +
+
+
+ ); +} + +// function Blocklist() { +// return ( +//
+//

Blocklists

+//
+// ); +// } \ No newline at end of file diff --git a/web/source/panels/admin/index.js b/web/source/panels/admin/index.js new file mode 100644 index 000000000..05ab8e583 --- /dev/null +++ b/web/source/panels/admin/index.js @@ -0,0 +1,95 @@ +/* + 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 . +*/ + +"use strict"; + +const Promise = require("bluebird"); +const React = require("react"); +const ReactDom = require("react-dom"); + +const oauthLib = require("../../lib/oauth.js"); +const Auth = require("./auth"); +const Settings = require("./settings"); +const Blocks = require("./blocks"); + +require("./style.css"); + +function App() { + const [oauth, setOauth] = React.useState(); + const [hasAuth, setAuth] = React.useState(false); + const [oauthState, setOauthState] = React.useState(localStorage.getItem("oauth")); + + React.useEffect(() => { + let state = localStorage.getItem("oauth"); + if (state != undefined) { + state = JSON.parse(state); + let restoredOauth = oauthLib(state.config, state); + Promise.try(() => { + return restoredOauth.callback(); + }).then(() => { + setAuth(true); + }); + setOauth(restoredOauth); + } + }, []); + + if (!hasAuth && oauth && oauth.isAuthorized()) { + setAuth(true); + } + + if (oauth && oauth.isAuthorized()) { + return ; + } else if (oauthState != undefined) { + return "processing oauth..."; + } else { + return ; + } +} + +function AdminPanel({oauth}) { + /* + Features: (issue #78) + - [ ] Instance information updating + GET /api/v1/instance PATCH /api/v1/instance + - [ ] Domain block creation, viewing, and deletion + GET /api/v1/admin/domain_blocks + POST /api/v1/admin/domain_blocks + GET /api/v1/admin/domain_blocks/DOMAIN_BLOCK_ID, DELETE /api/v1/admin/domain_blocks/DOMAIN_BLOCK_ID + - [ ] Blocklist import/export + GET /api/v1/admin/domain_blocks?export=true + POST json file as form field domains to /api/v1/admin/domain_blocks + */ + + return ( + + + + + + ); +} + +function Logout({oauth}) { + return ( +
+ +
+ ); +} + +ReactDom.render(, document.getElementById("root")); \ No newline at end of file diff --git a/web/source/panels/admin/settings.js b/web/source/panels/admin/settings.js new file mode 100644 index 000000000..2a10951a7 --- /dev/null +++ b/web/source/panels/admin/settings.js @@ -0,0 +1,175 @@ +/* + 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 . +*/ + +"use strict"; + +const Promise = require("bluebird"); +const React = require("react"); + +module.exports = function Settings({oauth}) { + const [info, setInfo] = React.useState({}); + const [errorMsg, setError] = React.useState(""); + const [statusMsg, setStatus] = React.useState("Fetching instance info"); + + React.useEffect(() => { + Promise.try(() => { + return oauth.apiRequest("/api/v1/instance", "GET"); + }).then((json) => { + setInfo(json); + }).catch((e) => { + setError(e.message); + setStatus(""); + }); + }, []); + + function submit() { + setStatus("PATCHing"); + setError(""); + return Promise.try(() => { + let formDataInfo = new FormData(); + Object.entries(info).forEach(([key, val]) => { + if (key == "contact_account") { + key = "contact_username"; + val = val.username; + } + if (key == "email") { + key = "contact_email"; + } + if (typeof val != "object") { + formDataInfo.append(key, val); + } + }); + return oauth.apiRequest("/api/v1/instance", "PATCH", formDataInfo, "form"); + }).then((json) => { + setStatus("Config saved"); + console.log(json); + }).catch((e) => { + setError(e.message); + setStatus(""); + }); + } + + return ( +
+

Instance Information

+
+ {errorMsg} +
+
+ {statusMsg} +
+
e.preventDefault()}> + {editableObject(info)} +
+
+ ); +}; + +function editableObject(obj, path=[]) { + const readOnlyKeys = ["uri", "version", "urls_streaming_api", "stats"]; + const hiddenKeys = ["contact_account_", "urls"]; + const explicitShownKeys = ["contact_account_username"]; + const implementedKeys = "title, contact_account_username, email, short_description, description, terms, avatar, header".split(", "); + + let listing = Object.entries(obj).map(([key, val]) => { + let fullkey = [...path, key].join("_"); + + if ( + hiddenKeys.includes(fullkey) || + hiddenKeys.includes(path.join("_")+"_") // also match just parent path + ) { + if (!explicitShownKeys.includes(fullkey)) { + return null; + } + } + + if (Array.isArray(val)) { + // FIXME: handle this + } else if (typeof val == "object") { + return ( + {editableObject(val, [...path, key])} + ); + } + + let isImplemented = ""; + if (!implementedKeys.includes(fullkey)) { + isImplemented = " notImplemented"; + } + + let isReadOnly = ( + readOnlyKeys.includes(fullkey) || + readOnlyKeys.includes(path.join("_")) || + isImplemented != "" + ); + + let label = key.replace(/_/g, " "); + if (path.length > 0) { + label = `\u00A0`.repeat(4 * path.length) + label; + } + + let inputProps; + let changeFunc; + if (val === true || val === false) { + inputProps = { + type: "checkbox", + defaultChecked: val, + disabled: isReadOnly + }; + changeFunc = (e) => e.target.checked; + } else if (val.length != 0 && !isNaN(val)) { + inputProps = { + type: "number", + defaultValue: val, + readOnly: isReadOnly + }; + changeFunc = (e) => e.target.value; + } else { + inputProps = { + type: "text", + defaultValue: val, + readOnly: isReadOnly + }; + changeFunc = (e) => e.target.value; + } + + function setRef(element) { + if (element != null) { + element.addEventListener("change", (e) => { + obj[key] = changeFunc(e); + }); + } + } + + return ( + + +
+ +
+
+ ); + }); + return ( + + {path != "" && + <>{path}: + } + {listing} + + ); +} \ No newline at end of file diff --git a/web/source/panels/admin/style.css b/web/source/panels/admin/style.css new file mode 100644 index 000000000..c9d2f09b4 --- /dev/null +++ b/web/source/panels/admin/style.css @@ -0,0 +1,141 @@ +/* + 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 . +*/ + +body { + grid-template-rows: auto 1fr; +} + +.capitalize { + text-transform: capitalize; +} + +section { + margin-bottom: 1rem; +} + +input, select, textarea { + box-sizing: border-box; +} + +section.info { + form { + grid-template-columns: auto 1fr; + width: calc(100% - 0.35rem); + + input { + width: 100%; + line-height: 1.5rem; + } + + label, input { + padding: 0.2rem 0.5rem; + } + + input[type=checkbox] { + justify-self: start; + width: initial; + } + + input:read-only { + border: none; + } + + input:invalid { + border-color: red; + } + } + + textarea { + width: 100%; + height: 8rem; + } + + h1 { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + } +} + +section.blocks { + .overflow { + max-height: 80vh; + overflow-y: auto; + } + + .blocklist { + display: grid; + grid-template-columns: auto 1fr auto; + grid-gap: 0.35rem 0; + + div { + background: rgb(70, 79, 88); + padding: 0.2rem 0.4rem; + } + } + + .addblock { + display: grid; + grid-template-columns: 1fr auto auto; + grid-gap: 0.35rem; + + input, select { + font-size: 1.2rem; + } + + input, select, textarea { + padding: 0.5rem; + } + + div { + grid-column: 1/4; + } + + div.single input { + width: initial; + } + } + + h3 { + margin-bottom: 0; + } + + .controls { + display: flex; + gap: 0.5rem; + } +} + +.error { + font-weight: bold; +} + +.hidden { + display: none; +} + +.notImplemented { + border: 2px solid rgb(70, 79, 88); + background: repeating-linear-gradient( + -45deg, + #525c66, + #525c66 10px, + rgb(70, 79, 88) 10px, + rgb(70, 79, 88) 20px + ) !important; +} \ No newline at end of file diff --git a/web/source/panels/user/index.js b/web/source/panels/user/index.js new file mode 100644 index 000000000..7adc320d8 --- /dev/null +++ b/web/source/panels/user/index.js @@ -0,0 +1,31 @@ +/* + 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 . +*/ + +"use strict"; + +const Promise = require("bluebird"); +const React = require("react"); +const ReactDom = require("react-dom"); + +// require("./style.css"); + +function App() { + return "hello world - user panel"; +} + +ReactDom.render(, document.getElementById("root")); \ No newline at end of file -- cgit v1.2.3