chore: init

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

View file

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,5 @@
package helpers
func StringPtr(s string) *string {
return &s
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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