mirror of
https://github.com/versia-pub/versia-go.git
synced 2026-03-12 20:19:15 +01:00
chore: init
This commit is contained in:
commit
320715f3e7
174 changed files with 42083 additions and 0 deletions
86
internal/api_schema/api.go
Normal file
86
internal/api_schema/api.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package api_schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type APIError struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
Description string `json:"description"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (e APIError) Error() string {
|
||||
if e.Metadata == nil || len(e.Metadata) == 0 {
|
||||
return fmt.Sprintf("APIError: %d - %s", e.StatusCode, e.Description)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("APIError: %d - %s, %s", e.StatusCode, e.Description, stringifyErrorMetadata(e.Metadata))
|
||||
}
|
||||
|
||||
func stringifyErrorMetadata(m map[string]any) string {
|
||||
sb := strings.Builder{}
|
||||
for key, value := range m {
|
||||
sb.WriteString(fmt.Sprintf("%s=%v, ", key, value))
|
||||
}
|
||||
return strings.TrimSuffix(sb.String(), ", ")
|
||||
}
|
||||
|
||||
func (e APIError) Equals(other any) bool {
|
||||
var err *APIError
|
||||
|
||||
switch raw := other.(type) {
|
||||
case *APIError:
|
||||
err = raw
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
return e.StatusCode == err.StatusCode && e.Description == err.Description
|
||||
}
|
||||
|
||||
func (e APIError) URLEncode() (string, error) {
|
||||
v := url.Values{}
|
||||
v.Set("status_code", fmt.Sprintf("%d", e.StatusCode))
|
||||
v.Set("description", e.Description)
|
||||
|
||||
if e.Metadata != nil {
|
||||
b, err := json.Marshal(e.Metadata)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
v.Set("metadata", string(b))
|
||||
}
|
||||
|
||||
// Fix up spaces because Golang's net/url.URL encodes " " as "+" instead of "%20"
|
||||
// https://github.com/golang/go/issues/13982
|
||||
return strings.ReplaceAll(v.Encode(), "+", "%20"), nil
|
||||
}
|
||||
|
||||
func NewAPIError(code int, description string) func(metadata map[string]any) *APIError {
|
||||
return func(metadata map[string]any) *APIError {
|
||||
return &APIError{StatusCode: code, Description: description, Metadata: metadata}
|
||||
}
|
||||
}
|
||||
|
||||
type APIResponse[T any] struct {
|
||||
Ok bool `json:"ok"`
|
||||
Data *T `json:"data"`
|
||||
Error *APIError `json:"error"`
|
||||
}
|
||||
|
||||
func NewFailedAPIResponse[T any](err error) APIResponse[T] {
|
||||
var e *APIError
|
||||
|
||||
if errors.As(err, &e) {
|
||||
} else {
|
||||
e = NewAPIError(500, err.Error())(nil)
|
||||
}
|
||||
|
||||
return APIResponse[T]{Ok: false, Error: e}
|
||||
}
|
||||
15
internal/api_schema/errors.go
Normal file
15
internal/api_schema/errors.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package api_schema
|
||||
|
||||
var (
|
||||
ErrBadRequest = NewAPIError(400, "Bad request")
|
||||
ErrInvalidRequestBody = NewAPIError(400, "Invalid request body")
|
||||
ErrUnauthorized = NewAPIError(401, "Unauthorized")
|
||||
ErrForbidden = NewAPIError(403, "Forbidden")
|
||||
ErrNotFound = NewAPIError(404, "Not found")
|
||||
ErrConflict = NewAPIError(409, "Conflict")
|
||||
ErrUsernameTaken = NewAPIError(409, "Username is taken")
|
||||
ErrRateLimitExceeded = NewAPIError(429, "Rate limit exceeded")
|
||||
|
||||
ErrInternalServerError = NewAPIError(500, "Internal server error")
|
||||
ErrNotImplemented = NewAPIError(501, "Not implemented")
|
||||
)
|
||||
18
internal/api_schema/notes.go
Normal file
18
internal/api_schema/notes.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package api_schema
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/pkg/lysand"
|
||||
)
|
||||
|
||||
type Note struct {
|
||||
ID uuid.UUID `json:"id,string"`
|
||||
}
|
||||
|
||||
type FetchNoteResponse = APIResponse[Note]
|
||||
|
||||
type CreateNoteRequest struct {
|
||||
Content string `json:"content" validate:"required,min=1,max=1024"`
|
||||
Visibility lysand.PublicationVisibility `json:"visibility" validate:"required,oneof=public private direct"`
|
||||
Mentions []lysand.URL `json:"mentions"`
|
||||
}
|
||||
17
internal/api_schema/users.go
Normal file
17
internal/api_schema/users.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package api_schema
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id,string"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type FetchUserResponse = APIResponse[User]
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username" validate:"required,username_regex,min=3,max=32"`
|
||||
Password string `json:"password" validate:"required,min=8,max=256"`
|
||||
}
|
||||
85
internal/database/transaction.go
Normal file
85
internal/database/transaction.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"git.devminer.xyz/devminer/unitel"
|
||||
"github.com/lysand-org/versia-go/ent"
|
||||
)
|
||||
|
||||
func BeginTx(ctx context.Context, db *ent.Client, telemetry *unitel.Telemetry) (*Tx, error) {
|
||||
span := telemetry.StartSpan(ctx, "db.sql.transaction", "BeginTx")
|
||||
ctx = span.Context()
|
||||
|
||||
tx, err := db.Tx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newTx(tx, ctx, span), nil
|
||||
}
|
||||
|
||||
type TxAction uint8
|
||||
|
||||
const (
|
||||
TxActionRollback TxAction = iota
|
||||
TxActionCommit
|
||||
)
|
||||
|
||||
type Tx struct {
|
||||
*ent.Tx
|
||||
ctx context.Context
|
||||
span *unitel.Span
|
||||
|
||||
m sync.Mutex
|
||||
action TxAction
|
||||
|
||||
finishOnce func() error
|
||||
}
|
||||
|
||||
func newTx(tx *ent.Tx, ctx context.Context, span *unitel.Span) *Tx {
|
||||
t := &Tx{
|
||||
Tx: tx,
|
||||
ctx: ctx,
|
||||
span: span,
|
||||
}
|
||||
|
||||
t.finishOnce = sync.OnceValue(t.finish)
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *Tx) MarkForCommit() {
|
||||
t.m.Lock()
|
||||
defer t.m.Unlock()
|
||||
|
||||
t.action = TxActionCommit
|
||||
}
|
||||
|
||||
func (t *Tx) finish() error {
|
||||
t.m.Lock()
|
||||
defer t.m.Unlock()
|
||||
defer t.span.End()
|
||||
|
||||
var err error
|
||||
switch t.action {
|
||||
case TxActionCommit:
|
||||
err = t.Tx.Commit()
|
||||
case TxActionRollback:
|
||||
err = t.Tx.Rollback()
|
||||
}
|
||||
if err != nil {
|
||||
t.span.CaptureError(err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *Tx) Context() context.Context {
|
||||
return t.ctx
|
||||
}
|
||||
|
||||
func (t *Tx) Finish() error {
|
||||
return t.finishOnce()
|
||||
}
|
||||
76
internal/entity/follow.go
Normal file
76
internal/entity/follow.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"github.com/lysand-org/versia-go/ent"
|
||||
"github.com/lysand-org/versia-go/pkg/lysand"
|
||||
)
|
||||
|
||||
type Follow struct {
|
||||
*ent.Follow
|
||||
|
||||
URI *lysand.URL
|
||||
FollowerURI *lysand.URL
|
||||
FolloweeURI *lysand.URL
|
||||
}
|
||||
|
||||
func NewFollow(dbFollow *ent.Follow) (*Follow, error) {
|
||||
f := &Follow{Follow: dbFollow}
|
||||
|
||||
var err error
|
||||
|
||||
f.URI, err = lysand.ParseURL(dbFollow.URI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f.FollowerURI, err = lysand.ParseURL(dbFollow.Edges.Follower.URI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f.FolloweeURI, err = lysand.ParseURL(dbFollow.Edges.Followee.URI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (f Follow) ToLysand() *lysand.Follow {
|
||||
return &lysand.Follow{
|
||||
Entity: lysand.Entity{
|
||||
ID: f.ID,
|
||||
URI: f.URI,
|
||||
CreatedAt: lysand.TimeFromStd(f.CreatedAt),
|
||||
Extensions: f.Extensions,
|
||||
},
|
||||
Author: f.FollowerURI,
|
||||
Followee: f.FolloweeURI,
|
||||
}
|
||||
}
|
||||
|
||||
func (f Follow) ToLysandAccept() *lysand.FollowAccept {
|
||||
return &lysand.FollowAccept{
|
||||
Entity: lysand.Entity{
|
||||
ID: f.ID,
|
||||
URI: f.URI,
|
||||
CreatedAt: lysand.TimeFromStd(f.CreatedAt),
|
||||
Extensions: f.Extensions,
|
||||
},
|
||||
Author: f.FolloweeURI,
|
||||
Follower: f.FollowerURI,
|
||||
}
|
||||
}
|
||||
|
||||
func (f Follow) ToLysandReject() *lysand.FollowReject {
|
||||
return &lysand.FollowReject{
|
||||
Entity: lysand.Entity{
|
||||
ID: f.ID,
|
||||
URI: f.URI,
|
||||
CreatedAt: lysand.TimeFromStd(f.CreatedAt),
|
||||
Extensions: f.Extensions,
|
||||
},
|
||||
Author: f.FolloweeURI,
|
||||
Follower: f.FollowerURI,
|
||||
}
|
||||
}
|
||||
70
internal/entity/note.go
Normal file
70
internal/entity/note.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"github.com/lysand-org/versia-go/ent"
|
||||
"github.com/lysand-org/versia-go/pkg/lysand"
|
||||
)
|
||||
|
||||
type Note struct {
|
||||
*ent.Note
|
||||
URI *lysand.URL
|
||||
Content lysand.TextContentTypeMap
|
||||
Author *User
|
||||
Mentions []User
|
||||
MentionURIs []lysand.URL
|
||||
}
|
||||
|
||||
func NewNote(dbNote *ent.Note) (*Note, error) {
|
||||
n := &Note{
|
||||
Note: dbNote,
|
||||
Content: lysand.TextContentTypeMap{
|
||||
"text/plain": lysand.TextContent{Content: dbNote.Content},
|
||||
},
|
||||
Mentions: make([]User, 0, len(dbNote.Edges.Mentions)),
|
||||
MentionURIs: make([]lysand.URL, 0, len(dbNote.Edges.Mentions)),
|
||||
}
|
||||
|
||||
var err error
|
||||
if n.URI, err = lysand.ParseURL(dbNote.URI); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n.Author, err = NewUser(dbNote.Edges.Author); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range dbNote.Edges.Mentions {
|
||||
u, err := NewUser(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n.Mentions = append(n.Mentions, *u)
|
||||
n.MentionURIs = append(n.MentionURIs, *u.URI)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (n Note) ToLysand() lysand.Note {
|
||||
return lysand.Note{
|
||||
Entity: lysand.Entity{
|
||||
ID: n.ID,
|
||||
URI: n.URI,
|
||||
CreatedAt: lysand.TimeFromStd(n.CreatedAt),
|
||||
Extensions: n.Extensions,
|
||||
},
|
||||
Author: n.Author.URI,
|
||||
Content: n.Content,
|
||||
Category: nil,
|
||||
Device: nil,
|
||||
Previews: nil,
|
||||
Group: nil,
|
||||
Attachments: nil,
|
||||
RepliesTo: nil,
|
||||
Quoting: nil,
|
||||
Mentions: n.MentionURIs,
|
||||
Subject: n.Subject,
|
||||
IsSensitive: &n.IsSensitive,
|
||||
Visibility: lysand.PublicationVisibility(n.Visibility),
|
||||
}
|
||||
}
|
||||
139
internal/entity/user.go
Normal file
139
internal/entity/user.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"github.com/lysand-org/versia-go/internal/helpers"
|
||||
"net/url"
|
||||
|
||||
"github.com/lysand-org/versia-go/ent"
|
||||
"github.com/lysand-org/versia-go/internal/utils"
|
||||
"github.com/lysand-org/versia-go/pkg/lysand"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
*ent.User
|
||||
|
||||
URI *lysand.URL
|
||||
Inbox *lysand.URL
|
||||
Outbox *lysand.URL
|
||||
Featured *lysand.URL
|
||||
Followers *lysand.URL
|
||||
Following *lysand.URL
|
||||
|
||||
DisplayName string
|
||||
LysandAvatar lysand.ImageContentTypeMap
|
||||
LysandBiography lysand.TextContentTypeMap
|
||||
Signer lysand.Signer
|
||||
}
|
||||
|
||||
func NewUser(dbUser *ent.User) (*User, error) {
|
||||
u := &User{User: dbUser}
|
||||
|
||||
u.DisplayName = u.Username
|
||||
if dbUser.DisplayName != nil {
|
||||
u.DisplayName = *dbUser.DisplayName
|
||||
}
|
||||
|
||||
var err error
|
||||
if u.URI, err = lysand.ParseURL(dbUser.URI); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Inbox, err = lysand.ParseURL(dbUser.Inbox); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Outbox, err = lysand.ParseURL(dbUser.Outbox); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Featured, err = lysand.ParseURL(dbUser.Featured); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Followers, err = lysand.ParseURL(dbUser.Followers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Following, err = lysand.ParseURL(dbUser.Following); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u.LysandAvatar = lysandAvatar(dbUser)
|
||||
u.LysandBiography = lysandBiography(dbUser)
|
||||
u.Signer = lysand.Signer{
|
||||
PrivateKey: dbUser.PrivateKey,
|
||||
UserURL: u.URI.ToStd(),
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (u User) ToLysand() *lysand.User {
|
||||
return &lysand.User{
|
||||
Entity: lysand.Entity{
|
||||
ID: u.ID,
|
||||
URI: u.URI,
|
||||
CreatedAt: lysand.TimeFromStd(u.CreatedAt),
|
||||
Extensions: u.Extensions,
|
||||
},
|
||||
DisplayName: helpers.StringPtr(u.DisplayName),
|
||||
Username: u.Username,
|
||||
Avatar: u.LysandAvatar,
|
||||
Header: imageMap(u.Edges.HeaderImage),
|
||||
Indexable: u.Indexable,
|
||||
PublicKey: lysand.PublicKey{
|
||||
Actor: utils.UserAPIURL(u.ID),
|
||||
PublicKey: lysand.SPKIPublicKey(u.PublicKey),
|
||||
},
|
||||
Bio: u.LysandBiography,
|
||||
Fields: u.Fields,
|
||||
|
||||
Inbox: u.Inbox,
|
||||
Outbox: u.Outbox,
|
||||
Featured: u.Featured,
|
||||
Followers: u.Followers,
|
||||
Following: u.Following,
|
||||
|
||||
// TODO: Remove these, they got deprecated and moved into an extension
|
||||
Likes: utils.UserLikesAPIURL(u.ID),
|
||||
Dislikes: utils.UserDislikesAPIURL(u.ID),
|
||||
}
|
||||
}
|
||||
|
||||
func lysandAvatar(u *ent.User) lysand.ImageContentTypeMap {
|
||||
if avatar := imageMap(u.Edges.AvatarImage); avatar != nil {
|
||||
return avatar
|
||||
}
|
||||
|
||||
return lysand.ImageContentTypeMap{
|
||||
"image/svg+xml": lysand.ImageContent{
|
||||
Content: utils.DefaultAvatarURL(u.ID),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func lysandBiography(u *ent.User) lysand.TextContentTypeMap {
|
||||
if u.Biography == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Render HTML
|
||||
|
||||
return lysand.TextContentTypeMap{
|
||||
"text/html": lysand.TextContent{
|
||||
Content: *u.Biography,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func imageMap(i *ent.Image) lysand.ImageContentTypeMap {
|
||||
if i == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(i.URL)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return lysand.ImageContentTypeMap{
|
||||
i.MimeType: {
|
||||
Content: (*lysand.URL)(u),
|
||||
},
|
||||
}
|
||||
}
|
||||
33
internal/handlers/follow_handler/handler.go
Normal file
33
internal/handlers/follow_handler/handler.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package follow_handler
|
||||
|
||||
import (
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/lysand-org/versia-go/config"
|
||||
"github.com/lysand-org/versia-go/internal/service"
|
||||
"github.com/lysand-org/versia-go/pkg/webfinger"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
followService service.FollowService
|
||||
federationService service.FederationService
|
||||
|
||||
hostMeta webfinger.HostMeta
|
||||
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
func New(followService service.FollowService, federationService service.FederationService, log logr.Logger) *Handler {
|
||||
return &Handler{
|
||||
followService: followService,
|
||||
federationService: federationService,
|
||||
|
||||
hostMeta: webfinger.NewHostMeta(config.C.PublicAddress),
|
||||
|
||||
log: log.WithName("users"),
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Handler) Register(r fiber.Router) {
|
||||
r.Get("/api/follows/:id", i.GetLysandFollow)
|
||||
}
|
||||
28
internal/handlers/follow_handler/lysand_follow_get.go
Normal file
28
internal/handlers/follow_handler/lysand_follow_get.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package follow_handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/internal/api_schema"
|
||||
)
|
||||
|
||||
func (i *Handler) GetLysandFollow(c *fiber.Ctx) error {
|
||||
parsedRequestedFollowID, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
return api_schema.ErrBadRequest(map[string]any{"reason": "Invalid follow ID"})
|
||||
}
|
||||
|
||||
f, err := i.followService.GetFollow(c.UserContext(), parsedRequestedFollowID)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to query follow", "id", parsedRequestedFollowID)
|
||||
|
||||
return api_schema.ErrInternalServerError(map[string]any{"reason": "Failed to query follow"})
|
||||
}
|
||||
if f == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
||||
"error": "Follow not found",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(f.ToLysand())
|
||||
}
|
||||
28
internal/handlers/meta_handler/handler.go
Normal file
28
internal/handlers/meta_handler/handler.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package meta_handler
|
||||
|
||||
import (
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/lysand-org/versia-go/config"
|
||||
"github.com/lysand-org/versia-go/pkg/webfinger"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
hostMeta webfinger.HostMeta
|
||||
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
func New(log logr.Logger) *Handler {
|
||||
return &Handler{
|
||||
hostMeta: webfinger.NewHostMeta(config.C.PublicAddress),
|
||||
|
||||
log: log.WithName("users"),
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Handler) Register(r fiber.Router) {
|
||||
r.Get("/.well-known/lysand", i.GetLysandServerMetadata)
|
||||
r.Get("/.well-known/host-meta", i.GetHostMeta)
|
||||
r.Get("/.well-known/host-meta.json", i.GetHostMetaJSON)
|
||||
}
|
||||
28
internal/handlers/meta_handler/lysand_server_metadata_get.go
Normal file
28
internal/handlers/meta_handler/lysand_server_metadata_get.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package meta_handler
|
||||
|
||||
import (
|
||||
"github.com/Masterminds/semver"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/lysand-org/versia-go/config"
|
||||
"github.com/lysand-org/versia-go/pkg/lysand"
|
||||
)
|
||||
|
||||
func (i *Handler) GetLysandServerMetadata(c *fiber.Ctx) error {
|
||||
return c.JSON(lysand.ServerMetadata{
|
||||
// TODO: Get version from build linker flags
|
||||
Version: semver.MustParse("0.0.0-dev"),
|
||||
|
||||
Name: config.C.InstanceName,
|
||||
Description: config.C.InstanceDescription,
|
||||
Website: lysand.URLFromStd(config.C.PublicAddress),
|
||||
|
||||
// TODO: Get more info
|
||||
Moderators: nil,
|
||||
Admins: nil,
|
||||
Logo: nil,
|
||||
Banner: nil,
|
||||
|
||||
SupportedExtensions: []string{},
|
||||
Extensions: map[string]any{},
|
||||
})
|
||||
}
|
||||
23
internal/handlers/meta_handler/wellknown_host_meta.go
Normal file
23
internal/handlers/meta_handler/wellknown_host_meta.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package meta_handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func (i *Handler) GetHostMeta(c *fiber.Ctx) error {
|
||||
if c.Accepts(fiber.MIMEApplicationJSON) != "" {
|
||||
return i.GetHostMetaJSON(c)
|
||||
}
|
||||
|
||||
if c.Accepts(fiber.MIMEApplicationXML) != "" {
|
||||
c.Set(fiber.HeaderContentType, fiber.MIMEApplicationXMLCharsetUTF8)
|
||||
return c.Send(i.hostMeta.XML)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusNotAcceptable).SendString("Not Acceptable")
|
||||
}
|
||||
|
||||
func (i *Handler) GetHostMetaJSON(c *fiber.Ctx) error {
|
||||
c.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSONCharsetUTF8)
|
||||
return c.Send(i.hostMeta.JSON)
|
||||
}
|
||||
32
internal/handlers/note_handler/app_note_create.go
Normal file
32
internal/handlers/note_handler/app_note_create.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package note_handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/lysand-org/versia-go/internal/api_schema"
|
||||
)
|
||||
|
||||
func (i *Handler) CreateNote(c *fiber.Ctx) error {
|
||||
req := api_schema.CreateNoteRequest{}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "invalid request",
|
||||
})
|
||||
}
|
||||
if err := i.bodyValidator.Validate(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
n, err := i.noteService.CreateNote(c.UserContext(), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "failed to create note",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(api_schema.Note{
|
||||
ID: n.ID,
|
||||
})
|
||||
}
|
||||
28
internal/handlers/note_handler/app_note_get.go
Normal file
28
internal/handlers/note_handler/app_note_get.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package note_handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/internal/api_schema"
|
||||
)
|
||||
|
||||
func (i *Handler) GetNote(c *fiber.Ctx) error {
|
||||
parsedRequestedNoteID, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
return api_schema.ErrBadRequest(map[string]any{
|
||||
"reason": "Invalid note ID",
|
||||
})
|
||||
}
|
||||
|
||||
u, err := i.noteService.GetNote(c.UserContext(), parsedRequestedNoteID)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to query note", "id", parsedRequestedNoteID)
|
||||
|
||||
return api_schema.ErrInternalServerError(map[string]any{"reason": "Failed to query note"})
|
||||
}
|
||||
if u == nil {
|
||||
return api_schema.ErrNotFound(nil)
|
||||
}
|
||||
|
||||
return c.JSON(u.ToLysand())
|
||||
}
|
||||
35
internal/handlers/note_handler/handler.go
Normal file
35
internal/handlers/note_handler/handler.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package note_handler
|
||||
|
||||
import (
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/lysand-org/versia-go/config"
|
||||
"github.com/lysand-org/versia-go/internal/service"
|
||||
"github.com/lysand-org/versia-go/internal/validators"
|
||||
"github.com/lysand-org/versia-go/pkg/webfinger"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
noteService service.NoteService
|
||||
bodyValidator validators.BodyValidator
|
||||
|
||||
hostMeta webfinger.HostMeta
|
||||
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
func New(noteService service.NoteService, bodyValidator validators.BodyValidator, log logr.Logger) *Handler {
|
||||
return &Handler{
|
||||
noteService: noteService,
|
||||
bodyValidator: bodyValidator,
|
||||
|
||||
hostMeta: webfinger.NewHostMeta(config.C.PublicAddress),
|
||||
|
||||
log: log.WithName("users"),
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Handler) Register(r fiber.Router) {
|
||||
r.Get("/api/app/notes/:id", i.GetNote)
|
||||
r.Post("/api/app/notes/", i.CreateNote)
|
||||
}
|
||||
35
internal/handlers/user_handler/app_user_create.go
Normal file
35
internal/handlers/user_handler/app_user_create.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package user_handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/lysand-org/versia-go/ent"
|
||||
"github.com/lysand-org/versia-go/internal/api_schema"
|
||||
)
|
||||
|
||||
func (i *Handler) CreateUser(c *fiber.Ctx) error {
|
||||
var req api_schema.CreateUserRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return api_schema.ErrInvalidRequestBody(nil)
|
||||
}
|
||||
|
||||
if err := i.bodyValidator.Validate(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u, err := i.userService.NewUser(c.UserContext(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
// TODO: Wrap this in a custom error
|
||||
if ent.IsConstraintError(err) {
|
||||
return api_schema.ErrUsernameTaken(nil)
|
||||
}
|
||||
|
||||
i.log.Error(err, "Failed to create user", "username", req.Username)
|
||||
|
||||
return api_schema.ErrInternalServerError(nil)
|
||||
}
|
||||
|
||||
return c.JSON(api_schema.User{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
})
|
||||
}
|
||||
31
internal/handlers/user_handler/app_user_get.go
Normal file
31
internal/handlers/user_handler/app_user_get.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package user_handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/internal/api_schema"
|
||||
)
|
||||
|
||||
func (i *Handler) GetUser(c *fiber.Ctx) error {
|
||||
parsedRequestedUserID, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Invalid user ID",
|
||||
})
|
||||
}
|
||||
|
||||
u, err := i.userService.GetUserByID(c.UserContext(), parsedRequestedUserID)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to query user", "id", parsedRequestedUserID)
|
||||
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Failed to query user",
|
||||
})
|
||||
|
||||
}
|
||||
if u == nil {
|
||||
return api_schema.ErrNotFound(map[string]any{"id": parsedRequestedUserID})
|
||||
}
|
||||
|
||||
return c.JSON(u)
|
||||
}
|
||||
46
internal/handlers/user_handler/handler.go
Normal file
46
internal/handlers/user_handler/handler.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package user_handler
|
||||
|
||||
import (
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/lysand-org/versia-go/internal/service"
|
||||
"github.com/lysand-org/versia-go/internal/validators"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
userService service.UserService
|
||||
federationService service.FederationService
|
||||
inboxService service.InboxService
|
||||
|
||||
bodyValidator validators.BodyValidator
|
||||
requestValidator validators.RequestValidator
|
||||
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
func New(userService service.UserService, federationService service.FederationService, inboxService service.InboxService, bodyValidator validators.BodyValidator, requestValidator validators.RequestValidator, log logr.Logger) *Handler {
|
||||
return &Handler{
|
||||
userService: userService,
|
||||
federationService: federationService,
|
||||
inboxService: inboxService,
|
||||
|
||||
bodyValidator: bodyValidator,
|
||||
requestValidator: requestValidator,
|
||||
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Handler) Register(r fiber.Router) {
|
||||
// TODO: Handle this differently
|
||||
// There might be other routes that might want to also add their stuff to the robots.txt
|
||||
r.Get("/robots.txt", i.RobotsTXT)
|
||||
|
||||
r.Get("/.well-known/webfinger", i.Webfinger)
|
||||
|
||||
r.Get("/api/app/users/:id", i.GetUser)
|
||||
r.Post("/api/app/users/", i.CreateUser)
|
||||
|
||||
r.Get("/api/users/:id", i.GetLysandUser)
|
||||
r.Post("/api/users/:id/inbox", i.LysandInbox)
|
||||
}
|
||||
57
internal/handlers/user_handler/lysand_inbox.go
Normal file
57
internal/handlers/user_handler/lysand_inbox.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package user_handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/lysand-org/versia-go/internal/validators/val_impls"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/internal/api_schema"
|
||||
"github.com/lysand-org/versia-go/pkg/lysand"
|
||||
)
|
||||
|
||||
func (i *Handler) LysandInbox(c *fiber.Ctx) error {
|
||||
if err := i.requestValidator.ValidateFiberCtx(c.UserContext(), c); err != nil {
|
||||
if errors.Is(err, val_impls.ErrInvalidSignature) {
|
||||
i.log.Error(err, "Invalid signature")
|
||||
return c.SendStatus(fiber.StatusUnauthorized)
|
||||
}
|
||||
|
||||
i.log.Error(err, "Failed to validate signature")
|
||||
return err
|
||||
}
|
||||
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(c.Body(), &raw); err != nil {
|
||||
i.log.Error(err, "Failed to unmarshal inbox object")
|
||||
return api_schema.ErrBadRequest(nil)
|
||||
}
|
||||
|
||||
obj, err := lysand.ParseInboxObject(raw)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to parse inbox object")
|
||||
|
||||
if errors.Is(err, lysand.ErrUnknownType{}) {
|
||||
return api_schema.ErrNotFound(map[string]any{
|
||||
"error": fmt.Sprintf("Unknown object type: %s", err.(lysand.ErrUnknownType).Type),
|
||||
})
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
userId, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Invalid user ID",
|
||||
})
|
||||
}
|
||||
|
||||
if err := i.inboxService.Handle(c.UserContext(), obj, userId); err != nil {
|
||||
i.log.Error(err, "Failed to handle inbox", "user", userId)
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
31
internal/handlers/user_handler/lysand_user_get.go
Normal file
31
internal/handlers/user_handler/lysand_user_get.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package user_handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/internal/api_schema"
|
||||
)
|
||||
|
||||
func (i *Handler) GetLysandUser(c *fiber.Ctx) error {
|
||||
parsedRequestedUserID, err := uuid.Parse(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Invalid user ID",
|
||||
})
|
||||
}
|
||||
|
||||
u, err := i.userService.GetUserByID(c.UserContext(), parsedRequestedUserID)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to query user", "id", parsedRequestedUserID)
|
||||
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Failed to query user",
|
||||
"id": parsedRequestedUserID,
|
||||
})
|
||||
}
|
||||
if u == nil {
|
||||
return api_schema.ErrNotFound(map[string]any{"id": parsedRequestedUserID})
|
||||
}
|
||||
|
||||
return c.JSON(u.ToLysand())
|
||||
}
|
||||
9
internal/handlers/user_handler/robots_txt.go
Normal file
9
internal/handlers/user_handler/robots_txt.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package user_handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func (i *Handler) RobotsTXT(c *fiber.Ctx) error {
|
||||
return c.SendString("")
|
||||
}
|
||||
32
internal/handlers/user_handler/wellknown_webfinger.go
Normal file
32
internal/handlers/user_handler/wellknown_webfinger.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package user_handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/lysand-org/versia-go/config"
|
||||
"github.com/lysand-org/versia-go/internal/helpers"
|
||||
"github.com/lysand-org/versia-go/pkg/webfinger"
|
||||
)
|
||||
|
||||
func (i *Handler) Webfinger(c *fiber.Ctx) error {
|
||||
userID, err := webfinger.ParseResource(c.Query("resource"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(webfinger.Response{
|
||||
Error: helpers.StringPtr(err.Error()),
|
||||
})
|
||||
}
|
||||
|
||||
if userID.Domain != config.C.PublicAddress.Host {
|
||||
return c.Status(fiber.StatusNotFound).JSON(webfinger.Response{
|
||||
Error: helpers.StringPtr("The requested user is a remote user"),
|
||||
})
|
||||
}
|
||||
|
||||
wf, err := i.userService.GetWebfingerForUser(c.UserContext(), userID.ID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(webfinger.Response{
|
||||
Error: helpers.StringPtr("Failed to query user"),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(wf.WebFingerResource())
|
||||
}
|
||||
22
internal/helpers/crypto.go
Normal file
22
internal/helpers/crypto.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package helpers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"time"
|
||||
)
|
||||
|
||||
func HashSHA256(data []byte) []byte {
|
||||
h := sha256.New()
|
||||
|
||||
h.Write(data)
|
||||
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func ISO8601(t time.Time) string {
|
||||
return t.Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
|
||||
func ParseISO8601(s string) (time.Time, error) {
|
||||
return time.Parse("2006-01-02T15:04:05Z", s)
|
||||
}
|
||||
5
internal/helpers/ptr.go
Normal file
5
internal/helpers/ptr.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package helpers
|
||||
|
||||
func StringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
171
internal/repository/repo_impls/follow_repository_impl.go
Normal file
171
internal/repository/repo_impls/follow_repository_impl.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
package repo_impls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/lysand-org/versia-go/internal/repository"
|
||||
|
||||
"git.devminer.xyz/devminer/unitel"
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/ent"
|
||||
"github.com/lysand-org/versia-go/ent/follow"
|
||||
"github.com/lysand-org/versia-go/ent/predicate"
|
||||
"github.com/lysand-org/versia-go/ent/user"
|
||||
"github.com/lysand-org/versia-go/internal/entity"
|
||||
"github.com/lysand-org/versia-go/internal/utils"
|
||||
)
|
||||
|
||||
var ErrFollowAlreadyExists = errors.New("follow already exists")
|
||||
|
||||
var _ repository.FollowRepository = (*FollowRepositoryImpl)(nil)
|
||||
|
||||
type FollowRepositoryImpl struct {
|
||||
db *ent.Client
|
||||
log logr.Logger
|
||||
telemetry *unitel.Telemetry
|
||||
}
|
||||
|
||||
func NewFollowRepositoryImpl(db *ent.Client, log logr.Logger, telemetry *unitel.Telemetry) repository.FollowRepository {
|
||||
return &FollowRepositoryImpl{
|
||||
db: db,
|
||||
log: log,
|
||||
telemetry: telemetry,
|
||||
}
|
||||
}
|
||||
|
||||
func (i FollowRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entity.Follow, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.FollowRepositoryImpl.GetByID").
|
||||
AddAttribute("followID", id)
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
f, err := i.db.Follow.Query().
|
||||
Where(follow.ID(id)).
|
||||
WithFollowee().
|
||||
WithFollower().
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.AddAttribute("follower", f.Edges.Follower.URI).
|
||||
AddAttribute("followee", f.Edges.Followee.URI).
|
||||
AddAttribute("followURI", f.URI)
|
||||
|
||||
return entity.NewFollow(f)
|
||||
}
|
||||
|
||||
func (i FollowRepositoryImpl) Follow(ctx context.Context, follower, followee *entity.User) (*entity.Follow, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.FollowRepositoryImpl.Follow").
|
||||
AddAttribute("follower", follower.URI).
|
||||
AddAttribute("followee", followee.URI)
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
fid := uuid.New()
|
||||
|
||||
fid, err := i.db.Follow.Create().
|
||||
SetID(fid).
|
||||
SetIsRemote(false).
|
||||
SetURI(utils.UserAPIURL(fid).String()).
|
||||
SetStatus(follow.StatusPending).
|
||||
SetFollower(follower.User).
|
||||
SetFollowee(followee.User).
|
||||
OnConflictColumns(follow.FollowerColumn, follow.FolloweeColumn).
|
||||
UpdateStatus().
|
||||
ID(ctx)
|
||||
if err != nil {
|
||||
if !ent.IsConstraintError(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, ErrFollowAlreadyExists
|
||||
}
|
||||
|
||||
s.AddAttribute("followID", fid)
|
||||
|
||||
f, err := i.db.Follow.Query().
|
||||
Where(follow.ID(fid)).
|
||||
WithFollowee().
|
||||
WithFollower().
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.AddAttribute("followURI", f.URI)
|
||||
|
||||
return entity.NewFollow(f)
|
||||
}
|
||||
|
||||
func (i FollowRepositoryImpl) Unfollow(ctx context.Context, follower, followee *entity.User) error {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.FollowRepositoryImpl.Unfollow").
|
||||
AddAttribute("follower", follower.URI).
|
||||
AddAttribute("followee", followee.URI)
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
n, err := i.db.Follow.Delete().
|
||||
Where(matchFollowUsers(follower, followee)).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
s.CaptureError(err)
|
||||
} else {
|
||||
s.AddAttribute("deleted", n).
|
||||
CaptureMessage(fmt.Sprintf("Deleted %d follow(s)", n))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i FollowRepositoryImpl) AcceptFollow(ctx context.Context, follower, followee *entity.User) error {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.FollowRepositoryImpl.AcceptFollow").
|
||||
AddAttribute("follower", follower.URI).
|
||||
AddAttribute("followee", followee.URI)
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
n, err := i.db.Follow.Update().
|
||||
Where(matchFollowUsers(follower, followee), follow.StatusEQ(follow.StatusPending)).
|
||||
SetStatus(follow.StatusAccepted).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
s.CaptureError(err)
|
||||
} else {
|
||||
s.CaptureMessage(fmt.Sprintf("Accepted %d follow(s)", n))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (i FollowRepositoryImpl) RejectFollow(ctx context.Context, follower, followee *entity.User) error {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.FollowRepositoryImpl.RejectFollow").
|
||||
AddAttribute("follower", follower.URI).
|
||||
AddAttribute("followee", followee.URI)
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
n, err := i.db.Follow.Delete().
|
||||
Where(follow.And(matchFollowUsers(follower, followee), follow.StatusEQ(follow.StatusPending))).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
s.CaptureError(err)
|
||||
} else {
|
||||
s.CaptureMessage(fmt.Sprintf("Deleted %d follow(s)", n))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func matchFollowUsers(follower, followee *entity.User) predicate.Follow {
|
||||
return follow.And(
|
||||
follow.HasFollowerWith(
|
||||
user.ID(follower.ID), user.ID(followee.ID),
|
||||
),
|
||||
follow.HasFolloweeWith(
|
||||
user.ID(follower.ID), user.ID(followee.ID),
|
||||
),
|
||||
)
|
||||
}
|
||||
90
internal/repository/repo_impls/manager.go
Normal file
90
internal/repository/repo_impls/manager.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package repo_impls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/lysand-org/versia-go/internal/repository"
|
||||
|
||||
"git.devminer.xyz/devminer/unitel"
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/lysand-org/versia-go/ent"
|
||||
"github.com/lysand-org/versia-go/internal/database"
|
||||
)
|
||||
|
||||
type Factory[T any] func(db *ent.Client, log logr.Logger, telemetry *unitel.Telemetry) T
|
||||
|
||||
var _ repository.Manager = (*ManagerImpl)(nil)
|
||||
|
||||
type ManagerImpl struct {
|
||||
users repository.UserRepository
|
||||
notes repository.NoteRepository
|
||||
follows repository.FollowRepository
|
||||
|
||||
uRFactory Factory[repository.UserRepository]
|
||||
nRFactory Factory[repository.NoteRepository]
|
||||
fRFactory Factory[repository.FollowRepository]
|
||||
|
||||
db *ent.Client
|
||||
log logr.Logger
|
||||
telemetry *unitel.Telemetry
|
||||
}
|
||||
|
||||
func NewManagerImpl(db *ent.Client, telemetry *unitel.Telemetry, log logr.Logger, userRepositoryFunc Factory[repository.UserRepository], noteRepositoryFunc Factory[repository.NoteRepository], followRepositoryFunc Factory[repository.FollowRepository]) *ManagerImpl {
|
||||
userRepository := userRepositoryFunc(db, log.WithName("users"), telemetry)
|
||||
noteRepository := noteRepositoryFunc(db, log.WithName("notes"), telemetry)
|
||||
followRepository := followRepositoryFunc(db, log.WithName("follows"), telemetry)
|
||||
|
||||
return &ManagerImpl{
|
||||
users: userRepository,
|
||||
notes: noteRepository,
|
||||
follows: followRepository,
|
||||
|
||||
uRFactory: userRepositoryFunc,
|
||||
nRFactory: noteRepositoryFunc,
|
||||
fRFactory: followRepositoryFunc,
|
||||
|
||||
db: db,
|
||||
log: log,
|
||||
telemetry: telemetry,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *ManagerImpl) withDB(db *ent.Client) *ManagerImpl {
|
||||
return NewManagerImpl(db, i.telemetry, i.log, i.uRFactory, i.nRFactory, i.fRFactory)
|
||||
}
|
||||
|
||||
func (i *ManagerImpl) Atomic(ctx context.Context, fn func(ctx context.Context, tx repository.Manager) error) error {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.ManagerImpl.Atomic")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
tx, err := database.BeginTx(ctx, i.db, i.telemetry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func(tx *database.Tx) {
|
||||
err := tx.Finish()
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to finish transaction")
|
||||
}
|
||||
}(tx)
|
||||
|
||||
if err := fn(ctx, i.withDB(tx.Client())); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx.MarkForCommit()
|
||||
|
||||
return tx.Finish()
|
||||
}
|
||||
|
||||
func (i *ManagerImpl) Users() repository.UserRepository {
|
||||
return i.users
|
||||
}
|
||||
|
||||
func (i *ManagerImpl) Notes() repository.NoteRepository {
|
||||
return i.notes
|
||||
}
|
||||
|
||||
func (i *ManagerImpl) Follows() repository.FollowRepository {
|
||||
return i.follows
|
||||
}
|
||||
117
internal/repository/repo_impls/note_repository_impl.go
Normal file
117
internal/repository/repo_impls/note_repository_impl.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package repo_impls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/lysand-org/versia-go/internal/repository"
|
||||
|
||||
"git.devminer.xyz/devminer/unitel"
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/ent"
|
||||
"github.com/lysand-org/versia-go/ent/note"
|
||||
"github.com/lysand-org/versia-go/internal/entity"
|
||||
"github.com/lysand-org/versia-go/internal/utils"
|
||||
"github.com/lysand-org/versia-go/pkg/lysand"
|
||||
)
|
||||
|
||||
var _ repository.NoteRepository = (*NoteRepositoryImpl)(nil)
|
||||
|
||||
type NoteRepositoryImpl struct {
|
||||
db *ent.Client
|
||||
log logr.Logger
|
||||
telemetry *unitel.Telemetry
|
||||
}
|
||||
|
||||
func NewNoteRepositoryImpl(db *ent.Client, log logr.Logger, telemetry *unitel.Telemetry) repository.NoteRepository {
|
||||
return &NoteRepositoryImpl{
|
||||
db: db,
|
||||
log: log,
|
||||
telemetry: telemetry,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *NoteRepositoryImpl) NewNote(ctx context.Context, author *entity.User, content string, mentions []*entity.User) (*entity.Note, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.NoteRepositoryImpl.NewNote")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
nid := uuid.New()
|
||||
|
||||
n, err := i.db.Note.Create().
|
||||
SetID(nid).
|
||||
SetIsRemote(false).
|
||||
SetURI(utils.NoteAPIURL(nid).String()).
|
||||
SetAuthor(author.User).
|
||||
SetContent(content).
|
||||
AddMentions(utils.MapSlice(mentions, func(m *entity.User) *ent.User { return m.User })...).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n, err = i.db.Note.Query().
|
||||
Where(note.ID(nid)).
|
||||
WithAuthor().
|
||||
WithMentions().
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to query author", "id", nid)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return entity.NewNote(n)
|
||||
}
|
||||
|
||||
func (i *NoteRepositoryImpl) ImportLysandNote(ctx context.Context, lNote *lysand.Note) (*entity.Note, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.NoteRepositoryImpl.ImportLysandNote")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
id, err := i.db.Note.Create().
|
||||
SetID(uuid.New()).
|
||||
SetIsRemote(true).
|
||||
SetURI(lNote.URI.String()).
|
||||
OnConflict().
|
||||
UpdateNewValues().
|
||||
ID(ctx)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to import note into database", "uri", lNote.URI)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n, err := i.db.Note.Get(ctx, id)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to get imported note", "id", id, "uri", lNote.URI)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i.log.V(2).Info("Imported note into database", "id", id, "uri", lNote.URI)
|
||||
|
||||
return entity.NewNote(n)
|
||||
}
|
||||
|
||||
func (i *NoteRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entity.Note, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.NoteRepositoryImpl.LookupByIDOrUsername")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
n, err := i.db.Note.Query().
|
||||
Where(note.ID(id)).
|
||||
WithAuthor().
|
||||
WithMentions().
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
if !ent.IsNotFound(err) {
|
||||
i.log.Error(err, "Failed to query user", "id", id)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i.log.V(2).Info("User not found in DB", "id", id)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
i.log.V(2).Info("User found in DB", "id", id)
|
||||
|
||||
return entity.NewNote(n)
|
||||
}
|
||||
325
internal/repository/repo_impls/user_repository_impl.go
Normal file
325
internal/repository/repo_impls/user_repository_impl.go
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
package repo_impls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"github.com/lysand-org/versia-go/internal/repository"
|
||||
"github.com/lysand-org/versia-go/internal/service"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.devminer.xyz/devminer/unitel"
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/ent"
|
||||
"github.com/lysand-org/versia-go/ent/predicate"
|
||||
"github.com/lysand-org/versia-go/ent/user"
|
||||
"github.com/lysand-org/versia-go/internal/entity"
|
||||
"github.com/lysand-org/versia-go/internal/utils"
|
||||
"github.com/lysand-org/versia-go/pkg/lysand"
|
||||
)
|
||||
|
||||
const bcryptCost = 12
|
||||
|
||||
var (
|
||||
ErrUsernameTaken = errors.New("username taken")
|
||||
|
||||
_ repository.UserRepository = (*UserRepositoryImpl)(nil)
|
||||
)
|
||||
|
||||
type UserRepositoryImpl struct {
|
||||
federationService service.FederationService
|
||||
|
||||
db *ent.Client
|
||||
log logr.Logger
|
||||
telemetry *unitel.Telemetry
|
||||
}
|
||||
|
||||
func NewUserRepositoryImpl(federationService service.FederationService, db *ent.Client, log logr.Logger, telemetry *unitel.Telemetry) repository.UserRepository {
|
||||
return &UserRepositoryImpl{
|
||||
federationService: federationService,
|
||||
db: db,
|
||||
log: log,
|
||||
telemetry: telemetry,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *UserRepositoryImpl) NewUser(ctx context.Context, username, password string, priv ed25519.PrivateKey, pub ed25519.PublicKey) (*entity.User, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.UserRepositoryImpl.NewUser")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
pwHash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uid := uuid.New()
|
||||
|
||||
u, err := i.db.User.Create().
|
||||
SetID(uid).
|
||||
SetIsRemote(false).
|
||||
SetURI(utils.UserAPIURL(uid).String()).
|
||||
SetUsername(username).
|
||||
SetPasswordHash(pwHash).
|
||||
SetPublicKey(pub).
|
||||
SetPrivateKey(priv).
|
||||
SetInbox(utils.UserInboxAPIURL(uid).String()).
|
||||
SetOutbox(utils.UserOutboxAPIURL(uid).String()).
|
||||
SetFeatured(utils.UserFeaturedAPIURL(uid).String()).
|
||||
SetFollowers(utils.UserFollowersAPIURL(uid).String()).
|
||||
SetFollowing(utils.UserFollowingAPIURL(uid).String()).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
if ent.IsConstraintError(err) {
|
||||
return nil, ErrUsernameTaken
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return entity.NewUser(u)
|
||||
}
|
||||
|
||||
func (i *UserRepositoryImpl) ImportLysandUserByURI(ctx context.Context, uri *lysand.URL) (*entity.User, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.UserRepositoryImpl.ImportLysandUserByURI")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
lUser, err := i.federationService.GetUser(ctx, uri)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to fetch remote user", "uri", uri)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, err := i.db.User.Create().
|
||||
SetID(uuid.New()).
|
||||
SetIsRemote(true).
|
||||
SetURI(lUser.URI.String()).
|
||||
SetUsername(lUser.Username).
|
||||
SetNillableDisplayName(lUser.DisplayName).
|
||||
SetBiography(lUser.Bio.String()).
|
||||
SetPublicKey(lUser.PublicKey.PublicKey.ToStd()).
|
||||
SetIndexable(lUser.Indexable).
|
||||
SetFields(lUser.Fields).
|
||||
SetExtensions(lUser.Extensions).
|
||||
SetInbox(lUser.Inbox.String()).
|
||||
SetOutbox(lUser.Outbox.String()).
|
||||
SetFeatured(lUser.Featured.String()).
|
||||
SetFollowers(lUser.Followers.String()).
|
||||
SetFollowing(lUser.Following.String()).
|
||||
OnConflict().
|
||||
UpdateNewValues().
|
||||
ID(ctx)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to import user into database", "uri", lUser.URI)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u, err := i.db.User.Get(ctx, id)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to get imported user", "id", id, "uri", lUser.URI)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i.log.V(2).Info("Imported user into database", "id", id, "uri", lUser.URI)
|
||||
|
||||
return entity.NewUser(u)
|
||||
}
|
||||
|
||||
func (i *UserRepositoryImpl) Resolve(ctx context.Context, uri *lysand.URL) (*entity.User, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.UserRepositoryImpl.Resolve")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
u, err := i.LookupByURI(ctx, uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// check if the user is already imported
|
||||
if u == nil {
|
||||
i.log.V(2).Info("User not found in DB", "uri", uri)
|
||||
|
||||
u, err := i.ImportLysandUserByURI(ctx, uri)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to import user", "uri", uri)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
i.log.V(2).Info("User found in DB", "uri", uri)
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (i *UserRepositoryImpl) ResolveMultiple(ctx context.Context, uris []lysand.URL) ([]*entity.User, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.UserRepositoryImpl.ResolveMultiple")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
us, err := i.LookupByURIs(ctx, uris)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: Refactor to use async imports using a work queue
|
||||
outer:
|
||||
for _, uri := range uris {
|
||||
// check if the user is already imported
|
||||
for _, u := range us {
|
||||
if uri.String() == u.URI.String() {
|
||||
i.log.V(2).Info("User found in DB", "uri", uri)
|
||||
|
||||
continue outer
|
||||
}
|
||||
}
|
||||
|
||||
i.log.V(2).Info("User not found in DB", "uri", uri)
|
||||
|
||||
importedUser, err := i.ImportLysandUserByURI(ctx, &uri)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to import user", "uri", uri)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
i.log.V(2).Info("Imported user", "uri", uri)
|
||||
|
||||
us = append(us, importedUser)
|
||||
}
|
||||
|
||||
return us, nil
|
||||
}
|
||||
|
||||
func (i *UserRepositoryImpl) GetByID(ctx context.Context, uid uuid.UUID) (*entity.User, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.UserRepositoryImpl.GetByID")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
u, err := i.db.User.Query().
|
||||
Where(user.IDEQ(uid)).
|
||||
WithAvatarImage().
|
||||
WithHeaderImage().
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
if !ent.IsNotFound(err) {
|
||||
i.log.Error(err, "Failed to query user", "id", uid)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i.log.V(2).Info("User not found in DB", "id", uid)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
i.log.V(2).Info("User found in DB", "id", uid)
|
||||
|
||||
return entity.NewUser(u)
|
||||
}
|
||||
|
||||
func (i *UserRepositoryImpl) GetLocalByID(ctx context.Context, uid uuid.UUID) (*entity.User, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.UserRepositoryImpl.GetLocalByID")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
u, err := i.db.User.Query().
|
||||
Where(user.And(user.ID(uid), user.IsRemote(false))).
|
||||
WithAvatarImage().
|
||||
WithHeaderImage().
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
if !ent.IsNotFound(err) {
|
||||
i.log.Error(err, "Failed to query local user", "id", uid)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i.log.V(2).Info("Local user not found in DB", "id", uid)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
i.log.V(2).Info("Local user found in DB", "id", uid)
|
||||
|
||||
return entity.NewUser(u)
|
||||
}
|
||||
|
||||
func (i *UserRepositoryImpl) LookupByURI(ctx context.Context, uri *lysand.URL) (*entity.User, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.UserRepositoryImpl.LookupByURI")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
// check if the user is already imported
|
||||
u, err := i.db.User.Query().
|
||||
Where(user.URI(uri.String())).
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
if !ent.IsNotFound(err) {
|
||||
i.log.Error(err, "Failed to query user", "uri", uri)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i.log.V(2).Info("User not found in DB", "uri", uri)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
i.log.V(2).Info("User found in DB", "uri", uri)
|
||||
|
||||
return entity.NewUser(u)
|
||||
}
|
||||
|
||||
func (i *UserRepositoryImpl) LookupByURIs(ctx context.Context, uris []lysand.URL) ([]*entity.User, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.UserRepositoryImpl.LookupByURIs")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
urisStrs := make([]string, 0, len(uris))
|
||||
for _, u := range uris {
|
||||
urisStrs = append(urisStrs, u.String())
|
||||
}
|
||||
|
||||
us, err := i.db.User.Query().
|
||||
Where(user.URIIn(urisStrs...)).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return utils.MapErrorSlice(us, entity.NewUser)
|
||||
}
|
||||
|
||||
func (i *UserRepositoryImpl) LookupByIDOrUsername(ctx context.Context, idOrUsername string) (*entity.User, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "repository/repo_impls.UserRepositoryImpl.LookupByIDOrUsername")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
var preds []predicate.User
|
||||
if u, err := uuid.Parse(idOrUsername); err == nil {
|
||||
preds = append(preds, user.IDEQ(u))
|
||||
} else {
|
||||
preds = append(preds, user.UsernameEQ(idOrUsername))
|
||||
}
|
||||
|
||||
u, err := i.db.User.Query().
|
||||
Where(preds...).
|
||||
WithAvatarImage().
|
||||
WithHeaderImage().
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
if !ent.IsNotFound(err) {
|
||||
i.log.Error(err, "Failed to query user", "idOrUsername", idOrUsername)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i.log.V(2).Info("User not found in DB", "idOrUsername", idOrUsername)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
i.log.V(2).Info("User found in DB", "idOrUsername", idOrUsername, "id", u.ID)
|
||||
|
||||
return entity.NewUser(u)
|
||||
}
|
||||
49
internal/repository/repository.go
Normal file
49
internal/repository/repository.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/internal/entity"
|
||||
"github.com/lysand-org/versia-go/pkg/lysand"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
NewUser(ctx context.Context, username, password string, privateKey ed25519.PrivateKey, publicKey ed25519.PublicKey) (*entity.User, error)
|
||||
ImportLysandUserByURI(ctx context.Context, uri *lysand.URL) (*entity.User, error)
|
||||
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*entity.User, error)
|
||||
GetLocalByID(ctx context.Context, id uuid.UUID) (*entity.User, error)
|
||||
|
||||
Resolve(ctx context.Context, uri *lysand.URL) (*entity.User, error)
|
||||
ResolveMultiple(ctx context.Context, uris []lysand.URL) ([]*entity.User, error)
|
||||
|
||||
LookupByURI(ctx context.Context, uri *lysand.URL) (*entity.User, error)
|
||||
LookupByURIs(ctx context.Context, uris []lysand.URL) ([]*entity.User, error)
|
||||
LookupByIDOrUsername(ctx context.Context, idOrUsername string) (*entity.User, error)
|
||||
}
|
||||
|
||||
type FollowRepository interface {
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*entity.Follow, error)
|
||||
|
||||
Follow(ctx context.Context, follower, followee *entity.User) (*entity.Follow, error)
|
||||
Unfollow(ctx context.Context, follower, followee *entity.User) error
|
||||
AcceptFollow(ctx context.Context, follower, followee *entity.User) error
|
||||
RejectFollow(ctx context.Context, follower, followee *entity.User) error
|
||||
}
|
||||
|
||||
type NoteRepository interface {
|
||||
NewNote(ctx context.Context, author *entity.User, content string, mentions []*entity.User) (*entity.Note, error)
|
||||
ImportLysandNote(ctx context.Context, lNote *lysand.Note) (*entity.Note, error)
|
||||
|
||||
GetByID(ctx context.Context, idOrUsername uuid.UUID) (*entity.Note, error)
|
||||
}
|
||||
|
||||
type Manager interface {
|
||||
Atomic(ctx context.Context, fn func(ctx context.Context, tx Manager) error) error
|
||||
|
||||
Users() UserRepository
|
||||
Notes() NoteRepository
|
||||
Follows() FollowRepository
|
||||
}
|
||||
49
internal/service/service.go
Normal file
49
internal/service/service.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/lysand-org/versia-go/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/internal/api_schema"
|
||||
"github.com/lysand-org/versia-go/internal/entity"
|
||||
"github.com/lysand-org/versia-go/pkg/lysand"
|
||||
"github.com/lysand-org/versia-go/pkg/webfinger"
|
||||
)
|
||||
|
||||
type UserService interface {
|
||||
WithRepositories(repositories repository.Manager) UserService
|
||||
|
||||
NewUser(ctx context.Context, username, password string) (*entity.User, error)
|
||||
|
||||
GetUserByID(ctx context.Context, id uuid.UUID) (*entity.User, error)
|
||||
|
||||
GetWebfingerForUser(ctx context.Context, userID string) (*webfinger.User, error)
|
||||
}
|
||||
|
||||
type FederationService interface {
|
||||
SendToInbox(ctx context.Context, author *entity.User, target *entity.User, object any) ([]byte, error)
|
||||
GetUser(ctx context.Context, uri *lysand.URL) (*lysand.User, error)
|
||||
}
|
||||
|
||||
type InboxService interface {
|
||||
Handle(ctx context.Context, obj any, userId uuid.UUID) error
|
||||
}
|
||||
|
||||
type NoteService interface {
|
||||
CreateNote(ctx context.Context, req api_schema.CreateNoteRequest) (*entity.Note, error)
|
||||
GetNote(ctx context.Context, id uuid.UUID) (*entity.Note, error)
|
||||
|
||||
ImportLysandNote(ctx context.Context, lNote *lysand.Note) (*entity.Note, error)
|
||||
}
|
||||
|
||||
type FollowService interface {
|
||||
NewFollow(ctx context.Context, follower, followee *entity.User) (*entity.Follow, error)
|
||||
GetFollow(ctx context.Context, id uuid.UUID) (*entity.Follow, error)
|
||||
|
||||
ImportLysandFollow(ctx context.Context, lFollow *lysand.Follow) (*entity.Follow, error)
|
||||
}
|
||||
|
||||
type TaskService interface {
|
||||
ScheduleTask(ctx context.Context, type_ string, data any) error
|
||||
}
|
||||
147
internal/service/svc_impls/Inbox_service_impl.go
Normal file
147
internal/service/svc_impls/Inbox_service_impl.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package svc_impls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/internal/repository"
|
||||
"github.com/lysand-org/versia-go/internal/service"
|
||||
|
||||
"git.devminer.xyz/devminer/unitel"
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/lysand-org/versia-go/ent"
|
||||
"github.com/lysand-org/versia-go/ent/user"
|
||||
"github.com/lysand-org/versia-go/internal/api_schema"
|
||||
"github.com/lysand-org/versia-go/internal/entity"
|
||||
"github.com/lysand-org/versia-go/pkg/lysand"
|
||||
)
|
||||
|
||||
var _ service.InboxService = (*InboxServiceImpl)(nil)
|
||||
|
||||
type InboxServiceImpl struct {
|
||||
repositories repository.Manager
|
||||
|
||||
federationService service.FederationService
|
||||
|
||||
telemetry *unitel.Telemetry
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
func NewInboxService(repositories repository.Manager, federationService service.FederationService, telemetry *unitel.Telemetry, log logr.Logger) *InboxServiceImpl {
|
||||
return &InboxServiceImpl{
|
||||
repositories: repositories,
|
||||
federationService: federationService,
|
||||
telemetry: telemetry,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (i InboxServiceImpl) WithRepositories(repositories repository.Manager) service.InboxService {
|
||||
return NewInboxService(repositories, i.federationService, i.telemetry, i.log)
|
||||
}
|
||||
|
||||
func (i InboxServiceImpl) Handle(ctx context.Context, obj any, userId uuid.UUID) error {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "service/svc_impls.InboxServiceImpl.Handle")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
return i.repositories.Atomic(ctx, func(ctx context.Context, tx repository.Manager) error {
|
||||
i := i.WithRepositories(tx).(*InboxServiceImpl)
|
||||
|
||||
u, err := i.repositories.Users().GetLocalByID(ctx, userId)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to get user", "id", userId)
|
||||
|
||||
return api_schema.ErrInternalServerError(nil)
|
||||
}
|
||||
if u == nil {
|
||||
return api_schema.ErrNotFound(map[string]any{
|
||||
"id": userId,
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Implement more types
|
||||
switch o := obj.(type) {
|
||||
case lysand.Note:
|
||||
i.log.Info("Received note", "note", o)
|
||||
if err := i.handleNote(ctx, o, u); err != nil {
|
||||
i.log.Error(err, "Failed to handle note", "note", o)
|
||||
return err
|
||||
}
|
||||
|
||||
case lysand.Patch:
|
||||
i.log.Info("Received patch", "patch", o)
|
||||
case lysand.Follow:
|
||||
if err := i.handleFollow(ctx, o, u); err != nil {
|
||||
i.log.Error(err, "Failed to handle follow", "follow", o)
|
||||
return err
|
||||
}
|
||||
case lysand.Undo:
|
||||
i.log.Info("Received undo", "undo", o)
|
||||
default:
|
||||
i.log.Info("Unimplemented object type", "object", obj)
|
||||
return api_schema.ErrNotImplemented(nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (i InboxServiceImpl) handleFollow(ctx context.Context, o lysand.Follow, u *entity.User) error {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "service/svc_impls.InboxServiceImpl.handleFollow")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
author, err := i.repositories.Users().Resolve(ctx, o.Author)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to resolve author", "author", o.Author)
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := i.repositories.Follows().Follow(ctx, author, u)
|
||||
if err != nil {
|
||||
// TODO: Handle constraint errors
|
||||
if ent.IsConstraintError(err) {
|
||||
i.log.Error(err, "Follow already exists", "user", user.ID, "author", author.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
i.log.Error(err, "Failed to create follow", "user", user.ID, "author", author.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
switch u.PrivacyLevel {
|
||||
case user.PrivacyLevelPublic:
|
||||
if err := i.repositories.Follows().AcceptFollow(ctx, author, u); err != nil {
|
||||
i.log.Error(err, "Failed to accept follow", "user", user.ID, "author", author.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := i.federationService.SendToInbox(ctx, u, author, f.ToLysandAccept()); err != nil {
|
||||
i.log.Error(err, "Failed to send follow accept to inbox", "user", user.ID, "author", author.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
case user.PrivacyLevelRestricted:
|
||||
case user.PrivacyLevelPrivate:
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i InboxServiceImpl) handleNote(ctx context.Context, o lysand.Note, u *entity.User) error {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "service/svc_impls.InboxServiceImpl.handleNote")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
author, err := i.repositories.Users().Resolve(ctx, o.Author)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to resolve author", "author", o.Author)
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: Implement
|
||||
|
||||
_ = author
|
||||
|
||||
return nil
|
||||
}
|
||||
56
internal/service/svc_impls/federation_service_impl.go
Normal file
56
internal/service/svc_impls/federation_service_impl.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package svc_impls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.devminer.xyz/devminer/unitel"
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/lysand-org/versia-go/internal/entity"
|
||||
"github.com/lysand-org/versia-go/internal/service"
|
||||
"github.com/lysand-org/versia-go/pkg/lysand"
|
||||
)
|
||||
|
||||
var _ service.FederationService = (*FederationServiceImpl)(nil)
|
||||
|
||||
type FederationServiceImpl struct {
|
||||
federationClient *lysand.FederationClient
|
||||
|
||||
telemetry *unitel.Telemetry
|
||||
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
func NewFederationServiceImpl(federationClient *lysand.FederationClient, telemetry *unitel.Telemetry, log logr.Logger) *FederationServiceImpl {
|
||||
return &FederationServiceImpl{
|
||||
federationClient: federationClient,
|
||||
telemetry: telemetry,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (i FederationServiceImpl) SendToInbox(ctx context.Context, author *entity.User, target *entity.User, object any) ([]byte, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "service/svc_impls.FederationServiceImpl.SendToInbox")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
response, err := i.federationClient.SendToInbox(ctx, author.Signer, target.ToLysand(), object)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to send to inbox", "author", author.ID, "target", target.ID)
|
||||
return response, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (i FederationServiceImpl) GetUser(ctx context.Context, uri *lysand.URL) (*lysand.User, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "service/svc_impls.FederationServiceImpl.GetUser")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
u, err := i.federationClient.GetUser(ctx, uri.ToStd())
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to fetch remote user", "uri", uri)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
102
internal/service/svc_impls/follow_service_impl.go
Normal file
102
internal/service/svc_impls/follow_service_impl.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package svc_impls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/lysand-org/versia-go/internal/repository"
|
||||
"github.com/lysand-org/versia-go/internal/service"
|
||||
|
||||
"git.devminer.xyz/devminer/unitel"
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/internal/entity"
|
||||
"github.com/lysand-org/versia-go/pkg/lysand"
|
||||
)
|
||||
|
||||
var _ service.FollowService = (*FollowServiceImpl)(nil)
|
||||
|
||||
type FollowServiceImpl struct {
|
||||
federationService service.FederationService
|
||||
|
||||
repositories repository.Manager
|
||||
|
||||
telemetry *unitel.Telemetry
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
func NewFollowServiceImpl(federationService service.FederationService, repositories repository.Manager, telemetry *unitel.Telemetry, log logr.Logger) *FollowServiceImpl {
|
||||
return &FollowServiceImpl{
|
||||
federationService: federationService,
|
||||
repositories: repositories,
|
||||
telemetry: telemetry,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (i FollowServiceImpl) NewFollow(ctx context.Context, follower, followee *entity.User) (*entity.Follow, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "service/svc_impls.FollowServiceImpl.NewFollow").
|
||||
AddAttribute("follower", follower.URI).
|
||||
AddAttribute("followee", followee.URI)
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
f, err := i.repositories.Follows().Follow(ctx, follower, followee)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to create follow", "follower", follower.ID, "followee", followee.ID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.AddAttribute("followID", f.URI).
|
||||
AddAttribute("followURI", f.URI)
|
||||
|
||||
i.log.V(2).Info("Created follow", "follower", follower.ID, "followee", followee.ID)
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (i FollowServiceImpl) GetFollow(ctx context.Context, id uuid.UUID) (*entity.Follow, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "service/svc_impls.FollowServiceImpl.GetFollow").
|
||||
AddAttribute("followID", id)
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
f, err := i.repositories.Follows().GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if f != nil {
|
||||
s.AddAttribute("followURI", f.URI)
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (i FollowServiceImpl) ImportLysandFollow(ctx context.Context, lFollow *lysand.Follow) (*entity.Follow, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "service/svc_impls.FollowServiceImpl.ImportLysandFollow").
|
||||
AddAttribute("uri", lFollow.URI.String())
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
var f *entity.Follow
|
||||
if err := i.repositories.Atomic(ctx, func(ctx context.Context, tx repository.Manager) error {
|
||||
follower, err := i.repositories.Users().Resolve(ctx, lFollow.Author)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.AddAttribute("follower", follower.URI)
|
||||
|
||||
followee, err := i.repositories.Users().Resolve(ctx, lFollow.Followee)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.AddAttribute("followee", followee.URI)
|
||||
|
||||
f, err = i.repositories.Follows().Follow(ctx, follower, followee)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.AddAttribute("followID", f.ID).
|
||||
AddAttribute("followURI", f.URI)
|
||||
|
||||
return f, nil
|
||||
}
|
||||
98
internal/service/svc_impls/note_service_impl.go
Normal file
98
internal/service/svc_impls/note_service_impl.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package svc_impls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/lysand-org/versia-go/internal/repository"
|
||||
"github.com/lysand-org/versia-go/internal/service"
|
||||
"slices"
|
||||
|
||||
"git.devminer.xyz/devminer/unitel"
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/internal/api_schema"
|
||||
"github.com/lysand-org/versia-go/internal/entity"
|
||||
"github.com/lysand-org/versia-go/internal/tasks"
|
||||
"github.com/lysand-org/versia-go/pkg/lysand"
|
||||
)
|
||||
|
||||
var _ service.NoteService = (*NoteServiceImpl)(nil)
|
||||
|
||||
type NoteServiceImpl struct {
|
||||
federationService service.FederationService
|
||||
taskService service.TaskService
|
||||
|
||||
repositories repository.Manager
|
||||
|
||||
telemetry *unitel.Telemetry
|
||||
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
func NewNoteServiceImpl(federationService service.FederationService, taskService service.TaskService, repositories repository.Manager, telemetry *unitel.Telemetry, log logr.Logger) *NoteServiceImpl {
|
||||
return &NoteServiceImpl{
|
||||
federationService: federationService,
|
||||
taskService: taskService,
|
||||
repositories: repositories,
|
||||
telemetry: telemetry,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (i NoteServiceImpl) CreateNote(ctx context.Context, req api_schema.CreateNoteRequest) (*entity.Note, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "service/svc_impls.NoteServiceImpl.CreateNote")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
var n *entity.Note
|
||||
|
||||
if err := i.repositories.Atomic(ctx, func(ctx context.Context, tx repository.Manager) error {
|
||||
// FIXME: Use the user that created the note
|
||||
author, err := tx.Users().GetLocalByID(ctx, uuid.MustParse("b6f4bcb5-ac5a-4a87-880a-c7f88f58a172"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if author == nil {
|
||||
return api_schema.ErrBadRequest(map[string]any{"reason": "author not found"})
|
||||
}
|
||||
|
||||
mentionedUsers, err := i.repositories.Users().ResolveMultiple(ctx, req.Mentions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if slices.ContainsFunc(mentionedUsers, func(u *entity.User) bool { return u.ID == author.ID }) {
|
||||
return api_schema.ErrBadRequest(map[string]any{"reason": "cannot mention self"})
|
||||
}
|
||||
|
||||
n, err = tx.Notes().NewNote(ctx, author, req.Content, mentionedUsers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := i.taskService.ScheduleTask(ctx, tasks.FederateNote, tasks.FederateNoteData{NoteID: n.ID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (i NoteServiceImpl) GetNote(ctx context.Context, id uuid.UUID) (*entity.Note, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "service/svc_impls.NoteServiceImpl.GetUserByID")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
return i.repositories.Notes().GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (i NoteServiceImpl) ImportLysandNote(ctx context.Context, lNote *lysand.Note) (*entity.Note, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "service/svc_impls.NoteServiceImpl.ImportLysandNote")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
return i.repositories.Notes().ImportLysandNote(ctx, lNote)
|
||||
}
|
||||
49
internal/service/svc_impls/task_service_impl.go
Normal file
49
internal/service/svc_impls/task_service_impl.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package svc_impls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/lysand-org/versia-go/internal/service"
|
||||
|
||||
"git.devminer.xyz/devminer/unitel"
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/lysand-org/versia-go/pkg/taskqueue"
|
||||
)
|
||||
|
||||
var _ service.TaskService = (*TaskServiceImpl)(nil)
|
||||
|
||||
type TaskServiceImpl struct {
|
||||
client *taskqueue.Client
|
||||
|
||||
telemetry *unitel.Telemetry
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
func NewTaskServiceImpl(client *taskqueue.Client, telemetry *unitel.Telemetry, log logr.Logger) *TaskServiceImpl {
|
||||
return &TaskServiceImpl{
|
||||
client: client,
|
||||
|
||||
telemetry: telemetry,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (i TaskServiceImpl) ScheduleTask(ctx context.Context, type_ string, data any) error {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "service/svc_impls.TaskServiceImpl.ScheduleTask")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
t, err := taskqueue.NewTask(type_, data)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to create task", "type", type_)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := i.client.Submit(ctx, t); err != nil {
|
||||
i.log.Error(err, "Failed to schedule task", "type", type_, "taskID", t.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
i.log.V(2).Info("Scheduled task", "type", type_, "taskID", t.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
122
internal/service/svc_impls/user_service_impl.go
Normal file
122
internal/service/svc_impls/user_service_impl.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package svc_impls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"github.com/lysand-org/versia-go/internal/repository"
|
||||
"github.com/lysand-org/versia-go/internal/service"
|
||||
"net/url"
|
||||
|
||||
"git.devminer.xyz/devminer/unitel"
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/config"
|
||||
"github.com/lysand-org/versia-go/ent/schema"
|
||||
"github.com/lysand-org/versia-go/internal/entity"
|
||||
"github.com/lysand-org/versia-go/internal/utils"
|
||||
"github.com/lysand-org/versia-go/pkg/webfinger"
|
||||
)
|
||||
|
||||
var _ service.UserService = (*UserServiceImpl)(nil)
|
||||
|
||||
type UserServiceImpl struct {
|
||||
repositories repository.Manager
|
||||
|
||||
federationService service.FederationService
|
||||
|
||||
telemetry *unitel.Telemetry
|
||||
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
func NewUserServiceImpl(repositories repository.Manager, federationService service.FederationService, telemetry *unitel.Telemetry, log logr.Logger) *UserServiceImpl {
|
||||
return &UserServiceImpl{
|
||||
repositories: repositories,
|
||||
federationService: federationService,
|
||||
telemetry: telemetry,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (i UserServiceImpl) WithRepositories(repositories repository.Manager) service.UserService {
|
||||
return NewUserServiceImpl(repositories, i.federationService, i.telemetry, i.log)
|
||||
}
|
||||
|
||||
func (i UserServiceImpl) NewUser(ctx context.Context, username, password string) (*entity.User, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "service/svc_impls.UserServiceImpl.NewUser")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
if err := schema.ValidateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to generate ed25519 key pair")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := i.repositories.Users().NewUser(ctx, username, password, priv, pub)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to create user", "username", username)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i.log.V(2).Info("Create user", "id", user.ID, "uri", user.URI)
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (i UserServiceImpl) GetUserByID(ctx context.Context, id uuid.UUID) (*entity.User, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "service/svc_impls.UserServiceImpl.GetUserByID")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
return i.repositories.Users().LookupByIDOrUsername(ctx, id.String())
|
||||
}
|
||||
|
||||
func (i UserServiceImpl) GetWebfingerForUser(ctx context.Context, userID string) (*webfinger.User, error) {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "service/svc_impls.UserServiceImpl.GetWebfingerForUser")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
u, err := i.repositories.Users().LookupByIDOrUsername(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u == nil {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
wf := &webfinger.User{
|
||||
UserID: webfinger.UserID{
|
||||
ID: u.ID.String(),
|
||||
// FIXME: Move this away into a service or sth
|
||||
Domain: config.C.PublicAddress.Host,
|
||||
},
|
||||
URI: utils.UserAPIURL(u.ID).ToStd(),
|
||||
}
|
||||
|
||||
if u.Edges.AvatarImage != nil {
|
||||
avatarURL, err := url.Parse(u.Edges.AvatarImage.URL)
|
||||
if err != nil {
|
||||
i.log.Error(err, "Failed to parse avatar URL")
|
||||
|
||||
wf.Avatar = utils.DefaultAvatarURL(u.ID).ToStd()
|
||||
wf.AvatarMIMEType = "image/svg+xml"
|
||||
} else {
|
||||
wf.Avatar = avatarURL
|
||||
wf.AvatarMIMEType = u.Edges.AvatarImage.MimeType
|
||||
}
|
||||
} else {
|
||||
wf.Avatar = utils.DefaultAvatarURL(u.ID).ToStd()
|
||||
wf.AvatarMIMEType = "image/svg+xml"
|
||||
}
|
||||
|
||||
return wf, nil
|
||||
}
|
||||
11
internal/tasks/federate_follow.go
Normal file
11
internal/tasks/federate_follow.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package tasks
|
||||
|
||||
import "context"
|
||||
|
||||
type FederateFollowData struct {
|
||||
FollowID string `json:"followID"`
|
||||
}
|
||||
|
||||
func (t *Handler) FederateFollow(ctx context.Context, data FederateNoteData) error {
|
||||
return nil
|
||||
}
|
||||
48
internal/tasks/federate_note.go
Normal file
48
internal/tasks/federate_note.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/lysand-org/versia-go/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/internal/entity"
|
||||
)
|
||||
|
||||
type FederateNoteData struct {
|
||||
NoteID uuid.UUID `json:"noteID"`
|
||||
}
|
||||
|
||||
func (t *Handler) FederateNote(ctx context.Context, data FederateNoteData) error {
|
||||
s := t.telemetry.StartSpan(ctx, "function", "tasks/Handler.FederateNote")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
var n *entity.Note
|
||||
if err := t.repositories.Atomic(ctx, func(ctx context.Context, tx repository.Manager) error {
|
||||
var err error
|
||||
n, err = tx.Notes().GetByID(ctx, data.NoteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, uu := range n.Mentions {
|
||||
if !uu.IsRemote {
|
||||
t.log.V(2).Info("User is not remote", "user", uu.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
res, err := t.federationService.SendToInbox(ctx, n.Author, &uu, n.ToLysand())
|
||||
if err != nil {
|
||||
t.log.Error(err, "Failed to send note to remote user", "res", res, "user", uu.ID)
|
||||
} else {
|
||||
t.log.V(2).Info("Sent note to remote user", "res", res, "user", uu.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
53
internal/tasks/handler.go
Normal file
53
internal/tasks/handler.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/lysand-org/versia-go/internal/repository"
|
||||
"github.com/lysand-org/versia-go/internal/service"
|
||||
|
||||
"git.devminer.xyz/devminer/unitel"
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/lysand-org/versia-go/pkg/taskqueue"
|
||||
)
|
||||
|
||||
const (
|
||||
FederateNote = "federate_note"
|
||||
FederateFollow = "federate_follow"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
federationService service.FederationService
|
||||
|
||||
repositories repository.Manager
|
||||
|
||||
telemetry *unitel.Telemetry
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
func NewHandler(federationService service.FederationService, repositories repository.Manager, telemetry *unitel.Telemetry, log logr.Logger) *Handler {
|
||||
return &Handler{
|
||||
federationService: federationService,
|
||||
|
||||
repositories: repositories,
|
||||
|
||||
telemetry: telemetry,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Handler) Register(tq *taskqueue.Client) {
|
||||
tq.RegisterHandler(FederateNote, parse(t.FederateNote))
|
||||
tq.RegisterHandler(FederateFollow, parse(t.FederateFollow))
|
||||
}
|
||||
|
||||
func parse[T any](handler func(context.Context, T) error) func(context.Context, taskqueue.Task) error {
|
||||
return func(ctx context.Context, task taskqueue.Task) error {
|
||||
var data T
|
||||
if err := json.Unmarshal(task.Payload, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return handler(ctx, data)
|
||||
}
|
||||
}
|
||||
52
internal/utils/mapper.go
Normal file
52
internal/utils/mapper.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package utils
|
||||
|
||||
import "strings"
|
||||
|
||||
func MapSlice[T any, V any](obj []T, transform func(T) V) []V {
|
||||
vs := make([]V, 0, len(obj))
|
||||
|
||||
for _, o := range obj {
|
||||
vs = append(vs, transform(o))
|
||||
}
|
||||
|
||||
return vs
|
||||
}
|
||||
|
||||
type CombinedError struct {
|
||||
Errors []error
|
||||
}
|
||||
|
||||
func (e CombinedError) Error() string {
|
||||
sb := strings.Builder{}
|
||||
|
||||
for i, err := range e.Errors {
|
||||
sb.WriteString(err.Error())
|
||||
|
||||
if i < len(e.Errors)-1 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func MapErrorSlice[T any, V any](obj []T, transform func(T) (V, error)) ([]V, error) {
|
||||
vs := make([]V, 0, len(obj))
|
||||
errs := make([]error, 0, len(obj))
|
||||
|
||||
for _, o := range obj {
|
||||
v, err := transform(o)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
|
||||
vs = append(vs, v)
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return nil, CombinedError{Errors: errs}
|
||||
}
|
||||
|
||||
return vs, nil
|
||||
}
|
||||
75
internal/utils/urls.go
Normal file
75
internal/utils/urls.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lysand-org/versia-go/config"
|
||||
"github.com/lysand-org/versia-go/pkg/lysand"
|
||||
)
|
||||
|
||||
var dicebearURL = &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "api.dicebear.com",
|
||||
Path: "9.x/adventurer/svg",
|
||||
}
|
||||
|
||||
func UserAPIURL(uuid uuid.UUID) *lysand.URL {
|
||||
newPath := &url.URL{Path: fmt.Sprintf("/api/users/%s/", uuid.String())}
|
||||
return lysand.URLFromStd(config.C.PublicAddress.ResolveReference(newPath))
|
||||
}
|
||||
|
||||
func DefaultAvatarURL(uuid uuid.UUID) *lysand.URL {
|
||||
u := &url.URL{}
|
||||
q := u.Query()
|
||||
q.Set("seed", uuid.String())
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return lysand.URLFromStd(dicebearURL.ResolveReference(u))
|
||||
}
|
||||
|
||||
func UserInboxAPIURL(uuid uuid.UUID) *lysand.URL {
|
||||
newPath := &url.URL{Path: "./inbox"}
|
||||
return UserAPIURL(uuid).ResolveReference(newPath)
|
||||
}
|
||||
|
||||
func UserOutboxAPIURL(uuid uuid.UUID) *lysand.URL {
|
||||
newPath := &url.URL{Path: "./outbox"}
|
||||
return UserAPIURL(uuid).ResolveReference(newPath)
|
||||
}
|
||||
|
||||
func UserFollowersAPIURL(uuid uuid.UUID) *lysand.URL {
|
||||
newPath := &url.URL{Path: "./followers"}
|
||||
return UserAPIURL(uuid).ResolveReference(newPath)
|
||||
}
|
||||
|
||||
func UserFollowingAPIURL(uuid uuid.UUID) *lysand.URL {
|
||||
newPath := &url.URL{Path: "./following"}
|
||||
return UserAPIURL(uuid).ResolveReference(newPath)
|
||||
}
|
||||
|
||||
func UserFeaturedAPIURL(uuid uuid.UUID) *lysand.URL {
|
||||
newPath := &url.URL{Path: "./featured"}
|
||||
return UserAPIURL(uuid).ResolveReference(newPath)
|
||||
}
|
||||
|
||||
func UserLikesAPIURL(uuid uuid.UUID) *lysand.URL {
|
||||
newPath := &url.URL{Path: "./likes"}
|
||||
return UserAPIURL(uuid).ResolveReference(newPath)
|
||||
}
|
||||
|
||||
func UserDislikesAPIURL(uuid uuid.UUID) *lysand.URL {
|
||||
newPath := &url.URL{Path: "./dislikes"}
|
||||
return UserAPIURL(uuid).ResolveReference(newPath)
|
||||
}
|
||||
|
||||
func FollowAPIURL(uuid uuid.UUID) *lysand.URL {
|
||||
newPath := &url.URL{Path: fmt.Sprintf("/api/follows/%s/", uuid.String())}
|
||||
return lysand.URLFromStd(config.C.PublicAddress.ResolveReference(newPath))
|
||||
}
|
||||
|
||||
func NoteAPIURL(uuid uuid.UUID) *lysand.URL {
|
||||
newPath := &url.URL{Path: fmt.Sprintf("/api/notes/%s/", uuid.String())}
|
||||
return lysand.URLFromStd(config.C.PublicAddress.ResolveReference(newPath))
|
||||
}
|
||||
90
internal/validators/val_impls/body_validator_impl.go
Normal file
90
internal/validators/val_impls/body_validator_impl.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package val_impls
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/lysand-org/versia-go/internal/validators"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
en_locale "github.com/go-playground/locales/en"
|
||||
universal_translator "github.com/go-playground/universal-translator"
|
||||
"github.com/go-playground/validator/v10"
|
||||
en_translations "github.com/go-playground/validator/v10/translations/en"
|
||||
"github.com/lysand-org/versia-go/ent/schema"
|
||||
"github.com/lysand-org/versia-go/internal/api_schema"
|
||||
)
|
||||
|
||||
var _ validators.BodyValidator = (*BodyValidatorImpl)(nil)
|
||||
|
||||
type BodyValidatorImpl struct {
|
||||
validator *validator.Validate
|
||||
translator *universal_translator.UniversalTranslator
|
||||
enTranslator universal_translator.Translator
|
||||
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
func NewBodyValidator(log logr.Logger) *BodyValidatorImpl {
|
||||
en := en_locale.New()
|
||||
translator := universal_translator.New(en, en)
|
||||
trans, ok := translator.GetTranslator("en")
|
||||
if !ok {
|
||||
panic("failed to get \"en\" translator")
|
||||
}
|
||||
|
||||
validate := validator.New(validator.WithRequiredStructEnabled())
|
||||
if err := en_translations.RegisterDefaultTranslations(validate, trans); err != nil {
|
||||
panic("failed to register default translations")
|
||||
}
|
||||
|
||||
validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
|
||||
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
|
||||
if name == "-" {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
})
|
||||
|
||||
if err := validate.RegisterValidation("username_regex", func(fl validator.FieldLevel) bool {
|
||||
return schema.ValidateUsername(fl.Field().String()) == nil
|
||||
}); err != nil {
|
||||
panic("failed to register username_regex validator")
|
||||
}
|
||||
|
||||
if err := validate.RegisterTranslation("username_regex", trans, func(ut universal_translator.Translator) error {
|
||||
return trans.Add("user_regex", "{0} must match '^[a-z0-9_-]+$'!", true)
|
||||
}, func(ut universal_translator.Translator, fe validator.FieldError) string {
|
||||
t, _ := ut.T("user_regex", fe.Field())
|
||||
return t
|
||||
}); err != nil {
|
||||
panic("failed to register user_regex translation")
|
||||
}
|
||||
|
||||
return &BodyValidatorImpl{
|
||||
validator: validate,
|
||||
translator: translator,
|
||||
enTranslator: trans,
|
||||
}
|
||||
}
|
||||
|
||||
func (i BodyValidatorImpl) Validate(v any) error {
|
||||
err := i.validator.Struct(v)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var invalidValidationError *validator.InvalidValidationError
|
||||
if errors.As(err, &invalidValidationError) {
|
||||
panic(invalidValidationError)
|
||||
}
|
||||
|
||||
i.log.Error(err, "Failed to validate object")
|
||||
|
||||
errs := make([]string, 0)
|
||||
for _, err := range err.(validator.ValidationErrors) {
|
||||
errs = append(errs, err.Translate(i.enTranslator))
|
||||
}
|
||||
|
||||
return api_schema.ErrBadRequest(map[string]any{"validation": errs})
|
||||
}
|
||||
109
internal/validators/val_impls/request_validator_impl.go
Normal file
109
internal/validators/val_impls/request_validator_impl.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package val_impls
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"git.devminer.xyz/devminer/unitel"
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/lysand-org/versia-go/internal/repository"
|
||||
"github.com/lysand-org/versia-go/internal/validators"
|
||||
"github.com/lysand-org/versia-go/pkg/lysand"
|
||||
"github.com/valyala/fasthttp/fasthttpadaptor"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidSignature = errors.New("invalid signature")
|
||||
|
||||
_ validators.RequestValidator = (*RequestValidatorImpl)(nil)
|
||||
)
|
||||
|
||||
type RequestValidatorImpl struct {
|
||||
repositories repository.Manager
|
||||
|
||||
telemetry *unitel.Telemetry
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
func NewRequestValidator(repositories repository.Manager, telemetry *unitel.Telemetry, log logr.Logger) *RequestValidatorImpl {
|
||||
return &RequestValidatorImpl{
|
||||
repositories: repositories,
|
||||
|
||||
telemetry: telemetry,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (i RequestValidatorImpl) Validate(ctx context.Context, r *http.Request) error {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "validator/val_impls.RequestValidatorImpl.Validate")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
date, sigHeader, err := lysand.ExtractFederationHeaders(r.Header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: Fetch user from database instead of using the URI
|
||||
user, err := i.repositories.Users().Resolve(ctx, lysand.URLFromStd(sigHeader.KeyID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := copyBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !(lysand.Verifier{PublicKey: user.PublicKey}).Verify(r.Method, date, r.Host, r.URL.Path, body, sigHeader) {
|
||||
i.log.Info("signature verification failed", "user", user.URI, "ur", r.URL.Path)
|
||||
s.CaptureError(ErrInvalidSignature)
|
||||
|
||||
return ErrInvalidSignature
|
||||
} else {
|
||||
i.log.V(2).Info("signature verification succeeded", "user", user.URI, "ur", r.URL.Path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i RequestValidatorImpl) ValidateFiberCtx(ctx context.Context, c *fiber.Ctx) error {
|
||||
s := i.telemetry.StartSpan(ctx, "function", "validator/val_impls.RequestValidatorImpl.ValidateFiberCtx")
|
||||
defer s.End()
|
||||
ctx = s.Context()
|
||||
|
||||
r, err := convertToStdRequest(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return i.Validate(ctx, r)
|
||||
}
|
||||
|
||||
func convertToStdRequest(c *fiber.Ctx) (*http.Request, error) {
|
||||
stdReq := &http.Request{}
|
||||
if err := fasthttpadaptor.ConvertRequest(c.Context(), stdReq, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stdReq, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
16
internal/validators/validator.go
Normal file
16
internal/validators/validator.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package validators
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type BodyValidator interface {
|
||||
Validate(v any) error
|
||||
}
|
||||
|
||||
type RequestValidator interface {
|
||||
Validate(ctx context.Context, r *http.Request) error
|
||||
ValidateFiberCtx(ctx context.Context, c *fiber.Ctx) error
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue