mirror of
https://github.com/lawrencehook/remove-youtube-suggestions.git
synced 2026-07-25 06:54:31 +00:00
Merge the monetization feature branch.
Add RYS Premium subscription system Introduce optional paid tier with sign-in, Stripe billing, and premium feature gating. Core features remain free. Includes Node.js server for auth, payments, and license management. Extension changes: - Sign-in flow with magic link email - Premium/upgrade/account modals with branded UI - Premium feature gating (60+ advanced settings) - Password lock and scheduling (premium) - Fix "hide all but first row" for YouTube's new flat homepage DOM - Hide sidebar ad panels by default - Enable "Hide all Shorts" by default Server: - Magic link auth with rate limiting - Stripe checkout, webhooks, and billing portal - JWT-based license tokens - AWS SES transactional emails (welcome, sign-in, cancellation) - Grandfathered donor support Also adds CI workflow, server test suite, and extension unit tests.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
extension-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- run: npm test
|
||||
working-directory: tests
|
||||
|
||||
server-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- run: npm install
|
||||
working-directory: server
|
||||
- run: npm test
|
||||
working-directory: server
|
||||
@@ -3,3 +3,4 @@ web-ext-artifacts/
|
||||
extension.zip
|
||||
manifest.json
|
||||
_scratch/
|
||||
node_modules/
|
||||
|
||||
+521
@@ -0,0 +1,521 @@
|
||||
# RYS Premium Server Deployment Guide
|
||||
|
||||
Complete guide to deploying the premium subscription server and testing the monetization flow.
|
||||
|
||||
**Server URL:** `https://server.lawrencehook.com/rys/`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [Stripe Setup](#stripe-setup)
|
||||
3. [AWS SES Setup](#aws-ses-setup)
|
||||
4. [Server Deployment](#server-deployment)
|
||||
5. [Apache Configuration](#apache-configuration)
|
||||
6. [Extension Setup](#extension-setup)
|
||||
7. [Testing](#testing)
|
||||
8. [Troubleshooting](#troubleshooting)
|
||||
9. [Maintenance](#maintenance)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- AWS Lightsail instance (Bitnami stack)
|
||||
- Node.js v20+
|
||||
- Stripe account (test mode for development)
|
||||
- AWS SES configured for sending emails
|
||||
|
||||
---
|
||||
|
||||
## Stripe Setup
|
||||
|
||||
### Create Products
|
||||
|
||||
1. Go to https://dashboard.stripe.com/test/products
|
||||
2. Click **"+ Add product"**
|
||||
|
||||
**Monthly Plan:**
|
||||
- Name: `RYS Premium Monthly`
|
||||
- Pricing: `$3.00 USD` / `month` / `recurring`
|
||||
- Save and copy the **Price ID** (starts with `price_...`)
|
||||
|
||||
**Yearly Plan:**
|
||||
- Name: `RYS Premium Yearly`
|
||||
- Pricing: `$24.00 USD` / `year` / `recurring`
|
||||
- Save and copy the **Price ID**
|
||||
|
||||
### Get API Keys
|
||||
|
||||
1. Go to https://dashboard.stripe.com/test/apikeys
|
||||
2. Copy the **Secret key** (starts with `sk_test_...`)
|
||||
|
||||
### Configure Billing Portal
|
||||
|
||||
1. Go to Settings → Billing → Customer portal
|
||||
2. Enable the customer portal
|
||||
3. Configure allowed actions:
|
||||
- [x] Update payment methods
|
||||
- [x] View invoice history
|
||||
- [x] Cancel subscriptions
|
||||
4. Save changes
|
||||
|
||||
### Create Webhook Endpoint
|
||||
|
||||
1. Go to https://dashboard.stripe.com/test/webhooks
|
||||
2. Click **"+ Add endpoint"**
|
||||
3. Endpoint URL: `https://server.lawrencehook.com/rys/webhook/stripe`
|
||||
4. Select events:
|
||||
- `checkout.session.completed`
|
||||
- `customer.subscription.created`
|
||||
- `customer.subscription.updated`
|
||||
- `customer.subscription.deleted`
|
||||
- `invoice.payment_failed`
|
||||
- `invoice.payment_succeeded`
|
||||
5. Click **"Add endpoint"**
|
||||
6. Copy the **Signing secret** (starts with `whsec_...`)
|
||||
|
||||
---
|
||||
|
||||
## AWS SES Setup
|
||||
|
||||
### Verify Sender Email/Domain
|
||||
|
||||
1. In AWS Console → SES → Verified identities
|
||||
2. Click "Create identity"
|
||||
3. For email: click the verification link sent to you
|
||||
4. For domain: add the DNS records provided
|
||||
|
||||
### Request Production Access
|
||||
|
||||
SES starts in sandbox mode (can only send to verified emails).
|
||||
|
||||
1. Go to SES → Account dashboard
|
||||
2. Click "Request production access"
|
||||
3. Fill out the form explaining your use case
|
||||
4. Wait for approval (usually 24-48 hours)
|
||||
|
||||
### IAM Credentials
|
||||
|
||||
If not using instance roles, create IAM credentials:
|
||||
|
||||
1. Create a new user (e.g., `rys-premium-ses`)
|
||||
2. Attach policy: `AmazonSESFullAccess`
|
||||
3. Create access keys
|
||||
4. Copy the **Access Key ID** and **Secret Access Key**
|
||||
|
||||
---
|
||||
|
||||
## Server Deployment
|
||||
|
||||
### SSH to Lightsail Instance
|
||||
|
||||
```bash
|
||||
ssh bitnami@your-instance-ip
|
||||
```
|
||||
|
||||
### Install Node.js 20+ (if needed)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
node --version # Should show v20.x.x
|
||||
```
|
||||
|
||||
### Install PM2
|
||||
|
||||
```bash
|
||||
sudo npm install -g pm2
|
||||
```
|
||||
|
||||
### Clone and Setup Repository
|
||||
|
||||
```bash
|
||||
cd ~/github
|
||||
git clone https://github.com/lawrencehook/remove-youtube-suggestions.git
|
||||
cd remove-youtube-suggestions
|
||||
git checkout feature/monetize
|
||||
cd server
|
||||
npm install
|
||||
```
|
||||
|
||||
### Create Environment File
|
||||
|
||||
```bash
|
||||
nano .env
|
||||
```
|
||||
|
||||
Paste the following (fill in your values):
|
||||
|
||||
```bash
|
||||
# Server
|
||||
PORT=3005
|
||||
BASE_URL=https://server.lawrencehook.com/rys
|
||||
|
||||
# JWT (generate with: openssl rand -base64 32)
|
||||
JWT_SECRET=your-random-secret-at-least-32-characters-long
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=sk_test_YOUR_KEY
|
||||
STRIPE_PRICE_MONTHLY=price_YOUR_MONTHLY_ID
|
||||
STRIPE_PRICE_YEARLY=price_YOUR_YEARLY_ID
|
||||
STRIPE_WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET
|
||||
|
||||
# AWS SES (uses instance role, no explicit credentials needed)
|
||||
AWS_REGION=us-east-1
|
||||
EMAIL_FROM=noreply@lawrencehook.com
|
||||
```
|
||||
|
||||
Save: `Ctrl+O`, `Enter`, `Ctrl+X`
|
||||
|
||||
### Create Data Directory
|
||||
|
||||
```bash
|
||||
mkdir -p data
|
||||
echo '[]' > data/grandfathered.json
|
||||
```
|
||||
|
||||
To add grandfathered users (past donors with lifetime access):
|
||||
```bash
|
||||
echo '["donor1@example.com", "donor2@example.com"]' > data/grandfathered.json
|
||||
```
|
||||
|
||||
### Test Server Starts
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
Loaded X grandfathered emails
|
||||
RYS Premium Server running on port 3005
|
||||
Base URL: https://server.lawrencehook.com/rys
|
||||
```
|
||||
|
||||
Press `Ctrl+C` to stop.
|
||||
|
||||
### Start with PM2
|
||||
|
||||
```bash
|
||||
pm2 start src/index.js --name rys-premium
|
||||
pm2 save
|
||||
```
|
||||
|
||||
### Configure PM2 Auto-Start on Reboot
|
||||
|
||||
```bash
|
||||
pm2 startup
|
||||
# Run the command it outputs (starts with: sudo env PATH=...)
|
||||
pm2 save
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Apache Configuration
|
||||
|
||||
Your Lightsail instance uses Bitnami's Apache with path-based proxying.
|
||||
|
||||
### Edit SSL Config
|
||||
|
||||
Edit your local deployments repo:
|
||||
|
||||
```bash
|
||||
nano ~/github/deployments/opt/bitnami/apache/conf/bitnami/bitnami-ssl.conf
|
||||
```
|
||||
|
||||
Add these lines inside the `<VirtualHost _default_:443>` block:
|
||||
|
||||
```apache
|
||||
ProxyPass /rys/ http://localhost:3005/
|
||||
ProxyPassReverse /rys/ http://localhost:3005/
|
||||
```
|
||||
|
||||
### Deploy Apache Config
|
||||
|
||||
```bash
|
||||
# Local machine
|
||||
cd ~/github/deployments
|
||||
git add -A && git commit -m "Add RYS Premium server proxy config" && git push
|
||||
|
||||
# On server
|
||||
cd ~/github/deployments && git pull
|
||||
./update_apache_conf.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Extension Setup
|
||||
|
||||
### Verify Config Points to Production
|
||||
|
||||
Check `src/shared/config.js`:
|
||||
|
||||
```javascript
|
||||
const PREMIUM_CONFIG = {
|
||||
SERVER_URL: 'https://server.lawrencehook.com/rys',
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### Build the Extension
|
||||
|
||||
**For Chrome:**
|
||||
```bash
|
||||
./make_chrome.sh
|
||||
```
|
||||
|
||||
**For Firefox:**
|
||||
```bash
|
||||
./make_firefox.sh
|
||||
```
|
||||
|
||||
### Load in Chrome
|
||||
|
||||
1. Navigate to `chrome://extensions/`
|
||||
2. Enable **"Developer mode"** (toggle in top-right)
|
||||
3. Click **"Load unpacked"**
|
||||
4. Select: `chrome_extension/`
|
||||
|
||||
### Load in Firefox
|
||||
|
||||
1. Navigate to `about:debugging#/runtime/this-firefox`
|
||||
2. Click **"Load Temporary Add-on..."**
|
||||
3. Select: `src/firefox_manifest.json`
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Automated Tests
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm test
|
||||
```
|
||||
|
||||
All tests should pass (currently 46).
|
||||
|
||||
### Health Check
|
||||
|
||||
```bash
|
||||
curl https://server.lawrencehook.com/rys/health
|
||||
# Expected: {"status":"ok","timestamp":"..."}
|
||||
```
|
||||
|
||||
### Magic Link Flow
|
||||
|
||||
**Via curl:**
|
||||
```bash
|
||||
curl -X POST https://server.lawrencehook.com/rys/auth/send-magic-link \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"your-email@example.com"}'
|
||||
# Expected: {"request_id":"abc123-..."}
|
||||
```
|
||||
|
||||
**Via extension:**
|
||||
1. Go to any YouTube page
|
||||
2. Click the RYS extension icon
|
||||
3. Click the gear icon (settings menu)
|
||||
4. Click **"Sign In"**
|
||||
5. Enter your email and click **"Send Sign-In Link"**
|
||||
6. Check your email and click the magic link
|
||||
7. Return to extension — should show "Signed in successfully"
|
||||
|
||||
### License Check
|
||||
|
||||
After signing in:
|
||||
1. Click gear icon → **"Account"**
|
||||
2. Should show your email and status: **"Free"**
|
||||
|
||||
**For grandfathered users:**
|
||||
- Add email to `data/grandfathered.json` on server
|
||||
- Restart: `pm2 restart rys-premium`
|
||||
- Sign out and sign back in
|
||||
- Status should show: **"Premium (Lifetime)"**
|
||||
|
||||
### Checkout Flow
|
||||
|
||||
1. Open Account modal
|
||||
2. Click **"Upgrade to Premium"**
|
||||
3. Select a plan (Monthly or Yearly)
|
||||
4. Click **"Continue to Checkout"**
|
||||
5. Complete payment with test card:
|
||||
- Card: `4242 4242 4242 4242`
|
||||
- Expiry: Any future date (e.g., `12/34`)
|
||||
- CVC: Any 3 digits (e.g., `123`)
|
||||
- ZIP: Any valid ZIP (e.g., `12345`)
|
||||
6. After success page, return to extension
|
||||
7. Open Account — status should show **"Premium Active"**
|
||||
|
||||
### Billing Portal
|
||||
|
||||
1. With an active subscription, open Account modal
|
||||
2. Click **"Manage Subscription"**
|
||||
3. Stripe billing portal opens in new tab
|
||||
4. Can cancel subscription here for testing
|
||||
|
||||
### Webhook Verification
|
||||
|
||||
1. Go to Stripe Dashboard → Webhooks → your endpoint
|
||||
2. Click **"Send test webhook"**
|
||||
3. Select `checkout.session.completed`
|
||||
4. Check server logs: `pm2 logs rys-premium`
|
||||
|
||||
### Extension Smoke Tests
|
||||
|
||||
- **Sign-in flow:** Send magic link → click link → see "Signed in successfully"
|
||||
- **Poll cancel:** Start sign-in → hit Cancel → spinner stops, no sign-in after cancel
|
||||
- **Premium gate:** Mark option as premium → non-premium click opens upgrade modal
|
||||
- **License refresh:** Open Account → premium label reflects correct status
|
||||
- **Upgrade flow:** Choose plan → click checkout → Stripe checkout opens
|
||||
- **Billing portal:** With subscription → click "Manage Subscription" → portal opens
|
||||
|
||||
### Pre-Launch Checklist
|
||||
|
||||
- [ ] All automated tests pass
|
||||
- [ ] Server starts without errors
|
||||
- [ ] `/health` endpoint returns OK
|
||||
- [ ] Magic link emails are received
|
||||
- [ ] Magic link verification works
|
||||
- [ ] Grandfathered users show as premium
|
||||
- [ ] Stripe checkout creates a session
|
||||
- [ ] Stripe webhook is configured and verified
|
||||
- [ ] Billing portal is accessible
|
||||
- [ ] SSL certificate is valid
|
||||
- [ ] CORS works from extension origins
|
||||
|
||||
### Test Cards Reference
|
||||
|
||||
| Card | Result |
|
||||
|------|--------|
|
||||
| `4242 4242 4242 4242` | Success |
|
||||
| `4000 0000 0000 0002` | Declined |
|
||||
| `4000 0025 0000 3155` | Requires authentication |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server won't start
|
||||
|
||||
```bash
|
||||
pm2 logs rys-premium --lines 50
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- Missing .env variables
|
||||
- JWT_SECRET too short (needs 32+ chars)
|
||||
- Port already in use
|
||||
|
||||
### Magic link emails not sending
|
||||
|
||||
- Check SES is configured
|
||||
- Verify EMAIL_FROM domain is verified in SES
|
||||
- If in SES sandbox, recipient must also be verified
|
||||
|
||||
### CORS errors in extension
|
||||
|
||||
```bash
|
||||
# Verify server is running
|
||||
curl http://localhost:3005/health
|
||||
```
|
||||
|
||||
### SSL certificate issues
|
||||
|
||||
```bash
|
||||
sudo /opt/bitnami/bncert-tool # regenerate if needed
|
||||
```
|
||||
|
||||
### Extension can't connect
|
||||
|
||||
```bash
|
||||
# Test endpoint
|
||||
curl https://server.lawrencehook.com/rys/health
|
||||
|
||||
# Check Apache config
|
||||
sudo apachectl configtest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Update Server Code
|
||||
|
||||
```bash
|
||||
cd ~/github/remove-youtube-suggestions
|
||||
git pull
|
||||
git checkout feature/monetize
|
||||
cd server
|
||||
npm install
|
||||
pm2 restart rys-premium
|
||||
```
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
pm2 logs rys-premium # Tail logs
|
||||
pm2 logs rys-premium --lines 100 # Last 100 lines
|
||||
```
|
||||
|
||||
### Server Commands
|
||||
|
||||
```bash
|
||||
pm2 restart rys-premium # Restart
|
||||
pm2 stop rys-premium # Stop
|
||||
pm2 status # Check status
|
||||
```
|
||||
|
||||
### Add to Deployment Script
|
||||
|
||||
Add to `~/github/deployments/run`:
|
||||
|
||||
```bash
|
||||
# RYS Premium Server (pm2)
|
||||
cd /home/bitnami/github/remove-youtube-suggestions && git checkout -- . && git pull && git checkout feature/monetize;
|
||||
cd server && npm install && npm audit fix;
|
||||
pm2 restart rys-premium || pm2 start src/index.js --name rys-premium;
|
||||
```
|
||||
|
||||
### Updating Grandfathered List
|
||||
|
||||
1. Edit `data/grandfathered.json`
|
||||
2. Restart: `pm2 restart rys-premium`
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `PORT` | Server port | `3005` |
|
||||
| `BASE_URL` | Public URL | `https://server.lawrencehook.com/rys` |
|
||||
| `JWT_SECRET` | 32+ char secret | (generate with `openssl rand -base64 32`) |
|
||||
| `STRIPE_SECRET_KEY` | Stripe API secret | `sk_test_...` |
|
||||
| `STRIPE_PRICE_MONTHLY` | Monthly plan price ID | `price_...` |
|
||||
| `STRIPE_PRICE_YEARLY` | Yearly plan price ID | `price_...` |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Webhook signing secret | `whsec_...` |
|
||||
| `AWS_REGION` | AWS region for SES | `us-east-1` |
|
||||
| `EMAIL_FROM` | Sender email address | `noreply@lawrencehook.com` |
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints Reference
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/health` | GET | No | Health check |
|
||||
| `/auth/send-magic-link` | POST | No | Send magic link email |
|
||||
| `/auth/verify` | GET | No | Magic link target (HTML) |
|
||||
| `/auth/poll` | GET | No | Poll for auth status |
|
||||
| `/license/check` | GET | Yes | Check premium status |
|
||||
| `/checkout/create` | POST | Yes | Create Stripe checkout |
|
||||
| `/checkout/success` | GET | No | Success page (HTML) |
|
||||
| `/checkout/cancel` | GET | No | Cancel page (HTML) |
|
||||
| `/billing/portal` | POST | Yes | Create billing portal session |
|
||||
| `/billing/return` | GET | No | Return page (HTML) |
|
||||
| `/webhook/stripe` | POST | No* | Stripe webhook |
|
||||
|
||||
*Authenticated via Stripe signature
|
||||
@@ -0,0 +1,8 @@
|
||||
# Issues Found During Testing
|
||||
|
||||
1. **Footer missing Premium link** — Footer shows "Donors" but no "Premium" link for signed-in users. Fix: add a "Premium" link visible when signed in. ✅ Fixed
|
||||
2. **"Premium features coming soon" banner** — Remove entirely since premium is now deployed. ✅ Fixed
|
||||
3. **Settings menu icon** — Consider changing from three dots to a gear icon. ✅ Fixed
|
||||
4. **Browser action icon** — Update the toolbar icon with a premium indicator when user has premium. (Deferred.)
|
||||
5. **Static site branding** — Add "Premium" branding to the premium page and checkout/billing pages on lawrencehook.com/rys. (Server pages done; static site deferred until launch.)
|
||||
6. **Email sender name** — Change "noreply" to "rys" for magic link emails. ✅ Fixed
|
||||
@@ -0,0 +1,40 @@
|
||||
# Premium Launch Checklist
|
||||
|
||||
## Pre-Launch
|
||||
|
||||
### Configuration
|
||||
- [x] Update `grandfathered.txt` with all donor emails
|
||||
- [x] Switch Stripe keys from test to production
|
||||
- `STRIPE_SECRET_KEY`
|
||||
- `STRIPE_PRICE_MONTHLY`
|
||||
- `STRIPE_PRICE_YEARLY`
|
||||
- `STRIPE_WEBHOOK_SECRET`
|
||||
- [x] Configure production webhook endpoint in Stripe dashboard
|
||||
- [x] Verify `EMAIL_FROM` is set correctly for production
|
||||
|
||||
### Code
|
||||
- [ ] Merge `feature/monetize` branch into `main`
|
||||
- [x] Redeploy server with production config
|
||||
- [ ] Test production sign-in flow (magic link email)
|
||||
- [ ] Test production checkout flow (real Stripe)
|
||||
|
||||
### Extension
|
||||
- [ ] Build Chrome extension
|
||||
- [ ] Build Firefox extension
|
||||
- [ ] Submit to Chrome Web Store
|
||||
- [ ] Submit to Firefox Add-ons
|
||||
|
||||
### Static Site (lawrencehook.com/rys)
|
||||
- [ ] Update `/rys/premium/index.html` — change "Premium is Coming" to live copy
|
||||
- [ ] Add sign-in/upgrade CTA to premium page
|
||||
|
||||
## Post-Launch
|
||||
|
||||
- [ ] Verify webhooks are received in production
|
||||
- [ ] Monitor for errors in server logs
|
||||
- [ ] Test full flow as a new user
|
||||
|
||||
## Optional / Deferred
|
||||
|
||||
- [ ] Browser action icon — premium indicator in toolbar
|
||||
- [x] Add timestamps to server logs
|
||||
@@ -14,7 +14,7 @@ Leave a review!
|
||||
- [Chrome](https://chrome.google.com/webstore/detail/remove-youtube-suggestion/cdhdichomdnlaadbndgmagohccgpejae)
|
||||
- [Google Form](https://docs.google.com/forms/d/1AzQQxTWgG6M5N87jinvXKQkGS6Mehzg19XV4mjteTK0/edit)
|
||||
|
||||
Completely free. Donations welcome — [Paypal](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=FF9K9YD6K6SWG¤cy_code=USD&source=url)
|
||||
Free to use with optional premium features. Donations welcome — [Paypal](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=FF9K9YD6K6SWG¤cy_code=USD&source=url)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
# Manual Testing Guide — Premium Subscription
|
||||
|
||||
This document covers all manual testing paths for the premium monetization feature.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Extension loaded unpacked in Chrome (or Firefox)
|
||||
- Server running locally or deployed to `server.lawrencehook.com`
|
||||
- Stripe test mode configured with test price IDs
|
||||
- AWS SES configured (or use a local email trap)
|
||||
- At least one email in `data/grandfathered.json` for grandfathered testing
|
||||
- A separate non-grandfathered email for regular user testing
|
||||
- Stripe CLI installed for webhook testing (`stripe listen --forward-to localhost:3000/rys/webhook/stripe`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Initial State (Not Signed In)
|
||||
|
||||
### 1.1 Options page loads correctly
|
||||
- [ ] Open extension popup — settings menu shows "Sign In" (not "Account")
|
||||
- [ ] "Donate" link visible in header, links to PayPal
|
||||
|
||||
### 1.2 Free features work without sign-in
|
||||
- [ ] Toggle a non-premium feature (e.g., "Hide homepage recommendations")
|
||||
- [ ] Verify it takes effect on YouTube
|
||||
|
||||
### 1.3 Premium feature click — not signed in
|
||||
- [ ] Click any premium-gated toggle (marked with a lock/premium indicator)
|
||||
- [ ] Verify the "Premium Required" modal appears
|
||||
- [ ] Click "Cancel" — modal closes, setting unchanged
|
||||
- [ ] Click the premium toggle again, then click "Sign In" in the modal
|
||||
- [ ] Verify a new tab opens to `main.html?signin=1` and the popup closes
|
||||
|
||||
### 1.4 Schedule and Password (premium settings menu items)
|
||||
- [ ] Click "Schedule" in the settings menu — Premium Required modal appears
|
||||
- [ ] Click "Password" in the settings menu — Premium Required modal appears
|
||||
|
||||
---
|
||||
|
||||
## 2. Sign-In Flow
|
||||
|
||||
### 2.1 Happy path — sign in from new tab
|
||||
- [ ] From the options tab (`main.html?signin=1`), sign-in modal opens automatically
|
||||
- [ ] Enter a valid email, press Enter (or click send button)
|
||||
- [ ] Modal transitions to waiting state with countdown timer
|
||||
- [ ] Check email — magic link email received from the configured sender
|
||||
- [ ] Click magic link — browser opens server success page ("You're signed in!")
|
||||
- [ ] Return to extension tab — modal closes, status shows "Signed in successfully"
|
||||
- [ ] Header now shows "Account" instead of "Sign In"
|
||||
|
||||
### 2.2 Sign in from popup
|
||||
- [ ] Open extension popup, click "Sign In"
|
||||
- [ ] Verify popup closes and a new tab opens to `main.html?signin=1`
|
||||
- [ ] Complete the sign-in flow in the new tab
|
||||
|
||||
### 2.3 Invalid email
|
||||
- [ ] Enter an invalid email (no @ symbol)
|
||||
- [ ] Verify "Please enter a valid email" status message appears
|
||||
- [ ] No network request is made
|
||||
|
||||
### 2.4 Rate limiting
|
||||
- [ ] Send 5 magic link requests for the same email in rapid succession
|
||||
- [ ] On the 6th attempt, verify a rate limit error message appears
|
||||
- [ ] Wait for the rate limit window to pass (or clear rate limit data on server)
|
||||
|
||||
### 2.5 Cancel during polling
|
||||
- [ ] Start sign-in flow, reach waiting state
|
||||
- [ ] Click "Cancel" button
|
||||
- [ ] Verify modal closes and polling stops (no continued network requests)
|
||||
- [ ] Verify user is still not signed in
|
||||
|
||||
### 2.6 Poll timeout
|
||||
- [ ] Start sign-in flow but do NOT click the magic link
|
||||
- [ ] Wait for the 16-minute countdown to reach 0
|
||||
- [ ] Verify error state appears with "Verification timed out" message
|
||||
- [ ] Click "Retry" — email form reappears
|
||||
|
||||
### 2.7 Expired magic link
|
||||
- [ ] Start sign-in flow, wait >15 minutes, then click the magic link
|
||||
- [ ] Verify the server shows an error page (link expired)
|
||||
- [ ] Extension should show error state when poll detects 404
|
||||
|
||||
### 2.8 Multiple sign-in attempts
|
||||
- [ ] Start sign-in, reach waiting state
|
||||
- [ ] Close modal, reopen, start a new sign-in with a different email
|
||||
- [ ] Verify the previous polling is aborted (no interference)
|
||||
|
||||
---
|
||||
|
||||
## 3. Signed-In, Free User
|
||||
|
||||
### 3.1 Account modal
|
||||
- [ ] Click "Account" in settings menu
|
||||
- [ ] Verify Account modal opens showing:
|
||||
- Your email address
|
||||
- "Free Plan" status
|
||||
- "Upgrade" button visible
|
||||
- "Billing" button hidden
|
||||
- "Sign Out" button visible
|
||||
|
||||
### 3.2 Premium feature click — signed in, not premium
|
||||
- [ ] Click a premium feature toggle
|
||||
- [ ] Verify the Upgrade modal appears (NOT the Premium Required modal)
|
||||
|
||||
### 3.3 Upgrade modal
|
||||
- [ ] Verify Upgrade modal shows plan selection (Monthly / Yearly)
|
||||
- [ ] Verify Yearly is selected by default
|
||||
- [ ] Click Monthly — verify it becomes selected
|
||||
- [ ] Click Yearly — verify it becomes selected
|
||||
- [ ] Click "Cancel" — modal closes
|
||||
|
||||
### 3.4 License check — no subscription
|
||||
- [ ] Open DevTools, check console for `[license] email -> free` on server
|
||||
- [ ] Verify `is_premium` attribute on `<html>` is `"false"`
|
||||
|
||||
---
|
||||
|
||||
## 4. Checkout / Purchase Flow
|
||||
|
||||
### 4.1 Monthly subscription
|
||||
- [ ] Click a premium feature → Upgrade modal
|
||||
- [ ] Select "Monthly", click "Subscribe"
|
||||
- [ ] Verify Stripe Checkout opens in a new tab
|
||||
- [ ] Complete payment with Stripe test card (`4242 4242 4242 4242`)
|
||||
- [ ] Verify Stripe success page appears
|
||||
- [ ] Return to extension tab (click on it or close Stripe tab)
|
||||
- [ ] Verify extension auto-refreshes license (visibility/focus event triggers refresh)
|
||||
- [ ] Verify the premium feature you clicked is now enabled
|
||||
- [ ] Verify "Donate" link changed to "Premium" in header
|
||||
|
||||
### 4.2 Yearly subscription
|
||||
- [ ] Sign out, sign in with a different email
|
||||
- [ ] Repeat checkout flow selecting "Yearly"
|
||||
- [ ] Verify Stripe Checkout shows the yearly price
|
||||
- [ ] Complete payment and verify premium activates
|
||||
|
||||
### 4.3 Cancel checkout
|
||||
- [ ] Open Upgrade modal, click "Subscribe"
|
||||
- [ ] On Stripe Checkout page, click back or close the tab
|
||||
- [ ] Return to extension — verify user is still on Free Plan
|
||||
- [ ] Verify no error messages appear
|
||||
|
||||
### 4.4 Checkout with expired session
|
||||
- [ ] Clear `session_token` from `chrome.storage.local` manually (DevTools → Application → Local Storage)
|
||||
- [ ] Try to subscribe — verify error about session expired
|
||||
- [ ] Verify user is signed out and prompted to sign in again
|
||||
|
||||
---
|
||||
|
||||
## 5. Premium User Experience
|
||||
|
||||
### 5.1 All premium features accessible
|
||||
- [ ] Toggle several premium features on — verify they take effect on YouTube
|
||||
- [ ] Toggle them off — verify they revert
|
||||
|
||||
### 5.2 Account modal — premium subscriber
|
||||
- [ ] Open Account modal
|
||||
- [ ] Verify it shows:
|
||||
- Your email
|
||||
- "Premium Active" status
|
||||
- "Billing" button visible
|
||||
- "Upgrade" button hidden
|
||||
- "Sign Out" button
|
||||
|
||||
### 5.3 Billing portal
|
||||
- [ ] Click "Billing" button in Account modal
|
||||
- [ ] Verify Stripe billing portal opens in new tab
|
||||
- [ ] Verify you can see subscription details, update payment method, cancel
|
||||
- [ ] Close billing portal tab, return to extension
|
||||
|
||||
### 5.4 Schedule (premium feature)
|
||||
- [ ] Click "Schedule" in settings menu
|
||||
- [ ] Verify Schedule modal opens (not blocked by premium gate)
|
||||
- [ ] Configure a schedule and verify it works
|
||||
|
||||
### 5.5 Password (premium feature)
|
||||
- [ ] Click "Password" in settings menu
|
||||
- [ ] Verify Password modal opens (not blocked by premium gate)
|
||||
- [ ] Set a password, close settings, reopen — verify password prompt appears
|
||||
- [ ] Enter correct password — verify unlock works
|
||||
- [ ] Disable password — verify it's removed
|
||||
|
||||
---
|
||||
|
||||
## 6. Grandfathered User
|
||||
|
||||
### 6.1 Sign in with grandfathered email
|
||||
- [ ] Sign in with an email that's in `data/grandfathered.json`
|
||||
- [ ] Verify license check returns `grandfathered: true`
|
||||
|
||||
### 6.2 Account modal — grandfathered
|
||||
- [ ] Open Account modal
|
||||
- [ ] Verify it shows:
|
||||
- Your email
|
||||
- "Lifetime Premium" status
|
||||
- "Billing" button hidden (no subscription to manage)
|
||||
- "Upgrade" button hidden
|
||||
- "Sign Out" button
|
||||
|
||||
### 6.3 Premium features work
|
||||
- [ ] Verify all premium features are accessible
|
||||
- [ ] Verify "Donate" link shows "Premium"
|
||||
|
||||
### 6.4 Case-insensitive matching
|
||||
- [ ] Add `test@example.com` to `grandfathered.json`
|
||||
- [ ] Sign in with `Test@Example.com` (different case)
|
||||
- [ ] Verify user is recognized as grandfathered
|
||||
|
||||
---
|
||||
|
||||
## 7. Sign Out
|
||||
|
||||
### 7.1 Sign out clears state
|
||||
- [ ] While signed in (premium or free), click "Sign Out" in Account modal
|
||||
- [ ] Verify header changes back to "Sign In"
|
||||
- [ ] Verify "Donate" link reappears (if was showing "Premium")
|
||||
- [ ] Verify premium features are re-gated
|
||||
|
||||
### 7.2 Storage is cleared
|
||||
- [ ] After sign-out, check `chrome.storage.local` in DevTools
|
||||
- [ ] Verify `session_token`, `license_token`, and `user_email` are removed
|
||||
- [ ] Verify YouTube settings (non-premium) are NOT affected
|
||||
|
||||
---
|
||||
|
||||
## 8. Session Expiry
|
||||
|
||||
### 8.1 Expired session token
|
||||
- [ ] Sign in, then manually modify the `session_token` in storage to an expired JWT
|
||||
- [ ] Open Account modal (or trigger a license check)
|
||||
- [ ] Verify "Session expired. Please sign in again." message appears
|
||||
- [ ] Verify user is automatically signed out
|
||||
- [ ] Verify sign-in modal opens
|
||||
|
||||
### 8.2 Invalid session token
|
||||
- [ ] Replace `session_token` with garbage string in storage
|
||||
- [ ] Trigger a license check
|
||||
- [ ] Verify 401 from server causes automatic sign-out
|
||||
|
||||
---
|
||||
|
||||
## 9. License Token Caching & Refresh
|
||||
|
||||
### 9.1 Cached token is used
|
||||
- [ ] Sign in as premium user, verify license check succeeds
|
||||
- [ ] Disconnect from network (or stop the server)
|
||||
- [ ] Close and reopen extension popup
|
||||
- [ ] Verify premium features are still accessible (cached license token is valid)
|
||||
|
||||
### 9.2 Expired cached token without network
|
||||
- [ ] Manually set `license_token` to an expired JWT in storage
|
||||
- [ ] With server unreachable, open extension
|
||||
- [ ] Verify user shows as non-premium (expired cache, can't refresh)
|
||||
|
||||
### 9.3 Token refresh near expiry
|
||||
- [ ] Set a license token that expires within 24 hours (modify JWT payload)
|
||||
- [ ] Open extension with server running
|
||||
- [ ] Verify a fresh license check is triggered (not using cache)
|
||||
|
||||
### 9.4 Force refresh after checkout
|
||||
- [ ] Complete a checkout flow
|
||||
- [ ] Verify the extension calls `checkLicense(true)` (force refresh)
|
||||
- [ ] Monitor network tab — a `/license/check` request should fire immediately on tab focus
|
||||
|
||||
---
|
||||
|
||||
## 10. Subscription Lifecycle
|
||||
|
||||
### 10.1 Cancel subscription via billing portal
|
||||
- [ ] As a premium user, open Billing portal
|
||||
- [ ] Cancel the subscription in Stripe
|
||||
- [ ] Return to extension, close and reopen popup
|
||||
- [ ] Wait for license token to expire (or force refresh)
|
||||
- [ ] Verify user shows as "Free Plan" after refresh
|
||||
- [ ] Verify premium features are re-gated
|
||||
|
||||
### 10.2 Resubscribe after cancellation
|
||||
- [ ] After cancellation, click a premium feature
|
||||
- [ ] Go through checkout flow again
|
||||
- [ ] Verify premium re-activates
|
||||
|
||||
---
|
||||
|
||||
## 11. Stripe Webhooks
|
||||
|
||||
### 11.1 Webhook receives events
|
||||
- [ ] With Stripe CLI forwarding, complete a checkout
|
||||
- [ ] Verify `checkout.session.completed` event is logged on server
|
||||
- [ ] Cancel subscription — verify `customer.subscription.deleted` is logged
|
||||
|
||||
### 11.2 Invalid webhook signature
|
||||
- [ ] Send a POST to `/webhook/stripe` with invalid/missing `Stripe-Signature` header
|
||||
- [ ] Verify 400 response
|
||||
|
||||
---
|
||||
|
||||
## 12. Cross-Browser
|
||||
|
||||
### 12.1 Chrome
|
||||
- [ ] Run through sign-in, checkout, and premium feature flows on Chrome
|
||||
|
||||
### 12.2 Firefox
|
||||
- [ ] Load extension in Firefox
|
||||
- [ ] Verify sign-in flow works (popup-to-tab handoff, polling)
|
||||
- [ ] Verify premium features gate correctly
|
||||
- [ ] Verify `browser.storage.local` API works as expected
|
||||
|
||||
---
|
||||
|
||||
## 13. Edge Cases
|
||||
|
||||
### 13.1 Multiple tabs
|
||||
- [ ] Open extension settings in two tabs
|
||||
- [ ] Sign in on one tab
|
||||
- [ ] Refresh the second tab — verify it reflects signed-in state
|
||||
|
||||
### 13.2 Rapid feature toggle clicks
|
||||
- [ ] Click a premium feature rapidly multiple times
|
||||
- [ ] Verify only one modal opens, no duplicate modals or errors
|
||||
|
||||
### 13.3 Network failure during sign-in
|
||||
- [ ] Start sign-in, disconnect network after magic link is sent
|
||||
- [ ] Verify polling handles network errors gracefully (retries silently)
|
||||
- [ ] Reconnect network — verify polling resumes and succeeds when link is clicked
|
||||
|
||||
### 13.4 Server down during license check
|
||||
- [ ] Sign in successfully, then stop the server
|
||||
- [ ] Trigger a license check (reopen popup)
|
||||
- [ ] Verify cached license token is used if still valid
|
||||
- [ ] Verify no error shown to user
|
||||
|
||||
### 13.5 Extension update while signed in
|
||||
- [ ] Sign in and get premium status
|
||||
- [ ] Reload the extension (simulate update)
|
||||
- [ ] Verify session and license tokens persist across reload
|
||||
- [ ] Verify premium status is maintained
|
||||
|
||||
---
|
||||
|
||||
## 14. Analytics Verification
|
||||
|
||||
Use Mixpanel (or check console logs) to verify events fire correctly:
|
||||
|
||||
- [ ] `Sign In Started` — when email is submitted
|
||||
- [ ] `Magic Link Sent` — after server responds
|
||||
- [ ] `Sign In Success` — after verification completes
|
||||
- [ ] `Sign In Canceled` — when user cancels polling
|
||||
- [ ] `Sign In Error` — on timeout or failure
|
||||
- [ ] `Premium Feature Click` — with correct `signedIn` flag
|
||||
- [ ] `Upgrade Modal Opened` — when upgrade modal shows
|
||||
- [ ] `Checkout Started` — with correct `plan` value
|
||||
- [ ] `Checkout Completed` — after returning from successful checkout
|
||||
- [ ] `Account Modal Opened` — with `isPremium` and `source`
|
||||
- [ ] `Billing Portal Opened` — when billing portal opens
|
||||
- [ ] `Session Expired` — when 401 triggers sign-out
|
||||
- [ ] `Sign Out` — when user signs out
|
||||
- [ ] `License Check` — with `cached`, `offline`, `isPremium` properties
|
||||
@@ -0,0 +1,682 @@
|
||||
# Premium Subscription System Spec
|
||||
|
||||
This document specifies the implementation of a premium subscription system for a browser extension. It is intended to be used as a reference for implementation.
|
||||
|
||||
## Overview
|
||||
|
||||
The system enables monetization of a free, open-source browser extension through a subscription model. Users authenticate via email magic links, purchase subscriptions through Stripe, and the extension gates premium features based on subscription status.
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
- **Single public repo**: Premium feature code is not hidden. Access is gated by license checks.
|
||||
- **No application database**: Stripe is the source of truth for subscription status.
|
||||
- **Magic link authentication**: Users verify email ownership by clicking a link sent to their inbox.
|
||||
- **Polling-based handoff**: Extension polls the server to detect when magic link has been clicked.
|
||||
- **Bypass tolerance**: Determined users circumventing the paywall is acceptable.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Constants
|
||||
|
||||
All timing values should be defined in a central config file for easy adjustment.
|
||||
|
||||
| Constant | Default Value | Description |
|
||||
|----------|---------------|-------------|
|
||||
| `MAGIC_LINK_EXPIRY` | 15 minutes | How long a magic link remains valid |
|
||||
| `REQUEST_ID_EXPIRY` | 20 minutes | How long the server holds a pending auth request |
|
||||
| `POLL_INTERVAL` | 2 seconds | How often extension polls during auth |
|
||||
| `POLL_TIMEOUT` | 16 minutes | How long extension polls before giving up |
|
||||
| `SESSION_TOKEN_LIFETIME` | 30 days | How long a session token remains valid |
|
||||
| `LICENSE_TOKEN_LIFETIME` | 3 days | How long a license token remains valid |
|
||||
| `GRANDFATHERED_TOKEN_LIFETIME` | 730 days | How long a grandfathered license token remains valid |
|
||||
| `LICENSE_REFRESH_THRESHOLD` | 24 hours | Refresh license token if expiring within this window |
|
||||
| `RATE_LIMIT_WINDOW` | 1 hour | Rate limit window for magic link requests |
|
||||
| `RATE_LIMIT_MAX_REQUESTS` | 5 | Max magic link requests per email per window |
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
### AuthRequest (server-side, in-memory or temporary storage)
|
||||
|
||||
Temporary record created when user initiates login. Can be stored in Redis, or in-memory if single-server.
|
||||
|
||||
```
|
||||
{
|
||||
request_id: string, // Random, unguessable ID (e.g., UUID v4)
|
||||
email: string, // User's email address
|
||||
status: "pending" | "verified",
|
||||
created_at: timestamp,
|
||||
session_token: string | null // Populated once verified
|
||||
}
|
||||
```
|
||||
|
||||
### Session Token (JWT)
|
||||
|
||||
Issued after successful magic link verification. Contains:
|
||||
|
||||
```
|
||||
{
|
||||
email: string, // Verified email address
|
||||
iat: number, // Issued at timestamp
|
||||
exp: number // Expiration timestamp
|
||||
}
|
||||
```
|
||||
|
||||
Signed with a server-side secret (`JWT_SECRET` environment variable).
|
||||
|
||||
### License Token (JWT)
|
||||
|
||||
Issued by the server during license checks. Embedded premium status avoids extra network calls. Contains:
|
||||
|
||||
```
|
||||
{
|
||||
email: string, // User's email address
|
||||
premium: boolean, // Whether user has premium access
|
||||
grandfathered: boolean, // Whether user is a past donor
|
||||
exp: number // Expiration timestamp
|
||||
}
|
||||
```
|
||||
|
||||
Lifetime is 3 days for regular users, 730 days for grandfathered users. The extension refreshes the token when it's within 24 hours of expiry.
|
||||
|
||||
### Extension Local Storage
|
||||
|
||||
Stored via `chrome.storage.local` (works in both Chrome and Firefox):
|
||||
|
||||
```
|
||||
{
|
||||
session_token: string | null, // JWT from auth flow
|
||||
license_token: string | null, // JWT with embedded premium status
|
||||
user_email: string | null // User's email for display
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pricing
|
||||
|
||||
| Plan | Price | Stripe Price ID |
|
||||
|------|-------|-----------------|
|
||||
| Monthly | $X.XX/month | Set in environment variable `STRIPE_PRICE_MONTHLY` |
|
||||
| Yearly | $X.XX/year | Set in environment variable `STRIPE_PRICE_YEARLY` |
|
||||
|
||||
Yearly plan should be positioned as a discount (e.g., ~2 months free).
|
||||
|
||||
---
|
||||
|
||||
## Server Specification
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```
|
||||
JWT_SECRET=<random-secret-for-signing-tokens>
|
||||
STRIPE_SECRET_KEY=<stripe-secret-key>
|
||||
STRIPE_PRICE_MONTHLY=<stripe-price-id>
|
||||
STRIPE_PRICE_YEARLY=<stripe-price-id>
|
||||
STRIPE_WEBHOOK_SECRET=<stripe-webhook-signing-secret>
|
||||
EMAIL_FROM=<sender-email-address>
|
||||
BASE_URL=<https://yourserver.com>
|
||||
AWS_REGION=<aws-region> # defaults to us-east-1
|
||||
DATA_DIR=<data-directory-path> # defaults to ./data
|
||||
```
|
||||
|
||||
Email is sent via AWS SES. AWS credentials are expected via standard environment or IAM role.
|
||||
|
||||
### Endpoints
|
||||
|
||||
---
|
||||
|
||||
#### `POST /auth/send-magic-link`
|
||||
|
||||
Initiates the authentication flow.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Server behavior:**
|
||||
1. Validate email format
|
||||
2. Generate a random `request_id` (UUID v4)
|
||||
3. Store AuthRequest with status "pending"
|
||||
4. Send email containing link: `{BASE_URL}/auth/verify?token={request_id}`
|
||||
5. Return `request_id` to caller
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"request_id": "abc123..."
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `400` — Invalid email format
|
||||
- `429` — Rate limited (5 requests per email per hour). Response includes `Retry-After` header.
|
||||
|
||||
---
|
||||
|
||||
#### `GET /auth/verify`
|
||||
|
||||
Magic link target. User's browser hits this when they click the email link.
|
||||
|
||||
**Query parameters:**
|
||||
- `token` — The `request_id`
|
||||
|
||||
**Server behavior:**
|
||||
1. Look up AuthRequest by `request_id`
|
||||
2. If not found or expired → show error page
|
||||
3. If found and pending:
|
||||
- Generate JWT session token with user's email
|
||||
- Update AuthRequest: status = "verified", session_token = JWT
|
||||
4. Show success page: "You're signed in! You can close this tab."
|
||||
|
||||
**Response:** HTML page (not JSON)
|
||||
|
||||
---
|
||||
|
||||
#### `GET /auth/poll`
|
||||
|
||||
Extension polls this to check if magic link has been clicked.
|
||||
|
||||
**Query parameters:**
|
||||
- `request_id` — The `request_id` returned from `/auth/send-magic-link`
|
||||
|
||||
**Server behavior:**
|
||||
1. Look up AuthRequest by `request_id`
|
||||
2. If not found or expired → return error
|
||||
3. If status is "pending" → return pending status
|
||||
4. If status is "verified" → return session token, then delete the AuthRequest
|
||||
|
||||
**Response (pending):**
|
||||
```json
|
||||
{
|
||||
"status": "pending"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (verified):**
|
||||
```json
|
||||
{
|
||||
"status": "verified",
|
||||
"session_token": "eyJhbGc...",
|
||||
"email": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `404` — Unknown or expired `request_id`
|
||||
|
||||
---
|
||||
|
||||
#### `GET /license/check`
|
||||
|
||||
Returns a signed license token containing the user's premium status.
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
Authorization: Bearer <session_token>
|
||||
```
|
||||
|
||||
**Server behavior:**
|
||||
1. Validate and decode JWT session token
|
||||
2. Extract email from token
|
||||
3. Check if email is in the grandfathered list (case-insensitive)
|
||||
4. If grandfathered → return license token with `premium: true, grandfathered: true` (730-day lifetime)
|
||||
5. Otherwise, query Stripe for customer by email
|
||||
6. Check for active subscription
|
||||
7. Return license token with `premium: true/false, grandfathered: false` (3-day lifetime)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"license_token": "eyJhbGc..."
|
||||
}
|
||||
```
|
||||
|
||||
The license token is a JWT containing `{ email, premium, grandfathered, exp }`.
|
||||
|
||||
**Errors:**
|
||||
- `401` — Missing, invalid, or expired session token
|
||||
|
||||
---
|
||||
|
||||
#### `POST /checkout/create`
|
||||
|
||||
Creates a Stripe Checkout session for purchasing a subscription.
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
Authorization: Bearer <session_token>
|
||||
```
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"plan": "monthly" | "yearly"
|
||||
}
|
||||
```
|
||||
|
||||
**Server behavior:**
|
||||
1. Validate and decode JWT
|
||||
2. Extract email from token
|
||||
3. Look up or create Stripe customer by email
|
||||
4. Create Stripe Checkout session with:
|
||||
- Customer ID
|
||||
- Appropriate price ID based on plan
|
||||
- Success URL: `{BASE_URL}/checkout/success`
|
||||
- Cancel URL: `{BASE_URL}/checkout/cancel`
|
||||
5. Return checkout URL
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"checkout_url": "https://checkout.stripe.com/..."
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `401` — Missing, invalid, or expired token
|
||||
- `400` — Invalid plan value
|
||||
|
||||
---
|
||||
|
||||
#### `POST /webhook/stripe`
|
||||
|
||||
Receives Stripe webhook events.
|
||||
|
||||
**Headers:**
|
||||
- `Stripe-Signature` — Used to verify webhook authenticity
|
||||
|
||||
**Server behavior:**
|
||||
1. Verify webhook signature using `STRIPE_WEBHOOK_SECRET`
|
||||
2. Handle relevant events:
|
||||
- `checkout.session.completed` — Subscription created (optional: send welcome email)
|
||||
- `customer.subscription.deleted` — Subscription canceled
|
||||
- `customer.subscription.updated` — Subscription changed
|
||||
- `invoice.payment_failed` — Payment failed (optional: send warning email)
|
||||
|
||||
Since Stripe is the source of truth, these events are mainly useful for sending transactional emails or logging. The `/license/check` endpoint always queries Stripe directly.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"received": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `GET /checkout/success`
|
||||
|
||||
Simple HTML page shown after successful checkout.
|
||||
|
||||
**Content:** "Payment successful! You can close this tab and return to the extension."
|
||||
|
||||
---
|
||||
|
||||
#### `GET /checkout/cancel`
|
||||
|
||||
Simple HTML page shown if user cancels checkout.
|
||||
|
||||
**Content:** "Payment canceled. You can close this tab and try again from the extension."
|
||||
|
||||
---
|
||||
|
||||
#### `POST /billing/portal`
|
||||
|
||||
Creates a Stripe billing portal session for managing an existing subscription.
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
Authorization: Bearer <session_token>
|
||||
```
|
||||
|
||||
**Server behavior:**
|
||||
1. Validate and decode JWT
|
||||
2. Look up Stripe customer by email
|
||||
3. Create billing portal session
|
||||
4. Return portal URL
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"url": "https://billing.stripe.com/..."
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `401` — Missing, invalid, or expired token
|
||||
- `404` — No Stripe customer found for this email
|
||||
|
||||
---
|
||||
|
||||
#### `GET /billing/return`
|
||||
|
||||
Simple HTML page shown after returning from the Stripe billing portal.
|
||||
|
||||
**Content:** "Billing updated. You can close this tab and return to the extension."
|
||||
|
||||
---
|
||||
|
||||
### Stripe API Usage
|
||||
|
||||
**Find customer by email:**
|
||||
```
|
||||
stripe.customers.list({ email: email, limit: 1 })
|
||||
```
|
||||
|
||||
**Check for active subscription:**
|
||||
```
|
||||
stripe.subscriptions.list({ customer: customer_id, status: 'active', limit: 1 })
|
||||
```
|
||||
|
||||
**Create customer:**
|
||||
```
|
||||
stripe.customers.create({ email: email })
|
||||
```
|
||||
|
||||
**Create checkout session:**
|
||||
```
|
||||
stripe.checkout.sessions.create({
|
||||
customer: customer_id,
|
||||
mode: 'subscription',
|
||||
line_items: [{ price: price_id, quantity: 1 }],
|
||||
success_url: success_url,
|
||||
cancel_url: cancel_url
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Extension Specification
|
||||
|
||||
### Permissions Required
|
||||
|
||||
**manifest.json (Manifest V3 for Chrome, V2/V3 for Firefox):**
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": [
|
||||
"storage"
|
||||
],
|
||||
"host_permissions": [
|
||||
"*://*.youtube.com/*"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
No special permissions beyond storage and YouTube host access. The premium server is accessed via `fetch()` which does not require additional host permissions in MV3.
|
||||
|
||||
---
|
||||
|
||||
### Auth Module
|
||||
|
||||
Handles sign-in and session management.
|
||||
|
||||
#### `sendMagicLink(email: string): Promise<string>`
|
||||
|
||||
1. Call `POST /auth/send-magic-link` with email
|
||||
2. Handle 429 (rate limit) with user-friendly error
|
||||
3. Return `request_id`
|
||||
|
||||
#### `pollForVerification(requestId, onStatusUpdate, options): Promise<Result>`
|
||||
|
||||
1. Call `GET /auth/poll?request_id={requestId}` every 2 seconds
|
||||
2. If status is "pending", call `onStatusUpdate` with elapsed time, continue polling
|
||||
3. If status is "verified", store session token and email in `chrome.storage.local`, return `{ success: true }`
|
||||
4. Timeout after 16 minutes with error
|
||||
5. Supports `AbortController` signal via `options.signal` for cancellation
|
||||
6. If aborted, return `{ canceled: true }`
|
||||
7. Network errors during polling are silently retried; fatal errors (404, non-OK) throw
|
||||
|
||||
#### `isSignedIn(): Promise<boolean>`
|
||||
|
||||
Returns true if a session token exists in storage.
|
||||
|
||||
#### `getUserEmail(): Promise<string | null>`
|
||||
|
||||
Returns the stored user email.
|
||||
|
||||
#### `getSessionToken(): Promise<string | null>`
|
||||
|
||||
Returns the stored session token.
|
||||
|
||||
#### `signOut(): Promise<void>`
|
||||
|
||||
Clears session token, license token, and email from `chrome.storage.local`.
|
||||
|
||||
---
|
||||
|
||||
### License Module
|
||||
|
||||
Handles premium status checking and caching via license tokens (JWTs with embedded premium status).
|
||||
|
||||
#### `checkLicense(forceRefresh?: boolean): Promise<LicenseResult>`
|
||||
|
||||
1. Read cached session token and license token from `chrome.storage.local`
|
||||
2. If no session token, return `{ isPremium: false }`
|
||||
3. Decode license token JWT (no signature verification — we trust our server)
|
||||
4. If not forcing refresh, token is valid, and not expiring within 24 hours:
|
||||
- Return cached status from token payload
|
||||
5. Otherwise, call `GET /license/check` with session token
|
||||
6. If 401 response: auto sign-out, return `{ isPremium: false, signedOut: true }`
|
||||
7. Store new license token in `chrome.storage.local`
|
||||
8. Return status from new token
|
||||
9. On network error: fall back to cached token if still valid (not expired)
|
||||
|
||||
Returns `{ isPremium, source, cached?, offline?, signedOut?, error? }` where `source` is `'grandfathered'` or `null`.
|
||||
|
||||
#### `isPremium(): Promise<boolean>`
|
||||
|
||||
Quick synchronous-style check. Reads cached license token, decodes JWT, returns `true` only if `premium === true` and token not expired. No network call.
|
||||
|
||||
#### `createCheckoutSession(plan): Promise<string>`
|
||||
|
||||
Calls `POST /checkout/create` with plan, returns checkout URL. Auto signs out on 401.
|
||||
|
||||
#### `createBillingPortalSession(): Promise<string>`
|
||||
|
||||
Calls `POST /billing/portal`, returns portal URL. Auto signs out on 401.
|
||||
|
||||
---
|
||||
|
||||
### Checkout Module
|
||||
|
||||
Handles upgrade flow.
|
||||
|
||||
#### `startCheckout(plan: 'monthly' | 'yearly'): Promise<void>`
|
||||
|
||||
1. Get session token from auth module
|
||||
2. If not signed in, throw error (UI should prompt sign-in first)
|
||||
3. Call `POST /checkout/create` with plan
|
||||
4. Open returned `checkout_url` in new tab
|
||||
|
||||
---
|
||||
|
||||
### Feature Gating
|
||||
|
||||
Features are marked with `premium: true` in `src/shared/main.js`. When a user clicks a premium-gated option:
|
||||
|
||||
1. Check `HTML.getAttribute('is_premium')`
|
||||
2. If not premium:
|
||||
- Check if signed in via `Auth.isSignedIn()`
|
||||
- If not signed in → show Premium Required Modal (then sign-in flow)
|
||||
- If signed in but not premium → show Upgrade Modal
|
||||
3. The `is_premium` attribute is set on page load via `License.checkLicense()` and updated after sign-in, checkout, and tab focus events
|
||||
|
||||
Schedule and Password settings menus are also gated with the same `handlePremiumFeatureClick()` handler.
|
||||
|
||||
---
|
||||
|
||||
### UI Components
|
||||
|
||||
1. **Premium Required Modal** (for non-signed-in users)
|
||||
- Shown when a non-signed-in user clicks a premium feature
|
||||
- "Sign In" button → opens sign-in flow
|
||||
- "Cancel" button → closes modal
|
||||
|
||||
2. **Sign-In Modal**
|
||||
- Email input field with Enter key support
|
||||
- "Send magic link" button
|
||||
- "Check your email" waiting state with countdown timer (16 min)
|
||||
- Cancel button to abort polling (uses AbortController)
|
||||
- Error state with retry button
|
||||
- **Important**: When triggered from popup, opens a new tab (`main.html?signin=1`) because popup closing would kill the polling loop
|
||||
|
||||
3. **Account Modal**
|
||||
- Shows signed-in email
|
||||
- Premium status display:
|
||||
- "Lifetime Premium" (for grandfathered users, no billing button)
|
||||
- "Premium Active" (for subscribers, with billing button)
|
||||
- "Free Plan" (with upgrade button)
|
||||
- Billing button → opens Stripe customer portal in new tab
|
||||
- Sign-out button → clears all auth data
|
||||
|
||||
4. **Upgrade Modal**
|
||||
- Plan selection: Monthly / Yearly (defaults to yearly)
|
||||
- "Subscribe" button → creates Stripe checkout session, opens in new tab
|
||||
- Sets `awaitingUpgrade` flag; on tab refocus, auto-refreshes license
|
||||
|
||||
5. **Header UI changes**
|
||||
- "Sign In" text when not signed in → "Account" when signed in
|
||||
- "Donate" link → changes to "Premium" (no link) when premium is active
|
||||
|
||||
---
|
||||
|
||||
## Sequence Diagrams
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
```
|
||||
User Extension Server Email
|
||||
| | | |
|
||||
|--Enter email---->| | |
|
||||
| |--POST /send-magic-->| |
|
||||
| |<--{ request_id }----| |
|
||||
| | |----Magic link----->|
|
||||
| |--GET /poll--------->| |
|
||||
| |<--{ pending }-------| |
|
||||
| | ... | |
|
||||
|<-----------------+---------------------+-<--Click link------|
|
||||
| | | |
|
||||
| | GET /verify |
|
||||
| | |--Show success page |
|
||||
| |--GET /poll--------->| |
|
||||
| |<--{ verified, token } |
|
||||
| | | |
|
||||
| |--Store token locally| |
|
||||
|<--Signed in!-----| | |
|
||||
```
|
||||
|
||||
### License Check Flow
|
||||
|
||||
```
|
||||
Extension Server Stripe
|
||||
| | |
|
||||
|---GET /license/check----->| |
|
||||
| (with session token) | |
|
||||
| |---Get customer by email-->|
|
||||
| |<--Customer data-----------|
|
||||
| | |
|
||||
| |---List subscriptions----->|
|
||||
| |<--Subscription data-------|
|
||||
| | |
|
||||
|<--{ premium: true/false }--| |
|
||||
| | |
|
||||
|---Cache result locally | |
|
||||
```
|
||||
|
||||
### Purchase Flow
|
||||
|
||||
```
|
||||
User Extension Server Stripe
|
||||
| | | |
|
||||
|--Click upgrade-->| | |
|
||||
| |--POST /checkout---->| |
|
||||
| | { plan: "yearly" }| |
|
||||
| | |--Create session--->|
|
||||
| | |<--Session URL------|
|
||||
| |<--{ checkout_url }--| |
|
||||
| | | |
|
||||
|<--Open checkout--| | |
|
||||
| | | |
|
||||
|------------ User completes payment on Stripe ------------->|
|
||||
| | | |
|
||||
| | |<--Webhook: paid----|
|
||||
| | | |
|
||||
|--Return to ext-->| | |
|
||||
| |---Check license---->| |
|
||||
| |<--{ premium: true }-| |
|
||||
|<--Premium active!| | |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **JWT secret**: Use a strong, random secret (>32 characters). Rotate periodically.
|
||||
2. **HTTPS only**: All server endpoints must be HTTPS.
|
||||
3. **Webhook verification**: Always verify Stripe webhook signatures.
|
||||
4. **Rate limiting**: 5 requests per email per hour on `/auth/send-magic-link`. Returns 429 with `Retry-After` header.
|
||||
5. **Request ID entropy**: UUIDv4 (128 bits of entropy).
|
||||
6. **Token in memory during polling**: `request_id` kept in memory only, not persisted to storage.
|
||||
7. **CORS**: Only chrome-extension:// and moz-extension:// origins allowed.
|
||||
8. **License token decoding**: Extension decodes JWT payload without signature verification (trusts the server). This is acceptable given the bypass-tolerant design.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Extension-side
|
||||
|
||||
- **Network errors**: Show retry option, fall back to cached license status
|
||||
- **Auth expired**: Clear session, prompt re-login
|
||||
- **Poll timeout**: Show "Link expired, try again" message
|
||||
|
||||
### Server-side
|
||||
|
||||
- **Invalid email**: 400 error with message
|
||||
- **Expired magic link**: Show friendly error page with "Request new link" option
|
||||
- **Stripe API errors**: Log error, return 500 with generic message
|
||||
- **Invalid JWT**: 401 error
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
See `TESTING.md` for detailed manual testing instructions covering all user paths.
|
||||
|
||||
---
|
||||
|
||||
## Grandfathered Users
|
||||
|
||||
Past donors are stored in `data/grandfathered.json` (array of email strings). During license checks:
|
||||
|
||||
1. Email is matched case-insensitively
|
||||
2. Grandfathered users receive a license token with `premium: true, grandfathered: true` and a 730-day lifetime
|
||||
3. The UI displays "Lifetime Premium" and hides the billing portal button
|
||||
4. No Stripe query is made for grandfathered users
|
||||
|
||||
## Analytics
|
||||
|
||||
Events are tracked via Mixpanel. Key events:
|
||||
|
||||
- `License Check` — with `isPremium`, `source`, `cached`, `offline`, `error` properties
|
||||
- `Premium Feature Click` — with `signedIn` flag
|
||||
- `Sign In Started`, `Magic Link Sent`, `Sign In Success`, `Sign In Error`, `Sign In Canceled`
|
||||
- `Checkout Started`, `Checkout Completed`, `Checkout Error` — with `plan` and `source`
|
||||
- `Upgrade Modal Opened`, `Account Modal Opened`
|
||||
- `Billing Portal Opened`, `Billing Portal Error`
|
||||
- `Session Expired`, `Sign Out`
|
||||
|
||||
## Future Considerations (Out of Scope)
|
||||
|
||||
These are not part of the initial implementation but may be relevant later:
|
||||
|
||||
- **Lifetime plans**: One-time purchase option via Stripe
|
||||
- **Team/family plans**: Multiple users under one subscription
|
||||
- **Promo codes**: Stripe coupon integration
|
||||
- **Trial periods**: Free trial before requiring payment
|
||||
@@ -0,0 +1,18 @@
|
||||
# Server
|
||||
PORT=3000
|
||||
BASE_URL=https://yourserver.com
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-random-secret-here-min-32-chars
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_PRICE_MONTHLY=price_...
|
||||
STRIPE_PRICE_YEARLY=price_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# AWS SES
|
||||
AWS_REGION=us-east-1
|
||||
AWS_ACCESS_KEY_ID=your-access-key
|
||||
AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
data/
|
||||
tests/test-data/
|
||||
@@ -0,0 +1,72 @@
|
||||
# RYS Premium Server
|
||||
|
||||
Backend server for the premium subscription system.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Copy env and fill in values
|
||||
cp .env.example .env
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Start production server
|
||||
npm start
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/health` | GET | No | Health check |
|
||||
| `/auth/send-magic-link` | POST | No | Send magic link email |
|
||||
| `/auth/verify` | GET | No | Magic link target (HTML) |
|
||||
| `/auth/poll` | GET | No | Poll for verification status |
|
||||
| `/license/check` | GET | Yes | Check premium status |
|
||||
| `/checkout/create` | POST | Yes | Create Stripe checkout session |
|
||||
| `/checkout/success` | GET | No | Success page (HTML) |
|
||||
| `/checkout/cancel` | GET | No | Cancel page (HTML) |
|
||||
| `/billing/portal` | POST | Yes | Create billing portal session |
|
||||
| `/billing/return` | GET | No | Return page (HTML) |
|
||||
| `/webhook/stripe` | POST | No* | Stripe webhook |
|
||||
|
||||
*Authenticated via Stripe signature
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
server/
|
||||
├── src/
|
||||
│ ├── index.js # Entry point
|
||||
│ ├── config.js # Configuration
|
||||
│ ├── routes/ # Express routes
|
||||
│ ├── services/ # Stripe, JWT, email
|
||||
│ └── storage/ # File-based storage
|
||||
├── data/
|
||||
│ ├── grandfathered.json # Donor emails (gitignored)
|
||||
│ ├── auth-requests/ # Pending auth (auto-created)
|
||||
│ └── rate-limits/ # Rate limit counters (auto-created)
|
||||
├── tests/
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Grandfathered Users
|
||||
|
||||
Past donors get lifetime premium. Store emails in `data/grandfathered.json`:
|
||||
|
||||
```json
|
||||
["donor1@example.com", "donor2@example.com"]
|
||||
```
|
||||
|
||||
This file is gitignored. See `data.example/` for format.
|
||||
|
||||
## Full Documentation
|
||||
|
||||
See [DEPLOYMENT.md](../DEPLOYMENT.md) for complete setup, deployment, and testing instructions.
|
||||
@@ -0,0 +1,4 @@
|
||||
[
|
||||
"donor1@example.com",
|
||||
"donor2@example.com"
|
||||
]
|
||||
Generated
+2630
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "rys-premium-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Premium subscription server for Remove YouTube Suggestions extension",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"test": "node --test --test-concurrency=1 tests/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-ses": "^3.500.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.1",
|
||||
"stripe": "^14.12.0",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"supertest": "^6.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Configuration constants
|
||||
// All timing values in milliseconds unless noted
|
||||
|
||||
// Use getters for values that may change in tests
|
||||
module.exports = {
|
||||
// Server
|
||||
get PORT() { return process.env.PORT || 3000; },
|
||||
get BASE_URL() { return process.env.BASE_URL || 'http://localhost:3000'; },
|
||||
|
||||
// JWT
|
||||
get JWT_SECRET() { return process.env.JWT_SECRET; },
|
||||
SESSION_TOKEN_LIFETIME_DAYS: 30,
|
||||
LICENSE_TOKEN_LIFETIME_DAYS: 3,
|
||||
GRANDFATHERED_TOKEN_LIFETIME_DAYS: 730, // 2 years
|
||||
|
||||
// Timing
|
||||
MAGIC_LINK_EXPIRY_MS: 15 * 60 * 1000, // 15 minutes
|
||||
REQUEST_ID_EXPIRY_MS: 20 * 60 * 1000, // 20 minutes
|
||||
RATE_LIMIT_WINDOW_MS: 60 * 60 * 1000, // 1 hour
|
||||
RATE_LIMIT_MAX_REQUESTS: 5, // per email per window
|
||||
|
||||
// Stripe
|
||||
get STRIPE_SECRET_KEY() { return process.env.STRIPE_SECRET_KEY; },
|
||||
get STRIPE_PRICE_MONTHLY() { return process.env.STRIPE_PRICE_MONTHLY; },
|
||||
get STRIPE_PRICE_YEARLY() { return process.env.STRIPE_PRICE_YEARLY; },
|
||||
get STRIPE_WEBHOOK_SECRET() { return process.env.STRIPE_WEBHOOK_SECRET; },
|
||||
|
||||
// AWS SES
|
||||
get AWS_REGION() { return process.env.AWS_REGION || 'us-east-1'; },
|
||||
get EMAIL_FROM() { return process.env.EMAIL_FROM; },
|
||||
|
||||
// File paths
|
||||
get DATA_DIR() { return process.env.DATA_DIR || './data'; },
|
||||
AUTH_REQUESTS_DIR: 'auth-requests',
|
||||
RATE_LIMITS_DIR: 'rate-limits',
|
||||
GRANDFATHERED_FILE: 'grandfathered.txt',
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
require('dotenv').config();
|
||||
|
||||
// Add timestamps to all console output
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
const timestamp = () => new Date().toISOString();
|
||||
console.log = (...args) => originalLog(timestamp(), ...args);
|
||||
console.error = (...args) => originalError(timestamp(), ...args);
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const morgan = require('morgan');
|
||||
const config = require('./config');
|
||||
const storage = require('./storage');
|
||||
|
||||
// Route imports
|
||||
const authRoutes = require('./routes/auth');
|
||||
const licenseRoutes = require('./routes/license');
|
||||
const checkoutRoutes = require('./routes/checkout');
|
||||
const billingRoutes = require('./routes/billing');
|
||||
const webhookRoutes = require('./routes/webhook');
|
||||
|
||||
const app = express();
|
||||
|
||||
function isAllowedOrigin(origin) {
|
||||
// Allow requests with no origin (like mobile apps or curl)
|
||||
if (!origin) return true;
|
||||
|
||||
// Allow Chrome and Firefox extension origins
|
||||
if (origin.startsWith('chrome-extension://') ||
|
||||
origin.startsWith('moz-extension://')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow same-origin requests (for testing)
|
||||
try {
|
||||
const { hostname } = new URL(origin);
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// CORS configuration for extension origins
|
||||
app.use(cors({
|
||||
origin: (origin, callback) => {
|
||||
if (isAllowedOrigin(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
return callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Stripe webhook needs raw body - must be before express.json()
|
||||
app.use('/webhook/stripe', express.raw({ type: 'application/json' }));
|
||||
|
||||
// JSON parsing for all other routes
|
||||
app.use(express.json());
|
||||
|
||||
// Request logging
|
||||
app.use(morgan(':method :url :status :response-time ms', {
|
||||
stream: { write: msg => console.log(msg.trimEnd()) },
|
||||
}));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Mount routes
|
||||
app.use('/auth', authRoutes);
|
||||
app.use('/license', licenseRoutes);
|
||||
app.use('/checkout', checkoutRoutes);
|
||||
app.use('/billing', billingRoutes);
|
||||
app.use('/webhook', webhookRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
if (err && err.message === 'Not allowed by CORS') {
|
||||
return res.status(403).json({ error: 'CORS origin denied' });
|
||||
}
|
||||
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
// Periodic cleanup of expired data (every 5 minutes)
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
function runCleanup() {
|
||||
try {
|
||||
storage.pruneExpiredAuthRequests();
|
||||
storage.pruneExpiredRateLimits();
|
||||
} catch (err) {
|
||||
console.error('Cleanup error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Start server
|
||||
function start() {
|
||||
// Validate required config
|
||||
const required = [
|
||||
'JWT_SECRET',
|
||||
'STRIPE_SECRET_KEY',
|
||||
'STRIPE_WEBHOOK_SECRET',
|
||||
'STRIPE_PRICE_MONTHLY',
|
||||
'STRIPE_PRICE_YEARLY',
|
||||
'EMAIL_FROM',
|
||||
];
|
||||
const missing = required.filter(key => !config[key]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(`Missing required environment variables: ${missing.join(', ')}`);
|
||||
console.error('See .env.example for required configuration');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate JWT_SECRET strength
|
||||
if (config.JWT_SECRET.length < 32) {
|
||||
console.error('JWT_SECRET must be at least 32 characters long');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure data directories exist
|
||||
storage.ensureDirectories();
|
||||
|
||||
// Start cleanup interval
|
||||
setInterval(runCleanup, CLEANUP_INTERVAL_MS);
|
||||
|
||||
// Start listening
|
||||
const grandfathered = storage.readGrandfatheredEmails();
|
||||
console.log(`Loaded ${grandfathered.size} grandfathered emails`);
|
||||
|
||||
app.listen(config.PORT, () => {
|
||||
console.log(`RYS Premium Server running on port ${config.PORT}`);
|
||||
console.log(`Base URL: ${config.BASE_URL}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
module.exports = { app, start };
|
||||
|
||||
// Start server if run directly
|
||||
if (require.main === module) {
|
||||
start();
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
const express = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const config = require('../config');
|
||||
const storage = require('../storage');
|
||||
const { sendMagicLinkEmail } = require('../services/email');
|
||||
const { generateSessionToken } = require('../services/jwt');
|
||||
const { checkPremiumStatus } = require('../services/stripe');
|
||||
const { renderPage } = require('../templates');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Simple in-memory rate limiter for poll endpoint (by IP)
|
||||
const pollRateLimits = new Map();
|
||||
const POLL_RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
||||
const POLL_RATE_LIMIT_MAX = 60; // 60 requests per minute per IP
|
||||
|
||||
function checkPollRateLimit(ip) {
|
||||
const now = Date.now();
|
||||
const record = pollRateLimits.get(ip);
|
||||
|
||||
if (!record || now > record.resetTime) {
|
||||
pollRateLimits.set(ip, { count: 1, resetTime: now + POLL_RATE_LIMIT_WINDOW_MS });
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
if (record.count >= POLL_RATE_LIMIT_MAX) {
|
||||
return { allowed: false, retryAfter: Math.ceil((record.resetTime - now) / 1000) };
|
||||
}
|
||||
|
||||
record.count++;
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Clean up old entries periodically (unref so it doesn't prevent process exit)
|
||||
const cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, record] of pollRateLimits) {
|
||||
if (now > record.resetTime) {
|
||||
pollRateLimits.delete(ip);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000); // Every 5 minutes
|
||||
cleanupInterval.unref();
|
||||
|
||||
// Simple email validation
|
||||
function isValidEmail(email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
// POST /auth/send-magic-link
|
||||
router.post('/send-magic-link', async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email || !isValidEmail(email)) {
|
||||
return res.status(400).json({ error: 'Invalid email format' });
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
const rateLimit = storage.checkRateLimit(email);
|
||||
if (!rateLimit.allowed) {
|
||||
const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
|
||||
res.set('Retry-After', retryAfter);
|
||||
return res.status(429).json({
|
||||
error: 'Too many requests. Please try again later.',
|
||||
retryAfter,
|
||||
});
|
||||
}
|
||||
|
||||
// Generate request ID and create auth request
|
||||
const requestId = uuidv4();
|
||||
storage.createAuthRequest(requestId, email);
|
||||
|
||||
// Send magic link email
|
||||
const magicLinkUrl = `${config.BASE_URL}/auth/verify?token=${requestId}`;
|
||||
try {
|
||||
const status = await checkPremiumStatus(email);
|
||||
await sendMagicLinkEmail(email, magicLinkUrl, { isPremium: status.premium });
|
||||
} catch (err) {
|
||||
storage.deleteAuthRequest(requestId);
|
||||
throw err;
|
||||
}
|
||||
|
||||
console.log(`[auth] Magic link sent to ${email}`);
|
||||
res.json({ request_id: requestId });
|
||||
} catch (err) {
|
||||
console.error('Error sending magic link:', err);
|
||||
res.status(500).json({ error: 'Failed to send magic link' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /auth/verify - Magic link target (returns HTML)
|
||||
router.get('/verify', async (req, res) => {
|
||||
const { token: requestId } = req.query;
|
||||
|
||||
if (!requestId) {
|
||||
return res.status(400).send(renderErrorPage('Missing token'));
|
||||
}
|
||||
|
||||
const authRequest = storage.getAuthRequest(requestId);
|
||||
|
||||
if (!authRequest) {
|
||||
return res.status(404).send(renderErrorPage('Link expired or invalid. Please request a new sign-in link.'));
|
||||
}
|
||||
|
||||
// Check if magic link itself is expired (stricter than request expiry)
|
||||
if (Date.now() - authRequest.created_at > config.MAGIC_LINK_EXPIRY_MS) {
|
||||
storage.deleteAuthRequest(requestId);
|
||||
return res.status(410).send(renderErrorPage('Link expired. Please request a new sign-in link.'));
|
||||
}
|
||||
|
||||
if (authRequest.status === 'pending') {
|
||||
// Generate session token and mark as verified
|
||||
const sessionToken = generateSessionToken(authRequest.email);
|
||||
storage.updateAuthRequest(requestId, {
|
||||
status: 'verified',
|
||||
session_token: sessionToken,
|
||||
});
|
||||
storage.decrementRateLimit(authRequest.email);
|
||||
console.log(`[auth] Email verified: ${authRequest.email}`);
|
||||
}
|
||||
|
||||
res.send(renderSuccessPage());
|
||||
});
|
||||
|
||||
// GET /auth/poll - Extension polls this to check verification status
|
||||
router.get('/poll', async (req, res) => {
|
||||
// Rate limit by IP
|
||||
const ip = req.ip || req.connection.remoteAddress;
|
||||
const rateLimit = checkPollRateLimit(ip);
|
||||
if (!rateLimit.allowed) {
|
||||
res.set('Retry-After', rateLimit.retryAfter);
|
||||
return res.status(429).json({ error: 'Too many requests', retryAfter: rateLimit.retryAfter });
|
||||
}
|
||||
|
||||
const { request_id: requestId } = req.query;
|
||||
|
||||
if (!requestId) {
|
||||
return res.status(400).json({ error: 'Missing request_id' });
|
||||
}
|
||||
|
||||
const authRequest = storage.getAuthRequest(requestId);
|
||||
|
||||
if (!authRequest) {
|
||||
return res.status(404).json({ error: 'Unknown or expired request_id' });
|
||||
}
|
||||
|
||||
if (authRequest.status === 'pending') {
|
||||
return res.json({ status: 'pending' });
|
||||
}
|
||||
|
||||
if (authRequest.status === 'verified') {
|
||||
// Return token and delete the request
|
||||
const response = {
|
||||
status: 'verified',
|
||||
session_token: authRequest.session_token,
|
||||
email: authRequest.email,
|
||||
};
|
||||
|
||||
res.once('finish', () => {
|
||||
storage.deleteAuthRequest(requestId);
|
||||
});
|
||||
|
||||
return res.json(response);
|
||||
}
|
||||
|
||||
// Unknown status
|
||||
res.status(500).json({ error: 'Unknown auth request status' });
|
||||
});
|
||||
|
||||
// HTML utilities
|
||||
function escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
return text.replace(/[&<>"']/g, char => map[char]);
|
||||
}
|
||||
|
||||
function renderSuccessPage() {
|
||||
return renderPage({
|
||||
title: 'Signed In',
|
||||
heading: "You're signed in!",
|
||||
message: 'You can close this tab and return to the extension.',
|
||||
icon: 'check',
|
||||
});
|
||||
}
|
||||
|
||||
function renderErrorPage(message) {
|
||||
return renderPage({
|
||||
title: 'Error',
|
||||
heading: 'Something went wrong',
|
||||
message: escapeHtml(message),
|
||||
icon: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,35 @@
|
||||
const express = require('express');
|
||||
const { requireAuth } = require('../services/jwt');
|
||||
const { createBillingPortalSession } = require('../services/stripe');
|
||||
const { renderPage } = require('../templates');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// POST /billing/portal - Create Stripe billing portal session
|
||||
router.post('/portal', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const session = await createBillingPortalSession(req.userEmail);
|
||||
console.log(`[billing] Portal session created for ${req.userEmail}`);
|
||||
res.json({ url: session.url });
|
||||
} catch (err) {
|
||||
console.error('Error creating billing portal session:', err);
|
||||
|
||||
if (err.message === 'No customer found for this email') {
|
||||
return res.status(404).json({ error: 'No subscription found for this account' });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to create billing portal session' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /billing/return - Return page from billing portal
|
||||
router.get('/return', (req, res) => {
|
||||
res.send(renderPage({
|
||||
title: 'Billing Updated',
|
||||
heading: 'Billing updated',
|
||||
message: 'You can close this tab and return to the extension.',
|
||||
icon: 'check',
|
||||
}));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,46 @@
|
||||
const express = require('express');
|
||||
const { requireAuth } = require('../services/jwt');
|
||||
const { createCheckoutSession, createBillingPortalSession } = require('../services/stripe');
|
||||
const { renderPage } = require('../templates');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// POST /checkout/create
|
||||
router.post('/create', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { plan } = req.body;
|
||||
|
||||
if (!plan || !['monthly', 'yearly'].includes(plan)) {
|
||||
return res.status(400).json({ error: 'Invalid plan. Must be "monthly" or "yearly"' });
|
||||
}
|
||||
|
||||
const session = await createCheckoutSession(req.userEmail, plan);
|
||||
|
||||
console.log(`[checkout] Session created for ${req.userEmail}, plan: ${plan}`);
|
||||
res.json({ checkout_url: session.url });
|
||||
} catch (err) {
|
||||
console.error('Error creating checkout session:', err);
|
||||
res.status(500).json({ error: 'Failed to create checkout session' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /checkout/success - Success page after payment
|
||||
router.get('/success', (req, res) => {
|
||||
res.send(renderPage({
|
||||
title: 'Payment Successful',
|
||||
heading: 'Payment successful!',
|
||||
message: 'You can close this tab and return to the extension. Your premium features are now active.',
|
||||
icon: 'check',
|
||||
}));
|
||||
});
|
||||
|
||||
// GET /checkout/cancel - Cancel page
|
||||
router.get('/cancel', (req, res) => {
|
||||
res.send(renderPage({
|
||||
title: 'Payment Canceled',
|
||||
heading: 'Payment canceled',
|
||||
message: "You can close this tab and try again from the extension whenever you're ready.",
|
||||
}));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,42 @@
|
||||
const express = require('express');
|
||||
const { requireAuth, generateLicenseToken } = require('../services/jwt');
|
||||
const { checkPremiumStatus } = require('../services/stripe');
|
||||
const storage = require('../storage');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /license/check
|
||||
router.get('/check', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const email = req.userEmail;
|
||||
|
||||
// First check if user is grandfathered (past donor)
|
||||
const isGrandfathered = storage.isGrandfathered(email);
|
||||
if (isGrandfathered) {
|
||||
console.log(`[license] ${email} -> premium (grandfathered)`);
|
||||
const licenseToken = generateLicenseToken(email, true, true);
|
||||
return res.json({ license_token: licenseToken });
|
||||
}
|
||||
|
||||
// Check subscription cache first
|
||||
const cached = storage.getSubscriptionStatus(email);
|
||||
if (cached) {
|
||||
console.log(`[license] ${email} -> ${cached.premium ? 'premium' : 'free'} (cached)`);
|
||||
const licenseToken = generateLicenseToken(email, cached.premium, false);
|
||||
return res.json({ license_token: licenseToken });
|
||||
}
|
||||
|
||||
// Cache miss — check Stripe and populate cache
|
||||
const status = await checkPremiumStatus(email);
|
||||
storage.setSubscriptionStatus(email, status.premium, null);
|
||||
|
||||
console.log(`[license] ${email} -> ${status.premium ? 'premium' : 'free'} (stripe)`);
|
||||
const licenseToken = generateLicenseToken(email, status.premium, false);
|
||||
res.json({ license_token: licenseToken });
|
||||
} catch (err) {
|
||||
console.error('Error checking license:', err);
|
||||
res.status(500).json({ error: 'Failed to check license status' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,109 @@
|
||||
const express = require('express');
|
||||
const { constructWebhookEvent, stripe } = require('../services/stripe');
|
||||
const { sendWelcomeEmail, sendCancellationEmail } = require('../services/email');
|
||||
const storage = require('../storage');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
async function getCustomerEmail(customerId) {
|
||||
const customer = await stripe.customers.retrieve(customerId);
|
||||
return customer.email ? customer.email.toLowerCase() : null;
|
||||
}
|
||||
|
||||
// POST /webhook/stripe
|
||||
// Note: This route needs raw body, configured in index.js
|
||||
router.post('/stripe', async (req, res) => {
|
||||
const signature = req.headers['stripe-signature'];
|
||||
|
||||
if (!signature) {
|
||||
return res.status(400).json({ error: 'Missing stripe-signature header' });
|
||||
}
|
||||
|
||||
let event;
|
||||
|
||||
try {
|
||||
event = constructWebhookEvent(req.body, signature);
|
||||
} catch (err) {
|
||||
console.error('[webhook] Signature verification failed:', err.message);
|
||||
return res.status(400).json({ error: 'Invalid signature' });
|
||||
}
|
||||
|
||||
// Handle the event
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object;
|
||||
try {
|
||||
const email = session.customer_email || await getCustomerEmail(session.customer);
|
||||
if (email) {
|
||||
await sendWelcomeEmail(email);
|
||||
console.log(`[webhook] Checkout completed: ${email} (welcome email sent)`);
|
||||
} else {
|
||||
console.log(`[webhook] Checkout completed: ${session.customer} (no email found)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[webhook] Checkout completed but welcome email failed:`, err.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated': {
|
||||
const subscription = event.data.object;
|
||||
const premium = subscription.status === 'active';
|
||||
const action = event.type === 'customer.subscription.created' ? 'created' : 'updated';
|
||||
try {
|
||||
const email = await getCustomerEmail(subscription.customer);
|
||||
if (email) {
|
||||
storage.setSubscriptionStatus(email, premium, subscription.customer);
|
||||
console.log(`[webhook] Subscription ${action}: ${email} -> ${premium ? 'premium' : 'free'}`);
|
||||
|
||||
// Send cancellation email when user cancels (before period ends)
|
||||
if (subscription.cancel_at_period_end && event.data.previous_attributes?.cancel_at_period_end === false) {
|
||||
await sendCancellationEmail(email);
|
||||
console.log(`[webhook] Cancellation scheduled: ${email} (cancellation email sent)`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[webhook] Subscription ${action}: ${subscription.id} (no email found)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[webhook] Subscription ${action} cache error:`, err.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object;
|
||||
try {
|
||||
const email = await getCustomerEmail(subscription.customer);
|
||||
if (email) {
|
||||
storage.setSubscriptionStatus(email, false, subscription.customer);
|
||||
console.log(`[webhook] Subscription ended: ${email} -> free`);
|
||||
} else {
|
||||
console.log(`[webhook] Subscription ended: ${subscription.id} (no email found)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[webhook] Subscription ended error:', err.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'invoice.payment_failed': {
|
||||
const invoice = event.data.object;
|
||||
console.log(`[webhook] Payment failed: ${invoice.id}, customer: ${invoice.customer}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'invoice.payment_succeeded': {
|
||||
const invoice = event.data.object;
|
||||
console.log(`[webhook] Payment succeeded: ${invoice.id}`);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log(`[webhook] Unhandled event: ${event.type}`);
|
||||
}
|
||||
|
||||
res.json({ received: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,195 @@
|
||||
const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses');
|
||||
const config = require('../config');
|
||||
|
||||
const sesClient = new SESClient({ region: config.AWS_REGION });
|
||||
|
||||
async function sendMagicLinkEmail(email, magicLinkUrl, { isPremium = false } = {}) {
|
||||
const premiumBadge = isPremium
|
||||
? ' <span style="font-size: 12px; font-weight: 500; color: #0600fb; letter-spacing: 0.02em;">Premium</span>'
|
||||
: '';
|
||||
const params = {
|
||||
Source: config.EMAIL_FROM,
|
||||
Destination: {
|
||||
ToAddresses: [email],
|
||||
},
|
||||
Message: {
|
||||
Subject: {
|
||||
Data: 'Sign in to RYS',
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
Body: {
|
||||
Text: {
|
||||
Data: `Here's your sign-in link for RYS:\n\n${magicLinkUrl}\n\nLink expires in 15 minutes. If you didn't request this, just ignore it.\n\n— Lawrence`,
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
Html: {
|
||||
Data: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 0; margin: 0; color: #333; background: #f5f5f5;">
|
||||
<div style="max-width: 600px; margin: 0 auto; background: #fff;">
|
||||
<div style="padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.1);">
|
||||
<a href="https://lawrencehook.com/rys/" style="text-decoration: none; font-size: 18px; font-weight: 600; color: #1a1a1a;">RYS</a>${premiumBadge}
|
||||
</div>
|
||||
<div style="padding: 32px 24px 40px;">
|
||||
<p style="font-size: 16px; line-height: 1.5; margin: 0 0 24px 0;">
|
||||
Here's your sign-in link for RYS:
|
||||
</p>
|
||||
<p style="margin: 24px 0;">
|
||||
<a href="${magicLinkUrl}"
|
||||
style="background-color: #0600FB; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 500; display: inline-block;">
|
||||
Sign In
|
||||
</a>
|
||||
</p>
|
||||
<p style="color: #666; font-size: 14px;">
|
||||
Link expires in 15 minutes. If you didn't request this, just ignore it.
|
||||
</p>
|
||||
<p style="color: #999; font-size: 13px; margin-top: 32px;">
|
||||
— Lawrence
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim(),
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const command = new SendEmailCommand(params);
|
||||
return sesClient.send(command);
|
||||
}
|
||||
|
||||
async function sendWelcomeEmail(email) {
|
||||
const params = {
|
||||
Source: config.EMAIL_FROM,
|
||||
Destination: {
|
||||
ToAddresses: [email],
|
||||
},
|
||||
Message: {
|
||||
Subject: {
|
||||
Data: 'Welcome to RYS Premium',
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
Body: {
|
||||
Text: {
|
||||
Data: `Welcome to RYS Premium!\n\nThank you for subscribing. Your premium features are now active.\n\n— Lawrence`,
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
Html: {
|
||||
Data: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 0; margin: 0; color: #333; background: #f5f5f5;">
|
||||
<div style="max-width: 600px; margin: 0 auto; background: #fff;">
|
||||
<div style="padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.1);">
|
||||
<a href="https://lawrencehook.com/rys/" style="text-decoration: none; font-size: 18px; font-weight: 600; color: #1a1a1a;">RYS</a> <span style="font-size: 12px; font-weight: 500; color: #0600fb; letter-spacing: 0.02em;">Premium</span>
|
||||
</div>
|
||||
<div style="padding: 32px 24px 40px;">
|
||||
<p style="font-size: 16px; line-height: 1.5; margin: 0 0 24px 0;">
|
||||
Welcome to RYS Premium!
|
||||
</p>
|
||||
<p style="font-size: 15px; line-height: 1.5; color: #444; margin: 0 0 16px 0;">
|
||||
Thank you for subscribing. Your premium features are now active.
|
||||
</p>
|
||||
<p style="color: #999; font-size: 13px; margin-top: 32px;">
|
||||
— Lawrence
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim(),
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const command = new SendEmailCommand(params);
|
||||
return sesClient.send(command);
|
||||
}
|
||||
|
||||
const FEEDBACK_URL = 'https://docs.google.com/forms/d/1AzQQxTWgG6M5N87jinvXKQkGS6Mehzg19XV4mjteTK0';
|
||||
|
||||
async function sendCancellationEmail(email) {
|
||||
const params = {
|
||||
Source: config.EMAIL_FROM,
|
||||
Destination: {
|
||||
ToAddresses: [email],
|
||||
},
|
||||
Message: {
|
||||
Subject: {
|
||||
Data: 'Your RYS Premium subscription has been canceled',
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
Body: {
|
||||
Text: {
|
||||
Data: `Your RYS Premium subscription has been canceled.\n\nThe free version of RYS is still yours to use anytime.\n\nIf you have a moment, we'd love to hear what we could do better: ${FEEDBACK_URL}\n\nThanks for giving Premium a try.\n\n— Lawrence`,
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
Html: {
|
||||
Data: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 0; margin: 0; color: #333; background: #f5f5f5;">
|
||||
<div style="max-width: 600px; margin: 0 auto; background: #fff;">
|
||||
<div style="padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.1);">
|
||||
<a href="https://lawrencehook.com/rys/" style="text-decoration: none; font-size: 18px; font-weight: 600; color: #1a1a1a;">RYS</a>
|
||||
</div>
|
||||
<div style="padding: 32px 24px 40px;">
|
||||
<p style="font-size: 16px; line-height: 1.5; margin: 0 0 24px 0;">
|
||||
Your Premium subscription has been canceled.
|
||||
</p>
|
||||
<p style="font-size: 15px; line-height: 1.5; color: #444; margin: 0 0 16px 0;">
|
||||
The free version of RYS is still yours to use anytime.
|
||||
</p>
|
||||
<p style="font-size: 15px; line-height: 1.5; color: #444; margin: 0 0 24px 0;">
|
||||
If you have a moment, we'd love to hear what we could do better:
|
||||
</p>
|
||||
<p style="margin: 0 0 24px 0;">
|
||||
<a href="${FEEDBACK_URL}"
|
||||
style="background-color: #0600FB; color: white; padding: 10px 20px; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 14px; display: inline-block;">
|
||||
Share Feedback
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size: 15px; line-height: 1.5; color: #444; margin: 0 0 16px 0;">
|
||||
Thanks for giving Premium a try.
|
||||
</p>
|
||||
<p style="color: #999; font-size: 13px; margin-top: 32px;">
|
||||
— Lawrence
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim(),
|
||||
Charset: 'UTF-8',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const command = new SendEmailCommand(params);
|
||||
return sesClient.send(command);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendMagicLinkEmail,
|
||||
sendWelcomeEmail,
|
||||
sendCancellationEmail,
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../config');
|
||||
|
||||
function generateSessionToken(email) {
|
||||
const payload = {
|
||||
email: email.toLowerCase(),
|
||||
};
|
||||
|
||||
const options = {
|
||||
expiresIn: `${config.SESSION_TOKEN_LIFETIME_DAYS}d`,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, config.JWT_SECRET, options);
|
||||
}
|
||||
|
||||
function generateLicenseToken(email, premium, grandfathered = false) {
|
||||
const payload = {
|
||||
email: email.toLowerCase(),
|
||||
premium: !!premium,
|
||||
grandfathered: !!grandfathered,
|
||||
};
|
||||
|
||||
const lifetimeDays = grandfathered
|
||||
? config.GRANDFATHERED_TOKEN_LIFETIME_DAYS
|
||||
: config.LICENSE_TOKEN_LIFETIME_DAYS;
|
||||
|
||||
return jwt.sign(payload, config.JWT_SECRET, { expiresIn: `${lifetimeDays}d` });
|
||||
}
|
||||
|
||||
function verifySessionToken(token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.JWT_SECRET);
|
||||
return { valid: true, email: decoded.email };
|
||||
} catch (err) {
|
||||
return { valid: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Express middleware to require authentication
|
||||
function requireAuth(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Missing or invalid authorization header' });
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
const result = verifySessionToken(token);
|
||||
|
||||
if (!result.valid) {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
|
||||
req.userEmail = result.email;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateSessionToken,
|
||||
generateLicenseToken,
|
||||
verifySessionToken,
|
||||
requireAuth,
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
const Stripe = require('stripe');
|
||||
const config = require('../config');
|
||||
|
||||
const stripe = new Stripe(config.STRIPE_SECRET_KEY);
|
||||
|
||||
async function findCustomerByEmail(email) {
|
||||
const customers = await stripe.customers.list({
|
||||
email: email.toLowerCase(),
|
||||
limit: 1,
|
||||
});
|
||||
return customers.data[0] || null;
|
||||
}
|
||||
|
||||
async function createCustomer(email) {
|
||||
return stripe.customers.create({
|
||||
email: email.toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
async function findOrCreateCustomer(email) {
|
||||
const existing = await findCustomerByEmail(email);
|
||||
if (existing) return existing;
|
||||
return createCustomer(email);
|
||||
}
|
||||
|
||||
async function getActiveSubscription(customerId) {
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: 'active',
|
||||
limit: 1,
|
||||
});
|
||||
return subscriptions.data[0] || null;
|
||||
}
|
||||
|
||||
async function checkPremiumStatus(email) {
|
||||
const customer = await findCustomerByEmail(email);
|
||||
|
||||
if (!customer) {
|
||||
return { premium: false, expiresAt: null };
|
||||
}
|
||||
|
||||
const subscription = await getActiveSubscription(customer.id);
|
||||
|
||||
if (!subscription) {
|
||||
return { premium: false, expiresAt: null };
|
||||
}
|
||||
|
||||
return {
|
||||
premium: true,
|
||||
expiresAt: new Date(subscription.current_period_end * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function createCheckoutSession(email, plan) {
|
||||
const customer = await findOrCreateCustomer(email);
|
||||
|
||||
const priceId = plan === 'yearly'
|
||||
? config.STRIPE_PRICE_YEARLY
|
||||
: config.STRIPE_PRICE_MONTHLY;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customer.id,
|
||||
mode: 'subscription',
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `${config.BASE_URL}/checkout/success`,
|
||||
cancel_url: `${config.BASE_URL}/checkout/cancel`,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async function createBillingPortalSession(email) {
|
||||
const customer = await findCustomerByEmail(email);
|
||||
|
||||
if (!customer) {
|
||||
throw new Error('No customer found for this email');
|
||||
}
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: customer.id,
|
||||
return_url: `${config.BASE_URL}/billing/return`,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
function constructWebhookEvent(payload, signature) {
|
||||
return stripe.webhooks.constructEvent(
|
||||
payload,
|
||||
signature,
|
||||
config.STRIPE_WEBHOOK_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
stripe,
|
||||
findCustomerByEmail,
|
||||
createCustomer,
|
||||
findOrCreateCustomer,
|
||||
getActiveSubscription,
|
||||
checkPremiumStatus,
|
||||
createCheckoutSession,
|
||||
createBillingPortalSession,
|
||||
constructWebhookEvent,
|
||||
};
|
||||
@@ -0,0 +1,297 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const config = require('../config');
|
||||
|
||||
// Get paths dynamically to support test environment changes
|
||||
function getDataDir() {
|
||||
return config.DATA_DIR;
|
||||
}
|
||||
|
||||
function getAuthRequestsDir() {
|
||||
return path.join(getDataDir(), config.AUTH_REQUESTS_DIR);
|
||||
}
|
||||
|
||||
function getRateLimitsDir() {
|
||||
return path.join(getDataDir(), config.RATE_LIMITS_DIR);
|
||||
}
|
||||
|
||||
function ensureDirectories() {
|
||||
const dirs = [getDataDir(), getAuthRequestsDir(), getRateLimitsDir()];
|
||||
dirs.forEach(dir => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
ensureDirectories();
|
||||
|
||||
// --- Grandfathered Emails (file-backed, no in-memory cache) ---
|
||||
|
||||
function readGrandfatheredEmails() {
|
||||
try {
|
||||
const filePath = path.join(getDataDir(), config.GRANDFATHERED_FILE);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
||||
return new Set(lines.map(l => l.trim().toLowerCase()).filter(l => l && !l.startsWith('#')));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to read grandfathered emails:', err.message);
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function isGrandfathered(email) {
|
||||
return readGrandfatheredEmails().has(email.toLowerCase());
|
||||
}
|
||||
|
||||
// --- Subscription Cache (email -> premium status, file-backed, no in-memory cache) ---
|
||||
|
||||
function getSubscriptionCachePath() {
|
||||
return path.join(getDataDir(), 'subscriptions.json');
|
||||
}
|
||||
|
||||
function readSubscriptionFile() {
|
||||
try {
|
||||
const filePath = getSubscriptionCachePath();
|
||||
if (fs.existsSync(filePath)) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to read subscription cache:', err.message);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function setSubscriptionStatus(email, premium, customerId) {
|
||||
email = email.toLowerCase();
|
||||
const data = readSubscriptionFile();
|
||||
data[email] = { premium, customerId, updatedAt: Date.now() };
|
||||
try {
|
||||
fs.writeFileSync(getSubscriptionCachePath(), JSON.stringify(data, null, 2));
|
||||
} catch (err) {
|
||||
console.error('Failed to save subscription cache:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function getSubscriptionStatus(email) {
|
||||
const data = readSubscriptionFile();
|
||||
return data[email.toLowerCase()] || null;
|
||||
}
|
||||
|
||||
|
||||
// --- Auth Requests (file per request) ---
|
||||
|
||||
function getAuthRequestFilename(requestId, timestamp) {
|
||||
return `${timestamp}-${requestId}.json`;
|
||||
}
|
||||
|
||||
function parseAuthRequestFilename(filename) {
|
||||
const match = filename.match(/^(\d+)-(.+)\.json$/);
|
||||
if (!match) return null;
|
||||
return { timestamp: parseInt(match[1], 10), requestId: match[2] };
|
||||
}
|
||||
|
||||
function createAuthRequest(requestId, email) {
|
||||
ensureDirectories(); // Ensure directory exists before write
|
||||
const timestamp = Date.now();
|
||||
const data = {
|
||||
request_id: requestId,
|
||||
email: email,
|
||||
status: 'pending',
|
||||
created_at: timestamp,
|
||||
session_token: null,
|
||||
};
|
||||
const filename = getAuthRequestFilename(requestId, timestamp);
|
||||
const filePath = path.join(getAuthRequestsDir(), filename);
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
return data;
|
||||
}
|
||||
|
||||
function findAuthRequestFile(requestId) {
|
||||
const dir = getAuthRequestsDir();
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const parsed = parseAuthRequestFilename(file);
|
||||
if (parsed && parsed.requestId === requestId) {
|
||||
return path.join(dir, file);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAuthRequest(requestId) {
|
||||
const filePath = findAuthRequestFile(requestId);
|
||||
if (!filePath) return null;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
|
||||
// Check if expired
|
||||
if (Date.now() - data.created_at > config.REQUEST_ID_EXPIRY_MS) {
|
||||
// Clean up expired request
|
||||
fs.unlinkSync(filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateAuthRequest(requestId, updates) {
|
||||
const filePath = findAuthRequestFile(requestId);
|
||||
if (!filePath) return null;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
const updated = { ...data, ...updates };
|
||||
fs.writeFileSync(filePath, JSON.stringify(updated, null, 2));
|
||||
return updated;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function deleteAuthRequest(requestId) {
|
||||
const filePath = findAuthRequestFile(requestId);
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pruneExpiredAuthRequests() {
|
||||
const now = Date.now();
|
||||
const dir = getAuthRequestsDir();
|
||||
const files = fs.readdirSync(dir);
|
||||
let pruned = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const parsed = parseAuthRequestFilename(file);
|
||||
if (parsed && now - parsed.timestamp > config.REQUEST_ID_EXPIRY_MS) {
|
||||
fs.unlinkSync(path.join(dir, file));
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
|
||||
if (pruned > 0) {
|
||||
console.log(`Pruned ${pruned} expired auth requests`);
|
||||
}
|
||||
return pruned;
|
||||
}
|
||||
|
||||
// --- Rate Limiting (file per email hash) ---
|
||||
|
||||
function getEmailHash(email) {
|
||||
return crypto.createHash('sha256').update(email.toLowerCase()).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
function getRateLimitFilePath(email) {
|
||||
return path.join(getRateLimitsDir(), `${getEmailHash(email)}.json`);
|
||||
}
|
||||
|
||||
function checkRateLimit(email) {
|
||||
ensureDirectories(); // Ensure directory exists before write
|
||||
const filePath = getRateLimitFilePath(email);
|
||||
const now = Date.now();
|
||||
|
||||
let data = { count: 0, windowStart: now };
|
||||
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
|
||||
// Reset if window expired
|
||||
if (now - data.windowStart > config.RATE_LIMIT_WINDOW_MS) {
|
||||
data = { count: 0, windowStart: now };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Start fresh on error
|
||||
data = { count: 0, windowStart: now };
|
||||
}
|
||||
|
||||
// Check if over limit
|
||||
if (data.count >= config.RATE_LIMIT_MAX_REQUESTS) {
|
||||
const resetTime = data.windowStart + config.RATE_LIMIT_WINDOW_MS;
|
||||
return { allowed: false, resetTime };
|
||||
}
|
||||
|
||||
// Increment and save
|
||||
data.count++;
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
|
||||
return { allowed: true, remaining: config.RATE_LIMIT_MAX_REQUESTS - data.count };
|
||||
}
|
||||
|
||||
function decrementRateLimit(email) {
|
||||
const filePath = getRateLimitFilePath(email);
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
if (data.count > 0) {
|
||||
data.count--;
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
function pruneExpiredRateLimits() {
|
||||
const now = Date.now();
|
||||
const dir = getRateLimitsDir();
|
||||
const files = fs.readdirSync(dir);
|
||||
let pruned = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
if (now - data.windowStart > config.RATE_LIMIT_WINDOW_MS) {
|
||||
fs.unlinkSync(filePath);
|
||||
pruned++;
|
||||
}
|
||||
} catch (err) {
|
||||
// Remove invalid files
|
||||
fs.unlinkSync(filePath);
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
|
||||
if (pruned > 0) {
|
||||
console.log(`Pruned ${pruned} expired rate limit records`);
|
||||
}
|
||||
return pruned;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// Grandfathered
|
||||
readGrandfatheredEmails,
|
||||
isGrandfathered,
|
||||
|
||||
// Auth requests
|
||||
createAuthRequest,
|
||||
getAuthRequest,
|
||||
updateAuthRequest,
|
||||
deleteAuthRequest,
|
||||
pruneExpiredAuthRequests,
|
||||
|
||||
// Rate limiting
|
||||
checkRateLimit,
|
||||
decrementRateLimit,
|
||||
pruneExpiredRateLimits,
|
||||
|
||||
// Subscription cache
|
||||
setSubscriptionStatus,
|
||||
getSubscriptionStatus,
|
||||
|
||||
// Utils
|
||||
ensureDirectories,
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
// Shared HTML page templates — styled to match lawrencehook.com/rys
|
||||
|
||||
const RYS_LOGO_URL = 'https://lawrencehook.com/rys/assets/rys.svg';
|
||||
const RYS_HOME_URL = 'https://lawrencehook.com/rys/';
|
||||
|
||||
function renderPage({ title, heading, message, icon }) {
|
||||
const iconHTML = icon === 'check'
|
||||
? `<div class="icon check">
|
||||
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
||||
</div>`
|
||||
: icon === 'error'
|
||||
? `<div class="icon error">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title} — RYS</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #1a1a1a;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
header {
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
header a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
header img {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
header .title {
|
||||
font-size: 20px;
|
||||
font-weight: normal;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
header .premium-badge {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #0600fb;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
text-align: center;
|
||||
padding: 48px 40px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.icon.check { background: #4CAF50; }
|
||||
.icon.error { background: #f44336; }
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
footer {
|
||||
height: 48px;
|
||||
background: #fff;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a href="${RYS_HOME_URL}">
|
||||
<img src="${RYS_LOGO_URL}" alt="RYS">
|
||||
</a>
|
||||
<span class="title">RYS</span>
|
||||
<span class="premium-badge">Premium</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="card">
|
||||
${iconHTML}
|
||||
<h1>${heading}</h1>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<a href="https://docs.google.com/forms/d/1AzQQxTWgG6M5N87jinvXKQkGS6Mehzg19XV4mjteTK0" target="_blank">Feedback</a>
|
||||
<a href="${RYS_HOME_URL}" target="_blank">Homepage</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
module.exports = { renderPage };
|
||||
@@ -0,0 +1,180 @@
|
||||
const { describe, it, before, after, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const request = require('supertest');
|
||||
|
||||
// Setup must be required first
|
||||
const { cleanTestData } = require('./setup');
|
||||
|
||||
// Mock the email service before requiring app
|
||||
const emailService = require('../src/services/email');
|
||||
let lastSentEmail = null;
|
||||
emailService.sendMagicLinkEmail = async (email, url) => {
|
||||
lastSentEmail = { email, url };
|
||||
return { MessageId: 'test-message-id' };
|
||||
};
|
||||
|
||||
// Mock Stripe service
|
||||
const stripeService = require('../src/services/stripe');
|
||||
stripeService.checkPremiumStatus = async () => ({ premium: false, expiresAt: null });
|
||||
|
||||
const { app } = require('../src/index');
|
||||
const storage = require('../src/storage');
|
||||
|
||||
describe('Auth Routes', () => {
|
||||
beforeEach(() => {
|
||||
cleanTestData();
|
||||
storage.ensureDirectories();
|
||||
lastSentEmail = null;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanTestData();
|
||||
});
|
||||
|
||||
describe('POST /auth/send-magic-link', () => {
|
||||
it('should send magic link for valid email', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({ email: 'test@example.com' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.body.request_id);
|
||||
assert.strictEqual(lastSentEmail.email, 'test@example.com');
|
||||
assert.ok(lastSentEmail.url.includes(res.body.request_id));
|
||||
});
|
||||
|
||||
it('should reject invalid email format', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({ email: 'not-an-email' });
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.ok(res.body.error.includes('Invalid email'));
|
||||
});
|
||||
|
||||
it('should reject missing email', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({});
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
|
||||
it('should rate limit after too many requests', async () => {
|
||||
const email = 'ratelimit@example.com';
|
||||
|
||||
// Make max allowed requests
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({ email });
|
||||
}
|
||||
|
||||
// Next should be rate limited
|
||||
const res = await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({ email });
|
||||
|
||||
assert.strictEqual(res.status, 429);
|
||||
assert.ok(res.headers['retry-after']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /auth/verify', () => {
|
||||
it('should verify valid magic link and show success page', async () => {
|
||||
// Create auth request first
|
||||
const sendRes = await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({ email: 'verify@example.com' });
|
||||
|
||||
const requestId = sendRes.body.request_id;
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/auth/verify?token=${requestId}`);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.text.includes("You're signed in"));
|
||||
|
||||
// Auth request should now be verified
|
||||
const authRequest = storage.getAuthRequest(requestId);
|
||||
assert.strictEqual(authRequest.status, 'verified');
|
||||
assert.ok(authRequest.session_token);
|
||||
});
|
||||
|
||||
it('should reject missing token', async () => {
|
||||
const res = await request(app).get('/auth/verify');
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.ok(res.text.includes('Missing token'));
|
||||
});
|
||||
|
||||
it('should reject invalid token', async () => {
|
||||
const res = await request(app)
|
||||
.get('/auth/verify?token=invalid-token');
|
||||
|
||||
assert.strictEqual(res.status, 404);
|
||||
assert.ok(res.text.includes('expired or invalid'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /auth/poll', () => {
|
||||
it('should return pending for unverified request', async () => {
|
||||
const sendRes = await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({ email: 'poll@example.com' });
|
||||
|
||||
const requestId = sendRes.body.request_id;
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/auth/poll?request_id=${requestId}`);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.body.status, 'pending');
|
||||
});
|
||||
|
||||
it('should return verified with token after magic link clicked', async () => {
|
||||
// Create and verify request
|
||||
const sendRes = await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({ email: 'poll2@example.com' });
|
||||
|
||||
const requestId = sendRes.body.request_id;
|
||||
|
||||
// Click magic link
|
||||
await request(app).get(`/auth/verify?token=${requestId}`);
|
||||
|
||||
// Poll should return verified
|
||||
const res = await request(app)
|
||||
.get(`/auth/poll?request_id=${requestId}`);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.body.status, 'verified');
|
||||
assert.ok(res.body.session_token);
|
||||
assert.strictEqual(res.body.email, 'poll2@example.com');
|
||||
});
|
||||
|
||||
it('should delete auth request after returning verified', async () => {
|
||||
const sendRes = await request(app)
|
||||
.post('/auth/send-magic-link')
|
||||
.send({ email: 'poll3@example.com' });
|
||||
|
||||
const requestId = sendRes.body.request_id;
|
||||
|
||||
await request(app).get(`/auth/verify?token=${requestId}`);
|
||||
await request(app).get(`/auth/poll?request_id=${requestId}`);
|
||||
|
||||
// Second poll should return 404
|
||||
const res = await request(app)
|
||||
.get(`/auth/poll?request_id=${requestId}`);
|
||||
|
||||
assert.strictEqual(res.status, 404);
|
||||
});
|
||||
|
||||
it('should reject invalid request_id', async () => {
|
||||
const res = await request(app)
|
||||
.get('/auth/poll?request_id=invalid');
|
||||
|
||||
assert.strictEqual(res.status, 404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
const { describe, it, before, after, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const request = require('supertest');
|
||||
|
||||
// Setup must be required first
|
||||
const { cleanTestData } = require('./setup');
|
||||
|
||||
// Mock the email service
|
||||
const emailService = require('../src/services/email');
|
||||
emailService.sendMagicLinkEmail = async () => ({ MessageId: 'test' });
|
||||
|
||||
// Mock Stripe service
|
||||
const stripeService = require('../src/services/stripe');
|
||||
stripeService.createCheckoutSession = async (email, plan) => ({
|
||||
url: `https://checkout.stripe.com/test?plan=${plan}&email=${email}`,
|
||||
});
|
||||
stripeService.createBillingPortalSession = async (email) => {
|
||||
if (email === 'nocustomer@example.com') {
|
||||
throw new Error('No customer found for this email');
|
||||
}
|
||||
return { url: 'https://billing.stripe.com/test' };
|
||||
};
|
||||
|
||||
const { app } = require('../src/index');
|
||||
const storage = require('../src/storage');
|
||||
const { generateSessionToken } = require('../src/services/jwt');
|
||||
|
||||
describe('Checkout Routes', () => {
|
||||
beforeEach(() => {
|
||||
cleanTestData();
|
||||
storage.ensureDirectories();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanTestData();
|
||||
});
|
||||
|
||||
describe('POST /checkout/create', () => {
|
||||
it('should require authentication', async () => {
|
||||
const res = await request(app)
|
||||
.post('/checkout/create')
|
||||
.send({ plan: 'monthly' });
|
||||
|
||||
assert.strictEqual(res.status, 401);
|
||||
});
|
||||
|
||||
it('should create checkout session for monthly plan', async () => {
|
||||
const token = generateSessionToken('checkout@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/checkout/create')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ plan: 'monthly' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.body.checkout_url);
|
||||
assert.ok(res.body.checkout_url.includes('monthly'));
|
||||
});
|
||||
|
||||
it('should create checkout session for yearly plan', async () => {
|
||||
const token = generateSessionToken('checkout@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/checkout/create')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ plan: 'yearly' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.body.checkout_url.includes('yearly'));
|
||||
});
|
||||
|
||||
it('should reject invalid plan', async () => {
|
||||
const token = generateSessionToken('checkout@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/checkout/create')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ plan: 'invalid' });
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.ok(res.body.error.includes('Invalid plan'));
|
||||
});
|
||||
|
||||
it('should reject missing plan', async () => {
|
||||
const token = generateSessionToken('checkout@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/checkout/create')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({});
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /checkout/success', () => {
|
||||
it('should return success page', async () => {
|
||||
const res = await request(app).get('/checkout/success');
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.text.includes('Payment successful'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /checkout/cancel', () => {
|
||||
it('should return cancel page', async () => {
|
||||
const res = await request(app).get('/checkout/cancel');
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.text.includes('Payment canceled'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Billing Routes', () => {
|
||||
beforeEach(() => {
|
||||
cleanTestData();
|
||||
storage.ensureDirectories();
|
||||
});
|
||||
|
||||
describe('POST /billing/portal', () => {
|
||||
it('should require authentication', async () => {
|
||||
const res = await request(app).post('/billing/portal');
|
||||
|
||||
assert.strictEqual(res.status, 401);
|
||||
});
|
||||
|
||||
it('should return billing portal URL', async () => {
|
||||
const token = generateSessionToken('billing@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/billing/portal')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.body.url);
|
||||
assert.ok(res.body.url.includes('billing.stripe.com'));
|
||||
});
|
||||
|
||||
it('should return 404 for user without subscription', async () => {
|
||||
const token = generateSessionToken('nocustomer@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/billing/portal')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
assert.strictEqual(res.status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /billing/return', () => {
|
||||
it('should return billing return page', async () => {
|
||||
const res = await request(app).get('/billing/return');
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.text.includes('Billing updated'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
const { describe, it, beforeEach, after } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const request = require('supertest');
|
||||
|
||||
// Setup must be required first
|
||||
const { cleanTestData } = require('./setup');
|
||||
|
||||
// Mock email service
|
||||
const emailService = require('../src/services/email');
|
||||
emailService.sendMagicLinkEmail = async () => ({ MessageId: 'test' });
|
||||
|
||||
const { app } = require('../src/index');
|
||||
const storage = require('../src/storage');
|
||||
|
||||
describe('Health Check', () => {
|
||||
beforeEach(() => {
|
||||
cleanTestData();
|
||||
storage.ensureDirectories();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanTestData();
|
||||
});
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('should return health status', async () => {
|
||||
const res = await request(app).get('/health');
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.body.status, 'ok');
|
||||
assert.ok(res.body.timestamp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('404 handling', () => {
|
||||
it('should return 404 for unknown routes', async () => {
|
||||
const res = await request(app).get('/unknown-route');
|
||||
|
||||
assert.strictEqual(res.status, 404);
|
||||
assert.strictEqual(res.body.error, 'Not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
// Setup must be required first
|
||||
require('./setup');
|
||||
|
||||
const { generateSessionToken, generateLicenseToken, verifySessionToken } = require('../src/services/jwt');
|
||||
|
||||
describe('JWT Service', () => {
|
||||
describe('generateSessionToken', () => {
|
||||
it('should generate a valid JWT token', () => {
|
||||
const token = generateSessionToken('test@example.com');
|
||||
|
||||
assert.ok(token);
|
||||
assert.strictEqual(typeof token, 'string');
|
||||
assert.ok(token.split('.').length === 3); // JWT format
|
||||
});
|
||||
|
||||
it('should lowercase email in token', () => {
|
||||
const token = generateSessionToken('Test@EXAMPLE.com');
|
||||
const result = verifySessionToken(token);
|
||||
|
||||
assert.strictEqual(result.email, 'test@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifySessionToken', () => {
|
||||
it('should verify a valid token', () => {
|
||||
const token = generateSessionToken('verify@example.com');
|
||||
const result = verifySessionToken(token);
|
||||
|
||||
assert.strictEqual(result.valid, true);
|
||||
assert.strictEqual(result.email, 'verify@example.com');
|
||||
});
|
||||
|
||||
it('should reject an invalid token', () => {
|
||||
const result = verifySessionToken('invalid-token');
|
||||
|
||||
assert.strictEqual(result.valid, false);
|
||||
assert.ok(result.error);
|
||||
});
|
||||
|
||||
it('should reject a token with wrong secret', () => {
|
||||
// Create a token with a different secret (simulated by tampering)
|
||||
const validToken = generateSessionToken('test@example.com');
|
||||
const tamperedToken = validToken.slice(0, -5) + 'xxxxx';
|
||||
|
||||
const result = verifySessionToken(tamperedToken);
|
||||
|
||||
assert.strictEqual(result.valid, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateLicenseToken', () => {
|
||||
it('should generate a valid license token with premium status', () => {
|
||||
const token = generateLicenseToken('test@example.com', true, false);
|
||||
|
||||
assert.ok(token);
|
||||
assert.strictEqual(typeof token, 'string');
|
||||
assert.ok(token.split('.').length === 3);
|
||||
|
||||
const result = verifySessionToken(token);
|
||||
assert.strictEqual(result.valid, true);
|
||||
assert.strictEqual(result.email, 'test@example.com');
|
||||
});
|
||||
|
||||
it('should include premium and grandfathered flags in payload', () => {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../src/config');
|
||||
|
||||
const token = generateLicenseToken('test@example.com', true, true);
|
||||
const decoded = jwt.verify(token, config.JWT_SECRET);
|
||||
|
||||
assert.strictEqual(decoded.email, 'test@example.com');
|
||||
assert.strictEqual(decoded.premium, true);
|
||||
assert.strictEqual(decoded.grandfathered, true);
|
||||
});
|
||||
|
||||
it('should lowercase email in license token', () => {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../src/config');
|
||||
|
||||
const token = generateLicenseToken('Test@EXAMPLE.com', true, false);
|
||||
const decoded = jwt.verify(token, config.JWT_SECRET);
|
||||
|
||||
assert.strictEqual(decoded.email, 'test@example.com');
|
||||
});
|
||||
|
||||
it('should set non-premium status correctly', () => {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../src/config');
|
||||
|
||||
const token = generateLicenseToken('test@example.com', false, false);
|
||||
const decoded = jwt.verify(token, config.JWT_SECRET);
|
||||
|
||||
assert.strictEqual(decoded.premium, false);
|
||||
assert.strictEqual(decoded.grandfathered, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
const { describe, it, before, after, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const request = require('supertest');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Setup must be required first
|
||||
const { cleanTestData, testDataDir } = require('./setup');
|
||||
|
||||
// Mock the email service
|
||||
const emailService = require('../src/services/email');
|
||||
emailService.sendMagicLinkEmail = async () => ({ MessageId: 'test' });
|
||||
|
||||
// Mock Stripe service
|
||||
const stripeService = require('../src/services/stripe');
|
||||
let mockPremiumStatus = { premium: false, expiresAt: null };
|
||||
stripeService.checkPremiumStatus = async () => mockPremiumStatus;
|
||||
|
||||
const { app } = require('../src/index');
|
||||
const storage = require('../src/storage');
|
||||
const config = require('../src/config');
|
||||
const { generateSessionToken } = require('../src/services/jwt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// Helper to decode license token from response
|
||||
function decodeLicenseToken(response) {
|
||||
const token = response.body.license_token;
|
||||
if (!token) return null;
|
||||
return jwt.verify(token, config.JWT_SECRET);
|
||||
}
|
||||
|
||||
describe('License Routes', () => {
|
||||
beforeEach(() => {
|
||||
cleanTestData();
|
||||
storage.ensureDirectories();
|
||||
mockPremiumStatus = { premium: false, expiresAt: null };
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanTestData();
|
||||
});
|
||||
|
||||
describe('GET /license/check', () => {
|
||||
it('should require authentication', async () => {
|
||||
const res = await request(app).get('/license/check');
|
||||
|
||||
assert.strictEqual(res.status, 401);
|
||||
});
|
||||
|
||||
it('should reject invalid token', async () => {
|
||||
const res = await request(app)
|
||||
.get('/license/check')
|
||||
.set('Authorization', 'Bearer invalid-token');
|
||||
|
||||
assert.strictEqual(res.status, 401);
|
||||
});
|
||||
|
||||
it('should return license token with non-premium for user without subscription', async () => {
|
||||
const token = generateSessionToken('free@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/license/check')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.body.license_token);
|
||||
|
||||
const decoded = decodeLicenseToken(res);
|
||||
assert.strictEqual(decoded.premium, false);
|
||||
assert.strictEqual(decoded.grandfathered, false);
|
||||
assert.strictEqual(decoded.email, 'free@example.com');
|
||||
});
|
||||
|
||||
it('should return license token with premium for user with active subscription', async () => {
|
||||
mockPremiumStatus = {
|
||||
premium: true,
|
||||
expiresAt: '2025-12-31T00:00:00Z',
|
||||
};
|
||||
|
||||
const token = generateSessionToken('premium@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/license/check')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.body.license_token);
|
||||
|
||||
const decoded = decodeLicenseToken(res);
|
||||
assert.strictEqual(decoded.premium, true);
|
||||
assert.strictEqual(decoded.grandfathered, false);
|
||||
assert.strictEqual(decoded.email, 'premium@example.com');
|
||||
});
|
||||
|
||||
it('should return license token with premium and grandfathered for grandfathered user', async () => {
|
||||
// Set up grandfathered file
|
||||
const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE);
|
||||
fs.writeFileSync(grandfatheredFile, 'donor@example.com\n');
|
||||
|
||||
|
||||
const token = generateSessionToken('donor@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/license/check')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.body.license_token);
|
||||
|
||||
const decoded = decodeLicenseToken(res);
|
||||
assert.strictEqual(decoded.premium, true);
|
||||
assert.strictEqual(decoded.grandfathered, true);
|
||||
assert.strictEqual(decoded.email, 'donor@example.com');
|
||||
});
|
||||
|
||||
it('should handle grandfathered check case-insensitively', async () => {
|
||||
const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE);
|
||||
fs.writeFileSync(grandfatheredFile, 'Donor@Example.com\n');
|
||||
|
||||
|
||||
// Token with lowercase email
|
||||
const token = generateSessionToken('donor@example.com');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/license/check')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
const decoded = decodeLicenseToken(res);
|
||||
assert.strictEqual(decoded.premium, true);
|
||||
assert.strictEqual(decoded.grandfathered, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Set up test environment variables
|
||||
process.env.JWT_SECRET = 'test-secret-key-for-testing-only-32chars';
|
||||
process.env.STRIPE_SECRET_KEY = 'sk_test_fake';
|
||||
process.env.STRIPE_PRICE_MONTHLY = 'price_test_monthly';
|
||||
process.env.STRIPE_PRICE_YEARLY = 'price_test_yearly';
|
||||
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test';
|
||||
process.env.EMAIL_FROM = 'test@example.com';
|
||||
process.env.BASE_URL = 'http://localhost:3000';
|
||||
process.env.DATA_DIR = './tests/test-data';
|
||||
|
||||
// Clean up test data directory
|
||||
const testDataDir = './tests/test-data';
|
||||
|
||||
function cleanTestData() {
|
||||
try {
|
||||
if (fs.existsSync(testDataDir)) {
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore ENOTEMPTY errors from race conditions in parallel tests
|
||||
if (err.code !== 'ENOTEMPTY' && err.code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean before tests
|
||||
cleanTestData();
|
||||
|
||||
// Export for use in tests
|
||||
module.exports = {
|
||||
cleanTestData,
|
||||
testDataDir,
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
const { describe, it, before, after, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Setup must be required first
|
||||
const { cleanTestData, testDataDir } = require('./setup');
|
||||
|
||||
// Now require storage (after env vars are set)
|
||||
const storage = require('../src/storage');
|
||||
const config = require('../src/config');
|
||||
|
||||
describe('Storage', () => {
|
||||
beforeEach(() => {
|
||||
cleanTestData();
|
||||
storage.ensureDirectories();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanTestData();
|
||||
});
|
||||
|
||||
describe('Auth Requests', () => {
|
||||
it('should create and retrieve an auth request', () => {
|
||||
const requestId = 'test-request-123';
|
||||
const email = 'test@example.com';
|
||||
|
||||
const created = storage.createAuthRequest(requestId, email);
|
||||
|
||||
assert.strictEqual(created.request_id, requestId);
|
||||
assert.strictEqual(created.email, email);
|
||||
assert.strictEqual(created.status, 'pending');
|
||||
assert.strictEqual(created.session_token, null);
|
||||
|
||||
const retrieved = storage.getAuthRequest(requestId);
|
||||
|
||||
assert.strictEqual(retrieved.request_id, requestId);
|
||||
assert.strictEqual(retrieved.email, email);
|
||||
});
|
||||
|
||||
it('should update an auth request', () => {
|
||||
const requestId = 'test-request-456';
|
||||
storage.createAuthRequest(requestId, 'test@example.com');
|
||||
|
||||
const updated = storage.updateAuthRequest(requestId, {
|
||||
status: 'verified',
|
||||
session_token: 'fake-token',
|
||||
});
|
||||
|
||||
assert.strictEqual(updated.status, 'verified');
|
||||
assert.strictEqual(updated.session_token, 'fake-token');
|
||||
|
||||
const retrieved = storage.getAuthRequest(requestId);
|
||||
assert.strictEqual(retrieved.status, 'verified');
|
||||
});
|
||||
|
||||
it('should delete an auth request', () => {
|
||||
const requestId = 'test-request-789';
|
||||
storage.createAuthRequest(requestId, 'test@example.com');
|
||||
|
||||
const deleted = storage.deleteAuthRequest(requestId);
|
||||
assert.strictEqual(deleted, true);
|
||||
|
||||
const retrieved = storage.getAuthRequest(requestId);
|
||||
assert.strictEqual(retrieved, null);
|
||||
});
|
||||
|
||||
it('should return null for non-existent request', () => {
|
||||
const retrieved = storage.getAuthRequest('non-existent');
|
||||
assert.strictEqual(retrieved, null);
|
||||
});
|
||||
|
||||
it('should return null for expired request', () => {
|
||||
const requestId = 'expired-request';
|
||||
const email = 'test@example.com';
|
||||
|
||||
// Create request with old timestamp
|
||||
const oldTimestamp = Date.now() - config.REQUEST_ID_EXPIRY_MS - 1000;
|
||||
const filename = `${oldTimestamp}-${requestId}.json`;
|
||||
const filePath = path.join(testDataDir, config.AUTH_REQUESTS_DIR, filename);
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
request_id: requestId,
|
||||
email: email,
|
||||
status: 'pending',
|
||||
created_at: oldTimestamp,
|
||||
session_token: null,
|
||||
}));
|
||||
|
||||
const retrieved = storage.getAuthRequest(requestId);
|
||||
assert.strictEqual(retrieved, null);
|
||||
|
||||
// File should be deleted
|
||||
assert.strictEqual(fs.existsSync(filePath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limiting', () => {
|
||||
it('should allow requests under limit', () => {
|
||||
const email = 'ratelimit@example.com';
|
||||
|
||||
for (let i = 0; i < config.RATE_LIMIT_MAX_REQUESTS; i++) {
|
||||
const result = storage.checkRateLimit(email);
|
||||
assert.strictEqual(result.allowed, true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should block requests over limit', () => {
|
||||
const email = 'overlimit@example.com';
|
||||
|
||||
// Use up all allowed requests
|
||||
for (let i = 0; i < config.RATE_LIMIT_MAX_REQUESTS; i++) {
|
||||
storage.checkRateLimit(email);
|
||||
}
|
||||
|
||||
// Next should be blocked
|
||||
const result = storage.checkRateLimit(email);
|
||||
assert.strictEqual(result.allowed, false);
|
||||
assert.ok(result.resetTime > Date.now());
|
||||
});
|
||||
|
||||
it('should track remaining requests', () => {
|
||||
const email = 'remaining@example.com';
|
||||
|
||||
const first = storage.checkRateLimit(email);
|
||||
assert.strictEqual(first.remaining, config.RATE_LIMIT_MAX_REQUESTS - 1);
|
||||
|
||||
const second = storage.checkRateLimit(email);
|
||||
assert.strictEqual(second.remaining, config.RATE_LIMIT_MAX_REQUESTS - 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Grandfathered Emails', () => {
|
||||
it('should read grandfathered emails from file', () => {
|
||||
const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE);
|
||||
fs.writeFileSync(grandfatheredFile, 'donor1@example.com\nDonor2@Example.com\n');
|
||||
|
||||
assert.strictEqual(storage.isGrandfathered('donor1@example.com'), true);
|
||||
assert.strictEqual(storage.isGrandfathered('DONOR1@EXAMPLE.COM'), true);
|
||||
assert.strictEqual(storage.isGrandfathered('donor2@example.com'), true);
|
||||
assert.strictEqual(storage.isGrandfathered('notadonor@example.com'), false);
|
||||
});
|
||||
|
||||
it('should handle missing grandfathered file gracefully', () => {
|
||||
// File doesn't exist, should not throw
|
||||
assert.strictEqual(storage.isGrandfathered('anyone@example.com'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pruning', () => {
|
||||
it('should prune expired auth requests', () => {
|
||||
// Create an expired request
|
||||
const oldTimestamp = Date.now() - config.REQUEST_ID_EXPIRY_MS - 1000;
|
||||
const filename = `${oldTimestamp}-old-request.json`;
|
||||
const filePath = path.join(testDataDir, config.AUTH_REQUESTS_DIR, filename);
|
||||
fs.writeFileSync(filePath, JSON.stringify({ created_at: oldTimestamp }));
|
||||
|
||||
// Create a valid request
|
||||
storage.createAuthRequest('valid-request', 'test@example.com');
|
||||
|
||||
const pruned = storage.pruneExpiredAuthRequests();
|
||||
|
||||
assert.strictEqual(pruned, 1);
|
||||
assert.strictEqual(fs.existsSync(filePath), false);
|
||||
assert.notStrictEqual(storage.getAuthRequest('valid-request'), null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,8 @@
|
||||
],
|
||||
"host_permissions": [
|
||||
"*://www.youtube.com/*",
|
||||
"*://m.youtube.com/*"
|
||||
"*://m.youtube.com/*",
|
||||
"https://server.lawrencehook.com/*"
|
||||
],
|
||||
"web_accessible_resources": [{
|
||||
"resources": ["/images/rys.svg"],
|
||||
|
||||
+19
-61
@@ -5,6 +5,7 @@ html[global_enable="true"] ytd-carousel-ad-renderer,
|
||||
html[global_enable="true"] .ytd-display-ad-renderer,
|
||||
html[global_enable="true"] ytd-ad-slot-renderer,
|
||||
html[global_enable="true"] ytd-action-companion-ad-renderer,
|
||||
html[global_enable="true"] ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-ads"],
|
||||
|
||||
/* General */
|
||||
html[global_enable="true"][remove_all_shorts="true"] a[title="Shorts"],
|
||||
@@ -53,7 +54,7 @@ html[global_enable="true"][remove_footer_section="true"] #guide-renderer >
|
||||
|
||||
/* Homepage */
|
||||
html[global_enable="true"][remove_header="true"] ytd-browse[page-subtype="home"] ytd-rich-grid-renderer > div#header,
|
||||
html[global_enable="true"][remove_all_but_one="true"] ytd-browse[page-subtype="home"] ytd-rich-grid-renderer > #contents > ytd-rich-grid-row:nth-child(n+2),
|
||||
html[global_enable="true"][remove_all_but_one="true"] ytd-rich-item-renderer[rys-hidden-row],
|
||||
html[global_enable="true"][remove_all_but_one="true"] ytd-browse[page-subtype="home"] ytd-rich-grid-renderer > #contents > ytd-continuation-item-renderer,
|
||||
html[global_enable="true"][remove_extra_rows="true"] ytd-browse[page-subtype="home"] ytd-rich-grid-renderer > #contents > ytd-rich-section-renderer,
|
||||
html[global_enable="true"][remove_infinite_scroll="true"] ytd-browse[page-subtype="home"] ytd-rich-grid-renderer > #contents > ytd-continuation-item-renderer,
|
||||
@@ -206,95 +207,54 @@ html[global_enable="true"] ytd-rich-grid-row[empty="true"] #contents {
|
||||
|
||||
/* Reveal suggestions box */
|
||||
.rys_reveal_box {
|
||||
--rys-reveal-text: #606060;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
margin: auto;
|
||||
margin-top: 15%;
|
||||
background: var(--rys-primary);
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-family: var(--rys-font);
|
||||
z-index: 1000000;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
box-shadow: 0 4px 16px var(--rys-shadow);
|
||||
}
|
||||
|
||||
html[dark] .rys_reveal_box {
|
||||
--rys-reveal-text: #aaa;
|
||||
}
|
||||
|
||||
.rys_reveal_box * {
|
||||
color: var(--rys-text);
|
||||
}
|
||||
|
||||
.rys_reveal_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--rys-separator);
|
||||
}
|
||||
|
||||
.rys_reveal_branding {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.rys_reveal_logo {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.rys_reveal_brand {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--rys-reveal-text);
|
||||
}
|
||||
|
||||
.rys_reveal_actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rys_reveal_content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rys_reveal_message {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rys_reveal_show {
|
||||
background: var(--rys-text);
|
||||
color: var(--rys-primary) !important;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 6px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 2px 6px var(--rys-shadow);
|
||||
}
|
||||
|
||||
.rys_reveal_show:hover {
|
||||
background: #f5f5f5;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rys_reveal_dismiss {
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
opacity: 0.75;
|
||||
opacity: 0.5;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
@@ -307,12 +267,12 @@ html[global_enable="true"] ytd-rich-grid-row[empty="true"] #contents {
|
||||
.rys_reveal_close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 11px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
padding: 2px 4px;
|
||||
opacity: 0.6;
|
||||
line-height: 1;
|
||||
color: var(--rys-text);
|
||||
color: var(--rys-reveal-text);
|
||||
transition: opacity 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -496,8 +456,6 @@ html[global_enable='true'][grayscale_mode='true'] ytd-app {
|
||||
:root {
|
||||
--rys-primary: #065fd4;
|
||||
--rys-text: #fff;
|
||||
--rys-separator: rgba(255, 255, 255, 0.15);
|
||||
--rys-shadow: rgba(0, 0, 0, 0.15);
|
||||
--rys-font: "Roboto", "Arial", sans-serif;
|
||||
--rys-banner-height: 40px;
|
||||
}
|
||||
|
||||
+95
-44
@@ -14,26 +14,22 @@ const REDIRECT_URLS = {
|
||||
// Dynamic settings variables
|
||||
const cache = {};
|
||||
const closedRevealBoxes = new Set();
|
||||
const rysLogoUrl = browser.runtime.getURL('images/rys.svg');
|
||||
const REVEAL_BOX_CONFIGS = [
|
||||
{
|
||||
containerSelector: 'ytd-page-manager',
|
||||
boxId: 'rys_homepage_reveal_box',
|
||||
message: 'Homepage suggestions are hidden.',
|
||||
showAction: () => HTML.setAttribute('remove_homepage', false),
|
||||
revealSetting: 'add_reveal_homepage',
|
||||
},
|
||||
{
|
||||
containerSelector: '#secondary-inner',
|
||||
boxId: 'rys_sidebar_reveal_box',
|
||||
message: 'Sidebar suggestions are hidden.',
|
||||
showAction: () => HTML.setAttribute('remove_sidebar', false),
|
||||
revealSetting: 'add_reveal_sidebar',
|
||||
},
|
||||
{
|
||||
containerSelector: '#movie_player',
|
||||
boxId: 'rys_end_of_video_reveal_box',
|
||||
message: 'End-of-video suggestions are hidden.',
|
||||
showAction: () => HTML.setAttribute('remove_end_of_video', false),
|
||||
revealSetting: 'add_reveal_end_of_video',
|
||||
},
|
||||
@@ -57,13 +53,47 @@ let lastRedirect;
|
||||
const redirectInterval = 1_000; // 1 second
|
||||
|
||||
|
||||
// Get list of premium feature IDs
|
||||
const PREMIUM_FEATURE_IDS = SECTIONS.flatMap(section =>
|
||||
section.options.filter(opt => opt.premium).map(opt => opt.id)
|
||||
);
|
||||
|
||||
// Decode license token to check premium status (no signature verification - we trust our server)
|
||||
function decodeLicenseToken(token) {
|
||||
if (!token) return null;
|
||||
try {
|
||||
const payload = token.split('.')[1];
|
||||
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||
return JSON.parse(atob(base64));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPremiumFromToken(token) {
|
||||
const decoded = decodeLicenseToken(token);
|
||||
if (!decoded) return false;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return decoded.premium === true && decoded.exp > now;
|
||||
}
|
||||
|
||||
// Respond to changes in settings
|
||||
function logStorageChange(changes, area) {
|
||||
if (area !== 'local') return;
|
||||
|
||||
// Update premium status in cache if license token changed
|
||||
if ('license_token' in changes) {
|
||||
cache['_isPremium'] = isPremiumFromToken(changes['license_token'].newValue);
|
||||
}
|
||||
|
||||
Object.entries(changes).forEach(([id, { oldValue, newValue }]) => {
|
||||
if (oldValue === newValue) return;
|
||||
|
||||
// Enforce premium features are false for non-premium users
|
||||
if (PREMIUM_FEATURE_IDS.includes(id) && cache['_isPremium'] !== true) {
|
||||
newValue = false;
|
||||
}
|
||||
|
||||
HTML.setAttribute(id, newValue);
|
||||
cache[id] = newValue;
|
||||
|
||||
@@ -74,7 +104,6 @@ function logStorageChange(changes, area) {
|
||||
}
|
||||
browser.storage.onChanged.addListener(logStorageChange);
|
||||
|
||||
|
||||
// Get settings
|
||||
browser.storage.local.get(settings => {
|
||||
if (!settings) return;
|
||||
@@ -84,6 +113,15 @@ browser.storage.local.get(settings => {
|
||||
browser.storage.local.set(revealUpdates);
|
||||
}
|
||||
|
||||
// Enforce premium features are false for non-premium users
|
||||
const isPremium = isPremiumFromToken(settings['license_token']);
|
||||
cache['_isPremium'] = isPremium;
|
||||
if (!isPremium) {
|
||||
PREMIUM_FEATURE_IDS.forEach(id => {
|
||||
settings[id] = false;
|
||||
});
|
||||
}
|
||||
|
||||
Object.entries({ ...DEFAULT_SETTINGS, ...settings}).forEach(([ id, value ]) => {
|
||||
HTML.setAttribute(id, value);
|
||||
cache[id] = value;
|
||||
@@ -149,9 +187,11 @@ function runDynamicSettings() {
|
||||
handleNewPage();
|
||||
}
|
||||
|
||||
// Double check for redirects.
|
||||
// Double check for redirects. Also reset dynamicIters so the
|
||||
// reveal box creation code keeps running on the homepage.
|
||||
if (onHomepage && !cache['redirect_off']) {
|
||||
handleNewPage();
|
||||
dynamicIters = 0;
|
||||
checkRedirects();
|
||||
}
|
||||
|
||||
// Dynamic settings
|
||||
@@ -192,6 +232,23 @@ function runDynamicSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
// Hide all but the first row of homepage suggestions
|
||||
if (onHomepage) {
|
||||
const grid = qs('ytd-browse[page-subtype="home"] ytd-rich-grid-renderer');
|
||||
if (grid) {
|
||||
const items = qsa(':scope > #contents > ytd-rich-item-renderer', grid);
|
||||
if (cache['remove_all_but_one']) {
|
||||
const perRow = parseInt(getComputedStyle(grid).getPropertyValue('--ytd-rich-grid-items-per-row')) || 4;
|
||||
items.forEach((item, i) => {
|
||||
if (i >= perRow) item.setAttribute('rys-hidden-row', '');
|
||||
else item.removeAttribute('rys-hidden-row');
|
||||
});
|
||||
} else {
|
||||
items.forEach(item => item.removeAttribute('rys-hidden-row'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channel page option
|
||||
if (onChannel) {
|
||||
if (cache['remove_channel_for_you']) {
|
||||
@@ -485,7 +542,7 @@ function runDynamicSettings() {
|
||||
}
|
||||
|
||||
// Reveal suggestions boxes (only check on early iterations)
|
||||
if (dynamicIters <= 30) REVEAL_BOX_CONFIGS.forEach(({ containerSelector, boxId, message, showAction, revealSetting }) => {
|
||||
if (dynamicIters <= 30) REVEAL_BOX_CONFIGS.forEach(({ containerSelector, boxId, showAction, revealSetting }) => {
|
||||
|
||||
if (closedRevealBoxes.has(boxId)) return;
|
||||
if (qs(`#${boxId}`)) return;
|
||||
@@ -497,20 +554,11 @@ function runDynamicSettings() {
|
||||
box.id = boxId;
|
||||
box.className = 'rys_reveal_box';
|
||||
box.innerHTML = `
|
||||
<div class="rys_reveal_header">
|
||||
<div class="rys_reveal_branding">
|
||||
<img src="${rysLogoUrl}" alt="RYS" class="rys_reveal_logo">
|
||||
<span class="rys_reveal_brand">RYS</span>
|
||||
</div>
|
||||
<div class="rys_reveal_actions">
|
||||
<a class="rys_reveal_dismiss">Don't show again</a>
|
||||
<button class="rys_reveal_close" aria-label="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rys_reveal_content">
|
||||
<span class="rys_reveal_message">${message}</span>
|
||||
<a class="rys_reveal_show">Reveal</a>
|
||||
<div class="rys_reveal_actions">
|
||||
<a class="rys_reveal_dismiss">Don't show again</a>
|
||||
<button class="rys_reveal_close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<a class="rys_reveal_show">Reveal suggestions</a>
|
||||
`;
|
||||
|
||||
box.querySelector('.rys_reveal_show')?.addEventListener('click', showAction);
|
||||
@@ -587,9 +635,33 @@ function injectAnnouncementBanners() {
|
||||
|
||||
|
||||
|
||||
function checkRedirects() {
|
||||
const on = cache['global_enable'] === true;
|
||||
if (
|
||||
on &&
|
||||
onHomepage &&
|
||||
!cache['redirect_off'] &&
|
||||
(!lastRedirect || Date.now() - lastRedirect > redirectInterval)
|
||||
) {
|
||||
if (cache['redirect_to_subs']) {
|
||||
const button = qs('a#endpoint[href="/feed/subscriptions"]');
|
||||
button?.click();
|
||||
lastRedirect = Date.now();
|
||||
}
|
||||
if (cache['redirect_to_wl']) {
|
||||
location.replace(REDIRECT_URLS['redirect_to_wl']);
|
||||
lastRedirect = Date.now();
|
||||
}
|
||||
if (cache['redirect_to_library']) {
|
||||
const button = qs('a#endpoint[href="/feed/library"]');
|
||||
button?.click();
|
||||
lastRedirect = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleNewPage() {
|
||||
const on = cache['global_enable'] === true;
|
||||
|
||||
dynamicIters = 0;
|
||||
url = location.href;
|
||||
theaterClicked = false;
|
||||
@@ -614,28 +686,7 @@ function handleNewPage() {
|
||||
// Refresh HTML attributes
|
||||
Object.entries(cache).forEach(([key, value]) => HTML.setAttribute(key, value));
|
||||
|
||||
// Homepage redirects
|
||||
if (
|
||||
on &&
|
||||
onHomepage &&
|
||||
!cache['redirect_off'] &&
|
||||
(!lastRedirect || Date.now() - lastRedirect > redirectInterval)
|
||||
) {
|
||||
if (cache['redirect_to_subs']) {
|
||||
const button = qs('a#endpoint[href="/feed/subscriptions"]');
|
||||
button?.click();
|
||||
lastRedirect = Date.now();
|
||||
}
|
||||
if (cache['redirect_to_wl']) {
|
||||
location.replace(REDIRECT_URLS['redirect_to_wl']);
|
||||
lastRedirect = Date.now();
|
||||
}
|
||||
if (cache['redirect_to_library']) {
|
||||
const button = qs('a#endpoint[href="/feed/library"]');
|
||||
button?.click();
|
||||
lastRedirect = Date.now();
|
||||
}
|
||||
}
|
||||
checkRedirects();
|
||||
|
||||
// Redirect the shorts player
|
||||
if (on && onShorts && cache['normalize_shorts']) {
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
"permissions": [
|
||||
"storage",
|
||||
"*://www.youtube.com/*",
|
||||
"*://m.youtube.com/*"
|
||||
"*://m.youtube.com/*",
|
||||
"https://server.lawrencehook.com/*"
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
"/images/rys.svg"
|
||||
|
||||
@@ -550,3 +550,483 @@ html[foo=bar] {
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
|
||||
/*************************************
|
||||
* SIGN-IN / ACCOUNT / UPGRADE MODALS
|
||||
* (Premium/Monetization Feature)
|
||||
*************************************/
|
||||
|
||||
#signin_container_background,
|
||||
#account_container_background,
|
||||
#upgrade_container_background,
|
||||
#premium_required_container_background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#signin_container,
|
||||
#account_container,
|
||||
#upgrade_container,
|
||||
#premium_required_container {
|
||||
height: fit-content;
|
||||
max-height: calc(100vh - 20px);
|
||||
width: min(90vw, 380px);
|
||||
margin-top: calc(1.2 * var(--header-height));
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
|
||||
text-align: center;
|
||||
|
||||
background-color: var(--body-background-color);
|
||||
color: var(--body-color);
|
||||
box-shadow: 0px 0px .5px .5px var(--box-shadow-color);
|
||||
box-sizing: border-box;
|
||||
|
||||
border: 1px solid var(--box-shadow-color);
|
||||
border-radius: 2px;
|
||||
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#account_header {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#signin_header,
|
||||
#upgrade_header,
|
||||
#premium_required_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#signin_header_brand,
|
||||
#upgrade_header_brand,
|
||||
#premium_required_header_brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#signin_header_logo,
|
||||
#upgrade_header_logo,
|
||||
#premium_required_header_logo {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
#signin_header_rys,
|
||||
#upgrade_header_rys,
|
||||
#premium_required_header_rys {
|
||||
font-size: 1.3rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
#signin_header_premium,
|
||||
#upgrade_header_premium,
|
||||
#premium_required_header_premium {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: var(--blue-color, #4a6cf7);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
#signin_email_form,
|
||||
#signin_error {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#signin_waiting {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#signin_email_form p,
|
||||
#signin_waiting p {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#signin-email-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.85rem;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--body-background-color);
|
||||
color: var(--body-color);
|
||||
}
|
||||
|
||||
#signin-email-input::placeholder {
|
||||
color: var(--body-color);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
#signin_container button,
|
||||
#account_container button,
|
||||
#upgrade_container button,
|
||||
#premium_required_container button {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
background-color: var(--blue-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
#signin_container button:hover,
|
||||
#account_container button:hover,
|
||||
#upgrade_container button:hover,
|
||||
#premium_required_container button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#signin_container button:disabled,
|
||||
#account_container button:disabled,
|
||||
#upgrade_container button:disabled,
|
||||
#premium_required_container button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#signin-send-button,
|
||||
#upgrade-checkout-button,
|
||||
#premium-required-signin-button {
|
||||
background-color: transparent !important;
|
||||
color: var(--blue-color) !important;
|
||||
border: 1px solid var(--blue-color) !important;
|
||||
}
|
||||
|
||||
#signin_email_row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#signin_email_row #signin-email-input {
|
||||
flex: 1;
|
||||
border-right: none;
|
||||
border-radius: var(--border-radius) 0 0 var(--border-radius);
|
||||
}
|
||||
|
||||
#signin-send-button {
|
||||
padding: 8px 10px;
|
||||
border-radius: 0 var(--border-radius) var(--border-radius) 0 !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#signin-send-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
#signin-send-button:hover,
|
||||
#premium-required-signin-button:hover {
|
||||
background-color: rgba(6, 0, 251, 0.05) !important;
|
||||
}
|
||||
|
||||
#signin-cancel-button,
|
||||
#upgrade-cancel-button,
|
||||
#premium-required-cancel-button,
|
||||
#account-signout-button {
|
||||
background-color: transparent !important;
|
||||
color: var(--body-color) !important;
|
||||
border: 1px solid var(--box-shadow-color) !important;
|
||||
}
|
||||
|
||||
#signin-cancel-button:hover,
|
||||
#upgrade-cancel-button:hover,
|
||||
#premium-required-cancel-button:hover,
|
||||
#account-signout-button:hover {
|
||||
background-color: var(--box-shadow-color) !important;
|
||||
}
|
||||
|
||||
#signin-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#signin-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--box-shadow-color);
|
||||
border-top-color: var(--blue-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
#signin-waiting-email {
|
||||
font-weight: bold;
|
||||
word-break: break-all;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
#signin-status {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
#signin-error-message {
|
||||
color: var(--red-color);
|
||||
}
|
||||
|
||||
/* Account Modal */
|
||||
#account_container {
|
||||
width: auto;
|
||||
padding: 16px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#account_row_1 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#account-email-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#account-email-label {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
#account-email {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
#account-premium-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#account-premium-label[data-status="free"] {
|
||||
color: var(--body-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
#account-premium-label[data-status="premium"] {
|
||||
color: var(--blue-color);
|
||||
}
|
||||
|
||||
#account-premium-label[data-status="grandfathered"] {
|
||||
color: #b8860b;
|
||||
}
|
||||
|
||||
#account_row_2 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#account_row_2 button {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#account-billing-button {
|
||||
background-color: transparent !important;
|
||||
color: var(--blue-color) !important;
|
||||
border: 1px solid var(--blue-color) !important;
|
||||
}
|
||||
|
||||
#account-billing-button:hover {
|
||||
background-color: rgba(6, 0, 251, 0.05) !important;
|
||||
}
|
||||
|
||||
#account-signout-button {
|
||||
background-color: transparent !important;
|
||||
color: var(--body-color) !important;
|
||||
border: 1px solid var(--box-shadow-color) !important;
|
||||
}
|
||||
|
||||
#account-signout-button:hover {
|
||||
background-color: var(--box-shadow-color) !important;
|
||||
}
|
||||
|
||||
/* Upgrade Modal */
|
||||
#upgrade_container {
|
||||
width: auto;
|
||||
padding: 24px;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
|
||||
#upgrade_header_action {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--body-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#premium_required_description {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#upgrade_plans {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upgrade-plan {
|
||||
width: 120px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--box-shadow-color);
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.upgrade-plan:hover {
|
||||
border-color: var(--light-blue);
|
||||
}
|
||||
|
||||
.upgrade-plan[selected] {
|
||||
border-color: var(--blue-color);
|
||||
background-color: rgba(6, 0, 251, 0.08);
|
||||
}
|
||||
html[dark_mode='true'] .upgrade-plan[selected] {
|
||||
background-color: rgba(74, 108, 247, 0.15);
|
||||
}
|
||||
|
||||
.plan-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--body-color);
|
||||
}
|
||||
|
||||
.plan-price {
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
color: var(--label-color);
|
||||
}
|
||||
|
||||
.plan-price-period {
|
||||
font-size: 0.7rem;
|
||||
font-weight: normal;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
|
||||
#upgrade_buttons,
|
||||
#premium_required_buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#upgrade_buttons button,
|
||||
#premium_required_buttons button {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#upgrade-checkout-button {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
#upgrade-checkout-button:hover {
|
||||
background-color: rgba(6, 0, 251, 0.05) !important;
|
||||
}
|
||||
|
||||
#upgrade-cancel-button {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
/* Premium lock icon for options */
|
||||
html:not([is_premium='true']) .option[data-premium='true'] {
|
||||
opacity: 0.25;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
html:not([is_premium='true']) .option[data-premium='true']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23666'%3E%3Cpath d='M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z'/%3E%3C/svg%3E");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* Premium options unlocked */
|
||||
html[is_premium='true'] .option[data-premium='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Premium lock icon for settings menu items */
|
||||
html:not([is_premium='true']) #settings-menu div[data-premium='true'],
|
||||
html:not([is_premium='true']) #power-options div[data-premium='true'] {
|
||||
opacity: 0.25;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
html:not([is_premium='true']) #settings-menu div[data-premium='true']::after,
|
||||
html:not([is_premium='true']) #power-options div[data-premium='true']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23666'%3E%3Cpath d='M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z'/%3E%3C/svg%3E");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
html[is_premium='true'] #settings-menu div[data-premium='true'],
|
||||
html[is_premium='true'] #power-options div[data-premium='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
+10
-138
@@ -4,153 +4,25 @@
|
||||
}
|
||||
|
||||
html[dark_mode='true'] {
|
||||
--body-color: #E0E0E0;
|
||||
--body-background-color: #1a1a2e;
|
||||
--card-background: #16213e;
|
||||
--card-border: #0f3460;
|
||||
--label-color: #E9E9E9;
|
||||
--box-shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--accent-light: rgba(233, 30, 99, 0.15);
|
||||
}
|
||||
|
||||
html[dark_mode='false'], html:not([dark_mode]) {
|
||||
--body-color: #333;
|
||||
--body-background-color: #f8f9fa;
|
||||
--card-background: #fff;
|
||||
--card-border: #e9ecef;
|
||||
--label-color: #212529;
|
||||
--box-shadow-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--body-background-color);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
color: var(--body-color);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
height: 600px;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Back Button */
|
||||
#back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 10px;
|
||||
text-decoration: none;
|
||||
color: var(--body-color);
|
||||
background: var(--card-background);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
#back-button:hover {
|
||||
background: var(--accent-light);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#back-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
header h1 {
|
||||
margin-bottom: 4px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: var(--accent-light);
|
||||
border-radius: 50%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header-icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--label-color);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--body-color);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.subtitle.secondary {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.subtitle a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.subtitle a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Tier Cards */
|
||||
.tier {
|
||||
flex: 1;
|
||||
background: var(--card-background);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
box-shadow: 0 1px 4px var(--box-shadow-color);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -168,8 +40,8 @@ main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--accent);
|
||||
@@ -222,9 +94,9 @@ footer {
|
||||
flex-shrink: 0;
|
||||
background: var(--card-background);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
margin-top: 16px;
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<link rel="icon" href="/images/128.png">
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="/options/general.css">
|
||||
<link rel="stylesheet" type="text/css" href="/options/subpage-base.css">
|
||||
<link rel="stylesheet" type="text/css" href="/options/donors/donors.css">
|
||||
|
||||
<script defer src="/shared/main.js"></script>
|
||||
@@ -35,20 +36,17 @@
|
||||
</div>
|
||||
|
||||
<div class="page-container">
|
||||
<a href="/options/main.html" id="back-button">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
Back
|
||||
</a>
|
||||
<header>
|
||||
<a href="/options/main.html" id="back-button">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
Back
|
||||
</a>
|
||||
|
||||
<div class="header-content">
|
||||
<div class="header-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
|
||||
</div>
|
||||
<h1>Supporters</h1>
|
||||
<p class="subtitle">RYS is made possible by generous donations from people like you.</p>
|
||||
<p class="subtitle secondary">You can also support by sharing with a friend or leaving a <a id="review-link" target="_blank">review</a></p>
|
||||
</div>
|
||||
<h1>
|
||||
<svg class="header-icon" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
|
||||
Supporters
|
||||
</h1>
|
||||
<p class="subtitle">RYS is supported by people like you.</p>
|
||||
<p class="subtitle secondary">You can also support by sharing with a friend or leaving a <a id="review-link" target="_blank">review</a></p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
@@ -8,134 +8,29 @@
|
||||
}
|
||||
|
||||
html[dark_mode='true'] {
|
||||
--body-color: #E0E0E0;
|
||||
--body-background-color: #1a1a2e;
|
||||
--card-background: #16213e;
|
||||
--card-border: #0f3460;
|
||||
--label-color: #E9E9E9;
|
||||
--box-shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--accent-light: rgba(33, 150, 243, 0.15);
|
||||
--github: #E0E0E0;
|
||||
}
|
||||
|
||||
html[dark_mode='false'], html:not([dark_mode]) {
|
||||
--body-color: #333;
|
||||
--body-background-color: #f8f9fa;
|
||||
--card-background: #fff;
|
||||
--card-border: #e9ecef;
|
||||
--label-color: #212529;
|
||||
--box-shadow-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--body-background-color);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
color: var(--body-color);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
height: 600px;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Back Button */
|
||||
#back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 10px;
|
||||
text-decoration: none;
|
||||
color: var(--body-color);
|
||||
background: var(--card-background);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
#back-button:hover {
|
||||
background: var(--accent-light);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#back-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
header h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--accent-light);
|
||||
border-radius: 50%;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.header-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--label-color);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Section Cards */
|
||||
.section-card {
|
||||
background: var(--card-background);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
padding: 10px 12px;
|
||||
box-shadow: 0 1px 4px var(--box-shadow-color);
|
||||
}
|
||||
|
||||
@@ -143,7 +38,7 @@ main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 8px;
|
||||
margin: 0 0 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--label-color);
|
||||
@@ -157,7 +52,7 @@ main {
|
||||
}
|
||||
|
||||
.section-card > p {
|
||||
margin: 0 0 10px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: var(--body-color);
|
||||
opacity: 0.85;
|
||||
@@ -175,8 +70,8 @@ main {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 6px;
|
||||
gap: 4px;
|
||||
padding: 8px 6px;
|
||||
background: var(--body-background-color);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
@@ -242,7 +137,7 @@ main {
|
||||
|
||||
/* Troubleshooting */
|
||||
.troubleshoot-steps {
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.troubleshoot-steps p {
|
||||
@@ -265,7 +160,7 @@ main {
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--card-border);
|
||||
margin: 8px 0;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
/* Help Request */
|
||||
@@ -284,7 +179,7 @@ main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.info-row:not(:last-child) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<link rel="icon" href="/images/128.png">
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="/options/general.css">
|
||||
<link rel="stylesheet" type="text/css" href="/options/subpage-base.css">
|
||||
<link rel="stylesheet" type="text/css" href="/options/feedback/feedback.css">
|
||||
|
||||
<script defer src="/shared/main.js"></script>
|
||||
@@ -22,19 +23,16 @@
|
||||
<body>
|
||||
|
||||
<div class="page-container">
|
||||
<a href="/options/main.html" id="back-button">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
Back
|
||||
</a>
|
||||
<header>
|
||||
<a href="/options/main.html" id="back-button">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
Back
|
||||
</a>
|
||||
|
||||
<div class="header-content">
|
||||
<div class="header-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 12h-2v-2h2v2zm0-4h-2V6h2v4z"/></svg>
|
||||
</div>
|
||||
<h1>Feedback & Support</h1>
|
||||
<p class="subtitle">We'd love to hear from you</p>
|
||||
</div>
|
||||
<h1>
|
||||
<svg class="header-icon" viewBox="0 0 24 24" fill="currentColor"><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 12h-2v-2h2v2zm0-4h-2V6h2v4z"/></svg>
|
||||
Feedback & Support
|
||||
</h1>
|
||||
<p class="subtitle">We'd love to hear from you</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
@@ -27,6 +27,8 @@ html[dark_mode='true'] {
|
||||
--body-background-color: #393939;
|
||||
--label-color: #E9E9E9;
|
||||
--box-shadow-color: rgba(127, 127, 127, 0.90);
|
||||
--blue-color: hsl(238 80% 70%);
|
||||
--light-blue: hsl(238 50% 70%);
|
||||
}
|
||||
|
||||
html[dark_mode='false'], html:not([dark_mode]) {
|
||||
@@ -43,10 +45,12 @@ html[global_enable="false"] #lock_code_container,
|
||||
html[global_enable="true"][lock_code="false"] #lock_code_container,
|
||||
html[global_enable="true"][lock_code="true"]:not([entering_lock_code]) #lock_code_container,
|
||||
html[global_enable="true"] #disabled_message_container,
|
||||
html[global_enable="false"] #main_container,
|
||||
html[global_enable="false"] #search_bar {
|
||||
html[global_enable="false"] #main_container {
|
||||
display: none;
|
||||
}
|
||||
html[global_enable="false"] #search_bar {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--body-background-color);
|
||||
|
||||
@@ -43,12 +43,19 @@ html[dark_mode='false'] #header-light { display: none }
|
||||
max-width: var(--icon-dim);
|
||||
}
|
||||
#header-text {
|
||||
margin-right: auto;
|
||||
font-size: 1.3rem;
|
||||
color: var(--label-color);
|
||||
}
|
||||
|
||||
#header-premium-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: var(--blue-color, #4a6cf7);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
#search_bar {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
#search_bar input {
|
||||
|
||||
+111
-4
@@ -17,6 +17,9 @@
|
||||
|
||||
<script defer src="/shared/md5.js"></script>
|
||||
<script defer src="/shared/main.js"></script>
|
||||
<script defer src="/shared/config.js"></script>
|
||||
<script defer src="/shared/auth.js"></script>
|
||||
<script defer src="/shared/license.js"></script>
|
||||
<script defer src="/shared/https.js"></script>
|
||||
<script defer src="/shared/analytics.js"></script>
|
||||
<script defer src="/shared/utils.js"></script>
|
||||
@@ -121,6 +124,107 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div id="signin_container_background" hidden>
|
||||
<div id="signin_container">
|
||||
<div id="signin_header">
|
||||
<div id="signin_header_brand">
|
||||
<img src="../images/rys.svg" alt="RYS" id="signin_header_logo">
|
||||
<span id="signin_header_rys">RYS</span>
|
||||
<span id="signin_header_premium">Premium</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="signin_email_form">
|
||||
<p>Enter your email to receive a sign-in link.</p>
|
||||
<div id="signin_email_row">
|
||||
<input id="signin-email-input" type="email" placeholder="you@example.com" autocomplete="email">
|
||||
<button id="signin-send-button" title="Send sign-in link">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="signin_waiting" hidden>
|
||||
<p>Check your email for a sign-in link.</p>
|
||||
<p id="signin-waiting-email"></p>
|
||||
<div id="signin-status-row">
|
||||
<div id="signin-spinner"></div>
|
||||
<p id="signin-status">Waiting for verification...</p>
|
||||
</div>
|
||||
<button id="signin-cancel-button">Cancel</button>
|
||||
</div>
|
||||
|
||||
<div id="signin_error" hidden>
|
||||
<p id="signin-error-message"></p>
|
||||
<button id="signin-retry-button">Try Again</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="account_container_background" hidden>
|
||||
<div id="account_container">
|
||||
<div id="account_row_1">
|
||||
<div id="account-email-container">
|
||||
<span id="account-email-label">Signed in as</span>
|
||||
<span id="account-email"></span>
|
||||
</div>
|
||||
<span id="account-premium-label" data-status="free"></span>
|
||||
</div>
|
||||
<div id="account_row_2">
|
||||
<button id="account-upgrade-button" hidden>Upgrade</button>
|
||||
<button id="account-billing-button" hidden>Manage Subscription</button>
|
||||
<button id="account-signout-button">Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="premium_required_container_background" hidden>
|
||||
<div id="premium_required_container">
|
||||
<div id="premium_required_header">
|
||||
<div id="premium_required_header_brand">
|
||||
<img src="../images/rys.svg" alt="RYS" id="premium_required_header_logo">
|
||||
<span id="premium_required_header_rys">RYS</span>
|
||||
<span id="premium_required_header_premium">Premium</span>
|
||||
</div>
|
||||
</div>
|
||||
<p id="premium_required_description">Premium required. Sign in to upgrade.</p>
|
||||
<div id="premium_required_buttons">
|
||||
<button id="premium-required-cancel-button">Cancel</button>
|
||||
<button id="premium-required-signin-button">Sign In</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="upgrade_container_background" hidden>
|
||||
<div id="upgrade_container">
|
||||
<div id="upgrade_header">
|
||||
<div id="upgrade_header_brand">
|
||||
<img src="../images/rys.svg" alt="RYS" id="upgrade_header_logo">
|
||||
<span id="upgrade_header_rys">RYS</span>
|
||||
<span id="upgrade_header_premium">Premium</span>
|
||||
</div>
|
||||
<span id="upgrade_header_action">Upgrade</span>
|
||||
</div>
|
||||
<div id="upgrade_plans">
|
||||
<div class="upgrade-plan" data-plan="monthly">
|
||||
<div class="plan-name">Monthly</div>
|
||||
<div class="plan-price">$3<span class="plan-price-period">/mo</span></div>
|
||||
</div>
|
||||
<div class="upgrade-plan" data-plan="yearly">
|
||||
<div class="plan-name">Yearly</div>
|
||||
<div class="plan-price">$24<span class="plan-price-period">/yr</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="upgrade_buttons">
|
||||
<button id="upgrade-cancel-button">Not now</button>
|
||||
<button id="upgrade-checkout-button">Checkout</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="log_prompt_banner" hidden>
|
||||
<div id="log_prompt_text">Help improve RYS? Share anonymous usage data to guide development.</div>
|
||||
<div id="log_prompt_buttons">
|
||||
@@ -142,8 +246,10 @@
|
||||
</div>
|
||||
|
||||
<div id='settings-menu' class='hidden'>
|
||||
<div id='settings-password'>Password</div>
|
||||
<div id='settings-schedule'>Schedule</div>
|
||||
<div id='settings-account'>Sign In</div>
|
||||
<hr>
|
||||
<div id='settings-password' data-premium='true'>Password</div>
|
||||
<div id='settings-schedule' data-premium='true'>Schedule</div>
|
||||
<hr>
|
||||
<div id='import-settings'>Import settings</div>
|
||||
<div id='export-settings'>Export settings</div>
|
||||
@@ -157,12 +263,12 @@
|
||||
<a href='https://github.com/lawrencehook/remove-youtube-suggestions' target='_blank'>View Source Code</a>
|
||||
<a href='https://docs.google.com/forms/d/1AzQQxTWgG6M5N87jinvXKQkGS6Mehzg19XV4mjteTK0' target='_blank'>Give Feedback</a>
|
||||
<a href='https://lawrencehook.com/rys/' target='_blank'>Homepage</a>
|
||||
<a href='https://www.paypal.com/donate/?cmd=_donations&business=FF9K9YD6K6SWG&Z3JncnB0=' target='_blank'>❤️ Donate</a>
|
||||
<a id='settings-donate' href='https://www.paypal.com/donate/?cmd=_donations&business=FF9K9YD6K6SWG&Z3JncnB0=' target='_blank'>Donate</a>
|
||||
</div>
|
||||
|
||||
<div id="power-options" hidden>
|
||||
<div id='global_enable'>Turn <span class="on">on</span><span class='off'>off</span></div>
|
||||
<div id='open-schedule'>Edit Schedule</div>
|
||||
<div id='open-schedule' data-premium='true'>Edit Schedule</div>
|
||||
<div id='resume-schedule'>Resume Schedule</div>
|
||||
<hr>
|
||||
<div minutes="10">Turn <span class="on">on</span><span class='off'>off</span> for 10 minutes</div>
|
||||
@@ -179,6 +285,7 @@
|
||||
</a>
|
||||
|
||||
<div id='header-text'>RYS</div>
|
||||
<div id='header-premium-badge' hidden>Premium</div>
|
||||
|
||||
<div id='search_bar'>
|
||||
<input type=search placeholder='Search' autofocus tabindex="0"></input>
|
||||
|
||||
+38
-2
@@ -23,6 +23,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
const settings = { ...DEFAULT_SETTINGS, ...localSettings };
|
||||
|
||||
// Enforce premium features are disabled for non-premium users
|
||||
const isPremium = License.isPremiumSync(localSettings['license_token']);
|
||||
if (!isPremium) {
|
||||
SECTIONS.forEach(section => {
|
||||
section.options.forEach(opt => {
|
||||
if (opt.premium) settings[opt.id] = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const headerSettings = Object.entries(OTHER_SETTINGS).reduce((acc, [id, value]) => {
|
||||
acc[id] = id in localSettings ? localSettings[id] : value;
|
||||
return acc;
|
||||
@@ -78,7 +89,7 @@ function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) {
|
||||
label.setAttribute('href', sectionNameToUrl(name));
|
||||
|
||||
options.forEach(option => {
|
||||
const { id, name, tags, defaultValue, effects, display } = option;
|
||||
const { id, name, tags, defaultValue, effects, display, premium } = option;
|
||||
if (display === false) return;
|
||||
|
||||
const optionNode = TEMPLATE_OPTION.cloneNode(true);
|
||||
@@ -90,13 +101,38 @@ function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) {
|
||||
const optionLabel = optionNode.querySelector('.option_label');
|
||||
optionLabel.innerText = name;
|
||||
|
||||
// Mark premium options
|
||||
if (premium) {
|
||||
optionNode.setAttribute('data-premium', 'true');
|
||||
}
|
||||
|
||||
const svg = optionNode.querySelector('svg');
|
||||
const value = id in SETTING_VALUES ? SETTING_VALUES[id] : defaultValue;
|
||||
HTML.setAttribute(id, value);
|
||||
cache[id] = value;
|
||||
svg.toggleAttribute('active', value);
|
||||
|
||||
optionNode.addEventListener('click', e => {
|
||||
optionNode.addEventListener('click', async e => {
|
||||
// Check if premium-locked
|
||||
if (premium && HTML.getAttribute('is_premium') !== 'true') {
|
||||
// Check if signed in first
|
||||
const signedIn = await Auth.isSignedIn();
|
||||
if (!signedIn) {
|
||||
// Not signed in - show premium required modal
|
||||
if (typeof openPremiumRequiredModal === 'function') {
|
||||
openPremiumRequiredModal();
|
||||
} else {
|
||||
displayStatus('Premium feature - sign in to upgrade');
|
||||
}
|
||||
} else if (typeof openUpgradeModal === 'function') {
|
||||
// Signed in but not premium - open upgrade modal
|
||||
openUpgradeModal();
|
||||
} else {
|
||||
displayStatus('Premium feature - upgrade to access');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const value = svg.toggleAttribute('active');
|
||||
updateSetting(id, value, { manual: true });
|
||||
|
||||
|
||||
@@ -82,10 +82,18 @@ function openScheduleModal() {
|
||||
updateDays(scheduleDays);
|
||||
});
|
||||
}
|
||||
SCHEDULING_OPTION.addEventListener('click', e => {
|
||||
SCHEDULING_OPTION.addEventListener('click', async e => {
|
||||
if (HTML.getAttribute('is_premium') !== 'true') {
|
||||
await handlePremiumFeatureClick();
|
||||
return;
|
||||
}
|
||||
openScheduleModal();
|
||||
});
|
||||
OPEN_SCHEDULE_OPTION.addEventListener('click', e => {
|
||||
OPEN_SCHEDULE_OPTION.addEventListener('click', async e => {
|
||||
if (HTML.getAttribute('is_premium') !== 'true') {
|
||||
await handlePremiumFeatureClick();
|
||||
return;
|
||||
}
|
||||
openScheduleModal();
|
||||
});
|
||||
RESUME_SCHEDULE_OPTION.addEventListener('click', e => {
|
||||
@@ -184,7 +192,11 @@ function openPasswordModal() {
|
||||
DISABLE_PASSWORD_CONTAINER.toggleAttribute('hidden', !password);
|
||||
});
|
||||
}
|
||||
PASSWORD_OPTION.addEventListener('click', e => {
|
||||
PASSWORD_OPTION.addEventListener('click', async e => {
|
||||
if (HTML.getAttribute('is_premium') !== 'true') {
|
||||
await handlePremiumFeatureClick();
|
||||
return;
|
||||
}
|
||||
openPasswordModal();
|
||||
});
|
||||
|
||||
@@ -319,7 +331,7 @@ function closeImportModal() {
|
||||
|
||||
function updateSettings(settings) {
|
||||
Object.entries(settings).forEach(([ id, value ]) => {
|
||||
updateSetting(id, value, false);
|
||||
updateSetting(id, value, { write: false });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -375,3 +387,414 @@ function settingsStrToObj(settingsStr) {
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
|
||||
/***************
|
||||
* Account/Premium
|
||||
***************/
|
||||
const ACCOUNT_OPTION = qs('#settings-account');
|
||||
const DONATE_LINK = qs('#settings-donate');
|
||||
const DONATE_URL = 'https://www.paypal.com/donate/?cmd=_donations&business=FF9K9YD6K6SWG&Z3JncnB0=';
|
||||
const HEADER_PREMIUM_BADGE = qs('#header-premium-badge');
|
||||
|
||||
// Sign-in modal elements
|
||||
const signinModalContainer = qs('#signin_container_background');
|
||||
const signinEmailForm = qs('#signin_email_form');
|
||||
const signinEmailInput = qs('#signin-email-input');
|
||||
const signinSendButton = qs('#signin-send-button');
|
||||
const signinWaiting = qs('#signin_waiting');
|
||||
const signinWaitingEmail = qs('#signin-waiting-email');
|
||||
const signinStatus = qs('#signin-status');
|
||||
const signinCancelButton = qs('#signin-cancel-button');
|
||||
const signinError = qs('#signin_error');
|
||||
const signinErrorMessage = qs('#signin-error-message');
|
||||
const signinRetryButton = qs('#signin-retry-button');
|
||||
|
||||
// Account modal elements
|
||||
const accountModalContainer = qs('#account_container_background');
|
||||
const accountEmail = qs('#account-email');
|
||||
const accountPremiumLabel = qs('#account-premium-label');
|
||||
const accountUpgradeButton = qs('#account-upgrade-button');
|
||||
const accountBillingButton = qs('#account-billing-button');
|
||||
const accountSignoutButton = qs('#account-signout-button');
|
||||
|
||||
// Upgrade modal elements
|
||||
const upgradeModalContainer = qs('#upgrade_container_background');
|
||||
const upgradePlans = qsa('.upgrade-plan');
|
||||
const upgradeCheckoutButton = qs('#upgrade-checkout-button');
|
||||
const upgradeCancelButton = qs('#upgrade-cancel-button');
|
||||
|
||||
// Premium required modal elements
|
||||
const premiumRequiredModalContainer = qs('#premium_required_container_background');
|
||||
const premiumRequiredSigninButton = qs('#premium-required-signin-button');
|
||||
const premiumRequiredCancelButton = qs('#premium-required-cancel-button');
|
||||
|
||||
let currentPollAbortController = null;
|
||||
let selectedPlan = 'monthly'; // Default to monthly
|
||||
let awaitingUpgrade = false;
|
||||
let upgradePollInterval = null;
|
||||
|
||||
async function refreshLicense(force = false) {
|
||||
const signedIn = await Auth.isSignedIn();
|
||||
if (!signedIn) return;
|
||||
|
||||
const licenseData = await License.checkLicense(force);
|
||||
updatePremiumUI(licenseData);
|
||||
if (licenseData.isPremium && awaitingUpgrade) {
|
||||
recordEvent('Checkout Completed', { source: licenseData.source });
|
||||
awaitingUpgrade = false;
|
||||
stopUpgradePolling();
|
||||
displayStatus('Welcome to Premium!');
|
||||
}
|
||||
}
|
||||
|
||||
function startUpgradePolling() {
|
||||
stopUpgradePolling();
|
||||
upgradePollInterval = setInterval(() => refreshLicense(true), PREMIUM_CONFIG.POLL_INTERVAL_MS);
|
||||
// Stop polling after timeout
|
||||
setTimeout(stopUpgradePolling, PREMIUM_CONFIG.POLL_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function stopUpgradePolling() {
|
||||
if (upgradePollInterval) {
|
||||
clearInterval(upgradePollInterval);
|
||||
upgradePollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityRefresh() {
|
||||
if (!awaitingUpgrade || document.hidden) return;
|
||||
refreshLicense(true);
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityRefresh);
|
||||
window.addEventListener('focus', handleVisibilityRefresh);
|
||||
|
||||
// Initialize account state on load
|
||||
async function initAccountState() {
|
||||
const signedIn = await Auth.isSignedIn();
|
||||
if (signedIn) {
|
||||
ACCOUNT_OPTION.textContent = 'Account';
|
||||
// Check license in background
|
||||
refreshLicense(true);
|
||||
} else {
|
||||
ACCOUNT_OPTION.textContent = 'Sign In';
|
||||
// Auto-open sign-in modal if ?signin=1 param is present
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('signin') === '1') {
|
||||
openSigninModal();
|
||||
}
|
||||
}
|
||||
}
|
||||
initAccountState();
|
||||
|
||||
function updatePremiumUI(licenseData) {
|
||||
if (licenseData && licenseData.signedOut) {
|
||||
ACCOUNT_OPTION.textContent = 'Sign In';
|
||||
HTML.setAttribute('is_premium', 'false');
|
||||
if (DONATE_LINK) {
|
||||
DONATE_LINK.textContent = 'Donate';
|
||||
DONATE_LINK.setAttribute('href', DONATE_URL);
|
||||
DONATE_LINK.style.cursor = '';
|
||||
}
|
||||
if (HEADER_PREMIUM_BADGE) HEADER_PREMIUM_BADGE.setAttribute('hidden', '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (licenseData && licenseData.isPremium) {
|
||||
HTML.setAttribute('is_premium', 'true');
|
||||
if (DONATE_LINK) {
|
||||
DONATE_LINK.textContent = 'Premium';
|
||||
DONATE_LINK.removeAttribute('href');
|
||||
DONATE_LINK.style.cursor = 'default';
|
||||
}
|
||||
if (HEADER_PREMIUM_BADGE) HEADER_PREMIUM_BADGE.removeAttribute('hidden');
|
||||
} else {
|
||||
HTML.setAttribute('is_premium', 'false');
|
||||
if (DONATE_LINK) {
|
||||
DONATE_LINK.textContent = 'Donate';
|
||||
DONATE_LINK.setAttribute('href', DONATE_URL);
|
||||
DONATE_LINK.style.cursor = '';
|
||||
}
|
||||
if (HEADER_PREMIUM_BADGE) HEADER_PREMIUM_BADGE.setAttribute('hidden', '');
|
||||
}
|
||||
}
|
||||
|
||||
// Account option click handler
|
||||
ACCOUNT_OPTION.addEventListener('click', async () => {
|
||||
const signedIn = await Auth.isSignedIn();
|
||||
if (signedIn) {
|
||||
openAccountModal();
|
||||
} else {
|
||||
// Always open in new tab for sign-in
|
||||
// (prevents popup closing and killing the polling loop)
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('signin') === '1') {
|
||||
// Already in the sign-in tab, just open modal
|
||||
openSigninModal();
|
||||
} else {
|
||||
browser.tabs.create({ url: browser.runtime.getURL('/options/main.html?signin=1') });
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sign-in modal
|
||||
function openSigninModal() {
|
||||
signinModalContainer.removeAttribute('hidden');
|
||||
signinEmailForm.removeAttribute('hidden');
|
||||
signinWaiting.setAttribute('hidden', '');
|
||||
signinError.setAttribute('hidden', '');
|
||||
signinEmailInput.value = '';
|
||||
signinEmailInput.focus();
|
||||
}
|
||||
|
||||
function closeSigninModal() {
|
||||
signinModalContainer.setAttribute('hidden', '');
|
||||
if (currentPollAbortController) {
|
||||
currentPollAbortController.abort();
|
||||
currentPollAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
signinModalContainer.addEventListener('click', e => {
|
||||
if (e.target === signinModalContainer) closeSigninModal();
|
||||
});
|
||||
|
||||
signinEmailInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
signinSendButton.click();
|
||||
}
|
||||
});
|
||||
|
||||
signinSendButton.addEventListener('click', async () => {
|
||||
const email = signinEmailInput.value.trim();
|
||||
if (!email || !email.includes('@')) {
|
||||
displayStatus('Please enter a valid email');
|
||||
return;
|
||||
}
|
||||
|
||||
signinSendButton.disabled = true;
|
||||
recordEvent('Sign In Started');
|
||||
try {
|
||||
const requestId = await Auth.sendMagicLink(email);
|
||||
recordEvent('Magic Link Sent');
|
||||
|
||||
// Show waiting state
|
||||
signinEmailForm.setAttribute('hidden', '');
|
||||
signinWaiting.removeAttribute('hidden');
|
||||
signinWaitingEmail.textContent = email;
|
||||
|
||||
// Start polling
|
||||
currentPollAbortController = new AbortController();
|
||||
const result = await Auth.pollForVerification(requestId, ({ elapsed }) => {
|
||||
const totalSeconds = Math.floor(PREMIUM_CONFIG.POLL_TIMEOUT_MS / 1000);
|
||||
const remaining = Math.max(0, totalSeconds - elapsed);
|
||||
const mins = Math.floor(remaining / 60);
|
||||
const secs = remaining % 60;
|
||||
signinStatus.textContent = `Waiting for verification... ${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}, { signal: currentPollAbortController.signal });
|
||||
|
||||
if (result && result.canceled) {
|
||||
recordEvent('Sign In Canceled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Success
|
||||
displayStatus('Signed in successfully');
|
||||
closeSigninModal();
|
||||
ACCOUNT_OPTION.textContent = 'Account';
|
||||
|
||||
// Check license
|
||||
const licenseData = await License.checkLicense(true);
|
||||
updatePremiumUI(licenseData);
|
||||
|
||||
recordEvent('Sign In Success');
|
||||
} catch (err) {
|
||||
signinWaiting.setAttribute('hidden', '');
|
||||
signinError.removeAttribute('hidden');
|
||||
signinErrorMessage.textContent = err.message;
|
||||
recordEvent('Sign In Error', { error: err.message });
|
||||
} finally {
|
||||
signinSendButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
signinCancelButton.addEventListener('click', closeSigninModal);
|
||||
|
||||
signinRetryButton.addEventListener('click', () => {
|
||||
signinError.setAttribute('hidden', '');
|
||||
signinEmailForm.removeAttribute('hidden');
|
||||
signinEmailInput.focus();
|
||||
});
|
||||
|
||||
// Account modal
|
||||
async function openAccountModal() {
|
||||
const email = await Auth.getUserEmail();
|
||||
const licenseData = await License.checkLicense(true);
|
||||
if (licenseData.signedOut) {
|
||||
recordEvent('Session Expired');
|
||||
updatePremiumUI(licenseData);
|
||||
closeAccountModal();
|
||||
openSigninModal();
|
||||
displayStatus('Session expired. Please sign in again.');
|
||||
return;
|
||||
}
|
||||
recordEvent('Account Modal Opened', { isPremium: licenseData.isPremium, source: licenseData.source });
|
||||
|
||||
accountEmail.textContent = email || 'Unknown';
|
||||
|
||||
if (licenseData.isPremium) {
|
||||
if (licenseData.source === 'grandfathered') {
|
||||
accountPremiumLabel.textContent = 'Lifetime';
|
||||
accountPremiumLabel.setAttribute('data-status', 'grandfathered');
|
||||
accountBillingButton.setAttribute('hidden', '');
|
||||
} else {
|
||||
accountPremiumLabel.textContent = 'Premium';
|
||||
accountPremiumLabel.setAttribute('data-status', 'premium');
|
||||
accountBillingButton.removeAttribute('hidden');
|
||||
}
|
||||
accountUpgradeButton.setAttribute('hidden', '');
|
||||
} else {
|
||||
accountPremiumLabel.textContent = 'Free';
|
||||
accountPremiumLabel.setAttribute('data-status', 'free');
|
||||
accountUpgradeButton.removeAttribute('hidden');
|
||||
accountBillingButton.setAttribute('hidden', '');
|
||||
}
|
||||
|
||||
accountModalContainer.removeAttribute('hidden');
|
||||
}
|
||||
|
||||
function closeAccountModal() {
|
||||
accountModalContainer.setAttribute('hidden', '');
|
||||
}
|
||||
|
||||
accountModalContainer.addEventListener('click', e => {
|
||||
if (e.target === accountModalContainer) closeAccountModal();
|
||||
});
|
||||
|
||||
accountSignoutButton.addEventListener('click', async () => {
|
||||
await Auth.signOut();
|
||||
ACCOUNT_OPTION.textContent = 'Sign In';
|
||||
HTML.setAttribute('is_premium', 'false');
|
||||
// Disable all premium options
|
||||
SECTIONS.forEach(section => {
|
||||
section.options.forEach(option => {
|
||||
if (option.premium) updateSetting(option.id, false);
|
||||
});
|
||||
});
|
||||
if (DONATE_LINK) {
|
||||
DONATE_LINK.textContent = 'Donate';
|
||||
DONATE_LINK.setAttribute('href', DONATE_URL);
|
||||
DONATE_LINK.style.cursor = '';
|
||||
}
|
||||
if (HEADER_PREMIUM_BADGE) HEADER_PREMIUM_BADGE.setAttribute('hidden', '');
|
||||
closeAccountModal();
|
||||
displayStatus('Signed out');
|
||||
recordEvent('Sign Out');
|
||||
});
|
||||
|
||||
accountUpgradeButton.addEventListener('click', () => {
|
||||
closeAccountModal();
|
||||
openUpgradeModal();
|
||||
});
|
||||
|
||||
accountBillingButton.addEventListener('click', async () => {
|
||||
try {
|
||||
accountBillingButton.disabled = true;
|
||||
const portalUrl = await License.createBillingPortalSession();
|
||||
window.open(portalUrl, '_blank');
|
||||
closeAccountModal();
|
||||
recordEvent('Billing Portal Opened');
|
||||
} catch (err) {
|
||||
displayStatus(err.message);
|
||||
recordEvent('Billing Portal Error', { error: err.message });
|
||||
} finally {
|
||||
accountBillingButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Upgrade modal
|
||||
function openUpgradeModal() {
|
||||
recordEvent('Upgrade Modal Opened');
|
||||
upgradeModalContainer.removeAttribute('hidden');
|
||||
selectPlan('monthly');
|
||||
}
|
||||
|
||||
// Premium feature click handler - checks sign-in first
|
||||
async function handlePremiumFeatureClick() {
|
||||
const signedIn = await Auth.isSignedIn();
|
||||
if (!signedIn) {
|
||||
// Not signed in - show premium required modal
|
||||
recordEvent('Premium Feature Click', { signedIn: false });
|
||||
openPremiumRequiredModal();
|
||||
return;
|
||||
}
|
||||
// Signed in but not premium - open upgrade modal
|
||||
recordEvent('Premium Feature Click', { signedIn: true });
|
||||
openUpgradeModal();
|
||||
}
|
||||
|
||||
// Premium required modal (for non-signed-in users)
|
||||
function openPremiumRequiredModal() {
|
||||
premiumRequiredModalContainer.removeAttribute('hidden');
|
||||
}
|
||||
|
||||
function closePremiumRequiredModal() {
|
||||
premiumRequiredModalContainer.setAttribute('hidden', '');
|
||||
}
|
||||
|
||||
premiumRequiredModalContainer.addEventListener('click', e => {
|
||||
if (e.target === premiumRequiredModalContainer) closePremiumRequiredModal();
|
||||
});
|
||||
|
||||
premiumRequiredCancelButton.addEventListener('click', closePremiumRequiredModal);
|
||||
|
||||
premiumRequiredSigninButton.addEventListener('click', () => {
|
||||
closePremiumRequiredModal();
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('signin') === '1') {
|
||||
openSigninModal();
|
||||
} else {
|
||||
browser.tabs.create({ url: browser.runtime.getURL('/options/main.html?signin=1') });
|
||||
window.close();
|
||||
}
|
||||
});
|
||||
|
||||
function closeUpgradeModal() {
|
||||
upgradeModalContainer.setAttribute('hidden', '');
|
||||
}
|
||||
|
||||
function selectPlan(plan) {
|
||||
selectedPlan = plan;
|
||||
upgradePlans.forEach(el => {
|
||||
el.toggleAttribute('selected', el.dataset.plan === plan);
|
||||
});
|
||||
}
|
||||
|
||||
upgradeModalContainer.addEventListener('click', e => {
|
||||
if (e.target === upgradeModalContainer) closeUpgradeModal();
|
||||
});
|
||||
|
||||
upgradePlans.forEach(el => {
|
||||
el.addEventListener('click', () => selectPlan(el.dataset.plan));
|
||||
});
|
||||
|
||||
upgradeCancelButton.addEventListener('click', closeUpgradeModal);
|
||||
|
||||
upgradeCheckoutButton.addEventListener('click', async () => {
|
||||
try {
|
||||
upgradeCheckoutButton.disabled = true;
|
||||
const checkoutUrl = await License.createCheckoutSession(selectedPlan);
|
||||
window.open(checkoutUrl, '_blank');
|
||||
closeUpgradeModal();
|
||||
recordEvent('Checkout Started', { plan: selectedPlan });
|
||||
awaitingUpgrade = true;
|
||||
startUpgradePolling();
|
||||
} catch (err) {
|
||||
displayStatus(err.message);
|
||||
recordEvent('Checkout Error', { plan: selectedPlan, error: err.message });
|
||||
} finally {
|
||||
upgradeCheckoutButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/* Shared base styles for subpages (donors, feedback, etc.) */
|
||||
/* Each page sets :root { --accent; --accent-light } and dark mode --accent-light override */
|
||||
|
||||
html[dark_mode='true'] {
|
||||
--body-color: #E0E0E0;
|
||||
--body-background-color: #1a1a2e;
|
||||
--card-background: #16213e;
|
||||
--card-border: #0f3460;
|
||||
--label-color: #E9E9E9;
|
||||
--box-shadow-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
html[dark_mode='false'], html:not([dark_mode]) {
|
||||
--body-color: #333;
|
||||
--body-background-color: #f8f9fa;
|
||||
--card-background: #fff;
|
||||
--card-border: #e9ecef;
|
||||
--label-color: #212529;
|
||||
--box-shadow-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--body-background-color);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
color: var(--body-color);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Back Button */
|
||||
#back-button {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 10px;
|
||||
text-decoration: none;
|
||||
color: var(--body-color);
|
||||
background: var(--card-background);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#back-button:hover {
|
||||
background: var(--accent-light);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#back-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
color: var(--label-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--body-color);
|
||||
opacity: 0.85;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subtitle.secondary {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.subtitle a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.subtitle a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Authentication module for RYS Premium
|
||||
|
||||
const Auth = {
|
||||
// Send magic link to email
|
||||
async sendMagicLink(email) {
|
||||
const response = await fetch(`${PREMIUM_CONFIG.SERVER_URL}/auth/send-magic-link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
if (response.status === 429) {
|
||||
throw new Error(error.error || 'Too many requests. Please wait and try again.');
|
||||
}
|
||||
throw new Error(error.error || 'Failed to send magic link');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.request_id;
|
||||
},
|
||||
|
||||
// Poll for verification status
|
||||
async pollForVerification(requestId, onStatusUpdate, options = {}) {
|
||||
const startTime = Date.now();
|
||||
const { POLL_INTERVAL_MS, POLL_TIMEOUT_MS } = PREMIUM_CONFIG;
|
||||
const signal = options.signal;
|
||||
|
||||
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
|
||||
if (signal && signal.aborted) {
|
||||
return { canceled: true };
|
||||
}
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${PREMIUM_CONFIG.SERVER_URL}/auth/poll?request_id=${encodeURIComponent(requestId)}`,
|
||||
{ signal }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
const error = new Error('Request expired. Please try again.');
|
||||
error.fatal = true;
|
||||
throw error;
|
||||
}
|
||||
const error = new Error('Failed to check verification status');
|
||||
error.fatal = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'verified') {
|
||||
// Store session token and email
|
||||
await browser.storage.local.set({
|
||||
[PREMIUM_CONFIG.STORAGE_KEYS.SESSION_TOKEN]: data.session_token,
|
||||
[PREMIUM_CONFIG.STORAGE_KEYS.USER_EMAIL]: data.email,
|
||||
});
|
||||
return { success: true, email: data.email };
|
||||
}
|
||||
|
||||
if (onStatusUpdate) {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
onStatusUpdate({ status: 'pending', elapsed });
|
||||
}
|
||||
} catch (err) {
|
||||
if (err && (err.name === 'AbortError' || (signal && signal.aborted))) {
|
||||
return { canceled: true };
|
||||
}
|
||||
if (err && err.fatal) {
|
||||
throw err;
|
||||
}
|
||||
// Network error, continue polling
|
||||
console.error('Poll error:', err);
|
||||
}
|
||||
|
||||
if (signal && signal.aborted) {
|
||||
return { canceled: true };
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
}
|
||||
|
||||
throw new Error('Verification timed out. Please request a new link.');
|
||||
},
|
||||
|
||||
// Check if user is signed in
|
||||
async isSignedIn() {
|
||||
const data = await browser.storage.local.get([
|
||||
PREMIUM_CONFIG.STORAGE_KEYS.SESSION_TOKEN,
|
||||
PREMIUM_CONFIG.STORAGE_KEYS.USER_EMAIL,
|
||||
]);
|
||||
return !!data[PREMIUM_CONFIG.STORAGE_KEYS.SESSION_TOKEN];
|
||||
},
|
||||
|
||||
// Get current user email
|
||||
async getUserEmail() {
|
||||
const data = await browser.storage.local.get(PREMIUM_CONFIG.STORAGE_KEYS.USER_EMAIL);
|
||||
return data[PREMIUM_CONFIG.STORAGE_KEYS.USER_EMAIL] || null;
|
||||
},
|
||||
|
||||
// Get session token
|
||||
async getSessionToken() {
|
||||
const data = await browser.storage.local.get(PREMIUM_CONFIG.STORAGE_KEYS.SESSION_TOKEN);
|
||||
return data[PREMIUM_CONFIG.STORAGE_KEYS.SESSION_TOKEN] || null;
|
||||
},
|
||||
|
||||
// Sign out
|
||||
async signOut() {
|
||||
await browser.storage.local.remove([
|
||||
PREMIUM_CONFIG.STORAGE_KEYS.SESSION_TOKEN,
|
||||
PREMIUM_CONFIG.STORAGE_KEYS.LICENSE_TOKEN,
|
||||
PREMIUM_CONFIG.STORAGE_KEYS.USER_EMAIL,
|
||||
]);
|
||||
},
|
||||
};
|
||||
+1
-11
@@ -1,16 +1,6 @@
|
||||
// Shared banner configuration and utilities
|
||||
|
||||
const BANNERS = [
|
||||
{
|
||||
id: 'premium_coming',
|
||||
storageKey: 'announcement_dismissed_premium_coming',
|
||||
message: 'Premium features coming soon.',
|
||||
linkText: 'Learn more',
|
||||
linkUrl: 'https://lawrencehook.com/rys/premium',
|
||||
dismissText: "Don't show again",
|
||||
showOn: ['options', 'youtube_homepage'],
|
||||
},
|
||||
];
|
||||
const BANNERS = [];
|
||||
|
||||
function createBannerHTML(banner, logoUrl) {
|
||||
const container = document.createElement('div');
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Premium server configuration
|
||||
const PREMIUM_CONFIG = {
|
||||
// Server URL - update for production
|
||||
SERVER_URL: 'https://server.lawrencehook.com/rys',
|
||||
|
||||
// Polling configuration
|
||||
POLL_INTERVAL_MS: 2000,
|
||||
POLL_TIMEOUT_MS: 16 * 60 * 1000, // 16 minutes
|
||||
|
||||
// License token refresh threshold (refresh if expiring within this window)
|
||||
LICENSE_REFRESH_THRESHOLD_MS: 24 * 60 * 60 * 1000, // 24 hours
|
||||
|
||||
// Storage keys
|
||||
STORAGE_KEYS: {
|
||||
SESSION_TOKEN: 'session_token',
|
||||
LICENSE_TOKEN: 'license_token',
|
||||
USER_EMAIL: 'user_email',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
// License checking module for RYS Premium
|
||||
|
||||
const License = {
|
||||
// Decode JWT payload (no signature verification - we trust our server)
|
||||
_decodeToken(token) {
|
||||
if (!token) return null;
|
||||
try {
|
||||
const payload = token.split('.')[1];
|
||||
// Handle base64url encoding
|
||||
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||
return JSON.parse(atob(base64));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Check premium status from license token (fetches new token if needed)
|
||||
async checkLicense(forceRefresh = false) {
|
||||
const { STORAGE_KEYS, LICENSE_REFRESH_THRESHOLD_MS } = PREMIUM_CONFIG;
|
||||
|
||||
const cached = await browser.storage.local.get([
|
||||
STORAGE_KEYS.SESSION_TOKEN,
|
||||
STORAGE_KEYS.LICENSE_TOKEN,
|
||||
]);
|
||||
|
||||
const sessionToken = cached[STORAGE_KEYS.SESSION_TOKEN];
|
||||
if (!sessionToken) {
|
||||
return { isPremium: false, source: null };
|
||||
}
|
||||
|
||||
const licenseToken = cached[STORAGE_KEYS.LICENSE_TOKEN];
|
||||
const decoded = this._decodeToken(licenseToken);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Check if license token is valid and not expiring soon
|
||||
const isValid = decoded && decoded.exp > now;
|
||||
const expiresWithinThreshold = decoded && decoded.exp &&
|
||||
(decoded.exp - now) < (LICENSE_REFRESH_THRESHOLD_MS / 1000);
|
||||
|
||||
if (!forceRefresh && isValid && !expiresWithinThreshold) {
|
||||
// Token valid and not expiring soon - use cached value
|
||||
if (typeof recordEvent === 'function') {
|
||||
recordEvent('License Check', { isPremium: decoded.premium, cached: true });
|
||||
}
|
||||
return {
|
||||
isPremium: decoded.premium,
|
||||
source: decoded.grandfathered ? 'grandfathered' : null,
|
||||
cached: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch fresh license token from server
|
||||
try {
|
||||
const response = await fetch(`${PREMIUM_CONFIG.SERVER_URL}/license/check`, {
|
||||
headers: { 'Authorization': `Bearer ${sessionToken}` },
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
if (typeof recordEvent === 'function') {
|
||||
recordEvent('License Check', { error: 'token_expired' });
|
||||
}
|
||||
await Auth.signOut();
|
||||
return { isPremium: false, source: null, signedOut: true };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to check license');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
await browser.storage.local.set({
|
||||
[STORAGE_KEYS.LICENSE_TOKEN]: data.license_token,
|
||||
});
|
||||
|
||||
const newDecoded = this._decodeToken(data.license_token);
|
||||
const isPremium = newDecoded?.premium || false;
|
||||
const source = newDecoded?.grandfathered ? 'grandfathered' : null;
|
||||
|
||||
if (typeof recordEvent === 'function') {
|
||||
recordEvent('License Check', { isPremium, source, cached: false });
|
||||
}
|
||||
|
||||
return { isPremium, source };
|
||||
} catch (err) {
|
||||
console.error('License check error:', err);
|
||||
|
||||
// Network error - use existing token if still valid (not expired)
|
||||
if (isValid) {
|
||||
if (typeof recordEvent === 'function') {
|
||||
recordEvent('License Check', { error: 'offline', offline: true, isPremium: decoded.premium });
|
||||
}
|
||||
return {
|
||||
isPremium: decoded.premium,
|
||||
source: decoded.grandfathered ? 'grandfathered' : null,
|
||||
offline: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof recordEvent === 'function') {
|
||||
recordEvent('License Check', { error: 'offline_token_expired' });
|
||||
}
|
||||
return { isPremium: false, source: null, error: true };
|
||||
}
|
||||
},
|
||||
|
||||
// Quick check for premium status (uses license token only, no network)
|
||||
async isPremium() {
|
||||
const cached = await browser.storage.local.get(PREMIUM_CONFIG.STORAGE_KEYS.LICENSE_TOKEN);
|
||||
return this.isPremiumSync(cached[PREMIUM_CONFIG.STORAGE_KEYS.LICENSE_TOKEN]);
|
||||
},
|
||||
|
||||
// Synchronous check given a license token string
|
||||
isPremiumSync(token) {
|
||||
const decoded = this._decodeToken(token);
|
||||
if (!decoded) return false;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return decoded.premium === true && decoded.exp > now;
|
||||
},
|
||||
|
||||
// Create checkout session for subscription
|
||||
async createCheckoutSession(plan) {
|
||||
const token = await Auth.getSessionToken();
|
||||
if (!token) {
|
||||
throw new Error('Not signed in');
|
||||
}
|
||||
|
||||
const response = await fetch(`${PREMIUM_CONFIG.SERVER_URL}/checkout/create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ plan }),
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
await Auth.signOut();
|
||||
throw new Error('Session expired. Please sign in again.');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.error || 'Failed to create checkout session');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.checkout_url;
|
||||
},
|
||||
|
||||
// Create billing portal session
|
||||
async createBillingPortalSession() {
|
||||
const token = await Auth.getSessionToken();
|
||||
if (!token) {
|
||||
throw new Error('Not signed in');
|
||||
}
|
||||
|
||||
const response = await fetch(`${PREMIUM_CONFIG.SERVER_URL}/billing/portal`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
await Auth.signOut();
|
||||
throw new Error('Session expired. Please sign in again.');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.error || 'Failed to create billing portal session');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.url;
|
||||
},
|
||||
};
|
||||
+140
-69
@@ -13,35 +13,40 @@ const SECTIONS = [
|
||||
name: "Hide all Shorts",
|
||||
tags: "Homepage, Subscriptions, Video Player, Search",
|
||||
id: "remove_all_shorts",
|
||||
defaultValue: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
name: "Hide video thumbnails",
|
||||
tags: "Homepage, Subscriptions, Video Player, Search",
|
||||
id: "remove_video_thumbnails",
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Blur video thumbnails",
|
||||
tags: "Homepage, Subscriptions, Video Player, Search",
|
||||
id: "blur_video_thumbnails",
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Shrink video thumbnails",
|
||||
tags: "Homepage, Subscriptions, Video Player, Search",
|
||||
id: "shrink_video_thumbnails",
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Disable play on hover",
|
||||
id: "disable_play_on_hover",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Enable search engine mode",
|
||||
id: "search_engine_mode",
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -76,12 +81,13 @@ const SECTIONS = [
|
||||
{
|
||||
name: "Show reveal box for homepage suggestions",
|
||||
id: "add_reveal_homepage",
|
||||
defaultValue: true
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
name: "Hide the header",
|
||||
id: "remove_header",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide all but the first row of suggestions",
|
||||
@@ -93,22 +99,26 @@ const SECTIONS = [
|
||||
remove_extra_rows: true,
|
||||
remove_infinite_scroll: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide extra rows (Shorts, Trending, etc.)",
|
||||
id: "remove_extra_rows",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Disable infinite scroll - homepage",
|
||||
id: "remove_infinite_scroll",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide Playables",
|
||||
id: "remove_playables",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -121,7 +131,7 @@ const SECTIONS = [
|
||||
tags: "Basic",
|
||||
id: "remove_left_nav_bar",
|
||||
defaultValue: false,
|
||||
effects: { true: { only_show_playlists: false }}
|
||||
effects: { true: { only_show_playlists: false }},
|
||||
},
|
||||
{
|
||||
name: "Only show playlists",
|
||||
@@ -141,36 +151,42 @@ const SECTIONS = [
|
||||
remove_shorts_link: true,
|
||||
remove_subscriptions_link: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Disable the YouTube logo link",
|
||||
id: "remove_logo_link",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide button - Home",
|
||||
id: "remove_home_link",
|
||||
defaultValue: false,
|
||||
effects: { false: { only_show_playlists: false }}
|
||||
effects: { false: { only_show_playlists: false }},
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide button - Explore/Trending",
|
||||
id: "remove_explore_link",
|
||||
defaultValue: false,
|
||||
effects: { false: { only_show_playlists: false }}
|
||||
effects: { false: { only_show_playlists: false }},
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide button - Shorts",
|
||||
id: "remove_shorts_link",
|
||||
defaultValue: false,
|
||||
effects: { false: { only_show_playlists: false }}
|
||||
effects: { false: { only_show_playlists: false }},
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide button - Subscriptions",
|
||||
id: "remove_subscriptions_link",
|
||||
defaultValue: false,
|
||||
effects: { false: { only_show_playlists: false }}
|
||||
effects: { false: { only_show_playlists: false }},
|
||||
premium: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -182,37 +198,43 @@ const SECTIONS = [
|
||||
name: "Hide section - Subscriptions",
|
||||
id: "remove_sub_section",
|
||||
defaultValue: false,
|
||||
effects: { false: { only_show_playlists: false }}
|
||||
effects: { false: { only_show_playlists: false }},
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide section - You/Library",
|
||||
id: "remove_quick_links_section",
|
||||
defaultValue: false,
|
||||
effects: { true: { only_show_playlists: false }}
|
||||
effects: { true: { only_show_playlists: false }},
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide section - Explore",
|
||||
id: "remove_explore_section",
|
||||
defaultValue: false,
|
||||
effects: { false: { only_show_playlists: false }}
|
||||
effects: { false: { only_show_playlists: false }},
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide section - More from YouTube",
|
||||
id: "remove_more_section",
|
||||
defaultValue: false,
|
||||
effects: { false: { only_show_playlists: false }}
|
||||
effects: { false: { only_show_playlists: false }},
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide section - Settings",
|
||||
id: "remove_settings_section",
|
||||
defaultValue: false,
|
||||
effects: { false: { only_show_playlists: false }}
|
||||
effects: { false: { only_show_playlists: false }},
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide section - Footer",
|
||||
id: "remove_footer_section",
|
||||
defaultValue: false,
|
||||
effects: { false: { only_show_playlists: false }}
|
||||
effects: { false: { only_show_playlists: false }},
|
||||
premium: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -224,43 +246,49 @@ const SECTIONS = [
|
||||
name: "Skip and close ads",
|
||||
tags: "Basic",
|
||||
id: "auto_skip_ads",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: "Disable autoplay",
|
||||
id: "disable_autoplay",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Disable ambient mode",
|
||||
id: "disable_ambient_mode",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Disable annotations",
|
||||
id: "disable_annotations",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Auto-expand the description",
|
||||
id: "expand_description",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Disable scroll in fullscreen",
|
||||
id: "disable_fullscreen_scroll",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Redirect shorts to the default viewer",
|
||||
tags: "Redirects, Basic",
|
||||
id: "normalize_shorts",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: "Enable theater mode",
|
||||
id: "enable_theater",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -271,52 +299,61 @@ const SECTIONS = [
|
||||
{
|
||||
name: "Show reveal box for end-of-video suggestions",
|
||||
id: "add_reveal_end_of_video",
|
||||
defaultValue: true
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
name: "Hide info cards",
|
||||
id: "remove_info_cards",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide overlay suggestions",
|
||||
id: "remove_overlay_suggestions",
|
||||
defaultValue: true
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide the play next button",
|
||||
id: "remove_play_next_button",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide the menu buttons - Like, Share, etc.",
|
||||
id: "remove_menu_buttons",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide the clip button",
|
||||
id: "remove_clip_button",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide the likes",
|
||||
id: "remove_video_likes",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide channel subscribers count",
|
||||
id: "remove_channel_subscribers",
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide the description",
|
||||
id: "remove_vid_description",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide panel - \"More Videos\" in embedded player",
|
||||
id: "remove_embedded_more_videos",
|
||||
defaultValue: true
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -327,27 +364,31 @@ const SECTIONS = [
|
||||
{
|
||||
name: "Show reveal box for sidebar suggestions",
|
||||
id: "add_reveal_sidebar",
|
||||
defaultValue: true
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
name: "Center contents - removes the sidebar",
|
||||
id: "remove_entire_sidebar",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Disable infinite scroll - sidebar",
|
||||
id: "remove_sidebar_infinite_scroll",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide extra sidebar tags (English only)",
|
||||
id: "remove_extra_sidebar_tags",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide the live-stream chat",
|
||||
id: "remove_chat",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -359,7 +400,7 @@ const SECTIONS = [
|
||||
name: "Hide all comments",
|
||||
tags: "Basic",
|
||||
id: "remove_comments",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: "Hide all but the timestamped comments",
|
||||
@@ -369,27 +410,32 @@ const SECTIONS = [
|
||||
true: {
|
||||
remove_comments: false
|
||||
}
|
||||
}
|
||||
},
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide comment usernames",
|
||||
id: "remove_comment_usernames",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide comment profile pictures",
|
||||
id: "remove_comment_profiles",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide comment replies",
|
||||
id: "remove_comment_replies",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide comment upvotes",
|
||||
id: "remove_comment_upvotes",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -401,37 +447,43 @@ const SECTIONS = [
|
||||
name: "Hide search bar suggestions",
|
||||
tags: "Basic",
|
||||
id: "remove_search_suggestions",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: "Hide promoted videos",
|
||||
id: "remove_search_promoted",
|
||||
defaultValue: true
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide results - shorts",
|
||||
id: "remove_shorts_results",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide result description",
|
||||
id: "remove_results_description",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide results - For You, Trending, etc.",
|
||||
id: "remove_extra_results",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Disable the thumbnail slideshow - on hover",
|
||||
id: "remove_thumbnail_mouseover_effect",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Disable infinite scroll - search results",
|
||||
id: "remove_infinite_scroll_search",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -443,16 +495,19 @@ const SECTIONS = [
|
||||
name: "Disable autoplay - channel page",
|
||||
id: "disable_channel_autoplay",
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide the For You section - channel page",
|
||||
id: "remove_channel_for_you",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Reverse sort channel videos: oldest, least popular",
|
||||
id: "reverse_channel_video_list",
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -463,27 +518,32 @@ const SECTIONS = [
|
||||
{
|
||||
name: "Hide shorts",
|
||||
id: "remove_sub_shorts",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide live videos",
|
||||
id: "remove_sub_live",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide upcoming videos",
|
||||
id: "remove_sub_upcoming",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide premiere videos",
|
||||
id: "remove_sub_premiere",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide VODs (English only)",
|
||||
id: "remove_sub_vods",
|
||||
defaultValue: false
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -504,7 +564,8 @@ const SECTIONS = [
|
||||
false: {
|
||||
redirect_off: true
|
||||
}
|
||||
}
|
||||
},
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Redirect home to Watch Later",
|
||||
@@ -519,7 +580,8 @@ const SECTIONS = [
|
||||
false: {
|
||||
redirect_off: true
|
||||
}
|
||||
}
|
||||
},
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Redirect home to Library",
|
||||
@@ -534,13 +596,15 @@ const SECTIONS = [
|
||||
false: {
|
||||
redirect_off: true
|
||||
}
|
||||
}
|
||||
},
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Do not redirect home",
|
||||
id: "redirect_off",
|
||||
defaultValue: true,
|
||||
display: false
|
||||
defaultValue: false,
|
||||
display: false,
|
||||
premium: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -552,26 +616,31 @@ const SECTIONS = [
|
||||
name: "Hide playlist suggestions",
|
||||
id: "remove_playlist_suggestions",
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Hide the notification bell",
|
||||
id: "remove_notif_bell",
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Autofocus the search bar",
|
||||
id: "autofocus_search",
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Remove context boxes",
|
||||
id: "remove_context",
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Enable grayscale mode",
|
||||
id: "grayscale_mode",
|
||||
defaultValue: false,
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Lock settings - 10 second timer",
|
||||
@@ -581,7 +650,8 @@ const SECTIONS = [
|
||||
true: {
|
||||
lock_code: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
premium: true,
|
||||
},
|
||||
{
|
||||
name: "Lock settings - code entry",
|
||||
@@ -591,7 +661,8 @@ const SECTIONS = [
|
||||
true: {
|
||||
menu_timer: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
premium: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
/**
|
||||
* Tests for URL regex patterns used in content-script/main.js
|
||||
* These patterns determine which YouTube page type the extension is on.
|
||||
*
|
||||
* Since the actual content script depends on DOM, we recreate the patterns here
|
||||
* to test their correctness independently.
|
||||
*/
|
||||
|
||||
// Recreate the regex patterns from content-script/main.js
|
||||
const resultsPageRegex = new RegExp('.*://.*youtube\\.com/results.*', 'i');
|
||||
const homepageRegex = new RegExp('.*://(www|m)\\.youtube\\.com(/)?$', 'i');
|
||||
const shortsRegex = new RegExp('.*://.*youtube\\.com/shorts.*', 'i');
|
||||
const videoRegex = new RegExp('.*://.*youtube\\.com/watch\\?v=.*', 'i');
|
||||
const channelRegex = new RegExp('.*://.*youtube\\.com/(@|channel)', 'i');
|
||||
const subsRegex = new RegExp(/\/feed\/subscriptions$/, 'i');
|
||||
|
||||
describe('YouTube URL Pattern Matching', () => {
|
||||
describe('resultsPageRegex', () => {
|
||||
it('should match search results page', () => {
|
||||
assert.strictEqual(resultsPageRegex.test('https://www.youtube.com/results?search_query=test'), true);
|
||||
assert.strictEqual(resultsPageRegex.test('https://youtube.com/results?search_query=cats'), true);
|
||||
assert.strictEqual(resultsPageRegex.test('http://www.youtube.com/results'), true);
|
||||
});
|
||||
|
||||
it('should not match other pages', () => {
|
||||
assert.strictEqual(resultsPageRegex.test('https://www.youtube.com/'), false);
|
||||
assert.strictEqual(resultsPageRegex.test('https://www.youtube.com/watch?v=abc'), false);
|
||||
assert.strictEqual(resultsPageRegex.test('https://www.youtube.com/shorts/abc'), false);
|
||||
});
|
||||
|
||||
it('should match mobile results page', () => {
|
||||
assert.strictEqual(resultsPageRegex.test('https://m.youtube.com/results?search_query=test'), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('homepageRegex', () => {
|
||||
it('should match desktop homepage', () => {
|
||||
assert.strictEqual(homepageRegex.test('https://www.youtube.com/'), true);
|
||||
assert.strictEqual(homepageRegex.test('https://www.youtube.com'), true);
|
||||
assert.strictEqual(homepageRegex.test('http://www.youtube.com/'), true);
|
||||
});
|
||||
|
||||
it('should match mobile homepage', () => {
|
||||
assert.strictEqual(homepageRegex.test('https://m.youtube.com/'), true);
|
||||
assert.strictEqual(homepageRegex.test('https://m.youtube.com'), true);
|
||||
});
|
||||
|
||||
it('should not match other pages', () => {
|
||||
assert.strictEqual(homepageRegex.test('https://www.youtube.com/feed/subscriptions'), false);
|
||||
assert.strictEqual(homepageRegex.test('https://www.youtube.com/watch?v=abc'), false);
|
||||
assert.strictEqual(homepageRegex.test('https://www.youtube.com/results?search_query=test'), false);
|
||||
});
|
||||
|
||||
it('should not match youtube.com without subdomain prefix', () => {
|
||||
// The regex requires www or m subdomain
|
||||
assert.strictEqual(homepageRegex.test('https://youtube.com/'), false);
|
||||
assert.strictEqual(homepageRegex.test('https://youtube.com'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortsRegex', () => {
|
||||
it('should match shorts page', () => {
|
||||
assert.strictEqual(shortsRegex.test('https://www.youtube.com/shorts/abc123'), true);
|
||||
assert.strictEqual(shortsRegex.test('https://youtube.com/shorts/xyz'), true);
|
||||
assert.strictEqual(shortsRegex.test('https://m.youtube.com/shorts/video'), true);
|
||||
});
|
||||
|
||||
it('should not match other pages', () => {
|
||||
assert.strictEqual(shortsRegex.test('https://www.youtube.com/'), false);
|
||||
assert.strictEqual(shortsRegex.test('https://www.youtube.com/watch?v=abc'), false);
|
||||
});
|
||||
|
||||
it('should match shorts in URL path', () => {
|
||||
// Even if shorts is part of query string (edge case)
|
||||
assert.strictEqual(shortsRegex.test('https://www.youtube.com/shorts'), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('videoRegex', () => {
|
||||
it('should match video watch page', () => {
|
||||
assert.strictEqual(videoRegex.test('https://www.youtube.com/watch?v=dQw4w9WgXcQ'), true);
|
||||
assert.strictEqual(videoRegex.test('https://youtube.com/watch?v=abc123'), true);
|
||||
assert.strictEqual(videoRegex.test('https://m.youtube.com/watch?v=xyz'), true);
|
||||
});
|
||||
|
||||
it('should match video with additional parameters', () => {
|
||||
assert.strictEqual(videoRegex.test('https://www.youtube.com/watch?v=abc&t=120'), true);
|
||||
assert.strictEqual(videoRegex.test('https://www.youtube.com/watch?v=abc&list=PLxyz'), true);
|
||||
});
|
||||
|
||||
it('should not match other pages', () => {
|
||||
assert.strictEqual(videoRegex.test('https://www.youtube.com/'), false);
|
||||
assert.strictEqual(videoRegex.test('https://www.youtube.com/shorts/abc'), false);
|
||||
assert.strictEqual(videoRegex.test('https://www.youtube.com/results?search_query=test'), false);
|
||||
});
|
||||
|
||||
it('should not match watch without v parameter', () => {
|
||||
assert.strictEqual(videoRegex.test('https://www.youtube.com/watch'), false);
|
||||
assert.strictEqual(videoRegex.test('https://www.youtube.com/watch?list=abc'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('channelRegex', () => {
|
||||
it('should match @ handle channels', () => {
|
||||
assert.strictEqual(channelRegex.test('https://www.youtube.com/@username'), true);
|
||||
assert.strictEqual(channelRegex.test('https://youtube.com/@channel123'), true);
|
||||
assert.strictEqual(channelRegex.test('https://m.youtube.com/@creator'), true);
|
||||
});
|
||||
|
||||
it('should match /channel/ URLs', () => {
|
||||
assert.strictEqual(channelRegex.test('https://www.youtube.com/channel/UCxyz123'), true);
|
||||
assert.strictEqual(channelRegex.test('https://youtube.com/channel/UC123abc'), true);
|
||||
});
|
||||
|
||||
it('should not match other pages', () => {
|
||||
assert.strictEqual(channelRegex.test('https://www.youtube.com/'), false);
|
||||
assert.strictEqual(channelRegex.test('https://www.youtube.com/watch?v=abc'), false);
|
||||
assert.strictEqual(channelRegex.test('https://www.youtube.com/results?search_query=@user'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subsRegex', () => {
|
||||
it('should match subscriptions feed', () => {
|
||||
assert.strictEqual(subsRegex.test('https://www.youtube.com/feed/subscriptions'), true);
|
||||
assert.strictEqual(subsRegex.test('https://youtube.com/feed/subscriptions'), true);
|
||||
assert.strictEqual(subsRegex.test('/feed/subscriptions'), true);
|
||||
});
|
||||
|
||||
it('should not match with trailing path', () => {
|
||||
assert.strictEqual(subsRegex.test('https://www.youtube.com/feed/subscriptions/more'), false);
|
||||
});
|
||||
|
||||
it('should not match other feeds', () => {
|
||||
assert.strictEqual(subsRegex.test('https://www.youtube.com/feed/library'), false);
|
||||
assert.strictEqual(subsRegex.test('https://www.youtube.com/feed/history'), false);
|
||||
});
|
||||
});
|
||||
|
||||
// Edge cases and potential bugs
|
||||
describe('Edge Cases', () => {
|
||||
it('should be case insensitive', () => {
|
||||
assert.strictEqual(resultsPageRegex.test('HTTPS://WWW.YOUTUBE.COM/RESULTS'), true);
|
||||
assert.strictEqual(homepageRegex.test('HTTPS://WWW.YOUTUBE.COM/'), true);
|
||||
assert.strictEqual(videoRegex.test('HTTPS://WWW.YOUTUBE.COM/WATCH?V=ABC'), true);
|
||||
});
|
||||
|
||||
it('should not match URLs with ports (regex limitation)', () => {
|
||||
// The current regex doesn't handle ports - they would fail to match
|
||||
// This is fine since YouTube doesn't use non-standard ports
|
||||
assert.strictEqual(videoRegex.test('https://www.youtube.com:443/watch?v=abc'), false);
|
||||
});
|
||||
|
||||
it('should not match youtu.be short URLs (not youtube.com)', () => {
|
||||
assert.strictEqual(videoRegex.test('https://youtu.be/abc123'), false);
|
||||
assert.strictEqual(homepageRegex.test('https://youtu.be/'), false);
|
||||
});
|
||||
|
||||
it('should not match fakeyoutube.com', () => {
|
||||
assert.strictEqual(homepageRegex.test('https://www.fakeyoutube.com/'), false);
|
||||
});
|
||||
|
||||
it('should not match youtube.com.evil.com', () => {
|
||||
// The regex checks for youtube.com/ pattern, so evil.com after doesn't match
|
||||
assert.strictEqual(videoRegex.test('https://www.youtube.com.evil.com/watch?v=abc'), false);
|
||||
});
|
||||
|
||||
it('homepage should handle www.youtube.com edge cases', () => {
|
||||
// No subdomain - should NOT match
|
||||
assert.strictEqual(homepageRegex.test('https://youtube.com/'), false);
|
||||
|
||||
// music subdomain - should NOT match (not www or m)
|
||||
assert.strictEqual(homepageRegex.test('https://music.youtube.com/'), false);
|
||||
|
||||
// tv subdomain - should NOT match
|
||||
assert.strictEqual(homepageRegex.test('https://tv.youtube.com/'), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "rys-tests",
|
||||
"version": "1.0.0",
|
||||
"description": "Unit tests for Remove YouTube Suggestions extension",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "node --test **/*.test.js",
|
||||
"test:watch": "node --test --watch **/*.test.js"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Test setup for browser extension code
|
||||
*
|
||||
* This module provides:
|
||||
* 1. Browser API mocks (browser.storage, etc.)
|
||||
* 2. Helpers to load source files and extract their globals
|
||||
*/
|
||||
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Mock storage data
|
||||
let storageData = {};
|
||||
|
||||
// Mock browser APIs
|
||||
const browserMock = {
|
||||
storage: {
|
||||
local: {
|
||||
get: async (keys, callback) => {
|
||||
let effectiveKeys = keys;
|
||||
let effectiveCallback = callback;
|
||||
|
||||
if (typeof effectiveKeys === 'function') {
|
||||
effectiveCallback = effectiveKeys;
|
||||
effectiveKeys = null;
|
||||
}
|
||||
|
||||
let result;
|
||||
if (typeof effectiveKeys === 'string') {
|
||||
result = { [effectiveKeys]: storageData[effectiveKeys] };
|
||||
} else if (Array.isArray(effectiveKeys)) {
|
||||
result = {};
|
||||
effectiveKeys.forEach(key => {
|
||||
if (key in storageData) result[key] = storageData[key];
|
||||
});
|
||||
} else if (effectiveKeys && typeof effectiveKeys === 'object') {
|
||||
result = {};
|
||||
Object.keys(effectiveKeys).forEach(key => {
|
||||
result[key] = (key in storageData) ? storageData[key] : effectiveKeys[key];
|
||||
});
|
||||
} else {
|
||||
result = { ...storageData };
|
||||
}
|
||||
|
||||
if (typeof effectiveCallback === 'function') {
|
||||
effectiveCallback(result);
|
||||
return;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
set: async (items) => {
|
||||
Object.assign(storageData, items);
|
||||
},
|
||||
remove: async (keys) => {
|
||||
const keysArray = Array.isArray(keys) ? keys : [keys];
|
||||
keysArray.forEach(key => delete storageData[key]);
|
||||
},
|
||||
},
|
||||
onChanged: {
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
getURL: (p) => `chrome-extension://mock-id/${p}`,
|
||||
},
|
||||
};
|
||||
|
||||
function resetStorage() {
|
||||
storageData = {};
|
||||
}
|
||||
|
||||
function setStorageData(data) {
|
||||
storageData = { ...data };
|
||||
}
|
||||
|
||||
function getStorageData() {
|
||||
return { ...storageData };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create base context with browser globals and common APIs
|
||||
*/
|
||||
function createBaseContext(extraGlobals = {}) {
|
||||
return {
|
||||
browser: browserMock,
|
||||
chrome: browserMock,
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
Date,
|
||||
Math,
|
||||
JSON,
|
||||
Array,
|
||||
Object,
|
||||
String,
|
||||
Number,
|
||||
Boolean,
|
||||
Error,
|
||||
Promise,
|
||||
RegExp,
|
||||
Set,
|
||||
Map,
|
||||
fetch: async () => { throw new Error('fetch not mocked'); },
|
||||
atob: (str) => Buffer.from(str, 'base64').toString('binary'),
|
||||
btoa: (str) => Buffer.from(str, 'binary').toString('base64'),
|
||||
...extraGlobals,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform source code to capture all top-level const/let/function declarations.
|
||||
*
|
||||
* This uses a simple regex-based approach to find declarations and export them.
|
||||
* It's not a full parser but works well for the extension's coding style.
|
||||
*/
|
||||
function wrapCodeForExport(code) {
|
||||
// Find all top-level declarations:
|
||||
// - const NAME = ...
|
||||
// - let NAME = ...
|
||||
// - function NAME(...) { ... }
|
||||
// - var NAME = ...
|
||||
|
||||
const declarations = new Set();
|
||||
|
||||
// Match: const/let/var NAME (at start of line or after semicolon/brace)
|
||||
const varRegex = /(?:^|[;\n{])\s*(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)/gm;
|
||||
let match;
|
||||
while ((match = varRegex.exec(code)) !== null) {
|
||||
declarations.add(match[1]);
|
||||
}
|
||||
|
||||
// Match: function NAME(
|
||||
const funcRegex = /(?:^|[;\n{])\s*function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/gm;
|
||||
while ((match = funcRegex.exec(code)) !== null) {
|
||||
declarations.add(match[1]);
|
||||
}
|
||||
|
||||
// Build export code
|
||||
const exportStatements = Array.from(declarations)
|
||||
.map(name => `if (typeof ${name} !== 'undefined') __exports__["${name}"] = ${name};`)
|
||||
.join('\n ');
|
||||
|
||||
return `
|
||||
(function() {
|
||||
${code}
|
||||
|
||||
${exportStatements}
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a source file and return its exports/globals
|
||||
*/
|
||||
function loadSourceFile(relativePath, extraGlobals = {}) {
|
||||
const srcPath = path.join(__dirname, '..', 'src', relativePath);
|
||||
const code = fs.readFileSync(srcPath, 'utf-8');
|
||||
|
||||
const context = createBaseContext(extraGlobals);
|
||||
context.__exports__ = {};
|
||||
|
||||
vm.createContext(context);
|
||||
vm.runInContext(wrapCodeForExport(code), context, { filename: srcPath });
|
||||
|
||||
Object.assign(context, context.__exports__);
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load multiple source files in order (for dependencies)
|
||||
*/
|
||||
function loadSourceFiles(relativePaths, extraGlobals = {}) {
|
||||
const context = createBaseContext(extraGlobals);
|
||||
context.__exports__ = {};
|
||||
|
||||
vm.createContext(context);
|
||||
|
||||
for (const relativePath of relativePaths) {
|
||||
const srcPath = path.join(__dirname, '..', 'src', relativePath);
|
||||
const code = fs.readFileSync(srcPath, 'utf-8');
|
||||
vm.runInContext(wrapCodeForExport(code), context, { filename: srcPath });
|
||||
}
|
||||
|
||||
Object.assign(context, context.__exports__);
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep equality check that works across VM realms
|
||||
*/
|
||||
function deepEqual(a, b) {
|
||||
if (a === b) return true;
|
||||
if (typeof a !== typeof b) return false;
|
||||
if (a === null || b === null) return a === b;
|
||||
if (typeof a !== 'object') return a === b;
|
||||
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
|
||||
for (const key of keysA) {
|
||||
if (!keysB.includes(key)) return false;
|
||||
if (!deepEqual(a[key], b[key])) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert deep equality across VM realms
|
||||
*/
|
||||
function assertDeepEqual(actual, expected, message) {
|
||||
if (!deepEqual(actual, expected)) {
|
||||
const err = new Error(message || `Expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`);
|
||||
err.actual = actual;
|
||||
err.expected = expected;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
browserMock,
|
||||
resetStorage,
|
||||
setStorageData,
|
||||
getStorageData,
|
||||
loadSourceFile,
|
||||
loadSourceFiles,
|
||||
deepEqual,
|
||||
assertDeepEqual,
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
const { describe, it, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { loadSourceFiles, resetStorage, setStorageData, getStorageData } = require('../setup');
|
||||
|
||||
describe('Auth', () => {
|
||||
let context;
|
||||
|
||||
beforeEach(() => {
|
||||
resetStorage();
|
||||
// Load config first, then auth
|
||||
context = loadSourceFiles(['shared/config.js', 'shared/auth.js']);
|
||||
});
|
||||
|
||||
describe('isSignedIn', () => {
|
||||
it('should return false when no session token exists', async () => {
|
||||
const result = await context.Auth.isSignedIn();
|
||||
assert.strictEqual(result, false);
|
||||
});
|
||||
|
||||
it('should return true when session token exists', async () => {
|
||||
setStorageData({ session_token: 'valid-token' });
|
||||
const result = await context.Auth.isSignedIn();
|
||||
assert.strictEqual(result, true);
|
||||
});
|
||||
|
||||
it('should return true even if email is missing but token exists', async () => {
|
||||
setStorageData({ session_token: 'valid-token' });
|
||||
const result = await context.Auth.isSignedIn();
|
||||
assert.strictEqual(result, true);
|
||||
});
|
||||
|
||||
it('should return false for empty string token', async () => {
|
||||
setStorageData({ session_token: '' });
|
||||
const result = await context.Auth.isSignedIn();
|
||||
assert.strictEqual(result, false);
|
||||
});
|
||||
|
||||
it('should return false for null token', async () => {
|
||||
setStorageData({ session_token: null });
|
||||
const result = await context.Auth.isSignedIn();
|
||||
assert.strictEqual(result, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserEmail', () => {
|
||||
it('should return null when no email exists', async () => {
|
||||
const result = await context.Auth.getUserEmail();
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
it('should return email when it exists', async () => {
|
||||
setStorageData({ user_email: 'test@example.com' });
|
||||
const result = await context.Auth.getUserEmail();
|
||||
assert.strictEqual(result, 'test@example.com');
|
||||
});
|
||||
|
||||
it('should return null for empty string email', async () => {
|
||||
setStorageData({ user_email: '' });
|
||||
const result = await context.Auth.getUserEmail();
|
||||
// Note: empty string is falsy, so || null returns null
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionToken', () => {
|
||||
it('should return null when no token exists', async () => {
|
||||
const result = await context.Auth.getSessionToken();
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
it('should return token when it exists', async () => {
|
||||
setStorageData({ session_token: 'my-session-token' });
|
||||
const result = await context.Auth.getSessionToken();
|
||||
assert.strictEqual(result, 'my-session-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('signOut', () => {
|
||||
it('should clear all auth-related storage', async () => {
|
||||
setStorageData({
|
||||
session_token: 'token',
|
||||
license_token: 'license',
|
||||
user_email: 'test@example.com',
|
||||
other_setting: 'should-remain', // Non-auth setting
|
||||
});
|
||||
|
||||
await context.Auth.signOut();
|
||||
|
||||
const data = getStorageData();
|
||||
assert.strictEqual(data.session_token, undefined);
|
||||
assert.strictEqual(data.license_token, undefined);
|
||||
assert.strictEqual(data.user_email, undefined);
|
||||
// Other settings should remain
|
||||
assert.strictEqual(data.other_setting, 'should-remain');
|
||||
});
|
||||
|
||||
it('should not throw when storage is already empty', async () => {
|
||||
// Should complete without error
|
||||
await context.Auth.signOut();
|
||||
const data = getStorageData();
|
||||
assert.strictEqual(Object.keys(data).length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMagicLink', () => {
|
||||
it('should call fetch with correct URL and body', async () => {
|
||||
let capturedRequest;
|
||||
const contextWithFetch = loadSourceFiles(['shared/config.js', 'shared/auth.js'], {
|
||||
fetch: async (url, options) => {
|
||||
capturedRequest = { url, options };
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ request_id: 'test-request-id' }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await contextWithFetch.Auth.sendMagicLink('test@example.com');
|
||||
|
||||
assert.strictEqual(result, 'test-request-id');
|
||||
assert.ok(capturedRequest.url.includes('/auth/send-magic-link'));
|
||||
assert.strictEqual(capturedRequest.options.method, 'POST');
|
||||
assert.strictEqual(capturedRequest.options.headers['Content-Type'], 'application/json');
|
||||
|
||||
const body = JSON.parse(capturedRequest.options.body);
|
||||
assert.strictEqual(body.email, 'test@example.com');
|
||||
});
|
||||
|
||||
it('should throw on rate limit (429)', async () => {
|
||||
const contextWithFetch = loadSourceFiles(['shared/config.js', 'shared/auth.js'], {
|
||||
fetch: async () => ({
|
||||
ok: false,
|
||||
status: 429,
|
||||
json: async () => ({ error: 'Rate limited' }),
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
contextWithFetch.Auth.sendMagicLink('test@example.com'),
|
||||
/Rate limited|Too many requests/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on other errors', async () => {
|
||||
const contextWithFetch = loadSourceFiles(['shared/config.js', 'shared/auth.js'], {
|
||||
fetch: async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: 'Server error' }),
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
contextWithFetch.Auth.sendMagicLink('test@example.com'),
|
||||
/Server error|Failed to send/
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle json parse failure gracefully', async () => {
|
||||
const contextWithFetch = loadSourceFiles(['shared/config.js', 'shared/auth.js'], {
|
||||
fetch: async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => { throw new Error('Invalid JSON'); },
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
contextWithFetch.Auth.sendMagicLink('test@example.com'),
|
||||
/Failed to send magic link/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pollForVerification', () => {
|
||||
it('should return success when verification succeeds', async () => {
|
||||
resetStorage();
|
||||
const contextWithFetch = loadSourceFiles(['shared/config.js', 'shared/auth.js'], {
|
||||
fetch: async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
status: 'verified',
|
||||
session_token: 'new-token',
|
||||
email: 'verified@example.com',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await contextWithFetch.Auth.pollForVerification('request-id');
|
||||
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.strictEqual(result.email, 'verified@example.com');
|
||||
|
||||
// Should have stored the token
|
||||
const data = getStorageData();
|
||||
assert.strictEqual(data.session_token, 'new-token');
|
||||
assert.strictEqual(data.user_email, 'verified@example.com');
|
||||
});
|
||||
|
||||
it('should return canceled when signal is aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const result = await context.Auth.pollForVerification('request-id', null, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.canceled, true);
|
||||
});
|
||||
|
||||
it('should throw on 404 (request expired)', async () => {
|
||||
const contextWithFetch = loadSourceFiles(['shared/config.js', 'shared/auth.js'], {
|
||||
fetch: async () => ({
|
||||
ok: false,
|
||||
status: 404,
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
contextWithFetch.Auth.pollForVerification('expired-request'),
|
||||
/expired|Please try again/
|
||||
);
|
||||
});
|
||||
|
||||
it('should call status update callback while pending', async () => {
|
||||
let pollCount = 0;
|
||||
let statusUpdates = [];
|
||||
|
||||
const contextWithFetch = loadSourceFiles(['shared/config.js', 'shared/auth.js'], {
|
||||
fetch: async () => {
|
||||
pollCount++;
|
||||
if (pollCount < 3) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ status: 'pending' }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
status: 'verified',
|
||||
session_token: 'token',
|
||||
email: 'test@example.com',
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Override POLL_INTERVAL_MS for faster test
|
||||
contextWithFetch.PREMIUM_CONFIG.POLL_INTERVAL_MS = 10;
|
||||
|
||||
await contextWithFetch.Auth.pollForVerification('request-id', (status) => {
|
||||
statusUpdates.push(status);
|
||||
});
|
||||
|
||||
assert.ok(statusUpdates.length >= 1, 'Should have called status update');
|
||||
assert.strictEqual(statusUpdates[0].status, 'pending');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { loadSourceFile } = require('../setup');
|
||||
|
||||
// Load banners.js
|
||||
const bannersContext = loadSourceFile('shared/banners.js');
|
||||
|
||||
describe('Banners', () => {
|
||||
describe('BANNERS constant', () => {
|
||||
it('should be an array', () => {
|
||||
assert.ok(Array.isArray(bannersContext.BANNERS));
|
||||
});
|
||||
|
||||
it('should have required properties on each banner', () => {
|
||||
for (const banner of bannersContext.BANNERS) {
|
||||
assert.ok(banner.id, 'Banner should have id');
|
||||
assert.ok(banner.storageKey, 'Banner should have storageKey');
|
||||
assert.ok(banner.message, 'Banner should have message');
|
||||
assert.ok(banner.linkText, 'Banner should have linkText');
|
||||
assert.ok(banner.linkUrl, 'Banner should have linkUrl');
|
||||
assert.ok(banner.dismissText, 'Banner should have dismissText');
|
||||
assert.ok(Array.isArray(banner.showOn), 'Banner showOn should be array');
|
||||
}
|
||||
});
|
||||
|
||||
it('should have valid showOn locations', () => {
|
||||
const validLocations = ['options', 'youtube_homepage'];
|
||||
for (const banner of bannersContext.BANNERS) {
|
||||
for (const location of banner.showOn) {
|
||||
assert.ok(
|
||||
validLocations.includes(location),
|
||||
`Invalid location "${location}" in banner ${banner.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveBanners', () => {
|
||||
it('should return banners for options page', () => {
|
||||
const result = bannersContext.getActiveBanners('options');
|
||||
assert.ok(Array.isArray(result));
|
||||
// All returned banners should include 'options' in showOn
|
||||
for (const banner of result) {
|
||||
assert.ok(banner.showOn.includes('options'));
|
||||
}
|
||||
});
|
||||
|
||||
it('should return banners for youtube_homepage', () => {
|
||||
const result = bannersContext.getActiveBanners('youtube_homepage');
|
||||
assert.ok(Array.isArray(result));
|
||||
for (const banner of result) {
|
||||
assert.ok(banner.showOn.includes('youtube_homepage'));
|
||||
}
|
||||
});
|
||||
|
||||
it('should return empty array for unknown location', () => {
|
||||
const result = bannersContext.getActiveBanners('unknown_location');
|
||||
assert.ok(Array.isArray(result));
|
||||
assert.strictEqual(result.length, 0);
|
||||
});
|
||||
|
||||
it('should return empty array for null/undefined location', () => {
|
||||
const nullResult = bannersContext.getActiveBanners(null);
|
||||
const undefinedResult = bannersContext.getActiveBanners(undefined);
|
||||
assert.ok(Array.isArray(nullResult));
|
||||
assert.strictEqual(nullResult.length, 0);
|
||||
assert.ok(Array.isArray(undefinedResult));
|
||||
assert.strictEqual(undefinedResult.length, 0);
|
||||
});
|
||||
|
||||
it('should filter correctly when banner has multiple locations', () => {
|
||||
// Find any banner that shows on both options and youtube_homepage
|
||||
const multiLocationBanner = bannersContext.BANNERS.find(
|
||||
b => b.showOn.includes('options') && b.showOn.includes('youtube_homepage')
|
||||
);
|
||||
|
||||
if (multiLocationBanner) {
|
||||
const optionsResult = bannersContext.getActiveBanners('options');
|
||||
const homepageResult = bannersContext.getActiveBanners('youtube_homepage');
|
||||
|
||||
const inOptions = optionsResult.find(b => b.id === multiLocationBanner.id);
|
||||
const inHomepage = homepageResult.find(b => b.id === multiLocationBanner.id);
|
||||
|
||||
assert.ok(inOptions, `${multiLocationBanner.id} should appear in options`);
|
||||
assert.ok(inHomepage, `${multiLocationBanner.id} should appear in youtube_homepage`);
|
||||
} else {
|
||||
// No multi-location banners currently configured - test passes trivially
|
||||
assert.ok(true, 'No multi-location banners to test');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Banner IDs', () => {
|
||||
it('should have unique IDs', () => {
|
||||
const ids = bannersContext.BANNERS.map(b => b.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
assert.strictEqual(uniqueIds.size, ids.length, 'Banner IDs should be unique');
|
||||
});
|
||||
|
||||
it('should have unique storage keys', () => {
|
||||
const keys = bannersContext.BANNERS.map(b => b.storageKey);
|
||||
const uniqueKeys = new Set(keys);
|
||||
assert.strictEqual(uniqueKeys.size, keys.length, 'Storage keys should be unique');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Banner URLs', () => {
|
||||
it('should have valid HTTPS URLs', () => {
|
||||
for (const banner of bannersContext.BANNERS) {
|
||||
assert.ok(
|
||||
banner.linkUrl.startsWith('https://'),
|
||||
`Banner ${banner.id} should have HTTPS URL`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
const { describe, it, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { loadSourceFiles, resetStorage, setStorageData } = require('../setup');
|
||||
|
||||
// Helper to create a mock JWT token
|
||||
function createMockJWT(payload, expiresInSeconds = 3600) {
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const fullPayload = {
|
||||
...payload,
|
||||
iat: now,
|
||||
exp: now + expiresInSeconds,
|
||||
};
|
||||
|
||||
const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||
return `${encode(header)}.${encode(fullPayload)}.fake-signature`;
|
||||
}
|
||||
|
||||
describe('License Billing Methods', () => {
|
||||
let context;
|
||||
let fetchCalls;
|
||||
let fetchResponse;
|
||||
|
||||
beforeEach(() => {
|
||||
resetStorage();
|
||||
fetchCalls = [];
|
||||
fetchResponse = { ok: true, status: 200, json: async () => ({}) };
|
||||
|
||||
context = loadSourceFiles(['shared/config.js', 'shared/auth.js', 'shared/license.js'], {
|
||||
fetch: async (url, options) => {
|
||||
fetchCalls.push({ url, options });
|
||||
return fetchResponse;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCheckoutSession', () => {
|
||||
it('should throw when not signed in', async () => {
|
||||
await assert.rejects(
|
||||
context.License.createCheckoutSession('monthly'),
|
||||
/Not signed in/
|
||||
);
|
||||
});
|
||||
|
||||
it('should call checkout endpoint with correct parameters', async () => {
|
||||
setStorageData({ session_token: 'valid-session' });
|
||||
fetchResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ checkout_url: 'https://checkout.stripe.com/test' }),
|
||||
};
|
||||
|
||||
const result = await context.License.createCheckoutSession('monthly');
|
||||
|
||||
assert.strictEqual(result, 'https://checkout.stripe.com/test');
|
||||
assert.strictEqual(fetchCalls.length, 1);
|
||||
assert.ok(fetchCalls[0].url.includes('/checkout/create'));
|
||||
assert.strictEqual(fetchCalls[0].options.method, 'POST');
|
||||
assert.strictEqual(fetchCalls[0].options.headers['Content-Type'], 'application/json');
|
||||
assert.ok(fetchCalls[0].options.headers['Authorization'].includes('Bearer valid-session'));
|
||||
|
||||
const body = JSON.parse(fetchCalls[0].options.body);
|
||||
assert.strictEqual(body.plan, 'monthly');
|
||||
});
|
||||
|
||||
it('should throw and sign out on 401', async () => {
|
||||
setStorageData({ session_token: 'expired-session' });
|
||||
fetchResponse = { ok: false, status: 401 };
|
||||
|
||||
await assert.rejects(
|
||||
context.License.createCheckoutSession('yearly'),
|
||||
/Session expired/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on server error with message', async () => {
|
||||
setStorageData({ session_token: 'valid-session' });
|
||||
fetchResponse = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: 'Payment provider unavailable' }),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
context.License.createCheckoutSession('monthly'),
|
||||
/Payment provider unavailable/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw generic error when response has no error message', async () => {
|
||||
setStorageData({ session_token: 'valid-session' });
|
||||
fetchResponse = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => { throw new Error('Invalid JSON'); },
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
context.License.createCheckoutSession('monthly'),
|
||||
/Failed to create checkout session/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBillingPortalSession', () => {
|
||||
it('should throw when not signed in', async () => {
|
||||
await assert.rejects(
|
||||
context.License.createBillingPortalSession(),
|
||||
/Not signed in/
|
||||
);
|
||||
});
|
||||
|
||||
it('should call billing portal endpoint correctly', async () => {
|
||||
setStorageData({ session_token: 'valid-session' });
|
||||
fetchResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ url: 'https://billing.stripe.com/portal' }),
|
||||
};
|
||||
|
||||
const result = await context.License.createBillingPortalSession();
|
||||
|
||||
assert.strictEqual(result, 'https://billing.stripe.com/portal');
|
||||
assert.strictEqual(fetchCalls.length, 1);
|
||||
assert.ok(fetchCalls[0].url.includes('/billing/portal'));
|
||||
assert.strictEqual(fetchCalls[0].options.method, 'POST');
|
||||
assert.ok(fetchCalls[0].options.headers['Authorization'].includes('Bearer valid-session'));
|
||||
});
|
||||
|
||||
it('should throw and sign out on 401', async () => {
|
||||
setStorageData({ session_token: 'expired-session' });
|
||||
fetchResponse = { ok: false, status: 401 };
|
||||
|
||||
await assert.rejects(
|
||||
context.License.createBillingPortalSession(),
|
||||
/Session expired/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on server error', async () => {
|
||||
setStorageData({ session_token: 'valid-session' });
|
||||
fetchResponse = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: 'Customer not found' }),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
context.License.createBillingPortalSession(),
|
||||
/Customer not found/
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
const { describe, it, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { loadSourceFiles, resetStorage, setStorageData, getStorageData } = require('../setup');
|
||||
|
||||
// Helper to create a mock JWT token
|
||||
function createMockJWT(payload, expiresInSeconds = 3600) {
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const fullPayload = {
|
||||
...payload,
|
||||
iat: now,
|
||||
exp: now + expiresInSeconds,
|
||||
};
|
||||
|
||||
const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||
// Note: signature is fake, but we don't verify it client-side
|
||||
return `${encode(header)}.${encode(fullPayload)}.fake-signature`;
|
||||
}
|
||||
|
||||
describe('License', () => {
|
||||
let context;
|
||||
let fetchCalls;
|
||||
let fetchResponse;
|
||||
|
||||
beforeEach(() => {
|
||||
resetStorage();
|
||||
fetchCalls = [];
|
||||
fetchResponse = { ok: true, status: 200, json: async () => ({}) };
|
||||
|
||||
// Load config first, then license
|
||||
context = loadSourceFiles(['shared/config.js', 'shared/license.js'], {
|
||||
fetch: async (url, options) => {
|
||||
fetchCalls.push({ url, options });
|
||||
return fetchResponse;
|
||||
},
|
||||
Auth: {
|
||||
signOut: async () => {
|
||||
resetStorage();
|
||||
},
|
||||
},
|
||||
recordEvent: () => {}, // Mock analytics
|
||||
});
|
||||
});
|
||||
|
||||
describe('_decodeToken', () => {
|
||||
it('should decode a valid JWT payload', () => {
|
||||
const token = createMockJWT({ email: 'test@example.com', premium: true });
|
||||
const decoded = context.License._decodeToken(token);
|
||||
|
||||
assert.strictEqual(decoded.email, 'test@example.com');
|
||||
assert.strictEqual(decoded.premium, true);
|
||||
assert.ok(decoded.exp > 0);
|
||||
});
|
||||
|
||||
it('should return null for invalid token', () => {
|
||||
assert.strictEqual(context.License._decodeToken('invalid'), null);
|
||||
assert.strictEqual(context.License._decodeToken(''), null);
|
||||
assert.strictEqual(context.License._decodeToken(null), null);
|
||||
assert.strictEqual(context.License._decodeToken(undefined), null);
|
||||
});
|
||||
|
||||
it('should handle base64url encoding', () => {
|
||||
// Create token with characters that differ between base64 and base64url
|
||||
const token = createMockJWT({ email: 'test+special@example.com', data: '>>>' });
|
||||
const decoded = context.License._decodeToken(token);
|
||||
assert.strictEqual(decoded.email, 'test+special@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPremium', () => {
|
||||
it('should return false when no license token exists', async () => {
|
||||
const result = await context.License.isPremium();
|
||||
assert.strictEqual(result, false);
|
||||
});
|
||||
|
||||
it('should return true for valid premium token', async () => {
|
||||
const token = createMockJWT({ email: 'test@example.com', premium: true });
|
||||
setStorageData({ license_token: token });
|
||||
|
||||
const result = await context.License.isPremium();
|
||||
assert.strictEqual(result, true);
|
||||
});
|
||||
|
||||
it('should return false for non-premium token', async () => {
|
||||
const token = createMockJWT({ email: 'test@example.com', premium: false });
|
||||
setStorageData({ license_token: token });
|
||||
|
||||
const result = await context.License.isPremium();
|
||||
assert.strictEqual(result, false);
|
||||
});
|
||||
|
||||
it('should return false for expired token', async () => {
|
||||
const token = createMockJWT({ email: 'test@example.com', premium: true }, -3600); // expired 1hr ago
|
||||
setStorageData({ license_token: token });
|
||||
|
||||
const result = await context.License.isPremium();
|
||||
assert.strictEqual(result, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkLicense', () => {
|
||||
it('should return not premium when not signed in', async () => {
|
||||
const result = await context.License.checkLicense();
|
||||
|
||||
assert.strictEqual(result.isPremium, false);
|
||||
assert.strictEqual(result.source, null);
|
||||
assert.strictEqual(fetchCalls.length, 0); // No fetch call made
|
||||
});
|
||||
|
||||
it('should use cached token when valid and not expiring soon', async () => {
|
||||
const token = createMockJWT({
|
||||
email: 'test@example.com',
|
||||
premium: true,
|
||||
grandfathered: false,
|
||||
}, 3 * 24 * 3600); // 3 days from now
|
||||
|
||||
setStorageData({
|
||||
session_token: 'session-token',
|
||||
license_token: token,
|
||||
});
|
||||
|
||||
const result = await context.License.checkLicense();
|
||||
|
||||
assert.strictEqual(result.isPremium, true);
|
||||
assert.strictEqual(result.cached, true);
|
||||
assert.strictEqual(fetchCalls.length, 0); // No fetch call made
|
||||
});
|
||||
|
||||
it('should fetch new token when license token is expiring soon', async () => {
|
||||
const expiringToken = createMockJWT({
|
||||
email: 'test@example.com',
|
||||
premium: true,
|
||||
}, 12 * 3600); // 12 hours from now (within 24hr threshold)
|
||||
|
||||
const newToken = createMockJWT({
|
||||
email: 'test@example.com',
|
||||
premium: true,
|
||||
grandfathered: false,
|
||||
}, 3 * 24 * 3600);
|
||||
|
||||
setStorageData({
|
||||
session_token: 'session-token',
|
||||
license_token: expiringToken,
|
||||
});
|
||||
|
||||
fetchResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ license_token: newToken }),
|
||||
};
|
||||
|
||||
const result = await context.License.checkLicense();
|
||||
|
||||
assert.strictEqual(result.isPremium, true);
|
||||
assert.strictEqual(result.cached, undefined); // Not cached
|
||||
assert.strictEqual(fetchCalls.length, 1);
|
||||
assert.ok(fetchCalls[0].url.includes('/license/check'));
|
||||
});
|
||||
|
||||
it('should fetch new token when forceRefresh is true', async () => {
|
||||
const token = createMockJWT({
|
||||
email: 'test@example.com',
|
||||
premium: true,
|
||||
}, 3 * 24 * 3600);
|
||||
|
||||
setStorageData({
|
||||
session_token: 'session-token',
|
||||
license_token: token,
|
||||
});
|
||||
|
||||
fetchResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ license_token: token }),
|
||||
};
|
||||
|
||||
const result = await context.License.checkLicense(true);
|
||||
|
||||
assert.strictEqual(fetchCalls.length, 1); // Force fetch even with valid token
|
||||
});
|
||||
|
||||
it('should sign out on 401 response', async () => {
|
||||
setStorageData({
|
||||
session_token: 'expired-session',
|
||||
license_token: createMockJWT({ email: 'test@example.com', premium: true }, -3600),
|
||||
});
|
||||
|
||||
fetchResponse = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
};
|
||||
|
||||
const result = await context.License.checkLicense();
|
||||
|
||||
assert.strictEqual(result.isPremium, false);
|
||||
assert.strictEqual(result.signedOut, true);
|
||||
});
|
||||
|
||||
it('should use existing valid token on network error', async () => {
|
||||
const token = createMockJWT({
|
||||
email: 'test@example.com',
|
||||
premium: true,
|
||||
grandfathered: true,
|
||||
}, 12 * 3600); // Within refresh threshold to trigger fetch
|
||||
|
||||
setStorageData({
|
||||
session_token: 'session-token',
|
||||
license_token: token,
|
||||
});
|
||||
|
||||
// Simulate network error
|
||||
context = loadSourceFiles(['shared/config.js', 'shared/license.js'], {
|
||||
fetch: async () => { throw new Error('Network error'); },
|
||||
Auth: { signOut: async () => {} },
|
||||
recordEvent: () => {},
|
||||
});
|
||||
|
||||
// Re-set storage since we reloaded context
|
||||
setStorageData({
|
||||
session_token: 'session-token',
|
||||
license_token: token,
|
||||
});
|
||||
|
||||
const result = await context.License.checkLicense();
|
||||
|
||||
assert.strictEqual(result.isPremium, true);
|
||||
assert.strictEqual(result.offline, true);
|
||||
assert.strictEqual(result.source, 'grandfathered');
|
||||
});
|
||||
|
||||
it('should return grandfathered source for grandfathered users', async () => {
|
||||
const token = createMockJWT({
|
||||
email: 'donor@example.com',
|
||||
premium: true,
|
||||
grandfathered: true,
|
||||
}, 3 * 24 * 3600);
|
||||
|
||||
setStorageData({
|
||||
session_token: 'session-token',
|
||||
license_token: token,
|
||||
});
|
||||
|
||||
const result = await context.License.checkLicense();
|
||||
|
||||
assert.strictEqual(result.isPremium, true);
|
||||
assert.strictEqual(result.source, 'grandfathered');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
const { describe, it, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { loadSourceFile, assertDeepEqual } = require('../setup');
|
||||
|
||||
// Load main.js
|
||||
const main = loadSourceFile('shared/main.js');
|
||||
|
||||
describe('Main Settings', () => {
|
||||
describe('migrateRevealSettings', () => {
|
||||
it('should return empty object when no legacy setting exists', () => {
|
||||
const settings = { remove_homepage: true };
|
||||
const updates = main.migrateRevealSettings(settings);
|
||||
assertDeepEqual(updates, {});
|
||||
});
|
||||
|
||||
it('should return empty object for null/undefined settings', () => {
|
||||
assertDeepEqual(main.migrateRevealSettings(null), {});
|
||||
assertDeepEqual(main.migrateRevealSettings(undefined), {});
|
||||
});
|
||||
|
||||
it('should migrate add_reveal_button to all three reveal settings', () => {
|
||||
const settings = { add_reveal_button: true };
|
||||
const updates = main.migrateRevealSettings(settings);
|
||||
|
||||
assert.strictEqual(updates.add_reveal_homepage, true);
|
||||
assert.strictEqual(updates.add_reveal_sidebar, true);
|
||||
assert.strictEqual(updates.add_reveal_end_of_video, true);
|
||||
});
|
||||
|
||||
it('should migrate false value correctly', () => {
|
||||
const settings = { add_reveal_button: false };
|
||||
const updates = main.migrateRevealSettings(settings);
|
||||
|
||||
assert.strictEqual(updates.add_reveal_homepage, false);
|
||||
assert.strictEqual(updates.add_reveal_sidebar, false);
|
||||
assert.strictEqual(updates.add_reveal_end_of_video, false);
|
||||
});
|
||||
|
||||
it('should not overwrite existing reveal settings', () => {
|
||||
const settings = {
|
||||
add_reveal_button: true,
|
||||
add_reveal_homepage: false, // Already set - should not be overwritten
|
||||
};
|
||||
const updates = main.migrateRevealSettings(settings);
|
||||
|
||||
// Should not include homepage since it already exists
|
||||
assert.strictEqual('add_reveal_homepage' in updates, false);
|
||||
assert.strictEqual(updates.add_reveal_sidebar, true);
|
||||
assert.strictEqual(updates.add_reveal_end_of_video, true);
|
||||
});
|
||||
|
||||
it('should also update the settings object in place', () => {
|
||||
const settings = { add_reveal_button: true };
|
||||
main.migrateRevealSettings(settings);
|
||||
|
||||
// The function mutates settings object too
|
||||
assert.strictEqual(settings.add_reveal_homepage, true);
|
||||
assert.strictEqual(settings.add_reveal_sidebar, true);
|
||||
assert.strictEqual(settings.add_reveal_end_of_video, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sectionNameToUrl', () => {
|
||||
it('should convert simple section name to URL', () => {
|
||||
const url = main.sectionNameToUrl('General');
|
||||
assert.strictEqual(url, 'https://lawrencehook.com/rys/features/general/');
|
||||
});
|
||||
|
||||
it('should handle spaces by converting to underscores', () => {
|
||||
const url = main.sectionNameToUrl('Video Player');
|
||||
assert.strictEqual(url, 'https://lawrencehook.com/rys/features/video_player/');
|
||||
});
|
||||
|
||||
it('should handle " - " by converting to underscore', () => {
|
||||
const url = main.sectionNameToUrl('Video Player - UX');
|
||||
assert.strictEqual(url, 'https://lawrencehook.com/rys/features/video_player_ux/');
|
||||
});
|
||||
|
||||
it('should handle complex names', () => {
|
||||
const url = main.sectionNameToUrl('Left Navigation Bar Sections');
|
||||
assert.strictEqual(url, 'https://lawrencehook.com/rys/features/left_navigation_bar_sections/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('idToShortId and shortIdToId', () => {
|
||||
it('should have bidirectional mapping for all entries', () => {
|
||||
const ids = Object.keys(main.idToShortId);
|
||||
const shortIds = Object.keys(main.shortIdToId);
|
||||
|
||||
// Every id should map to a shortId and back
|
||||
for (const id of ids) {
|
||||
const shortId = main.idToShortId[id];
|
||||
const backToId = main.shortIdToId[shortId];
|
||||
assert.strictEqual(backToId, id, `Mapping broken for ${id} -> ${shortId} -> ${backToId}`);
|
||||
}
|
||||
|
||||
// Every shortId should map to an id and back
|
||||
for (const shortId of shortIds) {
|
||||
const id = main.shortIdToId[shortId];
|
||||
const backToShortId = main.idToShortId[id];
|
||||
assert.strictEqual(backToShortId, shortId, `Reverse mapping broken for ${shortId} -> ${id} -> ${backToShortId}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have same number of entries in both directions', () => {
|
||||
const idCount = Object.keys(main.idToShortId).length;
|
||||
const shortIdCount = Object.keys(main.shortIdToId).length;
|
||||
assert.strictEqual(idCount, shortIdCount, `Mismatch: ${idCount} ids vs ${shortIdCount} shortIds`);
|
||||
});
|
||||
|
||||
it('should have unique shortIds (no collisions)', () => {
|
||||
const shortIds = Object.values(main.idToShortId);
|
||||
const uniqueShortIds = new Set(shortIds);
|
||||
assert.strictEqual(shortIds.length, uniqueShortIds.size, 'Duplicate shortIds found');
|
||||
});
|
||||
|
||||
it('should include all settings from SECTIONS', () => {
|
||||
const allOptionIds = main.SECTIONS.flatMap(section =>
|
||||
section.options.map(opt => opt.id)
|
||||
);
|
||||
|
||||
const missingIds = allOptionIds.filter(id => !(id in main.idToShortId));
|
||||
assert.strictEqual(missingIds.length, 0, `Missing from idToShortId: ${missingIds.join(', ')}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_SETTINGS', () => {
|
||||
it('should include all options from SECTIONS', () => {
|
||||
const allOptionIds = main.SECTIONS.flatMap(section =>
|
||||
section.options.map(opt => opt.id)
|
||||
);
|
||||
|
||||
for (const id of allOptionIds) {
|
||||
assert.ok(id in main.DEFAULT_SETTINGS, `Missing default for: ${id}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have correct default values from SECTIONS', () => {
|
||||
for (const section of main.SECTIONS) {
|
||||
for (const option of section.options) {
|
||||
assert.strictEqual(
|
||||
main.DEFAULT_SETTINGS[option.id],
|
||||
option.defaultValue,
|
||||
`Wrong default for ${option.id}: expected ${option.defaultValue}, got ${main.DEFAULT_SETTINGS[option.id]}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should include OTHER_SETTINGS', () => {
|
||||
assert.ok('global_enable' in main.DEFAULT_SETTINGS);
|
||||
assert.ok('dark_mode' in main.DEFAULT_SETTINGS);
|
||||
assert.ok('schedule' in main.DEFAULT_SETTINGS);
|
||||
assert.ok('password' in main.DEFAULT_SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SECTIONS structure validation', () => {
|
||||
it('should have unique option ids across all sections', () => {
|
||||
const allIds = main.SECTIONS.flatMap(section =>
|
||||
section.options.map(opt => opt.id)
|
||||
);
|
||||
const uniqueIds = new Set(allIds);
|
||||
|
||||
if (allIds.length !== uniqueIds.size) {
|
||||
// Find duplicates
|
||||
const seen = new Set();
|
||||
const duplicates = [];
|
||||
for (const id of allIds) {
|
||||
if (seen.has(id)) duplicates.push(id);
|
||||
seen.add(id);
|
||||
}
|
||||
assert.fail(`Duplicate option ids found: ${duplicates.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have valid effect references (effects point to existing options)', () => {
|
||||
const allIds = new Set(main.SECTIONS.flatMap(section =>
|
||||
section.options.map(opt => opt.id)
|
||||
));
|
||||
|
||||
for (const section of main.SECTIONS) {
|
||||
for (const option of section.options) {
|
||||
if (option.effects) {
|
||||
for (const [triggerValue, effectMap] of Object.entries(option.effects)) {
|
||||
for (const targetId of Object.keys(effectMap)) {
|
||||
assert.ok(
|
||||
allIds.has(targetId),
|
||||
`Option ${option.id} has effect targeting non-existent option: ${targetId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have boolean defaultValues for all options', () => {
|
||||
for (const section of main.SECTIONS) {
|
||||
for (const option of section.options) {
|
||||
assert.strictEqual(
|
||||
typeof option.defaultValue,
|
||||
'boolean',
|
||||
`Option ${option.id} has non-boolean defaultValue: ${option.defaultValue}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('premium options should be marked consistently', () => {
|
||||
for (const section of main.SECTIONS) {
|
||||
for (const option of section.options) {
|
||||
// premium should be either true or undefined (not false)
|
||||
if ('premium' in option) {
|
||||
assert.strictEqual(
|
||||
option.premium,
|
||||
true,
|
||||
`Option ${option.id} has premium: ${option.premium} (should be true or omitted)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { loadSourceFile } = require('../setup');
|
||||
|
||||
// Load md5.js
|
||||
const { md5 } = loadSourceFile('shared/md5.js');
|
||||
|
||||
describe('MD5', () => {
|
||||
describe('known test vectors', () => {
|
||||
// Standard MD5 test vectors from RFC 1321
|
||||
it('should hash empty string correctly', () => {
|
||||
assert.strictEqual(md5(''), 'd41d8cd98f00b204e9800998ecf8427e');
|
||||
});
|
||||
|
||||
it('should hash "a" correctly', () => {
|
||||
assert.strictEqual(md5('a'), '0cc175b9c0f1b6a831c399e269772661');
|
||||
});
|
||||
|
||||
it('should hash "abc" correctly', () => {
|
||||
assert.strictEqual(md5('abc'), '900150983cd24fb0d6963f7d28e17f72');
|
||||
});
|
||||
|
||||
it('should hash "message digest" correctly', () => {
|
||||
assert.strictEqual(md5('message digest'), 'f96b697d7cb7938d525a2f31aaf161d0');
|
||||
});
|
||||
|
||||
it('should hash "hello" correctly', () => {
|
||||
// This is used as a self-test in the source code
|
||||
assert.strictEqual(md5('hello'), '5d41402abc4b2a76b9719d911017c592');
|
||||
});
|
||||
|
||||
it('should hash lowercase alphabet correctly', () => {
|
||||
assert.strictEqual(md5('abcdefghijklmnopqrstuvwxyz'), 'c3fcd3d76192e4007dfb496cca67e13b');
|
||||
});
|
||||
|
||||
it('should hash digits correctly', () => {
|
||||
assert.strictEqual(md5('12345678901234567890123456789012345678901234567890123456789012345678901234567890'),
|
||||
'57edf4a22be3c955ac49da2e2107b67a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('password hashing scenarios', () => {
|
||||
it('should produce consistent hashes for same input', () => {
|
||||
const password = 'mySecretPassword123';
|
||||
const hash1 = md5(password);
|
||||
const hash2 = md5(password);
|
||||
assert.strictEqual(hash1, hash2);
|
||||
});
|
||||
|
||||
it('should produce different hashes for different inputs', () => {
|
||||
const hash1 = md5('password1');
|
||||
const hash2 = md5('password2');
|
||||
assert.notStrictEqual(hash1, hash2);
|
||||
});
|
||||
|
||||
it('should always return 32 character hex string', () => {
|
||||
const inputs = ['', 'a', 'abc', 'a very long string with many characters'];
|
||||
for (const input of inputs) {
|
||||
const hash = md5(input);
|
||||
assert.strictEqual(hash.length, 32, `Hash of "${input}" has length ${hash.length}`);
|
||||
assert.match(hash, /^[0-9a-f]{32}$/, `Hash contains invalid characters: ${hash}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle special characters', () => {
|
||||
const hash = md5('!@#$%^&*()_+-=[]{}|;:,.<>?');
|
||||
assert.strictEqual(hash.length, 32);
|
||||
assert.match(hash, /^[0-9a-f]{32}$/);
|
||||
});
|
||||
|
||||
it('should handle unicode (though results may vary)', () => {
|
||||
// MD5 on unicode is implementation-dependent
|
||||
// Just verify it doesn't crash and returns valid format
|
||||
const hash = md5('こんにちは');
|
||||
assert.strictEqual(hash.length, 32);
|
||||
assert.match(hash, /^[0-9a-f]{32}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle strings with null bytes', () => {
|
||||
const hash = md5('hello\x00world');
|
||||
assert.strictEqual(hash.length, 32);
|
||||
});
|
||||
|
||||
it('should handle very long strings', () => {
|
||||
const longString = 'a'.repeat(10000);
|
||||
const hash = md5(longString);
|
||||
assert.strictEqual(hash.length, 32);
|
||||
assert.match(hash, /^[0-9a-f]{32}$/);
|
||||
});
|
||||
|
||||
it('should handle strings at 64-byte boundary', () => {
|
||||
// MD5 processes in 64-byte blocks
|
||||
const exactly64 = 'a'.repeat(64);
|
||||
const hash = md5(exactly64);
|
||||
assert.strictEqual(hash.length, 32);
|
||||
});
|
||||
|
||||
it('should handle strings just over 64-byte boundary', () => {
|
||||
const just65 = 'a'.repeat(65);
|
||||
const hash = md5(just65);
|
||||
assert.strictEqual(hash.length, 32);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
const { describe, it, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { loadSourceFile, assertDeepEqual } = require('../setup');
|
||||
|
||||
// Load utils.js - it has pure functions that don't need DOM
|
||||
const utils = loadSourceFile('shared/utils.js');
|
||||
|
||||
describe('Utils', () => {
|
||||
describe('uniq', () => {
|
||||
it('should remove duplicate values from array', () => {
|
||||
assert.deepStrictEqual(utils.uniq([1, 2, 2, 3, 3, 3]), [1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
assert.deepStrictEqual(utils.uniq([]), []);
|
||||
});
|
||||
|
||||
it('should handle array with no duplicates', () => {
|
||||
assert.deepStrictEqual(utils.uniq([1, 2, 3]), [1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should work with strings', () => {
|
||||
assert.deepStrictEqual(utils.uniq(['a', 'b', 'a', 'c']), ['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should preserve order (first occurrence)', () => {
|
||||
assert.deepStrictEqual(utils.uniq([3, 1, 2, 1, 3]), [3, 1, 2]);
|
||||
});
|
||||
|
||||
it('should handle null/undefined values in array', () => {
|
||||
assert.deepStrictEqual(utils.uniq([null, 1, null, undefined, 1]), [null, 1, undefined]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeIsValid', () => {
|
||||
it('should validate correct time format', () => {
|
||||
assert.strictEqual(utils.timeIsValid('9:00a-5:00p'), true);
|
||||
assert.strictEqual(utils.timeIsValid('12:30p-1:30p'), true);
|
||||
assert.strictEqual(utils.timeIsValid('1:00a-2:00a'), true);
|
||||
});
|
||||
|
||||
it('should validate multiple time ranges', () => {
|
||||
assert.strictEqual(utils.timeIsValid('9:00a-12:00p,1:00p-5:00p'), true);
|
||||
});
|
||||
|
||||
it('should reject invalid time format', () => {
|
||||
assert.strictEqual(utils.timeIsValid('25:00a-5:00p'), false);
|
||||
assert.strictEqual(utils.timeIsValid('9:00-5:00'), false); // missing am/pm
|
||||
assert.strictEqual(utils.timeIsValid('9:00a5:00p'), false); // missing dash
|
||||
assert.strictEqual(utils.timeIsValid('13:00a-5:00p'), false); // 13 is invalid for 12hr
|
||||
});
|
||||
|
||||
it('should handle case insensitivity', () => {
|
||||
assert.strictEqual(utils.timeIsValid('9:00A-5:00P'), true);
|
||||
assert.strictEqual(utils.timeIsValid('9:00a-5:00P'), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseScheduleTime', () => {
|
||||
it('should parse morning times correctly', () => {
|
||||
const now = new Date('2024-01-15T12:00:00');
|
||||
const result = utils.parseScheduleTime('9:30a', now);
|
||||
const expected = new Date('2024-01-15T09:30:00').getTime();
|
||||
assert.strictEqual(result, expected);
|
||||
});
|
||||
|
||||
it('should parse afternoon times correctly', () => {
|
||||
const now = new Date('2024-01-15T12:00:00');
|
||||
const result = utils.parseScheduleTime('2:30p', now);
|
||||
const expected = new Date('2024-01-15T14:30:00').getTime();
|
||||
assert.strictEqual(result, expected);
|
||||
});
|
||||
|
||||
it('should handle 12:00a (midnight) correctly', () => {
|
||||
const now = new Date('2024-01-15T12:00:00');
|
||||
const result = utils.parseScheduleTime('12:00a', now);
|
||||
const expected = new Date('2024-01-15T00:00:00').getTime();
|
||||
assert.strictEqual(result, expected);
|
||||
});
|
||||
|
||||
it('should handle 12:00p (noon) correctly', () => {
|
||||
const now = new Date('2024-01-15T12:00:00');
|
||||
const result = utils.parseScheduleTime('12:00p', now);
|
||||
const expected = new Date('2024-01-15T12:00:00').getTime();
|
||||
assert.strictEqual(result, expected);
|
||||
});
|
||||
|
||||
it('should return undefined for invalid input', () => {
|
||||
assert.strictEqual(utils.parseScheduleTime(null, new Date()), undefined);
|
||||
assert.strictEqual(utils.parseScheduleTime('9:00a', null), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkSchedule', () => {
|
||||
it('should return true when within scheduled time and day', () => {
|
||||
// Monday at 10am
|
||||
const now = new Date('2024-01-15T10:00:00'); // Monday
|
||||
const result = utils.checkSchedule('9:00a-5:00p', 'mo,tu,we,th,fr', now);
|
||||
assert.strictEqual(result, true);
|
||||
});
|
||||
|
||||
it('should return false when outside scheduled time', () => {
|
||||
// Monday at 6pm (after 5pm)
|
||||
const now = new Date('2024-01-15T18:00:00'); // Monday
|
||||
const result = utils.checkSchedule('9:00a-5:00p', 'mo,tu,we,th,fr', now);
|
||||
assert.strictEqual(result, false);
|
||||
});
|
||||
|
||||
it('should return false when on wrong day', () => {
|
||||
// Saturday at 10am
|
||||
const now = new Date('2024-01-13T10:00:00'); // Saturday
|
||||
const result = utils.checkSchedule('9:00a-5:00p', 'mo,tu,we,th,fr', now);
|
||||
assert.strictEqual(result, false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined inputs', () => {
|
||||
const now = new Date('2024-01-15T10:00:00');
|
||||
assert.strictEqual(utils.checkSchedule(null, 'mo', now), false);
|
||||
assert.strictEqual(utils.checkSchedule('9:00a-5:00p', null, now), false);
|
||||
});
|
||||
|
||||
it('should handle multiple time ranges', () => {
|
||||
// Monday at 2pm (in second time range)
|
||||
const now = new Date('2024-01-15T14:00:00'); // Monday
|
||||
const result = utils.checkSchedule('9:00a-12:00p,1:00p-5:00p', 'mo', now);
|
||||
assert.strictEqual(result, true);
|
||||
});
|
||||
|
||||
it('should handle case insensitive days', () => {
|
||||
const now = new Date('2024-01-15T10:00:00'); // Monday
|
||||
assert.strictEqual(utils.checkSchedule('9:00a-5:00p', 'MO', now), true);
|
||||
assert.strictEqual(utils.checkSchedule('9:00a-5:00p', 'Mo', now), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTimeRemaining', () => {
|
||||
it('should parse milliseconds into time components', () => {
|
||||
// 1 day, 2 hours, 30 minutes, 45 seconds
|
||||
const ms = (1 * 24 * 60 * 60 * 1000) + (2 * 60 * 60 * 1000) + (30 * 60 * 1000) + (45 * 1000);
|
||||
const result = utils.parseTimeRemaining(ms);
|
||||
assertDeepEqual(result, { days: 1, hours: 2, minutes: 30, seconds: 45 });
|
||||
});
|
||||
|
||||
it('should handle zero', () => {
|
||||
const result = utils.parseTimeRemaining(0);
|
||||
assertDeepEqual(result, { days: 0, hours: 0, minutes: 0, seconds: 0 });
|
||||
});
|
||||
|
||||
it('should handle partial values', () => {
|
||||
// 90 seconds = 1 minute 30 seconds
|
||||
const result = utils.parseTimeRemaining(90 * 1000);
|
||||
assertDeepEqual(result, { days: 0, hours: 0, minutes: 1, seconds: 30 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateRandomString', () => {
|
||||
it('should generate string of specified length', () => {
|
||||
const result = utils.generateRandomString(10);
|
||||
assert.strictEqual(result.length, 10);
|
||||
});
|
||||
|
||||
it('should only contain allowed characters', () => {
|
||||
const allowed = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
|
||||
const result = utils.generateRandomString(100);
|
||||
for (const char of result) {
|
||||
assert.ok(allowed.includes(char), `Unexpected character: ${char}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should generate different strings', () => {
|
||||
const results = new Set();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
results.add(utils.generateRandomString(20));
|
||||
}
|
||||
// All 10 should be unique
|
||||
assert.strictEqual(results.size, 10);
|
||||
});
|
||||
|
||||
it('should handle length 0', () => {
|
||||
const result = utils.generateRandomString(0);
|
||||
assert.strictEqual(result, '');
|
||||
});
|
||||
|
||||
it('should handle length 1', () => {
|
||||
const result = utils.generateRandomString(1);
|
||||
assert.strictEqual(result.length, 1);
|
||||
});
|
||||
|
||||
it('should exclude ambiguous characters (0, O, l, I, 1)', () => {
|
||||
// Generate many strings and check none contain ambiguous chars
|
||||
const ambiguous = '0OlI1';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const result = utils.generateRandomString(50);
|
||||
for (const char of ambiguous) {
|
||||
assert.ok(!result.includes(char), `Found ambiguous character ${char} in ${result}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// RED TEAM: Edge cases and potential bug discovery
|
||||
describe('Edge Cases and Potential Bugs', () => {
|
||||
describe('timeIsValid edge cases', () => {
|
||||
it('should reject time with invalid minutes (60+)', () => {
|
||||
assert.strictEqual(utils.timeIsValid('9:60a-5:00p'), false);
|
||||
});
|
||||
|
||||
it('should reject time with hour 0', () => {
|
||||
// 12-hour format doesn't have hour 0
|
||||
assert.strictEqual(utils.timeIsValid('0:00a-5:00p'), false);
|
||||
});
|
||||
|
||||
it('should accept 12:59', () => {
|
||||
assert.strictEqual(utils.timeIsValid('12:59a-12:59p'), true);
|
||||
});
|
||||
|
||||
it('should handle whitespace in comma-separated times', () => {
|
||||
assert.strictEqual(utils.timeIsValid('9:00a-12:00p, 1:00p-5:00p'), true);
|
||||
assert.strictEqual(utils.timeIsValid(' 9:00a-12:00p , 1:00p-5:00p '), true);
|
||||
});
|
||||
|
||||
it('should reject empty string', () => {
|
||||
// Empty string split by comma gives [''], which should fail regex
|
||||
assert.strictEqual(utils.timeIsValid(''), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkSchedule boundary conditions', () => {
|
||||
it('should handle exact start time (exclusive)', () => {
|
||||
// At exactly 9:00a, should we be in schedule?
|
||||
// Current impl uses now > startTime, so exactly at start = false
|
||||
const now = new Date('2024-01-15T09:00:00'); // Monday at exactly 9am
|
||||
const result = utils.checkSchedule('9:00a-5:00p', 'mo', now);
|
||||
// This tests whether boundary is inclusive or exclusive
|
||||
assert.strictEqual(result, false, 'Exactly at start time should be false (exclusive)');
|
||||
});
|
||||
|
||||
it('should handle exact end time (exclusive)', () => {
|
||||
// At exactly 5:00p, should we be in schedule?
|
||||
const now = new Date('2024-01-15T17:00:00'); // Monday at exactly 5pm
|
||||
const result = utils.checkSchedule('9:00a-5:00p', 'mo', now);
|
||||
assert.strictEqual(result, false, 'Exactly at end time should be false (exclusive)');
|
||||
});
|
||||
|
||||
it('should handle one second after start', () => {
|
||||
const now = new Date('2024-01-15T09:00:01');
|
||||
const result = utils.checkSchedule('9:00a-5:00p', 'mo', now);
|
||||
assert.strictEqual(result, true);
|
||||
});
|
||||
|
||||
it('should handle one second before end', () => {
|
||||
const now = new Date('2024-01-15T16:59:59');
|
||||
const result = utils.checkSchedule('9:00a-5:00p', 'mo', now);
|
||||
assert.strictEqual(result, true);
|
||||
});
|
||||
|
||||
it('should handle all days of week', () => {
|
||||
const days = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'];
|
||||
// 2024-01-14 is Sunday, 15 is Monday, etc.
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = new Date(2024, 0, 14 + i, 12, 0, 0); // Noon on each day
|
||||
const result = utils.checkSchedule('9:00a-5:00p', days[i], date);
|
||||
assert.strictEqual(result, true, `Failed for ${days[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
it.todo('should handle midnight crossing');
|
||||
});
|
||||
|
||||
describe('parseScheduleTime edge cases', () => {
|
||||
it('should handle 11:59p correctly', () => {
|
||||
const now = new Date('2024-01-15T12:00:00');
|
||||
const result = utils.parseScheduleTime('11:59p', now);
|
||||
const expected = new Date('2024-01-15T23:59:00').getTime();
|
||||
assert.strictEqual(result, expected);
|
||||
});
|
||||
|
||||
it('should handle 12:01a correctly', () => {
|
||||
const now = new Date('2024-01-15T12:00:00');
|
||||
const result = utils.parseScheduleTime('12:01a', now);
|
||||
const expected = new Date('2024-01-15T00:01:00').getTime();
|
||||
assert.strictEqual(result, expected);
|
||||
});
|
||||
|
||||
it('should handle 12:01p correctly', () => {
|
||||
const now = new Date('2024-01-15T12:00:00');
|
||||
const result = utils.parseScheduleTime('12:01p', now);
|
||||
const expected = new Date('2024-01-15T12:01:00').getTime();
|
||||
assert.strictEqual(result, expected);
|
||||
});
|
||||
|
||||
it('should handle 1:00a correctly (1am)', () => {
|
||||
const now = new Date('2024-01-15T12:00:00');
|
||||
const result = utils.parseScheduleTime('1:00a', now);
|
||||
const expected = new Date('2024-01-15T01:00:00').getTime();
|
||||
assert.strictEqual(result, expected);
|
||||
});
|
||||
|
||||
it('should handle 1:00p correctly (1pm = 13:00)', () => {
|
||||
const now = new Date('2024-01-15T12:00:00');
|
||||
const result = utils.parseScheduleTime('1:00p', now);
|
||||
const expected = new Date('2024-01-15T13:00:00').getTime();
|
||||
assert.strictEqual(result, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTimeRemaining edge cases', () => {
|
||||
it('should handle exactly 1 week', () => {
|
||||
const oneWeek = 7 * 24 * 60 * 60 * 1000;
|
||||
const result = utils.parseTimeRemaining(oneWeek);
|
||||
// Note: The function uses % 7 for days, so 7 days = 0 days
|
||||
assertDeepEqual(result, { days: 0, hours: 0, minutes: 0, seconds: 0 });
|
||||
});
|
||||
|
||||
it('should handle 6 days 23 hours 59 minutes 59 seconds', () => {
|
||||
const ms = (6 * 24 * 60 * 60 * 1000) + (23 * 60 * 60 * 1000) + (59 * 60 * 1000) + (59 * 1000);
|
||||
const result = utils.parseTimeRemaining(ms);
|
||||
assertDeepEqual(result, { days: 6, hours: 23, minutes: 59, seconds: 59 });
|
||||
});
|
||||
|
||||
it('should handle fractional milliseconds (rounds down)', () => {
|
||||
const result = utils.parseTimeRemaining(1500); // 1.5 seconds
|
||||
assert.strictEqual(result.seconds, 1);
|
||||
});
|
||||
|
||||
it.todo('should handle negative values');
|
||||
});
|
||||
|
||||
describe('DAYS constant', () => {
|
||||
it('should have exactly 7 days', () => {
|
||||
assert.strictEqual(utils.DAYS.length, 7);
|
||||
});
|
||||
|
||||
it('should start with Sunday (index 0 = Sunday)', () => {
|
||||
// JavaScript's Date.getDay() returns 0 for Sunday
|
||||
assert.strictEqual(utils.DAYS[0], 'su');
|
||||
assert.strictEqual(utils.DAYS[6], 'sa');
|
||||
});
|
||||
|
||||
it('should have all unique values', () => {
|
||||
const unique = new Set(utils.DAYS);
|
||||
assert.strictEqual(unique.size, 7);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user