From 183eaa5b298235acb8f25ba8f18b98e31471d965 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Thu, 21 Sep 2023 12:12:04 +0200 Subject: [PATCH] [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 --- .vscode/settings.json | 8 +- README.md | 8 +- ROADMAP.md | 18 +- docs/admin/domain_blocks.md | 73 ++++ docs/admin/federation_modes.md | 62 ++++ docs/admin/settings.md | 88 +++-- docs/api/swagger.yaml | 243 +++++++++++-- docs/assets/diagrams/federation_modes.drawio | 124 +++++++ docs/assets/diagrams/federation_modes.png | Bin 0 -> 60821 bytes docs/configuration/instance.md | 33 +- docs/faq.md | 1 - example/config.yaml | 26 +- internal/api/client/admin/admin.go | 8 + .../api/client/admin/domainallowcreate.go | 128 +++++++ .../api/client/admin/domainallowdelete.go | 72 ++++ internal/api/client/admin/domainallowget.go | 67 ++++ internal/api/client/admin/domainallowsget.go | 73 ++++ .../api/client/admin/domainblockcreate.go | 118 +----- .../api/client/admin/domainblockdelete.go | 42 +-- internal/api/client/admin/domainblockget.go | 46 +-- internal/api/client/admin/domainblocksget.go | 40 +-- internal/api/client/admin/domainpermission.go | 295 +++++++++++++++ internal/api/model/domain.go | 43 ++- internal/api/util/parsequery.go | 14 +- internal/cache/domain/domain.go | 51 +-- internal/cache/domain/domain_test.go | 30 +- internal/cache/gts.go | 17 +- internal/config/config.go | 13 +- internal/config/const.go | 26 ++ internal/config/defaults.go | 1 + internal/config/flags.go | 1 + internal/config/helpers.gen.go | 25 ++ internal/config/validate.go | 11 + internal/db/bundb/domain.go | 148 +++++++- internal/db/bundb/domain_test.go | 53 +++ .../migrations/20230908083121_allowlist.go.go | 62 ++++ internal/db/domain.go | 34 +- internal/gtsmodel/adminaction.go | 2 +- internal/gtsmodel/domainallow.go | 78 ++++ internal/gtsmodel/domainblock.go | 44 +++ internal/gtsmodel/domainpermission.go | 67 ++++ internal/processing/admin/domainallow.go | 255 +++++++++++++ internal/processing/admin/domainblock.go | 305 ++++------------ internal/processing/admin/domainblock_test.go | 76 ---- internal/processing/admin/domainpermission.go | 335 ++++++++++++++++++ .../processing/admin/domainpermission_test.go | 280 +++++++++++++++ internal/processing/admin/util.go | 17 - internal/typeutils/converter.go | 4 +- internal/typeutils/internaltofrontend.go | 37 +- mkdocs.yml | 2 + test/envparsing.sh | 2 + testrig/config.go | 1 + 52 files changed, 2877 insertions(+), 730 deletions(-) create mode 100644 docs/admin/domain_blocks.md create mode 100644 docs/admin/federation_modes.md create mode 100644 docs/assets/diagrams/federation_modes.drawio create mode 100644 docs/assets/diagrams/federation_modes.png create mode 100644 internal/api/client/admin/domainallowcreate.go create mode 100644 internal/api/client/admin/domainallowdelete.go create mode 100644 internal/api/client/admin/domainallowget.go create mode 100644 internal/api/client/admin/domainallowsget.go create mode 100644 internal/api/client/admin/domainpermission.go create mode 100644 internal/config/const.go create mode 100644 internal/db/bundb/migrations/20230908083121_allowlist.go.go create mode 100644 internal/gtsmodel/domainallow.go create mode 100644 internal/gtsmodel/domainpermission.go create mode 100644 internal/processing/admin/domainallow.go delete mode 100644 internal/processing/admin/domainblock_test.go create mode 100644 internal/processing/admin/domainpermission.go create mode 100644 internal/processing/admin/domainpermission_test.go diff --git a/.vscode/settings.json b/.vscode/settings.json index fa26c263e..b2adc1cb8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,9 +3,11 @@ "go.lintFlags": [ "--fast" ], - "go.vetFlags": [ - "-composites=false ." - ], + "gopls": { + "analyses": { + "composites": false + }, + }, "eslint.workingDirectories": ["web/source"], "eslint.lintTask.enable": true, "eslint.lintTask.options": "${workspaceFolder}/web/source" diff --git a/README.md b/README.md index fcfad7869..8086ab6fd 100644 --- a/README.md +++ b/README.md @@ -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. -- 'Normal' federation; discover new servers. -- *Allow list*-only federation; choose which servers you talk to (not yet implemented). -- Zero federation; keep your server private (not yet implemented). +- 'blocklist' mode (default): discover new servers; block servers you don't like. +- 'allowlist' mode (experimental); opt-in to federation with trusted servers. +- '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 diff --git a/ROADMAP.md b/ROADMAP.md index 074abfc12..bca1e7091 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -45,24 +45,24 @@ What follows is a rough timeline of features that will be implemented on the roa ### Mid 2023 -- **Hashtags** -- implement federating hashtags and viewing hashtags to allow users to discover posts that they might be interested in. -- **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. +- [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). +- [ ] **Direct conversation view** -- allow users to easily page through all direct-message conversations they're a part of. ### Mid/late 2023 -- **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. -- **Limited peering/allowlists** -- allow instance admins to limit federation with other instances by default. +- [ ] **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. +- [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 -- **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. +- [ ] **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. ### 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... diff --git a/docs/admin/domain_blocks.md b/docs/admin/domain_blocks.md new file mode 100644 index 000000000..76f11d8a5 --- /dev/null +++ b/docs/admin/domain_blocks.md @@ -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! diff --git a/docs/admin/federation_modes.md b/docs/admin/federation_modes.md new file mode 100644 index 000000000..8313d8af2 --- /dev/null +++ b/docs/admin/federation_modes.md @@ -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! diff --git a/docs/admin/settings.md b/docs/admin/settings.md index c30806331..344a97473 100644 --- a/docs/admin/settings.md +++ b/docs/admin/settings.md @@ -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 -To use the Admin API your account has to be promoted as such: -``` +## Setting admin account permissions and logging in + +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 ``` -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 -![Screenshot of the GoToSocial admin panel, showing the fields to change an instance's settings](../assets/admin-settings.png) +## Moderation -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 -You can use media cleanup to remove remote media older than the specified number of days. This also removes unused headers and avatars. +### Reports + +![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) -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 -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. +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). + +#### 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) 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 -![List of reports for testing, one resolved and one open.](../assets/admin-settings-reports.png) +## Administration -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. -### 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) 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. -### 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) 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). +### 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. diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index e522cdb2a..d3069866f 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -947,16 +947,25 @@ definitions: type: object x-go-name: Domain x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model - domainBlock: - description: DomainBlock represents a block on one domain + 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 + domainPermission: properties: 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" type: string x-go-name: CreatedAt 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 type: string x-go-name: CreatedBy @@ -966,20 +975,18 @@ definitions: type: string x-go-name: Domain id: - description: The ID of the domain block. + description: The ID of the domain permission entry. example: 01FBW21XJA09XYX51KV5JVBW0F readOnly: true type: string x-go-name: ID obfuscate: - description: |- - Obfuscate the domain name when serving this domain block publicly. - A useful anti-harassment tool. + description: Obfuscate the domain name when serving this domain permission entry publicly. example: false type: boolean x-go-name: Obfuscate 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 type: string x-go-name: PrivateComment @@ -994,7 +1001,7 @@ definitions: type: string x-go-name: SilencedAt 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 type: string x-go-name: SubscriptionID @@ -1003,43 +1010,46 @@ definitions: example: "2021-07-30T09:20:25+00:00" type: string x-go-name: SuspendedAt + title: DomainPermission represents a permission applied to one domain (explicit block/allow). type: object - x-go-name: DomainBlock + x-go-name: DomainPermission x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model - domainBlockCreateRequest: + domainPermissionCreateRequest: properties: 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 x-go-name: Domain 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 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 x-go-name: Obfuscate 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 x-go-name: PrivateComment 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 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 - x-go-name: DomainBlockCreateRequest - 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-name: DomainPermissionRequest x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model emoji: properties: @@ -4011,6 +4021,173 @@ paths: summary: Get a list of existing emoji categories. tags: - 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: get: operationId: domainBlocksGet @@ -4026,7 +4203,7 @@ paths: description: All domain blocks currently in place. schema: items: - $ref: '#/definitions/domainBlock' + $ref: '#/definitions/domainPermission' type: array "400": description: bad request @@ -4088,7 +4265,7 @@ paths: "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. schema: - $ref: '#/definitions/domainBlock' + $ref: '#/definitions/domainPermission' "400": description: bad request "401": @@ -4124,7 +4301,7 @@ paths: "200": description: The domain block that was just deleted. schema: - $ref: '#/definitions/domainBlock' + $ref: '#/definitions/domainPermission' "400": description: bad request "401": @@ -4159,7 +4336,7 @@ paths: "200": description: The requested domain block. schema: - $ref: '#/definitions/domainBlock' + $ref: '#/definitions/domainPermission' "400": description: bad request "401": diff --git a/docs/assets/diagrams/federation_modes.drawio b/docs/assets/diagrams/federation_modes.drawio new file mode 100644 index 000000000..225c57ea2 --- /dev/null +++ b/docs/assets/diagrams/federation_modes.drawio @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/diagrams/federation_modes.png b/docs/assets/diagrams/federation_modes.png new file mode 100644 index 0000000000000000000000000000000000000000..054bf58ea1c579c722ab9270f0b7e08dd1aa6938 GIT binary patch literal 60821 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. + +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, + ) +} diff --git a/internal/api/client/admin/domainallowdelete.go b/internal/api/client/admin/domainallowdelete.go new file mode 100644 index 000000000..6237e403f --- /dev/null +++ b/internal/api/client/admin/domainallowdelete.go @@ -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 . + +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) +} diff --git a/internal/api/client/admin/domainallowget.go b/internal/api/client/admin/domainallowget.go new file mode 100644 index 000000000..aa21743fa --- /dev/null +++ b/internal/api/client/admin/domainallowget.go @@ -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 . + +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) +} diff --git a/internal/api/client/admin/domainallowsget.go b/internal/api/client/admin/domainallowsget.go new file mode 100644 index 000000000..6391c7138 --- /dev/null +++ b/internal/api/client/admin/domainallowsget.go @@ -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 . + +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) +} diff --git a/internal/api/client/admin/domainblockcreate.go b/internal/api/client/admin/domainblockcreate.go index 5cf9ea279..5234561cf 100644 --- a/internal/api/client/admin/domainblockcreate.go +++ b/internal/api/client/admin/domainblockcreate.go @@ -18,15 +18,8 @@ package admin import ( - "errors" - "fmt" - "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/oauth" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) // DomainBlocksPOSTHandler swagger:operation POST /api/v1/admin/domain_blocks domainBlockCreate @@ -108,7 +101,7 @@ // 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: -// "$ref": "#/definitions/domainBlock" +// "$ref": "#/definitions/domainPermission" // '400': // description: bad request // '401': @@ -127,108 +120,9 @@ // '500': // description: internal server error func (m *Module) DomainBlocksPOSTHandler(c *gin.Context) { - 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.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. + m.createDomainPermissions(c, + gtsmodel.DomainPermissionBlock, + m.processor.Admin().DomainPermissionCreate, + m.processor.Admin().DomainPermissionsImport, ) - 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 } diff --git a/internal/api/client/admin/domainblockdelete.go b/internal/api/client/admin/domainblockdelete.go index 9318bad87..a6f6619cd 100644 --- a/internal/api/client/admin/domainblockdelete.go +++ b/internal/api/client/admin/domainblockdelete.go @@ -18,14 +18,8 @@ package admin import ( - "errors" - "fmt" - "net/http" - "github.com/gin-gonic/gin" - apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) // DomainBlockDELETEHandler swagger:operation DELETE /api/v1/admin/domain_blocks/{id} domainBlockDelete @@ -55,7 +49,7 @@ // '200': // description: The domain block that was just deleted. // schema: -// "$ref": "#/definitions/domainBlock" +// "$ref": "#/definitions/domainPermission" // '400': // description: bad request // '401': @@ -74,35 +68,5 @@ // '500': // description: internal server error func (m *Module) DomainBlockDELETEHandler(c *gin.Context) { - 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 - } - - 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) + m.deleteDomainPermission(c, gtsmodel.DomainPermissionBlock) } diff --git a/internal/api/client/admin/domainblockget.go b/internal/api/client/admin/domainblockget.go index 87bb75a27..9e8d29905 100644 --- a/internal/api/client/admin/domainblockget.go +++ b/internal/api/client/admin/domainblockget.go @@ -18,13 +18,8 @@ package admin import ( - "fmt" - "net/http" - "github.com/gin-gonic/gin" - apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) // DomainBlockGETHandler swagger:operation GET /api/v1/admin/domain_blocks/{id} domainBlockGet @@ -54,7 +49,7 @@ // '200': // description: The requested domain block. // schema: -// "$ref": "#/definitions/domainBlock" +// "$ref": "#/definitions/domainPermission" // '400': // description: bad request // '401': @@ -68,40 +63,5 @@ // '500': // description: internal server error func (m *Module) DomainBlockGETHandler(c *gin.Context) { - 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 - } - - 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) + m.getDomainPermission(c, gtsmodel.DomainPermissionBlock) } diff --git a/internal/api/client/admin/domainblocksget.go b/internal/api/client/admin/domainblocksget.go index 68947f471..bdcc03469 100644 --- a/internal/api/client/admin/domainblocksget.go +++ b/internal/api/client/admin/domainblocksget.go @@ -18,13 +18,8 @@ package admin import ( - "fmt" - "net/http" - "github.com/gin-gonic/gin" - apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) // DomainBlocksGETHandler swagger:operation GET /api/v1/admin/domain_blocks domainBlocksGet @@ -60,7 +55,7 @@ // schema: // type: array // items: -// "$ref": "#/definitions/domainBlock" +// "$ref": "#/definitions/domainPermission" // '400': // description: bad request // '401': @@ -74,34 +69,5 @@ // '500': // description: internal server error func (m *Module) DomainBlocksGETHandler(c *gin.Context) { - 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.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) + m.getDomainPermissions(c, gtsmodel.DomainPermissionBlock) } diff --git a/internal/api/client/admin/domainpermission.go b/internal/api/client/admin/domainpermission.go new file mode 100644 index 000000000..80aa05041 --- /dev/null +++ b/internal/api/client/admin/domainpermission.go @@ -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 . + +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) +} diff --git a/internal/api/model/domain.go b/internal/api/model/domain.go index c5f77c82f..a5e1ddf10 100644 --- a/internal/api/model/domain.go +++ b/internal/api/model/domain.go @@ -37,46 +37,53 @@ type Domain struct { 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 -type DomainBlock struct { +// swagger:model domainPermission +type DomainPermission struct { Domain - // The ID of the domain block. + // The ID of the domain permission entry. // example: 01FBW21XJA09XYX51KV5JVBW0F // readonly: true ID string `json:"id,omitempty"` - // Obfuscate the domain name when serving this domain block publicly. - // A useful anti-harassment tool. + // Obfuscate the domain name when serving this domain permission entry publicly. // example: false 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 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 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 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 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 -type DomainBlockCreateRequest struct { - // A list of domains to block. Only used if import=true is specified. +// swagger:model domainPermissionCreateRequest +type DomainPermissionRequest struct { + // 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"` - // 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"` - // 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"` - // 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"` - // 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"` } diff --git a/internal/api/util/parsequery.go b/internal/api/util/parsequery.go index a87c77aeb..6a9116dcf 100644 --- a/internal/api/util/parsequery.go +++ b/internal/api/util/parsequery.go @@ -60,10 +60,10 @@ WebUsernameKey = "username" WebStatusIDKey = "status" - /* Domain block keys */ + /* Domain permission keys */ - DomainBlockExportKey = "export" - DomainBlockImportKey = "import" + DomainPermissionExportKey = "export" + DomainPermissionImportKey = "import" ) // 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) } -func ParseDomainBlockExport(value string, defaultValue bool) (bool, gtserror.WithCode) { - return parseBool(value, defaultValue, DomainBlockExportKey) +func ParseDomainPermissionExport(value string, defaultValue bool) (bool, gtserror.WithCode) { + return parseBool(value, defaultValue, DomainPermissionExportKey) } -func ParseDomainBlockImport(value string, defaultValue bool) (bool, gtserror.WithCode) { - return parseBool(value, defaultValue, DomainBlockImportKey) +func ParseDomainPermissionImport(value string, defaultValue bool) (bool, gtserror.WithCode) { + return parseBool(value, defaultValue, DomainPermissionImportKey) } /* diff --git a/internal/cache/domain/domain.go b/internal/cache/domain/domain.go index 37e97472a..051ec5c1b 100644 --- a/internal/cache/domain/domain.go +++ b/internal/cache/domain/domain.go @@ -26,23 +26,28 @@ "golang.org/x/exp/slices" ) -// BlockCache provides a means of caching domain blocks in memory to reduce load -// on an underlying storage mechanism, e.g. a database. +// Cache provides a means of caching domains in memory to reduce +// 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 -// call to .IsBlocked(). In the case of a nil internal block list, the loader function is called to -// hydrate the cache with the latest list of domain blocks. The .Clear() function can be used to -// invalidate the cache, e.g. when a domain block is added / deleted from the database. -type BlockCache struct { +// The in-memory domain list is kept up-to-date by means of a passed +// loader function during every call to .Matches(). In the case of +// a nil internal domain list, the loader function is called to hydrate +// the cache with the latest list of domains. +// +// 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 - // current domain block cache radix trie. + // current domain cache radix trie. 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. -func (b *BlockCache) IsBlocked(domain string, load func() ([]string, error)) (bool, error) { +// Matches checks whether domain matches an entry in the cache. +// 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. - ptr := atomic.LoadPointer(&b.rootptr) + ptr := atomic.LoadPointer(&c.rootptr) if ptr == nil { // Cache is not hydrated. @@ -67,7 +72,7 @@ func (b *BlockCache) IsBlocked(domain string, load func() ([]string, error)) (bo // Store the new node ptr. ptr = unsafe.Pointer(root) - atomic.StorePointer(&b.rootptr, ptr) + atomic.StorePointer(&c.rootptr, ptr) } // 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, -// triggering a reload on next call to .IsBlocked(). -func (b *BlockCache) Clear() { - atomic.StorePointer(&b.rootptr, nil) +// triggering a reload on next call to .Matches(). +func (c *Cache) Clear() { + atomic.StorePointer(&c.rootptr, nil) } -// String returns a string representation of stored domains in block cache. -func (b *BlockCache) String() string { - if ptr := atomic.LoadPointer(&b.rootptr); ptr != nil { +// String returns a string representation of stored domains in cache. +func (c *Cache) String() string { + if ptr := atomic.LoadPointer(&c.rootptr); ptr != nil { return (*root)(ptr).String() } return "" } -// root is the root node in the domain -// block cache radix trie. this is the -// singular access point to the trie. +// root is the root node in the domain cache radix trie. this is the singular access point to the trie. type root struct{ root node } // 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 -// an existing stored domain block in this radix trie. +// an existing stored domain in this radix trie. func (r *root) Match(domain string) bool { return r.root.match(strings.Split(domain, ".")) } // Sort will sort the entire radix trie ensuring that // 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. func (r *root) Sort() { r.root.sort() @@ -154,7 +157,7 @@ func (n *node) add(parts []string) { if len(parts) == 0 { // Drop all children here as - // this is a higher-level block + // this is a higher-level domain // than that we previously had. nn.child = nil return diff --git a/internal/cache/domain/domain_test.go b/internal/cache/domain/domain_test.go index 8f975497b..9e091e1d0 100644 --- a/internal/cache/domain/domain_test.go +++ b/internal/cache/domain/domain_test.go @@ -24,21 +24,21 @@ "github.com/superseriousbusiness/gotosocial/internal/cache/domain" ) -func TestBlockCache(t *testing.T) { - c := new(domain.BlockCache) +func TestCache(t *testing.T) { + c := new(domain.Cache) - blocks := []string{ + cachedDomains := []string{ "google.com", "google.co.uk", "pleroma.bad.host", } loader := func() ([]string, error) { - t.Log("load: returning blocked domains") - return blocks, nil + t.Log("load: returning cached domains") + return cachedDomains, nil } - // Check a list of known blocked domains. + // Check a list of known cached domains. for _, domain := range []string{ "google.com", "mail.google.com", @@ -47,13 +47,13 @@ func TestBlockCache(t *testing.T) { "pleroma.bad.host", "dev.pleroma.bad.host", } { - t.Logf("checking domain is blocked: %s", domain) - if b, _ := c.IsBlocked(domain, loader); !b { - t.Errorf("domain should be blocked: %s", domain) + t.Logf("checking domain matches: %s", domain) + if b, _ := c.Matches(domain, loader); !b { + 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{ "askjeeves.com", "ask-kim.co.uk", @@ -62,9 +62,9 @@ func TestBlockCache(t *testing.T) { "gts.bad.host", "mastodon.bad.host", } { - t.Logf("checking domain isn't blocked: %s", domain) - if b, _ := c.IsBlocked(domain, loader); b { - t.Errorf("domain should not be blocked: %s", domain) + t.Logf("checking domain isn't matched: %s", domain) + if b, _ := c.Matches(domain, loader); b { + t.Errorf("domain should not be matched: %s", domain) } } @@ -76,10 +76,10 @@ func TestBlockCache(t *testing.T) { knownErr := errors.New("known 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") return nil, 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) } } diff --git a/internal/cache/gts.go b/internal/cache/gts.go index 12e917919..16a1585f7 100644 --- a/internal/cache/gts.go +++ b/internal/cache/gts.go @@ -36,7 +36,8 @@ type GTSCaches struct { block *result.Cache[*gtsmodel.Block] blockIDs *SliceCache[string] boostOfIDs *SliceCache[string] - domainBlock *domain.BlockCache + domainAllow *domain.Cache + domainBlock *domain.Cache emoji *result.Cache[*gtsmodel.Emoji] emojiCategory *result.Cache[*gtsmodel.EmojiCategory] follow *result.Cache[*gtsmodel.Follow] @@ -72,6 +73,7 @@ func (c *GTSCaches) Init() { c.initBlock() c.initBlockIDs() c.initBoostOfIDs() + c.initDomainAllow() c.initDomainBlock() c.initEmoji() c.initEmojiCategory() @@ -139,8 +141,13 @@ func (c *GTSCaches) BoostOfIDs() *SliceCache[string] { 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. -func (c *GTSCaches) DomainBlock() *domain.BlockCache { +func (c *GTSCaches) DomainBlock() *domain.Cache { return c.domainBlock } @@ -384,8 +391,12 @@ func (c *GTSCaches) initBoostOfIDs() { )} } +func (c *GTSCaches) initDomainAllow() { + c.domainAllow = new(domain.Cache) +} + func (c *GTSCaches) initDomainBlock() { - c.domainBlock = new(domain.BlockCache) + c.domainBlock = new(domain.Cache) } func (c *GTSCaches) initEmoji() { diff --git a/internal/config/config.go b/internal/config/config.go index 16ef32a8b..314257831 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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."` 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"` - 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"` - InstanceExposeSuspendedWeb bool `name:"instance-expose-suspended-web" usage:"Expose list of suspended instances as webpage on /about/suspended"` - InstanceExposePublicTimeline bool `name:"instance-expose-public-timeline" usage:"Allow unauthenticated users to query /api/v1/timelines/public"` - 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"` + InstanceFederationMode string `name:"instance-federation-mode" usage:"Set instance federation mode."` + InstanceExposePeers bool `name:"instance-expose-peers" usage:"Allow unauthenticated users to query /api/v1/instance/peers?filter=open"` + 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"` + InstanceExposeSuspendedWeb bool `name:"instance-expose-suspended-web" usage:"Expose list of suspended instances as webpage on /about/suspended"` + InstanceExposePublicTimeline bool `name:"instance-expose-public-timeline" usage:"Allow unauthenticated users to query /api/v1/timelines/public"` + 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."` 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."` diff --git a/internal/config/const.go b/internal/config/const.go new file mode 100644 index 000000000..29e4b14e8 --- /dev/null +++ b/internal/config/const.go @@ -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 . + +package config + +// Instance federation mode determines how this +// instance federates with others (if at all). +const ( + InstanceFederationModeBlocklist = "blocklist" + InstanceFederationModeAllowlist = "allowlist" + InstanceFederationModeDefault = InstanceFederationModeBlocklist +) diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 9ad9c125c..fe2aa3acc 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -57,6 +57,7 @@ WebTemplateBaseDir: "./web/template/", WebAssetBaseDir: "./web/assets/", + InstanceFederationMode: InstanceFederationModeDefault, InstanceExposePeers: false, InstanceExposeSuspended: false, InstanceExposeSuspendedWeb: false, diff --git a/internal/config/flags.go b/internal/config/flags.go index 74ceedc00..29e0726a6 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -83,6 +83,7 @@ func (s *ConfigState) AddServerFlags(cmd *cobra.Command) { cmd.Flags().String(WebAssetBaseDirFlag(), cfg.WebAssetBaseDir, fieldtag("WebAssetBaseDir", "usage")) // Instance + cmd.Flags().String(InstanceFederationModeFlag(), cfg.InstanceFederationMode, fieldtag("InstanceFederationMode", "usage")) cmd.Flags().Bool(InstanceExposePeersFlag(), cfg.InstanceExposePeers, fieldtag("InstanceExposePeers", "usage")) cmd.Flags().Bool(InstanceExposeSuspendedFlag(), cfg.InstanceExposeSuspended, fieldtag("InstanceExposeSuspended", "usage")) cmd.Flags().Bool(InstanceExposeSuspendedWebFlag(), cfg.InstanceExposeSuspendedWeb, fieldtag("InstanceExposeSuspendedWeb", "usage")) diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index f232d37a3..46a239596 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -749,6 +749,31 @@ func GetWebAssetBaseDir() string { return global.GetWebAssetBaseDir() } // SetWebAssetBaseDir safely sets the value for global configuration 'WebAssetBaseDir' field 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 func (st *ConfigState) GetInstanceExposePeers() (v bool) { st.mutex.RLock() diff --git a/internal/config/validate.go b/internal/config/validate.go index bc8edc816..45cdc4eee 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -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)) } + // 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() if webAssetsBaseDir == "" { errs = append(errs, fmt.Errorf("%s must be set", WebAssetBaseDirFlag())) diff --git a/internal/db/bundb/domain.go b/internal/db/bundb/domain.go index c989d4fe4..dd626bc0a 100644 --- a/internal/db/bundb/domain.go +++ b/internal/db/bundb/domain.go @@ -23,6 +23,7 @@ "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/state" "github.com/superseriousbusiness/gotosocial/internal/util" @@ -34,6 +35,102 @@ type domainDB struct { 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 { // Normalize the domain as punycode var err error @@ -137,14 +234,32 @@ func (d *domainDB) IsDomainBlocked(ctx context.Context, domain string) (bool, er return false, err } - // Check for easy case, domain referencing *us* + // Domain referencing *us* cannot be blocked. if domain == "" || domain == config.GetAccountDomain() || domain == config.GetHost() { 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) - 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 // 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 }) + 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) { diff --git a/internal/db/bundb/domain_test.go b/internal/db/bundb/domain_test.go index e4e199fa1..ff687cf59 100644 --- a/internal/db/bundb/domain_test.go +++ b/internal/db/bundb/domain_test.go @@ -55,6 +55,59 @@ func (suite *DomainTestSuite) TestIsDomainBlocked() { suite.WithinDuration(time.Now(), domainBlock.CreatedAt, 10*time.Second) } +func (suite *DomainTestSuite) TestIsDomainBlockedWithAllow() { + ctx := context.Background() + + domainBlock := >smodel.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 := >smodel.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() { ctx := context.Background() diff --git a/internal/db/bundb/migrations/20230908083121_allowlist.go.go b/internal/db/bundb/migrations/20230908083121_allowlist.go.go new file mode 100644 index 000000000..2d86f8c03 --- /dev/null +++ b/internal/db/bundb/migrations/20230908083121_allowlist.go.go @@ -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 . + +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(>smodel.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) + } +} diff --git a/internal/db/domain.go b/internal/db/domain.go index 740ccefe6..3f7803d62 100644 --- a/internal/db/domain.go +++ b/internal/db/domain.go @@ -26,6 +26,25 @@ // Domain contains DB functions related to domains and domain blocks. 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(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(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) - // 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) - // 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) - // 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) } diff --git a/internal/gtsmodel/adminaction.go b/internal/gtsmodel/adminaction.go index 1e55a33f9..e8b82e495 100644 --- a/internal/gtsmodel/adminaction.go +++ b/internal/gtsmodel/adminaction.go @@ -42,7 +42,7 @@ func (c AdminActionCategory) String() string { case AdminActionCategoryDomain: return "domain" default: - return "unknown" + return "unknown" //nolint:goconst } } diff --git a/internal/gtsmodel/domainallow.go b/internal/gtsmodel/domainallow.go new file mode 100644 index 000000000..2a3e53e79 --- /dev/null +++ b/internal/gtsmodel/domainallow.go @@ -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 . + +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 +} diff --git a/internal/gtsmodel/domainblock.go b/internal/gtsmodel/domainblock.go index dfe642ef5..4e0b3ca65 100644 --- a/internal/gtsmodel/domainblock.go +++ b/internal/gtsmodel/domainblock.go @@ -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 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 +} diff --git a/internal/gtsmodel/domainpermission.go b/internal/gtsmodel/domainpermission.go new file mode 100644 index 000000000..01e8fdaaa --- /dev/null +++ b/internal/gtsmodel/domainpermission.go @@ -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 . + +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 + } +} diff --git a/internal/processing/admin/domainallow.go b/internal/processing/admin/domainallow.go new file mode 100644 index 000000000..bab54e308 --- /dev/null +++ b/internal/processing/admin/domainallow.go @@ -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 . + +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 = >smodel.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, + >smodel.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, + >smodel.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) +} diff --git a/internal/processing/admin/domainblock.go b/internal/processing/admin/domainblock.go index 1262bf6b0..4161ec12f 100644 --- a/internal/processing/admin/domainblock.go +++ b/internal/processing/admin/domainblock.go @@ -18,14 +18,9 @@ package admin import ( - "bytes" "context" - "encoding/json" "errors" "fmt" - "io" - "mime/multipart" - "net/http" "time" "codeberg.org/gruf/go-kv" @@ -40,14 +35,7 @@ "github.com/superseriousbusiness/gotosocial/internal/text" ) -// DomainBlockCreate creates an instance-level block against the given domain, -// 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( +func (p *Processor) createDomainBlock( ctx context.Context, adminAcct *gtsmodel.Account, domain string, @@ -55,7 +43,7 @@ func (p *Processor) DomainBlockCreate( publicComment string, privateComment string, subscriptionID string, -) (*apimodel.DomainBlock, string, gtserror.WithCode) { +) (*apimodel.DomainPermission, string, gtserror.WithCode) { // Check if a block already exists for this domain. domainBlock, err := p.state.DB.GetDomainBlock(ctx, domain) if err != nil && !errors.Is(err, db.ErrNoEntries) { @@ -98,13 +86,22 @@ func (p *Processor) DomainBlockCreate( Text: domainBlock.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 block side effects") + defer func() { l.Info("finished processing domain block side effects") }() + return p.domainBlockSideEffects(ctx, domainBlock) }, ); errWithCode != nil { return nil, actionID, errWithCode } - apiDomainBlock, errWithCode := p.apiDomainBlock(ctx, domainBlock) + apiDomainBlock, errWithCode := p.apiDomainPerm(ctx, domainBlock, false) if errWithCode != nil { return nil, actionID, errWithCode } @@ -112,206 +109,6 @@ func(ctx context.Context) gtserror.MultiError { 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, - >smodel.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: // // 1. Strip most info away from the instance entry for the domain. @@ -323,13 +120,6 @@ func (p *Processor) domainBlockSideEffects( ctx context.Context, block *gtsmodel.DomainBlock, ) gtserror.MultiError { - l := log. - WithContext(ctx). - WithFields(kv.Fields{ - {"domain", block.Domain}, - }...) - l.Debug("processing domain block side effects") - var errs gtserror.MultiError // 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) return errs } - l.Debug("instance entry updated") } // For each account that belongs to this domain, @@ -372,6 +161,68 @@ func (p *Processor) domainBlockSideEffects( 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, + >smodel.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 // domain block: // @@ -385,13 +236,6 @@ func (p *Processor) domainUnblockSideEffects( ctx context.Context, block *gtsmodel.DomainBlock, ) gtserror.MultiError { - l := log. - WithContext(ctx). - WithFields(kv.Fields{ - {"domain", block.Domain}, - }...) - l.Debug("processing domain unblock side effects") - var errs gtserror.MultiError // 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) return errs } - l.Debug("instance entry updated") } // Unsuspend all accounts whose suspension origin was this domain block. diff --git a/internal/processing/admin/domainblock_test.go b/internal/processing/admin/domainblock_test.go deleted file mode 100644 index 9525ce7c3..000000000 --- a/internal/processing/admin/domainblock_test.go +++ /dev/null @@ -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 . - -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)) -} diff --git a/internal/processing/admin/domainpermission.go b/internal/processing/admin/domainpermission.go new file mode 100644 index 000000000..c759c0f11 --- /dev/null +++ b/internal/processing/admin/domainpermission.go @@ -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 . + +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) +} diff --git a/internal/processing/admin/domainpermission_test.go b/internal/processing/admin/domainpermission_test.go new file mode 100644 index 000000000..b6de226c1 --- /dev/null +++ b/internal/processing/admin/domainpermission_test.go @@ -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 . + +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)) +} diff --git a/internal/processing/admin/util.go b/internal/processing/admin/util.go index 403602901..c82ff2dc1 100644 --- a/internal/processing/admin/util.go +++ b/internal/processing/admin/util.go @@ -22,28 +22,11 @@ "errors" "time" - 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" ) -// 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, // removing most information from it and marking it as // suspended. diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 774b68157..af77734cc 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -91,8 +91,8 @@ type TypeConverter interface { RelationshipToAPIRelationship(ctx context.Context, r *gtsmodel.Relationship) (*apimodel.Relationship, error) // NotificationToAPINotification converts a gts notification into a api notification 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 - DomainBlockToAPIDomainBlock(ctx context.Context, b *gtsmodel.DomainBlock, export bool) (*apimodel.DomainBlock, error) + // DomainPermToAPIDomainPerm converts a gts model domin block or allow into an api domain permission. + 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(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 diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 050997bda..11838e2bd 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -1041,32 +1041,39 @@ func (c *converter) NotificationToAPINotification(ctx context.Context, n *gtsmod }, 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, // de-punify it just in case. - d, err := util.DePunify(b.Domain) + domain, err := util.DePunify(d.GetDomain()) 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: d, - PublicComment: b.PublicComment, + Domain: domain, + PublicComment: d.GetPublicComment(), }, } - // if we're exporting a domain block, return it with minimal information attached - if !export { - domainBlock.ID = b.ID - domainBlock.Obfuscate = *b.Obfuscate - domainBlock.PrivateComment = b.PrivateComment - domainBlock.SubscriptionID = b.SubscriptionID - domainBlock.CreatedBy = b.CreatedByAccountID - domainBlock.CreatedAt = util.FormatISO8601(b.CreatedAt) + // If we're exporting, provide + // only bare minimum detail. + if export { + return domainPerm, nil } - 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) { diff --git a/mkdocs.yml b/mkdocs.yml index bcfc9f754..189f01a7f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -102,6 +102,8 @@ nav: - "Admin": - "admin/settings.md" + - "admin/federation_modes.md" + - "admin/domain_blocks.md" - "admin/cli.md" - "admin/backup_and_restore.md" - "Federation": diff --git a/test/envparsing.sh b/test/envparsing.sh index 68e250db0..684d008a9 100755 --- a/test/envparsing.sh +++ b/test/envparsing.sh @@ -81,6 +81,7 @@ EXPECT=$(cat << "EOF" "instance-expose-public-timeline": true, "instance-expose-suspended": true, "instance-expose-suspended-web": true, + "instance-federation-mode": "allowlist", "instance-inject-mastodon-version": true, "landing-page-user": "admin", "letsencrypt-cert-dir": "/gotosocial/storage/certs", @@ -192,6 +193,7 @@ GTS_INSTANCE_EXPOSE_PEERS=true \ GTS_INSTANCE_EXPOSE_SUSPENDED=true \ GTS_INSTANCE_EXPOSE_SUSPENDED_WEB=true \ GTS_INSTANCE_EXPOSE_PUBLIC_TIMELINE=true \ +GTS_INSTANCE_FEDERATION_MODE='allowlist' \ GTS_INSTANCE_DELIVER_TO_SHARED_INBOXES=false \ GTS_INSTANCE_INJECT_MASTODON_VERSION=true \ GTS_ACCOUNTS_ALLOW_CUSTOM_CSS=true \ diff --git a/testrig/config.go b/testrig/config.go index a85a88477..154e61f47 100644 --- a/testrig/config.go +++ b/testrig/config.go @@ -63,6 +63,7 @@ func InitTestConfig() { WebTemplateBaseDir: "./web/template/", WebAssetBaseDir: "./web/assets/", + InstanceFederationMode: config.InstanceFederationModeDefault, InstanceExposePeers: true, InstanceExposeSuspended: true, InstanceExposeSuspendedWeb: true,