refactor!: add missing fields and docs

This commit is contained in:
DevMiner 2024-08-22 23:03:38 +02:00
parent 61891d891a
commit 6e59386f60
73 changed files with 726 additions and 580 deletions

View file

@ -0,0 +1,32 @@
package versia
import (
"encoding/json"
versiautils "github.com/lysand-org/versia-go/pkg/versia/utils"
)
// Delete signals the deletion of an entity. For more information, see the [Spec].
// This entity does not have a URI.
//
// Implementations must ensure that the author of the Delete entity has the authorization to delete the target entity.
//
// [Spec]: https://versia.pub/entities/delete
type Delete struct {
Entity
// Author is the URL to the user that triggered the deletion
Author *versiautils.URL `json:"author"`
// DeletedType is the type of the object that is being deleted
DeletedType string `json:"deleted_type"`
// Deleted is the URL to the object that is being deleted
Deleted *versiautils.URL `json:"deleted"`
}
func (d Delete) MarshalJSON() ([]byte, error) {
type a Delete
d2 := a(d)
d2.Type = "Delete"
return json.Marshal(d2)
}

View file

@ -0,0 +1,99 @@
package versia
import (
"encoding/json"
versiautils "github.com/lysand-org/versia-go/pkg/versia/utils"
)
// Follow defines a follow relationship between two users. For more information, see the [Spec].
//
// Once a follow relationship is established, the followee's instance should send all new notes from the followee to
// the follower's inbox.
//
// [Spec]: https://versia.pub/entities/follow
type Follow struct {
Entity
// Author is the URL to the user that triggered the follow
Author *versiautils.URL `json:"author"`
// Followee is the URL to the user that is being followed
Followee *versiautils.URL `json:"followee"`
}
func (f Follow) MarshalJSON() ([]byte, error) {
type follow Follow
f2 := follow(f)
f2.Type = "Follow"
return json.Marshal(f2)
}
// FollowAccept accepts a Follow request, which will form the follow relationship between the two parties.
// For more information, see the [Spec].
//
// This can only be sent by the Followee.
//
// [Spec]: https://versia.pub/entities/follow-accept
type FollowAccept struct {
Entity
// Author is the URL to the user that accepted the follow
Author *versiautils.URL `json:"author"`
// Follower is the URL to the user that is now following the followee
Follower *versiautils.URL `json:"follower"`
}
func (f FollowAccept) MarshalJSON() ([]byte, error) {
type followAccept FollowAccept
f2 := followAccept(f)
f2.Type = "FollowAccept"
return json.Marshal(f2)
}
// FollowReject rejects a Follow request, which will dismiss the follow relationship between the two parties.
// For more information, see the [Spec].
//
// This can only be sent by the Followee and should not be confused with Unfollow, which can only be sent by the Follower.
// FollowReject can still be sent after the relationship has been formed.
//
// [Spec]: https://versia.pub/entities/follow-reject
type FollowReject struct {
Entity
// Author is the URL to the user that rejected the follow
Author *versiautils.URL `json:"author"`
// Follower is the URL to the user that is no longer following the followee
Follower *versiautils.URL `json:"follower"`
}
func (f FollowReject) MarshalJSON() ([]byte, error) {
type followReject FollowReject
f2 := followReject(f)
f2.Type = "FollowReject"
return json.Marshal(f2)
}
// Unfollow disbands request, which will disband the follow relationship between the two parties.
// For more information, see the [Spec].
//
// This can only be sent by the Follower and should not be confused with FollowReject, which can only be sent by the Followee.
//
// [Spec]: https://versia.pub/entities/unfollow
type Unfollow struct {
Entity
// Author is the URL to the user that unfollowed the followee
Author *versiautils.URL `json:"author"`
// Followee is the URL to the user that has been followed
Followee *versiautils.URL `json:"follower"`
}
func (f Unfollow) MarshalJSON() ([]byte, error) {
type a Unfollow
u := a(f)
u.Type = "Unfollow"
return json.Marshal(u)
}

32
pkg/versia/actor_group.go Normal file
View file

@ -0,0 +1,32 @@
package versia
import (
"encoding/json"
versiautils "github.com/lysand-org/versia-go/pkg/versia/utils"
)
// Group is a way to organize users and notes into communities. For more information, see the [Spec].
//
// [Spec]: https://versia.pub/entities/pub
type Group struct {
Entity
// Name is the group's name / title.
Name versiautils.TextContentTypeMap `json:"name"`
// Description is a description of the group's contents / purpose.
Description versiautils.TextContentTypeMap `json:"description"`
// Members is a list of URLs of the group's members.
Members []versiautils.URL `json:"members"`
// Notes is a URL to the collection of notes associated with this group.
Notes *versiautils.URL `json:"notes"`
}
func (g Group) MarshalJSON() ([]byte, error) {
type a Group
g2 := a(g)
g2.Type = "Group"
return json.Marshal(g2)
}

125
pkg/versia/actor_user.go Normal file
View file

@ -0,0 +1,125 @@
package versia
import (
"encoding/json"
versiacrypto "github.com/lysand-org/versia-go/pkg/versia/crypto"
versiautils "github.com/lysand-org/versia-go/pkg/versia/utils"
)
// 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 UserPublicKey `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 versiautils.ImageContentTypeMap `json:"avatar,omitempty"`
// Header is the header image of the user in different image content types.
// https://lysand.org/objects/user#header
Header versiautils.ImageContentTypeMap `json:"header,omitempty"`
// Bio is the biography of the user in different text content types.
// https://lysand.org/objects/user#bio
Bio versiautils.TextContentTypeMap `json:"bio"`
// Fields is a list of fields that the user has filled out.
// https://lysand.org/objects/user#fields
Fields []UserField `json:"fields,omitempty"`
// Featured is the featured posts of the user.
// https://lysand.org/objects/user#featured
Featured *versiautils.URL `json:"featured"`
// Followers is the followers of the user.
// https://lysand.org/objects/user#followers
Followers *versiautils.URL `json:"followers"`
// Following is the users that the user is following.
// https://lysand.org/objects/user#following
Following *versiautils.URL `json:"following"`
// Inbox is the inbox of the user.
// https://lysand.org/objects/user#posts
Inbox *versiautils.URL `json:"inbox"`
// Outbox is the outbox of the user.
// https://lysand.org/objects/user#outbox
Outbox *versiautils.URL `json:"outbox"`
}
func (u User) MarshalJSON() ([]byte, error) {
type user User
u2 := user(u)
u2.Type = "User"
return json.Marshal(u2)
}
type UserField struct {
Key versiautils.TextContentTypeMap `json:"key"`
Value versiautils.TextContentTypeMap `json:"value"`
}
// UserPublicKey represents a public key for a user. For more information, see the [Spec].
//
// [Spec]: https://lysand.org/security/keys#public-key-cryptography
type UserPublicKey struct {
Actor *versiautils.URL `json:"actor"`
// Algorithm can only be `ed25519` for now
Algorithm string `json:"algorithm"`
Key *versiacrypto.SPKIPublicKey `json:"-"`
RawKey json.RawMessage `json:"key"`
}
func (k *UserPublicKey) UnmarshalJSON(raw []byte) error {
type t UserPublicKey
k2 := (*t)(k)
if err := json.Unmarshal(raw, k2); err != nil {
return err
}
var err error
if k2.Key, err = versiacrypto.UnmarshalSPKIPubKey(k2.Algorithm, k2.RawKey); err != nil {
return err
}
*k = UserPublicKey(*k2)
return nil
}
func (k UserPublicKey) MarshalJSON() ([]byte, error) {
type t UserPublicKey
k2 := t(k)
var err error
if k2.RawKey, err = k2.Key.MarshalJSON(); err != nil {
return nil, err
}
return json.Marshal(k2)
}

26
pkg/versia/attachment.go Normal file
View file

@ -0,0 +1,26 @@
package versia
// 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"`
}

31
pkg/versia/collection.go Normal file
View file

@ -0,0 +1,31 @@
package versia
import versiautils "github.com/lysand-org/versia-go/pkg/versia/utils"
// Collection is a paginated group of entities. For more information, see the [Spec].
//
// [Spec]: https://versia.pub/structures/collection
type Collection[T any] struct {
// Author represents the author of the collection. `nil` is used to represent the instance.
Author *versiautils.URL `json:"author"`
// First is a URI to the first page of the collection.
First *versiautils.URL `json:"first"`
// Last is a URI to the last page of the collection.
// If the collection only has one page, this should be the same as First.
Last *versiautils.URL `json:"last"`
// Total is a count of all entities in the collection across all pages.
Total uint64 `json:"total"`
// Next is a URI to the next page of the collection. If there's no next page, this should be `nil`.
Next *versiautils.URL `json:"next"`
// Previous is a URI to the previous page of the collection. If there's no next page, this should be `nil`.
// FIXME(spec): The spec uses `prev` instead of `previous` as the field name.
Previous *versiautils.URL `json:"previous"`
// Items is a list of T for the current page of the collection.
Items []T `json:"items"`
}

View file

@ -0,0 +1,39 @@
package versiacrypto
import (
"crypto/ed25519"
"crypto/x509"
"encoding/base64"
"github.com/stretchr/testify/assert"
"net/url"
"testing"
)
func TestFederationClient_ValidateSignatureHeader(t *testing.T) {
var (
bobURL = &url.URL{Scheme: "https", Host: "bob.com"}
bobPrivBytes = must(base64.StdEncoding.DecodeString, "MC4CAQAwBQYDK2VwBCIEINOATgmaya61Ha9OEE+DD3RnOEqDaHyQ3yLf5upwskUU")
bobPriv = must(x509.ParsePKCS8PrivateKey, bobPrivBytes).(ed25519.PrivateKey)
signer = Signer{PrivateKey: bobPriv, UserURL: bobURL}
bobPubBytes = must(base64.StdEncoding.DecodeString, "MCowBQYDK2VwAyEAQ08Z/FJ5f16o8mthLaFZMo4ssn0fJ7c+bipNYm3kId4=")
bobPub = must(x509.ParsePKIXPublicKey, bobPubBytes).(ed25519.PublicKey)
verifier = Verifier{PublicKey: bobPub}
method = "POST"
nonce = "myrandomnonce"
u = &url.URL{Scheme: "https", Host: "bob.com", Path: "/a/b/c", RawQuery: "z=foo&a=bar"}
body = []byte("hello")
)
toSign := NewSignatureData(method, nonce, u, hashSHA256(body))
assert.Equal(t, `post /a/b/c?z=foo&a=bar myrandomnonce LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=`, toSign.String())
signed := signer.Sign(*toSign)
assert.Equal(t, true, verifier.Verify(method, u, body, signed), "signature verification failed")
assert.Equal(t, "myrandomnonce", signed.Nonce)
assert.Equal(t, bobURL, signed.SignedBy)
assert.Equal(t, "datQHNaqJ1jeKzK3UeReUVf+B65JPq5P9LxfqUUJTMv3QNqDu5KawosKoduIRk4/D/A+EKjDhlcw0c7GzUlMCA==", base64.StdEncoding.EncodeToString(signed.Signature))
}

View file

@ -0,0 +1,67 @@
package versiacrypto
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
)
// FederationHeaders represents the signature header of the Lysand protocol. For more information, see the [Spec].
//
// [Spec]: https://versia.pub/signatures#signature-definition
type FederationHeaders struct {
// SignedBy is the URL to a user
SignedBy *url.URL
// Nonce is a random string, used to prevent replay attacks
Nonce string
// Signature is the signature of the request
Signature []byte
}
func (f *FederationHeaders) Inject(h http.Header) {
h.Set("x-signed-by", f.SignedBy.String())
h.Set("x-nonce", f.Nonce)
h.Set("x-signature", base64.StdEncoding.EncodeToString(f.Signature))
}
func (f *FederationHeaders) Headers() map[string]string {
return map[string]string{
"x-signed-by": f.SignedBy.String(),
"x-nonce": f.Nonce,
"x-signature": base64.StdEncoding.EncodeToString(f.Signature),
}
}
func ExtractFederationHeaders(h http.Header) (*FederationHeaders, error) {
signedBy := h.Get("x-signed-by")
if signedBy == "" {
return nil, fmt.Errorf("missing x-signed-by header")
}
u, err := url.Parse(signedBy)
if err != nil {
return nil, err
}
nonce := h.Get("x-nonce")
if nonce == "" {
return nil, fmt.Errorf("missing x-nonce header")
}
rawSignature := h.Get("x-signature")
if rawSignature == "" {
return nil, fmt.Errorf("missing x-signature header")
}
signature, err := base64.StdEncoding.DecodeString(rawSignature)
if err != nil {
return nil, err
}
return &FederationHeaders{
SignedBy: u,
Nonce: nonce,
Signature: signature,
}, nil
}

View file

@ -0,0 +1,18 @@
package versiacrypto
import (
"github.com/stretchr/testify/assert"
"net/url"
"testing"
)
func TestFederationHeaders_String(t *testing.T) {
one := SignatureData{
RequestMethod: "POST",
Nonce: "1234567890",
URL: &url.URL{Scheme: "https", Host: "bob.com", Path: "/users/bob", RawQuery: "z=foo&a=bar"},
Digest: hashSHA256([]byte("hello")),
}
assert.Equal(t, "post /users/bob?z=foo&a=bar 1234567890 LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=", one.String())
}

23
pkg/versia/crypto/keys.go Normal file
View file

@ -0,0 +1,23 @@
package versiacrypto
import (
"crypto/ed25519"
"fmt"
)
type UnknownPublicKeyTypeError struct {
Got string
}
func (i UnknownPublicKeyTypeError) Error() string {
return fmt.Sprintf("unknown public key type: \"%s\"", i.Got)
}
func ToTypedKey(algorithm string, raw []byte) (any, error) {
switch algorithm {
case "ed25519":
return ed25519.PublicKey(raw), nil
default:
return nil, UnknownPublicKeyTypeError{algorithm}
}
}

View file

@ -0,0 +1,9 @@
package versiacrypto
import "crypto/sha256"
func SHA256(data []byte) []byte {
h := sha256.New()
h.Write(data)
return h.Sum(nil)
}

View file

@ -0,0 +1,94 @@
package versiacrypto
import (
"crypto"
"crypto/ed25519"
"encoding/base64"
"fmt"
"net/url"
"os"
"strings"
)
// SignatureData is a combination of HTTP method, URL (only url.URL#Path and url.URL#RawQuery are required),
// a nonce and the Base64 encoded SHA256 hash of the request body.
// For more information, see the [Spec].
//
// [Spec]: https://versia.pub/signatures
type SignatureData struct {
// RequestMethod is the *lowercase* HTTP method of the request
RequestMethod string
// Nonce is a random byte array, used to prevent replay attacks
Nonce string
// RawPath is the path of the request, without the query string
URL *url.URL
// Digest is the SHA-256 hash of the request body
Digest []byte
}
func NewSignatureData(method, nonce string, u *url.URL, digest []byte) *SignatureData {
return &SignatureData{
RequestMethod: method,
Nonce: nonce,
URL: u,
Digest: digest,
}
}
// String returns the payload to sign
func (s *SignatureData) String() string {
return fmt.Sprintf("%s %s?%s %s %s", strings.ToLower(s.RequestMethod), s.URL.Path, s.URL.RawQuery, s.Nonce, base64.StdEncoding.EncodeToString(s.Digest))
}
// Validate validate that the SignatureData belongs to the provided public key and matches the provided signature.
func (s *SignatureData) Validate(pubKey crypto.PublicKey, signature []byte) bool {
data := []byte(s.String())
verify, err := NewVerify(pubKey)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
return false
}
return verify(data, signature)
}
// Sign signs the SignatureData with the provided private key.
func (s *SignatureData) Sign(privKey ed25519.PrivateKey) []byte {
return ed25519.Sign(privKey, []byte(s.String()))
}
// Signer is an object, with which requests can be signed with the user's private key.
// For more information, see the [Spec].
//
// [Spec]: https://versia.pub/signatures
type Signer struct {
PrivateKey ed25519.PrivateKey
UserURL *url.URL
}
// Sign signs a signature data and returns the headers to inject into the response.
func (s Signer) Sign(signatureData SignatureData) *FederationHeaders {
return &FederationHeaders{
SignedBy: s.UserURL,
Nonce: signatureData.Nonce,
Signature: signatureData.Sign(s.PrivateKey),
}
}
// Verifier is an object, with which requests can be verified against a user's public key.
// For more information, see the [Spec].
//
// [Spec]: https://versia.pub/signatures
type Verifier struct {
PublicKey crypto.PublicKey
}
// Verify verifies a request against the public key provided to it duration object creation.
func (v Verifier) Verify(method string, u *url.URL, body []byte, fedHeaders *FederationHeaders) bool {
return NewSignatureData(method, fedHeaders.Nonce, u, SHA256(body)).
Validate(v.PublicKey, fedHeaders.Signature)
}

View file

@ -0,0 +1,56 @@
package versiacrypto
import (
"crypto"
"crypto/x509"
"encoding/base64"
"encoding/json"
)
// SPKIPublicKey is a type that represents a [ed25519.PublicKey] in the SPKI
// format.
type SPKIPublicKey struct {
Key any
Algorithm string
}
func UnmarshalSPKIPubKey(algorithm string, raw []byte) (*SPKIPublicKey, error) {
rawStr := ""
if err := json.Unmarshal(raw, &rawStr); err != nil {
return nil, err
}
raw, err := base64.StdEncoding.DecodeString(rawStr)
if err != nil {
return nil, err
}
return NewSPKIPubKey(algorithm, raw)
}
// NewSPKIPubKey decodes the public key from a base64 encoded string and then unmarshals it from the SPKI form.
func NewSPKIPubKey(algorithm string, raw []byte) (*SPKIPublicKey, error) {
parsed, err := x509.ParsePKIXPublicKey(raw)
if err != nil {
return nil, err
}
return &SPKIPublicKey{
Key: parsed,
Algorithm: algorithm,
}, nil
}
// MarshalJSON marshals the SPKI-encoded public key to a base64 encoded string.
func (k SPKIPublicKey) MarshalJSON() ([]byte, error) {
raw, err := x509.MarshalPKIXPublicKey(k.Key)
if err != nil {
return nil, err
}
return json.Marshal(base64.StdEncoding.EncodeToString(raw))
}
func (k SPKIPublicKey) ToKey() crypto.PublicKey {
return k.Key
}

View file

@ -0,0 +1,33 @@
package versiacrypto
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 := UserPublicKey{}
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.Key))
}
func TestSPKIPublicKey_MarshalJSON(t *testing.T) {
expectedPk := must(x509.ParsePKIXPublicKey, must(base64.StdEncoding.DecodeString, "MCowBQYDK2VwAyEAgKNt+9eyOXdb7MSrrmHlsFD2H9NGwC+56PjpWD46Tcs=")).(ed25519.PublicKey)
pk := UserPublicKey{
Key: SPKIPublicKey(expectedPk),
}
if _, err := json.Marshal(pk); err != nil {
t.Error(err)
}
}

View file

@ -0,0 +1,10 @@
package versiacrypto
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
}

View file

@ -0,0 +1,29 @@
package versiacrypto
import (
"crypto"
"crypto/ed25519"
"fmt"
"reflect"
)
type InvalidPublicKeyTypeError struct {
Got reflect.Type
}
func (i InvalidPublicKeyTypeError) Error() string {
return fmt.Sprintf("failed to convert public key of type \"%s\"", i.Got.String())
}
type Verify = func(data, signature []byte) bool
func NewVerify(pubKey crypto.PublicKey) (Verify, error) {
switch pk := pubKey.(type) {
case ed25519.PublicKey:
return func(data, signature []byte) bool {
return ed25519.Verify(pk, data, signature)
}, nil
default:
return nil, InvalidPublicKeyTypeError{reflect.TypeOf(pk)}
}
}

44
pkg/versia/entity.go Normal file
View file

@ -0,0 +1,44 @@
package versia
import (
"github.com/google/uuid"
versiautils "github.com/lysand-org/versia-go/pkg/versia/utils"
)
// 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 *versiautils.URL `json:"uri"`
// CreatedAt is the time the entity was created
CreatedAt versiautils.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,85 @@
package versia
import (
"fmt"
"github.com/go-logr/logr"
"github.com/lysand-org/versia-go/pkg/protoretry"
"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
hc *protoretry.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/lysand-org/versia-go/pkg/lysand#0.0.1",
}
c.hc = protoretry.New(c.httpC)
return c
}
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
}

66
pkg/versia/inbox.go Normal file
View file

@ -0,0 +1,66 @@
package versia
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 "Note":
m := Note{}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
return m, nil
case "Group":
m := Group{}
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 "Unfollow":
m := Unfollow{}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
return m, nil
default:
return nil, UnknownEntityTypeError{Type: i.Type}
}
}
type UnknownEntityTypeError struct {
Type string
}
func (e UnknownEntityTypeError) Error() string {
return fmt.Sprintf("unknown entity type: %s", e.Type)
}

View file

@ -0,0 +1,113 @@
package versia
import (
"encoding/json"
versiacrypto "github.com/lysand-org/versia-go/pkg/versia/crypto"
versiautils "github.com/lysand-org/versia-go/pkg/versia/utils"
)
// InstanceMetadata represents the metadata of a Lysand instance. For more information, see the [Spec].
//
// ! Unlike other entities, instance metadata is not meant to be federated.
//
// [Spec]: https://versia.pub/entities/instance-metadata
type InstanceMetadata struct {
// Type is always "InstanceMetadata"
Type string `json:"type"`
// Extensions is a map of active extensions
Extensions Extensions `json:"extensions,omitempty"`
// Name is the name of the instance
Name string `json:"name"`
// Description is a description of the instance
Description *string `json:"description,omitempty"`
// Host is the hostname of the instance, including the port
Host string `json:"host,omitempty"`
// PublicKey is the public key of the instance
PublicKey InstancePublicKey `json:"public_key"`
// SharedInbox is the URL to the instance's shared inbox
SharedInbox *versiautils.URL `json:"shared_inbox,omitempty"`
// Moderators is a URL to a collection of moderators
Moderators *versiautils.URL `json:"moderators,omitempty"`
// Admins is a URL to a collection of administrators
Admins *versiautils.URL `json:"admins,omitempty"`
// Logo is the URL to the instance's logo
Logo *versiautils.ImageContentTypeMap `json:"logo,omitempty"`
// Banner is the URL to the instance's banner
Banner *versiautils.ImageContentTypeMap `json:"banner,omitempty"`
// Software is information about the instance software
Software InstanceSoftware `json:"software"`
// Compatibility is information about the instance's compatibility with different Lysand versions
Compatibility InstanceCompatibility `json:"compatibility"`
}
func (s InstanceMetadata) MarshalJSON() ([]byte, error) {
type instanceMetadata InstanceMetadata
s2 := instanceMetadata(s)
s2.Type = "InstanceMetadata"
return json.Marshal(s2)
}
// InstanceSoftware represents the software of a Lysand instance.
type InstanceSoftware struct {
// Name is the name of the instance software
Name string `json:"name"`
// Version is the version of the instance software
Version string `json:"version"`
}
// InstanceCompatibility represents the compatibility of a Lysand instance.
type InstanceCompatibility struct {
// Versions is a list of versions of Lysand the instance is compatible with
Versions []string `json:"versions"`
// Extensions is a list of extensions supported by the instance
Extensions []string `json:"extensions"`
}
// InstancePublicKey represents the public key of a Versia instance.
type InstancePublicKey struct {
// Algorithm can only be `ed25519` for now
Algorithm string `json:"algorithm"`
Key *versiacrypto.SPKIPublicKey `json:"-"`
RawKey json.RawMessage `json:"key"`
}
func (k *InstancePublicKey) UnmarshalJSON(raw []byte) error {
type t InstancePublicKey
k2 := (*t)(k)
if err := json.Unmarshal(raw, k2); err != nil {
return nil
}
var err error
if k2.Key, err = versiacrypto.UnmarshalSPKIPubKey(k2.Algorithm, k2.RawKey); err != nil {
return nil
}
return nil
}
func (k InstancePublicKey) MarshalJSON() ([]byte, error) {
type t InstancePublicKey
k2 := t(k)
var err error
if k2.RawKey, err = k2.Key.MarshalJSON(); err != nil {
return nil, err
}
return json.Marshal(k2)
}

132
pkg/versia/note.go Normal file
View file

@ -0,0 +1,132 @@
package versia
import (
"encoding/json"
versiautils "github.com/lysand-org/versia-go/pkg/versia/utils"
)
// NoteVisibility is the visibility of a note. For more information, see the [Spec].
//
// TODO:
// [Spec]: https://lysand.org/objects/publications#visibility
type NoteVisibility string
const (
// NoteVisiblePublic means that the Note is visible to everyone.
NoteVisiblePublic NoteVisibility = "public"
// NoteVisibleUnlisted means that the Note is visible everyone, but should not appear in public timelines and search results.
NoteVisibleUnlisted NoteVisibility = "unlisted"
// NoteVisibleFollowers means that the Note is visible to followers only.
NoteVisibleFollowers NoteVisibility = "followers"
// NoteVisibleDirect means that the Note is a direct message, and is visible only to the mentioned users.
NoteVisibleDirect NoteVisibility = "direct"
)
// Note is a published message, similar to a tweet (from Twitter) or a toot (from Mastodon).
// For more information, see the [Spec].
//
// [Spec]: https://versia.pub/entities/note
type Note struct {
Entity
// Author is the URL to the user
// https://lysand.org/objects/publications#author
Author *versiautils.URL `json:"author"`
// Content is the content of the publication
// https://lysand.org/objects/publications#content
Content versiautils.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 *versiautils.URL `json:"group,omitempty"`
// Attachments is a list of attachment objects, keyed by their MIME type
// https://lysand.org/objects/publications#attachments
Attachments []versiautils.ContentTypeMap[Attachment] `json:"attachments,omitempty"`
// RepliesTo is the URL to the publication being replied to
// https://lysand.org/objects/publications#replies-to
RepliesTo *versiautils.URL `json:"replies_to,omitempty"`
// Quoting is the URL to the publication being quoted
// https://lysand.org/objects/publications#quotes
Quoting *versiautils.URL `json:"quoting,omitempty"`
// Mentions is a list of URLs to users
// https://lysand.org/objects/publications#mentionshttps://lysand.org/objects/publications#mentions
Mentions []versiautils.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 NoteVisibility `json:"visibility"`
}
func (p Note) MarshalJSON() ([]byte, error) {
type a Note
n2 := a(p)
n2.Type = "Note"
return json.Marshal(n2)
}
// LinkPreview is a preview of a link. For more information, see the [Spec].
//
// [Spec]: https://lysand.org/objects/publications#types
type LinkPreview struct {
Link *versiautils.URL `json:"link"`
Title string `json:"title"`
Description *string `json:"description"`
Image *versiautils.URL `json:"image"`
Icon *versiautils.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 *versiautils.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,85 @@
package versiautils
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 ""
}

57
pkg/versia/utils/time.go Normal file
View file

@ -0,0 +1,57 @@
package versiautils
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/versia/utils/url.go Normal file
View file

@ -0,0 +1,54 @@
package versiautils
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
}