From 4b594516ec5fe6d849663d877db5a0614de03089 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Tue, 12 Sep 2023 11:43:12 +0200
Subject: [PATCH] [feature] Allow admins to expire remote public keys; refetch
expired keys on demand (#2183)
---
docs/api/swagger.yaml | 73 ++++++
internal/api/client/admin/admin.go | 4 +
internal/api/client/admin/domainkeysexpire.go | 149 +++++++++++
internal/api/model/admin.go | 11 +
internal/api/model/domain.go | 8 +
.../20230905133904_remote_pubkey_expiry.go | 45 ++++
internal/federation/authenticate.go | 232 ++++++++++++++----
internal/federation/federatingprotocol.go | 20 +-
.../federation/federatingprotocol_test.go | 27 ++
internal/federation/federator.go | 3 +-
internal/federation/federator_test.go | 11 +-
internal/gtsmodel/account.go | 16 +-
internal/gtsmodel/adminaction.go | 5 +
internal/processing/admin/domainkeysexpire.go | 87 +++++++
internal/processing/fedi/common.go | 6 +-
internal/processing/fedi/user.go | 9 +-
testrig/transportcontroller.go | 66 +++--
.../admin/actions/keys/expireremote.jsx | 61 +++++
.../settings/admin/actions/keys/index.jsx | 32 +++
.../{actions.js => actions/media/cleanup.jsx} | 45 ++--
.../settings/admin/actions/media/index.jsx | 32 +++
web/source/settings/index.js | 7 +-
web/source/settings/lib/query/admin/index.js | 9 +
23 files changed, 841 insertions(+), 117 deletions(-)
create mode 100644 internal/api/client/admin/domainkeysexpire.go
create mode 100644 internal/db/bundb/migrations/20230905133904_remote_pubkey_expiry.go
create mode 100644 internal/processing/admin/domainkeysexpire.go
create mode 100644 web/source/settings/admin/actions/keys/expireremote.jsx
create mode 100644 web/source/settings/admin/actions/keys/index.jsx
rename web/source/settings/admin/{actions.js => actions/media/cleanup.jsx} (66%)
create mode 100644 web/source/settings/admin/actions/media/index.jsx
diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml
index 793478aeb..d9bf40b06 100644
--- a/docs/api/swagger.yaml
+++ b/docs/api/swagger.yaml
@@ -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:
diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go
index ce6604c29..605c53731 100644
--- a/internal/api/client/admin/admin.go
+++ b/internal/api/client/admin/admin.go
@@ -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)
diff --git a/internal/api/client/admin/domainkeysexpire.go b/internal/api/client/admin/domainkeysexpire.go
new file mode 100644
index 000000000..73a811dd4
--- /dev/null
+++ b/internal/api/client/admin/domainkeysexpire.go
@@ -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 .
+
+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
+}
diff --git a/internal/api/model/admin.go b/internal/api/model/admin.go
index 6be3e9cbd..ca4aa32da 100644
--- a/internal/api/model/admin.go
+++ b/internal/api/model/admin.go
@@ -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
diff --git a/internal/api/model/domain.go b/internal/api/model/domain.go
index 045dc2700..c5f77c82f 100644
--- a/internal/api/model/domain.go
+++ b/internal/api/model/domain.go
@@ -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"`
+}
diff --git a/internal/db/bundb/migrations/20230905133904_remote_pubkey_expiry.go b/internal/db/bundb/migrations/20230905133904_remote_pubkey_expiry.go
new file mode 100644
index 000000000..814540505
--- /dev/null
+++ b/internal/db/bundb/migrations/20230905133904_remote_pubkey_expiry.go
@@ -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 .
+
+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)
+ }
+}
diff --git a/internal/federation/authenticate.go b/internal/federation/authenticate.go
index 80998680e..12d6f459a 100644
--- a/internal/federation/authenticate.go
+++ b/internal/federation/authenticate.go
@@ -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,
@@ -102,10 +141,10 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU
// so now we need to validate the signature.
var (
- pubKeyIDStr = pubKeyID.String()
- requestingAccountURI *url.URL
- pubKey interface{}
- errWithCode gtserror.WithCode
+ pubKeyIDStr = pubKeyID.String()
+ local = (pubKeyID.Host == config.GetHost())
+ pubKeyAuth *PubKeyAuth
+ errWithCode gtserror.WithCode
)
l := log.
@@ -115,37 +154,49 @@ 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.
- 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
+ // 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
}
- l.Tracef("authentication NOT PASSED with %s: %q", algo, err)
+ 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 pubKeyAuth, nil
+ }
+
+ l.Tracef("authentication NOT PASSED with %s: %q", algo, err)
+ }
}
// At this point no algorithms passed.
@@ -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 {
+ if errors.Is(err, db.ErrNoEntries) {
+ // We don't have this
+ // account stored (yet).
+ return nil, nil
+ }
+
err = gtserror.Newf("db error getting account with pubKeyID %s: %w", pubKeyIDStr, err)
- return nil, nil, gtserror.NewErrorInternalError(err)
+ return nil, gtserror.NewErrorInternalError(err)
}
- reqAcctURI, err := url.Parse(reqAcct.URI)
+ 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
diff --git a/internal/federation/federatingprotocol.go b/internal/federation/federatingprotocol.go
index ef42639ed..fb4e5bfb9 100644
--- a/internal/federation/federatingprotocol.go
+++ b/internal/federation/federatingprotocol.go
@@ -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
}
diff --git a/internal/federation/federatingprotocol_test.go b/internal/federation/federatingprotocol_test.go
index 8da6859dd..7a8343048 100644
--- a/internal/federation/federatingprotocol_test.go
+++ b/internal/federation/federatingprotocol_test.go
@@ -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 := >smodel.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"]
diff --git a/internal/federation/federator.go b/internal/federation/federator.go
index 40af08d25..ad6db8ff7 100644
--- a/internal/federation/federator.go
+++ b/internal/federation/federator.go
@@ -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
diff --git a/internal/federation/federator_test.go b/internal/federation/federator_test.go
index a80d590a4..3287cd11a 100644
--- a/internal/federation/federator_test.go
+++ b/internal/federation/federator_test.go
@@ -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()
diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go
index 7b27f076a..578d4c811 100644
--- a/internal/gtsmodel/account.go
+++ b/internal/gtsmodel/account.go
@@ -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"`
diff --git a/internal/gtsmodel/adminaction.go b/internal/gtsmodel/adminaction.go
index c6c598b32..1e55a33f9 100644
--- a/internal/gtsmodel/adminaction.go
+++ b/internal/gtsmodel/adminaction.go
@@ -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
}
diff --git a/internal/processing/admin/domainkeysexpire.go b/internal/processing/admin/domainkeysexpire.go
new file mode 100644
index 000000000..886da8b2f
--- /dev/null
+++ b/internal/processing/admin/domainkeysexpire.go
@@ -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 .
+
+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,
+ >smodel.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
+}
diff --git a/internal/processing/fedi/common.go b/internal/processing/fedi/common.go
index 1331a20e0..38c31ffd2 100644
--- a/internal/processing/fedi/common.go
+++ b/internal/processing/fedi/common.go
@@ -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)
}
diff --git a/internal/processing/fedi/user.go b/internal/processing/fedi/user.go
index 4a55df01f..f3305c103 100644
--- a/internal/processing/fedi/user.go
+++ b/internal/processing/fedi/user.go
@@ -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)
}
diff --git a/testrig/transportcontroller.go b/testrig/transportcontroller.go
index 1c75e1974..46a9b0fb2 100644
--- a/testrig/transportcontroller.go
+++ b/testrig/transportcontroller.go
@@ -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))
diff --git a/web/source/settings/admin/actions/keys/expireremote.jsx b/web/source/settings/admin/actions/keys/expireremote.jsx
new file mode 100644
index 000000000..b9045a7ed
--- /dev/null
+++ b/web/source/settings/admin/actions/keys/expireremote.jsx
@@ -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 .
+*/
+
+"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 (
+
+ );
+};
diff --git a/web/source/settings/admin/actions/keys/index.jsx b/web/source/settings/admin/actions/keys/index.jsx
new file mode 100644
index 000000000..b40835c12
--- /dev/null
+++ b/web/source/settings/admin/actions/keys/index.jsx
@@ -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 .
+*/
+
+"use strict";
+
+const React = require("react");
+const ExpireRemote = require("./expireremote");
+
+module.exports = function Keys() {
+ return (
+ <>
+ Key Actions
+
+ >
+ );
+};
diff --git a/web/source/settings/admin/actions.js b/web/source/settings/admin/actions/media/cleanup.jsx
similarity index 66%
rename from web/source/settings/admin/actions.js
rename to web/source/settings/admin/actions/media/cleanup.jsx
index 7f25299e5..61ee15258 100644
--- a/web/source/settings/admin/actions.js
+++ b/web/source/settings/admin/actions/media/cleanup.jsx
@@ -21,42 +21,39 @@
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 (
- <>
- Admin Actions
-
- >
+
+
+
+
);
-};
\ No newline at end of file
+};
diff --git a/web/source/settings/admin/actions/media/index.jsx b/web/source/settings/admin/actions/media/index.jsx
new file mode 100644
index 000000000..c5167506a
--- /dev/null
+++ b/web/source/settings/admin/actions/media/index.jsx
@@ -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 .
+*/
+
+"use strict";
+
+const React = require("react");
+const Cleanup = require("./cleanup");
+
+module.exports = function Media() {
+ return (
+ <>
+ Media Actions
+
+ >
+ );
+};
diff --git a/web/source/settings/index.js b/web/source/settings/index.js
index 398bca0f6..9758e89e6 100644
--- a/web/source/settings/index.js
+++ b/web/source/settings/index.js
@@ -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"))
- ])
+ ]),
])
]);
diff --git a/web/source/settings/lib/query/admin/index.js b/web/source/settings/lib/query/admin/index.js
index 515d8edcf..7b46e6ba4 100644
--- a/web/source/settings/lib/query/admin/index.js
+++ b/web/source/settings/lib/query/admin/index.js
@@ -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`