mirror of
https://github.com/versia-pub/versia-go.git
synced 2026-03-12 20:19:15 +01:00
chore: init
This commit is contained in:
commit
320715f3e7
174 changed files with 42083 additions and 0 deletions
51
pkg/lysand/action_follow.go
Normal file
51
pkg/lysand/action_follow.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package lysand
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type Follow struct {
|
||||
Entity
|
||||
|
||||
// Author is the URL to the user that triggered the follow
|
||||
Author *URL `json:"author"`
|
||||
// Followee is the URL to the user that is being followed
|
||||
Followee *URL `json:"followee"`
|
||||
}
|
||||
|
||||
func (f Follow) MarshalJSON() ([]byte, error) {
|
||||
type follow Follow
|
||||
f2 := follow(f)
|
||||
f2.Type = "Follow"
|
||||
return json.Marshal(f2)
|
||||
}
|
||||
|
||||
type FollowAccept struct {
|
||||
Entity
|
||||
|
||||
// Author is the URL to the user that accepted the follow
|
||||
Author *URL `json:"author"`
|
||||
// Follower is the URL to the user that is now following the followee
|
||||
Follower *URL `json:"follower"`
|
||||
}
|
||||
|
||||
func (f FollowAccept) MarshalJSON() ([]byte, error) {
|
||||
type followAccept FollowAccept
|
||||
f2 := followAccept(f)
|
||||
f2.Type = "FollowAccept"
|
||||
return json.Marshal(f2)
|
||||
}
|
||||
|
||||
type FollowReject struct {
|
||||
Entity
|
||||
|
||||
// Author is the URL to the user that rejected the follow
|
||||
Author *URL `json:"author"`
|
||||
// Follower is the URL to the user that is no longer following the followee
|
||||
Follower *URL `json:"follower"`
|
||||
}
|
||||
|
||||
func (f FollowReject) MarshalJSON() ([]byte, error) {
|
||||
type followReject FollowReject
|
||||
f2 := followReject(f)
|
||||
f2.Type = "FollowReject"
|
||||
return json.Marshal(f2)
|
||||
}
|
||||
19
pkg/lysand/action_undo.go
Normal file
19
pkg/lysand/action_undo.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package lysand
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type Undo struct {
|
||||
Entity
|
||||
|
||||
// Author is the URL to the user that triggered the undo action
|
||||
Author *URL `json:"author"`
|
||||
// Object is the URL to the object that was undone
|
||||
Object *URL `json:"object"`
|
||||
}
|
||||
|
||||
func (u Undo) MarshalJSON() ([]byte, error) {
|
||||
type undo Undo
|
||||
u2 := undo(u)
|
||||
u2.Type = "Undo"
|
||||
return json.Marshal(u2)
|
||||
}
|
||||
150
pkg/lysand/actor_user.go
Normal file
150
pkg/lysand/actor_user.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User represents a user object in the Lysand protocol. For more information, see the [Spec].
|
||||
//
|
||||
// [Spec]: https://lysand.org/objects/user
|
||||
type User struct {
|
||||
Entity
|
||||
|
||||
// PublicKey is the public key of the user.
|
||||
// https://lysand.org/objects/user#public-key
|
||||
PublicKey PublicKey `json:"public_key"`
|
||||
|
||||
// DisplayName is the display name of the user.
|
||||
// https://lysand.org/objects/user#display-name
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
|
||||
// Username is the username of the user. Must be unique on the instance and match the following regex: ^[a-z0-9_-]+$
|
||||
// https://lysand.org/objects/user#username
|
||||
Username string `json:"username"`
|
||||
|
||||
// Indexable is a boolean that indicates whether the user is indexable by search engines.
|
||||
// https://lysand.org/objects/user#indexable
|
||||
Indexable bool `json:"indexable"`
|
||||
|
||||
// ManuallyApprovesFollowers is a boolean that indicates whether the user manually approves followers.
|
||||
// https://lysand.org/objects/user#manually-approves-followers
|
||||
ManuallyApprovesFollowers bool `json:"manually_approves_followers"`
|
||||
|
||||
// Avatar is the avatar of the user in different image content types.
|
||||
// https://lysand.org/objects/user#avatar
|
||||
Avatar ImageContentTypeMap `json:"avatar,omitempty"`
|
||||
|
||||
// Header is the header image of the user in different image content types.
|
||||
// https://lysand.org/objects/user#header
|
||||
Header ImageContentTypeMap `json:"header,omitempty"`
|
||||
|
||||
// Bio is the biography of the user in different text content types.
|
||||
// https://lysand.org/objects/user#bio
|
||||
Bio TextContentTypeMap `json:"bio"`
|
||||
|
||||
// Fields is a list of fields that the user has filled out.
|
||||
// https://lysand.org/objects/user#fields
|
||||
Fields []Field `json:"fields,omitempty"`
|
||||
|
||||
// Featured is the featured posts of the user.
|
||||
// https://lysand.org/objects/user#featured
|
||||
Featured *URL `json:"featured"`
|
||||
|
||||
// Followers is the followers of the user.
|
||||
// https://lysand.org/objects/user#followers
|
||||
Followers *URL `json:"followers"`
|
||||
|
||||
// Following is the users that the user is following.
|
||||
// https://lysand.org/objects/user#following
|
||||
Following *URL `json:"following"`
|
||||
|
||||
// Likes is the likes of the user.
|
||||
// https://lysand.org/objects/user#likes
|
||||
Likes *URL `json:"likes"`
|
||||
|
||||
// Dislikes is the dislikes of the user.
|
||||
// https://lysand.org/objects/user#dislikes
|
||||
Dislikes *URL `json:"dislikes"`
|
||||
|
||||
// Inbox is the inbox of the user.
|
||||
// https://lysand.org/objects/user#posts
|
||||
Inbox *URL `json:"inbox"`
|
||||
|
||||
// Outbox is the outbox of the user.
|
||||
// https://lysand.org/objects/user#outbox
|
||||
Outbox *URL `json:"outbox"`
|
||||
}
|
||||
|
||||
func (u User) MarshalJSON() ([]byte, error) {
|
||||
type user User
|
||||
u2 := user(u)
|
||||
u2.Type = "User"
|
||||
return json.Marshal(u2)
|
||||
}
|
||||
|
||||
type Field struct {
|
||||
Key TextContentTypeMap `json:"key"`
|
||||
Value TextContentTypeMap `json:"value"`
|
||||
}
|
||||
|
||||
func (c *FederationClient) GetUser(ctx context.Context, uri *url.URL) (*User, error) {
|
||||
resp, body, err := c.rawGET(ctx, uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &User{}
|
||||
if err := json.Unmarshal(body, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
date, sigHeader, err := ExtractFederationHeaders(resp.Header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v := Verifier{ed25519.PublicKey(user.PublicKey.PublicKey)}
|
||||
if !v.Verify("GET", date, uri.Host, uri.Path, body, sigHeader) {
|
||||
c.log.V(2).Info("signature verification failed", "user", user.URI.String())
|
||||
return nil, fmt.Errorf("signature verification failed")
|
||||
}
|
||||
c.log.V(2).Info("signature verification succeeded", "user", user.URI.String())
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (c *FederationClient) SendToInbox(ctx context.Context, signer Signer, user *User, object any) ([]byte, error) {
|
||||
uri := user.Inbox.ToStd()
|
||||
|
||||
body, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
date := time.Now()
|
||||
|
||||
sigData := NewSignatureData("POST", date, uri.Host, uri.Path, hashSHA256(body))
|
||||
sig := signer.Sign(*sigData)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", uri.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Date", TimeFromStd(date).String())
|
||||
req.Header.Set("Signature", sig.String())
|
||||
|
||||
_, respBody, err := c.doReq(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
26
pkg/lysand/attachment.go
Normal file
26
pkg/lysand/attachment.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package lysand
|
||||
|
||||
// Attachment is a file or other piece of content that is attached to a post. For more information, see the [Spec].
|
||||
//
|
||||
// [Spec]: https://lysand.org/structures/content-format
|
||||
type Attachment struct {
|
||||
// URL to the attachment
|
||||
Content string `json:"content"`
|
||||
Description string `json:"description"`
|
||||
Hash DataHash `json:"hash"`
|
||||
Size int `json:"size"`
|
||||
|
||||
// BlurHash is available when the content type is an image
|
||||
BlurHash *string `json:"blurhash,omitempty"`
|
||||
// BlurHash is available when the content type is an image
|
||||
Height *int `json:"height,omitempty"`
|
||||
// BlurHash is available when the content type is an image
|
||||
Width *int `json:"width,omitempty"`
|
||||
|
||||
// TODO: Figure out when this is available
|
||||
FPS *int `json:"fps,omitempty"`
|
||||
}
|
||||
|
||||
type DataHash struct {
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
85
pkg/lysand/content_types.go
Normal file
85
pkg/lysand/content_types.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var (
|
||||
validTextContentTypes = []string{"text/html", "text/plain"}
|
||||
validImageContentTypes = []string{"image/png", "image/jpeg", "image/gif", "image/svg+xml"}
|
||||
)
|
||||
|
||||
// ContentTypeMap is a map of content types to their respective content.
|
||||
type ContentTypeMap[T any] map[string]T
|
||||
|
||||
func (m *ContentTypeMap[T]) unmarshalJSON(raw []byte, valid []string) error {
|
||||
var cm map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &cm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*m = make(ContentTypeMap[T])
|
||||
|
||||
for k, v := range cm {
|
||||
if !slices.Contains(valid, k) {
|
||||
// TODO: replace with logr
|
||||
log.Debug().Caller().Str("mimetype", k).Msg("unexpected content type, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
var c T
|
||||
if err := json.Unmarshal(v, &c); err != nil {
|
||||
return err
|
||||
}
|
||||
(*m)[k] = c
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m ContentTypeMap[T]) getPreferred(preferred []string) *T {
|
||||
for _, v := range preferred {
|
||||
if c, ok := m[v]; ok {
|
||||
return &c
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type TextContent struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
type TextContentTypeMap ContentTypeMap[TextContent]
|
||||
|
||||
func (t *TextContentTypeMap) UnmarshalJSON(data []byte) error {
|
||||
return (*ContentTypeMap[TextContent])(t).unmarshalJSON(data, validTextContentTypes)
|
||||
}
|
||||
|
||||
func (t TextContentTypeMap) String() string {
|
||||
if c := (ContentTypeMap[TextContent])(t).getPreferred(validTextContentTypes); c != nil {
|
||||
return c.Content
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
type ImageContent struct {
|
||||
Content *URL `json:"content"`
|
||||
}
|
||||
type ImageContentTypeMap ContentTypeMap[ImageContent]
|
||||
|
||||
func (i *ImageContentTypeMap) UnmarshalJSON(data []byte) error {
|
||||
return (*ContentTypeMap[ImageContent])(i).unmarshalJSON(data, validImageContentTypes)
|
||||
}
|
||||
|
||||
func (i ImageContentTypeMap) String() string {
|
||||
if c := (ContentTypeMap[ImageContent])(i).getPreferred(validImageContentTypes); c != nil {
|
||||
return c.Content.String()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
92
pkg/lysand/crypto.go
Normal file
92
pkg/lysand/crypto.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (c *FederationClient) ValidateSignatureHeader(req *http.Request) (bool, error) {
|
||||
date, sigHeader, err := ExtractFederationHeaders(req.Header)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// TODO: Fetch user from database instead of using the URI
|
||||
user, err := c.GetUser(req.Context(), sigHeader.KeyID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
body, err := copyBody(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
v := Verifier{ed25519.PublicKey(user.PublicKey.PublicKey)}
|
||||
valid := v.Verify(req.Method, date, req.Host, req.URL.Path, body, sigHeader)
|
||||
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
func ExtractFederationHeaders(h http.Header) (time.Time, *SignatureHeader, error) {
|
||||
gotDates := h.Values("date")
|
||||
var date *Time
|
||||
for i, raw := range gotDates {
|
||||
if parsed, err := ParseTime(raw); err != nil {
|
||||
log.Printf("invalid date[%d] header: %s", i, raw)
|
||||
continue
|
||||
} else {
|
||||
date = &parsed
|
||||
break
|
||||
}
|
||||
}
|
||||
if date == nil {
|
||||
return time.Time{}, nil, fmt.Errorf("missing date header")
|
||||
}
|
||||
|
||||
gotSignature := h.Get("signature")
|
||||
if gotSignature == "" {
|
||||
return date.ToStd(), nil, fmt.Errorf("missing signature header")
|
||||
}
|
||||
sigHeader, err := ParseSignatureHeader(gotSignature)
|
||||
if err != nil {
|
||||
return date.ToStd(), nil, err
|
||||
}
|
||||
|
||||
return date.ToStd(), sigHeader, nil
|
||||
}
|
||||
|
||||
func hashSHA256(data []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(data)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func must[In any, Out any](fn func(In) (Out, error), v In) Out {
|
||||
out, err := fn(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func copyBody(req *http.Request) ([]byte, error) {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := req.Body.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
return body, nil
|
||||
}
|
||||
100
pkg/lysand/crypto_test.go
Normal file
100
pkg/lysand/crypto_test.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFederationClient_ValidateSignatureHeader(t *testing.T) {
|
||||
var (
|
||||
bobPrivBytes = must(base64.StdEncoding.DecodeString, "MC4CAQAwBQYDK2VwBCIEINOATgmaya61Ha9OEE+DD3RnOEqDaHyQ3yLf5upwskUU")
|
||||
bobPubBytes = must(base64.StdEncoding.DecodeString, "MCowBQYDK2VwAyEAQ08Z/FJ5f16o8mthLaFZMo4ssn0fJ7c+bipNYm3kId4=")
|
||||
)
|
||||
|
||||
bobPub := must(x509.ParsePKIXPublicKey, bobPubBytes).(ed25519.PublicKey)
|
||||
bobPriv := must(x509.ParsePKCS8PrivateKey, bobPrivBytes).(ed25519.PrivateKey)
|
||||
|
||||
date := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
body := []byte("hello")
|
||||
|
||||
sigData := NewSignatureData("POST", date, "example2.com", "/users/bob", hashSHA256(body))
|
||||
|
||||
sig := Signer{PrivateKey: bobPriv, UserURL: &url.URL{Scheme: "https", Host: "example.com", Path: "/users/bob"}}.
|
||||
Sign(*sigData)
|
||||
|
||||
t.Run("validate against itself", func(t *testing.T) {
|
||||
v := Verifier{
|
||||
PublicKey: bobPub,
|
||||
}
|
||||
|
||||
if !v.Verify("POST", date, "example2.com", "/users/bob", body, sig) {
|
||||
t.Error("signature verification failed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validate against @lysand/api JS implementation", func(t *testing.T) {
|
||||
expectedSignedString := `(request-target): post /users/bob
|
||||
host: example2.com
|
||||
date: 1970-01-01T00:00:00.000Z
|
||||
digest: SHA-256=LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=
|
||||
`
|
||||
assert.Equal(t, expectedSignedString, sigData.String())
|
||||
|
||||
expectedSignatureHeader := `keyId="https://example.com/users/bob",algorithm="ed25519",headers="(request-target) host date digest",signature="PbVicu1spnATYUznWn6N5ebNUC+w94U9k6y4dncLsr6hNfUD8CLInbUSkgR3AZrCWEZ+Md2+Lch70ofiSqXgAQ=="`
|
||||
assert.Equal(t, expectedSignatureHeader, sig.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestSignatureInterop(t *testing.T) {
|
||||
var (
|
||||
bobPubBytes = must(base64.StdEncoding.DecodeString, "MCowBQYDK2VwAyEAgKNt+9eyOXdb7MSrrmHlsFD2H9NGwC+56PjpWD46Tcs=")
|
||||
bobPrivBytes = must(base64.StdEncoding.DecodeString, "MC4CAQAwBQYDK2VwBCIEII+nkwT3nXwBp9FEE0q95RBBfikf6UTzPzdH2yrtIvL1")
|
||||
)
|
||||
|
||||
bobPub := must(x509.ParsePKIXPublicKey, bobPubBytes).(ed25519.PublicKey)
|
||||
bobPriv := must(x509.ParsePKCS8PrivateKey, bobPrivBytes).(ed25519.PrivateKey)
|
||||
|
||||
signedString := `(request-target): post /api/users/ec042557-8c30-492d-87d6-9e6495993072/inbox
|
||||
host: lysand-test.i.devminer.xyz
|
||||
date: 2024-07-25T21:03:24.866Z
|
||||
digest: SHA-256=mPN5WKMoC4k3zor6FPTJUhDQ1JKX6zqA2QfEGh3omuc=
|
||||
`
|
||||
method := "POST"
|
||||
dateHeader := "2024-07-25T21:03:24.866Z"
|
||||
date := must(ParseTime, dateHeader)
|
||||
host := "lysand-test.i.devminer.xyz"
|
||||
path := "/api/users/ec042557-8c30-492d-87d6-9e6495993072/inbox"
|
||||
body := []byte(`{"type":"Follow","id":"2265b3b2-a176-4b20-8fcf-ac82cf2efd7d","author":"https://lysand.i.devminer.xyz/users/0190d697-c83a-7376-8d15-0f77fd09e180","followee":"https://lysand-test.i.devminer.xyz/api/users/ec042557-8c30-492d-87d6-9e6495993072/","created_at":"2024-07-25T21:03:24.863Z","uri":"https://lysand.i.devminer.xyz/follows/2265b3b2-a176-4b20-8fcf-ac82cf2efd7d"}`)
|
||||
signatureHeader := `keyId="https://lysand.i.devminer.xyz/users/0190d697-c83a-7376-8d15-0f77fd09e180",algorithm="ed25519",headers="(request-target) host date digest",signature="KUkKYexLk2hOfE+NVIacLDHSJP2QpX4xJGclHhQIM39ce2or7UJauRtCL8eWrhpSgQdVPk11bYhvvi8fdCruBw=="`
|
||||
|
||||
sigData := NewSignatureData(method, date.ToStd(), host, path, hashSHA256(body))
|
||||
assert.Equal(t, signedString, sigData.String())
|
||||
|
||||
t.Run("signature header parsing", func(t *testing.T) {
|
||||
parsedSignatureHeader, err := ParseSignatureHeader(signatureHeader)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
assert.Equal(t, "https://lysand.i.devminer.xyz/users/0190d697-c83a-7376-8d15-0f77fd09e180", parsedSignatureHeader.KeyID.String())
|
||||
assert.Equal(t, "ed25519", parsedSignatureHeader.Algorithm)
|
||||
assert.Equal(t, "(request-target) host date digest", parsedSignatureHeader.Headers)
|
||||
assert.Equal(t, sigData.Sign(bobPriv), parsedSignatureHeader.Signature)
|
||||
|
||||
v := Verifier{PublicKey: bobPub}
|
||||
if !v.Verify(method, date.ToStd(), host, path, body, parsedSignatureHeader) {
|
||||
t.Error("signature verification failed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("signature header generation", func(t *testing.T) {
|
||||
sig := Signer{PrivateKey: bobPriv, UserURL: &url.URL{Scheme: "https", Host: "lysand.i.devminer.xyz", Path: "/users/0190d697-c83a-7376-8d15-0f77fd09e180"}}.
|
||||
Sign(*sigData)
|
||||
assert.Equal(t, signatureHeader, sig.String())
|
||||
})
|
||||
}
|
||||
43
pkg/lysand/entity.go
Normal file
43
pkg/lysand/entity.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Entity is the base type for all Lysand entities. For more information, see the [Spec].
|
||||
//
|
||||
// [Spec]: https://lysand.org/objects#types
|
||||
type Entity struct {
|
||||
// Type is the type of the entity
|
||||
Type string `json:"type"`
|
||||
|
||||
// ID is a UUID for the entity
|
||||
ID uuid.UUID `json:"id"`
|
||||
|
||||
// URI is the URL to the entity
|
||||
URI *URL `json:"uri"`
|
||||
|
||||
// CreatedAt is the time the entity was created
|
||||
CreatedAt Time `json:"created_at"`
|
||||
|
||||
// Extensions is a map of active extensions
|
||||
// https://lysand.org/objects/server-metadata#extensions
|
||||
Extensions Extensions `json:"extensions,omitempty"`
|
||||
}
|
||||
|
||||
type Extensions map[string]any
|
||||
|
||||
// {
|
||||
// "org.lysand:custom_emojis": {
|
||||
// "emojis": [
|
||||
// {
|
||||
// "name": "neocat_3c",
|
||||
// "url": {
|
||||
// "image/webp": {
|
||||
// "content": "https://cdn.lysand.org/a97727158bf062ad31cbfb02e212ce0c7eca599a2f863276511b8512270b25e8/neocat_3c_256.webp"
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
123
pkg/lysand/federation_client.go
Normal file
123
pkg/lysand/federation_client.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/go-logr/logr"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ResponseError struct {
|
||||
StatusCode int
|
||||
URL *url.URL
|
||||
}
|
||||
|
||||
func (e *ResponseError) Error() string {
|
||||
return fmt.Sprintf("error from %s: %d", e.URL, e.StatusCode)
|
||||
}
|
||||
|
||||
type FederationClient struct {
|
||||
log logr.Logger
|
||||
httpC *http.Client
|
||||
}
|
||||
|
||||
type Opt func(c *FederationClient)
|
||||
|
||||
func WithHTTPClient(h *http.Client) Opt {
|
||||
return func(c *FederationClient) {
|
||||
c.httpC = h
|
||||
}
|
||||
}
|
||||
|
||||
func WithLogger(l logr.Logger) Opt {
|
||||
return func(c *FederationClient) {
|
||||
c.log = l
|
||||
}
|
||||
}
|
||||
|
||||
func NewClient(opts ...Opt) *FederationClient {
|
||||
c := &FederationClient{
|
||||
httpC: http.DefaultClient,
|
||||
log: logr.Discard(),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
|
||||
c.httpC.Transport = &federationClientHTTPTransport{
|
||||
inner: c.httpC.Transport,
|
||||
useragent: "github.com/thedevminertv/go-lysand#0.0.1",
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *FederationClient) rawGET(ctx context.Context, uri *url.URL) (*http.Response, []byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", uri.String(), nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return c.doReq(req)
|
||||
}
|
||||
|
||||
func (c *FederationClient) rawPOST(ctx context.Context, uri *url.URL, body io.Reader) (*http.Response, []byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", uri.String(), body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return c.doReq(req)
|
||||
}
|
||||
|
||||
func (c *FederationClient) doReq(req *http.Request) (*http.Response, []byte, error) {
|
||||
resp, err := c.httpC.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
return resp, nil, &ResponseError{
|
||||
StatusCode: resp.StatusCode,
|
||||
URL: req.URL,
|
||||
}
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return resp, nil, err
|
||||
}
|
||||
|
||||
return resp, respBody, nil
|
||||
}
|
||||
|
||||
type federationClientHTTPTransport struct {
|
||||
inner http.RoundTripper
|
||||
useragent string
|
||||
l logr.Logger
|
||||
}
|
||||
|
||||
func (t *federationClientHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", t.useragent)
|
||||
|
||||
if req.Body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
res, err := t.inner.RoundTrip(req)
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.l.V(1).Info("fetch succeeded", "url", req.URL.String(), "status", res.StatusCode, "duration", elapsed)
|
||||
} else {
|
||||
t.l.V(1).Error(err, "fetch failed", "url", req.URL.String(), "duration", elapsed)
|
||||
}
|
||||
|
||||
return res, err
|
||||
}
|
||||
72
pkg/lysand/inbox.go
Normal file
72
pkg/lysand/inbox.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type inboxObject struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func ParseInboxObject(raw json.RawMessage) (any, error) {
|
||||
var i inboxObject
|
||||
if err := json.Unmarshal(raw, &i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch i.Type {
|
||||
case "Publication":
|
||||
m := Publication{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
case "Note":
|
||||
m := Note{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
case "Patch":
|
||||
m := Patch{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
case "Follow":
|
||||
m := Follow{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
case "FollowAccept":
|
||||
m := FollowAccept{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
case "FollowReject":
|
||||
m := FollowReject{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
case "Undo":
|
||||
m := Undo{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
default:
|
||||
return nil, ErrUnknownType{Type: i.Type}
|
||||
}
|
||||
}
|
||||
|
||||
type ErrUnknownType struct {
|
||||
Type string
|
||||
}
|
||||
|
||||
func (e ErrUnknownType) Error() string {
|
||||
return fmt.Sprintf("unknown inbox object type: %s", e.Type)
|
||||
}
|
||||
66
pkg/lysand/public_key.go
Normal file
66
pkg/lysand/public_key.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidPublicKeyType = errors.New("invalid public key type")
|
||||
)
|
||||
|
||||
// PublicKey represents a public key for a user. For more information, see the [Spec].
|
||||
//
|
||||
// [Spec]: https://lysand.org/security/keys#public-key-cryptography
|
||||
type PublicKey struct {
|
||||
PublicKey SPKIPublicKey `json:"public_key"`
|
||||
Actor *URL `json:"actor"`
|
||||
}
|
||||
|
||||
// SPKIPublicKey is a type that represents a [ed25519.PublicKey] in the SPKI
|
||||
// format.
|
||||
type SPKIPublicKey ed25519.PublicKey
|
||||
|
||||
// UnmarshalJSON decodes the public key from a base64 encoded string and then unmarshals it from the SPKI form.
|
||||
func (k *SPKIPublicKey) UnmarshalJSON(raw []byte) error {
|
||||
rawStr := ""
|
||||
if err := json.Unmarshal(raw, &rawStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw, err := base64.StdEncoding.DecodeString(rawStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsed, err := x509.ParsePKIXPublicKey(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
edKey, ok := parsed.(ed25519.PublicKey)
|
||||
if !ok {
|
||||
return ErrInvalidPublicKeyType
|
||||
}
|
||||
|
||||
*k = SPKIPublicKey(edKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON marshals the SPKI-encoded public key to a base64 encoded string.
|
||||
func (k SPKIPublicKey) MarshalJSON() ([]byte, error) {
|
||||
raw, err := x509.MarshalPKIXPublicKey(ed25519.PublicKey(k))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.Marshal(base64.StdEncoding.EncodeToString(raw))
|
||||
}
|
||||
|
||||
func (k SPKIPublicKey) ToStd() ed25519.PublicKey {
|
||||
return ed25519.PublicKey(k)
|
||||
}
|
||||
33
pkg/lysand/public_key_test.go
Normal file
33
pkg/lysand/public_key_test.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSPKIPublicKey_UnmarshalJSON(t *testing.T) {
|
||||
expectedPk := must(x509.ParsePKIXPublicKey, must(base64.StdEncoding.DecodeString, "MCowBQYDK2VwAyEAgKNt+9eyOXdb7MSrrmHlsFD2H9NGwC+56PjpWD46Tcs="))
|
||||
|
||||
pk := PublicKey{}
|
||||
raw := []byte(`{"public_key":"MCowBQYDK2VwAyEAgKNt+9eyOXdb7MSrrmHlsFD2H9NGwC+56PjpWD46Tcs="}`)
|
||||
if err := json.Unmarshal(raw, &pk); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
assert.Equal(t, expectedPk, ed25519.PublicKey(pk.PublicKey))
|
||||
}
|
||||
|
||||
func TestSPKIPublicKey_MarshalJSON(t *testing.T) {
|
||||
expectedPk := must(x509.ParsePKIXPublicKey, must(base64.StdEncoding.DecodeString, "MCowBQYDK2VwAyEAgKNt+9eyOXdb7MSrrmHlsFD2H9NGwC+56PjpWD46Tcs=")).(ed25519.PublicKey)
|
||||
|
||||
pk := PublicKey{
|
||||
PublicKey: SPKIPublicKey(expectedPk),
|
||||
}
|
||||
if _, err := json.Marshal(pk); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
118
pkg/lysand/publication.go
Normal file
118
pkg/lysand/publication.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package lysand
|
||||
|
||||
// PublicationVisibility is the visibility of a publication. For more information, see the [Spec].
|
||||
//
|
||||
// [Spec]: https://lysand.org/objects/publications#visibility
|
||||
type PublicationVisibility string
|
||||
|
||||
const (
|
||||
// PublicationVisiblePublic means that the publication is visible to everyone.
|
||||
PublicationVisiblePublic PublicationVisibility = "public"
|
||||
// PublicationVisibleUnlisted means that the publication is visible everyone, but should not appear in public timelines and search results.
|
||||
PublicationVisibleUnlisted PublicationVisibility = "unlisted"
|
||||
// PublicationVisibleFollowers means that the publication is visible to followers only.
|
||||
PublicationVisibleFollowers PublicationVisibility = "followers"
|
||||
// PublicationVisibleDirect means that the publication is a direct message, and is visible only to the mentioned users.
|
||||
PublicationVisibleDirect PublicationVisibility = "direct"
|
||||
)
|
||||
|
||||
// Publication is a publication object. For more information, see the [Spec].
|
||||
//
|
||||
// [Spec]: https://lysand.org/objects/publications
|
||||
type Publication struct {
|
||||
Entity
|
||||
|
||||
// Author is the URL to the user
|
||||
// https://lysand.org/objects/publications#author
|
||||
Author *URL `json:"author"`
|
||||
|
||||
// Content is the content of the publication
|
||||
// https://lysand.org/objects/publications#content
|
||||
Content TextContentTypeMap `json:"content,omitempty"`
|
||||
|
||||
// Category is the category of the publication
|
||||
// https://lysand.org/objects/publications#category
|
||||
Category *CategoryType `json:"category,omitempty"`
|
||||
|
||||
// Device that created the publication
|
||||
// https://lysand.org/objects/publications#device
|
||||
Device *Device `json:"device,omitempty"`
|
||||
|
||||
// Previews is a list of URLs to preview images
|
||||
// https://lysand.org/objects/publications#previews
|
||||
Previews []LinkPreview `json:"previews,omitempty"`
|
||||
|
||||
// Group is the URL to a group
|
||||
// https://lysand.org/objects/publications#group
|
||||
Group *URL `json:"group,omitempty"`
|
||||
|
||||
// Attachments is a list of attachment objects, keyed by their MIME type
|
||||
// https://lysand.org/objects/publications#attachments
|
||||
Attachments []ContentTypeMap[Attachment] `json:"attachments,omitempty"`
|
||||
|
||||
// RepliesTo is the URL to the publication being replied to
|
||||
// https://lysand.org/objects/publications#replies-to
|
||||
RepliesTo *URL `json:"replies_to,omitempty"`
|
||||
|
||||
// Quoting is the URL to the publication being quoted
|
||||
// https://lysand.org/objects/publications#quotes
|
||||
Quoting *URL `json:"quoting,omitempty"`
|
||||
|
||||
// Mentions is a list of URLs to users
|
||||
// https://lysand.org/objects/publications#mentionshttps://lysand.org/objects/publications#mentions
|
||||
Mentions []URL `json:"mentions,omitempty"`
|
||||
|
||||
// Subject is the subject of the publication
|
||||
// https://lysand.org/objects/publications#subject
|
||||
Subject *string `json:"subject,omitempty"`
|
||||
|
||||
// IsSensitive is a boolean indicating whether the publication contains sensitive content
|
||||
// https://lysand.org/objects/publications#is-sensitive
|
||||
IsSensitive *bool `json:"is_sensitive,omitempty"`
|
||||
|
||||
// Visibility is the visibility of the publication
|
||||
// https://lysand.org/objects/publications#visibility
|
||||
Visibility PublicationVisibility `json:"visibility"`
|
||||
}
|
||||
|
||||
// LinkPreview is a preview of a link. For more information, see the [Spec].
|
||||
//
|
||||
// [Spec]: https://lysand.org/objects/publications#types
|
||||
type LinkPreview struct {
|
||||
Link URL `json:"link"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Image *URL `json:"image"`
|
||||
Icon *URL `json:"icon"`
|
||||
}
|
||||
|
||||
// Device is the device that creates publications. For more information, see the [Spec].
|
||||
//
|
||||
// [Spec]: https://lysand.org/objects/publications#types
|
||||
type Device struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version,omitempty"`
|
||||
URL *URL `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// CategoryType is the type of publication. For more information, see the [Spec].
|
||||
//
|
||||
// [Spec]: https://lysand.org/objects/publications#types
|
||||
type CategoryType string
|
||||
|
||||
const (
|
||||
// CategoryMicroblog is similar to Twitter, Mastodon
|
||||
CategoryMicroblog CategoryType = "microblog"
|
||||
// CategoryForum is similar to Reddit
|
||||
CategoryForum CategoryType = "forum"
|
||||
// CategoryBlog is similar to Wordpress, WriteFreely
|
||||
CategoryBlog CategoryType = "blog"
|
||||
// CategoryImage is similar to Instagram
|
||||
CategoryImage CategoryType = "image"
|
||||
// CategoryVideo is similar to YouTube
|
||||
CategoryVideo CategoryType = "video"
|
||||
// CategoryAudio is similar to SoundCloud, Spotify
|
||||
CategoryAudio CategoryType = "audio"
|
||||
// CategoryMessaging is similar to Discord, Matrix, Signal
|
||||
CategoryMessaging CategoryType = "messaging"
|
||||
)
|
||||
12
pkg/lysand/publication_note.go
Normal file
12
pkg/lysand/publication_note.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package lysand
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type Note Publication
|
||||
|
||||
func (n Note) MarshalJSON() ([]byte, error) {
|
||||
type note Note
|
||||
n2 := note(n)
|
||||
n2.Type = "Note"
|
||||
return json.Marshal(n2)
|
||||
}
|
||||
29
pkg/lysand/publication_patch.go
Normal file
29
pkg/lysand/publication_patch.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Patch is a type that represents a modification to a note. For more information, see the [Spec].
|
||||
//
|
||||
// [Spec]: https://lysand.org/objects/patch
|
||||
type Patch struct {
|
||||
Note
|
||||
|
||||
// PatchedID is the ID of the publication that was patched.
|
||||
// https://lysand.org/objects/patch#patched-id
|
||||
PatchedID uuid.UUID `json:"patched_id"`
|
||||
|
||||
// PatchedAt is the time that the publication was patched.
|
||||
// https://lysand.org/objects/patch#patched-at
|
||||
PatchedAt Time `json:"patched_at"`
|
||||
}
|
||||
|
||||
func (p Patch) MarshalJSON() ([]byte, error) {
|
||||
type patch Patch
|
||||
p2 := patch(p)
|
||||
p2.Type = "Patch"
|
||||
return json.Marshal(p2)
|
||||
}
|
||||
64
pkg/lysand/server_metadata.go
Normal file
64
pkg/lysand/server_metadata.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
)
|
||||
|
||||
// ServerMetadata represents the metadata of a Lysand server. For more information, see the [Spec].
|
||||
//
|
||||
// ! Unlike other objects, server metadata is not meant to be federated.
|
||||
//
|
||||
// [Spec]: https://lysand.org/objects/server-metadata
|
||||
type ServerMetadata struct {
|
||||
// Type is always "ServerMetadata"
|
||||
// https://lysand.org/objects/server-metadata#type
|
||||
Type string `json:"type"`
|
||||
|
||||
// Extensions is a map of active extensions
|
||||
// https://lysand.org/objects/server-metadata#extensions
|
||||
Extensions Extensions `json:"extensions,omitempty"`
|
||||
|
||||
// Name is the name of the server
|
||||
// https://lysand.org/objects/server-metadata#name
|
||||
Name string `json:"name"`
|
||||
|
||||
// Version is the version of the server software
|
||||
// https://lysand.org/objects/server-metadata#version
|
||||
Version *semver.Version `json:"version"`
|
||||
|
||||
// Description is a description of the server
|
||||
// https://lysand.org/objects/server-metadata#description
|
||||
Description *string `json:"description,omitempty"`
|
||||
|
||||
// Website is the URL to the server's website
|
||||
// https://lysand.org/objects/server-metadata#website
|
||||
Website *URL `json:"website,omitempty"`
|
||||
|
||||
// Moderators is a list of URLs to moderators
|
||||
// https://lysand.org/objects/server-metadata#moderators
|
||||
Moderators []*URL `json:"moderators,omitempty"`
|
||||
|
||||
// Admins is a list of URLs to administrators
|
||||
// https://lysand.org/objects/server-metadata#admins
|
||||
Admins []*URL `json:"admins,omitempty"`
|
||||
|
||||
// Logo is the URL to the server's logo
|
||||
// https://lysand.org/objects/server-metadata#logo
|
||||
Logo *ImageContentTypeMap `json:"logo,omitempty"`
|
||||
|
||||
// Banner is the URL to the server's banner
|
||||
// https://lysand.org/objects/server-metadata#banner
|
||||
Banner *ImageContentTypeMap `json:"banner,omitempty"`
|
||||
|
||||
// SupportedExtensions is a list of supported extensions
|
||||
SupportedExtensions []string `json:"supported_extensions"`
|
||||
}
|
||||
|
||||
func (s ServerMetadata) MarshalJSON() ([]byte, error) {
|
||||
type serverMetadata ServerMetadata
|
||||
s2 := serverMetadata(s)
|
||||
s2.Type = "ServerMetadata"
|
||||
return json.Marshal(s2)
|
||||
}
|
||||
70
pkg/lysand/signature.go
Normal file
70
pkg/lysand/signature.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SignatureData struct {
|
||||
RequestMethod string
|
||||
Date time.Time
|
||||
Host string
|
||||
Path string
|
||||
Digest []byte
|
||||
}
|
||||
|
||||
func NewSignatureData(method string, date time.Time, host, path string, digest []byte) *SignatureData {
|
||||
return &SignatureData{
|
||||
RequestMethod: method,
|
||||
Date: date,
|
||||
Host: host,
|
||||
Path: path,
|
||||
Digest: digest,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SignatureData) String() string {
|
||||
return strings.Join([]string{
|
||||
fmt.Sprintf("(request-target): %s %s", strings.ToLower(s.RequestMethod), s.Path),
|
||||
fmt.Sprintf("host: %s", s.Host),
|
||||
fmt.Sprintf("date: %s", TimeFromStd(s.Date).String()),
|
||||
fmt.Sprintf("digest: SHA-256=%s", base64.StdEncoding.EncodeToString(s.Digest)),
|
||||
"",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func (s *SignatureData) Validate(pubKey ed25519.PublicKey, signature []byte) bool {
|
||||
return ed25519.Verify(pubKey, []byte(s.String()), signature)
|
||||
}
|
||||
|
||||
func (s *SignatureData) Sign(privKey ed25519.PrivateKey) []byte {
|
||||
return ed25519.Sign(privKey, []byte(s.String()))
|
||||
}
|
||||
|
||||
type Signer struct {
|
||||
PrivateKey ed25519.PrivateKey
|
||||
UserURL *url.URL
|
||||
}
|
||||
|
||||
func (s Signer) Sign(signatureData SignatureData) *SignatureHeader {
|
||||
return &SignatureHeader{
|
||||
KeyID: s.UserURL,
|
||||
Algorithm: "ed25519",
|
||||
Headers: "(request-target) host date digest",
|
||||
Signature: signatureData.Sign(s.PrivateKey),
|
||||
}
|
||||
}
|
||||
|
||||
type Verifier struct {
|
||||
PublicKey ed25519.PublicKey
|
||||
}
|
||||
|
||||
func (v Verifier) Verify(method string, date time.Time, host, path string, body []byte, sigHeader *SignatureHeader) bool {
|
||||
sigData := NewSignatureData(method, date, host, path, hashSHA256(body))
|
||||
|
||||
return sigData.Validate(v.PublicKey, sigHeader.Signature)
|
||||
}
|
||||
66
pkg/lysand/signature_header.go
Normal file
66
pkg/lysand/signature_header.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidSignatureHeader = errors.New("invalid signature header")
|
||||
)
|
||||
|
||||
type SignatureHeader struct {
|
||||
// URL to a user
|
||||
KeyID *url.URL
|
||||
Headers string
|
||||
Algorithm string
|
||||
Signature []byte
|
||||
}
|
||||
|
||||
func (s SignatureHeader) String() string {
|
||||
return strings.Join([]string{
|
||||
fmt.Sprintf(`keyId="%s"`, s.KeyID.String()),
|
||||
fmt.Sprintf(`algorithm="%s"`, s.Algorithm),
|
||||
fmt.Sprintf(`headers="%s"`, s.Headers),
|
||||
fmt.Sprintf(`signature="%s"`, base64.StdEncoding.EncodeToString(s.Signature)),
|
||||
}, ",")
|
||||
}
|
||||
|
||||
// ParseSignatureHeader parses strings in the form of
|
||||
// `keyId="<URL>",algorithm="ed25519",headers="(request-target) host date digest",signature="<BASE64 SIGNATURE>"`
|
||||
func ParseSignatureHeader(raw string) (*SignatureHeader, error) {
|
||||
parts := strings.Split(raw, ",")
|
||||
if len(parts) != 4 {
|
||||
return nil, ErrInvalidSignatureHeader
|
||||
}
|
||||
|
||||
sig := &SignatureHeader{}
|
||||
|
||||
for _, part := range parts {
|
||||
kv := strings.SplitN(part, "=", 2)
|
||||
kv[1] = strings.TrimPrefix(kv[1], "\"")
|
||||
kv[1] = strings.TrimSuffix(kv[1], "\"")
|
||||
|
||||
var err error
|
||||
|
||||
switch kv[0] {
|
||||
case "keyId":
|
||||
sig.KeyID, err = url.Parse(kv[1])
|
||||
case "algorithm":
|
||||
sig.Algorithm = kv[1]
|
||||
case "headers":
|
||||
sig.Headers = kv[1]
|
||||
case "signature":
|
||||
sig.Signature, err = base64.StdEncoding.DecodeString(kv[1])
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return sig, nil
|
||||
}
|
||||
41
pkg/lysand/signature_header_test.go
Normal file
41
pkg/lysand/signature_header_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseSignatureHeader(t *testing.T) {
|
||||
data := `keyId="https://example.com/users/bob",algorithm="ed25519",headers="(request-target) host date digest",signature="PbVicu1spnATYUznWn6N5ebNUC+w94U9k6y4dncLsr6hNfUD8CLInbUSkgR3AZrCWEZ+Md2+Lch70ofiSqXgAQ=="`
|
||||
expectedSignature := must(base64.StdEncoding.DecodeString, "PbVicu1spnATYUznWn6N5ebNUC+w94U9k6y4dncLsr6hNfUD8CLInbUSkgR3AZrCWEZ+Md2+Lch70ofiSqXgAQ==")
|
||||
|
||||
sig, err := ParseSignatureHeader(data)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "https://example.com/users/bob", sig.KeyID.String())
|
||||
assert.Equal(t, "ed25519", sig.Algorithm)
|
||||
assert.Equal(t, "(request-target) host date digest", sig.Headers)
|
||||
assert.Equal(t, expectedSignature, sig.Signature)
|
||||
}
|
||||
|
||||
func TestSignatureHeader_String(t *testing.T) {
|
||||
one := SignatureData{
|
||||
RequestMethod: "POST",
|
||||
Date: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
Host: "example2.com",
|
||||
Path: "/users/bob",
|
||||
Digest: hashSHA256([]byte("hello")),
|
||||
}
|
||||
|
||||
expected := `(request-target): post /users/bob
|
||||
host: example2.com
|
||||
date: 1970-01-01T00:00:00.000Z
|
||||
digest: SHA-256=LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=
|
||||
`
|
||||
|
||||
assert.Equal(t, expected, one.String())
|
||||
}
|
||||
57
pkg/lysand/time.go
Normal file
57
pkg/lysand/time.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ISO8601 = "2006-01-02T15:04:05.000Z"
|
||||
|
||||
func ParseTime(s string) (Time, error) {
|
||||
t, err := time.Parse(ISO8601, s)
|
||||
return Time(t), err
|
||||
}
|
||||
|
||||
// Time is a type that represents a time in the ISO8601 format.
|
||||
type Time time.Time
|
||||
|
||||
// String returns the time in the ISO8601 format.
|
||||
func (t Time) String() string {
|
||||
return t.ToStd().Format(ISO8601)
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the time from a string in the ISO8601 format.
|
||||
func (t *Time) UnmarshalJSON(data []byte) error {
|
||||
raw := ""
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsed, err := time.Parse(ISO8601, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*t = Time(parsed)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON marshals the time to a string in the ISO8601 format.
|
||||
func (t Time) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(t.String())
|
||||
}
|
||||
|
||||
// ToStd converts the time to a [time.Time].
|
||||
func (t Time) ToStd() time.Time {
|
||||
return time.Time(t)
|
||||
}
|
||||
|
||||
// TimeFromStd converts a [time.Time] to a Time.
|
||||
func TimeFromStd(u time.Time) Time {
|
||||
return Time(u)
|
||||
}
|
||||
|
||||
func TimeNow() Time {
|
||||
return Time(time.Now())
|
||||
}
|
||||
54
pkg/lysand/url.go
Normal file
54
pkg/lysand/url.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// URL is a type that represents a URL, represented by a string in JSON, instead of a JSON object.
|
||||
type URL url.URL
|
||||
|
||||
func (u *URL) ResolveReference(ref *url.URL) *URL {
|
||||
return URLFromStd(u.ToStd().ResolveReference(ref))
|
||||
}
|
||||
|
||||
func (u *URL) String() string {
|
||||
return u.ToStd().String()
|
||||
}
|
||||
|
||||
func (u *URL) UnmarshalJSON(data []byte) error {
|
||||
raw := ""
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*u = URL(*parsed)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *URL) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(u.String())
|
||||
}
|
||||
|
||||
func (u *URL) ToStd() *url.URL {
|
||||
return (*url.URL)(u)
|
||||
}
|
||||
|
||||
func URLFromStd(u *url.URL) *URL {
|
||||
return (*URL)(u)
|
||||
}
|
||||
|
||||
func ParseURL(raw string) (*URL, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return URLFromStd(parsed), nil
|
||||
}
|
||||
288
pkg/taskqueue/client.go
Normal file
288
pkg/taskqueue/client.go
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
package taskqueue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.devminer.xyz/devminer/unitel"
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/google/uuid"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
type taskWrapper struct {
|
||||
Task Task `json:"task"`
|
||||
EnqueuedAt time.Time `json:"enqueuedAt"`
|
||||
TraceInfo map[string]string `json:"traceInfo"`
|
||||
}
|
||||
|
||||
func (c *Client) newTaskWrapper(ctx context.Context, task Task) taskWrapper {
|
||||
traceInfo := make(map[string]string)
|
||||
c.telemetry.InjectIntoMap(ctx, traceInfo)
|
||||
|
||||
return taskWrapper{
|
||||
Task: task,
|
||||
EnqueuedAt: time.Now(),
|
||||
TraceInfo: traceInfo,
|
||||
}
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID string
|
||||
Type string
|
||||
Payload json.RawMessage
|
||||
}
|
||||
|
||||
func NewTask(type_ string, payload any) (Task, error) {
|
||||
id := uuid.New()
|
||||
|
||||
d, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
|
||||
return Task{
|
||||
ID: id.String(),
|
||||
Type: type_,
|
||||
Payload: d,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type Handler func(ctx context.Context, task Task) error
|
||||
|
||||
type Client struct {
|
||||
name string
|
||||
subject string
|
||||
handlers map[string][]Handler
|
||||
|
||||
nc *nats.Conn
|
||||
js jetstream.JetStream
|
||||
s jetstream.Stream
|
||||
|
||||
stopCh chan struct{}
|
||||
closeOnce func()
|
||||
|
||||
telemetry *unitel.Telemetry
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
func NewClient(ctx context.Context, name string, natsClient *nats.Conn, telemetry *unitel.Telemetry, log logr.Logger) (*Client, error) {
|
||||
js, err := jetstream.New(natsClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s, err := js.CreateStream(ctx, jetstream.StreamConfig{
|
||||
Name: name,
|
||||
Subjects: []string{name + ".*"},
|
||||
MaxConsumers: -1,
|
||||
MaxMsgs: -1,
|
||||
Discard: jetstream.DiscardOld,
|
||||
MaxMsgsPerSubject: -1,
|
||||
Storage: jetstream.FileStorage,
|
||||
Compression: jetstream.S2Compression,
|
||||
AllowDirect: true,
|
||||
})
|
||||
if errors.Is(err, nats.ErrStreamNameAlreadyInUse) {
|
||||
s, err = js.Stream(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stopCh := make(chan struct{})
|
||||
|
||||
c := &Client{
|
||||
name: name,
|
||||
subject: name + ".tasks",
|
||||
|
||||
handlers: map[string][]Handler{},
|
||||
|
||||
stopCh: stopCh,
|
||||
closeOnce: sync.OnceFunc(func() {
|
||||
close(stopCh)
|
||||
}),
|
||||
|
||||
nc: natsClient,
|
||||
js: js,
|
||||
s: s,
|
||||
|
||||
telemetry: telemetry,
|
||||
log: log,
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
c.closeOnce()
|
||||
c.nc.Close()
|
||||
}
|
||||
|
||||
func (c *Client) Submit(ctx context.Context, task Task) error {
|
||||
s := c.telemetry.StartSpan(ctx, "queue.publish", "taskqueue/Client.Submit").
|
||||
AddAttribute("messaging.destination.name", c.subject)
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
s.AddAttribute("jobID", task.ID)
|
||||
|
||||
data, err := json.Marshal(c.newTaskWrapper(ctx, task))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.AddAttribute("messaging.message.body.size", len(data))
|
||||
|
||||
msg, err := c.js.PublishMsg(ctx, &nats.Msg{Subject: c.subject, Data: data})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.log.V(1).Info("submitted task", "id", task.ID, "type", task.Type, "sequence", msg.Sequence)
|
||||
|
||||
s.AddAttribute("messaging.message.id", msg.Sequence)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) RegisterHandler(type_ string, handler Handler) {
|
||||
c.log.V(2).Info("registering handler", "type", type_)
|
||||
|
||||
if _, ok := c.handlers[type_]; !ok {
|
||||
c.handlers[type_] = []Handler{}
|
||||
}
|
||||
c.handlers[type_] = append(c.handlers[type_], handler)
|
||||
}
|
||||
|
||||
func (c *Client) Start(ctx context.Context) error {
|
||||
c.log.Info("starting")
|
||||
|
||||
sub, err := c.js.CreateConsumer(ctx, c.name, jetstream.ConsumerConfig{
|
||||
// TODO: set name properly
|
||||
Name: "versia-go",
|
||||
Durable: "versia-go",
|
||||
DeliverPolicy: jetstream.DeliverAllPolicy,
|
||||
ReplayPolicy: jetstream.ReplayInstantPolicy,
|
||||
AckPolicy: jetstream.AckExplicitPolicy,
|
||||
FilterSubject: c.subject,
|
||||
MaxWaiting: 1,
|
||||
MaxAckPending: 1,
|
||||
HeadersOnly: false,
|
||||
MemoryStorage: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m, err := sub.Messages(jetstream.PullMaxMessages(1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
msg, err := m.Next()
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrMsgIteratorClosed) {
|
||||
c.log.Info("stopping")
|
||||
return
|
||||
}
|
||||
|
||||
c.log.Error(err, "failed to get next message")
|
||||
break
|
||||
}
|
||||
|
||||
if err := c.handleTask(ctx, msg); err != nil {
|
||||
c.log.Error(err, "failed to handle task")
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
<-c.stopCh
|
||||
m.Drain()
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) handleTask(ctx context.Context, msg jetstream.Msg) error {
|
||||
msgMeta, err := msg.Metadata()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data := msg.Data()
|
||||
|
||||
var w taskWrapper
|
||||
if err := json.Unmarshal(data, &w); err != nil {
|
||||
if err := msg.Nak(); err != nil {
|
||||
c.log.Error(err, "failed to nak message")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
s := c.telemetry.StartSpan(
|
||||
context.Background(),
|
||||
"queue.process",
|
||||
"taskqueue/Client.handleTask",
|
||||
c.telemetry.ContinueFromMap(w.TraceInfo),
|
||||
).
|
||||
AddAttribute("messaging.destination.name", c.subject).
|
||||
AddAttribute("messaging.message.id", msgMeta.Sequence.Stream).
|
||||
AddAttribute("messaging.message.retry.count", msgMeta.NumDelivered).
|
||||
AddAttribute("messaging.message.body.size", len(data)).
|
||||
AddAttribute("messaging.message.receive.latency", time.Since(w.EnqueuedAt).Milliseconds())
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
handlers, ok := c.handlers[w.Task.Type]
|
||||
if !ok {
|
||||
c.log.V(1).Info("no handler for task", "type", w.Task.Type)
|
||||
return msg.Nak()
|
||||
}
|
||||
|
||||
var errs CombinedError
|
||||
for _, handler := range handlers {
|
||||
if err := handler(ctx, w.Task); err != nil {
|
||||
c.log.Error(err, "handler failed", "type", w.Task.Type)
|
||||
errs.Errors = append(errs.Errors, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs.Errors) > 0 {
|
||||
if err := msg.Nak(); err != nil {
|
||||
c.log.Error(err, "failed to nak message")
|
||||
errs.Errors = append(errs.Errors, err)
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
return msg.Ack()
|
||||
}
|
||||
|
||||
type CombinedError struct {
|
||||
Errors []error
|
||||
}
|
||||
|
||||
func (e CombinedError) Error() string {
|
||||
sb := strings.Builder{}
|
||||
sb.WriteRune('[')
|
||||
for i, err := range e.Errors {
|
||||
if i > 0 {
|
||||
sb.WriteRune(',')
|
||||
}
|
||||
sb.WriteString(err.Error())
|
||||
}
|
||||
sb.WriteRune(']')
|
||||
return sb.String()
|
||||
}
|
||||
51
pkg/webfinger/host_meta.go
Normal file
51
pkg/webfinger/host_meta.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package webfinger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type HostMeta struct {
|
||||
JSON []byte
|
||||
XML []byte
|
||||
}
|
||||
|
||||
func NewHostMeta(baseURL *url.URL) HostMeta {
|
||||
template := &url.URL{Path: "/.well-known/webfinger?resource={uri}"}
|
||||
template = baseURL.ResolveReference(template)
|
||||
|
||||
return HostMeta{
|
||||
JSON: generateJSONHostMeta(template),
|
||||
XML: generateXMLHostMeta(template),
|
||||
}
|
||||
}
|
||||
|
||||
func generateXMLHostMeta(template *url.URL) []byte {
|
||||
return []byte(`<?xml version="1.0"?>
|
||||
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
|
||||
<Link rel="lrdd" template="` + template.String() + `" />
|
||||
</XRD>`)
|
||||
}
|
||||
|
||||
func generateJSONHostMeta(template *url.URL) []byte {
|
||||
b, err := json.Marshal(hostMetaStruct{
|
||||
Links: []hostMetaLink{{
|
||||
Rel: "lrdd",
|
||||
Template: template.String(),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
type hostMetaStruct struct {
|
||||
Links []hostMetaLink `json:"links"`
|
||||
}
|
||||
|
||||
type hostMetaLink struct {
|
||||
Rel string `json:"rel"`
|
||||
Template string `json:"template"`
|
||||
}
|
||||
72
pkg/webfinger/webfinger.go
Normal file
72
pkg/webfinger/webfinger.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package webfinger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidSyntax = errors.New("must follow the format \"acct:<ID|Username>@<DOMAIN>\"")
|
||||
)
|
||||
|
||||
func ParseResource(res string) (*UserID, error) {
|
||||
if !strings.HasPrefix(res, "acct:") {
|
||||
return nil, ErrInvalidSyntax
|
||||
}
|
||||
|
||||
if !strings.Contains(res, "@") {
|
||||
return nil, ErrInvalidSyntax
|
||||
}
|
||||
|
||||
spl := strings.Split(res, "@")
|
||||
if len(spl) != 2 {
|
||||
return nil, ErrInvalidSyntax
|
||||
}
|
||||
|
||||
userID := strings.TrimPrefix(spl[0], "acct:")
|
||||
domain := spl[1]
|
||||
|
||||
return &UserID{userID, domain}, nil
|
||||
}
|
||||
|
||||
type UserID struct {
|
||||
ID string
|
||||
Domain string
|
||||
}
|
||||
|
||||
func (u UserID) String() string {
|
||||
return u.ID + "@" + u.Domain
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Links []Link `json:"links,omitempty"`
|
||||
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Link struct {
|
||||
Relation string `json:"rel"`
|
||||
Type any `json:"type"`
|
||||
Link string `json:"href"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
UserID
|
||||
|
||||
URI *url.URL
|
||||
|
||||
Avatar *url.URL
|
||||
AvatarMIMEType string
|
||||
}
|
||||
|
||||
func (u User) WebFingerResource() Response {
|
||||
return Response{
|
||||
Subject: "acct:" + u.String(),
|
||||
Links: []Link{
|
||||
{"self", "application/json", u.URI.String()},
|
||||
{"avatar", u.AvatarMIMEType, u.Avatar.String()},
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue