Compare commits
9 Commits
a2e5fa0ab9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcf3fb9aa0 | ||
| a99117dc98 | |||
| 85301135c7 | |||
|
|
7a2bb88207 | ||
|
|
6261f1baa0 | ||
| bb0927ad92 | |||
| 2dba6c3597 | |||
|
|
6ba7d678c6 | ||
|
|
dffb86007e |
@@ -1,5 +1,5 @@
|
||||
# HTTP port (default: 3000)
|
||||
PORT=3000
|
||||
# HTTP port (default: 3003)
|
||||
PORT=3003
|
||||
|
||||
# Firebase Admin: inline service account JSON (one line).
|
||||
# If unset, uses Application Default Credentials (e.g. GOOGLE_APPLICATION_CREDENTIALS).
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ dist/
|
||||
.env
|
||||
*.log
|
||||
data/
|
||||
.DS_Store
|
||||
|
||||
26
Dockerfile
26
Dockerfile
@@ -1,17 +1,31 @@
|
||||
FROM node:22-alpine
|
||||
# ---- build stage: install everything, type-check + compile to JS ----
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@11.4.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --prod
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY src ./src
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN pnpm build
|
||||
|
||||
# ---- runtime stage: prod deps + compiled JS only, no tsx/esbuild ----
|
||||
FROM node:22-alpine AS runtime
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@11.4.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
ENV PORT=3003
|
||||
EXPOSE 3003
|
||||
|
||||
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
@@ -14,7 +14,7 @@ pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
The server starts on `http://localhost:3000` (or the port in `PORT`). Hot-reloads on file changes.
|
||||
The server starts on `http://localhost:3003` (or the port in `PORT`). Hot-reloads on file changes.
|
||||
|
||||
Set `NODE_ENV=test-local` in `.env` to bypass JWT expiry verification during local development.
|
||||
|
||||
@@ -30,8 +30,8 @@ pnpm start
|
||||
Or with Docker:
|
||||
|
||||
```bash
|
||||
docker build -t notification-wakeup-service .
|
||||
docker run -e FIREBASE_SERVICE_ACCOUNT_JSON='...' -p 3000:3000 notification-wakeup-service
|
||||
docker build --no-cache -t notify-wakeup-api:amd-$NOTIFY_WAKEUP_API_VERSION --platform linux/amd64 .
|
||||
docker run --env-file notify-wakeup-api.env -p 3003:3003 notify-wakeup-api
|
||||
```
|
||||
|
||||
Required environment variables:
|
||||
@@ -39,5 +39,5 @@ Required environment variables:
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `FIREBASE_SERVICE_ACCOUNT_JSON` | Inline service account JSON (one line). If unset, falls back to Application Default Credentials. |
|
||||
| `PORT` | HTTP port (default: `3000`). |
|
||||
| `PORT` | HTTP port (default: `3003`). |
|
||||
| `FCM_TOKEN_DATA_DIR` | Directory for persisting registered FCM tokens (default: `./data`). |
|
||||
|
||||
3786
package-lock.json
generated
Normal file
3786
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "notification-wakeup-service",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.4.0",
|
||||
@@ -13,15 +13,18 @@
|
||||
"@peculiar/asn1-ecc": "^2.7.0",
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"cbor-x": "^1.6.4",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^16.6.1",
|
||||
"did-jwt": "^7.4.7",
|
||||
"did-resolver": "^4.1.0",
|
||||
"express": "^5.2.1",
|
||||
"firebase-admin": "^13.10.0",
|
||||
"tsx": "^4.22.3"
|
||||
"firebase-admin": "^13.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.19.19",
|
||||
"@types/cors": "^2.8.19",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
43
pnpm-lock.yaml
generated
43
pnpm-lock.yaml
generated
@@ -17,28 +17,37 @@ importers:
|
||||
cbor-x:
|
||||
specifier: ^1.6.4
|
||||
version: 1.6.4
|
||||
cors:
|
||||
specifier: ^2.8.6
|
||||
version: 2.8.6
|
||||
did-jwt:
|
||||
specifier: ^7.4.7
|
||||
version: 7.4.7
|
||||
did-resolver:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
dotenv:
|
||||
specifier: ^16.6.1
|
||||
version: 16.6.1
|
||||
express:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
firebase-admin:
|
||||
specifier: ^13.10.0
|
||||
version: 13.10.0
|
||||
tsx:
|
||||
specifier: ^4.22.3
|
||||
version: 4.22.3
|
||||
devDependencies:
|
||||
'@types/cors':
|
||||
specifier: ^2.8.19
|
||||
version: 2.8.19
|
||||
'@types/express':
|
||||
specifier: ^5.0.6
|
||||
version: 5.0.6
|
||||
'@types/node':
|
||||
specifier: ^22.19.19
|
||||
version: 22.19.19
|
||||
tsx:
|
||||
specifier: ^4.22.3
|
||||
version: 4.22.3
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
@@ -382,6 +391,9 @@ packages:
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
||||
|
||||
'@types/express-serve-static-core@5.1.1':
|
||||
resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==}
|
||||
|
||||
@@ -530,6 +542,10 @@ packages:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
cors@2.8.6:
|
||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
data-uri-to-buffer@4.0.1:
|
||||
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
|
||||
engines: {node: '>= 12'}
|
||||
@@ -561,6 +577,10 @@ packages:
|
||||
did-resolver@4.1.0:
|
||||
resolution: {integrity: sha512-S6fWHvCXkZg2IhS4RcVHxwuyVejPR7c+a4Go0xbQ9ps5kILa8viiYQgrM4gfTyeTjJ0ekgJH9gk/BawTpmkbZA==}
|
||||
|
||||
dotenv@16.6.1:
|
||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -926,6 +946,10 @@ packages:
|
||||
resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==}
|
||||
hasBin: true
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-hash@3.0.0:
|
||||
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -1480,6 +1504,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.19.19
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 22.19.19
|
||||
|
||||
'@types/express-serve-static-core@5.1.1':
|
||||
dependencies:
|
||||
'@types/node': 22.19.19
|
||||
@@ -1657,6 +1685,11 @@ snapshots:
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
cors@2.8.6:
|
||||
dependencies:
|
||||
object-assign: 4.1.1
|
||||
vary: 1.1.2
|
||||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
|
||||
debug@4.4.3:
|
||||
@@ -1685,6 +1718,8 @@ snapshots:
|
||||
|
||||
did-resolver@4.1.0: {}
|
||||
|
||||
dotenv@16.6.1: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -2193,6 +2228,8 @@ snapshots:
|
||||
detect-libc: 2.1.2
|
||||
optional: true
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-hash@3.0.0:
|
||||
optional: true
|
||||
|
||||
|
||||
@@ -4,3 +4,8 @@ onlyBuiltDependencies:
|
||||
- cbor-extract
|
||||
- esbuild
|
||||
- protobufjs
|
||||
allowBuilds:
|
||||
'@firebase/util': set this to true or false
|
||||
cbor-extract: set this to true or false
|
||||
esbuild: set this to true or false
|
||||
protobufjs: set this to true or false
|
||||
|
||||
3
src/env.ts
Normal file
3
src/env.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { config } from "dotenv";
|
||||
|
||||
config();
|
||||
12
src/index.ts
12
src/index.ts
@@ -1,3 +1,5 @@
|
||||
import "./env.js";
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import "./services/firebase.js";
|
||||
import { debugRouter } from "./routes/debug.js";
|
||||
@@ -5,7 +7,15 @@ import { notificationsRouter } from "./routes/notifications.js";
|
||||
import { startScheduler } from "./scheduler.js";
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
const port = Number(process.env.PORT) || 3003;
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: true,
|
||||
methods: ["GET", "POST", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
|
||||
@@ -13,6 +13,21 @@ type ClientErrorBody = {
|
||||
};
|
||||
};
|
||||
|
||||
/** Synthetic userId for unauthenticated local debug registrations (testMode). */
|
||||
const LOCAL_TEST_USER_ID = "__notification_local_test__";
|
||||
|
||||
function isNotificationLocalTestBypass(req: Request): boolean {
|
||||
if (req.headers.authorization?.startsWith("Bearer ")) {
|
||||
return false;
|
||||
}
|
||||
const body = req.body;
|
||||
return (
|
||||
body !== null &&
|
||||
typeof body === "object" &&
|
||||
(body as { testMode?: unknown }).testMode === true
|
||||
);
|
||||
}
|
||||
|
||||
function clientErrorMessage(err: unknown): string | undefined {
|
||||
if (err && typeof err === "object" && "clientError" in err) {
|
||||
const message = (err as ClientErrorBody).clientError?.message;
|
||||
@@ -86,3 +101,17 @@ export async function requireAuth(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAuthOrNotificationLocalTest(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
if (isNotificationLocalTestBypass(req)) {
|
||||
req.did = LOCAL_TEST_USER_ID;
|
||||
console.log("[Auth] Local notification test bypass");
|
||||
next();
|
||||
return;
|
||||
}
|
||||
return requireAuth(req, res, next);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Router } from "express";
|
||||
import express, { Router } from "express";
|
||||
import { db } from "../db/fcmTokens.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
import {
|
||||
requireAuth,
|
||||
requireAuthOrNotificationLocalTest,
|
||||
} from "../middleware/auth.js";
|
||||
import {
|
||||
computeNextEligibleAt,
|
||||
sendPushToDevice,
|
||||
@@ -9,9 +12,7 @@ import { formatElapsedMs } from "../util/formatElapsed.js";
|
||||
import { maskToken } from "../util/maskToken.js";
|
||||
|
||||
// TODO: Protect this endpoint before production deployment
|
||||
export const debugRouter = Router();
|
||||
|
||||
debugRouter.use(requireAuth);
|
||||
export const debugRouter: express.Router = Router();
|
||||
|
||||
function deviceDebugPayload(row: {
|
||||
id: string;
|
||||
@@ -47,7 +48,7 @@ function sendWakeupFailureReason(
|
||||
}
|
||||
|
||||
// TODO: Protect this endpoint before production deployment
|
||||
debugRouter.get("/device/:token", async (req, res) => {
|
||||
debugRouter.get("/device/:token", requireAuth, async (req, res) => {
|
||||
const started = Date.now();
|
||||
const userId = req.did;
|
||||
if (userId === undefined) {
|
||||
@@ -55,7 +56,10 @@ debugRouter.get("/device/:token", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const fcmToken = decodeURIComponent(req.params.token);
|
||||
const tokenParam = req.params.token;
|
||||
const fcmToken = decodeURIComponent(
|
||||
Array.isArray(tokenParam) ? tokenParam[0] : tokenParam
|
||||
);
|
||||
const suffix = maskToken(fcmToken);
|
||||
console.log("[DebugEndpoint] Device lookup request, token suffix:", suffix);
|
||||
|
||||
@@ -81,7 +85,7 @@ debugRouter.get("/device/:token", async (req, res) => {
|
||||
});
|
||||
|
||||
// TODO: Protect this endpoint before production deployment
|
||||
debugRouter.post("/send-wakeup", async (req, res) => {
|
||||
debugRouter.post("/send-wakeup", requireAuthOrNotificationLocalTest, async (req, res) => {
|
||||
const started = Date.now();
|
||||
const userId = req.did;
|
||||
if (userId === undefined) {
|
||||
|
||||
@@ -1,181 +1,191 @@
|
||||
import { Router } from "express";
|
||||
import express, { Router } from "express";
|
||||
import { db } from "../db/fcmTokens.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
import { requireAuthOrNotificationLocalTest } from "../middleware/auth.js";
|
||||
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
|
||||
import { maskToken } from "../util/maskToken.js";
|
||||
|
||||
export const notificationsRouter = Router();
|
||||
export const notificationsRouter: express.Router = Router();
|
||||
|
||||
notificationsRouter.get("/", (_req, res) => {
|
||||
res.json({ ok: true, resource: "notifications" });
|
||||
});
|
||||
|
||||
notificationsRouter.post("/refresh", requireAuth, async (req, res) => {
|
||||
const started = Date.now();
|
||||
const userId = req.did;
|
||||
if (userId === undefined) {
|
||||
res.status(401).json({ success: false, message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
notificationsRouter.post(
|
||||
"/refresh",
|
||||
requireAuthOrNotificationLocalTest,
|
||||
async (req, res) => {
|
||||
const started = Date.now();
|
||||
const userId = req.did;
|
||||
if (userId === undefined) {
|
||||
res.status(401).json({ success: false, message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { deviceId, fcmToken } = req.body as {
|
||||
deviceId?: unknown;
|
||||
fcmToken?: unknown;
|
||||
};
|
||||
const { deviceId, fcmToken } = req.body as {
|
||||
deviceId?: unknown;
|
||||
fcmToken?: unknown;
|
||||
};
|
||||
|
||||
const canonicalDeviceId =
|
||||
typeof deviceId === "string" ? deviceId.trim() : undefined;
|
||||
const token =
|
||||
typeof fcmToken === "string" && fcmToken.length > 0 ? fcmToken : undefined;
|
||||
const canonicalDeviceId =
|
||||
typeof deviceId === "string" ? deviceId.trim() : undefined;
|
||||
const token =
|
||||
typeof fcmToken === "string" && fcmToken.length > 0
|
||||
? fcmToken
|
||||
: undefined;
|
||||
|
||||
console.log(
|
||||
"[Refresh] Request received",
|
||||
canonicalDeviceId !== undefined ? `deviceId=${canonicalDeviceId}` : "",
|
||||
token !== undefined ? `token suffix=${maskToken(token)}` : ""
|
||||
);
|
||||
|
||||
if (
|
||||
(canonicalDeviceId === undefined || canonicalDeviceId.length === 0) &&
|
||||
token === undefined
|
||||
) {
|
||||
console.log(
|
||||
"[Refresh] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
"deviceId or fcmToken is required"
|
||||
);
|
||||
res.status(400).json({ error: "deviceId or fcmToken is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const device = await db.resolveOwnedDevice(userId, {
|
||||
deviceId: canonicalDeviceId,
|
||||
fcmToken: token,
|
||||
});
|
||||
|
||||
if (device === undefined) {
|
||||
console.log(
|
||||
"[Refresh] Device not found in",
|
||||
formatElapsedMs(Date.now() - started),
|
||||
canonicalDeviceId !== undefined
|
||||
? `deviceId=${canonicalDeviceId}`
|
||||
: "",
|
||||
"[Refresh] Request received",
|
||||
canonicalDeviceId !== undefined ? `deviceId=${canonicalDeviceId}` : "",
|
||||
token !== undefined ? `token suffix=${maskToken(token)}` : ""
|
||||
);
|
||||
res.status(404).json({ error: "Device not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
res.json({
|
||||
shouldNotify: true,
|
||||
nextNotifications: [{ timestamp: now + 600000 }],
|
||||
});
|
||||
console.log(
|
||||
"[Refresh] Completed in",
|
||||
formatElapsedMs(Date.now() - started) + ",",
|
||||
"deviceId=" + device.deviceId + ",",
|
||||
"token suffix=" + maskToken(device.fcmToken)
|
||||
);
|
||||
});
|
||||
if (
|
||||
(canonicalDeviceId === undefined || canonicalDeviceId.length === 0) &&
|
||||
token === undefined
|
||||
) {
|
||||
console.log(
|
||||
"[Refresh] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
"deviceId or fcmToken is required"
|
||||
);
|
||||
res.status(400).json({ error: "deviceId or fcmToken is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
notificationsRouter.post("/register", requireAuth, async (req, res) => {
|
||||
const started = Date.now();
|
||||
const userId = req.did;
|
||||
if (userId === undefined) {
|
||||
res.status(401).json({ success: false, message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
req.body !== null &&
|
||||
typeof req.body === "object" &&
|
||||
"userId" in req.body
|
||||
) {
|
||||
console.log(
|
||||
"[Register] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
"userId must not be sent in the request body"
|
||||
);
|
||||
res.status(400).json({
|
||||
error: "userId must not be sent in the request body",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { deviceId, fcmToken, platform, testMode } = req.body as {
|
||||
deviceId?: unknown;
|
||||
fcmToken?: unknown;
|
||||
platform?: unknown;
|
||||
testMode?: unknown;
|
||||
};
|
||||
|
||||
if (typeof deviceId !== "string" || deviceId.trim().length === 0) {
|
||||
console.log(
|
||||
"[Register] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
"deviceId is required"
|
||||
);
|
||||
res.status(400).json({ error: "deviceId is required" });
|
||||
return;
|
||||
}
|
||||
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
|
||||
console.log(
|
||||
"[Register] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
"fcmToken is required"
|
||||
);
|
||||
res.status(400).json({ error: "fcmToken is required" });
|
||||
return;
|
||||
}
|
||||
if (typeof platform !== "string" || platform.length === 0) {
|
||||
console.log(
|
||||
"[Register] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
"platform is required"
|
||||
);
|
||||
res.status(400).json({ error: "platform is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const canonicalDeviceId = deviceId.trim();
|
||||
console.log(
|
||||
"[Register] Request received,",
|
||||
"deviceId=" + canonicalDeviceId + ",",
|
||||
"platform=" + platform + ",",
|
||||
"token suffix=" + maskToken(fcmToken)
|
||||
);
|
||||
|
||||
try {
|
||||
const existing = await db.getByDeviceId(userId, canonicalDeviceId);
|
||||
const action =
|
||||
existing === undefined
|
||||
? "create"
|
||||
: existing.fcmToken !== fcmToken
|
||||
? "update-token"
|
||||
: "update";
|
||||
|
||||
await db.upsert({
|
||||
userId,
|
||||
const device = await db.resolveOwnedDevice(userId, {
|
||||
deviceId: canonicalDeviceId,
|
||||
fcmToken,
|
||||
platform,
|
||||
testMode: typeof testMode === "boolean" ? testMode : undefined,
|
||||
updatedAt: new Date(),
|
||||
fcmToken: token,
|
||||
});
|
||||
|
||||
if (device === undefined) {
|
||||
console.log(
|
||||
"[Refresh] Device not found in",
|
||||
formatElapsedMs(Date.now() - started),
|
||||
canonicalDeviceId !== undefined
|
||||
? `deviceId=${canonicalDeviceId}`
|
||||
: "",
|
||||
token !== undefined ? `token suffix=${maskToken(token)}` : ""
|
||||
);
|
||||
res.status(404).json({ error: "Device not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
res.json({
|
||||
shouldNotify: true,
|
||||
nextNotifications: [{ timestamp: now + 600000 }],
|
||||
});
|
||||
res.sendStatus(200);
|
||||
console.log(
|
||||
"[Register] Completed in",
|
||||
"[Refresh] Completed in",
|
||||
formatElapsedMs(Date.now() - started) + ",",
|
||||
"deviceId=" + canonicalDeviceId + ",",
|
||||
"action=" + action
|
||||
"deviceId=" + device.deviceId + ",",
|
||||
"token suffix=" + maskToken(device.fcmToken)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[Register] Failed in",
|
||||
formatElapsedMs(Date.now() - started) + ",",
|
||||
"deviceId=" + canonicalDeviceId + ":",
|
||||
errorMessage(err)
|
||||
);
|
||||
res.sendStatus(500);
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
notificationsRouter.post(
|
||||
"/register",
|
||||
requireAuthOrNotificationLocalTest,
|
||||
async (req, res) => {
|
||||
const started = Date.now();
|
||||
const userId = req.did;
|
||||
if (userId === undefined) {
|
||||
res.status(401).json({ success: false, message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
req.body !== null &&
|
||||
typeof req.body === "object" &&
|
||||
"userId" in req.body
|
||||
) {
|
||||
console.log(
|
||||
"[Register] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
"userId must not be sent in the request body"
|
||||
);
|
||||
res.status(400).json({
|
||||
error: "userId must not be sent in the request body",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { deviceId, fcmToken, platform, testMode } = req.body as {
|
||||
deviceId?: unknown;
|
||||
fcmToken?: unknown;
|
||||
platform?: unknown;
|
||||
testMode?: unknown;
|
||||
};
|
||||
|
||||
if (typeof deviceId !== "string" || deviceId.trim().length === 0) {
|
||||
console.log(
|
||||
"[Register] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
"deviceId is required"
|
||||
);
|
||||
res.status(400).json({ error: "deviceId is required" });
|
||||
return;
|
||||
}
|
||||
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
|
||||
console.log(
|
||||
"[Register] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
"fcmToken is required"
|
||||
);
|
||||
res.status(400).json({ error: "fcmToken is required" });
|
||||
return;
|
||||
}
|
||||
if (typeof platform !== "string" || platform.length === 0) {
|
||||
console.log(
|
||||
"[Register] Rejected in",
|
||||
formatElapsedMs(Date.now() - started) + ":",
|
||||
"platform is required"
|
||||
);
|
||||
res.status(400).json({ error: "platform is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const canonicalDeviceId = deviceId.trim();
|
||||
console.log(
|
||||
"[Register] Request received,",
|
||||
"deviceId=" + canonicalDeviceId + ",",
|
||||
"platform=" + platform + ",",
|
||||
"token suffix=" + maskToken(fcmToken)
|
||||
);
|
||||
|
||||
try {
|
||||
const existing = await db.getByDeviceId(userId, canonicalDeviceId);
|
||||
const action =
|
||||
existing === undefined
|
||||
? "create"
|
||||
: existing.fcmToken !== fcmToken
|
||||
? "update-token"
|
||||
: "update";
|
||||
|
||||
await db.upsert({
|
||||
userId,
|
||||
deviceId: canonicalDeviceId,
|
||||
fcmToken,
|
||||
platform,
|
||||
testMode: typeof testMode === "boolean" ? testMode : undefined,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
res.sendStatus(200);
|
||||
console.log(
|
||||
"[Register] Completed in",
|
||||
formatElapsedMs(Date.now() - started) + ",",
|
||||
"deviceId=" + canonicalDeviceId + ",",
|
||||
"action=" + action
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[Register] Failed in",
|
||||
formatElapsedMs(Date.now() - started) + ",",
|
||||
"deviceId=" + canonicalDeviceId + ":",
|
||||
errorMessage(err)
|
||||
);
|
||||
res.sendStatus(500);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,11 +2,45 @@ import admin from "firebase-admin";
|
||||
import type { ServiceAccount } from "firebase-admin/app";
|
||||
import type { Messaging } from "firebase-admin/messaging";
|
||||
|
||||
type ServiceAccountJson = ServiceAccount & { project_id?: string };
|
||||
|
||||
function serviceAccountProjectId(account: ServiceAccountJson): string | undefined {
|
||||
if (typeof account.projectId === "string" && account.projectId.length > 0) {
|
||||
return account.projectId;
|
||||
}
|
||||
if (typeof account.project_id === "string" && account.project_id.length > 0) {
|
||||
return account.project_id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveCredential(): admin.credential.Credential {
|
||||
const json = process.env.FIREBASE_SERVICE_ACCOUNT_JSON;
|
||||
if (json !== undefined && json.trim() !== "") {
|
||||
return admin.credential.cert(JSON.parse(json) as ServiceAccount);
|
||||
let account: ServiceAccountJson;
|
||||
try {
|
||||
account = JSON.parse(json) as ServiceAccountJson;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(
|
||||
"[Firebase] FIREBASE_SERVICE_ACCOUNT_JSON parse failed:",
|
||||
message
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
const projectId = serviceAccountProjectId(account);
|
||||
console.log(
|
||||
"[Firebase] Credential: FIREBASE_SERVICE_ACCOUNT_JSON (parsed successfully)"
|
||||
);
|
||||
if (projectId !== undefined) {
|
||||
console.log("[Firebase] project_id:", projectId);
|
||||
} else {
|
||||
console.log("[Firebase] project_id: (not found in service account JSON)");
|
||||
}
|
||||
return admin.credential.cert(account);
|
||||
}
|
||||
|
||||
console.log("[Firebase] Credential: Application Default Credentials");
|
||||
return admin.credential.applicationDefault();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user