mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 17:24:16 +00:00
more labeler improvements. standardize did work between labeler and hold. improve sql race conditions on local-only db
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -buildvcs=false -o ./tmp/atcr-labeler ./cmd/labeler"
|
||||
entrypoint = ["./tmp/atcr-labeler", "serve", "--config", "config-labeler.example.yaml"]
|
||||
include_ext = ["go", "html", "css", "js"]
|
||||
exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "pkg/appview", "pkg/hold", "node_modules"]
|
||||
exclude_regex = ["_test\\.go$", "cbor_gen\\.go$", "\\.min\\.js$", "public/css/style\\.css$", "public/icons\\.svg$"]
|
||||
delay = 3000
|
||||
stop_on_error = true
|
||||
send_interrupt = true
|
||||
kill_delay = 500
|
||||
|
||||
[log]
|
||||
time = false
|
||||
|
||||
[color]
|
||||
main = "cyan"
|
||||
watcher = "magenta"
|
||||
build = "yellow"
|
||||
runner = "green"
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
+168
-103
@@ -5,12 +5,11 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"atcr.io/pkg/atproto/did"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"atcr.io/pkg/hold"
|
||||
"atcr.io/pkg/hold/pds"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
didplc "github.com/did-method-plc/go-didplc"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -21,144 +20,210 @@ var plcCmd = &cobra.Command{
|
||||
|
||||
var plcConfigFile string
|
||||
|
||||
var (
|
||||
plcAddRotationKeyFirst bool
|
||||
plcAddRotationKeyLast bool
|
||||
)
|
||||
|
||||
var plcAddRotationKeyCmd = &cobra.Command{
|
||||
Use: "add-rotation-key <multibase-key>",
|
||||
Use: "add-rotation-key [multibase-key]",
|
||||
Short: "Add a rotation key to this hold's PLC identity",
|
||||
Long: `Add an additional rotation key to the hold's did:plc document.
|
||||
The key must be a multibase-encoded private key (K-256 or P-256, starting with 'z').
|
||||
|
||||
If a multibase-encoded private key (K-256 or P-256, starting with 'z') is supplied as
|
||||
the positional argument, that key is added. If no argument is given, a fresh K-256
|
||||
keypair is generated and the private half is printed to stdout. Save it offline as
|
||||
your recovery key, since it will not be shown again.
|
||||
|
||||
By default the new key is inserted at the highest priority position (--first), which
|
||||
allows it to override ops signed by lower-priority keys within PLC's 72-hour recovery
|
||||
window. Pass --last to append at the lowest priority instead.
|
||||
|
||||
The hold's configured rotation key is used to sign the PLC update.
|
||||
|
||||
atcr-hold plc add-rotation-key --config config.yaml z...`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
atcr-hold plc add-rotation-key --config config.yaml # generate + print
|
||||
atcr-hold plc add-rotation-key --config config.yaml --last # append, low priority
|
||||
atcr-hold plc add-rotation-key --config config.yaml z... # use supplied key`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
firstSet := cmd.Flags().Changed("first")
|
||||
lastSet := cmd.Flags().Changed("last")
|
||||
if firstSet && lastSet {
|
||||
return fmt.Errorf("--first and --last are mutually exclusive")
|
||||
}
|
||||
prepend := !plcAddRotationKeyLast
|
||||
|
||||
cfg, err := hold.LoadConfig(plcConfigFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Database.DIDMethod != "plc" {
|
||||
return fmt.Errorf("this command only works with did:plc (database.did_method is %q)", cfg.Database.DIDMethod)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Resolve the hold's DID
|
||||
holdDID, err := pds.LoadOrCreateDID(ctx, pds.DIDConfig{
|
||||
DID: cfg.Database.DID,
|
||||
DIDMethod: cfg.Database.DIDMethod,
|
||||
PublicURL: cfg.Server.PublicURL,
|
||||
DBPath: cfg.Database.Path,
|
||||
SigningKeyPath: cfg.Database.KeyPath,
|
||||
RotationKey: cfg.Database.RotationKey,
|
||||
PLCDirectoryURL: cfg.Database.PLCDirectoryURL,
|
||||
})
|
||||
holdDID, rotationKey, signingKey, err := loadHoldPLCIdentity(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve hold DID: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse the rotation key from config (required for signing PLC updates)
|
||||
if cfg.Database.RotationKey == "" {
|
||||
return fmt.Errorf("database.rotation_key must be set to sign PLC updates")
|
||||
}
|
||||
rotationKey, err := atcrypto.ParsePrivateMultibase(cfg.Database.RotationKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse rotation_key from config: %w", err)
|
||||
}
|
||||
|
||||
// Parse the new key to add (K-256 or P-256)
|
||||
newKey, err := atcrypto.ParsePrivateMultibase(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse key argument: %w", err)
|
||||
}
|
||||
newKeyPub, err := newKey.PublicKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get public key from argument: %w", err)
|
||||
}
|
||||
newKeyDIDKey := newKeyPub.DIDKey()
|
||||
|
||||
// Load signing key for verification methods
|
||||
keyPath := cfg.Database.KeyPath
|
||||
if keyPath == "" {
|
||||
keyPath = cfg.Database.Path + "/signing.key"
|
||||
}
|
||||
signingKey, err := oauth.GenerateOrLoadPDSKey(keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load signing key: %w", err)
|
||||
}
|
||||
|
||||
// Fetch current PLC state
|
||||
plcDirectoryURL := cfg.Database.PLCDirectoryURL
|
||||
if plcDirectoryURL == "" {
|
||||
plcDirectoryURL = "https://plc.directory"
|
||||
}
|
||||
client := &didplc.Client{DirectoryURL: plcDirectoryURL}
|
||||
|
||||
opLog, err := client.OpLog(ctx, holdDID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch PLC op log: %w", err)
|
||||
}
|
||||
if len(opLog) == 0 {
|
||||
return fmt.Errorf("empty op log for %s", holdDID)
|
||||
}
|
||||
|
||||
lastEntry := opLog[len(opLog)-1]
|
||||
lastOp := lastEntry.Regular
|
||||
if lastOp == nil {
|
||||
return fmt.Errorf("last PLC operation is not a regular op")
|
||||
}
|
||||
|
||||
// Check if key already present
|
||||
for _, k := range lastOp.RotationKeys {
|
||||
if k == newKeyDIDKey {
|
||||
fmt.Printf("Key %s is already a rotation key for %s\n", newKeyDIDKey, holdDID)
|
||||
return nil
|
||||
var newKey atcrypto.PrivateKeyExportable
|
||||
if len(args) == 1 {
|
||||
newKey, err = atcrypto.ParsePrivateMultibase(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse key argument: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build updated rotation keys: keep existing, append new
|
||||
rotationKeys := make([]string, len(lastOp.RotationKeys))
|
||||
copy(rotationKeys, lastOp.RotationKeys)
|
||||
rotationKeys = append(rotationKeys, newKeyDIDKey)
|
||||
|
||||
// Build update: preserve everything else from current state
|
||||
sigPub, err := signingKey.PublicKey()
|
||||
res, err := did.AddRotationKey(ctx, did.AddRotationKeyOptions{
|
||||
DID: holdDID,
|
||||
PLCDirectoryURL: cfg.Database.PLCDirectoryURL,
|
||||
RotationKey: rotationKey,
|
||||
SigningKey: signingKey,
|
||||
VerificationKeyName: "atproto",
|
||||
NewKey: newKey,
|
||||
Prepend: prepend,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get signing public key: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
prevCID := lastEntry.AsOperation().CID().String()
|
||||
|
||||
op := &didplc.RegularOp{
|
||||
Type: "plc_operation",
|
||||
RotationKeys: rotationKeys,
|
||||
VerificationMethods: map[string]string{
|
||||
"atproto": sigPub.DIDKey(),
|
||||
},
|
||||
AlsoKnownAs: lastOp.AlsoKnownAs,
|
||||
Services: lastOp.Services,
|
||||
Prev: &prevCID,
|
||||
if res.AlreadyPresent {
|
||||
fmt.Printf("Key %s is already a rotation key for %s (priority %d of %d)\n",
|
||||
res.NewKeyDIDKey, holdDID, res.ExistingAt, res.TotalKeys)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := op.Sign(rotationKey); err != nil {
|
||||
return fmt.Errorf("failed to sign PLC update: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Submit(ctx, holdDID, op); err != nil {
|
||||
return fmt.Errorf("failed to submit PLC update: %w", err)
|
||||
if res.Generated {
|
||||
fmt.Println("=========================================================================")
|
||||
fmt.Println("GENERATED NEW ROTATION KEY. SAVE THIS NOW. IT WILL NOT BE SHOWN AGAIN.")
|
||||
fmt.Println("Store it offline (password manager, paper, hardware token).")
|
||||
fmt.Println()
|
||||
fmt.Printf("Private key (multibase): %s\n", res.NewKey.Multibase())
|
||||
fmt.Printf("Public key (did:key): %s\n", res.NewKeyDIDKey)
|
||||
fmt.Println("=========================================================================")
|
||||
}
|
||||
|
||||
slog.Info("Added rotation key to PLC identity",
|
||||
"did", holdDID,
|
||||
"new_key", newKeyDIDKey,
|
||||
"total_rotation_keys", len(rotationKeys),
|
||||
"new_key", res.NewKeyDIDKey,
|
||||
"priority", res.InsertedAt,
|
||||
"total_rotation_keys", res.TotalKeys,
|
||||
"generated", res.Generated,
|
||||
)
|
||||
fmt.Printf("Added rotation key %s to %s\n", newKeyDIDKey, holdDID)
|
||||
fmt.Printf("Added rotation key %s to %s (priority %d of %d)\n",
|
||||
res.NewKeyDIDKey, holdDID, res.InsertedAt, res.TotalKeys)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var plcListRotationKeysCmd = &cobra.Command{
|
||||
Use: "list-rotation-keys",
|
||||
Short: "List rotation keys in this hold's PLC document",
|
||||
Long: `Fetch the hold's did:plc document from the PLC directory and print its
|
||||
rotation keys in priority order (index 0 is highest priority and can override
|
||||
ops signed by lower-priority keys within PLC's 72-hour recovery window).
|
||||
|
||||
The key matching the local database.rotation_key is marked as LOCAL.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := hold.LoadConfig(plcConfigFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
if cfg.Database.DIDMethod != "plc" {
|
||||
return fmt.Errorf("this command only works with did:plc (database.did_method is %q)", cfg.Database.DIDMethod)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
holdDID, err := did.LoadOrCreate(ctx, cfg.DIDConfig())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve hold DID: %w", err)
|
||||
}
|
||||
|
||||
var localRotationKey atcrypto.PrivateKey
|
||||
if cfg.Database.RotationKey != "" {
|
||||
localRotationKey, err = atcrypto.ParsePrivateMultibase(cfg.Database.RotationKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse rotation_key from config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
res, err := did.ListRotationKeys(ctx, did.ListRotationKeysOptions{
|
||||
DID: holdDID,
|
||||
PLCDirectoryURL: cfg.Database.PLCDirectoryURL,
|
||||
LocalRotationKey: localRotationKey,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printRotationKeys(res)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// loadHoldPLCIdentity is the shared "load DID + rotation key + signing key" helper used
|
||||
// by every PLC command. It enforces that database.rotation_key is set since every PLC
|
||||
// command needs a rotation key to either sign updates or verify the LOCAL marker.
|
||||
func loadHoldPLCIdentity(ctx context.Context, cfg *hold.Config) (string, atcrypto.PrivateKey, *atcrypto.PrivateKeyK256, error) {
|
||||
holdDID, err := did.LoadOrCreate(ctx, cfg.DIDConfig())
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("failed to resolve hold DID: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Database.RotationKey == "" {
|
||||
return "", nil, nil, fmt.Errorf("database.rotation_key must be set to sign PLC updates")
|
||||
}
|
||||
rotationKey, err := atcrypto.ParsePrivateMultibase(cfg.Database.RotationKey)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("failed to parse rotation_key from config: %w", err)
|
||||
}
|
||||
|
||||
keyPath := cfg.Database.KeyPath
|
||||
if keyPath == "" {
|
||||
keyPath = cfg.Database.Path + "/signing.key"
|
||||
}
|
||||
signingKey, err := oauth.GenerateOrLoadPDSKey(keyPath)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("failed to load signing key: %w", err)
|
||||
}
|
||||
return holdDID, rotationKey, signingKey, nil
|
||||
}
|
||||
|
||||
// printRotationKeys is the shared CLI output for `list-rotation-keys`.
|
||||
func printRotationKeys(res *did.ListRotationKeysResult) {
|
||||
fmt.Printf("DID: %s\n", res.DID)
|
||||
fmt.Printf("PLC directory: %s\n", res.Directory)
|
||||
fmt.Printf("Rotation keys (%d):\n", len(res.Keys))
|
||||
for i, k := range res.Keys {
|
||||
marker := ""
|
||||
switch {
|
||||
case len(res.Keys) == 1:
|
||||
marker = "(only key)"
|
||||
case i == 0:
|
||||
marker = "(highest priority)"
|
||||
case i == len(res.Keys)-1:
|
||||
marker = "(lowest priority)"
|
||||
}
|
||||
localTag := ""
|
||||
if res.LocalDIDKey != "" && k == res.LocalDIDKey {
|
||||
localTag = " [LOCAL — database.rotation_key]"
|
||||
}
|
||||
fmt.Printf(" [%d] %s %s%s\n", i, k, marker, localTag)
|
||||
}
|
||||
|
||||
if res.LocalDIDKey != "" && !res.LocalPresent {
|
||||
fmt.Printf("\nWARNING: local rotation_key (%s) is NOT present in the PLC document.\n", res.LocalDIDKey)
|
||||
fmt.Println("This service cannot sign PLC updates. Possible compromise or out-of-band rotation.")
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
plcCmd.PersistentFlags().StringVarP(&plcConfigFile, "config", "c", "", "path to YAML configuration file")
|
||||
|
||||
plcAddRotationKeyCmd.Flags().BoolVar(&plcAddRotationKeyFirst, "first", true, "insert at highest priority (default)")
|
||||
plcAddRotationKeyCmd.Flags().BoolVar(&plcAddRotationKeyLast, "last", false, "insert at lowest priority")
|
||||
|
||||
plcCmd.AddCommand(plcAddRotationKeyCmd)
|
||||
plcCmd.AddCommand(plcListRotationKeysCmd)
|
||||
}
|
||||
|
||||
+3
-10
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"atcr.io/pkg/atproto/did"
|
||||
"atcr.io/pkg/hold"
|
||||
holddb "atcr.io/pkg/hold/db"
|
||||
"atcr.io/pkg/hold/pds"
|
||||
@@ -39,7 +40,7 @@ The CAR is written to stdout, so redirect to a file:
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if err := holdPDS.ExportToCAR(ctx, os.Stdout); err != nil {
|
||||
if err := holdPDS.RepomgrRef().ReadRepo(ctx, holdPDS.UID(), "", os.Stdout); err != nil {
|
||||
return fmt.Errorf("failed to export: %w", err)
|
||||
}
|
||||
|
||||
@@ -105,15 +106,7 @@ func init() {
|
||||
// openHoldPDS creates a HoldPDS from config for offline CLI operations.
|
||||
// Returns the PDS and a cleanup function that must be deferred.
|
||||
func openHoldPDS(ctx context.Context, cfg *hold.Config) (*pds.HoldPDS, func(), error) {
|
||||
holdDID, err := pds.LoadOrCreateDID(ctx, pds.DIDConfig{
|
||||
DID: cfg.Database.DID,
|
||||
DIDMethod: cfg.Database.DIDMethod,
|
||||
PublicURL: cfg.Server.PublicURL,
|
||||
DBPath: cfg.Database.Path,
|
||||
SigningKeyPath: cfg.Database.KeyPath,
|
||||
RotationKey: cfg.Database.RotationKey,
|
||||
PLCDirectoryURL: cfg.Database.PLCDirectoryURL,
|
||||
})
|
||||
holdDID, err := did.LoadOrCreate(ctx, cfg.DIDConfig())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to resolve hold DID: %w", err)
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ func init() {
|
||||
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
rootCmd.AddCommand(configCmd)
|
||||
rootCmd.AddCommand(plcCmd)
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"atcr.io/pkg/atproto/did"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"atcr.io/pkg/labeler"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var plcCmd = &cobra.Command{
|
||||
Use: "plc",
|
||||
Short: "PLC directory management commands",
|
||||
}
|
||||
|
||||
var plcConfigFile string
|
||||
|
||||
var (
|
||||
plcAddRotationKeyFirst bool
|
||||
plcAddRotationKeyLast bool
|
||||
)
|
||||
|
||||
var plcAddRotationKeyCmd = &cobra.Command{
|
||||
Use: "add-rotation-key [multibase-key]",
|
||||
Short: "Add a rotation key to this labeler's PLC identity",
|
||||
Long: `Add an additional rotation key to the labeler's did:plc document.
|
||||
|
||||
If a multibase-encoded private key (K-256 or P-256, starting with 'z') is supplied as
|
||||
the positional argument, that key is added. If no argument is given, a fresh K-256
|
||||
keypair is generated and the private half is printed to stdout. Save it offline as
|
||||
your recovery key, since it will not be shown again.
|
||||
|
||||
By default the new key is inserted at the highest priority position (--first), which
|
||||
allows it to override ops signed by lower-priority keys within PLC's 72-hour recovery
|
||||
window. Pass --last to append at the lowest priority instead.
|
||||
|
||||
The labeler's configured rotation key is used to sign the PLC update.
|
||||
|
||||
atcr-labeler plc add-rotation-key --config config.yaml # generate + print
|
||||
atcr-labeler plc add-rotation-key --config config.yaml --last # append, low priority
|
||||
atcr-labeler plc add-rotation-key --config config.yaml z... # use supplied key`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
firstSet := cmd.Flags().Changed("first")
|
||||
lastSet := cmd.Flags().Changed("last")
|
||||
if firstSet && lastSet {
|
||||
return fmt.Errorf("--first and --last are mutually exclusive")
|
||||
}
|
||||
prepend := !plcAddRotationKeyLast
|
||||
|
||||
cfg, err := labeler.LoadConfig(plcConfigFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
if cfg.Labeler.DIDMethod != "plc" {
|
||||
return fmt.Errorf("this command only works with did:plc (labeler.did_method is %q)", cfg.Labeler.DIDMethod)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
labelerDID, rotationKey, signingKey, err := loadLabelerPLCIdentity(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var newKey atcrypto.PrivateKeyExportable
|
||||
if len(args) == 1 {
|
||||
newKey, err = atcrypto.ParsePrivateMultibase(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse key argument: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
res, err := did.AddRotationKey(ctx, did.AddRotationKeyOptions{
|
||||
DID: labelerDID,
|
||||
PLCDirectoryURL: cfg.PLCDirectoryURL(),
|
||||
RotationKey: rotationKey,
|
||||
SigningKey: signingKey,
|
||||
VerificationKeyName: "atproto_label",
|
||||
NewKey: newKey,
|
||||
Prepend: prepend,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.AlreadyPresent {
|
||||
fmt.Printf("Key %s is already a rotation key for %s (priority %d of %d)\n",
|
||||
res.NewKeyDIDKey, labelerDID, res.ExistingAt, res.TotalKeys)
|
||||
return nil
|
||||
}
|
||||
|
||||
if res.Generated {
|
||||
fmt.Println("=========================================================================")
|
||||
fmt.Println("GENERATED NEW ROTATION KEY. SAVE THIS NOW. IT WILL NOT BE SHOWN AGAIN.")
|
||||
fmt.Println("Store it offline (password manager, paper, hardware token).")
|
||||
fmt.Println()
|
||||
fmt.Printf("Private key (multibase): %s\n", res.NewKey.Multibase())
|
||||
fmt.Printf("Public key (did:key): %s\n", res.NewKeyDIDKey)
|
||||
fmt.Println("=========================================================================")
|
||||
}
|
||||
|
||||
slog.Info("Added rotation key to PLC identity",
|
||||
"did", labelerDID,
|
||||
"new_key", res.NewKeyDIDKey,
|
||||
"priority", res.InsertedAt,
|
||||
"total_rotation_keys", res.TotalKeys,
|
||||
"generated", res.Generated,
|
||||
)
|
||||
fmt.Printf("Added rotation key %s to %s (priority %d of %d)\n",
|
||||
res.NewKeyDIDKey, labelerDID, res.InsertedAt, res.TotalKeys)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var plcListRotationKeysCmd = &cobra.Command{
|
||||
Use: "list-rotation-keys",
|
||||
Short: "List rotation keys in this labeler's PLC document",
|
||||
Long: `Fetch the labeler's did:plc document from the PLC directory and print its
|
||||
rotation keys in priority order (index 0 is highest priority and can override
|
||||
ops signed by lower-priority keys within PLC's 72-hour recovery window).
|
||||
|
||||
The key matching the local labeler.rotation_key is marked as LOCAL.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := labeler.LoadConfig(plcConfigFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
if cfg.Labeler.DIDMethod != "plc" {
|
||||
return fmt.Errorf("this command only works with did:plc (labeler.did_method is %q)", cfg.Labeler.DIDMethod)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
labelerDID, _, _, err := loadLabelerPLCIdentity(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var localRotationKey atcrypto.PrivateKey
|
||||
if cfg.Labeler.RotationKey != "" {
|
||||
localRotationKey, err = atcrypto.ParsePrivateMultibase(cfg.Labeler.RotationKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse rotation_key from config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
res, err := did.ListRotationKeys(ctx, did.ListRotationKeysOptions{
|
||||
DID: labelerDID,
|
||||
PLCDirectoryURL: cfg.PLCDirectoryURL(),
|
||||
LocalRotationKey: localRotationKey,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printRotationKeys(res)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// loadLabelerPLCIdentity is the shared "load DID + rotation key + signing key" helper
|
||||
// used by every PLC command. Mirrors loadHoldPLCIdentity over in cmd/hold/plc.go.
|
||||
func loadLabelerPLCIdentity(ctx context.Context, cfg *labeler.Config) (string, atcrypto.PrivateKey, *atcrypto.PrivateKeyK256, error) {
|
||||
labelerDID, _, err := labeler.LoadIdentity(ctx, cfg)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
|
||||
if cfg.Labeler.RotationKey == "" {
|
||||
return "", nil, nil, fmt.Errorf("labeler.rotation_key must be set to sign PLC updates")
|
||||
}
|
||||
rotationKey, err := atcrypto.ParsePrivateMultibase(cfg.Labeler.RotationKey)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("failed to parse rotation_key from config: %w", err)
|
||||
}
|
||||
|
||||
signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath())
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("failed to load signing key: %w", err)
|
||||
}
|
||||
return labelerDID, rotationKey, signingKey, nil
|
||||
}
|
||||
|
||||
// printRotationKeys is the shared CLI output for list-rotation-keys, kept identical to
|
||||
// the hold version since the formatting is service-agnostic.
|
||||
func printRotationKeys(res *did.ListRotationKeysResult) {
|
||||
fmt.Printf("DID: %s\n", res.DID)
|
||||
fmt.Printf("PLC directory: %s\n", res.Directory)
|
||||
fmt.Printf("Rotation keys (%d):\n", len(res.Keys))
|
||||
for i, k := range res.Keys {
|
||||
marker := ""
|
||||
switch {
|
||||
case len(res.Keys) == 1:
|
||||
marker = "(only key)"
|
||||
case i == 0:
|
||||
marker = "(highest priority)"
|
||||
case i == len(res.Keys)-1:
|
||||
marker = "(lowest priority)"
|
||||
}
|
||||
localTag := ""
|
||||
if res.LocalDIDKey != "" && k == res.LocalDIDKey {
|
||||
localTag = " [LOCAL — labeler.rotation_key]"
|
||||
}
|
||||
fmt.Printf(" [%d] %s %s%s\n", i, k, marker, localTag)
|
||||
}
|
||||
|
||||
if res.LocalDIDKey != "" && !res.LocalPresent {
|
||||
fmt.Printf("\nWARNING: local rotation_key (%s) is NOT present in the PLC document.\n", res.LocalDIDKey)
|
||||
fmt.Println("This service cannot sign PLC updates. Possible compromise or out-of-band rotation.")
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
plcCmd.PersistentFlags().StringVarP(&plcConfigFile, "config", "c", "", "path to YAML configuration file")
|
||||
|
||||
plcAddRotationKeyCmd.Flags().BoolVar(&plcAddRotationKeyFirst, "first", true, "insert at highest priority (default)")
|
||||
plcAddRotationKeyCmd.Flags().BoolVar(&plcAddRotationKeyLast, "last", false, "insert at lowest priority")
|
||||
|
||||
plcCmd.AddCommand(plcAddRotationKeyCmd)
|
||||
plcCmd.AddCommand(plcListRotationKeysCmd)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# ATCR Labeler Configuration
|
||||
# Generated with defaults — edit as needed.
|
||||
|
||||
# Configuration format version.
|
||||
version: "0.1"
|
||||
# Log level: debug, info, warn, error.
|
||||
log_level: info
|
||||
# Labeler service settings.
|
||||
labeler:
|
||||
# Enable the labeler service.
|
||||
enabled: true
|
||||
# Listen address for labeler (e.g., :5002).
|
||||
addr: :5002
|
||||
# Externally reachable labeler URL. Empty = derive from server.base_url.
|
||||
public_url: ""
|
||||
# DID of the labeler admin. Only this DID can log into the admin panel.
|
||||
owner_did: did:plc:your-did-here
|
||||
# Directory for labeler state (database, signing key, did.txt).
|
||||
data_dir: /var/lib/atcr-labeler
|
||||
# DID method: "plc" (recommended) or "web".
|
||||
did_method: plc
|
||||
# Explicit did:plc identifier for adoption/recovery (optional).
|
||||
did: ""
|
||||
# Path to K-256 signing key (defaults to <data_dir>/signing.key).
|
||||
key_path: ""
|
||||
# Multibase-encoded rotation key (K-256 or P-256). Required to update the PLC document.
|
||||
rotation_key: ""
|
||||
# PLC directory URL (default https://plc.directory).
|
||||
plc_directory_url: https://plc.directory
|
||||
# Optional libSQL/Bunny remote sync URL. Empty = local-only.
|
||||
libsql_sync_url: ""
|
||||
# Auth token for libsql_sync_url.
|
||||
libsql_auth_token: ""
|
||||
# Embedded-replica pull interval (e.g. 30s). 0 = manual sync only.
|
||||
libsql_sync_interval: 0s
|
||||
# AppView server settings (shared config).
|
||||
server:
|
||||
base_url: https://atcr.io
|
||||
client_name: AT Container Registry
|
||||
client_short_name: ATCR
|
||||
test_mode: false
|
||||
# Remote log shipping settings.
|
||||
log_shipper:
|
||||
# Log shipping backend: "victoria", "opensearch", or "loki". Empty disables shipping.
|
||||
backend: ""
|
||||
# Remote log service endpoint, e.g. "http://victorialogs:9428".
|
||||
url: ""
|
||||
# Number of log entries to buffer before flushing to the remote service.
|
||||
batch_size: 0
|
||||
# Maximum time between flushes, even if batch is not full.
|
||||
flush_interval: 0s
|
||||
# Basic auth username for the log service (optional).
|
||||
username: ""
|
||||
# Basic auth password for the log service (optional).
|
||||
password: ""
|
||||
@@ -11,7 +11,12 @@ labeler:
|
||||
enabled: true
|
||||
addr: :5002
|
||||
owner_did: ""
|
||||
db_path: "{{.BasePath}}/labeler/labeler.db"
|
||||
data_dir: "{{.BasePath}}/labeler"
|
||||
did_method: plc
|
||||
did: ""
|
||||
key_path: ""
|
||||
rotation_key: ""
|
||||
plc_directory_url: https://plc.directory
|
||||
server:
|
||||
base_url: "https://seamark.dev"
|
||||
client_name: Seamark
|
||||
|
||||
@@ -19,6 +19,9 @@ services:
|
||||
# ATCR_SERVER_CLIENT_SHORT_NAME: "Seamark"
|
||||
ATCR_SERVER_MANAGED_HOLDS: did:web:172.28.0.3%3A8080
|
||||
ATCR_SERVER_DEFAULT_HOLD_DID: did:web:172.28.0.3%3A8080
|
||||
# Labeler URL (HTTP for dev — ParseLabelerURL accepts it directly so we don't
|
||||
# have to round-trip through did:web → https:// resolution).
|
||||
ATCR_LABELER_DID: http://172.28.0.4:5002
|
||||
ATCR_SERVER_TEST_MODE: true
|
||||
ATCR_LOG_LEVEL: debug
|
||||
LOG_SHIPPER_BACKEND: victoria
|
||||
@@ -97,6 +100,52 @@ services:
|
||||
atcr-network:
|
||||
ipv4_address: 172.28.0.3
|
||||
|
||||
atcr-labeler:
|
||||
# Base config: config-labeler.example.yaml (passed via Air entrypoint).
|
||||
# Env vars below override config file values for local dev.
|
||||
#
|
||||
# Why did:web for dev: did:plc would submit a real PLC operation to plc.directory
|
||||
# for every fresh dev environment, polluting production with throwaway DIDs that
|
||||
# point at 172.28.0.x. did:web is purely self-served via /.well-known/did.json so
|
||||
# nothing leaks. Switch to plc + a real public_url for production.
|
||||
environment:
|
||||
LABELER_LABELER_DID_METHOD: web
|
||||
LABELER_LABELER_PUBLIC_URL: http://172.28.0.4:5002
|
||||
LABELER_LABELER_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg
|
||||
LABELER_LABELER_DATA_DIR: /var/lib/atcr-labeler
|
||||
LABELER_SERVER_TEST_MODE: true
|
||||
LABELER_LOG_LEVEL: debug
|
||||
LOG_SHIPPER_BACKEND: victoria
|
||||
LOG_SHIPPER_URL: http://172.28.0.10:9428
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "1"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
args:
|
||||
AIR_CONFIG: .air.labeler.toml
|
||||
image: atcr-labeler-dev:latest
|
||||
container_name: atcr-labeler
|
||||
ports:
|
||||
- "5002:5002"
|
||||
volumes:
|
||||
# Mount source code for Air hot reload
|
||||
- .:/app:z
|
||||
- go-mod-cache:/go/pkg/mod
|
||||
# Persist signing key + did.txt + label database across container restarts so
|
||||
# dev signatures stay verifiable. Wipe with `docker compose down -v` to reset.
|
||||
- atcr-labeler:/var/lib/atcr-labeler
|
||||
restart: unless-stopped
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 1.1.1.1
|
||||
networks:
|
||||
atcr-network:
|
||||
ipv4_address: 172.28.0.4
|
||||
|
||||
# Victoria Logs for centralized log storage
|
||||
# Uncomment to enable, then set LOG_SHIPPER_* env vars above
|
||||
victorialogs:
|
||||
@@ -123,6 +172,7 @@ networks:
|
||||
|
||||
volumes:
|
||||
atcr-hold:
|
||||
atcr-labeler:
|
||||
atcr-auth:
|
||||
atcr-ui:
|
||||
go-mod-cache:
|
||||
|
||||
+28
-15
@@ -16,6 +16,31 @@ func BlobCDNURL(did, cid string) string {
|
||||
return fmt.Sprintf("https://imgs.blue/%s/%s", did, cid)
|
||||
}
|
||||
|
||||
// activeTakedownClause returns a SQL fragment ready to drop into a `WHERE NOT
|
||||
// EXISTS (...)` filter for excluding rows whose `(did, repository)` pair is currently
|
||||
// taken down. The `alias` argument is the outer table alias (e.g. "m" for manifests,
|
||||
// "lm" for latest_manifests) and must already be in scope at the use site.
|
||||
//
|
||||
// Mirrors the semantics of `IsTakenDown` (defined in labels.go) so listings stay
|
||||
// consistent with the per-repo page check: a label only counts as active when it has
|
||||
// neg=0, no newer neg=1 row with the same (src, uri, val), and a non-expired `exp`.
|
||||
// Without these clauses listings hide a repo forever once you've ever taken it down,
|
||||
// even after a reversal.
|
||||
func activeTakedownClause(alias string) string {
|
||||
return `NOT EXISTS (
|
||||
SELECT 1 FROM labels l1
|
||||
WHERE l1.subject_did = ` + alias + `.did
|
||||
AND (l1.subject_repo = ` + alias + `.repository OR l1.subject_repo = '')
|
||||
AND l1.val = '!takedown' AND l1.neg = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM labels l2
|
||||
WHERE l2.src = l1.src AND l2.uri = l1.uri AND l2.val = l1.val
|
||||
AND l2.neg = 1 AND l2.id > l1.id
|
||||
)
|
||||
AND (l1.exp IS NULL OR l1.exp > CURRENT_TIMESTAMP)
|
||||
)`
|
||||
}
|
||||
|
||||
// accessibleHoldsSubquery returns SQL that evaluates to the set of hold DIDs
|
||||
// the viewer is allowed to see in listings. Requires the viewerDID to be
|
||||
// passed twice as query arguments (once for the owner_did check and once
|
||||
@@ -107,11 +132,7 @@ func SearchRepositories(db DBTX, query string, limit, offset int, currentUserDID
|
||||
WHERE ra.did = lm.did AND ra.repository = lm.repository
|
||||
AND ra.value LIKE ? ESCAPE '\'
|
||||
))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM labels
|
||||
WHERE (subject_did = lm.did AND (subject_repo = lm.repository OR subject_repo = ''))
|
||||
AND val = '!takedown' AND neg = 0
|
||||
)
|
||||
AND ` + activeTakedownClause("lm") + `
|
||||
),
|
||||
repo_stats AS (
|
||||
SELECT
|
||||
@@ -2122,11 +2143,7 @@ func GetRepoCards(db DBTX, limit int, currentUserDID string, sortOrder RepoCardS
|
||||
JOIN users u ON m.did = u.did
|
||||
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
|
||||
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM labels
|
||||
WHERE (subject_did = m.did AND (subject_repo = m.repository OR subject_repo = ''))
|
||||
AND val = '!takedown' AND neg = 0
|
||||
)
|
||||
WHERE ` + activeTakedownClause("m") + `
|
||||
ORDER BY ` + orderBy + `
|
||||
LIMIT ?
|
||||
`
|
||||
@@ -2205,11 +2222,7 @@ func GetUserRepoCards(db DBTX, userDID string, currentUserDID string) ([]RepoCar
|
||||
JOIN users u ON m.did = u.did
|
||||
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
|
||||
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM labels
|
||||
WHERE (subject_did = m.did AND (subject_repo = m.repository OR subject_repo = ''))
|
||||
AND val = '!takedown' AND neg = 0
|
||||
)
|
||||
WHERE ` + activeTakedownClause("m") + `
|
||||
ORDER BY MAX(rs.last_push, m.created_at) DESC
|
||||
`
|
||||
|
||||
|
||||
@@ -39,18 +39,17 @@ func InitializeDatabase(dbPath string, cfg LibsqlConfig) (*sql.DB, *sql.DB, *Ses
|
||||
} else {
|
||||
roDSN += "?mode=ro"
|
||||
}
|
||||
readOnlyDB, err := sql.Open("libsql", roDSN)
|
||||
// Wrap with busyTimeoutConnector so every pooled read-only connection
|
||||
// gets PRAGMA busy_timeout. Without this, reads return SQLITE_BUSY
|
||||
// immediately when a write is in progress on the read-write connection
|
||||
// (busy_timeout is per-connection, so a one-shot PRAGMA only configures
|
||||
// whichever conn served it).
|
||||
roBase, err := openLibsqlLocalConnector(roDSN)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to open read-only database connection", "error", err)
|
||||
slog.Warn("Failed to open read-only database connector", "error", err)
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// busy_timeout is per-connection — without this, reads return SQLITE_BUSY
|
||||
// immediately when a write is in progress on the read-write connection.
|
||||
var busyTimeout int
|
||||
if err := readOnlyDB.QueryRow("PRAGMA busy_timeout = 5000").Scan(&busyTimeout); err != nil {
|
||||
slog.Warn("Failed to set busy_timeout on read-only connection", "error", err)
|
||||
}
|
||||
readOnlyDB := sql.OpenDB(&busyTimeoutConnector{base: roBase, timeoutMs: 5000})
|
||||
|
||||
slog.Info("UI database initialized", "mode", "readonly", "path", dbPath)
|
||||
|
||||
|
||||
+71
-15
@@ -5,7 +5,9 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -55,36 +57,34 @@ func InitDB(path string, cfg LibsqlConfig) (*sql.DB, error) {
|
||||
db = sql.OpenDB(connector)
|
||||
slog.Info("Database opened in embedded replica mode", "path", path, "sync_url", cfg.SyncURL)
|
||||
} else {
|
||||
// Local-only mode: plain file via libsql driver
|
||||
// Paths starting with "file:" or ":memory:" are already valid libsql URIs
|
||||
// Local-only mode: plain file via libsql driver, wrapped so every new
|
||||
// connection gets PRAGMA busy_timeout. SQLite's busy_timeout is
|
||||
// per-connection, so a one-shot db.Exec only configures whichever
|
||||
// pooled conn served the call — leaving the rest to fail SQLITE_BUSY
|
||||
// instantly on any write contention with the jetstream/backfill workers.
|
||||
// Paths starting with "file:" or ":memory:" are already valid libsql URIs.
|
||||
dsn := path
|
||||
if !strings.HasPrefix(path, "file:") && !strings.HasPrefix(path, ":memory:") {
|
||||
dsn = "file:" + path
|
||||
}
|
||||
var err error
|
||||
db, err = sql.Open("libsql", dsn)
|
||||
baseConnector, err := openLibsqlLocalConnector(dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db = sql.OpenDB(&busyTimeoutConnector{base: baseConnector, timeoutMs: 5000})
|
||||
slog.Info("Database opened in local-only mode", "path", path)
|
||||
}
|
||||
|
||||
// In local-only mode, configure WAL and busy_timeout locally.
|
||||
// In embedded replica mode, the remote server manages these settings
|
||||
// and PRAGMA assignments are rejected as "unsupported statement"
|
||||
// (observed with Bunny Database; Turso may behave similarly).
|
||||
// In local-only mode, set WAL mode (database-wide setting, persists
|
||||
// across connections — single call is sufficient unlike busy_timeout).
|
||||
// In embedded replica mode, the remote server manages this and the
|
||||
// PRAGMA is rejected as "unsupported statement" (observed with Bunny;
|
||||
// Turso may behave similarly).
|
||||
if cfg.SyncURL == "" {
|
||||
// Enable WAL mode for concurrent read/write access
|
||||
var journalMode string
|
||||
if err := db.QueryRow("PRAGMA journal_mode = WAL").Scan(&journalMode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Retry on lock instead of failing immediately (5s timeout)
|
||||
var busyTimeout int
|
||||
if err := db.QueryRow("PRAGMA busy_timeout = 5000").Scan(&busyTimeout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Enable foreign keys
|
||||
@@ -377,3 +377,59 @@ func parseMigrationFilename(filename string) (int, string, error) {
|
||||
|
||||
return version, name, nil
|
||||
}
|
||||
|
||||
// openLibsqlLocalConnector returns a driver.Connector for a local libsql DSN.
|
||||
// go-libsql exports NewEmbeddedReplicaConnector for replica mode but no public
|
||||
// constructor for local files, so we obtain the driver via a probe sql.Open
|
||||
// (which is lazy and opens no connection) and ask it for a Connector.
|
||||
func openLibsqlLocalConnector(dsn string) (driver.Connector, error) {
|
||||
probe, err := sql.Open("libsql", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("probe libsql driver: %w", err)
|
||||
}
|
||||
drv := probe.Driver()
|
||||
_ = probe.Close()
|
||||
|
||||
dctx, ok := drv.(driver.DriverContext)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("libsql driver does not implement driver.DriverContext")
|
||||
}
|
||||
return dctx.OpenConnector(dsn)
|
||||
}
|
||||
|
||||
// busyTimeoutConnector wraps a driver.Connector and runs PRAGMA busy_timeout
|
||||
// on every newly opened connection. SQLite's busy_timeout is per-connection,
|
||||
// so this is the only way to ensure every conn in the pool waits on lock
|
||||
// contention instead of returning SQLITE_BUSY immediately.
|
||||
type busyTimeoutConnector struct {
|
||||
base driver.Connector
|
||||
timeoutMs int
|
||||
}
|
||||
|
||||
func (c *busyTimeoutConnector) Connect(ctx context.Context) (driver.Conn, error) {
|
||||
conn, err := c.base.Connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// libsql treats PRAGMA assignments as queries that return a row, so we
|
||||
// must use QueryerContext rather than ExecerContext.
|
||||
queryer, ok := conn.(driver.QueryerContext)
|
||||
if !ok {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("libsql conn does not support QueryerContext")
|
||||
}
|
||||
|
||||
rows, err := queryer.QueryContext(ctx, fmt.Sprintf("PRAGMA busy_timeout = %d", c.timeoutMs), nil)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("set busy_timeout on new conn: %w", err)
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (c *busyTimeoutConnector) Driver() driver.Driver {
|
||||
return c.base.Driver()
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ func (h *ManifestDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
if owner.Handle != resolvedHandle {
|
||||
_ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle)
|
||||
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
|
||||
owner.Handle = resolvedHandle
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ func (h *DigestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
if owner.Handle != resolvedHandle {
|
||||
_ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle)
|
||||
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
|
||||
owner.Handle = resolvedHandle
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
|
||||
|
||||
// Opportunistically update cached handle if it changed
|
||||
if owner.Handle != resolvedHandle {
|
||||
_ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle)
|
||||
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
|
||||
owner.Handle = resolvedHandle
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
} else if viewedUser.Handle != resolvedHandle {
|
||||
// Opportunistically update cached handle if it changed
|
||||
_ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle)
|
||||
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
|
||||
viewedUser.Handle = resolvedHandle
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
package labeler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
@@ -13,26 +14,11 @@ import (
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
|
||||
comatproto "github.com/bluesky-social/indigo/api/atproto"
|
||||
"github.com/bluesky-social/indigo/events"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// LabelsMessage is the wire format for subscribeLabels events.
|
||||
type LabelsMessage struct {
|
||||
Seq int64 `json:"seq"`
|
||||
Labels []LabelEvent `json:"labels"`
|
||||
}
|
||||
|
||||
// LabelEvent is a single label from the labeler.
|
||||
type LabelEvent struct {
|
||||
Src string `json:"src"`
|
||||
URI string `json:"uri"`
|
||||
CID string `json:"cid,omitempty"`
|
||||
Val string `json:"val"`
|
||||
Neg bool `json:"neg"`
|
||||
Cts string `json:"cts"`
|
||||
Exp string `json:"exp,omitempty"`
|
||||
}
|
||||
|
||||
// Subscriber connects to a labeler's subscribeLabels endpoint
|
||||
// and mirrors labels into the appview database.
|
||||
type Subscriber struct {
|
||||
@@ -121,35 +107,55 @@ func (s *Subscriber) connect() error {
|
||||
default:
|
||||
}
|
||||
|
||||
var msg LabelsMessage
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
mt, payload, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read error: %w", err)
|
||||
}
|
||||
// Per the ATProto event-stream spec each frame is a binary message; reject text.
|
||||
if mt != websocket.BinaryMessage {
|
||||
slog.Warn("Ignoring non-binary frame from labeler", "type", mt)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, le := range msg.Labels {
|
||||
seq, labels, err := decodeFrame(payload)
|
||||
if err != nil {
|
||||
if errors.Is(err, errInfoFrame) {
|
||||
continue // already logged inside decodeFrame
|
||||
}
|
||||
return fmt.Errorf("decode frame: %w", err)
|
||||
}
|
||||
|
||||
for _, le := range labels {
|
||||
cts, _ := time.Parse(time.RFC3339, le.Cts)
|
||||
did, repo := extractSubjectFromURI(le.URI)
|
||||
did, repo := extractSubjectFromURI(le.Uri)
|
||||
|
||||
label := &db.Label{
|
||||
Src: le.Src,
|
||||
URI: le.URI,
|
||||
URI: le.Uri,
|
||||
Val: le.Val,
|
||||
Neg: le.Neg,
|
||||
Neg: le.Neg != nil && *le.Neg,
|
||||
Cts: cts,
|
||||
SubjectDID: did,
|
||||
SubjectRepo: repo,
|
||||
Seq: msg.Seq,
|
||||
Seq: seq,
|
||||
}
|
||||
|
||||
if err := db.UpsertLabel(s.database, label); err != nil {
|
||||
slog.Warn("Failed to upsert label", "uri", le.URI, "error", err)
|
||||
slog.Warn("Failed to upsert label", "uri", le.Uri, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("Mirrored label",
|
||||
"uri", le.URI,
|
||||
// "Mirrored label X" reads as an apply; reversals are a different action
|
||||
// from the operator's POV (and a different SQL effect — the NOT EXISTS
|
||||
// negation clause kicks in), so log them distinctly.
|
||||
msg := "Mirrored label"
|
||||
if label.Neg {
|
||||
msg = "Mirrored label reversal"
|
||||
}
|
||||
slog.Info(msg,
|
||||
"uri", le.Uri,
|
||||
"val", le.Val,
|
||||
"neg", le.Neg,
|
||||
"neg", label.Neg,
|
||||
"subject_did", did,
|
||||
"subject_repo", repo,
|
||||
)
|
||||
@@ -157,6 +163,54 @@ func (s *Subscriber) connect() error {
|
||||
}
|
||||
}
|
||||
|
||||
// errInfoFrame is returned by decodeFrame when the frame is informational and the
|
||||
// caller should just continue to the next message.
|
||||
var errInfoFrame = errors.New("labeler: info frame")
|
||||
|
||||
// decodeFrame parses a single subscribeLabels binary frame. ATProto event-stream framing
|
||||
// is two concatenated CBOR objects: a {op,t} header and a body. We dispatch on the
|
||||
// header op/t pair and return the labels body for op=1, t="#labels". For #info frames
|
||||
// we log and signal errInfoFrame so the caller skips. Error frames (op=-1) become Go
|
||||
// errors so the run loop reconnects with backoff.
|
||||
func decodeFrame(payload []byte) (int64, []*comatproto.LabelDefs_Label, error) {
|
||||
r := bytes.NewReader(payload)
|
||||
var header events.EventHeader
|
||||
if err := header.UnmarshalCBOR(r); err != nil {
|
||||
return 0, nil, fmt.Errorf("unmarshal header: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case header.Op == events.EvtKindErrorFrame:
|
||||
var ef events.ErrorFrame
|
||||
if err := ef.UnmarshalCBOR(r); err != nil {
|
||||
return 0, nil, fmt.Errorf("unmarshal error frame: %w", err)
|
||||
}
|
||||
return 0, nil, fmt.Errorf("labeler error frame: %s — %s", ef.Error, ef.Message)
|
||||
|
||||
case header.Op == events.EvtKindMessage && header.MsgType == "#labels":
|
||||
var body comatproto.LabelSubscribeLabels_Labels
|
||||
if err := body.UnmarshalCBOR(r); err != nil {
|
||||
return 0, nil, fmt.Errorf("unmarshal labels body: %w", err)
|
||||
}
|
||||
return body.Seq, body.Labels, nil
|
||||
|
||||
case header.Op == events.EvtKindMessage && header.MsgType == "#info":
|
||||
var info comatproto.LabelSubscribeLabels_Info
|
||||
if err := info.UnmarshalCBOR(r); err != nil {
|
||||
return 0, nil, fmt.Errorf("unmarshal info body: %w", err)
|
||||
}
|
||||
message := ""
|
||||
if info.Message != nil {
|
||||
message = *info.Message
|
||||
}
|
||||
slog.Info("Labeler info frame", "name", info.Name, "message", message)
|
||||
return 0, nil, errInfoFrame
|
||||
|
||||
default:
|
||||
return 0, nil, fmt.Errorf("unexpected frame op=%d t=%q", header.Op, header.MsgType)
|
||||
}
|
||||
}
|
||||
|
||||
// extractSubjectFromURI extracts the DID and repository from an AT URI.
|
||||
// Examples:
|
||||
//
|
||||
@@ -228,12 +282,3 @@ func SubscriberFromConfig(labelerDIDOrURL string, database *sql.DB) *Subscriber
|
||||
labelerURL := ParseLabelerURL(labelerDIDOrURL)
|
||||
return NewSubscriber(labelerURL, database)
|
||||
}
|
||||
|
||||
// DecodeLabelsFromJSON decodes a JSON-encoded labels message.
|
||||
func DecodeLabelsFromJSON(data []byte) (*LabelsMessage, error) {
|
||||
var msg LabelsMessage
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package did
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
didplc "github.com/did-method-plc/go-didplc"
|
||||
)
|
||||
|
||||
// AddRotationKeyOptions configures a rotation-key insert operation.
|
||||
type AddRotationKeyOptions struct {
|
||||
// DID is the resolved did:plc identifier of the service.
|
||||
DID string
|
||||
|
||||
// PLCDirectoryURL is the PLC directory endpoint (defaults to https://plc.directory if empty).
|
||||
PLCDirectoryURL string
|
||||
|
||||
// RotationKey is the currently-authorized rotation key used to sign the update op.
|
||||
RotationKey atcrypto.PrivateKey
|
||||
|
||||
// SigningKey is the local k256 verification key — its public part goes into the
|
||||
// new op's VerificationMethods so we don't accidentally drop it during the update.
|
||||
SigningKey *atcrypto.PrivateKeyK256
|
||||
|
||||
// VerificationKeyName is the fragment under which SigningKey is registered
|
||||
// (e.g. "atproto" for a PDS, "atproto_label" for a labeler).
|
||||
VerificationKeyName string
|
||||
|
||||
// NewKey is the rotation key to add. If nil, a fresh K-256 key is generated and
|
||||
// returned in the result so the caller can print/persist it.
|
||||
NewKey atcrypto.PrivateKeyExportable
|
||||
|
||||
// Prepend places the new key at the highest priority position. When false the key
|
||||
// is appended at the lowest priority — only set false when the operator explicitly
|
||||
// asks for it.
|
||||
Prepend bool
|
||||
}
|
||||
|
||||
// AddRotationKeyResult describes the outcome of an AddRotationKey call.
|
||||
type AddRotationKeyResult struct {
|
||||
NewKey atcrypto.PrivateKeyExportable
|
||||
NewKeyDIDKey string
|
||||
Generated bool
|
||||
AlreadyPresent bool
|
||||
ExistingAt int
|
||||
InsertedAt int
|
||||
TotalKeys int
|
||||
}
|
||||
|
||||
// AddRotationKey fetches the current PLC op log, inserts NewKey (generating one if nil),
|
||||
// signs the update with RotationKey, and submits it. Caller is responsible for printing
|
||||
// the generated key material — this function returns it on the result so prints can
|
||||
// happen in the binary's own format.
|
||||
func AddRotationKey(ctx context.Context, opt AddRotationKeyOptions) (*AddRotationKeyResult, error) {
|
||||
if opt.DID == "" {
|
||||
return nil, fmt.Errorf("plc: DID is required")
|
||||
}
|
||||
if opt.RotationKey == nil {
|
||||
return nil, fmt.Errorf("plc: rotation key is required to sign updates")
|
||||
}
|
||||
if opt.SigningKey == nil {
|
||||
return nil, fmt.Errorf("plc: signing key is required (becomes verificationMethods.%s)", opt.VerificationKeyName)
|
||||
}
|
||||
if opt.VerificationKeyName == "" {
|
||||
return nil, fmt.Errorf("plc: VerificationKeyName is required")
|
||||
}
|
||||
|
||||
directory := opt.PLCDirectoryURL
|
||||
if directory == "" {
|
||||
directory = "https://plc.directory"
|
||||
}
|
||||
client := &didplc.Client{DirectoryURL: directory}
|
||||
|
||||
res := &AddRotationKeyResult{NewKey: opt.NewKey}
|
||||
if res.NewKey == nil {
|
||||
raw, err := atcrypto.GeneratePrivateKeyK256()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plc: failed to generate rotation key: %w", err)
|
||||
}
|
||||
res.NewKey = raw
|
||||
res.Generated = true
|
||||
}
|
||||
newPub, err := res.NewKey.PublicKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plc: failed to derive new public key: %w", err)
|
||||
}
|
||||
res.NewKeyDIDKey = newPub.DIDKey()
|
||||
|
||||
opLog, err := client.OpLog(ctx, opt.DID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plc: failed to fetch op log for %s: %w", opt.DID, err)
|
||||
}
|
||||
if len(opLog) == 0 {
|
||||
return nil, fmt.Errorf("plc: empty op log for %s", opt.DID)
|
||||
}
|
||||
lastEntry := opLog[len(opLog)-1]
|
||||
lastOp := lastEntry.Regular
|
||||
if lastOp == nil {
|
||||
return nil, fmt.Errorf("plc: last operation is not a regular op")
|
||||
}
|
||||
|
||||
for i, k := range lastOp.RotationKeys {
|
||||
if k == res.NewKeyDIDKey {
|
||||
res.AlreadyPresent = true
|
||||
res.ExistingAt = i
|
||||
res.TotalKeys = len(lastOp.RotationKeys)
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
|
||||
rotationKeys := make([]string, 0, len(lastOp.RotationKeys)+1)
|
||||
if opt.Prepend {
|
||||
rotationKeys = append(rotationKeys, res.NewKeyDIDKey)
|
||||
rotationKeys = append(rotationKeys, lastOp.RotationKeys...)
|
||||
res.InsertedAt = 0
|
||||
} else {
|
||||
rotationKeys = append(rotationKeys, lastOp.RotationKeys...)
|
||||
rotationKeys = append(rotationKeys, res.NewKeyDIDKey)
|
||||
res.InsertedAt = len(rotationKeys) - 1
|
||||
}
|
||||
res.TotalKeys = len(rotationKeys)
|
||||
|
||||
sigPub, err := opt.SigningKey.PublicKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plc: failed to derive signing public key: %w", err)
|
||||
}
|
||||
prevCID := lastEntry.AsOperation().CID().String()
|
||||
|
||||
op := &didplc.RegularOp{
|
||||
Type: "plc_operation",
|
||||
RotationKeys: rotationKeys,
|
||||
VerificationMethods: map[string]string{
|
||||
opt.VerificationKeyName: sigPub.DIDKey(),
|
||||
},
|
||||
AlsoKnownAs: lastOp.AlsoKnownAs,
|
||||
Services: lastOp.Services,
|
||||
Prev: &prevCID,
|
||||
}
|
||||
if err := op.Sign(opt.RotationKey); err != nil {
|
||||
return nil, fmt.Errorf("plc: failed to sign update: %w", err)
|
||||
}
|
||||
if err := client.Submit(ctx, opt.DID, op); err != nil {
|
||||
return nil, fmt.Errorf("plc: failed to submit update: %w", err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ListRotationKeysOptions configures a list-rotation-keys read.
|
||||
type ListRotationKeysOptions struct {
|
||||
DID string
|
||||
PLCDirectoryURL string
|
||||
LocalRotationKey atcrypto.PrivateKey // optional — used to compute the LOCAL marker
|
||||
}
|
||||
|
||||
// ListRotationKeysResult holds the priority-ordered rotation keys plus the local
|
||||
// rotation key's did:key form (if provided), so callers can mark and warn appropriately.
|
||||
type ListRotationKeysResult struct {
|
||||
DID string
|
||||
Directory string
|
||||
Keys []string
|
||||
LocalDIDKey string
|
||||
LocalPresent bool
|
||||
}
|
||||
|
||||
// ListRotationKeys fetches the current PLC op and returns its rotation keys in priority order.
|
||||
func ListRotationKeys(ctx context.Context, opt ListRotationKeysOptions) (*ListRotationKeysResult, error) {
|
||||
if opt.DID == "" {
|
||||
return nil, fmt.Errorf("plc: DID is required")
|
||||
}
|
||||
directory := opt.PLCDirectoryURL
|
||||
if directory == "" {
|
||||
directory = "https://plc.directory"
|
||||
}
|
||||
client := &didplc.Client{DirectoryURL: directory}
|
||||
|
||||
opLog, err := client.OpLog(ctx, opt.DID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plc: failed to fetch op log for %s: %w", opt.DID, err)
|
||||
}
|
||||
if len(opLog) == 0 {
|
||||
return nil, fmt.Errorf("plc: empty op log for %s", opt.DID)
|
||||
}
|
||||
lastOp := opLog[len(opLog)-1].Regular
|
||||
if lastOp == nil {
|
||||
return nil, fmt.Errorf("plc: last operation is not a regular op")
|
||||
}
|
||||
|
||||
res := &ListRotationKeysResult{
|
||||
DID: opt.DID,
|
||||
Directory: directory,
|
||||
Keys: append([]string(nil), lastOp.RotationKeys...),
|
||||
}
|
||||
if opt.LocalRotationKey != nil {
|
||||
pub, err := opt.LocalRotationKey.PublicKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plc: failed to derive local rotation public key: %w", err)
|
||||
}
|
||||
res.LocalDIDKey = pub.DIDKey()
|
||||
for _, k := range res.Keys {
|
||||
if k == res.LocalDIDKey {
|
||||
res.LocalPresent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package did
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
)
|
||||
|
||||
// TestAddRotationKey_AppendNew confirms a fresh key is appended at the lowest priority
|
||||
// when Prepend is false.
|
||||
func TestAddRotationKey_AppendNew(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
serverRot := generateK256(t)
|
||||
signing := generateK256(t)
|
||||
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing)
|
||||
defer fake.Close()
|
||||
|
||||
newKey := generateK256(t)
|
||||
res, err := AddRotationKey(ctx, AddRotationKeyOptions{
|
||||
DID: fake.did,
|
||||
PLCDirectoryURL: fake.URL(),
|
||||
RotationKey: serverRot,
|
||||
SigningKey: signing,
|
||||
VerificationKeyName: "atproto",
|
||||
NewKey: newKey,
|
||||
Prepend: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddRotationKey: %v", err)
|
||||
}
|
||||
if res.AlreadyPresent {
|
||||
t.Fatal("AlreadyPresent should be false")
|
||||
}
|
||||
if res.Generated {
|
||||
t.Error("Generated should be false when NewKey provided")
|
||||
}
|
||||
if res.TotalKeys != 2 {
|
||||
t.Errorf("TotalKeys: got %d want 2", res.TotalKeys)
|
||||
}
|
||||
if res.InsertedAt != 1 {
|
||||
t.Errorf("InsertedAt: got %d want 1 (appended)", res.InsertedAt)
|
||||
}
|
||||
|
||||
if len(fake.submitted) != 1 {
|
||||
t.Fatalf("expected one update submission, got %d", len(fake.submitted))
|
||||
}
|
||||
got := fake.submitted[0]
|
||||
newPub, _ := newKey.PublicKey()
|
||||
if got.RotationKeys[len(got.RotationKeys)-1] != newPub.DIDKey() {
|
||||
t.Errorf("appended key not at last position: %v", got.RotationKeys)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddRotationKey_Prepend confirms Prepend=true puts the new key at index 0
|
||||
// (highest priority position).
|
||||
func TestAddRotationKey_Prepend(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
serverRot := generateK256(t)
|
||||
signing := generateK256(t)
|
||||
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing)
|
||||
defer fake.Close()
|
||||
|
||||
newKey := generateK256(t)
|
||||
res, err := AddRotationKey(ctx, AddRotationKeyOptions{
|
||||
DID: fake.did,
|
||||
PLCDirectoryURL: fake.URL(),
|
||||
RotationKey: serverRot,
|
||||
SigningKey: signing,
|
||||
VerificationKeyName: "atproto",
|
||||
NewKey: newKey,
|
||||
Prepend: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddRotationKey: %v", err)
|
||||
}
|
||||
if res.InsertedAt != 0 {
|
||||
t.Errorf("InsertedAt: got %d want 0", res.InsertedAt)
|
||||
}
|
||||
if len(fake.submitted) != 1 {
|
||||
t.Fatalf("expected one update, got %d", len(fake.submitted))
|
||||
}
|
||||
newPub, _ := newKey.PublicKey()
|
||||
if fake.submitted[0].RotationKeys[0] != newPub.DIDKey() {
|
||||
t.Errorf("prepended key not at first position: %v", fake.submitted[0].RotationKeys)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddRotationKey_GeneratesWhenNil confirms the helper generates a fresh key
|
||||
// and reports it via Result.
|
||||
func TestAddRotationKey_GeneratesWhenNil(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
serverRot := generateK256(t)
|
||||
signing := generateK256(t)
|
||||
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing)
|
||||
defer fake.Close()
|
||||
|
||||
res, err := AddRotationKey(ctx, AddRotationKeyOptions{
|
||||
DID: fake.did,
|
||||
PLCDirectoryURL: fake.URL(),
|
||||
RotationKey: serverRot,
|
||||
SigningKey: signing,
|
||||
VerificationKeyName: "atproto",
|
||||
NewKey: nil,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddRotationKey: %v", err)
|
||||
}
|
||||
if !res.Generated {
|
||||
t.Error("Generated should be true when NewKey is nil")
|
||||
}
|
||||
if res.NewKey == nil {
|
||||
t.Fatal("NewKey on result should not be nil")
|
||||
}
|
||||
if res.NewKeyDIDKey == "" {
|
||||
t.Error("NewKeyDIDKey should be populated")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddRotationKey_AlreadyPresent confirms a no-op when the key is already in the list.
|
||||
func TestAddRotationKey_AlreadyPresent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
serverRot := generateK256(t)
|
||||
signing := generateK256(t)
|
||||
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing)
|
||||
defer fake.Close()
|
||||
|
||||
res, err := AddRotationKey(ctx, AddRotationKeyOptions{
|
||||
DID: fake.did,
|
||||
PLCDirectoryURL: fake.URL(),
|
||||
RotationKey: serverRot,
|
||||
SigningKey: signing,
|
||||
VerificationKeyName: "atproto",
|
||||
NewKey: serverRot,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddRotationKey: %v", err)
|
||||
}
|
||||
if !res.AlreadyPresent {
|
||||
t.Error("AlreadyPresent should be true")
|
||||
}
|
||||
if res.ExistingAt != 0 {
|
||||
t.Errorf("ExistingAt: got %d want 0", res.ExistingAt)
|
||||
}
|
||||
if len(fake.submitted) != 0 {
|
||||
t.Errorf("no submission expected when key already present, got %d", len(fake.submitted))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddRotationKey_ValidationErrors covers the early-return guard clauses in AddRotationKey.
|
||||
func TestAddRotationKey_ValidationErrors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
signing := generateK256(t)
|
||||
rot := generateK256(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
opt AddRotationKeyOptions
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "missing DID",
|
||||
opt: AddRotationKeyOptions{RotationKey: rot, SigningKey: signing, VerificationKeyName: "atproto"},
|
||||
wantSub: "DID is required",
|
||||
},
|
||||
{
|
||||
name: "missing rotation key",
|
||||
opt: AddRotationKeyOptions{DID: "did:plc:abc", SigningKey: signing, VerificationKeyName: "atproto"},
|
||||
wantSub: "rotation key is required",
|
||||
},
|
||||
{
|
||||
name: "missing signing key",
|
||||
opt: AddRotationKeyOptions{DID: "did:plc:abc", RotationKey: rot, VerificationKeyName: "atproto"},
|
||||
wantSub: "signing key is required",
|
||||
},
|
||||
{
|
||||
name: "missing verification key name",
|
||||
opt: AddRotationKeyOptions{DID: "did:plc:abc", RotationKey: rot, SigningKey: signing},
|
||||
wantSub: "VerificationKeyName is required",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := AddRotationKey(ctx, tc.opt)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantSub) {
|
||||
t.Errorf("error: got %q want substring %q", err.Error(), tc.wantSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestListRotationKeys returns the priority-ordered keys from the latest op.
|
||||
func TestListRotationKeys(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
rot1 := generateK256(t)
|
||||
rot2 := generateK256(t)
|
||||
signing := generateK256(t)
|
||||
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{rot1, rot2}, rot1, signing)
|
||||
defer fake.Close()
|
||||
|
||||
res, err := ListRotationKeys(ctx, ListRotationKeysOptions{
|
||||
DID: fake.did,
|
||||
PLCDirectoryURL: fake.URL(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListRotationKeys: %v", err)
|
||||
}
|
||||
if res.DID != fake.did {
|
||||
t.Errorf("DID: got %s want %s", res.DID, fake.did)
|
||||
}
|
||||
if res.Directory != fake.URL() {
|
||||
t.Errorf("Directory: got %s want %s", res.Directory, fake.URL())
|
||||
}
|
||||
if len(res.Keys) != 2 {
|
||||
t.Fatalf("Keys length: got %d want 2", len(res.Keys))
|
||||
}
|
||||
pub1, _ := rot1.PublicKey()
|
||||
pub2, _ := rot2.PublicKey()
|
||||
if res.Keys[0] != pub1.DIDKey() {
|
||||
t.Errorf("Keys[0]: got %s want %s", res.Keys[0], pub1.DIDKey())
|
||||
}
|
||||
if res.Keys[1] != pub2.DIDKey() {
|
||||
t.Errorf("Keys[1]: got %s want %s", res.Keys[1], pub2.DIDKey())
|
||||
}
|
||||
if res.LocalDIDKey != "" {
|
||||
t.Errorf("LocalDIDKey should be empty when no LocalRotationKey provided, got %s", res.LocalDIDKey)
|
||||
}
|
||||
if res.LocalPresent {
|
||||
t.Error("LocalPresent should be false when no LocalRotationKey provided")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListRotationKeys_LocalPresent confirms LocalPresent flips to true when the local
|
||||
// key matches one in the published list.
|
||||
func TestListRotationKeys_LocalPresent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
serverRot := generateK256(t)
|
||||
signing := generateK256(t)
|
||||
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing)
|
||||
defer fake.Close()
|
||||
|
||||
res, err := ListRotationKeys(ctx, ListRotationKeysOptions{
|
||||
DID: fake.did,
|
||||
PLCDirectoryURL: fake.URL(),
|
||||
LocalRotationKey: serverRot,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListRotationKeys: %v", err)
|
||||
}
|
||||
if !res.LocalPresent {
|
||||
t.Error("LocalPresent should be true")
|
||||
}
|
||||
pub, _ := serverRot.PublicKey()
|
||||
if res.LocalDIDKey != pub.DIDKey() {
|
||||
t.Errorf("LocalDIDKey: got %s want %s", res.LocalDIDKey, pub.DIDKey())
|
||||
}
|
||||
}
|
||||
|
||||
// TestListRotationKeys_LocalNotPresent flags a rotated-out local key.
|
||||
func TestListRotationKeys_LocalNotPresent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
serverRot := generateK256(t)
|
||||
signing := generateK256(t)
|
||||
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing)
|
||||
defer fake.Close()
|
||||
|
||||
stranger := generateK256(t)
|
||||
res, err := ListRotationKeys(ctx, ListRotationKeysOptions{
|
||||
DID: fake.did,
|
||||
PLCDirectoryURL: fake.URL(),
|
||||
LocalRotationKey: stranger,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListRotationKeys: %v", err)
|
||||
}
|
||||
if res.LocalPresent {
|
||||
t.Error("LocalPresent should be false for a stranger key")
|
||||
}
|
||||
if res.LocalDIDKey == "" {
|
||||
t.Error("LocalDIDKey should still be populated even when not present")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListRotationKeys_MissingDID surfaces the early-return validation.
|
||||
func TestListRotationKeys_MissingDID(t *testing.T) {
|
||||
_, err := ListRotationKeys(context.Background(), ListRotationKeysOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing DID")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "DID is required") {
|
||||
t.Errorf("error: got %q", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Package did provides shared did:web and did:plc identity management for ATCR services.
|
||||
//
|
||||
// Both the hold and labeler services declare an ATProto identity with a signing key
|
||||
// and one or more service endpoints. This package generalizes the genesis/update/load
|
||||
// flow so callers only have to specify their verification key fragment name and the
|
||||
// service entries they want to register.
|
||||
package did
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
)
|
||||
|
||||
// Service is a service entry in a DID document or PLC operation.
|
||||
type Service struct {
|
||||
Type string
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
// Config configures DID identity loading or creation.
|
||||
type Config struct {
|
||||
// Method is "web" or "plc".
|
||||
Method string
|
||||
|
||||
// PublicURL is the externally reachable URL of the service.
|
||||
PublicURL string
|
||||
|
||||
// DBPath is a directory used to persist did.txt for did:plc identities.
|
||||
DBPath string
|
||||
|
||||
// SigningKeyPath is the on-disk path for the K-256 signing key (will be generated if missing).
|
||||
SigningKeyPath string
|
||||
|
||||
// RotationKey is a multibase-encoded private key used to sign PLC operations (optional).
|
||||
// If empty for did:plc, a new rotation key is generated and logged once for the operator.
|
||||
RotationKey string
|
||||
|
||||
// PLCDirectoryURL is the PLC directory endpoint.
|
||||
PLCDirectoryURL string
|
||||
|
||||
// DID overrides the persisted DID (used for adoption/recovery of an existing did:plc).
|
||||
DID string
|
||||
|
||||
// VerificationKeyName is the fragment used in the DID document and PLC operation
|
||||
// for the signing key (e.g. "atproto" for a PDS, "atproto_label" for a labeler).
|
||||
VerificationKeyName string
|
||||
|
||||
// Services lists service entries keyed by service id (e.g. "atproto_pds", "atproto_labeler").
|
||||
Services map[string]Service
|
||||
}
|
||||
|
||||
// LoadOrCreate returns the service's DID. did:web is derived deterministically from
|
||||
// PublicURL; did:plc is loaded from disk or created and registered with the PLC directory.
|
||||
func LoadOrCreate(ctx context.Context, cfg Config) (string, error) {
|
||||
if cfg.Method != "plc" {
|
||||
return GenerateDIDFromURL(cfg.PublicURL), nil
|
||||
}
|
||||
|
||||
if cfg.VerificationKeyName == "" {
|
||||
return "", fmt.Errorf("did: VerificationKeyName is required for did:plc")
|
||||
}
|
||||
if len(cfg.Services) == 0 {
|
||||
return "", fmt.Errorf("did: at least one service entry is required for did:plc")
|
||||
}
|
||||
|
||||
didPath := filepath.Join(cfg.DBPath, "did.txt")
|
||||
|
||||
var d string
|
||||
if cfg.DID != "" {
|
||||
if !strings.HasPrefix(cfg.DID, "did:plc:") {
|
||||
return "", fmt.Errorf("did: DID must be a did:plc identifier, got %q", cfg.DID)
|
||||
}
|
||||
d = cfg.DID
|
||||
slog.Info("Using DID from config (adoption/recovery)", "did", d)
|
||||
} else if data, err := os.ReadFile(didPath); err == nil {
|
||||
val := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(val, "did:plc:") {
|
||||
d = val
|
||||
slog.Info("Loaded existing did:plc identity", "did", d)
|
||||
}
|
||||
}
|
||||
|
||||
if d != "" {
|
||||
if err := os.MkdirAll(filepath.Dir(didPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("did: failed to create did.txt directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(didPath, []byte(d+"\n"), 0600); err != nil {
|
||||
return "", fmt.Errorf("did: failed to write did.txt: %w", err)
|
||||
}
|
||||
|
||||
signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("did: failed to load signing key: %w", err)
|
||||
}
|
||||
rotationKey, _ := parseOptionalMultibaseKey(cfg.RotationKey)
|
||||
|
||||
if err := EnsureCurrent(ctx, d, rotationKey, signingKey, cfg); err != nil {
|
||||
slog.Warn("Failed to verify PLC identity is current (will retry on next restart)",
|
||||
"did", d, "error", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
slog.Info("Creating new did:plc identity")
|
||||
|
||||
signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("did: failed to load signing key: %w", err)
|
||||
}
|
||||
|
||||
var rotationKey atcrypto.PrivateKeyExportable
|
||||
if cfg.RotationKey != "" {
|
||||
rotationKey, err = parseOptionalMultibaseKey(cfg.RotationKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("did: failed to parse rotation_key: %w", err)
|
||||
}
|
||||
} else {
|
||||
rawKey, genErr := atcrypto.GeneratePrivateKeyK256()
|
||||
if genErr != nil {
|
||||
return "", fmt.Errorf("did: failed to generate rotation key: %w", genErr)
|
||||
}
|
||||
rotationKey = rawKey
|
||||
slog.Warn("Generated new rotation key — save this in your config as rotation_key",
|
||||
"rotation_key", rawKey.Multibase())
|
||||
}
|
||||
|
||||
d, err = CreateIdentity(ctx, rotationKey, signingKey, cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("did: failed to create PLC identity: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(didPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("did: failed to create did.txt directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(didPath, []byte(d+"\n"), 0600); err != nil {
|
||||
return "", fmt.Errorf("did: failed to write did.txt: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Created did:plc identity", "did", d, "plc_directory", cfg.PLCDirectoryURL)
|
||||
slog.Warn("Back up your rotation_key. It is only needed for DID updates (URL changes, key rotation).")
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// DIDDocument is the JSON shape we serve for did:web identities.
|
||||
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 []DIDService `json:"service,omitempty"`
|
||||
}
|
||||
|
||||
// VerificationMethod is a public key entry in a DID document.
|
||||
type VerificationMethod struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Controller string `json:"controller"`
|
||||
PublicKeyMultibase string `json:"publicKeyMultibase"`
|
||||
}
|
||||
|
||||
// DIDService is a service entry in a DID document.
|
||||
type DIDService struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ServiceEndpoint string `json:"serviceEndpoint"`
|
||||
}
|
||||
|
||||
// BuildDIDDocument constructs a DID document for a did:web identity. The verification
|
||||
// method fragment matches verificationKeyName (e.g. "#atproto" or "#atproto_label");
|
||||
// pass "" to default to "atproto". Authentication is only added for the standard
|
||||
// "atproto" key per the bsky/PDS pattern.
|
||||
func BuildDIDDocument(did, publicURL string, signingKey *atcrypto.PrivateKeyK256, verificationKeyName string, services map[string]Service) (*DIDDocument, error) {
|
||||
host, err := hostWithPort(publicURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pub, err := signingKey.PublicKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("did: failed to get public key: %w", err)
|
||||
}
|
||||
|
||||
keyName := verificationKeyName
|
||||
if keyName == "" {
|
||||
keyName = "atproto"
|
||||
}
|
||||
|
||||
doc := &DIDDocument{
|
||||
Context: []string{
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1",
|
||||
},
|
||||
ID: did,
|
||||
AlsoKnownAs: []string{"at://" + host},
|
||||
VerificationMethod: []VerificationMethod{
|
||||
{
|
||||
ID: fmt.Sprintf("%s#%s", did, keyName),
|
||||
Type: "Multikey",
|
||||
Controller: did,
|
||||
PublicKeyMultibase: pub.Multibase(),
|
||||
},
|
||||
},
|
||||
}
|
||||
if keyName == "atproto" {
|
||||
doc.Authentication = []string{fmt.Sprintf("%s#atproto", did)}
|
||||
}
|
||||
for id, svc := range services {
|
||||
doc.Service = append(doc.Service, DIDService{
|
||||
ID: "#" + id,
|
||||
Type: svc.Type,
|
||||
ServiceEndpoint: svc.Endpoint,
|
||||
})
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// MarshalDIDDocument is a convenience for serving a DID doc as indented JSON.
|
||||
func MarshalDIDDocument(doc *DIDDocument) ([]byte, error) {
|
||||
return json.MarshalIndent(doc, "", " ")
|
||||
}
|
||||
|
||||
func hostWithPort(publicURL string) (string, error) {
|
||||
u, err := url.Parse(publicURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("did: failed to parse public URL: %w", err)
|
||||
}
|
||||
host := u.Hostname()
|
||||
if port := u.Port(); port != "" && port != "80" && port != "443" {
|
||||
host = host + ":" + port
|
||||
}
|
||||
return host, nil
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package did
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testServices(publicURL string) map[string]Service {
|
||||
return map[string]Service{
|
||||
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: publicURL},
|
||||
"atcr_hold": {Type: "AtcrHoldService", Endpoint: publicURL},
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDIDDocument verifies the standard atproto DID document layout for a did:web service.
|
||||
func TestBuildDIDDocument(t *testing.T) {
|
||||
publicURL := "https://hold.example.com"
|
||||
signingKey := generateK256(t)
|
||||
|
||||
doc, err := BuildDIDDocument("did:web:hold.example.com", publicURL, signingKey, "atproto", testServices(publicURL))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDIDDocument: %v", err)
|
||||
}
|
||||
|
||||
if doc.ID != "did:web:hold.example.com" {
|
||||
t.Errorf("ID: got %s want did:web:hold.example.com", doc.ID)
|
||||
}
|
||||
|
||||
expectedContexts := []string{
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1",
|
||||
}
|
||||
if len(doc.Context) != len(expectedContexts) {
|
||||
t.Errorf("Context length: got %d want %d", len(doc.Context), len(expectedContexts))
|
||||
}
|
||||
for i, expected := range expectedContexts {
|
||||
if doc.Context[i] != expected {
|
||||
t.Errorf("Context[%d]: got %s want %s", i, doc.Context[i], expected)
|
||||
}
|
||||
}
|
||||
|
||||
if len(doc.AlsoKnownAs) != 1 || doc.AlsoKnownAs[0] != "at://hold.example.com" {
|
||||
t.Errorf("AlsoKnownAs: got %v want [at://hold.example.com]", doc.AlsoKnownAs)
|
||||
}
|
||||
|
||||
if len(doc.VerificationMethod) != 1 {
|
||||
t.Fatalf("VerificationMethod length: got %d want 1", len(doc.VerificationMethod))
|
||||
}
|
||||
vm := doc.VerificationMethod[0]
|
||||
if vm.ID != "did:web:hold.example.com#atproto" {
|
||||
t.Errorf("VerificationMethod.ID: got %s", vm.ID)
|
||||
}
|
||||
if vm.Type != "Multikey" {
|
||||
t.Errorf("VerificationMethod.Type: got %s want Multikey", vm.Type)
|
||||
}
|
||||
if vm.Controller != "did:web:hold.example.com" {
|
||||
t.Errorf("VerificationMethod.Controller: got %s", vm.Controller)
|
||||
}
|
||||
if vm.PublicKeyMultibase == "" {
|
||||
t.Error("VerificationMethod.PublicKeyMultibase is empty")
|
||||
}
|
||||
|
||||
pub, _ := signingKey.PublicKey()
|
||||
if vm.PublicKeyMultibase != pub.Multibase() {
|
||||
t.Errorf("VerificationMethod.PublicKeyMultibase: got %s want %s", vm.PublicKeyMultibase, pub.Multibase())
|
||||
}
|
||||
|
||||
if len(doc.Authentication) != 1 || doc.Authentication[0] != "did:web:hold.example.com#atproto" {
|
||||
t.Errorf("Authentication: got %v", doc.Authentication)
|
||||
}
|
||||
|
||||
if len(doc.Service) != 2 {
|
||||
t.Fatalf("Service length: got %d want 2", len(doc.Service))
|
||||
}
|
||||
|
||||
svcByID := map[string]DIDService{}
|
||||
for _, s := range doc.Service {
|
||||
svcByID[s.ID] = s
|
||||
}
|
||||
pdsService, ok := svcByID["#atproto_pds"]
|
||||
if !ok {
|
||||
t.Fatalf("missing #atproto_pds service in %v", svcByID)
|
||||
}
|
||||
if pdsService.Type != "AtprotoPersonalDataServer" {
|
||||
t.Errorf("#atproto_pds Type: got %s", pdsService.Type)
|
||||
}
|
||||
if pdsService.ServiceEndpoint != publicURL {
|
||||
t.Errorf("#atproto_pds Endpoint: got %s want %s", pdsService.ServiceEndpoint, publicURL)
|
||||
}
|
||||
holdService, ok := svcByID["#atcr_hold"]
|
||||
if !ok {
|
||||
t.Fatalf("missing #atcr_hold service in %v", svcByID)
|
||||
}
|
||||
if holdService.Type != "AtcrHoldService" {
|
||||
t.Errorf("#atcr_hold Type: got %s", holdService.Type)
|
||||
}
|
||||
if holdService.ServiceEndpoint != publicURL {
|
||||
t.Errorf("#atcr_hold Endpoint: got %s want %s", holdService.ServiceEndpoint, publicURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDIDDocument_WithPort confirms non-standard ports flow into AlsoKnownAs.
|
||||
func TestBuildDIDDocument_WithPort(t *testing.T) {
|
||||
publicURL := "https://hold.example.com:8443"
|
||||
signingKey := generateK256(t)
|
||||
|
||||
doc, err := BuildDIDDocument("did:web:hold.example.com%3A8443", publicURL, signingKey, "atproto", testServices(publicURL))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDIDDocument: %v", err)
|
||||
}
|
||||
|
||||
if doc.ID != "did:web:hold.example.com%3A8443" {
|
||||
t.Errorf("ID: got %s", doc.ID)
|
||||
}
|
||||
if doc.AlsoKnownAs[0] != "at://hold.example.com:8443" {
|
||||
t.Errorf("AlsoKnownAs: got %s want at://hold.example.com:8443", doc.AlsoKnownAs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDIDDocument_StandardPortsStripped verifies port 80/443 are not appended to alsoKnownAs.
|
||||
func TestBuildDIDDocument_StandardPortsStripped(t *testing.T) {
|
||||
signingKey := generateK256(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
publicURL string
|
||||
wantAKA string
|
||||
}{
|
||||
{"http port 80", "http://hold.example.com:80", "at://hold.example.com"},
|
||||
{"https port 443", "https://hold.example.com:443", "at://hold.example.com"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
doc, err := BuildDIDDocument("did:web:hold.example.com", tc.publicURL, signingKey, "atproto", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDIDDocument: %v", err)
|
||||
}
|
||||
if doc.AlsoKnownAs[0] != tc.wantAKA {
|
||||
t.Errorf("AlsoKnownAs: got %s want %s", doc.AlsoKnownAs[0], tc.wantAKA)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDIDDocument_InvalidURL confirms malformed URLs surface as errors.
|
||||
func TestBuildDIDDocument_InvalidURL(t *testing.T) {
|
||||
signingKey := generateK256(t)
|
||||
_, err := BuildDIDDocument("did:web:bogus", "ht!tp://invalid url", signingKey, "atproto", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid URL, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDIDDocument_DefaultVerificationKeyName confirms the empty fragment defaults to "atproto"
|
||||
// and adds Authentication.
|
||||
func TestBuildDIDDocument_DefaultVerificationKeyName(t *testing.T) {
|
||||
signingKey := generateK256(t)
|
||||
doc, err := BuildDIDDocument("did:web:example.com", "https://example.com", signingKey, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDIDDocument: %v", err)
|
||||
}
|
||||
if doc.VerificationMethod[0].ID != "did:web:example.com#atproto" {
|
||||
t.Errorf("VerificationMethod.ID: got %s want did:web:example.com#atproto", doc.VerificationMethod[0].ID)
|
||||
}
|
||||
if len(doc.Authentication) != 1 || doc.Authentication[0] != "did:web:example.com#atproto" {
|
||||
t.Errorf("Authentication: got %v", doc.Authentication)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDIDDocument_LabelerKey confirms a non-"atproto" verification key (e.g. labeler)
|
||||
// does not add Authentication, mirroring the bsky labeler pattern.
|
||||
func TestBuildDIDDocument_LabelerKey(t *testing.T) {
|
||||
signingKey := generateK256(t)
|
||||
services := map[string]Service{
|
||||
"atproto_labeler": {Type: "AtprotoLabeler", Endpoint: "https://labeler.example.com"},
|
||||
}
|
||||
doc, err := BuildDIDDocument("did:web:labeler.example.com", "https://labeler.example.com", signingKey, "atproto_label", services)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDIDDocument: %v", err)
|
||||
}
|
||||
if doc.VerificationMethod[0].ID != "did:web:labeler.example.com#atproto_label" {
|
||||
t.Errorf("VerificationMethod.ID: got %s", doc.VerificationMethod[0].ID)
|
||||
}
|
||||
if len(doc.Authentication) != 0 {
|
||||
t.Errorf("Authentication should be empty for non-atproto key, got %v", doc.Authentication)
|
||||
}
|
||||
if len(doc.Service) != 1 || doc.Service[0].ID != "#atproto_labeler" {
|
||||
t.Errorf("Service: got %v", doc.Service)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDIDDocument_NoServices confirms a DID document can be built without any service entries.
|
||||
func TestBuildDIDDocument_NoServices(t *testing.T) {
|
||||
signingKey := generateK256(t)
|
||||
doc, err := BuildDIDDocument("did:web:example.com", "https://example.com", signingKey, "atproto", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDIDDocument: %v", err)
|
||||
}
|
||||
if len(doc.Service) != 0 {
|
||||
t.Errorf("Service should be empty, got %v", doc.Service)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarshalDIDDocument confirms marshaling produces parseable, indented JSON.
|
||||
func TestMarshalDIDDocument(t *testing.T) {
|
||||
signingKey := generateK256(t)
|
||||
doc, err := BuildDIDDocument("did:web:example.com", "https://example.com", signingKey, "atproto", testServices("https://example.com"))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDIDDocument: %v", err)
|
||||
}
|
||||
|
||||
data, err := MarshalDIDDocument(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalDIDDocument: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(data), " ") {
|
||||
t.Error("expected indented JSON output")
|
||||
}
|
||||
|
||||
var parsed DIDDocument
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
if parsed.ID != doc.ID {
|
||||
t.Errorf("ID round-trip: got %s want %s", parsed.ID, doc.ID)
|
||||
}
|
||||
if len(parsed.Service) != len(doc.Service) {
|
||||
t.Errorf("Service length round-trip: got %d want %d", len(parsed.Service), len(doc.Service))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOrCreate_DIDWeb confirms did:web mode returns a deterministic identifier
|
||||
// without touching disk or any external service.
|
||||
func TestLoadOrCreate_DIDWeb(t *testing.T) {
|
||||
cfg := Config{
|
||||
Method: "web",
|
||||
PublicURL: "https://hold.example.com",
|
||||
}
|
||||
d, err := LoadOrCreate(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate: %v", err)
|
||||
}
|
||||
if d != "did:web:hold.example.com" {
|
||||
t.Errorf("DID: got %s want did:web:hold.example.com", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOrCreate_DIDWebDefaultsToWebWhenMethodEmpty confirms an empty method behaves like did:web.
|
||||
func TestLoadOrCreate_DIDWebDefaultsToWebWhenMethodEmpty(t *testing.T) {
|
||||
cfg := Config{
|
||||
PublicURL: "https://example.com:8443",
|
||||
}
|
||||
d, err := LoadOrCreate(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate: %v", err)
|
||||
}
|
||||
if d != "did:web:example.com%3A8443" {
|
||||
t.Errorf("DID: got %s want did:web:example.com%%3A8443", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOrCreate_PLCRequiresVerificationKeyName confirms missing required PLC fields error early.
|
||||
func TestLoadOrCreate_PLCRequiresVerificationKeyName(t *testing.T) {
|
||||
cfg := Config{
|
||||
Method: "plc",
|
||||
PublicURL: "https://example.com",
|
||||
Services: map[string]Service{
|
||||
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.com"},
|
||||
},
|
||||
}
|
||||
_, err := LoadOrCreate(context.Background(), cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when VerificationKeyName is empty")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "VerificationKeyName") {
|
||||
t.Errorf("expected error about VerificationKeyName, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOrCreate_PLCRequiresServices confirms PLC mode demands at least one service entry.
|
||||
func TestLoadOrCreate_PLCRequiresServices(t *testing.T) {
|
||||
cfg := Config{
|
||||
Method: "plc",
|
||||
PublicURL: "https://example.com",
|
||||
VerificationKeyName: "atproto",
|
||||
}
|
||||
_, err := LoadOrCreate(context.Background(), cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when Services is empty")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "service") {
|
||||
t.Errorf("expected error about services, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOrCreate_PLCRejectsNonPLCAdoption confirms a configured DID must be a did:plc identifier.
|
||||
func TestLoadOrCreate_PLCRejectsNonPLCAdoption(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cfg := Config{
|
||||
Method: "plc",
|
||||
PublicURL: "https://example.com",
|
||||
DBPath: tmp,
|
||||
SigningKeyPath: filepath.Join(tmp, "signing.key"),
|
||||
VerificationKeyName: "atproto",
|
||||
DID: "did:web:example.com",
|
||||
Services: map[string]Service{
|
||||
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.com"},
|
||||
},
|
||||
}
|
||||
_, err := LoadOrCreate(context.Background(), cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-did:plc adoption")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "did:plc") {
|
||||
t.Errorf("expected error about did:plc, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOrCreate_PLCAdoptionPersistsDID confirms a configured did:plc is written to did.txt
|
||||
// even when the PLC directory call fails (the failure is logged, not returned).
|
||||
func TestLoadOrCreate_PLCAdoptionPersistsDID(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cfg := Config{
|
||||
Method: "plc",
|
||||
PublicURL: "https://example.com",
|
||||
DBPath: tmp,
|
||||
SigningKeyPath: filepath.Join(tmp, "signing.key"),
|
||||
VerificationKeyName: "atproto",
|
||||
DID: "did:plc:abcdefghijklmnopqrstuvwx",
|
||||
PLCDirectoryURL: "http://127.0.0.1:1", // unreachable; EnsureCurrent failure is non-fatal
|
||||
Services: map[string]Service{
|
||||
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.com"},
|
||||
},
|
||||
}
|
||||
d, err := LoadOrCreate(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate: %v", err)
|
||||
}
|
||||
if d != "did:plc:abcdefghijklmnopqrstuvwx" {
|
||||
t.Errorf("DID: got %s", d)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(tmp, "did.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read did.txt: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(got)) != "did:plc:abcdefghijklmnopqrstuvwx" {
|
||||
t.Errorf("did.txt contents: got %q", string(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package did
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
didplc "github.com/did-method-plc/go-didplc"
|
||||
)
|
||||
|
||||
// CreateIdentity builds a genesis PLC operation with the configured verification key and
|
||||
// services, signs it with the rotation key, and submits it to the PLC directory.
|
||||
func CreateIdentity(ctx context.Context, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, cfg Config) (string, error) {
|
||||
rotPub, err := rotationKey.PublicKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("did: failed to get rotation public key: %w", err)
|
||||
}
|
||||
sigPub, err := signingKey.PublicKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("did: failed to get signing public key: %w", err)
|
||||
}
|
||||
|
||||
host, err := hostWithPort(cfg.PublicURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
op := &didplc.RegularOp{
|
||||
Type: "plc_operation",
|
||||
RotationKeys: []string{rotPub.DIDKey()},
|
||||
VerificationMethods: map[string]string{
|
||||
cfg.VerificationKeyName: sigPub.DIDKey(),
|
||||
},
|
||||
AlsoKnownAs: []string{"at://" + host},
|
||||
Services: toOpServices(cfg.Services),
|
||||
Prev: nil,
|
||||
}
|
||||
if err := op.Sign(rotationKey); err != nil {
|
||||
return "", fmt.Errorf("did: failed to sign genesis operation: %w", err)
|
||||
}
|
||||
|
||||
d, err := op.DID()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("did: failed to compute DID from genesis: %w", err)
|
||||
}
|
||||
|
||||
client := &didplc.Client{DirectoryURL: cfg.PLCDirectoryURL}
|
||||
if err := client.Submit(ctx, d, op); err != nil {
|
||||
return "", fmt.Errorf("did: failed to submit genesis operation: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// EnsureCurrent reconciles the published DID document with the local config; if the local
|
||||
// signing key, public URL, or service set differs, an update operation is signed and submitted.
|
||||
// Without a rotation key, mismatches log a warning but are not fatal.
|
||||
func EnsureCurrent(ctx context.Context, did string, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, cfg Config) error {
|
||||
client := &didplc.Client{DirectoryURL: cfg.PLCDirectoryURL}
|
||||
|
||||
opLog, err := client.OpLog(ctx, did)
|
||||
if err != nil {
|
||||
return fmt.Errorf("did: failed to fetch op log for %s: %w", did, err)
|
||||
}
|
||||
if len(opLog) == 0 {
|
||||
return fmt.Errorf("did: empty op log for %s", did)
|
||||
}
|
||||
lastEntry := opLog[len(opLog)-1]
|
||||
lastOp := lastEntry.Regular
|
||||
if lastOp == nil {
|
||||
slog.Warn("Last PLC operation is not a regular op, skipping auto-update", "did", did)
|
||||
return nil
|
||||
}
|
||||
|
||||
sigPub, err := signingKey.PublicKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("did: failed to get signing public key: %w", err)
|
||||
}
|
||||
localKey := sigPub.DIDKey()
|
||||
plcKey := lastOp.VerificationMethods[cfg.VerificationKeyName]
|
||||
keyMatch := localKey == plcKey
|
||||
|
||||
servicesMatch := true
|
||||
for name, svc := range cfg.Services {
|
||||
plcSvc, ok := lastOp.Services[name]
|
||||
if !ok || plcSvc.Type != svc.Type || plcSvc.Endpoint != svc.Endpoint {
|
||||
servicesMatch = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if keyMatch && servicesMatch {
|
||||
slog.Info("PLC identity is current", "did", did)
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Info("PLC identity needs update",
|
||||
"did", did, "signing_key_changed", !keyMatch, "services_changed", !servicesMatch)
|
||||
|
||||
if rotationKey == nil {
|
||||
slog.Warn("PLC document doesn't match local state but no rotation key available. Provide rotation key to auto-update.",
|
||||
"did", did, "signing_key_changed", !keyMatch, "services_changed", !servicesMatch)
|
||||
return nil
|
||||
}
|
||||
|
||||
rotPub, err := rotationKey.PublicKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("did: failed to get rotation public key: %w", err)
|
||||
}
|
||||
localRotKey := rotPub.DIDKey()
|
||||
|
||||
// Verify the local rotation key still has authority on the PLC document.
|
||||
// If it's been rotated out (possibly maliciously), refuse to submit — PLC would
|
||||
// reject anyway, and silent failure here would be confusing.
|
||||
localRotKeyPresent := false
|
||||
for _, k := range lastOp.RotationKeys {
|
||||
if k == localRotKey {
|
||||
localRotKeyPresent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !localRotKeyPresent {
|
||||
slog.Warn("Local rotation key not present in PLC document — refusing to update. Possible compromise or out-of-band rotation. Recover with offline key if available.",
|
||||
"did", did, "local_rotation_key", localRotKey, "plc_rotation_keys", lastOp.RotationKeys)
|
||||
return nil
|
||||
}
|
||||
|
||||
host, err := hostWithPort(cfg.PublicURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prevCID := lastEntry.AsOperation().CID().String()
|
||||
|
||||
op := &didplc.RegularOp{
|
||||
Type: "plc_operation",
|
||||
RotationKeys: lastOp.RotationKeys,
|
||||
VerificationMethods: map[string]string{
|
||||
cfg.VerificationKeyName: localKey,
|
||||
},
|
||||
AlsoKnownAs: []string{"at://" + host},
|
||||
Services: toOpServices(cfg.Services),
|
||||
Prev: &prevCID,
|
||||
}
|
||||
if err := op.Sign(rotationKey); err != nil {
|
||||
return fmt.Errorf("did: failed to sign update operation: %w", err)
|
||||
}
|
||||
if err := client.Submit(ctx, did, op); err != nil {
|
||||
return fmt.Errorf("did: failed to submit update: %w", err)
|
||||
}
|
||||
slog.Info("Updated PLC identity",
|
||||
"did", did, "signing_key_rotated", !keyMatch, "services_changed", !servicesMatch)
|
||||
return nil
|
||||
}
|
||||
|
||||
func toOpServices(in map[string]Service) map[string]didplc.OpService {
|
||||
out := make(map[string]didplc.OpService, len(in))
|
||||
for name, svc := range in {
|
||||
out[name] = didplc.OpService{Type: svc.Type, Endpoint: svc.Endpoint}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseOptionalMultibaseKey(encoded string) (atcrypto.PrivateKeyExportable, error) {
|
||||
if encoded == "" {
|
||||
return nil, nil
|
||||
}
|
||||
key, err := atcrypto.ParsePrivateMultibase(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("did: failed to parse multibase key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package did
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
didplc "github.com/did-method-plc/go-didplc"
|
||||
)
|
||||
|
||||
// fakePLC stands up an httptest server that serves a single op log entry and
|
||||
// captures any submitted update op for inspection.
|
||||
type fakePLC struct {
|
||||
server *httptest.Server
|
||||
did string
|
||||
logEntries []didplc.OpEnum
|
||||
submitted []didplc.RegularOp
|
||||
}
|
||||
|
||||
func (f *fakePLC) URL() string { return f.server.URL }
|
||||
|
||||
func (f *fakePLC) Close() { f.server.Close() }
|
||||
|
||||
// newFakePLC creates a fake PLC directory pre-loaded with a single signed
|
||||
// genesis op containing the given rotation keys (in priority order). Returns
|
||||
// the fake server and the resulting did:plc DID derived from the genesis op.
|
||||
func newFakePLC(t *testing.T, rotationKeys []*atcrypto.PrivateKeyK256, signer atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256) *fakePLC {
|
||||
t.Helper()
|
||||
|
||||
rotationDIDKeys := make([]string, 0, len(rotationKeys))
|
||||
for _, k := range rotationKeys {
|
||||
pub, err := k.PublicKey()
|
||||
if err != nil {
|
||||
t.Fatalf("rotation key public: %v", err)
|
||||
}
|
||||
rotationDIDKeys = append(rotationDIDKeys, pub.DIDKey())
|
||||
}
|
||||
|
||||
sigPub, err := signingKey.PublicKey()
|
||||
if err != nil {
|
||||
t.Fatalf("signing key public: %v", err)
|
||||
}
|
||||
|
||||
op := &didplc.RegularOp{
|
||||
Type: "plc_operation",
|
||||
RotationKeys: rotationDIDKeys,
|
||||
VerificationMethods: map[string]string{
|
||||
"atproto": sigPub.DIDKey(),
|
||||
},
|
||||
AlsoKnownAs: []string{"at://example.test"},
|
||||
Services: map[string]didplc.OpService{
|
||||
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.test"},
|
||||
},
|
||||
Prev: nil,
|
||||
}
|
||||
if err := op.Sign(signer); err != nil {
|
||||
t.Fatalf("sign genesis: %v", err)
|
||||
}
|
||||
did, err := op.DID()
|
||||
if err != nil {
|
||||
t.Fatalf("compute DID: %v", err)
|
||||
}
|
||||
|
||||
f := &fakePLC{
|
||||
did: did,
|
||||
logEntries: []didplc.OpEnum{{Regular: op}},
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/log") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(f.logEntries)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/"+did {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var op didplc.RegularOp
|
||||
if err := json.Unmarshal(body, &op); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
f.submitted = append(f.submitted, op)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
f.server = httptest.NewServer(mux)
|
||||
return f
|
||||
}
|
||||
|
||||
// generateK256 returns a fresh K-256 keypair, failing the test on error.
|
||||
func generateK256(t *testing.T) *atcrypto.PrivateKeyK256 {
|
||||
t.Helper()
|
||||
k, err := atcrypto.GeneratePrivateKeyK256()
|
||||
if err != nil {
|
||||
t.Fatalf("generate K-256: %v", err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// writeSigningKey persists a signing key to a temp file and returns its path.
|
||||
// Used so EnsureCurrent can load it via oauth.GenerateOrLoadPDSKey.
|
||||
func writeSigningKey(t *testing.T, dir string, key *atcrypto.PrivateKeyK256) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "signing.key")
|
||||
if err := os.WriteFile(path, key.Bytes(), 0600); err != nil {
|
||||
t.Fatalf("write signing key: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestEnsureCurrent_PreservesRotationKeys(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmp := t.TempDir()
|
||||
|
||||
// Server-side rotation key (the one stored in database.rotation_key) and an
|
||||
// "offline" recovery key that lives only in PLC. The genesis op lists offline
|
||||
// FIRST (highest priority).
|
||||
serverRot := generateK256(t)
|
||||
offlineRot := generateK256(t)
|
||||
|
||||
// Original signing key used to build genesis; the local signing key on disk
|
||||
// will be different to force EnsureCurrent into the update path.
|
||||
originalSigning := generateK256(t)
|
||||
localSigning := generateK256(t)
|
||||
writeSigningKey(t, tmp, localSigning)
|
||||
|
||||
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{offlineRot, serverRot}, serverRot, originalSigning)
|
||||
defer fake.Close()
|
||||
|
||||
cfg := Config{
|
||||
PublicURL: "https://example.test",
|
||||
PLCDirectoryURL: fake.URL(),
|
||||
VerificationKeyName: "atproto",
|
||||
Services: map[string]Service{
|
||||
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.test"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := EnsureCurrent(ctx, fake.did, serverRot, localSigning, cfg); err != nil {
|
||||
t.Fatalf("EnsureCurrent: %v", err)
|
||||
}
|
||||
|
||||
if len(fake.submitted) != 1 {
|
||||
t.Fatalf("expected exactly one update op submitted, got %d", len(fake.submitted))
|
||||
}
|
||||
got := fake.submitted[0]
|
||||
|
||||
offlinePub, _ := offlineRot.PublicKey()
|
||||
serverPub, _ := serverRot.PublicKey()
|
||||
want := []string{offlinePub.DIDKey(), serverPub.DIDKey()}
|
||||
|
||||
if len(got.RotationKeys) != len(want) {
|
||||
t.Fatalf("rotation keys length: got %d want %d", len(got.RotationKeys), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got.RotationKeys[i] != want[i] {
|
||||
t.Errorf("rotation key [%d]: got %s want %s", i, got.RotationKeys[i], want[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Verify signing key was actually rotated (sanity check we hit the update path).
|
||||
localPub, _ := localSigning.PublicKey()
|
||||
if got.VerificationMethods["atproto"] != localPub.DIDKey() {
|
||||
t.Errorf("expected signing key to update to local key %s, got %s",
|
||||
localPub.DIDKey(), got.VerificationMethods["atproto"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCurrent_RefusesUpdateWhenLocalKeyMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmp := t.TempDir()
|
||||
|
||||
// Genesis lists only the offline key. The local server has been rotated out.
|
||||
offlineRot := generateK256(t)
|
||||
localRot := generateK256(t) // not in PLC
|
||||
|
||||
originalSigning := generateK256(t)
|
||||
localSigning := generateK256(t)
|
||||
writeSigningKey(t, tmp, localSigning)
|
||||
|
||||
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{offlineRot}, offlineRot, originalSigning)
|
||||
defer fake.Close()
|
||||
|
||||
cfg := Config{
|
||||
PublicURL: "https://example.test",
|
||||
PLCDirectoryURL: fake.URL(),
|
||||
VerificationKeyName: "atproto",
|
||||
Services: map[string]Service{
|
||||
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.test"},
|
||||
},
|
||||
}
|
||||
|
||||
// Local signing key has drifted, which would normally trigger an update.
|
||||
if err := EnsureCurrent(ctx, fake.did, localRot, localSigning, cfg); err != nil {
|
||||
t.Fatalf("EnsureCurrent returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(fake.submitted) != 0 {
|
||||
t.Fatalf("expected no update submission when local rotation key isn't in PLC list, got %d", len(fake.submitted))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCurrent_NoOpWhenCurrent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmp := t.TempDir()
|
||||
|
||||
serverRot := generateK256(t)
|
||||
signing := generateK256(t)
|
||||
writeSigningKey(t, tmp, signing)
|
||||
|
||||
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing)
|
||||
defer fake.Close()
|
||||
|
||||
cfg := Config{
|
||||
PublicURL: "https://example.test",
|
||||
PLCDirectoryURL: fake.URL(),
|
||||
VerificationKeyName: "atproto",
|
||||
Services: map[string]Service{
|
||||
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.test"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := EnsureCurrent(ctx, fake.did, serverRot, signing, cfg); err != nil {
|
||||
t.Fatalf("EnsureCurrent: %v", err)
|
||||
}
|
||||
if len(fake.submitted) != 0 {
|
||||
t.Fatalf("expected no update when state is current, got %d submitted", len(fake.submitted))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package did
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// GenerateDIDFromURL computes a did:web identifier from a public URL.
|
||||
// Per the did:web spec, ports are percent-encoded (`:` → `%3A`).
|
||||
func GenerateDIDFromURL(publicURL string) string {
|
||||
u, err := url.Parse(publicURL)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("did:web:%s", publicURL)
|
||||
}
|
||||
hostname := u.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
port := u.Port()
|
||||
if port != "" && port != "80" && port != "443" {
|
||||
return fmt.Sprintf("did:web:%s%%3A%s", hostname, port)
|
||||
}
|
||||
return fmt.Sprintf("did:web:%s", hostname)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package did
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestGenerateDIDFromURL covers host extraction and port encoding for did:web.
|
||||
func TestGenerateDIDFromURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
publicURL string
|
||||
want string
|
||||
}{
|
||||
{"https no port", "https://hold.example.com", "did:web:hold.example.com"},
|
||||
{"http no port", "http://hold.example.com", "did:web:hold.example.com"},
|
||||
{"https port 443 stripped", "https://hold.example.com:443", "did:web:hold.example.com"},
|
||||
{"http port 80 stripped", "http://hold.example.com:80", "did:web:hold.example.com"},
|
||||
{"non-standard port encoded", "https://hold.example.com:8443", "did:web:hold.example.com%3A8443"},
|
||||
{"localhost with port", "http://localhost:3000", "did:web:localhost%3A3000"},
|
||||
{"trailing path ignored", "https://hold.example.com/foo/bar", "did:web:hold.example.com"},
|
||||
{"subdomain preserved", "https://api.hold.example.com", "did:web:api.hold.example.com"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := GenerateDIDFromURL(tc.publicURL)
|
||||
if got != tc.want {
|
||||
t.Errorf("GenerateDIDFromURL(%q): got %s want %s", tc.publicURL, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateDIDFromURL_EmptyHost confirms a URL without a hostname falls back to localhost.
|
||||
// (url.Parse on a bare path does not error, so the fallback is the only signal we have.)
|
||||
func TestGenerateDIDFromURL_EmptyHost(t *testing.T) {
|
||||
got := GenerateDIDFromURL("")
|
||||
if got != "did:web:localhost" {
|
||||
t.Errorf("empty URL: got %s want did:web:localhost", got)
|
||||
}
|
||||
}
|
||||
@@ -644,10 +644,10 @@ func (m *Manager) aggregateHoldFeatures(rank int) []string {
|
||||
}
|
||||
|
||||
var (
|
||||
minQuota int64 = -1
|
||||
maxQuota int64
|
||||
scanCount int
|
||||
totalHolds int
|
||||
minQuota int64 = -1
|
||||
maxQuota int64
|
||||
scanCount int
|
||||
totalHolds int
|
||||
)
|
||||
|
||||
for _, cached := range m.holdTierCache {
|
||||
|
||||
@@ -15,8 +15,10 @@ import (
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"atcr.io/pkg/atproto/did"
|
||||
"atcr.io/pkg/config"
|
||||
"atcr.io/pkg/hold/gc"
|
||||
"atcr.io/pkg/hold/pds"
|
||||
"atcr.io/pkg/hold/quota"
|
||||
)
|
||||
|
||||
@@ -57,6 +59,23 @@ type Config struct {
|
||||
// Subsystems (e.g. billing) use this to re-read the same file for extended fields.
|
||||
func (c *Config) ConfigPath() string { return c.configPath }
|
||||
|
||||
// DIDConfig builds the did.Config used to load or create the hold's identity.
|
||||
// The verification key fragment and service set are hold-specific (atproto PDS +
|
||||
// AtcrHoldService), so they're filled in here rather than at every callsite.
|
||||
func (c *Config) DIDConfig() did.Config {
|
||||
return did.Config{
|
||||
DID: c.Database.DID,
|
||||
Method: c.Database.DIDMethod,
|
||||
PublicURL: c.Server.PublicURL,
|
||||
DBPath: c.Database.Path,
|
||||
SigningKeyPath: c.Database.KeyPath,
|
||||
RotationKey: c.Database.RotationKey,
|
||||
PLCDirectoryURL: c.Database.PLCDirectoryURL,
|
||||
VerificationKeyName: "atproto",
|
||||
Services: pds.HoldServices(c.Server.PublicURL),
|
||||
}
|
||||
}
|
||||
|
||||
// AdminConfig defines admin panel settings
|
||||
type AdminConfig struct {
|
||||
// Enable the web-based admin panel.
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
package pds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
didplc "github.com/did-method-plc/go-didplc"
|
||||
)
|
||||
|
||||
// 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 the hold's identity.
|
||||
// It uses the hold's stored DID (which may be did:web or did:plc).
|
||||
func (p *HoldPDS) GenerateDIDDocument(publicURL string) (*DIDDocument, error) {
|
||||
did := p.did
|
||||
|
||||
// Parse URL for alsoKnownAs
|
||||
u, err := url.Parse(publicURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse public URL: %w", err)
|
||||
}
|
||||
host := u.Hostname()
|
||||
if port := u.Port(); port != "" && port != "80" && port != "443" {
|
||||
host = fmt.Sprintf("%s:%s", host, port)
|
||||
}
|
||||
|
||||
// Get public key in multibase format using indigo's crypto
|
||||
pubKey, err := p.signingKey.PublicKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
publicKeyMultibase := pubKey.Multibase()
|
||||
|
||||
doc := &DIDDocument{
|
||||
Context: []string{
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1",
|
||||
},
|
||||
ID: did,
|
||||
AlsoKnownAs: []string{
|
||||
fmt.Sprintf("at://%s", host),
|
||||
},
|
||||
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,
|
||||
},
|
||||
{
|
||||
ID: "#atcr_hold",
|
||||
Type: "AtcrHoldService",
|
||||
ServiceEndpoint: publicURL,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return doc, 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, "", " ")
|
||||
}
|
||||
|
||||
// DIDConfig holds parameters for DID creation/loading.
|
||||
type DIDConfig struct {
|
||||
DID string // Explicit DID for adoption/recovery (optional)
|
||||
DIDMethod string // "web" or "plc"
|
||||
PublicURL string
|
||||
DBPath string
|
||||
SigningKeyPath string
|
||||
RotationKey string // Multibase-encoded private key, K-256 or P-256 (optional)
|
||||
PLCDirectoryURL string
|
||||
}
|
||||
|
||||
// LoadOrCreateDID returns the hold's DID, either by deriving it from the URL (did:web)
|
||||
// or by loading/creating a did:plc identity registered with the PLC directory.
|
||||
//
|
||||
// For did:plc, the priority is: config DID > did.txt > create new.
|
||||
// When an existing DID is found (config or did.txt), EnsurePLCCurrent is called
|
||||
// to auto-update the PLC directory if the signing key or URL has changed.
|
||||
func LoadOrCreateDID(ctx context.Context, cfg DIDConfig) (string, error) {
|
||||
if cfg.DIDMethod != "plc" {
|
||||
return GenerateDIDFromURL(cfg.PublicURL), nil
|
||||
}
|
||||
|
||||
didPath := filepath.Join(cfg.DBPath, "did.txt")
|
||||
|
||||
// Priority: config DID > did.txt > create new
|
||||
var did string
|
||||
if cfg.DID != "" {
|
||||
if !strings.HasPrefix(cfg.DID, "did:plc:") {
|
||||
return "", fmt.Errorf("database.did must be a did:plc identifier, got %q", cfg.DID)
|
||||
}
|
||||
did = cfg.DID
|
||||
slog.Info("Using DID from config (adoption/recovery)", "did", did)
|
||||
} else if data, err := os.ReadFile(didPath); err == nil {
|
||||
d := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(d, "did:plc:") {
|
||||
did = d
|
||||
slog.Info("Loaded existing did:plc identity", "did", did)
|
||||
}
|
||||
}
|
||||
|
||||
if did != "" {
|
||||
// Persist to did.txt (may be from config on first adoption)
|
||||
if err := os.MkdirAll(filepath.Dir(didPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create directory for did.txt: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(didPath, []byte(did+"\n"), 0600); err != nil {
|
||||
return "", fmt.Errorf("failed to write did.txt: %w", err)
|
||||
}
|
||||
|
||||
// Load signing key (generate if missing — recovery case)
|
||||
signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load signing key: %w", err)
|
||||
}
|
||||
|
||||
// Try to parse rotation key (optional — may not be configured)
|
||||
rotationKey, _ := parseOptionalMultibaseKey(cfg.RotationKey)
|
||||
|
||||
if err := EnsurePLCCurrent(ctx, did, rotationKey, signingKey, cfg.PublicURL, cfg.PLCDirectoryURL); err != nil {
|
||||
slog.Warn("Failed to verify PLC identity is current (will retry on next restart)",
|
||||
"did", did,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
|
||||
return did, nil
|
||||
}
|
||||
|
||||
// No existing DID — create new genesis operation
|
||||
slog.Info("Creating new did:plc identity")
|
||||
|
||||
// Load or generate signing key
|
||||
signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load signing key: %w", err)
|
||||
}
|
||||
|
||||
// Parse or generate rotation key
|
||||
var rotationKey atcrypto.PrivateKeyExportable
|
||||
if cfg.RotationKey != "" {
|
||||
rotationKey, err = parseOptionalMultibaseKey(cfg.RotationKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse rotation_key: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Generate a new rotation key — user must save the multibase output
|
||||
rawKey, genErr := atcrypto.GeneratePrivateKeyK256()
|
||||
if genErr != nil {
|
||||
return "", fmt.Errorf("failed to generate rotation key: %w", genErr)
|
||||
}
|
||||
rotationKey = rawKey
|
||||
slog.Warn("Generated new rotation key — save this in your config as database.rotation_key",
|
||||
"rotation_key", rawKey.Multibase(),
|
||||
)
|
||||
}
|
||||
|
||||
did, err = CreatePLCIdentity(ctx, rotationKey, signingKey, cfg.PublicURL, cfg.PLCDirectoryURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create PLC identity: %w", err)
|
||||
}
|
||||
|
||||
// Persist DID
|
||||
if err := os.MkdirAll(filepath.Dir(didPath), 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create directory for did.txt: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(didPath, []byte(did+"\n"), 0600); err != nil {
|
||||
return "", fmt.Errorf("failed to write did.txt: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Created did:plc identity",
|
||||
"did", did,
|
||||
"plc_directory", cfg.PLCDirectoryURL,
|
||||
)
|
||||
slog.Warn("Back up your rotation_key. It is only needed for DID updates (URL changes, key rotation).")
|
||||
|
||||
return did, nil
|
||||
}
|
||||
|
||||
// parseOptionalMultibaseKey parses a multibase-encoded private key string (K-256 or P-256).
|
||||
// Returns nil, nil if the input is empty (key not configured).
|
||||
func parseOptionalMultibaseKey(encoded string) (atcrypto.PrivateKeyExportable, error) {
|
||||
if encoded == "" {
|
||||
return nil, nil
|
||||
}
|
||||
key, err := atcrypto.ParsePrivateMultibase(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse rotation key multibase string: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// EnsurePLCCurrent checks the PLC directory for the given DID and updates it
|
||||
// if the local signing key or public URL doesn't match what's registered.
|
||||
// If rotationKey is nil, mismatches are logged as warnings but not fatal.
|
||||
func EnsurePLCCurrent(ctx context.Context, did string, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) error {
|
||||
client := &didplc.Client{DirectoryURL: plcDirectoryURL}
|
||||
|
||||
// Fetch current op log
|
||||
opLog, err := client.OpLog(ctx, did)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch PLC op log for %s: %w", did, err)
|
||||
}
|
||||
if len(opLog) == 0 {
|
||||
return fmt.Errorf("empty op log for %s", did)
|
||||
}
|
||||
|
||||
lastEntry := opLog[len(opLog)-1]
|
||||
lastOp := lastEntry.Regular
|
||||
if lastOp == nil {
|
||||
// Last op is not a regular op (could be legacy or tombstone) — skip update
|
||||
slog.Warn("Last PLC operation is not a regular op, skipping auto-update", "did", did)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compare local state vs PLC state
|
||||
sigPub, err := signingKey.PublicKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get signing public key: %w", err)
|
||||
}
|
||||
localVerificationKey := sigPub.DIDKey()
|
||||
plcVerificationKey := lastOp.VerificationMethods["atproto"]
|
||||
|
||||
localEndpoint := publicURL
|
||||
var plcEndpoint string
|
||||
if svc, ok := lastOp.Services["atproto_pds"]; ok {
|
||||
plcEndpoint = svc.Endpoint
|
||||
}
|
||||
|
||||
keyMatch := localVerificationKey == plcVerificationKey
|
||||
endpointMatch := localEndpoint == plcEndpoint
|
||||
|
||||
if keyMatch && endpointMatch {
|
||||
slog.Info("PLC identity is current", "did", did)
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Info("PLC identity needs update",
|
||||
"did", did,
|
||||
"signing_key_changed", !keyMatch,
|
||||
"endpoint_changed", !endpointMatch,
|
||||
)
|
||||
|
||||
if rotationKey == nil {
|
||||
slog.Warn("PLC document doesn't match local state but no rotation key available. Provide rotation key to auto-update PLC directory.",
|
||||
"did", did,
|
||||
"signing_key_changed", !keyMatch,
|
||||
"endpoint_changed", !endpointMatch,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build update operation
|
||||
rotPub, err := rotationKey.PublicKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rotation public key: %w", err)
|
||||
}
|
||||
|
||||
// Extract hostname for alsoKnownAs
|
||||
u, err := url.Parse(publicURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse public URL: %w", err)
|
||||
}
|
||||
host := u.Hostname()
|
||||
if port := u.Port(); port != "" && port != "80" && port != "443" {
|
||||
host = host + ":" + port
|
||||
}
|
||||
|
||||
prevCID := lastEntry.AsOperation().CID().String()
|
||||
|
||||
op := &didplc.RegularOp{
|
||||
Type: "plc_operation",
|
||||
RotationKeys: []string{rotPub.DIDKey()},
|
||||
VerificationMethods: map[string]string{
|
||||
"atproto": localVerificationKey,
|
||||
},
|
||||
AlsoKnownAs: []string{"at://" + host},
|
||||
Services: map[string]didplc.OpService{
|
||||
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: publicURL},
|
||||
"atcr_hold": {Type: "AtcrHoldService", Endpoint: publicURL},
|
||||
},
|
||||
Prev: &prevCID,
|
||||
}
|
||||
|
||||
if err := op.Sign(rotationKey); err != nil {
|
||||
return fmt.Errorf("failed to sign PLC update operation: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Submit(ctx, did, op); err != nil {
|
||||
return fmt.Errorf("failed to submit PLC update: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Updated PLC identity",
|
||||
"did", did,
|
||||
"signing_key_rotated", !keyMatch,
|
||||
"endpoint_changed", !endpointMatch,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreatePLCIdentity creates a new did:plc identity by building a genesis operation,
|
||||
// signing it with the rotation key, and submitting it to the PLC directory.
|
||||
func CreatePLCIdentity(ctx context.Context, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) (string, error) {
|
||||
rotPub, err := rotationKey.PublicKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get rotation public key: %w", err)
|
||||
}
|
||||
|
||||
sigPub, err := signingKey.PublicKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get signing public key: %w", err)
|
||||
}
|
||||
|
||||
// Extract hostname for alsoKnownAs
|
||||
u, err := url.Parse(publicURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse public URL: %w", err)
|
||||
}
|
||||
host := u.Hostname()
|
||||
if port := u.Port(); port != "" && port != "80" && port != "443" {
|
||||
host = host + ":" + port
|
||||
}
|
||||
|
||||
op := &didplc.RegularOp{
|
||||
Type: "plc_operation",
|
||||
RotationKeys: []string{rotPub.DIDKey()},
|
||||
VerificationMethods: map[string]string{
|
||||
"atproto": sigPub.DIDKey(),
|
||||
},
|
||||
AlsoKnownAs: []string{"at://" + host},
|
||||
Services: map[string]didplc.OpService{
|
||||
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: publicURL},
|
||||
"atcr_hold": {Type: "AtcrHoldService", Endpoint: publicURL},
|
||||
},
|
||||
Prev: nil,
|
||||
}
|
||||
|
||||
if err := op.Sign(rotationKey); err != nil {
|
||||
return "", fmt.Errorf("failed to sign PLC genesis operation: %w", err)
|
||||
}
|
||||
|
||||
did, err := op.DID()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to compute DID from genesis operation: %w", err)
|
||||
}
|
||||
|
||||
client := &didplc.Client{DirectoryURL: plcDirectoryURL}
|
||||
if err := client.Submit(ctx, did, op); err != nil {
|
||||
return "", fmt.Errorf("failed to submit genesis operation to PLC directory: %w", err)
|
||||
}
|
||||
|
||||
return did, nil
|
||||
}
|
||||
|
||||
// GenerateDIDFromURL creates a did:web identifier from a public URL.
|
||||
// Per the did:web spec, ports are percent-encoded: the colon becomes %3A.
|
||||
// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com%3A8080"
|
||||
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", publicURL)
|
||||
}
|
||||
|
||||
// Get hostname
|
||||
hostname := u.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
// Get port
|
||||
port := u.Port()
|
||||
|
||||
// Include port in DID if it's non-standard (not 80 for http, not 443 for https)
|
||||
// Per did:web spec, the colon is percent-encoded as %3A
|
||||
if port != "" && port != "80" && port != "443" {
|
||||
return fmt.Sprintf("did:web:%s%%3A%s", hostname, port)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("did:web:%s", hostname)
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
package pds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGenerateDIDFromURL tests DID generation from various URL formats
|
||||
func TestGenerateDIDFromURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
publicURL string
|
||||
expectedDID string
|
||||
}{
|
||||
{
|
||||
name: "standard HTTP with standard port",
|
||||
publicURL: "http://hold.example.com",
|
||||
expectedDID: "did:web:hold.example.com",
|
||||
},
|
||||
{
|
||||
name: "standard HTTPS with standard port",
|
||||
publicURL: "https://hold.example.com",
|
||||
expectedDID: "did:web:hold.example.com",
|
||||
},
|
||||
{
|
||||
name: "HTTP with non-standard port",
|
||||
publicURL: "http://hold.example.com:8080",
|
||||
expectedDID: "did:web:hold.example.com%3A8080",
|
||||
},
|
||||
{
|
||||
name: "HTTPS with non-standard port",
|
||||
publicURL: "https://hold.example.com:8443",
|
||||
expectedDID: "did:web:hold.example.com%3A8443",
|
||||
},
|
||||
{
|
||||
name: "localhost with port",
|
||||
publicURL: "http://localhost:8080",
|
||||
expectedDID: "did:web:localhost%3A8080",
|
||||
},
|
||||
{
|
||||
name: "HTTP with explicit port 80",
|
||||
publicURL: "http://hold.example.com:80",
|
||||
expectedDID: "did:web:hold.example.com",
|
||||
},
|
||||
{
|
||||
name: "HTTPS with explicit port 443",
|
||||
publicURL: "https://hold.example.com:443",
|
||||
expectedDID: "did:web:hold.example.com",
|
||||
},
|
||||
{
|
||||
name: "subdomain",
|
||||
publicURL: "https://hold1.atcr.io",
|
||||
expectedDID: "did:web:hold1.atcr.io",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
did := GenerateDIDFromURL(tt.publicURL)
|
||||
if did != tt.expectedDID {
|
||||
t.Errorf("Expected DID %s, got %s", tt.expectedDID, did)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateDIDFromURL_InvalidURL tests handling of invalid URLs
|
||||
func TestGenerateDIDFromURL_InvalidURL(t *testing.T) {
|
||||
// Invalid URLs get parsed with empty hostname, which defaults to localhost
|
||||
did := GenerateDIDFromURL("not a url")
|
||||
if did != "did:web:localhost" {
|
||||
t.Errorf("Expected did:web:localhost for invalid URL, got %s", did)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateDIDDocument tests DID document generation
|
||||
func TestGenerateDIDDocument(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "pds.db")
|
||||
keyPath := filepath.Join(tmpDir, "signing-key")
|
||||
publicURL := "https://hold.example.com"
|
||||
|
||||
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, "https://atcr.io", dbPath, keyPath, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create PDS: %v", err)
|
||||
}
|
||||
|
||||
doc, err := pds.GenerateDIDDocument(publicURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate DID document: %v", err)
|
||||
}
|
||||
|
||||
// Verify required fields
|
||||
if doc.ID != "did:web:hold.example.com" {
|
||||
t.Errorf("Expected DID did:web:hold.example.com, got %s", doc.ID)
|
||||
}
|
||||
|
||||
// Verify context
|
||||
if len(doc.Context) != 3 {
|
||||
t.Errorf("Expected 3 context entries, got %d", len(doc.Context))
|
||||
}
|
||||
|
||||
expectedContexts := []string{
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1",
|
||||
}
|
||||
for i, expected := range expectedContexts {
|
||||
if doc.Context[i] != expected {
|
||||
t.Errorf("Expected context[%d] = %s, got %s", i, expected, doc.Context[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Verify alsoKnownAs
|
||||
if len(doc.AlsoKnownAs) != 1 || doc.AlsoKnownAs[0] != "at://hold.example.com" {
|
||||
t.Errorf("Expected alsoKnownAs=['at://hold.example.com'], got %v", doc.AlsoKnownAs)
|
||||
}
|
||||
|
||||
// Verify verification method
|
||||
if len(doc.VerificationMethod) != 1 {
|
||||
t.Fatalf("Expected 1 verification method, got %d", len(doc.VerificationMethod))
|
||||
}
|
||||
|
||||
vm := doc.VerificationMethod[0]
|
||||
if vm.ID != "did:web:hold.example.com#atproto" {
|
||||
t.Errorf("Expected verification method ID did:web:hold.example.com#atproto, got %s", vm.ID)
|
||||
}
|
||||
if vm.Type != "Multikey" {
|
||||
t.Errorf("Expected type Multikey, got %s", vm.Type)
|
||||
}
|
||||
if vm.Controller != "did:web:hold.example.com" {
|
||||
t.Errorf("Expected controller did:web:hold.example.com, got %s", vm.Controller)
|
||||
}
|
||||
if vm.PublicKeyMultibase == "" {
|
||||
t.Error("Expected non-empty publicKeyMultibase")
|
||||
}
|
||||
|
||||
// Verify authentication
|
||||
if len(doc.Authentication) != 1 || doc.Authentication[0] != "did:web:hold.example.com#atproto" {
|
||||
t.Errorf("Expected authentication=['did:web:hold.example.com#atproto'], got %v", doc.Authentication)
|
||||
}
|
||||
|
||||
// Verify services
|
||||
if len(doc.Service) != 2 {
|
||||
t.Fatalf("Expected 2 services, got %d", len(doc.Service))
|
||||
}
|
||||
|
||||
// Check PDS service
|
||||
pdsService := doc.Service[0]
|
||||
if pdsService.ID != "#atproto_pds" {
|
||||
t.Errorf("Expected service ID #atproto_pds, got %s", pdsService.ID)
|
||||
}
|
||||
if pdsService.Type != "AtprotoPersonalDataServer" {
|
||||
t.Errorf("Expected service type AtprotoPersonalDataServer, got %s", pdsService.Type)
|
||||
}
|
||||
if pdsService.ServiceEndpoint != publicURL {
|
||||
t.Errorf("Expected service endpoint %s, got %s", publicURL, pdsService.ServiceEndpoint)
|
||||
}
|
||||
|
||||
// Check hold service
|
||||
holdService := doc.Service[1]
|
||||
if holdService.ID != "#atcr_hold" {
|
||||
t.Errorf("Expected service ID #atcr_hold, got %s", holdService.ID)
|
||||
}
|
||||
if holdService.Type != "AtcrHoldService" {
|
||||
t.Errorf("Expected service type AtcrHoldService, got %s", holdService.Type)
|
||||
}
|
||||
if holdService.ServiceEndpoint != publicURL {
|
||||
t.Errorf("Expected service endpoint %s, got %s", publicURL, holdService.ServiceEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateDIDDocument_WithPort tests DID document with non-standard port
|
||||
func TestGenerateDIDDocument_WithPort(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "pds.db")
|
||||
keyPath := filepath.Join(tmpDir, "signing-key")
|
||||
publicURL := "https://hold.example.com:8443"
|
||||
|
||||
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com%3A8443", publicURL, "https://atcr.io", dbPath, keyPath, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create PDS: %v", err)
|
||||
}
|
||||
|
||||
doc, err := pds.GenerateDIDDocument(publicURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate DID document: %v", err)
|
||||
}
|
||||
|
||||
// Verify DID includes percent-encoded port
|
||||
if doc.ID != "did:web:hold.example.com%3A8443" {
|
||||
t.Errorf("Expected DID did:web:hold.example.com%%3A8443, got %s", doc.ID)
|
||||
}
|
||||
|
||||
// Verify alsoKnownAs includes port
|
||||
if doc.AlsoKnownAs[0] != "at://hold.example.com:8443" {
|
||||
t.Errorf("Expected alsoKnownAs with port, got %s", doc.AlsoKnownAs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarshalDIDDocument tests DID document JSON marshaling
|
||||
func TestMarshalDIDDocument(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "pds.db")
|
||||
keyPath := filepath.Join(tmpDir, "signing-key")
|
||||
publicURL := "https://hold.example.com"
|
||||
|
||||
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, "https://atcr.io", dbPath, keyPath, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create PDS: %v", err)
|
||||
}
|
||||
|
||||
jsonBytes, err := pds.MarshalDIDDocument()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal DID document: %v", err)
|
||||
}
|
||||
|
||||
// Verify it's valid JSON
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(jsonBytes, &doc); err != nil {
|
||||
t.Fatalf("Failed to unmarshal DID document JSON: %v", err)
|
||||
}
|
||||
|
||||
// Verify required fields
|
||||
if id, ok := doc["id"].(string); !ok || id != "did:web:hold.example.com" {
|
||||
t.Errorf("Expected id='did:web:hold.example.com', got %v", doc["id"])
|
||||
}
|
||||
|
||||
if _, ok := doc["@context"]; !ok {
|
||||
t.Error("Expected @context field in JSON")
|
||||
}
|
||||
|
||||
if _, ok := doc["verificationMethod"]; !ok {
|
||||
t.Error("Expected verificationMethod field in JSON")
|
||||
}
|
||||
|
||||
if _, ok := doc["service"]; !ok {
|
||||
t.Error("Expected service field in JSON")
|
||||
}
|
||||
|
||||
// Verify pretty-printed (has indentation)
|
||||
if len(jsonBytes) < 100 {
|
||||
t.Error("Expected pretty-printed JSON to be reasonably sized")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateDIDDocument_InvalidURL tests error handling
|
||||
func TestGenerateDIDDocument_InvalidURL(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "pds.db")
|
||||
keyPath := filepath.Join(tmpDir, "signing-key")
|
||||
publicURL := "https://hold.example.com"
|
||||
|
||||
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, "https://atcr.io", dbPath, keyPath, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create PDS: %v", err)
|
||||
}
|
||||
|
||||
// Try to generate DID document with invalid URL
|
||||
_, err = pds.GenerateDIDDocument("ht!tp://invalid url")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid URL, got nil")
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package pds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ExportToCAR streams the hold's repo as a CAR file to the writer.
|
||||
func (p *HoldPDS) ExportToCAR(ctx context.Context, w io.Writer) error {
|
||||
return p.repomgr.ReadRepo(ctx, p.uid, "", w)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/atproto/did"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
holddb "atcr.io/pkg/hold/db"
|
||||
"atcr.io/pkg/s3"
|
||||
@@ -36,6 +37,17 @@ func init() {
|
||||
lexutil.RegisterType(atproto.ImageConfigCollection, &atproto.ImageConfigRecord{})
|
||||
}
|
||||
|
||||
// HoldServices returns the service entries the hold publishes in its DID document
|
||||
// and PLC operations: an atproto PDS endpoint plus the ATCR hold service endpoint.
|
||||
// Single source of truth, used by both boot-time identity loading and DID-document
|
||||
// serving.
|
||||
func HoldServices(publicURL string) map[string]did.Service {
|
||||
return map[string]did.Service{
|
||||
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: publicURL},
|
||||
"atcr_hold": {Type: "AtcrHoldService", Endpoint: publicURL},
|
||||
}
|
||||
}
|
||||
|
||||
// HoldPDS is a minimal ATProto PDS implementation for a hold service
|
||||
type HoldPDS struct {
|
||||
did string
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/atproto/did"
|
||||
"atcr.io/pkg/hold/quota"
|
||||
"atcr.io/pkg/s3"
|
||||
"github.com/bluesky-social/indigo/api/bsky"
|
||||
@@ -425,7 +426,7 @@ func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// Generate DID document
|
||||
didDoc, err := h.pds.GenerateDIDDocument(h.pds.PublicURL)
|
||||
didDoc, err := did.BuildDIDDocument(h.pds.DID(), h.pds.PublicURL, h.pds.SigningKey(), "atproto", HoldServices(h.pds.PublicURL))
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to generate DID document: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1387,7 +1388,7 @@ func (h *XRPCHandler) HandleGetLatestCommit(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
// HandleDIDDocument returns the DID document
|
||||
func (h *XRPCHandler) HandleDIDDocument(w http.ResponseWriter, r *http.Request) {
|
||||
doc, err := h.pds.GenerateDIDDocument(h.pds.PublicURL)
|
||||
doc, err := did.BuildDIDDocument(h.pds.DID(), h.pds.PublicURL, h.pds.SigningKey(), "atproto", HoldServices(h.pds.PublicURL))
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to generate DID document: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
+2
-9
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/atproto/did"
|
||||
"atcr.io/pkg/hold/admin"
|
||||
holddb "atcr.io/pkg/hold/db"
|
||||
"atcr.io/pkg/hold/gc"
|
||||
@@ -76,15 +77,7 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
||||
if cfg.Database.Path != "" {
|
||||
ctx := context.Background()
|
||||
|
||||
holdDID, err := pds.LoadOrCreateDID(ctx, pds.DIDConfig{
|
||||
DID: cfg.Database.DID,
|
||||
DIDMethod: cfg.Database.DIDMethod,
|
||||
PublicURL: cfg.Server.PublicURL,
|
||||
DBPath: cfg.Database.Path,
|
||||
SigningKeyPath: cfg.Database.KeyPath,
|
||||
RotationKey: cfg.Database.RotationKey,
|
||||
PLCDirectoryURL: cfg.Database.PLCDirectoryURL,
|
||||
})
|
||||
holdDID, err := did.LoadOrCreate(ctx, cfg.DIDConfig())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve hold DID: %w", err)
|
||||
}
|
||||
|
||||
+188
-21
@@ -1,26 +1,53 @@
|
||||
package labeler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Session represents an authenticated admin session.
|
||||
const (
|
||||
sessionCookieName = "labeler_session"
|
||||
sessionTTL = 24 * time.Hour
|
||||
csrfHeaderName = "X-CSRF-Token"
|
||||
csrfFormField = "csrf_token"
|
||||
)
|
||||
|
||||
// Session represents an authenticated admin session. Restart wipes the in-memory
|
||||
// map so any stolen cookie token becomes useless after a restart, by design.
|
||||
//
|
||||
// UserAgent and IPPrefix are captured at login and rechecked on every request —
|
||||
// a stolen token replayed from a different browser or network prefix is rejected
|
||||
// and the session is torn down. Empty bound values (Unix sockets, tests, unusual
|
||||
// proxies) opt out rather than locking users out. Binding at /24 (IPv4) / /64
|
||||
// (IPv6) tolerates DHCP renewals within a prefix without inviting cross-network
|
||||
// replay.
|
||||
type Session struct {
|
||||
DID string
|
||||
Handle string
|
||||
DID string
|
||||
Handle string
|
||||
CSRFToken string
|
||||
CreatedAt time.Time
|
||||
UserAgent string
|
||||
IPPrefix string
|
||||
}
|
||||
|
||||
// Auth manages admin authentication.
|
||||
// Auth manages in-memory admin sessions for the labeler.
|
||||
type Auth struct {
|
||||
ownerDID string
|
||||
sessions map[string]*Session
|
||||
sessionsMu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewAuth creates a new Auth manager.
|
||||
// NewAuth wires a fresh in-memory session store keyed to the configured owner DID.
|
||||
func NewAuth(ownerDID string) *Auth {
|
||||
return &Auth{
|
||||
ownerDID: ownerDID,
|
||||
@@ -28,41 +55,70 @@ func NewAuth(ownerDID string) *Auth {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Auth) createSession(did, handle string) (string, error) {
|
||||
func randToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("generate token: %w", err)
|
||||
}
|
||||
token := base64.URLEncoding.EncodeToString(b)
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// CreateSession installs a new in-memory session and returns its cookie token
|
||||
// alongside the embedded CSRF token (for echoing into forms).
|
||||
func (a *Auth) CreateSession(did, handle, userAgent, ipPrefix string) (string, *Session, error) {
|
||||
token, err := randToken()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
csrfToken, err := randToken()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
s := &Session{
|
||||
DID: did,
|
||||
Handle: handle,
|
||||
CSRFToken: csrfToken,
|
||||
CreatedAt: time.Now(),
|
||||
UserAgent: userAgent,
|
||||
IPPrefix: ipPrefix,
|
||||
}
|
||||
a.sessionsMu.Lock()
|
||||
a.sessions[token] = &Session{DID: did, Handle: handle}
|
||||
a.sessions[token] = s
|
||||
a.sessionsMu.Unlock()
|
||||
|
||||
return token, nil
|
||||
return token, s, nil
|
||||
}
|
||||
|
||||
func (a *Auth) getSession(token string) *Session {
|
||||
// GetSession returns the session for the cookie token, evicting expired entries on access.
|
||||
func (a *Auth) GetSession(token string) *Session {
|
||||
a.sessionsMu.RLock()
|
||||
defer a.sessionsMu.RUnlock()
|
||||
return a.sessions[token]
|
||||
s := a.sessions[token]
|
||||
a.sessionsMu.RUnlock()
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if !s.CreatedAt.IsZero() && time.Since(s.CreatedAt) > sessionTTL {
|
||||
a.sessionsMu.Lock()
|
||||
delete(a.sessions, token)
|
||||
a.sessionsMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (a *Auth) deleteSession(token string) {
|
||||
// DeleteSession removes a session by cookie token (logout).
|
||||
func (a *Auth) DeleteSession(token string) {
|
||||
a.sessionsMu.Lock()
|
||||
delete(a.sessions, token)
|
||||
a.sessionsMu.Unlock()
|
||||
}
|
||||
|
||||
const sessionCookieName = "labeler_session"
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
|
||||
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: 86400, // 24 hours
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
@@ -88,7 +144,18 @@ func getSessionCookie(r *http.Request) (string, bool) {
|
||||
return cookie.Value, true
|
||||
}
|
||||
|
||||
// RequireOwner is middleware that checks the session belongs to the owner DID.
|
||||
type sessionContextKeyT struct{}
|
||||
|
||||
var sessionContextKey = sessionContextKeyT{}
|
||||
|
||||
// SessionFromContext returns the session attached to the request context, if any.
|
||||
func SessionFromContext(ctx context.Context) *Session {
|
||||
s, _ := ctx.Value(sessionContextKey).(*Session)
|
||||
return s
|
||||
}
|
||||
|
||||
// RequireOwner enforces a valid session bound to the owner DID, with UA / IP-prefix
|
||||
// replay defense. State-mutating methods then go through the CSRF check below.
|
||||
func (a *Auth) RequireOwner(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := getSessionCookie(r)
|
||||
@@ -96,11 +163,111 @@ func (a *Auth) RequireOwner(next http.Handler) http.Handler {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
session := a.getSession(token)
|
||||
if session == nil || session.DID != a.ownerDID {
|
||||
session := a.GetSession(token)
|
||||
if session == nil {
|
||||
clearSessionCookie(w)
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if session.DID != a.ownerDID {
|
||||
a.DeleteSession(token)
|
||||
clearSessionCookie(w)
|
||||
http.Redirect(w, r, "/auth/login?error=access+denied", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if session.UserAgent != "" && session.UserAgent != r.UserAgent() {
|
||||
slog.Warn("Admin session UA mismatch — suspected token replay", "did", session.DID)
|
||||
a.DeleteSession(token)
|
||||
clearSessionCookie(w)
|
||||
http.Redirect(w, r, "/auth/login?error=access+denied", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if session.IPPrefix != "" {
|
||||
if now := clientIPPrefix(r); now != "" && now != session.IPPrefix {
|
||||
slog.Warn("Admin session IP-prefix mismatch — suspected token replay",
|
||||
"did", session.DID, "session", session.IPPrefix, "request", now)
|
||||
a.DeleteSession(token)
|
||||
clearSessionCookie(w)
|
||||
http.Redirect(w, r, "/auth/login?error=access+denied", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), sessionContextKey, session)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// RequireCSRF validates a per-session CSRF token on state-mutating requests. Safe
|
||||
// methods pass through. Token comes from X-CSRF-Token header or the csrf_token form
|
||||
// field for application/x-www-form-urlencoded bodies. Must run after RequireOwner.
|
||||
func (a *Auth) RequireCSRF(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
session := SessionFromContext(r.Context())
|
||||
if session == nil || session.CSRFToken == "" {
|
||||
http.Error(w, "Forbidden: missing session", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
got := r.Header.Get(csrfHeaderName)
|
||||
if got == "" {
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if idx := strings.IndexByte(contentType, ';'); idx >= 0 {
|
||||
contentType = contentType[:idx]
|
||||
}
|
||||
contentType = strings.TrimSpace(strings.ToLower(contentType))
|
||||
if contentType == "application/x-www-form-urlencoded" {
|
||||
if err := r.ParseForm(); err == nil {
|
||||
got = r.PostFormValue(csrfFormField)
|
||||
}
|
||||
}
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(session.CSRFToken)) != 1 {
|
||||
slog.Warn("Labeler CSRF mismatch", "path", r.URL.Path, "did", session.DID)
|
||||
http.Error(w, "Forbidden: CSRF token mismatch — reload the page and try again.", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// csrfInputHTML emits a hidden form input carrying the per-session CSRF token.
|
||||
func csrfInputHTML(token string) template.HTML {
|
||||
escaped := template.HTMLEscapeString(token)
|
||||
return template.HTML(`<input type="hidden" name="` + csrfFormField + `" value="` + escaped + `">`)
|
||||
}
|
||||
|
||||
// clientIPPrefix returns a stable prefix key for the request's client IP — /24 for
|
||||
// IPv4, /64 for IPv6. Empty string means "don't bind" (avoids locking users behind
|
||||
// unusual proxies / Unix sockets / tests).
|
||||
func clientIPPrefix(r *http.Request) string {
|
||||
var host string
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
if comma := strings.IndexByte(fwd, ','); comma >= 0 {
|
||||
host = strings.TrimSpace(fwd[:comma])
|
||||
} else {
|
||||
host = strings.TrimSpace(fwd)
|
||||
}
|
||||
} else {
|
||||
h, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
host = h
|
||||
} else {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return fmt.Sprintf("v4:%d.%d.%d", v4[0], v4[1], v4[2])
|
||||
}
|
||||
v6 := ip.To16()
|
||||
return fmt.Sprintf("v6:%02x%02x%02x%02x%02x%02x%02x%02x",
|
||||
v6[0], v6[1], v6[2], v6[3], v6[4], v6[5], v6[6], v6[7])
|
||||
}
|
||||
|
||||
+78
-18
@@ -5,7 +5,9 @@ package labeler
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
@@ -30,11 +32,41 @@ type LabelerConfig struct {
|
||||
// Listen address for the labeler HTTP server.
|
||||
Addr string `yaml:"addr" comment:"Listen address for labeler (e.g., :5002)."`
|
||||
|
||||
// PublicURL is the externally reachable URL of the labeler. When empty the URL is
|
||||
// derived from server.base_url by prefixing "labeler." (so https://atcr.io →
|
||||
// https://labeler.atcr.io). Set explicitly for IP-based dev environments.
|
||||
PublicURL string `yaml:"public_url" comment:"Externally reachable labeler URL. Empty = derive from server.base_url."`
|
||||
|
||||
// DID of the labeler admin. Only this DID can log into the admin panel.
|
||||
OwnerDID string `yaml:"owner_did" comment:"DID of the labeler admin. Only this DID can log into the admin panel."`
|
||||
|
||||
// Path to labeler SQLite database.
|
||||
DBPath string `yaml:"db_path" comment:"Path to labeler SQLite database."`
|
||||
// Directory for labeler state: SQLite database, signing key, did.txt.
|
||||
DataDir string `yaml:"data_dir" comment:"Directory for labeler state (database, signing key, did.txt)."`
|
||||
|
||||
// DID method: "plc" (recommended, portable) or "web" (hostname-bound).
|
||||
DIDMethod string `yaml:"did_method" comment:"DID method: \"plc\" (recommended) or \"web\"."`
|
||||
|
||||
// Explicit DID for did:plc adoption/recovery (optional).
|
||||
DID string `yaml:"did" comment:"Explicit did:plc identifier for adoption/recovery (optional)."`
|
||||
|
||||
// Signing key path (defaults to <DataDir>/signing.key).
|
||||
KeyPath string `yaml:"key_path" comment:"Path to K-256 signing key (defaults to <data_dir>/signing.key)."`
|
||||
|
||||
// Rotation key multibase (K-256 or P-256). Required to update the PLC document.
|
||||
RotationKey string `yaml:"rotation_key" comment:"Multibase-encoded rotation key (K-256 or P-256). Required to update the PLC document."`
|
||||
|
||||
// PLC directory URL (default https://plc.directory).
|
||||
PLCDirectoryURL string `yaml:"plc_directory_url" comment:"PLC directory URL (default https://plc.directory)."`
|
||||
|
||||
// LibsqlSyncURL enables embedded-replica sync to a remote libSQL/Bunny database when set.
|
||||
// Empty = local-only mode (default).
|
||||
LibsqlSyncURL string `yaml:"libsql_sync_url" comment:"Optional libSQL/Bunny remote sync URL. Empty = local-only."`
|
||||
|
||||
// LibsqlAuthToken is the auth token for the remote libSQL database.
|
||||
LibsqlAuthToken string `yaml:"libsql_auth_token" comment:"Auth token for libsql_sync_url."`
|
||||
|
||||
// LibsqlSyncInterval is how often the embedded replica pulls from the remote.
|
||||
LibsqlSyncInterval time.Duration `yaml:"libsql_sync_interval" comment:"Embedded-replica pull interval (e.g. 30s). 0 = manual sync only."`
|
||||
}
|
||||
|
||||
// AppviewServerConfig is a subset of the appview ServerConfig that the labeler needs.
|
||||
@@ -45,9 +77,13 @@ type AppviewServerConfig struct {
|
||||
TestMode bool `yaml:"test_mode"`
|
||||
}
|
||||
|
||||
// PublicURL returns the labeler's public URL derived from the appview base URL.
|
||||
// If appview is https://atcr.io, labeler is https://labeler.atcr.io.
|
||||
// PublicURL returns the labeler's externally reachable URL. When labeler.public_url
|
||||
// is set explicitly it wins; otherwise it's derived from server.base_url by prefixing
|
||||
// "labeler." (so https://atcr.io → https://labeler.atcr.io).
|
||||
func (c *Config) PublicURL() string {
|
||||
if c.Labeler.PublicURL != "" {
|
||||
return c.Labeler.PublicURL
|
||||
}
|
||||
u, err := url.Parse(c.Server.BaseURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -56,17 +92,25 @@ func (c *Config) PublicURL() string {
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// DID returns the labeler's did:web identity derived from its public URL.
|
||||
func (c *Config) DID() string {
|
||||
u, err := url.Parse(c.PublicURL())
|
||||
if err != nil {
|
||||
return ""
|
||||
// DBPath returns the path to the SQLite database file inside the data dir.
|
||||
func (c *Config) DBPath() string {
|
||||
return filepath.Join(c.Labeler.DataDir, "labeler.db")
|
||||
}
|
||||
|
||||
// SigningKeyPath returns the configured signing key path or the default inside DataDir.
|
||||
func (c *Config) SigningKeyPath() string {
|
||||
if c.Labeler.KeyPath != "" {
|
||||
return c.Labeler.KeyPath
|
||||
}
|
||||
host := u.Hostname()
|
||||
if port := u.Port(); port != "" {
|
||||
host += "%3A" + port
|
||||
return filepath.Join(c.Labeler.DataDir, "signing.key")
|
||||
}
|
||||
|
||||
// PLCDirectoryURL returns the configured PLC directory URL or the canonical default.
|
||||
func (c *Config) PLCDirectoryURL() string {
|
||||
if c.Labeler.PLCDirectoryURL != "" {
|
||||
return c.Labeler.PLCDirectoryURL
|
||||
}
|
||||
return "did:web:" + host
|
||||
return "https://plc.directory"
|
||||
}
|
||||
|
||||
func setDefaults(v *viper.Viper) {
|
||||
@@ -76,8 +120,17 @@ func setDefaults(v *viper.Viper) {
|
||||
// Labeler defaults
|
||||
v.SetDefault("labeler.enabled", false)
|
||||
v.SetDefault("labeler.addr", ":5002")
|
||||
v.SetDefault("labeler.public_url", "")
|
||||
v.SetDefault("labeler.owner_did", "")
|
||||
v.SetDefault("labeler.db_path", "/var/lib/atcr-labeler/labeler.db")
|
||||
v.SetDefault("labeler.data_dir", "/var/lib/atcr-labeler")
|
||||
v.SetDefault("labeler.did_method", "plc")
|
||||
v.SetDefault("labeler.did", "")
|
||||
v.SetDefault("labeler.key_path", "")
|
||||
v.SetDefault("labeler.rotation_key", "")
|
||||
v.SetDefault("labeler.plc_directory_url", "https://plc.directory")
|
||||
v.SetDefault("labeler.libsql_sync_url", "")
|
||||
v.SetDefault("labeler.libsql_auth_token", "")
|
||||
v.SetDefault("labeler.libsql_sync_interval", 0)
|
||||
|
||||
// Server defaults (read from shared appview config)
|
||||
v.SetDefault("server.base_url", "")
|
||||
@@ -121,6 +174,11 @@ func LoadConfig(yamlPath string) (*Config, error) {
|
||||
if !strings.HasPrefix(cfg.Labeler.OwnerDID, "did:") {
|
||||
return nil, fmt.Errorf("labeler.owner_did must be a DID (got %q)", cfg.Labeler.OwnerDID)
|
||||
}
|
||||
switch cfg.Labeler.DIDMethod {
|
||||
case "plc", "web":
|
||||
default:
|
||||
return nil, fmt.Errorf("labeler.did_method must be \"plc\" or \"web\" (got %q)", cfg.Labeler.DIDMethod)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -136,10 +194,12 @@ func ExampleYAML() ([]byte, error) {
|
||||
ClientShortName: "ATCR",
|
||||
},
|
||||
Labeler: LabelerConfig{
|
||||
Enabled: true,
|
||||
Addr: ":5002",
|
||||
OwnerDID: "did:plc:your-did-here",
|
||||
DBPath: "/var/lib/atcr-labeler/labeler.db",
|
||||
Enabled: true,
|
||||
Addr: ":5002",
|
||||
OwnerDID: "did:plc:your-did-here",
|
||||
DataDir: "/var/lib/atcr-labeler",
|
||||
DIDMethod: "plc",
|
||||
PLCDirectoryURL: "https://plc.directory",
|
||||
},
|
||||
}
|
||||
return config.MarshalCommentedYAML("ATCR Labeler Configuration", cfg)
|
||||
|
||||
@@ -25,27 +25,3 @@ func TestConfig_PublicURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_DID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
want string
|
||||
}{
|
||||
{"standard", "https://atcr.io", "did:web:labeler.atcr.io"},
|
||||
{"with port", "https://atcr.io:8080", "did:web:labeler.atcr.io%3A8080"},
|
||||
{"localhost", "http://localhost:5000", "did:web:labeler.localhost%3A5000"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: AppviewServerConfig{BaseURL: tt.baseURL},
|
||||
}
|
||||
got := cfg.DID()
|
||||
if got != tt.want {
|
||||
t.Errorf("DID() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+240
-86
@@ -3,13 +3,21 @@ package labeler
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/tursodatabase/go-libsql"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
"github.com/bluesky-social/indigo/atproto/labeling"
|
||||
"github.com/tursodatabase/go-libsql"
|
||||
)
|
||||
|
||||
// LabelVersion is the ATProto label format version (currently 1).
|
||||
const LabelVersion int64 = labeling.ATPROTO_LABEL_VERSION
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS labels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -20,15 +28,19 @@ CREATE TABLE IF NOT EXISTS labels (
|
||||
neg BOOLEAN NOT NULL DEFAULT 0,
|
||||
cts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
exp TIMESTAMP,
|
||||
ver INTEGER NOT NULL DEFAULT 1,
|
||||
sig BLOB NOT NULL,
|
||||
subject_did TEXT NOT NULL,
|
||||
subject_repo TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE(src, uri, val, neg)
|
||||
subject_repo TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_labels_subject ON labels(subject_did, subject_repo);
|
||||
CREATE INDEX IF NOT EXISTS idx_labels_cts ON labels(cts DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_labels_uri ON labels(uri);
|
||||
`
|
||||
|
||||
// Label represents an ATProto label (com.atproto.label.defs#label).
|
||||
// Label represents an ATProto label record stored locally. Its on-the-wire representation
|
||||
// is produced by ToLabeling() which round-trips through indigo's labeling package so the
|
||||
// signature stays valid byte-for-byte.
|
||||
type Label struct {
|
||||
ID int64
|
||||
Src string
|
||||
@@ -38,100 +50,218 @@ type Label struct {
|
||||
Neg bool
|
||||
Cts time.Time
|
||||
Exp *time.Time
|
||||
Ver int64
|
||||
Sig []byte
|
||||
SubjectDID string
|
||||
SubjectRepo string
|
||||
}
|
||||
|
||||
// OpenDB opens or creates the labeler database.
|
||||
func OpenDB(dbPath string) (*sql.DB, error) {
|
||||
// LibsqlSync configures optional embedded-replica sync to a remote libSQL database.
|
||||
// SyncURL empty means local-only mode.
|
||||
type LibsqlSync struct {
|
||||
SyncURL string
|
||||
AuthToken string
|
||||
SyncInterval time.Duration
|
||||
}
|
||||
|
||||
// LabelerDB wraps the *sql.DB plus its libsql connector (when in embedded-replica mode)
|
||||
// so the caller can release file locks on shutdown.
|
||||
type LabelerDB struct {
|
||||
DB *sql.DB
|
||||
connector io.Closer
|
||||
}
|
||||
|
||||
// Close closes the database and the libsql connector (if any). The connector close is
|
||||
// what releases file locks; without it a subsequent local-only open errors with
|
||||
// "database is locked" — the same gotcha the hold ran into.
|
||||
func (l *LabelerDB) Close() error {
|
||||
var dbErr, connErr error
|
||||
if l.DB != nil {
|
||||
dbErr = l.DB.Close()
|
||||
}
|
||||
if l.connector != nil {
|
||||
connErr = l.connector.Close()
|
||||
}
|
||||
if dbErr != nil {
|
||||
return dbErr
|
||||
}
|
||||
return connErr
|
||||
}
|
||||
|
||||
// OpenDB opens or creates the labeler database. When sync.SyncURL is set, the DB runs
|
||||
// in embedded-replica mode (writes go to the remote, frames replicate to the local
|
||||
// file); otherwise it's a plain local libSQL file. Schema is applied either way.
|
||||
func OpenDB(dbPath string, sync LibsqlSync) (*LabelerDB, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create db directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("libsql", "file:"+dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
var (
|
||||
db *sql.DB
|
||||
connector io.Closer
|
||||
)
|
||||
|
||||
if sync.SyncURL != "" {
|
||||
opts := []libsql.Option{libsql.WithAuthToken(sync.AuthToken)}
|
||||
if sync.SyncInterval > 0 {
|
||||
opts = append(opts, libsql.WithSyncInterval(sync.SyncInterval))
|
||||
}
|
||||
conn, err := libsql.NewEmbeddedReplicaConnector(dbPath, sync.SyncURL, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create libsql embedded replica connector: %w", err)
|
||||
}
|
||||
db = sql.OpenDB(conn)
|
||||
connector = conn
|
||||
slog.Info("Labeler database opened in embedded replica mode", "path", dbPath, "sync_url", sync.SyncURL)
|
||||
} else {
|
||||
dsn := dbPath
|
||||
if !strings.HasPrefix(dsn, "file:") && !strings.HasPrefix(dsn, ":memory:") {
|
||||
dsn = "file:" + dsn
|
||||
}
|
||||
var err error
|
||||
db, err = sql.Open("libsql", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
slog.Info("Labeler database opened in local-only mode", "path", dbPath)
|
||||
}
|
||||
|
||||
// Local PRAGMAs only — Bunny rejects PRAGMA assignments forwarded over the
|
||||
// replication protocol (same caveat as pkg/hold/db).
|
||||
if sync.SyncURL == "" {
|
||||
var journalMode string
|
||||
if err := db.QueryRow("PRAGMA journal_mode = WAL").Scan(&journalMode); err != nil {
|
||||
_ = closeIfNonNil(db, connector)
|
||||
return nil, fmt.Errorf("failed to set journal mode: %w", err)
|
||||
}
|
||||
var busyTimeout int
|
||||
if err := db.QueryRow("PRAGMA busy_timeout = 5000").Scan(&busyTimeout); err != nil {
|
||||
_ = closeIfNonNil(db, connector)
|
||||
return nil, fmt.Errorf("failed to set busy_timeout: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply schema
|
||||
for _, stmt := range splitStatements(schema) {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
_ = closeIfNonNil(db, connector)
|
||||
return nil, fmt.Errorf("failed to apply schema: %w", err)
|
||||
}
|
||||
}
|
||||
return &LabelerDB{DB: db, connector: connector}, nil
|
||||
}
|
||||
|
||||
return db, nil
|
||||
// closeIfNonNil is the defensive cleanup for the failure path on OpenDB so we don't
|
||||
// leave file locks dangling if schema application fails.
|
||||
func closeIfNonNil(db *sql.DB, connector io.Closer) error {
|
||||
if db != nil {
|
||||
_ = db.Close()
|
||||
}
|
||||
if connector != nil {
|
||||
return connector.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitStatements splits SQL by semicolons (go-libsql doesn't support multi-statement exec).
|
||||
func splitStatements(sql string) []string {
|
||||
var stmts []string
|
||||
for _, s := range splitOnSemicolon(sql) {
|
||||
s = trimSpace(s)
|
||||
parts := strings.Split(sql, ";")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, s := range parts {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
stmts = append(stmts, s)
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return stmts
|
||||
return out
|
||||
}
|
||||
|
||||
func splitOnSemicolon(s string) []string {
|
||||
var parts []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == ';' {
|
||||
parts = append(parts, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
// ToLabeling converts the row into indigo's label struct (deterministic CBOR shape).
|
||||
func (l *Label) ToLabeling() labeling.Label {
|
||||
out := labeling.Label{
|
||||
CreatedAt: l.Cts.UTC().Format(time.RFC3339),
|
||||
SourceDID: l.Src,
|
||||
URI: l.URI,
|
||||
Val: l.Val,
|
||||
Version: l.Ver,
|
||||
}
|
||||
if start < len(s) {
|
||||
parts = append(parts, s[start:])
|
||||
if l.CID != "" {
|
||||
s := l.CID
|
||||
out.CID = &s
|
||||
}
|
||||
return parts
|
||||
if l.Exp != nil {
|
||||
s := l.Exp.UTC().Format(time.RFC3339)
|
||||
out.ExpiresAt = &s
|
||||
}
|
||||
if l.Neg {
|
||||
t := true
|
||||
out.Negated = &t
|
||||
}
|
||||
if len(l.Sig) > 0 {
|
||||
out.Sig = l.Sig
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trimSpace(s string) string {
|
||||
// Simple trim that handles newlines and spaces
|
||||
i := 0
|
||||
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') {
|
||||
i++
|
||||
// Sign computes a k256 signature over the deterministic CBOR encoding of the label
|
||||
// (without the sig field) and stores it on the row.
|
||||
func (l *Label) Sign(key *atcrypto.PrivateKeyK256) error {
|
||||
if l.Ver == 0 {
|
||||
l.Ver = LabelVersion
|
||||
}
|
||||
j := len(s)
|
||||
for j > i && (s[j-1] == ' ' || s[j-1] == '\t' || s[j-1] == '\n' || s[j-1] == '\r') {
|
||||
j--
|
||||
if l.Cts.IsZero() {
|
||||
l.Cts = time.Now().UTC()
|
||||
}
|
||||
return s[i:j]
|
||||
pre := l.ToLabeling()
|
||||
pre.Sig = nil
|
||||
if err := pre.Sign(key); err != nil {
|
||||
return fmt.Errorf("failed to sign label: %w", err)
|
||||
}
|
||||
l.Sig = pre.Sig
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateLabel inserts a new label into the database.
|
||||
// CreateLabel inserts a freshly signed label and returns its sequence id.
|
||||
// Caller must Sign() first — CreateLabel rejects rows missing a signature.
|
||||
func CreateLabel(db *sql.DB, l *Label) (int64, error) {
|
||||
if len(l.Sig) == 0 {
|
||||
return 0, fmt.Errorf("refusing to insert unsigned label")
|
||||
}
|
||||
if l.Ver == 0 {
|
||||
l.Ver = LabelVersion
|
||||
}
|
||||
var expStr *string
|
||||
if l.Exp != nil {
|
||||
s := l.Exp.UTC().Format(time.RFC3339)
|
||||
expStr = &s
|
||||
}
|
||||
result, err := db.Exec(
|
||||
`INSERT INTO labels (src, uri, cid, val, neg, cts, exp, subject_did, subject_repo)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(src, uri, val, neg) DO UPDATE SET cts = excluded.cts`,
|
||||
l.Src, l.URI, l.CID, l.Val, l.Neg, l.Cts.UTC().Format(time.RFC3339), l.Exp,
|
||||
`INSERT INTO labels (src, uri, cid, val, neg, cts, exp, ver, sig, subject_did, subject_repo)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
l.Src, l.URI, nullableString(l.CID), l.Val, l.Neg,
|
||||
l.Cts.UTC().Format(time.RFC3339), expStr, l.Ver, l.Sig,
|
||||
l.SubjectDID, l.SubjectRepo,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create label: %w", err)
|
||||
return 0, fmt.Errorf("failed to insert label: %w", err)
|
||||
}
|
||||
return result.LastInsertId()
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
l.ID = id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// NegateLabel creates a negation label to reverse a previous label.
|
||||
func NegateLabel(db *sql.DB, src, uri, val string, subjectDID, subjectRepo string) error {
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO labels (src, uri, val, neg, cts, subject_did, subject_repo)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?)`,
|
||||
src, uri, val, time.Now().UTC().Format(time.RFC3339), subjectDID, subjectRepo,
|
||||
)
|
||||
return err
|
||||
func nullableString(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GetLabelsSince returns labels with id > cursor, ordered by id ascending.
|
||||
func GetLabelsSince(db *sql.DB, cursor int64, limit int) ([]Label, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, subject_did, subject_repo
|
||||
`SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo
|
||||
FROM labels WHERE id > ? ORDER BY id ASC LIMIT ?`,
|
||||
cursor, limit,
|
||||
)
|
||||
@@ -139,10 +269,21 @@ func GetLabelsSince(db *sql.DB, cursor int64, limit int) ([]Label, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanLabels(rows)
|
||||
}
|
||||
|
||||
// LatestSeq returns the highest sequence id in the database, or 0 if empty.
|
||||
func LatestSeq(db *sql.DB) (int64, error) {
|
||||
var seq sql.NullInt64
|
||||
if err := db.QueryRow(`SELECT MAX(id) FROM labels`).Scan(&seq); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !seq.Valid {
|
||||
return 0, nil
|
||||
}
|
||||
return seq.Int64, nil
|
||||
}
|
||||
|
||||
// ListActiveTakedowns returns active (non-negated) takedown labels.
|
||||
func ListActiveTakedowns(db *sql.DB, limit, offset int) ([]Label, int, error) {
|
||||
var total int
|
||||
@@ -161,7 +302,7 @@ func ListActiveTakedowns(db *sql.DB, limit, offset int) ([]Label, int, error) {
|
||||
}
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT l1.id, l1.src, l1.uri, COALESCE(l1.cid, ''), l1.val, l1.neg, l1.cts, l1.exp, l1.subject_did, l1.subject_repo
|
||||
`SELECT l1.id, l1.src, l1.uri, COALESCE(l1.cid, ''), l1.val, l1.neg, l1.cts, l1.exp, l1.ver, l1.sig, l1.subject_did, l1.subject_repo
|
||||
FROM labels l1
|
||||
WHERE l1.val = '!takedown' AND l1.neg = 0
|
||||
AND NOT EXISTS (
|
||||
@@ -182,10 +323,10 @@ func ListActiveTakedowns(db *sql.DB, limit, offset int) ([]Label, int, error) {
|
||||
return labels, total, err
|
||||
}
|
||||
|
||||
// GetLabelsForRepo returns all active labels for a specific DID + repository.
|
||||
// GetLabelsForRepo returns all labels for a specific DID + repository.
|
||||
func GetLabelsForRepo(db *sql.DB, did, repo string) ([]Label, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, subject_did, subject_repo
|
||||
`SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo
|
||||
FROM labels
|
||||
WHERE subject_did = ? AND subject_repo = ?
|
||||
ORDER BY cts DESC`,
|
||||
@@ -198,55 +339,67 @@ func GetLabelsForRepo(db *sql.DB, did, repo string) ([]Label, error) {
|
||||
return scanLabels(rows)
|
||||
}
|
||||
|
||||
// NegateRepoLabels creates negation labels for all active takedown labels on a (DID, repo) pair.
|
||||
func NegateRepoLabels(db *sql.DB, src, did, repo string) error {
|
||||
// newNegationLabel constructs an unsigned negation label awaiting Sign().
|
||||
func newNegationLabel(src, uri, val, did, repo string) *Label {
|
||||
return &Label{
|
||||
Src: src,
|
||||
URI: uri,
|
||||
Val: val,
|
||||
Neg: true,
|
||||
Cts: time.Now().UTC(),
|
||||
SubjectDID: did,
|
||||
SubjectRepo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
// NegateRepoLabels signs+inserts negation labels for all active takedown labels on (DID, repo).
|
||||
func NegateRepoLabels(db *sql.DB, key *atcrypto.PrivateKeyK256, src, did, repo string) ([]Label, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT uri FROM labels
|
||||
WHERE subject_did = ? AND subject_repo = ? AND val = '!takedown' AND neg = 0`,
|
||||
did, repo,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var uris []string
|
||||
for rows.Next() {
|
||||
var uri string
|
||||
if err := rows.Scan(&uri); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
uris = append(uris, uri)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
out := make([]Label, 0, len(uris))
|
||||
for _, uri := range uris {
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO labels (src, uri, val, neg, cts, subject_did, subject_repo)
|
||||
VALUES (?, ?, '!takedown', 1, ?, ?, ?)`,
|
||||
src, uri, now, did, repo,
|
||||
); err != nil {
|
||||
return err
|
||||
neg := newNegationLabel(src, uri, "!takedown", did, repo)
|
||||
if err := neg.Sign(key); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if _, err := CreateLabel(db, neg); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out = append(out, *neg)
|
||||
}
|
||||
return nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NegateUserLabels creates negation labels for all active takedown labels on a DID (user-level).
|
||||
func NegateUserLabels(db *sql.DB, src, did string) error {
|
||||
// NegateUserLabels signs+inserts negation labels for all active takedown labels on a DID.
|
||||
func NegateUserLabels(db *sql.DB, key *atcrypto.PrivateKeyK256, src, did string) ([]Label, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT uri, subject_repo FROM labels
|
||||
WHERE subject_did = ? AND val = '!takedown' AND neg = 0`,
|
||||
did,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type uriRepo struct {
|
||||
uri string
|
||||
repo string
|
||||
@@ -256,26 +409,27 @@ func NegateUserLabels(db *sql.DB, src, did string) error {
|
||||
var e uriRepo
|
||||
if err := rows.Scan(&e.uri, &e.repo); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
out := make([]Label, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO labels (src, uri, val, neg, cts, subject_did, subject_repo)
|
||||
VALUES (?, ?, '!takedown', 1, ?, ?, ?)`,
|
||||
src, e.uri, now, did, e.repo,
|
||||
); err != nil {
|
||||
return err
|
||||
neg := newNegationLabel(src, e.uri, "!takedown", did, e.repo)
|
||||
if err := neg.Sign(key); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if _, err := CreateLabel(db, neg); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out = append(out, *neg)
|
||||
}
|
||||
return nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scanLabels(rows *sql.Rows) ([]Label, error) {
|
||||
@@ -284,7 +438,7 @@ func scanLabels(rows *sql.Rows) ([]Label, error) {
|
||||
var l Label
|
||||
var cts string
|
||||
var exp *string
|
||||
if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.CID, &l.Val, &l.Neg, &cts, &exp, &l.SubjectDID, &l.SubjectRepo); err != nil {
|
||||
if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.CID, &l.Val, &l.Neg, &cts, &exp, &l.Ver, &l.Sig, &l.SubjectDID, &l.SubjectRepo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, cts); err == nil {
|
||||
|
||||
+141
-180
@@ -1,31 +1,66 @@
|
||||
package labeler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
)
|
||||
|
||||
func newTestKey(t *testing.T) *atcrypto.PrivateKeyK256 {
|
||||
t.Helper()
|
||||
k, err := atcrypto.GeneratePrivateKeyK256()
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// openTestDB opens a fresh local-only labeler DB and registers cleanup. Returns the
|
||||
// raw *sql.DB so existing tests can keep using it; the wrapper lifecycle is handled
|
||||
// here so tests don't have to know about the embedded-replica machinery.
|
||||
func openTestDB(t *testing.T, path string) *sql.DB {
|
||||
t.Helper()
|
||||
storage, err := OpenDB(path, LibsqlSync{})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = storage.Close() })
|
||||
return storage.DB
|
||||
}
|
||||
|
||||
// signAndCreate is a helper that signs the label and inserts it; it returns the row id.
|
||||
func signAndCreate(t *testing.T, db *sql.DB, key *atcrypto.PrivateKeyK256, l *Label) int64 {
|
||||
t.Helper()
|
||||
if err := l.Sign(key); err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
id, err := CreateLabel(db, l)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestOpenDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "subdir", "test.db")
|
||||
|
||||
db, err := OpenDB(dbPath)
|
||||
storage, err := OpenDB(dbPath, LibsqlSync{})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenDB failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
defer storage.Close()
|
||||
|
||||
// Verify directory was created
|
||||
if _, err := os.Stat(filepath.Dir(dbPath)); os.IsNotExist(err) {
|
||||
t.Error("expected directory to be created")
|
||||
}
|
||||
|
||||
// Verify tables exist
|
||||
var count int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM labels").Scan(&count)
|
||||
if err != nil {
|
||||
if err := storage.DB.QueryRow("SELECT COUNT(*) FROM labels").Scan(&count); err != nil {
|
||||
t.Fatalf("failed to query labels table: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
@@ -35,31 +70,26 @@ func TestOpenDB(t *testing.T) {
|
||||
|
||||
func TestCreateLabel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := OpenDB(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
db := openTestDB(t, filepath.Join(dir, "test.db"))
|
||||
key := newTestKey(t)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
label := &Label{
|
||||
Src: "did:web:labeler.atcr.io",
|
||||
Src: "did:plc:labeler-1",
|
||||
URI: "at://did:plc:abc/io.atcr.manifest/sha256-123",
|
||||
Val: "!takedown",
|
||||
Cts: now,
|
||||
SubjectDID: "did:plc:abc",
|
||||
SubjectRepo: "myimage",
|
||||
}
|
||||
|
||||
id, err := CreateLabel(db, label)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLabel failed: %v", err)
|
||||
}
|
||||
id := signAndCreate(t, db, key, label)
|
||||
if id <= 0 {
|
||||
t.Errorf("expected positive id, got %d", id)
|
||||
}
|
||||
if len(label.Sig) == 0 {
|
||||
t.Error("expected signature populated by Sign()")
|
||||
}
|
||||
|
||||
// Verify it was stored
|
||||
labels, err := GetLabelsSince(db, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -67,146 +97,88 @@ func TestCreateLabel(t *testing.T) {
|
||||
if len(labels) != 1 {
|
||||
t.Fatalf("expected 1 label, got %d", len(labels))
|
||||
}
|
||||
if labels[0].Src != "did:web:labeler.atcr.io" {
|
||||
t.Errorf("expected src did:web:labeler.atcr.io, got %s", labels[0].Src)
|
||||
}
|
||||
if labels[0].Val != "!takedown" {
|
||||
t.Errorf("expected val !takedown, got %s", labels[0].Val)
|
||||
if labels[0].Src != label.Src {
|
||||
t.Errorf("Src = %s, want %s", labels[0].Src, label.Src)
|
||||
}
|
||||
if labels[0].SubjectDID != "did:plc:abc" {
|
||||
t.Errorf("expected subject_did did:plc:abc, got %s", labels[0].SubjectDID)
|
||||
t.Errorf("SubjectDID = %s", labels[0].SubjectDID)
|
||||
}
|
||||
if labels[0].SubjectRepo != "myimage" {
|
||||
t.Errorf("expected subject_repo myimage, got %s", labels[0].SubjectRepo)
|
||||
t.Errorf("SubjectRepo = %s", labels[0].SubjectRepo)
|
||||
}
|
||||
if labels[0].Ver != LabelVersion {
|
||||
t.Errorf("Ver = %d, want %d", labels[0].Ver, LabelVersion)
|
||||
}
|
||||
if len(labels[0].Sig) == 0 {
|
||||
t.Error("expected stored sig to be populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateLabel_Upsert(t *testing.T) {
|
||||
func TestCreateLabel_RejectsUnsigned(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := OpenDB(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
db := openTestDB(t, filepath.Join(dir, "test.db"))
|
||||
|
||||
now := time.Now().UTC()
|
||||
label := &Label{
|
||||
Src: "did:web:labeler.atcr.io",
|
||||
URI: "at://did:plc:abc/io.atcr.manifest/sha256-123",
|
||||
Val: "!takedown",
|
||||
Cts: now,
|
||||
SubjectDID: "did:plc:abc",
|
||||
SubjectRepo: "myimage",
|
||||
Src: "did:plc:labeler-1", URI: "at://did:plc:abc",
|
||||
Val: "!takedown", Cts: time.Now().UTC(),
|
||||
SubjectDID: "did:plc:abc",
|
||||
}
|
||||
|
||||
// First insert
|
||||
_, err = CreateLabel(db, label)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Same (src, uri, val) - should upsert, not error
|
||||
label.Cts = now.Add(time.Hour)
|
||||
_, err = CreateLabel(db, label)
|
||||
if err != nil {
|
||||
t.Fatalf("upsert should not fail: %v", err)
|
||||
}
|
||||
|
||||
// Should still be 1 label
|
||||
labels, err := GetLabelsSince(db, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != 1 {
|
||||
t.Errorf("expected 1 label after upsert, got %d", len(labels))
|
||||
if _, err := CreateLabel(db, label); err == nil {
|
||||
t.Fatal("expected CreateLabel to reject an unsigned label")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNegateLabel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := OpenDB(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
func TestSignAndVerify(t *testing.T) {
|
||||
key := newTestKey(t)
|
||||
label := &Label{
|
||||
Src: "did:plc:labeler-1",
|
||||
URI: "at://did:plc:abc",
|
||||
Val: "!takedown",
|
||||
Cts: time.Now().UTC(),
|
||||
Ver: LabelVersion,
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
src := "did:web:labeler.atcr.io"
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Create a label
|
||||
_, err = CreateLabel(db, &Label{
|
||||
Src: src, URI: "at://did:plc:abc/io.atcr.manifest/sha256-123",
|
||||
Val: "!takedown", Cts: now,
|
||||
SubjectDID: "did:plc:abc", SubjectRepo: "myimage",
|
||||
})
|
||||
if err != nil {
|
||||
if err := label.Sign(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Negate it
|
||||
err = NegateLabel(db, src, "at://did:plc:abc/io.atcr.manifest/sha256-123", "!takedown", "did:plc:abc", "myimage")
|
||||
if err != nil {
|
||||
t.Fatalf("NegateLabel failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have 2 labels now (original + negation)
|
||||
labels, err := GetLabelsSince(db, 0, 10)
|
||||
pub, err := key.PublicKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != 2 {
|
||||
t.Fatalf("expected 2 labels, got %d", len(labels))
|
||||
}
|
||||
|
||||
// The negation label should have neg=true
|
||||
negLabel := labels[1]
|
||||
if !negLabel.Neg {
|
||||
t.Error("expected negation label to have neg=true")
|
||||
wire := label.ToLabeling()
|
||||
if err := wire.VerifySignature(pub); err != nil {
|
||||
t.Fatalf("signature did not verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListActiveTakedowns(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := OpenDB(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
db := openTestDB(t, filepath.Join(dir, "test.db"))
|
||||
key := newTestKey(t)
|
||||
|
||||
src := "did:web:labeler.atcr.io"
|
||||
src := "did:plc:labeler-1"
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Create 3 labels
|
||||
for i, repo := range []string{"repo1", "repo2", "repo3"} {
|
||||
_, err = CreateLabel(db, &Label{
|
||||
signAndCreate(t, db, key, &Label{
|
||||
Src: src, URI: "at://did:plc:abc/io.atcr.repo/" + repo,
|
||||
Val: "!takedown", Cts: now.Add(time.Duration(i) * time.Minute),
|
||||
SubjectDID: "did:plc:abc", SubjectRepo: repo,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// All 3 should be active
|
||||
labels, total, err := ListActiveTakedowns(db, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 3 {
|
||||
t.Errorf("expected 3 active takedowns, got %d", total)
|
||||
}
|
||||
if len(labels) != 3 {
|
||||
t.Errorf("expected 3 labels returned, got %d", len(labels))
|
||||
if total != 3 || len(labels) != 3 {
|
||||
t.Errorf("expected 3 active takedowns, got total=%d returned=%d", total, len(labels))
|
||||
}
|
||||
|
||||
// Negate one
|
||||
err = NegateLabel(db, src, "at://did:plc:abc/io.atcr.repo/repo2", "!takedown", "did:plc:abc", "repo2")
|
||||
if err != nil {
|
||||
if _, err := NegateRepoLabels(db, key, src, "did:plc:abc", "repo2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Should be 2 active
|
||||
_, total, err = ListActiveTakedowns(db, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -218,39 +190,33 @@ func TestListActiveTakedowns(t *testing.T) {
|
||||
|
||||
func TestNegateRepoLabels(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := OpenDB(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
db := openTestDB(t, filepath.Join(dir, "test.db"))
|
||||
key := newTestKey(t)
|
||||
|
||||
src := "did:web:labeler.atcr.io"
|
||||
src := "did:plc:labeler-1"
|
||||
now := time.Now().UTC()
|
||||
did := "did:plc:abc"
|
||||
|
||||
// Create multiple labels for same repo
|
||||
uris := []string{
|
||||
"at://did:plc:abc/io.atcr.manifest/sha256-111",
|
||||
"at://did:plc:abc/io.atcr.manifest/sha256-222",
|
||||
"at://did:plc:abc/io.atcr.tag/myimage-latest",
|
||||
}
|
||||
for _, uri := range uris {
|
||||
_, err = CreateLabel(db, &Label{
|
||||
signAndCreate(t, db, key, &Label{
|
||||
Src: src, URI: uri, Val: "!takedown", Cts: now,
|
||||
SubjectDID: did, SubjectRepo: "myimage",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Negate all labels for the repo
|
||||
err = NegateRepoLabels(db, src, did, "myimage")
|
||||
negs, err := NegateRepoLabels(db, key, src, did, "myimage")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(negs) != len(uris) {
|
||||
t.Errorf("expected %d negation labels, got %d", len(uris), len(negs))
|
||||
}
|
||||
|
||||
// Should have 0 active takedowns
|
||||
_, total, err := ListActiveTakedowns(db, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -262,39 +228,30 @@ func TestNegateRepoLabels(t *testing.T) {
|
||||
|
||||
func TestNegateUserLabels(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := OpenDB(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
db := openTestDB(t, filepath.Join(dir, "test.db"))
|
||||
key := newTestKey(t)
|
||||
|
||||
src := "did:web:labeler.atcr.io"
|
||||
src := "did:plc:labeler-1"
|
||||
now := time.Now().UTC()
|
||||
did := "did:plc:abc"
|
||||
|
||||
// Create labels for different repos + a user-level label
|
||||
_, err = CreateLabel(db, &Label{
|
||||
signAndCreate(t, db, key, &Label{
|
||||
Src: src, URI: "at://did:plc:abc", Val: "!takedown", Cts: now,
|
||||
SubjectDID: did, SubjectRepo: "",
|
||||
SubjectDID: did,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = CreateLabel(db, &Label{
|
||||
signAndCreate(t, db, key, &Label{
|
||||
Src: src, URI: "at://did:plc:abc/io.atcr.repo/repo1", Val: "!takedown", Cts: now,
|
||||
SubjectDID: did, SubjectRepo: "repo1",
|
||||
})
|
||||
|
||||
negs, err := NegateUserLabels(db, key, src, did)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Negate all labels for the user
|
||||
err = NegateUserLabels(db, src, did)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
if len(negs) != 2 {
|
||||
t.Errorf("expected 2 negation labels, got %d", len(negs))
|
||||
}
|
||||
|
||||
// Should have 0 active
|
||||
_, total, err := ListActiveTakedowns(db, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -306,28 +263,20 @@ func TestNegateUserLabels(t *testing.T) {
|
||||
|
||||
func TestGetLabelsSince(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := OpenDB(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
db := openTestDB(t, filepath.Join(dir, "test.db"))
|
||||
key := newTestKey(t)
|
||||
|
||||
src := "did:web:labeler.atcr.io"
|
||||
src := "did:plc:labeler-1"
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Create 5 labels
|
||||
for i := 0; i < 5; i++ {
|
||||
_, err = CreateLabel(db, &Label{
|
||||
signAndCreate(t, db, key, &Label{
|
||||
Src: src, URI: "at://did:plc:abc/io.atcr.manifest/" + string(rune('a'+i)),
|
||||
Val: "!takedown", Cts: now.Add(time.Duration(i) * time.Minute),
|
||||
SubjectDID: "did:plc:abc", SubjectRepo: "repo",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get all since 0
|
||||
labels, err := GetLabelsSince(db, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -336,19 +285,15 @@ func TestGetLabelsSince(t *testing.T) {
|
||||
t.Errorf("expected 5 labels, got %d", len(labels))
|
||||
}
|
||||
|
||||
// Get since cursor (skip first 3)
|
||||
if len(labels) >= 3 {
|
||||
cursor := labels[2].ID
|
||||
after, err := GetLabelsSince(db, cursor, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(after) != 2 {
|
||||
t.Errorf("expected 2 labels after cursor %d, got %d", cursor, len(after))
|
||||
}
|
||||
cursor := labels[2].ID
|
||||
after, err := GetLabelsSince(db, cursor, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(after) != 2 {
|
||||
t.Errorf("expected 2 labels after cursor %d, got %d", cursor, len(after))
|
||||
}
|
||||
|
||||
// Get with limit
|
||||
limited, err := GetLabelsSince(db, 0, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -358,32 +303,50 @@ func TestGetLabelsSince(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLabelsForRepo(t *testing.T) {
|
||||
func TestLatestSeq(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := OpenDB(filepath.Join(dir, "test.db"))
|
||||
db := openTestDB(t, filepath.Join(dir, "test.db"))
|
||||
key := newTestKey(t)
|
||||
|
||||
if seq, err := LatestSeq(db); err != nil || seq != 0 {
|
||||
t.Fatalf("expected empty seq=0, got %d (err=%v)", seq, err)
|
||||
}
|
||||
|
||||
id := signAndCreate(t, db, key, &Label{
|
||||
Src: "did:plc:labeler-1", URI: "at://did:plc:abc",
|
||||
Val: "!takedown", Cts: time.Now().UTC(),
|
||||
SubjectDID: "did:plc:abc",
|
||||
})
|
||||
seq, err := LatestSeq(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if seq != id {
|
||||
t.Errorf("LatestSeq = %d, want %d", seq, id)
|
||||
}
|
||||
}
|
||||
|
||||
src := "did:web:labeler.atcr.io"
|
||||
func TestGetLabelsForRepo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db := openTestDB(t, filepath.Join(dir, "test.db"))
|
||||
key := newTestKey(t)
|
||||
|
||||
src := "did:plc:labeler-1"
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Labels for different repos
|
||||
_, _ = CreateLabel(db, &Label{
|
||||
signAndCreate(t, db, key, &Label{
|
||||
Src: src, URI: "at://did:plc:abc/io.atcr.repo/repo1",
|
||||
Val: "!takedown", Cts: now, SubjectDID: "did:plc:abc", SubjectRepo: "repo1",
|
||||
})
|
||||
_, _ = CreateLabel(db, &Label{
|
||||
signAndCreate(t, db, key, &Label{
|
||||
Src: src, URI: "at://did:plc:abc/io.atcr.repo/repo2",
|
||||
Val: "!takedown", Cts: now, SubjectDID: "did:plc:abc", SubjectRepo: "repo2",
|
||||
})
|
||||
_, _ = CreateLabel(db, &Label{
|
||||
signAndCreate(t, db, key, &Label{
|
||||
Src: src, URI: "at://did:plc:def/io.atcr.repo/repo1",
|
||||
Val: "!takedown", Cts: now, SubjectDID: "did:plc:def", SubjectRepo: "repo1",
|
||||
})
|
||||
|
||||
// Get labels for specific did+repo
|
||||
labels, err := GetLabelsForRepo(db, "did:plc:abc", "repo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -392,7 +355,6 @@ func TestGetLabelsForRepo(t *testing.T) {
|
||||
t.Errorf("expected 1 label for did:plc:abc/repo1, got %d", len(labels))
|
||||
}
|
||||
|
||||
// Different user same repo
|
||||
labels, err = GetLabelsForRepo(db, "did:plc:def", "repo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -401,7 +363,6 @@ func TestGetLabelsForRepo(t *testing.T) {
|
||||
t.Errorf("expected 1 label for did:plc:def/repo1, got %d", len(labels))
|
||||
}
|
||||
|
||||
// No labels
|
||||
labels, err = GetLabelsForRepo(db, "did:plc:xyz", "repo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -13,9 +13,8 @@ import (
|
||||
// Auth handlers
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// If already logged in, redirect to dashboard
|
||||
if token, ok := getSessionCookie(r); ok {
|
||||
if session := s.auth.getSession(token); session != nil && session.DID == s.config.Labeler.OwnerDID {
|
||||
if session := s.auth.GetSession(token); session != nil && session.DID == s.config.Labeler.OwnerDID {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
@@ -99,7 +98,7 @@ func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.auth.createSession(did, handle)
|
||||
token, _, err := s.auth.CreateSession(did, handle, r.UserAgent(), clientIPPrefix(r))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -111,7 +110,7 @@ func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if token, ok := getSessionCookie(r); ok {
|
||||
s.auth.deleteSession(token)
|
||||
s.auth.DeleteSession(token)
|
||||
}
|
||||
clearSessionCookie(w)
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package labeler
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// hubSubscriber is one connected subscribeLabels client. The hub fans out new labels
|
||||
// to each subscriber's bounded channel; if a slow client fills the buffer, the hub
|
||||
// drops them rather than blocking the writer.
|
||||
type hubSubscriber struct {
|
||||
ch chan *Label
|
||||
closed bool
|
||||
}
|
||||
|
||||
// Hub broadcasts newly-inserted labels to all live subscribeLabels clients.
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
subs map[*hubSubscriber]struct{}
|
||||
}
|
||||
|
||||
// NewHub returns an empty hub ready to accept subscribers.
|
||||
func NewHub() *Hub {
|
||||
return &Hub{subs: make(map[*hubSubscriber]struct{})}
|
||||
}
|
||||
|
||||
// subscribe registers a new subscriber and returns its event channel + a cancel func.
|
||||
// The buffer size bounds backpressure tolerance per client.
|
||||
func (h *Hub) subscribe(buffer int) (*hubSubscriber, func()) {
|
||||
s := &hubSubscriber{ch: make(chan *Label, buffer)}
|
||||
h.mu.Lock()
|
||||
h.subs[s] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
return s, func() { h.unsubscribe(s) }
|
||||
}
|
||||
|
||||
func (h *Hub) unsubscribe(s *hubSubscriber) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if _, ok := h.subs[s]; !ok {
|
||||
return
|
||||
}
|
||||
delete(h.subs, s)
|
||||
if !s.closed {
|
||||
s.closed = true
|
||||
close(s.ch)
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast sends a copy of the label to every live subscriber. Subscribers whose
|
||||
// buffer is full are evicted on the spot rather than slowing down the writer.
|
||||
func (h *Hub) Broadcast(l *Label) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
dead := make([]*hubSubscriber, 0)
|
||||
for s := range h.subs {
|
||||
select {
|
||||
case s.ch <- l:
|
||||
default:
|
||||
dead = append(dead, s)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
for _, s := range dead {
|
||||
h.unsubscribe(s)
|
||||
}
|
||||
}
|
||||
|
||||
// Len returns the number of live subscribers (mostly for tests / metrics).
|
||||
func (h *Hub) Len() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return len(h.subs)
|
||||
}
|
||||
+38
-21
@@ -1,38 +1,55 @@
|
||||
package labeler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"atcr.io/pkg/atproto/did"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
)
|
||||
|
||||
// DIDDocument represents a did:web DID document.
|
||||
type DIDDocument struct {
|
||||
Context []string `json:"@context"`
|
||||
ID string `json:"id"`
|
||||
Service []DIDService `json:"service,omitempty"`
|
||||
// labelerServices returns the service entries the labeler publishes in its DID document
|
||||
// and PLC operations: a single AtprotoLabeler endpoint at #atproto_labeler.
|
||||
func labelerServices(publicURL string) map[string]did.Service {
|
||||
return map[string]did.Service{
|
||||
"atproto_labeler": {Type: "AtprotoLabeler", Endpoint: publicURL},
|
||||
}
|
||||
}
|
||||
|
||||
// DIDService represents a service entry in a DID document.
|
||||
type DIDService struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ServiceEndpoint string `json:"serviceEndpoint"`
|
||||
// LoadIdentity resolves the labeler's DID and loads its k256 signing key.
|
||||
// For did:plc this calls into the shared PLC package (loading or creating); for did:web
|
||||
// the DID is derived from PublicURL and the signing key is generated on disk if missing.
|
||||
func LoadIdentity(ctx context.Context, cfg *Config) (string, *atcrypto.PrivateKeyK256, error) {
|
||||
labelerDID, err := did.LoadOrCreate(ctx, did.Config{
|
||||
Method: cfg.Labeler.DIDMethod,
|
||||
PublicURL: cfg.PublicURL(),
|
||||
DBPath: cfg.Labeler.DataDir,
|
||||
SigningKeyPath: cfg.SigningKeyPath(),
|
||||
RotationKey: cfg.Labeler.RotationKey,
|
||||
PLCDirectoryURL: cfg.PLCDirectoryURL(),
|
||||
DID: cfg.Labeler.DID,
|
||||
VerificationKeyName: "atproto_label",
|
||||
Services: labelerServices(cfg.PublicURL()),
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("labeler: failed to resolve DID: %w", err)
|
||||
}
|
||||
signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath())
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("labeler: failed to load signing key: %w", err)
|
||||
}
|
||||
return labelerDID, signingKey, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleDIDDocument(w http.ResponseWriter, r *http.Request) {
|
||||
doc := DIDDocument{
|
||||
Context: []string{"https://www.w3.org/ns/did/v1"},
|
||||
ID: s.config.DID(),
|
||||
Service: []DIDService{
|
||||
{
|
||||
ID: "#atproto_labeler",
|
||||
Type: "AtprotoLabeler",
|
||||
ServiceEndpoint: s.config.PublicURL(),
|
||||
},
|
||||
},
|
||||
doc, err := did.BuildDIDDocument(s.did, s.config.PublicURL(), s.signingKey, "atproto_label", labelerServices(s.config.PublicURL()))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to build DID document", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(doc)
|
||||
}
|
||||
|
||||
+50
-16
@@ -5,34 +5,51 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Server is the labeler HTTP server.
|
||||
type Server struct {
|
||||
config *Config
|
||||
db *sql.DB
|
||||
router chi.Router
|
||||
clientApp *indigooauth.ClientApp
|
||||
auth *Auth
|
||||
config *Config
|
||||
storage *LabelerDB
|
||||
db *sql.DB
|
||||
router chi.Router
|
||||
clientApp *indigooauth.ClientApp
|
||||
auth *Auth
|
||||
did string
|
||||
signingKey *atcrypto.PrivateKeyK256
|
||||
hub *Hub
|
||||
}
|
||||
|
||||
// NewServer creates a new labeler server.
|
||||
func NewServer(cfg *Config) (*Server, error) {
|
||||
db, err := OpenDB(cfg.Labeler.DBPath)
|
||||
storage, err := OpenDB(cfg.DBPath(), LibsqlSync{
|
||||
SyncURL: cfg.Labeler.LibsqlSyncURL,
|
||||
AuthToken: cfg.Labeler.LibsqlAuthToken,
|
||||
SyncInterval: cfg.Labeler.LibsqlSyncInterval,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
did, signingKey, err := LoadIdentity(ctx, cfg)
|
||||
if err != nil {
|
||||
_ = storage.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publicURL := cfg.PublicURL()
|
||||
|
||||
// Set up OAuth client for admin login
|
||||
@@ -68,10 +85,14 @@ func NewServer(cfg *Config) (*Server, error) {
|
||||
auth := NewAuth(cfg.Labeler.OwnerDID)
|
||||
|
||||
s := &Server{
|
||||
config: cfg,
|
||||
db: db,
|
||||
clientApp: clientApp,
|
||||
auth: auth,
|
||||
config: cfg,
|
||||
storage: storage,
|
||||
db: storage.DB,
|
||||
clientApp: clientApp,
|
||||
auth: auth,
|
||||
did: did,
|
||||
signingKey: signingKey,
|
||||
hub: NewHub(),
|
||||
}
|
||||
|
||||
s.setupRoutes()
|
||||
@@ -97,9 +118,11 @@ func (s *Server) setupRoutes() {
|
||||
r.Get("/xrpc/com.atproto.label.subscribeLabels", s.handleSubscribeLabels)
|
||||
r.Get("/xrpc/com.atproto.label.queryLabels", s.handleQueryLabels)
|
||||
|
||||
// Protected routes (require owner)
|
||||
// Protected routes (require owner). CSRF is enforced for state-mutating
|
||||
// methods inside the same group, so it sees the session on the context.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.auth.RequireOwner)
|
||||
r.Use(s.auth.RequireCSRF)
|
||||
|
||||
r.Get("/", s.handleDashboard)
|
||||
r.Get("/takedown", s.handleTakedownForm)
|
||||
@@ -115,7 +138,7 @@ func (s *Server) Serve() error {
|
||||
slog.Info("Starting labeler service",
|
||||
"addr", s.config.Labeler.Addr,
|
||||
"public_url", s.config.PublicURL(),
|
||||
"did", s.config.DID(),
|
||||
"did", s.did,
|
||||
"owner", s.config.Labeler.OwnerDID,
|
||||
)
|
||||
|
||||
@@ -140,17 +163,28 @@ func (s *Server) Serve() error {
|
||||
}
|
||||
case <-ctx.Done():
|
||||
slog.Info("Shutting down labeler service")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5000000000) // 5s
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("shutdown error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.db.Close()
|
||||
if err := s.storage.Close(); err != nil {
|
||||
slog.Warn("Error closing labeler database", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isLocalhost returns true when the host is reachable only from the local machine /
|
||||
// docker host — anything that an external PDS can't reach. Matches the hold's policy:
|
||||
// any IP literal counts (covers 127.0.0.1, 192.168.*, 172.16-31.*, 10.*, ::1, etc.) plus
|
||||
// the literal "localhost". When this is true, OAuth uses indigo's `NewLocalhostConfig`
|
||||
// which sets a `http://localhost`-form client_id that PDSes accept under the loopback
|
||||
// exception — so the PDS never has to fetch the client metadata URL we publish.
|
||||
func isLocalhost(host string) bool {
|
||||
return host == "localhost" || host == "127.0.0.1" || strings.HasPrefix(host, "192.168.")
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
return net.ParseIP(host) != nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package labeler
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsLocalhost covers the OAuth-mode decision: any IP-literal host (including
|
||||
// docker-compose private addresses like 172.28.0.x and the standard 127.0.0.1) plus
|
||||
// the literal "localhost" routes through the loopback OAuth path so PDSes don't have
|
||||
// to fetch our published client metadata. Domain names go through the public-client
|
||||
// path and require the metadata endpoint to be reachable from the PDS.
|
||||
func TestIsLocalhost(t *testing.T) {
|
||||
tests := []struct {
|
||||
host string
|
||||
want bool
|
||||
}{
|
||||
{"localhost", true},
|
||||
{"127.0.0.1", true},
|
||||
{"::1", true},
|
||||
{"192.168.1.10", true},
|
||||
{"172.28.0.4", true}, // docker-compose private network
|
||||
{"10.0.0.5", true}, // RFC 1918
|
||||
{"labeler.atcr.io", false},
|
||||
{"labeler.example.com", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isLocalhost(tt.host); got != tt.want {
|
||||
t.Errorf("isLocalhost(%q) = %v, want %v", tt.host, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+249
-99
@@ -1,62 +1,107 @@
|
||||
package labeler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
comatproto "github.com/bluesky-social/indigo/api/atproto"
|
||||
"github.com/bluesky-social/indigo/events"
|
||||
"github.com/gorilla/websocket"
|
||||
cbg "github.com/whyrusleeping/cbor-gen"
|
||||
)
|
||||
|
||||
const (
|
||||
subscriberBuffer = 64
|
||||
backfillPageLimit = 200
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
// CheckOrigin is permissive: the firehose is a public stream by design and ATProto
|
||||
// consumers are not browsers, so the same-origin policy doesn't apply to them anyway.
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// LabelsMessage is the ATProto subscribeLabels wire format.
|
||||
type LabelsMessage struct {
|
||||
Seq int64 `json:"seq"`
|
||||
Labels []LabelOutput `json:"labels"`
|
||||
}
|
||||
// frameLabels builds the binary frame for a labels event: CBOR-encoded
|
||||
// {op:1, t:"#labels"} header concatenated with CBOR-encoded {seq, labels:[...]} body.
|
||||
func frameLabels(seq int64, labels []*comatproto.LabelDefs_Label) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := cbg.NewCborWriter(&buf)
|
||||
|
||||
// LabelOutput is the ATProto label format for subscribeLabels/queryLabels output.
|
||||
type LabelOutput struct {
|
||||
Src string `json:"src"`
|
||||
URI string `json:"uri"`
|
||||
CID string `json:"cid,omitempty"`
|
||||
Val string `json:"val"`
|
||||
Neg bool `json:"neg"`
|
||||
Cts string `json:"cts"`
|
||||
Exp string `json:"exp,omitempty"`
|
||||
}
|
||||
|
||||
func labelToOutput(l Label) LabelOutput {
|
||||
out := LabelOutput{
|
||||
Src: l.Src,
|
||||
URI: l.URI,
|
||||
CID: l.CID,
|
||||
Val: l.Val,
|
||||
Neg: l.Neg,
|
||||
Cts: l.Cts.UTC().Format(time.RFC3339),
|
||||
header := events.EventHeader{Op: events.EvtKindMessage, MsgType: "#labels"}
|
||||
if err := header.MarshalCBOR(w); err != nil {
|
||||
return nil, fmt.Errorf("marshal header: %w", err)
|
||||
}
|
||||
if l.Exp != nil {
|
||||
out.Exp = l.Exp.UTC().Format(time.RFC3339)
|
||||
body := comatproto.LabelSubscribeLabels_Labels{Seq: seq, Labels: labels}
|
||||
if err := body.MarshalCBOR(w); err != nil {
|
||||
return nil, fmt.Errorf("marshal body: %w", err)
|
||||
}
|
||||
return out
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// handleSubscribeLabels implements com.atproto.label.subscribeLabels (WebSocket).
|
||||
// frameInfo builds the binary frame for an info event: header {op:1, t:"#info"} plus body.
|
||||
func frameInfo(name, message string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := cbg.NewCborWriter(&buf)
|
||||
|
||||
header := events.EventHeader{Op: events.EvtKindMessage, MsgType: "#info"}
|
||||
if err := header.MarshalCBOR(w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := comatproto.LabelSubscribeLabels_Info{Name: name}
|
||||
if message != "" {
|
||||
body.Message = &message
|
||||
}
|
||||
if err := body.MarshalCBOR(w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// frameError builds an error frame: header {op:-1} plus {error, message}.
|
||||
func frameError(name, message string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := cbg.NewCborWriter(&buf)
|
||||
|
||||
header := events.EventHeader{Op: events.EvtKindErrorFrame}
|
||||
if err := header.MarshalCBOR(w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := events.ErrorFrame{Error: name, Message: message}
|
||||
if err := body.MarshalCBOR(w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// labelToLexicon converts a stored row into the indigo lexicon type used in the wire format.
|
||||
func labelToLexicon(l *Label) *comatproto.LabelDefs_Label {
|
||||
tmp := l.ToLabeling()
|
||||
lex := tmp.ToLexicon()
|
||||
return &lex
|
||||
}
|
||||
|
||||
// handleSubscribeLabels implements com.atproto.label.subscribeLabels.
|
||||
//
|
||||
// Wire format: each WebSocket binary message is two concatenated CBOR objects (header
|
||||
// + body) matching the firehose convention. Backfill pages historical labels since the
|
||||
// cursor, then the connection joins the broadcast hub for live deliveries.
|
||||
func (s *Server) handleSubscribeLabels(w http.ResponseWriter, r *http.Request) {
|
||||
cursorStr := r.URL.Query().Get("cursor")
|
||||
var cursor int64
|
||||
if cursorStr != "" {
|
||||
var err error
|
||||
cursor, err = strconv.ParseInt(cursorStr, 10, 64)
|
||||
v, err := strconv.ParseInt(cursorStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid cursor", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cursor = v
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
@@ -68,34 +113,64 @@ func (s *Server) handleSubscribeLabels(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
slog.Info("subscribeLabels client connected", "cursor", cursor)
|
||||
|
||||
// Send historical labels since cursor
|
||||
labels, err := GetLabelsSince(s.db, cursor, 1000)
|
||||
latest, err := LatestSeq(s.db)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get labels", "error", err)
|
||||
slog.Error("Failed to read latest seq", "error", err)
|
||||
return
|
||||
}
|
||||
if cursor > latest {
|
||||
if frame, ferr := frameError("FutureCursor", "cursor is in the future"); ferr == nil {
|
||||
_ = conn.WriteMessage(websocket.BinaryMessage, frame)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for _, l := range labels {
|
||||
msg := LabelsMessage{
|
||||
Seq: l.ID,
|
||||
Labels: []LabelOutput{labelToOutput(l)},
|
||||
// Subscribe to the broadcast hub BEFORE backfilling so we don't lose events
|
||||
// that arrive while we're streaming the historical tail.
|
||||
sub, cancel := s.hub.subscribe(subscriberBuffer)
|
||||
defer cancel()
|
||||
|
||||
if cursor > 0 {
|
||||
if frame, ferr := frameInfo("OutdatedCursor", "starting backfill from cursor"); ferr == nil {
|
||||
if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := conn.WriteJSON(msg); err != nil {
|
||||
return
|
||||
}
|
||||
cursor = l.ID
|
||||
}
|
||||
|
||||
// Poll for new labels
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
// Backfill historical labels in pages until we catch up.
|
||||
for {
|
||||
labels, err := GetLabelsSince(s.db, cursor, backfillPageLimit)
|
||||
if err != nil {
|
||||
slog.Error("Failed to read labels for backfill", "error", err)
|
||||
return
|
||||
}
|
||||
if len(labels) == 0 {
|
||||
break
|
||||
}
|
||||
for i := range labels {
|
||||
frame, ferr := frameLabels(labels[i].ID, []*comatproto.LabelDefs_Label{labelToLexicon(&labels[i])})
|
||||
if ferr != nil {
|
||||
slog.Error("Failed to encode label frame", "error", ferr)
|
||||
return
|
||||
}
|
||||
if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
|
||||
return
|
||||
}
|
||||
cursor = labels[i].ID
|
||||
}
|
||||
if len(labels) < backfillPageLimit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Read pump (detect client disconnect)
|
||||
// Live delivery: a goroutine monitors the read side so we notice client disconnects;
|
||||
// the main loop pulls from the hub and writes frames until either side closes.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
if _, _, rerr := conn.ReadMessage(); rerr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -105,83 +180,158 @@ func (s *Server) handleSubscribeLabels(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
labels, err := GetLabelsSince(s.db, cursor, 100)
|
||||
if err != nil {
|
||||
slog.Error("Failed to poll labels", "error", err)
|
||||
continue
|
||||
case lbl, ok := <-sub.ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, l := range labels {
|
||||
msg := LabelsMessage{
|
||||
Seq: l.ID,
|
||||
Labels: []LabelOutput{labelToOutput(l)},
|
||||
}
|
||||
if err := conn.WriteJSON(msg); err != nil {
|
||||
return
|
||||
}
|
||||
cursor = l.ID
|
||||
if lbl.ID <= cursor {
|
||||
continue // already delivered during backfill
|
||||
}
|
||||
frame, ferr := frameLabels(lbl.ID, []*comatproto.LabelDefs_Label{labelToLexicon(lbl)})
|
||||
if ferr != nil {
|
||||
slog.Error("Failed to encode label frame", "error", ferr)
|
||||
return
|
||||
}
|
||||
if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
|
||||
return
|
||||
}
|
||||
cursor = lbl.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleQueryLabels implements com.atproto.label.queryLabels (HTTP GET).
|
||||
// queryLabelsResponse mirrors the lexicon JSON shape for queryLabels.
|
||||
type queryLabelsResponse struct {
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
Labels []*comatproto.LabelDefs_Label `json:"labels"`
|
||||
}
|
||||
|
||||
// handleQueryLabels implements com.atproto.label.queryLabels.
|
||||
//
|
||||
// Filters (uriPatterns, sources) are applied in SQL so the LIMIT cap operates on the
|
||||
// filtered result, not the raw scan. URI patterns support a single trailing `*` glob
|
||||
// (LIKE), with `%` and `_` escaped to remain literal.
|
||||
func (s *Server) handleQueryLabels(w http.ResponseWriter, r *http.Request) {
|
||||
uriPatterns := r.URL.Query()["uriPatterns"]
|
||||
cursorStr := r.URL.Query().Get("cursor")
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
q := r.URL.Query()
|
||||
patterns := q["uriPatterns"]
|
||||
sources := q["sources"]
|
||||
|
||||
var cursor int64
|
||||
if cursorStr != "" {
|
||||
cursor, _ = strconv.ParseInt(cursorStr, 10, 64)
|
||||
if cs := q.Get("cursor"); cs != "" {
|
||||
v, err := strconv.ParseInt(cs, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid cursor", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cursor = v
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 250 {
|
||||
if ls := q.Get("limit"); ls != "" {
|
||||
if l, err := strconv.Atoi(ls); err == nil && l > 0 && l <= 250 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
labels, err := GetLabelsSince(s.db, cursor, limit)
|
||||
rows, err := queryLabelsSQL(s.db, patterns, sources, cursor, limit)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to query labels", http.StatusInternalServerError)
|
||||
if errors.Is(err, errInvalidPattern) {
|
||||
http.Error(w, "invalid uriPattern: wildcard '*' must be at the end", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
slog.Error("queryLabels failed", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter by URI patterns if provided
|
||||
var filtered []LabelOutput
|
||||
for _, l := range labels {
|
||||
if len(uriPatterns) == 0 || matchesAnyPattern(l.URI, uriPatterns) {
|
||||
filtered = append(filtered, labelToOutput(l))
|
||||
}
|
||||
out := &queryLabelsResponse{Labels: make([]*comatproto.LabelDefs_Label, 0, len(rows))}
|
||||
for i := range rows {
|
||||
out.Labels = append(out.Labels, labelToLexicon(&rows[i]))
|
||||
}
|
||||
|
||||
var nextCursor string
|
||||
if len(labels) > 0 {
|
||||
nextCursor = strconv.FormatInt(labels[len(labels)-1].ID, 10)
|
||||
}
|
||||
|
||||
resp := struct {
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
Labels []LabelOutput `json:"labels"`
|
||||
}{
|
||||
Cursor: nextCursor,
|
||||
Labels: filtered,
|
||||
}
|
||||
if resp.Labels == nil {
|
||||
resp.Labels = []LabelOutput{}
|
||||
if len(rows) > 0 {
|
||||
out.Cursor = strconv.FormatInt(rows[len(rows)-1].ID, 10)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
|
||||
func matchesAnyPattern(uri string, patterns []string) bool {
|
||||
for _, p := range patterns {
|
||||
// Simple prefix matching (ATProto spec allows glob-like patterns)
|
||||
if p == uri || (len(p) > 0 && p[len(p)-1] == '*' && len(uri) >= len(p)-1 && uri[:len(p)-1] == p[:len(p)-1]) {
|
||||
return true
|
||||
var errInvalidPattern = errors.New("invalid uriPattern")
|
||||
|
||||
// queryLabelsSQL builds the WHERE clause from filter args and runs the query. All
|
||||
// filtering happens in SQL so LIMIT operates on already-filtered rows.
|
||||
func queryLabelsSQL(db *sql.DB, patterns, sources []string, cursor int64, limit int) ([]Label, error) {
|
||||
var (
|
||||
where []string
|
||||
args []any
|
||||
)
|
||||
where = append(where, "id > ?")
|
||||
args = append(args, cursor)
|
||||
|
||||
if len(patterns) > 0 {
|
||||
var ors []string
|
||||
var matchAll bool
|
||||
for _, p := range patterns {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if p == "*" {
|
||||
matchAll = true
|
||||
break
|
||||
}
|
||||
like, err := patternToLike(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.ContainsAny(like, `%_\`) {
|
||||
ors = append(ors, "uri LIKE ? ESCAPE '\\'")
|
||||
} else {
|
||||
ors = append(ors, "uri = ?")
|
||||
}
|
||||
args = append(args, like)
|
||||
}
|
||||
if !matchAll && len(ors) > 0 {
|
||||
where = append(where, "("+strings.Join(ors, " OR ")+")")
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
if len(sources) > 0 {
|
||||
placeholders := strings.Repeat("?,", len(sources))
|
||||
placeholders = placeholders[:len(placeholders)-1]
|
||||
where = append(where, "src IN ("+placeholders+")")
|
||||
for _, s := range sources {
|
||||
args = append(args, s)
|
||||
}
|
||||
}
|
||||
|
||||
args = append(args, limit)
|
||||
q := `SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo
|
||||
FROM labels WHERE ` + strings.Join(where, " AND ") +
|
||||
` ORDER BY id ASC LIMIT ?`
|
||||
|
||||
rows, err := db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanLabels(rows)
|
||||
}
|
||||
|
||||
// patternToLike converts a uriPattern into a SQLite LIKE expression. The only
|
||||
// wildcard supported is a trailing `*`, which becomes `%`. Literal `%`, `_`, and `\`
|
||||
// in the input are escaped via the LIKE ESCAPE clause used at query time.
|
||||
func patternToLike(p string) (string, error) {
|
||||
if idx := strings.Index(p, "*"); idx >= 0 && idx != len(p)-1 {
|
||||
return "", errInvalidPattern
|
||||
}
|
||||
literal := p
|
||||
suffix := ""
|
||||
if strings.HasSuffix(p, "*") {
|
||||
literal = p[:len(p)-1]
|
||||
suffix = "%"
|
||||
}
|
||||
literal = strings.ReplaceAll(literal, `\`, `\\`)
|
||||
literal = strings.ReplaceAll(literal, `%`, `\%`)
|
||||
literal = strings.ReplaceAll(literal, `_`, `\_`)
|
||||
return literal + suffix, nil
|
||||
}
|
||||
|
||||
@@ -1,86 +1,109 @@
|
||||
package labeler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
comatproto "github.com/bluesky-social/indigo/api/atproto"
|
||||
)
|
||||
|
||||
func TestLabelToOutput(t *testing.T) {
|
||||
// TestLabelToLexicon checks the wire-format conversion populates the indigo lexicon
|
||||
// fields (which is what's serialized into both the WS frame and the queryLabels JSON).
|
||||
func TestLabelToLexicon(t *testing.T) {
|
||||
now := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC)
|
||||
exp := time.Date(2026, 4, 22, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
label := Label{
|
||||
ID: 1,
|
||||
Src: "did:web:labeler.atcr.io",
|
||||
Src: "did:plc:abc",
|
||||
URI: "at://did:plc:abc/io.atcr.manifest/sha256-123",
|
||||
CID: "bafyabc",
|
||||
Val: "!takedown",
|
||||
Neg: false,
|
||||
Cts: now,
|
||||
Exp: &exp,
|
||||
Ver: LabelVersion,
|
||||
Sig: []byte{0x01, 0x02, 0x03},
|
||||
SubjectDID: "did:plc:abc",
|
||||
SubjectRepo: "myimage",
|
||||
}
|
||||
lex := labelToLexicon(&label)
|
||||
|
||||
out := labelToOutput(label)
|
||||
if out.Src != "did:web:labeler.atcr.io" {
|
||||
t.Errorf("Src = %q, want did:web:labeler.atcr.io", out.Src)
|
||||
if lex.Src != label.Src {
|
||||
t.Errorf("Src = %q", lex.Src)
|
||||
}
|
||||
if out.URI != "at://did:plc:abc/io.atcr.manifest/sha256-123" {
|
||||
t.Errorf("URI = %q", out.URI)
|
||||
if lex.Uri != label.URI {
|
||||
t.Errorf("Uri = %q", lex.Uri)
|
||||
}
|
||||
if out.CID != "bafyabc" {
|
||||
t.Errorf("CID = %q, want bafyabc", out.CID)
|
||||
if lex.Cid == nil || *lex.Cid != "bafyabc" {
|
||||
t.Errorf("Cid = %v", lex.Cid)
|
||||
}
|
||||
if out.Val != "!takedown" {
|
||||
t.Errorf("Val = %q", out.Val)
|
||||
if lex.Cts != "2026-03-22T10:00:00Z" {
|
||||
t.Errorf("Cts = %q", lex.Cts)
|
||||
}
|
||||
if out.Neg {
|
||||
t.Error("expected Neg=false")
|
||||
if lex.Exp == nil || *lex.Exp != "2026-04-22T10:00:00Z" {
|
||||
t.Errorf("Exp = %v", lex.Exp)
|
||||
}
|
||||
if out.Cts != "2026-03-22T10:00:00Z" {
|
||||
t.Errorf("Cts = %q", out.Cts)
|
||||
}
|
||||
if out.Exp != "2026-04-22T10:00:00Z" {
|
||||
t.Errorf("Exp = %q", out.Exp)
|
||||
if len(lex.Sig) != 3 {
|
||||
t.Errorf("Sig length = %d, want 3", len(lex.Sig))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelToOutput_NoExpiration(t *testing.T) {
|
||||
label := Label{
|
||||
Src: "did:web:labeler.atcr.io",
|
||||
URI: "at://did:plc:abc",
|
||||
Val: "!takedown",
|
||||
Cts: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
out := labelToOutput(label)
|
||||
if out.Exp != "" {
|
||||
t.Errorf("expected empty Exp, got %q", out.Exp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesAnyPattern(t *testing.T) {
|
||||
func TestPatternToLike(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uri string
|
||||
patterns []string
|
||||
want bool
|
||||
in string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"exact match", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{"at://did:plc:abc/io.atcr.manifest/sha256-123"}, true},
|
||||
{"no match", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{"at://did:plc:def/io.atcr.manifest/sha256-123"}, false},
|
||||
{"wildcard match", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{"at://did:plc:abc/*"}, true},
|
||||
{"wildcard no match", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{"at://did:plc:def/*"}, false},
|
||||
{"empty patterns", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{}, false},
|
||||
{"multiple patterns", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{"at://did:plc:def/*", "at://did:plc:abc/*"}, true},
|
||||
{"at://did:plc:abc/foo", "at://did:plc:abc/foo", false},
|
||||
{"at://did:plc:abc/*", "at://did:plc:abc/%", false},
|
||||
{"at://did:plc:abc%/foo", `at://did:plc:abc\%/foo`, false},
|
||||
{"at://did:plc:abc_/foo", `at://did:plc:abc\_/foo`, false},
|
||||
{"at://*/foo", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := matchesAnyPattern(tt.uri, tt.patterns)
|
||||
t.Run(tt.in, func(t *testing.T) {
|
||||
got, err := patternToLike(tt.in)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("expected error, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("matchesAnyPattern(%q, %v) = %v, want %v", tt.uri, tt.patterns, got, tt.want)
|
||||
t.Errorf("patternToLike(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFrameLabels exercises the CBOR framing — produces non-empty bytes whose first
|
||||
// byte is a CBOR map header and whose body contains the labels payload keys.
|
||||
func TestFrameLabels(t *testing.T) {
|
||||
now := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC)
|
||||
l := &Label{
|
||||
Src: "did:plc:abc",
|
||||
URI: "at://did:plc:abc",
|
||||
Val: "!takedown",
|
||||
Cts: now,
|
||||
Ver: LabelVersion,
|
||||
Sig: []byte("sig-bytes"),
|
||||
}
|
||||
frame, err := frameLabels(42, []*comatproto.LabelDefs_Label{labelToLexicon(l)})
|
||||
if err != nil {
|
||||
t.Fatalf("frame: %v", err)
|
||||
}
|
||||
if len(frame) < 2 {
|
||||
t.Fatalf("frame too short: %d bytes", len(frame))
|
||||
}
|
||||
if frame[0]&0xe0 != 0xa0 {
|
||||
t.Errorf("first byte %#x is not a CBOR map header", frame[0])
|
||||
}
|
||||
if !strings.Contains(string(frame), "labels") || !strings.Contains(string(frame), "seq") {
|
||||
t.Errorf("frame missing expected keys")
|
||||
}
|
||||
}
|
||||
|
||||
+151
-71
@@ -7,6 +7,7 @@ import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,88 +22,137 @@ type TakedownInput struct {
|
||||
}
|
||||
|
||||
// ParseTakedownInput parses various input formats into a TakedownInput.
|
||||
// Supported formats:
|
||||
// - atcr.io/r/handle/repo
|
||||
// - handle/repo
|
||||
// - at://did:plc:xyz/io.atcr.repo.page/repo
|
||||
// - at://did:plc:xyz (user-level)
|
||||
// - handle (user-level)
|
||||
// - did:plc:xyz (user-level)
|
||||
//
|
||||
// Supported shapes (dispatched in order):
|
||||
//
|
||||
// - at://<did-or-handle>[/collection/rkey] — ATProto AT URI
|
||||
// - did:plc:..., did:web:... — bare DID, user-level takedown
|
||||
// - URL with /u/<handle> or /r/<handle>/<repo> — appview routes (with or without scheme)
|
||||
// - <handle> — bare handle, user-level takedown
|
||||
//
|
||||
// Anything else is rejected. The appview's /r/ route uses the repo name as a single
|
||||
// path segment so any trailing path (digest pages, tag tabs) is discarded; URL
|
||||
// fragments and query strings are dropped in all cases.
|
||||
func ParseTakedownInput(ctx context.Context, input string) (*TakedownInput, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return nil, fmt.Errorf("empty takedown input")
|
||||
}
|
||||
|
||||
// AT URI format
|
||||
if strings.HasPrefix(input, "at://") {
|
||||
return parseATURI(ctx, input)
|
||||
}
|
||||
|
||||
// Strip URL prefix if present
|
||||
input = strings.TrimPrefix(input, "https://")
|
||||
input = strings.TrimPrefix(input, "http://")
|
||||
|
||||
// Remove atcr.io/r/ or similar prefix
|
||||
for _, prefix := range []string{"atcr.io/r/", "localhost/r/"} {
|
||||
if strings.HasPrefix(input, prefix) {
|
||||
input = strings.TrimPrefix(input, prefix)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Also handle custom domains: anything ending in /r/
|
||||
if idx := strings.Index(input, "/r/"); idx >= 0 {
|
||||
input = input[idx+3:]
|
||||
// Bare DID — no slashes, no scheme. did:plc:..., did:web:..., did:web:host%3Aport.
|
||||
if strings.HasPrefix(input, "did:") && !strings.Contains(input, "/") {
|
||||
return resolveBareIdentifier(ctx, input)
|
||||
}
|
||||
|
||||
// Now input should be "handle/repo" or "handle" or "did:xxx"
|
||||
parts := strings.SplitN(input, "/", 2)
|
||||
identifier := parts[0]
|
||||
var repo string
|
||||
if len(parts) > 1 {
|
||||
repo = parts[1]
|
||||
repo = strings.TrimSuffix(repo, "/")
|
||||
// URL-shaped: contains a scheme or a slash. Parse and dispatch on the path.
|
||||
if hasURLShape(input) {
|
||||
return parseTakedownURL(ctx, input)
|
||||
}
|
||||
|
||||
did, handle, err := resolveIdentifier(ctx, identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TakedownInput{
|
||||
DID: did,
|
||||
Handle: handle,
|
||||
Repository: repo,
|
||||
}, nil
|
||||
// Otherwise: bare handle.
|
||||
return resolveBareIdentifier(ctx, input)
|
||||
}
|
||||
|
||||
func parseATURI(ctx context.Context, uri string) (*TakedownInput, error) {
|
||||
// at://did:plc:xyz/collection/rkey
|
||||
trimmed := strings.TrimPrefix(uri, "at://")
|
||||
parts := strings.SplitN(trimmed, "/", 3)
|
||||
// hasURLShape reports whether the input looks like a URL or a path. A bare handle like
|
||||
// "alice.bsky.social" is not URL-shaped (no slashes, no scheme).
|
||||
func hasURLShape(s string) bool {
|
||||
return strings.Contains(s, "://") || strings.Contains(s, "/")
|
||||
}
|
||||
|
||||
did := parts[0]
|
||||
if !strings.HasPrefix(did, "did:") {
|
||||
// It's a handle
|
||||
resolvedDID, handle, err := resolveIdentifier(ctx, did)
|
||||
// parseTakedownURL parses a URL whose path is one of the appview's takedown-relevant
|
||||
// routes: /u/<handle> for user-level, /r/<handle>/<repo> for repo-level. The host part
|
||||
// is irrelevant — we only use the path — so this also accepts schemeless input like
|
||||
// "atcr.io/r/handle/repo" by prepending https:// before parsing.
|
||||
func parseTakedownURL(ctx context.Context, input string) (*TakedownInput, error) {
|
||||
if !strings.Contains(input, "://") {
|
||||
input = "https://" + input
|
||||
}
|
||||
u, err := url.Parse(input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
// No path — treat the host as the identifier (e.g. "alice.bsky.social/").
|
||||
return resolveBareIdentifier(ctx, u.Host)
|
||||
}
|
||||
|
||||
switch parts[0] {
|
||||
case "u":
|
||||
if len(parts) < 2 || parts[1] == "" {
|
||||
return nil, fmt.Errorf("missing handle in /u/<handle>")
|
||||
}
|
||||
return resolveBareIdentifier(ctx, parts[1])
|
||||
case "r":
|
||||
if len(parts) < 3 || parts[1] == "" || parts[2] == "" {
|
||||
return nil, fmt.Errorf("missing handle or repo in /r/<handle>/<repo>")
|
||||
}
|
||||
base, err := resolveBareIdentifier(ctx, parts[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
did = resolvedDID
|
||||
if len(parts) >= 3 {
|
||||
return &TakedownInput{DID: did, Handle: handle, Repository: parts[2]}, nil
|
||||
// parts[2] only — discard any /digest/..., /tags/..., etc. trailing path.
|
||||
base.Repository = parts[2]
|
||||
return base, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported URL path %q (expected /u/<handle> or /r/<handle>/<repo>)", u.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// parseATURI parses an at:// URI. The authority is a DID or handle; the path's third
|
||||
// segment (rkey) becomes the repo for repo-level takedowns. Fragment and query are
|
||||
// stripped first since paste-of-browser-AT-URI may include them.
|
||||
func parseATURI(ctx context.Context, uri string) (*TakedownInput, error) {
|
||||
trimmed := strings.TrimPrefix(uri, "at://")
|
||||
if idx := strings.IndexAny(trimmed, "#?"); idx >= 0 {
|
||||
trimmed = trimmed[:idx]
|
||||
}
|
||||
parts := strings.SplitN(trimmed, "/", 3)
|
||||
authority := parts[0]
|
||||
if authority == "" {
|
||||
return nil, fmt.Errorf("at:// URI missing authority")
|
||||
}
|
||||
|
||||
var (
|
||||
did, handle string
|
||||
err error
|
||||
)
|
||||
if strings.HasPrefix(authority, "did:") {
|
||||
did = authority
|
||||
_, handle, _, _ = atproto.ResolveIdentity(ctx, did)
|
||||
} else {
|
||||
did, handle, err = resolveIdentifier(ctx, authority)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TakedownInput{DID: did, Handle: handle}, nil
|
||||
}
|
||||
|
||||
// Resolve handle from DID
|
||||
_, handle, _, _ := atproto.ResolveIdentity(ctx, did)
|
||||
|
||||
if len(parts) < 3 {
|
||||
// User-level takedown
|
||||
return &TakedownInput{DID: did, Handle: handle}, nil
|
||||
out := &TakedownInput{DID: did, Handle: handle}
|
||||
if len(parts) >= 3 {
|
||||
out.Repository = parts[2]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Extract repository from rkey (third part)
|
||||
repo := parts[2]
|
||||
return &TakedownInput{DID: did, Handle: handle, Repository: repo}, nil
|
||||
// resolveBareIdentifier resolves a handle or DID to a user-level TakedownInput. When
|
||||
// the input is already a DID, the resolve is best-effort (DID is the source of truth;
|
||||
// handle is just for display) and we don't fail if PLC/web resolution is unreachable.
|
||||
// For a handle, resolution is required since we need a DID to label.
|
||||
func resolveBareIdentifier(ctx context.Context, id string) (*TakedownInput, error) {
|
||||
if strings.HasPrefix(id, "did:") {
|
||||
_, handle, _, _ := atproto.ResolveIdentity(ctx, id)
|
||||
return &TakedownInput{DID: id, Handle: handle}, nil
|
||||
}
|
||||
did, handle, err := resolveIdentifier(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TakedownInput{DID: did, Handle: handle}, nil
|
||||
}
|
||||
|
||||
func resolveIdentifier(ctx context.Context, identifier string) (did, handle string, err error) {
|
||||
@@ -124,7 +174,7 @@ type TakedownResult struct {
|
||||
|
||||
// ExecuteTakedown creates takedown labels for a repo or user.
|
||||
func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*TakedownResult, error) {
|
||||
src := s.config.DID()
|
||||
src := s.did
|
||||
now := time.Now().UTC()
|
||||
result := &TakedownResult{
|
||||
DID: input.DID,
|
||||
@@ -143,9 +193,13 @@ func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*Ta
|
||||
SubjectDID: input.DID,
|
||||
SubjectRepo: "",
|
||||
}
|
||||
if err := label.Sign(s.signingKey); err != nil {
|
||||
return nil, fmt.Errorf("failed to sign user-level label: %w", err)
|
||||
}
|
||||
if _, err := CreateLabel(s.db, label); err != nil {
|
||||
return nil, fmt.Errorf("failed to create user-level label: %w", err)
|
||||
}
|
||||
s.hub.Broadcast(label)
|
||||
result.Labels = append(result.Labels, *label)
|
||||
slog.Info("Created user-level takedown", "did", input.DID, "handle", input.Handle)
|
||||
return result, nil
|
||||
@@ -168,9 +222,13 @@ func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*Ta
|
||||
SubjectDID: input.DID,
|
||||
SubjectRepo: input.Repository,
|
||||
}
|
||||
if err := summaryLabel.Sign(s.signingKey); err != nil {
|
||||
return nil, fmt.Errorf("failed to sign summary label: %w", err)
|
||||
}
|
||||
if _, err := CreateLabel(s.db, summaryLabel); err != nil {
|
||||
return nil, fmt.Errorf("failed to create summary label: %w", err)
|
||||
}
|
||||
s.hub.Broadcast(summaryLabel)
|
||||
result.Labels = append(result.Labels, *summaryLabel)
|
||||
|
||||
slog.Info("Created repo-level takedown",
|
||||
@@ -225,10 +283,15 @@ func (s *Server) discoverAndLabelRecords(ctx context.Context, did, repo, src str
|
||||
SubjectDID: did,
|
||||
SubjectRepo: repo,
|
||||
}
|
||||
if err := label.Sign(s.signingKey); err != nil {
|
||||
slog.Warn("Failed to sign label", "uri", uri, "error", err)
|
||||
continue
|
||||
}
|
||||
if _, err := CreateLabel(s.db, label); err != nil {
|
||||
slog.Warn("Failed to create label", "uri", uri, "error", err)
|
||||
continue
|
||||
}
|
||||
s.hub.Broadcast(label)
|
||||
labels = append(labels, *label)
|
||||
}
|
||||
}
|
||||
@@ -256,6 +319,10 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Failed to list takedowns", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
csrf := ""
|
||||
if session := SessionFromContext(r.Context()); session != nil {
|
||||
csrf = session.CSRFToken
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
@@ -301,12 +368,13 @@ form{display:inline}
|
||||
<td>%s</td>
|
||||
<td><code>%s</code></td>
|
||||
<td>%s</td>
|
||||
<td><form method="POST" action="/reverse"><input type="hidden" name="did" value="%s"><input type="hidden" name="repo" value="%s"><button type="submit" class="btn btn-danger" onclick="return confirm('Reverse this takedown?')">Reverse</button></form></td>
|
||||
<td><form method="POST" action="/reverse">%s<input type="hidden" name="did" value="%s"><input type="hidden" name="repo" value="%s"><button type="submit" class="btn btn-danger" onclick="return confirm('Reverse this takedown?')">Reverse</button></form></td>
|
||||
</tr>`,
|
||||
template.HTMLEscapeString(l.SubjectDID),
|
||||
repoDisplay,
|
||||
template.HTMLEscapeString(l.URI),
|
||||
l.Cts.Format("2006-01-02 15:04"),
|
||||
csrfInputHTML(csrf),
|
||||
template.HTMLEscapeString(l.SubjectDID),
|
||||
template.HTMLEscapeString(l.SubjectRepo),
|
||||
)
|
||||
@@ -320,6 +388,10 @@ form{display:inline}
|
||||
func (s *Server) handleTakedownForm(w http.ResponseWriter, r *http.Request) {
|
||||
msg := r.URL.Query().Get("msg")
|
||||
errorMsg := r.URL.Query().Get("error")
|
||||
csrf := ""
|
||||
if session := SessionFromContext(r.Context()); session != nil {
|
||||
csrf = session.CSRFToken
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
@@ -353,15 +425,16 @@ nav{display:flex;gap:16px;margin-bottom:24px}
|
||||
fmt.Fprintf(w, `<div class="error">%s</div>`, template.HTMLEscapeString(errorMsg))
|
||||
}
|
||||
|
||||
fmt.Fprint(w, `
|
||||
fmt.Fprintf(w, `
|
||||
<form method="POST" action="/takedown">
|
||||
%s
|
||||
<label for="target"><strong>Target</strong></label>
|
||||
<input type="text" id="target" name="target" placeholder="atcr.io/r/handle/repo, at://did/collection/rkey, or handle" required>
|
||||
<p class="help">Accepts repo URLs, AT URIs, handles, or DIDs. Omit the repo for a user-level takedown.</p>
|
||||
<input type="text" id="target" name="target" placeholder="/r/handle/repo, /u/handle, at://did/collection/rkey, handle, or did:..." required>
|
||||
<p class="help">Repo: <code>/r/handle/repo</code> (or full atcr.io URL). User-level: <code>/u/handle</code>, a bare handle, or a DID. AT URIs (<code>at://...</code>) also work.</p>
|
||||
<br>
|
||||
<button type="submit" class="btn" onclick="return confirm('Issue takedown? This will suppress the content immediately.')">Issue Takedown</button>
|
||||
</form>
|
||||
</body></html>`)
|
||||
</body></html>`, csrfInputHTML(csrf))
|
||||
}
|
||||
|
||||
func (s *Server) handleTakedownSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -399,12 +472,15 @@ func (s *Server) handleReverse(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
src := s.config.DID()
|
||||
var err error
|
||||
src := s.did
|
||||
var (
|
||||
negs []Label
|
||||
err error
|
||||
)
|
||||
if repo == "" {
|
||||
err = NegateUserLabels(s.db, src, did)
|
||||
negs, err = NegateUserLabels(s.db, s.signingKey, src, did)
|
||||
} else {
|
||||
err = NegateRepoLabels(s.db, src, did, repo)
|
||||
negs, err = NegateRepoLabels(s.db, s.signingKey, src, did, repo)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -413,6 +489,10 @@ func (s *Server) handleReverse(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Reversed takedown", "did", did, "repo", repo)
|
||||
for i := range negs {
|
||||
s.hub.Broadcast(&negs[i])
|
||||
}
|
||||
|
||||
slog.Info("Reversed takedown", "did", did, "repo", repo, "negations", len(negs))
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -5,37 +5,73 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTakedownInput_RepoURL(t *testing.T) {
|
||||
// These tests only exercise parsing logic, not PDS resolution.
|
||||
// ResolveIdentity calls are tested with mock server below.
|
||||
// TestParseTakedownURL exercises the URL-shaped parsing path offline by feeding only
|
||||
// inputs whose identifier is a DID — DIDs short-circuit ResolveIdentity entirely, so
|
||||
// this whole table runs without network. Every case here came up while debugging
|
||||
// browser-paste bugs (URL fragments, query strings, trailing /digest/... segments).
|
||||
func TestParseTakedownURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantDID string
|
||||
wantRepo string
|
||||
wantErr bool
|
||||
}{
|
||||
{"full URL", "https://atcr.io/r/handle/myimage", "myimage"},
|
||||
{"no scheme", "atcr.io/r/handle/myimage", "myimage"},
|
||||
{"handle/repo", "handle/myimage", "myimage"},
|
||||
{"trailing slash", "atcr.io/r/handle/myimage/", "myimage"},
|
||||
{"custom domain", "https://registry.example.com/r/handle/myimage", "myimage"},
|
||||
}
|
||||
// /r/<handle>/<repo>
|
||||
{"r path with scheme", "https://atcr.io/r/did:plc:abc/myimage", "did:plc:abc", "myimage", false},
|
||||
{"r path no scheme", "atcr.io/r/did:plc:abc/myimage", "did:plc:abc", "myimage", false},
|
||||
{"r path trailing slash", "atcr.io/r/did:plc:abc/myimage/", "did:plc:abc", "myimage", false},
|
||||
{"r path custom domain", "https://seamark.dev/r/did:plc:abc/myimage", "did:plc:abc", "myimage", false},
|
||||
{"r path subroute (digest)", "https://atcr.io/r/did:plc:abc/myimage/digest/sha256-deadbeef", "did:plc:abc", "myimage", false},
|
||||
{"r path with hash fragment", "https://atcr.io/r/did:plc:abc/myimage#overview", "did:plc:abc", "myimage", false},
|
||||
{"r path with query string", "https://atcr.io/r/did:plc:abc/myimage?tag=latest", "did:plc:abc", "myimage", false},
|
||||
{"r path missing repo", "https://atcr.io/r/did:plc:abc", "", "", true},
|
||||
|
||||
// /u/<handle>
|
||||
{"u path with scheme", "https://atcr.io/u/did:plc:abc", "did:plc:abc", "", false},
|
||||
{"u path no scheme", "atcr.io/u/did:plc:abc", "did:plc:abc", "", false},
|
||||
{"u path with hash", "https://atcr.io/u/did:plc:abc#tab", "did:plc:abc", "", false},
|
||||
{"u path missing handle", "https://atcr.io/u", "", "", true},
|
||||
|
||||
// Unknown route
|
||||
{"unsupported path", "https://atcr.io/foo/did:plc:abc", "", "", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// These will fail on ResolveIdentity since there's no real PDS,
|
||||
// but we can at least verify the parsing doesn't panic
|
||||
_, err := ParseTakedownInput(context.Background(), tt.input)
|
||||
if err == nil {
|
||||
t.Skip("ResolveIdentity succeeded unexpectedly (network available)")
|
||||
got, err := parseTakedownURL(context.Background(), tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("expected error, got %+v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
// The error should be from resolution, not parsing
|
||||
if err != nil {
|
||||
t.Logf("Expected resolution error: %v", err)
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.DID != tt.wantDID {
|
||||
t.Errorf("DID = %q, want %q", got.DID, tt.wantDID)
|
||||
}
|
||||
if got.Repository != tt.wantRepo {
|
||||
t.Errorf("Repository = %q, want %q", got.Repository, tt.wantRepo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTakedownInput_BareDID(t *testing.T) {
|
||||
// Bare DIDs short-circuit network resolution.
|
||||
got, err := ParseTakedownInput(context.Background(), "did:plc:abc123")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.DID != "did:plc:abc123" {
|
||||
t.Errorf("DID = %q, want did:plc:abc123", got.DID)
|
||||
}
|
||||
if got.Repository != "" {
|
||||
t.Errorf("Repository = %q, want empty (user-level)", got.Repository)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTakedownInput_ATURI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -61,37 +97,32 @@ func TestParseTakedownInput_ATURI(t *testing.T) {
|
||||
"did:plc:xyz",
|
||||
"sha256-deadbeef",
|
||||
},
|
||||
{
|
||||
"AT URI with hash fragment",
|
||||
"at://did:plc:abc#frag",
|
||||
"did:plc:abc",
|
||||
"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input, err := ParseTakedownInput(context.Background(), tt.input)
|
||||
got, err := ParseTakedownInput(context.Background(), tt.input)
|
||||
if err != nil {
|
||||
// Resolution may fail for handle-based AT URIs
|
||||
t.Logf("Parse error (may be expected): %v", err)
|
||||
return
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if input.DID != tt.wantDID {
|
||||
t.Errorf("DID = %q, want %q", input.DID, tt.wantDID)
|
||||
if got.DID != tt.wantDID {
|
||||
t.Errorf("DID = %q, want %q", got.DID, tt.wantDID)
|
||||
}
|
||||
if input.Repository != tt.wantRepo {
|
||||
t.Errorf("Repository = %q, want %q", input.Repository, tt.wantRepo)
|
||||
if got.Repository != tt.wantRepo {
|
||||
t.Errorf("Repository = %q, want %q", got.Repository, tt.wantRepo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTakedownInput_DID(t *testing.T) {
|
||||
// Direct DID input (user-level takedown)
|
||||
input, err := ParseTakedownInput(context.Background(), "at://did:plc:abc123")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if input.DID != "did:plc:abc123" {
|
||||
t.Errorf("DID = %q, want did:plc:abc123", input.DID)
|
||||
}
|
||||
if input.Repository != "" {
|
||||
t.Errorf("Repository = %q, want empty (user-level)", input.Repository)
|
||||
func TestParseTakedownInput_Empty(t *testing.T) {
|
||||
if _, err := ParseTakedownInput(context.Background(), ""); err == nil {
|
||||
t.Error("expected error for empty input")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user