Add follower and following collections
All checks were successful
/ docker (push) Successful in 4m34s

This commit is contained in:
Melody Becker 2025-05-11 18:28:51 +02:00
parent b75db5676b
commit af6ff2dd30
Signed by: mstar
SSH key fingerprint: SHA256:vkXfS9FG2pVNVfvDrzd1VW9n8VJzqqdKQGljxxX8uK8
11 changed files with 431 additions and 22 deletions

View file

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

View file

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

View file

@ -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"`
}

View file

@ -9,6 +9,8 @@ const (
CollectionTargetPinnedNotes = CollectionTargetType("pinned")
CollectionTargetReactions = CollectionTargetType("reactions")
CollectionTargetBoostsAndQuotes = CollectionTargetType("boosts")
COllectionTargetFollows = CollectionTargetType("follows")
COllectionTargetFollowers = CollectionTargetType("followers")
)
var AllCollectionTargetTypes = []CollectionTargetType{

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

@ -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,

View file

@ -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.