[feature] Parse instance descriptors as markdown, show T&C on /about (#2481)

* [feature] Parse instance descriptors as markdown, show T&C on /about

* lint

* remove unnecessary nullzero tags
This commit is contained in:
tobi 2024-01-05 13:39:31 +01:00 committed by GitHub
parent 511ad97fe7
commit d5e3996a18
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 885 additions and 515 deletions

View file

@ -96,6 +96,68 @@ Through the 'remote' section, you can look up a link to any remote toots (provid
### Instance Settings ### Instance Settings
![Screenshot of the GoToSocial admin panel, showing the fields to change an instance's settings](../assets/admin-settings.png) ![Screenshot of the GoToSocial admin panel, showing the fields to change an instance's settings](../assets/admin-settings-instance.png)
Here you can set various metadata for your instance, like the displayed name/title, thumbnail image, description (HTML accepted), and contact username and email. Here you can set various metadata for your instance, like the displayed name/title, thumbnail image, (short) description, and contact info.
#### Instance Appearance
These settings primary affect how your instance appears to others and on the web.
Your **instance title** will appear at the top of every web page on your instance, and in OpenGraph meta tags, so pick something that represents the vibe of your instance.
The **instance avatar** is sort of like the mascot of your instance. It will appear next to the instance title at the top of every page, and as the preview image in browser tabs, OpenGraph links, and that sort of thing.
If you set an instance avatar, we highly recommend setting the **avatar image description** as well. This will provide alt text for the image you set as avatar, helping screenreader users to understand what's depicted in the image. Keep it short and sweet.
#### Instance Descriptors
You can use these fields to set short and full descriptions of your instance, as well as to provide terms and conditions for current and prospective users of your instance.
The **short description** will be shown on the instance home page, right near the top, and in response to `/api/v1/instance` queries.
It's a good idea to provide something pithy in here, to give visitors to your instance an immediate impression of what you're all about. For example:
> This is an instance for enthusiasts of classic synthesizers.
>
> Sick beats are for life, and not just for Christmas!
or:
> This is a single-user instance just for me!
>
> Here's my profile: @your_username
The **full description** will appear on your instance's /about page, and in response to `/api/v1/instance` queries.
You can use this to provide info like:
- your instance's history, ethos, attitude, and vibe
- the kinds of things your instance denizens tend to post about
- how to get an account on your instance (if it's possible at all)
- a list of users with accounts on the instance, who want to be found more easily
The **terms and conditions** box also appears on your instance's /about page, and in response to `/api/v1/instance` queries.
Use it for filling in stuff like:
- legal jargon (imprint, GDPR, or links thereto)
- federation policy
- data policy
- account deletion/suspension policy
All of the above fields accept **markdown** input, so you can write proper lists, codeblocks, horizontal rules, block quotes, or whatever you like.
You can also mention accounts using the standard `@user[@domain]` format.
Have a look at the [markdown cheat sheet](https://markdownguide.offshoot.io/cheat-sheet/) to see what else you can do.
### Instance Contact Info
In this section, you can provide visitors to your instance with a convenient way of reaching your instance admin.
Links to the set contact account and/or email address will appear on the footer of every web page of your instance, on the /about page in the "contact" section, and in response to `/api/v1/instance` queries.
The selected **contact user** must be an active (not suspended) admin and/or moderator on the instance.
If you're on a single-user instance and you give admin privileges to your main account, you can just fill in your own username here; you don't need to make a separate admin account just for this.

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

View file

@ -78,8 +78,10 @@ func (suite *InstancePatchTestSuite) TestInstancePatch1() {
"uri": "http://localhost:8080", "uri": "http://localhost:8080",
"account_domain": "localhost:8080", "account_domain": "localhost:8080",
"title": "Example Instance", "title": "Example Instance",
"description": "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>", "description": "<p>Here's a fuller description of the GoToSocial testrig instance.</p><p>This instance is for testing purposes only. It doesn't federate at all. Go check out <a href=\"https://github.com/superseriousbusiness/gotosocial/tree/main/testrig\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://github.com/superseriousbusiness/gotosocial/tree/main/testrig</a> and <a href=\"https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing</a></p><p>Users on this instance:</p><ul><li><span class=\"h-card\"><a href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>admin</span></a></span> (admin!).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span> (posts about turtles, we don't know why).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@the_mighty_zork\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>the_mighty_zork</span></a></span> (who knows).</li></ul><p>If you need to edit the models for the testrig, you can do so at <code>internal/testmodels.go</code>.</p>",
"description_text": "Here's a fuller description of the GoToSocial testrig instance.\n\nThis instance is for testing purposes only. It doesn't federate at all. Go check out https://github.com/superseriousbusiness/gotosocial/tree/main/testrig and https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\n\nUsers on this instance:\n\n- @admin (admin!).\n- @1happyturtle (posts about turtles, we don't know why).\n- @the_mighty_zork (who knows).\n\nIf you need to edit the models for the testrig, you can do so at `+"`"+`internal/testmodels.go`+"`"+`.",
"short_description": "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>", "short_description": "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>",
"short_description_text": "This is the GoToSocial testrig. It doesn't federate or anything.\n\nWhen the testrig is shut down, all data on it will be deleted.\n\nDon't use this in production!",
"email": "someone@example.org", "email": "someone@example.org",
"version": "0.0.0-testrig", "version": "0.0.0-testrig",
"languages": [ "languages": [
@ -173,7 +175,9 @@ func (suite *InstancePatchTestSuite) TestInstancePatch1() {
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3", "id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime" "text": "Do crime"
} }
] ],
"terms": "<p>This is where a list of terms and conditions might go.</p><p>For example:</p><p>If you want to sign up on this instance, you oughta know that we:</p><ol><li>Will sell your data to whoever offers.</li><li>Secure the server with password <code>password</code> wherever possible.</li></ol>",
"terms_text": "This is where a list of terms and conditions might go.\n\nFor example:\n\nIf you want to sign up on this instance, you oughta know that we:\n\n1. Will sell your data to whoever offers.\n2. Secure the server with password `+"`"+`password`+"`"+` wherever possible."
}`, dst.String()) }`, dst.String())
} }
@ -195,8 +199,10 @@ func (suite *InstancePatchTestSuite) TestInstancePatch2() {
"uri": "http://localhost:8080", "uri": "http://localhost:8080",
"account_domain": "localhost:8080", "account_domain": "localhost:8080",
"title": "Geoff's Instance", "title": "Geoff's Instance",
"description": "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>", "description": "<p>Here's a fuller description of the GoToSocial testrig instance.</p><p>This instance is for testing purposes only. It doesn't federate at all. Go check out <a href=\"https://github.com/superseriousbusiness/gotosocial/tree/main/testrig\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://github.com/superseriousbusiness/gotosocial/tree/main/testrig</a> and <a href=\"https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing</a></p><p>Users on this instance:</p><ul><li><span class=\"h-card\"><a href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>admin</span></a></span> (admin!).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span> (posts about turtles, we don't know why).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@the_mighty_zork\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>the_mighty_zork</span></a></span> (who knows).</li></ul><p>If you need to edit the models for the testrig, you can do so at <code>internal/testmodels.go</code>.</p>",
"description_text": "Here's a fuller description of the GoToSocial testrig instance.\n\nThis instance is for testing purposes only. It doesn't federate at all. Go check out https://github.com/superseriousbusiness/gotosocial/tree/main/testrig and https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\n\nUsers on this instance:\n\n- @admin (admin!).\n- @1happyturtle (posts about turtles, we don't know why).\n- @the_mighty_zork (who knows).\n\nIf you need to edit the models for the testrig, you can do so at `+"`"+`internal/testmodels.go`+"`"+`.",
"short_description": "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>", "short_description": "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>",
"short_description_text": "This is the GoToSocial testrig. It doesn't federate or anything.\n\nWhen the testrig is shut down, all data on it will be deleted.\n\nDon't use this in production!",
"email": "admin@example.org", "email": "admin@example.org",
"version": "0.0.0-testrig", "version": "0.0.0-testrig",
"languages": [ "languages": [
@ -290,13 +296,15 @@ func (suite *InstancePatchTestSuite) TestInstancePatch2() {
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3", "id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime" "text": "Do crime"
} }
] ],
"terms": "<p>This is where a list of terms and conditions might go.</p><p>For example:</p><p>If you want to sign up on this instance, you oughta know that we:</p><ol><li>Will sell your data to whoever offers.</li><li>Secure the server with password <code>password</code> wherever possible.</li></ol>",
"terms_text": "This is where a list of terms and conditions might go.\n\nFor example:\n\nIf you want to sign up on this instance, you oughta know that we:\n\n1. Will sell your data to whoever offers.\n2. Secure the server with password `+"`"+`password`+"`"+` wherever possible."
}`, dst.String()) }`, dst.String())
} }
func (suite *InstancePatchTestSuite) TestInstancePatch3() { func (suite *InstancePatchTestSuite) TestInstancePatch3() {
code, b := suite.instancePatch("", "", map[string][]string{ code, b := suite.instancePatch("", "", map[string][]string{
"short_description": {"<p>This is some html, which is <em>allowed</em> in short descriptions.</p>"}, "short_description": {"This is some html, which is <em>allowed</em> in short descriptions."},
}) })
if expectedCode := http.StatusOK; code != expectedCode { if expectedCode := http.StatusOK; code != expectedCode {
@ -312,8 +320,10 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() {
"uri": "http://localhost:8080", "uri": "http://localhost:8080",
"account_domain": "localhost:8080", "account_domain": "localhost:8080",
"title": "GoToSocial Testrig Instance", "title": "GoToSocial Testrig Instance",
"description": "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>", "description": "<p>Here's a fuller description of the GoToSocial testrig instance.</p><p>This instance is for testing purposes only. It doesn't federate at all. Go check out <a href=\"https://github.com/superseriousbusiness/gotosocial/tree/main/testrig\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://github.com/superseriousbusiness/gotosocial/tree/main/testrig</a> and <a href=\"https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing</a></p><p>Users on this instance:</p><ul><li><span class=\"h-card\"><a href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>admin</span></a></span> (admin!).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span> (posts about turtles, we don't know why).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@the_mighty_zork\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>the_mighty_zork</span></a></span> (who knows).</li></ul><p>If you need to edit the models for the testrig, you can do so at <code>internal/testmodels.go</code>.</p>",
"description_text": "Here's a fuller description of the GoToSocial testrig instance.\n\nThis instance is for testing purposes only. It doesn't federate at all. Go check out https://github.com/superseriousbusiness/gotosocial/tree/main/testrig and https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\n\nUsers on this instance:\n\n- @admin (admin!).\n- @1happyturtle (posts about turtles, we don't know why).\n- @the_mighty_zork (who knows).\n\nIf you need to edit the models for the testrig, you can do so at `+"`"+`internal/testmodels.go`+"`"+`.",
"short_description": "<p>This is some html, which is <em>allowed</em> in short descriptions.</p>", "short_description": "<p>This is some html, which is <em>allowed</em> in short descriptions.</p>",
"short_description_text": "This is some html, which is <em>allowed</em> in short descriptions.",
"email": "admin@example.org", "email": "admin@example.org",
"version": "0.0.0-testrig", "version": "0.0.0-testrig",
"languages": [ "languages": [
@ -407,7 +417,9 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() {
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3", "id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime" "text": "Do crime"
} }
] ],
"terms": "<p>This is where a list of terms and conditions might go.</p><p>For example:</p><p>If you want to sign up on this instance, you oughta know that we:</p><ol><li>Will sell your data to whoever offers.</li><li>Secure the server with password <code>password</code> wherever possible.</li></ol>",
"terms_text": "This is where a list of terms and conditions might go.\n\nFor example:\n\nIf you want to sign up on this instance, you oughta know that we:\n\n1. Will sell your data to whoever offers.\n2. Secure the server with password `+"`"+`password`+"`"+` wherever possible."
}`, dst.String()) }`, dst.String())
} }
@ -480,8 +492,10 @@ func (suite *InstancePatchTestSuite) TestInstancePatch6() {
"uri": "http://localhost:8080", "uri": "http://localhost:8080",
"account_domain": "localhost:8080", "account_domain": "localhost:8080",
"title": "GoToSocial Testrig Instance", "title": "GoToSocial Testrig Instance",
"description": "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>", "description": "<p>Here's a fuller description of the GoToSocial testrig instance.</p><p>This instance is for testing purposes only. It doesn't federate at all. Go check out <a href=\"https://github.com/superseriousbusiness/gotosocial/tree/main/testrig\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://github.com/superseriousbusiness/gotosocial/tree/main/testrig</a> and <a href=\"https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing</a></p><p>Users on this instance:</p><ul><li><span class=\"h-card\"><a href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>admin</span></a></span> (admin!).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span> (posts about turtles, we don't know why).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@the_mighty_zork\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>the_mighty_zork</span></a></span> (who knows).</li></ul><p>If you need to edit the models for the testrig, you can do so at <code>internal/testmodels.go</code>.</p>",
"description_text": "Here's a fuller description of the GoToSocial testrig instance.\n\nThis instance is for testing purposes only. It doesn't federate at all. Go check out https://github.com/superseriousbusiness/gotosocial/tree/main/testrig and https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\n\nUsers on this instance:\n\n- @admin (admin!).\n- @1happyturtle (posts about turtles, we don't know why).\n- @the_mighty_zork (who knows).\n\nIf you need to edit the models for the testrig, you can do so at `+"`"+`internal/testmodels.go`+"`"+`.",
"short_description": "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>", "short_description": "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>",
"short_description_text": "This is the GoToSocial testrig. It doesn't federate or anything.\n\nWhen the testrig is shut down, all data on it will be deleted.\n\nDon't use this in production!",
"email": "", "email": "",
"version": "0.0.0-testrig", "version": "0.0.0-testrig",
"languages": [ "languages": [
@ -575,7 +589,9 @@ func (suite *InstancePatchTestSuite) TestInstancePatch6() {
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3", "id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime" "text": "Do crime"
} }
] ],
"terms": "<p>This is where a list of terms and conditions might go.</p><p>For example:</p><p>If you want to sign up on this instance, you oughta know that we:</p><ol><li>Will sell your data to whoever offers.</li><li>Secure the server with password <code>password</code> wherever possible.</li></ol>",
"terms_text": "This is where a list of terms and conditions might go.\n\nFor example:\n\nIf you want to sign up on this instance, you oughta know that we:\n\n1. Will sell your data to whoever offers.\n2. Secure the server with password `+"`"+`password`+"`"+` wherever possible."
}`, dst.String()) }`, dst.String())
} }
@ -619,8 +635,10 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() {
"uri": "http://localhost:8080", "uri": "http://localhost:8080",
"account_domain": "localhost:8080", "account_domain": "localhost:8080",
"title": "GoToSocial Testrig Instance", "title": "GoToSocial Testrig Instance",
"description": "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>", "description": "<p>Here's a fuller description of the GoToSocial testrig instance.</p><p>This instance is for testing purposes only. It doesn't federate at all. Go check out <a href=\"https://github.com/superseriousbusiness/gotosocial/tree/main/testrig\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://github.com/superseriousbusiness/gotosocial/tree/main/testrig</a> and <a href=\"https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing</a></p><p>Users on this instance:</p><ul><li><span class=\"h-card\"><a href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>admin</span></a></span> (admin!).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span> (posts about turtles, we don't know why).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@the_mighty_zork\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>the_mighty_zork</span></a></span> (who knows).</li></ul><p>If you need to edit the models for the testrig, you can do so at <code>internal/testmodels.go</code>.</p>",
"description_text": "Here's a fuller description of the GoToSocial testrig instance.\n\nThis instance is for testing purposes only. It doesn't federate at all. Go check out https://github.com/superseriousbusiness/gotosocial/tree/main/testrig and https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\n\nUsers on this instance:\n\n- @admin (admin!).\n- @1happyturtle (posts about turtles, we don't know why).\n- @the_mighty_zork (who knows).\n\nIf you need to edit the models for the testrig, you can do so at `+"`"+`internal/testmodels.go`+"`"+`.",
"short_description": "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>", "short_description": "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>",
"short_description_text": "This is the GoToSocial testrig. It doesn't federate or anything.\n\nWhen the testrig is shut down, all data on it will be deleted.\n\nDon't use this in production!",
"email": "admin@example.org", "email": "admin@example.org",
"version": "0.0.0-testrig", "version": "0.0.0-testrig",
"languages": [ "languages": [
@ -716,7 +734,9 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() {
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3", "id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime" "text": "Do crime"
} }
] ],
"terms": "<p>This is where a list of terms and conditions might go.</p><p>For example:</p><p>If you want to sign up on this instance, you oughta know that we:</p><ol><li>Will sell your data to whoever offers.</li><li>Secure the server with password <code>password</code> wherever possible.</li></ol>",
"terms_text": "This is where a list of terms and conditions might go.\n\nFor example:\n\nIf you want to sign up on this instance, you oughta know that we:\n\n1. Will sell your data to whoever offers.\n2. Secure the server with password `+"`"+`password`+"`"+` wherever possible."
}`, dst.String()) }`, dst.String())
// extra bonus: check the v2 model thumbnail after the patch // extra bonus: check the v2 model thumbnail after the patch
@ -773,8 +793,10 @@ func (suite *InstancePatchTestSuite) TestInstancePatch9() {
"uri": "http://localhost:8080", "uri": "http://localhost:8080",
"account_domain": "localhost:8080", "account_domain": "localhost:8080",
"title": "GoToSocial Testrig Instance", "title": "GoToSocial Testrig Instance",
"description": "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>", "description": "<p>Here's a fuller description of the GoToSocial testrig instance.</p><p>This instance is for testing purposes only. It doesn't federate at all. Go check out <a href=\"https://github.com/superseriousbusiness/gotosocial/tree/main/testrig\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://github.com/superseriousbusiness/gotosocial/tree/main/testrig</a> and <a href=\"https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing</a></p><p>Users on this instance:</p><ul><li><span class=\"h-card\"><a href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>admin</span></a></span> (admin!).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span> (posts about turtles, we don't know why).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@the_mighty_zork\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>the_mighty_zork</span></a></span> (who knows).</li></ul><p>If you need to edit the models for the testrig, you can do so at <code>internal/testmodels.go</code>.</p>",
"description_text": "Here's a fuller description of the GoToSocial testrig instance.\n\nThis instance is for testing purposes only. It doesn't federate at all. Go check out https://github.com/superseriousbusiness/gotosocial/tree/main/testrig and https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\n\nUsers on this instance:\n\n- @admin (admin!).\n- @1happyturtle (posts about turtles, we don't know why).\n- @the_mighty_zork (who knows).\n\nIf you need to edit the models for the testrig, you can do so at `+"`"+`internal/testmodels.go`+"`"+`.",
"short_description": "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>", "short_description": "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>",
"short_description_text": "This is the GoToSocial testrig. It doesn't federate or anything.\n\nWhen the testrig is shut down, all data on it will be deleted.\n\nDon't use this in production!",
"email": "admin@example.org", "email": "admin@example.org",
"version": "0.0.0-testrig", "version": "0.0.0-testrig",
"languages": [ "languages": [
@ -868,7 +890,9 @@ func (suite *InstancePatchTestSuite) TestInstancePatch9() {
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3", "id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime" "text": "Do crime"
} }
] ],
"terms": "<p>This is where a list of terms and conditions might go.</p><p>For example:</p><p>If you want to sign up on this instance, you oughta know that we:</p><ol><li>Will sell your data to whoever offers.</li><li>Secure the server with password <code>password</code> wherever possible.</li></ol>",
"terms_text": "This is where a list of terms and conditions might go.\n\nFor example:\n\nIf you want to sign up on this instance, you oughta know that we:\n\n1. Will sell your data to whoever offers.\n2. Secure the server with password `+"`"+`password`+"`"+` wherever possible."
}`, dst.String()) }`, dst.String())
} }

View file

@ -38,12 +38,16 @@ type InstanceV1 struct {
// //
// This should be displayed on the 'about' page for an instance. // This should be displayed on the 'about' page for an instance.
Description string `json:"description"` Description string `json:"description"`
// Raw (unparsed) version of description.
DescriptionText string `json:"description_text,omitempty"`
// A shorter description of the instance. // A shorter description of the instance.
// //
// Should be HTML formatted, but might be plaintext. // Should be HTML formatted, but might be plaintext.
// //
// This should be displayed on the instance splash/landing page. // This should be displayed on the instance splash/landing page.
ShortDescription string `json:"short_description"` ShortDescription string `json:"short_description"`
// Raw (unparsed) version of short description.
ShortDescriptionText string `json:"short_description_text,omitempty"`
// An email address that may be used for inquiries. // An email address that may be used for inquiries.
// example: admin@example.org // example: admin@example.org
Email string `json:"email"` Email string `json:"email"`
@ -92,6 +96,8 @@ type InstanceV1 struct {
Rules []InstanceRule `json:"rules"` Rules []InstanceRule `json:"rules"`
// Terms and conditions for accounts on this instance. // Terms and conditions for accounts on this instance.
Terms string `json:"terms,omitempty"` Terms string `json:"terms,omitempty"`
// Raw (unparsed) version of terms.
TermsRaw string `json:"terms_text,omitempty"`
} }
// InstanceV1URLs models instance-relevant URLs for client application consumption. // InstanceV1URLs models instance-relevant URLs for client application consumption.

View file

@ -49,6 +49,8 @@ type InstanceV2 struct {
// //
// This should be displayed on the 'about' page for an instance. // This should be displayed on the 'about' page for an instance.
Description string `json:"description"` Description string `json:"description"`
// Raw (unparsed) version of description.
DescriptionText string `json:"description_text,omitempty"`
// Basic anonymous usage data for this instance. // Basic anonymous usage data for this instance.
Usage InstanceV2Usage `json:"usage"` Usage InstanceV2Usage `json:"usage"`
// An image used to represent this instance. // An image used to represent this instance.
@ -66,6 +68,8 @@ type InstanceV2 struct {
Rules []InstanceRule `json:"rules"` Rules []InstanceRule `json:"rules"`
// Terms and conditions for accounts on this instance. // Terms and conditions for accounts on this instance.
Terms string `json:"terms,omitempty"` Terms string `json:"terms,omitempty"`
// Raw (unparsed) version of terms.
TermsText string `json:"terms_text,omitempty"`
} }
// Usage data for this instance. // Usage data for this instance.

View file

@ -0,0 +1,64 @@
// 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/>.
package migrations
import (
"context"
"strings"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
columns := []string{
"short_description_text",
"description_text",
"terms_text",
}
for _, column := range columns {
_, err := tx.ExecContext(ctx,
"ALTER TABLE ? ADD COLUMN ? TEXT",
bun.Ident("instances"), bun.Ident(column),
)
if err != nil {
e := err.Error()
if !(strings.Contains(e, "already exists") ||
strings.Contains(e, "duplicate column name") ||
strings.Contains(e, "SQLSTATE 42701")) {
return err
}
}
}
return nil
})
}
down := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
return nil
})
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -31,8 +31,11 @@ type Instance struct {
DomainBlockID string `bun:"type:CHAR(26),nullzero"` // ID of any existing domain block for this instance in the database DomainBlockID string `bun:"type:CHAR(26),nullzero"` // ID of any existing domain block for this instance in the database
DomainBlock *DomainBlock `bun:"rel:belongs-to"` // Domain block corresponding to domainBlockID DomainBlock *DomainBlock `bun:"rel:belongs-to"` // Domain block corresponding to domainBlockID
ShortDescription string `bun:""` // Short description of this instance ShortDescription string `bun:""` // Short description of this instance
Description string `bun:""` // Longer description of this instance ShortDescriptionText string `bun:""` // Raw text version of short description (before parsing).
Terms string `bun:""` // Terms and conditions of this instance Description string `bun:""` // Longer description of this instance.
DescriptionText string `bun:""` // Raw text version of long description (before parsing).
Terms string `bun:""` // Terms and conditions of this instance.
TermsText string `bun:""` // Raw text version of terms (before parsing).
ContactEmail string `bun:""` // Contact email address for this instance ContactEmail string `bun:""` // Contact email address for this instance
ContactAccountUsername string `bun:",nullzero"` // Username of the contact account for this instance ContactAccountUsername string `bun:",nullzero"` // Username of the contact account for this instance
ContactAccountID string `bun:"type:CHAR(26),nullzero"` // Contact account ID in the database for this instance ContactAccountID string `bun:"type:CHAR(26),nullzero"` // Contact account ID in the database for this instance

View file

@ -33,15 +33,6 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/validate" "github.com/superseriousbusiness/gotosocial/internal/validate"
) )
func (p *Processor) getThisInstance(ctx context.Context) (*gtsmodel.Instance, error) {
instance, err := p.state.DB.GetInstance(ctx, config.GetHost())
if err != nil {
return nil, err
}
return instance, nil
}
func (p *Processor) InstanceGetV1(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) { func (p *Processor) InstanceGetV1(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) {
i, err := p.getThisInstance(ctx) i, err := p.getThisInstance(ctx)
if err != nil { if err != nil {
@ -146,67 +137,55 @@ func (p *Processor) InstanceGetRules(ctx context.Context) ([]apimodel.InstanceRu
} }
func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSettingsUpdateRequest) (*apimodel.InstanceV1, gtserror.WithCode) { func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSettingsUpdateRequest) (*apimodel.InstanceV1, gtserror.WithCode) {
// fetch the instance entry from the db for processing // Fetch this instance from the db for processing.
host := config.GetHost() instance, err := p.getThisInstance(ctx)
instance, err := p.state.DB.GetInstance(ctx, host)
if err != nil { if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error fetching instance %s: %s", host, err)) err = fmt.Errorf("db error fetching instance: %w", err)
return nil, gtserror.NewErrorInternalError(err)
} }
// fetch the instance account from the db for processing // Fetch this instance account from the db for processing.
ia, err := p.state.DB.GetInstanceAccount(ctx, "") instanceAcc, err := p.state.DB.GetInstanceAccount(ctx, "")
if err != nil { if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error fetching instance account %s: %s", host, err)) err = fmt.Errorf("db error fetching instance account: %w", err)
return nil, gtserror.NewErrorInternalError(err)
} }
updatingColumns := []string{} // Columns to update
// in the database.
var columns []string
// validate & update site title if it's set on the form // Validate & update site
// title if set on the form.
if form.Title != nil { if form.Title != nil {
if err := validate.SiteTitle(*form.Title); err != nil { title := *form.Title
return nil, gtserror.NewErrorBadRequest(err, fmt.Sprintf("site title invalid: %s", err)) if err := validate.SiteTitle(title); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error())
} }
updatingColumns = append(updatingColumns, "title")
instance.Title = text.SanitizeToPlaintext(*form.Title) // don't allow html in site title // Don't allow html in site title.
instance.Title = text.SanitizeToPlaintext(title)
columns = append(columns, "title")
} }
// validate & update site contact account if it's set on the form // Validate & update site contact
// account if set on the form.
//
// Empty username unsets contact.
if form.ContactUsername != nil { if form.ContactUsername != nil {
// make sure the account with the given username exists in the db contactAccountID, err := p.contactAccountIDForUsername(ctx, *form.ContactUsername)
contactAccount, err := p.state.DB.GetAccountByUsernameDomain(ctx, *form.ContactUsername, "")
if err != nil { if err != nil {
return nil, gtserror.NewErrorBadRequest(err, fmt.Sprintf("account with username %s not retrievable", *form.ContactUsername))
}
// make sure it has a user associated with it
contactUser, err := p.state.DB.GetUserByAccountID(ctx, contactAccount.ID)
if err != nil {
return nil, gtserror.NewErrorBadRequest(err, fmt.Sprintf("user for account with username %s not retrievable", *form.ContactUsername))
}
// suspended accounts cannot be contact accounts
if !contactAccount.SuspendedAt.IsZero() {
err := fmt.Errorf("selected contact account %s is suspended", contactAccount.Username)
return nil, gtserror.NewErrorBadRequest(err, err.Error()) return nil, gtserror.NewErrorBadRequest(err, err.Error())
} }
// unconfirmed or unapproved users cannot be contacts
if contactUser.ConfirmedAt.IsZero() { columns = append(columns, "contact_account_id")
err := fmt.Errorf("user of selected contact account %s is not confirmed", contactAccount.Username) instance.ContactAccountID = contactAccountID
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
if !*contactUser.Approved {
err := fmt.Errorf("user of selected contact account %s is not approved", contactAccount.Username)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
// contact account user must be admin or moderator otherwise what's the point of contacting them
if !*contactUser.Admin && !*contactUser.Moderator {
err := fmt.Errorf("user of selected contact account %s is neither admin nor moderator", contactAccount.Username)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
updatingColumns = append(updatingColumns, "contact_account_id")
instance.ContactAccountID = contactAccount.ID
} }
// validate & update site contact email if it's set on the form // Validate & update contact
// email if set on the form.
//
// Empty email unsets contact.
if form.ContactEmail != nil { if form.ContactEmail != nil {
contactEmail := *form.ContactEmail contactEmail := *form.ContactEmail
if contactEmail != "" { if contactEmail != "" {
@ -214,87 +193,162 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe
return nil, gtserror.NewErrorBadRequest(err, err.Error()) return nil, gtserror.NewErrorBadRequest(err, err.Error())
} }
} }
updatingColumns = append(updatingColumns, "contact_email")
columns = append(columns, "contact_email")
instance.ContactEmail = contactEmail instance.ContactEmail = contactEmail
} }
// validate & update site short description if it's set on the form // Validate & update site short
// description if set on the form.
if form.ShortDescription != nil { if form.ShortDescription != nil {
if err := validate.SiteShortDescription(*form.ShortDescription); err != nil { shortDescription := *form.ShortDescription
if err := validate.SiteShortDescription(shortDescription); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error()) return nil, gtserror.NewErrorBadRequest(err, err.Error())
} }
updatingColumns = append(updatingColumns, "short_description")
instance.ShortDescription = text.SanitizeToHTML(*form.ShortDescription) // html is OK in site description, but we should sanitize it // Parse description as Markdown, keep
// the raw version for later editing.
instance.ShortDescriptionText = shortDescription
instance.ShortDescription = p.formatter.FromMarkdown(ctx, p.parseMentionFunc, instanceAcc.ID, "", shortDescription).HTML
columns = append(columns, []string{"short_description", "short_description_text"}...)
} }
// validate & update site description if it's set on the form // validate & update site description if it's set on the form
if form.Description != nil { if form.Description != nil {
if err := validate.SiteDescription(*form.Description); err != nil { description := *form.Description
if err := validate.SiteDescription(description); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error()) return nil, gtserror.NewErrorBadRequest(err, err.Error())
} }
updatingColumns = append(updatingColumns, "description")
instance.Description = text.SanitizeToHTML(*form.Description) // html is OK in site description, but we should sanitize it // Parse description as Markdown, keep
// the raw version for later editing.
instance.DescriptionText = description
instance.Description = p.formatter.FromMarkdown(ctx, p.parseMentionFunc, instanceAcc.ID, "", description).HTML
columns = append(columns, []string{"description", "description_text"}...)
} }
// validate & update site terms if it's set on the form // Validate & update site
// terms if set on the form.
if form.Terms != nil { if form.Terms != nil {
if err := validate.SiteTerms(*form.Terms); err != nil { terms := *form.Terms
if err := validate.SiteTerms(terms); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error()) return nil, gtserror.NewErrorBadRequest(err, err.Error())
} }
updatingColumns = append(updatingColumns, "terms")
instance.Terms = text.SanitizeToHTML(*form.Terms) // html is OK in site terms, but we should sanitize it // Parse terms as Markdown, keep
// the raw version for later editing.
instance.TermsText = terms
instance.Terms = p.formatter.FromMarkdown(ctx, p.parseMentionFunc, "", "", terms).HTML
columns = append(columns, []string{"terms", "terms_text"}...)
} }
var updateInstanceAccount bool var updateInstanceAccount bool
if form.Avatar != nil && form.Avatar.Size != 0 { if form.Avatar != nil && form.Avatar.Size != 0 {
// process instance avatar image + description // Process instance avatar image + description.
avatarInfo, err := p.account.UpdateAvatar(ctx, form.Avatar, form.AvatarDescription, ia.ID) avatarInfo, err := p.account.UpdateAvatar(ctx, form.Avatar, form.AvatarDescription, instanceAcc.ID)
if err != nil { if err != nil {
return nil, gtserror.NewErrorBadRequest(err, "error processing avatar") return nil, gtserror.NewErrorBadRequest(err, "error processing avatar")
} }
ia.AvatarMediaAttachmentID = avatarInfo.ID instanceAcc.AvatarMediaAttachmentID = avatarInfo.ID
ia.AvatarMediaAttachment = avatarInfo instanceAcc.AvatarMediaAttachment = avatarInfo
updateInstanceAccount = true updateInstanceAccount = true
} else if form.AvatarDescription != nil && ia.AvatarMediaAttachment != nil { } else if form.AvatarDescription != nil && instanceAcc.AvatarMediaAttachment != nil {
// process just the description for the existing avatar // Process just the description for the existing avatar.
ia.AvatarMediaAttachment.Description = *form.AvatarDescription instanceAcc.AvatarMediaAttachment.Description = *form.AvatarDescription
if err := p.state.DB.UpdateAttachment(ctx, ia.AvatarMediaAttachment, "description"); err != nil { if err := p.state.DB.UpdateAttachment(ctx, instanceAcc.AvatarMediaAttachment, "description"); err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error updating instance avatar description: %s", err)) err = fmt.Errorf("db error updating instance avatar description: %w", err)
return nil, gtserror.NewErrorInternalError(err)
} }
} }
if form.Header != nil && form.Header.Size != 0 { if form.Header != nil && form.Header.Size != 0 {
// process instance header image // process instance header image
headerInfo, err := p.account.UpdateHeader(ctx, form.Header, nil, ia.ID) headerInfo, err := p.account.UpdateHeader(ctx, form.Header, nil, instanceAcc.ID)
if err != nil { if err != nil {
return nil, gtserror.NewErrorBadRequest(err, "error processing header") return nil, gtserror.NewErrorBadRequest(err, "error processing header")
} }
ia.HeaderMediaAttachmentID = headerInfo.ID instanceAcc.HeaderMediaAttachmentID = headerInfo.ID
ia.HeaderMediaAttachment = headerInfo instanceAcc.HeaderMediaAttachment = headerInfo
updateInstanceAccount = true updateInstanceAccount = true
} }
if updateInstanceAccount { if updateInstanceAccount {
// if either avatar or header is updated, we need // If either avatar or header is updated, we need
// to update the instance account that stores them // to update the instance account that stores them.
if err := p.state.DB.UpdateAccount(ctx, ia); err != nil { if err := p.state.DB.UpdateAccount(ctx, instanceAcc); err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error updating instance account: %s", err)) err = fmt.Errorf("db error updating instance account: %w", err)
return nil, gtserror.NewErrorInternalError(err, err.Error())
} }
} }
if len(updatingColumns) != 0 { if len(columns) != 0 {
if err := p.state.DB.UpdateInstance(ctx, instance, updatingColumns...); err != nil { if err := p.state.DB.UpdateInstance(ctx, instance, columns...); err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error updating instance %s: %s", host, err)) err = fmt.Errorf("db error updating instance: %w", err)
return nil, gtserror.NewErrorInternalError(err, err.Error())
} }
} }
ai, err := p.converter.InstanceToAPIV1Instance(ctx, instance) return p.InstanceGetV1(ctx)
}
func (p *Processor) getThisInstance(ctx context.Context) (*gtsmodel.Instance, error) {
instance, err := p.state.DB.GetInstance(ctx, config.GetHost())
if err != nil { if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting instance to api representation: %s", err)) return nil, err
} }
return ai, nil return instance, nil
}
func (p *Processor) contactAccountIDForUsername(ctx context.Context, username string) (string, error) {
if username == "" {
// Easy: unset
// contact account.
return "", nil
}
// Make sure local account with the given username exists in the db.
contactAccount, err := p.state.DB.GetAccountByUsernameDomain(ctx, username, "")
if err != nil {
err = fmt.Errorf("db error getting selected contact account with username %s: %w", username, err)
return "", err
}
// Make sure account corresponds to a user.
contactUser, err := p.state.DB.GetUserByAccountID(ctx, contactAccount.ID)
if err != nil {
err = fmt.Errorf("db error getting user for selected contact account %s: %w", username, err)
return "", err
}
// Ensure account/user is:
//
// - confirmed and approved
// - not suspended
// - an admin or a moderator
if contactUser.ConfirmedAt.IsZero() {
err := fmt.Errorf("user of selected contact account %s is not confirmed", contactAccount.Username)
return "", err
}
if !*contactUser.Approved {
err := fmt.Errorf("user of selected contact account %s is not approved", contactAccount.Username)
return "", err
}
if !contactAccount.SuspendedAt.IsZero() {
err := fmt.Errorf("selected contact account %s is suspended", contactAccount.Username)
return "", err
}
if !*contactUser.Admin && !*contactUser.Moderator {
err := fmt.Errorf("user of selected contact account %s is neither admin nor moderator", contactAccount.Username)
return "", err
}
// All good!
return contactAccount.ID, nil
} }
func obfuscate(domain string) string { func obfuscate(domain string) string {

View file

@ -21,6 +21,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/cleaner" "github.com/superseriousbusiness/gotosocial/internal/cleaner"
"github.com/superseriousbusiness/gotosocial/internal/email" "github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
mm "github.com/superseriousbusiness/gotosocial/internal/media" mm "github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/processing/account" "github.com/superseriousbusiness/gotosocial/internal/processing/account"
@ -39,6 +40,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/processing/user" "github.com/superseriousbusiness/gotosocial/internal/processing/user"
"github.com/superseriousbusiness/gotosocial/internal/processing/workers" "github.com/superseriousbusiness/gotosocial/internal/processing/workers"
"github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/visibility" "github.com/superseriousbusiness/gotosocial/internal/visibility"
) )
@ -55,6 +57,13 @@ type Processor struct {
oauthServer oauth.Server oauthServer oauth.Server
state *state.State state *state.State
/*
Required for instance description / terms updating.
*/
formatter *text.Formatter
parseMentionFunc gtsmodel.ParseMentionFunc
/* /*
SUB-PROCESSORS SUB-PROCESSORS
*/ */
@ -147,9 +156,11 @@ func NewProcessor(
) )
processor := &Processor{ processor := &Processor{
converter: converter, converter: converter,
oauthServer: oauthServer, oauthServer: oauthServer,
state: state, state: state,
formatter: text.NewFormatter(state.DB),
parseMentionFunc: parseMentionFunc,
} }
// Instantiate sub processors. // Instantiate sub processors.

View file

@ -941,20 +941,23 @@ func (c *Converter) InstanceRuleToAdminAPIRule(r *gtsmodel.Rule) *apimodel.Admin
// InstanceToAPIV1Instance converts a gts instance into its api equivalent for serving at /api/v1/instance // InstanceToAPIV1Instance converts a gts instance into its api equivalent for serving at /api/v1/instance
func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV1, error) { func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV1, error) {
instance := &apimodel.InstanceV1{ instance := &apimodel.InstanceV1{
URI: i.URI, URI: i.URI,
AccountDomain: config.GetAccountDomain(), AccountDomain: config.GetAccountDomain(),
Title: i.Title, Title: i.Title,
Description: i.Description, Description: i.Description,
ShortDescription: i.ShortDescription, DescriptionText: i.DescriptionText,
Email: i.ContactEmail, ShortDescription: i.ShortDescription,
Version: config.GetSoftwareVersion(), ShortDescriptionText: i.ShortDescriptionText,
Languages: config.GetInstanceLanguages().TagStrs(), Email: i.ContactEmail,
Registrations: config.GetAccountsRegistrationOpen(), Version: config.GetSoftwareVersion(),
ApprovalRequired: config.GetAccountsApprovalRequired(), Languages: config.GetInstanceLanguages().TagStrs(),
InvitesEnabled: false, // todo: not supported yet Registrations: config.GetAccountsRegistrationOpen(),
MaxTootChars: uint(config.GetStatusesMaxChars()), ApprovalRequired: config.GetAccountsApprovalRequired(),
Rules: c.InstanceRulesToAPIRules(i.Rules), InvitesEnabled: false, // todo: not supported yet
Terms: i.Terms, MaxTootChars: uint(config.GetStatusesMaxChars()),
Rules: c.InstanceRulesToAPIRules(i.Rules),
Terms: i.Terms,
TermsRaw: i.TermsText,
} }
if config.GetInstanceInjectMastodonVersion() { if config.GetInstanceInjectMastodonVersion() {
@ -1050,16 +1053,18 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
// InstanceToAPIV2Instance converts a gts instance into its api equivalent for serving at /api/v2/instance // InstanceToAPIV2Instance converts a gts instance into its api equivalent for serving at /api/v2/instance
func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV2, error) { func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV2, error) {
instance := &apimodel.InstanceV2{ instance := &apimodel.InstanceV2{
Domain: i.Domain, Domain: i.Domain,
AccountDomain: config.GetAccountDomain(), AccountDomain: config.GetAccountDomain(),
Title: i.Title, Title: i.Title,
Version: config.GetSoftwareVersion(), Version: config.GetSoftwareVersion(),
SourceURL: instanceSourceURL, SourceURL: instanceSourceURL,
Description: i.Description, Description: i.Description,
Usage: apimodel.InstanceV2Usage{}, // todo: not implemented DescriptionText: i.DescriptionText,
Languages: config.GetInstanceLanguages().TagStrs(), Usage: apimodel.InstanceV2Usage{}, // todo: not implemented
Rules: c.InstanceRulesToAPIRules(i.Rules), Languages: config.GetInstanceLanguages().TagStrs(),
Terms: i.Terms, Rules: c.InstanceRulesToAPIRules(i.Rules),
Terms: i.Terms,
TermsText: i.TermsText,
} }
if config.GetInstanceInjectMastodonVersion() { if config.GetInstanceInjectMastodonVersion() {

View file

@ -841,8 +841,10 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV1ToFrontend() {
"uri": "http://localhost:8080", "uri": "http://localhost:8080",
"account_domain": "localhost:8080", "account_domain": "localhost:8080",
"title": "GoToSocial Testrig Instance", "title": "GoToSocial Testrig Instance",
"description": "\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e", "description": "\u003cp\u003eHere's a fuller description of the GoToSocial testrig instance.\u003c/p\u003e\u003cp\u003eThis instance is for testing purposes only. It doesn't federate at all. Go check out \u003ca href=\"https://github.com/superseriousbusiness/gotosocial/tree/main/testrig\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003ehttps://github.com/superseriousbusiness/gotosocial/tree/main/testrig\u003c/a\u003e and \u003ca href=\"https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003ehttps://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\u003c/a\u003e\u003c/p\u003e\u003cp\u003eUsers on this instance:\u003c/p\u003e\u003cul\u003e\u003cli\u003e\u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003eadmin\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e (admin!).\u003c/li\u003e\u003cli\u003e\u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003e1happyturtle\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e (posts about turtles, we don't know why).\u003c/li\u003e\u003cli\u003e\u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@the_mighty_zork\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003ethe_mighty_zork\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e (who knows).\u003c/li\u003e\u003c/ul\u003e\u003cp\u003eIf you need to edit the models for the testrig, you can do so at \u003ccode\u003einternal/testmodels.go\u003c/code\u003e.\u003c/p\u003e",
"description_text": "Here's a fuller description of the GoToSocial testrig instance.\n\nThis instance is for testing purposes only. It doesn't federate at all. Go check out https://github.com/superseriousbusiness/gotosocial/tree/main/testrig and https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\n\nUsers on this instance:\n\n- @admin (admin!).\n- @1happyturtle (posts about turtles, we don't know why).\n- @the_mighty_zork (who knows).\n\nIf you need to edit the models for the testrig, you can do so at `+"`"+`internal/testmodels.go`+"`"+`.",
"short_description": "\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e", "short_description": "\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e",
"short_description_text": "This is the GoToSocial testrig. It doesn't federate or anything.\n\nWhen the testrig is shut down, all data on it will be deleted.\n\nDon't use this in production!",
"email": "admin@example.org", "email": "admin@example.org",
"version": "0.0.0-testrig", "version": "0.0.0-testrig",
"languages": [ "languages": [
@ -927,7 +929,9 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV1ToFrontend() {
} }
}, },
"max_toot_chars": 5000, "max_toot_chars": 5000,
"rules": [] "rules": [],
"terms": "\u003cp\u003eThis is where a list of terms and conditions might go.\u003c/p\u003e\u003cp\u003eFor example:\u003c/p\u003e\u003cp\u003eIf you want to sign up on this instance, you oughta know that we:\u003c/p\u003e\u003col\u003e\u003cli\u003eWill sell your data to whoever offers.\u003c/li\u003e\u003cli\u003eSecure the server with password \u003ccode\u003epassword\u003c/code\u003e wherever possible.\u003c/li\u003e\u003c/ol\u003e",
"terms_text": "This is where a list of terms and conditions might go.\n\nFor example:\n\nIf you want to sign up on this instance, you oughta know that we:\n\n1. Will sell your data to whoever offers.\n2. Secure the server with password `+"`"+`password`+"`"+` wherever possible."
}`, string(b)) }`, string(b))
} }
@ -953,7 +957,8 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV2ToFrontend() {
"title": "GoToSocial Testrig Instance", "title": "GoToSocial Testrig Instance",
"version": "0.0.0-testrig", "version": "0.0.0-testrig",
"source_url": "https://github.com/superseriousbusiness/gotosocial", "source_url": "https://github.com/superseriousbusiness/gotosocial",
"description": "\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e", "description": "\u003cp\u003eHere's a fuller description of the GoToSocial testrig instance.\u003c/p\u003e\u003cp\u003eThis instance is for testing purposes only. It doesn't federate at all. Go check out \u003ca href=\"https://github.com/superseriousbusiness/gotosocial/tree/main/testrig\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003ehttps://github.com/superseriousbusiness/gotosocial/tree/main/testrig\u003c/a\u003e and \u003ca href=\"https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003ehttps://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\u003c/a\u003e\u003c/p\u003e\u003cp\u003eUsers on this instance:\u003c/p\u003e\u003cul\u003e\u003cli\u003e\u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003eadmin\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e (admin!).\u003c/li\u003e\u003cli\u003e\u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003e1happyturtle\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e (posts about turtles, we don't know why).\u003c/li\u003e\u003cli\u003e\u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@the_mighty_zork\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003ethe_mighty_zork\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e (who knows).\u003c/li\u003e\u003c/ul\u003e\u003cp\u003eIf you need to edit the models for the testrig, you can do so at \u003ccode\u003einternal/testmodels.go\u003c/code\u003e.\u003c/p\u003e",
"description_text": "Here's a fuller description of the GoToSocial testrig instance.\n\nThis instance is for testing purposes only. It doesn't federate at all. Go check out https://github.com/superseriousbusiness/gotosocial/tree/main/testrig and https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\n\nUsers on this instance:\n\n- @admin (admin!).\n- @1happyturtle (posts about turtles, we don't know why).\n- @the_mighty_zork (who knows).\n\nIf you need to edit the models for the testrig, you can do so at `+"`"+`internal/testmodels.go`+"`"+`.",
"usage": { "usage": {
"users": { "users": {
"active_month": 0 "active_month": 0
@ -1045,7 +1050,9 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV2ToFrontend() {
} }
} }
}, },
"rules": [] "rules": [],
"terms": "\u003cp\u003eThis is where a list of terms and conditions might go.\u003c/p\u003e\u003cp\u003eFor example:\u003c/p\u003e\u003cp\u003eIf you want to sign up on this instance, you oughta know that we:\u003c/p\u003e\u003col\u003e\u003cli\u003eWill sell your data to whoever offers.\u003c/li\u003e\u003cli\u003eSecure the server with password \u003ccode\u003epassword\u003c/code\u003e wherever possible.\u003c/li\u003e\u003c/ol\u003e",
"terms_text": "This is where a list of terms and conditions might go.\n\nFor example:\n\nIf you want to sign up on this instance, you oughta know that we:\n\n1. Will sell your data to whoever offers.\n2. Secure the server with password `+"`"+`password`+"`"+` wherever possible."
}`, string(b)) }`, string(b))
} }

View file

@ -1312,7 +1312,11 @@ func NewTestInstances() map[string]*gtsmodel.Instance {
URI: "http://localhost:8080", URI: "http://localhost:8080",
Title: "GoToSocial Testrig Instance", Title: "GoToSocial Testrig Instance",
ShortDescription: "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>", ShortDescription: "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>",
Description: "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>", ShortDescriptionText: "This is the GoToSocial testrig. It doesn't federate or anything.\n\nWhen the testrig is shut down, all data on it will be deleted.\n\nDon't use this in production!",
Description: "<p>Here's a fuller description of the GoToSocial testrig instance.</p><p>This instance is for testing purposes only. It doesn't federate at all. Go check out <a href=\"https://github.com/superseriousbusiness/gotosocial/tree/main/testrig\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://github.com/superseriousbusiness/gotosocial/tree/main/testrig</a> and <a href=\"https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing</a></p><p>Users on this instance:</p><ul><li><span class=\"h-card\"><a href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>admin</span></a></span> (admin!).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span> (posts about turtles, we don't know why).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@the_mighty_zork\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>the_mighty_zork</span></a></span> (who knows).</li></ul><p>If you need to edit the models for the testrig, you can do so at <code>internal/testmodels.go</code>.</p>",
DescriptionText: "Here's a fuller description of the GoToSocial testrig instance.\n\nThis instance is for testing purposes only. It doesn't federate at all. Go check out https://github.com/superseriousbusiness/gotosocial/tree/main/testrig and https://github.com/superseriousbusiness/gotosocial/blob/main/CONTRIBUTING.md#testing\n\nUsers on this instance:\n\n- @admin (admin!).\n- @1happyturtle (posts about turtles, we don't know why).\n- @the_mighty_zork (who knows).\n\nIf you need to edit the models for the testrig, you can do so at `internal/testmodels.go`.",
Terms: "<p>This is where a list of terms and conditions might go.</p><p>For example:</p><p>If you want to sign up on this instance, you oughta know that we:</p><ol><li>Will sell your data to whoever offers.</li><li>Secure the server with password <code>password</code> wherever possible.</li></ol>",
TermsText: "This is where a list of terms and conditions might go.\n\nFor example:\n\nIf you want to sign up on this instance, you oughta know that we:\n\n1. Will sell your data to whoever offers.\n2. Secure the server with password `password` wherever possible.",
ContactEmail: "admin@example.org", ContactEmail: "admin@example.org",
ContactAccountUsername: "admin", ContactAccountUsername: "admin",
ContactAccountID: "01F8MH17FWEB39HZJ76B6VXSKF", ContactAccountID: "01F8MH17FWEB39HZJ76B6VXSKF",

View file

@ -28,11 +28,7 @@
border-radius: $br; border-radius: $br;
.about-section { .about-section {
ul, ol { h1, h2, h3, h4, h5 {
margin-top: 0;
}
h3, h4 {
margin-top: 0; margin-top: 0;
} }
} }

View file

@ -16,7 +16,12 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
/***************************************
***** SECTION 0: IMPORTS AND FONTS *****
****************************************/
@import "modern-normalize/modern-normalize.css"; @import "modern-normalize/modern-normalize.css";
@import "./prism.css";
/* noto-sans-regular - latin */ /* noto-sans-regular - latin */
@font-face { @font-face {
@ -261,6 +266,77 @@ label {
cursor: pointer; cursor: pointer;
} }
/*
Set our own nice background for
monospace code and pre blocks.
*/
pre, pre[class*="language-"],
code, code[class*="language-"] {
background-color: $gray2;
}
/*
Just code on its own inside status
content, ie, `here is some code`.
*/
code {
padding: 0.25rem;
border-radius: $br-inner;
white-space: pre-wrap;
}
/*
Restyle Prism code highlighting toolbar
plugin buttons to our own button style.
We have to use really specific selectors
because of how specific prism.css is.
*/
div.code-toolbar > div.toolbar {
margin-right: 0.5rem;
display: flex;
gap: 0.25rem;
> div.toolbar-item {
> span, > button {
color: $button-fg;
background: $button-bg;
font-weight: bold;
box-shadow: $boxshadow;
&:hover, &:focus {
color: $button-fg;
}
}
.copy-to-clipboard-button:hover {
background: $button-hover-bg;
}
}
}
pre, pre[class*="language-"] {
border-radius: $br;
padding: 0.5rem;
white-space: pre;
overflow-x: auto;
/*
Code inside a pre block, ie.,
```
here is some code
```
*/
code {
width: 100%;
padding: 0;
white-space: pre;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
/************************************* /*************************************
***** SECTION 3: UTILITY CLASSES ***** ***** SECTION 3: UTILITY CLASSES *****
**************************************/ **************************************/

View file

@ -19,7 +19,6 @@
@import "photoswipe/dist/photoswipe.css"; @import "photoswipe/dist/photoswipe.css";
@import "photoswipe-dynamic-caption-plugin/photoswipe-dynamic-caption-plugin.css"; @import "photoswipe-dynamic-caption-plugin/photoswipe-dynamic-caption-plugin.css";
@import "plyr/dist/plyr.css"; @import "plyr/dist/plyr.css";
@import "./prism.css";
main { main {
background: transparent; background: transparent;
@ -194,68 +193,6 @@ main {
line-height: initial; line-height: initial;
} }
pre, code {
background-color: $gray2;
}
/*
Just code on its own inside status
content, ie, `here is some code`.
*/
code {
padding: 0.25rem;
border-radius: $br-inner;
white-space: pre-wrap;
}
/*
Restyle Prism code highlighting toolbar
plugin buttons to our own button style.
*/
.code-toolbar .toolbar {
margin-right: 0.5rem;
display: flex;
gap: 0.25rem;
.toolbar-item {
span, button {
color: $button-fg;
background: $button-bg;
font-weight: bold;
}
.copy-to-clipboard-button, span {
box-shadow: $boxshadow;
}
.copy-to-clipboard-button:hover, .copy-to-clipboard-button:hover span {
background: $button-hover-bg;
}
}
}
pre, pre[class*="language-"] {
border-radius: $br;
padding: 0.5rem;
white-space: pre;
overflow-x: auto;
/*
Code inside a pre block, ie.,
```
here is some code
```
*/
code {
width: 100%;
padding: 0;
white-space: pre;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
img { img {
max-width: 100%; max-width: 100%;
margin: 5px auto; margin: 5px auto;

View file

@ -1,126 +0,0 @@
/*
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/>.
*/
const React = require("react");
const {
useTextInput,
useFileInput
} = require("../../lib/form");
const useFormSubmit = require("../../lib/form/submit").default;
const {
TextInput,
TextArea,
FileInput
} = require("../../components/form/inputs");
const FormWithData = require("../../lib/form/form-with-data").default;
const MutationButton = require("../../components/form/mutation-button");
const { useInstanceV1Query } = require("../../lib/query");
const { useUpdateInstanceMutation } = require("../../lib/query/admin");
module.exports = function AdminSettings() {
return (
<FormWithData
dataQuery={useInstanceV1Query}
DataForm={AdminSettingsForm}
/>
);
};
function AdminSettingsForm({ data: instance }) {
const form = {
title: useTextInput("title", {
source: instance,
validator: (val) => val.length <= 40 ? "" : "Instance title must be 40 characters or less"
}),
thumbnail: useFileInput("thumbnail", { withPreview: true }),
thumbnailDesc: useTextInput("thumbnail_description", { source: instance }),
shortDesc: useTextInput("short_description", { source: instance }),
description: useTextInput("description", { source: instance }),
contactUser: useTextInput("contact_username", { source: instance, valueSelector: (s) => s.contact_account?.username }),
contactEmail: useTextInput("contact_email", { source: instance, valueSelector: (s) => s.email }),
terms: useTextInput("terms", { source: instance })
};
const [submitForm, result] = useFormSubmit(form, useUpdateInstanceMutation());
return (
<form onSubmit={submitForm}>
<h1>Instance Settings</h1>
<TextInput
field={form.title}
label="Title"
placeholder="My GoToSocial instance"
/>
<div className="file-upload">
<h3>Instance thumbnail</h3>
<div>
<img className="preview avatar" src={form.thumbnail.previewValue ?? instance.thumbnail} alt={form.thumbnailDesc.value ?? (instance.thumbnail ? `Thumbnail image for the instance` : "No instance thumbnail image set")} />
<FileInput
field={form.thumbnail}
accept="image/*"
/>
</div>
</div>
<TextInput
field={form.thumbnailDesc}
label="Instance thumbnail description"
placeholder="A cute drawing of a smiling sloth."
/>
<TextArea
field={form.shortDesc}
label="Short description"
placeholder="A small testing instance for the GoToSocial alpha software."
/>
<TextArea
field={form.description}
label="Full description"
placeholder="A small testing instance for the GoToSocial alpha software. Just trying it out, my main instance is https://example.com"
/>
<TextInput
field={form.contactUser}
label="Contact user (local account username)"
placeholder="admin"
/>
<TextInput
field={form.contactEmail}
label="Contact email"
placeholder="admin@example.com"
/>
<TextArea
field={form.terms}
label="Terms & Conditions"
placeholder=""
/>
<MutationButton label="Save" result={result} />
</form>
);
}

View file

@ -0,0 +1,191 @@
/*
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/>.
*/
import React from "react";
import { useTextInput, useFileInput } from "../../lib/form";
const useFormSubmit = require("../../lib/form/submit").default;
import { TextInput, TextArea, FileInput } from "../../components/form/inputs";
const FormWithData = require("../../lib/form/form-with-data").default;
import MutationButton from "../../components/form/mutation-button";
import { useInstanceV1Query } from "../../lib/query";
import { useUpdateInstanceMutation } from "../../lib/query/admin";
import { InstanceV1 } from "../../lib/types/instance";
export default function AdminSettings() {
return (
<FormWithData
dataQuery={useInstanceV1Query}
DataForm={AdminSettingsForm}
/>
);
}
interface AdminSettingsFormProps{
data: InstanceV1;
}
function AdminSettingsForm({ data: instance }: AdminSettingsFormProps) {
const titleLimit = 40;
const shortDescLimit = 500;
const descLimit = 5000;
const termsLimit = 5000;
const form = {
title: useTextInput("title", {
source: instance,
validator: (val: string) => val.length <= titleLimit ? "" : `Instance title is ${val.length} characters; must be ${titleLimit} characters or less`
}),
thumbnail: useFileInput("thumbnail", { withPreview: true }),
thumbnailDesc: useTextInput("thumbnail_description", { source: instance }),
shortDesc: useTextInput("short_description", {
source: instance,
// Select "raw" text version of parsed field for editing.
valueSelector: (s: InstanceV1) => s.short_description_text,
validator: (val: string) => val.length <= shortDescLimit ? "" : `Instance short description is ${val.length} characters; must be ${shortDescLimit} characters or less`
}),
description: useTextInput("description", {
source: instance,
// Select "raw" text version of parsed field for editing.
valueSelector: (s: InstanceV1) => s.description_text,
validator: (val: string) => val.length <= descLimit ? "" : `Instance description is ${val.length} characters; must be ${descLimit} characters or less`
}),
terms: useTextInput("terms", {
source: instance,
// Select "raw" text version of parsed field for editing.
valueSelector: (s: InstanceV1) => s.terms_text,
validator: (val: string) => val.length <= termsLimit ? "" : `Instance terms and conditions is ${val.length} characters; must be ${termsLimit} characters or less`
}),
contactUser: useTextInput("contact_username", { source: instance, valueSelector: (s) => s.contact_account?.username }),
contactEmail: useTextInput("contact_email", { source: instance, valueSelector: (s) => s.email })
};
const [submitForm, result] = useFormSubmit(form, useUpdateInstanceMutation());
return (
<form onSubmit={submitForm}>
<h1>Instance Settings</h1>
<div className="form-section-docs">
<h3>Appearance</h3>
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#instance-appearance"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about these settings (opens in a new tab)
</a>
</div>
<TextInput
field={form.title}
label={`Instance title (max ${titleLimit} characters)`}
placeholder="My GoToSocial instance"
/>
<div className="file-upload" aria-labelledby="avatar">
<strong id="avatar">Instance avatar</strong>
<div>
<img
className="preview avatar"
src={form.thumbnail.previewValue ?? instance?.thumbnail}
alt={form.thumbnailDesc.value ?? (instance?.thumbnail ? `Thumbnail image for the instance` : "No instance thumbnail image set")}
/>
<div>
<FileInput
field={form.thumbnail}
accept="image/png, image/jpeg, image/webp, image/gif"
/>
<br/>
<TextInput
field={form.thumbnailDesc}
label="Avatar image description"
placeholder="A cute drawing of a smiling sloth."
/>
</div>
</div>
</div>
<div className="form-section-docs">
<h3>Descriptors</h3>
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#instance-descriptors"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about these settings (opens in a new tab)
</a>
</div>
<TextArea
field={form.shortDesc}
label={`Short description (markdown accepted, max ${shortDescLimit} characters)`}
placeholder="A small testing instance for the GoToSocial alpha software."
rows={6}
/>
<TextArea
field={form.description}
label={`Full description (markdown accepted, max ${descLimit} characters)`}
placeholder="A small testing instance for the GoToSocial alpha software. Just trying it out, my main instance is https://example.com"
rows={6}
/>
<TextArea
field={form.terms}
label={`Terms & Conditions (markdown accepted, max ${termsLimit} characters)`}
placeholder="Terms and conditions of using this instance, data policy, imprint, GDPR stuff, yadda yadda."
rows={6}
/>
<div className="form-section-docs">
<h3>Contact info</h3>
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#instance-contact-info"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about these settings (opens in a new tab)
</a>
</div>
<TextInput
field={form.contactUser}
label="Contact user (local account username)"
placeholder="admin"
/>
<TextInput
field={form.contactEmail}
label="Contact email"
placeholder="admin@example.com"
/>
<MutationButton label="Save" result={result} disabled={false} />
</form>
);
}

View file

@ -33,6 +33,8 @@ const { RoleContext } = require("./lib/navigation/util");
const DomainPerms = require("./admin/domain-permissions").default; const DomainPerms = require("./admin/domain-permissions").default;
const DomainPermsImportExport = require("./admin/domain-permissions/import-export").default; const DomainPermsImportExport = require("./admin/domain-permissions/import-export").default;
const InstanceSettings = require("./admin/settings").default;
require("./style.css"); require("./style.css");
const { Sidebar, ViewRouter } = createNavigation("/settings", [ const { Sidebar, ViewRouter } = createNavigation("/settings", [
@ -66,7 +68,7 @@ const { Sidebar, ViewRouter } = createNavigation("/settings", [
Item("Remote", { icon: "fa-cloud" }, require("./admin/emoji/remote")) Item("Remote", { icon: "fa-cloud" }, require("./admin/emoji/remote"))
]), ]),
Menu("Settings", { icon: "fa-sliders" }, [ Menu("Settings", { icon: "fa-sliders" }, [
Item("Settings", { icon: "fa-sliders", url: "" }, require("./admin/settings")), Item("Settings", { icon: "fa-sliders", url: "" }, InstanceSettings),
Item("Rules", { icon: "fa-dot-circle-o", wildcard: true }, require("./admin/settings/rules")) Item("Rules", { icon: "fa-dot-circle-o", wildcard: true }, require("./admin/settings/rules"))
]), ]),
]) ])

View file

@ -18,24 +18,28 @@
*/ */
export interface InstanceV1 { export interface InstanceV1 {
uri: string; uri: string;
account_domain: string; account_domain: string;
title: string; title: string;
description: string; description: string;
short_description: string; description_text?: string;
email: string; short_description: string;
version: string; short_description_text?: string;
languages: any[]; // TODO: define this email: string;
registrations: boolean; version: string;
approval_required: boolean; languages: any[]; // TODO: define this
invites_enabled: boolean; registrations: boolean;
configuration: InstanceConfiguration; approval_required: boolean;
urls: InstanceUrls; invites_enabled: boolean;
stats: InstanceStats; configuration: InstanceConfiguration;
thumbnail: string; urls: InstanceUrls;
contact_account: Object; // TODO: define this. stats: InstanceStats;
max_toot_chars: number; thumbnail: string;
rules: any[]; // TODO: define this contact_account: Object; // TODO: define this.
max_toot_chars: number;
rules: any[]; // TODO: define this
terms?: string;
terms_text?: string;
} }
export interface InstanceConfiguration { export interface InstanceConfiguration {

View file

@ -21,7 +21,15 @@
{{- if .instance.Description }} {{- if .instance.Description }}
{{ .instance.Description | noescape }} {{ .instance.Description | noescape }}
{{- else }} {{- else }}
<p>No description has yet been set for this instance.<p> <p>No description has yet been set for this instance.</p>
{{- end }}
{{- end -}}
{{- define "termsAndConditions" -}}
{{- if .instance.Terms }}
{{ .instance.Terms | noescape }}
{{- else }}
<p>No terms and conditions have yet been set for this instance.</p>
{{- end }} {{- end }}
{{- end -}} {{- end -}}
@ -60,90 +68,124 @@ Polls can have up to&nbsp;
{{- with . }} {{- with . }}
<main class="about"> <main class="about">
<nav class="about-section" aria-labelledby="toc">
<h3 id="toc">Table of Contents</h3>
<div class="about-section-contents">
<ol>
<li><a href="#about">About {{ .instance.Title -}}</a></li>
<li><a href="#contact">Contact</a></li>
<li><a href="#features">Features</a></li>
<li><a href="#languages">Languages</a></li>
<li><a href="#rules">Rules</a></li>
<li><a href="#terms">Terms and Conditions</a></li>
<li><a href="#moderated-servers">Moderated Servers</a></li>
</ol>
</div>
</nav>
<section class="about-section" role="region" aria-labelledby="about"> <section class="about-section" role="region" aria-labelledby="about">
<h3 id="about">About {{ .instance.Title -}}</h3> <h3 id="about">About {{ .instance.Title -}}</h3>
{{- with . }} <div class="about-section-contents">
{{- include "description" . | indent 2 }} {{- with . }}
{{- end }} {{- include "description" . | indent 3 }}
{{- end }}
</div>
</section> </section>
<section class="about-section" role="region" aria-labelledby="contact"> <section class="about-section" role="region" aria-labelledby="contact">
<h3 id="contact">Admin Contact</h3> <h3 id="contact">Admin Contact</h3>
{{- if .instance.ContactAccount }} <div class="about-section-contents">
<a href="{{- .instance.ContactAccount.URL -}}" class="account-card"> {{- if .instance.ContactAccount }}
<img class="avatar" src="{{- .instance.ContactAccount.Avatar -}}" alt=""/> <a href="{{- .instance.ContactAccount.URL -}}" class="account-card">
<h3> <img class="avatar" src="{{- .instance.ContactAccount.Avatar -}}" alt=""/>
{{- if .instance.ContactAccount.DisplayName -}} <h3>
{{- emojify .instance.ContactAccount.Emojis (escape .instance.ContactAccount.DisplayName) -}} {{- if .instance.ContactAccount.DisplayName -}}
{{- else -}} {{- emojify .instance.ContactAccount.Emojis (escape .instance.ContactAccount.DisplayName) -}}
{{- .instance.ContactAccount.Username -}} {{- else -}}
{{- end -}} {{- .instance.ContactAccount.Username -}}
</h3> {{- end -}}
<span>@{{- .instance.ContactAccount.Username -}}</span> </h3>
</a> <span>@{{- .instance.ContactAccount.Username -}}</span>
{{- else }} </a>
<p>This instance has not yet set a contact account.</p> {{- else }}
{{- end }} <p>This instance has not yet set a contact account.</p>
{{- if .instance.Email }}
<p>Email: <a href="mailto:{{- .instance.Email -}}">{{- .instance.Email -}}</a></p>
{{- else }}
<p>This instance has not yet set a contact email address.</p>
{{- end }}
</section>
<section class="about-section" role="region" aria-labelledby="languages">
<h3 id="languages">Languages</h3>
{{- if .languages }}
<p>This instance prefers the following languages:</p>
<ol>
{{- range .languages }}
<li>{{- . -}}</li>
{{- end }} {{- end }}
</ol> {{- if .instance.Email }}
{{- else }} <p>Email: <a href="mailto:{{- .instance.Email -}}">{{- .instance.Email -}}</a></p>
<p>This instance does not have any preferred languages.</p> {{- else }}
{{- end }} <p>This instance has not yet set a contact email address.</p>
</section>
<section class="about-section" role="region" aria-labelledby="rules">
<h3 id="rules">Instance Rules</h3>
<p>This instance has the following rules:</p>
{{- if .instance.Rules }}
<ol>
{{- range .instance.Rules }}
<li>{{- .Text -}}</li>
{{- end }} {{- end }}
</ol> </div>
{{- else }}
<p>This instance has not yet set any rules.</p>
{{- end }}
</section> </section>
<section class="about-section" role="region" aria-labelledby="features"> <section class="about-section" role="region" aria-labelledby="features">
<h3 id="features">Instance Features</h3> <h3 id="features">Instance Features</h3>
<ul> <div class="about-section-contents">
<li>{{- template "registrationLimits" . -}}</li> <ul>
<li>{{- template "customCSSLimits" . -}}</li> <li>{{- template "registrationLimits" . -}}</li>
<li>{{- template "statusLimits" . -}}</li> <li>{{- template "customCSSLimits" . -}}</li>
<li>{{- template "pollLimits" . -}}</li> <li>{{- template "statusLimits" . -}}</li>
</ul> <li>{{- template "pollLimits" . -}}</li>
</ul>
</div>
</section>
<section class="about-section" role="region" aria-labelledby="languages">
<h3 id="languages">Languages</h3>
<div class="about-section-contents">
{{- if .languages }}
<p>This instance prefers the following languages:</p>
<ol>
{{- range .languages }}
<li>{{- . -}}</li>
{{- end }}
</ol>
{{- else }}
<p>This instance does not have any preferred languages.</p>
{{- end }}
</div>
</section>
<section class="about-section" role="region" aria-labelledby="rules">
<h3 id="rules">Instance Rules</h3>
<div class="about-section-contents">
<p>This instance has the following rules:</p>
{{- if .instance.Rules }}
<ol>
{{- range .instance.Rules }}
<li>{{- .Text -}}</li>
{{- end }}
</ol>
{{- else }}
<p>This instance has not yet set any rules.</p>
{{- end }}
</div>
</section>
<section class="about-section" role="region" aria-labelledby="terms">
<h3 id="terms">Terms and Conditions</h3>
<div class="about-section-contents">
{{- with . }}
{{- include "termsAndConditions" . | indent 3 }}
{{- end }}
</div>
</section> </section>
<section class="about-section" role="region" aria-labelledby="moderated-servers"> <section class="about-section" role="region" aria-labelledby="moderated-servers">
<h3 id="moderated-servers">Moderated servers</h3> <h3 id="moderated-servers">Moderated servers</h3>
<p> <div class="about-section-contents">
ActivityPub instances federate with other instances by exchanging data with them over the network. <p>
Exchanged data includes things like accounts, statuses, likes, boosts, and media attachments. ActivityPub instances federate with other instances by exchanging data with them over the network.
This exchange of data can prevented for instances on specific domains via a domain block created Exchanged data includes things like accounts, statuses, likes, boosts, and media attachments.
by an instance admin. When an instance is domain blocked by another instance: This exchange of data can prevented for instances on specific domains via a domain block created
</p> by an instance admin. When an instance is domain blocked by another instance:
<ul> </p>
<li>Any existing data from the blocked instance is deleted from the storage of the instance doing the blocking.</li> <ul>
<li>Interaction between the two instances is cut off in both directions; neither instance can interact with the other.</li> <li>Any existing data from the blocked instance is deleted from the storage of the instance doing the blocking.</li>
<li>No new data from the blocked instance will be created on the instance that blocks it.</li> <li>Interaction between the two instances is cut off in both directions; neither instance can interact with the other.</li>
</ul> <li>No new data from the blocked instance will be created on the instance that blocks it.</li>
<p> </ul>
{{- if .blocklistExposed }} <p>
<a href="/about/suspended">View the list of domains blocked by this instance</a> {{- if .blocklistExposed }}
{{- else }} <a href="/about/suspended">View the list of domains blocked by this instance</a>
This instance does not publically share their list of blocked domains. {{- else }}
{{- end }} This instance does not publically share their list of blocked domains.
</p> {{- end }}
</p>
</div>
</section> </section>
</main> </main>
{{- end }} {{- end }}

View file

@ -21,7 +21,7 @@
{{- if .instance.ShortDescription }} {{- if .instance.ShortDescription }}
{{ .instance.ShortDescription | noescape }} {{ .instance.ShortDescription | noescape }}
{{- else }} {{- else }}
<p>No short description has yet been set for this instance.<p> <p>No short description has yet been set for this instance.</p>
{{- end }} {{- end }}
{{- end -}} {{- end -}}
@ -29,8 +29,10 @@
<main class="about"> <main class="about">
<section class="about-section" role="region" aria-labelledby="about"> <section class="about-section" role="region" aria-labelledby="about">
<h3 id="about">About this instance</h3> <h3 id="about">About this instance</h3>
{{- include "shortDescription" . | indent 2 }} <div class="about-section-contents">
<a href="/about">See more details</a> {{- include "shortDescription" . | indent 3 }}
<a href="/about">See more details</a>
</div>
</section> </section>
{{- include "index_apps.tmpl" . | indent 1 }} {{- include "index_apps.tmpl" . | indent 1 }}
</main> </main>

View file

@ -20,96 +20,98 @@
{{- with . }} {{- with . }}
<section role="region" class="about-section apps" aria-labelledby="apps"> <section role="region" class="about-section apps" aria-labelledby="apps">
<h3 id="apps">Client applications</h3> <h3 id="apps">Client applications</h3>
<p> <div class="about-section-contents">
GoToSocial does not provide its own webclient, but implements the Mastodon client API. <p>
You can use this server through a variety of other clients: GoToSocial does not provide its own webclient, but implements the Mastodon client API.
</p> You can use this server through a variety of other clients:
<ul class="applist nodot" role="group"> </p>
<li class="applist-entry"> <ul class="applist nodot" role="group">
<div class="applist-text"> <li class="applist-entry">
<p><strong>Semaphore</strong> is a web client designed for speed and simplicity.</p> <div class="applist-text">
<a <p><strong>Semaphore</strong> is a web client designed for speed and simplicity.</p>
href="https://semaphore.social/" <a
rel="nofollow noreferrer noopener" href="https://semaphore.social/"
target="_blank" rel="nofollow noreferrer noopener"
target="_blank"
>
Use Semaphore
</a>
</div>
<svg
role="img"
aria-labelledby="semaphore-title semaphore-desc"
class="applist-logo redraw"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 146 120"
width="100"
height="100"
> >
Use Semaphore <title id="semaphore-title">The Semaphore logo</title>
</a> <desc id="semaphore-desc">A waving flag</desc>
</div> <path d="M68.13 0C53.94 0 42.81 20 13.9 27.1l-2.23-5.29a6.5 6.5 0 0 0-5.17-10.4 6.5 6.5 0 0 0-.81 12.95L46.2 120l5.99-2.5-14.42-33.33c22.8-6.86 32.51-22.16 49.83-20.58 9.9.9 4.87 19.56 8.11 17.93 16.22-8.15 32.44-11.41 50.29-11.41-7.96-9.78-17.38-20.55-22.71-31.74L120.8 32c-2.32-7.33-2.56-14.75.87-22.22-9.74-3.26-21.1 0-32.45 4.9C82.2 9.77 79.5 0 68.13 0zM15.26 30.42c8.95 6.63 13.63 13.86 16.07 20.94l1.62 6.32c1.24 6.58 1.07 12.8 1.27 18.03z"></path>
<svg </svg>
role="img" </li>
aria-labelledby="semaphore-title semaphore-desc" <li class="applist-entry">
class="applist-logo redraw" <div class="applist-text">
xmlns="http://www.w3.org/2000/svg" <p><strong>Tusky</strong> is a lightweight mobile client for Android.</p>
viewBox="0 0 146 120" <a
width="100" href="https://tusky.app"
height="100" rel="nofollow noreferrer noopener"
> target="_blank"
<title id="semaphore-title">The Semaphore logo</title> >
<desc id="semaphore-desc">A waving flag</desc> Get Tusky
<path d="M68.13 0C53.94 0 42.81 20 13.9 27.1l-2.23-5.29a6.5 6.5 0 0 0-5.17-10.4 6.5 6.5 0 0 0-.81 12.95L46.2 120l5.99-2.5-14.42-33.33c22.8-6.86 32.51-22.16 49.83-20.58 9.9.9 4.87 19.56 8.11 17.93 16.22-8.15 32.44-11.41 50.29-11.41-7.96-9.78-17.38-20.55-22.71-31.74L120.8 32c-2.32-7.33-2.56-14.75.87-22.22-9.74-3.26-21.1 0-32.45 4.9C82.2 9.77 79.5 0 68.13 0zM15.26 30.42c8.95 6.63 13.63 13.86 16.07 20.94l1.62 6.32c1.24 6.58 1.07 12.8 1.27 18.03z"></path> </a>
</svg> </div>
</li> <img
<li class="applist-entry"> class="applist-logo"
<div class="applist-text"> src="/assets/tusky.svg"
<p><strong>Tusky</strong> is a lightweight mobile client for Android.</p> alt="The Tusky mascot, a cartoon elephant tooting happily"
<a title="The Tusky mascot, a cartoon elephant tooting happily"
href="https://tusky.app" width="100"
rel="nofollow noreferrer noopener" height="100"
target="_blank" />
> </li>
Get Tusky <li class="applist-entry">
</a> <div class="applist-text">
</div> <p><strong>Feditext</strong> (beta) is a beautiful client for iOS, iPadOS and macOS.</p>
<img <a
class="applist-logo" href="https://fedi.software/@Feditext"
src="/assets/tusky.svg" rel="nofollow noreferrer noopener"
alt="The Tusky mascot, a cartoon elephant tooting happily" target="_blank"
title="The Tusky mascot, a cartoon elephant tooting happily" >
width="100" Get Feditext
height="100" </a>
/> </div>
</li> <img
<li class="applist-entry"> class="applist-logo"
<div class="applist-text"> src="/assets/feditext.svg"
<p><strong>Feditext</strong> (beta) is a beautiful client for iOS, iPadOS and macOS.</p> alt="The Feditext logo, the characters 'ft' at a slight angle"
<a title="The Feditext logo, the characters 'ft' at a slight angle"
href="https://fedi.software/@Feditext" width="100"
rel="nofollow noreferrer noopener" height="100"
target="_blank" />
> </li>
Get Feditext <li class="applist-entry">
</a> <div class="applist-text">
</div> <p>Or try one of the <strong>Mastodon clients</strong> listed on the official Mastodon page.</p>
<img <a
class="applist-logo" href="https://joinmastodon.org/apps"
src="/assets/feditext.svg" rel="nofollow noreferrer noopener"
alt="The Feditext logo, the characters 'ft' at a slight angle" target="_blank"
title="The Feditext logo, the characters 'ft' at a slight angle" >
width="100" Get Mastodon apps
height="100" </a>
/> </div>
</li> <img
<li class="applist-entry"> class="applist-logo"
<div class="applist-text"> src="/assets/mastodon.svg"
<p>Or try one of the <strong>Mastodon clients</strong> listed on the official Mastodon page.</p> alt="The Mastodon logo, the character 'M' in a speech bubble"
<a title="The Mastodon logo, the character 'M' in a speech bubble"
href="https://joinmastodon.org/apps" width="100"
rel="nofollow noreferrer noopener" height="100"
target="_blank" />
> </li>
Get Mastodon apps </ul>
</a> </div>
</div>
<img
class="applist-logo"
src="/assets/mastodon.svg"
alt="The Mastodon logo, the character 'M' in a speech bubble"
title="The Mastodon logo, the character 'M' in a speech bubble"
width="100"
height="100"
/>
</li>
</ul>
</section> </section>
{{- end }} {{- end }}