···11-description: Normalize hold_endpoint column to store DIDs instead of URLs
22-query: |
33- -- Convert any URL-formatted hold_endpoint values to DID format
44- -- This ensures all hold identifiers are stored consistently as did:web:hostname
55-66- -- Convert HTTPS URLs to did:web: format
77- -- https://hold.example.com → did:web:hold.example.com
88- UPDATE manifests
99- SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 9)
1010- WHERE hold_endpoint LIKE 'https://%';
1111-1212- -- Convert HTTP URLs to did:web: format
1313- -- http://172.28.0.3:8080 → did:web:172.28.0.3:8080
1414- UPDATE manifests
1515- SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 8)
1616- WHERE hold_endpoint LIKE 'http://%';
1717-1818- -- Entries already in did:web: format are left unchanged
1919- -- did:web:hold.example.com → did:web:hold.example.com (no change)
···11-description: Add readme_url to manifests (obsolete - kept for migration history)
22-query: |
33- -- This migration is obsolete. The readme_url and other annotations
44- -- are now stored in the repository_annotations table (see schema.sql).
55- -- Backfill will populate annotation data from PDS records.
66- -- This migration is kept as a no-op to maintain migration history.
77- SELECT 1;
···11-description: Remove annotation columns from manifests table
22-query: |
33- -- Drop annotation columns from manifests table (if they exist)
44- -- Annotations are now stored in repository_annotations table
55- -- SQLite doesn't support DROP COLUMN IF EXISTS, so we recreate the table
66-77- -- Create new manifests table without annotation columns
88- CREATE TABLE IF NOT EXISTS manifests_new (
99- id INTEGER PRIMARY KEY AUTOINCREMENT,
1010- did TEXT NOT NULL,
1111- repository TEXT NOT NULL,
1212- digest TEXT NOT NULL,
1313- hold_endpoint TEXT NOT NULL,
1414- schema_version INTEGER NOT NULL,
1515- media_type TEXT NOT NULL,
1616- config_digest TEXT,
1717- config_size INTEGER,
1818- created_at TIMESTAMP NOT NULL,
1919- UNIQUE(did, repository, digest),
2020- FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
2121- );
2222-2323- -- Copy data (only core fields, annotation columns are dropped)
2424- INSERT INTO manifests_new (id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, created_at)
2525- SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, created_at
2626- FROM manifests;
2727-2828- -- Swap tables
2929- DROP TABLE manifests;
3030- ALTER TABLE manifests_new RENAME TO manifests;
3131-3232- -- Recreate indexes
3333- CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
3434- CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
3535- CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
···11-description: Add repo_pages table and remove readme_cache
22-query: |
33- -- Create repo_pages table for storing repository page metadata
44- -- This replaces readme_cache with PDS-synced data
55- CREATE TABLE IF NOT EXISTS repo_pages (
66- did TEXT NOT NULL,
77- repository TEXT NOT NULL,
88- description TEXT,
99- avatar_cid TEXT,
1010- created_at TIMESTAMP NOT NULL,
1111- updated_at TIMESTAMP NOT NULL,
1212- PRIMARY KEY(did, repository),
1313- FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
1414- );
1515- CREATE INDEX IF NOT EXISTS idx_repo_pages_did ON repo_pages(did);
1616-1717- -- Drop readme_cache table (no longer needed)
1818- DROP TABLE IF EXISTS readme_cache;
···11-description: Add artifact_type column to manifests table for Helm chart support
22-query: |
33- -- Add artifact_type column to track manifest types (container-image, helm-chart, unknown)
44- -- Default to container-image for existing manifests
55- ALTER TABLE manifests ADD COLUMN artifact_type TEXT NOT NULL DEFAULT 'container-image';
66-77- -- Add index for filtering by artifact type
88- CREATE INDEX IF NOT EXISTS idx_manifests_artifact_type ON manifests(artifact_type);
···11-description: Add hold_crew_members table for cached crew memberships from Jetstream
22-query: |
33- -- Cached hold crew memberships from Jetstream
44- -- Enables reverse lookup: "which holds is user X a member of?"
55- CREATE TABLE IF NOT EXISTS hold_crew_members (
66- hold_did TEXT NOT NULL,
77- member_did TEXT NOT NULL,
88- rkey TEXT NOT NULL,
99- role TEXT,
1010- permissions TEXT, -- JSON array
1111- tier TEXT,
1212- added_at TEXT,
1313- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
1414- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
1515- PRIMARY KEY (hold_did, member_did)
1616- );
1717- CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did);
1818- CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did);
1919- CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey);
···11+description: Drop dead annotation/platform columns from manifests table
22+query: |
33+ -- Migration 0004 was supposed to drop these columns but either failed or
44+ -- was only recorded (not executed) on production. The 11 dead columns are:
55+ -- title, description, source_url, documentation_url, licenses,
66+ -- icon_url, readme_url, platform_os, platform_architecture,
77+ -- platform_variant, platform_os_version
88+ -- Annotations now live in repository_annotations; platform info lives in
99+ -- manifest_references. Recreate the table to match schema.sql.
1010+1111+ -- Create the clean table (matches schema.sql exactly)
1212+ CREATE TABLE IF NOT EXISTS manifests_new (
1313+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1414+ did TEXT NOT NULL,
1515+ repository TEXT NOT NULL,
1616+ digest TEXT NOT NULL,
1717+ hold_endpoint TEXT NOT NULL,
1818+ schema_version INTEGER NOT NULL,
1919+ media_type TEXT NOT NULL,
2020+ config_digest TEXT,
2121+ config_size INTEGER,
2222+ artifact_type TEXT NOT NULL DEFAULT 'container-image',
2323+ created_at TIMESTAMP NOT NULL,
2424+ UNIQUE(did, repository, digest),
2525+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
2626+ );
2727+2828+ -- Copy data (only the columns we keep)
2929+ INSERT INTO manifests_new (id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, artifact_type, created_at)
3030+ SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, COALESCE(artifact_type, 'container-image'), created_at
3131+ FROM manifests;
3232+3333+ -- Swap tables
3434+ DROP TABLE manifests;
3535+ ALTER TABLE manifests_new RENAME TO manifests;
3636+3737+ -- Recreate indexes (matches schema.sql)
3838+ CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
3939+ CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
4040+ CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
4141+ CREATE INDEX IF NOT EXISTS idx_manifests_artifact_type ON manifests(artifact_type);
-21
pkg/appview/db/queries.go
···15511551 return err
15521552}
1553155315541554-// IncrementStarCount increments the star count for a repository
15551555-func IncrementStarCount(db *sql.DB, did, repository string) error {
15561556- _, err := db.Exec(`
15571557- INSERT INTO repository_stats (did, repository, star_count)
15581558- VALUES (?, ?, 1)
15591559- ON CONFLICT(did, repository) DO UPDATE SET
15601560- star_count = star_count + 1
15611561- `, did, repository)
15621562- return err
15631563-}
15641564-15651565-// DecrementStarCount decrements the star count for a repository
15661566-func DecrementStarCount(db *sql.DB, did, repository string) error {
15671567- _, err := db.Exec(`
15681568- UPDATE repository_stats
15691569- SET star_count = MAX(0, star_count - 1)
15701570- WHERE did = ? AND repository = ?
15711571- `, did, repository)
15721572- return err
15731573-}
15741574-15751554// UpsertStar inserts or updates a star record (idempotent)
15761555func UpsertStar(db *sql.DB, starrerDID, ownerDID, repository string, createdAt time.Time) error {
15771556 _, err := db.Exec(`
+54-11
pkg/appview/db/schema.go
···273273}
274274275275// splitSQLStatements splits a SQL query into individual statements.
276276-// It handles semicolons as statement separators and filters out empty statements.
276276+// It splits on semicolons that are not inside -- line comments or 'string literals'.
277277func splitSQLStatements(query string) []string {
278278 var statements []string
279279+ var current strings.Builder
280280+ inLineComment := false
281281+ inString := false
279282280280- // Split on semicolons
281281- for part := range strings.SplitSeq(query, ";") {
282282- // Trim whitespace
283283- stmt := strings.TrimSpace(part)
283283+ for i := 0; i < len(query); i++ {
284284+ ch := query[i]
285285+286286+ if inLineComment {
287287+ current.WriteByte(ch)
288288+ if ch == '\n' {
289289+ inLineComment = false
290290+ }
291291+ continue
292292+ }
284293285285- // Skip empty statements (could be trailing semicolon or comment-only)
286286- if stmt == "" {
294294+ if inString {
295295+ current.WriteByte(ch)
296296+ if ch == '\'' {
297297+ // Check for escaped quote ('')
298298+ if i+1 < len(query) && query[i+1] == '\'' {
299299+ current.WriteByte(query[i+1])
300300+ i++
301301+ } else {
302302+ inString = false
303303+ }
304304+ }
287305 continue
288306 }
289307290290- // Skip comment-only statements
308308+ switch {
309309+ case ch == '-' && i+1 < len(query) && query[i+1] == '-':
310310+ inLineComment = true
311311+ current.WriteByte(ch)
312312+ case ch == '\'':
313313+ inString = true
314314+ current.WriteByte(ch)
315315+ case ch == ';':
316316+ // Statement boundary — flush if non-empty
317317+ stmt := strings.TrimSpace(current.String())
318318+ if stmt != "" {
319319+ statements = append(statements, stmt)
320320+ }
321321+ current.Reset()
322322+ default:
323323+ current.WriteByte(ch)
324324+ }
325325+ }
326326+327327+ // Flush trailing statement
328328+ if stmt := strings.TrimSpace(current.String()); stmt != "" {
329329+ statements = append(statements, stmt)
330330+ }
331331+332332+ // Filter out comment-only statements
333333+ filtered := statements[:0]
334334+ for _, stmt := range statements {
291335 hasCode := false
292336 for line := range strings.SplitSeq(stmt, "\n") {
293337 trimmed := strings.TrimSpace(line)
···296340 break
297341 }
298342 }
299299-300343 if hasCode {
301301- statements = append(statements, stmt)
344344+ filtered = append(filtered, stmt)
302345 }
303346 }
304347305305- return statements
348348+ return filtered
306349}
307350308351// parseMigrationFilename extracts version and name from migration filename
+18
pkg/appview/db/schema_test.go
···5555 expected: nil,
5656 },
5757 {
5858+ name: "semicolon inside comment",
5959+ query: `-- Annotations live in repository_annotations; platform info lives in
6060+ -- manifest_references.
6161+ CREATE TABLE foo (id INTEGER);`,
6262+ expected: []string{
6363+ "-- Annotations live in repository_annotations; platform info lives in\n -- manifest_references.\n CREATE TABLE foo (id INTEGER)",
6464+ },
6565+ },
6666+ {
6767+ name: "semicolon inside string literal",
6868+ query: `INSERT INTO foo VALUES ('hello; world');
6969+SELECT 1;`,
7070+ expected: []string{
7171+ "INSERT INTO foo VALUES ('hello; world')",
7272+ "SELECT 1",
7373+ },
7474+ },
7575+ {
5876 name: "migration 0005 format",
5977 query: `-- Add is_attestation column to track attestation manifests
6078-- Attestation manifests have vnd.docker.reference.type = "attestation-manifest"
+18-17
pkg/appview/handlers/storage.go
···3333 return
3434 }
35353636- // Create ATProto client with session provider
3737- client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
3838-3939- // Get user's sailor profile to find their default hold
4040- profile, err := storage.GetProfile(r.Context(), client)
4141- if err != nil {
4242- slog.Warn("Failed to get profile for storage quota", "did", user.DID, "error", err)
4343- h.renderError(w, "Failed to load profile")
4444- return
4545- }
4646-4747- if profile == nil || profile.DefaultHold == "" {
4848- // No default hold configured - can't check quota
4949- h.renderNoHold(w)
5050- return
3636+ // Use hold_did query param if provided (for previewing other holds),
3737+ // otherwise fall back to the user's saved default hold from their profile.
3838+ holdDID := r.URL.Query().Get("hold_did")
3939+ if holdDID == "" {
4040+ client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
4141+ profile, err := storage.GetProfile(r.Context(), client)
4242+ if err != nil {
4343+ slog.Warn("Failed to get profile for storage quota", "did", user.DID, "error", err)
4444+ h.renderError(w, "Failed to load profile")
4545+ return
4646+ }
4747+ if profile == nil || profile.DefaultHold == "" {
4848+ h.renderNoHold(w)
4949+ return
5050+ }
5151+ holdDID = profile.DefaultHold
5152 }
52535354 // Resolve hold URL from DID
5454- holdURL := atproto.ResolveHoldURL(profile.DefaultHold)
5555+ holdURL := atproto.ResolveHoldURL(holdDID)
5556 if holdURL == "" {
5656- slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", profile.DefaultHold)
5757+ slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", holdDID)
5758 h.renderError(w, "Failed to resolve hold service")
5859 return
5960 }
+43-23
pkg/appview/handlers/subscription.go
···2525 SubscriptionID string `json:"subscriptionId,omitempty"`
2626 BillingInterval string `json:"billingInterval,omitempty"`
2727 Error string `json:"error,omitempty"`
2828+ HideBilling bool `json:"-"` // hide entire section (no billing support)
2929+ HoldDisplayName string `json:"-"` // human-readable hold name for display
2830}
29313032// TierInfo mirrors the hold's billing.TierInfo.
···4850func (h *SubscriptionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
4951 user := middleware.GetUser(r)
5052 if user == nil {
5151- h.renderError(w, "Unauthorized")
5353+ h.renderHidden(w)
5254 return
5355 }
54565555- // Get user's default hold
5656- client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
5757- profile, err := storage.GetProfile(r.Context(), client)
5858- if err != nil {
5959- slog.Warn("Failed to get profile for subscription", "did", user.DID, "error", err)
6060- h.renderError(w, "Failed to load profile")
6161- return
6262- }
6363-6464- // Determine hold endpoint
6565- holdDID := h.DefaultHoldDID
6666- if profile != nil && profile.DefaultHold != "" {
6767- holdDID = profile.DefaultHold
5757+ // Use hold_did query param if provided (for previewing other holds),
5858+ // otherwise fall back to the user's saved default hold from their profile.
5959+ holdDID := r.URL.Query().Get("hold_did")
6060+ if holdDID == "" {
6161+ client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
6262+ profile, err := storage.GetProfile(r.Context(), client)
6363+ if err != nil {
6464+ slog.Warn("Failed to get profile for subscription", "did", user.DID, "error", err)
6565+ h.renderHidden(w)
6666+ return
6767+ }
6868+ holdDID = h.DefaultHoldDID
6969+ if profile != nil && profile.DefaultHold != "" {
7070+ holdDID = profile.DefaultHold
7171+ }
6872 }
69737074 if holdDID == "" {
7171- h.renderError(w, "No default hold configured")
7575+ h.renderHidden(w)
7276 return
7377 }
7478···7680 holdEndpoint := atproto.ResolveHoldURL(holdDID)
7781 if holdEndpoint == "" {
7882 slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID)
7979- h.renderError(w, "Failed to resolve hold")
8383+ h.renderHidden(w)
8084 return
8185 }
8286···8589 resp, err := http.Get(subURL)
8690 if err != nil {
8791 slog.Warn("Failed to fetch subscription info", "url", subURL, "error", err)
8888- h.renderError(w, "Failed to connect to hold")
9292+ h.renderHidden(w)
8993 return
9094 }
9195 defer resp.Body.Close()
92969397 if resp.StatusCode != http.StatusOK {
9498 slog.Warn("Hold returned error for subscription", "status", resp.StatusCode)
9595- h.renderError(w, "Hold does not support billing")
9999+ h.renderHidden(w)
96100 return
97101 }
9810299103 var info SubscriptionInfo
100104 if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
101105 slog.Warn("Failed to decode subscription info", "error", err)
102102- h.renderError(w, "Invalid response from hold")
106106+ h.renderHidden(w)
107107+ return
108108+ }
109109+110110+ if !info.PaymentsEnabled {
111111+ h.renderHidden(w)
103112 return
104113 }
114114+115115+ // Set hold display name so users know which hold the subscription applies to
116116+ info.HoldDisplayName = deriveDisplayName(holdDID)
105117106118 // Format prices for display
107119 // Note: -1 means "has price, fetch from Stripe" (placeholder from hold)
···135147 }
136148}
137149150150+func (h *SubscriptionHandler) renderHidden(w http.ResponseWriter) {
151151+ w.Header().Set("Content-Type", "text/html")
152152+ info := SubscriptionInfo{HideBilling: true}
153153+ if err := h.Templates.ExecuteTemplate(w, "subscription_info", info); err != nil {
154154+ slog.Error("Failed to render hidden subscription template", "error", err)
155155+ }
156156+}
157157+138158func (h *SubscriptionHandler) renderError(w http.ResponseWriter, message string) {
139159 w.Header().Set("Content-Type", "text/html")
140160 fmt.Fprintf(w, `<div class="alert alert-error"><svg class="icon size-5" aria-hidden="true"><use href="/icons.svg#alert-circle"></use></svg> %s</div>`, message)
···148168func (h *SubscriptionCheckoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
149169 user := middleware.GetUser(r)
150170 if user == nil {
151151- http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound)
171171+ http.Redirect(w, r, "/auth/oauth/login?return_to=/settings%23storage", http.StatusFound)
152172 return
153173 }
154174···195215 checkoutURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.createCheckoutSession", holdEndpoint)
196216 reqBody := map[string]string{
197217 "tier": tier,
198198- "returnUrl": h.SiteURL + "/settings",
218218+ "returnUrl": h.SiteURL + "/settings#storage",
199219 }
200220 bodyBytes, _ := json.Marshal(reqBody)
201221···242262func (h *SubscriptionPortalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
243263 user := middleware.GetUser(r)
244264 if user == nil {
245245- http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound)
265265+ http.Redirect(w, r, "/auth/oauth/login?return_to=/settings%23storage", http.StatusFound)
246266 return
247267 }
248268···280300 }
281301282302 // Call hold's portal endpoint
283283- portalURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=%s/settings", holdEndpoint, h.SiteURL)
303303+ portalURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=%s/settings%%23storage", holdEndpoint, h.SiteURL)
284304285305 req, err := http.NewRequestWithContext(r.Context(), "GET", portalURL, nil)
286306 if err != nil {