move file store cache to sqlite. implement repository page

This commit is contained in:
Evan Jarrett
2025-10-07 21:51:53 -05:00
parent 261f1d6547
commit 08d5fce21f
21 changed files with 1811 additions and 806 deletions
+60 -63
View File
@@ -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
}
+1 -5
View File
@@ -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:
+183
View File
@@ -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/)
+418
View File
@@ -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:])
}
+221
View File
@@ -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)
}
+137
View File
@@ -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
}
+64 -71
View File
@@ -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
}
}
+203
View File
@@ -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
}
-395
View File
@@ -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:])
}
+16 -17
View File
@@ -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
+67
View File
@@ -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
}
}
+50
View File
@@ -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
}
+13 -5
View File
@@ -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)
-228
View File
@@ -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
}
+221
View File
@@ -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;
}
}
+3 -3
View File
@@ -20,13 +20,13 @@
{{ range .Repositories }}
{{ $repoName := .Name }}
<div class="repository-card">
<div class="repo-header" onclick="toggleRepo('{{ $repoName }}')">
<div class="repo-header">
{{ if .IconURL }}
<img src="{{ .IconURL }}" alt="{{ $repoName }}" class="repo-icon">
{{ end }}
<div class="repo-info">
<div class="repo-title-row">
<h2>{{ if .Title }}{{ .Title }}{{ else }}{{ $repoName }}{{ end }}</h2>
<h2><a href="/r/{{ $.User.Handle }}/{{ $repoName }}" class="repo-title-link">{{ if .Title }}{{ .Title }}{{ else }}{{ $repoName }}{{ end }}</a></h2>
{{ if .Licenses }}
<span class="repo-badge license-badge">{{ .Licenses }}</span>
{{ end }}
@@ -52,7 +52,7 @@
{{ end }}
</div>
</div>
<button class="expand-btn" id="btn-{{ $repoName }}"></button>
<button class="expand-btn" id="btn-{{ $repoName }}" onclick="toggleRepo('{{ $repoName }}'); event.stopPropagation();"></button>
</div>
<div id="repo-{{ $repoName }}" class="repo-details" style="display: none;">
+139
View File
@@ -0,0 +1,139 @@
{{ define "repository" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ if .Repository.Title }}{{ .Repository.Title }}{{ else }}{{ .Owner.Handle }}/{{ .Repository.Name }}{{ end }} - ATCR</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="/static/js/app.js"></script>
</head>
<body>
{{ template "nav" . }}
<main class="container">
<div class="repository-page">
<!-- Repository Header -->
<div class="repository-header">
<div class="repo-hero">
{{ if .Repository.IconURL }}
<img src="{{ .Repository.IconURL }}" alt="{{ .Repository.Name }}" class="repo-hero-icon">
{{ else }}
<div class="repo-hero-icon-placeholder">{{ firstChar .Repository.Name }}</div>
{{ end }}
<div class="repo-hero-info">
<h1>
<a href="/u/{{ .Owner.Handle }}" class="owner-link">{{ .Owner.Handle }}</a>
<span class="repo-separator">/</span>
<span class="repo-name">{{ .Repository.Name }}</span>
</h1>
{{ if .Repository.Description }}
<p class="repo-hero-description">{{ .Repository.Description }}</p>
{{ end }}
</div>
</div>
<!-- Metadata Section -->
{{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL }}
<div class="repo-metadata">
{{ if .Repository.Licenses }}
<span class="metadata-badge license-badge">{{ .Repository.Licenses }}</span>
{{ end }}
{{ if .Repository.SourceURL }}
<a href="{{ .Repository.SourceURL }}" target="_blank" class="metadata-link">
Source
</a>
{{ end }}
{{ if .Repository.DocumentationURL }}
<a href="{{ .Repository.DocumentationURL }}" target="_blank" class="metadata-link">
Documentation
</a>
{{ end }}
</div>
{{ end }}
<!-- Pull Command -->
<div class="pull-command-section">
<h3>Pull this image</h3>
{{ if .Repository.Tags }}
{{ $firstTag := index .Repository.Tags 0 }}
<div class="push-command">
<code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag }}</code>
<button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag }}')">
Copy
</button>
</div>
{{ else }}
<div class="push-command">
<code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:latest</code>
<button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:latest')">
Copy
</button>
</div>
{{ end }}
</div>
</div>
<!-- Tags Section -->
<div class="repo-section">
<h2>Tags</h2>
{{ if .Repository.Tags }}
<div class="tags-list">
{{ range .Repository.Tags }}
<div class="tag-item">
<div class="tag-item-header">
<span class="tag-name-large">{{ .Tag }}</span>
<time class="tag-timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
{{ timeAgo .CreatedAt }}
</time>
</div>
<div class="tag-item-details">
<code class="digest" title="{{ .Digest }}">{{ truncateDigest .Digest 12 }}</code>
</div>
<div class="push-command">
<code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag }}</code>
<button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag }}')">
Copy
</button>
</div>
</div>
{{ end }}
</div>
{{ else }}
<p class="empty-message">No tags available</p>
{{ end }}
</div>
<!-- Manifests Section -->
<div class="repo-section">
<h2>Manifests</h2>
{{ if .Repository.Manifests }}
<div class="manifests-list">
{{ range .Repository.Manifests }}
<div class="manifest-item">
<div class="manifest-item-header">
<code class="manifest-digest" title="{{ .Digest }}">{{ truncateDigest .Digest 16 }}</code>
<time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
{{ timeAgo .CreatedAt }}
</time>
</div>
<div class="manifest-item-details">
<span class="manifest-detail-label">Storage:</span>
<span>{{ .HoldEndpoint }}</span>
</div>
</div>
{{ end }}
</div>
{{ else }}
<p class="empty-message">No manifests available</p>
{{ end }}
</div>
</div>
</main>
<!-- Modal container for HTMX -->
<div id="modal"></div>
</body>
</html>
{{ end }}
+1 -1
View File
@@ -27,7 +27,7 @@
{{ range .Pushes }}
<div class="push-card">
<div class="push-header">
<span class="push-repo">{{ .Repository }}</span>
<a href="/r/{{ $.ViewedUser.Handle }}/{{ .Repository }}" class="push-repo">{{ .Repository }}</a>
<span class="push-separator">:</span>
<span class="push-tag">{{ .Tag }}</span>
</div>
@@ -3,7 +3,7 @@
<div class="push-header">
<a href="/u/{{ .Handle }}" class="push-user">{{ .Handle }}</a>
<span class="push-separator">/</span>
<span class="push-repo">{{ .Repository }}</span>
<a href="/r/{{ .Handle }}/{{ .Repository }}" class="push-repo">{{ .Repository }}</a>
<span class="push-separator">:</span>
<span class="push-tag">{{ .Tag }}</span>
</div>
+10 -14
View File
@@ -81,23 +81,19 @@ func (r *Refresher) resumeSession(ctx context.Context, did string) (*oauth.Clien
return nil, fmt.Errorf("failed to parse DID: %w", err)
}
// Get all sessions for this DID from store
fileStore, ok := r.app.clientApp.Store.(*FileStore)
// Get the latest session for this DID from SQLite store
// The store must implement GetLatestSessionForDID (returns newest by updated_at)
type sessionGetter interface {
GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error)
}
getter, ok := r.app.clientApp.Store.(sessionGetter)
if !ok {
return nil, fmt.Errorf("store is not a FileStore")
return nil, fmt.Errorf("store must implement GetLatestSessionForDID (SQLite store required)")
}
// Find a session for this DID
sessions := fileStore.ListSessions()
var sessionID string
for _, sessionData := range sessions {
if sessionData.AccountDID.String() == did {
sessionID = sessionData.SessionID
break
}
}
if sessionID == "" {
_, sessionID, err := getter.GetLatestSessionForDID(ctx, did)
if err != nil {
return nil, fmt.Errorf("no session found for DID: %s", did)
}
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
"atcr.io/pkg/appview/device"
"atcr.io/pkg/appview/db"
mainAtproto "atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/atproto"
@@ -20,12 +20,12 @@ import (
type Handler struct {
issuer *Issuer
validator *atproto.SessionValidator
deviceStore *device.Store // For validating device secrets
deviceStore *db.DeviceStore // For validating device secrets
defaultHoldEndpoint string
}
// NewHandler creates a new token handler
func NewHandler(issuer *Issuer, deviceStore *device.Store, defaultHoldEndpoint string) *Handler {
func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore, defaultHoldEndpoint string) *Handler {
return &Handler{
issuer: issuer,
validator: atproto.NewSessionValidator(),