Follow accept works and messags are pushed as expected
Some checks failed
/ docker (push) Failing after 2m50s

This commit is contained in:
Melody Becker 2025-05-10 11:18:28 +02:00
parent 9a3a330b1d
commit ff6a730e58
Signed by: mstar
SSH key fingerprint: SHA256:vkXfS9FG2pVNVfvDrzd1VW9n8VJzqqdKQGljxxX8uK8
10 changed files with 482 additions and 19 deletions

View file

@ -50,6 +50,7 @@ func main() {
g.ApplyInterface(func(models.IUser) {}, models.User{})
g.ApplyInterface(func(models.IAccessToken) {}, models.AccessToken{})
g.ApplyInterface(func(models.INote) {}, models.Note{})
g.ApplyInterface(func(models.IUserToUserRelation) {}, models.UserToUserRelation{})
log.Info().Msg("Extra features applied, starting generation")
g.Execute()

View file

@ -7,6 +7,7 @@ package dbgen
import (
"context"
"database/sql"
"strings"
"git.mstar.dev/mstar/linstrom/storage-new/models"
"gorm.io/gorm"
@ -587,6 +588,30 @@ type IUserToUserRelationDo interface {
Returning(value interface{}, columns ...string) IUserToUserRelationDo
UnderlyingDB() *gorm.DB
schema.Tabler
GetFollowersForId(id string) (result []string, err error)
}
// SELECT u.inbox_link
// FROM user_to_user_relations r
// LEFT JOIN user_remote_links u
// ON r.user_id = u.user_id
// WHERE
//
// r.target_user_id = @id AND
// r.relation = 'follow'
func (u userToUserRelationDo) GetFollowersForId(id string) (result []string, err error) {
var params []interface{}
var generateSQL strings.Builder
params = append(params, id)
generateSQL.WriteString("SELECT u.inbox_link FROM user_to_user_relations r LEFT JOIN user_remote_links u ON r.user_id = u.user_id WHERE r.target_user_id = ? AND r.relation = 'follow' ")
var executeSQL *gorm.DB
executeSQL = u.UnderlyingDB().Raw(generateSQL.String(), params...).Find(&result) // ignore_security_alert
err = executeSQL.Error
return
}
func (u userToUserRelationDo) Debug() IUserToUserRelationDo {

View file

@ -11,3 +11,14 @@ type UserToUserRelation struct {
TargetUserId string
Relation string // `gorm:"type:relation_type"`
}
type IUserToUserRelation interface {
// SELECT u.inbox_link
// FROM user_to_user_relations r
// LEFT JOIN user_remote_links u
// ON r.user_id = u.user_id
// WHERE
// r.target_user_id = @id AND
// r.relation = 'follow'
GetFollowersForId(id string) ([]string, error)
}

View file

@ -1,7 +1,7 @@
[general]
protocol = "https"
domain = "serveo.net"
subdomain = "b2f4e7c5596220d4c4957b24f6954220"
domain = "lhr.life"
subdomain = "d0deb4c7b3ee95"
private_port = 8080
public_port = 443

View file

@ -4,6 +4,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"io"
"net/http"
webutils "git.mstar.dev/mstar/goutils/http"
@ -11,10 +12,12 @@ import (
"github.com/rs/zerolog/hlog"
"gorm.io/gorm"
"git.mstar.dev/mstar/linstrom/activitypub"
"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"
webap "git.mstar.dev/mstar/linstrom/web/public/api/activitypub"
webshared "git.mstar.dev/mstar/linstrom/web/shared"
)
@ -66,7 +69,8 @@ func postAs(w http.ResponseWriter, r *http.Request) {
AccessLevel: models.NOTE_TARGET_PUBLIC,
OriginId: 1,
}
err = n.Select(
tx := dbgen.Q.Begin()
err = tx.Note.Select(
n.ID,
n.CreatorId,
n.RawContent,
@ -78,6 +82,7 @@ func postAs(w http.ResponseWriter, r *http.Request) {
n.OriginId,
).Create(&note)
if err != nil {
_ = tx.Rollback()
log.Error().
Err(err).
Str("username", data.Username).
@ -92,11 +97,51 @@ func postAs(w http.ResponseWriter, r *http.Request) {
ObjectId: note.ID,
ObjectType: uint32(models.ActivitystreamsActivityTargetNote),
}
a := dbgen.Activity
err = a.Create(&activity)
err = tx.Activity.Create(&activity)
if err != nil {
_ = tx.Rollback()
log.Error().Err(err).Msg("Failed to create activity for new note")
}
err = tx.Commit()
if err != nil {
log.Error().Err(err).Msg("Failed to commit note creation")
webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
u2u := dbgen.UserToUserRelation
links, err := u2u.GetFollowersForId(user.ID)
if err != nil {
log.Error().Err(err).Msg("Failed to get follower inbox links for user")
webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
log.Debug().Strs("links", links).Send()
act, err := webap.CreateFromStorage(r.Context(), activity.Id)
if err != nil {
log.Error().Err(err).Msg("Failed to fetch and format new note")
webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
act.Context = activitypub.BaseLdContext
outData, err := json.Marshal(act)
if err != nil {
log.Error().Err(err).Msg("Failed to marshal new note")
webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
for _, link := range links {
log.Debug().Str("target-inbox", link).Msg("Sending message to")
go func() {
res, err := webshared.RequestSignedCavage("POST", link, outData, user)
if err != nil {
log.Warn().Err(err).Str("link", link).Msg("Failed to send create to target inbox")
}
if res.StatusCode >= 400 {
body, _ := io.ReadAll(res.Body)
log.Warn().Int("status-code", res.StatusCode).Bytes("body", body).Msg("Bad reply")
}
}()
}
}
func notesFrom(w http.ResponseWriter, r *http.Request) {

View file

@ -1,5 +1,75 @@
package activitypub
import "net/http"
import (
"context"
"encoding/json"
"fmt"
"net/http"
func activityAccept(w http.ResponseWriter, r *http.Request) {}
webutils "git.mstar.dev/mstar/goutils/http"
"github.com/rs/zerolog/hlog"
"gorm.io/gorm"
"git.mstar.dev/mstar/linstrom/activitypub"
"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) {
log := hlog.FromRequest(r)
id := r.PathValue("id")
activity, err := CreateFromStorage(r.Context(), id)
switch err {
case gorm.ErrRecordNotFound:
webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound)
case nil:
activity.Context = activitypub.BaseLdContext
data, err := json.Marshal(activity)
if err != nil {
log.Error().Err(err).Any("activity", activity).Msg("Failed to marshal create activity")
webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/activity+json")
fmt.Fprint(w, string(data))
default:
if storage.HandleReconnectError(err) {
log.Error().Err(err).Msg("Connection failed, restart attempt started")
} else {
log.Error().Err(err).Msg("Failed to get create activity from db")
}
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
}
return &ActivityAcceptOut{
Id: id,
Actor: follow.Object.(string),
Type: "Accept",
Object: follow,
}, nil
}

View file

@ -17,7 +17,7 @@ import (
"git.mstar.dev/mstar/linstrom/storage-new/models"
)
type activityCreateOut struct {
type ActivityCreateOut struct {
Context any `json:"@context,omitempty"`
Id string `json:"id"`
Type string `json:"type"`
@ -28,7 +28,7 @@ type activityCreateOut struct {
func activityCreate(w http.ResponseWriter, r *http.Request) {
log := hlog.FromRequest(r)
id := r.PathValue("id")
activity, err := createFromStorage(r.Context(), id)
activity, err := CreateFromStorage(r.Context(), id)
switch err {
case gorm.ErrRecordNotFound:
webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound)
@ -56,7 +56,7 @@ func activityCreate(w http.ResponseWriter, r *http.Request) {
// 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) (*activityCreateOut, error) {
func CreateFromStorage(ctx context.Context, id string) (*ActivityCreateOut, error) {
// log := log.Ctx(ctx)
a := dbgen.Activity
activity, err := a.Where(a.Type.Eq(string(models.ActivityCreate))).
@ -67,11 +67,11 @@ func createFromStorage(ctx context.Context, id string) (*activityCreateOut, erro
}
switch models.ActivitystreamsActivityTargetType(activity.ObjectType) {
case models.ActivitystreamsActivityTargetNote:
note, err := noteFromStorage(ctx, activity.ObjectId)
note, err := NoteFromStorage(ctx, activity.ObjectId)
if err != nil {
return nil, err
}
out := activityCreateOut{
out := ActivityCreateOut{
Id: config.GlobalConfig.General.GetFullPublicUrl() + "/api/activitypub/create/" + id,
Type: "Create",
Actor: note.AttributedTo,

View file

@ -1 +1,95 @@
package activitypub
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
webutils "git.mstar.dev/mstar/goutils/http"
"github.com/rs/zerolog/hlog"
"gorm.io/gorm"
"git.mstar.dev/mstar/linstrom/activitypub"
"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) {
log := hlog.FromRequest(r)
id := r.PathValue("id")
activity, err := FollowFromStorage(r.Context(), id)
switch err {
case gorm.ErrRecordNotFound:
webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound)
case nil:
activity.Context = activitypub.BaseLdContext
data, err := json.Marshal(activity)
if err != nil {
log.Error().Err(err).Any("activity", activity).Msg("Failed to marshal create activity")
webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/activity+json")
fmt.Fprint(w, string(data))
default:
if storage.HandleReconnectError(err) {
log.Error().Err(err).Msg("Connection failed, restart attempt started")
} else {
log.Error().Err(err).Msg("Failed to get create activity from db")
}
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: id,
Type: "Follow",
}
if follower.RemoteInfo != nil {
out.Actor = follower.RemoteInfo.ApLink
} else {
out.Actor = userIdToApUrl(follower.ID)
}
if followed.RemoteInfo != nil {
out.Object = followed.RemoteInfo.ApLink
} else {
out.Object = userIdToApUrl(followed.ID)
}
return &out, nil
}

View file

@ -8,6 +8,7 @@ import (
"net/http"
"regexp"
"strconv"
"time"
webutils "git.mstar.dev/mstar/goutils/http"
"git.mstar.dev/mstar/goutils/other"
@ -15,8 +16,10 @@ import (
"gorm.io/gorm"
"git.mstar.dev/mstar/linstrom/activitypub"
"git.mstar.dev/mstar/linstrom/shared"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models"
webshared "git.mstar.dev/mstar/linstrom/web/shared"
)
var objectIdRegex = regexp.MustCompile(
@ -316,8 +319,54 @@ func handleFollow(w http.ResponseWriter, r *http.Request, object map[string]any)
if err != nil {
log.Error().Err(err).Msg("Failed to commit follow activity transaction")
}
if !followed.RestrictedFollow {
// FIXME: Handle errors
tx = dbgen.Q.Begin()
_, err = u2u.Where(u2u.ID.Eq(req.ID)).UpdateColumn(u2u.Relation, models.RelationFollow)
acceptActivity := models.Activity{
Id: shared.NewId(),
Type: string(models.ActivityAccept),
ObjectId: activity.Id,
ObjectType: uint32(models.ActivitystreamsActivityTargetActivity),
}
err = tx.Activity.Create(&acceptActivity)
tx.Commit()
go func() {
// FIXME: Clean this entire mess up
time.Sleep(time.Millisecond * 20)
webAccept, err := AcceptFromStorage(r.Context(), acceptActivity.Id)
if err != nil {
log.Error().Err(err).Msg("Failed to get accept from db")
return
}
webAccept.Context = activitypub.BaseLdContext
body, err := json.Marshal(webAccept)
if err != nil {
log.Error().Err(err).Msg("Failed to marshal accept")
return
}
res, err := webshared.RequestSignedCavage(
"POST",
follower.RemoteInfo.InboxLink,
body,
followed,
)
if err != nil {
log.Error().Err(err).Msg("Failed to send accept")
return
}
if res.StatusCode >= 400 {
body, _ = io.ReadAll(res.Body)
log.Error().
Int("status-code", res.StatusCode).
Bytes("body", body).
Msg("Post of accept failed")
}
}()
}
}
// WARN: Untested as can't send follow activities yet
func handleAccept(w http.ResponseWriter, r *http.Request, object map[string]any) {
log := hlog.FromRequest(r)
rawTarget, ok := object["object"]
@ -404,9 +453,124 @@ func handleAccept(w http.ResponseWriter, r *http.Request, object map[string]any)
}
relationId := other.Must(strconv.ParseUint(followActivity.ObjectId, 10, 64))
dbrel := dbgen.UserToUserRelation
_, err = dbrel.Where(dbrel.ID.Eq(relationId)).
tx := dbgen.Q.Begin()
_, err = tx.UserToUserRelation.Where(dbrel.ID.Eq(relationId)).
UpdateColumn(dbrel.Relation, models.RelationFollow)
switch err {
case gorm.ErrRecordNotFound:
// No need to rollback, nothing was done
webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound)
return
case nil:
default:
_ = tx.Rollback()
log.Error().
Err(err).
Str("target-id", internalId).
Msg("Failed to update follow status to confirmed follow")
webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
activity := models.Activity{
Id: object["id"].(string),
Type: string(models.ActivityAccept),
ObjectType: uint32(models.ActivitystreamsActivityTargetActivity),
ObjectId: followActivity.Id,
}
err = tx.Activity.Create(&activity)
if err != nil {
err = tx.Rollback()
log.Error().
Err(err).
Str("target-id", internalId).
Msg("Failed to store accept activity in db")
webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
err = tx.Commit()
if err != nil {
log.Error().
Err(err).
Str("target-id", internalId).
Msg("Failed to commit accept transaction")
webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
}
// WARN: Untested as can't send follow activities yet
func handleReject(w http.ResponseWriter, r *http.Request, object map[string]any) {
log := hlog.FromRequest(r)
rawTarget, ok := object["object"]
if !ok {
webutils.ProblemDetails(
w,
http.StatusBadRequest,
"/errors/bad-request-data",
"Bad activity data",
other.IntoPointer(`Request data needs to contain a field "object"`),
nil,
)
return
}
// FIXME: Also handle other undo cases, such as follows
var targetObjectId string
// I *think* the spec says that this must be an object. Not sure though
switch target := rawTarget.(type) {
case string:
targetObjectId = target
case map[string]any:
objType, ok := target["type"].(string)
// TODO: Ensure accept is only used for follows
if !ok || objType != "Follow" {
webutils.ProblemDetails(
w,
http.StatusBadRequest,
"/errors/bad-request-data",
"Bad activity data",
other.IntoPointer(`Target object type must be a string with value "Follow"`),
nil,
)
return
}
targetObjectId, ok = target["id"].(string)
if !ok {
webutils.ProblemDetails(
w,
http.StatusBadRequest,
"/errors/bad-request-data",
"Bad activity data",
other.IntoPointer(`Missing id in undone object`),
nil,
)
return
}
default:
webutils.ProblemDetails(
w,
http.StatusBadRequest,
"/errors/bad-request-data",
"Bad activity data",
other.IntoPointer(`Request data needs to contain a field "object" of type string or object`),
nil,
)
return
}
internalIdMatch := objectIdRegex.FindStringSubmatch(targetObjectId)
if len(internalIdMatch) != 2 {
webutils.ProblemDetails(
w,
http.StatusBadRequest,
"/errors/bad-request-data",
"Bad activity data",
other.IntoPointer(`Request data target object is not internal id`),
nil,
)
return
}
internalId := internalIdMatch[1]
followActivity, err := dbgen.Activity.Where(dbgen.Activity.Id.Eq(internalId)).First()
switch err {
case gorm.ErrRecordNotFound:
webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound)
return
@ -419,6 +583,59 @@ func handleAccept(w http.ResponseWriter, r *http.Request, object map[string]any)
webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
relationId := other.Must(strconv.ParseUint(followActivity.ObjectId, 10, 64))
dbrel := dbgen.UserToUserRelation
tx := dbgen.Q.Begin()
_, err = tx.UserToUserRelation.Where(dbrel.ID.Eq(relationId)).Delete()
switch err {
case gorm.ErrRecordNotFound:
// No need to rollback, nothing was done
webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound)
return
case nil:
default:
_ = tx.Rollback()
log.Error().
Err(err).
Str("target-id", internalId).
Msg("Failed to delete follow status")
webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
_, err = tx.Activity.Where(
dbgen.Activity.ObjectId.Eq(followActivity.Id),
dbgen.Activity.Type.Eq("Accept"),
).
Delete()
if err != nil {
_ = tx.Rollback()
log.Error().Err(err).Msg("Failed to delete accept for later rejected follow")
webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
activity := models.Activity{
Id: object["id"].(string),
Type: string(models.ActivityAccept),
ObjectType: uint32(models.ActivitystreamsActivityTargetActivity),
ObjectId: followActivity.Id,
}
err = tx.Activity.Create(&activity)
if err != nil {
err = tx.Rollback()
log.Error().
Err(err).
Str("target-id", internalId).
Msg("Failed to store accept activity in db")
webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
err = tx.Commit()
if err != nil {
log.Error().
Err(err).
Str("target-id", internalId).
Msg("Failed to commit accept transaction")
webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError)
return
}
}
func handleReject(w http.ResponseWriter, r *http.Request, object map[string]any) {}

View file

@ -17,7 +17,7 @@ import (
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
)
type objectNoteOut struct {
type ObjectNoteOut struct {
// Context should be set, if needed, by the endpoint handler
Context any `json:"@context,omitempty"`
@ -48,7 +48,7 @@ type objectNoteOut struct {
func objectNote(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
log := hlog.FromRequest(r)
note, err := noteFromStorage(r.Context(), id)
note, err := NoteFromStorage(r.Context(), id)
switch err {
case gorm.ErrRecordNotFound:
webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound)
@ -73,12 +73,12 @@ func objectNote(w http.ResponseWriter, r *http.Request) {
}
}
func noteFromStorage(ctx context.Context, id string) (*objectNoteOut, error) {
func NoteFromStorage(ctx context.Context, id string) (*ObjectNoteOut, error) {
note, err := dbgen.Note.Where(dbgen.Note.ID.Eq(id)).Preload(dbgen.Note.Creator).First()
if err != nil {
return nil, err
}
data := &objectNoteOut{
data := &ObjectNoteOut{
Id: config.GlobalConfig.General.GetFullPublicUrl() + "/api/activitypub/note/" + id,
Type: "Note",
Published: note.CreatedAt,