summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJustZvan <justzvan@justzvan.xyz>2026-03-30 13:10:26 +0200
committerJustZvan <justzvan@justzvan.xyz>2026-03-30 13:10:26 +0200
commit4f34bdf4de0d847591faa3e6981eafdf2b80a0b7 (patch)
treef2dcb99cec464f0576a2f9815ce81afd866f485c /src
parent045c533fa3f5ff958829ea412cff647de1ebadcd (diff)
feat: add gallery scanning
Diffstat (limited to 'src')
-rw-r--r--src/db/schema.ts16
-rw-r--r--src/index.ts85
-rw-r--r--src/routes/kid.ts1
3 files changed, 100 insertions, 2 deletions
diff --git a/src/db/schema.ts b/src/db/schema.ts
index ed2e36b..fe58708 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -1,7 +1,18 @@
-import { integer, pgTable, varchar, boolean, text, pgEnum } from "drizzle-orm/pg-core";
+import {
+ integer,
+ pgTable,
+ varchar,
+ boolean,
+ text,
+ pgEnum,
+} from "drizzle-orm/pg-core";
import { defineRelations, sql } from "drizzle-orm";
-export const galleryScanningMode = pgEnum("galleryScanningMode", ["delete", "notify", "none"]);
+export const galleryScanningMode = pgEnum("galleryScanningMode", [
+ "delete",
+ "notify",
+ "none",
+]);
/** Parent user accounts with email auth and push notification tokens */
export const users = pgTable("users", {
@@ -41,6 +52,7 @@ export const deviceConfig = pgTable("deviceConfig", {
notifyNewContactAdded: boolean("notify_new_contact_added")
.notNull()
.default(true),
+ galleryScanningMode: galleryScanningMode().default("notify").notNull(),
});
/** Stores flagged messages and content alerts for parent review */
diff --git a/src/index.ts b/src/index.ts
index 22c9024..2d4c114 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -552,6 +552,91 @@ app.ws("/kid/connect", (ws, req) => {
return;
}
+ if (data.type === "nsfw_image_detected") {
+ const deviceId = (ws as unknown as KidWebSocket).deviceId;
+ if (!deviceId) {
+ ws.send(
+ JSON.stringify({ success: false, reason: "Not authenticated" }),
+ );
+ return;
+ }
+
+ try {
+ 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 nsfw image event");
+ ws.send(JSON.stringify({ success: true }));
+ return;
+ }
+
+ 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("nsfw-image-alert", {
+ pushTokens: parent[0]!.pushTokens,
+ notification: {
+ title: `⚠️ NSFW Image Detected`,
+ body: `An NSFW image was found on ${deviceName}`,
+ data: {
+ type: "nsfw_image_detected",
+ screen: "DeviceDetail",
+ deviceId: deviceId.toString(),
+ deviceName: deviceName,
+ },
+ channelId: "alerts",
+ },
+ });
+
+ await db.insert(alerts).values({
+ deviceId: deviceId,
+ parentId: parentId,
+ category: "nsfw_image",
+ title: `NSFW Image Detected`,
+ message: `An NSFW image was found on ${deviceName}`,
+ summary:
+ "An image containing nudity or sexually explicit content was detected.",
+ confidence: 100,
+ packageName: (data.packageName as string) || "unknown",
+ timestamp: Math.floor(Date.now() / 1000),
+ read: false,
+ });
+
+ logger.info({ parentId, deviceId }, "NSFW image notification sent");
+ }
+
+ ws.send(JSON.stringify({ success: true }));
+ } catch (e) {
+ logger.error(
+ { error: e, deviceId },
+ "Failed to process nsfw image event",
+ );
+ ws.send(
+ JSON.stringify({
+ success: false,
+ reason: "Failed to process nsfw image event",
+ }),
+ );
+ }
+
+ return;
+ }
+
logger.debug(
{ data, deviceId: (ws as unknown as KidWebSocket).deviceId },
"Unknown message type received",
diff --git a/src/routes/kid.ts b/src/routes/kid.ts
index a326d5b..182535c 100644
--- a/src/routes/kid.ts
+++ b/src/routes/kid.ts
@@ -93,6 +93,7 @@ router.get("/kid/getconfig", authDevice, async (req, res) => {
disableBuddy: cfg.disableBuddy,
blockAdultSites: cfg.blockAdultSites,
familyLinkAntiCircumvention: cfg.familyLinkAntiCircumvention,
+ galleryScanningMode: cfg.galleryScanningMode,
},
});
} catch (e) {