diff options
author | 2023-01-18 14:45:14 +0100 | |
---|---|---|
committer | 2023-01-18 14:45:14 +0100 | |
commit | 9b139b632098e6741b10fa87ff6224dcb5045947 (patch) | |
tree | c72b5c666ed01db7d1a18e531e5e01e07f504a46 /web/source/settings/lib | |
parent | [chore] Change default sqlite busy timeout to 5m (#1352) (diff) | |
download | gotosocial-9b139b632098e6741b10fa87ff6224dcb5045947.tar.xz |
[frogend] Settings refactor (#1318)
* yakshave new form field structure
* fully refactor user profile settings form
* use rtk query api for profile settings
* refactor user post settings
* refactor password change form
* refactor admin settings
* FormWithData structure for user forms
* admin actions refactor
* whitespace
* fix user settings data prop
* remove superfluous logging
* cleanup old code
* refactor federation/suspend (overview, detail)
* mostly abstracted (emoji) checkbox list
* refactor parse-from-toot
* refactor custom-emoji, progress on federation bulk
* loading icon styling to prevent big spinny
* refactor federation import-export interface
* cleanup old files
* [chore] Update/add license headers for 2023
* redux fixes
* text-field exports
* appease the linter
* refactor authentication with RTK Query
* fix login/logout state transition weirdness
* fixes/cleanup
* small linter-related fixes
* add eslint license header check, fix existing files
* remove old code, clarify comment
* clarify suspend on subdomains
* collapse if/else
* fa-fw width info comment
Diffstat (limited to 'web/source/settings/lib')
24 files changed, 1438 insertions, 820 deletions
diff --git a/web/source/settings/lib/api/admin.js b/web/source/settings/lib/api/admin.js deleted file mode 100644 index 848772db7..000000000 --- a/web/source/settings/lib/api/admin.js +++ /dev/null @@ -1,168 +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 Promise = require("bluebird"); -const isValidDomain = require("is-valid-domain"); - -const instance = require("../../redux/reducers/instances").actions; -const admin = require("../../redux/reducers/admin").actions; - -module.exports = function ({ apiCall, getChanges }) { - const adminAPI = { - updateInstance: function updateInstance() { - return function (dispatch, getState) { - return Promise.try(() => { - const state = getState().instances.adminSettings; - - const update = getChanges(state, { - formKeys: ["title", "short_description", "description", "contact_account.username", "email", "terms", "thumbnail_description"], - renamedKeys: { - "email": "contact_email", - "contact_account.username": "contact_username" - }, - fileKeys: ["thumbnail"] - }); - - return dispatch(apiCall("PATCH", "/api/v1/instance", update, "form")); - }).then((data) => { - return dispatch(instance.setInstanceInfo(data)); - }); - }; - }, - - fetchDomainBlocks: function fetchDomainBlocks() { - return function (dispatch, _getState) { - return Promise.try(() => { - return dispatch(apiCall("GET", "/api/v1/admin/domain_blocks")); - }).then((data) => { - return dispatch(admin.setBlockedInstances(data)); - }); - }; - }, - - updateDomainBlock: function updateDomainBlock(domain) { - return function (dispatch, getState) { - return Promise.try(() => { - const state = getState().admin.newInstanceBlocks[domain]; - const update = getChanges(state, { - formKeys: ["domain", "obfuscate", "public_comment", "private_comment"], - }); - - return dispatch(apiCall("POST", "/api/v1/admin/domain_blocks", update, "form")); - }).then((block) => { - return Promise.all([ - dispatch(admin.newDomainBlock([domain, block])), - dispatch(admin.setDomainBlock([domain, block])) - ]); - }); - }; - }, - - getEditableDomainBlock: function getEditableDomainBlock(domain) { - return function (dispatch, getState) { - let data = getState().admin.blockedInstances[domain]; - return dispatch(admin.newDomainBlock([domain, data])); - }; - }, - - bulkDomainBlock: function bulkDomainBlock() { - return function (dispatch, getState) { - let invalidDomains = []; - let success = 0; - - return Promise.try(() => { - const state = getState().admin.bulkBlock; - let list = state.list; - let domains; - - let fields = getChanges(state, { - formKeys: ["obfuscate", "public_comment", "private_comment"] - }); - - let defaultDate = new Date().toUTCString(); - - if (list[0] == "[") { - domains = JSON.parse(state.list); - } else { - domains = list.split("\n").map((line_) => { - let line = line_.trim(); - if (line.length == 0) { - return null; - } - - if (!isValidDomain(line, {wildcard: true, allowUnicode: true})) { - invalidDomains.push(line); - return null; - } - - return { - domain: line, - created_at: defaultDate, - ...fields - }; - }).filter((a) => a != null); - } - - if (domains.length == 0) { - return; - } - - const update = { - domains: new Blob([JSON.stringify(domains)], {type: "application/json"}) - }; - - return dispatch(apiCall("POST", "/api/v1/admin/domain_blocks?import=true", update, "form")); - }).then((blocks) => { - if (blocks != undefined) { - return Promise.each(blocks, (block) => { - success += 1; - return dispatch(admin.setDomainBlock([block.domain, block])); - }); - } - }).then(() => { - return { - success, - invalidDomains - }; - }); - }; - }, - - removeDomainBlock: function removeDomainBlock(domain) { - return function (dispatch, getState) { - return Promise.try(() => { - const id = getState().admin.blockedInstances[domain].id; - return dispatch(apiCall("DELETE", `/api/v1/admin/domain_blocks/${id}`)); - }).then((removed) => { - return dispatch(admin.removeDomainBlock(removed.domain)); - }); - }; - }, - - mediaCleanup: function mediaCleanup(days) { - return function (dispatch, _getState) { - return Promise.try(() => { - return dispatch(apiCall("POST", `/api/v1/admin/media_cleanup?remote_cache_days=${days}`)); - }); - }; - }, - }; - return adminAPI; -};
\ No newline at end of file diff --git a/web/source/settings/lib/api/index.js b/web/source/settings/lib/api/index.js deleted file mode 100644 index 89f12cc80..000000000 --- a/web/source/settings/lib/api/index.js +++ /dev/null @@ -1,193 +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 Promise = require("bluebird"); -const { isPlainObject } = require("is-plain-object"); -const d = require("dotty"); - -const { APIError, AuthenticationError } = require("../errors"); -const { setInstanceInfo, setNamedInstanceInfo } = require("../../redux/reducers/instances").actions; - -function apiCall(method, route, payload, type = "json") { - return function (dispatch, getState) { - const state = getState(); - let base = state.oauth.instance; - let auth = state.oauth.token; - - return Promise.try(() => { - let url = new URL(base); - let [path, query] = route.split("?"); - url.pathname = path; - if (query != undefined) { - url.search = query; - } - let body; - - let headers = { - "Accept": "application/json", - }; - - if (payload != undefined) { - if (type == "json") { - headers["Content-Type"] = "application/json"; - body = JSON.stringify(payload); - } else if (type == "form") { - body = convertToForm(payload); - } - } - - if (auth != undefined) { - headers["Authorization"] = auth; - } - - return fetch(url.toString(), { - method, - headers, - body - }); - }).then((res) => { - // try parse json even with error - let json = res.json().catch((e) => { - throw new APIError(`JSON parsing error: ${e.message}`); - }); - - return Promise.all([res, json]); - }).then(([res, json]) => { - if (!res.ok) { - if (auth != undefined && (res.status == 401 || res.status == 403)) { - // stored access token is invalid - throw new AuthenticationError("401: Authentication error", {json, status: res.status}); - } else { - throw new APIError(json.error, { json }); - } - } else { - return json; - } - }); - }; -} - -/* - Takes an object with (nested) keys, and transforms it into - a FormData object to be sent over the API -*/ -function convertToForm(payload) { - const formData = new FormData(); - Object.entries(payload).forEach(([key, val]) => { - if (isPlainObject(val)) { - Object.entries(val).forEach(([key2, val2]) => { - if (val2 != undefined) { - formData.set(`${key}[${key2}]`, val2); - } - }); - } else { - if (val != undefined) { - formData.set(key, val); - } - } - }); - return formData; -} - -function getChanges(state, keys) { - const { formKeys = [], fileKeys = [], renamedKeys = {} } = keys; - const update = {}; - - formKeys.forEach((key) => { - let value = d.get(state, key); - if (value == undefined) { - return; - } - if (renamedKeys[key]) { - key = renamedKeys[key]; - } - d.put(update, key, value); - }); - - fileKeys.forEach((key) => { - let file = d.get(state, `${key}File`); - if (file != undefined) { - if (renamedKeys[key]) { - key = renamedKeys[key]; - } - d.put(update, key, file); - } - }); - - return update; -} - -function getCurrentUrl() { - let [pre, _past] = window.location.pathname.split("/settings"); - return `${window.location.origin}${pre}/settings`; -} - -function fetchInstanceWithoutStore(domain) { - return function (dispatch, getState) { - return Promise.try(() => { - let lookup = getState().instances.info[domain]; - if (lookup != undefined) { - return lookup; - } - - // apiCall expects to pull the domain from state, - // but we don't want to store it there yet - // so we mock the API here with our function argument - let fakeState = { - oauth: { instance: domain } - }; - - return apiCall("GET", "/api/v1/instance")(dispatch, () => fakeState); - }).then((json) => { - if (json && json.uri) { // TODO: validate instance json more? - dispatch(setNamedInstanceInfo([domain, json])); - return json; - } - }); - }; -} - -function fetchInstance() { - return function (dispatch, _getState) { - return Promise.try(() => { - return dispatch(apiCall("GET", "/api/v1/instance")); - }).then((json) => { - if (json && json.uri) { - dispatch(setInstanceInfo(json)); - return json; - } - }); - }; -} - -let submoduleArgs = { apiCall, getCurrentUrl, getChanges }; - -module.exports = { - instance: { - fetchWithoutStore: fetchInstanceWithoutStore, - fetch: fetchInstance - }, - oauth: require("./oauth")(submoduleArgs), - user: require("./user")(submoduleArgs), - admin: require("./admin")(submoduleArgs), - apiCall, - convertToForm, - getChanges -};
\ No newline at end of file diff --git a/web/source/settings/lib/api/oauth.js b/web/source/settings/lib/api/oauth.js deleted file mode 100644 index 68095cac5..000000000 --- a/web/source/settings/lib/api/oauth.js +++ /dev/null @@ -1,127 +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 Promise = require("bluebird"); - -const { OAUTHError, AuthenticationError } = require("../errors"); - -const oauth = require("../../redux/reducers/oauth").actions; -const temporary = require("../../redux/reducers/temporary").actions; -const admin = require("../../redux/reducers/admin").actions; - -module.exports = function oauthAPI({ apiCall, getCurrentUrl }) { - return { - - register: function register(scopes = []) { - return function (dispatch, _getState) { - return Promise.try(() => { - return dispatch(apiCall("POST", "/api/v1/apps", { - client_name: "GoToSocial Settings", - scopes: scopes.join(" "), - redirect_uris: getCurrentUrl(), - website: getCurrentUrl() - })); - }).then((json) => { - json.scopes = scopes; - dispatch(oauth.setRegistration(json)); - }); - }; - }, - - authorize: function authorize() { - return function (dispatch, getState) { - let state = getState(); - let reg = state.oauth.registration; - let base = new URL(state.oauth.instance); - - base.pathname = "/oauth/authorize"; - base.searchParams.set("client_id", reg.client_id); - base.searchParams.set("redirect_uri", getCurrentUrl()); - base.searchParams.set("response_type", "code"); - base.searchParams.set("scope", reg.scopes.join(" ")); - - dispatch(oauth.setLoginState("callback")); - dispatch(temporary.setStatus("Redirecting to instance login...")); - - // send user to instance's login flow - window.location.assign(base.href); - }; - }, - - tokenize: function tokenize(code) { - return function (dispatch, getState) { - let reg = getState().oauth.registration; - - return Promise.try(() => { - if (reg == undefined || reg.client_id == undefined) { - throw new OAUTHError("Callback code present, but no client registration is available from localStorage. \nNote: localStorage is unavailable in Private Browsing."); - } - - return dispatch(apiCall("POST", "/oauth/token", { - client_id: reg.client_id, - client_secret: reg.client_secret, - redirect_uri: getCurrentUrl(), - grant_type: "authorization_code", - code: code - })); - }).then((json) => { - window.history.replaceState({}, document.title, window.location.pathname); - return dispatch(oauth.login(json)); - }); - }; - }, - - checkIfAdmin: function checkIfAdmin() { - return function (dispatch, getState) { - const state = getState(); - let stored = state.oauth.isAdmin; - if (stored != undefined) { - return stored; - } - - // newer GoToSocial version will include a `role` in the Account data, check that first - if (state.user.profile.role == "admin") { - dispatch(oauth.setAdmin(true)); - return true; - } - - // no role info, try fetching an admin-only route and see if we get an error - return Promise.try(() => { - return dispatch(apiCall("GET", "/api/v1/admin/domain_blocks")); - }).then((data) => { - return Promise.all([ - dispatch(oauth.setAdmin(true)), - dispatch(admin.setBlockedInstances(data)) - ]); - }).catch(AuthenticationError, () => { - return dispatch(oauth.setAdmin(false)); - }); - }; - }, - - logout: function logout() { - return function (dispatch, _getState) { - // TODO: GoToSocial does not have a logout API route yet - - return dispatch(oauth.remove()); - }; - } - }; -};
\ No newline at end of file diff --git a/web/source/settings/lib/api/user.js b/web/source/settings/lib/api/user.js deleted file mode 100644 index 41031d489..000000000 --- a/web/source/settings/lib/api/user.js +++ /dev/null @@ -1,67 +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 Promise = require("bluebird"); - -const user = require("../../redux/reducers/user").actions; - -module.exports = function ({ apiCall, getChanges }) { - function updateCredentials(selector, keys) { - return function (dispatch, getState) { - return Promise.try(() => { - const state = selector(getState()); - - const update = getChanges(state, keys); - - return dispatch(apiCall("PATCH", "/api/v1/accounts/update_credentials", update, "form")); - }).then((account) => { - return dispatch(user.setAccount(account)); - }); - }; - } - - return { - fetchAccount: function fetchAccount() { - return function (dispatch, _getState) { - return Promise.try(() => { - return dispatch(apiCall("GET", "/api/v1/accounts/verify_credentials")); - }).then((account) => { - return dispatch(user.setAccount(account)); - }); - }; - }, - - updateProfile: function updateProfile() { - const formKeys = ["display_name", "locked", "source", "custom_css", "source.note", "enable_rss"]; - const renamedKeys = { - "source.note": "note" - }; - const fileKeys = ["header", "avatar"]; - - return updateCredentials((state) => state.user.profile, {formKeys, renamedKeys, fileKeys}); - }, - - updateSettings: function updateProfile() { - const formKeys = ["source"]; - - return updateCredentials((state) => state.user.settings, {formKeys}); - } - }; -};
\ No newline at end of file diff --git a/web/source/settings/lib/errors.js b/web/source/settings/lib/errors.js deleted file mode 100644 index 85302f18e..000000000 --- a/web/source/settings/lib/errors.js +++ /dev/null @@ -1,27 +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 createError = require("create-error"); - -module.exports = { - APIError: createError("APIError"), - OAUTHError: createError("OAUTHError"), - AuthenticationError: createError("AuthenticationError"), -};
\ No newline at end of file diff --git a/web/source/settings/lib/form/bool.jsx b/web/source/settings/lib/form/bool.jsx new file mode 100644 index 000000000..b124abd50 --- /dev/null +++ b/web/source/settings/lib/form/bool.jsx @@ -0,0 +1,50 @@ +/* + 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 useBoolInput({ name, Name }, { defaultValue = false } = {}) { + const [value, setValue] = React.useState(defaultValue); + + function onChange(e) { + setValue(e.target.checked); + } + + function reset() { + setValue(defaultValue); + } + + // Array / Object hybrid, for easier access in different contexts + return Object.assign([ + onChange, + reset, + { + [name]: value, + [`set${Name}`]: setValue + } + ], { + name, + onChange, + reset, + value, + setter: setValue, + hasChanged: () => value != defaultValue + }); +};
\ No newline at end of file diff --git a/web/source/settings/lib/form/check-list.jsx b/web/source/settings/lib/form/check-list.jsx new file mode 100644 index 000000000..c1233273d --- /dev/null +++ b/web/source/settings/lib/form/check-list.jsx @@ -0,0 +1,147 @@ +/* + 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 syncpipe = require("syncpipe"); + +function createState(entries, uniqueKey, oldState, defaultValue) { + return syncpipe(entries, [ + (_) => _.map((entry) => { + let key = entry[uniqueKey]; + return [ + key, + { + ...entry, + key, + checked: oldState[key]?.checked ?? entry.checked ?? defaultValue + } + ]; + }), + (_) => Object.fromEntries(_) + ]); +} + +function updateAllState(state, newValue) { + return syncpipe(state, [ + (_) => Object.values(_), + (_) => _.map((entry) => [entry.key, { + ...entry, + checked: newValue + }]), + (_) => Object.fromEntries(_) + ]); +} + +function updateState(state, key, newValue) { + return { + ...state, + [key]: { + ...state[key], + ...newValue + } + }; +} + +module.exports = function useCheckListInput({ name }, { entries, uniqueKey = "key", defaultValue = false }) { + const [state, setState] = React.useState({}); + + const [someSelected, setSomeSelected] = React.useState(false); + const [toggleAllState, setToggleAllState] = React.useState(0); + const toggleAllRef = React.useRef(null); + + React.useEffect(() => { + /* + entries changed, update state, + re-using old state if available for key + */ + setState(createState(entries, uniqueKey, state, defaultValue)); + + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [entries]); + + React.useEffect(() => { + /* Updates (un)check all checkbox, based on shortcode checkboxes + Can be 0 (not checked), 1 (checked) or 2 (indeterminate) + */ + if (toggleAllRef.current == null) { + return; + } + + let values = Object.values(state); + /* one or more boxes are checked */ + let some = values.some((v) => v.checked); + + let all = false; + if (some) { + /* there's not at least one unchecked box */ + all = !values.some((v) => v.checked == false); + } + + setSomeSelected(some); + + if (some && !all) { + setToggleAllState(2); + toggleAllRef.current.indeterminate = true; + } else { + setToggleAllState(all ? 1 : 0); + toggleAllRef.current.indeterminate = false; + } + }, [state, toggleAllRef]); + + function toggleAll(e) { + let selectAll = e.target.checked; + + if (toggleAllState == 2) { // indeterminate + selectAll = false; + } + + setState(updateAllState(state, selectAll)); + setToggleAllState(selectAll); + } + + function reset() { + setState(updateAllState(state, defaultValue)); + } + + function selectedValues() { + return syncpipe(state, [ + (_) => Object.values(_), + (_) => _.filter((entry) => entry.checked) + ]); + } + + return Object.assign([ + state, + reset, + { name } + ], { + name, + value: state, + onChange: (key, newValue) => setState(updateState(state, key, newValue)), + selectedValues, + reset, + someSelected, + toggleAll: { + ref: toggleAllRef, + value: toggleAllState, + onChange: toggleAll + } + }); +};
\ No newline at end of file diff --git a/web/source/settings/lib/form/combo-box.jsx b/web/source/settings/lib/form/combo-box.jsx new file mode 100644 index 000000000..3e8cea44a --- /dev/null +++ b/web/source/settings/lib/form/combo-box.jsx @@ -0,0 +1,56 @@ +/* + 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 { useComboboxState } = require("ariakit/combobox"); + +module.exports = function useComboBoxInput({ name, Name }, { defaultValue } = {}) { + const [isNew, setIsNew] = React.useState(false); + + const state = useComboboxState({ + defaultValue, + gutter: 0, + sameWidth: true + }); + + function reset() { + state.setValue(""); + } + + return Object.assign([ + state, + reset, + { + [name]: state.value, + name, + [`${name}IsNew`]: isNew, + [`set${Name}IsNew`]: setIsNew + } + ], { + name, + state, + value: state.value, + hasChanged: () => state.value != defaultValue, + isNew, + setIsNew, + reset + }); +};
\ No newline at end of file diff --git a/web/source/settings/lib/form/file.jsx b/web/source/settings/lib/form/file.jsx new file mode 100644 index 000000000..85f23e274 --- /dev/null +++ b/web/source/settings/lib/form/file.jsx @@ -0,0 +1,91 @@ +/* + 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 prettierBytes = require("prettier-bytes"); + +module.exports = function useFileInput({ name, _Name }, { + withPreview, + maxSize, + initialInfo = "no file selected" +} = {}) { + const [file, setFile] = React.useState(); + const [imageURL, setImageURL] = React.useState(); + const [info, setInfo] = React.useState(); + + function onChange(e) { + let file = e.target.files[0]; + setFile(file); + + URL.revokeObjectURL(imageURL); + + if (file != undefined) { + if (withPreview) { + setImageURL(URL.createObjectURL(file)); + } + + let size = prettierBytes(file.size); + if (maxSize && file.size > maxSize) { + size = <span className="error-text">{size}</span>; + } + + setInfo(<> + {file.name} ({size}) + </>); + } else { + setInfo(); + } + } + + function reset() { + URL.revokeObjectURL(imageURL); + setImageURL(); + setFile(); + setInfo(); + } + + const infoComponent = ( + <span className="form-info"> + {info + ? info + : initialInfo + } + </span> + ); + + // Array / Object hybrid, for easier access in different contexts + return Object.assign([ + onChange, + reset, + { + [name]: file, + [`${name}URL`]: imageURL, + [`${name}Info`]: infoComponent, + } + ], { + onChange, + reset, + name, + value: file, + previewValue: imageURL, + hasChanged: () => file != undefined, + infoComponent + }); +};
\ No newline at end of file diff --git a/web/source/settings/lib/submit.js b/web/source/settings/lib/form/form-with-data.jsx index 6bb8836fc..a383af502 100644 --- a/web/source/settings/lib/submit.js +++ b/web/source/settings/lib/form/form-with-data.jsx @@ -18,31 +18,22 @@ "use strict"; -const Promise = require("bluebird"); - -module.exports = function submit(func, { - setStatus, setError, - startStatus="PATCHing", successStatus="Saved!", - onSuccess, - onError -}) { - return function() { - setStatus(startStatus); - setError(""); - return Promise.try(() => { - return func(); - }).then(() => { - setStatus(successStatus); - if (onSuccess != undefined) { - return onSuccess(); - } - }).catch((e) => { - setError(e.message); - setStatus(""); - console.error(e); - if (onError != undefined) { - onError(e); - } - }); - }; +const React = require("react"); + +const Loading = require("../../components/loading"); + +// Wrap Form component inside component that fires the RTK Query call, +// so Form will only be rendered when data is available to generate form-fields for +module.exports = function FormWithData({ dataQuery, DataForm, queryArg, ...formProps }) { + const { data, isLoading } = dataQuery(queryArg); + + if (isLoading) { + return ( + <div> + <Loading /> + </div> + ); + } else { + return <DataForm data={data} {...formProps} />; + } };
\ No newline at end of file diff --git a/web/source/settings/lib/form/index.js b/web/source/settings/lib/form/index.js new file mode 100644 index 000000000..aef3bf0d2 --- /dev/null +++ b/web/source/settings/lib/form/index.js @@ -0,0 +1,46 @@ +/* + 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"; + +function capitalizeFirst(str) { + return str.slice(0, 1).toUpperCase() + str.slice(1); +} + +function makeHook(func) { + return (name, ...args) => func({ + name, + Name: capitalizeFirst(name) + }, ...args); +} + +module.exports = { + useTextInput: makeHook(require("./text")), + useFileInput: makeHook(require("./file")), + useBoolInput: makeHook(require("./bool")), + useRadioInput: makeHook(require("./radio")), + useComboBoxInput: makeHook(require("./combo-box")), + useCheckListInput: makeHook(require("./check-list")), + useValue: function (name, value) { + return { + name, + value, + hasChanged: () => true // always included + }; + } +};
\ No newline at end of file diff --git a/web/source/settings/lib/form/radio.jsx b/web/source/settings/lib/form/radio.jsx new file mode 100644 index 000000000..47ab6c726 --- /dev/null +++ b/web/source/settings/lib/form/radio.jsx @@ -0,0 +1,51 @@ +/* + 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 useRadioInput({ name, Name }, { defaultValue, options } = {}) { + const [value, setValue] = React.useState(defaultValue); + + function onChange(e) { + setValue(e.target.value); + } + + function reset() { + setValue(defaultValue); + } + + // Array / Object hybrid, for easier access in different contexts + return Object.assign([ + onChange, + reset, + { + [name]: value, + [`set${Name}`]: setValue + } + ], { + name, + onChange, + reset, + value, + setter: setValue, + options, + hasChanged: () => value != defaultValue + }); +};
\ No newline at end of file diff --git a/web/source/settings/lib/form/submit.js b/web/source/settings/lib/form/submit.js new file mode 100644 index 000000000..6f20165a5 --- /dev/null +++ b/web/source/settings/lib/form/submit.js @@ -0,0 +1,83 @@ +/* + 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 Promise = require("bluebird"); +const React = require("react"); +const syncpipe = require("syncpipe"); + +module.exports = function useFormSubmit(form, mutationQuery, { changedOnly = true } = {}) { + if (!Array.isArray(mutationQuery)) { + throw new ("useFormSubmit: mutationQuery was not an Array. Is a valid useMutation RTK Query provided?"); + } + const [runMutation, result] = mutationQuery; + const [usedAction, setUsedAction] = React.useState(); + return [ + function submitForm(e) { + let action; + if (e?.preventDefault) { + e.preventDefault(); + action = e.nativeEvent.submitter.name; + } else { + action = e; + } + + if (action == "") { + action = undefined; + } + setUsedAction(action); + // transform the field definitions into an object with just their values + let updatedFields = []; + const mutationData = syncpipe(form, [ + (_) => Object.values(_), + (_) => _.map((field) => { + if (field.selectedValues != undefined) { + let selected = field.selectedValues(); + if (!changedOnly || selected.length > 0) { + updatedFields.push(field); + return [field.name, selected]; + } + } else if (!changedOnly || field.hasChanged()) { + updatedFields.push(field); + return [field.name, field.value]; + } + return null; + }), + (_) => _.filter((value) => value != null), + (_) => Object.fromEntries(_) + ]); + + mutationData.action = action; + + return Promise.try(() => { + return runMutation(mutationData); + }).then((res) => { + if (res.error == undefined) { + updatedFields.forEach((field) => { + field.reset(); + }); + } + }); + }, + { + ...result, + action: usedAction + } + ]; +};
\ No newline at end of file diff --git a/web/source/settings/lib/form/text.jsx b/web/source/settings/lib/form/text.jsx new file mode 100644 index 000000000..70e61657c --- /dev/null +++ b/web/source/settings/lib/form/text.jsx @@ -0,0 +1,67 @@ +/* + 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 useTextInput({ name, Name }, { validator, defaultValue = "", dontReset = false } = {}) { + const [text, setText] = React.useState(defaultValue); + const [valid, setValid] = React.useState(true); + const textRef = React.useRef(null); + + function onChange(e) { + let input = e.target.value; + setText(input); + } + + function reset() { + if (!dontReset) { + setText(defaultValue); + } + } + + React.useEffect(() => { + if (validator && textRef.current) { + let res = validator(text); + setValid(res == ""); + textRef.current.setCustomValidity(res); + } + }, [text, textRef, validator]); + + // Array / Object hybrid, for easier access in different contexts + return Object.assign([ + onChange, + reset, + { + [name]: text, + [`${name}Ref`]: textRef, + [`set${Name}`]: setText, + [`${name}Valid`]: valid, + } + ], { + onChange, + reset, + name, + value: text, + ref: textRef, + setter: setText, + valid, + hasChanged: () => text != defaultValue + }); +};
\ No newline at end of file diff --git a/web/source/settings/lib/get-views.js b/web/source/settings/lib/get-views.js index 99644ea90..f0d5433fb 100644 --- a/web/source/settings/lib/get-views.js +++ b/web/source/settings/lib/get-views.js @@ -22,7 +22,7 @@ const React = require("react"); const { Link, Route, Redirect } = require("wouter"); const { ErrorBoundary } = require("react-error-boundary"); -const ErrorFallback = require("../components/error"); +const { ErrorFallback } = require("../components/error"); const NavButton = require("../components/nav-button"); function urlSafe(str) { @@ -64,7 +64,7 @@ module.exports = function getViews(struct) { } panelRouterEl.push(( - <Route path={`${url}/:page?`} key={url}> + <Route path={`${url}/:page*`} key={url}> <ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => { }}> {/* FIXME: implement onReset */} <ViewComponent /> diff --git a/web/source/settings/lib/query/admin/custom-emoji.js b/web/source/settings/lib/query/admin/custom-emoji.js new file mode 100644 index 000000000..94917f382 --- /dev/null +++ b/web/source/settings/lib/query/admin/custom-emoji.js @@ -0,0 +1,195 @@ +/* + 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 Promise = require("bluebird"); + +const { unwrapRes } = require("../lib"); + +module.exports = (build) => ({ + getAllEmoji: build.query({ + query: (params = {}) => ({ + url: "/api/v1/admin/custom_emojis", + params: { + limit: 0, + ...params + } + }), + providesTags: (res) => + res + ? [...res.map((emoji) => ({ type: "Emojis", id: emoji.id })), { type: "Emojis", id: "LIST" }] + : [{ type: "Emojis", id: "LIST" }] + }), + + getEmoji: build.query({ + query: (id) => ({ + url: `/api/v1/admin/custom_emojis/${id}` + }), + providesTags: (res, error, id) => [{ type: "Emojis", id }] + }), + + addEmoji: build.mutation({ + query: (form) => { + return { + method: "POST", + url: `/api/v1/admin/custom_emojis`, + asForm: true, + body: form, + discardEmpty: true + }; + }, + invalidatesTags: (res) => + res + ? [{ type: "Emojis", id: "LIST" }, { type: "Emojis", id: res.id }] + : [{ type: "Emojis", id: "LIST" }] + }), + + editEmoji: build.mutation({ + query: ({ id, ...patch }) => { + return { + method: "PATCH", + url: `/api/v1/admin/custom_emojis/${id}`, + asForm: true, + body: { + type: "modify", + ...patch + } + }; + }, + invalidatesTags: (res) => + res + ? [{ type: "Emojis", id: "LIST" }, { type: "Emojis", id: res.id }] + : [{ type: "Emojis", id: "LIST" }] + }), + + deleteEmoji: build.mutation({ + query: (id) => ({ + method: "DELETE", + url: `/api/v1/admin/custom_emojis/${id}` + }), + invalidatesTags: (res, error, id) => [{ type: "Emojis", id }] + }), + + searchStatusForEmoji: build.mutation({ + queryFn: (url, api, _extraOpts, baseQuery) => { + return Promise.try(() => { + return baseQuery({ + url: `/api/v2/search?q=${encodeURIComponent(url)}&resolve=true&limit=1` + }).then(unwrapRes); + }).then((searchRes) => { + return emojiFromSearchResult(searchRes); + }).then(({ type, domain, list }) => { + const state = api.getState(); + if (domain == new URL(state.oauth.instance).host) { + throw "LOCAL_INSTANCE"; + } + + // search for every mentioned emoji with the admin api to get their ID + return Promise.map(list, (emoji) => { + return baseQuery({ + url: `/api/v1/admin/custom_emojis`, + params: { + filter: `domain:${domain},shortcode:${emoji.shortcode}`, + limit: 1 + } + }).then((unwrapRes)).then((list) => list[0]); + }, { concurrency: 5 }).then((listWithIDs) => { + return { + data: { + type, + domain, + list: listWithIDs + } + }; + }); + }).catch((e) => { + return { error: e }; + }); + } + }), + + patchRemoteEmojis: build.mutation({ + queryFn: ({ action, ...formData }, _api, _extraOpts, baseQuery) => { + const data = []; + const errors = []; + + return Promise.each(formData.selectedEmoji, (emoji) => { + return Promise.try(() => { + let body = { + type: action + }; + + if (action == "copy") { + body.shortcode = emoji.shortcode; + if (formData.category.trim().length != 0) { + body.category = formData.category; + } + } + + return baseQuery({ + method: "PATCH", + url: `/api/v1/admin/custom_emojis/${emoji.id}`, + asForm: true, + body: body + }).then(unwrapRes); + }).then((res) => { + data.push([emoji.shortcode, res]); + }).catch((e) => { + let msg = e.message ?? e; + if (e.data.error) { + msg = e.data.error; + } + errors.push([emoji.shortcode, msg]); + }); + }).then(() => { + if (errors.length == 0) { + return { data }; + } else { + return { + error: errors + }; + } + }); + }, + invalidatesTags: () => [{ type: "Emojis", id: "LIST" }] + }) +}); + +function emojiFromSearchResult(searchRes) { + /* Parses the search response, prioritizing a toot result, + and returns referenced custom emoji + */ + let type; + + if (searchRes.statuses.length > 0) { + type = "statuses"; + } else if (searchRes.accounts.length > 0) { + type = "accounts"; + } else { + throw "NONE_FOUND"; + } + + let data = searchRes[type][0]; + + return { + type, + domain: (new URL(data.url)).host, // to get WEB_DOMAIN, see https://github.com/superseriousbusiness/gotosocial/issues/1225 + list: data.emojis + }; +}
\ No newline at end of file diff --git a/web/source/settings/lib/query/admin/import-export.js b/web/source/settings/lib/query/admin/import-export.js new file mode 100644 index 000000000..94e462bd2 --- /dev/null +++ b/web/source/settings/lib/query/admin/import-export.js @@ -0,0 +1,212 @@ +/* + 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 Promise = require("bluebird"); +const isValidDomain = require("is-valid-domain"); +const fileDownload = require("js-file-download"); + +const { + replaceCacheOnMutation, + domainListToObject, + unwrapRes +} = require("../lib"); + +function parseDomainList(list) { + if (list[0] == "[") { + return JSON.parse(list); + } else { + return list.split("\n").map((line) => { + let domain = line.trim(); + let valid = true; + if (domain.startsWith("http")) { + try { + domain = new URL(domain).hostname; + } catch (e) { + valid = false; + } + } + return domain.length > 0 + ? { domain, valid } + : null; + }).filter((a) => a); // not `null` + } +} + +function validateDomainList(list) { + list.forEach((entry) => { + entry.valid = (entry.valid !== false) && isValidDomain(entry.domain, { wildcard: true, allowUnicode: true }); + entry.checked = entry.valid; + }); + + return list; +} + +function deduplicateDomainList(list) { + let domains = new Set(); + return list.filter((entry) => { + if (domains.has(entry.domain)) { + return false; + } else { + domains.add(entry.domain); + return true; + } + }); +} + +module.exports = (build) => ({ + processDomainList: build.mutation({ + queryFn: (formData) => { + return Promise.try(() => { + if (formData.domains == undefined || formData.domains.length == 0) { + throw "No domains entered"; + } + return parseDomainList(formData.domains); + }).then((parsed) => { + return deduplicateDomainList(parsed); + }).then((deduped) => { + return validateDomainList(deduped); + }).then((data) => { + return { data }; + }).catch((e) => { + return { error: e.toString() }; + }); + } + }), + exportDomainList: build.mutation({ + queryFn: (formData, api, _extraOpts, baseQuery) => { + return Promise.try(() => { + return baseQuery({ + url: `/api/v1/admin/domain_blocks` + }); + }).then(unwrapRes).then((blockedInstances) => { + return blockedInstances.map((entry) => { + if (formData.exportType == "json") { + return { + domain: entry.domain, + public_comment: entry.public_comment + }; + } else { + return entry.domain; + } + }); + }).then((exportList) => { + if (formData.exportType == "json") { + return JSON.stringify(exportList); + } else { + return exportList.join("\n"); + } + }).then((exportAsString) => { + if (formData.action == "export") { + return { + data: exportAsString + }; + } else if (formData.action == "export-file") { + let domain = new URL(api.getState().oauth.instance).host; + let date = new Date(); + let mime; + + let filename = [ + domain, + "blocklist", + date.getFullYear(), + (date.getMonth() + 1).toString().padStart(2, "0"), + date.getDate().toString().padStart(2, "0"), + ].join("-"); + + if (formData.exportType == "json") { + filename += ".json"; + mime = "application/json"; + } else { + filename += ".txt"; + mime = "text/plain"; + } + + fileDownload(exportAsString, filename, mime); + } + return { data: null }; + }).catch((e) => { + return { error: e }; + }); + } + }), + importDomainList: build.mutation({ + query: (formData) => { + const { domains } = formData; + + // add/replace comments, obfuscation data + let process = entryProcessor(formData); + domains.forEach((entry) => { + process(entry); + }); + + return { + method: "POST", + url: `/api/v1/admin/domain_blocks?import=true`, + asForm: true, + discardEmpty: true, + body: { + domains: new Blob([JSON.stringify(domains)], { type: "application/json" }) + } + }; + }, + transformResponse: domainListToObject, + ...replaceCacheOnMutation("instanceBlocks") + }) +}); + +function entryProcessor(formData) { + let funcs = []; + + ["private_comment", "public_comment"].forEach((type) => { + let text = formData[type].trim(); + + if (text.length > 0) { + let behavior = formData[`${type}_behavior`]; + + if (behavior == "append") { + funcs.push(function appendComment(entry) { + if (entry[type] == undefined) { + entry[type] = text; + } else { + entry[type] = [entry[type], text].join("\n"); + } + }); + } else if (behavior == "replace") { + funcs.push(function replaceComment(entry) { + entry[type] = text; + }); + } + } + }); + + return function process(entry) { + funcs.forEach((func) => { + func(entry); + }); + + entry.obfuscate = formData.obfuscate; + + Object.entries(entry).forEach(([key, val]) => { + if (val == undefined) { + delete entry[key]; + } + }); + }; +}
\ No newline at end of file diff --git a/web/source/settings/lib/query/admin/index.js b/web/source/settings/lib/query/admin/index.js new file mode 100644 index 000000000..33e14521e --- /dev/null +++ b/web/source/settings/lib/query/admin/index.js @@ -0,0 +1,84 @@ +/* + 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 { + replaceCacheOnMutation, + removeFromCacheOnMutation, + domainListToObject +} = require("../lib"); +const base = require("../base"); + +const endpoints = (build) => ({ + updateInstance: build.mutation({ + query: (formData) => ({ + method: "PATCH", + url: `/api/v1/instance`, + asForm: true, + body: formData, + discardEmpty: true + }), + ...replaceCacheOnMutation("instance") + }), + mediaCleanup: build.mutation({ + query: (days) => ({ + method: "POST", + url: `/api/v1/admin/media_cleanup`, + params: { + remote_cache_days: days + } + }) + }), + instanceBlocks: build.query({ + query: () => ({ + url: `/api/v1/admin/domain_blocks` + }), + transformResponse: domainListToObject + }), + addInstanceBlock: build.mutation({ + query: (formData) => ({ + method: "POST", + url: `/api/v1/admin/domain_blocks`, + asForm: true, + body: formData, + discardEmpty: true + }), + transformResponse: (data) => { + return { + [data.domain]: data + }; + }, + ...replaceCacheOnMutation("instanceBlocks") + }), + removeInstanceBlock: build.mutation({ + query: (id) => ({ + method: "DELETE", + url: `/api/v1/admin/domain_blocks/${id}`, + }), + ...removeFromCacheOnMutation("instanceBlocks", { + findKey: (_draft, newData) => { + return newData.domain; + } + }) + }), + ...require("./import-export")(build), + ...require("./custom-emoji")(build) +}); + +module.exports = base.injectEndpoints({ endpoints });
\ No newline at end of file diff --git a/web/source/settings/lib/query/base.js b/web/source/settings/lib/query/base.js index a782be54a..05d31925f 100644 --- a/web/source/settings/lib/query/base.js +++ b/web/source/settings/lib/query/base.js @@ -1,35 +1,57 @@ /* - GoToSocial - Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + 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 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. + 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/>. + 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 { createApi, fetchBaseQuery } = require("@reduxjs/toolkit/query/react"); +const { isPlainObject } = require("is-plain-object"); -const { convertToForm } = require("../api"); +function convertToForm(obj) { + const formData = new FormData(); + Object.entries(obj).forEach(([key, val]) => { + if (isPlainObject(val)) { + Object.entries(val).forEach(([key2, val2]) => { + if (val2 != undefined) { + formData.set(`${key}[${key2}]`, val2); + } + }); + } else if (val != undefined) { + formData.set(key, val); + } + }); + return formData; +} function instanceBasedQuery(args, api, extraOptions) { const state = api.getState(); - const {instance, token} = state.oauth; + const { instance, token } = state.oauth; if (args.baseUrl == undefined) { args.baseUrl = instance; } + if (args.discardEmpty) { + if (args.body == undefined || Object.keys(args.body).length == 0) { + return { data: null }; + } + delete args.discardEmpty; + } + if (args.asForm) { delete args.asForm; args.body = convertToForm(args.body); @@ -50,6 +72,12 @@ function instanceBasedQuery(args, api, extraOptions) { module.exports = createApi({ reducerPath: "api", baseQuery: instanceBasedQuery, - tagTypes: ["Emojis"], - endpoints: () => ({}) + tagTypes: ["Auth"], + endpoints: (build) => ({ + instance: build.query({ + query: () => ({ + url: `/api/v1/instance` + }) + }) + }) });
\ No newline at end of file diff --git a/web/source/settings/lib/query/custom-emoji.js b/web/source/settings/lib/query/custom-emoji.js deleted file mode 100644 index e62931cb2..000000000 --- a/web/source/settings/lib/query/custom-emoji.js +++ /dev/null @@ -1,180 +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 Promise = require("bluebird"); - -const base = require("./base"); - -function unwrap(res) { - if (res.error != undefined) { - throw res.error; - } else { - return res.data; - } -} - -const endpoints = (build) => ({ - getAllEmoji: build.query({ - query: (params = {}) => ({ - url: "/api/v1/admin/custom_emojis", - params: { - limit: 0, - ...params - } - }), - providesTags: (res) => - res - ? [...res.map((emoji) => ({type: "Emojis", id: emoji.id})), {type: "Emojis", id: "LIST"}] - : [{type: "Emojis", id: "LIST"}] - }), - getEmoji: build.query({ - query: (id) => ({ - url: `/api/v1/admin/custom_emojis/${id}` - }), - providesTags: (res, error, id) => [{type: "Emojis", id}] - }), - addEmoji: build.mutation({ - query: (form) => { - return { - method: "POST", - url: `/api/v1/admin/custom_emojis`, - asForm: true, - body: form - }; - }, - invalidatesTags: (res) => - res - ? [{type: "Emojis", id: "LIST"}, {type: "Emojis", id: res.id}] - : [{type: "Emojis", id: "LIST"}] - }), - editEmoji: build.mutation({ - query: ({id, ...patch}) => { - return { - method: "PATCH", - url: `/api/v1/admin/custom_emojis/${id}`, - asForm: true, - body: { - type: "modify", - ...patch - } - }; - }, - invalidatesTags: (res) => - res - ? [{type: "Emojis", id: "LIST"}, {type: "Emojis", id: res.id}] - : [{type: "Emojis", id: "LIST"}] - }), - deleteEmoji: build.mutation({ - query: (id) => ({ - method: "DELETE", - url: `/api/v1/admin/custom_emojis/${id}` - }), - invalidatesTags: (res, error, id) => [{type: "Emojis", id}] - }), - searchStatusForEmoji: build.mutation({ - query: (url) => ({ - method: "GET", - url: `/api/v2/search?q=${encodeURIComponent(url)}&resolve=true&limit=1` - }), - transformResponse: (res) => { - /* Parses search response, prioritizing a toot result, - and returns referenced custom emoji - */ - let type; - - if (res.statuses.length > 0) { - type = "statuses"; - } else if (res.accounts.length > 0) { - type = "accounts"; - } else { - return { - type: "none" - }; - } - - let data = res[type][0]; - - return { - type, - domain: (new URL(data.url)).host, // to get WEB_DOMAIN, see https://github.com/superseriousbusiness/gotosocial/issues/1225 - list: data.emojis - }; - } - }), - patchRemoteEmojis: build.mutation({ - queryFn: ({action, domain, list, category}, api, _extraOpts, baseQuery) => { - const data = []; - const errors = []; - - return Promise.each(list, (emoji) => { - return Promise.try(() => { - return baseQuery({ - method: "GET", - url: `/api/v1/admin/custom_emojis`, - params: { - filter: `domain:${domain},shortcode:${emoji.shortcode}`, - limit: 1 - } - }).then(unwrap); - }).then(([lookup]) => { - if (lookup == undefined) { throw "not found"; } - - let body = { - type: action - }; - - if (action == "copy") { - body.shortcode = emoji.localShortcode ?? emoji.shortcode; - if (category.trim().length != 0) { - body.category = category; - } - } - - return baseQuery({ - method: "PATCH", - url: `/api/v1/admin/custom_emojis/${lookup.id}`, - asForm: true, - body: body - }).then(unwrap); - }).then((res) => { - data.push([emoji.shortcode, res]); - }).catch((e) => { - console.error("emoji lookup for", emoji.shortcode, "failed:", e); - let msg = e.message ?? e; - if (e.data.error) { - msg = e.data.error; - } - errors.push([emoji.shortcode, msg]); - }); - }).then(() => { - if (errors.length == 0) { - return { data }; - } else { - return { - error: errors - }; - } - }); - }, - invalidatesTags: () => [{type: "Emojis", id: "LIST"}] - }) -}); - -module.exports = base.injectEndpoints({endpoints});
\ No newline at end of file diff --git a/web/source/settings/lib/query/index.js b/web/source/settings/lib/query/index.js index 4bd0ff100..a8f275da7 100644 --- a/web/source/settings/lib/query/index.js +++ b/web/source/settings/lib/query/index.js @@ -1,24 +1,26 @@ /* - GoToSocial - Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org + 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 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. + 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/>. + 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"; module.exports = { ...require("./base"), - ...require("./custom-emoji.js") + ...require("./oauth"), + ...require("./user"), + ...require("./admin") };
\ No newline at end of file diff --git a/web/source/settings/lib/query/lib.js b/web/source/settings/lib/query/lib.js new file mode 100644 index 000000000..dae749198 --- /dev/null +++ b/web/source/settings/lib/query/lib.js @@ -0,0 +1,75 @@ +/* + 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 syncpipe = require("syncpipe"); +const base = require("./base"); + +module.exports = { + unwrapRes(res) { + if (res.error != undefined) { + throw res.error; + } else { + return res.data; + } + }, + domainListToObject: (data) => { + // Turn flat Array into Object keyed by block's domain + return syncpipe(data, [ + (_) => _.map((entry) => [entry.domain, entry]), + (_) => Object.fromEntries(_) + ]); + }, + replaceCacheOnMutation: makeCacheMutation((draft, newData) => { + Object.assign(draft, newData); + }), + appendCacheOnMutation: makeCacheMutation((draft, newData) => { + draft.push(newData); + }), + spliceCacheOnMutation: makeCacheMutation((draft, newData, { key }) => { + draft.splice(key, 1); + }), + updateCacheOnMutation: makeCacheMutation((draft, newData, { key }) => { + draft[key] = newData; + }), + removeFromCacheOnMutation: makeCacheMutation((draft, newData, { key }) => { + delete draft[key]; + }), + editCacheOnMutation: makeCacheMutation((draft, newData, { update }) => { + update(draft, newData); + }) +}; + +// https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#pessimistic-updates +function makeCacheMutation(action) { + return function cacheMutation(queryName, { key, findKey, arg, ...opts } = {}) { + return { + onQueryStarted: (_, { dispatch, queryFulfilled }) => { + queryFulfilled.then(({ data: newData }) => { + dispatch(base.util.updateQueryData(queryName, arg, (draft) => { + if (findKey != undefined) { + key = findKey(draft, newData); + } + action(draft, newData, { key, ...opts }); + })); + }); + } + }; + }; +}
\ No newline at end of file diff --git a/web/source/settings/lib/query/oauth.js b/web/source/settings/lib/query/oauth.js new file mode 100644 index 000000000..4fac50429 --- /dev/null +++ b/web/source/settings/lib/query/oauth.js @@ -0,0 +1,158 @@ +/* + 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 Promise = require("bluebird"); + +const base = require("./base"); +const { unwrapRes } = require("./lib"); +const oauth = require("../../redux/oauth").actions; + +function getSettingsURL() { + /* needed in case the settings interface isn't hosted at /settings but + some subpath like /gotosocial/settings. Other parts of the code don't + take this into account yet so mostly future-proofing. + + Also drops anything past /settings/, because authorization urls that are too long + get rejected by GTS. + */ + let [pre, _past] = window.location.pathname.split("/settings"); + return `${window.location.origin}${pre}/settings`; +} + +const SETTINGS_URL = getSettingsURL(); + +const endpoints = (build) => ({ + verifyCredentials: build.query({ + providesTags: (_res, error) => + error == undefined + ? ["Auth"] + : [], + queryFn: (_arg, api, _extraOpts, baseQuery) => { + const state = api.getState(); + + return Promise.try(() => { + // Process callback code first, if available + if (state.oauth.loginState == "callback") { + let urlParams = new URLSearchParams(window.location.search); + let code = urlParams.get("code"); + + if (code == undefined) { + throw { + message: "Waiting for callback, but no ?code= provided in url." + }; + } else { + let app = state.oauth.registration; + + if (app == undefined || app.client_id == undefined) { + throw { + message: "No stored registration data, can't finish login flow." + }; + } + + return baseQuery({ + method: "POST", + url: "/oauth/token", + body: { + client_id: app.client_id, + client_secret: app.client_secret, + redirect_uri: SETTINGS_URL, + grant_type: "authorization_code", + code: code + } + }).then(unwrapRes).then((token) => { + // remove ?code= from url + window.history.replaceState({}, document.title, window.location.pathname); + api.dispatch(oauth.setToken(token)); + }); + } + } + }).then(() => { + return baseQuery({ + url: `/api/v1/accounts/verify_credentials` + }); + }).catch((e) => { + return { error: e }; + }); + } + }), + authorizeFlow: build.mutation({ + queryFn: (formData, api, _extraOpts, baseQuery) => { + let instance; + const state = api.getState(); + + return Promise.try(() => { + if (!formData.instance.startsWith("http")) { + formData.instance = `https://${formData.instance}`; + } + instance = new URL(formData.instance).origin; + + const stored = state.oauth.instance; + if (stored?.instance == instance && stored.registration) { + return stored.registration; + } + + return baseQuery({ + method: "POST", + baseUrl: instance, + url: "/api/v1/apps", + body: { + client_name: "GoToSocial Settings", + scopes: formData.scopes, + redirect_uris: SETTINGS_URL, + website: SETTINGS_URL + } + }).then(unwrapRes).then((app) => { + app.scopes = formData.scopes; + + api.dispatch(oauth.setInstance({ + instance: instance, + registration: app, + loginState: "callback" + })); + + return app; + }); + }).then((app) => { + let url = new URL(instance); + url.pathname = "/oauth/authorize"; + url.searchParams.set("client_id", app.client_id); + url.searchParams.set("redirect_uri", SETTINGS_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", app.scopes); + + let redirectURL = url.toString(); + window.location.assign(redirectURL); + + return { data: null }; + }).catch((e) => { + return { error: e }; + }); + }, + }), + logout: build.mutation({ + queryFn: (_arg, api) => { + api.dispatch(oauth.remove()); + return { data: null }; + }, + invalidatesTags: ["Auth"] + }) +}); + +module.exports = base.injectEndpoints({ endpoints });
\ No newline at end of file diff --git a/web/source/settings/lib/query/user.js b/web/source/settings/lib/query/user.js new file mode 100644 index 000000000..d2d2830b7 --- /dev/null +++ b/web/source/settings/lib/query/user.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 { replaceCacheOnMutation } = require("./lib"); +const base = require("./base"); + +const endpoints = (build) => ({ + updateCredentials: build.mutation({ + query: (formData) => ({ + method: "PATCH", + url: `/api/v1/accounts/update_credentials`, + asForm: true, + body: formData, + discardEmpty: true + }), + ...replaceCacheOnMutation("verifyCredentials") + }), + passwordChange: build.mutation({ + query: (data) => ({ + method: "POST", + url: `/api/v1/user/password_change`, + body: data + }) + }) +}); + +module.exports = base.injectEndpoints({ endpoints });
\ No newline at end of file |