[feature] Instance rules (#2125)

* init instance rules database model, admin api

* expose instance rules in public instance api

* public /api/v1/instance/rules route

* GET ruleById

* createRule route

* createRule auth check

* updateRule

* deleteRule

* list rules on about page

* ruleGet auth

* add about page ids for anchors

* process and store adding violated rules to reports

* admin api models for instance rules

* instance rule edit frontend

* change rule inputs to textareas

* database fixes after rebase (#2124)

* remove unused imports

* fix db migration column name

* fix tests

* fix more tests

* fix postgres error with wrongly used Ident

* add some tests, fiddle with rule model a bit, fix postgres migration

* swagger docs

---------

Co-authored-by: tsmethurst <tobi.smethurst@protonmail.com>
This commit is contained in:
f0x52
2023-08-19 14:33:15 +02:00
committed by GitHub
parent d5d6ad406f
commit 92de8fb396
49 changed files with 2189 additions and 107 deletions

View File

@@ -542,6 +542,57 @@ label {
}
}
.instance-rules {
list-style-position: inside;
margin: 0;
padding: 0;
a.rule {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
color: $fg;
text-decoration: none;
background: $toot-bg;
padding: 1rem;
margin: 0.5rem 0;
border-radius: $br;
line-height: 2rem;
position: relative;
&:hover {
color: $fg-accent;
.edit-icon {
display: inline;
}
}
.edit-icon {
display: none;
font-size: 1rem;
line-height: 1.5rem;
}
li {
font-size: 1.75rem;
padding: 0;
margin: 0;
h2 {
margin: 0;
margin-top: 0 !important;
display: inline-block;
font-size: 1.5rem;
}
}
span {
color: $fg-reduced;
}
}
}
@media screen and (max-width: 30rem) {
.domain-blocklist .entry {
grid-template-columns: 1fr;

View File

@@ -141,22 +141,29 @@ function DomainBlockForm({ defaultDomain, block = {}, baseUrl }) {
{...disabledForm}
/>
<MutationButton
label="Suspend"
result={addResult}
{...disabledForm}
/>
{
isExistingBlock &&
<div className="action-buttons row">
<MutationButton
type="button"
onClick={() => removeBlock(block.id)}
label="Remove"
result={removeResult}
className="button danger"
label="Suspend"
result={addResult}
showError={false}
{...disabledForm}
/>
}
{
isExistingBlock &&
<MutationButton
type="button"
onClick={() => removeBlock(block.id)}
label="Remove"
result={removeResult}
className="button danger"
showError={false}
/>
}
</div>
{addResult.error && <Error error={addResult.error} />}
{removeResult.error && <Error error={removeResult.error} />}
</form>
);

View File

@@ -21,23 +21,23 @@
const React = require("react");
const query = require("../lib/query");
const query = require("../../lib/query");
const {
useTextInput,
useFileInput
} = require("../lib/form");
} = require("../../lib/form");
const useFormSubmit = require("../lib/form/submit");
const useFormSubmit = require("../../lib/form/submit");
const {
TextInput,
TextArea,
FileInput
} = require("../components/form/inputs");
} = require("../../components/form/inputs");
const FormWithData = require("../lib/form/form-with-data");
const MutationButton = require("../components/form/mutation-button");
const FormWithData = require("../../lib/form/form-with-data");
const MutationButton = require("../../components/form/mutation-button");
module.exports = function AdminSettings() {
return (

View File

@@ -0,0 +1,169 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const React = require("react");
const { Switch, Route, Link, Redirect, useRoute } = require("wouter");
const query = require("../../lib/query");
const FormWithData = require("../../lib/form/form-with-data");
const { useBaseUrl } = require("../../lib/navigation/util");
const { useValue, useTextInput } = require("../../lib/form");
const useFormSubmit = require("../../lib/form/submit");
const { TextArea } = require("../../components/form/inputs");
const MutationButton = require("../../components/form/mutation-button");
module.exports = function InstanceRulesData({ baseUrl }) {
return (
<FormWithData
dataQuery={query.useInstanceRulesQuery}
DataForm={InstanceRules}
baseUrl={baseUrl}
/>
);
};
function InstanceRules({ baseUrl, data: rules }) {
return (
<Switch>
<Route path={`${baseUrl}/:ruleId`}>
<InstanceRuleDetail rules={rules} />
</Route>
<Route>
<div>
<h1>Instance Rules</h1>
<div>
<p>
The rules for your instance are listed on the about page, and can be selected when submitting reports.
</p>
</div>
<InstanceRuleList rules={rules} />
</div>
</Route>
</Switch>
);
}
function InstanceRuleList({ rules }) {
const newRule = useTextInput("text", {});
const [submitForm, result] = useFormSubmit({ newRule }, query.useAddInstanceRuleMutation(), {
onFinish: () => newRule.reset()
});
return (
<>
<form onSubmit={submitForm} className="new-rule">
<ol className="instance-rules">
{Object.values(rules).map((rule) => (
<InstanceRule key={rule.id} rule={rule} />
))}
</ol>
<TextArea
field={newRule}
label="New instance rule"
/>
<MutationButton label="Add rule" result={result} />
</form>
</>
);
}
function InstanceRule({ rule }) {
const baseUrl = useBaseUrl();
return (
<Link to={`${baseUrl}/${rule.id}`}>
<a className="rule">
<li>
<h2>{rule.text} <i className="fa fa-pencil edit-icon" /></h2>
</li>
<span>{new Date(rule.created_at).toLocaleString()}</span>
</a>
</Link>
);
}
function InstanceRuleDetail({ rules }) {
const baseUrl = useBaseUrl();
let [_match, params] = useRoute(`${baseUrl}/:ruleId`);
if (params?.ruleId == undefined || rules[params.ruleId] == undefined) {
return <Redirect to={baseUrl} />;
} else {
return (
<>
<Link to={baseUrl}><a>&lt; go back</a></Link>
<InstanceRuleForm rule={rules[params.ruleId]} />
</>
);
}
}
function InstanceRuleForm({ rule }) {
const baseUrl = useBaseUrl();
const form = {
id: useValue("id", rule.id),
rule: useTextInput("text", { defaultValue: rule.text })
};
const [submitForm, result] = useFormSubmit(form, query.useUpdateInstanceRuleMutation());
const [deleteRule, deleteResult] = query.useDeleteInstanceRuleMutation({ fixedCacheKey: rule.id });
if (result.isSuccess || deleteResult.isSuccess) {
return (
<Redirect to={baseUrl} />
);
}
return (
<div className="rule-detail">
<form onSubmit={submitForm}>
<TextArea
field={form.rule}
/>
<div className="action-buttons row">
<MutationButton
label="Save"
showError={false}
result={result}
disabled={!form.rule.hasChanged()}
/>
<MutationButton
type="button"
onClick={() => deleteRule(rule.id)}
label="Delete"
className="button danger"
showError={false}
result={deleteResult}
/>
</div>
{result.error && <Error error={result.error} />}
{deleteResult.error && <Error error={deleteResult.error} />}
</form>
</div>
);
}

View File

@@ -60,7 +60,10 @@ const { Sidebar, ViewRouter } = createNavigation("/settings", [
Item("Local", { icon: "fa-home", wildcard: true }, require("./admin/emoji/local")),
Item("Remote", { icon: "fa-cloud" }, require("./admin/emoji/remote"))
]),
Item("Settings", { icon: "fa-sliders" }, require("./admin/settings"))
Menu("Settings", { icon: "fa-sliders" }, [
Item("Settings", { icon: "fa-sliders", url: "" }, require("./admin/settings")),
Item("Rules", { icon: "fa-dot-circle-o", wildcard: true }, require("./admin/settings/rules"))
])
])
]);

View File

@@ -22,7 +22,8 @@
const {
replaceCacheOnMutation,
removeFromCacheOnMutation,
domainListToObject
domainListToObject,
idListToObject
} = require("../lib");
const base = require("../base");
@@ -104,6 +105,51 @@ const endpoints = (build) => ({
return res.accounts ?? [];
}
}),
instanceRules: build.query({
query: () => ({
url: `/api/v1/admin/instance/rules`
}),
transformResponse: idListToObject
}),
addInstanceRule: build.mutation({
query: (formData) => ({
method: "POST",
url: `/api/v1/admin/instance/rules`,
asForm: true,
body: formData,
discardEmpty: true
}),
transformResponse: (data) => {
return {
[data.id]: data
};
},
...replaceCacheOnMutation("instanceRules")
}),
updateInstanceRule: build.mutation({
query: ({ id, ...edit }) => ({
method: "PATCH",
url: `/api/v1/admin/instance/rules/${id}`,
asForm: true,
body: edit,
discardEmpty: true
}),
transformResponse: (data) => {
return {
[data.id]: data
};
},
...replaceCacheOnMutation("instanceRules")
}),
deleteInstanceRule: build.mutation({
query: (id) => ({
method: "DELETE",
url: `/api/v1/admin/instance/rules/${id}`
}),
...removeFromCacheOnMutation("instanceRules", {
findKey: (_draft, rule) => rule.id
})
}),
...require("./import-export")(build),
...require("./custom-emoji")(build),
...require("./reports")(build)

View File

@@ -59,7 +59,7 @@ function instanceBasedQuery(args, api, extraOptions) {
module.exports = createApi({
reducerPath: "api",
baseQuery: instanceBasedQuery,
tagTypes: ["Auth", "Emoji", "Reports", "Account"],
tagTypes: ["Auth", "Emoji", "Reports", "Account", "InstanceRules"],
endpoints: (build) => ({
instance: build.query({
query: () => ({

View File

@@ -37,6 +37,13 @@ module.exports = {
(_) => Object.fromEntries(_)
]);
},
idListToObject: (data) => {
// Turn flat Array into Object keyed by entry id field
return syncpipe(data, [
(_) => _.map((entry) => [entry.id, entry]),
(_) => Object.fromEntries(_)
]);
},
replaceCacheOnMutation: makeCacheMutation((draft, newData) => {
Object.assign(draft, newData);
}),