mirror of
https://github.com/versia-pub/versia-go.git
synced 2026-03-13 04:29:15 +01:00
refactor!: add missing fields and docs
This commit is contained in:
parent
61891d891a
commit
6e59386f60
73 changed files with 726 additions and 580 deletions
32
pkg/versia/action_delete.go
Normal file
32
pkg/versia/action_delete.go
Normal 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)
|
||||
}
|
||||
99
pkg/versia/action_follow.go
Normal file
99
pkg/versia/action_follow.go
Normal 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
32
pkg/versia/actor_group.go
Normal 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
125
pkg/versia/actor_user.go
Normal 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
26
pkg/versia/attachment.go
Normal 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
31
pkg/versia/collection.go
Normal 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"`
|
||||
}
|
||||
39
pkg/versia/crypto/crypto_test.go
Normal file
39
pkg/versia/crypto/crypto_test.go
Normal 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))
|
||||
}
|
||||
67
pkg/versia/crypto/federation_headers.go
Normal file
67
pkg/versia/crypto/federation_headers.go
Normal 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
|
||||
}
|
||||
18
pkg/versia/crypto/federation_headers_test.go
Normal file
18
pkg/versia/crypto/federation_headers_test.go
Normal 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
23
pkg/versia/crypto/keys.go
Normal 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}
|
||||
}
|
||||
}
|
||||
9
pkg/versia/crypto/sha256.go
Normal file
9
pkg/versia/crypto/sha256.go
Normal 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)
|
||||
}
|
||||
94
pkg/versia/crypto/signature_data.go
Normal file
94
pkg/versia/crypto/signature_data.go
Normal 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)
|
||||
}
|
||||
56
pkg/versia/crypto/spki_public_key.go
Normal file
56
pkg/versia/crypto/spki_public_key.go
Normal 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
|
||||
}
|
||||
33
pkg/versia/crypto/spki_public_key_test.go
Normal file
33
pkg/versia/crypto/spki_public_key_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
10
pkg/versia/crypto/utils_test.go
Normal file
10
pkg/versia/crypto/utils_test.go
Normal 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
|
||||
}
|
||||
29
pkg/versia/crypto/verify.go
Normal file
29
pkg/versia/crypto/verify.go
Normal 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
44
pkg/versia/entity.go
Normal 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"
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
85
pkg/versia/federation_client.go
Normal file
85
pkg/versia/federation_client.go
Normal 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
66
pkg/versia/inbox.go
Normal 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)
|
||||
}
|
||||
113
pkg/versia/instance_metadata.go
Normal file
113
pkg/versia/instance_metadata.go
Normal 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
132
pkg/versia/note.go
Normal 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"
|
||||
)
|
||||
85
pkg/versia/utils/content_types.go
Normal file
85
pkg/versia/utils/content_types.go
Normal 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
57
pkg/versia/utils/time.go
Normal 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
54
pkg/versia/utils/url.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue