linstrom/web/public/api/activitypub/inbox.go
mstar 82627e8467
Some checks failed
/ docker (push) Failing after 2m58s
Add undo of likes
2025-05-07 17:43:52 +02:00

321 lines
8.6 KiB
Go

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)
}
}