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 }}
-
+
{{ if .IconURL }} {{ $repoName }} {{ end }}
-

{{ if .Title }}{{ .Title }}{{ else }}{{ $repoName }}{{ end }}

+

{{ if .Title }}{{ .Title }}{{ else }}{{ $repoName }}{{ end }}

{{ if .Licenses }} {{ .Licenses }} {{ end }} @@ -52,7 +52,7 @@ {{ end }}
- +