chore: init

This commit is contained in:
DevMiner 2024-08-11 03:51:22 +02:00
commit 320715f3e7
174 changed files with 42083 additions and 0 deletions

View 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
View 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
View 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
View 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"`
}

View 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
View 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
View 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
View 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"
// }
// }
// }
// ]
// }
// }

View 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
View 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
View 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)
}

View 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
View 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"
)

View 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)
}

View 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)
}

View 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
View 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)
}

View 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
}

View 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
View 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
View 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
View 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()
}

View 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"`
}

View 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()},
},
}
}