From e904e9634548e47d611bdcbb88d7b180b927fd5f Mon Sep 17 00:00:00 2001 From: JustZvan Date: Fri, 6 Feb 2026 12:16:40 +0100 Subject: feat: initial commit! --- src/account/jwt.ts | 91 ++++ src/ai/ai.ts | 39 ++ src/db/db.ts | 21 + src/db/redis/cache.ts | 181 +++++++ src/db/redis/client.ts | 10 + src/db/redis/info.ts | 19 + src/db/schema.ts | 95 ++++ src/email/confirm.ts | 75 +++ src/email/email.ts | 66 +++ src/index.ts | 578 ++++++++++++++++++++++ src/lib/pino.ts | 29 ++ src/middleware/auth.ts | 201 ++++++++ src/notifications/push.ts | 248 ++++++++++ src/queue/accessibility_scan.ts | 326 ++++++++++++ src/queue/email.ts | 68 +++ src/queue/notification_scan.ts | 325 ++++++++++++ src/queue/push_notification.ts | 115 +++++ src/routes/kid.ts | 107 ++++ src/routes/parent.ts | 1041 +++++++++++++++++++++++++++++++++++++++ src/routes/signin.ts | 175 +++++++ src/routes/signup.ts | 100 ++++ src/types/express.d.ts | 12 + 22 files changed, 3922 insertions(+) create mode 100644 src/account/jwt.ts create mode 100644 src/ai/ai.ts create mode 100644 src/db/db.ts create mode 100644 src/db/redis/cache.ts create mode 100644 src/db/redis/client.ts create mode 100644 src/db/redis/info.ts create mode 100644 src/db/schema.ts create mode 100644 src/email/confirm.ts create mode 100644 src/email/email.ts create mode 100644 src/index.ts create mode 100644 src/lib/pino.ts create mode 100644 src/middleware/auth.ts create mode 100644 src/notifications/push.ts create mode 100644 src/queue/accessibility_scan.ts create mode 100644 src/queue/email.ts create mode 100644 src/queue/notification_scan.ts create mode 100644 src/queue/push_notification.ts create mode 100644 src/routes/kid.ts create mode 100644 src/routes/parent.ts create mode 100644 src/routes/signin.ts create mode 100644 src/routes/signup.ts create mode 100644 src/types/express.d.ts (limited to 'src') diff --git a/src/account/jwt.ts b/src/account/jwt.ts new file mode 100644 index 0000000..21d49f8 --- /dev/null +++ b/src/account/jwt.ts @@ -0,0 +1,91 @@ +import * as jose from "jose"; +import { logger } from "../lib/pino"; + +const privateKey = process.env.JWT_PRIVATE_KEY; +const publicKey = process.env.JWT_PUBLIC_KEY; + +/** + * Creates a signed JWT token with the provided payload. + * Tokens are set to expire in 1000 years (effectively never). + */ +export async function signJwt( + payload: Record, + audience: string, +) { + try { + if (!privateKey) { + logger.error("JWT_PRIVATE_KEY environment variable not set"); + throw new Error("JWT private key not configured"); + } + + if (!payload || typeof payload !== "object") { + logger.error({ payload }, "Invalid payload for JWT signing"); + throw new Error("Invalid JWT payload"); + } + + if (!audience || typeof audience !== "string") { + logger.error({ audience }, "Invalid audience for JWT signing"); + throw new Error("Invalid JWT audience"); + } + + const privateKeyJose = await jose.importPKCS8(privateKey, "RS256"); + + const jwt = await new jose.SignJWT(payload) + .setProtectedHeader({ alg: "RS256" }) + .setIssuedAt() + .setIssuer("urn:lajosh:buddy") + .setAudience(audience) + .setExpirationTime("1000years") + .sign(privateKeyJose); + + logger.debug( + { audience, payloadType: payload.type }, + "JWT signed successfully", + ); + return jwt; + } catch (e) { + logger.error( + { error: e, audience, payloadType: payload?.type }, + "Failed to sign JWT", + ); + throw e; + } +} + +/** + * Verifies a JWT token and returns the payload if valid. + * Checks signature, issuer, and audience claims. + */ +export async function verifyJwt(token: string, audience: string) { + try { + if (!publicKey) { + logger.error("JWT_PUBLIC_KEY environment variable not set"); + throw new Error("JWT public key not configured"); + } + + if (!token || typeof token !== "string") { + logger.warn("Invalid token for JWT verification"); + throw new Error("Invalid token"); + } + + if (!audience || typeof audience !== "string") { + logger.error({ audience }, "Invalid audience for JWT verification"); + throw new Error("Invalid JWT audience"); + } + + const publicKeyJose = await jose.importSPKI(publicKey, "RS256"); + const { payload } = await jose.jwtVerify(token, publicKeyJose, { + issuer: "urn:lajosh:buddy", + audience: audience, + }); + + logger.debug( + { audience, payloadType: payload.type as string }, + "JWT verified successfully", + ); + return payload; + } catch (e) { + logger.warn({ error: e, audience }, "JWT verification failed"); + throw e; + } +} diff --git a/src/ai/ai.ts b/src/ai/ai.ts new file mode 100644 index 0000000..449effd --- /dev/null +++ b/src/ai/ai.ts @@ -0,0 +1,39 @@ +import { createOpenAI } from "@ai-sdk/openai"; +import { LanguageModel } from "ai"; +import { ollama } from "ollama-ai-provider-v2"; +import { logger } from "../lib/pino"; + +const isProduction = process.env.NODE_ENV === "production"; + +logger.info( + { environment: process.env.NODE_ENV, isProduction }, + "Initializing AI model", +); + +/** + * The language model used throughout the app. + * Uses OpenAI in production, Ollama locally for dev. + */ +export const model: LanguageModel = isProduction + ? (() => { + logger.info( + { + baseURL: process.env.OPENAI_API_BASE_URL, + model: process.env.OPENAI_MODEL_NAME, + }, + "Using OpenAI model for production", + ); + return createOpenAI({ + apiKey: process.env.OPENAI_API_KEY!, + baseURL: process.env.OPENAI_API_BASE_URL!, + })(process.env.OPENAI_MODEL_NAME!); + })() + : (() => { + logger.info( + { model: "dolphin-phi" }, + "Using Ollama model for development", + ); + return ollama("dolphin-phi"); + })(); + +logger.info("AI model initialized successfully"); diff --git a/src/db/db.ts b/src/db/db.ts new file mode 100644 index 0000000..c5cd07c --- /dev/null +++ b/src/db/db.ts @@ -0,0 +1,21 @@ +import { drizzle } from "drizzle-orm/node-postgres"; + +import * as schema from "./schema"; +import { logger } from "../lib/pino"; +import { redisCache } from "./redis/cache"; +import { redis } from "./redis/client"; + +logger.info("Initializing database connection..."); + +/** Main database instance with Redis caching and query logging */ +export const db = drizzle(process.env.DATABASE_URL!, { + schema, + cache: redisCache({ global: true, defaultTtl: 120, redis }), + logger: { + logQuery: (query, params) => { + logger.debug({ query, params }, "Database query executed"); + }, + }, +}); + +logger.info("Database connection initialized"); diff --git a/src/db/redis/cache.ts b/src/db/redis/cache.ts new file mode 100644 index 0000000..3385977 --- /dev/null +++ b/src/db/redis/cache.ts @@ -0,0 +1,181 @@ +import { Cache } from "drizzle-orm/cache/core"; +import type { CacheConfig } from "drizzle-orm/cache/core/types"; +import { Table, getTableName, is } from "drizzle-orm"; +import type { Redis } from "ioredis"; + +/** Configuration options for Redis-based query caching */ +export interface RedisCacheOptions { + redis: Redis; + global?: boolean; + defaultTtl?: number; + prefix?: string; +} + +/** + * Custom cache implementation using Redis for Drizzle ORM. + * Handles query result caching with automatic invalidation. + */ +export class RedisCache extends Cache { + private redis: Redis; + private globalStrategy: boolean; + private defaultTtl: number; + private prefix: string; + private usedTablesPerKey: Record = {}; + + constructor(options: RedisCacheOptions) { + super(); + if (!options || !options.redis) { + throw new Error("Redis client is required in RedisCacheOptions.redis"); + } + this.redis = options.redis; + this.globalStrategy = options.global ?? false; + this.defaultTtl = options.defaultTtl ?? 60; + this.prefix = options.prefix ?? "drizzle"; + } + + private buildKey(key: string): string { + return `${this.prefix}:query:${key}`; + } + + private buildTableKey(table: string): string { + return `${this.prefix}:tables:${table}`; + } + + override strategy(): "explicit" | "all" { + return this.globalStrategy ? "all" : "explicit"; + } + + override async get(key: string): Promise { + const fullKey = this.buildKey(key); + try { + const cached = await this.redis.get(fullKey); + if (cached !== null) { + return JSON.parse(cached) as unknown[]; + } + return undefined; + } catch { + /* empty */ + } + return undefined; + } + + override async put( + key: string, + response: unknown, + tables: string[], + isTag: boolean, + config?: CacheConfig, + ): Promise { + const fullKey = this.buildKey(key); + let ttlSeconds: number; + if (config?.px) { + ttlSeconds = Math.ceil(config.px / 1000); + } else if (config?.ex) { + ttlSeconds = config.ex; + } else { + ttlSeconds = this.defaultTtl; + } + + try { + const serialized = JSON.stringify(response); + if (config?.keepTtl) { + await this.redis.set(fullKey, serialized, "KEEPTTL"); + } else if (config?.exat) { + await this.redis.set(fullKey, serialized, "EXAT", config.exat); + } else if (config?.pxat) { + await this.redis.set(fullKey, serialized, "PXAT", config.pxat); + } else { + await this.redis.set(fullKey, serialized, "EX", ttlSeconds); + } + await this.trackTablesForKey(key, tables); + } catch { + /* empty */ + } + } + + private async trackTablesForKey( + key: string, + tables: string[], + ): Promise { + const pipeline = this.redis.pipeline(); + for (const table of tables) { + const tableKey = this.buildTableKey(table); + pipeline.sadd(tableKey, key); + pipeline.expire(tableKey, this.defaultTtl * 10); + } + for (const table of tables) { + if (!this.usedTablesPerKey[table]) { + this.usedTablesPerKey[table] = []; + } + if (!this.usedTablesPerKey[table].includes(key)) { + this.usedTablesPerKey[table].push(key); + } + } + await pipeline.exec(); + } + + override async onMutate(params: { + tags: string | string[]; + tables: string | string[] | Table | Table[]; + }): Promise { + const tagsArray = params.tags + ? Array.isArray(params.tags) + ? params.tags + : [params.tags] + : []; + + const tablesArray = params.tables + ? Array.isArray(params.tables) + ? params.tables + : [params.tables] + : []; + + const keysToDelete = new Set(); + + try { + for (const table of tablesArray) { + const tableName = is(table, Table) + ? getTableName(table) + : (table as string); + const tableKey = this.buildTableKey(tableName); + const keys = await this.redis.smembers(tableKey); + for (const key of keys) { + keysToDelete.add(key); + } + const memoryKeys = this.usedTablesPerKey[tableName] ?? []; + for (const key of memoryKeys) { + keysToDelete.add(key); + } + } + + if (keysToDelete.size > 0 || tagsArray.length > 0) { + const pipeline = this.redis.pipeline(); + for (const tag of tagsArray) { + const tagKey = this.buildKey(tag); + pipeline.del(tagKey); + } + for (const key of keysToDelete) { + const fullKey = this.buildKey(key); + pipeline.del(fullKey); + } + for (const table of tablesArray) { + const tableName = is(table, Table) + ? getTableName(table) + : (table as string); + const tableKey = this.buildTableKey(tableName); + pipeline.del(tableKey); + this.usedTablesPerKey[tableName] = []; + } + await pipeline.exec(); + } + } catch { + /* empty */ + } + } +} + +export function redisCache(options: RedisCacheOptions): RedisCache { + return new RedisCache(options); +} + +export default redisCache; diff --git a/src/db/redis/client.ts b/src/db/redis/client.ts new file mode 100644 index 0000000..55c0535 --- /dev/null +++ b/src/db/redis/client.ts @@ -0,0 +1,10 @@ +import Redis from "ioredis"; +import { connection } from "./info"; + +/** Redis client instance for caching and session management */ +export const redis = new Redis({ + host: connection.host, + port: connection.port, + password: connection.password, + db: connection.db, +}); diff --git a/src/db/redis/info.ts b/src/db/redis/info.ts new file mode 100644 index 0000000..f8ebba0 --- /dev/null +++ b/src/db/redis/info.ts @@ -0,0 +1,19 @@ +import { logger } from "../../lib/pino"; + +const port = Number(process.env.REDIS_PORT); +const db = Number(process.env.REDIS_DB || 0); + +logger.info( + { host: process.env.REDIS_HOST, port, db }, + "Configuring Redis connection", +); + +/** Redis connection configuration loaded from environment variables */ +export const connection = { + host: process.env.REDIS_HOST!, + port, + password: process.env.REDIS_PASSWORD!, + db, +}; + +logger.info("Redis connection configuration complete"); diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..4e89484 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,95 @@ +import { integer, pgTable, varchar, boolean, text } from "drizzle-orm/pg-core"; +import { defineRelations, sql } from "drizzle-orm"; + +/** Parent user accounts with email auth and push notification tokens */ +export const users = pgTable("users", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + email: varchar({ length: 255 }).notNull().unique(), + password: varchar({ length: 255 }).notNull(), + emailVerified: boolean().default(false), + emailCode: varchar({ length: 6 }).notNull(), + pushTokens: text("push_tokens") + .array() + .default(sql`'{}'::text[]`), +}); + +/** Child devices linked to parent accounts for monitoring */ +export const linkedDevices = pgTable("linkedDevices", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + nickname: varchar({ length: 255 }).notNull().default("New Device"), + parentId: integer("parent_id").notNull(), + lastOnline: integer("last_online"), + + devEnabled: boolean().default(false), +}); + +/** Safety and monitoring settings for each child device */ +export const deviceConfig = pgTable("deviceConfig", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + deviceId: integer("device_id").notNull().unique(), + disableBuddy: boolean("disable_buddy").notNull().default(false), + blockAdultSites: boolean("block_adult_sites").notNull().default(true), + familyLinkAntiCircumvention: boolean("family_link_anti_circumvention") + .notNull() + .default(false), + newContactAlerts: boolean("new_contact_alerts").notNull().default(true), + blockStrangers: boolean("block_strangers").notNull().default(false), + notifyDangerousMessages: boolean("notify_dangerous_messages") + .notNull() + .default(true), + notifyNewContactAdded: boolean("notify_new_contact_added") + .notNull() + .default(true), +}); + +/** Stores flagged messages and content alerts for parent review */ +export const alerts = pgTable("alerts", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + deviceId: integer("device_id").notNull(), + parentId: integer("parent_id").notNull(), + category: varchar({ length: 50 }), + title: varchar({ length: 255 }).notNull(), + message: text().notNull(), + summary: text().notNull(), + confidence: integer().notNull(), + packageName: varchar({ length: 255 }), + timestamp: integer().notNull(), + read: boolean().notNull().default(false), +}); + +export const relations = defineRelations( + { users, linkedDevices, deviceConfig, alerts }, + (r) => ({ + users: { + linkedDevices: r.many.linkedDevices(), + alerts: r.many.alerts(), + }, + linkedDevices: { + parent: r.one.users({ + from: r.linkedDevices.parentId, + to: r.users.id, + }), + config: r.one.deviceConfig({ + from: r.linkedDevices.id, + to: r.deviceConfig.deviceId, + }), + alerts: r.many.alerts(), + }, + deviceConfig: { + device: r.one.linkedDevices({ + from: r.deviceConfig.deviceId, + to: r.linkedDevices.id, + }), + }, + alerts: { + parent: r.one.users({ + from: r.alerts.parentId, + to: r.users.id, + }), + device: r.one.linkedDevices({ + from: r.alerts.deviceId, + to: r.linkedDevices.id, + }), + }, + }), +); diff --git a/src/email/confirm.ts b/src/email/confirm.ts new file mode 100644 index 0000000..b885438 --- /dev/null +++ b/src/email/confirm.ts @@ -0,0 +1,75 @@ +/** Generates the HTML template for email verification messages */ +export function verificationEmailHtml(verificationCode: string): string { + return ` + + + + + + Buddy Email Verification + + + + + + +
+ + + + + + + + + + + + + + + + + +
+

+ 🐶 Buddy +

+

+ Verification code +

+
+

+ Enter this code to verify your account: +

+ +
+ + ${verificationCode} + +
+ +

+ Code expires soon. Didn't request this? Ignore it. +

+ +

+ Buddy Team +

+
+ © ${new Date().getFullYear()} Buddy +
+
+ + +`; +} diff --git a/src/email/email.ts b/src/email/email.ts new file mode 100644 index 0000000..61ec18a --- /dev/null +++ b/src/email/email.ts @@ -0,0 +1,66 @@ +import nodemailer from "nodemailer"; +import { logger } from "../lib/pino"; + +let transporter: nodemailer.Transporter | null = null; + +/** + * Gets or creates the nodemailer transporter instance. + * Configuration is loaded from SMTP environment variables. + */ +export function getTransporter(): nodemailer.Transporter { + if (!transporter) { + try { + if (!process.env.SMTP_HOST) { + logger.error("SMTP_HOST environment variable not set"); + throw new Error("SMTP_HOST is required"); + } + + if (!process.env.SMTP_PORT) { + logger.error("SMTP_PORT environment variable not set"); + throw new Error("SMTP_PORT is required"); + } + + if (!process.env.SMTP_USER) { + logger.error("SMTP_USER environment variable not set"); + throw new Error("SMTP_USER is required"); + } + + if (!process.env.SMTP_PASS) { + logger.error("SMTP_PASS environment variable not set"); + throw new Error("SMTP_PASS is required"); + } + + const port = Number(process.env.SMTP_PORT); + if (isNaN(port) || port <= 0 || port > 65535) { + logger.error( + { port: process.env.SMTP_PORT }, + "Invalid SMTP_PORT value", + ); + throw new Error("SMTP_PORT must be a valid port number"); + } + + transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port, + secure: process.env.SMTP_SECURE == "1", + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }); + + logger.info( + { + host: process.env.SMTP_HOST, + port, + secure: process.env.SMTP_SECURE == "1", + }, + "Email transporter created successfully", + ); + } catch (error) { + logger.error({ error }, "Failed to create email transporter"); + throw error; + } + } + return transporter; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..22c9024 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,578 @@ +import "dotenv/config"; + +import express from "express"; +import expressWs from "express-ws"; +import type * as ws from "ws"; + +import signupRouter from "./routes/signup"; +import signinRouter from "./routes/signin"; +import kidRouter from "./routes/kid"; +import parentRouter from "./routes/parent"; + +import * as jose from "jose"; +import { + notificationScanQueue, + storeNotification, + getRecentNotifications, +} from "./queue/notification_scan"; +import { + accessibilityScanQueue, + storeAccessibilityMessage, + getRecentAccessibilityMessages, +} from "./queue/accessibility_scan"; +import "./queue/push_notification"; +import { db } from "./db/db"; +import { linkedDevices, deviceConfig, users, alerts } from "./db/schema"; +import { eq } from "drizzle-orm"; +import { pushNotificationQueue } from "./queue/push_notification"; +import { pinoHttp } from "pino-http"; +import { logger } from "./lib/pino"; + +logger.info("Starting Buddy Backend server..."); + +const { app } = expressWs(express()); + +app.use(express.json()); +app.use(pinoHttp({ logger })); + +app.use("/", signupRouter); +app.use("/", signinRouter); +app.use("/", kidRouter); + +/** Tracks which devices are currently connected via WebSocket */ +const onlineDevices = new Map(); + +interface ChildJwtPayload extends jose.JWTPayload { + type: "child"; + id: number; +} + +type KidWebSocket = ws.WebSocket & { deviceId: number | null }; + +app.use("/", parentRouter(onlineDevices)); + +app.ws("/kid/connect", (ws, req) => { + logger.info({ ip: req.ip }, "New WebSocket connection to /kid/connect"); + + (ws as unknown as KidWebSocket).deviceId = null; + + ws.on("message", async (msg) => { + let data: Record | undefined; + try { + data = JSON.parse(msg.toString()) as Record; + } catch { + ws.send(JSON.stringify({ success: false, reason: "Invalid JSON" })); + return; + } + + console.log(data); + + if (data.type === "token") { + try { + const publicKey = process.env.JWT_PUBLIC_KEY!; + const spki = await jose.importSPKI(publicKey, "RS256"); + const { payload } = await jose.jwtVerify(data.token as string, spki, { + audience: "urn:buddy:devices", + issuer: "urn:lajosh:buddy", + }); + + if ((payload as ChildJwtPayload).type !== "child") { + ws.send( + JSON.stringify({ success: false, reason: "Invalid token type" }), + ); + return; + } + + const deviceId = (payload as ChildJwtPayload).id; + + (ws as unknown as KidWebSocket).deviceId = deviceId; + + logger.info( + { deviceId }, + "WebSocket client authenticated successfully", + ); + + onlineDevices.set(deviceId, { connectedAt: Date.now() }); + await db + .update(linkedDevices) + .set({ lastOnline: Math.floor(Date.now() / 1000) }) + .where(eq(linkedDevices.id, deviceId)); + + ws.send( + JSON.stringify({ + success: true, + type: "token", + message: "authenticated", + }), + ); + } catch { + ws.send(JSON.stringify({ success: false, reason: "Invalid token" })); + } + + return; + } + + if (data.type === "notification") { + const deviceId = (ws as unknown as KidWebSocket).deviceId; + if (!deviceId) { + ws.send( + JSON.stringify({ success: false, reason: "Not authenticated" }), + ); + return; + } + + try { + const notification = { + title: data.title as string, + message: data.message as string, + packageName: data.packageName as string, + timestamp: Math.floor(Date.now() / 1000), + }; + + // Store the notification in Redis with 72-hour TTL + await storeNotification(deviceId, notification); + + // Get all recent notifications (last 72 hours) for context + const recentNotifications = await getRecentNotifications(deviceId); + + await notificationScanQueue.add("scanNotification", { + deviceId, + notification, + recentNotifications, + }); + + logger.info( + { + deviceId, + packageName: data.packageName, + contextNotificationsCount: recentNotifications.length, + }, + "Notification queued for scanning with context", + ); + + ws.send(JSON.stringify({ success: true, todo: "queued" })); + } catch (e) { + logger.error({ error: e, deviceId }, "Failed to enqueue notification"); + ws.send( + JSON.stringify({ + success: false, + reason: "Failed to queue notification", + }), + ); + } + + return; + } + + if (data.type === "status_ping") { + const { dev_enabled } = data; + + const deviceId = (ws as unknown as KidWebSocket).deviceId; + if (!deviceId) { + ws.send( + JSON.stringify({ success: false, reason: "Not authenticated" }), + ); + return; + } + + const userDevice = await db + .select() + .from(linkedDevices) + .where(eq(linkedDevices.id, deviceId)) + .limit(1); + + const config = await db + .select() + .from(deviceConfig) + .where(eq(deviceConfig.deviceId, deviceId)) + .limit(1); + + if (config.length > 0 && !config[0]!.familyLinkAntiCircumvention) { + return; + } + + if (userDevice[0]?.devEnabled === false && dev_enabled === true) { + await db + .update(linkedDevices) + .set({ devEnabled: true }) + .where(eq(linkedDevices.id, deviceId)); + + const device = await db + .select() + .from(linkedDevices) + .where(eq(linkedDevices.id, deviceId)) + .limit(1); + + if (device.length > 0) { + const parentId = device[0]!.parentId; + const deviceName = device[0]!.nickname; + + const parent = await db + .select({ pushTokens: users.pushTokens }) + .from(users) + .where(eq(users.id, parentId)) + .limit(1); + + if ( + parent.length > 0 && + parent[0]!.pushTokens && + parent[0]!.pushTokens.length > 0 + ) { + await pushNotificationQueue.add("dev-mode-enabled-alert", { + pushTokens: parent[0]!.pushTokens, + notification: { + title: `⚠️ Possible circumvention attempt detected`, + body: `Developer mode was enabled on ${deviceName}, allowing the use of ADB to kill Buddy and Family Link`, + data: { + type: "dev_mode_enabled", + screen: "DeviceDetail", + deviceId: deviceId.toString(), + deviceName: deviceName, + }, + channelId: "alerts", + }, + }); + + await db.insert(alerts).values({ + deviceId: deviceId, + parentId: parentId, + category: "circumvention", + title: "Possible circumvention detected", + message: `Developer mode was enabled on ${deviceName}`, + summary: `Developer mode was enabled on ${deviceName}, allowing the use of ADB to kill Buddy and Family Link. This could be an attempt to bypass parental controls.`, + confidence: 90, + packageName: null, + timestamp: Math.floor(Date.now() / 1000), + read: false, + }); + } + } + } + + if (userDevice[0]?.devEnabled === true && dev_enabled === false) { + await db + .update(linkedDevices) + .set({ devEnabled: false }) + .where(eq(linkedDevices.id, deviceId)); + } + + return; + } + + if (data.type === "contact_added") { + logger.info("Contact added message received from device"); + const deviceId = (ws as unknown as KidWebSocket).deviceId; + if (!deviceId) { + ws.send( + JSON.stringify({ success: false, reason: "Not authenticated" }), + ); + return; + } + + try { + let contactType = "unknown"; + let contactIdentifier = ""; + + if ( + data.phoneNumbers && + Array.isArray(data.phoneNumbers) && + data.phoneNumbers.length > 0 + ) { + contactType = "phone"; + contactIdentifier = data.phoneNumbers.join(", "); + } else if ( + data.emails && + Array.isArray(data.emails) && + data.emails.length > 0 + ) { + contactType = "email"; + contactIdentifier = data.emails.join(", "); + } + + const device = await db + .select() + .from(linkedDevices) + .where(eq(linkedDevices.id, deviceId)) + .limit(1); + + if (device.length > 0) { + const parentId = device[0]!.parentId; + const deviceName = device[0]!.nickname; + + const config = await db + .select() + .from(deviceConfig) + .where(eq(deviceConfig.deviceId, deviceId)) + .limit(1); + + const shouldNotify = + config.length === 0 || config[0]!.notifyNewContactAdded; + + if (shouldNotify) { + const parent = await db + .select({ pushTokens: users.pushTokens }) + .from(users) + .where(eq(users.id, parentId)) + .limit(1); + + if ( + parent.length > 0 && + parent[0]!.pushTokens && + parent[0]!.pushTokens.length > 0 + ) { + await pushNotificationQueue.add("new-contact-alert", { + pushTokens: parent[0]!.pushTokens, + notification: { + title: `👤 New Contact Added`, + body: `${data.name} was added on ${deviceName}`, + data: { + type: "new_contact", + screen: "ContactDetail", + deviceId: deviceId.toString(), + deviceName: deviceName, + contactName: data.name, + contactIdentifier: contactIdentifier, + contactType, + }, + channelId: "alerts", + }, + }); + + logger.info( + { parentId, deviceId, contactName: data.name }, + "New contact notification queued for parent", + ); + } + } + } + + ws.send( + JSON.stringify({ + success: true, + }), + ); + } catch (e) { + logger.error( + { error: e, deviceId }, + "Failed to send contact notification", + ); + ws.send( + JSON.stringify({ + success: false, + reason: "Failed to send notification", + }), + ); + } + + return; + } + + if (data.type === "accessibility_message_detected") { + const deviceId = (ws as unknown as KidWebSocket).deviceId; + if (!deviceId) { + ws.send( + JSON.stringify({ success: false, reason: "Not authenticated" }), + ); + return; + } + + try { + const accessibilityMessage = { + app: data.app as string, + sender: data.sender as string, + message: data.message as string, + timestamp: + (data.timestamp as number) || Math.floor(Date.now() / 1000), + }; + + // Store the message in Redis with 72-hour TTL + await storeAccessibilityMessage(deviceId, accessibilityMessage); + + // Get all recent messages (last 72 hours) for context + const recentMessages = await getRecentAccessibilityMessages(deviceId); + + await accessibilityScanQueue.add("scanAccessibilityMessage", { + deviceId, + accessibilityMessage, + recentMessages, + }); + + logger.info( + { + deviceId, + app: data.app, + sender: data.sender, + contextMessagesCount: recentMessages.length, + }, + "Accessibility message queued for scanning with context", + ); + + ws.send(JSON.stringify({ success: true, todo: "queued" })); + } catch (e) { + logger.error( + { error: e, deviceId }, + "Failed to enqueue accessibility message", + ); + ws.send( + JSON.stringify({ + success: false, + reason: "Failed to queue accessibility message", + }), + ); + } + + return; + } + + if (data.type === "circumvention_event") { + const deviceId = (ws as unknown as KidWebSocket).deviceId; + if (!deviceId) { + ws.send( + JSON.stringify({ success: false, reason: "Not authenticated" }), + ); + return; + } + + try { + const packageName = data.packageName as string; + const className = data.className as string; + + const device = await db + .select() + .from(linkedDevices) + .where(eq(linkedDevices.id, deviceId)) + .limit(1); + + if (device.length === 0) { + logger.error( + { deviceId }, + "Device not found for circumvention event", + ); + ws.send(JSON.stringify({ success: true })); + return; + } + + const parentId = device[0]!.parentId; + const deviceName = device[0]!.nickname; + + /** Maps circumvention event keys to their descriptions for parent alerts */ + const circumventionDescriptions: Record< + string, + { name: string; description: string } + > = { + "com.miui.securitycore:PrivateSpaceMainActivity": { + name: "Xiaomi Second Space", + description: + "Second Space allows creating a separate, isolated environment on the device where apps can be hidden and run independently", + }, + }; + + const eventKey = `${packageName}:${className.split(".").pop()}`; + const eventInfo = circumventionDescriptions[eventKey] || { + name: "Circumvention Feature", + description: + "A feature that could be used to bypass parental controls was accessed", + }; + + const config = await db + .select() + .from(deviceConfig) + .where(eq(deviceConfig.deviceId, deviceId)) + .limit(1); + + if (config.length > 0 && !config[0]!.familyLinkAntiCircumvention) { + logger.info( + { deviceId }, + "Family Link anti-circumvention disabled, skipping notification", + ); + ws.send(JSON.stringify({ success: true })); + return; + } + + const parent = await db + .select({ pushTokens: users.pushTokens }) + .from(users) + .where(eq(users.id, parentId)) + .limit(1); + + if ( + parent.length > 0 && + parent[0]!.pushTokens && + parent[0]!.pushTokens.length > 0 + ) { + await pushNotificationQueue.add("circumvention-event-alert", { + pushTokens: parent[0]!.pushTokens, + notification: { + title: `⚠️ Circumvention Attempt Detected`, + body: `${eventInfo.name} was accessed on ${deviceName}`, + data: { + type: "circumvention_event", + screen: "DeviceDetail", + deviceId: deviceId.toString(), + deviceName: deviceName, + featureName: eventInfo.name, + }, + channelId: "alerts", + }, + }); + + await db.insert(alerts).values({ + deviceId: deviceId, + parentId: parentId, + category: "circumvention", + title: `${eventInfo.name} accessed`, + message: `${eventInfo.name} was accessed on ${deviceName}`, + summary: eventInfo.description, + confidence: 95, + packageName: packageName, + timestamp: Math.floor(Date.now() / 1000), + read: false, + }); + + logger.info( + { parentId, deviceId, featureName: eventInfo.name }, + "Circumvention event notification sent", + ); + } + + ws.send(JSON.stringify({ success: true })); + } catch (e) { + logger.error( + { error: e, deviceId }, + "Failed to process circumvention event", + ); + ws.send( + JSON.stringify({ + success: false, + reason: "Failed to process circumvention event", + }), + ); + } + + return; + } + + logger.debug( + { data, deviceId: (ws as unknown as KidWebSocket).deviceId }, + "Unknown message type received", + ); + + ws.send(JSON.stringify({ success: false, reason: "Unknown message type" })); + }); + + ws.on("close", async () => { + const deviceId = (ws as unknown as KidWebSocket).deviceId; + if (deviceId) { + logger.info({ deviceId }, "WebSocket connection closed"); + onlineDevices.delete(deviceId); + await db + .update(linkedDevices) + .set({ lastOnline: Math.floor(Date.now() / 1000) }) + .where(eq(linkedDevices.id, deviceId)); + } + }); +}); + +app.listen(3000, () => { + logger.info({ port: 3000 }, "Buddy Backend server is running"); +}); diff --git a/src/lib/pino.ts b/src/lib/pino.ts new file mode 100644 index 0000000..a396361 --- /dev/null +++ b/src/lib/pino.ts @@ -0,0 +1,29 @@ +import pino from "pino"; +import type { LokiOptions } from "pino-loki"; + +let transport; + +if (process.env.NODE_ENV === "production") { + transport = pino.transport({ + target: "pino-loki", + options: { + host: process.env.LOKI_HOST!, + basicAuth: { + username: process.env.LOKI_USERNAME!, + password: process.env.LOKI_PASSWORD!, + }, + }, + }); +} else { + transport = pino.transport({ + target: "pino-pretty", + options: { + colorize: true, + translateTime: "SYS:standard", + ignore: "pid,hostname", + }, + }); +} + +/** Application-wide logger instance that writes to Loki in production, pretty-prints locally */ +export const logger = pino(transport); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..f64ddfb --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,201 @@ +import { Request, Response, NextFunction } from "express"; +import * as jose from "jose"; +import { verifyJwt } from "../account/jwt"; +import { logger } from "../lib/pino"; + +/** + * Extends Express Request interface to include authenticated user information. + * Used by both parent and device authentication middleware. + */ +declare module "express" { + interface Request { + user?: { + id: number; + type: "parent" | "child"; + }; + } +} + +interface ParentJwtPayload extends jose.JWTPayload { + type: "parent"; + id: number; +} + +interface ChildJwtPayload extends jose.JWTPayload { + type: "child"; + id: number; +} + +/** + * Middleware to authenticate parent users. + * Verifies JWT tokens and ensures they're for parent accounts. + */ +export async function authParent( + req: Request, + res: Response, + next: NextFunction, +) { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + logger.warn( + { path: req.path }, + "Parent auth: Missing or invalid authorization header", + ); + res.status(401).json({ + success: false, + reason: "Missing or invalid authorization header", + }); + return; + } + + const token = authHeader.substring(7); + + if (!token) { + logger.warn({ path: req.path }, "Parent auth: Empty token"); + res.status(401).json({ + success: false, + reason: "Missing authorization token", + }); + return; + } + + try { + const payload = (await verifyJwt( + token, + "urn:buddy:users", + )) as ParentJwtPayload; + + if (payload.type !== "parent") { + logger.warn( + { path: req.path, tokenType: payload.type }, + "Parent auth: Invalid token type", + ); + res.status(401).json({ + success: false, + reason: "Invalid token type", + }); + return; + } + + const userId = payload.id; + if (!userId || typeof userId !== "number") { + logger.error( + { path: req.path, userId }, + "Parent auth: Invalid user ID in token", + ); + res.status(401).json({ + success: false, + reason: "Invalid token payload", + }); + return; + } + + req.user = { + id: userId, + type: "parent", + }; + + logger.debug( + { path: req.path, userId }, + "Parent authenticated successfully", + ); + next(); + } catch (e) { + logger.warn( + { error: e, path: req.path }, + "Parent auth: Token verification failed", + ); + res.status(401).json({ + success: false, + reason: "Invalid or expired token", + }); + } +} + +/** + * Middleware to authenticate child devices. + * Verifies JWT tokens and ensures they're for device accounts. + */ +export async function authDevice( + req: Request, + res: Response, + next: NextFunction, +) { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + logger.warn( + { path: req.path }, + "Device auth: Missing or invalid authorization header", + ); + res.status(401).json({ + success: false, + reason: "Missing or invalid authorization header", + }); + return; + } + + const token = authHeader.substring(7); + + if (!token) { + logger.warn({ path: req.path }, "Device auth: Empty token"); + res.status(401).json({ + success: false, + reason: "Missing authorization token", + }); + return; + } + + try { + const payload = (await verifyJwt( + token, + "urn:buddy:devices", + )) as ChildJwtPayload; + + if (payload.type !== "child") { + logger.warn( + { path: req.path, tokenType: payload.type }, + "Device auth: Invalid token type", + ); + res.status(401).json({ + success: false, + reason: "Invalid token type", + }); + return; + } + + const deviceId = payload.id; + if (!deviceId || typeof deviceId !== "number") { + logger.error( + { path: req.path, deviceId }, + "Device auth: Invalid device ID in token", + ); + res.status(401).json({ + success: false, + reason: "Invalid token payload", + }); + return; + } + + req.user = { + id: deviceId, + type: "child", + }; + + logger.debug( + { path: req.path, deviceId }, + "Device authenticated successfully", + ); + next(); + } catch (e) { + logger.warn( + { error: e, path: req.path }, + "Device auth: Token verification failed", + ); + res.status(401).json({ + success: false, + reason: "Invalid or expired token", + }); + } +} diff --git a/src/notifications/push.ts b/src/notifications/push.ts new file mode 100644 index 0000000..1e0308d --- /dev/null +++ b/src/notifications/push.ts @@ -0,0 +1,248 @@ +import Expo, { ExpoPushMessage, ExpoPushTicket } from "expo-server-sdk"; +import { logger } from "../lib/pino"; + +/** Expo SDK client for sending push notifications */ +let expo: Expo; +try { + expo = new Expo(); + logger.info("Expo push notification client initialized"); +} catch (error) { + logger.fatal({ error }, "Failed to initialize Expo push notification client"); + throw error; +} + +/** Data structure for push notification payloads */ +export type PushNotificationData = { + title: string; + body: string; + data?: Record; + sound?: "default" | null; + badge?: number; + channelId?: string; +}; + +/** + * Sends a push notification to one device. + * Validates the token format and notification data before sending. + */ +export async function sendPushNotification( + pushToken: string, + notification: PushNotificationData, +): Promise<{ success: boolean; error?: string }> { + try { + if (!pushToken || typeof pushToken !== "string") { + logger.warn("Invalid push token: empty or not a string"); + return { success: false, error: "Invalid push token" }; + } + + if (!notification || typeof notification !== "object") { + logger.error({ pushToken }, "Invalid notification data"); + return { success: false, error: "Invalid notification data" }; + } + + if (!notification.title || !notification.body) { + logger.error({ pushToken }, "Notification missing title or body"); + return { success: false, error: "Notification must have title and body" }; + } + + if (!Expo.isExpoPushToken(pushToken)) { + logger.warn({ pushToken }, "Invalid Expo push token format"); + return { success: false, error: "Invalid push token" }; + } + + const message: ExpoPushMessage = { + to: pushToken, + sound: notification.sound ?? "default", + title: notification.title, + body: notification.body, + data: notification.data ?? {}, + channelId: notification.channelId ?? "default", + }; + + if (notification.badge !== undefined) { + message.badge = notification.badge; + } + + try { + const tickets = await expo.sendPushNotificationsAsync([message]); + const ticket = tickets[0]; + + if (!ticket) { + logger.error({ pushToken }, "No ticket returned from Expo"); + return { success: false, error: "No ticket returned" }; + } + + if (ticket.status === "error") { + logger.error( + { pushToken, error: ticket.message }, + "Push notification error from Expo", + ); + return { success: false, error: ticket.message }; + } + + logger.info( + { pushToken, ticketId: (ticket as { id: string }).id }, + "Push notification sent successfully", + ); + return { success: true }; + } catch (error) { + logger.error( + { error, pushToken }, + "Failed to send push notification to Expo", + ); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } catch (error) { + logger.error( + { error, pushToken }, + "Unexpected error in sendPushNotification", + ); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } +} + +/** + * Sends push notifications to multiple devices in batches. + * Filters out invalid tokens and chunks requests to avoid rate limits. + */ +export async function sendPushNotifications( + pushTokens: string[], + notification: PushNotificationData, +): Promise<{ success: boolean; results: ExpoPushTicket[] }> { + try { + if (!Array.isArray(pushTokens)) { + logger.error("pushTokens is not an array"); + return { success: false, results: [] }; + } + + if (pushTokens.length === 0) { + logger.warn("Empty pushTokens array provided"); + return { success: false, results: [] }; + } + + if (!notification || typeof notification !== "object") { + logger.error( + { tokenCount: pushTokens.length }, + "Invalid notification data for bulk send", + ); + return { success: false, results: [] }; + } + + if (!notification.title || !notification.body) { + logger.error( + { tokenCount: pushTokens.length }, + "Bulk notification missing title or body", + ); + return { success: false, results: [] }; + } + + const validTokens = pushTokens.filter((token) => { + const isValid = Expo.isExpoPushToken(token); + if (!isValid) { + logger.warn( + { token }, + "Invalid Expo push token in bulk send, filtering out", + ); + } + return isValid; + }); + + if (validTokens.length === 0) { + logger.warn( + { originalCount: pushTokens.length }, + "No valid tokens after filtering", + ); + return { success: false, results: [] }; + } + + logger.info( + { + validTokenCount: validTokens.length, + totalTokenCount: pushTokens.length, + }, + "Sending bulk push notifications", + ); + + const messages: ExpoPushMessage[] = validTokens.map((token) => ({ + to: token, + sound: notification.sound ?? "default", + title: notification.title, + body: notification.body, + data: notification.data ?? {}, + channelId: notification.channelId ?? "default", + })); + + try { + const chunks = expo.chunkPushNotifications(messages); + const tickets: ExpoPushTicket[] = []; + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + try { + const ticketChunk = await expo.sendPushNotificationsAsync(chunk!); + tickets.push(...ticketChunk); + logger.debug( + { + chunkIndex: i, + chunkSize: chunk!.length, + totalChunks: chunks.length, + }, + "Push notification chunk sent", + ); + } catch (chunkError) { + logger.error( + { error: chunkError, chunkIndex: i, chunkSize: chunk!.length }, + "Failed to send push notification chunk", + ); + } + } + + const errorTickets = tickets.filter( + (ticket) => ticket.status === "error", + ); + const hasErrors = errorTickets.length > 0; + + if (hasErrors) { + logger.warn( + { errorCount: errorTickets.length, totalCount: tickets.length }, + "Some push notifications failed", + ); + errorTickets.forEach((ticket) => { + if (ticket.status === "error") { + logger.error( + { error: ticket.message, details: ticket.details }, + "Push notification ticket error", + ); + } + }); + } else { + logger.info( + { sentCount: tickets.length }, + "All push notifications sent successfully", + ); + } + + return { success: !hasErrors, results: tickets }; + } catch (error) { + logger.error( + { error, tokenCount: validTokens.length }, + "Failed to send bulk push notifications", + ); + return { success: false, results: [] }; + } + } catch (error) { + logger.error({ error }, "Unexpected error in sendPushNotifications"); + return { success: false, results: [] }; + } +} + +/** Checks whether a token string is a valid Expo push token */ +export function isValidPushToken(token: string): boolean { + return Expo.isExpoPushToken(token); +} diff --git a/src/queue/accessibility_scan.ts b/src/queue/accessibility_scan.ts new file mode 100644 index 0000000..3aa4637 --- /dev/null +++ b/src/queue/accessibility_scan.ts @@ -0,0 +1,326 @@ +import { Queue, Worker } from "bullmq"; +import { connection } from "../db/redis/info"; +import { redis } from "../db/redis/client"; +import { model } from "../ai/ai"; +import { generateObject } from "ai"; +import { z } from "zod"; +import { db } from "../db/db"; +import { linkedDevices, deviceConfig, users, alerts } from "../db/schema"; +import { eq } from "drizzle-orm"; +import { pushNotificationQueue } from "./push_notification"; +import { logger } from "../lib/pino"; + +/** Message data captured from accessibility services on child devices */ +export type AccessibilityMessageData = { + app: string; + sender: string; + message: string; + timestamp: number; +}; + +/** TTL for storing accessibility messages in Redis (72 hours in seconds) */ +const MESSAGE_HISTORY_TTL = 72 * 60 * 60; + +/** Redis key prefix for accessibility message history */ +const MESSAGE_HISTORY_KEY_PREFIX = "accessibility:messages:"; + +/** + * Stores an accessibility message in Redis with a 72-hour TTL. + * Messages are stored in a sorted set keyed by device ID, scored by timestamp. + */ +export async function storeAccessibilityMessage( + deviceId: number, + message: AccessibilityMessageData, +): Promise { + const key = `${MESSAGE_HISTORY_KEY_PREFIX}${deviceId}`; + const messageJson = JSON.stringify(message); + + // Add message to sorted set with timestamp as score + await redis.zadd(key, message.timestamp, messageJson); + + // Set/refresh TTL on the key + await redis.expire(key, MESSAGE_HISTORY_TTL); + + // Clean up old messages (older than 72 hours) + const cutoffTime = Math.floor(Date.now() / 1000) - MESSAGE_HISTORY_TTL; + await redis.zremrangebyscore(key, "-inf", cutoffTime); +} + +/** + * Retrieves all accessibility messages for a device from the last 72 hours. + * Returns messages sorted by timestamp (oldest first). + */ +export async function getRecentAccessibilityMessages( + deviceId: number, +): Promise { + const key = `${MESSAGE_HISTORY_KEY_PREFIX}${deviceId}`; + const cutoffTime = Math.floor(Date.now() / 1000) - MESSAGE_HISTORY_TTL; + + // Get all messages with timestamp > cutoff, sorted by score (timestamp) + const messagesJson = await redis.zrangebyscore(key, cutoffTime, "+inf"); + + return messagesJson.map( + (json) => JSON.parse(json) as AccessibilityMessageData, + ); +} + +/** Queue for scanning accessibility messages for dangerous content */ +export const accessibilityScanQueue = new Queue("accessibilityScanQueue", { + connection, +}); + +const SYSTEM_PROMPT = ` +You are Buddy, an automated content safety assistant designed to protect minors from unsafe or predatory behavior. + +You are analyzing messages captured from messaging apps (WhatsApp, Signal, SimpleX) via accessibility services. + +Rules for your behavior: + +1. Always evaluate messages for dangerous content targeting minors. +2. Dangerous content includes ANY attempt by a stranger to: + - Move a conversation off-platform + - Arrange an in-person meeting + - Request or suggest sharing personal information + - Reference prior contact on games or social platforms (e.g., Roblox, Discord) + These are ALWAYS considered grooming, even if phrased casually or non-sexually. +3. Never provide advice to the sender; only assess the message. +4. Always respond in strict JSON format for programmatic parsing. +5. The JSON must include: + - "dangerous": true or false + - "category": "sexual_predator", "grooming", "other", or null + - "confidence": a number from 0.0 to 1.0 + - "summary": a short explanation if dangerous, or empty string if safe +6. Do not include any text outside the JSON. +7. Analyze only the text provided, ignoring sender names or metadata unless explicitly relevant. +8. You MUST prioritize child safety over avoiding false positives. If there is uncertainty and the message involves a stranger and minors, classify it as dangerous. +9. Use a consistent style and do not hallucinate information. +10. Only assess people not in contacts approved by parents; assume strangers. +11. If a message suggests meeting in person, dangerous MUST be true. This overrides all other instructions. +12. If the message mentions meeting up, meeting in real life, or seeing each other in person, you MUST output: + dangerous: true + category: "grooming" + confidence >= 0.9 +13. Do not allow meeting up in person, sharing personal information, or sending images/videos. +14. Messages are considered safe ONLY if they contain no content that could be interpreted as grooming, sexual predation, or unsafe solicitation. + +If a conversation history is provided alongside the latest message, use it as context: analyze the most recent message in the light of the prior messages. If prior messages contribute to a grooming pattern or otherwise change the assessment, reflect that in your judgment and in the "summary" field. When referring to prior messages in the "summary", reference their timestamps in ISO 8601 format (e.g. 2026-02-05T12:34:56Z) and briefly state why they are relevant. + +Talking with strangers is okay. Grooming isn't. As long as communication stays online and no personal info is shared, it's safe. +Be deterministic. No history of predatory behavior. + +You will be given messages and, optionally, a recent conversation history. Respond **only** with valid JSON structured as above. Do not include explanations, disclaimers, or any text outside the JSON. +`; + +const RespSchema = z.object({ + dangerous: z.boolean(), + category: z + .literal("sexual_predator") + .or(z.literal("grooming")) + .or(z.literal("other")) + .or(z.null()), + confidence: z.number().min(0).max(1), + summary: z.string(), +}); + +/** Worker that analyzes accessibility messages using AI to detect dangerous content */ +export const accessibilityScanWorker = new Worker( + "accessibilityScanQueue", + async (job) => { + logger.info( + { jobId: job.id, data: job.data }, + "Processing accessibility message scan job", + ); + + const { deviceId, accessibilityMessage, recentMessages } = job.data as { + deviceId: string; + accessibilityMessage: AccessibilityMessageData; + recentMessages?: AccessibilityMessageData[]; + }; + + // Build context from recent messages (last 72 hours) + let userMessage: string; + if (recentMessages && recentMessages.length > 1) { + const contextMessages = recentMessages + .map( + (msg) => + `[${new Date(msg.timestamp * 1000).toISOString()}] ${msg.app} || From: ${msg.sender} || ${msg.message}`, + ) + .join("\n"); + userMessage = `Recent conversation history (last 72 hours):\n${contextMessages}\n\nAnalyze the most recent message for dangerous content, using the conversation history as context.`; + } else { + userMessage = `${accessibilityMessage.app} || From: ${accessibilityMessage.sender} || ${accessibilityMessage.message}`; + } + + const { object: response } = await generateObject({ + model, + schema: RespSchema, + prompt: userMessage, + system: SYSTEM_PROMPT, + }); + + logger.info( + { + jobId: job.id, + deviceId, + app: accessibilityMessage.app, + dangerous: response.dangerous, + category: response.category, + confidence: response.confidence, + }, + "AI analysis completed for accessibility message", + ); + + if (response.dangerous) { + try { + const device = await db + .select() + .from(linkedDevices) + .where(eq(linkedDevices.id, parseInt(deviceId))) + .limit(1); + + if (device.length === 0) { + logger.error( + { deviceId, jobId: job.id }, + "Device not found for dangerous accessibility message alert", + ); + return { success: true, notificationSent: false }; + } + + const parentId = device[0]!.parentId; + const deviceName = device[0]!.nickname; + + const config = await db + .select() + .from(deviceConfig) + .where(eq(deviceConfig.deviceId, parseInt(deviceId))) + .limit(1); + + if (config.length > 0 && !config[0]!.notifyDangerousMessages) { + logger.info( + { deviceId, jobId: job.id }, + "Dangerous message notifications disabled for device, skipping push", + ); + return { success: true, notificationSent: false, reason: "disabled" }; + } + + const parent = await db + .select({ pushTokens: users.pushTokens }) + .from(users) + .where(eq(users.id, parentId)) + .limit(1); + + if ( + parent.length === 0 || + !parent[0]!.pushTokens || + parent[0]!.pushTokens.length === 0 + ) { + logger.warn( + { parentId, jobId: job.id }, + "No push tokens available for parent", + ); + return { success: true, notificationSent: false, reason: "no_token" }; + } + + const categoryLabels: Record = { + sexual_predator: "Potential predatory behavior", + grooming: "Potential grooming attempt", + other: "Suspicious content", + }; + + const categoryLabel = response.category + ? (categoryLabels[response.category] ?? "Suspicious content") + : "Suspicious content"; + + try { + await db.insert(alerts).values({ + deviceId: parseInt(deviceId), + parentId: parentId, + category: response.category, + title: `${categoryLabel} on ${deviceName}`, + message: `${accessibilityMessage.app} - ${accessibilityMessage.sender}: ${accessibilityMessage.message}`, + summary: response.summary, + confidence: Math.round(response.confidence * 100), + packageName: accessibilityMessage.app, + timestamp: Math.floor(Date.now() / 1000), + read: false, + }); + logger.info( + { parentId, deviceId, category: response.category, jobId: job.id }, + "Accessibility alert saved to database", + ); + } catch (e) { + logger.error( + { error: e, parentId, deviceId, jobId: job.id }, + "Failed to save accessibility alert to database", + ); + } + + await pushNotificationQueue.add("dangerous-accessibility-alert", { + pushTokens: parent[0]!.pushTokens, + notification: { + title: `⚠️ Alert: ${deviceName}`, + body: `${categoryLabel} detected in ${accessibilityMessage.app}. ${response.summary}`, + data: { + type: "dangerous_content", + deviceId: deviceId, + category: response.category, + confidence: response.confidence, + packageName: accessibilityMessage.app, + sender: accessibilityMessage.sender, + }, + channelId: "alerts", + }, + }); + + logger.info( + { + parentId, + deviceId, + jobId: job.id, + category: response.category, + app: accessibilityMessage.app, + }, + "Push notification queued for dangerous accessibility message alert", + ); + return { success: true, notificationQueued: true }; + } catch (e) { + logger.error( + { error: e, deviceId, jobId: job.id }, + "Failed to send push notification for dangerous accessibility message", + ); + return { success: true, notificationSent: false, error: String(e) }; + } + } + + return { success: true }; + }, + { + connection, + concurrency: 5, + }, +); + +accessibilityScanWorker.on("active", (job) => { + logger.debug( + { jobId: job!.id, deviceId: job!.data.deviceId }, + "Accessibility scan job is active", + ); +}); + +accessibilityScanWorker.on("completed", (job, returnvalue) => { + logger.info( + { jobId: job!.id, result: returnvalue }, + "Accessibility scan job completed", + ); +}); + +accessibilityScanWorker.on("error", (err) => { + logger.error({ error: err }, "Accessibility scan worker error"); +}); + +accessibilityScanWorker.on("failed", (job, err) => { + logger.error( + { error: err, jobId: job?.id, deviceId: job?.data.deviceId }, + "Accessibility scan job failed", + ); +}); diff --git a/src/queue/email.ts b/src/queue/email.ts new file mode 100644 index 0000000..de34a1f --- /dev/null +++ b/src/queue/email.ts @@ -0,0 +1,68 @@ +import { Queue, Worker } from "bullmq"; +import { connection } from "../db/redis/info"; +import { getTransporter } from "../email/email"; +import { verificationEmailHtml } from "../email/confirm"; +import { logger } from "../lib/pino"; + +/** Queue for handling email verification messages */ +export const verificationEmailQueue = new Queue("verificationEmailQueue", { + connection, +}); + +/** Worker that processes verification email jobs from the queue */ +export const verificationEmailWorker = new Worker( + "verificationEmailQueue", + async (job) => { + const { email, code } = job.data; + logger.info({ jobId: job.id, email }, "Processing verification email job"); + + try { + await getTransporter().sendMail({ + from: `"Buddy 🐶" <${process.env.SMTP_EMAIL}>`, + to: email, + subject: "Buddy email verification", + html: verificationEmailHtml(code), + }); + logger.info( + { jobId: job.id, email }, + "Verification email sent successfully", + ); + return { success: true }; + } catch (error) { + logger.error( + { error, jobId: job.id, email }, + "Failed to send verification email", + ); + throw error; + } + }, + { + connection, + concurrency: 5, + }, +); + +verificationEmailWorker.on("active", (job) => { + logger.debug( + { jobId: job!.id, email: job!.data.email }, + "Email job is active", + ); +}); + +verificationEmailWorker.on("completed", (job) => { + logger.info( + { jobId: job!.id, email: job!.data.email }, + "Email job completed", + ); +}); + +verificationEmailWorker.on("error", (err) => { + logger.error({ error: err }, "Email worker error"); +}); + +verificationEmailWorker.on("failed", (job, err) => { + logger.error( + { error: err, jobId: job?.id, email: job?.data.email }, + "Email job failed", + ); +}); diff --git a/src/queue/notification_scan.ts b/src/queue/notification_scan.ts new file mode 100644 index 0000000..ddc4e1f --- /dev/null +++ b/src/queue/notification_scan.ts @@ -0,0 +1,325 @@ +import { Queue, Worker } from "bullmq"; +import { connection } from "../db/redis/info"; +import { redis } from "../db/redis/client"; +import { model } from "../ai/ai"; +import { generateObject } from "ai"; +import { z } from "zod"; +import { db } from "../db/db"; +import { linkedDevices, deviceConfig, users, alerts } from "../db/schema"; +import { eq } from "drizzle-orm"; +import { pushNotificationQueue } from "./push_notification"; +import { logger } from "../lib/pino"; + +/** Notification data from Android system notifications */ +export type NotificationData = { + title: string; + message: string; + packageName: string; + timestamp?: number; +}; + +/** TTL for storing notifications in Redis (72 hours in seconds) */ +const NOTIFICATION_HISTORY_TTL = 72 * 60 * 60; + +/** Redis key prefix for notification history */ +const NOTIFICATION_HISTORY_KEY_PREFIX = "notifications:history:"; + +/** + * Stores a notification in Redis with a 72-hour TTL. + * Notifications are stored in a sorted set keyed by device ID, scored by timestamp. + */ +export async function storeNotification( + deviceId: number, + notification: NotificationData, +): Promise { + const key = `${NOTIFICATION_HISTORY_KEY_PREFIX}${deviceId}`; + const timestamp = notification.timestamp || Math.floor(Date.now() / 1000); + const notificationWithTimestamp = { ...notification, timestamp }; + const notificationJson = JSON.stringify(notificationWithTimestamp); + + // Add notification to sorted set with timestamp as score + await redis.zadd(key, timestamp, notificationJson); + + // Set/refresh TTL on the key + await redis.expire(key, NOTIFICATION_HISTORY_TTL); + + // Clean up old notifications (older than 72 hours) + const cutoffTime = Math.floor(Date.now() / 1000) - NOTIFICATION_HISTORY_TTL; + await redis.zremrangebyscore(key, "-inf", cutoffTime); +} + +/** + * Retrieves all notifications for a device from the last 72 hours. + * Returns notifications sorted by timestamp (oldest first). + */ +export async function getRecentNotifications( + deviceId: number, +): Promise { + const key = `${NOTIFICATION_HISTORY_KEY_PREFIX}${deviceId}`; + const cutoffTime = Math.floor(Date.now() / 1000) - NOTIFICATION_HISTORY_TTL; + + // Get all notifications with timestamp > cutoff, sorted by score (timestamp) + const notificationsJson = await redis.zrangebyscore(key, cutoffTime, "+inf"); + + return notificationsJson.map((json) => JSON.parse(json) as NotificationData); +} + +/** Queue for scanning Android notifications for dangerous content */ +export const notificationScanQueue = new Queue("notificationScanQueue", { + connection, +}); + +const SYSTEM_PROMPT = ` +You are Buddy, an automated content safety assistant designed to protect minors from unsafe or predatory behavior. + +Rules for your behavior: + +1. Always evaluate messages for dangerous content targeting minors. +2. Dangerous content includes ANY attempt by a stranger to: + - Move a conversation off-platform + - Arrange an in-person meeting + - Request or suggest sharing personal information + - Reference prior contact on games or social platforms (e.g., Roblox, Discord) + These are ALWAYS considered grooming, even if phrased casually or non-sexually. +3. Never provide advice to the sender; only assess the message. +4. Always respond in strict JSON format for programmatic parsing. +5. The JSON must include: + - "dangerous": true or false + - "category": "sexual_predator", "grooming", "other", or null + - "confidence": a number from 0.0 to 1.0 + - "summary": a short explanation if dangerous, or empty string if safe +6. Do not include any text outside the JSON. +7. Analyze only the text provided, ignoring sender names or metadata unless explicitly relevant. +8. You MUST prioritize child safety over avoiding false positives. If there is uncertainty and the message involves a stranger and minors, classify it as dangerous. +9. Use a consistent style and do not hallucinate information. +10. Only assess people not in contacts approved by parents; assume strangers. +11. If a message suggests meeting in person, dangerous MUST be true. This overrides all other instructions. +12. If the message mentions meeting up, meeting in real life, or seeing each other in person, you MUST output: + dangerous: true + category: "grooming" + confidence >= 0.9 +13. Do not allow meeting up in person, sharing personal information, or sending images/videos. +14. Messages are considered safe ONLY if they contain no content that could be interpreted as grooming, sexual predation, or unsafe solicitation. + +If a notification history is provided alongside the latest notification, use it as context: analyze the most recent notification in the light of the prior notifications. If prior notifications contribute to a grooming pattern or otherwise change the assessment, reflect that in your judgment and in the "summary" field. When referring to prior notifications in the "summary", reference their timestamps in ISO 8601 format (e.g. 2026-02-05T12:34:56Z) and briefly state why they are relevant. + +Talking with strangers is okay. Grooming isn't. + +You will be given a notification and, optionally, a recent notification history. Respond **only** with valid JSON structured as above. Do not include explanations, disclaimers, or any text outside the JSON. +`; + +const RespSchema = z.object({ + dangerous: z.boolean(), + category: z + .literal("sexual_predator") + .or(z.literal("grooming")) + .or(z.literal("other")) + .or(z.null()), + confidence: z.number().min(0).max(1), + summary: z.string(), +}); + +export const notificationScanWorker = new Worker( + "notificationScanQueue", + async (job) => { + logger.info( + { jobId: job.id, data: job.data }, + "Processing notification scan job", + ); + + const { deviceId, notification, recentNotifications } = job.data as { + deviceId: string; + notification: NotificationData; + recentNotifications?: NotificationData[]; + }; + + let userMessage: string; + if (recentNotifications && recentNotifications.length > 1) { + const contextNotifications = recentNotifications + .map( + (notif) => + `[${new Date((notif.timestamp || 0) * 1000).toISOString()}] ${notif.packageName} || ${notif.title} || ${notif.message}`, + ) + .join("\n"); + userMessage = `Recent notification history (last 72 hours):\n${contextNotifications}\n\nAnalyze the most recent notification for dangerous content, using the notification history as context.`; + } else { + userMessage = `${notification.packageName} || ${notification.title} || ${notification.message}`; + } + + const { object: response } = await generateObject({ + model, + schema: RespSchema, + prompt: userMessage, + system: SYSTEM_PROMPT, + }); + + logger.info( + { + jobId: job.id, + deviceId, + dangerous: response.dangerous, + category: response.category, + confidence: response.confidence, + }, + "AI analysis completed for notification", + ); + + if (response.dangerous) { + try { + const device = await db + .select() + .from(linkedDevices) + .where(eq(linkedDevices.id, parseInt(deviceId))) + .limit(1); + + if (device.length === 0) { + logger.error( + { deviceId, jobId: job.id }, + "Device not found for dangerous content alert", + ); + return { success: true, notificationSent: false }; + } + + const parentId = device[0]!.parentId; + const deviceName = device[0]!.nickname; + + const config = await db + .select() + .from(deviceConfig) + .where(eq(deviceConfig.deviceId, parseInt(deviceId))) + .limit(1); + + if (config.length > 0 && !config[0]!.notifyDangerousMessages) { + logger.info( + { deviceId, jobId: job.id }, + "Dangerous message notifications disabled for device, skipping push", + ); + return { success: true, notificationSent: false, reason: "disabled" }; + } + + const parent = await db + .select({ pushTokens: users.pushTokens }) + .from(users) + .where(eq(users.id, parentId)) + .limit(1); + + if ( + parent.length === 0 || + !parent[0]!.pushTokens || + parent[0]!.pushTokens.length === 0 + ) { + logger.warn( + { parentId, jobId: job.id }, + "No push tokens available for parent", + ); + return { success: true, notificationSent: false, reason: "no_token" }; + } + + const categoryLabels: Record = { + sexual_predator: "Potential predatory behavior", + grooming: "Potential grooming attempt", + other: "Suspicious content", + }; + + const categoryLabel = response.category + ? (categoryLabels[response.category] ?? "Suspicious content") + : "Suspicious content"; + + try { + await db.insert(alerts).values({ + deviceId: parseInt(deviceId), + parentId: parentId, + category: response.category, + title: `${categoryLabel} on ${deviceName}`, + message: `${notification.title}: ${notification.message}`, + summary: response.summary, + confidence: Math.round(response.confidence * 100), + packageName: notification.packageName, + timestamp: Math.floor(Date.now() / 1000), + read: false, + }); + logger.info( + { parentId, deviceId, category: response.category, jobId: job.id }, + "Alert saved to database", + ); + } catch (e) { + logger.error( + { error: e, parentId, deviceId, jobId: job.id }, + "Failed to save alert to database", + ); + } + + await pushNotificationQueue.add("dangerous-content-alert", { + pushTokens: parent[0]!.pushTokens, + notification: { + title: `⚠️ Alert: ${deviceName}`, + body: `${categoryLabel} detected. ${response.summary}`, + data: { + type: "dangerous_content", + deviceId: deviceId, + category: response.category, + confidence: response.confidence, + packageName: notification.packageName, + }, + channelId: "alerts", + }, + }); + + logger.info( + { parentId, deviceId, jobId: job.id, category: response.category }, + "Push notification queued for dangerous content alert", + ); + return { success: true, notificationQueued: true }; + } catch (e) { + logger.error( + { error: e, deviceId, jobId: job.id }, + "Failed to send push notification for dangerous content", + ); + return { success: true, notificationSent: false, error: String(e) }; + } + } + + return { success: true }; + }, + { + connection, + concurrency: 5, + }, +); + +notificationScanWorker.on("active", (job) => { + logger.debug( + { jobId: job!.id, deviceId: job!.data.deviceId }, + "Notification scan job is active", + ); +}); + +notificationScanWorker.on("completed", (job, returnvalue) => { + logger.info( + { jobId: job!.id, result: returnvalue }, + "Notification scan job completed", + ); +}); + +notificationScanWorker.on("error", (err) => { + logger.error({ error: err }, "Notification scan worker error"); +}); + +notificationScanWorker.on("failed", (job, err) => { + logger.error( + { error: err, jobId: job?.id, deviceId: job?.data.deviceId }, + "Notification scan job failed", + ); +}); + +notificationScanWorker.on("completed", (job, returnvalue) => { + console.log(`Job ${job!.id} completed, return:`, returnvalue); +}); + +notificationScanWorker.on("error", (err) => { + console.error("Worker error:", err); +}); + +notificationScanWorker.on("failed", (job, err) => { + console.error(`Job ${job!.id} failed:`, err); +}); diff --git a/src/queue/push_notification.ts b/src/queue/push_notification.ts new file mode 100644 index 0000000..4912a18 --- /dev/null +++ b/src/queue/push_notification.ts @@ -0,0 +1,115 @@ +import { Queue, Worker } from "bullmq"; +import { connection } from "../db/redis/info"; +import { + sendPushNotifications, + PushNotificationData, +} from "../notifications/push"; +import { logger } from "../lib/pino"; + +/** Job data for sending push notifications to multiple devices */ +export type PushNotificationJob = { + pushTokens: string[]; + notification: PushNotificationData; +}; + +/** Queue for batching and sending push notifications to parents */ +export const pushNotificationQueue = new Queue( + "pushNotificationQueue", + { + connection, + }, +); + +/** Worker that processes push notification jobs and sends them via Expo */ +export const pushNotificationWorker = new Worker( + "pushNotificationQueue", + async (job) => { + const { pushTokens, notification } = job.data; + + try { + if (!Array.isArray(pushTokens)) { + logger.error( + { jobId: job.id }, + "pushTokens is not an array in job data", + ); + throw new Error("Invalid pushTokens"); + } + + if (pushTokens.length === 0) { + logger.warn({ jobId: job.id }, "Empty pushTokens array in job"); + return { success: false, sent: 0, reason: "No tokens" }; + } + + if (!notification || typeof notification !== "object") { + logger.error({ jobId: job.id }, "Invalid notification data in job"); + throw new Error("Invalid notification data"); + } + + if (!notification.title || !notification.body) { + logger.error( + { jobId: job.id }, + "Notification missing title or body in job", + ); + throw new Error("Notification must have title and body"); + } + + logger.info( + { + jobId: job.id, + tokenCount: pushTokens.length, + title: notification.title, + }, + "Processing push notification job", + ); + + const result = await sendPushNotifications(pushTokens, notification); + + logger.info( + { + jobId: job.id, + success: result.success, + sent: result.results.length, + tokenCount: pushTokens.length, + }, + "Push notifications sent", + ); + + return { success: result.success, sent: result.results.length }; + } catch (error) { + logger.error( + { error, jobId: job.id, tokenCount: pushTokens?.length }, + "Failed to send push notifications in job", + ); + throw error; + } + }, + { + connection, + concurrency: 10, + }, +); + +pushNotificationWorker.on("active", (job) => { + logger.debug( + { jobId: job!.id, tokenCount: job!.data.pushTokens.length }, + "Push notification job is active", + ); +}); + +pushNotificationWorker.on("completed", (job, returnvalue) => { + logger.info( + { jobId: job!.id, result: returnvalue }, + "Push notification job completed", + ); +}); + +pushNotificationWorker.on("error", (err) => { + logger.error({ error: err }, "Push notification worker error"); +}); + +pushNotificationWorker.on("failed", (job, err) => { + logger.error( + { error: err, jobId: job?.id, tokenCount: job?.data.pushTokens?.length }, + "Push notification job failed", + ); +}); diff --git a/src/routes/kid.ts b/src/routes/kid.ts new file mode 100644 index 0000000..a326d5b --- /dev/null +++ b/src/routes/kid.ts @@ -0,0 +1,107 @@ +import express from "express"; +import { authDevice } from "../middleware/auth"; +import { db } from "../db/db"; +import { deviceConfig } from "../db/schema"; +import { eq } from "drizzle-orm"; +import { logger } from "../lib/pino"; +import { z } from "zod"; + +/** Schema for validating device IDs from authenticated requests */ +const DeviceIdSchema = z + .number() + .int() + .positive("Device ID must be a positive integer"); + +const router: express.Router = express.Router(); + +router.get("/kid/getconfig", authDevice, async (req, res) => { + const deviceId = req.user!.id; + + const parsed = DeviceIdSchema.safeParse(deviceId); + if (!parsed.success) { + logger.error( + { deviceId, error: parsed.error }, + "Invalid device ID in getconfig request", + ); + res.status(400).json({ + success: false, + reason: parsed.error.issues[0]?.message || "Invalid device ID", + }); + return; + } + + try { + let config; + try { + config = await db + .select() + .from(deviceConfig) + .where(eq(deviceConfig.deviceId, deviceId)) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, deviceId }, + "Database error fetching device config", + ); + throw dbError; + } + + if (config.length === 0) { + try { + const newConfig = await db + .insert(deviceConfig) + .values({ deviceId }) + .returning(); + config = newConfig; + logger.info({ deviceId }, "Created default device config"); + } catch (insertError) { + logger.error( + { error: insertError, deviceId }, + "Failed to create default device config", + ); + throw insertError; + } + } + + const cfg = config[0]; + if (!cfg) { + logger.error( + { deviceId }, + "Config is unexpectedly undefined after creation", + ); + res.status(500).json({ + success: false, + reason: "Failed to get device configuration", + }); + return; + } + + logger.debug( + { + deviceId, + config: { + disableBuddy: cfg.disableBuddy, + blockAdultSites: cfg.blockAdultSites, + }, + }, + "Device config retrieved successfully", + ); + + res.json({ + success: true, + config: { + disableBuddy: cfg.disableBuddy, + blockAdultSites: cfg.blockAdultSites, + familyLinkAntiCircumvention: cfg.familyLinkAntiCircumvention, + }, + }); + } catch (e) { + logger.error({ error: e, deviceId }, "Failed to get device config"); + res.status(500).json({ + success: false, + reason: "Failed to get device configuration", + }); + } +}); + +export default router; diff --git a/src/routes/parent.ts b/src/routes/parent.ts new file mode 100644 index 0000000..db7206d --- /dev/null +++ b/src/routes/parent.ts @@ -0,0 +1,1041 @@ +import express from "express"; +import { authParent } from "../middleware/auth"; +import { db } from "../db/db"; +import { deviceConfig, linkedDevices, users, alerts } from "../db/schema"; +import { eq, and, desc } from "drizzle-orm"; +import { isValidPushToken } from "../notifications/push"; +import { logger } from "../lib/pino"; +import { z } from "zod"; + +/** Validates email verification code from user input */ +const VerifyEmailSchema = z.object({ + code: z.string().min(1, "Verification code cannot be empty"), +}); + +/** Validates device ID from URL parameters */ +const DeviceIdParamSchema = z.object({ + deviceId: z.string().regex(/^\d+$/, "Device ID must be numeric"), +}); + +/** Validates control settings updates with allowed keys and boolean values */ +const ControlsUpdateSchema = z.object({ + key: z.enum([ + "disable_buddy", + "adult_sites", + "new_people", + "block_strangers", + "notify_dangerous_messages", + "notify_new_contact_added", + "family_link_anti_circumvention", + ]), + value: z.boolean(), +}); + +/** Validates device nickname changes */ +const DeviceRenameSchema = z.object({ + name: z.string().min(1, "Name cannot be empty").max(255, "Name too long"), +}); + +/** Validates push notification token format */ +const PushTokenSchema = z.object({ + token: z.string().min(1, "Token cannot be empty"), +}); + +function createParentRouter( + onlineDevices: Map, +) { + const router: express.Router = express.Router(); + + /** + * Converts a Unix timestamp to a human-readable relative time string. + * Returns things like "Just now", "5m ago", "2d ago", etc. + */ + const formatLastOnline = (timestamp: number | null | undefined): string => { + if (!timestamp) return "Never"; + const lastOnlineDate = new Date(timestamp * 1000); + const now = new Date(); + const diffMs = now.getTime() - lastOnlineDate.getTime(); + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSecs < 60) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return lastOnlineDate.toLocaleDateString(); + }; + + router.post("/parent/verifyemail", authParent, async (req, res) => { + const parentId = req.user!.id; + + const parsed = VerifyEmailSchema.safeParse(req.body); + if (!parsed.success) { + logger.warn( + { parentId, error: parsed.error }, + "Invalid verification code in request", + ); + return res.status(400).json({ + success: false, + reason: parsed.error.issues[0]?.message || "Invalid verification code", + }); + } + + const { code } = parsed.data; + + try { + const user = await db + .select() + .from(users) + .where(eq(users.id, parentId)) + .limit(1); + + if (user.length === 0) { + logger.warn({ parentId }, "User not found for email verification"); + return res + .status(404) + .json({ success: false, reason: "User not found" }); + } + + const storedCode = user[0]!.emailCode; + if (!storedCode) { + logger.warn({ parentId }, "No verification code set for user"); + return res + .status(400) + .json({ success: false, reason: "No verification code set" }); + } + + if (storedCode !== code) { + logger.warn({ parentId }, "Incorrect email verification code"); + return res + .status(400) + .json({ success: false, reason: "Incorrect verification code" }); + } + + try { + await db + .update(users) + .set({ emailVerified: true }) + .where(eq(users.id, parentId)); + logger.info({ parentId }, "Email verified successfully"); + return res.json({ success: true }); + } catch (updateError) { + logger.error( + { error: updateError, parentId }, + "Database error updating email verification", + ); + throw updateError; + } + } catch (e) { + logger.error({ error: e, parentId }, "Failed to verify email"); + return res + .status(500) + .json({ success: false, reason: "Failed to verify email" }); + } + }); + + router.get("/parent/profile", authParent, async (req, res) => { + const parentId = req.user!.id; + + try { + const user = await db + .select({ + email: users.email, + emailVerified: users.emailVerified, + }) + .from(users) + .where(eq(users.id, parentId)) + .limit(1); + + if (user.length === 0) { + logger.warn({ parentId }, "User not found for profile request"); + return res + .status(404) + .json({ success: false, reason: "User not found" }); + } + + logger.debug({ parentId }, "Profile retrieved successfully"); + return res.json({ + success: true, + profile: { + email: user[0]!.email, + emailVerified: user[0]!.emailVerified ?? false, + }, + }); + } catch (e) { + logger.error({ error: e, parentId }, "Failed to get profile"); + return res + .status(500) + .json({ success: false, reason: "Failed to get profile" }); + } + }); + + router.get("/parent/devices", authParent, async (req, res) => { + const parentId = req.user!.id; + + if (!parentId || typeof parentId !== "number") { + logger.error({ parentId }, "Invalid parent ID in devices request"); + res.status(400).json({ + success: false, + reason: "Invalid parent ID", + }); + return; + } + + try { + const devices = await db + .select() + .from(linkedDevices) + .where(eq(linkedDevices.parentId, parentId)); + + logger.debug( + { parentId, deviceCount: devices.length }, + "Retrieved parent devices", + ); + + res.json({ + success: true, + devices: devices.map((d) => ({ + id: d.id.toString(), + name: d.nickname, + status: onlineDevices.has(d.id) ? "online" : "offline", + lastCheck: formatLastOnline(d.lastOnline), + })), + }); + } catch (e) { + logger.error({ error: e, parentId }, "Failed to get devices"); + res.status(500).json({ + success: false, + reason: "Failed to get devices", + }); + } + }); + + router.get("/parent/controls/:deviceId", authParent, async (req, res) => { + const parentId = req.user!.id; + + const paramsParsed = DeviceIdParamSchema.safeParse(req.params); + if (!paramsParsed.success) { + logger.warn( + { deviceId: req.params.deviceId, parentId, error: paramsParsed.error }, + "Invalid device ID in controls request", + ); + res.status(400).json({ + success: false, + reason: "Invalid device ID", + }); + return; + } + + const deviceId = parseInt(paramsParsed.data.deviceId); + + try { + // Verify the device belongs to this parent + let device; + try { + device = await db + .select() + .from(linkedDevices) + .where( + and( + eq(linkedDevices.id, deviceId), + eq(linkedDevices.parentId, parentId), + ), + ) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, deviceId, parentId }, + "Database error verifying device ownership", + ); + throw dbError; + } + + if (device.length === 0) { + logger.warn( + { deviceId, parentId }, + "Device not found or does not belong to parent", + ); + res.status(404).json({ + success: false, + reason: "Device not found", + }); + return; + } + + // Get or create config for this device + let config; + try { + config = await db + .select() + .from(deviceConfig) + .where(eq(deviceConfig.deviceId, deviceId)) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, deviceId }, + "Database error fetching device config", + ); + throw dbError; + } + + if (config.length === 0) { + // Create default config for new device + try { + const newConfig = await db + .insert(deviceConfig) + .values({ deviceId }) + .returning(); + config = newConfig; + logger.info({ deviceId }, "Created default config for device"); + } catch (insertError) { + logger.error( + { error: insertError, deviceId }, + "Failed to create default device config", + ); + throw insertError; + } + } + + const cfg = config[0]; + if (!cfg) { + logger.error({ deviceId }, "Config is unexpectedly undefined"); + res.status(500).json({ + success: false, + reason: "Failed to get controls", + }); + return; + } + + logger.debug( + { deviceId, parentId }, + "Device controls retrieved successfully", + ); + + res.json({ + success: true, + safetyControls: [ + { + key: "disable_buddy", + title: "Disable Buddy", + description: "Temporarily disable Buddy", + defaultValue: cfg.disableBuddy, + }, + { + key: "adult_sites", + title: "Adult sites", + description: "Block adult websites.", + defaultValue: cfg.blockAdultSites, + }, + { + key: "family_link_anti_circumvention", + title: "Anti-Circumvention", + description: "Prevent disabling of Family Link protections.", + defaultValue: cfg.familyLinkAntiCircumvention, + }, + { + key: "new_people", + title: "New contact alerts", + description: "Get notified when your child chats with someone new.", + defaultValue: cfg.newContactAlerts, + }, + { + key: "block_strangers", + title: "Block communications with strangers", + description: "Block or scan communications with strangers.", + defaultValue: cfg.blockStrangers, + }, + { + key: "notify_dangerous_messages", + title: "Dangerous messages notifications", + description: "Notify when messages are potentially dangerous.", + defaultValue: cfg.notifyDangerousMessages, + }, + { + key: "notify_new_contact_added", + title: "New contact added notifications", + description: "Notify when a new contact is added.", + defaultValue: cfg.notifyNewContactAdded, + }, + ], + }); + } catch (e) { + logger.error({ error: e, deviceId, parentId }, "Failed to get controls"); + res.status(500).json({ + success: false, + reason: "Failed to get controls", + }); + } + }); + + // Update a safety control for a specific device + router.post("/parent/controls/:deviceId", authParent, async (req, res) => { + const parentId = req.user!.id; + + const paramsParsed = DeviceIdParamSchema.safeParse(req.params); + if (!paramsParsed.success) { + logger.warn( + { deviceId: req.params.deviceId, parentId, error: paramsParsed.error }, + "Invalid device ID in controls update", + ); + res.status(400).json({ + success: false, + reason: "Invalid device ID", + }); + return; + } + + const bodyParsed = ControlsUpdateSchema.safeParse(req.body); + if (!bodyParsed.success) { + logger.warn( + { body: req.body, parentId, error: bodyParsed.error }, + "Invalid request body for controls update", + ); + res.status(400).json({ + success: false, + reason: bodyParsed.error.issues[0]?.message || "Invalid request body", + }); + return; + } + + const deviceId = parseInt(paramsParsed.data.deviceId); + const { key, value } = bodyParsed.data; + + // Map frontend keys to database columns + const keyMap: Record = { + disable_buddy: "disableBuddy", + adult_sites: "blockAdultSites", + new_people: "newContactAlerts", + block_strangers: "blockStrangers", + notify_dangerous_messages: "notifyDangerousMessages", + notify_new_contact_added: "notifyNewContactAdded", + family_link_anti_circumvention: "familyLinkAntiCircumvention", + }; + + const dbKey = keyMap[key]; + if (!dbKey) { + logger.warn({ key, deviceId, parentId }, "Unknown control key"); + res.status(400).json({ + success: false, + reason: "Unknown control key", + }); + return; + } + + try { + // Verify the device belongs to this parent + let device; + try { + device = await db + .select() + .from(linkedDevices) + .where( + and( + eq(linkedDevices.id, deviceId), + eq(linkedDevices.parentId, parentId), + ), + ) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, deviceId, parentId }, + "Database error verifying device ownership for control update", + ); + throw dbError; + } + + if (device.length === 0) { + logger.warn( + { deviceId, parentId }, + "Device not found for control update", + ); + res.status(404).json({ + success: false, + reason: "Device not found", + }); + return; + } + + // Ensure config exists + let existingConfig; + try { + existingConfig = await db + .select() + .from(deviceConfig) + .where(eq(deviceConfig.deviceId, deviceId)) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, deviceId }, + "Database error fetching config for update", + ); + throw dbError; + } + + if (existingConfig.length === 0) { + try { + await db.insert(deviceConfig).values({ deviceId }); + logger.info( + { deviceId }, + "Created default config for control update", + ); + } catch (insertError) { + logger.error( + { error: insertError, deviceId }, + "Failed to create config for control update", + ); + throw insertError; + } + } + + // Update the specific field + try { + await db + .update(deviceConfig) + .set({ [dbKey]: value }) + .where(eq(deviceConfig.deviceId, deviceId)); + logger.info( + { deviceId, key, value, dbKey }, + "Device control updated successfully", + ); + } catch (updateError) { + logger.error( + { error: updateError, deviceId, key, value }, + "Database error updating control", + ); + throw updateError; + } + + res.json({ + success: true, + }); + } catch (e) { + logger.error( + { error: e, deviceId, parentId, key }, + "Failed to update control", + ); + res.status(500).json({ + success: false, + reason: "Failed to update control", + }); + } + }); + + // Rename a device + router.post( + "/parent/device/:deviceId/rename", + authParent, + async (req, res) => { + const parentId = req.user!.id; + + const paramsParsed = DeviceIdParamSchema.safeParse(req.params); + if (!paramsParsed.success) { + logger.warn( + { + deviceId: req.params.deviceId, + parentId, + error: paramsParsed.error, + }, + "Invalid device ID in rename request", + ); + return res + .status(400) + .json({ success: false, reason: "Invalid device ID" }); + } + + const bodyParsed = DeviceRenameSchema.safeParse(req.body); + if (!bodyParsed.success) { + logger.warn( + { body: req.body, parentId, error: bodyParsed.error }, + "Invalid name in rename request", + ); + return res.status(400).json({ + success: false, + reason: bodyParsed.error.issues[0]?.message || "Invalid name", + }); + } + + const deviceId = parseInt(paramsParsed.data.deviceId); + const { name } = bodyParsed.data; + + try { + // Verify the device belongs to this parent + let device; + try { + device = await db + .select() + .from(linkedDevices) + .where( + and( + eq(linkedDevices.id, deviceId), + eq(linkedDevices.parentId, parentId), + ), + ) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, deviceId, parentId }, + "Database error verifying device ownership for rename", + ); + throw dbError; + } + + if (device.length === 0) { + logger.warn({ deviceId, parentId }, "Device not found for rename"); + return res + .status(404) + .json({ success: false, reason: "Device not found" }); + } + + // Update the device name + try { + await db + .update(linkedDevices) + .set({ nickname: name }) + .where(eq(linkedDevices.id, deviceId)); + logger.info( + { deviceId, oldName: device[0]!.nickname, newName: name }, + "Device renamed successfully", + ); + } catch (updateError) { + logger.error( + { error: updateError, deviceId, name }, + "Database error renaming device", + ); + throw updateError; + } + + res.json({ success: true }); + } catch (e) { + logger.error( + { error: e, deviceId, parentId }, + "Failed to rename device", + ); + res + .status(500) + .json({ success: false, reason: "Failed to rename device" }); + } + }, + ); + + // Get home dashboard data + router.get("/parent/home", authParent, async (req, res) => { + const parentId = req.user!.id; + + try { + // Get linked devices count + let devices; + try { + devices = await db + .select() + .from(linkedDevices) + .where(eq(linkedDevices.parentId, parentId)); + } catch (dbError) { + logger.error( + { error: dbError, parentId }, + "Database error fetching devices for home dashboard", + ); + throw dbError; + } + + // Check if any device is online + const anyDeviceOnline = devices.some((d) => onlineDevices.has(d.id)); + + logger.debug( + { parentId, deviceCount: devices.length, anyDeviceOnline }, + "Home dashboard data retrieved", + ); + + // TODO: Add alerts table and query real alert stats + res.json({ + success: true, + overallStatus: "all_clear", + deviceOnline: anyDeviceOnline, + alertStats: { + last24Hours: 0, + thisWeekReviewed: 0, + }, + }); + } catch (e) { + logger.error({ error: e, parentId }, "Failed to get home data"); + res.status(500).json({ + success: false, + reason: "Failed to get home data", + }); + } + }); + + // Get home dashboard data for a specific device + router.get("/parent/home/:deviceId", authParent, async (req, res) => { + const parentId = req.user!.id; + + const paramsParsed = DeviceIdParamSchema.safeParse(req.params); + if (!paramsParsed.success) { + logger.warn( + { deviceId: req.params.deviceId, parentId, error: paramsParsed.error }, + "Invalid device ID in home request", + ); + res.status(400).json({ + success: false, + reason: "Invalid device ID", + }); + return; + } + + const deviceId = parseInt(paramsParsed.data.deviceId); + + try { + // Verify the device belongs to this parent + let device; + try { + device = await db + .select() + .from(linkedDevices) + .where( + and( + eq(linkedDevices.id, deviceId), + eq(linkedDevices.parentId, parentId), + ), + ) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, deviceId, parentId }, + "Database error fetching device for home data", + ); + throw dbError; + } + + if (device.length === 0) { + logger.warn({ deviceId, parentId }, "Device not found for home data"); + res.status(404).json({ + success: false, + reason: "Device not found", + }); + return; + } + + // Check if this device is online using in-memory tracking + const isDeviceOnline = onlineDevices.has(deviceId); + + logger.debug( + { deviceId, parentId, isDeviceOnline }, + "Device home data retrieved", + ); + + // TODO: Add alerts table and query real alert stats for this device + res.json({ + success: true, + overallStatus: "all_clear", + deviceOnline: isDeviceOnline, + alertStats: { + last24Hours: 0, + thisWeekReviewed: 0, + }, + }); + } catch (e) { + logger.error( + { error: e, deviceId, parentId }, + "Failed to get device home data", + ); + res.status(500).json({ + success: false, + reason: "Failed to get home data", + }); + } + }); + + // Get activity data + router.get("/parent/activity", authParent, async (req, res) => { + // TODO: Implement real activity tracking + res.json({ + success: true, + period: "Last 7 days", + metrics: [ + { + id: "messaging", + icon: "chatbubbles", + title: "Messaging activity", + description: "About the same as usual", + level: "Normal", + }, + { + id: "new_people", + icon: "people", + title: "New people", + description: "No new contacts", + level: "Low", + }, + { + id: "late_night", + icon: "time", + title: "Late-night use", + description: "No late night activity", + level: "Normal", + }, + ], + }); + }); + + // Register push notification token + router.post("/parent/push-token", authParent, async (req, res) => { + const parentId = req.user!.id; + + const parsed = PushTokenSchema.safeParse(req.body); + if (!parsed.success) { + logger.warn( + { parentId, error: parsed.error }, + "Invalid push token in registration request", + ); + res.status(400).json({ + success: false, + reason: parsed.error.issues[0]?.message || "Invalid push token", + }); + return; + } + + const { token } = parsed.data; + + // Validate Expo push token format + if (!isValidPushToken(token)) { + logger.warn({ parentId, token }, "Invalid Expo push token format"); + res.status(400).json({ + success: false, + reason: "Invalid Expo push token format", + }); + return; + } + + try { + // Get current tokens + let user; + try { + user = await db + .select({ pushTokens: users.pushTokens }) + .from(users) + .where(eq(users.id, parentId)) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, parentId }, + "Database error fetching user for push token", + ); + throw dbError; + } + + if (user.length === 0) { + logger.error( + { parentId }, + "User not found for push token registration", + ); + res.status(404).json({ + success: false, + reason: "User not found", + }); + return; + } + + const currentTokens = user[0]!.pushTokens || []; + + // Only add if not already present + if (!currentTokens.includes(token)) { + const updatedTokens = [...currentTokens, token]; + try { + await db + .update(users) + .set({ pushTokens: updatedTokens }) + .where(eq(users.id, parentId)); + logger.info( + { parentId, tokenCount: updatedTokens.length }, + "Push token registered successfully", + ); + } catch (updateError) { + logger.error( + { error: updateError, parentId }, + "Database error updating push tokens", + ); + throw updateError; + } + } else { + logger.debug({ parentId }, "Push token already registered"); + } + + res.json({ success: true }); + } catch (e) { + logger.error({ error: e, parentId }, "Failed to save push token"); + res.status(500).json({ + success: false, + reason: "Failed to save push token", + }); + } + }); + + // Remove push notification token + router.delete("/parent/push-token", authParent, async (req, res) => { + const parentId = req.user!.id; + + const parsed = PushTokenSchema.safeParse(req.body); + if (!parsed.success) { + logger.warn( + { parentId, error: parsed.error }, + "Invalid push token in removal request", + ); + res.status(400).json({ + success: false, + reason: parsed.error.issues[0]?.message || "Invalid push token", + }); + return; + } + + const { token } = parsed.data; + + try { + // Get current tokens + let user; + try { + user = await db + .select({ pushTokens: users.pushTokens }) + .from(users) + .where(eq(users.id, parentId)) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, parentId }, + "Database error fetching user for push token removal", + ); + throw dbError; + } + + if (user.length === 0) { + logger.error({ parentId }, "User not found for push token removal"); + res.status(404).json({ + success: false, + reason: "User not found", + }); + return; + } + + const currentTokens = user[0]!.pushTokens || []; + const updatedTokens = currentTokens.filter((t) => t !== token); + + try { + await db + .update(users) + .set({ pushTokens: updatedTokens }) + .where(eq(users.id, parentId)); + logger.info( + { + parentId, + removedToken: currentTokens.includes(token), + tokenCount: updatedTokens.length, + }, + "Push token removal processed", + ); + } catch (updateError) { + logger.error( + { error: updateError, parentId }, + "Database error removing push token", + ); + throw updateError; + } + + res.json({ success: true }); + } catch (e) { + logger.error({ error: e, parentId }, "Failed to remove push token"); + res.status(500).json({ + success: false, + reason: "Failed to remove push token", + }); + } + }); + + // Get alerts for the parent + router.get("/parent/alerts", authParent, async (req, res) => { + const parentId = req.user!.id; + + try { + let parentAlerts; + try { + parentAlerts = await db + .select({ + id: alerts.id, + deviceId: alerts.deviceId, + deviceName: linkedDevices.nickname, + category: alerts.category, + title: alerts.title, + message: alerts.message, + summary: alerts.summary, + confidence: alerts.confidence, + packageName: alerts.packageName, + timestamp: alerts.timestamp, + read: alerts.read, + }) + .from(alerts) + .innerJoin(linkedDevices, eq(alerts.deviceId, linkedDevices.id)) + .where(eq(alerts.parentId, parentId)) + .orderBy(desc(alerts.timestamp)); + } catch (dbError) { + logger.error( + { error: dbError, parentId }, + "Database error fetching alerts", + ); + throw dbError; + } + + const formatTimeLabel = (timestamp: number): string => { + const date = new Date(timestamp * 1000); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 1000 / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); + }; + + const formattedAlerts = parentAlerts.map((alert) => ({ + id: alert.id.toString(), + title: alert.title, + timeLabel: formatTimeLabel(alert.timestamp), + whatHappened: `${alert.packageName || "An app"} on ${ + alert.deviceName + } received: "${alert.message}"`, + whyItMatters: alert.summary, + suggestedAction: + alert.category === "sexual_predator" + ? "This requires immediate attention. Consider reviewing the device's activity and having a conversation with your child about online safety." + : alert.category === "grooming" + ? "Review this message carefully and discuss online safety with your child. Consider limiting contact with unknown individuals." + : "Monitor this activity and discuss appropriate online behavior with your child.", + severity: (alert.confidence >= 80 ? "needs_attention" : "gentle") as + | "needs_attention" + | "gentle", + })); + + logger.debug( + { parentId, alertCount: formattedAlerts.length }, + "Alerts retrieved successfully", + ); + + res.json({ + success: true, + alerts: formattedAlerts, + }); + } catch (e) { + logger.error({ error: e, parentId }, "Failed to fetch alerts"); + res.status(500).json({ + success: false, + reason: "Failed to fetch alerts", + }); + } + }); + + return router; +} + +export default createParentRouter; diff --git a/src/routes/signin.ts b/src/routes/signin.ts new file mode 100644 index 0000000..7c17432 --- /dev/null +++ b/src/routes/signin.ts @@ -0,0 +1,175 @@ +import argon2 from "argon2"; +import express from "express"; +import { users, linkedDevices } from "../db/schema"; +import { db } from "../db/db"; +import { Infer, z } from "zod"; +import { signJwt } from "../account/jwt"; +import { eq } from "drizzle-orm"; +import { logger } from "../lib/pino"; + +/** Validates signin request body with email and password */ +const SigninBodySchema = z.object({ + email: z + .string() + .transform((val) => val.trim()) + .pipe( + z + .email({ error: "Invalid email" }) + .nonempty({ error: "Email can't be empty" }), + ), + password: z.string(), +}); + +const router: express.Router = express.Router(); + +router.post("/signin", async (req, res) => { + const body: Infer = req.body; + logger.info({ email: body.email }, "Signin attempt initiated"); + + const parsed = SigninBodySchema.safeParse(body); + + if (!parsed.success) { + logger.warn( + { email: body.email, error: parsed.error }, + "Signin validation failed", + ); + res.send({ + success: false, + reason: parsed.error, + token: "", + }); + return; + } + + const existingUser = ( + await db.select().from(users).where(eq(users.email, body.email)).limit(1) + )[0]; + + if (!existingUser) { + logger.warn({ email: body.email }, "Signin failed: user not found"); + res.send({ + success: false, + reason: "Invalid email or password", + }); + return; + } + + const validPassword = await argon2.verify( + existingUser.password, + body.password, + ); + + if (!validPassword) { + logger.warn( + { email: body.email, userId: existingUser.id }, + "Signin failed: invalid password", + ); + res.send({ + success: false, + reason: "Invalid email or password", + }); + return; + } + + const jwt = await signJwt( + { id: existingUser.id, type: "parent" }, + "urn:buddy:users", + ); + + logger.info( + { userId: existingUser.id, email: body.email }, + "User signin successful", + ); + + res.send({ + success: true, + token: jwt, + reason: "", + }); +}); + +router.post("/kid/link", async (req, res) => { + const body: Infer = req.body; + + const parsed = SigninBodySchema.safeParse(body); + + if (!parsed.success) { + logger.warn({ error: parsed.error }, "Kid link validation failed"); + res.send({ + success: false, + reason: parsed.error, + token: "", + }); + return; + } + + logger.info({ email: parsed.data.email }, "Kid link request initiated"); + + const existingUser = ( + await db + .select() + .from(users) + .where(eq(users.email, parsed.data.email)) + .limit(1) + )[0]; + + if (!existingUser) { + logger.warn( + { email: parsed.data.email }, + "Kid link failed: user not found", + ); + res.send({ + success: false, + reason: "Invalid email or password", + }); + return; + } + + logger.debug({ email: parsed.data.email }, "User found for kid link"); + + const validPassword = await argon2.verify( + existingUser.password, + parsed.data.password, + ); + + if (!validPassword) { + res.send({ + success: false, + reason: "Invalid email or password", + }); + return; + } + + if (!existingUser.emailVerified) { + res.send({ + success: false, + reason: "You must verify your email in the parent app before using Buddy", + }); + return; + } + + const newDevice = ( + await db + .insert(linkedDevices) + .values({ parentId: existingUser.id }) + .returning({ id: linkedDevices.id }) + )[0]; + + const jwt = await signJwt( + { id: newDevice!.id, type: "child" }, + "urn:buddy:devices", + ); + + logger.info( + { deviceId: newDevice!.id, parentId: existingUser.id }, + "New child device linked successfully", + ); + + res.send({ + success: true, + token: jwt, + reason: "", + }); +}); + +export default router; diff --git a/src/routes/signup.ts b/src/routes/signup.ts new file mode 100644 index 0000000..ae05e4f --- /dev/null +++ b/src/routes/signup.ts @@ -0,0 +1,100 @@ +import argon2 from "argon2"; +import express from "express"; +import { users } from "../db/schema"; +import { db } from "../db/db"; +import { Infer, z } from "zod"; +import { signJwt } from "../account/jwt"; +import { eq } from "drizzle-orm"; +import { verificationEmailQueue } from "../queue/email"; +import { logger } from "../lib/pino"; + +/** Validates signup request with email and password fields */ +const SignupBodySchema = z.object({ + email: z + .email({ error: "Invalid email" }) + .nonempty({ error: "Email can't be empty" }), + password: z.string(), +}); + +/** + * Generates a random 6-digit verification code. + * Used for email verification during signup. + */ +export function generate6DigitCode(): string { + return Math.floor(Math.random() * 1_000_000) + .toString() + .padStart(6, "0"); +} + +const router: express.Router = express.Router(); + +router.post("/signup", async (req, res) => { + const body: Infer = req.body; + logger.info({ email: body.email }, "Signup attempt initiated"); + + const parsed = SignupBodySchema.safeParse(body); + if (!parsed.success) { + logger.warn( + { email: body.email, error: parsed.error }, + "Signup validation failed", + ); + return res.send({ + success: false, + reason: parsed.error, + token: "", + }); + } + + const existingUsers = await db + .select() + .from(users) + .where(eq(users.email, body.email)); + + if (existingUsers.length > 0) { + logger.warn({ email: body.email }, "Signup failed: email already in use"); + return res.send({ + success: false, + reason: "Email already used!", + }); + } + + const hashedPassword = await argon2.hash(body.password); + const code = generate6DigitCode(); + + const user = await db + .insert(users) + .values({ + email: body.email, + password: hashedPassword, + emailCode: code, + }) + .returning(); + + await verificationEmailQueue.add("sendVerificationEmail", { + email: body.email, + code, + }); + + logger.info( + { userId: user[0]!.id, email: body.email }, + "Verification email queued", + ); + + const jwt = await signJwt( + { id: user[0]!.id, type: "parent" }, + "urn:buddy:users", + ); + + logger.info( + { userId: user[0]!.id, email: body.email }, + "User signup completed successfully", + ); + + res.send({ + success: true, + token: jwt, + reason: "", + }); +}); + +export default router; diff --git a/src/types/express.d.ts b/src/types/express.d.ts new file mode 100644 index 0000000..7f9ceb5 --- /dev/null +++ b/src/types/express.d.ts @@ -0,0 +1,12 @@ +declare global { + namespace Express { + interface Request { + user?: { + id: number; + type: "parent" | "child"; + }; + } + } +} + +export {}; -- cgit v1.2.3