eintopf.info@v0.13.16/service/invitation/invitation.go (about) 1 // Copyright (C) 2022 The Eintopf authors 2 // 3 // This program is free software: you can redistribute it and/or modify 4 // it under the terms of the GNU Affero General Public License as 5 // published by the Free Software Foundation, either version 3 of the 6 // License, or (at your option) any later version. 7 // 8 // This program is distributed in the hope that it will be useful, 9 // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 // GNU Affero General Public License for more details. 12 // 13 // You should have received a copy of the GNU Affero General Public License 14 // along with this program. If not, see <https://www.gnu.org/licenses/>. 15 16 package invitation 17 18 import ( 19 "context" 20 "crypto/sha256" 21 "encoding/hex" 22 "fmt" 23 "strconv" 24 "time" 25 26 "eintopf.info/internal/xerror" 27 "eintopf.info/service/auth" 28 "eintopf.info/service/user" 29 ) 30 31 // Service defines an invitation services, that can handle crud operations on 32 // the Invitation model 33 // -go:generate go run github.com/petergtz/pegomock/pegomock generate eintopf.info/service/invitation Service --output=../../internal/mock/invitation_service.go --package=mock --mock-name=InvitationService 34 type Service interface { 35 Storer 36 Invite(ctx context.Context) (token string, err error) 37 UseInvite(ctx context.Context, token string, user *user.NewUser) error 38 } 39 40 // Invitation is an invitation model. 41 type Invitation struct { 42 ID string `json:"id" db:"id"` 43 Token string `json:"token" db:"token"` 44 CreatedAt time.Time `json:"createdAt" db:"created_at"` 45 CreatedBy string `json:"createdBy" db:"created_by"` 46 UsedBy string `json:"usedBy" db:"used_by"` 47 } 48 49 // NewInvitation is the model used to create new invitations by the storer. 50 type NewInvitation struct { 51 Token string `json:"token"` 52 CreatedAt time.Time `json:"createdAt"` 53 CreatedBy string `json:"createdBy"` 54 } 55 56 // InvitationFromNewInvitation converts a NewInvitation to an Invitation with a 57 // given id. 58 func InvitationFromNewInvitation(newInvitation *NewInvitation, id string) *Invitation { 59 return &Invitation{ 60 ID: id, 61 Token: newInvitation.Token, 62 CreatedAt: newInvitation.CreatedAt, 63 CreatedBy: newInvitation.CreatedBy, 64 } 65 } 66 67 // Storer defines an interface for crud operations on the invitation model. 68 type Storer interface { 69 Create(ctx context.Context, invitation *NewInvitation) (*Invitation, error) 70 Update(ctx context.Context, invitation *Invitation) (*Invitation, error) 71 Delete(ctx context.Context, id string) error 72 FindByID(ctx context.Context, id string) (*Invitation, error) 73 Find(ctx context.Context, params *FindParams) ([]Invitation, int, error) 74 } 75 76 // SortOrder defines the order of sorting. 77 type SortOrder string 78 79 // Possible values for SortOrder. 80 const ( 81 OrderAsc = SortOrder("ASC") 82 OrderDesc = SortOrder("DESC") 83 ) 84 85 // FindParams defines parameters used by the Find method. 86 type FindParams struct { 87 Offset int64 88 Limit int64 89 90 Sort string 91 Order SortOrder 92 93 Filters *FindFilters 94 } 95 96 // FindFilters defines the possible filters for the find method. 97 type FindFilters struct { 98 ID *string 99 Token *string 100 CreatedAt *time.Time 101 CreatedBy *string 102 UsedBy *string 103 } 104 105 type service struct { 106 store Storer 107 userService user.Service 108 } 109 110 func NewService(store Storer, userService user.Service) Service { 111 return &service{store: store, userService: userService} 112 } 113 114 func (s *service) Create(ctx context.Context, invitation *NewInvitation) (*Invitation, error) { 115 return s.store.Create(ctx, invitation) 116 } 117 func (s *service) Update(ctx context.Context, invitation *Invitation) (*Invitation, error) { 118 119 return s.store.Update(ctx, invitation) 120 } 121 func (s *service) Delete(ctx context.Context, id string) error { 122 invitation, err := s.store.FindByID(ctx, id) 123 if err != nil { 124 return err 125 } 126 if invitation.UsedBy != "" { 127 return fmt.Errorf("can not delete used invitation") 128 } 129 return s.store.Delete(ctx, id) 130 } 131 func (s *service) FindByID(ctx context.Context, id string) (*Invitation, error) { 132 133 return s.store.FindByID(ctx, id) 134 } 135 func (s *service) Find(ctx context.Context, params *FindParams) ([]Invitation, int, error) { 136 137 return s.store.Find(ctx, params) 138 } 139 140 var ErrNoInvitationYet = fmt.Errorf("no invitation yet") 141 var ErrInvitationLimit = fmt.Errorf("invitation limit") 142 143 func (s *service) Invite(ctx context.Context) (token string, err error) { 144 role, err := auth.RoleFromContext(ctx) 145 if err != nil { 146 return "", err 147 } 148 userID, err := auth.UserIDFromContext(ctx) 149 if err != nil { 150 return "", fmt.Errorf("invalid inviter") 151 } 152 153 if role == auth.RoleNormal { 154 user, err := s.userService.FindByID(ctx, userID) 155 if err != nil { 156 return "", err 157 } 158 if time.Now().Sub(user.CreatedAt) < time.Hour*24*7 { 159 return "", ErrNoInvitationYet 160 } 161 162 invitations, _, err := s.store.Find(ctx, &FindParams{ 163 Filters: &FindFilters{CreatedBy: &userID}, 164 }) 165 if err != nil { 166 return "", err 167 } 168 169 lastInvitationAt := time.Date(2000, 0, 0, 0, 0, 0, 0, time.UTC) 170 for _, invitation := range invitations { 171 if invitation.CreatedAt.After(lastInvitationAt) { 172 lastInvitationAt = invitation.CreatedAt 173 } 174 } 175 if time.Now().Sub(lastInvitationAt) < time.Hour*24*7 { 176 return "", ErrInvitationLimit 177 } 178 } 179 180 h := sha256.New() 181 h.Write([]byte(userID + strconv.FormatInt(time.Now().Unix(), 10))) 182 sum := h.Sum(nil) 183 dst := make([]byte, hex.EncodedLen(len(sum))) 184 hex.Encode(dst, sum) 185 186 invitation := &NewInvitation{ 187 Token: string(dst), 188 CreatedAt: time.Now(), 189 CreatedBy: userID, 190 } 191 _, err = s.store.Create(auth.ContextWithRole(ctx, auth.RoleInternal), invitation) 192 if err != nil { 193 return "", err 194 } 195 196 return invitation.Token, nil 197 } 198 199 var ErrInvalidInvitationToken = xerror.BadInputError{Err: fmt.Errorf("invalid invitation token")} 200 var ErrInvitationTokenAlreadyUsed = xerror.BadInputError{Err: fmt.Errorf("invitation token already used")} 201 202 func (s *service) UseInvite(ctx context.Context, token string, user *user.NewUser) error { 203 invitations, _, err := s.store.Find(auth.ContextWithRole(ctx, auth.RoleInternal), &FindParams{ 204 Filters: &FindFilters{Token: &token}, 205 }) 206 if err != nil || len(invitations) != 1 { 207 return ErrInvalidInvitationToken 208 } 209 invitation := invitations[0] 210 if invitation.UsedBy != "" { 211 return ErrInvitationTokenAlreadyUsed 212 } 213 user.Role = auth.RoleNormal 214 215 newUser, err := s.userService.Create(auth.ContextWithRole(ctx, auth.RoleInternal), user) 216 if err != nil { 217 return err 218 } 219 220 invitation.UsedBy = newUser.ID 221 222 _, err = s.store.Update(auth.ContextWithRole(ctx, auth.RoleInternal), &invitation) 223 if err != nil { 224 return err 225 } 226 return nil 227 }