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
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
const mongoUri = 'mongodb://localhost:27017/myScheduledApp';
|
||||
const mongoUri = process.env.MONGO_URI || 'mongodb://127.0.0.1:27017/myScheduledApp';
|
||||
await mongoose.connect(mongoUri);
|
||||
console.log('MongoDB connected successfully for Mongoose!');
|
||||
} 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 express = require('express');
|
||||
const { json } = require('express');
|
||||
@@ -54,7 +54,8 @@ const wsMessageRateLimiter = new RateLimiterMemory({
|
||||
|
||||
const path = require('path');
|
||||
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(json());
|
||||
|
||||
@@ -79,9 +80,9 @@ const io = socketIo(server, {
|
||||
let agenda;
|
||||
|
||||
(async () => {
|
||||
// 1. Connect to MongoDB
|
||||
// 1. Connect to MongoDB (URI comes from env in containerized deploys)
|
||||
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 () => {
|
||||
@@ -96,25 +97,11 @@ let agenda;
|
||||
await pool.query("DELETE FROM user_pending_emails WHERE expires_at < NOW()");
|
||||
console.log("Cleared expired pending email changes");
|
||||
|
||||
// Add battery_soc column if this is the first deploy with battery support
|
||||
await pool.query("ALTER TABLE devices ADD COLUMN IF NOT EXISTS battery_soc SMALLINT");
|
||||
// Schema lives in db/schema.sql and is applied by db/migrate.js before this
|
||||
// process starts (compose CMD chains them).
|
||||
|
||||
// Add fcm_token column for push notification delivery
|
||||
await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS fcm_token TEXT");
|
||||
|
||||
// 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");
|
||||
}
|
||||
// Initialise APNs provider (lazy — push.js logs a warning if env is missing)
|
||||
push.init();
|
||||
})();
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
const TOKEN_EXPIRY = '5d';
|
||||
@@ -2014,8 +2001,9 @@ app.post('/update_schedule', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.log(`Example app listening on 127.0.0.1:${port}`);
|
||||
const bindHost = process.env.BIND_HOST || '0.0.0.0';
|
||||
server.listen(port, bindHost, () => {
|
||||
console.log(`Example app listening on ${bindHost}:${port}`);
|
||||
});
|
||||
|
||||
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
|
||||
app.post('/register_fcm_token', authenticateToken, async (req, res) => {
|
||||
// Store/update the APNs device token for the authenticated user
|
||||
app.post('/register_apns_token', authenticateToken, async (req, res) => {
|
||||
const { token } = req.body;
|
||||
if (!token || typeof token !== 'string') return res.sendStatus(400);
|
||||
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);
|
||||
} catch {
|
||||
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 });
|
||||
}
|
||||
|
||||
// FCM — background push for persistent alerts (not transient voltage dips)
|
||||
const fcmPushTypes = ['overvoltage', 'critical_low', 'low_20', 'low_10'];
|
||||
if (fcmPushTypes.includes(type) && admin.apps.length > 0) {
|
||||
const { rows: fcmRows } = 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",
|
||||
// APNs — background push for persistent alerts (not transient voltage dips)
|
||||
const pushTypes = ['overvoltage', 'critical_low', 'low_20', 'low_10'];
|
||||
if (pushTypes.includes(type)) {
|
||||
const { rows: tokenRows } = await pool.query(
|
||||
"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]
|
||||
);
|
||||
if (fcmRows.length === 1) {
|
||||
const fcmContent = {
|
||||
if (tokenRows.length === 1) {
|
||||
const pushContent = {
|
||||
overvoltage: { title: 'Battery Fault', body: 'Overvoltage detected. Please check your charger.' },
|
||||
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_10: { title: 'Battery Very Low', body: `Battery at ${soc}% — charge now.` },
|
||||
};
|
||||
const { title, body } = fcmContent[type];
|
||||
await admin.messaging().send({
|
||||
token: fcmRows[0].fcm_token,
|
||||
notification: { title, body },
|
||||
const { title, body } = pushContent[type];
|
||||
await push.sendNotification(tokenRows[0].apns_token, {
|
||||
title,
|
||||
body,
|
||||
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 { SESv2Client, SendEmailCommand } = require('@aws-sdk/client-sesv2');
|
||||
const FormData = require('form-data');
|
||||
const Mailgun = require('mailgun.js');
|
||||
|
||||
const sesClient = new SESv2Client({
|
||||
region: process.env.AWS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
const mailgun = new Mailgun(FormData);
|
||||
const mg = process.env.MAILGUN_API_KEY ? mailgun.client({
|
||||
username: 'api',
|
||||
key: process.env.MAILGUN_API_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 transporter = nodemailer.createTransport({
|
||||
SES: { sesClient, SendEmailCommand },
|
||||
});
|
||||
const DOMAIN = process.env.MAILGUN_DOMAIN;
|
||||
const FROM = process.env.EMAIL_FROM || (DOMAIN ? `BlindMaster <postmaster@${DOMAIN}>` : null);
|
||||
|
||||
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
|
||||
// hour parameter should be the local hour (0-23) from the client
|
||||
@@ -32,11 +43,10 @@ function getColorForTime(hour) {
|
||||
// Helper function to send email
|
||||
async function sendVerificationEmail(toEmail, token, name, localHour = new Date().getHours()) {
|
||||
const primaryColor = getColorForTime(localHour);
|
||||
const verificationLink = `https://wahwa.com/verify-email?token=${token}`;
|
||||
const verificationLink = `https://blindmaster.wahwa.com/verify-email?token=${token}`;
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
from: `"BlindMaster" <${process.env.EMAIL_FROM}>`, // Sender address
|
||||
const info = await sendMail({
|
||||
to: toEmail,
|
||||
subject: "Verify your BlindMaster account",
|
||||
html: `
|
||||
@@ -119,10 +129,10 @@ async function sendVerificationEmail(toEmail, token, name, localHour = new Date(
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
console.log("Email sent successfully:", info.messageId);
|
||||
console.log("Verification email sent:", info?.id || '(skipped)');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error sending email:", error);
|
||||
console.error("Error sending verification email:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -132,8 +142,7 @@ async function sendPasswordResetEmail(toEmail, code, name, localHour = new Date(
|
||||
const primaryColor = getColorForTime(localHour);
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
from: `"BlindMaster" <${process.env.EMAIL_FROM}>`,
|
||||
const info = await sendMail({
|
||||
to: toEmail,
|
||||
subject: "Reset your BlindMaster password",
|
||||
html: `
|
||||
@@ -217,7 +226,7 @@ async function sendPasswordResetEmail(toEmail, code, name, localHour = new Date(
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
console.log("Password reset email sent successfully:", info.messageId);
|
||||
console.log("Password reset email sent:", info?.id || '(skipped)');
|
||||
return true;
|
||||
} catch (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
|
||||
async function sendEmailChangeVerification(newEmail, token, name, oldEmail, localHour = new Date().getHours()) {
|
||||
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 {
|
||||
const info = await transporter.sendMail({
|
||||
from: `"BlindMaster" <${process.env.EMAIL_FROM}>`,
|
||||
const info = await sendMail({
|
||||
to: newEmail,
|
||||
subject: "Verify your new BlindMaster email address",
|
||||
html: `
|
||||
@@ -406,10 +329,95 @@ async function sendEmailChangeVerification(newEmail, token, name, oldEmail, loca
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
console.log("Email change verification sent successfully:", info.messageId);
|
||||
console.log("Email change verification sent:", info?.id || '(skipped)');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error sending email change verification:", error);
|
||||
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
3795
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
@@ -10,19 +11,19 @@
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sesv2": "^3.965.0",
|
||||
"agenda": "^5.0.0",
|
||||
"argon2": "^0.43.0",
|
||||
"cron-parser": "^4.9.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"form-data": "^4.0.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailgun.js": "^11.1.0",
|
||||
"mongoose": "^8.16.1",
|
||||
"node-schedule": "^2.1.1",
|
||||
"nodemailer": "^7.0.12",
|
||||
"pg": "^8.16.0",
|
||||
"pg-format": "^1.0.4",
|
||||
"rate-limiter-flexible": "^9.0.1",
|
||||
"firebase-admin": "^13.0.0",
|
||||
"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