version 4.3.71

This commit is contained in:
Lawrence Hook
2026-02-15 18:52:44 -05:00
parent 6fd112f8da
commit d6e642b962
63 changed files with 10368 additions and 488 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
+3 -5
View File
@@ -4,7 +4,7 @@
---
### What it does
This extension aims to make YouTube less engaging and more configurable. It provides options to hide recommended videos and to customize the user interface.
A browser extension that lets you hide recommendations, customize the interface, and take control of your YouTube experience.
---
@@ -14,14 +14,12 @@ 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)
---
### Why I made it
Motivated by an attempt to stymie the YouTube rabbit hole.
The YouTube recommendation algorithm optimizes for the most _engaging_ videos, regardless of whether or not you are interested. Persistent exposure to these suggestions can result in a waste of your time. Download this add-on and remove unwanted suggestions as you please.
The attention economy has gotten out of control. YouTube's recommendation algorithm optimizes for the most _engaging_ videos, not the ones you actually want to watch — and it's easy to lose hours to the rabbit hole. This extension lets you cut the noise and use YouTube on your own terms.
Available for download at the links below:
- [Firefox](https://addons.mozilla.org/en-US/firefox/addon/remove-youtube-s-suggestions)
+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);
});
});
});
+3 -2
View File
@@ -3,7 +3,7 @@
"description": "Spend less time on YouTube. Customize YouTube's user interface to be less engaging.",
"homepage_url": "https://github.com/lawrencehook/remove-youtube-suggestions",
"manifest_version": 3,
"version": "4.3.70",
"version": "4.3.71",
"icons": {
"16": "/images/16.png",
@@ -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;
}
+95 -44
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 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>
<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']) {
+3 -2
View File
@@ -3,7 +3,7 @@
"description": "Spend less time on YouTube. Customize YouTube's user interface to be less engaging.",
"homepage_url": "https://github.com/lawrencehook/remove-youtube-suggestions",
"manifest_version": 2,
"version": "4.3.70",
"version": "4.3.71",
"icons": {
"16": "/images/16.png",
@@ -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 {
+11 -13
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">
<a href="/options/main.html" id="back-button">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Back
</a>
<header>
<a href="/options/main.html" id="back-button">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Back
</a>
<div class="header-content">
<div class="header-icon">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
</div>
<h1>Supporters</h1>
<p class="subtitle">RYS is made possible by generous donations from people like you.</p>
<p class="subtitle secondary">You can also support by sharing with a friend or leaving a <a id="review-link" target="_blank">review</a></p>
</div>
<h1>
<svg class="header-icon" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
Supporters
</h1>
<p class="subtitle">RYS is supported by people like you.</p>
<p class="subtitle secondary">You can also support by sharing with a friend or leaving a <a id="review-link" target="_blank">review</a></p>
</header>
<main>
+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) {
+10 -12
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">
<a href="/options/main.html" id="back-button">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Back
</a>
<header>
<a href="/options/main.html" id="back-button">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
Back
</a>
<div class="header-content">
<div class="header-icon">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 12h-2v-2h2v2zm0-4h-2V6h2v4z"/></svg>
</div>
<h1>Feedback & Support</h1>
<p class="subtitle">We'd love to hear from you</p>
</div>
<h1>
<svg class="header-icon" viewBox="0 0 24 24" fill="currentColor"><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 12h-2v-2h2v2zm0-4h-2V6h2v4z"/></svg>
Feedback & Support
</h1>
<p class="subtitle">We'd love to hear from you</p>
</header>
<main>
+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);
});
});
});
});