Merge branch 'main' into moonlight_hunt
121
README.md
|
|
@ -11,7 +11,7 @@ With GoToSocial, you can keep in touch with your friends, post, read, and share
|
|||
<img src="https://raw.githubusercontent.com/superseriousbusiness/gotosocial/main/docs/assets/sloth.webp" width="300"/>
|
||||
</p>
|
||||
|
||||
**GoToSocial is still [ALPHA SOFTWARE](https://en.wikipedia.org/wiki/Software_release_life_cycle#Alpha)**. It is already deployable and useable, and it federates cleanly with many other Fediverse servers (not yet all). However, many things are not yet implemented, and there are plenty of bugs! We foresee entering beta around the beginning of 2024.
|
||||
**GoToSocial is still [BETA SOFTWARE](https://en.wikipedia.org/wiki/Software_release_life_cycle#Beta)**. It is already deployable and useable, and it federates cleanly with many other Fediverse servers (not yet all). However, many things are not yet implemented, and there are plenty of bugs! We left alpha stage around September/October 2024, and we intend to exit beta some time around 2026.
|
||||
|
||||
Documentation is at [docs.gotosocial.org](https://docs.gotosocial.org). You can skip straight to the API documentation [here](https://docs.gotosocial.org/en/latest/api/swagger/).
|
||||
|
||||
|
|
@ -29,8 +29,12 @@ Here's a screenshot of the instance landing page!
|
|||
- [History and Status](#history-and-status)
|
||||
- [Features](#features)
|
||||
- [Mastodon API compatibility](#mastodon-api-compatibility)
|
||||
- [Granular post settings](#granular-post-settings)
|
||||
- [Customizability](#customizability)
|
||||
- [Granular post visibility settings](#granular-post-visibility-settings)
|
||||
- [Reply controls](#reply-controls)
|
||||
- [Local-only posting](#local-only-posting)
|
||||
- [RSS feed](#rss-feed)
|
||||
- [Rich text formatting](#rich-text-formatting)
|
||||
- [Themes and custom CSS](#themes-and-custom-css)
|
||||
- [Easy to run](#easy-to-run)
|
||||
- [Safety + security features](#safety--security-features)
|
||||
- [Various federation modes](#various-federation-modes)
|
||||
|
|
@ -90,7 +94,9 @@ This project sprang up in February/March 2021 out of a dissatisfaction with the
|
|||
|
||||
It began as a solo project, and then picked up steam as more developers became interested and jumped on.
|
||||
|
||||
For a detailed view on what's implemented and what's not, and progress made towards [beta release](https://en.wikipedia.org/wiki/Software_release_life_cycle#Beta), please see [the roadmap document](https://github.com/superseriousbusiness/gotosocial/blob/main/ROADMAP.md).
|
||||
We made our first Alpha release in November 2021. We left Alpha and entered Beta in September/October 2024.
|
||||
|
||||
For a detailed view on what's implemented and what's not, and progress made towards [stable release](https://en.wikipedia.org/wiki/Software_release_life_cycle#Stable_release), please see [the roadmap document](https://github.com/superseriousbusiness/gotosocial/blob/main/ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -100,44 +106,120 @@ For a detailed view on what's implemented and what's not, and progress made towa
|
|||
|
||||
The Mastodon API has become the de facto standard for client communication with federated servers, so GoToSocial has implemented and extended the API with custom functionality.
|
||||
|
||||
Though most apps that implement the Mastodon API should work, GoToSocial works reliably with beautiful apps like:
|
||||
Though most apps that implement the Mastodon API should work, GoToSocial is tested and works reliably with beautiful apps like:
|
||||
|
||||
* [Tusky](https://tusky.app/) for Android
|
||||
* [Semaphore](https://semaphore.social/) in the browser
|
||||
* [Feditext](https://github.com/feditext/feditext) (beta) on iOS, iPadOS and macOS
|
||||
|
||||
If you've used Mastodon with any of these apps before, you'll find using GoToSocial a breeze.
|
||||
If you've used Mastodon with a third-party app before, you'll find using GoToSocial a breeze.
|
||||
|
||||
### Granular post settings
|
||||
### Granular post visibility settings
|
||||
|
||||
It's important that when you post something, you can choose who sees it.
|
||||
|
||||
GoToSocial offers public/unlisted/friends-only/mutuals-only/and direct posts (slide in DMs! -- with consent).
|
||||
GoToSocial offers public, unlisted/unlocked, followers-only, and direct posts (slide in DMs! -- with consent).
|
||||
|
||||
It also allows you to customize how people interact with your posts:
|
||||
### Reply controls
|
||||
|
||||
- Local-only posts.
|
||||
- Rebloggable/boostable toggle.
|
||||
- 'Likeable' toggle.
|
||||
- 'Replyable' toggle.
|
||||
GoToSocial lets you choose who can reply to your posts, via [interaction policies](https://docs.gotosocial.org/en/latest/user_guide/settings/#default-interaction-policies). You can choose to let anyone reply to your posts, let only your friends reply, and more.
|
||||
|
||||
### Customizability
|
||||

|
||||
|
||||
### Local-only posting
|
||||
|
||||
Sometimes you only want to talk to people you share an instance with. GoToSocial supports this via local-only posting, which ensures that your post stays on your instance only. (Local-only posting is currently dependent on client support.)
|
||||
|
||||
### RSS feed
|
||||
|
||||
GoToSocial lets you opt-in to exposing your profile as an RSS feed, so that people can subscribe to your public feed without missing a post.
|
||||
|
||||
### Rich text formatting
|
||||
|
||||
With GoToSocial, you can write posts using the popular, easy-to-use Markdown markup language, which lets you produce rich HTML posts with support for blockquotes, syntax-highlighted code blocks, lists, inline links, and more.
|
||||
|
||||

|
||||
|
||||
### Themes and custom CSS
|
||||
|
||||
Users can [choose from a variety of fun themes](https://docs.gotosocial.org/en/latest/user_guide/settings/#select-theme) for their profile, or even write their own [custom CSS](https://docs.gotosocial.org/en/latest/user_guide/settings/#custom-css).
|
||||
|
||||
Plenty of [config options](https://github.com/superseriousbusiness/gotosocial/blob/main/example/config.yaml) for admins to play around with too.
|
||||
It's also easy for admins to [add their own custom themes](https://docs.gotosocial.org/en/latest/admin/themes/) for users to choose from.
|
||||
|
||||
<details>
|
||||
<summary>Show theme examples</summary>
|
||||
<figure>
|
||||
<img src="https://raw.githubusercontent.com/superseriousbusiness/gotosocial/main/docs/assets/theme-blurple-dark.png"/>
|
||||
<figcaption>Blurple dark</figcaption>
|
||||
</figure>
|
||||
<hr/>
|
||||
<figure>
|
||||
<img src="https://raw.githubusercontent.com/superseriousbusiness/gotosocial/main/docs/assets/theme-blurple-light.png"/>
|
||||
<figcaption>Blurple light</figcaption>
|
||||
</figure>
|
||||
<hr/>
|
||||
<figure>
|
||||
<img src="https://raw.githubusercontent.com/superseriousbusiness/gotosocial/main/docs/assets/theme-brutalist-light.png"/>
|
||||
<figcaption>Brutalist light</figcaption>
|
||||
</figure>
|
||||
<hr/>
|
||||
<figure>
|
||||
<img src="https://raw.githubusercontent.com/superseriousbusiness/gotosocial/main/docs/assets/theme-brutalist-dark.png"/>
|
||||
<figcaption>Brutalist dark</figcaption>
|
||||
</figure>
|
||||
<hr/>
|
||||
<figure>
|
||||
<img src="https://raw.githubusercontent.com/superseriousbusiness/gotosocial/main/docs/assets/theme-ecks-pee.png"/>
|
||||
<figcaption>Ecks pee</figcaption>
|
||||
</figure>
|
||||
<hr/>
|
||||
<figure>
|
||||
<img src="https://raw.githubusercontent.com/superseriousbusiness/gotosocial/main/docs/assets/theme-midnight-trip.png"/>
|
||||
<figcaption>Midnight trip</figcaption>
|
||||
</figure>
|
||||
<hr/>
|
||||
<figure>
|
||||
<img src="https://raw.githubusercontent.com/superseriousbusiness/gotosocial/main/docs/assets/theme-rainforest.png"/>
|
||||
<figcaption>Rainforest</figcaption>
|
||||
</figure>
|
||||
<hr/>
|
||||
<figure>
|
||||
<img src="https://raw.githubusercontent.com/superseriousbusiness/gotosocial/main/docs/assets/theme-soft.png"/>
|
||||
<figcaption>Soft</figcaption>
|
||||
</figure>
|
||||
<hr/>
|
||||
<figure>
|
||||
<img src="https://raw.githubusercontent.com/superseriousbusiness/gotosocial/main/docs/assets/theme-solarized-dark.png"/>
|
||||
<figcaption>Solarized dark</figcaption>
|
||||
</figure>
|
||||
<hr/>
|
||||
<figure>
|
||||
<img src="https://raw.githubusercontent.com/superseriousbusiness/gotosocial/main/docs/assets/theme-solarized-light.png"/>
|
||||
<figcaption>Solarized light</figcaption>
|
||||
</figure>
|
||||
<hr/>
|
||||
<figure>
|
||||
<img src="https://raw.githubusercontent.com/superseriousbusiness/gotosocial/main/docs/assets/theme-sunset.png"/>
|
||||
<figcaption>Sunset</figcaption>
|
||||
</figure>
|
||||
<hr/>
|
||||
</details>
|
||||
|
||||
### Easy to run
|
||||
|
||||
No external dependencies apart from a database (or just use SQLite!). Simply download the binary + assets (or Docker container), and run.
|
||||
GoToSocial uses only about 250-350MiB of RAM, and requires very little CPU power, so it plays nice with single-board computers, old laptops and tiny $5/month VPSes.
|
||||
|
||||
GoToSocial uses only about 150-250MiB of RAM, so it plays nice with single-board computers, old laptops and tiny $5/month VPSes.
|
||||

|
||||
|
||||
No external dependencies apart from a database (or just use SQLite!).
|
||||
|
||||
Simply download the binary + assets (or Docker container), tweak your configuration, and run.
|
||||
|
||||
### Safety + security features
|
||||
|
||||
- Built-in, automatic support for secure HTTPS with [Let's Encrypt](https://letsencrypt.org/).
|
||||
- Strict privacy enforcement for posts and strict blocking logic.
|
||||
- Import and export allow lists and deny lists. Subscribe to community-created block lists (think Ad blocker, but for federation!).
|
||||
- Import and export allow lists and deny lists. Subscribe to community-created block lists (think Ad blocker, but for federation!) (feature still in progress).
|
||||
- HTTP signature authentication: GoToSocial requires [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12) when sending and receiving messages, to ensure that your messages can't be tampered with and your identity can't be forged.
|
||||
|
||||
### Various federation modes
|
||||
|
|
@ -166,7 +248,7 @@ On top of this API, web developers are encouraged to build any front-end impleme
|
|||
|
||||
## Known Issues
|
||||
|
||||
Since GoToSocial is still in alpha, there are plenty of bugs. We use [GitHub issues](https://github.com/superseriousbusiness/gotosocial/issues?q=is%3Aissue+is%3Aopen+label%3Abug) to track these.
|
||||
Since GoToSocial is still in beta, there are plenty of bugs. We use [GitHub issues](https://github.com/superseriousbusiness/gotosocial/issues?q=is%3Aissue+is%3Aopen+label%3Abug) to track these.
|
||||
|
||||
Since every ActivityPub server implementation has a slightly different interpretation of the protocol, some servers don't quite federate properly with GoToSocial yet. We're tracking these issues [in this project](https://github.com/superseriousbusiness/gotosocial/projects/4). Eventually, we want to make sure that any implementation that can federate nicely with Mastodon should also be able to federate with GoToSocial.
|
||||
|
||||
|
|
@ -273,6 +355,7 @@ The following open source libraries, frameworks, and tools are used by GoToSocia
|
|||
- [jackc/pgconn](https://github.com/jackc/pgconn); Postgres driver. [MIT License](https://spdx.org/licenses/MIT.html).
|
||||
- [jackc/pgx](https://github.com/jackc/pgx); Postgres driver and toolkit. [MIT License](https://spdx.org/licenses/MIT.html).
|
||||
- [KimMachineGun/automemlimit](https://github.com/KimMachineGun/automemlimit); cgroups memory limit checking. [MIT License](https://spdx.org/licenses/MIT.html).
|
||||
- [k3a/html2text](https://github.com/k3a/html2text); HTML-to-text conversion. [MIT License](https://spdx.org/licenses/MIT.html).
|
||||
- [mcuadros/go-syslog](https://github.com/mcuadros/go-syslog); Syslog server library. [MIT License](https://spdx.org/licenses/MIT.html).
|
||||
- [microcosm-cc/bluemonday](https://github.com/microcosm-cc/bluemonday); HTML user-input sanitization. [BSD-3-Clause License](https://spdx.org/licenses/BSD-3-Clause.html).
|
||||
- [miekg/dns](https://github.com/miekg/dns); DNS utilities. [Go License](https://go.dev/LICENSE).
|
||||
|
|
|
|||
15
ROADMAP.md
|
|
@ -1,10 +1,10 @@
|
|||
# Roadmap to Beta <!-- omit in toc -->
|
||||
|
||||
This document contains the roadmap for GoToSocial to be considered eligible for its first [beta release](https://en.wikipedia.org/wiki/Software_release_life_cycle#Beta).
|
||||
This document contains the roadmap for GoToSocial to be considered eligible for its first proper [stable release](https://en.wikipedia.org/wiki/Software_release_life_cycle#Stable_release).
|
||||
|
||||
All the info contained in this document is best-guess only. It's useful to have a rough timeline we can direct people to, but things will undoubtedly change along the way; don't hold us to anything in this doc!
|
||||
|
||||
Thank you to [NLnet](https://nlnet.nl) for helping to fund the alpha phase of GoToSocial development and get us moving towards beta!
|
||||
Thank you to [NLnet](https://nlnet.nl) for helping to fund the alpha and beta phases of GoToSocial development!
|
||||
|
||||
Big thank you to all of our [Open Collective](https://opencollective.com/gotosocial) and [Liberapay](https://liberapay.com/gotosocial) contributors, who've helped us keep the lights on! 💕
|
||||
|
||||
|
|
@ -17,6 +17,7 @@ Big thank you to all of our [Open Collective](https://opencollective.com/gotosoc
|
|||
- [Early 2024](#early-2024)
|
||||
- [BETA milestone](#beta-milestone)
|
||||
- [Remainder 2024 - early 2025](#remainder-2024---early-2025)
|
||||
- [On the way out of BETA to STABLE RELEASE](#on-the-way-out-of-beta-to-stable-release)
|
||||
- [Wishlist](#wishlist)
|
||||
|
||||
## Beta Aims
|
||||
|
|
@ -61,7 +62,7 @@ What follows is a rough timeline of features that will be implemented on the roa
|
|||
|
||||
### BETA milestone
|
||||
|
||||
Completion of all above features indicates that we are now in the BETA phase of GoToSocial. We foresee this happening around Feb/March 2024.
|
||||
Completion of all above features indicates that we are now in the BETA phase of GoToSocial. We foresee this happening around Feb/March 2024. EDIT: It ended up happening in September/October 2024, whoops!
|
||||
|
||||
### Remainder 2024 - early 2025
|
||||
|
||||
|
|
@ -69,9 +70,9 @@ These are provided in no specific order.
|
|||
|
||||
- [x] **Filters v2** -- implement v2 of the filters API.
|
||||
- [x] **Mute accounts** -- mute accounts to prevent their posts showing up in your home timeline (optional: for limited period of time).
|
||||
- [ ] **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.
|
||||
- [x] **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.
|
||||
- [ ] **Block + allow list subscriptions** -- allow instance admins to subscribe their instance to plaintext domain block/allow 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] **Direct conversation view** -- allow users to easily page through all direct-message conversations they're a part of.
|
||||
- [ ] **Oauth token management** -- create / view / invalidate OAuth tokens via the settings panel.
|
||||
- [ ] **Status EDIT support** -- edit statuses that you've created, without having to delete + redraft. Federate edits out properly.
|
||||
- [ ] **Fediverse relay support** -- publish posts to relays, pull posts from relays.
|
||||
|
|
@ -80,6 +81,10 @@ These are provided in no specific order.
|
|||
|
||||
More tbd!
|
||||
|
||||
### On the way out of BETA to STABLE RELEASE
|
||||
|
||||
Tbd.
|
||||
|
||||
## Wishlist
|
||||
|
||||
These cool things will be implemented if time allows (because we really want them):
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import (
|
|||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"codeberg.org/gruf/go-logger/v2/level"
|
||||
"codeberg.org/gruf/go-storage/memory"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/bundb"
|
||||
|
|
@ -41,7 +40,7 @@ func main() {
|
|||
ctx, cncl := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)
|
||||
defer cncl()
|
||||
|
||||
log.SetLevel(level.INFO)
|
||||
log.SetLevel(log.INFO)
|
||||
|
||||
if len(os.Args) != 3 {
|
||||
log.Panic(ctx, "Usage: go run ./cmd/process-emoji <input-file> <output-static>")
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import (
|
|||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"codeberg.org/gruf/go-logger/v2/level"
|
||||
"codeberg.org/gruf/go-storage/memory"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db/bundb"
|
||||
|
|
@ -40,7 +39,7 @@ func main() {
|
|||
ctx, cncl := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)
|
||||
defer cncl()
|
||||
|
||||
log.SetLevel(level.INFO)
|
||||
log.SetLevel(log.INFO)
|
||||
|
||||
if len(os.Args) != 4 {
|
||||
log.Panic(ctx, "Usage: go run ./cmd/process-media <input-file> <output-processed> <output-thumbnail>")
|
||||
|
|
|
|||
|
|
@ -16,3 +16,4 @@ We consider these topics advanced because applying them incorrectly does have th
|
|||
* [Tracing](tracing.md)
|
||||
* [Metrics](metrics.md)
|
||||
* [Replicating SQLite](replicating-sqlite.md)
|
||||
* [SQLite on networked storage](sqlite-networked-storage.md)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ $ sudo apparmor_parser -Kr /etc/apparmor.d/gotosocial
|
|||
```
|
||||
|
||||
!!! tip
|
||||
If you're using SQLite, the AppArmor profile expects the database in `/gotosocial/db/` so you'll need to adjust your configuration paths or the policy accordingly.
|
||||
The provided AppArmor example is just intended to get you started. It will still need to be edited depending on your exact setup; consult the comments in the example profile file for more information.
|
||||
|
||||
With the policy installed, you'll need to configure your system to use it to constrain the permissions GoToSocial has.
|
||||
|
||||
|
|
|
|||
35
docs/advanced/sqlite-networked-storage.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# SQLite on networked storage
|
||||
|
||||
SQLite's operating model assumes the database and the processes or applications using it are colocated on the same host. When running the database in WAL-mode, which is GoToSocial's default, it relies on shared memory between processes to ensure the integrity of your database.
|
||||
|
||||
!!! quote
|
||||
All processes using a database must be on the same host computer; WAL does not work over a network filesystem. This is because WAL requires all processes to share a small amount of memory and processes on separate host machines obviously cannot share memory with each other.
|
||||
|
||||
— SQLite.org [Write-Ahead Logging](https://www.sqlite.org/wal.html)
|
||||
|
||||
This also means that any other processes accessing the database need to run in the same namespace or container context.
|
||||
|
||||
It is in theory possible to run SQLite over Samba, NFS, iSCSI or other forms of filesystems accessed over the network. But it is neither recommended nor supported by the SQLite maintainers, irrespective of whether you're running with write-ahead logging or not. Doing so puts you at risk of database corruption. There is a long history of networked storage having synchronisation issues in their locking primitives, or implementing them with weaker guarantees than what a local filesystem can provide.
|
||||
|
||||
Your cloud provider's external volumes, like Hetzner Cloud Volumes, AWS EBS, GCP Persistent Disk etc. may also cause problems, and add variable latency. This has a tendency to severely degrade SQLite's performance.
|
||||
|
||||
If you're going to access your database over the network, it's better to use a database with a client-server architecture. GoToSocial supports Postgres for such use-cases.
|
||||
|
||||
For the purpose of having a copy of the SQLite database on durable long-term storage, refer to [SQLite streaming replication](replicating-sqlite.md) instead. Remember that neither replication nor using a networked filesystem are a substitute [for having backups](../admin/backup_and_restore.md).
|
||||
|
||||
## Settings
|
||||
|
||||
!!! danger "Corrupted database"
|
||||
We do not support running GoToSocial with SQLite on a networked filesystem and we will not be able to help you if you damage your database this way.
|
||||
|
||||
Should you really want to take this risk, you'll need to adjust the SQLite [synchronous][sqlite-sync] mode and [journal][sqlite-journal] mode to match the limitations of the filesystem.
|
||||
|
||||
[sqlite-sync]: https://www.sqlite.org/pragma.html#pragma_synchronous
|
||||
[sqlite-journal]: https://www.sqlite.org/pragma.html#pragma_journal_mode
|
||||
|
||||
You'll need to update the following settings:
|
||||
|
||||
* `db-sqlite-journal-mode`
|
||||
* `db-sqlite-synchronous`
|
||||
|
||||
We don't provide any recommendations as this will vary based on the solution you're using. See [this issue](https://github.com/superseriousbusiness/gotosocial/issues/3360#issuecomment-2380332027) for what you could potentially set those values to.
|
||||
|
|
@ -950,7 +950,12 @@ definitions:
|
|||
with "direct message" visibility.
|
||||
properties:
|
||||
accounts:
|
||||
description: Participants in the conversation.
|
||||
description: |-
|
||||
Participants in the conversation.
|
||||
|
||||
If this is a conversation between no accounts (ie., a self-directed DM),
|
||||
this will include only the requesting account itself. Otherwise, it will
|
||||
include every other account in the conversation *except* the requester.
|
||||
items:
|
||||
$ref: '#/definitions/account'
|
||||
type: array
|
||||
|
|
@ -8826,11 +8831,27 @@ paths:
|
|||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
- application/xml
|
||||
- application/x-www-form-urlencoded
|
||||
description: |-
|
||||
The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'.
|
||||
The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'.
|
||||
|
||||
The 'interaction_policy' field can be used to set an interaction policy for this status.
|
||||
|
||||
If submitting using form data, use the following pattern to set an interaction policy:
|
||||
|
||||
`interaction_policy[INTERACTION_TYPE][CONDITION][INDEX]=Value`
|
||||
|
||||
For example: `interaction_policy[can_reply][always][0]=author`
|
||||
|
||||
Using `curl` this might look something like:
|
||||
|
||||
`curl -F 'interaction_policy[can_reply][always][0]=author' -F 'interaction_policy[can_reply][always][1]=followers' [... other form fields ...]`
|
||||
|
||||
The JSON equivalent would be:
|
||||
|
||||
`curl -H 'Content-Type: application/json' -d '{"interaction_policy":{"can_reply":{"always":["author","followers"]}} [... other json fields ...]}'`
|
||||
|
||||
The server will perform some normalization on the submitted policy so that you can't submit something totally invalid.
|
||||
operationId: statusCreate
|
||||
parameters:
|
||||
- description: |-
|
||||
|
|
@ -8944,6 +8965,30 @@ paths:
|
|||
name: content_type
|
||||
type: string
|
||||
x-go-name: ContentType
|
||||
- description: Nth entry for interaction_policy.can_favourite.always.
|
||||
in: formData
|
||||
name: interaction_policy[can_favourite][always][0]
|
||||
type: string
|
||||
- description: Nth entry for interaction_policy.can_favourite.with_approval.
|
||||
in: formData
|
||||
name: interaction_policy[can_favourite][with_approval][0]
|
||||
type: string
|
||||
- description: Nth entry for interaction_policy.can_reply.always.
|
||||
in: formData
|
||||
name: interaction_policy[can_reply][always][0]
|
||||
type: string
|
||||
- description: Nth entry for interaction_policy.can_reply.with_approval.
|
||||
in: formData
|
||||
name: interaction_policy[can_reply][with_approval][0]
|
||||
type: string
|
||||
- description: Nth entry for interaction_policy.can_reblog.always.
|
||||
in: formData
|
||||
name: interaction_policy[can_reblog][always][0]
|
||||
type: string
|
||||
- description: Nth entry for interaction_policy.can_reblog.with_approval.
|
||||
in: formData
|
||||
name: interaction_policy[can_reblog][with_approval][0]
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
|
|
@ -8966,7 +9011,7 @@ paths:
|
|||
security:
|
||||
- OAuth2 Bearer:
|
||||
- write:statuses
|
||||
summary: Create a new status.
|
||||
summary: Create a new status using the given form field parameters.
|
||||
tags:
|
||||
- statuses
|
||||
/api/v1/statuses/{id}:
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 90 KiB |
BIN
docs/assets/markdown-post.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
docs/assets/theme-blurple-dark.png
Normal file
|
After Width: | Height: | Size: 650 KiB |
BIN
docs/assets/theme-blurple-light.png
Normal file
|
After Width: | Height: | Size: 652 KiB |
BIN
docs/assets/theme-brutalist-dark.png
Normal file
|
After Width: | Height: | Size: 635 KiB |
BIN
docs/assets/theme-brutalist-light.png
Normal file
|
After Width: | Height: | Size: 639 KiB |
BIN
docs/assets/theme-ecks-pee.png
Normal file
|
After Width: | Height: | Size: 855 KiB |
BIN
docs/assets/theme-midnight-trip.png
Normal file
|
After Width: | Height: | Size: 631 KiB |
BIN
docs/assets/theme-rainforest.png
Normal file
|
After Width: | Height: | Size: 637 KiB |
BIN
docs/assets/theme-soft.png
Normal file
|
After Width: | Height: | Size: 680 KiB |
BIN
docs/assets/theme-solarized-dark.png
Normal file
|
After Width: | Height: | Size: 658 KiB |
BIN
docs/assets/theme-solarized-light.png
Normal file
|
After Width: | Height: | Size: 663 KiB |
BIN
docs/assets/theme-sunset.png
Normal file
|
After Width: | Height: | Size: 653 KiB |
|
|
@ -80,10 +80,18 @@ host: "localhost"
|
|||
# Default: ""
|
||||
account-domain: ""
|
||||
|
||||
# String. Protocol to use for the server. Only change to http for local testing!
|
||||
# This should be the protocol part of the URI that your server is actually reachable on. So even if you're
|
||||
# running GoToSocial behind a reverse proxy that handles SSL certificates for you, instead of using built-in
|
||||
# letsencrypt, it should still be https.
|
||||
# String. Protocol over which the server is reachable from the outside world.
|
||||
#
|
||||
# ONLY CHANGE THIS TO HTTP FOR LOCAL TESTING! IN 99.99% OF CASES YOU SHOULD NOT CHANGE THIS!
|
||||
#
|
||||
# This should be the protocol part of the URI that your server is actually reachable on.
|
||||
# So even if you're running GoToSocial behind a reverse proxy that handles SSL certificates
|
||||
# for you, instead of using built-in letsencrypt, it should still be https, not http.
|
||||
#
|
||||
# Again, ONLY CHANGE THIS TO HTTP FOR LOCAL TESTING! If you set this to `http`, start your instance,
|
||||
# and then later change it to `https`, you will have already broken URI generation for any created
|
||||
# users on the instance. You should only touch this setting if you 100% know what you're doing.
|
||||
#
|
||||
# Options: ["http","https"]
|
||||
# Default: "https"
|
||||
protocol: "https"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,18 @@ In order to make GoToSocial email sending work, you need an smtp-compatible mail
|
|||
|
||||
To validate your configuration, you can use the "Administration -> Actions -> Email" section of the settings panel to send a test email.
|
||||
|
||||
!!! warning
|
||||
Pending an smtp library update, currently only email providers that work with STARTTLS will work with GoToSocial. STARTTLS is generally available over **port 587**.
|
||||
|
||||
For more info, see:
|
||||
|
||||
- [STARTTLS vs SSL vs TLS](https://mailtrap.io/blog/starttls-ssl-tls/)
|
||||
- [Understanding Ports](https://www.mailgun.com/blog/email/which-smtp-port-understanding-ports-25-465-587/)
|
||||
- [Port 587](https://www.mailgun.com/blog/deliverability/smtp-port-587/)
|
||||
|
||||
!!! info
|
||||
For safety reasons, the smtp library used by GoToSocial will refuse to send authentication credentials over an unencrypted connection, unless the mail provider is running on localhost.
|
||||
|
||||
## Settings
|
||||
|
||||
The configuration options for smtp are as follows:
|
||||
|
|
@ -26,6 +38,7 @@ The configuration options for smtp are as follows:
|
|||
smtp-host: ""
|
||||
|
||||
# Int. Port to use to connect to the smtp server.
|
||||
# In the majority of cases, you should use port 587.
|
||||
# Examples: []
|
||||
# Default: 0
|
||||
smtp-port: 0
|
||||
|
|
@ -63,27 +76,16 @@ smtp-disclose-recipients: false
|
|||
|
||||
Note that if you don't set `Host`, then email sending via smtp will be disabled, and the other settings will be ignored. GoToSocial will still log (at trace level) emails that *would* have been sent if smtp was enabled.
|
||||
|
||||
## Behavior
|
||||
|
||||
### SSL
|
||||
|
||||
GoToSocial requires your smtp server to present valid SSL certificates. Most of the big services like Mailgun do this anyway, but if you're running your own mail server without SSL for some reason, and you're trying to connect GoToSocial to it, it will not work.
|
||||
|
||||
The exception to this requirement is if you're running your mail server (or bridge to a mail server) on `localhost`, in which case SSL certs are not required.
|
||||
|
||||
### When are emails sent?
|
||||
## When are emails sent?
|
||||
|
||||
Currently, emails are sent:
|
||||
|
||||
- To the provided email address of a new user to request email confirmation when a new account is created via the API.
|
||||
- To the provided email address of a new user to request email confirmation when a new account is created via the sign up page or API.
|
||||
- To instance admins when a new account is created in this way.
|
||||
- To all active instance moderators + admins when a new moderation report is received. By default, recipients are Bcc'd, but you can change this behavior with the setting `smtp-disclose-recipients`.
|
||||
- To the creator of a report (on this instance) when the report is closed by a moderator.
|
||||
|
||||
### Can I test if my SMTP configuration is correct?
|
||||
|
||||
Yes, you can use the API to send a test email to yourself. Check the API documentation for the `/api/v1/admin/email/test` endpoint.
|
||||
|
||||
### HTML versus Plaintext
|
||||
## HTML versus Plaintext
|
||||
|
||||
Emails are sent in plaintext by default. At this point, there is no option to send emails in html, but this is something that might be added later if there's enough demand for it.
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,6 @@ To see posts, you have to start following people! Once you've followed a few peo
|
|||
|
||||
We introduced a sign-up flow in v0.16.0. The server you want to sign up to must have enabled registrations/sign-ups, as detailed [right here](./admin/signups.md).
|
||||
|
||||
## Why's it still in alpha?
|
||||
## Why's it still in Beta?
|
||||
|
||||
Take a look at the [list of open bugs](https://github.com/superseriousbusiness/gotosocial/issues?q=is%3Aissue+is%3Aopen+label%3Abug) and the [roadmap](https://github.com/superseriousbusiness/gotosocial/blob/main/ROADMAP.md) for a more detailed rundown.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ You can find more detail on system requirements below, but in short you should a
|
|||
|
||||
For a small instance (1-20 active users), GoToSocial will likely hover consistently between 250MB and 350MB of RAM usage once the internal caches are hydrated:
|
||||
|
||||

|
||||

|
||||
|
||||
In the graph above you can see that RAM usage spikes during periods of load. This happens, for example, when when a status gets boosted by someone with many followers, or when the embedded `ffmpeg` binary is decoding or reencoding media files into thumbnails (especially larger video files).
|
||||
|
||||
|
|
@ -65,14 +65,16 @@ If you decide to use a VPS instead, you can spin yourself up something cheap wit
|
|||
|
||||
[Greenhost](https://greenhost.net) is also great: it has zero CO2 emissions, but is a bit more costly. Their 1GB, 1-cpu VPS works great for a single-user or small instance.
|
||||
|
||||
!!! warning "Cloud storage volumes"
|
||||
Not all cloud VPS storage offerings are equal, and just because something claims to be backed by an SSD doesn't mean that it will necessarily be suitable to run a GoToSocial instance.
|
||||
|
||||
The [performance of Hetzner Cloud Volumes](https://github.com/superseriousbusiness/gotosocial/issues/2471#issuecomment-1891098323) is not guaranteed and seems to have very volatile latency. This will result in your GoToSocial instance performing poorly.
|
||||
|
||||
!!! danger "Oracle Free Tier"
|
||||
[Oracle Cloud Free Tier](https://www.oracle.com/cloud/free/) servers are not suitable for a GoToSocial deployment if you intend to federate with more than a handful of other instances and users.
|
||||
|
||||
GoToSocial admins running on Oracle Cloud Free Tier have reported that their instances become extremely slow or unresponsive during periods of moderate load. This is most likely due to memory or storage latency, which causes even simple database queries to take a long time to run.
|
||||
|
||||
!!! danger "Hetzner Cloud Volume"
|
||||
The [performance of Hetzner Cloud Volumes](https://github.com/superseriousbusiness/gotosocial/issues/2471#issuecomment-1891098323) is not guaranteed and seems to have very volatile latency. You're going to have a bad time running your database on those, with extremely poor query performance for even the most basic operations. Before filing performance issues against GoToSocial, make sure the problems reproduce with local storage.
|
||||
|
||||
### Distribution system requirements
|
||||
|
||||
Please make sure to check on your distribution system requirments, especially memory. Many distributions have baseline requirements and running them on a system that doesn't meet them will cause problems without further tuning and tweaking on your part.
|
||||
|
|
@ -99,13 +101,15 @@ GoToSocial supports both SQLite and Postgres as database drivers. Though it is p
|
|||
|
||||
SQLite is the default driver and it's been shown to work brilliantly for instances in the range of 1-30 users (or maybe more).
|
||||
|
||||
!!! danger "SQLite on networked storage"
|
||||
Don't put your SQLite database on remote storage, whether that's NFS/Samba, iSCSI volumes, things like Ceph/Gluster or your cloud provider's network volume storage solution.
|
||||
|
||||
See [SQLite on networked storage](../advanced/sqlite-networked-storage.md) for further information.
|
||||
|
||||
If you're planning on hosting more people than this on an instance, you may wish to use Postgres instead, as it offers the possibility of database clustering and redundancy, at the cost of some complexity.
|
||||
|
||||
Regardless of which database driver you choose, for proper performance they should be run on fast storage that operates with low and stable latency. It is possible to run databases on network attached storage, but this adds variable latency and network congestion to the mix, as well as potential I/O contention on the origin storage.
|
||||
|
||||
!!! danger "Cloud Storage Volumes"
|
||||
Not all cloud VPS storage offerings are equal, and just because something claims to be backed by an SSD doesn't mean that it will necessarily be suitable to run a GoToSocial instance on. Please see the [Server/VPS section](#vps) section below.
|
||||
|
||||
!!! tip
|
||||
Please [backup your database](../admin/backup_and_restore.md). The database contains encryption keys for the instance and any user accounts. You won't be able to federate again from the same domain if you lose these keys!
|
||||
|
||||
|
|
|
|||
|
|
@ -7,23 +7,44 @@ profile gotosocial flags=(attach_disconnected, mediate_deleted) {
|
|||
include <abstractions/nameservice>
|
||||
include <abstractions/user-tmp>
|
||||
|
||||
# Allow common binary install paths.
|
||||
#
|
||||
# You can change or remove these depending on
|
||||
# where you've installed your GoToSocial binary.
|
||||
/gotosocial/gotosocial mrix,
|
||||
/usr/local/bin/gotosocial mrix,
|
||||
/usr/bin/gotosocial mrix,
|
||||
/usr/sbin/gotosocial mrix,
|
||||
|
||||
# Allow access to GoToSocial's storage and database paths.
|
||||
# Change these depending on your db + storage locations.
|
||||
owner /gotosocial/{,**} r,
|
||||
owner /gotosocial/db/* wk,
|
||||
owner /gotosocial/storage/** wk,
|
||||
|
||||
# Allow GoToSocial to write logs
|
||||
# NOTE: you only need to allow write permissions to /var/log/syslog if you've
|
||||
# enabled logging to syslog.
|
||||
# Embedded ffmpeg needs read
|
||||
# permission on /dev/urandom.
|
||||
owner /dev/ r,
|
||||
owner /dev/urandom r,
|
||||
|
||||
# Temp dir access is needed for storing
|
||||
# files briefly during media processing.
|
||||
owner /tmp/ r,
|
||||
owner /tmp/* rwk,
|
||||
|
||||
# If running with GTS_WAZERO_COMPILATION_CACHE set,
|
||||
# change + uncomment the below lines as appropriate:
|
||||
# owner /your/wazero/cache/directory/ r,
|
||||
# owner /your/wazero/cache/directory/** rwk,
|
||||
|
||||
# If you've enabled logging to syslog, allow GoToSocial
|
||||
# to write logs by uncommenting the following line:
|
||||
# owner /var/log/syslog w,
|
||||
|
||||
# These directories are not currently used by any of the recommended
|
||||
# GoToSocial installation methods, but they may be used in the future and/or
|
||||
# for custom installations.
|
||||
# These directories are not currently used by any of
|
||||
# the recommended GoToSocial installation methods, but
|
||||
# may be used in the future and/or for custom installs.
|
||||
# Delete them if you prefer.
|
||||
owner /etc/gotosocial/{,**} r,
|
||||
owner /usr/local/etc/gotosocial/{,**} r,
|
||||
owner /usr/share/gotosocial/{,**} r,
|
||||
|
|
@ -55,9 +76,10 @@ profile gotosocial flags=(attach_disconnected, mediate_deleted) {
|
|||
network inet dgram,
|
||||
network inet6 dgram,
|
||||
|
||||
# Allow GoToSocial to receive signals from unconfined processes
|
||||
# Allow GoToSocial to receive signals from unconfined processes.
|
||||
signal (receive) peer=unconfined,
|
||||
# Allow GoToSocial to send signals to/receive signals from worker processes
|
||||
|
||||
# Allow GoToSocial to send signals to/receive signals from worker processes.
|
||||
signal (send,receive) peer=gotosocial,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,10 +88,18 @@ host: "localhost"
|
|||
# Default: ""
|
||||
account-domain: ""
|
||||
|
||||
# String. Protocol to use for the server. Only change to http for local testing!
|
||||
# This should be the protocol part of the URI that your server is actually reachable on. So even if you're
|
||||
# running GoToSocial behind a reverse proxy that handles SSL certificates for you, instead of using built-in
|
||||
# letsencrypt, it should still be https.
|
||||
# String. Protocol over which the server is reachable from the outside world.
|
||||
#
|
||||
# ONLY CHANGE THIS TO HTTP FOR LOCAL TESTING! IN 99.99% OF CASES YOU SHOULD NOT CHANGE THIS!
|
||||
#
|
||||
# This should be the protocol part of the URI that your server is actually reachable on.
|
||||
# So even if you're running GoToSocial behind a reverse proxy that handles SSL certificates
|
||||
# for you, instead of using built-in letsencrypt, it should still be https, not http.
|
||||
#
|
||||
# Again, ONLY CHANGE THIS TO HTTP FOR LOCAL TESTING! If you set this to `http`, start your instance,
|
||||
# and then later change it to `https`, you will have already broken URI generation for any created
|
||||
# users on the instance. You should only touch this setting if you 100% know what you're doing.
|
||||
#
|
||||
# Options: ["http","https"]
|
||||
# Default: "https"
|
||||
protocol: "https"
|
||||
|
|
@ -817,6 +825,7 @@ oidc-admin-groups: []
|
|||
smtp-host: ""
|
||||
|
||||
# Int. Port to use to connect to the smtp server.
|
||||
# In the majority of cases, you should use port 587.
|
||||
# Examples: []
|
||||
# Default: 0
|
||||
smtp-port: 0
|
||||
|
|
|
|||
48
go.mod
|
|
@ -2,28 +2,27 @@ module github.com/superseriousbusiness/gotosocial
|
|||
|
||||
go 1.22.2
|
||||
|
||||
replace modernc.org/sqlite => gitlab.com/NyaaaWhatsUpDoc/sqlite v1.29.9-concurrency-workaround
|
||||
replace modernc.org/sqlite => gitlab.com/NyaaaWhatsUpDoc/sqlite v1.33.1-concurrency-workaround
|
||||
|
||||
require (
|
||||
codeberg.org/gruf/go-bytes v1.0.2
|
||||
codeberg.org/gruf/go-bytesize v1.0.3
|
||||
codeberg.org/gruf/go-byteutil v1.2.0
|
||||
codeberg.org/gruf/go-cache/v3 v3.5.7
|
||||
codeberg.org/gruf/go-byteutil v1.3.0
|
||||
codeberg.org/gruf/go-cache/v3 v3.6.1
|
||||
codeberg.org/gruf/go-debug v1.3.0
|
||||
codeberg.org/gruf/go-errors/v2 v2.3.2
|
||||
codeberg.org/gruf/go-fastcopy v1.1.3
|
||||
codeberg.org/gruf/go-ffmpreg v0.2.6
|
||||
codeberg.org/gruf/go-iotools v0.0.0-20240710125620-934ae9c654cf
|
||||
codeberg.org/gruf/go-kv v1.6.4
|
||||
codeberg.org/gruf/go-kv v1.6.5
|
||||
codeberg.org/gruf/go-list v0.0.0-20240425093752-494db03d641f
|
||||
codeberg.org/gruf/go-logger/v2 v2.2.1
|
||||
codeberg.org/gruf/go-mempool v0.0.0-20240507125005-cef10d64a760
|
||||
codeberg.org/gruf/go-mimetypes v1.2.0
|
||||
codeberg.org/gruf/go-mutexes v1.5.1
|
||||
codeberg.org/gruf/go-runners v1.6.2
|
||||
codeberg.org/gruf/go-sched v1.2.3
|
||||
codeberg.org/gruf/go-storage v0.1.2
|
||||
codeberg.org/gruf/go-structr v0.8.8
|
||||
codeberg.org/gruf/go-runners v1.6.3
|
||||
codeberg.org/gruf/go-sched v1.2.4
|
||||
codeberg.org/gruf/go-storage v0.2.0
|
||||
codeberg.org/gruf/go-structr v0.8.11
|
||||
codeberg.org/superseriousbusiness/exif-terminator v0.9.0
|
||||
github.com/DmitriyVTitov/size v1.5.0
|
||||
github.com/KimMachineGun/automemlimit v0.6.1
|
||||
|
|
@ -39,14 +38,15 @@ require (
|
|||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/feeds v1.2.0
|
||||
github.com/gorilla/websocket v1.5.2
|
||||
github.com/jackc/pgx/v5 v5.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.1
|
||||
github.com/k3a/html2text v1.2.1
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/miekg/dns v1.1.62
|
||||
github.com/minio/minio-go/v7 v7.0.76
|
||||
github.com/minio/minio-go/v7 v7.0.77
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/ncruces/go-sqlite3 v0.18.3
|
||||
github.com/ncruces/go-sqlite3 v0.18.4
|
||||
github.com/oklog/ulid v1.3.1
|
||||
github.com/prometheus/client_golang v1.20.3
|
||||
github.com/prometheus/client_golang v1.20.4
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
|
|
@ -72,10 +72,10 @@ require (
|
|||
go.opentelemetry.io/otel/sdk v1.29.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.29.0
|
||||
go.opentelemetry.io/otel/trace v1.29.0
|
||||
go.uber.org/automaxprocs v1.5.3
|
||||
go.uber.org/automaxprocs v1.6.0
|
||||
golang.org/x/crypto v0.27.0
|
||||
golang.org/x/image v0.20.0
|
||||
golang.org/x/net v0.28.0
|
||||
golang.org/x/net v0.29.0
|
||||
golang.org/x/oauth2 v0.23.0
|
||||
golang.org/x/text v0.18.0
|
||||
gopkg.in/mcuadros/go-syslog.v2 v2.3.0
|
||||
|
|
@ -85,11 +85,9 @@ require (
|
|||
)
|
||||
|
||||
require (
|
||||
codeberg.org/gruf/go-atomics v1.1.0 // indirect
|
||||
codeberg.org/gruf/go-bitutil v1.1.0 // indirect
|
||||
codeberg.org/gruf/go-fastpath/v2 v2.0.0 // indirect
|
||||
codeberg.org/gruf/go-mangler v1.4.1 // indirect
|
||||
codeberg.org/gruf/go-maps v1.0.3 // indirect
|
||||
codeberg.org/gruf/go-maps v1.0.4 // indirect
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.2.1 // indirect
|
||||
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
|
||||
|
|
@ -154,8 +152,8 @@ require (
|
|||
github.com/imdario/mergo v0.3.16 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jessevdk/go-flags v1.5.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
|
|
@ -182,7 +180,7 @@ require (
|
|||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/common v0.59.1 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
|
|
@ -217,14 +215,14 @@ require (
|
|||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
golang.org/x/tools v0.22.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd // indirect
|
||||
google.golang.org/grpc v1.65.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
google.golang.org/grpc v1.66.1 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.49.3 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
|
|
|
|||
106
go.sum
|
|
@ -30,22 +30,16 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
|
|||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||
codeberg.org/gruf/go-atomics v1.1.0 h1:ni9QXYoRUFYQMXE3akWaUb1wMcPBDc05Md6Rgml7W58=
|
||||
codeberg.org/gruf/go-atomics v1.1.0/go.mod h1:a/4/y/LgvjxjQVnpoy1VVkOSzLS1W9i1g4SJ0nflAa4=
|
||||
codeberg.org/gruf/go-bitutil v1.0.0/go.mod h1:sb8IjlDnjVTz8zPK/8lmHesKxY0Yb3iqHWjUM/SkphA=
|
||||
codeberg.org/gruf/go-bitutil v1.1.0 h1:U1Q+A1mtnPk+npqYrlRBc9ar2C5hYiBd17l1Wrp2Bt8=
|
||||
codeberg.org/gruf/go-bitutil v1.1.0/go.mod h1:rGibFevYTQfYKcPv0Df5KpG8n5xC3AfD4d/UgYeoNy0=
|
||||
codeberg.org/gruf/go-bytes v1.0.2 h1:malqE42Ni+h1nnYWBUAJaDDtEzF4aeN4uPN8DfMNNvo=
|
||||
codeberg.org/gruf/go-bytes v1.0.2/go.mod h1:1v/ibfaosfXSZtRdW2rWaVrDXMc9E3bsi/M9Ekx39cg=
|
||||
codeberg.org/gruf/go-bytesize v1.0.3 h1:Tz8tCxhPLeyM5VryuBNjUHgKmLj4Bx9RbPaUSA3qg6g=
|
||||
codeberg.org/gruf/go-bytesize v1.0.3/go.mod h1:n/GU8HzL9f3UNp/mUKyr1qVmTlj7+xacpp0OHfkvLPs=
|
||||
codeberg.org/gruf/go-byteutil v1.2.0 h1:YoxkpUOoHS82BcPXfiIcWLe/YhS8QhpNUHdfuhN09QM=
|
||||
codeberg.org/gruf/go-byteutil v1.2.0/go.mod h1:cWM3tgMCroSzqoBXUXMhvxTxYJp+TbCr6ioISRY5vSU=
|
||||
codeberg.org/gruf/go-cache/v3 v3.5.7 h1:5hut49a8Wp3hdwrCEJYj6pHY2aRR1hyTmkK4+wHVYq4=
|
||||
codeberg.org/gruf/go-cache/v3 v3.5.7/go.mod h1:Thahfuf3PgHSv2+1zHpvhRdX97tx1WXurVNGWpZucAM=
|
||||
codeberg.org/gruf/go-byteutil v1.3.0 h1:nRqJnCcRQ7xbfU6azw7zOzJrSMDIJHBqX6FL9vEMYmU=
|
||||
codeberg.org/gruf/go-byteutil v1.3.0/go.mod h1:chgnZz1LUcfaObaIFglxF5MRYQkJGjQf4WwVz95ccCM=
|
||||
codeberg.org/gruf/go-cache/v3 v3.6.1 h1:sY1XhYeskjZAuYeMm5R0o4Qymru5taNbzmZPSn1oXLE=
|
||||
codeberg.org/gruf/go-cache/v3 v3.6.1/go.mod h1:JUNjc4E8gRccn3t+B99akxURFrU6NTDkvFVcwiZirnw=
|
||||
codeberg.org/gruf/go-debug v1.3.0 h1:PIRxQiWUFKtGOGZFdZ3Y0pqyfI0Xr87j224IYe2snZs=
|
||||
codeberg.org/gruf/go-debug v1.3.0/go.mod h1:N+vSy9uJBQgpQcJUqjctvqFz7tBHJf+S/PIjLILzpLg=
|
||||
codeberg.org/gruf/go-errors/v2 v2.0.0/go.mod h1:ZRhbdhvgoUA3Yw6e56kd9Ox984RrvbEFC2pOXyHDJP4=
|
||||
codeberg.org/gruf/go-errors/v2 v2.3.2 h1:8ItWaOMfhDaqrJK1Pw8MO0Nu+o/tVcQtR5cJ58Vc4zo=
|
||||
codeberg.org/gruf/go-errors/v2 v2.3.2/go.mod h1:LfzD9nkAAJpEDbkUqOZQ2jdaQ8VrK0pnR36zLOMFq6Y=
|
||||
codeberg.org/gruf/go-fastcopy v1.1.3 h1:Jo9VTQjI6KYimlw25PPc7YLA3Xm+XMQhaHwKnM7xD1g=
|
||||
|
|
@ -56,32 +50,30 @@ codeberg.org/gruf/go-ffmpreg v0.2.6 h1:OHlTOF+62/b+VeM3Svg7praweU/NECRIsuhilZLFa
|
|||
codeberg.org/gruf/go-ffmpreg v0.2.6/go.mod h1:sViRI0BYK2B8PJw4BrOg7DquPD71mZjDfffRAFcDtvk=
|
||||
codeberg.org/gruf/go-iotools v0.0.0-20240710125620-934ae9c654cf h1:84s/ii8N6lYlskZjHH+DG6jyia8w2mXMZlRwFn8Gs3A=
|
||||
codeberg.org/gruf/go-iotools v0.0.0-20240710125620-934ae9c654cf/go.mod h1:zZAICsp5rY7+hxnws2V0ePrWxE0Z2Z/KXcN3p/RQCfk=
|
||||
codeberg.org/gruf/go-kv v1.6.4 h1:3NZiW8HVdBM3kpOiLb7XfRiihnzZWMAixdCznguhILk=
|
||||
codeberg.org/gruf/go-kv v1.6.4/go.mod h1:O/YkSvKiS9XsRolM3rqCd9YJmND7dAXu9z+PrlYO4bc=
|
||||
codeberg.org/gruf/go-kv v1.6.5 h1:ttPf0NA8F79pDqBttSudPTVCZmGncumeNIxmeM9ztz0=
|
||||
codeberg.org/gruf/go-kv v1.6.5/go.mod h1:c4PsGqw05bDScvISpK+d31SiDEpBorweCL50hsiK3dc=
|
||||
codeberg.org/gruf/go-list v0.0.0-20240425093752-494db03d641f h1:Ss6Z+vygy+jOGhj96d/GwsYYDd22QmIcH74zM7/nQkw=
|
||||
codeberg.org/gruf/go-list v0.0.0-20240425093752-494db03d641f/go.mod h1:F9pl4h34iuVN7kucKam9fLwsItTc+9mmaKt7pNXRd/4=
|
||||
codeberg.org/gruf/go-logger/v2 v2.2.1 h1:RP2u059EQKTBFV3cN8X6xDxNk2RkzqdgXGKflKqB7Oc=
|
||||
codeberg.org/gruf/go-logger/v2 v2.2.1/go.mod h1:m/vBfG5jNUmYXI8Hg9aVSk7Pn8YgEBITQB/B/CzdRss=
|
||||
codeberg.org/gruf/go-loosy v0.0.0-20231007123304-bb910d1ab5c4 h1:IXwfoU7f2whT6+JKIKskNl/hBlmWmnF1vZd84Eb3cyA=
|
||||
codeberg.org/gruf/go-loosy v0.0.0-20231007123304-bb910d1ab5c4/go.mod h1:fiO8HE1wjZCephcYmRRsVnNI/i0+mhy44Z5dQalS0rM=
|
||||
codeberg.org/gruf/go-mangler v1.4.1 h1:Dv58jFfy9On49L11ji6tpADUknwoJA46iaiZvnNXecs=
|
||||
codeberg.org/gruf/go-mangler v1.4.1/go.mod h1:mDmW8Ia352RvNFaXoP9K60TgcmCZJtX0j6wm3vjAsJE=
|
||||
codeberg.org/gruf/go-maps v1.0.3 h1:VDwhnnaVNUIy5O93CvkcE2IZXnMB1+IJjzfop9V12es=
|
||||
codeberg.org/gruf/go-maps v1.0.3/go.mod h1:D5LNDxlC9rsDuVQVM6JObaVGAdHB6g2dTdOdkh1aXWA=
|
||||
codeberg.org/gruf/go-maps v1.0.4 h1:K+Ww4vvR3TZqm5jqrKVirmguZwa3v1VUvmig2SE8uxY=
|
||||
codeberg.org/gruf/go-maps v1.0.4/go.mod h1:ASX7osM7kFwt5O8GfGflcFjrwYGD8eIuRLl/oMjhEi8=
|
||||
codeberg.org/gruf/go-mempool v0.0.0-20240507125005-cef10d64a760 h1:m2/UCRXhjDwAg4vyji6iKCpomKw6P4PmBOUi5DvAMH4=
|
||||
codeberg.org/gruf/go-mempool v0.0.0-20240507125005-cef10d64a760/go.mod h1:E3RcaCFNq4zXpvaJb8lfpPqdUAmSkP5F1VmMiEUYTEk=
|
||||
codeberg.org/gruf/go-mimetypes v1.2.0 h1:3rZGXY/SkNYbamiddWXs2gETXIBkGIeWYnbWpp2OEbc=
|
||||
codeberg.org/gruf/go-mimetypes v1.2.0/go.mod h1:YiUWRj/nAdJQc+UFRvcsL6xXZsbc6b6Ic739ycEO8Yg=
|
||||
codeberg.org/gruf/go-mutexes v1.5.1 h1:xICU0WXhWr6wf+Iror4eE3xT+xnXNPrO6o77D/G6QuY=
|
||||
codeberg.org/gruf/go-mutexes v1.5.1/go.mod h1:rPEqQ/y6CmGITaZ3GPTMQVsoZAOzbsAHyIaLsJcOqVE=
|
||||
codeberg.org/gruf/go-runners v1.6.2 h1:oQef9niahfHu/wch14xNxlRMP8i+ABXH1Cb9PzZ4oYo=
|
||||
codeberg.org/gruf/go-runners v1.6.2/go.mod h1:Tq5PrZ/m/rBXbLZz0u5if+yP3nG5Sf6S8O/GnyEePeQ=
|
||||
codeberg.org/gruf/go-sched v1.2.3 h1:H5ViDxxzOBR3uIyGBCf0eH8b1L8wMybOXcdtUUTXZHk=
|
||||
codeberg.org/gruf/go-sched v1.2.3/go.mod h1:vT9uB6KWFIIwnG9vcPY2a0alYNoqdL1mSzRM8I+PK7A=
|
||||
codeberg.org/gruf/go-storage v0.1.2 h1:dIOVOKq1CJpRmuhbB8Zok3mmo8V6VV/nX5GLIm6hywA=
|
||||
codeberg.org/gruf/go-storage v0.1.2/go.mod h1:LRDpFHqRJi0f+35c3ltBH2e/pGfwY5dGlNlgCJ/R1DA=
|
||||
codeberg.org/gruf/go-structr v0.8.8 h1:lRPpyTmLKvQCkkQiSUbOAh6jtL2wncEO8DwksMqQXM8=
|
||||
codeberg.org/gruf/go-structr v0.8.8/go.mod h1:zkoXVrAnKosh8VFAsbP/Hhs8FmLBjbVVy5w/Ngm8ApM=
|
||||
codeberg.org/gruf/go-runners v1.6.3 h1:To/AX7eTrWuXrTkA3RA01YTP5zha1VZ68LQ+0D4RY7E=
|
||||
codeberg.org/gruf/go-runners v1.6.3/go.mod h1:oXAaUmG2VxoKttpCqZGv5nQBeSvZSR2BzIk7h1yTRlU=
|
||||
codeberg.org/gruf/go-sched v1.2.4 h1:ddBB9o0D/2oU8NbQ0ldN5aWxogpXPRBATWi58+p++Hw=
|
||||
codeberg.org/gruf/go-sched v1.2.4/go.mod h1:wad6l+OcYGWMA2TzNLMmLObsrbBDxdJfEy5WvTgBjNk=
|
||||
codeberg.org/gruf/go-storage v0.2.0 h1:mKj3Lx6AavEkuXXtxqPhdq+akW9YwrnP16yQBF7K5ZI=
|
||||
codeberg.org/gruf/go-storage v0.2.0/go.mod h1:o3GzMDE5QNUaRnm/daUzFqvuAaC4utlgXDXYO79sWKU=
|
||||
codeberg.org/gruf/go-structr v0.8.11 h1:I3cQCHpK3fQSXWaaUfksAJRN4+efULiuF11Oi/m8c+o=
|
||||
codeberg.org/gruf/go-structr v0.8.11/go.mod h1:zkoXVrAnKosh8VFAsbP/Hhs8FmLBjbVVy5w/Ngm8ApM=
|
||||
codeberg.org/superseriousbusiness/exif-terminator v0.9.0 h1:/EfyGI6HIrbkhFwgXGSjZ9o1kr/+k8v4mKdfXTH02Go=
|
||||
codeberg.org/superseriousbusiness/exif-terminator v0.9.0/go.mod h1:gCWKduudUWFzsnixoMzu0FYVdxHWG+AbXnZ50DqxsUE=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
|
|
@ -364,12 +356,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
|||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
|
||||
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc=
|
||||
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
|
||||
|
|
@ -384,6 +376,8 @@ github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/X
|
|||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
|
||||
github.com/k3a/html2text v1.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY=
|
||||
github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.10.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.10.10/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
|
|
@ -419,8 +413,8 @@ github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
|
|||
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.76 h1:9nxHH2XDai61cT/EFhyIw/wW4vJfpPNvl7lSFpRt+Ng=
|
||||
github.com/minio/minio-go/v7 v7.0.76/go.mod h1:AVM3IUN6WwKzmwBxVdjzhH8xq+f57JSbbvzqvUzR6eg=
|
||||
github.com/minio/minio-go/v7 v7.0.77 h1:GaGghJRg9nwDVlNbwYjSDJT1rqltQkBFDsypWX1v3Bw=
|
||||
github.com/minio/minio-go/v7 v7.0.77/go.mod h1:AVM3IUN6WwKzmwBxVdjzhH8xq+f57JSbbvzqvUzR6eg=
|
||||
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
|
|
@ -440,8 +434,8 @@ github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs=
|
|||
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-sqlite3 v0.18.3 h1:tyMa75uh7LcINcfo0WrzOvcTkfz8Hqu0TEPX+KVyes4=
|
||||
github.com/ncruces/go-sqlite3 v0.18.3/go.mod h1:HAwOtA+cyEX3iN6YmkpQwfT4vMMgCB7rQRFUdOgEFik=
|
||||
github.com/ncruces/go-sqlite3 v0.18.4 h1:Je8o3y33MDwPYY/Cacas8yCsuoUzpNY/AgoSlN2ekyE=
|
||||
github.com/ncruces/go-sqlite3 v0.18.4/go.mod h1:4HLag13gq1k10s4dfGBhMfRVsssJRT9/5hYqVM9RUYo=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
|
||||
|
|
@ -468,13 +462,13 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/prometheus/client_golang v1.20.3 h1:oPksm4K8B+Vt35tUhw6GbSNSgVlVSBH0qELP/7u83l4=
|
||||
github.com/prometheus/client_golang v1.20.3/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
|
||||
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/common v0.59.1 h1:LXb1quJHWm1P6wq/U824uxYi4Sg0oGvNeUm1z5dJoX0=
|
||||
github.com/prometheus/common v0.59.1/go.mod h1:GpWM7dewqmVYcd7SmRaiWVe9SSqjf0UrwnYnpEZNuT0=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b h1:aUNXCGgukb4gtY99imuIeoh8Vr0GSwAlYxPAhqZrpFc=
|
||||
|
|
@ -629,8 +623,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
|||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
|
||||
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
gitlab.com/NyaaaWhatsUpDoc/sqlite v1.29.9-concurrency-workaround h1:gFAlklid3jyXIuZBy5Vy0dhG+F6YBgosRy4syT5CDsg=
|
||||
gitlab.com/NyaaaWhatsUpDoc/sqlite v1.29.9-concurrency-workaround/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
|
||||
gitlab.com/NyaaaWhatsUpDoc/sqlite v1.33.1-concurrency-workaround h1:pFMJnlc1PuH+jcVz4vz53vcpnoZG+NqFBr3qikDmEB4=
|
||||
gitlab.com/NyaaaWhatsUpDoc/sqlite v1.33.1-concurrency-workaround/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80=
|
||||
go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
|
|
@ -658,8 +652,8 @@ go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt3
|
|||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||
go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
|
||||
go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
|
|
@ -745,8 +739,8 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R
|
|||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
|
|
@ -924,10 +918,10 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc
|
|||
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd h1:BBOTEWLuuEGQy9n1y9MhVJ9Qt0BDu21X8qZs71/uPZo=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:fO8wJzT2zbQbAjbIoos1285VfEIYKDDY+Dt+WpTkh6g=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd h1:6TEm2ZxXoQmFWFlt1vNxvVOa1Q0dXFQD1m/rYjXmS0E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240822170219-fc7c04adadcd/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
|
|
@ -940,8 +934,8 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa
|
|||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
|
||||
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
|
||||
google.golang.org/grpc v1.66.1 h1:hO5qAXR19+/Z44hmvIM4dQFMSYX9XcWsByfoxutBpAM=
|
||||
google.golang.org/grpc v1.66.1/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
|
|
@ -983,18 +977,18 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
|
|||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk=
|
||||
modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA=
|
||||
modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg=
|
||||
modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
|
|
|
|||
|
|
@ -1170,7 +1170,7 @@ func extractPolicyValues[T WithIRI](
|
|||
case owner.FollowersURI:
|
||||
PolicyValues = append(PolicyValues, gtsmodel.PolicyValueFollowers)
|
||||
case owner.FollowingURI:
|
||||
PolicyValues = append(PolicyValues, gtsmodel.PolicyValueFollowers)
|
||||
PolicyValues = append(PolicyValues, gtsmodel.PolicyValueFollowing)
|
||||
case owner.URI:
|
||||
PolicyValues = append(PolicyValues, gtsmodel.PolicyValueAuthor)
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ func (suite *AccountStatusesTestSuite) TestGetStatusesPublicOnly() {
|
|||
suite.Equal(apimodel.VisibilityPublic, s.Visibility)
|
||||
}
|
||||
|
||||
suite.Equal(`<http://localhost:8080/api/v1/accounts/01F8MH17FWEB39HZJ76B6VXSKF/statuses?limit=20&max_id=01F8MH75CBF9JFX4ZAD54N0W0R&exclude_replies=false&exclude_reblogs=false&pinned=false&only_media=false&only_public=true>; rel="next", <http://localhost:8080/api/v1/accounts/01F8MH17FWEB39HZJ76B6VXSKF/statuses?limit=20&min_id=01G36SF3V6Y6V5BF9P4R7PQG7G&exclude_replies=false&exclude_reblogs=false&pinned=false&only_media=false&only_public=true>; rel="prev"`, result.Header.Get("link"))
|
||||
suite.Equal(`<http://localhost:8080/api/v1/accounts/01F8MH17FWEB39HZJ76B6VXSKF/statuses?limit=20&max_id=01F8MH75CBF9JFX4ZAD54N0W0R&exclude_replies=false&exclude_reblogs=false&pinned=false&only_media=false&only_public=true>; rel="next", <http://localhost:8080/api/v1/accounts/01F8MH17FWEB39HZJ76B6VXSKF/statuses?limit=20&min_id=01J5QVB9VC76NPPRQ207GG4DRZ&exclude_replies=false&exclude_reblogs=false&pinned=false&only_media=false&only_public=true>; rel="prev"`, result.Header.Get("link"))
|
||||
}
|
||||
|
||||
func (suite *AccountStatusesTestSuite) TestGetStatusesPublicOnlyMediaOnly() {
|
||||
|
|
|
|||
|
|
@ -160,9 +160,9 @@ func (suite *ExportsTestSuite) TestExports() {
|
|||
token: suite.testTokens["local_account_1"],
|
||||
user: suite.testUsers["local_account_1"],
|
||||
account: suite.testAccounts["local_account_1"],
|
||||
expect: `Account address,Show boosts
|
||||
admin@localhost:8080,true
|
||||
1happyturtle@localhost:8080,true
|
||||
expect: `Account address,Show boosts,Notify on new posts,Languages
|
||||
1happyturtle@localhost:8080,true,false,
|
||||
admin@localhost:8080,true,false,
|
||||
`,
|
||||
},
|
||||
// Export Followers.
|
||||
|
|
@ -188,8 +188,8 @@ admin@localhost:8080
|
|||
token: suite.testTokens["local_account_1"],
|
||||
user: suite.testUsers["local_account_1"],
|
||||
account: suite.testAccounts["local_account_1"],
|
||||
expect: `Cool Ass Posters From This Instance,admin@localhost:8080
|
||||
Cool Ass Posters From This Instance,1happyturtle@localhost:8080
|
||||
expect: `Cool Ass Posters From This Instance,1happyturtle@localhost:8080
|
||||
Cool Ass Posters From This Instance,admin@localhost:8080
|
||||
`,
|
||||
},
|
||||
// Export Mutes.
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ func (m *Module) PoliciesDefaultsPATCHHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
form, err := parseUpdateAccountForm(c)
|
||||
form, err := parseUpdatePoliciesForm(c)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
@ -290,7 +290,7 @@ func customBind(
|
|||
return nil
|
||||
}
|
||||
|
||||
func parseUpdateAccountForm(c *gin.Context) (*apimodel.UpdateInteractionPoliciesRequest, error) {
|
||||
func parseUpdatePoliciesForm(c *gin.Context) (*apimodel.UpdateInteractionPoliciesRequest, error) {
|
||||
form := new(apimodel.UpdateInteractionPoliciesRequest)
|
||||
|
||||
switch ct := c.ContentType(); ct {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import (
|
|||
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/paging"
|
||||
)
|
||||
|
||||
// ListAccountsGETHandler swagger:operation GET /api/v1/lists/{id}/accounts listAccounts
|
||||
|
|
@ -129,42 +130,27 @@ func (m *Module) ListAccountsGETHandler(c *gin.Context) {
|
|||
|
||||
targetListID := c.Param(IDKey)
|
||||
if targetListID == "" {
|
||||
err := errors.New("no list id specified")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
limit, errWithCode := apiutil.ParseLimit(c.Query(apiutil.LimitKey), 40, 80, 0)
|
||||
if errWithCode != nil {
|
||||
const text = "no list id specified"
|
||||
errWithCode := gtserror.NewErrorBadRequest(errors.New(text), text)
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
ctx = c.Request.Context()
|
||||
page, errWithCode := paging.ParseIDPage(c,
|
||||
1, // min limit
|
||||
80, // max limit
|
||||
0, // default = paging disabled
|
||||
)
|
||||
|
||||
if limit == 0 {
|
||||
// Return all accounts in the list without pagination.
|
||||
accounts, errWithCode := m.processor.List().GetAllListAccounts(ctx, authed.Account, targetListID)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, accounts)
|
||||
return
|
||||
}
|
||||
|
||||
// Return subset of accounts in the list with pagination.
|
||||
resp, errWithCode := m.processor.List().GetListAccounts(
|
||||
ctx,
|
||||
c.Request.Context(),
|
||||
authed.Account,
|
||||
targetListID,
|
||||
c.Query(MaxIDKey),
|
||||
c.Query(SinceIDKey),
|
||||
c.Query(MinIDKey),
|
||||
limit,
|
||||
page,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ package lists_test
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
|
|
@ -97,7 +97,7 @@ func (suite *ListAccountsTestSuite) getListAccounts(
|
|||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
b, err := io.ReadAll(result.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
|
@ -151,8 +151,7 @@ func (suite *ListAccountsTestSuite) TestGetListAccountsPaginatedDefaultLimit() {
|
|||
|
||||
suite.Len(accounts, 2)
|
||||
suite.Equal(
|
||||
`<http://localhost:8080/api/v1/lists/01H0G8E4Q2J3FE3JDWJVWEDCD1/accounts?limit=40&max_id=01H0G89MWVQE0M58VD2HQYMQWH>; rel="next", `+
|
||||
`<http://localhost:8080/api/v1/lists/01H0G8E4Q2J3FE3JDWJVWEDCD1/accounts?limit=40&min_id=01H0G8FFM1AGQDRNGBGGX8CYJQ>; rel="prev"`,
|
||||
"<http://localhost:8080/api/v1/lists/01H0G8E4Q2J3FE3JDWJVWEDCD1/accounts?limit=40&max_id=01F8MH5NBDF2MV7CTC4Q5128HF>; rel=\"next\", <http://localhost:8080/api/v1/lists/01H0G8E4Q2J3FE3JDWJVWEDCD1/accounts?limit=40&min_id=01F8MH17FWEB39HZJ76B6VXSKF>; rel=\"prev\"",
|
||||
link,
|
||||
)
|
||||
}
|
||||
|
|
@ -184,8 +183,7 @@ func (suite *ListAccountsTestSuite) TestGetListAccountsPaginatedNextPage() {
|
|||
|
||||
suite.Len(accounts, 1)
|
||||
suite.Equal(
|
||||
`<http://localhost:8080/api/v1/lists/01H0G8E4Q2J3FE3JDWJVWEDCD1/accounts?limit=1&max_id=01H0G8FFM1AGQDRNGBGGX8CYJQ>; rel="next", `+
|
||||
`<http://localhost:8080/api/v1/lists/01H0G8E4Q2J3FE3JDWJVWEDCD1/accounts?limit=1&min_id=01H0G8FFM1AGQDRNGBGGX8CYJQ>; rel="prev"`,
|
||||
"<http://localhost:8080/api/v1/lists/01H0G8E4Q2J3FE3JDWJVWEDCD1/accounts?limit=1&max_id=01F8MH17FWEB39HZJ76B6VXSKF>; rel=\"next\", <http://localhost:8080/api/v1/lists/01H0G8E4Q2J3FE3JDWJVWEDCD1/accounts?limit=1&min_id=01F8MH17FWEB39HZJ76B6VXSKF>; rel=\"prev\"",
|
||||
link,
|
||||
)
|
||||
|
||||
|
|
@ -206,8 +204,7 @@ func (suite *ListAccountsTestSuite) TestGetListAccountsPaginatedNextPage() {
|
|||
|
||||
suite.Len(accounts, 1)
|
||||
suite.Equal(
|
||||
`<http://localhost:8080/api/v1/lists/01H0G8E4Q2J3FE3JDWJVWEDCD1/accounts?limit=1&max_id=01H0G89MWVQE0M58VD2HQYMQWH>; rel="next", `+
|
||||
`<http://localhost:8080/api/v1/lists/01H0G8E4Q2J3FE3JDWJVWEDCD1/accounts?limit=1&min_id=01H0G89MWVQE0M58VD2HQYMQWH>; rel="prev"`,
|
||||
"<http://localhost:8080/api/v1/lists/01H0G8E4Q2J3FE3JDWJVWEDCD1/accounts?limit=1&max_id=01F8MH17FWEB39HZJ76B6VXSKF>; rel=\"next\", <http://localhost:8080/api/v1/lists/01H0G8E4Q2J3FE3JDWJVWEDCD1/accounts?limit=1&min_id=01F8MH17FWEB39HZJ76B6VXSKF>; rel=\"prev\"",
|
||||
link,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,14 +98,17 @@ func (suite *ListAccountsAddTestSuite) TestPostListAccountNotFollowed() {
|
|||
|
||||
resp, err := suite.postListAccounts(http.StatusNotFound, listID, accountIDs)
|
||||
suite.NoError(err)
|
||||
suite.Equal(`{"error":"Not Found: you do not follow account 01F8MH5ZK5VRH73AKHQM6Y9VNX"}`, string(resp))
|
||||
suite.Equal(`{"error":"Not Found: account 01F8MH5ZK5VRH73AKHQM6Y9VNX not currently followed"}`, string(resp))
|
||||
}
|
||||
|
||||
func (suite *ListAccountsAddTestSuite) TestPostListAccountOK() {
|
||||
entry := suite.testListEntries["local_account_1_list_1_entry_1"]
|
||||
|
||||
// Remove turtle from the list.
|
||||
if err := suite.db.DeleteListEntry(
|
||||
context.Background(),
|
||||
suite.testListEntries["local_account_1_list_1_entry_1"].ID,
|
||||
entry.ListID,
|
||||
entry.FollowID,
|
||||
); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@
|
|||
package statuses_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
|
|
@ -25,6 +31,7 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
|
|
@ -59,6 +66,113 @@ type StatusStandardTestSuite struct {
|
|||
statusModule *statuses.Module
|
||||
}
|
||||
|
||||
// Normalizes a status response to a determinate
|
||||
// form, and pretty-prints it to JSON.
|
||||
func (suite *StatusStandardTestSuite) parseStatusResponse(
|
||||
recorder *httptest.ResponseRecorder,
|
||||
) (string, *httptest.ResponseRecorder) {
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(result.Body)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
rawMap := make(map[string]any)
|
||||
if err := json.Unmarshal(data, &rawMap); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Make status fields determinate.
|
||||
suite.determinateStatus(rawMap)
|
||||
|
||||
// For readability, don't
|
||||
// escape HTML, and indent json.
|
||||
out := new(bytes.Buffer)
|
||||
enc := json.NewEncoder(out)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
|
||||
if err := enc.Encode(&rawMap); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
return strings.TrimSpace(out.String()), recorder
|
||||
}
|
||||
|
||||
func (suite *StatusStandardTestSuite) determinateStatus(rawMap map[string]any) {
|
||||
// Replace any fields from the raw map that
|
||||
// aren't determinate (date, id, url, etc).
|
||||
if _, ok := rawMap["id"]; ok {
|
||||
rawMap["id"] = id.Highest
|
||||
}
|
||||
|
||||
if _, ok := rawMap["uri"]; ok {
|
||||
rawMap["uri"] = "http://localhost:8080/some/determinate/url"
|
||||
}
|
||||
|
||||
if _, ok := rawMap["url"]; ok {
|
||||
rawMap["url"] = "http://localhost:8080/some/determinate/url"
|
||||
}
|
||||
|
||||
if _, ok := rawMap["created_at"]; ok {
|
||||
rawMap["created_at"] = "right the hell just now babyee"
|
||||
}
|
||||
|
||||
// Make ID of any mentions determinate.
|
||||
if menchiesRaw, ok := rawMap["mentions"]; ok {
|
||||
menchies, ok := menchiesRaw.([]any)
|
||||
if !ok {
|
||||
suite.FailNow("couldn't coerce menchies")
|
||||
}
|
||||
|
||||
for _, menchieRaw := range menchies {
|
||||
menchie, ok := menchieRaw.(map[string]any)
|
||||
if !ok {
|
||||
suite.FailNow("couldn't coerce menchie")
|
||||
}
|
||||
|
||||
if _, ok := menchie["id"]; ok {
|
||||
menchie["id"] = id.Highest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make fields of any poll determinate.
|
||||
if pollRaw, ok := rawMap["poll"]; ok && pollRaw != nil {
|
||||
poll, ok := pollRaw.(map[string]any)
|
||||
if !ok {
|
||||
suite.FailNow("couldn't coerce poll")
|
||||
}
|
||||
|
||||
if _, ok := poll["id"]; ok {
|
||||
poll["id"] = id.Highest
|
||||
}
|
||||
|
||||
if _, ok := poll["expires_at"]; ok {
|
||||
poll["expires_at"] = "ah like you know whatever dude it's chill"
|
||||
}
|
||||
}
|
||||
|
||||
// Replace account since that's not really
|
||||
// what we care about for these tests.
|
||||
if _, ok := rawMap["account"]; ok {
|
||||
rawMap["account"] = "yeah this is my account, what about it punk"
|
||||
}
|
||||
|
||||
// If status contains an embedded
|
||||
// reblog do the same thing for that.
|
||||
if reblogRaw, ok := rawMap["reblog"]; ok && reblogRaw != nil {
|
||||
reblog, ok := reblogRaw.(map[string]any)
|
||||
if !ok {
|
||||
suite.FailNow("couldn't coerce reblog")
|
||||
}
|
||||
suite.determinateStatus(reblog)
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *StatusStandardTestSuite) SetupSuite() {
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
|
|
|
|||
|
|
@ -17,9 +17,6 @@ package statuses_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
|
@ -28,7 +25,7 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
|
|
@ -38,212 +35,596 @@ type StatusBoostTestSuite struct {
|
|||
StatusStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *StatusBoostTestSuite) TestPostBoost() {
|
||||
t := suite.testTokens["local_account_1"]
|
||||
oauthToken := oauth.DBTokenToToken(t)
|
||||
|
||||
targetStatus := suite.testStatuses["admin_account_status_1"]
|
||||
|
||||
// setup
|
||||
func (suite *StatusBoostTestSuite) postStatusBoost(
|
||||
targetStatusID string,
|
||||
app *gtsmodel.Application,
|
||||
token *gtsmodel.Token,
|
||||
user *gtsmodel.User,
|
||||
account *gtsmodel.Account,
|
||||
) (string, *httptest.ResponseRecorder) {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, app)
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token))
|
||||
ctx.Set(oauth.SessionAuthorizedUser, user)
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, account)
|
||||
|
||||
const pathBase = "http://localhost:8080/api" + statuses.ReblogPath
|
||||
path := strings.ReplaceAll(pathBase, ":"+apiutil.IDKey, targetStatusID)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, path, nil)
|
||||
ctx.Request.Header.Set("accept", "application/json")
|
||||
|
||||
// normally the router would populate these params from the path values,
|
||||
// but because we're calling the function directly, we need to set them manually.
|
||||
// Populate target status ID.
|
||||
ctx.Params = gin.Params{
|
||||
gin.Param{
|
||||
Key: statuses.IDKey,
|
||||
Value: targetStatus.ID,
|
||||
Key: apiutil.IDKey,
|
||||
Value: targetStatusID,
|
||||
},
|
||||
}
|
||||
|
||||
// Trigger handler.
|
||||
suite.statusModule.StatusBoostPOSTHandler(ctx)
|
||||
return suite.parseStatusResponse(recorder)
|
||||
}
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
func (suite *StatusBoostTestSuite) TestPostBoost() {
|
||||
var (
|
||||
targetStatus = suite.testStatuses["admin_account_status_1"]
|
||||
app = suite.testApplications["application_1"]
|
||||
token = suite.testTokens["local_account_1"]
|
||||
user = suite.testUsers["local_account_1"]
|
||||
account = suite.testAccounts["local_account_1"]
|
||||
)
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
suite.NoError(err)
|
||||
out, recorder := suite.postStatusBoost(
|
||||
targetStatus.ID,
|
||||
app,
|
||||
token,
|
||||
user,
|
||||
account,
|
||||
)
|
||||
|
||||
statusReply := &apimodel.Status{}
|
||||
err = json.Unmarshal(b, statusReply)
|
||||
suite.NoError(err)
|
||||
// We should have OK from
|
||||
// our call to the function.
|
||||
suite.Equal(http.StatusOK, recorder.Code)
|
||||
|
||||
suite.False(statusReply.Sensitive)
|
||||
suite.Equal(apimodel.VisibilityPublic, statusReply.Visibility)
|
||||
|
||||
suite.Empty(statusReply.SpoilerText)
|
||||
suite.Empty(statusReply.Content)
|
||||
suite.Equal("the_mighty_zork", statusReply.Account.Username)
|
||||
suite.Len(statusReply.MediaAttachments, 0)
|
||||
suite.Len(statusReply.Mentions, 0)
|
||||
suite.Len(statusReply.Emojis, 0)
|
||||
suite.Len(statusReply.Tags, 0)
|
||||
|
||||
suite.NotNil(statusReply.Application)
|
||||
suite.Equal("really cool gts application", statusReply.Application.Name)
|
||||
|
||||
suite.NotNil(statusReply.Reblog)
|
||||
suite.Equal(1, statusReply.Reblog.ReblogsCount)
|
||||
suite.Equal(1, statusReply.Reblog.FavouritesCount)
|
||||
suite.Equal(targetStatus.Content, statusReply.Reblog.Content)
|
||||
suite.Equal(targetStatus.ContentWarning, statusReply.Reblog.SpoilerText)
|
||||
suite.Equal(targetStatus.AccountID, statusReply.Reblog.Account.ID)
|
||||
suite.Len(statusReply.Reblog.MediaAttachments, 1)
|
||||
suite.Len(statusReply.Reblog.Tags, 1)
|
||||
suite.Len(statusReply.Reblog.Emojis, 1)
|
||||
suite.True(statusReply.Reblogged)
|
||||
suite.True(statusReply.Reblog.Reblogged)
|
||||
suite.Equal("superseriousbusiness", statusReply.Reblog.Application.Name)
|
||||
// Target status should now
|
||||
// be "reblogged" by us.
|
||||
suite.Equal(`{
|
||||
"account": "yeah this is my account, what about it punk",
|
||||
"application": {
|
||||
"name": "really cool gts application",
|
||||
"website": "https://reallycool.app"
|
||||
},
|
||||
"bookmarked": true,
|
||||
"card": null,
|
||||
"content": "",
|
||||
"created_at": "right the hell just now babyee",
|
||||
"emojis": [],
|
||||
"favourited": true,
|
||||
"favourites_count": 0,
|
||||
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
"in_reply_to_account_id": null,
|
||||
"in_reply_to_id": null,
|
||||
"interaction_policy": {
|
||||
"can_favourite": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reblog": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reply": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
}
|
||||
},
|
||||
"language": null,
|
||||
"media_attachments": [],
|
||||
"mentions": [],
|
||||
"muted": false,
|
||||
"pinned": false,
|
||||
"poll": null,
|
||||
"reblog": {
|
||||
"account": "yeah this is my account, what about it punk",
|
||||
"application": {
|
||||
"name": "superseriousbusiness",
|
||||
"website": "https://superserious.business"
|
||||
},
|
||||
"bookmarked": true,
|
||||
"card": null,
|
||||
"content": "hello world! #welcome ! first post on the instance :rainbow: !",
|
||||
"created_at": "right the hell just now babyee",
|
||||
"emojis": [
|
||||
{
|
||||
"category": "reactions",
|
||||
"shortcode": "rainbow",
|
||||
"static_url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
|
||||
"url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
|
||||
"visible_in_picker": true
|
||||
}
|
||||
],
|
||||
"favourited": true,
|
||||
"favourites_count": 1,
|
||||
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
"in_reply_to_account_id": null,
|
||||
"in_reply_to_id": null,
|
||||
"interaction_policy": {
|
||||
"can_favourite": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reblog": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reply": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
}
|
||||
},
|
||||
"language": "en",
|
||||
"media_attachments": [
|
||||
{
|
||||
"blurhash": "LIIE|gRj00WB-;j[t7j[4nWBj[Rj",
|
||||
"description": "Black and white image of some 50's style text saying: Welcome On Board",
|
||||
"id": "01F8MH6NEM8D7527KZAECTCR76",
|
||||
"meta": {
|
||||
"focus": {
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"original": {
|
||||
"aspect": 1.9047619,
|
||||
"height": 630,
|
||||
"size": "1200x630",
|
||||
"width": 1200
|
||||
},
|
||||
"small": {
|
||||
"aspect": 1.9104477,
|
||||
"height": 268,
|
||||
"size": "512x268",
|
||||
"width": 512
|
||||
}
|
||||
},
|
||||
"preview_remote_url": null,
|
||||
"preview_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.webp",
|
||||
"remote_url": null,
|
||||
"text_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg",
|
||||
"type": "image",
|
||||
"url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg"
|
||||
}
|
||||
],
|
||||
"mentions": [],
|
||||
"muted": false,
|
||||
"pinned": false,
|
||||
"poll": null,
|
||||
"reblog": null,
|
||||
"reblogged": true,
|
||||
"reblogs_count": 1,
|
||||
"replies_count": 1,
|
||||
"sensitive": false,
|
||||
"spoiler_text": "",
|
||||
"tags": [
|
||||
{
|
||||
"name": "welcome",
|
||||
"url": "http://localhost:8080/tags/welcome"
|
||||
}
|
||||
],
|
||||
"text": "hello world! #welcome ! first post on the instance :rainbow: !",
|
||||
"uri": "http://localhost:8080/some/determinate/url",
|
||||
"url": "http://localhost:8080/some/determinate/url",
|
||||
"visibility": "public"
|
||||
},
|
||||
"reblogged": true,
|
||||
"reblogs_count": 0,
|
||||
"replies_count": 0,
|
||||
"sensitive": false,
|
||||
"spoiler_text": "",
|
||||
"tags": [],
|
||||
"uri": "http://localhost:8080/some/determinate/url",
|
||||
"url": "http://localhost:8080/some/determinate/url",
|
||||
"visibility": "public"
|
||||
}`, out)
|
||||
}
|
||||
|
||||
func (suite *StatusBoostTestSuite) TestPostBoostOwnFollowersOnly() {
|
||||
t := suite.testTokens["local_account_1"]
|
||||
oauthToken := oauth.DBTokenToToken(t)
|
||||
var (
|
||||
targetStatus = suite.testStatuses["local_account_1_status_5"]
|
||||
app = suite.testApplications["application_1"]
|
||||
token = suite.testTokens["local_account_1"]
|
||||
user = suite.testUsers["local_account_1"]
|
||||
account = suite.testAccounts["local_account_1"]
|
||||
)
|
||||
|
||||
testStatus := suite.testStatuses["local_account_1_status_5"]
|
||||
testAccount := suite.testAccounts["local_account_1"]
|
||||
testUser := suite.testUsers["local_account_1"]
|
||||
out, recorder := suite.postStatusBoost(
|
||||
targetStatus.ID,
|
||||
app,
|
||||
token,
|
||||
user,
|
||||
account,
|
||||
)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||
ctx.Set(oauth.SessionAuthorizedUser, testUser)
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, testAccount)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", testStatus.ID, 1)), nil)
|
||||
ctx.Request.Header.Set("accept", "application/json")
|
||||
// We should have OK from
|
||||
// our call to the function.
|
||||
suite.Equal(http.StatusOK, recorder.Code)
|
||||
|
||||
ctx.Params = gin.Params{
|
||||
gin.Param{
|
||||
Key: statuses.IDKey,
|
||||
Value: testStatus.ID,
|
||||
// Target status should now
|
||||
// be "reblogged" by us.
|
||||
suite.Equal(`{
|
||||
"account": "yeah this is my account, what about it punk",
|
||||
"application": {
|
||||
"name": "really cool gts application",
|
||||
"website": "https://reallycool.app"
|
||||
},
|
||||
"bookmarked": false,
|
||||
"card": null,
|
||||
"content": "",
|
||||
"created_at": "right the hell just now babyee",
|
||||
"emojis": [],
|
||||
"favourited": false,
|
||||
"favourites_count": 0,
|
||||
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
"in_reply_to_account_id": null,
|
||||
"in_reply_to_id": null,
|
||||
"interaction_policy": {
|
||||
"can_favourite": {
|
||||
"always": [
|
||||
"author",
|
||||
"followers",
|
||||
"mentioned",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reblog": {
|
||||
"always": [
|
||||
"author",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reply": {
|
||||
"always": [
|
||||
"author",
|
||||
"followers",
|
||||
"mentioned",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
}
|
||||
},
|
||||
"language": null,
|
||||
"media_attachments": [],
|
||||
"mentions": [],
|
||||
"muted": false,
|
||||
"pinned": false,
|
||||
"poll": null,
|
||||
"reblog": {
|
||||
"account": "yeah this is my account, what about it punk",
|
||||
"application": {
|
||||
"name": "really cool gts application",
|
||||
"website": "https://reallycool.app"
|
||||
},
|
||||
"bookmarked": false,
|
||||
"card": null,
|
||||
"content": "hi!",
|
||||
"created_at": "right the hell just now babyee",
|
||||
"emojis": [],
|
||||
"favourited": false,
|
||||
"favourites_count": 0,
|
||||
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
"in_reply_to_account_id": null,
|
||||
"in_reply_to_id": null,
|
||||
"interaction_policy": {
|
||||
"can_favourite": {
|
||||
"always": [
|
||||
"author",
|
||||
"followers",
|
||||
"mentioned",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reblog": {
|
||||
"always": [
|
||||
"author",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reply": {
|
||||
"always": [
|
||||
"author",
|
||||
"followers",
|
||||
"mentioned",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
}
|
||||
},
|
||||
"language": "en",
|
||||
"media_attachments": [],
|
||||
"mentions": [],
|
||||
"muted": false,
|
||||
"pinned": false,
|
||||
"poll": null,
|
||||
"reblog": null,
|
||||
"reblogged": true,
|
||||
"reblogs_count": 1,
|
||||
"replies_count": 0,
|
||||
"sensitive": false,
|
||||
"spoiler_text": "",
|
||||
"tags": [],
|
||||
"text": "hi!",
|
||||
"uri": "http://localhost:8080/some/determinate/url",
|
||||
"url": "http://localhost:8080/some/determinate/url",
|
||||
"visibility": "private"
|
||||
},
|
||||
"reblogged": true,
|
||||
"reblogs_count": 0,
|
||||
"replies_count": 0,
|
||||
"sensitive": false,
|
||||
"spoiler_text": "",
|
||||
"tags": [],
|
||||
"uri": "http://localhost:8080/some/determinate/url",
|
||||
"url": "http://localhost:8080/some/determinate/url",
|
||||
"visibility": "private"
|
||||
}`, out)
|
||||
}
|
||||
|
||||
suite.statusModule.StatusBoostPOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
suite.NoError(err)
|
||||
|
||||
responseStatus := &apimodel.Status{}
|
||||
err = json.Unmarshal(b, responseStatus)
|
||||
suite.NoError(err)
|
||||
|
||||
suite.False(responseStatus.Sensitive)
|
||||
suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Visibility)
|
||||
|
||||
suite.Empty(responseStatus.SpoilerText)
|
||||
suite.Empty(responseStatus.Content)
|
||||
suite.Equal("the_mighty_zork", responseStatus.Account.Username)
|
||||
suite.Len(responseStatus.MediaAttachments, 0)
|
||||
suite.Len(responseStatus.Mentions, 0)
|
||||
suite.Len(responseStatus.Emojis, 0)
|
||||
suite.Len(responseStatus.Tags, 0)
|
||||
|
||||
suite.NotNil(responseStatus.Application)
|
||||
suite.Equal("really cool gts application", responseStatus.Application.Name)
|
||||
|
||||
suite.NotNil(responseStatus.Reblog)
|
||||
suite.Equal(1, responseStatus.Reblog.ReblogsCount)
|
||||
suite.Equal(0, responseStatus.Reblog.FavouritesCount)
|
||||
suite.Equal(testStatus.Content, responseStatus.Reblog.Content)
|
||||
suite.Equal(testStatus.ContentWarning, responseStatus.Reblog.SpoilerText)
|
||||
suite.Equal(testStatus.AccountID, responseStatus.Reblog.Account.ID)
|
||||
suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Reblog.Visibility)
|
||||
suite.Empty(responseStatus.Reblog.MediaAttachments)
|
||||
suite.Empty(responseStatus.Reblog.Tags)
|
||||
suite.Empty(responseStatus.Reblog.Emojis)
|
||||
suite.True(responseStatus.Reblogged)
|
||||
suite.True(responseStatus.Reblog.Reblogged)
|
||||
suite.Equal("really cool gts application", responseStatus.Reblog.Application.Name)
|
||||
}
|
||||
|
||||
// try to boost a status that's not boostable / visible to us
|
||||
// Try to boost a status that's
|
||||
// not boostable / visible to us.
|
||||
func (suite *StatusBoostTestSuite) TestPostUnboostable() {
|
||||
t := suite.testTokens["local_account_1"]
|
||||
oauthToken := oauth.DBTokenToToken(t)
|
||||
var (
|
||||
targetStatus = suite.testStatuses["local_account_2_status_4"]
|
||||
app = suite.testApplications["application_1"]
|
||||
token = suite.testTokens["local_account_1"]
|
||||
user = suite.testUsers["local_account_1"]
|
||||
account = suite.testAccounts["local_account_1"]
|
||||
)
|
||||
|
||||
targetStatus := suite.testStatuses["local_account_2_status_4"]
|
||||
out, recorder := suite.postStatusBoost(
|
||||
targetStatus.ID,
|
||||
app,
|
||||
token,
|
||||
user,
|
||||
account,
|
||||
)
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||
ctx.Request.Header.Set("accept", "application/json")
|
||||
|
||||
// normally the router would populate these params from the path values,
|
||||
// but because we're calling the function directly, we need to set them manually.
|
||||
ctx.Params = gin.Params{
|
||||
gin.Param{
|
||||
Key: statuses.IDKey,
|
||||
Value: targetStatus.ID,
|
||||
},
|
||||
}
|
||||
|
||||
suite.statusModule.StatusBoostPOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
// We should have 403 from
|
||||
// our call to the function.
|
||||
suite.Equal(http.StatusForbidden, recorder.Code)
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
suite.NoError(err)
|
||||
suite.Equal(`{"error":"Forbidden: you do not have permission to boost this status"}`, string(b))
|
||||
// We should have a helpful message.
|
||||
suite.Equal(`{
|
||||
"error": "Forbidden: you do not have permission to boost this status"
|
||||
}`, out)
|
||||
}
|
||||
|
||||
// try to boost a status that's not visible to the user
|
||||
// Try to boost a status that's not visible to the user.
|
||||
func (suite *StatusBoostTestSuite) TestPostNotVisible() {
|
||||
// stop local_account_2 following zork
|
||||
err := suite.db.DeleteByID(context.Background(), suite.testFollows["local_account_2_local_account_1"].ID, >smodel.Follow{})
|
||||
suite.NoError(err)
|
||||
|
||||
t := suite.testTokens["local_account_2"]
|
||||
oauthToken := oauth.DBTokenToToken(t)
|
||||
|
||||
targetStatus := suite.testStatuses["local_account_1_status_3"] // this is a mutual only status and these accounts aren't mutuals
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"])
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"])
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||
ctx.Request.Header.Set("accept", "application/json")
|
||||
|
||||
// normally the router would populate these params from the path values,
|
||||
// but because we're calling the function directly, we need to set them manually.
|
||||
ctx.Params = gin.Params{
|
||||
gin.Param{
|
||||
Key: statuses.IDKey,
|
||||
Value: targetStatus.ID,
|
||||
},
|
||||
// Stop local_account_2 following zork.
|
||||
err := suite.db.DeleteFollowByID(
|
||||
context.Background(),
|
||||
suite.testFollows["local_account_2_local_account_1"].ID,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.statusModule.StatusBoostPOSTHandler(ctx)
|
||||
var (
|
||||
// This is a mutual only status and
|
||||
// these accounts aren't mutuals anymore.
|
||||
targetStatus = suite.testStatuses["local_account_1_status_3"]
|
||||
app = suite.testApplications["application_1"]
|
||||
token = suite.testTokens["local_account_2"]
|
||||
user = suite.testUsers["local_account_2"]
|
||||
account = suite.testAccounts["local_account_2"]
|
||||
)
|
||||
|
||||
// check response
|
||||
suite.Equal(http.StatusNotFound, recorder.Code) // we 404 statuses that aren't visible
|
||||
out, recorder := suite.postStatusBoost(
|
||||
targetStatus.ID,
|
||||
app,
|
||||
token,
|
||||
user,
|
||||
account,
|
||||
)
|
||||
|
||||
// We should have 404 from
|
||||
// our call to the function.
|
||||
suite.Equal(http.StatusNotFound, recorder.Code)
|
||||
|
||||
// We should have a helpful message.
|
||||
suite.Equal(`{
|
||||
"error": "Not Found: target status not found"
|
||||
}`, out)
|
||||
}
|
||||
|
||||
// Boost a status that's pending approval by us.
|
||||
func (suite *StatusBoostTestSuite) TestPostBoostImplicitAccept() {
|
||||
var (
|
||||
targetStatus = suite.testStatuses["admin_account_status_5"]
|
||||
app = suite.testApplications["application_1"]
|
||||
token = suite.testTokens["local_account_2"]
|
||||
user = suite.testUsers["local_account_2"]
|
||||
account = suite.testAccounts["local_account_2"]
|
||||
)
|
||||
|
||||
out, recorder := suite.postStatusBoost(
|
||||
targetStatus.ID,
|
||||
app,
|
||||
token,
|
||||
user,
|
||||
account,
|
||||
)
|
||||
|
||||
// We should have OK from
|
||||
// our call to the function.
|
||||
suite.Equal(http.StatusOK, recorder.Code)
|
||||
|
||||
// Target status should now
|
||||
// be "reblogged" by us.
|
||||
suite.Equal(`{
|
||||
"account": "yeah this is my account, what about it punk",
|
||||
"application": {
|
||||
"name": "really cool gts application",
|
||||
"website": "https://reallycool.app"
|
||||
},
|
||||
"bookmarked": false,
|
||||
"card": null,
|
||||
"content": "",
|
||||
"created_at": "right the hell just now babyee",
|
||||
"emojis": [],
|
||||
"favourited": false,
|
||||
"favourites_count": 0,
|
||||
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
"in_reply_to_account_id": null,
|
||||
"in_reply_to_id": null,
|
||||
"interaction_policy": {
|
||||
"can_favourite": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reblog": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reply": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
}
|
||||
},
|
||||
"language": null,
|
||||
"media_attachments": [],
|
||||
"mentions": [],
|
||||
"muted": false,
|
||||
"pinned": false,
|
||||
"poll": null,
|
||||
"reblog": {
|
||||
"account": "yeah this is my account, what about it punk",
|
||||
"application": {
|
||||
"name": "superseriousbusiness",
|
||||
"website": "https://superserious.business"
|
||||
},
|
||||
"bookmarked": false,
|
||||
"card": null,
|
||||
"content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p>",
|
||||
"created_at": "right the hell just now babyee",
|
||||
"emojis": [],
|
||||
"favourited": false,
|
||||
"favourites_count": 0,
|
||||
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
"in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF",
|
||||
"in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5",
|
||||
"interaction_policy": {
|
||||
"can_favourite": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reblog": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reply": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
}
|
||||
},
|
||||
"language": null,
|
||||
"media_attachments": [],
|
||||
"mentions": [
|
||||
{
|
||||
"acct": "1happyturtle",
|
||||
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
"url": "http://localhost:8080/@1happyturtle",
|
||||
"username": "1happyturtle"
|
||||
}
|
||||
],
|
||||
"muted": false,
|
||||
"pinned": false,
|
||||
"poll": null,
|
||||
"reblog": null,
|
||||
"reblogged": true,
|
||||
"reblogs_count": 1,
|
||||
"replies_count": 0,
|
||||
"sensitive": false,
|
||||
"spoiler_text": "",
|
||||
"tags": [],
|
||||
"text": "Hi @1happyturtle, can I reply?",
|
||||
"uri": "http://localhost:8080/some/determinate/url",
|
||||
"url": "http://localhost:8080/some/determinate/url",
|
||||
"visibility": "public"
|
||||
},
|
||||
"reblogged": true,
|
||||
"reblogs_count": 0,
|
||||
"replies_count": 0,
|
||||
"sensitive": false,
|
||||
"spoiler_text": "",
|
||||
"tags": [],
|
||||
"uri": "http://localhost:8080/some/determinate/url",
|
||||
"url": "http://localhost:8080/some/determinate/url",
|
||||
"visibility": "public"
|
||||
}`, out)
|
||||
|
||||
// Target status should no
|
||||
// longer be pending approval.
|
||||
dbStatus, err := suite.state.DB.GetStatusByID(
|
||||
context.Background(),
|
||||
targetStatus.ID,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.False(*dbStatus.PendingApproval)
|
||||
|
||||
// There should be an Accept
|
||||
// stored for the target status.
|
||||
intReq, err := suite.state.DB.GetInteractionRequestByInteractionURI(
|
||||
context.Background(), targetStatus.URI,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.NotZero(intReq.AcceptedAt)
|
||||
suite.NotEmpty(intReq.URI)
|
||||
}
|
||||
|
||||
func TestStatusBoostTestSuite(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import (
|
|||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/form/v4"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
|
|
@ -35,10 +37,27 @@ import (
|
|||
|
||||
// StatusCreatePOSTHandler swagger:operation POST /api/v1/statuses statusCreate
|
||||
//
|
||||
// Create a new status.
|
||||
// Create a new status using the given form field parameters.
|
||||
//
|
||||
// The parameters can also be given in the body of the request, as JSON, if the content-type is set to 'application/json'.
|
||||
// The parameters can also be given in the body of the request, as XML, if the content-type is set to 'application/xml'.
|
||||
//
|
||||
// The 'interaction_policy' field can be used to set an interaction policy for this status.
|
||||
//
|
||||
// If submitting using form data, use the following pattern to set an interaction policy:
|
||||
//
|
||||
// `interaction_policy[INTERACTION_TYPE][CONDITION][INDEX]=Value`
|
||||
//
|
||||
// For example: `interaction_policy[can_reply][always][0]=author`
|
||||
//
|
||||
// Using `curl` this might look something like:
|
||||
//
|
||||
// `curl -F 'interaction_policy[can_reply][always][0]=author' -F 'interaction_policy[can_reply][always][1]=followers' [... other form fields ...]`
|
||||
//
|
||||
// The JSON equivalent would be:
|
||||
//
|
||||
// `curl -H 'Content-Type: application/json' -d '{"interaction_policy":{"can_reply":{"always":["author","followers"]}} [... other json fields ...]}'`
|
||||
//
|
||||
// The server will perform some normalization on the submitted policy so that you can't submit something totally invalid.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
|
|
@ -46,7 +65,6 @@ import (
|
|||
//
|
||||
// consumes:
|
||||
// - application/json
|
||||
// - application/xml
|
||||
// - application/x-www-form-urlencoded
|
||||
//
|
||||
// parameters:
|
||||
|
|
@ -181,6 +199,36 @@ import (
|
|||
// - text/plain
|
||||
// - text/markdown
|
||||
// in: formData
|
||||
// -
|
||||
// name: interaction_policy[can_favourite][always][0]
|
||||
// in: formData
|
||||
// description: Nth entry for interaction_policy.can_favourite.always.
|
||||
// type: string
|
||||
// -
|
||||
// name: interaction_policy[can_favourite][with_approval][0]
|
||||
// in: formData
|
||||
// description: Nth entry for interaction_policy.can_favourite.with_approval.
|
||||
// type: string
|
||||
// -
|
||||
// name: interaction_policy[can_reply][always][0]
|
||||
// in: formData
|
||||
// description: Nth entry for interaction_policy.can_reply.always.
|
||||
// type: string
|
||||
// -
|
||||
// name: interaction_policy[can_reply][with_approval][0]
|
||||
// in: formData
|
||||
// description: Nth entry for interaction_policy.can_reply.with_approval.
|
||||
// type: string
|
||||
// -
|
||||
// name: interaction_policy[can_reblog][always][0]
|
||||
// in: formData
|
||||
// description: Nth entry for interaction_policy.can_reblog.always.
|
||||
// type: string
|
||||
// -
|
||||
// name: interaction_policy[can_reblog][with_approval][0]
|
||||
// in: formData
|
||||
// description: Nth entry for interaction_policy.can_reblog.with_approval.
|
||||
// type: string
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
|
|
@ -223,8 +271,8 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
form := &apimodel.StatusCreateRequest{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
form, err := parseStatusCreateForm(c)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
|
@ -257,6 +305,75 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, apiStatus)
|
||||
}
|
||||
|
||||
// intPolicyFormBinding satisfies gin's binding.Binding interface.
|
||||
// Should only be used specifically for multipart/form-data MIME type.
|
||||
type intPolicyFormBinding struct{}
|
||||
|
||||
func (i intPolicyFormBinding) Name() string {
|
||||
return "InteractionPolicy"
|
||||
}
|
||||
|
||||
func (intPolicyFormBinding) Bind(req *http.Request, obj any) error {
|
||||
if err := req.ParseForm(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Change default namespace prefix and suffix to
|
||||
// allow correct parsing of the field attributes.
|
||||
decoder := form.NewDecoder()
|
||||
decoder.SetNamespacePrefix("[")
|
||||
decoder.SetNamespaceSuffix("]")
|
||||
|
||||
return decoder.Decode(obj, req.Form)
|
||||
}
|
||||
|
||||
func parseStatusCreateForm(c *gin.Context) (*apimodel.StatusCreateRequest, error) {
|
||||
form := new(apimodel.StatusCreateRequest)
|
||||
|
||||
switch ct := c.ContentType(); ct {
|
||||
case binding.MIMEJSON:
|
||||
// Just bind with default json binding.
|
||||
if err := c.ShouldBindWith(form, binding.JSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
case binding.MIMEPOSTForm:
|
||||
// Bind with default form binding first.
|
||||
if err := c.ShouldBindWith(form, binding.FormPost); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Now do custom binding.
|
||||
intReqForm := new(apimodel.StatusInteractionPolicyForm)
|
||||
if err := c.ShouldBindWith(intReqForm, intPolicyFormBinding{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
form.InteractionPolicy = intReqForm.InteractionPolicy
|
||||
|
||||
case binding.MIMEMultipartPOSTForm:
|
||||
// Bind with default form binding first.
|
||||
if err := c.ShouldBindWith(form, binding.FormMultipart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Now do custom binding.
|
||||
intReqForm := new(apimodel.StatusInteractionPolicyForm)
|
||||
if err := c.ShouldBindWith(intReqForm, intPolicyFormBinding{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
form.InteractionPolicy = intReqForm.InteractionPolicy
|
||||
|
||||
default:
|
||||
err := fmt.Errorf(
|
||||
"content-type %s not supported for this endpoint; supported content-types are %s, %s, %s",
|
||||
ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm,
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return form, nil
|
||||
}
|
||||
|
||||
// validateNormalizeCreateStatus checks the form
|
||||
// for disallowed combinations of attachments and
|
||||
// overlength inputs.
|
||||
|
|
|
|||
|
|
@ -18,20 +18,19 @@
|
|||
package statuses_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
|
@ -40,90 +39,281 @@ type StatusFaveTestSuite struct {
|
|||
StatusStandardTestSuite
|
||||
}
|
||||
|
||||
// fave a status
|
||||
func (suite *StatusFaveTestSuite) postStatusFave(
|
||||
targetStatusID string,
|
||||
app *gtsmodel.Application,
|
||||
token *gtsmodel.Token,
|
||||
user *gtsmodel.User,
|
||||
account *gtsmodel.Account,
|
||||
) (string, *httptest.ResponseRecorder) {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, app)
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token))
|
||||
ctx.Set(oauth.SessionAuthorizedUser, user)
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, account)
|
||||
|
||||
const pathBase = "http://localhost:8080/api" + statuses.FavouritePath
|
||||
path := strings.ReplaceAll(pathBase, ":"+apiutil.IDKey, targetStatusID)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, path, nil)
|
||||
ctx.Request.Header.Set("accept", "application/json")
|
||||
|
||||
// Populate target status ID.
|
||||
ctx.Params = gin.Params{
|
||||
gin.Param{
|
||||
Key: apiutil.IDKey,
|
||||
Value: targetStatusID,
|
||||
},
|
||||
}
|
||||
|
||||
// Trigger handler.
|
||||
suite.statusModule.StatusFavePOSTHandler(ctx)
|
||||
return suite.parseStatusResponse(recorder)
|
||||
}
|
||||
|
||||
// Fave a status we haven't faved yet.
|
||||
func (suite *StatusFaveTestSuite) TestPostFave() {
|
||||
t := suite.testTokens["local_account_1"]
|
||||
oauthToken := oauth.DBTokenToToken(t)
|
||||
var (
|
||||
targetStatus = suite.testStatuses["admin_account_status_2"]
|
||||
app = suite.testApplications["application_1"]
|
||||
token = suite.testTokens["local_account_1"]
|
||||
user = suite.testUsers["local_account_1"]
|
||||
account = suite.testAccounts["local_account_1"]
|
||||
)
|
||||
|
||||
targetStatus := suite.testStatuses["admin_account_status_2"]
|
||||
out, recorder := suite.postStatusFave(
|
||||
targetStatus.ID,
|
||||
app,
|
||||
token,
|
||||
user,
|
||||
account,
|
||||
)
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||
ctx.Request.Header.Set("accept", "application/json")
|
||||
// We should have OK from
|
||||
// our call to the function.
|
||||
suite.Equal(http.StatusOK, recorder.Code)
|
||||
|
||||
// normally the router would populate these params from the path values,
|
||||
// but because we're calling the function directly, we need to set them manually.
|
||||
ctx.Params = gin.Params{
|
||||
gin.Param{
|
||||
Key: statuses.IDKey,
|
||||
Value: targetStatus.ID,
|
||||
// Target status should now
|
||||
// be "favourited" by us.
|
||||
suite.Equal(`{
|
||||
"account": "yeah this is my account, what about it punk",
|
||||
"application": {
|
||||
"name": "superseriousbusiness",
|
||||
"website": "https://superserious.business"
|
||||
},
|
||||
"bookmarked": false,
|
||||
"card": null,
|
||||
"content": "🐕🐕🐕🐕🐕",
|
||||
"created_at": "right the hell just now babyee",
|
||||
"emojis": [],
|
||||
"favourited": true,
|
||||
"favourites_count": 1,
|
||||
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
"in_reply_to_account_id": null,
|
||||
"in_reply_to_id": null,
|
||||
"interaction_policy": {
|
||||
"can_favourite": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reblog": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reply": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
}
|
||||
},
|
||||
"language": "en",
|
||||
"media_attachments": [],
|
||||
"mentions": [],
|
||||
"muted": false,
|
||||
"pinned": false,
|
||||
"poll": null,
|
||||
"reblog": null,
|
||||
"reblogged": false,
|
||||
"reblogs_count": 0,
|
||||
"replies_count": 0,
|
||||
"sensitive": true,
|
||||
"spoiler_text": "open to see some puppies",
|
||||
"tags": [],
|
||||
"text": "🐕🐕🐕🐕🐕",
|
||||
"uri": "http://localhost:8080/some/determinate/url",
|
||||
"url": "http://localhost:8080/some/determinate/url",
|
||||
"visibility": "public"
|
||||
}`, out)
|
||||
}
|
||||
|
||||
suite.statusModule.StatusFavePOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
statusReply := &apimodel.Status{}
|
||||
err = json.Unmarshal(b, statusReply)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
|
||||
assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
|
||||
assert.True(suite.T(), statusReply.Sensitive)
|
||||
assert.Equal(suite.T(), apimodel.VisibilityPublic, statusReply.Visibility)
|
||||
assert.True(suite.T(), statusReply.Favourited)
|
||||
assert.Equal(suite.T(), 1, statusReply.FavouritesCount)
|
||||
}
|
||||
|
||||
// try to fave a status that's not faveable
|
||||
// Try to fave a status
|
||||
// that's not faveable by us.
|
||||
func (suite *StatusFaveTestSuite) TestPostUnfaveable() {
|
||||
t := suite.testTokens["admin_account"]
|
||||
oauthToken := oauth.DBTokenToToken(t)
|
||||
var (
|
||||
targetStatus = suite.testStatuses["local_account_1_status_3"]
|
||||
app = suite.testApplications["application_1"]
|
||||
token = suite.testTokens["admin_account"]
|
||||
user = suite.testUsers["admin_account"]
|
||||
account = suite.testAccounts["admin_account"]
|
||||
)
|
||||
|
||||
targetStatus := suite.testStatuses["local_account_1_status_3"] // this one is unlikeable
|
||||
out, recorder := suite.postStatusFave(
|
||||
targetStatus.ID,
|
||||
app,
|
||||
token,
|
||||
user,
|
||||
account,
|
||||
)
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
|
||||
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["admin_account"])
|
||||
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["admin_account"])
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||
ctx.Request.Header.Set("accept", "application/json")
|
||||
// We should have 403 from
|
||||
// our call to the function.
|
||||
suite.Equal(http.StatusForbidden, recorder.Code)
|
||||
|
||||
// normally the router would populate these params from the path values,
|
||||
// but because we're calling the function directly, we need to set them manually.
|
||||
ctx.Params = gin.Params{
|
||||
gin.Param{
|
||||
Key: statuses.IDKey,
|
||||
Value: targetStatus.ID,
|
||||
},
|
||||
// We should get a helpful error.
|
||||
suite.Equal(`{
|
||||
"error": "Forbidden: you do not have permission to fave this status"
|
||||
}`, out)
|
||||
}
|
||||
|
||||
suite.statusModule.StatusFavePOSTHandler(ctx)
|
||||
// Fave a status that's pending approval by us.
|
||||
func (suite *StatusFaveTestSuite) TestPostFaveImplicitAccept() {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
targetStatus = suite.testStatuses["admin_account_status_5"]
|
||||
app = suite.testApplications["application_1"]
|
||||
token = suite.testTokens["local_account_2"]
|
||||
user = suite.testUsers["local_account_2"]
|
||||
account = suite.testAccounts["local_account_2"]
|
||||
visFilter = visibility.NewFilter(&suite.state)
|
||||
)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusForbidden, recorder.Code)
|
||||
// Check visibility of status to public before posting fave.
|
||||
visible, err := visFilter.StatusVisible(ctx, nil, targetStatus)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
if visible {
|
||||
suite.FailNow("status should not be visible yet")
|
||||
}
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), `{"error":"Forbidden: you do not have permission to fave this status"}`, string(b))
|
||||
out, recorder := suite.postStatusFave(
|
||||
targetStatus.ID,
|
||||
app,
|
||||
token,
|
||||
user,
|
||||
account,
|
||||
)
|
||||
|
||||
// We should have OK from
|
||||
// our call to the function.
|
||||
suite.Equal(http.StatusOK, recorder.Code)
|
||||
|
||||
// Target status should now
|
||||
// be "favourited" by us.
|
||||
suite.Equal(`{
|
||||
"account": "yeah this is my account, what about it punk",
|
||||
"application": {
|
||||
"name": "superseriousbusiness",
|
||||
"website": "https://superserious.business"
|
||||
},
|
||||
"bookmarked": false,
|
||||
"card": null,
|
||||
"content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p>",
|
||||
"created_at": "right the hell just now babyee",
|
||||
"emojis": [],
|
||||
"favourited": true,
|
||||
"favourites_count": 1,
|
||||
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
"in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF",
|
||||
"in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5",
|
||||
"interaction_policy": {
|
||||
"can_favourite": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reblog": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
},
|
||||
"can_reply": {
|
||||
"always": [
|
||||
"public",
|
||||
"me"
|
||||
],
|
||||
"with_approval": []
|
||||
}
|
||||
},
|
||||
"language": null,
|
||||
"media_attachments": [],
|
||||
"mentions": [
|
||||
{
|
||||
"acct": "1happyturtle",
|
||||
"id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
"url": "http://localhost:8080/@1happyturtle",
|
||||
"username": "1happyturtle"
|
||||
}
|
||||
],
|
||||
"muted": false,
|
||||
"pinned": false,
|
||||
"poll": null,
|
||||
"reblog": null,
|
||||
"reblogged": false,
|
||||
"reblogs_count": 0,
|
||||
"replies_count": 0,
|
||||
"sensitive": false,
|
||||
"spoiler_text": "",
|
||||
"tags": [],
|
||||
"text": "Hi @1happyturtle, can I reply?",
|
||||
"uri": "http://localhost:8080/some/determinate/url",
|
||||
"url": "http://localhost:8080/some/determinate/url",
|
||||
"visibility": "public"
|
||||
}`, out)
|
||||
|
||||
// Target status should no
|
||||
// longer be pending approval.
|
||||
dbStatus, err := suite.state.DB.GetStatusByID(
|
||||
ctx,
|
||||
targetStatus.ID,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.False(*dbStatus.PendingApproval)
|
||||
suite.NotEmpty(dbStatus.ApprovedByURI)
|
||||
|
||||
// There should be an Accept
|
||||
// stored for the target status.
|
||||
intReq, err := suite.state.DB.GetInteractionRequestByInteractionURI(
|
||||
ctx, targetStatus.URI,
|
||||
)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.NotZero(intReq.AcceptedAt)
|
||||
suite.NotEmpty(intReq.URI)
|
||||
|
||||
// Check visibility of status to public after posting fave.
|
||||
visible, err = visFilter.StatusVisible(ctx, nil, dbStatus)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
if !visible {
|
||||
suite.FailNow("status should be visible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusFaveTestSuite(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//go:build !nometrics
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
|
|
|
|||
31
internal/api/metrics/no_metrics.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//go:build nometrics
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Module struct{}
|
||||
|
||||
func New() *Module { return &Module{} }
|
||||
|
||||
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
|
||||
}
|
||||
|
|
@ -27,6 +27,10 @@ type Conversation struct {
|
|||
// Is the conversation currently marked as unread?
|
||||
Unread bool `json:"unread"`
|
||||
// Participants in the conversation.
|
||||
//
|
||||
// If this is a conversation between no accounts (ie., a self-directed DM),
|
||||
// this will include only the requesting account itself. Otherwise, it will
|
||||
// include every other account in the conversation *except* the requester.
|
||||
Accounts []Account `json:"accounts"`
|
||||
// The last status in the conversation. May be `null`.
|
||||
LastStatus *Status `json:"last_status"`
|
||||
|
|
|
|||
|
|
@ -196,33 +196,44 @@ type StatusCreateRequest struct {
|
|||
// Text content of the status.
|
||||
// If media_ids is provided, this becomes optional.
|
||||
// Attaching a poll is optional while status is provided.
|
||||
Status string `form:"status" json:"status" xml:"status"`
|
||||
Status string `form:"status" json:"status"`
|
||||
// Array of Attachment ids to be attached as media.
|
||||
// If provided, status becomes optional, and poll cannot be used.
|
||||
MediaIDs []string `form:"media_ids[]" json:"media_ids" xml:"media_ids"`
|
||||
MediaIDs []string `form:"media_ids[]" json:"media_ids"`
|
||||
// Poll to include with this status.
|
||||
Poll *PollRequest `form:"poll" json:"poll" xml:"poll"`
|
||||
Poll *PollRequest `form:"poll" json:"poll"`
|
||||
// ID of the status being replied to, if status is a reply.
|
||||
InReplyToID string `form:"in_reply_to_id" json:"in_reply_to_id" xml:"in_reply_to_id"`
|
||||
InReplyToID string `form:"in_reply_to_id" json:"in_reply_to_id"`
|
||||
// Status and attached media should be marked as sensitive.
|
||||
Sensitive bool `form:"sensitive" json:"sensitive" xml:"sensitive"`
|
||||
Sensitive bool `form:"sensitive" json:"sensitive"`
|
||||
// Text to be shown as a warning or subject before the actual content.
|
||||
// Statuses are generally collapsed behind this field.
|
||||
SpoilerText string `form:"spoiler_text" json:"spoiler_text" xml:"spoiler_text"`
|
||||
SpoilerText string `form:"spoiler_text" json:"spoiler_text"`
|
||||
// Visibility of the posted status.
|
||||
Visibility Visibility `form:"visibility" json:"visibility" xml:"visibility"`
|
||||
Visibility Visibility `form:"visibility" json:"visibility"`
|
||||
// Set to "true" if this status should not be federated, ie. it should be a "local only" status.
|
||||
LocalOnly *bool `form:"local_only"`
|
||||
LocalOnly *bool `form:"local_only" json:"local_only"`
|
||||
// Deprecated: Only used if LocalOnly is not set.
|
||||
Federated *bool `form:"federated"`
|
||||
Federated *bool `form:"federated" json:"federated"`
|
||||
// ISO 8601 Datetime at which to schedule a status.
|
||||
// Providing this parameter will cause ScheduledStatus to be returned instead of Status.
|
||||
// Must be at least 5 minutes in the future.
|
||||
ScheduledAt string `form:"scheduled_at" json:"scheduled_at" xml:"scheduled_at"`
|
||||
ScheduledAt string `form:"scheduled_at" json:"scheduled_at"`
|
||||
// ISO 639 language code for this status.
|
||||
Language string `form:"language" json:"language" xml:"language"`
|
||||
Language string `form:"language" json:"language"`
|
||||
// Content type to use when parsing this status.
|
||||
ContentType StatusContentType `form:"content_type" json:"content_type" xml:"content_type"`
|
||||
ContentType StatusContentType `form:"content_type" json:"content_type"`
|
||||
// Interaction policy to use for this status.
|
||||
InteractionPolicy *InteractionPolicy `form:"-" json:"interaction_policy"`
|
||||
}
|
||||
|
||||
// Separate form for parsing interaction
|
||||
// policy on status create requests.
|
||||
//
|
||||
// swagger:ignore
|
||||
type StatusInteractionPolicyForm struct {
|
||||
// Interaction policy to use for this status.
|
||||
InteractionPolicy *InteractionPolicy `form:"interaction_policy" json:"-"`
|
||||
}
|
||||
|
||||
// Visibility models the visibility of a status.
|
||||
|
|
|
|||
32
internal/cache/cache.go
vendored
|
|
@ -47,6 +47,11 @@ type Caches struct {
|
|||
// Webfinger provides access to the webfinger URL cache.
|
||||
Webfinger *ttl.Cache[string, string] // TTL=24hr, sweep=5min
|
||||
|
||||
// TTL cache of statuses -> filterable text fields.
|
||||
// To ensure up-to-date fields, cache is keyed as:
|
||||
// `[status.ID][status.UpdatedAt.Unix()]`
|
||||
StatusesFilterableFields *ttl.Cache[string, []string]
|
||||
|
||||
// prevent pass-by-value.
|
||||
_ nocopy
|
||||
}
|
||||
|
|
@ -57,7 +62,6 @@ func (c *Caches) Init() {
|
|||
log.Infof(nil, "init: %p", c)
|
||||
|
||||
c.initAccount()
|
||||
c.initAccountIDsFollowingTag()
|
||||
c.initAccountNote()
|
||||
c.initAccountSettings()
|
||||
c.initAccountStats()
|
||||
|
|
@ -79,11 +83,13 @@ func (c *Caches) Init() {
|
|||
c.initFollowIDs()
|
||||
c.initFollowRequest()
|
||||
c.initFollowRequestIDs()
|
||||
c.initFollowingTagIDs()
|
||||
c.initInReplyToIDs()
|
||||
c.initInstance()
|
||||
c.initInteractionRequest()
|
||||
c.initList()
|
||||
c.initListEntry()
|
||||
c.initListIDs()
|
||||
c.initListedIDs()
|
||||
c.initMarker()
|
||||
c.initMedia()
|
||||
c.initMention()
|
||||
|
|
@ -100,7 +106,6 @@ func (c *Caches) Init() {
|
|||
c.initStatusFave()
|
||||
c.initStatusFaveIDs()
|
||||
c.initTag()
|
||||
c.initTagIDsFollowedByAccount()
|
||||
c.initThreadMute()
|
||||
c.initToken()
|
||||
c.initTombstone()
|
||||
|
|
@ -109,6 +114,7 @@ func (c *Caches) Init() {
|
|||
c.initUserMuteIDs()
|
||||
c.initWebfinger()
|
||||
c.initVisibility()
|
||||
c.initStatusesFilterableFields()
|
||||
}
|
||||
|
||||
// Start will start any caches that require a background
|
||||
|
|
@ -119,6 +125,10 @@ func (c *Caches) Start() {
|
|||
tryUntil("starting webfinger cache", 5, func() bool {
|
||||
return c.Webfinger.Start(5 * time.Minute)
|
||||
})
|
||||
|
||||
tryUntil("starting statusesFilterableFields cache", 5, func() bool {
|
||||
return c.StatusesFilterableFields.Start(5 * time.Minute)
|
||||
})
|
||||
}
|
||||
|
||||
// Stop will stop any caches that require a background
|
||||
|
|
@ -127,6 +137,7 @@ func (c *Caches) Stop() {
|
|||
log.Infof(nil, "stop: %p", c)
|
||||
|
||||
tryUntil("stopping webfinger cache", 5, c.Webfinger.Stop)
|
||||
tryUntil("stopping statusesFilterableFields cache", 5, c.StatusesFilterableFields.Stop)
|
||||
}
|
||||
|
||||
// Sweep will sweep all the available caches to ensure none
|
||||
|
|
@ -137,7 +148,6 @@ func (c *Caches) Stop() {
|
|||
// significant overhead to all cache writes.
|
||||
func (c *Caches) Sweep(threshold float64) {
|
||||
c.DB.Account.Trim(threshold)
|
||||
c.DB.AccountIDsFollowingTag.Trim(threshold)
|
||||
c.DB.AccountNote.Trim(threshold)
|
||||
c.DB.AccountSettings.Trim(threshold)
|
||||
c.DB.AccountStats.Trim(threshold)
|
||||
|
|
@ -157,11 +167,13 @@ func (c *Caches) Sweep(threshold float64) {
|
|||
c.DB.FollowIDs.Trim(threshold)
|
||||
c.DB.FollowRequest.Trim(threshold)
|
||||
c.DB.FollowRequestIDs.Trim(threshold)
|
||||
c.DB.FollowingTagIDs.Trim(threshold)
|
||||
c.DB.InReplyToIDs.Trim(threshold)
|
||||
c.DB.Instance.Trim(threshold)
|
||||
c.DB.InteractionRequest.Trim(threshold)
|
||||
c.DB.List.Trim(threshold)
|
||||
c.DB.ListEntry.Trim(threshold)
|
||||
c.DB.ListIDs.Trim(threshold)
|
||||
c.DB.ListedIDs.Trim(threshold)
|
||||
c.DB.Marker.Trim(threshold)
|
||||
c.DB.Media.Trim(threshold)
|
||||
c.DB.Mention.Trim(threshold)
|
||||
|
|
@ -178,7 +190,6 @@ func (c *Caches) Sweep(threshold float64) {
|
|||
c.DB.StatusFave.Trim(threshold)
|
||||
c.DB.StatusFaveIDs.Trim(threshold)
|
||||
c.DB.Tag.Trim(threshold)
|
||||
c.DB.TagIDsFollowedByAccount.Trim(threshold)
|
||||
c.DB.ThreadMute.Trim(threshold)
|
||||
c.DB.Token.Trim(threshold)
|
||||
c.DB.Tombstone.Trim(threshold)
|
||||
|
|
@ -204,3 +215,12 @@ func (c *Caches) initWebfinger() {
|
|||
24*time.Hour,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Caches) initStatusesFilterableFields() {
|
||||
c.StatusesFilterableFields = new(ttl.Cache[string, []string])
|
||||
c.StatusesFilterableFields.Init(
|
||||
0,
|
||||
512,
|
||||
1*time.Hour,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
164
internal/cache/db.go
vendored
|
|
@ -18,6 +18,8 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"codeberg.org/gruf/go-structr"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/cache/domain"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
|
|
@ -29,9 +31,6 @@ type DBCaches struct {
|
|||
// Account provides access to the gtsmodel Account database cache.
|
||||
Account StructCache[*gtsmodel.Account]
|
||||
|
||||
// AccountIDsFollowingTag caches account IDs following a given tag ID.
|
||||
AccountIDsFollowingTag SliceCache[string]
|
||||
|
||||
// AccountNote provides access to the gtsmodel Note database cache.
|
||||
AccountNote StructCache[*gtsmodel.AccountNote]
|
||||
|
||||
|
|
@ -88,10 +87,23 @@ type DBCaches struct {
|
|||
|
||||
// FollowIDs provides access to the follower / following IDs database cache.
|
||||
// THIS CACHE IS KEYED AS THE FOLLOWING {prefix}{accountID} WHERE PREFIX IS:
|
||||
// - '>' for following IDs
|
||||
// - 'l>' for local following IDs
|
||||
// - '<' for follower IDs
|
||||
// - 'l<' for local follower IDs
|
||||
//
|
||||
// - '>{$accountID}' for following IDs
|
||||
// e.g. FollowIDs.Load(">" + account.ID, func() {})
|
||||
// which will load a slice of follows IDs FROM account.
|
||||
//
|
||||
// - 'l>{$accountID}' for local following IDs
|
||||
// e.g. FollowIDs.Load("l>" + account.ID, func() {})
|
||||
// which will load a slice of LOCAL follows IDs FROM account.
|
||||
//
|
||||
// - '<{$accountID}' for follower IDs
|
||||
// e.g. FollowIDs.Load("<" + account.ID, func() {})
|
||||
// which will load a slice of follows IDs TARGETTING account.
|
||||
//
|
||||
// - 'l<{$accountID}' for local follower IDs
|
||||
// e.g. FollowIDs.Load("l<" + account.ID, func() {})
|
||||
// which will load a slice of LOCAL follows IDs TARGETTING account.
|
||||
//
|
||||
FollowIDs SliceCache[string]
|
||||
|
||||
// FollowRequest provides access to the gtsmodel FollowRequest database cache.
|
||||
|
|
@ -99,13 +111,41 @@ type DBCaches struct {
|
|||
|
||||
// FollowRequestIDs provides access to the follow requester / requesting IDs database
|
||||
// cache. THIS CACHE IS KEYED AS THE FOLLOWING {prefix}{accountID} WHERE PREFIX IS:
|
||||
// - '>' for following IDs
|
||||
// - '<' for follower IDs
|
||||
//
|
||||
// - '>{$accountID}' for follow request IDs
|
||||
// e.g. FollowRequestIDs.Load(">" + account.ID, func() {})
|
||||
// which will load a slice of follow request IDs TARGETTING account.
|
||||
//
|
||||
// - '<{$accountID}' for follow request IDs
|
||||
// e.g. FollowRequestIDs.Load("<" + account.ID, func() {})
|
||||
// which will load a slice of follow request IDs FROM account.
|
||||
//
|
||||
FollowRequestIDs SliceCache[string]
|
||||
|
||||
// FollowingTagIDs provides access to account IDs following / tag IDs followed by
|
||||
// account db cache. THIS CACHE IS KEYED AS THE FOLLOWING {prefix}{id} WHERE:
|
||||
//
|
||||
// - '>{$accountID}' for tag IDs followed by account
|
||||
// e.g. FollowingTagIDs.Load(">" + account.ID, func() {})
|
||||
// which will load a slice of tag IDs followed by account.
|
||||
//
|
||||
// - '<{$tagIDs}' for account IDs following tag
|
||||
// e.g. FollowingTagIDs.Load("<" + tag.ID, func() {})
|
||||
// which will load a slice of account IDs following tag.
|
||||
//
|
||||
FollowingTagIDs SliceCache[string]
|
||||
|
||||
// Instance provides access to the gtsmodel Instance database cache.
|
||||
Instance StructCache[*gtsmodel.Instance]
|
||||
|
||||
// LocalInstance provides caching for
|
||||
// simple + common local instance queries.
|
||||
LocalInstance struct {
|
||||
Domains atomic.Pointer[int]
|
||||
Statuses atomic.Pointer[int]
|
||||
Users atomic.Pointer[int]
|
||||
}
|
||||
|
||||
// InteractionRequest provides access to the gtsmodel InteractionRequest database cache.
|
||||
InteractionRequest StructCache[*gtsmodel.InteractionRequest]
|
||||
|
||||
|
|
@ -115,8 +155,31 @@ type DBCaches struct {
|
|||
// List provides access to the gtsmodel List database cache.
|
||||
List StructCache[*gtsmodel.List]
|
||||
|
||||
// ListEntry provides access to the gtsmodel ListEntry database cache.
|
||||
ListEntry StructCache[*gtsmodel.ListEntry]
|
||||
// ListIDs provides access to the list IDs owned by account / list IDs follow
|
||||
// contained in db cache. THIS CACHE IS KEYED AS FOLLOWING {prefix}{id} WHERE:
|
||||
//
|
||||
// - 'a{$accountID}' for list IDs owned by account
|
||||
// e.g. ListIDs.Load("a" + account.ID, func() {})
|
||||
// which will load a slice of list IDs owned by account.
|
||||
//
|
||||
// - 'f{$followID}' for list IDs follow contained in
|
||||
// e.g. ListIDs.Load("f" + follow.ID, func() {})
|
||||
// which will load a slice of list IDs containing follow.
|
||||
//
|
||||
ListIDs SliceCache[string]
|
||||
|
||||
// ListedIDs provides access to the account IDs in list / follow IDs in
|
||||
// list db cache. THIS CACHE IS KEYED AS FOLLOWING {prefix}{id} WHERE:
|
||||
//
|
||||
// - 'a{listID}' for account IDs in list ID
|
||||
// e.g. ListedIDs.Load("a" + list.ID, func() {})
|
||||
// which will load a slice of account IDs in list.
|
||||
//
|
||||
// - 'f{listID}' for follow IDs in list ID
|
||||
// e.g. ListedIDs.Load("f" + list.ID, func() {})
|
||||
// which will load a slice of follow IDs in list.
|
||||
//
|
||||
ListedIDs SliceCache[string]
|
||||
|
||||
// Marker provides access to the gtsmodel Marker database cache.
|
||||
Marker StructCache[*gtsmodel.Marker]
|
||||
|
|
@ -151,10 +214,10 @@ type DBCaches struct {
|
|||
// Status provides access to the gtsmodel Status database cache.
|
||||
Status StructCache[*gtsmodel.Status]
|
||||
|
||||
// StatusBookmark ...
|
||||
// StatusBookmark provides access to the gtsmodel StatusBookmark database cache.
|
||||
StatusBookmark StructCache[*gtsmodel.StatusBookmark]
|
||||
|
||||
// StatusBookmarkIDs ...
|
||||
// StatusBookmarkIDs provides access to the status bookmark IDs list database cache.
|
||||
StatusBookmarkIDs SliceCache[string]
|
||||
|
||||
// StatusFave provides access to the gtsmodel StatusFave database cache.
|
||||
|
|
@ -166,9 +229,6 @@ type DBCaches struct {
|
|||
// Tag provides access to the gtsmodel Tag database cache.
|
||||
Tag StructCache[*gtsmodel.Tag]
|
||||
|
||||
// TagIDsFollowedByAccount caches tag IDs followed by a given account ID.
|
||||
TagIDsFollowedByAccount SliceCache[string]
|
||||
|
||||
// ThreadMute provides access to the gtsmodel ThreadMute database cache.
|
||||
ThreadMute StructCache[*gtsmodel.ThreadMute]
|
||||
|
||||
|
|
@ -243,17 +303,6 @@ func (c *Caches) initAccount() {
|
|||
})
|
||||
}
|
||||
|
||||
func (c *Caches) initAccountIDsFollowingTag() {
|
||||
// Calculate maximum cache size.
|
||||
cap := calculateSliceCacheMax(
|
||||
config.GetCacheAccountIDsFollowingTagMemRatio(),
|
||||
)
|
||||
|
||||
log.Infof(nil, "cache size = %d", cap)
|
||||
|
||||
c.DB.AccountIDsFollowingTag.Init(0, cap)
|
||||
}
|
||||
|
||||
func (c *Caches) initAccountNote() {
|
||||
// Calculate maximum cache size.
|
||||
cap := calculateResultCacheMax(
|
||||
|
|
@ -761,6 +810,17 @@ func (c *Caches) initFollowRequestIDs() {
|
|||
c.DB.FollowRequestIDs.Init(0, cap)
|
||||
}
|
||||
|
||||
func (c *Caches) initFollowingTagIDs() {
|
||||
// Calculate maximum cache size.
|
||||
cap := calculateSliceCacheMax(
|
||||
config.GetCacheFollowingTagIDsMemRatio(),
|
||||
)
|
||||
|
||||
log.Infof(nil, "cache size = %d", cap)
|
||||
|
||||
c.DB.FollowingTagIDs.Init(0, cap)
|
||||
}
|
||||
|
||||
func (c *Caches) initInReplyToIDs() {
|
||||
// Calculate maximum cache size.
|
||||
cap := calculateSliceCacheMax(
|
||||
|
|
@ -802,6 +862,7 @@ func (c *Caches) initInstance() {
|
|||
MaxSize: cap,
|
||||
IgnoreErr: ignoreErrors,
|
||||
Copy: copyF,
|
||||
Invalidate: c.OnInvalidateInstance,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -860,7 +921,6 @@ func (c *Caches) initList() {
|
|||
// will be populated separately.
|
||||
// See internal/db/bundb/list.go.
|
||||
l2.Account = nil
|
||||
l2.ListEntries = nil
|
||||
|
||||
return l2
|
||||
}
|
||||
|
|
@ -876,37 +936,26 @@ func (c *Caches) initList() {
|
|||
})
|
||||
}
|
||||
|
||||
func (c *Caches) initListEntry() {
|
||||
func (c *Caches) initListIDs() {
|
||||
// Calculate maximum cache size.
|
||||
cap := calculateResultCacheMax(
|
||||
sizeofListEntry(), // model in-mem size.
|
||||
config.GetCacheListEntryMemRatio(),
|
||||
cap := calculateSliceCacheMax(
|
||||
config.GetCacheListIDsMemRatio(),
|
||||
)
|
||||
|
||||
log.Infof(nil, "cache size = %d", cap)
|
||||
|
||||
copyF := func(l1 *gtsmodel.ListEntry) *gtsmodel.ListEntry {
|
||||
l2 := new(gtsmodel.ListEntry)
|
||||
*l2 = *l1
|
||||
|
||||
// Don't include ptr fields that
|
||||
// will be populated separately.
|
||||
// See internal/db/bundb/list.go.
|
||||
l2.Follow = nil
|
||||
|
||||
return l2
|
||||
c.DB.ListIDs.Init(0, cap)
|
||||
}
|
||||
|
||||
c.DB.ListEntry.Init(structr.CacheConfig[*gtsmodel.ListEntry]{
|
||||
Indices: []structr.IndexConfig{
|
||||
{Fields: "ID"},
|
||||
{Fields: "ListID", Multiple: true},
|
||||
{Fields: "FollowID", Multiple: true},
|
||||
},
|
||||
MaxSize: cap,
|
||||
IgnoreErr: ignoreErrors,
|
||||
Copy: copyF,
|
||||
})
|
||||
func (c *Caches) initListedIDs() {
|
||||
// Calculate maximum cache size.
|
||||
cap := calculateSliceCacheMax(
|
||||
config.GetCacheListedIDsMemRatio(),
|
||||
)
|
||||
|
||||
log.Infof(nil, "cache size = %d", cap)
|
||||
|
||||
c.DB.ListedIDs.Init(0, cap)
|
||||
}
|
||||
|
||||
func (c *Caches) initMarker() {
|
||||
|
|
@ -1368,17 +1417,6 @@ func (c *Caches) initTag() {
|
|||
})
|
||||
}
|
||||
|
||||
func (c *Caches) initTagIDsFollowedByAccount() {
|
||||
// Calculate maximum cache size.
|
||||
cap := calculateSliceCacheMax(
|
||||
config.GetCacheTagIDsFollowedByAccountMemRatio(),
|
||||
)
|
||||
|
||||
log.Infof(nil, "cache size = %d", cap)
|
||||
|
||||
c.DB.TagIDsFollowedByAccount.Init(0, cap)
|
||||
}
|
||||
|
||||
func (c *Caches) initThreadMute() {
|
||||
cap := calculateResultCacheMax(
|
||||
sizeofThreadMute(), // model in-mem size.
|
||||
|
|
|
|||
96
internal/cache/invalidate.go
vendored
|
|
@ -19,6 +19,7 @@ package cache
|
|||
|
||||
import (
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
// Below are cache invalidation hooks between other caches,
|
||||
|
|
@ -97,9 +98,6 @@ func (c *Caches) OnInvalidateFollow(follow *gtsmodel.Follow) {
|
|||
// Invalidate follow request with this same ID.
|
||||
c.DB.FollowRequest.Invalidate("ID", follow.ID)
|
||||
|
||||
// Invalidate any related list entries.
|
||||
c.DB.ListEntry.Invalidate("FollowID", follow.ID)
|
||||
|
||||
// Invalidate follow origin account ID cached visibility.
|
||||
c.Visibility.Invalidate("ItemID", follow.AccountID)
|
||||
c.Visibility.Invalidate("RequesterID", follow.AccountID)
|
||||
|
|
@ -108,18 +106,47 @@ func (c *Caches) OnInvalidateFollow(follow *gtsmodel.Follow) {
|
|||
c.Visibility.Invalidate("ItemID", follow.TargetAccountID)
|
||||
c.Visibility.Invalidate("RequesterID", follow.TargetAccountID)
|
||||
|
||||
// Invalidate source account's following
|
||||
// lists, and destination's follwer lists.
|
||||
// (see FollowIDs() comment for details).
|
||||
// Invalidate ID slice cache.
|
||||
c.DB.FollowIDs.Invalidate(
|
||||
|
||||
// Invalidate follow ID lists
|
||||
// TARGETTING origin account
|
||||
// (including local-only follows).
|
||||
">"+follow.AccountID,
|
||||
"l>"+follow.AccountID,
|
||||
|
||||
// Invalidate follow ID lists
|
||||
// FROM the origin account
|
||||
// (including local-only follows).
|
||||
"<"+follow.AccountID,
|
||||
"l<"+follow.AccountID,
|
||||
"<"+follow.TargetAccountID,
|
||||
"l<"+follow.TargetAccountID,
|
||||
|
||||
// Invalidate follow ID lists
|
||||
// TARGETTING the target account
|
||||
// (including local-only follows).
|
||||
">"+follow.TargetAccountID,
|
||||
"l>"+follow.TargetAccountID,
|
||||
|
||||
// Invalidate follow ID lists
|
||||
// FROM the target account
|
||||
// (including local-only follows).
|
||||
"<"+follow.TargetAccountID,
|
||||
"l<"+follow.TargetAccountID,
|
||||
)
|
||||
|
||||
// Invalidate ID slice cache.
|
||||
c.DB.ListIDs.Invalidate(
|
||||
|
||||
// Invalidate source
|
||||
// account's owned lists.
|
||||
"a"+follow.AccountID,
|
||||
|
||||
// Invalidate target account's.
|
||||
"a"+follow.TargetAccountID,
|
||||
|
||||
// Invalidate lists containing
|
||||
// list entries for follow.
|
||||
"f"+follow.ID,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -127,20 +154,53 @@ func (c *Caches) OnInvalidateFollowRequest(followReq *gtsmodel.FollowRequest) {
|
|||
// Invalidate follow with this same ID.
|
||||
c.DB.Follow.Invalidate("ID", followReq.ID)
|
||||
|
||||
// Invalidate source account's followreq
|
||||
// lists, and destinations follow req lists.
|
||||
// (see FollowRequestIDs() comment for details).
|
||||
// Invalidate ID slice cache.
|
||||
c.DB.FollowRequestIDs.Invalidate(
|
||||
|
||||
// Invalidate follow request ID
|
||||
// lists TARGETTING origin account
|
||||
// (including local-only follows).
|
||||
">"+followReq.AccountID,
|
||||
|
||||
// Invalidate follow request ID
|
||||
// lists FROM the origin account
|
||||
// (including local-only follows).
|
||||
"<"+followReq.AccountID,
|
||||
|
||||
// Invalidate follow request ID
|
||||
// lists TARGETTING target account
|
||||
// (including local-only follows).
|
||||
">"+followReq.TargetAccountID,
|
||||
|
||||
// Invalidate follow request ID
|
||||
// lists FROM the target account
|
||||
// (including local-only follows).
|
||||
"<"+followReq.TargetAccountID,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Caches) OnInvalidateInstance(instance *gtsmodel.Instance) {
|
||||
// Invalidate the local domains count.
|
||||
c.DB.LocalInstance.Domains.Store(nil)
|
||||
}
|
||||
|
||||
func (c *Caches) OnInvalidateList(list *gtsmodel.List) {
|
||||
// Invalidate all cached entries of this list.
|
||||
c.DB.ListEntry.Invalidate("ListID", list.ID)
|
||||
// Invalidate list IDs cache.
|
||||
c.DB.ListIDs.Invalidate(
|
||||
"a" + list.AccountID,
|
||||
)
|
||||
|
||||
// Invalidate ID slice cache.
|
||||
c.DB.ListedIDs.Invalidate(
|
||||
|
||||
// Invalidate list of
|
||||
// account IDs in list.
|
||||
"a"+list.ID,
|
||||
|
||||
// Invalidate list of
|
||||
// follow IDs in list.
|
||||
"f"+list.ID,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *Caches) OnInvalidateMedia(media *gtsmodel.MediaAttachment) {
|
||||
|
|
@ -184,7 +244,7 @@ func (c *Caches) OnInvalidateStatus(status *gtsmodel.Status) {
|
|||
// the media IDs in use before the media table is
|
||||
// aware of the status ID they are linked to.
|
||||
//
|
||||
// c.DB.Media().Invalidate("StatusID") will not work.
|
||||
// c.DB.Media.Invalidate("StatusID") will not work.
|
||||
c.DB.Media.InvalidateIDs("ID", status.AttachmentIDs)
|
||||
|
||||
if status.BoostOfID != "" {
|
||||
|
|
@ -201,6 +261,11 @@ func (c *Caches) OnInvalidateStatus(status *gtsmodel.Status) {
|
|||
// Invalidate cache of attached poll ID.
|
||||
c.DB.Poll.Invalidate("ID", status.PollID)
|
||||
}
|
||||
|
||||
if util.PtrOrZero(status.Local) {
|
||||
// Invalidate the local statuses count.
|
||||
c.DB.LocalInstance.Statuses.Store(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Caches) OnInvalidateStatusBookmark(bookmark *gtsmodel.StatusBookmark) {
|
||||
|
|
@ -217,6 +282,9 @@ func (c *Caches) OnInvalidateUser(user *gtsmodel.User) {
|
|||
// Invalidate local account ID cached visibility.
|
||||
c.Visibility.Invalidate("ItemID", user.AccountID)
|
||||
c.Visibility.Invalidate("RequesterID", user.AccountID)
|
||||
|
||||
// Invalidate the local users count.
|
||||
c.DB.LocalInstance.Users.Store(nil)
|
||||
}
|
||||
|
||||
func (c *Caches) OnInvalidateUserMute(mute *gtsmodel.UserMute) {
|
||||
|
|
|
|||
21
internal/cache/size.go
vendored
|
|
@ -166,6 +166,7 @@ func calculateCacheMax(keySz, valSz uintptr, ratio float64) int {
|
|||
|
||||
// totalOfRatios returns the total of all cache ratios added together.
|
||||
func totalOfRatios() float64 {
|
||||
|
||||
// NOTE: this is not performant calculating
|
||||
// this every damn time (mainly the mutex unlocks
|
||||
// required to access each config var). fortunately
|
||||
|
|
@ -189,11 +190,13 @@ func totalOfRatios() float64 {
|
|||
config.GetCacheFollowIDsMemRatio() +
|
||||
config.GetCacheFollowRequestMemRatio() +
|
||||
config.GetCacheFollowRequestIDsMemRatio() +
|
||||
config.GetCacheFollowingTagIDsMemRatio() +
|
||||
config.GetCacheInReplyToIDsMemRatio() +
|
||||
config.GetCacheInstanceMemRatio() +
|
||||
config.GetCacheInteractionRequestMemRatio() +
|
||||
config.GetCacheInReplyToIDsMemRatio() +
|
||||
config.GetCacheListMemRatio() +
|
||||
config.GetCacheListEntryMemRatio() +
|
||||
config.GetCacheListIDsMemRatio() +
|
||||
config.GetCacheListedIDsMemRatio() +
|
||||
config.GetCacheMarkerMemRatio() +
|
||||
config.GetCacheMediaMemRatio() +
|
||||
config.GetCacheMentionMemRatio() +
|
||||
|
|
@ -201,7 +204,9 @@ func totalOfRatios() float64 {
|
|||
config.GetCacheNotificationMemRatio() +
|
||||
config.GetCachePollMemRatio() +
|
||||
config.GetCachePollVoteMemRatio() +
|
||||
config.GetCachePollVoteIDsMemRatio() +
|
||||
config.GetCacheReportMemRatio() +
|
||||
config.GetCacheSinBinStatusMemRatio() +
|
||||
config.GetCacheStatusMemRatio() +
|
||||
config.GetCacheStatusBookmarkMemRatio() +
|
||||
config.GetCacheStatusBookmarkIDsMemRatio() +
|
||||
|
|
@ -212,6 +217,8 @@ func totalOfRatios() float64 {
|
|||
config.GetCacheTokenMemRatio() +
|
||||
config.GetCacheTombstoneMemRatio() +
|
||||
config.GetCacheUserMemRatio() +
|
||||
config.GetCacheUserMuteMemRatio() +
|
||||
config.GetCacheUserMuteIDsMemRatio() +
|
||||
config.GetCacheWebfingerMemRatio() +
|
||||
config.GetCacheVisibilityMemRatio()
|
||||
}
|
||||
|
|
@ -466,16 +473,6 @@ func sizeofList() uintptr {
|
|||
}))
|
||||
}
|
||||
|
||||
func sizeofListEntry() uintptr {
|
||||
return uintptr(size.Of(>smodel.ListEntry{
|
||||
ID: exampleID,
|
||||
CreatedAt: exampleTime,
|
||||
UpdatedAt: exampleTime,
|
||||
ListID: exampleID,
|
||||
FollowID: exampleID,
|
||||
}))
|
||||
}
|
||||
|
||||
func sizeofMarker() uintptr {
|
||||
return uintptr(size.Of(>smodel.Marker{
|
||||
AccountID: exampleID,
|
||||
|
|
|
|||
2
internal/cache/util.go
vendored
|
|
@ -18,7 +18,6 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
|
|
@ -42,7 +41,6 @@ func ignoreErrors(err error) bool {
|
|||
// (until invalidation).
|
||||
db.ErrNoEntries,
|
||||
db.ErrAlreadyExists,
|
||||
sql.ErrNoRows,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
8
internal/cache/visibility.go
vendored
|
|
@ -49,7 +49,13 @@ func (c *Caches) initVisibility() {
|
|||
{Fields: "Type,RequesterID,ItemID"},
|
||||
},
|
||||
MaxSize: cap,
|
||||
IgnoreErr: ignoreErrors,
|
||||
IgnoreErr: func(err error) bool {
|
||||
// don't cache any errors,
|
||||
// it gets a little too tricky
|
||||
// otherwise with ensuring
|
||||
// errors are cleared out
|
||||
return true
|
||||
},
|
||||
Copy: copyF,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,7 +196,6 @@ type HTTPClientConfiguration struct {
|
|||
type CacheConfiguration struct {
|
||||
MemoryTarget bytesize.Size `name:"memory-target"`
|
||||
AccountMemRatio float64 `name:"account-mem-ratio"`
|
||||
AccountIDsFollowingTagMemRatio float64 `name:"account-ids-following-tag-mem-ratio"`
|
||||
AccountNoteMemRatio float64 `name:"account-note-mem-ratio"`
|
||||
AccountSettingsMemRatio float64 `name:"account-settings-mem-ratio"`
|
||||
AccountStatsMemRatio float64 `name:"account-stats-mem-ratio"`
|
||||
|
|
@ -216,11 +215,13 @@ type CacheConfiguration struct {
|
|||
FollowIDsMemRatio float64 `name:"follow-ids-mem-ratio"`
|
||||
FollowRequestMemRatio float64 `name:"follow-request-mem-ratio"`
|
||||
FollowRequestIDsMemRatio float64 `name:"follow-request-ids-mem-ratio"`
|
||||
FollowingTagIDsMemRatio float64 `name:"following-tag-ids-mem-ratio"`
|
||||
InReplyToIDsMemRatio float64 `name:"in-reply-to-ids-mem-ratio"`
|
||||
InstanceMemRatio float64 `name:"instance-mem-ratio"`
|
||||
InteractionRequestMemRatio float64 `name:"interaction-request-mem-ratio"`
|
||||
ListMemRatio float64 `name:"list-mem-ratio"`
|
||||
ListEntryMemRatio float64 `name:"list-entry-mem-ratio"`
|
||||
ListIDsMemRatio float64 `name:"list-ids-mem-ratio"`
|
||||
ListedIDsMemRatio float64 `name:"listed-ids-mem-ratio"`
|
||||
MarkerMemRatio float64 `name:"marker-mem-ratio"`
|
||||
MediaMemRatio float64 `name:"media-mem-ratio"`
|
||||
MentionMemRatio float64 `name:"mention-mem-ratio"`
|
||||
|
|
@ -237,7 +238,6 @@ type CacheConfiguration struct {
|
|||
StatusFaveMemRatio float64 `name:"status-fave-mem-ratio"`
|
||||
StatusFaveIDsMemRatio float64 `name:"status-fave-ids-mem-ratio"`
|
||||
TagMemRatio float64 `name:"tag-mem-ratio"`
|
||||
TagIDsFollowedByAccountMemRatio float64 `name:"tag-ids-followed-by-account-mem-ratio"`
|
||||
ThreadMuteMemRatio float64 `name:"thread-mute-mem-ratio"`
|
||||
TokenMemRatio float64 `name:"token-mem-ratio"`
|
||||
TombstoneMemRatio float64 `name:"tombstone-mem-ratio"`
|
||||
|
|
|
|||
|
|
@ -159,7 +159,6 @@ var Defaults = Configuration{
|
|||
// file have been addressed, these should
|
||||
// be able to make some more sense :D
|
||||
AccountMemRatio: 5,
|
||||
AccountIDsFollowingTagMemRatio: 1,
|
||||
AccountNoteMemRatio: 1,
|
||||
AccountSettingsMemRatio: 0.1,
|
||||
AccountStatsMemRatio: 2,
|
||||
|
|
@ -179,11 +178,13 @@ var Defaults = Configuration{
|
|||
FollowIDsMemRatio: 4,
|
||||
FollowRequestMemRatio: 2,
|
||||
FollowRequestIDsMemRatio: 2,
|
||||
FollowingTagIDsMemRatio: 2,
|
||||
InReplyToIDsMemRatio: 3,
|
||||
InstanceMemRatio: 1,
|
||||
InteractionRequestMemRatio: 1,
|
||||
ListMemRatio: 1,
|
||||
ListEntryMemRatio: 2,
|
||||
ListIDsMemRatio: 2,
|
||||
ListedIDsMemRatio: 2,
|
||||
MarkerMemRatio: 0.5,
|
||||
MediaMemRatio: 4,
|
||||
MentionMemRatio: 2,
|
||||
|
|
@ -200,7 +201,6 @@ var Defaults = Configuration{
|
|||
StatusFaveMemRatio: 2,
|
||||
StatusFaveIDsMemRatio: 3,
|
||||
TagMemRatio: 2,
|
||||
TagIDsFollowedByAccountMemRatio: 1,
|
||||
ThreadMuteMemRatio: 0.2,
|
||||
TokenMemRatio: 0.75,
|
||||
TombstoneMemRatio: 0.5,
|
||||
|
|
|
|||
|
|
@ -2850,37 +2850,6 @@ func GetCacheAccountMemRatio() float64 { return global.GetCacheAccountMemRatio()
|
|||
// SetCacheAccountMemRatio safely sets the value for global configuration 'Cache.AccountMemRatio' field
|
||||
func SetCacheAccountMemRatio(v float64) { global.SetCacheAccountMemRatio(v) }
|
||||
|
||||
// GetCacheAccountIDsFollowingTagMemRatio safely fetches the Configuration value for state's 'Cache.AccountIDsFollowingTagMemRatio' field
|
||||
func (st *ConfigState) GetCacheAccountIDsFollowingTagMemRatio() (v float64) {
|
||||
st.mutex.RLock()
|
||||
v = st.config.Cache.AccountIDsFollowingTagMemRatio
|
||||
st.mutex.RUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
// SetCacheAccountIDsFollowingTagMemRatio safely sets the Configuration value for state's 'Cache.AccountIDsFollowingTagMemRatio' field
|
||||
func (st *ConfigState) SetCacheAccountIDsFollowingTagMemRatio(v float64) {
|
||||
st.mutex.Lock()
|
||||
defer st.mutex.Unlock()
|
||||
st.config.Cache.AccountIDsFollowingTagMemRatio = v
|
||||
st.reloadToViper()
|
||||
}
|
||||
|
||||
// CacheAccountIDsFollowingTagMemRatioFlag returns the flag name for the 'Cache.AccountIDsFollowingTagMemRatio' field
|
||||
func CacheAccountIDsFollowingTagMemRatioFlag() string {
|
||||
return "cache-account-ids-following-tag-mem-ratio"
|
||||
}
|
||||
|
||||
// GetCacheAccountIDsFollowingTagMemRatio safely fetches the value for global configuration 'Cache.AccountIDsFollowingTagMemRatio' field
|
||||
func GetCacheAccountIDsFollowingTagMemRatio() float64 {
|
||||
return global.GetCacheAccountIDsFollowingTagMemRatio()
|
||||
}
|
||||
|
||||
// SetCacheAccountIDsFollowingTagMemRatio safely sets the value for global configuration 'Cache.AccountIDsFollowingTagMemRatio' field
|
||||
func SetCacheAccountIDsFollowingTagMemRatio(v float64) {
|
||||
global.SetCacheAccountIDsFollowingTagMemRatio(v)
|
||||
}
|
||||
|
||||
// GetCacheAccountNoteMemRatio safely fetches the Configuration value for state's 'Cache.AccountNoteMemRatio' field
|
||||
func (st *ConfigState) GetCacheAccountNoteMemRatio() (v float64) {
|
||||
st.mutex.RLock()
|
||||
|
|
@ -3362,6 +3331,31 @@ func GetCacheFollowRequestIDsMemRatio() float64 { return global.GetCacheFollowRe
|
|||
// SetCacheFollowRequestIDsMemRatio safely sets the value for global configuration 'Cache.FollowRequestIDsMemRatio' field
|
||||
func SetCacheFollowRequestIDsMemRatio(v float64) { global.SetCacheFollowRequestIDsMemRatio(v) }
|
||||
|
||||
// GetCacheFollowingTagIDsMemRatio safely fetches the Configuration value for state's 'Cache.FollowingTagIDsMemRatio' field
|
||||
func (st *ConfigState) GetCacheFollowingTagIDsMemRatio() (v float64) {
|
||||
st.mutex.RLock()
|
||||
v = st.config.Cache.FollowingTagIDsMemRatio
|
||||
st.mutex.RUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
// SetCacheFollowingTagIDsMemRatio safely sets the Configuration value for state's 'Cache.FollowingTagIDsMemRatio' field
|
||||
func (st *ConfigState) SetCacheFollowingTagIDsMemRatio(v float64) {
|
||||
st.mutex.Lock()
|
||||
defer st.mutex.Unlock()
|
||||
st.config.Cache.FollowingTagIDsMemRatio = v
|
||||
st.reloadToViper()
|
||||
}
|
||||
|
||||
// CacheFollowingTagIDsMemRatioFlag returns the flag name for the 'Cache.FollowingTagIDsMemRatio' field
|
||||
func CacheFollowingTagIDsMemRatioFlag() string { return "cache-following-tag-ids-mem-ratio" }
|
||||
|
||||
// GetCacheFollowingTagIDsMemRatio safely fetches the value for global configuration 'Cache.FollowingTagIDsMemRatio' field
|
||||
func GetCacheFollowingTagIDsMemRatio() float64 { return global.GetCacheFollowingTagIDsMemRatio() }
|
||||
|
||||
// SetCacheFollowingTagIDsMemRatio safely sets the value for global configuration 'Cache.FollowingTagIDsMemRatio' field
|
||||
func SetCacheFollowingTagIDsMemRatio(v float64) { global.SetCacheFollowingTagIDsMemRatio(v) }
|
||||
|
||||
// GetCacheInReplyToIDsMemRatio safely fetches the Configuration value for state's 'Cache.InReplyToIDsMemRatio' field
|
||||
func (st *ConfigState) GetCacheInReplyToIDsMemRatio() (v float64) {
|
||||
st.mutex.RLock()
|
||||
|
|
@ -3462,30 +3456,55 @@ func GetCacheListMemRatio() float64 { return global.GetCacheListMemRatio() }
|
|||
// SetCacheListMemRatio safely sets the value for global configuration 'Cache.ListMemRatio' field
|
||||
func SetCacheListMemRatio(v float64) { global.SetCacheListMemRatio(v) }
|
||||
|
||||
// GetCacheListEntryMemRatio safely fetches the Configuration value for state's 'Cache.ListEntryMemRatio' field
|
||||
func (st *ConfigState) GetCacheListEntryMemRatio() (v float64) {
|
||||
// GetCacheListIDsMemRatio safely fetches the Configuration value for state's 'Cache.ListIDsMemRatio' field
|
||||
func (st *ConfigState) GetCacheListIDsMemRatio() (v float64) {
|
||||
st.mutex.RLock()
|
||||
v = st.config.Cache.ListEntryMemRatio
|
||||
v = st.config.Cache.ListIDsMemRatio
|
||||
st.mutex.RUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
// SetCacheListEntryMemRatio safely sets the Configuration value for state's 'Cache.ListEntryMemRatio' field
|
||||
func (st *ConfigState) SetCacheListEntryMemRatio(v float64) {
|
||||
// SetCacheListIDsMemRatio safely sets the Configuration value for state's 'Cache.ListIDsMemRatio' field
|
||||
func (st *ConfigState) SetCacheListIDsMemRatio(v float64) {
|
||||
st.mutex.Lock()
|
||||
defer st.mutex.Unlock()
|
||||
st.config.Cache.ListEntryMemRatio = v
|
||||
st.config.Cache.ListIDsMemRatio = v
|
||||
st.reloadToViper()
|
||||
}
|
||||
|
||||
// CacheListEntryMemRatioFlag returns the flag name for the 'Cache.ListEntryMemRatio' field
|
||||
func CacheListEntryMemRatioFlag() string { return "cache-list-entry-mem-ratio" }
|
||||
// CacheListIDsMemRatioFlag returns the flag name for the 'Cache.ListIDsMemRatio' field
|
||||
func CacheListIDsMemRatioFlag() string { return "cache-list-ids-mem-ratio" }
|
||||
|
||||
// GetCacheListEntryMemRatio safely fetches the value for global configuration 'Cache.ListEntryMemRatio' field
|
||||
func GetCacheListEntryMemRatio() float64 { return global.GetCacheListEntryMemRatio() }
|
||||
// GetCacheListIDsMemRatio safely fetches the value for global configuration 'Cache.ListIDsMemRatio' field
|
||||
func GetCacheListIDsMemRatio() float64 { return global.GetCacheListIDsMemRatio() }
|
||||
|
||||
// SetCacheListEntryMemRatio safely sets the value for global configuration 'Cache.ListEntryMemRatio' field
|
||||
func SetCacheListEntryMemRatio(v float64) { global.SetCacheListEntryMemRatio(v) }
|
||||
// SetCacheListIDsMemRatio safely sets the value for global configuration 'Cache.ListIDsMemRatio' field
|
||||
func SetCacheListIDsMemRatio(v float64) { global.SetCacheListIDsMemRatio(v) }
|
||||
|
||||
// GetCacheListedIDsMemRatio safely fetches the Configuration value for state's 'Cache.ListedIDsMemRatio' field
|
||||
func (st *ConfigState) GetCacheListedIDsMemRatio() (v float64) {
|
||||
st.mutex.RLock()
|
||||
v = st.config.Cache.ListedIDsMemRatio
|
||||
st.mutex.RUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
// SetCacheListedIDsMemRatio safely sets the Configuration value for state's 'Cache.ListedIDsMemRatio' field
|
||||
func (st *ConfigState) SetCacheListedIDsMemRatio(v float64) {
|
||||
st.mutex.Lock()
|
||||
defer st.mutex.Unlock()
|
||||
st.config.Cache.ListedIDsMemRatio = v
|
||||
st.reloadToViper()
|
||||
}
|
||||
|
||||
// CacheListedIDsMemRatioFlag returns the flag name for the 'Cache.ListedIDsMemRatio' field
|
||||
func CacheListedIDsMemRatioFlag() string { return "cache-listed-ids-mem-ratio" }
|
||||
|
||||
// GetCacheListedIDsMemRatio safely fetches the value for global configuration 'Cache.ListedIDsMemRatio' field
|
||||
func GetCacheListedIDsMemRatio() float64 { return global.GetCacheListedIDsMemRatio() }
|
||||
|
||||
// SetCacheListedIDsMemRatio safely sets the value for global configuration 'Cache.ListedIDsMemRatio' field
|
||||
func SetCacheListedIDsMemRatio(v float64) { global.SetCacheListedIDsMemRatio(v) }
|
||||
|
||||
// GetCacheMarkerMemRatio safely fetches the Configuration value for state's 'Cache.MarkerMemRatio' field
|
||||
func (st *ConfigState) GetCacheMarkerMemRatio() (v float64) {
|
||||
|
|
@ -3887,37 +3906,6 @@ func GetCacheTagMemRatio() float64 { return global.GetCacheTagMemRatio() }
|
|||
// SetCacheTagMemRatio safely sets the value for global configuration 'Cache.TagMemRatio' field
|
||||
func SetCacheTagMemRatio(v float64) { global.SetCacheTagMemRatio(v) }
|
||||
|
||||
// GetCacheTagIDsFollowedByAccountMemRatio safely fetches the Configuration value for state's 'Cache.TagIDsFollowedByAccountMemRatio' field
|
||||
func (st *ConfigState) GetCacheTagIDsFollowedByAccountMemRatio() (v float64) {
|
||||
st.mutex.RLock()
|
||||
v = st.config.Cache.TagIDsFollowedByAccountMemRatio
|
||||
st.mutex.RUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
// SetCacheTagIDsFollowedByAccountMemRatio safely sets the Configuration value for state's 'Cache.TagIDsFollowedByAccountMemRatio' field
|
||||
func (st *ConfigState) SetCacheTagIDsFollowedByAccountMemRatio(v float64) {
|
||||
st.mutex.Lock()
|
||||
defer st.mutex.Unlock()
|
||||
st.config.Cache.TagIDsFollowedByAccountMemRatio = v
|
||||
st.reloadToViper()
|
||||
}
|
||||
|
||||
// CacheTagIDsFollowedByAccountMemRatioFlag returns the flag name for the 'Cache.TagIDsFollowedByAccountMemRatio' field
|
||||
func CacheTagIDsFollowedByAccountMemRatioFlag() string {
|
||||
return "cache-tag-ids-followed-by-account-mem-ratio"
|
||||
}
|
||||
|
||||
// GetCacheTagIDsFollowedByAccountMemRatio safely fetches the value for global configuration 'Cache.TagIDsFollowedByAccountMemRatio' field
|
||||
func GetCacheTagIDsFollowedByAccountMemRatio() float64 {
|
||||
return global.GetCacheTagIDsFollowedByAccountMemRatio()
|
||||
}
|
||||
|
||||
// SetCacheTagIDsFollowedByAccountMemRatio safely sets the value for global configuration 'Cache.TagIDsFollowedByAccountMemRatio' field
|
||||
func SetCacheTagIDsFollowedByAccountMemRatio(v float64) {
|
||||
global.SetCacheTagIDsFollowedByAccountMemRatio(v)
|
||||
}
|
||||
|
||||
// GetCacheThreadMuteMemRatio safely fetches the Configuration value for state's 'Cache.ThreadMuteMemRatio' field
|
||||
func (st *ConfigState) GetCacheThreadMuteMemRatio() (v float64) {
|
||||
st.mutex.RLock()
|
||||
|
|
|
|||
|
|
@ -123,9 +123,6 @@ type Account interface {
|
|||
// In the case of no statuses, this function will return db.ErrNoEntries.
|
||||
GetAccountWebStatuses(ctx context.Context, account *gtsmodel.Account, limit int, maxID string) ([]*gtsmodel.Status, error)
|
||||
|
||||
// SetAccountHeaderOrAvatar sets the header or avatar for the given accountID to the given media attachment.
|
||||
SetAccountHeaderOrAvatar(ctx context.Context, mediaAttachment *gtsmodel.MediaAttachment, accountID string) error
|
||||
|
||||
// GetInstanceAccount returns the instance account for the given domain.
|
||||
// If domain is empty, this instance account will be returned.
|
||||
GetInstanceAccount(ctx context.Context, domain string) (*gtsmodel.Account, error)
|
||||
|
|
|
|||
|
|
@ -64,15 +64,8 @@ func (a *accountDB) GetAccountsByIDs(ctx context.Context, ids []string) ([]*gtsm
|
|||
accounts, err := a.state.Caches.DB.Account.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.Account, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached accounts.
|
||||
accounts := make([]*gtsmodel.Account, 0, count)
|
||||
accounts := make([]*gtsmodel.Account, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) account IDs.
|
||||
|
|
@ -796,20 +789,14 @@ func (a *accountDB) UpdateAccount(ctx context.Context, account *gtsmodel.Account
|
|||
}
|
||||
|
||||
func (a *accountDB) DeleteAccount(ctx context.Context, id string) error {
|
||||
defer a.state.Caches.DB.Account.Invalidate("ID", id)
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted gtsmodel.Account
|
||||
deleted.ID = id
|
||||
|
||||
// Load account into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
_, err := a.GetAccountByID(gtscontext.SetBarebones(ctx), id)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
// NOTE: even if db.ErrNoEntries is returned, we
|
||||
// still run the below transaction to ensure related
|
||||
// objects are appropriately deleted.
|
||||
return err
|
||||
}
|
||||
// Delete account from database and any related links in a transaction.
|
||||
if err := a.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
|
||||
return a.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
// clear out any emoji links
|
||||
if _, err := tx.
|
||||
NewDelete().
|
||||
|
|
@ -822,44 +809,19 @@ func (a *accountDB) DeleteAccount(ctx context.Context, id string) error {
|
|||
// delete the account
|
||||
_, err := tx.
|
||||
NewDelete().
|
||||
TableExpr("? AS ?", bun.Ident("accounts"), bun.Ident("account")).
|
||||
Where("? = ?", bun.Ident("account.id"), id).
|
||||
Model(&deleted).
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Returning("?", bun.Ident("uri")).
|
||||
Exec(ctx)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (a *accountDB) SetAccountHeaderOrAvatar(ctx context.Context, mediaAttachment *gtsmodel.MediaAttachment, accountID string) error {
|
||||
if *mediaAttachment.Avatar && *mediaAttachment.Header {
|
||||
return errors.New("one media attachment cannot be both header and avatar")
|
||||
}
|
||||
|
||||
var column bun.Ident
|
||||
switch {
|
||||
case *mediaAttachment.Avatar:
|
||||
column = bun.Ident("account.avatar_media_attachment_id")
|
||||
case *mediaAttachment.Header:
|
||||
column = bun.Ident("account.header_media_attachment_id")
|
||||
default:
|
||||
return errors.New("given media attachment was neither a header nor an avatar")
|
||||
}
|
||||
|
||||
// TODO: there are probably more side effects here that need to be handled
|
||||
if _, err := a.db.
|
||||
NewInsert().
|
||||
Model(mediaAttachment).
|
||||
Exec(ctx); err != nil {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := a.db.
|
||||
NewUpdate().
|
||||
TableExpr("? AS ?", bun.Ident("accounts"), bun.Ident("account")).
|
||||
Set("? = ?", column, mediaAttachment.ID).
|
||||
Where("? = ?", bun.Ident("account.id"), accountID).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// Invalidate cached account by its ID, manually
|
||||
// call invalidate hook in case not cached.
|
||||
a.state.Caches.DB.Account.Invalidate("ID", id)
|
||||
a.state.Caches.OnInvalidateAccount(&deleted)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,15 +147,8 @@ func (a *applicationDB) GetAllTokens(ctx context.Context) ([]*gtsmodel.Token, er
|
|||
tokens, err := a.state.Caches.DB.Token.LoadIDs("ID",
|
||||
tokenIDs,
|
||||
func(uncached []string) ([]*gtsmodel.Token, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached tokens.
|
||||
tokens := make([]*gtsmodel.Token, 0, count)
|
||||
tokens := make([]*gtsmodel.Token, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) token IDs.
|
||||
|
|
|
|||
|
|
@ -188,15 +188,8 @@ func (c *conversationDB) getConversationsByLastStatusIDs(
|
|||
accountID,
|
||||
conversationLastStatusIDs,
|
||||
func(accountID string, uncached []string) ([]*gtsmodel.Conversation, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached conversations.
|
||||
conversations := make([]*gtsmodel.Conversation, 0, count)
|
||||
conversations := make([]*gtsmodel.Conversation, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning the remaining (uncached) IDs.
|
||||
if err := c.db.NewSelect().
|
||||
|
|
@ -267,29 +260,29 @@ func (c *conversationDB) LinkConversationToStatus(ctx context.Context, conversat
|
|||
}
|
||||
|
||||
func (c *conversationDB) DeleteConversationByID(ctx context.Context, id string) error {
|
||||
// Load conversation into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
_, err := c.GetConversationByID(gtscontext.SetBarebones(ctx), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// not an issue.
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted gtsmodel.Conversation
|
||||
deleted.ID = id
|
||||
|
||||
// Drop this now-cached conversation on return after delete.
|
||||
defer c.state.Caches.DB.Conversation.Invalidate("ID", id)
|
||||
|
||||
// Finally delete conversation from DB.
|
||||
_, err = c.db.NewDelete().
|
||||
Model((*gtsmodel.Conversation)(nil)).
|
||||
// Delete conversation from DB.
|
||||
if _, err := c.db.NewDelete().
|
||||
Model(&deleted).
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Exec(ctx)
|
||||
Returning("?", bun.Ident("account_id")).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate cached conversation by ID,
|
||||
// manually invalidate hook in case not cached.
|
||||
c.state.Caches.DB.Conversation.Invalidate("ID", id)
|
||||
c.state.Caches.OnInvalidateConversation(&deleted)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *conversationDB) DeleteConversationsByOwnerAccountID(ctx context.Context, accountID string) error {
|
||||
defer func() {
|
||||
// Invalidate any cached conversations and conversation IDs owned by this account on return.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ package bundb
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -70,34 +69,15 @@ func (e *emojiDB) UpdateEmoji(ctx context.Context, emoji *gtsmodel.Emoji, column
|
|||
|
||||
func (e *emojiDB) DeleteEmojiByID(ctx context.Context, id string) error {
|
||||
var (
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
accountIDs []string
|
||||
statusIDs []string
|
||||
)
|
||||
|
||||
defer func() {
|
||||
// Invalidate cached emoji.
|
||||
e.state.Caches.DB.Emoji.Invalidate("ID", id)
|
||||
// Delete the emoji and all related links to it in a singular transaction.
|
||||
if err := e.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
|
||||
// Invalidate cached account and status IDs.
|
||||
e.state.Caches.DB.Account.InvalidateIDs("ID", accountIDs)
|
||||
e.state.Caches.DB.Status.InvalidateIDs("ID", statusIDs)
|
||||
}()
|
||||
|
||||
// Load emoji into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
_, err := e.GetEmojiByID(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
id,
|
||||
)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
// NOTE: even if db.ErrNoEntries is returned, we
|
||||
// still run the below transaction to ensure related
|
||||
// objects are appropriately deleted.
|
||||
return err
|
||||
}
|
||||
|
||||
return e.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
// Delete relational links between this emoji
|
||||
// and any statuses using it, returning the
|
||||
// status IDs so we can later update them.
|
||||
|
|
@ -195,7 +175,16 @@ func (e *emojiDB) DeleteEmojiByID(ctx context.Context, id string) error {
|
|||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate emoji, and any effected statuses / accounts.
|
||||
e.state.Caches.DB.Emoji.Invalidate("ID", id)
|
||||
e.state.Caches.DB.Account.InvalidateIDs("ID", accountIDs)
|
||||
e.state.Caches.DB.Status.InvalidateIDs("ID", statusIDs)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *emojiDB) GetEmojisBy(ctx context.Context, domain string, includeDisabled bool, includeEnabled bool, shortcode string, maxShortcodeDomain string, minShortcodeDomain string, limit int) ([]*gtsmodel.Emoji, error) {
|
||||
|
|
@ -586,15 +575,8 @@ func (e *emojiDB) GetEmojisByIDs(ctx context.Context, ids []string) ([]*gtsmodel
|
|||
emojis, err := e.state.Caches.DB.Emoji.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.Emoji, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached emojis.
|
||||
emojis := make([]*gtsmodel.Emoji, 0, count)
|
||||
emojis := make([]*gtsmodel.Emoji, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) IDs.
|
||||
|
|
@ -657,15 +639,8 @@ func (e *emojiDB) GetEmojiCategoriesByIDs(ctx context.Context, ids []string) ([]
|
|||
categories, err := e.state.Caches.DB.EmojiCategory.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.EmojiCategory, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached categories.
|
||||
categories := make([]*gtsmodel.EmojiCategory, 0, count)
|
||||
categories := make([]*gtsmodel.EmojiCategory, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) IDs.
|
||||
|
|
|
|||
|
|
@ -83,14 +83,7 @@ func (f *filterDB) GetFiltersForAccountID(ctx context.Context, accountID string)
|
|||
filters, err := f.state.Caches.DB.Filter.LoadIDs("ID",
|
||||
filterIDs,
|
||||
func(uncached []string) ([]*gtsmodel.Filter, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
filters := make([]*gtsmodel.Filter, 0, count)
|
||||
filters := make([]*gtsmodel.Filter, 0, len(uncached))
|
||||
if err := f.db.
|
||||
NewSelect().
|
||||
Model(&filters).
|
||||
|
|
|
|||
|
|
@ -113,14 +113,8 @@ func (f *filterDB) getFilterKeywords(ctx context.Context, idColumn string, id st
|
|||
filterKeywords, err := f.state.Caches.DB.FilterKeyword.LoadIDs("ID",
|
||||
filterKeywordIDs,
|
||||
func(uncached []string) ([]*gtsmodel.FilterKeyword, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
filterKeywords := make([]*gtsmodel.FilterKeyword, 0, len(uncached))
|
||||
|
||||
filterKeywords := make([]*gtsmodel.FilterKeyword, 0, count)
|
||||
if err := f.db.
|
||||
NewSelect().
|
||||
Model(&filterKeywords).
|
||||
|
|
|
|||
|
|
@ -100,14 +100,7 @@ func (f *filterDB) getFilterStatuses(ctx context.Context, idColumn string, id st
|
|||
filterStatuses, err := f.state.Caches.DB.FilterStatus.LoadIDs("ID",
|
||||
filterStatusIDs,
|
||||
func(uncached []string) ([]*gtsmodel.FilterStatus, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
filterStatuses := make([]*gtsmodel.FilterStatus, 0, count)
|
||||
filterStatuses := make([]*gtsmodel.FilterStatus, 0, len(uncached))
|
||||
if err := f.db.
|
||||
NewSelect().
|
||||
Model(&filterStatuses).
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import (
|
|||
"time"
|
||||
|
||||
"codeberg.org/gruf/go-kv"
|
||||
"codeberg.org/gruf/go-logger/v2/level"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
|
@ -50,7 +49,7 @@ func (queryHook) AfterQuery(ctx context.Context, event *bun.QueryEvent) {
|
|||
|
||||
// On trace, we log query information,
|
||||
// manually crafting so DB query not escaped.
|
||||
case log.Level() >= level.TRACE:
|
||||
case log.Level() >= log.TRACE:
|
||||
log.Printf("level=TRACE duration=%s query=%s", dur, event.Query)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@ type instanceDB struct {
|
|||
}
|
||||
|
||||
func (i *instanceDB) CountInstanceUsers(ctx context.Context, domain string) (int, error) {
|
||||
localhost := (domain == config.GetHost() || domain == config.GetAccountDomain())
|
||||
|
||||
if localhost {
|
||||
// Check for a cached instance user count, if so return this.
|
||||
if n := i.state.Caches.DB.LocalInstance.Users.Load(); n != nil {
|
||||
return *n, nil
|
||||
}
|
||||
}
|
||||
|
||||
q := i.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("accounts"), bun.Ident("account")).
|
||||
|
|
@ -46,7 +55,7 @@ func (i *instanceDB) CountInstanceUsers(ctx context.Context, domain string) (int
|
|||
Where("? != ?", bun.Ident("account.username"), domain).
|
||||
Where("? IS NULL", bun.Ident("account.suspended_at"))
|
||||
|
||||
if domain == config.GetHost() || domain == config.GetAccountDomain() {
|
||||
if localhost {
|
||||
// If the domain is *this* domain, just
|
||||
// count where the domain field is null.
|
||||
q = q.Where("? IS NULL", bun.Ident("account.domain"))
|
||||
|
|
@ -58,15 +67,30 @@ func (i *instanceDB) CountInstanceUsers(ctx context.Context, domain string) (int
|
|||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if localhost {
|
||||
// Update cached instance users account value.
|
||||
i.state.Caches.DB.LocalInstance.Users.Store(&count)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (i *instanceDB) CountInstanceStatuses(ctx context.Context, domain string) (int, error) {
|
||||
localhost := (domain == config.GetHost() || domain == config.GetAccountDomain())
|
||||
|
||||
if localhost {
|
||||
// Check for a cached instance statuses count, if so return this.
|
||||
if n := i.state.Caches.DB.LocalInstance.Statuses.Load(); n != nil {
|
||||
return *n, nil
|
||||
}
|
||||
}
|
||||
|
||||
q := i.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status"))
|
||||
|
||||
if domain == config.GetHost() || domain == config.GetAccountDomain() {
|
||||
if localhost {
|
||||
// if the domain is *this* domain, just count where local is true
|
||||
q = q.Where("? = ?", bun.Ident("status.local"), true)
|
||||
} else {
|
||||
|
|
@ -83,15 +107,30 @@ func (i *instanceDB) CountInstanceStatuses(ctx context.Context, domain string) (
|
|||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if localhost {
|
||||
// Update cached instance statuses account value.
|
||||
i.state.Caches.DB.LocalInstance.Statuses.Store(&count)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (i *instanceDB) CountInstanceDomains(ctx context.Context, domain string) (int, error) {
|
||||
localhost := (domain == config.GetHost() || domain == config.GetAccountDomain())
|
||||
|
||||
if localhost {
|
||||
// Check for a cached instance domains count, if so return this.
|
||||
if n := i.state.Caches.DB.LocalInstance.Domains.Load(); n != nil {
|
||||
return *n, nil
|
||||
}
|
||||
}
|
||||
|
||||
q := i.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("instances"), bun.Ident("instance"))
|
||||
|
||||
if domain == config.GetHost() {
|
||||
if localhost {
|
||||
// if the domain is *this* domain, just count other instances it knows about
|
||||
// exclude domains that are blocked
|
||||
q = q.
|
||||
|
|
@ -106,6 +145,12 @@ func (i *instanceDB) CountInstanceDomains(ctx context.Context, domain string) (i
|
|||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if localhost {
|
||||
// Update cached instance domains account value.
|
||||
i.state.Caches.DB.LocalInstance.Domains.Store(&count)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
|
|
@ -215,13 +260,15 @@ func (i *instanceDB) PopulateInstance(ctx context.Context, instance *gtsmodel.In
|
|||
}
|
||||
|
||||
func (i *instanceDB) PutInstance(ctx context.Context, instance *gtsmodel.Instance) error {
|
||||
// Normalize the domain as punycode
|
||||
var err error
|
||||
|
||||
// Normalize the domain as punycode
|
||||
instance.Domain, err = util.Punify(instance.Domain)
|
||||
if err != nil {
|
||||
return gtserror.Newf("error punifying domain %s: %w", instance.Domain, err)
|
||||
}
|
||||
|
||||
// Store the new instance model in database, invalidating cache.
|
||||
return i.state.Caches.DB.Instance.Store(instance, func() error {
|
||||
_, err := i.db.NewInsert().Model(instance).Exec(ctx)
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -26,8 +26,10 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
|
|
@ -84,6 +86,53 @@ func (i *interactionDB) GetInteractionRequestByURI(ctx context.Context, uri stri
|
|||
)
|
||||
}
|
||||
|
||||
func (i *interactionDB) GetInteractionRequestsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.InteractionRequest, error) {
|
||||
// Load all interaction request IDs via cache loader callbacks.
|
||||
requests, err := i.state.Caches.DB.InteractionRequest.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.InteractionRequest, error) {
|
||||
// Preallocate expected length of uncached interaction requests.
|
||||
requests := make([]*gtsmodel.InteractionRequest, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) IDs.
|
||||
if err := i.db.NewSelect().
|
||||
Model(&requests).
|
||||
Where("? IN (?)", bun.Ident("id"), bun.In(uncached)).
|
||||
Scan(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return requests, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reorder the requests by their
|
||||
// IDs to ensure in correct order.
|
||||
getID := func(r *gtsmodel.InteractionRequest) string { return r.ID }
|
||||
util.OrderBy(requests, ids, getID)
|
||||
|
||||
if gtscontext.Barebones(ctx) {
|
||||
// no need to fully populate.
|
||||
return requests, nil
|
||||
}
|
||||
|
||||
// Populate all loaded interaction requests, removing those we
|
||||
// fail to populate (removes needing so many nil checks everywhere).
|
||||
requests = slices.DeleteFunc(requests, func(request *gtsmodel.InteractionRequest) bool {
|
||||
if err := i.PopulateInteractionRequest(ctx, request); err != nil {
|
||||
log.Errorf(ctx, "error populating %s: %v", request.ID, err)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
return requests, nil
|
||||
}
|
||||
|
||||
func (i *interactionDB) getInteractionRequest(
|
||||
ctx context.Context,
|
||||
lookup string,
|
||||
|
|
@ -205,15 +254,20 @@ func (i *interactionDB) UpdateInteractionRequest(ctx context.Context, request *g
|
|||
}
|
||||
|
||||
func (i *interactionDB) DeleteInteractionRequestByID(ctx context.Context, id string) error {
|
||||
defer i.state.Caches.DB.InteractionRequest.Invalidate("ID", id)
|
||||
|
||||
_, err := i.db.NewDelete().
|
||||
TableExpr("? AS ?", bun.Ident("interaction_requests"), bun.Ident("interaction_request")).
|
||||
Where("? = ?", bun.Ident("interaction_request.id"), id).
|
||||
Exec(ctx)
|
||||
// Delete interaction request by ID.
|
||||
if _, err := i.db.NewDelete().
|
||||
Table("interaction_requests").
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate cached interaction request with ID.
|
||||
i.state.Caches.DB.InteractionRequest.Invalidate("ID", id)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *interactionDB) GetInteractionsRequestsForAcct(
|
||||
ctx context.Context,
|
||||
acctID string,
|
||||
|
|
@ -248,9 +302,9 @@ func (i *interactionDB) GetInteractionsRequestsForAcct(
|
|||
bun.Ident("interaction_request"),
|
||||
).
|
||||
// Select only interaction requests that
|
||||
// are neither accepted or rejected yet,
|
||||
// ie., without an Accept or Reject URI.
|
||||
Where("? IS NULL", bun.Ident("uri"))
|
||||
// are neither accepted or rejected yet.
|
||||
Where("? IS NULL", bun.Ident("accepted_at")).
|
||||
Where("? IS NULL", bun.Ident("rejected_at"))
|
||||
|
||||
// Select interactions targeting status.
|
||||
if statusID != "" {
|
||||
|
|
@ -317,19 +371,8 @@ func (i *interactionDB) GetInteractionsRequestsForAcct(
|
|||
slices.Reverse(reqIDs)
|
||||
}
|
||||
|
||||
// For each interaction request ID,
|
||||
// select the interaction request.
|
||||
reqs := make([]*gtsmodel.InteractionRequest, 0, len(reqIDs))
|
||||
for _, id := range reqIDs {
|
||||
req, err := i.GetInteractionRequestByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqs = append(reqs, req)
|
||||
}
|
||||
|
||||
return reqs, nil
|
||||
// Load all interaction requests by their IDs.
|
||||
return i.GetInteractionRequestsByIDs(ctx, reqIDs)
|
||||
}
|
||||
|
||||
func (i *interactionDB) IsInteractionRejected(ctx context.Context, interactionURI string) (bool, error) {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
"github.com/uptrace/bun"
|
||||
|
|
@ -85,39 +86,52 @@ func (l *listDB) getList(ctx context.Context, lookup string, dbQuery func(*gtsmo
|
|||
return list, nil
|
||||
}
|
||||
|
||||
func (l *listDB) GetListsForAccountID(ctx context.Context, accountID string) ([]*gtsmodel.List, error) {
|
||||
// Fetch IDs of all lists owned by this account.
|
||||
var listIDs []string
|
||||
if err := l.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("lists"), bun.Ident("list")).
|
||||
Column("list.id").
|
||||
Where("? = ?", bun.Ident("list.account_id"), accountID).
|
||||
Order("list.id DESC").
|
||||
Scan(ctx, &listIDs); err != nil {
|
||||
func (l *listDB) GetListsByAccountID(ctx context.Context, accountID string) ([]*gtsmodel.List, error) {
|
||||
listIDs, err := l.getListIDsByAccountID(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(listIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Return lists by their IDs.
|
||||
return l.GetListsByIDs(ctx, listIDs)
|
||||
}
|
||||
|
||||
func (l *listDB) CountListsForAccountID(ctx context.Context, accountID string) (int, error) {
|
||||
return l.db.
|
||||
NewSelect().
|
||||
Table("lists").
|
||||
Where("? = ?", bun.Ident("account_id"), accountID).
|
||||
Count(ctx)
|
||||
func (l *listDB) CountListsByAccountID(ctx context.Context, accountID string) (int, error) {
|
||||
listIDs, err := l.getListIDsByAccountID(ctx, accountID)
|
||||
return len(listIDs), err
|
||||
}
|
||||
|
||||
func (l *listDB) GetListsContainingFollowID(ctx context.Context, followID string) ([]*gtsmodel.List, error) {
|
||||
listIDs, err := l.getListIDsWithFollowID(ctx, followID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return l.GetListsByIDs(ctx, listIDs)
|
||||
}
|
||||
|
||||
func (l *listDB) GetFollowsInList(ctx context.Context, listID string, page *paging.Page) ([]*gtsmodel.Follow, error) {
|
||||
followIDs, err := l.GetFollowIDsInList(ctx, listID, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return l.state.DB.GetFollowsByIDs(ctx, followIDs)
|
||||
}
|
||||
|
||||
func (l *listDB) GetAccountsInList(ctx context.Context, listID string, page *paging.Page) ([]*gtsmodel.Account, error) {
|
||||
accountIDs, err := l.GetAccountIDsInList(ctx, listID, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return l.state.DB.GetAccountsByIDs(ctx, accountIDs)
|
||||
}
|
||||
|
||||
func (l *listDB) IsAccountInList(ctx context.Context, listID string, accountID string) (bool, error) {
|
||||
accountIDs, err := l.GetAccountIDsInList(ctx, listID, nil)
|
||||
return slices.Contains(accountIDs, accountID), err
|
||||
}
|
||||
|
||||
func (l *listDB) PopulateList(ctx context.Context, list *gtsmodel.List) error {
|
||||
var (
|
||||
err error
|
||||
errs = gtserror.NewMultiError(2)
|
||||
errs gtserror.MultiError
|
||||
)
|
||||
|
||||
if list.Account == nil {
|
||||
|
|
@ -131,22 +145,12 @@ func (l *listDB) PopulateList(ctx context.Context, list *gtsmodel.List) error {
|
|||
}
|
||||
}
|
||||
|
||||
if list.ListEntries == nil {
|
||||
// List entries are not set, fetch from the database.
|
||||
list.ListEntries, err = l.state.DB.GetListEntries(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
list.ID,
|
||||
"", "", "", 0,
|
||||
)
|
||||
if err != nil {
|
||||
errs.Appendf("error populating list entries: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return errs.Combine()
|
||||
}
|
||||
|
||||
func (l *listDB) PutList(ctx context.Context, list *gtsmodel.List) error {
|
||||
// note that inserting list will call OnInvalidateList()
|
||||
// which will handle clearing caches other than List cache.
|
||||
return l.state.Caches.DB.List.Store(list, func() error {
|
||||
_, err := l.db.NewInsert().Model(list).Exec(ctx)
|
||||
return err
|
||||
|
|
@ -160,192 +164,146 @@ func (l *listDB) UpdateList(ctx context.Context, list *gtsmodel.List, columns ..
|
|||
columns = append(columns, "updated_at")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Invalidate all entries for this list ID.
|
||||
l.state.Caches.DB.ListEntry.Invalidate("ListID", list.ID)
|
||||
|
||||
// Invalidate this entire list's timeline.
|
||||
if err := l.state.Timelines.List.RemoveTimeline(ctx, list.ID); err != nil {
|
||||
log.Errorf(ctx, "error invalidating list timeline: %q", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return l.state.Caches.DB.List.Store(list, func() error {
|
||||
// Update list in the database, invalidating main list cache.
|
||||
if err := l.state.Caches.DB.List.Store(list, func() error {
|
||||
_, err := l.db.NewUpdate().
|
||||
Model(list).
|
||||
Where("? = ?", bun.Ident("list.id"), list.ID).
|
||||
Column(columns...).
|
||||
Exec(ctx)
|
||||
return err
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate this entire list's timeline.
|
||||
if err := l.state.Timelines.List.RemoveTimeline(ctx, list.ID); err != nil {
|
||||
log.Errorf(ctx, "error invalidating list timeline: %q", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *listDB) DeleteListByID(ctx context.Context, id string) error {
|
||||
// Load list by ID into cache to ensure we can perform
|
||||
// all necessary cache invalidation hooks on removal.
|
||||
_, err := l.GetListByID(
|
||||
// Don't populate the entry;
|
||||
// we only want the list ID.
|
||||
gtscontext.SetBarebones(ctx),
|
||||
id,
|
||||
)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
// NOTE: even if db.ErrNoEntries is returned, we
|
||||
// still run the below transaction to ensure related
|
||||
// objects are appropriately deleted.
|
||||
return err
|
||||
}
|
||||
// Acquire list owner ID.
|
||||
var accountID string
|
||||
|
||||
defer func() {
|
||||
// Invalidate this list from cache.
|
||||
l.state.Caches.DB.List.Invalidate("ID", id)
|
||||
// Gather follow IDs of all
|
||||
// entries contained in list.
|
||||
var followIDs []string
|
||||
|
||||
// Invalidate this entire list's timeline.
|
||||
if err := l.state.Timelines.List.RemoveTimeline(ctx, id); err != nil {
|
||||
log.Errorf(ctx, "error invalidating list timeline: %q", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return l.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
// Delete all entries attached to list.
|
||||
// Delete all list entries associated with list, and list itself in transaction.
|
||||
if err := l.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
if _, err := tx.NewDelete().
|
||||
Table("list_entries").
|
||||
Where("? = ?", bun.Ident("list_id"), id).
|
||||
Exec(ctx); err != nil {
|
||||
Returning("?", bun.Ident("follow_id")).
|
||||
Exec(ctx, &followIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the list itself.
|
||||
_, err := tx.NewDelete().
|
||||
Table("lists").
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Exec(ctx)
|
||||
Returning("?", bun.Ident("account_id")).
|
||||
Exec(ctx, &accountID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate the main list database cache.
|
||||
l.state.Caches.DB.List.Invalidate("ID", id)
|
||||
|
||||
// Invalidate cache of list IDs owned by account.
|
||||
l.state.Caches.DB.ListIDs.Invalidate("a" + accountID)
|
||||
|
||||
// Invalidate all related entry caches for this list.
|
||||
l.invalidateEntryCaches(ctx, []string{id}, followIDs)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *listDB) getListIDsByAccountID(ctx context.Context, accountID string) ([]string, error) {
|
||||
return l.state.Caches.DB.ListIDs.Load("a"+accountID, func() ([]string, error) {
|
||||
var listIDs []string
|
||||
|
||||
// List IDs not in cache.
|
||||
// Perform the DB query.
|
||||
if _, err := l.db.NewSelect().
|
||||
Table("lists").
|
||||
Column("id").
|
||||
Where("? = ?", bun.Ident("account_id"), accountID).
|
||||
OrderExpr("? DESC", bun.Ident("created_at")).
|
||||
Exec(ctx, &listIDs); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return listIDs, nil
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
LIST ENTRY functions
|
||||
*/
|
||||
func (l *listDB) getListIDsWithFollowID(ctx context.Context, followID string) ([]string, error) {
|
||||
return l.state.Caches.DB.ListIDs.Load("f"+followID, func() ([]string, error) {
|
||||
var listIDs []string
|
||||
|
||||
func (l *listDB) GetListEntryByID(ctx context.Context, id string) (*gtsmodel.ListEntry, error) {
|
||||
return l.getListEntry(
|
||||
ctx,
|
||||
"ID",
|
||||
func(listEntry *gtsmodel.ListEntry) error {
|
||||
return l.db.NewSelect().
|
||||
Model(listEntry).
|
||||
Where("? = ?", bun.Ident("list_entry.id"), id).
|
||||
Scan(ctx)
|
||||
},
|
||||
id,
|
||||
)
|
||||
}
|
||||
|
||||
func (l *listDB) getListEntry(ctx context.Context, lookup string, dbQuery func(*gtsmodel.ListEntry) error, keyParts ...any) (*gtsmodel.ListEntry, error) {
|
||||
listEntry, err := l.state.Caches.DB.ListEntry.LoadOne(lookup, func() (*gtsmodel.ListEntry, error) {
|
||||
var listEntry gtsmodel.ListEntry
|
||||
|
||||
// Not cached! Perform database query.
|
||||
if err := dbQuery(&listEntry); err != nil {
|
||||
// List IDs not in cache.
|
||||
// Perform the DB query.
|
||||
if _, err := l.db.NewSelect().
|
||||
Table("list_entries").
|
||||
Column("list_id").
|
||||
Where("? = ?", bun.Ident("follow_id"), followID).
|
||||
OrderExpr("? DESC", bun.Ident("created_at")).
|
||||
Exec(ctx, &listIDs); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &listEntry, nil
|
||||
}, keyParts...)
|
||||
if err != nil {
|
||||
return nil, err // already processed
|
||||
return listIDs, nil
|
||||
})
|
||||
}
|
||||
|
||||
if gtscontext.Barebones(ctx) {
|
||||
// Only a barebones model was requested.
|
||||
return listEntry, nil
|
||||
}
|
||||
func (l *listDB) GetFollowIDsInList(ctx context.Context, listID string, page *paging.Page) ([]string, error) {
|
||||
return loadPagedIDs(&l.state.Caches.DB.ListedIDs, "f"+listID, page, func() ([]string, error) {
|
||||
var followIDs []string
|
||||
|
||||
// Further populate the list entry fields where applicable.
|
||||
if err := l.state.DB.PopulateListEntry(ctx, listEntry); err != nil {
|
||||
// Follow IDs not in cache.
|
||||
// Perform the DB query.
|
||||
_, err := l.db.NewSelect().
|
||||
Table("list_entries").
|
||||
Column("follow_id").
|
||||
Where("? = ?", bun.Ident("list_id"), listID).
|
||||
OrderExpr("? DESC", bun.Ident("created_at")).
|
||||
Exec(ctx, &followIDs)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return listEntry, nil
|
||||
return followIDs, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (l *listDB) GetListEntries(ctx context.Context,
|
||||
listID string,
|
||||
maxID string,
|
||||
sinceID string,
|
||||
minID string,
|
||||
limit int,
|
||||
) ([]*gtsmodel.ListEntry, error) {
|
||||
// Ensure reasonable
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
func (l *listDB) GetAccountIDsInList(ctx context.Context, listID string, page *paging.Page) ([]string, error) {
|
||||
return loadPagedIDs(&l.state.Caches.DB.ListedIDs, "a"+listID, page, func() ([]string, error) {
|
||||
var accountIDs []string
|
||||
|
||||
// Make educated guess for slice size
|
||||
var (
|
||||
entryIDs = make([]string, 0, limit)
|
||||
frontToBack = true
|
||||
)
|
||||
|
||||
q := l.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("list_entries"), bun.Ident("entry")).
|
||||
// Select only IDs from table
|
||||
Column("entry.id").
|
||||
// Select only entries belonging to listID.
|
||||
Where("? = ?", bun.Ident("entry.list_id"), listID)
|
||||
|
||||
if maxID != "" {
|
||||
// return only entries LOWER (ie., older) than maxID
|
||||
q = q.Where("? < ?", bun.Ident("entry.id"), maxID)
|
||||
}
|
||||
|
||||
if sinceID != "" {
|
||||
// return only entries HIGHER (ie., newer) than sinceID
|
||||
q = q.Where("? > ?", bun.Ident("entry.id"), sinceID)
|
||||
}
|
||||
|
||||
if minID != "" {
|
||||
// return only entries HIGHER (ie., newer) than minID
|
||||
q = q.Where("? > ?", bun.Ident("entry.id"), minID)
|
||||
|
||||
// page up
|
||||
frontToBack = false
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
// limit amount of entries returned
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
|
||||
if frontToBack {
|
||||
// Page down.
|
||||
q = q.Order("entry.id DESC")
|
||||
} else {
|
||||
// Page up.
|
||||
q = q.Order("entry.id ASC")
|
||||
}
|
||||
|
||||
if err := q.Scan(ctx, &entryIDs); err != nil {
|
||||
// Account IDs not in cache.
|
||||
// Perform the DB query.
|
||||
_, err := l.db.NewSelect().
|
||||
Table("follows").
|
||||
Column("follows.target_account_id").
|
||||
Join("INNER JOIN ?", bun.Ident("list_entries")).
|
||||
JoinOn("? = ?", bun.Ident("follows.id"), bun.Ident("list_entries.follow_id")).
|
||||
Where("? = ?", bun.Ident("list_entries.list_id"), listID).
|
||||
OrderExpr("? DESC", bun.Ident("list_entries.id")).
|
||||
Exec(ctx, &accountIDs)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(entryIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// If we're paging up, we still want entries
|
||||
// to be sorted by ID desc, so reverse ids slice.
|
||||
// https://zchee.github.io/golang-wiki/SliceTricks/#reversing
|
||||
if !frontToBack {
|
||||
for l, r := 0, len(entryIDs)-1; l < r; l, r = l+1, r-1 {
|
||||
entryIDs[l], entryIDs[r] = entryIDs[r], entryIDs[l]
|
||||
}
|
||||
}
|
||||
|
||||
// Return list entries by their IDs.
|
||||
return l.GetListEntriesByIDs(ctx, entryIDs)
|
||||
return accountIDs, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (l *listDB) GetListsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.List, error) {
|
||||
|
|
@ -353,15 +311,8 @@ func (l *listDB) GetListsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.L
|
|||
lists, err := l.state.Caches.DB.List.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.List, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached lists.
|
||||
lists := make([]*gtsmodel.List, 0, count)
|
||||
lists := make([]*gtsmodel.List, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) IDs.
|
||||
|
|
@ -402,82 +353,6 @@ func (l *listDB) GetListsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.L
|
|||
return lists, nil
|
||||
}
|
||||
|
||||
func (l *listDB) GetListEntriesByIDs(ctx context.Context, ids []string) ([]*gtsmodel.ListEntry, error) {
|
||||
// Load all entry IDs via cache loader callbacks.
|
||||
entries, err := l.state.Caches.DB.ListEntry.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.ListEntry, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached entries.
|
||||
entries := make([]*gtsmodel.ListEntry, 0, count)
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) IDs.
|
||||
if err := l.db.NewSelect().
|
||||
Model(&entries).
|
||||
Where("? IN (?)", bun.Ident("id"), bun.In(uncached)).
|
||||
Scan(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reorder the entries by their
|
||||
// IDs to ensure in correct order.
|
||||
getID := func(e *gtsmodel.ListEntry) string { return e.ID }
|
||||
util.OrderBy(entries, ids, getID)
|
||||
|
||||
if gtscontext.Barebones(ctx) {
|
||||
// no need to fully populate.
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// Populate all loaded entries, removing those we fail to
|
||||
// populate (removes needing so many nil checks everywhere).
|
||||
entries = slices.DeleteFunc(entries, func(entry *gtsmodel.ListEntry) bool {
|
||||
if err := l.PopulateListEntry(ctx, entry); err != nil {
|
||||
log.Errorf(ctx, "error populating entry %s: %v", entry.ID, err)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (l *listDB) GetListEntriesForFollowID(ctx context.Context, followID string) ([]*gtsmodel.ListEntry, error) {
|
||||
var entryIDs []string
|
||||
|
||||
if err := l.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("list_entries"), bun.Ident("entry")).
|
||||
// Select only IDs from table
|
||||
Column("entry.id").
|
||||
// Select only entries belonging with given followID.
|
||||
Where("? = ?", bun.Ident("entry.follow_id"), followID).
|
||||
Scan(ctx, &entryIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(entryIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Return list entries by their IDs.
|
||||
return l.GetListEntriesByIDs(ctx, entryIDs)
|
||||
}
|
||||
|
||||
func (l *listDB) PopulateListEntry(ctx context.Context, listEntry *gtsmodel.ListEntry) error {
|
||||
var err error
|
||||
|
||||
|
|
@ -496,109 +371,111 @@ func (l *listDB) PopulateListEntry(ctx context.Context, listEntry *gtsmodel.List
|
|||
}
|
||||
|
||||
func (l *listDB) PutListEntries(ctx context.Context, entries []*gtsmodel.ListEntry) error {
|
||||
defer func() {
|
||||
// Collect unique list IDs from the provided entries.
|
||||
// Insert all entries into the database in a single transaction (all or nothing!).
|
||||
if err := l.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
for _, entry := range entries {
|
||||
if _, err := tx.
|
||||
NewInsert().
|
||||
Model(entry).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Collect unique list IDs from the provided list entries.
|
||||
listIDs := util.Collate(entries, func(e *gtsmodel.ListEntry) string {
|
||||
return e.ListID
|
||||
})
|
||||
|
||||
for _, id := range listIDs {
|
||||
// Invalidate the timeline for the list this entry belongs to.
|
||||
if err := l.state.Timelines.List.RemoveTimeline(ctx, id); err != nil {
|
||||
log.Errorf(ctx, "error invalidating list timeline: %q", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Finally, insert each list entry into the database.
|
||||
return l.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
for _, entry := range entries {
|
||||
entry := entry // rescope
|
||||
if err := l.state.Caches.DB.ListEntry.Store(entry, func() error {
|
||||
_, err := tx.
|
||||
NewInsert().
|
||||
Model(entry).
|
||||
Exec(ctx)
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
// Collect unique follow IDs from the provided list entries.
|
||||
followIDs := util.Collate(entries, func(e *gtsmodel.ListEntry) string {
|
||||
return e.FollowID
|
||||
})
|
||||
}
|
||||
|
||||
func (l *listDB) DeleteListEntry(ctx context.Context, id string) error {
|
||||
// Load list entry into cache to ensure we can perform
|
||||
// all necessary cache invalidation hooks on removal.
|
||||
entry, err := l.GetListEntryByID(
|
||||
// Don't populate the entry;
|
||||
// we only want the list ID.
|
||||
gtscontext.SetBarebones(ctx),
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// Already gone.
|
||||
// Invalidate all related list entry caches.
|
||||
l.invalidateEntryCaches(ctx, listIDs, followIDs)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *listDB) DeleteListEntry(ctx context.Context, listID string, followID string) error {
|
||||
// Delete list entry with given
|
||||
// ID, returning its list ID.
|
||||
if _, err := l.db.NewDelete().
|
||||
Table("list_entries").
|
||||
Where("? = ?", bun.Ident("list_id"), listID).
|
||||
Where("? = ?", bun.Ident("follow_id"), followID).
|
||||
Exec(ctx, &listID); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Invalidate this list entry upon delete.
|
||||
l.state.Caches.DB.ListEntry.Invalidate("ID", id)
|
||||
// Invalidate all related list entry caches.
|
||||
l.invalidateEntryCaches(ctx, []string{listID},
|
||||
[]string{followID})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *listDB) DeleteAllListEntriesByFollows(ctx context.Context, followIDs ...string) error {
|
||||
var listIDs []string
|
||||
|
||||
// Check for empty list.
|
||||
if len(followIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete all entries with follow
|
||||
// ID, returning IDs and list IDs.
|
||||
if _, err := l.db.NewDelete().
|
||||
Table("list_entries").
|
||||
Where("? IN (?)", bun.Ident("follow_id"), bun.In(followIDs)).
|
||||
Returning("?", bun.Ident("list_id")).
|
||||
Exec(ctx, &listIDs); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Deduplicate IDs before invalidate.
|
||||
listIDs = util.Deduplicate(listIDs)
|
||||
|
||||
// Invalidate all related list entry caches.
|
||||
l.invalidateEntryCaches(ctx, listIDs, followIDs)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// invalidateEntryCaches will invalidate all related ListEntry caches for given list IDs and follow IDs, including timelines.
|
||||
func (l *listDB) invalidateEntryCaches(ctx context.Context, listIDs, followIDs []string) {
|
||||
var keys []string
|
||||
|
||||
// Generate ListedID keys to invalidate.
|
||||
keys = slices.Grow(keys[:0], 2*len(listIDs))
|
||||
for _, listID := range listIDs {
|
||||
keys = append(keys,
|
||||
"a"+listID,
|
||||
"f"+listID,
|
||||
)
|
||||
|
||||
// Invalidate the timeline for the list this entry belongs to.
|
||||
if err := l.state.Timelines.List.RemoveTimeline(ctx, entry.ListID); err != nil {
|
||||
if err := l.state.Timelines.List.RemoveTimeline(ctx, listID); err != nil {
|
||||
log.Errorf(ctx, "error invalidating list timeline: %q", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Finally delete the list entry.
|
||||
_, err = l.db.NewDelete().
|
||||
Table("list_entries").
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (l *listDB) DeleteListEntriesForFollowID(ctx context.Context, followID string) error {
|
||||
var entryIDs []string
|
||||
// Invalidate ListedID slice cache entries.
|
||||
l.state.Caches.DB.ListedIDs.Invalidate(keys...)
|
||||
|
||||
// Fetch entry IDs for follow ID.
|
||||
if err := l.db.
|
||||
NewSelect().
|
||||
Table("list_entries").
|
||||
Column("id").
|
||||
Where("? = ?", bun.Ident("follow_id"), followID).
|
||||
Order("id DESC").
|
||||
Scan(ctx, &entryIDs); err != nil {
|
||||
return err
|
||||
// Generate ListID keys to invalidate.
|
||||
keys = slices.Grow(keys[:0], len(followIDs))
|
||||
for _, followID := range followIDs {
|
||||
keys = append(keys, "f"+followID)
|
||||
}
|
||||
|
||||
for _, id := range entryIDs {
|
||||
// Delete each separately to trigger cache invalidations.
|
||||
if err := l.DeleteListEntry(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *listDB) ListIncludesAccount(ctx context.Context, listID string, accountID string) (bool, error) {
|
||||
exists, err := l.db.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("list_entries"), bun.Ident("list_entry")).
|
||||
Join(
|
||||
"JOIN ? AS ? ON ? = ?",
|
||||
bun.Ident("follows"), bun.Ident("follow"),
|
||||
bun.Ident("list_entry.follow_id"), bun.Ident("follow.id"),
|
||||
).
|
||||
Where("? = ?", bun.Ident("list_entry.list_id"), listID).
|
||||
Where("? = ?", bun.Ident("follow.target_account_id"), accountID).
|
||||
Exists(ctx)
|
||||
|
||||
return exists, err
|
||||
// Invalidate ListID slice cache entries.
|
||||
l.state.Caches.DB.ListIDs.Invalidate(keys...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import (
|
|||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
)
|
||||
|
||||
|
|
@ -32,7 +31,7 @@ type ListTestSuite struct {
|
|||
BunDBStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *ListTestSuite) testStructs() (*gtsmodel.List, *gtsmodel.Account) {
|
||||
func (suite *ListTestSuite) testStructs() (*gtsmodel.List, []*gtsmodel.ListEntry, *gtsmodel.Account) {
|
||||
testList := >smodel.List{}
|
||||
*testList = *suite.testLists["local_account_1_list_1"]
|
||||
|
||||
|
|
@ -55,12 +54,10 @@ func (suite *ListTestSuite) testStructs() (*gtsmodel.List, *gtsmodel.Account) {
|
|||
}
|
||||
})
|
||||
|
||||
testList.ListEntries = entries
|
||||
|
||||
testAccount := >smodel.Account{}
|
||||
*testAccount = *suite.testAccounts["local_account_1"]
|
||||
|
||||
return testList, testAccount
|
||||
return testList, entries, testAccount
|
||||
}
|
||||
|
||||
func (suite *ListTestSuite) checkList(expected *gtsmodel.List, actual *gtsmodel.List) {
|
||||
|
|
@ -103,7 +100,7 @@ func (suite *ListTestSuite) checkListEntries(expected []*gtsmodel.ListEntry, act
|
|||
}
|
||||
|
||||
func (suite *ListTestSuite) TestGetListByID() {
|
||||
testList, _ := suite.testStructs()
|
||||
testList, _, _ := suite.testStructs()
|
||||
|
||||
dbList, err := suite.db.GetListByID(context.Background(), testList.ID)
|
||||
if err != nil {
|
||||
|
|
@ -111,13 +108,12 @@ func (suite *ListTestSuite) TestGetListByID() {
|
|||
}
|
||||
|
||||
suite.checkList(testList, dbList)
|
||||
suite.checkListEntries(testList.ListEntries, dbList.ListEntries)
|
||||
}
|
||||
|
||||
func (suite *ListTestSuite) TestGetListsForAccountID() {
|
||||
testList, testAccount := suite.testStructs()
|
||||
testList, _, testAccount := suite.testStructs()
|
||||
|
||||
dbLists, err := suite.db.GetListsForAccountID(context.Background(), testAccount.ID)
|
||||
dbLists, err := suite.db.GetListsByAccountID(context.Background(), testAccount.ID)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
|
@ -129,20 +125,9 @@ func (suite *ListTestSuite) TestGetListsForAccountID() {
|
|||
suite.checkList(testList, dbLists[0])
|
||||
}
|
||||
|
||||
func (suite *ListTestSuite) TestGetListEntries() {
|
||||
testList, _ := suite.testStructs()
|
||||
|
||||
dbListEntries, err := suite.db.GetListEntries(context.Background(), testList.ID, "", "", "", 0)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkListEntries(testList.ListEntries, dbListEntries)
|
||||
}
|
||||
|
||||
func (suite *ListTestSuite) TestPutList() {
|
||||
ctx := context.Background()
|
||||
_, testAccount := suite.testStructs()
|
||||
_, _, testAccount := suite.testStructs()
|
||||
|
||||
testList := >smodel.List{
|
||||
ID: "01H0J2PMYM54618VCV8Y8QYAT4",
|
||||
|
|
@ -166,7 +151,7 @@ func (suite *ListTestSuite) TestPutList() {
|
|||
|
||||
func (suite *ListTestSuite) TestUpdateList() {
|
||||
ctx := context.Background()
|
||||
testList, _ := suite.testStructs()
|
||||
testList, _, _ := suite.testStructs()
|
||||
|
||||
// Get List in the cache first.
|
||||
dbList, err := suite.db.GetListByID(ctx, testList.ID)
|
||||
|
|
@ -192,7 +177,7 @@ func (suite *ListTestSuite) TestUpdateList() {
|
|||
|
||||
func (suite *ListTestSuite) TestDeleteList() {
|
||||
ctx := context.Background()
|
||||
testList, _ := suite.testStructs()
|
||||
testList, _, _ := suite.testStructs()
|
||||
|
||||
// Get List in the cache first.
|
||||
if _, err := suite.db.GetListByID(ctx, testList.ID); err != nil {
|
||||
|
|
@ -209,18 +194,19 @@ func (suite *ListTestSuite) TestDeleteList() {
|
|||
_, err := suite.db.GetListByID(ctx, testList.ID)
|
||||
suite.ErrorIs(err, db.ErrNoEntries)
|
||||
|
||||
// All entries belonging to this
|
||||
// list should now be deleted.
|
||||
listEntries, err := suite.db.GetListEntries(ctx, testList.ID, "", "", "", 0)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.Empty(listEntries)
|
||||
// All accounts / follows attached to this
|
||||
// list should now be return empty values.
|
||||
listAccounts, err1 := suite.db.GetAccountsInList(ctx, testList.ID, nil)
|
||||
listFollows, err2 := suite.db.GetFollowsInList(ctx, testList.ID, nil)
|
||||
suite.NoError(err1)
|
||||
suite.NoError(err2)
|
||||
suite.Empty(listAccounts)
|
||||
suite.Empty(listFollows)
|
||||
}
|
||||
|
||||
func (suite *ListTestSuite) TestPutListEntries() {
|
||||
ctx := context.Background()
|
||||
testList, _ := suite.testStructs()
|
||||
testList, testEntries, _ := suite.testStructs()
|
||||
|
||||
listEntries := []*gtsmodel.ListEntry{
|
||||
{
|
||||
|
|
@ -244,91 +230,58 @@ func (suite *ListTestSuite) TestPutListEntries() {
|
|||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Add these entries to the test list, sort it again
|
||||
// to reflect what we'd expect to get from the db.
|
||||
testList.ListEntries = append(testList.ListEntries, listEntries...)
|
||||
slices.SortFunc(testList.ListEntries, func(a, b *gtsmodel.ListEntry) int {
|
||||
const k = -1
|
||||
switch {
|
||||
case a.ID > b.ID:
|
||||
return +k
|
||||
case a.ID < b.ID:
|
||||
return -k
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
|
||||
// Now get all list entries from the db.
|
||||
// Use barebones for this because the ones
|
||||
// we just added will fail if we try to get
|
||||
// the nonexistent follows.
|
||||
dbListEntries, err := suite.db.GetListEntries(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
testList.ID,
|
||||
"", "", "", 0)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.checkListEntries(testList.ListEntries, dbListEntries)
|
||||
// Get all follows stored under this list ID, to ensure
|
||||
// the newly added list entry follows are among these.
|
||||
followIDs, err := suite.db.GetFollowIDsInList(ctx, testList.ID, nil)
|
||||
suite.NoError(err)
|
||||
suite.Len(followIDs, len(testEntries)+len(listEntries))
|
||||
suite.Contains(followIDs, "01H0MKNFRFZS8R9WV6DBX31Y03")
|
||||
suite.Contains(followIDs, "01H0MKP6RR8VEHN3GVWFBP2H30")
|
||||
suite.Contains(followIDs, "01H0MKQ0KA29C6NFJ27GTZD16J")
|
||||
}
|
||||
|
||||
func (suite *ListTestSuite) TestDeleteListEntry() {
|
||||
ctx := context.Background()
|
||||
testList, _ := suite.testStructs()
|
||||
|
||||
// Get List in the cache first.
|
||||
if _, err := suite.db.GetListByID(ctx, testList.ID); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
testList, testEntries, _ := suite.testStructs()
|
||||
|
||||
// Delete the first entry.
|
||||
if err := suite.db.DeleteListEntry(ctx, testList.ListEntries[0].ID); err != nil {
|
||||
if err := suite.db.DeleteListEntry(ctx,
|
||||
testEntries[0].ListID,
|
||||
testEntries[0].FollowID,
|
||||
); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Get list from the db again.
|
||||
dbList, err := suite.db.GetListByID(ctx, testList.ID)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
// Get all follows stored under this list ID, to ensure
|
||||
// the newly removed list entry follow is now missing.
|
||||
followIDs, err := suite.db.GetFollowIDsInList(ctx, testList.ID, nil)
|
||||
suite.NoError(err)
|
||||
suite.Len(followIDs, len(testEntries)-1)
|
||||
suite.NotContains(followIDs, testEntries[0].FollowID)
|
||||
}
|
||||
|
||||
// Bodge the testlist as though
|
||||
// we'd removed the first entry.
|
||||
testList.ListEntries = testList.ListEntries[1:]
|
||||
suite.checkList(testList, dbList)
|
||||
}
|
||||
|
||||
func (suite *ListTestSuite) TestDeleteListEntriesForFollowID() {
|
||||
func (suite *ListTestSuite) TestDeleteAllListEntriesByFollows() {
|
||||
ctx := context.Background()
|
||||
testList, _ := suite.testStructs()
|
||||
|
||||
// Get List in the cache first.
|
||||
if _, err := suite.db.GetListByID(ctx, testList.ID); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
testList, testEntries, _ := suite.testStructs()
|
||||
|
||||
// Delete the first entry.
|
||||
if err := suite.db.DeleteListEntriesForFollowID(ctx, testList.ListEntries[0].FollowID); err != nil {
|
||||
if err := suite.db.DeleteAllListEntriesByFollows(ctx,
|
||||
testEntries[0].FollowID,
|
||||
); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Get list from the db again.
|
||||
dbList, err := suite.db.GetListByID(ctx, testList.ID)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// Bodge the testlist as though
|
||||
// we'd removed the first entry.
|
||||
testList.ListEntries = testList.ListEntries[1:]
|
||||
suite.checkList(testList, dbList)
|
||||
// Get all follows stored under this list ID, to ensure
|
||||
// the newly removed list entry follow is now missing.
|
||||
followIDs, err := suite.db.GetFollowIDsInList(ctx, testList.ID, nil)
|
||||
suite.NoError(err)
|
||||
suite.Len(followIDs, len(testEntries)-1)
|
||||
suite.NotContains(followIDs, testEntries[0].FollowID)
|
||||
}
|
||||
|
||||
func (suite *ListTestSuite) TestListIncludesAccount() {
|
||||
ctx := context.Background()
|
||||
testList, _ := suite.testStructs()
|
||||
testList, _, _ := suite.testStructs()
|
||||
|
||||
for accountID, expected := range map[string]bool{
|
||||
suite.testAccounts["admin_account"].ID: true,
|
||||
|
|
@ -336,7 +289,7 @@ func (suite *ListTestSuite) TestListIncludesAccount() {
|
|||
suite.testAccounts["local_account_2"].ID: true,
|
||||
"01H7074GEZJ56J5C86PFB0V2CT": false,
|
||||
} {
|
||||
includes, err := suite.db.ListIncludesAccount(ctx, testList.ID, accountID)
|
||||
includes, err := suite.db.IsAccountInList(ctx, testList.ID, accountID)
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
|
|
@ -57,15 +56,8 @@ func (m *mediaDB) GetAttachmentsByIDs(ctx context.Context, ids []string) ([]*gts
|
|||
media, err := m.state.Caches.DB.Media.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.MediaAttachment, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached media attachments.
|
||||
media := make([]*gtsmodel.MediaAttachment, 0, count)
|
||||
media := make([]*gtsmodel.MediaAttachment, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) IDs.
|
||||
|
|
@ -129,30 +121,38 @@ func (m *mediaDB) UpdateAttachment(ctx context.Context, media *gtsmodel.MediaAtt
|
|||
}
|
||||
|
||||
func (m *mediaDB) DeleteAttachment(ctx context.Context, id string) error {
|
||||
// Load media into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
media, err := m.GetAttachmentByID(gtscontext.SetBarebones(ctx), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// not an issue.
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted gtsmodel.MediaAttachment
|
||||
deleted.ID = id
|
||||
|
||||
// Delete media attachment and update related models in new transaction.
|
||||
err := m.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
|
||||
// Initially, delete the media model,
|
||||
// returning the required fields we need.
|
||||
if _, err := tx.NewDelete().
|
||||
Model(&deleted).
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Returning("?, ?, ?, ?",
|
||||
bun.Ident("account_id"),
|
||||
bun.Ident("status_id"),
|
||||
bun.Ident("avatar"),
|
||||
bun.Ident("header"),
|
||||
).
|
||||
Exec(ctx); err != nil {
|
||||
return gtserror.Newf("error deleting media: %w", err)
|
||||
}
|
||||
|
||||
// On return, ensure that media with ID is invalidated.
|
||||
defer m.state.Caches.DB.Media.Invalidate("ID", id)
|
||||
|
||||
// Delete media attachment in new transaction.
|
||||
err = m.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
if media.AccountID != "" {
|
||||
// If media was attached to account,
|
||||
// we need to remove link from account.
|
||||
if deleted.AccountID != "" {
|
||||
var account gtsmodel.Account
|
||||
|
||||
// Get related account model.
|
||||
if _, err := tx.NewSelect().
|
||||
Model(&account).
|
||||
Where("? = ?", bun.Ident("id"), media.AccountID).
|
||||
Where("? = ?", bun.Ident("id"), deleted.AccountID).
|
||||
Exec(ctx); err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return gtserror.Newf("error selecting account: %w", err)
|
||||
}
|
||||
|
|
@ -160,11 +160,11 @@ func (m *mediaDB) DeleteAttachment(ctx context.Context, id string) error {
|
|||
var set func(*bun.UpdateQuery) *bun.UpdateQuery
|
||||
|
||||
switch {
|
||||
case *media.Avatar && account.AvatarMediaAttachmentID == id:
|
||||
case *deleted.Avatar && account.AvatarMediaAttachmentID == id:
|
||||
set = func(q *bun.UpdateQuery) *bun.UpdateQuery {
|
||||
return q.Set("? = NULL", bun.Ident("avatar_media_attachment_id"))
|
||||
}
|
||||
case *media.Header && account.HeaderMediaAttachmentID == id:
|
||||
case *deleted.Header && account.HeaderMediaAttachmentID == id:
|
||||
set = func(q *bun.UpdateQuery) *bun.UpdateQuery {
|
||||
return q.Set("? = NULL", bun.Ident("header_media_attachment_id"))
|
||||
}
|
||||
|
|
@ -183,13 +183,15 @@ func (m *mediaDB) DeleteAttachment(ctx context.Context, id string) error {
|
|||
}
|
||||
}
|
||||
|
||||
if media.StatusID != "" {
|
||||
// If media was attached to a status,
|
||||
// we need to remove link from status.
|
||||
if deleted.StatusID != "" {
|
||||
var status gtsmodel.Status
|
||||
|
||||
// Get related status model.
|
||||
if _, err := tx.NewSelect().
|
||||
Model(&status).
|
||||
Where("? = ?", bun.Ident("id"), media.StatusID).
|
||||
Where("? = ?", bun.Ident("id"), deleted.StatusID).
|
||||
Exec(ctx); err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return gtserror.Newf("error selecting status: %w", err)
|
||||
}
|
||||
|
|
@ -213,17 +215,14 @@ func (m *mediaDB) DeleteAttachment(ctx context.Context, id string) error {
|
|||
}
|
||||
}
|
||||
|
||||
// Finally delete this media.
|
||||
if _, err := tx.NewDelete().
|
||||
Table("media_attachments").
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Exec(ctx); err != nil {
|
||||
return gtserror.Newf("error deleting media: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// Invalidate cached media with ID, manually
|
||||
// call invalidate hook in case not in cache.
|
||||
m.state.Caches.DB.Media.Invalidate("ID", id)
|
||||
m.state.Caches.OnInvalidateMedia(&deleted)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,15 +69,8 @@ func (m *mentionDB) GetMentions(ctx context.Context, ids []string) ([]*gtsmodel.
|
|||
mentions, err := m.state.Caches.DB.Mention.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.Mention, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached mentions.
|
||||
mentions := make([]*gtsmodel.Mention, 0, count)
|
||||
mentions := make([]*gtsmodel.Mention, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) IDs.
|
||||
|
|
@ -166,24 +159,18 @@ func (m *mentionDB) PutMention(ctx context.Context, mention *gtsmodel.Mention) e
|
|||
}
|
||||
|
||||
func (m *mentionDB) DeleteMentionByID(ctx context.Context, id string) error {
|
||||
defer m.state.Caches.DB.Mention.Invalidate("ID", id)
|
||||
|
||||
// Load mention into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
_, err := m.GetMention(gtscontext.SetBarebones(ctx), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// not an issue.
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Finally delete mention from DB.
|
||||
_, err = m.db.NewDelete().
|
||||
// Delete mention with given ID,
|
||||
// returning the deleted models.
|
||||
if _, err := m.db.NewDelete().
|
||||
Table("mentions").
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Exec(ctx)
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate the cached mention with ID.
|
||||
m.state.Caches.DB.Mention.Invalidate("ID", id)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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 {
|
||||
for idx, col := range map[string]string{
|
||||
"interaction_requests_accepted_at_idx": "accepted_at",
|
||||
"interaction_requests_rejected_at_idx": "rejected_at",
|
||||
} {
|
||||
if _, err := tx.
|
||||
NewCreateIndex().
|
||||
Table("interaction_requests").
|
||||
Index(idx).
|
||||
Column(col).
|
||||
IfNotExists().
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -234,13 +234,17 @@ func (m *moveDB) UpdateMove(ctx context.Context, move *gtsmodel.Move, columns ..
|
|||
}
|
||||
|
||||
func (m *moveDB) DeleteMoveByID(ctx context.Context, id string) error {
|
||||
defer m.state.Caches.DB.Move.Invalidate("ID", id)
|
||||
|
||||
_, err := m.db.
|
||||
NewDelete().
|
||||
// Delete move with given ID.
|
||||
if _, err := m.db.NewDelete().
|
||||
TableExpr("? AS ?", bun.Ident("moves"), bun.Ident("move")).
|
||||
Where("? = ?", bun.Ident("move.id"), id).
|
||||
Exec(ctx)
|
||||
|
||||
return err
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Invalidate the cached move model with ID.
|
||||
m.state.Caches.DB.Move.Invalidate("ID", id)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import (
|
|||
"errors"
|
||||
"slices"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
|
|
@ -107,15 +108,8 @@ func (n *notificationDB) GetNotificationsByIDs(ctx context.Context, ids []string
|
|||
notifs, err := n.state.Caches.DB.Notification.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.Notification, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached notifications.
|
||||
notifs := make([]*gtsmodel.Notification, 0, count)
|
||||
notifs := make([]*gtsmodel.Notification, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) IDs.
|
||||
|
|
@ -299,7 +293,8 @@ func (n *notificationDB) DeleteNotificationByID(ctx context.Context, id string)
|
|||
NewDelete().
|
||||
Table("notifications").
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Exec(ctx); err != nil {
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -310,7 +305,7 @@ func (n *notificationDB) DeleteNotificationByID(ctx context.Context, id string)
|
|||
|
||||
func (n *notificationDB) DeleteNotifications(ctx context.Context, types []string, targetAccountID string, originAccountID string) error {
|
||||
if targetAccountID == "" && originAccountID == "" {
|
||||
return errors.New("DeleteNotifications: one of targetAccountID or originAccountID must be set")
|
||||
return gtserror.New("one of targetAccountID or originAccountID must be set")
|
||||
}
|
||||
|
||||
q := n.db.
|
||||
|
|
|
|||
|
|
@ -177,17 +177,36 @@ func (p *pollDB) UpdatePoll(ctx context.Context, poll *gtsmodel.Poll, cols ...st
|
|||
}
|
||||
|
||||
func (p *pollDB) DeletePollByID(ctx context.Context, id string) error {
|
||||
// Delete poll by ID from database.
|
||||
if _, err := p.db.NewDelete().
|
||||
// Delete poll vote with ID, and its associated votes from the database.
|
||||
if err := p.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
|
||||
// Delete poll from database.
|
||||
if _, err := tx.NewDelete().
|
||||
Table("polls").
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate poll by ID from cache.
|
||||
// Delete the poll votes.
|
||||
_, err := tx.NewDelete().
|
||||
Table("poll_votes").
|
||||
Where("? = ?", bun.Ident("poll_id"), id).
|
||||
Exec(ctx)
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Wrap provided ID in a poll
|
||||
// model for calling cache hook.
|
||||
var deleted gtsmodel.Poll
|
||||
deleted.ID = id
|
||||
|
||||
// Invalidate cached poll with ID, manually
|
||||
// call invalidate hook in case not cached.
|
||||
p.state.Caches.DB.Poll.Invalidate("ID", id)
|
||||
p.state.Caches.DB.PollVoteIDs.Invalidate(id)
|
||||
p.state.Caches.OnInvalidatePoll(&deleted)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -274,15 +293,8 @@ func (p *pollDB) GetPollVotes(ctx context.Context, pollID string) ([]*gtsmodel.P
|
|||
votes, err := p.state.Caches.DB.PollVote.LoadIDs("ID",
|
||||
voteIDs,
|
||||
func(uncached []string) ([]*gtsmodel.PollVote, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached votes.
|
||||
votes := make([]*gtsmodel.PollVote, 0, count)
|
||||
votes := make([]*gtsmodel.PollVote, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) IDs.
|
||||
|
|
@ -391,148 +403,44 @@ func (p *pollDB) PutPollVote(ctx context.Context, vote *gtsmodel.PollVote) error
|
|||
})
|
||||
}
|
||||
|
||||
func (p *pollDB) DeletePollVotes(ctx context.Context, pollID string) error {
|
||||
err := p.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
// Delete all votes in poll.
|
||||
res, err := tx.NewDelete().
|
||||
Table("poll_votes").
|
||||
Where("? = ?", bun.Ident("poll_id"), pollID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
// irrecoverable
|
||||
return err
|
||||
}
|
||||
|
||||
ra, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
// irrecoverable
|
||||
return err
|
||||
}
|
||||
|
||||
if ra == 0 {
|
||||
// No poll votes deleted,
|
||||
// nothing to update.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Select current poll counts from DB,
|
||||
// taking minimal columns needed to
|
||||
// increment/decrement votes.
|
||||
var poll gtsmodel.Poll
|
||||
switch err := tx.NewSelect().
|
||||
Model(&poll).
|
||||
Column("options", "votes", "voters").
|
||||
Where("? = ?", bun.Ident("id"), pollID).
|
||||
Scan(ctx); {
|
||||
|
||||
case err == nil:
|
||||
// no issue.
|
||||
|
||||
case errors.Is(err, db.ErrNoEntries):
|
||||
// no votes found,
|
||||
// return here.
|
||||
return nil
|
||||
|
||||
default:
|
||||
// irrecoverable.
|
||||
return err
|
||||
}
|
||||
|
||||
// Zero all counts.
|
||||
poll.ResetVotes()
|
||||
|
||||
// Finally, update the poll entry.
|
||||
_, err = tx.NewUpdate().
|
||||
Model(&poll).
|
||||
Column("votes", "voters").
|
||||
Where("? = ?", bun.Ident("id"), pollID).
|
||||
Exec(ctx)
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate poll vote and poll entry from caches.
|
||||
p.state.Caches.DB.Poll.Invalidate("ID", pollID)
|
||||
p.state.Caches.DB.PollVote.Invalidate("PollID", pollID)
|
||||
p.state.Caches.DB.PollVoteIDs.Invalidate(pollID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *pollDB) DeletePollVoteBy(ctx context.Context, pollID string, accountID string) error {
|
||||
err := p.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
// Slice should only ever be of length
|
||||
// 0 or 1; it's a slice of slices only
|
||||
// because we can't LIMIT deletes to 1.
|
||||
var choicesSlice [][]int
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted gtsmodel.PollVote
|
||||
deleted.AccountID = accountID
|
||||
deleted.PollID = pollID
|
||||
|
||||
// Delete the poll vote with given poll and account IDs, and update vote counts.
|
||||
if err := p.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
|
||||
// Delete vote in poll by account,
|
||||
// returning the ID + choices of the vote.
|
||||
if err := tx.NewDelete().
|
||||
Table("poll_votes").
|
||||
// returning deleted model info.
|
||||
switch _, err := tx.NewDelete().
|
||||
Model(&deleted).
|
||||
Where("? = ?", bun.Ident("poll_id"), pollID).
|
||||
Where("? = ?", bun.Ident("account_id"), accountID).
|
||||
Returning("?", bun.Ident("choices")).
|
||||
Scan(ctx, &choicesSlice); err != nil {
|
||||
// irrecoverable.
|
||||
return err
|
||||
}
|
||||
|
||||
if len(choicesSlice) != 1 {
|
||||
// No poll votes by this
|
||||
// acct on this poll.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract the *actual* choices.
|
||||
choices := choicesSlice[0]
|
||||
|
||||
// Select current poll counts from DB,
|
||||
// taking minimal columns needed to
|
||||
// increment/decrement votes.
|
||||
var poll gtsmodel.Poll
|
||||
switch err := tx.NewSelect().
|
||||
Model(&poll).
|
||||
Column("options", "votes", "voters").
|
||||
Where("? = ?", bun.Ident("id"), pollID).
|
||||
Scan(ctx); {
|
||||
Exec(ctx); {
|
||||
|
||||
case err == nil:
|
||||
// no issue.
|
||||
|
||||
// no issue
|
||||
case errors.Is(err, db.ErrNoEntries):
|
||||
// no poll found,
|
||||
// return here.
|
||||
return nil
|
||||
|
||||
default:
|
||||
// irrecoverable.
|
||||
return err
|
||||
}
|
||||
|
||||
// Decrement votes for choices.
|
||||
poll.DecrementVotes(choices)
|
||||
|
||||
// Finally, update the poll entry.
|
||||
_, err := tx.NewUpdate().
|
||||
Model(&poll).
|
||||
Column("votes", "voters").
|
||||
Where("? = ?", bun.Ident("id"), pollID).
|
||||
Exec(ctx)
|
||||
// Update the votes for this deleted poll.
|
||||
err := updatePollCounts(ctx, tx, &deleted)
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate poll vote and poll entry from caches.
|
||||
p.state.Caches.DB.Poll.Invalidate("ID", pollID)
|
||||
// Invalidate the poll vote cache by given poll + account IDs, also
|
||||
// manually call invalidation hook in case not actually stored in cache.
|
||||
p.state.Caches.DB.PollVote.Invalidate("PollID,AccountID", pollID, accountID)
|
||||
p.state.Caches.DB.PollVoteIDs.Invalidate(pollID)
|
||||
p.state.Caches.OnInvalidatePollVote(&deleted)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -562,6 +470,48 @@ func (p *pollDB) DeletePollVotesByAccountID(ctx context.Context, accountID strin
|
|||
return nil
|
||||
}
|
||||
|
||||
// updatePollCounts updates the vote counts on a poll for the given deleted PollVote model.
|
||||
func updatePollCounts(ctx context.Context, tx bun.Tx, deleted *gtsmodel.PollVote) error {
|
||||
|
||||
// Select current poll counts from DB,
|
||||
// taking minimal columns needed to
|
||||
// increment/decrement votes.
|
||||
var poll gtsmodel.Poll
|
||||
switch err := tx.NewSelect().
|
||||
Model(&poll).
|
||||
Column("options", "votes", "voters").
|
||||
Where("? = ?", bun.Ident("id"), deleted.PollID).
|
||||
Scan(ctx); {
|
||||
|
||||
case err == nil:
|
||||
// no issue.
|
||||
|
||||
case errors.Is(err, db.ErrNoEntries):
|
||||
// no poll found,
|
||||
// return here.
|
||||
return nil
|
||||
|
||||
default:
|
||||
// irrecoverable.
|
||||
return err
|
||||
}
|
||||
|
||||
// Decrement votes for these choices.
|
||||
poll.DecrementVotes(deleted.Choices)
|
||||
|
||||
// Finally, update the poll entry.
|
||||
if _, err := tx.NewUpdate().
|
||||
Model(&poll).
|
||||
Column("votes", "voters").
|
||||
Where("? = ?", bun.Ident("id"), deleted.PollID).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// newSelectPollVotes returns a new select query for all rows in the poll_votes table with poll_id = pollID.
|
||||
func newSelectPollVotes(db *bun.DB, pollID string) *bun.SelectQuery {
|
||||
return db.NewSelect().
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import (
|
|||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
|
|
@ -286,41 +285,6 @@ func (suite *PollTestSuite) TestDeletePoll() {
|
|||
}
|
||||
}
|
||||
|
||||
func (suite *PollTestSuite) TestDeletePollVotes() {
|
||||
// Create a new context for this test.
|
||||
ctx, cncl := context.WithCancel(context.Background())
|
||||
defer cncl()
|
||||
|
||||
for _, poll := range suite.testPolls {
|
||||
// Delete votes associated with poll from database.
|
||||
err := suite.db.DeletePollVotes(ctx, poll.ID)
|
||||
suite.NoError(err)
|
||||
|
||||
// Fetch latest version of poll from database.
|
||||
poll, err = suite.db.GetPollByID(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
poll.ID,
|
||||
)
|
||||
suite.NoError(err)
|
||||
|
||||
// Check that poll counts are all zero.
|
||||
suite.Equal(*poll.Voters, 0)
|
||||
suite.Equal(make([]int, len(poll.Options)), poll.Votes)
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *PollTestSuite) TestDeletePollVotesNoPoll() {
|
||||
// Create a new context for this test.
|
||||
ctx, cncl := context.WithCancel(context.Background())
|
||||
defer cncl()
|
||||
|
||||
// Try to delete votes of nonexistent poll.
|
||||
nonPollID := "01HF6V4XWTSZWJ80JNPPDTD4DB"
|
||||
|
||||
err := suite.db.DeletePollVotes(ctx, nonPollID)
|
||||
suite.NoError(err)
|
||||
}
|
||||
|
||||
func (suite *PollTestSuite) TestDeletePollVotesBy() {
|
||||
ctx, cncl := context.WithCancel(context.Background())
|
||||
defer cncl()
|
||||
|
|
|
|||
|
|
@ -105,15 +105,8 @@ func (r *relationshipDB) GetBlocksByIDs(ctx context.Context, ids []string) ([]*g
|
|||
blocks, err := r.state.Caches.DB.Block.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.Block, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached blocks.
|
||||
blocks := make([]*gtsmodel.Block, 0, count)
|
||||
blocks := make([]*gtsmodel.Block, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) IDs.
|
||||
|
|
@ -222,94 +215,93 @@ func (r *relationshipDB) PutBlock(ctx context.Context, block *gtsmodel.Block) er
|
|||
}
|
||||
|
||||
func (r *relationshipDB) DeleteBlockByID(ctx context.Context, id string) error {
|
||||
// Load block into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
_, err := r.GetBlockByID(gtscontext.SetBarebones(ctx), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// not an issue.
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted gtsmodel.Block
|
||||
|
||||
// Drop this now-cached block on return after delete.
|
||||
defer r.state.Caches.DB.Block.Invalidate("ID", id)
|
||||
|
||||
// Finally delete block from DB.
|
||||
_, err = r.db.NewDelete().
|
||||
Table("blocks").
|
||||
// Delete block with given ID,
|
||||
// returning the deleted models.
|
||||
if _, err := r.db.NewDelete().
|
||||
Model(&deleted).
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Exec(ctx)
|
||||
Returning("?, ?",
|
||||
bun.Ident("account_id"),
|
||||
bun.Ident("target_account_id"),
|
||||
).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate cached block with ID, manually
|
||||
// call invalidate hook in case not cached.
|
||||
r.state.Caches.DB.Block.Invalidate("ID", id)
|
||||
r.state.Caches.OnInvalidateBlock(&deleted)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *relationshipDB) DeleteBlockByURI(ctx context.Context, uri string) error {
|
||||
// Load block into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
_, err := r.GetBlockByURI(gtscontext.SetBarebones(ctx), uri)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// not an issue.
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted gtsmodel.Block
|
||||
|
||||
// Drop this now-cached block on return after delete.
|
||||
defer r.state.Caches.DB.Block.Invalidate("URI", uri)
|
||||
|
||||
// Finally delete block from DB.
|
||||
_, err = r.db.NewDelete().
|
||||
Table("blocks").
|
||||
// Delete block with given URI,
|
||||
// returning the deleted models.
|
||||
if _, err := r.db.NewDelete().
|
||||
Model(&deleted).
|
||||
Where("? = ?", bun.Ident("uri"), uri).
|
||||
Exec(ctx)
|
||||
Returning("?, ?",
|
||||
bun.Ident("account_id"),
|
||||
bun.Ident("target_account_id"),
|
||||
).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate cached block with URI, manually
|
||||
// call invalidate hook in case not cached.
|
||||
r.state.Caches.DB.Block.Invalidate("URI", uri)
|
||||
r.state.Caches.OnInvalidateBlock(&deleted)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *relationshipDB) DeleteAccountBlocks(ctx context.Context, accountID string) error {
|
||||
var blockIDs []string
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted []*gtsmodel.Block
|
||||
|
||||
// Get full list of IDs.
|
||||
if err := r.db.NewSelect().
|
||||
Column("id").
|
||||
Table("blocks").
|
||||
// Delete all blocks either from
|
||||
// account, or targeting account,
|
||||
// returning the deleted models.
|
||||
if _, err := r.db.NewDelete().
|
||||
Model(&deleted).
|
||||
WhereOr("? = ? OR ? = ?",
|
||||
bun.Ident("account_id"),
|
||||
accountID,
|
||||
bun.Ident("target_account_id"),
|
||||
accountID,
|
||||
).
|
||||
Scan(ctx, &blockIDs); err != nil {
|
||||
Returning("?, ?",
|
||||
bun.Ident("account_id"),
|
||||
bun.Ident("target_account_id"),
|
||||
).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(blockIDs) == 0 {
|
||||
// Nothing
|
||||
// to delete.
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Invalidate all account's incoming / outoing blocks on return.
|
||||
// Invalidate all account's incoming / outoing blocks.
|
||||
r.state.Caches.DB.Block.Invalidate("AccountID", accountID)
|
||||
r.state.Caches.DB.Block.Invalidate("TargetAccountID", accountID)
|
||||
}()
|
||||
|
||||
// Load all blocks into cache, this *really* isn't great
|
||||
// but it is the only way we can ensure we invalidate all
|
||||
// related caches correctly (e.g. visibility).
|
||||
_, err := r.GetAccountBlocks(ctx, accountID, nil)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
// In case not all blocks were in
|
||||
// cache, manually call invalidate hooks.
|
||||
for _, block := range deleted {
|
||||
r.state.Caches.OnInvalidateBlock(block)
|
||||
}
|
||||
|
||||
// Finally delete all from DB.
|
||||
_, err = r.db.NewDelete().
|
||||
Table("blocks").
|
||||
Where("? IN (?)", bun.Ident("id"), bun.In(blockIDs)).
|
||||
Exec(ctx)
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ package bundb
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
|
|
@ -82,15 +81,8 @@ func (r *relationshipDB) GetFollowsByIDs(ctx context.Context, ids []string) ([]*
|
|||
follows, err := r.state.Caches.DB.Follow.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.Follow, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached follows.
|
||||
follows := make([]*gtsmodel.Follow, 0, count)
|
||||
follows := make([]*gtsmodel.Follow, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) IDs.
|
||||
|
|
@ -252,139 +244,155 @@ func (r *relationshipDB) UpdateFollow(ctx context.Context, follow *gtsmodel.Foll
|
|||
})
|
||||
}
|
||||
|
||||
func (r *relationshipDB) deleteFollow(ctx context.Context, id string) error {
|
||||
// Delete the follow itself using the given ID.
|
||||
func (r *relationshipDB) DeleteFollow(
|
||||
ctx context.Context,
|
||||
sourceAccountID string,
|
||||
targetAccountID string,
|
||||
) error {
|
||||
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted gtsmodel.Follow
|
||||
deleted.AccountID = sourceAccountID
|
||||
deleted.TargetAccountID = targetAccountID
|
||||
|
||||
// Delete follow from origin
|
||||
// account, to targeting account,
|
||||
// returning the deleted models.
|
||||
if _, err := r.db.NewDelete().
|
||||
Table("follows").
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Exec(ctx); err != nil {
|
||||
Model(&deleted).
|
||||
Where("? = ?", bun.Ident("account_id"), sourceAccountID).
|
||||
Where("? = ?", bun.Ident("target_account_id"), targetAccountID).
|
||||
Returning("?", bun.Ident("id")).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete every list entry that used this followID.
|
||||
if err := r.state.DB.DeleteListEntriesForFollowID(ctx, id); err != nil {
|
||||
return fmt.Errorf("deleteFollow: error deleting list entries: %w", err)
|
||||
// Invalidate cached follow with source / target account IDs,
|
||||
// manually calling invalidate hook in case it isn't cached.
|
||||
r.state.Caches.DB.Follow.Invalidate("AccountID,TargetAccountID",
|
||||
sourceAccountID, targetAccountID)
|
||||
r.state.Caches.OnInvalidateFollow(&deleted)
|
||||
|
||||
// Delete every list entry that was created targetting this follow ID.
|
||||
if err := r.state.DB.DeleteAllListEntriesByFollows(ctx, deleted.ID); err != nil {
|
||||
return gtserror.Newf("error deleting list entries: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *relationshipDB) DeleteFollow(ctx context.Context, sourceAccountID string, targetAccountID string) error {
|
||||
// Load follow into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
follow, err := r.GetFollow(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
sourceAccountID,
|
||||
targetAccountID,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// Already gone.
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop this now-cached follow on return after delete.
|
||||
defer r.state.Caches.DB.Follow.Invalidate("AccountID,TargetAccountID", sourceAccountID, targetAccountID)
|
||||
|
||||
// Finally delete follow from DB.
|
||||
return r.deleteFollow(ctx, follow.ID)
|
||||
}
|
||||
|
||||
func (r *relationshipDB) DeleteFollowByID(ctx context.Context, id string) error {
|
||||
// Load follow into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
follow, err := r.GetFollowByID(gtscontext.SetBarebones(ctx), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// Already gone.
|
||||
return nil
|
||||
}
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted gtsmodel.Follow
|
||||
deleted.ID = id
|
||||
|
||||
// Delete follow with given ID,
|
||||
// returning the deleted models.
|
||||
if _, err := r.db.NewDelete().
|
||||
Model(&deleted).
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Returning("?, ?",
|
||||
bun.Ident("account_id"),
|
||||
bun.Ident("target_account_id"),
|
||||
).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop this now-cached follow on return after delete.
|
||||
defer r.state.Caches.DB.Follow.Invalidate("ID", id)
|
||||
// Invalidate cached follow with ID, manually
|
||||
// call invalidate hook in case not cached.
|
||||
r.state.Caches.DB.Follow.Invalidate("ID", id)
|
||||
r.state.Caches.OnInvalidateFollow(&deleted)
|
||||
|
||||
// Finally delete follow from DB.
|
||||
return r.deleteFollow(ctx, follow.ID)
|
||||
// Delete every list entry that was created targetting this follow ID.
|
||||
if err := r.state.DB.DeleteAllListEntriesByFollows(ctx, id); err != nil {
|
||||
return gtserror.Newf("error deleting list entries: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *relationshipDB) DeleteFollowByURI(ctx context.Context, uri string) error {
|
||||
// Load follow into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
follow, err := r.GetFollowByURI(gtscontext.SetBarebones(ctx), uri)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// Already gone.
|
||||
return nil
|
||||
}
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted gtsmodel.Follow
|
||||
|
||||
// Delete follow with given URI,
|
||||
// returning the deleted models.
|
||||
if _, err := r.db.NewDelete().
|
||||
Model(&deleted).
|
||||
Where("? = ?", bun.Ident("uri"), uri).
|
||||
Returning("?, ?, ?",
|
||||
bun.Ident("id"),
|
||||
bun.Ident("account_id"),
|
||||
bun.Ident("target_account_id"),
|
||||
).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop this now-cached follow on return after delete.
|
||||
defer r.state.Caches.DB.Follow.Invalidate("URI", uri)
|
||||
// Invalidate cached follow with URI, manually
|
||||
// call invalidate hook in case not cached.
|
||||
r.state.Caches.DB.Follow.Invalidate("URI", uri)
|
||||
r.state.Caches.OnInvalidateFollow(&deleted)
|
||||
|
||||
// Finally delete follow from DB.
|
||||
return r.deleteFollow(ctx, follow.ID)
|
||||
// Delete every list entry that was created targetting this follow ID.
|
||||
if err := r.state.DB.DeleteAllListEntriesByFollows(ctx, deleted.ID); err != nil {
|
||||
return gtserror.Newf("error deleting list entries: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *relationshipDB) DeleteAccountFollows(ctx context.Context, accountID string) error {
|
||||
var followIDs []string
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted []*gtsmodel.Follow
|
||||
|
||||
// Get full list of IDs.
|
||||
if _, err := r.db.
|
||||
NewSelect().
|
||||
Column("id").
|
||||
Table("follows").
|
||||
// Delete all follows either from
|
||||
// account, or targeting account,
|
||||
// returning the deleted models.
|
||||
if _, err := r.db.NewDelete().
|
||||
Model(&deleted).
|
||||
WhereOr("? = ? OR ? = ?",
|
||||
bun.Ident("account_id"),
|
||||
accountID,
|
||||
bun.Ident("target_account_id"),
|
||||
accountID,
|
||||
).
|
||||
Exec(ctx, &followIDs); err != nil {
|
||||
Returning("?, ?, ?",
|
||||
bun.Ident("id"),
|
||||
bun.Ident("account_id"),
|
||||
bun.Ident("target_account_id"),
|
||||
).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(followIDs) == 0 {
|
||||
// Nothing
|
||||
// to delete.
|
||||
return nil
|
||||
// Gather the follow IDs that were deleted for removing related list entries.
|
||||
followIDs := util.Gather(nil, deleted, func(follow *gtsmodel.Follow) string {
|
||||
return follow.ID
|
||||
})
|
||||
|
||||
// Delete every list entry that was created targetting any of these follow IDs.
|
||||
if err := r.state.DB.DeleteAllListEntriesByFollows(ctx, followIDs...); err != nil {
|
||||
return gtserror.Newf("error deleting list entries: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Invalidate all account's incoming / outoing follows on return.
|
||||
// Invalidate all account's incoming / outoing follows.
|
||||
r.state.Caches.DB.Follow.Invalidate("AccountID", accountID)
|
||||
r.state.Caches.DB.Follow.Invalidate("TargetAccountID", accountID)
|
||||
}()
|
||||
|
||||
// Load all follows into cache, this *really* isn't great
|
||||
// but it is the only way we can ensure we invalidate all
|
||||
// related caches correctly (e.g. visibility).
|
||||
_, err := r.GetAccountFollows(ctx, accountID, nil)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete all follows from DB.
|
||||
_, err = r.db.NewDelete().
|
||||
Table("follows").
|
||||
Where("? IN (?)", bun.Ident("id"), bun.In(followIDs)).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, id := range followIDs {
|
||||
// Finally, delete all list entries associated with each follow ID.
|
||||
if err := r.state.DB.DeleteListEntriesForFollowID(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
// In case not all follow were in
|
||||
// cache, manually call invalidate hooks.
|
||||
for _, follow := range deleted {
|
||||
r.state.Caches.OnInvalidateFollow(follow)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -81,15 +81,8 @@ func (r *relationshipDB) GetFollowRequestsByIDs(ctx context.Context, ids []strin
|
|||
follows, err := r.state.Caches.DB.FollowRequest.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.FollowRequest, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached followReqs.
|
||||
follows := make([]*gtsmodel.FollowRequest, 0, count)
|
||||
follows := make([]*gtsmodel.FollowRequest, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) IDs.
|
||||
|
|
@ -293,124 +286,131 @@ func (r *relationshipDB) RejectFollowRequest(ctx context.Context, sourceAccountI
|
|||
}, targetAccountID, sourceAccountID)
|
||||
}
|
||||
|
||||
func (r *relationshipDB) DeleteFollowRequest(ctx context.Context, sourceAccountID string, targetAccountID string) error {
|
||||
// Load followreq into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
follow, err := r.GetFollowRequest(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
sourceAccountID,
|
||||
targetAccountID,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// Already gone.
|
||||
func (r *relationshipDB) DeleteFollowRequest(
|
||||
ctx context.Context,
|
||||
sourceAccountID string,
|
||||
targetAccountID string,
|
||||
) error {
|
||||
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted gtsmodel.FollowRequest
|
||||
deleted.AccountID = sourceAccountID
|
||||
deleted.TargetAccountID = targetAccountID
|
||||
|
||||
// Delete all follow reqs either
|
||||
// from account, or targeting account,
|
||||
// returning the deleted models.
|
||||
if _, err := r.db.NewDelete().
|
||||
Model(&deleted).
|
||||
Where("? = ?", bun.Ident("account_id"), sourceAccountID).
|
||||
Where("? = ?", bun.Ident("target_account_id"), targetAccountID).
|
||||
Returning("?", bun.Ident("id")).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate cached follow with source / target account IDs,
|
||||
// manually calling invalidate hook in case it isn't cached.
|
||||
r.state.Caches.DB.FollowRequest.Invalidate("AccountID,TargetAccountID",
|
||||
sourceAccountID, targetAccountID)
|
||||
r.state.Caches.OnInvalidateFollowRequest(&deleted)
|
||||
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop this now-cached follow request on return after delete.
|
||||
defer r.state.Caches.DB.FollowRequest.Invalidate("AccountID,TargetAccountID", sourceAccountID, targetAccountID)
|
||||
|
||||
// Finally delete followreq from DB.
|
||||
_, err = r.db.NewDelete().
|
||||
Table("follow_requests").
|
||||
Where("? = ?", bun.Ident("id"), follow.ID).
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *relationshipDB) DeleteFollowRequestByID(ctx context.Context, id string) error {
|
||||
// Load followreq into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
_, err := r.GetFollowRequestByID(gtscontext.SetBarebones(ctx), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// not an issue.
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted gtsmodel.FollowRequest
|
||||
deleted.ID = id
|
||||
|
||||
// Drop this now-cached follow request on return after delete.
|
||||
defer r.state.Caches.DB.FollowRequest.Invalidate("ID", id)
|
||||
|
||||
// Finally delete followreq from DB.
|
||||
_, err = r.db.NewDelete().
|
||||
Table("follow_requests").
|
||||
// Delete follow with given URI,
|
||||
// returning the deleted models.
|
||||
if _, err := r.db.NewDelete().
|
||||
Model(&deleted).
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Exec(ctx)
|
||||
Returning("?, ?",
|
||||
bun.Ident("account_id"),
|
||||
bun.Ident("target_account_id"),
|
||||
).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate cached follow with URI, manually
|
||||
// call invalidate hook in case not cached.
|
||||
r.state.Caches.DB.FollowRequest.Invalidate("ID", id)
|
||||
r.state.Caches.OnInvalidateFollowRequest(&deleted)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *relationshipDB) DeleteFollowRequestByURI(ctx context.Context, uri string) error {
|
||||
// Load followreq into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
_, err := r.GetFollowRequestByURI(gtscontext.SetBarebones(ctx), uri)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// not an issue.
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted gtsmodel.FollowRequest
|
||||
|
||||
// Drop this now-cached follow request on return after delete.
|
||||
defer r.state.Caches.DB.FollowRequest.Invalidate("URI", uri)
|
||||
|
||||
// Finally delete followreq from DB.
|
||||
_, err = r.db.NewDelete().
|
||||
Table("follow_requests").
|
||||
// Delete follow with given URI,
|
||||
// returning the deleted models.
|
||||
if _, err := r.db.NewDelete().
|
||||
Model(&deleted).
|
||||
Where("? = ?", bun.Ident("uri"), uri).
|
||||
Exec(ctx)
|
||||
Returning("?, ?, ?",
|
||||
bun.Ident("id"),
|
||||
bun.Ident("account_id"),
|
||||
bun.Ident("target_account_id"),
|
||||
).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate cached follow with URI, manually
|
||||
// call invalidate hook in case not cached.
|
||||
r.state.Caches.DB.FollowRequest.Invalidate("URI", uri)
|
||||
r.state.Caches.OnInvalidateFollowRequest(&deleted)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *relationshipDB) DeleteAccountFollowRequests(ctx context.Context, accountID string) error {
|
||||
var followReqIDs []string
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted []*gtsmodel.FollowRequest
|
||||
|
||||
// Get full list of IDs.
|
||||
if _, err := r.db.
|
||||
NewSelect().
|
||||
Column("id").
|
||||
Table("follow_requests").
|
||||
// Delete all follows either from
|
||||
// account, or targeting account,
|
||||
// returning the deleted models.
|
||||
if _, err := r.db.NewDelete().
|
||||
Model(&deleted).
|
||||
WhereOr("? = ? OR ? = ?",
|
||||
bun.Ident("account_id"),
|
||||
accountID,
|
||||
bun.Ident("target_account_id"),
|
||||
accountID,
|
||||
).
|
||||
Exec(ctx, &followReqIDs); err != nil {
|
||||
Returning("?, ?, ?",
|
||||
bun.Ident("id"),
|
||||
bun.Ident("account_id"),
|
||||
bun.Ident("target_account_id"),
|
||||
).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(followReqIDs) == 0 {
|
||||
// Nothing
|
||||
// to delete.
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Invalidate all account's incoming / outoing follow requests on return.
|
||||
// Invalidate all account's incoming / outoing follows requests.
|
||||
r.state.Caches.DB.FollowRequest.Invalidate("AccountID", accountID)
|
||||
r.state.Caches.DB.FollowRequest.Invalidate("TargetAccountID", accountID)
|
||||
}()
|
||||
|
||||
// Load all followreqs into cache, this *really* isn't
|
||||
// great but it is the only way we can ensure we invalidate
|
||||
// all related caches correctly (e.g. visibility).
|
||||
_, err := r.GetAccountFollowRequests(ctx, accountID, nil)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
// In case not all follow were in
|
||||
// cache, manually call invalidate hooks.
|
||||
for _, followReq := range deleted {
|
||||
r.state.Caches.OnInvalidateFollowRequest(followReq)
|
||||
}
|
||||
|
||||
// Finally delete all from DB.
|
||||
_, err = r.db.NewDelete().
|
||||
Table("follow_requests").
|
||||
Where("? IN (?)", bun.Ident("id"), bun.In(followReqIDs)).
|
||||
Exec(ctx)
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,15 +87,8 @@ func (r *relationshipDB) getMutesByIDs(ctx context.Context, ids []string) ([]*gt
|
|||
mutes, err := r.state.Caches.DB.UserMute.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.UserMute, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached mutes.
|
||||
mutes := make([]*gtsmodel.UserMute, 0, count)
|
||||
mutes := make([]*gtsmodel.UserMute, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) IDs.
|
||||
|
|
@ -209,72 +202,64 @@ func (r *relationshipDB) PutMute(ctx context.Context, mute *gtsmodel.UserMute) e
|
|||
}
|
||||
|
||||
func (r *relationshipDB) DeleteMuteByID(ctx context.Context, id string) error {
|
||||
// Load mute into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
_, err := r.GetMuteByID(gtscontext.SetBarebones(ctx), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// not an issue.
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted gtsmodel.UserMute
|
||||
|
||||
// Drop this now-cached mute on return after delete.
|
||||
defer r.state.Caches.DB.UserMute.Invalidate("ID", id)
|
||||
|
||||
// Finally delete mute from DB.
|
||||
_, err = r.db.NewDelete().
|
||||
Table("user_mutes").
|
||||
// Delete mute with given ID,
|
||||
// returning the deleted models.
|
||||
if _, err := r.db.NewDelete().
|
||||
Model(&deleted).
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Exec(ctx)
|
||||
Returning("?", bun.Ident("account_id")).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate cached mute with ID, manually
|
||||
// call invalidate hook in case not cached.
|
||||
r.state.Caches.DB.UserMute.Invalidate("ID", id)
|
||||
r.state.Caches.OnInvalidateUserMute(&deleted)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *relationshipDB) DeleteAccountMutes(ctx context.Context, accountID string) error {
|
||||
var muteIDs []string
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted []*gtsmodel.UserMute
|
||||
|
||||
// Get full list of IDs.
|
||||
if err := r.db.NewSelect().
|
||||
Column("id").
|
||||
Table("user_mutes").
|
||||
// Delete all mutes either from
|
||||
// account, or targeting account,
|
||||
// returning the deleted models.
|
||||
if _, err := r.db.NewDelete().
|
||||
Model(&deleted).
|
||||
WhereOr("? = ? OR ? = ?",
|
||||
bun.Ident("account_id"),
|
||||
accountID,
|
||||
bun.Ident("target_account_id"),
|
||||
accountID,
|
||||
).
|
||||
Scan(ctx, &muteIDs); err != nil {
|
||||
Returning("?",
|
||||
bun.Ident("account_id"),
|
||||
).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(muteIDs) == 0 {
|
||||
// Nothing
|
||||
// to delete.
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Invalidate all account's incoming / outoing mutes on return.
|
||||
// Invalidate all account's incoming / outoing user mutes.
|
||||
r.state.Caches.DB.UserMute.Invalidate("AccountID", accountID)
|
||||
r.state.Caches.DB.UserMute.Invalidate("TargetAccountID", accountID)
|
||||
}()
|
||||
|
||||
// Load all mutes into cache, this *really* isn't great
|
||||
// but it is the only way we can ensure we invalidate all
|
||||
// related caches correctly (e.g. visibility).
|
||||
_, err := r.GetAccountMutes(ctx, accountID, nil)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
// In case not all user mutes were in
|
||||
// cache, manually call invalidate hooks.
|
||||
for _, block := range deleted {
|
||||
r.state.Caches.OnInvalidateUserMute(block)
|
||||
}
|
||||
|
||||
// Finally delete all from DB.
|
||||
_, err = r.db.NewDelete().
|
||||
Table("user_mutes").
|
||||
Where("? IN (?)", bun.Ident("id"), bun.In(muteIDs)).
|
||||
Exec(ctx)
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *relationshipDB) GetAccountMutes(
|
||||
|
|
|
|||
|
|
@ -826,10 +826,10 @@ func (suite *RelationshipTestSuite) TestUnfollowExisting() {
|
|||
suite.NotNil(follow)
|
||||
followID := follow.ID
|
||||
|
||||
// We should have list entries for this follow.
|
||||
listEntries, err := suite.db.GetListEntriesForFollowID(context.Background(), followID)
|
||||
// We should have lists that this follow is a part of.
|
||||
lists, err := suite.db.GetListsContainingFollowID(context.Background(), followID)
|
||||
suite.NoError(err)
|
||||
suite.NotEmpty(listEntries)
|
||||
suite.NotEmpty(lists)
|
||||
|
||||
err = suite.db.DeleteFollowByID(context.Background(), followID)
|
||||
suite.NoError(err)
|
||||
|
|
@ -838,10 +838,10 @@ func (suite *RelationshipTestSuite) TestUnfollowExisting() {
|
|||
suite.EqualError(err, db.ErrNoEntries.Error())
|
||||
suite.Nil(follow)
|
||||
|
||||
// ListEntries pertaining to this follow should be deleted too.
|
||||
listEntries, err = suite.db.GetListEntriesForFollowID(context.Background(), followID)
|
||||
// Lists containing this follow should return empty too.
|
||||
lists, err = suite.db.GetListsContainingFollowID(context.Background(), followID)
|
||||
suite.NoError(err)
|
||||
suite.Empty(listEntries)
|
||||
suite.Empty(lists)
|
||||
}
|
||||
|
||||
func (suite *RelationshipTestSuite) TestGetFollowNotExisting() {
|
||||
|
|
|
|||
|
|
@ -248,45 +248,36 @@ func (r *reportDB) PutReport(ctx context.Context, report *gtsmodel.Report) error
|
|||
})
|
||||
}
|
||||
|
||||
func (r *reportDB) UpdateReport(ctx context.Context, report *gtsmodel.Report, columns ...string) (*gtsmodel.Report, error) {
|
||||
func (r *reportDB) UpdateReport(ctx context.Context, report *gtsmodel.Report, columns ...string) error {
|
||||
// Update the report's last-updated
|
||||
report.UpdatedAt = time.Now()
|
||||
if len(columns) != 0 {
|
||||
columns = append(columns, "updated_at")
|
||||
}
|
||||
|
||||
if _, err := r.db.
|
||||
return r.state.Caches.DB.Report.Store(report, func() error {
|
||||
_, err := r.db.
|
||||
NewUpdate().
|
||||
Model(report).
|
||||
Where("? = ?", bun.Ident("report.id"), report.ID).
|
||||
Column(columns...).
|
||||
Exec(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.state.Caches.DB.Report.Invalidate("ID", report.ID)
|
||||
return report, nil
|
||||
Exec(ctx)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (r *reportDB) DeleteReportByID(ctx context.Context, id string) error {
|
||||
defer r.state.Caches.DB.Report.Invalidate("ID", id)
|
||||
|
||||
// Load status into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
_, err := r.GetReportByID(gtscontext.SetBarebones(ctx), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// not an issue.
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Finally delete report from DB.
|
||||
_, err = r.db.NewDelete().
|
||||
// Delete the report from DB.
|
||||
if _, err := r.db.NewDelete().
|
||||
TableExpr("? AS ?", bun.Ident("reports"), bun.Ident("report")).
|
||||
Where("? = ?", bun.Ident("report.id"), id).
|
||||
Exec(ctx)
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate any cached report model by ID.
|
||||
r.state.Caches.DB.Report.Invalidate("ID", id)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ func (suite *ReportTestSuite) TestUpdateReport() {
|
|||
report.ActionTakenByAccountID = suite.testAccounts["admin_account"].ID
|
||||
report.ActionTakenAt = testrig.TimeMustParse("2022-05-14T12:20:03+02:00")
|
||||
|
||||
if _, err := suite.db.UpdateReport(ctx, report, "action_taken", "action_taken_by_account_id", "action_taken_at"); err != nil {
|
||||
if err := suite.db.UpdateReport(ctx, report, "action_taken", "action_taken_by_account_id", "action_taken_at"); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
|
|
@ -228,7 +228,7 @@ func (suite *ReportTestSuite) TestUpdateReportAllColumns() {
|
|||
report.ActionTakenByAccountID = suite.testAccounts["admin_account"].ID
|
||||
report.ActionTakenAt = testrig.TimeMustParse("2022-05-14T12:20:03+02:00")
|
||||
|
||||
if _, err := suite.db.UpdateReport(ctx, report); err != nil {
|
||||
if err := suite.db.UpdateReport(ctx, report); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@ package bundb
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/uptrace/bun"
|
||||
|
|
@ -110,13 +112,18 @@ func (s *sinBinStatusDB) UpdateSinBinStatus(
|
|||
}
|
||||
|
||||
func (s *sinBinStatusDB) DeleteSinBinStatusByID(ctx context.Context, id string) error {
|
||||
// On return ensure status invalidated from cache.
|
||||
defer s.state.Caches.DB.SinBinStatus.Invalidate("ID", id)
|
||||
|
||||
_, err := s.db.
|
||||
// Delete the status from DB.
|
||||
if _, err := s.db.
|
||||
NewDelete().
|
||||
TableExpr("? AS ?", bun.Ident("sin_bin_statuses"), bun.Ident("sin_bin_status")).
|
||||
Where("? = ?", bun.Ident("sin_bin_status.id"), id).
|
||||
Exec(ctx)
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate any cached sinbin status model by ID.
|
||||
s.state.Caches.DB.SinBinStatus.Invalidate("ID", id)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,15 +54,8 @@ func (s *statusDB) GetStatusesByIDs(ctx context.Context, ids []string) ([]*gtsmo
|
|||
statuses, err := s.state.Caches.DB.Status.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.Status, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached statuses.
|
||||
statuses := make([]*gtsmodel.Status, 0, count)
|
||||
statuses := make([]*gtsmodel.Status, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) status IDs.
|
||||
|
|
@ -486,24 +479,13 @@ func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, co
|
|||
}
|
||||
|
||||
func (s *statusDB) DeleteStatusByID(ctx context.Context, id string) error {
|
||||
// Load status into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
_, err := s.GetStatusByID(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
id,
|
||||
)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
// NOTE: even if db.ErrNoEntries is returned, we
|
||||
// still run the below transaction to ensure related
|
||||
// objects are appropriately deleted.
|
||||
return err
|
||||
}
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted gtsmodel.Status
|
||||
deleted.ID = id
|
||||
|
||||
// On return ensure status invalidated from cache.
|
||||
defer s.state.Caches.DB.Status.Invalidate("ID", id)
|
||||
|
||||
return s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
// Delete status from database and any related links in a transaction.
|
||||
if err := s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
// delete links between this status and any emojis it uses
|
||||
if _, err := tx.
|
||||
NewDelete().
|
||||
|
|
@ -524,26 +506,42 @@ func (s *statusDB) DeleteStatusByID(ctx context.Context, id string) error {
|
|||
|
||||
// Delete links between this status
|
||||
// and any threads it was a part of.
|
||||
_, err = tx.
|
||||
if _, err := tx.
|
||||
NewDelete().
|
||||
TableExpr("? AS ?", bun.Ident("thread_to_statuses"), bun.Ident("thread_to_status")).
|
||||
Where("? = ?", bun.Ident("thread_to_status.status_id"), id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete the status itself
|
||||
if _, err := tx.
|
||||
NewDelete().
|
||||
TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")).
|
||||
Where("? = ?", bun.Ident("status.id"), id).
|
||||
Exec(ctx); err != nil {
|
||||
Model(&deleted).
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Returning("?, ?, ?, ?, ?",
|
||||
bun.Ident("account_id"),
|
||||
bun.Ident("boost_of_id"),
|
||||
bun.Ident("in_reply_to_id"),
|
||||
bun.Ident("attachments"),
|
||||
bun.Ident("poll_id"),
|
||||
).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate cached status by its ID, manually
|
||||
// call the invalidate hook in case not cached.
|
||||
s.state.Caches.DB.Status.Invalidate("ID", id)
|
||||
s.state.Caches.OnInvalidateStatus(&deleted)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *statusDB) GetStatusesUsingEmoji(ctx context.Context, emojiID string) ([]*gtsmodel.Status, error) {
|
||||
|
|
|
|||
|
|
@ -73,15 +73,8 @@ func (s *statusBookmarkDB) GetStatusBookmarksByIDs(ctx context.Context, ids []st
|
|||
bookmarks, err := s.state.Caches.DB.StatusBookmark.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.StatusBookmark, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached bookmarks.
|
||||
bookmarks := make([]*gtsmodel.StatusBookmark, 0, count)
|
||||
bookmarks := make([]*gtsmodel.StatusBookmark, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) bookmarks.
|
||||
|
|
@ -264,60 +257,86 @@ func (s *statusBookmarkDB) PutStatusBookmark(ctx context.Context, bookmark *gtsm
|
|||
}
|
||||
|
||||
func (s *statusBookmarkDB) DeleteStatusBookmarkByID(ctx context.Context, id string) error {
|
||||
_, err := s.db.
|
||||
NewDelete().
|
||||
Table("status_bookmarks").
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted gtsmodel.StatusBookmark
|
||||
deleted.ID = id
|
||||
|
||||
// Delete block with given URI,
|
||||
// returning the deleted models.
|
||||
if _, err := s.db.NewDelete().
|
||||
Model(&deleted).
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
Returning("?", bun.Ident("status_id")).
|
||||
Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate cached status bookmark by its ID,
|
||||
// manually call invalidate hook in case not cached.
|
||||
s.state.Caches.DB.StatusBookmark.Invalidate("ID", id)
|
||||
s.state.Caches.OnInvalidateStatusBookmark(&deleted)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *statusBookmarkDB) DeleteStatusBookmarks(ctx context.Context, targetAccountID string, originAccountID string) error {
|
||||
if targetAccountID == "" && originAccountID == "" {
|
||||
return errors.New("DeleteBookmarks: one of targetAccountID or originAccountID must be set")
|
||||
return gtserror.New("one of targetAccountID or originAccountID must be set")
|
||||
}
|
||||
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted []*gtsmodel.StatusBookmark
|
||||
|
||||
q := s.db.
|
||||
NewDelete().
|
||||
TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark"))
|
||||
Model(&deleted).
|
||||
Returning("?", bun.Ident("status_id"))
|
||||
|
||||
if targetAccountID != "" {
|
||||
q = q.Where("? = ?", bun.Ident("status_bookmark.target_account_id"), targetAccountID)
|
||||
defer s.state.Caches.DB.StatusBookmark.Invalidate("TargetAccountID", targetAccountID)
|
||||
q = q.Where("? = ?", bun.Ident("target_account_id"), targetAccountID)
|
||||
}
|
||||
|
||||
if originAccountID != "" {
|
||||
q = q.Where("? = ?", bun.Ident("status_bookmark.account_id"), originAccountID)
|
||||
defer s.state.Caches.DB.StatusBookmark.Invalidate("AccountID", originAccountID)
|
||||
q = q.Where("? = ?", bun.Ident("account_id"), originAccountID)
|
||||
}
|
||||
|
||||
if _, err := q.Exec(ctx); err != nil {
|
||||
if _, err := q.Exec(ctx); err != nil &&
|
||||
!errors.Is(err, db.ErrNoEntries) {
|
||||
return err
|
||||
}
|
||||
|
||||
if targetAccountID != "" {
|
||||
s.state.Caches.DB.StatusBookmark.Invalidate("TargetAccountID", targetAccountID)
|
||||
}
|
||||
|
||||
if originAccountID != "" {
|
||||
s.state.Caches.DB.StatusBookmark.Invalidate("AccountID", originAccountID)
|
||||
for _, deleted := range deleted {
|
||||
// Invalidate cached status bookmark by status ID,
|
||||
// manually call invalidate hook in case not cached.
|
||||
s.state.Caches.DB.StatusBookmark.Invalidate("StatusID", deleted.StatusID)
|
||||
s.state.Caches.OnInvalidateStatusBookmark(deleted)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *statusBookmarkDB) DeleteStatusBookmarksForStatus(ctx context.Context, statusID string) error {
|
||||
q := s.db.
|
||||
NewDelete().
|
||||
// Delete status bookmarks
|
||||
// from database by status ID.
|
||||
q := s.db.NewDelete().
|
||||
TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")).
|
||||
Where("? = ?", bun.Ident("status_bookmark.status_id"), statusID)
|
||||
if _, err := q.Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Wrap provided ID in a bookmark
|
||||
// model for calling cache hook.
|
||||
var deleted gtsmodel.StatusBookmark
|
||||
deleted.StatusID = statusID
|
||||
|
||||
// Invalidate cached status bookmark by status ID,
|
||||
// manually call invalidate hook in case not cached.
|
||||
s.state.Caches.DB.StatusBookmark.Invalidate("StatusID", statusID)
|
||||
s.state.Caches.OnInvalidateStatusBookmark(&deleted)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,15 +133,8 @@ func (s *statusFaveDB) GetStatusFaves(ctx context.Context, statusID string) ([]*
|
|||
faves, err := s.state.Caches.DB.StatusFave.LoadIDs("ID",
|
||||
faveIDs,
|
||||
func(uncached []string) ([]*gtsmodel.StatusFave, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached faves.
|
||||
faves := make([]*gtsmodel.StatusFave, 0, count)
|
||||
faves := make([]*gtsmodel.StatusFave, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) fave IDs.
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ package bundb
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
|
|
@ -79,15 +80,8 @@ func (t *tagDB) GetTags(ctx context.Context, ids []string) ([]*gtsmodel.Tag, err
|
|||
tags, err := t.state.Caches.DB.Tag.LoadIDs("ID",
|
||||
ids,
|
||||
func(uncached []string) ([]*gtsmodel.Tag, error) {
|
||||
// Avoid querying
|
||||
// if none uncached.
|
||||
count := len(uncached)
|
||||
if count == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Preallocate expected length of uncached tags.
|
||||
tags := make([]*gtsmodel.Tag, 0, count)
|
||||
tags := make([]*gtsmodel.Tag, 0, len(uncached))
|
||||
|
||||
// Perform database query scanning
|
||||
// the remaining (uncached) IDs.
|
||||
|
|
@ -148,17 +142,11 @@ func (t *tagDB) GetFollowedTags(ctx context.Context, accountID string, page *pag
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := t.GetTags(ctx, tagIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
return t.GetTags(ctx, tagIDs)
|
||||
}
|
||||
|
||||
func (t *tagDB) getTagIDsFollowedByAccount(ctx context.Context, accountID string, page *paging.Page) ([]string, error) {
|
||||
return loadPagedIDs(&t.state.Caches.DB.TagIDsFollowedByAccount, accountID, page, func() ([]string, error) {
|
||||
return loadPagedIDs(&t.state.Caches.DB.FollowingTagIDs, ">"+accountID, page, func() ([]string, error) {
|
||||
var tagIDs []string
|
||||
|
||||
// Tag IDs not in cache. Perform DB query.
|
||||
|
|
@ -178,7 +166,7 @@ func (t *tagDB) getTagIDsFollowedByAccount(ctx context.Context, accountID string
|
|||
}
|
||||
|
||||
func (t *tagDB) getAccountIDsFollowingTag(ctx context.Context, tagID string) ([]string, error) {
|
||||
return loadPagedIDs(&t.state.Caches.DB.AccountIDsFollowingTag, tagID, nil, func() ([]string, error) {
|
||||
return loadPagedIDs(&t.state.Caches.DB.FollowingTagIDs, "<"+tagID, nil, func() ([]string, error) {
|
||||
var accountIDs []string
|
||||
|
||||
// Account IDs not in cache. Perform DB query.
|
||||
|
|
@ -198,18 +186,11 @@ func (t *tagDB) getAccountIDsFollowingTag(ctx context.Context, tagID string) ([]
|
|||
}
|
||||
|
||||
func (t *tagDB) IsAccountFollowingTag(ctx context.Context, accountID string, tagID string) (bool, error) {
|
||||
accountTagIDs, err := t.getTagIDsFollowedByAccount(ctx, accountID, nil)
|
||||
followingTagIDs, err := t.getTagIDsFollowedByAccount(ctx, accountID, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, accountTagID := range accountTagIDs {
|
||||
if accountTagID == tagID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
return slices.Contains(followingTagIDs, tagID), nil
|
||||
}
|
||||
|
||||
func (t *tagDB) PutFollowedTag(ctx context.Context, accountID string, tagID string) error {
|
||||
|
|
@ -234,9 +215,15 @@ func (t *tagDB) PutFollowedTag(ctx context.Context, accountID string, tagID stri
|
|||
return nil
|
||||
}
|
||||
|
||||
// Otherwise, this is a new followed tag, so we invalidate caches related to it.
|
||||
t.state.Caches.DB.AccountIDsFollowingTag.Invalidate(tagID)
|
||||
t.state.Caches.DB.TagIDsFollowedByAccount.Invalidate(accountID)
|
||||
// We updated something, invalidate caches.
|
||||
t.state.Caches.DB.FollowingTagIDs.Invalidate(
|
||||
|
||||
// tag IDs followed by account
|
||||
">"+accountID,
|
||||
|
||||
// account IDs following tag
|
||||
"<"+tagID,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -259,9 +246,15 @@ func (t *tagDB) DeleteFollowedTag(ctx context.Context, accountID string, tagID s
|
|||
return nil
|
||||
}
|
||||
|
||||
// If we deleted anything, invalidate caches related to it.
|
||||
t.state.Caches.DB.AccountIDsFollowingTag.Invalidate(tagID)
|
||||
t.state.Caches.DB.TagIDsFollowedByAccount.Invalidate(accountID)
|
||||
// We deleted something, invalidate caches.
|
||||
t.state.Caches.DB.FollowingTagIDs.Invalidate(
|
||||
|
||||
// tag IDs followed by account
|
||||
">"+accountID,
|
||||
|
||||
// account IDs following tag
|
||||
"<"+tagID,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
|
@ -278,16 +271,26 @@ func (t *tagDB) DeleteFollowedTagsByAccountID(ctx context.Context, accountID str
|
|||
return gtserror.Newf("error deleting followed tags for account %s: %w", accountID, err)
|
||||
}
|
||||
|
||||
// Invalidate account ID caches for the account and those tags.
|
||||
t.state.Caches.DB.TagIDsFollowedByAccount.Invalidate(accountID)
|
||||
t.state.Caches.DB.AccountIDsFollowingTag.Invalidate(tagIDs...)
|
||||
// Convert tag IDs to the keys
|
||||
// we use for caching tag follow
|
||||
// and following IDs.
|
||||
keys := tagIDs
|
||||
for i := range keys {
|
||||
keys[i] = "<" + keys[i]
|
||||
}
|
||||
keys = append(keys, ">"+accountID)
|
||||
|
||||
// If we deleted anything, invalidate caches with keys.
|
||||
t.state.Caches.DB.FollowingTagIDs.Invalidate(keys...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *tagDB) GetAccountIDsFollowingTagIDs(ctx context.Context, tagIDs []string) ([]string, error) {
|
||||
// Accounts might be following multiple tags in this list, but we only want to return each account once.
|
||||
accountIDs := []string{}
|
||||
// Make conservative estimate for no. accounts.
|
||||
accountIDs := make([]string, 0, len(tagIDs))
|
||||
|
||||
// Gather all accounts following tags.
|
||||
for _, tagID := range tagIDs {
|
||||
tagAccountIDs, err := t.getAccountIDsFollowingTag(ctx, tagID)
|
||||
if err != nil {
|
||||
|
|
@ -295,5 +298,8 @@ func (t *tagDB) GetAccountIDsFollowingTagIDs(ctx context.Context, tagIDs []strin
|
|||
}
|
||||
accountIDs = append(accountIDs, tagAccountIDs...)
|
||||
}
|
||||
|
||||
// Accounts might be following multiple tags in list,
|
||||
// but we only want to return each account once.
|
||||
return util.Deduplicate(accountIDs), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, maxI
|
|||
// To take account of exclusive lists, get all of
|
||||
// this account's lists, so we can filter out follows
|
||||
// that are in contained in exclusive lists.
|
||||
lists, err := t.state.DB.GetListsForAccountID(ctx, accountID)
|
||||
lists, err := t.state.DB.GetListsByAccountID(ctx, accountID)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, gtserror.Newf("db error getting lists for account %s: %w", accountID, err)
|
||||
}
|
||||
|
|
@ -84,9 +84,15 @@ func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, maxI
|
|||
continue
|
||||
}
|
||||
|
||||
// Fetch all follow IDs of the entries ccontained in this list.
|
||||
listFollowIDs, err := t.state.DB.GetFollowIDsInList(ctx, list.ID, nil)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
return nil, gtserror.Newf("db error getting list entry follow ids: %w", err)
|
||||
}
|
||||
|
||||
// Exclusive list, index all its follow IDs.
|
||||
for _, listEntry := range list.ListEntries {
|
||||
ignoreFollowIDs[listEntry.FollowID] = struct{}{}
|
||||
for _, followID := range listFollowIDs {
|
||||
ignoreFollowIDs[followID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -370,30 +376,20 @@ func (t *timelineDB) GetListTimeline(
|
|||
frontToBack = true
|
||||
)
|
||||
|
||||
// Fetch all listEntries entries from the database.
|
||||
listEntries, err := t.state.DB.GetListEntries(
|
||||
// Don't need actual follows
|
||||
// for this, just the IDs.
|
||||
gtscontext.SetBarebones(ctx),
|
||||
listID,
|
||||
"", "", "", 0,
|
||||
// Fetch all follow IDs contained in list from DB.
|
||||
followIDs, err := t.state.DB.GetFollowIDsInList(
|
||||
ctx, listID, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting entries for list %s: %w", listID, err)
|
||||
return nil, fmt.Errorf("error getting follows in list: %w", err)
|
||||
}
|
||||
|
||||
// If there's no list entries we can't
|
||||
// If there's no list follows we can't
|
||||
// possibly return anything for this list.
|
||||
if len(listEntries) == 0 {
|
||||
if len(followIDs) == 0 {
|
||||
return make([]*gtsmodel.Status, 0), nil
|
||||
}
|
||||
|
||||
// Extract just the IDs of each follow.
|
||||
followIDs := make([]string, 0, len(listEntries))
|
||||
for _, listEntry := range listEntries {
|
||||
followIDs = append(followIDs, listEntry.FollowID)
|
||||
}
|
||||
|
||||
// Select target account IDs from follows.
|
||||
subQ := t.db.
|
||||
NewSelect().
|
||||
|
|
|
|||
|
|
@ -74,7 +74,8 @@ func (suite *TimelineTestSuite) publicCount() int {
|
|||
var publicCount int
|
||||
for _, status := range suite.testStatuses {
|
||||
if status.Visibility == gtsmodel.VisibilityPublic &&
|
||||
status.BoostOfID == "" {
|
||||
status.BoostOfID == "" &&
|
||||
!util.PtrOrZero(status.PendingApproval) {
|
||||
publicCount++
|
||||
}
|
||||
}
|
||||
|
|
@ -184,8 +185,8 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineIgnoreExclusive() {
|
|||
suite.checkStatuses(s, id.Highest, id.Lowest, 8)
|
||||
|
||||
// Remove admin account from the exclusive list.
|
||||
listEntryID := suite.testListEntries["local_account_1_list_1_entry_2"].ID
|
||||
if err := suite.db.DeleteListEntry(ctx, listEntryID); err != nil {
|
||||
listEntry := suite.testListEntries["local_account_1_list_1_entry_2"]
|
||||
if err := suite.db.DeleteListEntry(ctx, listEntry.ListID, listEntry.FollowID); err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,12 +67,14 @@ func (t *tombstoneDB) PutTombstone(ctx context.Context, tombstone *gtsmodel.Tomb
|
|||
}
|
||||
|
||||
func (t *tombstoneDB) DeleteTombstone(ctx context.Context, id string) error {
|
||||
defer t.state.Caches.DB.Tombstone.Invalidate("ID", id)
|
||||
|
||||
// Delete tombstone from DB.
|
||||
_, err := t.db.NewDelete().
|
||||
TableExpr("? AS ?", bun.Ident("tombstones"), bun.Ident("tombstone")).
|
||||
Where("? = ?", bun.Ident("tombstone.id"), id).
|
||||
Exec(ctx)
|
||||
|
||||
// Invalidate any cached tombstone by given ID.
|
||||
t.state.Caches.DB.Tombstone.Invalidate("ID", id)
|
||||
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,10 +19,8 @@ package bundb
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
|
|
@ -209,26 +207,26 @@ func (u *userDB) UpdateUser(ctx context.Context, user *gtsmodel.User, columns ..
|
|||
}
|
||||
|
||||
func (u *userDB) DeleteUserByID(ctx context.Context, userID string) error {
|
||||
defer u.state.Caches.DB.User.Invalidate("ID", userID)
|
||||
// Gather necessary fields from
|
||||
// deleted for cache invaliation.
|
||||
var deleted gtsmodel.User
|
||||
deleted.ID = userID
|
||||
|
||||
// Load user into cache before attempting a delete,
|
||||
// as we need it cached in order to trigger the invalidate
|
||||
// callback. This in turn invalidates others.
|
||||
_, err := u.GetUserByID(gtscontext.SetBarebones(ctx), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoEntries) {
|
||||
// not an issue.
|
||||
err = nil
|
||||
}
|
||||
// Delete user from DB.
|
||||
if _, err := u.db.NewDelete().
|
||||
Model(&deleted).
|
||||
Where("? = ?", bun.Ident("id"), userID).
|
||||
Returning("?", bun.Ident("account_id")).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Finally delete user from DB.
|
||||
_, err = u.db.NewDelete().
|
||||
TableExpr("? AS ?", bun.Ident("users"), bun.Ident("user")).
|
||||
Where("? = ?", bun.Ident("user.id"), userID).
|
||||
Exec(ctx)
|
||||
return err
|
||||
// Invalidate cached user by ID, manually
|
||||
// call invalidate hook in case not cached.
|
||||
u.state.Caches.DB.User.Invalidate("ID", userID)
|
||||
u.state.Caches.OnInvalidateUser(&deleted)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *userDB) PutDeniedUser(ctx context.Context, deniedUser *gtsmodel.DeniedUser) error {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import (
|
|||
"context"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/paging"
|
||||
)
|
||||
|
||||
type List interface {
|
||||
|
|
@ -30,11 +31,29 @@ type List interface {
|
|||
// GetListsByIDs fetches all lists with the provided IDs.
|
||||
GetListsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.List, error)
|
||||
|
||||
// GetListsForAccountID gets all lists owned by the given accountID.
|
||||
GetListsForAccountID(ctx context.Context, accountID string) ([]*gtsmodel.List, error)
|
||||
// GetListsByAccountID gets all lists owned by the given accountID.
|
||||
GetListsByAccountID(ctx context.Context, accountID string) ([]*gtsmodel.List, error)
|
||||
|
||||
// CountListsForAccountID counts the number of lists owned by the given accountID.
|
||||
CountListsForAccountID(ctx context.Context, accountID string) (int, error)
|
||||
// CountListsByAccountID counts the number of lists owned by the given accountID.
|
||||
CountListsByAccountID(ctx context.Context, accountID string) (int, error)
|
||||
|
||||
// GetListsContainingFollowID gets all lists that contain the given follow with ID.
|
||||
GetListsContainingFollowID(ctx context.Context, followID string) ([]*gtsmodel.List, error)
|
||||
|
||||
// GetFollowIDsInList returns all the follow IDs contained within given list ID.
|
||||
GetFollowIDsInList(ctx context.Context, listID string, page *paging.Page) ([]string, error)
|
||||
|
||||
// GetFollowsInList returns all the follows contained within given list ID.
|
||||
GetFollowsInList(ctx context.Context, listID string, page *paging.Page) ([]*gtsmodel.Follow, error)
|
||||
|
||||
// GetAccountIDsInList return all the account IDs (follow targets) contained within given list ID.
|
||||
GetAccountIDsInList(ctx context.Context, listID string, page *paging.Page) ([]string, error)
|
||||
|
||||
// GetAccountsInList return all the accounts (follow targets) contained within given list ID.
|
||||
GetAccountsInList(ctx context.Context, listID string, page *paging.Page) ([]*gtsmodel.Account, error)
|
||||
|
||||
// IsAccountInListID returns whether given account with ID is in the list with ID.
|
||||
IsAccountInList(ctx context.Context, listID string, accountID string) (bool, error)
|
||||
|
||||
// PopulateList ensures that the list's struct fields are populated.
|
||||
PopulateList(ctx context.Context, list *gtsmodel.List) error
|
||||
|
|
@ -49,31 +68,13 @@ type List interface {
|
|||
// DeleteListByID deletes one list with the given ID.
|
||||
DeleteListByID(ctx context.Context, id string) error
|
||||
|
||||
// GetListEntryByID gets one list entry with the given ID.
|
||||
GetListEntryByID(ctx context.Context, id string) (*gtsmodel.ListEntry, error)
|
||||
|
||||
// GetListEntriesyIDs fetches all list entries with the provided IDs.
|
||||
GetListEntriesByIDs(ctx context.Context, ids []string) ([]*gtsmodel.ListEntry, error)
|
||||
|
||||
// GetListEntries gets list entries from the given listID, using the given parameters.
|
||||
GetListEntries(ctx context.Context, listID string, maxID string, sinceID string, minID string, limit int) ([]*gtsmodel.ListEntry, error)
|
||||
|
||||
// GetListEntriesForFollowID returns all listEntries that pertain to the given followID.
|
||||
GetListEntriesForFollowID(ctx context.Context, followID string) ([]*gtsmodel.ListEntry, error)
|
||||
|
||||
// PopulateListEntry ensures that the listEntry's struct fields are populated.
|
||||
PopulateListEntry(ctx context.Context, listEntry *gtsmodel.ListEntry) error
|
||||
|
||||
// PutListEntries inserts a slice of listEntries into the database.
|
||||
// It uses a transaction to ensure no partial updates.
|
||||
PutListEntries(ctx context.Context, listEntries []*gtsmodel.ListEntry) error
|
||||
|
||||
// DeleteListEntry deletes one list entry with the given id.
|
||||
DeleteListEntry(ctx context.Context, id string) error
|
||||
// DeleteListEntry deletes the list entry with given list ID and follow ID.
|
||||
DeleteListEntry(ctx context.Context, listID string, followID string) error
|
||||
|
||||
// DeleteListEntryForFollowID deletes all list entries with the given followID.
|
||||
DeleteListEntriesForFollowID(ctx context.Context, followID string) error
|
||||
|
||||
// ListIncludesAccount returns true if the given listID includes the given accountID.
|
||||
ListIncludesAccount(ctx context.Context, listID string, accountID string) (bool, error)
|
||||
// DeleteAllListEntryByFollow deletes all list entries with the given followIDs.
|
||||
DeleteAllListEntriesByFollows(ctx context.Context, followIDs ...string) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ type Poll interface {
|
|||
// UpdatePoll updates the Poll in the database, only on selected columns if provided (else, all).
|
||||
UpdatePoll(ctx context.Context, poll *gtsmodel.Poll, cols ...string) error
|
||||
|
||||
// DeletePollByID deletes the Poll with given ID from the database.
|
||||
// DeletePollByID deletes the Poll with given ID from the
|
||||
// database, along with all its associated poll votes.
|
||||
DeletePollByID(ctx context.Context, id string) error
|
||||
|
||||
// GetPollVoteByID gets the PollVote with given ID from the database.
|
||||
|
|
@ -57,9 +58,6 @@ type Poll interface {
|
|||
// PutPollVote puts the given PollVote in the database.
|
||||
PutPollVote(ctx context.Context, vote *gtsmodel.PollVote) error
|
||||
|
||||
// DeletePollVotes deletes all PollVotes in Poll with given ID from the database.
|
||||
DeletePollVotes(ctx context.Context, pollID string) error
|
||||
|
||||
// DeletePollVoteBy deletes the PollVote in Poll with ID, by account ID, from the database.
|
||||
DeletePollVoteBy(ctx context.Context, pollID string, accountID string) error
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ type Relationship interface {
|
|||
// GetFollow retrieves a follow if it exists between source and target accounts.
|
||||
GetFollow(ctx context.Context, sourceAccountID string, targetAccountID string) (*gtsmodel.Follow, error)
|
||||
|
||||
// GetFollowsByIDs fetches all follows from database with given IDs.
|
||||
GetFollowsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.Follow, error)
|
||||
|
||||
// PopulateFollow populates the struct pointers on the given follow.
|
||||
PopulateFollow(ctx context.Context, follow *gtsmodel.Follow) error
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ type Report interface {
|
|||
// provided, then all columns will be updated.
|
||||
// updated_at will also be updated, no need to pass this
|
||||
// as a specific column.
|
||||
UpdateReport(ctx context.Context, report *gtsmodel.Report, columns ...string) (*gtsmodel.Report, error)
|
||||
UpdateReport(ctx context.Context, report *gtsmodel.Report, columns ...string) error
|
||||
|
||||
// DeleteReportByID deletes report with the given id.
|
||||
DeleteReportByID(ctx context.Context, id string) error
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ func (c *sqliteConn) Close() (err error) {
|
|||
raw := c.connIface.(sqlite3driver.Conn).Raw()
|
||||
|
||||
// see: https://www.sqlite.org/pragma.html#pragma_optimize
|
||||
const onClose = "PRAGMA analysis_limit=1000; PRAGMA optimize;"
|
||||
const onClose = "PRAGMA optimize;"
|
||||
_ = raw.Exec(onClose)
|
||||
|
||||
// Finally, close.
|
||||
|
|
|
|||
|
|
@ -71,17 +71,26 @@ func NewSender() (Sender, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
username := config.GetSMTPUsername()
|
||||
password := config.GetSMTPPassword()
|
||||
host := config.GetSMTPHost()
|
||||
port := config.GetSMTPPort()
|
||||
from := config.GetSMTPFrom()
|
||||
msgIDHost := config.GetHost()
|
||||
var (
|
||||
username = config.GetSMTPUsername()
|
||||
password = config.GetSMTPPassword()
|
||||
host = config.GetSMTPHost()
|
||||
port = config.GetSMTPPort()
|
||||
from = config.GetSMTPFrom()
|
||||
msgIDHost = config.GetHost()
|
||||
smtpAuth smtp.Auth
|
||||
)
|
||||
|
||||
if username == "" || password == "" {
|
||||
smtpAuth = nil
|
||||
} else {
|
||||
smtpAuth = smtp.PlainAuth("", username, password, host)
|
||||
}
|
||||
|
||||
return &sender{
|
||||
hostAddress: fmt.Sprintf("%s:%d", host, port),
|
||||
from: from,
|
||||
auth: smtp.PlainAuth("", username, password, host),
|
||||
auth: smtpAuth,
|
||||
msgIDHost: msgIDHost,
|
||||
template: t,
|
||||
}, nil
|
||||
|
|
|
|||
|
|
@ -373,7 +373,7 @@ func (d *Dereferencer) enrichStatus(
|
|||
requestUser string,
|
||||
uri *url.URL,
|
||||
status *gtsmodel.Status,
|
||||
apubStatus ap.Statusable,
|
||||
statusable ap.Statusable,
|
||||
) (
|
||||
*gtsmodel.Status,
|
||||
ap.Statusable,
|
||||
|
|
@ -393,7 +393,7 @@ func (d *Dereferencer) enrichStatus(
|
|||
return nil, nil, gtserror.SetUnretrievable(err)
|
||||
}
|
||||
|
||||
if apubStatus == nil {
|
||||
if statusable == nil {
|
||||
// Dereference latest version of the status.
|
||||
rsp, err := tsport.Dereference(ctx, uri)
|
||||
if err != nil {
|
||||
|
|
@ -402,7 +402,7 @@ func (d *Dereferencer) enrichStatus(
|
|||
}
|
||||
|
||||
// Attempt to resolve ActivityPub status from response.
|
||||
apubStatus, err = ap.ResolveStatusable(ctx, rsp.Body)
|
||||
statusable, err = ap.ResolveStatusable(ctx, rsp.Body)
|
||||
|
||||
// Tidy up now done.
|
||||
_ = rsp.Body.Close()
|
||||
|
|
@ -444,7 +444,7 @@ func (d *Dereferencer) enrichStatus(
|
|||
}
|
||||
|
||||
// Get the attributed-to account in order to fetch profile.
|
||||
attributedTo, err := ap.ExtractAttributedToURI(apubStatus)
|
||||
attributedTo, err := ap.ExtractAttributedToURI(statusable)
|
||||
if err != nil {
|
||||
return nil, nil, gtserror.New("attributedTo was empty")
|
||||
}
|
||||
|
|
@ -460,7 +460,7 @@ func (d *Dereferencer) enrichStatus(
|
|||
|
||||
// ActivityPub model was recently dereferenced, so assume passed status
|
||||
// may contain out-of-date information. Convert AP model to our GTS model.
|
||||
latestStatus, err := d.converter.ASStatusToStatus(ctx, apubStatus)
|
||||
latestStatus, err := d.converter.ASStatusToStatus(ctx, statusable)
|
||||
if err != nil {
|
||||
return nil, nil, gtserror.Newf("error converting statusable to gts model for status %s: %w", uri, err)
|
||||
}
|
||||
|
|
@ -479,8 +479,8 @@ func (d *Dereferencer) enrichStatus(
|
|||
matches, err := util.URIMatches(
|
||||
uri,
|
||||
append(
|
||||
ap.GetURL(apubStatus), // status URL(s)
|
||||
ap.GetJSONLDId(apubStatus), // status URI
|
||||
ap.GetURL(statusable), // status URL(s)
|
||||
ap.GetJSONLDId(statusable), // status URI
|
||||
)...,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -516,10 +516,12 @@ func (d *Dereferencer) enrichStatus(
|
|||
latestStatus.ID = status.ID
|
||||
}
|
||||
|
||||
// Carry-over values and set fetch time.
|
||||
latestStatus.UpdatedAt = status.UpdatedAt
|
||||
// Set latest fetch time and carry-
|
||||
// over some values from "old" status.
|
||||
latestStatus.FetchedAt = time.Now()
|
||||
latestStatus.UpdatedAt = status.UpdatedAt
|
||||
latestStatus.Local = status.Local
|
||||
latestStatus.PinnedAt = status.PinnedAt
|
||||
|
||||
// Carry-over approvals. Remote instances might not yet
|
||||
// serve statuses with the `approved_by` field, but we
|
||||
|
|
@ -591,7 +593,7 @@ func (d *Dereferencer) enrichStatus(
|
|||
}
|
||||
}
|
||||
|
||||
return latestStatus, apubStatus, nil
|
||||
return latestStatus, statusable, nil
|
||||
}
|
||||
|
||||
func (d *Dereferencer) fetchStatusMentions(
|
||||
|
|
@ -826,9 +828,6 @@ func (d *Dereferencer) fetchStatusPoll(
|
|||
if err := d.state.DB.DeletePollByID(ctx, pollID); err != nil {
|
||||
return gtserror.Newf("error deleting existing poll from database: %w", err)
|
||||
}
|
||||
if err := d.state.DB.DeletePollVotes(ctx, pollID); err != nil {
|
||||
return gtserror.Newf("error deleting existing votes from database: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,12 +19,18 @@ package dereferencing
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"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/uris"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
|
|
@ -43,6 +49,14 @@ import (
|
|||
// pending approval, then "PendingApproval" will be set
|
||||
// to "true" on status. Callers should check this
|
||||
// and handle it as appropriate.
|
||||
//
|
||||
// If status is a reply that is not permitted based on
|
||||
// interaction policies, or status replies to a status
|
||||
// that's been Rejected before (ie., it has a rejected
|
||||
// InteractionRequest stored in the db) then the reply
|
||||
// will also be rejected, and a pre-rejected interaction
|
||||
// request will be stored for it before doing cleanup,
|
||||
// if one didn't already exist.
|
||||
func (d *Dereferencer) isPermittedStatus(
|
||||
ctx context.Context,
|
||||
requestUser string,
|
||||
|
|
@ -58,7 +72,7 @@ func (d *Dereferencer) isPermittedStatus(
|
|||
log.Warnf(ctx, "status author suspended: %s", status.AccountURI)
|
||||
permitted = false
|
||||
|
||||
case status.InReplyTo != nil:
|
||||
case status.InReplyToURI != "":
|
||||
// Status is a reply, check permissivity.
|
||||
permitted, err = d.isPermittedReply(ctx,
|
||||
requestUser,
|
||||
|
|
@ -101,8 +115,90 @@ func (d *Dereferencer) isPermittedReply(
|
|||
requestUser string,
|
||||
status *gtsmodel.Status,
|
||||
) (bool, error) {
|
||||
// Extract reply from status.
|
||||
inReplyTo := status.InReplyTo
|
||||
var (
|
||||
statusURI = status.URI // Definitely set.
|
||||
inReplyToURI = status.InReplyToURI // Definitely set.
|
||||
inReplyTo = status.InReplyTo // Might not yet be set.
|
||||
)
|
||||
|
||||
// Check if status with this URI has previously been rejected.
|
||||
req, err := d.state.DB.GetInteractionRequestByInteractionURI(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
statusURI,
|
||||
)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err := gtserror.Newf("db error getting interaction request: %w", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
if req != nil && req.IsRejected() {
|
||||
// This status has been
|
||||
// rejected reviously, so
|
||||
// it's not permitted now.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check if replied-to status has previously been rejected.
|
||||
req, err = d.state.DB.GetInteractionRequestByInteractionURI(
|
||||
gtscontext.SetBarebones(ctx),
|
||||
inReplyToURI,
|
||||
)
|
||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||
err := gtserror.Newf("db error getting interaction request: %w", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
if req != nil && req.IsRejected() {
|
||||
// This status's parent was rejected, so
|
||||
// implicitly this reply should be rejected too.
|
||||
//
|
||||
// We know already that we haven't inserted
|
||||
// a rejected interaction request for this
|
||||
// status yet so do it before returning.
|
||||
id := id.NewULID()
|
||||
|
||||
// To ensure the Reject chain stays coherent,
|
||||
// borrow fields from the up-thread rejection.
|
||||
// This collapses the chain beyond the first
|
||||
// rejected reply and allows us to avoid derefing
|
||||
// further replies we already know we don't want.
|
||||
statusID := req.StatusID
|
||||
targetAccountID := req.TargetAccountID
|
||||
|
||||
// As nobody is actually Rejecting the reply
|
||||
// directly, but it's an implicit Reject coming
|
||||
// from our internal logic, don't bother setting
|
||||
// a URI (it's not a required field anyway).
|
||||
uri := ""
|
||||
|
||||
rejection := >smodel.InteractionRequest{
|
||||
ID: id,
|
||||
StatusID: statusID,
|
||||
TargetAccountID: targetAccountID,
|
||||
InteractingAccountID: status.AccountID,
|
||||
InteractionURI: statusURI,
|
||||
InteractionType: gtsmodel.InteractionReply,
|
||||
URI: uri,
|
||||
RejectedAt: time.Now(),
|
||||
}
|
||||
err := d.state.DB.PutInteractionRequest(ctx, rejection)
|
||||
if err != nil && !errors.Is(err, db.ErrAlreadyExists) {
|
||||
return false, gtserror.Newf("db error putting pre-rejected interaction request: %w", err)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if inReplyTo == nil {
|
||||
// We didn't have the replied-to status in
|
||||
// our database (yet) so we can't know if
|
||||
// this reply is permitted or not. For now
|
||||
// just return true; worst-case, the status
|
||||
// sticks around on the instance for a couple
|
||||
// hours until we try to dereference it again
|
||||
// and realize it should be forbidden.
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if inReplyTo.BoostOfID != "" {
|
||||
// We do not permit replies to
|
||||
|
|
@ -142,8 +238,28 @@ func (d *Dereferencer) isPermittedReply(
|
|||
}
|
||||
|
||||
if replyable.Forbidden() {
|
||||
// Replier is not permitted
|
||||
// to do this interaction.
|
||||
// Reply is not permitted.
|
||||
//
|
||||
// Insert a pre-rejected interaction request
|
||||
// into the db and return. This ensures that
|
||||
// replies to this now-rejected status aren't
|
||||
// inadvertently permitted.
|
||||
id := id.NewULID()
|
||||
rejection := >smodel.InteractionRequest{
|
||||
ID: id,
|
||||
StatusID: inReplyTo.ID,
|
||||
TargetAccountID: inReplyTo.AccountID,
|
||||
InteractingAccountID: status.AccountID,
|
||||
InteractionURI: statusURI,
|
||||
InteractionType: gtsmodel.InteractionReply,
|
||||
URI: uris.GenerateURIForReject(inReplyTo.Account.Username, id),
|
||||
RejectedAt: time.Now(),
|
||||
}
|
||||
err := d.state.DB.PutInteractionRequest(ctx, rejection)
|
||||
if err != nil && !errors.Is(err, db.ErrAlreadyExists) {
|
||||
return false, gtserror.Newf("db error putting pre-rejected interaction request: %w", err)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
|
|
@ -193,7 +309,7 @@ func (d *Dereferencer) isPermittedReply(
|
|||
ctx,
|
||||
requestUser,
|
||||
status.ApprovedByURI,
|
||||
status.URI,
|
||||
statusURI,
|
||||
inReplyTo.AccountURI,
|
||||
); err != nil {
|
||||
|
||||
|
|
|
|||
|
|
@ -170,14 +170,22 @@ func (f *federatingActor) PostInboxScheme(ctx context.Context, w http.ResponseWr
|
|||
//
|
||||
// Post the activity to the Actor's inbox and trigger side effects.
|
||||
if err := f.sideEffectActor.PostInbox(ctx, inboxID, activity); err != nil {
|
||||
// Special case: We know it is a bad request if the object or target
|
||||
// props needed to be populated, or we failed parsing activity details.
|
||||
// Send the rejection to the peer.
|
||||
if errors.Is(err, pub.ErrObjectRequired) ||
|
||||
errors.Is(err, pub.ErrTargetRequired) ||
|
||||
gtserror.IsMalformed(err) {
|
||||
// Check if a function in the federatingDB
|
||||
// has returned an explicit errWithCode for us.
|
||||
if errWithCode, ok := err.(gtserror.WithCode); ok {
|
||||
return false, errWithCode
|
||||
}
|
||||
|
||||
// Check if it's a bad request because the
|
||||
// object or target props weren't populated,
|
||||
// or we failed parsing activity details.
|
||||
//
|
||||
// Log such activities to help debug, then
|
||||
// return the rejection (400) to the peer.
|
||||
if gtserror.IsMalformed(err) ||
|
||||
errors.Is(err, pub.ErrObjectRequired) ||
|
||||
errors.Is(err, pub.ErrTargetRequired) {
|
||||
|
||||
// Log malformed activities to help debug.
|
||||
l = l.WithField("activity", activity)
|
||||
l.Warnf("malformed incoming activity: %v", err)
|
||||
|
||||
|
|
@ -185,7 +193,7 @@ func (f *federatingActor) PostInboxScheme(ctx context.Context, w http.ResponseWr
|
|||
return false, gtserror.NewErrorBadRequest(errors.New(text), text)
|
||||
}
|
||||
|
||||
// There's been some real error.
|
||||
// Default: there's been some real error.
|
||||
err := gtserror.Newf("error calling sideEffectActor.PostInbox: %w", err)
|
||||
return false, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import (
|
|||
"errors"
|
||||
"net/url"
|
||||
|
||||
"codeberg.org/gruf/go-logger/v2/level"
|
||||
"github.com/superseriousbusiness/activity/streams/vocab"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
|
|
@ -46,15 +45,7 @@ func (f *federatingDB) GetAccept(
|
|||
}
|
||||
|
||||
func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error {
|
||||
if log.Level() >= level.DEBUG {
|
||||
i, err := marshalItem(accept)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l := log.WithContext(ctx).
|
||||
WithField("accept", i)
|
||||
l.Debug("entering Accept")
|
||||
}
|
||||
log.DebugKV(ctx, "accept", serialize{accept})
|
||||
|
||||
activityContext := getActivityContext(ctx)
|
||||
if activityContext.internal {
|
||||
|
|
@ -81,10 +72,9 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA
|
|||
// handling the ones we know how to handle.
|
||||
for _, object := range ap.ExtractObjects(accept) {
|
||||
if asType := object.GetType(); asType != nil {
|
||||
// Check and handle any
|
||||
// vocab.Type objects.
|
||||
// nolint:gocritic
|
||||
switch asType.GetTypeName() {
|
||||
|
||||
// Check and handle any vocab.Type objects.
|
||||
switch name := asType.GetTypeName(); name {
|
||||
|
||||
// ACCEPT FOLLOW
|
||||
case ap.ActivityFollow:
|
||||
|
|
@ -96,6 +86,10 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA
|
|||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// UNHANDLED
|
||||
default:
|
||||
log.Debugf(ctx, "unhandled object type: %s", name)
|
||||
}
|
||||
|
||||
} else if object.IsIRI() {
|
||||
|
|
@ -137,6 +131,10 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA
|
|||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// UNHANDLED
|
||||
default:
|
||||
log.Debugf(ctx, "unhandled iri type: %s", objIRI)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -311,7 +309,8 @@ func (f *federatingDB) acceptStatusIRI(
|
|||
return nil
|
||||
}
|
||||
|
||||
if util.PtrOrValue(status.PendingApproval, false) {
|
||||
pendingApproval := util.PtrOrValue(status.PendingApproval, false)
|
||||
if !pendingApproval {
|
||||
// Status doesn't need approval or it's
|
||||
// already been approved by an Accept.
|
||||
// Just return.
|
||||
|
|
@ -402,7 +401,8 @@ func (f *federatingDB) acceptLikeIRI(
|
|||
return nil
|
||||
}
|
||||
|
||||
if !util.PtrOrValue(fave.PendingApproval, false) {
|
||||
pendingApproval := util.PtrOrValue(fave.PendingApproval, false)
|
||||
if !pendingApproval {
|
||||
// Like doesn't need approval or it's
|
||||
// already been approved by an Accept.
|
||||
// Just return.
|
||||
|
|
|
|||