mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 08:44:14 +00:00
update docker credential helper to remove configure and store devices, not api keys. improve ui, fetch profile image
This commit is contained in:
+173
-104
@@ -1,13 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -15,11 +18,11 @@ const (
|
||||
defaultAppViewURL = "http://127.0.0.1:5000"
|
||||
)
|
||||
|
||||
// CredentialStore represents the stored API key credentials
|
||||
type CredentialStore struct {
|
||||
APIKey string `json:"api_key"`
|
||||
Handle string `json:"handle"`
|
||||
AppViewURL string `json:"appview_url"`
|
||||
// DeviceConfig represents the stored device configuration
|
||||
type DeviceConfig struct {
|
||||
Handle string `json:"handle"`
|
||||
DeviceSecret string `json:"device_secret"`
|
||||
AppViewURL string `json:"appview_url"`
|
||||
}
|
||||
|
||||
// Docker credential helper protocol
|
||||
@@ -32,9 +35,34 @@ type Credentials struct {
|
||||
Secret string `json:"Secret,omitempty"`
|
||||
}
|
||||
|
||||
// Device authorization API types
|
||||
|
||||
type DeviceCodeRequest struct {
|
||||
DeviceName string `json:"device_name"`
|
||||
}
|
||||
|
||||
type DeviceCodeResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
type DeviceTokenRequest struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
}
|
||||
|
||||
type DeviceTokenResponse struct {
|
||||
DeviceSecret string `json:"device_secret,omitempty"`
|
||||
Handle string `json:"handle,omitempty"`
|
||||
DID string `json:"did,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase|configure [handle]>\n")
|
||||
fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase>\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -47,13 +75,6 @@ func main() {
|
||||
handleStore()
|
||||
case "erase":
|
||||
handleErase()
|
||||
case "configure":
|
||||
// Optional handle argument
|
||||
var handle string
|
||||
if len(os.Args) > 2 {
|
||||
handle = os.Args[2]
|
||||
}
|
||||
handleConfigure(handle)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
|
||||
os.Exit(1)
|
||||
@@ -69,22 +90,34 @@ func handleGet() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Load credentials from storage
|
||||
credsPath := getCredentialsPath()
|
||||
storedCreds, err := loadCredentials(credsPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading credentials: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Please run: docker-credential-atcr configure\n")
|
||||
os.Exit(1)
|
||||
// Load device configuration
|
||||
configPath := getConfigPath()
|
||||
deviceConfig, err := loadDeviceConfig(configPath)
|
||||
if err != nil || deviceConfig.DeviceSecret == "" {
|
||||
// First time - trigger device authorization
|
||||
fmt.Fprintf(os.Stderr, "No device configuration found. Starting device authorization...\n")
|
||||
|
||||
deviceConfig, err = authorizeDevice()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Device authorization failed: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "\nFallback: Use 'docker login atcr.io' with your ATProto app-password\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Save device configuration
|
||||
if err := saveDeviceConfig(configPath, deviceConfig); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to save device config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "✓ Device authorized successfully!\n")
|
||||
}
|
||||
|
||||
// Return credentials for Docker
|
||||
// Docker will send these as Basic Auth to /auth/token
|
||||
// The token handler will validate the API key and issue a registry JWT
|
||||
creds := Credentials{
|
||||
ServerURL: serverURL,
|
||||
Username: storedCreds.Handle, // Use handle as username
|
||||
Secret: storedCreds.APIKey, // API key as password
|
||||
Username: deviceConfig.Handle,
|
||||
Secret: deviceConfig.DeviceSecret,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(os.Stdout).Encode(creds); err != nil {
|
||||
@@ -101,9 +134,9 @@ func handleStore() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// For OAuth flow, we don't actually store credentials from docker login
|
||||
// The credentials are managed through the OAuth flow
|
||||
// This is a no-op for us
|
||||
// This is a no-op for the device auth flow
|
||||
// Users should use the automatic device authorization, not docker login
|
||||
// If they use docker login with app-password, that goes through /auth/token directly
|
||||
}
|
||||
|
||||
// handleErase removes stored credentials
|
||||
@@ -115,81 +148,103 @@ func handleErase() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Remove credentials file
|
||||
credsPath := getCredentialsPath()
|
||||
if err := os.Remove(credsPath); err != nil && !os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "Error removing credentials: %v\n", err)
|
||||
// Remove device configuration file
|
||||
configPath := getConfigPath()
|
||||
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "Error removing device config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// handleConfigure prompts for API key and saves credentials
|
||||
func handleConfigure(handle string) {
|
||||
fmt.Println("ATCR Credential Helper Configuration")
|
||||
fmt.Println("=====================================")
|
||||
fmt.Println()
|
||||
fmt.Println("You need an API key from the ATCR web UI.")
|
||||
fmt.Println()
|
||||
|
||||
// Get AppView URL from environment or use default
|
||||
// authorizeDevice performs the device authorization flow
|
||||
func authorizeDevice() (*DeviceConfig, error) {
|
||||
// Get AppView URL
|
||||
appViewURL := os.Getenv("ATCR_APPVIEW_URL")
|
||||
if appViewURL == "" {
|
||||
appViewURL = defaultAppViewURL
|
||||
}
|
||||
|
||||
// Auto-open settings page
|
||||
settingsURL := appViewURL + "/settings"
|
||||
fmt.Printf("Opening settings page: %s\n", settingsURL)
|
||||
fmt.Println("Log in and generate an API key if you haven't already.")
|
||||
fmt.Println()
|
||||
|
||||
if err := oauth.OpenBrowser(settingsURL); err != nil {
|
||||
fmt.Printf("Could not open browser. Please visit: %s\n\n", settingsURL)
|
||||
// Get device name (hostname)
|
||||
deviceName, err := os.Hostname()
|
||||
if err != nil {
|
||||
deviceName = "Unknown Device"
|
||||
}
|
||||
|
||||
// Prompt for credentials
|
||||
if handle == "" {
|
||||
fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ")
|
||||
if _, err := fmt.Scanln(&handle); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading handle: %v\n", err)
|
||||
os.Exit(1)
|
||||
// 1. Request device code
|
||||
fmt.Fprintf(os.Stderr, "Requesting device authorization...\n")
|
||||
|
||||
reqBody, _ := json.Marshal(DeviceCodeRequest{DeviceName: deviceName})
|
||||
resp, err := http.Post(appViewURL+"/auth/device/code", "application/json", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to request device code: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
||||
}
|
||||
|
||||
var codeResp DeviceCodeResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&codeResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode device code response: %w", err)
|
||||
}
|
||||
|
||||
// 2. Open browser for user to approve
|
||||
verificationURL := codeResp.VerificationURI + "?user_code=" + codeResp.UserCode
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nOpening browser for device authorization...\n")
|
||||
fmt.Fprintf(os.Stderr, "User code: %s\n", codeResp.UserCode)
|
||||
fmt.Fprintf(os.Stderr, "\nIf browser doesn't open, visit: %s\n\n", verificationURL)
|
||||
|
||||
if err := openBrowser(verificationURL); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not open browser: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Waiting for authorization...\n")
|
||||
|
||||
// 3. Poll for authorization completion
|
||||
pollInterval := time.Duration(codeResp.Interval) * time.Second
|
||||
timeout := time.Duration(codeResp.ExpiresIn) * time.Second
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(pollInterval)
|
||||
|
||||
// Poll token endpoint
|
||||
tokenReqBody, _ := json.Marshal(DeviceTokenRequest{DeviceCode: codeResp.DeviceCode})
|
||||
tokenResp, err := http.Post(appViewURL+"/auth/device/token", "application/json", bytes.NewReader(tokenReqBody))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Poll failed: %v\n", err)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Using handle: %s\n", handle)
|
||||
|
||||
var tokenResult DeviceTokenResponse
|
||||
json.NewDecoder(tokenResp.Body).Decode(&tokenResult)
|
||||
tokenResp.Body.Close()
|
||||
|
||||
if tokenResult.Error == "authorization_pending" {
|
||||
// Still waiting
|
||||
continue
|
||||
}
|
||||
|
||||
if tokenResult.Error != "" {
|
||||
return nil, fmt.Errorf("authorization failed: %s", tokenResult.Error)
|
||||
}
|
||||
|
||||
// Success!
|
||||
return &DeviceConfig{
|
||||
Handle: tokenResult.Handle,
|
||||
DeviceSecret: tokenResult.DeviceSecret,
|
||||
AppViewURL: appViewURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
fmt.Print("Enter your API key (from settings page): ")
|
||||
var apiKey string
|
||||
if _, err := fmt.Scanln(&apiKey); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading API key: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Validate key format
|
||||
if !strings.HasPrefix(apiKey, "atcr_") {
|
||||
fmt.Fprintf(os.Stderr, "Invalid API key format. Key should start with 'atcr_'\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Save credentials
|
||||
creds := &CredentialStore{
|
||||
Handle: handle,
|
||||
APIKey: apiKey,
|
||||
AppViewURL: appViewURL,
|
||||
}
|
||||
|
||||
if err := saveCredentials(getCredentialsPath(), creds); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error saving credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("✓ Configuration complete!")
|
||||
fmt.Println("You can now use docker push/pull with atcr.io")
|
||||
return nil, fmt.Errorf("authorization timeout")
|
||||
}
|
||||
|
||||
// getCredentialsPath returns the path to the credentials file
|
||||
func getCredentialsPath() string {
|
||||
// getConfigPath returns the path to the device configuration file
|
||||
func getConfigPath() string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err)
|
||||
@@ -202,34 +257,48 @@ func getCredentialsPath() string {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return filepath.Join(atcrDir, "credentials.json")
|
||||
return filepath.Join(atcrDir, "device.json")
|
||||
}
|
||||
|
||||
// loadCredentials loads the credentials from disk
|
||||
func loadCredentials(path string) (*CredentialStore, error) {
|
||||
// loadDeviceConfig loads the device configuration from disk
|
||||
func loadDeviceConfig(path string) (*DeviceConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read credentials file: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var creds CredentialStore
|
||||
if err := json.Unmarshal(data, &creds); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse credentials file: %w", err)
|
||||
var config DeviceConfig
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &creds, nil
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// saveCredentials saves the credentials to disk
|
||||
func saveCredentials(path string, creds *CredentialStore) error {
|
||||
data, err := json.MarshalIndent(creds, "", " ")
|
||||
// saveDeviceConfig saves the device configuration to disk
|
||||
func saveDeviceConfig(path string, config *DeviceConfig) error {
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal credentials: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write credentials file: %w", err)
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
// openBrowser opens the specified URL in the default browser
|
||||
func openBrowser(url string) error {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", url)
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform")
|
||||
}
|
||||
|
||||
return nil
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
+91
-26
@@ -23,8 +23,8 @@ import (
|
||||
|
||||
// UI components
|
||||
"atcr.io/pkg/appview"
|
||||
"atcr.io/pkg/appview/apikey"
|
||||
"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"
|
||||
@@ -92,13 +92,22 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("failed to create OAuth store: %w", err)
|
||||
}
|
||||
|
||||
// 2. Create API key store
|
||||
apiKeyStorePath := filepath.Join(filepath.Dir(storagePath), "api-keys.json")
|
||||
apiKeyStore, err := apikey.NewStore(apiKeyStorePath)
|
||||
// 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 API key store: %w", err)
|
||||
return fmt.Errorf("failed to create device store: %w", err)
|
||||
}
|
||||
fmt.Printf("Using API key storage path: %s\n", apiKeyStorePath)
|
||||
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()
|
||||
}
|
||||
}()
|
||||
|
||||
// 3. Get base URL from config or environment
|
||||
baseURL := os.Getenv("ATCR_BASE_URL")
|
||||
@@ -127,7 +136,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
middleware.SetGlobalRefresher(refresher)
|
||||
|
||||
// 7. Initialize UI components (get session store for OAuth integration)
|
||||
uiDatabase, uiSessionStore, uiTemplates, uiRouter := initializeUI(config, refresher, baseURL, apiKeyStore)
|
||||
uiDatabase, uiSessionStore, uiTemplates, uiRouter := initializeUI(config, oauthApp, refresher, baseURL, deviceStore)
|
||||
|
||||
// 8. Create OAuth server
|
||||
oauthServer := oauth.NewServer(oauthApp)
|
||||
@@ -137,6 +146,10 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
if uiSessionStore != nil {
|
||||
oauthServer.SetUISessionStore(uiSessionStore)
|
||||
}
|
||||
// Connect database for user avatar management
|
||||
if uiDatabase != nil {
|
||||
oauthServer.SetDatabase(uiDatabase)
|
||||
}
|
||||
|
||||
// 8. Initialize auth keys and create token issuer
|
||||
var issuer *token.Issuer
|
||||
@@ -187,12 +200,23 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
// Extract default hold endpoint from middleware config
|
||||
defaultHoldEndpoint := extractDefaultHoldEndpoint(config)
|
||||
|
||||
// Basic Auth token endpoint (supports API keys and app passwords)
|
||||
tokenHandler := token.NewHandler(issuer, apiKeyStore, defaultHoldEndpoint)
|
||||
// Basic Auth token endpoint (supports device secrets and app passwords)
|
||||
tokenHandler := token.NewHandler(issuer, deviceStore, defaultHoldEndpoint)
|
||||
tokenHandler.RegisterRoutes(mux)
|
||||
|
||||
// Device authorization endpoints (public)
|
||||
mux.Handle("/auth/device/code", &uihandlers.DeviceCodeHandler{
|
||||
Store: deviceStore,
|
||||
AppViewBaseURL: baseURL,
|
||||
})
|
||||
mux.Handle("/auth/device/token", &uihandlers.DeviceTokenHandler{
|
||||
Store: deviceStore,
|
||||
})
|
||||
|
||||
fmt.Printf("Auth endpoints enabled:\n")
|
||||
fmt.Printf(" - Basic Auth: /auth/token (API keys + app passwords)\n")
|
||||
fmt.Printf(" - Basic Auth: /auth/token (device secrets + app passwords)\n")
|
||||
fmt.Printf(" - Device Auth: /auth/device/code\n")
|
||||
fmt.Printf(" - Device Auth: /auth/device/token\n")
|
||||
fmt.Printf(" - OAuth: /auth/oauth/authorize\n")
|
||||
fmt.Printf(" - OAuth: /auth/oauth/callback\n")
|
||||
}
|
||||
@@ -326,7 +350,7 @@ func extractDefaultHoldEndpoint(config *configuration.Configuration) string {
|
||||
}
|
||||
|
||||
// initializeUI initializes the web UI components
|
||||
func initializeUI(config *configuration.Configuration, refresher *oauth.Refresher, baseURL string, apiKeyStore *apikey.Store) (*sql.DB, *appsession.Store, *template.Template, *mux.Router) {
|
||||
func initializeUI(config *configuration.Configuration, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *device.Store) (*sql.DB, *appsession.Store, *template.Template, *mux.Router) {
|
||||
// Check if UI is enabled (optional configuration)
|
||||
uiEnabled := os.Getenv("ATCR_UI_ENABLED")
|
||||
if uiEnabled == "false" {
|
||||
@@ -386,10 +410,14 @@ func initializeUI(config *configuration.Configuration, refresher *oauth.Refreshe
|
||||
Templates: templates,
|
||||
}).Methods("GET")
|
||||
|
||||
router.Handle("/auth/oauth/login", &uihandlers.LoginSubmitHandler{}).Methods("POST")
|
||||
router.Handle("/auth/oauth/login", &uihandlers.LoginSubmitHandler{
|
||||
Refresher: refresher,
|
||||
Directory: oauthApp.Directory(),
|
||||
SessionStore: sessionStore,
|
||||
}).Methods("POST")
|
||||
|
||||
// Public routes (with optional auth for navbar)
|
||||
router.Handle("/", appmiddleware.OptionalAuth(sessionStore)(
|
||||
router.Handle("/", appmiddleware.OptionalAuth(sessionStore, database)(
|
||||
&uihandlers.HomeHandler{
|
||||
DB: database,
|
||||
Templates: templates,
|
||||
@@ -397,7 +425,7 @@ func initializeUI(config *configuration.Configuration, refresher *oauth.Refreshe
|
||||
},
|
||||
)).Methods("GET")
|
||||
|
||||
router.Handle("/api/recent-pushes", appmiddleware.OptionalAuth(sessionStore)(
|
||||
router.Handle("/api/recent-pushes", appmiddleware.OptionalAuth(sessionStore, database)(
|
||||
&uihandlers.RecentPushesHandler{
|
||||
DB: database,
|
||||
Templates: templates,
|
||||
@@ -407,7 +435,7 @@ func initializeUI(config *configuration.Configuration, refresher *oauth.Refreshe
|
||||
|
||||
// Authenticated routes
|
||||
authRouter := router.NewRoute().Subrouter()
|
||||
authRouter.Use(appmiddleware.RequireAuth(sessionStore))
|
||||
authRouter.Use(appmiddleware.RequireAuth(sessionStore, database))
|
||||
|
||||
authRouter.Handle("/images", &uihandlers.ImagesHandler{
|
||||
DB: database,
|
||||
@@ -416,8 +444,9 @@ func initializeUI(config *configuration.Configuration, refresher *oauth.Refreshe
|
||||
}).Methods("GET")
|
||||
|
||||
authRouter.Handle("/settings", &uihandlers.SettingsHandler{
|
||||
Templates: templates,
|
||||
Refresher: refresher,
|
||||
Templates: templates,
|
||||
Refresher: refresher,
|
||||
RegistryURL: baseURL,
|
||||
}).Methods("GET")
|
||||
|
||||
authRouter.Handle("/api/profile/default-hold", &uihandlers.UpdateDefaultHoldHandler{
|
||||
@@ -432,17 +461,26 @@ func initializeUI(config *configuration.Configuration, refresher *oauth.Refreshe
|
||||
DB: database,
|
||||
}).Methods("DELETE")
|
||||
|
||||
// API key management routes
|
||||
authRouter.Handle("/api/keys", &uihandlers.GenerateAPIKeyHandler{
|
||||
Store: apiKeyStore,
|
||||
}).Methods("POST")
|
||||
|
||||
authRouter.Handle("/api/keys", &uihandlers.ListAPIKeysHandler{
|
||||
Store: apiKeyStore,
|
||||
// Device approval page (authenticated)
|
||||
authRouter.Handle("/device", &uihandlers.DeviceApprovalPageHandler{
|
||||
Store: deviceStore,
|
||||
SessionStore: sessionStore,
|
||||
}).Methods("GET")
|
||||
|
||||
authRouter.Handle("/api/keys/{id}", &uihandlers.DeleteAPIKeyHandler{
|
||||
Store: apiKeyStore,
|
||||
authRouter.Handle("/device/approve", &uihandlers.DeviceApproveHandler{
|
||||
Store: deviceStore,
|
||||
SessionStore: sessionStore,
|
||||
}).Methods("POST")
|
||||
|
||||
// Device management routes
|
||||
authRouter.Handle("/api/devices", &uihandlers.ListDevicesHandler{
|
||||
Store: deviceStore,
|
||||
SessionStore: sessionStore,
|
||||
}).Methods("GET")
|
||||
|
||||
authRouter.Handle("/api/devices/{id}", &uihandlers.RevokeDeviceHandler{
|
||||
Store: deviceStore,
|
||||
SessionStore: sessionStore,
|
||||
}).Methods("DELETE")
|
||||
|
||||
// Logout endpoint
|
||||
@@ -484,6 +522,7 @@ func initializeUI(config *configuration.Configuration, refresher *oauth.Refreshe
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Failed to create backfill worker: %v\n", err)
|
||||
} else {
|
||||
// Run initial backfill
|
||||
go func() {
|
||||
fmt.Printf("Backfill: Starting sync-based backfill from %s...\n", relayEndpoint)
|
||||
if err := backfillWorker.Start(context.Background()); err != nil {
|
||||
@@ -492,6 +531,32 @@ func initializeUI(config *configuration.Configuration, refresher *oauth.Refreshe
|
||||
fmt.Println("Backfill: Completed successfully!")
|
||||
}
|
||||
}()
|
||||
|
||||
// Start periodic backfill scheduler
|
||||
backfillInterval := os.Getenv("ATCR_BACKFILL_INTERVAL")
|
||||
if backfillInterval == "" {
|
||||
backfillInterval = "1h" // Default to 1 hour
|
||||
}
|
||||
interval, err := time.ParseDuration(backfillInterval)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Invalid ATCR_BACKFILL_INTERVAL '%s', using default 1h: %v\n", backfillInterval, err)
|
||||
interval = time.Hour
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
fmt.Printf("Backfill: Starting periodic backfill (runs every %s)...\n", interval)
|
||||
if err := backfillWorker.Start(context.Background()); err != nil {
|
||||
fmt.Printf("Backfill: Periodic backfill finished with error: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Backfill: Periodic backfill completed successfully!")
|
||||
}
|
||||
}
|
||||
}()
|
||||
fmt.Printf("Backfill: Periodic scheduler started (interval: %s)\n", interval)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ services:
|
||||
# UI database (firehose cache for web interface)
|
||||
- atcr-ui:/var/lib/atcr
|
||||
restart: unless-stopped
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 1.1.1.1
|
||||
networks:
|
||||
atcr-network:
|
||||
ipv4_address: 172.28.0.2
|
||||
@@ -46,6 +49,9 @@ services:
|
||||
volumes:
|
||||
- atcr-hold:/var/lib/atcr/hold
|
||||
restart: unless-stopped
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 1.1.1.1
|
||||
networks:
|
||||
atcr-network:
|
||||
ipv4_address: 172.28.0.3
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// APIKey represents a user's API key
|
||||
type APIKey struct {
|
||||
ID string `json:"id"` // UUID
|
||||
KeyHash string `json:"key_hash"` // bcrypt hash
|
||||
DID string `json:"did"` // Owner's DID
|
||||
Handle string `json:"handle"` // Owner's handle
|
||||
Name string `json:"name"` // User-provided name
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsed time.Time `json:"last_used"`
|
||||
}
|
||||
|
||||
// Store manages API keys
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]*APIKey // keyHash -> APIKey
|
||||
byDID map[string][]string // DID -> []keyHash
|
||||
filePath string // /var/lib/atcr/api-keys.json
|
||||
}
|
||||
|
||||
// persistentData is the structure saved to disk
|
||||
type persistentData struct {
|
||||
Keys []*APIKey `json:"keys"`
|
||||
}
|
||||
|
||||
// NewStore creates a new API key store
|
||||
func NewStore(filePath string) (*Store, error) {
|
||||
s := &Store{
|
||||
keys: make(map[string]*APIKey),
|
||||
byDID: make(map[string][]string),
|
||||
filePath: filePath,
|
||||
}
|
||||
|
||||
// Load existing keys from file
|
||||
if err := s.load(); err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to load API keys: %w", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Generate creates a new API key and returns the plaintext key (shown once)
|
||||
func (s *Store) Generate(did, handle, name string) (key string, keyID string, err error) {
|
||||
// Generate 32 random bytes
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
|
||||
// Format: atcr_<base64>
|
||||
key = "atcr_" + base64.RawURLEncoding.EncodeToString(b)
|
||||
|
||||
// Hash for storage
|
||||
keyHashBytes, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to hash key: %w", err)
|
||||
}
|
||||
keyHash := string(keyHashBytes)
|
||||
|
||||
// Generate ID
|
||||
keyID = uuid.New().String()
|
||||
|
||||
apiKey := &APIKey{
|
||||
ID: keyID,
|
||||
KeyHash: keyHash,
|
||||
DID: did,
|
||||
Handle: handle,
|
||||
Name: name,
|
||||
CreatedAt: time.Now(),
|
||||
LastUsed: time.Time{}, // Never used yet
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.keys[keyHash] = apiKey
|
||||
s.byDID[did] = append(s.byDID[did], keyHash)
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.save(); err != nil {
|
||||
return "", "", fmt.Errorf("failed to save keys: %w", err)
|
||||
}
|
||||
|
||||
// Return plaintext key (only time it's available)
|
||||
return key, keyID, nil
|
||||
}
|
||||
|
||||
// Validate checks if an API key is valid and returns the associated data
|
||||
func (s *Store) Validate(key string) (*APIKey, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// Try to match against all stored hashes
|
||||
for hash, apiKey := range s.keys {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(key)); err == nil {
|
||||
// Update last used asynchronously
|
||||
go s.UpdateLastUsed(hash)
|
||||
|
||||
// Return a copy to prevent external modifications
|
||||
keyCopy := *apiKey
|
||||
return &keyCopy, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid API key")
|
||||
}
|
||||
|
||||
// List returns all API keys for a DID (without plaintext keys)
|
||||
func (s *Store) List(did string) []*APIKey {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
keyHashes, ok := s.byDID[did]
|
||||
if !ok {
|
||||
return []*APIKey{}
|
||||
}
|
||||
|
||||
result := make([]*APIKey, 0, len(keyHashes))
|
||||
for _, hash := range keyHashes {
|
||||
if apiKey, ok := s.keys[hash]; ok {
|
||||
// Return copy without hash
|
||||
keyCopy := *apiKey
|
||||
keyCopy.KeyHash = "" // Don't expose hash
|
||||
result = append(result, &keyCopy)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Delete removes an API key
|
||||
func (s *Store) Delete(did, keyID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Find the key by DID and ID
|
||||
keyHashes, ok := s.byDID[did]
|
||||
if !ok {
|
||||
return fmt.Errorf("no keys found for DID: %s", did)
|
||||
}
|
||||
|
||||
var foundHash string
|
||||
for _, hash := range keyHashes {
|
||||
if apiKey, ok := s.keys[hash]; ok && apiKey.ID == keyID {
|
||||
foundHash = hash
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if foundHash == "" {
|
||||
return fmt.Errorf("key not found: %s", keyID)
|
||||
}
|
||||
|
||||
// Remove from keys map
|
||||
delete(s.keys, foundHash)
|
||||
|
||||
// Remove from byDID index
|
||||
newHashes := make([]string, 0, len(keyHashes)-1)
|
||||
for _, hash := range keyHashes {
|
||||
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(keyHash string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
apiKey, ok := s.keys[keyHash]
|
||||
if !ok {
|
||||
return fmt.Errorf("key not found")
|
||||
}
|
||||
|
||||
apiKey.LastUsed = time.Now()
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// load reads keys 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 keys: %w", err)
|
||||
}
|
||||
|
||||
// Rebuild in-memory structures
|
||||
for _, apiKey := range pd.Keys {
|
||||
s.keys[apiKey.KeyHash] = apiKey
|
||||
s.byDID[apiKey.DID] = append(s.byDID[apiKey.DID], apiKey.KeyHash)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// save writes keys to disk
|
||||
func (s *Store) save() error {
|
||||
// Collect all keys
|
||||
allKeys := make([]*APIKey, 0, len(s.keys))
|
||||
for _, apiKey := range s.keys {
|
||||
allKeys = append(allKeys, apiKey)
|
||||
}
|
||||
|
||||
pd := persistentData{
|
||||
Keys: allKeys,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(pd, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal keys: %w", err)
|
||||
}
|
||||
|
||||
// Write atomically with temp file + rename
|
||||
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
|
||||
}
|
||||
@@ -63,6 +63,20 @@ func Templates() (*template.Template, error) {
|
||||
}
|
||||
return digest[:length] + "..."
|
||||
},
|
||||
|
||||
"firstChar": func(s string) string {
|
||||
if len(s) == 0 {
|
||||
return "?"
|
||||
}
|
||||
return string([]rune(s)[0])
|
||||
},
|
||||
|
||||
"trimPrefix": func(s, prefix string) string {
|
||||
if len(s) >= len(prefix) && s[:len(prefix)] == prefix {
|
||||
return s[len(prefix):]
|
||||
}
|
||||
return s
|
||||
},
|
||||
}
|
||||
|
||||
tmpl := template.New("").Funcs(funcMap)
|
||||
|
||||
@@ -7,6 +7,7 @@ type User struct {
|
||||
DID string
|
||||
Handle string
|
||||
PDSEndpoint string
|
||||
Avatar string
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
|
||||
@@ -168,10 +168,10 @@ func GetUserRepositories(db *sql.DB, did string) ([]Repository, error) {
|
||||
func GetUserByDID(db *sql.DB, did string) (*User, error) {
|
||||
var user User
|
||||
err := db.QueryRow(`
|
||||
SELECT did, handle, pds_endpoint, last_seen
|
||||
SELECT did, handle, pds_endpoint, avatar, last_seen
|
||||
FROM users
|
||||
WHERE did = ?
|
||||
`, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &user.LastSeen)
|
||||
`, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &user.Avatar, &user.LastSeen)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
@@ -186,13 +186,14 @@ func GetUserByDID(db *sql.DB, did string) (*User, error) {
|
||||
// UpsertUser inserts or updates a user record
|
||||
func UpsertUser(db *sql.DB, user *User) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO users (did, handle, pds_endpoint, last_seen)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(did) DO UPDATE SET
|
||||
handle = excluded.handle,
|
||||
pds_endpoint = excluded.pds_endpoint,
|
||||
avatar = excluded.avatar,
|
||||
last_seen = excluded.last_seen
|
||||
`, user.DID, user.Handle, user.PDSEndpoint, user.LastSeen)
|
||||
`, user.DID, user.Handle, user.PDSEndpoint, user.Avatar, user.LastSeen)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
@@ -11,6 +12,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
did TEXT PRIMARY KEY,
|
||||
handle TEXT NOT NULL,
|
||||
pds_endpoint TEXT NOT NULL,
|
||||
avatar TEXT,
|
||||
last_seen TIMESTAMP NOT NULL,
|
||||
UNIQUE(handle)
|
||||
);
|
||||
@@ -90,5 +92,82 @@ func InitDB(path string) (*sql.DB, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Migration: Add avatar column if it doesn't exist
|
||||
_, err = db.Exec(`ALTER TABLE users ADD COLUMN avatar TEXT`)
|
||||
// Ignore error if column already exists
|
||||
if err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
||||
// 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())
|
||||
}
|
||||
|
||||
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,395 @@
|
||||
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:])
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"atcr.io/pkg/appview/apikey"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// GenerateAPIKeyHandler handles POST /api/keys
|
||||
type GenerateAPIKeyHandler struct {
|
||||
Store *apikey.Store
|
||||
}
|
||||
|
||||
func (h *GenerateAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
name = "Unnamed Key"
|
||||
}
|
||||
|
||||
key, keyID, err := h.Store.Generate(user.DID, user.Handle, name)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR [apikeys]: Failed to generate key for DID=%s: %v\n", user.DID, err)
|
||||
http.Error(w, "Failed to generate key", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("INFO [apikeys]: Generated API key for DID=%s, handle=%s, name=%s, keyID=%s\n",
|
||||
user.DID, user.Handle, name, keyID)
|
||||
|
||||
// Return key (shown once!)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"id": keyID,
|
||||
"key": key,
|
||||
})
|
||||
}
|
||||
|
||||
// ListAPIKeysHandler handles GET /api/keys
|
||||
type ListAPIKeysHandler struct {
|
||||
Store *apikey.Store
|
||||
}
|
||||
|
||||
func (h *ListAPIKeysHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
keys := h.Store.List(user.DID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(keys)
|
||||
}
|
||||
|
||||
// DeleteAPIKeyHandler handles DELETE /api/keys/{id}
|
||||
type DeleteAPIKeyHandler struct {
|
||||
Store *apikey.Store
|
||||
}
|
||||
|
||||
func (h *DeleteAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
keyID := vars["id"]
|
||||
|
||||
if err := h.Store.Delete(user.DID, keyID); err != nil {
|
||||
fmt.Printf("ERROR [apikeys]: Failed to delete key for DID=%s, keyID=%s: %v\n",
|
||||
user.DID, keyID, err)
|
||||
http.Error(w, "Failed to delete key", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("INFO [apikeys]: Deleted API key for DID=%s, keyID=%s\n", user.DID, keyID)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/identity"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
// LoginHandler shows the OAuth login form
|
||||
@@ -31,7 +37,16 @@ func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// LoginSubmitHandler processes the login form submission
|
||||
type LoginSubmitHandler struct{}
|
||||
type LoginSubmitHandler struct {
|
||||
Refresher *oauth.Refresher
|
||||
Directory identity.Directory
|
||||
SessionStore UISessionStore
|
||||
}
|
||||
|
||||
// UISessionStore is the interface for UI session management
|
||||
type UISessionStore interface {
|
||||
CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error)
|
||||
}
|
||||
|
||||
func (h *LoginSubmitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -41,12 +56,71 @@ func (h *LoginSubmitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
handle := r.FormValue("handle")
|
||||
returnTo := r.FormValue("return_to")
|
||||
if returnTo == "" {
|
||||
returnTo = "/"
|
||||
}
|
||||
|
||||
if handle == "" {
|
||||
http.Redirect(w, r, "/auth/oauth/login?return_to="+returnTo+"&error=handle_required", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Attempt silent login first
|
||||
if h.Refresher != nil && h.Directory != nil && h.SessionStore != nil {
|
||||
// Parse handle
|
||||
handleSyntax, err := syntax.ParseHandle(handle)
|
||||
if err == nil {
|
||||
// Resolve handle to identity (DID + PDS endpoint)
|
||||
ident, err := h.Directory.LookupHandle(r.Context(), handleSyntax)
|
||||
if err == nil {
|
||||
did := ident.DID.String()
|
||||
|
||||
// Try to get existing OAuth session
|
||||
_, err := h.Refresher.GetSession(r.Context(), did)
|
||||
if err == nil {
|
||||
// Found valid OAuth session! Create UI session silently
|
||||
fmt.Printf("DEBUG [auth]: Silent login successful for %s (DID: %s)\n", handle, did)
|
||||
|
||||
// Get PDS endpoint from identity
|
||||
pdsEndpoint := ident.PDSEndpoint()
|
||||
|
||||
// Get OAuth sessionID from refresher
|
||||
sessionID := h.Refresher.GetSessionID(did)
|
||||
|
||||
uiSessionID, err := h.SessionStore.CreateWithOAuth(did, handle, pdsEndpoint, sessionID, 30*24*time.Hour)
|
||||
if err == nil {
|
||||
// Set session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "atcr_session",
|
||||
Value: uiSessionID,
|
||||
Path: "/",
|
||||
MaxAge: 30 * 86400, // 30 days
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
// Redirect to return URL
|
||||
fmt.Printf("DEBUG [auth]: Silent login complete, redirecting to %s\n", returnTo)
|
||||
http.Redirect(w, r, returnTo, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("WARNING [auth]: Failed to create UI session during silent login: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("DEBUG [auth]: No valid OAuth session found for %s: %v\n", handle, err)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("DEBUG [auth]: Failed to resolve handle %s: %v\n", handle, err)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("DEBUG [auth]: Failed to parse handle %s: %v\n", handle, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Silent login failed or not configured - proceed with full OAuth flow
|
||||
fmt.Printf("DEBUG [auth]: Proceeding with full OAuth flow for %s\n", handle)
|
||||
|
||||
// Store return_to in cookie so callback can use it
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "oauth_return_to",
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"atcr.io/pkg/appview/device"
|
||||
"atcr.io/pkg/appview/session"
|
||||
)
|
||||
|
||||
// DeviceCodeRequest is the request to start device authorization
|
||||
type DeviceCodeRequest struct {
|
||||
DeviceName string `json:"device_name"`
|
||||
}
|
||||
|
||||
// DeviceCodeResponse is the response with user and device codes
|
||||
type DeviceCodeResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
// DeviceCodeHandler handles POST /auth/device/code
|
||||
type DeviceCodeHandler struct {
|
||||
Store *device.Store
|
||||
AppViewBaseURL string // e.g., "http://localhost:5000"
|
||||
}
|
||||
|
||||
func (h *DeviceCodeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req DeviceCodeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Default device name if not provided
|
||||
if req.DeviceName == "" {
|
||||
req.DeviceName = "Unknown Device"
|
||||
}
|
||||
|
||||
// Get client IP
|
||||
ip := getClientIP(r)
|
||||
|
||||
// Get user agent
|
||||
userAgent := r.UserAgent()
|
||||
|
||||
// Create pending authorization
|
||||
pending, err := h.Store.CreatePendingAuth(req.DeviceName, ip, userAgent)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to create authorization", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return device code info
|
||||
resp := DeviceCodeResponse{
|
||||
DeviceCode: pending.DeviceCode,
|
||||
UserCode: pending.UserCode,
|
||||
VerificationURI: h.AppViewBaseURL + "/device",
|
||||
ExpiresIn: 600, // 10 minutes
|
||||
Interval: 5, // Poll every 5 seconds
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// DeviceTokenRequest is the request to poll for device authorization
|
||||
type DeviceTokenRequest struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
}
|
||||
|
||||
// DeviceTokenResponse is the response with device secret or error
|
||||
type DeviceTokenResponse struct {
|
||||
DeviceSecret string `json:"device_secret,omitempty"`
|
||||
Handle string `json:"handle,omitempty"`
|
||||
DID string `json:"did,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// DeviceTokenHandler handles POST /auth/device/token
|
||||
type DeviceTokenHandler struct {
|
||||
Store *device.Store
|
||||
}
|
||||
|
||||
func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req DeviceTokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get pending authorization
|
||||
pending, ok := h.Store.GetPendingByDeviceCode(req.DeviceCode)
|
||||
if !ok {
|
||||
resp := DeviceTokenResponse{
|
||||
Error: "expired_token",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if approved
|
||||
if pending.ApprovedDID == "" {
|
||||
// Still pending
|
||||
resp := DeviceTokenResponse{
|
||||
Error: "authorization_pending",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
}
|
||||
|
||||
// Approved! Get device from store to find handle
|
||||
devices := h.Store.ListDevices(pending.ApprovedDID)
|
||||
var handle string
|
||||
for _, d := range devices {
|
||||
if d.DID == pending.ApprovedDID {
|
||||
handle = d.Handle
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Return device secret
|
||||
resp := DeviceTokenResponse{
|
||||
DeviceSecret: pending.DeviceSecret,
|
||||
Handle: handle,
|
||||
DID: pending.ApprovedDID,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// DeviceApprovalPageHandler handles GET /device
|
||||
type DeviceApprovalPageHandler struct {
|
||||
Store *device.Store
|
||||
SessionStore *session.Store
|
||||
}
|
||||
|
||||
func (h *DeviceApprovalPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is logged in
|
||||
sessionID, ok := session.GetSessionID(r)
|
||||
if !ok {
|
||||
// Not logged in - redirect to login with return URL
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "oauth_return_to",
|
||||
Value: r.URL.RequestURI(),
|
||||
Path: "/",
|
||||
MaxAge: 600, // 10 minutes
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
sess, ok := h.SessionStore.Get(sessionID)
|
||||
if !ok {
|
||||
// Invalid session
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "oauth_return_to",
|
||||
Value: r.URL.RequestURI(),
|
||||
Path: "/",
|
||||
MaxAge: 600,
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user code from query
|
||||
userCode := r.URL.Query().Get("user_code")
|
||||
if userCode == "" {
|
||||
http.Error(w, "user_code required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get pending authorization
|
||||
pending, ok := h.Store.GetPendingByUserCode(userCode)
|
||||
if !ok {
|
||||
h.renderError(w, "Invalid or expired authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already approved
|
||||
if pending.ApprovedDID != "" {
|
||||
h.renderSuccess(w, pending.DeviceName)
|
||||
return
|
||||
}
|
||||
|
||||
// Render approval page
|
||||
h.renderApprovalPage(w, sess.Handle, pending)
|
||||
}
|
||||
|
||||
// DeviceApproveRequest is the request to approve a device
|
||||
type DeviceApproveRequest struct {
|
||||
UserCode string `json:"user_code"`
|
||||
Approve bool `json:"approve"`
|
||||
}
|
||||
|
||||
// DeviceApproveHandler handles POST /device/approve
|
||||
type DeviceApproveHandler struct {
|
||||
Store *device.Store
|
||||
SessionStore *session.Store
|
||||
}
|
||||
|
||||
func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Check session
|
||||
sessionID, ok := session.GetSessionID(r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
sess, ok := h.SessionStore.Get(sessionID)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req DeviceApproveRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !req.Approve {
|
||||
// User denied
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// Approve the device
|
||||
_, err := h.Store.ApprovePending(req.UserCode, sess.DID, sess.Handle)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to approve: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "approved"})
|
||||
}
|
||||
|
||||
// ListDevicesHandler handles GET /api/devices
|
||||
type ListDevicesHandler struct {
|
||||
Store *device.Store
|
||||
SessionStore *session.Store
|
||||
}
|
||||
|
||||
func (h *ListDevicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Check session
|
||||
sessionID, ok := session.GetSessionID(r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
sess, ok := h.SessionStore.Get(sessionID)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Get devices for this user
|
||||
devices := h.Store.ListDevices(sess.DID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(devices)
|
||||
}
|
||||
|
||||
// RevokeDeviceHandler handles DELETE /api/devices/{id}
|
||||
type RevokeDeviceHandler struct {
|
||||
Store *device.Store
|
||||
SessionStore *session.Store
|
||||
}
|
||||
|
||||
func (h *RevokeDeviceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Check session
|
||||
sessionID, ok := session.GetSessionID(r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
sess, ok := h.SessionStore.Get(sessionID)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Get device ID from URL
|
||||
vars := mux.Vars(r)
|
||||
deviceID := vars["id"]
|
||||
if deviceID == "" {
|
||||
http.Error(w, "device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Revoke device
|
||||
if err := h.Store.RevokeDevice(sess.DID, deviceID); err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to revoke: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func (h *DeviceApprovalPageHandler) renderApprovalPage(w http.ResponseWriter, handle string, pending *device.PendingAuthorization) {
|
||||
tmpl := template.Must(template.New("approval").Parse(deviceApprovalTemplate))
|
||||
data := struct {
|
||||
Handle string
|
||||
DeviceName string
|
||||
UserCode string
|
||||
IPAddress string
|
||||
}{
|
||||
Handle: handle,
|
||||
DeviceName: pending.DeviceName,
|
||||
UserCode: pending.UserCode,
|
||||
IPAddress: pending.IPAddress,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl.Execute(w, data)
|
||||
}
|
||||
|
||||
func (h *DeviceApprovalPageHandler) renderSuccess(w http.ResponseWriter, deviceName string) {
|
||||
tmpl := template.Must(template.New("success").Parse(deviceSuccessTemplate))
|
||||
data := struct {
|
||||
DeviceName string
|
||||
}{
|
||||
DeviceName: deviceName,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl.Execute(w, data)
|
||||
}
|
||||
|
||||
func (h *DeviceApprovalPageHandler) renderError(w http.ResponseWriter, message string) {
|
||||
tmpl := template.Must(template.New("error").Parse(deviceErrorTemplate))
|
||||
data := struct {
|
||||
Message string
|
||||
}{
|
||||
Message: message,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
tmpl.Execute(w, data)
|
||||
}
|
||||
|
||||
func getClientIP(r *http.Request) string {
|
||||
// Check X-Forwarded-For header
|
||||
xff := r.Header.Get("X-Forwarded-For")
|
||||
if xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
|
||||
// Check X-Real-IP header
|
||||
xri := r.Header.Get("X-Real-IP")
|
||||
if xri != "" {
|
||||
return xri
|
||||
}
|
||||
|
||||
// Fall back to RemoteAddr
|
||||
parts := strings.Split(r.RemoteAddr, ":")
|
||||
if len(parts) > 0 {
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// HTML templates
|
||||
|
||||
const deviceApprovalTemplate = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authorize Device - ATCR</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
||||
.approval-box { background: #e3f2fd; border: 1px solid #90caf9; padding: 30px; border-radius: 8px; }
|
||||
.user-code { font-size: 32px; font-weight: bold; letter-spacing: 4px; text-align: center; margin: 20px 0; color: #1976d2; }
|
||||
.device-info { background: #fff; padding: 15px; border-radius: 4px; margin: 15px 0; }
|
||||
.device-info dt { font-weight: bold; margin-top: 10px; }
|
||||
.device-info dd { margin-left: 0; color: #666; }
|
||||
.actions { text-align: center; margin-top: 30px; }
|
||||
button { font-size: 16px; padding: 12px 30px; margin: 0 10px; border: none; border-radius: 4px; cursor: pointer; }
|
||||
.approve { background: #4caf50; color: white; }
|
||||
.approve:hover { background: #45a049; }
|
||||
.deny { background: #f44336; color: white; }
|
||||
.deny:hover { background: #da190b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="approval-box">
|
||||
<h1>Authorize Device</h1>
|
||||
<p>User: <strong>{{.Handle}}</strong></p>
|
||||
|
||||
<div class="user-code">{{.UserCode}}</div>
|
||||
|
||||
<div class="device-info">
|
||||
<dl>
|
||||
<dt>Device Name:</dt>
|
||||
<dd>{{.DeviceName}}</dd>
|
||||
<dt>IP Address:</dt>
|
||||
<dd>{{.IPAddress}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<p><strong>Do you want to authorize this device?</strong></p>
|
||||
<p>This device will be able to push and pull container images to your registry.</p>
|
||||
|
||||
<div class="actions">
|
||||
<button class="approve" onclick="approve(true)">Approve</button>
|
||||
<button class="deny" onclick="approve(false)">Deny</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function approve(approved) {
|
||||
const resp = await fetch('/device/approve', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
user_code: '{{.UserCode}}',
|
||||
approve: approved
|
||||
})
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
if (approved) {
|
||||
window.location.href = '/device?user_code={{.UserCode}}';
|
||||
} else {
|
||||
alert('Device authorization denied');
|
||||
window.location.href = '/';
|
||||
}
|
||||
} else {
|
||||
alert('Failed to process authorization');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const deviceSuccessTemplate = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Device Authorized - ATCR</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
||||
.success { background: #d4edda; border: 1px solid #c3e6cb; padding: 30px; border-radius: 8px; }
|
||||
h1 { color: #155724; }
|
||||
a { color: #007bff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="success">
|
||||
<h1>✓ Device Authorized!</h1>
|
||||
<p>Device <strong>{{.DeviceName}}</strong> has been successfully authorized.</p>
|
||||
<p>You can now close this window and return to your terminal.</p>
|
||||
<p><a href="/settings">View your authorized devices</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const deviceErrorTemplate = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authorization Error - ATCR</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
||||
.error { background: #f8d7da; border: 1px solid #f5c6cb; padding: 30px; border-radius: 8px; }
|
||||
h1 { color: #721c24; }
|
||||
a { color: #007bff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error">
|
||||
<h1>✗ Authorization Error</h1>
|
||||
<p>{{.Message}}</p>
|
||||
<p><a href="/">Return to home</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
@@ -14,8 +14,9 @@ import (
|
||||
|
||||
// SettingsHandler handles the settings page
|
||||
type SettingsHandler struct {
|
||||
Templates *template.Template
|
||||
Refresher *oauth.Refresher
|
||||
Templates *template.Template
|
||||
Refresher *oauth.Refresher
|
||||
RegistryURL string
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -60,10 +61,12 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
SessionExpiry time.Time
|
||||
Query string
|
||||
RegistryURL string
|
||||
}{
|
||||
User: user,
|
||||
SessionExpiry: time.Now().Add(24 * time.Hour), // TODO: Get from actual session
|
||||
Query: r.URL.Query().Get("q"),
|
||||
RegistryURL: h.RegistryURL,
|
||||
}
|
||||
|
||||
data.Profile.Handle = user.Handle
|
||||
|
||||
@@ -363,11 +363,24 @@ func (b *BackfillWorker) ensureUser(ctx context.Context, did string) error {
|
||||
pdsEndpoint = "https://bsky.social"
|
||||
}
|
||||
|
||||
// Fetch user's Bluesky profile (including avatar)
|
||||
// Use public Bluesky AppView API (doesn't require auth for public profiles)
|
||||
avatar := ""
|
||||
publicClient := atproto.NewClient("https://public.api.bsky.app", "", "")
|
||||
profile, err := publicClient.GetActorProfile(ctx, resolvedDID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARNING [backfill]: Failed to fetch profile for DID %s: %v\n", resolvedDID, err)
|
||||
// Continue without avatar
|
||||
} else {
|
||||
avatar = profile.Avatar
|
||||
}
|
||||
|
||||
// Upsert to database
|
||||
user := &db.User{
|
||||
DID: resolvedDID,
|
||||
Handle: handle,
|
||||
PDSEndpoint: pdsEndpoint,
|
||||
Avatar: avatar,
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ type Worker struct {
|
||||
userCache *UserCache
|
||||
directory identity.Directory
|
||||
eventCallback EventCallback
|
||||
connStartTime time.Time // Track when connection started for debugging
|
||||
}
|
||||
|
||||
// NewWorker creates a new Jetstream worker
|
||||
@@ -93,6 +94,9 @@ func (w *Worker) Start(ctx context.Context) error {
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Track connection start time for debugging
|
||||
w.connStartTime = time.Now()
|
||||
|
||||
// Create zstd decoder for decompressing messages
|
||||
decoder, err := zstd.NewReader(nil)
|
||||
if err != nil {
|
||||
@@ -122,6 +126,16 @@ func (w *Worker) Start(ctx context.Context) error {
|
||||
default:
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
// Calculate connection duration and idle time for debugging
|
||||
connDuration := time.Since(w.connStartTime)
|
||||
timeSinceLastEvent := time.Since(lastHeartbeat)
|
||||
|
||||
// Log detailed context about the failure
|
||||
fmt.Printf("Jetstream: Connection closed after %s\n", connDuration)
|
||||
fmt.Printf(" - Events in last 30s: %d\n", eventCount)
|
||||
fmt.Printf(" - Time since last event: %s\n", timeSinceLastEvent)
|
||||
fmt.Printf(" - Error: %v\n", err)
|
||||
|
||||
return fmt.Errorf("failed to read message: %w", err)
|
||||
}
|
||||
|
||||
@@ -241,11 +255,24 @@ func (w *Worker) ensureUser(ctx context.Context, did string) error {
|
||||
pdsEndpoint = "https://bsky.social"
|
||||
}
|
||||
|
||||
// Fetch user's Bluesky profile (including avatar)
|
||||
// Use public Bluesky AppView API (doesn't require auth for public profiles)
|
||||
avatar := ""
|
||||
publicClient := atproto.NewClient("https://public.api.bsky.app", "", "")
|
||||
profile, err := publicClient.GetActorProfile(ctx, resolvedDID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARNING [worker]: Failed to fetch profile for DID %s: %v\n", resolvedDID, err)
|
||||
// Continue without avatar
|
||||
} else {
|
||||
avatar = profile.Avatar
|
||||
}
|
||||
|
||||
// Cache the user
|
||||
user := &db.User{
|
||||
DID: resolvedDID,
|
||||
Handle: handle,
|
||||
PDSEndpoint: pdsEndpoint,
|
||||
Avatar: avatar,
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
w.userCache.cache[did] = user
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
@@ -13,7 +14,7 @@ type contextKey string
|
||||
const userKey contextKey = "user"
|
||||
|
||||
// RequireAuth is middleware that requires authentication
|
||||
func RequireAuth(store *session.Store) func(http.Handler) http.Handler {
|
||||
func RequireAuth(store *session.Store, 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)
|
||||
@@ -28,10 +29,15 @@ func RequireAuth(store *session.Store) func(http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
user := &db.User{
|
||||
DID: sess.DID,
|
||||
Handle: sess.Handle,
|
||||
PDSEndpoint: sess.PDSEndpoint,
|
||||
// Look up full user from database to get avatar
|
||||
user, err := db.GetUserByDID(database, sess.DID)
|
||||
if err != nil || user == nil {
|
||||
// Fallback to session data if DB lookup fails
|
||||
user = &db.User{
|
||||
DID: sess.DID,
|
||||
Handle: sess.Handle,
|
||||
PDSEndpoint: sess.PDSEndpoint,
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), userKey, user)
|
||||
@@ -41,16 +47,21 @@ func RequireAuth(store *session.Store) func(http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// OptionalAuth is middleware that optionally includes user if authenticated
|
||||
func OptionalAuth(store *session.Store) func(http.Handler) http.Handler {
|
||||
func OptionalAuth(store *session.Store, 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)
|
||||
if ok {
|
||||
if sess, ok := store.Get(sessionID); ok {
|
||||
user := &db.User{
|
||||
DID: sess.DID,
|
||||
Handle: sess.Handle,
|
||||
PDSEndpoint: sess.PDSEndpoint,
|
||||
// Look up full user from database to get avatar
|
||||
user, err := db.GetUserByDID(database, sess.DID)
|
||||
if err != nil || user == nil {
|
||||
// Fallback to session data if DB lookup fails
|
||||
user = &db.User{
|
||||
DID: sess.DID,
|
||||
Handle: sess.Handle,
|
||||
PDSEndpoint: sess.PDSEndpoint,
|
||||
}
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userKey, user)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
@@ -13,11 +13,12 @@ import (
|
||||
|
||||
// Session represents a user session
|
||||
type Session struct {
|
||||
ID string
|
||||
DID string
|
||||
Handle string
|
||||
PDSEndpoint string
|
||||
ExpiresAt time.Time
|
||||
ID string
|
||||
DID string
|
||||
Handle string
|
||||
PDSEndpoint string
|
||||
OAuthSessionID string // Store OAuth sessionID for resuming
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Store manages user sessions
|
||||
@@ -89,6 +90,11 @@ func (s *Store) save() error {
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -99,11 +105,12 @@ func (s *Store) Create(did, handle, pdsEndpoint string, duration time.Duration)
|
||||
}
|
||||
|
||||
sess := &Session{
|
||||
ID: base64.URLEncoding.EncodeToString(b),
|
||||
DID: did,
|
||||
Handle: handle,
|
||||
PDSEndpoint: pdsEndpoint,
|
||||
ExpiresAt: time.Now().Add(duration),
|
||||
ID: base64.URLEncoding.EncodeToString(b),
|
||||
DID: did,
|
||||
Handle: handle,
|
||||
PDSEndpoint: pdsEndpoint,
|
||||
OAuthSessionID: oauthSessionID,
|
||||
ExpiresAt: time.Now().Add(duration),
|
||||
}
|
||||
|
||||
s.sessions[sess.ID] = sess
|
||||
|
||||
@@ -32,7 +32,7 @@ body {
|
||||
/* Navigation */
|
||||
.navbar {
|
||||
background: var(--fg);
|
||||
color: white;
|
||||
color:var(--bg);
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -41,7 +41,7 @@ body {
|
||||
}
|
||||
|
||||
.nav-brand a {
|
||||
color: white;
|
||||
color:var(--bg);
|
||||
text-decoration: none;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
@@ -68,29 +68,121 @@ body {
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: white;
|
||||
color:var(--fg);
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background:var(--secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.user-handle {
|
||||
color: #aaa;
|
||||
/* User dropdown */
|
||||
.user-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
font-size: 1.2rem;
|
||||
.user-menu-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: transparent;
|
||||
color:var(--bg);
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.user-menu-btn:hover {
|
||||
background:var(--secondary);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-avatar-placeholder {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.user-handle {
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.user-menu-btn[aria-expanded="true"] .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
background:var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
min-width: 200px;
|
||||
overflow: hidden;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dropdown-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
background:var(--bg);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
color: var(--danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button, .btn, .btn-primary, .btn-secondary {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
color:var(--bg);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
@@ -104,13 +196,18 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Override nav-links color for primary button */
|
||||
.nav-links .btn-primary {
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
color: white;
|
||||
color:var(--bg);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -132,7 +229,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: white;
|
||||
background:var(--bg);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
@@ -286,7 +383,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
background: white;
|
||||
background:var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
@@ -468,7 +565,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
|
||||
}
|
||||
|
||||
.login-form {
|
||||
background: white;
|
||||
background:var(--bg);
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -72,3 +72,47 @@ function toggleRepo(name) {
|
||||
btn.textContent = '▼';
|
||||
}
|
||||
}
|
||||
|
||||
// User dropdown menu
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const menuBtn = document.getElementById('user-menu-btn');
|
||||
const dropdownMenu = document.getElementById('user-dropdown-menu');
|
||||
|
||||
if (menuBtn && dropdownMenu) {
|
||||
// Toggle dropdown on button click
|
||||
menuBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isExpanded = menuBtn.getAttribute('aria-expanded') === 'true';
|
||||
|
||||
if (isExpanded) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menuBtn.contains(e.target) && !dropdownMenu.contains(e.target)) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
// Close dropdown on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
function openDropdown() {
|
||||
menuBtn.setAttribute('aria-expanded', 'true');
|
||||
dropdownMenu.removeAttribute('hidden');
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
menuBtn.setAttribute('aria-expanded', 'false');
|
||||
dropdownMenu.setAttribute('hidden', '');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,12 +12,27 @@
|
||||
|
||||
<div class="nav-links">
|
||||
{{ if .User }}
|
||||
<a href="/images">Your Images</a>
|
||||
<span class="user-handle">@{{ .User.Handle }}</span>
|
||||
<a href="/settings" class="settings-icon" title="Settings">⚙️</a>
|
||||
<form action="/auth/logout" method="POST" style="display: inline;">
|
||||
<button type="submit" class="btn-link">Logout</button>
|
||||
</form>
|
||||
<div class="user-dropdown">
|
||||
<button class="user-menu-btn" id="user-menu-btn" aria-expanded="false" aria-haspopup="true">
|
||||
{{ if .User.Avatar }}
|
||||
<img src="{{ .User.Avatar }}" alt="{{ .User.Handle }}" class="user-avatar">
|
||||
{{ else }}
|
||||
<div class="user-avatar-placeholder">{{ firstChar .User.Handle }}</div>
|
||||
{{ end }}
|
||||
<span class="user-handle">@{{ .User.Handle }}</span>
|
||||
<svg class="dropdown-arrow" width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<path d="M6 9L1 4h10z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="dropdown-menu" id="user-dropdown-menu" hidden>
|
||||
<a href="/images" class="dropdown-item">Your Images</a>
|
||||
<a href="/settings" class="dropdown-item">Settings</a>
|
||||
<hr class="dropdown-divider">
|
||||
<form action="/auth/logout" method="POST">
|
||||
<button type="submit" class="dropdown-item logout-btn">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<a href="/auth/oauth/login?return_to=/" class="btn-primary">Login</a>
|
||||
{{ end }}
|
||||
|
||||
@@ -57,37 +57,53 @@
|
||||
<div id="hold-status"></div>
|
||||
</section>
|
||||
|
||||
<!-- API Keys Section -->
|
||||
<section class="settings-section api-keys-section">
|
||||
<h2>API Keys</h2>
|
||||
<p>Generate API keys for Docker CLI and CI/CD. Each key is linked to your OAuth session.</p>
|
||||
<!-- Authorized Devices Section -->
|
||||
<section class="settings-section devices-section">
|
||||
<h2>Authorized Devices</h2>
|
||||
<p>Devices authorized via <code>docker-credential-atcr</code> credential helper.</p>
|
||||
|
||||
<!-- Generate New Key -->
|
||||
<div class="generate-key">
|
||||
<h3>Generate New API Key</h3>
|
||||
<form id="generate-key-form">
|
||||
<div class="form-group">
|
||||
<label for="key-name">Key Name:</label>
|
||||
<input type="text" id="key-name" name="key-name" placeholder="e.g., My Laptop, CI/CD" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Generate Key</button>
|
||||
</form>
|
||||
<!-- Setup Instructions -->
|
||||
<div class="setup-instructions">
|
||||
<h3>First Time Setup</h3>
|
||||
<ol>
|
||||
<li>Install credential helper:
|
||||
<pre><code>brew install atcr-credential-helper</code></pre>
|
||||
(or download from releases)
|
||||
</li>
|
||||
<li>Configure Docker to use the helper. Add to <code>~/.docker/config.json</code>:
|
||||
<pre><code>{
|
||||
"credHelpers": {
|
||||
"{{ .RegistryURL | trimPrefix "http://" | trimPrefix "https://" }}": "atcr"
|
||||
}
|
||||
}</code></pre>
|
||||
</li>
|
||||
<li>Run any Docker command:
|
||||
<pre><code>docker pull {{ .RegistryURL | trimPrefix "http://" | trimPrefix "https://" }}/{{ .Profile.Handle }}/myimage</code></pre>
|
||||
</li>
|
||||
<li>Browser will open for authorization - click Approve</li>
|
||||
<li>Done! Device is automatically authorized</li>
|
||||
</ol>
|
||||
|
||||
<div class="fallback-note">
|
||||
<strong>Fallback:</strong> Use app-password with <code>docker login {{ .RegistryURL | trimPrefix "http://" | trimPrefix "https://" }}</code> for quick start (no device tracking)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Keys List -->
|
||||
<div class="keys-list">
|
||||
<h3>Your API Keys</h3>
|
||||
<!-- Devices List -->
|
||||
<div class="devices-list">
|
||||
<h3>Your Authorized Devices</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Device Name</th>
|
||||
<th>IP Address</th>
|
||||
<th>Created</th>
|
||||
<th>Last Used</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="keys-table">
|
||||
<tr><td colspan="4">Loading...</td></tr>
|
||||
<tbody id="devices-table">
|
||||
<tr><td colspan="5">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -111,134 +127,65 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal container for HTMX -->
|
||||
<div id="modal"></div>
|
||||
|
||||
<!-- API Key Modal (shown once after generation) -->
|
||||
<div id="key-modal" class="modal hidden">
|
||||
<div class="modal-backdrop" onclick="closeKeyModal()"></div>
|
||||
<div class="modal-content">
|
||||
<h3>✓ API Key Generated!</h3>
|
||||
<p><strong>Copy this key now - it won't be shown again:</strong></p>
|
||||
<div class="key-display">
|
||||
<code id="generated-key"></code>
|
||||
<button class="btn-secondary" onclick="copyKey()">Copy to Clipboard</button>
|
||||
</div>
|
||||
<div class="usage-instructions">
|
||||
<h4>Using with Docker:</h4>
|
||||
<p><strong>Direct login (quick start)</strong></p>
|
||||
<pre><code>docker login atcr.io -u {{ .Profile.Handle }} -p [paste key here]</code></pre>
|
||||
<p><strong>Credential helper (if you opened this from configure)</strong></p>
|
||||
<p>Just paste your handle and this key when prompted in the terminal.</p>
|
||||
</div>
|
||||
<button class="btn-primary" onclick="closeKeyModal()">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
|
||||
<script>
|
||||
// API Key Management JavaScript
|
||||
// Device Management JavaScript
|
||||
(function() {
|
||||
// Generate key
|
||||
document.getElementById('generate-key-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('key-name').value;
|
||||
|
||||
// Load devices
|
||||
async function loadDevices() {
|
||||
try {
|
||||
const resp = await fetch('/api/keys', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: `name=${encodeURIComponent(name)}`
|
||||
});
|
||||
|
||||
const resp = await fetch('/api/devices');
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to generate key');
|
||||
throw new Error('Failed to load devices');
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
const devices = await resp.json();
|
||||
const tbody = document.getElementById('devices-table');
|
||||
|
||||
// Show key in modal (only time it's available)
|
||||
document.getElementById('generated-key').textContent = data.key;
|
||||
document.getElementById('key-modal').classList.remove('hidden');
|
||||
|
||||
// Clear form
|
||||
document.getElementById('key-name').value = '';
|
||||
|
||||
// Refresh keys list
|
||||
loadKeys();
|
||||
} catch (err) {
|
||||
alert('Error generating key: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Copy key to clipboard
|
||||
window.copyKey = function() {
|
||||
const key = document.getElementById('generated-key').textContent;
|
||||
navigator.clipboard.writeText(key).then(() => {
|
||||
alert('Copied to clipboard!');
|
||||
}).catch(err => {
|
||||
alert('Failed to copy: ' + err.message);
|
||||
});
|
||||
};
|
||||
|
||||
// Close modal
|
||||
window.closeKeyModal = function() {
|
||||
document.getElementById('key-modal').classList.add('hidden');
|
||||
};
|
||||
|
||||
// Load existing keys
|
||||
async function loadKeys() {
|
||||
try {
|
||||
const resp = await fetch('/api/keys');
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to load keys');
|
||||
}
|
||||
|
||||
const keys = await resp.json();
|
||||
const tbody = document.getElementById('keys-table');
|
||||
|
||||
if (keys.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4">No API keys yet. Generate one above!</td></tr>';
|
||||
if (devices.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5">No authorized devices yet. Follow the setup instructions above!</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = keys.map(key => {
|
||||
const createdDate = new Date(key.created_at).toLocaleDateString();
|
||||
const lastUsed = key.last_used && key.last_used !== '0001-01-01T00:00:00Z'
|
||||
? new Date(key.last_used).toLocaleDateString()
|
||||
tbody.innerHTML = devices.map(device => {
|
||||
const createdDate = new Date(device.created_at).toLocaleDateString();
|
||||
const lastUsed = device.last_used && device.last_used !== '0001-01-01T00:00:00Z'
|
||||
? new Date(device.last_used).toLocaleDateString()
|
||||
: 'Never';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(key.name)}</td>
|
||||
<td>${escapeHtml(device.name)}</td>
|
||||
<td>${escapeHtml(device.ip_address || 'Unknown')}</td>
|
||||
<td>${createdDate}</td>
|
||||
<td>${lastUsed}</td>
|
||||
<td><button class="btn-danger" onclick="deleteKey('${key.id}')">Revoke</button></td>
|
||||
<td><button class="btn-danger" onclick="revokeDevice('${device.id}')">Revoke</button></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('Error loading keys:', err);
|
||||
document.getElementById('keys-table').innerHTML =
|
||||
'<tr><td colspan="4">Error loading keys</td></tr>';
|
||||
console.error('Error loading devices:', err);
|
||||
document.getElementById('devices-table').innerHTML =
|
||||
'<tr><td colspan="5">Error loading devices</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// Delete key
|
||||
window.deleteKey = async function(id) {
|
||||
if (!confirm('Are you sure you want to revoke this key? This cannot be undone.')) {
|
||||
// Revoke device
|
||||
window.revokeDevice = async function(id) {
|
||||
if (!confirm('Are you sure you want to revoke this device? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/keys/${id}`, { method: 'DELETE' });
|
||||
const resp = await fetch(`/api/devices/${id}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to delete key');
|
||||
throw new Error('Failed to revoke device');
|
||||
}
|
||||
loadKeys();
|
||||
loadDevices();
|
||||
} catch (err) {
|
||||
alert('Error revoking key: ' + err.message);
|
||||
alert('Error revoking device: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -249,94 +196,65 @@
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Load keys on page load
|
||||
loadKeys();
|
||||
// Load devices on page load
|
||||
loadDevices();
|
||||
|
||||
// Refresh devices every 30 seconds (to show new authorizations)
|
||||
setInterval(loadDevices, 30000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* API Key Modal Styles */
|
||||
.modal.hidden { display: none; }
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
z-index: 1001;
|
||||
}
|
||||
.key-display {
|
||||
background: #f5f5f5;
|
||||
padding: 1rem;
|
||||
/* Devices Section Styles */
|
||||
.devices-section .setup-instructions {
|
||||
margin: 1rem 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.key-display code {
|
||||
word-break: break-all;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.usage-instructions {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: #e3f2fd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.usage-instructions h4 {
|
||||
.devices-section .setup-instructions h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.usage-instructions pre {
|
||||
.devices-section .setup-instructions ol {
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
.devices-section .setup-instructions li {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.devices-section .setup-instructions pre {
|
||||
background: #263238;
|
||||
color: #aed581;
|
||||
padding: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 0.5rem 0 0 0;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.usage-instructions code {
|
||||
.devices-section .setup-instructions code {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* API Keys Section Styles */
|
||||
.api-keys-section table {
|
||||
.devices-section .fallback-note {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.devices-section table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.api-keys-section th,
|
||||
.api-keys-section td {
|
||||
.devices-section th,
|
||||
.devices-section td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.api-keys-section th {
|
||||
.devices-section th {
|
||||
background: #f5f5f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
.api-keys-section .btn-danger {
|
||||
.devices-section .btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
@@ -344,14 +262,11 @@
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.api-keys-section .btn-danger:hover {
|
||||
.devices-section .btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
.generate-key {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
.devices-list {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
|
||||
@@ -433,3 +433,140 @@ func (c *Client) ListRecordsForRepo(ctx context.Context, repoDID, collection str
|
||||
|
||||
return result.Records, result.Cursor, nil
|
||||
}
|
||||
|
||||
// ActorProfile represents a Bluesky actor profile (from AppView)
|
||||
type ActorProfile struct {
|
||||
DID string `json:"did"`
|
||||
Handle string `json:"handle"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"` // CDN URL from AppView
|
||||
}
|
||||
|
||||
// ProfileRecord represents the app.bsky.actor.profile record (from PDS)
|
||||
type ProfileRecord struct {
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Avatar *ATProtoBlobRef `json:"avatar,omitempty"` // Blob reference
|
||||
Banner *ATProtoBlobRef `json:"banner,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
// GetActorProfile fetches an actor's profile from their PDS
|
||||
// The actor parameter can be a DID or handle
|
||||
func (c *Client) GetActorProfile(ctx context.Context, actor string) (*ActorProfile, error) {
|
||||
// Use indigo API client (OAuth with DPoP)
|
||||
if c.useIndigoClient && c.indigoClient != nil {
|
||||
params := map[string]any{
|
||||
"actor": actor,
|
||||
}
|
||||
|
||||
var profile ActorProfile
|
||||
err := c.indigoClient.Get(ctx, "app.bsky.actor.getProfile", params, &profile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getProfile failed: %w", err)
|
||||
}
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
// Basic Auth (app passwords)
|
||||
url := fmt.Sprintf("%s/xrpc/app.bsky.actor.getProfile?actor=%s", c.pdsEndpoint, actor)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// This endpoint typically doesn't require auth for public profiles
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get profile: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("get profile failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var profile ActorProfile
|
||||
if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode profile: %w", err)
|
||||
}
|
||||
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
// GetProfileRecord fetches the app.bsky.actor.profile record from PDS
|
||||
// This returns the raw profile record with blob references (not CDN URLs)
|
||||
func (c *Client) GetProfileRecord(ctx context.Context, did string) (*ProfileRecord, error) {
|
||||
// Use indigo API client (OAuth with DPoP)
|
||||
if c.useIndigoClient && c.indigoClient != nil {
|
||||
params := map[string]any{
|
||||
"repo": did,
|
||||
"collection": "app.bsky.actor.profile",
|
||||
"rkey": "self",
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Value ProfileRecord `json:"value"`
|
||||
}
|
||||
|
||||
err := c.indigoClient.Get(ctx, "com.atproto.repo.getRecord", params, &result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getRecord failed: %w", err)
|
||||
}
|
||||
return &result.Value, nil
|
||||
}
|
||||
|
||||
// Basic Auth (app passwords)
|
||||
url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=app.bsky.actor.profile&rkey=self",
|
||||
c.pdsEndpoint, did)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get profile record: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("profile record not found")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("get profile record failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Value ProfileRecord `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode profile record: %w", err)
|
||||
}
|
||||
|
||||
return &result.Value, nil
|
||||
}
|
||||
|
||||
// BlobCDNURL constructs an imgs.blue CDN URL for a blob
|
||||
// The imgs.blue service can serve blobs using DID or handle
|
||||
func BlobCDNURL(didOrHandle, cid string) string {
|
||||
return fmt.Sprintf("https://imgs.blue/%s/%s", didOrHandle, cid)
|
||||
}
|
||||
|
||||
@@ -88,6 +88,11 @@ func (a *App) GetClientApp() *oauth.ClientApp {
|
||||
return a.clientApp
|
||||
}
|
||||
|
||||
// Directory returns the identity directory used by the OAuth app
|
||||
func (a *App) Directory() identity.Directory {
|
||||
return a.directory
|
||||
}
|
||||
|
||||
// ClientID generates the OAuth client ID for ATCR
|
||||
func ClientID(baseURL string) string {
|
||||
return ClientIDWithScopes(baseURL, GetDefaultScopes())
|
||||
@@ -115,11 +120,8 @@ func RedirectURI(baseURL string) string {
|
||||
func GetDefaultScopes() []string {
|
||||
return []string{
|
||||
"atproto",
|
||||
"transition:generic.full",
|
||||
"blob:application/vnd.docker.distribution.manifest.v2+json",
|
||||
fmt.Sprintf("repo:%s?action=create", atproto.ManifestCollection),
|
||||
fmt.Sprintf("repo:%s?action=update", atproto.ManifestCollection),
|
||||
fmt.Sprintf("repo:%s?action=create", atproto.TagCollection),
|
||||
fmt.Sprintf("repo:%s?action=update", atproto.TagCollection),
|
||||
fmt.Sprintf("repo:%s", atproto.ManifestCollection),
|
||||
fmt.Sprintf("repo:%s", atproto.TagCollection),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,3 +125,17 @@ func (r *Refresher) InvalidateSession(did string) {
|
||||
delete(r.sessions, did)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetSessionID returns the sessionID for a cached session
|
||||
// Returns empty string if session not cached
|
||||
func (r *Refresher) GetSessionID(did string) string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
cached, ok := r.sessions[did]
|
||||
if !ok || cached == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return cached.SessionID
|
||||
}
|
||||
|
||||
+125
-15
@@ -1,10 +1,16 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
// UISessionStore is the interface for UI session management
|
||||
@@ -12,11 +18,17 @@ type UISessionStore interface {
|
||||
Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error)
|
||||
}
|
||||
|
||||
// UserStore is the interface for user management
|
||||
type UserStore interface {
|
||||
UpsertUser(did, handle, pdsEndpoint, avatar string) error
|
||||
}
|
||||
|
||||
// Server handles OAuth authorization for the AppView
|
||||
type Server struct {
|
||||
app *App
|
||||
refresher *Refresher
|
||||
uiSessionStore UISessionStore
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewServer creates a new OAuth server
|
||||
@@ -36,6 +48,11 @@ func (s *Server) SetUISessionStore(store UISessionStore) {
|
||||
s.uiSessionStore = store
|
||||
}
|
||||
|
||||
// SetDatabase sets the database for user management
|
||||
func (s *Server) SetDatabase(db *sql.DB) {
|
||||
s.db = db
|
||||
}
|
||||
|
||||
// ServeAuthorize handles GET /auth/oauth/authorize
|
||||
func (s *Server) ServeAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -107,26 +124,52 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
|
||||
handle = did // Fallback to DID if resolution fails
|
||||
}
|
||||
|
||||
// Fetch user's Bluesky profile (including avatar) and store in database
|
||||
if s.db != nil {
|
||||
s.fetchAndStoreAvatar(r.Context(), did, sessionID, handle, sessionData.HostURL)
|
||||
}
|
||||
|
||||
// Check if this is a UI login (has oauth_return_to cookie)
|
||||
if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil {
|
||||
// Create UI session (30 days to match OAuth refresh token lifetime)
|
||||
uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 30*24*time.Hour)
|
||||
if err != nil {
|
||||
s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err))
|
||||
return
|
||||
// Store OAuth sessionID so we can resume it on next login
|
||||
if store, ok := s.uiSessionStore.(interface {
|
||||
CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error)
|
||||
}); ok {
|
||||
uiSessionID, err := store.CreateWithOAuth(did, handle, sessionData.HostURL, sessionID, 30*24*time.Hour)
|
||||
if err != nil {
|
||||
s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err))
|
||||
return
|
||||
}
|
||||
// Set UI session cookie and redirect (code below)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "atcr_session",
|
||||
Value: uiSessionID,
|
||||
Path: "/",
|
||||
MaxAge: 30 * 86400, // 30 days
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
} else {
|
||||
// Fallback for stores that don't support OAuth sessionID
|
||||
uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 30*24*time.Hour)
|
||||
if err != nil {
|
||||
s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err))
|
||||
return
|
||||
}
|
||||
// Set UI session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "atcr_session",
|
||||
Value: uiSessionID,
|
||||
Path: "/",
|
||||
MaxAge: 30 * 86400, // 30 days
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// Set UI session cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "atcr_session",
|
||||
Value: uiSessionID,
|
||||
Path: "/",
|
||||
MaxAge: 30 * 86400, // 30 days
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
// Clear the return_to cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "oauth_return_to",
|
||||
@@ -180,6 +223,73 @@ func (s *Server) renderError(w http.ResponseWriter, message string) {
|
||||
}
|
||||
}
|
||||
|
||||
// fetchAndStoreAvatar fetches the user's Bluesky profile and stores avatar in database
|
||||
func (s *Server) fetchAndStoreAvatar(ctx context.Context, did, sessionID, handle, pdsEndpoint string) {
|
||||
fmt.Printf("DEBUG [oauth/server]: Fetching avatar for DID=%s from PDS=%s\n", did, pdsEndpoint)
|
||||
|
||||
// Parse DID for session resume
|
||||
didParsed, err := syntax.ParseDID(did)
|
||||
if err != nil {
|
||||
fmt.Printf("WARNING [oauth/server]: Failed to parse DID %s: %v\n", did, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Resume OAuth session to get authenticated client
|
||||
session, err := s.app.ResumeSession(ctx, didParsed, sessionID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARNING [oauth/server]: Failed to resume session for DID=%s: %v\n", did, err)
|
||||
// Fallback: update user without avatar
|
||||
_ = db.UpsertUser(s.db, &db.User{
|
||||
DID: did,
|
||||
Handle: handle,
|
||||
PDSEndpoint: pdsEndpoint,
|
||||
Avatar: "",
|
||||
LastSeen: time.Now(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create authenticated atproto client using the indigo session's API client
|
||||
client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, session.APIClient())
|
||||
|
||||
// Fetch user's profile record from PDS (contains blob references)
|
||||
profileRecord, err := client.GetProfileRecord(ctx, did)
|
||||
if err != nil {
|
||||
fmt.Printf("WARNING [oauth/server]: Failed to fetch profile record for DID=%s: %v\n", did, err)
|
||||
// Still update user without avatar
|
||||
_ = db.UpsertUser(s.db, &db.User{
|
||||
DID: did,
|
||||
Handle: handle,
|
||||
PDSEndpoint: pdsEndpoint,
|
||||
Avatar: "",
|
||||
LastSeen: time.Now(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Construct avatar URL from blob CID using imgs.blue CDN
|
||||
var avatarURL string
|
||||
if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" {
|
||||
avatarURL = atproto.BlobCDNURL(did, profileRecord.Avatar.Ref.Link)
|
||||
fmt.Printf("DEBUG [oauth/server]: Constructed avatar URL: %s\n", avatarURL)
|
||||
}
|
||||
|
||||
// Store user with avatar in database
|
||||
err = db.UpsertUser(s.db, &db.User{
|
||||
DID: did,
|
||||
Handle: handle,
|
||||
PDSEndpoint: pdsEndpoint,
|
||||
Avatar: avatarURL,
|
||||
LastSeen: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("WARNING [oauth/server]: Failed to store user in database: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG [oauth/server]: Stored user with avatar for DID=%s\n", did)
|
||||
}
|
||||
|
||||
// HTML templates
|
||||
|
||||
const redirectToSettingsTemplate = `
|
||||
|
||||
+13
-13
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/bluesky-social/indigo/atproto/identity"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
|
||||
"atcr.io/pkg/appview/apikey"
|
||||
"atcr.io/pkg/appview/device"
|
||||
mainAtproto "atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
"atcr.io/pkg/auth/atproto"
|
||||
@@ -20,16 +20,16 @@ import (
|
||||
type Handler struct {
|
||||
issuer *Issuer
|
||||
validator *atproto.SessionValidator
|
||||
apiKeyStore *apikey.Store // For validating API keys
|
||||
deviceStore *device.Store // For validating device secrets
|
||||
defaultHoldEndpoint string
|
||||
}
|
||||
|
||||
// NewHandler creates a new token handler
|
||||
func NewHandler(issuer *Issuer, apiKeyStore *apikey.Store, defaultHoldEndpoint string) *Handler {
|
||||
func NewHandler(issuer *Issuer, deviceStore *device.Store, defaultHoldEndpoint string) *Handler {
|
||||
return &Handler{
|
||||
issuer: issuer,
|
||||
validator: atproto.NewSessionValidator(),
|
||||
apiKeyStore: apiKeyStore,
|
||||
deviceStore: deviceStore,
|
||||
defaultHoldEndpoint: defaultHoldEndpoint,
|
||||
}
|
||||
}
|
||||
@@ -83,25 +83,25 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var handle string
|
||||
var accessToken string
|
||||
|
||||
// 1. Check if it's an API key (starts with "atcr_")
|
||||
if strings.HasPrefix(password, "atcr_") {
|
||||
apiKey, err := h.apiKeyStore.Validate(password)
|
||||
// 1. Check if it's a device secret (starts with "atcr_device_")
|
||||
if strings.HasPrefix(password, "atcr_device_") {
|
||||
device, err := h.deviceStore.ValidateDeviceSecret(password)
|
||||
if err != nil {
|
||||
fmt.Printf("DEBUG [token/handler]: API key validation failed: %v\n", err)
|
||||
fmt.Printf("DEBUG [token/handler]: Device secret validation failed: %v\n", err)
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`)
|
||||
http.Error(w, "authentication failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
did = apiKey.DID
|
||||
handle = apiKey.Handle
|
||||
fmt.Printf("DEBUG [token/handler]: API key validated for DID=%s, handle=%s\n", did, handle)
|
||||
did = device.DID
|
||||
handle = device.Handle
|
||||
fmt.Printf("DEBUG [token/handler]: Device secret validated for DID=%s, handle=%s\n", did, handle)
|
||||
|
||||
// API key is linked to OAuth session
|
||||
// Device is linked to OAuth session via DID
|
||||
// OAuth refresher will provide access token when needed via middleware
|
||||
} else {
|
||||
// 2. Try app password (direct PDS authentication)
|
||||
fmt.Printf("DEBUG [token/handler]: Not an API key, trying app password for %s\n", username)
|
||||
fmt.Printf("DEBUG [token/handler]: Not a device secret, trying app password for %s\n", username)
|
||||
did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password)
|
||||
if err != nil {
|
||||
fmt.Printf("DEBUG [token/handler]: App password validation failed: %v\n", err)
|
||||
|
||||
Reference in New Issue
Block a user