diff --git a/api/.env.development b/api/.env.development index 2f2db9c7a..e15c8a34f 100644 --- a/api/.env.development +++ b/api/.env.development @@ -13,6 +13,6 @@ PLAYGROUND=true INTROSPECTION=true MOTHERSHIP_GRAPHQL_LINK="http://authenticator:3000/graphql" NODE_TLS_REJECT_UNAUTHORIZED=0 -BYPASS_PERMISSION_CHECKS=false -BYPASS_CORS_CHECKS=false +BYPASS_PERMISSION_CHECKS=true +BYPASS_CORS_CHECKS=true CHOKIDAR_USEPOLLING=true diff --git a/api/src/core/types/states/notification.ts b/api/src/core/types/states/notification.ts new file mode 100644 index 000000000..f3fee7cf0 --- /dev/null +++ b/api/src/core/types/states/notification.ts @@ -0,0 +1,8 @@ +export interface NotificationIni { + timestamp: string; + event: string; + subject: string; + description: string; + importance: 'normal' | 'alert' | 'warning'; + link?: string; +} \ No newline at end of file diff --git a/api/src/graphql/generated/api/operations.ts b/api/src/graphql/generated/api/operations.ts index 91a8a87b2..f5b7134de 100755 --- a/api/src/graphql/generated/api/operations.ts +++ b/api/src/graphql/generated/api/operations.ts @@ -2,7 +2,7 @@ import * as Types from '@app/graphql/generated/api/types'; import { z } from 'zod' -import { AccessUrl, AccessUrlInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationFilter, NotificationInput, NotificationType, Os, Owner, ParityCheck, Partition, Pci, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addApiKeyInput, addUserInput, arrayDiskInput, authenticateInput, deleteUserInput, mdState, registrationType, updateApikeyInput, usersInput } from '@app/graphql/generated/api/types' +import { AccessUrl, AccessUrlInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationFilter, NotificationType, Os, Owner, ParityCheck, Partition, Pci, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addApiKeyInput, addUserInput, arrayDiskInput, authenticateInput, deleteUserInput, mdState, registrationType, updateApikeyInput, usersInput } from '@app/graphql/generated/api/types' import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; type Properties = Required<{ @@ -610,19 +610,6 @@ export function NotificationFilterSchema(): z.ZodObject> { - return z.object({ - description: z.string().nullish(), - id: z.string(), - importance: ImportanceSchema, - link: z.string().nullish(), - subject: z.string(), - timestamp: z.string().nullish(), - title: z.string(), - type: NotificationTypeSchema - }) -} - export function OsSchema(): z.ZodObject> { return z.object({ __typename: z.literal('Os').optional(), diff --git a/api/src/graphql/generated/api/types.ts b/api/src/graphql/generated/api/types.ts index 39b6a633d..1ddae711b 100644 --- a/api/src/graphql/generated/api/types.ts +++ b/api/src/graphql/generated/api/types.ts @@ -781,20 +781,8 @@ export type NotificationFilter = { type?: InputMaybe; }; -export type NotificationInput = { - description?: InputMaybe; - id: Scalars['ID']['input']; - importance: Importance; - link?: InputMaybe; - subject: Scalars['String']['input']; - timestamp?: InputMaybe; - title: Scalars['String']['input']; - type: NotificationType; -}; - export enum NotificationType { - ARCHIVED = 'ARCHIVED', - RESTORED = 'RESTORED', + ARCHIVE = 'ARCHIVE', UNREAD = 'UNREAD' } @@ -1724,7 +1712,6 @@ export type ResolversTypes = ResolversObject<{ Node: ResolverTypeWrapper['Node']>; Notification: ResolverTypeWrapper; NotificationFilter: NotificationFilter; - NotificationInput: NotificationInput; NotificationType: NotificationType; Os: ResolverTypeWrapper; Owner: ResolverTypeWrapper; @@ -1829,7 +1816,6 @@ export type ResolversParentTypes = ResolversObject<{ Node: ResolversInterfaceTypes['Node']; Notification: Notification; NotificationFilter: NotificationFilter; - NotificationInput: NotificationInput; Os: Os; Owner: Owner; ParityCheck: ParityCheck; diff --git a/api/src/graphql/generated/client/validators.ts b/api/src/graphql/generated/client/validators.ts index 0cc0fd964..4bb0c65ba 100644 --- a/api/src/graphql/generated/client/validators.ts +++ b/api/src/graphql/generated/client/validators.ts @@ -201,7 +201,9 @@ export function RemoteAccessInputSchema(): z.ZodObject> { return z.object({ apiKey: z.string(), - body: z.string() + body: z.string(), + timeout: z.number().nullish(), + ttl: z.number().nullish() }) } diff --git a/api/src/graphql/schema/types/notifications/notifications.graphql b/api/src/graphql/schema/types/notifications/notifications.graphql index 0a794d63a..0d6bf7432 100644 --- a/api/src/graphql/schema/types/notifications/notifications.graphql +++ b/api/src/graphql/schema/types/notifications/notifications.graphql @@ -1,18 +1,6 @@ enum NotificationType { UNREAD - ARCHIVED - RESTORED -} - -input NotificationInput { - id: ID! - title: String! - subject: String! - description: String - importance: Importance! - link: String - type: NotificationType! - timestamp: String + ARCHIVE } input NotificationFilter { @@ -47,3 +35,11 @@ type Notification { """ ISO Timestamp for when the notification occurred """ timestamp: String } + +type NotificationOverview { + unread: Int! + archived: Int! + critical: Int! + warning: Int! + alert: Int! +} \ No newline at end of file diff --git a/api/src/store/modules/notifications.ts b/api/src/store/modules/notifications.ts index 2c43961d8..c39589dad 100644 --- a/api/src/store/modules/notifications.ts +++ b/api/src/store/modules/notifications.ts @@ -14,6 +14,7 @@ import { createSlice, } from '@reduxjs/toolkit'; import { PUBSUB_CHANNEL, pubsub } from '@app/core/pubsub'; +import { NotificationIni } from '@app/core/types/states/notification'; interface NotificationState { notifications: Record; @@ -23,15 +24,6 @@ const notificationInitialState: NotificationState = { notifications: {}, }; -interface NotificationIni { - timestamp: string; - event: string; - subject: string; - description: string; - importance: 'normal' | 'alert' | 'warning'; - link?: string; -} - const fileImportanceToGqlImportance = ( importance: NotificationIni['importance'] ): Importance => { @@ -64,7 +56,7 @@ export const loadNotification = createAsyncThunk< type: 'ini', }); - const notification: NotificationInput = { + const notification: Notification = { id: path, title: notificationFile.event, subject: notificationFile.subject, diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts index ef1bfce08..553a7f92b 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts @@ -4,9 +4,12 @@ import { Query, Resolver, Args, Subscription } from '@nestjs/graphql'; import { GraphQLError } from 'graphql'; import { UseRoles } from 'nest-access-control'; import { PUBSUB_CHANNEL, createSubscription } from '@app/core/pubsub'; +import { NotificationsService } from './notifications.service'; @Resolver() export class NotificationsResolver { + constructor(readonly notificationsService: NotificationsService) {} + @Query() @UseRoles({ resource: 'notifications', @@ -17,26 +20,7 @@ export class NotificationsResolver { @Args('filter') { limit, importance, type, offset }: NotificationFilter ) { - if (limit > 50) { - throw new GraphQLError('Limit must be less than 50'); - } - return Object.values(getters.notifications().notifications) - .filter((notification) => { - if (importance && importance !== notification.importance) { - return false; - } - if (type && type !== notification.type) { - return false; - } - - return true; - }) - .sort( - (a, b) => - new Date(b.timestamp ?? 0).getTime() - - new Date(a.timestamp ?? 0).getTime() - ) - .slice(offset, limit + offset); + return await this.notificationsService.getNotifications(); } @Subscription('notificationAdded') diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts new file mode 100644 index 000000000..47119104c --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotificationsService } from './notifications.service'; + +describe('NotificationsService', () => { + let service: NotificationsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [NotificationsService], + }).compile(); + + service = module.get(NotificationsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts new file mode 100644 index 000000000..98d243960 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts @@ -0,0 +1,126 @@ +import { NotificationIni } from '@app/core/types/states/notification'; +import { parseConfig } from '@app/core/utils/misc/parse-config'; +import { NotificationSchema } from '@app/graphql/generated/api/operations'; +import { Importance, NotificationType, type Notification, } from '@app/graphql/generated/api/types'; +import { getters } from '@app/store'; +import { Injectable } from '@nestjs/common'; +import { readdir, stat } from 'fs/promises'; +import { join } from 'path'; +import { Logger } from '@nestjs/common'; +import { isFulfilled, isRejected } from '@app/utils'; + +@Injectable() +export class NotificationsService { + private logger = new Logger(NotificationsService.name); + + /** + * Retrieves all notifications from the file system. + * + * @returns An array of all notifications in the system. + */ + public async getNotifications(): Promise { + this.logger.debug('Getting Notifications'); + const logErrors = (error: unknown) => this.logger.error(error); + + const notificationBasePath = getters.dynamix().notify!.path; + + const [unreadNotifications, unreadNotificationErrors] = await this.getUnreadNotifications(notificationBasePath); + + this.logger.debug(`${unreadNotifications.length} Unread Notifications`); + this.logger.debug(`${unreadNotificationErrors.length} Unread Notification Errors`); + unreadNotificationErrors.forEach(logErrors); + + const [archivedNotifications, archivedNotificationErrors] = await this.getArchiveNotifications(notificationBasePath); + + this.logger.debug(`${archivedNotifications.length} Archived Notifications`); + this.logger.debug(`${archivedNotificationErrors.length} Archived Notification Errors`); + archivedNotificationErrors.forEach(logErrors); + + return [...unreadNotifications, ...archivedNotifications]; + } + + private getUnreadNotifications(notificationsDirectory: string) { + const unreadsDirectoryPath = join(notificationsDirectory, NotificationType.UNREAD.toLowerCase()); + return this.getNotificationsFromDirectory(unreadsDirectoryPath, NotificationType.UNREAD); + } + + private getArchiveNotifications(notificationsDirectory: string) { + const archivedDirectoryPath = join(notificationsDirectory, NotificationType.ARCHIVE.toLowerCase()); + return this.getNotificationsFromDirectory(archivedDirectoryPath, NotificationType.ARCHIVE); + } + + /** + * Given a directory path, reads all the files in the directory, + * and attempts to parse each file as a Notification. + * Returns an array of two elements: + * - the first element is an array of successfully parsed Notifications, + * - the second element is an array of errors for any files that failed parsing. + * @param containingDirectory the directory to read + * @param type the type of notification to load + * @returns an array of two elements: [successes, errors/failures] + */ + private async getNotificationsFromDirectory(containingDirectory: string, type: NotificationType): Promise<[Notification[], unknown[]]> { + const loadNotification = (filePath: string) => this.loadNotificationFile(filePath, type); + + const contents = await readdir(containingDirectory); + const absolutePaths = contents.map((content) => join(containingDirectory, content)); + const fileReads = absolutePaths.map(loadNotification); + const results = await Promise.allSettled(fileReads); + + return [ + results.filter(isFulfilled).map((result) => result.value), + results.filter(isRejected).map((result) => result.reason), + ]; + } + + /** + * Loads a notification file from disk, parses it to a Notification object, and + * validates the object against the NotificationSchema. + * + * @param path The path to the notification file on disk. + * @param type The type of the notification that is being loaded. + * @returns A parsed Notification object, or throws an error if the object is invalid. + * @throws An error if the object is invalid (doesn't conform to the graphql NotificationSchema). + */ + private async loadNotificationFile(path: string, type: NotificationType): Promise { + const notificationFile = parseConfig({ + filePath: path, + type: 'ini', + }); + + const notification: Notification = { + id: path, + title: notificationFile.event, + subject: notificationFile.subject, + description: notificationFile.description ?? '', + importance: this.fileImportanceToGqlImportance(notificationFile.importance), + link: notificationFile.link, + timestamp: this.parseNotificationDateToIsoDate(notificationFile.timestamp), + type + }; + + return NotificationSchema().parse(notification); + } + + private fileImportanceToGqlImportance( + importance: NotificationIni['importance'] + ): Importance { + switch (importance) { + case 'alert': + return Importance.ALERT; + case 'warning': + return Importance.WARNING; + default: + return Importance.INFO; + } + }; + + private parseNotificationDateToIsoDate( + unixStringSeconds: string | undefined + ): string | null { + if (unixStringSeconds && !isNaN(Number(unixStringSeconds))) { + return new Date(Number(unixStringSeconds) * 1_000).toISOString(); + } + return null; + }; +} diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index 677748ba9..6d7eec31f 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -14,6 +14,7 @@ import { RegistrationResolver } from './registration/registration.resolver'; import { ServerResolver } from './servers/server.resolver'; import { VarsResolver } from './vars/vars.resolver'; import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver'; +import { NotificationsService } from './notifications/notifications.service'; @Module({ providers: [ @@ -32,6 +33,7 @@ import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.re ServerResolver, VarsResolver, VmsResolver, + NotificationsService, ], }) export class ResolversModule {} diff --git a/api/src/utils.ts b/api/src/utils.ts new file mode 100644 index 000000000..5dd5c4d8a --- /dev/null +++ b/api/src/utils.ts @@ -0,0 +1,23 @@ +export function notNull(value: T): value is NonNullable { + return value !== null; +} + +/** + * Checks if a PromiseSettledResult is fulfilled. + * + * @param result A PromiseSettledResult. + * @returns true if the result is fulfilled, false otherwise. + */ +export function isFulfilled(result: PromiseSettledResult): result is PromiseFulfilledResult { + return result.status === 'fulfilled'; +} + +/** + * Checks if a PromiseSettledResult is rejected. + * + * @param result A PromiseSettledResult. + * @returns true if the result is rejected, false otherwise. + */ +export function isRejected(result: PromiseSettledResult): result is PromiseRejectedResult { + return result.status === 'rejected'; +} \ No newline at end of file