diff --git a/.github/workflows/ci_production.yml b/.github/workflows/ci_production.yml index 85a65c2a..9e06ef35 100644 --- a/.github/workflows/ci_production.yml +++ b/.github/workflows/ci_production.yml @@ -34,6 +34,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 6e526582..ada0ba60 100644 --- a/.github/workflows/ci_staging.yml +++ b/.github/workflows/ci_staging.yml @@ -32,6 +32,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 e8b4ca96..83b8468f 100644 --- a/api/.env.sample +++ b/api/.env.sample @@ -1,68 +1,70 @@ -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_REPORT="" +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_REPORT= 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 5f672eeb..8675b33e 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 { @@ -67,7 +70,6 @@ type ServiceConfig struct { MaxDailyAliases int MaxDailySendReply int MaxSessions int - ForwardGracePeriodDays int AccountGracePeriodDays int IdLimiterMax int IdLimiterExpiration time.Duration @@ -135,11 +137,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 @@ -148,6 +145,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"), @@ -165,6 +168,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, @@ -206,7 +212,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 59472a91..dc6c4df1 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 0d3688a5..9bb1133f 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 b48fbd4d..9085b4dc 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 bcef7fd2..cbf00174 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 00000000..d8920d69 --- /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 3d4b4eaa..0d431707 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 00000000..67db24d7 --- /dev/null +++ b/api/internal/cron/jobs/subscription.go @@ -0,0 +1,106 @@ +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) + 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 d9795592..8255a7fa 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 1f370d9d..90a64ed1 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 00000000..e71f25dd --- /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 00000000..9d33b883 --- /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 3a006e12..42cd4de6 100644 --- a/api/internal/model/subscription.go +++ b/api/internal/model/subscription.go @@ -2,33 +2,79 @@ 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) && !s.IsOutage() +} + +func (s *Subscription) GracePeriod() bool { + return s.IsOutage() && s.GracePeriodDays(3) && s.OutageGracePeriodDays(3) +} + +func (s *Subscription) LimitedAccess() bool { + return s.GracePeriodDays(14) || s.OutageGracePeriodDays(14) } -func (s *Subscription) IsActive() bool { - return s.ActiveUntil.After(time.Now()) +func (s *Subscription) PendingDelete() bool { + return !s.GracePeriodDays(14) || !s.OutageGracePeriodDays(14) } -func (s *Subscription) IsActiveWithGracePeriod(days int) bool { +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) OutageGracePeriodDays(days int) bool { + return s.UpdatedAt.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 db337cdf..3929b41a 100644 --- a/api/internal/model/subscription_test.go +++ b/api/internal/model/subscription_test.go @@ -5,26 +5,269 @@ import ( "time" ) -func TestSubscription_IsActive(t *testing.T) { +// helpers to build a Subscription at a point in time +func activeSubscription() *Subscription { + return &Subscription{ + ActiveUntil: time.Now().Add(30 * 24 * time.Hour), // 30 days from now + UpdatedAt: time.Now().Add(-1 * time.Hour), // updated 1 hour ago (no outage) + Tier: "Tier 2", + } +} + +func expiredSubscription() *Subscription { + return &Subscription{ + ActiveUntil: time.Now().Add(-30 * 24 * time.Hour), // expired 30 days ago + UpdatedAt: time.Now().Add(-1 * time.Hour), + Tier: "Tier 2", + } +} + +// --- IsOutage --- + +func TestIsOutage(t *testing.T) { + tests := []struct { + name string + updatedAt time.Time + want bool + }{ + { + name: "updated more than 48h ago is outage", + updatedAt: time.Now().Add(-49 * time.Hour), + want: true, + }, + { + name: "updated exactly 48h ago is outage", + updatedAt: time.Now().Add(-48 * time.Hour).Add(-time.Second), + want: true, + }, + { + name: "updated less than 48h ago is not outage", + updatedAt: time.Now().Add(-1 * time.Hour), + want: false, + }, + { + name: "updated just now is not outage", + updatedAt: time.Now(), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Subscription{UpdatedAt: tt.updatedAt} + if got := s.IsOutage(); got != tt.want { + t.Errorf("IsOutage() = %v, want %v", got, tt.want) + } + }) + } +} + +// --- GracePeriodDays --- + +func TestGracePeriodDays(t *testing.T) { + tests := []struct { + name string + activeUntil time.Time + days int + want bool + }{ + { + name: "active until yesterday + 3 days grace = still in grace", + activeUntil: time.Now().Add(-24 * time.Hour), + days: 3, + want: true, + }, + { + name: "active until 4 days ago + 3 days grace = outside grace", + activeUntil: time.Now().AddDate(0, 0, -4), + days: 3, + want: false, + }, + { + name: "active until tomorrow + 0 days = in grace", + activeUntil: time.Now().Add(24 * time.Hour), + days: 0, + want: true, + }, + { + name: "active until 15 days ago + 14 days grace = outside grace", + activeUntil: time.Now().AddDate(0, 0, -15), + days: 14, + want: false, + }, + { + name: "active until 13 days ago + 14 days grace = in grace", + activeUntil: time.Now().AddDate(0, 0, -13), + days: 14, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Subscription{ActiveUntil: tt.activeUntil} + if got := s.GracePeriodDays(tt.days); got != tt.want { + t.Errorf("GracePeriodDays(%d) = %v, want %v", tt.days, got, tt.want) + } + }) + } +} + +// --- OutageGracePeriodDays --- + +func TestOutageGracePeriodDays(t *testing.T) { + tests := []struct { + name string + updatedAt time.Time + days int + want bool + }{ + { + name: "updated 1 day ago + 3 days = in outage grace", + updatedAt: time.Now().Add(-24 * time.Hour), + days: 3, + want: true, + }, + { + name: "updated 4 days ago + 3 days = outside outage grace", + updatedAt: time.Now().AddDate(0, 0, -4), + days: 3, + want: false, + }, + { + name: "updated 13 days ago + 14 days = in outage grace", + updatedAt: time.Now().AddDate(0, 0, -13), + days: 14, + want: true, + }, + { + name: "updated 15 days ago + 14 days = outside outage grace", + updatedAt: time.Now().AddDate(0, 0, -15), + days: 14, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Subscription{UpdatedAt: tt.updatedAt} + if got := s.OutageGracePeriodDays(tt.days); got != tt.want { + t.Errorf("OutageGracePeriodDays(%d) = %v, want %v", tt.days, got, tt.want) + } + }) + } +} + +// --- Active --- + +func TestActive(t *testing.T) { + future := time.Now().Add(30 * 24 * time.Hour) + past := time.Now().Add(-30 * 24 * time.Hour) + recent := time.Now().Add(-1 * time.Hour) + outageTime := time.Now().Add(-49 * time.Hour) + + tests := []struct { + name string + activeUntil time.Time + updatedAt time.Time + tier string + want bool + }{ + { + name: "active: future expiry, non-Tier1, no outage", + activeUntil: future, + updatedAt: recent, + tier: "Tier 2", + want: true, + }, + { + name: "inactive: expired subscription", + activeUntil: past, + updatedAt: recent, + tier: "Tier 2", + want: false, + }, + { + name: "inactive: Tier 1 subscription", + activeUntil: future, + updatedAt: recent, + tier: Tier1, + want: false, + }, + { + name: "inactive: tier contains 'Tier 1' string", + activeUntil: future, + updatedAt: recent, + tier: "Tier 1 Plus", + want: false, + }, + { + name: "inactive: outage (updatedAt > 48h ago)", + activeUntil: future, + updatedAt: outageTime, + tier: "Tier 2", + want: false, + }, + { + name: "inactive: expired and Tier 1", + activeUntil: past, + updatedAt: recent, + tier: Tier1, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Subscription{ + ActiveUntil: tt.activeUntil, + UpdatedAt: tt.updatedAt, + Tier: tt.tier, + } + if got := s.Active(); got != tt.want { + t.Errorf("Active() = %v, want %v", got, tt.want) + } + }) + } +} + +// --- GracePeriod --- + +func TestGracePeriod(t *testing.T) { + future := time.Now().Add(30 * 24 * time.Hour) + recent := time.Now().Add(-1 * time.Hour) + outageTime := time.Now().Add(-49 * time.Hour) + outageOutside3Days := time.Now().AddDate(0, 0, -4) // outage AND outside 3-day grace + tests := []struct { name string activeUntil time.Time + updatedAt time.Time want bool }{ { - name: "active subscription", - activeUntil: time.Now().Add(24 * time.Hour), // 1 day in the future + name: "grace period: outage + within 3-day ActiveUntil grace + within 3-day outage grace", + activeUntil: time.Now().Add(-24 * time.Hour), // expired 1d ago, within 3d grace + updatedAt: outageTime, // outage, but < 3d want: true, }, { - name: "expired subscription", - activeUntil: time.Now().Add(-24 * time.Hour), // 1 day in the past + name: "no grace period: no outage", + activeUntil: future, + updatedAt: recent, + want: false, + }, + { + name: "no grace period: outage but ActiveUntil grace expired", + activeUntil: time.Now().AddDate(0, 0, -4), // 4 days ago, outside 3d grace + updatedAt: outageTime, want: false, }, { - name: "subscription expires now", - activeUntil: time.Now(), - want: false, // time.Now() is not After time.Now() + name: "no grace period: outage but OutageGracePeriodDays(3) expired", + activeUntil: time.Now().Add(-24 * time.Hour), + updatedAt: outageOutside3Days, + want: false, }, } @@ -32,49 +275,210 @@ func TestSubscription_IsActive(t *testing.T) { t.Run(tt.name, func(t *testing.T) { s := &Subscription{ ActiveUntil: tt.activeUntil, + UpdatedAt: tt.updatedAt, } - if got := s.IsActive(); got != tt.want { - t.Errorf("Subscription.IsActive() = %v, want %v", got, tt.want) + if got := s.GracePeriod(); got != tt.want { + t.Errorf("GracePeriod() = %v, want %v", got, tt.want) } }) } } -func TestSubscription_IsActiveWithGracePeriod(t *testing.T) { + +// --- LimitedAccess --- + +func TestLimitedAccess(t *testing.T) { tests := []struct { name string activeUntil time.Time - graceDays int + updatedAt time.Time want bool }{ { - name: "active subscription", - activeUntil: time.Now().Add(24 * time.Hour), // 1 day in the future - graceDays: 0, + name: "limited: within 14-day ActiveUntil grace", + activeUntil: time.Now().AddDate(0, 0, -13), + updatedAt: time.Now().AddDate(0, 0, -20), 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: "limited: within 14-day outage grace", + activeUntil: time.Now().AddDate(0, 0, -20), + updatedAt: time.Now().AddDate(0, 0, -13), want: true, }, { - name: "expired subscription outside grace period", - activeUntil: time.Now().Add(-5 * 24 * time.Hour), // 5 days in the past - graceDays: 3, + name: "limited: within both 14-day graces", + activeUntil: time.Now().AddDate(0, 0, -5), + updatedAt: time.Now().AddDate(0, 0, -5), + want: true, + }, + { + name: "not limited: both 14-day graces exceeded", + activeUntil: time.Now().AddDate(0, 0, -15), + updatedAt: time.Now().AddDate(0, 0, -15), want: false, }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Subscription{ + ActiveUntil: tt.activeUntil, + UpdatedAt: tt.updatedAt, + } + if got := s.LimitedAccess(); got != tt.want { + t.Errorf("LimitedAccess() = %v, want %v", got, tt.want) + } + }) + } +} + +// --- PendingDelete --- + +func TestPendingDelete(t *testing.T) { + tests := []struct { + name string + activeUntil time.Time + updatedAt time.Time + want bool + }{ + { + name: "pending delete: ActiveUntil grace(14) exceeded", + activeUntil: time.Now().AddDate(0, 0, -15), + updatedAt: time.Now().AddDate(0, 0, -5), + want: true, + }, { - name: "subscription expires today with grace period", - activeUntil: time.Now(), - graceDays: 1, + name: "pending delete: outage grace(14) exceeded", + activeUntil: time.Now().AddDate(0, 0, -5), + updatedAt: time.Now().AddDate(0, 0, -15), want: true, }, { - name: "subscription expires today without grace period", - activeUntil: time.Now(), - graceDays: 0, - want: false, // time.Now() is not After time.Now() + name: "pending delete: both graces exceeded", + activeUntil: time.Now().AddDate(0, 0, -15), + updatedAt: time.Now().AddDate(0, 0, -15), + want: true, + }, + { + name: "not pending delete: both graces still valid", + activeUntil: time.Now().AddDate(0, 0, -5), + updatedAt: time.Now().AddDate(0, 0, -5), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Subscription{ + ActiveUntil: tt.activeUntil, + UpdatedAt: tt.updatedAt, + } + if got := s.PendingDelete(); got != tt.want { + t.Errorf("PendingDelete() = %v, want %v", got, tt.want) + } + }) + } +} + +// --- ActiveStatus --- + +func TestActiveStatus(t *testing.T) { + future := time.Now().Add(30 * 24 * time.Hour) + recent := time.Now().Add(-1 * time.Hour) + outageTime := time.Now().Add(-49 * time.Hour) + + tests := []struct { + name string + activeUntil time.Time + updatedAt time.Time + tier string + want bool + }{ + { + name: "active status: subscription is active", + activeUntil: future, + updatedAt: recent, + tier: "Tier 2", + want: true, + }, + { + name: "active status: subscription is in grace period", + activeUntil: time.Now().Add(-24 * time.Hour), // expired 1d ago, within 3d grace + updatedAt: outageTime, + tier: "Tier 2", + want: true, + }, + { + name: "not active: limited access (no outage, expired)", + activeUntil: time.Now().AddDate(0, 0, -5), + updatedAt: recent, + tier: "Tier 2", + want: false, + }, + { + name: "not active: pending delete", + activeUntil: time.Now().AddDate(0, 0, -15), + updatedAt: time.Now().AddDate(0, 0, -15), + tier: "Tier 2", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Subscription{ + ActiveUntil: tt.activeUntil, + UpdatedAt: tt.updatedAt, + Tier: tt.tier, + } + if got := s.ActiveStatus(); got != tt.want { + t.Errorf("ActiveStatus() = %v, want %v", got, tt.want) + } + }) + } +} + +// --- GetStatus --- + +func TestGetStatus(t *testing.T) { + future := time.Now().Add(30 * 24 * time.Hour) + recent := time.Now().Add(-1 * time.Hour) + outageTime := time.Now().Add(-49 * time.Hour) + + tests := []struct { + name string + activeUntil time.Time + updatedAt time.Time + tier string + want SubscriptionStatus + }{ + { + name: "status Active: future expiry, no outage, non-Tier1", + activeUntil: future, + updatedAt: recent, + tier: "Tier 2", + want: Active, + }, + { + name: "status GracePeriod: outage + within 3-day graces", + activeUntil: time.Now().Add(-24 * time.Hour), + updatedAt: outageTime, + tier: "Tier 2", + want: GracePeriod, + }, + { + name: "status LimitedAccess: expired but within 14-day grace, no outage", + activeUntil: time.Now().AddDate(0, 0, -5), + updatedAt: recent, + tier: "Tier 2", + want: LimitedAccess, + }, + { + name: "status PendingDelete: both 14-day graces exceeded", + activeUntil: time.Now().AddDate(0, 0, -15), + updatedAt: time.Now().AddDate(0, 0, -15), + tier: "Tier 2", + want: PendingDelete, }, } @@ -82,9 +486,11 @@ func TestSubscription_IsActiveWithGracePeriod(t *testing.T) { t.Run(tt.name, func(t *testing.T) { s := &Subscription{ ActiveUntil: tt.activeUntil, + UpdatedAt: tt.updatedAt, + Tier: tt.tier, } - if got := s.IsActiveWithGracePeriod(tt.graceDays); got != tt.want { - t.Errorf("Subscription.IsActiveWithGracePeriod(%v) = %v, want %v", tt.graceDays, got, tt.want) + if got := s.GetStatus(); got != tt.want { + t.Errorf("GetStatus() = %v, want %v", got, tt.want) } }) } diff --git a/api/internal/repository/subscription.go b/api/internal/repository/subscription.go index f0aea7d8..bea472c5 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 5fe5a530..c275f699 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 92dd35d2..70ecd3c8 100644 --- a/api/internal/service/processor.go +++ b/api/internal/service/processor.go @@ -107,14 +107,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 89210f40..312f1e12 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 } @@ -178,7 +178,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 1e07d2de..35043509 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 6e818b65..6cdad53d 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 @@ -254,7 +274,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 108fe358..bd576f59 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 5156d508..72a73dd5 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 b0e71695..512610d6 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 ab0773e0..830fedd0 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 e599dff2..db3455bd 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 62273699..b14d80a8 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 a816da27..23c25c49 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 02e011e7..d69d418c 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 6be4e96a..43530968 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 e34073c2..944e5157 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 2c59d869..316c0257 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 391506a1..952d3611 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; }