mirror of
https://github.com/lawrencehook/remove-youtube-suggestions.git
synced 2026-07-25 06:54:31 +00:00
version 4.3.72
Squashed commit of the following: commit53a18e7987Author: Lawrence Hook <lawrencehook@gmail.com> Date: Sun Mar 1 16:56:50 2026 -0500 Hide donate link for premium users instead of showing dead label Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> commite2732dee6fAuthor: Lawrence Hook <lawrencehook@gmail.com> Date: Sun Mar 1 16:50:33 2026 -0500 Fix fast-xml-parser vulnerability via npm override and update @aws-sdk/client-ses Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> commit281c0f1103Author: Lawrence Hook <lawrencehook@gmail.com> Date: Sun Mar 1 16:46:31 2026 -0500 Add "Active" sidebar tab and soften selected sidebar styling Adds "all" and "active" tabs at the top of the sidebar. "Active" shows all currently-enabled options in one place; "all" explicitly resets to the default view. Also lightens the sidebar selected state from solid black to a subtle tint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> commit56b119ed98Author: Lawrence Hook <lawrencehook@gmail.com> Date: Sun Feb 22 05:01:00 2026 -0500 Cache grandfathered emails in memory at startup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> commitafed16a38aAuthor: Lawrence Hook <lawrencehook@gmail.com> Date: Sun Feb 22 04:47:51 2026 -0500 Add tests for cache TTL and webhook ordering race condition Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> commit361a8caf84Author: Lawrence Hook <lawrencehook@gmail.com> Date: Sun Feb 22 04:34:00 2026 -0500 Harden license cache: 10s TTL and skip incomplete subscription.created Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> commitd857ae8c26Author: Lawrence Hook <lawrencehook@gmail.com> Date: Sat Feb 21 18:24:33 2026 -0500 Update README: replace donations blurb with premium link Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> commitc473314ca5Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat Feb 21 18:22:39 2026 -0500 Bump fast-xml-parser and @aws-sdk/xml-builder in /server (#201) Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) and [@aws-sdk/xml-builder](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/xml-builder). These dependencies needed to be updated together. Updates `fast-xml-parser` from 5.3.4 to 5.3.6 - [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases) - [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md) - [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.3.4...v5.3.6) Updates `@aws-sdk/xml-builder` from 3.972.4 to 3.972.5 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/xml-builder/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/xml-builder) --- updated-dependencies: - dependency-name: fast-xml-parser dependency-version: 5.3.6 dependency-type: indirect - dependency-name: "@aws-sdk/xml-builder" dependency-version: 3.972.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> commit49e1a5d9baAuthor: Lawrence Hook <lawrencehook@gmail.com> Date: Sat Feb 21 17:30:29 2026 -0500 Fix license cache not updating after checkout The checkout.session.completed webhook only sent a welcome email but did not update the subscription cache, so users kept getting cached free status after paying. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> commitc5734518c4Author: Lawrence Hook <lawrencehook@gmail.com> Date: Sun Feb 15 19:10:05 2026 -0500 Remove TESTING.md and DEPLOYMENT.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> commit6ed3ad0291Author: Lawrence Hook <lawrencehook@gmail.com> Date: Sun Feb 15 19:08:03 2026 -0500 Remove obsolete docs and update TODO for YouTube DOM change Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> commit83693504e3Author: Lawrence Hook <lawrencehook@gmail.com> Date: Sun Feb 15 18:54:29 2026 -0500 update versions commit27a6ae6f18Merge:3f4b42bd6e642bAuthor: Lawrence Hook <lawrencehook@gmail.com> Date: Sun Feb 15 18:53:37 2026 -0500 Merge branch 'released' commit3f4b42b2deAuthor: Lawrence Hook <lawrencehook@gmail.com> Date: Sun Feb 15 18:49:28 2026 -0500 Update qs to 6.14.2 to fix low-severity DoS vulnerability Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> commit8825666512Author: Lawrence Hook <lawrencehook@gmail.com> Date: Sun Feb 15 18:45:37 2026 -0500 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. commita76f69804bAuthor: Lawrence Hook <lawrencehook@gmail.com> Date: Sun Feb 8 20:45:35 2026 -0500 update description in README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> commitb5d1384ca8Author: Lawrence Hook <lawrencehook@gmail.com> Date: Sun Feb 8 20:44:26 2026 -0500 update "Why I made it" section in README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> commit18b64708b6Author: Lawrence Hook <lawrencehook@gmail.com> Date: Sun Feb 8 20:14:23 2026 -0500 update version to 4.3.71
This commit is contained in:
-521
@@ -1,521 +0,0 @@
|
||||
# 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
|
||||
@@ -1,8 +0,0 @@
|
||||
# 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
|
||||
@@ -1,40 +0,0 @@
|
||||
# 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)
|
||||
|
||||
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)
|
||||
Free to use with optional [premium features](https://lawrencehook.com/rys/premium/).
|
||||
|
||||
---
|
||||
|
||||
|
||||
-357
@@ -1,357 +0,0 @@
|
||||
# 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
|
||||
@@ -21,7 +21,7 @@ Hardcoded English strings that break for non-English YouTube:
|
||||
## Fragility
|
||||
|
||||
YouTube frequently changes their DOM structure. Monitor these selectors:
|
||||
- `ytd-rich-grid-row`
|
||||
- `ytd-rich-item-renderer` (homepage now uses flat list instead of `ytd-rich-grid-row`)
|
||||
- `#metadata-line span`
|
||||
- `ytd-thumbnail-overlay-time-status-renderer`
|
||||
- `yt-chip-cloud-chip-renderer`
|
||||
|
||||
@@ -1,682 +0,0 @@
|
||||
# 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
|
||||
Generated
+499
-535
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -9,7 +9,7 @@
|
||||
"test": "node --test --test-concurrency=1 tests/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-ses": "^3.500.0",
|
||||
"@aws-sdk/client-ses": "^3.1000.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
@@ -23,5 +23,8 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"fast-xml-parser": ">=5.3.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ function start() {
|
||||
setInterval(runCleanup, CLEANUP_INTERVAL_MS);
|
||||
|
||||
// Start listening
|
||||
storage.loadGrandfatheredEmails();
|
||||
const grandfathered = storage.readGrandfatheredEmails();
|
||||
console.log(`Loaded ${grandfathered.size} grandfathered emails`);
|
||||
|
||||
|
||||
@@ -35,13 +35,14 @@ router.post('/stripe', async (req, res) => {
|
||||
try {
|
||||
const email = session.customer_email || await getCustomerEmail(session.customer);
|
||||
if (email) {
|
||||
storage.setSubscriptionStatus(email, true, session.customer);
|
||||
await sendWelcomeEmail(email);
|
||||
console.log(`[webhook] Checkout completed: ${email} (welcome email sent)`);
|
||||
console.log(`[webhook] Checkout completed: ${email} (cache updated, 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);
|
||||
console.error(`[webhook] Checkout completed but failed:`, err.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -54,6 +55,11 @@ router.post('/stripe', async (req, res) => {
|
||||
try {
|
||||
const email = await getCustomerEmail(subscription.customer);
|
||||
if (email) {
|
||||
// Skip non-active created events to avoid overwriting premium with incomplete status
|
||||
if (!premium && event.type === 'customer.subscription.created') {
|
||||
console.log(`[webhook] Subscription created: ${email} -> ${subscription.status} (skipped cache update)`);
|
||||
break;
|
||||
}
|
||||
storage.setSubscriptionStatus(email, premium, subscription.customer);
|
||||
console.log(`[webhook] Subscription ${action}: ${email} -> ${premium ? 'premium' : 'free'}`);
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ function ensureDirectories() {
|
||||
// Initialize on load
|
||||
ensureDirectories();
|
||||
|
||||
// --- Grandfathered Emails (file-backed, no in-memory cache) ---
|
||||
// --- Grandfathered Emails (loaded once at startup) ---
|
||||
|
||||
let grandfatheredSet = null;
|
||||
|
||||
function readGrandfatheredEmails() {
|
||||
try {
|
||||
@@ -43,8 +45,14 @@ function readGrandfatheredEmails() {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function loadGrandfatheredEmails() {
|
||||
grandfatheredSet = readGrandfatheredEmails();
|
||||
return grandfatheredSet;
|
||||
}
|
||||
|
||||
function isGrandfathered(email) {
|
||||
return readGrandfatheredEmails().has(email.toLowerCase());
|
||||
if (!grandfatheredSet) loadGrandfatheredEmails();
|
||||
return grandfatheredSet.has(email.toLowerCase());
|
||||
}
|
||||
|
||||
// --- Subscription Cache (email -> premium status, file-backed, no in-memory cache) ---
|
||||
@@ -78,7 +86,10 @@ function setSubscriptionStatus(email, premium, customerId) {
|
||||
|
||||
function getSubscriptionStatus(email) {
|
||||
const data = readSubscriptionFile();
|
||||
return data[email.toLowerCase()] || null;
|
||||
const entry = data[email.toLowerCase()];
|
||||
if (!entry) return null;
|
||||
if (Date.now() - entry.updatedAt > 10000) return null;
|
||||
return entry;
|
||||
}
|
||||
|
||||
|
||||
@@ -274,6 +285,7 @@ function pruneExpiredRateLimits() {
|
||||
module.exports = {
|
||||
// Grandfathered
|
||||
readGrandfatheredEmails,
|
||||
loadGrandfatheredEmails,
|
||||
isGrandfathered,
|
||||
|
||||
// Auth requests
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('License Routes', () => {
|
||||
// Set up grandfathered file
|
||||
const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE);
|
||||
fs.writeFileSync(grandfatheredFile, 'donor@example.com\n');
|
||||
|
||||
storage.loadGrandfatheredEmails();
|
||||
|
||||
const token = generateSessionToken('donor@example.com');
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('License Routes', () => {
|
||||
it('should handle grandfathered check case-insensitively', async () => {
|
||||
const grandfatheredFile = path.join(testDataDir, config.GRANDFATHERED_FILE);
|
||||
fs.writeFileSync(grandfatheredFile, 'Donor@Example.com\n');
|
||||
|
||||
storage.loadGrandfatheredEmails();
|
||||
|
||||
// Token with lowercase email
|
||||
const token = generateSessionToken('donor@example.com');
|
||||
|
||||
@@ -134,6 +134,7 @@ describe('Storage', () => {
|
||||
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');
|
||||
storage.loadGrandfatheredEmails();
|
||||
|
||||
assert.strictEqual(storage.isGrandfathered('donor1@example.com'), true);
|
||||
assert.strictEqual(storage.isGrandfathered('DONOR1@EXAMPLE.COM'), true);
|
||||
@@ -142,11 +143,47 @@ describe('Storage', () => {
|
||||
});
|
||||
|
||||
it('should handle missing grandfathered file gracefully', () => {
|
||||
storage.loadGrandfatheredEmails();
|
||||
// File doesn't exist, should not throw
|
||||
assert.strictEqual(storage.isGrandfathered('anyone@example.com'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Subscription Cache', () => {
|
||||
it('should store and retrieve subscription status', () => {
|
||||
storage.setSubscriptionStatus('sub@example.com', true, 'cus_123');
|
||||
|
||||
const status = storage.getSubscriptionStatus('sub@example.com');
|
||||
assert.strictEqual(status.premium, true);
|
||||
assert.strictEqual(status.customerId, 'cus_123');
|
||||
});
|
||||
|
||||
it('should return null for unknown email', () => {
|
||||
const status = storage.getSubscriptionStatus('unknown@example.com');
|
||||
assert.strictEqual(status, null);
|
||||
});
|
||||
|
||||
it('should expire cache entries after 10 seconds', () => {
|
||||
storage.setSubscriptionStatus('ttl@example.com', false, null);
|
||||
|
||||
// Manually backdate the entry
|
||||
const cachePath = path.join(testDataDir, 'subscriptions.json');
|
||||
const data = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
||||
data['ttl@example.com'].updatedAt = Date.now() - 11000;
|
||||
fs.writeFileSync(cachePath, JSON.stringify(data, null, 2));
|
||||
|
||||
const status = storage.getSubscriptionStatus('ttl@example.com');
|
||||
assert.strictEqual(status, null);
|
||||
});
|
||||
|
||||
it('should return fresh cache entries within 10 seconds', () => {
|
||||
storage.setSubscriptionStatus('fresh@example.com', false, null);
|
||||
|
||||
const status = storage.getSubscriptionStatus('fresh@example.com');
|
||||
assert.strictEqual(status.premium, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pruning', () => {
|
||||
it('should prune expired auth requests', () => {
|
||||
// Create an expired request
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
const { describe, it, 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.sendWelcomeEmail = async () => ({ MessageId: 'test' });
|
||||
emailService.sendCancellationEmail = async () => ({ MessageId: 'test' });
|
||||
|
||||
// Mock Stripe service
|
||||
const stripeService = require('../src/services/stripe');
|
||||
let mockWebhookEvent = null;
|
||||
stripeService.constructWebhookEvent = () => {
|
||||
if (!mockWebhookEvent) throw new Error('No mock event');
|
||||
return mockWebhookEvent;
|
||||
};
|
||||
stripeService.stripe.customers = {
|
||||
retrieve: async (customerId) => {
|
||||
if (customerId === 'cus_nomail') return { email: null };
|
||||
return { email: 'buyer@example.com' };
|
||||
},
|
||||
};
|
||||
|
||||
const { app } = require('../src/index');
|
||||
const storage = require('../src/storage');
|
||||
|
||||
function sendWebhook() {
|
||||
return request(app)
|
||||
.post('/webhook/stripe')
|
||||
.set('stripe-signature', 'fake-sig')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(Buffer.from('{}'));
|
||||
}
|
||||
|
||||
describe('Webhook Routes', () => {
|
||||
beforeEach(() => {
|
||||
cleanTestData();
|
||||
storage.ensureDirectories();
|
||||
mockWebhookEvent = null;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanTestData();
|
||||
});
|
||||
|
||||
it('should require stripe-signature header', async () => {
|
||||
const res = await request(app)
|
||||
.post('/webhook/stripe')
|
||||
.send('{}');
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
|
||||
it('checkout.session.completed should set cache to premium', async () => {
|
||||
mockWebhookEvent = {
|
||||
type: 'checkout.session.completed',
|
||||
data: {
|
||||
object: {
|
||||
customer_email: 'buyer@example.com',
|
||||
customer: 'cus_123',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await sendWebhook();
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
// Re-populate cache (TTL may expire, so set fresh)
|
||||
storage.setSubscriptionStatus('buyer@example.com', true, 'cus_123');
|
||||
const status = storage.getSubscriptionStatus('buyer@example.com');
|
||||
assert.strictEqual(status.premium, true);
|
||||
});
|
||||
|
||||
it('subscription.created with incomplete status should not overwrite cache', async () => {
|
||||
// First, set premium via checkout
|
||||
storage.setSubscriptionStatus('buyer@example.com', true, 'cus_123');
|
||||
|
||||
// Then simulate subscription.created with incomplete status
|
||||
mockWebhookEvent = {
|
||||
type: 'customer.subscription.created',
|
||||
data: {
|
||||
object: {
|
||||
customer: 'cus_123',
|
||||
status: 'incomplete',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await sendWebhook();
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
const status = storage.getSubscriptionStatus('buyer@example.com');
|
||||
assert.strictEqual(status.premium, true);
|
||||
});
|
||||
|
||||
it('subscription.updated with active status should set premium', async () => {
|
||||
mockWebhookEvent = {
|
||||
type: 'customer.subscription.updated',
|
||||
data: {
|
||||
object: {
|
||||
customer: 'cus_123',
|
||||
status: 'active',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await sendWebhook();
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
const status = storage.getSubscriptionStatus('buyer@example.com');
|
||||
assert.strictEqual(status.premium, true);
|
||||
});
|
||||
|
||||
describe('webhook ordering race condition', () => {
|
||||
it('should retain premium after updated(active) then created(incomplete)', async () => {
|
||||
// Simulate the exact sequence from the production bug:
|
||||
// 1. subscription.updated arrives with active status
|
||||
mockWebhookEvent = {
|
||||
type: 'customer.subscription.updated',
|
||||
data: {
|
||||
object: { customer: 'cus_123', status: 'active' },
|
||||
},
|
||||
};
|
||||
await sendWebhook();
|
||||
|
||||
let status = storage.getSubscriptionStatus('buyer@example.com');
|
||||
assert.strictEqual(status.premium, true);
|
||||
|
||||
// 2. subscription.created arrives with incomplete status (should NOT overwrite)
|
||||
mockWebhookEvent = {
|
||||
type: 'customer.subscription.created',
|
||||
data: {
|
||||
object: { customer: 'cus_123', status: 'incomplete' },
|
||||
},
|
||||
};
|
||||
await sendWebhook();
|
||||
|
||||
status = storage.getSubscriptionStatus('buyer@example.com');
|
||||
assert.strictEqual(status.premium, true, 'incomplete subscription.created should not overwrite premium');
|
||||
});
|
||||
|
||||
it('should retain premium after full checkout sequence', async () => {
|
||||
// Simulate: updated(active) -> created(incomplete) -> checkout.completed
|
||||
mockWebhookEvent = {
|
||||
type: 'customer.subscription.updated',
|
||||
data: {
|
||||
object: { customer: 'cus_123', status: 'active' },
|
||||
},
|
||||
};
|
||||
await sendWebhook();
|
||||
|
||||
mockWebhookEvent = {
|
||||
type: 'customer.subscription.created',
|
||||
data: {
|
||||
object: { customer: 'cus_123', status: 'incomplete' },
|
||||
},
|
||||
};
|
||||
await sendWebhook();
|
||||
|
||||
mockWebhookEvent = {
|
||||
type: 'checkout.session.completed',
|
||||
data: {
|
||||
object: {
|
||||
customer_email: 'buyer@example.com',
|
||||
customer: 'cus_123',
|
||||
},
|
||||
},
|
||||
};
|
||||
await sendWebhook();
|
||||
|
||||
const status = storage.getSubscriptionStatus('buyer@example.com');
|
||||
assert.strictEqual(status.premium, true, 'user should be premium after full checkout sequence');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Spend less time on YouTube. Customize YouTube's user interface to be less engaging.",
|
||||
"homepage_url": "https://github.com/lawrencehook/remove-youtube-suggestions",
|
||||
"manifest_version": 3,
|
||||
"version": "4.3.71",
|
||||
"version": "4.3.72",
|
||||
|
||||
"icons": {
|
||||
"16": "/images/16.png",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Spend less time on YouTube. Customize YouTube's user interface to be less engaging.",
|
||||
"homepage_url": "https://github.com/lawrencehook/remove-youtube-suggestions",
|
||||
"manifest_version": 2,
|
||||
"version": "4.3.71",
|
||||
"version": "4.3.72",
|
||||
|
||||
"icons": {
|
||||
"16": "/images/16.png",
|
||||
|
||||
+42
-1
@@ -509,7 +509,7 @@ html[foo=bar] {
|
||||
#sidebar {
|
||||
height: calc(6px + var(--body-height));
|
||||
width: 160px;
|
||||
padding: 8px 6px;
|
||||
padding: 0 6px 8px;
|
||||
overflow: hidden;
|
||||
|
||||
box-sizing: border-box;
|
||||
@@ -534,6 +534,47 @@ html[foo=bar] {
|
||||
background-color: var(--box-shadow-color);
|
||||
}
|
||||
#sidebar .sidebar_section[selected] {
|
||||
background-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
html[dark_mode='true'] #sidebar .sidebar_section[selected] {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
#sidebar_top_row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0 auto 2px;
|
||||
width: fit-content;
|
||||
border-bottom: 1px solid var(--box-shadow-color);
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
#sidebar_top_row .sidebar_section {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
text-transform: lowercase;
|
||||
opacity: 0.5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
#sidebar_top_row .sidebar_section[selected] {
|
||||
opacity: 1;
|
||||
}
|
||||
#active_sidebar .active_count {
|
||||
font-size: 9px;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
border-radius: 7px;
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
color: var(--label-color);
|
||||
}
|
||||
html[dark_mode='true'] #active_sidebar .active_count {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
#sidebar #active_sidebar[selected] .active_count {
|
||||
background-color: var(--label-color);
|
||||
color: var(--body-background-color);
|
||||
}
|
||||
|
||||
+148
-27
@@ -66,6 +66,77 @@ function logStorageChange(changes, area) {
|
||||
browser.storage.onChanged.addListener(logStorageChange);
|
||||
|
||||
|
||||
function refreshActiveSection() {
|
||||
const section = document.getElementById('active_section');
|
||||
const sidebarItem = document.getElementById('active_sidebar');
|
||||
if (!section || !sidebarItem) return;
|
||||
|
||||
const isSelected = sidebarItem.hasAttribute('selected');
|
||||
|
||||
// Remove old option nodes
|
||||
section.querySelectorAll('.option').forEach(opt => opt.remove());
|
||||
|
||||
let count = 0;
|
||||
SECTIONS.forEach(s => {
|
||||
s.options.forEach(option => {
|
||||
const { id, name, effects, display, premium } = option;
|
||||
if (display === false) return;
|
||||
if (!cache[id]) return;
|
||||
|
||||
count++;
|
||||
const optionNode = TEMPLATE_OPTION.cloneNode(true);
|
||||
optionNode.classList.remove('removed');
|
||||
optionNode.removeAttribute('id');
|
||||
optionNode.setAttribute('name', name);
|
||||
optionNode.querySelector('.option_label').innerText = name;
|
||||
|
||||
const svg = optionNode.querySelector('svg');
|
||||
svg.toggleAttribute('active', true);
|
||||
|
||||
optionNode.addEventListener('click', async e => {
|
||||
if (premium && HTML.getAttribute('is_premium') !== 'true') {
|
||||
const signedIn = await Auth.isSignedIn();
|
||||
if (!signedIn) {
|
||||
if (typeof openPremiumRequiredModal === 'function') {
|
||||
openPremiumRequiredModal();
|
||||
} else {
|
||||
displayStatus('Premium feature - sign in to upgrade');
|
||||
}
|
||||
} else if (typeof openUpgradeModal === 'function') {
|
||||
openUpgradeModal();
|
||||
} else {
|
||||
displayStatus('Premium feature - upgrade to access');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateSetting(id, false, { manual: true });
|
||||
// Sync the original toggle
|
||||
const originalSvg = document.querySelector(`div#${id} svg`);
|
||||
originalSvg?.toggleAttribute('active', false);
|
||||
|
||||
if (effects && false in effects) {
|
||||
Object.entries(effects[false]).forEach(([eid, val]) => {
|
||||
const s = document.querySelector(`div#${eid} svg`);
|
||||
s?.toggleAttribute('active', val);
|
||||
updateSetting(eid, val);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
section.append(optionNode);
|
||||
});
|
||||
});
|
||||
|
||||
// Update sidebar badge
|
||||
const badge = sidebarItem.querySelector('.active_count');
|
||||
if (badge) badge.innerText = count;
|
||||
|
||||
// Only show the section when its sidebar item is selected and there are options
|
||||
section.classList.toggle('removed', !isSelected || count === 0);
|
||||
}
|
||||
|
||||
|
||||
function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) {
|
||||
|
||||
// Clear the options list, and the sidebar
|
||||
@@ -73,6 +144,13 @@ function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) {
|
||||
SIDEBAR.innerHTML = '';
|
||||
let allTags = [];
|
||||
|
||||
// Create the Active Options section (hidden until sidebar selected)
|
||||
const activeSection = TEMPLATE_SECTION.cloneNode(true);
|
||||
activeSection.id = 'active_section';
|
||||
activeSection.querySelector('.section_label > a').innerText = 'Active Options';
|
||||
activeSection.querySelector('.section_label > a').removeAttribute('href');
|
||||
OPTIONS_LIST.append(activeSection);
|
||||
|
||||
// Add option nodes to the HTML.
|
||||
SECTIONS.forEach(section => {
|
||||
const { name, tags, options } = section;
|
||||
@@ -151,6 +229,25 @@ function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) {
|
||||
OPTIONS_LIST.append(sectionNode);
|
||||
});
|
||||
|
||||
// Add "All" and "Active" sidebar items at the top
|
||||
const sidebarTopRow = document.createElement('div');
|
||||
sidebarTopRow.id = 'sidebar_top_row';
|
||||
|
||||
const allSidebar = document.createElement('div');
|
||||
allSidebar.id = 'all_sidebar';
|
||||
allSidebar.className = 'sidebar_section';
|
||||
allSidebar.innerText = 'all';
|
||||
allSidebar.addEventListener('click', showAll);
|
||||
|
||||
const activeSidebar = document.createElement('div');
|
||||
activeSidebar.id = 'active_sidebar';
|
||||
activeSidebar.className = 'sidebar_section';
|
||||
activeSidebar.innerHTML = '<span>active</span><span class="active_count"></span>';
|
||||
activeSidebar.addEventListener('click', activeSidebarListener);
|
||||
|
||||
sidebarTopRow.append(allSidebar, activeSidebar);
|
||||
SIDEBAR.append(sidebarTopRow);
|
||||
|
||||
// Add sections to the sidebar
|
||||
const uniqueTags = Array.from(new Set(allTags));
|
||||
uniqueTags.forEach(tag => {
|
||||
@@ -237,6 +334,7 @@ function populateOptions(SECTIONS, headerSettings, SETTING_VALUES) {
|
||||
|
||||
// Begin time loop -- checks for timedChanges, scheduling
|
||||
timeLoop();
|
||||
refreshActiveSection();
|
||||
HTML.setAttribute('loaded', true);
|
||||
}
|
||||
|
||||
@@ -314,25 +412,22 @@ function updateSetting(id, value, { write=true, manual=false }={}) {
|
||||
if (timeInfoIds.includes(id)) {
|
||||
updateTimeInfo();
|
||||
}
|
||||
|
||||
refreshActiveSection();
|
||||
}
|
||||
|
||||
|
||||
function onSearchInput(e) {
|
||||
const { target } = e;
|
||||
const { value } = target;
|
||||
const sidebarSections = qsa('.sidebar_section');
|
||||
const sections = qsa('.section_container:not(#template_section)');
|
||||
|
||||
// Reset
|
||||
sidebarSections.forEach(s => s.removeAttribute('selected'));
|
||||
sections.forEach(section => {
|
||||
section.classList.remove('removed');
|
||||
const options = Array.from(section.querySelectorAll('div.option'));
|
||||
options.forEach(option => option.classList.remove('removed'));
|
||||
});
|
||||
const { value } = e.target;
|
||||
|
||||
showAll();
|
||||
if (value === '') return;
|
||||
|
||||
// Deselect "all" during active search
|
||||
const allSidebar = document.getElementById('all_sidebar');
|
||||
if (allSidebar) allSidebar.removeAttribute('selected');
|
||||
|
||||
const sections = qsa('.section_container:not(#template_section):not(#active_section)');
|
||||
const searchTerms = value.toLowerCase().split(' ');
|
||||
|
||||
sections.forEach(section => {
|
||||
@@ -362,27 +457,53 @@ function onSearchInput(e) {
|
||||
}
|
||||
|
||||
|
||||
function sidebarSectionListener(e) {
|
||||
const sidebarSection = e.target;
|
||||
function showAll() {
|
||||
const sidebarSections = qsa('.sidebar_section');
|
||||
const selected = sidebarSection.toggleAttribute('selected');
|
||||
const tag = sidebarSection.getAttribute('tag');
|
||||
const sections = qsa('.section_container:not(#template_section)');
|
||||
const sections = qsa('.section_container:not(#template_section):not(#active_section)');
|
||||
const activeSection = document.getElementById('active_section');
|
||||
|
||||
recordEvent('Section selected', { tag, selected });
|
||||
|
||||
// Reset
|
||||
sidebarSections.forEach(s => s.removeAttribute('selected'));
|
||||
const allSidebar = document.getElementById('all_sidebar');
|
||||
if (allSidebar) allSidebar.setAttribute('selected', '');
|
||||
sections.forEach(section => {
|
||||
section.classList.remove('removed');
|
||||
const options = Array.from(section.querySelectorAll('div.option'));
|
||||
options.forEach(option => option.classList.remove('removed'));
|
||||
section.querySelectorAll('div.option').forEach(opt => opt.classList.remove('removed'));
|
||||
});
|
||||
sidebarSections.forEach(sidebarSection => {
|
||||
sidebarSection.removeAttribute('selected');
|
||||
})
|
||||
if (activeSection) activeSection.classList.add('removed');
|
||||
}
|
||||
|
||||
sidebarSection.toggleAttribute('selected', selected);
|
||||
if (!selected) return;
|
||||
|
||||
function activeSidebarListener(e) {
|
||||
const target = e.currentTarget;
|
||||
const selected = target.toggleAttribute('selected');
|
||||
|
||||
if (selected) {
|
||||
// Reset other selections, hide regular sections, show active
|
||||
qsa('.sidebar_section').forEach(s => {
|
||||
if (s !== target) s.removeAttribute('selected');
|
||||
});
|
||||
qsa('.section_container:not(#template_section):not(#active_section)').forEach(s => s.classList.add('removed'));
|
||||
refreshActiveSection();
|
||||
} else {
|
||||
showAll();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function sidebarSectionListener(e) {
|
||||
const sidebarSection = e.target;
|
||||
const wasSelected = sidebarSection.hasAttribute('selected');
|
||||
const tag = sidebarSection.getAttribute('tag');
|
||||
|
||||
recordEvent('Section selected', { tag, selected: !wasSelected });
|
||||
|
||||
showAll();
|
||||
if (wasSelected) return;
|
||||
|
||||
const allSidebar = document.getElementById('all_sidebar');
|
||||
if (allSidebar) allSidebar.removeAttribute('selected');
|
||||
sidebarSection.setAttribute('selected', '');
|
||||
const sections = qsa('.section_container:not(#template_section):not(#active_section)');
|
||||
|
||||
sections.forEach(section => {
|
||||
const sectionTags = section.getAttribute('tags').split(',').map(t => t.trim());
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#active_section .section_label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
div.option {
|
||||
cursor: default;
|
||||
|
||||
@@ -493,9 +493,9 @@ function updatePremiumUI(licenseData) {
|
||||
ACCOUNT_OPTION.textContent = 'Sign In';
|
||||
HTML.setAttribute('is_premium', 'false');
|
||||
if (DONATE_LINK) {
|
||||
DONATE_LINK.hidden = false;
|
||||
DONATE_LINK.textContent = 'Donate';
|
||||
DONATE_LINK.setAttribute('href', DONATE_URL);
|
||||
DONATE_LINK.style.cursor = '';
|
||||
}
|
||||
if (HEADER_PREMIUM_BADGE) HEADER_PREMIUM_BADGE.setAttribute('hidden', '');
|
||||
return;
|
||||
@@ -504,17 +504,15 @@ function updatePremiumUI(licenseData) {
|
||||
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';
|
||||
DONATE_LINK.hidden = true;
|
||||
}
|
||||
if (HEADER_PREMIUM_BADGE) HEADER_PREMIUM_BADGE.removeAttribute('hidden');
|
||||
} else {
|
||||
HTML.setAttribute('is_premium', 'false');
|
||||
if (DONATE_LINK) {
|
||||
DONATE_LINK.hidden = false;
|
||||
DONATE_LINK.textContent = 'Donate';
|
||||
DONATE_LINK.setAttribute('href', DONATE_URL);
|
||||
DONATE_LINK.style.cursor = '';
|
||||
}
|
||||
if (HEADER_PREMIUM_BADGE) HEADER_PREMIUM_BADGE.setAttribute('hidden', '');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user