[feature] Implement explicit domain allows + allowlist federation mode (#2200)

* love like winter! wohoah, wohoah

* domain allow side effects

* tests! logging! unallow!

* document federation modes

* linty linterson

* test

* further adventures in documentation

* finish up domain block documentation (i think)

* change wording a wee little bit

* docs, example

* consolidate shared domainPermission code

* call mode once

* fetch federation mode within domain blocked func

* read domain perm import in streaming manner

* don't use pointer to slice for domain perms

* don't bother copying blocks + allows before deleting

* admonish!

* change wording just a scooch

* update docs
This commit is contained in:
tobi 2023-09-21 12:12:04 +02:00 committed by GitHub
parent d6add4ef93
commit 183eaa5b29
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 2877 additions and 730 deletions

View file

@ -3,9 +3,11 @@
"go.lintFlags": [ "go.lintFlags": [
"--fast" "--fast"
], ],
"go.vetFlags": [ "gopls": {
"-composites=false ." "analyses": {
], "composites": false
},
},
"eslint.workingDirectories": ["web/source"], "eslint.workingDirectories": ["web/source"],
"eslint.lintTask.enable": true, "eslint.lintTask.enable": true,
"eslint.lintTask.options": "${workspaceFolder}/web/source" "eslint.lintTask.options": "${workspaceFolder}/web/source"

View file

@ -141,9 +141,11 @@ GoToSocial plays nice with lower-powered machines like Raspberry Pi, old laptops
GoToSocial doesn't apply a one-size-fits-all approach to federation. Who your server federates with should be up to you. GoToSocial doesn't apply a one-size-fits-all approach to federation. Who your server federates with should be up to you.
- 'Normal' federation; discover new servers. - 'blocklist' mode (default): discover new servers; block servers you don't like.
- *Allow list*-only federation; choose which servers you talk to (not yet implemented). - 'allowlist' mode (experimental); opt-in to federation with trusted servers.
- Zero federation; keep your server private (not yet implemented). - 'zero' federation mode; keep your server private (not yet implemented).
[See the docs for more info](https://docs.gotosocial.org/en/latest/admin/federation_modes).
### OIDC integration ### OIDC integration

View file

@ -45,24 +45,24 @@ What follows is a rough timeline of features that will be implemented on the roa
### Mid 2023 ### Mid 2023
- **Hashtags** -- implement federating hashtags and viewing hashtags to allow users to discover posts that they might be interested in. - [x] **Hashtags** -- implement federating hashtags and viewing hashtags to allow users to discover posts that they might be interested in. (Done! https://github.com/superseriousbusiness/gotosocial/pull/2032).
- **Block list subscriptions** -- allow instance admins to subscribe their instance to plaintext domain block lists (much of the work for this is already in place). - [ ] **Block list subscriptions** -- allow instance admins to subscribe their instance to plaintext domain block lists (much of the work for this is already in place).
- **Direct conversation view** -- allow users to easily page through all direct-message conversations they're a part of. - [ ] **Direct conversation view** -- allow users to easily page through all direct-message conversations they're a part of.
### Mid/late 2023 ### Mid/late 2023
- **Polls** -- implementing parsing, creating, and voting in polls. - [ ] **Polls** -- implementing parsing, creating, and voting in polls.
- **Mute posts/threads** -- opt-out of notifications for replies to a thread; no longer show a given post in your timeline. - [ ] **Mute posts/threads** -- opt-out of notifications for replies to a thread; no longer show a given post in your timeline.
- **Limited peering/allowlists** -- allow instance admins to limit federation with other instances by default. - [x] **Limited peering/allowlists** -- allow instance admins to limit federation with other instances by default. (Done! https://github.com/superseriousbusiness/gotosocial/pull/2200)
### Late 2023 ### Late 2023
- **Move activity** -- use the ActivityPub `Move` activity to support migration of a user's profile across servers. - [ ] **Move activity** -- use the ActivityPub `Move` activity to support migration of a user's profile across servers.
- **Sign-up flow** -- allow users to submit a sign-up request to an instance; allow admins to moderate sign-up requests. - [ ] **Sign-up flow** -- allow users to submit a sign-up request to an instance; allow admins to moderate sign-up requests.
### Early 2024 ### Early 2024
- **Non-replyable posts** -- design a non-replyable post path for GoToSocial based on https://github.com/mastodon/mastodon/issues/14762#issuecomment-1196889788; allow users to create non-replyable posts. - [ ] **Non-replyable posts** -- design a non-replyable post path for GoToSocial based on https://github.com/mastodon/mastodon/issues/14762#issuecomment-1196889788; allow users to create non-replyable posts.
### And then... ### And then...

View file

@ -0,0 +1,73 @@
# Domain Blocks
GoToSocial supports 'blocking'/'suspending' domains that you don't want your instance to federate with. In our documentation, the two terms 'block' and 'suspend' are used interchangeably with regard to domains, because they mean the same thing: preventing your instance and the instance running on the target domain from communicating with one another, effectively cutting off federation between the two instances.
You can view, create, and remove domain blocks and domain allows using the [instance admin panel](./settings.md#federation).
This document focuses on what domain blocks actually *do* and what side effects are processed when you create a new domain block.
## How does a domain block work
A domain block works by doing two things:
Firstly, it instructs your instance to refuse any requests made to it from the target domain:
- All incoming requests from the blocked domain to your instance will be responded to with HTTP status code `403 Forbidden`.
- This makes it impossible for an account on the target domain to interact with an account on your instance, or any statuses created by that account, since your instance will simply refuse to process the request.
- This also extends to GET requests: your instance will no longer serve an ActivityPub response to a request by a blocked instance to fetch, say, an account's bio, or pinned statuses, etc.
- Boosts of statuses from accounts on your instance should also not be visible to accounts on blocked instances, since those instances will not be able to fetch the content of the status that has been boosted.
Secondly, a domain block instructs your instance to no longer make any requests to the target instance. This means:
- Your instance will not deliver any messages to an instance on a blocked domain.
- Nor will it fetch statuses, accounts, media, or emojis from that instance.
## Safety concerns
### Block evasion
Domain blocking is not airtight. GoToSocial *can* ensure that it will neither serve requests from nor make requests to instances on blocked domains. Unfortunately it *cannot* guarantee that accounts on your instance will never be visible in any way to users with accounts on blocked instances. Consider the following circumstances, all of which represent a form of [block evasion](https://en.wikipedia.org/wiki/Block_(Internet)#Evasion):
- You've domain blocked `blocked.instance.org`. A user on `blocked.instance.org` makes an account on `not-blocked.domain`, so that they can use their new account to interact with your posts or send messages to you. They may be upfront about who they are, or they may use a false identity.
- You've domain blocked `blocked.instance.org`. A user on `not-blocked.domain` screenshots a post of yours and sends it to someone on `blocked.instance.org`.
- You've domain blocked `blocked.instance.org`. A user on `blocked.instance.org` visits the web view of your profile to read your public posts.
- You've domain blocked `blocked.instance.org`. You have RSS enabled for your profile. A user from `blocked.instance.org` subscribes to your RSS feed to read your public posts.
In the above cases, `blocked.instance.org` remains blocked, but users from that instance may still have other ways of seeing your posts and possibly reaching you.
With this in mind, you should only ever treat domain blocking as *one layer* of your privacy onion. That is, domain blocking should be deployed alongside other layers in order to achieve a level of privacy that you are comfortable with. This ought to include things like not posting sensitive information publicly, not accidentally doxxing yourself in photos, etc.
### Block announce bots
Unfortunately, the Fediverse has its share of trolls, many of whom see domain blocking as an adversary to be defeated. To achieve this, they often target instances which use domain blocks to protect users.
As such, there are bots on the Fediverse which scrape instance domain blocks and announce any discovered blocks to the followers of the bot, opening the admin of the blocking instance up to harassment. These bots use the `api/v1/instance/peers?filter=suspended` endpoint of GoToSocial instances to gather domain block information.
By default, GoToSocial does not expose this endpoint publicly, so your instance will be safe from such scraping. However, if you set `instance-expose-suspended` to `true` in your config.yaml file, you may find that this endpoint gets scraped occasionally, and you may see your blocks being announced by troll bots.
## What are the side effects of creating a domain block
When you create a new domain block (or resubmit an existing domain block), your instance will process side effects for the block. These side effects are:
1. Mark all accounts stored in your database from the target domain as suspended, and remove most information (bio, display name, fields, etc) from each account marked this way.
2. Clear all mutual and one-way relationships between local accounts and suspended accounts (followed, following, follow requests, bookmarks, etc).
3. Delete all statuses from suspended accounts.
4. Delete all media from suspended accounts and their statuses, including media attachments, avatars, headers, and emojis.
!!! danger
Currently, most of the above side effects are **irreversible**. If you unblock a domain after blocking it, all accounts on that domain will be marked as no longer suspended, and you will be able to interact with them again, but all relationships will still be wiped out, and all statuses and media will be gone.
Think carefully before blocking a domain.
## Blocking a domain and all subdomains
When you add a new domain block, GoToSocial will also block all subdomains of the blocked domain. This allows you to block specific subdomains, if you wish, or to block a domain more generally if you don't trust the domain owner.
Some examples:
1. You block `example.org`. This blocks the following domains (not exhaustive): `example.org`, `subdomain.example.org`, `another-subdomain.example.org`, `sub.sub.sub.domain.example.org`.
2. You block `baddies.example.org`. This blocks the following domains (not exhaustive): `baddies.example.org`, `really-bad.baddies.example.org`. However the following domains are not blocked (not exhaustive): `example.org`, `subdomain.example.org`, `not-baddies.example.org`.
A more practical example:
Some absolute jabroni owns the domain `fossbros-anonymous.io`. Not only do they run a Mastodon instance at `mastodon.fossbros-anonymous.io`, they also have a GoToSocial instance at `gts.fossbros-anonymous.io`, and an Akkoma instance at `akko.fossbros-anonymous.io`. You want to block all of these instances at once (and any future instances they might create at, say, `pl.fossbros-anonymous.io`, etc). You can do this by simply creating a domain block for `fossbros-anonymous.io`. None of the instances at subdomains will be able to communicate with your instance. Yeet!

View file

@ -0,0 +1,62 @@
# Federation Modes
GoToSocial currently offers both 'blocklist' and 'allowlist' federation modes, which can be set using the `instance-federation-mode` setting in the config.yaml, or using the `GTS_INSTANCE_FEDERATION_MODE` environment variable. These are described below.
## Blocklist federation mode (default)
When `instance-federation-mode` is set to `blocklist`, your instance will federate openly with other instances, without restriction, with the exception of instances you have explicitly created domain blocks for via the settings panel.
When your instance receives a new request from an instance that is not blocked via a domain block entry, it will serve the request if the request is valid, and if the requester is permitted to view the resource that's being requested (taking account of status visibility, and any user-level blocks).
When your instance encounters a mention or an announce of a status or account it hasn't seen before, it will go fetch the resource if the domain of the resource is not blocked via a domain block entry.
!!! info
Blocklist federation mode is the default federation mode for GoToSocial. It's also the default for most other ActivityPub server implementations.
## Allowlist federation mode
!!! warning
Allowlist federation mode is still considered "experimental" while we work out how well it actually works in practice. It should do what it says on the box, but it may cause bugs or edge cases to appear elsewhere, we're not sure yet!
When `instance-federation-mode` is set to `allowlist`, your instance will federate only with instances for which an explicit allow has been created via the settings panel, and will restrict access by any instances for which an allow has not been created.
When your instance receives a new request from an instance that is not explicitly allowed via a domain allow entry, it will refuse to serve the request. If the request comes from a domain that is on the allowlist, your instance will serve the request (taking account of status visibility, and any user-level blocks).
When your instance encounters a mention or an announce of a status or account it hasn't seen before, it will only go fetch the resource if the domain of the resource is explicitly allowed via a domain allow entry.
!!! tip
Allowlist federation mode is useful in cases where you want to federate only with select 'trusted' instances. However, this comes at the cost of hampering discovery. Under blocklist federation mode, you will organically encounter posts and accounts from instances you were not yet aware of, via boosts and replies, but in allowlist federation mode no such serendipity will occur.
As such, it is recommended that you *either* start with blocklist federation mode and switch over to allowlist federation later on once you've established which other instances you 'like', *or* you start with allowlist federation mode, and have an allowlist populated and ready to import after first booting up your instance, in order to 'bootstrap' it.
## Combining blocks and allows
It is possible to both block and allow the same domain, and the effect of combining these two things depends on which federation mode your instance is currently using.
![A flow chart diagram showing how the two different federation modes treat incoming requests.](../assets/diagrams/federation_modes.png)
### In blocklist mode
As the chart shows, in blocklist mode (the left-hand part of the diagram), an explicit domain allow can be used to override a domain block.
This is useful in cases where you are importing a blocklist from someone else, but the imported blocklist contains some instances you would actually prefer not to block. To avoid blocking those instances, you can create explicit domain allows for those instances first. Then, when you import the block list, the explicitly allowed domains will not be blocked, and the side effects of creating a block (deleting statuses, media, relationships etc) will not be processed.
If you later remove an explicit allow for a domain that also has a block, the instance will become blocked, and side effects of block creation will be processed.
Conversely, if you add an explicit allow for a domain that was blocked, the side effects of block *deletion* will be processed.
### In allowlist mode
As the chart shows, in allowlist mode (the right-hand part of the diagram) an explicit domain block trumps an explicit domain allow. The following two things must be true in order for an instance to be allowed through, when running in allowlist mode:
1. An explicit domain block **does not exist** for the instance.
2. An explicit domain allow **does exist** for the instance.
If either of the above conditions are not met, the request will be denied.
!!! danger
Combining blocks and allows is a tricky business!
When importing lists of allows and blocks, you should always review the list manually to make sure that you do not inadvertently block a domain that you would prefer not to block, since this can have **very annoying side effects** like removing follows/following, statuses, etc.
When in doubt, always add an explicit allow first as an insurance policy!

View file

@ -1,57 +1,101 @@
# Admin Settings # Admin Settings Panel
The GoToSocial Settings interface uses the [admin api routes](https://docs.gotosocial.org/en/latest/api/swagger/#operations-tag-admin) to manage your instance. It's combined with the [User settings](../user_guide/settings.md) and uses the same OAUTH mechanism as normal clients (with scope: admin). The GoToSocial admin settings panel uses the [admin API](https://docs.gotosocial.org/en/latest/api/swagger/#operations-tag-admin) to manage your instance. It's combined with the [user settings panel](../user_guide/settings.md) and uses the same OAuth mechanism as normal clients (with scope: admin).
## Account permissions ## Setting admin account permissions and logging in
To use the Admin API your account has to be promoted as such:
``` To use the admin settings panel, your account has to be promoted to admin:
```bash
./gotosocial --config-path ./config.yaml admin account promote --username YOUR_USERNAME ./gotosocial --config-path ./config.yaml admin account promote --username YOUR_USERNAME
``` ```
After this, you can enter your instance domain in the login field (auto-filled if you run GoToSocial on the same domain), and login like you would with any other client. In order for the promotion to 'take', you may need to restart your instance after running the command.
After this, you can navigate to `https://[your-instance-name.org]/settings`, enter your domain in the login field, and login like you would with any other client. You should now see the admin settings.
## Instance Settings ## Moderation
![Screenshot of the GoToSocial admin panel, showing the fields to change an instance's settings](../assets/admin-settings.png)
Here you can set various metadata for your instance, like the displayed name, thumbnail image, description texts (HTML), and contact username and email. Instance moderation settings.
## Actions ### Reports
You can use media cleanup to remove remote media older than the specified number of days. This also removes unused headers and avatars.
![List of reports for testing, one resolved and one open.](../assets/admin-settings-reports.png)
The reports section shows a list of reports, originating from your local users, or remote instances (shown anonymously as just the name of the instance, without specific username).
Clicking a report shows if it was resolved (with the reasoning if available), more information, and a list of reported toots if selected by the reporting user. You can also use this view to mark a report as resolved, and fill in a comment. Whatever comment you enter here will be visible to the user that created the report, if that user is from your instance.
Clicking on the username of the reported account opens that account in the 'Accounts' view, allowing you to perform moderation actions on it.
### Accounts
You can use this section to search for an account and perform moderation actions on it.
### Federation
## Federation
![List of suspended instances, with a field to filter/add new blocks. Below is a link to the bulk import/export interface](../assets/admin-settings-federation.png) ![List of suspended instances, with a field to filter/add new blocks. Below is a link to the bulk import/export interface](../assets/admin-settings-federation.png)
In the federation section you can influence which instances you federate with, through adding domain blocks. You can enter a domain to suspend in the search field, which will filter the list to show you if you already have a block for it. Clicking 'suspend' gives you a form to add a public and/or private comment, and submit to add the block. Adding a suspension will suspend all the currently known accounts on the instance, and prevent any new interactions with any user on the blocked instance. In the federation section you can create, delete, and review explicit domain blocks and domain allows.
### Bulk import/export For more detail on federation settings, and specifically how domain allows and domain blocks work in combination, please see [the federation modes section](./federation_modes.md), and [the domain blocks section](./domain_blocks.md).
Through the link at the bottom of the Federation section (or going to `/settings/admin/federation/import-export`) you can do bulk import/export of your domain blocklist.
#### Domain Blocks
You can enter a domain to suspend in the search field, which will filter the list to show you if you already have a block for it.
Clicking 'suspend' gives you a form to add a public and/or private comment, and submit to add the block. Adding a suspension will suspend all the currently known accounts on the instance, and prevent any new interactions with any user on the blocked instance.
#### Domain Allows
The domain allows section works much like the domain blocks section, described above, only for explicit domain allows rather than domain blocks.
#### Bulk import/export
Through the link at the bottom of the Federation section (or going to `/settings/admin/federation/import-export`) you can do bulk import/export of blocklists and allowlists.
![List of domains included in an import, providing ways to select some or all of them, change their domains, and update the use of subdomains.](../assets/admin-settings-federation-import-export.png) ![List of domains included in an import, providing ways to select some or all of them, change their domains, and update the use of subdomains.](../assets/admin-settings-federation-import-export.png)
Upon importing a list, either through the input field or from a file, you can review the entries in the list before importing a subset. You'll also be warned for entries that use subdomains, providing an easy way to change them to the main domain. Upon importing a list, either through the input field or from a file, you can review the entries in the list before importing a subset. You'll also be warned for entries that use subdomains, providing an easy way to change them to the main domain.
## Reports ## Administration
![List of reports for testing, one resolved and one open.](../assets/admin-settings-reports.png)
The reports section shows a list of reports, originating from your local users, or remote instances (shown anonymously as just the name of the instance, without specific username). Instance administration settings.
Clicking a report shows if it was resolved (with the reasoning if available), more information, and a list of reported toots if selected by the reporting user. ### Actions
Run one-off administrative actions.
#### Media
You can use this section run a media action to clean up the remote media cache using the specified number of days. Media older than the given number of days will be removed from storage (s3 or local). Media removed in this way will be refetched again later if the media is required again. This action is functionally identical to the media cleanup that runs every night, automatically.
#### Keys
You can use this section to expire/invalidate public keys from the selected remote instance. The next time your instance receives a signed request using an expired key, it will attempt to fetch and store the public key again.
### Custom Emoji
## Custom Emoji
Custom Emoji will be automatically fetched when included in remote toots, but to use them in your own posts they have to be enabled on your instance. Custom Emoji will be automatically fetched when included in remote toots, but to use them in your own posts they have to be enabled on your instance.
### Local #### Local
![Local custom emoji section, showing an overview of custom emoji sorted by category. There are a lot of garfields.](../assets/admin-settings-emoji-local.png) ![Local custom emoji section, showing an overview of custom emoji sorted by category. There are a lot of garfields.](../assets/admin-settings-emoji-local.png)
This section shows an overview of all the custom emoji enabled on your instance, sorted by their category. Clicking an emoji shows it's details, and provides options to change the category or image, or delete it completely. The shortcode cannot be updated here, you would have to upload it with the new shortcode yourself (and optionally delete the old one). This section shows an overview of all the custom emoji enabled on your instance, sorted by their category. Clicking an emoji shows it's details, and provides options to change the category or image, or delete it completely. The shortcode cannot be updated here, you would have to upload it with the new shortcode yourself (and optionally delete the old one).
Below the overview you can upload your own custom emoji, after previewing how they look in a toot. PNG and (animated) GIF's are supported. Below the overview you can upload your own custom emoji, after previewing how they look in a toot. PNG and (animated) GIF's are supported.
### Remote #### Remote
![Remote custom emoji section, showing a list of 3 emoji parsed from the entered toot, garfield, blobfoxbox and blobhajmlem. They can be selected, their shortcode can be tweaked, and they can be assigned to a category, before submitting as a copy or delete operation](../assets/admin-settings-emoji-remote.png) ![Remote custom emoji section, showing a list of 3 emoji parsed from the entered toot, garfield, blobfoxbox and blobhajmlem. They can be selected, their shortcode can be tweaked, and they can be assigned to a category, before submitting as a copy or delete operation](../assets/admin-settings-emoji-remote.png)
Through the 'remote' section, you can look up a link to any remote toots (provided the instance isn't suspended). If they use any custom emoji they will be listed, providing an easy way to copy them to the local emoji (for use in your own toots), or disable them ( hiding them from toots). Through the 'remote' section, you can look up a link to any remote toots (provided the instance isn't suspended). If they use any custom emoji they will be listed, providing an easy way to copy them to the local emoji (for use in your own toots), or disable them ( hiding them from toots).
**Note:** as the testrig server does not federate, this feature can't be used in development (500: Internal Server Error). **Note:** as the testrig server does not federate, this feature can't be used in development (500: Internal Server Error).
### Instance Settings
![Screenshot of the GoToSocial admin panel, showing the fields to change an instance's settings](../assets/admin-settings.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.

View file

@ -947,16 +947,25 @@ definitions:
type: object type: object
x-go-name: Domain x-go-name: Domain
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
domainBlock: domainKeysExpireRequest:
description: DomainBlock represents a block on one domain properties:
domain:
description: hostname/domain to expire keys for.
type: string
x-go-name: Domain
title: DomainBlockCreateRequest is the form submitted as a POST to /api/v1/admin/domain_keys_expire to expire a domain's public keys.
type: object
x-go-name: DomainKeysExpireRequest
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
domainPermission:
properties: properties:
created_at: created_at:
description: Time at which this block was created (ISO 8601 Datetime). description: Time at which the permission entry was created (ISO 8601 Datetime).
example: "2021-07-30T09:20:25+00:00" example: "2021-07-30T09:20:25+00:00"
type: string type: string
x-go-name: CreatedAt x-go-name: CreatedAt
created_by: created_by:
description: ID of the account that created this domain block. description: ID of the account that created this domain permission entry.
example: 01FBW2758ZB6PBR200YPDDJK4C example: 01FBW2758ZB6PBR200YPDDJK4C
type: string type: string
x-go-name: CreatedBy x-go-name: CreatedBy
@ -966,20 +975,18 @@ definitions:
type: string type: string
x-go-name: Domain x-go-name: Domain
id: id:
description: The ID of the domain block. description: The ID of the domain permission entry.
example: 01FBW21XJA09XYX51KV5JVBW0F example: 01FBW21XJA09XYX51KV5JVBW0F
readOnly: true readOnly: true
type: string type: string
x-go-name: ID x-go-name: ID
obfuscate: obfuscate:
description: |- description: Obfuscate the domain name when serving this domain permission entry publicly.
Obfuscate the domain name when serving this domain block publicly.
A useful anti-harassment tool.
example: false example: false
type: boolean type: boolean
x-go-name: Obfuscate x-go-name: Obfuscate
private_comment: private_comment:
description: Private comment for this block, visible to our instance admins only. description: Private comment for this permission entry, visible to this instance's admins only.
example: they are poopoo example: they are poopoo
type: string type: string
x-go-name: PrivateComment x-go-name: PrivateComment
@ -994,7 +1001,7 @@ definitions:
type: string type: string
x-go-name: SilencedAt x-go-name: SilencedAt
subscription_id: subscription_id:
description: The ID of the subscription that created/caused this domain block. description: If applicable, the ID of the subscription that caused this domain permission entry to be created.
example: 01FBW25TF5J67JW3HFHZCSD23K example: 01FBW25TF5J67JW3HFHZCSD23K
type: string type: string
x-go-name: SubscriptionID x-go-name: SubscriptionID
@ -1003,43 +1010,46 @@ definitions:
example: "2021-07-30T09:20:25+00:00" example: "2021-07-30T09:20:25+00:00"
type: string type: string
x-go-name: SuspendedAt x-go-name: SuspendedAt
title: DomainPermission represents a permission applied to one domain (explicit block/allow).
type: object type: object
x-go-name: DomainBlock x-go-name: DomainPermission
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
domainBlockCreateRequest: domainPermissionCreateRequest:
properties: properties:
domain: domain:
description: hostname/domain to block description: |-
A single domain for which this permission request should apply.
Only used if import=true is NOT specified or if import=false.
example: example.org
type: string type: string
x-go-name: Domain x-go-name: Domain
domains: domains:
description: A list of domains to block. Only used if import=true is specified. description: |-
A list of domains for which this permission request should apply.
Only used if import=true is specified.
x-go-name: Domains x-go-name: Domains
obfuscate: obfuscate:
description: whether the domain should be obfuscated when being displayed publicly description: |-
Obfuscate the domain name when displaying this permission entry publicly.
Ie., instead of 'example.org' show something like 'e**mpl*.or*'.
example: false
type: boolean type: boolean
x-go-name: Obfuscate x-go-name: Obfuscate
private_comment: private_comment:
description: private comment for other admins on why the domain was blocked description: Private comment for other admins on why this permission entry was created.
example: don't like 'em!!!!
type: string type: string
x-go-name: PrivateComment x-go-name: PrivateComment
public_comment: public_comment:
description: public comment on the reason for the domain block description: |-
Public comment on why this permission entry was created.
Will be visible to requesters at /api/v1/instance/peers if this endpoint is exposed.
example: "foss dorks \U0001F62B"
type: string type: string
x-go-name: PublicComment x-go-name: PublicComment
title: DomainBlockCreateRequest is the form submitted as a POST to /api/v1/admin/domain_blocks to create a new block. title: DomainPermissionRequest is the form submitted as a POST to create a new domain permission entry (allow/block).
type: object type: object
x-go-name: DomainBlockCreateRequest x-go-name: DomainPermissionRequest
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
domainKeysExpireRequest:
properties:
domain:
description: hostname/domain to expire keys for.
type: string
x-go-name: Domain
title: DomainBlockCreateRequest is the form submitted as a POST to /api/v1/admin/domain_keys_expire to expire a domain's public keys.
type: object
x-go-name: DomainKeysExpireRequest
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
emoji: emoji:
properties: properties:
@ -4011,6 +4021,173 @@ paths:
summary: Get a list of existing emoji categories. summary: Get a list of existing emoji categories.
tags: tags:
- admin - admin
/api/v1/admin/domain_allows:
get:
operationId: domainAllowsGet
parameters:
- description: If set to `true`, then each entry in the returned list of domain allows will only consist of the fields `domain` and `public_comment`. This is perfect for when you want to save and share a list of all the domains you have allowed on your instance, so that someone else can easily import them, but you don't want them to see the database IDs of your allows, or private comments etc.
in: query
name: export
type: boolean
produces:
- application/json
responses:
"200":
description: All domain allows currently in place.
schema:
items:
$ref: '#/definitions/domainPermission'
type: array
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: View all domain allows currently in place.
tags:
- admin
post:
consumes:
- multipart/form-data
description: |-
You have two options when using this endpoint: either you can set `import` to `true` and
upload a file containing multiple domain allows, JSON-formatted, or you can leave import as
`false`, and just add one domain allow.
The format of the json file should be something like: `[{"domain":"example.org"},{"domain":"whatever.com","public_comment":"they smell"}]`
operationId: domainAllowCreate
parameters:
- default: false
description: Signal that a list of domain allows is being imported as a file. If set to `true`, then 'domains' must be present as a JSON-formatted file. If set to `false`, then `domains` will be ignored, and `domain` must be present.
in: query
name: import
type: boolean
- description: JSON-formatted list of domain allows to import. This is only used if `import` is set to `true`.
in: formData
name: domains
type: file
- description: Single domain to allow. Used only if `import` is not `true`.
in: formData
name: domain
type: string
- description: Obfuscate the name of the domain when serving it publicly. Eg., `example.org` becomes something like `ex***e.org`. Used only if `import` is not `true`.
in: formData
name: obfuscate
type: boolean
- description: Public comment about this domain allow. This will be displayed alongside the domain allow if you choose to share allows. Used only if `import` is not `true`.
in: formData
name: public_comment
type: string
- description: Private comment about this domain allow. Will only be shown to other admins, so this is a useful way of internally keeping track of why a certain domain ended up allowed. Used only if `import` is not `true`.
in: formData
name: private_comment
type: string
produces:
- application/json
responses:
"200":
description: The newly created domain allow, if `import` != `true`. If a list has been imported, then an `array` of newly created domain allows will be returned instead.
schema:
$ref: '#/definitions/domainPermission'
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"404":
description: not found
"406":
description: not acceptable
"409":
description: 'Conflict: There is already an admin action running that conflicts with this action. Check the error message in the response body for more information. This is a temporary error; it should be possible to process this action if you try again in a bit.'
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: Create one or more domain allows, from a string or a file.
tags:
- admin
/api/v1/admin/domain_allows/{id}:
delete:
operationId: domainAllowDelete
parameters:
- description: The id of the domain allow.
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: The domain allow that was just deleted.
schema:
$ref: '#/definitions/domainPermission'
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"404":
description: not found
"406":
description: not acceptable
"409":
description: 'Conflict: There is already an admin action running that conflicts with this action. Check the error message in the response body for more information. This is a temporary error; it should be possible to process this action if you try again in a bit.'
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: Delete domain allow with the given ID.
tags:
- admin
get:
operationId: domainAllowGet
parameters:
- description: The id of the domain allow.
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: The requested domain allow.
schema:
$ref: '#/definitions/domainPermission'
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"404":
description: not found
"406":
description: not acceptable
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: View domain allow with the given ID.
tags:
- admin
/api/v1/admin/domain_blocks: /api/v1/admin/domain_blocks:
get: get:
operationId: domainBlocksGet operationId: domainBlocksGet
@ -4026,7 +4203,7 @@ paths:
description: All domain blocks currently in place. description: All domain blocks currently in place.
schema: schema:
items: items:
$ref: '#/definitions/domainBlock' $ref: '#/definitions/domainPermission'
type: array type: array
"400": "400":
description: bad request description: bad request
@ -4088,7 +4265,7 @@ paths:
"200": "200":
description: The newly created domain block, if `import` != `true`. If a list has been imported, then an `array` of newly created domain blocks will be returned instead. description: The newly created domain block, if `import` != `true`. If a list has been imported, then an `array` of newly created domain blocks will be returned instead.
schema: schema:
$ref: '#/definitions/domainBlock' $ref: '#/definitions/domainPermission'
"400": "400":
description: bad request description: bad request
"401": "401":
@ -4124,7 +4301,7 @@ paths:
"200": "200":
description: The domain block that was just deleted. description: The domain block that was just deleted.
schema: schema:
$ref: '#/definitions/domainBlock' $ref: '#/definitions/domainPermission'
"400": "400":
description: bad request description: bad request
"401": "401":
@ -4159,7 +4336,7 @@ paths:
"200": "200":
description: The requested domain block. description: The requested domain block.
schema: schema:
$ref: '#/definitions/domainBlock' $ref: '#/definitions/domainPermission'
"400": "400":
description: bad request description: bad request
"401": "401":

View file

@ -0,0 +1,124 @@
<mxfile host="Electron" modified="2023-08-25T10:31:38.050Z" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/21.6.8 Chrome/114.0.5735.289 Electron/25.5.0 Safari/537.36" etag="NPxJhlnTDVsZYA0iSynQ" version="21.6.8" type="device">
<diagram id="C5RBs43oDa-KdzZeNtuy" name="Page-1">
<mxGraphModel dx="2074" dy="1149" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="WIyWlLk6GJQsqaUBKTNV-0" />
<mxCell id="WIyWlLk6GJQsqaUBKTNV-1" parent="WIyWlLk6GJQsqaUBKTNV-0" />
<mxCell id="WIyWlLk6GJQsqaUBKTNV-2" value="" style="rounded=0;html=1;jettySize=auto;orthogonalLoop=1;fontSize=11;endArrow=block;endFill=0;endSize=8;strokeWidth=1;shadow=0;labelBackgroundColor=none;edgeStyle=orthogonalEdgeStyle;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-3" target="WIyWlLk6GJQsqaUBKTNV-6" edge="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="WIyWlLk6GJQsqaUBKTNV-3" value="Signed request enters GoToSocial instance" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
<mxGeometry x="160" y="80" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="WIyWlLk6GJQsqaUBKTNV-4" value="No" style="rounded=0;html=1;jettySize=auto;orthogonalLoop=1;fontSize=11;endArrow=block;endFill=0;endSize=8;strokeWidth=1;shadow=0;labelBackgroundColor=none;edgeStyle=orthogonalEdgeStyle;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-6" target="WIyWlLk6GJQsqaUBKTNV-10" edge="1">
<mxGeometry y="20" relative="1" as="geometry">
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="WIyWlLk6GJQsqaUBKTNV-5" value="Yes" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;jettySize=auto;orthogonalLoop=1;fontSize=11;endArrow=block;endFill=0;endSize=8;strokeWidth=1;shadow=0;labelBackgroundColor=none;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-6" target="WIyWlLk6GJQsqaUBKTNV-11" edge="1">
<mxGeometry y="10" relative="1" as="geometry">
<mxPoint as="offset" />
<mxPoint x="90" y="210" as="targetPoint" />
<Array as="points">
<mxPoint x="110" y="210" />
<mxPoint x="110" y="450" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="WIyWlLk6GJQsqaUBKTNV-6" value="DomainAllow exists?" style="rhombus;whiteSpace=wrap;html=1;shadow=0;fontFamily=Helvetica;fontSize=12;align=center;strokeWidth=1;spacing=6;spacingTop=-4;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
<mxGeometry x="170" y="170" width="100" height="80" as="geometry" />
</mxCell>
<mxCell id="WIyWlLk6GJQsqaUBKTNV-8" value="No" style="rounded=0;html=1;jettySize=auto;orthogonalLoop=1;fontSize=11;endArrow=block;endFill=0;endSize=8;strokeWidth=1;shadow=0;labelBackgroundColor=none;edgeStyle=orthogonalEdgeStyle;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-10" target="WIyWlLk6GJQsqaUBKTNV-11" edge="1">
<mxGeometry x="0.3333" y="20" relative="1" as="geometry">
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="WIyWlLk6GJQsqaUBKTNV-9" value="Yes" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;jettySize=auto;orthogonalLoop=1;fontSize=11;endArrow=block;endFill=0;endSize=8;strokeWidth=1;shadow=0;labelBackgroundColor=none;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-10" target="WIyWlLk6GJQsqaUBKTNV-12" edge="1">
<mxGeometry y="10" relative="1" as="geometry">
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="WIyWlLk6GJQsqaUBKTNV-10" value="DomainBlock exists?" style="rhombus;whiteSpace=wrap;html=1;shadow=0;fontFamily=Helvetica;fontSize=12;align=center;strokeWidth=1;spacing=6;spacingTop=-4;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
<mxGeometry x="170" y="290" width="100" height="80" as="geometry" />
</mxCell>
<mxCell id="WIyWlLk6GJQsqaUBKTNV-11" value="Request Passed" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
<mxGeometry x="160" y="430" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="WIyWlLk6GJQsqaUBKTNV-12" value="Request Denied (403)" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
<mxGeometry x="320" y="310" width="140" height="40" as="geometry" />
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-0" value="&quot;Open&quot; / denylist federation (current default)" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="90" y="30" width="260" height="30" as="geometry" />
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-2" value="" style="rounded=0;html=1;jettySize=auto;orthogonalLoop=1;fontSize=11;endArrow=block;endFill=0;endSize=8;strokeWidth=1;shadow=0;labelBackgroundColor=none;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="V7qh8wB5WtYMs-n4lDnG-3" target="V7qh8wB5WtYMs-n4lDnG-6">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-3" value="Signed request enters GoToSocial instance" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="640" y="80" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-4" value="No" style="rounded=0;html=1;jettySize=auto;orthogonalLoop=1;fontSize=11;endArrow=block;endFill=0;endSize=8;strokeWidth=1;shadow=0;labelBackgroundColor=none;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="V7qh8wB5WtYMs-n4lDnG-6" target="V7qh8wB5WtYMs-n4lDnG-9">
<mxGeometry y="20" relative="1" as="geometry">
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-6" value="DomainBlock exists?" style="rhombus;whiteSpace=wrap;html=1;shadow=0;fontFamily=Helvetica;fontSize=12;align=center;strokeWidth=1;spacing=6;spacingTop=-4;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="650" y="170" width="100" height="80" as="geometry" />
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-7" value="Yes" style="rounded=0;html=1;jettySize=auto;orthogonalLoop=1;fontSize=11;endArrow=block;endFill=0;endSize=8;strokeWidth=1;shadow=0;labelBackgroundColor=none;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="V7qh8wB5WtYMs-n4lDnG-9" target="V7qh8wB5WtYMs-n4lDnG-10">
<mxGeometry x="0.3333" y="20" relative="1" as="geometry">
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-8" value="No" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;jettySize=auto;orthogonalLoop=1;fontSize=11;endArrow=block;endFill=0;endSize=8;strokeWidth=1;shadow=0;labelBackgroundColor=none;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="V7qh8wB5WtYMs-n4lDnG-9" target="V7qh8wB5WtYMs-n4lDnG-11">
<mxGeometry y="10" relative="1" as="geometry">
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-9" value="DomainAllow exists?" style="rhombus;whiteSpace=wrap;html=1;shadow=0;fontFamily=Helvetica;fontSize=12;align=center;strokeWidth=1;spacing=6;spacingTop=-4;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="650" y="290" width="100" height="80" as="geometry" />
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-10" value="Request Passed" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="640" y="430" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-11" value="Request Denied (403)" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="800" y="310" width="140" height="40" as="geometry" />
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-12" value="Basic &quot;closed&quot; / allowlist federation" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="595" y="30" width="210" height="30" as="geometry" />
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-13" value="" style="endArrow=classic;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="V7qh8wB5WtYMs-n4lDnG-6" target="V7qh8wB5WtYMs-n4lDnG-11">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="870" y="490" as="sourcePoint" />
<mxPoint x="920" y="440" as="targetPoint" />
<Array as="points">
<mxPoint x="870" y="210" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-14" value="Yes" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="V7qh8wB5WtYMs-n4lDnG-13">
<mxGeometry x="-0.4667" y="3" relative="1" as="geometry">
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-15" value="Further processing / check ACLs etc." style="rounded=0;whiteSpace=wrap;html=1;dashed=1;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="160" y="530" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-16" value="" style="endArrow=classic;html=1;rounded=0;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-11" target="V7qh8wB5WtYMs-n4lDnG-15">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="870" y="490" as="sourcePoint" />
<mxPoint x="920" y="440" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-17" value="Further processing / check ACLs etc." style="rounded=0;whiteSpace=wrap;html=1;dashed=1;" vertex="1" parent="WIyWlLk6GJQsqaUBKTNV-1">
<mxGeometry x="640" y="530" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="V7qh8wB5WtYMs-n4lDnG-18" value="" style="endArrow=classic;html=1;rounded=0;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="WIyWlLk6GJQsqaUBKTNV-1" source="V7qh8wB5WtYMs-n4lDnG-10" target="V7qh8wB5WtYMs-n4lDnG-17">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="870" y="490" as="sourcePoint" />
<mxPoint x="920" y="440" as="targetPoint" />
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View file

@ -9,6 +9,21 @@
# Config pertaining to instance federation settings, pages to hide/expose, etc. # Config pertaining to instance federation settings, pages to hide/expose, etc.
# String. Federation mode to use for this instance.
#
# "blocklist" -- open federation by default. Only instances that are explicitly
# blocked will be denied (unless they are also explicitly allowed).
#
# "allowlist" -- closed federation by default. Only instances that are explicitly
# allowed will be able to interact with this instance.
#
# For more details on blocklist and allowlist modes, check the documentation at:
# https://docs.gotosocial.org/en/latest/admin/federation_modes
#
# Options: ["blocklist", "allowlist"]
# Default: "blocklist"
instance-federation-mode: "blocklist"
# Bool. Allow unauthenticated users to make queries to /api/v1/instance/peers?filter=open in order # Bool. Allow unauthenticated users to make queries to /api/v1/instance/peers?filter=open in order
# to see a list of instances that this instance 'peers' with. Even if set to 'false', then authenticated # to see a list of instances that this instance 'peers' with. Even if set to 'false', then authenticated
# users (members of the instance) will still be able to query the endpoint. # users (members of the instance) will still be able to query the endpoint.
@ -17,9 +32,12 @@
instance-expose-peers: false instance-expose-peers: false
# Bool. Allow unauthenticated users to make queries to /api/v1/instance/peers?filter=suspended in order # Bool. Allow unauthenticated users to make queries to /api/v1/instance/peers?filter=suspended in order
# to see a list of instances that this instance blocks/suspends. This will also allow unauthenticated # to see a list of instances that this instance blocks/suspends. Even if set to 'false', then authenticated
# users to see the list through the web UI. Even if set to 'false', then authenticated users (members # users (members of the instance) will still be able to query the endpoint.
# of the instance) will still be able to query the endpoint. #
# WARNING: Setting this variable to 'true' may result in your instance being scraped by blocklist scrapers.
# See: https://docs.gotosocial.org/en/latest/admin/domain_blocks/#block-announce-bots
#
# Options: [true, false] # Options: [true, false]
# Default: false # Default: false
instance-expose-suspended: false instance-expose-suspended: false
@ -49,4 +67,13 @@ instance-expose-public-timeline: false
# Options: [true, false] # Options: [true, false]
# Default: true # Default: true
instance-deliver-to-shared-inboxes: true instance-deliver-to-shared-inboxes: true
# Bool. This flag will inject a Mastodon version into the version field that
# is included in /api/v1/instance. This version is often used by Mastodon clients
# to do API feature detection. By injecting a Mastodon compatible version, it is
# possible to cajole those clients to behave correctly with GoToSocial.
#
# Options: [true, false]
# Default: false
instance-inject-mastodon-version: false
``` ```

View file

@ -46,5 +46,4 @@ Take a look at the [list of open bugs](https://github.com/superseriousbusiness/g
- polls - polls
- scheduling posts - scheduling posts
- account migration - account migration
- federated hashtag search
- shared block lists across servers - shared block lists across servers

View file

@ -81,7 +81,7 @@ host: "localhost"
# DO NOT change this after your server has already run once, or you will break things! # DO NOT change this after your server has already run once, or you will break things!
# #
# Please read the appropriate section of the installation guide before you go messing around with this setting: # Please read the appropriate section of the installation guide before you go messing around with this setting:
# https://docs.gotosocial.org/installation_guide/advanced/#can-i-host-my-instance-at-fediexampleorg-but-have-just-exampleorg-in-my-username # https://docs.gotosocial.org/en/latest/advanced/host-account-domain/
# #
# Examples: ["example.org","server.com"] # Examples: ["example.org","server.com"]
# Default: "" # Default: ""
@ -272,6 +272,21 @@ web-asset-base-dir: "./web/assets/"
# Config pertaining to instance federation settings, pages to hide/expose, etc. # Config pertaining to instance federation settings, pages to hide/expose, etc.
# String. Federation mode to use for this instance.
#
# "blocklist" -- open federation by default. Only instances that are explicitly
# blocked will be denied (unless they are also explicitly allowed).
#
# "allowlist" -- closed federation by default. Only instances that are explicitly
# allowed will be able to interact with this instance.
#
# For more details on blocklist and allowlist modes, check the documentation at:
# https://docs.gotosocial.org/en/latest/admin/federation_modes
#
# Options: ["blocklist", "allowlist"]
# Default: "blocklist"
instance-federation-mode: "blocklist"
# Bool. Allow unauthenticated users to make queries to /api/v1/instance/peers?filter=open in order # Bool. Allow unauthenticated users to make queries to /api/v1/instance/peers?filter=open in order
# to see a list of instances that this instance 'peers' with. Even if set to 'false', then authenticated # to see a list of instances that this instance 'peers' with. Even if set to 'false', then authenticated
# users (members of the instance) will still be able to query the endpoint. # users (members of the instance) will still be able to query the endpoint.
@ -280,9 +295,12 @@ web-asset-base-dir: "./web/assets/"
instance-expose-peers: false instance-expose-peers: false
# Bool. Allow unauthenticated users to make queries to /api/v1/instance/peers?filter=suspended in order # Bool. Allow unauthenticated users to make queries to /api/v1/instance/peers?filter=suspended in order
# to see a list of instances that this instance blocks/suspends. This will also allow unauthenticated # to see a list of instances that this instance blocks/suspends. Even if set to 'false', then authenticated
# users to see the list through the web UI. Even if set to 'false', then authenticated users (members # users (members of the instance) will still be able to query the endpoint.
# of the instance) will still be able to query the endpoint. #
# WARNING: Setting this variable to 'true' may result in your instance being scraped by blocklist scrapers.
# See: https://docs.gotosocial.org/en/latest/admin/domain_blocks/#block-announce-bots
#
# Options: [true, false] # Options: [true, false]
# Default: false # Default: false
instance-expose-suspended: false instance-expose-suspended: false

View file

@ -31,6 +31,8 @@
EmojiCategoriesPath = EmojiPath + "/categories" EmojiCategoriesPath = EmojiPath + "/categories"
DomainBlocksPath = BasePath + "/domain_blocks" DomainBlocksPath = BasePath + "/domain_blocks"
DomainBlocksPathWithID = DomainBlocksPath + "/:" + IDKey DomainBlocksPathWithID = DomainBlocksPath + "/:" + IDKey
DomainAllowsPath = BasePath + "/domain_allows"
DomainAllowsPathWithID = DomainAllowsPath + "/:" + IDKey
DomainKeysExpirePath = BasePath + "/domain_keys_expire" DomainKeysExpirePath = BasePath + "/domain_keys_expire"
AccountsPath = BasePath + "/accounts" AccountsPath = BasePath + "/accounts"
AccountsPathWithID = AccountsPath + "/:" + IDKey AccountsPathWithID = AccountsPath + "/:" + IDKey
@ -84,6 +86,12 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
attachHandler(http.MethodGet, DomainBlocksPathWithID, m.DomainBlockGETHandler) attachHandler(http.MethodGet, DomainBlocksPathWithID, m.DomainBlockGETHandler)
attachHandler(http.MethodDelete, DomainBlocksPathWithID, m.DomainBlockDELETEHandler) attachHandler(http.MethodDelete, DomainBlocksPathWithID, m.DomainBlockDELETEHandler)
// domain allow stuff
attachHandler(http.MethodPost, DomainAllowsPath, m.DomainAllowsPOSTHandler)
attachHandler(http.MethodGet, DomainAllowsPath, m.DomainAllowsGETHandler)
attachHandler(http.MethodGet, DomainAllowsPathWithID, m.DomainAllowGETHandler)
attachHandler(http.MethodDelete, DomainAllowsPathWithID, m.DomainAllowDELETEHandler)
// domain maintenance stuff // domain maintenance stuff
attachHandler(http.MethodPost, DomainKeysExpirePath, m.DomainKeysExpirePOSTHandler) attachHandler(http.MethodPost, DomainKeysExpirePath, m.DomainKeysExpirePOSTHandler)

View file

@ -0,0 +1,128 @@
// 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 admin
import (
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// DomainAllowsPOSTHandler swagger:operation POST /api/v1/admin/domain_allows domainAllowCreate
//
// Create one or more domain allows, from a string or a file.
//
// You have two options when using this endpoint: either you can set `import` to `true` and
// upload a file containing multiple domain allows, JSON-formatted, or you can leave import as
// `false`, and just add one domain allow.
//
// The format of the json file should be something like: `[{"domain":"example.org"},{"domain":"whatever.com","public_comment":"they smell"}]`
//
// ---
// tags:
// - admin
//
// consumes:
// - multipart/form-data
//
// produces:
// - application/json
//
// parameters:
// -
// name: import
// in: query
// description: >-
// Signal that a list of domain allows is being imported as a file.
// If set to `true`, then 'domains' must be present as a JSON-formatted file.
// If set to `false`, then `domains` will be ignored, and `domain` must be present.
// type: boolean
// default: false
// -
// name: domains
// in: formData
// description: >-
// JSON-formatted list of domain allows to import.
// This is only used if `import` is set to `true`.
// type: file
// -
// name: domain
// in: formData
// description: >-
// Single domain to allow.
// Used only if `import` is not `true`.
// type: string
// -
// name: obfuscate
// in: formData
// description: >-
// Obfuscate the name of the domain when serving it publicly.
// Eg., `example.org` becomes something like `ex***e.org`.
// Used only if `import` is not `true`.
// type: boolean
// -
// name: public_comment
// in: formData
// description: >-
// Public comment about this domain allow.
// This will be displayed alongside the domain allow if you choose to share allows.
// Used only if `import` is not `true`.
// type: string
// -
// name: private_comment
// in: formData
// description: >-
// Private comment about this domain allow. Will only be shown to other admins, so this
// is a useful way of internally keeping track of why a certain domain ended up allowed.
// Used only if `import` is not `true`.
// type: string
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: >-
// The newly created domain allow, if `import` != `true`.
// If a list has been imported, then an `array` of newly created domain allows will be returned instead.
// schema:
// "$ref": "#/definitions/domainPermission"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '409':
// description: >-
// Conflict: There is already an admin action running that conflicts with this action.
// Check the error message in the response body for more information. This is a temporary
// error; it should be possible to process this action if you try again in a bit.
// '500':
// description: internal server error
func (m *Module) DomainAllowsPOSTHandler(c *gin.Context) {
m.createDomainPermissions(c,
gtsmodel.DomainPermissionAllow,
m.processor.Admin().DomainPermissionCreate,
m.processor.Admin().DomainPermissionsImport,
)
}

View file

@ -0,0 +1,72 @@
// 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 admin
import (
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// DomainAllowDELETEHandler swagger:operation DELETE /api/v1/admin/domain_allows/{id} domainAllowDelete
//
// Delete domain allow with the given ID.
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: The id of the domain allow.
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: The domain allow that was just deleted.
// schema:
// "$ref": "#/definitions/domainPermission"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '409':
// description: >-
// Conflict: There is already an admin action running that conflicts with this action.
// Check the error message in the response body for more information. This is a temporary
// error; it should be possible to process this action if you try again in a bit.
// '500':
// description: internal server error
func (m *Module) DomainAllowDELETEHandler(c *gin.Context) {
m.deleteDomainPermission(c, gtsmodel.DomainPermissionAllow)
}

View file

@ -0,0 +1,67 @@
// 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 admin
import (
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// DomainAllowGETHandler swagger:operation GET /api/v1/admin/domain_allows/{id} domainAllowGet
//
// View domain allow with the given ID.
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: The id of the domain allow.
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: The requested domain allow.
// schema:
// "$ref": "#/definitions/domainPermission"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) DomainAllowGETHandler(c *gin.Context) {
m.getDomainPermission(c, gtsmodel.DomainPermissionAllow)
}

View file

@ -0,0 +1,73 @@
// 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 admin
import (
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// DomainAllowsGETHandler swagger:operation GET /api/v1/admin/domain_allows domainAllowsGet
//
// View all domain allows currently in place.
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: export
// type: boolean
// description: >-
// If set to `true`, then each entry in the returned list of domain allows will only consist of
// the fields `domain` and `public_comment`. This is perfect for when you want to save and share
// a list of all the domains you have allowed on your instance, so that someone else can easily import them,
// but you don't want them to see the database IDs of your allows, or private comments etc.
// in: query
// required: false
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '200':
// description: All domain allows currently in place.
// schema:
// type: array
// items:
// "$ref": "#/definitions/domainPermission"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) DomainAllowsGETHandler(c *gin.Context) {
m.getDomainPermissions(c, gtsmodel.DomainPermissionAllow)
}

View file

@ -18,15 +18,8 @@
package admin package admin
import ( import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
) )
// DomainBlocksPOSTHandler swagger:operation POST /api/v1/admin/domain_blocks domainBlockCreate // DomainBlocksPOSTHandler swagger:operation POST /api/v1/admin/domain_blocks domainBlockCreate
@ -108,7 +101,7 @@
// The newly created domain block, if `import` != `true`. // The newly created domain block, if `import` != `true`.
// If a list has been imported, then an `array` of newly created domain blocks will be returned instead. // If a list has been imported, then an `array` of newly created domain blocks will be returned instead.
// schema: // schema:
// "$ref": "#/definitions/domainBlock" // "$ref": "#/definitions/domainPermission"
// '400': // '400':
// description: bad request // description: bad request
// '401': // '401':
@ -127,108 +120,9 @@
// '500': // '500':
// description: internal server error // description: internal server error
func (m *Module) DomainBlocksPOSTHandler(c *gin.Context) { func (m *Module) DomainBlocksPOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true) m.createDomainPermissions(c,
if err != nil { gtsmodel.DomainPermissionBlock,
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) m.processor.Admin().DomainPermissionCreate,
return m.processor.Admin().DomainPermissionsImport,
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
importing, errWithCode := apiutil.ParseDomainBlockImport(c.Query(apiutil.DomainBlockImportKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
form := new(apimodel.DomainBlockCreateRequest)
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
if err := validateCreateDomainBlock(form, importing); err != nil {
err := fmt.Errorf("error validating form: %w", err)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !importing {
// Single domain block creation.
domainBlock, _, errWithCode := m.processor.Admin().DomainBlockCreate(
c.Request.Context(),
authed.Account,
form.Domain,
form.Obfuscate,
form.PublicComment,
form.PrivateComment,
"", // No sub ID for single block creation.
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, domainBlock)
return
}
// We're importing multiple domain blocks,
// so we're looking at a multi-status response.
multiStatus, errWithCode := m.processor.Admin().DomainBlocksImport(
c.Request.Context(),
authed.Account,
form.Domains, // Pass the file through.
) )
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
// TODO: Return 207 and multiStatus data nicely
// when supported by the admin panel.
if multiStatus.Metadata.Failure != 0 {
failures := make(map[string]any, multiStatus.Metadata.Failure)
for _, entry := range multiStatus.Data {
// nolint:forcetypeassert
failures[entry.Resource.(string)] = entry.Message
}
err := fmt.Errorf("one or more errors importing domain blocks: %+v", failures)
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1)
return
}
// Success, return slice of domain blocks.
domainBlocks := make([]any, 0, multiStatus.Metadata.Success)
for _, entry := range multiStatus.Data {
domainBlocks = append(domainBlocks, entry.Resource)
}
c.JSON(http.StatusOK, domainBlocks)
}
func validateCreateDomainBlock(form *apimodel.DomainBlockCreateRequest, imp bool) error {
if imp {
if form.Domains.Size == 0 {
return errors.New("import was specified but list of domains is empty")
}
} else {
// add some more validation here later if necessary
if form.Domain == "" {
return errors.New("empty domain provided")
}
}
return nil
} }

View file

@ -18,14 +18,8 @@
package admin package admin
import ( import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
) )
// DomainBlockDELETEHandler swagger:operation DELETE /api/v1/admin/domain_blocks/{id} domainBlockDelete // DomainBlockDELETEHandler swagger:operation DELETE /api/v1/admin/domain_blocks/{id} domainBlockDelete
@ -55,7 +49,7 @@
// '200': // '200':
// description: The domain block that was just deleted. // description: The domain block that was just deleted.
// schema: // schema:
// "$ref": "#/definitions/domainBlock" // "$ref": "#/definitions/domainPermission"
// '400': // '400':
// description: bad request // description: bad request
// '401': // '401':
@ -74,35 +68,5 @@
// '500': // '500':
// description: internal server error // description: internal server error
func (m *Module) DomainBlockDELETEHandler(c *gin.Context) { func (m *Module) DomainBlockDELETEHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true) m.deleteDomainPermission(c, gtsmodel.DomainPermissionBlock)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
domainBlockID := c.Param(IDKey)
if domainBlockID == "" {
err := errors.New("no domain block id specified")
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
domainBlock, _, errWithCode := m.processor.Admin().DomainBlockDelete(c.Request.Context(), authed.Account, domainBlockID)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, domainBlock)
} }

View file

@ -18,13 +18,8 @@
package admin package admin
import ( import (
"fmt"
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
) )
// DomainBlockGETHandler swagger:operation GET /api/v1/admin/domain_blocks/{id} domainBlockGet // DomainBlockGETHandler swagger:operation GET /api/v1/admin/domain_blocks/{id} domainBlockGet
@ -54,7 +49,7 @@
// '200': // '200':
// description: The requested domain block. // description: The requested domain block.
// schema: // schema:
// "$ref": "#/definitions/domainBlock" // "$ref": "#/definitions/domainPermission"
// '400': // '400':
// description: bad request // description: bad request
// '401': // '401':
@ -68,40 +63,5 @@
// '500': // '500':
// description: internal server error // description: internal server error
func (m *Module) DomainBlockGETHandler(c *gin.Context) { func (m *Module) DomainBlockGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true) m.getDomainPermission(c, gtsmodel.DomainPermissionBlock)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
domainBlockID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
export, errWithCode := apiutil.ParseDomainBlockExport(c.Query(apiutil.DomainBlockExportKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
domainBlock, errWithCode := m.processor.Admin().DomainBlockGet(c.Request.Context(), domainBlockID, export)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, domainBlock)
} }

View file

@ -18,13 +18,8 @@
package admin package admin
import ( import (
"fmt"
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
) )
// DomainBlocksGETHandler swagger:operation GET /api/v1/admin/domain_blocks domainBlocksGet // DomainBlocksGETHandler swagger:operation GET /api/v1/admin/domain_blocks domainBlocksGet
@ -60,7 +55,7 @@
// schema: // schema:
// type: array // type: array
// items: // items:
// "$ref": "#/definitions/domainBlock" // "$ref": "#/definitions/domainPermission"
// '400': // '400':
// description: bad request // description: bad request
// '401': // '401':
@ -74,34 +69,5 @@
// '500': // '500':
// description: internal server error // description: internal server error
func (m *Module) DomainBlocksGETHandler(c *gin.Context) { func (m *Module) DomainBlocksGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true) m.getDomainPermissions(c, gtsmodel.DomainPermissionBlock)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
export, errWithCode := apiutil.ParseDomainBlockExport(c.Query(apiutil.DomainBlockExportKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
domainBlocks, errWithCode := m.processor.Admin().DomainBlocksGet(c.Request.Context(), authed.Account, export)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, domainBlocks)
} }

View file

@ -0,0 +1,295 @@
// 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 admin
import (
"context"
"errors"
"fmt"
"mime/multipart"
"net/http"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
type singleDomainPermCreate func(
context.Context,
gtsmodel.DomainPermissionType, // block/allow
*gtsmodel.Account, // admin account
string, // domain
bool, // obfuscate
string, // publicComment
string, // privateComment
string, // subscriptionID
) (*apimodel.DomainPermission, string, gtserror.WithCode)
type multiDomainPermCreate func(
context.Context,
gtsmodel.DomainPermissionType, // block/allow
*gtsmodel.Account, // admin account
*multipart.FileHeader, // domains
) (*apimodel.MultiStatus, gtserror.WithCode)
// createDomainPemissions either creates a single domain
// permission entry (block/allow) or imports multiple domain
// permission entries (multiple blocks, multiple allows)
// using the given functions.
//
// Handling the creation of both types of permissions in
// one function in this way reduces code duplication.
func (m *Module) createDomainPermissions(
c *gin.Context,
permType gtsmodel.DomainPermissionType,
single singleDomainPermCreate,
multi multiDomainPermCreate,
) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
importing, errWithCode := apiutil.ParseDomainPermissionImport(c.Query(apiutil.DomainPermissionImportKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
// Parse + validate form.
form := new(apimodel.DomainPermissionRequest)
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
if importing && form.Domains.Size == 0 {
err = errors.New("import was specified but list of domains is empty")
} else if form.Domain == "" {
err = errors.New("empty domain provided")
}
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !importing {
// Single domain permission creation.
domainBlock, _, errWithCode := single(
c.Request.Context(),
permType,
authed.Account,
form.Domain,
form.Obfuscate,
form.PublicComment,
form.PrivateComment,
"", // No sub ID for single perm creation.
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, domainBlock)
return
}
// We're importing multiple domain permissions,
// so we're looking at a multi-status response.
multiStatus, errWithCode := multi(
c.Request.Context(),
permType,
authed.Account,
form.Domains, // Pass the file through.
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
// TODO: Return 207 and multiStatus data nicely
// when supported by the admin panel.
if multiStatus.Metadata.Failure != 0 {
failures := make(map[string]any, multiStatus.Metadata.Failure)
for _, entry := range multiStatus.Data {
// nolint:forcetypeassert
failures[entry.Resource.(string)] = entry.Message
}
err := fmt.Errorf("one or more errors importing domain %ss: %+v", permType.String(), failures)
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, err.Error()), m.processor.InstanceGetV1)
return
}
// Success, return slice of newly-created domain perms.
domainPerms := make([]any, 0, multiStatus.Metadata.Success)
for _, entry := range multiStatus.Data {
domainPerms = append(domainPerms, entry.Resource)
}
c.JSON(http.StatusOK, domainPerms)
}
// deleteDomainPermission deletes a single domain permission (block or allow).
func (m *Module) deleteDomainPermission(
c *gin.Context,
permType gtsmodel.DomainPermissionType, // block/allow
) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
domainPermID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
domainPerm, _, errWithCode := m.processor.Admin().DomainPermissionDelete(
c.Request.Context(),
permType,
authed.Account,
domainPermID,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, domainPerm)
}
// getDomainPermission gets a single domain permission (block or allow).
func (m *Module) getDomainPermission(
c *gin.Context,
permType gtsmodel.DomainPermissionType,
) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
domainPermID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
export, errWithCode := apiutil.ParseDomainPermissionExport(c.Query(apiutil.DomainPermissionExportKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
domainPerm, errWithCode := m.processor.Admin().DomainPermissionGet(
c.Request.Context(),
permType,
domainPermID,
export,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, domainPerm)
}
// getDomainPermissions gets all domain permissions of the given type (block, allow).
func (m *Module) getDomainPermissions(
c *gin.Context,
permType gtsmodel.DomainPermissionType,
) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
export, errWithCode := apiutil.ParseDomainPermissionExport(c.Query(apiutil.DomainPermissionExportKey), false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
domainPerm, errWithCode := m.processor.Admin().DomainPermissionsGet(
c.Request.Context(),
permType,
authed.Account,
export,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusOK, domainPerm)
}

View file

@ -37,46 +37,53 @@ type Domain struct {
PublicComment string `form:"public_comment" json:"public_comment,omitempty"` PublicComment string `form:"public_comment" json:"public_comment,omitempty"`
} }
// DomainBlock represents a block on one domain // DomainPermission represents a permission applied to one domain (explicit block/allow).
// //
// swagger:model domainBlock // swagger:model domainPermission
type DomainBlock struct { type DomainPermission struct {
Domain Domain
// The ID of the domain block. // The ID of the domain permission entry.
// example: 01FBW21XJA09XYX51KV5JVBW0F // example: 01FBW21XJA09XYX51KV5JVBW0F
// readonly: true // readonly: true
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
// Obfuscate the domain name when serving this domain block publicly. // Obfuscate the domain name when serving this domain permission entry publicly.
// A useful anti-harassment tool.
// example: false // example: false
Obfuscate bool `json:"obfuscate,omitempty"` Obfuscate bool `json:"obfuscate,omitempty"`
// Private comment for this block, visible to our instance admins only. // Private comment for this permission entry, visible to this instance's admins only.
// example: they are poopoo // example: they are poopoo
PrivateComment string `json:"private_comment,omitempty"` PrivateComment string `json:"private_comment,omitempty"`
// The ID of the subscription that created/caused this domain block. // If applicable, the ID of the subscription that caused this domain permission entry to be created.
// example: 01FBW25TF5J67JW3HFHZCSD23K // example: 01FBW25TF5J67JW3HFHZCSD23K
SubscriptionID string `json:"subscription_id,omitempty"` SubscriptionID string `json:"subscription_id,omitempty"`
// ID of the account that created this domain block. // ID of the account that created this domain permission entry.
// example: 01FBW2758ZB6PBR200YPDDJK4C // example: 01FBW2758ZB6PBR200YPDDJK4C
CreatedBy string `json:"created_by,omitempty"` CreatedBy string `json:"created_by,omitempty"`
// Time at which this block was created (ISO 8601 Datetime). // Time at which the permission entry was created (ISO 8601 Datetime).
// example: 2021-07-30T09:20:25+00:00 // example: 2021-07-30T09:20:25+00:00
CreatedAt string `json:"created_at,omitempty"` CreatedAt string `json:"created_at,omitempty"`
} }
// DomainBlockCreateRequest is the form submitted as a POST to /api/v1/admin/domain_blocks to create a new block. // DomainPermissionRequest is the form submitted as a POST to create a new domain permission entry (allow/block).
// //
// swagger:model domainBlockCreateRequest // swagger:model domainPermissionCreateRequest
type DomainBlockCreateRequest struct { type DomainPermissionRequest struct {
// A list of domains to block. Only used if import=true is specified. // A list of domains for which this permission request should apply.
// Only used if import=true is specified.
Domains *multipart.FileHeader `form:"domains" json:"domains" xml:"domains"` Domains *multipart.FileHeader `form:"domains" json:"domains" xml:"domains"`
// hostname/domain to block // A single domain for which this permission request should apply.
// Only used if import=true is NOT specified or if import=false.
// example: example.org
Domain string `form:"domain" json:"domain" xml:"domain"` Domain string `form:"domain" json:"domain" xml:"domain"`
// whether the domain should be obfuscated when being displayed publicly // Obfuscate the domain name when displaying this permission entry publicly.
// Ie., instead of 'example.org' show something like 'e**mpl*.or*'.
// example: false
Obfuscate bool `form:"obfuscate" json:"obfuscate" xml:"obfuscate"` Obfuscate bool `form:"obfuscate" json:"obfuscate" xml:"obfuscate"`
// private comment for other admins on why the domain was blocked // Private comment for other admins on why this permission entry was created.
// example: don't like 'em!!!!
PrivateComment string `form:"private_comment" json:"private_comment" xml:"private_comment"` PrivateComment string `form:"private_comment" json:"private_comment" xml:"private_comment"`
// public comment on the reason for the domain block // Public comment on why this permission entry was created.
// Will be visible to requesters at /api/v1/instance/peers if this endpoint is exposed.
// example: foss dorks 😫
PublicComment string `form:"public_comment" json:"public_comment" xml:"public_comment"` PublicComment string `form:"public_comment" json:"public_comment" xml:"public_comment"`
} }

View file

@ -60,10 +60,10 @@
WebUsernameKey = "username" WebUsernameKey = "username"
WebStatusIDKey = "status" WebStatusIDKey = "status"
/* Domain block keys */ /* Domain permission keys */
DomainBlockExportKey = "export" DomainPermissionExportKey = "export"
DomainBlockImportKey = "import" DomainPermissionImportKey = "import"
) )
// parseError returns gtserror.WithCode set to 400 Bad Request, to indicate // parseError returns gtserror.WithCode set to 400 Bad Request, to indicate
@ -121,12 +121,12 @@ func ParseSearchResolve(value string, defaultValue bool) (bool, gtserror.WithCod
return parseBool(value, defaultValue, SearchResolveKey) return parseBool(value, defaultValue, SearchResolveKey)
} }
func ParseDomainBlockExport(value string, defaultValue bool) (bool, gtserror.WithCode) { func ParseDomainPermissionExport(value string, defaultValue bool) (bool, gtserror.WithCode) {
return parseBool(value, defaultValue, DomainBlockExportKey) return parseBool(value, defaultValue, DomainPermissionExportKey)
} }
func ParseDomainBlockImport(value string, defaultValue bool) (bool, gtserror.WithCode) { func ParseDomainPermissionImport(value string, defaultValue bool) (bool, gtserror.WithCode) {
return parseBool(value, defaultValue, DomainBlockImportKey) return parseBool(value, defaultValue, DomainPermissionImportKey)
} }
/* /*

View file

@ -26,23 +26,28 @@
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
) )
// BlockCache provides a means of caching domain blocks in memory to reduce load // Cache provides a means of caching domains in memory to reduce
// on an underlying storage mechanism, e.g. a database. // load on an underlying storage mechanism, e.g. a database.
// //
// The in-memory block list is kept up-to-date by means of a passed loader function during every // The in-memory domain list is kept up-to-date by means of a passed
// call to .IsBlocked(). In the case of a nil internal block list, the loader function is called to // loader function during every call to .Matches(). In the case of
// hydrate the cache with the latest list of domain blocks. The .Clear() function can be used to // a nil internal domain list, the loader function is called to hydrate
// invalidate the cache, e.g. when a domain block is added / deleted from the database. // the cache with the latest list of domains.
type BlockCache struct { //
// The .Clear() function can be used to invalidate the cache,
// e.g. when an entry is added / deleted from the database.
type Cache struct {
// atomically updated ptr value to the // atomically updated ptr value to the
// current domain block cache radix trie. // current domain cache radix trie.
rootptr unsafe.Pointer rootptr unsafe.Pointer
} }
// IsBlocked checks whether domain is blocked. If the cache is not currently loaded, then the provided load function is used to hydrate it. // Matches checks whether domain matches an entry in the cache.
func (b *BlockCache) IsBlocked(domain string, load func() ([]string, error)) (bool, error) { // If the cache is not currently loaded, then the provided load
// function is used to hydrate it.
func (c *Cache) Matches(domain string, load func() ([]string, error)) (bool, error) {
// Load the current root pointer value. // Load the current root pointer value.
ptr := atomic.LoadPointer(&b.rootptr) ptr := atomic.LoadPointer(&c.rootptr)
if ptr == nil { if ptr == nil {
// Cache is not hydrated. // Cache is not hydrated.
@ -67,7 +72,7 @@ func (b *BlockCache) IsBlocked(domain string, load func() ([]string, error)) (bo
// Store the new node ptr. // Store the new node ptr.
ptr = unsafe.Pointer(root) ptr = unsafe.Pointer(root)
atomic.StorePointer(&b.rootptr, ptr) atomic.StorePointer(&c.rootptr, ptr)
} }
// Look for a match in the trie node. // Look for a match in the trie node.
@ -75,22 +80,20 @@ func (b *BlockCache) IsBlocked(domain string, load func() ([]string, error)) (bo
} }
// Clear will drop the currently loaded domain list, // Clear will drop the currently loaded domain list,
// triggering a reload on next call to .IsBlocked(). // triggering a reload on next call to .Matches().
func (b *BlockCache) Clear() { func (c *Cache) Clear() {
atomic.StorePointer(&b.rootptr, nil) atomic.StorePointer(&c.rootptr, nil)
} }
// String returns a string representation of stored domains in block cache. // String returns a string representation of stored domains in cache.
func (b *BlockCache) String() string { func (c *Cache) String() string {
if ptr := atomic.LoadPointer(&b.rootptr); ptr != nil { if ptr := atomic.LoadPointer(&c.rootptr); ptr != nil {
return (*root)(ptr).String() return (*root)(ptr).String()
} }
return "<empty>" return "<empty>"
} }
// root is the root node in the domain // root is the root node in the domain cache radix trie. this is the singular access point to the trie.
// block cache radix trie. this is the
// singular access point to the trie.
type root struct{ root node } type root struct{ root node }
// Add will add the given domain to the radix trie. // Add will add the given domain to the radix trie.
@ -99,14 +102,14 @@ func (r *root) Add(domain string) {
} }
// Match will return whether the given domain matches // Match will return whether the given domain matches
// an existing stored domain block in this radix trie. // an existing stored domain in this radix trie.
func (r *root) Match(domain string) bool { func (r *root) Match(domain string) bool {
return r.root.match(strings.Split(domain, ".")) return r.root.match(strings.Split(domain, "."))
} }
// Sort will sort the entire radix trie ensuring that // Sort will sort the entire radix trie ensuring that
// child nodes are stored in alphabetical order. This // child nodes are stored in alphabetical order. This
// MUST be done to finalize the block cache in order // MUST be done to finalize the domain cache in order
// to speed up the binary search of node child parts. // to speed up the binary search of node child parts.
func (r *root) Sort() { func (r *root) Sort() {
r.root.sort() r.root.sort()
@ -154,7 +157,7 @@ func (n *node) add(parts []string) {
if len(parts) == 0 { if len(parts) == 0 {
// Drop all children here as // Drop all children here as
// this is a higher-level block // this is a higher-level domain
// than that we previously had. // than that we previously had.
nn.child = nil nn.child = nil
return return

View file

@ -24,21 +24,21 @@
"github.com/superseriousbusiness/gotosocial/internal/cache/domain" "github.com/superseriousbusiness/gotosocial/internal/cache/domain"
) )
func TestBlockCache(t *testing.T) { func TestCache(t *testing.T) {
c := new(domain.BlockCache) c := new(domain.Cache)
blocks := []string{ cachedDomains := []string{
"google.com", "google.com",
"google.co.uk", "google.co.uk",
"pleroma.bad.host", "pleroma.bad.host",
} }
loader := func() ([]string, error) { loader := func() ([]string, error) {
t.Log("load: returning blocked domains") t.Log("load: returning cached domains")
return blocks, nil return cachedDomains, nil
} }
// Check a list of known blocked domains. // Check a list of known cached domains.
for _, domain := range []string{ for _, domain := range []string{
"google.com", "google.com",
"mail.google.com", "mail.google.com",
@ -47,13 +47,13 @@ func TestBlockCache(t *testing.T) {
"pleroma.bad.host", "pleroma.bad.host",
"dev.pleroma.bad.host", "dev.pleroma.bad.host",
} { } {
t.Logf("checking domain is blocked: %s", domain) t.Logf("checking domain matches: %s", domain)
if b, _ := c.IsBlocked(domain, loader); !b { if b, _ := c.Matches(domain, loader); !b {
t.Errorf("domain should be blocked: %s", domain) t.Errorf("domain should be matched: %s", domain)
} }
} }
// Check a list of known unblocked domains. // Check a list of known uncached domains.
for _, domain := range []string{ for _, domain := range []string{
"askjeeves.com", "askjeeves.com",
"ask-kim.co.uk", "ask-kim.co.uk",
@ -62,9 +62,9 @@ func TestBlockCache(t *testing.T) {
"gts.bad.host", "gts.bad.host",
"mastodon.bad.host", "mastodon.bad.host",
} { } {
t.Logf("checking domain isn't blocked: %s", domain) t.Logf("checking domain isn't matched: %s", domain)
if b, _ := c.IsBlocked(domain, loader); b { if b, _ := c.Matches(domain, loader); b {
t.Errorf("domain should not be blocked: %s", domain) t.Errorf("domain should not be matched: %s", domain)
} }
} }
@ -76,10 +76,10 @@ func TestBlockCache(t *testing.T) {
knownErr := errors.New("known error") knownErr := errors.New("known error")
// Check that reload is actually performed and returns our error // Check that reload is actually performed and returns our error
if _, err := c.IsBlocked("", func() ([]string, error) { if _, err := c.Matches("", func() ([]string, error) {
t.Log("load: returning known error") t.Log("load: returning known error")
return nil, knownErr return nil, knownErr
}); !errors.Is(err, knownErr) { }); !errors.Is(err, knownErr) {
t.Errorf("is blocked did not return expected error: %v", err) t.Errorf("matches did not return expected error: %v", err)
} }
} }

17
internal/cache/gts.go vendored
View file

@ -36,7 +36,8 @@ type GTSCaches struct {
block *result.Cache[*gtsmodel.Block] block *result.Cache[*gtsmodel.Block]
blockIDs *SliceCache[string] blockIDs *SliceCache[string]
boostOfIDs *SliceCache[string] boostOfIDs *SliceCache[string]
domainBlock *domain.BlockCache domainAllow *domain.Cache
domainBlock *domain.Cache
emoji *result.Cache[*gtsmodel.Emoji] emoji *result.Cache[*gtsmodel.Emoji]
emojiCategory *result.Cache[*gtsmodel.EmojiCategory] emojiCategory *result.Cache[*gtsmodel.EmojiCategory]
follow *result.Cache[*gtsmodel.Follow] follow *result.Cache[*gtsmodel.Follow]
@ -72,6 +73,7 @@ func (c *GTSCaches) Init() {
c.initBlock() c.initBlock()
c.initBlockIDs() c.initBlockIDs()
c.initBoostOfIDs() c.initBoostOfIDs()
c.initDomainAllow()
c.initDomainBlock() c.initDomainBlock()
c.initEmoji() c.initEmoji()
c.initEmojiCategory() c.initEmojiCategory()
@ -139,8 +141,13 @@ func (c *GTSCaches) BoostOfIDs() *SliceCache[string] {
return c.boostOfIDs return c.boostOfIDs
} }
// DomainAllow provides access to the domain allow database cache.
func (c *GTSCaches) DomainAllow() *domain.Cache {
return c.domainAllow
}
// DomainBlock provides access to the domain block database cache. // DomainBlock provides access to the domain block database cache.
func (c *GTSCaches) DomainBlock() *domain.BlockCache { func (c *GTSCaches) DomainBlock() *domain.Cache {
return c.domainBlock return c.domainBlock
} }
@ -384,8 +391,12 @@ func (c *GTSCaches) initBoostOfIDs() {
)} )}
} }
func (c *GTSCaches) initDomainAllow() {
c.domainAllow = new(domain.Cache)
}
func (c *GTSCaches) initDomainBlock() { func (c *GTSCaches) initDomainBlock() {
c.domainBlock = new(domain.BlockCache) c.domainBlock = new(domain.Cache)
} }
func (c *GTSCaches) initEmoji() { func (c *GTSCaches) initEmoji() {

View file

@ -76,12 +76,13 @@ type Configuration struct {
WebTemplateBaseDir string `name:"web-template-base-dir" usage:"Basedir for html templating files for rendering pages and composing emails."` WebTemplateBaseDir string `name:"web-template-base-dir" usage:"Basedir for html templating files for rendering pages and composing emails."`
WebAssetBaseDir string `name:"web-asset-base-dir" usage:"Directory to serve static assets from, accessible at example.org/assets/"` WebAssetBaseDir string `name:"web-asset-base-dir" usage:"Directory to serve static assets from, accessible at example.org/assets/"`
InstanceExposePeers bool `name:"instance-expose-peers" usage:"Allow unauthenticated users to query /api/v1/instance/peers?filter=open"` InstanceFederationMode string `name:"instance-federation-mode" usage:"Set instance federation mode."`
InstanceExposeSuspended bool `name:"instance-expose-suspended" usage:"Expose suspended instances via web UI, and allow unauthenticated users to query /api/v1/instance/peers?filter=suspended"` InstanceExposePeers bool `name:"instance-expose-peers" usage:"Allow unauthenticated users to query /api/v1/instance/peers?filter=open"`
InstanceExposeSuspendedWeb bool `name:"instance-expose-suspended-web" usage:"Expose list of suspended instances as webpage on /about/suspended"` InstanceExposeSuspended bool `name:"instance-expose-suspended" usage:"Expose suspended instances via web UI, and allow unauthenticated users to query /api/v1/instance/peers?filter=suspended"`
InstanceExposePublicTimeline bool `name:"instance-expose-public-timeline" usage:"Allow unauthenticated users to query /api/v1/timelines/public"` InstanceExposeSuspendedWeb bool `name:"instance-expose-suspended-web" usage:"Expose list of suspended instances as webpage on /about/suspended"`
InstanceDeliverToSharedInboxes bool `name:"instance-deliver-to-shared-inboxes" usage:"Deliver federated messages to shared inboxes, if they're available."` InstanceExposePublicTimeline bool `name:"instance-expose-public-timeline" usage:"Allow unauthenticated users to query /api/v1/timelines/public"`
InstanceInjectMastodonVersion bool `name:"instance-inject-mastodon-version" usage:"This injects a Mastodon compatible version in /api/v1/instance to help Mastodon clients that use that version for feature detection"` InstanceDeliverToSharedInboxes bool `name:"instance-deliver-to-shared-inboxes" usage:"Deliver federated messages to shared inboxes, if they're available."`
InstanceInjectMastodonVersion bool `name:"instance-inject-mastodon-version" usage:"This injects a Mastodon compatible version in /api/v1/instance to help Mastodon clients that use that version for feature detection"`
AccountsRegistrationOpen bool `name:"accounts-registration-open" usage:"Allow anyone to submit an account signup request. If false, server will be invite-only."` AccountsRegistrationOpen bool `name:"accounts-registration-open" usage:"Allow anyone to submit an account signup request. If false, server will be invite-only."`
AccountsApprovalRequired bool `name:"accounts-approval-required" usage:"Do account signups require approval by an admin or moderator before user can log in? If false, new registrations will be automatically approved."` AccountsApprovalRequired bool `name:"accounts-approval-required" usage:"Do account signups require approval by an admin or moderator before user can log in? If false, new registrations will be automatically approved."`

26
internal/config/const.go Normal file
View file

@ -0,0 +1,26 @@
// 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 config
// Instance federation mode determines how this
// instance federates with others (if at all).
const (
InstanceFederationModeBlocklist = "blocklist"
InstanceFederationModeAllowlist = "allowlist"
InstanceFederationModeDefault = InstanceFederationModeBlocklist
)

View file

@ -57,6 +57,7 @@
WebTemplateBaseDir: "./web/template/", WebTemplateBaseDir: "./web/template/",
WebAssetBaseDir: "./web/assets/", WebAssetBaseDir: "./web/assets/",
InstanceFederationMode: InstanceFederationModeDefault,
InstanceExposePeers: false, InstanceExposePeers: false,
InstanceExposeSuspended: false, InstanceExposeSuspended: false,
InstanceExposeSuspendedWeb: false, InstanceExposeSuspendedWeb: false,

View file

@ -83,6 +83,7 @@ func (s *ConfigState) AddServerFlags(cmd *cobra.Command) {
cmd.Flags().String(WebAssetBaseDirFlag(), cfg.WebAssetBaseDir, fieldtag("WebAssetBaseDir", "usage")) cmd.Flags().String(WebAssetBaseDirFlag(), cfg.WebAssetBaseDir, fieldtag("WebAssetBaseDir", "usage"))
// Instance // Instance
cmd.Flags().String(InstanceFederationModeFlag(), cfg.InstanceFederationMode, fieldtag("InstanceFederationMode", "usage"))
cmd.Flags().Bool(InstanceExposePeersFlag(), cfg.InstanceExposePeers, fieldtag("InstanceExposePeers", "usage")) cmd.Flags().Bool(InstanceExposePeersFlag(), cfg.InstanceExposePeers, fieldtag("InstanceExposePeers", "usage"))
cmd.Flags().Bool(InstanceExposeSuspendedFlag(), cfg.InstanceExposeSuspended, fieldtag("InstanceExposeSuspended", "usage")) cmd.Flags().Bool(InstanceExposeSuspendedFlag(), cfg.InstanceExposeSuspended, fieldtag("InstanceExposeSuspended", "usage"))
cmd.Flags().Bool(InstanceExposeSuspendedWebFlag(), cfg.InstanceExposeSuspendedWeb, fieldtag("InstanceExposeSuspendedWeb", "usage")) cmd.Flags().Bool(InstanceExposeSuspendedWebFlag(), cfg.InstanceExposeSuspendedWeb, fieldtag("InstanceExposeSuspendedWeb", "usage"))

View file

@ -749,6 +749,31 @@ func GetWebAssetBaseDir() string { return global.GetWebAssetBaseDir() }
// SetWebAssetBaseDir safely sets the value for global configuration 'WebAssetBaseDir' field // SetWebAssetBaseDir safely sets the value for global configuration 'WebAssetBaseDir' field
func SetWebAssetBaseDir(v string) { global.SetWebAssetBaseDir(v) } func SetWebAssetBaseDir(v string) { global.SetWebAssetBaseDir(v) }
// GetInstanceFederationMode safely fetches the Configuration value for state's 'InstanceFederationMode' field
func (st *ConfigState) GetInstanceFederationMode() (v string) {
st.mutex.RLock()
v = st.config.InstanceFederationMode
st.mutex.RUnlock()
return
}
// SetInstanceFederationMode safely sets the Configuration value for state's 'InstanceFederationMode' field
func (st *ConfigState) SetInstanceFederationMode(v string) {
st.mutex.Lock()
defer st.mutex.Unlock()
st.config.InstanceFederationMode = v
st.reloadToViper()
}
// InstanceFederationModeFlag returns the flag name for the 'InstanceFederationMode' field
func InstanceFederationModeFlag() string { return "instance-federation-mode" }
// GetInstanceFederationMode safely fetches the value for global configuration 'InstanceFederationMode' field
func GetInstanceFederationMode() string { return global.GetInstanceFederationMode() }
// SetInstanceFederationMode safely sets the value for global configuration 'InstanceFederationMode' field
func SetInstanceFederationMode(v string) { global.SetInstanceFederationMode(v) }
// GetInstanceExposePeers safely fetches the Configuration value for state's 'InstanceExposePeers' field // GetInstanceExposePeers safely fetches the Configuration value for state's 'InstanceExposePeers' field
func (st *ConfigState) GetInstanceExposePeers() (v bool) { func (st *ConfigState) GetInstanceExposePeers() (v bool) {
st.mutex.RLock() st.mutex.RLock()

View file

@ -61,6 +61,17 @@ func Validate() error {
errs = append(errs, fmt.Errorf("%s must be set to either http or https, provided value was %s", ProtocolFlag(), proto)) errs = append(errs, fmt.Errorf("%s must be set to either http or https, provided value was %s", ProtocolFlag(), proto))
} }
// federation mode
switch federationMode := GetInstanceFederationMode(); federationMode {
case InstanceFederationModeBlocklist, InstanceFederationModeAllowlist:
// no problem
break
case "":
errs = append(errs, fmt.Errorf("%s must be set", InstanceFederationModeFlag()))
default:
errs = append(errs, fmt.Errorf("%s must be set to either blocklist or allowlist, provided value was %s", InstanceFederationModeFlag(), federationMode))
}
webAssetsBaseDir := GetWebAssetBaseDir() webAssetsBaseDir := GetWebAssetBaseDir()
if webAssetsBaseDir == "" { if webAssetsBaseDir == "" {
errs = append(errs, fmt.Errorf("%s must be set", WebAssetBaseDirFlag())) errs = append(errs, fmt.Errorf("%s must be set", WebAssetBaseDirFlag()))

View file

@ -23,6 +23,7 @@
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/state" "github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/util" "github.com/superseriousbusiness/gotosocial/internal/util"
@ -34,6 +35,102 @@ type domainDB struct {
state *state.State state *state.State
} }
func (d *domainDB) CreateDomainAllow(ctx context.Context, allow *gtsmodel.DomainAllow) error {
// Normalize the domain as punycode
var err error
allow.Domain, err = util.Punify(allow.Domain)
if err != nil {
return err
}
// Attempt to store domain allow in DB
if _, err := d.db.NewInsert().
Model(allow).
Exec(ctx); err != nil {
return err
}
// Clear the domain allow cache (for later reload)
d.state.Caches.GTS.DomainAllow().Clear()
return nil
}
func (d *domainDB) GetDomainAllow(ctx context.Context, domain string) (*gtsmodel.DomainAllow, error) {
// Normalize the domain as punycode
domain, err := util.Punify(domain)
if err != nil {
return nil, err
}
// Check for easy case, domain referencing *us*
if domain == "" || domain == config.GetAccountDomain() ||
domain == config.GetHost() {
return nil, db.ErrNoEntries
}
var allow gtsmodel.DomainAllow
// Look for allow matching domain in DB
q := d.db.
NewSelect().
Model(&allow).
Where("? = ?", bun.Ident("domain_allow.domain"), domain)
if err := q.Scan(ctx); err != nil {
return nil, err
}
return &allow, nil
}
func (d *domainDB) GetDomainAllows(ctx context.Context) ([]*gtsmodel.DomainAllow, error) {
allows := []*gtsmodel.DomainAllow{}
if err := d.db.
NewSelect().
Model(&allows).
Scan(ctx); err != nil {
return nil, err
}
return allows, nil
}
func (d *domainDB) GetDomainAllowByID(ctx context.Context, id string) (*gtsmodel.DomainAllow, error) {
var allow gtsmodel.DomainAllow
q := d.db.
NewSelect().
Model(&allow).
Where("? = ?", bun.Ident("domain_allow.id"), id)
if err := q.Scan(ctx); err != nil {
return nil, err
}
return &allow, nil
}
func (d *domainDB) DeleteDomainAllow(ctx context.Context, domain string) error {
// Normalize the domain as punycode
domain, err := util.Punify(domain)
if err != nil {
return err
}
// Attempt to delete domain allow
if _, err := d.db.NewDelete().
Model((*gtsmodel.DomainAllow)(nil)).
Where("? = ?", bun.Ident("domain_allow.domain"), domain).
Exec(ctx); err != nil {
return err
}
// Clear the domain allow cache (for later reload)
d.state.Caches.GTS.DomainAllow().Clear()
return nil
}
func (d *domainDB) CreateDomainBlock(ctx context.Context, block *gtsmodel.DomainBlock) error { func (d *domainDB) CreateDomainBlock(ctx context.Context, block *gtsmodel.DomainBlock) error {
// Normalize the domain as punycode // Normalize the domain as punycode
var err error var err error
@ -137,14 +234,32 @@ func (d *domainDB) IsDomainBlocked(ctx context.Context, domain string) (bool, er
return false, err return false, err
} }
// Check for easy case, domain referencing *us* // Domain referencing *us* cannot be blocked.
if domain == "" || domain == config.GetAccountDomain() || if domain == "" || domain == config.GetAccountDomain() ||
domain == config.GetHost() { domain == config.GetHost() {
return false, nil return false, nil
} }
// Check the cache for an explicit domain allow (hydrating the cache with callback if necessary).
explicitAllow, err := d.state.Caches.GTS.DomainAllow().Matches(domain, func() ([]string, error) {
var domains []string
// Scan list of all explicitly allowed domains from DB
q := d.db.NewSelect().
Table("domain_allows").
Column("domain")
if err := q.Scan(ctx, &domains); err != nil {
return nil, err
}
return domains, nil
})
if err != nil {
return false, err
}
// Check the cache for a domain block (hydrating the cache with callback if necessary) // Check the cache for a domain block (hydrating the cache with callback if necessary)
return d.state.Caches.GTS.DomainBlock().IsBlocked(domain, func() ([]string, error) { explicitBlock, err := d.state.Caches.GTS.DomainBlock().Matches(domain, func() ([]string, error) {
var domains []string var domains []string
// Scan list of all blocked domains from DB // Scan list of all blocked domains from DB
@ -157,6 +272,35 @@ func (d *domainDB) IsDomainBlocked(ctx context.Context, domain string) (bool, er
return domains, nil return domains, nil
}) })
if err != nil {
return false, err
}
// Calculate if blocked
// based on federation mode.
switch mode := config.GetInstanceFederationMode(); mode {
case config.InstanceFederationModeBlocklist:
// Blocklist/default mode: explicit allow
// takes precedence over explicit block.
//
// Domains that have neither block
// or allow entries are allowed.
return !(explicitAllow || !explicitBlock), nil
case config.InstanceFederationModeAllowlist:
// Allowlist mode: explicit block takes
// precedence over explicit allow.
//
// Domains that have neither block
// or allow entries are blocked.
return (explicitBlock || !explicitAllow), nil
default:
// This should never happen but account
// for it anyway to make the code tidier.
return false, gtserror.Newf("unrecognized federation mode: %s", mode)
}
} }
func (d *domainDB) AreDomainsBlocked(ctx context.Context, domains []string) (bool, error) { func (d *domainDB) AreDomainsBlocked(ctx context.Context, domains []string) (bool, error) {

View file

@ -55,6 +55,59 @@ func (suite *DomainTestSuite) TestIsDomainBlocked() {
suite.WithinDuration(time.Now(), domainBlock.CreatedAt, 10*time.Second) suite.WithinDuration(time.Now(), domainBlock.CreatedAt, 10*time.Second)
} }
func (suite *DomainTestSuite) TestIsDomainBlockedWithAllow() {
ctx := context.Background()
domainBlock := &gtsmodel.DomainBlock{
ID: "01G204214Y9TNJEBX39C7G88SW",
Domain: "some.bad.apples",
CreatedByAccountID: suite.testAccounts["admin_account"].ID,
CreatedByAccount: suite.testAccounts["admin_account"],
}
// no domain block exists for the given domain yet
blocked, err := suite.db.IsDomainBlocked(ctx, domainBlock.Domain)
if err != nil {
suite.FailNow(err.Error())
}
suite.False(blocked)
// Block this domain.
if err := suite.db.CreateDomainBlock(ctx, domainBlock); err != nil {
suite.FailNow(err.Error())
}
// domain block now exists
blocked, err = suite.db.IsDomainBlocked(ctx, domainBlock.Domain)
if err != nil {
suite.FailNow(err.Error())
}
suite.True(blocked)
suite.WithinDuration(time.Now(), domainBlock.CreatedAt, 10*time.Second)
// Explicitly allow this domain.
domainAllow := &gtsmodel.DomainAllow{
ID: "01H8KY9MJQFWE712EG3VN02Y3J",
Domain: "some.bad.apples",
CreatedByAccountID: suite.testAccounts["admin_account"].ID,
CreatedByAccount: suite.testAccounts["admin_account"],
}
if err := suite.db.CreateDomainAllow(ctx, domainAllow); err != nil {
suite.FailNow(err.Error())
}
// Domain allow now exists
blocked, err = suite.db.IsDomainBlocked(ctx, domainBlock.Domain)
if err != nil {
suite.FailNow(err.Error())
}
suite.False(blocked)
}
func (suite *DomainTestSuite) TestIsDomainBlockedWildcard() { func (suite *DomainTestSuite) TestIsDomainBlockedWildcard() {
ctx := context.Background() ctx := context.Background()

View file

@ -0,0 +1,62 @@
// 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"
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"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 {
// Create domain allow.
if _, err := tx.
NewCreateTable().
Model(&gtsmodel.DomainAllow{}).
IfNotExists().
Exec(ctx); err != nil {
return err
}
// Index domain allow.
if _, err := tx.
NewCreateIndex().
Table("domain_allows").
Index("domain_allows_domain_idx").
Column("domain").
Exec(ctx); err != nil {
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

@ -26,6 +26,25 @@
// Domain contains DB functions related to domains and domain blocks. // Domain contains DB functions related to domains and domain blocks.
type Domain interface { type Domain interface {
/*
Block/allow storage + retrieval functions.
*/
// CreateDomainAllow puts the given instance-level domain allow into the database.
CreateDomainAllow(ctx context.Context, allow *gtsmodel.DomainAllow) error
// GetDomainAllow returns one instance-level domain allow with the given domain, if it exists.
GetDomainAllow(ctx context.Context, domain string) (*gtsmodel.DomainAllow, error)
// GetDomainAllowByID returns one instance-level domain allow with the given id, if it exists.
GetDomainAllowByID(ctx context.Context, id string) (*gtsmodel.DomainAllow, error)
// GetDomainAllows returns all instance-level domain allows currently enforced by this instance.
GetDomainAllows(ctx context.Context) ([]*gtsmodel.DomainAllow, error)
// DeleteDomainAllow deletes an instance-level domain allow with the given domain, if it exists.
DeleteDomainAllow(ctx context.Context, domain string) error
// CreateDomainBlock puts the given instance-level domain block into the database. // CreateDomainBlock puts the given instance-level domain block into the database.
CreateDomainBlock(ctx context.Context, block *gtsmodel.DomainBlock) error CreateDomainBlock(ctx context.Context, block *gtsmodel.DomainBlock) error
@ -41,15 +60,22 @@ type Domain interface {
// DeleteDomainBlock deletes an instance-level domain block with the given domain, if it exists. // DeleteDomainBlock deletes an instance-level domain block with the given domain, if it exists.
DeleteDomainBlock(ctx context.Context, domain string) error DeleteDomainBlock(ctx context.Context, domain string) error
// IsDomainBlocked checks if an instance-level domain block exists for the given domain string (eg., `example.org`). /*
Block/allow checking functions.
*/
// IsDomainBlocked checks if domain is blocked, accounting for both explicit allows and blocks.
// Will check allows first, so an allowed domain will always return false, even if it's also blocked.
IsDomainBlocked(ctx context.Context, domain string) (bool, error) IsDomainBlocked(ctx context.Context, domain string) (bool, error)
// AreDomainsBlocked checks if an instance-level domain block exists for any of the given domains strings, and returns true if even one is found. // AreDomainsBlocked calls IsDomainBlocked for each domain.
// Will return true if even one of the given domains is blocked.
AreDomainsBlocked(ctx context.Context, domains []string) (bool, error) AreDomainsBlocked(ctx context.Context, domains []string) (bool, error)
// IsURIBlocked checks if an instance-level domain block exists for the `host` in the given URI (eg., `https://example.org/users/whatever`). // IsURIBlocked calls IsDomainBlocked for the host of the given URI.
IsURIBlocked(ctx context.Context, uri *url.URL) (bool, error) IsURIBlocked(ctx context.Context, uri *url.URL) (bool, error)
// AreURIsBlocked checks if an instance-level domain block exists for any `host` in the given URI slice, and returns true if even one is found. // AreURIsBlocked calls IsURIBlocked for each URI.
// Will return true if even one of the given URIs is blocked.
AreURIsBlocked(ctx context.Context, uris []*url.URL) (bool, error) AreURIsBlocked(ctx context.Context, uris []*url.URL) (bool, error)
} }

View file

@ -42,7 +42,7 @@ func (c AdminActionCategory) String() string {
case AdminActionCategoryDomain: case AdminActionCategoryDomain:
return "domain" return "domain"
default: default:
return "unknown" return "unknown" //nolint:goconst
} }
} }

View file

@ -0,0 +1,78 @@
// 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 gtsmodel
import "time"
// DomainAllow represents a federation allow towards a particular domain.
type DomainAllow struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
Domain string `bun:",nullzero,notnull"` // domain to allow. Eg. 'whatever.com'
CreatedByAccountID string `bun:"type:CHAR(26),nullzero,notnull"` // Account ID of the creator of this allow
CreatedByAccount *Account `bun:"rel:belongs-to"` // Account corresponding to createdByAccountID
PrivateComment string `bun:""` // Private comment on this allow, viewable to admins
PublicComment string `bun:""` // Public comment on this allow, viewable (optionally) by everyone
Obfuscate *bool `bun:",nullzero,notnull,default:false"` // whether the domain name should appear obfuscated when displaying it publicly
SubscriptionID string `bun:"type:CHAR(26),nullzero"` // if this allow was created through a subscription, what's the subscription ID?
}
func (d *DomainAllow) GetID() string {
return d.ID
}
func (d *DomainAllow) GetCreatedAt() time.Time {
return d.CreatedAt
}
func (d *DomainAllow) GetUpdatedAt() time.Time {
return d.UpdatedAt
}
func (d *DomainAllow) GetDomain() string {
return d.Domain
}
func (d *DomainAllow) GetCreatedByAccountID() string {
return d.CreatedByAccountID
}
func (d *DomainAllow) GetCreatedByAccount() *Account {
return d.CreatedByAccount
}
func (d *DomainAllow) GetPrivateComment() string {
return d.PrivateComment
}
func (d *DomainAllow) GetPublicComment() string {
return d.PublicComment
}
func (d *DomainAllow) GetObfuscate() *bool {
return d.Obfuscate
}
func (d *DomainAllow) GetSubscriptionID() string {
return d.SubscriptionID
}
func (d *DomainAllow) GetType() DomainPermissionType {
return DomainPermissionAllow
}

View file

@ -32,3 +32,47 @@ type DomainBlock struct {
Obfuscate *bool `bun:",nullzero,notnull,default:false"` // whether the domain name should appear obfuscated when displaying it publicly Obfuscate *bool `bun:",nullzero,notnull,default:false"` // whether the domain name should appear obfuscated when displaying it publicly
SubscriptionID string `bun:"type:CHAR(26),nullzero"` // if this block was created through a subscription, what's the subscription ID? SubscriptionID string `bun:"type:CHAR(26),nullzero"` // if this block was created through a subscription, what's the subscription ID?
} }
func (d *DomainBlock) GetID() string {
return d.ID
}
func (d *DomainBlock) GetCreatedAt() time.Time {
return d.CreatedAt
}
func (d *DomainBlock) GetUpdatedAt() time.Time {
return d.UpdatedAt
}
func (d *DomainBlock) GetDomain() string {
return d.Domain
}
func (d *DomainBlock) GetCreatedByAccountID() string {
return d.CreatedByAccountID
}
func (d *DomainBlock) GetCreatedByAccount() *Account {
return d.CreatedByAccount
}
func (d *DomainBlock) GetPrivateComment() string {
return d.PrivateComment
}
func (d *DomainBlock) GetPublicComment() string {
return d.PublicComment
}
func (d *DomainBlock) GetObfuscate() *bool {
return d.Obfuscate
}
func (d *DomainBlock) GetSubscriptionID() string {
return d.SubscriptionID
}
func (d *DomainBlock) GetType() DomainPermissionType {
return DomainPermissionBlock
}

View file

@ -0,0 +1,67 @@
// 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 gtsmodel
import "time"
// DomainPermission models a domain
// permission entry (block/allow).
type DomainPermission interface {
GetID() string
GetCreatedAt() time.Time
GetUpdatedAt() time.Time
GetDomain() string
GetCreatedByAccountID() string
GetCreatedByAccount() *Account
GetPrivateComment() string
GetPublicComment() string
GetObfuscate() *bool
GetSubscriptionID() string
GetType() DomainPermissionType
}
// Domain permission type.
type DomainPermissionType uint8
const (
DomainPermissionUnknown DomainPermissionType = iota
DomainPermissionBlock // Explicitly block a domain.
DomainPermissionAllow // Explicitly allow a domain.
)
func (p DomainPermissionType) String() string {
switch p {
case DomainPermissionBlock:
return "block"
case DomainPermissionAllow:
return "allow"
default:
return "unknown"
}
}
func NewDomainPermissionType(in string) DomainPermissionType {
switch in {
case "block":
return DomainPermissionBlock
case "allow":
return DomainPermissionAllow
default:
return DomainPermissionUnknown
}
}

View file

@ -0,0 +1,255 @@
// 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 admin
import (
"context"
"errors"
"fmt"
"codeberg.org/gruf/go-kv"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/text"
)
func (p *Processor) createDomainAllow(
ctx context.Context,
adminAcct *gtsmodel.Account,
domain string,
obfuscate bool,
publicComment string,
privateComment string,
subscriptionID string,
) (*apimodel.DomainPermission, string, gtserror.WithCode) {
// Check if an allow already exists for this domain.
domainAllow, err := p.state.DB.GetDomainAllow(ctx, domain)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
// Something went wrong in the DB.
err = gtserror.Newf("db error getting domain allow %s: %w", domain, err)
return nil, "", gtserror.NewErrorInternalError(err)
}
if domainAllow == nil {
// No allow exists yet, create it.
domainAllow = &gtsmodel.DomainAllow{
ID: id.NewULID(),
Domain: domain,
CreatedByAccountID: adminAcct.ID,
PrivateComment: text.SanitizeToPlaintext(privateComment),
PublicComment: text.SanitizeToPlaintext(publicComment),
Obfuscate: &obfuscate,
SubscriptionID: subscriptionID,
}
// Insert the new allow into the database.
if err := p.state.DB.CreateDomainAllow(ctx, domainAllow); err != nil {
err = gtserror.Newf("db error putting domain allow %s: %w", domain, err)
return nil, "", gtserror.NewErrorInternalError(err)
}
}
actionID := id.NewULID()
// Process domain allow side
// effects asynchronously.
if errWithCode := p.actions.Run(
ctx,
&gtsmodel.AdminAction{
ID: actionID,
TargetCategory: gtsmodel.AdminActionCategoryDomain,
TargetID: domain,
Type: gtsmodel.AdminActionSuspend,
AccountID: adminAcct.ID,
Text: domainAllow.PrivateComment,
},
func(ctx context.Context) gtserror.MultiError {
// Log start + finish.
l := log.WithFields(kv.Fields{
{"domain", domain},
{"actionID", actionID},
}...).WithContext(ctx)
l.Info("processing domain allow side effects")
defer func() { l.Info("finished processing domain allow side effects") }()
return p.domainAllowSideEffects(ctx, domainAllow)
},
); errWithCode != nil {
return nil, actionID, errWithCode
}
apiDomainAllow, errWithCode := p.apiDomainPerm(ctx, domainAllow, false)
if errWithCode != nil {
return nil, actionID, errWithCode
}
return apiDomainAllow, actionID, nil
}
func (p *Processor) domainAllowSideEffects(
ctx context.Context,
allow *gtsmodel.DomainAllow,
) gtserror.MultiError {
if config.GetInstanceFederationMode() == config.InstanceFederationModeAllowlist {
// We're running in allowlist mode,
// so there are no side effects to
// process here.
return nil
}
// We're running in blocklist mode or
// some similar mode which necessitates
// domain allow side effects if a block
// was in place when the allow was created.
//
// So, check if there's a block.
block, err := p.state.DB.GetDomainBlock(ctx, allow.Domain)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
errs := gtserror.NewMultiError(1)
errs.Appendf("db error getting domain block %s: %w", allow.Domain, err)
return errs
}
if block == nil {
// No block?
// No problem!
return nil
}
// There was a block, over which the new
// allow ought to take precedence. To account
// for this, just run side effects as though
// the domain was being unblocked, while
// leaving the existing block in place.
//
// Any accounts that were suspended by
// the block will be unsuspended and be
// able to interact with the instance again.
return p.domainUnblockSideEffects(ctx, block)
}
func (p *Processor) deleteDomainAllow(
ctx context.Context,
adminAcct *gtsmodel.Account,
domainAllowID string,
) (*apimodel.DomainPermission, string, gtserror.WithCode) {
domainAllow, err := p.state.DB.GetDomainAllowByID(ctx, domainAllowID)
if err != nil {
if !errors.Is(err, db.ErrNoEntries) {
// Real error.
err = gtserror.Newf("db error getting domain allow: %w", err)
return nil, "", gtserror.NewErrorInternalError(err)
}
// There are just no entries for this ID.
err = fmt.Errorf("no domain allow entry exists with ID %s", domainAllowID)
return nil, "", gtserror.NewErrorNotFound(err, err.Error())
}
// Prepare the domain allow to return, *before* the deletion goes through.
apiDomainAllow, errWithCode := p.apiDomainPerm(ctx, domainAllow, false)
if errWithCode != nil {
return nil, "", errWithCode
}
// Delete the original domain allow.
if err := p.state.DB.DeleteDomainAllow(ctx, domainAllow.Domain); err != nil {
err = gtserror.Newf("db error deleting domain allow: %w", err)
return nil, "", gtserror.NewErrorInternalError(err)
}
actionID := id.NewULID()
// Process domain unallow side
// effects asynchronously.
if errWithCode := p.actions.Run(
ctx,
&gtsmodel.AdminAction{
ID: actionID,
TargetCategory: gtsmodel.AdminActionCategoryDomain,
TargetID: domainAllow.Domain,
Type: gtsmodel.AdminActionUnsuspend,
AccountID: adminAcct.ID,
},
func(ctx context.Context) gtserror.MultiError {
// Log start + finish.
l := log.WithFields(kv.Fields{
{"domain", domainAllow.Domain},
{"actionID", actionID},
}...).WithContext(ctx)
l.Info("processing domain unallow side effects")
defer func() { l.Info("finished processing domain unallow side effects") }()
return p.domainUnallowSideEffects(ctx, domainAllow)
},
); errWithCode != nil {
return nil, actionID, errWithCode
}
return apiDomainAllow, actionID, nil
}
func (p *Processor) domainUnallowSideEffects(
ctx context.Context,
allow *gtsmodel.DomainAllow,
) gtserror.MultiError {
if config.GetInstanceFederationMode() == config.InstanceFederationModeAllowlist {
// We're running in allowlist mode,
// so there are no side effects to
// process here.
return nil
}
// We're running in blocklist mode or
// some similar mode which necessitates
// domain allow side effects if a block
// was in place when the allow was removed.
//
// So, check if there's a block.
block, err := p.state.DB.GetDomainBlock(ctx, allow.Domain)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
errs := gtserror.NewMultiError(1)
errs.Appendf("db error getting domain block %s: %w", allow.Domain, err)
return errs
}
if block == nil {
// No block?
// No problem!
return nil
}
// There was a block, over which the previous
// allow was taking precedence. Now that the
// allow has been removed, we should put the
// side effects of the block back in place.
//
// To do this, process the block side effects
// again as though the block were freshly
// created. This will mark all accounts from
// the blocked domain as suspended, and clean
// up their follows/following, media, etc.
return p.domainBlockSideEffects(ctx, block)
}

View file

@ -18,14 +18,9 @@
package admin package admin
import ( import (
"bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"mime/multipart"
"net/http"
"time" "time"
"codeberg.org/gruf/go-kv" "codeberg.org/gruf/go-kv"
@ -40,14 +35,7 @@
"github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/text"
) )
// DomainBlockCreate creates an instance-level block against the given domain, func (p *Processor) createDomainBlock(
// and then processes side effects of that block (deleting accounts, media, etc).
//
// If a domain block already exists for the domain, side effects will be retried.
//
// Return values for this function are the (new) domain block, the ID of the admin
// action resulting from this call, and/or an error if something goes wrong.
func (p *Processor) DomainBlockCreate(
ctx context.Context, ctx context.Context,
adminAcct *gtsmodel.Account, adminAcct *gtsmodel.Account,
domain string, domain string,
@ -55,7 +43,7 @@ func (p *Processor) DomainBlockCreate(
publicComment string, publicComment string,
privateComment string, privateComment string,
subscriptionID string, subscriptionID string,
) (*apimodel.DomainBlock, string, gtserror.WithCode) { ) (*apimodel.DomainPermission, string, gtserror.WithCode) {
// Check if a block already exists for this domain. // Check if a block already exists for this domain.
domainBlock, err := p.state.DB.GetDomainBlock(ctx, domain) domainBlock, err := p.state.DB.GetDomainBlock(ctx, domain)
if err != nil && !errors.Is(err, db.ErrNoEntries) { if err != nil && !errors.Is(err, db.ErrNoEntries) {
@ -98,13 +86,22 @@ func (p *Processor) DomainBlockCreate(
Text: domainBlock.PrivateComment, Text: domainBlock.PrivateComment,
}, },
func(ctx context.Context) gtserror.MultiError { func(ctx context.Context) gtserror.MultiError {
// Log start + finish.
l := log.WithFields(kv.Fields{
{"domain", domain},
{"actionID", actionID},
}...).WithContext(ctx)
l.Info("processing domain block side effects")
defer func() { l.Info("finished processing domain block side effects") }()
return p.domainBlockSideEffects(ctx, domainBlock) return p.domainBlockSideEffects(ctx, domainBlock)
}, },
); errWithCode != nil { ); errWithCode != nil {
return nil, actionID, errWithCode return nil, actionID, errWithCode
} }
apiDomainBlock, errWithCode := p.apiDomainBlock(ctx, domainBlock) apiDomainBlock, errWithCode := p.apiDomainPerm(ctx, domainBlock, false)
if errWithCode != nil { if errWithCode != nil {
return nil, actionID, errWithCode return nil, actionID, errWithCode
} }
@ -112,206 +109,6 @@ func(ctx context.Context) gtserror.MultiError {
return apiDomainBlock, actionID, nil return apiDomainBlock, actionID, nil
} }
// DomainBlockDelete removes one domain block with the given ID,
// and processes side effects of removing the block asynchronously.
//
// Return values for this function are the deleted domain block, the ID of the admin
// action resulting from this call, and/or an error if something goes wrong.
func (p *Processor) DomainBlockDelete(
ctx context.Context,
adminAcct *gtsmodel.Account,
domainBlockID string,
) (*apimodel.DomainBlock, string, gtserror.WithCode) {
domainBlock, err := p.state.DB.GetDomainBlockByID(ctx, domainBlockID)
if err != nil {
if !errors.Is(err, db.ErrNoEntries) {
// Real error.
err = gtserror.Newf("db error getting domain block: %w", err)
return nil, "", gtserror.NewErrorInternalError(err)
}
// There are just no entries for this ID.
err = fmt.Errorf("no domain block entry exists with ID %s", domainBlockID)
return nil, "", gtserror.NewErrorNotFound(err, err.Error())
}
// Prepare the domain block to return, *before* the deletion goes through.
apiDomainBlock, errWithCode := p.apiDomainBlock(ctx, domainBlock)
if errWithCode != nil {
return nil, "", errWithCode
}
// Copy value of the domain block.
domainBlockC := new(gtsmodel.DomainBlock)
*domainBlockC = *domainBlock
// Delete the original domain block.
if err := p.state.DB.DeleteDomainBlock(ctx, domainBlock.Domain); err != nil {
err = gtserror.Newf("db error deleting domain block: %w", err)
return nil, "", gtserror.NewErrorInternalError(err)
}
actionID := id.NewULID()
// Process domain unblock side
// effects asynchronously.
if errWithCode := p.actions.Run(
ctx,
&gtsmodel.AdminAction{
ID: actionID,
TargetCategory: gtsmodel.AdminActionCategoryDomain,
TargetID: domainBlockC.Domain,
Type: gtsmodel.AdminActionUnsuspend,
AccountID: adminAcct.ID,
},
func(ctx context.Context) gtserror.MultiError {
return p.domainUnblockSideEffects(ctx, domainBlock)
},
); errWithCode != nil {
return nil, actionID, errWithCode
}
return apiDomainBlock, actionID, nil
}
// DomainBlocksImport handles the import of multiple domain blocks,
// by calling the DomainBlockCreate function for each domain in the
// provided file. Will return a slice of processed domain blocks.
//
// In the case of total failure, a gtserror.WithCode will be returned
// so that the caller can respond appropriately. In the case of
// partial or total success, a MultiStatus model will be returned,
// which contains information about success/failure count, so that
// the caller can retry any failures as they wish.
func (p *Processor) DomainBlocksImport(
ctx context.Context,
account *gtsmodel.Account,
domainsF *multipart.FileHeader,
) (*apimodel.MultiStatus, gtserror.WithCode) {
// Open the provided file.
file, err := domainsF.Open()
if err != nil {
err = gtserror.Newf("error opening attachment: %w", err)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
defer file.Close()
// Copy the file contents into a buffer.
buf := new(bytes.Buffer)
size, err := io.Copy(buf, file)
if err != nil {
err = gtserror.Newf("error reading attachment: %w", err)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
// Ensure we actually read something.
if size == 0 {
err = gtserror.New("error reading attachment: size 0 bytes")
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
// Parse bytes as slice of domain blocks.
domainBlocks := make([]*apimodel.DomainBlock, 0)
if err := json.Unmarshal(buf.Bytes(), &domainBlocks); err != nil {
err = gtserror.Newf("error parsing attachment as domain blocks: %w", err)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
count := len(domainBlocks)
if count == 0 {
err = gtserror.New("error importing domain blocks: 0 entries provided")
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
// Try to process each domain block, differentiating
// between successes and errors so that the caller can
// try failed imports again if desired.
multiStatusEntries := make([]apimodel.MultiStatusEntry, 0, count)
for _, domainBlock := range domainBlocks {
var (
domain = domainBlock.Domain.Domain
obfuscate = domainBlock.Obfuscate
publicComment = domainBlock.PublicComment
privateComment = domainBlock.PrivateComment
subscriptionID = "" // No sub ID for imports.
errWithCode gtserror.WithCode
)
domainBlock, _, errWithCode = p.DomainBlockCreate(
ctx,
account,
domain,
obfuscate,
publicComment,
privateComment,
subscriptionID,
)
var entry *apimodel.MultiStatusEntry
if errWithCode != nil {
entry = &apimodel.MultiStatusEntry{
// Use the failed domain entry as the resource value.
Resource: domain,
Message: errWithCode.Safe(),
Status: errWithCode.Code(),
}
} else {
entry = &apimodel.MultiStatusEntry{
// Use successfully created API model domain block as the resource value.
Resource: domainBlock,
Message: http.StatusText(http.StatusOK),
Status: http.StatusOK,
}
}
multiStatusEntries = append(multiStatusEntries, *entry)
}
return apimodel.NewMultiStatus(multiStatusEntries), nil
}
// DomainBlocksGet returns all existing domain blocks. If export is
// true, the format will be suitable for writing out to an export.
func (p *Processor) DomainBlocksGet(ctx context.Context, account *gtsmodel.Account, export bool) ([]*apimodel.DomainBlock, gtserror.WithCode) {
domainBlocks, err := p.state.DB.GetDomainBlocks(ctx)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err = gtserror.Newf("db error getting domain blocks: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
apiDomainBlocks := make([]*apimodel.DomainBlock, 0, len(domainBlocks))
for _, domainBlock := range domainBlocks {
apiDomainBlock, errWithCode := p.apiDomainBlock(ctx, domainBlock)
if errWithCode != nil {
return nil, errWithCode
}
apiDomainBlocks = append(apiDomainBlocks, apiDomainBlock)
}
return apiDomainBlocks, nil
}
// DomainBlockGet returns one domain block with the given id. If export
// is true, the format will be suitable for writing out to an export.
func (p *Processor) DomainBlockGet(ctx context.Context, id string, export bool) (*apimodel.DomainBlock, gtserror.WithCode) {
domainBlock, err := p.state.DB.GetDomainBlockByID(ctx, id)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
err = fmt.Errorf("no domain block exists with id %s", id)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
// Something went wrong in the DB.
err = gtserror.Newf("db error getting domain block %s: %w", id, err)
return nil, gtserror.NewErrorInternalError(err)
}
return p.apiDomainBlock(ctx, domainBlock)
}
// domainBlockSideEffects processes the side effects of a domain block: // domainBlockSideEffects processes the side effects of a domain block:
// //
// 1. Strip most info away from the instance entry for the domain. // 1. Strip most info away from the instance entry for the domain.
@ -323,13 +120,6 @@ func (p *Processor) domainBlockSideEffects(
ctx context.Context, ctx context.Context,
block *gtsmodel.DomainBlock, block *gtsmodel.DomainBlock,
) gtserror.MultiError { ) gtserror.MultiError {
l := log.
WithContext(ctx).
WithFields(kv.Fields{
{"domain", block.Domain},
}...)
l.Debug("processing domain block side effects")
var errs gtserror.MultiError var errs gtserror.MultiError
// If we have an instance entry for this domain, // If we have an instance entry for this domain,
@ -347,7 +137,6 @@ func (p *Processor) domainBlockSideEffects(
errs.Appendf("db error updating instance: %w", err) errs.Appendf("db error updating instance: %w", err)
return errs return errs
} }
l.Debug("instance entry updated")
} }
// For each account that belongs to this domain, // For each account that belongs to this domain,
@ -372,6 +161,68 @@ func (p *Processor) domainBlockSideEffects(
return errs return errs
} }
func (p *Processor) deleteDomainBlock(
ctx context.Context,
adminAcct *gtsmodel.Account,
domainBlockID string,
) (*apimodel.DomainPermission, string, gtserror.WithCode) {
domainBlock, err := p.state.DB.GetDomainBlockByID(ctx, domainBlockID)
if err != nil {
if !errors.Is(err, db.ErrNoEntries) {
// Real error.
err = gtserror.Newf("db error getting domain block: %w", err)
return nil, "", gtserror.NewErrorInternalError(err)
}
// There are just no entries for this ID.
err = fmt.Errorf("no domain block entry exists with ID %s", domainBlockID)
return nil, "", gtserror.NewErrorNotFound(err, err.Error())
}
// Prepare the domain block to return, *before* the deletion goes through.
apiDomainBlock, errWithCode := p.apiDomainPerm(ctx, domainBlock, false)
if errWithCode != nil {
return nil, "", errWithCode
}
// Delete the original domain block.
if err := p.state.DB.DeleteDomainBlock(ctx, domainBlock.Domain); err != nil {
err = gtserror.Newf("db error deleting domain block: %w", err)
return nil, "", gtserror.NewErrorInternalError(err)
}
actionID := id.NewULID()
// Process domain unblock side
// effects asynchronously.
if errWithCode := p.actions.Run(
ctx,
&gtsmodel.AdminAction{
ID: actionID,
TargetCategory: gtsmodel.AdminActionCategoryDomain,
TargetID: domainBlock.Domain,
Type: gtsmodel.AdminActionUnsuspend,
AccountID: adminAcct.ID,
},
func(ctx context.Context) gtserror.MultiError {
// Log start + finish.
l := log.WithFields(kv.Fields{
{"domain", domainBlock.Domain},
{"actionID", actionID},
}...).WithContext(ctx)
l.Info("processing domain unblock side effects")
defer func() { l.Info("finished processing domain unblock side effects") }()
return p.domainUnblockSideEffects(ctx, domainBlock)
},
); errWithCode != nil {
return nil, actionID, errWithCode
}
return apiDomainBlock, actionID, nil
}
// domainUnblockSideEffects processes the side effects of undoing a // domainUnblockSideEffects processes the side effects of undoing a
// domain block: // domain block:
// //
@ -385,13 +236,6 @@ func (p *Processor) domainUnblockSideEffects(
ctx context.Context, ctx context.Context,
block *gtsmodel.DomainBlock, block *gtsmodel.DomainBlock,
) gtserror.MultiError { ) gtserror.MultiError {
l := log.
WithContext(ctx).
WithFields(kv.Fields{
{"domain", block.Domain},
}...)
l.Debug("processing domain unblock side effects")
var errs gtserror.MultiError var errs gtserror.MultiError
// Update instance entry for this domain, if we have it. // Update instance entry for this domain, if we have it.
@ -414,7 +258,6 @@ func (p *Processor) domainUnblockSideEffects(
errs.Appendf("db error updating instance: %w", err) errs.Appendf("db error updating instance: %w", err)
return errs return errs
} }
l.Debug("instance entry updated")
} }
// Unsuspend all accounts whose suspension origin was this domain block. // Unsuspend all accounts whose suspension origin was this domain block.

View file

@ -1,76 +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/>.
package admin_test
import (
"context"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type DomainBlockTestSuite struct {
AdminStandardTestSuite
}
func (suite *DomainBlockTestSuite) TestCreateDomainBlock() {
var (
ctx = context.Background()
adminAcct = suite.testAccounts["admin_account"]
domain = "fossbros-anonymous.io"
obfuscate = false
publicComment = ""
privateComment = ""
subscriptionID = ""
)
apiBlock, actionID, errWithCode := suite.adminProcessor.DomainBlockCreate(
ctx,
adminAcct,
domain,
obfuscate,
publicComment,
privateComment,
subscriptionID,
)
suite.NoError(errWithCode)
suite.NotNil(apiBlock)
suite.NotEmpty(actionID)
// Wait for action to finish.
if !testrig.WaitFor(func() bool {
return suite.adminProcessor.Actions().TotalRunning() == 0
}) {
suite.FailNow("timed out waiting for admin action(s) to finish")
}
// Ensure action marked as
// completed in the database.
adminAction, err := suite.db.GetAdminAction(ctx, actionID)
if err != nil {
suite.FailNow(err.Error())
}
suite.NotZero(adminAction.CompletedAt)
suite.Empty(adminAction.Errors)
}
func TestDomainBlockTestSuite(t *testing.T) {
suite.Run(t, new(DomainBlockTestSuite))
}

View file

@ -0,0 +1,335 @@
// 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 admin
import (
"context"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"net/http"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// apiDomainPerm is a cheeky shortcut for returning
// the API version of the given domain permission
// (*gtsmodel.DomainBlock or *gtsmodel.DomainAllow),
// or an appropriate error if something goes wrong.
func (p *Processor) apiDomainPerm(
ctx context.Context,
domainPermission gtsmodel.DomainPermission,
export bool,
) (*apimodel.DomainPermission, gtserror.WithCode) {
apiDomainPerm, err := p.tc.DomainPermToAPIDomainPerm(ctx, domainPermission, export)
if err != nil {
err := gtserror.NewfAt(3, "error converting domain permission to api model: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiDomainPerm, nil
}
// DomainPermissionCreate creates an instance-level permission
// targeting the given domain, and then processes any side
// effects of the permission creation.
//
// If the same permission type already exists for the domain,
// side effects will be retried.
//
// Return values for this function are the new or existing
// domain permission, the ID of the admin action resulting
// from this call, and/or an error if something goes wrong.
func (p *Processor) DomainPermissionCreate(
ctx context.Context,
permissionType gtsmodel.DomainPermissionType,
adminAcct *gtsmodel.Account,
domain string,
obfuscate bool,
publicComment string,
privateComment string,
subscriptionID string,
) (*apimodel.DomainPermission, string, gtserror.WithCode) {
switch permissionType {
// Explicitly block a domain.
case gtsmodel.DomainPermissionBlock:
return p.createDomainBlock(
ctx,
adminAcct,
domain,
obfuscate,
publicComment,
privateComment,
subscriptionID,
)
// Explicitly allow a domain.
case gtsmodel.DomainPermissionAllow:
return p.createDomainAllow(
ctx,
adminAcct,
domain,
obfuscate,
publicComment,
privateComment,
subscriptionID,
)
// Weeping, roaring, red-faced.
default:
err := gtserror.Newf("unrecognized permission type %d", permissionType)
return nil, "", gtserror.NewErrorInternalError(err)
}
}
// DomainPermissionDelete removes one domain block with the given ID,
// and processes side effects of removing the block asynchronously.
//
// Return values for this function are the deleted domain block, the ID of the admin
// action resulting from this call, and/or an error if something goes wrong.
func (p *Processor) DomainPermissionDelete(
ctx context.Context,
permissionType gtsmodel.DomainPermissionType,
adminAcct *gtsmodel.Account,
domainBlockID string,
) (*apimodel.DomainPermission, string, gtserror.WithCode) {
switch permissionType {
// Delete explicit domain block.
case gtsmodel.DomainPermissionBlock:
return p.deleteDomainBlock(
ctx,
adminAcct,
domainBlockID,
)
// Delete explicit domain allow.
case gtsmodel.DomainPermissionAllow:
return p.deleteDomainAllow(
ctx,
adminAcct,
domainBlockID,
)
// You do the hokey-cokey and you turn
// around, that's what it's all about.
default:
err := gtserror.Newf("unrecognized permission type %d", permissionType)
return nil, "", gtserror.NewErrorInternalError(err)
}
}
// DomainPermissionsImport handles the import of multiple
// domain permissions, by calling the DomainPermissionCreate
// function for each domain in the provided file. Will return
// a slice of processed domain permissions.
//
// In the case of total failure, a gtserror.WithCode will be
// returned so that the caller can respond appropriately. In
// the case of partial or total success, a MultiStatus model
// will be returned, which contains information about success
// + failure count, so that the caller can retry any failures
// as they wish.
func (p *Processor) DomainPermissionsImport(
ctx context.Context,
permissionType gtsmodel.DomainPermissionType,
account *gtsmodel.Account,
domainsF *multipart.FileHeader,
) (*apimodel.MultiStatus, gtserror.WithCode) {
// Ensure known permission type.
if permissionType != gtsmodel.DomainPermissionBlock &&
permissionType != gtsmodel.DomainPermissionAllow {
err := gtserror.Newf("unrecognized permission type %d", permissionType)
return nil, gtserror.NewErrorInternalError(err)
}
// Open the provided file.
file, err := domainsF.Open()
if err != nil {
err = gtserror.Newf("error opening attachment: %w", err)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
defer file.Close()
// Parse file as slice of domain blocks.
domainPerms := make([]*apimodel.DomainPermission, 0)
if err := json.NewDecoder(file).Decode(&domainPerms); err != nil {
err = gtserror.Newf("error parsing attachment as domain permissions: %w", err)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
count := len(domainPerms)
if count == 0 {
err = gtserror.New("error importing domain permissions: 0 entries provided")
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
// Try to process each domain permission, differentiating
// between successes and errors so that the caller can
// try failed imports again if desired.
multiStatusEntries := make([]apimodel.MultiStatusEntry, 0, count)
for _, domainPerm := range domainPerms {
var (
domain = domainPerm.Domain.Domain
obfuscate = domainPerm.Obfuscate
publicComment = domainPerm.PublicComment
privateComment = domainPerm.PrivateComment
subscriptionID = "" // No sub ID for imports.
errWithCode gtserror.WithCode
)
domainPerm, _, errWithCode = p.DomainPermissionCreate(
ctx,
permissionType,
account,
domain,
obfuscate,
publicComment,
privateComment,
subscriptionID,
)
var entry *apimodel.MultiStatusEntry
if errWithCode != nil {
entry = &apimodel.MultiStatusEntry{
// Use the failed domain entry as the resource value.
Resource: domain,
Message: errWithCode.Safe(),
Status: errWithCode.Code(),
}
} else {
entry = &apimodel.MultiStatusEntry{
// Use successfully created API model domain block as the resource value.
Resource: domainPerm,
Message: http.StatusText(http.StatusOK),
Status: http.StatusOK,
}
}
multiStatusEntries = append(multiStatusEntries, *entry)
}
return apimodel.NewMultiStatus(multiStatusEntries), nil
}
// DomainPermissionsGet returns all existing domain
// permissions of the requested type. If export is
// true, the format will be suitable for writing out
// to an export.
func (p *Processor) DomainPermissionsGet(
ctx context.Context,
permissionType gtsmodel.DomainPermissionType,
account *gtsmodel.Account,
export bool,
) ([]*apimodel.DomainPermission, gtserror.WithCode) {
var (
domainPerms []gtsmodel.DomainPermission
err error
)
switch permissionType {
case gtsmodel.DomainPermissionBlock:
var blocks []*gtsmodel.DomainBlock
blocks, err = p.state.DB.GetDomainBlocks(ctx)
if err != nil {
break
}
for _, block := range blocks {
domainPerms = append(domainPerms, block)
}
case gtsmodel.DomainPermissionAllow:
var allows []*gtsmodel.DomainAllow
allows, err = p.state.DB.GetDomainAllows(ctx)
if err != nil {
break
}
for _, allow := range allows {
domainPerms = append(domainPerms, allow)
}
default:
err = errors.New("unrecognized permission type")
}
if err != nil {
err := gtserror.Newf("error getting %ss: %w", permissionType.String(), err)
return nil, gtserror.NewErrorInternalError(err)
}
apiDomainPerms := make([]*apimodel.DomainPermission, len(domainPerms))
for i, domainPerm := range domainPerms {
apiDomainBlock, errWithCode := p.apiDomainPerm(ctx, domainPerm, export)
if errWithCode != nil {
return nil, errWithCode
}
apiDomainPerms[i] = apiDomainBlock
}
return apiDomainPerms, nil
}
// DomainPermissionGet returns one domain
// permission with the given id and type.
//
// If export is true, the format will be
// suitable for writing out to an export.
func (p *Processor) DomainPermissionGet(
ctx context.Context,
permissionType gtsmodel.DomainPermissionType,
id string,
export bool,
) (*apimodel.DomainPermission, gtserror.WithCode) {
var (
domainPerm gtsmodel.DomainPermission
err error
)
switch permissionType {
case gtsmodel.DomainPermissionBlock:
domainPerm, err = p.state.DB.GetDomainBlockByID(ctx, id)
case gtsmodel.DomainPermissionAllow:
domainPerm, err = p.state.DB.GetDomainAllowByID(ctx, id)
default:
err = gtserror.New("unrecognized permission type")
}
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
err = fmt.Errorf("no domain %s exists with id %s", permissionType.String(), id)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
err = gtserror.Newf("error getting domain %s with id %s: %w", permissionType.String(), id, err)
return nil, gtserror.NewErrorInternalError(err)
}
return p.apiDomainPerm(ctx, domainPerm, export)
}

View file

@ -0,0 +1,280 @@
// 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 admin_test
import (
"context"
"testing"
"github.com/stretchr/testify/suite"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type DomainBlockTestSuite struct {
AdminStandardTestSuite
}
type domainPermAction struct {
// 'create' or 'delete'
// the domain permission.
createOrDelete string
// Type of permission
// to create or delete.
permissionType gtsmodel.DomainPermissionType
// Domain to target
// with the permission.
domain string
// Expected result of this
// permission action on each
// account on the target domain.
// Eg., suite.Zero(account.SuspendedAt)
expected func(*gtsmodel.Account) bool
}
type domainPermTest struct {
// Federation mode under which to
// run this test. This is important
// because it may effect which side
// effects are taken, if any.
instanceFederationMode string
// Series of actions to run as part
// of this test. After each action,
// expected will be called. This
// allows testers to run multiple
// actions in a row and check that
// the results after each action are
// what they expected, in light of
// previous actions.
actions []domainPermAction
}
// run a domainPermTest by running each of
// its actions in turn and checking results.
func (suite *DomainBlockTestSuite) runDomainPermTest(t domainPermTest) {
config.SetInstanceFederationMode(t.instanceFederationMode)
for _, action := range t.actions {
// Run the desired action.
var actionID string
switch action.createOrDelete {
case "create":
_, actionID = suite.createDomainPerm(action.permissionType, action.domain)
case "delete":
_, actionID = suite.deleteDomainPerm(action.permissionType, action.domain)
default:
panic("createOrDelete was not 'create' or 'delete'")
}
// Let the action finish.
suite.awaitAction(actionID)
// Check expected results
// against each account.
accounts, err := suite.db.GetInstanceAccounts(
context.Background(),
action.domain,
"", 0,
)
if err != nil {
suite.FailNow("", "error getting instance accounts for %s: %v", action.domain, err)
}
for _, account := range accounts {
if !action.expected(account) {
suite.T().FailNow()
}
}
}
}
// create given permissionType with default values.
func (suite *DomainBlockTestSuite) createDomainPerm(
permissionType gtsmodel.DomainPermissionType,
domain string,
) (*apimodel.DomainPermission, string) {
ctx := context.Background()
apiPerm, actionID, errWithCode := suite.adminProcessor.DomainPermissionCreate(
ctx,
permissionType,
suite.testAccounts["admin_account"],
domain,
false,
"",
"",
"",
)
suite.NoError(errWithCode)
suite.NotNil(apiPerm)
suite.NotEmpty(actionID)
return apiPerm, actionID
}
// delete given permission type.
func (suite *DomainBlockTestSuite) deleteDomainPerm(
permissionType gtsmodel.DomainPermissionType,
domain string,
) (*apimodel.DomainPermission, string) {
var (
ctx = context.Background()
domainPermission gtsmodel.DomainPermission
)
// To delete the permission,
// first get it from the db.
switch permissionType {
case gtsmodel.DomainPermissionBlock:
domainPermission, _ = suite.db.GetDomainBlock(ctx, domain)
case gtsmodel.DomainPermissionAllow:
domainPermission, _ = suite.db.GetDomainAllow(ctx, domain)
default:
panic("unrecognized permission type")
}
if domainPermission == nil {
suite.FailNow("domain permission was nil")
}
// Now use the ID to delete it.
apiPerm, actionID, errWithCode := suite.adminProcessor.DomainPermissionDelete(
ctx,
permissionType,
suite.testAccounts["admin_account"],
domainPermission.GetID(),
)
suite.NoError(errWithCode)
suite.NotNil(apiPerm)
suite.NotEmpty(actionID)
return apiPerm, actionID
}
// waits for given actionID to be completed.
func (suite *DomainBlockTestSuite) awaitAction(actionID string) {
ctx := context.Background()
if !testrig.WaitFor(func() bool {
return suite.adminProcessor.Actions().TotalRunning() == 0
}) {
suite.FailNow("timed out waiting for admin action(s) to finish")
}
// Ensure action marked as
// completed in the database.
adminAction, err := suite.db.GetAdminAction(ctx, actionID)
if err != nil {
suite.FailNow(err.Error())
}
suite.NotZero(adminAction.CompletedAt)
suite.Empty(adminAction.Errors)
}
func (suite *DomainBlockTestSuite) TestBlockAndUnblockDomain() {
const domain = "fossbros-anonymous.io"
suite.runDomainPermTest(domainPermTest{
instanceFederationMode: config.InstanceFederationModeBlocklist,
actions: []domainPermAction{
{
createOrDelete: "create",
permissionType: gtsmodel.DomainPermissionBlock,
domain: domain,
expected: func(account *gtsmodel.Account) bool {
// Domain was blocked, so each
// account should now be suspended.
return suite.NotZero(account.SuspendedAt)
},
},
{
createOrDelete: "delete",
permissionType: gtsmodel.DomainPermissionBlock,
domain: domain,
expected: func(account *gtsmodel.Account) bool {
// Domain was unblocked, so each
// account should now be unsuspended.
return suite.Zero(account.SuspendedAt)
},
},
},
})
}
func (suite *DomainBlockTestSuite) TestBlockAndAllowDomain() {
const domain = "fossbros-anonymous.io"
suite.runDomainPermTest(domainPermTest{
instanceFederationMode: config.InstanceFederationModeBlocklist,
actions: []domainPermAction{
{
createOrDelete: "create",
permissionType: gtsmodel.DomainPermissionBlock,
domain: domain,
expected: func(account *gtsmodel.Account) bool {
// Domain was blocked, so each
// account should now be suspended.
return suite.NotZero(account.SuspendedAt)
},
},
{
createOrDelete: "create",
permissionType: gtsmodel.DomainPermissionAllow,
domain: domain,
expected: func(account *gtsmodel.Account) bool {
// Domain was explicitly allowed, so each
// account should now be unsuspended, since
// the allow supercedes the block.
return suite.Zero(account.SuspendedAt)
},
},
{
createOrDelete: "delete",
permissionType: gtsmodel.DomainPermissionAllow,
domain: domain,
expected: func(account *gtsmodel.Account) bool {
// Deleting the allow now, while there's
// still a block in place, should cause
// the block to take effect again.
return suite.NotZero(account.SuspendedAt)
},
},
{
createOrDelete: "delete",
permissionType: gtsmodel.DomainPermissionBlock,
domain: domain,
expected: func(account *gtsmodel.Account) bool {
// Deleting the block now should
// unsuspend the accounts again.
return suite.Zero(account.SuspendedAt)
},
},
},
})
}
func TestDomainBlockTestSuite(t *testing.T) {
suite.Run(t, new(DomainBlockTestSuite))
}

View file

@ -22,28 +22,11 @@
"errors" "errors"
"time" "time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
) )
// apiDomainBlock is a cheeky shortcut for returning
// the API version of the given domainBlock, or an
// appropriate error if something goes wrong.
func (p *Processor) apiDomainBlock(
ctx context.Context,
domainBlock *gtsmodel.DomainBlock,
) (*apimodel.DomainBlock, gtserror.WithCode) {
apiDomainBlock, err := p.tc.DomainBlockToAPIDomainBlock(ctx, domainBlock, false)
if err != nil {
err = gtserror.Newf("error converting domain block for %s to api model : %w", domainBlock.Domain, err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiDomainBlock, nil
}
// stubbifyInstance renders the given instance as a stub, // stubbifyInstance renders the given instance as a stub,
// removing most information from it and marking it as // removing most information from it and marking it as
// suspended. // suspended.

View file

@ -91,8 +91,8 @@ type TypeConverter interface {
RelationshipToAPIRelationship(ctx context.Context, r *gtsmodel.Relationship) (*apimodel.Relationship, error) RelationshipToAPIRelationship(ctx context.Context, r *gtsmodel.Relationship) (*apimodel.Relationship, error)
// NotificationToAPINotification converts a gts notification into a api notification // NotificationToAPINotification converts a gts notification into a api notification
NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification) (*apimodel.Notification, error) NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification) (*apimodel.Notification, error)
// DomainBlockToAPIDomainBlock converts a gts model domin block into a api domain block, for serving at /api/v1/admin/domain_blocks // DomainPermToAPIDomainPerm converts a gts model domin block or allow into an api domain permission.
DomainBlockToAPIDomainBlock(ctx context.Context, b *gtsmodel.DomainBlock, export bool) (*apimodel.DomainBlock, error) DomainPermToAPIDomainPerm(ctx context.Context, d gtsmodel.DomainPermission, export bool) (*apimodel.DomainPermission, error)
// ReportToAPIReport converts a gts model report into an api model report, for serving at /api/v1/reports // ReportToAPIReport converts a gts model report into an api model report, for serving at /api/v1/reports
ReportToAPIReport(ctx context.Context, r *gtsmodel.Report) (*apimodel.Report, error) ReportToAPIReport(ctx context.Context, r *gtsmodel.Report) (*apimodel.Report, error)
// ReportToAdminAPIReport converts a gts model report into an admin view report, for serving at /api/v1/admin/reports // ReportToAdminAPIReport converts a gts model report into an admin view report, for serving at /api/v1/admin/reports

View file

@ -1041,32 +1041,39 @@ func (c *converter) NotificationToAPINotification(ctx context.Context, n *gtsmod
}, nil }, nil
} }
func (c *converter) DomainBlockToAPIDomainBlock(ctx context.Context, b *gtsmodel.DomainBlock, export bool) (*apimodel.DomainBlock, error) { func (c *converter) DomainPermToAPIDomainPerm(
ctx context.Context,
d gtsmodel.DomainPermission,
export bool,
) (*apimodel.DomainPermission, error) {
// Domain may be in Punycode, // Domain may be in Punycode,
// de-punify it just in case. // de-punify it just in case.
d, err := util.DePunify(b.Domain) domain, err := util.DePunify(d.GetDomain())
if err != nil { if err != nil {
return nil, fmt.Errorf("DomainBlockToAPIDomainBlock: error de-punifying domain %s: %w", b.Domain, err) return nil, gtserror.Newf("error de-punifying domain %s: %w", d.GetDomain(), err)
} }
domainBlock := &apimodel.DomainBlock{ domainPerm := &apimodel.DomainPermission{
Domain: apimodel.Domain{ Domain: apimodel.Domain{
Domain: d, Domain: domain,
PublicComment: b.PublicComment, PublicComment: d.GetPublicComment(),
}, },
} }
// if we're exporting a domain block, return it with minimal information attached // If we're exporting, provide
if !export { // only bare minimum detail.
domainBlock.ID = b.ID if export {
domainBlock.Obfuscate = *b.Obfuscate return domainPerm, nil
domainBlock.PrivateComment = b.PrivateComment
domainBlock.SubscriptionID = b.SubscriptionID
domainBlock.CreatedBy = b.CreatedByAccountID
domainBlock.CreatedAt = util.FormatISO8601(b.CreatedAt)
} }
return domainBlock, nil domainPerm.ID = d.GetID()
domainPerm.Obfuscate = *d.GetObfuscate()
domainPerm.PrivateComment = d.GetPrivateComment()
domainPerm.SubscriptionID = d.GetSubscriptionID()
domainPerm.CreatedBy = d.GetCreatedByAccountID()
domainPerm.CreatedAt = util.FormatISO8601(d.GetCreatedAt())
return domainPerm, nil
} }
func (c *converter) ReportToAPIReport(ctx context.Context, r *gtsmodel.Report) (*apimodel.Report, error) { func (c *converter) ReportToAPIReport(ctx context.Context, r *gtsmodel.Report) (*apimodel.Report, error) {

View file

@ -102,6 +102,8 @@ nav:
- "Admin": - "Admin":
- "admin/settings.md" - "admin/settings.md"
- "admin/federation_modes.md"
- "admin/domain_blocks.md"
- "admin/cli.md" - "admin/cli.md"
- "admin/backup_and_restore.md" - "admin/backup_and_restore.md"
- "Federation": - "Federation":

View file

@ -81,6 +81,7 @@ EXPECT=$(cat << "EOF"
"instance-expose-public-timeline": true, "instance-expose-public-timeline": true,
"instance-expose-suspended": true, "instance-expose-suspended": true,
"instance-expose-suspended-web": true, "instance-expose-suspended-web": true,
"instance-federation-mode": "allowlist",
"instance-inject-mastodon-version": true, "instance-inject-mastodon-version": true,
"landing-page-user": "admin", "landing-page-user": "admin",
"letsencrypt-cert-dir": "/gotosocial/storage/certs", "letsencrypt-cert-dir": "/gotosocial/storage/certs",
@ -192,6 +193,7 @@ GTS_INSTANCE_EXPOSE_PEERS=true \
GTS_INSTANCE_EXPOSE_SUSPENDED=true \ GTS_INSTANCE_EXPOSE_SUSPENDED=true \
GTS_INSTANCE_EXPOSE_SUSPENDED_WEB=true \ GTS_INSTANCE_EXPOSE_SUSPENDED_WEB=true \
GTS_INSTANCE_EXPOSE_PUBLIC_TIMELINE=true \ GTS_INSTANCE_EXPOSE_PUBLIC_TIMELINE=true \
GTS_INSTANCE_FEDERATION_MODE='allowlist' \
GTS_INSTANCE_DELIVER_TO_SHARED_INBOXES=false \ GTS_INSTANCE_DELIVER_TO_SHARED_INBOXES=false \
GTS_INSTANCE_INJECT_MASTODON_VERSION=true \ GTS_INSTANCE_INJECT_MASTODON_VERSION=true \
GTS_ACCOUNTS_ALLOW_CUSTOM_CSS=true \ GTS_ACCOUNTS_ALLOW_CUSTOM_CSS=true \

View file

@ -63,6 +63,7 @@ func InitTestConfig() {
WebTemplateBaseDir: "./web/template/", WebTemplateBaseDir: "./web/template/",
WebAssetBaseDir: "./web/assets/", WebAssetBaseDir: "./web/assets/",
InstanceFederationMode: config.InstanceFederationModeDefault,
InstanceExposePeers: true, InstanceExposePeers: true,
InstanceExposeSuspended: true, InstanceExposeSuspended: true,
InstanceExposeSuspendedWeb: true, InstanceExposeSuspendedWeb: true,