Move translators db->ap to separate module

This commit is contained in:
Melody Becker 2025-06-13 13:42:56 +02:00
parent cfe5047433
commit d86ad370df
Signed by: mstar
SSH key fingerprint: SHA256:9VAo09aaVNTWKzPW7Hq2LW+ox9OdwmTSHRoD4mlz1yI
16 changed files with 456 additions and 423 deletions

View file

@ -0,0 +1,48 @@
package translators
import (
"context"
"fmt"
"strings"
"git.mstar.dev/mstar/linstrom/config"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models"
)
type ActivityAcceptOut struct {
Context any `json:"@context,omitempty"`
Id string `json:"id"`
Type string `json:"type"`
Actor string `json:"actor"`
Object any `json:"object"`
}
func AcceptFromStorage(ctx context.Context, id string) (*ActivityAcceptOut, error) {
a := dbgen.Activity
activity, err := a.Where(a.Id.Eq(id), a.Type.Eq(string(models.ActivityAccept))).First()
if err != nil {
return nil, err
}
// switch activity.ObjectType {
// case models.ActivitystreamsActivityTargetFollow:
// default:
// return nil, errors.New("unknown activity target type")
// }
follow, err := FollowFromStorage(ctx, activity.ObjectId)
if err != nil {
return nil, err
}
var outId string
if strings.HasPrefix(id, "http") {
outId = id
} else {
outId = fmt.Sprintf("%s/api/activitypub/activity/accept/%s", config.GlobalConfig.General.GetFullPublicUrl(), id)
}
return &ActivityAcceptOut{
Id: outId,
Actor: follow.Object.(string),
Type: "Accept",
Object: follow,
}, nil
}

View file

@ -0,0 +1,23 @@
package translators
// Used for both unordered and ordered
type CollectionOut struct {
Context any `json:"@context,omitempty"`
Summary string `json:"summary,omitempty"`
Type string `json:"type"`
Items []any `json:"items,omitempty"`
Id string `json:"id"`
TotalItems int `json:"totalItems"`
First string `json:"first"`
}
// Used for both unordered and ordered
type CollectionPageOut struct {
Context any `json:"@context,omitempty"`
Type string `json:"type"`
Id string `json:"id"`
PartOf string `json:"partOf"`
Next string `json:"next,omitempty"`
Previous string `json:"prev,omitempty"`
Items []any `json:"items"`
}

View file

@ -0,0 +1,58 @@
package translators
import (
"context"
"git.mstar.dev/mstar/linstrom/config"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models"
)
type ActivityCreate struct {
Context any `json:"@context,omitempty"`
Id string `json:"id"`
Type string `json:"type"`
Actor string `json:"actor"`
Object any `json:"object"`
}
// Find a create activity from the db and format it for activitypub reads
// Does not set the context for the activity, in case the activity is embedded
// in another activity or object. That's the responsibility of the handler
// getting the final result
func CreateFromStorage(ctx context.Context, id string) (*ActivityCreate, error) {
// log := log.Ctx(ctx)
a := dbgen.Activity
activity, err := a.Where(a.Type.Eq(string(models.ActivityCreate))).
Where(a.Id.Eq(id)).
First()
if err != nil {
return nil, err
}
switch models.ActivitystreamsActivityTargetType(activity.ObjectType) {
case models.ActivitystreamsActivityTargetNote:
note, err := NoteFromStorage(ctx, activity.ObjectId)
if err != nil {
return nil, err
}
out := ActivityCreate{
Id: config.GlobalConfig.General.GetFullPublicUrl() + "/api/activitypub/create/" + id,
Type: "Create",
Actor: note.AttributedTo,
Object: note,
}
return &out, nil
case models.ActivitystreamsActivityTargetBoost:
panic("Not implemented")
case models.ActivitystreamsActivityTargetReaction:
panic("Not implemented")
case models.ActivitystreamsActivityTargetActivity:
panic("Not implemented")
case models.ActivitystreamsActivityTargetUser:
panic("Not implemented")
case models.ActivitystreamsActivityTargetUnknown:
panic("Not implemented")
default:
panic("Not implemented")
}
}

View file

@ -0,0 +1,61 @@
package translators
import (
"context"
"strconv"
"git.mstar.dev/mstar/linstrom/activitypub"
"git.mstar.dev/mstar/linstrom/config"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models"
)
type ActivityFollowOut struct {
Context any `json:"@context,omitempty"`
Id string `json:"id"`
Type string `json:"type"`
Actor string `json:"actor"`
Object any `json:"object"`
}
func FollowFromStorage(ctx context.Context, id string) (*ActivityFollowOut, error) {
ac := dbgen.Activity
u2u := dbgen.UserToUserRelation
u := dbgen.User
// log := log.Ctx(ctx)
activity, err := ac.Where(ac.Id.Eq(id), ac.Type.Eq(string(models.ActivityFollow))).First()
if err != nil {
return nil, err
}
followId, err := strconv.ParseUint(activity.ObjectId, 10, 64)
if err != nil {
return nil, err
}
relation, err := u2u.Where(u2u.ID.Eq(followId)).First()
if err != nil {
return nil, err
}
follower, err := u.Where(u.ID.Eq(relation.UserId)).Preload(u.RemoteInfo).First()
if err != nil {
return nil, err
}
followed, err := u.Where(u.ID.Eq(relation.TargetUserId)).Preload(u.RemoteInfo).First()
if err != nil {
return nil, err
}
out := ActivityFollowOut{
Id: config.GlobalConfig.General.GetFullPublicUrl() + "/api/activitypub/activity/follow/" + id,
Type: "Follow",
}
if follower.RemoteInfo != nil {
out.Actor = follower.RemoteInfo.ApLink
} else {
out.Actor = activitypub.UserIdToApUrl(follower.ID)
}
if followed.RemoteInfo != nil {
out.Object = followed.RemoteInfo.ApLink
} else {
out.Object = activitypub.UserIdToApUrl(followed.ID)
}
return &out, nil
}

View file

@ -0,0 +1,113 @@
package translators
import (
"context"
"fmt"
"time"
"git.mstar.dev/mstar/linstrom/config"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models"
webshared "git.mstar.dev/mstar/linstrom/web/shared"
)
type Tag struct {
Type string `json:"type"`
Href string `json:"href"`
Name string `json:"name"`
}
type ObjectNote struct {
// Context should be set, if needed, by the endpoint handler
Context any `json:"@context,omitempty"`
// Attributes below set from storage
Id string `json:"id"`
Type string `json:"type"`
Summary *string `json:"summary"`
InReplyTo *string `json:"inReplyTo"`
Published time.Time `json:"published"`
Url string `json:"url"`
AttributedTo string `json:"attributedTo"`
To []string `json:"to"`
CC []string `json:"cc"`
Sensitive bool `json:"sensitive"`
AtomUri string `json:"atomUri"`
InReplyToAtomUri *string `json:"inReplyToAtomUri"`
// Conversation string `json:"conversation"` // FIXME: Uncomment once understood what this field wants
Content string `json:"content"`
// ContentMap map[string]string `json:"content_map"` // TODO: Uncomment once/if support for multiple languages available
// Attachments []string `json:"attachments"` // FIXME: Change this to document type
Tags []Tag `json:"tag"`
// Replies any `json:"replies"` // FIXME: Change this to collection type embedding first page
// Likes any `json:"likes"` // FIXME: Change this to collection
// Shares any `json:"shares"` // FIXME: Change this to collection, is boosts
}
func NoteFromStorage(ctx context.Context, id string) (*ObjectNote, error) {
note, err := dbgen.Note.Where(dbgen.Note.ID.Eq(id)).
Preload(dbgen.Note.Creator).
Preload(dbgen.Note.PingRelations).
Preload(dbgen.Note.Tags).
First()
if err != nil {
return nil, err
}
// TODO: Check access level, requires acting user to be included in function signature
publicUrlPrefix := config.GlobalConfig.General.GetFullPublicUrl()
data := &ObjectNote{
Id: publicUrlPrefix + "/api/activitypub/note/" + id,
Type: "Note",
Published: note.CreatedAt,
AttributedTo: publicUrlPrefix + "/api/activitypub/user/" + note.CreatorId,
Content: note.RawContent, // FIXME: Escape content
Url: publicUrlPrefix + "/@" + note.Creator.Username + "/" + id,
AtomUri: publicUrlPrefix + "/api/activitypub/object/" + id,
Tags: []Tag{},
}
switch note.AccessLevel {
case models.NOTE_TARGET_PUBLIC:
data.To = []string{
"https://www.w3.org/ns/activitystreams#Public",
}
data.CC = []string{
fmt.Sprintf("%s/api/activitypub/user/%s/followers", publicUrlPrefix, note.CreatorId),
}
case models.NOTE_TARGET_HOME:
return nil, fmt.Errorf("home access level not implemented")
case models.NOTE_TARGET_FOLLOWERS:
return nil, fmt.Errorf("followers access level not implemented")
case models.NOTE_TARGET_DM:
return nil, fmt.Errorf("dm access level not implemented")
default:
return nil, fmt.Errorf("unknown access level %v", note.AccessLevel)
}
if note.RepliesTo.Valid {
data.InReplyTo = &note.RepliesTo.String
data.InReplyToAtomUri = &note.RepliesTo.String
}
if note.ContentWarning.Valid {
data.Summary = &note.ContentWarning.String
data.Sensitive = true
}
for _, ping := range note.PingRelations {
target, err := dbgen.User.GetById(ping.PingTargetId)
if err != nil {
return nil, err
}
data.Tags = append(data.Tags, Tag{
Type: "Mention",
Href: webshared.UserPublicUrl(target.ID),
Name: target.Username,
})
}
for _, tag := range note.Tags {
data.Tags = append(data.Tags, Tag{
Type: "Hashtag",
Href: tag.TagUrl,
Name: tag.Tag,
})
}
return data, nil
}

View file

@ -0,0 +1,126 @@
package translators
import (
"context"
"time"
"git.mstar.dev/mstar/goutils/sliceutils"
"github.com/rs/zerolog/log"
"git.mstar.dev/mstar/linstrom/activitypub"
"git.mstar.dev/mstar/linstrom/config"
"git.mstar.dev/mstar/linstrom/shared"
"git.mstar.dev/mstar/linstrom/storage-new"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models"
webshared "git.mstar.dev/mstar/linstrom/web/shared"
)
type UserKey struct {
Id string `json:"id"`
Owner string `json:"owner"`
Pem string `json:"publicKeyPem"`
}
type Media struct {
Type string `json:"type"`
Url string `json:"url"`
MediaType string `json:"mediaType"`
}
type User struct {
Context []any `json:"@context"`
Id string `json:"id"`
Type string `json:"type"`
PreferredUsername string `json:"preferredUsername"`
Inbox string `json:"inbox"`
Outboux string `json:"outbox"`
PublicKey UserKey `json:"publicKey"`
Published time.Time `json:"published"`
DisplayName string `json:"name"`
Description *string `json:"summary,omitempty"`
PublicUrl string `json:"url"`
Icon *Media `json:"icon,omitempty"`
Banner *Media `json:"image,omitempty"`
Discoverable bool `json:"discoverable"`
Location *string `json:"vcard:Address,omitempty"`
Birthday *string `json:"vcard:bday,omitempty"`
SpeakAsCat bool `json:"speakAsCat"`
IsCat bool `json:"isCat"`
RestrictedFollow bool `json:"manuallyApprovesFollowers"`
Following string `json:"following"`
Followers string `json:"followers"`
}
func UserFromStorage(ctx context.Context, id string) (*User, error) {
user, err := dbgen.User.Where(dbgen.User.ID.Eq(id)).
Preload(dbgen.User.Icon).Preload(dbgen.User.Banner).
Preload(dbgen.User.BeingTypes).
First()
err = storage.EnsureLocalUserIdHasLinks(id)
if err != nil {
log.Warn().Err(err).Msg("Failed to create links for local user")
}
apUrl := activitypub.UserIdToApUrl(user.ID)
var keyBytes string
if config.GlobalConfig.Experimental.UseEd25519Keys {
keyBytes = shared.KeyBytesToPem(user.PublicKeyEd, true)
} else {
keyBytes = shared.KeyBytesToPem(user.PublicKeyRsa, false)
}
data := User{
Id: apUrl,
Type: "Person",
PreferredUsername: user.Username,
Inbox: apUrl + "/inbox",
Outboux: apUrl + "/outbox",
PublicKey: UserKey{
Id: apUrl + "#main-key",
Owner: apUrl,
Pem: keyBytes,
},
Published: user.CreatedAt,
DisplayName: user.DisplayName,
PublicUrl: config.GlobalConfig.General.GetFullPublicUrl() + "/user/" + user.Username,
Discoverable: user.Indexable,
RestrictedFollow: user.RestrictedFollow,
Following: apUrl + "/following",
Followers: apUrl + "/followers",
}
if user.Description != "" {
data.Description = &user.Description
}
if user.Icon != nil {
log.Debug().Msg("icon found")
data.Icon = &Media{
Type: "Image",
Url: config.GlobalConfig.General.GetFullPublicUrl() + webshared.EnsurePublicUrl(
user.Icon.Location,
),
MediaType: user.Icon.Type,
}
}
if user.Banner != nil {
log.Debug().Msg("icon banner")
data.Banner = &Media{
Type: "Image",
Url: config.GlobalConfig.General.GetFullPublicUrl() + webshared.EnsurePublicUrl(
user.Banner.Location,
),
MediaType: user.Banner.Type,
}
}
if sliceutils.ContainsFunc(user.BeingTypes, func(t models.UserToBeing) bool {
return t.Being == string(models.BEING_CAT)
}) {
data.IsCat = true
// data.SpeakAsCat = true // TODO: Move to check of separate field in db model
}
if user.Location.Valid {
data.Location = &user.Location.String
}
if user.Birthday.Valid {
data.Birthday = &user.Birthday.String
// data.Birthday = other.IntoPointer(user.Birthday.Time.Format("2006-Jan-02")) //YYYY-Month-DD
}
return &data, nil
}

View file

@ -5,6 +5,7 @@ var AllTypes = []any{
&Activity{}, &Activity{},
&Collection{}, &Collection{},
&Emote{}, &Emote{},
&FailedOutboundRequest{},
&Feed{}, &Feed{},
&MediaMetadata{}, &MediaMetadata{},
&Note{}, &Note{},

View file

@ -13,11 +13,11 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"git.mstar.dev/mstar/linstrom/activitypub" "git.mstar.dev/mstar/linstrom/activitypub"
"git.mstar.dev/mstar/linstrom/activitypub/translators"
"git.mstar.dev/mstar/linstrom/shared" "git.mstar.dev/mstar/linstrom/shared"
"git.mstar.dev/mstar/linstrom/storage-new" "git.mstar.dev/mstar/linstrom/storage-new"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen" "git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models" "git.mstar.dev/mstar/linstrom/storage-new/models"
webap "git.mstar.dev/mstar/linstrom/web/public/api/activitypub"
webshared "git.mstar.dev/mstar/linstrom/web/shared" webshared "git.mstar.dev/mstar/linstrom/web/shared"
) )
@ -148,7 +148,7 @@ func postAs(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Debug().Strs("links", links).Send() log.Debug().Strs("links", links).Send()
act, err := webap.CreateFromStorage(r.Context(), activity.Id) act, err := translators.CreateFromStorage(r.Context(), activity.Id)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to fetch and format new note") log.Error().Err(err).Msg("Failed to fetch and format new note")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)

View file

@ -19,11 +19,11 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"git.mstar.dev/mstar/linstrom/activitypub" "git.mstar.dev/mstar/linstrom/activitypub"
"git.mstar.dev/mstar/linstrom/activitypub/translators"
"git.mstar.dev/mstar/linstrom/shared" "git.mstar.dev/mstar/linstrom/shared"
"git.mstar.dev/mstar/linstrom/storage-new" "git.mstar.dev/mstar/linstrom/storage-new"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen" "git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models" "git.mstar.dev/mstar/linstrom/storage-new/models"
webap "git.mstar.dev/mstar/linstrom/web/public/api/activitypub"
webshared "git.mstar.dev/mstar/linstrom/web/shared" webshared "git.mstar.dev/mstar/linstrom/web/shared"
) )
@ -377,7 +377,7 @@ func requestFollow(w http.ResponseWriter, r *http.Request) {
log.Error().Err(err).Msg("Failed to get target user with remote links") log.Error().Err(err).Msg("Failed to get target user with remote links")
return return
} }
activity, err := webap.FollowFromStorage(context.Background(), activity.Id) activity, err := translators.FollowFromStorage(context.Background(), activity.Id)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to retrieve and format follow request") log.Error().Err(err).Msg("Failed to retrieve and format follow request")
return return

View file

@ -1,35 +1,23 @@
package activitypub package activitypub
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings"
webutils "git.mstar.dev/mstar/goutils/http" webutils "git.mstar.dev/mstar/goutils/http"
"github.com/rs/zerolog/hlog" "github.com/rs/zerolog/hlog"
"gorm.io/gorm" "gorm.io/gorm"
"git.mstar.dev/mstar/linstrom/activitypub" "git.mstar.dev/mstar/linstrom/activitypub"
"git.mstar.dev/mstar/linstrom/config" "git.mstar.dev/mstar/linstrom/activitypub/translators"
"git.mstar.dev/mstar/linstrom/storage-new" "git.mstar.dev/mstar/linstrom/storage-new"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models"
) )
type ActivityAcceptOut struct {
Context any `json:"@context,omitempty"`
Id string `json:"id"`
Type string `json:"type"`
Actor string `json:"actor"`
Object any `json:"object"`
}
func activityAccept(w http.ResponseWriter, r *http.Request) { func activityAccept(w http.ResponseWriter, r *http.Request) {
log := hlog.FromRequest(r) log := hlog.FromRequest(r)
id := r.PathValue("id") id := r.PathValue("id")
activity, err := CreateFromStorage(r.Context(), id) activity, err := translators.CreateFromStorage(r.Context(), id)
switch err { switch err {
case gorm.ErrRecordNotFound: case gorm.ErrRecordNotFound:
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound) _ = webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound)
@ -52,32 +40,3 @@ func activityAccept(w http.ResponseWriter, r *http.Request) {
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
} }
} }
func AcceptFromStorage(ctx context.Context, id string) (*ActivityAcceptOut, error) {
a := dbgen.Activity
activity, err := a.Where(a.Id.Eq(id), a.Type.Eq(string(models.ActivityAccept))).First()
if err != nil {
return nil, err
}
// switch activity.ObjectType {
// case models.ActivitystreamsActivityTargetFollow:
// default:
// return nil, errors.New("unknown activity target type")
// }
follow, err := FollowFromStorage(ctx, activity.ObjectId)
if err != nil {
return nil, err
}
var outId string
if strings.HasPrefix(id, "http") {
outId = id
} else {
outId = fmt.Sprintf("%s/api/activitypub/activity/accept/%s", config.GlobalConfig.General.GetFullPublicUrl(), id)
}
return &ActivityAcceptOut{
Id: outId,
Actor: follow.Object.(string),
Type: "Accept",
Object: follow,
}, nil
}

View file

@ -1,7 +1,6 @@
package activitypub package activitypub
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@ -11,24 +10,14 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"git.mstar.dev/mstar/linstrom/activitypub" "git.mstar.dev/mstar/linstrom/activitypub"
"git.mstar.dev/mstar/linstrom/config" "git.mstar.dev/mstar/linstrom/activitypub/translators"
"git.mstar.dev/mstar/linstrom/storage-new" "git.mstar.dev/mstar/linstrom/storage-new"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models"
) )
type ActivityCreate struct {
Context any `json:"@context,omitempty"`
Id string `json:"id"`
Type string `json:"type"`
Actor string `json:"actor"`
Object any `json:"object"`
}
func activityCreate(w http.ResponseWriter, r *http.Request) { func activityCreate(w http.ResponseWriter, r *http.Request) {
log := hlog.FromRequest(r) log := hlog.FromRequest(r)
id := r.PathValue("id") id := r.PathValue("id")
activity, err := CreateFromStorage(r.Context(), id) activity, err := translators.CreateFromStorage(r.Context(), id)
switch err { switch err {
case gorm.ErrRecordNotFound: case gorm.ErrRecordNotFound:
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound) _ = webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound)
@ -51,44 +40,3 @@ func activityCreate(w http.ResponseWriter, r *http.Request) {
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
} }
} }
// Find a create activity from the db and format it for activitypub reads
// Does not set the context for the activity, in case the activity is embedded
// in another activity or object. That's the responsibility of the handler
// getting the final result
func CreateFromStorage(ctx context.Context, id string) (*ActivityCreate, error) {
// log := log.Ctx(ctx)
a := dbgen.Activity
activity, err := a.Where(a.Type.Eq(string(models.ActivityCreate))).
Where(a.Id.Eq(id)).
First()
if err != nil {
return nil, err
}
switch models.ActivitystreamsActivityTargetType(activity.ObjectType) {
case models.ActivitystreamsActivityTargetNote:
note, err := NoteFromStorage(ctx, activity.ObjectId)
if err != nil {
return nil, err
}
out := ActivityCreate{
Id: config.GlobalConfig.General.GetFullPublicUrl() + "/api/activitypub/create/" + id,
Type: "Create",
Actor: note.AttributedTo,
Object: note,
}
return &out, nil
case models.ActivitystreamsActivityTargetBoost:
panic("Not implemented")
case models.ActivitystreamsActivityTargetReaction:
panic("Not implemented")
case models.ActivitystreamsActivityTargetActivity:
panic("Not implemented")
case models.ActivitystreamsActivityTargetUser:
panic("Not implemented")
case models.ActivitystreamsActivityTargetUnknown:
panic("Not implemented")
default:
panic("Not implemented")
}
}

View file

@ -1,35 +1,23 @@
package activitypub package activitypub
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
webutils "git.mstar.dev/mstar/goutils/http" webutils "git.mstar.dev/mstar/goutils/http"
"github.com/rs/zerolog/hlog" "github.com/rs/zerolog/hlog"
"gorm.io/gorm" "gorm.io/gorm"
"git.mstar.dev/mstar/linstrom/activitypub" "git.mstar.dev/mstar/linstrom/activitypub"
"git.mstar.dev/mstar/linstrom/config" "git.mstar.dev/mstar/linstrom/activitypub/translators"
"git.mstar.dev/mstar/linstrom/storage-new" "git.mstar.dev/mstar/linstrom/storage-new"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models"
) )
type ActivityFollowOut struct {
Context any `json:"@context,omitempty"`
Id string `json:"id"`
Type string `json:"type"`
Actor string `json:"actor"`
Object any `json:"object"`
}
func activityFollow(w http.ResponseWriter, r *http.Request) { func activityFollow(w http.ResponseWriter, r *http.Request) {
log := hlog.FromRequest(r) log := hlog.FromRequest(r)
id := r.PathValue("id") id := r.PathValue("id")
activity, err := FollowFromStorage(r.Context(), id) activity, err := translators.FollowFromStorage(r.Context(), id)
switch err { switch err {
case gorm.ErrRecordNotFound: case gorm.ErrRecordNotFound:
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound) _ = webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound)
@ -52,45 +40,3 @@ func activityFollow(w http.ResponseWriter, r *http.Request) {
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
} }
} }
func FollowFromStorage(ctx context.Context, id string) (*ActivityFollowOut, error) {
ac := dbgen.Activity
u2u := dbgen.UserToUserRelation
u := dbgen.User
// log := log.Ctx(ctx)
activity, err := ac.Where(ac.Id.Eq(id), ac.Type.Eq(string(models.ActivityFollow))).First()
if err != nil {
return nil, err
}
followId, err := strconv.ParseUint(activity.ObjectId, 10, 64)
if err != nil {
return nil, err
}
relation, err := u2u.Where(u2u.ID.Eq(followId)).First()
if err != nil {
return nil, err
}
follower, err := u.Where(u.ID.Eq(relation.UserId)).Preload(u.RemoteInfo).First()
if err != nil {
return nil, err
}
followed, err := u.Where(u.ID.Eq(relation.TargetUserId)).Preload(u.RemoteInfo).First()
if err != nil {
return nil, err
}
out := ActivityFollowOut{
Id: config.GlobalConfig.General.GetFullPublicUrl() + "/api/activitypub/activity/follow/" + id,
Type: "Follow",
}
if follower.RemoteInfo != nil {
out.Actor = follower.RemoteInfo.ApLink
} else {
out.Actor = activitypub.UserIdToApUrl(follower.ID)
}
if followed.RemoteInfo != nil {
out.Object = followed.RemoteInfo.ApLink
} else {
out.Object = activitypub.UserIdToApUrl(followed.ID)
}
return &out, nil
}

View file

@ -2,28 +2,6 @@ package activitypub
import "net/http" import "net/http"
// Used for both unordered and ordered
type collectionOut struct {
Context any `json:"@context,omitempty"`
Summary string `json:"summary,omitempty"`
Type string `json:"type"`
Items []any `json:"items,omitempty"`
Id string `json:"id"`
TotalItems int `json:"totalItems"`
First string `json:"first"`
}
// Used for both unordered and ordered
type collectionPageOut struct {
Context any `json:"@context,omitempty"`
Type string `json:"type"`
Id string `json:"id"`
PartOf string `json:"partOf"`
Next string `json:"next,omitempty"`
Previous string `json:"prev,omitempty"`
Items []any `json:"items"`
}
// Unordered collections handler // Unordered collections handler
func collections(w http.ResponseWriter, r *http.Request) {} func collections(w http.ResponseWriter, r *http.Request) {}

View file

@ -20,6 +20,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"git.mstar.dev/mstar/linstrom/activitypub" "git.mstar.dev/mstar/linstrom/activitypub"
"git.mstar.dev/mstar/linstrom/activitypub/translators"
"git.mstar.dev/mstar/linstrom/config" "git.mstar.dev/mstar/linstrom/config"
"git.mstar.dev/mstar/linstrom/shared" "git.mstar.dev/mstar/linstrom/shared"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen" "git.mstar.dev/mstar/linstrom/storage-new/dbgen"
@ -682,7 +683,7 @@ func handleReject(w http.ResponseWriter, r *http.Request, object map[string]any)
func handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any) bool { func handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any) bool {
log := hlog.FromRequest(r) log := hlog.FromRequest(r)
activity := ActivityCreate{} activity := translators.ActivityCreate{}
err := mapstructure.Decode(object, &activity) err := mapstructure.Decode(object, &activity)
if err != nil { if err != nil {
log.Error(). log.Error().
@ -735,7 +736,7 @@ func handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any)
} else { } else {
obj["published"] = tmpTime obj["published"] = tmpTime
} }
objectNote := ObjectNote{} objectNote := translators.ObjectNote{}
err = mapstructure.Decode(obj, &objectNote) err = mapstructure.Decode(obj, &objectNote)
if err != nil { if err != nil {
log.Error(). log.Error().
@ -850,7 +851,7 @@ func AcceptFollow(
if err != nil { if err != nil {
return err return err
} }
webAccept, err := AcceptFromStorage(ctx, acceptActivity.Id) webAccept, err := translators.AcceptFromStorage(ctx, acceptActivity.Id)
if err != nil { if err != nil {
return err return err
} }

View file

@ -1,62 +1,23 @@
package activitypub package activitypub
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"time"
webutils "git.mstar.dev/mstar/goutils/http" webutils "git.mstar.dev/mstar/goutils/http"
"github.com/rs/zerolog/hlog" "github.com/rs/zerolog/hlog"
"gorm.io/gorm" "gorm.io/gorm"
"git.mstar.dev/mstar/linstrom/activitypub" "git.mstar.dev/mstar/linstrom/activitypub"
"git.mstar.dev/mstar/linstrom/config" "git.mstar.dev/mstar/linstrom/activitypub/translators"
"git.mstar.dev/mstar/linstrom/storage-new" "git.mstar.dev/mstar/linstrom/storage-new"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models"
webshared "git.mstar.dev/mstar/linstrom/web/shared"
) )
type Tag struct {
Type string `json:"type"`
Href string `json:"href"`
Name string `json:"name"`
}
type ObjectNote struct {
// Context should be set, if needed, by the endpoint handler
Context any `json:"@context,omitempty"`
// Attributes below set from storage
Id string `json:"id"`
Type string `json:"type"`
Summary *string `json:"summary"`
InReplyTo *string `json:"inReplyTo"`
Published time.Time `json:"published"`
Url string `json:"url"`
AttributedTo string `json:"attributedTo"`
To []string `json:"to"`
CC []string `json:"cc"`
Sensitive bool `json:"sensitive"`
AtomUri string `json:"atomUri"`
InReplyToAtomUri *string `json:"inReplyToAtomUri"`
// Conversation string `json:"conversation"` // FIXME: Uncomment once understood what this field wants
Content string `json:"content"`
// ContentMap map[string]string `json:"content_map"` // TODO: Uncomment once/if support for multiple languages available
// Attachments []string `json:"attachments"` // FIXME: Change this to document type
Tags []Tag `json:"tag"`
// Replies any `json:"replies"` // FIXME: Change this to collection type embedding first page
// Likes any `json:"likes"` // FIXME: Change this to collection
// Shares any `json:"shares"` // FIXME: Change this to collection, is boosts
}
func objectNote(w http.ResponseWriter, r *http.Request) { func objectNote(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")
log := hlog.FromRequest(r) log := hlog.FromRequest(r)
note, err := NoteFromStorage(r.Context(), id) note, err := translators.NoteFromStorage(r.Context(), id)
switch err { switch err {
case gorm.ErrRecordNotFound: case gorm.ErrRecordNotFound:
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound) _ = webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound)
@ -80,70 +41,3 @@ func objectNote(w http.ResponseWriter, r *http.Request) {
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
} }
} }
func NoteFromStorage(ctx context.Context, id string) (*ObjectNote, error) {
note, err := dbgen.Note.Where(dbgen.Note.ID.Eq(id)).
Preload(dbgen.Note.Creator).
Preload(dbgen.Note.PingRelations).
Preload(dbgen.Note.Tags).
First()
if err != nil {
return nil, err
}
// TODO: Check access level, requires acting user to be included in function signature
publicUrlPrefix := config.GlobalConfig.General.GetFullPublicUrl()
data := &ObjectNote{
Id: publicUrlPrefix + "/api/activitypub/note/" + id,
Type: "Note",
Published: note.CreatedAt,
AttributedTo: publicUrlPrefix + "/api/activitypub/user/" + note.CreatorId,
Content: note.RawContent, // FIXME: Escape content
Url: publicUrlPrefix + "/@" + note.Creator.Username + "/" + id,
AtomUri: publicUrlPrefix + "/api/activitypub/object/" + id,
Tags: []Tag{},
}
switch note.AccessLevel {
case models.NOTE_TARGET_PUBLIC:
data.To = []string{
"https://www.w3.org/ns/activitystreams#Public",
}
data.CC = []string{
fmt.Sprintf("%s/api/activitypub/user/%s/followers", publicUrlPrefix, note.CreatorId),
}
case models.NOTE_TARGET_HOME:
return nil, fmt.Errorf("home access level not implemented")
case models.NOTE_TARGET_FOLLOWERS:
return nil, fmt.Errorf("followers access level not implemented")
case models.NOTE_TARGET_DM:
return nil, fmt.Errorf("dm access level not implemented")
default:
return nil, fmt.Errorf("unknown access level %v", note.AccessLevel)
}
if note.RepliesTo.Valid {
data.InReplyTo = &note.RepliesTo.String
data.InReplyToAtomUri = &note.RepliesTo.String
}
if note.ContentWarning.Valid {
data.Summary = &note.ContentWarning.String
data.Sensitive = true
}
for _, ping := range note.PingRelations {
target, err := dbgen.User.GetById(ping.PingTargetId)
if err != nil {
return nil, err
}
data.Tags = append(data.Tags, Tag{
Type: "Mention",
Href: webshared.UserPublicUrl(target.ID),
Name: target.Username,
})
}
for _, tag := range note.Tags {
data.Tags = append(data.Tags, Tag{
Type: "Hashtag",
Href: tag.TagUrl,
Name: tag.Tag,
})
}
return data, nil
}

View file

@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"time"
webutils "git.mstar.dev/mstar/goutils/http" webutils "git.mstar.dev/mstar/goutils/http"
"git.mstar.dev/mstar/goutils/other" "git.mstar.dev/mstar/goutils/other"
@ -14,143 +13,21 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"git.mstar.dev/mstar/linstrom/activitypub" "git.mstar.dev/mstar/linstrom/activitypub"
"git.mstar.dev/mstar/linstrom/config" "git.mstar.dev/mstar/linstrom/activitypub/translators"
"git.mstar.dev/mstar/linstrom/shared"
"git.mstar.dev/mstar/linstrom/storage-new"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen" "git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models"
webshared "git.mstar.dev/mstar/linstrom/web/shared"
) )
func users(w http.ResponseWriter, r *http.Request) { func users(w http.ResponseWriter, r *http.Request) {
type OutboundKey struct {
Id string `json:"id"`
Owner string `json:"owner"`
Pem string `json:"publicKeyPem"`
}
type OutboundMedia struct {
Type string `json:"type"`
Url string `json:"url"`
MediaType string `json:"mediaType"`
}
type Outbound struct {
Context []any `json:"@context"`
Id string `json:"id"`
Type string `json:"type"`
PreferredUsername string `json:"preferredUsername"`
Inbox string `json:"inbox"`
Outboux string `json:"outbox"`
PublicKey OutboundKey `json:"publicKey"`
Published time.Time `json:"published"`
DisplayName string `json:"name"`
Description *string `json:"summary,omitempty"`
PublicUrl string `json:"url"`
Icon *OutboundMedia `json:"icon,omitempty"`
Banner *OutboundMedia `json:"image,omitempty"`
Discoverable bool `json:"discoverable"`
Location *string `json:"vcard:Address,omitempty"`
Birthday *string `json:"vcard:bday,omitempty"`
SpeakAsCat bool `json:"speakAsCat"`
IsCat bool `json:"isCat"`
RestrictedFollow bool `json:"manuallyApprovesFollowers"`
Following string `json:"following"`
Followers string `json:"followers"`
}
log := hlog.FromRequest(r) log := hlog.FromRequest(r)
userId := r.PathValue("id") userId := r.PathValue("id")
user, err := dbgen.User.Where(dbgen.User.ID.Eq(userId)).
Preload(dbgen.User.Icon).Preload(dbgen.User.Banner). user, err := translators.UserFromStorage(r.Context(), userId)
Preload(dbgen.User.BeingTypes).
First()
if err != nil { if err != nil {
_ = webutils.ProblemDetails( log.Error().Err(err).Msg("Failed to get user from db")
w, _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
500,
"/errors/db-failure",
"internal database failure",
nil,
nil,
)
if storage.HandleReconnectError(err) {
log.Warn().Msg("Connection to db lost. Reconnect attempt started")
} else {
log.Error().Err(err).Msg("Failed to get total user count from db")
}
return return
} }
// FIXME: Remove this later encoded, err := json.Marshal(user)
// (or rather move to dedicated module in storage for old migration stuff),
// temporary fix for old data. User creation locations are fixed already
err = storage.EnsureLocalUserIdHasLinks(userId)
if err != nil {
log.Warn().Err(err).Msg("Failed to create links for local user")
}
apUrl := activitypub.UserIdToApUrl(user.ID)
var keyBytes string
if config.GlobalConfig.Experimental.UseEd25519Keys {
keyBytes = shared.KeyBytesToPem(user.PublicKeyEd, true)
} else {
keyBytes = shared.KeyBytesToPem(user.PublicKeyRsa, false)
}
data := Outbound{
Context: activitypub.BaseLdContext,
Id: apUrl,
Type: "Person",
PreferredUsername: user.Username,
Inbox: apUrl + "/inbox",
Outboux: apUrl + "/outbox",
PublicKey: OutboundKey{
Id: apUrl + "#main-key",
Owner: apUrl,
Pem: keyBytes,
},
Published: user.CreatedAt,
DisplayName: user.DisplayName,
PublicUrl: config.GlobalConfig.General.GetFullPublicUrl() + "/user/" + user.Username,
Discoverable: user.Indexable,
RestrictedFollow: user.RestrictedFollow,
Following: apUrl + "/following",
Followers: apUrl + "/followers",
}
if user.Description != "" {
data.Description = &user.Description
}
if user.Icon != nil {
log.Debug().Msg("icon found")
data.Icon = &OutboundMedia{
Type: "Image",
Url: config.GlobalConfig.General.GetFullPublicUrl() + webshared.EnsurePublicUrl(
user.Icon.Location,
),
MediaType: user.Icon.Type,
}
}
if user.Banner != nil {
log.Debug().Msg("icon banner")
data.Banner = &OutboundMedia{
Type: "Image",
Url: config.GlobalConfig.General.GetFullPublicUrl() + webshared.EnsurePublicUrl(
user.Banner.Location,
),
MediaType: user.Banner.Type,
}
}
if sliceutils.ContainsFunc(user.BeingTypes, func(t models.UserToBeing) bool {
return t.Being == string(models.BEING_CAT)
}) {
data.IsCat = true
// data.SpeakAsCat = true // TODO: Move to check of separate field in db model
}
if user.Location.Valid {
data.Location = &user.Location.String
}
if user.Birthday.Valid {
data.Birthday = &user.Birthday.String
// data.Birthday = other.IntoPointer(user.Birthday.Time.Format("2006-Jan-02")) //YYYY-Month-DD
}
encoded, err := json.Marshal(data)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to marshal response") log.Error().Err(err).Msg("Failed to marshal response")
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
@ -183,7 +60,7 @@ func userFollowing(w http.ResponseWriter, r *http.Request) {
return return
} }
if pageNrStr == "" { if pageNrStr == "" {
col := collectionOut{ col := translators.CollectionOut{
Context: "https://www.w3.org/ns/activitystreams", Context: "https://www.w3.org/ns/activitystreams",
Type: "OrderedCollection", Type: "OrderedCollection",
Id: apUrl + "/following", Id: apUrl + "/following",
@ -222,7 +99,7 @@ func userFollowing(w http.ResponseWriter, r *http.Request) {
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return return
} }
page := collectionPageOut{ page := translators.CollectionPageOut{
Context: "https://www.w3.org/ns/activitystreams", Context: "https://www.w3.org/ns/activitystreams",
Type: "OrderedCollectionPage", Type: "OrderedCollectionPage",
Id: fmt.Sprintf("%s/following?page=%d", apUrl, pageNr), Id: fmt.Sprintf("%s/following?page=%d", apUrl, pageNr),
@ -270,7 +147,7 @@ func userFollowers(w http.ResponseWriter, r *http.Request) {
return return
} }
if pageNrStr == "" { if pageNrStr == "" {
col := collectionOut{ col := translators.CollectionOut{
Context: activitypub.BaseLdContext, Context: activitypub.BaseLdContext,
Type: "OrderedCollection", Type: "OrderedCollection",
Id: apUrl + "/followers", Id: apUrl + "/followers",
@ -310,7 +187,7 @@ func userFollowers(w http.ResponseWriter, r *http.Request) {
_ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return return
} }
page := collectionPageOut{ page := translators.CollectionPageOut{
Context: activitypub.BaseLdContext, Context: activitypub.BaseLdContext,
Type: "OrderedCollectionPage", Type: "OrderedCollectionPage",
Id: fmt.Sprintf("%s/followers?page=%d", apUrl, pageNr), Id: fmt.Sprintf("%s/followers?page=%d", apUrl, pageNr),