+2
-1
pkg/appview/db/models.go
+2
-1
pkg/appview/db/models.go
···
154
154
Tag
155
155
Platforms []PlatformInfo
156
156
IsMultiArch bool
157
-
HasAttestations bool // true if manifest list contains attestation references
157
+
HasAttestations bool // true if manifest list contains attestation references
158
+
ArtifactType string // container-image, helm-chart, unknown
158
159
}
159
160
160
161
// ManifestWithMetadata extends Manifest with tags and platform information
+6
-4
pkg/appview/db/queries.go
+6
-4
pkg/appview/db/queries.go
···
653
653
t.digest,
654
654
t.created_at,
655
655
m.media_type,
656
+
m.artifact_type,
656
657
COALESCE(mr.platform_os, '') as platform_os,
657
658
COALESCE(mr.platform_architecture, '') as platform_architecture,
658
659
COALESCE(mr.platform_variant, '') as platform_variant,
···
676
677
677
678
for rows.Next() {
678
679
var t Tag
679
-
var mediaType, platformOS, platformArch, platformVariant, platformOSVersion string
680
+
var mediaType, artifactType, platformOS, platformArch, platformVariant, platformOSVersion string
680
681
var isAttestation bool
681
682
682
683
if err := rows.Scan(&t.ID, &t.DID, &t.Repository, &t.Tag, &t.Digest, &t.CreatedAt,
683
-
&mediaType, &platformOS, &platformArch, &platformVariant, &platformOSVersion, &isAttestation); err != nil {
684
+
&mediaType, &artifactType, &platformOS, &platformArch, &platformVariant, &platformOSVersion, &isAttestation); err != nil {
684
685
return nil, err
685
686
}
686
687
···
688
689
tagKey := t.Tag
689
690
if _, exists := tagMap[tagKey]; !exists {
690
691
tagMap[tagKey] = &TagWithPlatforms{
691
-
Tag: t,
692
-
Platforms: []PlatformInfo{},
692
+
Tag: t,
693
+
Platforms: []PlatformInfo{},
694
+
ArtifactType: artifactType,
693
695
}
694
696
tagOrder = append(tagOrder, tagKey)
695
697
}
+62
-6
pkg/appview/db/schema.go
+62
-6
pkg/appview/db/schema.go
···
37
37
return nil, err
38
38
}
39
39
40
-
// Create schema from embedded SQL file
41
-
if _, err := db.Exec(schemaSQL); err != nil {
42
-
return nil, err
40
+
// Check if this is an existing database with migrations applied
41
+
isExisting, err := hasAppliedMigrations(db)
42
+
if err != nil {
43
+
return nil, fmt.Errorf("failed to check database state: %w", err)
44
+
}
45
+
46
+
if isExisting {
47
+
// Existing database: skip schema.sql, only run pending migrations
48
+
slog.Debug("Existing database detected, skipping schema.sql")
49
+
} else {
50
+
// Fresh database: apply schema.sql
51
+
slog.Info("Fresh database detected, applying schema")
52
+
if err := applySchema(db); err != nil {
53
+
return nil, err
54
+
}
43
55
}
44
56
45
57
// Run migrations unless skipped
58
+
// For fresh databases, migrations are recorded but not executed (schema.sql is already complete)
46
59
if !skipMigrations {
47
-
if err := runMigrations(db); err != nil {
60
+
if err := runMigrations(db, !isExisting); err != nil {
48
61
return nil, err
49
62
}
50
63
}
···
52
65
return db, nil
53
66
}
54
67
68
+
// hasAppliedMigrations checks if this is an existing database with migrations applied
69
+
func hasAppliedMigrations(db *sql.DB) (bool, error) {
70
+
// Check if schema_migrations table exists
71
+
var count int
72
+
err := db.QueryRow(`
73
+
SELECT COUNT(*) FROM sqlite_master
74
+
WHERE type='table' AND name='schema_migrations'
75
+
`).Scan(&count)
76
+
if err != nil {
77
+
return false, err
78
+
}
79
+
if count == 0 {
80
+
return false, nil // No migrations table = fresh DB
81
+
}
82
+
83
+
// Table exists, check if it has entries
84
+
err = db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count)
85
+
if err != nil {
86
+
return false, err
87
+
}
88
+
return count > 0, nil
89
+
}
90
+
91
+
// applySchema executes schema.sql for fresh databases
92
+
func applySchema(db *sql.DB) error {
93
+
for _, stmt := range splitSQLStatements(schemaSQL) {
94
+
if _, err := db.Exec(stmt); err != nil {
95
+
return fmt.Errorf("failed to apply schema: %w", err)
96
+
}
97
+
}
98
+
return nil
99
+
}
100
+
55
101
// Migration represents a database migration
56
102
type Migration struct {
57
103
Version int
···
61
107
}
62
108
63
109
// runMigrations applies any pending database migrations
64
-
func runMigrations(db *sql.DB) error {
110
+
// If freshDB is true, migrations are recorded but not executed (schema.sql already includes their changes)
111
+
func runMigrations(db *sql.DB, freshDB bool) error {
65
112
// Load migrations from files
66
113
migrations, err := loadMigrations()
67
114
if err != nil {
···
86
133
continue
87
134
}
88
135
89
-
// Apply migration in a transaction
136
+
if freshDB {
137
+
// Fresh database: schema.sql already has everything, just record the migration
138
+
slog.Debug("Recording migration as applied (fresh DB)", "version", m.Version, "name", m.Name)
139
+
if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", m.Version); err != nil {
140
+
return fmt.Errorf("failed to record migration %d: %w", m.Version, err)
141
+
}
142
+
continue
143
+
}
144
+
145
+
// Existing database: apply migration in a transaction
90
146
slog.Info("Applying migration", "version", m.Version, "name", m.Name, "description", m.Description)
91
147
92
148
tx, err := db.Begin()
+6
-3
pkg/appview/handlers/repository.go
+6
-3
pkg/appview/handlers/repository.go
···
231
231
}
232
232
}
233
233
234
-
// Determine dominant artifact type from manifests
234
+
// Determine artifact type for header section from first tag
235
+
// This is used for the "Pull this image/chart" header command
235
236
artifactType := "container-image"
236
-
if len(manifests) > 0 {
237
-
// Use the most recent manifest's artifact type
237
+
if len(tagsWithPlatforms) > 0 {
238
+
artifactType = tagsWithPlatforms[0].ArtifactType
239
+
} else if len(manifests) > 0 {
240
+
// Fallback to manifests if no tags
238
241
artifactType = manifests[0].ArtifactType
239
242
}
240
243
+6
pkg/appview/storage/manifest_store.go
+6
pkg/appview/storage/manifest_store.go
···
124
124
return "", fmt.Errorf("failed to create manifest record: %w", err)
125
125
}
126
126
127
+
// OCI spec allows omitting mediaType from the manifest body (inferred from Content-Type header)
128
+
// Helm charts typically omit it, so use the media type from the request if body is empty
129
+
if manifestRecord.MediaType == "" && mediaType != "" {
130
+
manifestRecord.MediaType = mediaType
131
+
}
132
+
127
133
// Set the blob reference, hold DID, and hold endpoint
128
134
manifestRecord.ManifestBlob = blobRef
129
135
manifestRecord.HoldDID = s.ctx.HoldDID // Primary reference (DID)
+1
-1
pkg/appview/templates/pages/repository.html
+1
-1
pkg/appview/templates/pages/repository.html
···
183
183
{{ end }}
184
184
</div>
185
185
</div>
186
-
{{ if eq $.ArtifactType "helm-chart" }}
186
+
{{ if eq .ArtifactType "helm-chart" }}
187
187
{{ template "docker-command" (print "helm pull oci://" $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name " --version " .Tag.Tag) }}
188
188
{{ else }}
189
189
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .Tag.Tag) }}
+23
-5
pkg/auth/session.go
+23
-5
pkg/auth/session.go
···
9
9
"crypto/sha256"
10
10
"encoding/hex"
11
11
"encoding/json"
12
+
"errors"
12
13
"fmt"
13
14
"io"
14
15
"log/slog"
···
17
18
"time"
18
19
19
20
"atcr.io/pkg/atproto"
21
+
)
22
+
23
+
// Sentinel errors for authentication failures
24
+
var (
25
+
// ErrIdentityResolution indicates handle/DID resolution failed
26
+
ErrIdentityResolution = errors.New("identity resolution failed")
27
+
// ErrInvalidCredentials indicates PDS returned 401 (bad password/app-password)
28
+
ErrInvalidCredentials = errors.New("invalid credentials")
29
+
// ErrPDSUnavailable indicates PDS is unreachable or returned a server error
30
+
ErrPDSUnavailable = errors.New("PDS unavailable")
20
31
)
21
32
22
33
// CachedSession represents a cached session
···
99
110
// Resolve identifier to PDS endpoint
100
111
_, _, pds, err := atproto.ResolveIdentity(ctx, identifier)
101
112
if err != nil {
102
-
return "", "", "", err
113
+
return "", "", "", fmt.Errorf("%w: %v", ErrIdentityResolution, err)
103
114
}
104
115
105
116
// Create session
106
117
sessionResp, err := v.createSession(ctx, pds, identifier, password)
107
118
if err != nil {
108
-
return "", "", "", fmt.Errorf("authentication failed: %w", err)
119
+
// Pass through typed errors from createSession
120
+
return "", "", "", err
109
121
}
110
122
111
123
// Cache the session (ATProto sessions typically last 2 hours)
···
146
158
resp, err := v.httpClient.Do(req)
147
159
if err != nil {
148
160
slog.Debug("Session creation HTTP request failed", "error", err)
149
-
return nil, fmt.Errorf("failed to create session: %w", err)
161
+
return nil, fmt.Errorf("%w: %v", ErrPDSUnavailable, err)
150
162
}
151
163
defer resp.Body.Close()
152
164
···
155
167
if resp.StatusCode == http.StatusUnauthorized {
156
168
bodyBytes, _ := io.ReadAll(resp.Body)
157
169
slog.Debug("Session creation unauthorized", "response", string(bodyBytes))
158
-
return nil, fmt.Errorf("invalid credentials")
170
+
return nil, ErrInvalidCredentials
171
+
}
172
+
173
+
if resp.StatusCode >= 500 {
174
+
bodyBytes, _ := io.ReadAll(resp.Body)
175
+
slog.Debug("PDS server error", "status", resp.StatusCode, "response", string(bodyBytes))
176
+
return nil, fmt.Errorf("%w: server returned %d", ErrPDSUnavailable, resp.StatusCode)
159
177
}
160
178
161
179
if resp.StatusCode != http.StatusOK {
162
180
bodyBytes, _ := io.ReadAll(resp.Body)
163
181
slog.Debug("Session creation failed", "status", resp.StatusCode, "response", string(bodyBytes))
164
-
return nil, fmt.Errorf("create session failed with status %d: %s", resp.StatusCode, string(bodyBytes))
182
+
return nil, fmt.Errorf("%w: unexpected status %d: %s", ErrPDSUnavailable, resp.StatusCode, string(bodyBytes))
165
183
}
166
184
167
185
var sessionResp SessionResponse
+15
-2
pkg/auth/token/handler.go
+15
-2
pkg/auth/token/handler.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
7
8
"log/slog"
8
9
"net/http"
···
194
195
slog.Debug("Trying app password authentication", "username", username)
195
196
did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password)
196
197
if err != nil {
197
-
slog.Debug("App password validation failed", "error", err, "username", username)
198
-
sendAuthError(w, r, "authentication failed")
198
+
// Log at WARN level with specific error type
199
+
if errors.Is(err, auth.ErrIdentityResolution) {
200
+
slog.Warn("Identity resolution failed", "error", err, "username", username)
201
+
sendAuthError(w, r, "authentication failed: could not resolve handle")
202
+
} else if errors.Is(err, auth.ErrInvalidCredentials) {
203
+
slog.Warn("Invalid credentials", "username", username)
204
+
sendAuthError(w, r, "authentication failed: invalid credentials")
205
+
} else if errors.Is(err, auth.ErrPDSUnavailable) {
206
+
slog.Warn("PDS unavailable", "error", err, "username", username)
207
+
sendAuthError(w, r, "authentication failed: PDS unavailable")
208
+
} else {
209
+
slog.Warn("Authentication failed", "error", err, "username", username)
210
+
sendAuthError(w, r, "authentication failed")
211
+
}
199
212
return
200
213
}
201
214