[feature] Allow admins to expire remote public keys; refetch expired keys on demand (#2183)

This commit is contained in:
tobi 2023-09-12 11:43:12 +02:00 committed by GitHub
parent 2cac5a4613
commit 4b594516ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 841 additions and 117 deletions

View file

@ -445,6 +445,19 @@ definitions:
type: object
x-go-name: AdminAccountInfo
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
adminActionResponse:
description: |-
AdminActionResponse models the server
response to an admin action.
properties:
action_id:
description: Internal ID of the action.
example: 01H9QG6TZ9W5P0402VFRVM17TH
type: string
x-go-name: ActionID
type: object
x-go-name: AdminActionResponse
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
adminEmoji:
properties:
category:
@ -1018,6 +1031,16 @@ definitions:
type: object
x-go-name: DomainBlockCreateRequest
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
domainKeysExpireRequest:
properties:
domain:
description: hostname/domain to expire keys for.
type: string
x-go-name: Domain
title: DomainBlockCreateRequest is the form submitted as a POST to /api/v1/admin/domain_keys_expire to expire a domain's public keys.
type: object
x-go-name: DomainKeysExpireRequest
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
emoji:
properties:
category:
@ -4103,6 +4126,56 @@ paths:
summary: View domain block with the given ID.
tags:
- admin
/api/v1/admin/domain_keys_expire:
post:
consumes:
- multipart/form-data
description: |-
This is useful in cases where the remote domain has had to rotate their keys for whatever
reason (security issue, data leak, routine safety procedure, etc), and your instance can no
longer communicate with theirs properly using cached keys. A key marked as expired in this way
will be lazily refetched next time a request is made to your instance signed by the owner of that
key, so no further action should be required in order to reestablish communication with that domain.
This endpoint is explicitly not for rotating your *own* keys, it only works for remote instances.
Using this endpoint to expire keys for a domain that hasn't rotated all of their keys is not
harmful and won't break federation, but it is pointless and will cause unnecessary requests to
be performed.
operationId: domainKeysExpire
parameters:
- description: Domain to expire keys for.
example: example.org
in: formData
name: domain
type: string
produces:
- application/json
responses:
"202":
description: Request accepted and will be processed. Check the logs for progress / errors.
schema:
$ref: '#/definitions/adminActionResponse'
"400":
description: bad request
"401":
description: unauthorized
"403":
description: forbidden
"404":
description: not found
"406":
description: not acceptable
"409":
description: 'Conflict: There is already an admin action running that conflicts with this action. Check the error message in the response body for more information. This is a temporary error; it should be possible to process this action if you try again in a bit.'
"500":
description: internal server error
security:
- OAuth2 Bearer:
- admin
summary: Force expiry of cached public keys for all accounts on the given domain stored in your database.
tags:
- admin
/api/v1/admin/email/test:
post:
consumes:

View file

@ -31,6 +31,7 @@ const (
EmojiCategoriesPath = EmojiPath + "/categories"
DomainBlocksPath = BasePath + "/domain_blocks"
DomainBlocksPathWithID = DomainBlocksPath + "/:" + IDKey
DomainKeysExpirePath = BasePath + "/domain_keys_expire"
AccountsPath = BasePath + "/accounts"
AccountsPathWithID = AccountsPath + "/:" + IDKey
AccountsActionPath = AccountsPathWithID + "/action"
@ -83,6 +84,9 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
attachHandler(http.MethodGet, DomainBlocksPathWithID, m.DomainBlockGETHandler)
attachHandler(http.MethodDelete, DomainBlocksPathWithID, m.DomainBlockDELETEHandler)
// domain maintenance stuff
attachHandler(http.MethodPost, DomainKeysExpirePath, m.DomainKeysExpirePOSTHandler)
// accounts stuff
attachHandler(http.MethodPost, AccountsActionPath, m.AccountActionPOSTHandler)

View file

@ -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/>.
package admin
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// DomainKeysExpirePOSTHandler swagger:operation POST /api/v1/admin/domain_keys_expire domainKeysExpire
//
// Force expiry of cached public keys for all accounts on the given domain stored in your database.
//
// This is useful in cases where the remote domain has had to rotate their keys for whatever
// reason (security issue, data leak, routine safety procedure, etc), and your instance can no
// longer communicate with theirs properly using cached keys. A key marked as expired in this way
// will be lazily refetched next time a request is made to your instance signed by the owner of that
// key, so no further action should be required in order to reestablish communication with that domain.
//
// This endpoint is explicitly not for rotating your *own* keys, it only works for remote instances.
//
// Using this endpoint to expire keys for a domain that hasn't rotated all of their keys is not
// harmful and won't break federation, but it is pointless and will cause unnecessary requests to
// be performed.
//
// ---
// tags:
// - admin
//
// consumes:
// - multipart/form-data
//
// produces:
// - application/json
//
// parameters:
// -
// name: domain
// in: formData
// description: Domain to expire keys for.
// example: example.org
// type: string
//
// security:
// - OAuth2 Bearer:
// - admin
//
// responses:
// '202':
// description: >-
// Request accepted and will be processed.
// Check the logs for progress / errors.
// schema:
// "$ref": "#/definitions/adminActionResponse"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '403':
// description: forbidden
// '404':
// description: not found
// '406':
// description: not acceptable
// '409':
// description: >-
// Conflict: There is already an admin action running that conflicts with this action.
// Check the error message in the response body for more information. This is a temporary
// error; it should be possible to process this action if you try again in a bit.
// '500':
// description: internal server error
func (m *Module) DomainKeysExpirePOSTHandler(c *gin.Context) {
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !*authed.User.Admin {
err := fmt.Errorf("user %s not an admin", authed.User.ID)
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
form := new(apimodel.DomainKeysExpireRequest)
if err := c.ShouldBind(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
if err := validateDomainKeysExpire(form); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
actionID, errWithCode := m.processor.Admin().DomainKeysExpire(
c.Request.Context(),
authed.Account,
form.Domain,
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
c.JSON(http.StatusAccepted, &apimodel.AdminActionResponse{ActionID: actionID})
}
func validateDomainKeysExpire(form *apimodel.DomainKeysExpireRequest) error {
form.Domain = strings.TrimSpace(form.Domain)
if form.Domain == "" {
return errors.New("no domain given")
}
if form.Domain == config.GetHost() || form.Domain == config.GetAccountDomain() {
return errors.New("provided domain was this domain, but must be a remote domain")
}
return nil
}

View file

@ -178,6 +178,17 @@ type AdminActionRequest struct {
TargetID string `form:"-" json:"-" xml:"-"`
}
// AdminActionResponse models the server
// response to an admin action.
//
// swagger:model adminActionResponse
type AdminActionResponse struct {
// Internal ID of the action.
//
// example: 01H9QG6TZ9W5P0402VFRVM17TH
ActionID string `json:"action_id"`
}
// MediaCleanupRequest models admin media cleanup parameters
//
// swagger:parameters mediaCleanup

View file

@ -79,3 +79,11 @@ type DomainBlockCreateRequest struct {
// public comment on the reason for the domain block
PublicComment string `form:"public_comment" json:"public_comment" xml:"public_comment"`
}
// DomainBlockCreateRequest is the form submitted as a POST to /api/v1/admin/domain_keys_expire to expire a domain's public keys.
//
// swagger:model domainKeysExpireRequest
type DomainKeysExpireRequest struct {
// hostname/domain to expire keys for.
Domain string `form:"domain" json:"domain" xml:"domain"`
}

View file

@ -0,0 +1,45 @@
// 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"
"strings"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, "ALTER TABLE ? ADD COLUMN ? TIMESTAMPTZ", bun.Ident("accounts"), bun.Ident("public_key_expires_at"))
if err != nil && !(strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate column name") || strings.Contains(err.Error(), "SQLSTATE 42701")) {
return err
}
return nil
}
down := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
return nil
})
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -25,14 +25,17 @@ import (
"fmt"
"net/http"
"net/url"
"time"
"codeberg.org/gruf/go-kv"
"github.com/go-fed/httpsig"
"github.com/superseriousbusiness/activity/streams"
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
)
@ -45,11 +48,47 @@ var (
}
)
// PubKeyAuth models authorization information for a remote
// Actor making a signed HTTP request to this GtS instance
// using a public key.
type PubKeyAuth struct {
// CachedPubKey is the public key found in the db
// for the Actor whose request we're now authenticating.
// Will be set only in cases where we had the Owner
// of the key stored in the database already.
CachedPubKey *rsa.PublicKey
// FetchedPubKey is an up-to-date public key fetched
// from the remote instance. Will be set in cases
// where EITHER we hadn't seen the Actor before whose
// request we're now authenticating, OR a CachedPubKey
// was found in our database, but was expired.
FetchedPubKey *rsa.PublicKey
// OwnerURI is the ActivityPub id of the owner of
// the public key used to sign the request we're
// now authenticating. This will always be set
// even if Owner isn't, so that callers can use
// this URI to go fetch the Owner from remote.
OwnerURI *url.URL
// Owner is the account corresponding to OwnerURI.
//
// Owner will only be defined if the account who
// owns the public key was already cached in the
// database when we received the request we're now
// authenticating (ie., we've seen it before).
//
// If it's not defined, callers should use OwnerURI
// to go and dereference it.
Owner *gtsmodel.Account
}
// AuthenticateFederatedRequest authenticates any kind of incoming federated
// request from a remote server. This includes things like GET requests for
// dereferencing our users or statuses etc, and POST requests for delivering
// new Activities. The function returns the URL of the owner of the public key
// used in the requesting http signature.
// new Activities. The function returns details of the public key(s) used to
// authenticate the requesting http signature.
//
// 'Authenticate' in this case is defined as making sure that the http request
// is actually signed by whoever claims to have signed it, by fetching the public
@ -70,7 +109,7 @@ var (
// Also note that this function *does not* dereference the remote account that
// the signature key is associated with. Other functions should use the returned
// URL to dereference the remote account, if required.
func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedUsername string) (*url.URL, gtserror.WithCode) {
func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedUsername string) (*PubKeyAuth, gtserror.WithCode) {
// Thanks to the signature check middleware,
// we should already have an http signature
// verifier set on the context. If we don't,
@ -103,8 +142,8 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU
var (
pubKeyIDStr = pubKeyID.String()
requestingAccountURI *url.URL
pubKey interface{}
local = (pubKeyID.Host == config.GetHost())
pubKeyAuth *PubKeyAuth
errWithCode gtserror.WithCode
)
@ -115,38 +154,50 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU
{"pubKeyID", pubKeyIDStr},
}...)
if pubKeyID.Host == config.GetHost() {
l.Trace("public key is ours, no dereference needed")
requestingAccountURI, pubKey, errWithCode = f.derefDBOnly(ctx, pubKeyIDStr)
if local {
l.Trace("public key is local, no dereference needed")
pubKeyAuth, errWithCode = f.derefPubKeyDBOnly(ctx, pubKeyIDStr)
} else {
l.Trace("public key is not ours, checking if we need to dereference")
requestingAccountURI, pubKey, errWithCode = f.deref(ctx, requestedUsername, pubKeyIDStr, pubKeyID)
l.Trace("public key is remote, checking if we need to dereference")
pubKeyAuth, errWithCode = f.derefPubKey(ctx, requestedUsername, pubKeyIDStr, pubKeyID)
}
if errWithCode != nil {
return nil, errWithCode
}
// Ensure public key now defined.
if pubKey == nil {
err := gtserror.New("public key was nil")
if local && pubKeyAuth == nil {
// We signed this request, apparently, but
// local lookup didn't find anything. This
// is an almost impossible error condition!
err := gtserror.Newf("local public key %s could not be found; "+
"has the account been manually removed from the db?", pubKeyIDStr)
return nil, gtserror.NewErrorInternalError(err)
}
// Try to authenticate using permitted algorithms in
// order of most -> least common. Return OK as soon
// as one passes.
// order of most -> least common, checking each defined
// pubKey for this Actor. Return OK as soon as one passes.
for _, pubKey := range [2]*rsa.PublicKey{
pubKeyAuth.FetchedPubKey,
pubKeyAuth.CachedPubKey,
} {
if pubKey == nil {
continue
}
for _, algo := range signingAlgorithms {
l.Tracef("trying %s", algo)
err := verifier.Verify(pubKey, algo)
if err == nil {
l.Tracef("authentication PASSED with %s", algo)
return requestingAccountURI, nil
return pubKeyAuth, nil
}
l.Tracef("authentication NOT PASSED with %s: %q", algo, err)
}
}
// At this point no algorithms passed.
err := gtserror.Newf(
@ -157,36 +208,52 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU
return nil, gtserror.NewErrorUnauthorized(err, err.Error())
}
// derefDBOnly tries to dereference the given public
// key using only entries already in the database.
func (f *federator) derefDBOnly(
// derefPubKeyDBOnly tries to dereference the given
// pubKey using only entries already in the database.
//
// In case of a db or URL error, will return the error.
//
// In case an entry for the pubKey owner just doesn't
// exist in the db (yet), will return nil, nil.
func (f *federator) derefPubKeyDBOnly(
ctx context.Context,
pubKeyIDStr string,
) (*url.URL, interface{}, gtserror.WithCode) {
reqAcct, err := f.db.GetAccountByPubkeyID(ctx, pubKeyIDStr)
) (*PubKeyAuth, gtserror.WithCode) {
owner, err := f.db.GetAccountByPubkeyID(ctx, pubKeyIDStr)
if err != nil {
err = gtserror.Newf("db error getting account with pubKeyID %s: %w", pubKeyIDStr, err)
return nil, nil, gtserror.NewErrorInternalError(err)
if errors.Is(err, db.ErrNoEntries) {
// We don't have this
// account stored (yet).
return nil, nil
}
reqAcctURI, err := url.Parse(reqAcct.URI)
err = gtserror.Newf("db error getting account with pubKeyID %s: %w", pubKeyIDStr, err)
return nil, gtserror.NewErrorInternalError(err)
}
ownerURI, err := url.Parse(owner.URI)
if err != nil {
err = gtserror.Newf("error parsing account uri with pubKeyID %s: %w", pubKeyIDStr, err)
return nil, nil, gtserror.NewErrorInternalError(err)
return nil, gtserror.NewErrorInternalError(err)
}
return reqAcctURI, reqAcct.PublicKey, nil
return &PubKeyAuth{
CachedPubKey: owner.PublicKey,
OwnerURI: ownerURI,
Owner: owner,
}, nil
}
// deref tries to dereference the given public key by first
// checking in the database, and then (if no entries found)
// calling the remote pub key URI and extracting the key.
func (f *federator) deref(
// derefPubKey tries to dereference the given public key by first
// checking in the database, and then (if no entry found, or entry
// found but pubKey expired) calling the remote pub key URI and
// extracting the key.
func (f *federator) derefPubKey(
ctx context.Context,
requestedUsername string,
pubKeyIDStr string,
pubKeyID *url.URL,
) (*url.URL, interface{}, gtserror.WithCode) {
) (*PubKeyAuth, gtserror.WithCode) {
l := log.
WithContext(ctx).
WithFields(kv.Fields{
@ -196,42 +263,101 @@ func (f *federator) deref(
// Try a database only deref first. We may already
// have the requesting account cached locally.
reqAcctURI, pubKey, errWithCode := f.derefDBOnly(ctx, pubKeyIDStr)
if errWithCode == nil {
l.Trace("public key cached, no dereference needed")
return reqAcctURI, pubKey, nil
pubKeyAuth, errWithCode := f.derefPubKeyDBOnly(ctx, pubKeyIDStr)
if errWithCode != nil {
return nil, errWithCode
}
l.Trace("public key not cached, trying dereference")
var (
// Just haven't seen this
// Actor + their pubkey yet.
uncached = (pubKeyAuth == nil)
// Have seen this Actor + their
// pubkey but latter is now expired.
expired = (!uncached && pubKeyAuth.Owner.PubKeyExpired())
)
switch {
case uncached:
l.Trace("public key was not cached, trying dereference of public key")
case !expired:
l.Trace("public key cached and up to date, no dereference needed")
return pubKeyAuth, nil
case expired:
// This is fairly rare and it may be helpful for
// admins to see what's going on, so log at info.
l.Infof(
"public key was cached, but expired at %s, trying dereference of new public key",
pubKeyAuth.Owner.PublicKeyExpiresAt,
)
}
// If we've tried to get this account before and we
// now have a tombstone for it (ie., it's been deleted
// from remote), don't try to dereference it again.
gone, err := f.CheckGone(ctx, pubKeyID)
if err != nil {
err := gtserror.Newf("error checking for tombstone for %s: %w", pubKeyIDStr, err)
return nil, nil, gtserror.NewErrorInternalError(err)
err := gtserror.Newf("error checking for tombstone (%s): %w", pubKeyIDStr, err)
return nil, gtserror.NewErrorInternalError(err)
}
if gone {
err := gtserror.Newf("account with public key %s is gone", pubKeyIDStr)
return nil, nil, gtserror.NewErrorGone(err)
err := gtserror.Newf("account with public key is gone (%s)", pubKeyIDStr)
return nil, gtserror.NewErrorGone(err)
}
// Make an http call to get the pubkey.
// Make an http call to get the (refreshed) pubkey.
pubKeyBytes, errWithCode := f.callForPubKey(ctx, requestedUsername, pubKeyID)
if errWithCode != nil {
return nil, nil, errWithCode
return nil, errWithCode
}
// Extract the key and the owner from the response.
pubKey, pubKeyOwner, err := parsePubKeyBytes(ctx, pubKeyBytes, pubKeyID)
if err != nil {
err := fmt.Errorf("error parsing public key %s: %w", pubKeyID, err)
return nil, nil, gtserror.NewErrorUnauthorized(err)
err := fmt.Errorf("error parsing public key (%s): %w", pubKeyID, err)
return nil, gtserror.NewErrorUnauthorized(err)
}
return pubKeyOwner, pubKey, nil
if !expired {
// PubKeyResponse was nil before because
// we had nothing cached; return the key
// we just fetched, and nothing else.
return &PubKeyAuth{
FetchedPubKey: pubKey,
OwnerURI: pubKeyOwner,
}, nil
}
// Add newly-fetched key to response.
pubKeyAuth.FetchedPubKey = pubKey
// If key was expired, that means we already
// had an owner stored for it locally. Since
// we now successfully refreshed the pub key,
// we should update the account to reflect that.
ownerAcct := pubKeyAuth.Owner
ownerAcct.PublicKey = pubKeyAuth.FetchedPubKey
ownerAcct.PublicKeyExpiresAt = time.Time{}
l.Info("obtained a new public key to replace expired key, caching now; " +
"authorization for this request will be attempted with both old and new keys")
if err := f.db.UpdateAccount(
ctx,
ownerAcct,
"public_key",
"public_key_expires_at",
); err != nil {
err := gtserror.Newf("db error updating account with refreshed public key (%s): %w", pubKeyIDStr, err)
return nil, gtserror.NewErrorInternalError(err)
}
// Return both new and cached (now
// expired) keys, authentication
// will be attempted with both.
return pubKeyAuth, nil
}
// callForPubKey handles the nitty gritty of actually

View file

@ -209,7 +209,7 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr
}
// Check who's trying to deliver to us by inspecting the http signature.
pubKeyOwner, errWithCode := f.AuthenticateFederatedRequest(ctx, receivingAccount.Username)
pubKeyAuth, errWithCode := f.AuthenticateFederatedRequest(ctx, receivingAccount.Username)
if errWithCode != nil {
switch errWithCode.Code() {
case http.StatusUnauthorized, http.StatusForbidden, http.StatusBadRequest:
@ -232,12 +232,14 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr
}
}
pubKeyOwnerURI := pubKeyAuth.OwnerURI
// Authentication has passed, check if we need to create a
// new instance entry for the Host of the requesting account.
if _, err := f.db.GetInstance(ctx, pubKeyOwner.Host); err != nil {
if _, err := f.db.GetInstance(ctx, pubKeyOwnerURI.Host); err != nil {
if !errors.Is(err, db.ErrNoEntries) {
// There's been an actual error.
err = gtserror.Newf("error getting instance %s: %w", pubKeyOwner.Host, err)
err = gtserror.Newf("error getting instance %s: %w", pubKeyOwnerURI.Host, err)
return ctx, false, err
}
@ -247,17 +249,17 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr
gtscontext.SetFastFail(ctx),
username,
&url.URL{
Scheme: pubKeyOwner.Scheme,
Host: pubKeyOwner.Host,
Scheme: pubKeyOwnerURI.Scheme,
Host: pubKeyOwnerURI.Host,
},
)
if err != nil {
err = gtserror.Newf("error dereferencing instance %s: %w", pubKeyOwner.Host, err)
err = gtserror.Newf("error dereferencing instance %s: %w", pubKeyOwnerURI.Host, err)
return nil, false, err
}
if err := f.db.PutInstance(ctx, instance); err != nil && !errors.Is(err, db.ErrAlreadyExists) {
err = gtserror.Newf("error inserting instance entry for %s: %w", pubKeyOwner.Host, err)
err = gtserror.Newf("error inserting instance entry for %s: %w", pubKeyOwnerURI.Host, err)
return nil, false, err
}
}
@ -268,7 +270,7 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr
requestingAccount, _, err := f.GetAccountByURI(
gtscontext.SetFastFail(ctx),
username,
pubKeyOwner,
pubKeyOwnerURI,
)
if err != nil {
if gtserror.StatusCode(err) == http.StatusGone {
@ -282,7 +284,7 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr
return ctx, false, nil
}
err = gtserror.Newf("couldn't get requesting account %s: %w", pubKeyOwner, err)
err = gtserror.Newf("couldn't get requesting account %s: %w", pubKeyOwnerURI, err)
return nil, false, err
}

View file

@ -257,6 +257,33 @@ func (suite *FederatingProtocolTestSuite) TestAuthenticatePostInbox() {
suite.Equal(http.StatusOK, code)
}
func (suite *FederatingProtocolTestSuite) TestAuthenticatePostInboxKeyExpired() {
var (
ctx = context.Background()
activity = suite.testActivities["dm_for_zork"]
receivingAccount = suite.testAccounts["local_account_1"]
)
// Update remote account to mark key as expired.
remoteAcct := &gtsmodel.Account{}
*remoteAcct = *suite.testAccounts["remote_account_1"]
remoteAcct.PublicKeyExpiresAt = testrig.TimeMustParse("2022-06-10T15:22:08Z")
if err := suite.state.DB.UpdateAccount(ctx, remoteAcct, "public_key_expires_at"); err != nil {
suite.FailNow(err.Error())
}
ctx, authed, resp, code := suite.authenticatePostInbox(
ctx,
receivingAccount,
activity,
)
suite.NotNil(gtscontext.RequestingAccount(ctx))
suite.True(authed)
suite.Equal([]byte{}, resp)
suite.Equal(http.StatusOK, code)
}
func (suite *FederatingProtocolTestSuite) TestAuthenticatePostGoneWithTombstone() {
var (
activity = suite.testActivities["delete_https://somewhere.mysterious/users/rest_in_piss#main-key"]

View file

@ -19,7 +19,6 @@ package federation
import (
"context"
"net/url"
"github.com/superseriousbusiness/activity/pub"
"github.com/superseriousbusiness/gotosocial/internal/db"
@ -49,7 +48,7 @@ type Federator interface {
// If the request does not pass authentication, or there's a domain block, nil, false, nil will be returned.
//
// If something goes wrong during authentication, nil, false, and an error will be returned.
AuthenticateFederatedRequest(ctx context.Context, username string) (*url.URL, gtserror.WithCode)
AuthenticateFederatedRequest(ctx context.Context, username string) (*PubKeyAuth, gtserror.WithCode)
pub.CommonBehavior
pub.FederatingProtocol

View file

@ -18,6 +18,8 @@
package federation_test
import (
"context"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/federation"
@ -71,7 +73,14 @@ func (suite *FederatorStandardTestSuite) SetupTest() {
suite.typeconverter,
)
suite.httpClient = testrig.NewMockHTTPClient(nil, "../../testrig/media")
// Ensure it's possible to deref
// main key of foss satan.
fossSatanPerson, err := suite.typeconverter.AccountToAS(context.Background(), suite.testAccounts["remote_account_1"])
if err != nil {
suite.FailNow(err.Error())
}
suite.httpClient = testrig.NewMockHTTPClient(nil, "../../testrig/media", fossSatanPerson)
suite.httpClient.TestRemotePeople = testrig.NewTestFediPeople()
suite.httpClient.TestRemoteStatuses = testrig.NewTestFediStatuses()

View file

@ -72,9 +72,10 @@ type Account struct {
FollowersURI string `bun:",nullzero,unique"` // URI for getting the followers list of this account
FeaturedCollectionURI string `bun:",nullzero,unique"` // URL for getting the featured collection list of this account
ActorType string `bun:",nullzero,notnull"` // What type of activitypub actor is this account?
PrivateKey *rsa.PrivateKey `bun:""` // Privatekey for validating activitypub requests, will only be defined for local accounts
PublicKey *rsa.PublicKey `bun:",notnull"` // Publickey for encoding activitypub requests, will be defined for both local and remote accounts
PrivateKey *rsa.PrivateKey `bun:""` // Privatekey for signing activitypub requests, will only be defined for local accounts
PublicKey *rsa.PublicKey `bun:",notnull"` // Publickey for authorizing signed activitypub requests, will be defined for both local and remote accounts
PublicKeyURI string `bun:",nullzero,notnull,unique"` // Web-reachable location of this account's public key
PublicKeyExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // PublicKey will expire/has expired at given time, and should be fetched again as appropriate. Only ever set for remote accounts.
SensitizedAt time.Time `bun:"type:timestamptz,nullzero"` // When was this account set to have all its media shown as sensitive?
SilencedAt time.Time `bun:"type:timestamptz,nullzero"` // When was this account silenced (eg., statuses only visible to followers, not public)?
SuspendedAt time.Time `bun:"type:timestamptz,nullzero"` // When was this account suspended (eg., don't allow it to log in/post, don't accept media/posts from this account)
@ -129,6 +130,17 @@ func (a *Account) EmojisPopulated() bool {
return true
}
// PubKeyExpired returns true if the account's public key
// has been marked as expired, and the expiry time has passed.
func (a *Account) PubKeyExpired() bool {
if a == nil {
return false
}
return !a.PublicKeyExpiresAt.IsZero() &&
a.PublicKeyExpiresAt.Before(time.Now())
}
// AccountToEmoji is an intermediate struct to facilitate the many2many relationship between an account and one or more emojis.
type AccountToEmoji struct {
AccountID string `bun:"type:CHAR(26),unique:accountemoji,nullzero,notnull"`

View file

@ -72,6 +72,7 @@ const (
AdminActionUnsilence
AdminActionSuspend
AdminActionUnsuspend
AdminActionExpireKeys
)
func (t AdminActionType) String() string {
@ -88,6 +89,8 @@ func (t AdminActionType) String() string {
return "suspend"
case AdminActionUnsuspend:
return "unsuspend"
case AdminActionExpireKeys:
return "expire-keys"
default:
return "unknown"
}
@ -107,6 +110,8 @@ func NewAdminActionType(in string) AdminActionType {
return AdminActionSuspend
case "unsuspend":
return AdminActionUnsuspend
case "expire-keys":
return AdminActionExpireKeys
default:
return AdminActionUnknown
}

View file

@ -0,0 +1,87 @@
// 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"
"time"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
)
// DomainKeysExpire iterates through all
// accounts belonging to the given domain,
// and expires the public key of each
// account found this way.
//
// The PublicKey for each account will be
// re-fetched next time a signed request
// from that account is received.
func (p *Processor) DomainKeysExpire(
ctx context.Context,
adminAcct *gtsmodel.Account,
domain string,
) (string, gtserror.WithCode) {
actionID := id.NewULID()
// Process key expiration asynchronously.
if errWithCode := p.actions.Run(
ctx,
&gtsmodel.AdminAction{
ID: actionID,
TargetCategory: gtsmodel.AdminActionCategoryDomain,
TargetID: domain,
Type: gtsmodel.AdminActionExpireKeys,
AccountID: adminAcct.ID,
},
func(ctx context.Context) gtserror.MultiError {
return p.domainKeysExpireSideEffects(ctx, domain)
},
); errWithCode != nil {
return actionID, errWithCode
}
return actionID, nil
}
func (p *Processor) domainKeysExpireSideEffects(ctx context.Context, domain string) gtserror.MultiError {
var (
expiresAt = time.Now()
errs gtserror.MultiError
)
// For each account on this domain, expire
// the public key and update the account.
if err := p.rangeDomainAccounts(ctx, domain, func(account *gtsmodel.Account) {
account.PublicKeyExpiresAt = expiresAt
if err := p.state.DB.UpdateAccount(
ctx,
account,
"public_key_expires_at",
); err != nil {
errs.Appendf("db error updating account: %w", err)
}
}); err != nil {
errs.Appendf("db error ranging through accounts: %w", err)
}
return errs
}

View file

@ -48,7 +48,7 @@ func (p *Processor) authenticate(ctx context.Context, requestedUsername string)
// Ensure request signed, and use signature URI to
// get requesting account, dereferencing if necessary.
requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
pubKeyAuth, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if errWithCode != nil {
return nil, nil, errWithCode
}
@ -56,10 +56,10 @@ func (p *Processor) authenticate(ctx context.Context, requestedUsername string)
requestingAccount, _, err := p.federator.GetAccountByURI(
gtscontext.SetFastFail(ctx),
requestedUsername,
requestingAccountURI,
pubKeyAuth.OwnerURI,
)
if err != nil {
err = gtserror.Newf("error getting account %s: %w", requestingAccountURI, err)
err = gtserror.Newf("error getting account %s: %w", pubKeyAuth.OwnerURI, err)
return nil, nil, gtserror.NewErrorUnauthorized(err)
}

View file

@ -66,7 +66,7 @@ func (p *Processor) UserGet(ctx context.Context, requestedUsername string, reque
// If the request is not on a public key path, we want to
// try to authenticate it before we serve any data, so that
// we can serve a more complete profile.
requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
pubKeyAuth, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if errWithCode != nil {
return nil, errWithCode // likely 401
}
@ -89,7 +89,7 @@ func (p *Processor) UserGet(ctx context.Context, requestedUsername string, reque
// Instead, we end up in an 'I'll show you mine if you show me
// yours' situation, where we sort of agree to reveal each
// other's profiles at the same time.
if p.federator.Handshaking(requestedUsername, requestingAccountURI) {
if p.federator.Handshaking(requestedUsername, pubKeyAuth.OwnerURI) {
return data(person)
}
@ -98,10 +98,11 @@ func (p *Processor) UserGet(ctx context.Context, requestedUsername string, reque
requestingAccount, _, err := p.federator.GetAccountByURI(
// On a hot path so fail quickly.
gtscontext.SetFastFail(ctx),
requestedUsername, requestingAccountURI,
requestedUsername,
pubKeyAuth.OwnerURI,
)
if err != nil {
err := gtserror.Newf("error getting account %s: %w", requestingAccountURI, err)
err := gtserror.Newf("error getting account %s: %w", pubKeyAuth.OwnerURI, err)
return nil, gtserror.NewErrorUnauthorized(err)
}

View file

@ -78,7 +78,7 @@ type MockHTTPClient struct {
// to customize how the client is mocked.
//
// Note that you should never ever make ACTUAL http calls with this thing.
func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relativeMediaPath string) *MockHTTPClient {
func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relativeMediaPath string, extraPeople ...vocab.ActivityStreamsPerson) *MockHTTPClient {
mockHTTPClient := &MockHTTPClient{}
if do != nil {
@ -95,10 +95,13 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat
mockHTTPClient.TestTombstones = NewTestTombstones()
mockHTTPClient.do = func(req *http.Request) (*http.Response, error) {
responseCode := http.StatusNotFound
responseBytes := []byte(`{"error":"404 not found"}`)
responseContentType := applicationJSON
responseContentLength := len(responseBytes)
var (
responseCode = http.StatusNotFound
responseBytes = []byte(`{"error":"404 not found"}`)
responseContentType = applicationJSON
responseContentLength = len(responseBytes)
reqURLString = req.URL.String()
)
if req.Method == http.MethodPost {
b, err := io.ReadAll(req.Body)
@ -106,26 +109,26 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat
panic(err)
}
if sI, loaded := mockHTTPClient.SentMessages.LoadOrStore(req.URL.String(), [][]byte{b}); loaded {
if sI, loaded := mockHTTPClient.SentMessages.LoadOrStore(reqURLString, [][]byte{b}); loaded {
s, ok := sI.([][]byte)
if !ok {
panic("SentMessages entry wasn't [][]byte")
}
s = append(s, b)
mockHTTPClient.SentMessages.Store(req.URL.String(), s)
mockHTTPClient.SentMessages.Store(reqURLString, s)
}
responseCode = http.StatusOK
responseBytes = []byte(`{"ok":"accepted"}`)
responseContentType = applicationJSON
responseContentLength = len(responseBytes)
} else if strings.Contains(req.URL.String(), ".well-known/webfinger") {
} else if strings.Contains(reqURLString, ".well-known/webfinger") {
responseCode, responseBytes, responseContentType, responseContentLength = WebfingerResponse(req)
} else if strings.Contains(req.URL.String(), ".weird-webfinger-location/webfinger") {
} else if strings.Contains(reqURLString, ".weird-webfinger-location/webfinger") {
responseCode, responseBytes, responseContentType, responseContentLength = WebfingerResponse(req)
} else if strings.Contains(req.URL.String(), ".well-known/host-meta") {
} else if strings.Contains(reqURLString, ".well-known/host-meta") {
responseCode, responseBytes, responseContentType, responseContentLength = HostMetaResponse(req)
} else if note, ok := mockHTTPClient.TestRemoteStatuses[req.URL.String()]; ok {
} else if note, ok := mockHTTPClient.TestRemoteStatuses[reqURLString]; ok {
// the request is for a note that we have stored
noteI, err := streams.Serialize(note)
if err != nil {
@ -139,7 +142,7 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat
responseBytes = noteJSON
responseContentType = applicationActivityJSON
responseContentLength = len(noteJSON)
} else if person, ok := mockHTTPClient.TestRemotePeople[req.URL.String()]; ok {
} else if person, ok := mockHTTPClient.TestRemotePeople[reqURLString]; ok {
// the request is for a person that we have stored
personI, err := streams.Serialize(person)
if err != nil {
@ -153,7 +156,7 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat
responseBytes = personJSON
responseContentType = applicationActivityJSON
responseContentLength = len(personJSON)
} else if group, ok := mockHTTPClient.TestRemoteGroups[req.URL.String()]; ok {
} else if group, ok := mockHTTPClient.TestRemoteGroups[reqURLString]; ok {
// the request is for a person that we have stored
groupI, err := streams.Serialize(group)
if err != nil {
@ -167,7 +170,7 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat
responseBytes = groupJSON
responseContentType = applicationActivityJSON
responseContentLength = len(groupJSON)
} else if service, ok := mockHTTPClient.TestRemoteServices[req.URL.String()]; ok {
} else if service, ok := mockHTTPClient.TestRemoteServices[reqURLString]; ok {
serviceI, err := streams.Serialize(service)
if err != nil {
panic(err)
@ -180,7 +183,7 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat
responseBytes = serviceJSON
responseContentType = applicationActivityJSON
responseContentLength = len(serviceJSON)
} else if emoji, ok := mockHTTPClient.TestRemoteEmojis[req.URL.String()]; ok {
} else if emoji, ok := mockHTTPClient.TestRemoteEmojis[reqURLString]; ok {
emojiI, err := streams.Serialize(emoji)
if err != nil {
panic(err)
@ -193,16 +196,45 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat
responseBytes = emojiJSON
responseContentType = applicationActivityJSON
responseContentLength = len(emojiJSON)
} else if attachment, ok := mockHTTPClient.TestRemoteAttachments[req.URL.String()]; ok {
} else if attachment, ok := mockHTTPClient.TestRemoteAttachments[reqURLString]; ok {
responseCode = http.StatusOK
responseBytes = attachment.Data
responseContentType = attachment.ContentType
responseContentLength = len(attachment.Data)
} else if _, ok := mockHTTPClient.TestTombstones[req.URL.String()]; ok {
} else if _, ok := mockHTTPClient.TestTombstones[reqURLString]; ok {
responseCode = http.StatusGone
responseBytes = []byte{}
responseContentType = "text/html"
responseContentLength = 0
} else {
for _, person := range extraPeople {
// For any extra people, check if the
// request matches one of:
//
// - Public key URI
// - ActivityPub URI/id
// - Web URL.
//
// Since this is a test environment,
// just assume all these values have
// been properly set.
if reqURLString == person.GetW3IDSecurityV1PublicKey().At(0).Get().GetJSONLDId().GetIRI().String() ||
reqURLString == person.GetJSONLDId().GetIRI().String() ||
reqURLString == person.GetActivityStreamsUrl().At(0).GetIRI().String() {
personI, err := streams.Serialize(person)
if err != nil {
panic(err)
}
personJSON, err := json.Marshal(personI)
if err != nil {
panic(err)
}
responseCode = http.StatusOK
responseBytes = personJSON
responseContentType = applicationActivityJSON
responseContentLength = len(personJSON)
}
}
}
log.Debugf(nil, "returning response %s", string(responseBytes))

View file

@ -0,0 +1,61 @@
/*
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/>.
*/
"use strict";
const React = require("react");
const query = require("../../../lib/query");
const { useTextInput } = require("../../../lib/form");
const { TextInput } = require("../../../components/form/inputs");
const MutationButton = require("../../../components/form/mutation-button");
module.exports = function ExpireRemote({}) {
const domainField = useTextInput("domain");
const [expire, expireResult] = query.useInstanceKeysExpireMutation();
function submitExpire(e) {
e.preventDefault();
expire(domainField.value);
}
return (
<form onSubmit={submitExpire}>
<h2>Expire remote instance keys</h2>
<p>
Mark all public keys from the given remote instance as expired.<br/><br/>
This is useful in cases where the remote domain has had to rotate their keys for whatever
reason (security issue, data leak, routine safety procedure, etc), and your instance can no
longer communicate with theirs properly using cached keys. A key marked as expired in this way
will be lazily refetched next time a request is made to your instance signed by the owner of that
key.
</p>
<TextInput
field={domainField}
label="Domain"
type="string"
placeholder="example.org"
/>
<MutationButton label="Expire keys" result={expireResult} />
</form>
);
};

View file

@ -0,0 +1,32 @@
/*
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/>.
*/
"use strict";
const React = require("react");
const ExpireRemote = require("./expireremote");
module.exports = function Keys() {
return (
<>
<h1>Key Actions</h1>
<ExpireRemote />
</>
);
};

View file

@ -21,28 +21,26 @@
const React = require("react");
const query = require("../lib/query");
const query = require("../../../lib/query");
const { useTextInput } = require("../lib/form");
const { TextInput } = require("../components/form/inputs");
const { useTextInput } = require("../../../lib/form");
const { TextInput } = require("../../../components/form/inputs");
const MutationButton = require("../components/form/mutation-button");
const MutationButton = require("../../../components/form/mutation-button");
module.exports = function AdminActionPanel() {
module.exports = function Cleanup({}) {
const daysField = useTextInput("days", { defaultValue: 30 });
const [mediaCleanup, mediaCleanupResult] = query.useMediaCleanupMutation();
function submitMediaCleanup(e) {
function submitCleanup(e) {
e.preventDefault();
mediaCleanup(daysField.value);
}
return (
<>
<h1>Admin Actions</h1>
<form onSubmit={submitMediaCleanup}>
<h2>Media cleanup</h2>
<form onSubmit={submitCleanup}>
<h2>Cleanup</h2>
<p>
Clean up remote media older than the specified number of days.
If the remote instance is still online they will be refetched when needed.
@ -57,6 +55,5 @@ module.exports = function AdminActionPanel() {
/>
<MutationButton label="Remove old media" result={mediaCleanupResult} />
</form>
</>
);
};

View file

@ -0,0 +1,32 @@
/*
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/>.
*/
"use strict";
const React = require("react");
const Cleanup = require("./cleanup");
module.exports = function Media() {
return (
<>
<h1>Media Actions</h1>
<Cleanup />
</>
);
};

View file

@ -55,7 +55,10 @@ const { Sidebar, ViewRouter } = createNavigation("/settings", [
defaultUrl: "/settings/admin/settings",
permissions: ["admin"]
}, [
Item("Actions", { icon: "fa-bolt" }, require("./admin/actions")),
Menu("Actions", { icon: "fa-bolt" }, [
Item("Media", { icon: "fa-photo" }, require("./admin/actions/media")),
Item("Keys", { icon: "fa-key-modern" }, require("./admin/actions/keys")),
]),
Menu("Custom Emoji", { icon: "fa-smile-o" }, [
Item("Local", { icon: "fa-home", wildcard: true }, require("./admin/emoji/local")),
Item("Remote", { icon: "fa-cloud" }, require("./admin/emoji/remote"))
@ -63,7 +66,7 @@ const { Sidebar, ViewRouter } = createNavigation("/settings", [
Menu("Settings", { icon: "fa-sliders" }, [
Item("Settings", { icon: "fa-sliders", url: "" }, require("./admin/settings")),
Item("Rules", { icon: "fa-dot-circle-o", wildcard: true }, require("./admin/settings/rules"))
])
]),
])
]);

View file

@ -47,6 +47,15 @@ const endpoints = (build) => ({
}
})
}),
instanceKeysExpire: build.mutation({
query: (domain) => ({
method: "POST",
url: `/api/v1/admin/domain_keys_expire`,
params: {
domain: domain
}
})
}),
instanceBlocks: build.query({
query: () => ({
url: `/api/v1/admin/domain_blocks`