mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 13:48:31 +00:00
feat: setup oxlint and prettier for the project
This commit is contained in:
@@ -1,18 +1,13 @@
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import type { Database } from "../db/client";
|
||||
import { checks, incidents, monitors } from "../db/schema";
|
||||
import type { CheckResult, Monitor } from "./run-check";
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import type { Database } from '../db/client';
|
||||
import { checks, incidents, monitors } from '../db/schema';
|
||||
import type { CheckResult, Monitor } from './run-check';
|
||||
|
||||
export type IncidentTransition = "opened" | "resolved" | null;
|
||||
export type IncidentTransition = 'opened' | 'resolved' | null;
|
||||
|
||||
type BatchStatement = Parameters<Database["batch"]>[0][number];
|
||||
type BatchStatement = Parameters<Database['batch']>[0][number];
|
||||
|
||||
export function buildResultStatements(
|
||||
db: Database,
|
||||
monitor: Monitor,
|
||||
result: CheckResult,
|
||||
checkedAt: Date,
|
||||
) {
|
||||
export function buildResultStatements(db: Database, monitor: Monitor, result: CheckResult, checkedAt: Date) {
|
||||
const statements: BatchStatement[] = [
|
||||
db.insert(checks).values({
|
||||
monitorId: monitor.id,
|
||||
@@ -47,7 +42,7 @@ export function buildResultStatements(
|
||||
updatedAt: checkedAt,
|
||||
}),
|
||||
);
|
||||
transition = "opened";
|
||||
transition = 'opened';
|
||||
} else if (monitor.lastOk === false && result.ok) {
|
||||
statements.push(
|
||||
db
|
||||
@@ -57,14 +52,9 @@ export function buildResultStatements(
|
||||
durationMs: sql`${checkedAt.getTime()} - ${incidents.startedAt}`,
|
||||
updatedAt: checkedAt,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(incidents.monitorId, monitor.id),
|
||||
isNull(incidents.resolvedAt),
|
||||
),
|
||||
),
|
||||
.where(and(eq(incidents.monitorId, monitor.id), isNull(incidents.resolvedAt))),
|
||||
);
|
||||
transition = "resolved";
|
||||
transition = 'resolved';
|
||||
}
|
||||
|
||||
return { statements, transition };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { monitors } from "../db/schema";
|
||||
import type { monitors } from '../db/schema';
|
||||
|
||||
export type Monitor = typeof monitors.$inferSelect;
|
||||
|
||||
@@ -14,9 +14,9 @@ export async function runCheck(monitor: Monitor): Promise<CheckResult> {
|
||||
try {
|
||||
const response = await fetch(monitor.url, {
|
||||
method: monitor.method,
|
||||
redirect: "follow",
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(monitor.timeoutMs),
|
||||
headers: { "User-Agent": "Upwatch/1.0 (+uptime monitor)" },
|
||||
headers: { 'User-Agent': 'Upwatch/1.0 (+uptime monitor)' },
|
||||
});
|
||||
await response.body?.cancel();
|
||||
const ok = response.status === monitor.expectedStatus;
|
||||
@@ -24,18 +24,14 @@ export async function runCheck(monitor: Monitor): Promise<CheckResult> {
|
||||
ok,
|
||||
statusCode: response.status,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: ok
|
||||
? null
|
||||
: `Expected HTTP ${monitor.expectedStatus}, received ${response.status}`,
|
||||
error: ok ? null : `Expected HTTP ${monitor.expectedStatus}, received ${response.status}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: null,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: error instanceof Error
|
||||
? error.message.slice(0, 200)
|
||||
: "Request failed",
|
||||
error: error instanceof Error ? error.message.slice(0, 200) : 'Request failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { getDb } from "../db/client";
|
||||
import { monitors } from "../db/schema";
|
||||
import { sendIncidentAlert } from "../notifications/webhook";
|
||||
import { buildResultStatements } from "./persist-result";
|
||||
import { runCheck } from "./run-check";
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { getDb } from '../db/client';
|
||||
import { monitors } from '../db/schema';
|
||||
import { sendIncidentAlert } from '../notifications/webhook';
|
||||
import { buildResultStatements } from './persist-result';
|
||||
import { runCheck } from './run-check';
|
||||
|
||||
const MAX_MONITORS_PER_RUN = 40;
|
||||
const CONCURRENCY = 10;
|
||||
@@ -14,10 +14,7 @@ export type DueCheckSummary = {
|
||||
down: number;
|
||||
};
|
||||
|
||||
export async function runDueChecks(
|
||||
env: Env,
|
||||
ctx?: Pick<ExecutionContext, "waitUntil">,
|
||||
): Promise<DueCheckSummary> {
|
||||
export async function runDueChecks(env: Env, ctx?: Pick<ExecutionContext, 'waitUntil'>): Promise<DueCheckSummary> {
|
||||
const db = getDb(env);
|
||||
const now = Date.now();
|
||||
const due = await db
|
||||
@@ -60,16 +57,20 @@ export async function runDueChecks(
|
||||
}));
|
||||
const statements = persisted.flatMap((item) => item.statements);
|
||||
|
||||
await db.batch(statements as [typeof statements[number], ...typeof statements]);
|
||||
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
|
||||
|
||||
const notifications = persisted.flatMap((item) => item.transition === null ? [] : [
|
||||
sendIncidentAlert(env, {
|
||||
monitor: item.monitor,
|
||||
kind: item.transition,
|
||||
result: item.result,
|
||||
at: item.checkedAt,
|
||||
}),
|
||||
]);
|
||||
const notifications = persisted.flatMap((item) =>
|
||||
item.transition === null
|
||||
? []
|
||||
: [
|
||||
sendIncidentAlert(env, {
|
||||
monitor: item.monitor,
|
||||
kind: item.transition,
|
||||
result: item.result,
|
||||
at: item.checkedAt,
|
||||
}),
|
||||
],
|
||||
);
|
||||
if (notifications.length > 0) {
|
||||
const notificationWork = Promise.all(notifications).then(() => undefined);
|
||||
if (ctx) ctx.waitUntil(notificationWork);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { drizzle } from "drizzle-orm/d1";
|
||||
import * as schema from "./schema";
|
||||
import { drizzle } from 'drizzle-orm/d1';
|
||||
import * as schema from './schema';
|
||||
|
||||
export const getDb = (env: Env) => drizzle(env.DB, { schema });
|
||||
|
||||
|
||||
+74
-88
@@ -1,125 +1,111 @@
|
||||
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const adminCredentials = sqliteTable("admin_credentials", {
|
||||
id: integer("id").primaryKey(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||
export const adminCredentials = sqliteTable('admin_credentials', {
|
||||
id: integer('id').primaryKey(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
});
|
||||
|
||||
export const notificationSettings = sqliteTable("notification_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
webhookUrl: text("webhook_url"),
|
||||
webhookEnabled: integer("webhook_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||
export const notificationSettings = sqliteTable('notification_settings', {
|
||||
id: integer('id').primaryKey(),
|
||||
webhookUrl: text('webhook_url'),
|
||||
webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable(
|
||||
"sessions",
|
||||
'sessions',
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
userAgent: text("user_agent"),
|
||||
id: text('id').primaryKey(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
userAgent: text('user_agent'),
|
||||
},
|
||||
(table) => [index("sessions_expires_at_idx").on(table.expiresAt)],
|
||||
(table) => [index('sessions_expires_at_idx').on(table.expiresAt)],
|
||||
);
|
||||
|
||||
export const loginAttempts = sqliteTable(
|
||||
"login_attempts",
|
||||
'login_attempts',
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
ipAddress: text("ip_address").notNull(),
|
||||
attemptedAt: integer("attempted_at", { mode: "timestamp_ms" }).notNull(),
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
ipAddress: text('ip_address').notNull(),
|
||||
attemptedAt: integer('attempted_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(table) => [index("login_attempts_ip_attempted_at_idx").on(table.ipAddress, table.attemptedAt)],
|
||||
(table) => [index('login_attempts_ip_attempted_at_idx').on(table.ipAddress, table.attemptedAt)],
|
||||
);
|
||||
|
||||
export const monitors = sqliteTable(
|
||||
"monitors",
|
||||
'monitors',
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
url: text("url").notNull(),
|
||||
method: text("method").notNull().default("GET"),
|
||||
expectedStatus: integer("expected_status").notNull().default(200),
|
||||
intervalSeconds: integer("interval_seconds").notNull().default(300),
|
||||
timeoutMs: integer("timeout_ms").notNull().default(10_000),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
alertsEnabled: integer("alerts_enabled", { mode: "boolean" }).notNull().default(true),
|
||||
lastOk: integer("last_ok", { mode: "boolean" }),
|
||||
lastStatusCode: integer("last_status_code"),
|
||||
lastLatencyMs: integer("last_latency_ms"),
|
||||
lastError: text("last_error"),
|
||||
lastCheckedAt: integer("last_checked_at", { mode: "timestamp_ms" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull(),
|
||||
url: text('url').notNull(),
|
||||
method: text('method').notNull().default('GET'),
|
||||
expectedStatus: integer('expected_status').notNull().default(200),
|
||||
intervalSeconds: integer('interval_seconds').notNull().default(300),
|
||||
timeoutMs: integer('timeout_ms').notNull().default(10_000),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
alertsEnabled: integer('alerts_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
lastOk: integer('last_ok', { mode: 'boolean' }),
|
||||
lastStatusCode: integer('last_status_code'),
|
||||
lastLatencyMs: integer('last_latency_ms'),
|
||||
lastError: text('last_error'),
|
||||
lastCheckedAt: integer('last_checked_at', { mode: 'timestamp_ms' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("monitors_enabled_last_checked_at_idx").on(
|
||||
table.enabled,
|
||||
table.lastCheckedAt,
|
||||
),
|
||||
],
|
||||
(table) => [index('monitors_enabled_last_checked_at_idx').on(table.enabled, table.lastCheckedAt)],
|
||||
);
|
||||
|
||||
export const checks = sqliteTable(
|
||||
"checks",
|
||||
'checks',
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
monitorId: integer("monitor_id")
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
monitorId: integer('monitor_id')
|
||||
.notNull()
|
||||
.references(() => monitors.id, { onDelete: "cascade" }),
|
||||
ok: integer("ok", { mode: "boolean" }).notNull(),
|
||||
statusCode: integer("status_code"),
|
||||
latencyMs: integer("latency_ms"),
|
||||
error: text("error"),
|
||||
checkedAt: integer("checked_at", { mode: "timestamp_ms" }).notNull(),
|
||||
.references(() => monitors.id, { onDelete: 'cascade' }),
|
||||
ok: integer('ok', { mode: 'boolean' }).notNull(),
|
||||
statusCode: integer('status_code'),
|
||||
latencyMs: integer('latency_ms'),
|
||||
error: text('error'),
|
||||
checkedAt: integer('checked_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("checks_monitor_id_checked_at_idx").on(
|
||||
table.monitorId,
|
||||
table.checkedAt,
|
||||
),
|
||||
],
|
||||
(table) => [index('checks_monitor_id_checked_at_idx').on(table.monitorId, table.checkedAt)],
|
||||
);
|
||||
|
||||
export const incidents = sqliteTable(
|
||||
"incidents",
|
||||
'incidents',
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
monitorId: integer("monitor_id")
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
monitorId: integer('monitor_id')
|
||||
.notNull()
|
||||
.references(() => monitors.id, { onDelete: "cascade" }),
|
||||
startedAt: integer("started_at", { mode: "timestamp_ms" }).notNull(),
|
||||
resolvedAt: integer("resolved_at", { mode: "timestamp_ms" }),
|
||||
startStatusCode: integer("start_status_code"),
|
||||
startError: text("start_error"),
|
||||
durationMs: integer("duration_ms"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||
.references(() => monitors.id, { onDelete: 'cascade' }),
|
||||
startedAt: integer('started_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
resolvedAt: integer('resolved_at', { mode: 'timestamp_ms' }),
|
||||
startStatusCode: integer('start_status_code'),
|
||||
startError: text('start_error'),
|
||||
durationMs: integer('duration_ms'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("incidents_monitor_id_started_at_idx").on(table.monitorId, table.startedAt),
|
||||
],
|
||||
(table) => [index('incidents_monitor_id_started_at_idx').on(table.monitorId, table.startedAt)],
|
||||
);
|
||||
|
||||
export const monitorDailyStats = sqliteTable(
|
||||
"monitor_daily_stats",
|
||||
'monitor_daily_stats',
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
monitorId: integer("monitor_id")
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
monitorId: integer('monitor_id')
|
||||
.notNull()
|
||||
.references(() => monitors.id, { onDelete: "cascade" }),
|
||||
day: integer("day", { mode: "timestamp_ms" }).notNull(),
|
||||
totalChecks: integer("total_checks").notNull(),
|
||||
upChecks: integer("up_checks").notNull(),
|
||||
avgLatencyMs: integer("avg_latency_ms"),
|
||||
minLatencyMs: integer("min_latency_ms"),
|
||||
maxLatencyMs: integer("max_latency_ms"),
|
||||
.references(() => monitors.id, { onDelete: 'cascade' }),
|
||||
day: integer('day', { mode: 'timestamp_ms' }).notNull(),
|
||||
totalChecks: integer('total_checks').notNull(),
|
||||
upChecks: integer('up_checks').notNull(),
|
||||
avgLatencyMs: integer('avg_latency_ms'),
|
||||
minLatencyMs: integer('min_latency_ms'),
|
||||
maxLatencyMs: integer('max_latency_ms'),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("monitor_daily_stats_monitor_id_day_uidx").on(table.monitorId, table.day),
|
||||
],
|
||||
(table) => [uniqueIndex('monitor_daily_stats_monitor_id_day_uidx').on(table.monitorId, table.day)],
|
||||
);
|
||||
|
||||
+26
-24
@@ -1,19 +1,19 @@
|
||||
import { Hono } from "hono";
|
||||
import { csrf } from "hono/csrf";
|
||||
import { runDueChecks } from "./checks/run-due-checks";
|
||||
import authRoutes from "./routes/auth";
|
||||
import monitorRoutes from "./routes/monitors";
|
||||
import settingsRoutes from "./routes/settings";
|
||||
import statusRoutes from "./routes/status";
|
||||
import { cleanupExpiredAuthRecords } from "./scheduled/cleanup";
|
||||
import { runDailyRollup } from "./scheduled/rollup";
|
||||
import { Hono } from 'hono';
|
||||
import { csrf } from 'hono/csrf';
|
||||
import { runDueChecks } from './checks/run-due-checks';
|
||||
import authRoutes from './routes/auth';
|
||||
import monitorRoutes from './routes/monitors';
|
||||
import settingsRoutes from './routes/settings';
|
||||
import statusRoutes from './routes/status';
|
||||
import { cleanupExpiredAuthRecords } from './scheduled/cleanup';
|
||||
import { runDailyRollup } from './scheduled/rollup';
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
app.use("/api/*", csrf());
|
||||
app.use('/api/*', csrf());
|
||||
|
||||
app.get("/api/health", async (context) => {
|
||||
const db = await context.env.DB.prepare("SELECT 1 AS ok").first<{
|
||||
app.get('/api/health', async (context) => {
|
||||
const db = await context.env.DB.prepare('SELECT 1 AS ok').first<{
|
||||
ok: number;
|
||||
}>();
|
||||
|
||||
@@ -24,22 +24,24 @@ app.get("/api/health", async (context) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.route("/", authRoutes);
|
||||
app.route("/api/monitors", monitorRoutes);
|
||||
app.route("/api/settings", settingsRoutes);
|
||||
app.route("/api/status", statusRoutes);
|
||||
app.route('/', authRoutes);
|
||||
app.route('/api/monitors', monitorRoutes);
|
||||
app.route('/api/settings', settingsRoutes);
|
||||
app.route('/api/status', statusRoutes);
|
||||
|
||||
export default {
|
||||
fetch: app.fetch,
|
||||
async scheduled(controller, env, ctx) {
|
||||
if (controller.cron === "5 0 * * *") {
|
||||
if (controller.cron === '5 0 * * *') {
|
||||
const result = await runDailyRollup(env, new Date(controller.scheduledTime));
|
||||
console.log(JSON.stringify({
|
||||
message: "daily rollup completed",
|
||||
cron: controller.cron,
|
||||
scheduledTime: controller.scheduledTime,
|
||||
...result,
|
||||
}));
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
message: 'daily rollup completed',
|
||||
cron: controller.cron,
|
||||
scheduledTime: controller.scheduledTime,
|
||||
...result,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,7 +49,7 @@ export default {
|
||||
const result = await runDueChecks(env, ctx);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
message: "scheduled run completed",
|
||||
message: 'scheduled run completed',
|
||||
cron: controller.cron,
|
||||
scheduledTime: controller.scheduledTime,
|
||||
...result,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const HASH_ALGORITHM = "SHA-256";
|
||||
const HASH_ALGORITHM = 'SHA-256';
|
||||
const HASH_BYTES = 32;
|
||||
const SALT_BYTES = 16;
|
||||
|
||||
@@ -7,7 +7,7 @@ const SALT_BYTES = 16;
|
||||
export const PBKDF2_ITERATIONS = 25_000;
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
@@ -18,18 +18,12 @@ function base64ToBytes(value: string) {
|
||||
}
|
||||
|
||||
async function derivePassword(plain: string, salt: ArrayBuffer, iterations: number) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(plain),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"],
|
||||
);
|
||||
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(plain), 'PBKDF2', false, ['deriveBits']);
|
||||
|
||||
return new Uint8Array(
|
||||
await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
name: 'PBKDF2',
|
||||
hash: HASH_ALGORITHM,
|
||||
salt,
|
||||
iterations,
|
||||
@@ -48,12 +42,12 @@ export async function hashPassword(plain: string) {
|
||||
}
|
||||
|
||||
export async function verifyPassword(plain: string, stored: string) {
|
||||
const [scheme, digest, iterationValue, saltValue, hashValue, ...extra] = stored.split("$");
|
||||
const [scheme, digest, iterationValue, saltValue, hashValue, ...extra] = stored.split('$');
|
||||
const iterations = Number(iterationValue);
|
||||
|
||||
if (
|
||||
scheme !== "pbkdf2" ||
|
||||
digest !== "sha256" ||
|
||||
scheme !== 'pbkdf2' ||
|
||||
digest !== 'sha256' ||
|
||||
extra.length > 0 ||
|
||||
!Number.isSafeInteger(iterations) ||
|
||||
iterations < 1 ||
|
||||
@@ -69,11 +63,7 @@ export async function verifyPassword(plain: string, stored: string) {
|
||||
const expected = base64ToBytes(hashValue);
|
||||
if (salt.length !== SALT_BYTES || expected.length !== HASH_BYTES) return false;
|
||||
|
||||
const actual = await derivePassword(
|
||||
plain,
|
||||
Uint8Array.from(salt).buffer,
|
||||
iterations,
|
||||
);
|
||||
const actual = await derivePassword(plain, Uint8Array.from(salt).buffer, iterations);
|
||||
const subtle = crypto.subtle as SubtleCrypto & {
|
||||
timingSafeEqual(a: ArrayBufferView, b: ArrayBufferView): boolean;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getCookie } from "hono/cookie";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import { getDb } from "../db/client";
|
||||
import { hasValidSession, SESSION_COOKIE } from "./session";
|
||||
import { getCookie } from 'hono/cookie';
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
import { getDb } from '../db/client';
|
||||
import { hasValidSession, SESSION_COOKIE } from './session';
|
||||
|
||||
export type AuthVariables = {
|
||||
authenticated: true;
|
||||
@@ -12,11 +12,11 @@ export const requireAuth = createMiddleware<{
|
||||
Variables: AuthVariables;
|
||||
}>(async (context, next) => {
|
||||
const token = getCookie(context, SESSION_COOKIE);
|
||||
if (!token) return context.json({ message: "Authentication required" }, 401);
|
||||
if (!token) return context.json({ message: 'Authentication required' }, 401);
|
||||
|
||||
const authenticated = await hasValidSession(getDb(context.env), token);
|
||||
if (!authenticated) return context.json({ message: "Authentication required" }, 401);
|
||||
if (!authenticated) return context.json({ message: 'Authentication required' }, 401);
|
||||
|
||||
context.set("authenticated", true);
|
||||
context.set('authenticated', true);
|
||||
await next();
|
||||
});
|
||||
|
||||
+15
-28
@@ -1,38 +1,33 @@
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
import type { CookieOptions } from "hono/utils/cookie";
|
||||
import type { Database } from "../db/client";
|
||||
import { sessions } from "../db/schema";
|
||||
import { and, eq, gt } from 'drizzle-orm';
|
||||
import type { CookieOptions } from 'hono/utils/cookie';
|
||||
import type { Database } from '../db/client';
|
||||
import { sessions } from '../db/schema';
|
||||
|
||||
export const SESSION_COOKIE = "upwatch_session";
|
||||
export const SESSION_COOKIE = 'upwatch_session';
|
||||
export const SESSION_DURATION_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
function bytesToBase64Url(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
||||
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
async function sha256Hex(value: string) {
|
||||
const digest = new Uint8Array(
|
||||
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)),
|
||||
);
|
||||
return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)));
|
||||
return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export function sessionCookieOptions(requestUrl: string): CookieOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
sameSite: 'Lax',
|
||||
path: '/',
|
||||
maxAge: SESSION_DURATION_SECONDS,
|
||||
secure: new URL(requestUrl).protocol === "https:",
|
||||
secure: new URL(requestUrl).protocol === 'https:',
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
db: Database,
|
||||
userAgent: string | null,
|
||||
) {
|
||||
export async function createSession(db: Database, userAgent: string | null) {
|
||||
const token = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(32)));
|
||||
const now = new Date();
|
||||
|
||||
@@ -46,19 +41,11 @@ export async function createSession(
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function hasValidSession(
|
||||
db: Database,
|
||||
token: string,
|
||||
): Promise<boolean> {
|
||||
export async function hasValidSession(db: Database, token: string): Promise<boolean> {
|
||||
const [result] = await db
|
||||
.select({ id: sessions.id })
|
||||
.from(sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.id, await sha256Hex(token)),
|
||||
gt(sessions.expiresAt, new Date()),
|
||||
),
|
||||
)
|
||||
.where(and(eq(sessions.id, await sha256Hex(token)), gt(sessions.expiresAt, new Date())))
|
||||
.limit(1);
|
||||
|
||||
return result !== undefined;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { CheckResult, Monitor } from "../checks/run-check";
|
||||
import { getDb } from "../db/client";
|
||||
import { notificationSettings } from "../db/schema";
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { CheckResult, Monitor } from '../checks/run-check';
|
||||
import { getDb } from '../db/client';
|
||||
import { notificationSettings } from '../db/schema';
|
||||
|
||||
export type IncidentAlert = {
|
||||
monitor: Monitor;
|
||||
kind: "opened" | "resolved";
|
||||
kind: 'opened' | 'resolved';
|
||||
result: CheckResult;
|
||||
at: Date;
|
||||
};
|
||||
|
||||
type WebhookPayload = {
|
||||
event: "down" | "recovered" | "test";
|
||||
event: 'down' | 'recovered' | 'test';
|
||||
monitor: { id: number; name: string; url: string };
|
||||
statusCode: number | null;
|
||||
error: string | null;
|
||||
@@ -20,10 +20,10 @@ type WebhookPayload = {
|
||||
|
||||
async function postWebhook(url: string, payload: WebhookPayload): Promise<boolean> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Upwatch/1.0 (+incident webhook)",
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Upwatch/1.0 (+incident webhook)',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
@@ -34,59 +34,58 @@ async function postWebhook(url: string, payload: WebhookPayload): Promise<boolea
|
||||
export async function sendTestWebhook(url: string): Promise<boolean> {
|
||||
try {
|
||||
return await postWebhook(url, {
|
||||
event: "test",
|
||||
monitor: { id: 0, name: "Upwatch test", url: "https://example.com/health" },
|
||||
event: 'test',
|
||||
monitor: { id: 0, name: 'Upwatch test', url: 'https://example.com/health' },
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
message: "test webhook failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: 'test webhook failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendIncidentAlert(
|
||||
env: Env,
|
||||
alert: IncidentAlert,
|
||||
): Promise<boolean> {
|
||||
export async function sendIncidentAlert(env: Env, alert: IncidentAlert): Promise<boolean> {
|
||||
if (!alert.monitor.alertsEnabled) return false;
|
||||
|
||||
try {
|
||||
const [settings] = await getDb(env)
|
||||
.select()
|
||||
.from(notificationSettings)
|
||||
.where(eq(notificationSettings.id, 1))
|
||||
.limit(1);
|
||||
const [settings] = await getDb(env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1);
|
||||
if (!settings?.webhookEnabled || !settings.webhookUrl) return false;
|
||||
|
||||
const ok = await postWebhook(settings.webhookUrl, {
|
||||
event: alert.kind === "opened" ? "down" : "recovered",
|
||||
monitor: {
|
||||
id: alert.monitor.id,
|
||||
name: alert.monitor.name,
|
||||
url: alert.monitor.url,
|
||||
},
|
||||
statusCode: alert.result.statusCode,
|
||||
error: alert.result.error,
|
||||
at: alert.at.toISOString(),
|
||||
event: alert.kind === 'opened' ? 'down' : 'recovered',
|
||||
monitor: {
|
||||
id: alert.monitor.id,
|
||||
name: alert.monitor.name,
|
||||
url: alert.monitor.url,
|
||||
},
|
||||
statusCode: alert.result.statusCode,
|
||||
error: alert.result.error,
|
||||
at: alert.at.toISOString(),
|
||||
});
|
||||
if (!ok) {
|
||||
console.warn(JSON.stringify({
|
||||
message: "incident webhook returned an error",
|
||||
monitorId: alert.monitor.id,
|
||||
}));
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
message: 'incident webhook returned an error',
|
||||
monitorId: alert.monitor.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return ok;
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({
|
||||
message: "incident webhook failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
monitorId: alert.monitor.id,
|
||||
}));
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: 'incident webhook failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
monitorId: alert.monitor.id,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+23
-50
@@ -1,17 +1,11 @@
|
||||
import { and, count, eq, gte } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||
import { getDb } from "../db/client";
|
||||
import { adminCredentials, loginAttempts } from "../db/schema";
|
||||
import { verifyPassword } from "../lib/password";
|
||||
import { requireAuth, type AuthVariables } from "../lib/require-auth";
|
||||
import {
|
||||
createSession,
|
||||
hasValidSession,
|
||||
revokeSession,
|
||||
SESSION_COOKIE,
|
||||
sessionCookieOptions,
|
||||
} from "../lib/session";
|
||||
import { and, count, eq, gte } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
|
||||
import { getDb } from '../db/client';
|
||||
import { adminCredentials, loginAttempts } from '../db/schema';
|
||||
import { verifyPassword } from '../lib/password';
|
||||
import { requireAuth, type AuthVariables } from '../lib/require-auth';
|
||||
import { createSession, hasValidSession, revokeSession, SESSION_COOKIE, sessionCookieOptions } from '../lib/session';
|
||||
|
||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||
const MAX_FAILED_ATTEMPTS = 10;
|
||||
@@ -23,42 +17,28 @@ type LoginBody = {
|
||||
|
||||
const authRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
|
||||
|
||||
authRoutes.post("/api/auth/login", async (context) => {
|
||||
authRoutes.post('/api/auth/login', async (context) => {
|
||||
let body: LoginBody;
|
||||
try {
|
||||
body = await context.req.json<LoginBody>();
|
||||
} catch {
|
||||
return context.json({ message: "Invalid request body" }, 400);
|
||||
return context.json({ message: 'Invalid request body' }, 400);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof body.password !== "string" ||
|
||||
body.password.length < 8
|
||||
) {
|
||||
return context.json(
|
||||
{ message: "Enter a password of at least 8 characters" },
|
||||
400,
|
||||
);
|
||||
if (typeof body.password !== 'string' || body.password.length < 8) {
|
||||
return context.json({ message: 'Enter a password of at least 8 characters' }, 400);
|
||||
}
|
||||
|
||||
const db = getDb(context.env);
|
||||
const ipAddress = context.req.header("CF-Connecting-IP") ?? "unknown";
|
||||
const ipAddress = context.req.header('CF-Connecting-IP') ?? 'unknown';
|
||||
const cutoff = new Date(Date.now() - LOGIN_WINDOW_MS);
|
||||
const [attemptResult] = await db
|
||||
.select({ value: count() })
|
||||
.from(loginAttempts)
|
||||
.where(
|
||||
and(
|
||||
eq(loginAttempts.ipAddress, ipAddress),
|
||||
gte(loginAttempts.attemptedAt, cutoff),
|
||||
),
|
||||
);
|
||||
.where(and(eq(loginAttempts.ipAddress, ipAddress), gte(loginAttempts.attemptedAt, cutoff)));
|
||||
|
||||
if ((attemptResult?.value ?? 0) >= MAX_FAILED_ATTEMPTS) {
|
||||
return context.json(
|
||||
{ message: "Too many login attempts. Try again later" },
|
||||
429,
|
||||
);
|
||||
return context.json({ message: 'Too many login attempts. Try again later' }, 429);
|
||||
}
|
||||
|
||||
const [credential] = await db
|
||||
@@ -68,41 +48,34 @@ authRoutes.post("/api/auth/login", async (context) => {
|
||||
.from(adminCredentials)
|
||||
.where(eq(adminCredentials.id, ADMIN_CREDENTIAL_ID))
|
||||
.limit(1);
|
||||
const passwordMatches = credential
|
||||
? await verifyPassword(body.password, credential.passwordHash)
|
||||
: false;
|
||||
const passwordMatches = credential ? await verifyPassword(body.password, credential.passwordHash) : false;
|
||||
|
||||
if (!credential || !passwordMatches) {
|
||||
await db.insert(loginAttempts).values({ ipAddress, attemptedAt: new Date() });
|
||||
return context.json({ message: "Password is incorrect" }, 401);
|
||||
return context.json({ message: 'Password is incorrect' }, 401);
|
||||
}
|
||||
|
||||
await db.delete(loginAttempts).where(eq(loginAttempts.ipAddress, ipAddress));
|
||||
const token = await createSession(
|
||||
db,
|
||||
context.req.header("User-Agent") ?? null,
|
||||
);
|
||||
const token = await createSession(db, context.req.header('User-Agent') ?? null);
|
||||
setCookie(context, SESSION_COOKIE, token, sessionCookieOptions(context.req.url));
|
||||
|
||||
return context.json({ authenticated: true });
|
||||
});
|
||||
|
||||
authRoutes.post("/api/auth/logout", requireAuth, async (context) => {
|
||||
authRoutes.post('/api/auth/logout', requireAuth, async (context) => {
|
||||
const token = getCookie(context, SESSION_COOKIE);
|
||||
if (token) await revokeSession(getDb(context.env), token);
|
||||
|
||||
deleteCookie(context, SESSION_COOKIE, {
|
||||
path: "/",
|
||||
secure: new URL(context.req.url).protocol === "https:",
|
||||
path: '/',
|
||||
secure: new URL(context.req.url).protocol === 'https:',
|
||||
});
|
||||
return context.json({ ok: true });
|
||||
});
|
||||
|
||||
authRoutes.get("/api/auth/me", async (context) => {
|
||||
authRoutes.get('/api/auth/me', async (context) => {
|
||||
const token = getCookie(context, SESSION_COOKIE);
|
||||
const authenticated = token
|
||||
? await hasValidSession(getDb(context.env), token)
|
||||
: false;
|
||||
const authenticated = token ? await hasValidSession(getDb(context.env), token) : false;
|
||||
return context.json({ authenticated });
|
||||
});
|
||||
|
||||
|
||||
+147
-154
@@ -1,13 +1,13 @@
|
||||
import { and, desc, eq, gte, isNull, lt, or, sql } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { buildResultStatements } from "../checks/persist-result";
|
||||
import { runCheck } from "../checks/run-check";
|
||||
import { getDb } from "../db/client";
|
||||
import { checks, incidents, monitorDailyStats, monitors } from "../db/schema";
|
||||
import { requireAuth, type AuthVariables } from "../lib/require-auth";
|
||||
import { sendIncidentAlert } from "../notifications/webhook";
|
||||
import { and, desc, eq, gte, isNull, or, sql } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
import { buildResultStatements } from '../checks/persist-result';
|
||||
import { runCheck } from '../checks/run-check';
|
||||
import { getDb } from '../db/client';
|
||||
import { checks, incidents, monitors } from '../db/schema';
|
||||
import { requireAuth, type AuthVariables } from '../lib/require-auth';
|
||||
import { sendIncidentAlert } from '../notifications/webhook';
|
||||
|
||||
type MonitorMethod = "GET" | "HEAD" | "POST";
|
||||
type MonitorMethod = 'GET' | 'HEAD' | 'POST';
|
||||
|
||||
type ParsedMonitorInput = {
|
||||
name?: string;
|
||||
@@ -20,11 +20,9 @@ type ParsedMonitorInput = {
|
||||
alertsEnabled?: boolean;
|
||||
};
|
||||
|
||||
type ParseResult =
|
||||
| { ok: true; value: ParsedMonitorInput }
|
||||
| { ok: false; message: string };
|
||||
type ParseResult = { ok: true; value: ParsedMonitorInput } | { ok: false; message: string };
|
||||
|
||||
const METHODS = new Set<MonitorMethod>(["GET", "HEAD", "POST"]);
|
||||
const METHODS = new Set<MonitorMethod>(['GET', 'HEAD', 'POST']);
|
||||
const FAVICON_CACHE_SECONDS = 86_400;
|
||||
const FAVICON_FETCH_TIMEOUT_MS = 5_000;
|
||||
const MAX_FAVICON_BYTES = 1024 * 1024;
|
||||
@@ -42,31 +40,33 @@ type EdgeCache = {
|
||||
};
|
||||
|
||||
function isPrivateHostname(rawHostname: string): boolean {
|
||||
const hostname = rawHostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
||||
if (hostname === "localhost" || hostname.endsWith(".localhost")) return true;
|
||||
const hostname = rawHostname
|
||||
.toLowerCase()
|
||||
.replace(/^\[|\]$/g, '')
|
||||
.replace(/\.$/, '');
|
||||
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
|
||||
|
||||
const ipv4 = hostname.split(".").map(Number);
|
||||
const ipv4 = hostname.split('.').map(Number);
|
||||
if (ipv4.length === 4 && ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
|
||||
const [first, second] = ipv4;
|
||||
return first === 0
|
||||
|| first === 10
|
||||
|| first === 127
|
||||
|| (first === 169 && second === 254)
|
||||
|| (first === 172 && second >= 16 && second <= 31)
|
||||
|| (first === 192 && second === 168);
|
||||
return (
|
||||
first === 0 ||
|
||||
first === 10 ||
|
||||
first === 127 ||
|
||||
(first === 169 && second === 254) ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && second === 168)
|
||||
);
|
||||
}
|
||||
|
||||
if (hostname === "::" || hostname === "::1") return true;
|
||||
if (hostname === '::' || hostname === '::1') return true;
|
||||
if (/^f[cd][0-9a-f]{2}(?::|$)/i.test(hostname) || /^fe[89ab][0-9a-f](?::|$)/i.test(hostname)) return true;
|
||||
const mappedIpv4 = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
||||
return mappedIpv4 ? isPrivateHostname(mappedIpv4[1]) : false;
|
||||
}
|
||||
|
||||
function isSafeRemoteUrl(url: URL) {
|
||||
return (url.protocol === "http:" || url.protocol === "https:")
|
||||
&& !url.username
|
||||
&& !url.password
|
||||
&& !isPrivateHostname(url.hostname);
|
||||
return (url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password && !isPrivateHostname(url.hostname);
|
||||
}
|
||||
|
||||
async function readBodyLimited(response: Response, maximum: number, truncate: boolean) {
|
||||
@@ -117,16 +117,16 @@ async function fetchRemote(url: URL, maximumBytes: number, truncate = false) {
|
||||
if (!isSafeRemoteUrl(currentUrl)) return null;
|
||||
const response = await fetch(currentUrl, {
|
||||
headers: {
|
||||
Accept: "image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,text/html;q=0.5,*/*;q=0.1",
|
||||
"User-Agent": "Upwatch Favicon Proxy/1.0",
|
||||
Accept: 'image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,text/html;q=0.5,*/*;q=0.1',
|
||||
'User-Agent': 'Upwatch Favicon Proxy/1.0',
|
||||
},
|
||||
redirect: "manual",
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
if (response.body) await response.body.cancel().catch(() => undefined);
|
||||
const location = response.headers.get("Location");
|
||||
const location = response.headers.get('Location');
|
||||
if (!location || redirects === MAX_REDIRECTS) return null;
|
||||
try {
|
||||
currentUrl = new URL(location, currentUrl);
|
||||
@@ -141,7 +141,7 @@ async function fetchRemote(url: URL, maximumBytes: number, truncate = false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const declaredLength = Number(response.headers.get("Content-Length"));
|
||||
const declaredLength = Number(response.headers.get('Content-Length'));
|
||||
if (!truncate && Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
|
||||
if (response.body) await response.body.cancel().catch(() => undefined);
|
||||
return null;
|
||||
@@ -161,9 +161,9 @@ async function fetchRemote(url: URL, maximumBytes: number, truncate = false) {
|
||||
}
|
||||
|
||||
function imageContentType(headers: Headers) {
|
||||
const contentType = headers.get("Content-Type")?.split(";", 1)[0].trim().toLowerCase();
|
||||
if (!contentType || contentType === "application/octet-stream") return "image/x-icon";
|
||||
if (!contentType.startsWith("image/")) {
|
||||
const contentType = headers.get('Content-Type')?.split(';', 1)[0].trim().toLowerCase();
|
||||
if (!contentType || contentType === 'application/octet-stream') return 'image/x-icon';
|
||||
if (!contentType.startsWith('image/')) {
|
||||
return null;
|
||||
}
|
||||
return contentType;
|
||||
@@ -172,7 +172,7 @@ function imageContentType(headers: Headers) {
|
||||
function readTagAttribute(tag: string, name: string) {
|
||||
const attributes = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
||||
for (const match of tag.matchAll(attributes)) {
|
||||
if (match[1].toLowerCase() === name) return match[2] ?? match[3] ?? match[4] ?? "";
|
||||
if (match[1].toLowerCase() === name) return match[2] ?? match[3] ?? match[4] ?? '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -182,7 +182,7 @@ function findFaviconUrl(html: string, pageUrl: URL) {
|
||||
const head = closingHead >= 0 ? html.slice(0, closingHead) : html;
|
||||
let baseUrl = pageUrl;
|
||||
const baseTag = head.match(/<base\b[^>]*>/i)?.[0];
|
||||
const baseHref = baseTag ? readTagAttribute(baseTag, "href") : null;
|
||||
const baseHref = baseTag ? readTagAttribute(baseTag, 'href') : null;
|
||||
if (baseHref) {
|
||||
try {
|
||||
const candidate = new URL(baseHref, pageUrl);
|
||||
@@ -193,9 +193,9 @@ function findFaviconUrl(html: string, pageUrl: URL) {
|
||||
}
|
||||
|
||||
for (const match of head.matchAll(/<link\b[^>]*>/gi)) {
|
||||
const rel = readTagAttribute(match[0], "rel")?.toLowerCase().split(/\s+/) ?? [];
|
||||
if (!rel.includes("icon") && !rel.includes("apple-touch-icon")) continue;
|
||||
const href = readTagAttribute(match[0], "href");
|
||||
const rel = readTagAttribute(match[0], 'rel')?.toLowerCase().split(/\s+/) ?? [];
|
||||
if (!rel.includes('icon') && !rel.includes('apple-touch-icon')) continue;
|
||||
const href = readTagAttribute(match[0], 'href');
|
||||
if (!href) continue;
|
||||
try {
|
||||
const faviconUrl = new URL(href, baseUrl);
|
||||
@@ -217,7 +217,7 @@ export async function resolveFavicon(siteUrl: string): Promise<FaviconResult | n
|
||||
if (!isSafeRemoteUrl(site)) return null;
|
||||
|
||||
const origin = new URL(site.origin);
|
||||
const defaultIcon = await fetchRemote(new URL("/favicon.ico", origin), MAX_FAVICON_BYTES);
|
||||
const defaultIcon = await fetchRemote(new URL('/favicon.ico', origin), MAX_FAVICON_BYTES);
|
||||
if (defaultIcon && defaultIcon.body.byteLength > 0) {
|
||||
const contentType = imageContentType(defaultIcon.headers);
|
||||
if (contentType) return { body: defaultIcon.body, contentType };
|
||||
@@ -225,8 +225,8 @@ export async function resolveFavicon(siteUrl: string): Promise<FaviconResult | n
|
||||
|
||||
const page = await fetchRemote(origin, MAX_HEAD_BYTES, true);
|
||||
if (!page || page.body.byteLength === 0) return null;
|
||||
const pageContentType = page.headers.get("Content-Type")?.toLowerCase();
|
||||
if (pageContentType && !pageContentType.includes("text/html") && !pageContentType.includes("application/xhtml+xml")) {
|
||||
const pageContentType = page.headers.get('Content-Type')?.toLowerCase();
|
||||
if (pageContentType && !pageContentType.includes('text/html') && !pageContentType.includes('application/xhtml+xml')) {
|
||||
return null;
|
||||
}
|
||||
const faviconUrl = findFaviconUrl(new TextDecoder().decode(page.body), page.url);
|
||||
@@ -239,7 +239,7 @@ export async function resolveFavicon(siteUrl: string): Promise<FaviconResult | n
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseInteger(
|
||||
@@ -258,40 +258,40 @@ function parseInteger(
|
||||
}
|
||||
|
||||
export function parseMonitorInput(body: unknown, partial = false): ParseResult {
|
||||
if (!isRecord(body)) return { ok: false, message: "Invalid request body" };
|
||||
if (!isRecord(body)) return { ok: false, message: 'Invalid request body' };
|
||||
|
||||
const value: ParsedMonitorInput = {};
|
||||
if (!partial || "name" in body) {
|
||||
if (typeof body.name !== "string" || body.name.trim().length < 1 || body.name.trim().length > 100) {
|
||||
return { ok: false, message: "Name must be between 1 and 100 characters" };
|
||||
if (!partial || 'name' in body) {
|
||||
if (typeof body.name !== 'string' || body.name.trim().length < 1 || body.name.trim().length > 100) {
|
||||
return { ok: false, message: 'Name must be between 1 and 100 characters' };
|
||||
}
|
||||
value.name = body.name.trim();
|
||||
}
|
||||
|
||||
if (!partial || "url" in body) {
|
||||
if (typeof body.url !== "string") {
|
||||
return { ok: false, message: "Enter a valid http or https URL" };
|
||||
if (!partial || 'url' in body) {
|
||||
if (typeof body.url !== 'string') {
|
||||
return { ok: false, message: 'Enter a valid http or https URL' };
|
||||
}
|
||||
try {
|
||||
const url = new URL(body.url);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Invalid protocol");
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('Invalid protocol');
|
||||
value.url = url.toString();
|
||||
} catch {
|
||||
return { ok: false, message: "Enter a valid http or https URL" };
|
||||
return { ok: false, message: 'Enter a valid http or https URL' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!partial || "method" in body) {
|
||||
if (typeof body.method !== "string" || !METHODS.has(body.method as MonitorMethod)) {
|
||||
return { ok: false, message: "Method must be GET, HEAD, or POST" };
|
||||
if (!partial || 'method' in body) {
|
||||
if (typeof body.method !== 'string' || !METHODS.has(body.method as MonitorMethod)) {
|
||||
return { ok: false, message: 'Method must be GET, HEAD, or POST' };
|
||||
}
|
||||
value.method = body.method as MonitorMethod;
|
||||
}
|
||||
|
||||
for (const [key, label, minimum, maximum] of [
|
||||
["expectedStatus", "expectedStatus", 100, 599],
|
||||
["intervalSeconds", "intervalSeconds", 300, 86_400],
|
||||
["timeoutMs", "timeoutMs", 1_000, 30_000],
|
||||
['expectedStatus', 'expectedStatus', 100, 599],
|
||||
['intervalSeconds', 'intervalSeconds', 300, 86_400],
|
||||
['timeoutMs', 'timeoutMs', 1_000, 30_000],
|
||||
] as const) {
|
||||
if (!partial || key in body) {
|
||||
const parsed = parseInteger(body[key], label, minimum, maximum);
|
||||
@@ -300,15 +300,15 @@ export function parseMonitorInput(body: unknown, partial = false): ParseResult {
|
||||
}
|
||||
}
|
||||
|
||||
if ("enabled" in body) {
|
||||
if (typeof body.enabled !== "boolean") {
|
||||
return { ok: false, message: "enabled must be a boolean" };
|
||||
if ('enabled' in body) {
|
||||
if (typeof body.enabled !== 'boolean') {
|
||||
return { ok: false, message: 'enabled must be a boolean' };
|
||||
}
|
||||
value.enabled = body.enabled;
|
||||
}
|
||||
if ("alertsEnabled" in body) {
|
||||
if (typeof body.alertsEnabled !== "boolean") {
|
||||
return { ok: false, message: "alertsEnabled must be a boolean" };
|
||||
if ('alertsEnabled' in body) {
|
||||
if (typeof body.alertsEnabled !== 'boolean') {
|
||||
return { ok: false, message: 'alertsEnabled must be a boolean' };
|
||||
}
|
||||
value.alertsEnabled = body.alertsEnabled;
|
||||
}
|
||||
@@ -335,10 +335,7 @@ type StatsWindow = {
|
||||
incidentCount: number;
|
||||
};
|
||||
|
||||
function asStatsWindow(
|
||||
row: { totalChecks: number; upChecks: number; avgLatencyMs: number | null },
|
||||
incidentCount: number,
|
||||
): StatsWindow {
|
||||
function asStatsWindow(row: { totalChecks: number; upChecks: number; avgLatencyMs: number | null }, incidentCount: number): StatsWindow {
|
||||
return {
|
||||
uptimePct: row.totalChecks > 0 ? Math.round((row.upChecks / row.totalChecks) * 100_000) / 1_000 : null,
|
||||
totalChecks: row.totalChecks,
|
||||
@@ -350,38 +347,33 @@ function asStatsWindow(
|
||||
|
||||
const monitorRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
|
||||
|
||||
monitorRoutes.use("*", requireAuth);
|
||||
monitorRoutes.use('*', requireAuth);
|
||||
|
||||
monitorRoutes.get("/", async (context) => {
|
||||
monitorRoutes.get('/', async (context) => {
|
||||
const rows = await getDb(context.env).select().from(monitors).orderBy(monitors.createdAt);
|
||||
return context.json({ monitors: rows });
|
||||
});
|
||||
|
||||
monitorRoutes.get("/:id", async (context) => {
|
||||
const id = parseId(context.req.param("id"));
|
||||
if (id === null) return context.json({ message: "Monitor not found" }, 404);
|
||||
monitorRoutes.get('/:id', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
|
||||
const [monitor] = await getDb(context.env).select().from(monitors).where(eq(monitors.id, id)).limit(1);
|
||||
if (!monitor) return context.json({ message: "Monitor not found" }, 404);
|
||||
if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
|
||||
return context.json({ monitor });
|
||||
});
|
||||
|
||||
monitorRoutes.get("/:id/checks", async (context) => {
|
||||
const id = parseId(context.req.param("id"));
|
||||
if (id === null) return context.json({ message: "Monitor not found" }, 404);
|
||||
const limit = parseLimit(context.req.query("limit"), 100, 500);
|
||||
const rows = await getDb(context.env)
|
||||
.select()
|
||||
.from(checks)
|
||||
.where(eq(checks.monitorId, id))
|
||||
.orderBy(desc(checks.checkedAt))
|
||||
.limit(limit);
|
||||
monitorRoutes.get('/:id/checks', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
|
||||
const limit = parseLimit(context.req.query('limit'), 100, 500);
|
||||
const rows = await getDb(context.env).select().from(checks).where(eq(checks.monitorId, id)).orderBy(desc(checks.checkedAt)).limit(limit);
|
||||
return context.json({ checks: rows });
|
||||
});
|
||||
|
||||
monitorRoutes.get("/:id/incidents", async (context) => {
|
||||
const id = parseId(context.req.param("id"));
|
||||
if (id === null) return context.json({ message: "Monitor not found" }, 404);
|
||||
const limit = parseLimit(context.req.query("limit"), 50, 200);
|
||||
monitorRoutes.get('/:id/incidents', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
|
||||
const limit = parseLimit(context.req.query('limit'), 50, 200);
|
||||
const rows = await getDb(context.env)
|
||||
.select()
|
||||
.from(incidents)
|
||||
@@ -391,34 +383,38 @@ monitorRoutes.get("/:id/incidents", async (context) => {
|
||||
return context.json({ incidents: rows });
|
||||
});
|
||||
|
||||
monitorRoutes.get("/:id/stats", async (context) => {
|
||||
const id = parseId(context.req.param("id"));
|
||||
if (id === null) return context.json({ message: "Monitor not found" }, 404);
|
||||
monitorRoutes.get('/:id/stats', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
|
||||
const db = getDb(context.env);
|
||||
const [monitor] = await db.select({ id: monitors.id }).from(monitors).where(eq(monitors.id, id)).limit(1);
|
||||
if (!monitor) return context.json({ message: "Monitor not found" }, 404);
|
||||
if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
|
||||
|
||||
const now = Date.now();
|
||||
const currentDayMs = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate());
|
||||
const windows = [
|
||||
{ key: "24h", start: now - 24 * 60 * 60 * 1000, raw: true },
|
||||
{ key: "7d", start: now - 7 * 24 * 60 * 60 * 1000, raw: true },
|
||||
{ key: "30d", start: currentDayMs - 29 * 24 * 60 * 60 * 1000, raw: false },
|
||||
{ key: "90d", start: currentDayMs - 89 * 24 * 60 * 60 * 1000, raw: false },
|
||||
{ key: '24h', start: now - 24 * 60 * 60 * 1000, raw: true },
|
||||
{ key: '7d', start: now - 7 * 24 * 60 * 60 * 1000, raw: true },
|
||||
{ key: '30d', start: currentDayMs - 29 * 24 * 60 * 60 * 1000, raw: false },
|
||||
{ key: '90d', start: currentDayMs - 89 * 24 * 60 * 60 * 1000, raw: false },
|
||||
] as const;
|
||||
|
||||
const results = await Promise.all(windows.map(async (window) => {
|
||||
const [aggregate] = window.raw
|
||||
? await db.select({
|
||||
totalChecks: sql<number>`count(*)`,
|
||||
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
|
||||
avgLatencyMs: sql<number | null>`round(avg(${checks.latencyMs}))`,
|
||||
}).from(checks).where(and(eq(checks.monitorId, id), gte(checks.checkedAt, new Date(window.start))))
|
||||
: await db.select({
|
||||
totalChecks: sql<number>`coalesce(sum(total_checks), 0)`,
|
||||
upChecks: sql<number>`coalesce(sum(up_checks), 0)`,
|
||||
avgLatencyMs: sql<number | null>`round(sum(avg_latency_ms * total_checks) / nullif(sum(total_checks), 0))`,
|
||||
}).from(sql`(
|
||||
const results = await Promise.all(
|
||||
windows.map(async (window) => {
|
||||
const [aggregate] = window.raw
|
||||
? await db
|
||||
.select({
|
||||
totalChecks: sql<number>`count(*)`,
|
||||
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
|
||||
avgLatencyMs: sql<number | null>`round(avg(${checks.latencyMs}))`,
|
||||
})
|
||||
.from(checks)
|
||||
.where(and(eq(checks.monitorId, id), gte(checks.checkedAt, new Date(window.start))))
|
||||
: await db.select({
|
||||
totalChecks: sql<number>`coalesce(sum(total_checks), 0)`,
|
||||
upChecks: sql<number>`coalesce(sum(up_checks), 0)`,
|
||||
avgLatencyMs: sql<number | null>`round(sum(avg_latency_ms * total_checks) / nullif(sum(total_checks), 0))`,
|
||||
}).from(sql`(
|
||||
select total_checks, up_checks, avg_latency_ms
|
||||
from monitor_daily_stats
|
||||
where monitor_id = ${id} and day >= ${window.start} and day < ${currentDayMs}
|
||||
@@ -427,28 +423,28 @@ monitorRoutes.get("/:id/stats", async (context) => {
|
||||
from checks
|
||||
where monitor_id = ${id} and checked_at >= ${currentDayMs}
|
||||
)`);
|
||||
const [incidentAggregate] = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(incidents)
|
||||
.where(and(
|
||||
eq(incidents.monitorId, id),
|
||||
gte(incidents.startedAt, new Date(window.start)),
|
||||
or(isNull(incidents.resolvedAt), gte(incidents.resolvedAt, new Date(window.start))),
|
||||
));
|
||||
return [window.key, asStatsWindow(aggregate, incidentAggregate.count)] as const;
|
||||
}));
|
||||
const [incidentAggregate] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(incidents)
|
||||
.where(
|
||||
and(
|
||||
eq(incidents.monitorId, id),
|
||||
gte(incidents.startedAt, new Date(window.start)),
|
||||
or(isNull(incidents.resolvedAt), gte(incidents.resolvedAt, new Date(window.start))),
|
||||
),
|
||||
);
|
||||
return [window.key, asStatsWindow(aggregate, incidentAggregate.count)] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
return context.json({ windows: Object.fromEntries(results) as Record<(typeof windows)[number]["key"], StatsWindow> });
|
||||
return context.json({ windows: Object.fromEntries(results) as Record<(typeof windows)[number]['key'], StatsWindow> });
|
||||
});
|
||||
|
||||
monitorRoutes.get("/:id/favicon", async (context) => {
|
||||
const id = parseId(context.req.param("id"));
|
||||
if (id === null) return context.json({ message: "Monitor not found" }, 404);
|
||||
const [monitor] = await getDb(context.env)
|
||||
.select({ url: monitors.url })
|
||||
.from(monitors)
|
||||
.where(eq(monitors.id, id))
|
||||
.limit(1);
|
||||
if (!monitor) return context.json({ message: "Monitor not found" }, 404);
|
||||
monitorRoutes.get('/:id/favicon', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
|
||||
const [monitor] = await getDb(context.env).select({ url: monitors.url }).from(monitors).where(eq(monitors.id, id)).limit(1);
|
||||
if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
|
||||
|
||||
const cacheKey = new Request(`${new URL(context.req.url).origin}/api/monitors/${id}/favicon`);
|
||||
let cache: EdgeCache | null = null;
|
||||
@@ -462,14 +458,14 @@ monitorRoutes.get("/:id/favicon", async (context) => {
|
||||
}
|
||||
|
||||
const favicon = await resolveFavicon(monitor.url);
|
||||
if (!favicon) return context.json({ message: "No favicon" }, 404);
|
||||
if (!favicon) return context.json({ message: 'No favicon' }, 404);
|
||||
|
||||
const response = new Response(favicon.body, {
|
||||
headers: {
|
||||
"Cache-Control": `public, max-age=${FAVICON_CACHE_SECONDS}`,
|
||||
"Content-Length": String(favicon.body.byteLength),
|
||||
"Content-Type": favicon.contentType,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
'Cache-Control': `public, max-age=${FAVICON_CACHE_SECONDS}`,
|
||||
'Content-Length': String(favicon.body.byteLength),
|
||||
'Content-Type': favicon.contentType,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
if (cache) {
|
||||
@@ -478,12 +474,12 @@ monitorRoutes.get("/:id/favicon", async (context) => {
|
||||
return response;
|
||||
});
|
||||
|
||||
monitorRoutes.post("/", async (context) => {
|
||||
monitorRoutes.post('/', async (context) => {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await context.req.json();
|
||||
} catch {
|
||||
return context.json({ message: "Invalid request body" }, 400);
|
||||
return context.json({ message: 'Invalid request body' }, 400);
|
||||
}
|
||||
|
||||
const parsed = parseMonitorInput(body);
|
||||
@@ -509,21 +505,21 @@ monitorRoutes.post("/", async (context) => {
|
||||
return context.json({ monitor });
|
||||
});
|
||||
|
||||
monitorRoutes.patch("/:id", async (context) => {
|
||||
const id = parseId(context.req.param("id"));
|
||||
if (id === null) return context.json({ message: "Monitor not found" }, 404);
|
||||
monitorRoutes.patch('/:id', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await context.req.json();
|
||||
} catch {
|
||||
return context.json({ message: "Invalid request body" }, 400);
|
||||
return context.json({ message: 'Invalid request body' }, 400);
|
||||
}
|
||||
|
||||
const parsed = parseMonitorInput(body, true);
|
||||
if (!parsed.ok) return context.json({ message: parsed.message }, 400);
|
||||
if (Object.keys(parsed.value).length === 0) {
|
||||
return context.json({ message: "Provide at least one field to update" }, 400);
|
||||
return context.json({ message: 'Provide at least one field to update' }, 400);
|
||||
}
|
||||
|
||||
const [monitor] = await getDb(context.env)
|
||||
@@ -531,38 +527,35 @@ monitorRoutes.patch("/:id", async (context) => {
|
||||
.set({ ...parsed.value, updatedAt: new Date() })
|
||||
.where(eq(monitors.id, id))
|
||||
.returning();
|
||||
if (!monitor) return context.json({ message: "Monitor not found" }, 404);
|
||||
if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
|
||||
|
||||
return context.json({ monitor });
|
||||
});
|
||||
|
||||
monitorRoutes.delete("/:id", async (context) => {
|
||||
const id = parseId(context.req.param("id"));
|
||||
if (id === null) return context.json({ message: "Monitor not found" }, 404);
|
||||
monitorRoutes.delete('/:id', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
|
||||
|
||||
const db = getDb(context.env);
|
||||
const [monitor] = await db.select({ id: monitors.id }).from(monitors).where(eq(monitors.id, id)).limit(1);
|
||||
if (!monitor) return context.json({ message: "Monitor not found" }, 404);
|
||||
if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
|
||||
|
||||
await db.batch([
|
||||
db.delete(checks).where(eq(checks.monitorId, id)),
|
||||
db.delete(monitors).where(eq(monitors.id, id)),
|
||||
]);
|
||||
await db.batch([db.delete(checks).where(eq(checks.monitorId, id)), db.delete(monitors).where(eq(monitors.id, id))]);
|
||||
return context.json({ ok: true });
|
||||
});
|
||||
|
||||
monitorRoutes.post("/:id/check", async (context) => {
|
||||
const id = parseId(context.req.param("id"));
|
||||
if (id === null) return context.json({ message: "Monitor not found" }, 404);
|
||||
monitorRoutes.post('/:id/check', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
|
||||
|
||||
const db = getDb(context.env);
|
||||
const [monitor] = await db.select().from(monitors).where(eq(monitors.id, id)).limit(1);
|
||||
if (!monitor) return context.json({ message: "Monitor not found" }, 404);
|
||||
if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
|
||||
|
||||
const result = await runCheck(monitor);
|
||||
const checkedAt = new Date();
|
||||
const { statements, transition } = buildResultStatements(db, monitor, result, checkedAt);
|
||||
await db.batch(statements as [typeof statements[number], ...typeof statements]);
|
||||
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
|
||||
if (transition) {
|
||||
await sendIncidentAlert(context.env, { monitor, kind: transition, result, at: checkedAt });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { getDb } from "../db/client";
|
||||
import { notificationSettings } from "../db/schema";
|
||||
import { requireAuth, type AuthVariables } from "../lib/require-auth";
|
||||
import { sendTestWebhook } from "../notifications/webhook";
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
import { getDb } from '../db/client';
|
||||
import { notificationSettings } from '../db/schema';
|
||||
import { requireAuth, type AuthVariables } from '../lib/require-auth';
|
||||
import { sendTestWebhook } from '../notifications/webhook';
|
||||
|
||||
type NotificationInput = {
|
||||
webhookUrl: string | null;
|
||||
@@ -11,52 +11,48 @@ type NotificationInput = {
|
||||
};
|
||||
|
||||
function parseNotificationInput(value: unknown): NotificationInput | string {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return "Invalid request body";
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return 'Invalid request body';
|
||||
}
|
||||
const body = value as Record<string, unknown>;
|
||||
if (typeof body.webhookEnabled !== "boolean") {
|
||||
return "webhookEnabled must be a boolean";
|
||||
if (typeof body.webhookEnabled !== 'boolean') {
|
||||
return 'webhookEnabled must be a boolean';
|
||||
}
|
||||
const rawUrl = typeof body.webhookUrl === "string" ? body.webhookUrl.trim() : body.webhookUrl;
|
||||
if (rawUrl !== null && typeof rawUrl !== "string") return "webhookUrl must be a URL or null";
|
||||
const rawUrl = typeof body.webhookUrl === 'string' ? body.webhookUrl.trim() : body.webhookUrl;
|
||||
if (rawUrl !== null && typeof rawUrl !== 'string') return 'webhookUrl must be a URL or null';
|
||||
let webhookUrl = rawUrl || null;
|
||||
if (webhookUrl) {
|
||||
try {
|
||||
const url = new URL(webhookUrl);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("protocol");
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('protocol');
|
||||
webhookUrl = url.toString();
|
||||
} catch {
|
||||
return "Enter a valid http or https webhook URL";
|
||||
return 'Enter a valid http or https webhook URL';
|
||||
}
|
||||
}
|
||||
if (body.webhookEnabled && !webhookUrl) return "A webhook URL is required when alerts are enabled";
|
||||
if (body.webhookEnabled && !webhookUrl) return 'A webhook URL is required when alerts are enabled';
|
||||
return { webhookUrl, webhookEnabled: body.webhookEnabled };
|
||||
}
|
||||
|
||||
const settingsRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
|
||||
settingsRoutes.use("*", requireAuth);
|
||||
settingsRoutes.use('*', requireAuth);
|
||||
|
||||
settingsRoutes.get("/notifications", async (context) => {
|
||||
const [settings] = await getDb(context.env)
|
||||
.select()
|
||||
.from(notificationSettings)
|
||||
.where(eq(notificationSettings.id, 1))
|
||||
.limit(1);
|
||||
settingsRoutes.get('/notifications', async (context) => {
|
||||
const [settings] = await getDb(context.env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1);
|
||||
return context.json({
|
||||
settings: settings ?? { id: 1, webhookUrl: null, webhookEnabled: false, createdAt: null, updatedAt: null },
|
||||
});
|
||||
});
|
||||
|
||||
settingsRoutes.put("/notifications", async (context) => {
|
||||
settingsRoutes.put('/notifications', async (context) => {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await context.req.json();
|
||||
} catch {
|
||||
return context.json({ message: "Invalid request body" }, 400);
|
||||
return context.json({ message: 'Invalid request body' }, 400);
|
||||
}
|
||||
const input = parseNotificationInput(body);
|
||||
if (typeof input === "string") return context.json({ message: input }, 400);
|
||||
if (typeof input === 'string') return context.json({ message: input }, 400);
|
||||
|
||||
const db = getDb(context.env);
|
||||
const now = new Date();
|
||||
@@ -71,15 +67,11 @@ settingsRoutes.put("/notifications", async (context) => {
|
||||
return context.json({ settings });
|
||||
});
|
||||
|
||||
settingsRoutes.post("/notifications/test", async (context) => {
|
||||
const [settings] = await getDb(context.env)
|
||||
.select()
|
||||
.from(notificationSettings)
|
||||
.where(eq(notificationSettings.id, 1))
|
||||
.limit(1);
|
||||
if (!settings?.webhookUrl) return context.json({ message: "Save a webhook URL first" }, 400);
|
||||
settingsRoutes.post('/notifications/test', async (context) => {
|
||||
const [settings] = await getDb(context.env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1);
|
||||
if (!settings?.webhookUrl) return context.json({ message: 'Save a webhook URL first' }, 400);
|
||||
const delivered = await sendTestWebhook(settings.webhookUrl);
|
||||
if (!delivered) return context.json({ message: "Webhook delivery failed" }, 502);
|
||||
if (!delivered) return context.json({ message: 'Webhook delivery failed' }, 502);
|
||||
return context.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
+47
-46
@@ -1,14 +1,14 @@
|
||||
import { and, eq, gte, inArray, lt, sql } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { getDb } from "../db/client";
|
||||
import { checks, monitorDailyStats, monitors } from "../db/schema";
|
||||
import { resolveFavicon } from "./monitors";
|
||||
import { and, eq, gte, inArray, lt, sql } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
import { getDb } from '../db/client';
|
||||
import { checks, monitorDailyStats, monitors } from '../db/schema';
|
||||
import { resolveFavicon } from './monitors';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const FAVICON_CACHE_SECONDS = 86_400;
|
||||
|
||||
type ServiceStatus = "up" | "down" | "unknown";
|
||||
type OverallStatus = "operational" | "degraded" | "down";
|
||||
type ServiceStatus = 'up' | 'down' | 'unknown';
|
||||
type OverallStatus = 'operational' | 'degraded' | 'down';
|
||||
|
||||
type HistoryEntry = {
|
||||
day: number;
|
||||
@@ -37,27 +37,27 @@ function roundUptime(upChecks: number, totalChecks: number) {
|
||||
}
|
||||
|
||||
function serviceStatus(lastOk: boolean | null): ServiceStatus {
|
||||
if (lastOk === true) return "up";
|
||||
if (lastOk === false) return "down";
|
||||
return "unknown";
|
||||
if (lastOk === true) return 'up';
|
||||
if (lastOk === false) return 'down';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function overallStatus(statuses: ServiceStatus[]): OverallStatus {
|
||||
let checked = 0;
|
||||
let down = 0;
|
||||
for (const status of statuses) {
|
||||
if (status === "unknown") continue;
|
||||
if (status === 'unknown') continue;
|
||||
checked += 1;
|
||||
if (status === "down") down += 1;
|
||||
if (status === 'down') down += 1;
|
||||
}
|
||||
if (down === 0) return "operational";
|
||||
if (down === checked) return "down";
|
||||
return "degraded";
|
||||
if (down === 0) return 'operational';
|
||||
if (down === checked) return 'down';
|
||||
return 'degraded';
|
||||
}
|
||||
|
||||
const statusRoutes = new Hono<{ Bindings: Env }>();
|
||||
|
||||
statusRoutes.get("/", async (context) => {
|
||||
statusRoutes.get('/', async (context) => {
|
||||
const db = getDb(context.env);
|
||||
const monitorRows = await db
|
||||
.select({
|
||||
@@ -79,30 +79,31 @@ statusRoutes.get("/", async (context) => {
|
||||
|
||||
if (monitorIds.length > 0) {
|
||||
[historicalRows, todayRows] = await Promise.all([
|
||||
db.select({
|
||||
monitorId: monitorDailyStats.monitorId,
|
||||
day: monitorDailyStats.day,
|
||||
totalChecks: monitorDailyStats.totalChecks,
|
||||
upChecks: monitorDailyStats.upChecks,
|
||||
})
|
||||
db
|
||||
.select({
|
||||
monitorId: monitorDailyStats.monitorId,
|
||||
day: monitorDailyStats.day,
|
||||
totalChecks: monitorDailyStats.totalChecks,
|
||||
upChecks: monitorDailyStats.upChecks,
|
||||
})
|
||||
.from(monitorDailyStats)
|
||||
.where(and(
|
||||
inArray(monitorDailyStats.monitorId, monitorIds),
|
||||
gte(monitorDailyStats.day, new Date(cutoff)),
|
||||
lt(monitorDailyStats.day, new Date(today)),
|
||||
))
|
||||
.where(
|
||||
and(
|
||||
inArray(monitorDailyStats.monitorId, monitorIds),
|
||||
gte(monitorDailyStats.day, new Date(cutoff)),
|
||||
lt(monitorDailyStats.day, new Date(today)),
|
||||
),
|
||||
)
|
||||
.orderBy(monitorDailyStats.day),
|
||||
db.select({
|
||||
monitorId: checks.monitorId,
|
||||
day: sql<Date>`cast(${today} as integer)`,
|
||||
totalChecks: sql<number>`count(*)`,
|
||||
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
|
||||
})
|
||||
db
|
||||
.select({
|
||||
monitorId: checks.monitorId,
|
||||
day: sql<Date>`cast(${today} as integer)`,
|
||||
totalChecks: sql<number>`count(*)`,
|
||||
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
|
||||
})
|
||||
.from(checks)
|
||||
.where(and(
|
||||
inArray(checks.monitorId, monitorIds),
|
||||
gte(checks.checkedAt, new Date(today)),
|
||||
))
|
||||
.where(and(inArray(checks.monitorId, monitorIds), gte(checks.checkedAt, new Date(today))))
|
||||
.groupBy(checks.monitorId),
|
||||
]);
|
||||
}
|
||||
@@ -144,15 +145,15 @@ statusRoutes.get("/", async (context) => {
|
||||
});
|
||||
});
|
||||
|
||||
statusRoutes.get("/:id/favicon", async (context) => {
|
||||
const id = parseId(context.req.param("id"));
|
||||
if (id === null) return context.json({ message: "Service not found" }, 404);
|
||||
statusRoutes.get('/:id/favicon', async (context) => {
|
||||
const id = parseId(context.req.param('id'));
|
||||
if (id === null) return context.json({ message: 'Service not found' }, 404);
|
||||
const [monitor] = await getDb(context.env)
|
||||
.select({ url: monitors.url })
|
||||
.from(monitors)
|
||||
.where(and(eq(monitors.id, id), eq(monitors.enabled, true)))
|
||||
.limit(1);
|
||||
if (!monitor) return context.json({ message: "Service not found" }, 404);
|
||||
if (!monitor) return context.json({ message: 'Service not found' }, 404);
|
||||
|
||||
const cacheKey = new Request(`${new URL(context.req.url).origin}/api/status/${id}/favicon`);
|
||||
let cache: EdgeCache | null = null;
|
||||
@@ -166,14 +167,14 @@ statusRoutes.get("/:id/favicon", async (context) => {
|
||||
}
|
||||
|
||||
const favicon = await resolveFavicon(monitor.url);
|
||||
if (!favicon) return context.json({ message: "No favicon" }, 404);
|
||||
if (!favicon) return context.json({ message: 'No favicon' }, 404);
|
||||
|
||||
const response = new Response(favicon.body, {
|
||||
headers: {
|
||||
"Cache-Control": `public, max-age=${FAVICON_CACHE_SECONDS}`,
|
||||
"Content-Length": String(favicon.body.byteLength),
|
||||
"Content-Type": favicon.contentType,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
'Cache-Control': `public, max-age=${FAVICON_CACHE_SECONDS}`,
|
||||
'Content-Length': String(favicon.body.byteLength),
|
||||
'Content-Type': favicon.contentType,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
if (cache) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { lt } from "drizzle-orm";
|
||||
import { getDb } from "../db/client";
|
||||
import { checks, loginAttempts, monitorDailyStats, sessions } from "../db/schema";
|
||||
import { lt } from 'drizzle-orm';
|
||||
import { getDb } from '../db/client';
|
||||
import { checks, loginAttempts, monitorDailyStats, sessions } from '../db/schema';
|
||||
|
||||
const LOGIN_ATTEMPT_RETENTION_MS = 60 * 60 * 1000;
|
||||
const CHECK_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -12,29 +12,8 @@ export async function cleanupExpiredAuthRecords(env: Env) {
|
||||
|
||||
await db.batch([
|
||||
db.delete(sessions).where(lt(sessions.expiresAt, now)),
|
||||
db
|
||||
.delete(loginAttempts)
|
||||
.where(
|
||||
lt(
|
||||
loginAttempts.attemptedAt,
|
||||
new Date(now.getTime() - LOGIN_ATTEMPT_RETENTION_MS),
|
||||
),
|
||||
),
|
||||
db
|
||||
.delete(checks)
|
||||
.where(
|
||||
lt(
|
||||
checks.checkedAt,
|
||||
new Date(now.getTime() - CHECK_RETENTION_MS),
|
||||
),
|
||||
),
|
||||
db
|
||||
.delete(monitorDailyStats)
|
||||
.where(
|
||||
lt(
|
||||
monitorDailyStats.day,
|
||||
new Date(now.getTime() - DAILY_STATS_RETENTION_MS),
|
||||
),
|
||||
),
|
||||
db.delete(loginAttempts).where(lt(loginAttempts.attemptedAt, new Date(now.getTime() - LOGIN_ATTEMPT_RETENTION_MS))),
|
||||
db.delete(checks).where(lt(checks.checkedAt, new Date(now.getTime() - CHECK_RETENTION_MS))),
|
||||
db.delete(monitorDailyStats).where(lt(monitorDailyStats.day, new Date(now.getTime() - DAILY_STATS_RETENTION_MS))),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { and, gte, lt, sql } from "drizzle-orm";
|
||||
import { getDb } from "../db/client";
|
||||
import { checks, monitorDailyStats } from "../db/schema";
|
||||
import { and, gte, lt, sql } from 'drizzle-orm';
|
||||
import { getDb } from '../db/client';
|
||||
import { checks, monitorDailyStats } from '../db/schema';
|
||||
|
||||
export type DailyRollupSummary = {
|
||||
day: string;
|
||||
monitors: number;
|
||||
};
|
||||
|
||||
export async function runDailyRollup(
|
||||
env: Env,
|
||||
now = new Date(),
|
||||
): Promise<DailyRollupSummary> {
|
||||
export async function runDailyRollup(env: Env, now = new Date()): Promise<DailyRollupSummary> {
|
||||
const db = getDb(env);
|
||||
const currentUtcDay = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
const dayStart = new Date(currentUtcDay - 24 * 60 * 60 * 1000);
|
||||
@@ -30,28 +27,30 @@ export async function runDailyRollup(
|
||||
.groupBy(checks.monitorId);
|
||||
|
||||
if (rows.length > 0) {
|
||||
const statements = rows.map((row) => db
|
||||
.insert(monitorDailyStats)
|
||||
.values({
|
||||
monitorId: row.monitorId,
|
||||
day: dayStart,
|
||||
totalChecks: row.totalChecks,
|
||||
upChecks: row.upChecks,
|
||||
avgLatencyMs: row.avgLatencyMs,
|
||||
minLatencyMs: row.minLatencyMs,
|
||||
maxLatencyMs: row.maxLatencyMs,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [monitorDailyStats.monitorId, monitorDailyStats.day],
|
||||
set: {
|
||||
const statements = rows.map((row) =>
|
||||
db
|
||||
.insert(monitorDailyStats)
|
||||
.values({
|
||||
monitorId: row.monitorId,
|
||||
day: dayStart,
|
||||
totalChecks: row.totalChecks,
|
||||
upChecks: row.upChecks,
|
||||
avgLatencyMs: row.avgLatencyMs,
|
||||
minLatencyMs: row.minLatencyMs,
|
||||
maxLatencyMs: row.maxLatencyMs,
|
||||
},
|
||||
}));
|
||||
await db.batch(statements as [typeof statements[number], ...typeof statements]);
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [monitorDailyStats.monitorId, monitorDailyStats.day],
|
||||
set: {
|
||||
totalChecks: row.totalChecks,
|
||||
upChecks: row.upChecks,
|
||||
avgLatencyMs: row.avgLatencyMs,
|
||||
minLatencyMs: row.minLatencyMs,
|
||||
maxLatencyMs: row.maxLatencyMs,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
|
||||
}
|
||||
|
||||
return { day: dayStart.toISOString().slice(0, 10), monitors: rows.length };
|
||||
|
||||
Reference in New Issue
Block a user