641 lines
17 KiB
Go
641 lines
17 KiB
Go
package activitypub
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"time"
|
|
|
|
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/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.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)
|
|
case "Accept":
|
|
handleAccept(w, r, data)
|
|
case "Reject":
|
|
handleReject(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")
|
|
}
|
|
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"]
|
|
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
|
|
}
|
|
}
|