diff --git a/.github/workflows/ci_production.yml b/.github/workflows/ci_production.yml index 75be357..60d0f0a 100644 --- a/.github/workflows/ci_production.yml +++ b/.github/workflows/ci_production.yml @@ -29,6 +29,7 @@ jobs: echo "VITE_API_URL=${{ secrets.PROD_VITE_API_URL }}" >> app/.env echo "VITE_DOMAINS=${{ secrets.PROD_VITE_DOMAINS }}" >> app/.env echo "VITE_APP_NAME=${{ secrets.PROD_VITE_APP_NAME }}" >> app/.env + echo "VITE_RESYNC_URL=${{ secrets.PROD_VITE_RESYNC_URL }}" >> app/.env - name: Build api image run: docker build -t $REGISTRY/$API_IMAGE:$TAG api/. diff --git a/.github/workflows/ci_staging.yml b/.github/workflows/ci_staging.yml index 0ff4d17..9ca811d 100644 --- a/.github/workflows/ci_staging.yml +++ b/.github/workflows/ci_staging.yml @@ -29,6 +29,7 @@ jobs: echo "VITE_API_URL=${{ secrets.STAGING_VITE_API_URL }}" >> app/.env echo "VITE_DOMAINS=${{ secrets.STAGING_VITE_DOMAINS }}" >> app/.env echo "VITE_APP_NAME=${{ secrets.STAGING_VITE_APP_NAME }}" >> app/.env + echo "VITE_RESYNC_URL=${{ secrets.STAGING_VITE_RESYNC_URL }}" >> app/.env - name: Build api image run: docker build -t $REGISTRY/$API_IMAGE:$TAG api/. diff --git a/api/.env.sample b/api/.env.sample index 10a8fef..d499482 100644 --- a/api/.env.sample +++ b/api/.env.sample @@ -1,67 +1,69 @@ -FQDN="localhost" -API_NAME="Service Name" -API_PORT="3000" -API_ALLOW_ORIGIN="http://localhost:3001" -TOKEN_SECRET="secret" +FQDN=localhost +API_NAME=Service Name +API_PORT=3000 +API_ALLOW_ORIGIN=http://localhost:3001 +TOKEN_SECRET=secret TOKEN_EXPIRATION=168h API_TOKEN_EXPIRATION=8760h -PSK="" -PSK_ALLOW_ORIGIN="http://localhost:3001" -DOMAINS="example1.net,example2.com" -LOG_FILE="/var/log/api.log" -BASIC_AUTH_USER="" -BASIC_AUTH_PASSWORD="" -NET_SUBNET="" -NET_GATEWAY="" -SIGNUP_WEBHOOK_URL="" -SIGNUP_WEBHOOK_PSK="" +PSK= +PSK_ALLOW_ORIGIN=http://localhost:3001 +DOMAINS=example1.net,example2.com +LOG_FILE=/var/log/api.log +BASIC_AUTH_USER= +BASIC_AUTH_PASSWORD= +NET_SUBNET= +NET_GATEWAY= +SIGNUP_WEBHOOK_URL= +SIGNUP_WEBHOOK_PSK= +PREAUTH_URL= +PREAUTH_PSK= +PREAUTH_TTL=60m -APP_PORT="3001" +APP_PORT=3001 -DB_HOSTS="db" -DB_PORT="3306" -DB_NAME="email" -DB_USER="email" -DB_PASSWORD="email" -DB_ROOT_USER="root" -DB_ROOT_PASSWORD="root" +DB_HOSTS=db +DB_PORT=3306 +DB_NAME=email +DB_USER=email +DB_PASSWORD=email +DB_ROOT_USER=root +DB_ROOT_PASSWORD=root -REDIS_ADDR="redis:6379" -REDIS_ADDRS="" -REDIS_MASTER_NAME="" -REDIS_USERNAME="" -REDIS_PASSWORD="" -REDIS_FAILOVER_USERNAME="" -REDIS_FAILOVER_PASSWORD="" -REDIS_TLS_ENABLED="false" -REDIS_CERT_FILE="" -REDIS_KEY_FILE="" -REDIS_CA_CERT_FILE="" -REDIS_TLS_INSECURE_SKIP_VERIFY="false" +REDIS_ADDR=redis:6379 +REDIS_ADDRS= +REDIS_MASTER_NAME= +REDIS_USERNAME= +REDIS_PASSWORD= +REDIS_FAILOVER_USERNAME= +REDIS_FAILOVER_PASSWORD= +REDIS_TLS_ENABLED=false +REDIS_CERT_FILE= +REDIS_KEY_FILE= +REDIS_CA_CERT_FILE= +REDIS_TLS_INSECURE_SKIP_VERIFY=false -SMTP_CLIENT_HOST="smtp.example.net" -SMTP_CLIENT_PORT="2525" -SMTP_CLIENT_USER="" -SMTP_CLIENT_PASSWORD="" -SMTP_CLIENT_SENDER="from@example.net" -SMTP_CLIENT_SENDER_NAME="From Name" +SMTP_CLIENT_HOST=smtp.example.net +SMTP_CLIENT_PORT=2525 +SMTP_CLIENT_USER= +SMTP_CLIENT_PASSWORD= +SMTP_CLIENT_SENDER=from@example.net +SMTP_CLIENT_SENDER_NAME=From Name OTP_EXPIRATION=15m -SUBSCRIPTION_TYPE="" +SUBSCRIPTION_TYPE= MAX_CREDENTIALS=10 MAX_RECIPIENTS=10 MAX_DAILY_ALIASES=100 MAX_DAILY_SEND_REPLY=100 MAX_SESSIONS=10 -FORWARD_GRACE_PERIOD_DAYS=14 ACCOUNT_GRACE_PERIOD_DAYS=194 ID_LIMITER_MAX=5 ID_LIMITER_EXPIRATION=60m -BACKUP_FILENAME="backup" -BACKUP_CRON_EXPRESSION="0 0 29 2 1" +BACKUP_FILENAME=backup +BACKUP_CRON_EXPRESSION=0 0 29 2 1 BACKUP_RETENTION_DAYS=7 -GPG_PASSPHRASE="" -AWS_S3_BUCKET_NAME="" -AWS_ACCESS_KEY_ID="" -AWS_SECRET_ACCESS_KEY="" +GPG_PASSPHRASE= +AWS_S3_BUCKET_NAME= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= diff --git a/api/config/config.go b/api/config/config.go index 9a255c3..f5241e3 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -23,6 +23,9 @@ type APIConfig struct { BasicAuthPassword string SignupWebhookURL string SignupWebhookPSK string + PreauthURL string + PreauthPSK string + PreauthTTL time.Duration } type DBConfig struct { @@ -65,7 +68,6 @@ type ServiceConfig struct { MaxDailyAliases int MaxDailySendReply int MaxSessions int - ForwardGracePeriodDays int AccountGracePeriodDays int IdLimiterMax int IdLimiterExpiration time.Duration @@ -133,11 +135,6 @@ func New() (Config, error) { return Config{}, err } - forwardGracePeriodDays, err := strconv.Atoi(os.Getenv("FORWARD_GRACE_PERIOD_DAYS")) - if err != nil { - return Config{}, err - } - accountGracePeriodDays, err := strconv.Atoi(os.Getenv("ACCOUNT_GRACE_PERIOD_DAYS")) if err != nil { return Config{}, err @@ -146,6 +143,12 @@ func New() (Config, error) { dbHosts := strings.Split(os.Getenv("DB_HOSTS"), ",") redisAddrs := strings.Split(os.Getenv("REDIS_ADDRESSES"), ",") + preauthTTLStr := os.Getenv("PREAUTH_TTL") + preauthTTL, err := time.ParseDuration(preauthTTLStr) + if err != nil { + return Config{}, err + } + return Config{ API: APIConfig{ FQDN: os.Getenv("FQDN"), @@ -163,6 +166,9 @@ func New() (Config, error) { BasicAuthPassword: os.Getenv("BASIC_AUTH_PASSWORD"), SignupWebhookURL: os.Getenv("SIGNUP_WEBHOOK_URL"), SignupWebhookPSK: os.Getenv("SIGNUP_WEBHOOK_PSK"), + PreauthURL: os.Getenv("PREAUTH_URL"), + PreauthPSK: os.Getenv("PREAUTH_PSK"), + PreauthTTL: preauthTTL, }, DB: DBConfig{ Hosts: dbHosts, @@ -202,7 +208,6 @@ func New() (Config, error) { MaxDailyAliases: maxDailyAliases, MaxDailySendReply: maxDailySendReply, MaxSessions: maxSessions, - ForwardGracePeriodDays: forwardGracePeriodDays, AccountGracePeriodDays: accountGracePeriodDays, IdLimiterMax: idLimiterMax, IdLimiterExpiration: idLimiterExpiration, diff --git a/api/docs/docs.go b/api/docs/docs.go index 59472a9..dc6c4df 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1487,6 +1487,46 @@ const docTemplate = `{ } } }, + "/rotatepasession": { + "put": { + "description": "Rotate pre-auth session ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscription" + ], + "summary": "Rotate pre-auth session ID", + "parameters": [ + { + "description": "Rotate pre-auth session request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.RotatePASessionReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.SuccessRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrorRes" + } + } + } + } + }, "/settings": { "get": { "security": [ @@ -1598,14 +1638,14 @@ const docTemplate = `{ } } }, - "/subscription/add": { + "/sub/session": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Add subscription", + "description": "Add pre-auth session", "consumes": [ "application/json" ], @@ -1615,15 +1655,15 @@ const docTemplate = `{ "tags": [ "subscription" ], - "summary": "Add subscription", + "summary": "Add pre-auth session", "parameters": [ { - "description": "Subscription request", + "description": "Pre-auth session request", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/api.SubscriptionReq" + "$ref": "#/definitions/api.PASessionReq" } } ], @@ -2360,6 +2400,25 @@ const docTemplate = `{ } } }, + "api.PASessionReq": { + "type": "object", + "required": [ + "id", + "preauth_id", + "token" + ], + "properties": { + "id": { + "type": "string" + }, + "preauth_id": { + "type": "string" + }, + "token": { + "type": "string" + } + } + }, "api.RecipientReq": { "type": "object", "required": [ @@ -2394,6 +2453,17 @@ const docTemplate = `{ } } }, + "api.RotatePASessionReq": { + "type": "object", + "required": [ + "sessionid" + ], + "properties": { + "sessionid": { + "type": "string" + } + } + }, "api.SettingsReq": { "type": "object", "required": [ @@ -2459,14 +2529,14 @@ const docTemplate = `{ "api.SubscriptionReq": { "type": "object", "required": [ - "active_until", - "id" + "id", + "subid" ], "properties": { - "active_until": { + "id": { "type": "string" }, - "id": { + "subid": { "type": "string" } } @@ -2721,20 +2791,33 @@ const docTemplate = `{ "id": { "type": "string" }, - "type": { - "$ref": "#/definitions/model.SubscriptionType" + "outage": { + "type": "boolean" + }, + "status": { + "$ref": "#/definitions/model.SubscriptionStatus" + }, + "tier": { + "type": "string" + }, + "updated_at": { + "type": "string" } } }, - "model.SubscriptionType": { + "model.SubscriptionStatus": { "type": "string", "enum": [ - "Free", - "Managed" + "active", + "grace_period", + "limited_access", + "pending_delete" ], "x-enum-varnames": [ - "Free", - "Managed" + "Active", + "GracePeriod", + "LimitedAccess", + "PendingDelete" ] }, "model.TOTPBackup": { diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 0d3688a..9bb1133 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1476,6 +1476,46 @@ } } }, + "/rotatepasession": { + "put": { + "description": "Rotate pre-auth session ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscription" + ], + "summary": "Rotate pre-auth session ID", + "parameters": [ + { + "description": "Rotate pre-auth session request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.RotatePASessionReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.SuccessRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.ErrorRes" + } + } + } + } + }, "/settings": { "get": { "security": [ @@ -1587,14 +1627,14 @@ } } }, - "/subscription/add": { + "/sub/session": { "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Add subscription", + "description": "Add pre-auth session", "consumes": [ "application/json" ], @@ -1604,15 +1644,15 @@ "tags": [ "subscription" ], - "summary": "Add subscription", + "summary": "Add pre-auth session", "parameters": [ { - "description": "Subscription request", + "description": "Pre-auth session request", "name": "body", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/api.SubscriptionReq" + "$ref": "#/definitions/api.PASessionReq" } } ], @@ -2349,6 +2389,25 @@ } } }, + "api.PASessionReq": { + "type": "object", + "required": [ + "id", + "preauth_id", + "token" + ], + "properties": { + "id": { + "type": "string" + }, + "preauth_id": { + "type": "string" + }, + "token": { + "type": "string" + } + } + }, "api.RecipientReq": { "type": "object", "required": [ @@ -2383,6 +2442,17 @@ } } }, + "api.RotatePASessionReq": { + "type": "object", + "required": [ + "sessionid" + ], + "properties": { + "sessionid": { + "type": "string" + } + } + }, "api.SettingsReq": { "type": "object", "required": [ @@ -2448,14 +2518,14 @@ "api.SubscriptionReq": { "type": "object", "required": [ - "active_until", - "id" + "id", + "subid" ], "properties": { - "active_until": { + "id": { "type": "string" }, - "id": { + "subid": { "type": "string" } } @@ -2710,20 +2780,33 @@ "id": { "type": "string" }, - "type": { - "$ref": "#/definitions/model.SubscriptionType" + "outage": { + "type": "boolean" + }, + "status": { + "$ref": "#/definitions/model.SubscriptionStatus" + }, + "tier": { + "type": "string" + }, + "updated_at": { + "type": "string" } } }, - "model.SubscriptionType": { + "model.SubscriptionStatus": { "type": "string", "enum": [ - "Free", - "Managed" + "active", + "grace_period", + "limited_access", + "pending_delete" ], "x-enum-varnames": [ - "Free", - "Managed" + "Active", + "GracePeriod", + "LimitedAccess", + "PendingDelete" ] }, "model.TOTPBackup": { diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index b48fbd4..9085b4d 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -75,6 +75,19 @@ definitions: error: type: string type: object + api.PASessionReq: + properties: + id: + type: string + preauth_id: + type: string + token: + type: string + required: + - id + - preauth_id + - token + type: object api.RecipientReq: properties: id: @@ -97,6 +110,13 @@ definitions: required: - otp type: object + api.RotatePASessionReq: + properties: + sessionid: + type: string + required: + - sessionid + type: object api.SettingsReq: properties: alias_format: @@ -140,13 +160,13 @@ definitions: type: object api.SubscriptionReq: properties: - active_until: - type: string id: type: string + subid: + type: string required: - - active_until - id + - subid type: object api.SuccessRes: properties: @@ -314,17 +334,27 @@ definitions: type: string id: type: string - type: - $ref: '#/definitions/model.SubscriptionType' + outage: + type: boolean + status: + $ref: '#/definitions/model.SubscriptionStatus' + tier: + type: string + updated_at: + type: string type: object - model.SubscriptionType: + model.SubscriptionStatus: enum: - - Free - - Managed + - active + - grace_period + - limited_access + - pending_delete type: string x-enum-varnames: - - Free - - Managed + - Active + - GracePeriod + - LimitedAccess + - PendingDelete model.TOTPBackup: properties: backup: @@ -1306,6 +1336,32 @@ paths: summary: Reset password tags: - user + /rotatepasession: + put: + consumes: + - application/json + description: Rotate pre-auth session ID + parameters: + - description: Rotate pre-auth session request + in: body + name: body + required: true + schema: + $ref: '#/definitions/api.RotatePASessionReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.SuccessRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.ErrorRes' + summary: Rotate pre-auth session ID + tags: + - subscription /settings: get: consumes: @@ -1375,18 +1431,18 @@ paths: summary: Get subscription tags: - subscription - /subscription/add: + /sub/session: post: consumes: - application/json - description: Add subscription + description: Add pre-auth session parameters: - - description: Subscription request + - description: Pre-auth session request in: body name: body required: true schema: - $ref: '#/definitions/api.SubscriptionReq' + $ref: '#/definitions/api.PASessionReq' produces: - application/json responses: @@ -1400,7 +1456,7 @@ paths: $ref: '#/definitions/api.ErrorRes' security: - ApiKeyAuth: [] - summary: Add subscription + summary: Add pre-auth session tags: - subscription /subscription/update: diff --git a/api/internal/client/http/http.go b/api/internal/client/http/http.go index bcef7fd..cbf0017 100644 --- a/api/internal/client/http/http.go +++ b/api/internal/client/http/http.go @@ -1,12 +1,14 @@ package http import ( + "encoding/json" "errors" "log" "net/http" "github.com/gofiber/fiber/v2" "ivpn.net/email/api/config" + "ivpn.net/email/api/internal/model" ) type Http struct { @@ -24,18 +26,49 @@ func (h Http) SignupWebhook(subID string) error { req.Set("Content-Type", "application/json") req.Set("Accept", "application/json") req.Set("Authorization", "Bearer "+h.Cfg.SignupWebhookPSK) - req.Body([]byte(`{"uuid": "` + subID + `"}`)) + req.Body([]byte(`{"uuid": "` + subID + `", "service": "mail"}`)) - status, _, err := req.Bytes() + // Log request for debugging + log.Printf("Signup webhook request: %+v", req) + + status, res, err := req.Bytes() if err != nil { log.Printf("Error calling signup webhook: %v", err) return errors.New("error calling signup webhook") } if status != http.StatusOK { + // Log response for debugging log.Printf("Error calling signup webhook, status: %d", status) + log.Printf("Signup webhook response: %s", string(res)) return errors.New("error response from signup webhook") } return nil } + +func (h Http) GetPreauth(ID string) (model.Preauth, error) { + req := fiber.Get(h.Cfg.PreauthURL + "/" + ID) + req.Set("Content-Type", "application/json") + req.Set("Accept", "application/json") + req.Set("Authorization", "Bearer "+h.Cfg.PreauthPSK) + + var preauth model.Preauth + status, body, err := req.Bytes() + if err != nil { + log.Printf("Error calling preauth service: %v", err) + return model.Preauth{}, errors.New("error calling preauth service") + } + + if status != http.StatusOK { + log.Printf("Error calling preauth service, status: %d", status) + return model.Preauth{}, errors.New("error response from preauth service") + } + + if err := json.Unmarshal(body, &preauth); err != nil { + log.Printf("Error parsing preauth response: %v", err) + return model.Preauth{}, errors.New("error parsing preauth response") + } + + return preauth, nil +} diff --git a/api/internal/client/mailer/templates/expiring_sub.tmpl b/api/internal/client/mailer/templates/expiring_sub.tmpl new file mode 100644 index 0000000..d8920d6 --- /dev/null +++ b/api/internal/client/mailer/templates/expiring_sub.tmpl @@ -0,0 +1,24 @@ +{{define "body"}} +Hello, + +Your {{.from}} account is in limited access mode. + +You cannot create new aliases or recipients, reply to forwards or send emails via {{.from}}. + +Incoming forwards are still processed for 14 days from the date of this message. + +To regain full access with no restrictions, add time to your IVPN account. + +Sent by {{.from}} +{{end}} + +{{define "bodyHtml"}} +
+Hello,

+Your {{.from}} account is in limited access mode.

+You cannot create new aliases or recipients, reply to forwards or send emails via {{.from}}.

+Incoming forwards are still processed for 14 days from the date of this message.

+To regain full access with no restrictions, add time to your IVPN account.

+Sent by {{.from}} +
+{{end}} diff --git a/api/internal/cron/cron.go b/api/internal/cron/cron.go index 3d4b4ea..0d43170 100644 --- a/api/internal/cron/cron.go +++ b/api/internal/cron/cron.go @@ -52,6 +52,12 @@ func New(db *gorm.DB) { return } + err = gocron.Every(1).Hour().Do(jobs.NotifyExpiringSubscriptionsJob, cfg, db) + if err != nil { + log.Println("Error scheduling job:", err) + return + } + err = gocron.Every(1).Hour().Do(jobs.DeleteOldLogs, db) if err != nil { log.Println("Error scheduling job:", err) diff --git a/api/internal/cron/jobs/subscription.go b/api/internal/cron/jobs/subscription.go new file mode 100644 index 0000000..191dd7c --- /dev/null +++ b/api/internal/cron/jobs/subscription.go @@ -0,0 +1,108 @@ +package jobs + +import ( + "log" + + "gorm.io/gorm" + "ivpn.net/email/api/config" + "ivpn.net/email/api/internal/client/mailer" + "ivpn.net/email/api/internal/model" + "ivpn.net/email/api/internal/utils" +) + +func NotifyExpiringSubscriptionsJob(cfg config.Config, db *gorm.DB) { + // Reset `notified` for active subscriptions + UpdateActiveSubscriptions(db) + + // Get expiring subscriptions + subs, err := GetExpiringSubscriptions(db) + if err != nil { + log.Println("Error getting expiring subscriptions:", err) + return + } + + if len(subs) == 0 { + return + } + + // Send notifications + log.Printf("Notifying %d expiring subscriptions...", len(subs)) + NotifyExpiringSubscriptions(cfg, db, subs) + + // Mark as notified + MarkSubscriptionsNotified(db, subs) +} + +// Set `notified` to false for all subscriptions that are active +func UpdateActiveSubscriptions(db *gorm.DB) { + err := db.Model(&model.Subscription{}). + Where("active_until >= NOW()"). + UpdateColumn("notified", false).Error + if err != nil { + log.Println("Error resetting notified flag for active subscriptions:", err) + } +} + +// Find subscriptions with `notified` false and `active_until` expired 1 day ago +func GetExpiringSubscriptions(db *gorm.DB) ([]model.Subscription, error) { + subs := []model.Subscription{} + err := db.Where("notified = false AND active_until < NOW() - INTERVAL 1 DAY").Find(&subs).Error + if err != nil { + log.Println("Error fetching expiring subscriptions:", err) + return nil, err + } + + return subs, nil +} + +// Send email notifications for expiring subscriptions +func NotifyExpiringSubscriptions(cfg config.Config, db *gorm.DB, subs []model.Subscription) { + for _, sub := range subs { + // Send email notification + err := sendSubscriptionExpiryEmail(cfg, db, sub) + if err != nil { + log.Println("Error sending subscription expiry email:", err) + continue + } + + } +} + +// Mark expiring subscriptions as notified +func MarkSubscriptionsNotified(db *gorm.DB, subs []model.Subscription) { + ids := make([]string, 0, len(subs)) + for _, sub := range subs { + ids = append(ids, sub.ID) + } + + err := db.Model(&model.Subscription{}). + Where("id IN ?", ids). + UpdateColumn("notified", true).Error + if err != nil { + log.Println("Error marking subscriptions as notified:", err) + } +} + +// Send subscription expiry email +func sendSubscriptionExpiryEmail(cfg config.Config, db *gorm.DB, sub model.Subscription) error { + user := model.User{} + err := db.Where("id = ?", sub.UserID).First(&user).Error + if err != nil { + return err + } + + utils.Background(func() { + data := map[string]any{ + "from": cfg.SMTPClient.SenderName, + } + mailer := mailer.New(cfg.SMTPClient) + mailer.Sender = cfg.SMTPClient.Sender + mailer.SenderName = cfg.SMTPClient.SenderName + err = mailer.SendTemplate(user.Email, "Limited Access Mode", "expiring_sub.tmpl", data) + if err != nil { + log.Printf("error sending expiring subscription email: %s", err.Error()) + } + }) + + return nil +} diff --git a/api/internal/middleware/auth/auth.go b/api/internal/middleware/auth/auth.go index d979559..8255a7f 100644 --- a/api/internal/middleware/auth/auth.go +++ b/api/internal/middleware/auth/auth.go @@ -24,6 +24,7 @@ const ( AUTH_COOKIE = "auth" AUTHN_COOKIE = "authn" AUTHN_TEMP_COOKIE = "authntemp" + PA_SESSION_COOKIE = "pasession" USER_ID = "user_id" ) @@ -124,6 +125,17 @@ func NewCookieTempAuthn(token string, path string, cfg config.APIConfig) *fiber. } } +func NewCookiePASession(id string) *fiber.Cookie { + return &fiber.Cookie{ + Name: PA_SESSION_COOKIE, + Value: id, + HTTPOnly: true, + Secure: true, + MaxAge: 900, // 15 minutes + Expires: time.Now().Add(15 * time.Minute), + } +} + func NewWebAuthn(cfg config.APIConfig) *webauthn.WebAuthn { var webAuthn *webauthn.WebAuthn config := &webauthn.Config{ diff --git a/api/internal/model/log.go b/api/internal/model/log.go index 1f370d9..90a64ed 100644 --- a/api/internal/model/log.go +++ b/api/internal/model/log.go @@ -5,9 +5,10 @@ import "time" type LogType string const ( - BounceMessage LogType = "bounce" - DisabledAlias LogType = "disabled_alias" - UnauthorisedSend LogType = "unauthorised_send" + BounceMessage LogType = "bounce" + DisabledAlias LogType = "disabled_alias" + UnauthorisedSend LogType = "unauthorised_send" + InactiveSubscription LogType = "inactive_subscription" ) type Log struct { diff --git a/api/internal/model/pa_session.go b/api/internal/model/pa_session.go new file mode 100644 index 0000000..e71f25d --- /dev/null +++ b/api/internal/model/pa_session.go @@ -0,0 +1,7 @@ +package model + +type PASession struct { + ID string `json:"id"` + Token string `json:"token"` + PreauthId string `json:"preauth_id"` +} diff --git a/api/internal/model/preauth.go b/api/internal/model/preauth.go new file mode 100644 index 0000000..9d33b88 --- /dev/null +++ b/api/internal/model/preauth.go @@ -0,0 +1,11 @@ +package model + +import "time" + +type Preauth struct { + ID string `json:"id"` + TokenHash string `json:"token_hash"` + IsActive bool `json:"is_active"` + ActiveUntil time.Time `json:"active_until"` + Tier string `json:"tier"` +} diff --git a/api/internal/model/subscription.go b/api/internal/model/subscription.go index 3a006e1..f94007f 100644 --- a/api/internal/model/subscription.go +++ b/api/internal/model/subscription.go @@ -2,33 +2,75 @@ package model import ( "errors" + "strings" "time" ) -type SubscriptionType string - -const ( - Free SubscriptionType = "Free" - Managed SubscriptionType = "Managed" -) - var ( ErrDuplicateSubscription = errors.New("subscription already exists") ) +type SubscriptionStatus string + +const ( + Active SubscriptionStatus = "active" + GracePeriod SubscriptionStatus = "grace_period" + LimitedAccess SubscriptionStatus = "limited_access" + PendingDelete SubscriptionStatus = "pending_delete" + Tier1 string = "Tier 1" +) + type Subscription struct { - ID string `gorm:"unique" json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"-"` - UserID string `json:"-"` - Type SubscriptionType `json:"type"` - ActiveUntil time.Time `json:"active_until"` + ID string `gorm:"unique" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + UserID string `json:"-"` + 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"` +} + +func (s *Subscription) Active() bool { + return s.ActiveUntil.After(time.Now()) && !strings.Contains(s.Tier, Tier1) } -func (s *Subscription) IsActive() bool { - return s.ActiveUntil.After(time.Now()) +func (s *Subscription) GracePeriod() bool { + return s.IsOutage() && s.GracePeriodDays(3) } -func (s *Subscription) IsActiveWithGracePeriod(days int) bool { +func (s *Subscription) LimitedAccess() bool { + return s.GracePeriodDays(14) +} + +func (s *Subscription) PendingDelete() bool { + return !s.GracePeriodDays(14) +} + +func (s *Subscription) ActiveStatus() bool { + return s.Active() || s.GracePeriod() +} + +func (s *Subscription) IsOutage() bool { + return s.UpdatedAt.Add(time.Duration(48) * time.Hour).Before(time.Now()) +} + +func (s *Subscription) GracePeriodDays(days int) bool { return s.ActiveUntil.AddDate(0, 0, days).After(time.Now()) } + +func (s *Subscription) GetStatus() SubscriptionStatus { + if s.Active() { + return Active + } + if s.GracePeriod() { + return GracePeriod + } + if s.LimitedAccess() { + return LimitedAccess + } + return PendingDelete +} diff --git a/api/internal/model/subscription_test.go b/api/internal/model/subscription_test.go index db337cd..0f798ac 100644 --- a/api/internal/model/subscription_test.go +++ b/api/internal/model/subscription_test.go @@ -5,86 +5,481 @@ import ( "time" ) -func TestSubscription_IsActive(t *testing.T) { +func TestSubscriptionActive(t *testing.T) { + now := time.Now() + tests := []struct { name string activeUntil time.Time + tier string want bool }{ { - name: "active subscription", - activeUntil: time.Now().Add(24 * time.Hour), // 1 day in the future + name: "future time with Tier 2 returns true", + activeUntil: now.Add(2 * time.Second), + tier: "Tier 2", want: true, }, { - name: "expired subscription", - activeUntil: time.Now().Add(-24 * time.Hour), // 1 day in the past + name: "past time returns false", + activeUntil: now.Add(-2 * time.Second), + tier: "Tier 2", want: false, }, { - name: "subscription expires now", - activeUntil: time.Now(), - want: false, // time.Now() is not After time.Now() + name: "equal to now returns false", + activeUntil: now, + tier: "Tier 2", + want: false, + }, + { + name: "future time but Tier 1 returns false", + activeUntil: now.Add(2 * time.Second), + tier: "Tier 1", + want: false, + }, + { + name: "future time but tier contains Tier 1 returns false", + activeUntil: now.Add(2 * time.Second), + tier: "Plan Tier 1 Special", + want: false, + }, + { + name: "future time with empty tier returns true", + activeUntil: now.Add(2 * time.Second), + tier: "", + want: true, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := &Subscription{ActiveUntil: tc.activeUntil, Tier: tc.tier} + got := s.Active() + if got != tc.want { + t.Fatalf("Active() = %v, want %v (activeUntil=%v, tier=%q, now=%v)", got, tc.want, tc.activeUntil, tc.tier, time.Now()) + } + }) + } +} + +func TestSubscriptionGracePeriod(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + updatedAt time.Time + activeUntil time.Time + want bool + }{ + { + name: "outage and within 3-day window => true", + updatedAt: now.Add(-49 * time.Hour), // outage (older than 48h) + activeUntil: now.Add(-48 * time.Hour), // 2 days ago (+3d => +1d > now) + want: true, + }, + { + name: "not outage and within 3-day window => false", + updatedAt: now.Add(-47 * time.Hour), // not outage + activeUntil: now.Add(-24 * time.Hour), // 1 day ago (+3d => +2d > now) + want: false, + }, + { + name: "outage but outside 3-day window => false", + updatedAt: now.Add(-50 * time.Hour), // outage + activeUntil: now.Add(-5 * 24 * time.Hour), // 5 days ago (+3d => 2 days before now) + want: false, + }, + { + name: "near outage boundary but not outage => false", + updatedAt: now.Add(-48 * time.Hour).Add(1 * time.Second), // UpdatedAt +48h ~ now +1s => not outage + activeUntil: now.Add(-2 * 24 * time.Hour), // 2 days ago (+3d => +1d > now) + want: false, + }, + { + name: "outage and just inside 3-day window boundary => true", + updatedAt: now.Add(-49 * time.Hour), // outage + activeUntil: now.AddDate(0, 0, -3).Add(2 * time.Second), // ActiveUntil +3d ~ now +2s > now + want: true, + }, + { + name: "outage and exactly outside 3-day window => false", + updatedAt: now.Add(-49 * time.Hour), // outage + activeUntil: now.AddDate(0, 0, -3).Add(-2 * time.Second), // ActiveUntil +3d ~ now -2s < now + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { s := &Subscription{ - ActiveUntil: tt.activeUntil, + UpdatedAt: tc.updatedAt, + ActiveUntil: tc.activeUntil, } - if got := s.IsActive(); got != tt.want { - t.Errorf("Subscription.IsActive() = %v, want %v", got, tt.want) + got := s.GracePeriod() + if got != tc.want { + t.Fatalf("GracePeriod() = %v, want %v (updatedAt=%v activeUntil=%v now=%v)", got, tc.want, tc.updatedAt, tc.activeUntil, time.Now()) } }) } } -func TestSubscription_IsActiveWithGracePeriod(t *testing.T) { + +func TestSubscriptionLimitedAccess(t *testing.T) { + now := time.Now() + tests := []struct { name string activeUntil time.Time - graceDays int want bool }{ { - name: "active subscription", - activeUntil: time.Now().Add(24 * time.Hour), // 1 day in the future - graceDays: 0, + name: "active in future => true", + activeUntil: now.Add(1 * time.Hour), + want: true, + }, + { + name: "just expired 1s ago (<14d) => true", + activeUntil: now.Add(-1 * time.Second), want: true, }, { - name: "expired subscription but within grace period", - activeUntil: time.Now().Add(-2 * 24 * time.Hour), // 2 days in the past - graceDays: 3, + name: "13d 23h 59m 59s ago (<14d) => true", + activeUntil: now.Add(-14*24*time.Hour + 1*time.Second), want: true, }, { - name: "expired subscription outside grace period", - activeUntil: time.Now().Add(-5 * 24 * time.Hour), // 5 days in the past - graceDays: 3, + name: "exactly 14d ago boundary => false", + activeUntil: now.Add(-14 * 24 * time.Hour), + want: false, + }, + { + name: "14d and 1s ago => false", + activeUntil: now.Add(-14*24*time.Hour - 1*time.Second), want: false, }, { - name: "subscription expires today with grace period", - activeUntil: time.Now(), - graceDays: 1, + name: "30d ago => false", + activeUntil: now.Add(-30 * 24 * time.Hour), + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := &Subscription{ActiveUntil: tc.activeUntil} + got := s.LimitedAccess() + if got != tc.want { + t.Fatalf("LimitedAccess() = %v, want %v (activeUntil=%v now=%v)", got, tc.want, tc.activeUntil, time.Now()) + } + }) + } +} + +func TestSubscriptionPendingDelete(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + activeUntil time.Time + want bool + }{ + { + name: "still active in future => false", + activeUntil: now.Add(1 * time.Hour), + want: false, + }, + { + name: "expired 1s ago (<14d) => false", + activeUntil: now.Add(-1 * time.Second), + want: false, + }, + { + name: "13d 23h 59m 59s ago (<14d) => false", + activeUntil: now.Add(-14*24*time.Hour + 1*time.Second), + want: false, + }, + { + name: "exactly 14d ago boundary => true", + activeUntil: now.Add(-14 * 24 * time.Hour), + want: true, + }, + { + name: "14d and 1s ago => true", + activeUntil: now.Add(-14*24*time.Hour - 1*time.Second), + want: true, + }, + { + name: "30d ago => true", + activeUntil: now.Add(-30 * 24 * time.Hour), want: true, }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := &Subscription{ActiveUntil: tc.activeUntil} + got := s.PendingDelete() + if got != tc.want { + t.Fatalf("PendingDelete() = %v, want %v (activeUntil=%v now=%v)", got, tc.want, tc.activeUntil, time.Now()) + } + }) + } +} + +func TestSubscriptionIsOutage(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + updatedAt time.Time + want bool + }{ + { + name: "updated 49h ago => outage", + updatedAt: now.Add(-49 * time.Hour), + want: true, + }, + { + name: "updated 48h + 1s ago => outage", + updatedAt: now.Add(-48*time.Hour - 1*time.Second), + want: true, + }, + { + name: "updated 48h - 1s ago => not outage", + updatedAt: now.Add(-48*time.Hour + 1*time.Second), + want: false, + }, + { + name: "updated 47h ago => not outage", + updatedAt: now.Add(-47 * time.Hour), + want: false, + }, + { + name: "just updated (now) => not outage", + updatedAt: now, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := &Subscription{UpdatedAt: tc.updatedAt} + got := s.IsOutage() + if got != tc.want { + t.Fatalf("IsOutage() = %v, want %v (updatedAt=%v now=%v threshold=%v)", got, tc.want, tc.updatedAt, time.Now(), tc.updatedAt.Add(48*time.Hour)) + } + }) + } +} + +func TestSubscriptionGracePeriodDays(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + activeUntil time.Time + days int + want bool + }{ + { + name: "future activeUntil with 3 days window => true", + activeUntil: now.Add(2 * time.Hour), + days: 3, + want: true, + }, + { + name: "exact boundary 3 days ago => false", + activeUntil: now.AddDate(0, 0, -3), + days: 3, + want: false, + }, + { + name: "just inside boundary 3 days => true", + activeUntil: now.AddDate(0, 0, -3).Add(1 * time.Second), + days: 3, + want: true, + }, + { + name: "just outside boundary 3 days => false", + activeUntil: now.AddDate(0, 0, -3).Add(-1 * time.Second), + days: 3, + want: false, + }, + { + name: "exact boundary 14 days => false", + activeUntil: now.AddDate(0, 0, -14), + days: 14, + want: false, + }, + { + name: "just inside boundary 14 days => true", + activeUntil: now.AddDate(0, 0, -14).Add(1 * time.Second), + days: 14, + want: true, + }, + { + name: "past boundary 14 days => false", + activeUntil: now.AddDate(0, 0, -14).Add(-1 * time.Second), + days: 14, + want: false, + }, + { + name: "exact boundary 1 day => false", + activeUntil: now.Add(-24 * time.Hour), + days: 1, + want: false, + }, + { + name: "just inside boundary 1 day => true", + activeUntil: now.Add(-24*time.Hour + 1*time.Second), + days: 1, + want: true, + }, + { + name: "far past even with large window => false", + activeUntil: now.AddDate(0, 0, -30), + days: 14, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := &Subscription{ActiveUntil: tc.activeUntil} + got := s.GracePeriodDays(tc.days) + if got != tc.want { + t.Fatalf("GracePeriodDays(%d) = %v, want %v (activeUntil=%v now=%v boundary=%v)", tc.days, got, tc.want, tc.activeUntil, time.Now(), tc.activeUntil.AddDate(0, 0, tc.days)) + } + }) + } +} + +func TestSubscriptionActiveStatus(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + updatedAt time.Time + activeUntil time.Time + tier string + want bool + }{ + { + name: "active subscription => true", + updatedAt: now.Add(-24 * time.Hour), + activeUntil: now.Add(24 * time.Hour), + tier: "Tier 2", + want: true, + }, + { + name: "in grace period => true", + updatedAt: now.Add(-49 * time.Hour), + activeUntil: now.Add(-48 * time.Hour), + tier: "Tier 2", + want: true, + }, + { + name: "expired and no outage => false", + updatedAt: now.Add(-24 * time.Hour), + activeUntil: now.Add(-1 * time.Second), + tier: "Tier 2", + want: false, + }, + { + name: "Tier 1 even if has future time => false (not Active, but may be GracePeriod)", + updatedAt: now.Add(-49 * time.Hour), + activeUntil: now.Add(24 * time.Hour), + tier: "Tier 1", + want: true, // GracePeriod returns true if outage and within 3-day window + }, + { + name: "Tier 1 without outage => false", + updatedAt: now.Add(-24 * time.Hour), + activeUntil: now.Add(24 * time.Hour), + tier: "Tier 1", + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := &Subscription{ + UpdatedAt: tc.updatedAt, + ActiveUntil: tc.activeUntil, + Tier: tc.tier, + } + got := s.ActiveStatus() + if got != tc.want { + t.Fatalf("ActiveStatus() = %v, want %v (updatedAt=%v activeUntil=%v tier=%q now=%v)", got, tc.want, tc.updatedAt, tc.activeUntil, tc.tier, time.Now()) + } + }) + } +} + +func TestSubscriptionGetStatus(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + updatedAt time.Time + activeUntil time.Time + tier string + want SubscriptionStatus + }{ + { + name: "active tier 2 subscription => Active", + updatedAt: now.Add(-24 * time.Hour), + activeUntil: now.Add(24 * time.Hour), + tier: "Tier 2", + want: Active, + }, + { + name: "in grace period => GracePeriod", + updatedAt: now.Add(-49 * time.Hour), + activeUntil: now.Add(-48 * time.Hour), + tier: "Tier 2", + want: GracePeriod, + }, + { + name: "limited access (14d window) => LimitedAccess", + updatedAt: now.Add(-24 * time.Hour), + activeUntil: now.Add(-5 * 24 * time.Hour), + tier: "Tier 2", + want: LimitedAccess, + }, + { + name: "pending delete (>14d) => PendingDelete", + updatedAt: now.Add(-24 * time.Hour), + activeUntil: now.Add(-30 * 24 * time.Hour), + tier: "Tier 2", + want: PendingDelete, + }, + { + name: "Tier 1 with future time and outage => GracePeriod", + updatedAt: now.Add(-49 * time.Hour), + activeUntil: now.Add(24 * time.Hour), + tier: "Tier 1", + want: GracePeriod, + }, { - name: "subscription expires today without grace period", - activeUntil: time.Now(), - graceDays: 0, - want: false, // time.Now() is not After time.Now() + name: "Tier 1 without outage but within 14d => LimitedAccess", + updatedAt: now.Add(-24 * time.Hour), + activeUntil: now.Add(-5 * 24 * time.Hour), + tier: "Tier 1", + want: LimitedAccess, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { s := &Subscription{ - ActiveUntil: tt.activeUntil, + UpdatedAt: tc.updatedAt, + ActiveUntil: tc.activeUntil, + Tier: tc.tier, } - if got := s.IsActiveWithGracePeriod(tt.graceDays); got != tt.want { - t.Errorf("Subscription.IsActiveWithGracePeriod(%v) = %v, want %v", tt.graceDays, got, tt.want) + got := s.GetStatus() + if got != tc.want { + t.Fatalf("GetStatus() = %v, want %v (updatedAt=%v activeUntil=%v tier=%q now=%v)", got, tc.want, tc.updatedAt, tc.activeUntil, tc.tier, time.Now()) } }) } diff --git a/api/internal/repository/subscription.go b/api/internal/repository/subscription.go index f0aea7d..bea472c 100644 --- a/api/internal/repository/subscription.go +++ b/api/internal/repository/subscription.go @@ -17,19 +17,12 @@ func (d *Database) GetSubscription(ctx context.Context, userID string) (model.Su return subscription, q.Error } -func (d *Database) PostSubscription(ctx context.Context, subscription model.Subscription) error { - return d.Client.Create(&subscription).Error +func (d *Database) PostSubscription(ctx context.Context, sub model.Subscription) error { + return d.Client.Create(&sub).Error } -func (d *Database) UpdateSubscription(ctx context.Context, subscription model.Subscription) error { - sub := model.Subscription{} - sub.ID = subscription.ID - err := d.Client.First(&sub).Error - if err != nil { - return err - } - - return d.Client.Updates(subscription).Error +func (d *Database) UpdateSubscription(ctx context.Context, sub model.Subscription) error { + return d.Client.Select("*").Updates(&sub).Error } func (d *Database) DeleteSubscription(ctx context.Context, userID string) error { diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go index f699bf3..d5b5bf6 100644 --- a/api/internal/service/alias.go +++ b/api/internal/service/alias.go @@ -95,8 +95,7 @@ func (s *Service) PostAlias(ctx context.Context, alias model.Alias, format strin return model.Alias{}, ErrPostAlias } - if !sub.IsActive() { - log.Println("error creating alias: subscription is not active") + if !sub.ActiveStatus() { return model.Alias{}, ErrPostAlias } diff --git a/api/internal/service/processor.go b/api/internal/service/processor.go index 7d7c9e9..c65a148 100644 --- a/api/internal/service/processor.go +++ b/api/internal/service/processor.go @@ -102,14 +102,38 @@ func (s *Service) ProcessMessage(data []byte) error { } // Forward - if relayType == model.Forward && !sub.IsActiveWithGracePeriod(s.Cfg.Service.ForwardGracePeriodDays) { - log.Println("inactive subscription for forward") + if relayType == model.Forward && sub.PendingDelete() { + settings, err := s.GetSettings(context.Background(), alias.UserID) + if err != nil { + log.Println("error getting settings", err) + continue + } + + if settings.LogIssues { + err := s.ProcessDiscardLog(alias, msg.From, to, ErrInactiveSubscription.Error(), model.InactiveSubscription) + if err != nil { + log.Println("error processing discard log", err) + } + } + continue } // Reply | Send - if relayType != model.Forward && !sub.IsActive() { - log.Println("inactive subscription for reply/send") + if relayType != model.Forward && !sub.ActiveStatus() { + settings, err := s.GetSettings(context.Background(), alias.UserID) + if err != nil { + log.Println("error getting settings", err) + continue + } + + if settings.LogIssues { + err := s.ProcessDiscardLog(alias, msg.From, to, ErrInactiveSubscription.Error(), model.InactiveSubscription) + if err != nil { + log.Println("error processing discard log", err) + } + } + continue } diff --git a/api/internal/service/recipient.go b/api/internal/service/recipient.go index 347ba4e..212f457 100644 --- a/api/internal/service/recipient.go +++ b/api/internal/service/recipient.go @@ -84,7 +84,7 @@ func (s *Service) PostRecipient(ctx context.Context, recipient model.Recipient) return ErrPostRecipient } - if !sub.IsActive() { + if !sub.ActiveStatus() { log.Println("error creating recipient: subscription is not active") return ErrPostRecipient } @@ -182,7 +182,7 @@ func (s *Service) UpdateRecipient(ctx context.Context, recipient model.Recipient return ErrUpdateRecipient } - if !sub.IsActive() { + if !sub.ActiveStatus() { log.Println("error updating recipient: subscription is not active") return ErrUpdateRecipient } diff --git a/api/internal/service/subscription.go b/api/internal/service/subscription.go index 1e07d2d..3504350 100644 --- a/api/internal/service/subscription.go +++ b/api/internal/service/subscription.go @@ -2,11 +2,15 @@ package service import ( "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" "errors" "log" + "time" - "github.com/araddon/dateparse" "github.com/go-sql-driver/mysql" + "github.com/google/uuid" "ivpn.net/email/api/internal/model" ) @@ -16,6 +20,8 @@ var ( ErrPostSubscription = errors.New("Unable to create subscription.") ErrUpdateSubscription = errors.New("Unable to update subscription.") ErrDeleteSubscription = errors.New("Unable to delete subscription.") + ErrPANotFound = errors.New("Pre-auth entry not found.") + ErrPASessionNotFound = errors.New("Pre-auth session not found.") ) type SubscriptionStore interface { @@ -26,29 +32,28 @@ type SubscriptionStore interface { } func (s *Service) GetSubscription(ctx context.Context, userID string) (model.Subscription, error) { - subscription, err := s.Store.GetSubscription(ctx, userID) + sub, err := s.Store.GetSubscription(ctx, userID) if err != nil { return model.Subscription{}, ErrGetSubscription } - return subscription, nil -} + sub.Status = sub.GetStatus() + sub.Outage = sub.IsOutage() -func (s *Service) PostSubscription(ctx context.Context, userID string, subID string, activeUntil string) error { - activeUntilTime, err := dateparse.ParseAny(activeUntil) - if err != nil { - log.Printf("error posting subscription: %s", err.Error()) - return ErrPostSubscription - } + return sub, nil +} +func (s *Service) PostSubscription(ctx context.Context, userID string, preauth model.Preauth) error { sub := model.Subscription{ - Type: model.Managed, UserID: userID, - ActiveUntil: activeUntilTime, + ActiveUntil: preauth.ActiveUntil, + IsActive: preauth.IsActive, + Tier: preauth.Tier, + TokenHash: preauth.TokenHash, } - sub.ID = subID + 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 @@ -59,12 +64,6 @@ func (s *Service) PostSubscription(ctx context.Context, userID string, subID str } } - err = s.Cache.Del(ctx, "sub_"+subID) - if err != nil { - log.Printf("error deleting subscription: %s", err.Error()) - return ErrPostSubscription - } - return nil } @@ -78,14 +77,51 @@ func (s *Service) AddSubscription(ctx context.Context, subscription model.Subscr return nil } -func (s *Service) UpdateSubscription(ctx context.Context, subscription model.Subscription) error { - subscription.Type = model.Managed - err := s.Store.UpdateSubscription(ctx, subscription) +func (s *Service) UpdateSubscription(ctx context.Context, sub model.Subscription, subID string, sessionId string) error { + paSession, err := s.GetPASession(ctx, sessionId) + if err != nil { + log.Printf("error updating subscription: %s", err.Error()) + return ErrPASessionNotFound + } + + preauthId := paSession.PreauthId + token := paSession.Token + tokenHash := sha256.Sum256([]byte(token)) + tokenHashStr := base64.StdEncoding.EncodeToString(tokenHash[:]) + + preauth, err := s.Http.GetPreauth(preauthId) + if err != nil { + log.Printf("error updating subscription: %s", err.Error()) + return ErrPANotFound + } + + if preauth.TokenHash != tokenHashStr { + log.Printf("error updating subscription: Token hash does not match") + return ErrTokenHashMismatch + } + + sub.ActiveUntil = preauth.ActiveUntil + sub.IsActive = preauth.IsActive + sub.Tier = preauth.Tier + sub.TokenHash = preauth.TokenHash + + if sub.ID == "" || sub.UserID == "" { + log.Printf("error updating subscription: Subscription ID is required") + return ErrInvalidSubscription + } + + err = s.Store.UpdateSubscription(ctx, sub) if err != nil { log.Printf("error updating subscription: %s", err.Error()) return ErrUpdateSubscription } + err = s.Http.SignupWebhook(subID) + if err != nil { + log.Printf("error updating subscription: %s", err.Error()) + return ErrSignupWebhook + } + return nil } @@ -98,3 +134,67 @@ func (s *Service) DeleteSubscription(ctx context.Context, userID string) error { return nil } + +func (s *Service) AddPASession(ctx context.Context, paSession model.PASession) error { + data, err := json.Marshal(paSession) + if err != nil { + log.Println("failed to marshal pre-auth session to JSON:", err) + return err + } + + err = s.Cache.Set(ctx, "pasession_"+paSession.ID, string(data), s.Cfg.API.PreauthTTL) + if err != nil { + log.Println("failed to set pre-auth session in Redis:", err) + return err + } + + return nil +} + +func (s *Service) GetPASession(ctx context.Context, id string) (model.PASession, error) { + data, err := s.Cache.Get(ctx, "pasession_"+id) + if err != nil { + log.Println("failed to get pre-auth session from Redis:", err) + return model.PASession{}, err + } + + var paSession model.PASession + err = json.Unmarshal([]byte(data), &paSession) + if err != nil { + log.Println("failed to unmarshal pre-auth session JSON:", err) + return model.PASession{}, err + } + + return paSession, nil +} + +func (s *Service) RotatePASessionId(ctx context.Context, id string) (string, error) { + paSession, err := s.GetPASession(ctx, id) + if err != nil { + log.Println("failed to get pre-auth session for rotation:", err) + return "", err + } + + newID := uuid.New().String() + paSession.ID = newID + + data, err := json.Marshal(paSession) + if err != nil { + log.Println("failed to marshal rotated pre-auth session to JSON:", err) + return "", err + } + + err = s.Cache.Set(ctx, "pasession_"+newID, string(data), 15*time.Minute) + if err != nil { + log.Println("failed to set rotated pre-auth session in Redis:", err) + return "", err + } + + err = s.Cache.Del(ctx, "pasession_"+id) + if err != nil { + log.Println("failed to delete old pre-auth session from Redis:", err) + return "", err + } + + return newID, nil +} diff --git a/api/internal/service/user.go b/api/internal/service/user.go index 1ffea82..cb30d1c 100644 --- a/api/internal/service/user.go +++ b/api/internal/service/user.go @@ -2,7 +2,9 @@ package service import ( "context" + "crypto/sha256" "encoding/base32" + "encoding/base64" "errors" "log" "strings" @@ -41,6 +43,7 @@ var ( ErrTotpDisable = errors.New("Unable to disable 2FA.") ErrInvalidTOTPCode = errors.New("The 2FA code you entered is invalid.") ErrInvalidSubscription = errors.New("Invalid subscription or signup URL.") + ErrTokenHashMismatch = errors.New("Subscription token hash does not match.") ) type UserStore interface { @@ -90,7 +93,7 @@ func (s *Service) GetUserByEmail(ctx context.Context, email string) (model.User, return user, nil } -func (s *Service) GetUnfinishedSignupOrPostUser(ctx context.Context, user model.User, subID string) (model.User, error) { +func (s *Service) GetUnfinishedSignupOrPostUser(ctx context.Context, user model.User, subID string, sessionId string) (model.User, error) { email := user.Email pass := user.PasswordPlain user, err := s.Store.GetUserByEmailUnfinishedSignup(ctx, email) @@ -100,7 +103,7 @@ func (s *Service) GetUnfinishedSignupOrPostUser(ctx context.Context, user model. PasswordPlain: pass, IsActive: false, } - err = s.PostUser(ctx, user, subID) + err = s.PostUser(ctx, user, subID, sessionId) if err != nil { log.Printf("error creating user: %s", err.Error()) return model.User{}, ErrPostUser @@ -134,12 +137,29 @@ func (s *Service) SaveUser(ctx context.Context, user model.User) error { return nil } -func (s *Service) PostUser(ctx context.Context, user model.User, subID string) error { - activeUntil, err := s.Cache.Get(ctx, "sub_"+subID) +func (s *Service) PostUser(ctx context.Context, user model.User, subID string, sessionId string) error { + paSession, err := s.GetPASession(ctx, sessionId) if err != nil { + log.Printf("error creating user: %s", err.Error()) + return ErrPASessionNotFound + } + + preauthId := paSession.PreauthId + token := paSession.Token + tokenHash := sha256.Sum256([]byte(token)) + tokenHashStr := base64.StdEncoding.EncodeToString(tokenHash[:]) + + preauth, err := s.Http.GetPreauth(preauthId) + if err != nil { + log.Printf("error creating user: %s", err.Error()) return ErrInvalidSubscription } + if preauth.TokenHash != tokenHashStr { + log.Printf("error creating user: Token hash does not match") + return ErrTokenHashMismatch + } + exists, err := s.Store.CheckDuplicateRecipient(ctx, user.Email) if exists || err != nil { log.Printf("error creating user: ErrDuplicateEmail") @@ -165,7 +185,7 @@ func (s *Service) PostUser(ctx context.Context, user model.User, subID string) e } } - err = s.PostSubscription(ctx, user.ID, subID, activeUntil) + err = s.PostSubscription(ctx, user.ID, preauth) if err != nil { log.Printf("error creating user: %s", err.Error()) return ErrPostUser @@ -256,7 +276,7 @@ func (s *Service) ActivateUser(ctx context.Context, ID string, otp string) error return nil } - if !sub.IsActive() { + if !sub.ActiveStatus() { log.Println("error creating recipient: subscription is not active") return nil } diff --git a/api/internal/transport/api/req.go b/api/internal/transport/api/req.go index 108fe35..bd576f5 100644 --- a/api/internal/transport/api/req.go +++ b/api/internal/transport/api/req.go @@ -26,8 +26,8 @@ type SignupEmailReq struct { } type SubscriptionReq struct { - ID string `json:"id" validate:"required,uuid"` - ActiveUntil string `json:"active_until" validate:"required"` + ID string `json:"id" validate:"required,uuid"` + SubID string `json:"subid" validate:"required,uuid"` } type AliasReq struct { @@ -82,6 +82,16 @@ type TotpReq struct { OTP string `json:"otp" validate:"required,min=6,max=8"` } +type PASessionReq struct { + ID string `json:"id" validate:"required,uuid"` + PreauthId string `json:"preauth_id" validate:"required,uuid"` + Token string `json:"token" validate:"required"` +} + +type RotatePASessionReq struct { + ID string `json:"sessionid" validate:"required,uuid"` +} + type AccessKeyReq struct { Name string `json:"name" validate:"required"` ExpiresAt string `json:"expires_at"` diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go index 5156d50..72a73dd 100644 --- a/api/internal/transport/api/routes.go +++ b/api/internal/transport/api/routes.go @@ -26,6 +26,7 @@ func (h *Handler) SetupRoutes(cfg config.APIConfig) { h.Server.Post("/v1/login", limit.New(5, 10*time.Minute), h.Login) h.Server.Post("/v1/initiatepasswordreset", limiter.New(), h.InitiatePasswordReset) h.Server.Put("/v1/resetpassword", limiter.New(), h.ResetPassword) + h.Server.Put("/v1/rotatepasession", limiter.New(), h.RotatePASession) h.Server.Post("/v1/api/authenticate", limit.New(5, 10*time.Minute), h.Authenticate) h.Server.Post("/v1/register/begin", limiter.New(), h.BeginRegistration) @@ -33,9 +34,9 @@ func (h *Handler) SetupRoutes(cfg config.APIConfig) { h.Server.Post("/v1/login/begin", limiter.New(), h.BeginLogin) h.Server.Post("/v1/login/finish", limiter.New(), h.FinishLogin) - sub := h.Server.Group("/v1/subscription") - sub.Use(auth.NewPSK(cfg)) - sub.Post("/add", h.AddSubscription) + session := h.Server.Group("/v1/pasession") + session.Use(auth.NewPSK(cfg)) + session.Post("/add", h.AddPASession) api := h.Server.Group("/v1/api") api.Use(auth.NewAPIAuth(cfg, h.Service)) @@ -67,6 +68,7 @@ func (h *Handler) SetupRoutes(cfg config.APIConfig) { v1.Put("/user/totp/disable", limit.New(5, 10*time.Minute), h.TotpDisable) v1.Get("/sub", h.GetSubscription) + v1.Put("/sub/update", limiter.New(), h.UpdateSubscription) v1.Get("/settings", h.GetSettings) v1.Put("/settings", h.UpdateSettings) diff --git a/api/internal/transport/api/subscription.go b/api/internal/transport/api/subscription.go index b0e7169..512610d 100644 --- a/api/internal/transport/api/subscription.go +++ b/api/internal/transport/api/subscription.go @@ -3,7 +3,6 @@ package api import ( "context" - "github.com/araddon/dateparse" "github.com/gofiber/fiber/v2" "ivpn.net/email/api/internal/middleware/auth" "ivpn.net/email/api/internal/model" @@ -12,12 +11,14 @@ import ( var ( UpdateSubscriptionSuccess = "Subscription updated successfully." AddSubscriptionSuccess = "Subscription added successfully." + InvalidPASessionId = "This signup link has expired." ) type SubscriptionService interface { GetSubscription(context.Context, string) (model.Subscription, error) - AddSubscription(context.Context, model.Subscription, string) error - UpdateSubscription(context.Context, model.Subscription) error + UpdateSubscription(context.Context, model.Subscription, string, string) error + AddPASession(context.Context, model.PASession) error + RotatePASessionId(context.Context, string) (string, error) } // @Summary Get subscription @@ -42,8 +43,8 @@ func (h *Handler) GetSubscription(c *fiber.Ctx) error { return c.JSON(sub) } -// @Summary Add subscription -// @Description Add subscription +// @Summary Update subscription +// @Description Update subscription // @Tags subscription // @Accept json // @Produce json @@ -51,8 +52,11 @@ func (h *Handler) GetSubscription(c *fiber.Ctx) error { // @Param body body SubscriptionReq true "Subscription request" // @Success 200 {object} SuccessRes // @Failure 400 {object} ErrorRes -// @Router /subscription/add [post] -func (h *Handler) AddSubscription(c *fiber.Ctx) error { +// @Router /subscription/update [put] +func (h *Handler) UpdateSubscription(c *fiber.Ctx) error { + sessionId := c.Cookies(auth.PA_SESSION_COOKIE) + userID := auth.GetUserID(c) + req := SubscriptionReq{} err := c.BodyParser(&req) if err != nil { @@ -70,8 +74,14 @@ func (h *Handler) AddSubscription(c *fiber.Ctx) error { sub := model.Subscription{} sub.ID = req.ID + sub, err = h.Service.GetSubscription(c.Context(), userID) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": err.Error(), + }) + } - err = h.Service.AddSubscription(c.Context(), sub, req.ActiveUntil) + err = h.Service.UpdateSubscription(c.Context(), sub, req.SubID, sessionId) if err != nil { return c.Status(400).JSON(fiber.Map{ "error": err.Error(), @@ -79,22 +89,22 @@ func (h *Handler) AddSubscription(c *fiber.Ctx) error { } return c.Status(200).JSON(fiber.Map{ - "message": AddSubscriptionSuccess, + "message": UpdateSubscriptionSuccess, }) } -// @Summary Update subscription -// @Description Update subscription +// @Summary Add pre-auth session +// @Description Add pre-auth session // @Tags subscription // @Accept json // @Produce json // @Security ApiKeyAuth -// @Param body body SubscriptionReq true "Subscription request" +// @Param body body PASessionReq true "Pre-auth session request" // @Success 200 {object} SuccessRes // @Failure 400 {object} ErrorRes -// @Router /subscription/update [put] -func (h *Handler) UpdateSubscription(c *fiber.Ctx) error { - req := SubscriptionReq{} +// @Router /sub/session [post] +func (h *Handler) AddPASession(c *fiber.Ctx) error { + req := PASessionReq{} err := c.BodyParser(&req) if err != nil { return c.Status(400).JSON(fiber.Map{ @@ -109,25 +119,55 @@ func (h *Handler) UpdateSubscription(c *fiber.Ctx) error { }) } - activeUntil, err := dateparse.ParseAny(req.ActiveUntil) + paSession := model.PASession{ + ID: req.ID, + PreauthId: req.PreauthId, + Token: req.Token, + } + + err = h.Service.AddPASession(c.Context(), paSession) if err != nil { return c.Status(400).JSON(fiber.Map{ - "error": ErrInvalidRequest, + "error": err.Error(), }) } - sub := model.Subscription{} - sub.ID = req.ID - sub.ActiveUntil = activeUntil + return nil +} - err = h.Service.UpdateSubscription(c.Context(), sub) +// @Summary Rotate pre-auth session ID +// @Description Rotate pre-auth session ID +// @Tags subscription +// @Accept json +// @Produce json +// @Param body body RotatePASessionReq true "Rotate pre-auth session request" +// @Success 200 {object} SuccessRes +// @Failure 400 {object} ErrorRes +// @Router /rotatepasession [put] +func (h *Handler) RotatePASession(c *fiber.Ctx) error { + req := RotatePASessionReq{} + err := c.BodyParser(&req) if err != nil { return c.Status(400).JSON(fiber.Map{ - "error": err.Error(), + "error": InvalidPASessionId, }) } - return c.Status(200).JSON(fiber.Map{ - "message": UpdateSubscriptionSuccess, - }) + err = h.Validator.Struct(req) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": InvalidPASessionId, + }) + } + + newID, err := h.Service.RotatePASessionId(c.Context(), req.ID) + if err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": InvalidPASessionId, + }) + } + + c.Cookie(auth.NewCookiePASession(newID)) + + return c.SendStatus(fiber.StatusOK) } diff --git a/api/internal/transport/api/user.go b/api/internal/transport/api/user.go index ab0773e..830fedd 100644 --- a/api/internal/transport/api/user.go +++ b/api/internal/transport/api/user.go @@ -32,13 +32,12 @@ var ( ) type UserService interface { - PostUser(context.Context, model.User, string) error SendUserOTP(context.Context, string) error ActivateUser(context.Context, string, string) error GetUserByCredentials(context.Context, string, string) (model.User, error) GetUserByPassword(context.Context, string, string) (model.User, error) GetUserByEmail(context.Context, string) (model.User, error) - GetUnfinishedSignupOrPostUser(context.Context, model.User, string) (model.User, error) + GetUnfinishedSignupOrPostUser(context.Context, model.User, string, string) (model.User, error) SaveUser(context.Context, model.User) error DeleteUserRequest(context.Context, string) (string, error) DeleteUser(context.Context, string, string) error @@ -66,6 +65,9 @@ type UserService interface { // @Failure 400 {object} ErrorRes // @Router /register [post] func (h *Handler) Register(c *fiber.Ctx) error { + // Get session ID from cookie + sessionId := c.Cookies(auth.PA_SESSION_COOKIE) + // Parse the request req := SignupUserReq{} err := c.BodyParser(&req) @@ -91,7 +93,7 @@ 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) + user, err = h.Service.GetUnfinishedSignupOrPostUser(c.Context(), user, req.SubID, sessionId) if err != nil { return c.Status(400).JSON(fiber.Map{ "error": err.Error(), diff --git a/api/internal/transport/api/webauthn.go b/api/internal/transport/api/webauthn.go index e599dff..db3455b 100644 --- a/api/internal/transport/api/webauthn.go +++ b/api/internal/transport/api/webauthn.go @@ -51,6 +51,9 @@ type CredentialService interface { // @Failure 400 {object} ErrorRes // @Router /register/begin [post] func (h *Handler) BeginRegistration(c *fiber.Ctx) error { + // Get session ID from cookie + sessionId := c.Cookies(auth.PA_SESSION_COOKIE) + // Parse the request req := SignupEmailReq{} err := c.BodyParser(&req) @@ -75,7 +78,7 @@ 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) + user, err = h.Service.GetUnfinishedSignupOrPostUser(c.Context(), user, req.SubID, sessionId) if err != nil { return c.Status(400).JSON(fiber.Map{ "error": err.Error(), diff --git a/app/.env.sample b/app/.env.sample index 6227369..b14d80a 100644 --- a/app/.env.sample +++ b/app/.env.sample @@ -1,3 +1,4 @@ VITE_API_URL=http://localhost:3000 VITE_DOMAINS=example1.net,example2.net -VITE_APP_NAME=App \ No newline at end of file +VITE_APP_NAME=App +VITE_RESYNC_URL=http://localhost:8010/en/account/ \ No newline at end of file diff --git a/app/src/api/subscription.ts b/app/src/api/subscription.ts index a816da2..23c25c4 100644 --- a/app/src/api/subscription.ts +++ b/app/src/api/subscription.ts @@ -2,4 +2,6 @@ import { api } from './api' export const subscriptionApi = { get: () => api.get('/sub'), + update: (data: any) => api.put('/sub/update', data), + rotateSessionId: (data: any) => api.put('/rotatepasession', data), } \ No newline at end of file diff --git a/app/src/components/AccountSubscription.vue b/app/src/components/AccountSubscription.vue index 02e011e..d69d418 100644 --- a/app/src/components/AccountSubscription.vue +++ b/app/src/components/AccountSubscription.vue @@ -1,10 +1,13 @@ \ No newline at end of file diff --git a/app/src/components/AccountSubscriptionStatus.vue b/app/src/components/AccountSubscriptionStatus.vue index 6be4e96..4353096 100644 --- a/app/src/components/AccountSubscriptionStatus.vue +++ b/app/src/components/AccountSubscriptionStatus.vue @@ -1,8 +1,29 @@ @@ -11,26 +32,36 @@ import { onMounted, ref, watch } from 'vue' import { useRoute } from 'vue-router' import { subscriptionApi } from '../api/subscription.ts' -const res = ref({ +const sub = ref({ id: '', - active_until: '' + updated_at: '', + active_until: '', + status: '', + outage: false, }) const route = ref('/') const currentRoute = useRoute() -const isActive = ref(true) const props = defineProps(['dashboard']) const isDashboard = props.dashboard +const activateUrl = import.meta.env.VITE_RESYNC_URL const getSubscription = async () => { try { - const response = await subscriptionApi.get() - res.value = response.data - isActive.value = res.value.active_until > new Date().toISOString() + const res = await subscriptionApi.get() + sub.value = res.data } catch (err) { } } +const isLimited = () => { + return sub.value.status === 'limited_access' +} + +const isPendingDelete = () => { + return sub.value.status === 'pending_delete' +} + onMounted(() => { getSubscription() }) diff --git a/app/src/components/Signup.vue b/app/src/components/Signup.vue index e34073c..944e515 100644 --- a/app/src/components/Signup.vue +++ b/app/src/components/Signup.vue @@ -18,16 +18,18 @@ id="email_authn" type="email" class="email" + :disabled="!!rotateSessionError" @keypress.enter.prevent >

Required

-

Error: {{ apiError }}

+

Error: {{ rotateSessionError }}

Required

@@ -60,17 +63,19 @@ id="password" type="password" class="password" + :disabled="!!rotateSessionError" @keypress.enter.prevent >

Required

Must be 12+ characters and contain uppercase, lowercase, number, and special character (e.g. -_+=~!@#$%^&*(),;.?":{}|<>)

-

Error: {{ apiError }}

+

Error: {{ rotateSessionError }}

@@ -104,6 +109,7 @@ import { ref, onMounted, onUpdated } from 'vue' import { useRoute } from 'vue-router' import axios from 'axios' import { userApi } from '../api/user.ts' +import { subscriptionApi } from '../api/subscription.ts' import { startRegistration, browserSupportsWebAuthn } from '@simplewebauthn/browser' import tabs from '@preline/tabs' import Footer from './Footer.vue' @@ -116,9 +122,12 @@ const emailAuthnError = ref(false) const passwordError = ref(false) const apiSuccess = ref('') const apiError = ref('') +const rotateSessionError = ref('') const isLoading = ref(false) const passkeySupported = ref(false) const subid = ref('') +const sessionid = ref('') +const syncing = ref(false) const validateEmail = () => { emailError.value = !email.value @@ -127,7 +136,7 @@ const validateEmail = () => { const validateEmailAuthn = () => { emailAuthnError.value = !emailAuthn.value - return !emailAuthnError.value + return !emailAuthnError.value && syncing.value === false } const validatePassword = () => { @@ -138,7 +147,7 @@ const validatePassword = () => { const validate = () => { const validEmail = validateEmail() const validPass = validatePassword() - return validEmail && validPass + return validEmail && validPass && syncing.value === false } const register = async () => { @@ -148,7 +157,7 @@ const register = async () => { const data = { email: email.value, password: password.value, - subid: subid.value + subid: subid.value, } try { @@ -176,7 +185,7 @@ const registerWithPasskey = async () => { const data = { email: emailAuthn.value, - subid: subid.value + subid: subid.value, } try { @@ -199,12 +208,48 @@ const registerWithPasskey = async () => { } } -const parseSubid = () => { +const rotateSessionId = async () => { + if (!sessionid.value) { + return + } + + syncing.value = true + try { + await subscriptionApi.rotateSessionId({ + sessionid: sessionid.value, + }) + rotateSessionError.value = '' + } catch (err) { + if (axios.isAxiosError(err)) { + rotateSessionError.value = err.response?.data.error || err.message + + if (err.response?.status === 429) { + rotateSessionError.value = 'Too many requests, please try again later.' + } + } + } finally { + syncing.value = false + } +} + +const parseParams = () => { const route = useRoute() - subid.value = route.params.subid as string + const q = route.query + const first = (v: unknown) => typeof v === 'string' ? v : Array.isArray(v) ? v[0] : '' + subid.value = first(q.subid) || (route.params.subid as string) || '' + sessionid.value = first(q.sessionid) || (route.params.sessionid as string) || '' + if (!subid.value || !subid.value.match(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/)) { - window.location.href = '/login' + console.error('Invalid or missing subid') + return + } + + if (!sessionid.value || !sessionid.value.match(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/)) { + console.error('Invalid or missing sessionid') + return } + + rotateSessionId() } const isLoggedIn = (): boolean => { @@ -217,7 +262,7 @@ onMounted(() => { window.location.href = '/' } - parseSubid() + parseParams() passkeySupported.value = browserSupportsWebAuthn() tabs.autoInit() }) diff --git a/app/src/router.ts b/app/src/router.ts index 2c59d86..316c025 100644 --- a/app/src/router.ts +++ b/app/src/router.ts @@ -76,7 +76,7 @@ const routes: RouteRecordRaw[] = [ children: dashboardChildren }, { - path: '/signup/:subid', + path: '/signup', name: `${AppName} - Sign Up`, component: Signup }, diff --git a/app/src/style/components/badge.css b/app/src/style/components/badge.css index 391506a..952d361 100644 --- a/app/src/style/components/badge.css +++ b/app/src/style/components/badge.css @@ -6,6 +6,10 @@ @apply bg-success text-white font-semibold; } + &.progress { + @apply bg-accent text-white font-semibold; + } + &.bounce { @apply bg-amber-500 dark:bg-amber-700 text-white font-semibold; } @@ -18,6 +22,10 @@ @apply bg-violet-500 dark:bg-violet-700 text-white font-semibold; } + &.inactive_subscription { + @apply bg-gray-500 text-white font-semibold; + } + &.small { @apply py-0.5 px-1.5 text-xs; }