clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / tinyagent / client / client.go
client.go 16.2K
1// Package client provides the native Tinyrelay transport used by tinyagent.2package client34import (5 "bytes"6 "context"7 "crypto/rand"8 "crypto/sha256"9 "encoding/base64"10 "encoding/hex"11 "encoding/json"12 "errors"13 "fmt"14 "io"15 "net/http"16 "net/url"17 "path"18 "strings"19 "time"2021 "github.com/FelineStateMachine/tinyrelay/protocol/nostr"22 "github.com/coder/websocket"23)2425const maxDownload = 32 << 2026const maxQueryEvents = 100002728type Client struct {29 BaseURL, Secret, PubKey string30 HTTP *http.Client31}32type Filter map[string]any33type UploadResult map[string]any3435func New(base, secret string) (*Client, error) {36 u, err := normalizeBase(base)37 if err != nil {38 return nil, err39 }40 pub, err := nostr.PublicKey(secret)41 if err != nil {42 return nil, err43 }44 return &Client{BaseURL: u, Secret: secret, PubKey: pub, HTTP: &http.Client{CheckRedirect: rejectExternalRedirect}}, nil45}4647func (c *Client) Identity() map[string]string { return map[string]string{"pubkey": c.PubKey} }4849func (c *Client) Publish(ctx context.Context, e nostr.Event) (nostr.Event, error) {50 if e.CreatedAt == 0 {51 e.CreatedAt = time.Now().Unix()52 }53 if err := nostr.Sign(&e, c.Secret); err != nil {54 return nostr.Event{}, err55 }56 body, err := nostr.Canonical(e)57 if err != nil {58 return nostr.Event{}, err59 }60 result, err := c.doRaw(ctx, http.MethodPost, "/events", body)61 if err != nil {62 return nostr.Event{}, err63 }64 var receipt struct {65 Accepted *bool `json:"accepted"`66 Message string `json:"message"`67 }68 if err := json.Unmarshal(result, &receipt); err != nil || receipt.Accepted == nil {69 return nostr.Event{}, errors.New("relay returned an invalid publish receipt")70 }71 if !*receipt.Accepted {72 if receipt.Message == "" {73 receipt.Message = "relay rejected event"74 }75 return nostr.Event{}, errors.New(receipt.Message)76 }77 return e, nil78}7980func (c *Client) Request(ctx context.Context, requestPath, method string, body json.RawMessage) (any, error) {81 if method == "" {82 method = http.MethodGet83 }84 if !validMethod(method) {85 return nil, errors.New("invalid HTTP method")86 }87 b, err := c.doRaw(ctx, method, requestPath, body)88 if err != nil {89 return nil, err90 }91 var out any92 if err := json.Unmarshal(b, &out); err != nil {93 return nil, fmt.Errorf("decode response: %w", err)94 }95 return out, nil96}9798func (c *Client) Upload(ctx context.Context, room, filename, typ string, data []byte) (UploadResult, error) {99 if room == "" || len(data) == 0 || len(data) > maxDownload {100 return nil, errors.New("invalid upload")101 }102 p := "/rooms/" + url.PathEscape(room) + "/attachments?filename=" + url.QueryEscape(path.Base(filename))103 b, err := c.doRawWithHeaders(ctx, http.MethodPut, p, data, map[string]string{"Content-Type": typ, "X-Filename": filename})104 if err != nil {105 return nil, err106 }107 var out UploadResult108 if err := json.Unmarshal(b, &out); err != nil {109 return nil, fmt.Errorf("decode upload: %w", err)110 }111 return out, nil112}113114func (c *Client) Download(ctx context.Context, requestPath string) (map[string]any, error) {115 b, typ, err := c.getBounded(ctx, requestPath)116 if err != nil {117 return nil, err118 }119 return map[string]any{"data": base64.StdEncoding.EncodeToString(b), "type": typ}, nil120}121122func (c *Client) Query(ctx context.Context, filters []Filter) ([]nostr.Event, error) {123 return c.queryWebsocket(ctx, filters)124}125126type EventSink func(string, nostr.Event)127128func (c *Client) Subscribe(ctx context.Context, name string, filter Filter, sink EventSink) error {129 delay := time.Second130 for ctx.Err() == nil {131 err := c.subscribeOnce(ctx, name, filter, sink)132 if ctx.Err() != nil {133 return ctx.Err()134 }135 if err == nil {136 delay = time.Second137 } else {138 delay = min(delay*2, 30*time.Second)139 }140 timer := time.NewTimer(delay)141 select {142 case <-ctx.Done():143 timer.Stop()144 return ctx.Err()145 case <-timer.C:146 }147 }148 return ctx.Err()149}150151func (c *Client) subscribeOnce(ctx context.Context, name string, filter Filter, sink EventSink) error {152 wsURL, err := websocketURL(c.BaseURL)153 if err != nil {154 return err155 }156 conn, _, err := websocket.Dial(ctx, wsURL, nil)157 if err != nil {158 return err159 }160 defer conn.CloseNow()161 conn.SetReadLimit(1 << 20)162 if err := writeFrame(ctx, conn, []any{"REQ", name, filter}); err != nil {163 return err164 }165 connected := false166 authRequired := false167 authenticated := false168 emitConnected := func() {169 if !connected && sink != nil {170 sink("connected", nostr.Event{})171 connected = true172 }173 }174 authID := ""175 for {176 _, raw, err := conn.Read(ctx)177 if err != nil {178 return err179 }180 var frame []json.RawMessage181 if json.Unmarshal(raw, &frame) != nil || len(frame) < 2 {182 continue183 }184 var kind string185 _ = json.Unmarshal(frame[0], &kind)186 switch kind {187 case "AUTH":188 authRequired = true189 var challenge string190 if json.Unmarshal(frame[1], &challenge) != nil {191 return errors.New("invalid AUTH challenge")192 }193 authRelay, relayErr := websocketURL(c.BaseURL)194 if relayErr != nil {195 return relayErr196 }197 auth := nostr.Event{Kind: 22242, CreatedAt: time.Now().Unix(), Tags: [][]string{{"relay", authRelay}, {"challenge", challenge}}}198 if err := nostr.Sign(&auth, c.Secret); err != nil {199 return err200 }201 if err := writeFrame(ctx, conn, []any{"AUTH", auth}); err != nil {202 return err203 }204 authID = auth.ID205 // Tiny may have already evaluated the unauthenticated REQ. Reissue it206 // after AUTH so private filters are evaluated in the authenticated session.207 if err := writeFrame(ctx, conn, []any{"REQ", name, filter}); err != nil {208 return err209 }210 case "OK":211 if len(frame) >= 3 {212 var id string213 var accepted bool214 _ = json.Unmarshal(frame[1], &id)215 _ = json.Unmarshal(frame[2], &accepted)216 if id == authID {217 if !accepted {218 return errors.New("relay rejected AUTH")219 }220 authenticated = true221 }222 }223 case "EVENT":224 if len(frame) != 3 {225 continue226 }227 var e nostr.Event228 if json.Unmarshal(frame[2], &e) != nil || nostr.Validate(e) != nil {229 continue230 }231 var sub string232 _ = json.Unmarshal(frame[1], &sub)233 if sink != nil && sub == name && eventMatches(e, filter) && (!authRequired || authenticated) {234 emitConnected()235 sink(sub, e)236 }237 case "EOSE":238 var sub string239 _ = json.Unmarshal(frame[1], &sub)240 if sub == name && (!authRequired || authenticated) {241 emitConnected()242 }243 case "CLOSED":244 var reason string245 if len(frame) > 2 {246 _ = json.Unmarshal(frame[2], &reason)247 }248 if strings.HasPrefix(reason, "auth-required:") && !authenticated {249 continue250 }251 return errors.New("subscription closed")252 }253 }254}255256func (c *Client) queryWebsocket(ctx context.Context, filters []Filter) ([]nostr.Event, error) {257 wsURL, err := websocketURL(c.BaseURL)258 if err != nil {259 return nil, err260 }261 conn, _, err := websocket.Dial(ctx, wsURL, nil)262 if err != nil {263 return nil, err264 }265 defer conn.CloseNow()266 conn.SetReadLimit(1 << 20)267 name := "tinyagent-query"268 if err := writeQuery(ctx, conn, name, filters); err != nil {269 return nil, err270 }271 var out []nostr.Event272 awaitingAuth := false273 authID := ""274 for {275 _, raw, readErr := conn.Read(ctx)276 if readErr != nil {277 return nil, readErr278 }279 var frame []json.RawMessage280 if json.Unmarshal(raw, &frame) != nil || len(frame) < 2 {281 continue282 }283 var kind string284 _ = json.Unmarshal(frame[0], &kind)285 if kind == "AUTH" {286 var challenge string287 if json.Unmarshal(frame[1], &challenge) != nil {288 return nil, errors.New("invalid AUTH challenge")289 }290 authRelay, relayErr := websocketURL(c.BaseURL)291 if relayErr != nil {292 return nil, relayErr293 }294 auth := nostr.Event{Kind: 22242, CreatedAt: time.Now().Unix(), Tags: [][]string{{"relay", authRelay}, {"challenge", challenge}}}295 if err := nostr.Sign(&auth, c.Secret); err != nil {296 return nil, err297 }298 if err := writeFrame(ctx, conn, []any{"AUTH", auth}); err != nil {299 return nil, err300 }301 authID = auth.ID302 awaitingAuth = true303 if err := writeQuery(ctx, conn, name, filters); err != nil {304 return nil, err305 }306 continue307 }308 if kind == "OK" && len(frame) >= 3 {309 var id string310 var accepted bool311 _ = json.Unmarshal(frame[1], &id)312 _ = json.Unmarshal(frame[2], &accepted)313 if id == authID {314 if !accepted {315 return nil, errors.New("relay rejected AUTH")316 }317 awaitingAuth = false318 }319 }320 if kind == "EVENT" && len(frame) == 3 {321 var sub string322 if json.Unmarshal(frame[1], &sub) != nil || sub != name {323 continue324 }325 var e nostr.Event326 if json.Unmarshal(frame[2], &e) == nil && nostr.Validate(e) == nil && queryMatches(e, filters) {327 if len(out) >= maxQueryEvents {328 return nil, errors.New("query exceeds 10000 events")329 }330 out = append(out, e)331 }332 }333 if kind == "EOSE" {334 if awaitingAuth {335 continue336 }337 return out, nil338 }339 if kind == "CLOSED" {340 var reason string341 if len(frame) > 2 {342 _ = json.Unmarshal(frame[2], &reason)343 }344 if strings.HasPrefix(reason, "auth-required:") && awaitingAuth {345 continue346 }347 return nil, errors.New("query closed")348 }349 }350}351352func (c *Client) doJSON(ctx context.Context, method, p string, body []byte, out any) error {353 raw, err := c.doRaw(ctx, method, p, body)354 if err != nil {355 return err356 }357 return json.Unmarshal(raw, out)358}359func (c *Client) doRaw(ctx context.Context, method, p string, body []byte) ([]byte, error) {360 return c.doRawWithHeaders(ctx, method, p, body, map[string]string{"Content-Type": "application/json"})361}362func (c *Client) doRawWithHeaders(ctx context.Context, method, p string, body []byte, headers map[string]string) ([]byte, error) {363 u, err := c.requestURL(p)364 if err != nil {365 return nil, err366 }367 proof, err := c.proof(method, u.String(), body)368 if err != nil {369 return nil, err370 }371 req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(body))372 if err != nil {373 return nil, err374 }375 req.Header.Set("Authorization", proof)376 for k, v := range headers {377 if v != "" {378 req.Header.Set(k, v)379 }380 }381 if method == http.MethodPost && u.Path == "/manage/rpc" {382 req.Header.Set("Content-Type", "application/nostr+json+rpc")383 }384 resp, err := c.httpClient().Do(req)385 if err != nil {386 return nil, err387 }388 defer resp.Body.Close()389 data, err := io.ReadAll(io.LimitReader(resp.Body, maxDownload+1))390 if err != nil {391 return nil, err392 }393 if len(data) > maxDownload {394 return nil, errors.New("response exceeds 32 MiB")395 }396 if resp.StatusCode/100 != 2 {397 return nil, fmt.Errorf("relay HTTP %s: %s", resp.Status, strings.TrimSpace(string(data)))398 }399 return data, nil400}401402func (c *Client) getBounded(ctx context.Context, p string) ([]byte, string, error) {403 u, err := c.requestURL(p)404 if err != nil {405 return nil, "", err406 }407 proof, err := c.proof(http.MethodGet, u.String(), nil)408 if err != nil {409 return nil, "", err410 }411 req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)412 if err != nil {413 return nil, "", err414 }415 req.Header.Set("Authorization", proof)416 resp, err := c.httpClient().Do(req)417 if err != nil {418 return nil, "", err419 }420 defer resp.Body.Close()421 b, err := io.ReadAll(io.LimitReader(resp.Body, maxDownload+1))422 if err != nil {423 return nil, "", err424 }425 if len(b) > maxDownload {426 return nil, "", errors.New("response exceeds 32 MiB")427 }428 if resp.StatusCode/100 != 2 {429 return nil, "", fmt.Errorf("relay HTTP %s: %s", resp.Status, strings.TrimSpace(string(b)))430 }431 return b, resp.Header.Get("Content-Type"), nil432}433func (c *Client) proof(method, raw string, body []byte) (string, error) {434 nonce := make([]byte, 12)435 if _, err := rand.Read(nonce); err != nil {436 return "", fmt.Errorf("generate request nonce: %w", err)437 }438 tags := [][]string{{"u", raw}, {"method", strings.ToUpper(method)}, {"nonce", hex.EncodeToString(nonce)}}439 if len(body) > 0 {440 sum := sha256.Sum256(body)441 tags = append(tags, []string{"payload", hex.EncodeToString(sum[:])})442 }443 e := nostr.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Tags: tags}444 if err := nostr.Sign(&e, c.Secret); err != nil {445 return "", err446 }447 b, err := nostr.Canonical(e)448 if err != nil {449 return "", err450 }451 return "Nostr " + base64.StdEncoding.EncodeToString(b), nil452}453func (c *Client) requestURL(p string) (*url.URL, error) {454 if p == "" || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {455 return nil, errors.New("path must be same-origin and absolute")456 }457 u, err := url.Parse(p)458 if err != nil || strings.Contains(p, "..") || strings.Contains(u.Path, "..") {459 return nil, errors.New("unsafe path")460 }461 base, _ := url.Parse(c.BaseURL)462 u.Scheme, u.Host = base.Scheme, base.Host463 if base.Path != "" && base.Path != "/" {464 u.Path = strings.TrimRight(base.Path, "/") + u.Path465 }466 return u, nil467}468func (c *Client) httpClient() *http.Client {469 if c.HTTP != nil {470 return c.HTTP471 }472 return http.DefaultClient473}474475func rejectExternalRedirect(req *http.Request, via []*http.Request) error {476 if len(via) == 0 || req.URL.Scheme == via[0].URL.Scheme && req.URL.Host == via[0].URL.Host {477 return nil478 }479 return errors.New("external redirects are forbidden")480}481func normalizeBase(raw string) (string, error) {482 u, err := url.Parse(raw)483 if err != nil || u.Host == "" || u.User != nil || u.Fragment != "" || (u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "ws" && u.Scheme != "wss") {484 return "", errors.New("relay must be an http(s) or ws(s) URL")485 }486 if u.Scheme == "ws" {487 u.Scheme = "http"488 }489 if u.Scheme == "wss" {490 u.Scheme = "https"491 }492 return strings.TrimRight(u.String(), "/"), nil493}494func websocketURL(raw string) (string, error) {495 u, err := url.Parse(raw)496 if err != nil || u.Host == "" {497 return "", errors.New("invalid relay URL")498 }499 if u.Scheme == "http" {500 u.Scheme = "ws"501 } else if u.Scheme == "https" {502 u.Scheme = "wss"503 } else if u.Scheme != "ws" && u.Scheme != "wss" {504 return "", errors.New("relay must be HTTP or WebSocket")505 }506 return u.String(), nil507}508func writeFrame(ctx context.Context, conn *websocket.Conn, v any) error {509 b, err := json.Marshal(v)510 if err != nil {511 return err512 }513 return conn.Write(ctx, websocket.MessageText, b)514}515516func writeQuery(ctx context.Context, conn *websocket.Conn, name string, filters []Filter) error {517 args := make([]any, 2, len(filters)+2)518 args[0], args[1] = "REQ", name519 for _, filter := range filters {520 args = append(args, filter)521 }522 return writeFrame(ctx, conn, args)523}524func validateEvents(events []nostr.Event) ([]nostr.Event, error) {525 for _, e := range events {526 if err := nostr.Validate(e); err != nil {527 return nil, err528 }529 }530 return events, nil531}532533func eventMatches(e nostr.Event, filter Filter) bool { return queryMatches(e, []Filter{filter}) }534535func queryMatches(e nostr.Event, filters []Filter) bool {536 if len(filters) == 0 {537 return true538 }539 for _, filter := range filters {540 if filterMatches(e, filter) {541 return true542 }543 }544 return false545}546547func filterMatches(e nostr.Event, filter Filter) bool {548 if !matchesStrings(filter["ids"], e.ID) || !matchesStrings(filter["authors"], e.PubKey) || !matchesInts(filter["kinds"], e.Kind) {549 return false550 }551 if n, ok := filterInt64(filter["since"]); ok && e.CreatedAt < n {552 return false553 }554 if n, ok := filterInt64(filter["until"]); ok && e.CreatedAt > n {555 return false556 }557 for key, value := range filter {558 if strings.HasPrefix(key, "#") && !matchesTag(e, key[1:], value) {559 return false560 }561 }562 return true563}564565func matchesStrings(raw any, want string) bool {566 if raw == nil {567 return true568 }569 values, ok := raw.([]any)570 if !ok {571 return false572 }573 for _, value := range values {574 if value == want {575 return true576 }577 }578 return false579}580581func matchesInts(raw any, want int) bool {582 if raw == nil {583 return true584 }585 values, ok := raw.([]any)586 if !ok {587 return false588 }589 for _, value := range values {590 if number, ok := value.(float64); ok && int(number) == want {591 return true592 }593 }594 return false595}596597func filterInt64(raw any) (int64, bool) {598 number, ok := raw.(float64)599 return int64(number), ok600}601602func matchesTag(e nostr.Event, name string, raw any) bool {603 values, ok := raw.([]any)604 if !ok {605 return false606 }607 for _, tag := range e.Tags {608 if len(tag) < 2 || tag[0] != name {609 continue610 }611 for _, value := range values {612 if value == tag[1] {613 return true614 }615 }616 }617 return false618}619func validMethod(method string) bool {620 return method == http.MethodGet || method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch || method == http.MethodDelete || method == http.MethodHead621}622func min(a, b time.Duration) time.Duration {623 if a < b {624 return a625 }626 return b627}