mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-01 07:46:55 +00:00
move file store cache to sqlite. implement repository page
This commit is contained in:
+60
-63
@@ -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
@@ -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:
|
||||
|
||||
@@ -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/)
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user