diff --git a/Dockerfile b/Dockerfile index 1b247c3..c62eb5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ RUN npm ci --production COPY bin /app/bin COPY resources /app/resources +COPY resources/cli/fonts /usr/local/share/fonts COPY --from=build /app/dist /app/dist RUN ln -s /app/bin/nxapi.js /usr/local/bin/nxapi diff --git a/package-lock.json b/package-lock.json index 9151e60..184cb24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "node-notifier": "^10.0.1", "node-persist": "^3.1.3", "read": "^3.0.0", + "sharp": "^0.33.1", "splatnet3-types": "^0.2.20231119210145", "supports-color": "^8.1.1", "tslib": "^2.6.2", @@ -2682,6 +2683,15 @@ "node": ">= 10.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.44.0.tgz", + "integrity": "sha512-ZX/etZEZw8DR7zAB1eVQT40lNo0jeqpb6dCgOvctB6FIQ5PoXfMuNY8+ayQfu8tNQbAB8gQWSSJupR8NxeiZXw==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@fastify/busboy": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz", @@ -2707,6 +2717,437 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.1.tgz", + "integrity": "sha512-esr2BZ1x0bo+wl7Gx2hjssYhjrhUsD88VQulI0FrG8/otRQUOxLWHMBd1Y1qo2Gfg2KUvXNpT0ASnV9BzJCexw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.1.tgz", + "integrity": "sha512-YrnuB3bXuWdG+hJlXtq7C73lF8ampkhU3tMxg5Hh+E7ikxbUVOU9nlNtVTloDXz6pRHt2y2oKJq7DY/yt+UXYw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-VzYd6OwnUR81sInf3alj1wiokY50DjsHz5bvfnsFpxs5tqQxESoHtJO6xyksDs3RIkyhMWq2FufXo6GNSU9BMw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=11", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.0.tgz", + "integrity": "sha512-dD9OznTlHD6aovRswaPNEy8dKtSAmNo4++tO7uuR4o5VxbVAOoEQ1uSmN4iFAdQneTHws1lkTZeiXPrcCkh6IA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=10.13", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.0.tgz", + "integrity": "sha512-VwgD2eEikDJUk09Mn9Dzi1OW2OJFRQK+XlBTkUNmAWPrtj8Ly0yq05DFgu1VCMx2/DqCGQVi5A1dM9hTmxf3uw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.0.tgz", + "integrity": "sha512-xTYThiqEZEZc0PRU90yVtM3KE7lw1bKdnDQ9kCTHWbqWyHOe4NpPOtMGy27YnN51q0J5dqRrvicfPbALIOeAZA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.0.tgz", + "integrity": "sha512-o9E46WWBC6JsBlwU4QyU9578G77HBDT1NInd+aERfxeOPbk0qBZHgoDsQmA2v9TbqJRWzoBPx1aLOhprBMgPjw==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.0.tgz", + "integrity": "sha512-naldaJy4hSVhWBgEjfdBY85CAa4UO+W1nx6a1sWStHZ7EUfNiuBTTN2KUYT5dH1+p/xij1t2QSXfCiFJoC5S/Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.0.tgz", + "integrity": "sha512-OdorplCyvmSAPsoJLldtLh3nLxRrkAAAOHsGWGDYfN0kh730gifK+UZb3dWORRa6EusNqCTjfXV4GxvgJ/nPDQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.0.tgz", + "integrity": "sha512-FW8iK6rJrg+X2jKD0Ajhjv6y74lToIBEvkZhl42nZt563FfxkCYacrXZtd+q/sRQDypQLzY5WdLkVTbJoPyqNg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.1.tgz", + "integrity": "sha512-Ii4X1vnzzI4j0+cucsrYA5ctrzU9ciXERfJR633S2r39CiD8npqH2GMj63uFZRCFt3E687IenAdbwIpQOJ5BNA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.1.tgz", + "integrity": "sha512-59B5GRO2d5N3tIfeGHAbJps7cLpuWEQv/8ySd9109ohQ3kzyCACENkFVAnGPX00HwPTQcaBNF7HQYEfZyZUFfw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.1.tgz", + "integrity": "sha512-tRGrb2pHnFUXpOAj84orYNxHADBDIr0J7rrjwQrTNMQMWA4zy3StKmMvwsI7u3dEZcgwuMMooIIGWEWOjnmG8A==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.1.tgz", + "integrity": "sha512-4y8osC0cAc1TRpy02yn5omBeloZZwS62fPZ0WUAYQiLhSFSpWJfY/gMrzKzLcHB9ulUV6ExFiu2elMaixKDbeg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.1.tgz", + "integrity": "sha512-D3lV6clkqIKUizNS8K6pkuCKNGmWoKlBGh5p0sLO2jQERzbakhu4bVX1Gz+RS4vTZBprKlWaf+/Rdp3ni2jLfA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.1.tgz", + "integrity": "sha512-LOGKNu5w8uu1evVqUAUKTix2sQu1XDRIYbsi5Q0c/SrXhvJ4QyOx+GaajxmOg5PZSsSnCYPSmhjHHsRBx06/wQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.1.tgz", + "integrity": "sha512-vWI/sA+0p+92DLkpAMb5T6I8dg4z2vzCUnp8yvxHlwBpzN8CIcO3xlSXrLltSvK6iMsVMNswAv+ub77rsf25lA==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^0.44.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.1.tgz", + "integrity": "sha512-/xhYkylsKL05R+NXGJc9xr2Tuw6WIVl2lubFJaFYfW4/MQ4J+dgjIo/T4qjNRizrqs/szF/lC9a5+updmY9jaQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.1.tgz", + "integrity": "sha512-XaM69X0n6kTEsp9tVYYLhXdg7Qj32vYJlAKRutxUsm1UlgQNx6BOhHwZPwukCGXBU2+tH87ip2eV1I/E8MQnZg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jest/create-cache-key-function": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", @@ -5084,6 +5525,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5100,6 +5553,15 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/colorette": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", @@ -5655,6 +6117,14 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -10108,6 +10578,59 @@ "node": ">=8" } }, + "node_modules/sharp": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.1.tgz", + "integrity": "sha512-iAYUnOdTqqZDb3QjMneBKINTllCJDZ3em6WaWy7NPECM4aHncvqHRm0v0bN9nqJxMiwamv5KIdauJ6lUzKDpTQ==", + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "semver": "^7.5.4" + }, + "engines": { + "libvips": ">=8.15.0", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.1", + "@img/sharp-darwin-x64": "0.33.1", + "@img/sharp-libvips-darwin-arm64": "1.0.0", + "@img/sharp-libvips-darwin-x64": "1.0.0", + "@img/sharp-libvips-linux-arm": "1.0.0", + "@img/sharp-libvips-linux-arm64": "1.0.0", + "@img/sharp-libvips-linux-s390x": "1.0.0", + "@img/sharp-libvips-linux-x64": "1.0.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.0", + "@img/sharp-libvips-linuxmusl-x64": "1.0.0", + "@img/sharp-linux-arm": "0.33.1", + "@img/sharp-linux-arm64": "0.33.1", + "@img/sharp-linux-s390x": "0.33.1", + "@img/sharp-linux-x64": "0.33.1", + "@img/sharp-linuxmusl-arm64": "0.33.1", + "@img/sharp-linuxmusl-x64": "0.33.1", + "@img/sharp-wasm32": "0.33.1", + "@img/sharp-win32-ia32": "0.33.1", + "@img/sharp-win32-x64": "0.33.1" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -10164,6 +10687,19 @@ "dev": true, "peer": true }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -13131,6 +13667,15 @@ } } }, + "@emnapi/runtime": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.44.0.tgz", + "integrity": "sha512-ZX/etZEZw8DR7zAB1eVQT40lNo0jeqpb6dCgOvctB6FIQ5PoXfMuNY8+ayQfu8tNQbAB8gQWSSJupR8NxeiZXw==", + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, "@fastify/busboy": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz", @@ -13153,6 +13698,147 @@ "@hapi/hoek": "^9.0.0" } }, + "@img/sharp-darwin-arm64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.1.tgz", + "integrity": "sha512-esr2BZ1x0bo+wl7Gx2hjssYhjrhUsD88VQulI0FrG8/otRQUOxLWHMBd1Y1qo2Gfg2KUvXNpT0ASnV9BzJCexw==", + "optional": true, + "requires": { + "@img/sharp-libvips-darwin-arm64": "1.0.0" + } + }, + "@img/sharp-darwin-x64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.1.tgz", + "integrity": "sha512-YrnuB3bXuWdG+hJlXtq7C73lF8ampkhU3tMxg5Hh+E7ikxbUVOU9nlNtVTloDXz6pRHt2y2oKJq7DY/yt+UXYw==", + "optional": true, + "requires": { + "@img/sharp-libvips-darwin-x64": "1.0.0" + } + }, + "@img/sharp-libvips-darwin-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-VzYd6OwnUR81sInf3alj1wiokY50DjsHz5bvfnsFpxs5tqQxESoHtJO6xyksDs3RIkyhMWq2FufXo6GNSU9BMw==", + "optional": true + }, + "@img/sharp-libvips-darwin-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.0.tgz", + "integrity": "sha512-dD9OznTlHD6aovRswaPNEy8dKtSAmNo4++tO7uuR4o5VxbVAOoEQ1uSmN4iFAdQneTHws1lkTZeiXPrcCkh6IA==", + "optional": true + }, + "@img/sharp-libvips-linux-arm": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.0.tgz", + "integrity": "sha512-VwgD2eEikDJUk09Mn9Dzi1OW2OJFRQK+XlBTkUNmAWPrtj8Ly0yq05DFgu1VCMx2/DqCGQVi5A1dM9hTmxf3uw==", + "optional": true + }, + "@img/sharp-libvips-linux-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.0.tgz", + "integrity": "sha512-xTYThiqEZEZc0PRU90yVtM3KE7lw1bKdnDQ9kCTHWbqWyHOe4NpPOtMGy27YnN51q0J5dqRrvicfPbALIOeAZA==", + "optional": true + }, + "@img/sharp-libvips-linux-s390x": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.0.tgz", + "integrity": "sha512-o9E46WWBC6JsBlwU4QyU9578G77HBDT1NInd+aERfxeOPbk0qBZHgoDsQmA2v9TbqJRWzoBPx1aLOhprBMgPjw==", + "optional": true + }, + "@img/sharp-libvips-linux-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.0.tgz", + "integrity": "sha512-naldaJy4hSVhWBgEjfdBY85CAa4UO+W1nx6a1sWStHZ7EUfNiuBTTN2KUYT5dH1+p/xij1t2QSXfCiFJoC5S/Q==", + "optional": true + }, + "@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.0.tgz", + "integrity": "sha512-OdorplCyvmSAPsoJLldtLh3nLxRrkAAAOHsGWGDYfN0kh730gifK+UZb3dWORRa6EusNqCTjfXV4GxvgJ/nPDQ==", + "optional": true + }, + "@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.0.tgz", + "integrity": "sha512-FW8iK6rJrg+X2jKD0Ajhjv6y74lToIBEvkZhl42nZt563FfxkCYacrXZtd+q/sRQDypQLzY5WdLkVTbJoPyqNg==", + "optional": true + }, + "@img/sharp-linux-arm": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.1.tgz", + "integrity": "sha512-Ii4X1vnzzI4j0+cucsrYA5ctrzU9ciXERfJR633S2r39CiD8npqH2GMj63uFZRCFt3E687IenAdbwIpQOJ5BNA==", + "optional": true, + "requires": { + "@img/sharp-libvips-linux-arm": "1.0.0" + } + }, + "@img/sharp-linux-arm64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.1.tgz", + "integrity": "sha512-59B5GRO2d5N3tIfeGHAbJps7cLpuWEQv/8ySd9109ohQ3kzyCACENkFVAnGPX00HwPTQcaBNF7HQYEfZyZUFfw==", + "optional": true, + "requires": { + "@img/sharp-libvips-linux-arm64": "1.0.0" + } + }, + "@img/sharp-linux-s390x": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.1.tgz", + "integrity": "sha512-tRGrb2pHnFUXpOAj84orYNxHADBDIr0J7rrjwQrTNMQMWA4zy3StKmMvwsI7u3dEZcgwuMMooIIGWEWOjnmG8A==", + "optional": true, + "requires": { + "@img/sharp-libvips-linux-s390x": "1.0.0" + } + }, + "@img/sharp-linux-x64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.1.tgz", + "integrity": "sha512-4y8osC0cAc1TRpy02yn5omBeloZZwS62fPZ0WUAYQiLhSFSpWJfY/gMrzKzLcHB9ulUV6ExFiu2elMaixKDbeg==", + "optional": true, + "requires": { + "@img/sharp-libvips-linux-x64": "1.0.0" + } + }, + "@img/sharp-linuxmusl-arm64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.1.tgz", + "integrity": "sha512-D3lV6clkqIKUizNS8K6pkuCKNGmWoKlBGh5p0sLO2jQERzbakhu4bVX1Gz+RS4vTZBprKlWaf+/Rdp3ni2jLfA==", + "optional": true, + "requires": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.0" + } + }, + "@img/sharp-linuxmusl-x64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.1.tgz", + "integrity": "sha512-LOGKNu5w8uu1evVqUAUKTix2sQu1XDRIYbsi5Q0c/SrXhvJ4QyOx+GaajxmOg5PZSsSnCYPSmhjHHsRBx06/wQ==", + "optional": true, + "requires": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.0" + } + }, + "@img/sharp-wasm32": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.1.tgz", + "integrity": "sha512-vWI/sA+0p+92DLkpAMb5T6I8dg4z2vzCUnp8yvxHlwBpzN8CIcO3xlSXrLltSvK6iMsVMNswAv+ub77rsf25lA==", + "optional": true, + "requires": { + "@emnapi/runtime": "^0.44.0" + } + }, + "@img/sharp-win32-ia32": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.1.tgz", + "integrity": "sha512-/xhYkylsKL05R+NXGJc9xr2Tuw6WIVl2lubFJaFYfW4/MQ4J+dgjIo/T4qjNRizrqs/szF/lC9a5+updmY9jaQ==", + "optional": true + }, + "@img/sharp-win32-x64": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.1.tgz", + "integrity": "sha512-XaM69X0n6kTEsp9tVYYLhXdg7Qj32vYJlAKRutxUsm1UlgQNx6BOhHwZPwukCGXBU2+tH87ip2eV1I/E8MQnZg==", + "optional": true + }, "@jest/create-cache-key-function": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", @@ -15061,6 +15747,15 @@ "mimic-response": "^1.0.0" } }, + "color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "requires": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + } + }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -15074,6 +15769,15 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "requires": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "colorette": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", @@ -15520,6 +16224,11 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" }, + "detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==" + }, "detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -19065,6 +19774,45 @@ "kind-of": "^6.0.2" } }, + "sharp": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.1.tgz", + "integrity": "sha512-iAYUnOdTqqZDb3QjMneBKINTllCJDZ3em6WaWy7NPECM4aHncvqHRm0v0bN9nqJxMiwamv5KIdauJ6lUzKDpTQ==", + "requires": { + "@img/sharp-darwin-arm64": "0.33.1", + "@img/sharp-darwin-x64": "0.33.1", + "@img/sharp-libvips-darwin-arm64": "1.0.0", + "@img/sharp-libvips-darwin-x64": "1.0.0", + "@img/sharp-libvips-linux-arm": "1.0.0", + "@img/sharp-libvips-linux-arm64": "1.0.0", + "@img/sharp-libvips-linux-s390x": "1.0.0", + "@img/sharp-libvips-linux-x64": "1.0.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.0", + "@img/sharp-libvips-linuxmusl-x64": "1.0.0", + "@img/sharp-linux-arm": "0.33.1", + "@img/sharp-linux-arm64": "0.33.1", + "@img/sharp-linux-s390x": "0.33.1", + "@img/sharp-linux-x64": "0.33.1", + "@img/sharp-linuxmusl-arm64": "0.33.1", + "@img/sharp-linuxmusl-x64": "0.33.1", + "@img/sharp-wasm32": "0.33.1", + "@img/sharp-win32-ia32": "0.33.1", + "@img/sharp-win32-x64": "0.33.1", + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "semver": "^7.5.4" + }, + "dependencies": { + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -19109,6 +19857,21 @@ "dev": true, "peer": true }, + "simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "requires": { + "is-arrayish": "^0.3.1" + }, + "dependencies": { + "is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + } + } + }, "simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", diff --git a/package.json b/package.json index a39317f..86b6487 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "node-notifier": "^10.0.1", "node-persist": "^3.1.3", "read": "^3.0.0", + "sharp": "^0.33.1", "splatnet3-types": "^0.2.20231119210145", "supports-color": "^8.1.1", "tslib": "^2.6.2", diff --git a/resources/cli/fonts/opensans-normal-400.ttf b/resources/cli/fonts/opensans-normal-400.ttf new file mode 100644 index 0000000..67803bb Binary files /dev/null and b/resources/cli/fonts/opensans-normal-400.ttf differ diff --git a/resources/cli/fonts/opensans-normal-500.ttf b/resources/cli/fonts/opensans-normal-500.ttf new file mode 100644 index 0000000..ae71693 Binary files /dev/null and b/resources/cli/fonts/opensans-normal-500.ttf differ diff --git a/src/cli/presence-server.ts b/src/cli/presence-server.ts index 2d4e739..b3f0e50 100644 --- a/src/cli/presence-server.ts +++ b/src/cli/presence-server.ts @@ -2,12 +2,13 @@ import * as net from 'node:net'; import * as os from 'node:os'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; +import { createHash } from 'node:crypto'; import express, { Request, Response } from 'express'; import { fetch } from 'undici'; import * as persist from 'node-persist'; import { BankaraMatchSetting_schedule, CoopRule, CoopSetting_schedule, DetailFestRecordDetailResult, DetailVotingStatusResult, FestMatchSetting_schedule, FestRecordResult, FestState, FestTeam_schedule, FestTeam_votingStatus, FestVoteState, Fest_schedule, FriendListResult, FriendOnlineState, Friend_friendList, GraphQLSuccessResponse, KnownRequestId, LeagueMatchSetting_schedule, RegularMatchSetting_schedule, StageScheduleResult, XMatchSetting_schedule } from 'splatnet3-types/splatnet3'; import type { Arguments as ParentArguments } from '../cli.js'; -import { product, version } from '../util/product.js'; +import { git, product, version } from '../util/product.js'; import Users, { CoralUser } from '../common/users.js'; import { Friend } from '../api/coral-types.js'; import SplatNet3Api, { PersistedQueryResult, RequestIdSymbol } from '../api/splatnet3.js'; @@ -22,6 +23,7 @@ import { ArgumentsCamelCase, Argv, YargsArguments } from '../util/yargs.js'; import { getTitleIdFromEcUrl } from '../util/misc.js'; import { getSettingForCoopRule, getSettingForVsMode } from '../discord/monitor/splatoon3.js'; import { CoralApiInterface } from '../api/coral.js'; +import { PresenceEmbedFormat, getUserEmbedOptionsFromRequest, renderUserEmbedImage, renderUserEmbedSvg } from './util/presence-embed.js'; const debug = createDebug('cli:presence-server'); const debugSplatnet3Proxy = createDebug('cli:presence-server:splatnet3-proxy'); @@ -31,7 +33,7 @@ interface AllUsersResult extends Friend { splatoon3?: Friend_friendList | null; splatoon3_fest_team?: (FestTeam_schedule & FestTeam_votingStatus) | null; } -interface PresenceResponse { +export interface PresenceResponse { friend: Friend; title: TitleResult | null; splatoon3?: Friend_friendList | null; @@ -529,7 +531,8 @@ class Server extends HttpServer { app: express.Express; titles = new Map(); - readonly promise_image = new Map>(); + readonly promise_image = new Map>(); constructor( readonly storage: persist.LocalStorage, @@ -565,6 +568,18 @@ class Server extends HttpServer { app.get('/api/presence/:user/events', this.createApiRequestHandler((req, res) => this.handlePresenceStreamRequest(req, res, req.params.user))); + app.get('/api/presence/:user/image', this.createApiRequestHandler((req, res) => + this.handleUserImageRequest(req, res, req.params.user))); + + app.get('/api/presence/:user/embed', this.createApiRequestHandler((req, res) => + this.handlePresenceEmbedRequest(req, res, req.params.user, PresenceEmbedFormat.SVG))); + app.get('/api/presence/:user/embed.png', this.createApiRequestHandler((req, res) => + this.handlePresenceEmbedRequest(req, res, req.params.user, PresenceEmbedFormat.PNG))); + app.get('/api/presence/:user/embed.jpeg', this.createApiRequestHandler((req, res) => + this.handlePresenceEmbedRequest(req, res, req.params.user, PresenceEmbedFormat.JPEG))); + app.get('/api/presence/:user/embed.webp', this.createApiRequestHandler((req, res) => + this.handlePresenceEmbedRequest(req, res, req.params.user, PresenceEmbedFormat.WEBP))); + if (image_proxy_path?.baas) { this.image_proxy_path_baas = image_proxy_path.baas; app.use('/api/presence/resources/baas', express.static(this.image_proxy_path_baas, {redirect: false})); @@ -1097,6 +1112,55 @@ class Server extends HttpServer { } } + async handleUserImageRequest(req: Request, res: Response, presence_user_nsaid: string) { + res.setHeader('Access-Control-Allow-Origin', '*'); + + const result = await this.handlePresenceRequest(req, null, presence_user_nsaid); + + const url_map = await this.downloadImages({ + url: result.friend.imageUri, + }, this.getResourceBaseUrls(req)); + + const image_url = url_map[result.friend.imageUri]; + + res.statusCode = 303; + res.setHeader('Location', image_url); + res.setHeader('Content-Type', 'text/plain'); + res.write('Redirecting to ' + image_url + '\n'); + res.end(); + } + + async handlePresenceEmbedRequest(req: Request, res: Response, presence_user_nsaid: string, format = PresenceEmbedFormat.SVG) { + res.setHeader('Access-Control-Allow-Origin', '*'); + + const result = await this.handlePresenceRequest(req, null, presence_user_nsaid); + + const {theme, friend_code, transparent} = getUserEmbedOptionsFromRequest(req); + + const etag = createHash('sha256').update(JSON.stringify({ + result, + theme, + friend_code, + transparent, + v: version + '-' + git?.revision, + })).digest('base64url'); + + if (req.headers['if-none-match'] === '"' + etag + '"' || req.headers['if-none-match'] === 'W/"' + etag + '"') { + res.statusCode = 304; + res.end(); + return; + } + + const url_map = await this.downloadImages(result, this.getResourceBaseUrls(req)); + + const svg = renderUserEmbedSvg(result, url_map, theme, friend_code); + const [image, type] = await renderUserEmbedImage(svg, format); + + res.setHeader('Content-Type', type); + res.setHeader('Etag', '"' + etag + '"'); + res.end(image); + } + async handleSplatNet3ProxyFriends(req: Request, res: Response) { if (!this.enable_splatnet3_proxy) throw new ResponseError(403, 'forbidden'); @@ -1154,6 +1218,37 @@ class Server extends HttpServer { atum: string | null; splatnet3: string | null; }): Promise> { + const image_urls = this.getImageUrls(data, base_url); + const url_map: Record = {}; + + await Promise.all(image_urls.map(async ([url, dir, base_url]) => { + url_map[url] = new URL(await this.downloadImage(url, dir), base_url).toString(); + })); + + return url_map; + } + + async getImages(data: unknown, base_url: { + baas: string | null; + atum: string | null; + splatnet3: string | null; + }): Promise> { + const image_urls = this.getImageUrls(data, base_url); + const url_map: Record = {}; + + await Promise.all(image_urls.map(async ([url, dir, base_url]) => { + const [name, data, type] = await this.downloadImage(url, dir, true); + url_map[url] = [new URL(name, base_url).toString(), data, type]; + })); + + return url_map; + } + + getImageUrls(data: unknown, base_url: { + baas: string | null; + atum: string | null; + splatnet3: string | null; + }) { const image_urls: [url: string, dir: string, base_url: string][] = []; // Use JSON.stringify to iterate over everything in the response @@ -1185,13 +1280,7 @@ class Server extends HttpServer { return value; }); - const url_map: Record = {}; - - await Promise.all(image_urls.map(async ([url, dir, base_url]) => { - url_map[url] = new URL(await this.downloadImage(url, dir), base_url).toString(); - })); - - return url_map; + return image_urls; } getResourceBaseUrls(req: Request) { @@ -1206,7 +1295,10 @@ class Server extends HttpServer { }; } - downloadImage(url: string, dir: string) { + downloadImage(url: string, dir: string, return_image_data: true): Promise + downloadImage(url: string, dir: string, return_image_data?: false): Promise + downloadImage(url: string, dir: string, return_image_data?: boolean): Promise + downloadImage(url: string, dir: string, return_image_data?: boolean) { const pathname = new URL(url).pathname; const name = pathname.substr(1).toLowerCase() .replace(/^resources\//g, '') @@ -1215,6 +1307,11 @@ class Server extends HttpServer { const promise = this.promise_image.get(dir + '/' + name) ?? Promise.resolve().then(async () => { try { + if (return_image_data) { + const data = await fs.readFile(path.join(dir, name)); + return [name, data, 'image/jpeg'] as const; + } + await fs.stat(path.join(dir, name)); return name; } catch (err) {} @@ -1230,6 +1327,10 @@ class Server extends HttpServer { debug('Downloaded image %s', name); + if (return_image_data) { + return [name, data, 'image/jpeg'] as const; + } + return name; }).then(result => { this.promise_image.delete(dir + '/' + name); diff --git a/src/cli/util/index.ts b/src/cli/util/index.ts index f9ffe9b..6857546 100644 --- a/src/cli/util/index.ts +++ b/src/cli/util/index.ts @@ -5,3 +5,5 @@ export * as discordActivity from './discord-activity.js'; export * as discordRpc from './discord-rpc.js'; export * as remoteConfig from './remote-config.js'; export * as storage from './storage.js'; +export * as presenceEmbedRender from './presence-embed-render.js'; +export * as presenceEmbedServer from './presence-embed-server.js'; diff --git a/src/cli/util/presence-embed-render.ts b/src/cli/util/presence-embed-render.ts new file mode 100644 index 0000000..26b88ea --- /dev/null +++ b/src/cli/util/presence-embed-render.ts @@ -0,0 +1,78 @@ +import type { Arguments as ParentArguments } from '../util.js'; +import createDebug from '../../util/debug.js'; +import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; +import { getPresenceFromUrl } from '../../api/znc-proxy.js'; +import { PresenceResponse } from '../presence-server.js'; +import { PresenceEmbedFormat, PresenceEmbedTheme, renderUserEmbedImage, renderUserEmbedSvg } from './presence-embed.js'; + +const debug = createDebug('cli:util:render-presence-embed'); + +export const command = 'render-presence-embed '; +export const desc = 'Render presence embed'; + +export function builder(yargs: Argv) { + return yargs.positional('url', { + describe: 'Presence URL', + type: 'string', + demandOption: true, + }).option('output', { + describe: 'Output (svg, png, jpeg or webp)', + type: 'string', + default: 'svg', + }).option('theme', { + describe: 'Theme (light or dark)', + type: 'string', + default: 'light', + }).option('friend-code', { + describe: 'Friend code', + type: 'string', + }).option('scale', { + describe: 'Image scale', + type: 'number', + default: 1, + }).option('transparent', { + describe: 'Remove border and use transparent background', + type: 'boolean', + default: false, + }); +} + +type Arguments = YargsArguments>; + +export async function handler(argv: ArgumentsCamelCase) { + const theme = argv.theme === 'dark' ? PresenceEmbedTheme.DARK : PresenceEmbedTheme.LIGHT; + const format = + argv.output === 'png' ? PresenceEmbedFormat.PNG : + argv.output === 'jpeg' ? PresenceEmbedFormat.JPEG : + argv.output === 'webp' ? PresenceEmbedFormat.WEBP : + PresenceEmbedFormat.SVG; + + if (argv.friendCode && !argv.friendCode.match(/^\d{4}-\d{4}-\d{4}$/)) { + throw new TypeError('Invalid friend code'); + } + + const [presence, user, data] = await getPresenceFromUrl(argv.url); + const result = data as PresenceResponse; + + const image_urls = [result.friend.imageUri]; + + if ('imageUri' in result.friend.presence.game) image_urls.push(result.friend.presence.game.imageUri); + + const url_map: Record = {}; + + debug('images', image_urls); + + await Promise.all(image_urls.map(async (url) => { + debug('Fetching image %s', url); + const response = await fetch(url); + const data = new Uint8Array(await response.arrayBuffer()); + + url_map[url] = [url, data, 'image/jpeg']; + })); + + const svg = renderUserEmbedSvg(result, url_map, theme, argv.friendCode, argv.scale, argv.transparent); + const [image, type] = await renderUserEmbedImage(svg, format); + + console.warn('output type', type); + process.stdout.write(image); +} diff --git a/src/cli/util/presence-embed-server.ts b/src/cli/util/presence-embed-server.ts new file mode 100644 index 0000000..83b299d --- /dev/null +++ b/src/cli/util/presence-embed-server.ts @@ -0,0 +1,178 @@ +import * as os from 'node:os'; +import * as net from 'node:net'; +import express, { Request, Response } from 'express'; +import { createHash } from 'node:crypto'; +import type { Arguments as ParentArguments } from '../util.js'; +import createDebug from '../../util/debug.js'; +import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; +import { getPresenceFromUrl } from '../../api/znc-proxy.js'; +import { PresenceResponse } from '../presence-server.js'; +import { addCliFeatureUserAgent } from '../../util/useragent.js'; +import { HttpServer, ResponseError } from './http-server.js'; +import { git, product, version } from '../../util/product.js'; +import { parseListenAddress } from '../../util/net.js'; +import { RawValueSymbol, htmlentities } from '../../util/misc.js'; +import { PresenceEmbedFormat, PresenceEmbedTheme, getUserEmbedOptionsFromRequest, renderUserEmbedImage, renderUserEmbedSvg } from './presence-embed.js'; + +const debug = createDebug('cli:util:presence-embed-server'); + +export const command = 'presence-embed-server '; +export const desc = 'Presence embed test server'; + +export function builder(yargs: Argv) { + return yargs.positional('url', { + describe: 'Presence URL', + type: 'string', + demandOption: true, + }).option('listen', { + describe: 'Server address and port', + type: 'array', + default: ['[::]:0'], + }); +} + +type Arguments = YargsArguments>; + +export async function handler(argv: ArgumentsCamelCase) { + addCliFeatureUserAgent('presence-embed-test-server'); + + const server = new Server(argv.url); + const app = server.app; + + for (const address of argv.listen) { + const [host, port] = parseListenAddress(address); + const server = app.listen(port, host ?? '::'); + server.on('listening', () => { + const address = server.address() as net.AddressInfo; + console.log('Listening on %s, port %d', address.address, address.port); + }); + } +} + +class Server extends HttpServer { + app: express.Express; + + constructor( + readonly base_url: string, + ) { + super(); + + const app = this.app = express(); + + app.use('/api/presence', (req, res, next) => { + console.log('[%s] %s %s HTTP/%s from %s, port %d%s, %s', + new Date(), req.method, req.url, req.httpVersion, + req.socket.remoteAddress, req.socket.remotePort, + req.headers['x-forwarded-for'] ? ' (' + req.headers['x-forwarded-for'] + ')' : '', + req.headers['user-agent']); + + res.setHeader('Server', product + ' presence-embed-test-server'); + res.setHeader('X-Server', product + ' presence-embed-test-server'); + res.setHeader('X-Served-By', os.hostname()); + + next(); + }); + + app.get('/api/presence/:user/embed', this.createApiRequestHandler((req, res) => + this.handlePresenceEmbedRequest(req, res, req.params.user, PresenceEmbedFormat.SVG))); + app.get('/api/presence/:user/embed.png', this.createApiRequestHandler((req, res) => + this.handlePresenceEmbedRequest(req, res, req.params.user, PresenceEmbedFormat.PNG))); + app.get('/api/presence/:user/embed.jpeg', this.createApiRequestHandler((req, res) => + this.handlePresenceEmbedRequest(req, res, req.params.user, PresenceEmbedFormat.JPEG))); + app.get('/api/presence/:user/embed.webp', this.createApiRequestHandler((req, res) => + this.handlePresenceEmbedRequest(req, res, req.params.user, PresenceEmbedFormat.WEBP))); + + app.get('/api/presence/:user/embed.html', this.createApiRequestHandler((req, res) => + this.handlePresenceEmbedHtmlRequest(req, res, req.params.user))); + } + + async handlePresenceEmbedRequest(req: Request, res: Response, presence_user_nsaid: string, format = PresenceEmbedFormat.SVG) { + if (!presence_user_nsaid.match(/^[0-9a-f]{16}$/)) throw new ResponseError(404, 'not_found'); + + res.setHeader('Access-Control-Allow-Origin', '*'); + + const url = new URL(req.url, 'https://localhost'); + url.searchParams.delete('theme'); + url.searchParams.delete('friend-code'); + url.searchParams.delete('transparent'); + const qs = url.searchParams.size ? '?' + url.searchParams.toString() : ''; + + const [presence, user, data] = await getPresenceFromUrl(this.base_url + '/' + presence_user_nsaid + qs); + const result = data as PresenceResponse; + + const {theme, friend_code, transparent} = getUserEmbedOptionsFromRequest(req); + + const etag = createHash('sha256').update(JSON.stringify({ + data, + format, + theme, + friend_code, + transparent, + v: version + '-' + git?.revision, + })).digest('base64url'); + + if (req.headers['if-none-match'] === '"' + etag + '"' || req.headers['if-none-match'] === 'W/"' + etag + '"') { + res.statusCode = 304; + res.end(); + return; + } + + const image_urls = [result.friend.imageUri]; + + if ('imageUri' in result.friend.presence.game) image_urls.push(result.friend.presence.game.imageUri); + + const url_map: Record = {}; + + await Promise.all(image_urls.map(async (url) => { + debug('Fetching image %s', url); + const response = await fetch(url); + const data = new Uint8Array(await response.arrayBuffer()); + + url_map[url] = [url, data, 'image/jpeg']; + })); + + const svg = renderUserEmbedSvg(result, url_map, theme, friend_code, 1, transparent); + const [image, type] = await renderUserEmbedImage(svg, format); + + res.setHeader('Content-Type', type); + res.setHeader('Etag', '"' + etag + '"'); + res.end(image); + } + + async handlePresenceEmbedHtmlRequest(req: Request, res: Response, presence_user_nsaid: string) { + if (!presence_user_nsaid.match(/^[0-9a-f]{16}$/)) throw new ResponseError(404, 'not_found'); + + const url = new URL(req.url, 'https://localhost'); + url.searchParams.delete('theme'); + const qs = url.searchParams.size ? '&' + url.searchParams.toString() : ''; + + const url_2 = new URL(req.url, 'https://localhost'); + url_2.searchParams.delete('theme'); + url_2.searchParams.delete('friend-code'); + const qs_2 = url_2.searchParams.size ? '?' + url_2.searchParams.toString() : ''; + + const [presence, user, data] = await getPresenceFromUrl(this.base_url + '/' + presence_user_nsaid + qs_2); + const result = data as PresenceResponse; + + const image_urls = [result.friend.imageUri]; + + if ('imageUri' in result.friend.presence.game) image_urls.push(result.friend.presence.game.imageUri); + + const url_map: Record = {}; + + await Promise.all(image_urls.map(async (url) => { + debug('Fetching image %s', url); + const response = await fetch(url); + const data = new Uint8Array(await response.arrayBuffer()); + + url_map[url] = [url, data, 'image/jpeg']; + })); + + const svg = renderUserEmbedSvg(result, url_map, PresenceEmbedTheme.LIGHT, undefined, 1, true); + + res.setHeader('Content-Type', 'text/html'); + res.write(``); + res.write(htmlentities`Nintendo Switch presence

${{[RawValueSymbol]: svg}}

\n`); + res.end(); + } +} diff --git a/src/cli/util/presence-embed.ts b/src/cli/util/presence-embed.ts new file mode 100644 index 0000000..f1e736a --- /dev/null +++ b/src/cli/util/presence-embed.ts @@ -0,0 +1,220 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { Request } from 'express'; +import sharp from 'sharp'; +import { FriendOnlineState } from 'splatnet3-types/splatnet3'; +import { dir } from '../../util/product.js'; +import createDebug from '../../util/debug.js'; +import { Game, PresenceState } from '../../api/coral-types.js'; +import { RawValueSymbol, htmlentities } from '../../util/misc.js'; +import { PresenceResponse } from '../presence-server.js'; + +const debug = createDebug('cli:util:presence-embed'); + +export enum PresenceEmbedFormat { + SVG, + PNG, + JPEG, + WEBP, +} + +export enum PresenceEmbedTheme { + LIGHT, + DARK, +} +interface PresenceEmbedThemeColours { + background: string; + separator: string; + text: string; + online: string; + online_border: string; +} + +const embed_themes: Record = { + [PresenceEmbedTheme.LIGHT]: { + background: '#ebebeb', + separator: '#7b7b7b', + text: '#000000', + online: '#2db742', + online_border: '#0eb728', + }, + [PresenceEmbedTheme.DARK]: { + background: '#2d2d2d', + separator: '#7e7e7e', + text: '#ffffff', + online: '#47e85f', + online_border: '#19e838', + }, +}; + +const embed_titles: Partial, + image: (url: string) => string | undefined, + theme?: PresenceEmbedTheme, +) => readonly [svg: string, height: number]>> = { + '0100c2500fc20000': renderUserSplatoon3EmbedPartialSvg, +}; + +export function getUserEmbedOptionsFromRequest(req: Request) { + const url = new URL(req.url, 'https://localhost'); + + const theme = url.searchParams.get('theme') === 'dark' ? PresenceEmbedTheme.DARK : PresenceEmbedTheme.LIGHT; + const friend_code = url.searchParams.getAll('friend-code').find(c => c.match(/^\d{4}-\d{4}-\d{4}$/)); + const transparent = url.searchParams.get('transparent') === '1'; + + return {theme, friend_code, transparent}; +} + +export async function renderUserEmbedImage( + svg: string, + format: PresenceEmbedFormat, +): Promise<[data: Buffer, type: string]> { + if (format === PresenceEmbedFormat.SVG) { + return [Buffer.from(svg), 'image/svg+xml']; + } + + const start = Date.now(); + debug('generating image, format %s', PresenceEmbedFormat[format]); + + let image = sharp(Buffer.from(svg)).withMetadata({ + density: 72 * 2, + }); + + if (format === PresenceEmbedFormat.PNG) image = image.png(); + if (format === PresenceEmbedFormat.JPEG) image = image.jpeg(); + if (format === PresenceEmbedFormat.WEBP) image = image.webp(); + + const data = await image.toBuffer(); + + debug('generated %s in %d ms', PresenceEmbedFormat[format], Date.now() - start); + + if (format === PresenceEmbedFormat.PNG) return [data, 'image/png']; + if (format === PresenceEmbedFormat.JPEG) return [data, 'image/jpeg']; + if (format === PresenceEmbedFormat.WEBP) return [data, 'image/webp']; + + throw new TypeError('Invalid format'); +} + +export function renderUserEmbedSvg( + result: PresenceResponse, + url_map: Record, + theme = PresenceEmbedTheme.LIGHT, + friend_code?: string, + scale = 1, + transparent = false, +) { + let width = 500; + let height = 180; + if (friend_code) height += 40; + + const colours = embed_themes[theme]; + const font_family = '\'Open Sans\', -apple-system, BlinkMacSystemFont, Arial, sans-serif'; + + const state = result.friend.presence.state; + const game = 'name' in result.friend.presence.game ? result.friend.presence.game : null; + + const image = (url: string) => + url_map[url] instanceof Array ? + 'data:' + url_map[url][2] + ';base64,' + + Buffer.from(url_map[url][1]).toString('base64') : + url_map[url] as string | undefined; + + const title_extra = result.title ? embed_titles[result.title.id]?.call(null, result, url_map, image, theme) : null; + if (title_extra) height += title_extra[1]; + + return htmlentities` + + + + + + + + + + + + + + + + + ${{[RawValueSymbol]: transparent ? '' : htmlentities` + + `}} + + + ${result.friend.name} + + + + ${{[RawValueSymbol]: game && (state === PresenceState.ONLINE || state === PresenceState.PLAYING) ? htmlentities` + + + ${{[RawValueSymbol]: renderUserTitleEmbedPartialSvg(game, colours, font_family)}} + ` : htmlentities` + Offline + `}} + + ${{[RawValueSymbol]: friend_code ? htmlentities` + Friend code: SW-${friend_code} + ` : ''}} + + ${{[RawValueSymbol]: title_extra?.[0] ?? ''}} + +`; +} + +function renderUserTitleEmbedPartialSvg(game: Game, colours: PresenceEmbedThemeColours, font_family: string) { + const playing_text_offset = game.sysDescription ? 92 : 97; + const title_name_text_offset = game.sysDescription ? 122 : 133; + + return htmlentities` + + Playing + + ${game.name} + + ${game.sysDescription} + `; +} + +function renderUserSplatoon3EmbedPartialSvg( + result: PresenceResponse, + url_map: Record, + image: (url: string) => string | undefined, + theme = PresenceEmbedTheme.LIGHT, +) { + return ['', 0] as const; +} + +const embed_fonts: [name: string, style: string, weight: string, files: [format: string, type: string, path: string][]][] = [ + ['Open Sans', 'normal', '400', [['opentype', 'font/ttf', 'opensans-normal-400.ttf']]], + ['Open Sans', 'normal', '500', [['opentype', 'font/ttf', 'opensans-normal-500.ttf']]], +]; + +const embed_style = ` +text { + -webkit-user-select: none; + user-select: none; +} + +` + (await Promise.all(embed_fonts.map(async ([name, style, weight, files]) => `@font-face { + font-family: '${name}'; + font-style: ${style}; + font-weight: ${weight}; + src: ${(await Promise.all(files.map(async ([format, type, file]) => `url('data:${type};base64,${ + (await fs.readFile(path.join(dir, 'resources', 'cli', 'fonts', file))).toString('base64') + }') format('${format}')`))).join(',')}; +}`))).join('\n'); diff --git a/src/util/misc.ts b/src/util/misc.ts index f984065..18663a6 100644 --- a/src/util/misc.ts +++ b/src/util/misc.ts @@ -40,3 +40,14 @@ export function timeoutSignal(ms = 60 * 1000) { return [controller.signal, () => clearTimeout(timeout), controller] as const; } + +export const RawValueSymbol = Symbol('RawValue'); +export type RawValue = {[RawValueSymbol]: string}; + +export function htmlentities(strings: TemplateStringsArray, ...args: (string | number | RawValue)[]): string { + return strings.map((s, i) => s + (args[i] ? ( + typeof args[i] === 'object' && RawValueSymbol in (args[i] as object) ? + (args[i] as RawValue)[RawValueSymbol] : + args[i].toString().replace(/[\u00A0-\u9999<>\&]/gim, c => '&#' + c.charCodeAt(0) + ';') + ) : '')).join(''); +}