nandi/freeqsay-nimpublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/nandi/freeqsay-nim.git
git clone ssh://git@rickub.com/nandi/freeqsay-nim.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

resolve.nim · 102 lines · 3.7 KBNim Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
## Resolve Bluesky / ATProto handles to DIDs via the public AppView.
## Avatar traits are still a pure function of the DID after resolution.

import std/[httpclient, json, strutils]

const defaultEndpoint* =
  "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle"

type
  HttpResponse* = object
    status*: int        ## 0 means the request never reached the server
    body*: JsonNode
    error*: string

  HttpGet* = proc (url: string): HttpResponse {.gcsafe.}

  Identity* = object
    did*: string
    handle*: string

  ResolveOpts* = object
    endpoint*: string
    httpGet*: HttpGet   ## test hook; nil uses the real transport

func normalizeHandle*(handle: string): string =
  ## Strip @ and whitespace.
  handle.strip().strip(trailing = false, chars = {'@'}).toLowerAscii

func isDid*(s: string): bool =
  ## True if the string is already a DID: `did:<method>:…`, method case-insensitive.
  let t = s.strip()
  if not t.toLowerAscii.startsWith("did:"):
    return false
  var i = 4
  while i < t.len and t[i] in {'a'..'z', 'A'..'Z', '0'..'9'}:
    inc i
  i > 4 and i < t.len and t[i] == ':'

func urlEncode(s: string): string =
  for c in s:
    if c in {'A'..'Z', 'a'..'z', '0'..'9', '_', '.', '~', '-'}:
      result.add c
    else:
      result.add '%' & toHex(int(uint8(c)), 2)

proc httpGetJson(url: string): HttpResponse {.gcsafe.} =
  ## GET `url` and parse the body as JSON. `status: 0` means the request never
  ## reached the server — the transport itself failed — and `error` says why.
  {.cast(gcsafe).}:
    try:
      let client = newHttpClient(timeout = 15_000)
      defer: client.close()
      let resp = client.get(url)
      # `status` is "200 OK". Body parsing gets its own try: an error page from
      # the AppView must stay an HTTP failure, not look like a dead transport.
      let status = parseInt(resp.status.split(' ')[0])
      var body: JsonNode
      try: body = parseJson(resp.body) except CatchableError: discard
      HttpResponse(status: status, body: body)
    except CatchableError as e:
      HttpResponse(status: 0, error: e.msg)

proc resolveHandle*(handle: string, opts = ResolveOpts()): string =
  ## Resolve a handle to its DID. Raises if the handle cannot be resolved.
  let clean = normalizeHandle(handle)
  if clean.len == 0:
    raise newException(ValueError, "empty handle")
  if isDid(clean):
    return clean.strip()

  let
    endpoint = if opts.endpoint.len > 0: opts.endpoint else: defaultEndpoint
    url = endpoint & "?handle=" & urlEncode(clean)
    get = if opts.httpGet != nil: opts.httpGet else: HttpGet(httpGetJson)
    resp = get(url)

  if resp.status == 0:
    # No HTTP status at all: the request never left the machine. Saying "is
    # that a real handle?" here blames the user for our own missing transport.
    # Name it, and point at the offline path.
    raise newException(IOError,
      "couldn't reach the Bluesky API to resolve @" & clean &
      (if resp.error.len > 0: "" & resp.error else: "") &
      "\nPass the DID directly (freeqsay did:plc:… …) to skip resolution.")
  if resp.status != 200:
    raise newException(IOError,
      "couldn't resolve @" & clean & " — is that a real Bluesky handle? (" &
      $resp.status & ")")

  let did = if resp.body != nil and resp.body.kind == JObject and
                resp.body.hasKey("did"): resp.body["did"].getStr else: ""
  if did.len == 0 or not isDid(did):
    raise newException(IOError, "resolveHandle returned no DID for @" & clean)
  did

proc resolveIdentity*(handleOrDid: string, opts = ResolveOpts()): Identity =
  ## Accept either a handle or a DID; resolve handles, pass DIDs through.
  let raw = handleOrDid.strip()
  if isDid(raw):
    Identity(did: raw)
  else:
    Identity(did: resolveHandle(raw, opts), handle: normalizeHandle(raw))