Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions seerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2404,6 +2404,18 @@ paths:
description: Invalid payload.
'404':
description: Monitor not found.
/uptimerobot/suppression:
delete:
summary: Clear an active report-suppression window
description: |
Admin-only. Lifts a previously set "suppress problem reports for
N minutes" window so users can file reports again. Idempotent —
returns 204 even when no suppression was active.
tags:
- uptimerobot
responses:
'204':
description: Suppression cleared (or none was active).
/uptimerobot/reports:
get:
summary: List active per-monitor problem report counts
Expand Down
7 changes: 7 additions & 0 deletions server/lib/permissions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
export enum Permission {
NONE = 0,
// 1 (2^0) is used by STATUS_VIEW below — it was the only unused bit
// beneath the upstream MANAGE_BLOCKLIST/VIEW_BLOCKLIST values that
// still fits comfortably in Postgres `int4` for `user.permissions`.
STATUS_VIEW = 1,
ADMIN = 2,
MANAGE_SETTINGS = 4,
MANAGE_USERS = 8,
Expand Down Expand Up @@ -28,6 +32,9 @@ export enum Permission {
RECENT_VIEW = 67108864,
WATCHLIST_VIEW = 134217728,
MANAGE_BLOCKLIST = 268435456,
// 2^29 (536870912) was unused in upstream — reserve it for the
// status-page report permission so we stay inside int4.
STATUS_REPORT = 536870912,
VIEW_BLOCKLIST = 1073741824,
}

Expand Down
5 changes: 4 additions & 1 deletion server/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,10 @@ class Settings {
applicationTitle: 'Seerr',
applicationUrl: '',
cacheImages: false,
defaultPermissions: Permission.REQUEST,
defaultPermissions:
Permission.REQUEST |
Permission.STATUS_VIEW |
Permission.STATUS_REPORT,
defaultQuotas: {
movie: {},
tv: {},
Expand Down
85 changes: 85 additions & 0 deletions server/lib/uptimerobot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import UptimeRobotAPI, {
type UptimeRobotMonitor,
} from '@server/api/uptimerobot';
import { getRepository } from '@server/datasource';
import { ProblemReport } from '@server/entity/ProblemReport';
import { User } from '@server/entity/User';
import { UserMonitorRecoverySubscription } from '@server/entity/UserMonitorRecoverySubscription';
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
Expand All @@ -12,6 +13,7 @@ import {
type UptimeRobotMonitorOverride,
} from '@server/lib/settings';
import logger from '@server/logger';
import { IsNull } from 'typeorm';
import webpush from 'web-push';

export interface MonitorSummary {
Expand Down Expand Up @@ -287,6 +289,8 @@ class UptimeRobotService {
seen.add(monitor.id);
const state = this.recoveryStates.get(monitor.id) ?? {};

const wasDown = !!state.lastDownAt && !state.upSince;

if (monitor.status === 'down') {
state.lastDownAt = now;
state.upSince = undefined;
Expand All @@ -300,6 +304,21 @@ class UptimeRobotService {

this.recoveryStates.set(monitor.id, state);

// First poll where the monitor flipped from down to up: clear any
// active user-reports right away so people stop seeing stale "X
// others reporting" badges. Independent of the recovery-stable
// window — if the service is back up we always want to wipe the
// reports.
if (wasDown && monitor.status === 'up') {
await this.clearActiveReportsForMonitor(monitor.id).catch((e) => {
logger.warn('Failed to clear active reports on recovery', {
label: 'UptimeRobot',
monitorId: monitor.id,
errorMessage: (e as Error).message,
});
});
}

if (!settings.recoveryNotificationsEnabled) continue;
if (monitor.status !== 'up') continue;
if (!state.lastDownAt || !state.upSince) continue;
Expand Down Expand Up @@ -430,6 +449,72 @@ class UptimeRobotService {
// Consume the recovery subscriptions — they are one-shot.
await subRepo.remove(subscriptions);
}

/**
* Mark every active problem report for the given monitor as resolved.
* Returns the number of rows that were just resolved (0 when none).
*/
public async clearActiveReportsForMonitor(
monitorId: number
): Promise<number> {
const repo = getRepository(ProblemReport);
const rows = await repo.find({
where: { monitorId, resolvedAt: IsNull() },
});
if (!rows.length) return 0;
const resolvedAt = new Date();
await repo.save(rows.map((r) => Object.assign(r, { resolvedAt })));
return rows.length;
}

/**
* Prime the recovery state for a monitor at subscription time so that
* users who tap "Notify me" still get alerted even if the service
* never observed the monitor as down (e.g. process restarted between
* the down event and the user subscribing). No-op when the monitor is
* currently observed up — there's nothing to recover from.
*/
public primeRecovery(monitorId: number): void {
const monitor = this.latest.find((m) => m.id === monitorId);
if (!monitor) return;
if (monitor.status === 'up') return;
const state = this.recoveryStates.get(monitorId) ?? {};
if (!state.lastDownAt) {
state.lastDownAt = Date.now();
state.upSince = undefined;
this.recoveryStates.set(monitorId, state);
}
}

/**
* Dispatch the recovery web push for a single monitor right now, without
* waiting for the natural recovery-stable window. Called from the
* `/uptimerobot/override` route when an admin pins the monitor back to
* `operational`. Report-clearing is the route's responsibility (it
* awaits `clearActiveReportsForMonitor` directly) — this method only
* handles the push side.
*/
public async fireManualRecovery(monitorId: number): Promise<void> {
const monitor = this.latest.find((m) => m.id === monitorId);
if (!monitor) return;

// Reset recovery state — the override is the source of truth now.
this.recoveryStates.set(monitorId, {});

try {
await this.dispatchRecoveryNotifications({
...monitor,
// Force "up" semantics so the dispatch payload reads naturally.
status: 'up',
});
} catch (e) {
logger.error('Failed to dispatch manual recovery notifications', {
label: 'UptimeRobot',
monitorId,
errorMessage: (e as Error).message,
});
}
}
}

const uptimeRobotService = new UptimeRobotService();
Expand Down
24 changes: 24 additions & 0 deletions server/migration/postgres/1777500000000-GrantStatusPermissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

/**
* Grants the two new status-page permissions (STATUS_VIEW = 1,
* STATUS_REPORT = 536870912) to every existing user so accounts created
* before this fork's status feature don't suddenly lose access. New
* users created from now on inherit these bits via `defaultPermissions`.
*/
export class GrantStatusPermissions1777500000000 implements MigrationInterface {
name = 'GrantStatusPermissions1777500000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`UPDATE "user" SET "permissions" = "permissions" | 1 | 536870912`
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Strip both bits on rollback. In Postgres bitwise NOT is `~`.
await queryRunner.query(
`UPDATE "user" SET "permissions" = "permissions" & ~1 & ~536870912`
);
}
}
24 changes: 24 additions & 0 deletions server/migration/sqlite/1777500000000-GrantStatusPermissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

/**
* Grants the two new status-page permissions (STATUS_VIEW = 1,
* STATUS_REPORT = 536870912) to every existing user so accounts created
* before this fork's status feature don't suddenly lose access. New
* users created from now on inherit these bits via `defaultPermissions`.
*/
export class GrantStatusPermissions1777500000000 implements MigrationInterface {
name = 'GrantStatusPermissions1777500000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`UPDATE "user" SET "permissions" = "permissions" | 1 | 536870912`
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Strip both bits on rollback. Bitwise NOT in SQLite is `~`.
await queryRunner.query(
`UPDATE "user" SET "permissions" = "permissions" & ~1 & ~536870912`
);
}
}
6 changes: 5 additions & 1 deletion server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,11 @@ router.use('/collection', isAuthenticated(), collectionRoutes);
router.use('/service', isAuthenticated(), serviceRoutes);
router.use('/issue', isAuthenticated(), issueRoutes);
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
router.use('/uptimerobot', isAuthenticated(), uptimeRobotRoutes);
router.use(
'/uptimerobot',
isAuthenticated(Permission.STATUS_VIEW),
uptimeRobotRoutes
);
router.use('/auth', authRoutes);
router.use(
'/overrideRule',
Expand Down
106 changes: 105 additions & 1 deletion server/routes/uptimerobot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Announcement } from '@server/entity/Announcement';
import { ProblemReport } from '@server/entity/ProblemReport';
import { User } from '@server/entity/User';
import { UserMonitorRecoverySubscription } from '@server/entity/UserMonitorRecoverySubscription';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import uptimeRobotService from '@server/lib/uptimerobot';
import { checkUser, isAuthenticated } from '@server/middleware/auth';
Expand All @@ -14,6 +15,7 @@ import type { Express } from 'express';
import express from 'express';
import session from 'express-session';
import request from 'supertest';
import { IsNull } from 'typeorm';
import authRoutes from './auth';
import uptimeRobotRoutes from './uptimerobot';

Expand All @@ -31,7 +33,11 @@ function createApp() {
);
app.use(checkUser);
app.use('/auth', authRoutes);
app.use('/uptimerobot', isAuthenticated(), uptimeRobotRoutes);
app.use(
'/uptimerobot',
isAuthenticated(Permission.STATUS_VIEW),
uptimeRobotRoutes
);
app.use(
(
err: { status?: number; message?: string },
Expand Down Expand Up @@ -409,6 +415,104 @@ describe('POST /uptimerobot/override', () => {
assert.strictEqual(ov?.hideFromReports, true);
assert.strictEqual(ov?.manualStatus, undefined);
});

it('clears active reports when status is set to operational', async () => {
// Seed monitor 1002 (which the mock reports as down) with an active
// problem report, then have an admin pin it operational.
const friend = await getRepository(User).findOneOrFail({
where: { email: 'friend@seerr.dev' },
});
await getRepository(ProblemReport).save(
new ProblemReport({
reporter: friend,
monitorId: 1002,
monitorNameSnapshot: 'Web',
monitorStatusAtReport: 'down',
})
);

const admin = await loginAs('admin@seerr.dev', 'test1234');
const res = await admin
.post('/uptimerobot/override')
.send({ monitorId: 1002, status: 'operational', minutes: 30 });
assert.strictEqual(res.status, 200);

// The route awaits `clearActiveReportsForMonitor` before responding,
// so by the time we get a 200 the reports are already resolved.
const stillActive = await getRepository(ProblemReport).find({
where: { resolvedAt: IsNull() },
});
assert.strictEqual(stillActive.length, 0);
});
});

describe('DELETE /uptimerobot/suppression', () => {
it('rejects non-admin users with 403', async () => {
const friend = await loginAs('friend@seerr.dev', 'test1234');
const res = await friend.delete('/uptimerobot/suppression');
assert.strictEqual(res.status, 403);
});

it('clears an active suppression window', async () => {
const settings = getSettings();
settings.uptimerobot.reportsSuppressedUntil = Date.now() + 60 * 60_000;

const admin = await loginAs('admin@seerr.dev', 'test1234');
const res = await admin.delete('/uptimerobot/suppression');
assert.strictEqual(res.status, 204);
assert.strictEqual(
getSettings().uptimerobot.reportsSuppressedUntil,
undefined
);
});

it('is idempotent when no suppression is active', async () => {
const settings = getSettings();
settings.uptimerobot.reportsSuppressedUntil = undefined;
const admin = await loginAs('admin@seerr.dev', 'test1234');
const res = await admin.delete('/uptimerobot/suppression');
assert.strictEqual(res.status, 204);
});
});

describe('Status permissions', () => {
it('rejects users without STATUS_VIEW with 403 on GET /uptimerobot', async () => {
const limited = await loginAs('limited@seerr.dev', 'test1234');
const res = await limited.get('/uptimerobot');
assert.strictEqual(res.status, 403);
});

it('rejects users without STATUS_REPORT with 403 on POST /uptimerobot/reports', async () => {
// Grant just STATUS_VIEW so the user passes the parent gate.
const repo = getRepository(User);
const limited = await repo.findOneOrFail({
where: { email: 'limited@seerr.dev' },
});
limited.permissions = 1; // STATUS_VIEW only
await repo.save(limited);

const agent = await loginAs('limited@seerr.dev', 'test1234');
const res = await agent
.post('/uptimerobot/reports')
.send({ monitorIds: [1002] });
assert.strictEqual(res.status, 403);
});

it('lets users with STATUS_VIEW + STATUS_REPORT submit reports', async () => {
const repo = getRepository(User);
const limited = await repo.findOneOrFail({
where: { email: 'limited@seerr.dev' },
});
limited.permissions = 1 | 536870912; // VIEW + REPORT
await repo.save(limited);

const agent = await loginAs('limited@seerr.dev', 'test1234');
const res = await agent
.post('/uptimerobot/reports')
.send({ monitorIds: [1002] });
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.created, 1);
});
});

describe('Problem Reports', () => {
Expand Down
Loading
Loading