begin embedded pds with xrpc endpoints and well-known
This commit is contained in:
@@ -3,6 +3,7 @@ package hold
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/distribution/distribution/v3/configuration"
|
||||
@@ -14,6 +15,7 @@ type Config struct {
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Registration RegistrationConfig `yaml:"registration"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
}
|
||||
|
||||
// RegistrationConfig defines auto-registration settings
|
||||
@@ -57,6 +59,17 @@ type ServerConfig struct {
|
||||
WriteTimeout time.Duration `yaml:"write_timeout"`
|
||||
}
|
||||
|
||||
// DatabaseConfig defines embedded PDS database settings
|
||||
type DatabaseConfig struct {
|
||||
// Path is the directory path for carstore (from env: HOLD_DATABASE_DIR)
|
||||
// If empty, embedded PDS is disabled
|
||||
Path string `yaml:"path"`
|
||||
|
||||
// KeyPath is the path to the signing key (from env: HOLD_KEY_PATH)
|
||||
// Defaults to {Path}/signing.key
|
||||
KeyPath string `yaml:"key_path"`
|
||||
}
|
||||
|
||||
// LoadConfigFromEnv loads all configuration from environment variables
|
||||
func LoadConfigFromEnv() (*Config, error) {
|
||||
cfg := &Config{
|
||||
@@ -79,6 +92,15 @@ func LoadConfigFromEnv() (*Config, error) {
|
||||
cfg.Registration.OwnerDID = os.Getenv("HOLD_OWNER")
|
||||
cfg.Registration.AllowAllCrew = os.Getenv("HOLD_ALLOW_ALL_CREW") == "true"
|
||||
|
||||
// Database configuration (optional - enables embedded PDS)
|
||||
// Note: HOLD_DATABASE_DIR is a directory path, carstore creates db.sqlite3 inside it
|
||||
cfg.Database.Path = getEnvOrDefault("HOLD_DATABASE_DIR", "/var/lib/atcr-hold")
|
||||
cfg.Database.KeyPath = os.Getenv("HOLD_KEY_PATH")
|
||||
if cfg.Database.KeyPath == "" && cfg.Database.Path != "" {
|
||||
// Default: signing key in same directory as carstore
|
||||
cfg.Database.KeyPath = filepath.Join(cfg.Database.Path, "signing.key")
|
||||
}
|
||||
|
||||
// Storage configuration - build from env vars based on storage type
|
||||
storageType := getEnvOrDefault("STORAGE_DRIVER", "s3")
|
||||
var err error
|
||||
|
||||
44
pkg/hold/pds/blobstore_adapter.go
Normal file
44
pkg/hold/pds/blobstore_adapter.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package pds
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"atcr.io/pkg/hold"
|
||||
)
|
||||
|
||||
// HoldServiceBlobStore adapts the hold service to implement the BlobStore interface
|
||||
type HoldServiceBlobStore struct {
|
||||
service *hold.HoldService
|
||||
holdDID string
|
||||
}
|
||||
|
||||
// NewHoldServiceBlobStore creates a blob store adapter for the hold service
|
||||
func NewHoldServiceBlobStore(service *hold.HoldService, holdDID string) *HoldServiceBlobStore {
|
||||
return &HoldServiceBlobStore{
|
||||
service: service,
|
||||
holdDID: holdDID,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPresignedDownloadURL returns a presigned URL for downloading a blob
|
||||
func (b *HoldServiceBlobStore) GetPresignedDownloadURL(digest string) (string, error) {
|
||||
// Use the hold service's existing presigned URL logic
|
||||
// We need to expose a wrapper method on HoldService
|
||||
ctx := context.Background()
|
||||
url, err := b.service.GetPresignedURL(ctx, hold.OperationGet, digest, b.holdDID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// GetPresignedUploadURL returns a presigned URL for uploading a blob
|
||||
func (b *HoldServiceBlobStore) GetPresignedUploadURL(digest string) (string, error) {
|
||||
// Use the hold service's existing presigned URL logic
|
||||
ctx := context.Background()
|
||||
url, err := b.service.GetPresignedURL(ctx, hold.OperationPut, digest, b.holdDID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
113
pkg/hold/pds/crew.go
Normal file
113
pkg/hold/pds/crew.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package pds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/ipfs/go-cid"
|
||||
)
|
||||
|
||||
// CrewRecord represents a crew member in the hold
|
||||
type CrewRecord struct {
|
||||
Member string `json:"member" cborgen:"member"` // DID of the crew member
|
||||
Role string `json:"role" cborgen:"role"` // "admin" or "member"
|
||||
Permissions []string `json:"permissions" cborgen:"permissions"` // e.g., ["blob:read", "blob:write"]
|
||||
AddedAt time.Time `json:"addedAt" cborgen:"addedAt"`
|
||||
}
|
||||
|
||||
// MarshalCBOR implements cbg.CBORMarshaler
|
||||
func (c *CrewRecord) MarshalCBOR(w io.Writer) error {
|
||||
// TODO: Implement proper CBOR marshaling
|
||||
return fmt.Errorf("CBOR marshaling not yet implemented")
|
||||
}
|
||||
|
||||
// UnmarshalCBOR implements cbg.CBORUnmarshaler
|
||||
func (c *CrewRecord) UnmarshalCBOR(r io.Reader) error {
|
||||
// TODO: Implement proper CBOR unmarshaling
|
||||
return fmt.Errorf("CBOR unmarshaling not yet implemented")
|
||||
}
|
||||
|
||||
const (
|
||||
CrewCollection = "io.atcr.hold.crew"
|
||||
)
|
||||
|
||||
// AddCrewMember adds a new crew member to the hold
|
||||
func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string) (cid.Cid, error) {
|
||||
crewRecord := &CrewRecord{
|
||||
Member: memberDID,
|
||||
Role: role,
|
||||
Permissions: permissions,
|
||||
AddedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Create record in repo
|
||||
recordCID, rkey, err := p.repo.CreateRecord(ctx, CrewCollection, crewRecord)
|
||||
if err != nil {
|
||||
return cid.Undef, fmt.Errorf("failed to create crew record: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Commit the changes
|
||||
// For now, just return the CID
|
||||
_ = rkey // We'll use rkey for GetCrewMember later
|
||||
|
||||
return recordCID, nil
|
||||
}
|
||||
|
||||
// GetCrewMember retrieves a crew member by their record key
|
||||
func (p *HoldPDS) GetCrewMember(ctx context.Context, rkey string) (*CrewRecord, error) {
|
||||
path := fmt.Sprintf("%s/%s", CrewCollection, rkey)
|
||||
|
||||
_, rec, err := p.repo.GetRecord(ctx, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get crew record: %w", err)
|
||||
}
|
||||
|
||||
crewRecord, ok := rec.(*CrewRecord)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("record is not a CrewRecord")
|
||||
}
|
||||
|
||||
return crewRecord, nil
|
||||
}
|
||||
|
||||
// ListCrewMembers returns all crew members
|
||||
func (p *HoldPDS) ListCrewMembers(ctx context.Context) ([]*CrewRecord, error) {
|
||||
var crew []*CrewRecord
|
||||
|
||||
err := p.repo.ForEach(ctx, CrewCollection, func(k string, v cid.Cid) error {
|
||||
// Get the full record
|
||||
path := fmt.Sprintf("%s/%s", CrewCollection, k)
|
||||
_, rec, err := p.repo.GetRecord(ctx, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if crewRecord, ok := rec.(*CrewRecord); ok {
|
||||
crew = append(crew, crewRecord)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list crew members: %w", err)
|
||||
}
|
||||
|
||||
return crew, nil
|
||||
}
|
||||
|
||||
// RemoveCrewMember removes a crew member
|
||||
func (p *HoldPDS) RemoveCrewMember(ctx context.Context, rkey string) error {
|
||||
path := fmt.Sprintf("%s/%s", CrewCollection, rkey)
|
||||
|
||||
err := p.repo.DeleteRecord(ctx, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete crew record: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Commit the changes
|
||||
|
||||
return nil
|
||||
}
|
||||
150
pkg/hold/pds/did.go
Normal file
150
pkg/hold/pds/did.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package pds
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DIDDocument represents a did:web document
|
||||
type DIDDocument struct {
|
||||
Context []string `json:"@context"`
|
||||
ID string `json:"id"`
|
||||
AlsoKnownAs []string `json:"alsoKnownAs,omitempty"`
|
||||
VerificationMethod []VerificationMethod `json:"verificationMethod"`
|
||||
Authentication []string `json:"authentication,omitempty"`
|
||||
AssertionMethod []string `json:"assertionMethod,omitempty"`
|
||||
Service []Service `json:"service,omitempty"`
|
||||
}
|
||||
|
||||
// VerificationMethod represents a public key in a DID document
|
||||
type VerificationMethod struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Controller string `json:"controller"`
|
||||
PublicKeyMultibase string `json:"publicKeyMultibase"`
|
||||
}
|
||||
|
||||
// Service represents a service endpoint in a DID document
|
||||
type Service struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ServiceEndpoint string `json:"serviceEndpoint"`
|
||||
}
|
||||
|
||||
// GenerateDIDDocument creates a DID document for a did:web identity
|
||||
func (p *HoldPDS) GenerateDIDDocument(publicURL string) (*DIDDocument, error) {
|
||||
// Extract hostname from public URL
|
||||
hostname := strings.TrimPrefix(publicURL, "http://")
|
||||
hostname = strings.TrimPrefix(hostname, "https://")
|
||||
hostname = strings.Split(hostname, "/")[0] // Remove any path
|
||||
hostname = strings.Split(hostname, ":")[0] // Remove port for DID
|
||||
|
||||
did := fmt.Sprintf("did:web:%s", hostname)
|
||||
|
||||
// Convert public key to multibase format
|
||||
publicKeyMultibase, err := encodePublicKeyMultibase(p.signingKey.PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode public key: %w", err)
|
||||
}
|
||||
|
||||
doc := &DIDDocument{
|
||||
Context: []string{
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
},
|
||||
ID: did,
|
||||
VerificationMethod: []VerificationMethod{
|
||||
{
|
||||
ID: fmt.Sprintf("%s#atproto", did),
|
||||
Type: "Multikey",
|
||||
Controller: did,
|
||||
PublicKeyMultibase: publicKeyMultibase,
|
||||
},
|
||||
},
|
||||
Authentication: []string{
|
||||
fmt.Sprintf("%s#atproto", did),
|
||||
},
|
||||
Service: []Service{
|
||||
{
|
||||
ID: "#atproto_pds",
|
||||
Type: "AtprotoPersonalDataServer",
|
||||
ServiceEndpoint: publicURL,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// encodePublicKeyMultibase encodes an ECDSA public key in multibase format
|
||||
// For P-256 keys, we use the compressed format with multicodec prefix
|
||||
func encodePublicKeyMultibase(pubKey ecdsa.PublicKey) (string, error) {
|
||||
// Check if this is a P-256 key
|
||||
if pubKey.Curve != elliptic.P256() {
|
||||
return "", fmt.Errorf("unsupported curve: only P-256 is supported")
|
||||
}
|
||||
|
||||
// Use compressed point format
|
||||
// 0x02 if Y is even, 0x03 if Y is odd
|
||||
var prefix byte
|
||||
if pubKey.Y.Bit(0) == 0 {
|
||||
prefix = 0x02
|
||||
} else {
|
||||
prefix = 0x03
|
||||
}
|
||||
|
||||
// Compressed format: prefix (1 byte) + X coordinate (32 bytes for P-256)
|
||||
xBytes := pubKey.X.Bytes()
|
||||
|
||||
// Pad X to 32 bytes if needed
|
||||
paddedX := make([]byte, 32)
|
||||
copy(paddedX[32-len(xBytes):], xBytes)
|
||||
|
||||
compressed := append([]byte{prefix}, paddedX...)
|
||||
|
||||
// Multicodec prefix for P-256 public key: 0x1200
|
||||
// See https://github.com/multiformats/multicodec/blob/master/table.csv
|
||||
multicodec := []byte{0x80, 0x24} // P-256 public key multicodec
|
||||
|
||||
// Combine multicodec + compressed key
|
||||
multicodecKey := append(multicodec, compressed...)
|
||||
|
||||
// Encode in multibase (base58btc, prefix 'z')
|
||||
encoded := base64.RawURLEncoding.EncodeToString(multicodecKey)
|
||||
|
||||
return "z" + encoded, nil
|
||||
}
|
||||
|
||||
// MarshalDIDDocument converts a DID document to JSON using the stored public URL
|
||||
func (p *HoldPDS) MarshalDIDDocument() ([]byte, error) {
|
||||
doc, err := p.GenerateDIDDocument(p.publicURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.MarshalIndent(doc, "", " ")
|
||||
}
|
||||
|
||||
// GenerateDIDFromURL creates a did:web identifier from a public URL
|
||||
// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com"
|
||||
func GenerateDIDFromURL(publicURL string) string {
|
||||
// Parse URL
|
||||
u, err := url.Parse(publicURL)
|
||||
if err != nil {
|
||||
// Fallback: assume it's just a hostname
|
||||
return fmt.Sprintf("did:web:%s", strings.Split(publicURL, ":")[0])
|
||||
}
|
||||
|
||||
// Use hostname without port for DID
|
||||
hostname := u.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("did:web:%s", hostname)
|
||||
}
|
||||
102
pkg/hold/pds/keys.go
Normal file
102
pkg/hold/pds/keys.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package pds
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// GenerateOrLoadKey generates a new K256 key pair or loads an existing one
|
||||
func GenerateOrLoadKey(keyPath string) (*ecdsa.PrivateKey, error) {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(keyPath)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create key directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if key already exists
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
// Key exists, load it
|
||||
return loadKey(keyPath)
|
||||
}
|
||||
|
||||
// Key doesn't exist, generate new one
|
||||
return generateKey(keyPath)
|
||||
}
|
||||
|
||||
// generateKey creates a new K256 (secp256k1) key pair
|
||||
func generateKey(keyPath string) (*ecdsa.PrivateKey, error) {
|
||||
// Generate K256 key (secp256k1)
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key: %w", err)
|
||||
}
|
||||
|
||||
// Marshal private key to DER format
|
||||
derBytes, err := x509.MarshalECPrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal private key: %w", err)
|
||||
}
|
||||
|
||||
// Create PEM block
|
||||
pemBlock := &pem.Block{
|
||||
Type: "EC PRIVATE KEY",
|
||||
Bytes: derBytes,
|
||||
}
|
||||
|
||||
// Write to file with restrictive permissions
|
||||
file, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create key file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := pem.Encode(file, pemBlock); err != nil {
|
||||
return nil, fmt.Errorf("failed to write PEM data: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Generated new signing key at %s\n", keyPath)
|
||||
return privateKey, nil
|
||||
}
|
||||
|
||||
// loadKey loads an existing private key from disk
|
||||
func loadKey(keyPath string) (*ecdsa.PrivateKey, error) {
|
||||
// Read PEM file
|
||||
pemData, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read key file: %w", err)
|
||||
}
|
||||
|
||||
// Decode PEM block
|
||||
block, _ := pem.Decode(pemData)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("failed to decode PEM block")
|
||||
}
|
||||
|
||||
// Parse EC private key
|
||||
privateKey, err := x509.ParseECPrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Loaded existing signing key from %s\n", keyPath)
|
||||
return privateKey, nil
|
||||
}
|
||||
|
||||
// PublicKeyToBase58 converts an ECDSA public key to base58 format for DID documents
|
||||
func PublicKeyToBase58(pubKey *ecdsa.PublicKey) (string, error) {
|
||||
// Marshal public key to X.509 SPKI format
|
||||
derBytes, err := x509.MarshalPKIXPublicKey(pubKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal public key: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Convert to base58 (need to import base58 library)
|
||||
// For now, just return hex encoding as placeholder
|
||||
return fmt.Sprintf("%x", derBytes), nil
|
||||
}
|
||||
105
pkg/hold/pds/server.go
Normal file
105
pkg/hold/pds/server.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package pds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/bluesky-social/indigo/carstore"
|
||||
"github.com/bluesky-social/indigo/models"
|
||||
"github.com/bluesky-social/indigo/repo"
|
||||
)
|
||||
|
||||
// HoldPDS is a minimal ATProto PDS implementation for a hold service
|
||||
type HoldPDS struct {
|
||||
did string
|
||||
publicURL string
|
||||
carstore carstore.CarStore
|
||||
session *carstore.DeltaSession
|
||||
repo *repo.Repo
|
||||
dbPath string
|
||||
uid models.Uid
|
||||
signingKey *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
// NewHoldPDS creates or opens a hold PDS with SQLite carstore
|
||||
func NewHoldPDS(ctx context.Context, did, publicURL, dbPath, keyPath string) (*HoldPDS, error) {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate or load signing key
|
||||
signingKey, err := GenerateOrLoadKey(keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize signing key: %w", err)
|
||||
}
|
||||
|
||||
// Create and open SQLite-backed carstore
|
||||
// dbPath is the directory, carstore creates and opens db.sqlite3 inside it
|
||||
sqlStore, err := carstore.NewSqliteStore(dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create sqlite store: %w", err)
|
||||
}
|
||||
|
||||
cs := sqlStore.CarStore()
|
||||
|
||||
// For a single-user hold, we use a fixed UID (1)
|
||||
uid := models.Uid(1)
|
||||
|
||||
// Try to get existing repo head
|
||||
_, err = cs.GetUserRepoHead(ctx, uid)
|
||||
|
||||
var session *carstore.DeltaSession
|
||||
var r *repo.Repo
|
||||
|
||||
if err != nil {
|
||||
// Repo doesn't exist yet, create new delta session
|
||||
session, err = cs.NewDeltaSession(ctx, uid, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create delta session: %w", err)
|
||||
}
|
||||
|
||||
// Create new repo with session as blockstore (needs pointer)
|
||||
r = repo.NewRepo(ctx, did, session)
|
||||
} else {
|
||||
// TODO: Load existing repo
|
||||
// For now, just create a new session
|
||||
session, err = cs.NewDeltaSession(ctx, uid, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create delta session: %w", err)
|
||||
}
|
||||
|
||||
r = repo.NewRepo(ctx, did, session)
|
||||
}
|
||||
|
||||
return &HoldPDS{
|
||||
did: did,
|
||||
publicURL: publicURL,
|
||||
carstore: cs,
|
||||
session: session,
|
||||
repo: r,
|
||||
dbPath: dbPath,
|
||||
uid: uid,
|
||||
signingKey: signingKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DID returns the hold's DID
|
||||
func (p *HoldPDS) DID() string {
|
||||
return p.did
|
||||
}
|
||||
|
||||
// SigningKey returns the hold's signing key
|
||||
func (p *HoldPDS) SigningKey() *ecdsa.PrivateKey {
|
||||
return p.signingKey
|
||||
}
|
||||
|
||||
// Close closes the session and carstore
|
||||
func (p *HoldPDS) Close() error {
|
||||
// TODO: Close session properly
|
||||
return nil
|
||||
}
|
||||
273
pkg/hold/pds/xrpc.go
Normal file
273
pkg/hold/pds/xrpc.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package pds
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// XRPC handler for ATProto endpoints
|
||||
|
||||
// XRPCHandler handles XRPC requests for the embedded PDS
|
||||
type XRPCHandler struct {
|
||||
pds *HoldPDS
|
||||
publicURL string
|
||||
blobStore BlobStore
|
||||
}
|
||||
|
||||
// BlobStore interface wraps the existing hold service storage operations
|
||||
type BlobStore interface {
|
||||
// GetPresignedDownloadURL returns a presigned URL for downloading a blob
|
||||
GetPresignedDownloadURL(digest string) (string, error)
|
||||
// GetPresignedUploadURL returns a presigned URL for uploading a blob
|
||||
GetPresignedUploadURL(digest string) (string, error)
|
||||
}
|
||||
|
||||
// NewXRPCHandler creates a new XRPC handler
|
||||
func NewXRPCHandler(pds *HoldPDS, publicURL string, blobStore BlobStore) *XRPCHandler {
|
||||
return &XRPCHandler{
|
||||
pds: pds,
|
||||
publicURL: publicURL,
|
||||
blobStore: blobStore,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterHandlers registers all XRPC endpoints
|
||||
func (h *XRPCHandler) RegisterHandlers(mux *http.ServeMux) {
|
||||
// Standard PDS endpoints
|
||||
mux.HandleFunc("/xrpc/com.atproto.server.describeServer", h.HandleDescribeServer)
|
||||
mux.HandleFunc("/xrpc/com.atproto.repo.describeRepo", h.HandleDescribeRepo)
|
||||
mux.HandleFunc("/xrpc/com.atproto.repo.getRecord", h.HandleGetRecord)
|
||||
mux.HandleFunc("/xrpc/com.atproto.repo.listRecords", h.HandleListRecords)
|
||||
|
||||
// Blob endpoints (wrap existing presigned URL logic)
|
||||
mux.HandleFunc("/xrpc/com.atproto.repo.uploadBlob", h.HandleUploadBlob)
|
||||
mux.HandleFunc("/xrpc/com.atproto.sync.getBlob", h.HandleGetBlob)
|
||||
|
||||
// DID document
|
||||
mux.HandleFunc("/.well-known/did.json", h.HandleDIDDocument)
|
||||
}
|
||||
|
||||
// HandleDescribeServer returns server metadata
|
||||
func (h *XRPCHandler) HandleDescribeServer(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"did": h.pds.DID(),
|
||||
"availableUserDomains": []string{},
|
||||
"inviteCodeRequired": false,
|
||||
"links": map[string]string{},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleDescribeRepo returns repository information
|
||||
func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Get repo parameter
|
||||
repo := r.URL.Query().Get("repo")
|
||||
if repo == "" || repo != h.pds.DID() {
|
||||
http.Error(w, "invalid repo", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate DID document
|
||||
didDoc, err := h.pds.GenerateDIDDocument(h.publicURL)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to generate DID document: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract handle from did:web (remove "did:web:" prefix)
|
||||
handle := h.pds.DID()
|
||||
if len(handle) > 8 && handle[:8] == "did:web:" {
|
||||
handle = handle[8:] // "did:web:example.com" -> "example.com"
|
||||
}
|
||||
|
||||
// TODO: Get actual repo head from carstore
|
||||
response := map[string]interface{}{
|
||||
"did": h.pds.DID(),
|
||||
"handle": handle,
|
||||
"didDoc": didDoc,
|
||||
"collections": []string{CrewCollection},
|
||||
"handleIsCorrect": true,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleGetRecord retrieves a record from the repository
|
||||
func (h *XRPCHandler) HandleGetRecord(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
repo := r.URL.Query().Get("repo")
|
||||
collection := r.URL.Query().Get("collection")
|
||||
rkey := r.URL.Query().Get("rkey")
|
||||
|
||||
if repo == "" || collection == "" || rkey == "" {
|
||||
http.Error(w, "missing required parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if repo != h.pds.DID() {
|
||||
http.Error(w, "invalid repo", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Only support crew collection for now
|
||||
if collection != CrewCollection {
|
||||
http.Error(w, "collection not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
crewRecord, err := h.pds.GetCrewMember(r.Context(), rkey)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to get record: %v", err), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), collection, rkey),
|
||||
"value": crewRecord,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleListRecords lists records in a collection
|
||||
func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
repo := r.URL.Query().Get("repo")
|
||||
collection := r.URL.Query().Get("collection")
|
||||
|
||||
if repo == "" || collection == "" {
|
||||
http.Error(w, "missing required parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if repo != h.pds.DID() {
|
||||
http.Error(w, "invalid repo", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Only support crew collection for now
|
||||
if collection != CrewCollection {
|
||||
http.Error(w, "collection not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
crew, err := h.pds.ListCrewMembers(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to list records: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
records := make([]map[string]interface{}, len(crew))
|
||||
for i, member := range crew {
|
||||
// TODO: Get actual rkey from somewhere
|
||||
records[i] = map[string]interface{}{
|
||||
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), collection, member.Member),
|
||||
"value": member,
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"records": records,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleUploadBlob wraps existing presigned upload URL logic
|
||||
func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Authentication check
|
||||
|
||||
// Read digest from query or calculate from body
|
||||
digest := r.URL.Query().Get("digest")
|
||||
if digest == "" {
|
||||
http.Error(w, "digest required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get presigned upload URL from existing blob store
|
||||
uploadURL, err := h.blobStore.GetPresignedUploadURL(digest)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to get upload URL: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return 302 redirect to presigned URL
|
||||
http.Redirect(w, r, uploadURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// HandleGetBlob wraps existing presigned download URL logic
|
||||
func (h *XRPCHandler) HandleGetBlob(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
did := r.URL.Query().Get("did")
|
||||
digest := r.URL.Query().Get("cid")
|
||||
|
||||
if did == "" || digest == "" {
|
||||
http.Error(w, "missing required parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if did != h.pds.DID() {
|
||||
http.Error(w, "invalid did", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get presigned download URL from existing blob store
|
||||
downloadURL, err := h.blobStore.GetPresignedDownloadURL(digest)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to get download URL: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return 302 redirect to presigned URL
|
||||
http.Redirect(w, r, downloadURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// HandleDIDDocument returns the DID document
|
||||
func (h *XRPCHandler) HandleDIDDocument(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := h.pds.GenerateDIDDocument(h.publicURL)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to generate DID document: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/did+json")
|
||||
json.NewEncoder(w).Encode(doc)
|
||||
}
|
||||
@@ -42,3 +42,8 @@ func NewHoldService(cfg *Config) (*HoldService, error) {
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// GetPresignedURL is a public wrapper around getPresignedURL for use by PDS blob store
|
||||
func (s *HoldService) GetPresignedURL(ctx context.Context, operation PresignedURLOperation, digest string, did string) (string, error) {
|
||||
return s.getPresignedURL(ctx, operation, digest, did)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user