diff --git a/storage-new/dbgen/user_to_user_relations.gen.go b/storage-new/dbgen/user_to_user_relations.gen.go index e600a27..dd9b0ca 100644 --- a/storage-new/dbgen/user_to_user_relations.gen.go +++ b/storage-new/dbgen/user_to_user_relations.gen.go @@ -589,9 +589,15 @@ type IUserToUserRelationDo interface { UnderlyingDB() *gorm.DB schema.Tabler - GetFollowersForId(id string) (result []string, err error) + GetFollowerInboxesForId(id string) (result []string, err error) + GetFollowerApLinksPagedForId(id string, page int) (result []string, err error) + GetFollowingApLinksPagedForId(id string, page int) (result []string, err error) + CountFollowersForId(id string) (result int, err error) + CountFollowingForId(id string) (result int, err error) } +// Get all inbox links for accounts following the user with the specified id +// // SELECT u.inbox_link // FROM user_to_user_relations r // LEFT JOIN user_remote_links u @@ -600,7 +606,7 @@ type IUserToUserRelationDo interface { // // r.target_user_id = @id AND // r.relation = 'follow' -func (u userToUserRelationDo) GetFollowersForId(id string) (result []string, err error) { +func (u userToUserRelationDo) GetFollowerInboxesForId(id string) (result []string, err error) { var params []interface{} var generateSQL strings.Builder @@ -614,6 +620,106 @@ func (u userToUserRelationDo) GetFollowersForId(id string) (result []string, err return } +// Get all Ids of the accounts following the user with the specified id +// +// SELECT u.ap_link +// FROM user_to_user_relations r +// LEFT JOIN user_remote_links u +// ON r.user_id = u.user_id +// WHERE +// +// r.target_user_id = @id AND +// r.relation = 'follow' +// +// LIMIT 50 +// OFFSET @page * 50 +func (u userToUserRelationDo) GetFollowerApLinksPagedForId(id string, page int) (result []string, err error) { + var params []interface{} + + var generateSQL strings.Builder + params = append(params, id) + params = append(params, page) + generateSQL.WriteString("SELECT u.ap_link FROM user_to_user_relations r LEFT JOIN user_remote_links u ON r.user_id = u.user_id WHERE r.target_user_id = ? AND r.relation = 'follow' LIMIT 50 OFFSET ? * 50 ") + + var executeSQL *gorm.DB + executeSQL = u.UnderlyingDB().Raw(generateSQL.String(), params...).Find(&result) // ignore_security_alert + err = executeSQL.Error + + return +} + +// Get all Ids of the accounts followed by the user with the specified id +// +// SELECT u.ap_link +// FROM user_to_user_relations r +// LEFT JOIN user_remote_links u +// ON r.user_id = u.user_id +// WHERE +// +// r.user_id = @id AND +// r.relation = 'follow' +// +// LIMIT 50 +// OFFSET @page * 50 +func (u userToUserRelationDo) GetFollowingApLinksPagedForId(id string, page int) (result []string, err error) { + var params []interface{} + + var generateSQL strings.Builder + params = append(params, id) + params = append(params, page) + generateSQL.WriteString("SELECT u.ap_link FROM user_to_user_relations r LEFT JOIN user_remote_links u ON r.user_id = u.user_id WHERE r.user_id = ? AND r.relation = 'follow' LIMIT 50 OFFSET ? * 50 ") + + var executeSQL *gorm.DB + executeSQL = u.UnderlyingDB().Raw(generateSQL.String(), params...).Find(&result) // ignore_security_alert + err = executeSQL.Error + + return +} + +// Count the accounts following the user with the specified id +// +// SELECT COUNT(*) +// FROM user_to_user_relations r +// WHERE +// +// r.target_user_id = @id AND +// r.relation = 'follow' +func (u userToUserRelationDo) CountFollowersForId(id string) (result int, err error) { + var params []interface{} + + var generateSQL strings.Builder + params = append(params, id) + generateSQL.WriteString("SELECT COUNT(*) FROM user_to_user_relations r WHERE r.target_user_id = ? AND r.relation = 'follow' ") + + var executeSQL *gorm.DB + executeSQL = u.UnderlyingDB().Raw(generateSQL.String(), params...).Take(&result) // ignore_security_alert + err = executeSQL.Error + + return +} + +// Count the accounts following the user with the specified id +// +// SELECT COUNT(*) +// FROM user_to_user_relations r +// WHERE +// +// r.user_id = @id AND +// r.relation = 'follow' +func (u userToUserRelationDo) CountFollowingForId(id string) (result int, err error) { + var params []interface{} + + var generateSQL strings.Builder + params = append(params, id) + generateSQL.WriteString("SELECT COUNT(*) FROM user_to_user_relations r WHERE r.user_id = ? AND r.relation = 'follow' ") + + var executeSQL *gorm.DB + executeSQL = u.UnderlyingDB().Raw(generateSQL.String(), params...).Take(&result) // ignore_security_alert + err = executeSQL.Error + + return +} + func (u userToUserRelationDo) Debug() IUserToUserRelationDo { return u.withDO(u.DO.Debug()) } diff --git a/storage-new/dbgen/users.gen.go b/storage-new/dbgen/users.gen.go index 3582e31..e1c0656 100644 --- a/storage-new/dbgen/users.gen.go +++ b/storage-new/dbgen/users.gen.go @@ -1660,11 +1660,13 @@ type IUserDo interface { GetByUsernameUnrestricted(username string) (result *models.User, err error) GetByUsername(username string) (result *models.User, err error) + GetById(id string) (result *models.User, err error) GetPagedTruePublic(pageNr uint) (result []models.User, err error) GetPagedAllDeleted(pageNr uint) (result []models.User, err error) GetPagedAllNonDeleted(pageNr uint) (result []models.User, err error) GdprUsers() (err error) GetRemoteAccountByApUrl(url string) (result *models.User, err error) + DoesUserWithIdExist(id string) (result bool, err error) } // Get a user by a username, ignoring all restrictions on that user @@ -1709,6 +1711,31 @@ func (u userDo) GetByUsername(username string) (result *models.User, err error) return } +// Get a user by the id. +// Restricted to users visible to ActivityPub +// +// SELECT * FROM @@table WHERE +// +// id = @id AND +// deleted_at IS NULL AND +// finished_registration = true AND +// verified = true +// +// LIMIT 1 +func (u userDo) GetById(id string) (result *models.User, err error) { + var params []interface{} + + var generateSQL strings.Builder + params = append(params, id) + generateSQL.WriteString("SELECT * FROM users WHERE id = ? AND deleted_at IS NULL AND finished_registration = true AND verified = true LIMIT 1 ") + + var executeSQL *gorm.DB + executeSQL = u.UnderlyingDB().Raw(generateSQL.String(), params...).Take(&result) // ignore_security_alert + err = executeSQL.Error + + return +} + // Get all true public accounts (verified & no restricted follow & indexable) // in a paged manner, sorted by date saved // @@ -1820,6 +1847,31 @@ func (u userDo) GetRemoteAccountByApUrl(url string) (result *models.User, err er return } +// Does a user with the given Id exist? +// The user must be visible from AP +// +// SELECT EXISTS( +// +// SELECT * FROM @@table WHERE +// id = @id AND +// deleted_at IS NULL AND +// verified = true +// +// ) +func (u userDo) DoesUserWithIdExist(id string) (result bool, err error) { + var params []interface{} + + var generateSQL strings.Builder + params = append(params, id) + generateSQL.WriteString("SELECT EXISTS( SELECT * FROM users WHERE id = ? AND deleted_at IS NULL AND verified = true ) ") + + var executeSQL *gorm.DB + executeSQL = u.UnderlyingDB().Raw(generateSQL.String(), params...).Take(&result) // ignore_security_alert + err = executeSQL.Error + + return +} + func (u userDo) Debug() IUserDo { return u.withDO(u.DO.Debug()) } diff --git a/storage-new/models/Collection.go b/storage-new/models/Collection.go index d17b570..e704563 100644 --- a/storage-new/models/Collection.go +++ b/storage-new/models/Collection.go @@ -3,5 +3,5 @@ package models type Collection struct { Id string `gorm:"primarykey"` TargetId string - TargetType string `orm:"type:collection_target_type"` + TargetType string // `orm:"type:collection_target_type"` } diff --git a/storage-new/models/CollectionTargetType.go b/storage-new/models/CollectionTargetType.go index 28940bd..c6f6f5c 100644 --- a/storage-new/models/CollectionTargetType.go +++ b/storage-new/models/CollectionTargetType.go @@ -9,6 +9,8 @@ const ( CollectionTargetPinnedNotes = CollectionTargetType("pinned") CollectionTargetReactions = CollectionTargetType("reactions") CollectionTargetBoostsAndQuotes = CollectionTargetType("boosts") + COllectionTargetFollows = CollectionTargetType("follows") + COllectionTargetFollowers = CollectionTargetType("followers") ) var AllCollectionTargetTypes = []CollectionTargetType{ diff --git a/storage-new/models/User.go b/storage-new/models/User.go index 28feb53..61fce9a 100644 --- a/storage-new/models/User.go +++ b/storage-new/models/User.go @@ -103,6 +103,17 @@ type IUser interface { // LIMIT 1 GetByUsername(username string) (*gen.T, error) + // Get a user by the id. + // Restricted to users visible to ActivityPub + // + // SELECT * FROM @@table WHERE + // id = @id AND + // deleted_at IS NULL AND + // finished_registration = true AND + // verified = true + // LIMIT 1 + GetById(id string) (*gen.T, error) + // Get all true public accounts (verified & no restricted follow & indexable) // in a paged manner, sorted by date saved // @@ -148,4 +159,15 @@ type IUser interface { // ) // LIMIT 1 GetRemoteAccountByApUrl(url string) (*gen.T, error) + + // Does a user with the given Id exist? + // The user must be visible from AP + // + // SELECT EXISTS( + // SELECT * FROM @@table WHERE + // id = @id AND + // deleted_at IS NULL AND + // verified = true + // ) + DoesUserWithIdExist(id string) (bool, error) } diff --git a/storage-new/models/UserToUserRelation.go b/storage-new/models/UserToUserRelation.go index 95eabd2..a458a1d 100644 --- a/storage-new/models/UserToUserRelation.go +++ b/storage-new/models/UserToUserRelation.go @@ -13,6 +13,8 @@ type UserToUserRelation struct { } type IUserToUserRelation interface { + // Get all inbox links for accounts following the user with the specified id + // // SELECT u.inbox_link // FROM user_to_user_relations r // LEFT JOIN user_remote_links u @@ -20,5 +22,49 @@ type IUserToUserRelation interface { // WHERE // r.target_user_id = @id AND // r.relation = 'follow' - GetFollowersForId(id string) ([]string, error) + GetFollowerInboxesForId(id string) ([]string, error) + + // Get all Ids of the accounts following the user with the specified id + // + // SELECT u.ap_link + // FROM user_to_user_relations r + // LEFT JOIN user_remote_links u + // ON r.user_id = u.user_id + // WHERE + // r.target_user_id = @id AND + // r.relation = 'follow' + // LIMIT 50 + // OFFSET @page * 50 + GetFollowerApLinksPagedForId(id string, page int) ([]string, error) + + // Get all Ids of the accounts followed by the user with the specified id + // + // SELECT u.ap_link + // FROM user_to_user_relations r + // LEFT JOIN user_remote_links u + // ON r.user_id = u.user_id + // WHERE + // r.user_id = @id AND + // r.relation = 'follow' + // LIMIT 50 + // OFFSET @page * 50 + GetFollowingApLinksPagedForId(id string, page int) ([]string, error) + + // Count the accounts following the user with the specified id + // + // SELECT COUNT(*) + // FROM user_to_user_relations r + // WHERE + // r.target_user_id = @id AND + // r.relation = 'follow' + CountFollowersForId(id string) (int, error) + + // Count the accounts following the user with the specified id + // + // SELECT COUNT(*) + // FROM user_to_user_relations r + // WHERE + // r.user_id = @id AND + // r.relation = 'follow' + CountFollowingForId(id string) (int, error) } diff --git a/web/debug/posts.go b/web/debug/posts.go index 9aed01b..ce999f4 100644 --- a/web/debug/posts.go +++ b/web/debug/posts.go @@ -109,7 +109,7 @@ func postAs(w http.ResponseWriter, r *http.Request) { return } u2u := dbgen.UserToUserRelation - links, err := u2u.GetFollowersForId(user.ID) + links, err := u2u.GetFollowerInboxesForId(user.ID) if err != nil { log.Error().Err(err).Msg("Failed to get follower inbox links for user") webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) diff --git a/web/public/api/activitypub/activitypub.go b/web/public/api/activitypub/activitypub.go index abd878e..7d3e7d3 100644 --- a/web/public/api/activitypub/activitypub.go +++ b/web/public/api/activitypub/activitypub.go @@ -8,6 +8,8 @@ func BuildActivitypubRouter() http.Handler { router := http.NewServeMux() router.HandleFunc("/user/{id}", users) router.HandleFunc("/user/{id}/inbox", userInbox) + router.HandleFunc("/user/{id}/followers", userFollowers) + router.HandleFunc("/user/{id}/following", userFollowing) router.HandleFunc("/activity/accept/{id}", activityAccept) router.HandleFunc("/activity/create/{id}", activityCreate) router.HandleFunc("/activity/delete/{id}", activityDelete) diff --git a/web/public/api/activitypub/collection.go b/web/public/api/activitypub/collection.go index f996e23..bbc463f 100644 --- a/web/public/api/activitypub/collection.go +++ b/web/public/api/activitypub/collection.go @@ -4,23 +4,24 @@ import "net/http" // Used for both unordered and ordered type collectionOut struct { - Context any - Summary string - Type string - Items []any - Id string - TotalItems int - First *collectionPageOut + Context any `json:"@context,omitempty"` + Summary string `json:"summary,omitempty"` + Type string `json:"type"` + Items []any `json:"items,omitempty"` + Id string `json:"id"` + TotalItems int `json:"totalItems"` + First string `json:"first"` } // Used for both unordered and ordered type collectionPageOut struct { - Context any - Type string - Id string - PartOf string - Next string - Items []any + Context any `json:"@context,omitempty"` + Type string `json:"type"` + Id string `json:"id"` + PartOf string `json:"partOf"` + Next string `json:"next,omitempty"` + Previous string `json:"prev,omitempty"` + Items []any `json:"items"` } // Unordered collections handler diff --git a/web/public/api/activitypub/inbox.go b/web/public/api/activitypub/inbox.go index aa6519a..d28b792 100644 --- a/web/public/api/activitypub/inbox.go +++ b/web/public/api/activitypub/inbox.go @@ -368,10 +368,6 @@ func handleFollow(w http.ResponseWriter, r *http.Request, object map[string]any) log.Error().Err(err).Msg("Failed to marshal accept") return } - log.Debug(). - Bytes("body", body). - Str("target", follower.RemoteInfo.InboxLink). - Msg("Sending follow accept out") res, err := webshared.RequestSignedCavage( "POST", follower.RemoteInfo.InboxLink, diff --git a/web/public/api/activitypub/user.go b/web/public/api/activitypub/user.go index a683d61..2fbdcec 100644 --- a/web/public/api/activitypub/user.go +++ b/web/public/api/activitypub/user.go @@ -4,11 +4,14 @@ import ( "encoding/json" "fmt" "net/http" + "strconv" "time" webutils "git.mstar.dev/mstar/goutils/http" + "git.mstar.dev/mstar/goutils/other" "git.mstar.dev/mstar/goutils/sliceutils" "github.com/rs/zerolog/hlog" + "gorm.io/gorm" "git.mstar.dev/mstar/linstrom/activitypub" "git.mstar.dev/mstar/linstrom/config" @@ -50,6 +53,8 @@ func users(w http.ResponseWriter, r *http.Request) { SpeakAsCat bool `json:"speakAsCat"` IsCat bool `json:"isCat"` RestrictedFollow bool `json:"manuallyApprovesFollowers"` + Following string `json:"following"` + Followers string `json:"followers"` } log := hlog.FromRequest(r) userId := r.PathValue("id") @@ -91,6 +96,8 @@ func users(w http.ResponseWriter, r *http.Request) { PublicUrl: config.GlobalConfig.General.GetFullPublicUrl() + "/user/" + user.Username, Discoverable: user.Indexable, RestrictedFollow: user.RestrictedFollow, + Following: apUrl + "/following", + Followers: apUrl + "/followers", } if user.Description != "" { data.Description = &user.Description @@ -139,6 +146,181 @@ func users(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, string(encoded)) } +func userFollowing(w http.ResponseWriter, r *http.Request) { + log := hlog.FromRequest(r) + userId := r.PathValue("id") + pageNrStr := r.FormValue("page") + exists, err := dbgen.User.DoesUserWithIdExist(userId) + if err != nil { + log.Error().Err(err).Str("id", userId).Msg("Failed to check if user exists") + webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) + return + } + if !exists { + webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound) + return + } + apUrl := userIdToApUrl(userId) + var data []byte + followingCount, err := dbgen.UserToUserRelation.CountFollowingForId(userId) + if err != nil { + log.Error().Err(err).Str("id", userId).Msg("Failed to get following count") + webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) + return + } + if pageNrStr == "" { + col := collectionOut{ + Context: "https://www.w3.org/ns/activitystreams", + Type: "OrderedCollection", + Id: apUrl + "/following", + TotalItems: followingCount, + First: apUrl + "/following?page=0", + } + data, err = json.Marshal(col) + if err != nil { + log.Error().Err(err).Any("raw", data).Msg("Failed to marshal following collection page") + webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) + return + } + } else { + pageNr, err := strconv.Atoi(pageNrStr) + if err != nil { + webutils.ProblemDetails( + w, + http.StatusBadRequest, + "/errors/bad-page", + "bad page number", + other.IntoPointer("page number must be an uint"), + nil, + ) + return + } + hasNextPage := followingCount-(pageNr+1)*50 > 0 + hasPreviousPage := pageNr > 0 + links, err := dbgen.UserToUserRelation.GetFollowingApLinksPagedForId(userId, pageNr) + switch err { + case gorm.ErrRecordNotFound: + webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound) + return + case nil: + default: + log.Error().Err(err).Str("id", userId).Msg("Failed to get account via id") + webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) + return + } + page := collectionPageOut{ + Context: "https://www.w3.org/ns/activitystreams", + Type: "OrderedCollectionPage", + Id: fmt.Sprintf("%s/following?page=%d", apUrl, pageNr), + PartOf: userIdToApUrl(userId) + "/following", + Items: sliceutils.Map(links, func(t string) any { return t }), + } + if hasNextPage { + page.Next = fmt.Sprintf("%s/following?page=%d", apUrl, pageNr+1) + } + if hasPreviousPage { + page.Next = fmt.Sprintf("%s/following?page=%d", apUrl, pageNr-1) + } + data, err = json.Marshal(page) + if err != nil { + log.Error().Err(err).Any("raw", page).Msg("Failed to marshal following collection page") + webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) + return + } + } + w.Header().Add("Content-Type", "application/activity+json") + fmt.Fprint(w, string(data)) +} + +func userFollowers(w http.ResponseWriter, r *http.Request) { + log := hlog.FromRequest(r) + userId := r.PathValue("id") + pageNrStr := r.FormValue("page") + exists, err := dbgen.User.DoesUserWithIdExist(userId) + if err != nil { + log.Error().Err(err).Str("id", userId).Msg("Failed to check if user exists") + webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) + return + } + if !exists { + log.Debug().Str("id", userId).Msg("user not found") + webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound) + return + } + apUrl := userIdToApUrl(userId) + var data []byte + followersCount, err := dbgen.UserToUserRelation.CountFollowersForId(userId) + if err != nil { + log.Error().Err(err).Str("id", userId).Msg("Failed to get followers count") + webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) + return + } + if pageNrStr == "" { + col := collectionOut{ + Context: activitypub.BaseLdContext, + Type: "OrderedCollection", + Id: apUrl + "/followers", + TotalItems: followersCount, + First: apUrl + "/followers?page=0", + } + data, err = json.Marshal(col) + if err != nil { + log.Error().Err(err).Any("raw", data).Msg("Failed to marshal followers collection page") + webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) + return + } + } else { + pageNr, err := strconv.Atoi(pageNrStr) + if err != nil { + webutils.ProblemDetails( + w, + http.StatusBadRequest, + "/errors/bad-page", + "bad page number", + other.IntoPointer("page number must be an uint"), + nil, + ) + return + } + hasNextPage := followersCount-(pageNr+1)*50 > 0 + hasPreviousPage := pageNr > 0 + links, err := dbgen.UserToUserRelation.GetFollowerApLinksPagedForId(userId, pageNr) + switch err { + case gorm.ErrRecordNotFound: + log.Debug().Msg("No followers found") + webutils.ProblemDetailsStatusOnly(w, http.StatusNotFound) + return + case nil: + default: + log.Error().Err(err).Str("id", userId).Msg("Failed to get account via id") + webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) + return + } + page := collectionPageOut{ + Context: activitypub.BaseLdContext, + Type: "OrderedCollectionPage", + Id: fmt.Sprintf("%s/followers?page=%d", apUrl, pageNr), + PartOf: userIdToApUrl(userId) + "/followers", + Items: sliceutils.Map(links, func(t string) any { return t }), + } + if hasNextPage { + page.Next = fmt.Sprintf("%s/followers?page=%d", apUrl, pageNr+1) + } + if hasPreviousPage { + page.Next = fmt.Sprintf("%s/followers?page=%d", apUrl, pageNr-1) + } + data, err = json.Marshal(page) + if err != nil { + log.Error().Err(err).Any("raw", page).Msg("Failed to marshal followers collection page") + webutils.ProblemDetailsStatusOnly(w, http.StatusInternalServerError) + return + } + } + log.Debug().Bytes("body", data).Msg("Sending collection(page) out") + w.Header().Add("Content-Type", "application/activity+json") + fmt.Fprint(w, string(data)) +} + /* Fine. You win JsonLD. I can't get you to work properly. I'll just treat you like normal json then Fuck you.