[feature] Public list of suspended domains (#1362)

* basic rendered domain blocklist (unauthenticated!)

* style basic domain block list

* better formatting for domain blocklist

* add opt-in config option for showing suspended domains

* format/linter

* re-use InstancePeersGet for web-accessible domain blocklist

* reword explanation, border styling

* always attach blocklist handler, update error message

* domain blocklist error message grammar
This commit is contained in:
f0x52 2023-01-25 18:06:41 +01:00 committed by GitHub
parent 993aae5e48
commit 17eecfb6d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 265 additions and 66 deletions

View file

@ -292,6 +292,12 @@ instance-expose-peers: false
# Default: false
instance-expose-suspended: false
# Bool. Allow unauthenticated users to view /about/suspended,
# showing the HTML rendered list of instances that this instance blocks/suspends.
# Options: [true, false]
# Default: false
instance-expose-suspended-web: false
# Bool. Allow unauthenticated users to make queries to /api/v1/timelines/public in order
# to see a list of public posts on this server. Even if set to 'false', then authenticated
# users (members of the instance) will still be able to query the endpoint.

View file

@ -24,6 +24,7 @@ import (
"strings"
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"
@ -105,6 +106,8 @@ func (m *Module) InstancePeersGETHandler(c *gin.Context) {
return
}
var isUnauthenticated = authed.Account == nil || authed.User == nil
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
return
@ -136,7 +139,19 @@ func (m *Module) InstancePeersGETHandler(c *gin.Context) {
flat = true
}
data, errWithCode := m.processor.InstancePeersGet(c.Request.Context(), authed, includeSuspended, includeOpen, flat)
if includeOpen && !config.GetInstanceExposePeers() && isUnauthenticated {
err := fmt.Errorf("peers open query requires an authenticated account/user")
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
return
}
if includeSuspended && !config.GetInstanceExposeSuspended() && isUnauthenticated {
err := fmt.Errorf("peers suspended query requires an authenticated account/user")
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
return
}
data, errWithCode := m.processor.InstancePeersGet(c.Request.Context(), includeSuspended, includeOpen, flat)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
return

View file

@ -76,6 +76,7 @@ type Configuration struct {
InstanceExposePeers bool `name:"instance-expose-peers" usage:"Allow unauthenticated users to query /api/v1/instance/peers?filter=open"`
InstanceExposeSuspended bool `name:"instance-expose-suspended" usage:"Expose suspended instances via web UI, and allow unauthenticated users to query /api/v1/instance/peers?filter=suspended"`
InstanceExposeSuspendedWeb bool `name:"instance-expose-suspended-web" usage:"Expose list of suspended instances as webpage on /about/suspended"`
InstanceExposePublicTimeline bool `name:"instance-expose-public-timeline" usage:"Allow unauthenticated users to query /api/v1/timelines/public"`
InstanceDeliverToSharedInboxes bool `name:"instance-deliver-to-shared-inboxes" usage:"Deliver federated messages to shared inboxes, if they're available."`

View file

@ -58,6 +58,7 @@ var Defaults = Configuration{
InstanceExposePeers: false,
InstanceExposeSuspended: false,
InstanceExposeSuspendedWeb: false,
InstanceDeliverToSharedInboxes: true,
AccountsRegistrationOpen: true,

View file

@ -78,6 +78,7 @@ func (s *ConfigState) AddServerFlags(cmd *cobra.Command) {
// Instance
cmd.Flags().Bool(InstanceExposePeersFlag(), cfg.InstanceExposePeers, fieldtag("InstanceExposePeers", "usage"))
cmd.Flags().Bool(InstanceExposeSuspendedFlag(), cfg.InstanceExposeSuspended, fieldtag("InstanceExposeSuspended", "usage"))
cmd.Flags().Bool(InstanceExposeSuspendedWebFlag(), cfg.InstanceExposeSuspendedWeb, fieldtag("InstanceExposeSuspendedWeb", "usage"))
cmd.Flags().Bool(InstanceDeliverToSharedInboxesFlag(), cfg.InstanceDeliverToSharedInboxes, fieldtag("InstanceDeliverToSharedInboxes", "usage"))
// Accounts

View file

@ -724,6 +724,31 @@ func GetInstanceExposeSuspended() bool { return global.GetInstanceExposeSuspende
// SetInstanceExposeSuspended safely sets the value for global configuration 'InstanceExposeSuspended' field
func SetInstanceExposeSuspended(v bool) { global.SetInstanceExposeSuspended(v) }
// GetInstanceExposeSuspendedWeb safely fetches the Configuration value for state's 'InstanceExposeSuspendedWeb' field
func (st *ConfigState) GetInstanceExposeSuspendedWeb() (v bool) {
st.mutex.Lock()
v = st.config.InstanceExposeSuspendedWeb
st.mutex.Unlock()
return
}
// SetInstanceExposeSuspendedWeb safely sets the Configuration value for state's 'InstanceExposeSuspendedWeb' field
func (st *ConfigState) SetInstanceExposeSuspendedWeb(v bool) {
st.mutex.Lock()
defer st.mutex.Unlock()
st.config.InstanceExposeSuspendedWeb = v
st.reloadToViper()
}
// InstanceExposeSuspendedWebFlag returns the flag name for the 'InstanceExposeSuspendedWeb' field
func InstanceExposeSuspendedWebFlag() string { return "instance-expose-suspended-web" }
// GetInstanceExposeSuspendedWeb safely fetches the value for global configuration 'InstanceExposeSuspendedWeb' field
func GetInstanceExposeSuspendedWeb() bool { return global.GetInstanceExposeSuspendedWeb() }
// SetInstanceExposeSuspendedWeb safely sets the value for global configuration 'InstanceExposeSuspendedWeb' field
func SetInstanceExposeSuspendedWeb(v bool) { global.SetInstanceExposeSuspendedWeb(v) }
// GetInstanceExposePublicTimeline safely fetches the Configuration value for state's 'InstanceExposePublicTimeline' field
func (st *ConfigState) GetInstanceExposePublicTimeline() (v bool) {
st.mutex.Lock()

View file

@ -28,7 +28,6 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/validate"
@ -48,15 +47,10 @@ func (p *processor) InstanceGet(ctx context.Context, domain string) (*apimodel.I
return ai, nil
}
func (p *processor) InstancePeersGet(ctx context.Context, authed *oauth.Auth, includeSuspended bool, includeOpen bool, flat bool) (interface{}, gtserror.WithCode) {
func (p *processor) InstancePeersGet(ctx context.Context, includeSuspended bool, includeOpen bool, flat bool) (interface{}, gtserror.WithCode) {
domains := []*apimodel.Domain{}
if includeOpen {
if !config.GetInstanceExposePeers() && (authed.Account == nil || authed.User == nil) {
err := fmt.Errorf("peers open query requires an authenticated account/user")
return nil, gtserror.NewErrorUnauthorized(err, err.Error())
}
instances, err := p.db.GetInstancePeers(ctx, false)
if err != nil && err != db.ErrNoEntries {
err = fmt.Errorf("error selecting instance peers: %s", err)
@ -70,11 +64,6 @@ func (p *processor) InstancePeersGet(ctx context.Context, authed *oauth.Auth, in
}
if includeSuspended {
if !config.GetInstanceExposeSuspended() && (authed.Account == nil || authed.User == nil) {
err := fmt.Errorf("peers suspended query requires an authenticated account/user")
return nil, gtserror.NewErrorUnauthorized(err, err.Error())
}
domainBlocks := []*gtsmodel.DomainBlock{}
if err := p.db.GetAll(ctx, &domainBlocks); err != nil && err != db.ErrNoEntries {
return nil, gtserror.NewErrorInternalError(err)

View file

@ -172,7 +172,7 @@ type Processor interface {
// InstanceGet retrieves instance information for serving at api/v1/instance
InstanceGet(ctx context.Context, domain string) (*apimodel.Instance, gtserror.WithCode)
InstancePeersGet(ctx context.Context, authed *oauth.Auth, includeSuspended bool, includeOpen bool, flat bool) (interface{}, gtserror.WithCode)
InstancePeersGet(ctx context.Context, includeSuspended bool, includeOpen bool, flat bool) (interface{}, gtserror.WithCode)
// InstancePatch updates this instance according to the given form.
//
// It should already be ascertained that the requesting account is authenticated and an admin.

View file

@ -0,0 +1,71 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package web
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
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"
)
const (
domainBlockListPath = "/about/suspended"
)
func (m *Module) domainBlockListGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, false, false, false, false)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
return
}
if !config.GetInstanceExposeSuspendedWeb() && (authed.Account == nil || authed.User == nil) {
err := fmt.Errorf("this instance does not expose the list of suspended domains publicly")
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
return
}
host := config.GetHost()
instance, err := m.processor.InstanceGet(c.Request.Context(), host)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet)
return
}
domainBlocks, errWithCode := m.processor.InstancePeersGet(c.Request.Context(), true, false, false)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
return
}
c.HTML(http.StatusOK, "domain-blocklist.tmpl", gin.H{
"instance": instance,
"ogMeta": ogBase(instance),
"blocklist": domainBlocks,
"stylesheets": []string{
assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css",
},
"javascript": []string{distPathPrefix + "/frontend.js"},
})
}

View file

@ -49,7 +49,9 @@ Disallow: /emoji/
# panels
Disallow: /admin
Disallow: /user
Disallow: /settings/`
Disallow: /settings/
# domain blocklist
Disallow: /about/suspended`
)
// robotsGETHandler returns a decent robots.txt that prevents crawling

View file

@ -100,6 +100,8 @@ func (m *Module) Route(r router.Router, mi ...gin.HandlerFunc) {
r.AttachHandler(http.MethodGet, confirmEmailPath, m.confirmEmailGETHandler)
r.AttachHandler(http.MethodGet, robotsPath, m.robotsGETHandler)
r.AttachHandler(http.MethodGet, domainBlockListPath, m.domainBlockListGETHandler)
/*
Attach redirects from old endpoints to current ones for backwards compatibility
*/

View file

@ -2,7 +2,7 @@
set -eu
EXPECT='{"account-domain":"peepee","accounts-allow-custom-css":true,"accounts-approval-required":false,"accounts-reason-required":false,"accounts-registration-open":true,"advanced-cookies-samesite":"strict","advanced-rate-limit-requests":6969,"advanced-throttling-multiplier":-1,"application-name":"gts","bind-address":"127.0.0.1","cache":{"gts":{"account-max-size":99,"account-sweep-freq":1000000000,"account-ttl":10800000000000,"block-max-size":100,"block-sweep-freq":10000000000,"block-ttl":300000000000,"domain-block-max-size":1000,"domain-block-sweep-freq":60000000000,"domain-block-ttl":86400000000000,"emoji-category-max-size":100,"emoji-category-sweep-freq":10000000000,"emoji-category-ttl":300000000000,"emoji-max-size":500,"emoji-sweep-freq":10000000000,"emoji-ttl":300000000000,"mention-max-size":500,"mention-sweep-freq":10000000000,"mention-ttl":300000000000,"notification-max-size":500,"notification-sweep-freq":10000000000,"notification-ttl":300000000000,"report-max-size":100,"report-sweep-freq":10000000000,"report-ttl":300000000000,"status-max-size":500,"status-sweep-freq":10000000000,"status-ttl":300000000000,"tombstone-max-size":100,"tombstone-sweep-freq":10000000000,"tombstone-ttl":300000000000,"user-max-size":100,"user-sweep-freq":10000000000,"user-ttl":300000000000}},"config-path":"internal/config/testdata/test.yaml","db-address":":memory:","db-database":"gotosocial_prod","db-password":"hunter2","db-port":6969,"db-sqlite-busy-timeout":1000000000,"db-sqlite-cache-size":0,"db-sqlite-journal-mode":"DELETE","db-sqlite-synchronous":"FULL","db-tls-ca-cert":"","db-tls-mode":"disable","db-type":"sqlite","db-user":"sex-haver","dry-run":false,"email":"","host":"example.com","instance-deliver-to-shared-inboxes":false,"instance-expose-peers":true,"instance-expose-public-timeline":true,"instance-expose-suspended":true,"landing-page-user":"admin","letsencrypt-cert-dir":"/gotosocial/storage/certs","letsencrypt-email-address":"","letsencrypt-enabled":true,"letsencrypt-port":80,"log-db-queries":true,"log-level":"info","media-description-max-chars":5000,"media-description-min-chars":69,"media-emoji-local-max-size":420,"media-emoji-remote-max-size":420,"media-image-max-size":420,"media-remote-cache-days":30,"media-video-max-size":420,"oidc-client-id":"1234","oidc-client-secret":"shhhh its a secret","oidc-enabled":true,"oidc-idp-name":"sex-haver","oidc-issuer":"whoknows","oidc-link-existing":true,"oidc-scopes":["read","write"],"oidc-skip-verification":true,"password":"","path":"","port":6969,"protocol":"http","smtp-from":"queen.rip.in.piss@terfisland.org","smtp-host":"example.com","smtp-password":"hunter2","smtp-port":4269,"smtp-username":"sex-haver","software-version":"","statuses-cw-max-chars":420,"statuses-max-chars":69,"statuses-media-max-files":1,"statuses-poll-max-options":1,"statuses-poll-option-max-chars":50,"storage-backend":"local","storage-local-base-path":"/root/store","storage-s3-access-key":"minio","storage-s3-bucket":"gts","storage-s3-endpoint":"localhost:9000","storage-s3-proxy":true,"storage-s3-secret-key":"miniostorage","storage-s3-use-ssl":false,"syslog-address":"127.0.0.1:6969","syslog-enabled":true,"syslog-protocol":"udp","trusted-proxies":["127.0.0.1/32","docker.host.local"],"username":"","web-asset-base-dir":"/root","web-template-base-dir":"/root"}'
EXPECT='{"account-domain":"peepee","accounts-allow-custom-css":true,"accounts-approval-required":false,"accounts-reason-required":false,"accounts-registration-open":true,"advanced-cookies-samesite":"strict","advanced-rate-limit-requests":6969,"advanced-throttling-multiplier":-1,"application-name":"gts","bind-address":"127.0.0.1","cache":{"gts":{"account-max-size":99,"account-sweep-freq":1000000000,"account-ttl":10800000000000,"block-max-size":100,"block-sweep-freq":10000000000,"block-ttl":300000000000,"domain-block-max-size":1000,"domain-block-sweep-freq":60000000000,"domain-block-ttl":86400000000000,"emoji-category-max-size":100,"emoji-category-sweep-freq":10000000000,"emoji-category-ttl":300000000000,"emoji-max-size":500,"emoji-sweep-freq":10000000000,"emoji-ttl":300000000000,"mention-max-size":500,"mention-sweep-freq":10000000000,"mention-ttl":300000000000,"notification-max-size":500,"notification-sweep-freq":10000000000,"notification-ttl":300000000000,"report-max-size":100,"report-sweep-freq":10000000000,"report-ttl":300000000000,"status-max-size":500,"status-sweep-freq":10000000000,"status-ttl":300000000000,"tombstone-max-size":100,"tombstone-sweep-freq":10000000000,"tombstone-ttl":300000000000,"user-max-size":100,"user-sweep-freq":10000000000,"user-ttl":300000000000}},"config-path":"internal/config/testdata/test.yaml","db-address":":memory:","db-database":"gotosocial_prod","db-password":"hunter2","db-port":6969,"db-sqlite-busy-timeout":1000000000,"db-sqlite-cache-size":0,"db-sqlite-journal-mode":"DELETE","db-sqlite-synchronous":"FULL","db-tls-ca-cert":"","db-tls-mode":"disable","db-type":"sqlite","db-user":"sex-haver","dry-run":false,"email":"","host":"example.com","instance-deliver-to-shared-inboxes":false,"instance-expose-peers":true,"instance-expose-public-timeline":true,"instance-expose-suspended":true,"instance-expose-suspended-web":true,"landing-page-user":"admin","letsencrypt-cert-dir":"/gotosocial/storage/certs","letsencrypt-email-address":"","letsencrypt-enabled":true,"letsencrypt-port":80,"log-db-queries":true,"log-level":"info","media-description-max-chars":5000,"media-description-min-chars":69,"media-emoji-local-max-size":420,"media-emoji-remote-max-size":420,"media-image-max-size":420,"media-remote-cache-days":30,"media-video-max-size":420,"oidc-client-id":"1234","oidc-client-secret":"shhhh its a secret","oidc-enabled":true,"oidc-idp-name":"sex-haver","oidc-issuer":"whoknows","oidc-link-existing":true,"oidc-scopes":["read","write"],"oidc-skip-verification":true,"password":"","path":"","port":6969,"protocol":"http","smtp-from":"queen.rip.in.piss@terfisland.org","smtp-host":"example.com","smtp-password":"hunter2","smtp-port":4269,"smtp-username":"sex-haver","software-version":"","statuses-cw-max-chars":420,"statuses-max-chars":69,"statuses-media-max-files":1,"statuses-poll-max-options":1,"statuses-poll-option-max-chars":50,"storage-backend":"local","storage-local-base-path":"/root/store","storage-s3-access-key":"minio","storage-s3-bucket":"gts","storage-s3-endpoint":"localhost:9000","storage-s3-proxy":true,"storage-s3-secret-key":"miniostorage","storage-s3-use-ssl":false,"syslog-address":"127.0.0.1:6969","syslog-enabled":true,"syslog-protocol":"udp","trusted-proxies":["127.0.0.1/32","docker.host.local"],"username":"","web-asset-base-dir":"/root","web-template-base-dir":"/root"}'
# Set all the environment variables to
# ensure that these are parsed without panic
@ -32,6 +32,7 @@ GTS_WEB_TEMPLATE_BASE_DIR='/root' \
GTS_WEB_ASSET_BASE_DIR='/root' \
GTS_INSTANCE_EXPOSE_PEERS=true \
GTS_INSTANCE_EXPOSE_SUSPENDED=true \
GTS_INSTANCE_EXPOSE_SUSPENDED_WEB=true \
GTS_INSTANCE_EXPOSE_PUBLIC_TIMELINE=true \
GTS_INSTANCE_DELIVER_TO_SHARED_INBOXES=false \
GTS_ACCOUNTS_ALLOW_CUSTOM_CSS=true \

View file

@ -62,6 +62,7 @@ var testDefaults = config.Configuration{
InstanceExposePeers: true,
InstanceExposeSuspended: true,
InstanceExposeSuspendedWeb: true,
InstanceDeliverToSharedInboxes: true,
AccountsRegistrationOpen: true,

View file

@ -114,6 +114,6 @@ $settings-nav-bg-active: $gray2;
$error-fg: $error1;
$error-bg: $error2;
$settings-entry-bg: $gray2;
$settings-entry-alternate-bg: $gray3;
$settings-entry-hover-bg: $gray4;
$list-entry-bg: $gray2;
$list-entry-alternate-bg: $gray3;
$list-entry-hover-bg: $gray4;

View file

@ -414,3 +414,79 @@ label {
overflow: hidden;
white-space: nowrap;
}
.list {
display: flex;
flex-direction: column;
&.scrolling {
max-height: 40rem;
overflow: auto;
}
.header, .entry {
padding: 0.5rem;
}
.header {
border: 0.1rem solid transparent !important; /* for alignment with .entry border padding */
background: $gray1 !important;
display: flex;
font-weight: bold;
}
input[type=checkbox] {
margin-left: 0.5rem;
}
.entry {
display: flex;
flex-wrap: wrap;
background: $list-entry-bg;
border: 0.1rem solid transparent;
&:nth-child(even) {
background: $list-entry-alternate-bg;
}
&:hover {
background: $list-entry-hover-bg;
}
&:active, &:focus, &:hover, &:target {
border-color: $fg-accent;
}
}
}
.domain-blocklist {
box-shadow: $boxshadow;
.entry {
display: grid;
grid-template-columns: 15rem 1fr;
gap: 0.5rem;
align-items: start;
border: $boxshadow-border;
border-top-color: transparent;
& > div {
display: flex;
align-items: center
}
.domain a {
font-weight: bold;
text-decoration: none;
display: inline-block; /* so it wraps properly */
}
.public_comment p {
margin: 0;
}
}
.header .domain {
color: $fg;
}
}

View file

@ -368,49 +368,6 @@ span.form-info {
font-weight: initial;
}
.list {
display: flex;
flex-direction: column;
&.scrolling {
max-height: 40rem;
overflow: auto;
}
.header, .entry {
padding: 0.5rem;
}
.header {
border: 0.1rem solid transparent; /* for alignment with .entry border padding */
background: $gray2;
display: flex;
}
input[type=checkbox] {
margin-left: 0.5rem;
}
.entry {
display: flex;
flex-wrap: wrap;
background: $settings-entry-bg;
border: 0.1rem solid transparent;
&:nth-child(even) {
background: $settings-entry-alternate-bg;
}
&:hover {
background: $settings-entry-hover-bg;
}
&:active, &:focus, &:hover {
border-color: $fg-accent;
}
}
}
.checkbox-list {
.header, .entry {
gap: 1rem;
@ -446,7 +403,7 @@ span.form-info {
}
.emoji-list {
background: $settings-entry-bg;
background: $list-entry-bg;
.entry {
flex-direction: column;
@ -472,7 +429,7 @@ span.form-info {
}
&:hover {
background: $settings-entry-hover-bg;
background: $list-entry-hover-bg;
}
}
}

View file

@ -0,0 +1,51 @@
{{- /*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}}
{{ template "header.tmpl" .}}
<main>
<section>
<h1>Suspended Instances</h1>
<p>
The following list of domains have been suspended by the administrator(s) of this server.
</p>
<p>
All current and future accounts on these instances are blocked, and no more data is federated to the remote
servers.
This extends to subdomains, so an entry for 'example.com' includes 'social.example.com' as well.
</p>
<div class="list domain-blocklist">
<div class="header entry">
<div class="domain">Domain</div>
<div class="public_comment">Public comment</div>
</div>
{{range .blocklist}}
<div class="entry" id="{{.Domain}}">
<div class="domain">
<a class="text-cutoff" href="#{{.Domain}}" title="{{.Domain}}">{{.Domain}}</a>
</div>
<div class="public_comment">
<p>
{{.PublicComment}}
</p>
</div>
</div>
{{end}}
</div>
</section>
</main>
{{ template "footer.tmpl" .}}