diff --git a/.env-dist b/.env-dist index 9334404ea..1137253f8 100755 --- a/.env-dist +++ b/.env-dist @@ -166,3 +166,9 @@ NIMBUS_SIDECAR_URL=http://localhost:8001 # The maximum number of jobs that the email breach alert worker will process. EMAIL_BREACH_ALERT_MAX_MESSAGES = 10000 + +# The maximum number of scans and profiles allowed. May be used for alerts, and for redirecting to waitlist. +MAX_MANUAL_SCANS=100 +MAX_INITIAL_SCANS=100 +MAX_PROFILES_ACTIVATED=100 +MAX_PROFILES_CREATED=100 diff --git a/src/app/functions/server/onerep.ts b/src/app/functions/server/onerep.ts index d025e3200..fc7dad1ba 100644 --- a/src/app/functions/server/onerep.ts +++ b/src/app/functions/server/onerep.ts @@ -108,7 +108,7 @@ async function onerepFetch( } const onerepApiKey = process.env.ONEREP_API_KEY; if (!onerepApiKey) { - throw new Error("ONEREP_API_BASE env var not set"); + throw new Error("ONEREP_API_KEY env var not set"); } const url = new URL(path, onerepApiBase); const headers = new Headers(options.headers); diff --git a/src/db/migrations/20230618104332_feature_flags.js b/src/db/migrations/20230618104332_feature_flags.js index 6eae1193b..21d7bfd64 100644 --- a/src/db/migrations/20230618104332_feature_flags.js +++ b/src/db/migrations/20230618104332_feature_flags.js @@ -17,7 +17,7 @@ table.timestamp('deleted_at') table.string('owner') }) - + } export function down (knex) { diff --git a/src/db/migrations/20231220015816_onerep_stats.js b/src/db/migrations/20231220015816_onerep_stats.js new file mode 100644 index 000000000..aba103dc6 --- /dev/null +++ b/src/db/migrations/20231220015816_onerep_stats.js @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export async function up(knex) { + return knex.schema + .createTable("stats", table => { + table.increments('id').primary() + table.string("name") + table.string("current") + table.string("max") + table.string("type") + table.timestamp("created_at").defaultTo(knex.fn.now()) + table.timestamp("modified_at").defaultTo(knex.fn.now()) + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function down(knex) { + return knex.schema + .dropTableIfExists("stats") +} diff --git a/src/db/tables/stats.js b/src/db/tables/stats.js new file mode 100644 index 000000000..5f537735a --- /dev/null +++ b/src/db/tables/stats.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + + import createDbConnection from "../connect.js"; + const knex = createDbConnection(); + + export { knex as knexStats } + +// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy +/* c8 ignore start */ + +/** + * @param {string} name + * @param {string} current + * @param {string} max + * @returns {Promise} updated subscriber + */ +export async function addOnerepStats (name, current, max) { + const res = await knex("stats").insert({ name, current, max, type: "onerep"}).returning("*"); + return res[0]; +} + +/** + * @returns {object} + */ +export async function getOnerepStats () { + const res = await knex("stats").select("name", "current", "max").where("type", "onerep"); + return res[0]; +} + +/* c8 ignore stop */ diff --git a/src/knex-tables.d.ts b/src/knex-tables.d.ts index 6bce68837..a3ab03d73 100644 --- a/src/knex-tables.d.ts +++ b/src/knex-tables.d.ts @@ -370,4 +370,10 @@ declare module "knex/types/tables" { Pick >; } + interface StatsRow { + name: string; + current: string; + max: string; + type: string; + } } diff --git a/src/scripts/onerepStatsAlert.js b/src/scripts/onerepStatsAlert.js new file mode 100644 index 000000000..596dcf81d --- /dev/null +++ b/src/scripts/onerepStatsAlert.js @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import Sentry from "@sentry/nextjs"; +import { addOnerepStats, knexStats } from "../db/tables/stats.js"; + +const SENTRY_SLUG = "cron-onerep-stats-alerts"; + +const MAX_MANUAL_SCANS = parseInt(process.env.MAX_MANUAL_SCANS) || 0; +const MAX_INITIAL_SCANS = parseInt(process.env.MAX_INITIAL_SCANS) || 0; +const MAX_PROFILES_ACTIVATED = + parseInt(process.env.MAX_PROFILES_ACTIVATED) || 0; +const MAX_PROFILES_CREATED = parseInt(process.env.MAX_PROFILES_CREATED) || 0; + +Sentry.init({ + environment: process.env.APP_ENV, + dsn: process.env.SENTRY_DSN, + tracesSampleRate: 1.0, +}); + +const checkInId = Sentry.captureCheckIn({ + monitorSlug: SENTRY_SLUG, + status: "in_progress", +}); + +/** + * Fetch the latest usage statistics from OneRep's API. + * + * @see https://docs.onerep.com/#tag/Statistics + */ +export async function checkStats() { + const scans = await (await onerepFetch("/stats/scans")).json(); + const profiles = await (await onerepFetch("/stats/profiles")).json(); + + for (const alert of [ + ["free_scans", scans.manual, MAX_MANUAL_SCANS], + ["paid_scans", scans.initial, MAX_INITIAL_SCANS], + ["profiles_activated", profiles.activated, MAX_PROFILES_ACTIVATED], + ["profiles_created", profiles.created, MAX_PROFILES_CREATED], + ]) { + const [name, current, max] = alert; + + await addOnerepStats(name, current, max); + + if (current >= max) { + const msg = `Alert: OneRep scans over limit for ${name}. Current: ${current}, max: ${max}`; + console.error(msg); + Sentry.captureMessage(msg); + } + } +} + +// TODO use the shared version when this is converted to Typescript. +async function onerepFetch(path, options = {}) { + const onerepApiBase = process.env.ONEREP_API_BASE; + if (!onerepApiBase) { + throw new Error("ONEREP_API_BASE env var not set"); + } + const onerepApiKey = process.env.ONEREP_API_KEY; + if (!onerepApiKey) { + throw new Error("ONEREP_API_KEY env var not set"); + } + const url = new URL(path, onerepApiBase); + const headers = new Headers(options?.headers); + headers.set("Authorization", `Bearer ${onerepApiKey}`); + headers.set("Accept", "application/json"); + headers.set("Content-Type", "application/json"); + return fetch(url, { ...options, headers }); +} + +checkStats() + .then(async (_) => { + Sentry.captureCheckIn({ + checkInId, + monitorSlug: SENTRY_SLUG, + status: "ok", + }); + knexStats.destroy(); + }) + .catch((err) => { + console.error(err); + Sentry.captureException(err); + });