abcbooks.fyi is turning from two free web apps into a small product: a welcome and pricing page at https://abcbooks.fyi/ , the McGuffey readers at https://abcbooks.fyi/readers/ , Ray's Arithmetic at https://abcbooks.fyi/math/ , and family accounts at https://abcbooks.fyi/account/ . A parent signs in with an emailed code (no password), adds each learner once, and the names, stars and stickers follow them in both apps and on every device. The account page shows how each learner is doing. Testers get in free with a code. Stripe subscriptions are built in and switch on when the keys are added. This post is the complete rebuild guide; every block has a Copy button.
What it does
- Sign in by email code. The parent types an email address and gets a 6-number code and a link. The code matters on a Fire tablet: the link in the email opens the Silk browser, not the ABC Books app, so the parent types the code on the tablet instead. Codes last 30 minutes, work once, and are rate-limited.
- One family, many learners. Children get their own names, and a grown-up can add themselves ("This is me, a grown-up") to work through the books too. Up to 10 learners per family.
- Names in both apps. Both apps read their learner names from the same saved list on the site, so a name added on the account page, in the readers or in the math shows up everywhere.
- Progress sync. Each app's saved progress is merged with the account's copy (nothing is lost: lessons from either side are kept), pushed every minute and when the page is hidden. Blackboard pictures stay on the device because they are big.
- Progress report. For each learner: lessons finished in each book, stars, the last day they practised, and "Where to help": lessons that got 1 star, and kinds of steps (tracing, reading aloud, spelling...) that average under 2 stars over at least 3 lessons. The report is built from the stars alone; no photos or recordings of children are kept.
- Math work (added 18OCT2026). With the math plan, the scratch paper each child writes on in Ray's Arithmetic (the ✏️ Show work button) is saved to the account, one page at a time, as pen lines (about 1 to 3 KB a page; the newest 3000 pages per learner are kept). The account page's Math work section lists each learner's books; See the work shows the pages grouped by lesson with the question and answer, and a parent can delete one page or a whole book. Without the plan, pages stay on the tablet only. Removing a learner removes their work too. The app side is in the show-your-work post.
- Free lessons and the paywall. Every book's first 5 lessons are free. After that a lesson shows a 🔒 and a "Grown-ups: unlock" screen, but only when the paywall is switched on (
abc-manage paywall on). It ships off. - Plans. One price covers the whole family; each series is sold whole: Readers, Math, or both, monthly or yearly. Prices shown on the pages come from the settings file (
SHOW_*), so they can change without editing code. - Testers.
abc-manage comp <email> bothgives free access by email (before or after they sign up);abc-manage promo both 90 10makes a code for 10 testers that unlocks everything for 90 days. - Fire app rules. Inside the Fire tablet app (an Android WebView) the prices and the buy and manage buttons are hidden, because app stores don't allow buying digital content outside their own payment system. Parents subscribe on the website.
The new site layout
All on the one domain, so the learners' names and progress (saved per website) stay shared between the readers and the math. Subdomains such as math.abcbooks.fyi would have split them apart and reset everyone's stars.
| Address | Served by |
|---|---|
/ |
the welcome page (CT 308, /account/welcome). The installed app (home-screen app, Fire app) is sent on to /readers/ |
/account/* |
the account service on CT 308 (abc-account) |
/readers/* |
ABC Books on CT 306 (abc-web) (its files did not move; the prefix is stripped) |
/readers/api/* |
the reading check on CT 307 (abc-speech) (ABC Books calls api/recognize relative to its page) |
/math/* |
Ray's Arithmetic on CT 306 (its rays/ folder) |
/rays/* |
redirects to /math/ |
/api/*, /privacy.html, everything else |
as before (CT 307, CT 306) |
All of it lives in the abcbooks.fyi block of CT 108 (Caddy reverse proxy), written by add-route.py with handle blocks (never route, which doesn't stop at the first match).
Containers
| Container | Job | LAN address |
|---|---|---|
| CT 308 (abc-account) | Flask + gunicorn on port 8000, SQLite database, nightly copy of the database | x.x.0.239 (DHCP) |
| CT 306 (abc-web) | the two apps' files (Caddy) | x.x.0.127 |
| CT 307 (abc-speech) | the reading check (Vosk) | x.x.0.166 |
| CT 108 (Caddy reverse proxy) | HTTPS and the routes above | x.x.0.122 |
CT 308 is Debian 12, 1 core, 512 MB, 4 GB disk. It is its own product, kept apart from everything else on the server, including payments.
Files
Each block writes one file into /root/abc-account-rebuild/ when pasted into a Proxmox host shell (Proxmox web UI, pve, Shell). Then copy that folder to the PC and arrange it as:
C:\ABCAccounts\server\:app.py,manage.py,config.env.exampleC:\ABCAccounts\static\:account.html,account.css,account.js,sync.js,welcome.htmlC:\ABCAccounts\proxmox\:host.sh,setup-308.sh,add-route.pyC:\ABCAccounts\tools\:deploy.ps1
Proxmox host shell:
mkdir -p /root/abc-account-rebuild
app.py: the account service: sign-in, learners, saved progress, promo codes, Stripe checkout, customer portal and webhook.
cat > /root/abc-account-rebuild/app.py <<'EOF'
# ABC Books accounts: parent sign-in by email (link + 6-digit code), family profiles shared by ABC Books and
# Ray's Arithmetic, saved progress, tester promo codes and Stripe subscriptions. Runs in CT 308 (abc-account)
# behind CT 108's Caddy at https://abcbooks.fyi/account/ . Data: /opt/abc-account/data/accounts.db (SQLite).
import hashlib, hmac, json, os, re, secrets, smtplib, sqlite3, time
from email.message import EmailMessage
from functools import wraps
from flask import Flask, g, jsonify, redirect, request, send_from_directory, abort
from werkzeug.middleware.proxy_fix import ProxyFix
BASE = os.environ.get('ABC_BASE', '/opt/abc-account')
DB = os.path.join(BASE, 'data', 'accounts.db')
STATIC = os.path.join(BASE, 'static')
SITE = os.environ.get('SITE_URL', 'https://abcbooks.fyi')
CFG = os.environ.get # settings come from /opt/abc-account/config.env (systemd EnvironmentFile)
SERIES = ('readers', 'math')
FREE_LESSONS = int(CFG('FREE_LESSONS', '5'))
LINK_MINUTES = 30
SESSION_DAYS = 180
MAX_PROFILES = 10
NAME_RX = re.compile(r"^[^\W\d_][\w' .-]{0,19}$", re.U)
EMAIL_RX = re.compile(r'^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$')
app = Flask(__name__, static_folder=None)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
# ---------- database ----------
SCHEMA = '''
CREATE TABLE IF NOT EXISTS families (id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL, created INTEGER NOT NULL,
comp TEXT NOT NULL DEFAULT '', comp_note TEXT NOT NULL DEFAULT '', promo_until INTEGER NOT NULL DEFAULT 0,
promo_series TEXT NOT NULL DEFAULT '', stripe_customer TEXT, stripe_sub TEXT, sub_status TEXT NOT NULL DEFAULT '',
sub_series TEXT NOT NULL DEFAULT '', sub_until INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS profiles (id INTEGER PRIMARY KEY, family_id INTEGER NOT NULL, name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'child', created INTEGER NOT NULL, UNIQUE(family_id, name));
CREATE TABLE IF NOT EXISTS logins (token_hash TEXT PRIMARY KEY, code_hash TEXT NOT NULL, email TEXT NOT NULL,
expires INTEGER NOT NULL, tries INTEGER NOT NULL DEFAULT 0, used INTEGER NOT NULL DEFAULT 0, created INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, family_id INTEGER NOT NULL, created INTEGER NOT NULL,
last_seen INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS state (family_id INTEGER NOT NULL, app TEXT NOT NULL, data TEXT NOT NULL,
updated INTEGER NOT NULL, PRIMARY KEY(family_id, app));
CREATE TABLE IF NOT EXISTS promos (code TEXT PRIMARY KEY, series TEXT NOT NULL, days INTEGER NOT NULL,
uses_left INTEGER NOT NULL, note TEXT NOT NULL DEFAULT '', created INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS comp_emails (email TEXT PRIMARY KEY, series TEXT NOT NULL, note TEXT NOT NULL DEFAULT '');
CREATE TABLE IF NOT EXISTS attempts (key TEXT NOT NULL, at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS settings (k TEXT PRIMARY KEY, v TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS work (family_id INTEGER NOT NULL, reader TEXT NOT NULL, book TEXT NOT NULL, key TEXT NOT NULL,
data TEXT NOT NULL, updated INTEGER NOT NULL, PRIMARY KEY(family_id, reader, book, key));
CREATE INDEX IF NOT EXISTS work_age ON work(family_id, reader, updated);
'''
def db():
if 'db' not in g:
g.db = sqlite3.connect(DB, timeout=10)
g.db.row_factory = sqlite3.Row
g.db.execute('PRAGMA foreign_keys=ON')
return g.db
@app.teardown_appcontext
def close_db(_e):
d = g.pop('db', None)
if d is not None:
d.close()
def init_db():
os.makedirs(os.path.dirname(DB), exist_ok=True)
c = sqlite3.connect(DB)
c.executescript(SCHEMA)
c.execute('PRAGMA journal_mode=WAL')
c.commit()
c.close()
def h(s):
return hashlib.sha256(s.encode()).hexdigest()
def now():
return int(time.time())
def setting(k, default=''):
r = db().execute('SELECT v FROM settings WHERE k=?', (k,)).fetchone()
return r['v'] if r else default
# ---------- rate limits ----------
def limited(key, max_n, window):
t = now()
d = db()
d.execute('DELETE FROM attempts WHERE at < ?', (t - 86400,))
n = d.execute('SELECT COUNT(*) FROM attempts WHERE key=? AND at>=?', (key, t - window)).fetchone()[0]
if n >= max_n:
d.commit()
return True
d.execute('INSERT INTO attempts(key, at) VALUES(?, ?)', (key, t))
d.commit()
return False
# ---------- security helpers ----------
def same_origin():
# state-changing calls must come from our own pages (the cookie is SameSite=Lax as well)
o = request.headers.get('Origin')
if o is None:
ref = request.headers.get('Referer', '')
return ref.startswith(SITE + '/') or ref.startswith('http://localhost')
return o == SITE or o.startswith('http://localhost')
def json_body():
if not request.is_json:
abort(415)
b = request.get_json(silent=True)
if not isinstance(b, dict):
abort(400)
return b
def current_family():
tok = request.cookies.get('abc_session')
if not tok:
return None
r = db().execute('SELECT s.family_id, s.last_seen FROM sessions s WHERE s.token_hash=?', (h(tok),)).fetchone()
if not r or r['last_seen'] < now() - SESSION_DAYS * 86400:
return None
if r['last_seen'] < now() - 3600:
db().execute('UPDATE sessions SET last_seen=? WHERE token_hash=?', (now(), h(tok)))
db().commit()
return db().execute('SELECT * FROM families WHERE id=?', (r['family_id'],)).fetchone()
def needs_login(f):
@wraps(f)
def w(*a, **k):
fam = current_family()
if not fam:
return jsonify(error='Please sign in.'), 401
if request.method != 'GET' and not same_origin():
return jsonify(error='Bad origin.'), 403
g.fam = fam
return f(*a, **k)
return w
def access(fam):
# which series this family may use past the free lessons
have = set()
for s in (fam['comp'] or '').split(','):
if s in SERIES:
have.add(s)
if fam['promo_until'] > now():
have.update(s for s in fam['promo_series'].split(',') if s in SERIES)
if fam['sub_status'] in ('active', 'trialing') or (fam['sub_status'] == 'past_due' and fam['sub_until'] > now()):
have.update(s for s in fam['sub_series'].split(',') if s in SERIES)
return {s: s in have for s in SERIES}
def series_of(code):
return {'readers': 'readers', 'math': 'math', 'both': 'readers,math'}.get(code, '')
# ---------- email ----------
def send_mail(to, subject, text):
host = CFG('SMTP_HOST', '')
if not host:
app.logger.warning('mail not configured; sign-in mail for a family was not sent (use make-link.sh)')
return False
m = EmailMessage()
m['From'] = CFG('MAIL_FROM', 'ABC Books <support@abcbooks.fyi>')
m['To'] = to
m['Subject'] = subject
m.set_content(text)
with smtplib.SMTP(host, int(CFG('SMTP_PORT', '587')), timeout=20) as s:
s.starttls()
if CFG('SMTP_USER', ''):
s.login(CFG('SMTP_USER'), CFG('SMTP_PASS', ''))
s.send_message(m)
return True
def new_login(email):
tok = secrets.token_urlsafe(32)
code = f'{secrets.randbelow(1000000):06d}'
d = db()
d.execute('DELETE FROM logins WHERE expires < ?', (now() - 86400,))
d.execute('INSERT INTO logins(token_hash, code_hash, email, expires, created) VALUES(?,?,?,?,?)',
(h(tok), h(email + ':' + code), email, now() + LINK_MINUTES * 60, now()))
d.commit()
return tok, code
def start_session(email):
d = db()
fam = d.execute('SELECT * FROM families WHERE email=?', (email,)).fetchone()
if not fam:
comp = d.execute('SELECT series, note FROM comp_emails WHERE email=?', (email,)).fetchone()
d.execute('INSERT INTO families(email, created, comp, comp_note) VALUES(?,?,?,?)',
(email, now(), series_of(comp['series']) if comp else '', comp['note'] if comp else ''))
fam = d.execute('SELECT * FROM families WHERE email=?', (email,)).fetchone()
else:
comp = d.execute('SELECT series, note FROM comp_emails WHERE email=?', (email,)).fetchone()
if comp and not fam['comp']:
d.execute('UPDATE families SET comp=?, comp_note=? WHERE id=?', (series_of(comp['series']), comp['note'], fam['id']))
tok = secrets.token_urlsafe(32)
d.execute('INSERT INTO sessions(token_hash, family_id, created, last_seen) VALUES(?,?,?,?)', (h(tok), fam['id'], now(), now()))
d.commit()
return tok
def with_cookie(resp, tok):
resp.set_cookie('abc_session', tok, max_age=SESSION_DAYS * 86400, httponly=True, secure=SITE.startswith('https'),
samesite='Lax', path='/')
return resp
# ---------- pages ----------
@app.get('/account/')
def page_account():
return send_from_directory(STATIC, 'account.html')
@app.get('/account/welcome')
def page_welcome():
return send_from_directory(STATIC, 'welcome.html')
@app.get('/account/<path:name>')
def page_static(name):
if not re.fullmatch(r'[\w.-]+\.(js|css|png|svg|html)', name):
abort(404)
return send_from_directory(STATIC, name)
@app.get('/account')
def page_account_noslash():
return redirect('/account/', 301)
# ---------- sign in ----------
@app.post('/account/api/login')
def api_login():
if not same_origin():
return jsonify(error='Bad origin.'), 403
b = json_body()
email = str(b.get('email', '')).strip().lower()
if not EMAIL_RX.match(email):
return jsonify(error='Please type a whole email address.'), 400
if limited('mail:' + email, 5, 3600) or limited('ip:' + (request.remote_addr or ''), 30, 3600):
return jsonify(error='Too many tries. Please wait an hour and try again.'), 429
tok, code = new_login(email)
link = f'{SITE}/account/api/verify?t={tok}'
try:
sent = send_mail(email, f'Your ABC Books sign-in code: {code}',
f'Your sign-in code is {code}\n\nType it on the sign-in page, or open this link on the same '
f'device:\n{link}\n\nThe code and link work for {LINK_MINUTES} minutes. If you did not ask '
f'to sign in, you can ignore this email.\n\nABC Books - {SITE}\n')
except Exception:
app.logger.exception('sending sign-in mail failed')
return jsonify(error='We could not send the email just now. Please try again later.'), 502
return jsonify(ok=True, mailed=sent)
@app.post('/account/api/code')
def api_code():
if not same_origin():
return jsonify(error='Bad origin.'), 403
b = json_body()
email = str(b.get('email', '')).strip().lower()
code = re.sub(r'\D', '', str(b.get('code', '')))
if not EMAIL_RX.match(email) or len(code) != 6:
return jsonify(error='Please type the 6 numbers from the email.'), 400
if limited('code:' + email, 10, 3600):
return jsonify(error='Too many tries. Please ask for a new code in an hour.'), 429
d = db()
rows = d.execute('SELECT * FROM logins WHERE email=? AND used=0 AND expires>? AND tries<5 ORDER BY created DESC LIMIT 3',
(email, now())).fetchall()
for r in rows:
if hmac.compare_digest(r['code_hash'], h(email + ':' + code)):
d.execute('UPDATE logins SET used=1 WHERE token_hash=?', (r['token_hash'],))
d.commit()
return with_cookie(jsonify(ok=True), start_session(email))
for r in rows:
d.execute('UPDATE logins SET tries=tries+1 WHERE token_hash=?', (r['token_hash'],))
d.commit()
return jsonify(error='That code did not match. Check the newest email, or ask for a new code.'), 400
@app.get('/account/api/verify')
def api_verify():
tok = request.args.get('t', '')
d = db()
r = d.execute('SELECT * FROM logins WHERE token_hash=? AND used=0 AND expires>?', (h(tok), now())).fetchone() if tok else None
if not r:
return redirect('/account/?expired=1')
d.execute('UPDATE logins SET used=1 WHERE token_hash=?', (r['token_hash'],))
d.commit()
return with_cookie(redirect('/account/'), start_session(r['email']))
@app.post('/account/api/logout')
def api_logout():
tok = request.cookies.get('abc_session')
if tok:
db().execute('DELETE FROM sessions WHERE token_hash=?', (h(tok),))
db().commit()
r = jsonify(ok=True)
r.delete_cookie('abc_session', path='/')
return r
# ---------- the family ----------
def me_json(fam):
profs = db().execute('SELECT id, name, kind FROM profiles WHERE family_id=? ORDER BY id', (fam['id'],)).fetchall()
return {
'signedIn': True, 'email': fam['email'], 'access': access(fam),
'profiles': [dict(p) for p in profs],
'plan': {'status': fam['sub_status'], 'series': fam['sub_series'], 'until': fam['sub_until'],
'comp': fam['comp'], 'promoUntil': fam['promo_until'], 'promoSeries': fam['promo_series']},
'payments': bool(CFG('STRIPE_SECRET', '')),
# the plans on sale: only those with a Stripe price; the shown price text comes from config.env too
'plans': [{'id': p, 'price': CFG('SHOW_' + p.upper().replace('-', '_'), '')}
for p, v in price_ids().items() if v and CFG('STRIPE_SECRET', '')],
'trialDays': int(CFG('TRIAL_DAYS', '0')),
}
@app.get('/account/api/config')
def api_config():
# what the apps need before sign-in: is the free-lesson limit switched on, and how many lessons are free
# plus the price text for the welcome page (SHOW_* in config.env, e.g. SHOW_BOTH_YEAR=$65)
return jsonify(paywall=setting('paywall', 'off') == 'on', freeLessons=FREE_LESSONS,
prices={p: CFG('SHOW_' + p.upper().replace('-', '_'), '') for p in PLANS},
trialDays=int(CFG('TRIAL_DAYS', '0')), payments=bool(CFG('STRIPE_SECRET', '')))
@app.get('/account/api/me')
def api_me():
fam = current_family()
if not fam:
return jsonify(signedIn=False, access={s: False for s in SERIES}, profiles=[])
return jsonify(me_json(fam))
@app.post('/account/api/profiles')
@needs_login
def api_add_profile():
b = json_body()
name = re.sub(r'\s+', ' ', str(b.get('name', ''))).strip()
kind = 'adult' if b.get('kind') == 'adult' else 'child'
if not NAME_RX.match(name):
return jsonify(error='Please use letters only, up to 20 characters.'), 400
d = db()
if d.execute('SELECT COUNT(*) FROM profiles WHERE family_id=?', (g.fam['id'],)).fetchone()[0] >= MAX_PROFILES:
return jsonify(error=f'A family can have up to {MAX_PROFILES} readers.'), 400
try:
d.execute('INSERT INTO profiles(family_id, name, kind, created) VALUES(?,?,?,?)', (g.fam['id'], name, kind, now()))
d.commit()
except sqlite3.IntegrityError:
return jsonify(error='That name is already in your family.'), 400
return jsonify(me_json(g.fam))
@app.post('/account/api/profiles/<int:pid>/delete')
@needs_login
def api_del_profile(pid):
d = db()
r = d.execute('SELECT name FROM profiles WHERE id=? AND family_id=?', (pid, g.fam['id'])).fetchone()
if r: # their saved math work goes with them
d.execute('DELETE FROM work WHERE family_id=? AND reader=?', (g.fam['id'], r['name']))
d.execute('DELETE FROM profiles WHERE id=? AND family_id=?', (pid, g.fam['id']))
d.commit()
return jsonify(me_json(g.fam))
@app.post('/account/api/profiles/<int:pid>/rename')
@needs_login
def api_rename_profile(pid):
name = re.sub(r'\s+', ' ', str(json_body().get('name', ''))).strip()
if not NAME_RX.match(name):
return jsonify(error='Please use letters only, up to 20 characters.'), 400
try:
r = db().execute('SELECT name FROM profiles WHERE id=? AND family_id=?', (pid, g.fam['id'])).fetchone()
db().execute('UPDATE profiles SET name=? WHERE id=? AND family_id=?', (name, pid, g.fam['id']))
if r:
db().execute('UPDATE OR REPLACE work SET reader=? WHERE family_id=? AND reader=?', (name, g.fam['id'], r['name']))
db().commit()
except sqlite3.IntegrityError:
return jsonify(error='That name is already in your family.'), 400
return jsonify(me_json(g.fam))
# ---------- saved progress (each app's saved settings, kept per family) ----------
@app.get('/account/api/state/<appname>')
@needs_login
def api_get_state(appname):
if appname not in ('abcbooks', 'rays'):
abort(404)
r = db().execute('SELECT data, updated FROM state WHERE family_id=? AND app=?', (g.fam['id'], appname)).fetchone()
return jsonify(data=json.loads(r['data']) if r else None, updated=r['updated'] if r else 0)
@app.put('/account/api/state/<appname>')
@needs_login
def api_put_state(appname):
if appname not in ('abcbooks', 'rays'):
abort(404)
raw = request.get_data(cache=False, as_text=True)
if len(raw) > 2_000_000:
return jsonify(error='Too big.'), 413
try:
data = json.loads(raw)
except ValueError:
abort(400)
if not isinstance(data, dict):
abort(400)
db().execute('INSERT INTO state(family_id, app, data, updated) VALUES(?,?,?,?) ON CONFLICT(family_id, app) '
'DO UPDATE SET data=excluded.data, updated=excluded.updated', (g.fam['id'], appname, json.dumps(data), now()))
db().commit()
return jsonify(ok=True, updated=now())
# ---------- math work: the scratch paper pages from Ray's Arithmetic, one row per page ----------
# Saving needs the math plan; looking and deleting always work, so a family can clear its data after a plan ends.
# A page is { q: question, l: lesson, t: ms time, h: height/width, s: [[x,y,x,y...],...] in 0-1000, a: answer, r }.
WORK_PER_READER = 3000
WORK_PAGE_MAX = 80_000
BOOK_RX = re.compile(r'^[a-z0-9]{1,24}$')
def family_reader(name):
# the family's own spelling of a reader name, or None (work is only kept for the family's readers)
for p in db().execute('SELECT name FROM profiles WHERE family_id=?', (g.fam['id'],)):
if p['name'].lower() == str(name).strip().lower():
return p['name']
return None
@app.post('/account/api/work')
@needs_login
def api_put_work():
# { pages: [ { reader, book, key, page } ... ] }, up to 50; page null = delete it. Unknown readers are skipped.
raw = request.get_data(cache=False, as_text=True)
if len(raw) > 2_000_000:
return jsonify(error='Too big.'), 413
try:
items = json.loads(raw).get('pages')
except (ValueError, AttributeError):
abort(400)
if not isinstance(items, list) or len(items) > 50:
abort(400)
if not access(g.fam)['math'] and any(isinstance(it, dict) and it.get('page') is not None for it in items):
return jsonify(error='Saving work to the account is part of the math plan.', paid=False), 402
d, saved, readers = db(), 0, set()
for it in items:
if not isinstance(it, dict):
continue
reader, book, key, page = family_reader(it.get('reader', '')), str(it.get('book', '')), str(it.get('key', '')), it.get('page')
if not reader or not BOOK_RX.match(book) or not 0 < len(key) <= 80:
continue
if page is None:
d.execute('DELETE FROM work WHERE family_id=? AND reader=? AND book=? AND key=?', (g.fam['id'], reader, book, key))
else:
data = json.dumps(page, separators=(',', ':'))
if not isinstance(page, dict) or not isinstance(page.get('s'), list) or len(data) > WORK_PAGE_MAX:
continue
d.execute('INSERT INTO work(family_id, reader, book, key, data, updated) VALUES(?,?,?,?,?,?) '
'ON CONFLICT(family_id, reader, book, key) DO UPDATE SET data=excluded.data, updated=excluded.updated',
(g.fam['id'], reader, book, key, data, now()))
readers.add(reader)
saved += 1
for reader in readers: # keep the newest pages per reader
d.execute('DELETE FROM work WHERE family_id=? AND reader=? AND rowid NOT IN (SELECT rowid FROM work WHERE '
'family_id=? AND reader=? ORDER BY updated DESC LIMIT ?)', (g.fam['id'], reader, g.fam['id'], reader, WORK_PER_READER))
d.commit()
return jsonify(ok=True, saved=saved)
@app.get('/account/api/work')
@needs_login
def api_get_work():
# ?reader=X -> pages per book; ?reader=X&book=Y[&skip=N] -> that book's pages, newest first, 200 at a time
reader = family_reader(request.args.get('reader', ''))
if not reader:
return jsonify(books={}, pages=[])
book = request.args.get('book', '')
if not book:
rows = db().execute('SELECT book, COUNT(*) n, MAX(updated) last FROM work WHERE family_id=? AND reader=? GROUP BY book',
(g.fam['id'], reader)).fetchall()
return jsonify(books={r['book']: {'n': r['n'], 'last': r['last']} for r in rows})
skip = request.args.get('skip', '0')
skip = int(skip) if skip.isdigit() else 0
rows = db().execute('SELECT key, data, updated FROM work WHERE family_id=? AND reader=? AND book=? '
'ORDER BY updated DESC, key LIMIT 200 OFFSET ?', (g.fam['id'], reader, book, skip)).fetchall()
return jsonify(pages=[dict(json.loads(r['data']), key=r['key'], updated=r['updated']) for r in rows], more=len(rows) == 200)
@app.post('/account/api/work/delete')
@needs_login
def api_del_work():
# { reader, book }: every page of one reader in one book
b = json_body()
reader = family_reader(b.get('reader', ''))
if reader:
db().execute('DELETE FROM work WHERE family_id=? AND reader=? AND book=?', (g.fam['id'], reader, str(b.get('book', ''))))
db().commit()
return jsonify(ok=True)
# ---------- promo codes ----------
@app.post('/account/api/promo')
@needs_login
def api_promo():
code = re.sub(r'[^A-Z0-9-]', '', str(json_body().get('code', '')).upper())
if limited('promo:' + str(g.fam['id']), 10, 3600):
return jsonify(error='Too many tries. Please wait an hour.'), 429
d = db()
p = d.execute('SELECT * FROM promos WHERE code=? AND uses_left>0', (code,)).fetchone() if code else None
if not p:
return jsonify(error='That code is not valid.'), 400
until = max(g.fam['promo_until'], now()) + p['days'] * 86400
series = ','.join(sorted(set(filter(None, (g.fam['promo_series'] + ',' + series_of(p['series'])).split(','))) & set(SERIES)))
d.execute('UPDATE families SET promo_until=?, promo_series=? WHERE id=?', (until, series, g.fam['id']))
d.execute('UPDATE promos SET uses_left=uses_left-1 WHERE code=?', (code,))
d.commit()
return jsonify(me_json(d.execute('SELECT * FROM families WHERE id=?', (g.fam['id'],)).fetchone()))
# ---------- Stripe ----------
PLANS = ('readers-month', 'readers-year', 'math-month', 'math-year', 'both-month', 'both-year')
def price_ids():
return {p: CFG('PRICE_' + p.upper().replace('-', '_'), '') for p in PLANS}
def stripe_mod():
import stripe
stripe.api_key = CFG('STRIPE_SECRET', '')
return stripe
@app.post('/account/api/checkout')
@needs_login
def api_checkout():
if not CFG('STRIPE_SECRET', ''):
return jsonify(error='Subscriptions open soon.'), 503
plan = str(json_body().get('plan', ''))
price = price_ids().get(plan)
if not price:
return jsonify(error='Unknown plan.'), 400
stripe = stripe_mod()
args = dict(mode='subscription', line_items=[{'price': price, 'quantity': 1}], client_reference_id=str(g.fam['id']),
success_url=f'{SITE}/account/?paid=1', cancel_url=f'{SITE}/account/?cancelled=1',
allow_promotion_codes=True, metadata={'family': str(g.fam['id']), 'plan': plan})
if g.fam['stripe_customer']:
args['customer'] = g.fam['stripe_customer']
else:
args['customer_email'] = g.fam['email']
trial = int(CFG('TRIAL_DAYS', '0'))
if trial and not g.fam['stripe_sub']:
args['subscription_data'] = {'trial_period_days': trial}
s = stripe.checkout.Session.create(**args)
return jsonify(url=s.url)
@app.post('/account/api/portal')
@needs_login
def api_portal():
if not CFG('STRIPE_SECRET', '') or not g.fam['stripe_customer']:
return jsonify(error='No subscription yet.'), 400
s = stripe_mod().billing_portal.Session.create(customer=g.fam['stripe_customer'], return_url=f'{SITE}/account/')
return jsonify(url=s.url)
def apply_subscription(sub):
# sub = a Stripe subscription object; works out which series it pays for from its prices
rev = {v: k for k, v in price_ids().items() if v}
series = set()
for it in sub['items']['data']:
plan = rev.get(it['price']['id'], '')
series.update(series_of(plan.split('-')[0]).split(','))
series.discard('')
end = sub.get('current_period_end') or (sub['items']['data'][0].get('current_period_end') if sub['items']['data'] else 0) or 0
d = db()
d.execute('UPDATE families SET stripe_sub=?, sub_status=?, sub_series=?, sub_until=? WHERE stripe_customer=?',
(sub['id'], sub['status'], ','.join(sorted(series)), int(end) + 3 * 86400, sub['customer']))
d.commit()
@app.post('/account/api/stripe-webhook')
def api_webhook():
secret = CFG('STRIPE_WEBHOOK_SECRET', '')
if not secret:
abort(404)
stripe = stripe_mod()
try:
ev = stripe.Webhook.construct_event(request.get_data(), request.headers.get('Stripe-Signature', ''), secret)
except Exception:
return 'bad signature', 400
t, o = ev['type'], ev['data']['object']
d = db()
if t == 'checkout.session.completed' and o.get('mode') == 'subscription':
fid = int(o.get('client_reference_id') or 0)
d.execute('UPDATE families SET stripe_customer=? WHERE id=?', (o['customer'], fid))
d.commit()
apply_subscription(stripe.Subscription.retrieve(o['subscription']))
elif t in ('customer.subscription.created', 'customer.subscription.updated', 'customer.subscription.deleted'):
apply_subscription(o)
return jsonify(ok=True)
@app.get('/account/api/health')
def api_health():
db().execute('SELECT 1')
return jsonify(ok=True)
init_db()
if __name__ == '__main__':
app.run('127.0.0.1', 8000)
EOF
manage.py: admin commands (run through abc-manage, below).
cat > /root/abc-account-rebuild/manage.py <<'EOF'
# Admin commands for ABC Books accounts. Run inside CT 308 (abc-account) as:
# /opt/abc-account/venv/bin/python /opt/abc-account/manage.py <command> ...
# Commands:
# promo <readers|math|both> <days> <uses> [note] make a promo code (testers type it on the account page)
# promos list promo codes
# comp <email> <readers|math|both|none> [note] give (or take away) free access by email; works before or after sign-up
# comps list free-access emails
# link <email> print a sign-in link + code for an email (when mail isn't set up)
# families list families with their access
# paywall <on|off> switch the free-lesson limit in both apps on or off
import os, secrets, sqlite3, sys, time
os.environ.setdefault('ABC_BASE', '/opt/abc-account')
import app as A # noqa: E402 (creates the tables if needed)
def conn():
c = sqlite3.connect(A.DB)
c.row_factory = sqlite3.Row
return c
def need(n):
if len(sys.argv) < n:
print(open(__file__).read().split('\nimport')[0])
sys.exit(1)
def main():
need(2)
cmd = sys.argv[1]
c = conn()
if cmd == 'promo':
need(5)
series, days, uses = sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
if series not in ('readers', 'math', 'both'):
sys.exit('series must be readers, math or both')
code = 'ABC-' + '-'.join(secrets.token_hex(2).upper() for _ in range(2))
c.execute('INSERT INTO promos(code, series, days, uses_left, note, created) VALUES(?,?,?,?,?,?)',
(code, series, days, uses, ' '.join(sys.argv[5:]), int(time.time())))
c.commit()
print(code)
elif cmd == 'promos':
for r in c.execute('SELECT * FROM promos ORDER BY created'):
print(f"{r['code']} {r['series']:8} {r['days']:>5} days {r['uses_left']:>3} uses left {r['note']}")
elif cmd == 'comp':
need(4)
email, series = sys.argv[2].strip().lower(), sys.argv[3]
note = ' '.join(sys.argv[4:])
if series == 'none':
c.execute('DELETE FROM comp_emails WHERE email=?', (email,))
c.execute("UPDATE families SET comp='', comp_note='' WHERE email=?", (email,))
elif series in ('readers', 'math', 'both'):
c.execute('INSERT INTO comp_emails(email, series, note) VALUES(?,?,?) ON CONFLICT(email) DO UPDATE SET '
'series=excluded.series, note=excluded.note', (email, series, note))
c.execute('UPDATE families SET comp=?, comp_note=? WHERE email=?', (A.series_of(series), note, email))
else:
sys.exit('series must be readers, math, both or none')
c.commit()
print('ok')
elif cmd == 'comps':
for r in c.execute('SELECT * FROM comp_emails ORDER BY email'):
print(f"{r['email']} {r['series']} {r['note']}")
elif cmd == 'link':
need(3)
email = sys.argv[2].strip().lower()
with A.app.app_context():
tok, code = A.new_login(email)
print(f'{A.SITE}/account/api/verify?t={tok}')
print(f'code: {code} (works for {A.LINK_MINUTES} minutes, once)')
elif cmd == 'families':
for r in c.execute('SELECT * FROM families ORDER BY id'):
acc = [s for s, v in A.access(r).items() if v]
n = c.execute('SELECT COUNT(*) FROM profiles WHERE family_id=?', (r['id'],)).fetchone()[0]
print(f"{r['id']:>4} {r['email']:40} readers:{n} access:{','.join(acc) or '-'} sub:{r['sub_status'] or '-'} {r['comp_note']}")
elif cmd == 'paywall':
need(3)
v = sys.argv[2]
if v not in ('on', 'off'):
sys.exit('on or off')
c.execute("INSERT INTO settings(k, v) VALUES('paywall', ?) ON CONFLICT(k) DO UPDATE SET v=excluded.v", (v,))
c.commit()
print('paywall', v)
else:
sys.exit('unknown command')
main()
EOF
config.env.example: the settings. setup-308.sh copies it to /opt/abc-account/config.env the first time and never overwrites it after that.
cat > /root/abc-account-rebuild/config.env.example <<'EOF'
# /opt/abc-account/config.env on CT 308 (abc-account), mode 600. Edit it there, then: systemctl restart abc-account
SITE_URL=https://abcbooks.fyi
FREE_LESSONS=5
# Sign-in emails. Empty SMTP_HOST = no mail is sent; use "abc-manage link <email>" to get a sign-in code instead.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
MAIL_FROM="ABC Books <hello@abcbooks.fyi>"
# Stripe (empty STRIPE_SECRET = no subscriptions offered; testers use promo codes). Use Stripe's test-mode keys first.
STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
TRIAL_DAYS=0
# Stripe price ids (price_...) for each plan; a plan with no id is not offered
PRICE_READERS_MONTH=
PRICE_READERS_YEAR=
PRICE_MATH_MONTH=
PRICE_MATH_YEAR=
PRICE_BOTH_MONTH=
PRICE_BOTH_YEAR=
# the price text shown on the pages (keep it the same as in Stripe; keep the single quotes)
SHOW_READERS_MONTH='$5'
SHOW_READERS_YEAR='$40'
SHOW_MATH_MONTH='$5'
SHOW_MATH_YEAR='$40'
SHOW_BOTH_MONTH='$8'
SHOW_BOTH_YEAR='$65'
EOF
account.html, account.css, account.js: the parent's page: sign in, learners, progress report, plans, promo code.
cat > /root/abc-account-rebuild/account.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ABC Books Account</title>
<link rel="icon" href="/icon-192.png">
<link rel="stylesheet" href="/account/account.css">
</head>
<body>
<header class="top"><a href="/" class="brand">ABC Books</a><nav><a href="/readers/">Readers</a><a href="/math/">Math</a></nav></header>
<main id="main">
<section id="signin" hidden>
<h1>Sign in or create your family account</h1>
<p>Type your email. We'll send you a 6-number code (and a link). No password needed.</p>
<form id="emailForm">
<label for="email">Email</label>
<input id="email" type="email" autocomplete="email" required>
<button type="submit">Send my code</button>
</form>
<form id="codeForm" hidden>
<p id="sentTo"></p>
<label for="code">Code from the email</label>
<input id="code" inputmode="numeric" autocomplete="one-time-code" maxlength="7" required>
<button type="submit">Sign in</button>
<button type="button" class="ghost" id="again">Use a different email</button>
</form>
<p class="small">On a tablet, type the code on the tablet itself, even if you read the email on your phone.</p>
</section>
<section id="family" hidden>
<h1>Your family</h1>
<p id="who" class="small"></p>
<h2>Readers</h2>
<p class="small">Each child gets their own name, stars and stickers in both the readers and the math books. Add yourself too if you'd like to work through the books.</p>
<ul id="readers" class="readers"></ul>
<form id="addForm" class="row">
<label for="newName" class="sr">Name</label>
<input id="newName" maxlength="20" placeholder="First name or nickname" required>
<label class="check"><input type="checkbox" id="isAdult"> This is me (a grown-up)</label>
<button type="submit">Add reader</button>
</form>
<h2>How they're doing</h2>
<p class="small">From the stars each reader earned on any device signed in to this account.</p>
<div id="report"></div>
<h2>Math work</h2>
<p class="small">The scratch paper each child wrote on while solving in Ray's Arithmetic (the ✏️ Show work button), kept with its question. Tap a page to see it larger.</p>
<div id="work"></div>
<h2>Your books</h2>
<ul id="access" class="access"></ul>
<div id="plans"></div>
<h2>Have a code?</h2>
<form id="promoForm" class="row">
<label for="promo" class="sr">Code</label>
<input id="promo" placeholder="ABC-XXXX-XXXX" autocomplete="off" required>
<button type="submit">Use code</button>
</form>
<h2>Start reading</h2>
<p class="go"><a class="big" href="/readers/">ABC Books (McGuffey readers)</a> <a class="big" href="/math/">Ray's Arithmetic (math)</a></p>
<p><button type="button" class="ghost" id="logout">Sign out</button></p>
</section>
<p id="msg" role="status" aria-live="polite"></p>
</main>
<footer class="foot"><a href="/privacy.html">Privacy</a> · <a href="mailto:support@abcbooks.fyi">support@abcbooks.fyi</a></footer>
<script src="/account/account.js"></script>
</body>
</html>
EOF
cat > /root/abc-account-rebuild/account.css <<'EOF'
:root{--bg:#141922;--card:#1d2430;--fg:#f3efe6;--dim:#aab3c2;--line:#323c4d;--go:#3a8f5a;--go2:#4caf74;--warn:#d9a441}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:Georgia,'Times New Roman',serif;font-size:18px;line-height:1.5}
a{color:#9cc8ff}
.top{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line)}
.top nav a{margin-left:16px}
.brand{font-size:1.3em;font-weight:bold;color:var(--fg);text-decoration:none}
main{max-width:760px;margin:0 auto;padding:16px}
h1{font-size:1.6em;margin:.6em 0 .3em}h2{font-size:1.25em;margin:1.4em 0 .4em;border-top:1px solid var(--line);padding-top:.8em}
label{display:block;margin:.6em 0 .2em}
.check{display:flex;align-items:center;gap:8px;margin:.4em 0}
.check input{width:auto}
input{font:inherit;width:100%;max-width:420px;padding:10px 12px;border-radius:10px;border:1px solid var(--line);background:#0f131a;color:var(--fg)}
button,.big{font:inherit;background:var(--go);color:#fff;border:0;border-radius:10px;padding:10px 18px;margin:.4em .4em .4em 0;cursor:pointer;text-decoration:none;display:inline-block}
button:hover,.big:hover{background:var(--go2)}
button.ghost{background:none;border:2px solid var(--line);color:var(--fg)}
button.small{padding:4px 12px;font-size:.9em}
.row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.row input{flex:1 1 220px}
.small{color:var(--dim);font-size:.9em}
.readers,.access{list-style:none;padding:0}
.readers li,.access li{display:flex;justify-content:space-between;align-items:center;background:var(--card);border-radius:10px;padding:8px 12px;margin:6px 0}
.tag{font-size:.8em;color:var(--dim);margin-left:8px}
.yes{color:var(--go2);font-weight:bold}.no{color:var(--dim)}
.plans{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}
.plan{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px}
.plan h3{margin:.1em 0}.plan .price{font-size:1.2em}
.plan.best{border-color:var(--go2)}
#msg{min-height:1.5em;color:var(--warn)}
.sr{position:absolute;left:-9999px}
.rep{background:var(--card);border-radius:12px;padding:10px 14px;margin:8px 0}
.rep h3{margin:.2em 0}.rep ul{margin:.3em 0;padding-left:1.2em}
.inapp .noapp{display:none}
.wbook{display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}
.wpages{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px;margin:8px 0}
.wles{grid-column:1/-1;margin:.6em 0 0;font-size:1em}
.wpage{background:#0f131a;border:1px solid var(--line);border-radius:10px;padding:6px;font-size:.85em}
.wpage.big{grid-column:1/-1}
.wpage canvas{display:block;width:100%;height:auto;border-radius:6px;cursor:zoom-in}.wpage.big canvas{cursor:zoom-out}
.wpage .ok{color:var(--go2)}.wpage .miss{color:var(--warn)}
.foot{text-align:center;color:var(--dim);padding:24px 16px;font-size:.9em}
.hero{text-align:center;padding:20px 0}
.hero h1{font-size:2em}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px}
.card{background:var(--card);border-radius:14px;padding:16px}
.go{display:flex;flex-wrap:wrap;gap:8px}
@media (max-width:480px){body{font-size:17px}.top nav a{margin-left:10px}}
EOF
cat > /root/abc-account-rebuild/account.js <<'EOF'
// The account page: sign in with an emailed code, manage readers, see which books are unlocked, subscribe,
// use a promo code. Talks to /account/api/* (app.py in CT 308 abc-account).
(() => {
const $ = id => document.getElementById(id);
const msg = t => { $('msg').textContent = t || ''; };
const esc = t => String(t).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
async function call(path, body, method) {
const r = await fetch('/account/api' + path, {
method: method || (body ? 'POST' : 'GET'), credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || 'Something went wrong. Please try again.');
return j;
}
const NAMES = { readers: 'ABC Books (McGuffey readers, Primer to Sixth Reader)', math: "Ray's Arithmetic (math, grades 1-8)" };
const PLAN = {
'readers-month': ['Readers', 'per month'], 'readers-year': ['Readers', 'per year'],
'math-month': ['Math', 'per month'], 'math-year': ['Math', 'per year'],
'both-month': ['Readers + Math', 'per month'], 'both-year': ['Readers + Math', 'per year'] };
function signin() {
$('signin').hidden = false; $('family').hidden = true;
const q = new URLSearchParams(location.search);
if (q.get('expired')) msg('That link has expired or was already used. Ask for a new code below.');
$('emailForm').onsubmit = async e => {
e.preventDefault(); msg('Sending...');
try {
const r = await call('/login', { email: $('email').value });
$('emailForm').hidden = true; $('codeForm').hidden = false;
$('sentTo').textContent = `We sent a code to ${$('email').value}. It works for 30 minutes.`;
msg(r.mailed === false ? 'Email is not switched on yet. Ask ABC Books support for your code.' : '');
$('code').focus();
} catch (err) { msg(err.message); }
};
$('codeForm').onsubmit = async e => {
e.preventDefault(); msg('Checking...');
try { await call('/code', { email: $('email').value, code: $('code').value }); location.replace('/account/'); }
catch (err) { msg(err.message); }
};
$('again').onclick = () => { $('codeForm').hidden = true; $('emailForm').hidden = false; msg(''); };
}
function show(me) {
$('signin').hidden = true; $('family').hidden = false;
$('who').textContent = `Signed in as ${me.email}`;
report(me.profiles);
work(me);
const ul = $('readers'); ul.innerHTML = '';
if (!me.profiles.length) ul.innerHTML = '<li>No readers yet. Add the first one below.</li>';
me.profiles.forEach(p => {
const li = document.createElement('li');
li.innerHTML = `<span>${esc(p.name)}${p.kind === 'adult' ? '<span class="tag">grown-up</span>' : ''}</span>`;
const del = document.createElement('button'); del.className = 'ghost small'; del.type = 'button';
del.textContent = 'Remove'; del.setAttribute('aria-label', 'Remove ' + p.name);
del.onclick = async () => {
if (!del.dataset.sure) { del.dataset.sure = 1; del.textContent = 'Tap again to remove'; return; }
try { show(await call(`/profiles/${p.id}/delete`, {})); msg(`${p.name} was removed. Their stars stay on devices they used.`); } catch (err) { msg(err.message); }
};
li.appendChild(del); ul.appendChild(li);
});
const acc = $('access'); acc.innerHTML = '';
for (const s of ['readers', 'math']) {
const li = document.createElement('li');
li.innerHTML = `<span>${NAMES[s]}</span>${me.access[s] ? '<span class="yes">Unlocked</span>' : '<span class="no">Free lessons only</span>'}`;
acc.appendChild(li);
}
const pl = $('plans'); pl.innerHTML = '';
const p = me.plan;
// inside the Fire tablet app (Android WebView) there is no buying or plan changes; the app store doesn't allow it
const inApp = /; wv\)/.test(navigator.userAgent);
if (p.comp) pl.insertAdjacentHTML('beforeend', '<p class="small">Your family has free tester access. Thank you for helping!</p>');
if (p.promoUntil * 1000 > Date.now()) pl.insertAdjacentHTML('beforeend', `<p class="small">Your code unlocks books until ${new Date(p.promoUntil * 1000).toLocaleDateString()}.</p>`);
if (inApp) {
if (p.status && p.status !== 'canceled') pl.insertAdjacentHTML('beforeend', `<p>Subscription: <b>${esc(p.status === 'trialing' ? 'free trial' : p.status)}</b></p>`);
} else if (p.status && p.status !== 'canceled') {
pl.insertAdjacentHTML('beforeend', `<p>Subscription: <b>${esc(p.status === 'trialing' ? 'free trial' : p.status)}</b></p>`);
const b = document.createElement('button'); b.type = 'button'; b.textContent = 'Manage or cancel subscription';
b.onclick = async () => { try { location.href = (await call('/portal', {})).url; } catch (err) { msg(err.message); } };
pl.appendChild(b);
} else if (me.payments && me.plans.length && !(me.access.readers && me.access.math)) {
pl.insertAdjacentHTML('beforeend', `<p>Unlock every lesson. One price covers your whole family.${me.trialDays ? ` Try it free for ${me.trialDays} days.` : ''}</p>`);
const grid = document.createElement('div'); grid.className = 'plans';
me.plans.forEach(x => {
const [name, per] = PLAN[x.id] || [x.id, ''];
const d = document.createElement('div'); d.className = 'plan' + (x.id === 'both-year' ? ' best' : '');
d.innerHTML = `<h3>${name}</h3><p class="price">${esc(x.price)} <span class="small">${per}</span></p>${x.id === 'both-year' ? '<p class="small">Best value</p>' : ''}`;
const b = document.createElement('button'); b.type = 'button'; b.textContent = 'Choose';
b.onclick = async () => { msg('Opening secure checkout...'); try { location.href = (await call('/checkout', { plan: x.id })).url; } catch (err) { msg(err.message); } };
d.appendChild(b); grid.appendChild(d);
});
pl.appendChild(grid);
} else if (!(me.access.readers && me.access.math)) {
pl.insertAdjacentHTML('beforeend', '<p class="small">Subscriptions open soon. Until then, the first lessons of every book are free.</p>');
}
}
// ---------- progress report: from the saved state of both apps (progress[reader][book][lesson] = {stars, at, steps}) ----------
const BOOK = {
primer1881: 'Primer', first1879: 'First Reader', second1879: 'Second Reader', third1879: 'Third Reader',
fourth1879: 'Fourth Reader', fifth1879: 'Fifth Reader', sixth1879: 'Sixth Reader', spell1867: 'Speller',
primary1877: "Ray's Book 1", intellectual: "Ray's Book 2", practical: "Ray's Book 3", higher: "Ray's Book 4",
geometry: "Ray's Geometry", algebra1: "Ray's Algebra", algebra2: "Ray's Higher Algebra" };
const STEP = { words: 'new words', spell: 'spelling', listen: 'listening', read: 'reading aloud', talk: 'talking about the story',
print: 'tracing print letters', script: 'tracing cursive', board: 'blackboard writing' };
async function report(profiles) {
const box = $('report');
let ab = {}, rays = {};
try { ab = ((await call('/state/abcbooks')).data || {}).progress || {}; } catch (e) {}
try { rays = ((await call('/state/rays')).data || {}).progress || {}; } catch (e) {}
box.innerHTML = '';
if (!profiles.length) { box.innerHTML = '<p class="small">Add a reader to see their progress here.</p>'; return; }
for (const p of profiles) {
const lines = [], weak = [], stepScores = {};
let last = 0;
for (const [label, prog] of [['Readers', ab[p.name] || {}], ['Math', rays[p.name] || {}]]) {
for (const [book, lessons] of Object.entries(prog)) {
const done = Object.entries(lessons || {}).filter(([, x]) => x && x.stars);
if (!done.length) continue;
const stars = done.reduce((a, [, x]) => a + x.stars, 0);
lines.push(`${label}: ${BOOK[book] || book}, ${done.length} lesson${done.length > 1 ? 's' : ''} done, ${stars} stars (average ${(stars / done.length).toFixed(1)} of 3)`);
const low = done.filter(([, x]) => x.stars === 1).map(([n]) => n);
if (low.length) weak.push(`${BOOK[book] || book}: lesson${low.length > 1 ? 's' : ''} ${low.slice(0, 8).join(', ')}${low.length > 8 ? '...' : ''} got 1 star. Try ${low.length > 1 ? 'them' : 'it'} again together.`);
for (const [, x] of done) {
last = Math.max(last, x.at || 0);
for (const [s, v] of Object.entries(x.steps || {})) (stepScores[s] = stepScores[s] || []).push(v);
}
}
}
for (const [s, v] of Object.entries(stepScores)) {
const avg = v.reduce((a, b) => a + b, 0) / v.length;
if (v.length >= 3 && avg < 2) weak.unshift(`Needs practice: ${STEP[s] || s} (average ${avg.toFixed(1)} of 3 stars over ${v.length} lessons).`);
}
const d = document.createElement('div'); d.className = 'rep';
d.innerHTML = `<h3>${esc(p.name)}</h3>` + (lines.length
? `<p class="small">Last practised: ${last ? new Date(last).toLocaleDateString() : 'unknown'}</p><ul>${lines.map(l => `<li>${esc(l)}</li>`).join('')}</ul>` +
(weak.length ? `<p><b>Where to help</b></p><ul>${weak.map(l => `<li>${esc(l)}</li>`).join('')}</ul>` : '<p class="small">Doing well. No trouble spots yet.</p>')
: '<p class="small">No lessons finished yet.</p>');
box.appendChild(d);
}
}
// ---------- math work: scratch paper pages from Ray's Arithmetic (/account/api/work, one row per page) ----------
// A page: { q, l: lesson, t: ms time, h: height/width, s: [[x,y,x,y...],...] in 0-1000 across, a: answer, r: ok|miss }
function picture(pg, w) { // same drawing as the app's scratch.js
const h = Math.round(w * (pg.h || 0.75)), cv = document.createElement('canvas'), dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = w * dpr; cv.height = h * dpr;
const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#fffdf6'; ctx.fillRect(0, 0, w, h);
const step = Math.max(10, w / 18); ctx.strokeStyle = 'rgba(90,140,200,.22)'; ctx.lineWidth = 1; ctx.beginPath();
for (let x = step; x < w; x += step) { ctx.moveTo(x, 0); ctx.lineTo(x, h); }
for (let y = step; y < h; y += step) { ctx.moveTo(0, y); ctx.lineTo(w, y); }
ctx.stroke();
const sc = Math.min(w / 1000, h / (1000 * (pg.h || 0.75)));
ctx.strokeStyle = '#1d2a44'; ctx.lineCap = ctx.lineJoin = 'round'; ctx.lineWidth = Math.max(1.5, 4 * sc);
for (const s of pg.s || []) {
ctx.beginPath(); ctx.moveTo(s[0] * sc, s[1] * sc);
if (s.length === 2) ctx.lineTo(s[0] * sc + 0.1, s[1] * sc);
for (let i = 2; i < s.length; i += 2) ctx.lineTo(s[i] * sc, s[i + 1] * sc);
ctx.stroke();
}
return cv;
}
async function work(me) {
const box = $('work'); box.innerHTML = '';
if (!me.access.math) box.insertAdjacentHTML('beforeend', '<p class="small">Saving the scratch paper to your account comes with the math plan. Without it, each tablet keeps its own pages: open a book in Ray\'s Arithmetic and tap ✏️ See the work.</p>');
let any = false;
for (const p of me.profiles) {
let books = {};
try { books = (await call('/work?reader=' + encodeURIComponent(p.name))).books || {}; } catch (e) {}
const ids = Object.keys(books).sort((a, b) => books[b].last - books[a].last);
if (!ids.length) continue;
any = true;
const d = document.createElement('div'); d.className = 'rep';
d.innerHTML = `<h3>${esc(p.name)}</h3>`;
ids.forEach(b => {
const row = document.createElement('div'); row.className = 'wbook';
row.innerHTML = `<span>${esc(BOOK[b] || b)}: ${books[b].n} page${books[b].n > 1 ? 's' : ''}, last ${new Date(books[b].last * 1000).toLocaleDateString()}</span>`;
const open = document.createElement('button'); open.type = 'button'; open.className = 'small'; open.textContent = 'See the work';
const pagesBox = document.createElement('div');
open.onclick = () => { if (pagesBox.childElementCount) { pagesBox.innerHTML = ''; open.textContent = 'See the work'; } else { open.textContent = 'Hide'; showPages(p.name, b, pagesBox, me); } };
row.appendChild(open); d.append(row, pagesBox);
});
box.appendChild(d);
}
if (!any && me.access.math) box.insertAdjacentHTML('beforeend', '<p class="small">No work saved yet. In Story problems and Practice, the ✏️ Show work button opens scratch paper; each page shows up here.</p>');
}
async function showPages(reader, book, box, me, skip) {
let r;
try { r = await call(`/work?reader=${encodeURIComponent(reader)}&book=${encodeURIComponent(book)}&skip=${skip || 0}`); } catch (err) { return msg(err.message); }
if (!skip) box.innerHTML = '';
const old = box.querySelector('.wpages'); if (old) old.remove();
const all = (box._pages = (box._pages && skip ? box._pages : []).concat(r.pages));
// lessons with the newest work first; inside a lesson, the pages in question order
const newest = {}; all.forEach(pg => { newest[pg.l] = Math.max(newest[pg.l] || 0, pg.t || 0); });
all.sort((a, b) => newest[b.l] - newest[a.l] || a.key.localeCompare(b.key, undefined, { numeric: true }));
const grid = document.createElement('div'); grid.className = 'wpages'; let lastL = null;
all.forEach(pg => {
if (pg.l !== lastL) { lastL = pg.l; const hd = document.createElement('h4'); hd.className = 'wles'; hd.textContent = `Lesson ${pg.l}`; grid.appendChild(hd); }
const c = document.createElement('div'); c.className = 'wpage';
const cv = picture(pg, 440); cv.setAttribute('role', 'img'); cv.setAttribute('aria-label', 'Scratch paper for: ' + pg.q);
cv.onclick = () => c.classList.toggle('big');
const res = pg.a != null ? `Answer: ${esc(pg.a)} ` + (pg.r === 'ok' ? '<span class="ok">✓</span>' : pg.r === 'miss' ? '<span class="miss">(missed a try)</span>' : '') : 'Not answered';
c.appendChild(cv);
c.insertAdjacentHTML('beforeend', `<div>${esc(pg.q)}</div><div class="small">${res} · ${new Date(pg.t || pg.updated * 1000).toLocaleString()}</div>`);
const del = document.createElement('button'); del.type = 'button'; del.className = 'ghost small'; del.textContent = 'Delete';
del.onclick = async () => {
if (!del.dataset.sure) { del.dataset.sure = 1; del.textContent = 'Tap again to delete'; return; }
try { await call('/work', { pages: [{ reader, book, key: pg.key, page: null }] }); box._pages = all.filter(x => x !== pg); c.remove(); } catch (err) { msg(err.message); }
};
c.appendChild(del); grid.appendChild(c);
});
box.appendChild(grid);
const more = box.querySelector('.wmore'); if (more) more.remove();
if (r.more) { const m = document.createElement('button'); m.type = 'button'; m.className = 'ghost small wmore'; m.textContent = 'Show older pages'; m.onclick = () => showPages(reader, book, box, me, all.length); box.appendChild(m); }
if (!box.querySelector('.wall')) {
const da = document.createElement('button'); da.type = 'button'; da.className = 'ghost small wall'; da.textContent = `Delete all of ${reader}'s work in ${BOOK[book] || book}`;
da.onclick = async () => {
if (!da.dataset.sure) { da.dataset.sure = 1; da.textContent = 'Tap again to delete all of it'; return; }
try { await call('/work/delete', { reader, book }); work(me); msg('Deleted. Pages still on a tablet stay there until deleted in the app.'); } catch (err) { msg(err.message); }
};
box.appendChild(da);
}
}
$('addForm').onsubmit = async e => {
e.preventDefault();
try { show(await call('/profiles', { name: $('newName').value, kind: $('isAdult').checked ? 'adult' : 'child' })); $('newName').value = ''; $('isAdult').checked = false; msg('Added.'); }
catch (err) { msg(err.message); }
};
$('promoForm').onsubmit = async e => {
e.preventDefault();
try { show(await call('/promo', { code: $('promo').value })); $('promo').value = ''; msg('Code accepted. Enjoy!'); }
catch (err) { msg(err.message); }
};
$('logout').onclick = async () => { await call('/logout', {}).catch(() => {}); try { localStorage.removeItem('abcaccount'); } catch (e) {} location.replace('/account/'); };
(async () => {
const q = new URLSearchParams(location.search);
try {
const me = await call('/me');
if (!me.signedIn) return signin();
show(me);
try { localStorage.setItem('abcaccount', JSON.stringify(Object.assign(JSON.parse(localStorage.getItem('abcaccount') || '{}'), { signedIn: true, access: me.access, profiles: me.profiles }))); } catch (e) {}
if (q.get('paid')) msg('Thank you! Your books are unlocked (it can take a minute).');
if (q.get('cancelled')) msg('Checkout was cancelled. Nothing was charged.');
} catch (err) { msg('Could not reach the account server. Please try again in a moment.'); }
})();
})();
EOF
sync.js: loaded by both apps from /account/sync.js. Keeps names and progress in step with the account and says which lessons are free (ABCAccount.locked(series, lesson)). The apps work without it (offline, or an old cached copy).
cat > /root/abc-account-rebuild/sync.js <<'EOF'
// Shared by ABC Books (readers) and Ray's Arithmetic (math), served from /account/sync.js.
// - Signed in: the family's reader names (from the account) go into 'abcbooks'.readers, which both apps read,
// and each app's saved progress is kept on the account (merged with this device, so nothing is lost).
// - The free-lesson limit: ABCAccount.locked(series, lessonIndex) says whether a lesson needs a subscription.
// It is off until the paywall is switched on (manage.py paywall on), and works offline from the last answer.
// Apps must work without this file (old cached copies, no network): every call is guarded with window.ABCAccount.
window.ABCAccount = (() => {
const API = '/account/api';
const KEY = 'abcaccount'; // last known answer: { signedIn, access, profiles, paywall, freeLessons }
let info = { signedIn: false, access: { readers: false, math: false }, profiles: [], paywall: false, freeLessons: 5 };
try { Object.assign(info, JSON.parse(localStorage.getItem(KEY)) || {}); } catch (e) {}
const keep = () => { try { localStorage.setItem(KEY, JSON.stringify(info)); } catch (e) {} };
const SKIP = { abcbooks: ['boards'], rays: [] }; // device-only parts (blackboard pictures are big)
// union of two saved settings: objects merged key by key, lists joined, and this device wins on single values
function merge(local, remote) {
if (Array.isArray(local) && Array.isArray(remote)) return [...new Set([...remote, ...local])];
if (local && remote && typeof local === 'object' && typeof remote === 'object') {
const out = Object.assign({}, remote);
for (const k of Object.keys(local)) out[k] = k in remote ? merge(local[k], remote[k]) : local[k];
return out;
}
return local === undefined || local === null ? remote : local;
}
const read = k => { try { return JSON.parse(localStorage.getItem(k)); } catch (e) { return null; } };
const strip = (app, s) => { const c = Object.assign({}, s); for (const k of SKIP[app] || []) delete c[k]; return c; };
async function call(path, opts) {
const r = await fetch(API + path, Object.assign({ credentials: 'same-origin', headers: { 'Content-Type': 'application/json' } }, opts || {}));
if (!r.ok) throw new Error(r.status);
return r.json();
}
// the family's names become the readers on this device; names added on this device go up to the account
async function syncReaders() {
const ab = read('abcbooks') || { kid: null, progress: {}, boards: {} };
const local = Array.isArray(ab.readers) ? ab.readers : [];
const have = new Set(info.profiles.map(p => p.name.toLowerCase()));
for (const n of local) {
if (!have.has(n.toLowerCase()) && info.profiles.length < 10) {
try { const me = await call('/profiles', { method: 'POST', body: JSON.stringify({ name: n }) }); info.profiles = me.profiles; have.add(n.toLowerCase()); } catch (e) {}
}
}
const names = info.profiles.map(p => p.name);
const changed = JSON.stringify(names) !== JSON.stringify(local);
if (changed) { ab.readers = names; if (ab.kid && !names.includes(ab.kid)) ab.kid = null; localStorage.setItem('abcbooks', JSON.stringify(ab)); }
return changed;
}
// progress: pull the account's copy, merge, write back here and up to the account
const last = {};
async function syncApp(app) {
const local = read(app);
let remote = null;
try { remote = (await call('/state/' + app)).data; } catch (e) { return false; }
const merged = remote ? (local ? merge(local, remote) : remote) : local;
if (!merged) return false;
const before = JSON.stringify(local);
if (JSON.stringify(merged) !== before) localStorage.setItem(app, JSON.stringify(merged));
await push(app);
return JSON.stringify(merged) !== before;
}
async function push(app) {
const s = read(app); if (!s) return;
const body = JSON.stringify(strip(app, s));
if (body === last[app]) return;
try { await call('/state/' + app, { method: 'PUT', body }); last[app] = body; } catch (e) {}
}
const ready = (async () => {
try {
const [cfg, me] = await Promise.all([call('/config'), call('/me')]);
info.paywall = !!cfg.paywall; info.freeLessons = cfg.freeLessons || 5;
info.signedIn = !!me.signedIn; info.access = me.access || info.access; info.profiles = me.profiles || [];
keep();
if (!info.signedIn) return info;
let changed = await syncReaders();
for (const app of ['abcbooks', 'rays']) changed = (await syncApp(app)) || changed;
// the app read its settings before this finished: reload once so it shows the merged names and stars
if (changed && !sessionStorage.getItem('abcSynced')) { sessionStorage.setItem('abcSynced', '1'); location.reload(); }
} catch (e) {} // offline: keep the last known answer
return info;
})();
// keep the account copy up to date while the app is used
ready.then(i => {
if (!i.signedIn) return;
setInterval(() => { push('abcbooks'); push('rays'); }, 60000);
document.addEventListener('visibilitychange', () => { if (document.hidden) { push('abcbooks'); push('rays'); } });
});
// series: 'readers' (ABC Books) or 'math' (Ray's); index = the lesson's place in its book, from 0
const locked = (series, index) => !!info.paywall && !info.access[series] && index >= info.freeLessons;
// the screen a child sees at the first locked lesson
function unlockScreen(series) {
const old = document.getElementById('abcUnlock'); if (old) old.remove();
const d = document.createElement('div'); d.id = 'abcUnlock';
d.innerHTML = `<div class="abcU"><div class="abcUi">⭐</div><h2>You finished the free lessons!</h2>
<p>Ask a grown-up to unlock all the ${series === 'math' ? "Ray's Arithmetic" : 'ABC Books'} lessons.</p>
<a class="abcUb" href="/account/">Grown-ups: unlock</a><button class="abcUc" type="button">Not now</button></div>`;
const st = document.createElement('style');
st.textContent = '#abcUnlock{position:fixed;inset:0;background:rgba(0,0,0,.75);display:flex;align-items:center;justify-content:center;z-index:9999;font-family:Georgia,serif}' +
'.abcU{background:#1d2430;color:#f3efe6;border-radius:18px;padding:28px;max-width:420px;text-align:center;box-shadow:0 8px 30px #000}' +
'.abcUi{font-size:56px}.abcU h2{margin:.3em 0}.abcU p{font-size:1.15em}.abcUb{display:inline-block;background:#3a8f5a;color:#fff;text-decoration:none;padding:12px 22px;border-radius:12px;font-size:1.15em;margin:8px}' +
'.abcUc{background:none;border:2px solid #7a8496;color:#f3efe6;padding:10px 18px;border-radius:12px;font-size:1em;margin:8px}';
d.appendChild(st); document.body.appendChild(d);
d.querySelector('.abcUc').onclick = () => d.remove();
}
return { ready, locked, unlockScreen, get signedIn() { return info.signedIn; }, get info() { return info; } };
})();
EOF
welcome.html: the landing page at /: what the site is, why it exists, the two series, pricing and questions.
cat > /root/abc-account-rebuild/welcome.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script>
// abcbooks.fyi/ is this welcome page. The installed app (home-screen app, the Fire tablet app's WebView) opens
// abcbooks.fyi/ too: send it straight to the readers.
if (location.pathname === '/' && (matchMedia('(display-mode: standalone)').matches || /; wv\)/.test(navigator.userAgent))) location.replace('/readers/');
</script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ABC Books: classic readers and math for kids</title>
<meta name="description" content="The McGuffey Readers and Ray's Arithmetic as read-aloud games for children. Tracing, spoken reading checks, stars and stickers. Try the first lessons free.">
<link rel="icon" href="/icon-192.png">
<link rel="stylesheet" href="/account/account.css">
</head>
<body>
<header class="top"><a href="/" class="brand">ABC Books</a><nav><a href="/readers/">Readers</a><a href="/math/">Math</a><a href="#pricing">Pricing</a><a href="/account/">Sign in</a></nav></header>
<main>
<section class="hero">
<h1>Learn to read and do math with the books that taught America</h1>
<p>The McGuffey Readers and Ray's Arithmetic, turned into friendly lessons that read aloud, listen, and cheer. Made for children ages 4 to 14, and for grown-ups learning alongside them.</p>
<p class="go" style="justify-content:center"><a class="big" href="/readers/">Try the readers free</a> <a class="big" href="/math/">Try the math free</a></p>
<p class="small">No sign-up needed for the free lessons. <span id="freeNote"></span></p>
</section>
<h2>What this is, and why</h2>
<p>ABC Books started as a homeschool project: a parent turning the books that taught generations of American
children into something their own kids could use on a tablet. The McGuffey Readers and Ray's Arithmetic are
simple, steady and proven. Each lesson builds on the last, with real stories and real practice and no fluff.</p>
<p>What the old books couldn't do was read to a child, listen to them read back, or show them how to trace a letter.
That's what this site adds: every word read aloud, a reading check that listens, tracing for print and cursive,
worked math solutions, and stars and stickers to keep going.</p>
<p>Our goal is to give every family a complete, affordable path from the first letters to grade-8 math, and on
into algebra and geometry, without ads, tracking or screen-time tricks. The first lessons of every book stay free.
Subscriptions keep the site running and pay for the next books.</p>
<h2>Two complete series</h2>
<div class="cards">
<div class="card">
<h3>ABC Books: the McGuffey Readers</h3>
<p>Primer through Sixth Reader, plus the Speller. Each lesson: new words, spelling, listening to the story, reading it aloud with a microphone check, talking about it, and tracing print and cursive letters.</p>
</div>
<div class="card">
<h3>Ray's Arithmetic</h3>
<p>From counting to percentages, interest and roots, grades 1 to 8. Every problem from the original books, read aloud, with a number pad, worked solutions and a spoken-answer option.</p>
</div>
<div class="card">
<h3>One family account</h3>
<p>Add each child (and yourself). Names, stars and stickers follow them between the readers and the math, on every phone, tablet and computer you sign in on.</p>
</div>
<div class="card">
<h3>See how they're doing</h3>
<p>Your account page shows the lessons each child finished, their stars, and where they need a little help, like tracing or reading aloud.</p>
</div>
</div>
<div class="noapp">
<h2 id="pricing">Pricing</h2>
<p>Try the first lessons of every book free. To unlock everything, choose a plan. One plan covers your whole family. Cancel any time.<span id="trialNote"></span></p>
<div class="plans">
<div class="plan"><h3>Readers</h3><p class="price"><span data-p="readers-year">$40</span> <span class="small">per year</span></p><p class="small">or <span data-p="readers-month">$5</span> per month</p><p class="small">All McGuffey Readers and the Speller</p></div>
<div class="plan"><h3>Math</h3><p class="price"><span data-p="math-year">$40</span> <span class="small">per year</span></p><p class="small">or <span data-p="math-month">$5</span> per month</p><p class="small">All Ray's Arithmetic books</p></div>
<div class="plan best"><h3>Readers + Math</h3><p class="price"><span data-p="both-year">$65</span> <span class="small">per year</span></p><p class="small">or <span data-p="both-month">$8</span> per month</p><p class="small">Best value: everything, for the whole family</p></div>
</div>
<p><a class="big" href="/account/">Create your family account</a></p>
<p class="small" id="soon"></p>
</div>
<h2>Questions</h2>
<p><b>Do I need a password?</b> No. Type your email and we send you a code to sign in.</p>
<p><b>Are recordings kept?</b> No. The reading check listens on our own server and keeps no audio. See the <a href="/privacy.html">privacy page</a>.</p>
<p><b>What ages is it for?</b> The Primer and Ray's Book 1 start at about age 4 to 5. The Sixth Reader and Ray's Book 4 suit ages 12 to 14 and adults.</p>
<p><b>Is there an app?</b> It works in any web browser, and you can add it to your home screen. A Fire tablet app is on the way.</p>
</main>
<footer class="foot"><a href="/privacy.html">Privacy</a> · <a href="mailto:support@abcbooks.fyi">support@abcbooks.fyi</a></footer>
<script>
// inside the Fire tablet app (Android WebView): no prices or buying, which the app store doesn't allow
if (/; wv\)/.test(navigator.userAgent)) { document.documentElement.classList.add('inapp'); document.querySelector('a[href="#pricing"]').remove(); }
fetch('/account/api/config').then(r => r.json()).then(c => {
for (const el of document.querySelectorAll('[data-p]')) if (c.prices && c.prices[el.dataset.p]) el.textContent = c.prices[el.dataset.p];
if (c.paywall) document.getElementById('freeNote').textContent = `The first ${c.freeLessons} lessons of every book are free.`;
if (c.trialDays) document.getElementById('trialNote').textContent = ` New families get a ${c.trialDays}-day free trial.`;
if (!c.payments) document.getElementById('soon').textContent = 'Subscriptions open soon. For now every lesson is free while we test.';
}).catch(() => {});
</script>
</body>
</html>
EOF
host.sh: runs on the Proxmox host. Creates CT 308 if missing, installs or updates the service, then updates CT 108's routes (validated before the swap, old file kept as Caddyfile.bak-abc-account).
cat > /root/abc-account-rebuild/host.sh <<'EOF'
#!/bin/bash
# Runs on the Proxmox host as a background service, so a dropped SSH session can't stop it:
# systemd-run --unit abc-account-deploy bash /mnt/pve/pc-backups/abc-account/host.sh
# 1) creates CT 308 abc-account if it doesn't exist, 2) installs/updates the account service from acct.tar,
# 3) adds the /account/* route to abcbooks.fyi on CT 108 (Caddy reverse proxy) if it isn't there yet.
# Progress goes to host.log next to this script (on the PC: D:\Proxmox-backups\abc-account\host.log).
S=/mnt/pve/pc-backups/abc-account
exec >"$S/host.log" 2>&1
echo "start $(date)"
trap 'echo "ABC-FAILED at line $LINENO $(date)"' ERR
set -ex
CT=308
T=local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst
if ! pct status $CT; then
pct create $CT $T --hostname abc-account --cores 1 --memory 512 --swap 512 --rootfs local-lvm:4 \
--net0 name=eth0,bridge=vmbr0,ip=dhcp --unprivileged 1 --features nesting=1 --onboot 1 --ostype debian \
--timezone America/New_York
fi
pct start $CT || true
for i in $(seq 30); do pct exec $CT -- hostname -I | grep -q '[0-9]' && break; sleep 2; done
pct push $CT $S/acct.tar /root/acct.tar
pct push $CT $S/setup-308.sh /root/setup-308.sh
pct exec $CT -- bash /root/setup-308.sh
ACCT=$(pct exec $CT -- hostname -I | awk '{print $1}')
# edge route: inside the abcbooks.fyi block, before its catch-all handle (handle, never route)
mkdir -p /root/abc-account
pct pull 108 /etc/caddy/Caddyfile /root/abc-account/108_Caddyfile
WEB=$(pct exec 306 -- hostname -I | awk '{print $1}')
SPEECH=$(pct exec 307 -- hostname -I | awk '{print $1}')
python3 $S/add-route.py /root/abc-account/108_Caddyfile "$ACCT" "$WEB" "$SPEECH"
if ! cmp -s /root/abc-account/108_Caddyfile /root/abc-account/108_Caddyfile.orig; then
pct push 108 /root/abc-account/108_Caddyfile /etc/caddy/Caddyfile.new
pct exec 108 -- caddy validate --config /etc/caddy/Caddyfile.new --adapter caddyfile
pct exec 108 -- cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak-abc-account
pct exec 108 -- mv /etc/caddy/Caddyfile.new /etc/caddy/Caddyfile
pct exec 108 -- systemctl reload caddy
echo "edge route added"
fi
sleep 2
curl -fsS https://abcbooks.fyi/account/api/health --resolve abcbooks.fyi:443:$(pct exec 108 -- hostname -I | awk '{print $1}')
echo
echo "ABC-DONE $(date)"
EOF
setup-308.sh: runs inside CT 308: Python, the venv, the abc-account service, the abc-manage command and the nightly database copy.
cat > /root/abc-account-rebuild/setup-308.sh <<'EOF_OUTER1'
#!/bin/bash
# Runs INSIDE CT 308 (abc-account) as root. Installs or updates the account service (Flask + gunicorn, port 8000)
# from /root/acct.tar (server/ + static/). Safe to run again: config.env and the database are never replaced.
set -e
export DEBIAN_FRONTEND=noninteractive
B=/opt/abc-account
if [ ! -x $B/venv/bin/gunicorn ]; then
apt-get update -q
apt-get install -y -q python3 python3-venv sqlite3 curl
id abc >/dev/null 2>&1 || useradd --system --home $B --shell /usr/sbin/nologin abc
mkdir -p $B
python3 -m venv $B/venv
$B/venv/bin/pip install -q flask gunicorn stripe
fi
mkdir -p /tmp/acct $B/data $B/static
tar -xf /root/acct.tar -C /tmp/acct --no-same-owner
install -m 644 /tmp/acct/server/app.py /tmp/acct/server/manage.py $B/
install -m 644 /tmp/acct/static/* $B/static/
if [ ! -f $B/config.env ]; then install -m 600 /tmp/acct/server/config.env.example $B/config.env; fi
install -m 644 /tmp/acct/server/config.env.example $B/config.env.example
chown -R abc:abc $B
chmod 600 $B/config.env
unlink /root/acct.tar
# admin commands run as the service user, with the same settings, so the database files keep the right owner
cat > /usr/local/bin/abc-manage <<'EOF'
#!/bin/bash
# usage: abc-manage <command> ... (see /opt/abc-account/manage.py)
set -a; . /opt/abc-account/config.env; set +a
cd /opt/abc-account
exec runuser -u abc -- /opt/abc-account/venv/bin/python manage.py "$@"
EOF
chmod 755 /usr/local/bin/abc-manage
cat > /etc/systemd/system/abc-account.service <<'EOF'
[Unit]
Description=ABC Books accounts (abcbooks.fyi/account)
After=network-online.target
[Service]
User=abc
WorkingDirectory=/opt/abc-account
EnvironmentFile=/opt/abc-account/config.env
ExecStart=/opt/abc-account/venv/bin/gunicorn -w 2 -b 0.0.0.0:8000 app:app
Restart=always
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/abc-account/data
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
# nightly copy of the database (one per weekday, so 7 are kept), on top of the Proxmox CT backups
cat > /etc/cron.d/abc-account-backup <<'EOF'
15 2 * * * abc sqlite3 /opt/abc-account/data/accounts.db ".backup /opt/abc-account/data/backup-$(date +\%a).db"
EOF
systemctl daemon-reload
systemctl enable abc-account
systemctl restart abc-account
sleep 3
curl -fsS http://127.0.0.1:8000/account/api/health
echo
EOF_OUTER1
add-route.py: adds the layout above to the abcbooks.fyi block of CT 108's Caddyfile. Each part is added once; it works with tabs or spaces.
cat > /root/abc-account-rebuild/add-route.py <<'EOF'
# Adds the ABC Books site layout to the abcbooks.fyi block of CT 108's Caddyfile (run on the Proxmox host by host.sh):
# / the welcome page (CT 308 abc-account, /account/welcome)
# /account/* the account service (CT 308)
# /readers/* ABC Books (CT 306 abc-web, its top-level files; prefix stripped); its reading check /readers/api/* = CT 307
# /math/* Ray's Arithmetic (CT 306, its /rays/ folder); old /rays/ addresses redirect here
# usage: python3 add-route.py <Caddyfile> <CT 308 address> <CT 306 address> <CT 307 address>
# Keeps the original as <Caddyfile>.orig. Each part is added once (found by its marker); the CT 308 address is updated.
import re, shutil, sys
path, acct, web, speech = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
shutil.copy(path, path + '.orig')
lines = open(path).read().split('\n')
start = next((i for i, l in enumerate(lines) if re.match(r'^abcbooks\.fyi\s*\{', l)), None)
if start is None:
sys.exit('no abcbooks.fyi block in the Caddyfile')
end = next(i for i in range(start + 1, len(lines)) if lines[i].startswith('}'))
block = '\n'.join(lines[start:end])
# keep the account route pointed at CT 308's current address
for i in range(start, end):
if '/account/*' in lines[i - 1]:
lines[i] = re.sub(r'(reverse_proxy )[\d.]+(:8000)', r'\g<1>' + acct + r'\2', lines[i])
PARTS = [
('/account/*', ['# ABC Books accounts (CT 308 abc-account)',
'redir /account /account/ permanent',
'handle /account/* {', '\treverse_proxy ' + acct + ':8000', '}']),
('/readers/*', ['# the welcome page at / (CT 308); the readers at /readers/ and the math at /math/ (CT 306)',
'handle / {', '\trewrite * /account/welcome', '\treverse_proxy ' + acct + ':8000', '}',
'redir /readers /readers/ permanent',
'handle_path /readers/api/* {', '\trequest_body {', '\t\tmax_size 2MB', '\t}', '\treverse_proxy ' + speech + ':8000', '}',
'handle_path /readers/* {', '\treverse_proxy ' + web + ':80', '}',
'redir /math /math/ permanent',
'handle_path /math/* {', '\trewrite * /rays{uri}', '\treverse_proxy ' + web + ':80', '}',
'redir /rays /math/ permanent',
'handle_path /rays/* {', '\tredir * /math{uri} permanent', '}']),
]
cands = [i for i in range(start + 1, end) if re.match(r'^(\s+)handle\s*\{\s*$', lines[i])]
if not cands:
print(block)
sys.exit('no catch-all "handle {" in the abcbooks.fyi block')
at = cands[-1]
ind = re.match(r'^(\s+)', lines[at]).group(1)
step = '\t' if ind.startswith('\t') else ' '
add = []
for marker, part in PARTS:
if marker not in block:
add += [ind + l.replace('\t', step) for l in part]
lines[at:at] = add
open(path, 'w').write('\n'.join(lines))
print('ok, added %d lines' % len(add))
EOF
deploy.ps1: runs on the PC. Sends everything through the PC share the Proxmox host already mounts for backups (D:\Proxmox-backups = /mnt/pve/pc-backups), starts host.sh there as a background service, and shows its log.
cat > /root/abc-account-rebuild/deploy.ps1 <<'EOF'
# Deploys ABC Books accounts to CT 308 (abc-account) through the PC share the Proxmox host mounts for backups
# (D:\Proxmox-backups = /mnt/pve/pc-backups on the host). One short `ssh -tt` starts proxmox/host.sh on the host
# as a background service; this script follows its log on the share.
# Usage: powershell -ExecutionPolicy Bypass -File ABCAccounts\tools\deploy.ps1
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$share = 'D:\Proxmox-backups\abc-account'
New-Item -ItemType Directory -Force $share | Out-Null
foreach ($s in 'host.sh', 'setup-308.sh', 'add-route.py') { # Linux line endings
[IO.File]::WriteAllText("$share\$s", ([IO.File]::ReadAllText("$root\proxmox\$s") -replace "`r`n", "`n"))
}
$tmp = Join-Path $env:TEMP 'acct-stage'
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force "$tmp\server", "$tmp\static" | Out-Null
foreach ($f in Get-ChildItem "$root\server\*.py", "$root\server\config.env.example", "$root\static\*") {
$dst = if ($f.DirectoryName -like '*static') { "$tmp\static\$($f.Name)" } else { "$tmp\server\$($f.Name)" }
[IO.File]::WriteAllText($dst, ([IO.File]::ReadAllText($f.FullName) -replace "`r`n", "`n"))
}
tar -cf "$share\acct.tar" -C $tmp server static
Remove-Item "$share\host.log" -ErrorAction SilentlyContinue
"true`nsystemd-run --unit abc-account-deploy-$(Get-Date -Format HHmmss) bash /mnt/pve/pc-backups/abc-account/host.sh`nexit`n" |
ssh -o BatchMode=yes -o ConnectTimeout=15 -tt proxmox 2>&1 | Select-String 'Running as unit|Failed' | ForEach-Object { $_.Line.Trim() }
$seen = 0
for ($i = 0; $i -lt 180; $i++) { # follow the log for up to 30 minutes
Start-Sleep 10
if (-not (Test-Path "$share\host.log")) { continue }
$log = @(Get-Content "$share\host.log")
$log | Select-Object -Skip $seen; $seen = $log.Count
if ($log -match '^ABC-(DONE|FAILED)') { break }
}
EOF
The apps' own changes for accounts (the /account/sync.js script tag, the 🔒 on paid lessons, "Add or remove learners", adding a learner on the math page, the moved offline helpers) are in the current files of the ABC Books post and the Ray's Arithmetic post.
Rebuild, step by step
1. Have the ABC Books containers
CT 306, CT 307 and the abcbooks.fyi block on CT 108 come from the abcbooks.fyi production post. Deploy both apps from their posts first.
2. Deploy the accounts
PC (PowerShell). The first run creates CT 308 (about 1 minute) and ends with {"ok":true} and "ABC-DONE":
powershell -ExecutionPolicy Bypass -File C:\ABCAccounts\tools\deploy.ps1
Run the same command after any change to the files; it only updates what changed and never touches the settings or the database.
3. Check it
Open https://abcbooks.fyi/ (the welcome page), then https://abcbooks.fyi/account/ and sign in. Until email is set up, get the sign-in code on the Proxmox host (Proxmox web UI, pve, Shell):
pct exec 308 -- /usr/local/bin/abc-manage link you@example.com
It prints a link and a code; open the link, or type the email and the code on the page. Add a learner, then open https://abcbooks.fyi/math/ : the name is there.
Admin commands
All on the Proxmox host shell. abc-manage runs as the service's own user, so the database files keep the right owner. Use the full path; pct exec doesn't search /usr/local/bin.
Give a tester free access to both series:
pct exec 308 -- /usr/local/bin/abc-manage comp tester@example.com both tester
Make a code for 10 testers, good for 90 days:
pct exec 308 -- /usr/local/bin/abc-manage promo both 90 10 testers
List families, codes and free-access emails:
pct exec 308 -- /usr/local/bin/abc-manage families
pct exec 308 -- /usr/local/bin/abc-manage promos
pct exec 308 -- /usr/local/bin/abc-manage comps
Switch the free-lesson limit on (or off):
pct exec 308 -- /usr/local/bin/abc-manage paywall on
Switching on email and Stripe later
Both are only settings. Edit them in CT 308's console (Proxmox web UI, 308 (abc-account), Console):
nano /opt/abc-account/config.env
systemctl restart abc-account
- Email: fill in
SMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASSandMAIL_FROMfrom the mail provider (Amazon SES, Resend, Postmark...). The sender's domain needs the provider's DNS records (SPF/DKIM) in Route 53. - Stripe: use a Stripe account for ABC Books only. In test mode, create one product per plan with a monthly or yearly price, and paste the price ids (
price_...) intoPRICE_*, and the secret key intoSTRIPE_SECRET. Add a webhook endpointhttps://abcbooks.fyi/account/api/stripe-webhookfor the eventscheckout.session.completed,customer.subscription.created,customer.subscription.updatedandcustomer.subscription.deleted, and paste its signing secret intoSTRIPE_WEBHOOK_SECRET. Turn on the customer portal in Stripe's settings. The Subscribe buttons appear by themselves. Going live is the same with the live keys and prices.
Troubleshooting (where every log lives)
The account service (CT 308). Proxmox web UI, 308 (abc-account), Console:
journalctl -u abc-account -n 100 --no-pager
- Healthy:
Booting worker with pidtwice after a restart, nothing else. There is no access log on purpose (the privacy page promises no visit logs). - Problems: a Python
Traceback(the line names the cause),sqlite3.OperationalError: attempt to write a readonly database(the database files belong to root: someone ranmanage.pydirectly instead ofabc-manage; fix withchown -R abc:abc /opt/abc-account/data),smtpliberrors (wrong mail settings).
Quick check from the Proxmox host shell:
pct exec 308 -- curl -s http://127.0.0.1:8000/account/api/health
{"ok":true} means the service and its database are fine.
The deploy. The log is D:\Proxmox-backups\abc-account\host.log on the PC. It ends with ABC-DONE when everything worked, or ABC-FAILED at line N; the lines just above say why.
The routes (CT 108). Proxmox web UI, 108, Console:
journalctl -u caddy -n 50 --no-pager
dial tcp ... connection refusedon/accountmeans CT 308 is down or got a new address: rundeploy.ps1again, which points the route at the current address.- To undo the last route change:
cp /etc/caddy/Caddyfile.bak-abc-account /etc/caddy/Caddyfilethensystemctl reload caddy.
In the browser. A page that still shows the old layout is an old offline copy: reload twice. Learner names that don't match the account: sign out and in on the account page, then reload the app.
Uptime Kuma: no monitor yet. When one is added it should watch https://abcbooks.fyi/account/api/health for "ok":true on https://status.503mail.com .
Things learned on the way
- Paths, not subdomains. Saved progress belongs to one website (origin). Keeping the readers and the math on abcbooks.fyi is what lets one list of names serve both.
- Relative addresses move with the page. ABC Books calls its reading check as
api/recognize, which at/readers/becomes/readers/api/recognize, so that needed its own route. - The old offline helper. ABC Books' service worker used to cover the whole site (
/) and would have kept serving the old readers page at/. The samesw.jsfile still answers at/sw.js, and when it sees its scope is/it removes itself. - The Fire app still opens
/. Old builds of the app load abcbooks.fyi, so the welcome page sends a WebView or home-screen app straight to/readers/; new builds open/readers/directly. - Prices with
$in the settings file must be in single quotes (SHOW_BOTH_YEAR='$65'), or the shell thatabc-manageuses reads$65as a variable. - Match the Caddyfile's own indentation. The first version of
add-route.pylooked for a catch-allhandle {indented with four spaces, didn't find it in CT 108's file, and stopped (safely, before changing anything). It now finds the block however it is indented and copies that indentation.