diff --git a/pkg/cmd/main.go b/pkg/cmd/main.go index d3d08f4..7a7d016 100644 --- a/pkg/cmd/main.go +++ b/pkg/cmd/main.go @@ -68,6 +68,7 @@ func main() { quartz := cron.New(cron.WithLogger(cron.VerbosePrintfLogger(&log.Logger))) quartz.AddFunc("@every 60m", services.DoAutoSignoff) quartz.AddFunc("@every 60m", services.DoAutoAuthCleanup) + quartz.AddFunc("@every 60m", services.DoAutoDatabaseCleanup) quartz.Run() // Messages diff --git a/pkg/database/migrator.go b/pkg/database/migrator.go index 36699db..e031215 100644 --- a/pkg/database/migrator.go +++ b/pkg/database/migrator.go @@ -5,21 +5,24 @@ import ( "gorm.io/gorm" ) +var DatabaseAutoActionRange = []any{ + &models.Account{}, + &models.AuthFactor{}, + &models.AccountProfile{}, + &models.AccountPage{}, + &models.AccountContact{}, + &models.AccountFriendship{}, + &models.AuthSession{}, + &models.AuthChallenge{}, + &models.MagicToken{}, + &models.ThirdClient{}, + &models.ActionEvent{}, + &models.Notification{}, + &models.NotificationSubscriber{}, +} + func RunMigration(source *gorm.DB) error { - if err := source.AutoMigrate( - &models.Account{}, - &models.AuthFactor{}, - &models.AccountProfile{}, - &models.AccountPage{}, - &models.AccountContact{}, - &models.AuthSession{}, - &models.AuthChallenge{}, - &models.MagicToken{}, - &models.ThirdClient{}, - &models.ActionEvent{}, - &models.Notification{}, - &models.NotificationSubscriber{}, - ); err != nil { + if err := source.AutoMigrate(DatabaseAutoActionRange...); err != nil { return err } diff --git a/pkg/grpc/friendships.go b/pkg/grpc/friendships.go new file mode 100644 index 0000000..5edac2e --- /dev/null +++ b/pkg/grpc/friendships.go @@ -0,0 +1,46 @@ +package grpc + +import ( + "context" + "fmt" + "git.solsynth.dev/hydrogen/identity/pkg/grpc/proto" + "git.solsynth.dev/hydrogen/identity/pkg/models" + "git.solsynth.dev/hydrogen/identity/pkg/services" + "github.com/samber/lo" +) + +func (v *Server) ListFriendship(_ context.Context, request *proto.FriendshipLookupRequest) (*proto.ListFriendshipResponse, error) { + account, err := services.GetAccount(uint(request.GetAccountId())) + if err != nil { + return nil, err + } + friends, err := services.ListFriend(account, models.FriendshipStatus(request.GetStatus())) + if err != nil { + return nil, err + } + + return &proto.ListFriendshipResponse{ + Data: lo.Map(friends, func(item models.AccountFriendship, index int) *proto.FriendshipResponse { + return &proto.FriendshipResponse{ + AccountId: uint64(item.AccountID), + RelatedId: uint64(item.RelatedID), + Status: uint32(item.Status), + } + }), + }, nil +} + +func (v *Server) GetFriendship(ctx context.Context, request *proto.FriendshipTwoSideLookupRequest) (*proto.FriendshipResponse, error) { + friend, err := services.GetFriendWithTwoSides(uint(request.GetAccountId()), uint(request.GetRelatedId())) + if err != nil { + return nil, err + } else if friend.Status != models.FriendshipStatus(request.GetStatus()) { + return nil, fmt.Errorf("status mismatch") + } + + return &proto.FriendshipResponse{ + AccountId: uint64(friend.AccountID), + RelatedId: uint64(friend.RelatedID), + Status: uint32(friend.Status), + }, nil +} diff --git a/pkg/grpc/proto/friendships.proto b/pkg/grpc/proto/friendships.proto new file mode 100644 index 0000000..947002c --- /dev/null +++ b/pkg/grpc/proto/friendships.proto @@ -0,0 +1,31 @@ +syntax = "proto3"; + +option go_package = ".;proto"; + +package proto; + +service Friendships { + rpc ListFriendship(FriendshipLookupRequest) returns (ListFriendshipResponse) {} + rpc GetFriendship(FriendshipTwoSideLookupRequest) returns (FriendshipResponse) {} +} + +message FriendshipLookupRequest { + uint64 account_id = 1; + uint32 status = 2; +} + +message FriendshipTwoSideLookupRequest { + uint64 account_id = 1; + uint64 related_id = 2; + uint32 status = 3; +} + +message ListFriendshipResponse { + repeated FriendshipResponse data = 1; +} + +message FriendshipResponse { + uint64 account_id = 1; + uint64 related_id = 2; + uint32 status = 3; +} \ No newline at end of file diff --git a/pkg/grpc/server.go b/pkg/grpc/server.go index 4b244a3..c58a46a 100644 --- a/pkg/grpc/server.go +++ b/pkg/grpc/server.go @@ -12,6 +12,7 @@ import ( type Server struct { proto.UnimplementedAuthServer proto.UnimplementedNotifyServer + proto.UnimplementedFriendshipsServer } func StartGrpc() error { @@ -24,6 +25,7 @@ func StartGrpc() error { proto.RegisterAuthServer(server, &Server{}) proto.RegisterNotifyServer(server, &Server{}) + proto.RegisterFriendshipsServer(server, &Server{}) reflection.Register(server) diff --git a/pkg/models/accounts.go b/pkg/models/accounts.go index f72a7ec..e9e2e77 100644 --- a/pkg/models/accounts.go +++ b/pkg/models/accounts.go @@ -11,24 +11,32 @@ import ( type Account struct { BaseModel - Name string `json:"name" gorm:"uniqueIndex"` - Nick string `json:"nick"` - Description string `json:"description"` - Avatar string `json:"avatar"` - Banner string `json:"banner"` - Profile AccountProfile `json:"profile"` - PersonalPage AccountPage `json:"personal_page"` - Sessions []AuthSession `json:"sessions"` - Challenges []AuthChallenge `json:"challenges"` - Factors []AuthFactor `json:"factors"` - Contacts []AccountContact `json:"contacts"` - Events []ActionEvent `json:"events"` - MagicTokens []MagicToken `json:"-" gorm:"foreignKey:AssignTo"` - ThirdClients []ThirdClient `json:"clients"` + Name string `json:"name" gorm:"uniqueIndex"` + Nick string `json:"nick"` + Description string `json:"description"` + Avatar string `json:"avatar"` + Banner string `json:"banner"` + ConfirmedAt *time.Time `json:"confirmed_at"` + PowerLevel int `json:"power_level"` + + Profile AccountProfile `json:"profile"` + PersonalPage AccountPage `json:"personal_page"` + Contacts []AccountContact `json:"contacts"` + + Sessions []AuthSession `json:"sessions"` + Challenges []AuthChallenge `json:"challenges"` + Factors []AuthFactor `json:"factors"` + + Events []ActionEvent `json:"events"` + MagicTokens []MagicToken `json:"-" gorm:"foreignKey:AssignTo"` + + ThirdClients []ThirdClient `json:"clients"` + Notifications []Notification `json:"notifications" gorm:"foreignKey:RecipientID"` NotifySubscribers []NotificationSubscriber `json:"notify_subscribers"` - ConfirmedAt *time.Time `json:"confirmed_at"` - PowerLevel int `json:"power_level"` + + Friendships []AccountFriendship `json:"friendships" gorm:"foreignKey:AccountID"` + RelatedFriendships []AccountFriendship `json:"related_friendships" gorm:"foreignKey:RelatedID"` } func (v Account) GetPrimaryEmail() AccountContact { @@ -64,3 +72,21 @@ type AccountContact struct { VerifiedAt *time.Time `json:"verified_at"` AccountID uint `json:"account_id"` } + +type FriendshipStatus = int8 + +const ( + FriendshipPending = FriendshipStatus(iota) + FriendshipActive + FriendshipBlocked +) + +type AccountFriendship struct { + BaseModel + + AccountID uint `json:"account_id"` + RelatedID uint `json:"related_id"` + Account Account `json:"account"` + Related Account `json:"related"` + Status FriendshipStatus `json:"status"` +} diff --git a/pkg/server/friendships_api.go b/pkg/server/friendships_api.go new file mode 100644 index 0000000..e2c0167 --- /dev/null +++ b/pkg/server/friendships_api.go @@ -0,0 +1,103 @@ +package server + +import ( + "git.solsynth.dev/hydrogen/identity/pkg/models" + "git.solsynth.dev/hydrogen/identity/pkg/services" + "github.com/gofiber/fiber/v2" +) + +func listFriendship(c *fiber.Ctx) error { + user := c.Locals("principal").(models.Account) + status := c.QueryInt("status", int(models.FriendshipActive)) + + if friends, err := services.ListFriend(user, models.FriendshipStatus(status)); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } else { + return c.JSON(friends) + } +} + +func getFriendship(c *fiber.Ctx) error { + user := c.Locals("principal").(models.Account) + relatedId, _ := c.ParamsInt("relatedId", 0) + + related, err := services.GetAccount(uint(relatedId)) + if err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + + if friend, err := services.GetFriendWithTwoSides(user.ID, related.ID); err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } else { + return c.JSON(friend) + } +} + +func makeFriendship(c *fiber.Ctx) error { + user := c.Locals("principal").(models.Account) + relatedId, _ := c.ParamsInt("relatedId", 0) + + related, err := services.GetAccount(uint(relatedId)) + if err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + + friend, err := services.NewFriend(user, related, models.FriendshipPending) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } else { + return c.JSON(friend) + } +} + +func editFriendship(c *fiber.Ctx) error { + user := c.Locals("principal").(models.Account) + relatedId, _ := c.ParamsInt("relatedId", 0) + + var data struct { + Status uint8 `json:"status"` + } + + if err := BindAndValidate(c, &data); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + related, err := services.GetAccount(uint(relatedId)) + if err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + friendship, err := services.GetFriendWithTwoSides(user.ID, related.ID) + if err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } else if friendship.Status == models.FriendshipPending || data.Status == uint8(models.FriendshipPending) { + if friendship.RelatedID != user.ID { + return fiber.NewError(fiber.StatusNotFound, "only related person can accept or revoke accept friendship") + } + } + + if friendship, err := services.EditFriend(friendship); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } else { + return c.JSON(friendship) + } +} + +func deleteFriendship(c *fiber.Ctx) error { + user := c.Locals("principal").(models.Account) + relatedId, _ := c.ParamsInt("relatedId", 0) + + related, err := services.GetAccount(uint(relatedId)) + if err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + friendship, err := services.GetFriendWithTwoSides(user.ID, related.ID) + if err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + + if err := services.DeleteFriend(friendship); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } else { + return c.JSON(friendship) + } +} diff --git a/pkg/server/startup.go b/pkg/server/startup.go index 74b2aeb..5d2f6d9 100644 --- a/pkg/server/startup.go +++ b/pkg/server/startup.go @@ -86,6 +86,15 @@ func NewServer() { me.Delete("/sessions/:sessionId", authMiddleware, killSession) me.Post("/confirm", doRegisterConfirm) + + friends := me.Group("/friends").Name("Friends") + { + friends.Get("/", authMiddleware, listFriendship) + friends.Get("/:relatedId", authMiddleware, getFriendship) + friends.Post("/:relatedId", authMiddleware, makeFriendship) + friends.Put("/:relatedId", authMiddleware, editFriendship) + friends.Delete("/:relatedId", authMiddleware, deleteFriendship) + } } directory := api.Group("/users/:alias").Name("User Directory") diff --git a/pkg/server/userinfo..go b/pkg/server/userinfo..go index 7dc9287..7750c06 100644 --- a/pkg/server/userinfo..go +++ b/pkg/server/userinfo..go @@ -13,6 +13,7 @@ func getOtherUserinfo(c *fiber.Ctx) error { if err := database.C. Where(&models.Account{Name: alias}). Omit("sessions", "challenges", "factors", "events", "clients", "notifications", "notify_subscribers"). + Preload("Profile"). First(&account).Error; err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } diff --git a/pkg/services/cleaner.go b/pkg/services/cleaner.go new file mode 100644 index 0000000..4873d57 --- /dev/null +++ b/pkg/services/cleaner.go @@ -0,0 +1,23 @@ +package services + +import ( + "git.solsynth.dev/hydrogen/identity/pkg/database" + "github.com/rs/zerolog/log" + "time" +) + +func DoAutoDatabaseCleanup() { + deadline := time.Now().Add(60 * time.Minute) + log.Debug().Time("deadline", deadline).Msg("Now cleaning up entire database...") + + var count int64 + for _, model := range database.DatabaseAutoActionRange { + tx := database.C.Unscoped().Delete(model, "deleted_at >= ?", deadline) + if tx.Error != nil { + log.Error().Err(tx.Error).Msg("An error occurred when running auth context cleanup...") + } + count += tx.RowsAffected + } + + log.Debug().Int64("affected", count).Msg("Clean up entire database accomplished.") +} diff --git a/pkg/services/friendships.go b/pkg/services/friendships.go new file mode 100644 index 0000000..75b17cd --- /dev/null +++ b/pkg/services/friendships.go @@ -0,0 +1,89 @@ +package services + +import ( + "errors" + "fmt" + "git.solsynth.dev/hydrogen/identity/pkg/database" + "git.solsynth.dev/hydrogen/identity/pkg/models" + "gorm.io/gorm" +) + +func ListFriend(anyside models.Account, status models.FriendshipStatus) ([]models.AccountFriendship, error) { + var relationships []models.AccountFriendship + if err := database.C. + Where(&models.AccountFriendship{Status: status}). + Where(&models.AccountFriendship{AccountID: anyside.ID}). + Or(&models.AccountFriendship{RelatedID: anyside.ID}). + Find(&relationships).Error; err != nil { + return relationships, err + } + + return relationships, nil +} + +func GetFriend(anysideId uint) (models.AccountFriendship, error) { + var relationship models.AccountFriendship + if err := database.C. + Where(&models.AccountFriendship{AccountID: anysideId}). + Or(&models.AccountFriendship{RelatedID: anysideId}). + Preload("Account"). + Preload("Related"). + First(&relationship).Error; err != nil { + return relationship, err + } + + return relationship, nil +} + +func GetFriendWithTwoSides(userId, relatedId uint, noPreload ...bool) (models.AccountFriendship, error) { + var tx *gorm.DB + if len(noPreload) > 0 && noPreload[0] { + tx = database.C + } else { + tx = database.C.Preload("Account").Preload("Related") + } + + var relationship models.AccountFriendship + if err := tx. + Where(&models.AccountFriendship{AccountID: userId, RelatedID: relatedId}). + Or(&models.AccountFriendship{RelatedID: userId, AccountID: relatedId}). + First(&relationship).Error; err != nil { + return relationship, err + } + + return relationship, nil +} + +func NewFriend(user models.Account, related models.Account, status models.FriendshipStatus) (models.AccountFriendship, error) { + relationship := models.AccountFriendship{ + AccountID: user.ID, + RelatedID: related.ID, + Status: status, + } + + if user.ID == related.ID { + return relationship, fmt.Errorf("you cannot make friendship with yourself") + } else if _, err := GetFriendWithTwoSides(user.ID, related.ID, true); err == nil || !errors.Is(err, gorm.ErrRecordNotFound) { + return relationship, fmt.Errorf("you already have a friendship with him or her") + } + + if err := database.C.Save(&relationship).Error; err != nil { + return relationship, err + } + + return relationship, nil +} + +func EditFriend(relationship models.AccountFriendship) (models.AccountFriendship, error) { + if err := database.C.Save(&relationship).Error; err != nil { + return relationship, err + } + return relationship, nil +} + +func DeleteFriend(relationship models.AccountFriendship) error { + if err := database.C.Delete(&relationship).Error; err != nil { + return err + } + return nil +} diff --git a/pkg/services/sessions.go b/pkg/services/sessions.go index 5795887..8ef9ba5 100644 --- a/pkg/services/sessions.go +++ b/pkg/services/sessions.go @@ -27,7 +27,7 @@ func DoAutoSignoff() { duration := time.Duration(viper.GetInt64("security.auto_signoff_duration")) * time.Second divider := time.Now().Add(-duration) - log.Debug().Time("before", divider).Msg("Now auto signing off sessions...") + log.Debug().Time("before", divider).Msg("Now signing off sessions...") if tx := database.C. Where("last_grant_at < ?", divider). @@ -39,7 +39,7 @@ func DoAutoSignoff() { } func DoAutoAuthCleanup() { - log.Debug().Msg("Now auto cleaning up cached auth context...") + log.Debug().Msg("Now cleaning up cached auth context...") count := 0 err := database.B.Batch(func(tx *bbolt.Tx) error {