end-to-end CI to match containerization on rest of adipu_server
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
.gitea/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
readme.md
|
||||||
66
.gitea/workflows/deploy.yaml
Normal file
66
.gitea/workflows/deploy.yaml
Normal 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
|
||||||
32
.github/workflows/deploy.yml
vendored
32
.github/workflows/deploy.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -71,3 +71,5 @@ node_modules/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
previews/
|
previews/
|
||||||
|
|
||||||
|
*.p8
|
||||||
1
.node-version
Normal file
1
.node-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
22.11.0
|
||||||
208
ARCHITECTURE.md
Normal file
208
ARCHITECTURE.md
Normal 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
16
Dockerfile.backend
Normal 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
2
db.js
@@ -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
23
db/migrate.js
Normal 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
98
db/schema.sql
Normal 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
75
docker-compose.yml
Normal 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:
|
||||||
67
index.js
67
index.js
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
228
mailer.js
228
mailer.js
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
3811
package-lock.json
generated
3811
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
172
push.js
Normal 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 };
|
||||||
Reference in New Issue
Block a user