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 @@
Account
-
+
Active
Inactive
+
+ Syncing...
+
Account email:
@@ -17,72 +20,184 @@
{{ activeUntilDate() }}
-
-
Subscription ID:
-
-
-
- {{ res.id }}
-
-
- {{ copyText }}
-
-
-
+
+
+
Error: {{ error }}
+
{{ success }}
\ 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 @@
-
-
- Your subscription is inactive
-
+
+
@@ -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
-
+
Sign Up with Passkey
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. -_+=~!@#$%^&*(),;.?":{}|<>)
-
+
Sign Up
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;
}