fork of whitequark.org/git-pages with mods for tangled

Allow domains to be administratively frozen.

The following script may be used to handle abusive sites:

cd $(mktemp -d)
echo "<h1>Gone</h1>" >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

+5
src/backend.go
··· 11 ) 12 13 var ErrObjectNotFound = errors.New("not found") 14 15 func splitBlobName(name string) []string { 16 algo, hash, found := strings.Cut(name, "-") ··· 76 77 // Creates a domain. This allows us to start serving content for the domain. 78 CreateDomain(ctx context.Context, domain string) error 79 } 80 81 func CreateBackend(config *StorageConfig) (backend Backend, err error) {
··· 11 ) 12 13 var ErrObjectNotFound = errors.New("not found") 14 + var ErrDomainFrozen = errors.New("domain administratively frozen") 15 16 func splitBlobName(name string) []string { 17 algo, hash, found := strings.Cut(name, "-") ··· 77 78 // Creates a domain. This allows us to start serving content for the domain. 79 CreateDomain(ctx context.Context, domain string) error 80 + 81 + // Freeze or thaw a domain. This allows a site to be administratively locked, e.g. if it 82 + // is discovered serving abusive content. 83 + FreezeDomain(ctx context.Context, domain string, freeze bool) error 84 } 85 86 func CreateBackend(config *StorageConfig) (backend Backend, err error) {
+38 -1
src/backend_fs.go
··· 208 return nil 209 } 210 211 func (fs *FSBackend) CommitManifest(ctx context.Context, name string, manifest *Manifest) error { 212 manifestData := EncodeManifest(manifest) 213 manifestHashName := stagedManifestName(manifestData) 214 ··· 216 return fmt.Errorf("manifest not staged") 217 } 218 219 - if err := fs.siteRoot.MkdirAll(filepath.Dir(name), 0o755); err != nil { 220 return fmt.Errorf("mkdir: %w", err) 221 } 222 ··· 228 } 229 230 func (fs *FSBackend) DeleteManifest(ctx context.Context, name string) error { 231 err := fs.siteRoot.Remove(name) 232 if errors.Is(err, os.ErrNotExist) { 233 return nil ··· 250 func (fs *FSBackend) CreateDomain(ctx context.Context, domain string) error { 251 return nil // no-op 252 }
··· 208 return nil 209 } 210 211 + func domainFrozenMarkerName(domain string) string { 212 + return filepath.Join(domain, ".frozen") 213 + } 214 + 215 + func (fs *FSBackend) checkDomainFrozen(_ctx context.Context, domain string) error { 216 + if _, err := fs.siteRoot.Stat(domainFrozenMarkerName(domain)); err == nil { 217 + return ErrDomainFrozen 218 + } else if !errors.Is(err, os.ErrNotExist) { 219 + return fmt.Errorf("stat: %w", err) 220 + } else { 221 + return nil 222 + } 223 + } 224 + 225 func (fs *FSBackend) CommitManifest(ctx context.Context, name string, manifest *Manifest) error { 226 + domain := filepath.Dir(name) 227 + if err := fs.checkDomainFrozen(ctx, domain); err != nil { 228 + return err 229 + } 230 + 231 manifestData := EncodeManifest(manifest) 232 manifestHashName := stagedManifestName(manifestData) 233 ··· 235 return fmt.Errorf("manifest not staged") 236 } 237 238 + if err := fs.siteRoot.MkdirAll(domain, 0o755); err != nil { 239 return fmt.Errorf("mkdir: %w", err) 240 } 241 ··· 247 } 248 249 func (fs *FSBackend) DeleteManifest(ctx context.Context, name string) error { 250 + domain := filepath.Dir(name) 251 + if err := fs.checkDomainFrozen(ctx, domain); err != nil { 252 + return err 253 + } 254 + 255 err := fs.siteRoot.Remove(name) 256 if errors.Is(err, os.ErrNotExist) { 257 return nil ··· 274 func (fs *FSBackend) CreateDomain(ctx context.Context, domain string) error { 275 return nil // no-op 276 } 277 + 278 + func (fs *FSBackend) FreezeDomain(ctx context.Context, domain string, freeze bool) error { 279 + if freeze { 280 + return fs.siteRoot.WriteFile(domainFrozenMarkerName(domain), []byte{}, 0o644) 281 + } else { 282 + err := fs.siteRoot.Remove(domainFrozenMarkerName(domain)) 283 + if errors.Is(err, os.ErrNotExist) { 284 + return nil 285 + } else { 286 + return err 287 + } 288 + } 289 + }
+46
src/backend_s3.go
··· 499 return err 500 } 501 502 func (s3 *S3Backend) CommitManifest(ctx context.Context, name string, manifest *Manifest) error { 503 data := EncodeManifest(manifest) 504 logc.Printf(ctx, "s3: commit manifest %x -> %s", sha256.Sum256(data), name) 505 506 // Remove staged object unconditionally (whether commit succeeded or failed), since 507 // the upper layer has to retry the complete operation anyway. ··· 522 func (s3 *S3Backend) DeleteManifest(ctx context.Context, name string) error { 523 logc.Printf(ctx, "s3: delete manifest %s\n", name) 524 525 err := s3.client.RemoveObject(ctx, s3.bucket, manifestObjectName(name), 526 minio.RemoveObjectOptions{}) 527 s3.siteCache.Cache.Invalidate(name) ··· 570 &bytes.Reader{}, 0, minio.PutObjectOptions{}) 571 return err 572 }
··· 499 return err 500 } 501 502 + func domainFrozenObjectName(domain string) string { 503 + return manifestObjectName(fmt.Sprintf("%s/.frozen", domain)) 504 + } 505 + 506 + func (s3 *S3Backend) checkDomainFrozen(ctx context.Context, domain string) error { 507 + _, err := s3.client.GetObject(ctx, s3.bucket, domainFrozenObjectName(domain), 508 + minio.GetObjectOptions{}) 509 + if err == nil { 510 + return ErrDomainFrozen 511 + } else if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" { 512 + return nil 513 + } else { 514 + return err 515 + } 516 + } 517 + 518 func (s3 *S3Backend) CommitManifest(ctx context.Context, name string, manifest *Manifest) error { 519 data := EncodeManifest(manifest) 520 logc.Printf(ctx, "s3: commit manifest %x -> %s", sha256.Sum256(data), name) 521 + 522 + _, domain, _ := strings.Cut(name, "/") 523 + if err := s3.checkDomainFrozen(ctx, domain); err != nil { 524 + return err 525 + } 526 527 // Remove staged object unconditionally (whether commit succeeded or failed), since 528 // the upper layer has to retry the complete operation anyway. ··· 543 func (s3 *S3Backend) DeleteManifest(ctx context.Context, name string) error { 544 logc.Printf(ctx, "s3: delete manifest %s\n", name) 545 546 + _, domain, _ := strings.Cut(name, "/") 547 + if err := s3.checkDomainFrozen(ctx, domain); err != nil { 548 + return err 549 + } 550 + 551 err := s3.client.RemoveObject(ctx, s3.bucket, manifestObjectName(name), 552 minio.RemoveObjectOptions{}) 553 s3.siteCache.Cache.Invalidate(name) ··· 596 &bytes.Reader{}, 0, minio.PutObjectOptions{}) 597 return err 598 } 599 + 600 + func (s3 *S3Backend) FreezeDomain(ctx context.Context, domain string, freeze bool) error { 601 + if freeze { 602 + logc.Printf(ctx, "s3: freeze domain %s\n", domain) 603 + 604 + _, err := s3.client.PutObject(ctx, s3.bucket, domainFrozenObjectName(domain), 605 + &bytes.Reader{}, 0, minio.PutObjectOptions{}) 606 + return err 607 + } else { 608 + logc.Printf(ctx, "s3: thaw domain %s\n", domain) 609 + 610 + err := s3.client.RemoveObject(ctx, s3.bucket, domainFrozenObjectName(domain), 611 + minio.RemoveObjectOptions{}) 612 + if errResp := minio.ToErrorResponse(err); errResp.Code == "NoSuchKey" { 613 + return nil 614 + } else { 615 + return err 616 + } 617 + } 618 + }
+42 -2
src/main.go
··· 141 fmt.Fprintf(os.Stderr, "(server) "+ 142 "git-pages [-config <file>|-no-config]\n") 143 fmt.Fprintf(os.Stderr, "(admin) "+ 144 - "git-pages {-run-migration <name>}\n") 145 fmt.Fprintf(os.Stderr, "(info) "+ 146 "git-pages {-print-config-env-vars|-print-config}\n") 147 fmt.Fprintf(os.Stderr, "(cli) "+ ··· 169 "write archive for `site` (either 'domain.tld' or 'domain.tld/dir') in tar format") 170 updateSite := flag.String("update-site", "", 171 "update `site` (either 'domain.tld' or 'domain.tld/dir') from archive or repository URL") 172 flag.Parse() 173 174 var cliOperations int 175 if *getBlob != "" { 176 cliOperations += 1 177 } ··· 181 if *getArchive != "" { 182 cliOperations += 1 183 } 184 if cliOperations > 1 { 185 - log.Fatalln("-get-blob, -get-manifest, and -get-archive are mutually exclusive") 186 } 187 188 if *configTomlPath != "" && *noConfig { ··· 327 log.Println("deleted") 328 case UpdateNoChange: 329 log.Println("no-change") 330 } 331 332 default:
··· 141 fmt.Fprintf(os.Stderr, "(server) "+ 142 "git-pages [-config <file>|-no-config]\n") 143 fmt.Fprintf(os.Stderr, "(admin) "+ 144 + "git-pages {-run-migration <name>|-freeze-domain <domain>|-unfreeze-domain <domain>}\n") 145 fmt.Fprintf(os.Stderr, "(info) "+ 146 "git-pages {-print-config-env-vars|-print-config}\n") 147 fmt.Fprintf(os.Stderr, "(cli) "+ ··· 169 "write archive for `site` (either 'domain.tld' or 'domain.tld/dir') in tar format") 170 updateSite := flag.String("update-site", "", 171 "update `site` (either 'domain.tld' or 'domain.tld/dir') from archive or repository URL") 172 + freezeDomain := flag.String("freeze-domain", "", 173 + "prevent any site uploads to a given `domain`") 174 + unfreezeDomain := flag.String("unfreeze-domain", "", 175 + "allow site uploads to a `domain` again after it has been frozen") 176 flag.Parse() 177 178 var cliOperations int 179 + if *runMigration != "" { 180 + cliOperations += 1 181 + } 182 if *getBlob != "" { 183 cliOperations += 1 184 } ··· 188 if *getArchive != "" { 189 cliOperations += 1 190 } 191 + if *updateSite != "" { 192 + cliOperations += 1 193 + } 194 + if *freezeDomain != "" { 195 + cliOperations += 1 196 + } 197 + if *unfreezeDomain != "" { 198 + cliOperations += 1 199 + } 200 if cliOperations > 1 { 201 + log.Fatalln("-get-blob, -get-manifest, -get-archive, -update-site, -freeze, and -unfreeze are mutually exclusive") 202 } 203 204 if *configTomlPath != "" && *noConfig { ··· 343 log.Println("deleted") 344 case UpdateNoChange: 345 log.Println("no-change") 346 + } 347 + 348 + case *freezeDomain != "" || *unfreezeDomain != "": 349 + var domain string 350 + var freeze bool 351 + if *freezeDomain != "" { 352 + domain = *freezeDomain 353 + freeze = true 354 + } else { 355 + domain = *unfreezeDomain 356 + freeze = false 357 + } 358 + 359 + if backend, err = CreateBackend(&config.Storage); err != nil { 360 + log.Fatalln(err) 361 + } 362 + 363 + if err = backend.FreezeDomain(context.Background(), domain, freeze); err != nil { 364 + log.Fatalln(err) 365 + } 366 + if freeze { 367 + log.Println("frozen") 368 + } else { 369 + log.Println("thawed") 370 } 371 372 default:
+5 -1
src/manifest.go
··· 311 } 312 313 if err := backend.CommitManifest(ctx, name, &extManifest); err != nil { 314 - return nil, fmt.Errorf("commit manifest: %w", err) 315 } 316 317 return &extManifest, nil
··· 311 } 312 313 if err := backend.CommitManifest(ctx, name, &extManifest); err != nil { 314 + if errors.Is(err, ErrDomainFrozen) { 315 + return nil, err 316 + } else { 317 + return nil, fmt.Errorf("commit manifest: %w", err) 318 + } 319 } 320 321 return &extManifest, nil
+9 -2
src/observe.go
··· 417 } 418 419 func (backend *observedBackend) CheckDomain(ctx context.Context, domain string) (found bool, err error) { 420 - span, ctx := ObserveFunction(ctx, "CheckDomain", "manifest.domain", domain) 421 found, err = backend.inner.CheckDomain(ctx, domain) 422 span.Finish() 423 return 424 } 425 426 func (backend *observedBackend) CreateDomain(ctx context.Context, domain string) (err error) { 427 - span, ctx := ObserveFunction(ctx, "CreateDomain", "manifest.domain", domain) 428 err = backend.inner.CreateDomain(ctx, domain) 429 span.Finish() 430 return 431 }
··· 417 } 418 419 func (backend *observedBackend) CheckDomain(ctx context.Context, domain string) (found bool, err error) { 420 + span, ctx := ObserveFunction(ctx, "CheckDomain", "domain.name", domain) 421 found, err = backend.inner.CheckDomain(ctx, domain) 422 span.Finish() 423 return 424 } 425 426 func (backend *observedBackend) CreateDomain(ctx context.Context, domain string) (err error) { 427 + span, ctx := ObserveFunction(ctx, "CreateDomain", "domain.name", domain) 428 err = backend.inner.CreateDomain(ctx, domain) 429 span.Finish() 430 return 431 } 432 + 433 + func (backend *observedBackend) FreezeDomain(ctx context.Context, domain string, freeze bool) (err error) { 434 + span, ctx := ObserveFunction(ctx, "FreezeDomain", "domain.name", domain, "domain.frozen", freeze) 435 + err = backend.inner.FreezeDomain(ctx, domain, freeze) 436 + span.Finish() 437 + return 438 + }
+2
src/pages.go
··· 489 w.WriteHeader(http.StatusUnsupportedMediaType) 490 } else if errors.Is(result.err, ErrArchiveTooLarge) { 491 w.WriteHeader(http.StatusRequestEntityTooLarge) 492 } else { 493 w.WriteHeader(http.StatusServiceUnavailable) 494 }
··· 489 w.WriteHeader(http.StatusUnsupportedMediaType) 490 } else if errors.Is(result.err, ErrArchiveTooLarge) { 491 w.WriteHeader(http.StatusRequestEntityTooLarge) 492 + } else if errors.Is(result.err, ErrDomainFrozen) { 493 + w.WriteHeader(http.StatusForbidden) 494 } else { 495 w.WriteHeader(http.StatusServiceUnavailable) 496 }