end-to-end CI to match containerization on rest of adipu_server
All checks were successful
Deploy to Server / deploy (push) Successful in 18s

This commit is contained in:
2026-05-05 00:10:39 +00:00
parent 3da410770a
commit 7da8bda5eb
16 changed files with 1814 additions and 3061 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules/
.git/
.github/
.gitea/
.env
*.log
npm-debug.log*
.dockerignore
Dockerfile*
docker-compose*.yml
readme.md

View File

@@ -0,0 +1,66 @@
name: Deploy to Server
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest # Gitea runner label
container:
image: alpine:latest # Alpine to keep CPU/memory low
steps:
- name: Install SSH and Networking Tools
# 'apk add' is the Alpine equivalent of 'apt-get install'
run: apk add --no-cache openssh-client iproute2 git
- name: Configure SSH Key
run: |
mkdir -p ~/.ssh
# Gitea uses ${{ secrets.SECRET_NAME }} syntax
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
echo "StrictHostKeyChecking no" > ~/.ssh/config
- name: Execute Remote Deployment
run: |
# 1. Find the host machine's IP via the docker bridge gateway
HOST_IP=$(ip route | awk '/default/ { print $3 }')
echo "==> Detected Host IP: $HOST_IP"
# 2. SSH into the host to execute the deployment safely
ssh adipu@$HOST_IP << 'EOF'
# Exit immediately if any command fails
set -e
echo "==> Navigating to project directory..."
cd ~/blinds_express
echo "==> Pulling latest code..."
git pull origin main
echo "==> Updating Node version for Docker..."
NODE_VER=$(cat .node-version)
# Ensure a trailing newline exists before appending
echo "" >> .env
sed -i '/^NODE_VERSION=/d' .env || true
echo "NODE_VERSION=${NODE_VER}-alpine" >> .env
echo "==> Running Build..."
# If this build fails, 'set -e' aborts the script instantly.
# Existing containers will NOT be touched, keeping the API up.
docker compose build
echo "==> Build successful! Deploying new containers..."
# This only runs if the build was 100% successful.
# `up -d` recreates only the services whose image/config changed,
# and the backend's CMD applies db/schema.sql before starting,
# so the schema stays current on every push.
docker compose up -d
echo "==> Cleaning up old images to save disk space..."
docker image prune -f
echo "==> Deployment Complete!"
EOF

View File

@@ -1,32 +0,0 @@
name: Deploy to DigitalOcean
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.SSH_KEY }}
port: 22
script: |
# 1. Go to your app folder (CHANGE THIS PATH to match yours)
cd /root/blinds_express
# 2. Get latest code
git pull origin main
# 3. Install new dependencies if any
npm install --production
# 4. Restart the app (Assuming you use PM2)
pm2 restart all
# If you are NOT using PM2 yet, standard node won't work well here.
# I highly recommend installing PM2: `npm install -g pm2` on the server.

2
.gitignore vendored
View File

@@ -71,3 +71,5 @@ node_modules/
.DS_Store .DS_Store
previews/ previews/
*.p8

1
.node-version Normal file
View File

@@ -0,0 +1 @@
22.11.0

208
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,208 @@
# blinds_express — Architecture & Design Record
## What this is
Node.js/Express API for the BlindMaster smart blind controller. Manages users,
devices, peripherals, groups, scheduled jobs, email auth, and real-time
WebSocket events. Served at `blindmaster.wahwa.com` via Cloudflare Tunnel.
---
## Infrastructure overview
```
Cloudflare Tunnel ──► 127.0.0.1:3002 (backend container)
┌─────────────┴──────────────┐
▼ ▼
postgres:5432 (bridge) mongo:27017 (bridge)
postgres_data volume mongo_data volume
```
All three containers live on a Docker bridge network created by Compose. Nothing
is exposed on a host port except the backend's `127.0.0.1:3002` loopback bind.
---
## Key design decisions
### Cloudflare Tunnel replaces nginx
Express serves the static `public/` directory via `express.static`, handles all
API routes, and upgrades WebSocket connections. A Cloudflare Tunnel pointing
directly at `http://127.0.0.1:3002` is sufficient — nginx is not needed.
WebSocket proxying must be enabled in the Cloudflare dashboard for the tunnel.
### Dedicated postgres container (not host postgres)
The host runs a native `postgresql@16-main.service` on `127.0.0.1:5432` used by
other services. LabWise also runs its own `labwise-postgres-1` container, but
that container does **not** expose a host port — it lives on LabWise's bridge
network only. A third, isolated postgres container for blinds_express costs
~150 MB RAM but gives clean isolation, easy `pg_dump` migration, and full
parity with LabWise's container model.
To migrate existing data from the host postgres:
```bash
pg_dump -U <host_user> blinds_db | \
docker compose exec -T postgres psql -U blinds blinds_db
```
### Idempotent schema migration
`db/schema.sql` uses `CREATE TABLE IF NOT EXISTS` for every table and a tail
block of `ALTER TABLE … ADD COLUMN IF NOT EXISTS` for columns added after
initial creation. `db/migrate.js` runs this file on every container start via
the Dockerfile CMD:
```
CMD ["sh", "-c", "node db/migrate.js && node index.js"]
```
If the build fails, `docker compose build` exits non-zero and `docker compose up`
is never reached — existing containers keep running. This matches LabWise's
migration pattern exactly.
### APNs push notifications (no third-party library)
Replaced Firebase Admin (`firebase-admin`) with hand-rolled APNs over HTTP/2
using only Node built-ins (`http2`, `crypto`). The reference implementation was
`~/LockInBroAPI/app/services/push.py`, which was known-working.
Critical detail: Node's `crypto.sign` defaults to DER encoding for ECDSA. APNs
requires **P1363 (IEEE) format** (raw r‖s concatenation). Fixed with:
```js
crypto.sign('sha256', Buffer.from(signingInput), {
key: keyObj,
dsaEncoding: 'ieee-p1363',
});
```
JWT is cached 55 minutes (APNs invalidates tokens older than 60 min). HTTP/2
session is opened per request (connection reuse is a future optimisation).
A `410 Unregistered` response means the device token is dead — the handler
nulls it in the database immediately:
```js
if (result.status === 410 && pool) {
await pool.query("UPDATE users SET apns_token=NULL WHERE apns_token=$1", [token]);
}
```
Env vars (matching LockInBroAPI naming):
- `APNS_KEY_ID` — the 10-char key ID from the `.p8` filename
- `APNS_TEAM_ID` — Apple Developer team ID
- `APNS_P8_PATH` — container-internal path to the key file (e.g. `/app/AuthKey.p8`)
- `APPLE_BUNDLE_ID` — app bundle identifier
- `APNS_SANDBOX``true` for development, omit or `false` for production
### APNs key file mounting
The `.p8` file is never committed (`.gitignore` includes `*.p8`). It is mounted
read-only into the container via a Compose volume:
```yaml
- ${APNS_P8_HOST_PATH:-./AuthKey.p8}:${APNS_P8_PATH:-/app/AuthKey.p8}:ro
```
Set both in `.env`:
- `APNS_P8_HOST_PATH=./AuthKey_A7ASKB9B7V.p8` (actual filename on the host)
- `APNS_P8_PATH=/app/AuthKey.p8` (path inside the container, read by `push.js`)
### Mailgun replaces AWS SES
Removed `@aws-sdk/client-sesv2` and `nodemailer`; added `mailgun.js` + `form-data`.
`mailer.js` uses `mg.messages.create()` matching LabWise's `email.ts` pattern.
Verification emails link to `blindmaster.wahwa.com`.
### NODE_VERSION build arg
`.node-version` contains the bare version (`22.11.0`). The Gitea Actions workflow
appends `-alpine` and writes it into `.env` before `docker compose build`:
```yaml
NODE_VER=$(cat .node-version)
sed -i '/^NODE_VERSION=/d' .env || true
echo "NODE_VERSION=${NODE_VER}-alpine" >> .env
docker compose build
```
`Dockerfile.backend` consumes it as a build arg:
```dockerfile
ARG NODE_VERSION=22-alpine
FROM node:${NODE_VERSION}
```
### CI/CD (Gitea Actions)
`.gitea/workflows/deploy.yaml` mirrors LabWise's workflow exactly:
1. Alpine runner container with `openssh-client`, `iproute2`, `git`
2. SSH key from `secrets.DEPLOY_SSH_KEY``~/.ssh/id_rsa`
3. Host IP discovered from the Docker bridge gateway (`ip route | awk '/default/'`)
4. SSH into host, `git pull`, patch `NODE_VERSION` in `.env`, `docker compose build`,
`docker compose up -d`, `docker image prune -f`
`set -e` in the remote SSH block means a failed build aborts before `up -d`,
leaving existing containers untouched.
---
## Dependencies removed
| Package | Reason |
|---|---|
| `firebase-admin` | Replaced by hand-rolled APNs in `push.js` |
| `@aws-sdk/client-sesv2` | Replaced by Mailgun |
| `nodemailer` | No longer needed |
## Dependencies added
| Package | Reason |
|---|---|
| `mailgun.js` | Mailgun API client |
| `form-data` | Required peer dep of `mailgun.js` |
| `cron-parser` | Already used internally, made explicit |
---
## Files added / changed
| File | Status | Notes |
|---|---|---|
| `Dockerfile.backend` | New | Alpine Node image, production deps only |
| `docker-compose.yml` | New | postgres + mongo + backend |
| `.gitea/workflows/deploy.yaml` | New | Replaces `.github/workflows/deploy.yml` |
| `.github/workflows/deploy.yml` | Deleted | Replaced by Gitea Actions |
| `db/schema.sql` | New | Full idempotent schema |
| `db/migrate.js` | New | Reads schema.sql, runs via pg Pool |
| `mailer.js` | Rewritten | SES/nodemailer → Mailgun |
| `push.js` | New | Hand-rolled APNs, no library |
| `index.js` | Modified | FCM → APNs, port from env, listen on 0.0.0.0 |
| `db.js` | Modified | Mongo URI from `MONGO_URI` env var |
| `package.json` | Modified | Deps swapped, `"start"` script added |
| `.env.example` | New | Documents all required env vars |
| `.dockerignore` | New | Excludes node_modules, .env, .git, etc. |
| `.node-version` | New | `22.11.0` — consumed by deploy workflow |
---
## Port reference
| Service | Host binding | Notes |
|---|---|---|
| Backend API | `127.0.0.1:3002` | Cloudflare Tunnel → here |
| postgres | none | Bridge-only |
| mongo | none | Bridge-only |
| Gitea | `:3000` | Separate service, not part of this compose |
---
## First-time setup checklist
1. Copy `.env.example``.env` and fill in all values
2. Set `APNS_P8_HOST_PATH` to the actual `.p8` filename (e.g. `./AuthKey_A7ASKB9B7V.p8`)
3. Place the `.p8` file in the project root (it is gitignored)
4. Add `DEPLOY_SSH_KEY` (private key for `adipu@host`) to Gitea repository secrets
5. Point the Cloudflare Tunnel for `blindmaster.wahwa.com``http://127.0.0.1:3002`
6. Enable WebSocket proxying in Cloudflare dashboard for that tunnel
7. Push to `main` — the Gitea workflow builds, migrates, and starts everything

16
Dockerfile.backend Normal file
View File

@@ -0,0 +1,16 @@
ARG NODE_VERSION=22-alpine
FROM node:${NODE_VERSION}
WORKDIR /app
# Install only production deps using the lockfile so builds are deterministic.
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
# Source. (public/ is served by express.static, so it must be in the image.)
COPY index.js mailer.js push.js db.js agenda.js ./
COPY db ./db
COPY public ./public
EXPOSE 3002
# Apply the schema (idempotent) then start the server. Same pattern as LabWise.
CMD ["sh", "-c", "node db/migrate.js && node index.js"]

2
db.js
View File

@@ -3,7 +3,7 @@ const mongoose = require('mongoose');
const connectDB = async () => { const connectDB = async () => {
try { try {
const mongoUri = 'mongodb://localhost:27017/myScheduledApp'; const mongoUri = process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/myScheduledApp';
await mongoose.connect(mongoUri); await mongoose.connect(mongoUri);
console.log('MongoDB connected successfully for Mongoose!'); console.log('MongoDB connected successfully for Mongoose!');
} catch (err) { } catch (err) {

23
db/migrate.js Normal file
View File

@@ -0,0 +1,23 @@
// Idempotent schema migration. Mirrors LabWise's server/src/db/migrate.ts —
// reads schema.sql and runs it through the same env-driven pg config the app
// uses, then exits. Compose's CMD chains this before `node index.js`.
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
async function migrate() {
const sql = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf-8');
const pool = new Pool();
try {
await pool.query(sql);
console.log('[migrate] schema applied');
} finally {
await pool.end();
}
}
migrate().catch((err) => {
console.error('[migrate] failed:', err);
process.exit(1);
});

98
db/schema.sql Normal file
View File

@@ -0,0 +1,98 @@
-- BlindMaster schema. Idempotent: CREATE … IF NOT EXISTS so it works on a
-- fresh DB and replays cleanly against an existing one.
-- ── Core auth ───────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT,
email TEXT NOT NULL UNIQUE,
password_hash_string TEXT NOT NULL,
verification_token TEXT,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
timezone TEXT DEFAULT 'America/Chicago',
apns_token TEXT,
fcm_token TEXT, -- legacy (FCM era), kept harmless
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- One active session token per user. Code does delete-then-insert on login,
-- so PK on user_id enforces it.
CREATE TABLE IF NOT EXISTS user_tokens (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
connected BOOLEAN NOT NULL DEFAULT FALSE,
socket TEXT
);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
email TEXT PRIMARY KEY,
token TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_pending_emails (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
pending_email TEXT NOT NULL,
token TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
-- ── Hardware: hubs (devices) and their controlled blinds (peripherals) ──────
CREATE TABLE IF NOT EXISTS devices (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_name TEXT NOT NULL,
max_ports INTEGER NOT NULL DEFAULT 4,
battery_soc SMALLINT,
timezone TEXT,
UNIQUE (user_id, device_name)
);
-- token UNIQUE so socket-auth can `select device_id … where token=$1` quickly.
CREATE TABLE IF NOT EXISTS device_tokens (
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
connected BOOLEAN NOT NULL DEFAULT FALSE,
socket TEXT
);
CREATE INDEX IF NOT EXISTS device_tokens_device_id_idx ON device_tokens(device_id);
CREATE TABLE IF NOT EXISTS peripherals (
id SERIAL PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
peripheral_number INTEGER NOT NULL,
peripheral_name TEXT NOT NULL,
last_pos INTEGER,
last_set TIMESTAMPTZ,
calibrated BOOLEAN DEFAULT FALSE,
await_calib BOOLEAN DEFAULT FALSE,
UNIQUE (device_id, peripheral_number)
);
CREATE INDEX IF NOT EXISTS peripherals_user_id_idx ON peripherals(user_id);
-- ── Multi-blind groups (e.g. "all kitchen blinds") ──────────────────────────
CREATE TABLE IF NOT EXISTS groups (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
timezone TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, name)
);
CREATE TABLE IF NOT EXISTS group_peripherals (
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
peripheral_id INTEGER NOT NULL REFERENCES peripherals(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, peripheral_id)
);
-- ── Idempotent column additions for older DBs ───────────────────────────────
-- These mirror the runtime-bootstrap ALTERs the index.js used to do on every
-- startup. Safe on fresh DBs because the columns are already in CREATE TABLE.
ALTER TABLE users ADD COLUMN IF NOT EXISTS apns_token TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS timezone TEXT DEFAULT 'America/Chicago';
ALTER TABLE devices ADD COLUMN IF NOT EXISTS battery_soc SMALLINT;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS timezone TEXT;
ALTER TABLE groups ADD COLUMN IF NOT EXISTS timezone TEXT;

75
docker-compose.yml Normal file
View File

@@ -0,0 +1,75 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-blinds}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-blinds_db}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-blinds} -d ${POSTGRES_DB:-blinds_db}"]
interval: 5s
timeout: 5s
retries: 10
deploy:
resources:
limits:
memory: 256M
# MongoDB is only needed by Agenda for the job queue. Lives entirely inside
# this compose project; no host port exposure.
mongo:
image: mongo:7
restart: unless-stopped
command: ["--bind_ip_all", "--quiet", "--logpath", "/dev/null"]
volumes:
- mongo_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
deploy:
resources:
limits:
memory: 384M
backend:
build:
context: .
dockerfile: Dockerfile.backend
args:
- NODE_VERSION=${NODE_VERSION:-22-alpine}
restart: unless-stopped
env_file: ./.env
environment:
# Internal service-name DNS. Override anything in .env that pointed at
# 127.0.0.1 — those values were for the host-postgres era.
PGHOST: postgres
PGPORT: 5432
PGUSER: ${POSTGRES_USER:-blinds}
PGPASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: ${POSTGRES_DB:-blinds_db}
MONGO_URI: mongodb://mongo:27017/myScheduledApp
PORT: ${PORT:-3002}
# Bind only on host loopback — Cloudflare Tunnel proxies blindmaster.wahwa.com here.
ports:
- "127.0.0.1:${PORT:-3002}:${PORT:-3002}"
volumes:
# Set APNS_P8_HOST_PATH in .env to the actual filename, e.g. ./AuthKey_A7ASKB9B7V.p8
- ${APNS_P8_HOST_PATH:-./AuthKey.p8}:${APNS_P8_PATH:-/app/AuthKey.p8}:ro
depends_on:
postgres:
condition: service_healthy
mongo:
condition: service_healthy
deploy:
resources:
limits:
memory: 256M
volumes:
postgres_data:
mongo_data:

View File

@@ -1,4 +1,4 @@
const admin = require('firebase-admin'); const push = require('./push');
const { verify, hash } = require('argon2'); const { verify, hash } = require('argon2');
const express = require('express'); const express = require('express');
const { json } = require('express'); const { json } = require('express');
@@ -54,7 +54,8 @@ const wsMessageRateLimiter = new RateLimiterMemory({
const path = require('path'); const path = require('path');
const app = express(); const app = express();
const port = 3000; const port = parseInt(process.env.PORT || '3002', 10);
app.set('trust proxy', true);
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
app.use(json()); app.use(json());
@@ -79,9 +80,9 @@ const io = socketIo(server, {
let agenda; let agenda;
(async () => { (async () => {
// 1. Connect to MongoDB // 1. Connect to MongoDB (URI comes from env in containerized deploys)
await connectDB(); await connectDB();
agenda = await initializeAgenda('mongodb://localhost:27017/myScheduledApp', pool, io); agenda = await initializeAgenda(process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/myScheduledApp', pool, io);
})(); })();
(async () => { (async () => {
@@ -96,25 +97,11 @@ let agenda;
await pool.query("DELETE FROM user_pending_emails WHERE expires_at < NOW()"); await pool.query("DELETE FROM user_pending_emails WHERE expires_at < NOW()");
console.log("Cleared expired pending email changes"); console.log("Cleared expired pending email changes");
// Add battery_soc column if this is the first deploy with battery support // Schema lives in db/schema.sql and is applied by db/migrate.js before this
await pool.query("ALTER TABLE devices ADD COLUMN IF NOT EXISTS battery_soc SMALLINT"); // process starts (compose CMD chains them).
// Add fcm_token column for push notification delivery // Initialise APNs provider (lazy — push.js logs a warning if env is missing)
await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS fcm_token TEXT"); push.init();
// Add timezone support
await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS timezone TEXT DEFAULT 'America/Chicago'");
await pool.query("ALTER TABLE devices ADD COLUMN IF NOT EXISTS timezone TEXT");
await pool.query("ALTER TABLE groups ADD COLUMN IF NOT EXISTS timezone TEXT");
// Initialise Firebase Admin SDK for push notifications
if (process.env.FIREBASE_SERVICE_ACCOUNT_JSON) {
admin.initializeApp({
credential: admin.credential.cert(JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT_JSON)),
});
} else {
console.warn("FIREBASE_SERVICE_ACCOUNT_JSON not set — push notifications disabled");
}
})(); })();
const JWT_SECRET = process.env.JWT_SECRET; const JWT_SECRET = process.env.JWT_SECRET;
const TOKEN_EXPIRY = '5d'; const TOKEN_EXPIRY = '5d';
@@ -2014,8 +2001,9 @@ app.post('/update_schedule', authenticateToken, async (req, res) => {
} }
}); });
server.listen(port, '127.0.0.1', () => { const bindHost = process.env.BIND_HOST || '0.0.0.0';
console.log(`Example app listening on 127.0.0.1:${port}`); server.listen(port, bindHost, () => {
console.log(`Example app listening on ${bindHost}:${port}`);
}); });
app.post('/periph_schedule_list', authenticateToken, async (req, res) => { app.post('/periph_schedule_list', authenticateToken, async (req, res) => {
@@ -2430,12 +2418,12 @@ app.post('/update_group_schedule', authenticateToken, async (req, res) => {
} }
}); });
// Store/update the FCM token for the authenticated user // Store/update the APNs device token for the authenticated user
app.post('/register_fcm_token', authenticateToken, async (req, res) => { app.post('/register_apns_token', authenticateToken, async (req, res) => {
const { token } = req.body; const { token } = req.body;
if (!token || typeof token !== 'string') return res.sendStatus(400); if (!token || typeof token !== 'string') return res.sendStatus(400);
try { try {
await pool.query("UPDATE users SET fcm_token=$1 WHERE id=$2", [token, req.user]); await pool.query("UPDATE users SET apns_token=$1 WHERE id=$2", [token, req.user]);
res.sendStatus(204); res.sendStatus(204);
} catch { } catch {
res.sendStatus(500); res.sendStatus(500);
@@ -2477,26 +2465,27 @@ app.post('/battery_alert', authenticateToken, async (req, res) => {
io.to(rows[0].socket).emit("battery_alert", { deviceId: req.peripheral, type, soc }); io.to(rows[0].socket).emit("battery_alert", { deviceId: req.peripheral, type, soc });
} }
// FCM — background push for persistent alerts (not transient voltage dips) // APNs — background push for persistent alerts (not transient voltage dips)
const fcmPushTypes = ['overvoltage', 'critical_low', 'low_20', 'low_10']; const pushTypes = ['overvoltage', 'critical_low', 'low_20', 'low_10'];
if (fcmPushTypes.includes(type) && admin.apps.length > 0) { if (pushTypes.includes(type)) {
const { rows: fcmRows } = await pool.query( const { rows: tokenRows } = await pool.query(
"SELECT u.fcm_token FROM users u JOIN devices d ON d.user_id=u.id WHERE d.id=$1 AND u.fcm_token IS NOT NULL", "SELECT u.apns_token FROM users u JOIN devices d ON d.user_id=u.id WHERE d.id=$1 AND u.apns_token IS NOT NULL",
[req.peripheral] [req.peripheral]
); );
if (fcmRows.length === 1) { if (tokenRows.length === 1) {
const fcmContent = { const pushContent = {
overvoltage: { title: 'Battery Fault', body: 'Overvoltage detected. Please check your charger.' }, overvoltage: { title: 'Battery Fault', body: 'Overvoltage detected. Please check your charger.' },
critical_low: { title: 'Battery Critical', body: `Battery at ${soc}% — device is shutting down.` }, critical_low: { title: 'Battery Critical', body: `Battery at ${soc}% — device is shutting down.` },
low_20: { title: 'Battery Low', body: `Battery at ${soc}%. Consider charging soon.` }, low_20: { title: 'Battery Low', body: `Battery at ${soc}%. Consider charging soon.` },
low_10: { title: 'Battery Very Low', body: `Battery at ${soc}% — charge now.` }, low_10: { title: 'Battery Very Low', body: `Battery at ${soc}% — charge now.` },
}; };
const { title, body } = fcmContent[type]; const { title, body } = pushContent[type];
await admin.messaging().send({ await push.sendNotification(tokenRows[0].apns_token, {
token: fcmRows[0].fcm_token, title,
notification: { title, body }, body,
data: { type, soc: String(soc), deviceId: String(req.peripheral) }, data: { type, soc: String(soc), deviceId: String(req.peripheral) },
}).catch(err => console.error('FCM send failed:', err.message)); pool, // lets push.js scrub the token if APNs returns 410 Unregistered
}).catch(err => console.error('APNs send failed:', err.message));
} }
} }

226
mailer.js
View File

@@ -1,18 +1,29 @@
const nodemailer = require('nodemailer'); const FormData = require('form-data');
const { SESv2Client, SendEmailCommand } = require('@aws-sdk/client-sesv2'); const Mailgun = require('mailgun.js');
const sesClient = new SESv2Client({ const mailgun = new Mailgun(FormData);
region: process.env.AWS_REGION, const mg = process.env.MAILGUN_API_KEY ? mailgun.client({
credentials: { username: 'api',
accessKeyId: process.env.AWS_ACCESS_KEY_ID, key: process.env.MAILGUN_API_KEY,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, // EU customers: set MAILGUN_API_URL=https://api.eu.mailgun.net
}, url: process.env.MAILGUN_API_URL || 'https://api.mailgun.net',
}); }) : null;
// Create the transporter const DOMAIN = process.env.MAILGUN_DOMAIN;
const transporter = nodemailer.createTransport({ const FROM = process.env.EMAIL_FROM || (DOMAIN ? `BlindMaster <postmaster@${DOMAIN}>` : null);
SES: { sesClient, SendEmailCommand },
async function sendMail({ to, subject, html }) {
if (!mg || !DOMAIN) {
console.warn('[mailer] Mailgun not configured (MAILGUN_API_KEY / MAILGUN_DOMAIN missing). Skipping send:', subject);
return null;
}
return mg.messages.create(DOMAIN, {
from: `"BlindMaster" <${FROM}>`,
to: [to],
subject,
html,
}); });
}
// Helper function to get color based on time of day // Helper function to get color based on time of day
// hour parameter should be the local hour (0-23) from the client // hour parameter should be the local hour (0-23) from the client
@@ -32,11 +43,10 @@ function getColorForTime(hour) {
// Helper function to send email // Helper function to send email
async function sendVerificationEmail(toEmail, token, name, localHour = new Date().getHours()) { async function sendVerificationEmail(toEmail, token, name, localHour = new Date().getHours()) {
const primaryColor = getColorForTime(localHour); const primaryColor = getColorForTime(localHour);
const verificationLink = `https://wahwa.com/verify-email?token=${token}`; const verificationLink = `https://blindmaster.wahwa.com/verify-email?token=${token}`;
try { try {
const info = await transporter.sendMail({ const info = await sendMail({
from: `"BlindMaster" <${process.env.EMAIL_FROM}>`, // Sender address
to: toEmail, to: toEmail,
subject: "Verify your BlindMaster account", subject: "Verify your BlindMaster account",
html: ` html: `
@@ -119,10 +129,10 @@ async function sendVerificationEmail(toEmail, token, name, localHour = new Date(
</html> </html>
`, `,
}); });
console.log("Email sent successfully:", info.messageId); console.log("Verification email sent:", info?.id || '(skipped)');
return true; return true;
} catch (error) { } catch (error) {
console.error("Error sending email:", error); console.error("Error sending verification email:", error);
return false; return false;
} }
} }
@@ -132,8 +142,7 @@ async function sendPasswordResetEmail(toEmail, code, name, localHour = new Date(
const primaryColor = getColorForTime(localHour); const primaryColor = getColorForTime(localHour);
try { try {
const info = await transporter.sendMail({ const info = await sendMail({
from: `"BlindMaster" <${process.env.EMAIL_FROM}>`,
to: toEmail, to: toEmail,
subject: "Reset your BlindMaster password", subject: "Reset your BlindMaster password",
html: ` html: `
@@ -217,7 +226,7 @@ async function sendPasswordResetEmail(toEmail, code, name, localHour = new Date(
</html> </html>
`, `,
}); });
console.log("Password reset email sent successfully:", info.messageId); console.log("Password reset email sent:", info?.id || '(skipped)');
return true; return true;
} catch (error) { } catch (error) {
console.error("Error sending password reset email:", error); console.error("Error sending password reset email:", error);
@@ -225,99 +234,13 @@ async function sendPasswordResetEmail(toEmail, code, name, localHour = new Date(
} }
} }
// Helper function to generate styled HTML response for verification pages
function generateVerificationPageHTML(title, message, isSuccess = true) {
const primaryColor = '#2196F3'; // Blue theme
const icon = isSuccess ? '✓' : '✕';
const iconBg = isSuccess ? primaryColor : '#f44336';
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://fonts.googleapis.com/css2?family=ABeeZee:ital@0;1&display=swap" rel="stylesheet">
<title>${title} - BlindMaster</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f5f5f5; font-family: 'ABeeZee', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 20px; min-height: 100vh;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); overflow: hidden; max-width: 600px;">
<!-- Header with brand color -->
<tr>
<td align="center" style="background-color: ${primaryColor}; padding: 40px 20px;">
<h1 style="margin: 0; color: #ffffff; font-size: 32px; font-weight: bold; letter-spacing: 0.5px;">BlindMaster</h1>
<p style="margin: 10px 0 0 0; color: #ffffff; font-size: 14px; opacity: 0.95;">Smart Home Automation</p>
</td>
</tr>
<!-- Status Icon -->
<tr>
<td align="center" style="padding: 50px 40px 30px 40px;">
<div style="width: 80px; height: 80px; border-radius: 50%; background-color: ${iconBg}; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 30px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);">
<span style="color: #ffffff; font-size: 48px; font-weight: bold; line-height: 80px;">${icon}</span>
</div>
<h2 style="margin: 0 0 20px 0; color: #333333; font-size: 28px; font-weight: normal;">
${title}
</h2>
<p style="margin: 0; color: #666666; font-size: 16px; line-height: 1.6;">
${message}
</p>
</td>
</tr>
<!-- Divider -->
<tr>
<td style="padding: 0 40px;">
<div style="border-top: 1px solid #e0e0e0;"></div>
</td>
</tr>
<!-- Footer info -->
<tr>
<td style="padding: 30px 40px; text-align: center;">
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.5;">
You can safely close this window and return to the app.
</p>
</td>
</tr>
<!-- Footer bar -->
<tr>
<td align="center" style="background-color: #f9f9f9; padding: 25px 40px;">
<p style="margin: 0; color: #999999; font-size: 12px;">
© 2026 BlindMaster. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}
module.exports = {
sendVerificationEmail,
sendPasswordResetEmail,
sendEmailChangeVerification,
generateVerificationPageHTML
};
// Helper function to send email change verification email // Helper function to send email change verification email
async function sendEmailChangeVerification(newEmail, token, name, oldEmail, localHour = new Date().getHours()) { async function sendEmailChangeVerification(newEmail, token, name, oldEmail, localHour = new Date().getHours()) {
const primaryColor = getColorForTime(localHour); const primaryColor = getColorForTime(localHour);
const verificationLink = `https://wahwa.com/verify-email-change?token=${token}`; const verificationLink = `https://blindmaster.wahwa.com/verify-email-change?token=${token}`;
try { try {
const info = await transporter.sendMail({ const info = await sendMail({
from: `"BlindMaster" <${process.env.EMAIL_FROM}>`,
to: newEmail, to: newEmail,
subject: "Verify your new BlindMaster email address", subject: "Verify your new BlindMaster email address",
html: ` html: `
@@ -406,10 +329,95 @@ async function sendEmailChangeVerification(newEmail, token, name, oldEmail, loca
</html> </html>
`, `,
}); });
console.log("Email change verification sent successfully:", info.messageId); console.log("Email change verification sent:", info?.id || '(skipped)');
return true; return true;
} catch (error) { } catch (error) {
console.error("Error sending email change verification:", error); console.error("Error sending email change verification:", error);
return false; return false;
} }
} }
// Helper function to generate styled HTML response for verification pages
function generateVerificationPageHTML(title, message, isSuccess = true) {
const primaryColor = '#2196F3'; // Blue theme
const icon = isSuccess ? '✓' : '✕';
const iconBg = isSuccess ? primaryColor : '#f44336';
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://fonts.googleapis.com/css2?family=ABeeZee:ital@0;1&display=swap" rel="stylesheet">
<title>${title} - BlindMaster</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f5f5f5; font-family: 'ABeeZee', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5; padding: 40px 20px; min-height: 100vh;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); overflow: hidden; max-width: 600px;">
<!-- Header with brand color -->
<tr>
<td align="center" style="background-color: ${primaryColor}; padding: 40px 20px;">
<h1 style="margin: 0; color: #ffffff; font-size: 32px; font-weight: bold; letter-spacing: 0.5px;">BlindMaster</h1>
<p style="margin: 10px 0 0 0; color: #ffffff; font-size: 14px; opacity: 0.95;">Smart Home Automation</p>
</td>
</tr>
<!-- Status Icon -->
<tr>
<td align="center" style="padding: 50px 40px 30px 40px;">
<div style="width: 80px; height: 80px; border-radius: 50%; background-color: ${iconBg}; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 30px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);">
<span style="color: #ffffff; font-size: 48px; font-weight: bold; line-height: 80px;">${icon}</span>
</div>
<h2 style="margin: 0 0 20px 0; color: #333333; font-size: 28px; font-weight: normal;">
${title}
</h2>
<p style="margin: 0; color: #666666; font-size: 16px; line-height: 1.6;">
${message}
</p>
</td>
</tr>
<!-- Divider -->
<tr>
<td style="padding: 0 40px;">
<div style="border-top: 1px solid #e0e0e0;"></div>
</td>
</tr>
<!-- Footer info -->
<tr>
<td style="padding: 30px 40px; text-align: center;">
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.5;">
You can safely close this window and return to the app.
</p>
</td>
</tr>
<!-- Footer bar -->
<tr>
<td align="center" style="background-color: #f9f9f9; padding: 25px 40px;">
<p style="margin: 0; color: #999999; font-size: 12px;">
© 2026 BlindMaster. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}
module.exports = {
sendVerificationEmail,
sendPasswordResetEmail,
sendEmailChangeVerification,
generateVerificationPageHTML,
};

3795
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"keywords": [], "keywords": [],
@@ -10,19 +11,19 @@
"license": "ISC", "license": "ISC",
"description": "", "description": "",
"dependencies": { "dependencies": {
"@aws-sdk/client-sesv2": "^3.965.0",
"agenda": "^5.0.0", "agenda": "^5.0.0",
"argon2": "^0.43.0", "argon2": "^0.43.0",
"cron-parser": "^4.9.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"express": "^5.1.0", "express": "^5.1.0",
"form-data": "^4.0.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mailgun.js": "^11.1.0",
"mongoose": "^8.16.1", "mongoose": "^8.16.1",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"nodemailer": "^7.0.12",
"pg": "^8.16.0", "pg": "^8.16.0",
"pg-format": "^1.0.4", "pg-format": "^1.0.4",
"rate-limiter-flexible": "^9.0.1", "rate-limiter-flexible": "^9.0.1",
"firebase-admin": "^13.0.0",
"socket.io": "^4.8.1" "socket.io": "^4.8.1"
} }
} }

172
push.js Normal file
View File

@@ -0,0 +1,172 @@
// APNs push helper — direct port of LockInBroAPI's services/push.py.
// Uses Node's built-in http2 + crypto: no third-party APNs library.
//
// Required env vars (names match LockInBroAPI for consistency):
// APNS_KEY_ID — 10-char Key ID from Apple Developer portal
// APNS_TEAM_ID — 10-char Team ID
// APNS_P8_PATH — absolute path to AuthKey_XXXXXXXXXX.p8 inside the container
// APPLE_BUNDLE_ID — iOS app bundle ID (becomes apns-topic)
// APNS_SANDBOX — "true" for development/TestFlight, anything else = production
const http2 = require('http2');
const crypto = require('crypto');
const fs = require('fs');
let cachedJwt = null;
let cachedJwtExp = 0;
let cachedKeyObj = null;
function isConfigured() {
return Boolean(
process.env.APNS_KEY_ID &&
process.env.APNS_TEAM_ID &&
process.env.APNS_P8_PATH &&
process.env.APPLE_BUNDLE_ID
);
}
function b64url(buf) {
return Buffer.from(buf).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
}
function makeJwt() {
const now = Math.floor(Date.now() / 1000);
if (cachedJwt && now < cachedJwtExp) return cachedJwt;
if (!cachedKeyObj) {
const pem = fs.readFileSync(process.env.APNS_P8_PATH);
cachedKeyObj = crypto.createPrivateKey(pem);
}
const header = b64url(JSON.stringify({ alg: 'ES256', kid: process.env.APNS_KEY_ID }));
const payload = b64url(JSON.stringify({ iss: process.env.APNS_TEAM_ID, iat: now }));
const signingInput = `${header}.${payload}`;
// Sign with ES256 — Node returns DER by default; APNs needs P1363 (raw r||s).
const sig = crypto.sign('sha256', Buffer.from(signingInput), {
key: cachedKeyObj,
dsaEncoding: 'ieee-p1363',
});
cachedJwt = `${signingInput}.${b64url(sig)}`;
cachedJwtExp = now + 3300; // 55 min — APNs rejects tokens older than 60 min
return cachedJwt;
}
// Reuse the HTTP/2 session per host to avoid setup cost on every push.
const sessions = {};
function getSession(host) {
if (sessions[host] && !sessions[host].closed && !sessions[host].destroyed) {
return sessions[host];
}
const session = http2.connect(`https://${host}`);
session.on('error', (err) => console.error(`[push] APNs session error (${host}):`, err.message));
session.on('close', () => { if (sessions[host] === session) delete sessions[host]; });
sessions[host] = session;
return session;
}
// Posts a single notification to APNs.
// `apsPayload` is the full body, e.g. { aps: { alert: { title, body }, sound: 'default' } }.
// `pushType` is 'alert' (default), 'background', or 'liveactivity'.
async function sendApns(deviceToken, apsPayload, pushType = 'alert') {
if (!isConfigured()) {
console.warn(`[push] APNs not configured — skipping push to …${deviceToken.slice(-8)}`);
return { ok: false, status: 0, body: 'not_configured' };
}
const sandbox = process.env.APNS_SANDBOX === 'true';
const host = sandbox ? 'api.sandbox.push.apple.com' : 'api.push.apple.com';
let topic = process.env.APPLE_BUNDLE_ID;
if (pushType === 'liveactivity') topic += '.push-type.liveactivity';
const session = getSession(host);
const body = Buffer.from(JSON.stringify(apsPayload));
const req = session.request({
':method': 'POST',
':path': `/3/device/${deviceToken}`,
'authorization': `bearer ${makeJwt()}`,
'apns-topic': topic,
'apns-push-type': pushType,
'apns-priority': '10',
'content-type': 'application/json',
'content-length': body.length,
});
return new Promise((resolve) => {
let status = 0;
let chunks = [];
let timeout = setTimeout(() => { req.close(http2.constants.NGHTTP2_CANCEL); }, 10000);
req.on('response', (headers) => { status = headers[':status']; });
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
clearTimeout(timeout);
const respBody = Buffer.concat(chunks).toString();
const tail = deviceToken.slice(-8);
if (status === 200) {
resolve({ ok: true, status, body: respBody });
} else {
console.error(`[push] APNs ${status} for token …${tail}: ${respBody}`);
resolve({ ok: false, status, body: respBody });
}
});
req.on('error', (err) => {
clearTimeout(timeout);
console.error(`[push] APNs request error for token …${deviceToken.slice(-8)}:`, err.message);
resolve({ ok: false, status: 0, body: err.message });
});
req.end(body);
});
}
// Public helper used by routes/agenda jobs. `data` is delivered as custom keys
// alongside `aps`, matching how the iOS app reads userInfo.
async function sendNotification(deviceToken, { title, body, data = {}, pool = null }) {
if (!deviceToken) return null;
const apsPayload = {
aps: {
alert: { title, body },
sound: 'default',
'content-available': 1,
},
...data,
};
const result = await sendApns(deviceToken, apsPayload, 'alert');
// 410 Unregistered — token is dead, scrub it.
if (result.status === 410 && pool) {
try {
await pool.query("UPDATE users SET apns_token=NULL WHERE apns_token=$1", [deviceToken]);
console.warn(`[push] APNs 410 — cleared dead token …${deviceToken.slice(-8)}`);
} catch (err) {
console.error('[push] failed to clear dead token:', err.message);
}
}
return result;
}
function init() {
if (!isConfigured()) {
console.warn('[push] APNs env not fully set (APNS_KEY_ID/APNS_TEAM_ID/APNS_P8_PATH/APPLE_BUNDLE_ID) — push disabled');
return false;
}
// Pre-warm key + JWT so the first send doesn't pay the cost.
makeJwt();
console.log(`[push] APNs ready (topic=${process.env.APPLE_BUNDLE_ID}, sandbox=${process.env.APNS_SANDBOX === 'true'})`);
return true;
}
function shutdown() {
for (const host of Object.keys(sessions)) {
try { sessions[host].close(); } catch {}
delete sessions[host];
}
}
module.exports = { init, sendNotification, isConfigured, shutdown };