A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
80
fork

Configure Feed

Select the types of activity you want to include in your feed.

use presigned urls for s3 to avoid hold bandwidth

+186 -60
+6
.env.hold.example
··· 16 16 17 17 # Storage driver type (s3, filesystem) 18 18 # Default: s3 19 + # 20 + # S3 Presigned URLs: 21 + # When using S3 storage, presigned URLs are automatically enabled for direct 22 + # client ↔ S3 transfers. This eliminates the hold service as a bandwidth 23 + # bottleneck, reducing hold bandwidth by ~99% for push/pull operations. 24 + # Falls back to proxy mode automatically for non-S3 drivers. 19 25 STORAGE_DRIVER=filesystem 20 26 21 27 # For S3/Storj/Minio:
+10 -31
Dockerfile.appview
··· 1 - # ========================================== 2 - # Stage 1: Build stage with Debian (glibc) 3 - # ========================================== 4 1 FROM golang:1.25.2-trixie AS builder 5 2 6 - # Install SQLite development libraries (for CGO compilation) 7 3 RUN apt-get update && \ 8 4 apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev && \ 9 5 rm -rf /var/lib/apt/lists/* 10 6 11 - # Set working directory 12 7 WORKDIR /build 13 8 14 - # Copy go mod files and download dependencies (cached layer) 15 9 COPY go.mod go.sum ./ 16 10 RUN go mod download 17 11 18 - # Copy source code 19 12 COPY . . 20 13 21 - # Build optimized binary: 22 - # - CGO_ENABLED=1: Required for SQLite (mattn/go-sqlite3) 23 - # - -ldflags="-s -w": Strip debug symbols (~30% size reduction) 24 - # - -tags sqlite_omit_load_extension: Remove SQLite extension loading (~100KB savings) 25 - # - -trimpath: Remove build paths (reproducible builds) 26 - # SQLite is statically embedded in the binary (no runtime .so needed) 27 14 RUN CGO_ENABLED=1 go build \ 28 15 -ldflags="-s -w" \ 29 16 -tags sqlite_omit_load_extension \ 30 17 -trimpath \ 31 18 -o atcr-appview ./cmd/appview 32 19 33 - # Collect minimal runtime dependencies based on ldd output 34 - RUN mkdir -p /runtime-deps/lib/x86_64-linux-gnu /runtime-deps/lib64 && \ 35 - # Core glibc library (only one the binary links to) 36 - cp -L /lib/x86_64-linux-gnu/libc.so.6 /runtime-deps/lib/x86_64-linux-gnu/ && \ 37 - # Dynamic linker 38 - cp -L /lib64/ld-linux-x86-64.so.2 /runtime-deps/lib64/ && \ 39 - # NSS modules for DNS resolution (loaded via dlopen at runtime, not shown in ldd) 40 - cp -L /lib/x86_64-linux-gnu/libnss_dns.so.2 /runtime-deps/lib/x86_64-linux-gnu/ && \ 41 - cp -L /lib/x86_64-linux-gnu/libnss_files.so.2 /runtime-deps/lib/x86_64-linux-gnu/ && \ 42 - # NSS modules depend on libresolv 43 - cp -L /lib/x86_64-linux-gnu/libresolv.so.2 /runtime-deps/lib/x86_64-linux-gnu/ && \ 20 + # Collect minimal runtime dependencies 21 + RUN mkdir -p /runtime-deps/lib64 /runtime-deps/lib/x86_64-linux-gnu && \ 22 + # Core glibc libraries (from ldd output) 23 + cp /lib/x86_64-linux-gnu/libc.so.6 /runtime-deps/lib64/ && \ 24 + cp /lib/x86_64-linux-gnu/libresolv.so.2 /runtime-deps/lib64/ && \ 25 + cp /lib64/ld-linux-x86-64.so.2 /runtime-deps/lib64/ && \ 26 + # NSS (Name Service Switch) modules for DNS resolution 27 + cp /lib/x86_64-linux-gnu/libnss_dns.so.2 /runtime-deps/lib/x86_64-linux-gnu/ && \ 28 + cp /lib/x86_64-linux-gnu/libnss_files.so.2 /runtime-deps/lib/x86_64-linux-gnu/ && \ 44 29 # Create NSS config (tells glibc to check /etc/hosts then DNS) 45 30 echo "hosts: files dns" > /tmp/nsswitch.conf 46 31 ··· 51 36 52 37 # Copy minimal glibc runtime dependencies 53 38 COPY --from=builder /runtime-deps / 54 - 55 39 # Copy NSS configuration for DNS resolution 56 40 COPY --from=builder /tmp/nsswitch.conf /etc/nsswitch.conf 57 - 58 41 # Copy CA certificates for HTTPS (PDS, Jetstream, relay connections) 59 42 COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 60 - 61 43 # Copy timezone data for timestamp formatting 62 44 COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo 63 - 64 45 # Copy optimized binary (SQLite embedded) 65 46 COPY --from=builder /build/atcr-appview /atcr-appview 66 47 67 - # Expose port (main HTTP server) 48 + # Expose ports 68 49 EXPOSE 5000 69 50 70 51 # OCI image annotations ··· 77 58 org.opencontainers.image.version="0.1.0" \ 78 59 io.atcr.icon="https://imgs.blue/evan.jarrett.net/1TpTNrRelfloN2emuWZDrWmPT0o93bAjEnozjD6UPgoVV9m4" 79 60 80 - # Run the AppView (no config file - uses environment variables) 81 - # Creates /var/lib/atcr directories on first run via Go code 82 61 ENTRYPOINT ["/atcr-appview"] 83 62 CMD ["serve"]
+8 -8
cmd/appview/config.go
··· 97 97 storage["maintenance"] = configuration.Parameters{ 98 98 "uploadpurging": map[interface{}]interface{}{ 99 99 "enabled": false, 100 - "age": 7 * 24 * time.Hour, // 168h 101 - "interval": 24 * time.Hour, // 24h 100 + "age": 7 * 24 * time.Hour, // 168h 101 + "interval": 24 * time.Hour, // 24h 102 102 "dryrun": false, 103 103 }, 104 104 } ··· 141 141 142 142 return configuration.Auth{ 143 143 "token": configuration.Parameters{ 144 - "realm": realm, 145 - "service": serviceName, 146 - "issuer": serviceName, 147 - "rootcertbundle": certPath, 148 - "privatekey": privateKeyPath, 149 - "expiration": expiration, 144 + "realm": realm, 145 + "service": serviceName, 146 + "issuer": serviceName, 147 + "rootcertbundle": certPath, 148 + "privatekey": privateKeyPath, 149 + "expiration": expiration, 150 150 }, 151 151 }, nil 152 152 }
+5 -5
cmd/appview/serve.go
··· 33 33 34 34 // Define sensitive tables that should never be accessible from public queries 35 35 var sensitiveTables = map[string]bool{ 36 - "oauth_sessions": true, // OAuth tokens 37 - "ui_sessions": true, // Session IDs 38 - "oauth_auth_requests": true, // OAuth state 39 - "devices": true, // Device secret hashes 40 - "pending_device_auth": true, // Pending device secrets 36 + "oauth_sessions": true, // OAuth tokens 37 + "ui_sessions": true, // Session IDs 38 + "oauth_auth_requests": true, // OAuth state 39 + "devices": true, // Device secret hashes 40 + "pending_device_auth": true, // Pending device secrets 41 41 } 42 42 43 43 // readOnlyAuthorizerCallback blocks access to sensitive tables
+151 -10
cmd/hold/main.go
··· 12 12 "strings" 13 13 "time" 14 14 15 + "github.com/aws/aws-sdk-go/aws" 16 + "github.com/aws/aws-sdk-go/aws/credentials" 17 + "github.com/aws/aws-sdk-go/aws/session" 18 + "github.com/aws/aws-sdk-go/service/s3" 15 19 "github.com/distribution/distribution/v3/configuration" 16 20 storagedriver "github.com/distribution/distribution/v3/registry/storage/driver" 17 21 "github.com/distribution/distribution/v3/registry/storage/driver/factory" ··· 70 74 71 75 // HoldService provides presigned URLs for blob storage in a hold 72 76 type HoldService struct { 73 - driver storagedriver.StorageDriver 74 - config *Config 77 + driver storagedriver.StorageDriver 78 + config *Config 79 + s3Client *s3.S3 // S3 client for presigned URLs (nil if not S3 storage) 80 + bucket string // S3 bucket name 81 + s3PathPrefix string // S3 path prefix (if any) 75 82 } 76 83 77 84 // NewHoldService creates a new hold service ··· 83 90 return nil, fmt.Errorf("failed to create storage driver: %w", err) 84 91 } 85 92 86 - return &HoldService{ 93 + service := &HoldService{ 87 94 driver: driver, 88 95 config: cfg, 89 - }, nil 96 + } 97 + 98 + // Initialize S3 client for presigned URLs (if using S3 storage) 99 + if err := service.initS3Client(); err != nil { 100 + log.Printf("WARNING: S3 presigned URLs disabled: %v", err) 101 + } 102 + 103 + return service, nil 104 + } 105 + 106 + // initS3Client initializes the S3 client for presigned URL generation 107 + // Returns nil error if S3 client is successfully initialized 108 + // Returns error if storage is not S3 or if initialization fails (service will fall back to proxy mode) 109 + func (s *HoldService) initS3Client() error { 110 + // Check if storage driver is S3 111 + if s.config.Storage.Type() != "s3" { 112 + log.Printf("Storage driver is %s (not S3), presigned URLs disabled", s.config.Storage.Type()) 113 + return nil // Not an error - just using different driver 114 + } 115 + 116 + // Extract S3 configuration from storage parameters 117 + params, ok := s.config.Storage.Parameters()["s3"].(configuration.Parameters) 118 + if !ok { 119 + return fmt.Errorf("failed to get S3 parameters from storage config") 120 + } 121 + 122 + // Extract required S3 configuration 123 + region, _ := params["region"].(string) 124 + if region == "" { 125 + region = "us-east-1" // Default region 126 + } 127 + 128 + accessKey, _ := params["accesskey"].(string) 129 + secretKey, _ := params["secretkey"].(string) 130 + bucket, _ := params["bucket"].(string) 131 + 132 + if bucket == "" { 133 + return fmt.Errorf("S3 bucket not configured") 134 + } 135 + 136 + // Build AWS config 137 + awsConfig := &aws.Config{ 138 + Region: aws.String(region), 139 + } 140 + 141 + // Add credentials if provided (allow IAM role auth if not provided) 142 + if accessKey != "" && secretKey != "" { 143 + awsConfig.Credentials = credentials.NewStaticCredentials(accessKey, secretKey, "") 144 + } 145 + 146 + // Add custom endpoint for S3-compatible services (Storj, MinIO, R2, etc.) 147 + if endpoint, ok := params["regionendpoint"].(string); ok && endpoint != "" { 148 + awsConfig.Endpoint = aws.String(endpoint) 149 + awsConfig.S3ForcePathStyle = aws.Bool(true) // Required for MinIO, Storj 150 + } 151 + 152 + // Create AWS session 153 + sess, err := session.NewSession(awsConfig) 154 + if err != nil { 155 + return fmt.Errorf("failed to create AWS session: %w", err) 156 + } 157 + 158 + // Create S3 client 159 + s.s3Client = s3.New(sess) 160 + s.bucket = bucket 161 + 162 + // Extract path prefix if configured (rootdirectory in S3 params) 163 + if rootDir, ok := params["rootdirectory"].(string); ok && rootDir != "" { 164 + s.s3PathPrefix = strings.TrimPrefix(rootDir, "/") 165 + } 166 + 167 + log.Printf("S3 presigned URLs enabled for bucket: %s", s.bucket) 168 + if s.s3PathPrefix != "" { 169 + log.Printf("S3 path prefix: %s", s.s3PathPrefix) 170 + } 171 + 172 + return nil 90 173 } 91 174 92 175 // GetPresignedURLRequest represents a request for a presigned download URL ··· 491 574 return "", fmt.Errorf("blob not found: %w", err) 492 575 } 493 576 494 - // For drivers that support presigned URLs (S3), use those 495 - // For now, return a proxy URL through this service with DID for authorization 496 - return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did), nil 577 + // If S3 client available, generate presigned URL 578 + if s.s3Client != nil { 579 + // Build S3 key from blob path 580 + // blobPath returns paths like: /docker/registry/v2/blobs/sha256/ab/abc123.../data 581 + s3Key := strings.TrimPrefix(path, "/") 582 + if s.s3PathPrefix != "" { 583 + s3Key = s.s3PathPrefix + "/" + s3Key 584 + } 585 + 586 + // Generate presigned GET URL 587 + req, _ := s.s3Client.GetObjectRequest(&s3.GetObjectInput{ 588 + Bucket: aws.String(s.bucket), 589 + Key: aws.String(s3Key), 590 + }) 591 + 592 + url, err := req.Presign(15 * time.Minute) 593 + if err != nil { 594 + log.Printf("WARN: Presigned URL generation failed for %s, falling back to proxy: %v", digest, err) 595 + return s.getProxyDownloadURL(digest, did), nil 596 + } 597 + 598 + log.Printf("Generated presigned download URL for %s (expires in 15min)", digest) 599 + return url, nil 600 + } 601 + 602 + // Fallback: return proxy URL through this service 603 + return s.getProxyDownloadURL(digest, did), nil 604 + } 605 + 606 + // getProxyDownloadURL returns a proxy URL for blob download (fallback when presigned URLs unavailable) 607 + func (s *HoldService) getProxyDownloadURL(digest, did string) string { 608 + return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did) 497 609 } 498 610 499 611 // getUploadURL generates an upload URL for a blob 500 612 // Note: This is called from HandlePutPresignedURL which has the DID in the request 501 613 func (s *HoldService) getUploadURL(ctx context.Context, digest string, size int64, did string) (string, error) { 502 - // For drivers that support presigned URLs (S3), use those 503 - // For now, return a proxy URL through this service with DID for authorization 504 - return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did), nil 614 + // If S3 client available, generate presigned URL 615 + if s.s3Client != nil { 616 + // Build S3 key from blob path 617 + path := blobPath(digest) 618 + s3Key := strings.TrimPrefix(path, "/") 619 + if s.s3PathPrefix != "" { 620 + s3Key = s.s3PathPrefix + "/" + s3Key 621 + } 622 + 623 + // Generate presigned PUT URL 624 + req, _ := s.s3Client.PutObjectRequest(&s3.PutObjectInput{ 625 + Bucket: aws.String(s.bucket), 626 + Key: aws.String(s3Key), 627 + }) 628 + 629 + url, err := req.Presign(15 * time.Minute) 630 + if err != nil { 631 + log.Printf("WARN: Presigned URL generation failed for %s, falling back to proxy: %v", digest, err) 632 + return s.getProxyUploadURL(digest, did), nil 633 + } 634 + 635 + log.Printf("Generated presigned upload URL for %s (expires in 15min)", digest) 636 + return url, nil 637 + } 638 + 639 + // Fallback: return proxy URL through this service 640 + return s.getProxyUploadURL(digest, did), nil 641 + } 642 + 643 + // getProxyUploadURL returns a proxy URL for blob upload (fallback when presigned URLs unavailable) 644 + func (s *HoldService) getProxyUploadURL(digest, did string) string { 645 + return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did) 505 646 } 506 647 507 648 // RegisterRequest represents a request to register this hold in a user's PDS
+1 -1
go.mod
··· 3 3 go 1.24.7 4 4 5 5 require ( 6 + github.com/aws/aws-sdk-go v1.55.5 6 7 github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 7 8 github.com/distribution/distribution/v3 v3.0.0 8 9 github.com/distribution/reference v0.6.0 ··· 19 20 ) 20 21 21 22 require ( 22 - github.com/aws/aws-sdk-go v1.55.5 // indirect 23 23 github.com/beorn7/perks v1.0.1 // indirect 24 24 github.com/bshuster-repo/logrus-logstash-hook v1.0.0 // indirect 25 25 github.com/carlmjohnson/versioninfo v0.22.5 // indirect
+4 -4
pkg/appview/db/schema.go
··· 196 196 197 197 // Migration represents a database migration 198 198 type Migration struct { 199 - Version int 200 - Name string 201 - Description string `yaml:"description"` 202 - Query string `yaml:"query"` 199 + Version int 200 + Name string 201 + Description string `yaml:"description"` 202 + Query string `yaml:"query"` 203 203 } 204 204 205 205 // runMigrations applies any pending database migrations
+1 -1
pkg/appview/jetstream/worker.go
··· 46 46 pongMutex sync.Mutex 47 47 48 48 // In-memory cursor tracking for reconnects 49 - lastCursor int64 49 + lastCursor int64 50 50 cursorMutex sync.RWMutex 51 51 } 52 52