package activitypub import ( "database/sql" "encoding/json" "fmt" "io" "net/http" "regexp" "strconv" 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) 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 handleUndo(w http.ResponseWriter, r *http.Request, object map[string]any) { log := hlog.FromRequest(r) _ = object["id"].(string) _, 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 } 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 } // TODO: I *think* undo is only actively used for likes, but could also be used for // undoing create's and thus removing notes var likeActivityId string // I *think* the spec says that this must be an object. Not sure though switch target := rawTarget.(type) { case string: likeActivityId = target case map[string]any: objType, ok := target["type"].(string) if !ok || objType != "Like" { webutils.ProblemDetails( w, http.StatusBadRequest, "/errors/bad-request-data", "Bad activity data", other.IntoPointer(`Undone object is not of type like. If you receive this error for a valid undo for a different activity type,please contact the Linstrom developers with the activity type`), nil, ) return } likeActivityId, 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 } // TODO: For above todo, get target resource via likeActivityId and remove approprietly // Don't just assume it was a like being undone act, err := dbgen.Activity.Where(dbgen.Activity.Id.Eq(likeActivityId), dbgen.Activity.Type.Eq("like")). First() switch err { case gorm.ErrRecordNotFound: return case nil: default: log.Error(). Err(err). Str("activity-id", likeActivityId). Msg("Error while looking for find activity") webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } reactionId := uint(other.Must(strconv.ParseUint(act.ObjectId, 10, 64))) reaction, err := dbgen.Reaction.Where(dbgen.Reaction.ID.Eq(reactionId)).First() switch err { case gorm.ErrRecordNotFound: return case nil: default: log.Error(). Err(err). Str("activity-id", likeActivityId). Msg("Error while looking for find activity") webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } tx := dbgen.Q.Begin() _, err = tx.Activity.Where(dbgen.Activity.Id.Eq(act.Id)).Delete() if err != nil { _ = tx.Rollback() log.Error().Err(err).Str("activity-id", act.Id).Msg("Failed to delete activity on undo") webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } _, err = tx.Reaction.Where(dbgen.Reaction.ID.Eq(reaction.ID)).Delete() if err != nil { _ = tx.Rollback() log.Error(). Err(err). Uint("reaction-id", reaction.ID). Msg("Failed to delete reaction on undo") webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) return } err = tx.Commit() if err != nil { log.Error().Err(err).Msg("Failed to delete reaction and activity") webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) } }