[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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(), >smodel.DomainBlock{
|
||||
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
|
||||
defer testrig.TearDownTestStructs(testStructs)
|
||||
instanceModule := instance.New(testStructs.Processor)
|
||||
|
||||
err := testStructs.State.DB.Put(suite.T().Context(), >smodel.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(), >smodel.DomainBlock{
|
||||
testStructs := testrig.SetupTestStructs(rMediaPath, rTemplatePath)
|
||||
defer testrig.TearDownTestStructs(testStructs)
|
||||
instanceModule := instance.New(testStructs.Processor)
|
||||
|
||||
err := testStructs.State.DB.Put(suite.T().Context(), >smodel.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)
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Vendored
+1
@@ -89,6 +89,7 @@ func (c *Caches) Init() {
|
||||
c.initDomainPermissionExclude()
|
||||
c.initEmoji()
|
||||
c.initEmojiCategory()
|
||||
c.initFederationError()
|
||||
c.initFilterIDs()
|
||||
c.initFilter()
|
||||
c.initFilterKeyword()
|
||||
|
||||
Vendored
+32
-4
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+3
-3
@@ -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) {
|
||||
|
||||
Vendored
+13
-11
@@ -360,6 +360,15 @@ func sizeofEmojiCategory() uintptr {
|
||||
}))
|
||||
}
|
||||
|
||||
func sizeofFederationError() uintptr {
|
||||
return uintptr(size.Of(>smodel.FederationError{
|
||||
ID: exampleID,
|
||||
InstanceID: exampleID,
|
||||
Type: 1,
|
||||
Error: exampleTextSmall,
|
||||
}))
|
||||
}
|
||||
|
||||
func sizeofFilter() uintptr {
|
||||
return uintptr(size.Of(>smodel.Filter{
|
||||
ID: exampleID,
|
||||
@@ -415,17 +424,10 @@ func sizeofFollowRequest() uintptr {
|
||||
|
||||
func sizeofInstance() uintptr {
|
||||
return uintptr(size.Of(>smodel.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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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 := >smodel.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 = >smodel.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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 := >smodel.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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+25
@@ -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"`
|
||||
}
|
||||
+27
@@ -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"`
|
||||
}
|
||||
+33
@@ -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"`
|
||||
}
|
||||
+43
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 >smodel.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 := >smodel.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 := >smodel.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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 := >smodel.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 := >smodel.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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -37,6 +37,7 @@ var testModels = []interface{}{
|
||||
>smodel.Block{},
|
||||
>smodel.DomainBlock{},
|
||||
>smodel.EmailDomainBlock{},
|
||||
>smodel.FederationError{},
|
||||
>smodel.Filter{},
|
||||
>smodel.FilterKeyword{},
|
||||
>smodel.FilterStatus{},
|
||||
@@ -66,6 +67,7 @@ var testModels = []interface{}{
|
||||
>smodel.WebPushSubscription{},
|
||||
>smodel.Emoji{},
|
||||
>smodel.Instance{},
|
||||
>smodel.InstanceSettings{},
|
||||
>smodel.Notification{},
|
||||
>smodel.RouterSession{},
|
||||
>smodel.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
@@ -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 >smodel.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
@@ -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}>< back</Link>
|
||||
<Link className="button" to={backLocation}>< 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;
|
||||
|
||||
@@ -180,6 +180,7 @@ export const gtsApi = createApi({
|
||||
"DomainPermissionSubscription",
|
||||
"TokenInfo",
|
||||
"User",
|
||||
"AdminInstance",
|
||||
],
|
||||
endpoints: (build) => ({
|
||||
instanceV1: build.query<InstanceV1, void>({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user