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 @@ 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 @@ "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 @@ } ) +// 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 @@ // 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 @@ 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 @@ func NewAdminActionCategory(in string) AdminActionCategory { 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 ( +
+

Expire remote instance keys

+

+ Mark all public keys from the given remote instance as expired.

+ 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. +

+ + + + ); +}; 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

-
-

Media cleanup

-

+ +

Cleanup

+

Clean up remote media older than the specified number of days. If the remote instance is still online they will be refetched when needed. Also cleans up unused headers and avatars from the media cache. -

- - - - +

+ + + ); -}; \ 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`