From c250922f8dac8d6bf4590d29be685c560e127d94 Mon Sep 17 00:00:00 2001 From: Catherine Date: Tue, 2 Dec 2025 23:46:17 +0000 Subject: [PATCH] Allow domains to be administratively frozen. The following script may be used to handle abusive sites: cd $(mktemp -d) echo "

Gone

" >index.html echo "/* /index.html 410" >_redirects tar cf site.tar index.html _redirects git-pages -update-site $1 site.tar git-pages -freeze-domain $1 --- src/backend.go | 5 +++++ src/backend_fs.go | 39 ++++++++++++++++++++++++++++++++++++++- src/backend_s3.go | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.go | 44 ++++++++++++++++++++++++++++++++++++++++++-- src/manifest.go | 6 +++++- src/observe.go | 11 +++++++++-- src/pages.go | 2 ++ 7 files changed, 147 insertions(+), 6 deletions(-) diff --git a/src/backend.go b/src/backend.go index 952f8c8..c838d38 100644 --- a/src/backend.go +++ b/src/backend.go @@ -11,6 +11,7 @@ import ( ) var ErrObjectNotFound = errors.New("not found") +var ErrDomainFrozen = errors.New("domain administratively frozen") func splitBlobName(name string) []string { algo, hash, found := strings.Cut(name, "-") @@ -76,6 +77,10 @@ type Backend interface { // Creates a domain. This allows us to start serving content for the domain. CreateDomain(ctx context.Context, domain string) error + + // Freeze or thaw a domain. This allows a site to be administratively locked, e.g. if it + // is discovered serving abusive content. + FreezeDomain(ctx context.Context, domain string, freeze bool) error } func CreateBackend(config *StorageConfig) (backend Backend, err error) { diff --git a/src/backend_fs.go b/src/backend_fs.go index 20e82e0..6efbfab 100644 --- a/src/backend_fs.go +++ b/src/backend_fs.go @@ -208,7 +208,26 @@ func (fs *FSBackend) StageManifest(ctx context.Context, manifest *Manifest) erro return nil } +func domainFrozenMarkerName(domain string) string { + return filepath.Join(domain, ".frozen") +} + +func (fs *FSBackend) checkDomainFrozen(_ctx context.Context, domain string) error { + if _, err := fs.siteRoot.Stat(domainFrozenMarkerName(domain)); err == nil { + return ErrDomainFrozen + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("stat: %w", err) + } else { + return nil + } +} + func (fs *FSBackend) CommitManifest(ctx context.Context, name string, manifest *Manifest) error { + domain := filepath.Dir(name) + if err := fs.checkDomainFrozen(ctx, domain); err != nil { + return err + } + manifestData := EncodeManifest(manifest) manifestHashName := stagedManifestName(manifestData) @@ -216,7 +235,7 @@ func (fs *FSBackend) CommitManifest(ctx context.Context, name string, manifest * return fmt.Errorf("manifest not staged") } - if err := fs.siteRoot.MkdirAll(filepath.Dir(name), 0o755); err != nil { + if err := fs.siteRoot.MkdirAll(domain, 0o755); err != nil { return fmt.Errorf("mkdir: %w", err) } @@ -228,6 +247,11 @@ func (fs *FSBackend) CommitManifest(ctx context.Context, name string, manifest * } func (fs *FSBackend) DeleteManifest(ctx context.Context, name string) error { + domain := filepath.Dir(name) + if err := fs.checkDomainFrozen(ctx, domain); err != nil { + return err + } + err := fs.siteRoot.Remove(name) if errors.Is(err, os.ErrNotExist) { return nil @@ -250,3 +274,16 @@ func (fs *FSBackend) CheckDomain(ctx context.Context, domain string) (bool, erro func (fs *FSBackend) CreateDomain(ctx context.Context, domain string) error { return nil // no-op } + +func (fs *FSBackend) FreezeDomain(ctx context.Context, domain string, freeze bool) error { + if freeze { + return fs.siteRoot.WriteFile(domainFrozenMarkerName(domain), []byte{}, 0o644) + } else { + err := fs.siteRoot.Remove(domainFrozenMarkerName(domain)) + if errors.Is(err, os.ErrNotExist) { + return nil + } else { + return err + } + } +} diff --git a/src/backend_s3.go b/src/backend_s3.go index b1b83cc..26c830c 100644 --- a/src/backend_s3.go +++ b/src/backend_s3.go @@ -499,10 +499,31 @@ func (s3 *S3Backend) StageManifest(ctx context.Context, manifest *Manifest) erro return err } +func domainFrozenObjectName(domain string) string { + return manifestObjectName(fmt.Sprintf("%s/.frozen", domain)) +} + +func (s3 *S3Backend) checkDomainFrozen(ctx context.Context, domain string) error { + _, err := s3.client.GetObject(ctx, s3.bucket, domainFrozenObjectName(domain), + minio.GetObjectOptions{}) + if err == nil { + return ErrDomainFrozen + } else if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" { + return nil + } else { + return err + } +} + func (s3 *S3Backend) CommitManifest(ctx context.Context, name string, manifest *Manifest) error { data := EncodeManifest(manifest) logc.Printf(ctx, "s3: commit manifest %x -> %s", sha256.Sum256(data), name) + _, domain, _ := strings.Cut(name, "/") + if err := s3.checkDomainFrozen(ctx, domain); err != nil { + return err + } + // Remove staged object unconditionally (whether commit succeeded or failed), since // the upper layer has to retry the complete operation anyway. _, putErr := s3.client.PutObject(ctx, s3.bucket, manifestObjectName(name), @@ -522,6 +543,11 @@ func (s3 *S3Backend) CommitManifest(ctx context.Context, name string, manifest * func (s3 *S3Backend) DeleteManifest(ctx context.Context, name string) error { logc.Printf(ctx, "s3: delete manifest %s\n", name) + _, domain, _ := strings.Cut(name, "/") + if err := s3.checkDomainFrozen(ctx, domain); err != nil { + return err + } + err := s3.client.RemoveObject(ctx, s3.bucket, manifestObjectName(name), minio.RemoveObjectOptions{}) s3.siteCache.Cache.Invalidate(name) @@ -570,3 +596,23 @@ func (s3 *S3Backend) CreateDomain(ctx context.Context, domain string) error { &bytes.Reader{}, 0, minio.PutObjectOptions{}) return err } + +func (s3 *S3Backend) FreezeDomain(ctx context.Context, domain string, freeze bool) error { + if freeze { + logc.Printf(ctx, "s3: freeze domain %s\n", domain) + + _, err := s3.client.PutObject(ctx, s3.bucket, domainFrozenObjectName(domain), + &bytes.Reader{}, 0, minio.PutObjectOptions{}) + return err + } else { + logc.Printf(ctx, "s3: thaw domain %s\n", domain) + + err := s3.client.RemoveObject(ctx, s3.bucket, domainFrozenObjectName(domain), + minio.RemoveObjectOptions{}) + if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" { + return nil + } else { + return err + } + } +} diff --git a/src/main.go b/src/main.go index 22ec43e..843cf1f 100644 --- a/src/main.go +++ b/src/main.go @@ -141,7 +141,7 @@ func usage() { fmt.Fprintf(os.Stderr, "(server) "+ "git-pages [-config |-no-config]\n") fmt.Fprintf(os.Stderr, "(admin) "+ - "git-pages {-run-migration }\n") + "git-pages {-run-migration |-freeze-domain |-unfreeze-domain }\n") fmt.Fprintf(os.Stderr, "(info) "+ "git-pages {-print-config-env-vars|-print-config}\n") fmt.Fprintf(os.Stderr, "(cli) "+ @@ -169,9 +169,16 @@ func Main() { "write archive for `site` (either 'domain.tld' or 'domain.tld/dir') in tar format") updateSite := flag.String("update-site", "", "update `site` (either 'domain.tld' or 'domain.tld/dir') from archive or repository URL") + freezeDomain := flag.String("freeze-domain", "", + "prevent any site uploads to a given `domain`") + unfreezeDomain := flag.String("unfreeze-domain", "", + "allow site uploads to a `domain` again after it has been frozen") flag.Parse() var cliOperations int + if *runMigration != "" { + cliOperations += 1 + } if *getBlob != "" { cliOperations += 1 } @@ -181,8 +188,17 @@ func Main() { if *getArchive != "" { cliOperations += 1 } + if *updateSite != "" { + cliOperations += 1 + } + if *freezeDomain != "" { + cliOperations += 1 + } + if *unfreezeDomain != "" { + cliOperations += 1 + } if cliOperations > 1 { - log.Fatalln("-get-blob, -get-manifest, and -get-archive are mutually exclusive") + log.Fatalln("-get-blob, -get-manifest, -get-archive, -update-site, -freeze, and -unfreeze are mutually exclusive") } if *configTomlPath != "" && *noConfig { @@ -329,6 +345,30 @@ func Main() { log.Println("no-change") } + case *freezeDomain != "" || *unfreezeDomain != "": + var domain string + var freeze bool + if *freezeDomain != "" { + domain = *freezeDomain + freeze = true + } else { + domain = *unfreezeDomain + freeze = false + } + + if backend, err = CreateBackend(&config.Storage); err != nil { + log.Fatalln(err) + } + + if err = backend.FreezeDomain(context.Background(), domain, freeze); err != nil { + log.Fatalln(err) + } + if freeze { + log.Println("frozen") + } else { + log.Println("thawed") + } + default: // Hook a signal (SIGHUP on *nix, nothing on Windows) for reloading the configuration // at runtime. This is useful because it preserves S3 backend cache contents. Failed diff --git a/src/manifest.go b/src/manifest.go index f603bca..3aae654 100644 --- a/src/manifest.go +++ b/src/manifest.go @@ -311,7 +311,11 @@ func StoreManifest(ctx context.Context, name string, manifest *Manifest) (*Manif } if err := backend.CommitManifest(ctx, name, &extManifest); err != nil { - return nil, fmt.Errorf("commit manifest: %w", err) + if errors.Is(err, ErrDomainFrozen) { + return nil, err + } else { + return nil, fmt.Errorf("commit manifest: %w", err) + } } return &extManifest, nil diff --git a/src/observe.go b/src/observe.go index 5743996..ed048b1 100644 --- a/src/observe.go +++ b/src/observe.go @@ -417,15 +417,22 @@ func (backend *observedBackend) DeleteManifest(ctx context.Context, name string) } func (backend *observedBackend) CheckDomain(ctx context.Context, domain string) (found bool, err error) { - span, ctx := ObserveFunction(ctx, "CheckDomain", "manifest.domain", domain) + span, ctx := ObserveFunction(ctx, "CheckDomain", "domain.name", domain) found, err = backend.inner.CheckDomain(ctx, domain) span.Finish() return } func (backend *observedBackend) CreateDomain(ctx context.Context, domain string) (err error) { - span, ctx := ObserveFunction(ctx, "CreateDomain", "manifest.domain", domain) + span, ctx := ObserveFunction(ctx, "CreateDomain", "domain.name", domain) err = backend.inner.CreateDomain(ctx, domain) span.Finish() return } + +func (backend *observedBackend) FreezeDomain(ctx context.Context, domain string, freeze bool) (err error) { + span, ctx := ObserveFunction(ctx, "FreezeDomain", "domain.name", domain, "domain.frozen", freeze) + err = backend.inner.FreezeDomain(ctx, domain, freeze) + span.Finish() + return +} diff --git a/src/pages.go b/src/pages.go index b5db1a0..6fa5b7e 100644 --- a/src/pages.go +++ b/src/pages.go @@ -489,6 +489,8 @@ func putPage(w http.ResponseWriter, r *http.Request) error { w.WriteHeader(http.StatusUnsupportedMediaType) } else if errors.Is(result.err, ErrArchiveTooLarge) { w.WriteHeader(http.StatusRequestEntityTooLarge) + } else if errors.Is(result.err, ErrDomainFrozen) { + w.WriteHeader(http.StatusForbidden) } else { w.WriteHeader(http.StatusServiceUnavailable) }