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:
Lawrence Hook
2026-02-15 18:45:37 -05:00
parent a76f69804b
commit 8825666512
63 changed files with 10364 additions and 482 deletions
+26
View File
@@ -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
+1
View File
@@ -3,3 +3,4 @@ web-ext-artifacts/
extension.zip
manifest.json
_scratch/
node_modules/
+521
View File
@@ -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
+8
View File
@@ -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
+40
View File
@@ -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
+1 -1
View File
@@ -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&currency_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&currency_code=USD&source=url)
---
+357
View File
@@ -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
+682
View File
@@ -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
+18
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.env
data/
tests/test-data/
+72
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
[
"donor1@example.com",
"donor2@example.com"
]
+2630
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
}
}
+37
View File
@@ -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',
};
+153
View File
@@ -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();
}
+202
View File
@@ -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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};
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;
+35
View File
@@ -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;
+46
View File
@@ -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;
+42
View File
@@ -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;
+109
View File
@@ -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;
+195
View File
@@ -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,
};
+63
View File
@@ -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,
};
+105
View File
@@ -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,
};
+297
View File
@@ -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,
};
+168
View File
@@ -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 };
+180
View File
@@ -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);
});
});
});
+159
View File
@@ -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'));
});
});
});
+43
View File
@@ -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');
});
});
});
+100
View File
@@ -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);
});
});
});
+133
View File
@@ -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);
});
});
});
+37
View File
@@ -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,
};
+168
View File
@@ -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);
});
});
});
+2 -1
View File
@@ -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
View File
@@ -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;
}
+92 -41
View File
@@ -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">&times;</button>
</div>
</div>
<div class="rys_reveal_content">
<span class="rys_reveal_message">${message}</span>
<a class="rys_reveal_show">Reveal</a>
</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']) {
+2 -1
View File
@@ -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"
+480
View File
@@ -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
View File
@@ -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 {
+7 -9
View File
@@ -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">
<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>
<header>
<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>
</div>
</header>
<main>
+12 -117
View File
@@ -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) {
+6 -8
View File
@@ -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">
<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>
<header>
<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>
</div>
</header>
<main>
+6 -2
View File
@@ -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);
+8 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
+427 -4
View File
@@ -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;
}
});
+129
View File
@@ -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;
}
+115
View File
@@ -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
View File
@@ -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');
+19
View File
@@ -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',
},
};
+177
View File
@@ -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
View File
@@ -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,
},
]
},
+181
View File
@@ -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);
});
});
});
+14
View File
@@ -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
View File
@@ -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,
};
+260
View File
@@ -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');
});
});
});
+118
View File
@@ -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`
);
}
});
});
});
+154
View File
@@ -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/
);
});
});
});
+249
View File
@@ -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');
});
});
});
+225
View File
@@ -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)`
);
}
}
}
});
});
});
+106
View File
@@ -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);
});
});
});
+346
View File
@@ -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);
});
});
});
});