diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fae8e3d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true +} diff --git a/docker-compose.yml b/docker-compose.yml index 16e2b57..e380943 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,29 +1,21 @@ -version: "3.8" - services: - mongo: - image: mongo:6.0.4-focal - container_name: crowbar-mongo + crowbar-db: + image: postgres:16-alpine + container_name: crowbar-db restart: unless-stopped environment: - MONGO_INITDB_DATABASE: "crowbar" - MONGO_INITDB_ROOT_USERNAME: admin - MONGO_INITDB_ROOT_PASSWORD: crowbartools + POSTGRES_USER: crowbar + POSTGRES_PASSWORD: crowbar + POSTGRES_DB: crowbar ports: - - 127.0.0.1:27017-27019:27017-27019 + - 127.0.0.1:5433:5432 volumes: - - cbDbData:/data/db - - cbDbConfig:/data/configdb - mongo-express: - image: mongo-express - restart: unless-stopped - ports: - - 8081:8081 - environment: - ME_CONFIG_MONGODB_ADMINUSERNAME: admin - ME_CONFIG_MONGODB_ADMINPASSWORD: crowbartools - ME_CONFIG_MONGODB_URL: mongodb://admin:crowbartools@mongo:27017/ + - crowbarDbData:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U crowbar -d crowbar"] + interval: 10s + timeout: 5s + retries: 5 volumes: - cbDbData: - cbDbConfig: + crowbarDbData: diff --git a/package-lock.json b/package-lock.json index c7cb28d..f36493f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "helmet": "^8.1.0", "keyv": "^5.5.0", "node-gzip": "^1.1.2", + "pg": "^8.22.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "tar-stream": "^3.2.0", @@ -37,7 +38,7 @@ "ws": "^8.18.3" }, "devDependencies": { - "@crowbartools/firebot-types": "^5.67.0-alpha27", + "@crowbartools/firebot-types": "^5.67.0-alpha29", "@nestjs/cli": "^11.0.10", "@nestjs/schematics": "^11.0.7", "@nestjs/testing": "^11.1.6", @@ -45,6 +46,7 @@ "@types/jest": "30.0.0", "@types/node": "^22.19.21", "@types/node-gzip": "^1.1.3", + "@types/pg": "^8.20.0", "@types/supertest": "^6.0.3", "@types/tar-stream": "^3.1.4", "@types/ws": "^8.18.1", @@ -730,9 +732,9 @@ } }, "node_modules/@crowbartools/firebot-types": { - "version": "5.67.0-alpha27", - "resolved": "https://registry.npmjs.org/@crowbartools/firebot-types/-/firebot-types-5.67.0-alpha27.tgz", - "integrity": "sha512-rtRUXFoe4+lmoq1VYSkhKehEK+cERgyRkwwZGaGHVTMIjODE+xr88+xecIEmOjtKNOiPnUbHwF+UYIMSH716xw==", + "version": "5.67.0-alpha29", + "resolved": "https://registry.npmjs.org/@crowbartools/firebot-types/-/firebot-types-5.67.0-alpha29.tgz", + "integrity": "sha512-Cq6WPCkkFI5pHGHRXxisurg3lLF3GpPTcXZJ98oxLV3x0gFMF6VsWjyDg1hP3Kjt0flOYaokoiKCPg6WrJeYdg==", "dev": true, "license": "GPL-3.0", "peerDependencies": { @@ -2864,6 +2866,18 @@ "@types/node": "*" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -3330,9 +3344,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3347,9 +3358,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3364,9 +3372,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3381,9 +3386,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3398,9 +3400,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3415,9 +3414,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3432,9 +3428,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3449,9 +3442,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8023,6 +8013,95 @@ "node": ">=8" } }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8123,6 +8202,45 @@ "node": ">=4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8718,6 +8836,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", diff --git a/package.json b/package.json index a062c61..0dd568f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "helmet": "^8.1.0", "keyv": "^5.5.0", "node-gzip": "^1.1.2", + "pg": "^8.22.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "tar-stream": "^3.2.0", @@ -49,7 +50,7 @@ "ws": "^8.18.3" }, "devDependencies": { - "@crowbartools/firebot-types": "^5.67.0-alpha27", + "@crowbartools/firebot-types": "^5.67.0-alpha29", "@nestjs/cli": "^11.0.10", "@nestjs/schematics": "^11.0.7", "@nestjs/testing": "^11.1.6", @@ -57,6 +58,7 @@ "@types/jest": "30.0.0", "@types/node": "^22.19.21", "@types/node-gzip": "^1.1.3", + "@types/pg": "^8.20.0", "@types/supertest": "^6.0.3", "@types/tar-stream": "^3.1.4", "@types/ws": "^8.18.1", diff --git a/src/api/controllers/plugins/dtos/firebot-version.dto.ts b/src/api/controllers/plugins/dtos/firebot-version.dto.ts new file mode 100644 index 0000000..53c593d --- /dev/null +++ b/src/api/controllers/plugins/dtos/firebot-version.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsInt, IsOptional } from "class-validator"; + +export class FirebotVersionDto { + @ApiProperty() + @IsInt() + major: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + minor?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + patch?: number; +} diff --git a/src/api/controllers/plugins/dtos/plugin-ref.dto.ts b/src/api/controllers/plugins/dtos/plugin-ref.dto.ts new file mode 100644 index 0000000..a077044 --- /dev/null +++ b/src/api/controllers/plugins/dtos/plugin-ref.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString } from "class-validator"; + +export class PluginRefDto { + @ApiProperty() + @IsString() + author: string; + + @ApiProperty() + @IsString() + name: string; + + @ApiProperty() + @IsString() + version: string; +} diff --git a/src/api/controllers/plugins/dtos/plugin-search.dto.ts b/src/api/controllers/plugins/dtos/plugin-search.dto.ts new file mode 100644 index 0000000..b93db87 --- /dev/null +++ b/src/api/controllers/plugins/dtos/plugin-search.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsDefined, + IsIn, + IsInt, + IsOptional, + IsString, + Max, + Min, + ValidateNested, +} from "class-validator"; +import { + PLUGIN_CATEGORIES, + PLUGIN_FEATURES, + PLUGIN_SEARCH_SORT_MODES, + type PluginCategory, + type PluginFeature, + type PluginSearchSortMode, +} from "../../../../domain/plugins/plugin-types"; +import { FirebotVersionDto } from "./firebot-version.dto"; + +export class PluginSearchDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + query?: string; + + @ApiPropertyOptional({ enum: PLUGIN_CATEGORIES }) + @IsOptional() + @IsIn(PLUGIN_CATEGORIES) + category?: PluginCategory; + + @ApiPropertyOptional({ enum: PLUGIN_FEATURES, isArray: true }) + @IsOptional() + @IsArray() + @IsIn(PLUGIN_FEATURES, { each: true }) + features?: PluginFeature[]; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + official?: boolean; + + @ApiPropertyOptional({ enum: PLUGIN_SEARCH_SORT_MODES, default: "popular" }) + @IsOptional() + @IsIn(PLUGIN_SEARCH_SORT_MODES) + sortBy?: PluginSearchSortMode; + + @ApiPropertyOptional({ minimum: 1, default: 1 }) + @IsOptional() + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ minimum: 1, maximum: 50, default: 20 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(50) + pageSize?: number; + + @ApiProperty({ type: FirebotVersionDto }) + @IsDefined() + @ValidateNested() + @Type(() => FirebotVersionDto) + firebotVersion: FirebotVersionDto; +} diff --git a/src/api/controllers/plugins/dtos/plugin-update-check.dto.ts b/src/api/controllers/plugins/dtos/plugin-update-check.dto.ts new file mode 100644 index 0000000..f10f1ff --- /dev/null +++ b/src/api/controllers/plugins/dtos/plugin-update-check.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, IsDefined, ValidateNested } from "class-validator"; +import { FirebotVersionDto } from "./firebot-version.dto"; +import { PluginRefDto } from "./plugin-ref.dto"; + +export class PluginUpdateCheckDto { + @ApiProperty({ type: [PluginRefDto] }) + @IsDefined() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PluginRefDto) + plugins: PluginRefDto[]; + + @ApiProperty({ type: FirebotVersionDto }) + @IsDefined() + @ValidateNested() + @Type(() => FirebotVersionDto) + firebotVersion: FirebotVersionDto; +} diff --git a/src/api/controllers/plugins/plugins.controller.ts b/src/api/controllers/plugins/plugins.controller.ts index 3dde735..50848bf 100644 --- a/src/api/controllers/plugins/plugins.controller.ts +++ b/src/api/controllers/plugins/plugins.controller.ts @@ -4,19 +4,26 @@ import { Post, HttpCode, Body, + NotFoundException, } from "@nestjs/common"; import { ApiResponse } from "@nestjs/swagger"; import { PluginCacheService } from "../../../domain/plugins/plugin-cache.service"; -import type { - ManifestFirebotVersion, - ManagedPluginUpdateRequest -} from "@crowbartools/firebot-types"; +import { PluginStatsService } from "../../../domain/plugins/plugin-stats.service"; +import { TwitchAuth } from "../../decorators/twitch-auth"; +import { CurrentUser } from "../../decorators/current-user.decorator"; +import type { TwitchUser } from "../../../domain/profile-data/profile-types"; +import { PluginRefDto } from "./dtos/plugin-ref.dto"; +import { PluginSearchDto } from "./dtos/plugin-search.dto"; +import { PluginUpdateCheckDto } from "./dtos/plugin-update-check.dto"; @Controller({ path: "plugins", }) export class PluginsController { - constructor(private readonly pluginCache: PluginCacheService) { } + constructor( + private readonly pluginCache: PluginCacheService, + private readonly pluginStats: PluginStatsService, + ) { } @Get("refresh") @HttpCode(204) @@ -30,16 +37,40 @@ export class PluginsController { @Post("search") @HttpCode(200) async searchPlugins( - @Body() body: { query: string, firebotVersion: ManifestFirebotVersion } + @Body() body: PluginSearchDto, ) { - return await this.pluginCache.searchPlugins(body.query, body.firebotVersion); + return await this.pluginCache.searchPlugins({ + query: body.query, + category: body.category, + features: body.features, + official: body.official, + sortBy: body.sortBy ?? "popular", + page: body.page ?? 1, + pageSize: body.pageSize ?? 20, + firebotVersion: body.firebotVersion, + }); } @Post("updates") @HttpCode(200) async checkPluginsForUpdates( - @Body() request: ManagedPluginUpdateRequest + @Body() request: PluginUpdateCheckDto ) { return await this.pluginCache.checkPluginsForUpdates(request); } -} \ No newline at end of file + + @Post("track-download") + @HttpCode(200) + @TwitchAuth() + async trackDownload( + @Body() body: PluginRefDto, + @CurrentUser() user: TwitchUser + ) { + const validPluginVersion = await this.pluginCache.pluginVersionExists(body.author, body.name, body.version); + if (!validPluginVersion) { + return; + } + + await this.pluginStats.trackDownload(body.author, body.name, body.version, user.twitchUserId); + } +} diff --git a/src/api/decorators/twitch-auth.ts b/src/api/decorators/twitch-auth.ts new file mode 100644 index 0000000..28e51fc --- /dev/null +++ b/src/api/decorators/twitch-auth.ts @@ -0,0 +1,11 @@ +import { applyDecorators, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth } from "@nestjs/swagger"; +import { TwitchAuthGuard } from "../auth/twitch-auth.guard"; + +export function TwitchAuth() { + const decorators = [ + UseGuards(TwitchAuthGuard), + ApiBearerAuth("twitchAuth") + ]; + return applyDecorators(...decorators); +} \ No newline at end of file diff --git a/src/domain/domain.module.ts b/src/domain/domain.module.ts index 6704653..a7c60a2 100644 --- a/src/domain/domain.module.ts +++ b/src/domain/domain.module.ts @@ -4,6 +4,7 @@ import { ProfileDataCacheService } from "./profile-data/profile-data-cache.servi import { DataBinCacheService } from "./data-bin/data-bin-cache.service"; import { NotificationCacheService } from "./notifications/notification-cache.service"; import { PluginCacheService } from "./plugins/plugin-cache.service"; +import { PluginStatsService } from "./plugins/plugin-stats.service"; import { SteamService } from "./steam/steam.service"; const providers: Provider[] = [ @@ -11,6 +12,7 @@ const providers: Provider[] = [ DataBinCacheService, NotificationCacheService, PluginCacheService, + PluginStatsService, SteamService, ]; diff --git a/src/domain/plugins/plugin-cache.service.ts b/src/domain/plugins/plugin-cache.service.ts index 7251318..340f6ac 100644 --- a/src/domain/plugins/plugin-cache.service.ts +++ b/src/domain/plugins/plugin-cache.service.ts @@ -1,12 +1,15 @@ import type { - ManagedPluginManifest, ManagedPlugin, + ManagedPluginManifest, ManagedPluginUpdateRequest, ManifestFirebotVersion, } from "@crowbartools/firebot-types"; -import type { - CachedPlugin, - PluginVersionWithManifest, +import { + OFFICIAL_PLUGIN_GITHUB_ORGS, + type CachedPlugin, + type PluginSearchOptions, + type PluginSearchSortMode, + type PluginVersionWithManifest, } from "./plugin-types"; import { Octokit } from "@octokit/rest"; import { Readable } from "node:stream"; @@ -17,6 +20,7 @@ import { Injectable } from "@nestjs/common"; import { ICacheService } from "../cache/cache-service.interface"; import { createGunzip } from "node:zlib"; import sortVersionStrings from "../util/sort-versions"; +import { PluginStatsService } from "./plugin-stats.service"; const PLUGIN_MANIFEST_OWNER = "crowbartools"; const PLUGIN_MANIFEST_REPO = "firebot-plugins"; @@ -29,7 +33,10 @@ const PLUGIN_MANIFEST_FILENAME_REGEX = /^manifests\/(.*)\/(.*)\/(.*)\/manifest.j @Injectable() export class PluginCacheService { - constructor(private readonly cache: ICacheService) { } + constructor( + private readonly cache: ICacheService, + private readonly pluginStats: PluginStatsService, + ) { } private _octokit = new Octokit(); private _latestHash = ""; @@ -248,13 +255,30 @@ export class PluginCacheService { return null; } - async searchPlugins(query: string, firebotVersion: ManifestFirebotVersion): Promise { + private isOfficialPlugin(manifest: ManagedPluginManifest): boolean { + const repoOwner = manifest.repo + ?.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\//i)?.[1] + ?.toLowerCase(); + + return repoOwner != null && (OFFICIAL_PLUGIN_GITHUB_ORGS as readonly string[]).includes(repoOwner); + } + + async pluginVersionExists(author: string, name: string, version: string): Promise { // Load the cache if it hasn't been already await this.loadCache(); const pluginCache = await this.cache.get("plugin-cache") ?? []; - const availablePlugins = pluginCache.map(r => { - const latest = this.getLatestCompatiblePluginVersion(r.versions, firebotVersion); + const plugin = pluginCache.find(p => p.author === author && p.name === name); + return plugin?.versions.some(v => v.version === version) ?? false; + } + + async searchPlugins(options: PluginSearchOptions): Promise<{ items: ManagedPlugin[], total: number }> { + // Load the cache if it hasn't been already + await this.loadCache(); + + const pluginCache = await this.cache.get("plugin-cache") ?? []; + let plugins = pluginCache.map(r => { + const latest = this.getLatestCompatiblePluginVersion(r.versions, options.firebotVersion); return latest != null ? { author: r.author, @@ -263,22 +287,70 @@ export class PluginCacheService { manifest: latest.manifest } as ManagedPlugin : null; - }).filter(r => r != null) - - const fuse = new Fuse(availablePlugins ?? [], { - keys: [ - "author", - "pluginName", - "manifest.name", - "manifest.author", - "manifest.description", - "manifest.tags" - ], - threshold: 0.3 // We still want fuzzy, but pretty close - }); - const results = fuse.search(query).map(r => r.item) ?? []; - - return results; + }).filter(r => r != null); + + if (options.category != null) { + plugins = plugins.filter(p => + (p.manifest).category === options.category + ); + } + + if (options.features?.length) { + plugins = plugins.filter(p => + p.manifest.features?.some(f => options.features!.includes(f)) + ); + } + + if (options.official === true) { + plugins = plugins.filter(p => this.isOfficialPlugin(p.manifest)); + } + + const query = options.query?.trim(); + if (query?.length) { + const fuse = new Fuse(plugins, { + keys: [ + "author", + "name", + "manifest.name", + "manifest.author", + "manifest.description", + "manifest.tags" + ], + threshold: 0.3 // We still want fuzzy, but pretty close + }); + plugins = fuse.search(query).map(r => r.item); + } + + const sorted = await this.sortPlugins(plugins, options.sortBy); + + const start = (options.page - 1) * options.pageSize; + return { + items: sorted.slice(start, start + options.pageSize), + total: sorted.length + }; + } + + private async sortPlugins( + plugins: ManagedPlugin[], + sortBy: PluginSearchSortMode + ): Promise { + switch (sortBy) { + case "popular": { + const downloads = await this.pluginStats.getDownloadTotals(); + return plugins.toSorted((a, b) => + ((downloads.get(`${b.author}/${b.name}`) ?? 0) - (downloads.get(`${a.author}/${a.name}`) ?? 0)) + || a.manifest.name.localeCompare(b.manifest.name) + ); + } + case "recently-updated": + return plugins.toSorted((a, b) => + (Date.parse(b.manifest.releaseDate) || 0) - (Date.parse(a.manifest.releaseDate) || 0) + ); + case "name": + return plugins.toSorted((a, b) => + a.manifest.name.localeCompare(b.manifest.name, undefined, { sensitivity: "base" }) + ); + } } private isLatestVersionNewer(latestVersion: string, currentVersion: string): boolean { diff --git a/src/domain/plugins/plugin-stats.repository.ts b/src/domain/plugins/plugin-stats.repository.ts new file mode 100644 index 0000000..ae50e44 --- /dev/null +++ b/src/domain/plugins/plugin-stats.repository.ts @@ -0,0 +1,14 @@ +export type PluginStatTotals = { + author: string; + name: string; + downloads: number; +}; + +export abstract class IPluginStatsRepository { + abstract incrementDownload( + author: string, + name: string, + version: string, + ): Promise; + abstract getAllTotals(): Promise; +} diff --git a/src/domain/plugins/plugin-stats.service.ts b/src/domain/plugins/plugin-stats.service.ts new file mode 100644 index 0000000..ed28b7f --- /dev/null +++ b/src/domain/plugins/plugin-stats.service.ts @@ -0,0 +1,36 @@ +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Inject, Injectable } from "@nestjs/common"; +import { Cache } from "cache-manager"; +import { IPluginStatsRepository } from "./plugin-stats.repository"; + +const DOWNLOAD_COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000; // 3 days + +@Injectable() +export class PluginStatsService { + constructor( + private readonly repository: IPluginStatsRepository, + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + ) {} + + async trackDownload( + author: string, + name: string, + version: string, + twitchUserId: string, + ): Promise { + const cooldownKey = `plugin-download-cooldown:${twitchUserId}:${author}/${name}@${version}`; + + if (await this.cacheManager.get(cooldownKey)) { + return; + } + + await this.repository.incrementDownload(author, name, version); + await this.cacheManager.set(cooldownKey, true, DOWNLOAD_COOLDOWN_MS); + return; + } + + async getDownloadTotals(): Promise> { + const totals = await this.repository.getAllTotals(); + return new Map(totals.map((t) => [`${t.author}/${t.name}`, t.downloads])); + } +} diff --git a/src/domain/plugins/plugin-types.ts b/src/domain/plugins/plugin-types.ts index 64d2f77..229977d 100644 --- a/src/domain/plugins/plugin-types.ts +++ b/src/domain/plugins/plugin-types.ts @@ -1,4 +1,52 @@ -import type { ManagedPluginManifest } from "@crowbartools/firebot-types"; +import type { ManagedPluginManifest, ManifestFirebotVersion } from "@crowbartools/firebot-types"; + +export const PLUGIN_CATEGORIES = [ + "stream-services", + "social", + "music-media", + "games", + "overlays", + "tools-utilities", +] as const; +export type PluginCategory = (typeof PLUGIN_CATEGORIES)[number]; + +export const PLUGIN_FEATURES = [ + "effects", + "events", + "variables", + "integrations", + "overlay-widgets", + "games", + "commands", + "ui-extensions", +] as const; +export type PluginFeature = (typeof PLUGIN_FEATURES)[number]; + +export const PLUGIN_SEARCH_SORT_MODES = ["popular", "recently-updated", "name"] as const; +export type PluginSearchSortMode = (typeof PLUGIN_SEARCH_SORT_MODES)[number]; + +export const OFFICIAL_PLUGIN_GITHUB_ORGS = [ + "crowbartools", + "ebiggz", + "zunderscore", + "sreject", + "heyaapl", + "cavemobster", + "itsjesski", + "brumoen", +] as const; + + +export type PluginSearchOptions = { + query?: string; + category?: PluginCategory; + features?: PluginFeature[]; + official?: boolean; + sortBy: PluginSearchSortMode; + page: number; + pageSize: number; + firebotVersion: ManifestFirebotVersion; +}; export type PluginVersionWithManifest = { version: string; diff --git a/src/infrastructure/config.ts b/src/infrastructure/config.ts index 3c0a000..3b71ed7 100644 --- a/src/infrastructure/config.ts +++ b/src/infrastructure/config.ts @@ -10,4 +10,5 @@ export default registerAs("app", () => ({ cacheLruSize: parseInt(process.env.CACHE_LRU_SIZE!, 10) || 2500, twitchClientId: process.env.TWITCH_CLIENT_ID || "", steamWebApiKey: process.env.STEAM_WEB_API_KEY || "", + databaseUrl: process.env.DATABASE_URL || "", })); diff --git a/src/infrastructure/database/database.service.ts b/src/infrastructure/database/database.service.ts new file mode 100644 index 0000000..4300541 --- /dev/null +++ b/src/infrastructure/database/database.service.ts @@ -0,0 +1,49 @@ +import { + Inject, + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from "@nestjs/common"; +import { ConfigType } from "@nestjs/config"; +import { Pool, QueryResult, QueryResultRow } from "pg"; +import appConfig from "../config"; + +const SCHEMA = ` + CREATE TABLE IF NOT EXISTS plugin_downloads ( + author text NOT NULL, + name text NOT NULL, + version text NOT NULL, + count bigint NOT NULL DEFAULT 0, + PRIMARY KEY (author, name, version) + ); +`; + +@Injectable() +export class DatabaseService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(DatabaseService.name); + private readonly pool: Pool; + + constructor( + @Inject(appConfig.KEY) + private readonly config: ConfigType, + ) { + this.pool = new Pool({ connectionString: this.config.databaseUrl }); + } + + async onModuleInit(): Promise { + await this.pool.query(SCHEMA); + this.logger.log("Database schema ready"); + } + + async onModuleDestroy(): Promise { + await this.pool.end(); + } + + async query( + text: string, + params?: unknown[], + ): Promise> { + return this.pool.query(text, params); + } +} diff --git a/src/infrastructure/database/plugin-stats.repository.ts b/src/infrastructure/database/plugin-stats.repository.ts new file mode 100644 index 0000000..1c29aa8 --- /dev/null +++ b/src/infrastructure/database/plugin-stats.repository.ts @@ -0,0 +1,46 @@ +import { Injectable } from "@nestjs/common"; +import { + IPluginStatsRepository, + PluginStatTotals, +} from "../../domain/plugins/plugin-stats.repository"; +import { DatabaseService } from "./database.service"; + +@Injectable() +export class PluginStatsRepository extends IPluginStatsRepository { + constructor(private readonly db: DatabaseService) { + super(); + } + + async incrementDownload( + author: string, + name: string, + version: string, + ): Promise { + await this.db.query( + `INSERT INTO plugin_downloads (author, name, version, count) + VALUES ($1, $2, $3, 1) + ON CONFLICT (author, name, version) + DO UPDATE SET count = plugin_downloads.count + 1`, + [author, name, version], + ); + } + + async getAllTotals(): Promise { + const result = await this.db.query<{ + author: string; + name: string; + total: string; + }>( + `SELECT author, name, SUM(count) AS total + FROM plugin_downloads + GROUP BY author, name`, + ); + + // pg returns bigint columns as strings + return result.rows.map((row) => ({ + author: row.author, + name: row.name, + downloads: Number(row.total), + })); + } +} diff --git a/src/infrastructure/infrastructure.module.ts b/src/infrastructure/infrastructure.module.ts index b605b2d..1bde086 100644 --- a/src/infrastructure/infrastructure.module.ts +++ b/src/infrastructure/infrastructure.module.ts @@ -1,13 +1,21 @@ import { forwardRef, Module, Provider, Scope } from "@nestjs/common"; -import { ICacheService } from "src/domain/cache/cache-service.interface"; -import { DomainModule } from "src/domain/domain.module"; +import { ICacheService } from "../domain/cache/cache-service.interface"; +import { IPluginStatsRepository } from "../domain/plugins/plugin-stats.repository"; +import { DomainModule } from "../domain/domain.module"; import { CompressedCacheService } from "./cache/compressed-cache.service"; +import { DatabaseService } from "./database/database.service"; +import { PluginStatsRepository } from "./database/plugin-stats.repository"; const providers: Provider[] = [ { provide: ICacheService, useClass: CompressedCacheService, }, + DatabaseService, + { + provide: IPluginStatsRepository, + useClass: PluginStatsRepository, + }, ]; @Module({