Add blob service and support for signing and verifying HTTP requests (#178)

Alternative to #177

The way this works is:

Client:

- Client creates a normal `net/http.Request{}` object using the `Request()` function in `utils.go`. The `http.Request{}` object is then signed using the Client's Ed25519 private key.
- The HTTP Method and Path (_note this is important_) are hashed, as well as the request body (if any) using the FNV128a hashing algorithm.
- This hash is then signed by the Client's's Ed25519 private key.
- The resulting signature is then encoded to Base64 (_standard encoding_) and added to the HTTP headers as a `Signature:` header.
- In addition the Client's Ed25519 public key is added to the HTTP headers as `Signer:`

Server:

- The server calculates the same FNV128a hash of the HTTP Request Method and Path and the body (if any)
- The server decodes the HTTP header `Signature:`
- The server then uses the Client's Ed25519 public key in the HTTP header `Signer:` to verify the signature of the `Signature:` HTTP header which gives us back the original FNV128a hash the Client calculated for the request.
- The server then compares the Client's hash with the expected hash to see if they compare equally.

Co-authored-by: James Mills <1290234+prologic@users.noreply.github.com>
Co-authored-by: Jon Lundy <jon@xuu.cc>
Reviewed-on: https://git.mills.io/saltyim/saltyim/pulls/178
Reviewed-by: xuu <xuu@noreply@mills.io>
This commit is contained in:
James Mills 2023-01-25 23:05:29 +00:00
parent 96d8afcbef
commit ddd16c202f
32 changed files with 1140 additions and 170 deletions

1
.gitignore vendored
View File

@ -23,6 +23,7 @@
/data/*.json
/data/logs
/data/acme
/data/blobs
/data/avatars
/data/.well-known

View File

@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
@ -95,9 +96,9 @@ func PackMessageTime(me *Addr, msg string, t *lextwt.DateTime) []byte {
// Client is a Salty IM client that handles talking to a Salty IM Broker
// and Sedngina and Receiving messages to/from Salty IM Users.
type Client struct {
me *Addr
id *Identity
key *keys.EdX25519Key
me *Addr
id *Identity
cache addrCache
state *State
@ -111,9 +112,8 @@ type ClientOption func(cli *Client) error
// NewClient reeturns a new Salty IM client for sending and receiving
// encrypted messages to other Salty IM users as well as decrypting
// and displaying messages of the user's own inbox.
func NewClient(me *Addr, options ...ClientOption) (*Client, error) {
func NewClient(options ...ClientOption) (*Client, error) {
cli := &Client{
me: me,
cache: make(addrCache),
lookup: &DirectLookup{},
send: &DirectSend{},
@ -190,7 +190,7 @@ func (cli *Client) processMessage(msg *msgbus.Message, extraenvs, prehook, posth
log.Debugf("pre-hook: %q", out)
}
unencrypted, senderKey, err := salty.Decrypt(cli.key, msg.Payload)
unencrypted, senderKey, err := salty.Decrypt(cli.id.key, msg.Payload)
if err != nil {
return Message{}, fmt.Errorf("error decrypting message: %w", err)
}
@ -216,8 +216,8 @@ func (cli *Client) messageHandler(extraenvs, prehook, posthook string, msgs chan
// Me returns our (self) address
func (cli *Client) Me() *Addr { return cli.me }
// Key returns out (self) public key
func (cli *Client) Key() *keys.EdX25519PublicKey { return cli.key.PublicKey() }
// Key returns our (self) public key
func (cli *Client) Key() *keys.EdX25519PublicKey { return cli.id.key.PublicKey() }
// State returns the current state of the client
func (cli *Client) State() *State { return cli.state }
@ -312,9 +312,8 @@ func (cli *Client) OutboxClient(to *Addr) *Client {
capabilities: cli.me.capabilities,
checkedAvatar: cli.me.checkedAvatar,
},
key: cli.key,
key: cli.id.key,
},
key: cli.key,
cache: cli.cache,
state: cli.state,
lookup: cli.lookup,
@ -329,7 +328,7 @@ func (cli *Client) String() string {
fmt.Fprintln(b, "Me: ", cli.me)
fmt.Fprintln(b, "Endpoint: ", cli.me.Endpoint())
fmt.Fprintln(b, "Outbox: ", cli.Outbox())
fmt.Fprintln(b, "Key: ", cli.key)
fmt.Fprintln(b, "Key: ", cli.id.key)
return b.String()
}
@ -422,14 +421,14 @@ func (cli *Client) SendToAddr(addr *Addr, msg string) error {
return ErrNoSender
}
b, err := salty.Encrypt(cli.key, PackMessage(cli.me, msg), []string{addr.key.ID().String()})
b, err := salty.Encrypt(cli.id.key, PackMessage(cli.me, msg), []string{addr.key.ID().String()})
if err != nil {
return fmt.Errorf("error encrypting message to %s: %w", addr, err)
}
endpoint := addr.Endpoint().String()
log.Debugf("sending message to %s", endpoint)
if err := cli.send.Send(cli.key, endpoint, string(b), addr.Cap()); err != nil {
if err := cli.send.Send(cli.id.key, endpoint, string(b), addr.Cap()); err != nil {
return fmt.Errorf("error sending message to %s: %w", addr, err)
}
@ -457,7 +456,7 @@ func (cli *Client) Register(brokerURI string) error {
if err != nil {
return fmt.Errorf("error serializing register request: %w", err)
}
signed, err := salty.Sign(cli.key, data)
signed, err := salty.Sign(cli.id.key, data)
if err != nil {
return fmt.Errorf("error signing register request: %w", err)
}
@ -479,7 +478,7 @@ func (cli *Client) SetAvatar(content []byte) error {
if err != nil {
return fmt.Errorf("error serializing avatar request: %w", err)
}
signed, err := salty.Sign(cli.key, data)
signed, err := salty.Sign(cli.id.key, data)
if err != nil {
return fmt.Errorf("error signing avatar request: %w", err)
}
@ -495,3 +494,18 @@ func (cli *Client) SetAvatar(content []byte) error {
return nil
}
// Request makes a signed request to a broker's API.
func (cli *Client) Request(method, endpoint string, body []byte) ([]byte, error) {
// TODO: Automatically work out the URI based on SRV lookups of the user's address
u := cli.Me().Endpoint()
u.Path = endpoint
res, err := SignedRequest(cli.id.key, method, u.String(), nil, bytes.NewBuffer(body))
if err != nil {
return nil, fmt.Errorf("error making %s request to %s: %w", method, u, err)
}
defer res.Body.Close()
return io.ReadAll(res.Body)
}

View File

@ -19,12 +19,13 @@ func TestClient_InvalidEndpoint(t *testing.T) {
require.NoError(err)
assert.NotNil(me)
_, err = NewClient(me)
assert.Error(err)
_, err = NewClient(WithAddr(me))
require.Error(err)
}
func TestClient_Outbox(t *testing.T) {
test := require.New(t)
assert := assert.New(t)
require := require.New(t)
endpoint := &url.URL{Host: "example.com", Path: "/path", Scheme: "https"}
key := keys.GenerateEdX25519Key()
@ -32,14 +33,13 @@ func TestClient_Outbox(t *testing.T) {
client := &Client{me: &Addr{endpoint: endpoint}, id: &Identity{key: key}}
outbox := client.Outbox()
test.True(endpoint.Path == "/path",
require.True(endpoint.Path == "/path",
"endpoint.Path should not be modified after call to client.Outbox()")
test.False(*endpoint == *outbox,
require.False(*endpoint == *outbox,
"endpoint and outbox should not point to the same *url.URL")
expected := fmt.Sprintf("/%x", sha256.Sum256(key.Private()))
test.True(outbox.Path == expected, "expected %s but got %s", expected, outbox.Path)
assert.Equal(expected, outbox.Path, "expected %s but got %s", expected, outbox.Path)
}
func TestClient_Outbox_State(t *testing.T) {

View File

@ -4,7 +4,6 @@ import (
"fmt"
"os"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.mills.io/saltyim"
@ -19,24 +18,24 @@ on a Salty Broker (an instance of saltyd).
NOTE: This is only spported on a Salty Broker.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
user := viper.GetString("user")
identity := viper.GetString("identity")
var profiles []profile
viper.UnmarshalKey("profiles", &profiles)
for _, p := range profiles {
if user == p.User {
identity = p.Identity
}
}
me, err := saltyim.ParseAddr(user)
id, err := saltyim.GetIdentity(
saltyim.WithIdentityPath(viper.GetString("identity")),
)
if err != nil {
log.Debugf("error parsing addr: %s\n", err)
fmt.Fprintf(os.Stderr, "error loading identity: %s\n", err)
os.Exit(2)
}
// XXX: What if me.IsZero()
setavatar(me, identity, args[0])
cli, err := saltyim.NewClient(
saltyim.WithIdentity(id),
saltyim.WithUser(viper.GetString("user")),
)
if err != nil {
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
os.Exit(2)
}
setavatar(cli, args[0])
},
}
@ -44,13 +43,7 @@ func init() {
rootCmd.AddCommand(setavatarCmd)
}
func setavatar(me *saltyim.Addr, identity, fn string) {
cli, err := saltyim.NewClient(me, saltyim.WithClientIdentity(saltyim.WithIdentityPath(identity)))
if err != nil {
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
os.Exit(2)
}
func setavatar(cli *saltyim.Client, fn string) {
data, err := os.ReadFile(fn)
if err != nil {
fmt.Fprintf(os.Stderr, "error reading avatar file %s: %s", fn, err)

View File

@ -21,28 +21,30 @@ and subscribing to your endpoint and prompts for input and sends encrypted
messages to the user via their discovered endpoint.`,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
user := viper.GetString("user")
identity := viper.GetString("identity")
id, err := saltyim.GetIdentity(
saltyim.WithIdentityPath(viper.GetString("identity")),
)
if err != nil {
fmt.Fprintf(os.Stderr, "error loading identity: %s\n", err)
os.Exit(2)
}
state := viper.GetString("state")
var profiles []profile
viper.UnmarshalKey("profiles", &profiles)
for _, p := range profiles {
if user == p.User {
identity = p.Identity
}
}
me, err := saltyim.ParseAddr(user)
cli, err := saltyim.NewClient(
saltyim.WithIdentity(id),
saltyim.WithStateFromFile(state),
saltyim.WithUser(viper.GetString("user")),
)
if err != nil {
log.Debugf("error parsing addr: %s\n", err)
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
os.Exit(2)
}
// XXX: What if me.IsZero()
if len(args) == 1 {
chat(me, identity, state, args[0])
chat(cli, state, args[0])
} else {
chat(me, identity, state, "")
chat(cli, state, "")
}
},
}
@ -52,15 +54,7 @@ func init() {
rootCmd.AddCommand(chatCmd)
}
func chat(me *saltyim.Addr, identity, state, user string) {
cli, err := saltyim.NewClient(me,
saltyim.WithStateFromFile(state),
saltyim.WithClientIdentity(saltyim.WithIdentityPath(identity)),
)
if err != nil {
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
os.Exit(2)
}
func chat(cli *saltyim.Client, state, user string) {
defer func() {
if err := cli.State().Save(state); err != nil {
log.WithError(err).Warnf("error saving state: %s", state)

View File

@ -21,23 +21,24 @@ var readCmd = &cobra.Command{
Long: `This command subscribes to the provided inbox (optiona) which if
not specified defaults to the local user ($USER)`,
Run: func(cmd *cobra.Command, args []string) {
user := viper.GetString("user")
identity := viper.GetString("identity")
state := viper.GetString("state")
var profiles []profile
viper.UnmarshalKey("profiles", &profiles)
for _, p := range profiles {
if user == p.User {
identity = p.Identity
}
}
me, err := saltyim.ParseAddr(user)
id, err := saltyim.GetIdentity(
saltyim.WithIdentityPath(viper.GetString("identity")),
)
if err != nil {
log.Debugf("error parsing addr: %s\n", err)
fmt.Fprintf(os.Stderr, "error loading identity: %s\n", err)
os.Exit(2)
}
// XXX: What if me.IsZero()
cli, err := saltyim.NewClient(
saltyim.WithIdentity(id),
saltyim.WithUser(viper.GetString("user")),
)
if err != nil {
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
os.Exit(2)
}
state := viper.GetString("state")
follow, err := cmd.Flags().GetBool("follow")
if err != nil {
@ -59,7 +60,7 @@ not specified defaults to the local user ($USER)`,
log.Fatal("error getting --post-hook flag")
}
read(me, identity, state, follow, extraenvs, prehook, posthook, args...)
read(cli, state, follow, extraenvs, prehook, posthook, args...)
},
}
@ -92,15 +93,7 @@ func init() {
)
}
func read(me *saltyim.Addr, identity, state string, follow bool, extraenvs, prehook, posthook string, args ...string) {
cli, err := saltyim.NewClient(me,
saltyim.WithStateFromFile(state),
saltyim.WithClientIdentity(saltyim.WithIdentityPath(identity)),
)
if err != nil {
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
os.Exit(2)
}
func read(cli *saltyim.Client, state string, follow bool, extraenvs, prehook, posthook string, args ...string) {
defer func() {
if err := cli.State().Save(state); err != nil {
log.WithError(err).Warnf("error saving state: %s", state)

View File

@ -64,7 +64,7 @@ func register(me *saltyim.Addr, identity, broker string) {
os.Exit(2)
}
cli, err := saltyim.NewClient(me, saltyim.WithClientIdentity(saltyim.WithIdentity(id)))
cli, err := saltyim.NewClient(saltyim.WithIdentity(id))
if err != nil {
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
os.Exit(2)

83
cmd/salty-chat/request.go Normal file
View File

@ -0,0 +1,83 @@
package main
import (
"fmt"
"io"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.mills.io/saltyim"
)
var requestCmd = &cobra.Command{
Aliases: []string{"req"},
Use: "request <method> <endpoint> [<file>|-]",
Short: "Makes a signed request to an API endpoint on a Salty Broker",
Long: `This command creates and makes a signed request to an API endpoint
on a Salty Broker (an instance of saltyd). A valid HTTP method such as GET, PUT, POST, DELETE or HEAD
is provided as the first argument and a valid API endpoint path such as /api/v1/blob -- The 3rd argument
is optional and if supplifed must either be a path to a filename containing the payload of the request,
- for reading from stdin, or if omitted will read from stdin.
This is mostly useful in debugging and development of the Salty Broker API and Client.
NOTE: This is only spported on a Salty Broker.`,
Args: cobra.MinimumNArgs(2),
Run: func(cmd *cobra.Command, args []string) {
id, err := saltyim.GetIdentity(
saltyim.WithIdentityPath(viper.GetString("identity")),
)
if err != nil {
fmt.Fprintf(os.Stderr, "error loading identity: %s\n", err)
os.Exit(2)
}
cli, err := saltyim.NewClient(
saltyim.WithIdentity(id),
saltyim.WithUser(viper.GetString("user")),
)
if err != nil {
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
os.Exit(2)
}
request(cli, args[:])
},
}
func init() {
rootCmd.AddCommand(requestCmd)
}
func request(cli *saltyim.Client, args []string) {
var (
body []byte
err error
)
method := args[0]
endpoint := args[1]
if len(args) == 3 {
if args[2] == "-" {
body, err = io.ReadAll(os.Stdin)
} else {
body, err = os.ReadFile(args[2])
}
} else {
body = nil
}
if err != nil {
fmt.Fprintf(os.Stderr, "error reading request payload: %s", err)
os.Exit(2)
}
data, err := cli.Request(method, endpoint, body)
if err != nil {
fmt.Fprintf(os.Stderr, "error making request: %s\n", err)
os.Exit(2)
}
fmt.Println(string(data))
}

View File

@ -42,6 +42,29 @@ See https://salty.im for more details.`,
// Disable deadlock detection in production mode
sync.Opts.Disable = true
}
identity := viper.GetString("identity")
log.Debugf("identity: %s", identity)
user := viper.GetString("user")
log.Debugf("user: %s", user)
var profiles []profile
viper.UnmarshalKey("profiles", &profiles)
log.Debugf("profiles: %#v", profiles)
for _, p := range profiles {
if user == p.User {
identity = p.Identity
}
}
log.Debugf("identity: %s", identity)
me, err := saltyim.ParseAddr(user)
if err != nil {
log.Debugf("error parsing addr: %s\n", err)
}
log.Debugf("me: %s", me)
// XXX: What if me.IsZero()
},
}

View File

@ -6,7 +6,6 @@ import (
"os"
"strings"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@ -35,24 +34,24 @@ For example:
https://mills.io/.well-known/salty/prologic.json`,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
user := viper.GetString("user")
identity := viper.GetString("identity")
var profiles []profile
viper.UnmarshalKey("profiles", &profiles)
for _, p := range profiles {
if user == p.User {
identity = p.Identity
}
}
me, err := saltyim.ParseAddr(user)
id, err := saltyim.GetIdentity(
saltyim.WithIdentityPath(viper.GetString("identity")),
)
if err != nil {
log.Debugf("error parsing addr: %s\n", err)
fmt.Fprintf(os.Stderr, "error loading identity: %s\n", err)
os.Exit(2)
}
// XXX: What if me.IsZero()
send(me, identity, args[0], args[1:]...)
cli, err := saltyim.NewClient(
saltyim.WithIdentity(id),
saltyim.WithUser(viper.GetString("user")),
)
if err != nil {
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
os.Exit(2)
}
send(cli, args[0], args[1:]...)
},
}
@ -60,19 +59,13 @@ func init() {
rootCmd.AddCommand(sendCmd)
}
func send(me *saltyim.Addr, identity, user string, args ...string) {
func send(cli *saltyim.Client, user string, args ...string) {
user = strings.TrimSpace(user)
if user == "" {
fmt.Fprintf(os.Stderr, "error: no user supplied\n")
os.Exit(2)
}
cli, err := saltyim.NewClient(me, saltyim.WithClientIdentity(saltyim.WithIdentityPath(identity)))
if err != nil {
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
os.Exit(2)
}
var msg string
if len(args) == 0 {

0
data/blobs/.gitkeep Normal file
View File

140
docs/BlogStorage.md Normal file
View File

@ -0,0 +1,140 @@
---
title: Salty.im Blob Storage v1
description: A proposal for a secure blob store for the reference broker and client for Salty.im
tags: blob, store, saltyim
---
# Salty.im Blob Storage v1
> Design proposal for a blob storage v1 design for the reference broker and client https://git.mills.io/saltyim/saltyim
[toc]
## API
### Types
```go
// Blob defines the type, filename and whether or not a blob is publicly accessible or not.
// A Blob also holds zero r more properties as a map of key/value pairs of string interpreted
// by the client.
type Blob struct {
Type string
Public bool
Filename string
Properties map[string]string
}
// BlobRequest is the request used by clients to update blob metadata for blobs stored on a broker.
type BlobRequest struct {
Blob
}
```
### Endpoints
- `PUT /api/v1/blob/:key`
- Stores or updates a blob's contents.
- `POST /api/v1/blob/:key`
- Updates metadata of a blob
- `DELETE /api/v1/blob/:key`
- Deletes a blob
- `HEAD /api/v1/blob/:key`
- Returns metadata of a blob
- `GET /api/v1/blob/:key`
- Returns metadata and the blob's contents
General flow of requests and responses:
1. All requests are signed by the client using it's private key.
2. Updates to metadata are performed using a `POST` requests and a body marshaled to JSON using the `NewBlobRequest()` object, all other requests either use their raw request body (`PUT`), headers (`HEAD`, and `GET`) or an empty body (`DELETE`).
3. Verification of all signed requests are performed to ensure only the owner of the blob(s) can make modifications, delete or otherwise update a blob's metadata or its contents.
4. All responses are standard HTTP response codes as appropriate to the success or failure of a given request:
- `200 Ok`
- `201 Created`
- `400 Bad Request`
- `401 Unauthorized`
- `403 Forbidden`
- `404 Not Found`
- `500 Internal Server Error`
## Storage
Blobs are stored on-disk in a path structure:
```
/path/to/data/blobs/<owner>/<key>
```
Where:
- `<owner>` is owner's public key.
- `<key>` is the key for the blob (_computed or specified by the client_) and is the content address of the stored blob.
## Security
It is up to the client to decide whether or not the stored blob and its contents are encrypted. before submitting it to the blob store service on the broker for storage.
### Threat Model
> [name=James Mills] Still working on this...
The following list of items are threat we simply do not care about or are considered "out of scope":
- **public key** -- This is public knowledge by design, it can be looked up via a user's Salty Address using for example `salty-chat lookup <address>`.
- **law enforcement / state actor** -- We will consider state actors and law enforcement to be out of scope simply because given the resources of powerful actors, if they really wanted to access the contents of a blob, they probably will. However we will make it as hard as we can.
| Threat Actor | Affected Data | Vulnerability | Priority |
| ------------ | ------------- | ------------- | -------- |
| operators[^1]| blob[^2] | everything[^3]| P1 |
| other users | blob[^2] | everything[^3]| P1 |
[^1]: all operators, including broker, server and network operators
[^2]: all data about a blob, including its location, its contents and metadata
[^3]: all or most of the data is vulnerable, including spoofing, modifying, deleting and preventing the other from access, modifying or deleting.
#### Threat Actors
1. another user
2. broker operator
3. server operator
4. network operator
5. casual eavesdropper
6. law enforcement
7. state actor
#### Affected Data
1. public key
2. blob location
3. blob contents
4. blob metadata
#### Vulnerabilities
1. learn the data
2. spoof the data
3. delete the data
4. prevent owner from reading the data
5. prevent owner from modifying/deleting the data
## Properties
A blob can be accompanied by one or more "Properties" that are stored alongside the blob:
Stored on-disk as a simple `.json` file along-side the blob as `<key>.json`
## Sharing
If a blob's `.Public` flag is set to `true` then the blob is publicly accessible via a shared URL of the form:
- https://salty.yourdomain.tld/shared/:owner/:key
Where:
- `:owner` is the owner's public key.
- `:key` is the blob's identifying key (_content-addressing_).
### Access Controls
> [name=James Mills] TBD

17
go.mod
View File

@ -10,18 +10,19 @@ require (
github.com/cyphar/filepath-securejoin v0.2.3
github.com/disintegration/gift v1.2.1
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/golang-jwt/jwt/v4 v4.4.3
github.com/h2non/filetype v1.1.3
github.com/likexian/doh-go v0.6.4
github.com/mattn/go-isatty v0.0.14
github.com/mitchellh/go-homedir v1.1.0
github.com/mlctrez/goapp-mdc v0.2.6
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022
github.com/oklog/ulid/v2 v2.0.2
github.com/oklog/ulid/v2 v2.1.0
github.com/sasha-s/go-deadlock v0.3.1
github.com/sirupsen/logrus v1.8.1
github.com/spf13/cobra v1.4.0
github.com/spf13/viper v1.10.1
github.com/stretchr/testify v1.7.0
github.com/stretchr/testify v1.8.0
github.com/taigrr/go-colorhash v0.0.0-20220329080504-742db7f45eae
github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09
go.mills.io/salty v0.0.0-20220322161301-ce2b9f6573fa
@ -54,15 +55,16 @@ require (
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mitchellh/mapstructure v1.4.3 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pelletier/go-toml v1.9.4 // indirect
github.com/petermattis/goid v0.0.0-20220331194723-8ee3e6ded87a // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/plar/go-adaptive-radix-tree v1.0.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_golang v1.12.2 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.33.0 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
@ -83,6 +85,7 @@ require (
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/writeas/go-strip-markdown/v2 v2.1.1 // indirect
golang.org/x/exp v0.0.0-20220328175248-053ad81199eb // indirect
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
golang.org/x/text v0.3.7 // indirect
@ -90,7 +93,7 @@ require (
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/ini.v1 v1.66.4 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
nhooyr.io/websocket v1.8.7 // indirect
)
@ -101,7 +104,7 @@ require (
git.mills.io/prologic/useragent v0.0.0-20210714100044-d249fe7921a0
github.com/NYTimes/gziphandler v1.1.1
github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1
github.com/google/go-cmp v0.5.7 // indirect
github.com/google/go-cmp v0.5.8 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/julienschmidt/httprouter v1.3.0
github.com/justinas/nosurf v1.1.1

36
go.sum
View File

@ -175,6 +175,8 @@ github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14j
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@ -220,8 +222,8 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@ -357,8 +359,9 @@ github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI=
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/maxence-charriere/go-app/v9 v9.4.1 h1:uDrMIvjzkXwBjw5594i7ZqD5LY5iN7j1KeMImjWAYiw=
github.com/maxence-charriere/go-app/v9 v9.4.1/go.mod h1:zo0n1kh4OMKn7P+MrTUUi7QwUMU2HOfHsZ293TITtxI=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
@ -372,8 +375,8 @@ github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0Qu
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mlctrez/goapp-mdc v0.2.6 h1:/nSRAqC3xz+GFCd3Zl3yI87bBeEpJVXi5FSMnRighXo=
github.com/mlctrez/goapp-mdc v0.2.6/go.mod h1:hrbfhTSPD7jaaubJsUweirnVkbwtwQJcjJm5OrH+rVo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -389,9 +392,11 @@ github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022 h1:Ys0rDzh8s4U
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022/go.mod h1:x4NsS+uc7ecH/Cbm9xKQ6XzmJM57rWTkjywjfB2yQ18=
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/oklog/ulid/v2 v2.0.2 h1:r4fFzBm+bv0wNKNh5eXTwU7i85y5x+uwkxCUTNVQqLc=
github.com/oklog/ulid/v2 v2.0.2/go.mod h1:mtBL0Qe/0HAx6/a4Z30qxVIAL1eQDweXq5lxOEiwQ68=
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
@ -420,8 +425,9 @@ github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDf
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk=
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_golang v1.12.2 h1:51L9cDoUHVrXx4zWYlcLQIZ+d+VXHgqnYKkIuq4g/34=
github.com/prometheus/client_golang v1.12.2/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@ -500,15 +506,18 @@ github.com/spf13/viper v1.10.1/go.mod h1:IGlFPqhNAPKRxohIzWpI5QEy4kuI7tcl5WvR+8q
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As=
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/taigrr/go-colorhash v0.0.0-20220329080504-742db7f45eae h1:RXzKJmV0lGvBpY8/43bJShhPYIssF7X18UVMs9KIgIQ=
@ -714,8 +723,9 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -856,7 +866,6 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
@ -988,8 +997,9 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@ -175,14 +175,6 @@ func WithIdentityAddr(addr *Addr) IdentityOption {
return func(i *Identity) { i.addr = addr }
}
// WithIdentity indicates that an identity should be passed in
func WithIdentity(ident *Identity) IdentityOption {
return func(i *Identity) {
i.key = ident.key
i.addr = ident.addr
}
}
// WithIdentityPath indicates that an identity should be read / written from a file path
func WithIdentityPath(path string) IdentityOption {
return func(i *Identity) { i.path = path }

View File

@ -1,6 +1,8 @@
package internal
import (
"errors"
"io"
"net/http"
"net/url"
@ -9,6 +11,7 @@ import (
"github.com/unrolled/render"
"go.mills.io/saltyim"
"go.mills.io/saltyim/internal/authreq"
)
// API ...
@ -35,6 +38,13 @@ func (a *API) initRoutes() {
router.GET("/ping", a.PingEndpoint())
router.POST("/register", a.RegisterEndpoint())
// Blob Service
router.DELETE("/blob/:key", authreq.VerifyMiddleware(a.BlobEndpoint()))
router.HEAD("/blob/:key", authreq.VerifyMiddleware(a.BlobEndpoint()))
router.GET("/blob/:key", authreq.VerifyMiddleware(a.BlobEndpoint()))
router.PUT("/blob/:key", authreq.VerifyMiddleware(a.BlobEndpoint()))
router.POST("/blob/:key", authreq.VerifyMiddleware(a.BlobEndpoint()))
// Lookup and Send support for Web / PWA clients
router.GET("/lookup/:addr", a.LookupEndpoint())
router.POST("/send", a.SendEndpoint())
@ -147,3 +157,70 @@ func (a *API) AvatarEndpoint() httprouter.Handle {
http.Error(w, "Avatar Created", http.StatusCreated)
}
}
// BlobEndpoint ...
func (a *API) BlobEndpoint() httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
claims := authreq.ClaimsFromRequest(r)
if claims == nil {
log.Warn("no claims")
http.Error(w, "Unauthorised", http.StatusUnauthorized)
return
}
signer := claims.Issuer
key := p.ByName("key")
switch r.Method {
case http.MethodDelete:
if err := DeleteBlob(a.config, key, signer); err != nil {
if errors.Is(err, ErrBlobNotFound) {
http.Error(w, "Blob Not Found", http.StatusNotFound)
return
}
log.WithError(err).Errorf("error getting blob %s for %s", key, signer)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
http.Error(w, "Blob Deleted", http.StatusOK)
case http.MethodGet, http.MethodHead:
blob, err := GetBlob(a.config, key, signer)
if err != nil {
if errors.Is(err, ErrBlobNotFound) {
http.Error(w, "Blob Not Found", http.StatusNotFound)
return
}
log.WithError(err).Errorf("error getting blob %s for %s", key, signer)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer blob.Close()
blob.SetHeaders(r)
if r.Method == http.MethodGet {
_, _ = io.Copy(w, blob)
}
case http.MethodPut:
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer r.Body.Close()
if err := CreateOrUpdateBlob(a.config, key, data, signer); err != nil {
log.WithError(err).Errorf("error creating/updating blob for %s for %s", key, signer)
http.Error(w, "Avatar Error", http.StatusInternalServerError)
return
}
http.Error(w, "Blob Created", http.StatusCreated)
default:
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
}
}

139
internal/api_e2e_test.go Normal file
View File

@ -0,0 +1,139 @@
package internal_test
import (
"context"
"fmt"
"io"
"net/http"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
"go.mills.io/saltyim/internal"
)
var (
serverBind = ":61234"
serverBaseURL = "http://localhost:61234"
serverPrimaryDomain = "localhost"
)
func TestMain(m *testing.M) {
data, err := os.MkdirTemp("", "data*")
if err != nil {
fmt.Printf("error creating data dir: %s\n", err)
os.Exit(1)
}
defer os.RemoveAll(data)
svr, err := internal.NewServer(serverBind,
// Debug mode
internal.WithDebug(true),
// TLS options
internal.WithTLS(false),
internal.WithTLSKey(""),
internal.WithTLSCert(""),
// Basic options
internal.WithData(data),
internal.WithStore("memory://"),
internal.WithBaseURL(serverBaseURL),
internal.WithPrimaryDomain(serverPrimaryDomain),
// Oeprator
internal.WithAdminUser("admin@localhost"),
internal.WithSupportEmail("support@localhost"),
)
if err != nil {
fmt.Printf("error creating server: %s\n", err)
os.Exit(1)
}
stop := make(chan struct{})
wg, ctx := errgroup.WithContext(context.Background())
wg.Go(svr.Run)
wg.Go(func() error {
<-stop
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
return svr.Shutdown(ctx)
})
i := m.Run()
close(stop)
go func() {
<-time.After(3 * time.Second)
fmt.Println("KILLING IT ALL!! Service failed to shutdown on time!!")
os.Exit(0)
}()
err = wg.Wait()
fmt.Println(err)
os.Exit(i)
}
func TestBlobPostNoAuthentication(t *testing.T) {
assert := assert.New(t)
req, err := http.NewRequest(http.MethodGet, serverBaseURL+"/api/v1/blob/test", nil)
assert.NoError(err)
res, err := http.DefaultClient.Do(req)
assert.NoError(err)
assert.Equal(http.StatusUnauthorized, res.StatusCode)
}
func TestPing(t *testing.T) {
assert := assert.New(t)
req, err := http.NewRequest(http.MethodGet, serverBaseURL+"/api/v1/ping", nil)
assert.NoError(err)
res, err := http.DefaultClient.Do(req)
assert.NoError(err)
assert.Equal(http.StatusOK, res.StatusCode)
b, err := io.ReadAll(res.Body)
assert.NoError(err)
assert.Equal("{}", string(b))
}
func TestBlobPut(t *testing.T) {
require := require.New(t)
cli := internal.NewTestUser("alice@localhost", serverBaseURL, t)
err := cli.Register(serverBaseURL)
require.NoError(err)
body := []byte("Hello World!")
_, err = cli.Request(http.MethodPut, serverBaseURL+"/api/v1/blob/test", body)
require.NoError(err)
}
func TestBlobGet(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
cli := internal.NewTestUser("alice@localhost", serverBaseURL, t)
err := cli.Register(serverBaseURL)
require.NoError(err)
body := []byte("Hello World!")
_, err = cli.Request(http.MethodPut, serverBaseURL+"/api/v1/blob/test", body)
require.NoError(err)
data, err := cli.Request(http.MethodGet, serverBaseURL+"/api/v1/blob/test", nil)
require.NoError(err)
assert.Equal(body, data, "expected returned blob to be identical to what we stored")
}

157
internal/authreq/authreq.go Normal file
View File

@ -0,0 +1,157 @@
// Package authreq signa and verifies HTTP requests using Ed25519 private/public keys
package authreq
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/base64"
"fmt"
"hash/fnv"
"io"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/julienschmidt/httprouter"
"github.com/oklog/ulid/v2"
"github.com/patrickmn/go-cache"
log "github.com/sirupsen/logrus"
)
type contextKey int
const (
claimsContextKey contextKey = iota
authorizationHeader = "Authorization"
signatureLifetime = 5 * time.Minute
)
// ClaimsFromRequest returns the JWT claims object associated with the HTTP request (if any)
func ClaimsFromRequest(r *http.Request) *jwt.RegisteredClaims {
if claims := r.Context().Value(claimsContextKey); claims != nil {
return claims.(*jwt.RegisteredClaims)
}
return nil
}
// Sign signs the HTTP request with a Ed25519 private key
func Sign(req *http.Request, key ed25519.PrivateKey) (*http.Request, error) {
pub := enc([]byte(key.Public().(ed25519.PublicKey)))
h := fnv.New128a()
fmt.Fprint(h, req.Method, req.URL.Path)
if req.Body != nil {
b := &bytes.Buffer{}
w := io.MultiWriter(h, b)
_, err := io.Copy(w, req.Body)
if err != nil {
return req, err
}
req.Body = io.NopCloser(b)
}
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.RegisteredClaims{
ID: ulid.Make().String(),
Subject: enc(h.Sum(nil)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(signatureLifetime)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: pub,
})
sig, err := token.SignedString(key)
if err != nil {
return req, err
}
req.Header.Set(authorizationHeader, sig)
return req, nil
}
// VerifyMiddleware is a httprouter.handler middleware that verifies HTTP requests previously
// signed with a Ed25519 private key
func VerifyMiddleware(next httprouter.Handle) httprouter.Handle {
usedIDs := cache.New(signatureLifetime, 2*signatureLifetime)
return func(rw http.ResponseWriter, req *http.Request, p httprouter.Params) {
log.Debugf("XXX")
auth := req.Header.Get(authorizationHeader)
if auth == "" {
log.Warn("no authorization found")
rw.WriteHeader(http.StatusUnauthorized)
return
}
hash := fnv.New128a()
fmt.Fprint(hash, req.Method, req.URL.Path)
if req.Body != nil {
buf := &bytes.Buffer{}
mw := io.MultiWriter(hash, buf)
_, err := io.Copy(mw, req.Body)
if err != nil {
log.WithError(err).Error("error hasning request body")
rw.WriteHeader(http.StatusBadRequest)
return
}
}
subject := enc(hash.Sum(nil))
token, err := jwt.ParseWithClaims(
string(auth),
&jwt.RegisteredClaims{},
func(token *jwt.Token) (any, error) {
claims, ok := token.Claims.(*jwt.RegisteredClaims)
if !ok {
return nil, fmt.Errorf("wrong type of claim")
}
pub, err := dec(claims.Issuer)
return ed25519.PublicKey(pub), err
},
jwt.WithValidMethods([]string{"EdDSA"}),
jwt.WithJSONNumber(),
)
if err != nil {
log.WithError(err).Error("error verifying token")
rw.WriteHeader(http.StatusBadRequest)
return
}
claims, ok := token.Claims.(*jwt.RegisteredClaims)
if !ok {
log.Warn("no claims found!")
rw.WriteHeader(http.StatusUnprocessableEntity)
return
}
if claims.ID != "" {
if _, ok := usedIDs.Get(claims.ID); ok {
log.Warnf("ID has been seen before: %s", claims.ID)
rw.WriteHeader(http.StatusForbidden)
return
}
usedIDs.SetDefault(claims.ID, true)
}
if claims.Subject != subject {
log.Warnf("subjects do not match %s != %q", claims.Subject, subject)
rw.WriteHeader(http.StatusForbidden)
return
}
ctx := context.WithValue(req.Context(), claimsContextKey, claims)
next(rw, req.WithContext(ctx), p)
}
}
func enc(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
func dec(s string) ([]byte, error) {
s = strings.TrimSpace(s)
return base64.RawURLEncoding.DecodeString(s)
}

View File

@ -0,0 +1,111 @@
package authreq_test
import (
"crypto/ed25519"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/julienschmidt/httprouter"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mills.io/saltyim/internal/authreq"
)
var authorizationHeader = "Authorization"
func TestGETRequest(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
pub, priv, err := ed25519.GenerateKey(nil)
require.NoError(err)
req, err := http.NewRequest(http.MethodGet, "http://example.com/"+enc(pub)+"/test?q=test", nil)
require.NoError(err)
req, err = authreq.Sign(req, priv)
require.NoError(err)
t.Log(enc(pub))
t.Log(req.Header.Get(authorizationHeader))
var hdlr httprouter.Handle = func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
c := authreq.ClaimsFromRequest(r)
if c == nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if !strings.Contains(req.URL.Path, c.Issuer) {
w.WriteHeader(http.StatusForbidden)
return
}
}
hdlr = authreq.VerifyMiddleware(hdlr)
rw := httptest.NewRecorder()
hdlr(rw, req, nil)
assert.Equal(rw.Code, http.StatusOK)
}
func TestPOSTRequest(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
content := "this is post!"
pub, priv, err := ed25519.GenerateKey(nil)
require.NoError(err)
req, err := http.NewRequest(http.MethodPost, "http://example.com/"+enc(pub)+"/test?q=test", strings.NewReader(content))
require.NoError(err)
req, err = authreq.Sign(req, priv)
require.NoError(err)
t.Log(enc(pub))
t.Log(req.Header.Get(authorizationHeader))
var hdlr httprouter.Handle = func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
c := authreq.ClaimsFromRequest(r)
if c == nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
contentCheck, err := io.ReadAll(r.Body)
r.Body.Close()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
t.Log(string(contentCheck))
if !strings.Contains(req.URL.Path, c.Issuer) {
w.WriteHeader(http.StatusForbidden)
return
}
}
hdlr = authreq.VerifyMiddleware(hdlr)
rw := httptest.NewRecorder()
hdlr(rw, req, nil)
assert.Equal(rw.Code, http.StatusOK)
}
func enc(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}

View File

@ -5,12 +5,11 @@ import (
log "github.com/sirupsen/logrus"
)
// BitcaskStore ...
type BitcaskStore struct {
type bitcaskStore struct {
db *bitcask.Bitcask
}
func newBitcaskStore(path string) (*BitcaskStore, error) {
func newBitcaskStore(path string) (*bitcaskStore, error) {
db, err := bitcask.Open(
path,
bitcask.WithMaxKeySize(256),
@ -19,16 +18,16 @@ func newBitcaskStore(path string) (*BitcaskStore, error) {
return nil, err
}
return &BitcaskStore{db: db}, nil
return &bitcaskStore{db: db}, nil
}
// Sync ...
func (bs *BitcaskStore) Sync() error {
func (bs *bitcaskStore) Sync() error {
return bs.db.Sync()
}
// Close ...
func (bs *BitcaskStore) Close() error {
func (bs *bitcaskStore) Close() error {
log.Info("syncing store ...")
if err := bs.db.Sync(); err != nil {
log.WithError(err).Error("error syncing store")
@ -45,7 +44,7 @@ func (bs *BitcaskStore) Close() error {
}
// Merge ...
func (bs *BitcaskStore) Merge() error {
func (bs *bitcaskStore) Merge() error {
log.Info("merging store ...")
if err := bs.db.Merge(); err != nil {
log.WithError(err).Error("error merging store")

View File

@ -1,13 +1,11 @@
package internal
// MemoryStore is an in-memory store mostly only used for tests
type MemoryStore struct {
type memoryStore struct{}
func newMemoryStore() *memoryStore {
return &memoryStore{}
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{}
}
func (s *MemoryStore) Sync() error { return nil }
func (s *MemoryStore) Close() error { return nil }
func (s *MemoryStore) Merge() error { return nil }
func (s *memoryStore) Sync() error { return nil }
func (s *memoryStore) Close() error { return nil }
func (s *memoryStore) Merge() error { return nil }

View File

@ -116,7 +116,7 @@ func (c *Configuration) registerIdentity() func(button app.HTMLButton) {
// not using client since that's not setup until we have an identity, might break the existing
// flow
log.Printf("identity;Addr(): %#v", identity.Addr())
registerClient, err := saltyim.NewClient(identity.Addr(), saltyim.WithClientIdentity(saltyim.WithIdentityBytes(identity.Contents())))
registerClient, err := saltyim.NewClient(saltyim.WithClientIdentity(saltyim.WithIdentityBytes(identity.Contents())))
if err != nil { // TODO: pop dialog
log.Println("error", err)
return

View File

@ -108,7 +108,7 @@ func (h *SaltyChat) connect(ctx app.Context) {
}
clientIdentity := saltyim.WithClientIdentity(saltyim.WithIdentityBytes(identity.Contents()))
newClient, err := saltyim.NewClient(identity.Addr(), clientIdentity, saltyim.WithState(state))
newClient, err := saltyim.NewClient(clientIdentity, saltyim.WithState(state))
if err != nil {
h.dialog.ShowError("error setting up client", err.Error())
return

View File

@ -389,6 +389,7 @@ func NewServer(bind string, options ...Option) (*Server, error) {
csrfHandler := nosurf.New(router)
csrfHandler.ExemptGlob("/api/v1/*")
csrfHandler.ExemptGlob("/api/v1/blob/*")
csrfHandler.ExemptGlob("/.well-known/*")
csrfHandler.ExemptGlob("/inbox/*")

View File

@ -47,6 +47,8 @@ func NewStore(store string) (Store, error) {
switch u.Type {
case "bitcask":
return newBitcaskStore(u.Path)
case "memory":
return newMemoryStore(), nil
default:
return nil, ErrInvalidStore
}

View File

@ -15,10 +15,12 @@ const (
wellknownPath = ".well-known/salty"
avatarsPath = "avatars"
avatarResolution = 80 // 80x80 px
blobsPath = "blobs"
)
var (
ErrAddressExists = errors.New("error: address already exists")
ErrBlobNotFound = errors.New("error: blob not found")
)
func CreateConfig(conf *Config, hash string, key string) error {
@ -75,3 +77,56 @@ func CreateOrUpdateAvatar(conf *Config, addr *saltyim.Addr, contents []byte) err
return nil
}
func CreateOrUpdateBlob(conf *Config, key string, data []byte, signer string) error {
p := filepath.Join(conf.Data, blobsPath, signer)
p = saltyim.FixUnixHome(p)
if err := os.MkdirAll(p, 0755); err != nil {
return fmt.Errorf("error creating blobs paths %s: %w", p, err)
}
fn := filepath.Join(p, key)
if err := os.WriteFile(fn, data, os.FileMode(0644)); err != nil {
return fmt.Errorf("error writing blob %s: %w", fn, err)
}
return nil
}
func GetBlob(conf *Config, key string, signer string) (*saltyim.Blob, error) {
p := filepath.Join(conf.Data, blobsPath, signer)
p = saltyim.FixUnixHome(p)
if err := os.MkdirAll(p, 0755); err != nil {
return nil, fmt.Errorf("error creating blobs paths %s: %w", p, err)
}
fn := filepath.Join(p, key)
if !FileExists(fn) {
return nil, ErrBlobNotFound
}
return saltyim.OpenBlob(fn)
}
func DeleteBlob(conf *Config, key string, signer string) error {
p := filepath.Join(conf.Data, blobsPath, signer)
p = saltyim.FixUnixHome(p)
if err := os.MkdirAll(p, 0755); err != nil {
return fmt.Errorf("error creating blobs paths %s: %w", p, err)
}
fn := filepath.Join(p, key)
if !FileExists(fn) {
return ErrBlobNotFound
}
// TODO: Delete the .json properties metadata files too
return os.Remove(fn)
}

24
internal/test_helpers.go Normal file
View File

@ -0,0 +1,24 @@
package internal
import (
"testing"
"github.com/stretchr/testify/require"
"go.mills.io/saltyim"
)
func NewTestUser(addr, broker string, t *testing.T) *saltyim.Client {
require := require.New(t)
me, err := saltyim.ParseAddr(addr)
require.NoError(err)
id, err := saltyim.CreateIdentity()
require.NoError(err)
cli, err := saltyim.NewClient(saltyim.WithAddr(me), saltyim.WithIdentity(id))
require.NoError(err)
return cli
}

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:62776cf8369416668d4a77ff87adbd68b5bc45a0adbbdfda9a67ff08afe3c3a8
size 29651160
oid sha256:3133c2c49ec1c697518deb6f2ffb59e9902bc07f8c6ab9f6cb558d4711bcd357
size 29757066

View File

@ -16,7 +16,6 @@ func WithClientIdentity(options ...IdentityOption) ClientOption {
return fmt.Errorf("error loading identity: %w", err)
}
cli.id = id
cli.key = id.key
return nil
}
}
@ -55,6 +54,14 @@ func WithStateFromBytes(data []byte) ClientOption {
}
}
// WithIdentity sets the client's identity from an identity object
func WithIdentity(id *Identity) ClientOption {
return func(cli *Client) error {
cli.id = id
return nil
}
}
// WithState sets the client's state from a state object
func WithState(state *State) ClientOption {
return func(cli *Client) error {
@ -62,3 +69,30 @@ func WithState(state *State) ClientOption {
return nil
}
}
// WithAddr sets the client's `me` Salty Address
func WithAddr(me *Addr) ClientOption {
return func(cli *Client) error {
cli.me = me
return nil
}
}
// WithUser sets the client's `me` Salty Address if a non-nil or non-empty and valid
// Salty Address for `user` is supplifed, otherwise the user in the client's identity
// is used.
func WithUser(user string) ClientOption {
return func(cli *Client) error {
if user == "" {
return nil
}
addr, err := ParseAddr(user)
if err != nil {
return err
}
cli.me = addr
return nil
}
}

View File

@ -89,9 +89,9 @@ func (svc *Service) Run(ctx context.Context) error {
// TODO: Should this timeout? Use a context?
if err := retry.Do(func() error {
cli, err := NewClient(
svc.me,
WithAddr(svc.me),
WithIdentity(svc.id),
WithStateFromFile(svc.state),
WithClientIdentity(WithIdentity(svc.id)),
)
if err != nil {
return err

View File

@ -2,7 +2,10 @@ package saltyim
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"go.mills.io/salty"
)
@ -54,7 +57,7 @@ func NewSendRequest(r io.Reader) (req SendRequest, signer string, err error) {
return
}
// AvatarRequest is the request used by clients to send messages via a broker
// AvatarRequest is the request used by clients to update avatars stored on a broker's avatar service.
type AvatarRequest struct {
Addr *Addr
Content []byte
@ -76,3 +79,75 @@ func NewAvatarRequest(r io.Reader) (req AvatarRequest, signer string, err error)
err = json.Unmarshal(out, &req)
return
}
// Blob defines the type, filename and whether or not a blob is publicly accessible or not.
// A Blob also holds zero r more properties as a map of key/value pairs of string interpreted
// by the client.
type Blob struct {
r io.ReadSeekCloser
Type string
Public bool
Filename string
Properties map[string]string
}
// Close closes the blob and the underlying io.ReadSeekCloser
func (b *Blob) Close() error { return b.r.Close() }
// Read reads data from the blob from the underlying io.ReadSeekCloser
func (b *Blob) Read(p []byte) (n int, err error) { return b.r.Read(p) }
// SetHeaders sets HTTP headers on the net/http.Request object based on the blob's type, filename
// and various other properties (if any).
func (b *Blob) SetHeaders(r *http.Request) {
// TODO: Implement this...
}
// OpenBlob opens a blob at the given path and returns a Blob object
func OpenBlob(fn string) (*Blob, error) {
f, err := os.Open(fn)
if err != nil {
return nil, fmt.Errorf("error opening blob %s: %w", fn, err)
}
return &Blob{r: f}, nil
}
// BlobRequest is the request used by clients to update blob metadata for blobs stored on a broker.
type BlobRequest struct {
Blob
}
// NewBlobRequest reads the signed request body from a client, verifies its signature
// and returns the resulting `BlobRequest` and key used to sign the request on success
// otherwise an empty object and en error on failure.
func NewBlobRequest(r io.Reader) (req BlobRequest, signer string, err error) {
body, err := io.ReadAll(r)
if err != nil {
return
}
out, key, err := salty.Verify(body)
if err != nil {
return
}
signer = key.ID().String()
err = json.Unmarshal(out, &req)
return
}
// NewRawRequest reads the signed request body from a client, verifies its signature
// and returns the resulting `[]byte` slice and key used to sign the request on success
// otherwise an empty object and en error on failure.
func NewRawRequest(r io.Reader) (out []byte, signer string, err error) {
body, err := io.ReadAll(r)
if err != nil {
return
}
out, key, err := salty.Verify(body)
if err != nil {
return
}
signer = key.ID().String()
return
}

View File

@ -11,8 +11,10 @@ import (
"time"
"github.com/andybalholm/brotli"
"github.com/keys-pub/keys"
"github.com/oklog/ulid/v2"
log "github.com/sirupsen/logrus"
"go.mills.io/saltyim/internal/authreq"
)
const (
@ -92,6 +94,63 @@ func Request(method, uri string, headers http.Header, body io.Reader) (*http.Res
return res, nil
}
// SignedRequest is a generic request handling function for making artbitrary HTPT
// requests to a Salty broker's API endpoints that require authorization.
func SignedRequest(key *keys.EdX25519Key, method, uri string, headers http.Header, body io.Reader) (*http.Response, error) {
if headers == nil {
headers = make(http.Header)
}
if body != nil {
switch headers.Get("Content-Encoding") {
case "br":
buf := &bytes.Buffer{}
br := brotli.NewWriter(buf)
io.Copy(br, body)
br.Close()
body = buf
case "gzip":
buf := &bytes.Buffer{}
gz := gzip.NewWriter(buf)
io.Copy(gz, body)
gz.Close()
body = buf
}
}
req, err := http.NewRequest(method, uri, body)
if err != nil {
return nil, fmt.Errorf("%s: http.NewRequest fail: %s", uri, err)
}
// Set a default User-Agent (if none set)
if headers.Get("User-Agent") == "" {
headers.Set("User-Agent", fmt.Sprintf("saltyim/%s", FullVersion()))
}
req.Header = headers
client := http.Client{
Timeout: defaultRequestTimeout,
}
req, err = authreq.Sign(req, key.Private())
if err != nil {
return nil, fmt.Errorf("error signing request: %w", err)
}
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%s: client.Do fail: %s", uri, err)
}
if res.StatusCode/100 != 2 {
return nil, fmt.Errorf("non-2xx response received: %s", res.Status)
}
return res, nil
}
// SplitInbox splits and endpoint into it's components (inbox, uri)
// where inbox is a topic queue on the Salty broker uri
func SplitInbox(endpoint string) (string, string) {