package activitypub import ( "database/sql" "encoding/json" "fmt" "io" "net/http" "regexp" webutils "git.mstar.dev/mstar/goutils/http" "git.mstar.dev/mstar/goutils/other" "github.com/rs/zerolog/hlog" "gorm.io/gorm" "git.mstar.dev/mstar/linstrom/activitypub" "git.mstar.dev/mstar/linstrom/storage-new/dbgen" "git.mstar.dev/mstar/linstrom/storage-new/models" ) 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.Info(). 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 } switch objectType { case "Like": handleLike(w, r, data) case "Undo": handleUndo(w, r, data) case "Follow": handleFollow(w, r, data) default: 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) 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") } }