Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions api/internal/cron/cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ func New(db *gorm.DB) {
// return
// }

err = gocron.Every(1).Hour().Do(jobs.DeleteTerminatedUsers, db)
if err != nil {
log.Println("Error scheduling job:", err)
return
}

err = gocron.Every(1).Hour().Do(jobs.CleanupDeletedAliases, db)
if err != nil {
log.Println("Error scheduling job:", err)
Expand Down
35 changes: 34 additions & 1 deletion api/internal/cron/jobs/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package jobs

import (
"log"
"time"

"gorm.io/gorm"
"ivpn.net/email/api/internal/model"
Expand All @@ -10,7 +11,7 @@ import (
// Delete unverified users older than 7 days
func DeleteUnverifiedUsers(db *gorm.DB) {
users := []model.User{}
err := db.Where("is_active = ? AND created_at < NOW() - INTERVAL ? DAY", false, 7).Find(&users).Error
err := db.Where("is_active = ? AND created_at < ?", false, time.Now().AddDate(0, 0, -7)).Find(&users).Error
if err != nil {
log.Println("Error deleting unverified users:", err)
return
Expand All @@ -19,6 +20,38 @@ func DeleteUnverifiedUsers(db *gorm.DB) {
deleteUsers(db, users)
}

// Delete users with terminated subscriptions older than 2 days
func DeleteTerminatedUsers(db *gorm.DB) {
subscriptions := []model.Subscription{}
err := db.Where("`terminated` = ? AND `terminated_at` < ?", true, time.Now().AddDate(0, 0, -2)).Find(&subscriptions).Error
if err != nil {
log.Println("Error finding terminated subscriptions:", err)
return
}

userIDs := make([]string, 0, len(subscriptions))
seen := make(map[string]struct{}, len(subscriptions))
for _, s := range subscriptions {
if _, ok := seen[s.UserID]; !ok {
seen[s.UserID] = struct{}{}
userIDs = append(userIDs, s.UserID)
}
}

if len(userIDs) == 0 {
return
}

users := []model.User{}
err = db.Where("id IN ?", userIDs).Find(&users).Error
if err != nil {
log.Println("Error finding terminated users:", err)
return
}

deleteUsers(db, users)
}

func deleteUsers(db *gorm.DB, users []model.User) {
for _, user := range users {
ID := user.ID
Expand Down
37 changes: 23 additions & 14 deletions api/internal/model/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,28 @@ const (
)

type Subscription struct {
ID string `gorm:"unique" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UserID string `json:"-"`
Type string `json:"type"`
ActiveUntil time.Time `json:"active_until"`
IsActive bool `json:"-"`
Tier string `json:"tier"`
TokenHash string `gorm:"unique" json:"-"`
Notified bool `json:"-"`
Status SubscriptionStatus `gorm:"-" json:"status"`
Outage bool `gorm:"-" json:"outage"`
ID string `gorm:"unique" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UserID string `json:"-"`
Type string `json:"type"`
ActiveUntil time.Time `json:"active_until"`
IsActive bool `json:"-"`
Tier string `json:"tier"`
TokenHash *string `gorm:"unique" json:"-"`
Notified bool `json:"-"`
Status SubscriptionStatus `gorm:"-" json:"status"`
Outage bool `gorm:"-" json:"outage"`
Terminated bool `json:"terminated"`
TerminatedAt time.Time `json:"terminated_at"`
}

func (s *Subscription) Active() bool {
return s.ActiveUntil.After(time.Now()) && !strings.Contains(s.Tier, Tier1) && !s.IsOutage()
return s.ActiveUntil.After(time.Now()) && !strings.Contains(s.Tier, Tier1) && !s.IsOutage() && !s.Terminated
}

func (s *Subscription) GracePeriod() bool {
return s.IsOutage() && s.GracePeriodDays(3) && s.OutageGracePeriodDays(3)
return s.IsOutage() && s.GracePeriodDays(3) && s.OutageGracePeriodDays(3) && !s.Terminated
}

func (s *Subscription) LimitedAccess() bool {
Expand All @@ -55,6 +57,10 @@ func (s *Subscription) LimitedAccess() bool {
return false
}

func (s *Subscription) PendingDelete() bool {
return s.Terminated
}

func (s *Subscription) ActiveStatus() bool {
return s.Active() || s.GracePeriod()
}
Expand All @@ -76,6 +82,9 @@ func (s *Subscription) OutageGracePeriodDays(days int) bool {
}

func (s *Subscription) GetStatus() SubscriptionStatus {
if s.PendingDelete() {
return PendingDelete
}
if s.Active() {
return Active
}
Expand Down
19 changes: 19 additions & 0 deletions api/internal/model/subscription_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func TestActive(t *testing.T) {
activeUntil time.Time
updatedAt time.Time
tier string
terminated bool
want bool
}{
{
Expand Down Expand Up @@ -85,6 +86,14 @@ func TestActive(t *testing.T) {
tier: "Tier 2",
want: true,
},
{
name: "not active: terminated, even with otherwise valid subscription",
activeUntil: future,
updatedAt: recent,
tier: "Tier 2",
terminated: true,
want: false,
},
}

for _, tt := range tests {
Expand All @@ -93,6 +102,7 @@ func TestActive(t *testing.T) {
ActiveUntil: tt.activeUntil,
UpdatedAt: tt.updatedAt,
Tier: tt.tier,
Terminated: tt.terminated,
}
if got := s.Active(); got != tt.want {
t.Errorf("Active() = %v, want %v", got, tt.want)
Expand Down Expand Up @@ -209,6 +219,7 @@ func TestGracePeriod(t *testing.T) {
name string
activeUntil time.Time
updatedAt time.Time
terminated bool
want bool
}{
{
Expand All @@ -235,13 +246,21 @@ func TestGracePeriod(t *testing.T) {
updatedAt: outageOutside3Days,
want: false,
},
{
name: "no grace period: terminated, even with otherwise valid grace period conditions",
activeUntil: time.Now().Add(-24 * time.Hour), // expired 1d ago, within 3d grace
updatedAt: outageTime, // outage, but < 3d
terminated: true,
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &Subscription{
ActiveUntil: tt.activeUntil,
UpdatedAt: tt.updatedAt,
Terminated: tt.terminated,
}
if got := s.GracePeriod(); got != tt.want {
t.Errorf("GracePeriod() = %v, want %v", got, tt.want)
Expand Down
10 changes: 10 additions & 0 deletions api/internal/repository/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ func (d *Database) GetSubscription(ctx context.Context, userID string) (model.Su
return subscription, q.Error
}

func (d *Database) GetSubscriptionByTokenHash(ctx context.Context, tokenHash string) (model.Subscription, error) {
var subscription model.Subscription
q := d.Client.Where("token_hash = ?", tokenHash).Find(&subscription)
if q.RowsAffected == 0 {
return model.Subscription{}, fmt.Errorf("could not get subscription by token hash")
}

return subscription, q.Error
}

func (d *Database) PostSubscription(ctx context.Context, sub model.Subscription) error {
return d.Client.Create(&sub).Error
}
Expand Down
20 changes: 17 additions & 3 deletions api/internal/service/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ var (

type SubscriptionStore interface {
GetSubscription(context.Context, string) (model.Subscription, error)
GetSubscriptionByTokenHash(context.Context, string) (model.Subscription, error)
PostSubscription(context.Context, model.Subscription) error
UpdateSubscription(context.Context, model.Subscription) error
DeleteSubscription(context.Context, string) error
Expand All @@ -44,16 +45,29 @@ func (s *Service) GetSubscription(ctx context.Context, userID string) (model.Sub
}

func (s *Service) PostSubscription(ctx context.Context, userID string, preauth model.Preauth) error {
// Support for signup reset
existingSub, err := s.Store.GetSubscriptionByTokenHash(ctx, preauth.TokenHash)
if err == nil {
existingSub.TokenHash = nil
existingSub.Terminated = true
existingSub.TerminatedAt = time.Now()
err = s.Store.UpdateSubscription(ctx, existingSub)
if err != nil {
log.Printf("error clearing token hash for existing subscription: %s", err.Error())
return ErrPostSubscription
}
}

sub := model.Subscription{
UserID: userID,
ActiveUntil: preauth.ActiveUntil,
IsActive: preauth.IsActive,
Tier: preauth.Tier,
TokenHash: preauth.TokenHash,
TokenHash: &preauth.TokenHash,
}
sub.ID = uuid.New().String()

err := s.Store.PostSubscription(ctx, sub)
err = s.Store.PostSubscription(ctx, sub)
if err != nil {
log.Printf("error posting subscription: %s", err.Error())
var mysqlErr *mysql.MySQLError
Expand Down Expand Up @@ -103,7 +117,7 @@ func (s *Service) UpdateSubscription(ctx context.Context, sub model.Subscription
sub.ActiveUntil = preauth.ActiveUntil
sub.IsActive = preauth.IsActive
sub.Tier = preauth.Tier
sub.TokenHash = preauth.TokenHash
sub.TokenHash = &preauth.TokenHash
sub.Type = ""

if sub.ID == "" || sub.UserID == "" {
Expand Down
28 changes: 28 additions & 0 deletions api/internal/service/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,34 @@ func (s *Service) DeleteUser(ctx context.Context, userID string, OTP string) err
return nil
}

func (s *Service) DeleteUnfinishedSignup(ctx context.Context, UserId string) error {
user, err := s.Store.GetUser(ctx, UserId)
if err != nil {
log.Printf("error deleting unfinished signup: %s", err.Error())
return ErrGetUser
}

err = s.Store.DeleteSubscription(ctx, user.ID)
if err != nil {
log.Printf("error deleting user: %s", err.Error())
return ErrDeleteUser
}

err = s.Store.DeleteSettings(ctx, user.ID)
if err != nil {
log.Printf("error deleting user: %s", err.Error())
return ErrDeleteUser
}

err = s.Store.DeleteUser(ctx, user.ID)
if err != nil {
log.Printf("error deleting unfinished signup: %s", err.Error())
return ErrDeleteUser
}

return nil
}

func (s *Service) GetUserStats(ctx context.Context, userID string) (model.UserStats, error) {
stats, err := s.Store.GetUserStats(ctx, userID)
if err != nil {
Expand Down
6 changes: 6 additions & 0 deletions api/internal/transport/api/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type UserService interface {
GetUserByPassword(context.Context, string, string) (model.User, error)
GetUserByEmail(context.Context, string) (model.User, error)
GetUnfinishedSignupOrPostUser(context.Context, model.User, string, string) (model.User, error)
DeleteUnfinishedSignup(context.Context, string) error
SaveUser(context.Context, model.User) error
DeleteUserRequest(context.Context, string) (string, error)
DeleteUser(context.Context, string, string) error
Expand Down Expand Up @@ -95,6 +96,11 @@ func (h *Handler) Register(c *fiber.Ctx) error {
// Get unfinished signup user or create new user
user, err = h.Service.GetUnfinishedSignupOrPostUser(c.Context(), user, req.SubID, sessionId)
if err != nil {
err = h.Service.DeleteUnfinishedSignup(c.Context(), user.ID)
if err != nil {
log.Printf("error deleting unfinished signup: %s", err.Error())
}

return c.Status(400).JSON(fiber.Map{
"error": err.Error(),
})
Expand Down
6 changes: 6 additions & 0 deletions api/internal/transport/api/webauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"context"
"log"
"time"

"github.com/go-webauthn/webauthn/webauthn"
Expand Down Expand Up @@ -80,6 +81,11 @@ func (h *Handler) BeginRegistration(c *fiber.Ctx) error {
// Get unfinished signup user or create new user
user, err = h.Service.GetUnfinishedSignupOrPostUser(c.Context(), user, req.SubID, sessionId)
if err != nil {
err = h.Service.DeleteUnfinishedSignup(c.Context(), user.ID)
if err != nil {
log.Printf("error deleting unfinished signup: %s", err.Error())
}

return c.Status(400).JSON(fiber.Map{
"error": err.Error(),
})
Expand Down
17 changes: 17 additions & 0 deletions app/src/components/AccountSubscription.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@
</div>
</footer>
</div>
<div v-if="isPendingDelete()" class="card-tertiary">
<footer>
<div>
<i class="icon info icon-primary"></i>
</div>
<div>
<h4>This account has been replaced and is scheduled for deletion.</h4>
<p>
A new Mailx signup was completed for your IVPN account, so this account will be deleted in 48 hours. Export any data you need before then.
</p>
</div>
</footer>
</div>
<div v-if="isOutage()" class="card-tertiary">
<footer>
<div>
Expand Down Expand Up @@ -149,6 +162,10 @@ const isLimited = () => {
return sub.value.status === 'limited_access'
}

const isPendingDelete = () => {
return sub.value.status === 'pending_delete'
}

const isManaged = () => {
return sub.value.type === 'Managed'
}
Expand Down
17 changes: 17 additions & 0 deletions app/src/components/AccountSubscriptionStatus.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@
</div>
</footer>
</div>
<div v-if="isPendingDelete() && isDashboard && sub.id" class="card-tertiary m-8 mb-0">
<footer>
<div>
<i class="icon info icon-primary"></i>
</div>
<div>
<h4>This account has been replaced and is scheduled for deletion.</h4>
<p>
A new Mailx signup was completed for your IVPN account, so this account will be deleted in 48 hours. Export any data you need before then.
</p>
</div>
</footer>
</div>
</template>

<script setup lang="ts">
Expand Down Expand Up @@ -59,6 +72,10 @@ const isLimited = () => {
return sub.value.status === 'limited_access'
}

const isPendingDelete = () => {
return sub.value.status === 'pending_delete'
}

const isManaged = () => {
return sub.value.type === 'Managed'
}
Expand Down
Loading