[feature] Store delivery errors, add remote instances overview to view stored errors (#4741)

- reworks the `instances` table significantly, to remove a lot of stuff we weren't using, and add fields for storing delivery errors
- adds an `instance_settings` table that just stores local instance settings like title, description, etc, and uses this table for building responses to `/api/v1/instance` and and `/api/v2/instance`, instance metadata, etc
- passes a database connection to delivery workers so that they can store delivery errors or clear delivery errors on successful delivery
- adds admin instance endpoints `/api/v1/admin/instances` and `/api/v1/admin/instances/{instance}` for fetching admin view of instances
- adds setting panel stuff for viewing instances and whatnot

Relates to https://codeberg.org/superseriousbusiness/gotosocial/issues/2493
Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4741
Co-authored-by: tobi <tobi.smethurst@protonmail.com>
Co-committed-by: tobi <tobi.smethurst@protonmail.com>
This commit is contained in:
tobi
2026-03-20 16:28:06 +01:00
committed by kim
parent e5f7b69df3
commit cc1316b498
74 changed files with 3282 additions and 951 deletions
+3 -3
View File
@@ -218,8 +218,8 @@ func Start(ctx context.Context) error {
if err := dbService.CreateInstanceAccount(ctx); err != nil {
return fmt.Errorf("error creating instance account: %s", err)
}
if err := dbService.CreateInstanceInstance(ctx); err != nil {
return fmt.Errorf("error creating instance instance: %s", err)
if err := dbService.CreateInstanceSettings(ctx); err != nil {
return fmt.Errorf("error creating instance settings: %s", err)
}
if err := dbService.CreateInstanceApplication(ctx); err != nil {
return fmt.Errorf("error creating instance application: %s", err)
@@ -375,7 +375,7 @@ func Start(ctx context.Context) error {
// Initialize the specialized workers pools.
state.Workers.Client.Init(messages.ClientMsgIndices())
state.Workers.Federator.Init(messages.FederatorMsgIndices())
state.Workers.Delivery.Init(client)
state.Workers.Delivery.Init(client, state.DB)
state.Workers.Client.Process = process.Workers().ProcessFromClientAPI
state.Workers.Federator.Process = process.Workers().ProcessFromFediAPI
+2 -2
View File
@@ -79,8 +79,8 @@ func main() {
log.Panicf(ctx, "error creating instance account: %s", err)
}
if err := state.DB.CreateInstanceInstance(ctx); err != nil {
log.Panicf(ctx, "error creating instance instance: %s", err)
if err := state.DB.CreateInstanceSettings(ctx); err != nil {
log.Panicf(ctx, "error creating instance settings: %s", err)
}
if err := state.DB.CreateInstanceApplication(ctx); err != nil {
+2 -2
View File
@@ -83,8 +83,8 @@ func main() {
log.Panicf(ctx, "error creating instance account: %s", err)
}
if err := state.DB.CreateInstanceInstance(ctx); err != nil {
log.Panicf(ctx, "error creating instance instance: %s", err)
if err := state.DB.CreateInstanceSettings(ctx); err != nil {
log.Panicf(ctx, "error creating instance settings: %s", err)
}
if err := state.DB.CreateInstanceApplication(ctx); err != nil {
+192
View File
@@ -947,6 +947,68 @@ definitions:
type: object
x-go-name: AdminEmoji
x-go-package: code.superseriousbusiness.org/gotosocial/internal/api/model
adminInstance:
properties:
delivery_errors:
description: |-
Last 20 errors from delivery attempts to this instance.
Cleared after a successful delivery.
Not set if delivery to this instance never attempted or delivery to this instance not errored since latest_successful_delivery time.
items:
$ref: '#/definitions/adminInstanceDeliveryError'
type: array
x-go-name: DeliveryErrors
domain:
description: Domain of this instance.
example: example.org
type: string
x-go-name: Domain
first_seen:
description: Time when the instance was first seen by this instance.
example: "2022-10-05T09:21:26.419Z"
type: string
x-go-name: FirstSeen
id:
description: ID of this instance.
example: 01KJQGK06SMJ8KDW6DCRRXTG8D
type: string
x-go-name: ID
latest_successful_delivery:
description: |-
Time of the latest successful delivery of a message to someone on this instance.
May be omitted if no messages have ever been delivered to this instance.
example: "2022-10-05T09:21:26.419Z"
type: string
x-go-name: LatestSuccessfulDelivery
software:
description: |-
Software this instance purports to be running.
May be omitted when the running software cannot be determined.
example: gotosocial
type: string
x-go-name: Software
title: AdminInstance models the admin view of an instance.
type: object
x-go-name: AdminInstance
x-go-package: code.superseriousbusiness.org/gotosocial/internal/api/model
adminInstanceDeliveryError:
description: |-
AdminInstanceDeliveryError models an error encountered
while trying to deliver a message to an inbox on an instance.
properties:
error:
description: Message for this delivery error.
example: boobs
type: string
x-go-name: Error
time:
description: Time of this delivery error.
example: "2022-10-05T09:21:26.419Z"
type: string
x-go-name: Time
type: object
x-go-name: AdminInstanceDeliveryError
x-go-package: code.superseriousbusiness.org/gotosocial/internal/api/model
adminReport:
properties:
account:
@@ -4091,6 +4153,7 @@ info:
admin:read:domain_allows: grants admin read access to domain allows
admin:read:domain_blocks: grants admin read access to domain blocks
admin:read:domain_limits: grants admin read access to domain limits
admin:read:instances: grants admin read access to instances
admin:read:reports: grants admin read access to reports
admin:write: grants admin write access to everything
admin:write:accounts: grants write read access to accounts
@@ -8945,6 +9008,134 @@ paths:
summary: Update an existing instance rule.
tags:
- admin
/api/v1/admin/instances:
get:
description: |-
The instances will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).
The next and previous queries can be parsed from the returned Link header.
Example:
```
<https://example.org/api/v1/admin/instances?limit=40&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/instances?limit=40&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
````
operationId: adminInstances
parameters:
- description: Filter by the given domain.
in: query
name: domain
type: string
- default: latest
description: Order by default "first_seen" (newest -> oldest) or "alphabetical" (a -> z).
in: query
name: order
type: string
- default: false
description: Only include instances that have one or more delivery errors since the last successful delivery.
in: query
name: with_errors_only
type: boolean
- description: Return only items *OLDER* than the given max ID (for paging downwards). The item with the specified ID will not be included in the response.
in: query
name: max_id
type: string
- description: Return only items *NEWER* than the given since ID. The item with the specified ID will not be included in the response.
in: query
name: since_id
type: string
- description: Return only items immediately *NEWER* than the given min ID (for paging upwards). The item with the specified ID will not be included in the response.
in: query
name: min_id
type: string
- default: 40
description: Number of items to return.
in: query
maximum: 100
minimum: 1
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: Array of admin model instances.
headers:
Link:
description: Links to the next and previous queries.
type: string
schema:
items:
$ref: '#/definitions/adminInstance'
type: array
"400":
description: bad request
schema:
$ref: '#/definitions/error'
"401":
description: unauthorized
schema:
$ref: '#/definitions/error'
"404":
description: not found
schema:
$ref: '#/definitions/error'
"406":
description: not acceptable
schema:
$ref: '#/definitions/error'
"500":
description: internal server error
schema:
$ref: '#/definitions/error'
security:
- OAuth2 Bearer:
- admin:read:instances
summary: Show admin view of instances.
tags:
- admin
/api/v1/admin/instances/{id}:
get:
operationId: adminInstanceGet
parameters:
- description: The id of the instance.
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: Admin model instance.
schema:
$ref: '#/definitions/adminInstance'
"400":
description: bad request
schema:
$ref: '#/definitions/error'
"401":
description: unauthorized
schema:
$ref: '#/definitions/error'
"404":
description: not found
schema:
$ref: '#/definitions/error'
"406":
description: not acceptable
schema:
$ref: '#/definitions/error'
"500":
description: internal server error
schema:
$ref: '#/definitions/error'
security:
- OAuth2 Bearer:
- admin:read:instances
summary: Show admin view of one instance.
tags:
- admin
/api/v1/admin/media_cleanup:
post:
consumes:
@@ -16927,6 +17118,7 @@ securityDefinitions:
admin:read:domain_allows: grants admin read access to domain allows
admin:read:domain_blocks: grants admin read access to domain blocks
admin:read:domain_limits: grants admin read access to domain limits
admin:read:instances: grants admin read access to instances
admin:read:reports: grants admin read access to reports
admin:write: grants admin write access to everything
admin:write:accounts: grants write read access to accounts
+2
View File
@@ -30,6 +30,7 @@
// - admin:read:domain_allows: grants admin read access to domain allows
// - admin:read:domain_blocks: grants admin read access to domain blocks
// - admin:read:domain_limits: grants admin read access to domain limits
// - admin:read:instances: grants admin read access to instances
// - admin:read:reports: grants admin read access to reports
// - admin:write: grants admin write access to everything
// - admin:write:accounts: grants write read access to accounts
@@ -93,6 +94,7 @@
// admin:read:domain_allows: grants admin read access to domain allows
// admin:read:domain_blocks: grants admin read access to domain blocks
// admin:read:domain_limits: grants admin read access to domain limits
// admin:read:instances: grants admin read access to instances
// admin:read:reports: grants admin read access to reports
// admin:write: grants admin write access to everything
// admin:write:accounts: grants write read access to accounts
+1 -41
View File
@@ -208,28 +208,10 @@ func (a *Actions) domainBlockSideEffects(
ctx context.Context,
block *gtsmodel.DomainBlock,
) gtserror.MultiError {
var errs gtserror.MultiError
// If we have an instance entry for this domain,
// update it with the new block ID and clear all fields
instance, err := a.db.GetInstance(ctx, block.Domain)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
errs.Appendf("db error getting instance %s: %w", block.Domain, err)
return errs
}
if instance != nil {
// We had an entry for this domain.
columns := stubbifyInstance(instance, block.ID)
if err := a.db.UpdateInstance(ctx, instance, columns...); err != nil {
errs.Appendf("db error updating instance: %w", err)
return errs
}
}
// For each account that belongs to this domain,
// process an account delete message to remove
// that account's posts, media, etc.
var errs gtserror.MultiError
if err := a.rangeDomainAccounts(ctx, block.Domain, func(account *gtsmodel.Account) {
if err := a.workers.Client.Process(ctx, &messages.FromClientAPI{
APObjectType: ap.ActorPerson,
@@ -283,28 +265,6 @@ func (a *Actions) domainUnblockSideEffects(
) gtserror.MultiError {
var errs gtserror.MultiError
// Update instance entry for this domain, if we have it.
instance, err := a.db.GetInstance(ctx, block.Domain)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
errs.Appendf("db error getting instance %s: %w", block.Domain, err)
}
if instance != nil {
// We had an entry, update it to signal
// that it's no longer suspended.
instance.SuspendedAt = time.Time{}
instance.DomainBlockID = ""
if err := a.db.UpdateInstance(
ctx,
instance,
"suspended_at",
"domain_block_id",
); err != nil {
errs.Appendf("db error updating instance: %w", err)
return errs
}
}
// Unsuspend all accounts whose suspension origin was this domain block.
if err := a.rangeDomainAccounts(ctx, block.Domain, func(account *gtsmodel.Account) {
if account.SuspensionOrigin == "" || account.SuspendedAt.IsZero() {
-33
View File
@@ -20,45 +20,12 @@ package admin
import (
"context"
"errors"
"time"
"code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
)
// stubbifyInstance renders the given instance as a stub,
// removing most information from it and marking it as
// suspended.
//
// For caller's convenience, this function returns the db
// names of all columns that are updated by it.
func stubbifyInstance(instance *gtsmodel.Instance, domainBlockID string) []string {
instance.Title = ""
instance.SuspendedAt = time.Now()
instance.DomainBlockID = domainBlockID
instance.ShortDescription = ""
instance.Description = ""
instance.Terms = ""
instance.ContactEmail = ""
instance.ContactAccountUsername = ""
instance.ContactAccountID = ""
instance.Version = ""
return []string{
"title",
"suspended_at",
"domain_block_id",
"short_description",
"description",
"terms",
"contact_email",
"contact_account_username",
"contact_account_id",
"version",
}
}
// rangeDomainAccounts iterates through all accounts
// originating from the given domain, and calls the
// provided range function on each account.
+6
View File
@@ -69,6 +69,8 @@ const (
EmailTestPath = EmailPath + "/test"
InstanceRulesPath = BasePath + "/instance/rules"
InstanceRulesPathWithID = InstanceRulesPath + "/:" + apiutil.IDKey
InstancesPath = BasePath + "/instances"
InstancesPathWithID = InstancesPath + "/:" + apiutil.IDKey
FilterQueryKey = "filter"
MaxShortcodeDomainKey = "max_shortcode_domain"
@@ -179,4 +181,8 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
attachHandler(http.MethodPost, InstanceRulesPath, m.RulePOSTHandler)
attachHandler(http.MethodPatch, InstanceRulesPathWithID, m.RulePATCHHandler)
attachHandler(http.MethodDelete, InstanceRulesPathWithID, m.RuleDELETEHandler)
// instances stuff
attachHandler(http.MethodGet, InstancesPath, m.InstancesGETHandler)
attachHandler(http.MethodGet, InstancesPathWithID, m.InstanceGETHandler)
}
@@ -182,7 +182,7 @@ func (m *Module) DomainPermissionDraftsGETHandler(c *gin.Context) {
resp, errWithCode := m.processor.Admin().DomainPermissionDraftsGet(
c.Request.Context(),
c.Query(apiutil.DomainPermissionSubscriptionIDKey),
c.Query(apiutil.DomainPermissionDomainKey),
c.Query(apiutil.DomainKey),
permType,
page,
)
@@ -157,7 +157,7 @@ func (m *Module) DomainPermissionExcludesGETHandler(c *gin.Context) {
resp, errWithCode := m.processor.Admin().DomainPermissionExcludesGet(
c.Request.Context(),
c.Query(apiutil.DomainPermissionDomainKey),
c.Query(apiutil.DomainKey),
page,
)
if errWithCode != nil {
+115
View File
@@ -0,0 +1,115 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package admin
import (
"fmt"
"net/http"
apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"github.com/gin-gonic/gin"
)
// InstanceGETHandler swagger:operation GET /api/v1/admin/instances/{id} adminInstanceGet
//
// Show admin view of one instance.
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: id
// type: string
// description: The id of the instance.
// in: path
// required: true
//
// security:
// - OAuth2 Bearer:
// - admin:read:instances
//
// responses:
// '200':
// name: instances
// description: Admin model instance.
// schema:
// "$ref": "#/definitions/adminInstance"
// '400':
// schema:
// "$ref": "#/definitions/error"
// description: bad request
// '401':
// schema:
// "$ref": "#/definitions/error"
// description: unauthorized
// '404':
// schema:
// "$ref": "#/definitions/error"
// description: not found
// '406':
// schema:
// "$ref": "#/definitions/error"
// description: not acceptable
// '500':
// schema:
// "$ref": "#/definitions/error"
// description: internal server error
func (m *Module) InstanceGETHandler(c *gin.Context) {
authed, errWithCode := apiutil.TokenAuth(c,
true, true, true, true,
apiutil.ScopeAdminReadInstances,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, errWithCode := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
id, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
resp, errWithCode := m.processor.Admin().InstanceGet(
c.Request.Context(),
id,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
apiutil.JSON(c, http.StatusOK, resp)
}
+202
View File
@@ -0,0 +1,202 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package admin
import (
"fmt"
"net/http"
apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/paging"
"github.com/gin-gonic/gin"
)
// InstancesGETHandler swagger:operation GET /api/v1/admin/instances adminInstances
//
// Show admin view of instances.
//
// The instances will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).
//
// The next and previous queries can be parsed from the returned Link header.
//
// Example:
//
// ```
// <https://example.org/api/v1/admin/instances?limit=40&max_id=01FC0SKA48HNSVR6YKZCQGS2V8>; rel="next", <https://example.org/api/v1/admin/instances?limit=40&min_id=01FC0SKW5JK2Q4EVAV2B462YY0>; rel="prev"
// ````
//
// ---
// tags:
// - admin
//
// produces:
// - application/json
//
// parameters:
// -
// name: domain
// in: query
// type: string
// description: Filter by the given domain.
// -
// name: order
// in: query
// type: string
// description: Order by default "first_seen" (newest -> oldest) or "alphabetical" (a -> z).
// default: latest
// -
// name: with_errors_only
// in: query
// type: boolean
// description: Only include instances that have one or more delivery errors since the last successful delivery.
// default: false
// -
// name: max_id
// type: string
// description: >-
// Return only items *OLDER* than the given max ID (for paging downwards).
// The item with the specified ID will not be included in the response.
// in: query
// -
// name: since_id
// type: string
// description: >-
// Return only items *NEWER* than the given since ID.
// The item with the specified ID will not be included in the response.
// in: query
// -
// name: min_id
// type: string
// description: >-
// Return only items immediately *NEWER* than the given min ID (for paging upwards).
// The item with the specified ID will not be included in the response.
// in: query
// -
// name: limit
// type: integer
// description: Number of items to return.
// default: 40
// minimum: 1
// maximum: 100
// in: query
//
// security:
// - OAuth2 Bearer:
// - admin:read:instances
//
// responses:
// '200':
// name: instances
// description: Array of admin model instances.
// schema:
// type: array
// items:
// "$ref": "#/definitions/adminInstance"
// headers:
// Link:
// type: string
// description: Links to the next and previous queries.
// '400':
// schema:
// "$ref": "#/definitions/error"
// description: bad request
// '401':
// schema:
// "$ref": "#/definitions/error"
// description: unauthorized
// '404':
// schema:
// "$ref": "#/definitions/error"
// description: not found
// '406':
// schema:
// "$ref": "#/definitions/error"
// description: not acceptable
// '500':
// schema:
// "$ref": "#/definitions/error"
// description: internal server error
func (m *Module) InstancesGETHandler(c *gin.Context) {
authed, errWithCode := apiutil.TokenAuth(c,
true, true, true, true,
apiutil.ScopeAdminReadInstances,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, errWithCode := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
page, errWithCode := paging.ParseIDPage(c,
1, // min limit
100, // max limit
40, // default limit
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
orderBy, errWithCode := apiutil.ParseInstancesOrder(
c.Query(apiutil.OrderKey),
gtsmodel.InstanceOrderByFirstSeen,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
withErrorsOnly, errWithCode := apiutil.ParseAdminWithErrorsOnly(
c.Query(apiutil.AdminWithErrorsOnlyKey),
false,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
resp, errWithCode := m.processor.Admin().InstancesGet(
c.Request.Context(),
page,
c.Query(apiutil.DomainKey),
orderBy,
withErrorsOnly,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
if resp.LinkHeader != "" {
c.Header("Link", resp.LinkHeader)
}
apiutil.JSON(c, http.StatusOK, resp.Items)
}
@@ -158,7 +158,7 @@ func (m *Module) DirectoryGETHandler(c *gin.Context) {
// Parse order (default "active").
orderBy, errWithCode := apiutil.ParseDirectoryOrder(
c.Query(apiutil.DirectoryOrderKey),
c.Query(apiutil.OrderKey),
gtsmodel.DirectoryOrderByActive,
)
if errWithCode != nil {
+16 -61
View File
@@ -22,92 +22,47 @@ import (
"fmt"
"net/http/httptest"
"code.superseriousbusiness.org/gotosocial/internal/admin"
"code.superseriousbusiness.org/gotosocial/internal/api/client/instance"
"code.superseriousbusiness.org/gotosocial/internal/config"
"code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/email"
"code.superseriousbusiness.org/gotosocial/internal/federation"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/media"
"code.superseriousbusiness.org/gotosocial/internal/oauth"
"code.superseriousbusiness.org/gotosocial/internal/processing"
"code.superseriousbusiness.org/gotosocial/internal/state"
"code.superseriousbusiness.org/gotosocial/internal/storage"
"code.superseriousbusiness.org/gotosocial/testrig"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/suite"
)
type InstanceStandardTestSuite struct {
// standard suite interfaces
suite.Suite
db db.DB
storage *storage.Driver
mediaManager *media.Manager
federator *federation.Federator
processor *processing.Processor
emailSender email.Sender
sentEmails map[string]string
state state.State
// standard suite models
testTokens map[string]*gtsmodel.Token
testApplications map[string]*gtsmodel.Application
testUsers map[string]*gtsmodel.User
testAccounts map[string]*gtsmodel.Account
testAttachments map[string]*gtsmodel.MediaAttachment
testStatuses map[string]*gtsmodel.Status
// module being tested
instanceModule *instance.Module
}
const (
rMediaPath = "../../../../testrig/media"
rTemplatePath = "../../../../web/template/"
)
func (suite *InstanceStandardTestSuite) SetupSuite() {
testrig.InitTestConfig()
testrig.InitTestLog()
suite.testTokens = testrig.NewTestTokens()
suite.testApplications = testrig.NewTestApplications()
suite.testUsers = testrig.NewTestUsers()
suite.testAccounts = testrig.NewTestAccounts()
suite.testAttachments = testrig.NewTestAttachments()
suite.testStatuses = testrig.NewTestStatuses()
}
func (suite *InstanceStandardTestSuite) SetupTest() {
suite.state.Caches.Init()
testrig.StartNoopWorkers(&suite.state)
testrig.InitTestConfig()
testrig.InitTestLog()
suite.db = testrig.NewTestDB(&suite.state)
suite.state.DB = suite.db
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
suite.storage = testrig.NewInMemoryStorage()
suite.state.Storage = suite.storage
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../../testrig/media")), suite.mediaManager)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../../web/template/", suite.sentEmails)
suite.processor = testrig.NewTestProcessor(
&suite.state,
suite.federator,
suite.emailSender,
testrig.NewNoopWebPushSender(),
suite.mediaManager,
)
suite.instanceModule = instance.New(suite.processor)
testrig.StandardDBSetup(suite.db, nil)
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
}
func (suite *InstanceStandardTestSuite) TearDownTest() {
testrig.StandardDBTeardown(suite.db)
testrig.StandardStorageTeardown(suite.storage)
testrig.StopWorkers(&suite.state)
}
func (suite *InstanceStandardTestSuite) newContext(recorder *httptest.ResponseRecorder, method string, path string, body []byte, contentType string, auth bool) *gin.Context {
func (suite *InstanceStandardTestSuite) newContext(
recorder *httptest.ResponseRecorder,
method string,
path string,
body []byte,
contentType string,
auth bool,
) *gin.Context {
protocol := config.GetProtocol()
host := config.GetHost()
@@ -26,8 +26,6 @@ import (
"testing"
"code.superseriousbusiness.org/gotosocial/internal/api/client/instance"
"code.superseriousbusiness.org/gotosocial/internal/middleware"
"code.superseriousbusiness.org/gotosocial/internal/oauth"
"code.superseriousbusiness.org/gotosocial/testrig"
"github.com/stretchr/testify/suite"
)
@@ -36,7 +34,12 @@ type InstancePatchTestSuite struct {
InstanceStandardTestSuite
}
func (suite *InstancePatchTestSuite) instancePatch(fieldName string, fileName string, extraFields map[string][]string) (code int, body []byte) {
func (suite *InstancePatchTestSuite) instancePatch(
module *instance.Module,
fieldName string,
fileName string,
extraFields map[string][]string,
) (code int, body []byte) {
var dataF testrig.DataF
if fieldName != "" && fileName != "" {
dataF = testrig.FileToDataF(fieldName, fileName)
@@ -48,10 +51,17 @@ func (suite *InstancePatchTestSuite) instancePatch(fieldName string, fileName st
}
recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPatch, instance.InstanceInformationPathV1, requestBody.Bytes(), w.FormDataContentType(), true)
ctx := suite.newContext(
recorder,
http.MethodPatch,
instance.InstanceInformationPathV1,
requestBody.Bytes(),
w.FormDataContentType(),
true, // auth
)
suite.instanceModule.InstanceUpdatePATCHHandler(ctx)
middleware.Logger(false)(ctx)
// Call the handler.
module.InstanceUpdatePATCHHandler(ctx)
result := recorder.Result()
defer result.Body.Close()
@@ -64,12 +74,20 @@ func (suite *InstancePatchTestSuite) instancePatch(fieldName string, fileName st
return recorder.Code, b
}
func (suite *InstancePatchTestSuite) TestInstancePatch1() {
code, b := suite.instancePatch("", "", map[string][]string{
"title": {"Example Instance"},
"contact_username": {"admin"},
"contact_email": {"someone@example.org"},
})
func (suite *InstancePatchTestSuite) TestInstancePatchUpdateInstanceInfo() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
code, b := suite.instancePatch(
instanceModule,
"", "",
map[string][]string{
"title": {"Example Instance"},
"contact_username": {"admin"},
"contact_email": {"someone@example.org"},
},
)
if expectedCode := http.StatusOK; code != expectedCode {
suite.FailNowf("wrong status code", "expected %d but got %d", expectedCode, code)
@@ -157,7 +175,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch1() {
"streaming_api": "wss://localhost:8080"
},
"stats": {
"domain_count": 2,
"domain_count": 4,
"status_count": 24,
"user_count": 5
},
@@ -212,10 +230,17 @@ func (suite *InstancePatchTestSuite) TestInstancePatch1() {
}`, dst.String())
}
func (suite *InstancePatchTestSuite) TestInstancePatch2() {
code, b := suite.instancePatch("", "", map[string][]string{
"title": {"<p>Geoff's Instance</p>"},
})
func (suite *InstancePatchTestSuite) TestInstancePatchUpdateTitleHTML() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
code, b := suite.instancePatch(
instanceModule,
"", "", map[string][]string{
"title": {"<p>Geoff's Instance</p>"},
},
)
if expectedCode := http.StatusOK; code != expectedCode {
suite.FailNowf("wrong status code", "expected %d but got %d", expectedCode, code)
@@ -303,7 +328,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch2() {
"streaming_api": "wss://localhost:8080"
},
"stats": {
"domain_count": 2,
"domain_count": 4,
"status_count": 24,
"user_count": 5
},
@@ -358,10 +383,17 @@ func (suite *InstancePatchTestSuite) TestInstancePatch2() {
}`, dst.String())
}
func (suite *InstancePatchTestSuite) TestInstancePatch3() {
code, b := suite.instancePatch("", "", map[string][]string{
"short_description": {"This is some html, which is <em>allowed</em> in short descriptions."},
})
func (suite *InstancePatchTestSuite) TestInstancePatchUpdateShortDescriptionHTML() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
code, b := suite.instancePatch(
instanceModule,
"", "", map[string][]string{
"short_description": {"This is some html, which is <em>allowed</em> in short descriptions."},
},
)
if expectedCode := http.StatusOK; code != expectedCode {
suite.FailNowf("wrong status code", "expected %d but got %d", expectedCode, code)
@@ -449,7 +481,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() {
"streaming_api": "wss://localhost:8080"
},
"stats": {
"domain_count": 2,
"domain_count": 4,
"status_count": 24,
"user_count": 5
},
@@ -504,10 +536,17 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() {
}`, dst.String())
}
func (suite *InstancePatchTestSuite) TestInstancePatch4() {
code, b := suite.instancePatch("", "", map[string][]string{
"": {""},
})
func (suite *InstancePatchTestSuite) TestInstancePatchEmptyForm() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
code, b := suite.instancePatch(
instanceModule,
"", "", map[string][]string{
"": {""},
},
)
if expectedCode := http.StatusBadRequest; code != expectedCode {
suite.FailNowf("wrong status code", "expected %d but got %d", expectedCode, code)
@@ -521,44 +560,17 @@ func (suite *InstancePatchTestSuite) TestInstancePatch4() {
suite.Equal(`{"error":"Bad Request: empty form submitted"}`, string(b))
}
func (suite *InstancePatchTestSuite) TestInstancePatch5() {
requestBody, w, err := testrig.CreateMultipartFormData(
nil,
map[string][]string{
"short_description": {"<p>This is some html, which is <em>allowed</em> in short descriptions.</p>"},
})
if err != nil {
panic(err)
}
bodyBytes := requestBody.Bytes()
func (suite *InstancePatchTestSuite) TestInstancePatchEmptyContactEmail() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
// set up the request
recorder := httptest.NewRecorder()
ctx := suite.newContext(recorder, http.MethodPatch, instance.InstanceInformationPathV1, bodyBytes, w.FormDataContentType(), true)
ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"]))
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
// call the handler
suite.instanceModule.InstanceUpdatePATCHHandler(ctx)
suite.Equal(http.StatusForbidden, recorder.Code)
result := recorder.Result()
defer result.Body.Close()
b, err := io.ReadAll(result.Body)
suite.NoError(err)
suite.Equal(`{"error":"Forbidden: token has insufficient scope permission"}`, string(b))
}
func (suite *InstancePatchTestSuite) TestInstancePatch6() {
code, b := suite.instancePatch("", "", map[string][]string{
"contact_email": {""},
})
code, b := suite.instancePatch(
instanceModule,
"", "", map[string][]string{
"contact_email": {""},
},
)
if expectedCode := http.StatusOK; code != expectedCode {
suite.FailNowf("wrong status code", "expected %d but got %d", expectedCode, code)
@@ -646,7 +658,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch6() {
"streaming_api": "wss://localhost:8080"
},
"stats": {
"domain_count": 2,
"domain_count": 4,
"status_count": 24,
"user_count": 5
},
@@ -701,10 +713,18 @@ func (suite *InstancePatchTestSuite) TestInstancePatch6() {
}`, dst.String())
}
func (suite *InstancePatchTestSuite) TestInstancePatch7() {
code, b := suite.instancePatch("", "", map[string][]string{
"contact_email": {"not.an.email.address"},
})
func (suite *InstancePatchTestSuite) TestInstancePatchInvalidEmailAddress() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
code, b := suite.instancePatch(
instanceModule,
"", "",
map[string][]string{
"contact_email": {"not.an.email.address"},
},
)
if expectedCode := http.StatusBadRequest; code != expectedCode {
suite.FailNowf("wrong status code", "expected %d but got %d", expectedCode, code)
@@ -718,10 +738,18 @@ func (suite *InstancePatchTestSuite) TestInstancePatch7() {
suite.Equal(`{"error":"Bad Request: mail: missing '@' or angle-addr"}`, string(b))
}
func (suite *InstancePatchTestSuite) TestInstancePatch8() {
code, b := suite.instancePatch("thumbnail", "../../../../testrig/media/peglin.gif", map[string][]string{
"thumbnail_description": {"A bouncing little green peglin."},
})
func (suite *InstancePatchTestSuite) TestInstancePatchUpdateThumbnail() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
code, b := suite.instancePatch(
instanceModule,
"thumbnail", "../../../../testrig/media/peglin.gif",
map[string][]string{
"thumbnail_description": {"A bouncing little green peglin."},
},
)
if expectedCode := http.StatusOK; code != expectedCode {
suite.FailNowf("wrong status code", "expected %d but got %d", expectedCode, code)
@@ -732,7 +760,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() {
suite.FailNow(err.Error())
}
instanceAccount, err := suite.db.GetInstanceAccount(suite.T().Context(), "")
instanceAccount, err := testStructs.State.DB.GetInstanceAccount(suite.T().Context(), "")
if err != nil {
suite.FailNow(err.Error())
}
@@ -814,7 +842,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() {
"streaming_api": "wss://localhost:8080"
},
"stats": {
"domain_count": 2,
"domain_count": 4,
"status_count": 24,
"user_count": 5
},
@@ -873,7 +901,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() {
}`, dst.String())
// extra bonus: check the v2 model thumbnail after the patch
instanceV2, err := suite.processor.InstanceGetV2(suite.T().Context())
instanceV2, err := testStructs.Processor.InstanceGetV2(suite.T().Context())
if err != nil {
suite.FailNow(err.Error())
}
@@ -893,9 +921,13 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() {
}`, string(instanceV2ThumbnailJson))
// double extra special bonus: now update the image description without changing the image
code2, b2 := suite.instancePatch("", "", map[string][]string{
"thumbnail_description": {"updating the thumbnail description without changing anything else!"},
})
code2, b2 := suite.instancePatch(
instanceModule,
"", "",
map[string][]string{
"thumbnail_description": {"updating the thumbnail description without changing anything else!"},
},
)
if expectedCode := http.StatusOK; code2 != expectedCode {
suite.FailNowf("wrong status code", "expected %d but got %d", expectedCode, code2)
@@ -910,10 +942,18 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() {
suite.EqualValues("updating the thumbnail description without changing anything else!", i["thumbnail_description"])
}
func (suite *InstancePatchTestSuite) TestInstancePatch9() {
code, b := suite.instancePatch("", "", map[string][]string{
"thumbnail_description": {"setting a new description without having a custom image set; this should change nothing!"},
})
func (suite *InstancePatchTestSuite) TestInstancePatchUpdateThumbnailDescription() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
code, b := suite.instancePatch(
instanceModule,
"", "",
map[string][]string{
"thumbnail_description": {"setting a new description without having a custom image set; this should change nothing!"},
},
)
if expectedCode := http.StatusOK; code != expectedCode {
suite.FailNowf("wrong status code", "expected %d but got %d", expectedCode, code)
@@ -1001,7 +1041,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch9() {
"streaming_api": "wss://localhost:8080"
},
"stats": {
"domain_count": 2,
"domain_count": 4,
"status_count": 24,
"user_count": 5
},
@@ -40,6 +40,10 @@ type InstancePeersGetTestSuite struct {
}
func (suite *InstancePeersGetTestSuite) TestInstancePeersGetNoParams() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
recorder := httptest.NewRecorder()
ctx, r := testrig.CreateGinTestContext(recorder, nil)
r.HTMLRender = render.HTMLDebug{}
@@ -48,7 +52,7 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetNoParams() {
requestURI := fmt.Sprintf("%s/%s", baseURI, instance.InstancePeersPath)
ctx.Request = httptest.NewRequest(http.MethodGet, requestURI, nil)
suite.instanceModule.InstancePeersGETHandler(ctx)
instanceModule.InstancePeersGETHandler(ctx)
suite.Equal(http.StatusOK, recorder.Code)
@@ -62,11 +66,17 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetNoParams() {
suite.NoError(err)
suite.Equal(`[
"example.org",
"fossbros-anonymous.io"
"fossbros-anonymous.io",
"thequeenisstillalive.technology",
"ëxample.org"
]`, dst.String())
}
func (suite *InstancePeersGetTestSuite) TestInstancePeersGetNoParamsUnauthorized() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
config.SetInstanceExposePeers(false)
recorder := httptest.NewRecorder()
@@ -74,7 +84,7 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetNoParamsUnauthorized
requestURI := fmt.Sprintf("%s/%s", baseURI, instance.InstancePeersPath)
ctx := suite.newContext(recorder, http.MethodGet, requestURI, nil, "", false)
suite.instanceModule.InstancePeersGETHandler(ctx)
instanceModule.InstancePeersGETHandler(ctx)
suite.Equal(http.StatusUnauthorized, recorder.Code)
@@ -88,6 +98,10 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetNoParamsUnauthorized
}
func (suite *InstancePeersGetTestSuite) TestInstancePeersGetNoParamsAuthorized() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
config.SetInstanceExposePeers(false)
recorder := httptest.NewRecorder()
@@ -95,7 +109,7 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetNoParamsAuthorized()
requestURI := fmt.Sprintf("%s/%s", baseURI, instance.InstancePeersPath)
ctx := suite.newContext(recorder, http.MethodGet, requestURI, nil, "", true)
suite.instanceModule.InstancePeersGETHandler(ctx)
instanceModule.InstancePeersGETHandler(ctx)
suite.Equal(http.StatusOK, recorder.Code)
@@ -109,17 +123,23 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetNoParamsAuthorized()
suite.NoError(err)
suite.Equal(`[
"example.org",
"fossbros-anonymous.io"
"fossbros-anonymous.io",
"thequeenisstillalive.technology",
"ëxample.org"
]`, dst.String())
}
func (suite *InstancePeersGetTestSuite) TestInstancePeersGetOnlySuspended() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
recorder := httptest.NewRecorder()
baseURI := fmt.Sprintf("%s://%s", config.GetProtocol(), config.GetHost())
requestURI := fmt.Sprintf("%s/%s?filter=suspended", baseURI, instance.InstancePeersPath)
ctx := suite.newContext(recorder, http.MethodGet, requestURI, nil, "", false)
suite.instanceModule.InstancePeersGETHandler(ctx)
instanceModule.InstancePeersGETHandler(ctx)
suite.Equal(http.StatusOK, recorder.Code)
@@ -142,6 +162,10 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetOnlySuspended() {
}
func (suite *InstancePeersGetTestSuite) TestInstancePeersGetOnlySuspendedUnauthorized() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
config.SetInstanceExposeBlocklist(false)
recorder := httptest.NewRecorder()
@@ -149,7 +173,7 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetOnlySuspendedUnautho
requestURI := fmt.Sprintf("%s/%s?filter=suspended", baseURI, instance.InstancePeersPath)
ctx := suite.newContext(recorder, http.MethodGet, requestURI, nil, "", false)
suite.instanceModule.InstancePeersGETHandler(ctx)
instanceModule.InstancePeersGETHandler(ctx)
suite.Equal(http.StatusUnauthorized, recorder.Code)
@@ -163,6 +187,10 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetOnlySuspendedUnautho
}
func (suite *InstancePeersGetTestSuite) TestInstancePeersGetOnlySuspendedAuthorized() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
config.SetInstanceExposeBlocklist(false)
recorder := httptest.NewRecorder()
@@ -170,7 +198,7 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetOnlySuspendedAuthori
requestURI := fmt.Sprintf("%s/%s?filter=suspended", baseURI, instance.InstancePeersPath)
ctx := suite.newContext(recorder, http.MethodGet, requestURI, nil, "", true)
suite.instanceModule.InstancePeersGETHandler(ctx)
instanceModule.InstancePeersGETHandler(ctx)
suite.Equal(http.StatusOK, recorder.Code)
@@ -193,12 +221,16 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetOnlySuspendedAuthori
}
func (suite *InstancePeersGetTestSuite) TestInstancePeersGetAll() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
recorder := httptest.NewRecorder()
baseURI := fmt.Sprintf("%s://%s", config.GetProtocol(), config.GetHost())
requestURI := fmt.Sprintf("%s/%s?filter=suspended,open", baseURI, instance.InstancePeersPath)
ctx := suite.newContext(recorder, http.MethodGet, requestURI, nil, "", false)
suite.instanceModule.InstancePeersGETHandler(ctx)
instanceModule.InstancePeersGETHandler(ctx)
suite.Equal(http.StatusOK, recorder.Code)
@@ -222,17 +254,27 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetAll() {
"suspended_at": "2020-05-13T13:29:12.000Z",
"comment": "reply-guying to tech posts",
"severity": "suspend"
},
{
"domain": "thequeenisstillalive.technology"
},
{
"domain": "ëxample.org"
}
]`, dst.String())
}
func (suite *InstancePeersGetTestSuite) TestInstancePeersGetAllowed() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
recorder := httptest.NewRecorder()
baseURI := fmt.Sprintf("%s://%s", config.GetProtocol(), config.GetHost())
requestURI := fmt.Sprintf("%s/%s?filter=allowed", baseURI, instance.InstancePeersPath)
ctx := suite.newContext(recorder, http.MethodGet, requestURI, nil, "", false)
suite.instanceModule.InstancePeersGETHandler(ctx)
instanceModule.InstancePeersGETHandler(ctx)
suite.Equal(http.StatusOK, recorder.Code)
@@ -248,7 +290,11 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetAllowed() {
}
func (suite *InstancePeersGetTestSuite) TestInstancePeersGetAllWithObfuscated() {
err := suite.db.Put(suite.T().Context(), &gtsmodel.DomainBlock{
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
err := testStructs.State.DB.Put(suite.T().Context(), &gtsmodel.DomainBlock{
ID: "01G633XTNK51GBADQZFZQDP6WR",
CreatedAt: testrig.TimeMustParse("2021-06-09T12:34:55+02:00"),
UpdatedAt: testrig.TimeMustParse("2021-06-09T12:34:55+02:00"),
@@ -264,7 +310,7 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetAllWithObfuscated()
requestURI := fmt.Sprintf("%s/%s?filter=suspended,open", baseURI, instance.InstancePeersPath)
ctx := suite.newContext(recorder, http.MethodGet, requestURI, nil, "", false)
suite.instanceModule.InstancePeersGETHandler(ctx)
instanceModule.InstancePeersGETHandler(ctx)
suite.Equal(http.StatusOK, recorder.Code)
@@ -294,12 +340,22 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetAllWithObfuscated()
"suspended_at": "2020-05-13T13:29:12.000Z",
"comment": "reply-guying to tech posts",
"severity": "suspend"
},
{
"domain": "thequeenisstillalive.technology"
},
{
"domain": "ëxample.org"
}
]`, dst.String())
}
func (suite *InstancePeersGetTestSuite) TestInstancePeersGetAllWithObfuscatedFlat() {
err := suite.db.Put(suite.T().Context(), &gtsmodel.DomainBlock{
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
err := testStructs.State.DB.Put(suite.T().Context(), &gtsmodel.DomainBlock{
ID: "01G633XTNK51GBADQZFZQDP6WR",
CreatedAt: testrig.TimeMustParse("2021-06-09T12:34:55+02:00"),
UpdatedAt: testrig.TimeMustParse("2021-06-09T12:34:55+02:00"),
@@ -315,7 +371,7 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetAllWithObfuscatedFla
requestURI := fmt.Sprintf("%s/%s?filter=suspended,open&flat=true", baseURI, instance.InstancePeersPath)
ctx := suite.newContext(recorder, http.MethodGet, requestURI, nil, "", false)
suite.instanceModule.InstancePeersGETHandler(ctx)
instanceModule.InstancePeersGETHandler(ctx)
suite.Equal(http.StatusOK, recorder.Code)
@@ -331,17 +387,23 @@ func (suite *InstancePeersGetTestSuite) TestInstancePeersGetAllWithObfuscatedFla
"example.org",
"fossbros-anonymous.io",
"o*g.*u**.t**.*or*t.*r**ev**",
"replyguys.com"
"replyguys.com",
"thequeenisstillalive.technology",
"ëxample.org"
]`, dst.String())
}
func (suite *InstancePeersGetTestSuite) TestInstancePeersGetFunkyParams() {
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
defer testrig.TearDownTestStructs(testStructs)
instanceModule := instance.New(testStructs.Processor)
recorder := httptest.NewRecorder()
baseURI := fmt.Sprintf("%s://%s", config.GetProtocol(), config.GetHost())
requestURI := fmt.Sprintf("%s/%s?filter=aaaaaaaaaaaaaaaaa,open", baseURI, instance.InstancePeersPath)
ctx := suite.newContext(recorder, http.MethodGet, requestURI, nil, "", true)
suite.instanceModule.InstancePeersGETHandler(ctx)
instanceModule.InstancePeersGETHandler(ctx)
suite.Equal(http.StatusBadRequest, recorder.Code)
+43
View File
@@ -288,3 +288,46 @@ type AdminAccountRejectRequest struct {
// them that their sign-up has been rejected.
SendEmail bool `form:"send_email" json:"send_email"`
}
// AdminInstance models the admin view of an instance.
//
// swagger:model adminInstance
type AdminInstance struct {
// ID of this instance.
// example: 01KJQGK06SMJ8KDW6DCRRXTG8D
ID string `json:"id"`
// Domain of this instance.
// example: example.org
Domain string `json:"domain"`
// Software this instance purports to be running.
// May be omitted when the running software cannot be determined.
// example: gotosocial
Software string `json:"software,omitempty"`
// Time when the instance was first seen by this instance.
// example: 2022-10-05T09:21:26.419Z
FirstSeen string `json:"first_seen"`
// Time of the latest successful delivery of a message to someone on this instance.
// May be omitted if no messages have ever been delivered to this instance.
// example: 2022-10-05T09:21:26.419Z
LatestSuccessfulDelivery string `json:"latest_successful_delivery,omitempty"`
// Last 20 errors from delivery attempts to this instance.
// Cleared after a successful delivery.
// Not set if delivery to this instance never attempted or delivery to this instance not errored since latest_successful_delivery time.
DeliveryErrors []AdminInstanceDeliveryError `json:"delivery_errors,omitempty"`
// TODO: add statuses count, accounts
// count, follow relationships etc.
}
// AdminInstanceDeliveryError models an error encountered
// while trying to deliver a message to an inbox on an instance.
//
// swagger:model adminInstanceDeliveryError
type AdminInstanceDeliveryError struct {
// Time of this delivery error.
// example: 2022-10-05T09:21:26.419Z
Time string `json:"time"`
// Message for this delivery error.
// example: boobs
Error string `json:"error"`
}
+38 -22
View File
@@ -47,6 +47,8 @@ const (
TargetAccountIDKey = "target_account_id"
ResolvedKey = "resolved"
OffsetKey = "offset"
DomainKey = "domain"
OrderKey = "order"
/* AP endpoint keys */
@@ -71,27 +73,27 @@ const (
DomainPermissionImportKey = "import"
DomainPermissionSubscriptionIDKey = "subscription_id"
DomainPermissionPermTypeKey = "permission_type"
DomainPermissionDomainKey = "domain"
/* Admin query keys */
AdminRemoteKey = "remote"
AdminActiveKey = "active"
AdminPendingKey = "pending"
AdminDisabledKey = "disabled"
AdminSilencedKey = "silenced"
AdminSuspendedKey = "suspended"
AdminSensitizedKey = "sensitized"
AdminDisplayNameKey = "display_name"
AdminByDomainKey = "by_domain"
AdminEmailKey = "email"
AdminIPKey = "ip"
AdminStaffKey = "staff"
AdminOriginKey = "origin"
AdminStatusKey = "status"
AdminPermissionsKey = "permissions"
AdminRoleIDsKey = "role_ids[]"
AdminInvitedByKey = "invited_by"
AdminRemoteKey = "remote"
AdminActiveKey = "active"
AdminPendingKey = "pending"
AdminDisabledKey = "disabled"
AdminSilencedKey = "silenced"
AdminSuspendedKey = "suspended"
AdminSensitizedKey = "sensitized"
AdminDisplayNameKey = "display_name"
AdminByDomainKey = "by_domain"
AdminEmailKey = "email"
AdminIPKey = "ip"
AdminStaffKey = "staff"
AdminOriginKey = "origin"
AdminStatusKey = "status"
AdminPermissionsKey = "permissions"
AdminRoleIDsKey = "role_ids[]"
AdminInvitedByKey = "invited_by"
AdminWithErrorsOnlyKey = "with_errors_only"
/* Interaction policy + request keys */
@@ -103,10 +105,6 @@ const (
/* Web view keys */
WebIncludeBoostsKey = "include_boosts"
/* Directory keys */
DirectoryOrderKey = "order"
)
/*
@@ -210,6 +208,10 @@ func ParseAdminStaff(value string, defaultValue bool) (bool, gtserror.WithCode)
return parseBool(value, defaultValue, AdminStaffKey)
}
func ParseAdminWithErrorsOnly(value string, defaultValue bool) (bool, gtserror.WithCode) {
return parseBool(value, defaultValue, AdminWithErrorsOnlyKey)
}
func ParseInteractionFavourites(value string, defaultValue bool) (bool, gtserror.WithCode) {
return parseBool(value, defaultValue, InteractionFavouritesKey)
}
@@ -240,6 +242,20 @@ func ParseDirectoryOrder(value string, defaultValue gtsmodel.DirectoryOrderBy) (
}
}
func ParseInstancesOrder(value string, defaultValue gtsmodel.InstanceOrderBy) (gtsmodel.InstanceOrderBy, gtserror.WithCode) {
switch strings.ToLower(value) {
case "":
return defaultValue, nil
case "alphabetical":
return gtsmodel.InstanceOrderByAlphabetical, nil
case "first_seen":
return gtsmodel.InstanceOrderByFirstSeen, nil
default:
const errText = "invalid value for order, valid values are '', 'alphabetical', or 'first_seen'"
return gtsmodel.InstanceOrderByUnknown, gtserror.NewErrorBadRequest(errors.New(errText), errText)
}
}
/*
Parse functions for *REQUIRED* parameters.
*/
+2
View File
@@ -39,6 +39,7 @@ const (
scopeFavourites = "favourites"
scopeFilters = "filters"
scopeFollows = "follows"
scopeInstances = "instances"
scopeLists = "lists"
scopeMedia = "media"
scopeMutes = "mutes"
@@ -97,6 +98,7 @@ const (
ScopeAdminWriteDomainBlocks Scope = ScopeAdminWrite + ":" + scopeDomainBlocks
ScopeAdminReadDomainLimits Scope = ScopeAdminRead + ":" + scopeDomainLimits
ScopeAdminWriteDomainLimits Scope = ScopeAdminWrite + ":" + scopeDomainLimits
ScopeAdminReadInstances Scope = ScopeAdminRead + ":" + scopeInstances
ScopeAdminReadReports Scope = ScopeAdminRead + ":" + scopeReports
ScopeAdminWriteReports Scope = ScopeAdminWrite + ":" + scopeReports
)
+1
View File
@@ -89,6 +89,7 @@ func (c *Caches) Init() {
c.initDomainPermissionExclude()
c.initEmoji()
c.initEmojiCategory()
c.initFederationError()
c.initFilterIDs()
c.initFilter()
c.initFilterKeyword()
+32 -4
View File
@@ -85,6 +85,9 @@ type DBCaches struct {
// EmojiCategory provides access to the gtsmodel EmojiCategory database cache.
EmojiCategory StructCache[*gtsmodel.EmojiCategory]
// FederationError provides access to the gtsmodel FederationError database cache.
FederationError StructCache[*gtsmodel.FederationError]
// Filter provides access to the gtsmodel Filter database cache.
Filter StructCache[*gtsmodel.Filter]
@@ -161,10 +164,11 @@ type DBCaches struct {
// LocalInstance provides caching for
// simple + common local instance queries.
LocalInstance struct {
Domains atomic.Pointer[int]
Peers atomic.Pointer[int]
Statuses atomic.Pointer[int]
Users atomic.Pointer[int]
Accounts atomic.Pointer[int]
UserIDs atomic.Pointer[[]string]
Settings atomic.Pointer[gtsmodel.InstanceSettings]
}
// InteractionRequest provides access to the gtsmodel InteractionRequest database cache.
@@ -721,6 +725,31 @@ func (c *Caches) initEmojiCategory() {
})
}
func (c *Caches) initFederationError() {
// Calculate maximum cache size.
cap := calculateResultCacheMax(
sizeofFederationError(), // model in-mem size.
config.GetCacheFederationErrorMemRatio(),
)
log.Infof(nil, "cache size = %d", cap)
copyF := func(f1 *gtsmodel.FederationError) *gtsmodel.FederationError {
f2 := new(gtsmodel.FederationError)
*f2 = *f1
return f2
}
c.DB.FederationError.Init(structr.CacheConfig[*gtsmodel.FederationError]{
Indices: []structr.IndexConfig{
{Fields: "ID"},
},
MaxSize: cap,
IgnoreErr: ignoreErrors,
Copy: copyF,
})
}
func (c *Caches) initFilter() {
// Calculate maximum cache size.
cap := calculateResultCacheMax(
@@ -970,8 +999,7 @@ func (c *Caches) initInstance() {
// Don't include ptr fields that
// will be populated separately.
// See internal/db/bundb/instance.go.
i2.DomainBlock = nil
i2.ContactAccount = nil
i2.DeliveryErrors = nil
return i2
}
+3 -3
View File
@@ -263,7 +263,7 @@ func (c *Caches) OnInvalidateFollowRequest(followReq *gtsmodel.FollowRequest) {
func (c *Caches) OnInvalidateInstance(instance *gtsmodel.Instance) {
// Invalidate the local domains count.
c.DB.LocalInstance.Domains.Store(nil)
c.DB.LocalInstance.Peers.Store(nil)
}
func (c *Caches) OnInvalidateList(list *gtsmodel.List) {
@@ -396,9 +396,9 @@ func (c *Caches) OnInvalidateUser(user *gtsmodel.User) {
c.Visibility.Invalidate("ItemID", user.AccountID)
c.Visibility.Invalidate("RequesterID", user.AccountID)
// Invalidate the local user IDs / count.
// Invalidate the local user IDs / accounts count.
c.DB.LocalInstance.UserIDs.Store(nil)
c.DB.LocalInstance.Users.Store(nil)
c.DB.LocalInstance.Accounts.Store(nil)
}
func (c *Caches) OnInvalidateUserMute(mute *gtsmodel.UserMute) {
+13 -11
View File
@@ -360,6 +360,15 @@ func sizeofEmojiCategory() uintptr {
}))
}
func sizeofFederationError() uintptr {
return uintptr(size.Of(&gtsmodel.FederationError{
ID: exampleID,
InstanceID: exampleID,
Type: 1,
Error: exampleTextSmall,
}))
}
func sizeofFilter() uintptr {
return uintptr(size.Of(&gtsmodel.Filter{
ID: exampleID,
@@ -415,17 +424,10 @@ func sizeofFollowRequest() uintptr {
func sizeofInstance() uintptr {
return uintptr(size.Of(&gtsmodel.Instance{
ID: exampleID,
CreatedAt: exampleTime,
UpdatedAt: exampleTime,
Domain: exampleURI,
URI: exampleURI,
Title: exampleTextSmall,
ShortDescription: exampleText,
Description: exampleText,
ContactEmail: exampleUsername,
ContactAccountUsername: exampleUsername,
ContactAccountID: exampleID,
ID: exampleID,
Domain: exampleURI,
Software: exampleTextSmall,
LatestSuccessfulDelivery: exampleTime,
}))
}
+1
View File
@@ -239,6 +239,7 @@ type CacheConfiguration struct {
DomainPermissionSubscriptionMemRatio float64 `name:"domain-permission-subscription-mem-ratio"`
EmojiMemRatio float64 `name:"emoji-mem-ratio"`
EmojiCategoryMemRatio float64 `name:"emoji-category-mem-ratio"`
FederationErrorMemRatio float64 `name:"federation-error-mem-ratio"`
FilterMemRatio float64 `name:"filter-mem-ratio"`
FilterIDsMemRatio float64 `name:"filter-ids-mem-ratio"`
FilterKeywordMemRatio float64 `name:"filter-keyword-mem-ratio"`
+1
View File
@@ -210,6 +210,7 @@ var Defaults = Configuration{
DomainPermissionSubscriptionMemRatio: 0.5,
EmojiMemRatio: 3,
EmojiCategoryMemRatio: 0.1,
FederationErrorMemRatio: 0.2,
FilterMemRatio: 0.5,
FilterIDsMemRatio: 2,
FilterKeywordMemRatio: 0.5,
+45
View File
@@ -178,6 +178,7 @@ const (
CacheDomainPermissionSubscriptionMemRatioFlag = "cache-domain-permission-subscription-mem-ratio"
CacheEmojiMemRatioFlag = "cache-emoji-mem-ratio"
CacheEmojiCategoryMemRatioFlag = "cache-emoji-category-mem-ratio"
CacheFederationErrorMemRatioFlag = "cache-federation-error-mem-ratio"
CacheFilterMemRatioFlag = "cache-filter-mem-ratio"
CacheFilterIDsMemRatioFlag = "cache-filter-ids-mem-ratio"
CacheFilterKeywordMemRatioFlag = "cache-filter-keyword-mem-ratio"
@@ -386,6 +387,7 @@ func (cfg *Configuration) RegisterFlags(flags *pflag.FlagSet) {
flags.Float64("cache-domain-permission-subscription-mem-ratio", cfg.Cache.DomainPermissionSubscriptionMemRatio, "")
flags.Float64("cache-emoji-mem-ratio", cfg.Cache.EmojiMemRatio, "")
flags.Float64("cache-emoji-category-mem-ratio", cfg.Cache.EmojiCategoryMemRatio, "")
flags.Float64("cache-federation-error-mem-ratio", cfg.Cache.FederationErrorMemRatio, "")
flags.Float64("cache-filter-mem-ratio", cfg.Cache.FilterMemRatio, "")
flags.Float64("cache-filter-ids-mem-ratio", cfg.Cache.FilterIDsMemRatio, "")
flags.Float64("cache-filter-keyword-mem-ratio", cfg.Cache.FilterKeywordMemRatio, "")
@@ -586,6 +588,7 @@ func (cfg *Configuration) MarshalMap() map[string]any {
cfgmap["cache-domain-permission-subscription-mem-ratio"] = cfg.Cache.DomainPermissionSubscriptionMemRatio
cfgmap["cache-emoji-mem-ratio"] = cfg.Cache.EmojiMemRatio
cfgmap["cache-emoji-category-mem-ratio"] = cfg.Cache.EmojiCategoryMemRatio
cfgmap["cache-federation-error-mem-ratio"] = cfg.Cache.FederationErrorMemRatio
cfgmap["cache-filter-mem-ratio"] = cfg.Cache.FilterMemRatio
cfgmap["cache-filter-ids-mem-ratio"] = cfg.Cache.FilterIDsMemRatio
cfgmap["cache-filter-keyword-mem-ratio"] = cfg.Cache.FilterKeywordMemRatio
@@ -1880,6 +1883,14 @@ func (cfg *Configuration) UnmarshalMap(cfgmap map[string]any) error {
}
}
if ival, ok := cfgmap["cache-federation-error-mem-ratio"]; ok {
var err error
cfg.Cache.FederationErrorMemRatio, err = cast.ToFloat64E(ival)
if err != nil {
return fmt.Errorf("error casting %#v -> float64 for 'cache-federation-error-mem-ratio': %w", ival, err)
}
}
if ival, ok := cfgmap["cache-filter-mem-ratio"]; ok {
var err error
cfg.Cache.FilterMemRatio, err = cast.ToFloat64E(ival)
@@ -5627,6 +5638,28 @@ func GetCacheEmojiCategoryMemRatio() float64 { return global.GetCacheEmojiCatego
// SetCacheEmojiCategoryMemRatio safely sets the value for global configuration 'Cache.EmojiCategoryMemRatio' field
func SetCacheEmojiCategoryMemRatio(v float64) { global.SetCacheEmojiCategoryMemRatio(v) }
// GetCacheFederationErrorMemRatio safely fetches the Configuration value for state's 'Cache.FederationErrorMemRatio' field
func (st *ConfigState) GetCacheFederationErrorMemRatio() (v float64) {
st.mutex.RLock()
v = st.config.Cache.FederationErrorMemRatio
st.mutex.RUnlock()
return
}
// SetCacheFederationErrorMemRatio safely sets the Configuration value for state's 'Cache.FederationErrorMemRatio' field
func (st *ConfigState) SetCacheFederationErrorMemRatio(v float64) {
st.mutex.Lock()
defer st.mutex.Unlock()
st.config.Cache.FederationErrorMemRatio = v
st.reloadToViper()
}
// GetCacheFederationErrorMemRatio safely fetches the value for global configuration 'Cache.FederationErrorMemRatio' field
func GetCacheFederationErrorMemRatio() float64 { return global.GetCacheFederationErrorMemRatio() }
// SetCacheFederationErrorMemRatio safely sets the value for global configuration 'Cache.FederationErrorMemRatio' field
func SetCacheFederationErrorMemRatio(v float64) { global.SetCacheFederationErrorMemRatio(v) }
// GetCacheFilterMemRatio safely fetches the Configuration value for state's 'Cache.FilterMemRatio' field
func (st *ConfigState) GetCacheFilterMemRatio() (v float64) {
st.mutex.RLock()
@@ -6884,6 +6917,7 @@ func (st *ConfigState) GetTotalOfMemRatios() (total float64) {
total += st.config.Cache.DomainPermissionSubscriptionMemRatio
total += st.config.Cache.EmojiMemRatio
total += st.config.Cache.EmojiCategoryMemRatio
total += st.config.Cache.FederationErrorMemRatio
total += st.config.Cache.FilterMemRatio
total += st.config.Cache.FilterIDsMemRatio
total += st.config.Cache.FilterKeywordMemRatio
@@ -7461,6 +7495,17 @@ func flattenConfigMap(cfgmap map[string]any) {
}
}
for _, key := range [][]string{
{"cache", "federation-error-mem-ratio"},
} {
ival, ok := mapGet(cfgmap, key...)
if ok {
cfgmap["cache-federation-error-mem-ratio"] = ival
nestedKeys[key[0]] = struct{}{}
break
}
}
for _, key := range [][]string{
{"cache", "filter-mem-ratio"},
} {
+10 -5
View File
@@ -46,11 +46,6 @@ type Admin interface {
// This is needed for things like serving files that belong to the instance and not an individual user/account.
CreateInstanceAccount(ctx context.Context) error
// CreateInstanceInstance creates an instance in the database with the same domain as the instance host value.
// Ie., if the instance is hosted at 'example.org' the instance will have a domain of 'example.org'.
// This is needed for things like serving instance information through /api/v1/instance
CreateInstanceInstance(ctx context.Context) error
// CreateInstanceApplication creates an application in the database
// for use in processing signups etc through the sign-up form.
CreateInstanceApplication(ctx context.Context) error
@@ -59,6 +54,16 @@ type Admin interface {
// (ie., the application owned by the instance account).
GetInstanceApplication(ctx context.Context) (*gtsmodel.Application, error)
// CreateInstanceSettings ensures that a
// settings entry exists for this instance.
CreateInstanceSettings(ctx context.Context) error
// GetInstanceSettings gets the instance settings entry for this instance.
GetInstanceSettings(ctx context.Context) (*gtsmodel.InstanceSettings, error)
// UpdateInstance settings updates the given instance settings entry for this instance.
UpdateInstanceSettings(ctx context.Context, settings *gtsmodel.InstanceSettings, columns ...string) error
// CountApprovedSignupsSince counts the number of new account
// sign-ups approved on this instance since the given time.
CountApprovedSignupsSince(ctx context.Context, since time.Time) (int, error)
+119 -40
View File
@@ -30,6 +30,7 @@ import (
"code.superseriousbusiness.org/gopkg/log"
"code.superseriousbusiness.org/gotosocial/internal/config"
"code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/gtscontext"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/id"
@@ -309,46 +310,6 @@ func (a *adminDB) CreateInstanceAccount(ctx context.Context) error {
return nil
}
func (a *adminDB) CreateInstanceInstance(ctx context.Context) error {
protocol := config.GetProtocol()
host := config.GetHost()
// check if instance entry already exists
q := a.db.
NewSelect().
Column("instance.id").
TableExpr("? AS ?", bun.Ident("instances"), bun.Ident("instance")).
Where("? = ?", bun.Ident("instance.domain"), host)
exists, err := exists(ctx, q)
if err != nil {
return err
}
if exists {
log.Infof(ctx, "instance entry already exists")
return nil
}
i := &gtsmodel.Instance{
ID: id.NewRandomULID(),
Domain: host,
Title: host,
URI: fmt.Sprintf("%s://%s", protocol, host),
}
insertQ := a.db.
NewInsert().
Model(i)
_, err = insertQ.Exec(ctx)
if err != nil {
return err
}
log.Infof(ctx, "created instance instance %s with id %s", host, i.ID)
return nil
}
func (a *adminDB) CreateInstanceApplication(ctx context.Context) error {
// Check if instance application already exists.
// Instance application client_id always = the
@@ -510,3 +471,121 @@ func (a *adminDB) DeleteAdminAction(ctx context.Context, id string) error {
return err
}
func (a *adminDB) GetInstanceSettings(ctx context.Context) (*gtsmodel.InstanceSettings, error) {
// Check if settings stored in the cache. Load it if not.
s := a.state.Caches.DB.LocalInstance.Settings.Load()
if s == nil {
// Not hydrated.
//
// Load from db and store in cache.
s = new(gtsmodel.InstanceSettings)
if err := a.db.
NewSelect().
Table("instance_settings").
Limit(1).
Scan(ctx, s); err != nil {
return nil, err
}
a.state.Caches.DB.LocalInstance.Settings.Store(s)
}
// Ensure no errant
// pointer fields set.
s.Rules = nil
s.ContactAccount = nil
// Copy cached settings.
settings := new(gtsmodel.InstanceSettings)
*settings = *s
// If barebones, just return.
if gtscontext.Barebones(ctx) {
return settings, nil
}
// Populate settings before returning.
if err := a.populateSettings(ctx, settings); err != nil {
return nil, gtserror.Newf("db error populating settings: %w", err)
}
return settings, nil
}
func (a *adminDB) CreateInstanceSettings(ctx context.Context) error {
// Check if settings already in the DB.
settings, err := a.GetInstanceSettings(gtscontext.SetBarebones(ctx))
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return err
}
if settings != nil {
// Settings already exist,
// no need to do anything.
return nil
}
// Create minimum settings with just title.
settings = &gtsmodel.InstanceSettings{
ID: id.NewULID(),
Title: config.GetHost(),
}
// Put settings in the db.
if _, err := a.db.
NewInsert().
Model(settings).
Exec(ctx, settings); err != nil {
return err
}
// Store in cache before returning.
a.state.Caches.DB.LocalInstance.Settings.Store(settings)
return nil
}
func (a *adminDB) UpdateInstanceSettings(ctx context.Context, settings *gtsmodel.InstanceSettings, columns ...string) error {
// Update settings in the db.
if _, err := a.db.
NewUpdate().
Model(settings).
Column(columns...).
WherePK().
Exec(ctx); err != nil {
return err
}
// Copy + depopulate settings
// before storing in cache.
s := new(gtsmodel.InstanceSettings)
*s = *settings
s.Rules = nil
s.ContactAccount = nil
a.state.Caches.DB.LocalInstance.Settings.Store(s)
return nil
}
func (a *adminDB) populateSettings(ctx context.Context, settings *gtsmodel.InstanceSettings) error {
// Populate rules if necessary.
if settings.Rules == nil {
var err error
settings.Rules, err = a.state.DB.GetActiveRules(ctx)
if err != nil {
return err
}
}
// Populate contact account if necessary.
if settings.ContactAccountID != "" && settings.ContactAccount == nil {
var err error
settings.ContactAccount, err = a.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
settings.ContactAccountID,
)
if err != nil {
return err
}
}
return nil
}
+2
View File
@@ -55,6 +55,7 @@ type BunDBStandardTestSuite struct {
testPollVotes map[string]*gtsmodel.PollVote
testInteractionRequests map[string]*gtsmodel.InteractionRequest
testStatusEdits map[string]*gtsmodel.StatusEdit
testInstances map[string]*gtsmodel.Instance
}
func (suite *BunDBStandardTestSuite) SetupSuite() {
@@ -81,6 +82,7 @@ func (suite *BunDBStandardTestSuite) SetupSuite() {
suite.testPollVotes = testrig.NewTestPollVotes()
suite.testInteractionRequests = testrig.NewTestInteractionRequests()
suite.testStatusEdits = testrig.NewTestStatusEdits()
suite.testInstances = testrig.NewTestInstances()
}
func (suite *BunDBStandardTestSuite) SetupTest() {
+479 -217
View File
@@ -19,15 +19,18 @@ package bundb
import (
"context"
"errors"
"time"
"code.superseriousbusiness.org/gopkg/log"
"code.superseriousbusiness.org/gopkg/xslices"
"code.superseriousbusiness.org/gotosocial/internal/config"
"code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/gtscontext"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/id"
"code.superseriousbusiness.org/gotosocial/internal/paging"
"code.superseriousbusiness.org/gotosocial/internal/state"
"code.superseriousbusiness.org/gotosocial/internal/util"
"github.com/uptrace/bun"
@@ -38,78 +41,35 @@ type instanceDB struct {
state *state.State
}
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
}
func (i *instanceDB) CountInstanceAccounts(ctx context.Context) (int, error) {
// Check for a cached instance accounts count. If present return this.
if n := i.state.Caches.DB.LocalInstance.Accounts.Load(); n != nil {
return *n, nil
}
q := i.db.
count, err := i.db.
NewSelect().
TableExpr("? AS ?", bun.Ident("accounts"), bun.Ident("account")).
// Just select IDs.
Column("account.id").
Where("? != ?", bun.Ident("account.username"), domain).
Where("? IS NULL", bun.Ident("account.suspended_at"))
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"))
} else {
q = q.Where("? = ?", bun.Ident("account.domain"), domain)
}
count, err := q.Count(ctx)
// Local accounts only.
Where("? IS NULL", bun.Ident("account.domain")).
// Ignore instance account.
Where("? != ?", bun.Ident("account.username"), config.GetHost()).
// Exclude suspended accounts.
Where("? IS NULL", bun.Ident("account.suspended_at")).
Count(ctx)
if err != nil {
return 0, err
}
if localhost {
// Update cached instance users account value.
i.state.Caches.DB.LocalInstance.Users.Store(&count)
}
// Update cached instance accounts count value.
i.state.Caches.DB.LocalInstance.Accounts.Store(&count)
return count, nil
}
func (i *instanceDB) CountInstanceStatuses(ctx context.Context, domain string) (int, error) {
local := (domain == config.GetHost() || domain == config.GetAccountDomain())
if local {
return i.countLocalStatuses(ctx)
}
q := i.db.
NewSelect().
TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")).
// Join on the domain of the account.
Join(
"JOIN ? AS ? ON ? = ?",
bun.Ident("accounts"), bun.Ident("account"),
bun.Ident("account.id"), bun.Ident("status.account_id"),
).
Where("? = ?", bun.Ident("account.domain"), domain).
// Ignore pending approval.
Where(db.BitNotSet("status.flags", gtsmodel.StatusFlagPendingApproval)).
// Ignore deleted statuses.
Where(db.BitNotSet("status.flags", gtsmodel.StatusFlagDeleted)).
// Ignore direct messages.
Where("NOT ? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityDirect)
count, err := q.Count(ctx)
if err != nil {
return 0, err
}
return count, nil
}
func (i *instanceDB) countLocalStatuses(ctx context.Context) (int, error) {
// Check for a cached instance statuses count, if so return this.
func (i *instanceDB) CountInstanceStatuses(ctx context.Context) (int, error) {
// Check for a cached instance statuses count. If present return this.
if n := i.state.Caches.DB.LocalInstance.Statuses.Load(); n != nil {
return *n, nil
}
@@ -124,61 +84,107 @@ func (i *instanceDB) countLocalStatuses(ctx context.Context) (int, error) {
return 0, err
}
// Update cached instance statuses account value.
// Update cached instance statuses count 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
}
func (i *instanceDB) CountInstancePeers(ctx context.Context) (int, error) {
// Check for a cached instance peers count. If present return this.
if n := i.state.Caches.DB.LocalInstance.Peers.Load(); n != nil {
return *n, nil
}
q := i.db.
// Select just the domain
// part of all known instances.
domains := []string{}
if err := i.db.
NewSelect().
TableExpr("? AS ?", bun.Ident("instances"), bun.Ident("instance"))
if localhost {
// if the domain is *this* domain, just count other instances it knows about
// exclude domains that are blocked
q = q.
Where("? != ?", bun.Ident("instance.domain"), domain).
Where("? IS NULL", bun.Ident("instance.suspended_at"))
} else {
// TODO: implement federated domain counting properly for remote domains
return 0, nil
}
count, err := q.Count(ctx)
if err != nil {
Table("instances").
Column("domain").
Scan(ctx, &domains); err != nil {
return 0, err
}
if localhost {
// Update cached instance domains account value.
i.state.Caches.DB.LocalInstance.Domains.Store(&count)
var count int
for _, domain := range domains {
// For each domain, check if
// we're federating with it.
blocked, err := i.state.DB.IsDomainBlocked(ctx, domain)
if err != nil {
return 0, gtserror.Newf("db error checking block: %w", err)
}
if blocked {
// Doesn't count as a
// peer if it's blocked.
continue
}
// Count as a peer.
count++
}
// Update cached instance peers count value.
i.state.Caches.DB.LocalInstance.Peers.Store(&count)
return count, nil
}
func (i *instanceDB) GetInstance(ctx context.Context, domain string) (*gtsmodel.Instance, error) {
var err error
func (i *instanceDB) GetInstancePeers(ctx context.Context, includeSuspended bool) ([]*gtsmodel.Instance, error) {
// Select just the domain
// part of all known instances.
domains := []string{}
if err := i.db.
NewSelect().
Table("instances").
Column("domain").
Scan(ctx, &domains); err != nil {
return nil, err
}
// Normalize the domain as punycode
if len(domains) == 0 {
// Empty response.
return make([]*gtsmodel.Instance, 0), nil
}
instances := make([]*gtsmodel.Instance, 0, len(domains))
for _, domain := range domains {
if !includeSuspended {
// Ensure peer not blocked.
blocked, err := i.state.DB.IsDomainBlocked(ctx, domain)
if err != nil {
return nil, gtserror.Newf("db error checking block: %w", err)
}
if blocked {
// Skip this one.
continue
}
}
// Select instance.
instance, err := i.GetInstance(ctx, domain)
if err != nil {
log.Errorf(ctx, "db error getting instance %q: %v", domain, err)
continue
}
// Append to return slice.
instances = append(instances, instance)
}
return instances, nil
}
func (i *instanceDB) GetInstance(ctx context.Context, domain string) (*gtsmodel.Instance, error) {
// Normalize the domain as punycode.
var err error
domain, err = util.Punify(domain)
if err != nil {
return nil, gtserror.Newf("error punifying domain %s: %w", domain, err)
}
return i.getInstance(
ctx,
return i.getInstance(ctx,
"Domain",
func(instance *gtsmodel.Instance) error {
return i.db.NewSelect().
@@ -191,8 +197,7 @@ func (i *instanceDB) GetInstance(ctx context.Context, domain string) (*gtsmodel.
}
func (i *instanceDB) GetInstanceByID(ctx context.Context, id string) (*gtsmodel.Instance, error) {
return i.getInstance(
ctx,
return i.getInstance(ctx,
"ID",
func(instance *gtsmodel.Instance) error {
return i.db.NewSelect().
@@ -204,81 +209,194 @@ func (i *instanceDB) GetInstanceByID(ctx context.Context, id string) (*gtsmodel.
)
}
func (i *instanceDB) getInstance(ctx context.Context, lookup string, dbQuery func(*gtsmodel.Instance) error, keyParts ...any) (*gtsmodel.Instance, error) {
// Fetch instance from database cache with loader callback
instance, err := i.state.Caches.DB.Instance.LoadOne(lookup, func() (*gtsmodel.Instance, error) {
var instance gtsmodel.Instance
func (i *instanceDB) GetInstancesPage(
ctx context.Context,
page *paging.Page,
domain string,
orderBy gtsmodel.InstanceOrderBy,
undeliverableOnly bool,
) ([]*gtsmodel.Instance, error) {
var (
// Extract page params.
minID = page.Min.Value
maxID = page.Max.Value
limit = page.Limit
order = page.Order()
// Not cached! Perform database query.
if err := dbQuery(&instance); err != nil {
// Pre-allocate slice of IDs.
instanceIDs = make([]string, 0, limit)
// We know orderBy is either Latest or Alphabetical.
orderByAlphabetical = orderBy == gtsmodel.InstanceOrderByAlphabetical
)
q := i.db.
NewSelect().
// Select just the ID
// of each instance.
Column("instance.id").
TableExpr("? AS ?", bun.Ident("instances"), bun.Ident("instance"))
if undeliverableOnly {
q = q.Join(
"RIGHT JOIN ? AS ? ON ? = ?",
bun.Ident("federation_errors"),
bun.Ident("federation_error"),
bun.Ident("instance.id"),
bun.Ident("federation_error.instance_id"),
).Distinct()
}
if domain != "" {
// Normalize the
// domain as punycode.
var err error
domain, err = util.Punify(domain)
if err != nil {
return nil, gtserror.Newf("error punifying domain %s: %w", domain, err)
}
// Get any instances *starting with* the given domain.
q = q.Where("? LIKE ?", bun.Ident("instance.domain"), domain+"%")
}
// Paging parameters.
if maxID != "" {
if orderByAlphabetical {
// Get instance for max ID.
maxIDInstance, err := i.GetInstanceByID(
gtscontext.SetBarebones(ctx),
maxID,
)
if err != nil {
err := gtserror.Newf("db error getting maxID instance %s: %w", maxID, err)
return nil, err
}
// Order alpahetically (a-z) by domain.
q = q.Where("? > ?", bun.Ident("instance.domain"), maxIDInstance.Domain)
} else {
// Order by ID, which indicates creation time.
q = q.Where("? < ?", bun.Ident("instance.id"), maxID)
}
}
if minID != "" {
if orderByAlphabetical {
// Get instance for min ID.
minIDInstance, err := i.GetInstanceByID(
gtscontext.SetBarebones(ctx),
minID,
)
if err != nil {
err := gtserror.Newf("db error getting minID instance %s: %w", minID, err)
return nil, err
}
// Order alpahetically (a-z) by domain.
q = q.Where("? < ?", bun.Ident("instance.domain"), minIDInstance.Domain)
} else {
// Order by ID, which indicates creation time.
q = q.Where("? > ?", bun.Ident("instance.id"), minID)
}
}
switch {
case !orderByAlphabetical && order == paging.OrderDescending:
// Order by ID (paging down).
q = q.Order("instance.id DESC")
case !orderByAlphabetical && order == paging.OrderAscending:
// Order by ID (paging up).
q = q.Order("instance.id ASC")
case orderByAlphabetical && order == paging.OrderDescending:
// Order alphabetically (paging down).
// Z > A in ASCII so use ASC.
q = q.Order("instance.domain ASC")
case orderByAlphabetical && order == paging.OrderAscending:
// Order alphabetically (paging up).
// A < Z in ASCII so use DESC.
q = q.Order("instance.domain DESC")
}
// Limit amount of
// instances returned.
q = q.Limit(limit)
// Run the query.
if err := q.Scan(ctx, &instanceIDs); err != nil {
return nil, err
}
count := len(instanceIDs)
if count == 0 {
// Nothing for
// this query.
return nil, nil
}
// Preallocate slice of instances,
// and fetch each instance by ID.
instances := make([]*gtsmodel.Instance, 0, count)
for _, instanceID := range instanceIDs {
instance, err := i.GetInstanceByID(ctx, instanceID)
if err != nil {
err := gtserror.Newf("db error getting instance: %w", err)
return nil, err
}
instances = append(instances, instance)
}
return instances, nil
}
if instance.Domain == config.GetHost() {
// also populate Rules
rules, err := i.state.DB.GetActiveRules(ctx)
if err != nil {
log.Error(ctx, err)
} else {
instance.Rules = rules
func (i *instanceDB) getInstance(
ctx context.Context,
lookup string,
dbQuery func(*gtsmodel.Instance) error,
keyParts ...any,
) (*gtsmodel.Instance, error) {
// Fetch instance from db cache with loader callback.
instance, err := i.state.Caches.DB.Instance.LoadOne(
lookup,
func() (*gtsmodel.Instance, error) {
// Not cached! Perform database query.
var instance gtsmodel.Instance
if err := dbQuery(&instance); err != nil {
return nil, err
}
}
return &instance, nil
}, keyParts...)
return &instance, nil
},
keyParts...,
)
if err != nil {
return nil, err
return nil, gtserror.Newf("db error getting instance: %w", err)
}
if gtscontext.Barebones(ctx) {
// no need to fully populate.
// No need to populate.
return instance, nil
}
// Further populate the instance fields where applicable.
if err := i.PopulateInstance(ctx, instance); err != nil {
return nil, err
// Set delivery errors on instance model.
dErrs, err := i.getFederationErrors(ctx,
instance.ID,
gtsmodel.FederationErrorTypeDelivery,
)
if err != nil {
return nil, gtserror.Newf("db error getting delivery errors: %w", err)
}
instance.DeliveryErrors = dErrs
// Return populated instance.
return instance, nil
}
func (i *instanceDB) PopulateInstance(ctx context.Context, instance *gtsmodel.Instance) error {
var (
err error
errs = gtserror.NewMultiError(2)
)
if instance.DomainBlockID != "" && instance.DomainBlock == nil {
// Instance domain block is not set, fetch from database.
instance.DomainBlock, err = i.state.DB.GetDomainBlock(
gtscontext.SetBarebones(ctx),
instance.Domain,
)
if err != nil {
errs.Appendf("error populating instance domain block: %w", err)
}
}
if instance.ContactAccountID != "" && instance.ContactAccount == nil {
// Instance domain block is not set, fetch from database.
instance.ContactAccount, err = i.state.DB.GetAccountByID(
gtscontext.SetBarebones(ctx),
instance.ContactAccountID,
)
if err != nil {
errs.Appendf("error populating instance contact account: %w", err)
}
}
return errs.Combine()
}
func (i *instanceDB) PutInstance(ctx context.Context, instance *gtsmodel.Instance) error {
var err error
// Normalize the domain as punycode, note the extra
// validation step for domain name write operations.
var err error
instance.Domain, err = util.PunifySafely(instance.Domain)
if err != nil {
return gtserror.Newf("error punifying domain %s: %w", instance.Domain, err)
@@ -291,73 +409,6 @@ func (i *instanceDB) PutInstance(ctx context.Context, instance *gtsmodel.Instanc
})
}
func (i *instanceDB) UpdateInstance(ctx context.Context, instance *gtsmodel.Instance, columns ...string) error {
var err error
// Normalize the domain as punycode, note the extra
// validation step for domain name write operations.
instance.Domain, err = util.PunifySafely(instance.Domain)
if err != nil {
return gtserror.Newf("error punifying domain %s: %w", instance.Domain, err)
}
// Update the instance's last-updated
instance.UpdatedAt = time.Now()
if len(columns) != 0 {
columns = append(columns, "updated_at")
}
return i.state.Caches.DB.Instance.Store(instance, func() error {
_, err := i.db.
NewUpdate().
Model(instance).
Where("? = ?", bun.Ident("instance.id"), instance.ID).
Column(columns...).
Exec(ctx)
return err
})
}
func (i *instanceDB) GetInstancePeers(ctx context.Context, includeSuspended bool) ([]*gtsmodel.Instance, error) {
instanceIDs := []string{}
q := i.db.
NewSelect().
TableExpr("? AS ?", bun.Ident("instances"), bun.Ident("instance")).
// Select just the IDs of each instance.
Column("instance.id").
// Exclude our own instance.
Where("? != ?", bun.Ident("instance.domain"), config.GetHost())
if !includeSuspended {
q = q.Where("? IS NULL", bun.Ident("instance.suspended_at"))
}
if err := q.Scan(ctx, &instanceIDs); err != nil {
return nil, err
}
if len(instanceIDs) == 0 {
return make([]*gtsmodel.Instance, 0), nil
}
instances := make([]*gtsmodel.Instance, 0, len(instanceIDs))
for _, id := range instanceIDs {
// Select each instance by its ID.
instance, err := i.GetInstanceByID(ctx, id)
if err != nil {
log.Errorf(ctx, "error getting instance %q: %v", id, err)
continue
}
// Append to return slice.
instances = append(instances, instance)
}
return instances, nil
}
func (i *instanceDB) GetInstanceAccounts(ctx context.Context, domain string, maxID string, limit int) ([]*gtsmodel.Account, error) {
// Ensure reasonable
if limit < 0 {
@@ -479,3 +530,214 @@ func (i *instanceDB) GetInstanceModerators(ctx context.Context) ([]*gtsmodel.Acc
return i.state.DB.GetAccountsByIDs(ctx, accountIDs)
}
func (i *instanceDB) AddInstanceDeliveryError(
ctx context.Context,
domain string,
errMsg string,
) error {
// Fetch instance with the given domain.
instance, err := i.GetInstance(
gtscontext.SetBarebones(ctx),
domain,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return gtserror.Newf("db error getting instance: %w", err)
}
if instance == nil {
// No entry so nothing to do. Weird though.
log.Warnf(ctx, "no instance entry found for domain %s", domain)
return nil
}
// Prepare delivery error.
dErr := &gtsmodel.FederationError{
ID: id.NewULID(),
InstanceID: instance.ID,
Type: gtsmodel.FederationErrorTypeDelivery,
Error: errMsg,
}
// Insert delivery error.
if err := i.state.Caches.DB.FederationError.Store(dErr, func() error {
_, err := i.db.
NewInsert().
Model(dErr).
Exec(ctx)
return err
}); err != nil {
return gtserror.Newf("db error putting delivery error: %w", err)
}
// Get ids of delivery errors for this instance.
dErrIDs, err := i.getFederationErrorIDs(ctx,
instance.ID,
gtsmodel.FederationErrorTypeDelivery,
)
if err != nil {
return gtserror.Newf("db error getting existing delivery error IDs: %w", err)
}
// If we don't have more than
// maxDeliveryErrors stored,
// don't bother tidying up.
const maxDeliveryErrors = 20
if len(dErrIDs) <= maxDeliveryErrors {
return nil
}
// Remove any surplus instance delivery errors.
surplusErrIDs := dErrIDs[maxDeliveryErrors:]
if _, err := i.db.
NewDelete().
Table("federation_errors").
Where("? IN (?)", bun.Ident("id"), bun.List(surplusErrIDs)).
Exec(ctx); err != nil {
return gtserror.Newf("db error deleting surplus delivery errors: %w", err)
}
// Invalidate surplus errors from cache.
i.state.Caches.DB.FederationError.InvalidateIDs("ID", surplusErrIDs)
return nil
}
func (i *instanceDB) SetInstanceSuccessfulDelivery(
ctx context.Context,
domain string,
) error {
// Fetch instance with
// the given domain.
instance, err := i.GetInstance(
gtscontext.SetBarebones(ctx),
domain,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return gtserror.Newf("db error getting instance: %w", err)
}
if instance == nil {
// No entry so nothing to do. Weird though.
log.Warnf(ctx, "no instance entry found for domain %s", domain)
return nil
}
// Set latest successful delivery to now.
instance.LatestSuccessfulDelivery = time.Now()
// Update the instance entry.
if err := i.state.Caches.DB.Instance.Store(instance, func() error {
_, err := i.db.
NewUpdate().
Model(instance).
Column("latest_successful_delivery").
Where("? = ?", bun.Ident("instance.id"), instance.ID).
Exec(ctx)
return err
}); err != nil {
return gtserror.Newf("db error updating instance: %w", err)
}
// Clear delivery errors for this instance (if any).
if err := i.clearFederationErrors(ctx,
instance.ID,
gtsmodel.FederationErrorTypeDelivery,
); err != nil {
return gtserror.Newf("db error clearing delivery errors: %w", err)
}
return nil
}
func (i *instanceDB) getFederationErrorIDs(
ctx context.Context,
instanceID string,
errType gtsmodel.FederationErrorType,
) ([]string, error) {
// Get IDs of federation
// errors for this instance.
ids := []string{}
if err := i.db.
NewSelect().
Column("id").
Table("federation_errors").
Where("? = ?", bun.Ident("instance_id"), instanceID).
Where("? = ?", bun.Ident("type"), errType).
OrderExpr("? DESC", bun.Ident("id")).
Scan(ctx, &ids); err != nil && !errors.Is(err, db.ErrNoEntries) {
return nil, err
}
return ids, nil
}
func (i *instanceDB) getFederationErrors(
ctx context.Context,
instanceID string,
errType gtsmodel.FederationErrorType,
) ([]*gtsmodel.FederationError, error) {
// Get IDs of federation
// errors for this instance.
ids, err := i.getFederationErrorIDs(ctx, instanceID, errType)
if err != nil {
return nil, err
}
// Check for 0 entries.
if len(ids) == 0 {
return nil, nil
}
// Load federation errors.
return i.getFederationErrorsByIDs(ctx, ids)
}
func (i *instanceDB) clearFederationErrors(
ctx context.Context,
instanceID string,
errType gtsmodel.FederationErrorType,
) error {
ids := []string{}
if _, err := i.db.
NewDelete().
Table("federation_errors").
Where("? = ?", bun.Ident("instance_id"), instanceID).
Where("? = ?", bun.Ident("type"), errType).
Returning("id").
Exec(ctx, &ids); err != nil {
return err
}
i.state.Caches.DB.FederationError.InvalidateIDs("ID", ids)
return nil
}
func (i *instanceDB) getFederationErrorsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.FederationError, error) {
fErrs, err := i.state.Caches.DB.FederationError.LoadIDs("ID",
ids,
func(uncached []string) ([]*gtsmodel.FederationError, error) {
fErrs := make([]*gtsmodel.FederationError, 0, len(uncached))
// Perform database query scanning
// the remaining (uncached) err IDs.
if err := i.db.
NewSelect().
Model(&fErrs).
Where("? IN (?)", bun.Ident("id"), bun.List(uncached)).
Scan(ctx); err != nil {
return nil, err
}
return fErrs, nil
},
)
if err != nil {
return nil, err
}
// Reorder the errors by their
// IDs to ensure in correct order.
getID := func(t *gtsmodel.FederationError) string { return t.ID }
xslices.OrderBy(fErrs, ids, getID)
return fErrs, nil
}
+44 -25
View File
@@ -18,9 +18,10 @@
package bundb_test
import (
"strconv"
"testing"
"time"
"code.superseriousbusiness.org/gotosocial/internal/config"
"code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/util"
@@ -32,41 +33,23 @@ type InstanceTestSuite struct {
}
func (suite *InstanceTestSuite) TestCountInstanceUsers() {
count, err := suite.db.CountInstanceUsers(suite.T().Context(), config.GetHost())
count, err := suite.db.CountInstanceAccounts(suite.T().Context())
suite.NoError(err)
suite.Equal(5, count)
}
func (suite *InstanceTestSuite) TestCountInstanceUsersRemote() {
count, err := suite.db.CountInstanceUsers(suite.T().Context(), "fossbros-anonymous.io")
suite.NoError(err)
suite.Equal(1, count)
}
func (suite *InstanceTestSuite) TestCountInstanceStatuses() {
count, err := suite.db.CountInstanceStatuses(suite.T().Context(), config.GetHost())
count, err := suite.db.CountInstanceStatuses(suite.T().Context())
suite.NoError(err)
suite.Equal(24, count)
}
func (suite *InstanceTestSuite) TestCountInstanceStatusesRemote() {
count, err := suite.db.CountInstanceStatuses(suite.T().Context(), "fossbros-anonymous.io")
func (suite *InstanceTestSuite) TestCountInstancePeers() {
count, err := suite.db.CountInstancePeers(suite.T().Context())
suite.NoError(err)
suite.Equal(4, count)
}
func (suite *InstanceTestSuite) TestCountInstanceDomains() {
count, err := suite.db.CountInstanceDomains(suite.T().Context(), config.GetHost())
suite.NoError(err)
suite.Equal(2, count)
}
func (suite *InstanceTestSuite) TestGetInstanceOK() {
instance, err := suite.db.GetInstance(suite.T().Context(), "localhost:8080")
suite.NoError(err)
suite.NotNil(instance)
}
func (suite *InstanceTestSuite) TestGetInstanceNonexistent() {
instance, err := suite.db.GetInstance(suite.T().Context(), "doesnt.exist.com")
suite.ErrorIs(err, db.ErrNoEntries)
@@ -76,13 +59,13 @@ func (suite *InstanceTestSuite) TestGetInstanceNonexistent() {
func (suite *InstanceTestSuite) TestGetInstancePeers() {
peers, err := suite.db.GetInstancePeers(suite.T().Context(), false)
suite.NoError(err)
suite.Len(peers, 2)
suite.Len(peers, 4)
}
func (suite *InstanceTestSuite) TestGetInstancePeersIncludeSuspended() {
peers, err := suite.db.GetInstancePeers(suite.T().Context(), true)
suite.NoError(err)
suite.Len(peers, 2)
suite.Len(peers, 5)
}
func (suite *InstanceTestSuite) TestGetInstanceAccounts() {
@@ -127,6 +110,42 @@ func (suite *InstanceTestSuite) TestGetInstanceModeratorAddressesNoAdmin() {
suite.Empty(addresses)
}
func (suite *InstanceTestSuite) TestInstanceDeliveryTracking() {
ctx := suite.T().Context()
testInstance := suite.testInstances["thequeenisstillalive.technology"]
for i := 0; i <= 25; i++ {
if err := suite.state.DB.AddInstanceDeliveryError(ctx,
testInstance.Domain,
"error "+strconv.Itoa(i),
); err != nil {
suite.FailNow(err.Error())
}
instance, err := suite.state.DB.GetInstanceByID(ctx, testInstance.ID)
if err != nil {
suite.FailNow(err.Error())
}
if l := len(instance.DeliveryErrors); l > 20 {
suite.FailNow("", "instance delivery errors length was %d, wanted < 20", l)
}
}
// Clear all the errors we just added by setting successful delivery to now.
if err := suite.state.DB.SetInstanceSuccessfulDelivery(ctx, testInstance.Domain); err != nil {
suite.FailNow(err.Error())
}
instance, err := suite.state.DB.GetInstanceByID(ctx, testInstance.ID)
if err != nil {
suite.FailNow(err.Error())
}
suite.Empty(instance.DeliveryErrors)
suite.WithinDuration(time.Now(), instance.LatestSuccessfulDelivery, 1*time.Minute)
}
func TestInstanceTestSuite(t *testing.T) {
suite.Run(t, new(InstanceTestSuite))
}
@@ -0,0 +1,311 @@
// 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"
"errors"
"fmt"
"strings"
"code.superseriousbusiness.org/gopkg/log"
"code.superseriousbusiness.org/gotosocial/internal/config"
dbpkg "code.superseriousbusiness.org/gotosocial/internal/db"
newmodel "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20260224093340_track_unreachable_instances/newmodel"
oldmodel "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20260224093340_track_unreachable_instances/oldmodel"
"code.superseriousbusiness.org/gotosocial/internal/id"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
log.Info(ctx, "migrating instances table, this may take a little while...")
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
// Create federation errors table.
if _, err := tx.
NewCreateTable().
Model((*newmodel.FederationError)(nil)).
Exec(ctx); err != nil {
return err
}
// Index federation errors table.
//
// This index allows doing selects by instance ID and
// type, ID descending (ie., newest errors to oldest).
if err := createIndex(ctx, tx,
"federation_errors_instance_id_type_idx",
"federation_errors",
dbpkg.BunExpr{
"?, ?, ? DESC",
dbpkg.Idents(
"instance_id",
"type",
"id",
)},
); err != nil {
return err
}
// Create instance settings table.
if _, err := tx.
NewCreateTable().
Model((*newmodel.InstanceSettings)(nil)).
Exec(ctx); err != nil {
return err
}
// If there's an instance entry for our
// own instance, take it out and use it
// create the instance settings entry.
host := config.GetHost()
oldInstance := new(oldmodel.Instance)
err := tx.
NewSelect().
Table("instances").
Where("? = ?", bun.Ident("domain"), host).
Scan(ctx, oldInstance)
if err != nil && !errors.Is(err, dbpkg.ErrNoEntries) {
return err
}
if oldInstance.ID != "" {
// We had an instance entry stored for our own
// instance, create an instance settings from it.
settings := &newmodel.InstanceSettings{
// Use time for ID so we can potentially do something cool
// in future like "instance existed since blah blah blah time".
ID: id.NewULIDFromTime(oldInstance.CreatedAt),
Title: oldInstance.Title,
ShortDescription: oldInstance.ShortDescription,
ShortDescriptionText: oldInstance.ShortDescriptionText,
Description: oldInstance.Description,
DescriptionText: oldInstance.DescriptionText,
CustomCSS: oldInstance.CustomCSS,
Terms: oldInstance.Terms,
TermsText: oldInstance.TermsText,
ContactEmail: oldInstance.ContactEmail,
ContactAccountUsername: oldInstance.ContactAccountUsername,
ContactAccountID: oldInstance.ContactAccountID,
}
if _, err := tx.
NewInsert().
Model(settings).
Exec(ctx); err != nil {
return err
}
// Remove this entry from the existing instances
// table, as we don't want it affecting the count
// of instances we need to update in a minute.
if _, err := tx.
NewDelete().
Table("instances").
Where("? = ?", bun.Ident("id"), oldInstance.ID).
Exec(ctx); err != nil {
return err
}
}
var (
// ID for paging.
maxID string = id.Highest
// Batch size for
// selecting + updating.
batchsz = 100
// Number of instances
// updated so far.
updated int
)
// Create the new instances table.
if _, err := tx.
NewCreateTable().
ModelTableExpr("new_instances").
Model((*newmodel.Instance)(nil)).
Exec(ctx); err != nil {
return err
}
// Count number of instances we need to update.
// This will exclude our own instance entry.
total, err := tx.
NewSelect().
Table("instances").
Count(ctx)
if err != nil && !errors.Is(err, dbpkg.ErrNoEntries) {
return err
}
for {
// Select old instances.
oldInstances := make([]*oldmodel.Instance, 0, batchsz)
if err := tx.
NewSelect().
Model(&oldInstances).
Where("? < ?", bun.Ident("id"), maxID).
OrderExpr("? DESC", bun.Ident("id")).
Limit(batchsz).
Scan(ctx); err != nil {
return err
}
l := len(oldInstances)
if len(oldInstances) == 0 {
// Nothing left
// to update.
break
}
// Convert old model
// instances into new ones.
newInstances := make([]*newmodel.Instance, 0, l)
for _, oldInstance := range oldInstances {
newInstances = append(newInstances, &newmodel.Instance{
// Use ID from time instead of random ID like
// previously, so we can sort by first-seen.
ID: id.NewULIDFromTime(oldInstance.CreatedAt),
Domain: oldInstance.Domain,
// Take just software without version, as that
// changes so it's kinda pointless storing it.
Software: strings.Split(oldInstance.Version, " ")[0],
})
}
// Insert this batch of instances.
// We don't care about return values.
res, err := tx.
NewInsert().
Model(&newInstances).
ModelTableExpr("new_instances").
Returning("").
Exec(ctx)
if err != nil {
return err
}
// Add rows affected to updated count.
rowsAffected, err := res.RowsAffected()
if err != nil {
return err
}
updated += int(rowsAffected)
if updated == total {
// Done.
break
}
// Set next page.
maxID = oldInstances[l-1].ID
// Log helpful message to admin.
log.Infof(ctx,
"migrated %d of %d instances (next page will be from %s)",
updated, total, maxID,
)
}
if total != int(updated) {
// Return error here in order to rollback the whole transaction.
return fmt.Errorf("total=%d does not match updated=%d", total, updated)
}
log.Infof(ctx, "finished migrating %d instances", total)
// Drop the old table.
log.Info(ctx, "dropping old instances table")
if _, err := tx.
NewDropTable().
Table("instances").
Exec(ctx); err != nil {
return err
}
// Rename new table to old table.
log.Info(ctx, "renaming new instances table")
if _, err := tx.
ExecContext(
ctx,
"ALTER TABLE ? RENAME TO ?",
bun.Ident("new_instances"),
bun.Ident("instances"),
); err != nil {
return err
}
if tx.Dialect().Name() == dialect.PG {
log.Info(ctx, "moving postgres constraints from old table to new table")
type spec struct {
old string
new string
columns []string
}
// Rename uniqueness constraints from
// "new_instances_*" to "instances_*".
for _, spec := range []spec{
{
old: "new_instances_pkey",
new: "instances_pkey",
columns: []string{"id"},
},
{
old: "new_instances_domain_key",
new: "instances_domain_key",
columns: []string{"domain"},
},
} {
if _, err := tx.ExecContext(
ctx,
"ALTER TABLE ? DROP CONSTRAINT IF EXISTS ?",
bun.Ident("instances"),
bun.Safe(spec.old),
); err != nil {
return err
}
if _, err := tx.ExecContext(
ctx,
"ALTER TABLE ? ADD CONSTRAINT ? UNIQUE(?)",
bun.Ident("instances"),
bun.Safe(spec.new),
bun.Safe(strings.Join(spec.columns, ",")),
); err != nil {
return err
}
}
}
return nil
})
}
down := func(ctx context.Context, db *bun.DB) error {
return nil
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}
@@ -0,0 +1,25 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
type FederationError struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
InstanceID string `bun:"type:CHAR(26),nullzero,notnull"`
Type int16 `bun:",nullzero,notnull"`
Error string `bun:",nullzero,notnull"`
}
@@ -0,0 +1,27 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import "time"
type Instance struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
Domain string `bun:",nullzero,notnull,unique"`
Software string `bun:",nullzero"`
LatestSuccessfulDelivery time.Time `bun:"type:timestamptz,nullzero"`
}
@@ -0,0 +1,33 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
type InstanceSettings struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
Title string `bun:""`
ShortDescription string `bun:""`
ShortDescriptionText string `bun:""`
Description string `bun:""`
DescriptionText string `bun:""`
CustomCSS string `bun:",nullzero"`
Terms string `bun:""`
TermsText string `bun:""`
ContactEmail string `bun:""`
ContactAccountUsername string `bun:",nullzero"`
ContactAccountID string `bun:"type:CHAR(26),nullzero"`
}
@@ -0,0 +1,43 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
import "time"
type Instance struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
Domain string `bun:",nullzero,notnull,unique"`
Title string `bun:""`
URI string `bun:",nullzero,notnull,unique"`
SuspendedAt time.Time `bun:"type:timestamptz,nullzero"`
DomainBlockID string `bun:"type:CHAR(26),nullzero"`
ShortDescription string `bun:""`
ShortDescriptionText string `bun:""`
Description string `bun:""`
DescriptionText string `bun:""`
CustomCSS string `bun:",nullzero"`
Terms string `bun:""`
TermsText string `bun:""`
ContactEmail string `bun:""`
ContactAccountUsername string `bun:",nullzero"`
ContactAccountID string `bun:"type:CHAR(26),nullzero"`
Reputation int64 `bun:",notnull,default:0"`
Version string `bun:",nullzero"`
}
+17 -11
View File
@@ -21,18 +21,19 @@ import (
"context"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/paging"
)
// Instance contains functions for instance-level actions (counting instance users etc.).
type Instance interface {
// CountInstanceUsers returns the number of known accounts registered with the given domain.
CountInstanceUsers(ctx context.Context, domain string) (int, error)
// CountInstanceAccounts returns the number of non-suspended accounts on this instance.
CountInstanceAccounts(ctx context.Context) (int, error)
// CountInstanceStatuses returns the number of known statuses posted from the given domain.
CountInstanceStatuses(ctx context.Context, domain string) (int, error)
// CountInstancePeers returns the number of statuses on this instance.
CountInstanceStatuses(ctx context.Context) (int, error)
// CountInstanceDomains returns the number of known instances known that the given domain federates with.
CountInstanceDomains(ctx context.Context, domain string) (int, error)
// CountInstancePeers returns the number of instances that this instance peers aka federates with.
CountInstancePeers(ctx context.Context) (int, error)
// GetInstance returns the instance entry for the given domain, if it exists.
GetInstance(ctx context.Context, domain string) (*gtsmodel.Instance, error)
@@ -40,14 +41,19 @@ type Instance interface {
// GetInstanceByID returns the instance entry corresponding to the given id, if it exists.
GetInstanceByID(ctx context.Context, id string) (*gtsmodel.Instance, error)
// PopulateInstance populates the struct pointers on the given instance.
PopulateInstance(ctx context.Context, instance *gtsmodel.Instance) error
// PutInstance inserts the given instance into the database.
PutInstance(ctx context.Context, instance *gtsmodel.Instance) error
// UpdateInstance updates the given instance entry.
UpdateInstance(ctx context.Context, instance *gtsmodel.Instance, columns ...string) error
// GetInstancesPage gets a page of instances with the given parameters.
GetInstancesPage(ctx context.Context, page *paging.Page, domain string, orderBy gtsmodel.InstanceOrderBy, withErrorsOnly bool) ([]*gtsmodel.Instance, error)
// AddInstanceDeliveryError adds the given instance delivery error message
// to the instance delivery errors field for the given domain, if it exists.
AddInstanceDeliveryError(ctx context.Context, domain string, errMsg string) error
// SetInstanceSuccessfulDelivery updates the LatestSuccessfulDelivery time on the
// instance entry for the given domain to time.Now() and clears stored delivery errors.
SetInstanceSuccessfulDelivery(ctx context.Context, domain string) error
// GetInstanceAccounts returns a slice of accounts from the given instance, arranged by ID.
GetInstanceAccounts(ctx context.Context, domain string, maxID string, limit int) ([]*gtsmodel.Account, error)
+8 -3
View File
@@ -523,14 +523,19 @@ func (f *Federator) fetchAccountInstance(
requestedUser string,
accountURI *url.URL,
) error {
// Look for an existing entry for instance in database.
instance, err := f.db.GetInstance(ctx, accountURI.Host)
// Look for an existing entry
// for instance in database.
instance, err := f.db.GetInstance(
gtscontext.SetBarebones(ctx),
accountURI.Host,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return gtserror.Newf("error getting instance from database: %w", err)
}
if instance != nil {
// already fetched.
// Already
// in the db.
return nil
}
@@ -41,7 +41,7 @@ func (suite *InstanceTestSuite) TestDerefInstance() {
// Fossbros anonymous doesn't shield their nodeinfo or
// well-known or anything so we should be able to fetch.
instanceIRI: testrig.URLMustParse("https://fossbros-anonymous.io"),
expectedSoftware: "Hellsoft 6.6.6",
expectedSoftware: "Hellsoft",
},
{
// Furtive nerds forbids /nodeinfo using
@@ -84,7 +84,7 @@ func (suite *InstanceTestSuite) TestDerefInstance() {
suite.FailNow(err.Error())
}
suite.Equal(tc.expectedSoftware, instance.Version)
suite.Equal(tc.expectedSoftware, instance.Software)
}
}
+50
View File
@@ -0,0 +1,50 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
type FederationError struct {
// ID of this item in the database.
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
// ID of the instance to which
// this federation error pertains.
InstanceID string `bun:"type:CHAR(26),nullzero,notnull"`
// Type of this federation error.
Type FederationErrorType `bun:",nullzero,notnull"`
// Error message.
Error string `bun:",nullzero,notnull"`
}
type FederationErrorType enumType
const (
// Should not occur.
FederationErrorTypeUnknown FederationErrorType = iota
// Error while attempting
// delivery to an inbox.
FederationErrorTypeDelivery
// RESERVED: currently unused.
FederationErrorTypeDereferencing
// RESERVED: currently unused.
FederationErrorTypeHTTPSignature
)
+47 -25
View File
@@ -17,31 +17,53 @@
package gtsmodel
import "time"
import (
"time"
)
// Instance represents a federated instance, either local or remote.
// Instance represents a
// single federated instance.
type Instance struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
Domain string `bun:",nullzero,notnull,unique"` // Instance domain eg example.org
Title string `bun:""` // Title of this instance as it would like to be displayed.
URI string `bun:",nullzero,notnull,unique"` // base URI of this instance eg https://example.org
SuspendedAt time.Time `bun:"type:timestamptz,nullzero"` // When was this instance suspended, if at all?
DomainBlockID string `bun:"type:CHAR(26),nullzero"` // ID of any existing domain block for this instance in the database
DomainBlock *DomainBlock `bun:"rel:belongs-to"` // Domain block corresponding to domainBlockID
ShortDescription string `bun:""` // Short description of this instance
ShortDescriptionText string `bun:""` // Raw text version of short description (before parsing).
Description string `bun:""` // Longer description of this instance.
DescriptionText string `bun:""` // Raw text version of long description (before parsing).
CustomCSS string `bun:",nullzero"` // Custom CSS for the instance.
Terms string `bun:""` // Terms and conditions of this instance.
TermsText string `bun:""` // Raw text version of terms (before parsing).
ContactEmail string `bun:""` // Contact email address for this instance
ContactAccountUsername string `bun:",nullzero"` // Username of the contact account for this instance
ContactAccountID string `bun:"type:CHAR(26),nullzero"` // Contact account ID in the database for this instance
ContactAccount *Account `bun:"rel:belongs-to"` // account corresponding to contactAccountID
Reputation int64 `bun:",notnull,default:0"` // Reputation score of this instance
Version string `bun:",nullzero"` // Version of the software used on this instance
Rules []Rule `bun:"-"` // List of instance rules
// ID of this item in the database.
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
// Instance domain,
// eg., example.org
Domain string `bun:",nullzero,notnull,unique"`
// Software deployed for this
// instance, eg., "mastodon".
Software string `bun:",nullzero"`
// Time of latest *SUCCESSFUL* attempt
// to deliver a message to this instance.
LatestSuccessfulDelivery time.Time `bun:"type:timestamptz,nullzero"`
// Recent delivery errors.
//
// Not stored in the db.
DeliveryErrors []*FederationError `bun:"-"`
}
// InstanceOrderBy is for doing db
// queries for admin view of instances
type InstanceOrderBy enumType
const (
InstanceOrderByUnknown InstanceOrderBy = iota
// Order alphabetically (a -> z).
InstanceOrderByAlphabetical
// Order by date instance first seen (newest -> oldest).
InstanceOrderByFirstSeen
)
func (d InstanceOrderBy) String() string {
switch d {
case InstanceOrderByAlphabetical:
return "alphabetical"
case InstanceOrderByFirstSeen:
return "first_seen"
default:
return "unknown"
}
}
+70
View File
@@ -0,0 +1,70 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package gtsmodel
// InstanceSettings represents settings for the instance.
type InstanceSettings struct {
// ID of this item in the database.
//
// Note: no need to set this as "unique", since
// there will only ever be one entry in this table.
ID string `bun:"type:CHAR(26),pk,nullzero,notnull"`
// Title of the instance.
Title string `bun:""`
// Short description of the instance
ShortDescription string `bun:""`
// Raw text version of short
// description (before parsing).
ShortDescriptionText string `bun:""`
// Longer description of the instance.
Description string `bun:""`
// Raw text version of long
// description (before parsing).
DescriptionText string `bun:""`
// Custom CSS for the instance.
CustomCSS string `bun:",nullzero"`
// Terms and conditions of the instance.
Terms string `bun:""`
// Raw text version of terms (before parsing).
TermsText string `bun:""`
// Contact email address for the instance
ContactEmail string `bun:""`
// Username of the contact account for the instance
ContactAccountUsername string `bun:",nullzero"`
// Contact account ID in the database for the instance
ContactAccountID string `bun:"type:CHAR(26),nullzero"`
// Account corresponding to contactAccountID.
// Field not stored in the db.
ContactAccount *Account `bun:"-"`
// List of instance rules.
// Field not stored in the db.
Rules []Rule `bun:"-"`
}
+3 -5
View File
@@ -76,13 +76,11 @@ func InitializeMetrics(ctx context.Context, state *state.State) error {
meter := meterProvider.Meter(serviceName)
thisInstance := config.GetHost()
_, err = meter.Int64ObservableGauge(
"gotosocial.instance.total_users",
metric.WithDescription("Total number of users on this instance"),
metric.WithInt64Callback(func(ctx context.Context, o metric.Int64Observer) error {
userCount, err := state.DB.CountInstanceUsers(ctx, thisInstance)
userCount, err := state.DB.CountInstanceAccounts(ctx)
if err != nil {
return err
}
@@ -98,7 +96,7 @@ func InitializeMetrics(ctx context.Context, state *state.State) error {
"gotosocial.instance.total_statuses",
metric.WithDescription("Total number of statuses on this instance"),
metric.WithInt64Callback(func(ctx context.Context, o metric.Int64Observer) error {
statusCount, err := state.DB.CountInstanceStatuses(ctx, thisInstance)
statusCount, err := state.DB.CountInstanceStatuses(ctx)
if err != nil {
return err
}
@@ -114,7 +112,7 @@ func InitializeMetrics(ctx context.Context, state *state.State) error {
"gotosocial.instance.total_federating_instances",
metric.WithDescription("Total number of other instances this instance is federating with"),
metric.WithInt64Callback(func(ctx context.Context, o metric.Int64Observer) error {
federatingCount, err := state.DB.CountInstanceDomains(ctx, thisInstance)
federatingCount, err := state.DB.CountInstancePeers(ctx)
if err != nil {
return err
}
+2 -2
View File
@@ -107,7 +107,7 @@ func (p *Processor) DirectoryGet(
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
Query: url.Values{
apiutil.DirectoryOrderKey: []string{orderBy.String()},
apiutil.OrderKey: []string{orderBy.String()},
},
}), nil
}
@@ -163,7 +163,7 @@ func (p *Processor) WebDirectoryGet(
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
Query: url.Values{
apiutil.DirectoryOrderKey: []string{orderBy.String()},
apiutil.OrderKey: []string{orderBy.String()},
},
}), nil
}
@@ -102,7 +102,7 @@ func (p *Processor) DomainPermissionDraftsGet(
query.Set(apiutil.DomainPermissionSubscriptionIDKey, subscriptionID)
}
if domain != "" {
query.Set(apiutil.DomainPermissionDomainKey, domain)
query.Set(apiutil.DomainKey, domain)
}
if permType != gtsmodel.DomainPermissionUnknown {
query.Set(apiutil.DomainPermissionPermTypeKey, permType.String())
@@ -121,7 +121,7 @@ func (p *Processor) DomainPermissionExcludesGet(
// Assemble next/prev page queries.
query := make(url.Values, 1)
if domain != "" {
query.Set(apiutil.DomainPermissionDomainKey, domain)
query.Set(apiutil.DomainKey, domain)
}
return paging.PackageResponse(paging.ResponseParams{
+6 -6
View File
@@ -19,7 +19,6 @@ package admin
import (
"context"
"fmt"
"code.superseriousbusiness.org/gotosocial/internal/config"
"code.superseriousbusiness.org/gotosocial/internal/email"
@@ -40,19 +39,20 @@ func (p *Processor) EmailTest(
toAddress string,
message string,
) gtserror.WithCode {
// Pull our instance entry from the database,
// Pull our instance settings from the database,
// so we can greet the email recipient nicely.
instance, err := p.state.DB.GetInstance(ctx, config.GetHost())
settings, err := p.state.DB.GetInstanceSettings(ctx)
if err != nil {
err = fmt.Errorf("SendConfirmEmail: error getting instance: %s", err)
err := gtserror.Newf("db error getting instance settings: %w", err)
return gtserror.NewErrorInternalError(err)
}
instanceURL := config.GetProtocol() + "://" + config.GetHost()
testData := email.TestData{
SendingUsername: account.Username,
Message: message,
InstanceURL: instance.URI,
InstanceName: instance.Title,
InstanceURL: instanceURL,
InstanceName: settings.Title,
}
if err := p.email.SendTestEmail(toAddress, testData); err != nil {
+113
View File
@@ -0,0 +1,113 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package admin
import (
"context"
"errors"
"net/url"
"strconv"
"code.superseriousbusiness.org/gopkg/log"
apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util"
"code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/paging"
)
func (p *Processor) InstancesGet(
ctx context.Context,
page *paging.Page,
domain string,
orderBy gtsmodel.InstanceOrderBy,
withErrorsOnly bool,
) (*apimodel.PageableResponse, gtserror.WithCode) {
// Get specified page of instances.
instances, err := p.state.DB.GetInstancesPage(ctx, page, domain, orderBy, withErrorsOnly)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting instances: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Check items length.
count := len(instances)
if count == 0 {
return paging.EmptyResponse(), nil
}
var (
// Preallocate expected items.
items = make([]any, 0, count)
// Set paging low / high IDs.
lo = instances[count-1].ID
hi = instances[0].ID
)
for _, a := range instances {
item, err := p.converter.InstanceToAdminAPIInstance(ctx, a)
if err != nil {
log.Errorf(ctx, "error converting to admin API instance: %v", err)
continue
}
items = append(items, item)
}
// Prepare paging query kvs.
query := url.Values{
apiutil.OrderKey: []string{orderBy.String()},
apiutil.AdminWithErrorsOnlyKey: []string{strconv.FormatBool(withErrorsOnly)},
}
if domain != "" {
query.Add(apiutil.DomainKey, domain)
}
// Prepare response.
return paging.PackageResponse(paging.ResponseParams{
Items: items,
Path: "/api/v1/admin/instances",
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
Query: query,
}), nil
}
func (p *Processor) InstanceGet(ctx context.Context, id string) (*apimodel.AdminInstance, gtserror.WithCode) {
// Get instance with specified ID.
instance, err := p.state.DB.GetInstanceByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting instance: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if instance == nil {
err := gtserror.Newf("instance not found in the db: %w", err)
return nil, gtserror.NewErrorNotFound(err)
}
item, err := p.converter.InstanceToAdminAPIInstance(ctx, instance)
if err != nil {
err := gtserror.Newf("error converting to admin API instance: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return item, nil
}
+14 -16
View File
@@ -96,26 +96,24 @@ func (p *Processor) NodeInfoGet(ctx context.Context, schemaVersion string) (*api
default:
// Mode is either "serve" or "default".
// Count actual stats.
host := config.GetHost()
userCount, err = p.state.DB.CountInstanceUsers(ctx, host)
userCount, err = p.state.DB.CountInstanceAccounts(ctx)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
postCount, err = p.state.DB.CountInstanceStatuses(ctx, host)
postCount, err = p.state.DB.CountInstanceStatuses(ctx)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
}
// Fill `metadata` field with instance info
instance, err := p.state.DB.GetInstance(ctx, config.GetHost())
// Fill `metadata` field with instance settings.
settings, err := p.state.DB.GetInstanceSettings(ctx)
if err != nil {
err := fmt.Errorf("db error getting instance: %w", err)
err := fmt.Errorf("db error getting instance settings: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
metadata := getNodeInfoMetadata(instance)
metadata := getNodeInfoMetadata(settings)
nodeInfo := &apimodel.Nodeinfo{
Version: schemaVersion,
@@ -149,25 +147,25 @@ func (p *Processor) NodeInfoGet(ctx context.Context, schemaVersion string) (*api
// getNodeInfoMetadata populates + returns a
// `metadata` map based on Misskey's nodeinfo metadata.
func getNodeInfoMetadata(instance *gtsmodel.Instance) map[string]any {
func getNodeInfoMetadata(settings *gtsmodel.InstanceSettings) map[string]any {
nodeInfoMetadata := make(map[string]any)
// nodeName: Name of this instance.
// Using title as name should be OK.
nodeInfoMetadata["nodeName"] = instance.Title
nodeInfoMetadata["nodeName"] = settings.Title
// nodeDescription: description of the site.
// Misskey seems to use HTML here, so it should be fine.
nodeInfoMetadata["nodeDescription"] = instance.Description
nodeInfoMetadata["nodeDescription"] = settings.Description
// Contact related info.
contactField := make(map[string]string, 2)
if instance.ContactAccount != nil {
contactField["name"] = "@" + instance.ContactAccount.Username + "@" + config.GetAccountDomain()
if settings.ContactAccount != nil {
contactField["name"] = "@" + settings.ContactAccount.Username + "@" + config.GetAccountDomain()
}
if instance.ContactEmail != "" {
contactField["email"] = instance.ContactEmail
nodeInfoMetadata["inquiryUrl"] = "mailto:" + instance.ContactEmail
if settings.ContactEmail != "" {
contactField["email"] = settings.ContactEmail
nodeInfoMetadata["inquiryUrl"] = "mailto:" + settings.ContactEmail
}
if len(contactField) != 0 {
nodeInfoMetadata["nodeAdmins"] = []any{contactField}
+33 -38
View File
@@ -27,7 +27,6 @@ import (
"code.superseriousbusiness.org/gopkg/log"
"code.superseriousbusiness.org/gopkg/xslices"
apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
"code.superseriousbusiness.org/gotosocial/internal/config"
"code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
@@ -38,31 +37,35 @@ import (
)
func (p *Processor) InstanceGetV1(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) {
i, err := p.getThisInstance(ctx)
settings, err := p.state.DB.GetInstanceSettings(ctx)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error fetching instance: %s", err))
err := gtserror.Newf("db error fetching instance settings: %s", err)
return nil, gtserror.NewErrorInternalError(err)
}
ai, err := p.converter.InstanceToAPIV1Instance(ctx, i)
v1, err := p.converter.InstanceSettingsToAPIV1Instance(ctx, settings)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting instance to api representation: %s", err))
err := gtserror.Newf("error converting instance settings to api representation: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return ai, nil
return v1, nil
}
func (p *Processor) InstanceGetV2(ctx context.Context) (*apimodel.InstanceV2, gtserror.WithCode) {
i, err := p.getThisInstance(ctx)
settings, err := p.state.DB.GetInstanceSettings(ctx)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error fetching instance: %s", err))
err := gtserror.Newf("db error fetching instance settings: %s", err)
return nil, gtserror.NewErrorInternalError(err)
}
ai, err := p.converter.InstanceToAPIV2Instance(ctx, i)
v2, err := p.converter.InstanceSettingsToAPIV2Instance(ctx, settings)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting instance to api representation: %s", err))
err := gtserror.Newf("error converting instance settings to api representation: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return ai, nil
return v2, nil
}
func (p *Processor) InstancePeersGet(
@@ -188,19 +191,20 @@ func (p *Processor) InstancePeersGet(
}
func (p *Processor) InstanceGetRules(ctx context.Context) ([]apimodel.InstanceRule, gtserror.WithCode) {
i, err := p.getThisInstance(ctx)
rules, err := p.state.DB.GetActiveRules(ctx)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error fetching instance: %s", err))
err := gtserror.Newf("db error getting rules: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return typeutils.InstanceRulesToAPIRules(i.Rules), nil
return typeutils.InstanceRulesToAPIRules(rules), nil
}
func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSettingsUpdateRequest) (*apimodel.InstanceV1, gtserror.WithCode) {
// Fetch this instance from the db for processing.
instance, err := p.getThisInstance(ctx)
// Fetch instance settings from the db for processing.
settings, err := p.state.DB.GetInstanceSettings(ctx)
if err != nil {
err = fmt.Errorf("db error fetching instance: %w", err)
err := gtserror.Newf("db error fetching instance settings: %s", err)
return nil, gtserror.NewErrorInternalError(err)
}
@@ -224,7 +228,7 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe
}
// Don't allow html in site title.
instance.Title = text.StripHTMLFromText(title)
settings.Title = text.StripHTMLFromText(title)
columns = append(columns, "title")
}
@@ -239,7 +243,7 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe
}
columns = append(columns, "contact_account_id")
instance.ContactAccountID = contactAccountID
settings.ContactAccountID = contactAccountID
}
// Validate & update contact
@@ -255,7 +259,7 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe
}
columns = append(columns, "contact_email")
instance.ContactEmail = contactEmail
settings.ContactEmail = contactEmail
}
// Validate & update site short
@@ -268,8 +272,8 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe
// Parse description as Markdown, keep
// the raw version for later editing.
instance.ShortDescriptionText = shortDescription
instance.ShortDescription = p.formatter.FromMarkdown(ctx, p.parseMentionFunc, instanceAcc.ID, "", shortDescription).HTML
settings.ShortDescriptionText = shortDescription
settings.ShortDescription = p.formatter.FromMarkdown(ctx, p.parseMentionFunc, instanceAcc.ID, "", shortDescription).HTML
columns = append(columns, []string{"short_description", "short_description_text"}...)
}
@@ -282,8 +286,8 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe
// Parse description as Markdown, keep
// the raw version for later editing.
instance.DescriptionText = description
instance.Description = p.formatter.FromMarkdown(ctx, p.parseMentionFunc, instanceAcc.ID, "", description).HTML
settings.DescriptionText = description
settings.Description = p.formatter.FromMarkdown(ctx, p.parseMentionFunc, instanceAcc.ID, "", description).HTML
columns = append(columns, []string{"description", "description_text"}...)
}
@@ -294,7 +298,7 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
instance.CustomCSS = text.StripHTMLFromText(customCSS)
settings.CustomCSS = text.StripHTMLFromText(customCSS)
columns = append(columns, []string{"custom_css"}...)
}
@@ -308,8 +312,8 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe
// Parse terms as Markdown, keep
// the raw version for later editing.
instance.TermsText = terms
instance.Terms = p.formatter.FromMarkdown(ctx, p.parseMentionFunc, "", "", terms).HTML
settings.TermsText = terms
settings.Terms = p.formatter.FromMarkdown(ctx, p.parseMentionFunc, "", "", terms).HTML
columns = append(columns, []string{"terms", "terms_text"}...)
}
@@ -362,8 +366,8 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe
}
if len(columns) != 0 {
if err := p.state.DB.UpdateInstance(ctx, instance, columns...); err != nil {
err = fmt.Errorf("db error updating instance: %w", err)
if err := p.state.DB.UpdateInstanceSettings(ctx, settings, columns...); err != nil {
err = fmt.Errorf("db error updating instance settings: %w", err)
return nil, gtserror.NewErrorInternalError(err, err.Error())
}
}
@@ -371,15 +375,6 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe
return p.InstanceGetV1(ctx)
}
func (p *Processor) getThisInstance(ctx context.Context) (*gtsmodel.Instance, error) {
instance, err := p.state.DB.GetInstance(ctx, config.GetHost())
if err != nil {
return nil, err
}
return instance, nil
}
func (p *Processor) contactAccountIDForUsername(ctx context.Context, username string) (string, error) {
if username == "" {
// Easy: unset
+35 -22
View File
@@ -25,6 +25,7 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/config"
"code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/email"
"code.superseriousbusiness.org/gotosocial/internal/gtscontext"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/uris"
@@ -51,7 +52,8 @@ func (s *Surfacer) EmailUserReportClosed(ctx context.Context, report *gtsmodel.R
return nil
}
instance, err := s.state.DB.GetInstance(ctx, config.GetHost())
// Get instance settings barebones as we only need the title.
instanceSettings, err := s.state.DB.GetInstanceSettings(gtscontext.SetBarebones(ctx))
if err != nil {
return gtserror.Newf("db error getting instance: %w", err)
}
@@ -60,10 +62,11 @@ func (s *Surfacer) EmailUserReportClosed(ctx context.Context, report *gtsmodel.R
return gtserror.Newf("error populating report: %w", err)
}
instanceURL := config.GetProtocol() + "://" + config.GetHost()
reportClosedData := email.ReportClosedData{
Username: report.Account.Username,
InstanceURL: instance.URI,
InstanceName: instance.Title,
InstanceURL: instanceURL,
InstanceName: instanceSettings.Title,
ReportTargetUsername: report.TargetAccount.Username,
ReportTargetDomain: report.TargetAccount.Domain,
ActionTakenComment: report.ActionTaken,
@@ -85,7 +88,8 @@ func (s *Surfacer) EmailUserPleaseConfirm(ctx context.Context, user *gtsmodel.Us
return nil
}
instance, err := s.state.DB.GetInstance(ctx, config.GetHost())
// Get instance settings barebones as we only need the title.
instanceSettings, err := s.state.DB.GetInstanceSettings(gtscontext.SetBarebones(ctx))
if err != nil {
return gtserror.Newf("db error getting instance: %w", err)
}
@@ -100,12 +104,13 @@ func (s *Surfacer) EmailUserPleaseConfirm(ctx context.Context, user *gtsmodel.Us
)
// Assemble email contents and send the email.
instanceURL := config.GetProtocol() + "://" + config.GetHost()
if err := s.emailSender.SendConfirmEmail(
user.UnconfirmedEmail,
email.ConfirmData{
Username: user.Account.Username,
InstanceURL: instance.URI,
InstanceName: instance.Title,
InstanceURL: instanceURL,
InstanceName: instanceSettings.Title,
ConfirmLink: confirmLink,
NewSignup: newSignup,
},
@@ -144,18 +149,20 @@ func (s *Surfacer) EmailUserSignupApproved(ctx context.Context, user *gtsmodel.U
emailAddr = user.UnconfirmedEmail
}
instance, err := s.state.DB.GetInstance(ctx, config.GetHost())
// Get instance settings barebones as we only need the title.
instanceSettings, err := s.state.DB.GetInstanceSettings(gtscontext.SetBarebones(ctx))
if err != nil {
return gtserror.Newf("db error getting instance: %w", err)
}
// Assemble email contents and send the email.
instanceURL := config.GetProtocol() + "://" + config.GetHost()
if err := s.emailSender.SendSignupApprovedEmail(
emailAddr,
email.SignupApprovedData{
Username: user.Account.Username,
InstanceURL: instance.URI,
InstanceName: instance.Title,
InstanceURL: instanceURL,
InstanceName: instanceSettings.Title,
},
); err != nil {
return err
@@ -180,18 +187,20 @@ func (s *Surfacer) EmailUserSignupApproved(ctx context.Context, user *gtsmodel.U
// emailUserSignupApproved emails the given user
// to inform them their sign-up has been approved.
func (s *Surfacer) EmailUserSignupRejected(ctx context.Context, deniedUser *gtsmodel.DeniedUser) error {
instance, err := s.state.DB.GetInstance(ctx, config.GetHost())
// Get instance settings barebones as we only need the title.
instanceSettings, err := s.state.DB.GetInstanceSettings(gtscontext.SetBarebones(ctx))
if err != nil {
return gtserror.Newf("db error getting instance: %w", err)
}
// Assemble email contents and send the email.
instanceURL := config.GetProtocol() + "://" + config.GetHost()
return s.emailSender.SendSignupRejectedEmail(
deniedUser.Email,
email.SignupRejectedData{
Message: deniedUser.Message,
InstanceURL: instance.URI,
InstanceName: instance.Title,
InstanceURL: instanceURL,
InstanceName: instanceSettings.Title,
},
)
}
@@ -199,9 +208,10 @@ func (s *Surfacer) EmailUserSignupRejected(ctx context.Context, deniedUser *gtsm
// EmailAdminReportOpened emails all active moderators/admins
// of this instance that a new report has been created.
func (s *Surfacer) EmailAdminReportOpened(ctx context.Context, report *gtsmodel.Report) error {
instance, err := s.state.DB.GetInstance(ctx, config.GetHost())
// Get instance settings barebones as we only need the title.
instanceSettings, err := s.state.DB.GetInstanceSettings(gtscontext.SetBarebones(ctx))
if err != nil {
return gtserror.Newf("error getting instance: %w", err)
return gtserror.Newf("db error getting instance: %w", err)
}
toAddresses, err := s.state.DB.GetInstanceModeratorAddresses(ctx)
@@ -217,10 +227,11 @@ func (s *Surfacer) EmailAdminReportOpened(ctx context.Context, report *gtsmodel.
return gtserror.Newf("error populating report: %w", err)
}
instanceURL := config.GetProtocol() + "://" + config.GetHost()
reportData := email.NewReportData{
InstanceURL: instance.URI,
InstanceName: instance.Title,
ReportURL: instance.URI + "/settings/moderation/reports/" + report.ID,
InstanceURL: instanceURL,
InstanceName: instanceSettings.Title,
ReportURL: instanceURL + "/settings/moderation/reports/" + report.ID,
ReportDomain: report.Account.Domain,
ReportTargetDomain: report.TargetAccount.Domain,
}
@@ -235,9 +246,10 @@ func (s *Surfacer) EmailAdminReportOpened(ctx context.Context, report *gtsmodel.
// EmailAdminNewSignup emails all active moderators/admins of this
// instance that a new account sign-up has been submitted to the instance.
func (s *Surfacer) EmailAdminNewSignup(ctx context.Context, newUser *gtsmodel.User) error {
instance, err := s.state.DB.GetInstance(ctx, config.GetHost())
// Get instance settings barebones as we only need the title.
instanceSettings, err := s.state.DB.GetInstanceSettings(gtscontext.SetBarebones(ctx))
if err != nil {
return gtserror.Newf("error getting instance: %w", err)
return gtserror.Newf("db error getting instance: %w", err)
}
toAddresses, err := s.state.DB.GetInstanceModeratorAddresses(ctx)
@@ -254,13 +266,14 @@ func (s *Surfacer) EmailAdminNewSignup(ctx context.Context, newUser *gtsmodel.Us
return gtserror.Newf("error populating user: %w", err)
}
instanceURL := config.GetProtocol() + "://" + config.GetHost()
newSignupData := email.NewSignupData{
InstanceURL: instance.URI,
InstanceName: instance.Title,
InstanceURL: instanceURL,
InstanceName: instanceSettings.Title,
SignupEmail: newUser.UnconfirmedEmail,
SignupUsername: newUser.Account.Username,
SignupReason: newUser.Reason,
SignupURL: instance.URI + "/settings/moderation/accounts/" + newUser.AccountID,
SignupURL: instanceURL + "/settings/moderation/accounts/" + newUser.AccountID,
}
if err := s.emailSender.SendNewSignupEmail(toAddresses, newSignupData); err != nil {
+5 -21
View File
@@ -17,26 +17,10 @@
package trans
import (
"time"
)
// Instance represents an instance entry as serialized in an export file.
// Instance represents an instance
// entry as serialized in an export file.
type Instance struct {
Type Type `json:"type" bun:"-"`
ID string `json:"id" bun:",nullzero"`
CreatedAt *time.Time `json:"createdAt" bun:",nullzero"`
Domain string `json:"domain" bun:",nullzero"`
Title string `json:"title,omitempty" bun:",nullzero"`
URI string `json:"uri" bun:",nullzero"`
SuspendedAt *time.Time `json:"suspendedAt,omitempty" bun:",nullzero"`
DomainBlockID string `json:"domainBlockID,omitempty" bun:",nullzero"`
ShortDescription string `json:"shortDescription,omitempty" bun:",nullzero"`
Description string `json:"description,omitempty" bun:",nullzero"`
Terms string `json:"terms,omitempty" bun:",nullzero"`
ContactEmail string `json:"contactEmail,omitempty" bun:",nullzero"`
ContactAccountUsername string `json:"contactAccountUsername,omitempty" bun:",nullzero"`
ContactAccountID string `json:"contactAccountID,omitempty" bun:",nullzero"`
Reputation int64 `json:"reputation"`
Version string `json:"version,omitempty" bun:",nullzero"`
Type Type `json:"type" bun:"-"`
ID string `json:"id" bun:",nullzero"`
Domain string `json:"domain" bun:",nullzero"`
}
+38 -6
View File
@@ -24,6 +24,7 @@ import (
"time"
"code.superseriousbusiness.org/gopkg/log"
"code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/gtscontext"
"code.superseriousbusiness.org/gotosocial/internal/httpclient"
"code.superseriousbusiness.org/gotosocial/internal/queue"
@@ -46,13 +47,19 @@ type WorkerPool struct {
// internal fields.
workers []*Worker
// Db connection so that workers from
// the pool can read + write delivery
// errors under instance entries.
db db.DB
}
// Init will initialize the Worker{} pool
// with given http client, request queue to pull
// Init will initialize the Worker{} pool with
// given http client and db, request queue to pull
// from and number of delivery workers to spawn.
func (p *WorkerPool) Init(client *httpclient.Client) {
func (p *WorkerPool) Init(client *httpclient.Client, db db.DB) {
p.Client = client
p.db = db
p.Queue.Init(structr.QueueConfig[*Delivery]{
Indices: []structr.IndexConfig{
{Fields: "ActorID", Multiple: true},
@@ -80,6 +87,11 @@ func (p *WorkerPool) Start(n int) {
p.workers[i].Client = p.Client
p.workers[i].Queue = &p.Queue
// Pass db connection to the
// worker so it can update
// instance delivery attempts.
p.workers[i].db = p.db
// Attempt to start worker.
// Return bool not useful
// here, as true = started,
@@ -132,6 +144,11 @@ type Worker struct {
// internal fields.
backlog []*Delivery
service runners.Service
// Db connection so that this worker
// can read + write delivery
// errors under instance entries.
db db.DB
}
// Start will attempt to start the Worker{}.
@@ -216,6 +233,14 @@ loop:
case err == nil:
// Ensure body closed.
_ = rsp.Body.Close()
// Set successful delivery time.
if err := w.db.SetInstanceSuccessfulDelivery(ctx,
dlv.Request.Host,
); err != nil {
log.Errorf(ctx, "db error setting successful delivery: %v", err)
}
continue loop
case errors.Is(err, context.Canceled) &&
@@ -233,9 +258,16 @@ loop:
continue loop
case !retry:
// Drop deliveries when no
// retry requested, or they
// reached max (either).
// Drop deliveries when no retry requested,
// or they reached max (either), but first try
// to store an error for this delivery attempt.
if err := w.db.AddInstanceDeliveryError(ctx,
dlv.Request.Host,
err.Error(),
); err != nil {
log.Errorf(ctx, "db error adding instance delivery error: %v", err)
}
continue loop
}
+1 -1
View File
@@ -45,7 +45,7 @@ func TestDeliveryWorkerPool(t *testing.T) {
func testDeliveryWorkerPool(t *testing.T, sz int, input []*testrequest) {
wp := new(delivery.WorkerPool)
allowLocal := []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}
wp.Init(httpclient.New(httpclient.Config{AllowRanges: allowLocal}))
wp.Init(httpclient.New(httpclient.Config{AllowRanges: allowLocal}), nil)
wp.Start(sz)
defer wp.Stop()
test(t, &wp.Queue, input)
+23 -97
View File
@@ -33,8 +33,6 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/id"
"code.superseriousbusiness.org/gotosocial/internal/util"
"code.superseriousbusiness.org/gotosocial/internal/validate"
"github.com/temoto/robotstxt"
)
@@ -42,9 +40,9 @@ func (t *transport) DereferenceInstance(ctx context.Context, iri *url.URL) (*gts
// Try to fetch robots.txt to check
// if we're allowed to try endpoints:
//
// - /api/v1/instance
// - /.well-known/nodeinfo
// - /nodeinfo/2.0|2.1 endpoints
// - /api/v1/instance
robotsTxt, err := t.DereferenceRobots(ctx, iri.Scheme, iri.Host)
if err != nil {
log.Debugf(ctx, "couldn't fetch robots.txt from %s: %v", iri.Host, err)
@@ -52,20 +50,9 @@ func (t *transport) DereferenceInstance(ctx context.Context, iri *url.URL) (*gts
var i *gtsmodel.Instance
// First try to dereference using /api/v1/instance.
// This will provide the most complete picture of an instance, and avoid unnecessary api calls.
// First try to dereference using nodeinfo.
//
// This will only work with Mastodon-api compatible instances: Mastodon, some Pleroma instances, GoToSocial.
log.Debugf(ctx, "trying to dereference instance %s by /api/v1/instance", iri.Host)
i, err = t.dereferenceByAPIV1Instance(ctx, iri, robotsTxt)
if err == nil {
log.Debugf(ctx, "successfully dereferenced instance using /api/v1/instance")
return i, nil
}
log.Debugf(ctx, "couldn't dereference instance using /api/v1/instance: %s", err)
// If that doesn't work, try to dereference using /.well-known/nodeinfo.
// This will involve two API calls and return less info overall, but should be more widely compatible.
// This should be quite widely compatible.
log.Debugf(ctx, "trying to dereference instance %s by /.well-known/nodeinfo", iri.Host)
i, err = t.dereferenceByNodeInfo(ctx, iri, robotsTxt)
if err == nil {
@@ -74,12 +61,24 @@ func (t *transport) DereferenceInstance(ctx context.Context, iri *url.URL) (*gts
}
log.Debugf(ctx, "couldn't dereference instance using /.well-known/nodeinfo: %s", err)
// we couldn't dereference the instance using any of the known methods, so just return a minimal representation
// Try to dereference using /api/v1/instance.
//
// This will only work with Mastodon-api compatible
// instances: Mastodon, some Pleroma instances, GoToSocial.
log.Debugf(ctx, "trying to dereference instance %s by /api/v1/instance", iri.Host)
i, err = t.dereferenceByAPIV1Instance(ctx, iri, robotsTxt)
if err == nil {
log.Debugf(ctx, "successfully dereferenced instance using /api/v1/instance")
return i, nil
}
log.Debugf(ctx, "couldn't dereference instance using /api/v1/instance: %s", err)
// If we couldn't dereference the instance using any of the
// known methods, just return a minimal representation
log.Debugf(ctx, "returning minimal representation of instance %s", iri.Host)
return &gtsmodel.Instance{
ID: id.NewRandomULID(),
ID: id.NewULID(),
Domain: iri.Host,
URI: iri.String(),
}, nil
}
@@ -155,21 +154,9 @@ func (t *transport) dereferenceByAPIV1Instance(
return nil, err
}
var contactUsername string
if apiResp.ContactAccount != nil {
contactUsername = apiResp.ContactAccount.Username
}
i := &gtsmodel.Instance{
ID: id.NewRandomULID(),
Domain: iri.Host,
Title: apiResp.Title,
URI: iri.Scheme + "://" + iri.Host,
ShortDescription: apiResp.ShortDescription,
Description: apiResp.Description,
ContactEmail: apiResp.Email,
ContactAccountUsername: contactUsername,
Version: apiResp.Version,
ID: id.NewULID(),
Domain: iri.Host,
}
return i, nil
@@ -193,73 +180,12 @@ func (t *transport) dereferenceByNodeInfo(
}
// We got a response of some kind!
//
// Start building out the bare minimum
// instance model, we'll add to it if we can.
i := &gtsmodel.Instance{
ID: id.NewRandomULID(),
Domain: iri.Host,
URI: iri.String(),
ID: id.NewULID(),
Domain: iri.Host,
Software: ni.Software.Name,
}
var title string
if i, present := ni.Metadata["nodeName"]; present {
// it's present, check it's a string
if v, ok := i.(string); ok {
// it is a string!
title = v
}
}
i.Title = title
var shortDescription string
if i, present := ni.Metadata["nodeDescription"]; present {
// it's present, check it's a string
if v, ok := i.(string); ok {
// it is a string!
shortDescription = v
}
}
i.ShortDescription = shortDescription
var contactEmail string
var contactAccountUsername string
if i, present := ni.Metadata["maintainer"]; present {
// it's present, check it's a map
if v, ok := i.(map[string]string); ok {
// see if there's an email in the map
if email, present := v["email"]; present {
if err := validate.Email(email); err == nil {
// valid email address
contactEmail = email
}
}
// see if there's a 'name' in the map
if name, present := v["name"]; present {
// name could be just a username, or could be a mention string eg @whatever@aaaa.com
username, _, err := util.ExtractNamestringParts(name)
if err == nil {
// it was a mention string
contactAccountUsername = username
} else {
// not a mention string
contactAccountUsername = name
}
}
}
}
i.ContactEmail = contactEmail
i.ContactAccountUsername = contactAccountUsername
var software string
if ni.Software.Name != "" {
software = ni.Software.Name
}
if ni.Software.Version != "" {
software = software + " " + ni.Software.Version
}
i.Version = software
return i, nil
}
+129 -73
View File
@@ -1615,9 +1615,13 @@ func InstanceRuleToAdminAPIRule(r *gtsmodel.Rule) *apimodel.AdminInstanceRule {
}
}
// InstanceToAPIV1Instance converts a gts instance into its api equivalent for serving at /api/v1/instance
func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV1, error) {
domain := i.Domain
// InstanceSettingsToAPIV1Instance converts our instance settings
// into its api equivalent for serving at /api/v1/instance.
func (c *Converter) InstanceSettingsToAPIV1Instance(
ctx context.Context,
settings *gtsmodel.InstanceSettings,
) (*apimodel.InstanceV1, error) {
domain := config.GetHost()
accDomain := config.GetAccountDomain()
if accDomain != "" {
domain = accDomain
@@ -1626,22 +1630,22 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
instance := &apimodel.InstanceV1{
URI: domain,
AccountDomain: accDomain,
Title: i.Title,
Description: i.Description,
DescriptionText: i.DescriptionText,
CustomCSS: i.CustomCSS,
ShortDescription: i.ShortDescription,
ShortDescriptionText: i.ShortDescriptionText,
Email: i.ContactEmail,
Title: settings.Title,
Description: settings.Description,
DescriptionText: settings.DescriptionText,
CustomCSS: settings.CustomCSS,
ShortDescription: settings.ShortDescription,
ShortDescriptionText: settings.ShortDescriptionText,
Email: settings.ContactEmail,
Version: config.GetSoftwareVersion(),
Languages: config.GetInstanceLanguages().TagStrs(),
Registrations: config.GetAccountsRegistrationOpen(),
ApprovalRequired: true, // approval always required
InvitesEnabled: false, // todo: not supported yet
MaxTootChars: uint(config.GetStatusesMaxChars()), // #nosec G115 -- Already validated.
Rules: InstanceRulesToAPIRules(i.Rules),
Terms: i.Terms,
TermsRaw: i.TermsText,
Rules: InstanceRulesToAPIRules(settings.Rules),
Terms: settings.Terms,
TermsRaw: settings.TermsText,
}
if config.GetInstanceInjectMastodonVersion() {
@@ -1652,7 +1656,7 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
instance.Debug = util.Ptr(true)
}
// configuration
// Instance configuration.
instance.Configuration.Statuses.MaxCharacters = config.GetStatusesMaxChars()
instance.Configuration.Statuses.MaxMediaAttachments = config.GetStatusesMediaMaxFiles()
instance.Configuration.Statuses.CharactersReservedPerURL = instanceStatusesCharactersReservedPerURL
@@ -1671,7 +1675,7 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
instance.Configuration.MediaAttachments.ImageSizeLimit = int(imageSz) // #nosec G115 -- Already validated.
instance.Configuration.MediaAttachments.VideoSizeLimit = int(videoSz) // #nosec G115 -- Already validated.
// we don't actually set any limits on these. set to max possible.
// We don't actually set any limits on these. Set to max possible.
instance.Configuration.MediaAttachments.ImageMatrixLimit = math.MaxInt32
instance.Configuration.MediaAttachments.VideoFrameRateLimit = math.MaxInt32
instance.Configuration.MediaAttachments.VideoMatrixLimit = math.MaxInt32
@@ -1687,25 +1691,25 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
instance.Configuration.OIDCEnabled = config.GetOIDCEnabled()
// URLs
instance.URLs.StreamingAPI = "wss://" + i.Domain
instance.URLs.StreamingAPI = "wss://" + domain
// statistics
// Populate instance statistics.
stats := make(map[string]*int, 3)
userCount, err := c.state.DB.CountInstanceUsers(ctx, i.Domain)
userCount, err := c.state.DB.CountInstanceAccounts(ctx)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV1Instance: db error getting counting instance users: %w", err)
return nil, gtserror.Newf("db error getting counting instance users: %w", err)
}
stats["user_count"] = util.Ptr(userCount)
statusCount, err := c.state.DB.CountInstanceStatuses(ctx, i.Domain)
statusCount, err := c.state.DB.CountInstanceStatuses(ctx)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV1Instance: db error getting counting instance statuses: %w", err)
return nil, gtserror.Newf("db error getting counting instance statuses: %w", err)
}
stats["status_count"] = util.Ptr(statusCount)
domainCount, err := c.state.DB.CountInstanceDomains(ctx, i.Domain)
domainCount, err := c.state.DB.CountInstancePeers(ctx)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV1Instance: db error getting counting instance domains: %w", err)
return nil, gtserror.Newf("db error getting counting instance domains: %w", err)
}
stats["domain_count"] = util.Ptr(domainCount)
instance.Stats = stats
@@ -1716,17 +1720,19 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
instance.RandomStats = c.RandomStats()
}
// thumbnail
// Instance thumbnail.
iAccount, err := c.state.DB.GetInstanceAccount(ctx, "")
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV1Instance: db error getting instance account: %w", err)
return nil, gtserror.Newf("db error getting instance account: %w", err)
}
if iAccount.AvatarMediaAttachmentID != "" {
// Use instance account's
// avatar as thumbnail, if set.
if iAccount.AvatarMediaAttachment == nil {
avi, err := c.state.DB.GetAttachmentByID(ctx, iAccount.AvatarMediaAttachmentID)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIInstance: error getting instance avatar attachment with id %s: %w", iAccount.AvatarMediaAttachmentID, err)
return nil, gtserror.Newf("error getting instance avatar attachment: %w", err)
}
iAccount.AvatarMediaAttachment = avi
}
@@ -1737,22 +1743,23 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
instance.ThumbnailStaticType = iAccount.AvatarMediaAttachment.Thumbnail.ContentType
instance.ThumbnailDescription = iAccount.AvatarMediaAttachment.Description
} else {
instance.Thumbnail = config.GetProtocol() + "://" + i.Domain + "/assets/logo.webp" // default thumb
// Fall back to default thumbnail.
instance.Thumbnail = config.GetProtocol() + "://" + domain + "/assets/logo.webp"
}
// contact account
if i.ContactAccountID != "" {
if i.ContactAccount == nil {
contactAccount, err := c.state.DB.GetAccountByID(ctx, i.ContactAccountID)
// Contact account, if set.
if settings.ContactAccountID != "" {
if settings.ContactAccount == nil {
contactAccount, err := c.state.DB.GetAccountByID(ctx, settings.ContactAccountID)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV1Instance: db error getting instance contact account %s: %w", i.ContactAccountID, err)
return nil, gtserror.Newf("db error getting instance contact account: %w", err)
}
i.ContactAccount = contactAccount
settings.ContactAccount = contactAccount
}
account, err := c.AccountToAPIAccountPublic(ctx, i.ContactAccount)
account, err := c.AccountToAPIAccountPublic(ctx, settings.ContactAccount)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV1Instance: error converting instance contact account %s: %w", i.ContactAccountID, err)
return nil, gtserror.Newf("error converting instance contact account: %w", err)
}
instance.ContactAccount = account
}
@@ -1760,9 +1767,13 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
return instance, nil
}
// InstanceToAPIV2Instance converts a gts instance into its api equivalent for serving at /api/v2/instance
func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.InstanceV2, error) {
domain := i.Domain
// InstanceSettingsToAPIV2Instance converts our instance settings
// into its api equivalent for serving at /api/v2/instance.
func (c *Converter) InstanceSettingsToAPIV2Instance(
ctx context.Context,
settings *gtsmodel.InstanceSettings,
) (*apimodel.InstanceV2, error) {
domain := config.GetHost()
accDomain := config.GetAccountDomain()
if accDomain != "" {
domain = accDomain
@@ -1771,17 +1782,17 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
instance := &apimodel.InstanceV2{
Domain: domain,
AccountDomain: accDomain,
Title: i.Title,
Title: settings.Title,
Version: config.GetSoftwareVersion(),
SourceURL: instanceSourceURL,
Description: i.Description,
DescriptionText: i.DescriptionText,
CustomCSS: i.CustomCSS,
Description: settings.Description,
DescriptionText: settings.DescriptionText,
CustomCSS: settings.CustomCSS,
Usage: apimodel.InstanceV2Usage{}, // todo: not implemented
Languages: config.GetInstanceLanguages().TagStrs(),
Rules: InstanceRulesToAPIRules(i.Rules),
Terms: i.Terms,
TermsText: i.TermsText,
Rules: InstanceRulesToAPIRules(settings.Rules),
Terms: settings.Terms,
TermsText: settings.TermsText,
}
if config.GetInstanceInjectMastodonVersion() {
@@ -1798,40 +1809,39 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
instance.RandomStats = c.RandomStats()
}
// thumbnail
thumbnail := apimodel.InstanceV2Thumbnail{}
// Instance thumbnail.
iAccount, err := c.state.DB.GetInstanceAccount(ctx, "")
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV2Instance: db error getting instance account: %w", err)
return nil, gtserror.Newf("db error getting instance account: %w", err)
}
if iAccount.AvatarMediaAttachmentID != "" {
// Use instance account's
// avatar as thumbnail, if set.
if iAccount.AvatarMediaAttachment == nil {
avi, err := c.state.DB.GetAttachmentByID(ctx, iAccount.AvatarMediaAttachmentID)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV2Instance: error getting instance avatar attachment with id %s: %w", iAccount.AvatarMediaAttachmentID, err)
return nil, gtserror.Newf("error getting instance avatar attachment: %w", err)
}
iAccount.AvatarMediaAttachment = avi
}
thumbnail.URL = iAccount.AvatarMediaAttachment.URL
thumbnail.Type = iAccount.AvatarMediaAttachment.File.ContentType
thumbnail.StaticURL = iAccount.AvatarMediaAttachment.Thumbnail.URL
thumbnail.StaticType = iAccount.AvatarMediaAttachment.Thumbnail.ContentType
thumbnail.Description = iAccount.AvatarMediaAttachment.Description
thumbnail.Blurhash = iAccount.AvatarMediaAttachment.Blurhash
instance.Thumbnail.URL = iAccount.AvatarMediaAttachment.URL
instance.Thumbnail.Type = iAccount.AvatarMediaAttachment.File.ContentType
instance.Thumbnail.StaticURL = iAccount.AvatarMediaAttachment.Thumbnail.URL
instance.Thumbnail.StaticType = iAccount.AvatarMediaAttachment.Thumbnail.ContentType
instance.Thumbnail.Description = iAccount.AvatarMediaAttachment.Description
instance.Thumbnail.Blurhash = iAccount.AvatarMediaAttachment.Blurhash
} else {
thumbnail.URL = config.GetProtocol() + "://" + i.Domain + "/assets/logo.webp" // default thumb
// Fall back to default thumbnail.
instance.Thumbnail.URL = config.GetProtocol() + "://" + domain + "/assets/logo.webp"
}
instance.Thumbnail = thumbnail
termsOfService := config.GetProtocol() + "://" + domain + "/about#rules"
termsOfService := config.GetProtocol() + "://" + i.Domain + "/about#rules"
// configuration
instance.Configuration.URLs.Streaming = "wss://" + i.Domain
instance.Configuration.URLs.About = config.GetProtocol() + "://" + i.Domain + "/about"
// Instance configuration.
instance.Configuration.URLs.Streaming = "wss://" + domain
instance.Configuration.URLs.About = config.GetProtocol() + "://" + domain + "/about"
instance.Configuration.URLs.TermsOfService = &termsOfService
instance.Configuration.Statuses.MaxCharacters = config.GetStatusesMaxChars()
instance.Configuration.Statuses.MaxMediaAttachments = config.GetStatusesMediaMaxFiles()
@@ -1853,7 +1863,7 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
instance.Configuration.MediaAttachments.ImageSizeLimit = int(imageSz) // #nosec G115 -- Already validated.
instance.Configuration.MediaAttachments.VideoSizeLimit = int(videoSz) // #nosec G115 -- Already validated.
// we don't actually set any limits on these. set to max possible.
// We don't actually set any limits on these. Set to max possible.
instance.Configuration.MediaAttachments.ImageMatrixLimit = math.MaxInt32
instance.Configuration.MediaAttachments.VideoFrameRateLimit = math.MaxInt32
instance.Configuration.MediaAttachments.VideoMatrixLimit = math.MaxInt32
@@ -1881,20 +1891,22 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
instance.Registrations.MinAge = nil // not implemented
instance.Registrations.ReasonRequired = config.GetAccountsReasonRequired()
// contact
instance.Contact.Email = i.ContactEmail
if i.ContactAccountID != "" {
if i.ContactAccount == nil {
contactAccount, err := c.state.DB.GetAccountByID(ctx, i.ContactAccountID)
// Contact email.
instance.Contact.Email = settings.ContactEmail
// Contact account, if set.
if settings.ContactAccountID != "" {
if settings.ContactAccount == nil {
contactAccount, err := c.state.DB.GetAccountByID(ctx, settings.ContactAccountID)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV2Instance: db error getting instance contact account %s: %w", i.ContactAccountID, err)
return nil, gtserror.Newf("db error getting instance contact account: %w", err)
}
i.ContactAccount = contactAccount
settings.ContactAccount = contactAccount
}
account, err := c.AccountToAPIAccountPublic(ctx, i.ContactAccount)
account, err := c.AccountToAPIAccountPublic(ctx, settings.ContactAccount)
if err != nil {
return nil, fmt.Errorf("InstanceToAPIV2Instance: error converting instance contact account %s: %w", i.ContactAccountID, err)
return nil, gtserror.Newf("error converting instance contact account: %w", err)
}
instance.Contact.Account = account
}
@@ -3241,6 +3253,50 @@ func (c *Converter) tagsToAPI(
return apiModels
}
func (c *Converter) InstanceToAdminAPIInstance(ctx context.Context, i *gtsmodel.Instance) (*apimodel.AdminInstance, error) {
firstSeen, err := id.TimeFromULID(i.ID)
if err != nil {
return nil, gtserror.Newf("error converting id to time: %w", err)
}
domain, err := util.DePunify(i.Domain)
if err != nil {
return nil, gtserror.Newf("error depunifying domain %s: %w", i.Domain, err)
}
var latestSuccessfulDelivery string
if !i.LatestSuccessfulDelivery.IsZero() {
latestSuccessfulDelivery = util.FormatISO8601(i.LatestSuccessfulDelivery)
}
deliveryErrors := make([]apimodel.AdminInstanceDeliveryError, 0)
for _, dErr := range i.DeliveryErrors {
errTime, err := id.TimeFromULID(dErr.ID)
if err != nil {
return nil, gtserror.Newf("error converting id to time: %w", err)
}
deliveryErrors = append(
deliveryErrors,
apimodel.AdminInstanceDeliveryError{
Time: util.FormatISO8601(errTime),
Error: dErr.Error,
},
)
}
// TODO: add accounts count, statuses
// count, following relationships, etc.
return &apimodel.AdminInstance{
ID: i.ID,
Domain: domain,
Software: i.Software,
FirstSeen: util.FormatISO8601(firstSeen),
LatestSuccessfulDelivery: latestSuccessfulDelivery,
DeliveryErrors: deliveryErrors,
}, nil
}
// toDeletedStatusPlaceholder returns a placeholder for a deleted status model.
func toDeletedStatusPlaceholder(status *gtsmodel.Status) *apimodel.Status {
apiStatus := &apimodel.Status{
+27 -11
View File
@@ -24,8 +24,6 @@ import (
"strings"
"testing"
"code.superseriousbusiness.org/gotosocial/internal/config"
"code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/typeutils"
"code.superseriousbusiness.org/gotosocial/internal/util"
@@ -1665,12 +1663,12 @@ func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() {
func (suite *InternalToFrontendTestSuite) TestInstanceV1ToFrontend() {
ctx := suite.T().Context()
i := &gtsmodel.Instance{}
if err := suite.db.GetWhere(ctx, []db.Where{{Key: "domain", Value: config.GetHost()}}, i); err != nil {
i, err := suite.state.DB.GetInstanceSettings(ctx)
if err != nil {
suite.FailNow(err.Error())
}
instance, err := suite.typeconverter.InstanceToAPIV1Instance(ctx, i)
instance, err := suite.typeconverter.InstanceSettingsToAPIV1Instance(ctx, i)
if err != nil {
suite.FailNow(err.Error())
}
@@ -1756,7 +1754,7 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV1ToFrontend() {
"streaming_api": "wss://localhost:8080"
},
"stats": {
"domain_count": 2,
"domain_count": 4,
"status_count": 24,
"user_count": 5
},
@@ -1796,7 +1794,16 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV1ToFrontend() {
"group": false
},
"max_toot_chars": 5000,
"rules": [],
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
],
"terms": "\u003cp\u003eThis is where a list of terms and conditions might go.\u003c/p\u003e\u003cp\u003eFor example:\u003c/p\u003e\u003cp\u003eIf you want to sign up on this instance, you oughta know that we:\u003c/p\u003e\u003col\u003e\u003cli\u003eWill sell your data to whoever offers.\u003c/li\u003e\u003cli\u003eSecure the server with password \u003ccode\u003epassword\u003c/code\u003e wherever possible.\u003c/li\u003e\u003c/ol\u003e",
"terms_text": "This is where a list of terms and conditions might go.\n\nFor example:\n\nIf you want to sign up on this instance, you oughta know that we:\n\n1. Will sell your data to whoever offers.\n2. Secure the server with password `+"`"+`password`+"`"+` wherever possible."
}`, string(b))
@@ -1805,12 +1812,12 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV1ToFrontend() {
func (suite *InternalToFrontendTestSuite) TestInstanceV2ToFrontend() {
ctx := suite.T().Context()
i := &gtsmodel.Instance{}
if err := suite.db.GetWhere(ctx, []db.Where{{Key: "domain", Value: config.GetHost()}}, i); err != nil {
i, err := suite.state.DB.GetInstanceSettings(ctx)
if err != nil {
suite.FailNow(err.Error())
}
instance, err := suite.typeconverter.InstanceToAPIV2Instance(ctx, i)
instance, err := suite.typeconverter.InstanceSettingsToAPIV2Instance(ctx, i)
if err != nil {
suite.FailNow(err.Error())
}
@@ -1958,7 +1965,16 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV2ToFrontend() {
"group": false
}
},
"rules": [],
"rules": [
{
"id": "01GP3AWY4CRDVRNZKW0TEAMB51",
"text": "Be gay"
},
{
"id": "01GP3DFY9XQ1TJMZT5BGAZPXX3",
"text": "Do crime"
}
],
"terms": "\u003cp\u003eThis is where a list of terms and conditions might go.\u003c/p\u003e\u003cp\u003eFor example:\u003c/p\u003e\u003cp\u003eIf you want to sign up on this instance, you oughta know that we:\u003c/p\u003e\u003col\u003e\u003cli\u003eWill sell your data to whoever offers.\u003c/li\u003e\u003cli\u003eSecure the server with password \u003ccode\u003epassword\u003c/code\u003e wherever possible.\u003c/li\u003e\u003c/ol\u003e",
"terms_text": "This is where a list of terms and conditions might go.\n\nFor example:\n\nIf you want to sign up on this instance, you oughta know that we:\n\n1. Will sell your data to whoever offers.\n2. Secure the server with password `+"`"+`password`+"`"+` wherever possible."
}`, s)
+1 -1
View File
@@ -77,7 +77,7 @@ func (m *Module) directoryGETHandler(c *gin.Context) {
// Parse order (default "active").
orderBy, errWithCode := apiutil.ParseDirectoryOrder(
c.Query(apiutil.DirectoryOrderKey),
c.Query(apiutil.OrderKey),
gtsmodel.DirectoryOrderByActive,
)
if errWithCode != nil {
+1
View File
@@ -41,6 +41,7 @@ EXPECT=$(cat << "EOF"
"cache-domain-permission-subscription-mem-ratio": 0.5,
"cache-emoji-category-mem-ratio": 0.1,
"cache-emoji-mem-ratio": 3,
"cache-federation-error-mem-ratio": 0.2,
"cache-filter-ids-mem-ratio": 2,
"cache-filter-keyword-mem-ratio": 0.5,
"cache-filter-mem-ratio": 0.5,
+9 -5
View File
@@ -37,6 +37,7 @@ var testModels = []interface{}{
&gtsmodel.Block{},
&gtsmodel.DomainBlock{},
&gtsmodel.EmailDomainBlock{},
&gtsmodel.FederationError{},
&gtsmodel.Filter{},
&gtsmodel.FilterKeyword{},
&gtsmodel.FilterStatus{},
@@ -66,6 +67,7 @@ var testModels = []interface{}{
&gtsmodel.WebPushSubscription{},
&gtsmodel.Emoji{},
&gtsmodel.Instance{},
&gtsmodel.InstanceSettings{},
&gtsmodel.Notification{},
&gtsmodel.RouterSession{},
&gtsmodel.Token{},
@@ -233,6 +235,12 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) {
}
}
for _, v := range NewTestFederationErrors() {
if err := db.Put(ctx, v); err != nil {
log.Panic(ctx, err)
}
}
for _, v := range NewTestStatusToEmojis() {
if err := db.Put(ctx, v); err != nil {
log.Panic(ctx, err)
@@ -377,11 +385,7 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) {
}
}
if err := db.CreateInstanceAccount(ctx); err != nil {
log.Panic(ctx, err)
}
if err := db.CreateInstanceInstance(ctx); err != nil {
if err := db.Put(ctx, NewTestInstanceSettings()); err != nil {
log.Panic(ctx, err)
}
+58 -27
View File
@@ -39,6 +39,7 @@ import (
"code.superseriousbusiness.org/activity/streams/vocab"
"code.superseriousbusiness.org/gotosocial/internal/ap"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/id"
"code.superseriousbusiness.org/gotosocial/internal/transport"
"code.superseriousbusiness.org/gotosocial/internal/util"
)
@@ -1884,37 +1885,67 @@ func NewTestStatusToEmojis() map[string]*gtsmodel.StatusToEmoji {
func NewTestInstances() map[string]*gtsmodel.Instance {
return map[string]*gtsmodel.Instance{
"localhost:8080": {
ID: "01G774F5TSHJ2ZSF7XRC5EMT6K",
CreatedAt: TimeMustParse("2020-01-20T13:12:00+02:00"),
UpdatedAt: TimeMustParse("2020-01-20T13:12:00+02:00"),
Domain: "localhost:8080",
URI: "http://localhost:8080",
Title: "GoToSocial Testrig Instance",
ShortDescription: "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>",
ShortDescriptionText: "This is the GoToSocial testrig. It doesn't federate or anything.\n\nWhen the testrig is shut down, all data on it will be deleted.\n\nDon't use this in production!",
Description: "<p>Here's a fuller description of the GoToSocial testrig instance.</p><p>This instance is for testing purposes only. It doesn't federate at all. Go check out <a href=\"https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/testrig\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/testrig</a> and <a href=\"https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/CONTRIBUTING.md#testing\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/CONTRIBUTING.md#testing</a></p><p>Users on this instance:</p><ul><li><span class=\"h-card\"><a href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>admin</span></a></span> (admin!).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span> (posts about turtles, we don't know why).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@the_mighty_zork\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>the_mighty_zork</span></a></span> (who knows).</li></ul><p>If you need to edit the models for the testrig, you can do so at <code>internal/testmodels.go</code>.</p>",
DescriptionText: "Here's a fuller description of the GoToSocial testrig instance.\n\nThis instance is for testing purposes only. It doesn't federate at all. Go check out https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/testrig and https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/CONTRIBUTING.md#testing\n\nUsers on this instance:\n\n- @admin (admin!).\n- @1happyturtle (posts about turtles, we don't know why).\n- @the_mighty_zork (who knows).\n\nIf you need to edit the models for the testrig, you can do so at `internal/testmodels.go`.",
Terms: "<p>This is where a list of terms and conditions might go.</p><p>For example:</p><p>If you want to sign up on this instance, you oughta know that we:</p><ol><li>Will sell your data to whoever offers.</li><li>Secure the server with password <code>password</code> wherever possible.</li></ol>",
TermsText: "This is where a list of terms and conditions might go.\n\nFor example:\n\nIf you want to sign up on this instance, you oughta know that we:\n\n1. Will sell your data to whoever offers.\n2. Secure the server with password `password` wherever possible.",
ContactEmail: "admin@example.org",
ContactAccountUsername: "admin",
ContactAccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
},
"fossbros-anonymous.io": {
ID: "01G5H6YMJQKR86QZKXXQ2S95FZ",
CreatedAt: TimeMustParse("2021-09-20T12:40:37+02:00"),
UpdatedAt: TimeMustParse("2021-09-20T12:40:37+02:00"),
Domain: "fossbros-anonymous.io",
URI: "http://fossbros-anonymous.io",
ID: "01FGGVRD9R2HH5ETYXYXYV8TNG",
Domain: "fossbros-anonymous.io",
Software: "pleroma",
LatestSuccessfulDelivery: TimeMustParse("2024-02-19T11:44:00Z"),
},
"example.org": {
ID: "01G5H71G52DJKVBYKXPNPNDN1G",
CreatedAt: TimeMustParse("2020-05-13T15:29:12+02:00"),
UpdatedAt: TimeMustParse("2020-05-13T15:29:12+02:00"),
Domain: "example.org",
URI: "http://example.org",
ID: "01EFC4MKCRP7ET05KTAHZ3MQ0K",
Domain: "example.org",
Software: "mastodon",
LatestSuccessfulDelivery: TimeMustParse("2023-07-18T09:01:00Z"),
},
"thequeenisstillalive.technology": {
ID: "01EFC4MHE8K7MVS500P32JZRYE",
Domain: "thequeenisstillalive.technology",
Software: "gotosocial",
},
"xn--xample-ova.org": {
ID: "01G4QA52E8Q9CZKHMBXD2J1PH1",
Domain: "xn--xample-ova.org",
Software: "misskey",
LatestSuccessfulDelivery: TimeMustParse("2022-06-10T15:22:08Z"),
},
"replyguys.com": {
ID: "01E85P26Y8DHXPY0R6JB28DNJV",
Domain: "replyguys.com",
Software: "akkoma",
},
}
}
func NewTestFederationErrors() map[string]*gtsmodel.FederationError {
return map[string]*gtsmodel.FederationError{
"thequeenisstillalive.technology_1": {
ID: id.NewULIDFromTime(TimeMustParse("2023-07-19T10:15:12Z")),
InstanceID: "01EFC4MHE8K7MVS500P32JZRYE",
Type: gtsmodel.FederationErrorTypeDelivery,
Error: "Post \"https://thequeenisstillalive.technology/inbox\": remote error: tls: unrecognized name",
},
"thequeenisstillalive.technology_2": {
ID: id.NewULIDFromTime(TimeMustParse("2023-07-18T09:01:00Z")),
InstanceID: "01EFC4MHE8K7MVS500P32JZRYE",
Type: gtsmodel.FederationErrorTypeDelivery,
Error: "too stinky",
},
}
}
func NewTestInstanceSettings() *gtsmodel.InstanceSettings {
return &gtsmodel.InstanceSettings{
ID: "01E8HC4KNRDNP52APRD1976KNB",
Title: "GoToSocial Testrig Instance",
ShortDescription: "<p>This is the GoToSocial testrig. It doesn't federate or anything.</p><p>When the testrig is shut down, all data on it will be deleted.</p><p>Don't use this in production!</p>",
ShortDescriptionText: "This is the GoToSocial testrig. It doesn't federate or anything.\n\nWhen the testrig is shut down, all data on it will be deleted.\n\nDon't use this in production!",
Description: "<p>Here's a fuller description of the GoToSocial testrig instance.</p><p>This instance is for testing purposes only. It doesn't federate at all. Go check out <a href=\"https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/testrig\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/testrig</a> and <a href=\"https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/CONTRIBUTING.md#testing\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/CONTRIBUTING.md#testing</a></p><p>Users on this instance:</p><ul><li><span class=\"h-card\"><a href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>admin</span></a></span> (admin!).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span> (posts about turtles, we don't know why).</li><li><span class=\"h-card\"><a href=\"http://localhost:8080/@the_mighty_zork\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>the_mighty_zork</span></a></span> (who knows).</li></ul><p>If you need to edit the models for the testrig, you can do so at <code>internal/testmodels.go</code>.</p>",
DescriptionText: "Here's a fuller description of the GoToSocial testrig instance.\n\nThis instance is for testing purposes only. It doesn't federate at all. Go check out https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/testrig and https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/CONTRIBUTING.md#testing\n\nUsers on this instance:\n\n- @admin (admin!).\n- @1happyturtle (posts about turtles, we don't know why).\n- @the_mighty_zork (who knows).\n\nIf you need to edit the models for the testrig, you can do so at `internal/testmodels.go`.",
Terms: "<p>This is where a list of terms and conditions might go.</p><p>For example:</p><p>If you want to sign up on this instance, you oughta know that we:</p><ol><li>Will sell your data to whoever offers.</li><li>Secure the server with password <code>password</code> wherever possible.</li></ol>",
TermsText: "This is where a list of terms and conditions might go.\n\nFor example:\n\nIf you want to sign up on this instance, you oughta know that we:\n\n1. Will sell your data to whoever offers.\n2. Secure the server with password `password` wherever possible.",
ContactEmail: "admin@example.org",
ContactAccountUsername: "admin",
ContactAccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
}
}
+2 -2
View File
@@ -42,7 +42,7 @@ func StartNoopWorkers(state *state.State) {
state.Workers.Client.Init(messages.ClientMsgIndices())
state.Workers.Federator.Init(messages.FederatorMsgIndices())
state.Workers.Delivery.Init(nil)
state.Workers.Delivery.Init(nil, state.DB)
// Specifically do NOT start the workers
// as caller may require queue contents.
@@ -71,7 +71,7 @@ func StartWorkers(state *state.State, processor *workers.Processor) {
state.Workers.Client.Init(messages.ClientMsgIndices())
state.Workers.Federator.Init(messages.FederatorMsgIndices())
state.Workers.Delivery.Init(nil)
state.Workers.Delivery.Init(nil, state.DB)
_ = state.Workers.Scheduler.Start()
state.Workers.Client.Start(1)
@@ -21,7 +21,8 @@ import React from "react";
import { Link } from "wouter";
export default function BackButton({ to }) {
const backLocation: string = history.state?.backLocation ?? to;
return (
<Link className="button" to={to}>&lt; back</Link>
<Link className="button" to={backLocation}>&lt; back</Link>
);
}
@@ -23,6 +23,7 @@ import { listToKeyedObject } from "../transforms";
import { ActionAccountParams, AdminAccount, HandleSignupParams, SearchAccountParams, SearchAccountResp } from "../../types/account";
import { InstanceRule, MappedRules } from "../../types/rules";
import parse from "parse-link-header";
import { AdminInstance, SearchInstancesParams, SearchInstancesResp } from "../../types/instance";
const extended = gtsApi.injectEndpoints({
endpoints: (build) => ({
@@ -108,6 +109,45 @@ const extended = gtsApi.injectEndpoints({
}
}),
searchInstances: build.query<SearchInstancesResp, SearchInstancesParams>({
query: (form) => {
const params = new(URLSearchParams);
Object.entries(form).forEach(([k, v]) => {
if (v !== undefined) {
params.append(k, v);
}
});
let query = "";
if (params.size !== 0) {
query = `?${params.toString()}`;
}
return {
url: `/api/v1/admin/instances${query}`
};
},
// Headers required for paging.
transformResponse: (apiResp: AdminInstance[], meta) => {
const instances = apiResp;
const linksStr = meta?.response?.headers.get("Link");
const links = parse(linksStr);
return { instances, links };
},
// Only provide LIST tag id since this model is not the
// same as getInstance model (due to transformResponse).
providesTags: [{ type: "AdminInstance", id: "TRANSFORMED" }]
}),
getInstance: build.query<AdminInstance, string>({
query: (id) => ({
url: `/api/v1/admin/instances/${id}`
}),
providesTags: (_result, _error, id) => [
{ type: 'AdminInstance', id }
],
}),
handleSignup: build.mutation<AdminAccount, HandleSignupParams>({
query: ({id, approve_or_reject, ...formData}) => {
return {
@@ -204,4 +244,6 @@ export const {
useAddInstanceRuleMutation,
useUpdateInstanceRuleMutation,
useDeleteInstanceRuleMutation,
useLazySearchInstancesQuery,
useGetInstanceQuery,
} = extended;
+1
View File
@@ -180,6 +180,7 @@ export const gtsApi = createApi({
"DomainPermissionSubscription",
"TokenInfo",
"User",
"AdminInstance",
],
endpoints: (build) => ({
instanceV1: build.query<InstanceV1, void>({
+30
View File
@@ -17,6 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Links } from "parse-link-header";
import { Account } from "./account";
export interface InstanceV1 {
@@ -142,3 +143,32 @@ export interface InstanceV2Translation {
export interface InstanceV2URLs {
streaming: string;
}
export interface AdminInstance {
id: string;
domain: string;
software?: string;
first_seen: string;
latest_successful_delivery?: string;
delivery_errors?: AdminInstanceDeliveryError[];
}
export interface AdminInstanceDeliveryError {
error: string;
time: string;
}
export interface SearchInstancesParams {
domain?: string;
order?: "alphabetical" | "latest",
undeliverable?: boolean;
max_id?: string,
since_id?: string,
min_id?: string,
limit?: number,
}
export interface SearchInstancesResp {
instances: AdminInstance[];
links: Links | null;
}
+35
View File
@@ -1875,6 +1875,41 @@ button.tab-button {
}
}
.instances-view {
.instance-info {
.info-list {
border: none;
width: 100%;
.info-list-entry {
background: none;
padding: 0;
&.delivery-errors {
color: $error-fg;
background: $error-bg;
border-radius: $br-inner;
}
}
}
}
}
.instance-detail {
.info-list {
margin-top: 1rem;
}
.delivery-errors-list {
margin-top: 0;
}
.domain-blocks-link {
color: $link-fg;
text-decoration: underline;
}
}
@media screen and (orientation: portrait) {
.reports .report .byline {
grid-template-columns: 1fr;
@@ -0,0 +1,149 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { ReactNode } from "react";
import { useGetInstanceQuery } from "../../../../lib/query/admin";
import FormWithData from "../../../../lib/form/form-with-data";
import { AdminInstance } from "../../../../lib/types/instance";
import { useLocation, useParams } from "wouter";
import { useBaseUrl } from "../../../../lib/navigation/util";
import BackButton from "../../../../components/back-button";
export default function InstanceDetail() {
const params: { instanceID: string } = useParams();
const baseUrl = useBaseUrl();
const backLocation: String = history.state?.backLocation ?? `~${baseUrl}`;
return (
<div className="instance-detail">
<h1><BackButton to={backLocation} /> Instance Details</h1>
<FormWithData
dataQuery={useGetInstanceQuery}
queryArg={params.instanceID}
DataForm={InstanceDetailForm}
/>
</div>
);
}
function InstanceDetailForm({ data: instance }: { data: AdminInstance }) {
const domain = instance.domain;
const software = instance.software ?? "unknown";
const firstSeen = new Date(instance.first_seen).toLocaleString();
const latestSuccessfulDelivery = instance.latest_successful_delivery && new Date(instance.latest_successful_delivery).toLocaleString();
return (
<>
<dl className="info-list">
<div className="info-list-entry">
<dt>Domain:</dt>
<dd>
<a
href={`https://${domain}`}
target="_blank"
rel="noreferrer"
>
<i className="fa fa-fw fa-external-link" aria-hidden="true"></i> {domain} (opens in a new tab)
</a>
</dd>
</div>
<div className="info-list-entry">
<dt>Software:</dt>
<dd>{software}</dd>
</div>
<div className="info-list-entry">
<dt>First seen:</dt>
<dd>
<time dateTime={instance.first_seen}>{firstSeen}</time>
</dd>
</div>
<div className="info-list-entry">
<dt>Latest successful delivery:</dt>
<dd>
{ latestSuccessfulDelivery
? <time dateTime={instance.latest_successful_delivery}>{latestSuccessfulDelivery}</time>
: "unknown/never"
}
</dd>
</div>
</dl>
<InstanceDeliveryErrors data={instance} />
</>
);
}
function InstanceDeliveryErrors({ data: instance }: { data: AdminInstance }): ReactNode {
const baseUrl = useBaseUrl();
const backLocation = `~${baseUrl}/${instance.id}`;
if (!instance.delivery_errors) {
return null;
}
return (
<>
<div className="form-section-docs">
<h3>Recent Delivery Errors</h3>
<p>
This section shows the 20 most recent delivery errors since the latest successful delivery of an activity
to an inbox on this instance (if ever). If the instance appears to have gone offline permanently, you may
wish to block it using the <DomainBlocksLink domain={instance.domain} backLocation={backLocation} />.
This cleans up accounts and statuses from the decommissioned instance stored in your database, and avoids the
risk of later federating with an instance created by a baddie masquerading as the original instance owner.
</p>
</div>
<dl className="info-list delivery-errors-list">
{ instance.delivery_errors.map((err, i) => {
return (
<div className="info-list-entry" key={i}>
<dt><time dateTime={err.time}>{new Date(err.time).toLocaleString()}</time></dt>
<dd>{err.error}</dd>
</div>
);
}) }
</dl>
</>
);
}
function DomainBlocksLink({ domain, backLocation }: { domain: string, backLocation: string }) {
const [ _location, setLocation ] = useLocation();
const linkTo = `~/settings/moderation/domain-permissions/blocks/${domain}`;
const onClick = () => {
setLocation(linkTo, {
// Store the back location in history so
// it can be used to return to this page.
state: { backLocation: backLocation }
});
};
return (
<span
className="domain-blocks-link pseudolink"
onClick={onClick}
onKeyDown={e => e.key === "Enter" && onClick()}
role="link"
tabIndex={0}
>
domain blocks page
</span>
);
}
@@ -0,0 +1,35 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { InstancesSearchForm } from "./search";
export default function InstancesSearch({ }) {
return (
<div className="instances-view">
<div className="form-section-docs">
<h1>Instances Search</h1>
<p>
On this screen you can browse and search within the list of remote instances known to your instance.
</p>
</div>
<InstancesSearchForm />
</div>
);
}
@@ -0,0 +1,227 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { ReactNode, useEffect, useMemo } from "react";
import { useLazySearchInstancesQuery } from "../../../../lib/query/admin";
import { useBoolInput, useTextInput } from "../../../../lib/form";
import { PageableList } from "../../../../components/pageable-list";
import { Checkbox, Select, TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
import { useLocation, useSearch } from "wouter";
import { AdminInstance } from "../../../../lib/types/instance";
export function InstancesSearchForm() {
const [ location, setLocation ] = useLocation();
const search = useSearch();
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
const [ searchInstances, searchRes ] = useLazySearchInstancesQuery();
// Populate search form using values from
// urlQueryParams, to allow paging.
const form = {
domain: useTextInput("domain", { defaultValue: urlQueryParams.get("domain") ?? undefined }),
order: useTextInput("order", { defaultValue: urlQueryParams.get("order") ?? undefined }),
with_errors_only: useBoolInput("with_errors_only", { defaultValue: Boolean(urlQueryParams.get("with_errors_only")) ?? undefined }),
limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "40"}),
};
// On mount, trigger the search.
useEffect(() => {
searchInstances(Object.fromEntries(urlQueryParams), true);
}, [urlQueryParams, searchInstances]);
// Rather than triggering the search directly,
// the "submit" button changes the location
// based on form field params, and lets the
// useEffect hook above actually do the search.
function submitQuery(e) {
e.preventDefault();
// Parse query parameters.
const entries = Object.entries(form).map(([k, v]) => {
// Take only defined form fields.
if (v.value === undefined) {
return null;
}
if (typeof v.value === "string") {
// Ignore 0 length strings.
if (v.value.length === 0) {
return null;
}
return [[k, v.value]];
} else {
if (!v.value) {
// Not interested in false value
// for "with_errors_only" as
// false is the default anyway.
return null;
}
return [[k, "true"]];
}
}).flatMap(kv => {
// Remove any nulls.
return kv || [];
});
const searchParams = new URLSearchParams(entries);
setLocation(location + "?" + searchParams.toString());
}
// Location to return to when user clicks "back" on the detail view.
const backLocation = location + (urlQueryParams.size > 0 ? `?${urlQueryParams}` : "");
// Function to map an item to a list entry.
function itemToEntry(instance: AdminInstance): ReactNode {
return (
<InstanceListEntry
key={instance.id}
instance={instance}
linkTo={`/${instance.id}`}
backLocation={backLocation}
/>
);
}
return (
<>
<form
onSubmit={submitQuery}
// Prevent password managers trying
// to fill in username/email fields.
autoComplete="off"
>
<TextInput
field={form.domain}
label={`Domain or first part of domain (without "https://" prefix)`}
placeholder="example.org"
autoCapitalize="none"
spellCheck="false"
/>
<Select
field={form.order}
label="Order results by first seen (newest -> oldest), or alphabetical (a -> z)"
options={
<>
<option value="first_seen">First seen</option>
<option value="alphabetical">Alphabetical</option>
</>
}
></Select>
<Checkbox
field={form.with_errors_only}
label={"Show only instances with delivery errors"}
/>
<MutationButton
disabled={false}
label={"Search"}
result={searchRes}
/>
</form>
<PageableList
isLoading={searchRes.isLoading}
isFetching={searchRes.isFetching}
isSuccess={searchRes.isSuccess}
items={searchRes.data?.instances}
itemToEntry={itemToEntry}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage={<b>No instances found that match your query.</b>}
prevNextLinks={searchRes.data?.links}
/>
</>
);
}
interface InstanceEntryProps {
instance: AdminInstance;
linkTo: string;
backLocation: string;
}
function InstanceListEntry({ instance, linkTo, backLocation }: InstanceEntryProps) {
const [ _location, setLocation ] = useLocation();
const domain = instance.domain;
const software = instance.software ?? "unknown";
const firstSeen = new Date(instance.first_seen).toLocaleString();
const latestSuccessfulDelivery = instance.latest_successful_delivery && new Date(instance.latest_successful_delivery).toLocaleString();
const deliveryErrors = instance.delivery_errors;
const onClick = (e) => {
e.preventDefault();
// When clicking on a instance, direct
// to the detail view for that instance.
setLocation(linkTo, {
// Store the back location in history so
// the detail view can use it to return to
// this page (including query parameters).
state: { backLocation: backLocation }
});
};
return (
<span
className="pseudolink instance-info entry"
aria-label={domain}
title={domain}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onClick(e);
}
}}
role="link"
tabIndex={0}
>
<h4 className="text-cutoff">{domain}</h4>
<dl className="info-list">
<div className="info-list-entry">
<dt>Software:</dt>
<dd className="text-cutoff">{software}</dd>
</div>
<div className="info-list-entry">
<dt>First seen:</dt>
<dd className="text-cutoff">
<time dateTime={instance.first_seen}>{firstSeen}</time>
</dd>
</div>
<div className="info-list-entry">
<dt>Latest successful delivery:</dt>
<dd className="text-cutoff">
{ latestSuccessfulDelivery
? <time dateTime={instance.latest_successful_delivery}>{latestSuccessfulDelivery}</time>
: "unknown/never"
}
</dd>
</div>
{ deliveryErrors &&
<div className="info-list-entry delivery-errors">
<dt>Delivery errors:</dt>
<dd>{deliveryErrors.length}</dd>
</div>
}
</dl>
</span>
);
}
+12
View File
@@ -33,6 +33,7 @@ import { useHasPermission } from "../../lib/navigation/util";
* - /settings/admin/emojis/local
* - /settings/admin/emojis/local/:emojiId
* - /settings/admin/emojis/remote
* - /settings/admin/instances/search
* - /settings/admin/actions
* - /settings/admin/actions/email
* - /settings/admin/actions/media
@@ -58,6 +59,7 @@ export default function AdminMenu() {
>
<AdminInstanceMenu />
<AdminEmojisMenu />
<AdminInstancesMenu />
<AdminActionsMenu />
<AdminHTTPHeaderPermissionsMenu />
<AdminDebugMenu />
@@ -140,6 +142,16 @@ function AdminEmojisMenu() {
);
}
function AdminInstancesMenu() {
return (
<MenuItem
name="Remote Instances"
itemUrl="instances"
icon="fa-server"
/>
);
}
function AdminHTTPHeaderPermissionsMenu() {
return (
<MenuItem
@@ -34,6 +34,8 @@ import HeaderPermDetail from "./http-header-permissions/detail";
import Email from "./actions/email";
import ApURL from "./debug/apurl";
import Caches from "./debug/caches";
import InstancesSearch from "./instances.go";
import InstanceDetail from "./instances.go/detail";
/*
EXPORTED COMPONENTS
@@ -47,6 +49,7 @@ import Caches from "./debug/caches";
* - /settings/admin/emojis/local
* - /settings/admin/emojis/local/:emojiId
* - /settings/admin/emojis/remote
* - /settings/admin/instances
* - /settings/admin/actions
* - /settings/admin/actions/media
* - /settings/admin/actions/keys
@@ -67,6 +70,7 @@ export default function AdminRouter() {
<Router base={thisBase}>
<AdminInstanceRouter />
<AdminEmojisRouter />
<AdminInstancesRouter />
<AdminActionsRouter />
<AdminHTTPHeaderPermissionsRouter />
<AdminDebugRouter />
@@ -112,6 +116,35 @@ function AdminEmojisRouter() {
);
}
/**
* - /settings/admin/instances
*/
function AdminInstancesRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/instances";
const absBase = parentUrl + thisBase;
const permissions = ["admin"];
const admin = useHasPermission(permissions);
if (!admin) {
return null;
}
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<ErrorBoundary>
<Switch>
<Route path="/search" component={InstancesSearch} />
<Route path="/:instanceID" component={InstanceDetail} />
<Route><Redirect to="/search" /></Route>
</Switch>
</ErrorBoundary>
</Router>
</BaseUrlContext.Provider>
);
}
/**
* - /settings/admin/actions
* - /settings/admin/actions/email
@@ -122,7 +122,7 @@ function GeneralAccountDetails({ adminAcct } : { adminAcct: AdminAccount }) {
rel="noreferrer"
>
<i className="fa fa-fw fa-external-link" aria-hidden="true"></i> {adminAcct.account.url} (opens in a new tab)
</a>
</a>
</dd>
</div>
<div className="info-list-entry">
+1 -1
View File
@@ -101,6 +101,6 @@ Instance Logo
<h1>{{- .instance.Title -}}</h1>
</a>
{{- if .showStrap }}
<aside>home to {{ template "strapUsers" . }} {{ template "strapPosts" . }}; knows of {{ template "strapInstances" . }}</aside>
<aside>home to {{ template "strapUsers" . }} {{ template "strapPosts" . }}; peered with {{ template "strapInstances" . }}</aside>
{{- end }}
{{- end }}