···4949AWS_ACCESS_KEY_ID=
5050AWS_SECRET_ACCESS_KEY=
51515252-# S3 Region
5252+# S3 Region (for distribution S3 driver)
5353# UpCloud regions: us-chi1, us-nyc1, de-fra1, uk-lon1, sg-sin1, etc.
5454# Default: us-chi1
5555-AWS_REGION=us-chi1
5555+S3_REGION=us-chi1
56565757# S3 Bucket Name
5858# Create this bucket in UpCloud Object Storage
5959# Example: atcr-blobs
6060S3_BUCKET=atcr
61616262-# S3 Endpoint (for custom domain or UpCloud endpoint)
6363-# If using custom domain (blobs.atcr.io):
6464-# S3_ENDPOINT=https://blobs.atcr.io
6565-# If using UpCloud default endpoint:
6666-# S3_ENDPOINT=https://s3.us-chi1.upcloudobjects.com
6262+# S3 Endpoint
6363+# Get this from UpCloud Console → Storage → Object Storage → Your bucket → "S3 endpoint"
6464+# Format: https://[bucket-id].upcloudobjects.com
6565+# Example: https://6vmss.upcloudobjects.com
6766#
6868-# IMPORTANT: If using custom domain, create CNAME:
6969-# blobs.atcr.io → [bucket].us-chi1.upcloudobjects.com
7070-# (with Cloudflare proxy DISABLED - gray cloud)
7171-S3_ENDPOINT=https://blobs.atcr.io
6767+# NOTE: Use the bucket-specific endpoint, NOT a custom domain
6868+# Custom domains break presigned URL generation
6969+S3_ENDPOINT=https://6vmss.upcloudobjects.com
72707371# S3 Region Endpoint (alternative to S3_ENDPOINT)
7472# Use this if your S3 driver requires region-specific endpoint format
+24-3
pkg/appview/db/queries.go
···535535}
536536537537// DeleteManifest deletes a manifest and its associated layers
538538+// If repository is empty, deletes all manifests matching did and digest
538539func DeleteManifest(db *sql.DB, did, repository, digest string) error {
539539- _, err := db.Exec(`
540540- DELETE FROM manifests WHERE did = ? AND repository = ? AND digest = ?
541541- `, did, repository, digest)
540540+ var err error
541541+ if repository == "" {
542542+ // Delete by DID + digest only (used when repository is unknown, e.g., Jetstream DELETE events)
543543+ _, err = db.Exec(`DELETE FROM manifests WHERE did = ? AND digest = ?`, did, digest)
544544+ } else {
545545+ // Delete specific manifest
546546+ _, err = db.Exec(`DELETE FROM manifests WHERE did = ? AND repository = ? AND digest = ?`, did, repository, digest)
547547+ }
542548 return err
543549}
544550···10151021 }
1016102210171023 return stars, rows.Err()
10241024+}
10251025+10261026+// CleanupOrphanedTags removes tags whose manifest digest no longer exists
10271027+// This handles cases where manifests were deleted but tags pointing to them remain
10281028+func CleanupOrphanedTags(db *sql.DB, did string) error {
10291029+ _, err := db.Exec(`
10301030+ DELETE FROM tags
10311031+ WHERE did = ?
10321032+ AND NOT EXISTS (
10331033+ SELECT 1 FROM manifests
10341034+ WHERE manifests.did = tags.did
10351035+ AND manifests.digest = tags.digest
10361036+ )
10371037+ `, did)
10381038+ return err
10181039}
1019104010201041// DeleteStarsNotInList deletes stars from the database that are not in the provided list
+7
pkg/appview/jetstream/backfill.go
···198198 fmt.Printf("WARNING: Failed to reconcile deletions for %s: %v\n", did, err)
199199 }
200200201201+ // After processing manifests, clean up orphaned tags (tags pointing to non-existent manifests)
202202+ if collection == atproto.ManifestCollection {
203203+ if err := db.CleanupOrphanedTags(b.db, did); err != nil {
204204+ fmt.Printf("WARNING: Failed to cleanup orphaned tags for %s: %v\n", did, err)
205205+ }
206206+ }
207207+201208 return recordCount, nil
202209}
203210
+8-31
pkg/appview/jetstream/worker.go
···66 "encoding/json"
77 "fmt"
88 "net/url"
99- "strings"
109 "sync"
1110 "time"
1211···418417 }
419418420419 if commit.Operation == "delete" {
421421- // Delete manifest
422422- repo := extractRepoFromRKey(commit.RKey)
420420+ // Delete manifest - rkey is just the digest, repository is not encoded
423421 digest := commit.RKey
424424- return db.DeleteManifest(w.db, commit.DID, repo, digest)
422422+ if err := db.DeleteManifest(w.db, commit.DID, "", digest); err != nil {
423423+ return err
424424+ }
425425+ // Clean up any orphaned tags pointing to this manifest
426426+ return db.CleanupOrphanedTags(w.db, commit.DID)
425427 }
426428427429 // Parse manifest record
···497499 }
498500499501 if commit.Operation == "delete" {
500500- // Delete tag
501501- parts := strings.Split(commit.RKey, "/")
502502- if len(parts) < 2 {
503503- return fmt.Errorf("invalid tag rkey: %s", commit.RKey)
504504- }
505505- repo := strings.Join(parts[:len(parts)-1], "/")
506506- tag := parts[len(parts)-1]
502502+ // Delete tag - decode rkey back to repository and tag
503503+ repo, tag := atproto.RKeyToRepositoryTag(commit.RKey)
507504 return db.DeleteTag(w.db, commit.DID, repo, tag)
508505 }
509506···606603 Status string `json:"status,omitempty"`
607604}
608605609609-// Helper functions
610610-611611-func extractRepoFromRKey(rkey string) string {
612612- // RKey format: <digest> or <repo>/<digest>
613613- // For manifest, it's just the digest
614614- parts := strings.Split(rkey, "/")
615615- if len(parts) > 1 {
616616- return parts[0]
617617- }
618618- return ""
619619-}
620620-621621-func calculateManifestSize(manifest *atproto.ManifestRecord) int64 {
622622- var total int64
623623- total += manifest.Config.Size
624624- for _, layer := range manifest.Layers {
625625- total += layer.Size
626626- }
627627- return total
628628-}
+20
pkg/atproto/manifest_store.go
···207207 return key
208208}
209209210210+// RKeyToRepositoryTag converts an ATProto record key back to repository and tag
211211+// This is the inverse of repositoryTagToRKey
212212+// Note: If the tag contains underscores, this will split on the LAST underscore
213213+func RKeyToRepositoryTag(rkey string) (repository, tag string) {
214214+ // Find the last underscore to split repository and tag
215215+ lastUnderscore := strings.LastIndex(rkey, "_")
216216+ if lastUnderscore == -1 {
217217+ // No underscore found - treat entire string as tag with empty repository
218218+ return "", rkey
219219+ }
220220+221221+ repository = rkey[:lastUnderscore]
222222+ tag = rkey[lastUnderscore+1:]
223223+224224+ // Convert dashes back to slashes in repository
225225+ repository = strings.ReplaceAll(repository, "-", "/")
226226+227227+ return repository, tag
228228+}
229229+210230// GetLastFetchedHoldEndpoint returns the hold endpoint from the most recently fetched manifest
211231// This is used by the routing repository to cache the hold for blob requests
212232func (s *ManifestStore) GetLastFetchedHoldEndpoint() string {