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

remove distribution from hold, add vulnerability scanning in appview.

1. Removing distribution/distribution from the Hold Service (biggest change)
The hold service previously used distribution's StorageDriver interface for all blob operations. This replaces it with direct AWS SDK v2 calls through ATCR's own pkg/s3.S3Service:
- New S3Service methods: Stat(), PutBytes(), Move(), Delete(), WalkBlobs(), ListPrefix() added to pkg/s3/types.go
- Pull zone fix: Presigned URLs are now generated against the real S3 endpoint, then the host is swapped to the CDN URL post-signing (previously the CDN URL was set as the endpoint, which
broke SigV4 signatures)
- All hold subsystems migrated: GC, OCI uploads, XRPC handlers, profile uploads, scan broadcaster, manifest posts — all now use *s3.S3Service instead of storagedriver.StorageDriver
- Config simplified: Removed configuration.Storage type and buildStorageConfigFromFields(); replaced with a simple S3Params() method
- Mock expanded: MockS3Client gains an in-memory object store + 5 new methods, replacing duplicate mockStorageDriver implementations in tests (~160 lines deleted from each test file)
2. Vulnerability Scan UI in AppView (new feature)
Displays scan results from the hold's PDS on the repository page:
- New lexicon: io/atcr/hold/scan.json with vulnReportBlob field for storing full Grype reports
- Two new HTMX endpoints: /api/scan-result (badge) and /api/vuln-details (modal with CVE table)
- New templates: vuln-badge.html (severity count chips) and vuln-details.html (full CVE table with NVD/GHSA links)
- Repository page: Lazy-loads scan badges per manifest via HTMX
- Tests: ~590 lines of test coverage for both handlers
3. S3 Diagnostic Tool
New cmd/s3-test/main.go (418 lines) — tests S3 connectivity with both SDK v1 and v2, including presigned URL generation, pull zone host swapping, and verbose signing debug output.
4. Deployment Tooling
- New syncServiceUnit() for comparing/updating systemd units on servers
- Update command now syncs config keys (adds missing keys from template) and service units with daemon-reload
5. DB Migration
0011_fix_captain_successor_column.yaml — rebuilds hold_captain_records to add the successor column that was missed in a previous migration.
6. Documentation
- APPVIEW-UI-FUTURE.md rewritten as a status-tracked feature inventory
- DISTRIBUTION.md renamed to CREDENTIAL_HELPER.md
- New REMOVING_DISTRIBUTION.md — 480-line analysis of fully removing distribution from the appview side
7. go.mod
aws-sdk-go v1 moved from indirect to direct (needed by cmd/s3-test).

evan.jarrett.net de02e1f0 434a5f1e

verified
+3133 -961
+418
cmd/s3-test/main.go
···
··· 1 + // Command s3-test is a diagnostic tool that tests S3 connectivity using both 2 + // AWS SDK v1 (used by distribution's storage driver) and AWS SDK v2 (used by 3 + // ATCR's presigned URL service). It helps diagnose signature compatibility 4 + // issues with S3-compatible storage providers. 5 + package main 6 + 7 + import ( 8 + "bufio" 9 + "context" 10 + "flag" 11 + "fmt" 12 + "io" 13 + "net/http" 14 + "net/url" 15 + "os" 16 + "strings" 17 + "time" 18 + 19 + awsv1 "github.com/aws/aws-sdk-go/aws" 20 + credentialsv1 "github.com/aws/aws-sdk-go/aws/credentials" 21 + "github.com/aws/aws-sdk-go/aws/session" 22 + s3v1 "github.com/aws/aws-sdk-go/service/s3" 23 + 24 + awsv2 "github.com/aws/aws-sdk-go-v2/aws" 25 + configv2 "github.com/aws/aws-sdk-go-v2/config" 26 + credentialsv2 "github.com/aws/aws-sdk-go-v2/credentials" 27 + s3v2 "github.com/aws/aws-sdk-go-v2/service/s3" 28 + ) 29 + 30 + func main() { 31 + var ( 32 + envFile = flag.String("env-file", "", "Load environment variables from file (KEY=VALUE format)") 33 + accessKey = flag.String("access-key", "", "S3 access key (env: AWS_ACCESS_KEY_ID)") 34 + secretKey = flag.String("secret-key", "", "S3 secret key (env: AWS_SECRET_ACCESS_KEY)") 35 + region = flag.String("region", "", "S3 region (env: S3_REGION)") 36 + bucket = flag.String("bucket", "", "S3 bucket name (env: S3_BUCKET)") 37 + endpoint = flag.String("endpoint", "", "S3 endpoint URL (env: S3_ENDPOINT)") 38 + pullZone = flag.String("pull-zone", "", "CDN pull zone URL for presigned reads (env: PULL_ZONE)") 39 + prefix = flag.String("prefix", "docker/registry/v2/blobs", "Key prefix for list operations") 40 + verbose = flag.Bool("verbose", false, "Enable SDK debug signing logs") 41 + ) 42 + flag.Parse() 43 + 44 + // Load env file first, then let flags and real env vars override 45 + if *envFile != "" { 46 + if err := loadEnvFile(*envFile); err != nil { 47 + fmt.Fprintf(os.Stderr, "Error loading env file: %v\n", err) 48 + os.Exit(1) 49 + } 50 + } 51 + 52 + // Resolve: flag > env var > default 53 + if *accessKey == "" { 54 + *accessKey = os.Getenv("AWS_ACCESS_KEY_ID") 55 + } 56 + if *secretKey == "" { 57 + *secretKey = os.Getenv("AWS_SECRET_ACCESS_KEY") 58 + } 59 + if *region == "" { 60 + *region = envOr("S3_REGION", "us-east-1") 61 + } 62 + if *bucket == "" { 63 + *bucket = os.Getenv("S3_BUCKET") 64 + } 65 + if *endpoint == "" { 66 + *endpoint = os.Getenv("S3_ENDPOINT") 67 + } 68 + if *pullZone == "" { 69 + *pullZone = os.Getenv("PULL_ZONE") 70 + } 71 + 72 + if *accessKey == "" || *secretKey == "" || *bucket == "" { 73 + fmt.Fprintln(os.Stderr, "Usage: s3-test [--env-file FILE] [--access-key KEY] [--secret-key KEY] [--bucket BUCKET] [--endpoint URL] [--region REGION] [--prefix PREFIX] [--verbose]") 74 + fmt.Fprintln(os.Stderr, "Env vars: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_BUCKET, S3_REGION, S3_ENDPOINT") 75 + os.Exit(1) 76 + } 77 + 78 + fmt.Println("S3 Connectivity Diagnostic") 79 + fmt.Println("==========================") 80 + fmt.Printf("Endpoint: %s\n", valueOr(*endpoint, "(default AWS)")) 81 + fmt.Printf("Pull Zone: %s\n", valueOr(*pullZone, "(none)")) 82 + fmt.Printf("Region: %s\n", *region) 83 + fmt.Printf("AccessKey: %s...%s (%d chars)\n", (*accessKey)[:3], (*accessKey)[len(*accessKey)-3:], len(*accessKey)) 84 + fmt.Printf("SecretKey: %s...%s (%d chars)\n", (*secretKey)[:3], (*secretKey)[len(*secretKey)-3:], len(*secretKey)) 85 + fmt.Printf("Bucket: %s\n", *bucket) 86 + fmt.Printf("Prefix: %s\n", *prefix) 87 + fmt.Println() 88 + 89 + ctx := context.Background() 90 + results := make([]result, 0, 6) 91 + 92 + // Build SDK v1 client (SigV4) — matches distribution driver's New() 93 + v1Client := buildV1Client(*accessKey, *secretKey, *region, *endpoint, *verbose) 94 + 95 + // Test 1: SDK v1 SigV4 HeadBucket 96 + results = append(results, runTest("SDK v1 / SigV4 / HeadBucket", func() error { 97 + _, err := v1Client.HeadBucketWithContext(ctx, &s3v1.HeadBucketInput{ 98 + Bucket: awsv1.String(*bucket), 99 + }) 100 + return err 101 + })) 102 + 103 + // Test 2: SDK v1 SigV4 ListObjectsV2 104 + results = append(results, runTest("SDK v1 / SigV4 / ListObjectsV2", func() error { 105 + _, err := v1Client.ListObjectsV2WithContext(ctx, &s3v1.ListObjectsV2Input{ 106 + Bucket: awsv1.String(*bucket), 107 + Prefix: awsv1.String(*prefix), 108 + MaxKeys: awsv1.Int64(5), 109 + }) 110 + return err 111 + })) 112 + 113 + // Test 3: SDK v1 SigV4 ListObjectsV2Pages (paginated, matches doWalk) 114 + results = append(results, runTest("SDK v1 / SigV4 / ListObjectsV2Pages", func() error { 115 + return v1Client.ListObjectsV2PagesWithContext(ctx, &s3v1.ListObjectsV2Input{ 116 + Bucket: awsv1.String(*bucket), 117 + Prefix: awsv1.String(*prefix), 118 + MaxKeys: awsv1.Int64(5), 119 + }, func(page *s3v1.ListObjectsV2Output, lastPage bool) bool { 120 + return false // stop after first page 121 + }) 122 + })) 123 + 124 + // Build SDK v2 client — matches NewS3Service() 125 + v2Client := buildV2Client(ctx, *accessKey, *secretKey, *region, *endpoint) 126 + 127 + // Test 5: SDK v2 SigV4 HeadBucket 128 + results = append(results, runTest("SDK v2 / SigV4 / HeadBucket", func() error { 129 + _, err := v2Client.HeadBucket(ctx, &s3v2.HeadBucketInput{ 130 + Bucket: awsv2.String(*bucket), 131 + }) 132 + return err 133 + })) 134 + 135 + // Test 6: SDK v2 SigV4 ListObjectsV2 136 + results = append(results, runTest("SDK v2 / SigV4 / ListObjectsV2", func() error { 137 + _, err := v2Client.ListObjectsV2(ctx, &s3v2.ListObjectsV2Input{ 138 + Bucket: awsv2.String(*bucket), 139 + Prefix: awsv2.String(*prefix), 140 + MaxKeys: awsv2.Int32(5), 141 + }) 142 + return err 143 + })) 144 + 145 + // Find a real object key for GetObject / presigned URL tests 146 + var testKey string 147 + listOut, err := v2Client.ListObjectsV2(ctx, &s3v2.ListObjectsV2Input{ 148 + Bucket: awsv2.String(*bucket), 149 + Prefix: awsv2.String(*prefix), 150 + MaxKeys: awsv2.Int32(1), 151 + }) 152 + if err == nil && len(listOut.Contents) > 0 { 153 + testKey = *listOut.Contents[0].Key 154 + } 155 + 156 + if testKey == "" { 157 + fmt.Printf("\n (Skipping GetObject/Presigned tests — no objects found under prefix %q)\n", *prefix) 158 + } else { 159 + fmt.Printf("\n Test object: %s\n\n", testKey) 160 + 161 + // Test 7: SDK v1 GetObject (HEAD only) 162 + results = append(results, runTest("SDK v1 / SigV4 / HeadObject", func() error { 163 + _, err := v1Client.HeadObjectWithContext(ctx, &s3v1.HeadObjectInput{ 164 + Bucket: awsv1.String(*bucket), 165 + Key: awsv1.String(testKey), 166 + }) 167 + return err 168 + })) 169 + 170 + // Test 8: SDK v2 GetObject (HEAD only) 171 + results = append(results, runTest("SDK v2 / SigV4 / HeadObject", func() error { 172 + _, err := v2Client.HeadObject(ctx, &s3v2.HeadObjectInput{ 173 + Bucket: awsv2.String(*bucket), 174 + Key: awsv2.String(testKey), 175 + }) 176 + return err 177 + })) 178 + 179 + // Test 9: SDK v2 Presigned GET URL (generate + fetch) 180 + presignClient := s3v2.NewPresignClient(v2Client) 181 + results = append(results, runTest("SDK v2 / Presigned GET URL", func() error { 182 + presigned, err := presignClient.PresignGetObject(ctx, &s3v2.GetObjectInput{ 183 + Bucket: awsv2.String(*bucket), 184 + Key: awsv2.String(testKey), 185 + }, func(opts *s3v2.PresignOptions) { 186 + opts.Expires = 5 * time.Minute 187 + }) 188 + if err != nil { 189 + return fmt.Errorf("presign: %w", err) 190 + } 191 + if *verbose { 192 + // Show host + query params (no path to avoid leaking key structure) 193 + u, _ := url.Parse(presigned.URL) 194 + fmt.Printf("\n Presigned host: %s\n", u.Host) 195 + fmt.Printf(" Signed headers: %s\n", presigned.SignedHeader) 196 + } 197 + resp, err := http.Get(presigned.URL) 198 + if err != nil { 199 + return fmt.Errorf("fetch: %w", err) 200 + } 201 + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) 202 + resp.Body.Close() 203 + if resp.StatusCode != 200 { 204 + return fmt.Errorf("presigned URL returned %d: %s", resp.StatusCode, string(body)) 205 + } 206 + return nil 207 + })) 208 + 209 + // Pull zone presigned tests — sign against real endpoint, swap host to pull zone 210 + if *pullZone != "" { 211 + results = append(results, runTest("SDK v2 / Presigned GET via Pull Zone", func() error { 212 + presigned, err := presignClient.PresignGetObject(ctx, &s3v2.GetObjectInput{ 213 + Bucket: awsv2.String(*bucket), 214 + Key: awsv2.String(testKey), 215 + }, func(opts *s3v2.PresignOptions) { 216 + opts.Expires = 5 * time.Minute 217 + }) 218 + if err != nil { 219 + return fmt.Errorf("presign: %w", err) 220 + } 221 + pzURL := swapHost(presigned.URL, *pullZone) 222 + if *verbose { 223 + fmt.Printf("\n Signed against: %s\n", presigned.URL[:40]+"...") 224 + fmt.Printf(" Fetching from: %s\n", pzURL[:40]+"...") 225 + } 226 + resp, err := http.Get(pzURL) 227 + if err != nil { 228 + return fmt.Errorf("fetch: %w", err) 229 + } 230 + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) 231 + resp.Body.Close() 232 + if resp.StatusCode != 200 { 233 + return fmt.Errorf("pull zone GET returned %d: %s", resp.StatusCode, string(body)) 234 + } 235 + return nil 236 + })) 237 + 238 + } 239 + 240 + // Test 10: SDK v2 Presigned PUT URL (generate + upload empty) 241 + results = append(results, runTest("SDK v2 / Presigned PUT URL", func() error { 242 + putKey := *prefix + "/_s3-test-probe" 243 + presigned, err := presignClient.PresignPutObject(ctx, &s3v2.PutObjectInput{ 244 + Bucket: awsv2.String(*bucket), 245 + Key: awsv2.String(putKey), 246 + }, func(opts *s3v2.PresignOptions) { 247 + opts.Expires = 5 * time.Minute 248 + }) 249 + if err != nil { 250 + return fmt.Errorf("presign: %w", err) 251 + } 252 + req, err := http.NewRequestWithContext(ctx, http.MethodPut, presigned.URL, strings.NewReader("")) 253 + if err != nil { 254 + return fmt.Errorf("build request: %w", err) 255 + } 256 + req.Header.Set("Content-Length", "0") 257 + resp, err := http.DefaultClient.Do(req) 258 + if err != nil { 259 + return fmt.Errorf("fetch: %w", err) 260 + } 261 + resp.Body.Close() 262 + if resp.StatusCode != 200 { 263 + return fmt.Errorf("presigned PUT returned %d", resp.StatusCode) 264 + } 265 + // Clean up 266 + _, _ = v2Client.DeleteObject(ctx, &s3v2.DeleteObjectInput{ 267 + Bucket: awsv2.String(*bucket), 268 + Key: awsv2.String(putKey), 269 + }) 270 + return nil 271 + })) 272 + } 273 + 274 + // Print summary 275 + fmt.Println() 276 + fmt.Println("Summary") 277 + fmt.Println("=======") 278 + 279 + allPass := true 280 + for _, r := range results { 281 + status := "PASS" 282 + if !r.ok { 283 + status = "FAIL" 284 + allPass = false 285 + } 286 + fmt.Printf(" [%s] %s (%s)\n", status, r.name, r.duration.Round(time.Millisecond)) 287 + if !r.ok { 288 + fmt.Printf(" Error: %s\n", r.err) 289 + } 290 + } 291 + 292 + fmt.Println() 293 + if allPass { 294 + fmt.Println("Diagnosis: All tests passed. S3 connectivity is working with both SDKs.") 295 + } else { 296 + fmt.Println("Diagnosis: Some tests failed. Review errors above.") 297 + } 298 + } 299 + 300 + type result struct { 301 + name string 302 + ok bool 303 + err error 304 + duration time.Duration 305 + } 306 + 307 + func runTest(name string, fn func() error) result { 308 + fmt.Printf(" Testing: %s ... ", name) 309 + start := time.Now() 310 + err := fn() 311 + d := time.Since(start) 312 + if err != nil { 313 + fmt.Printf("FAIL (%s)\n", d.Round(time.Millisecond)) 314 + return result{name: name, ok: false, err: err, duration: d} 315 + } 316 + fmt.Printf("PASS (%s)\n", d.Round(time.Millisecond)) 317 + return result{name: name, ok: true, duration: d} 318 + } 319 + 320 + func loadEnvFile(path string) error { 321 + f, err := os.Open(path) 322 + if err != nil { 323 + return err 324 + } 325 + defer f.Close() 326 + 327 + scanner := bufio.NewScanner(f) 328 + for scanner.Scan() { 329 + line := strings.TrimSpace(scanner.Text()) 330 + if line == "" || strings.HasPrefix(line, "#") { 331 + continue 332 + } 333 + line = strings.TrimPrefix(line, "export ") 334 + k, v, ok := strings.Cut(line, "=") 335 + if !ok { 336 + continue 337 + } 338 + v = strings.Trim(v, `"'`) 339 + os.Setenv(strings.TrimSpace(k), strings.TrimSpace(v)) 340 + } 341 + return scanner.Err() 342 + } 343 + 344 + func envOr(key, fallback string) string { 345 + if v := os.Getenv(key); v != "" { 346 + return v 347 + } 348 + return fallback 349 + } 350 + 351 + func swapHost(presignedURL, pullZone string) string { 352 + parsed, err := url.Parse(presignedURL) 353 + if err != nil { 354 + return presignedURL 355 + } 356 + pz, err := url.Parse(pullZone) 357 + if err != nil { 358 + return presignedURL 359 + } 360 + parsed.Scheme = pz.Scheme 361 + parsed.Host = pz.Host 362 + return parsed.String() 363 + } 364 + 365 + func valueOr(s, fallback string) string { 366 + if s == "" { 367 + return fallback 368 + } 369 + return s 370 + } 371 + 372 + // buildV1Client constructs an SDK v1 S3 client identically to 373 + // distribution/distribution's s3-aws driver New() function. 374 + func buildV1Client(accessKey, secretKey, region, endpoint string, verbose bool) *s3v1.S3 { 375 + awsConfig := awsv1.NewConfig() 376 + 377 + if verbose { 378 + awsConfig.WithLogLevel(awsv1.LogDebugWithSigning) 379 + } 380 + 381 + awsConfig.WithCredentials(credentialsv1.NewStaticCredentials(accessKey, secretKey, "")) 382 + awsConfig.WithRegion(region) 383 + 384 + if endpoint != "" { 385 + awsConfig.WithEndpoint(endpoint) 386 + awsConfig.WithS3ForcePathStyle(true) 387 + } 388 + 389 + sess, err := session.NewSession(awsConfig) 390 + if err != nil { 391 + fmt.Fprintf(os.Stderr, "Failed to create SDK v1 session: %v\n", err) 392 + os.Exit(1) 393 + } 394 + 395 + return s3v1.New(sess) 396 + } 397 + 398 + // buildV2Client constructs an SDK v2 S3 client identically to 399 + // ATCR's NewS3Service() in pkg/s3/types.go. 400 + func buildV2Client(ctx context.Context, accessKey, secretKey, region, endpoint string) *s3v2.Client { 401 + cfg, err := configv2.LoadDefaultConfig(ctx, 402 + configv2.WithRegion(region), 403 + configv2.WithCredentialsProvider( 404 + credentialsv2.NewStaticCredentialsProvider(accessKey, secretKey, ""), 405 + ), 406 + ) 407 + if err != nil { 408 + fmt.Fprintf(os.Stderr, "Failed to load SDK v2 config: %v\n", err) 409 + os.Exit(1) 410 + } 411 + 412 + return s3v2.NewFromConfig(cfg, func(o *s3v2.Options) { 413 + if endpoint != "" { 414 + o.BaseEndpoint = awsv2.String(endpoint) 415 + o.UsePathStyle = true 416 + } 417 + }) 418 + }
+33
deploy/upcloud/cloudinit.go
··· 192 return buf.String(), nil 193 } 194 195 // syncConfigKeys fetches the existing config from a server and merges in any 196 // missing keys from the rendered template. Existing values are never overwritten. 197 func syncConfigKeys(name, ip, configPath, templateYAML string) error {
··· 192 return buf.String(), nil 193 } 194 195 + // syncServiceUnit compares a rendered systemd service unit against what's on 196 + // the server. If they differ, it writes the new unit file. Returns true if the 197 + // unit was updated (caller should daemon-reload before restart). 198 + func syncServiceUnit(name, ip, serviceName, renderedUnit string) (bool, error) { 199 + unitPath := "/etc/systemd/system/" + serviceName + ".service" 200 + 201 + remote, err := runSSH(ip, fmt.Sprintf("cat %s 2>/dev/null || echo '__MISSING__'", unitPath), false) 202 + if err != nil { 203 + fmt.Printf(" service unit sync: could not reach %s (%v)\n", name, err) 204 + return false, nil 205 + } 206 + remote = strings.TrimSpace(remote) 207 + rendered := strings.TrimSpace(renderedUnit) 208 + 209 + if remote == "__MISSING__" { 210 + fmt.Printf(" service unit: %s not found (cloud-init will handle it)\n", name) 211 + return false, nil 212 + } 213 + 214 + if remote == rendered { 215 + fmt.Printf(" service unit: %s up to date\n", name) 216 + return false, nil 217 + } 218 + 219 + // Write the updated unit file 220 + script := fmt.Sprintf("cat > %s << 'SVCEOF'\n%s\nSVCEOF", unitPath, rendered) 221 + if _, err := runSSH(ip, script, false); err != nil { 222 + return false, fmt.Errorf("write service unit: %w", err) 223 + } 224 + fmt.Printf(" service unit: %s updated\n", name) 225 + return true, nil 226 + } 227 + 228 // syncConfigKeys fetches the existing config from a server and merges in any 229 // missing keys from the rendered template. Existing values are never overwritten. 230 func syncConfigKeys(name, ip, configPath, templateYAML string) error {
+1
deploy/upcloud/configs/hold.yaml.tmpl
··· 51 new_crew_tier: deckhand 52 scanner: 53 secret: ""
··· 51 new_crew_tier: deckhand 52 scanner: 53 secret: "" 54 +
+65 -1
deploy/upcloud/update.go
··· 55 return err 56 } 57 58 targets := map[string]struct { 59 ip string 60 binaryName string 61 buildCmd string 62 serviceName string 63 healthURL string 64 }{ 65 "appview": { 66 ip: state.Appview.PublicIP, ··· 68 buildCmd: "appview", 69 serviceName: naming.Appview(), 70 healthURL: "http://localhost:5000/health", 71 }, 72 "hold": { 73 ip: state.Hold.PublicIP, ··· 75 buildCmd: "hold", 76 serviceName: naming.Hold(), 77 healthURL: "http://localhost:8080/xrpc/_health", 78 }, 79 } 80 ··· 92 t := targets[name] 93 fmt.Printf("Updating %s (%s)...\n", name, t.ip) 94 95 updateScript := fmt.Sprintf(`set -euo pipefail 96 export PATH=$PATH:/usr/local/go/bin 97 export GOTMPDIR=/var/tmp ··· 113 -ldflags="-s -w -linkmode external -extldflags '-static'" \ 114 -tags sqlite_omit_load_extension -trimpath \ 115 -o bin/%s ./cmd/%s 116 systemctl restart %s 117 118 sleep 2 119 curl -sf %s > /dev/null && echo "HEALTH_OK" || echo "HEALTH_FAIL" 120 - `, goVersion, naming.InstallDir(), branch, t.binaryName, t.buildCmd, t.serviceName, t.healthURL) 121 122 output, err := runSSH(t.ip, updateScript, true) 123 if err != nil { ··· 137 } 138 139 return nil 140 } 141 142 func cmdSSH(target string) error {
··· 55 return err 56 } 57 58 + vals := configValsFromState(state) 59 + 60 targets := map[string]struct { 61 ip string 62 binaryName string 63 buildCmd string 64 serviceName string 65 healthURL string 66 + configTmpl string 67 + configPath string 68 + unitTmpl string 69 }{ 70 "appview": { 71 ip: state.Appview.PublicIP, ··· 73 buildCmd: "appview", 74 serviceName: naming.Appview(), 75 healthURL: "http://localhost:5000/health", 76 + configTmpl: appviewConfigTmpl, 77 + configPath: naming.AppviewConfigPath(), 78 + unitTmpl: appviewServiceTmpl, 79 }, 80 "hold": { 81 ip: state.Hold.PublicIP, ··· 83 buildCmd: "hold", 84 serviceName: naming.Hold(), 85 healthURL: "http://localhost:8080/xrpc/_health", 86 + configTmpl: holdConfigTmpl, 87 + configPath: naming.HoldConfigPath(), 88 + unitTmpl: holdServiceTmpl, 89 }, 90 } 91 ··· 103 t := targets[name] 104 fmt.Printf("Updating %s (%s)...\n", name, t.ip) 105 106 + // Sync config keys (adds missing keys from template, never overwrites) 107 + configYAML, err := renderConfig(t.configTmpl, vals) 108 + if err != nil { 109 + return fmt.Errorf("render %s config: %w", name, err) 110 + } 111 + if err := syncConfigKeys(name, t.ip, t.configPath, configYAML); err != nil { 112 + return fmt.Errorf("%s config sync: %w", name, err) 113 + } 114 + 115 + // Sync systemd service unit 116 + renderedUnit, err := renderServiceUnit(t.unitTmpl, serviceUnitParams{ 117 + DisplayName: naming.DisplayName(), 118 + User: naming.SystemUser(), 119 + BinaryPath: naming.InstallDir() + "/bin/" + t.binaryName, 120 + ConfigPath: t.configPath, 121 + DataDir: naming.BasePath(), 122 + ServiceName: t.serviceName, 123 + }) 124 + if err != nil { 125 + return fmt.Errorf("render %s service unit: %w", name, err) 126 + } 127 + unitChanged, err := syncServiceUnit(name, t.ip, t.serviceName, renderedUnit) 128 + if err != nil { 129 + return fmt.Errorf("%s service unit sync: %w", name, err) 130 + } 131 + 132 + daemonReload := "" 133 + if unitChanged { 134 + daemonReload = "systemctl daemon-reload" 135 + } 136 + 137 updateScript := fmt.Sprintf(`set -euo pipefail 138 export PATH=$PATH:/usr/local/go/bin 139 export GOTMPDIR=/var/tmp ··· 155 -ldflags="-s -w -linkmode external -extldflags '-static'" \ 156 -tags sqlite_omit_load_extension -trimpath \ 157 -o bin/%s ./cmd/%s 158 + %s 159 systemctl restart %s 160 161 sleep 2 162 curl -sf %s > /dev/null && echo "HEALTH_OK" || echo "HEALTH_FAIL" 163 + `, goVersion, naming.InstallDir(), branch, t.binaryName, t.buildCmd, daemonReload, t.serviceName, t.healthURL) 164 165 output, err := runSSH(t.ip, updateScript, true) 166 if err != nil { ··· 180 } 181 182 return nil 183 + } 184 + 185 + // configValsFromState builds ConfigValues from persisted state. 186 + // S3SecretKey is intentionally left empty — syncConfigKeys only adds missing 187 + // keys and never overwrites, so the server's existing secret is preserved. 188 + func configValsFromState(state *InfraState) *ConfigValues { 189 + naming := state.Naming() 190 + _, baseDomain, _, _ := extractFromAppviewTemplate() 191 + holdDomain := state.Zone + ".cove." + baseDomain 192 + 193 + return &ConfigValues{ 194 + S3Endpoint: state.ObjectStorage.Endpoint, 195 + S3Region: state.ObjectStorage.Region, 196 + S3Bucket: state.ObjectStorage.Bucket, 197 + S3AccessKey: state.ObjectStorage.AccessKeyID, 198 + S3SecretKey: "", // not persisted in state; existing value on server is preserved 199 + Zone: state.Zone, 200 + HoldDomain: holdDomain, 201 + HoldDid: "did:web:" + holdDomain, 202 + BasePath: naming.BasePath(), 203 + } 204 } 205 206 func cmdSSH(target string) error {
+304 -461
docs/APPVIEW-UI-FUTURE.md
··· 1 - # ATCR AppView UI - Future Features 2 3 - This document outlines potential features for future versions of the ATCR AppView UI, beyond the V1 MVP. These are ideas to consider as the project matures and user needs evolve. 4 5 ## Advanced Image Management 6 7 - ### Multi-Architecture Image Support 8 9 - **Display image indexes:** 10 - - Show when a tag points to an image index (multi-arch manifest) 11 - - Display all architectures/platforms in the index (linux/amd64, linux/arm64, darwin/arm64, etc.) 12 - Allow viewing individual manifests within the index 13 - - Show platform-specific layer details 14 15 - **Image index creation:** 16 - UI for combining multiple single-arch manifests into an image index 17 - Automatic platform detection from manifest metadata 18 - Validate that all manifests are for the same image (different platforms) 19 20 - ### Layer Inspection & Visualization 21 22 **Layer details page:** 23 - Show Dockerfile command that created each layer (if available in history) ··· 30 - Calculate storage savings from layer sharing 31 - Identify duplicate layers with different digests (potential optimization) 32 33 - ### Image Operations 34 35 - **Tag Management:** 36 - - **Tag promotion workflow:** dev → staging → prod with one click 37 - - **Tag aliases:** Create multiple tags pointing to same digest 38 - - **Tag patterns:** Auto-tag based on git commit, semantic version, date 39 - - **Tag protection:** Mark tags as immutable (prevent deletion/re-pointing) 40 41 - **Image Copying:** 42 - Copy image from one repository to another 43 - Copy image from another user's repository (fork) 44 - - Bulk copy operations (copy all tags, copy all manifests) 45 46 - **Image History:** 47 - - Timeline view of tag changes (what digest did "latest" point to over time) 48 - - Rollback functionality (revert tag to previous digest) 49 - - Audit log of all image operations (push, delete, tag changes) 50 51 - ### Vulnerability Scanning 52 53 - **Integration with security scanners:** 54 - - **Trivy** - Comprehensive vulnerability scanner 55 - - **Grype** - Anchore's vulnerability scanner 56 - - **Clair** - CoreOS vulnerability scanner 57 58 - **Features:** 59 - - Automatic scanning on image push 60 - Display CVE count by severity (critical, high, medium, low) 61 - Show detailed CVE information (description, CVSS score, affected packages) 62 - Filter images by vulnerability status 63 - Subscribe to CVE notifications for your images 64 - Compare vulnerability status across tags/versions 65 66 - ### Image Signing & Verification 67 68 - **Cosign/Sigstore integration:** 69 - - Sign images with Cosign 70 - - Display signature verification status 71 - - Show keyless signing certificate chains 72 - - Integrate with transparency log (Rekor) 73 74 - **Features:** 75 - - UI for signing images (generate key, sign manifest) 76 - - Verify signatures before pull (browser-based verification) 77 - - Display signature metadata (signer, timestamp, transparency log entry) 78 - Require signatures for protected repositories 79 80 - ### SBOM (Software Bill of Materials) 81 82 - **SBOM generation and display:** 83 - - Generate SBOM on push (SPDX or CycloneDX format) 84 - Display package list from SBOM 85 - Show license information 86 - Link to upstream package sources 87 - - Compare SBOMs across versions (what packages changed) 88 89 - **SBOM attestation:** 90 - - Store SBOM as attestation (in-toto format) 91 - - Link SBOM to image signature 92 - - Verify SBOM integrity 93 94 - ## Hold Management Dashboard 95 96 - ### Hold Discovery & Registration 97 98 - **Create hold:** 99 - - UI wizard for deploying hold service 100 - - One-click deployment to Fly.io, Railway, Render 101 - - Configuration generator (environment variables, docker-compose) 102 - - Test connectivity after deployment 103 104 - **Hold registration:** 105 - - Automatic registration via OAuth (already implemented) 106 - - Manual registration form (for existing holds) 107 - - Bulk import holds from JSON/YAML 108 109 - ### Hold Configuration 110 111 - **Hold settings page:** 112 - - Edit hold metadata (name, description, icon) 113 - Toggle public/private flag 114 - - Configure storage backend (S3, Storj, Minio, filesystem) 115 - - Set storage quotas and limits 116 - - Configure retention policies (auto-delete old blobs) 117 118 - **Hold credentials:** 119 - - Rotate S3 access keys 120 - - Test hold connectivity 121 - - View hold service logs (if accessible) 122 123 - ### Crew Management 124 125 - **Invite crew members:** 126 - - Send invitation links (OAuth-based) 127 - - Invite by handle or DID 128 - - Set crew permissions (read-only, read-write, admin) 129 - - Bulk invite (upload CSV) 130 131 - **Crew list:** 132 - - Display all crew members 133 - - Show last activity (last push, last pull) 134 - Remove crew members 135 - - Change crew permissions 136 137 - **Crew request workflow:** 138 - - Allow users to request access to a hold 139 - - Hold owner approves/rejects requests 140 - - Notification system for requests 141 142 - ### Hold Analytics 143 144 - **Storage metrics:** 145 - - Total storage used (bytes) 146 - - Blob count 147 - - Largest blobs 148 - - Growth over time (chart) 149 - - Deduplication savings 150 151 - **Access metrics:** 152 - - Total downloads (pulls) 153 - - Bandwidth used 154 - - Popular images (most pulled) 155 - - Geographic distribution (if available) 156 - - Access logs (who pulled what, when) 157 158 - **Cost estimation:** 159 - - Calculate S3 storage costs 160 - - Calculate bandwidth costs 161 - - Compare costs across storage backends 162 - - Budget alerts (notify when approaching limit) 163 164 ## Discovery & Social Features 165 166 - ### Federated Browse & Search 167 168 - **Enhanced discovery:** 169 - - Full-text search across all ATCR images (repository name, tag, description) 170 - Filter by user, hold, architecture, date range 171 - Sort by popularity, recency, size 172 - - Advanced query syntax (e.g., "user:alice tag:latest arch:arm64") 173 - 174 - **Popular/Trending:** 175 - - Most pulled images (past day, week, month) 176 - - Fastest growing images (new pulls) 177 - - Recently updated images (new tags) 178 - - Community favorites (curated list) 179 180 - **Categories & Tags:** 181 - - User-defined categories (web, database, ml, etc.) 182 - - Tag images with keywords (nginx, proxy, reverse-proxy) 183 - - Browse by category 184 - - Tag cloud visualization 185 186 - ### Sailor Profiles (Public) 187 188 - **Public profile page:** 189 - - `/ui/@alice` shows alice's public repositories 190 - - Bio, avatar, website links 191 - Statistics (total images, total pulls, joined date) 192 - - Pinned repositories (showcase best images) 193 194 - **Social features:** 195 - - Follow other sailors (get notified of their pushes) 196 - - Star repositories (bookmark favorites) 197 - - Comment on images (feedback, questions) 198 - - Like/upvote images 199 200 - **Activity feed:** 201 - - Timeline of followed sailors' activity 202 - - Recent pushes from community 203 - - Popular images from followed users 204 205 - ### Federated Timeline 206 207 - **ATProto-native feed:** 208 - - Real-time feed of container pushes (like Bluesky's timeline) 209 - - Filter by follows, community, or global 210 - - React to pushes (like, share, comment) 211 - - Share images to Bluesky/ATProto social apps 212 - 213 - **Custom feeds:** 214 - - Create algorithmic feeds (e.g., "Show me all ML images") 215 - - Subscribe to curated feeds 216 - - Publish feeds for others to subscribe 217 218 ## Access Control & Permissions 219 220 - ### Repository-Level Permissions 221 222 - **Private repositories:** 223 - - Mark repositories as private (only owner + collaborators can pull) 224 - - Invite collaborators by handle/DID 225 - - Set permissions (read-only, read-write, admin) 226 227 - **Public repositories:** 228 - - Default: public (anyone can pull) 229 - - Require authentication for private repos 230 - - Generate read-only tokens (for CI/CD) 231 232 - **Implementation challenge:** 233 - - ATProto doesn't support private records yet 234 - - May require proxy layer for access control 235 - - Or use encrypted blobs with shared keys 236 237 - ### Team/Organization Accounts 238 239 - **Multi-user organizations:** 240 - - Create organization account (e.g., `@acme-corp`) 241 - - Add members with roles (owner, maintainer, member) 242 - - Organization-owned repositories 243 - - Billing and quotas at org level 244 245 - **Features:** 246 - - Team-based access control 247 - - Shared hold for organization 248 - - Audit logs for all org activity 249 - - Single sign-on (SSO) integration 250 251 ## Analytics & Monitoring 252 253 - ### Dashboard 254 255 - **Personal dashboard:** 256 - - Overview of your images, holds, activity 257 - - Quick stats (total size, pull count, last push) 258 - - Recent activity (your pushes, pulls) 259 - - Alerts and notifications 260 261 - **Hold dashboard:** 262 - - Storage usage, bandwidth, costs 263 - - Active crew members 264 - - Recent uploads/downloads 265 - - Health status of hold service 266 267 - ### Pull Analytics 268 269 - **Detailed metrics:** 270 - Pull count per image/tag 271 - - Pull count by client (Docker, containerd, podman) 272 - - Pull count by geography (country, region) 273 - - Pull count over time (chart) 274 - - Failed pulls (errors, retries) 275 276 - **User analytics:** 277 - - Who is pulling your images (if authenticated) 278 - - Anonymous vs authenticated pulls 279 - - Repeat users vs new users 280 281 - ### Alerts & Notifications 282 283 - **Alert types:** 284 - - Storage quota exceeded 285 - - High bandwidth usage 286 - - New vulnerability detected 287 - - Image signature invalid 288 - - Hold service down 289 - - Crew member joined/left 290 - 291 - **Notification channels:** 292 - - Email 293 - - Webhook (POST to custom URL) 294 - - ATProto app notification (future: in-app notifications in Bluesky) 295 - - Slack, Discord, Telegram integrations 296 297 ## Developer Tools & Integrations 298 299 - ### API Documentation 300 - 301 - **Interactive API docs:** 302 - - Swagger/OpenAPI spec for OCI API 303 - - Swagger/OpenAPI spec for UI API 304 - - Interactive API explorer (try API calls in browser) 305 - - Code examples in multiple languages (curl, Go, Python, JavaScript) 306 307 - **SDK/Client Libraries:** 308 - - Official Go client library 309 - - JavaScript/TypeScript client 310 - - Python client 311 - - Rust client 312 313 - ### Webhooks 314 315 - **Webhook configuration:** 316 - - Register webhook URLs per repository 317 - - Select events to trigger (push, delete, tag update) 318 - - Test webhooks (send test payload) 319 - - View webhook delivery history 320 - - Retry failed deliveries 321 322 - **Webhook events:** 323 - - `manifest.pushed` 324 - - `manifest.deleted` 325 - - `tag.created` 326 - - `tag.updated` 327 - - `tag.deleted` 328 - - `scan.completed` (vulnerability scan finished) 329 330 - ### CI/CD Integration Guides 331 332 - **Documentation for popular CI/CD platforms:** 333 - - GitHub Actions (example workflows) 334 - - GitLab CI (.gitlab-ci.yml examples) 335 - - CircleCI (config.yml examples) 336 - - Jenkins (Jenkinsfile examples) 337 - - Drone CI 338 339 - **Features:** 340 - - One-click workflow generation 341 - - Pre-built actions/plugins for ATCR 342 - - Cache layer optimization for faster builds 343 - - Build status badges (show build status in README) 344 345 - ### Infrastructure as Code 346 347 - **IaC examples:** 348 - - Terraform module for deploying hold service 349 - - Pulumi program for ATCR infrastructure 350 - - Kubernetes manifests for hold service 351 - - Docker Compose for local development 352 - - Helm chart for AppView + hold 353 354 - **GitOps workflows:** 355 - - ArgoCD integration (deploy images from ATCR) 356 - - FluxCD integration 357 - - Automated deployments on tag push 358 359 - ## Documentation & Onboarding 360 361 - ### Interactive Getting Started 362 363 - **Onboarding wizard:** 364 - - Step-by-step guide for first-time users 365 - - Interactive tutorial (push your first image) 366 - - Verify setup (test authentication, test push/pull) 367 - - Completion checklist 368 369 - **Guided tours:** 370 - - Product tour of UI features 371 - - Tooltips and hints for new users 372 - Help center with FAQs 373 374 - ### Comprehensive Documentation 375 - 376 - **Documentation sections:** 377 - - Quickstart guide 378 - - Detailed user manual 379 - - API reference 380 - - ATProto record schemas 381 - - Deployment guides (hold service, AppView) 382 - - Troubleshooting guide 383 - - Security best practices 384 - 385 - **Video tutorials:** 386 - - YouTube channel with how-to videos 387 - - Screen recordings of common tasks 388 - - Conference talks and demos 389 - 390 - ### Community & Support 391 - 392 - **Community features:** 393 - - Discussion forum (or integrate with Discourse) 394 - - GitHub Discussions for ATCR project 395 - - Discord/Slack community 396 - - Monthly community calls 397 - 398 - **Support channels:** 399 - - Email support 400 - - Live chat (for paid tiers) 401 - - Priority support (for enterprise) 402 403 ## Advanced ATProto Integration 404 405 - ### Record Viewer 406 407 - **ATProto record browser:** 408 - - Browse all your `io.atcr.*` records 409 - - Raw JSON view with ATProto metadata (CID, commit info, timestamp) 410 - - Diff viewer for record updates 411 - - History view (see all versions of a record) 412 - - Link to ATP URI (`at://did/collection/rkey`) 413 414 - **Export/Import:** 415 - - Export all records as JSON (backup) 416 - - Import records from JSON (restore, migration) 417 - - CAR file export (ATProto native format) 418 419 - ### PDS Integration 420 421 - **Multi-PDS support:** 422 - - Switch between multiple PDS accounts 423 - - Manage images across different PDSs 424 - - Unified view of all your images (across PDSs) 425 - 426 - **PDS health monitoring:** 427 - - Show PDS connection status 428 - - Alert if PDS is unreachable 429 - - Fallback to alternate PDS (if configured) 430 - 431 - **PDS migration tools:** 432 - - Migrate images from one PDS to another 433 - - Bulk update hold endpoints 434 - - Re-sign OAuth tokens for new PDS 435 - 436 - ### Decentralization Features 437 - 438 - **Data sovereignty:** 439 - - "Verify on PDS" button (proves manifest is in your PDS) 440 - - "Clone my registry" guide (backup to another PDS) 441 - - "Export registry" (download all manifests + metadata) 442 443 - **Federation:** 444 - - Cross-AppView image pulls (pull from other ATCR AppViews) 445 - - AppView discovery (find other ATCR instances) 446 - - Federated search (search across multiple AppViews) 447 - 448 - ## Enterprise Features (Future Commercial Offering) 449 - 450 - ### Team Collaboration 451 - 452 - **Organizations:** 453 - - Enterprise org accounts with unlimited members 454 - - RBAC (role-based access control) 455 - - SSO integration (SAML, OIDC) 456 - - Audit logs for compliance 457 - 458 - ### Compliance & Security 459 - 460 - **Compliance tools:** 461 - - SOC 2 compliance reporting 462 - - HIPAA-compliant storage options 463 - - GDPR data export/deletion 464 - - Retention policies (auto-delete after N days) 465 - 466 - **Security features:** 467 - - Image scanning with policy enforcement (block vulnerable images) 468 - - Malware scanning (scan blobs for malware) 469 - - Secrets scanning (detect leaked credentials in layers) 470 - - Content trust (require signed images) 471 472 - ### SLA & Support 473 474 - **Paid tiers:** 475 - - Free tier: 5GB storage, community support 476 - - Pro tier: 100GB storage, email support, SLA 477 - - Enterprise tier: Unlimited storage, priority support, dedicated instance 478 479 - **Features:** 480 - - Guaranteed uptime (99.9%) 481 - - Premium support (24/7, faster response) 482 - - Dedicated account manager 483 - - Custom contract terms 484 485 ## UI/UX Enhancements 486 487 - ### Design System 488 489 - **Theming:** 490 - - Light and dark modes (system preference) 491 - - Custom themes (nautical, cyberpunk, minimalist) 492 - - Accessibility (WCAG 2.1 AA compliance) 493 - High contrast mode 494 495 - **Responsive design:** 496 - - Mobile-first design 497 - - Progressive web app (PWA) with offline support 498 - - Native mobile apps (iOS, Android) 499 500 - ### Performance Optimizations 501 502 - **Frontend optimizations:** 503 - - Lazy loading for images and data 504 - Virtual scrolling for large lists 505 - - Service worker for caching 506 - - Code splitting (load only what's needed) 507 508 - **Backend optimizations:** 509 - - GraphQL API (fetch only required fields) 510 - - Real-time updates via WebSocket 511 - - Server-sent events for firehose 512 - - Edge caching (CloudFlare, Fastly) 513 514 - ### Internationalization 515 516 - **Multi-language support:** 517 - - UI translations (English, Spanish, French, German, Japanese, Chinese, etc.) 518 - - RTL (right-to-left) language support 519 - - Localized date/time formats 520 - - Locale-specific formatting (numbers, currencies) 521 522 - ## Miscellaneous Ideas 523 524 - ### Image Build Service 525 526 - **Cloud-based builds:** 527 - - Build images from Dockerfile in the UI 528 - - Multi-stage build support 529 - - Build cache optimization 530 - - Build logs and status 531 532 - **Automated builds:** 533 - - Connect GitHub/GitLab repository 534 - - Auto-build on git push 535 - - Build matrix (multiple architectures, versions) 536 - - Build notifications 537 538 - ### Image Registry Mirroring 539 540 - **Mirror external registries:** 541 - - Cache images from Docker Hub, ghcr.io, quay.io 542 - - Transparent proxy (pull-through cache) 543 - - Reduce external bandwidth costs 544 - - Faster pulls (cache locally) 545 546 - **Features:** 547 - - Configurable cache retention 548 - - Whitelist/blacklist registries 549 - - Statistics (cache hit rate, savings) 550 551 - ### Deployment Tools 552 553 - **One-click deployments:** 554 - - Deploy image to Kubernetes 555 - - Deploy to Docker Swarm 556 - - Deploy to AWS ECS/Fargate 557 - - Deploy to Fly.io, Railway, Render 558 559 - **Deployment tracking:** 560 - - Track where images are deployed 561 - - Show running versions (which environments use which tags) 562 - - Notify on new deployments 563 564 - ### Image Recommendations 565 566 - **ML-based recommendations:** 567 - - "Similar images" (based on layers, packages, tags) 568 - - "People who pulled this also pulled..." (collaborative filtering) 569 - - "Recommended for you" (personalized based on history) 570 571 - ### Gamification 572 - 573 - **Achievements:** 574 - - Badges for milestones (first push, 100 pulls, 1GB storage, etc.) 575 - - Leaderboards (most popular images, most active sailors) 576 - - Community contributions (points for helping others) 577 - 578 - ### Advanced Search 579 - 580 - **Semantic search:** 581 - - Search by description, README, labels 582 - - Natural language queries ("show me nginx images with SSL") 583 - - AI-powered search (GPT-based understanding) 584 - 585 - **Saved searches:** 586 - - Save frequently used queries 587 - - Subscribe to search results (get notified of new matches) 588 - - Share searches with team 589 - 590 - ## Implementation Priority 591 - 592 - If implementing these features, suggested priority order: 593 - 594 - **High Priority (Next 6 months):** 595 - 1. Multi-architecture image support 596 - 2. Vulnerability scanning integration 597 - 3. Hold management dashboard 598 - 4. Enhanced search and filtering 599 - 5. Webhooks for CI/CD integration 600 - 601 - **Medium Priority (6-12 months):** 602 1. Team/organization accounts 603 - 2. Repository-level permissions 604 - 3. Image signing and verification 605 - 4. Pull analytics and monitoring 606 - 5. API documentation and SDKs 607 - 608 - **Low Priority (12+ months):** 609 - 1. Enterprise features (SSO, compliance, SLA) 610 2. Image build service 611 3. Registry mirroring 612 - 4. Mobile apps 613 - 5. ML-based recommendations 614 615 - **Research/Experimental:** 616 1. Private repositories (requires ATProto private records) 617 2. Federated timeline (requires ATProto feed infrastructure) 618 - 3. Deployment tools integration 619 - 4. Semantic search 620 621 --- 622 623 **Note:** This is a living document. Features may be added, removed, or reprioritized based on user feedback, technical feasibility, and ATProto ecosystem evolution.
··· 1 + # ATCR UI - Feature Roadmap 2 3 + This document tracks the status of ATCR features beyond the V1 MVP. Features are marked with their current status: 4 + 5 + - **DONE** — Fully implemented and shipping 6 + - **PARTIAL** — Some parts implemented 7 + - **BACKEND ONLY** — Backend exists, no UI yet 8 + - **NOT STARTED** — Future work 9 + - **BLOCKED** — Waiting on external dependency 10 + 11 + --- 12 + 13 + ## What's Already Built (not in original roadmap) 14 + 15 + These features were implemented but weren't in the original future features list: 16 + 17 + | Feature | Location | Notes | 18 + |---------|----------|-------| 19 + | **Billing (Stripe)** | `pkg/hold/billing/` | Checkout sessions, customer portal, subscription webhooks, tier upgrades. Build with `-tags billing`. | 20 + | **Garbage collection** | `pkg/hold/gc/` | Mark-and-sweep for orphaned blobs. Preview (dry-run) and execute modes. Triggered from hold admin UI. | 21 + | **libSQL embedded replicas** | AppView + Hold | Sync to Turso, Bunny DB, or self-hosted libsql-server. Configurable sync interval. | 22 + | **Hold successor/migration** | `pkg/hold/` | Promote a hold as successor to migrate users to new storage. | 23 + | **Relay management** | Hold admin | Manage firehose relay connections from admin panel. | 24 + | **Data export** | `pkg/appview/handlers/export.go` | GDPR-compliant export of all user data from AppView + all holds where user is member/captain. | 25 + | **Dark/light mode** | AppView UI | System preference detection, toggle, localStorage persistence. | 26 + | **Credential helper install page** | `/install` | Install scripts for macOS/Linux/Windows, version API. | 27 + | **Stars** | AppView UI | Star/unstar repos stored as `io.atcr.star` ATProto records, counts displayed. | 28 + 29 + --- 30 31 ## Advanced Image Management 32 33 + ### Multi-Architecture Image Support — DONE (display) / NOT STARTED (creation) 34 35 + **Display image indexes — DONE:** 36 + - Show when a tag points to an image index (multi-arch manifest) — `IsMultiArch` flag, "Multi-arch" badge 37 + - Display all architectures/platforms in the index — platform badges (e.g., linux/amd64, linux/arm64) 38 - Allow viewing individual manifests within the index 39 + - Show platform-specific details 40 41 + **Image index creation — NOT STARTED:** 42 - UI for combining multiple single-arch manifests into an image index 43 - Automatic platform detection from manifest metadata 44 - Validate that all manifests are for the same image (different platforms) 45 46 + ### Layer Inspection & Visualization — NOT STARTED 47 + 48 + DB stores layer metadata (digest, size, media type, layer index) but there's no UI for any of this. 49 50 **Layer details page:** 51 - Show Dockerfile command that created each layer (if available in history) ··· 58 - Calculate storage savings from layer sharing 59 - Identify duplicate layers with different digests (potential optimization) 60 61 + ### Image Operations — PARTIAL (delete only) 62 63 + **Tag/manifest deletion — DONE:** 64 + - Delete tags with `DeleteTagHandler` (cascade + confirmation modal) 65 + - Delete manifests with `DeleteManifestHandler` (handles tagged manifests gracefully) 66 67 + **Tag Management — NOT STARTED:** 68 + - Tag promotion workflow (dev → staging → prod) 69 + - Tag aliases (multiple tags → same digest) 70 + - Tag patterns (auto-tag based on git commit, semantic version, date) 71 + - Tag protection (mark tags as immutable) 72 + 73 + **Image Copying — NOT STARTED:** 74 - Copy image from one repository to another 75 - Copy image from another user's repository (fork) 76 + - Bulk copy operations 77 78 + **Image History — NOT STARTED:** 79 + - Timeline view of tag changes 80 + - Rollback functionality 81 + - Audit log of image operations 82 83 + ### Vulnerability Scanning — DONE (backend) / NOT STARTED (UI) 84 85 + **Backend — DONE:** 86 + - Separate scanner service (`scanner/` module) with Syft (SBOM) + Grype (vulnerabilities) 87 + - WebSocket-based job queue connecting scanner to hold service 88 + - Priority queue with tier-based scheduling (quartermaster > bosun > deckhand) 89 + - Scan results stored as ORAS artifacts in S3, referenced in hold PDS 90 + - Automatic scanning dispatched by hold on manifest push 91 + - See `docs/SBOM_SCANNING.md` 92 93 + **AppView UI — NOT STARTED:** 94 - Display CVE count by severity (critical, high, medium, low) 95 - Show detailed CVE information (description, CVSS score, affected packages) 96 - Filter images by vulnerability status 97 - Subscribe to CVE notifications for your images 98 - Compare vulnerability status across tags/versions 99 100 + ### Image Signing & Verification — NOT STARTED 101 102 + Concept doc exists at `docs/SIGNATURE_INTEGRATION.md` but no implementation. 103 104 + - Sign images 105 + - Display signature verification status 106 + - Display signature metadata 107 - Require signatures for protected repositories 108 109 + ### SBOM (Software Bill of Materials) — DONE (backend) / NOT STARTED (UI) 110 111 + **Backend — DONE:** 112 + - Syft generates SPDX JSON format SBOMs 113 + - Stored as ORAS artifacts (referenced via `artifactType: "application/spdx+json"`) 114 + - Blobs in S3, metadata in hold's PDS 115 + - Accessible via ORAS CLI and hold XRPC endpoints 116 + 117 + **UI — NOT STARTED:** 118 - Display package list from SBOM 119 - Show license information 120 - Link to upstream package sources 121 + - Compare SBOMs across versions 122 123 + --- 124 125 + ## Hold Management Dashboard — DONE (on hold admin panel) 126 127 + Hold management is implemented as a separate admin panel on the hold service itself (`pkg/hold/admin/`), not in the AppView UI. This makes sense architecturally — hold owners manage their own holds. 128 129 + ### Hold Discovery & Registration — PARTIAL 130 131 + **Hold registration — DONE:** 132 + - Automatic registration on hold startup (captain + crew records created in embedded PDS) 133 + - Auto-detection of region from cloud metadata 134 135 + **NOT STARTED:** 136 + - UI wizard for deploying hold service 137 + - One-click deployment to cloud platforms 138 + - Configuration generator 139 + - Test connectivity UI 140 141 + ### Hold Configuration — DONE (admin panel) 142 + 143 + **Hold settings — DONE (hold admin):** 144 - Toggle public/private flag 145 + - Toggle allow-all-crew 146 + - Toggle Bluesky post announcements 147 + - Set successor hold DID for migration 148 + - Writes changes back to YAML config file 149 150 + **Storage config — YAML-only:** 151 + - S3 credentials, region, bucket, endpoint, CDN pull zone all configured via YAML 152 + - No UI for editing S3 credentials or rotating keys 153 154 + **Quotas — DONE (read-only UI):** 155 + - Tier-based limits (deckhand 5GB, bosun 50GB, quartermaster 100GB) 156 + - Per-user quota tracking and display in admin 157 + - Not editable via UI (requires YAML change) 158 159 + **NOT STARTED:** 160 + - Retention policies (auto-delete old blobs) 161 + - Hold service log viewer 162 163 + ### Crew Management — DONE (hold admin panel) 164 + 165 + **Implemented in `pkg/hold/admin/handlers_crew.go`:** 166 + - Add crew by DID with role, permissions (`blob:read`, `blob:write`, `crew:admin`), and tier 167 + - Crew list showing handle, role, permissions, tier, usage, quota 168 + - Edit crew permissions and tier 169 - Remove crew members 170 + - Bulk JSON import/export with deduplication (`handlers_crew_io.go`) 171 172 + **NOT STARTED:** 173 + - Invitation links (OAuth-based, currently must know DID) 174 + - Invite by handle (currently DID-only) 175 + - Crew request workflow (users can't self-request access) 176 + - Approval/rejection flow 177 178 + ### Hold Analytics — PARTIAL 179 180 + **Storage metrics — DONE (hold admin):** 181 + - Total blobs, total size, unique digests 182 + - Per-user quota stats (total size, blob count) 183 + - Top users by storage (lazy-loaded HTMX partial) 184 + - Crew count and tier distribution 185 186 + **NOT STARTED:** 187 + - Access metrics (downloads, pulls, bandwidth) 188 + - Growth over time charts 189 + - Cost estimation 190 + - Geographic distribution 191 + - Access logs 192 193 + --- 194 195 ## Discovery & Social Features 196 197 + ### Federated Browse & Search — PARTIAL 198 199 + **Basic search — DONE:** 200 + - Full-text search across handles, DIDs, repo names, and annotations 201 + - Search UI with HTMX lazy loading and pagination 202 + - Navigation bar search component 203 + 204 + **NOT STARTED:** 205 - Filter by user, hold, architecture, date range 206 - Sort by popularity, recency, size 207 + - Advanced query syntax 208 + - Popular/trending images 209 + - Categories and user-defined tags 210 211 + ### Sailor Profiles — PARTIAL 212 213 + **Public profile page — DONE:** 214 + - `/u/{handle}` shows user's avatar, handle, DID, and all public repositories 215 + - OpenGraph meta tags and JSON-LD structured data 216 217 + **NOT STARTED:** 218 + - Bio/description field 219 + - Website links 220 - Statistics (total images, total pulls, joined date) 221 + - Pinned/featured repositories 222 223 + ### Social Features — PARTIAL (stars only) 224 225 + **Stars — DONE:** 226 + - Star/unstar repositories stored as `io.atcr.star` ATProto records 227 + - Star counts displayed on repository pages 228 229 + **NOT STARTED:** 230 + - Follow other sailors 231 + - Comment on images 232 + - Like/upvote images 233 + - Activity feed 234 + - Federated timeline / custom feeds 235 + - Sharing to Bluesky/ATProto social apps 236 237 + --- 238 239 ## Access Control & Permissions 240 241 + ### Hold-Level Access Control — DONE 242 243 + - Public/private hold toggle (admin UI + OCI enforcement) 244 + - Crew permissions: `blob:read`, `blob:write`, `crew:admin` 245 + - `blob:write` implicitly grants `blob:read` 246 + - Captain has all permissions implicitly 247 + - See `docs/BYOS.md` 248 249 + ### Repository-Level Permissions — BLOCKED 250 251 + - **Private repositories blocked by ATProto** — no private records support yet 252 + - Repository-level permissions, collaborator invites, read-only tokens all depend on this 253 + - May require proxy layer or encrypted blobs when ATProto adds private record support 254 255 + ### Team/Organization Accounts — NOT STARTED 256 257 + - Organization accounts, RBAC, SSO, audit logs 258 + - Likely a later-stage feature 259 260 + --- 261 262 ## Analytics & Monitoring 263 264 + ### Dashboard — PARTIAL 265 266 + **Hold dashboard — DONE (hold admin):** 267 + - Storage usage, crew count, tier distribution 268 269 + **Personal dashboard — NOT STARTED:** 270 + - Overview of your images, holds, activity 271 + - Quick stats, recent activity, alerts 272 273 + ### Pull Analytics — NOT STARTED 274 275 - Pull count per image/tag 276 + - Pull count by client, geography, over time 277 + - User analytics (authenticated vs anonymous) 278 279 + ### Alerts & Notifications — NOT STARTED 280 281 + - Alert types (quota exceeded, vulnerability detected, hold down, etc.) 282 + - Notification channels (email, webhook, ATProto, Slack/Discord) 283 284 + --- 285 286 ## Developer Tools & Integrations 287 288 + ### Credential Helper — DONE 289 290 + - Install page at `/install` with shell scripts 291 + - Version API endpoint for automatic updates 292 293 + ### API Documentation — NOT STARTED 294 295 + - Swagger/OpenAPI specs 296 + - Interactive API explorer 297 + - Code examples, SDKs 298 299 + ### Webhooks — NOT STARTED 300 301 + - Repository-level webhook registration 302 + - Events: manifest.pushed, tag.created, scan.completed, etc. 303 + - Test, retry, delivery history 304 305 + ### CI/CD Integration — NOT STARTED 306 307 + - GitHub Actions, GitLab CI, CircleCI example workflows 308 + - Pre-built actions/plugins 309 + - Build status badges 310 311 + ### Infrastructure as Code — PARTIAL 312 313 + **DONE:** 314 + - Custom UpCloud deployment tool (`deploy/upcloud/`) with Go-based provisioning, cloud-init, systemd, config templates 315 + - Docker Compose for dev and production 316 317 + **NOT STARTED:** 318 + - Terraform modules 319 + - Helm charts 320 + - Kubernetes manifests (only an example verification webhook exists) 321 + - GitOps integrations (ArgoCD, FluxCD) 322 323 + --- 324 325 + ## Documentation & Onboarding — PARTIAL 326 327 + **DONE:** 328 + - Install page with credential helper setup 329 + - Learn more page 330 + - Internal developer docs (`docs/`) 331 332 + **NOT STARTED:** 333 + - Interactive onboarding wizard 334 + - Product tour / tooltips 335 - Help center with FAQs 336 + - Video tutorials 337 + - Comprehensive user-facing documentation site 338 339 + --- 340 341 ## Advanced ATProto Integration 342 343 + ### Data Export — DONE 344 345 + - GDPR-compliant data export (`ExportUserDataHandler`) 346 + - Fetches data from AppView DB + all holds where user is member/captain 347 348 + ### Record Viewer — NOT STARTED 349 350 + - Browse `io.atcr.*` records with raw JSON view 351 + - Record history, diff viewer 352 + - ATP URI links 353 354 + ### PDS Integration — NOT STARTED 355 356 + - Multi-PDS support, PDS health monitoring 357 + - PDS migration tools 358 + - "Verify on PDS" button 359 360 + ### Federation — NOT STARTED 361 362 + - Cross-AppView image pulls 363 + - AppView discovery 364 + - Federated search 365 366 + --- 367 368 ## UI/UX Enhancements 369 370 + ### Theming — PARTIAL 371 372 + **DONE:** 373 + - Light/dark mode with system preference detection and toggle 374 + - Responsive design (Tailwind/DaisyUI, mobile-friendly) 375 + - PWA manifest with icons (no service worker yet) 376 + 377 + **NOT STARTED:** 378 + - Custom themes 379 + - WCAG 2.1 AA accessibility audit 380 - High contrast mode 381 + - Internationalization (i18n) 382 + - Native mobile apps 383 384 + ### Performance — PARTIAL 385 386 + **DONE:** 387 + - HTMX lazy loading for data-heavy partials 388 + - Efficient server-side rendering 389 390 + **NOT STARTED:** 391 + - Service worker for offline caching 392 - Virtual scrolling for large lists 393 + - GraphQL API 394 + - Real-time WebSocket updates in UI 395 396 + --- 397 398 + ## Enterprise Features — NOT STARTED (except billing) 399 400 + ### Billing — DONE 401 402 + - Stripe integration (`pkg/hold/billing/`, requires `-tags billing` build tag) 403 + - Checkout sessions, customer portal, subscription webhooks 404 + - Tier upgrades/downgrades 405 406 + ### Everything Else — NOT STARTED 407 408 + - Organization accounts with SSO (SAML, OIDC) 409 + - RBAC, audit logs for compliance 410 + - SOC 2, HIPAA, GDPR compliance tooling (data export exists, see above) 411 + - Image scanning policy enforcement 412 + - Paid tier SLAs 413 414 + --- 415 416 + ## Miscellaneous Ideas — NOT STARTED 417 418 + These remain future ideas with no implementation: 419 420 + - **Image build service** — Cloud-based Dockerfile builds 421 + - **Registry mirroring** — Pull-through cache for Docker Hub, ghcr.io, etc. 422 + - **Deployment tools** — One-click deploy to K8s, ECS, Fly.io 423 + - **Image recommendations** — ML-based "similar images" and "people also pulled" 424 + - **Gamification** — Achievement badges, leaderboards 425 + - **Advanced search** — Semantic/AI-powered search, saved searches 426 427 + --- 428 429 + ## Updated Priority List 430 431 + **Already done (was "High Priority"):** 432 + 1. ~~Multi-architecture image support~~ — display working 433 + 2. ~~Vulnerability scanning integration~~ — backend complete 434 + 3. ~~Hold management dashboard~~ — implemented on hold admin panel 435 + 4. ~~Basic search~~ — working 436 437 + **Remaining high priority:** 438 + 1. Scan results UI in AppView (backend exists, just needs frontend) 439 + 2. SBOM display UI in AppView (backend exists, just needs frontend) 440 + 3. Webhooks for CI/CD integration 441 + 4. Enhanced search (filters, sorting, advanced queries) 442 + 5. Richer sailor profiles (bio, stats, pinned repos) 443 444 + **Medium priority:** 445 + 1. Layer inspection UI 446 + 2. Pull analytics and monitoring 447 + 3. API documentation (Swagger/OpenAPI) 448 + 4. Tag management (promotion, protection, aliases) 449 + 5. Onboarding wizard / getting started guide 450 451 + **Low priority / long-term:** 452 1. Team/organization accounts 453 2. Image build service 454 3. Registry mirroring 455 + 4. Federation features 456 + 5. Internationalization 457 458 + **Blocked on external dependencies:** 459 1. Private repositories (requires ATProto private records) 460 2. Federated timeline (requires ATProto feed infrastructure) 461 462 --- 463 464 **Note:** This is a living document. Features may be added, removed, or reprioritized based on user feedback, technical feasibility, and ATProto ecosystem evolution. 465 + 466 + *Last audited: 2026-02-12*
docs/DISTRIBUTION.md docs/CREDENTIAL_HELPER.md
+480
docs/REMOVING_DISTRIBUTION.md
···
··· 1 + # Removing distribution/distribution 2 + 3 + This document analyzes what it would take to remove the `github.com/distribution/distribution/v3` library and implement ATCR's own OCI Distribution Spec HTTP endpoints. 4 + 5 + ## Why Consider Removing It 6 + 7 + 1. **Impedance mismatch** -- Distribution assumes manifests and blobs live in the same storage backend. ATCR routes manifests to ATProto PDS and blobs to hold/S3. Every storage interface is overridden. 8 + 2. **Context value workaround** -- `Repository()` receives only `context.Context` from distribution's interface, forcing auth/identity data through context keys into `RegistryContext`. 9 + 3. **Per-request repository creation** -- `RoutingRepository` is recreated on every request because distribution's caching assumptions conflict with ATCR's OAuth session model. 10 + 4. **Stale transitive dependencies** -- Distribution pulls in AWS SDK v1 (EOL) via its S3 storage driver, even though ATCR doesn't use that driver. 11 + 5. **Unused features** -- GC, notifications, storage drivers, replication -- none are used. ATCR has its own GC, its own event dispatch (`processManifest` XRPC), and its own S3 integration. 12 + 6. **Upstream maintenance pace** -- Slow to merge dependency updates and bug fixes. 13 + 14 + ## What Distribution Currently Provides 15 + 16 + Only these pieces are actually used: 17 + 18 + | What | Distribution Package | ATCR Usage | 19 + |------|---------------------|------------| 20 + | HTTP endpoint routing | `registry/handlers` | `handlers.NewApp()` creates the `/v2/` handler | 21 + | OCI error responses | `registry/api/errcode` | `ErrorCodeUnauthorized`, `ErrorCodeDenied`, `ErrorCodeUnsupported` | 22 + | Middleware registration | `registry/middleware/registry` | `Register("atproto-resolver", ...)` | 23 + | Repository interface | `distribution` (root) | `Repository`, `ManifestService`, `BlobStore`, `TagService` | 24 + | Reference parsing | `distribution/reference` | `reference.Named` for `identity/image` parsing | 25 + | Token auth | `registry/auth/token` | Blank import for registration | 26 + | In-memory driver | `registry/storage/driver/inmemory` | Blank import; placeholder since real storage is external | 27 + | Configuration types | `configuration` | `configuration.Configuration` struct | 28 + 29 + Everything else (S3 driver, GC, notifications, replication, schema validation) is dead weight. 30 + 31 + ## Files That Import Distribution 32 + 33 + All in `pkg/appview/` -- hold and scanner are unaffected. 34 + 35 + **Core implementation (8 files):** 36 + - `storage/routing_repository.go` -- `distribution.Repository` wrapper 37 + - `storage/manifest_store.go` -- `distribution.ManifestService` impl 38 + - `storage/proxy_blob_store.go` -- `distribution.BlobStore` + `BlobWriter` impl 39 + - `storage/tag_store.go` -- `distribution.TagService` impl 40 + - `middleware/registry.go` -- `distribution.Namespace` + middleware registration 41 + - `config.go` -- Builds `configuration.Configuration` 42 + - `server.go` -- `handlers.NewApp()`, `errcode` for error responses 43 + - `cmd/appview/main.go` -- Blank imports for driver/auth registration 44 + 45 + **Tests (6 files):** 46 + - `storage/routing_repository_test.go` 47 + - `storage/manifest_store_test.go` 48 + - `storage/proxy_blob_store_test.go` 49 + - `storage/tag_store_test.go` 50 + - `middleware/registry_test.go` 51 + 52 + ## OCI Distribution Spec Endpoints to Implement 53 + 54 + The spec defines these HTTP endpoints. ATCR would need handlers for each. 55 + 56 + ### Version Check 57 + 58 + ``` 59 + GET /v2/ 60 + 200 OK (confirms OCI compliance) 61 + 401 Unauthorized (triggers auth flow) 62 + ``` 63 + 64 + Docker clients hit this first. Must return 200 for authenticated requests. A 401 response with `WWW-Authenticate` header triggers the Docker auth handshake. 65 + 66 + ### Manifests 67 + 68 + ``` 69 + GET /v2/<name>/manifests/<reference> -> 200 + manifest body 70 + HEAD /v2/<name>/manifests/<reference> -> 200 + headers only 71 + PUT /v2/<name>/manifests/<reference> -> 201 Created 72 + DELETE /v2/<name>/manifests/<reference> -> 202 Accepted 73 + ``` 74 + 75 + `<reference>` is either a tag (`latest`) or digest (`sha256:abc...`). 76 + 77 + **Required headers:** 78 + - Request `Accept`: manifest media types the client supports 79 + - Response `Content-Type`: actual manifest media type 80 + - Response `Docker-Content-Digest`: canonical digest of manifest 81 + 82 + **Media types to support:** 83 + - `application/vnd.oci.image.manifest.v1+json` 84 + - `application/vnd.oci.image.index.v1+json` 85 + - `application/vnd.docker.distribution.manifest.v2+json` 86 + - `application/vnd.docker.distribution.manifest.list.v2+json` 87 + 88 + ### Blobs 89 + 90 + ``` 91 + GET /v2/<name>/blobs/<digest> -> 200 + blob body (or 307 redirect) 92 + HEAD /v2/<name>/blobs/<digest> -> 200 + headers only 93 + DELETE /v2/<name>/blobs/<digest> -> 202 Accepted 94 + ``` 95 + 96 + ATCR already redirects to presigned S3 URLs via `ServeBlob()` -- this would become a direct 307 redirect in the handler. 97 + 98 + ### Blob Uploads (Chunked/Resumable) 99 + 100 + **Initiate:** 101 + ``` 102 + POST /v2/<name>/blobs/uploads/ 103 + 202 Accepted 104 + Location: /v2/<name>/blobs/uploads/<uuid> 105 + ``` 106 + 107 + **Monolithic (single request):** 108 + ``` 109 + POST /v2/<name>/blobs/uploads/?digest=sha256:... 110 + Content-Type: application/octet-stream 111 + Body: <entire blob> 112 + 201 Created 113 + ``` 114 + 115 + **Chunked:** 116 + ``` 117 + PATCH /v2/<name>/blobs/uploads/<uuid> 118 + Content-Type: application/octet-stream 119 + Content-Range: <start>-<end> 120 + Body: <chunk data> 121 + 202 Accepted 122 + Range: 0-<end> 123 + 124 + (repeat PATCH for each chunk) 125 + 126 + PUT /v2/<name>/blobs/uploads/<uuid>?digest=sha256:... 127 + 201 Created 128 + Location: /v2/<name>/blobs/<digest> 129 + ``` 130 + 131 + **Check progress:** 132 + ``` 133 + GET /v2/<name>/blobs/uploads/<uuid> 134 + 204 No Content 135 + Range: 0-<bytes received> 136 + ``` 137 + 138 + **Cancel:** 139 + ``` 140 + DELETE /v2/<name>/blobs/uploads/<uuid> 141 + 204 No Content 142 + ``` 143 + 144 + **Cross-repo mount:** 145 + ``` 146 + POST /v2/<name>/blobs/uploads/?mount=<digest>&from=<other-repo> 147 + 201 Created (if blob exists in source repo) 148 + 202 Accepted (fall back to regular upload) 149 + ``` 150 + 151 + ### Tags 152 + 153 + ``` 154 + GET /v2/<name>/tags/list 155 + 200 OK 156 + { 157 + "name": "<name>", 158 + "tags": ["latest", "v1.0"] 159 + } 160 + ``` 161 + 162 + Supports pagination via `n` (count) and `last` (cursor) query params. 163 + 164 + ### Referrers (OCI v1.1) 165 + 166 + ``` 167 + GET /v2/<name>/referrers/<digest> 168 + 200 OK 169 + Content-Type: application/vnd.oci.image.index.v1+json 170 + Body: image index of referring manifests 171 + ``` 172 + 173 + Supports `artifactType` query filter. Returns manifests whose `subject` field points to the given digest. 174 + 175 + ### Catalog (Optional) 176 + 177 + ``` 178 + GET /v2/_catalog 179 + 200 OK 180 + { "repositories": ["alice/app", "bob/tool"] } 181 + ``` 182 + 183 + Pagination via `n` and `last`. ATCR may choose not to implement this (many registries don't). 184 + 185 + ## Error Response Format 186 + 187 + All 4xx/5xx responses must use the OCI error envelope: 188 + 189 + ```json 190 + { 191 + "errors": [ 192 + { 193 + "code": "MANIFEST_UNKNOWN", 194 + "message": "manifest not found", 195 + "detail": { "tag": "latest" } 196 + } 197 + ] 198 + } 199 + ``` 200 + 201 + **Standard error codes:** 202 + 203 + | Code | HTTP Status | Meaning | 204 + |------|-------------|---------| 205 + | `BLOB_UNKNOWN` | 404 | Blob not found | 206 + | `BLOB_UPLOAD_INVALID` | 400 | Bad digest or size mismatch | 207 + | `BLOB_UPLOAD_UNKNOWN` | 404 | Upload session expired/missing | 208 + | `DIGEST_INVALID` | 400 | Digest doesn't match content | 209 + | `MANIFEST_BLOB_UNKNOWN` | 404 | Manifest references missing blob | 210 + | `MANIFEST_INVALID` | 400 | Malformed manifest | 211 + | `MANIFEST_UNKNOWN` | 404 | Manifest not found | 212 + | `NAME_INVALID` | 400 | Bad repository name | 213 + | `NAME_UNKNOWN` | 404 | Repository doesn't exist | 214 + | `SIZE_INVALID` | 400 | Content-Length mismatch | 215 + | `UNAUTHORIZED` | 401 | Authentication required | 216 + | `DENIED` | 403 | Permission denied | 217 + | `UNSUPPORTED` | 405 | Operation not supported | 218 + | `TOOMANYREQUESTS` | 429 | Rate limited | 219 + 220 + ## What Exists Today vs What's New 221 + 222 + For each handler, this breaks down what logic already exists in the storage layer (and just needs to be called) vs what new HTTP glue code must be written. Distribution's handler layer currently handles all the HTTP parsing, header validation, content negotiation, and response formatting -- all of that becomes our responsibility. 223 + 224 + ### Shared New Code 225 + 226 + **Error helpers (~50 lines, new):** 227 + OCI error envelope formatting. Currently provided by `errcode.ErrorCodeUnauthorized` etc. 228 + 229 + ```go 230 + type RegistryError struct { 231 + Code string `json:"code"` 232 + Message string `json:"message"` 233 + Detail interface{} `json:"detail,omitempty"` 234 + } 235 + 236 + func WriteError(w http.ResponseWriter, status int, code, message string) { 237 + w.Header().Set("Content-Type", "application/json") 238 + w.WriteHeader(status) 239 + json.NewEncoder(w).Encode(struct { 240 + Errors []RegistryError `json:"errors"` 241 + }{Errors: []RegistryError{{Code: code, Message: message}}}) 242 + } 243 + ``` 244 + 245 + **Auth middleware (~80 lines, mostly exists):** 246 + `ExtractAuthMethod()` already exists in `middleware/registry.go`. Needs adaptation to work standalone (currently wraps distribution's app). Must also generate `WWW-Authenticate` header for 401 responses -- distribution's token auth handler currently does this via blank import of `registry/auth/token`. 247 + 248 + **Identity resolution middleware (~250 lines, exists):** 249 + `NamespaceResolver.Repository()` in `middleware/registry.go` does identity resolution, hold discovery, service token acquisition, and ATProto client creation. This logic moves into an HTTP middleware but the code is the same -- resolves DID, finds hold, gets service token, builds `RegistryContext`. The validation cache (concurrent service token deduplication) comes along as-is. 250 + 251 + **Router (~30 lines, new):** 252 + ```go 253 + mux.HandleFunc("GET /v2/", handleVersionCheck) 254 + mux.HandleFunc("GET /v2/{name...}/manifests/{reference}", handleManifestGet) 255 + // ... etc 256 + ``` 257 + 258 + ### Handler-by-Handler Breakdown 259 + 260 + --- 261 + 262 + **`handleVersionCheck`** -- `GET /v2/` 263 + 264 + | | | 265 + |---|---| 266 + | Existing logic | None needed -- this is just a 200 OK response | 267 + | New code | ~10 lines. Return 200 with `Docker-Distribution-API-Version: registry/2.0` header. If unauthenticated, return 401 with `WWW-Authenticate` header to trigger Docker's auth flow | 268 + 269 + --- 270 + 271 + **`handleManifestGet`** -- `GET /v2/<name>/manifests/<reference>` 272 + 273 + | | | 274 + |---|---| 275 + | Existing logic | `ManifestStore.Get()` fetches manifest from PDS (record lookup, optional blob download for new-format records). Returns media type + raw bytes. Also fires async pull notification to hold for stats. `TagStore.Get()` resolves tag → digest when reference is a tag | 276 + | New code (~40 lines) | Parse `<reference>` to determine tag vs digest. If tag, call `TagStore.Get()` first to resolve digest. Call `ManifestStore.Get()`. Set response headers: `Content-Type` (manifest media type), `Docker-Content-Digest` (canonical digest), `Content-Length`. Write body. Handle 404 (manifest not found → `MANIFEST_UNKNOWN` error) | 277 + | Subtle | Content negotiation: must check client's `Accept` header against the manifest's actual media type. Distribution handles this transparently. If client doesn't accept the type, return 404. In practice most clients accept everything, but `crane` and `skopeo` can be picky | 278 + 279 + --- 280 + 281 + **`handleManifestHead`** -- `HEAD /v2/<name>/manifests/<reference>` 282 + 283 + | | | 284 + |---|---| 285 + | Existing logic | `ManifestStore.Exists()` checks PDS record existence. `ManifestStore.Get()` needed for full headers | 286 + | New code (~30 lines) | Same as GET but write headers only, no body. Needs `Content-Type`, `Docker-Content-Digest`, `Content-Length`. Could call `Exists()` for a fast path and `Get()` for full header population, or just call `Get()` and skip the body write | 287 + | Note | Some clients (Docker) use HEAD to check existence before pulling. Must return same headers as GET | 288 + 289 + --- 290 + 291 + **`handleManifestPut`** -- `PUT /v2/<name>/manifests/<reference>` 292 + 293 + | | | 294 + |---|---| 295 + | Existing logic | `ManifestStore.Put()` does a LOT: calculates digest, uploads manifest bytes as blob to PDS, creates `ManifestRecord` with structured metadata, validates manifest list child references, extracts config labels, fetches README/icon, creates tag record, fires async notifications to hold, creates repo page records, handles successor migration | 296 + | New code (~50 lines) | Read request body. Extract `Content-Type` header as media type. Parse `<reference>` to determine if this is a tag push. Call `ManifestStore.Put()` with payload, media type, and optional tag. Set response headers: `Location` (`/v2/<name>/manifests/<digest>`), `Docker-Content-Digest`. Return 201 Created. Handle errors: `MANIFEST_INVALID` (bad JSON), `MANIFEST_BLOB_UNKNOWN` (missing child manifest in manifest list) | 297 + | Subtle | Distribution currently wraps the manifest in a `distribution.Manifest` interface (with `Payload()` and `References()` methods) before passing to `Put()`. Without distribution, we'd change `Put()` to accept raw `[]byte` + `mediaType` + optional tag directly -- simpler but requires updating the method signature and its internals | 298 + 299 + --- 300 + 301 + **`handleManifestDelete`** -- `DELETE /v2/<name>/manifests/<reference>` 302 + 303 + | | | 304 + |---|---| 305 + | Existing logic | `ManifestStore.Delete()` calls `ATProtoClient.DeleteRecord()` | 306 + | New code (~15 lines) | Parse digest from `<reference>`. Call `ManifestStore.Delete()`. Return 202 Accepted. Handle 404 | 307 + 308 + --- 309 + 310 + **`handleBlobGet`** -- `GET /v2/<name>/blobs/<digest>` 311 + 312 + | | | 313 + |---|---| 314 + | Existing logic | `ProxyBlobStore.ServeBlob()` checks read access, gets presigned URL from hold, and issues 307 redirect. This is already essentially an HTTP handler | 315 + | New code (~20 lines) | Parse digest from path. Call the presigned URL logic (read access check + hold XRPC call). Write 307 redirect with `Location` header pointing to presigned S3 URL | 316 + | Note | `ServeBlob()` currently takes `http.ResponseWriter` and `*http.Request` -- it's already doing the HTTP work. This handler is mostly just calling it. Could almost be used as-is | 317 + 318 + --- 319 + 320 + **`handleBlobHead`** -- `HEAD /v2/<name>/blobs/<digest>` 321 + 322 + | | | 323 + |---|---| 324 + | Existing logic | `ProxyBlobStore.Stat()` checks read access, gets presigned HEAD URL, makes HEAD request to S3, returns size | 325 + | New code (~20 lines) | Parse digest. Call `Stat()`. Set `Content-Length`, `Docker-Content-Digest`, `Content-Type: application/octet-stream`. Return 200. Handle 404 (`BLOB_UNKNOWN`) | 326 + 327 + --- 328 + 329 + **`handleBlobUploadInit`** -- `POST /v2/<name>/blobs/uploads/` 330 + 331 + | | | 332 + |---|---| 333 + | Existing logic | `ProxyBlobStore.Create()` checks write access, generates upload ID, calls `startMultipartUpload()` XRPC to hold, creates `ProxyBlobWriter`, stores in `globalUploads` map | 334 + | New code (~50 lines) | Check for `?mount=<digest>&from=<repo>` query params (cross-repo mount). Check for `?digest=<digest>` (monolithic upload -- read body, write to store, complete in one shot). Otherwise, call `Create()` to start a new upload session. Return 202 Accepted with `Location: /v2/<name>/blobs/uploads/<uuid>` header, `Docker-Upload-UUID` header | 335 + | Subtle | Monolithic upload (single POST with digest and body) is a shortcut some clients use. Distribution handles this transparently. We'd need to handle it explicitly: read body, create writer, write, commit. Cross-repo mount is also handled here -- check if blob exists in source repo, skip upload if so | 336 + 337 + --- 338 + 339 + **`handleBlobUploadChunk`** -- `PATCH /v2/<name>/blobs/uploads/<uuid>` 340 + 341 + | | | 342 + |---|---| 343 + | Existing logic | `ProxyBlobWriter.Write()` buffers data and auto-flushes 10MB chunks to S3 via presigned URLs. `flushPart()` handles the XRPC call to hold for part upload URLs and ETag tracking | 344 + | New code (~40 lines) | Look up writer from `globalUploads` by UUID. Parse `Content-Range` header (format: `<start>-<end>`). Read request body. Call `writer.Write(body)`. Return 202 Accepted with `Location` header (same upload URL), `Range: 0-<total bytes received>` header. Handle missing upload (`BLOB_UPLOAD_UNKNOWN`) | 345 + | Subtle | `Content-Range` validation: must verify start offset matches current writer position (no gaps, no out-of-order). Return 416 Range Not Satisfiable if misaligned. Distribution handles this; we'd need to track and validate | 346 + 347 + --- 348 + 349 + **`handleBlobUploadComplete`** -- `PUT /v2/<name>/blobs/uploads/<uuid>?digest=sha256:...` 350 + 351 + | | | 352 + |---|---| 353 + | Existing logic | `ProxyBlobWriter.Commit()` flushes remaining buffer, calls `completeMultipartUpload()` XRPC to hold, removes writer from `globalUploads` | 354 + | New code (~40 lines) | Look up writer from `globalUploads`. Parse `?digest=` query param. If request has body, write it to the writer (final chunk can be in the PUT). Call `writer.Commit()` with digest descriptor. Return 201 Created with `Location: /v2/<name>/blobs/<digest>`, `Docker-Content-Digest` header. Handle errors: `DIGEST_INVALID` (provided digest doesn't match), `BLOB_UPLOAD_UNKNOWN` (expired session) | 355 + | Subtle | Digest validation: distribution verifies the provided digest matches what was actually uploaded. Our writer doesn't currently track a running digest hash -- `Commit()` just passes the digest through to hold. Need to decide: trust the hold to validate, or add client-side validation. Currently hold does the final validation since it has all the parts | 356 + 357 + --- 358 + 359 + **`handleBlobUploadStatus`** -- `GET /v2/<name>/blobs/uploads/<uuid>` 360 + 361 + | | | 362 + |---|---| 363 + | Existing logic | `ProxyBlobWriter.Size()` returns total bytes written | 364 + | New code (~15 lines) | Look up writer from `globalUploads`. Return 204 No Content with `Range: 0-<size - 1>`, `Docker-Upload-UUID`, `Location` headers. Handle missing upload | 365 + 366 + --- 367 + 368 + **`handleBlobUploadCancel`** -- `DELETE /v2/<name>/blobs/uploads/<uuid>` 369 + 370 + | | | 371 + |---|---| 372 + | Existing logic | `ProxyBlobWriter.Cancel()` calls `abortMultipartUpload()` XRPC to hold, removes from `globalUploads` | 373 + | New code (~15 lines) | Look up writer. Call `Cancel()`. Return 204 No Content. Handle missing upload | 374 + 375 + --- 376 + 377 + **`handleTagsList`** -- `GET /v2/<name>/tags/list` 378 + 379 + | | | 380 + |---|---| 381 + | Existing logic | `TagStore.All()` lists all tag records from PDS, filters by repository | 382 + | New code (~30 lines) | Call `TagStore.All()`. Parse `?n=` and `?last=` query params for pagination (slice the results). Return JSON: `{"name": "<name>", "tags": [...]}`. Set `Link` header for pagination if there are more results | 383 + | Note | Distribution handles pagination. We'd need to implement it ourselves -- sort tags, apply cursor, set Link header with next page URL | 384 + 385 + --- 386 + 387 + **`handleReferrers`** -- `GET /v2/<name>/referrers/<digest>` 388 + 389 + | | | 390 + |---|---| 391 + | Existing logic | Not currently implemented in ATCR's storage layer. Distribution may return an empty index | 392 + | New code (~30 lines) | Query manifests that have a `subject` field pointing to the given digest. Return an OCI image index containing descriptors for each referrer. Support `?artifactType=` filter. If no referrers, return empty index | 393 + | Note | This is new functionality either way. ATCR would need to query PDS for manifests with matching subject digests. Could defer this (return empty index) and implement properly later | 394 + 395 + --- 396 + 397 + ### Interface Changes to Storage Layer 398 + 399 + The existing stores would need their method signatures simplified. This is mostly mechanical -- removing distribution wrapper types: 400 + 401 + **ManifestStore changes:** 402 + - `Get()`: returns `(distribution.Manifest, error)` → returns `(mediaType string, payload []byte, err error)` 403 + - `Put()`: accepts `distribution.Manifest` + `...distribution.ManifestServiceOption` → accepts `payload []byte, mediaType string, tag string` 404 + - `Exists()` and `Delete()`: signatures stay roughly the same (just `digest.Digest` in, error out) 405 + - Remove `rawManifest` struct (wrapper implementing `distribution.Manifest` interface) 406 + - Remove `distribution.WithTagOption` extraction logic in `Put()` 407 + 408 + **ProxyBlobStore changes:** 409 + - `Stat()`: returns `distribution.Descriptor` → returns `(size int64, err error)` 410 + - `Get()`: stays the same (returns `[]byte`) 411 + - `ServeBlob()`: already takes `http.ResponseWriter`/`*http.Request` -- could become the handler itself 412 + - `Create()`: returns `distribution.BlobWriter` → returns `*ProxyBlobWriter` directly 413 + - `Resume()`: same change 414 + - Remove `distribution.BlobCreateOption` / `distribution.CreateOptions` parsing 415 + - `ProxyBlobWriter.Commit()`: accepts `distribution.Descriptor` → accepts `digest string, size int64` 416 + 417 + **TagStore changes:** 418 + - `Get()`: returns `distribution.Descriptor` → returns `(digest string, err error)` 419 + - `Tag()`: accepts `distribution.Descriptor` → accepts `digest string` 420 + - `All()`, `Untag()`, `Lookup()`: minimal changes 421 + 422 + **RoutingRepository:** 423 + - Removed entirely. Handlers call stores directly. The lazy initialization via `sync.Once` goes away since there's no interface requiring a `Repository` object. 424 + 425 + **Estimated interface change work:** ~150 lines changed across storage files + ~150 lines changed across test files. 426 + 427 + ## What Stays 428 + 429 + These dependencies are used directly and stay regardless: 430 + 431 + - `github.com/opencontainers/go-digest` -- Digest parsing/validation (standard, lightweight) 432 + - `github.com/opencontainers/image-spec` -- OCI manifest/index structs (optional but useful for validation) 433 + - `github.com/distribution/reference` -- Could stay (lightweight, no heavy transitive deps) or replace with string splitting since ATCR's name format is always `<identity>/<image>` 434 + 435 + ## Revised Effort Estimate 436 + 437 + | Component | New Lines | Changed Lines | Notes | 438 + |-----------|-----------|---------------|-------| 439 + | Router + version check | ~40 | 0 | Trivial | 440 + | Error helpers | ~50 | 0 | OCI error envelope, error code constants | 441 + | Auth middleware adaptation | ~30 | ~50 | `WWW-Authenticate` header generation is new; `ExtractAuthMethod` moves | 442 + | Identity resolution middleware | ~20 | ~30 | `NamespaceResolver.Repository()` logic moves to HTTP middleware; code is the same | 443 + | Manifest handlers (GET/HEAD/PUT/DELETE) | ~135 | 0 | Content negotiation, header writing, tag vs digest parsing | 444 + | Blob handlers (GET/HEAD/DELETE) | ~55 | 0 | Presigned URL redirect, stat, delete stub | 445 + | Blob upload handlers (POST/PATCH/PUT/GET/DELETE) | ~160 | 0 | Chunked upload protocol, Content-Range validation, monolithic upload, cross-repo mount | 446 + | Tags list handler | ~30 | 0 | Pagination logic | 447 + | Referrers handler | ~30 | 0 | Could defer with empty index | 448 + | Storage interface changes | 0 | ~150 | Remove distribution types from method signatures | 449 + | Test updates | 0 | ~150 | Update mocks and assertions for new signatures | 450 + | Config cleanup | 0 | ~80 | Remove `buildDistributionConfig()`, blank imports | 451 + | **Total** | **~550 new** | **~460 changed** | **~1010 lines total** | 452 + 453 + This is not a trivial migration. The ~550 new lines are genuine new HTTP handler code that doesn't exist today -- distribution's handler layer provides all of it currently. The changed lines are mostly mechanical (removing distribution type wrappers) but still need care and test updates. 454 + 455 + ## Risk Assessment 456 + 457 + **Low risk:** 458 + - Storage logic is unchanged -- same PDS calls, same hold XRPC calls, same presigned URLs 459 + - Auth flow is unchanged -- same JWT validation, same OAuth refresh 460 + - Tests can be adapted incrementally 461 + 462 + **Medium risk:** 463 + - Subtle OCI spec compliance gaps (edge cases in content negotiation, digest validation, chunked upload semantics) 464 + - Docker client compatibility -- different clients (Docker, Podman, crane, skopeo) may exercise different code paths 465 + 466 + **Mitigation:** 467 + - Use [OCI conformance tests](https://github.com/opencontainers/distribution-spec/tree/main/conformance) to validate 468 + - Test against Docker, Podman, crane, and skopeo before shipping 469 + - Can be done incrementally: build new router, test alongside distribution handler, swap when ready 470 + 471 + ## Dependencies Removed 472 + 473 + Removing distribution eliminates ~30-40 transitive packages, notably: 474 + - `github.com/aws/aws-sdk-go` (v1, EOL) 475 + - Azure cloud SDK packages 476 + - Google Cloud Storage packages 477 + - Distribution-specific logging/metrics 478 + - Unused storage driver registrations 479 + 480 + Most other transitive deps (gRPC, protobuf, OpenTelemetry, logrus) are also pulled by `bluesky-social/indigo` and would remain.
+1 -1
go.mod
··· 3 go 1.25.7 4 5 require ( 6 github.com/aws/aws-sdk-go-v2 v1.41.1 7 github.com/aws/aws-sdk-go-v2/config v1.32.7 8 github.com/aws/aws-sdk-go-v2/credentials v1.19.7 ··· 52 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect 53 github.com/ajg/form v1.6.1 // indirect 54 github.com/antlr4-go/antlr/v4 v4.13.1 // indirect 55 - github.com/aws/aws-sdk-go v1.55.8 // indirect 56 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect 57 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect 58 github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
··· 3 go 1.25.7 4 5 require ( 6 + github.com/aws/aws-sdk-go v1.55.8 7 github.com/aws/aws-sdk-go-v2 v1.41.1 8 github.com/aws/aws-sdk-go-v2/config v1.32.7 9 github.com/aws/aws-sdk-go-v2/credentials v1.19.7 ··· 53 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect 54 github.com/ajg/form v1.6.1 // indirect 55 github.com/antlr4-go/antlr/v4 v4.13.1 // indirect 56 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect 57 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect 58 github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
+77
lexicons/io/atcr/hold/scan.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "io.atcr.hold.scan", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "any", 8 + "description": "Vulnerability scan results for a container manifest. Stored in the hold's embedded PDS. Record key is deterministic: the manifest digest hex without the 'sha256:' prefix, so re-scans upsert the existing record.", 9 + "record": { 10 + "type": "object", 11 + "required": ["manifest", "repository", "userDid", "critical", "high", "medium", "low", "total", "scannerVersion", "scannedAt"], 12 + "properties": { 13 + "manifest": { 14 + "type": "string", 15 + "format": "at-uri", 16 + "description": "AT-URI of the scanned manifest (e.g., at://did:plc:xyz/io.atcr.manifest/abc123...)" 17 + }, 18 + "repository": { 19 + "type": "string", 20 + "description": "Repository name (e.g., myapp)", 21 + "maxLength": 256 22 + }, 23 + "userDid": { 24 + "type": "string", 25 + "format": "did", 26 + "description": "DID of the image owner" 27 + }, 28 + "sbomBlob": { 29 + "type": "blob", 30 + "description": "SBOM blob (SPDX JSON format) uploaded to the hold's blob storage", 31 + "accept": ["application/spdx+json"] 32 + }, 33 + "vulnReportBlob": { 34 + "type": "blob", 35 + "description": "Grype vulnerability report blob (JSON) with full CVE details", 36 + "accept": ["application/vnd.atcr.vulnerabilities+json"] 37 + }, 38 + "critical": { 39 + "type": "integer", 40 + "minimum": 0, 41 + "description": "Count of critical severity vulnerabilities" 42 + }, 43 + "high": { 44 + "type": "integer", 45 + "minimum": 0, 46 + "description": "Count of high severity vulnerabilities" 47 + }, 48 + "medium": { 49 + "type": "integer", 50 + "minimum": 0, 51 + "description": "Count of medium severity vulnerabilities" 52 + }, 53 + "low": { 54 + "type": "integer", 55 + "minimum": 0, 56 + "description": "Count of low severity vulnerabilities" 57 + }, 58 + "total": { 59 + "type": "integer", 60 + "minimum": 0, 61 + "description": "Total vulnerability count" 62 + }, 63 + "scannerVersion": { 64 + "type": "string", 65 + "description": "Version of the scanner that produced this result (e.g., atcr-scanner-v1.0.0)", 66 + "maxLength": 64 67 + }, 68 + "scannedAt": { 69 + "type": "string", 70 + "format": "datetime", 71 + "description": "RFC3339 timestamp of when the scan completed" 72 + } 73 + } 74 + } 75 + } 76 + } 77 + }
+25
pkg/appview/db/migrations/0011_fix_captain_successor_column.yaml
···
··· 1 + description: Rebuild hold_captain_records to match schema.sql (provider→successor rename was missed when migration 0010 was recorded but not executed on a fresh DB) 2 + query: | 3 + -- Recreate table to match schema.sql exactly 4 + CREATE TABLE IF NOT EXISTS hold_captain_records_new ( 5 + hold_did TEXT PRIMARY KEY, 6 + owner_did TEXT NOT NULL, 7 + public BOOLEAN NOT NULL, 8 + allow_all_crew BOOLEAN NOT NULL, 9 + deployed_at TEXT, 10 + region TEXT, 11 + successor TEXT, 12 + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 13 + ); 14 + 15 + -- Copy data (only guaranteed-common columns; successor will be NULL) 16 + INSERT OR IGNORE INTO hold_captain_records_new (hold_did, owner_did, public, allow_all_crew, deployed_at, region, updated_at) 17 + SELECT hold_did, owner_did, public, allow_all_crew, deployed_at, region, updated_at 18 + FROM hold_captain_records; 19 + 20 + -- Swap tables 21 + DROP TABLE hold_captain_records; 22 + ALTER TABLE hold_captain_records_new RENAME TO hold_captain_records; 23 + 24 + -- Recreate index 25 + CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
+125
pkg/appview/handlers/scan_result.go
···
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "net/http" 9 + "net/url" 10 + "strings" 11 + "time" 12 + 13 + "atcr.io/pkg/atproto" 14 + ) 15 + 16 + // ScanResultHandler handles HTMX requests for vulnerability scan badges. 17 + // Returns an HTML fragment (vuln-badge partial) that replaces the placeholder span. 18 + type ScanResultHandler struct { 19 + BaseUIHandler 20 + } 21 + 22 + // vulnBadgeData is the template data for the vuln-badge partial. 23 + type vulnBadgeData struct { 24 + Critical int64 25 + High int64 26 + Medium int64 27 + Low int64 28 + Total int64 29 + ScannedAt string 30 + Found bool // true if scan record exists 31 + Error bool // true if hold unreachable or error 32 + Digest string // for the detail modal link 33 + HoldEndpoint string // for the detail modal link 34 + } 35 + 36 + func (h *ScanResultHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 37 + digest := r.URL.Query().Get("digest") 38 + holdEndpoint := r.URL.Query().Get("holdEndpoint") 39 + 40 + if digest == "" || holdEndpoint == "" { 41 + // Missing params — render nothing 42 + w.Header().Set("Content-Type", "text/html") 43 + return 44 + } 45 + 46 + // Derive hold DID from endpoint URL 47 + holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint) 48 + if holdDID == "" { 49 + h.renderBadge(w, vulnBadgeData{Error: true}) 50 + return 51 + } 52 + 53 + // Compute rkey from digest (strip sha256: prefix) 54 + rkey := strings.TrimPrefix(digest, "sha256:") 55 + 56 + // Fetch scan record from hold's PDS 57 + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) 58 + defer cancel() 59 + 60 + scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 61 + holdEndpoint, 62 + url.QueryEscape(holdDID), 63 + url.QueryEscape(atproto.ScanCollection), 64 + url.QueryEscape(rkey), 65 + ) 66 + 67 + req, err := http.NewRequestWithContext(ctx, "GET", scanURL, nil) 68 + if err != nil { 69 + h.renderBadge(w, vulnBadgeData{Error: true}) 70 + return 71 + } 72 + 73 + resp, err := http.DefaultClient.Do(req) 74 + if err != nil { 75 + // Hold unreachable or timeout — render nothing 76 + h.renderBadge(w, vulnBadgeData{Error: true}) 77 + return 78 + } 79 + defer resp.Body.Close() 80 + 81 + if resp.StatusCode == http.StatusNotFound { 82 + // No scan record — scanning disabled or not yet scanned. Render nothing. 83 + h.renderBadge(w, vulnBadgeData{Error: true}) 84 + return 85 + } 86 + 87 + if resp.StatusCode != http.StatusOK { 88 + h.renderBadge(w, vulnBadgeData{Error: true}) 89 + return 90 + } 91 + 92 + // Parse the getRecord response envelope 93 + var envelope struct { 94 + Value json.RawMessage `json:"value"` 95 + } 96 + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { 97 + h.renderBadge(w, vulnBadgeData{Error: true}) 98 + return 99 + } 100 + 101 + var scanRecord atproto.ScanRecord 102 + if err := json.Unmarshal(envelope.Value, &scanRecord); err != nil { 103 + h.renderBadge(w, vulnBadgeData{Error: true}) 104 + return 105 + } 106 + 107 + h.renderBadge(w, vulnBadgeData{ 108 + Critical: scanRecord.Critical, 109 + High: scanRecord.High, 110 + Medium: scanRecord.Medium, 111 + Low: scanRecord.Low, 112 + Total: scanRecord.Total, 113 + ScannedAt: scanRecord.ScannedAt, 114 + Found: true, 115 + Digest: digest, 116 + HoldEndpoint: holdEndpoint, 117 + }) 118 + } 119 + 120 + func (h *ScanResultHandler) renderBadge(w http.ResponseWriter, data vulnBadgeData) { 121 + w.Header().Set("Content-Type", "text/html") 122 + if err := h.Templates.ExecuteTemplate(w, "vuln-badge", data); err != nil { 123 + slog.Warn("Failed to render vuln badge", "error", err) 124 + } 125 + }
+255
pkg/appview/handlers/scan_result_test.go
···
··· 1 + package handlers_test 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/http/httptest" 7 + "strings" 8 + "testing" 9 + 10 + "atcr.io/pkg/appview" 11 + "atcr.io/pkg/appview/handlers" 12 + ) 13 + 14 + // mockScanRecord returns a getRecord JSON envelope wrapping a scan record 15 + func mockScanRecord(critical, high, medium, low, total int64) string { 16 + record := map[string]any{ 17 + "$type": "io.atcr.hold.scan", 18 + "manifest": "at://did:plc:test/io.atcr.manifest/abc123", 19 + "repository": "myapp", 20 + "userDid": "did:plc:test", 21 + "critical": critical, 22 + "high": high, 23 + "medium": medium, 24 + "low": low, 25 + "total": total, 26 + "scannerVersion": "atcr-scanner-v1.0.0", 27 + "scannedAt": "2025-01-15T10:30:00Z", 28 + } 29 + envelope := map[string]any{ 30 + "uri": "at://did:web:hold.example.com/io.atcr.hold.scan/abc123", 31 + "cid": "bafyreiabc123", 32 + "value": record, 33 + } 34 + b, _ := json.Marshal(envelope) 35 + return string(b) 36 + } 37 + 38 + func setupScanResultHandler(t *testing.T, holdURL string) *handlers.ScanResultHandler { 39 + t.Helper() 40 + templates, err := appview.Templates(nil) 41 + if err != nil { 42 + t.Fatalf("Failed to load templates: %v", err) 43 + } 44 + return &handlers.ScanResultHandler{ 45 + BaseUIHandler: handlers.BaseUIHandler{ 46 + Templates: templates, 47 + }, 48 + } 49 + } 50 + 51 + func TestScanResult_WithVulnerabilities(t *testing.T) { 52 + // Mock hold that returns a scan record with vulnerabilities 53 + hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 54 + w.Header().Set("Content-Type", "application/json") 55 + w.Write([]byte(mockScanRecord(2, 5, 10, 3, 20))) 56 + })) 57 + defer hold.Close() 58 + 59 + handler := setupScanResultHandler(t, hold.URL) 60 + 61 + req := httptest.NewRequest("GET", "/api/scan-result?digest=sha256:abc123&holdEndpoint="+hold.URL, nil) 62 + rr := httptest.NewRecorder() 63 + handler.ServeHTTP(rr, req) 64 + 65 + if rr.Code != http.StatusOK { 66 + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) 67 + } 68 + 69 + body := rr.Body.String() 70 + 71 + // Should contain severity badges 72 + if !strings.Contains(body, "badge-error") { 73 + t.Error("Expected body to contain badge-error for critical vulnerabilities") 74 + } 75 + if !strings.Contains(body, "C:2") { 76 + t.Error("Expected body to contain 'C:2' for critical count") 77 + } 78 + if !strings.Contains(body, "badge-warning") { 79 + t.Error("Expected body to contain badge-warning for high vulnerabilities") 80 + } 81 + if !strings.Contains(body, "H:5") { 82 + t.Error("Expected body to contain 'H:5' for high count") 83 + } 84 + if !strings.Contains(body, "M:10") { 85 + t.Error("Expected body to contain 'M:10' for medium count") 86 + } 87 + if !strings.Contains(body, "L:3") { 88 + t.Error("Expected body to contain 'L:3' for low count") 89 + } 90 + // Should be clickable (has openVulnDetails) 91 + if !strings.Contains(body, "openVulnDetails") { 92 + t.Error("Expected body to contain openVulnDetails click handler") 93 + } 94 + } 95 + 96 + func TestScanResult_Clean(t *testing.T) { 97 + // Mock hold that returns a scan record with zero vulnerabilities 98 + hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 99 + w.Header().Set("Content-Type", "application/json") 100 + w.Write([]byte(mockScanRecord(0, 0, 0, 0, 0))) 101 + })) 102 + defer hold.Close() 103 + 104 + handler := setupScanResultHandler(t, hold.URL) 105 + 106 + req := httptest.NewRequest("GET", "/api/scan-result?digest=sha256:abc123&holdEndpoint="+hold.URL, nil) 107 + rr := httptest.NewRecorder() 108 + handler.ServeHTTP(rr, req) 109 + 110 + body := rr.Body.String() 111 + 112 + if !strings.Contains(body, "Clean") { 113 + t.Error("Expected body to contain 'Clean' for zero-vulnerability scan") 114 + } 115 + if !strings.Contains(body, "badge-success") { 116 + t.Error("Expected body to contain badge-success for clean scan") 117 + } 118 + // Should NOT be clickable 119 + if strings.Contains(body, "openVulnDetails") { 120 + t.Error("Clean badge should not have openVulnDetails click handler") 121 + } 122 + } 123 + 124 + func TestScanResult_NotFound(t *testing.T) { 125 + // Mock hold that returns 404 (no scan record — scanning disabled or not yet scanned) 126 + hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 127 + http.Error(w, "record not found", http.StatusNotFound) 128 + })) 129 + defer hold.Close() 130 + 131 + handler := setupScanResultHandler(t, hold.URL) 132 + 133 + req := httptest.NewRequest("GET", "/api/scan-result?digest=sha256:abc123&holdEndpoint="+hold.URL, nil) 134 + rr := httptest.NewRecorder() 135 + handler.ServeHTTP(rr, req) 136 + 137 + body := strings.TrimSpace(rr.Body.String()) 138 + 139 + // 404 = no scan record. Should render NOTHING — not "Scan pending". 140 + if body != "" { 141 + t.Errorf("Expected empty body for 404, got: %q", body) 142 + } 143 + } 144 + 145 + func TestScanResult_HoldError(t *testing.T) { 146 + // Mock hold that returns 500 147 + hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 148 + http.Error(w, "internal error", http.StatusInternalServerError) 149 + })) 150 + defer hold.Close() 151 + 152 + handler := setupScanResultHandler(t, hold.URL) 153 + 154 + req := httptest.NewRequest("GET", "/api/scan-result?digest=sha256:abc123&holdEndpoint="+hold.URL, nil) 155 + rr := httptest.NewRecorder() 156 + handler.ServeHTTP(rr, req) 157 + 158 + body := strings.TrimSpace(rr.Body.String()) 159 + 160 + if body != "" { 161 + t.Errorf("Expected empty body for hold error, got: %q", body) 162 + } 163 + } 164 + 165 + func TestScanResult_HoldUnreachable(t *testing.T) { 166 + // Use a server that's already closed (unreachable) 167 + hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) 168 + hold.Close() 169 + 170 + handler := setupScanResultHandler(t, hold.URL) 171 + 172 + req := httptest.NewRequest("GET", "/api/scan-result?digest=sha256:abc123&holdEndpoint="+hold.URL, nil) 173 + rr := httptest.NewRecorder() 174 + handler.ServeHTTP(rr, req) 175 + 176 + body := strings.TrimSpace(rr.Body.String()) 177 + 178 + if body != "" { 179 + t.Errorf("Expected empty body for unreachable hold, got: %q", body) 180 + } 181 + } 182 + 183 + func TestScanResult_MissingParams(t *testing.T) { 184 + handler := setupScanResultHandler(t, "") 185 + 186 + // No params at all 187 + req := httptest.NewRequest("GET", "/api/scan-result", nil) 188 + rr := httptest.NewRecorder() 189 + handler.ServeHTTP(rr, req) 190 + 191 + body := strings.TrimSpace(rr.Body.String()) 192 + 193 + if body != "" { 194 + t.Errorf("Expected empty body for missing params, got: %q", body) 195 + } 196 + } 197 + 198 + func TestScanResult_MissingDigest(t *testing.T) { 199 + handler := setupScanResultHandler(t, "") 200 + 201 + req := httptest.NewRequest("GET", "/api/scan-result?holdEndpoint=https://hold.example.com", nil) 202 + rr := httptest.NewRecorder() 203 + handler.ServeHTTP(rr, req) 204 + 205 + body := strings.TrimSpace(rr.Body.String()) 206 + 207 + if body != "" { 208 + t.Errorf("Expected empty body for missing digest, got: %q", body) 209 + } 210 + } 211 + 212 + func TestScanResult_MissingHoldEndpoint(t *testing.T) { 213 + handler := setupScanResultHandler(t, "") 214 + 215 + req := httptest.NewRequest("GET", "/api/scan-result?digest=sha256:abc123", nil) 216 + rr := httptest.NewRecorder() 217 + handler.ServeHTTP(rr, req) 218 + 219 + body := strings.TrimSpace(rr.Body.String()) 220 + 221 + if body != "" { 222 + t.Errorf("Expected empty body for missing holdEndpoint, got: %q", body) 223 + } 224 + } 225 + 226 + func TestScanResult_OnlyCriticalShown(t *testing.T) { 227 + // Only critical vulns, no high/medium/low 228 + hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 229 + w.Header().Set("Content-Type", "application/json") 230 + w.Write([]byte(mockScanRecord(3, 0, 0, 0, 3))) 231 + })) 232 + defer hold.Close() 233 + 234 + handler := setupScanResultHandler(t, hold.URL) 235 + 236 + req := httptest.NewRequest("GET", "/api/scan-result?digest=sha256:abc123&holdEndpoint="+hold.URL, nil) 237 + rr := httptest.NewRecorder() 238 + handler.ServeHTTP(rr, req) 239 + 240 + body := rr.Body.String() 241 + 242 + if !strings.Contains(body, "C:3") { 243 + t.Error("Expected body to contain 'C:3'") 244 + } 245 + // Zero-count badges should NOT appear 246 + if strings.Contains(body, "H:0") { 247 + t.Error("Should not contain 'H:0' for zero high count") 248 + } 249 + if strings.Contains(body, "M:0") { 250 + t.Error("Should not contain 'M:0' for zero medium count") 251 + } 252 + if strings.Contains(body, "L:0") { 253 + t.Error("Should not contain 'L:0' for zero low count") 254 + } 255 + }
+244
pkg/appview/handlers/vuln_details.go
···
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "net/http" 9 + "net/url" 10 + "sort" 11 + "strings" 12 + "time" 13 + 14 + "atcr.io/pkg/atproto" 15 + ) 16 + 17 + // VulnDetailsHandler handles requests for the vulnerability detail modal content. 18 + // Returns an HTML fragment (vuln-details partial) for insertion into the modal body. 19 + type VulnDetailsHandler struct { 20 + BaseUIHandler 21 + } 22 + 23 + // grypeReport is the minimal Grype JSON structure we need. 24 + type grypeReport struct { 25 + Matches []grypeMatch `json:"matches"` 26 + } 27 + 28 + type grypeMatch struct { 29 + Vulnerability grypeVuln `json:"vulnerability"` 30 + Artifact grypeArtifact `json:"artifact"` 31 + } 32 + 33 + type grypeVuln struct { 34 + ID string `json:"id"` 35 + Severity string `json:"severity"` 36 + Fix grypeFix `json:"fix"` 37 + } 38 + 39 + type grypeFix struct { 40 + Versions []string `json:"versions"` 41 + State string `json:"state"` 42 + } 43 + 44 + type grypeArtifact struct { 45 + Name string `json:"name"` 46 + Version string `json:"version"` 47 + Type string `json:"type"` 48 + } 49 + 50 + // vulnDetailsData is the template data for the vuln-details partial. 51 + type vulnDetailsData struct { 52 + Matches []vulnMatch 53 + Summary vulnSummary 54 + Error string // non-empty if something went wrong 55 + ScannedAt string 56 + } 57 + 58 + type vulnMatch struct { 59 + CVEURL string 60 + CVEID string 61 + Severity string // Critical, High, Medium, Low, Negligible, Unknown 62 + Package string 63 + Version string 64 + FixedIn string 65 + Type string // deb, npm, gem, etc. 66 + } 67 + 68 + type vulnSummary struct { 69 + Critical int64 70 + High int64 71 + Medium int64 72 + Low int64 73 + Total int64 74 + } 75 + 76 + // severityOrder maps severity strings to sort order (lower = more severe). 77 + var severityOrder = map[string]int{ 78 + "Critical": 0, 79 + "High": 1, 80 + "Medium": 2, 81 + "Low": 3, 82 + "Negligible": 4, 83 + "Unknown": 5, 84 + } 85 + 86 + func (h *VulnDetailsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 87 + digest := r.URL.Query().Get("digest") 88 + holdEndpoint := r.URL.Query().Get("holdEndpoint") 89 + 90 + if digest == "" || holdEndpoint == "" { 91 + h.renderDetails(w, vulnDetailsData{Error: "Missing required parameters"}) 92 + return 93 + } 94 + 95 + holdDID := atproto.ResolveHoldDIDFromURL(holdEndpoint) 96 + if holdDID == "" { 97 + h.renderDetails(w, vulnDetailsData{Error: "Could not resolve hold identity"}) 98 + return 99 + } 100 + 101 + rkey := strings.TrimPrefix(digest, "sha256:") 102 + 103 + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) 104 + defer cancel() 105 + 106 + // Step 1: Fetch the scan record to get the VulnReportBlob CID 107 + scanURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 108 + holdEndpoint, 109 + url.QueryEscape(holdDID), 110 + url.QueryEscape(atproto.ScanCollection), 111 + url.QueryEscape(rkey), 112 + ) 113 + 114 + req, err := http.NewRequestWithContext(ctx, "GET", scanURL, nil) 115 + if err != nil { 116 + h.renderDetails(w, vulnDetailsData{Error: "Failed to build request"}) 117 + return 118 + } 119 + 120 + resp, err := http.DefaultClient.Do(req) 121 + if err != nil { 122 + h.renderDetails(w, vulnDetailsData{Error: "Hold service unreachable"}) 123 + return 124 + } 125 + defer resp.Body.Close() 126 + 127 + if resp.StatusCode != http.StatusOK { 128 + h.renderDetails(w, vulnDetailsData{Error: "No scan record found"}) 129 + return 130 + } 131 + 132 + var envelope struct { 133 + Value json.RawMessage `json:"value"` 134 + } 135 + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { 136 + h.renderDetails(w, vulnDetailsData{Error: "Failed to parse scan record"}) 137 + return 138 + } 139 + 140 + var scanRecord atproto.ScanRecord 141 + if err := json.Unmarshal(envelope.Value, &scanRecord); err != nil { 142 + h.renderDetails(w, vulnDetailsData{Error: "Failed to parse scan record"}) 143 + return 144 + } 145 + 146 + summary := vulnSummary{ 147 + Critical: scanRecord.Critical, 148 + High: scanRecord.High, 149 + Medium: scanRecord.Medium, 150 + Low: scanRecord.Low, 151 + Total: scanRecord.Total, 152 + } 153 + 154 + // Step 2: Fetch the vulnerability report blob 155 + if scanRecord.VulnReportBlob == nil || scanRecord.VulnReportBlob.Ref.String() == "" { 156 + h.renderDetails(w, vulnDetailsData{ 157 + Summary: summary, 158 + ScannedAt: scanRecord.ScannedAt, 159 + Error: "No detailed vulnerability report available. Only summary counts were recorded.", 160 + }) 161 + return 162 + } 163 + 164 + blobCID := scanRecord.VulnReportBlob.Ref.String() 165 + blobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 166 + holdEndpoint, 167 + url.QueryEscape(holdDID), 168 + url.QueryEscape(blobCID), 169 + ) 170 + 171 + blobReq, err := http.NewRequestWithContext(ctx, "GET", blobURL, nil) 172 + if err != nil { 173 + h.renderDetails(w, vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Failed to build blob request"}) 174 + return 175 + } 176 + 177 + blobResp, err := http.DefaultClient.Do(blobReq) 178 + if err != nil { 179 + h.renderDetails(w, vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Failed to fetch vulnerability report"}) 180 + return 181 + } 182 + defer blobResp.Body.Close() 183 + 184 + if blobResp.StatusCode != http.StatusOK { 185 + h.renderDetails(w, vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Vulnerability report not accessible"}) 186 + return 187 + } 188 + 189 + // Step 3: Parse the Grype JSON 190 + var report grypeReport 191 + if err := json.NewDecoder(blobResp.Body).Decode(&report); err != nil { 192 + h.renderDetails(w, vulnDetailsData{Summary: summary, ScannedAt: scanRecord.ScannedAt, Error: "Failed to parse vulnerability report"}) 193 + return 194 + } 195 + 196 + // Convert to template data 197 + matches := make([]vulnMatch, 0, len(report.Matches)) 198 + for _, m := range report.Matches { 199 + fixedIn := "" 200 + if len(m.Vulnerability.Fix.Versions) > 0 { 201 + fixedIn = strings.Join(m.Vulnerability.Fix.Versions, ", ") 202 + } 203 + 204 + cveURL := "" 205 + if strings.HasPrefix(m.Vulnerability.ID, "CVE-") { 206 + cveURL = "https://nvd.nist.gov/vuln/detail/" + m.Vulnerability.ID 207 + } else if strings.HasPrefix(m.Vulnerability.ID, "GHSA-") { 208 + cveURL = "https://github.com/advisories/" + m.Vulnerability.ID 209 + } 210 + 211 + matches = append(matches, vulnMatch{ 212 + CVEID: m.Vulnerability.ID, 213 + CVEURL: cveURL, 214 + Severity: m.Vulnerability.Severity, 215 + Package: m.Artifact.Name, 216 + Version: m.Artifact.Version, 217 + FixedIn: fixedIn, 218 + Type: m.Artifact.Type, 219 + }) 220 + } 221 + 222 + // Sort by severity (critical first) 223 + sort.Slice(matches, func(i, j int) bool { 224 + oi := severityOrder[matches[i].Severity] 225 + oj := severityOrder[matches[j].Severity] 226 + if oi != oj { 227 + return oi < oj 228 + } 229 + return matches[i].CVEID < matches[j].CVEID 230 + }) 231 + 232 + h.renderDetails(w, vulnDetailsData{ 233 + Matches: matches, 234 + Summary: summary, 235 + ScannedAt: scanRecord.ScannedAt, 236 + }) 237 + } 238 + 239 + func (h *VulnDetailsHandler) renderDetails(w http.ResponseWriter, data vulnDetailsData) { 240 + w.Header().Set("Content-Type", "text/html") 241 + if err := h.Templates.ExecuteTemplate(w, "vuln-details", data); err != nil { 242 + slog.Warn("Failed to render vuln details", "error", err) 243 + } 244 + }
+336
pkg/appview/handlers/vuln_details_test.go
···
··· 1 + package handlers_test 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "strings" 9 + "testing" 10 + 11 + "atcr.io/pkg/appview" 12 + "atcr.io/pkg/appview/handlers" 13 + lexutil "github.com/bluesky-social/indigo/lex/util" 14 + "github.com/ipfs/go-cid" 15 + "github.com/multiformats/go-multihash" 16 + ) 17 + 18 + // mockGrypeReport returns a minimal Grype JSON report 19 + func mockGrypeReport() string { 20 + report := map[string]any{ 21 + "matches": []map[string]any{ 22 + { 23 + "vulnerability": map[string]any{ 24 + "id": "CVE-2024-1234", 25 + "severity": "Critical", 26 + "fix": map[string]any{"versions": []string{"1.2.4"}, "state": "fixed"}, 27 + }, 28 + "artifact": map[string]any{ 29 + "name": "libssl", 30 + "version": "1.1.1", 31 + "type": "deb", 32 + }, 33 + }, 34 + { 35 + "vulnerability": map[string]any{ 36 + "id": "CVE-2024-5678", 37 + "severity": "Low", 38 + "fix": map[string]any{"versions": []string{}, "state": "not-fixed"}, 39 + }, 40 + "artifact": map[string]any{ 41 + "name": "zlib", 42 + "version": "1.2.11", 43 + "type": "deb", 44 + }, 45 + }, 46 + { 47 + "vulnerability": map[string]any{ 48 + "id": "GHSA-abcd-efgh-ijkl", 49 + "severity": "High", 50 + "fix": map[string]any{"versions": []string{"2.0.0"}, "state": "fixed"}, 51 + }, 52 + "artifact": map[string]any{ 53 + "name": "express", 54 + "version": "4.17.1", 55 + "type": "npm", 56 + }, 57 + }, 58 + }, 59 + } 60 + b, _ := json.Marshal(report) 61 + return string(b) 62 + } 63 + 64 + // testCID creates a valid CIDv1 from arbitrary data (for test fixtures) 65 + func testCID(data string) cid.Cid { 66 + hash := sha256.Sum256([]byte(data)) 67 + mh, _ := multihash.Encode(hash[:], multihash.SHA2_256) 68 + return cid.NewCidV1(0x55, mh) // raw codec 69 + } 70 + 71 + // mockScanRecordWithBlob returns a getRecord envelope with a VulnReportBlob reference. 72 + // Uses a real CID so LexBlob JSON unmarshaling works correctly. 73 + func mockScanRecordWithBlob(critical, high, medium, low, total int64) string { 74 + blobCID := testCID("test-vuln-report") 75 + 76 + // Build the record using the actual LexBlob type for correct JSON format 77 + blob := &lexutil.LexBlob{ 78 + Ref: lexutil.LexLink(blobCID), 79 + MimeType: "application/vnd.atcr.vulnerabilities+json", 80 + Size: 12345, 81 + } 82 + 83 + // Marshal blob separately to get the canonical JSON format 84 + blobJSON, _ := json.Marshal(blob) 85 + 86 + // Build the full record as a map, inserting the pre-marshaled blob 87 + record := map[string]json.RawMessage{ 88 + "$type": jsonStr("io.atcr.hold.scan"), 89 + "manifest": jsonStr("at://did:plc:test/io.atcr.manifest/abc123"), 90 + "repository": jsonStr("myapp"), 91 + "userDid": jsonStr("did:plc:test"), 92 + "vulnReportBlob": blobJSON, 93 + "critical": jsonInt(critical), 94 + "high": jsonInt(high), 95 + "medium": jsonInt(medium), 96 + "low": jsonInt(low), 97 + "total": jsonInt(total), 98 + "scannerVersion": jsonStr("atcr-scanner-v1.0.0"), 99 + "scannedAt": jsonStr("2025-01-15T10:30:00Z"), 100 + } 101 + recordJSON, _ := json.Marshal(record) 102 + 103 + envelope := map[string]any{ 104 + "uri": "at://did:web:hold.example.com/io.atcr.hold.scan/abc123", 105 + "cid": "bafyreiabc123", 106 + "value": json.RawMessage(recordJSON), 107 + } 108 + b, _ := json.Marshal(envelope) 109 + return string(b) 110 + } 111 + 112 + func jsonStr(s string) json.RawMessage { 113 + b, _ := json.Marshal(s) 114 + return b 115 + } 116 + 117 + func jsonInt(n int64) json.RawMessage { 118 + b, _ := json.Marshal(n) 119 + return b 120 + } 121 + 122 + // mockScanRecordWithoutBlob returns a getRecord envelope without VulnReportBlob 123 + func mockScanRecordWithoutBlob(critical, high, medium, low, total int64) string { 124 + record := map[string]any{ 125 + "$type": "io.atcr.hold.scan", 126 + "manifest": "at://did:plc:test/io.atcr.manifest/abc123", 127 + "repository": "myapp", 128 + "userDid": "did:plc:test", 129 + "critical": critical, 130 + "high": high, 131 + "medium": medium, 132 + "low": low, 133 + "total": total, 134 + "scannerVersion": "atcr-scanner-v1.0.0", 135 + "scannedAt": "2025-01-15T10:30:00Z", 136 + } 137 + envelope := map[string]any{ 138 + "uri": "at://did:web:hold.example.com/io.atcr.hold.scan/abc123", 139 + "cid": "bafyreiabc123", 140 + "value": record, 141 + } 142 + b, _ := json.Marshal(envelope) 143 + return string(b) 144 + } 145 + 146 + func setupVulnDetailsHandler(t *testing.T) *handlers.VulnDetailsHandler { 147 + t.Helper() 148 + templates, err := appview.Templates(nil) 149 + if err != nil { 150 + t.Fatalf("Failed to load templates: %v", err) 151 + } 152 + return &handlers.VulnDetailsHandler{ 153 + BaseUIHandler: handlers.BaseUIHandler{ 154 + Templates: templates, 155 + }, 156 + } 157 + } 158 + 159 + func TestVulnDetails_FullReport(t *testing.T) { 160 + grypeJSON := mockGrypeReport() 161 + 162 + // Mock hold that serves both getRecord and getBlob 163 + hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 164 + path := r.URL.Path 165 + if strings.Contains(path, "getRecord") { 166 + w.Header().Set("Content-Type", "application/json") 167 + w.Write([]byte(mockScanRecordWithBlob(1, 1, 0, 1, 3))) 168 + } else if strings.Contains(path, "getBlob") { 169 + // Serve the Grype JSON directly (no redirect in tests) 170 + w.Header().Set("Content-Type", "application/json") 171 + w.Write([]byte(grypeJSON)) 172 + } else { 173 + http.Error(w, "not found", http.StatusNotFound) 174 + } 175 + })) 176 + defer hold.Close() 177 + 178 + handler := setupVulnDetailsHandler(t) 179 + 180 + req := httptest.NewRequest("GET", "/api/vuln-details?digest=sha256:abc123&holdEndpoint="+hold.URL, nil) 181 + rr := httptest.NewRecorder() 182 + handler.ServeHTTP(rr, req) 183 + 184 + if rr.Code != http.StatusOK { 185 + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) 186 + } 187 + 188 + body := rr.Body.String() 189 + 190 + // Should contain CVE IDs 191 + if !strings.Contains(body, "CVE-2024-1234") { 192 + t.Error("Expected body to contain CVE-2024-1234") 193 + } 194 + if !strings.Contains(body, "GHSA-abcd-efgh-ijkl") { 195 + t.Error("Expected body to contain GHSA-abcd-efgh-ijkl") 196 + } 197 + 198 + // Should contain package names 199 + if !strings.Contains(body, "libssl") { 200 + t.Error("Expected body to contain package name 'libssl'") 201 + } 202 + if !strings.Contains(body, "express") { 203 + t.Error("Expected body to contain package name 'express'") 204 + } 205 + 206 + // Should contain NVD link for CVE 207 + if !strings.Contains(body, "nvd.nist.gov") { 208 + t.Error("Expected body to contain NVD link") 209 + } 210 + // Should contain GitHub advisory link for GHSA 211 + if !strings.Contains(body, "github.com/advisories") { 212 + t.Error("Expected body to contain GitHub advisory link") 213 + } 214 + 215 + // Should contain fix version 216 + if !strings.Contains(body, "1.2.4") { 217 + t.Error("Expected body to contain fix version '1.2.4'") 218 + } 219 + 220 + // Should contain "No fix" for unfixed vuln 221 + if !strings.Contains(body, "No fix") { 222 + t.Error("Expected body to contain 'No fix' for unfixed vulnerability") 223 + } 224 + 225 + // Should contain a table 226 + if !strings.Contains(body, "<table") { 227 + t.Error("Expected body to contain a table element") 228 + } 229 + } 230 + 231 + func TestVulnDetails_NoVulnReportBlob(t *testing.T) { 232 + // Mock hold returns scan record WITHOUT VulnReportBlob 233 + hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 234 + w.Header().Set("Content-Type", "application/json") 235 + w.Write([]byte(mockScanRecordWithoutBlob(2, 5, 10, 3, 20))) 236 + })) 237 + defer hold.Close() 238 + 239 + handler := setupVulnDetailsHandler(t) 240 + 241 + req := httptest.NewRequest("GET", "/api/vuln-details?digest=sha256:abc123&holdEndpoint="+hold.URL, nil) 242 + rr := httptest.NewRecorder() 243 + handler.ServeHTTP(rr, req) 244 + 245 + body := rr.Body.String() 246 + 247 + // Should show summary counts 248 + if !strings.Contains(body, "2 Critical") { 249 + t.Error("Expected body to contain '2 Critical' summary") 250 + } 251 + 252 + // Should indicate no detailed report 253 + if !strings.Contains(body, "No detailed vulnerability report") { 254 + t.Error("Expected body to indicate no detailed report available") 255 + } 256 + 257 + // Should NOT contain a table 258 + if strings.Contains(body, "<table") { 259 + t.Error("Should not render a table when no VulnReportBlob") 260 + } 261 + } 262 + 263 + func TestVulnDetails_NotFound(t *testing.T) { 264 + // Mock hold returns 404 265 + hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 266 + http.Error(w, "not found", http.StatusNotFound) 267 + })) 268 + defer hold.Close() 269 + 270 + handler := setupVulnDetailsHandler(t) 271 + 272 + req := httptest.NewRequest("GET", "/api/vuln-details?digest=sha256:abc123&holdEndpoint="+hold.URL, nil) 273 + rr := httptest.NewRecorder() 274 + handler.ServeHTTP(rr, req) 275 + 276 + body := rr.Body.String() 277 + 278 + // Should show an error message (modal still needs content) 279 + if !strings.Contains(body, "No scan record found") { 280 + t.Error("Expected body to contain error message for 404") 281 + } 282 + } 283 + 284 + func TestVulnDetails_SortsBySeverity(t *testing.T) { 285 + grypeJSON := mockGrypeReport() // Has Critical, Low, High in that order 286 + 287 + hold := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 288 + path := r.URL.Path 289 + if strings.Contains(path, "getRecord") { 290 + w.Header().Set("Content-Type", "application/json") 291 + w.Write([]byte(mockScanRecordWithBlob(1, 1, 0, 1, 3))) 292 + } else if strings.Contains(path, "getBlob") { 293 + w.Header().Set("Content-Type", "application/json") 294 + w.Write([]byte(grypeJSON)) 295 + } 296 + })) 297 + defer hold.Close() 298 + 299 + handler := setupVulnDetailsHandler(t) 300 + 301 + req := httptest.NewRequest("GET", "/api/vuln-details?digest=sha256:abc123&holdEndpoint="+hold.URL, nil) 302 + rr := httptest.NewRecorder() 303 + handler.ServeHTTP(rr, req) 304 + 305 + body := rr.Body.String() 306 + 307 + // Critical should appear before High, which should appear before Low 308 + critIdx := strings.Index(body, "CVE-2024-1234") // Critical 309 + highIdx := strings.Index(body, "GHSA-abcd-efgh") // High 310 + lowIdx := strings.Index(body, "CVE-2024-5678") // Low 311 + 312 + if critIdx == -1 || highIdx == -1 || lowIdx == -1 { 313 + t.Fatal("Expected all three CVEs to be present in body") 314 + } 315 + 316 + if critIdx > highIdx { 317 + t.Error("Critical CVE should appear before High CVE") 318 + } 319 + if highIdx > lowIdx { 320 + t.Error("High CVE should appear before Low CVE") 321 + } 322 + } 323 + 324 + func TestVulnDetails_MissingParams(t *testing.T) { 325 + handler := setupVulnDetailsHandler(t) 326 + 327 + req := httptest.NewRequest("GET", "/api/vuln-details", nil) 328 + rr := httptest.NewRecorder() 329 + handler.ServeHTTP(rr, req) 330 + 331 + body := rr.Body.String() 332 + 333 + if !strings.Contains(body, "Missing required parameters") { 334 + t.Error("Expected error message for missing parameters") 335 + } 336 + }
+2 -2
pkg/appview/public/js/bundle.min.js
··· 1 - var se=(function(){"use strict";let htmx={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){return getInputValues(e,t||"post").values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:!0,historyCacheSize:10,refreshOnHistoryMiss:!1,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:!0,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:!0,allowScriptTags:!0,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:!1,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:!1,getCacheBusterParam:!1,globalViewTransitions:!1,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:!0,ignoreTitle:!1,scrollIntoViewOnBoost:!0,triggerSpecsCache:null,disableInheritance:!1,responseHandling:[{code:"204",swap:!1},{code:"[23]..",swap:!0},{code:"[45]..",swap:!1,error:!0}],allowNestedOobSwaps:!0,historyRestoreAsHxRequest:!0,reportValidityOfForms:!1},parseInterval:null,location,_:null,version:"2.0.8"};htmx.onLoad=onLoadHelper,htmx.process=processNode,htmx.on=addEventListenerImpl,htmx.off=removeEventListenerImpl,htmx.trigger=triggerEvent,htmx.ajax=ajaxHelper,htmx.find=find,htmx.findAll=findAll,htmx.closest=closest,htmx.remove=removeElement,htmx.addClass=addClassToElement,htmx.removeClass=removeClassFromElement,htmx.toggleClass=toggleClassOnElement,htmx.takeClass=takeClassForElement,htmx.swap=swap,htmx.defineExtension=defineExtension,htmx.removeExtension=removeExtension,htmx.logAll=logAll,htmx.logNone=logNone,htmx.parseInterval=parseInterval,htmx._=internalEval;let internalAPI={addTriggerHandler,bodyContains,canAccessLocalStorage,findThisElement,filterValues,swap,hasAttribute,getAttributeValue,getClosestAttributeValue,getClosestMatch,getExpressionVars,getHeaders,getInputValues,getInternalData,getSwapSpecification,getTriggerSpecs,getTarget,makeFragment,mergeObjects,makeSettleInfo,oobSwap,querySelectorExt,settleImmediately,shouldCancel,triggerEvent,triggerErrorEvent,withExtensions},VERBS=["get","post","put","delete","patch"],VERB_SELECTOR=VERBS.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function parseInterval(e){if(e==null)return;let t=NaN;return e.slice(-2)=="ms"?t=parseFloat(e.slice(0,-2)):e.slice(-1)=="s"?t=parseFloat(e.slice(0,-1))*1e3:e.slice(-1)=="m"?t=parseFloat(e.slice(0,-1))*1e3*60:t=parseFloat(e),isNaN(t)?void 0:t}function getRawAttribute(e,t){return e instanceof Element&&e.getAttribute(t)}function hasAttribute(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function getAttributeValue(e,t){return getRawAttribute(e,t)||getRawAttribute(e,"data-"+t)}function parentElt(e){let t=e.parentElement;return!t&&e.parentNode instanceof ShadowRoot?e.parentNode:t}function getDocument(){return document}function getRootNode(e,t){return e.getRootNode?e.getRootNode({composed:t}):getDocument()}function getClosestMatch(e,t){for(;e&&!t(e);)e=parentElt(e);return e||null}function getAttributeValueWithDisinheritance(e,t,n){let r=getAttributeValue(t,n),o=getAttributeValue(t,"hx-disinherit");var i=getAttributeValue(t,"hx-inherit");if(e!==t){if(htmx.config.disableInheritance)return i&&(i==="*"||i.split(" ").indexOf(n)>=0)?r:null;if(o&&(o==="*"||o.split(" ").indexOf(n)>=0))return"unset"}return r}function getClosestAttributeValue(e,t){let n=null;if(getClosestMatch(e,function(r){return!!(n=getAttributeValueWithDisinheritance(e,asElement(r),t))}),n!=="unset")return n}function matches(e,t){return e instanceof Element&&e.matches(t)}function getStartTag(e){let n=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i.exec(e);return n?n[1].toLowerCase():""}function parseHTML(e){return"parseHTMLUnsafe"in Document?Document.parseHTMLUnsafe(e):new DOMParser().parseFromString(e,"text/html")}function takeChildrenFor(e,t){for(;t.childNodes.length>0;)e.append(t.childNodes[0])}function duplicateScript(e){let t=getDocument().createElement("script");return forEach(e.attributes,function(n){t.setAttribute(n.name,n.value)}),t.textContent=e.textContent,t.async=!1,htmx.config.inlineScriptNonce&&(t.nonce=htmx.config.inlineScriptNonce),t}function isJavaScriptScriptNode(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function normalizeScriptTags(e){Array.from(e.querySelectorAll("script")).forEach(t=>{if(isJavaScriptScriptNode(t)){let n=duplicateScript(t),r=t.parentNode;try{r.insertBefore(n,t)}catch(o){logError(o)}finally{t.remove()}}})}function makeFragment(e){let t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,""),n=getStartTag(t),r;if(n==="html"){r=new DocumentFragment;let i=parseHTML(e);takeChildrenFor(r,i.body),r.title=i.title}else if(n==="body"){r=new DocumentFragment;let i=parseHTML(t);takeChildrenFor(r,i.body),r.title=i.title}else{let i=parseHTML('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content,r.title=i.title;var o=r.querySelector("title");o&&o.parentNode===r&&(o.remove(),r.title=o.innerText)}return r&&(htmx.config.allowScriptTags?normalizeScriptTags(r):r.querySelectorAll("script").forEach(i=>i.remove())),r}function maybeCall(e){e&&e()}function isType(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function isFunction(e){return typeof e=="function"}function isRawObject(e){return isType(e,"Object")}function getInternalData(e){let t="htmx-internal-data",n=e[t];return n||(n=e[t]={}),n}function toArray(e){let t=[];if(e)for(let n=0;n<e.length;n++)t.push(e[n]);return t}function forEach(e,t){if(e)for(let n=0;n<e.length;n++)t(e[n])}function isScrolledIntoView(e){let t=e.getBoundingClientRect(),n=t.top,r=t.bottom;return n<window.innerHeight&&r>=0}function bodyContains(e){return e.getRootNode({composed:!0})===document}function splitOnWhitespace(e){return e.trim().split(/\s+/)}function mergeObjects(e,t){for(let n in t)t.hasOwnProperty(n)&&(e[n]=t[n]);return e}function parseJSON(e){try{return JSON.parse(e)}catch(t){return logError(t),null}}function canAccessLocalStorage(){let e="htmx:sessionStorageTest";try{return sessionStorage.setItem(e,e),sessionStorage.removeItem(e),!0}catch{return!1}}function normalizePath(e){let t=new URL(e,"http://x");return t&&(e=t.pathname+t.search),e!="/"&&(e=e.replace(/\/+$/,"")),e}function internalEval(str){return maybeEval(getDocument().body,function(){return eval(str)})}function onLoadHelper(e){return htmx.on("htmx:load",function(n){e(n.detail.elt)})}function logAll(){htmx.logger=function(e,t,n){console&&console.log(t,e,n)}}function logNone(){htmx.logger=null}function find(e,t){return typeof e!="string"?e.querySelector(t):find(getDocument(),e)}function findAll(e,t){return typeof e!="string"?e.querySelectorAll(t):findAll(getDocument(),e)}function getWindow(){return window}function removeElement(e,t){e=resolveTarget(e),t?getWindow().setTimeout(function(){removeElement(e),e=null},t):parentElt(e).removeChild(e)}function asElement(e){return e instanceof Element?e:null}function asHtmlElement(e){return e instanceof HTMLElement?e:null}function asString(e){return typeof e=="string"?e:null}function asParentNode(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function addClassToElement(e,t,n){e=asElement(resolveTarget(e)),e&&(n?getWindow().setTimeout(function(){addClassToElement(e,t),e=null},n):e.classList&&e.classList.add(t))}function removeClassFromElement(e,t,n){let r=asElement(resolveTarget(e));r&&(n?getWindow().setTimeout(function(){removeClassFromElement(r,t),r=null},n):r.classList&&(r.classList.remove(t),r.classList.length===0&&r.removeAttribute("class")))}function toggleClassOnElement(e,t){e=resolveTarget(e),e.classList.toggle(t)}function takeClassForElement(e,t){e=resolveTarget(e),forEach(e.parentElement.children,function(n){removeClassFromElement(n,t)}),addClassToElement(asElement(e),t)}function closest(e,t){return e=asElement(resolveTarget(e)),e?e.closest(t):null}function startsWith(e,t){return e.substring(0,t.length)===t}function endsWith(e,t){return e.substring(e.length-t.length)===t}function normalizeSelector(e){let t=e.trim();return startsWith(t,"<")&&endsWith(t,"/>")?t.substring(1,t.length-2):t}function querySelectorAllExt(e,t,n){if(t.indexOf("global ")===0)return querySelectorAllExt(e,t.slice(7),!0);e=resolveTarget(e);let r=[];{let s=0,a=0;for(let l=0;l<t.length;l++){let c=t[l];if(c===","&&s===0){r.push(t.substring(a,l)),a=l+1;continue}c==="<"?s++:c==="/"&&l<t.length-1&&t[l+1]===">"&&s--}a<t.length&&r.push(t.substring(a))}let o=[],i=[];for(;r.length>0;){let s=normalizeSelector(r.shift()),a;s.indexOf("closest ")===0?a=closest(asElement(e),normalizeSelector(s.slice(8))):s.indexOf("find ")===0?a=find(asParentNode(e),normalizeSelector(s.slice(5))):s==="next"||s==="nextElementSibling"?a=asElement(e).nextElementSibling:s.indexOf("next ")===0?a=scanForwardQuery(e,normalizeSelector(s.slice(5)),!!n):s==="previous"||s==="previousElementSibling"?a=asElement(e).previousElementSibling:s.indexOf("previous ")===0?a=scanBackwardsQuery(e,normalizeSelector(s.slice(9)),!!n):s==="document"?a=document:s==="window"?a=window:s==="body"?a=document.body:s==="root"?a=getRootNode(e,!!n):s==="host"?a=e.getRootNode().host:i.push(s),a&&o.push(a)}if(i.length>0){let s=i.join(","),a=asParentNode(getRootNode(e,!!n));o.push(...toArray(a.querySelectorAll(s)))}return o}var scanForwardQuery=function(e,t,n){let r=asParentNode(getRootNode(e,n)).querySelectorAll(t);for(let o=0;o<r.length;o++){let i=r[o];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_PRECEDING)return i}},scanBackwardsQuery=function(e,t,n){let r=asParentNode(getRootNode(e,n)).querySelectorAll(t);for(let o=r.length-1;o>=0;o--){let i=r[o];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING)return i}};function querySelectorExt(e,t){return typeof e!="string"?querySelectorAllExt(e,t)[0]:querySelectorAllExt(getDocument().body,e)[0]}function resolveTarget(e,t){return typeof e=="string"?find(asParentNode(t)||document,e):e}function processEventArgs(e,t,n,r){return isFunction(t)?{target:getDocument().body,event:asString(e),listener:t,options:n}:{target:resolveTarget(e),event:asString(t),listener:n,options:r}}function addEventListenerImpl(e,t,n,r){return ready(function(){let i=processEventArgs(e,t,n,r);i.target.addEventListener(i.event,i.listener,i.options)}),isFunction(t)?t:n}function removeEventListenerImpl(e,t,n){return ready(function(){let r=processEventArgs(e,t,n);r.target.removeEventListener(r.event,r.listener)}),isFunction(t)?t:n}let DUMMY_ELT=getDocument().createElement("output");function findAttributeTargets(e,t){let n=getClosestAttributeValue(e,t);if(n){if(n==="this")return[findThisElement(e,t)];{let r=querySelectorAllExt(e,n);if(/(^|,)(\s*)inherit(\s*)($|,)/.test(n)){let i=asElement(getClosestMatch(e,function(s){return s!==e&&hasAttribute(asElement(s),t)}));i&&r.push(...findAttributeTargets(i,t))}return r.length===0?(logError('The selector "'+n+'" on '+t+" returned no matches!"),[DUMMY_ELT]):r}}}function findThisElement(e,t){return asElement(getClosestMatch(e,function(n){return getAttributeValue(asElement(n),t)!=null}))}function getTarget(e){let t=getClosestAttributeValue(e,"hx-target");return t?t==="this"?findThisElement(e,"hx-target"):querySelectorExt(e,t):getInternalData(e).boosted?getDocument().body:e}function shouldSettleAttribute(e){return htmx.config.attributesToSettle.includes(e)}function cloneAttributes(e,t){forEach(Array.from(e.attributes),function(n){!t.hasAttribute(n.name)&&shouldSettleAttribute(n.name)&&e.removeAttribute(n.name)}),forEach(t.attributes,function(n){shouldSettleAttribute(n.name)&&e.setAttribute(n.name,n.value)})}function isInlineSwap(e,t){let n=getExtensions(t);for(let r=0;r<n.length;r++){let o=n[r];try{if(o.isInlineSwap(e))return!0}catch(i){logError(i)}}return e==="outerHTML"}function oobSwap(e,t,n,r){r=r||getDocument();let o="#"+CSS.escape(getRawAttribute(t,"id")),i="outerHTML";e==="true"||(e.indexOf(":")>0?(i=e.substring(0,e.indexOf(":")),o=e.substring(e.indexOf(":")+1)):i=e),t.removeAttribute("hx-swap-oob"),t.removeAttribute("data-hx-swap-oob");let s=querySelectorAllExt(r,o,!1);return s.length?(forEach(s,function(a){let l,c=t.cloneNode(!0);l=getDocument().createDocumentFragment(),l.appendChild(c),isInlineSwap(i,a)||(l=asParentNode(c));let h={shouldSwap:!0,target:a,fragment:l};triggerEvent(a,"htmx:oobBeforeSwap",h)&&(a=h.target,h.shouldSwap&&(handlePreservedElements(l),swapWithStyle(i,a,a,l,n),restorePreservedElements()),forEach(n.elts,function(u){triggerEvent(u,"htmx:oobAfterSwap",h)}))}),t.parentNode.removeChild(t)):(t.parentNode.removeChild(t),triggerErrorEvent(getDocument().body,"htmx:oobErrorNoTarget",{content:t})),e}function restorePreservedElements(){let e=find("#--htmx-preserve-pantry--");if(e){for(let t of[...e.children]){let n=find("#"+t.id);n.parentNode.moveBefore(t,n),n.remove()}e.remove()}}function handlePreservedElements(e){forEach(findAll(e,"[hx-preserve], [data-hx-preserve]"),function(t){let n=getAttributeValue(t,"id"),r=getDocument().getElementById(n);if(r!=null)if(t.moveBefore){let o=find("#--htmx-preserve-pantry--");o==null&&(getDocument().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>"),o=find("#--htmx-preserve-pantry--")),o.moveBefore(r,null)}else t.parentNode.replaceChild(r,t)})}function handleAttributes(e,t,n){forEach(t.querySelectorAll("[id]"),function(r){let o=getRawAttribute(r,"id");if(o&&o.length>0){let i=o.replace("'","\\'"),s=r.tagName.replace(":","\\:"),a=asParentNode(e),l=a&&a.querySelector(s+"[id='"+i+"']");if(l&&l!==a){let c=r.cloneNode();cloneAttributes(r,l),n.tasks.push(function(){cloneAttributes(r,c)})}}})}function makeAjaxLoadTask(e){return function(){removeClassFromElement(e,htmx.config.addedClass),processNode(asElement(e)),processFocus(asParentNode(e)),triggerEvent(e,"htmx:load")}}function processFocus(e){let t="[autofocus]",n=asHtmlElement(matches(e,t)?e:e.querySelector(t));n?.focus()}function insertNodesBefore(e,t,n,r){for(handleAttributes(e,n,r);n.childNodes.length>0;){let o=n.firstChild;addClassToElement(asElement(o),htmx.config.addedClass),e.insertBefore(o,t),o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE&&r.tasks.push(makeAjaxLoadTask(o))}}function stringHash(e,t){let n=0;for(;n<e.length;)t=(t<<5)-t+e.charCodeAt(n++)|0;return t}function attributeHash(e){let t=0;for(let n=0;n<e.attributes.length;n++){let r=e.attributes[n];r.value&&(t=stringHash(r.name,t),t=stringHash(r.value,t))}return t}function deInitOnHandlers(e){let t=getInternalData(e);if(t.onHandlers){for(let n=0;n<t.onHandlers.length;n++){let r=t.onHandlers[n];removeEventListenerImpl(e,r.event,r.listener)}delete t.onHandlers}}function deInitNode(e){let t=getInternalData(e);t.timeout&&clearTimeout(t.timeout),t.listenerInfos&&forEach(t.listenerInfos,function(n){n.on&&removeEventListenerImpl(n.on,n.trigger,n.listener)}),deInitOnHandlers(e),forEach(Object.keys(t),function(n){n!=="firstInitCompleted"&&delete t[n]})}function cleanUpElement(e){triggerEvent(e,"htmx:beforeCleanupElement"),deInitNode(e),forEach(e.children,function(t){cleanUpElement(t)})}function swapOuterHTML(e,t,n){if(e.tagName==="BODY")return swapInnerHTML(e,t,n);let r,o=e.previousSibling,i=parentElt(e);if(i){for(insertNodesBefore(i,e,t,n),o==null?r=i.firstChild:r=o.nextSibling,n.elts=n.elts.filter(function(s){return s!==e});r&&r!==e;)r instanceof Element&&n.elts.push(r),r=r.nextSibling;cleanUpElement(e),e.remove()}}function swapAfterBegin(e,t,n){return insertNodesBefore(e,e.firstChild,t,n)}function swapBeforeBegin(e,t,n){return insertNodesBefore(parentElt(e),e,t,n)}function swapBeforeEnd(e,t,n){return insertNodesBefore(e,null,t,n)}function swapAfterEnd(e,t,n){return insertNodesBefore(parentElt(e),e.nextSibling,t,n)}function swapDelete(e){cleanUpElement(e);let t=parentElt(e);if(t)return t.removeChild(e)}function swapInnerHTML(e,t,n){let r=e.firstChild;if(insertNodesBefore(e,r,t,n),r){for(;r.nextSibling;)cleanUpElement(r.nextSibling),e.removeChild(r.nextSibling);cleanUpElement(r),e.removeChild(r)}}function swapWithStyle(e,t,n,r,o){switch(e){case"none":return;case"outerHTML":swapOuterHTML(n,r,o);return;case"afterbegin":swapAfterBegin(n,r,o);return;case"beforebegin":swapBeforeBegin(n,r,o);return;case"beforeend":swapBeforeEnd(n,r,o);return;case"afterend":swapAfterEnd(n,r,o);return;case"delete":swapDelete(n);return;default:var i=getExtensions(t);for(let s=0;s<i.length;s++){let a=i[s];try{let l=a.handleSwap(e,n,r,o);if(l){if(Array.isArray(l))for(let c=0;c<l.length;c++){let h=l[c];h.nodeType!==Node.TEXT_NODE&&h.nodeType!==Node.COMMENT_NODE&&o.tasks.push(makeAjaxLoadTask(h))}return}}catch(l){logError(l)}}e==="innerHTML"?swapInnerHTML(n,r,o):swapWithStyle(htmx.config.defaultSwapStyle,t,n,r,o)}}function findAndSwapOobElements(e,t,n){var r=findAll(e,"[hx-swap-oob], [data-hx-swap-oob]");return forEach(r,function(o){if(htmx.config.allowNestedOobSwaps||o.parentElement===null){let i=getAttributeValue(o,"hx-swap-oob");i!=null&&oobSwap(i,o,t,n)}else o.removeAttribute("hx-swap-oob"),o.removeAttribute("data-hx-swap-oob")}),r.length>0}function swap(e,t,n,r){r||(r={});let o=null,i=null,s=function(){maybeCall(r.beforeSwapCallback),e=resolveTarget(e);let c=r.contextElement?getRootNode(r.contextElement,!1):getDocument(),h=document.activeElement,u={};u={elt:h,start:h?h.selectionStart:null,end:h?h.selectionEnd:null};let f=makeSettleInfo(e);if(n.swapStyle==="textContent")e.textContent=t;else{let d=makeFragment(t);if(f.title=r.title||d.title,r.historyRequest&&(d=d.querySelector("[hx-history-elt],[data-hx-history-elt]")||d),r.selectOOB){let y=r.selectOOB.split(",");for(let m=0;m<y.length;m++){let x=y[m].split(":",2),b=x[0].trim();b.indexOf("#")===0&&(b=b.substring(1));let E=x[1]||"true",g=d.querySelector("#"+b);g&&oobSwap(E,g,f,c)}}if(findAndSwapOobElements(d,f,c),forEach(findAll(d,"template"),function(y){y.content&&findAndSwapOobElements(y.content,f,c)&&y.remove()}),r.select){let y=getDocument().createDocumentFragment();forEach(d.querySelectorAll(r.select),function(m){y.appendChild(m)}),d=y}handlePreservedElements(d),swapWithStyle(n.swapStyle,r.contextElement,e,d,f),restorePreservedElements()}if(u.elt&&!bodyContains(u.elt)&&getRawAttribute(u.elt,"id")){let d=document.getElementById(getRawAttribute(u.elt,"id")),y={preventScroll:n.focusScroll!==void 0?!n.focusScroll:!htmx.config.defaultFocusScroll};if(d){if(u.start&&d.setSelectionRange)try{d.setSelectionRange(u.start,u.end)}catch{}d.focus(y)}}e.classList.remove(htmx.config.swappingClass),forEach(f.elts,function(d){d.classList&&d.classList.add(htmx.config.settlingClass),triggerEvent(d,"htmx:afterSwap",r.eventInfo)}),maybeCall(r.afterSwapCallback),n.ignoreTitle||handleTitle(f.title);let v=function(){if(forEach(f.tasks,function(d){d.call()}),forEach(f.elts,function(d){d.classList&&d.classList.remove(htmx.config.settlingClass),triggerEvent(d,"htmx:afterSettle",r.eventInfo)}),r.anchor){let d=asElement(resolveTarget("#"+r.anchor));d&&d.scrollIntoView({block:"start",behavior:"auto"})}updateScrollState(f.elts,n),maybeCall(r.afterSettleCallback),maybeCall(o)};n.settleDelay>0?getWindow().setTimeout(v,n.settleDelay):v()},a=htmx.config.globalViewTransitions;n.hasOwnProperty("transition")&&(a=n.transition);let l=r.contextElement||getDocument();if(a&&triggerEvent(l,"htmx:beforeTransition",r.eventInfo)&&typeof Promise<"u"&&document.startViewTransition){let c=new Promise(function(u,f){o=u,i=f}),h=s;s=function(){document.startViewTransition(function(){return h(),c})}}try{n?.swapDelay&&n.swapDelay>0?getWindow().setTimeout(s,n.swapDelay):s()}catch(c){throw triggerErrorEvent(l,"htmx:swapError",r.eventInfo),maybeCall(i),c}}function handleTriggerHeader(e,t,n){let r=e.getResponseHeader(t);if(r.indexOf("{")===0){let o=parseJSON(r);for(let i in o)if(o.hasOwnProperty(i)){let s=o[i];isRawObject(s)?n=s.target!==void 0?s.target:n:s={value:s},triggerEvent(n,i,s)}}else{let o=r.split(",");for(let i=0;i<o.length;i++)triggerEvent(n,o[i].trim(),[])}}let WHITESPACE=/\s/,WHITESPACE_OR_COMMA=/[\s,]/,SYMBOL_START=/[_$a-zA-Z]/,SYMBOL_CONT=/[_$a-zA-Z0-9]/,STRINGISH_START=['"',"'","/"],NOT_WHITESPACE=/[^\s]/,COMBINED_SELECTOR_START=/[{(]/,COMBINED_SELECTOR_END=/[})]/;function tokenizeString(e){let t=[],n=0;for(;n<e.length;){if(SYMBOL_START.exec(e.charAt(n))){for(var r=n;SYMBOL_CONT.exec(e.charAt(n+1));)n++;t.push(e.substring(r,n+1))}else if(STRINGISH_START.indexOf(e.charAt(n))!==-1){let o=e.charAt(n);var r=n;for(n++;n<e.length&&e.charAt(n)!==o;)e.charAt(n)==="\\"&&n++,n++;t.push(e.substring(r,n+1))}else{let o=e.charAt(n);t.push(o)}n++}return t}function isPossibleRelativeReference(e,t,n){return SYMBOL_START.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function maybeGenerateConditional(e,t,n){if(t[0]==="["){t.shift();let r=1,o=" return (function("+n+"){ return (",i=null;for(;t.length>0;){let s=t[0];if(s==="]"){if(r--,r===0){i===null&&(o=o+"true"),t.shift(),o+=")})";try{let a=maybeEval(e,function(){return Function(o)()},function(){return!0});return a.source=o,a}catch(a){return triggerErrorEvent(getDocument().body,"htmx:syntax:error",{error:a,source:o}),null}}}else s==="["&&r++;isPossibleRelativeReference(s,i,n)?o+="(("+n+"."+s+") ? ("+n+"."+s+") : (window."+s+"))":o=o+s,i=t.shift()}}}function consumeUntil(e,t){let n="";for(;e.length>0&&!t.test(e[0]);)n+=e.shift();return n}function consumeCSSSelector(e){let t;return e.length>0&&COMBINED_SELECTOR_START.test(e[0])?(e.shift(),t=consumeUntil(e,COMBINED_SELECTOR_END).trim(),e.shift()):t=consumeUntil(e,WHITESPACE_OR_COMMA),t}let INPUT_SELECTOR="input, textarea, select";function parseAndCacheTrigger(e,t,n){let r=[],o=tokenizeString(t);do{consumeUntil(o,NOT_WHITESPACE);let a=o.length,l=consumeUntil(o,/[,\[\s]/);if(l!=="")if(l==="every"){let c={trigger:"every"};consumeUntil(o,NOT_WHITESPACE),c.pollInterval=parseInterval(consumeUntil(o,/[,\[\s]/)),consumeUntil(o,NOT_WHITESPACE);var i=maybeGenerateConditional(e,o,"event");i&&(c.eventFilter=i),r.push(c)}else{let c={trigger:l};var i=maybeGenerateConditional(e,o,"event");for(i&&(c.eventFilter=i),consumeUntil(o,NOT_WHITESPACE);o.length>0&&o[0]!==",";){let u=o.shift();if(u==="changed")c.changed=!0;else if(u==="once")c.once=!0;else if(u==="consume")c.consume=!0;else if(u==="delay"&&o[0]===":")o.shift(),c.delay=parseInterval(consumeUntil(o,WHITESPACE_OR_COMMA));else if(u==="from"&&o[0]===":"){if(o.shift(),COMBINED_SELECTOR_START.test(o[0]))var s=consumeCSSSelector(o);else{var s=consumeUntil(o,WHITESPACE_OR_COMMA);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();let v=consumeCSSSelector(o);v.length>0&&(s+=" "+v)}}c.from=s}else u==="target"&&o[0]===":"?(o.shift(),c.target=consumeCSSSelector(o)):u==="throttle"&&o[0]===":"?(o.shift(),c.throttle=parseInterval(consumeUntil(o,WHITESPACE_OR_COMMA))):u==="queue"&&o[0]===":"?(o.shift(),c.queue=consumeUntil(o,WHITESPACE_OR_COMMA)):u==="root"&&o[0]===":"?(o.shift(),c[u]=consumeCSSSelector(o)):u==="threshold"&&o[0]===":"?(o.shift(),c[u]=consumeUntil(o,WHITESPACE_OR_COMMA)):triggerErrorEvent(e,"htmx:syntax:error",{token:o.shift()});consumeUntil(o,NOT_WHITESPACE)}r.push(c)}o.length===a&&triggerErrorEvent(e,"htmx:syntax:error",{token:o.shift()}),consumeUntil(o,NOT_WHITESPACE)}while(o[0]===","&&o.shift());return n&&(n[t]=r),r}function getTriggerSpecs(e){let t=getAttributeValue(e,"hx-trigger"),n=[];if(t){let r=htmx.config.triggerSpecsCache;n=r&&r[t]||parseAndCacheTrigger(e,t,r)}return n.length>0?n:matches(e,"form")?[{trigger:"submit"}]:matches(e,'input[type="button"], input[type="submit"]')?[{trigger:"click"}]:matches(e,INPUT_SELECTOR)?[{trigger:"change"}]:[{trigger:"click"}]}function cancelPolling(e){getInternalData(e).cancelled=!0}function processPolling(e,t,n){let r=getInternalData(e);r.timeout=getWindow().setTimeout(function(){bodyContains(e)&&r.cancelled!==!0&&(maybeFilterEvent(n,e,makeEvent("hx:poll:trigger",{triggerSpec:n,target:e}))||t(e),processPolling(e,t,n))},n.pollInterval)}function isLocalLink(e){return location.hostname===e.hostname&&getRawAttribute(e,"href")&&getRawAttribute(e,"href").indexOf("#")!==0}function eltIsDisabled(e){return closest(e,htmx.config.disableSelector)}function boostElement(e,t,n){if(e instanceof HTMLAnchorElement&&isLocalLink(e)&&(e.target===""||e.target==="_self")||e.tagName==="FORM"&&String(getRawAttribute(e,"method")).toLowerCase()!=="dialog"){t.boosted=!0;let r,o;if(e.tagName==="A")r="get",o=getRawAttribute(e,"href");else{let i=getRawAttribute(e,"method");r=i?i.toLowerCase():"get",o=getRawAttribute(e,"action"),(o==null||o==="")&&(o=location.href),r==="get"&&o.includes("?")&&(o=o.replace(/\?[^#]+/,""))}n.forEach(function(i){addEventListener(e,function(s,a){let l=asElement(s);if(eltIsDisabled(l)){cleanUpElement(l);return}issueAjaxRequest(r,o,l,a)},t,i,!0)})}}function shouldCancel(e,t){if(e.type==="submit"&&t.tagName==="FORM")return!0;if(e.type==="click"){let n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit")return!0;let r=t.closest("a"),o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href")))return!0}return!1}function ignoreBoostedAnchorCtrlClick(e,t){return getInternalData(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function maybeFilterEvent(e,t,n){let r=e.eventFilter;if(r)try{return r.call(t,n)!==!0}catch(o){let i=r.source;return triggerErrorEvent(getDocument().body,"htmx:eventFilter:error",{error:o,source:i}),!0}return!1}function addEventListener(e,t,n,r,o){let i=getInternalData(e),s;r.from?s=querySelectorAllExt(e,r.from):s=[e],r.changed&&("lastValue"in i||(i.lastValue=new WeakMap),s.forEach(function(a){i.lastValue.has(r)||i.lastValue.set(r,new WeakMap),i.lastValue.get(r).set(a,a.value)})),forEach(s,function(a){let l=function(c){if(!bodyContains(e)){a.removeEventListener(r.trigger,l);return}if(ignoreBoostedAnchorCtrlClick(e,c)||((o||shouldCancel(c,a))&&c.preventDefault(),maybeFilterEvent(r,e,c)))return;let h=getInternalData(c);if(h.triggerSpec=r,h.handledFor==null&&(h.handledFor=[]),h.handledFor.indexOf(e)<0){if(h.handledFor.push(e),r.consume&&c.stopPropagation(),r.target&&c.target&&!matches(asElement(c.target),r.target))return;if(r.once){if(i.triggeredOnce)return;i.triggeredOnce=!0}if(r.changed){let u=c.target,f=u.value,v=i.lastValue.get(r);if(v.has(u)&&v.get(u)===f)return;v.set(u,f)}if(i.delayed&&clearTimeout(i.delayed),i.throttle)return;r.throttle>0?i.throttle||(triggerEvent(e,"htmx:trigger"),t(e,c),i.throttle=getWindow().setTimeout(function(){i.throttle=null},r.throttle)):r.delay>0?i.delayed=getWindow().setTimeout(function(){triggerEvent(e,"htmx:trigger"),t(e,c)},r.delay):(triggerEvent(e,"htmx:trigger"),t(e,c))}};n.listenerInfos==null&&(n.listenerInfos=[]),n.listenerInfos.push({trigger:r.trigger,listener:l,on:a}),a.addEventListener(r.trigger,l)})}let windowIsScrolling=!1,scrollHandler=null;function initScrollHandler(){scrollHandler||(scrollHandler=function(){windowIsScrolling=!0},window.addEventListener("scroll",scrollHandler),window.addEventListener("resize",scrollHandler),setInterval(function(){windowIsScrolling&&(windowIsScrolling=!1,forEach(getDocument().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){maybeReveal(e)}))},200))}function maybeReveal(e){!hasAttribute(e,"data-hx-revealed")&&isScrolledIntoView(e)&&(e.setAttribute("data-hx-revealed","true"),getInternalData(e).initHash?triggerEvent(e,"revealed"):e.addEventListener("htmx:afterProcessNode",function(){triggerEvent(e,"revealed")},{once:!0}))}function loadImmediately(e,t,n,r){let o=function(){n.loaded||(n.loaded=!0,triggerEvent(e,"htmx:trigger"),t(e))};r>0?getWindow().setTimeout(o,r):o()}function processVerbs(e,t,n){let r=!1;return forEach(VERBS,function(o){if(hasAttribute(e,"hx-"+o)){let i=getAttributeValue(e,"hx-"+o);r=!0,t.path=i,t.verb=o,n.forEach(function(s){addTriggerHandler(e,s,t,function(a,l){let c=asElement(a);if(eltIsDisabled(c)){cleanUpElement(c);return}issueAjaxRequest(o,i,c,l)})})}}),r}function addTriggerHandler(e,t,n,r){if(t.trigger==="revealed")initScrollHandler(),addEventListener(e,r,n,t),maybeReveal(asElement(e));else if(t.trigger==="intersect"){let o={};t.root&&(o.root=querySelectorExt(e,t.root)),t.threshold&&(o.threshold=parseFloat(t.threshold)),new IntersectionObserver(function(s){for(let a=0;a<s.length;a++)if(s[a].isIntersecting){triggerEvent(e,"intersect");break}},o).observe(asElement(e)),addEventListener(asElement(e),r,n,t)}else!n.firstInitCompleted&&t.trigger==="load"?maybeFilterEvent(t,e,makeEvent("load",{elt:e}))||loadImmediately(asElement(e),r,n,t.delay):t.pollInterval>0?(n.polling=!0,processPolling(asElement(e),r,t)):addEventListener(e,r,n,t)}function shouldProcessHxOn(e){let t=asElement(e);if(!t)return!1;let n=t.attributes;for(let r=0;r<n.length;r++){let o=n[r].name;if(startsWith(o,"hx-on:")||startsWith(o,"data-hx-on:")||startsWith(o,"hx-on-")||startsWith(o,"data-hx-on-"))return!0}return!1}let HX_ON_QUERY=new XPathEvaluator().createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function processHXOnRoot(e,t){shouldProcessHxOn(e)&&t.push(asElement(e));let n=HX_ON_QUERY.evaluate(e),r=null;for(;r=n.iterateNext();)t.push(asElement(r))}function findHxOnWildcardElements(e){let t=[];if(e instanceof DocumentFragment)for(let n of e.childNodes)processHXOnRoot(n,t);else processHXOnRoot(e,t);return t}function findElementsToProcess(e){if(e.querySelectorAll){let n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]",r=[];for(let i in extensions){let s=extensions[i];if(s.getSelectors){var t=s.getSelectors();t&&r.push(t)}}return e.querySelectorAll(VERB_SELECTOR+n+", form, [type='submit'], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(i=>", "+i).join(""))}else return[]}function maybeSetLastButtonClicked(e){let t=getTargetButton(e.target),n=getRelatedFormData(e);n&&(n.lastButtonClicked=t)}function maybeUnsetLastButtonClicked(e){let t=getRelatedFormData(e);t&&(t.lastButtonClicked=null)}function getTargetButton(e){return closest(asElement(e),"button, input[type='submit']")}function getRelatedForm(e){return e.form||closest(e,"form")}function getRelatedFormData(e){let t=getTargetButton(e.target);if(!t)return;let n=getRelatedForm(t);if(n)return getInternalData(n)}function initButtonTracking(e){e.addEventListener("click",maybeSetLastButtonClicked),e.addEventListener("focusin",maybeSetLastButtonClicked),e.addEventListener("focusout",maybeUnsetLastButtonClicked)}function addHxOnEventHandler(e,t,n){let r=getInternalData(e);Array.isArray(r.onHandlers)||(r.onHandlers=[]);let o,i=function(s){maybeEval(e,function(){eltIsDisabled(e)||(o||(o=new Function("event",n)),o.call(e,s))})};e.addEventListener(t,i),r.onHandlers.push({event:t,listener:i})}function processHxOnWildcard(e){deInitOnHandlers(e);for(let t=0;t<e.attributes.length;t++){let n=e.attributes[t].name,r=e.attributes[t].value;if(startsWith(n,"hx-on")||startsWith(n,"data-hx-on")){let o=n.indexOf("-on")+3,i=n.slice(o,o+1);if(i==="-"||i===":"){let s=n.slice(o+1);startsWith(s,":")?s="htmx"+s:startsWith(s,"-")?s="htmx:"+s.slice(1):startsWith(s,"htmx-")&&(s="htmx:"+s.slice(5)),addHxOnEventHandler(e,s,r)}}}}function initNode(e){triggerEvent(e,"htmx:beforeProcessNode");let t=getInternalData(e),n=getTriggerSpecs(e);processVerbs(e,t,n)||(getClosestAttributeValue(e,"hx-boost")==="true"?boostElement(e,t,n):hasAttribute(e,"hx-trigger")&&n.forEach(function(o){addTriggerHandler(e,o,t,function(){})})),(e.tagName==="FORM"||getRawAttribute(e,"type")==="submit"&&hasAttribute(e,"form"))&&initButtonTracking(e),t.firstInitCompleted=!0,triggerEvent(e,"htmx:afterProcessNode")}function maybeDeInitAndHash(e){if(!(e instanceof Element))return!1;let t=getInternalData(e),n=attributeHash(e);return t.initHash!==n?(deInitNode(e),t.initHash=n,!0):!1}function processNode(e){if(e=resolveTarget(e),eltIsDisabled(e)){cleanUpElement(e);return}let t=[];maybeDeInitAndHash(e)&&t.push(e),forEach(findElementsToProcess(e),function(n){if(eltIsDisabled(n)){cleanUpElement(n);return}maybeDeInitAndHash(n)&&t.push(n)}),forEach(findHxOnWildcardElements(e),processHxOnWildcard),forEach(t,initNode)}function kebabEventName(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function makeEvent(e,t){return new CustomEvent(e,{bubbles:!0,cancelable:!0,composed:!0,detail:t})}function triggerErrorEvent(e,t,n){triggerEvent(e,t,mergeObjects({error:t},n))}function ignoreEventForLogging(e){return e==="htmx:afterProcessNode"}function withExtensions(e,t,n){forEach(getExtensions(e,[],n),function(r){try{t(r)}catch(o){logError(o)}})}function logError(e){console.error(e)}function triggerEvent(e,t,n){e=resolveTarget(e),n==null&&(n={}),n.elt=e;let r=makeEvent(t,n);htmx.logger&&!ignoreEventForLogging(t)&&htmx.logger(e,t,n),n.error&&(logError(n.error),triggerEvent(e,"htmx:error",{errorInfo:n}));let o=e.dispatchEvent(r),i=kebabEventName(t);if(o&&i!==t){let s=makeEvent(i,r.detail);o=o&&e.dispatchEvent(s)}return withExtensions(asElement(e),function(s){o=o&&s.onEvent(t,r)!==!1&&!r.defaultPrevented}),o}let currentPathForHistory;function setCurrentPathForHistory(e){currentPathForHistory=e,canAccessLocalStorage()&&sessionStorage.setItem("htmx-current-path-for-history",e)}setCurrentPathForHistory(location.pathname+location.search);function getHistoryElement(){return getDocument().querySelector("[hx-history-elt],[data-hx-history-elt]")||getDocument().body}function saveToHistoryCache(e,t){if(!canAccessLocalStorage())return;let n=cleanInnerHtmlForHistory(t),r=getDocument().title,o=window.scrollY;if(htmx.config.historyCacheSize<=0){sessionStorage.removeItem("htmx-history-cache");return}e=normalizePath(e);let i=parseJSON(sessionStorage.getItem("htmx-history-cache"))||[];for(let a=0;a<i.length;a++)if(i[a].url===e){i.splice(a,1);break}let s={url:e,content:n,title:r,scroll:o};for(triggerEvent(getDocument().body,"htmx:historyItemCreated",{item:s,cache:i}),i.push(s);i.length>htmx.config.historyCacheSize;)i.shift();for(;i.length>0;)try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(a){triggerErrorEvent(getDocument().body,"htmx:historyCacheError",{cause:a,cache:i}),i.shift()}}function getCachedHistory(e){if(!canAccessLocalStorage())return null;e=normalizePath(e);let t=parseJSON(sessionStorage.getItem("htmx-history-cache"))||[];for(let n=0;n<t.length;n++)if(t[n].url===e)return t[n];return null}function cleanInnerHtmlForHistory(e){let t=htmx.config.requestClass,n=e.cloneNode(!0);return forEach(findAll(n,"."+t),function(r){removeClassFromElement(r,t)}),forEach(findAll(n,"[data-disabled-by-htmx]"),function(r){r.removeAttribute("disabled")}),n.innerHTML}function saveCurrentPageToHistory(){let e=getHistoryElement(),t=currentPathForHistory;canAccessLocalStorage()&&(t=sessionStorage.getItem("htmx-current-path-for-history")),t=t||location.pathname+location.search,getDocument().querySelector('[hx-history="false" i],[data-hx-history="false" i]')||(triggerEvent(getDocument().body,"htmx:beforeHistorySave",{path:t,historyElt:e}),saveToHistoryCache(t,e)),htmx.config.historyEnabled&&history.replaceState({htmx:!0},getDocument().title,location.href)}function pushUrlIntoHistory(e){htmx.config.getCacheBusterParam&&(e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,""),(endsWith(e,"&")||endsWith(e,"?"))&&(e=e.slice(0,-1))),htmx.config.historyEnabled&&history.pushState({htmx:!0},"",e),setCurrentPathForHistory(e)}function replaceUrlInHistory(e){htmx.config.historyEnabled&&history.replaceState({htmx:!0},"",e),setCurrentPathForHistory(e)}function settleImmediately(e){forEach(e,function(t){t.call(void 0)})}function loadHistoryFromServer(e){let t=new XMLHttpRequest,n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0},r={path:e,xhr:t,historyElt:getHistoryElement(),swapSpec:n};t.open("GET",e,!0),htmx.config.historyRestoreAsHxRequest&&t.setRequestHeader("HX-Request","true"),t.setRequestHeader("HX-History-Restore-Request","true"),t.setRequestHeader("HX-Current-URL",location.href),t.onload=function(){this.status>=200&&this.status<400?(r.response=this.response,triggerEvent(getDocument().body,"htmx:historyCacheMissLoad",r),swap(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:!0}),setCurrentPathForHistory(r.path),triggerEvent(getDocument().body,"htmx:historyRestore",{path:e,cacheMiss:!0,serverResponse:r.response})):triggerErrorEvent(getDocument().body,"htmx:historyCacheMissLoadError",r)},triggerEvent(getDocument().body,"htmx:historyCacheMiss",r)&&t.send()}function restoreHistory(e){saveCurrentPageToHistory(),e=e||location.pathname+location.search;let t=getCachedHistory(e);if(t){let n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll},r={path:e,item:t,historyElt:getHistoryElement(),swapSpec:n};triggerEvent(getDocument().body,"htmx:historyCacheHit",r)&&(swap(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title}),setCurrentPathForHistory(r.path),triggerEvent(getDocument().body,"htmx:historyRestore",r))}else htmx.config.refreshOnHistoryMiss?htmx.location.reload(!0):loadHistoryFromServer(e)}function addRequestIndicatorClasses(e){let t=findAttributeTargets(e,"hx-indicator");return t==null&&(t=[e]),forEach(t,function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||0)+1,n.classList.add.call(n.classList,htmx.config.requestClass)}),t}function disableElements(e){let t=findAttributeTargets(e,"hx-disabled-elt");return t==null&&(t=[]),forEach(t,function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||0)+1,n.setAttribute("disabled",""),n.setAttribute("data-disabled-by-htmx","")}),t}function removeRequestIndicators(e,t){forEach(e.concat(t),function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||1)-1}),forEach(e,function(n){getInternalData(n).requestCount===0&&n.classList.remove.call(n.classList,htmx.config.requestClass)}),forEach(t,function(n){getInternalData(n).requestCount===0&&(n.removeAttribute("disabled"),n.removeAttribute("data-disabled-by-htmx"))})}function haveSeenNode(e,t){for(let n=0;n<e.length;n++)if(e[n].isSameNode(t))return!0;return!1}function shouldInclude(e){let t=e;return t.name===""||t.name==null||t.disabled||closest(t,"fieldset[disabled]")||t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"?!1:t.type==="checkbox"||t.type==="radio"?t.checked:!0}function addValueToFormData(e,t,n){e!=null&&t!=null&&(Array.isArray(t)?t.forEach(function(r){n.append(e,r)}):n.append(e,t))}function removeValueFromFormData(e,t,n){if(e!=null&&t!=null){let r=n.getAll(e);Array.isArray(t)?r=r.filter(o=>t.indexOf(o)<0):r=r.filter(o=>o!==t),n.delete(e),forEach(r,o=>n.append(e,o))}}function getValueFromInput(e){return e instanceof HTMLSelectElement&&e.multiple?toArray(e.querySelectorAll("option:checked")).map(function(t){return t.value}):e instanceof HTMLInputElement&&e.files?toArray(e.files):e.value}function processInputValue(e,t,n,r,o){if(!(r==null||haveSeenNode(e,r))){if(e.push(r),shouldInclude(r)){let i=getRawAttribute(r,"name");addValueToFormData(i,getValueFromInput(r),t),o&&validateElement(r,n)}r instanceof HTMLFormElement&&(forEach(r.elements,function(i){e.indexOf(i)>=0?removeValueFromFormData(i.name,getValueFromInput(i),t):e.push(i),o&&validateElement(i,n)}),new FormData(r).forEach(function(i,s){i instanceof File&&i.name===""||addValueToFormData(s,i,t)}))}}function validateElement(e,t){let n=e;n.willValidate&&(triggerEvent(n,"htmx:validation:validate"),n.checkValidity()||(triggerEvent(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&htmx.config.reportValidityOfForms&&n.reportValidity(),t.push({elt:n,message:n.validationMessage,validity:n.validity})))}function overrideFormData(e,t){for(let n of t.keys())e.delete(n);return t.forEach(function(n,r){e.append(r,n)}),e}function getInputValues(e,t){let n=[],r=new FormData,o=new FormData,i=[],s=getInternalData(e);s.lastButtonClicked&&!bodyContains(s.lastButtonClicked)&&(s.lastButtonClicked=null);let a=e instanceof HTMLFormElement&&e.noValidate!==!0||getAttributeValue(e,"hx-validate")==="true";if(s.lastButtonClicked&&(a=a&&s.lastButtonClicked.formNoValidate!==!0),t!=="get"&&processInputValue(n,o,i,getRelatedForm(e),a),processInputValue(n,r,i,e,a),s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&getRawAttribute(e,"type")==="submit"){let c=s.lastButtonClicked||e,h=getRawAttribute(c,"name");addValueToFormData(h,c.value,o)}let l=findAttributeTargets(e,"hx-include");return forEach(l,function(c){processInputValue(n,r,i,asElement(c),a),matches(c,"form")||forEach(asParentNode(c).querySelectorAll(INPUT_SELECTOR),function(h){processInputValue(n,r,i,h,a)})}),overrideFormData(r,o),{errors:i,formData:r,values:formDataProxy(r)}}function appendParam(e,t,n){e!==""&&(e+="&"),String(n)==="[object Object]"&&(n=JSON.stringify(n));let r=encodeURIComponent(n);return e+=encodeURIComponent(t)+"="+r,e}function urlEncode(e){e=formDataFromObject(e);let t="";return e.forEach(function(n,r){t=appendParam(t,r,n)}),t}function getHeaders(e,t,n){let r={"HX-Request":"true","HX-Trigger":getRawAttribute(e,"id"),"HX-Trigger-Name":getRawAttribute(e,"name"),"HX-Target":getAttributeValue(t,"id"),"HX-Current-URL":location.href};return getValuesForElement(e,"hx-headers",!1,r),n!==void 0&&(r["HX-Prompt"]=n),getInternalData(e).boosted&&(r["HX-Boosted"]="true"),r}function filterValues(e,t){let n=getClosestAttributeValue(t,"hx-params");if(n){if(n==="none")return new FormData;if(n==="*")return e;if(n.indexOf("not ")===0)return forEach(n.slice(4).split(","),function(r){r=r.trim(),e.delete(r)}),e;{let r=new FormData;return forEach(n.split(","),function(o){o=o.trim(),e.has(o)&&e.getAll(o).forEach(function(i){r.append(o,i)})}),r}}else return e}function isAnchorLink(e){return!!getRawAttribute(e,"href")&&getRawAttribute(e,"href").indexOf("#")>=0}function getSwapSpecification(e,t){let n=t||getClosestAttributeValue(e,"hx-swap"),r={swapStyle:getInternalData(e).boosted?"innerHTML":htmx.config.defaultSwapStyle,swapDelay:htmx.config.defaultSwapDelay,settleDelay:htmx.config.defaultSettleDelay};if(htmx.config.scrollIntoViewOnBoost&&getInternalData(e).boosted&&!isAnchorLink(e)&&(r.show="top"),n){let s=splitOnWhitespace(n);if(s.length>0)for(let a=0;a<s.length;a++){let l=s[a];if(l.indexOf("swap:")===0)r.swapDelay=parseInterval(l.slice(5));else if(l.indexOf("settle:")===0)r.settleDelay=parseInterval(l.slice(7));else if(l.indexOf("transition:")===0)r.transition=l.slice(11)==="true";else if(l.indexOf("ignoreTitle:")===0)r.ignoreTitle=l.slice(12)==="true";else if(l.indexOf("scroll:")===0){var o=l.slice(7).split(":");let h=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=h,r.scrollTarget=i}else if(l.indexOf("show:")===0){var o=l.slice(5).split(":");let u=o.pop();var i=o.length>0?o.join(":"):null;r.show=u,r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){let c=l.slice(13);r.focusScroll=c=="true"}else a==0?r.swapStyle=l:logError("Unknown modifier in hx-swap: "+l)}}return r}function usesFormData(e){return getClosestAttributeValue(e,"hx-encoding")==="multipart/form-data"||matches(e,"form")&&getRawAttribute(e,"enctype")==="multipart/form-data"}function encodeParamsForBody(e,t,n){let r=null;return withExtensions(t,function(o){r==null&&(r=o.encodeParameters(e,n,t))}),r??(usesFormData(t)?overrideFormData(new FormData,formDataFromObject(n)):urlEncode(n))}function makeSettleInfo(e){return{tasks:[],elts:[e]}}function updateScrollState(e,t){let n=e[0],r=e[e.length-1];if(t.scroll){var o=null;t.scrollTarget&&(o=asElement(querySelectorExt(n,t.scrollTarget))),t.scroll==="top"&&(n||o)&&(o=o||n,o.scrollTop=0),t.scroll==="bottom"&&(r||o)&&(o=o||r,o.scrollTop=o.scrollHeight),typeof t.scroll=="number"&&getWindow().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}if(t.show){var o=null;if(t.showTarget){let s=t.showTarget;t.showTarget==="window"&&(s="body"),o=asElement(querySelectorExt(n,s))}t.show==="top"&&(n||o)&&(o=o||n,o.scrollIntoView({block:"start",behavior:htmx.config.scrollBehavior})),t.show==="bottom"&&(r||o)&&(o=o||r,o.scrollIntoView({block:"end",behavior:htmx.config.scrollBehavior}))}}function getValuesForElement(e,t,n,r,o){if(r==null&&(r={}),e==null)return r;let i=getAttributeValue(e,t);if(i){let s=i.trim(),a=n;if(s==="unset")return null;s.indexOf("javascript:")===0?(s=s.slice(11),a=!0):s.indexOf("js:")===0&&(s=s.slice(3),a=!0),s.indexOf("{")!==0&&(s="{"+s+"}");let l;a?l=maybeEval(e,function(){return o?Function("event","return ("+s+")").call(e,o):Function("return ("+s+")").call(e)},{}):l=parseJSON(s);for(let c in l)l.hasOwnProperty(c)&&r[c]==null&&(r[c]=l[c])}return getValuesForElement(asElement(parentElt(e)),t,n,r,o)}function maybeEval(e,t,n){return htmx.config.allowEval?t():(triggerErrorEvent(e,"htmx:evalDisallowedError"),n)}function getHXVarsForElement(e,t,n){return getValuesForElement(e,"hx-vars",!0,n,t)}function getHXValsForElement(e,t,n){return getValuesForElement(e,"hx-vals",!1,n,t)}function getExpressionVars(e,t){return mergeObjects(getHXVarsForElement(e,t),getHXValsForElement(e,t))}function safelySetHeaderValue(e,t,n){if(n!==null)try{e.setRequestHeader(t,n)}catch{e.setRequestHeader(t,encodeURIComponent(n)),e.setRequestHeader(t+"-URI-AutoEncoded","true")}}function getPathFromResponse(e){if(e.responseURL)try{let t=new URL(e.responseURL);return t.pathname+t.search}catch{triggerErrorEvent(getDocument().body,"htmx:badResponseUrl",{url:e.responseURL})}}function hasHeader(e,t){return t.test(e.getAllResponseHeaders())}function ajaxHelper(e,t,n){if(e=e.toLowerCase(),n){if(n instanceof Element||typeof n=="string")return issueAjaxRequest(e,t,null,null,{targetOverride:resolveTarget(n)||DUMMY_ELT,returnPromise:!0});{let r=resolveTarget(n.target);return(n.target&&!r||n.source&&!r&&!resolveTarget(n.source))&&(r=DUMMY_ELT),issueAjaxRequest(e,t,resolveTarget(n.source),n.event,{handler:n.handler,headers:n.headers,values:n.values,targetOverride:r,swapOverride:n.swap,select:n.select,returnPromise:!0,push:n.push,replace:n.replace,selectOOB:n.selectOOB})}}else return issueAjaxRequest(e,t,null,null,{returnPromise:!0})}function hierarchyForElt(e){let t=[];for(;e;)t.push(e),e=e.parentElement;return t}function verifyPath(e,t,n){let r=new URL(t,location.protocol!=="about:"?location.href:window.origin),i=(location.protocol!=="about:"?location.origin:window.origin)===r.origin;return htmx.config.selfRequestsOnly&&!i?!1:triggerEvent(e,"htmx:validateUrl",mergeObjects({url:r,sameHost:i},n))}function formDataFromObject(e){if(e instanceof FormData)return e;let t=new FormData;for(let n in e)e.hasOwnProperty(n)&&(e[n]&&typeof e[n].forEach=="function"?e[n].forEach(function(r){t.append(n,r)}):typeof e[n]=="object"&&!(e[n]instanceof Blob)?t.append(n,JSON.stringify(e[n])):t.append(n,e[n]));return t}function formDataArrayProxy(e,t,n){return new Proxy(n,{get:function(r,o){return typeof o=="number"?r[o]:o==="length"?r.length:o==="push"?function(i){r.push(i),e.append(t,i)}:typeof r[o]=="function"?function(){r[o].apply(r,arguments),e.delete(t),r.forEach(function(i){e.append(t,i)})}:r[o]&&r[o].length===1?r[o][0]:r[o]},set:function(r,o,i){return r[o]=i,e.delete(t),r.forEach(function(s){e.append(t,s)}),!0}})}function formDataProxy(e){return new Proxy(e,{get:function(t,n){if(typeof n=="symbol"){let o=Reflect.get(t,n);return typeof o=="function"?function(){return o.apply(e,arguments)}:o}if(n==="toJSON")return()=>Object.fromEntries(e);if(n in t&&typeof t[n]=="function")return function(){return e[n].apply(e,arguments)};let r=e.getAll(n);if(r.length!==0)return r.length===1?r[0]:formDataArrayProxy(t,n,r)},set:function(t,n,r){return typeof n!="string"?!1:(t.delete(n),r&&typeof r.forEach=="function"?r.forEach(function(o){t.append(n,o)}):typeof r=="object"&&!(r instanceof Blob)?t.append(n,JSON.stringify(r)):t.append(n,r),!0)},deleteProperty:function(t,n){return typeof n=="string"&&t.delete(n),!0},ownKeys:function(t){return Reflect.ownKeys(Object.fromEntries(t))},getOwnPropertyDescriptor:function(t,n){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(t),n)}})}function issueAjaxRequest(e,t,n,r,o,i){let s=null,a=null;if(o=o??{},o.returnPromise&&typeof Promise<"u")var l=new Promise(function(p,w){s=p,a=w});n==null&&(n=getDocument().body);let c=o.handler||handleAjaxResponse,h=o.select||null;if(!bodyContains(n))return maybeCall(s),l;let u=o.targetOverride||asElement(getTarget(n));if(u==null||u==DUMMY_ELT)return triggerErrorEvent(n,"htmx:targetError",{target:getClosestAttributeValue(n,"hx-target")}),maybeCall(a),l;let f=getInternalData(n),v=f.lastButtonClicked;if(v){let p=getRawAttribute(v,"formaction");p!=null&&(t=p);let w=getRawAttribute(v,"formmethod");if(w!=null)if(VERBS.includes(w.toLowerCase()))e=w;else return maybeCall(s),l}let d=getClosestAttributeValue(n,"hx-confirm");if(i===void 0&&triggerEvent(n,"htmx:confirm",{target:u,elt:n,path:t,verb:e,triggeringEvent:r,etc:o,issueRequest:function(T){return issueAjaxRequest(e,t,n,r,o,!!T)},question:d})===!1)return maybeCall(s),l;let y=n,m=getClosestAttributeValue(n,"hx-sync"),x=null,b=!1;if(m){let p=m.split(":"),w=p[0].trim();if(w==="this"?y=findThisElement(n,"hx-sync"):y=asElement(querySelectorExt(n,w)),m=(p[1]||"drop").trim(),f=getInternalData(y),m==="drop"&&f.xhr&&f.abortable!==!0)return maybeCall(s),l;if(m==="abort"){if(f.xhr)return maybeCall(s),l;b=!0}else m==="replace"?triggerEvent(y,"htmx:abort"):m.indexOf("queue")===0&&(x=(m.split(" ")[1]||"last").trim())}if(f.xhr)if(f.abortable)triggerEvent(y,"htmx:abort");else{if(x==null){if(r){let p=getInternalData(r);p&&p.triggerSpec&&p.triggerSpec.queue&&(x=p.triggerSpec.queue)}x==null&&(x="last")}return f.queuedRequests==null&&(f.queuedRequests=[]),x==="first"&&f.queuedRequests.length===0?f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):x==="all"?f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):x==="last"&&(f.queuedRequests=[],f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)})),maybeCall(s),l}let E=new XMLHttpRequest;f.xhr=E,f.abortable=b;let g=function(){f.xhr=null,f.abortable=!1,f.queuedRequests!=null&&f.queuedRequests.length>0&&f.queuedRequests.shift()()},W=getClosestAttributeValue(n,"hx-prompt");if(W){var q=prompt(W);if(q===null||!triggerEvent(n,"htmx:prompt",{prompt:q,target:u}))return maybeCall(s),g(),l}if(d&&!i&&!confirm(d))return maybeCall(s),g(),l;let H=getHeaders(n,u,q);e!=="get"&&!usesFormData(n)&&(H["Content-Type"]="application/x-www-form-urlencoded"),o.headers&&(H=mergeObjects(H,o.headers));let X=getInputValues(n,e),R=X.errors,$=X.formData;o.values&&overrideFormData($,formDataFromObject(o.values));let re=formDataFromObject(getExpressionVars(n,r)),P=overrideFormData($,re),D=filterValues(P,n);htmx.config.getCacheBusterParam&&e==="get"&&D.set("org.htmx.cache-buster",getRawAttribute(u,"id")||"true"),(t==null||t==="")&&(t=location.href);let k=getValuesForElement(n,"hx-request"),J=getInternalData(n).boosted,I=htmx.config.methodsThatUseUrlParams.indexOf(e)>=0,S={boosted:J,useUrlParams:I,formData:D,parameters:formDataProxy(D),unfilteredFormData:P,unfilteredParameters:formDataProxy(P),headers:H,elt:n,target:u,verb:e,errors:R,withCredentials:o.credentials||k.credentials||htmx.config.withCredentials,timeout:o.timeout||k.timeout||htmx.config.timeout,path:t,triggeringEvent:r};if(!triggerEvent(n,"htmx:configRequest",S))return maybeCall(s),g(),l;if(t=S.path,e=S.verb,H=S.headers,D=formDataFromObject(S.parameters),R=S.errors,I=S.useUrlParams,R&&R.length>0)return triggerEvent(n,"htmx:validation:halted",S),maybeCall(s),g(),l;let z=t.split("#"),oe=z[0],N=z[1],A=t;if(I&&(A=oe,!D.keys().next().done&&(A.indexOf("?")<0?A+="?":A+="&",A+=urlEncode(D),N&&(A+="#"+N))),!verifyPath(n,A,S))return triggerErrorEvent(n,"htmx:invalidPath",S),maybeCall(a),g(),l;if(E.open(e.toUpperCase(),A,!0),E.overrideMimeType("text/html"),E.withCredentials=S.withCredentials,E.timeout=S.timeout,!k.noHeaders){for(let p in H)if(H.hasOwnProperty(p)){let w=H[p];safelySetHeaderValue(E,p,w)}}let C={xhr:E,target:u,requestConfig:S,etc:o,boosted:J,select:h,pathInfo:{requestPath:t,finalRequestPath:A,responsePath:null,anchor:N}};if(E.onload=function(){try{let p=hierarchyForElt(n);if(C.pathInfo.responsePath=getPathFromResponse(E),c(n,C),C.keepIndicators!==!0&&removeRequestIndicators(L,O),triggerEvent(n,"htmx:afterRequest",C),triggerEvent(n,"htmx:afterOnLoad",C),!bodyContains(n)){let w=null;for(;p.length>0&&w==null;){let T=p.shift();bodyContains(T)&&(w=T)}w&&(triggerEvent(w,"htmx:afterRequest",C),triggerEvent(w,"htmx:afterOnLoad",C))}maybeCall(s)}catch(p){throw triggerErrorEvent(n,"htmx:onLoadError",mergeObjects({error:p},C)),p}finally{g()}},E.onerror=function(){removeRequestIndicators(L,O),triggerErrorEvent(n,"htmx:afterRequest",C),triggerErrorEvent(n,"htmx:sendError",C),maybeCall(a),g()},E.onabort=function(){removeRequestIndicators(L,O),triggerErrorEvent(n,"htmx:afterRequest",C),triggerErrorEvent(n,"htmx:sendAbort",C),maybeCall(a),g()},E.ontimeout=function(){removeRequestIndicators(L,O),triggerErrorEvent(n,"htmx:afterRequest",C),triggerErrorEvent(n,"htmx:timeout",C),maybeCall(a),g()},!triggerEvent(n,"htmx:beforeRequest",C))return maybeCall(s),g(),l;var L=addRequestIndicatorClasses(n),O=disableElements(n);forEach(["loadstart","loadend","progress","abort"],function(p){forEach([E,E.upload],function(w){w.addEventListener(p,function(T){triggerEvent(n,"htmx:xhr:"+p,{lengthComputable:T.lengthComputable,loaded:T.loaded,total:T.total})})})}),triggerEvent(n,"htmx:beforeSend",C);let ie=I?null:encodeParamsForBody(E,n,D);return E.send(ie),l}function determineHistoryUpdates(e,t){let n=t.xhr,r=null,o=null;if(hasHeader(n,/HX-Push:/i)?(r=n.getResponseHeader("HX-Push"),o="push"):hasHeader(n,/HX-Push-Url:/i)?(r=n.getResponseHeader("HX-Push-Url"),o="push"):hasHeader(n,/HX-Replace-Url:/i)&&(r=n.getResponseHeader("HX-Replace-Url"),o="replace"),r)return r==="false"?{}:{type:o,path:r};let i=t.pathInfo.finalRequestPath,s=t.pathInfo.responsePath,a=t.etc.push||getClosestAttributeValue(e,"hx-push-url"),l=t.etc.replace||getClosestAttributeValue(e,"hx-replace-url"),c=getInternalData(e).boosted,h=null,u=null;return a?(h="push",u=a):l?(h="replace",u=l):c&&(h="push",u=s||i),u?u==="false"?{}:(u==="true"&&(u=s||i),t.pathInfo.anchor&&u.indexOf("#")===-1&&(u=u+"#"+t.pathInfo.anchor),{type:h,path:u}):{}}function codeMatches(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function resolveResponseHandling(e){for(var t=0;t<htmx.config.responseHandling.length;t++){var n=htmx.config.responseHandling[t];if(codeMatches(n,e.status))return n}return{swap:!1}}function handleTitle(e){if(e){let t=find("title");t?t.textContent=e:window.document.title=e}}function resolveRetarget(e,t){if(t==="this")return e;let n=asElement(querySelectorExt(e,t));if(n==null)throw triggerErrorEvent(e,"htmx:targetError",{target:t}),new Error(`Invalid re-target ${t}`);return n}function handleAjaxResponse(e,t){let n=t.xhr,r=t.target,o=t.etc,i=t.select;if(!triggerEvent(e,"htmx:beforeOnLoad",t))return;if(hasHeader(n,/HX-Trigger:/i)&&handleTriggerHeader(n,"HX-Trigger",e),hasHeader(n,/HX-Location:/i)){let b=n.getResponseHeader("HX-Location");var s={};b.indexOf("{")===0&&(s=parseJSON(b),b=s.path,delete s.path),s.push=s.push||"true",ajaxHelper("get",b,s);return}let a=hasHeader(n,/HX-Refresh:/i)&&n.getResponseHeader("HX-Refresh")==="true";if(hasHeader(n,/HX-Redirect:/i)){t.keepIndicators=!0,htmx.location.href=n.getResponseHeader("HX-Redirect"),a&&htmx.location.reload();return}if(a){t.keepIndicators=!0,htmx.location.reload();return}let l=determineHistoryUpdates(e,t),c=resolveResponseHandling(n),h=c.swap,u=!!c.error,f=htmx.config.ignoreTitle||c.ignoreTitle,v=c.select;c.target&&(t.target=resolveRetarget(e,c.target));var d=o.swapOverride;d==null&&c.swapOverride&&(d=c.swapOverride),hasHeader(n,/HX-Retarget:/i)&&(t.target=resolveRetarget(e,n.getResponseHeader("HX-Retarget"))),hasHeader(n,/HX-Reswap:/i)&&(d=n.getResponseHeader("HX-Reswap"));var y=n.response,m=mergeObjects({shouldSwap:h,serverResponse:y,isError:u,ignoreTitle:f,selectOverride:v,swapOverride:d},t);if(!(c.event&&!triggerEvent(r,c.event,m))&&triggerEvent(r,"htmx:beforeSwap",m)){if(r=m.target,y=m.serverResponse,u=m.isError,f=m.ignoreTitle,v=m.selectOverride,d=m.swapOverride,t.target=r,t.failed=u,t.successful=!u,m.shouldSwap){n.status===286&&cancelPolling(e),withExtensions(e,function(g){y=g.transformResponse(y,n,e)}),l.type&&saveCurrentPageToHistory();var x=getSwapSpecification(e,d);x.hasOwnProperty("ignoreTitle")||(x.ignoreTitle=f),r.classList.add(htmx.config.swappingClass),i&&(v=i),hasHeader(n,/HX-Reselect:/i)&&(v=n.getResponseHeader("HX-Reselect"));let b=o.selectOOB||getClosestAttributeValue(e,"hx-select-oob"),E=getClosestAttributeValue(e,"hx-select");swap(r,y,x,{select:v==="unset"?null:v||E,selectOOB:b,eventInfo:t,anchor:t.pathInfo.anchor,contextElement:e,afterSwapCallback:function(){if(hasHeader(n,/HX-Trigger-After-Swap:/i)){let g=e;bodyContains(e)||(g=getDocument().body),handleTriggerHeader(n,"HX-Trigger-After-Swap",g)}},afterSettleCallback:function(){if(hasHeader(n,/HX-Trigger-After-Settle:/i)){let g=e;bodyContains(e)||(g=getDocument().body),handleTriggerHeader(n,"HX-Trigger-After-Settle",g)}},beforeSwapCallback:function(){l.type&&(triggerEvent(getDocument().body,"htmx:beforeHistoryUpdate",mergeObjects({history:l},t)),l.type==="push"?(pushUrlIntoHistory(l.path),triggerEvent(getDocument().body,"htmx:pushedIntoHistory",{path:l.path})):(replaceUrlInHistory(l.path),triggerEvent(getDocument().body,"htmx:replacedInHistory",{path:l.path})))}})}u&&triggerErrorEvent(e,"htmx:responseError",mergeObjects({error:"Response Status Error Code "+n.status+" from "+t.pathInfo.requestPath},t))}}let extensions={};function extensionBase(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return!0},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return!1},handleSwap:function(e,t,n,r){return!1},encodeParameters:function(e,t,n){return null}}}function defineExtension(e,t){t.init&&t.init(internalAPI),extensions[e]=mergeObjects(extensionBase(),t)}function removeExtension(e){delete extensions[e]}function getExtensions(e,t,n){if(t==null&&(t=[]),e==null)return t;n==null&&(n=[]);let r=getAttributeValue(e,"hx-ext");return r&&forEach(r.split(","),function(o){if(o=o.replace(/ /g,""),o.slice(0,7)=="ignore:"){n.push(o.slice(7));return}if(n.indexOf(o)<0){let i=extensions[o];i&&t.indexOf(i)<0&&t.push(i)}}),getExtensions(asElement(parentElt(e)),t,n)}var isReady=!1;getDocument().addEventListener("DOMContentLoaded",function(){isReady=!0});function ready(e){isReady||getDocument().readyState==="complete"?e():getDocument().addEventListener("DOMContentLoaded",e)}function insertIndicatorStyles(){if(htmx.config.includeIndicatorStyles!==!1){let e=htmx.config.inlineStyleNonce?` nonce="${htmx.config.inlineStyleNonce}"`:"",t=htmx.config.indicatorClass,n=htmx.config.requestClass;getDocument().head.insertAdjacentHTML("beforeend",`<style${e}>.${t}{opacity:0;visibility: hidden} .${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}</style>`)}}function getMetaConfig(){let e=getDocument().querySelector('meta[name="htmx-config"]');return e?parseJSON(e.content):null}function mergeMetaConfig(){let e=getMetaConfig();e&&(htmx.config=mergeObjects(htmx.config,e))}return ready(function(){mergeMetaConfig(),insertIndicatorStyles();let e=getDocument().body;processNode(e);let t=getDocument().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(r){let o=r.detail.elt||r.target,i=getInternalData(o);i&&i.xhr&&i.xhr.abort()});let n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(r){r.state&&r.state.htmx?(restoreHistory(),forEach(t,function(o){triggerEvent(o,"htmx:restored",{document:getDocument(),triggerEvent})})):n&&n(r)},getWindow().setTimeout(function(){triggerEvent(e,"htmx:load",{}),e=null},0)}),htmx})(),F=se;(function(){let e;F.defineExtension("json-enc",{init:function(t){e=t},onEvent:function(t,n){t==="htmx:configRequest"&&(n.detail.headers["Content-Type"]="application/json")},encodeParameters:function(t,n,r){t.overrideMimeType("text/json");let o={};n.forEach(function(s,a){Object.hasOwn(o,a)?(Array.isArray(o[a])||(o[a]=[o[a]]),o[a].push(s)):o[a]=s});let i=e.getExpressionVars(r);return Object.keys(o).forEach(function(s){o[s]=Object.hasOwn(i,s)?i[s]:o[s]}),JSON.stringify(o)}})})();var K=document.createElement("template");K.innerHTML=` 2 <slot></slot> 3 4 <ul class="menu" part="menu"></ul> ··· 92 <span class="handle" part="handle"></span> 93 </button> 94 </li> 95 - `;function Y(e){return e.cloneNode(!0)}var M=class extends HTMLElement{static tag="actor-typeahead";static define(t=this.tag){this.tag=t;let n=customElements.getName(this);if(n&&n!==t)return console.warn(`${this.name} already defined as <${n}>!`);let r=customElements.get(t);if(r&&r!==this)return console.warn(`<${t}> already defined as ${r.name}!`);customElements.define(t,this)}static{let t=new URL(import.meta.url).searchParams.get("tag")||this.tag;t!=="none"&&this.define(t)}#n=this.attachShadow({mode:"closed"});#r=[];#e=-1;#o=!1;constructor(){super(),this.#n.append(Y(K).content),this.#t(),this.addEventListener("input",this),this.addEventListener("focusout",this),this.addEventListener("keydown",this),this.#n.addEventListener("pointerdown",this),this.#n.addEventListener("pointerup",this),this.#n.addEventListener("click",this)}get#i(){let t=Number.parseInt(this.getAttribute("rows")??"");return Number.isNaN(t)?5:t}handleEvent(t){switch(t.type){case"input":this.#a(t);break;case"keydown":this.#s(t);break;case"focusout":this.#l(t);break;case"pointerdown":this.#c(t);break;case"pointerup":this.#u(t);break}}#s(t){switch(t.key){case"ArrowDown":t.preventDefault(),this.#e=Math.min(this.#e+1,this.#i-1),this.#t();break;case"PageDown":t.preventDefault(),this.#e=this.#i-1,this.#t();break;case"ArrowUp":t.preventDefault(),this.#e=Math.max(this.#e-1,0),this.#t();break;case"PageUp":t.preventDefault(),this.#e=0,this.#t();break;case"Escape":t.preventDefault(),this.#r=[],this.#e=-1,this.#t();break;case"Enter":t.preventDefault(),this.#n.querySelectorAll("button")[this.#e]?.dispatchEvent(new PointerEvent("pointerup",{bubbles:!0}));break}}async#a(t){let n=t.target?.value;if(!n){this.#r=[],this.#t();return}let r=this.getAttribute("host")??"https://public.api.bsky.app",o=new URL("xrpc/app.bsky.actor.searchActorsTypeahead",r);o.searchParams.set("q",n),o.searchParams.set("limit",`${this.#i}`);let s=await(await fetch(o)).json();this.#r=s.actors,this.#e=-1,this.#t()}async#l(t){this.#o||(this.#r=[],this.#e=-1,this.#t())}#t(){let t=document.createDocumentFragment(),n=-1;for(let r of this.#r){let o=Y(G).content,i=o.querySelector("button");i&&(i.dataset.handle=r.handle,++n===this.#e&&(i.dataset.active="true"));let s=o.querySelector("img");s&&r.avatar&&(s.src=r.avatar);let a=o.querySelector(".handle");a&&(a.textContent=r.handle),t.append(o)}this.#n.querySelector(".menu")?.replaceChildren(...t.children)}#c(t){this.#o=!0}#u(t){this.#o=!1,this.querySelector("input")?.focus();let n=t.target?.closest("button"),r=this.querySelector("input");!r||!n||(r.value=n.dataset.handle||"",this.#r=[],this.#t())}};function Z(){return localStorage.getItem("theme")||"system"}function ae(e){return e==="dark"||e==="light"?e:window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function U(){let e=Z(),n=ae(e)==="dark";document.documentElement.classList.toggle("dark",n),document.documentElement.setAttribute("data-theme",n?"dark":"light"),le(e)}function ee(e){localStorage.setItem("theme",e),U(),ce()}function le(e){let t={system:"sun-moon",light:"sun",dark:"moon"};document.querySelectorAll("[data-theme-icon] use").forEach(n=>{n.setAttribute("href",`/icons.svg#${t[e]||"sun-moon"}`)}),document.querySelectorAll(".theme-option").forEach(n=>{let r=n.dataset.value===e,o=n.querySelector(".theme-check");o&&(o.style.visibility=r?"visible":"hidden")})}function ce(){document.querySelectorAll("[data-theme-toggle]").forEach(e=>{let t=e.closest("details");t&&t.removeAttribute("open")})}window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{Z()==="system"&&U()});function ue(){let e=document.querySelector(".nav-search-wrapper"),t=document.getElementById("nav-search-input");!e||!t||(e.classList.toggle("expanded"),e.classList.contains("expanded")&&t.focus())}function B(){let e=document.querySelector(".nav-search-wrapper");e&&e.classList.remove("expanded")}document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelector(".nav-search-wrapper"),t=document.getElementById("nav-search-input");!e||!t||(document.addEventListener("keydown",n=>{n.key==="Escape"&&e.classList.contains("expanded")&&B()}),document.addEventListener("click",n=>{e.classList.contains("expanded")&&!e.contains(n.target)&&B()}))});function te(e,t){!t&&typeof event<"u"&&(t=event.target.closest("button")),navigator.clipboard.writeText(e).then(()=>{if(!t)return;let n=t.innerHTML;t.innerHTML='<svg class="icon size-4" aria-hidden="true"><use href="/icons.svg#check"></use></svg> Copied!',setTimeout(()=>{t.innerHTML=n},2e3)}).catch(n=>{console.error("Failed to copy:",n)})}document.addEventListener("DOMContentLoaded",()=>{document.addEventListener("click",e=>{let t=e.target.closest("button[data-cmd]");if(t){te(t.getAttribute("data-cmd"),t);return}if(e.target.closest("a, button, input, .cmd"))return;let n=e.target.closest("[data-href]");n&&(window.location=n.getAttribute("data-href"))})});function fe(e){let t=Math.floor((new Date-new Date(e))/1e3),n={year:31536e3,month:2592e3,week:604800,day:86400,hour:3600,minute:60,second:1};for(let[r,o]of Object.entries(n)){let i=Math.floor(t/o);if(i>=1)return i===1?`1 ${r} ago`:`${i} ${r}s ago`}return"just now"}function _(){document.querySelectorAll("time[datetime]").forEach(e=>{let t=e.getAttribute("datetime");if(t&&!e.dataset.noUpdate){let n=fe(t);e.textContent!==n&&(e.textContent=n)}})}document.addEventListener("DOMContentLoaded",()=>{_(),U(),document.querySelectorAll("[data-theme-menu]").forEach(e=>{e.querySelectorAll(".theme-option").forEach(t=>{t.addEventListener("click",()=>{ee(t.dataset.value)})})}),document.addEventListener("click",e=>{let t=e.target.closest("details.dropdown");document.querySelectorAll("details.dropdown[open]").forEach(n=>{n!==t&&n.removeAttribute("open")})})});document.addEventListener("htmx:afterSwap",_);setInterval(_,6e4);function de(){let e=document.getElementById("show-offline-toggle"),t=document.querySelector(".manifests-list");!e||!t||(localStorage.setItem("showOfflineManifests",e.checked),e.checked?t.classList.add("show-offline"):t.classList.remove("show-offline"))}document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("show-offline-toggle");if(!e)return;let t=localStorage.getItem("showOfflineManifests")==="true";e.checked=t;let n=document.querySelector(".manifests-list");n&&(t?n.classList.add("show-offline"):n.classList.remove("show-offline"))});async function he(e,t,n){try{let r=await fetch("/api/manifests",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e,digest:t,confirm:!1})});if(r.status===409){let o=await r.json();me(e,t,n,o.tags)}else if(r.ok)ne(n);else{let o=await r.text();alert(`Failed to delete manifest: ${o}`)}}catch(r){console.error("Error deleting manifest:",r),alert(`Error deleting manifest: ${r.message}`)}}function me(e,t,n,r){let o=document.getElementById("manifest-delete-modal"),i=document.getElementById("manifest-delete-tags"),s=document.getElementById("confirm-manifest-delete-btn");i.innerHTML="",r.forEach(a=>{let l=document.createElement("li");l.textContent=a,i.appendChild(l)}),s.onclick=()=>ge(e,t,n),o.style.display="flex"}function j(){let e=document.getElementById("manifest-delete-modal");e.style.display="none"}async function ge(e,t,n){let r=document.getElementById("confirm-manifest-delete-btn"),o=r.textContent;try{r.disabled=!0,r.textContent="Deleting...";let i=await fetch("/api/manifests",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e,digest:t,confirm:!0})});if(i.ok)j(),ne(n),location.reload();else{let s=await i.text();alert(`Failed to delete manifest: ${s}`),r.disabled=!1,r.textContent=o}}catch(i){console.error("Error deleting manifest:",i),alert(`Error deleting manifest: ${i.message}`),r.disabled=!1,r.textContent=o}}function ne(e){let t=document.getElementById(`manifest-${e}`);t&&t.remove()}document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("manifest-delete-modal");e&&e.addEventListener("click",t=>{t.target===e&&j()})});var V=class{constructor(t){this.input=t,this.typeahead=t.closest("actor-typeahead"),this.dropdown=null,this.currentFocus=-1,this.typeaheadClosed=!1,this.init()}init(){this.createDropdown(),this.input.addEventListener("focus",()=>this.handleFocus()),this.input.addEventListener("input",()=>this.handleInput()),this.input.addEventListener("keydown",t=>this.handleKeydown(t)),document.addEventListener("click",t=>{!this.input.contains(t.target)&&!this.dropdown.contains(t.target)&&this.hideDropdown()})}createDropdown(){this.dropdown=document.createElement("div"),this.dropdown.className="recent-accounts-dropdown",this.dropdown.style.display="none",this.typeahead?this.typeahead.insertAdjacentElement("afterend",this.dropdown):this.input.insertAdjacentElement("afterend",this.dropdown)}handleFocus(){this.input.value.trim().length<1&&this.showRecentAccounts()}handleInput(){this.input.value.trim().length>=1&&this.hideDropdown(),this.typeaheadClosed=!1}showRecentAccounts(){let t=this.getRecentAccounts();if(t.length===0){this.hideDropdown();return}this.dropdown.innerHTML="",this.currentFocus=-1;let n=document.createElement("div");n.className="recent-accounts-header",n.textContent="Recent accounts",this.dropdown.appendChild(n),t.forEach((r,o)=>{let i=document.createElement("div");i.className="recent-accounts-item",i.dataset.index=o,i.dataset.handle=r,i.textContent=r,i.addEventListener("click",()=>this.selectItem(r)),this.dropdown.appendChild(i)}),this.dropdown.style.display="block"}selectItem(t){this.input.value=t,this.hideDropdown(),this.input.focus()}hideDropdown(){this.dropdown.style.display="none",this.currentFocus=-1}handleKeydown(t){if(t.key==="Enter"&&this.input.value.trim().length>=2&&(this.typeaheadClosed=!0),t.key==="Tab"&&this.input.value.trim().length>=2&&!this.typeaheadClosed){t.preventDefault();let o=t.shiftKey?"ArrowUp":"ArrowDown",i=new KeyboardEvent("keydown",{key:o,bubbles:!0,cancelable:!0});this.typeahead.dispatchEvent(i);return}if(this.dropdown.style.display==="none")return;let n=this.dropdown.querySelectorAll(".recent-accounts-item");t.key==="ArrowDown"?(t.preventDefault(),this.currentFocus++,this.currentFocus>=n.length&&(this.currentFocus=0),this.updateFocus(n)):t.key==="ArrowUp"?(t.preventDefault(),this.currentFocus--,this.currentFocus<0&&(this.currentFocus=n.length-1),this.updateFocus(n)):t.key==="Enter"&&this.currentFocus>-1&&n[this.currentFocus]?(t.preventDefault(),this.selectItem(n[this.currentFocus].dataset.handle)):t.key==="Escape"&&this.hideDropdown()}updateFocus(t){t.forEach((n,r)=>{n.classList.toggle("focused",r===this.currentFocus)})}getRecentAccounts(){try{let t=localStorage.getItem("atcr_recent_handles");return t?JSON.parse(t):[]}catch{return[]}}saveRecentAccount(t){if(t)try{let n=this.getRecentAccounts();n=n.filter(r=>r!==t),n.unshift(t),n=n.slice(0,5),localStorage.setItem("atcr_recent_handles",JSON.stringify(n))}catch(n){console.error("Failed to save recent account:",n)}}};document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("login-form"),t=document.getElementById("handle");e&&t&&new V(t)});document.addEventListener("DOMContentLoaded",()=>{let e=document.cookie.split("; ").find(n=>n.startsWith("atcr_login_handle="));if(!e)return;let t=decodeURIComponent(e.split("=")[1]);if(t){try{let n="atcr_recent_handles",r=JSON.parse(localStorage.getItem(n)||"[]");r=r.filter(o=>o!==t),r.unshift(t),r=r.slice(0,5),localStorage.setItem(n,JSON.stringify(r))}catch(n){console.error("Failed to save recent account:",n)}document.cookie="atcr_login_handle=; path=/; max-age=0"}});function Q(){let e=document.getElementById("featured-carousel"),t=document.getElementById("carousel-prev"),n=document.getElementById("carousel-next");if(!e)return;let r=Array.from(e.querySelectorAll(".carousel-item"));if(r.length===0)return;let o=null,i=5e3,s=0,a=0,l=0;function c(){let b=r[0];if(!b)return;let E=getComputedStyle(e),g=parseFloat(E.gap)||24;s=b.offsetWidth+g,a=e.offsetWidth,l=e.scrollWidth}requestAnimationFrame(()=>{requestAnimationFrame(()=>{c(),m()})});let h;window.addEventListener("resize",()=>{clearTimeout(h),h=setTimeout(c,150)});function u(){return s||c(),s}function f(){return(!a||!s)&&c(),Math.round(a/s)||1}function v(){return(!l||!a)&&c(),l-a}function d(){let b=u(),E=v(),g=e.scrollLeft;g>=E-10?e.scrollTo({left:0,behavior:"smooth"}):e.scrollTo({left:g+b,behavior:"smooth"})}function y(){let b=u(),E=v(),g=e.scrollLeft;g<=10?e.scrollTo({left:E,behavior:"smooth"}):e.scrollTo({left:g-b,behavior:"smooth"})}t&&t.addEventListener("click",()=>{x(),y(),m()}),n&&n.addEventListener("click",()=>{x(),d(),m()});function m(){o||r.length<=f()||(o=setInterval(d,i))}function x(){o&&(clearInterval(o),o=null)}e.addEventListener("mouseenter",x),e.addEventListener("mouseleave",m)}document.addEventListener("DOMContentLoaded",()=>{"requestIdleCallback"in window?requestIdleCallback(Q,{timeout:2e3}):setTimeout(Q,100)});window.setTheme=ee;window.toggleSearch=ue;window.closeSearch=B;window.copyToClipboard=te;window.toggleOfflineManifests=de;window.deleteManifest=he;window.closeManifestDeleteModal=j;window.htmx=F;
··· 1 + var se=(function(){"use strict";let htmx={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){return getInputValues(e,t||"post").values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:!0,historyCacheSize:10,refreshOnHistoryMiss:!1,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:!0,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:!0,allowScriptTags:!0,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:!1,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:!1,getCacheBusterParam:!1,globalViewTransitions:!1,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:!0,ignoreTitle:!1,scrollIntoViewOnBoost:!0,triggerSpecsCache:null,disableInheritance:!1,responseHandling:[{code:"204",swap:!1},{code:"[23]..",swap:!0},{code:"[45]..",swap:!1,error:!0}],allowNestedOobSwaps:!0,historyRestoreAsHxRequest:!0,reportValidityOfForms:!1},parseInterval:null,location,_:null,version:"2.0.8"};htmx.onLoad=onLoadHelper,htmx.process=processNode,htmx.on=addEventListenerImpl,htmx.off=removeEventListenerImpl,htmx.trigger=triggerEvent,htmx.ajax=ajaxHelper,htmx.find=find,htmx.findAll=findAll,htmx.closest=closest,htmx.remove=removeElement,htmx.addClass=addClassToElement,htmx.removeClass=removeClassFromElement,htmx.toggleClass=toggleClassOnElement,htmx.takeClass=takeClassForElement,htmx.swap=swap,htmx.defineExtension=defineExtension,htmx.removeExtension=removeExtension,htmx.logAll=logAll,htmx.logNone=logNone,htmx.parseInterval=parseInterval,htmx._=internalEval;let internalAPI={addTriggerHandler,bodyContains,canAccessLocalStorage,findThisElement,filterValues,swap,hasAttribute,getAttributeValue,getClosestAttributeValue,getClosestMatch,getExpressionVars,getHeaders,getInputValues,getInternalData,getSwapSpecification,getTriggerSpecs,getTarget,makeFragment,mergeObjects,makeSettleInfo,oobSwap,querySelectorExt,settleImmediately,shouldCancel,triggerEvent,triggerErrorEvent,withExtensions},VERBS=["get","post","put","delete","patch"],VERB_SELECTOR=VERBS.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function parseInterval(e){if(e==null)return;let t=NaN;return e.slice(-2)=="ms"?t=parseFloat(e.slice(0,-2)):e.slice(-1)=="s"?t=parseFloat(e.slice(0,-1))*1e3:e.slice(-1)=="m"?t=parseFloat(e.slice(0,-1))*1e3*60:t=parseFloat(e),isNaN(t)?void 0:t}function getRawAttribute(e,t){return e instanceof Element&&e.getAttribute(t)}function hasAttribute(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function getAttributeValue(e,t){return getRawAttribute(e,t)||getRawAttribute(e,"data-"+t)}function parentElt(e){let t=e.parentElement;return!t&&e.parentNode instanceof ShadowRoot?e.parentNode:t}function getDocument(){return document}function getRootNode(e,t){return e.getRootNode?e.getRootNode({composed:t}):getDocument()}function getClosestMatch(e,t){for(;e&&!t(e);)e=parentElt(e);return e||null}function getAttributeValueWithDisinheritance(e,t,n){let r=getAttributeValue(t,n),o=getAttributeValue(t,"hx-disinherit");var i=getAttributeValue(t,"hx-inherit");if(e!==t){if(htmx.config.disableInheritance)return i&&(i==="*"||i.split(" ").indexOf(n)>=0)?r:null;if(o&&(o==="*"||o.split(" ").indexOf(n)>=0))return"unset"}return r}function getClosestAttributeValue(e,t){let n=null;if(getClosestMatch(e,function(r){return!!(n=getAttributeValueWithDisinheritance(e,asElement(r),t))}),n!=="unset")return n}function matches(e,t){return e instanceof Element&&e.matches(t)}function getStartTag(e){let n=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i.exec(e);return n?n[1].toLowerCase():""}function parseHTML(e){return"parseHTMLUnsafe"in Document?Document.parseHTMLUnsafe(e):new DOMParser().parseFromString(e,"text/html")}function takeChildrenFor(e,t){for(;t.childNodes.length>0;)e.append(t.childNodes[0])}function duplicateScript(e){let t=getDocument().createElement("script");return forEach(e.attributes,function(n){t.setAttribute(n.name,n.value)}),t.textContent=e.textContent,t.async=!1,htmx.config.inlineScriptNonce&&(t.nonce=htmx.config.inlineScriptNonce),t}function isJavaScriptScriptNode(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function normalizeScriptTags(e){Array.from(e.querySelectorAll("script")).forEach(t=>{if(isJavaScriptScriptNode(t)){let n=duplicateScript(t),r=t.parentNode;try{r.insertBefore(n,t)}catch(o){logError(o)}finally{t.remove()}}})}function makeFragment(e){let t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,""),n=getStartTag(t),r;if(n==="html"){r=new DocumentFragment;let i=parseHTML(e);takeChildrenFor(r,i.body),r.title=i.title}else if(n==="body"){r=new DocumentFragment;let i=parseHTML(t);takeChildrenFor(r,i.body),r.title=i.title}else{let i=parseHTML('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content,r.title=i.title;var o=r.querySelector("title");o&&o.parentNode===r&&(o.remove(),r.title=o.innerText)}return r&&(htmx.config.allowScriptTags?normalizeScriptTags(r):r.querySelectorAll("script").forEach(i=>i.remove())),r}function maybeCall(e){e&&e()}function isType(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function isFunction(e){return typeof e=="function"}function isRawObject(e){return isType(e,"Object")}function getInternalData(e){let t="htmx-internal-data",n=e[t];return n||(n=e[t]={}),n}function toArray(e){let t=[];if(e)for(let n=0;n<e.length;n++)t.push(e[n]);return t}function forEach(e,t){if(e)for(let n=0;n<e.length;n++)t(e[n])}function isScrolledIntoView(e){let t=e.getBoundingClientRect(),n=t.top,r=t.bottom;return n<window.innerHeight&&r>=0}function bodyContains(e){return e.getRootNode({composed:!0})===document}function splitOnWhitespace(e){return e.trim().split(/\s+/)}function mergeObjects(e,t){for(let n in t)t.hasOwnProperty(n)&&(e[n]=t[n]);return e}function parseJSON(e){try{return JSON.parse(e)}catch(t){return logError(t),null}}function canAccessLocalStorage(){let e="htmx:sessionStorageTest";try{return sessionStorage.setItem(e,e),sessionStorage.removeItem(e),!0}catch{return!1}}function normalizePath(e){let t=new URL(e,"http://x");return t&&(e=t.pathname+t.search),e!="/"&&(e=e.replace(/\/+$/,"")),e}function internalEval(str){return maybeEval(getDocument().body,function(){return eval(str)})}function onLoadHelper(e){return htmx.on("htmx:load",function(n){e(n.detail.elt)})}function logAll(){htmx.logger=function(e,t,n){console&&console.log(t,e,n)}}function logNone(){htmx.logger=null}function find(e,t){return typeof e!="string"?e.querySelector(t):find(getDocument(),e)}function findAll(e,t){return typeof e!="string"?e.querySelectorAll(t):findAll(getDocument(),e)}function getWindow(){return window}function removeElement(e,t){e=resolveTarget(e),t?getWindow().setTimeout(function(){removeElement(e),e=null},t):parentElt(e).removeChild(e)}function asElement(e){return e instanceof Element?e:null}function asHtmlElement(e){return e instanceof HTMLElement?e:null}function asString(e){return typeof e=="string"?e:null}function asParentNode(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function addClassToElement(e,t,n){e=asElement(resolveTarget(e)),e&&(n?getWindow().setTimeout(function(){addClassToElement(e,t),e=null},n):e.classList&&e.classList.add(t))}function removeClassFromElement(e,t,n){let r=asElement(resolveTarget(e));r&&(n?getWindow().setTimeout(function(){removeClassFromElement(r,t),r=null},n):r.classList&&(r.classList.remove(t),r.classList.length===0&&r.removeAttribute("class")))}function toggleClassOnElement(e,t){e=resolveTarget(e),e.classList.toggle(t)}function takeClassForElement(e,t){e=resolveTarget(e),forEach(e.parentElement.children,function(n){removeClassFromElement(n,t)}),addClassToElement(asElement(e),t)}function closest(e,t){return e=asElement(resolveTarget(e)),e?e.closest(t):null}function startsWith(e,t){return e.substring(0,t.length)===t}function endsWith(e,t){return e.substring(e.length-t.length)===t}function normalizeSelector(e){let t=e.trim();return startsWith(t,"<")&&endsWith(t,"/>")?t.substring(1,t.length-2):t}function querySelectorAllExt(e,t,n){if(t.indexOf("global ")===0)return querySelectorAllExt(e,t.slice(7),!0);e=resolveTarget(e);let r=[];{let s=0,a=0;for(let l=0;l<t.length;l++){let c=t[l];if(c===","&&s===0){r.push(t.substring(a,l)),a=l+1;continue}c==="<"?s++:c==="/"&&l<t.length-1&&t[l+1]===">"&&s--}a<t.length&&r.push(t.substring(a))}let o=[],i=[];for(;r.length>0;){let s=normalizeSelector(r.shift()),a;s.indexOf("closest ")===0?a=closest(asElement(e),normalizeSelector(s.slice(8))):s.indexOf("find ")===0?a=find(asParentNode(e),normalizeSelector(s.slice(5))):s==="next"||s==="nextElementSibling"?a=asElement(e).nextElementSibling:s.indexOf("next ")===0?a=scanForwardQuery(e,normalizeSelector(s.slice(5)),!!n):s==="previous"||s==="previousElementSibling"?a=asElement(e).previousElementSibling:s.indexOf("previous ")===0?a=scanBackwardsQuery(e,normalizeSelector(s.slice(9)),!!n):s==="document"?a=document:s==="window"?a=window:s==="body"?a=document.body:s==="root"?a=getRootNode(e,!!n):s==="host"?a=e.getRootNode().host:i.push(s),a&&o.push(a)}if(i.length>0){let s=i.join(","),a=asParentNode(getRootNode(e,!!n));o.push(...toArray(a.querySelectorAll(s)))}return o}var scanForwardQuery=function(e,t,n){let r=asParentNode(getRootNode(e,n)).querySelectorAll(t);for(let o=0;o<r.length;o++){let i=r[o];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_PRECEDING)return i}},scanBackwardsQuery=function(e,t,n){let r=asParentNode(getRootNode(e,n)).querySelectorAll(t);for(let o=r.length-1;o>=0;o--){let i=r[o];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING)return i}};function querySelectorExt(e,t){return typeof e!="string"?querySelectorAllExt(e,t)[0]:querySelectorAllExt(getDocument().body,e)[0]}function resolveTarget(e,t){return typeof e=="string"?find(asParentNode(t)||document,e):e}function processEventArgs(e,t,n,r){return isFunction(t)?{target:getDocument().body,event:asString(e),listener:t,options:n}:{target:resolveTarget(e),event:asString(t),listener:n,options:r}}function addEventListenerImpl(e,t,n,r){return ready(function(){let i=processEventArgs(e,t,n,r);i.target.addEventListener(i.event,i.listener,i.options)}),isFunction(t)?t:n}function removeEventListenerImpl(e,t,n){return ready(function(){let r=processEventArgs(e,t,n);r.target.removeEventListener(r.event,r.listener)}),isFunction(t)?t:n}let DUMMY_ELT=getDocument().createElement("output");function findAttributeTargets(e,t){let n=getClosestAttributeValue(e,t);if(n){if(n==="this")return[findThisElement(e,t)];{let r=querySelectorAllExt(e,n);if(/(^|,)(\s*)inherit(\s*)($|,)/.test(n)){let i=asElement(getClosestMatch(e,function(s){return s!==e&&hasAttribute(asElement(s),t)}));i&&r.push(...findAttributeTargets(i,t))}return r.length===0?(logError('The selector "'+n+'" on '+t+" returned no matches!"),[DUMMY_ELT]):r}}}function findThisElement(e,t){return asElement(getClosestMatch(e,function(n){return getAttributeValue(asElement(n),t)!=null}))}function getTarget(e){let t=getClosestAttributeValue(e,"hx-target");return t?t==="this"?findThisElement(e,"hx-target"):querySelectorExt(e,t):getInternalData(e).boosted?getDocument().body:e}function shouldSettleAttribute(e){return htmx.config.attributesToSettle.includes(e)}function cloneAttributes(e,t){forEach(Array.from(e.attributes),function(n){!t.hasAttribute(n.name)&&shouldSettleAttribute(n.name)&&e.removeAttribute(n.name)}),forEach(t.attributes,function(n){shouldSettleAttribute(n.name)&&e.setAttribute(n.name,n.value)})}function isInlineSwap(e,t){let n=getExtensions(t);for(let r=0;r<n.length;r++){let o=n[r];try{if(o.isInlineSwap(e))return!0}catch(i){logError(i)}}return e==="outerHTML"}function oobSwap(e,t,n,r){r=r||getDocument();let o="#"+CSS.escape(getRawAttribute(t,"id")),i="outerHTML";e==="true"||(e.indexOf(":")>0?(i=e.substring(0,e.indexOf(":")),o=e.substring(e.indexOf(":")+1)):i=e),t.removeAttribute("hx-swap-oob"),t.removeAttribute("data-hx-swap-oob");let s=querySelectorAllExt(r,o,!1);return s.length?(forEach(s,function(a){let l,c=t.cloneNode(!0);l=getDocument().createDocumentFragment(),l.appendChild(c),isInlineSwap(i,a)||(l=asParentNode(c));let h={shouldSwap:!0,target:a,fragment:l};triggerEvent(a,"htmx:oobBeforeSwap",h)&&(a=h.target,h.shouldSwap&&(handlePreservedElements(l),swapWithStyle(i,a,a,l,n),restorePreservedElements()),forEach(n.elts,function(u){triggerEvent(u,"htmx:oobAfterSwap",h)}))}),t.parentNode.removeChild(t)):(t.parentNode.removeChild(t),triggerErrorEvent(getDocument().body,"htmx:oobErrorNoTarget",{content:t})),e}function restorePreservedElements(){let e=find("#--htmx-preserve-pantry--");if(e){for(let t of[...e.children]){let n=find("#"+t.id);n.parentNode.moveBefore(t,n),n.remove()}e.remove()}}function handlePreservedElements(e){forEach(findAll(e,"[hx-preserve], [data-hx-preserve]"),function(t){let n=getAttributeValue(t,"id"),r=getDocument().getElementById(n);if(r!=null)if(t.moveBefore){let o=find("#--htmx-preserve-pantry--");o==null&&(getDocument().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>"),o=find("#--htmx-preserve-pantry--")),o.moveBefore(r,null)}else t.parentNode.replaceChild(r,t)})}function handleAttributes(e,t,n){forEach(t.querySelectorAll("[id]"),function(r){let o=getRawAttribute(r,"id");if(o&&o.length>0){let i=o.replace("'","\\'"),s=r.tagName.replace(":","\\:"),a=asParentNode(e),l=a&&a.querySelector(s+"[id='"+i+"']");if(l&&l!==a){let c=r.cloneNode();cloneAttributes(r,l),n.tasks.push(function(){cloneAttributes(r,c)})}}})}function makeAjaxLoadTask(e){return function(){removeClassFromElement(e,htmx.config.addedClass),processNode(asElement(e)),processFocus(asParentNode(e)),triggerEvent(e,"htmx:load")}}function processFocus(e){let t="[autofocus]",n=asHtmlElement(matches(e,t)?e:e.querySelector(t));n?.focus()}function insertNodesBefore(e,t,n,r){for(handleAttributes(e,n,r);n.childNodes.length>0;){let o=n.firstChild;addClassToElement(asElement(o),htmx.config.addedClass),e.insertBefore(o,t),o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE&&r.tasks.push(makeAjaxLoadTask(o))}}function stringHash(e,t){let n=0;for(;n<e.length;)t=(t<<5)-t+e.charCodeAt(n++)|0;return t}function attributeHash(e){let t=0;for(let n=0;n<e.attributes.length;n++){let r=e.attributes[n];r.value&&(t=stringHash(r.name,t),t=stringHash(r.value,t))}return t}function deInitOnHandlers(e){let t=getInternalData(e);if(t.onHandlers){for(let n=0;n<t.onHandlers.length;n++){let r=t.onHandlers[n];removeEventListenerImpl(e,r.event,r.listener)}delete t.onHandlers}}function deInitNode(e){let t=getInternalData(e);t.timeout&&clearTimeout(t.timeout),t.listenerInfos&&forEach(t.listenerInfos,function(n){n.on&&removeEventListenerImpl(n.on,n.trigger,n.listener)}),deInitOnHandlers(e),forEach(Object.keys(t),function(n){n!=="firstInitCompleted"&&delete t[n]})}function cleanUpElement(e){triggerEvent(e,"htmx:beforeCleanupElement"),deInitNode(e),forEach(e.children,function(t){cleanUpElement(t)})}function swapOuterHTML(e,t,n){if(e.tagName==="BODY")return swapInnerHTML(e,t,n);let r,o=e.previousSibling,i=parentElt(e);if(i){for(insertNodesBefore(i,e,t,n),o==null?r=i.firstChild:r=o.nextSibling,n.elts=n.elts.filter(function(s){return s!==e});r&&r!==e;)r instanceof Element&&n.elts.push(r),r=r.nextSibling;cleanUpElement(e),e.remove()}}function swapAfterBegin(e,t,n){return insertNodesBefore(e,e.firstChild,t,n)}function swapBeforeBegin(e,t,n){return insertNodesBefore(parentElt(e),e,t,n)}function swapBeforeEnd(e,t,n){return insertNodesBefore(e,null,t,n)}function swapAfterEnd(e,t,n){return insertNodesBefore(parentElt(e),e.nextSibling,t,n)}function swapDelete(e){cleanUpElement(e);let t=parentElt(e);if(t)return t.removeChild(e)}function swapInnerHTML(e,t,n){let r=e.firstChild;if(insertNodesBefore(e,r,t,n),r){for(;r.nextSibling;)cleanUpElement(r.nextSibling),e.removeChild(r.nextSibling);cleanUpElement(r),e.removeChild(r)}}function swapWithStyle(e,t,n,r,o){switch(e){case"none":return;case"outerHTML":swapOuterHTML(n,r,o);return;case"afterbegin":swapAfterBegin(n,r,o);return;case"beforebegin":swapBeforeBegin(n,r,o);return;case"beforeend":swapBeforeEnd(n,r,o);return;case"afterend":swapAfterEnd(n,r,o);return;case"delete":swapDelete(n);return;default:var i=getExtensions(t);for(let s=0;s<i.length;s++){let a=i[s];try{let l=a.handleSwap(e,n,r,o);if(l){if(Array.isArray(l))for(let c=0;c<l.length;c++){let h=l[c];h.nodeType!==Node.TEXT_NODE&&h.nodeType!==Node.COMMENT_NODE&&o.tasks.push(makeAjaxLoadTask(h))}return}}catch(l){logError(l)}}e==="innerHTML"?swapInnerHTML(n,r,o):swapWithStyle(htmx.config.defaultSwapStyle,t,n,r,o)}}function findAndSwapOobElements(e,t,n){var r=findAll(e,"[hx-swap-oob], [data-hx-swap-oob]");return forEach(r,function(o){if(htmx.config.allowNestedOobSwaps||o.parentElement===null){let i=getAttributeValue(o,"hx-swap-oob");i!=null&&oobSwap(i,o,t,n)}else o.removeAttribute("hx-swap-oob"),o.removeAttribute("data-hx-swap-oob")}),r.length>0}function swap(e,t,n,r){r||(r={});let o=null,i=null,s=function(){maybeCall(r.beforeSwapCallback),e=resolveTarget(e);let c=r.contextElement?getRootNode(r.contextElement,!1):getDocument(),h=document.activeElement,u={};u={elt:h,start:h?h.selectionStart:null,end:h?h.selectionEnd:null};let f=makeSettleInfo(e);if(n.swapStyle==="textContent")e.textContent=t;else{let d=makeFragment(t);if(f.title=r.title||d.title,r.historyRequest&&(d=d.querySelector("[hx-history-elt],[data-hx-history-elt]")||d),r.selectOOB){let y=r.selectOOB.split(",");for(let m=0;m<y.length;m++){let x=y[m].split(":",2),b=x[0].trim();b.indexOf("#")===0&&(b=b.substring(1));let E=x[1]||"true",g=d.querySelector("#"+b);g&&oobSwap(E,g,f,c)}}if(findAndSwapOobElements(d,f,c),forEach(findAll(d,"template"),function(y){y.content&&findAndSwapOobElements(y.content,f,c)&&y.remove()}),r.select){let y=getDocument().createDocumentFragment();forEach(d.querySelectorAll(r.select),function(m){y.appendChild(m)}),d=y}handlePreservedElements(d),swapWithStyle(n.swapStyle,r.contextElement,e,d,f),restorePreservedElements()}if(u.elt&&!bodyContains(u.elt)&&getRawAttribute(u.elt,"id")){let d=document.getElementById(getRawAttribute(u.elt,"id")),y={preventScroll:n.focusScroll!==void 0?!n.focusScroll:!htmx.config.defaultFocusScroll};if(d){if(u.start&&d.setSelectionRange)try{d.setSelectionRange(u.start,u.end)}catch{}d.focus(y)}}e.classList.remove(htmx.config.swappingClass),forEach(f.elts,function(d){d.classList&&d.classList.add(htmx.config.settlingClass),triggerEvent(d,"htmx:afterSwap",r.eventInfo)}),maybeCall(r.afterSwapCallback),n.ignoreTitle||handleTitle(f.title);let v=function(){if(forEach(f.tasks,function(d){d.call()}),forEach(f.elts,function(d){d.classList&&d.classList.remove(htmx.config.settlingClass),triggerEvent(d,"htmx:afterSettle",r.eventInfo)}),r.anchor){let d=asElement(resolveTarget("#"+r.anchor));d&&d.scrollIntoView({block:"start",behavior:"auto"})}updateScrollState(f.elts,n),maybeCall(r.afterSettleCallback),maybeCall(o)};n.settleDelay>0?getWindow().setTimeout(v,n.settleDelay):v()},a=htmx.config.globalViewTransitions;n.hasOwnProperty("transition")&&(a=n.transition);let l=r.contextElement||getDocument();if(a&&triggerEvent(l,"htmx:beforeTransition",r.eventInfo)&&typeof Promise<"u"&&document.startViewTransition){let c=new Promise(function(u,f){o=u,i=f}),h=s;s=function(){document.startViewTransition(function(){return h(),c})}}try{n?.swapDelay&&n.swapDelay>0?getWindow().setTimeout(s,n.swapDelay):s()}catch(c){throw triggerErrorEvent(l,"htmx:swapError",r.eventInfo),maybeCall(i),c}}function handleTriggerHeader(e,t,n){let r=e.getResponseHeader(t);if(r.indexOf("{")===0){let o=parseJSON(r);for(let i in o)if(o.hasOwnProperty(i)){let s=o[i];isRawObject(s)?n=s.target!==void 0?s.target:n:s={value:s},triggerEvent(n,i,s)}}else{let o=r.split(",");for(let i=0;i<o.length;i++)triggerEvent(n,o[i].trim(),[])}}let WHITESPACE=/\s/,WHITESPACE_OR_COMMA=/[\s,]/,SYMBOL_START=/[_$a-zA-Z]/,SYMBOL_CONT=/[_$a-zA-Z0-9]/,STRINGISH_START=['"',"'","/"],NOT_WHITESPACE=/[^\s]/,COMBINED_SELECTOR_START=/[{(]/,COMBINED_SELECTOR_END=/[})]/;function tokenizeString(e){let t=[],n=0;for(;n<e.length;){if(SYMBOL_START.exec(e.charAt(n))){for(var r=n;SYMBOL_CONT.exec(e.charAt(n+1));)n++;t.push(e.substring(r,n+1))}else if(STRINGISH_START.indexOf(e.charAt(n))!==-1){let o=e.charAt(n);var r=n;for(n++;n<e.length&&e.charAt(n)!==o;)e.charAt(n)==="\\"&&n++,n++;t.push(e.substring(r,n+1))}else{let o=e.charAt(n);t.push(o)}n++}return t}function isPossibleRelativeReference(e,t,n){return SYMBOL_START.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function maybeGenerateConditional(e,t,n){if(t[0]==="["){t.shift();let r=1,o=" return (function("+n+"){ return (",i=null;for(;t.length>0;){let s=t[0];if(s==="]"){if(r--,r===0){i===null&&(o=o+"true"),t.shift(),o+=")})";try{let a=maybeEval(e,function(){return Function(o)()},function(){return!0});return a.source=o,a}catch(a){return triggerErrorEvent(getDocument().body,"htmx:syntax:error",{error:a,source:o}),null}}}else s==="["&&r++;isPossibleRelativeReference(s,i,n)?o+="(("+n+"."+s+") ? ("+n+"."+s+") : (window."+s+"))":o=o+s,i=t.shift()}}}function consumeUntil(e,t){let n="";for(;e.length>0&&!t.test(e[0]);)n+=e.shift();return n}function consumeCSSSelector(e){let t;return e.length>0&&COMBINED_SELECTOR_START.test(e[0])?(e.shift(),t=consumeUntil(e,COMBINED_SELECTOR_END).trim(),e.shift()):t=consumeUntil(e,WHITESPACE_OR_COMMA),t}let INPUT_SELECTOR="input, textarea, select";function parseAndCacheTrigger(e,t,n){let r=[],o=tokenizeString(t);do{consumeUntil(o,NOT_WHITESPACE);let a=o.length,l=consumeUntil(o,/[,\[\s]/);if(l!=="")if(l==="every"){let c={trigger:"every"};consumeUntil(o,NOT_WHITESPACE),c.pollInterval=parseInterval(consumeUntil(o,/[,\[\s]/)),consumeUntil(o,NOT_WHITESPACE);var i=maybeGenerateConditional(e,o,"event");i&&(c.eventFilter=i),r.push(c)}else{let c={trigger:l};var i=maybeGenerateConditional(e,o,"event");for(i&&(c.eventFilter=i),consumeUntil(o,NOT_WHITESPACE);o.length>0&&o[0]!==",";){let u=o.shift();if(u==="changed")c.changed=!0;else if(u==="once")c.once=!0;else if(u==="consume")c.consume=!0;else if(u==="delay"&&o[0]===":")o.shift(),c.delay=parseInterval(consumeUntil(o,WHITESPACE_OR_COMMA));else if(u==="from"&&o[0]===":"){if(o.shift(),COMBINED_SELECTOR_START.test(o[0]))var s=consumeCSSSelector(o);else{var s=consumeUntil(o,WHITESPACE_OR_COMMA);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();let v=consumeCSSSelector(o);v.length>0&&(s+=" "+v)}}c.from=s}else u==="target"&&o[0]===":"?(o.shift(),c.target=consumeCSSSelector(o)):u==="throttle"&&o[0]===":"?(o.shift(),c.throttle=parseInterval(consumeUntil(o,WHITESPACE_OR_COMMA))):u==="queue"&&o[0]===":"?(o.shift(),c.queue=consumeUntil(o,WHITESPACE_OR_COMMA)):u==="root"&&o[0]===":"?(o.shift(),c[u]=consumeCSSSelector(o)):u==="threshold"&&o[0]===":"?(o.shift(),c[u]=consumeUntil(o,WHITESPACE_OR_COMMA)):triggerErrorEvent(e,"htmx:syntax:error",{token:o.shift()});consumeUntil(o,NOT_WHITESPACE)}r.push(c)}o.length===a&&triggerErrorEvent(e,"htmx:syntax:error",{token:o.shift()}),consumeUntil(o,NOT_WHITESPACE)}while(o[0]===","&&o.shift());return n&&(n[t]=r),r}function getTriggerSpecs(e){let t=getAttributeValue(e,"hx-trigger"),n=[];if(t){let r=htmx.config.triggerSpecsCache;n=r&&r[t]||parseAndCacheTrigger(e,t,r)}return n.length>0?n:matches(e,"form")?[{trigger:"submit"}]:matches(e,'input[type="button"], input[type="submit"]')?[{trigger:"click"}]:matches(e,INPUT_SELECTOR)?[{trigger:"change"}]:[{trigger:"click"}]}function cancelPolling(e){getInternalData(e).cancelled=!0}function processPolling(e,t,n){let r=getInternalData(e);r.timeout=getWindow().setTimeout(function(){bodyContains(e)&&r.cancelled!==!0&&(maybeFilterEvent(n,e,makeEvent("hx:poll:trigger",{triggerSpec:n,target:e}))||t(e),processPolling(e,t,n))},n.pollInterval)}function isLocalLink(e){return location.hostname===e.hostname&&getRawAttribute(e,"href")&&getRawAttribute(e,"href").indexOf("#")!==0}function eltIsDisabled(e){return closest(e,htmx.config.disableSelector)}function boostElement(e,t,n){if(e instanceof HTMLAnchorElement&&isLocalLink(e)&&(e.target===""||e.target==="_self")||e.tagName==="FORM"&&String(getRawAttribute(e,"method")).toLowerCase()!=="dialog"){t.boosted=!0;let r,o;if(e.tagName==="A")r="get",o=getRawAttribute(e,"href");else{let i=getRawAttribute(e,"method");r=i?i.toLowerCase():"get",o=getRawAttribute(e,"action"),(o==null||o==="")&&(o=location.href),r==="get"&&o.includes("?")&&(o=o.replace(/\?[^#]+/,""))}n.forEach(function(i){addEventListener(e,function(s,a){let l=asElement(s);if(eltIsDisabled(l)){cleanUpElement(l);return}issueAjaxRequest(r,o,l,a)},t,i,!0)})}}function shouldCancel(e,t){if(e.type==="submit"&&t.tagName==="FORM")return!0;if(e.type==="click"){let n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit")return!0;let r=t.closest("a"),o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href")))return!0}return!1}function ignoreBoostedAnchorCtrlClick(e,t){return getInternalData(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function maybeFilterEvent(e,t,n){let r=e.eventFilter;if(r)try{return r.call(t,n)!==!0}catch(o){let i=r.source;return triggerErrorEvent(getDocument().body,"htmx:eventFilter:error",{error:o,source:i}),!0}return!1}function addEventListener(e,t,n,r,o){let i=getInternalData(e),s;r.from?s=querySelectorAllExt(e,r.from):s=[e],r.changed&&("lastValue"in i||(i.lastValue=new WeakMap),s.forEach(function(a){i.lastValue.has(r)||i.lastValue.set(r,new WeakMap),i.lastValue.get(r).set(a,a.value)})),forEach(s,function(a){let l=function(c){if(!bodyContains(e)){a.removeEventListener(r.trigger,l);return}if(ignoreBoostedAnchorCtrlClick(e,c)||((o||shouldCancel(c,a))&&c.preventDefault(),maybeFilterEvent(r,e,c)))return;let h=getInternalData(c);if(h.triggerSpec=r,h.handledFor==null&&(h.handledFor=[]),h.handledFor.indexOf(e)<0){if(h.handledFor.push(e),r.consume&&c.stopPropagation(),r.target&&c.target&&!matches(asElement(c.target),r.target))return;if(r.once){if(i.triggeredOnce)return;i.triggeredOnce=!0}if(r.changed){let u=c.target,f=u.value,v=i.lastValue.get(r);if(v.has(u)&&v.get(u)===f)return;v.set(u,f)}if(i.delayed&&clearTimeout(i.delayed),i.throttle)return;r.throttle>0?i.throttle||(triggerEvent(e,"htmx:trigger"),t(e,c),i.throttle=getWindow().setTimeout(function(){i.throttle=null},r.throttle)):r.delay>0?i.delayed=getWindow().setTimeout(function(){triggerEvent(e,"htmx:trigger"),t(e,c)},r.delay):(triggerEvent(e,"htmx:trigger"),t(e,c))}};n.listenerInfos==null&&(n.listenerInfos=[]),n.listenerInfos.push({trigger:r.trigger,listener:l,on:a}),a.addEventListener(r.trigger,l)})}let windowIsScrolling=!1,scrollHandler=null;function initScrollHandler(){scrollHandler||(scrollHandler=function(){windowIsScrolling=!0},window.addEventListener("scroll",scrollHandler),window.addEventListener("resize",scrollHandler),setInterval(function(){windowIsScrolling&&(windowIsScrolling=!1,forEach(getDocument().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){maybeReveal(e)}))},200))}function maybeReveal(e){!hasAttribute(e,"data-hx-revealed")&&isScrolledIntoView(e)&&(e.setAttribute("data-hx-revealed","true"),getInternalData(e).initHash?triggerEvent(e,"revealed"):e.addEventListener("htmx:afterProcessNode",function(){triggerEvent(e,"revealed")},{once:!0}))}function loadImmediately(e,t,n,r){let o=function(){n.loaded||(n.loaded=!0,triggerEvent(e,"htmx:trigger"),t(e))};r>0?getWindow().setTimeout(o,r):o()}function processVerbs(e,t,n){let r=!1;return forEach(VERBS,function(o){if(hasAttribute(e,"hx-"+o)){let i=getAttributeValue(e,"hx-"+o);r=!0,t.path=i,t.verb=o,n.forEach(function(s){addTriggerHandler(e,s,t,function(a,l){let c=asElement(a);if(eltIsDisabled(c)){cleanUpElement(c);return}issueAjaxRequest(o,i,c,l)})})}}),r}function addTriggerHandler(e,t,n,r){if(t.trigger==="revealed")initScrollHandler(),addEventListener(e,r,n,t),maybeReveal(asElement(e));else if(t.trigger==="intersect"){let o={};t.root&&(o.root=querySelectorExt(e,t.root)),t.threshold&&(o.threshold=parseFloat(t.threshold)),new IntersectionObserver(function(s){for(let a=0;a<s.length;a++)if(s[a].isIntersecting){triggerEvent(e,"intersect");break}},o).observe(asElement(e)),addEventListener(asElement(e),r,n,t)}else!n.firstInitCompleted&&t.trigger==="load"?maybeFilterEvent(t,e,makeEvent("load",{elt:e}))||loadImmediately(asElement(e),r,n,t.delay):t.pollInterval>0?(n.polling=!0,processPolling(asElement(e),r,t)):addEventListener(e,r,n,t)}function shouldProcessHxOn(e){let t=asElement(e);if(!t)return!1;let n=t.attributes;for(let r=0;r<n.length;r++){let o=n[r].name;if(startsWith(o,"hx-on:")||startsWith(o,"data-hx-on:")||startsWith(o,"hx-on-")||startsWith(o,"data-hx-on-"))return!0}return!1}let HX_ON_QUERY=new XPathEvaluator().createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function processHXOnRoot(e,t){shouldProcessHxOn(e)&&t.push(asElement(e));let n=HX_ON_QUERY.evaluate(e),r=null;for(;r=n.iterateNext();)t.push(asElement(r))}function findHxOnWildcardElements(e){let t=[];if(e instanceof DocumentFragment)for(let n of e.childNodes)processHXOnRoot(n,t);else processHXOnRoot(e,t);return t}function findElementsToProcess(e){if(e.querySelectorAll){let n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]",r=[];for(let i in extensions){let s=extensions[i];if(s.getSelectors){var t=s.getSelectors();t&&r.push(t)}}return e.querySelectorAll(VERB_SELECTOR+n+", form, [type='submit'], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(i=>", "+i).join(""))}else return[]}function maybeSetLastButtonClicked(e){let t=getTargetButton(e.target),n=getRelatedFormData(e);n&&(n.lastButtonClicked=t)}function maybeUnsetLastButtonClicked(e){let t=getRelatedFormData(e);t&&(t.lastButtonClicked=null)}function getTargetButton(e){return closest(asElement(e),"button, input[type='submit']")}function getRelatedForm(e){return e.form||closest(e,"form")}function getRelatedFormData(e){let t=getTargetButton(e.target);if(!t)return;let n=getRelatedForm(t);if(n)return getInternalData(n)}function initButtonTracking(e){e.addEventListener("click",maybeSetLastButtonClicked),e.addEventListener("focusin",maybeSetLastButtonClicked),e.addEventListener("focusout",maybeUnsetLastButtonClicked)}function addHxOnEventHandler(e,t,n){let r=getInternalData(e);Array.isArray(r.onHandlers)||(r.onHandlers=[]);let o,i=function(s){maybeEval(e,function(){eltIsDisabled(e)||(o||(o=new Function("event",n)),o.call(e,s))})};e.addEventListener(t,i),r.onHandlers.push({event:t,listener:i})}function processHxOnWildcard(e){deInitOnHandlers(e);for(let t=0;t<e.attributes.length;t++){let n=e.attributes[t].name,r=e.attributes[t].value;if(startsWith(n,"hx-on")||startsWith(n,"data-hx-on")){let o=n.indexOf("-on")+3,i=n.slice(o,o+1);if(i==="-"||i===":"){let s=n.slice(o+1);startsWith(s,":")?s="htmx"+s:startsWith(s,"-")?s="htmx:"+s.slice(1):startsWith(s,"htmx-")&&(s="htmx:"+s.slice(5)),addHxOnEventHandler(e,s,r)}}}}function initNode(e){triggerEvent(e,"htmx:beforeProcessNode");let t=getInternalData(e),n=getTriggerSpecs(e);processVerbs(e,t,n)||(getClosestAttributeValue(e,"hx-boost")==="true"?boostElement(e,t,n):hasAttribute(e,"hx-trigger")&&n.forEach(function(o){addTriggerHandler(e,o,t,function(){})})),(e.tagName==="FORM"||getRawAttribute(e,"type")==="submit"&&hasAttribute(e,"form"))&&initButtonTracking(e),t.firstInitCompleted=!0,triggerEvent(e,"htmx:afterProcessNode")}function maybeDeInitAndHash(e){if(!(e instanceof Element))return!1;let t=getInternalData(e),n=attributeHash(e);return t.initHash!==n?(deInitNode(e),t.initHash=n,!0):!1}function processNode(e){if(e=resolveTarget(e),eltIsDisabled(e)){cleanUpElement(e);return}let t=[];maybeDeInitAndHash(e)&&t.push(e),forEach(findElementsToProcess(e),function(n){if(eltIsDisabled(n)){cleanUpElement(n);return}maybeDeInitAndHash(n)&&t.push(n)}),forEach(findHxOnWildcardElements(e),processHxOnWildcard),forEach(t,initNode)}function kebabEventName(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function makeEvent(e,t){return new CustomEvent(e,{bubbles:!0,cancelable:!0,composed:!0,detail:t})}function triggerErrorEvent(e,t,n){triggerEvent(e,t,mergeObjects({error:t},n))}function ignoreEventForLogging(e){return e==="htmx:afterProcessNode"}function withExtensions(e,t,n){forEach(getExtensions(e,[],n),function(r){try{t(r)}catch(o){logError(o)}})}function logError(e){console.error(e)}function triggerEvent(e,t,n){e=resolveTarget(e),n==null&&(n={}),n.elt=e;let r=makeEvent(t,n);htmx.logger&&!ignoreEventForLogging(t)&&htmx.logger(e,t,n),n.error&&(logError(n.error),triggerEvent(e,"htmx:error",{errorInfo:n}));let o=e.dispatchEvent(r),i=kebabEventName(t);if(o&&i!==t){let s=makeEvent(i,r.detail);o=o&&e.dispatchEvent(s)}return withExtensions(asElement(e),function(s){o=o&&s.onEvent(t,r)!==!1&&!r.defaultPrevented}),o}let currentPathForHistory;function setCurrentPathForHistory(e){currentPathForHistory=e,canAccessLocalStorage()&&sessionStorage.setItem("htmx-current-path-for-history",e)}setCurrentPathForHistory(location.pathname+location.search);function getHistoryElement(){return getDocument().querySelector("[hx-history-elt],[data-hx-history-elt]")||getDocument().body}function saveToHistoryCache(e,t){if(!canAccessLocalStorage())return;let n=cleanInnerHtmlForHistory(t),r=getDocument().title,o=window.scrollY;if(htmx.config.historyCacheSize<=0){sessionStorage.removeItem("htmx-history-cache");return}e=normalizePath(e);let i=parseJSON(sessionStorage.getItem("htmx-history-cache"))||[];for(let a=0;a<i.length;a++)if(i[a].url===e){i.splice(a,1);break}let s={url:e,content:n,title:r,scroll:o};for(triggerEvent(getDocument().body,"htmx:historyItemCreated",{item:s,cache:i}),i.push(s);i.length>htmx.config.historyCacheSize;)i.shift();for(;i.length>0;)try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(a){triggerErrorEvent(getDocument().body,"htmx:historyCacheError",{cause:a,cache:i}),i.shift()}}function getCachedHistory(e){if(!canAccessLocalStorage())return null;e=normalizePath(e);let t=parseJSON(sessionStorage.getItem("htmx-history-cache"))||[];for(let n=0;n<t.length;n++)if(t[n].url===e)return t[n];return null}function cleanInnerHtmlForHistory(e){let t=htmx.config.requestClass,n=e.cloneNode(!0);return forEach(findAll(n,"."+t),function(r){removeClassFromElement(r,t)}),forEach(findAll(n,"[data-disabled-by-htmx]"),function(r){r.removeAttribute("disabled")}),n.innerHTML}function saveCurrentPageToHistory(){let e=getHistoryElement(),t=currentPathForHistory;canAccessLocalStorage()&&(t=sessionStorage.getItem("htmx-current-path-for-history")),t=t||location.pathname+location.search,getDocument().querySelector('[hx-history="false" i],[data-hx-history="false" i]')||(triggerEvent(getDocument().body,"htmx:beforeHistorySave",{path:t,historyElt:e}),saveToHistoryCache(t,e)),htmx.config.historyEnabled&&history.replaceState({htmx:!0},getDocument().title,location.href)}function pushUrlIntoHistory(e){htmx.config.getCacheBusterParam&&(e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,""),(endsWith(e,"&")||endsWith(e,"?"))&&(e=e.slice(0,-1))),htmx.config.historyEnabled&&history.pushState({htmx:!0},"",e),setCurrentPathForHistory(e)}function replaceUrlInHistory(e){htmx.config.historyEnabled&&history.replaceState({htmx:!0},"",e),setCurrentPathForHistory(e)}function settleImmediately(e){forEach(e,function(t){t.call(void 0)})}function loadHistoryFromServer(e){let t=new XMLHttpRequest,n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0},r={path:e,xhr:t,historyElt:getHistoryElement(),swapSpec:n};t.open("GET",e,!0),htmx.config.historyRestoreAsHxRequest&&t.setRequestHeader("HX-Request","true"),t.setRequestHeader("HX-History-Restore-Request","true"),t.setRequestHeader("HX-Current-URL",location.href),t.onload=function(){this.status>=200&&this.status<400?(r.response=this.response,triggerEvent(getDocument().body,"htmx:historyCacheMissLoad",r),swap(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:!0}),setCurrentPathForHistory(r.path),triggerEvent(getDocument().body,"htmx:historyRestore",{path:e,cacheMiss:!0,serverResponse:r.response})):triggerErrorEvent(getDocument().body,"htmx:historyCacheMissLoadError",r)},triggerEvent(getDocument().body,"htmx:historyCacheMiss",r)&&t.send()}function restoreHistory(e){saveCurrentPageToHistory(),e=e||location.pathname+location.search;let t=getCachedHistory(e);if(t){let n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll},r={path:e,item:t,historyElt:getHistoryElement(),swapSpec:n};triggerEvent(getDocument().body,"htmx:historyCacheHit",r)&&(swap(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title}),setCurrentPathForHistory(r.path),triggerEvent(getDocument().body,"htmx:historyRestore",r))}else htmx.config.refreshOnHistoryMiss?htmx.location.reload(!0):loadHistoryFromServer(e)}function addRequestIndicatorClasses(e){let t=findAttributeTargets(e,"hx-indicator");return t==null&&(t=[e]),forEach(t,function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||0)+1,n.classList.add.call(n.classList,htmx.config.requestClass)}),t}function disableElements(e){let t=findAttributeTargets(e,"hx-disabled-elt");return t==null&&(t=[]),forEach(t,function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||0)+1,n.setAttribute("disabled",""),n.setAttribute("data-disabled-by-htmx","")}),t}function removeRequestIndicators(e,t){forEach(e.concat(t),function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||1)-1}),forEach(e,function(n){getInternalData(n).requestCount===0&&n.classList.remove.call(n.classList,htmx.config.requestClass)}),forEach(t,function(n){getInternalData(n).requestCount===0&&(n.removeAttribute("disabled"),n.removeAttribute("data-disabled-by-htmx"))})}function haveSeenNode(e,t){for(let n=0;n<e.length;n++)if(e[n].isSameNode(t))return!0;return!1}function shouldInclude(e){let t=e;return t.name===""||t.name==null||t.disabled||closest(t,"fieldset[disabled]")||t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"?!1:t.type==="checkbox"||t.type==="radio"?t.checked:!0}function addValueToFormData(e,t,n){e!=null&&t!=null&&(Array.isArray(t)?t.forEach(function(r){n.append(e,r)}):n.append(e,t))}function removeValueFromFormData(e,t,n){if(e!=null&&t!=null){let r=n.getAll(e);Array.isArray(t)?r=r.filter(o=>t.indexOf(o)<0):r=r.filter(o=>o!==t),n.delete(e),forEach(r,o=>n.append(e,o))}}function getValueFromInput(e){return e instanceof HTMLSelectElement&&e.multiple?toArray(e.querySelectorAll("option:checked")).map(function(t){return t.value}):e instanceof HTMLInputElement&&e.files?toArray(e.files):e.value}function processInputValue(e,t,n,r,o){if(!(r==null||haveSeenNode(e,r))){if(e.push(r),shouldInclude(r)){let i=getRawAttribute(r,"name");addValueToFormData(i,getValueFromInput(r),t),o&&validateElement(r,n)}r instanceof HTMLFormElement&&(forEach(r.elements,function(i){e.indexOf(i)>=0?removeValueFromFormData(i.name,getValueFromInput(i),t):e.push(i),o&&validateElement(i,n)}),new FormData(r).forEach(function(i,s){i instanceof File&&i.name===""||addValueToFormData(s,i,t)}))}}function validateElement(e,t){let n=e;n.willValidate&&(triggerEvent(n,"htmx:validation:validate"),n.checkValidity()||(triggerEvent(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&htmx.config.reportValidityOfForms&&n.reportValidity(),t.push({elt:n,message:n.validationMessage,validity:n.validity})))}function overrideFormData(e,t){for(let n of t.keys())e.delete(n);return t.forEach(function(n,r){e.append(r,n)}),e}function getInputValues(e,t){let n=[],r=new FormData,o=new FormData,i=[],s=getInternalData(e);s.lastButtonClicked&&!bodyContains(s.lastButtonClicked)&&(s.lastButtonClicked=null);let a=e instanceof HTMLFormElement&&e.noValidate!==!0||getAttributeValue(e,"hx-validate")==="true";if(s.lastButtonClicked&&(a=a&&s.lastButtonClicked.formNoValidate!==!0),t!=="get"&&processInputValue(n,o,i,getRelatedForm(e),a),processInputValue(n,r,i,e,a),s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&getRawAttribute(e,"type")==="submit"){let c=s.lastButtonClicked||e,h=getRawAttribute(c,"name");addValueToFormData(h,c.value,o)}let l=findAttributeTargets(e,"hx-include");return forEach(l,function(c){processInputValue(n,r,i,asElement(c),a),matches(c,"form")||forEach(asParentNode(c).querySelectorAll(INPUT_SELECTOR),function(h){processInputValue(n,r,i,h,a)})}),overrideFormData(r,o),{errors:i,formData:r,values:formDataProxy(r)}}function appendParam(e,t,n){e!==""&&(e+="&"),String(n)==="[object Object]"&&(n=JSON.stringify(n));let r=encodeURIComponent(n);return e+=encodeURIComponent(t)+"="+r,e}function urlEncode(e){e=formDataFromObject(e);let t="";return e.forEach(function(n,r){t=appendParam(t,r,n)}),t}function getHeaders(e,t,n){let r={"HX-Request":"true","HX-Trigger":getRawAttribute(e,"id"),"HX-Trigger-Name":getRawAttribute(e,"name"),"HX-Target":getAttributeValue(t,"id"),"HX-Current-URL":location.href};return getValuesForElement(e,"hx-headers",!1,r),n!==void 0&&(r["HX-Prompt"]=n),getInternalData(e).boosted&&(r["HX-Boosted"]="true"),r}function filterValues(e,t){let n=getClosestAttributeValue(t,"hx-params");if(n){if(n==="none")return new FormData;if(n==="*")return e;if(n.indexOf("not ")===0)return forEach(n.slice(4).split(","),function(r){r=r.trim(),e.delete(r)}),e;{let r=new FormData;return forEach(n.split(","),function(o){o=o.trim(),e.has(o)&&e.getAll(o).forEach(function(i){r.append(o,i)})}),r}}else return e}function isAnchorLink(e){return!!getRawAttribute(e,"href")&&getRawAttribute(e,"href").indexOf("#")>=0}function getSwapSpecification(e,t){let n=t||getClosestAttributeValue(e,"hx-swap"),r={swapStyle:getInternalData(e).boosted?"innerHTML":htmx.config.defaultSwapStyle,swapDelay:htmx.config.defaultSwapDelay,settleDelay:htmx.config.defaultSettleDelay};if(htmx.config.scrollIntoViewOnBoost&&getInternalData(e).boosted&&!isAnchorLink(e)&&(r.show="top"),n){let s=splitOnWhitespace(n);if(s.length>0)for(let a=0;a<s.length;a++){let l=s[a];if(l.indexOf("swap:")===0)r.swapDelay=parseInterval(l.slice(5));else if(l.indexOf("settle:")===0)r.settleDelay=parseInterval(l.slice(7));else if(l.indexOf("transition:")===0)r.transition=l.slice(11)==="true";else if(l.indexOf("ignoreTitle:")===0)r.ignoreTitle=l.slice(12)==="true";else if(l.indexOf("scroll:")===0){var o=l.slice(7).split(":");let h=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=h,r.scrollTarget=i}else if(l.indexOf("show:")===0){var o=l.slice(5).split(":");let u=o.pop();var i=o.length>0?o.join(":"):null;r.show=u,r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){let c=l.slice(13);r.focusScroll=c=="true"}else a==0?r.swapStyle=l:logError("Unknown modifier in hx-swap: "+l)}}return r}function usesFormData(e){return getClosestAttributeValue(e,"hx-encoding")==="multipart/form-data"||matches(e,"form")&&getRawAttribute(e,"enctype")==="multipart/form-data"}function encodeParamsForBody(e,t,n){let r=null;return withExtensions(t,function(o){r==null&&(r=o.encodeParameters(e,n,t))}),r??(usesFormData(t)?overrideFormData(new FormData,formDataFromObject(n)):urlEncode(n))}function makeSettleInfo(e){return{tasks:[],elts:[e]}}function updateScrollState(e,t){let n=e[0],r=e[e.length-1];if(t.scroll){var o=null;t.scrollTarget&&(o=asElement(querySelectorExt(n,t.scrollTarget))),t.scroll==="top"&&(n||o)&&(o=o||n,o.scrollTop=0),t.scroll==="bottom"&&(r||o)&&(o=o||r,o.scrollTop=o.scrollHeight),typeof t.scroll=="number"&&getWindow().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}if(t.show){var o=null;if(t.showTarget){let s=t.showTarget;t.showTarget==="window"&&(s="body"),o=asElement(querySelectorExt(n,s))}t.show==="top"&&(n||o)&&(o=o||n,o.scrollIntoView({block:"start",behavior:htmx.config.scrollBehavior})),t.show==="bottom"&&(r||o)&&(o=o||r,o.scrollIntoView({block:"end",behavior:htmx.config.scrollBehavior}))}}function getValuesForElement(e,t,n,r,o){if(r==null&&(r={}),e==null)return r;let i=getAttributeValue(e,t);if(i){let s=i.trim(),a=n;if(s==="unset")return null;s.indexOf("javascript:")===0?(s=s.slice(11),a=!0):s.indexOf("js:")===0&&(s=s.slice(3),a=!0),s.indexOf("{")!==0&&(s="{"+s+"}");let l;a?l=maybeEval(e,function(){return o?Function("event","return ("+s+")").call(e,o):Function("return ("+s+")").call(e)},{}):l=parseJSON(s);for(let c in l)l.hasOwnProperty(c)&&r[c]==null&&(r[c]=l[c])}return getValuesForElement(asElement(parentElt(e)),t,n,r,o)}function maybeEval(e,t,n){return htmx.config.allowEval?t():(triggerErrorEvent(e,"htmx:evalDisallowedError"),n)}function getHXVarsForElement(e,t,n){return getValuesForElement(e,"hx-vars",!0,n,t)}function getHXValsForElement(e,t,n){return getValuesForElement(e,"hx-vals",!1,n,t)}function getExpressionVars(e,t){return mergeObjects(getHXVarsForElement(e,t),getHXValsForElement(e,t))}function safelySetHeaderValue(e,t,n){if(n!==null)try{e.setRequestHeader(t,n)}catch{e.setRequestHeader(t,encodeURIComponent(n)),e.setRequestHeader(t+"-URI-AutoEncoded","true")}}function getPathFromResponse(e){if(e.responseURL)try{let t=new URL(e.responseURL);return t.pathname+t.search}catch{triggerErrorEvent(getDocument().body,"htmx:badResponseUrl",{url:e.responseURL})}}function hasHeader(e,t){return t.test(e.getAllResponseHeaders())}function ajaxHelper(e,t,n){if(e=e.toLowerCase(),n){if(n instanceof Element||typeof n=="string")return issueAjaxRequest(e,t,null,null,{targetOverride:resolveTarget(n)||DUMMY_ELT,returnPromise:!0});{let r=resolveTarget(n.target);return(n.target&&!r||n.source&&!r&&!resolveTarget(n.source))&&(r=DUMMY_ELT),issueAjaxRequest(e,t,resolveTarget(n.source),n.event,{handler:n.handler,headers:n.headers,values:n.values,targetOverride:r,swapOverride:n.swap,select:n.select,returnPromise:!0,push:n.push,replace:n.replace,selectOOB:n.selectOOB})}}else return issueAjaxRequest(e,t,null,null,{returnPromise:!0})}function hierarchyForElt(e){let t=[];for(;e;)t.push(e),e=e.parentElement;return t}function verifyPath(e,t,n){let r=new URL(t,location.protocol!=="about:"?location.href:window.origin),i=(location.protocol!=="about:"?location.origin:window.origin)===r.origin;return htmx.config.selfRequestsOnly&&!i?!1:triggerEvent(e,"htmx:validateUrl",mergeObjects({url:r,sameHost:i},n))}function formDataFromObject(e){if(e instanceof FormData)return e;let t=new FormData;for(let n in e)e.hasOwnProperty(n)&&(e[n]&&typeof e[n].forEach=="function"?e[n].forEach(function(r){t.append(n,r)}):typeof e[n]=="object"&&!(e[n]instanceof Blob)?t.append(n,JSON.stringify(e[n])):t.append(n,e[n]));return t}function formDataArrayProxy(e,t,n){return new Proxy(n,{get:function(r,o){return typeof o=="number"?r[o]:o==="length"?r.length:o==="push"?function(i){r.push(i),e.append(t,i)}:typeof r[o]=="function"?function(){r[o].apply(r,arguments),e.delete(t),r.forEach(function(i){e.append(t,i)})}:r[o]&&r[o].length===1?r[o][0]:r[o]},set:function(r,o,i){return r[o]=i,e.delete(t),r.forEach(function(s){e.append(t,s)}),!0}})}function formDataProxy(e){return new Proxy(e,{get:function(t,n){if(typeof n=="symbol"){let o=Reflect.get(t,n);return typeof o=="function"?function(){return o.apply(e,arguments)}:o}if(n==="toJSON")return()=>Object.fromEntries(e);if(n in t&&typeof t[n]=="function")return function(){return e[n].apply(e,arguments)};let r=e.getAll(n);if(r.length!==0)return r.length===1?r[0]:formDataArrayProxy(t,n,r)},set:function(t,n,r){return typeof n!="string"?!1:(t.delete(n),r&&typeof r.forEach=="function"?r.forEach(function(o){t.append(n,o)}):typeof r=="object"&&!(r instanceof Blob)?t.append(n,JSON.stringify(r)):t.append(n,r),!0)},deleteProperty:function(t,n){return typeof n=="string"&&t.delete(n),!0},ownKeys:function(t){return Reflect.ownKeys(Object.fromEntries(t))},getOwnPropertyDescriptor:function(t,n){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(t),n)}})}function issueAjaxRequest(e,t,n,r,o,i){let s=null,a=null;if(o=o??{},o.returnPromise&&typeof Promise<"u")var l=new Promise(function(p,w){s=p,a=w});n==null&&(n=getDocument().body);let c=o.handler||handleAjaxResponse,h=o.select||null;if(!bodyContains(n))return maybeCall(s),l;let u=o.targetOverride||asElement(getTarget(n));if(u==null||u==DUMMY_ELT)return triggerErrorEvent(n,"htmx:targetError",{target:getClosestAttributeValue(n,"hx-target")}),maybeCall(a),l;let f=getInternalData(n),v=f.lastButtonClicked;if(v){let p=getRawAttribute(v,"formaction");p!=null&&(t=p);let w=getRawAttribute(v,"formmethod");if(w!=null)if(VERBS.includes(w.toLowerCase()))e=w;else return maybeCall(s),l}let d=getClosestAttributeValue(n,"hx-confirm");if(i===void 0&&triggerEvent(n,"htmx:confirm",{target:u,elt:n,path:t,verb:e,triggeringEvent:r,etc:o,issueRequest:function(T){return issueAjaxRequest(e,t,n,r,o,!!T)},question:d})===!1)return maybeCall(s),l;let y=n,m=getClosestAttributeValue(n,"hx-sync"),x=null,b=!1;if(m){let p=m.split(":"),w=p[0].trim();if(w==="this"?y=findThisElement(n,"hx-sync"):y=asElement(querySelectorExt(n,w)),m=(p[1]||"drop").trim(),f=getInternalData(y),m==="drop"&&f.xhr&&f.abortable!==!0)return maybeCall(s),l;if(m==="abort"){if(f.xhr)return maybeCall(s),l;b=!0}else m==="replace"?triggerEvent(y,"htmx:abort"):m.indexOf("queue")===0&&(x=(m.split(" ")[1]||"last").trim())}if(f.xhr)if(f.abortable)triggerEvent(y,"htmx:abort");else{if(x==null){if(r){let p=getInternalData(r);p&&p.triggerSpec&&p.triggerSpec.queue&&(x=p.triggerSpec.queue)}x==null&&(x="last")}return f.queuedRequests==null&&(f.queuedRequests=[]),x==="first"&&f.queuedRequests.length===0?f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):x==="all"?f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):x==="last"&&(f.queuedRequests=[],f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)})),maybeCall(s),l}let E=new XMLHttpRequest;f.xhr=E,f.abortable=b;let g=function(){f.xhr=null,f.abortable=!1,f.queuedRequests!=null&&f.queuedRequests.length>0&&f.queuedRequests.shift()()},W=getClosestAttributeValue(n,"hx-prompt");if(W){var q=prompt(W);if(q===null||!triggerEvent(n,"htmx:prompt",{prompt:q,target:u}))return maybeCall(s),g(),l}if(d&&!i&&!confirm(d))return maybeCall(s),g(),l;let H=getHeaders(n,u,q);e!=="get"&&!usesFormData(n)&&(H["Content-Type"]="application/x-www-form-urlencoded"),o.headers&&(H=mergeObjects(H,o.headers));let X=getInputValues(n,e),R=X.errors,$=X.formData;o.values&&overrideFormData($,formDataFromObject(o.values));let re=formDataFromObject(getExpressionVars(n,r)),P=overrideFormData($,re),D=filterValues(P,n);htmx.config.getCacheBusterParam&&e==="get"&&D.set("org.htmx.cache-buster",getRawAttribute(u,"id")||"true"),(t==null||t==="")&&(t=location.href);let k=getValuesForElement(n,"hx-request"),J=getInternalData(n).boosted,I=htmx.config.methodsThatUseUrlParams.indexOf(e)>=0,S={boosted:J,useUrlParams:I,formData:D,parameters:formDataProxy(D),unfilteredFormData:P,unfilteredParameters:formDataProxy(P),headers:H,elt:n,target:u,verb:e,errors:R,withCredentials:o.credentials||k.credentials||htmx.config.withCredentials,timeout:o.timeout||k.timeout||htmx.config.timeout,path:t,triggeringEvent:r};if(!triggerEvent(n,"htmx:configRequest",S))return maybeCall(s),g(),l;if(t=S.path,e=S.verb,H=S.headers,D=formDataFromObject(S.parameters),R=S.errors,I=S.useUrlParams,R&&R.length>0)return triggerEvent(n,"htmx:validation:halted",S),maybeCall(s),g(),l;let z=t.split("#"),oe=z[0],M=z[1],A=t;if(I&&(A=oe,!D.keys().next().done&&(A.indexOf("?")<0?A+="?":A+="&",A+=urlEncode(D),M&&(A+="#"+M))),!verifyPath(n,A,S))return triggerErrorEvent(n,"htmx:invalidPath",S),maybeCall(a),g(),l;if(E.open(e.toUpperCase(),A,!0),E.overrideMimeType("text/html"),E.withCredentials=S.withCredentials,E.timeout=S.timeout,!k.noHeaders){for(let p in H)if(H.hasOwnProperty(p)){let w=H[p];safelySetHeaderValue(E,p,w)}}let C={xhr:E,target:u,requestConfig:S,etc:o,boosted:J,select:h,pathInfo:{requestPath:t,finalRequestPath:A,responsePath:null,anchor:M}};if(E.onload=function(){try{let p=hierarchyForElt(n);if(C.pathInfo.responsePath=getPathFromResponse(E),c(n,C),C.keepIndicators!==!0&&removeRequestIndicators(L,O),triggerEvent(n,"htmx:afterRequest",C),triggerEvent(n,"htmx:afterOnLoad",C),!bodyContains(n)){let w=null;for(;p.length>0&&w==null;){let T=p.shift();bodyContains(T)&&(w=T)}w&&(triggerEvent(w,"htmx:afterRequest",C),triggerEvent(w,"htmx:afterOnLoad",C))}maybeCall(s)}catch(p){throw triggerErrorEvent(n,"htmx:onLoadError",mergeObjects({error:p},C)),p}finally{g()}},E.onerror=function(){removeRequestIndicators(L,O),triggerErrorEvent(n,"htmx:afterRequest",C),triggerErrorEvent(n,"htmx:sendError",C),maybeCall(a),g()},E.onabort=function(){removeRequestIndicators(L,O),triggerErrorEvent(n,"htmx:afterRequest",C),triggerErrorEvent(n,"htmx:sendAbort",C),maybeCall(a),g()},E.ontimeout=function(){removeRequestIndicators(L,O),triggerErrorEvent(n,"htmx:afterRequest",C),triggerErrorEvent(n,"htmx:timeout",C),maybeCall(a),g()},!triggerEvent(n,"htmx:beforeRequest",C))return maybeCall(s),g(),l;var L=addRequestIndicatorClasses(n),O=disableElements(n);forEach(["loadstart","loadend","progress","abort"],function(p){forEach([E,E.upload],function(w){w.addEventListener(p,function(T){triggerEvent(n,"htmx:xhr:"+p,{lengthComputable:T.lengthComputable,loaded:T.loaded,total:T.total})})})}),triggerEvent(n,"htmx:beforeSend",C);let ie=I?null:encodeParamsForBody(E,n,D);return E.send(ie),l}function determineHistoryUpdates(e,t){let n=t.xhr,r=null,o=null;if(hasHeader(n,/HX-Push:/i)?(r=n.getResponseHeader("HX-Push"),o="push"):hasHeader(n,/HX-Push-Url:/i)?(r=n.getResponseHeader("HX-Push-Url"),o="push"):hasHeader(n,/HX-Replace-Url:/i)&&(r=n.getResponseHeader("HX-Replace-Url"),o="replace"),r)return r==="false"?{}:{type:o,path:r};let i=t.pathInfo.finalRequestPath,s=t.pathInfo.responsePath,a=t.etc.push||getClosestAttributeValue(e,"hx-push-url"),l=t.etc.replace||getClosestAttributeValue(e,"hx-replace-url"),c=getInternalData(e).boosted,h=null,u=null;return a?(h="push",u=a):l?(h="replace",u=l):c&&(h="push",u=s||i),u?u==="false"?{}:(u==="true"&&(u=s||i),t.pathInfo.anchor&&u.indexOf("#")===-1&&(u=u+"#"+t.pathInfo.anchor),{type:h,path:u}):{}}function codeMatches(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function resolveResponseHandling(e){for(var t=0;t<htmx.config.responseHandling.length;t++){var n=htmx.config.responseHandling[t];if(codeMatches(n,e.status))return n}return{swap:!1}}function handleTitle(e){if(e){let t=find("title");t?t.textContent=e:window.document.title=e}}function resolveRetarget(e,t){if(t==="this")return e;let n=asElement(querySelectorExt(e,t));if(n==null)throw triggerErrorEvent(e,"htmx:targetError",{target:t}),new Error(`Invalid re-target ${t}`);return n}function handleAjaxResponse(e,t){let n=t.xhr,r=t.target,o=t.etc,i=t.select;if(!triggerEvent(e,"htmx:beforeOnLoad",t))return;if(hasHeader(n,/HX-Trigger:/i)&&handleTriggerHeader(n,"HX-Trigger",e),hasHeader(n,/HX-Location:/i)){let b=n.getResponseHeader("HX-Location");var s={};b.indexOf("{")===0&&(s=parseJSON(b),b=s.path,delete s.path),s.push=s.push||"true",ajaxHelper("get",b,s);return}let a=hasHeader(n,/HX-Refresh:/i)&&n.getResponseHeader("HX-Refresh")==="true";if(hasHeader(n,/HX-Redirect:/i)){t.keepIndicators=!0,htmx.location.href=n.getResponseHeader("HX-Redirect"),a&&htmx.location.reload();return}if(a){t.keepIndicators=!0,htmx.location.reload();return}let l=determineHistoryUpdates(e,t),c=resolveResponseHandling(n),h=c.swap,u=!!c.error,f=htmx.config.ignoreTitle||c.ignoreTitle,v=c.select;c.target&&(t.target=resolveRetarget(e,c.target));var d=o.swapOverride;d==null&&c.swapOverride&&(d=c.swapOverride),hasHeader(n,/HX-Retarget:/i)&&(t.target=resolveRetarget(e,n.getResponseHeader("HX-Retarget"))),hasHeader(n,/HX-Reswap:/i)&&(d=n.getResponseHeader("HX-Reswap"));var y=n.response,m=mergeObjects({shouldSwap:h,serverResponse:y,isError:u,ignoreTitle:f,selectOverride:v,swapOverride:d},t);if(!(c.event&&!triggerEvent(r,c.event,m))&&triggerEvent(r,"htmx:beforeSwap",m)){if(r=m.target,y=m.serverResponse,u=m.isError,f=m.ignoreTitle,v=m.selectOverride,d=m.swapOverride,t.target=r,t.failed=u,t.successful=!u,m.shouldSwap){n.status===286&&cancelPolling(e),withExtensions(e,function(g){y=g.transformResponse(y,n,e)}),l.type&&saveCurrentPageToHistory();var x=getSwapSpecification(e,d);x.hasOwnProperty("ignoreTitle")||(x.ignoreTitle=f),r.classList.add(htmx.config.swappingClass),i&&(v=i),hasHeader(n,/HX-Reselect:/i)&&(v=n.getResponseHeader("HX-Reselect"));let b=o.selectOOB||getClosestAttributeValue(e,"hx-select-oob"),E=getClosestAttributeValue(e,"hx-select");swap(r,y,x,{select:v==="unset"?null:v||E,selectOOB:b,eventInfo:t,anchor:t.pathInfo.anchor,contextElement:e,afterSwapCallback:function(){if(hasHeader(n,/HX-Trigger-After-Swap:/i)){let g=e;bodyContains(e)||(g=getDocument().body),handleTriggerHeader(n,"HX-Trigger-After-Swap",g)}},afterSettleCallback:function(){if(hasHeader(n,/HX-Trigger-After-Settle:/i)){let g=e;bodyContains(e)||(g=getDocument().body),handleTriggerHeader(n,"HX-Trigger-After-Settle",g)}},beforeSwapCallback:function(){l.type&&(triggerEvent(getDocument().body,"htmx:beforeHistoryUpdate",mergeObjects({history:l},t)),l.type==="push"?(pushUrlIntoHistory(l.path),triggerEvent(getDocument().body,"htmx:pushedIntoHistory",{path:l.path})):(replaceUrlInHistory(l.path),triggerEvent(getDocument().body,"htmx:replacedInHistory",{path:l.path})))}})}u&&triggerErrorEvent(e,"htmx:responseError",mergeObjects({error:"Response Status Error Code "+n.status+" from "+t.pathInfo.requestPath},t))}}let extensions={};function extensionBase(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return!0},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return!1},handleSwap:function(e,t,n,r){return!1},encodeParameters:function(e,t,n){return null}}}function defineExtension(e,t){t.init&&t.init(internalAPI),extensions[e]=mergeObjects(extensionBase(),t)}function removeExtension(e){delete extensions[e]}function getExtensions(e,t,n){if(t==null&&(t=[]),e==null)return t;n==null&&(n=[]);let r=getAttributeValue(e,"hx-ext");return r&&forEach(r.split(","),function(o){if(o=o.replace(/ /g,""),o.slice(0,7)=="ignore:"){n.push(o.slice(7));return}if(n.indexOf(o)<0){let i=extensions[o];i&&t.indexOf(i)<0&&t.push(i)}}),getExtensions(asElement(parentElt(e)),t,n)}var isReady=!1;getDocument().addEventListener("DOMContentLoaded",function(){isReady=!0});function ready(e){isReady||getDocument().readyState==="complete"?e():getDocument().addEventListener("DOMContentLoaded",e)}function insertIndicatorStyles(){if(htmx.config.includeIndicatorStyles!==!1){let e=htmx.config.inlineStyleNonce?` nonce="${htmx.config.inlineStyleNonce}"`:"",t=htmx.config.indicatorClass,n=htmx.config.requestClass;getDocument().head.insertAdjacentHTML("beforeend",`<style${e}>.${t}{opacity:0;visibility: hidden} .${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}</style>`)}}function getMetaConfig(){let e=getDocument().querySelector('meta[name="htmx-config"]');return e?parseJSON(e.content):null}function mergeMetaConfig(){let e=getMetaConfig();e&&(htmx.config=mergeObjects(htmx.config,e))}return ready(function(){mergeMetaConfig(),insertIndicatorStyles();let e=getDocument().body;processNode(e);let t=getDocument().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(r){let o=r.detail.elt||r.target,i=getInternalData(o);i&&i.xhr&&i.xhr.abort()});let n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(r){r.state&&r.state.htmx?(restoreHistory(),forEach(t,function(o){triggerEvent(o,"htmx:restored",{document:getDocument(),triggerEvent})})):n&&n(r)},getWindow().setTimeout(function(){triggerEvent(e,"htmx:load",{}),e=null},0)}),htmx})(),F=se;(function(){let e;F.defineExtension("json-enc",{init:function(t){e=t},onEvent:function(t,n){t==="htmx:configRequest"&&(n.detail.headers["Content-Type"]="application/json")},encodeParameters:function(t,n,r){t.overrideMimeType("text/json");let o={};n.forEach(function(s,a){Object.hasOwn(o,a)?(Array.isArray(o[a])||(o[a]=[o[a]]),o[a].push(s)):o[a]=s});let i=e.getExpressionVars(r);return Object.keys(o).forEach(function(s){o[s]=Object.hasOwn(i,s)?i[s]:o[s]}),JSON.stringify(o)}})})();var K=document.createElement("template");K.innerHTML=` 2 <slot></slot> 3 4 <ul class="menu" part="menu"></ul> ··· 92 <span class="handle" part="handle"></span> 93 </button> 94 </li> 95 + `;function Y(e){return e.cloneNode(!0)}var N=class extends HTMLElement{static tag="actor-typeahead";static define(t=this.tag){this.tag=t;let n=customElements.getName(this);if(n&&n!==t)return console.warn(`${this.name} already defined as <${n}>!`);let r=customElements.get(t);if(r&&r!==this)return console.warn(`<${t}> already defined as ${r.name}!`);customElements.define(t,this)}static{let t=new URL(import.meta.url).searchParams.get("tag")||this.tag;t!=="none"&&this.define(t)}#n=this.attachShadow({mode:"closed"});#r=[];#e=-1;#o=!1;constructor(){super(),this.#n.append(Y(K).content),this.#t(),this.addEventListener("input",this),this.addEventListener("focusout",this),this.addEventListener("keydown",this),this.#n.addEventListener("pointerdown",this),this.#n.addEventListener("pointerup",this),this.#n.addEventListener("click",this)}get#i(){let t=Number.parseInt(this.getAttribute("rows")??"");return Number.isNaN(t)?5:t}handleEvent(t){switch(t.type){case"input":this.#a(t);break;case"keydown":this.#s(t);break;case"focusout":this.#l(t);break;case"pointerdown":this.#c(t);break;case"pointerup":this.#u(t);break}}#s(t){switch(t.key){case"ArrowDown":t.preventDefault(),this.#e=Math.min(this.#e+1,this.#i-1),this.#t();break;case"PageDown":t.preventDefault(),this.#e=this.#i-1,this.#t();break;case"ArrowUp":t.preventDefault(),this.#e=Math.max(this.#e-1,0),this.#t();break;case"PageUp":t.preventDefault(),this.#e=0,this.#t();break;case"Escape":t.preventDefault(),this.#r=[],this.#e=-1,this.#t();break;case"Enter":t.preventDefault(),this.#n.querySelectorAll("button")[this.#e]?.dispatchEvent(new PointerEvent("pointerup",{bubbles:!0}));break}}async#a(t){let n=t.target?.value;if(!n){this.#r=[],this.#t();return}let r=this.getAttribute("host")??"https://public.api.bsky.app",o=new URL("xrpc/app.bsky.actor.searchActorsTypeahead",r);o.searchParams.set("q",n),o.searchParams.set("limit",`${this.#i}`);let s=await(await fetch(o)).json();this.#r=s.actors,this.#e=-1,this.#t()}async#l(t){this.#o||(this.#r=[],this.#e=-1,this.#t())}#t(){let t=document.createDocumentFragment(),n=-1;for(let r of this.#r){let o=Y(G).content,i=o.querySelector("button");i&&(i.dataset.handle=r.handle,++n===this.#e&&(i.dataset.active="true"));let s=o.querySelector("img");s&&r.avatar&&(s.src=r.avatar);let a=o.querySelector(".handle");a&&(a.textContent=r.handle),t.append(o)}this.#n.querySelector(".menu")?.replaceChildren(...t.children)}#c(t){this.#o=!0}#u(t){this.#o=!1,this.querySelector("input")?.focus();let n=t.target?.closest("button"),r=this.querySelector("input");!r||!n||(r.value=n.dataset.handle||"",this.#r=[],this.#t())}};function Z(){return localStorage.getItem("theme")||"system"}function ae(e){return e==="dark"||e==="light"?e:window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function U(){let e=Z(),n=ae(e)==="dark";document.documentElement.classList.toggle("dark",n),document.documentElement.setAttribute("data-theme",n?"dark":"light"),le(e)}function ee(e){localStorage.setItem("theme",e),U(),ce()}function le(e){let t={system:"sun-moon",light:"sun",dark:"moon"};document.querySelectorAll("[data-theme-icon] use").forEach(n=>{n.setAttribute("href",`/icons.svg#${t[e]||"sun-moon"}`)}),document.querySelectorAll(".theme-option").forEach(n=>{let r=n.dataset.value===e,o=n.querySelector(".theme-check");o&&(o.style.visibility=r?"visible":"hidden")})}function ce(){document.querySelectorAll("[data-theme-toggle]").forEach(e=>{let t=e.closest("details");t&&t.removeAttribute("open")})}window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{Z()==="system"&&U()});function ue(){let e=document.querySelector(".nav-search-wrapper"),t=document.getElementById("nav-search-input");!e||!t||(e.classList.toggle("expanded"),e.classList.contains("expanded")&&t.focus())}function B(){let e=document.querySelector(".nav-search-wrapper");e&&e.classList.remove("expanded")}document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelector(".nav-search-wrapper"),t=document.getElementById("nav-search-input");!e||!t||(document.addEventListener("keydown",n=>{n.key==="Escape"&&e.classList.contains("expanded")&&B()}),document.addEventListener("click",n=>{e.classList.contains("expanded")&&!e.contains(n.target)&&B()}))});function te(e,t){!t&&typeof event<"u"&&(t=event.target.closest("button")),navigator.clipboard.writeText(e).then(()=>{if(!t)return;let n=t.innerHTML;t.innerHTML='<svg class="icon size-4" aria-hidden="true"><use href="/icons.svg#check"></use></svg> Copied!',setTimeout(()=>{t.innerHTML=n},2e3)}).catch(n=>{console.error("Failed to copy:",n)})}document.addEventListener("DOMContentLoaded",()=>{document.addEventListener("click",e=>{let t=e.target.closest("button[data-cmd]");if(t){te(t.getAttribute("data-cmd"),t);return}if(e.target.closest("a, button, input, .cmd"))return;let n=e.target.closest("[data-href]");n&&(window.location=n.getAttribute("data-href"))})});function fe(e){let t=Math.floor((new Date-new Date(e))/1e3),n={year:31536e3,month:2592e3,week:604800,day:86400,hour:3600,minute:60,second:1};for(let[r,o]of Object.entries(n)){let i=Math.floor(t/o);if(i>=1)return i===1?`1 ${r} ago`:`${i} ${r}s ago`}return"just now"}function _(){document.querySelectorAll("time[datetime]").forEach(e=>{let t=e.getAttribute("datetime");if(t&&!e.dataset.noUpdate){let n=fe(t);e.textContent!==n&&(e.textContent=n)}})}document.addEventListener("DOMContentLoaded",()=>{_(),U(),document.querySelectorAll("[data-theme-menu]").forEach(e=>{e.querySelectorAll(".theme-option").forEach(t=>{t.addEventListener("click",()=>{ee(t.dataset.value)})})}),document.addEventListener("click",e=>{let t=e.target.closest("details.dropdown");document.querySelectorAll("details.dropdown[open]").forEach(n=>{n!==t&&n.removeAttribute("open")})})});document.addEventListener("htmx:afterSwap",_);setInterval(_,6e4);function de(){let e=document.getElementById("show-offline-toggle"),t=document.querySelector(".manifests-list");!e||!t||(localStorage.setItem("showOfflineManifests",e.checked),e.checked?t.classList.add("show-offline"):t.classList.remove("show-offline"))}document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("show-offline-toggle");if(!e)return;let t=localStorage.getItem("showOfflineManifests")==="true";e.checked=t;let n=document.querySelector(".manifests-list");n&&(t?n.classList.add("show-offline"):n.classList.remove("show-offline"))});async function he(e,t,n){try{let r=await fetch("/api/manifests",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e,digest:t,confirm:!1})});if(r.status===409){let o=await r.json();me(e,t,n,o.tags)}else if(r.ok)ne(n);else{let o=await r.text();alert(`Failed to delete manifest: ${o}`)}}catch(r){console.error("Error deleting manifest:",r),alert(`Error deleting manifest: ${r.message}`)}}function me(e,t,n,r){let o=document.getElementById("manifest-delete-modal"),i=document.getElementById("manifest-delete-tags"),s=document.getElementById("confirm-manifest-delete-btn");i.innerHTML="",r.forEach(a=>{let l=document.createElement("li");l.textContent=a,i.appendChild(l)}),s.onclick=()=>ge(e,t,n),o.style.display="flex"}function j(){let e=document.getElementById("manifest-delete-modal");e.style.display="none"}async function ge(e,t,n){let r=document.getElementById("confirm-manifest-delete-btn"),o=r.textContent;try{r.disabled=!0,r.textContent="Deleting...";let i=await fetch("/api/manifests",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e,digest:t,confirm:!0})});if(i.ok)j(),ne(n),location.reload();else{let s=await i.text();alert(`Failed to delete manifest: ${s}`),r.disabled=!1,r.textContent=o}}catch(i){console.error("Error deleting manifest:",i),alert(`Error deleting manifest: ${i.message}`),r.disabled=!1,r.textContent=o}}function ne(e){let t=document.getElementById(`manifest-${e}`);t&&t.remove()}document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("manifest-delete-modal");e&&e.addEventListener("click",t=>{t.target===e&&j()})});async function pe(e,t){let n=document.getElementById("vuln-detail-modal"),r=document.getElementById("vuln-modal-body");if(!(!n||!r)){r.innerHTML='<div class="flex justify-center py-8"><span class="loading loading-spinner loading-lg"></span></div>',n.showModal();try{let o=await fetch(`/api/vuln-details?digest=${encodeURIComponent(e)}&holdEndpoint=${encodeURIComponent(t)}`);r.innerHTML=await o.text()}catch{r.innerHTML='<p class="text-error">Failed to load vulnerability details</p>'}}}var V=class{constructor(t){this.input=t,this.typeahead=t.closest("actor-typeahead"),this.dropdown=null,this.currentFocus=-1,this.typeaheadClosed=!1,this.init()}init(){this.createDropdown(),this.input.addEventListener("focus",()=>this.handleFocus()),this.input.addEventListener("input",()=>this.handleInput()),this.input.addEventListener("keydown",t=>this.handleKeydown(t)),document.addEventListener("click",t=>{!this.input.contains(t.target)&&!this.dropdown.contains(t.target)&&this.hideDropdown()})}createDropdown(){this.dropdown=document.createElement("div"),this.dropdown.className="recent-accounts-dropdown",this.dropdown.style.display="none",this.typeahead?this.typeahead.insertAdjacentElement("afterend",this.dropdown):this.input.insertAdjacentElement("afterend",this.dropdown)}handleFocus(){this.input.value.trim().length<1&&this.showRecentAccounts()}handleInput(){this.input.value.trim().length>=1&&this.hideDropdown(),this.typeaheadClosed=!1}showRecentAccounts(){let t=this.getRecentAccounts();if(t.length===0){this.hideDropdown();return}this.dropdown.innerHTML="",this.currentFocus=-1;let n=document.createElement("div");n.className="recent-accounts-header",n.textContent="Recent accounts",this.dropdown.appendChild(n),t.forEach((r,o)=>{let i=document.createElement("div");i.className="recent-accounts-item",i.dataset.index=o,i.dataset.handle=r,i.textContent=r,i.addEventListener("click",()=>this.selectItem(r)),this.dropdown.appendChild(i)}),this.dropdown.style.display="block"}selectItem(t){this.input.value=t,this.hideDropdown(),this.input.focus()}hideDropdown(){this.dropdown.style.display="none",this.currentFocus=-1}handleKeydown(t){if(t.key==="Enter"&&this.input.value.trim().length>=2&&(this.typeaheadClosed=!0),t.key==="Tab"&&this.input.value.trim().length>=2&&!this.typeaheadClosed){t.preventDefault();let o=t.shiftKey?"ArrowUp":"ArrowDown",i=new KeyboardEvent("keydown",{key:o,bubbles:!0,cancelable:!0});this.typeahead.dispatchEvent(i);return}if(this.dropdown.style.display==="none")return;let n=this.dropdown.querySelectorAll(".recent-accounts-item");t.key==="ArrowDown"?(t.preventDefault(),this.currentFocus++,this.currentFocus>=n.length&&(this.currentFocus=0),this.updateFocus(n)):t.key==="ArrowUp"?(t.preventDefault(),this.currentFocus--,this.currentFocus<0&&(this.currentFocus=n.length-1),this.updateFocus(n)):t.key==="Enter"&&this.currentFocus>-1&&n[this.currentFocus]?(t.preventDefault(),this.selectItem(n[this.currentFocus].dataset.handle)):t.key==="Escape"&&this.hideDropdown()}updateFocus(t){t.forEach((n,r)=>{n.classList.toggle("focused",r===this.currentFocus)})}getRecentAccounts(){try{let t=localStorage.getItem("atcr_recent_handles");return t?JSON.parse(t):[]}catch{return[]}}saveRecentAccount(t){if(t)try{let n=this.getRecentAccounts();n=n.filter(r=>r!==t),n.unshift(t),n=n.slice(0,5),localStorage.setItem("atcr_recent_handles",JSON.stringify(n))}catch(n){console.error("Failed to save recent account:",n)}}};document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("login-form"),t=document.getElementById("handle");e&&t&&new V(t)});document.addEventListener("DOMContentLoaded",()=>{let e=document.cookie.split("; ").find(n=>n.startsWith("atcr_login_handle="));if(!e)return;let t=decodeURIComponent(e.split("=")[1]);if(t){try{let n="atcr_recent_handles",r=JSON.parse(localStorage.getItem(n)||"[]");r=r.filter(o=>o!==t),r.unshift(t),r=r.slice(0,5),localStorage.setItem(n,JSON.stringify(r))}catch(n){console.error("Failed to save recent account:",n)}document.cookie="atcr_login_handle=; path=/; max-age=0"}});function Q(){let e=document.getElementById("featured-carousel"),t=document.getElementById("carousel-prev"),n=document.getElementById("carousel-next");if(!e)return;let r=Array.from(e.querySelectorAll(".carousel-item"));if(r.length===0)return;let o=null,i=5e3,s=0,a=0,l=0;function c(){let b=r[0];if(!b)return;let E=getComputedStyle(e),g=parseFloat(E.gap)||24;s=b.offsetWidth+g,a=e.offsetWidth,l=e.scrollWidth}requestAnimationFrame(()=>{requestAnimationFrame(()=>{c(),m()})});let h;window.addEventListener("resize",()=>{clearTimeout(h),h=setTimeout(c,150)});function u(){return s||c(),s}function f(){return(!a||!s)&&c(),Math.round(a/s)||1}function v(){return(!l||!a)&&c(),l-a}function d(){let b=u(),E=v(),g=e.scrollLeft;g>=E-10?e.scrollTo({left:0,behavior:"smooth"}):e.scrollTo({left:g+b,behavior:"smooth"})}function y(){let b=u(),E=v(),g=e.scrollLeft;g<=10?e.scrollTo({left:E,behavior:"smooth"}):e.scrollTo({left:g-b,behavior:"smooth"})}t&&t.addEventListener("click",()=>{x(),y(),m()}),n&&n.addEventListener("click",()=>{x(),d(),m()});function m(){o||r.length<=f()||(o=setInterval(d,i))}function x(){o&&(clearInterval(o),o=null)}e.addEventListener("mouseenter",x),e.addEventListener("mouseleave",m)}document.addEventListener("DOMContentLoaded",()=>{"requestIdleCallback"in window?requestIdleCallback(Q,{timeout:2e3}):setTimeout(Q,100)});window.setTheme=ee;window.toggleSearch=ue;window.closeSearch=B;window.copyToClipboard=te;window.toggleOfflineManifests=de;window.deleteManifest=he;window.closeManifestDeleteModal=j;window.openVulnDetails=pe;window.htmx=F;
+4
pkg/appview/routes/routes.go
··· 122 // Manifest health check API endpoint (HTMX polling) 123 router.Get("/api/manifest-health", (&uihandlers.ManifestHealthHandler{BaseUIHandler: base}).ServeHTTP) 124 125 router.Get("/u/{handle}", middleware.OptionalAuth(deps.SessionStore, deps.Database)( 126 &uihandlers.UserPageHandler{BaseUIHandler: base}, 127 ).ServeHTTP)
··· 122 // Manifest health check API endpoint (HTMX polling) 123 router.Get("/api/manifest-health", (&uihandlers.ManifestHealthHandler{BaseUIHandler: base}).ServeHTTP) 124 125 + // Vulnerability scan result API endpoints (HTMX lazy loading + modal content) 126 + router.Get("/api/scan-result", (&uihandlers.ScanResultHandler{BaseUIHandler: base}).ServeHTTP) 127 + router.Get("/api/vuln-details", (&uihandlers.VulnDetailsHandler{BaseUIHandler: base}).ServeHTTP) 128 + 129 router.Get("/u/{handle}", middleware.OptionalAuth(deps.SessionStore, deps.Database)( 130 &uihandlers.UserPageHandler{BaseUIHandler: base}, 131 ).ServeHTTP)
+19
pkg/appview/src/js/app.js
··· 363 } 364 }); 365 366 // Login page recent accounts helper (works alongside actor-typeahead web component) 367 class RecentAccountsHelper { 368 constructor(inputElement) { ··· 692 window.toggleOfflineManifests = toggleOfflineManifests; 693 window.deleteManifest = deleteManifest; 694 window.closeManifestDeleteModal = closeManifestDeleteModal;
··· 363 } 364 }); 365 366 + // Vulnerability details modal 367 + async function openVulnDetails(digest, holdEndpoint) { 368 + const modal = document.getElementById('vuln-detail-modal'); 369 + const body = document.getElementById('vuln-modal-body'); 370 + if (!modal || !body) return; 371 + 372 + // Show modal with loading spinner 373 + body.innerHTML = '<div class="flex justify-center py-8"><span class="loading loading-spinner loading-lg"></span></div>'; 374 + modal.showModal(); 375 + 376 + try { 377 + const resp = await fetch(`/api/vuln-details?digest=${encodeURIComponent(digest)}&holdEndpoint=${encodeURIComponent(holdEndpoint)}`); 378 + body.innerHTML = await resp.text(); 379 + } catch { 380 + body.innerHTML = '<p class="text-error">Failed to load vulnerability details</p>'; 381 + } 382 + } 383 + 384 // Login page recent accounts helper (works alongside actor-typeahead web component) 385 class RecentAccountsHelper { 386 constructor(inputElement) { ··· 710 window.toggleOfflineManifests = toggleOfflineManifests; 711 window.deleteManifest = deleteManifest; 712 window.closeManifestDeleteModal = closeManifestDeleteModal; 713 + window.openVulnDetails = openVulnDetails;
+21
pkg/appview/templates/pages/repository.html
··· 208 {{ else if not .Reachable }} 209 <span class="badge badge-sm badge-warning">{{ icon "alert-triangle" "size-3" }} Offline</span> 210 {{ end }} 211 </div> 212 <div class="flex items-center gap-2"> 213 <code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code> ··· 281 <form method="dialog" class="modal-backdrop"> 282 <button onclick="closeManifestDeleteModal()">close</button> 283 </form> 284 </dialog> 285 286 {{ template "footer" . }}
··· 208 {{ else if not .Reachable }} 209 <span class="badge badge-sm badge-warning">{{ icon "alert-triangle" "size-3" }} Offline</span> 210 {{ end }} 211 + {{/* Vulnerability scan badge (lazy-loaded from hold) */}} 212 + {{ if and (not .IsManifestList) .Manifest.HoldEndpoint }} 213 + <span hx-get="/api/scan-result?digest={{ .Manifest.Digest | urlquery }}&holdEndpoint={{ .Manifest.HoldEndpoint | urlquery }}" 214 + hx-trigger="load delay:1s" 215 + hx-swap="outerHTML"> 216 + </span> 217 + {{ end }} 218 </div> 219 <div class="flex items-center gap-2"> 220 <code class="font-mono text-xs text-base-content/60 truncate max-w-40" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code> ··· 288 <form method="dialog" class="modal-backdrop"> 289 <button onclick="closeManifestDeleteModal()">close</button> 290 </form> 291 + </dialog> 292 + 293 + <!-- Vulnerability Details Modal --> 294 + <dialog id="vuln-detail-modal" class="modal"> 295 + <div class="modal-box max-w-4xl"> 296 + <h3 class="text-lg font-bold">Vulnerability Scan Results</h3> 297 + <div id="vuln-modal-body" class="py-4"> 298 + <span class="loading loading-spinner loading-md"></span> 299 + </div> 300 + <div class="modal-action"> 301 + <form method="dialog"><button class="btn">Close</button></form> 302 + </div> 303 + </div> 304 + <form method="dialog" class="modal-backdrop"><button>close</button></form> 305 </dialog> 306 307 {{ template "footer" . }}
+24
pkg/appview/templates/partials/vuln-badge.html
···
··· 1 + {{ define "vuln-badge" }} 2 + {{ if .Error }} 3 + {{/* Silently hide on error / no scan record — scan badges are non-critical */}} 4 + {{ else if eq .Total 0 }} 5 + <span class="badge badge-sm badge-success" title="No vulnerabilities found (scanned {{ .ScannedAt }})">{{ icon "shield-check" "size-3" }} Clean</span> 6 + {{ else }} 7 + <button class="flex items-center gap-1 cursor-pointer hover:opacity-80 transition-opacity" 8 + onclick="openVulnDetails('{{ .Digest }}', '{{ .HoldEndpoint }}')" 9 + title="Click for vulnerability details (scanned {{ .ScannedAt }})"> 10 + {{ if gt .Critical 0 }} 11 + <span class="badge badge-sm badge-error">C:{{ .Critical }}</span> 12 + {{ end }} 13 + {{ if gt .High 0 }} 14 + <span class="badge badge-sm badge-warning">H:{{ .High }}</span> 15 + {{ end }} 16 + {{ if gt .Medium 0 }} 17 + <span class="badge badge-sm badge-soft badge-warning">M:{{ .Medium }}</span> 18 + {{ end }} 19 + {{ if gt .Low 0 }} 20 + <span class="badge badge-sm badge-info">L:{{ .Low }}</span> 21 + {{ end }} 22 + </button> 23 + {{ end }} 24 + {{ end }}
+87
pkg/appview/templates/partials/vuln-details.html
···
··· 1 + {{ define "vuln-details" }} 2 + {{ if .Error }} 3 + {{ if .Summary.Total }} 4 + <!-- Summary available but no detailed report --> 5 + <div class="space-y-4"> 6 + <div class="flex flex-wrap gap-2"> 7 + {{ if gt .Summary.Critical 0 }}<span class="badge badge-error">{{ .Summary.Critical }} Critical</span>{{ end }} 8 + {{ if gt .Summary.High 0 }}<span class="badge badge-warning">{{ .Summary.High }} High</span>{{ end }} 9 + {{ if gt .Summary.Medium 0 }}<span class="badge badge-soft badge-warning">{{ .Summary.Medium }} Medium</span>{{ end }} 10 + {{ if gt .Summary.Low 0 }}<span class="badge badge-info">{{ .Summary.Low }} Low</span>{{ end }} 11 + </div> 12 + <p class="text-base-content/60 text-sm">{{ .Error }}</p> 13 + {{ if .ScannedAt }}<p class="text-base-content/40 text-xs">Scanned: {{ .ScannedAt }}</p>{{ end }} 14 + </div> 15 + {{ else }} 16 + <p class="text-base-content/60">{{ .Error }}</p> 17 + {{ end }} 18 + {{ else }} 19 + <div class="space-y-4"> 20 + <!-- Summary badges --> 21 + <div class="flex flex-wrap items-center gap-2"> 22 + <span class="font-semibold text-sm">{{ .Summary.Total }} vulnerabilities found</span> 23 + {{ if gt .Summary.Critical 0 }}<span class="badge badge-error">{{ .Summary.Critical }} Critical</span>{{ end }} 24 + {{ if gt .Summary.High 0 }}<span class="badge badge-warning">{{ .Summary.High }} High</span>{{ end }} 25 + {{ if gt .Summary.Medium 0 }}<span class="badge badge-soft badge-warning">{{ .Summary.Medium }} Medium</span>{{ end }} 26 + {{ if gt .Summary.Low 0 }}<span class="badge badge-info">{{ .Summary.Low }} Low</span>{{ end }} 27 + </div> 28 + 29 + {{ if .ScannedAt }}<p class="text-base-content/40 text-xs">Scanned: {{ .ScannedAt }}</p>{{ end }} 30 + 31 + {{ if .Matches }} 32 + <!-- CVE table --> 33 + <div class="overflow-x-auto max-h-96"> 34 + <table class="table table-sm table-pin-rows"> 35 + <thead> 36 + <tr> 37 + <th>CVE</th> 38 + <th>Severity</th> 39 + <th>Package</th> 40 + <th>Installed</th> 41 + <th>Fixed In</th> 42 + </tr> 43 + </thead> 44 + <tbody> 45 + {{ range .Matches }} 46 + <tr> 47 + <td class="font-mono text-xs"> 48 + {{ if .CVEURL }} 49 + <a href="{{ .CVEURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary">{{ .CVEID }}</a> 50 + {{ else }} 51 + {{ .CVEID }} 52 + {{ end }} 53 + </td> 54 + <td> 55 + {{ if eq .Severity "Critical" }} 56 + <span class="badge badge-sm badge-error">Critical</span> 57 + {{ else if eq .Severity "High" }} 58 + <span class="badge badge-sm badge-warning">High</span> 59 + {{ else if eq .Severity "Medium" }} 60 + <span class="badge badge-sm badge-soft badge-warning">Medium</span> 61 + {{ else if eq .Severity "Low" }} 62 + <span class="badge badge-sm badge-info">Low</span> 63 + {{ else }} 64 + <span class="badge badge-sm badge-ghost">{{ .Severity }}</span> 65 + {{ end }} 66 + </td> 67 + <td> 68 + <span class="font-mono text-xs">{{ .Package }}</span> 69 + {{ if .Type }}<span class="text-base-content/40 text-xs">({{ .Type }})</span>{{ end }} 70 + </td> 71 + <td class="font-mono text-xs">{{ .Version }}</td> 72 + <td class="font-mono text-xs"> 73 + {{ if .FixedIn }} 74 + <span class="text-success">{{ .FixedIn }}</span> 75 + {{ else }} 76 + <span class="text-base-content/40">No fix</span> 77 + {{ end }} 78 + </td> 79 + </tr> 80 + {{ end }} 81 + </tbody> 82 + </table> 83 + </div> 84 + {{ end }} 85 + </div> 86 + {{ end }} 87 + {{ end }}
+37 -1
pkg/atproto/cbor_gen.go
··· 1851 1852 cw := cbg.NewCborWriter(w) 1853 1854 - if _, err := cw.Write([]byte{172}); err != nil { 1855 return err 1856 } 1857 ··· 2118 if _, err := cw.WriteString(string(t.ScannerVersion)); err != nil { 2119 return err 2120 } 2121 return nil 2122 } 2123 ··· 2377 } 2378 2379 t.ScannerVersion = string(sval) 2380 } 2381 2382 default:
··· 1851 1852 cw := cbg.NewCborWriter(w) 1853 1854 + if _, err := cw.Write([]byte{173}); err != nil { 1855 return err 1856 } 1857 ··· 2118 if _, err := cw.WriteString(string(t.ScannerVersion)); err != nil { 2119 return err 2120 } 2121 + 2122 + // t.VulnReportBlob (util.LexBlob) (struct) 2123 + if len("vulnReportBlob") > 8192 { 2124 + return xerrors.Errorf("Value in field \"vulnReportBlob\" was too long") 2125 + } 2126 + 2127 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("vulnReportBlob"))); err != nil { 2128 + return err 2129 + } 2130 + if _, err := cw.WriteString(string("vulnReportBlob")); err != nil { 2131 + return err 2132 + } 2133 + 2134 + if err := t.VulnReportBlob.MarshalCBOR(cw); err != nil { 2135 + return err 2136 + } 2137 return nil 2138 } 2139 ··· 2393 } 2394 2395 t.ScannerVersion = string(sval) 2396 + } 2397 + // t.VulnReportBlob (util.LexBlob) (struct) 2398 + case "vulnReportBlob": 2399 + 2400 + { 2401 + 2402 + b, err := cr.ReadByte() 2403 + if err != nil { 2404 + return err 2405 + } 2406 + if b != cbg.CborNull[0] { 2407 + if err := cr.UnreadByte(); err != nil { 2408 + return err 2409 + } 2410 + t.VulnReportBlob = new(util.LexBlob) 2411 + if err := t.VulnReportBlob.UnmarshalCBOR(cr); err != nil { 2412 + return xerrors.Errorf("unmarshaling t.VulnReportBlob pointer: %w", err) 2413 + } 2414 + } 2415 + 2416 } 2417 2418 default:
+15 -12
pkg/atproto/lexicon.go
··· 801 // RKey is deterministic: based on manifest digest (one scan per manifest) 802 type ScanRecord struct { 803 Type string `json:"$type" cborgen:"$type"` 804 - Manifest string `json:"manifest" cborgen:"manifest"` // AT-URI of the scanned manifest (e.g., "at://did:plc:xyz/io.atcr.manifest/abc123...") 805 - Repository string `json:"repository" cborgen:"repository"` // Repository name (e.g., "myapp") 806 - UserDID string `json:"userDid" cborgen:"userDid"` // DID of the image owner 807 - SbomBlob *lexutil.LexBlob `json:"sbomBlob,omitempty" cborgen:"sbomBlob"` // SBOM blob uploaded to hold's PDS blob storage 808 - Critical int64 `json:"critical" cborgen:"critical"` // Count of critical vulnerabilities 809 - High int64 `json:"high" cborgen:"high"` // Count of high vulnerabilities 810 - Medium int64 `json:"medium" cborgen:"medium"` // Count of medium vulnerabilities 811 - Low int64 `json:"low" cborgen:"low"` // Count of low vulnerabilities 812 - Total int64 `json:"total" cborgen:"total"` // Total vulnerability count 813 - ScannerVersion string `json:"scannerVersion" cborgen:"scannerVersion"` // Scanner version (e.g., "atcr-scanner-v1.0.0") 814 - ScannedAt string `json:"scannedAt" cborgen:"scannedAt"` // RFC3339 timestamp of scan completion 815 } 816 817 // NewScanRecord creates a new scan record 818 // manifestDigest: the manifest digest (e.g., "sha256:abc123...") 819 // userDID: the DID of the image owner (used to build the manifest AT-URI) 820 // sbomBlob: blob reference from uploading SBOM to PDS blob storage (nil if no SBOM) 821 - func NewScanRecord(manifestDigest, repository, userDID string, sbomBlob *lexutil.LexBlob, critical, high, medium, low, total int, scannerVersion string) *ScanRecord { 822 return &ScanRecord{ 823 Type: ScanCollection, 824 Manifest: BuildManifestURI(userDID, manifestDigest), 825 Repository: repository, 826 UserDID: userDID, 827 SbomBlob: sbomBlob, 828 Critical: int64(critical), 829 High: int64(high), 830 Medium: int64(medium),
··· 801 // RKey is deterministic: based on manifest digest (one scan per manifest) 802 type ScanRecord struct { 803 Type string `json:"$type" cborgen:"$type"` 804 + Manifest string `json:"manifest" cborgen:"manifest"` // AT-URI of the scanned manifest (e.g., "at://did:plc:xyz/io.atcr.manifest/abc123...") 805 + Repository string `json:"repository" cborgen:"repository"` // Repository name (e.g., "myapp") 806 + UserDID string `json:"userDid" cborgen:"userDid"` // DID of the image owner 807 + SbomBlob *lexutil.LexBlob `json:"sbomBlob,omitempty" cborgen:"sbomBlob"` // SBOM blob uploaded to hold's PDS blob storage 808 + VulnReportBlob *lexutil.LexBlob `json:"vulnReportBlob,omitempty" cborgen:"vulnReportBlob"` // Grype vulnerability report blob (full CVE details) 809 + Critical int64 `json:"critical" cborgen:"critical"` // Count of critical vulnerabilities 810 + High int64 `json:"high" cborgen:"high"` // Count of high vulnerabilities 811 + Medium int64 `json:"medium" cborgen:"medium"` // Count of medium vulnerabilities 812 + Low int64 `json:"low" cborgen:"low"` // Count of low vulnerabilities 813 + Total int64 `json:"total" cborgen:"total"` // Total vulnerability count 814 + ScannerVersion string `json:"scannerVersion" cborgen:"scannerVersion"` // Scanner version (e.g., "atcr-scanner-v1.0.0") 815 + ScannedAt string `json:"scannedAt" cborgen:"scannedAt"` // RFC3339 timestamp of scan completion 816 } 817 818 // NewScanRecord creates a new scan record 819 // manifestDigest: the manifest digest (e.g., "sha256:abc123...") 820 // userDID: the DID of the image owner (used to build the manifest AT-URI) 821 // sbomBlob: blob reference from uploading SBOM to PDS blob storage (nil if no SBOM) 822 + // vulnReportBlob: blob reference from uploading Grype vulnerability report (nil if no report) 823 + func NewScanRecord(manifestDigest, repository, userDID string, sbomBlob, vulnReportBlob *lexutil.LexBlob, critical, high, medium, low, total int, scannerVersion string) *ScanRecord { 824 return &ScanRecord{ 825 Type: ScanCollection, 826 Manifest: BuildManifestURI(userDID, manifestDigest), 827 Repository: repository, 828 UserDID: userDID, 829 SbomBlob: sbomBlob, 830 + VulnReportBlob: vulnReportBlob, 831 Critical: int64(critical), 832 High: int64(high), 833 Medium: int64(medium),
+16 -42
pkg/hold/config.go
··· 12 "path/filepath" 13 "time" 14 15 - "github.com/distribution/distribution/v3/configuration" 16 "github.com/spf13/viper" 17 18 "atcr.io/pkg/config" ··· 64 Region string `yaml:"region" comment:"Deployment region, auto-detected from cloud metadata or S3 config."` 65 } 66 67 - // StorageConfig holds S3 storage credentials and the internal distribution config. 68 type StorageConfig struct { 69 // S3-compatible access key. 70 AccessKey string `yaml:"access_key" comment:"S3-compatible access key (AWS, Storj, Minio, UpCloud)."` ··· 83 84 // CDN pull zone URL for presigned download URLs. 85 PullZone string `yaml:"pull_zone" comment:"CDN pull zone URL for downloads. When set, presigned GET/HEAD URLs use this host instead of the S3 endpoint. Uploads and API calls still use the S3 endpoint."` 86 - 87 - // Internal distribution storage config, built from the above fields. 88 - distStorage configuration.Storage `yaml:"-"` 89 } 90 91 - // Type returns the storage driver type name (always "s3"). 92 - func (s StorageConfig) Type() string { 93 - return "s3" 94 - } 95 - 96 - // Parameters returns the distribution driver parameters. 97 - func (s StorageConfig) Parameters() configuration.Parameters { 98 - if s.distStorage != nil { 99 - if params, ok := s.distStorage["s3"]; ok { 100 - return params 101 - } 102 } 103 - return nil 104 } 105 106 // ServerConfig defines server settings ··· 276 // Store config path for subsystem config loading (e.g. billing) 277 cfg.configPath = yamlPath 278 279 - // Build distribution storage config from struct fields 280 - cfg.Storage.distStorage = buildStorageConfigFromFields(cfg.Storage) 281 - 282 // Detect region from cloud metadata or S3 config 283 if meta, err := DetectCloudMetadata(context.Background()); err == nil && meta != nil { 284 cfg.Registration.Region = meta.Region ··· 290 291 return cfg, nil 292 } 293 - 294 - // buildStorageConfigFromFields creates S3 storage configuration from StorageConfig fields. 295 - func buildStorageConfigFromFields(sc StorageConfig) configuration.Storage { 296 - params := make(map[string]any) 297 - 298 - params["accesskey"] = sc.AccessKey 299 - params["secretkey"] = sc.SecretKey 300 - params["region"] = sc.Region 301 - params["bucket"] = sc.Bucket 302 - if sc.Endpoint != "" { 303 - params["regionendpoint"] = sc.Endpoint 304 - params["forcepathstyle"] = true 305 - } 306 - if sc.PullZone != "" { 307 - params["pullzone"] = sc.PullZone 308 - } 309 - 310 - storageCfg := configuration.Storage{} 311 - storageCfg["s3"] = configuration.Parameters(params) 312 - 313 - return storageCfg 314 - }
··· 12 "path/filepath" 13 "time" 14 15 "github.com/spf13/viper" 16 17 "atcr.io/pkg/config" ··· 63 Region string `yaml:"region" comment:"Deployment region, auto-detected from cloud metadata or S3 config."` 64 } 65 66 + // StorageConfig holds S3 storage credentials. 67 type StorageConfig struct { 68 // S3-compatible access key. 69 AccessKey string `yaml:"access_key" comment:"S3-compatible access key (AWS, Storj, Minio, UpCloud)."` ··· 82 83 // CDN pull zone URL for presigned download URLs. 84 PullZone string `yaml:"pull_zone" comment:"CDN pull zone URL for downloads. When set, presigned GET/HEAD URLs use this host instead of the S3 endpoint. Uploads and API calls still use the S3 endpoint."` 85 } 86 87 + // S3Params returns a params map suitable for s3.NewS3Service. 88 + func (s StorageConfig) S3Params() map[string]any { 89 + params := map[string]any{ 90 + "accesskey": s.AccessKey, 91 + "secretkey": s.SecretKey, 92 + "region": s.Region, 93 + "bucket": s.Bucket, 94 } 95 + if s.Endpoint != "" { 96 + params["regionendpoint"] = s.Endpoint 97 + params["forcepathstyle"] = true 98 + } 99 + if s.PullZone != "" { 100 + params["pullzone"] = s.PullZone 101 + } 102 + return params 103 } 104 105 // ServerConfig defines server settings ··· 275 // Store config path for subsystem config loading (e.g. billing) 276 cfg.configPath = yamlPath 277 278 // Detect region from cloud metadata or S3 config 279 if meta, err := DetectCloudMetadata(context.Background()); err == nil && meta != nil { 280 cfg.Registration.Region = meta.Region ··· 286 287 return cfg, nil 288 }
+4 -18
pkg/hold/config_test.go
··· 187 } 188 } 189 190 - func TestBuildStorageConfigFromFields_S3_Complete(t *testing.T) { 191 sc := StorageConfig{ 192 AccessKey: "test-access-key", 193 SecretKey: "test-secret-key", ··· 196 Endpoint: "https://s3.example.com", 197 } 198 199 - cfg := buildStorageConfigFromFields(sc) 200 - 201 - s3Params, ok := cfg["s3"] 202 - if !ok { 203 - t.Fatal("Expected s3 storage config") 204 - } 205 - 206 - params := map[string]any(s3Params) 207 208 if params["accesskey"] != "test-access-key" { 209 t.Errorf("Expected accesskey=test-access-key, got %v", params["accesskey"]) ··· 222 } 223 } 224 225 - func TestBuildStorageConfigFromFields_S3_NoEndpoint(t *testing.T) { 226 sc := StorageConfig{ 227 AccessKey: "test-key", 228 SecretKey: "test-secret", ··· 231 Endpoint: "", // No custom endpoint 232 } 233 234 - cfg := buildStorageConfigFromFields(sc) 235 - 236 - s3Params, ok := cfg["s3"] 237 - if !ok { 238 - t.Fatal("Expected s3 storage config") 239 - } 240 - 241 - params := map[string]any(s3Params) 242 243 // Should have default region 244 if params["region"] != "us-east-1" {
··· 187 } 188 } 189 190 + func TestS3Params_Complete(t *testing.T) { 191 sc := StorageConfig{ 192 AccessKey: "test-access-key", 193 SecretKey: "test-secret-key", ··· 196 Endpoint: "https://s3.example.com", 197 } 198 199 + params := sc.S3Params() 200 201 if params["accesskey"] != "test-access-key" { 202 t.Errorf("Expected accesskey=test-access-key, got %v", params["accesskey"]) ··· 215 } 216 } 217 218 + func TestS3Params_NoEndpoint(t *testing.T) { 219 sc := StorageConfig{ 220 AccessKey: "test-key", 221 SecretKey: "test-secret", ··· 224 Endpoint: "", // No custom endpoint 225 } 226 227 + params := sc.S3Params() 228 229 // Should have default region 230 if params["region"] != "us-east-1" {
+15 -22
pkg/hold/gc/gc.go
··· 14 15 "atcr.io/pkg/atproto" 16 "atcr.io/pkg/hold/pds" 17 "github.com/bluesky-social/indigo/atproto/syntax" 18 - storagedriver "github.com/distribution/distribution/v3/registry/storage/driver" 19 ) 20 21 // maxPreviewItems caps per-category detail slices to prevent memory/HTML bloat ··· 64 // GarbageCollector handles cleanup of orphaned blobs from storage 65 type GarbageCollector struct { 66 pds *pds.HoldPDS 67 - driver storagedriver.StorageDriver 68 cfg Config 69 logger *slog.Logger 70 ··· 117 } 118 119 // NewGarbageCollector creates a new GC instance 120 - func NewGarbageCollector(holdPDS *pds.HoldPDS, driver storagedriver.StorageDriver, cfg Config) *GarbageCollector { 121 return &GarbageCollector{ 122 pds: holdPDS, 123 - driver: driver, 124 cfg: cfg, 125 logger: slog.Default().With("component", "gc"), 126 stopCh: make(chan struct{}), ··· 454 totalBlobs := 0 455 blobsPath := "/docker/registry/v2/blobs" 456 457 - err := gc.driver.Walk(ctx, blobsPath, func(fi storagedriver.FileInfo) error { 458 - if fi.IsDir() { 459 - return nil 460 - } 461 - if !strings.HasSuffix(fi.Path(), "/data") { 462 return nil 463 } 464 465 - digest := extractDigestFromPath(fi.Path()) 466 if digest == "" { 467 return nil 468 } ··· 473 if len(orphaned) < maxPreviewItems { 474 orphaned = append(orphaned, OrphanedBlobDetail{ 475 Digest: digest, 476 - Size: fi.Size(), 477 }) 478 } 479 } ··· 677 func (gc *GarbageCollector) deleteOrphanedBlobs(ctx context.Context, referenced map[string]bool, result *GCResult) error { 678 blobsPath := "/docker/registry/v2/blobs" 679 680 - err := gc.driver.Walk(ctx, blobsPath, func(fi storagedriver.FileInfo) error { 681 - if fi.IsDir() { 682 - return nil 683 - } 684 - 685 // Only process data files 686 - if !strings.HasSuffix(fi.Path(), "/data") { 687 return nil 688 } 689 690 // Extract digest from path 691 - digest := extractDigestFromPath(fi.Path()) 692 if digest == "" { 693 return nil 694 } ··· 700 701 result.OrphanedBlobs++ 702 703 - if err := gc.driver.Delete(ctx, fi.Path()); err != nil { 704 - gc.logger.Error("Failed to delete blob", "path", fi.Path(), "error", err) 705 return nil // Continue with other blobs 706 } 707 result.BlobsDeleted++ 708 - result.BytesReclaimed += fi.Size() 709 gc.logger.Debug("Deleted orphaned blob", 710 "digest", digest, 711 - "size", fi.Size()) 712 713 return nil 714 })
··· 14 15 "atcr.io/pkg/atproto" 16 "atcr.io/pkg/hold/pds" 17 + "atcr.io/pkg/s3" 18 "github.com/bluesky-social/indigo/atproto/syntax" 19 ) 20 21 // maxPreviewItems caps per-category detail slices to prevent memory/HTML bloat ··· 64 // GarbageCollector handles cleanup of orphaned blobs from storage 65 type GarbageCollector struct { 66 pds *pds.HoldPDS 67 + s3 *s3.S3Service 68 cfg Config 69 logger *slog.Logger 70 ··· 117 } 118 119 // NewGarbageCollector creates a new GC instance 120 + func NewGarbageCollector(holdPDS *pds.HoldPDS, s3svc *s3.S3Service, cfg Config) *GarbageCollector { 121 return &GarbageCollector{ 122 pds: holdPDS, 123 + s3: s3svc, 124 cfg: cfg, 125 logger: slog.Default().With("component", "gc"), 126 stopCh: make(chan struct{}), ··· 454 totalBlobs := 0 455 blobsPath := "/docker/registry/v2/blobs" 456 457 + err := gc.s3.WalkBlobs(ctx, blobsPath, func(key string, size int64) error { 458 + if !strings.HasSuffix(key, "/data") { 459 return nil 460 } 461 462 + digest := extractDigestFromPath(key) 463 if digest == "" { 464 return nil 465 } ··· 470 if len(orphaned) < maxPreviewItems { 471 orphaned = append(orphaned, OrphanedBlobDetail{ 472 Digest: digest, 473 + Size: size, 474 }) 475 } 476 } ··· 674 func (gc *GarbageCollector) deleteOrphanedBlobs(ctx context.Context, referenced map[string]bool, result *GCResult) error { 675 blobsPath := "/docker/registry/v2/blobs" 676 677 + err := gc.s3.WalkBlobs(ctx, blobsPath, func(key string, size int64) error { 678 // Only process data files 679 + if !strings.HasSuffix(key, "/data") { 680 return nil 681 } 682 683 // Extract digest from path 684 + digest := extractDigestFromPath(key) 685 if digest == "" { 686 return nil 687 } ··· 693 694 result.OrphanedBlobs++ 695 696 + if err := gc.s3.Delete(ctx, key); err != nil { 697 + gc.logger.Error("Failed to delete blob", "path", key, "error", err) 698 return nil // Continue with other blobs 699 } 700 result.BlobsDeleted++ 701 + result.BytesReclaimed += size 702 gc.logger.Debug("Deleted orphaned blob", 703 "digest", digest, 704 + "size", size) 705 706 return nil 707 })
+3 -4
pkg/hold/oci/multipart.go
··· 256 "source", sourcePath, 257 "dest", destPath) 258 259 - if _, err := h.driver.Stat(ctx, sourcePath); err != nil { 260 slog.Error("Source blob not found after multipart complete", 261 "path", sourcePath, 262 "error", err) ··· 264 } 265 slog.Debug("Source blob verified", "path", sourcePath) 266 267 - // Move from temp to final digest location using driver 268 - // Driver handles path management correctly (including S3 prefix) 269 - if err := h.driver.Move(ctx, sourcePath, destPath); err != nil { 270 slog.Error("Failed to move blob", 271 "source", sourcePath, 272 "dest", destPath,
··· 256 "source", sourcePath, 257 "dest", destPath) 258 259 + if _, err := h.s3Service.Stat(ctx, sourcePath); err != nil { 260 slog.Error("Source blob not found after multipart complete", 261 "path", sourcePath, 262 "error", err) ··· 264 } 265 slog.Debug("Source blob verified", "path", sourcePath) 266 267 + // Move from temp to final digest location (S3 copy + delete) 268 + if err := h.s3Service.Move(ctx, sourcePath, destPath); err != nil { 269 slog.Error("Failed to move blob", 270 "source", sourcePath, 271 "dest", destPath,
+2 -5
pkg/hold/oci/xrpc.go
··· 12 "atcr.io/pkg/hold/pds" 13 "atcr.io/pkg/hold/quota" 14 "atcr.io/pkg/s3" 15 - storagedriver "github.com/distribution/distribution/v3/registry/storage/driver" 16 "github.com/go-chi/chi/v5" 17 "github.com/go-chi/render" 18 ) 19 20 // XRPCHandler handles OCI-specific XRPC endpoints for multipart uploads 21 type XRPCHandler struct { 22 - driver storagedriver.StorageDriver 23 s3Service s3.S3Service 24 MultipartMgr *MultipartManager // Exported for access in route handlers 25 pds *pds.HoldPDS ··· 30 } 31 32 // NewXRPCHandler creates a new OCI XRPC handler 33 - func NewXRPCHandler(holdPDS *pds.HoldPDS, s3Service s3.S3Service, driver storagedriver.StorageDriver, enableBlueskyPosts bool, httpClient pds.HTTPClient, quotaMgr *quota.Manager) *XRPCHandler { 34 return &XRPCHandler{ 35 - driver: driver, 36 MultipartMgr: NewMultipartManager(), 37 s3Service: s3Service, 38 pds: holdPDS, ··· 366 367 postURI, err = h.pds.CreateManifestPost( 368 ctx, 369 - h.driver, 370 req.Repository, 371 req.Tag, 372 userHandle,
··· 12 "atcr.io/pkg/hold/pds" 13 "atcr.io/pkg/hold/quota" 14 "atcr.io/pkg/s3" 15 "github.com/go-chi/chi/v5" 16 "github.com/go-chi/render" 17 ) 18 19 // XRPCHandler handles OCI-specific XRPC endpoints for multipart uploads 20 type XRPCHandler struct { 21 s3Service s3.S3Service 22 MultipartMgr *MultipartManager // Exported for access in route handlers 23 pds *pds.HoldPDS ··· 28 } 29 30 // NewXRPCHandler creates a new OCI XRPC handler 31 + func NewXRPCHandler(holdPDS *pds.HoldPDS, s3Service s3.S3Service, enableBlueskyPosts bool, httpClient pds.HTTPClient, quotaMgr *quota.Manager) *XRPCHandler { 32 return &XRPCHandler{ 33 MultipartMgr: NewMultipartManager(), 34 s3Service: s3Service, 35 pds: holdPDS, ··· 363 364 postURI, err = h.pds.CreateManifestPost( 365 ctx, 366 + &h.s3Service, 367 req.Repository, 368 req.Tag, 369 userHandle,
+35 -214
pkg/hold/oci/xrpc_test.go
··· 2 3 import ( 4 "bytes" 5 - "context" 6 "encoding/json" 7 "fmt" 8 "io" ··· 10 "net/http/httptest" 11 "os" 12 "path/filepath" 13 - "sync" 14 "testing" 15 - "time" 16 17 "atcr.io/pkg/atproto" 18 "atcr.io/pkg/auth/oauth" 19 "atcr.io/pkg/hold/pds" 20 "atcr.io/pkg/s3" 21 - storagedriver "github.com/distribution/distribution/v3/registry/storage/driver" 22 - "github.com/distribution/distribution/v3/registry/storage/driver/factory" 23 - _ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws" 24 ) 25 26 // Shared test resources for OCI package ··· 68 }, nil 69 } 70 71 - // mockStorageDriver implements storagedriver.StorageDriver for testing 72 - type mockStorageDriver struct { 73 - mu sync.RWMutex 74 - blobs map[string][]byte 75 - 76 - // Error injection for testing error handling 77 - StatError error 78 - MoveError error 79 - } 80 - 81 - func newMockStorageDriver() *mockStorageDriver { 82 - return &mockStorageDriver{ 83 - blobs: make(map[string][]byte), 84 - } 85 - } 86 - 87 - func (m *mockStorageDriver) Name() string { return "mock" } 88 - 89 - func (m *mockStorageDriver) GetContent(ctx context.Context, path string) ([]byte, error) { 90 - m.mu.RLock() 91 - defer m.mu.RUnlock() 92 - if data, ok := m.blobs[path]; ok { 93 - return data, nil 94 - } 95 - return nil, storagedriver.PathNotFoundError{Path: path} 96 - } 97 - 98 - func (m *mockStorageDriver) PutContent(ctx context.Context, path string, content []byte) error { 99 - m.mu.Lock() 100 - defer m.mu.Unlock() 101 - m.blobs[path] = content 102 - return nil 103 - } 104 - 105 - func (m *mockStorageDriver) Reader(ctx context.Context, path string, offset int64) (io.ReadCloser, error) { 106 - data, err := m.GetContent(ctx, path) 107 - if err != nil { 108 - return nil, err 109 - } 110 - return io.NopCloser(bytes.NewReader(data[offset:])), nil 111 - } 112 - 113 - func (m *mockStorageDriver) Writer(ctx context.Context, path string, append bool) (storagedriver.FileWriter, error) { 114 - return &mockFileWriter{driver: m, path: path}, nil 115 - } 116 - 117 - func (m *mockStorageDriver) Stat(ctx context.Context, path string) (storagedriver.FileInfo, error) { 118 - m.mu.RLock() 119 - defer m.mu.RUnlock() 120 - 121 - // Check for injected error 122 - if m.StatError != nil { 123 - return nil, m.StatError 124 - } 125 - 126 - if data, ok := m.blobs[path]; ok { 127 - return &mockFileInfo{path: path, size: int64(len(data))}, nil 128 - } 129 - return nil, storagedriver.PathNotFoundError{Path: path} 130 - } 131 - 132 - func (m *mockStorageDriver) List(ctx context.Context, path string) ([]string, error) { 133 - return nil, nil 134 - } 135 - 136 - func (m *mockStorageDriver) Move(ctx context.Context, sourcePath string, destPath string) error { 137 - m.mu.Lock() 138 - defer m.mu.Unlock() 139 - 140 - // Check for injected error 141 - if m.MoveError != nil { 142 - return m.MoveError 143 - } 144 - 145 - if data, ok := m.blobs[sourcePath]; ok { 146 - m.blobs[destPath] = data 147 - delete(m.blobs, sourcePath) 148 - return nil 149 - } 150 - return storagedriver.PathNotFoundError{Path: sourcePath} 151 - } 152 - 153 - func (m *mockStorageDriver) Delete(ctx context.Context, path string) error { 154 - m.mu.Lock() 155 - defer m.mu.Unlock() 156 - delete(m.blobs, path) 157 - return nil 158 - } 159 - 160 - func (m *mockStorageDriver) RedirectURL(r *http.Request, path string) (string, error) { 161 - return "", storagedriver.ErrUnsupportedMethod{} 162 - } 163 - 164 - func (m *mockStorageDriver) Walk(ctx context.Context, path string, f storagedriver.WalkFn, options ...func(*storagedriver.WalkOptions)) error { 165 - return nil 166 - } 167 - 168 - // mockFileWriter implements storagedriver.FileWriter 169 - type mockFileWriter struct { 170 - driver *mockStorageDriver 171 - path string 172 - buf bytes.Buffer 173 - } 174 - 175 - func (w *mockFileWriter) Write(p []byte) (int, error) { 176 - return w.buf.Write(p) 177 - } 178 - 179 - func (w *mockFileWriter) Size() int64 { 180 - return int64(w.buf.Len()) 181 - } 182 - 183 - func (w *mockFileWriter) Close() error { 184 - return nil 185 - } 186 - 187 - func (w *mockFileWriter) Cancel(ctx context.Context) error { 188 - return nil 189 - } 190 - 191 - func (w *mockFileWriter) Commit(ctx context.Context) error { 192 - w.driver.mu.Lock() 193 - defer w.driver.mu.Unlock() 194 - w.driver.blobs[w.path] = w.buf.Bytes() 195 - return nil 196 - } 197 - 198 - // mockFileInfo implements storagedriver.FileInfo 199 - type mockFileInfo struct { 200 - path string 201 - size int64 202 - } 203 - 204 - func (f *mockFileInfo) Path() string { return f.path } 205 - func (f *mockFileInfo) Size() int64 { return f.size } 206 - func (f *mockFileInfo) ModTime() time.Time { return time.Time{} } 207 - func (f *mockFileInfo) IsDir() bool { return false } 208 - 209 // setupTestOCIHandlerWithMockS3 creates a test OCI XRPC handler with mock S3 210 // This does NOT require real S3 credentials - uses MockS3Client 211 - // Returns the handler, mock S3 client, and mock storage driver for test manipulation 212 - func setupTestOCIHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Client, *mockStorageDriver) { 213 t.Helper() 214 215 // Create temp directory for PDS database ··· 226 PathPrefix: "test-prefix", 227 } 228 229 - // Create mock storage driver 230 - mockDriver := newMockStorageDriver() 231 - 232 // Create minimal PDS for DID/auth 233 dbPath := ":memory:" 234 keyPath := filepath.Join(tmpDir, "signing-key") ··· 268 mockClient := &mockPDSClient{} 269 270 // Create OCI handler with mock S3 271 - handler := NewXRPCHandler(holdPDS, s3Service, mockDriver, false, mockClient, nil) 272 273 - return handler, mockS3Client, mockDriver 274 } 275 276 // setupTestOCIHandlerWithS3 creates a test OCI XRPC handler with S3 driver ··· 307 } 308 s3Params["rootdirectory"] = storageDir 309 310 - driver, err := factory.Create(ctx, "s3", s3Params) 311 - if err != nil { 312 - t.Logf("Failed to create S3 storage driver: %v", err) 313 - return nil, false 314 - } 315 - 316 // Create S3 service 317 s3Service, err := s3.NewS3Service(s3Params) 318 if err != nil { ··· 359 mockClient := &mockPDSClient{} 360 361 // Create OCI handler with S3 362 - handler := NewXRPCHandler(holdPDS, *s3Service, driver, false, mockClient, nil) 363 364 return handler, true 365 } ··· 392 // Tests for HandleInitiateUpload - Mock S3 (no credentials required) 393 394 func TestHandleInitiateUpload_MockS3_Success(t *testing.T) { 395 - handler, mockS3Client, _ := setupTestOCIHandlerWithMockS3(t) 396 397 req := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{ 398 "digest": "sha256:abc123", ··· 421 } 422 423 func TestHandleInitiateUpload_MockS3_MissingDigest(t *testing.T) { 424 - handler, _, _ := setupTestOCIHandlerWithMockS3(t) 425 426 req := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{}) 427 addMockAuth(req) ··· 437 // Tests for full Mock S3 upload flow (no credentials required) 438 439 func TestFullMockS3UploadFlow(t *testing.T) { 440 - handler, mockS3Client, _ := setupTestOCIHandlerWithMockS3(t) 441 442 // 1. Initiate upload 443 initReq := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{ ··· 506 } 507 508 func TestHandleGetPartUploadUrl_MockS3_InvalidSession(t *testing.T) { 509 - handler, _, _ := setupTestOCIHandlerWithMockS3(t) 510 511 req := makeJSONRequest("POST", atproto.HoldGetPartUploadURL, map[string]any{ 512 "uploadId": "invalid-upload-id", ··· 523 } 524 525 func TestHandleAbortUpload_MockS3_InvalidSession(t *testing.T) { 526 - handler, _, _ := setupTestOCIHandlerWithMockS3(t) 527 528 req := makeJSONRequest("POST", atproto.HoldAbortUpload, map[string]string{ 529 "uploadId": "invalid-upload-id", ··· 764 // Tests for HandleCompleteUpload with Mock S3 765 766 func TestHandleCompleteUpload_MockS3_Success(t *testing.T) { 767 - handler, mockS3Client, mockDriver := setupTestOCIHandlerWithMockS3(t) 768 769 // 1. Initiate upload with temp path 770 tempDigest := "uploads/temp-test-complete" ··· 796 t.Fatalf("Expected status 200 for part URL, got %d: %s", partW.Code, partW.Body.String()) 797 } 798 799 - // 3. Pre-populate mock storage driver with temp blob (simulates S3 upload completing) 800 - // Path format: /docker/registry/v2/uploads/temp-{id}/data 801 - tempBlobPath := fmt.Sprintf("/docker/registry/v2/%s/data", tempDigest) 802 - mockDriver.PutContent(t.Context(), tempBlobPath, []byte("test blob content")) 803 - 804 - // 4. Complete upload with parts 805 finalDigest := "sha256:abc123def456" 806 completeReq := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{ 807 "uploadId": uploadID, ··· 833 t.Errorf("Expected 1 Complete call, got %d", len(mockS3Client.CompleteCalls)) 834 } 835 836 - // 6. Verify blob was moved to final location 837 - // Final path format: /docker/registry/v2/blobs/sha256/ab/abc123def456/data 838 - finalBlobPath := "/docker/registry/v2/blobs/sha256/ab/abc123def456/data" 839 - _, err := mockDriver.Stat(t.Context(), finalBlobPath) 840 - if err != nil { 841 - t.Errorf("Expected blob at final location %s, got error: %v", finalBlobPath, err) 842 } 843 } 844 845 func TestHandleCompleteUpload_MockS3_InvalidSession(t *testing.T) { 846 - handler, _, _ := setupTestOCIHandlerWithMockS3(t) 847 848 req := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{ 849 "uploadId": "non-existent-upload-id", ··· 863 } 864 865 func TestHandleCompleteUpload_MockS3_MissingParams(t *testing.T) { 866 - handler, _, _ := setupTestOCIHandlerWithMockS3(t) 867 868 tests := []struct { 869 name string ··· 891 } 892 893 func TestHandleCompleteUpload_MockS3_ETagNormalization(t *testing.T) { 894 - handler, mockS3Client, mockDriver := setupTestOCIHandlerWithMockS3(t) 895 896 // Setup upload session 897 tempDigest := "uploads/temp-etag-test" ··· 905 var initResp map[string]any 906 decodeJSONResponse(t, initW, &initResp) 907 uploadID := initResp["uploadId"].(string) 908 - 909 - // Pre-populate mock driver 910 - tempBlobPath := fmt.Sprintf("/docker/registry/v2/%s/data", tempDigest) 911 - mockDriver.PutContent(t.Context(), tempBlobPath, []byte("test")) 912 913 // Complete with unquoted ETags 914 completeReq := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{ ··· 938 } 939 940 func TestHandleCompleteUpload_MockS3_S3Error(t *testing.T) { 941 - handler, mockS3Client, mockDriver := setupTestOCIHandlerWithMockS3(t) 942 943 // Setup upload session 944 tempDigest := "uploads/temp-s3-error" ··· 952 var initResp map[string]any 953 decodeJSONResponse(t, initW, &initResp) 954 uploadID := initResp["uploadId"].(string) 955 - 956 - // Pre-populate mock driver 957 - tempBlobPath := fmt.Sprintf("/docker/registry/v2/%s/data", tempDigest) 958 - mockDriver.PutContent(t.Context(), tempBlobPath, []byte("test")) 959 960 // Inject S3 error 961 mockS3Client.CompleteError = fmt.Errorf("simulated S3 CompleteMultipartUpload failure") ··· 983 } 984 985 func TestHandleCompleteUpload_MockS3_StatError(t *testing.T) { 986 - handler, _, mockDriver := setupTestOCIHandlerWithMockS3(t) 987 988 // Setup upload session 989 tempDigest := "uploads/temp-stat-error" ··· 998 decodeJSONResponse(t, initW, &initResp) 999 uploadID := initResp["uploadId"].(string) 1000 1001 - // Pre-populate mock driver (so S3 complete succeeds) 1002 - tempBlobPath := fmt.Sprintf("/docker/registry/v2/%s/data", tempDigest) 1003 - mockDriver.PutContent(t.Context(), tempBlobPath, []byte("test")) 1004 - 1005 - // Inject Stat error (simulates blob not found after S3 complete) 1006 - mockDriver.StatError = fmt.Errorf("simulated stat failure") 1007 1008 // Complete upload should fail 1009 completeReq := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{ ··· 1023 } 1024 1025 func TestHandleCompleteUpload_MockS3_MoveError(t *testing.T) { 1026 - handler, _, mockDriver := setupTestOCIHandlerWithMockS3(t) 1027 1028 // Setup upload session 1029 tempDigest := "uploads/temp-move-error" ··· 1038 decodeJSONResponse(t, initW, &initResp) 1039 uploadID := initResp["uploadId"].(string) 1040 1041 - // Pre-populate mock driver 1042 - tempBlobPath := fmt.Sprintf("/docker/registry/v2/%s/data", tempDigest) 1043 - mockDriver.PutContent(t.Context(), tempBlobPath, []byte("test")) 1044 - 1045 - // Inject Move error 1046 - mockDriver.MoveError = fmt.Errorf("simulated move failure") 1047 1048 // Complete upload should fail 1049 completeReq := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{ ··· 1068 } 1069 1070 func TestHandleCompleteUpload_MockS3_UnsortedParts(t *testing.T) { 1071 - handler, mockS3Client, mockDriver := setupTestOCIHandlerWithMockS3(t) 1072 1073 // Setup upload session 1074 tempDigest := "uploads/temp-unsorted" ··· 1083 decodeJSONResponse(t, initW, &initResp) 1084 uploadID := initResp["uploadId"].(string) 1085 1086 - // Pre-populate mock driver 1087 - tempBlobPath := fmt.Sprintf("/docker/registry/v2/%s/data", tempDigest) 1088 - mockDriver.PutContent(t.Context(), tempBlobPath, []byte("test")) 1089 - 1090 // Complete with unsorted parts (3, 1, 2) - handler should sort them 1091 completeReq := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{ 1092 "uploadId": uploadID, ··· 1117 // Tests for HandleInitiateUpload edge cases 1118 1119 func TestHandleInitiateUpload_MockS3_S3Error(t *testing.T) { 1120 - handler, mockS3Client, _ := setupTestOCIHandlerWithMockS3(t) 1121 1122 // Inject S3 error 1123 mockS3Client.CreateMultipartError = fmt.Errorf("simulated S3 CreateMultipartUpload failure") ··· 1141 } 1142 1143 func TestHandleInitiateUpload_MockS3_WhitespaceDigest(t *testing.T) { 1144 - handler, _, _ := setupTestOCIHandlerWithMockS3(t) 1145 1146 req := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{ 1147 "digest": " ", // Whitespace only ··· 1162 // Tests for HandleGetPartUploadURL edge cases 1163 1164 func TestHandleGetPartUploadUrl_MockS3_MissingParams(t *testing.T) { 1165 - handler, _, _ := setupTestOCIHandlerWithMockS3(t) 1166 1167 tests := []struct { 1168 name string ··· 1189 } 1190 1191 func TestHandleGetPartUploadUrl_MockS3_ValidSession(t *testing.T) { 1192 - handler, mockS3Client, _ := setupTestOCIHandlerWithMockS3(t) 1193 1194 // First initiate an upload 1195 initReq := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{ ··· 1237 // Tests for HandleAbortUpload edge cases 1238 1239 func TestHandleAbortUpload_MockS3_MissingUploadId(t *testing.T) { 1240 - handler, _, _ := setupTestOCIHandlerWithMockS3(t) 1241 1242 req := makeJSONRequest("POST", atproto.HoldAbortUpload, map[string]string{}) 1243 addMockAuth(req) ··· 1251 } 1252 1253 func TestHandleAbortUpload_MockS3_S3Error(t *testing.T) { 1254 - handler, mockS3Client, _ := setupTestOCIHandlerWithMockS3(t) 1255 1256 // First initiate an upload 1257 initReq := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{ ··· 1288 } 1289 1290 func TestHandleAbortUpload_MockS3_ValidSession(t *testing.T) { 1291 - handler, mockS3Client, _ := setupTestOCIHandlerWithMockS3(t) 1292 1293 // First initiate an upload 1294 initReq := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{
··· 2 3 import ( 4 "bytes" 5 "encoding/json" 6 "fmt" 7 "io" ··· 9 "net/http/httptest" 10 "os" 11 "path/filepath" 12 "testing" 13 14 "atcr.io/pkg/atproto" 15 "atcr.io/pkg/auth/oauth" 16 "atcr.io/pkg/hold/pds" 17 "atcr.io/pkg/s3" 18 ) 19 20 // Shared test resources for OCI package ··· 62 }, nil 63 } 64 65 // setupTestOCIHandlerWithMockS3 creates a test OCI XRPC handler with mock S3 66 // This does NOT require real S3 credentials - uses MockS3Client 67 + // Returns the handler and mock S3 client for test manipulation 68 + func setupTestOCIHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Client) { 69 t.Helper() 70 71 // Create temp directory for PDS database ··· 82 PathPrefix: "test-prefix", 83 } 84 85 // Create minimal PDS for DID/auth 86 dbPath := ":memory:" 87 keyPath := filepath.Join(tmpDir, "signing-key") ··· 121 mockClient := &mockPDSClient{} 122 123 // Create OCI handler with mock S3 124 + handler := NewXRPCHandler(holdPDS, s3Service, false, mockClient, nil) 125 126 + return handler, mockS3Client 127 } 128 129 // setupTestOCIHandlerWithS3 creates a test OCI XRPC handler with S3 driver ··· 160 } 161 s3Params["rootdirectory"] = storageDir 162 163 // Create S3 service 164 s3Service, err := s3.NewS3Service(s3Params) 165 if err != nil { ··· 206 mockClient := &mockPDSClient{} 207 208 // Create OCI handler with S3 209 + handler := NewXRPCHandler(holdPDS, *s3Service, false, mockClient, nil) 210 211 return handler, true 212 } ··· 239 // Tests for HandleInitiateUpload - Mock S3 (no credentials required) 240 241 func TestHandleInitiateUpload_MockS3_Success(t *testing.T) { 242 + handler, mockS3Client := setupTestOCIHandlerWithMockS3(t) 243 244 req := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{ 245 "digest": "sha256:abc123", ··· 268 } 269 270 func TestHandleInitiateUpload_MockS3_MissingDigest(t *testing.T) { 271 + handler, _ := setupTestOCIHandlerWithMockS3(t) 272 273 req := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{}) 274 addMockAuth(req) ··· 284 // Tests for full Mock S3 upload flow (no credentials required) 285 286 func TestFullMockS3UploadFlow(t *testing.T) { 287 + handler, mockS3Client := setupTestOCIHandlerWithMockS3(t) 288 289 // 1. Initiate upload 290 initReq := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{ ··· 353 } 354 355 func TestHandleGetPartUploadUrl_MockS3_InvalidSession(t *testing.T) { 356 + handler, _ := setupTestOCIHandlerWithMockS3(t) 357 358 req := makeJSONRequest("POST", atproto.HoldGetPartUploadURL, map[string]any{ 359 "uploadId": "invalid-upload-id", ··· 370 } 371 372 func TestHandleAbortUpload_MockS3_InvalidSession(t *testing.T) { 373 + handler, _ := setupTestOCIHandlerWithMockS3(t) 374 375 req := makeJSONRequest("POST", atproto.HoldAbortUpload, map[string]string{ 376 "uploadId": "invalid-upload-id", ··· 611 // Tests for HandleCompleteUpload with Mock S3 612 613 func TestHandleCompleteUpload_MockS3_Success(t *testing.T) { 614 + handler, mockS3Client := setupTestOCIHandlerWithMockS3(t) 615 616 // 1. Initiate upload with temp path 617 tempDigest := "uploads/temp-test-complete" ··· 643 t.Fatalf("Expected status 200 for part URL, got %d: %s", partW.Code, partW.Body.String()) 644 } 645 646 + // 3. Complete upload with parts 647 + // (Mock S3 CompleteMultipartUpload auto-populates object for Stat/Move) 648 finalDigest := "sha256:abc123def456" 649 completeReq := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{ 650 "uploadId": uploadID, ··· 676 t.Errorf("Expected 1 Complete call, got %d", len(mockS3Client.CompleteCalls)) 677 } 678 679 + // 6. Verify blob was moved to final location in mock S3 680 + finalS3Key := "test-prefix/docker/registry/v2/blobs/sha256/ab/abc123def456/data" 681 + if mockS3Client.GetObject(finalS3Key) == nil { 682 + t.Errorf("Expected blob at final S3 key %s", finalS3Key) 683 } 684 } 685 686 func TestHandleCompleteUpload_MockS3_InvalidSession(t *testing.T) { 687 + handler, _ := setupTestOCIHandlerWithMockS3(t) 688 689 req := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{ 690 "uploadId": "non-existent-upload-id", ··· 704 } 705 706 func TestHandleCompleteUpload_MockS3_MissingParams(t *testing.T) { 707 + handler, _ := setupTestOCIHandlerWithMockS3(t) 708 709 tests := []struct { 710 name string ··· 732 } 733 734 func TestHandleCompleteUpload_MockS3_ETagNormalization(t *testing.T) { 735 + handler, mockS3Client := setupTestOCIHandlerWithMockS3(t) 736 737 // Setup upload session 738 tempDigest := "uploads/temp-etag-test" ··· 746 var initResp map[string]any 747 decodeJSONResponse(t, initW, &initResp) 748 uploadID := initResp["uploadId"].(string) 749 750 // Complete with unquoted ETags 751 completeReq := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{ ··· 775 } 776 777 func TestHandleCompleteUpload_MockS3_S3Error(t *testing.T) { 778 + handler, mockS3Client := setupTestOCIHandlerWithMockS3(t) 779 780 // Setup upload session 781 tempDigest := "uploads/temp-s3-error" ··· 789 var initResp map[string]any 790 decodeJSONResponse(t, initW, &initResp) 791 uploadID := initResp["uploadId"].(string) 792 793 // Inject S3 error 794 mockS3Client.CompleteError = fmt.Errorf("simulated S3 CompleteMultipartUpload failure") ··· 816 } 817 818 func TestHandleCompleteUpload_MockS3_StatError(t *testing.T) { 819 + handler, mockS3Client := setupTestOCIHandlerWithMockS3(t) 820 821 // Setup upload session 822 tempDigest := "uploads/temp-stat-error" ··· 831 decodeJSONResponse(t, initW, &initResp) 832 uploadID := initResp["uploadId"].(string) 833 834 + // Inject HeadObject error (simulates blob not found after S3 complete) 835 + mockS3Client.HeadObjectError = fmt.Errorf("simulated stat failure") 836 837 // Complete upload should fail 838 completeReq := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{ ··· 852 } 853 854 func TestHandleCompleteUpload_MockS3_MoveError(t *testing.T) { 855 + handler, mockS3Client := setupTestOCIHandlerWithMockS3(t) 856 857 // Setup upload session 858 tempDigest := "uploads/temp-move-error" ··· 867 decodeJSONResponse(t, initW, &initResp) 868 uploadID := initResp["uploadId"].(string) 869 870 + // Inject CopyObject error (Move = Copy + Delete, so Copy error simulates move failure) 871 + mockS3Client.CopyObjectError = fmt.Errorf("simulated move failure") 872 873 // Complete upload should fail 874 completeReq := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{ ··· 893 } 894 895 func TestHandleCompleteUpload_MockS3_UnsortedParts(t *testing.T) { 896 + handler, mockS3Client := setupTestOCIHandlerWithMockS3(t) 897 898 // Setup upload session 899 tempDigest := "uploads/temp-unsorted" ··· 908 decodeJSONResponse(t, initW, &initResp) 909 uploadID := initResp["uploadId"].(string) 910 911 // Complete with unsorted parts (3, 1, 2) - handler should sort them 912 completeReq := makeJSONRequest("POST", atproto.HoldCompleteUpload, map[string]any{ 913 "uploadId": uploadID, ··· 938 // Tests for HandleInitiateUpload edge cases 939 940 func TestHandleInitiateUpload_MockS3_S3Error(t *testing.T) { 941 + handler, mockS3Client := setupTestOCIHandlerWithMockS3(t) 942 943 // Inject S3 error 944 mockS3Client.CreateMultipartError = fmt.Errorf("simulated S3 CreateMultipartUpload failure") ··· 962 } 963 964 func TestHandleInitiateUpload_MockS3_WhitespaceDigest(t *testing.T) { 965 + handler, _ := setupTestOCIHandlerWithMockS3(t) 966 967 req := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{ 968 "digest": " ", // Whitespace only ··· 983 // Tests for HandleGetPartUploadURL edge cases 984 985 func TestHandleGetPartUploadUrl_MockS3_MissingParams(t *testing.T) { 986 + handler, _ := setupTestOCIHandlerWithMockS3(t) 987 988 tests := []struct { 989 name string ··· 1010 } 1011 1012 func TestHandleGetPartUploadUrl_MockS3_ValidSession(t *testing.T) { 1013 + handler, mockS3Client := setupTestOCIHandlerWithMockS3(t) 1014 1015 // First initiate an upload 1016 initReq := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{ ··· 1058 // Tests for HandleAbortUpload edge cases 1059 1060 func TestHandleAbortUpload_MockS3_MissingUploadId(t *testing.T) { 1061 + handler, _ := setupTestOCIHandlerWithMockS3(t) 1062 1063 req := makeJSONRequest("POST", atproto.HoldAbortUpload, map[string]string{}) 1064 addMockAuth(req) ··· 1072 } 1073 1074 func TestHandleAbortUpload_MockS3_S3Error(t *testing.T) { 1075 + handler, mockS3Client := setupTestOCIHandlerWithMockS3(t) 1076 1077 // First initiate an upload 1078 initReq := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{ ··· 1109 } 1110 1111 func TestHandleAbortUpload_MockS3_ValidSession(t *testing.T) { 1112 + handler, mockS3Client := setupTestOCIHandlerWithMockS3(t) 1113 1114 // First initiate an upload 1115 initReq := makeJSONRequest("POST", atproto.HoldInitiateUpload, map[string]string{
+3 -3
pkg/hold/pds/manifest_post.go
··· 10 "time" 11 12 "atcr.io/pkg/atproto" 13 bsky "github.com/bluesky-social/indigo/api/bsky" 14 - "github.com/distribution/distribution/v3/registry/storage/driver" 15 ) 16 17 // CreateManifestPost creates a Bluesky post announcing a manifest upload ··· 19 // artifactType is "container-image", "helm-chart", or "unknown" 20 func (p *HoldPDS) CreateManifestPost( 21 ctx context.Context, 22 - storageDriver driver.StorageDriver, 23 repository, tag, userHandle, userDID, digest string, 24 totalSize int64, 25 platforms []string, ··· 50 slog.Warn("Failed to fetch OG image, posting without embed", "error", err) 51 } else { 52 // Upload OG image as blob 53 - thumbBlob, err := uploadBlobToStorage(ctx, storageDriver, p.did, ogImageData, "image/png") 54 if err != nil { 55 slog.Warn("Failed to upload OG image blob", "error", err) 56 } else {
··· 10 "time" 11 12 "atcr.io/pkg/atproto" 13 + "atcr.io/pkg/s3" 14 bsky "github.com/bluesky-social/indigo/api/bsky" 15 ) 16 17 // CreateManifestPost creates a Bluesky post announcing a manifest upload ··· 19 // artifactType is "container-image", "helm-chart", or "unknown" 20 func (p *HoldPDS) CreateManifestPost( 21 ctx context.Context, 22 + s3svc *s3.S3Service, 23 repository, tag, userHandle, userDID, digest string, 24 totalSize int64, 25 platforms []string, ··· 50 slog.Warn("Failed to fetch OG image, posting without embed", "error", err) 51 } else { 52 // Upload OG image as blob 53 + thumbBlob, err := uploadBlobToStorage(ctx, s3svc, p.did, ogImageData, "image/png") 54 if err != nil { 55 slog.Warn("Failed to upload OG image blob", "error", err) 56 } else {
+9 -29
pkg/hold/pds/profile.go
··· 1 package pds 2 3 import ( 4 - "bytes" 5 "context" 6 "crypto/sha256" 7 "fmt" ··· 11 "time" 12 13 "atcr.io/pkg/atproto" 14 bsky "github.com/bluesky-social/indigo/api/bsky" 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 - "github.com/distribution/distribution/v3/registry/storage/driver" 17 "github.com/ipfs/go-cid" 18 "github.com/multiformats/go-multihash" 19 ) ··· 68 return data, contentType, nil 69 } 70 71 - // uploadBlobToStorage uploads a blob to the hold's storage and returns a blob reference 72 - // This stores the blob at the ATProto path for the hold's DID 73 - func uploadBlobToStorage(ctx context.Context, storageDriver driver.StorageDriver, did string, data []byte, mimeType string) (*lexutil.LexBlob, error) { 74 if len(data) == 0 { 75 return nil, fmt.Errorf("empty blob data") 76 } ··· 90 // ATProto uses CIDv1 with raw codec for blobs 91 blobCID := cid.NewCidV1(0x55, mh) 92 93 - // Store blob via distribution driver at ATProto path 94 path := atprotoBlobPath(did, blobCID.String()) 95 96 - // Write blob to storage using distribution driver 97 - writer, err := storageDriver.Writer(ctx, path, false) 98 - if err != nil { 99 - return nil, fmt.Errorf("failed to create writer: %w", err) 100 - } 101 - 102 - // Write data 103 - n, err := io.Copy(writer, bytes.NewReader(data)) 104 - if err != nil { 105 - writer.Cancel(ctx) 106 - return nil, fmt.Errorf("failed to write blob: %w", err) 107 - } 108 - 109 - // Commit the write 110 - if err := writer.Commit(ctx); err != nil { 111 - return nil, fmt.Errorf("failed to commit blob: %w", err) 112 - } 113 - 114 - if n != size { 115 - return nil, fmt.Errorf("size mismatch: wrote %d bytes, expected %d", n, size) 116 } 117 118 // Create blob reference in the format expected by bsky.ActorProfile 119 - // LexLink is a type alias for cid.Cid 120 lexLink := lexutil.LexLink(blobCID) 121 blob := &lexutil.LexBlob{ 122 Ref: lexLink, ··· 129 130 // CreateProfileRecord creates the app.bsky.actor.profile record for the hold 131 // This will FAIL if the profile record already exists. 132 - func (p *HoldPDS) CreateProfileRecord(ctx context.Context, storageDriver driver.StorageDriver, displayName, description, avatarURL string) (cid.Cid, error) { 133 // Create profile struct 134 profile := &bsky.ActorProfile{ 135 DisplayName: &displayName, ··· 147 slog.Debug("Uploading avatar blob", 148 "size", len(imageData), 149 "mimeType", mimeType) 150 - avatarBlob, err := uploadBlobToStorage(ctx, storageDriver, p.did, imageData, mimeType) 151 if err != nil { 152 return cid.Undef, fmt.Errorf("failed to upload avatar blob: %w", err) 153 }
··· 1 package pds 2 3 import ( 4 "context" 5 "crypto/sha256" 6 "fmt" ··· 10 "time" 11 12 "atcr.io/pkg/atproto" 13 + "atcr.io/pkg/s3" 14 bsky "github.com/bluesky-social/indigo/api/bsky" 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 "github.com/ipfs/go-cid" 17 "github.com/multiformats/go-multihash" 18 ) ··· 67 return data, contentType, nil 68 } 69 70 + // uploadBlobToStorage uploads a blob to the hold's S3 storage and returns a blob reference. 71 + // This stores the blob at the ATProto path for the hold's DID. 72 + func uploadBlobToStorage(ctx context.Context, s3svc *s3.S3Service, did string, data []byte, mimeType string) (*lexutil.LexBlob, error) { 73 if len(data) == 0 { 74 return nil, fmt.Errorf("empty blob data") 75 } ··· 89 // ATProto uses CIDv1 with raw codec for blobs 90 blobCID := cid.NewCidV1(0x55, mh) 91 92 + // Store blob via S3 at ATProto path 93 path := atprotoBlobPath(did, blobCID.String()) 94 95 + if err := s3svc.PutBytes(ctx, path, data, mimeType); err != nil { 96 + return nil, fmt.Errorf("failed to put blob: %w", err) 97 } 98 99 // Create blob reference in the format expected by bsky.ActorProfile 100 lexLink := lexutil.LexLink(blobCID) 101 blob := &lexutil.LexBlob{ 102 Ref: lexLink, ··· 109 110 // CreateProfileRecord creates the app.bsky.actor.profile record for the hold 111 // This will FAIL if the profile record already exists. 112 + func (p *HoldPDS) CreateProfileRecord(ctx context.Context, s3svc *s3.S3Service, displayName, description, avatarURL string) (cid.Cid, error) { 113 // Create profile struct 114 profile := &bsky.ActorProfile{ 115 DisplayName: &displayName, ··· 127 slog.Debug("Uploading avatar blob", 128 "size", len(imageData), 129 "mimeType", mimeType) 130 + avatarBlob, err := uploadBlobToStorage(ctx, s3svc, p.did, imageData, mimeType) 131 if err != nil { 132 return cid.Undef, fmt.Errorf("failed to upload avatar blob: %w", err) 133 }
+21 -8
pkg/hold/pds/scan_broadcaster.go
··· 13 "time" 14 15 "atcr.io/pkg/atproto" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 - storagedriver "github.com/distribution/distribution/v3/registry/storage/driver" 18 "github.com/gorilla/websocket" 19 ) 20 ··· 28 db *sql.DB 29 holdDID string 30 holdEndpoint string 31 - driver storagedriver.StorageDriver 32 pds *HoldPDS 33 ackTimeout time.Duration 34 secret string // Shared secret for scanner authentication ··· 80 81 // NewScanBroadcaster creates a new scan job broadcaster 82 // dbPath should point to a SQLite database file (e.g., "/path/to/pds/db.sqlite3") 83 - func NewScanBroadcaster(holdDID, holdEndpoint, secret, dbPath string, driver storagedriver.StorageDriver, holdPDS *HoldPDS) (*ScanBroadcaster, error) { 84 dsn := dbPath 85 if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") { 86 dsn = "file:" + dbPath ··· 99 db: db, 100 holdDID: holdDID, 101 holdEndpoint: holdEndpoint, 102 - driver: driver, 103 pds: holdPDS, 104 ackTimeout: 5 * time.Minute, 105 secret: secret, ··· 119 120 // NewScanBroadcasterWithDB creates a scan job broadcaster using an existing *sql.DB connection. 121 // The caller is responsible for the DB lifecycle. 122 - func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret string, db *sql.DB, driver storagedriver.StorageDriver, holdPDS *HoldPDS) (*ScanBroadcaster, error) { 123 sb := &ScanBroadcaster{ 124 subscribers: make([]*ScanSubscriber, 0), 125 db: db, 126 holdDID: holdDID, 127 holdEndpoint: holdEndpoint, 128 - driver: driver, 129 pds: holdPDS, 130 ackTimeout: 5 * time.Minute, 131 secret: secret, ··· 424 // Upload SBOM as a blob to the hold's PDS blob storage (like manifest blobs) 425 var sbomBlob *lexutil.LexBlob 426 if msg.SBOM != "" { 427 - blob, err := uploadBlobToStorage(ctx, sb.driver, sb.holdDID, []byte(msg.SBOM), "application/spdx+json") 428 if err != nil { 429 slog.Error("Failed to upload SBOM blob to PDS storage", 430 "seq", msg.Seq, ··· 434 } 435 } 436 437 // Store scan result as a record in the hold's embedded PDS 438 if msg.Summary != nil { 439 scanRecord := atproto.NewScanRecord( 440 manifestDigest, repository, userDID, 441 - sbomBlob, 442 msg.Summary.Critical, msg.Summary.High, msg.Summary.Medium, msg.Summary.Low, msg.Summary.Total, 443 "atcr-scanner-v1.0.0", 444 )
··· 13 "time" 14 15 "atcr.io/pkg/atproto" 16 + "atcr.io/pkg/s3" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 "github.com/gorilla/websocket" 19 ) 20 ··· 28 db *sql.DB 29 holdDID string 30 holdEndpoint string 31 + s3 *s3.S3Service 32 pds *HoldPDS 33 ackTimeout time.Duration 34 secret string // Shared secret for scanner authentication ··· 80 81 // NewScanBroadcaster creates a new scan job broadcaster 82 // dbPath should point to a SQLite database file (e.g., "/path/to/pds/db.sqlite3") 83 + func NewScanBroadcaster(holdDID, holdEndpoint, secret, dbPath string, s3svc *s3.S3Service, holdPDS *HoldPDS) (*ScanBroadcaster, error) { 84 dsn := dbPath 85 if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") { 86 dsn = "file:" + dbPath ··· 99 db: db, 100 holdDID: holdDID, 101 holdEndpoint: holdEndpoint, 102 + s3: s3svc, 103 pds: holdPDS, 104 ackTimeout: 5 * time.Minute, 105 secret: secret, ··· 119 120 // NewScanBroadcasterWithDB creates a scan job broadcaster using an existing *sql.DB connection. 121 // The caller is responsible for the DB lifecycle. 122 + func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret string, db *sql.DB, s3svc *s3.S3Service, holdPDS *HoldPDS) (*ScanBroadcaster, error) { 123 sb := &ScanBroadcaster{ 124 subscribers: make([]*ScanSubscriber, 0), 125 db: db, 126 holdDID: holdDID, 127 holdEndpoint: holdEndpoint, 128 + s3: s3svc, 129 pds: holdPDS, 130 ackTimeout: 5 * time.Minute, 131 secret: secret, ··· 424 // Upload SBOM as a blob to the hold's PDS blob storage (like manifest blobs) 425 var sbomBlob *lexutil.LexBlob 426 if msg.SBOM != "" { 427 + blob, err := uploadBlobToStorage(ctx, sb.s3, sb.holdDID, []byte(msg.SBOM), "application/spdx+json") 428 if err != nil { 429 slog.Error("Failed to upload SBOM blob to PDS storage", 430 "seq", msg.Seq, ··· 434 } 435 } 436 437 + // Upload vulnerability report as a blob (full Grype JSON with CVE details) 438 + var vulnReportBlob *lexutil.LexBlob 439 + if msg.VulnReport != "" { 440 + blob, err := uploadBlobToStorage(ctx, sb.s3, sb.holdDID, []byte(msg.VulnReport), "application/vnd.atcr.vulnerabilities+json") 441 + if err != nil { 442 + slog.Error("Failed to upload VulnReport blob to PDS storage", 443 + "seq", msg.Seq, 444 + "error", err) 445 + } else { 446 + vulnReportBlob = blob 447 + } 448 + } 449 + 450 // Store scan result as a record in the hold's embedded PDS 451 if msg.Summary != nil { 452 scanRecord := atproto.NewScanRecord( 453 manifestDigest, repository, userDID, 454 + sbomBlob, vulnReportBlob, 455 msg.Summary.Critical, msg.Summary.High, msg.Summary.Medium, msg.Summary.Low, msg.Summary.Total, 456 "atcr-scanner-v1.0.0", 457 )
+5 -5
pkg/hold/pds/server.go
··· 13 "atcr.io/pkg/atproto" 14 "atcr.io/pkg/auth/oauth" 15 holddb "atcr.io/pkg/hold/db" 16 "github.com/bluesky-social/indigo/atproto/atcrypto" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 "github.com/bluesky-social/indigo/models" 19 "github.com/bluesky-social/indigo/repo" 20 - "github.com/distribution/distribution/v3/registry/storage/driver" 21 "github.com/ipfs/go-cid" 22 ) 23 ··· 231 } 232 233 // Bootstrap initializes the hold with the captain record, owner as first crew member, and profile 234 - func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDriver, ownerDID string, public bool, allowAllCrew bool, avatarURL, region string) error { 235 if ownerDID == "" { 236 return nil 237 } ··· 317 318 // Create Bluesky profile record (idempotent - check if exists first) 319 // This runs even if captain exists (for existing holds being upgraded) 320 - // Skip if no storage driver (e.g., in tests) 321 - if storageDriver != nil { 322 _, _, err = p.GetProfileRecord(ctx) 323 if err != nil { 324 // Bluesky profile doesn't exist, create it 325 displayName := "Cargo Hold" 326 description := "ahoy from the cargo hold" 327 328 - _, err = p.CreateProfileRecord(ctx, storageDriver, displayName, description, avatarURL) 329 if err != nil { 330 return fmt.Errorf("failed to create bluesky profile record: %w", err) 331 }
··· 13 "atcr.io/pkg/atproto" 14 "atcr.io/pkg/auth/oauth" 15 holddb "atcr.io/pkg/hold/db" 16 + "atcr.io/pkg/s3" 17 "github.com/bluesky-social/indigo/atproto/atcrypto" 18 lexutil "github.com/bluesky-social/indigo/lex/util" 19 "github.com/bluesky-social/indigo/models" 20 "github.com/bluesky-social/indigo/repo" 21 "github.com/ipfs/go-cid" 22 ) 23 ··· 231 } 232 233 // Bootstrap initializes the hold with the captain record, owner as first crew member, and profile 234 + func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, ownerDID string, public bool, allowAllCrew bool, avatarURL, region string) error { 235 if ownerDID == "" { 236 return nil 237 } ··· 317 318 // Create Bluesky profile record (idempotent - check if exists first) 319 // This runs even if captain exists (for existing holds being upgraded) 320 + // Skip if no S3 service (e.g., in tests) 321 + if s3svc != nil { 322 _, _, err = p.GetProfileRecord(ctx) 323 if err != nil { 324 // Bluesky profile doesn't exist, create it 325 displayName := "Cargo Hold" 326 description := "ahoy from the cargo hold" 327 328 + _, err = p.CreateProfileRecord(ctx, s3svc, displayName, description, avatarURL) 329 if err != nil { 330 return fmt.Errorf("failed to create bluesky profile record: %w", err) 331 }
+2 -2
pkg/hold/pds/status_test.go
··· 55 } 56 57 // Create handler for XRPC endpoints 58 - handler := NewXRPCHandler(holdPDS, s3.S3Service{}, nil, nil, &mockPDSClient{}, nil) 59 60 // Helper function to list posts via XRPC 61 listPosts := func() ([]map[string]any, error) { ··· 283 } 284 285 // Create shared handler 286 - sharedHandler = NewXRPCHandler(sharedPDS, s3.S3Service{}, nil, nil, &mockPDSClient{}, nil) 287 288 // Run tests 289 code := m.Run()
··· 55 } 56 57 // Create handler for XRPC endpoints 58 + handler := NewXRPCHandler(holdPDS, s3.S3Service{}, nil, &mockPDSClient{}, nil) 59 60 // Helper function to list posts via XRPC 61 listPosts := func() ([]map[string]any, error) { ··· 283 } 284 285 // Create shared handler 286 + sharedHandler = NewXRPCHandler(sharedPDS, s3.S3Service{}, nil, &mockPDSClient{}, nil) 287 288 // Run tests 289 code := m.Run()
+10 -34
pkg/hold/pds/xrpc.go
··· 12 "github.com/bluesky-social/indigo/api/bsky" 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 "github.com/bluesky-social/indigo/repo" 15 - "github.com/distribution/distribution/v3/registry/storage/driver" 16 "github.com/go-chi/chi/v5" 17 "github.com/go-chi/render" 18 "github.com/gorilla/websocket" ··· 46 type XRPCHandler struct { 47 pds *HoldPDS 48 s3Service s3.S3Service 49 - storageDriver driver.StorageDriver 50 broadcaster *EventBroadcaster 51 scanBroadcaster *ScanBroadcaster // Scan job dispatcher for connected scanners 52 httpClient HTTPClient // For testing - allows injecting mock HTTP client ··· 68 } 69 70 // NewXRPCHandler creates a new XRPC handler 71 - func NewXRPCHandler(pds *HoldPDS, s3Service s3.S3Service, storageDriver driver.StorageDriver, broadcaster *EventBroadcaster, httpClient HTTPClient, quotaMgr *quota.Manager) *XRPCHandler { 72 return &XRPCHandler{ 73 - pds: pds, 74 - s3Service: s3Service, 75 - storageDriver: storageDriver, 76 - broadcaster: broadcaster, 77 - httpClient: httpClient, 78 - quotaMgr: quotaMgr, 79 } 80 } 81 ··· 1052 // ATProto uses CIDv1 with raw codec for blobs 1053 blobCID := cid.NewCidV1(0x55, mh) 1054 1055 - // Store blob via distribution driver at ATProto path 1056 path := atprotoBlobPath(did, blobCID.String()) 1057 1058 - // Write blob to storage using distribution driver 1059 - writer, err := h.storageDriver.Writer(r.Context(), path, false) 1060 - if err != nil { 1061 - http.Error(w, fmt.Sprintf("failed to create writer: %v", err), http.StatusInternalServerError) 1062 - return 1063 - } 1064 - 1065 - // Write data 1066 - n, err := io.Copy(writer, bytes.NewReader(blobData)) 1067 - if err != nil { 1068 - writer.Cancel(r.Context()) 1069 - http.Error(w, fmt.Sprintf("failed to write blob: %v", err), http.StatusInternalServerError) 1070 - return 1071 - } 1072 - 1073 - // Commit the write 1074 - if err := writer.Commit(r.Context()); err != nil { 1075 - http.Error(w, fmt.Sprintf("failed to commit blob: %v", err), http.StatusInternalServerError) 1076 - return 1077 - } 1078 - 1079 - if n != size { 1080 - http.Error(w, fmt.Sprintf("size mismatch: wrote %d bytes, expected %d", n, size), http.StatusInternalServerError) 1081 return 1082 } 1083 ··· 1259 safeDID := strings.ReplaceAll(did, ":", "-") 1260 blobsPath := fmt.Sprintf("/repos/%s/blobs", safeDID) 1261 1262 - entries, err := h.storageDriver.List(r.Context(), blobsPath) 1263 if err != nil { 1264 // Path doesn't exist = no blobs, return empty list 1265 render.JSON(w, r, map[string]any{"cids": []string{}})
··· 12 "github.com/bluesky-social/indigo/api/bsky" 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 "github.com/bluesky-social/indigo/repo" 15 "github.com/go-chi/chi/v5" 16 "github.com/go-chi/render" 17 "github.com/gorilla/websocket" ··· 45 type XRPCHandler struct { 46 pds *HoldPDS 47 s3Service s3.S3Service 48 broadcaster *EventBroadcaster 49 scanBroadcaster *ScanBroadcaster // Scan job dispatcher for connected scanners 50 httpClient HTTPClient // For testing - allows injecting mock HTTP client ··· 66 } 67 68 // NewXRPCHandler creates a new XRPC handler 69 + func NewXRPCHandler(pds *HoldPDS, s3Service s3.S3Service, broadcaster *EventBroadcaster, httpClient HTTPClient, quotaMgr *quota.Manager) *XRPCHandler { 70 return &XRPCHandler{ 71 + pds: pds, 72 + s3Service: s3Service, 73 + broadcaster: broadcaster, 74 + httpClient: httpClient, 75 + quotaMgr: quotaMgr, 76 } 77 } 78 ··· 1049 // ATProto uses CIDv1 with raw codec for blobs 1050 blobCID := cid.NewCidV1(0x55, mh) 1051 1052 + // Store blob via S3 at ATProto path 1053 path := atprotoBlobPath(did, blobCID.String()) 1054 1055 + if err := h.s3Service.PutBytes(r.Context(), path, blobData, "application/octet-stream"); err != nil { 1056 + http.Error(w, fmt.Sprintf("failed to put blob: %v", err), http.StatusInternalServerError) 1057 return 1058 } 1059 ··· 1235 safeDID := strings.ReplaceAll(did, ":", "-") 1236 blobsPath := fmt.Sprintf("/repos/%s/blobs", safeDID) 1237 1238 + entries, err := h.s3Service.ListPrefix(r.Context(), blobsPath) 1239 if err != nil { 1240 // Path doesn't exist = no blobs, return empty list 1241 render.JSON(w, r, map[string]any{"cids": []string{}})
+24 -62
pkg/hold/pds/xrpc_test.go
··· 18 "atcr.io/pkg/s3" 19 indigoAtproto "github.com/bluesky-social/indigo/api/atproto" 20 "github.com/bluesky-social/indigo/events" 21 - "github.com/distribution/distribution/v3/registry/storage/driver/factory" 22 - _ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem" 23 "github.com/go-chi/chi/v5" 24 "github.com/gorilla/websocket" 25 "github.com/ipfs/go-cid" ··· 76 mockS3 := s3.S3Service{} 77 78 // Create XRPC handler with mock HTTP client 79 - handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient, nil) 80 81 return handler, ctx 82 } ··· 143 mockS3 := s3.S3Service{} 144 145 // Create XRPC handler with mock HTTP client 146 - handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient, nil) 147 148 return handler, ctx 149 } ··· 753 pds, ctx := setupTestPDS(t) // Don't bootstrap - no records created yet 754 mockClient := &mockPDSClient{} 755 mockS3 := s3.S3Service{} 756 - handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient, nil) 757 758 // Initialize repo manually (setupTestPDS doesn't call Bootstrap, so no crew members) 759 err := pds.repomgr.InitNewActor(ctx, pds.uid, "", pds.did, "", "", "") ··· 1231 pds, ctx := setupTestPDS(t) // Don't bootstrap 1232 mockClient := &mockPDSClient{} 1233 mockS3 := s3.S3Service{} 1234 - handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient, nil) 1235 1236 // setupTestPDS creates the PDS/database but doesn't initialize the repo 1237 // Check if implementation returns repos before initialization ··· 1317 pds, ctx := setupTestPDS(t) // Don't bootstrap 1318 mockClient := &mockPDSClient{} 1319 mockS3 := s3.S3Service{} 1320 - handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient, nil) 1321 holdDID := "did:web:hold.example.com" 1322 1323 // Initialize repo but don't add any records ··· 1960 // Mock S3 Service for testing blob endpoints 1961 1962 // mockS3Service is a simple mock that tracks calls and returns test URLs 1963 - type mockS3Service struct { 1964 - // Track calls 1965 - downloadCalls []string // Track digests requested for download 1966 - } 1967 - 1968 - func newMockS3Service() *mockS3Service { 1969 - return &mockS3Service{ 1970 - downloadCalls: []string{}, 1971 - } 1972 - } 1973 - 1974 - // toS3Service converts the mock to an s3.S3Service 1975 - // Returns empty s3.S3Service since we're not testing S3 presigned URLs in these tests 1976 - func (m *mockS3Service) toS3Service() s3.S3Service { 1977 - return s3.S3Service{ 1978 - Client: nil, // Not testing presigned URLs 1979 - Bucket: "", 1980 - PathPrefix: "", 1981 - } 1982 - } 1983 - 1984 // setupTestXRPCHandlerWithMockS3 creates handler with MockS3Client for testing presigned URLs 1985 func setupTestXRPCHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Client, context.Context) { 1986 t.Helper() ··· 2027 Client: mockS3Client, 2028 Bucket: "test-bucket", 2029 PathPrefix: "test-prefix", 2030 - } 2031 - 2032 - // Create filesystem storage driver for tests 2033 - storageDir := filepath.Join(tmpDir, "storage") 2034 - params := map[string]any{ 2035 - "rootdirectory": storageDir, 2036 - } 2037 - driver, err := factory.Create(ctx, "filesystem", params) 2038 - if err != nil { 2039 - t.Fatalf("Failed to create storage driver: %v", err) 2040 } 2041 2042 // Create mock PDS client for DPoP validation 2043 mockClient := &mockPDSClient{} 2044 2045 - // Create XRPC handler with mock S3 client and real filesystem driver 2046 - handler := NewXRPCHandler(pds, s3Service, driver, nil, mockClient, nil) 2047 2048 return handler, mockS3Client, ctx 2049 } 2050 2051 - // setupTestXRPCHandlerWithBlobs creates handler with mock s3 service and real filesystem driver 2052 - func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockS3Service, context.Context) { 2053 t.Helper() 2054 2055 ctx := context.Background() ··· 2088 t.Fatalf("Failed to bootstrap PDS: %v", err) 2089 } 2090 2091 - // Create mock s3 service that returns test URLs 2092 - mockS3Svc := newMockS3Service() 2093 - 2094 - // Create filesystem storage driver for tests 2095 - storageDir := filepath.Join(tmpDir, "storage") 2096 - params := map[string]any{ 2097 - "rootdirectory": storageDir, 2098 - } 2099 - driver, err := factory.Create(ctx, "filesystem", params) 2100 - if err != nil { 2101 - t.Fatalf("Failed to create storage driver: %v", err) 2102 } 2103 2104 // Create mock PDS client for DPoP validation 2105 mockClient := &mockPDSClient{} 2106 2107 - // Create XRPC handler with mock s3 service and real filesystem driver 2108 - handler := NewXRPCHandler(pds, mockS3Svc.toS3Service(), driver, nil, mockClient, nil) 2109 2110 - return handler, mockS3Svc, ctx 2111 } 2112 2113 // Tests for HandleUploadBlob ··· 2391 t.Error("Expected Location header in 307 redirect") 2392 } 2393 2394 - // Should be XRPC proxy URL since we don't have S3 client 2395 - if !strings.Contains(location, "/xrpc/com.atproto.sync.getBlob") { 2396 - t.Errorf("Expected XRPC proxy URL, got: %s", location) 2397 } 2398 } 2399 ··· 2457 t.Error("Expected Location header in 307 redirect") 2458 } 2459 2460 - // Should be XRPC proxy URL since we don't have S3 client 2461 - if !strings.Contains(location, "/xrpc/com.atproto.sync.getBlob") { 2462 - t.Errorf("Expected XRPC proxy URL, got: %s", location) 2463 } 2464 } 2465
··· 18 "atcr.io/pkg/s3" 19 indigoAtproto "github.com/bluesky-social/indigo/api/atproto" 20 "github.com/bluesky-social/indigo/events" 21 "github.com/go-chi/chi/v5" 22 "github.com/gorilla/websocket" 23 "github.com/ipfs/go-cid" ··· 74 mockS3 := s3.S3Service{} 75 76 // Create XRPC handler with mock HTTP client 77 + handler := NewXRPCHandler(pds, mockS3, nil, mockClient, nil) 78 79 return handler, ctx 80 } ··· 141 mockS3 := s3.S3Service{} 142 143 // Create XRPC handler with mock HTTP client 144 + handler := NewXRPCHandler(pds, mockS3, nil, mockClient, nil) 145 146 return handler, ctx 147 } ··· 751 pds, ctx := setupTestPDS(t) // Don't bootstrap - no records created yet 752 mockClient := &mockPDSClient{} 753 mockS3 := s3.S3Service{} 754 + handler := NewXRPCHandler(pds, mockS3, nil, mockClient, nil) 755 756 // Initialize repo manually (setupTestPDS doesn't call Bootstrap, so no crew members) 757 err := pds.repomgr.InitNewActor(ctx, pds.uid, "", pds.did, "", "", "") ··· 1229 pds, ctx := setupTestPDS(t) // Don't bootstrap 1230 mockClient := &mockPDSClient{} 1231 mockS3 := s3.S3Service{} 1232 + handler := NewXRPCHandler(pds, mockS3, nil, mockClient, nil) 1233 1234 // setupTestPDS creates the PDS/database but doesn't initialize the repo 1235 // Check if implementation returns repos before initialization ··· 1315 pds, ctx := setupTestPDS(t) // Don't bootstrap 1316 mockClient := &mockPDSClient{} 1317 mockS3 := s3.S3Service{} 1318 + handler := NewXRPCHandler(pds, mockS3, nil, mockClient, nil) 1319 holdDID := "did:web:hold.example.com" 1320 1321 // Initialize repo but don't add any records ··· 1958 // Mock S3 Service for testing blob endpoints 1959 1960 // mockS3Service is a simple mock that tracks calls and returns test URLs 1961 // setupTestXRPCHandlerWithMockS3 creates handler with MockS3Client for testing presigned URLs 1962 func setupTestXRPCHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Client, context.Context) { 1963 t.Helper() ··· 2004 Client: mockS3Client, 2005 Bucket: "test-bucket", 2006 PathPrefix: "test-prefix", 2007 } 2008 2009 // Create mock PDS client for DPoP validation 2010 mockClient := &mockPDSClient{} 2011 2012 + // Create XRPC handler with mock S3 client 2013 + handler := NewXRPCHandler(pds, s3Service, nil, mockClient, nil) 2014 2015 return handler, mockS3Client, ctx 2016 } 2017 2018 + // setupTestXRPCHandlerWithBlobs creates handler with MockS3Client for upload/list testing 2019 + func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *s3.MockS3Client, context.Context) { 2020 t.Helper() 2021 2022 ctx := context.Background() ··· 2055 t.Fatalf("Failed to bootstrap PDS: %v", err) 2056 } 2057 2058 + // Create MockS3Client for blob upload/list 2059 + mockS3Client := s3.NewMockS3Client("https://mock-s3.example.com") 2060 + s3Service := s3.S3Service{ 2061 + Client: mockS3Client, 2062 + Bucket: "test-bucket", 2063 + PathPrefix: "", 2064 } 2065 2066 // Create mock PDS client for DPoP validation 2067 mockClient := &mockPDSClient{} 2068 2069 + // Create XRPC handler 2070 + handler := NewXRPCHandler(pds, s3Service, nil, mockClient, nil) 2071 2072 + return handler, mockS3Client, ctx 2073 } 2074 2075 // Tests for HandleUploadBlob ··· 2353 t.Error("Expected Location header in 307 redirect") 2354 } 2355 2356 + // Should be a presigned URL from the mock S3 client 2357 + if !strings.Contains(location, "mock-s3.example.com") { 2358 + t.Errorf("Expected presigned S3 URL, got: %s", location) 2359 } 2360 } 2361 ··· 2419 t.Error("Expected Location header in 307 redirect") 2420 } 2421 2422 + // Should be a presigned URL from the mock S3 client 2423 + if !strings.Contains(location, "mock-s3.example.com") { 2424 + t.Errorf("Expected presigned S3 URL, got: %s", location) 2425 } 2426 } 2427
+11 -24
pkg/hold/server.go
··· 21 "atcr.io/pkg/logging" 22 "atcr.io/pkg/s3" 23 24 - "github.com/distribution/distribution/v3/registry/storage/driver/factory" 25 - _ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws" 26 - 27 "github.com/go-chi/chi/v5" 28 "github.com/go-chi/chi/v5/middleware" 29 ) ··· 72 73 // Initialize embedded PDS if database path is configured 74 var xrpcHandler *pds.XRPCHandler 75 if cfg.Database.Path != "" { 76 holdDID := pds.GenerateDIDFromURL(cfg.Server.PublicURL) 77 slog.Info("Initializing embedded PDS", "did", holdDID) ··· 109 s.broadcaster = pds.NewEventBroadcaster(holdDID, 100, ":memory:") 110 } 111 112 - // Create storage driver from config (needed for bootstrap profile avatar) 113 - driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters()) 114 if err != nil { 115 - return nil, fmt.Errorf("failed to create storage driver: %w", err) 116 } 117 118 // Bootstrap PDS with captain record, hold owner as first crew member, and profile 119 - if err := s.PDS.Bootstrap(ctx, driver, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew, cfg.Registration.ProfileAvatarURL, cfg.Registration.Region); err != nil { 120 return nil, fmt.Errorf("failed to bootstrap PDS: %w", err) 121 } 122 ··· 163 slog.Info("Quota enforcement disabled (no quota tiers configured)") 164 } 165 166 - // Create blob store adapter and XRPC handlers 167 var ociHandler *oci.XRPCHandler 168 if s.PDS != nil { 169 - ctx := context.Background() 170 - driver, err := factory.Create(ctx, cfg.Storage.Type(), cfg.Storage.Parameters()) 171 - if err != nil { 172 - return nil, fmt.Errorf("failed to create storage driver: %w", err) 173 - } 174 - 175 - s3Service, err := s3.NewS3Service(cfg.Storage.Parameters()) 176 - if err != nil { 177 - return nil, fmt.Errorf("failed to create S3 service: %w", err) 178 - } 179 - 180 - xrpcHandler = pds.NewXRPCHandler(s.PDS, *s3Service, driver, s.broadcaster, nil, s.QuotaManager) 181 - ociHandler = oci.NewXRPCHandler(s.PDS, *s3Service, driver, cfg.Registration.EnableBlueskyPosts, nil, s.QuotaManager) 182 183 // Initialize scan broadcaster if scanner secret is configured 184 if cfg.Scanner.Secret != "" { 185 holdDID := pds.GenerateDIDFromURL(cfg.Server.PublicURL) 186 var sb *pds.ScanBroadcaster 187 if s.holdDB != nil { 188 - sb, err = pds.NewScanBroadcasterWithDB(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, s.holdDB.DB, driver, s.PDS) 189 } else { 190 scanDBPath := cfg.Database.Path + "/db.sqlite3" 191 - sb, err = pds.NewScanBroadcaster(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, scanDBPath, driver, s.PDS) 192 } 193 if err != nil { 194 return nil, fmt.Errorf("failed to initialize scan broadcaster: %w", err) ··· 200 } 201 202 // Initialize garbage collector 203 - s.garbageCollector = gc.NewGarbageCollector(s.PDS, driver, cfg.GC) 204 slog.Info("Garbage collector initialized", 205 "enabled", cfg.GC.Enabled) 206 }
··· 21 "atcr.io/pkg/logging" 22 "atcr.io/pkg/s3" 23 24 "github.com/go-chi/chi/v5" 25 "github.com/go-chi/chi/v5/middleware" 26 ) ··· 69 70 // Initialize embedded PDS if database path is configured 71 var xrpcHandler *pds.XRPCHandler 72 + var s3Service *s3.S3Service 73 if cfg.Database.Path != "" { 74 holdDID := pds.GenerateDIDFromURL(cfg.Server.PublicURL) 75 slog.Info("Initializing embedded PDS", "did", holdDID) ··· 107 s.broadcaster = pds.NewEventBroadcaster(holdDID, 100, ":memory:") 108 } 109 110 + // Create S3 service (used for bootstrap, handlers, GC, etc.) 111 + s3Service, err = s3.NewS3Service(cfg.Storage.S3Params()) 112 if err != nil { 113 + return nil, fmt.Errorf("failed to create S3 service: %w", err) 114 } 115 116 // Bootstrap PDS with captain record, hold owner as first crew member, and profile 117 + if err := s.PDS.Bootstrap(ctx, s3Service, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew, cfg.Registration.ProfileAvatarURL, cfg.Registration.Region); err != nil { 118 return nil, fmt.Errorf("failed to bootstrap PDS: %w", err) 119 } 120 ··· 161 slog.Info("Quota enforcement disabled (no quota tiers configured)") 162 } 163 164 + // Create XRPC handlers 165 var ociHandler *oci.XRPCHandler 166 if s.PDS != nil { 167 + xrpcHandler = pds.NewXRPCHandler(s.PDS, *s3Service, s.broadcaster, nil, s.QuotaManager) 168 + ociHandler = oci.NewXRPCHandler(s.PDS, *s3Service, cfg.Registration.EnableBlueskyPosts, nil, s.QuotaManager) 169 170 // Initialize scan broadcaster if scanner secret is configured 171 if cfg.Scanner.Secret != "" { 172 holdDID := pds.GenerateDIDFromURL(cfg.Server.PublicURL) 173 var sb *pds.ScanBroadcaster 174 if s.holdDB != nil { 175 + sb, err = pds.NewScanBroadcasterWithDB(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, s.holdDB.DB, s3Service, s.PDS) 176 } else { 177 scanDBPath := cfg.Database.Path + "/db.sqlite3" 178 + sb, err = pds.NewScanBroadcaster(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, scanDBPath, s3Service, s.PDS) 179 } 180 if err != nil { 181 return nil, fmt.Errorf("failed to initialize scan broadcaster: %w", err) ··· 187 } 188 189 // Initialize garbage collector 190 + s.garbageCollector = gc.NewGarbageCollector(s.PDS, s3Service, cfg.GC) 191 slog.Info("Garbage collector initialized", 192 "enabled", cfg.GC.Enabled) 193 }
+178
pkg/s3/mock.go
··· 1 package s3 2 3 import ( 4 "context" 5 "fmt" 6 "sync" 7 "time" 8 9 "github.com/aws/aws-sdk-go-v2/aws" 10 awss3 "github.com/aws/aws-sdk-go-v2/service/s3" 11 "github.com/google/uuid" 12 ) 13 ··· 22 // If empty, a UUID is generated. 23 UploadID string 24 25 // Track calls for verification in tests 26 mu sync.Mutex 27 CreateMultipartCalls []CreateMultipartCall ··· 36 CreateMultipartError error 37 CompleteError error 38 AbortError error 39 } 40 41 // CreateMultipartCall records a CreateMultipartUpload call ··· 89 func NewMockS3Client(testServerURL string) *MockS3Client { 90 return &MockS3Client{ 91 TestServerURL: testServerURL, 92 CreateMultipartCalls: []CreateMultipartCall{}, 93 CompleteCalls: []CompleteCall{}, 94 AbortCalls: []AbortCall{}, ··· 144 return nil, m.CompleteError 145 } 146 147 // Return a mock ETag 148 etag := "\"mock-etag-" + uuid.New().String() + "\"" 149 return &awss3.CompleteMultipartUploadOutput{ ··· 169 return &awss3.AbortMultipartUploadOutput{}, nil 170 } 171 172 // PresignUploadPart implements S3Client 173 // Returns a mock presigned URL for test server 174 func (m *MockS3Client) PresignUploadPart(ctx context.Context, input *awss3.UploadPartInput, expires time.Duration) (string, error) { ··· 229 Key: aws.ToString(input.Key), 230 }) 231 232 url := fmt.Sprintf("%s/put/%s", m.TestServerURL, aws.ToString(input.Key)) 233 return url, nil 234 }
··· 1 package s3 2 3 import ( 4 + "bytes" 5 "context" 6 "fmt" 7 + "io" 8 + "strings" 9 "sync" 10 "time" 11 12 "github.com/aws/aws-sdk-go-v2/aws" 13 awss3 "github.com/aws/aws-sdk-go-v2/service/s3" 14 + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" 15 "github.com/google/uuid" 16 ) 17 ··· 26 // If empty, a UUID is generated. 27 UploadID string 28 29 + // Objects stores in-memory blobs for PutObject/HeadObject/DeleteObject/CopyObject/ListObjectsV2. 30 + Objects map[string][]byte 31 + 32 // Track calls for verification in tests 33 mu sync.Mutex 34 CreateMultipartCalls []CreateMultipartCall ··· 43 CreateMultipartError error 44 CompleteError error 45 AbortError error 46 + HeadObjectError error 47 + CopyObjectError error 48 } 49 50 // CreateMultipartCall records a CreateMultipartUpload call ··· 98 func NewMockS3Client(testServerURL string) *MockS3Client { 99 return &MockS3Client{ 100 TestServerURL: testServerURL, 101 + Objects: make(map[string][]byte), 102 CreateMultipartCalls: []CreateMultipartCall{}, 103 CompleteCalls: []CompleteCall{}, 104 AbortCalls: []AbortCall{}, ··· 154 return nil, m.CompleteError 155 } 156 157 + // Store a placeholder object at the key so Stat/HeadObject works after complete 158 + key := aws.ToString(input.Key) 159 + if m.Objects != nil { 160 + if _, exists := m.Objects[key]; !exists { 161 + m.Objects[key] = []byte("completed-multipart") 162 + } 163 + } 164 + 165 // Return a mock ETag 166 etag := "\"mock-etag-" + uuid.New().String() + "\"" 167 return &awss3.CompleteMultipartUploadOutput{ ··· 187 return &awss3.AbortMultipartUploadOutput{}, nil 188 } 189 190 + // HeadObject implements S3Client 191 + func (m *MockS3Client) HeadObject(ctx context.Context, input *awss3.HeadObjectInput, opts ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) { 192 + m.mu.Lock() 193 + defer m.mu.Unlock() 194 + 195 + if m.HeadObjectError != nil { 196 + return nil, m.HeadObjectError 197 + } 198 + 199 + key := aws.ToString(input.Key) 200 + data, ok := m.Objects[key] 201 + if !ok { 202 + return nil, fmt.Errorf("NoSuchKey: object %s not found", key) 203 + } 204 + 205 + size := int64(len(data)) 206 + return &awss3.HeadObjectOutput{ 207 + ContentLength: &size, 208 + }, nil 209 + } 210 + 211 + // PutObject implements S3Client 212 + func (m *MockS3Client) PutObject(ctx context.Context, input *awss3.PutObjectInput, opts ...func(*awss3.Options)) (*awss3.PutObjectOutput, error) { 213 + m.mu.Lock() 214 + defer m.mu.Unlock() 215 + 216 + key := aws.ToString(input.Key) 217 + m.PutObjectCalls = append(m.PutObjectCalls, PutObjectCall{ 218 + Bucket: aws.ToString(input.Bucket), 219 + Key: key, 220 + }) 221 + 222 + if input.Body != nil { 223 + data, err := io.ReadAll(input.Body) 224 + if err != nil { 225 + return nil, err 226 + } 227 + m.Objects[key] = data 228 + } else { 229 + m.Objects[key] = []byte{} 230 + } 231 + 232 + return &awss3.PutObjectOutput{}, nil 233 + } 234 + 235 + // CopyObject implements S3Client 236 + func (m *MockS3Client) CopyObject(ctx context.Context, input *awss3.CopyObjectInput, opts ...func(*awss3.Options)) (*awss3.CopyObjectOutput, error) { 237 + m.mu.Lock() 238 + defer m.mu.Unlock() 239 + 240 + if m.CopyObjectError != nil { 241 + return nil, m.CopyObjectError 242 + } 243 + 244 + // CopySource is "bucket/key" 245 + copySource := aws.ToString(input.CopySource) 246 + // Strip bucket prefix to get key 247 + parts := strings.SplitN(copySource, "/", 2) 248 + srcKey := copySource 249 + if len(parts) == 2 { 250 + srcKey = parts[1] 251 + } 252 + 253 + data, ok := m.Objects[srcKey] 254 + if !ok { 255 + return nil, fmt.Errorf("NoSuchKey: source object %s not found", srcKey) 256 + } 257 + 258 + dstKey := aws.ToString(input.Key) 259 + m.Objects[dstKey] = append([]byte{}, data...) 260 + 261 + return &awss3.CopyObjectOutput{}, nil 262 + } 263 + 264 + // DeleteObject implements S3Client 265 + func (m *MockS3Client) DeleteObject(ctx context.Context, input *awss3.DeleteObjectInput, opts ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error) { 266 + m.mu.Lock() 267 + defer m.mu.Unlock() 268 + 269 + key := aws.ToString(input.Key) 270 + delete(m.Objects, key) 271 + 272 + return &awss3.DeleteObjectOutput{}, nil 273 + } 274 + 275 + // ListObjectsV2 implements S3Client 276 + func (m *MockS3Client) ListObjectsV2(ctx context.Context, input *awss3.ListObjectsV2Input, opts ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) { 277 + m.mu.Lock() 278 + defer m.mu.Unlock() 279 + 280 + prefix := aws.ToString(input.Prefix) 281 + delimiter := aws.ToString(input.Delimiter) 282 + 283 + var contents []s3types.Object 284 + commonPrefixes := map[string]bool{} 285 + 286 + for key, data := range m.Objects { 287 + if !strings.HasPrefix(key, prefix) { 288 + continue 289 + } 290 + 291 + if delimiter != "" { 292 + // Check if there's a delimiter after the prefix 293 + rest := strings.TrimPrefix(key, prefix) 294 + idx := strings.Index(rest, delimiter) 295 + if idx >= 0 { 296 + // Has delimiter — this is a common prefix, not a content object 297 + cp := prefix + rest[:idx+len(delimiter)] 298 + commonPrefixes[cp] = true 299 + continue 300 + } 301 + } 302 + 303 + size := int64(len(data)) 304 + k := key 305 + contents = append(contents, s3types.Object{ 306 + Key: &k, 307 + Size: &size, 308 + }) 309 + } 310 + 311 + var cps []s3types.CommonPrefix 312 + for cp := range commonPrefixes { 313 + p := cp 314 + cps = append(cps, s3types.CommonPrefix{Prefix: &p}) 315 + } 316 + 317 + falseVal := false 318 + return &awss3.ListObjectsV2Output{ 319 + Contents: contents, 320 + CommonPrefixes: cps, 321 + IsTruncated: &falseVal, 322 + }, nil 323 + } 324 + 325 // PresignUploadPart implements S3Client 326 // Returns a mock presigned URL for test server 327 func (m *MockS3Client) PresignUploadPart(ctx context.Context, input *awss3.UploadPartInput, expires time.Duration) (string, error) { ··· 382 Key: aws.ToString(input.Key), 383 }) 384 385 + // Also store the body if provided (for PresignPutObject used in tests that also check objects) 386 + if input.Body != nil { 387 + key := aws.ToString(input.Key) 388 + data, _ := io.ReadAll(input.Body) 389 + m.Objects[key] = data 390 + } 391 + 392 url := fmt.Sprintf("%s/put/%s", m.TestServerURL, aws.ToString(input.Key)) 393 return url, nil 394 } 395 + 396 + // SetObject is a test helper to pre-populate an object in the mock store. 397 + func (m *MockS3Client) SetObject(key string, data []byte) { 398 + m.mu.Lock() 399 + defer m.mu.Unlock() 400 + m.Objects[key] = append([]byte{}, data...) 401 + } 402 + 403 + // GetObject is a test helper to read an object from the mock store (nil if not found). 404 + func (m *MockS3Client) GetObject(key string) []byte { 405 + m.mu.Lock() 406 + defer m.mu.Unlock() 407 + data, ok := m.Objects[key] 408 + if !ok { 409 + return nil 410 + } 411 + return bytes.Clone(data) 412 + }
+222 -11
pkg/s3/types.go
··· 3 package s3 4 5 import ( 6 "context" 7 "fmt" 8 "log/slog" 9 "strings" 10 "time" 11 ··· 23 CreateMultipartUpload(ctx context.Context, input *awss3.CreateMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.CreateMultipartUploadOutput, error) 24 CompleteMultipartUpload(ctx context.Context, input *awss3.CompleteMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.CompleteMultipartUploadOutput, error) 25 AbortMultipartUpload(ctx context.Context, input *awss3.AbortMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.AbortMultipartUploadOutput, error) 26 27 // Presigned URL operations - return URL string directly 28 PresignGetObject(ctx context.Context, input *awss3.GetObjectInput, expires time.Duration) (string, error) ··· 61 return r.client.AbortMultipartUpload(ctx, input, opts...) 62 } 63 64 // PresignGetObject implements S3Client 65 func (r *RealS3Client) PresignGetObject(ctx context.Context, input *awss3.GetObjectInput, expires time.Duration) (string, error) { 66 result, err := r.presign.PresignGetObject(ctx, input, func(opts *awss3.PresignOptions) { 67 opts.Expires = expires 68 - if r.pullZone != "" { 69 - opts.ClientOptions = append(opts.ClientOptions, func(o *awss3.Options) { 70 - o.BaseEndpoint = aws.String(r.pullZone) 71 - }) 72 - } 73 }) 74 if err != nil { 75 return "", err 76 } 77 - return result.URL, nil 78 } 79 80 // PresignHeadObject implements S3Client 81 func (r *RealS3Client) PresignHeadObject(ctx context.Context, input *awss3.HeadObjectInput, expires time.Duration) (string, error) { 82 result, err := r.presign.PresignHeadObject(ctx, input, func(opts *awss3.PresignOptions) { 83 opts.Expires = expires 84 - if r.pullZone != "" { 85 - opts.ClientOptions = append(opts.ClientOptions, func(o *awss3.Options) { 86 - o.BaseEndpoint = aws.String(r.pullZone) 87 - }) 88 - } 89 }) 90 if err != nil { 91 return "", err ··· 191 Bucket: bucket, 192 PathPrefix: s3PathPrefix, 193 }, nil 194 } 195 196 // BlobPath converts a digest (e.g., "sha256:abc123...") or temp path to a storage path
··· 3 package s3 4 5 import ( 6 + "bytes" 7 "context" 8 "fmt" 9 "log/slog" 10 + "net/url" 11 "strings" 12 "time" 13 ··· 25 CreateMultipartUpload(ctx context.Context, input *awss3.CreateMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.CreateMultipartUploadOutput, error) 26 CompleteMultipartUpload(ctx context.Context, input *awss3.CompleteMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.CompleteMultipartUploadOutput, error) 27 AbortMultipartUpload(ctx context.Context, input *awss3.AbortMultipartUploadInput, opts ...func(*awss3.Options)) (*awss3.AbortMultipartUploadOutput, error) 28 + 29 + // Direct object operations 30 + HeadObject(ctx context.Context, input *awss3.HeadObjectInput, opts ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) 31 + PutObject(ctx context.Context, input *awss3.PutObjectInput, opts ...func(*awss3.Options)) (*awss3.PutObjectOutput, error) 32 + CopyObject(ctx context.Context, input *awss3.CopyObjectInput, opts ...func(*awss3.Options)) (*awss3.CopyObjectOutput, error) 33 + DeleteObject(ctx context.Context, input *awss3.DeleteObjectInput, opts ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error) 34 + ListObjectsV2(ctx context.Context, input *awss3.ListObjectsV2Input, opts ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) 35 36 // Presigned URL operations - return URL string directly 37 PresignGetObject(ctx context.Context, input *awss3.GetObjectInput, expires time.Duration) (string, error) ··· 70 return r.client.AbortMultipartUpload(ctx, input, opts...) 71 } 72 73 + // HeadObject implements S3Client 74 + func (r *RealS3Client) HeadObject(ctx context.Context, input *awss3.HeadObjectInput, opts ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) { 75 + return r.client.HeadObject(ctx, input, opts...) 76 + } 77 + 78 + // PutObject implements S3Client 79 + func (r *RealS3Client) PutObject(ctx context.Context, input *awss3.PutObjectInput, opts ...func(*awss3.Options)) (*awss3.PutObjectOutput, error) { 80 + return r.client.PutObject(ctx, input, opts...) 81 + } 82 + 83 + // CopyObject implements S3Client 84 + func (r *RealS3Client) CopyObject(ctx context.Context, input *awss3.CopyObjectInput, opts ...func(*awss3.Options)) (*awss3.CopyObjectOutput, error) { 85 + return r.client.CopyObject(ctx, input, opts...) 86 + } 87 + 88 + // DeleteObject implements S3Client 89 + func (r *RealS3Client) DeleteObject(ctx context.Context, input *awss3.DeleteObjectInput, opts ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error) { 90 + return r.client.DeleteObject(ctx, input, opts...) 91 + } 92 + 93 + // ListObjectsV2 implements S3Client 94 + func (r *RealS3Client) ListObjectsV2(ctx context.Context, input *awss3.ListObjectsV2Input, opts ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) { 95 + return r.client.ListObjectsV2(ctx, input, opts...) 96 + } 97 + 98 // PresignGetObject implements S3Client 99 func (r *RealS3Client) PresignGetObject(ctx context.Context, input *awss3.GetObjectInput, expires time.Duration) (string, error) { 100 result, err := r.presign.PresignGetObject(ctx, input, func(opts *awss3.PresignOptions) { 101 opts.Expires = expires 102 }) 103 if err != nil { 104 return "", err 105 } 106 + return r.applyPullZone(result.URL), nil 107 } 108 109 // PresignHeadObject implements S3Client 110 + // Note: pull zone is intentionally NOT applied to HEAD requests. CDNs like 111 + // Bunny may convert HEAD to GET when proxying, which breaks the SigV4 112 + // signature. HEAD responses have no body, so CDN caching provides no benefit. 113 func (r *RealS3Client) PresignHeadObject(ctx context.Context, input *awss3.HeadObjectInput, expires time.Duration) (string, error) { 114 result, err := r.presign.PresignHeadObject(ctx, input, func(opts *awss3.PresignOptions) { 115 opts.Expires = expires 116 }) 117 if err != nil { 118 return "", err ··· 218 Bucket: bucket, 219 PathPrefix: s3PathPrefix, 220 }, nil 221 + } 222 + 223 + // s3Key converts a blob path (with leading /) to an S3 key with prefix. 224 + func (s *S3Service) s3Key(blobPath string) string { 225 + key := strings.TrimPrefix(blobPath, "/") 226 + if s.PathPrefix != "" { 227 + key = s.PathPrefix + "/" + key 228 + } 229 + return key 230 + } 231 + 232 + // Stat returns the size of an object at blobPath, or an error if it doesn't exist. 233 + func (s *S3Service) Stat(ctx context.Context, blobPath string) (int64, error) { 234 + key := s.s3Key(blobPath) 235 + out, err := s.Client.HeadObject(ctx, &awss3.HeadObjectInput{ 236 + Bucket: &s.Bucket, 237 + Key: &key, 238 + }) 239 + if err != nil { 240 + return 0, err 241 + } 242 + if out.ContentLength != nil { 243 + return *out.ContentLength, nil 244 + } 245 + return 0, nil 246 + } 247 + 248 + // PutBytes uploads data to blobPath with the given content type. 249 + func (s *S3Service) PutBytes(ctx context.Context, blobPath string, data []byte, contentType string) error { 250 + key := s.s3Key(blobPath) 251 + _, err := s.Client.PutObject(ctx, &awss3.PutObjectInput{ 252 + Bucket: &s.Bucket, 253 + Key: &key, 254 + Body: bytes.NewReader(data), 255 + ContentType: &contentType, 256 + }) 257 + return err 258 + } 259 + 260 + // Move copies srcPath to dstPath then deletes srcPath. 261 + func (s *S3Service) Move(ctx context.Context, srcPath, dstPath string) error { 262 + srcKey := s.s3Key(srcPath) 263 + dstKey := s.s3Key(dstPath) 264 + copySource := s.Bucket + "/" + srcKey 265 + 266 + _, err := s.Client.CopyObject(ctx, &awss3.CopyObjectInput{ 267 + Bucket: &s.Bucket, 268 + Key: &dstKey, 269 + CopySource: &copySource, 270 + }) 271 + if err != nil { 272 + return fmt.Errorf("copy %s -> %s: %w", srcPath, dstPath, err) 273 + } 274 + 275 + _, err = s.Client.DeleteObject(ctx, &awss3.DeleteObjectInput{ 276 + Bucket: &s.Bucket, 277 + Key: &srcKey, 278 + }) 279 + if err != nil { 280 + return fmt.Errorf("delete source %s after copy: %w", srcPath, err) 281 + } 282 + 283 + return nil 284 + } 285 + 286 + // Delete removes the object at blobPath. 287 + func (s *S3Service) Delete(ctx context.Context, blobPath string) error { 288 + key := s.s3Key(blobPath) 289 + _, err := s.Client.DeleteObject(ctx, &awss3.DeleteObjectInput{ 290 + Bucket: &s.Bucket, 291 + Key: &key, 292 + }) 293 + return err 294 + } 295 + 296 + // WalkBlobs paginates ListObjectsV2 under prefix and calls fn for each object. 297 + // Keys passed to fn have the PathPrefix stripped (same format as BlobPath output). 298 + func (s *S3Service) WalkBlobs(ctx context.Context, prefix string, fn func(key string, size int64) error) error { 299 + s3Prefix := s.s3Key(prefix) 300 + if !strings.HasSuffix(s3Prefix, "/") { 301 + s3Prefix += "/" 302 + } 303 + 304 + var continuationToken *string 305 + for { 306 + out, err := s.Client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{ 307 + Bucket: &s.Bucket, 308 + Prefix: &s3Prefix, 309 + ContinuationToken: continuationToken, 310 + }) 311 + if err != nil { 312 + return fmt.Errorf("list objects under %s: %w", prefix, err) 313 + } 314 + 315 + for _, obj := range out.Contents { 316 + if obj.Key == nil { 317 + continue 318 + } 319 + // Strip PathPrefix to get the logical path 320 + key := *obj.Key 321 + if s.PathPrefix != "" { 322 + key = strings.TrimPrefix(key, s.PathPrefix+"/") 323 + } 324 + // Restore leading / for consistency with BlobPath 325 + key = "/" + key 326 + 327 + var size int64 328 + if obj.Size != nil { 329 + size = *obj.Size 330 + } 331 + if err := fn(key, size); err != nil { 332 + return err 333 + } 334 + } 335 + 336 + if out.IsTruncated == nil || !*out.IsTruncated { 337 + break 338 + } 339 + continuationToken = out.NextContinuationToken 340 + } 341 + return nil 342 + } 343 + 344 + // ListPrefix returns immediate children (common prefixes) under blobPath using Delimiter="/". 345 + func (s *S3Service) ListPrefix(ctx context.Context, blobPath string) ([]string, error) { 346 + s3Prefix := s.s3Key(blobPath) 347 + if !strings.HasSuffix(s3Prefix, "/") { 348 + s3Prefix += "/" 349 + } 350 + delimiter := "/" 351 + 352 + var results []string 353 + var continuationToken *string 354 + for { 355 + out, err := s.Client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{ 356 + Bucket: &s.Bucket, 357 + Prefix: &s3Prefix, 358 + Delimiter: &delimiter, 359 + ContinuationToken: continuationToken, 360 + }) 361 + if err != nil { 362 + return nil, fmt.Errorf("list prefix %s: %w", blobPath, err) 363 + } 364 + 365 + for _, cp := range out.CommonPrefixes { 366 + if cp.Prefix == nil { 367 + continue 368 + } 369 + // Strip the PathPrefix and reconstruct logical path 370 + p := *cp.Prefix 371 + if s.PathPrefix != "" { 372 + p = strings.TrimPrefix(p, s.PathPrefix+"/") 373 + } 374 + p = "/" + strings.TrimSuffix(p, "/") 375 + results = append(results, p) 376 + } 377 + 378 + if out.IsTruncated == nil || !*out.IsTruncated { 379 + break 380 + } 381 + continuationToken = out.NextContinuationToken 382 + } 383 + 384 + return results, nil 385 + } 386 + 387 + // applyPullZone replaces the host in a presigned URL with the pull zone host. 388 + // The signature is computed against the real S3 endpoint so that the origin 389 + // can validate it; the CDN just proxies the request transparently. 390 + func (r *RealS3Client) applyPullZone(presignedURL string) string { 391 + if r.pullZone == "" { 392 + return presignedURL 393 + } 394 + parsed, err := url.Parse(presignedURL) 395 + if err != nil { 396 + return presignedURL 397 + } 398 + pz, err := url.Parse(r.pullZone) 399 + if err != nil { 400 + return presignedURL 401 + } 402 + parsed.Scheme = pz.Scheme 403 + parsed.Host = pz.Host 404 + return parsed.String() 405 } 406 407 // BlobPath converts a digest (e.g., "sha256:abc123...") or temp path to a storage path