package activitypub import ( "database/sql" "encoding/json" "fmt" "io" "net/http" "regexp" "strconv" "strings" "time" webutils "git.mstar.dev/mstar/goutils/http" "git.mstar.dev/mstar/goutils/other" "github.com/mitchellh/mapstructure" "github.com/rs/zerolog/hlog" "gorm.io/gorm" "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/dbgen" "git.mstar.dev/mstar/linstrom/storage-new/models" webshared "git.mstar.dev/mstar/linstrom/web/shared" ) var objectIdRegex = regexp.MustCompile( `https?://.+/api/activitypub/[a-z]+(?:/[a-z]+)?/([a-zA-Z0-9\-]+)`, ) func userInbox(w http.ResponseWriter, r *http.Request) { log := hlog.FromRequest(r) userId := r.PathValue("id") body, err := io.ReadAll(r.Body) log.Trace(). Err(err). Str("userId", userId). Bytes("body", body). Any("headers", r.Header). Msg("Inbox message") data := map[string]any{} err = json.Unmarshal(body, &data) if err != nil { _ = webutils.ProblemDetails( w, http.StatusBadRequest, "/errors/bad-request-data", "Bad activity data", other.IntoPointer("Body to inbox needs to be json"), nil, ) return } if _, ok := data["@context"]; !ok { _ = webutils.ProblemDetails( w, http.StatusBadRequest, "/errors/bad-request-data", "Bad activity data", other.IntoPointer("Request data needs to contain context"), nil, ) return } objectType, ok := data["type"].(string) if !ok { _ = webutils.ProblemDetails( w, http.StatusBadRequest, "/errors/bad-request-data", "Bad activity data", other.IntoPointer(`Request data needs to contain a field "type" with a string value`), nil, ) return } _, ok = data["id"].(string) if !ok { _ = webutils.ProblemDetails( w, http.StatusBadRequest, "/errors/bad-request-data", "Bad activity data", other.IntoPointer(`Request data needs to contain a field "id" with a string value`), nil, ) return } log.Debug().Str("object-type", objectType).Msg("Inbox message") log.Trace().Bytes("body", body).Msg("Inbox raw message") switch objectType { case "Like": handleLike(w, r, data) case "Undo": handleUndo(w, r, data) case "Follow": handleFollow(w, r, data) case "Accept": handleAccept(w, r, data) case "Reject": handleReject(w, r, data) case "Create": handleCreate(w, r, data) default: log.Warn().Str("object-type", objectType).Msg("Unknown message type") _ = webutils.ProblemDetailsStatusOnly(w, 500) } } func handleLike(w http.ResponseWriter, r *http.Request, object map[string]any) { log := hlog.FromRequest(r) activityId := object["id"].(string) likerUrl, ok := object["actor"].(string) if !ok { _ = webutils.ProblemDetails( w, http.StatusBadRequest, "/errors/bad-request-data", "Bad activity data", other.IntoPointer(`Request data needs to contain a field "actor" with a string value`), nil, ) return } // TODO: Account for case where object is embedded in like targetUrl, ok := object["object"].(string) if !ok { _ = webutils.ProblemDetails( w, http.StatusBadRequest, "/errors/bad-request-data", "Bad activity data", other.IntoPointer(`Request data needs to contain a field "object" with a string value`), nil, ) return } targetIdMatches := objectIdRegex.FindStringSubmatch(targetUrl) if len(targetIdMatches) != 2 { log.Error(). Strs("match-results", targetIdMatches). Str("url", targetUrl). Msg("Url didn't match id extractor regex") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } targetId := targetIdMatches[1] // Assume likes can only happen on notes for now // Thus check if a note with that Id exists at all note, err := dbgen.Note.Where(dbgen.Note.ID.Eq(targetId)).First() switch err { case nil: case gorm.ErrRecordNotFound: _ = webutils.ProblemDetails( w, http.StatusBadRequest, "/errors/bad-request-data", "Bad activity data", other.IntoPointer("There is no note with the target id"), nil, ) return default: log.Error().Err(err).Str("note-id", targetId).Msg("Failed to get note from db") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } // Import liker after verifying that target note is correct liker, err := activitypub.ImportRemoteAccountByAPUrl(likerUrl) if err != nil { log.Error(). Err(err). Str("liker-url", likerUrl). Msg("Failed to import liking remote account") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } reaction := models.Reaction{ Note: *note, NoteId: note.ID, Reactor: *liker, ReactorId: liker.ID, Emote: nil, EmoteId: sql.NullInt64{Valid: false}, } tx := dbgen.Q.Begin() err = tx.Reaction.Create(&reaction) if err != nil { _ = tx.Rollback() log.Error().Err(err).Any("raw-reaction", reaction).Msg("Failed to store reaction in db") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) } // TODO: Create corresponding activity too activity := models.Activity{ Id: activityId, Type: string(models.ActivityLike), ObjectId: fmt.Sprint(reaction.ID), ObjectType: uint32(models.ActivitystreamsActivityTargetReaction), } err = tx.Activity.Create(&activity) if err != nil { _ = tx.Rollback() log.Error().Err(err).Any("raw-reaction", reaction).Msg("Failed to store reaction in db") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) } err = tx.Commit() if err != nil { log.Error().Err(err).Msg("Failed to commit reaction transaction to db") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) } } func handleFollow(w http.ResponseWriter, r *http.Request, object map[string]any) { log := hlog.FromRequest(r) log.Debug().Msg("Received follow request") objectId, ok := object["id"].(string) if !ok { _ = webutils.ProblemDetails( w, http.StatusBadRequest, "/errors/bad-request-data", "Bad activity data", other.IntoPointer(`Request data needs to contain a field "id" with a string value`), nil, ) return } actorApId, ok := object["actor"].(string) if !ok { _ = webutils.ProblemDetails( w, http.StatusBadRequest, "/errors/bad-request-data", "Bad activity data", other.IntoPointer(`Request data needs to contain a field "actor" with a string value`), nil, ) return } targetUrl, ok := object["object"].(string) if !ok { _ = 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`), nil, ) return } followedMatch := objectIdRegex.FindStringSubmatch(targetUrl) if len(followedMatch) != 2 { _ = webutils.ProblemDetails( w, http.StatusBadRequest, "/errors/bad-request-data", "Bad activity data", other.IntoPointer(`Object must be a link to a Linstrom AP user`), nil, ) return } followedId := followedMatch[1] followed, err := dbgen.User.Where(dbgen.User.ID.Eq(followedId)).First() switch err { case gorm.ErrRecordNotFound: _ = webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound) return case nil: default: log.Error().Err(err).Str("target-id", followedId).Msg("Failed to get account from db") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } follower, err := activitypub.ImportRemoteAccountByAPUrl(actorApId) if err != nil { log.Error().Err(err).Msg("Failed to import following account") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } u2u := dbgen.UserToUserRelation followRelations, err := u2u.Where( u2u.UserId.Eq(follower.ID), u2u.TargetUserId.Eq(followed.ID), u2u.Or( u2u.Relation.Eq(string(models.RelationFollow)), u2u.Relation.Eq(string(models.RelationFollowRequest)), ), ).Count() if err != nil { log.Error(). Err(err). Str("follower", follower.ID). Str("followed", followedId). Msg("Failed to count follow relations") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } if followRelations > 0 { return } tx := dbgen.Q.Begin() req := models.UserToUserRelation{ Relation: string(models.RelationFollowRequest), TargetUserId: followed.ID, UserId: follower.ID, } err = tx.UserToUserRelation.Create(&req) if err != nil { _ = tx.Rollback() log.Error().Err(err).Any("follow-request", req).Msg("Failed to store follow request") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } activity := models.Activity{ Id: objectId, Type: string(models.ActivityFollow), ObjectId: fmt.Sprint(req.ID), ObjectType: uint32(models.ActivitystreamsActivityTargetFollow), } err = tx.Activity.Create(&activity) if err != nil { _ = tx.Rollback() log.Error().Err(err).Any("activity", activity).Msg("Failed to store follow activity") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } err = tx.Commit() if err != nil { log.Error().Err(err).Msg("Failed to commit follow activity transaction") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } if !followed.RestrictedFollow { tx = dbgen.Q.Begin() _, err = u2u.Where(u2u.ID.Eq(req.ID)).UpdateColumn(u2u.Relation, models.RelationFollow) if err != nil { _ = tx.Rollback() log.Error().Err(err).Msg("Failed to update follow to confirmed") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } acceptActivity := models.Activity{ Id: shared.NewId(), Type: string(models.ActivityAccept), ObjectId: activity.Id, ObjectType: uint32(models.ActivitystreamsActivityTargetActivity), } err = tx.Activity.Create(&acceptActivity) if err != nil { _ = tx.Rollback() log.Error().Err(err).Msg("Failed to store accept activity in db") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } err = tx.Commit() if err != nil { log.Error().Err(err).Msg("Failed to commit follow accept to db") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } go func() { // TODO: Maybe move this part to a separate function 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"] 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 case nil: default: log.Error(). Err(err). Str("target-id", internalId). Msg("Failed to get target follow activity from db") _ = 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)). 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 case nil: default: log.Error(). Err(err). Str("target-id", internalId). Msg("Failed to get target follow activity from db") _ = 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 handleCreate(w http.ResponseWriter, r *http.Request, object map[string]any) { log := hlog.FromRequest(r) activity := ActivityCreate{} err := mapstructure.Decode(object, &activity) if err != nil { log.Error(). Err(err). Any("raw", object). Msg("Failed to marshal create activity to proper type") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } actingUser, err := activitypub.ImportRemoteAccountByAPUrl(activity.Actor) if err != nil { log.Error(). Err(err). Str("actor", activity.Actor). Msg("Failed to import remote actor for note") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } switch val := activity.Object.(type) { case string: actor, err := dbgen.User.GetById(r.PathValue("id")) if err != nil { log.Error().Err(err).Msg("Failed to get local actor for importing targeted note") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } _, err = activitypub.ImportRemoteNote(val, actor) if err != nil { log.Error().Err(err).Str("note-url", val).Msg("Failed to import remote note that landed as id in the inbox") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) } return case map[string]any: default: _ = webutils.ProblemDetails( w, http.StatusBadRequest, "/errors/bad-request-data", "bad request data", other.IntoPointer("Bad object data for create activity. Must be a struct or string"), nil, ) return } obj := activity.Object.(map[string]any) // Dumb hack since published timestamp is still a string at this point tmpTime, err := time.Parse(time.RFC3339, obj["published"].(string)) if err != nil { delete(obj, "published") } else { obj["published"] = tmpTime } objectNote := ObjectNote{} err = mapstructure.Decode(obj, &objectNote) if err != nil { log.Error(). Err(err). Any("raw", activity.Object). Msg("Failed to unmarshal create object into note") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } if objectNote.Type != "Note" { _ = webutils.ProblemDetails( w, http.StatusBadRequest, "/errors/bad-request-data", "bad request data", other.IntoPointer("Bad object data for create activity. object.type must be 'Note'"), nil, ) return } dbNote := models.Note{ ID: objectNote.Id, CreatedAt: objectNote.Published, Creator: actingUser, CreatorId: actingUser.ID, Remote: true, RawContent: objectNote.Content, OriginId: actingUser.ServerId, } if objectNote.Summary != nil { dbNote.ContentWarning = sql.NullString{Valid: true, String: *objectNote.Summary} } if objectNote.InReplyTo != nil { replyUrl := *objectNote.InReplyTo if strings.HasPrefix(replyUrl, config.GlobalConfig.General.GetFullPublicUrl()) { // This is going to panic if the regex doesn't match. Who cares? Not me id := objectIdRegex.FindStringSubmatch(replyUrl)[1] dbNote.RepliesTo = sql.NullString{Valid: true, String: id} } else { dbNote.RepliesTo = sql.NullString{Valid: true, String: replyUrl} } } tx := dbgen.Q.Begin() err = tx.Note.Create(&dbNote) if err != nil { _ = tx.Rollback() log.Error().Err(err).Any("note", dbNote).Msg("Failed to create note in db") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } createActivity := models.Activity{ Type: string(models.ActivityCreate), Id: object["id"].(string), ObjectId: dbNote.ID, ObjectType: uint32(models.ActivitystreamsActivityTargetNote), } err = tx.Activity.Create(&createActivity) if err != nil { _ = tx.Rollback() log.Error().Err(err).Any("note", dbNote).Msg("Failed to create note create activity in db") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } err = tx.Commit() if err != nil { log.Error().Err(err).Any("note", dbNote).Msg("Failed to submit note creation in db") _ = webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } }