mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 16:54:15 +00:00
cleanup more auth
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
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
|
||||
}
|
||||
@@ -16,7 +16,7 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push,
|
||||
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
|
||||
`
|
||||
|
||||
args := []interface{}{}
|
||||
args := []any{}
|
||||
|
||||
if userFilter != "" {
|
||||
query += " WHERE u.handle = ? OR u.did = ?"
|
||||
@@ -43,7 +43,7 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push,
|
||||
|
||||
// Get total count
|
||||
countQuery := "SELECT COUNT(*) FROM tags t JOIN users u ON t.did = u.did"
|
||||
countArgs := []interface{}{}
|
||||
countArgs := []any{}
|
||||
|
||||
if userFilter != "" {
|
||||
countQuery += " WHERE u.handle = ? OR u.did = ?"
|
||||
@@ -228,7 +228,7 @@ func DeleteManifestsNotInList(db *sql.DB, did string, keepDigests []string) erro
|
||||
|
||||
// Build placeholders for IN clause
|
||||
placeholders := make([]string, len(keepDigests))
|
||||
args := []interface{}{did}
|
||||
args := []any{did}
|
||||
for i, digest := range keepDigests {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, digest)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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)
|
||||
}
|
||||
@@ -28,16 +28,17 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Get OAuth session for the user
|
||||
session, err := h.Refresher.GetSession(r.Context(), user.DID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get session: "+err.Error(), http.StatusInternalServerError)
|
||||
// OAuth session not found or expired - redirect to re-authenticate
|
||||
fmt.Printf("WARNING [settings]: OAuth session not found for %s: %v - redirecting to login\n", user.DID, err)
|
||||
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract access token and HTTP client from session
|
||||
accessToken, _ := session.GetHostAccessData()
|
||||
httpClient := session.APIClient().Client
|
||||
// Use indigo's API client directly - it handles all auth automatically
|
||||
apiClient := session.APIClient()
|
||||
|
||||
// Create ATProto client with indigo's DPoP-configured HTTP client
|
||||
client := atproto.NewClientWithHTTPClient(user.PDSEndpoint, user.DID, accessToken, httpClient)
|
||||
// Create ATProto client with indigo's XRPC client
|
||||
client := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
|
||||
|
||||
// Fetch sailor profile
|
||||
profile, err := atproto.GetProfile(r.Context(), client)
|
||||
@@ -93,16 +94,17 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
|
||||
// Get OAuth session for the user
|
||||
session, err := h.Refresher.GetSession(r.Context(), user.DID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get session: "+err.Error(), http.StatusInternalServerError)
|
||||
// OAuth session not found or expired - redirect to re-authenticate
|
||||
fmt.Printf("WARNING [settings]: OAuth session not found for %s: %v - redirecting to login\n", user.DID, err)
|
||||
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract access token and HTTP client from session
|
||||
accessToken, _ := session.GetHostAccessData()
|
||||
httpClient := session.APIClient().Client
|
||||
// Use indigo's API client directly - it handles all auth automatically
|
||||
apiClient := session.APIClient()
|
||||
|
||||
// Create ATProto client with indigo's DPoP-configured HTTP client
|
||||
client := atproto.NewClientWithHTTPClient(user.PDSEndpoint, user.DID, accessToken, httpClient)
|
||||
// Create ATProto client with indigo's XRPC client
|
||||
client := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
|
||||
|
||||
// Fetch existing profile or create new one
|
||||
profile, err := atproto.GetProfile(r.Context(), client)
|
||||
|
||||
@@ -8,15 +8,18 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/identity"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// BackfillWorker uses com.atproto.sync.listReposByCollection to backfill historical data
|
||||
type BackfillWorker struct {
|
||||
db *sql.DB
|
||||
client *atproto.Client
|
||||
resolver *atproto.Resolver
|
||||
db *sql.DB
|
||||
client *atproto.Client
|
||||
directory identity.Directory
|
||||
}
|
||||
|
||||
// BackfillState tracks backfill progress
|
||||
@@ -36,9 +39,9 @@ func NewBackfillWorker(database *sql.DB, relayEndpoint string) (*BackfillWorker,
|
||||
client := atproto.NewClient(relayEndpoint, "", "")
|
||||
|
||||
return &BackfillWorker{
|
||||
db: database,
|
||||
client: client, // This points to the relay
|
||||
resolver: atproto.NewResolver(),
|
||||
db: database,
|
||||
client: client, // This points to the relay
|
||||
directory: identity.DefaultDirectory(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -117,11 +120,21 @@ func (b *BackfillWorker) backfillRepo(ctx context.Context, did, collection strin
|
||||
}
|
||||
|
||||
// Resolve DID to get user's PDS endpoint
|
||||
_, pdsEndpoint, err := b.resolver.ResolveIdentity(ctx, did)
|
||||
didParsed, err := syntax.ParseDID(did)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid DID %s: %w", did, err)
|
||||
}
|
||||
|
||||
ident, err := b.directory.LookupDID(ctx, didParsed)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to resolve DID to PDS: %w", err)
|
||||
}
|
||||
|
||||
pdsEndpoint := ident.PDSEndpoint()
|
||||
if pdsEndpoint == "" {
|
||||
return 0, fmt.Errorf("no PDS endpoint found for DID %s", did)
|
||||
}
|
||||
|
||||
// Create a client for this user's PDS
|
||||
pdsClient := atproto.NewClient(pdsEndpoint, "", "")
|
||||
|
||||
@@ -314,17 +327,40 @@ func (b *BackfillWorker) ensureUser(ctx context.Context, did string) error {
|
||||
}
|
||||
|
||||
// Resolve DID to get handle and PDS endpoint
|
||||
resolvedDID, pdsEndpoint, err := b.resolver.ResolveIdentity(ctx, did)
|
||||
didParsed, err := syntax.ParseDID(did)
|
||||
if err != nil {
|
||||
// Fallback: use DID as handle
|
||||
resolvedDID = did
|
||||
pdsEndpoint = "https://bsky.social"
|
||||
user := &db.User{
|
||||
DID: did,
|
||||
Handle: did,
|
||||
PDSEndpoint: "https://bsky.social",
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
return db.UpsertUser(b.db, user)
|
||||
}
|
||||
|
||||
// Get handle from DID document
|
||||
handle, err := b.resolver.ResolveHandleFromDID(ctx, resolvedDID)
|
||||
ident, err := b.directory.LookupDID(ctx, didParsed)
|
||||
if err != nil {
|
||||
handle = resolvedDID // Fallback to DID
|
||||
// Fallback: use DID as handle
|
||||
user := &db.User{
|
||||
DID: did,
|
||||
Handle: did,
|
||||
PDSEndpoint: "https://bsky.social",
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
return db.UpsertUser(b.db, user)
|
||||
}
|
||||
|
||||
resolvedDID := ident.DID.String()
|
||||
handle := ident.Handle.String()
|
||||
pdsEndpoint := ident.PDSEndpoint()
|
||||
|
||||
// If handle is invalid or PDS is missing, use defaults
|
||||
if handle == "handle.invalid" || handle == "" {
|
||||
handle = resolvedDID
|
||||
}
|
||||
if pdsEndpoint == "" {
|
||||
pdsEndpoint = "https://bsky.social"
|
||||
}
|
||||
|
||||
// Upsert to database
|
||||
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/identity"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -31,7 +34,7 @@ type Worker struct {
|
||||
wantedCollections []string
|
||||
debugCollectionCount int
|
||||
userCache *UserCache
|
||||
resolver *atproto.Resolver
|
||||
directory identity.Directory
|
||||
eventCallback EventCallback
|
||||
}
|
||||
|
||||
@@ -53,7 +56,7 @@ func NewWorker(database *sql.DB, jetstreamURL string, startCursor int64) *Worker
|
||||
userCache: &UserCache{
|
||||
cache: make(map[string]*db.User),
|
||||
},
|
||||
resolver: atproto.NewResolver(),
|
||||
directory: identity.DefaultDirectory(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,19 +201,44 @@ func (w *Worker) ensureUser(ctx context.Context, did string) error {
|
||||
}
|
||||
|
||||
// Resolve DID to get handle and PDS endpoint
|
||||
resolvedDID, pdsEndpoint, err := w.resolver.ResolveIdentity(ctx, did)
|
||||
didParsed, err := syntax.ParseDID(did)
|
||||
if err != nil {
|
||||
fmt.Printf("WARNING: Invalid DID %s: %v (using DID as handle)\n", did, err)
|
||||
// Fallback: use DID as handle
|
||||
user := &db.User{
|
||||
DID: did,
|
||||
Handle: did,
|
||||
PDSEndpoint: "https://bsky.social", // Default PDS endpoint as fallback
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
w.userCache.cache[did] = user
|
||||
return db.UpsertUser(w.db, user)
|
||||
}
|
||||
|
||||
ident, err := w.directory.LookupDID(ctx, didParsed)
|
||||
if err != nil {
|
||||
fmt.Printf("WARNING: Failed to resolve DID %s: %v (using DID as handle)\n", did, err)
|
||||
// Fallback: use DID as handle
|
||||
resolvedDID = did
|
||||
pdsEndpoint = "https://bsky.social" // Default PDS endpoint as fallback
|
||||
user := &db.User{
|
||||
DID: did,
|
||||
Handle: did,
|
||||
PDSEndpoint: "https://bsky.social", // Default PDS endpoint as fallback
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
w.userCache.cache[did] = user
|
||||
return db.UpsertUser(w.db, user)
|
||||
}
|
||||
|
||||
// Get handle from DID document
|
||||
handle, err := w.resolver.ResolveHandleFromDID(ctx, resolvedDID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARNING: Failed to get handle for DID %s: %v (using DID as handle)\n", resolvedDID, err)
|
||||
handle = resolvedDID // Fallback to DID
|
||||
resolvedDID := ident.DID.String()
|
||||
handle := ident.Handle.String()
|
||||
pdsEndpoint := ident.PDSEndpoint()
|
||||
|
||||
// If handle is invalid or PDS is missing, use defaults
|
||||
if handle == "handle.invalid" || handle == "" {
|
||||
handle = resolvedDID
|
||||
}
|
||||
if pdsEndpoint == "" {
|
||||
pdsEndpoint = "https://bsky.social"
|
||||
}
|
||||
|
||||
// Cache the user
|
||||
@@ -349,13 +377,13 @@ type JetstreamEvent struct {
|
||||
|
||||
// CommitEvent represents a commit event (create/update/delete)
|
||||
type CommitEvent struct {
|
||||
Rev string `json:"rev"`
|
||||
Operation string `json:"operation"` // "create", "update", "delete"
|
||||
Collection string `json:"collection"`
|
||||
RKey string `json:"rkey"`
|
||||
Record map[string]interface{} `json:"record,omitempty"`
|
||||
CID string `json:"cid,omitempty"`
|
||||
DID string `json:"-"` // Set from parent event
|
||||
Rev string `json:"rev"`
|
||||
Operation string `json:"operation"` // "create", "update", "delete"
|
||||
Collection string `json:"collection"`
|
||||
RKey string `json:"rkey"`
|
||||
Record map[string]any `json:"record,omitempty"`
|
||||
CID string `json:"cid,omitempty"`
|
||||
DID string `json:"-"` // Set from parent event
|
||||
}
|
||||
|
||||
// IdentityInfo represents an identity event
|
||||
|
||||
@@ -129,6 +129,27 @@ func (s *Store) Get(id string) (*Session, bool) {
|
||||
return sess, true
|
||||
}
|
||||
|
||||
// Extend extends a session's expiration time
|
||||
func (s *Store) Extend(id string, duration time.Duration) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
sess, ok := s.sessions[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("session not found: %s", id)
|
||||
}
|
||||
|
||||
// Extend the expiration
|
||||
sess.ExpiresAt = time.Now().Add(duration)
|
||||
|
||||
// Save to disk
|
||||
if err := s.save(); err != nil {
|
||||
fmt.Printf("Warning: Failed to save sessions to disk: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a session
|
||||
func (s *Store) Delete(id string) {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -57,6 +57,42 @@
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<!-- Existing Keys List -->
|
||||
<div class="keys-list">
|
||||
<h3>Your API Keys</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</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>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- OAuth Session Section -->
|
||||
<section class="settings-section">
|
||||
<h2>OAuth Session</h2>
|
||||
@@ -78,7 +114,246 @@
|
||||
<!-- 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
|
||||
(function() {
|
||||
// Generate key
|
||||
document.getElementById('generate-key-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('key-name').value;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/keys', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: `name=${encodeURIComponent(name)}`
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to generate key');
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
// 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>';
|
||||
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()
|
||||
: 'Never';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(key.name)}</td>
|
||||
<td>${createdDate}</td>
|
||||
<td>${lastUsed}</td>
|
||||
<td><button class="btn-danger" onclick="deleteKey('${key.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>';
|
||||
}
|
||||
}
|
||||
|
||||
// Delete key
|
||||
window.deleteKey = async function(id) {
|
||||
if (!confirm('Are you sure you want to revoke this key? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/keys/${id}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to delete key');
|
||||
}
|
||||
loadKeys();
|
||||
} catch (err) {
|
||||
alert('Error revoking key: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Escape HTML helper
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Load keys on page load
|
||||
loadKeys();
|
||||
})();
|
||||
</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;
|
||||
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;
|
||||
background: #e3f2fd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.usage-instructions h4 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.usage-instructions pre {
|
||||
background: #263238;
|
||||
color: #aed581;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
.usage-instructions code {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* API Keys Section Styles */
|
||||
.api-keys-section table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.api-keys-section th,
|
||||
.api-keys-section td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.api-keys-section th {
|
||||
background: #f5f5f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
.api-keys-section .btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.api-keys-section .btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
.generate-key {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
|
||||
Reference in New Issue
Block a user