diff --git a/cmd/registry/serve.go b/cmd/registry/serve.go
index 5d95553..48a1f50 100644
--- a/cmd/registry/serve.go
+++ b/cmd/registry/serve.go
@@ -24,11 +24,9 @@ import (
// UI components
"atcr.io/pkg/appview"
"atcr.io/pkg/appview/db"
- "atcr.io/pkg/appview/device"
uihandlers "atcr.io/pkg/appview/handlers"
"atcr.io/pkg/appview/jetstream"
appmiddleware "atcr.io/pkg/appview/middleware"
- appsession "atcr.io/pkg/appview/session"
"github.com/gorilla/mux"
)
@@ -65,49 +63,23 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to parse configuration: %w", err)
}
+ // Initialize UI database first (required for all stores)
+ fmt.Println("Initializing UI database...")
+ uiDatabase, uiSessionStore := initializeDatabase(config)
+ if uiDatabase == nil {
+ return fmt.Errorf("failed to initialize UI database - required for session storage")
+ }
+
// Initialize OAuth components
fmt.Println("Initializing OAuth components...")
- // 1. Create OAuth session storage
- // Allow override via environment variable for Docker deployments
- storagePath := os.Getenv("ATCR_TOKEN_STORAGE_PATH")
- if storagePath == "" {
- var err error
- storagePath, err = oauth.GetDefaultStorePath()
- if err != nil {
- return fmt.Errorf("failed to get storage path: %w", err)
- }
- }
+ // 1. Create OAuth session storage (SQLite-backed)
+ oauthStore := db.NewOAuthStore(uiDatabase)
+ fmt.Println("Using SQLite for OAuth session storage")
- // Ensure directory exists
- storageDir := filepath.Dir(storagePath)
- if err := os.MkdirAll(storageDir, 0700); err != nil {
- return fmt.Errorf("failed to create storage directory: %w", err)
- }
-
- fmt.Printf("Using OAuth session storage path: %s\n", storagePath)
-
- oauthStore, err := oauth.NewFileStore(storagePath)
- if err != nil {
- return fmt.Errorf("failed to create OAuth store: %w", err)
- }
-
- // 2. Create device store
- deviceStorePath := filepath.Join(filepath.Dir(storagePath), "devices.json")
- deviceStore, err := device.NewStore(deviceStorePath)
- if err != nil {
- return fmt.Errorf("failed to create device store: %w", err)
- }
- fmt.Printf("Using device storage path: %s\n", deviceStorePath)
-
- // Start background cleanup for expired pending authorizations
- go func() {
- ticker := time.NewTicker(5 * time.Minute)
- defer ticker.Stop()
- for range ticker.C {
- deviceStore.CleanupExpired()
- }
- }()
+ // 2. Create device store (SQLite-backed)
+ deviceStore := db.NewDeviceStore(uiDatabase)
+ fmt.Println("Using SQLite for device storage")
// 3. Get base URL from config or environment
baseURL := os.Getenv("ATCR_BASE_URL")
@@ -135,8 +107,8 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
// 6. Set global refresher for middleware
middleware.SetGlobalRefresher(refresher)
- // 7. Initialize UI components (get session store for OAuth integration)
- uiDatabase, uiSessionStore, uiTemplates, uiRouter := initializeUI(config, oauthApp, refresher, baseURL, deviceStore)
+ // 7. Initialize UI routes with OAuth app, refresher, and device store
+ uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiSessionStore, oauthApp, refresher, baseURL, deviceStore)
// 8. Create OAuth server
oauthServer := oauth.NewServer(oauthApp)
@@ -147,9 +119,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
oauthServer.SetUISessionStore(uiSessionStore)
}
// Connect database for user avatar management
- if uiDatabase != nil {
- oauthServer.SetDatabase(uiDatabase)
- }
+ oauthServer.SetDatabase(uiDatabase)
// 8. Initialize auth keys and create token issuer
var issuer *token.Issuer
@@ -176,7 +146,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
mux.Handle("/v2/", app)
// Mount UI routes if enabled
- if uiDatabase != nil && uiSessionStore != nil && uiTemplates != nil && uiRouter != nil {
+ if uiSessionStore != nil && uiTemplates != nil && uiRouter != nil {
// Mount static files
mux.Handle("/static/", http.StripPrefix("/static/", appview.StaticHandler()))
@@ -349,12 +319,12 @@ func extractDefaultHoldEndpoint(config *configuration.Configuration) string {
return ""
}
-// initializeUI initializes the web UI components
-func initializeUI(config *configuration.Configuration, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *device.Store) (*sql.DB, *appsession.Store, *template.Template, *mux.Router) {
+// initializeDatabase initializes the SQLite database and session store
+func initializeDatabase(config *configuration.Configuration) (*sql.DB, *db.SessionStore) {
// Check if UI is enabled (optional configuration)
uiEnabled := os.Getenv("ATCR_UI_ENABLED")
if uiEnabled == "false" {
- return nil, nil, nil, nil
+ return nil, nil
}
// Get database path
@@ -367,39 +337,58 @@ func initializeUI(config *configuration.Configuration, oauthApp *oauth.App, refr
dbDir := filepath.Dir(dbPath)
if err := os.MkdirAll(dbDir, 0700); err != nil {
fmt.Printf("Warning: Failed to create UI database directory: %v\n", err)
- return nil, nil, nil, nil
+ return nil, nil
}
// Initialize database
database, err := db.InitDB(dbPath)
if err != nil {
fmt.Printf("Warning: Failed to initialize UI database: %v\n", err)
- return nil, nil, nil, nil
+ return nil, nil
}
fmt.Printf("UI database initialized at %s\n", dbPath)
- // Create session store with file persistence
- sessionStorePath := os.Getenv("ATCR_UI_SESSION_PATH")
- if sessionStorePath == "" {
- sessionStorePath = "/var/lib/atcr/ui-sessions.json"
- }
- sessionStore := appsession.NewStore(sessionStorePath)
+ // Create SQLite-backed session store
+ sessionStore := db.NewSessionStore(database)
- // Start cleanup goroutine
+ // Start cleanup goroutines for all SQLite stores
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
+ ctx := context.Background()
+
+ // Cleanup UI sessions
sessionStore.Cleanup()
+
+ // Cleanup OAuth sessions (older than 30 days)
+ oauthStore := db.NewOAuthStore(database)
+ oauthStore.CleanupOldSessions(ctx, 30*24*time.Hour)
+ oauthStore.CleanupExpiredAuthRequests(ctx)
+
+ // Cleanup device pending auths
+ deviceStore := db.NewDeviceStore(database)
+ deviceStore.CleanupExpired()
}
}()
+ return database, sessionStore
+}
+
+// initializeUIRoutes initializes the web UI routes
+func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore) (*template.Template, *mux.Router) {
+ // Check if UI is enabled
+ uiEnabled := os.Getenv("ATCR_UI_ENABLED")
+ if uiEnabled == "false" {
+ return nil, nil
+ }
+
// Load templates
templates, err := appview.Templates()
if err != nil {
fmt.Printf("Warning: Failed to load UI templates: %v\n", err)
- return nil, nil, nil, nil
+ return nil, nil
}
// Create router
@@ -441,6 +430,14 @@ func initializeUI(config *configuration.Configuration, oauthApp *oauth.App, refr
},
)).Methods("GET")
+ router.Handle("/r/{handle}/{repository}", appmiddleware.OptionalAuth(sessionStore, database)(
+ &uihandlers.RepositoryPageHandler{
+ DB: database,
+ Templates: templates,
+ RegistryURL: uihandlers.TrimRegistryURL(baseURL),
+ },
+ )).Methods("GET")
+
// Authenticated routes
authRouter := router.NewRoute().Subrouter()
authRouter.Use(appmiddleware.RequireAuth(sessionStore, database))
@@ -493,10 +490,10 @@ func initializeUI(config *configuration.Configuration, oauthApp *oauth.App, refr
// Logout endpoint
router.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) {
- if sessionID, ok := appsession.GetSessionID(r); ok {
+ if sessionID, ok := db.GetSessionID(r); ok {
sessionStore.Delete(sessionID)
}
- appsession.ClearCookie(w)
+ db.ClearCookie(w)
http.Redirect(w, r, "/", http.StatusFound)
}).Methods("POST")
@@ -568,5 +565,5 @@ func initializeUI(config *configuration.Configuration, oauthApp *oauth.App, refr
}
}
- return database, sessionStore, templates, router
+ return templates, router
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 00fa286..fff5af5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,15 +8,12 @@ services:
ports:
- "5000:5000"
environment:
- - ATCR_TOKEN_STORAGE_PATH=/var/lib/atcr/tokens/oauth-tokens.json
- ATCR_UI_ENABLED=true
- ATCR_BACKFILL_ENABLED=true
volumes:
# Auth keys (JWT signing keys)
- atcr-auth:/var/lib/atcr/auth
- # OAuth refresh tokens (persists user sessions across container restarts)
- - atcr-tokens:/var/lib/atcr/tokens
- # UI database (firehose cache for web interface)
+ # UI database (includes OAuth sessions, devices, and firehose cache)
- atcr-ui:/var/lib/atcr
restart: unless-stopped
dns:
@@ -66,5 +63,4 @@ networks:
volumes:
atcr-hold:
atcr-auth:
- atcr-tokens:
atcr-ui:
diff --git a/docs/README_EMBEDDING.md b/docs/README_EMBEDDING.md
new file mode 100644
index 0000000..541641c
--- /dev/null
+++ b/docs/README_EMBEDDING.md
@@ -0,0 +1,183 @@
+# README Embedding Feature
+
+## Overview
+
+Enhance the repository page (`/r/{handle}/{repository}`) with embedded README content fetched from the source repository, similar to Docker Hub's "Overview" tab.
+
+## Current State
+
+The repository page currently shows:
+- Repository metadata from OCI annotations
+- Short description from `org.opencontainers.image.description`
+- External links to source (`org.opencontainers.image.source`) and docs (`org.opencontainers.image.documentation`)
+- Tags and manifests lists
+
+## Proposed Feature
+
+Automatically fetch and render README.md content from the source repository when available, displaying it in an "Overview" section on the repository page.
+
+## Implementation Approach
+
+### 1. Source URL Detection
+
+Parse `org.opencontainers.image.source` annotation to detect GitHub repositories:
+- Pattern: `https://github.com/{owner}/{repo}`
+- Extract owner and repo name
+
+### 2. README Fetching
+
+Fetch README.md from GitHub via raw content URL:
+```
+https://raw.githubusercontent.com/{owner}/{repo}/{branch}/README.md
+```
+
+Try multiple branch names in order:
+1. `main`
+2. `master`
+3. `develop`
+
+Fallback if README not found or fetch fails.
+
+### 3. Markdown Rendering
+
+Use a Go markdown library to render README content:
+- **Option A**: `github.com/gomarkdown/markdown` - Pure Go, fast
+- **Option B**: `github.com/yuin/goldmark` - CommonMark compliant, extensible
+- **Option C**: Call GitHub's markdown API (requires network call)
+
+Recommended: `goldmark` for CommonMark compliance and GitHub-flavored markdown support.
+
+### 4. Caching Strategy
+
+Cache rendered README to avoid repeated fetches:
+
+**Option A: In-memory cache**
+- Simple, fast
+- Lost on restart
+- Good for MVP
+
+**Option B: Database cache**
+- Add `readme_html` column to `manifests` table
+- Update on new manifest pushes
+- Persistent across restarts
+- Background job to refresh periodically
+
+**Option C: Hybrid**
+- Cache in database
+- Also cache in memory for frequently accessed repos
+- TTL-based refresh (e.g., 1 hour)
+
+### 5. UI Integration
+
+Add "Overview" section to repository page:
+- Show after repository header, before tags/manifests
+- Render markdown as HTML
+- Apply CSS styling for markdown elements (headings, code blocks, tables, etc.)
+- Handle images in README (may need to proxy or allow external images)
+
+## Implementation Steps
+
+1. **Add README fetcher** (`pkg/appview/readme/fetcher.go`)
+ ```go
+ type Fetcher struct {
+ httpClient *http.Client
+ cache Cache
+ }
+
+ func (f *Fetcher) FetchGitHubReadme(sourceURL string) (string, error)
+ func (f *Fetcher) RenderMarkdown(content string) (string, error)
+ ```
+
+2. **Update database schema** (optional, for caching)
+ ```sql
+ ALTER TABLE manifests ADD COLUMN readme_html TEXT;
+ ALTER TABLE manifests ADD COLUMN readme_fetched_at TIMESTAMP;
+ ```
+
+3. **Update RepositoryPageHandler**
+ - Fetch README for repository
+ - Pass rendered HTML to template
+
+4. **Update repository.html template**
+ - Add "Overview" section
+ - Render HTML safely (use `template.HTML`)
+
+5. **Add markdown CSS**
+ - Style headings, code blocks, lists, tables
+ - Syntax highlighting for code blocks (optional)
+
+## Security Considerations
+
+1. **XSS Prevention**
+ - Sanitize HTML output from markdown renderer
+ - Use `bluemonday` or similar HTML sanitizer
+ - Only allow safe HTML elements and attributes
+
+2. **Rate Limiting**
+ - Cache aggressively to avoid hitting GitHub rate limits
+ - Consider GitHub API instead of raw content (requires token but higher limits)
+ - Handle 429 responses gracefully
+
+3. **Image Handling**
+ - README may contain images with relative URLs
+ - Options:
+ - Rewrite image URLs to absolute GitHub URLs
+ - Proxy images through ATCR (caching, security)
+ - Block external images (simplest, but breaks many READMEs)
+
+4. **Content Size**
+ - Limit README size (e.g., 1MB max)
+ - Truncate very long READMEs with "View on GitHub" link
+
+## Future Enhancements
+
+1. **Support other platforms**
+ - GitLab: `https://gitlab.com/{owner}/{repo}/-/raw/{branch}/README.md`
+ - Gitea/Forgejo
+ - Bitbucket
+
+2. **Custom README upload**
+ - Allow users to upload custom README via UI
+ - Store in PDS as `io.atcr.readme` record
+ - Priority: custom > source repo
+
+3. **Automatic updates**
+ - Background job to refresh READMEs periodically
+ - Webhook support to update on push to source repo
+
+4. **Syntax highlighting**
+ - Use highlight.js or similar for code blocks
+ - Support multiple languages
+
+## Example Flow
+
+1. User pushes image with label: `org.opencontainers.image.source=https://github.com/alice/myapp`
+2. Manifest stored with source URL annotation
+3. User visits `/r/alice/myapp`
+4. RepositoryPageHandler:
+ - Checks cache for README
+ - If not cached or expired:
+ - Fetches `https://raw.githubusercontent.com/alice/myapp/main/README.md`
+ - Renders markdown to HTML
+ - Sanitizes HTML
+ - Caches result
+ - Passes README HTML to template
+5. Template renders Overview section with README content
+
+## Dependencies
+
+```go
+// Markdown rendering
+github.com/yuin/goldmark v1.6.0
+github.com/yuin/goldmark-emoji v1.0.2 // GitHub emoji support
+
+// HTML sanitization
+github.com/microcosm-cc/bluemonday v1.0.26
+```
+
+## References
+
+- [OCI Image Spec - Annotations](https://github.com/opencontainers/image-spec/blob/main/annotations.md)
+- [Docker Hub Overview tab behavior](https://hub.docker.com/)
+- [Goldmark documentation](https://github.com/yuin/goldmark)
+- [GitHub raw content URLs](https://raw.githubusercontent.com/)
diff --git a/pkg/appview/db/device_store.go b/pkg/appview/db/device_store.go
new file mode 100644
index 0000000..75d5580
--- /dev/null
+++ b/pkg/appview/db/device_store.go
@@ -0,0 +1,418 @@
+package db
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "encoding/base64"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ "golang.org/x/crypto/bcrypt"
+)
+
+// Device represents an authorized device
+type Device struct {
+ ID string `json:"id"`
+ DID string `json:"did"`
+ Handle string `json:"handle"`
+ Name string `json:"name"`
+ SecretHash string `json:"secret_hash"`
+ IPAddress string `json:"ip_address"`
+ Location string `json:"location"`
+ UserAgent string `json:"user_agent"`
+ CreatedAt time.Time `json:"created_at"`
+ LastUsed time.Time `json:"last_used"`
+}
+
+// PendingAuthorization represents a device awaiting user approval
+type PendingAuthorization struct {
+ DeviceCode string `json:"device_code"`
+ UserCode string `json:"user_code"`
+ DeviceName string `json:"device_name"`
+ IPAddress string `json:"ip_address"`
+ UserAgent string `json:"user_agent"`
+ ExpiresAt time.Time `json:"expires_at"`
+ ApprovedDID string `json:"approved_did"`
+ ApprovedAt time.Time `json:"approved_at"`
+ DeviceSecret string `json:"device_secret"`
+}
+
+// DeviceStore manages devices and pending authorizations with SQLite persistence
+type DeviceStore struct {
+ db *sql.DB
+}
+
+// NewDeviceStore creates a new SQLite-backed device store
+func NewDeviceStore(db *sql.DB) *DeviceStore {
+ return &DeviceStore{db: db}
+}
+
+// CreatePendingAuth creates a new pending device authorization
+func (s *DeviceStore) CreatePendingAuth(deviceName, ip, userAgent string) (*PendingAuthorization, error) {
+ // Generate device code (long, random)
+ deviceCodeBytes := make([]byte, 32)
+ if _, err := rand.Read(deviceCodeBytes); err != nil {
+ return nil, fmt.Errorf("failed to generate device code: %w", err)
+ }
+ deviceCode := base64.RawURLEncoding.EncodeToString(deviceCodeBytes)
+
+ // Generate user code (short, human-readable)
+ userCode := generateUserCode()
+
+ expiresAt := time.Now().Add(10 * time.Minute)
+
+ _, err := s.db.Exec(`
+ INSERT INTO pending_device_auth (device_code, user_code, device_name, ip_address, user_agent, expires_at, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
+ `, deviceCode, userCode, deviceName, ip, userAgent, expiresAt)
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to create pending auth: %w", err)
+ }
+
+ pending := &PendingAuthorization{
+ DeviceCode: deviceCode,
+ UserCode: userCode,
+ DeviceName: deviceName,
+ IPAddress: ip,
+ UserAgent: userAgent,
+ ExpiresAt: expiresAt,
+ }
+
+ return pending, nil
+}
+
+// GetPendingByUserCode retrieves a pending auth by user code
+func (s *DeviceStore) GetPendingByUserCode(userCode string) (*PendingAuthorization, bool) {
+ var pending PendingAuthorization
+
+ err := s.db.QueryRow(`
+ SELECT device_code, user_code, device_name, ip_address, user_agent, expires_at, approved_did, approved_at, device_secret
+ FROM pending_device_auth
+ WHERE user_code = ?
+ `, userCode).Scan(
+ &pending.DeviceCode,
+ &pending.UserCode,
+ &pending.DeviceName,
+ &pending.IPAddress,
+ &pending.UserAgent,
+ &pending.ExpiresAt,
+ &pending.ApprovedDID,
+ &pending.ApprovedAt,
+ &pending.DeviceSecret,
+ )
+
+ if err == sql.ErrNoRows {
+ return nil, false
+ }
+ if err != nil {
+ fmt.Printf("Warning: Failed to query pending auth: %v\n", err)
+ return nil, false
+ }
+
+ // Check if expired
+ if time.Now().After(pending.ExpiresAt) {
+ return nil, false
+ }
+
+ return &pending, true
+}
+
+// GetPendingByDeviceCode retrieves a pending auth by device code
+func (s *DeviceStore) GetPendingByDeviceCode(deviceCode string) (*PendingAuthorization, bool) {
+ var pending PendingAuthorization
+
+ err := s.db.QueryRow(`
+ SELECT device_code, user_code, device_name, ip_address, user_agent, expires_at, approved_did, approved_at, device_secret
+ FROM pending_device_auth
+ WHERE device_code = ?
+ `, deviceCode).Scan(
+ &pending.DeviceCode,
+ &pending.UserCode,
+ &pending.DeviceName,
+ &pending.IPAddress,
+ &pending.UserAgent,
+ &pending.ExpiresAt,
+ &pending.ApprovedDID,
+ &pending.ApprovedAt,
+ &pending.DeviceSecret,
+ )
+
+ if err == sql.ErrNoRows {
+ return nil, false
+ }
+ if err != nil {
+ fmt.Printf("Warning: Failed to query pending auth: %v\n", err)
+ return nil, false
+ }
+
+ // Check if expired
+ if time.Now().After(pending.ExpiresAt) {
+ return nil, false
+ }
+
+ return &pending, true
+}
+
+// ApprovePending approves a pending authorization and generates device secret
+func (s *DeviceStore) ApprovePending(userCode, did, handle string) (deviceSecret string, err error) {
+ // Start transaction
+ tx, err := s.db.Begin()
+ if err != nil {
+ return "", fmt.Errorf("failed to start transaction: %w", err)
+ }
+ defer tx.Rollback()
+
+ // Get pending auth
+ var pending PendingAuthorization
+ err = tx.QueryRow(`
+ SELECT device_code, user_code, device_name, ip_address, user_agent, expires_at, approved_did
+ FROM pending_device_auth
+ WHERE user_code = ?
+ `, userCode).Scan(
+ &pending.DeviceCode,
+ &pending.UserCode,
+ &pending.DeviceName,
+ &pending.IPAddress,
+ &pending.UserAgent,
+ &pending.ExpiresAt,
+ &pending.ApprovedDID,
+ )
+
+ if err == sql.ErrNoRows {
+ return "", fmt.Errorf("pending authorization not found")
+ }
+ if err != nil {
+ return "", fmt.Errorf("failed to query pending auth: %w", err)
+ }
+
+ // Check expiration
+ if time.Now().After(pending.ExpiresAt) {
+ return "", fmt.Errorf("authorization expired")
+ }
+
+ // Check if already approved
+ if pending.ApprovedDID != "" {
+ return "", fmt.Errorf("already approved")
+ }
+
+ // Generate device secret
+ secretBytes := make([]byte, 32)
+ if _, err := rand.Read(secretBytes); err != nil {
+ return "", fmt.Errorf("failed to generate device secret: %w", err)
+ }
+ deviceSecret = "atcr_device_" + base64.RawURLEncoding.EncodeToString(secretBytes)
+
+ // Hash for storage
+ secretHashBytes, err := bcrypt.GenerateFromPassword([]byte(deviceSecret), bcrypt.DefaultCost)
+ if err != nil {
+ return "", fmt.Errorf("failed to hash device secret: %w", err)
+ }
+ secretHash := string(secretHashBytes)
+
+ // Create device record
+ deviceID := uuid.New().String()
+ now := time.Now()
+
+ _, err = tx.Exec(`
+ INSERT INTO devices (id, did, handle, name, secret_hash, ip_address, user_agent, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `, deviceID, did, handle, pending.DeviceName, secretHash, pending.IPAddress, pending.UserAgent, now)
+
+ if err != nil {
+ return "", fmt.Errorf("failed to create device: %w", err)
+ }
+
+ // Update pending auth to mark as approved
+ _, err = tx.Exec(`
+ UPDATE pending_device_auth
+ SET approved_did = ?, approved_at = ?, device_secret = ?
+ WHERE user_code = ?
+ `, did, now, deviceSecret, userCode)
+
+ if err != nil {
+ return "", fmt.Errorf("failed to update pending auth: %w", err)
+ }
+
+ // Commit transaction
+ if err := tx.Commit(); err != nil {
+ return "", fmt.Errorf("failed to commit transaction: %w", err)
+ }
+
+ return deviceSecret, nil
+}
+
+// ValidateDeviceSecret validates a device secret and returns the device
+func (s *DeviceStore) ValidateDeviceSecret(secret string) (*Device, error) {
+ // Query all devices and check bcrypt hash
+ rows, err := s.db.Query(`
+ SELECT id, did, handle, name, secret_hash, ip_address, location, user_agent, created_at, last_used
+ FROM devices
+ `)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query devices: %w", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var device Device
+ var lastUsed sql.NullTime
+
+ err := rows.Scan(
+ &device.ID,
+ &device.DID,
+ &device.Handle,
+ &device.Name,
+ &device.SecretHash,
+ &device.IPAddress,
+ &device.Location,
+ &device.UserAgent,
+ &device.CreatedAt,
+ &lastUsed,
+ )
+ if err != nil {
+ continue
+ }
+
+ if lastUsed.Valid {
+ device.LastUsed = lastUsed.Time
+ }
+
+ // Check if this device's hash matches the secret
+ if err := bcrypt.CompareHashAndPassword([]byte(device.SecretHash), []byte(secret)); err == nil {
+ // Update last used asynchronously
+ go s.UpdateLastUsed(device.SecretHash)
+
+ return &device, nil
+ }
+ }
+
+ return nil, fmt.Errorf("invalid device secret")
+}
+
+// ListDevices returns all devices for a DID
+func (s *DeviceStore) ListDevices(did string) []*Device {
+ rows, err := s.db.Query(`
+ SELECT id, did, handle, name, ip_address, location, user_agent, created_at, last_used
+ FROM devices
+ WHERE did = ?
+ ORDER BY created_at DESC
+ `, did)
+
+ if err != nil {
+ fmt.Printf("Warning: Failed to list devices: %v\n", err)
+ return []*Device{}
+ }
+ defer rows.Close()
+
+ var devices []*Device
+ for rows.Next() {
+ var device Device
+ var lastUsed sql.NullTime
+
+ err := rows.Scan(
+ &device.ID,
+ &device.DID,
+ &device.Handle,
+ &device.Name,
+ &device.IPAddress,
+ &device.Location,
+ &device.UserAgent,
+ &device.CreatedAt,
+ &lastUsed,
+ )
+ if err != nil {
+ continue
+ }
+
+ if lastUsed.Valid {
+ device.LastUsed = lastUsed.Time
+ }
+
+ devices = append(devices, &device)
+ }
+
+ return devices
+}
+
+// RevokeDevice removes a device
+func (s *DeviceStore) RevokeDevice(did, deviceID string) error {
+ result, err := s.db.Exec(`
+ DELETE FROM devices
+ WHERE did = ? AND id = ?
+ `, did, deviceID)
+
+ if err != nil {
+ return fmt.Errorf("failed to revoke device: %w", err)
+ }
+
+ rows, _ := result.RowsAffected()
+ if rows == 0 {
+ return fmt.Errorf("device not found")
+ }
+
+ return nil
+}
+
+// UpdateLastUsed updates the last used timestamp
+func (s *DeviceStore) UpdateLastUsed(secretHash string) error {
+ _, err := s.db.Exec(`
+ UPDATE devices
+ SET last_used = ?
+ WHERE secret_hash = ?
+ `, time.Now(), secretHash)
+
+ return err
+}
+
+// CleanupExpired removes expired pending authorizations
+func (s *DeviceStore) CleanupExpired() {
+ result, err := s.db.Exec(`
+ DELETE FROM pending_device_auth
+ WHERE expires_at < datetime('now')
+ `)
+
+ if err != nil {
+ fmt.Printf("Warning: Failed to cleanup expired pending auths: %v\n", err)
+ return
+ }
+
+ deleted, _ := result.RowsAffected()
+ if deleted > 0 {
+ fmt.Printf("Cleaned up %d expired pending device auths\n", deleted)
+ }
+}
+
+// CleanupExpiredContext is a context-aware version for background workers
+func (s *DeviceStore) CleanupExpiredContext(ctx context.Context) error {
+ result, err := s.db.ExecContext(ctx, `
+ DELETE FROM pending_device_auth
+ WHERE expires_at < datetime('now')
+ `)
+
+ if err != nil {
+ return fmt.Errorf("failed to cleanup expired pending auths: %w", err)
+ }
+
+ deleted, _ := result.RowsAffected()
+ if deleted > 0 {
+ fmt.Printf("Cleaned up %d expired pending device auths\n", deleted)
+ }
+
+ return nil
+}
+
+// generateUserCode creates a short, human-readable code
+// Format: XXXX-XXXX (e.g., "WDJB-MJHT")
+// Character set: A-Z excluding ambiguous chars (0, O, I, 1, L)
+func generateUserCode() string {
+ chars := "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
+ code := make([]byte, 8)
+ rand.Read(code)
+ for i := range code {
+ code[i] = chars[int(code[i])%len(chars)]
+ }
+ return string(code[:4]) + "-" + string(code[4:])
+}
diff --git a/pkg/appview/db/oauth_store.go b/pkg/appview/db/oauth_store.go
new file mode 100644
index 0000000..abeca4d
--- /dev/null
+++ b/pkg/appview/db/oauth_store.go
@@ -0,0 +1,221 @@
+package db
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
+ "github.com/bluesky-social/indigo/atproto/syntax"
+)
+
+// OAuthStore implements oauth.ClientAuthStore with SQLite persistence
+type OAuthStore struct {
+ db *sql.DB
+}
+
+// NewOAuthStore creates a new SQLite-backed OAuth store
+func NewOAuthStore(db *sql.DB) *OAuthStore {
+ return &OAuthStore{db: db}
+}
+
+// GetSession retrieves a session by DID and session ID
+func (s *OAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
+ sessionKey := makeSessionKey(did.String(), sessionID)
+
+ var sessionDataJSON string
+
+ err := s.db.QueryRowContext(ctx, `
+ SELECT session_data
+ FROM oauth_sessions
+ WHERE session_key = ?
+ `, sessionKey).Scan(&sessionDataJSON)
+
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("session not found: %s/%s", did, sessionID)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to query session: %w", err)
+ }
+
+ // Parse session data JSON
+ var sessionData oauth.ClientSessionData
+ if err := json.Unmarshal([]byte(sessionDataJSON), &sessionData); err != nil {
+ return nil, fmt.Errorf("failed to parse session data: %w", err)
+ }
+
+ return &sessionData, nil
+}
+
+// SaveSession saves or updates a session (upsert)
+func (s *OAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
+ sessionKey := makeSessionKey(sess.AccountDID.String(), sess.SessionID)
+
+ // Marshal entire session to JSON
+ sessionDataJSON, err := json.Marshal(sess)
+ if err != nil {
+ return fmt.Errorf("failed to marshal session data: %w", err)
+ }
+
+ _, err = s.db.ExecContext(ctx, `
+ INSERT INTO oauth_sessions (
+ session_key, account_did, session_id, session_data,
+ created_at, updated_at
+ ) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
+ ON CONFLICT(session_key) DO UPDATE SET
+ session_data = excluded.session_data,
+ updated_at = datetime('now')
+ `,
+ sessionKey,
+ sess.AccountDID.String(),
+ sess.SessionID,
+ string(sessionDataJSON),
+ )
+
+ if err != nil {
+ return fmt.Errorf("failed to save session: %w", err)
+ }
+
+ return nil
+}
+
+// DeleteSession removes a session
+func (s *OAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
+ sessionKey := makeSessionKey(did.String(), sessionID)
+
+ _, err := s.db.ExecContext(ctx, `
+ DELETE FROM oauth_sessions WHERE session_key = ?
+ `, sessionKey)
+
+ return err
+}
+
+// GetAuthRequestInfo retrieves authentication request data by state
+func (s *OAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
+ var requestDataJSON string
+
+ err := s.db.QueryRowContext(ctx, `
+ SELECT request_data FROM oauth_auth_requests WHERE state = ?
+ `, state).Scan(&requestDataJSON)
+
+ if err == sql.ErrNoRows {
+ return nil, fmt.Errorf("auth request not found: %s", state)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to query auth request: %w", err)
+ }
+
+ var requestData oauth.AuthRequestData
+ if err := json.Unmarshal([]byte(requestDataJSON), &requestData); err != nil {
+ return nil, fmt.Errorf("failed to parse auth request data: %w", err)
+ }
+
+ return &requestData, nil
+}
+
+// SaveAuthRequestInfo saves authentication request data
+func (s *OAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
+ requestDataJSON, err := json.Marshal(info)
+ if err != nil {
+ return fmt.Errorf("failed to marshal auth request data: %w", err)
+ }
+
+ _, err = s.db.ExecContext(ctx, `
+ INSERT INTO oauth_auth_requests (state, request_data, created_at)
+ VALUES (?, ?, datetime('now'))
+ `, info.State, string(requestDataJSON))
+
+ if err != nil {
+ return fmt.Errorf("failed to save auth request: %w", err)
+ }
+
+ return nil
+}
+
+// DeleteAuthRequestInfo removes authentication request data
+func (s *OAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
+ _, err := s.db.ExecContext(ctx, `
+ DELETE FROM oauth_auth_requests WHERE state = ?
+ `, state)
+
+ return err
+}
+
+// GetLatestSessionForDID returns the most recently updated session for a DID
+// This is the key improvement over the file-based store - we can query by timestamp
+func (s *OAuthStore) GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error) {
+ var sessionDataJSON string
+ var sessionID string
+
+ err := s.db.QueryRowContext(ctx, `
+ SELECT session_id, session_data
+ FROM oauth_sessions
+ WHERE account_did = ?
+ ORDER BY updated_at DESC
+ LIMIT 1
+ `, did).Scan(&sessionID, &sessionDataJSON)
+
+ if err == sql.ErrNoRows {
+ return nil, "", fmt.Errorf("no session found for DID: %s", did)
+ }
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to query session: %w", err)
+ }
+
+ // Parse session data JSON
+ var sessionData oauth.ClientSessionData
+ if err := json.Unmarshal([]byte(sessionDataJSON), &sessionData); err != nil {
+ return nil, "", fmt.Errorf("failed to parse session data: %w", err)
+ }
+
+ return &sessionData, sessionID, nil
+}
+
+// CleanupOldSessions removes sessions older than the specified duration
+func (s *OAuthStore) CleanupOldSessions(ctx context.Context, olderThan time.Duration) error {
+ cutoff := time.Now().Add(-olderThan)
+
+ result, err := s.db.ExecContext(ctx, `
+ DELETE FROM oauth_sessions
+ WHERE updated_at < ?
+ `, cutoff)
+
+ if err != nil {
+ return fmt.Errorf("failed to cleanup old sessions: %w", err)
+ }
+
+ deleted, _ := result.RowsAffected()
+ if deleted > 0 {
+ fmt.Printf("Cleaned up %d old OAuth sessions (older than %v)\n", deleted, olderThan)
+ }
+
+ return nil
+}
+
+// CleanupExpiredAuthRequests removes auth requests older than 10 minutes
+func (s *OAuthStore) CleanupExpiredAuthRequests(ctx context.Context) error {
+ cutoff := time.Now().Add(-10 * time.Minute)
+
+ result, err := s.db.ExecContext(ctx, `
+ DELETE FROM oauth_auth_requests
+ WHERE created_at < ?
+ `, cutoff)
+
+ if err != nil {
+ return fmt.Errorf("failed to cleanup auth requests: %w", err)
+ }
+
+ deleted, _ := result.RowsAffected()
+ if deleted > 0 {
+ fmt.Printf("Cleaned up %d expired auth requests\n", deleted)
+ }
+
+ return nil
+}
+
+// makeSessionKey creates a composite key for session storage
+func makeSessionKey(did, sessionID string) string {
+ return fmt.Sprintf("%s:%s", did, sessionID)
+}
diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go
index 05197fa..705c10b 100644
--- a/pkg/appview/db/queries.go
+++ b/pkg/appview/db/queries.go
@@ -624,3 +624,140 @@ func MarkBackfillCompleted(db *sql.DB) error {
`)
return err
}
+
+// GetRepository fetches a specific repository for a user
+func GetRepository(db *sql.DB, did, repository string) (*Repository, error) {
+ // Get repository summary
+ var r Repository
+ r.Name = repository
+
+ var tagCount, manifestCount int
+ var lastPushStr string
+
+ err := db.QueryRow(`
+ SELECT
+ COUNT(DISTINCT tag) as tag_count,
+ COUNT(DISTINCT digest) as manifest_count,
+ MAX(created_at) as last_push
+ FROM (
+ SELECT tag, digest, created_at FROM tags WHERE did = ? AND repository = ?
+ UNION
+ SELECT NULL, digest, created_at FROM manifests WHERE did = ? AND repository = ?
+ )
+ `, did, repository, did, repository).Scan(&tagCount, &manifestCount, &lastPushStr)
+
+ if err != nil {
+ return nil, err
+ }
+
+ r.TagCount = tagCount
+ r.ManifestCount = manifestCount
+
+ // Parse the timestamp string into time.Time
+ if lastPushStr != "" {
+ formats := []string{
+ time.RFC3339Nano,
+ "2006-01-02 15:04:05.999999999-07:00",
+ "2006-01-02 15:04:05.999999999",
+ time.RFC3339,
+ "2006-01-02 15:04:05",
+ }
+
+ for _, format := range formats {
+ if t, err := time.Parse(format, lastPushStr); err == nil {
+ r.LastPush = t
+ break
+ }
+ }
+ }
+
+ // Get tags for this repo
+ tagRows, err := db.Query(`
+ SELECT id, tag, digest, created_at
+ FROM tags
+ WHERE did = ? AND repository = ?
+ ORDER BY created_at DESC
+ `, did, repository)
+
+ if err != nil {
+ return nil, err
+ }
+
+ for tagRows.Next() {
+ var t Tag
+ t.DID = did
+ t.Repository = repository
+ if err := tagRows.Scan(&t.ID, &t.Tag, &t.Digest, &t.CreatedAt); err != nil {
+ tagRows.Close()
+ return nil, err
+ }
+ r.Tags = append(r.Tags, t)
+ }
+ tagRows.Close()
+
+ // Get manifests for this repo
+ manifestRows, err := db.Query(`
+ SELECT id, digest, hold_endpoint, schema_version, media_type,
+ config_digest, config_size, raw_manifest, created_at,
+ title, description, source_url, documentation_url, licenses, icon_url
+ FROM manifests
+ WHERE did = ? AND repository = ?
+ ORDER BY created_at DESC
+ `, did, repository)
+
+ if err != nil {
+ return nil, err
+ }
+
+ for manifestRows.Next() {
+ var m Manifest
+ m.DID = did
+ m.Repository = repository
+
+ // Use sql.NullString for nullable annotation fields
+ var title, description, sourceURL, documentationURL, licenses, iconURL sql.NullString
+
+ if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion,
+ &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.RawManifest, &m.CreatedAt,
+ &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL); err != nil {
+ manifestRows.Close()
+ return nil, err
+ }
+
+ // Convert NullString to string
+ if title.Valid {
+ m.Title = title.String
+ }
+ if description.Valid {
+ m.Description = description.String
+ }
+ if sourceURL.Valid {
+ m.SourceURL = sourceURL.String
+ }
+ if documentationURL.Valid {
+ m.DocumentationURL = documentationURL.String
+ }
+ if licenses.Valid {
+ m.Licenses = licenses.String
+ }
+ if iconURL.Valid {
+ m.IconURL = iconURL.String
+ }
+
+ r.Manifests = append(r.Manifests, m)
+ }
+ manifestRows.Close()
+
+ // Aggregate repository-level annotations from most recent manifest
+ if len(r.Manifests) > 0 {
+ latest := r.Manifests[0]
+ r.Title = latest.Title
+ r.Description = latest.Description
+ r.SourceURL = latest.SourceURL
+ r.DocumentationURL = latest.DocumentationURL
+ r.Licenses = latest.Licenses
+ r.IconURL = latest.IconURL
+ }
+
+ return &r, nil
+}
diff --git a/pkg/appview/db/schema.go b/pkg/appview/db/schema.go
index dc824d9..d024f1f 100644
--- a/pkg/appview/db/schema.go
+++ b/pkg/appview/db/schema.go
@@ -79,6 +79,69 @@ CREATE TABLE IF NOT EXISTS backfill_state (
completed BOOLEAN NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL
);
+
+CREATE TABLE IF NOT EXISTS oauth_sessions (
+ session_key TEXT PRIMARY KEY,
+ account_did TEXT NOT NULL,
+ session_id TEXT NOT NULL,
+ session_data TEXT NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(account_did, session_id)
+);
+CREATE INDEX IF NOT EXISTS idx_oauth_sessions_did ON oauth_sessions(account_did);
+CREATE INDEX IF NOT EXISTS idx_oauth_sessions_updated ON oauth_sessions(updated_at DESC);
+
+CREATE TABLE IF NOT EXISTS oauth_auth_requests (
+ state TEXT PRIMARY KEY,
+ request_data TEXT NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+CREATE INDEX IF NOT EXISTS idx_oauth_auth_requests_created ON oauth_auth_requests(created_at);
+
+CREATE TABLE IF NOT EXISTS ui_sessions (
+ id TEXT PRIMARY KEY,
+ did TEXT NOT NULL,
+ handle TEXT NOT NULL,
+ pds_endpoint TEXT NOT NULL,
+ oauth_session_id TEXT,
+ expires_at TIMESTAMP NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_ui_sessions_did ON ui_sessions(did);
+CREATE INDEX IF NOT EXISTS idx_ui_sessions_expires ON ui_sessions(expires_at);
+
+CREATE TABLE IF NOT EXISTS devices (
+ id TEXT PRIMARY KEY,
+ did TEXT NOT NULL,
+ handle TEXT NOT NULL,
+ name TEXT NOT NULL,
+ secret_hash TEXT NOT NULL UNIQUE,
+ ip_address TEXT,
+ location TEXT,
+ user_agent TEXT,
+ created_at TIMESTAMP NOT NULL,
+ last_used TIMESTAMP,
+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_devices_did ON devices(did);
+CREATE INDEX IF NOT EXISTS idx_devices_hash ON devices(secret_hash);
+
+CREATE TABLE IF NOT EXISTS pending_device_auth (
+ device_code TEXT PRIMARY KEY,
+ user_code TEXT NOT NULL UNIQUE,
+ device_name TEXT NOT NULL,
+ ip_address TEXT,
+ user_agent TEXT,
+ expires_at TIMESTAMP NOT NULL,
+ approved_did TEXT,
+ approved_at TIMESTAMP,
+ device_secret TEXT,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+CREATE INDEX IF NOT EXISTS idx_pending_device_auth_user_code ON pending_device_auth(user_code);
+CREATE INDEX IF NOT EXISTS idx_pending_device_auth_expires ON pending_device_auth(expires_at);
`
// InitDB initializes the SQLite database with the schema
@@ -105,12 +168,6 @@ func InitDB(path string) (*sql.DB, error) {
// Log but don't fail - column might already exist
}
- // Migration: Convert old cdn.bsky.app avatar URLs to imgs.blue
- if err := migrateCDNURLs(db); err != nil {
- // Log but don't fail - not critical
- println("Warning: Failed to migrate CDN URLs:", err.Error())
- }
-
// Migration: Add OCI annotation columns to manifests table
annotationColumns := []string{
"title TEXT",
@@ -130,68 +187,4 @@ func InitDB(path string) (*sql.DB, error) {
}
return db, nil
-}
-
-// migrateCDNURLs converts old cdn.bsky.app avatar URLs to imgs.blue format
-// Old format: https://cdn.bsky.app/img/avatar/plain/did:plc:abc123/bafkreibxuy73...@jpeg
-// New format: https://imgs.blue/did:plc:abc123/bafkreibxuy73...
-func migrateCDNURLs(db *sql.DB) error {
- // Find all users with cdn.bsky.app avatars
- rows, err := db.Query(`SELECT did, avatar FROM users WHERE avatar LIKE 'https://cdn.bsky.app/%'`)
- if err != nil {
- return err
- }
- defer rows.Close()
-
- updates := []struct {
- did string
- newURL string
- }{}
-
- for rows.Next() {
- var did, oldURL string
- if err := rows.Scan(&did, &oldURL); err != nil {
- continue
- }
-
- // Extract CID from old URL
- // Format: https://cdn.bsky.app/img/avatar/plain/did:plc:abc123/bafkreibxuy73...@jpeg
- parts := strings.Split(oldURL, "/")
- if len(parts) < 7 {
- continue
- }
-
- // Get the last part which contains CID@format
- cidPart := parts[len(parts)-1]
- // Strip off @jpeg or @png suffix
- cid := strings.Split(cidPart, "@")[0]
-
- // Construct new imgs.blue URL
- newURL := "https://imgs.blue/" + did + "/" + cid
-
- updates = append(updates, struct {
- did string
- newURL string
- }{did, newURL})
- }
-
- // Update all users
- stmt, err := db.Prepare(`UPDATE users SET avatar = ? WHERE did = ?`)
- if err != nil {
- return err
- }
- defer stmt.Close()
-
- for _, update := range updates {
- if _, err := stmt.Exec(update.newURL, update.did); err != nil {
- // Log but continue
- println("Warning: Failed to update avatar for", update.did, ":", err.Error())
- }
- }
-
- if len(updates) > 0 {
- println("Migrated", len(updates), "avatar URLs from cdn.bsky.app to imgs.blue")
- }
-
- return nil
-}
+}
\ No newline at end of file
diff --git a/pkg/appview/db/session_store.go b/pkg/appview/db/session_store.go
new file mode 100644
index 0000000..f3f7a63
--- /dev/null
+++ b/pkg/appview/db/session_store.go
@@ -0,0 +1,203 @@
+package db
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+// Session represents a user session
+// Compatible with pkg/appview/session.Session
+type Session struct {
+ ID string
+ DID string
+ Handle string
+ PDSEndpoint string
+ OAuthSessionID string // Links to oauth_sessions.session_id
+ ExpiresAt time.Time
+}
+
+// SessionStoreInterface defines the session storage interface
+// Both db.SessionStore and session.Store implement this
+type SessionStoreInterface interface {
+ Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error)
+ CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error)
+ Get(id string) (*Session, bool)
+ Delete(id string)
+ Cleanup()
+}
+
+// SessionStore manages user sessions with SQLite persistence
+type SessionStore struct {
+ db *sql.DB
+}
+
+// NewSessionStore creates a new SQLite-backed session store
+func NewSessionStore(db *sql.DB) *SessionStore {
+ return &SessionStore{db: db}
+}
+
+// Create creates a new session and returns the session ID
+func (s *SessionStore) Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) {
+ return s.CreateWithOAuth(did, handle, pdsEndpoint, "", duration)
+}
+
+// CreateWithOAuth creates a new session with OAuth sessionID and returns the session ID
+func (s *SessionStore) CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) {
+ // Generate random session ID
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", fmt.Errorf("failed to generate session ID: %w", err)
+ }
+
+ sessionID := base64.URLEncoding.EncodeToString(b)
+ expiresAt := time.Now().Add(duration)
+
+ _, err := s.db.Exec(`
+ INSERT INTO ui_sessions (id, did, handle, pds_endpoint, oauth_session_id, expires_at, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
+ `, sessionID, did, handle, pdsEndpoint, oauthSessionID, expiresAt)
+
+ if err != nil {
+ return "", fmt.Errorf("failed to create session: %w", err)
+ }
+
+ return sessionID, nil
+}
+
+// Get retrieves a session by ID
+func (s *SessionStore) Get(id string) (*Session, bool) {
+ var sess Session
+
+ err := s.db.QueryRow(`
+ SELECT id, did, handle, pds_endpoint, oauth_session_id, expires_at
+ FROM ui_sessions
+ WHERE id = ?
+ `, id).Scan(&sess.ID, &sess.DID, &sess.Handle, &sess.PDSEndpoint, &sess.OAuthSessionID, &sess.ExpiresAt)
+
+ if err == sql.ErrNoRows {
+ return nil, false
+ }
+ if err != nil {
+ fmt.Printf("Warning: Failed to query session: %v\n", err)
+ return nil, false
+ }
+
+ // Check if expired
+ if time.Now().After(sess.ExpiresAt) {
+ return nil, false
+ }
+
+ return &sess, true
+}
+
+// Extend extends a session's expiration time
+func (s *SessionStore) Extend(id string, duration time.Duration) error {
+ expiresAt := time.Now().Add(duration)
+
+ result, err := s.db.Exec(`
+ UPDATE ui_sessions
+ SET expires_at = ?
+ WHERE id = ?
+ `, expiresAt, id)
+
+ if err != nil {
+ return fmt.Errorf("failed to extend session: %w", err)
+ }
+
+ rows, _ := result.RowsAffected()
+ if rows == 0 {
+ return fmt.Errorf("session not found: %s", id)
+ }
+
+ return nil
+}
+
+// Delete removes a session
+func (s *SessionStore) Delete(id string) {
+ _, err := s.db.Exec(`
+ DELETE FROM ui_sessions WHERE id = ?
+ `, id)
+
+ if err != nil {
+ fmt.Printf("Warning: Failed to delete session: %v\n", err)
+ }
+}
+
+// Cleanup removes expired sessions
+func (s *SessionStore) Cleanup() {
+ result, err := s.db.Exec(`
+ DELETE FROM ui_sessions
+ WHERE expires_at < datetime('now')
+ `)
+
+ if err != nil {
+ fmt.Printf("Warning: Failed to cleanup sessions: %v\n", err)
+ return
+ }
+
+ deleted, _ := result.RowsAffected()
+ if deleted > 0 {
+ fmt.Printf("Cleaned up %d expired UI sessions\n", deleted)
+ }
+}
+
+// CleanupContext is a context-aware version of Cleanup for background workers
+func (s *SessionStore) CleanupContext(ctx context.Context) error {
+ result, err := s.db.ExecContext(ctx, `
+ DELETE FROM ui_sessions
+ WHERE expires_at < datetime('now')
+ `)
+
+ if err != nil {
+ return fmt.Errorf("failed to cleanup sessions: %w", err)
+ }
+
+ deleted, _ := result.RowsAffected()
+ if deleted > 0 {
+ fmt.Printf("Cleaned up %d expired UI sessions\n", deleted)
+ }
+
+ return nil
+}
+
+// Cookie helper functions (compatible with pkg/appview/session package)
+
+// SetCookie sets the session cookie
+func SetCookie(w http.ResponseWriter, sessionID string, maxAge int) {
+ http.SetCookie(w, &http.Cookie{
+ Name: "atcr_session",
+ Value: sessionID,
+ Path: "/",
+ MaxAge: maxAge,
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+// ClearCookie clears the session cookie
+func ClearCookie(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{
+ Name: "atcr_session",
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+// GetSessionID gets session ID from cookie
+func GetSessionID(r *http.Request) (string, bool) {
+ cookie, err := r.Cookie("atcr_session")
+ if err != nil {
+ return "", false
+ }
+ return cookie.Value, true
+}
diff --git a/pkg/appview/device/store.go b/pkg/appview/device/store.go
deleted file mode 100644
index a5d650f..0000000
--- a/pkg/appview/device/store.go
+++ /dev/null
@@ -1,395 +0,0 @@
-package device
-
-import (
- "crypto/rand"
- "encoding/base64"
- "encoding/json"
- "fmt"
- "os"
- "sync"
- "time"
-
- "github.com/google/uuid"
- "golang.org/x/crypto/bcrypt"
-)
-
-// Device represents an authorized device
-type Device struct {
- ID string `json:"id"` // UUID
- DID string `json:"did"` // Owner DID (links to OAuth session)
- Handle string `json:"handle"` // Owner handle
- Name string `json:"name"` // Device name (hostname)
- SecretHash string `json:"secret_hash"` // bcrypt hash of device secret
- IPAddress string `json:"ip_address"` // Registration IP
- Location string `json:"location"` // GeoIP location (optional)
- UserAgent string `json:"user_agent"` // Client info
- CreatedAt time.Time `json:"created_at"`
- LastUsed time.Time `json:"last_used"`
-}
-
-// PendingAuthorization represents a device awaiting user approval
-type PendingAuthorization struct {
- DeviceCode string `json:"device_code"` // Long code for polling
- UserCode string `json:"user_code"` // Short code shown to user
- DeviceName string `json:"device_name"` // Device hostname
- IPAddress string `json:"ip_address"` // Request IP
- UserAgent string `json:"user_agent"` // Client user agent
- ExpiresAt time.Time `json:"expires_at"` // Expiration (10 minutes)
- ApprovedDID string `json:"approved_did"` // Set when approved
- ApprovedAt time.Time `json:"approved_at"` // Set when approved
- DeviceSecret string `json:"device_secret"` // Generated after approval
-}
-
-// Store manages devices and pending authorizations
-type Store struct {
- mu sync.RWMutex
- devices map[string]*Device // secretHash -> Device
- byDID map[string][]string // DID -> []secretHash
- pending map[string]*PendingAuthorization // deviceCode -> pending auth
- pendingByUser map[string]*PendingAuthorization // userCode -> pending auth
- filePath string
-}
-
-// persistentData is saved to disk
-type persistentData struct {
- Devices []*Device `json:"devices"`
- Pending []*PendingAuthorization `json:"pending"`
-}
-
-// NewStore creates a new device store
-func NewStore(filePath string) (*Store, error) {
- s := &Store{
- devices: make(map[string]*Device),
- byDID: make(map[string][]string),
- pending: make(map[string]*PendingAuthorization),
- pendingByUser: make(map[string]*PendingAuthorization),
- filePath: filePath,
- }
-
- // Load existing data
- if err := s.load(); err != nil && !os.IsNotExist(err) {
- return nil, fmt.Errorf("failed to load devices: %w", err)
- }
-
- return s, nil
-}
-
-// CreatePendingAuth creates a new pending device authorization
-func (s *Store) CreatePendingAuth(deviceName, ip, userAgent string) (*PendingAuthorization, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- // Generate device code (long, random)
- deviceCodeBytes := make([]byte, 32)
- if _, err := rand.Read(deviceCodeBytes); err != nil {
- return nil, fmt.Errorf("failed to generate device code: %w", err)
- }
- deviceCode := base64.RawURLEncoding.EncodeToString(deviceCodeBytes)
-
- // Generate user code (short, human-readable)
- userCode := generateUserCode()
-
- pending := &PendingAuthorization{
- DeviceCode: deviceCode,
- UserCode: userCode,
- DeviceName: deviceName,
- IPAddress: ip,
- UserAgent: userAgent,
- ExpiresAt: time.Now().Add(10 * time.Minute),
- }
-
- s.pending[deviceCode] = pending
- s.pendingByUser[userCode] = pending
-
- if err := s.save(); err != nil {
- return nil, fmt.Errorf("failed to save pending auth: %w", err)
- }
-
- return pending, nil
-}
-
-// GetPendingByUserCode retrieves a pending auth by user code
-func (s *Store) GetPendingByUserCode(userCode string) (*PendingAuthorization, bool) {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- pending, ok := s.pendingByUser[userCode]
- if !ok || time.Now().After(pending.ExpiresAt) {
- return nil, false
- }
-
- return pending, true
-}
-
-// GetPendingByDeviceCode retrieves a pending auth by device code
-func (s *Store) GetPendingByDeviceCode(deviceCode string) (*PendingAuthorization, bool) {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- pending, ok := s.pending[deviceCode]
- if !ok || time.Now().After(pending.ExpiresAt) {
- return nil, false
- }
-
- return pending, true
-}
-
-// ApprovePending approves a pending authorization and generates device secret
-func (s *Store) ApprovePending(userCode, did, handle string) (deviceSecret string, err error) {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- pending, ok := s.pendingByUser[userCode]
- if !ok {
- return "", fmt.Errorf("pending authorization not found")
- }
-
- if time.Now().After(pending.ExpiresAt) {
- return "", fmt.Errorf("authorization expired")
- }
-
- if pending.ApprovedDID != "" {
- return "", fmt.Errorf("already approved")
- }
-
- // Generate device secret
- secretBytes := make([]byte, 32)
- if _, err := rand.Read(secretBytes); err != nil {
- return "", fmt.Errorf("failed to generate device secret: %w", err)
- }
- deviceSecret = "atcr_device_" + base64.RawURLEncoding.EncodeToString(secretBytes)
-
- // Hash for storage
- secretHashBytes, err := bcrypt.GenerateFromPassword([]byte(deviceSecret), bcrypt.DefaultCost)
- if err != nil {
- return "", fmt.Errorf("failed to hash device secret: %w", err)
- }
- secretHash := string(secretHashBytes)
-
- // Create device record
- device := &Device{
- ID: uuid.New().String(),
- DID: did,
- Handle: handle,
- Name: pending.DeviceName,
- SecretHash: secretHash,
- IPAddress: pending.IPAddress,
- UserAgent: pending.UserAgent,
- CreatedAt: time.Now(),
- LastUsed: time.Time{}, // Never used yet
- }
-
- // Store device
- s.devices[secretHash] = device
- s.byDID[did] = append(s.byDID[did], secretHash)
-
- // Mark pending as approved
- pending.ApprovedDID = did
- pending.ApprovedAt = time.Now()
- pending.DeviceSecret = deviceSecret // Store plaintext temporarily for polling
-
- if err := s.save(); err != nil {
- return "", fmt.Errorf("failed to save device: %w", err)
- }
-
- return deviceSecret, nil
-}
-
-// ValidateDeviceSecret validates a device secret and returns the device
-func (s *Store) ValidateDeviceSecret(secret string) (*Device, error) {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- // Try to match against all stored hashes
- for hash, device := range s.devices {
- if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(secret)); err == nil {
- // Update last used asynchronously
- go s.UpdateLastUsed(hash)
-
- // Return a copy
- deviceCopy := *device
- return &deviceCopy, nil
- }
- }
-
- return nil, fmt.Errorf("invalid device secret")
-}
-
-// ListDevices returns all devices for a DID
-func (s *Store) ListDevices(did string) []*Device {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- hashes, ok := s.byDID[did]
- if !ok {
- return []*Device{}
- }
-
- result := make([]*Device, 0, len(hashes))
- for _, hash := range hashes {
- if device, ok := s.devices[hash]; ok {
- // Return copy without hash
- deviceCopy := *device
- deviceCopy.SecretHash = ""
- result = append(result, &deviceCopy)
- }
- }
-
- return result
-}
-
-// RevokeDevice removes a device
-func (s *Store) RevokeDevice(did, deviceID string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- hashes, ok := s.byDID[did]
- if !ok {
- return fmt.Errorf("no devices found for DID")
- }
-
- var foundHash string
- for _, hash := range hashes {
- if device, ok := s.devices[hash]; ok && device.ID == deviceID {
- foundHash = hash
- break
- }
- }
-
- if foundHash == "" {
- return fmt.Errorf("device not found")
- }
-
- // Remove from devices map
- delete(s.devices, foundHash)
-
- // Remove from byDID index
- newHashes := make([]string, 0, len(hashes)-1)
- for _, hash := range hashes {
- if hash != foundHash {
- newHashes = append(newHashes, hash)
- }
- }
-
- if len(newHashes) == 0 {
- delete(s.byDID, did)
- } else {
- s.byDID[did] = newHashes
- }
-
- return s.save()
-}
-
-// UpdateLastUsed updates the last used timestamp
-func (s *Store) UpdateLastUsed(secretHash string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- device, ok := s.devices[secretHash]
- if !ok {
- return fmt.Errorf("device not found")
- }
-
- device.LastUsed = time.Now()
- return s.save()
-}
-
-// CleanupExpired removes expired pending authorizations
-func (s *Store) CleanupExpired() {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- now := time.Now()
- modified := false
-
- for deviceCode, pending := range s.pending {
- if now.After(pending.ExpiresAt) {
- delete(s.pending, deviceCode)
- delete(s.pendingByUser, pending.UserCode)
- modified = true
- }
- }
-
- if modified {
- s.save()
- }
-}
-
-// load reads data from disk
-func (s *Store) load() error {
- data, err := os.ReadFile(s.filePath)
- if err != nil {
- return err
- }
-
- var pd persistentData
- if err := json.Unmarshal(data, &pd); err != nil {
- return fmt.Errorf("failed to unmarshal devices: %w", err)
- }
-
- // Rebuild in-memory structures
- for _, device := range pd.Devices {
- s.devices[device.SecretHash] = device
- s.byDID[device.DID] = append(s.byDID[device.DID], device.SecretHash)
- }
-
- for _, pending := range pd.Pending {
- // Only load non-expired
- if time.Now().Before(pending.ExpiresAt) {
- s.pending[pending.DeviceCode] = pending
- s.pendingByUser[pending.UserCode] = pending
- }
- }
-
- return nil
-}
-
-// save writes data to disk
-func (s *Store) save() error {
- // Collect all devices
- allDevices := make([]*Device, 0, len(s.devices))
- for _, device := range s.devices {
- allDevices = append(allDevices, device)
- }
-
- // Collect all pending
- allPending := make([]*PendingAuthorization, 0, len(s.pending))
- for _, pending := range s.pending {
- allPending = append(allPending, pending)
- }
-
- pd := persistentData{
- Devices: allDevices,
- Pending: allPending,
- }
-
- data, err := json.MarshalIndent(pd, "", " ")
- if err != nil {
- return fmt.Errorf("failed to marshal devices: %w", err)
- }
-
- // Write atomically
- tmpPath := s.filePath + ".tmp"
- if err := os.WriteFile(tmpPath, data, 0600); err != nil {
- return fmt.Errorf("failed to write temp file: %w", err)
- }
-
- if err := os.Rename(tmpPath, s.filePath); err != nil {
- return fmt.Errorf("failed to rename temp file: %w", err)
- }
-
- return nil
-}
-
-// generateUserCode creates a short, human-readable code
-// Format: XXXX-XXXX (e.g., "WDJB-MJHT")
-// Character set: A-Z excluding ambiguous chars (0, O, I, 1, L)
-func generateUserCode() string {
- chars := "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
- code := make([]byte, 8)
- rand.Read(code)
- for i := range code {
- code[i] = chars[int(code[i])%len(chars)]
- }
- return string(code[:4]) + "-" + string(code[4:])
-}
diff --git a/pkg/appview/handlers/device.go b/pkg/appview/handlers/device.go
index a659bcf..5b2bfd8 100644
--- a/pkg/appview/handlers/device.go
+++ b/pkg/appview/handlers/device.go
@@ -9,8 +9,7 @@ import (
"github.com/gorilla/mux"
- "atcr.io/pkg/appview/device"
- "atcr.io/pkg/appview/session"
+ "atcr.io/pkg/appview/db"
)
// DeviceCodeRequest is the request to start device authorization
@@ -29,7 +28,7 @@ type DeviceCodeResponse struct {
// DeviceCodeHandler handles POST /auth/device/code
type DeviceCodeHandler struct {
- Store *device.Store
+ Store *db.DeviceStore
AppViewBaseURL string // e.g., "http://localhost:5000"
}
@@ -91,7 +90,7 @@ type DeviceTokenResponse struct {
// DeviceTokenHandler handles POST /auth/device/token
type DeviceTokenHandler struct {
- Store *device.Store
+ Store *db.DeviceStore
}
func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -151,8 +150,8 @@ func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// DeviceApprovalPageHandler handles GET /device
type DeviceApprovalPageHandler struct {
- Store *device.Store
- SessionStore *session.Store
+ Store *db.DeviceStore
+ SessionStore *db.SessionStore
}
func (h *DeviceApprovalPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -162,7 +161,7 @@ func (h *DeviceApprovalPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
}
// Check if user is logged in
- sessionID, ok := session.GetSessionID(r)
+ sessionID, ok := db.GetSessionID(r)
if !ok {
// Not logged in - redirect to login with return URL
http.SetCookie(w, &http.Cookie{
@@ -222,8 +221,8 @@ type DeviceApproveRequest struct {
// DeviceApproveHandler handles POST /device/approve
type DeviceApproveHandler struct {
- Store *device.Store
- SessionStore *session.Store
+ Store *db.DeviceStore
+ SessionStore *db.SessionStore
}
func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -233,7 +232,7 @@ func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
}
// Check session
- sessionID, ok := session.GetSessionID(r)
+ sessionID, ok := db.GetSessionID(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
@@ -271,8 +270,8 @@ func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
// ListDevicesHandler handles GET /api/devices
type ListDevicesHandler struct {
- Store *device.Store
- SessionStore *session.Store
+ Store *db.DeviceStore
+ SessionStore *db.SessionStore
}
func (h *ListDevicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -282,7 +281,7 @@ func (h *ListDevicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Check session
- sessionID, ok := session.GetSessionID(r)
+ sessionID, ok := db.GetSessionID(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
@@ -303,8 +302,8 @@ func (h *ListDevicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// RevokeDeviceHandler handles DELETE /api/devices/{id}
type RevokeDeviceHandler struct {
- Store *device.Store
- SessionStore *session.Store
+ Store *db.DeviceStore
+ SessionStore *db.SessionStore
}
func (h *RevokeDeviceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -314,7 +313,7 @@ func (h *RevokeDeviceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
}
// Check session
- sessionID, ok := session.GetSessionID(r)
+ sessionID, ok := db.GetSessionID(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
@@ -345,7 +344,7 @@ func (h *RevokeDeviceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
// Helper functions
-func (h *DeviceApprovalPageHandler) renderApprovalPage(w http.ResponseWriter, handle string, pending *device.PendingAuthorization) {
+func (h *DeviceApprovalPageHandler) renderApprovalPage(w http.ResponseWriter, handle string, pending *db.PendingAuthorization) {
tmpl := template.Must(template.New("approval").Parse(deviceApprovalTemplate))
data := struct {
Handle string
diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go
new file mode 100644
index 0000000..e29ee65
--- /dev/null
+++ b/pkg/appview/handlers/repository.go
@@ -0,0 +1,67 @@
+package handlers
+
+import (
+ "database/sql"
+ "html/template"
+ "net/http"
+
+ "atcr.io/pkg/appview/db"
+ "atcr.io/pkg/appview/middleware"
+ "github.com/gorilla/mux"
+)
+
+// RepositoryPageHandler handles the public repository page
+type RepositoryPageHandler struct {
+ DB *sql.DB
+ Templates *template.Template
+ RegistryURL string
+}
+
+func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ handle := vars["handle"]
+ repository := vars["repository"]
+
+ // Look up user by handle
+ owner, err := db.GetUserByHandle(h.DB, handle)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if owner == nil {
+ http.Error(w, "User not found", http.StatusNotFound)
+ return
+ }
+
+ // Fetch repository data
+ repo, err := db.GetRepository(h.DB, owner.DID, repository)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if repo == nil || len(repo.Manifests) == 0 {
+ http.Error(w, "Repository not found", http.StatusNotFound)
+ return
+ }
+
+ data := struct {
+ User *db.User // Logged-in user (for nav)
+ Owner *db.User // Repository owner
+ Repository *db.Repository
+ Query string
+ RegistryURL string
+ }{
+ User: middleware.GetUser(r), // May be nil if not logged in
+ Owner: owner,
+ Repository: repo,
+ Query: r.URL.Query().Get("q"),
+ RegistryURL: h.RegistryURL,
+ }
+
+ if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
diff --git a/pkg/appview/interfaces.go b/pkg/appview/interfaces.go
new file mode 100644
index 0000000..3fb6a80
--- /dev/null
+++ b/pkg/appview/interfaces.go
@@ -0,0 +1,50 @@
+package appview
+
+import "time"
+
+// SessionStore interface for UI session management
+// Implemented by both session.Store (file-based) and db.SessionStore (SQLite-based)
+type SessionStore interface {
+ Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error)
+ CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error)
+ Get(id string) (Session, bool)
+ Delete(id string)
+ Cleanup()
+}
+
+// Session represents a user session
+// Compatible with both file-based and SQLite implementations
+type Session interface {
+ GetID() string
+ GetDID() string
+ GetHandle() string
+ GetPDSEndpoint() string
+ GetOAuthSessionID() string
+}
+
+// DeviceStore interface for device authorization management
+// Implemented by both device.Store (file-based) and db.DeviceStore (SQLite-based)
+type DeviceStore interface {
+ CreatePendingAuth(deviceName, ip, userAgent string) (PendingAuth, error)
+ GetPendingByUserCode(userCode string) (PendingAuth, bool)
+ GetPendingByDeviceCode(deviceCode string) (PendingAuth, bool)
+ ApprovePending(userCode, did, handle string) (deviceSecret string, err error)
+ ValidateDeviceSecret(secret string) (Device, error)
+ ListDevices(did string) []Device
+ RevokeDevice(did, deviceID string) error
+ CleanupExpired()
+}
+
+// PendingAuth interface for pending device authorizations
+type PendingAuth interface {
+ GetDeviceCode() string
+ GetUserCode() string
+ GetDeviceName() string
+}
+
+// Device interface for authorized devices
+type Device interface {
+ GetID() string
+ GetDID() string
+ GetHandle() string
+}
diff --git a/pkg/appview/middleware/auth.go b/pkg/appview/middleware/auth.go
index 0c008a4..0dfbaa2 100644
--- a/pkg/appview/middleware/auth.go
+++ b/pkg/appview/middleware/auth.go
@@ -6,7 +6,6 @@ import (
"net/http"
"atcr.io/pkg/appview/db"
- "atcr.io/pkg/appview/session"
)
type contextKey string
@@ -14,10 +13,10 @@ type contextKey string
const userKey contextKey = "user"
// RequireAuth is middleware that requires authentication
-func RequireAuth(store *session.Store, database *sql.DB) func(http.Handler) http.Handler {
+func RequireAuth(store *db.SessionStore, database *sql.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- sessionID, ok := session.GetSessionID(r)
+ sessionID, ok := getSessionID(r)
if !ok {
http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound)
return
@@ -47,10 +46,10 @@ func RequireAuth(store *session.Store, database *sql.DB) func(http.Handler) http
}
// OptionalAuth is middleware that optionally includes user if authenticated
-func OptionalAuth(store *session.Store, database *sql.DB) func(http.Handler) http.Handler {
+func OptionalAuth(store *db.SessionStore, database *sql.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- sessionID, ok := session.GetSessionID(r)
+ sessionID, ok := getSessionID(r)
if ok {
if sess, ok := store.Get(sessionID); ok {
// Look up full user from database to get avatar
@@ -72,6 +71,15 @@ func OptionalAuth(store *session.Store, database *sql.DB) func(http.Handler) htt
}
}
+// getSessionID gets session ID from cookie
+func getSessionID(r *http.Request) (string, bool) {
+ cookie, err := r.Cookie("atcr_session")
+ if err != nil {
+ return "", false
+ }
+ return cookie.Value, true
+}
+
// GetUser retrieves the user from the request context
func GetUser(r *http.Request) *db.User {
user, ok := r.Context().Value(userKey).(*db.User)
diff --git a/pkg/appview/session/session.go b/pkg/appview/session/session.go
deleted file mode 100644
index e330d8a..0000000
--- a/pkg/appview/session/session.go
+++ /dev/null
@@ -1,228 +0,0 @@
-package session
-
-import (
- "crypto/rand"
- "encoding/base64"
- "encoding/json"
- "fmt"
- "net/http"
- "os"
- "sync"
- "time"
-)
-
-// Session represents a user session
-type Session struct {
- ID string
- DID string
- Handle string
- PDSEndpoint string
- OAuthSessionID string // Store OAuth sessionID for resuming
- ExpiresAt time.Time
-}
-
-// Store manages user sessions
-type Store struct {
- mu sync.RWMutex
- sessions map[string]*Session
- filePath string
-}
-
-// NewStore creates a new session store with file persistence
-func NewStore(filePath string) *Store {
- store := &Store{
- sessions: make(map[string]*Session),
- filePath: filePath,
- }
-
- // Load existing sessions from file
- if err := store.load(); err != nil {
- fmt.Printf("Warning: Failed to load sessions from %s: %v\n", filePath, err)
- }
-
- return store
-}
-
-// load reads sessions from disk
-func (s *Store) load() error {
- if s.filePath == "" {
- return nil
- }
-
- data, err := os.ReadFile(s.filePath)
- if err != nil {
- if os.IsNotExist(err) {
- return nil // File doesn't exist yet, that's fine
- }
- return err
- }
-
- var sessions map[string]*Session
- if err := json.Unmarshal(data, &sessions); err != nil {
- return err
- }
-
- // Filter out expired sessions
- now := time.Now()
- for id, sess := range sessions {
- if now.Before(sess.ExpiresAt) {
- s.sessions[id] = sess
- }
- }
-
- fmt.Printf("Loaded %d active sessions from disk\n", len(s.sessions))
- return nil
-}
-
-// save writes sessions to disk
-func (s *Store) save() error {
- if s.filePath == "" {
- return nil
- }
-
- data, err := json.Marshal(s.sessions)
- if err != nil {
- return err
- }
-
- return os.WriteFile(s.filePath, data, 0600)
-}
-
-// Create creates a new session and returns the session ID
-func (s *Store) Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) {
- return s.CreateWithOAuth(did, handle, pdsEndpoint, "", duration)
-}
-
-// CreateWithOAuth creates a new session with OAuth sessionID and returns the session ID
-func (s *Store) CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- // Generate random session ID
- b := make([]byte, 32)
- if _, err := rand.Read(b); err != nil {
- return "", err
- }
-
- sess := &Session{
- ID: base64.URLEncoding.EncodeToString(b),
- DID: did,
- Handle: handle,
- PDSEndpoint: pdsEndpoint,
- OAuthSessionID: oauthSessionID,
- ExpiresAt: time.Now().Add(duration),
- }
-
- s.sessions[sess.ID] = sess
-
- // Save to disk
- if err := s.save(); err != nil {
- fmt.Printf("Warning: Failed to save sessions to disk: %v\n", err)
- }
-
- return sess.ID, nil
-}
-
-// Get retrieves a session by ID
-func (s *Store) Get(id string) (*Session, bool) {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- sess, ok := s.sessions[id]
- if !ok || time.Now().After(sess.ExpiresAt) {
- return nil, false
- }
-
- return sess, true
-}
-
-// Extend extends a session's expiration time
-func (s *Store) Extend(id string, duration time.Duration) error {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- sess, ok := s.sessions[id]
- if !ok {
- return fmt.Errorf("session not found: %s", id)
- }
-
- // Extend the expiration
- sess.ExpiresAt = time.Now().Add(duration)
-
- // Save to disk
- if err := s.save(); err != nil {
- fmt.Printf("Warning: Failed to save sessions to disk: %v\n", err)
- }
-
- return nil
-}
-
-// Delete removes a session
-func (s *Store) Delete(id string) {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- delete(s.sessions, id)
-
- // Save to disk
- if err := s.save(); err != nil {
- fmt.Printf("Warning: Failed to save sessions to disk: %v\n", err)
- }
-}
-
-// Cleanup removes expired sessions
-func (s *Store) Cleanup() {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- now := time.Now()
- deleted := 0
- for id, sess := range s.sessions {
- if now.After(sess.ExpiresAt) {
- delete(s.sessions, id)
- deleted++
- }
- }
-
- if deleted > 0 {
- // Save to disk
- if err := s.save(); err != nil {
- fmt.Printf("Warning: Failed to save sessions to disk: %v\n", err)
- }
- }
-}
-
-// SetCookie sets the session cookie
-func SetCookie(w http.ResponseWriter, sessionID string, maxAge int) {
- http.SetCookie(w, &http.Cookie{
- Name: "atcr_session",
- Value: sessionID,
- Path: "/",
- MaxAge: maxAge,
- HttpOnly: true,
- Secure: true,
- SameSite: http.SameSiteLaxMode,
- })
-}
-
-// ClearCookie clears the session cookie
-func ClearCookie(w http.ResponseWriter) {
- http.SetCookie(w, &http.Cookie{
- Name: "atcr_session",
- Value: "",
- Path: "/",
- MaxAge: -1,
- HttpOnly: true,
- Secure: true,
- SameSite: http.SameSiteLaxMode,
- })
-}
-
-// GetSessionID gets session ID from cookie
-func GetSessionID(r *http.Request) (string, bool) {
- cookie, err := r.Cookie("atcr_session")
- if err != nil {
- return "", false
- }
- return cookie.Value, true
-}
diff --git a/pkg/appview/static/css/style.css b/pkg/appview/static/css/style.css
index 4d96aa3..24b692c 100644
--- a/pkg/appview/static/css/style.css
+++ b/pkg/appview/static/css/style.css
@@ -293,6 +293,13 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
.push-repo {
font-weight: 500;
+ color: var(--fg);
+ text-decoration: none;
+}
+
+.push-repo:hover {
+ color: var(--primary);
+ text-decoration: underline;
}
.push-tag {
@@ -377,6 +384,16 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
margin: 0;
}
+.repo-title-link {
+ color: var(--fg);
+ text-decoration: none;
+}
+
+.repo-title-link:hover {
+ color: var(--primary);
+ text-decoration: underline;
+}
+
.repo-badge {
display: inline-flex;
align-items: center;
@@ -710,6 +727,191 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
text-decoration: underline;
}
+/* Repository Page */
+.repository-page {
+ max-width: 1000px;
+ margin: 0 auto;
+}
+
+.repository-header {
+ background:var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 2rem;
+ margin-bottom: 2rem;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05);
+}
+
+.repo-hero {
+ display: flex;
+ gap: 1.5rem;
+ align-items: flex-start;
+ margin-bottom: 1.5rem;
+}
+
+.repo-hero-icon {
+ width: 80px;
+ height: 80px;
+ border-radius: 12px;
+ object-fit: cover;
+ flex-shrink: 0;
+}
+
+.repo-hero-icon-placeholder {
+ width: 80px;
+ height: 80px;
+ border-radius: 12px;
+ background: var(--primary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: bold;
+ font-size: 2.5rem;
+ text-transform: uppercase;
+ color: white;
+ flex-shrink: 0;
+}
+
+.repo-hero-info {
+ flex: 1;
+}
+
+.repo-hero-info h1 {
+ font-size: 2rem;
+ margin: 0 0 0.5rem 0;
+}
+
+.owner-link {
+ color: var(--primary);
+ text-decoration: none;
+}
+
+.owner-link:hover {
+ text-decoration: underline;
+}
+
+.repo-separator {
+ color: #999;
+ margin: 0 0.25rem;
+}
+
+.repo-name {
+ color: var(--fg);
+}
+
+.repo-hero-description {
+ color: #555;
+ font-size: 1.1rem;
+ line-height: 1.5;
+ margin: 0.5rem 0 0 0;
+}
+
+.repo-metadata {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ flex-wrap: wrap;
+ margin-bottom: 1.5rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--border);
+}
+
+.metadata-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.3rem 0.75rem;
+ font-size: 0.85rem;
+ font-weight: 500;
+ border-radius: 16px;
+ white-space: nowrap;
+}
+
+.metadata-link {
+ color: var(--primary);
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.metadata-link:hover {
+ text-decoration: underline;
+}
+
+.pull-command-section {
+ padding-top: 1rem;
+ border-top: 1px solid var(--border);
+}
+
+.pull-command-section h3 {
+ font-size: 1rem;
+ margin-bottom: 0.75rem;
+ color: var(--secondary);
+}
+
+.repo-section {
+ background:var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 1.5rem;
+ margin-bottom: 2rem;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05);
+}
+
+.repo-section h2 {
+ font-size: 1.5rem;
+ margin-bottom: 1rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 2px solid var(--border);
+}
+
+.tags-list, .manifests-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.tag-item, .manifest-item {
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 1rem;
+ background: var(--hover-bg);
+}
+
+.tag-item-header, .manifest-item-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+.tag-name-large {
+ font-size: 1.2rem;
+ font-weight: 600;
+ color: var(--primary);
+}
+
+.tag-timestamp {
+ color: #666;
+ font-size: 0.9rem;
+}
+
+.tag-item-details {
+ margin-bottom: 0.75rem;
+}
+
+.manifest-item-details {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+ color: #666;
+ font-size: 0.9rem;
+ margin-top: 0.5rem;
+}
+
+.manifest-detail-label {
+ font-weight: 500;
+ color: var(--secondary);
+}
+
/* Responsive */
@media (max-width: 768px) {
.navbar {
@@ -734,4 +936,23 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
margin: 2rem auto;
padding: 1rem;
}
+
+ .repo-hero {
+ flex-direction: column;
+ }
+
+ .repo-hero-info h1 {
+ font-size: 1.5rem;
+ }
+
+ .tag-item-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+
+ .manifest-item-details {
+ flex-direction: column;
+ align-items: flex-start;
+ }
}
diff --git a/pkg/appview/templates/pages/images.html b/pkg/appview/templates/pages/images.html
index b771e7f..c9c021d 100644
--- a/pkg/appview/templates/pages/images.html
+++ b/pkg/appview/templates/pages/images.html
@@ -20,13 +20,13 @@
{{ range .Repositories }}
{{ $repoName := .Name }}
-