linstrom/web/public/api/activitypub/inbox.go
mstar b0f041e7b0
Some checks are pending
/ docker (push) Waiting to run
Add-ish support for tags and mentions in new messages
2025-06-05 17:07:04 +02:00

812 lines
23 KiB
Go

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")
// TODO: Decide how to handle the handler failing for whatever reason
// Add object to unhandled message table and try again later?
// Discard it? And how would a handler return that it failed?
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, storing for later processing")
err = dbgen.UnhandledMessage.Create(&models.UnhandledMessage{
ForUserId: userId,
GlobalInbox: false,
RawData: body,
})
if err != nil {
log.Error().Err(err).Msg("Failed to store unhandled message for later")
}
_ = 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.RequestSigned(
"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
}
}