Initial commit
What is version control? LOL
This commit is contained in:
commit
814448dbc9
21
.env.example
Normal file
21
.env.example
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
PORT="3000"
|
||||
|
||||
DB_HOST=
|
||||
DB_PORT=
|
||||
DB_USERNAME=
|
||||
DB_PASSWORD=
|
||||
DB_NAME=
|
||||
|
||||
ISSUER=
|
||||
BS_SITE_URL=
|
||||
SHARED_CLIENT_ID=
|
||||
|
||||
TOKEN_EXPIRES_IN_1="259200"
|
||||
TOKEN_EXPIRES_IN_2="604800"
|
||||
DEVICE_CODE_EXPIRES_IN="600"
|
||||
GRANT_EXPIRES_IN="25920000"
|
||||
|
||||
# STOP HERE! You have completed all necessary editing.
|
||||
# DO NOT edit the following lines UNLESS you know what you're doing.
|
||||
|
||||
DB_CONNECTION_STRING="mysql://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||
62
.gitignore
vendored
Normal file
62
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/build
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Prisma Schema
|
||||
prisma/schema.prisma
|
||||
|
||||
# Signing Key
|
||||
oauth-private.key
|
||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
16
.vscode/launch.json
vendored
Normal file
16
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"name": "Run Script: start:debug",
|
||||
"request": "launch",
|
||||
"command": "npm run start:debug",
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
21
LICESNE
Normal file
21
LICESNE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025-present Blessing Skin Team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
28
README.md
Normal file
28
README.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Janus for Blessing Skin
|
||||
|
||||
Janus 是 [Blessing Skin Server](https://github.com/bs-community/blessing-skin-server) 的外挂 Yggdrasil Connect 服务端,基于 [NestJS](https://nestjs.com) 框架和 [oidc-provider](https://github.com/panva/node-oidc-provider) 库编写。
|
||||
|
||||
由于 Laravel 框架缺乏合适的 OpenID Connect 服务端扩展包,故采取这种外挂 OpenID Connect 服务端的方式为 Blessing Skin Server 实现 Yggdrasil Connect。
|
||||
|
||||
Janus 需要与 Blessing Skin Server 使用同一个 MySQL/MariaDB 数据库。不支持 PostgreSQL 和 SQLite 数据库。
|
||||
|
||||
## 环境需求
|
||||
|
||||
- Node.js >= 22
|
||||
- 更低版本或许也可以,但未经测试
|
||||
- Blessing Skin Server >= 6
|
||||
- 需要安装 [Yggdrasil Connect](https://github.com/bs-community/blessing-skin-plugins/blob/master/plugins/yggdrasil-connect) 插件,可在插件市场中下载
|
||||
- 该插件不需要也不可以与原版 Yggdrasil API 插件同时启用,但插件数据可以通用
|
||||
- 在安装完该插件后,请务必阅读该插件的 [README](https://github.com/bs-community/blessing-skin-plugins/blob/master/plugins/yggdrasil-connect/README.md),了解如何配置该插件
|
||||
|
||||
## 部署指南
|
||||
|
||||
请查看 [Wiki - 部署指南](https://github.com/bs-community/janus/wiki/%E9%83%A8%E7%BD%B2%E6%8C%87%E5%8D%97)。
|
||||
|
||||
Janus **不是** 开箱即用的,需要手动构建。部署 Janus 并不难,但最好有一定的运维经验。
|
||||
|
||||
## 版权信息
|
||||
|
||||
Copyright 2025-present Blessing Skin Team. All rights reserved. Open source under the MIT license.
|
||||
|
||||
_Disclaimer:某站产品经理自己写代码的原则就是代码和人有一个能跑就行,自然有些代码很粗糙很难看很低效。如果你看着哪里的代码不爽,欢迎直接重构并 PR。_
|
||||
34
eslint.config.mjs
Normal file
34
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn'
|
||||
},
|
||||
},
|
||||
);
|
||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
12862
package-lock.json
generated
Normal file
12862
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
80
package.json
Normal file
80
package.json
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
{
|
||||
"name": "janus",
|
||||
"version": "1.0.0",
|
||||
"description": "Standalone Yggdrasil Connect server for Blessing Skin.",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@prisma/client": "^6.10.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"nestjs-prisma": "^0.25.0",
|
||||
"oidc-provider": "^9.1.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@swc/cli": "^0.6.0",
|
||||
"@swc/core": "^1.10.7",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/oidc-provider": "^9.1.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^6.10.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
333
prisma/migrations/0_init/migration.sql
Normal file
333
prisma/migrations/0_init/migration.sql
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE `code_id_to_uuid` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`code_id` VARCHAR(255) NOT NULL,
|
||||
`uuid` VARCHAR(255) NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
|
||||
UNIQUE INDEX `code_id_to_uuid_code_id_unique`(`code_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `jobs` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`queue` VARCHAR(255) NOT NULL,
|
||||
`payload` LONGTEXT NOT NULL,
|
||||
`attempts` TINYINT UNSIGNED NOT NULL,
|
||||
`reserved_at` INTEGER UNSIGNED NULL,
|
||||
`available_at` INTEGER UNSIGNED NOT NULL,
|
||||
`created_at` INTEGER UNSIGNED NOT NULL,
|
||||
|
||||
INDEX `jobs_queue_index`(`queue`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `language_lines` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`group` VARCHAR(255) NOT NULL,
|
||||
`key` VARCHAR(255) NOT NULL,
|
||||
`text` TEXT NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NULL,
|
||||
`updated_at` TIMESTAMP(0) NULL,
|
||||
|
||||
INDEX `language_lines_group_index`(`group`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `migrations` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`migration` VARCHAR(255) NOT NULL,
|
||||
`batch` INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `notifications` (
|
||||
`id` CHAR(36) NOT NULL,
|
||||
`type` VARCHAR(255) NOT NULL,
|
||||
`notifiable_type` VARCHAR(255) NOT NULL,
|
||||
`notifiable_id` BIGINT UNSIGNED NOT NULL,
|
||||
`data` TEXT NOT NULL,
|
||||
`read_at` TIMESTAMP(0) NULL,
|
||||
`created_at` TIMESTAMP(0) NULL,
|
||||
`updated_at` TIMESTAMP(0) NULL,
|
||||
|
||||
INDEX `notifications_notifiable_type_notifiable_id_index`(`notifiable_type`, `notifiable_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `oauth_access_tokens` (
|
||||
`id` VARCHAR(100) NOT NULL,
|
||||
`user_id` BIGINT UNSIGNED NULL,
|
||||
`client_id` BIGINT UNSIGNED NOT NULL,
|
||||
`name` VARCHAR(255) NULL,
|
||||
`scopes` TEXT NULL,
|
||||
`revoked` BOOLEAN NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NULL,
|
||||
`updated_at` TIMESTAMP(0) NULL,
|
||||
`expires_at` DATETIME(0) NULL,
|
||||
|
||||
INDEX `oauth_access_tokens_user_id_index`(`user_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `oauth_auth_codes` (
|
||||
`id` VARCHAR(100) NOT NULL,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`client_id` BIGINT UNSIGNED NOT NULL,
|
||||
`scopes` TEXT NULL,
|
||||
`revoked` BOOLEAN NOT NULL,
|
||||
`expires_at` DATETIME(0) NULL,
|
||||
|
||||
INDEX `oauth_auth_codes_user_id_index`(`user_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `oauth_clients` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`secret` VARCHAR(100) NULL,
|
||||
`provider` VARCHAR(255) NULL,
|
||||
`redirect` TEXT NOT NULL,
|
||||
`personal_access_client` BOOLEAN NOT NULL,
|
||||
`password_client` BOOLEAN NOT NULL,
|
||||
`revoked` BOOLEAN NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NULL,
|
||||
`updated_at` TIMESTAMP(0) NULL,
|
||||
|
||||
INDEX `oauth_clients_user_id_index`(`user_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `oauth_personal_access_clients` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`client_id` BIGINT UNSIGNED NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NULL,
|
||||
`updated_at` TIMESTAMP(0) NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `oauth_refresh_tokens` (
|
||||
`id` VARCHAR(100) NOT NULL,
|
||||
`access_token_id` VARCHAR(100) NOT NULL,
|
||||
`revoked` BOOLEAN NOT NULL,
|
||||
`expires_at` DATETIME(0) NULL,
|
||||
|
||||
INDEX `oauth_refresh_tokens_access_token_id_index`(`access_token_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `options` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`option_name` VARCHAR(50) NOT NULL,
|
||||
`option_value` LONGTEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `players` (
|
||||
`pid` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uid` INTEGER NOT NULL,
|
||||
`name` VARCHAR(50) NOT NULL,
|
||||
`tid_cape` INTEGER NOT NULL DEFAULT 0,
|
||||
`last_modified` DATETIME(0) NOT NULL,
|
||||
`tid_skin` INTEGER NOT NULL DEFAULT -1,
|
||||
|
||||
PRIMARY KEY (`pid`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `reports` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tid` INTEGER NOT NULL,
|
||||
`uploader` INTEGER NOT NULL,
|
||||
`reporter` INTEGER NOT NULL,
|
||||
`reason` LONGTEXT NOT NULL,
|
||||
`status` INTEGER NOT NULL,
|
||||
`report_at` DATETIME(0) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `scopes` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`description` VARCHAR(255) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `scopes_name_unique`(`name`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `textures` (
|
||||
`tid` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(50) NOT NULL,
|
||||
`type` VARCHAR(10) NOT NULL,
|
||||
`hash` VARCHAR(64) NOT NULL,
|
||||
`size` INTEGER NOT NULL,
|
||||
`uploader` INTEGER NOT NULL,
|
||||
`public` TINYINT NOT NULL,
|
||||
`upload_at` DATETIME(0) NOT NULL,
|
||||
`likes` INTEGER UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
PRIMARY KEY (`tid`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `user_closet` (
|
||||
`user_uid` INTEGER NOT NULL,
|
||||
`texture_tid` INTEGER NOT NULL,
|
||||
`item_name` TEXT NULL
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `users` (
|
||||
`uid` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`email` VARCHAR(100) NOT NULL,
|
||||
`nickname` VARCHAR(50) NOT NULL DEFAULT '',
|
||||
`locale` VARCHAR(255) NULL,
|
||||
`score` INTEGER NOT NULL,
|
||||
`avatar` INTEGER NOT NULL DEFAULT 0,
|
||||
`password` VARCHAR(255) NOT NULL,
|
||||
`ip` VARCHAR(45) NOT NULL,
|
||||
`is_dark_mode` BOOLEAN NOT NULL DEFAULT false,
|
||||
`permission` INTEGER NOT NULL DEFAULT 0,
|
||||
`last_sign_at` DATETIME(0) NOT NULL,
|
||||
`register_at` DATETIME(0) NOT NULL,
|
||||
`verified` BOOLEAN NOT NULL DEFAULT false,
|
||||
`verification_token` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`remember_token` VARCHAR(100) NULL,
|
||||
|
||||
PRIMARY KEY (`uid`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `uuid` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`pid` INTEGER UNSIGNED NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`uuid` VARCHAR(255) NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NULL,
|
||||
`updated_at` TIMESTAMP(0) NULL,
|
||||
|
||||
UNIQUE INDEX `uuid_pid_unique`(`pid`),
|
||||
UNIQUE INDEX `uuid_name_unique`(`name`),
|
||||
UNIQUE INDEX `uuid_uuid_unique`(`uuid`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `ygg_log` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`action` VARCHAR(255) NOT NULL,
|
||||
`user_id` INTEGER NOT NULL,
|
||||
`player_id` INTEGER NOT NULL,
|
||||
`parameters` VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
`ip` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`time` DATETIME(0) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `yggc_authorization_codes` (
|
||||
`id` VARCHAR(255) NOT NULL,
|
||||
`payload` JSON NOT NULL,
|
||||
`uid` VARCHAR(255) NULL,
|
||||
`consumed` BOOLEAN NOT NULL DEFAULT false,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `yggc_authorization_codes_id_key`(`id`),
|
||||
UNIQUE INDEX `yggc_authorization_codes_uid_key`(`uid`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `yggc_device_codes` (
|
||||
`id` VARCHAR(255) NOT NULL,
|
||||
`payload` JSON NOT NULL,
|
||||
`userCode` VARCHAR(191) NULL,
|
||||
`uid` VARCHAR(255) NULL,
|
||||
`consumed` BOOLEAN NOT NULL DEFAULT false,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `yggc_device_codes_id_key`(`id`),
|
||||
UNIQUE INDEX `yggc_device_codes_uid_key`(`uid`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `yggc_refresh_tokens` (
|
||||
`id` VARCHAR(255) NOT NULL,
|
||||
`payload` JSON NOT NULL,
|
||||
`uid` VARCHAR(255) NULL,
|
||||
`consumed` BOOLEAN NOT NULL DEFAULT false,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `yggc_refresh_tokens_id_key`(`id`),
|
||||
UNIQUE INDEX `yggc_refresh_tokens_uid_key`(`uid`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `yggc_grants` (
|
||||
`id` VARCHAR(255) NOT NULL,
|
||||
`payload` JSON NOT NULL,
|
||||
`uid` VARCHAR(255) NULL,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `yggc_grants_id_key`(`id`),
|
||||
UNIQUE INDEX `yggc_grants_uid_key`(`uid`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `yggc_interactions` (
|
||||
`id` VARCHAR(255) NOT NULL,
|
||||
`payload` JSON NOT NULL,
|
||||
`uid` VARCHAR(255) NULL,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `yggc_interactions_id_key`(`id`),
|
||||
UNIQUE INDEX `yggc_interactions_uid_key`(`uid`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `yggc_sessions` (
|
||||
`id` VARCHAR(255) NOT NULL,
|
||||
`payload` JSON NOT NULL,
|
||||
`uid` VARCHAR(255) NULL,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `yggc_sessions_id_key`(`id`),
|
||||
UNIQUE INDEX `yggc_sessions_uid_key`(`uid`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `code_id_to_uuid` ADD CONSTRAINT `code_id_to_uuid_code_id_foreign` FOREIGN KEY (`code_id`) REFERENCES `oauth_auth_codes`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `uuid` ADD CONSTRAINT `uuid_pid_foreign` FOREIGN KEY (`pid`) REFERENCES `players`(`pid`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
81
prisma/migrations/20250618204012_init_janus/migration.sql
Normal file
81
prisma/migrations/20250618204012_init_janus/migration.sql
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE `yggc_authorization_codes` (
|
||||
`id` VARCHAR(255) NOT NULL,
|
||||
`payload` JSON NOT NULL,
|
||||
`uid` VARCHAR(255) NULL,
|
||||
`consumed` BOOLEAN NOT NULL DEFAULT false,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `yggc_authorization_codes_id_key`(`id`),
|
||||
UNIQUE INDEX `yggc_authorization_codes_uid_key`(`uid`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `yggc_device_codes` (
|
||||
`id` VARCHAR(255) NOT NULL,
|
||||
`payload` JSON NOT NULL,
|
||||
`userCode` VARCHAR(191) NULL,
|
||||
`uid` VARCHAR(255) NULL,
|
||||
`consumed` BOOLEAN NOT NULL DEFAULT false,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `yggc_device_codes_id_key`(`id`),
|
||||
UNIQUE INDEX `yggc_device_codes_uid_key`(`uid`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `yggc_refresh_tokens` (
|
||||
`id` VARCHAR(255) NOT NULL,
|
||||
`payload` JSON NOT NULL,
|
||||
`uid` VARCHAR(255) NULL,
|
||||
`consumed` BOOLEAN NOT NULL DEFAULT false,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `yggc_refresh_tokens_id_key`(`id`),
|
||||
UNIQUE INDEX `yggc_refresh_tokens_uid_key`(`uid`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `yggc_grants` (
|
||||
`id` VARCHAR(255) NOT NULL,
|
||||
`payload` JSON NOT NULL,
|
||||
`uid` VARCHAR(255) NULL,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `yggc_grants_id_key`(`id`),
|
||||
UNIQUE INDEX `yggc_grants_uid_key`(`uid`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `yggc_interactions` (
|
||||
`id` VARCHAR(255) NOT NULL,
|
||||
`payload` JSON NOT NULL,
|
||||
`uid` VARCHAR(255) NULL,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `yggc_interactions_id_key`(`id`),
|
||||
UNIQUE INDEX `yggc_interactions_uid_key`(`uid`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `yggc_sessions` (
|
||||
`id` VARCHAR(255) NOT NULL,
|
||||
`payload` JSON NOT NULL,
|
||||
`uid` VARCHAR(255) NULL,
|
||||
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `yggc_sessions_id_key`(`id`),
|
||||
UNIQUE INDEX `yggc_sessions_uid_key`(`uid`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "mysql"
|
||||
310
prisma/schema.prisma.example
Normal file
310
prisma/schema.prisma.example
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DB_CONNECTION_STRING")
|
||||
}
|
||||
|
||||
model CodeIdToUUID {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
code_id String @unique(map: "code_id_to_uuid_code_id_unique") @db.VarChar(255)
|
||||
uuid String @db.VarChar(255)
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
code PassportAuthCode @relation(fields: [code_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "code_id_to_uuid_code_id_foreign")
|
||||
|
||||
@@map("code_id_to_uuid")
|
||||
}
|
||||
|
||||
model PassportAccessToken {
|
||||
id String @id @db.VarChar(100)
|
||||
user_id BigInt? @db.UnsignedBigInt
|
||||
client_id BigInt @db.UnsignedBigInt
|
||||
name String? @db.VarChar(255)
|
||||
scopes String? @db.Text
|
||||
revoked Boolean
|
||||
created_at DateTime? @db.Timestamp(0)
|
||||
updated_at DateTime? @db.Timestamp(0)
|
||||
expires_at DateTime? @db.DateTime(0)
|
||||
|
||||
@@index([user_id], map: "oauth_access_tokens_user_id_index")
|
||||
@@map("oauth_access_tokens")
|
||||
}
|
||||
|
||||
model PassportAuthCode {
|
||||
id String @id @db.VarChar(100)
|
||||
user_id BigInt @db.UnsignedBigInt
|
||||
client_id BigInt @db.UnsignedBigInt
|
||||
scopes String? @db.Text
|
||||
revoked Boolean
|
||||
expires_at DateTime? @db.DateTime(0)
|
||||
CodeIdToUUID CodeIdToUUID?
|
||||
|
||||
@@index([user_id], map: "oauth_auth_codes_user_id_index")
|
||||
@@map("oauth_auth_codes")
|
||||
}
|
||||
|
||||
model PassportRefreshToken {
|
||||
id String @id @db.VarChar(100)
|
||||
access_token_id String @db.VarChar(100)
|
||||
revoked Boolean
|
||||
expires_at DateTime? @db.DateTime(0)
|
||||
|
||||
@@index([access_token_id], map: "oauth_refresh_tokens_access_token_id_index")
|
||||
@@map("oauth_refresh_tokens")
|
||||
}
|
||||
|
||||
model Client {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
user_id BigInt? @db.UnsignedBigInt
|
||||
client_name String @map("name") @db.VarChar(255)
|
||||
client_secret String? @map("secret") @db.VarChar(100)
|
||||
provider String? @db.VarChar(255)
|
||||
redirect String @db.Text
|
||||
personal_access_client Boolean
|
||||
password_client Boolean
|
||||
revoked Boolean
|
||||
created_at DateTime? @db.Timestamp(0)
|
||||
updated_at DateTime? @db.Timestamp(0)
|
||||
|
||||
@@index([user_id], map: "oauth_clients_user_id_index")
|
||||
@@map("oauth_clients")
|
||||
}
|
||||
|
||||
model Option {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
name String @map("option_name") @db.VarChar(50)
|
||||
value String @map("option_value") @db.LongText
|
||||
|
||||
@@map("options")
|
||||
}
|
||||
|
||||
model Player {
|
||||
pid Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
uid Int
|
||||
name String @db.VarChar(50)
|
||||
tid_cape Int @default(0)
|
||||
last_modified DateTime @db.DateTime(0)
|
||||
tid_skin Int @default(-1)
|
||||
uuid UUID?
|
||||
|
||||
@@map("players")
|
||||
}
|
||||
|
||||
model reports {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
tid Int
|
||||
uploader Int
|
||||
reporter Int
|
||||
reason String @db.LongText
|
||||
status Int
|
||||
report_at DateTime @db.DateTime(0)
|
||||
|
||||
@@map("reports")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
model scopes {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
name String @unique(map: "scopes_name_unique") @db.VarChar(255)
|
||||
description String @db.VarChar(255)
|
||||
|
||||
@@map("scopes")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
model User {
|
||||
uid Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
email String @db.VarChar(100)
|
||||
nickname String @default("") @db.VarChar(50)
|
||||
locale String? @db.VarChar(255)
|
||||
score Int
|
||||
avatar Int @default(0)
|
||||
password String @db.VarChar(255)
|
||||
ip String @db.VarChar(45)
|
||||
is_dark_mode Boolean @default(false)
|
||||
permission Int @default(0)
|
||||
last_sign_at DateTime @db.DateTime(0)
|
||||
register_at DateTime @db.DateTime(0)
|
||||
verified Boolean @default(false)
|
||||
verification_token String @default("") @db.VarChar(255)
|
||||
remember_token String? @db.VarChar(100)
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model UUID {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
pid Int? @unique(map: "uuid_pid_unique") @db.UnsignedInt
|
||||
name String @unique(map: "uuid_name_unique") @db.VarChar(255)
|
||||
uuid String @unique(map: "uuid_uuid_unique") @db.VarChar(255)
|
||||
created_at DateTime? @db.Timestamp(0)
|
||||
updated_at DateTime? @db.Timestamp(0)
|
||||
player Player? @relation(fields: [pid], references: [pid], onDelete: Cascade, onUpdate: NoAction, map: "uuid_pid_foreign")
|
||||
|
||||
@@map("uuid")
|
||||
}
|
||||
|
||||
model AuthorizationCode {
|
||||
id String @id @unique @db.VarChar(255)
|
||||
payload Json
|
||||
uid String? @unique @db.VarChar(255)
|
||||
consumed Boolean @default(false)
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
@@map("yggc_authorization_codes")
|
||||
}
|
||||
|
||||
model DeviceCode {
|
||||
id String @id @unique @db.VarChar(255)
|
||||
payload Json
|
||||
userCode String?
|
||||
uid String? @unique @db.VarChar(255)
|
||||
consumed Boolean @default(false)
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
@@map("yggc_device_codes")
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @unique @db.VarChar(255)
|
||||
payload Json
|
||||
uid String? @unique @db.VarChar(255)
|
||||
consumed Boolean @default(false)
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
@@map("yggc_refresh_tokens")
|
||||
}
|
||||
|
||||
model Grant {
|
||||
id String @id @unique @db.VarChar(255)
|
||||
payload Json
|
||||
uid String? @unique @db.VarChar(255)
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
@@map("yggc_grants")
|
||||
}
|
||||
|
||||
model Interaction {
|
||||
id String @id @unique @db.VarChar(255)
|
||||
payload Json
|
||||
uid String? @unique @db.VarChar(255)
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
@@map("yggc_interactions")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @unique @db.VarChar(255)
|
||||
payload Json
|
||||
uid String? @unique @db.VarChar(255)
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
@@map("yggc_sessions")
|
||||
}
|
||||
|
||||
model jobs {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
queue String @db.VarChar(255)
|
||||
payload String @db.LongText
|
||||
attempts Int @db.UnsignedTinyInt
|
||||
reserved_at Int? @db.UnsignedInt
|
||||
available_at Int @db.UnsignedInt
|
||||
created_at Int @db.UnsignedInt
|
||||
|
||||
@@index([queue], map: "jobs_queue_index")
|
||||
@@map("jobs")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
model language_lines {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
group String @db.VarChar(255)
|
||||
key String @db.VarChar(255)
|
||||
text String @db.Text
|
||||
created_at DateTime? @db.Timestamp(0)
|
||||
updated_at DateTime? @db.Timestamp(0)
|
||||
|
||||
@@index([group], map: "language_lines_group_index")
|
||||
@@map("language_lines")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
model migrations {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
migration String @db.VarChar(255)
|
||||
batch Int
|
||||
|
||||
@@map("migrations")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
model notifications {
|
||||
id String @id @db.Char(36)
|
||||
type String @db.VarChar(255)
|
||||
notifiable_type String @db.VarChar(255)
|
||||
notifiable_id BigInt @db.UnsignedBigInt
|
||||
data String @db.Text
|
||||
read_at DateTime? @db.Timestamp(0)
|
||||
created_at DateTime? @db.Timestamp(0)
|
||||
updated_at DateTime? @db.Timestamp(0)
|
||||
|
||||
@@index([notifiable_type, notifiable_id], map: "notifications_notifiable_type_notifiable_id_index")
|
||||
@@map("notifications")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
model ygg_log {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
action String @db.VarChar(255)
|
||||
user_id Int
|
||||
player_id Int
|
||||
parameters String @default("") @db.VarChar(2048)
|
||||
ip String @default("") @db.VarChar(255)
|
||||
time DateTime @db.DateTime(0)
|
||||
|
||||
@@map("ygg_log")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
model oauth_personal_access_clients {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
client_id BigInt @db.UnsignedBigInt
|
||||
created_at DateTime? @db.Timestamp(0)
|
||||
updated_at DateTime? @db.Timestamp(0)
|
||||
|
||||
@@map("oauth_personal_access_clients")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
model textures {
|
||||
tid Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
name String @db.VarChar(50)
|
||||
type String @db.VarChar(10)
|
||||
hash String @db.VarChar(64)
|
||||
size Int
|
||||
uploader Int
|
||||
public Int @db.TinyInt
|
||||
upload_at DateTime @db.DateTime(0)
|
||||
likes Int @default(0) @db.UnsignedInt
|
||||
|
||||
@@map("textures")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
/// The underlying table does not contain a valid unique identifier and can therefore currently not be handled by Prisma Client.
|
||||
model user_closet {
|
||||
user_uid Int
|
||||
texture_tid Int
|
||||
item_name String? @db.Text
|
||||
|
||||
@@map("user_closet")
|
||||
@@ignore
|
||||
}
|
||||
40
src/app.controller.ts
Normal file
40
src/app.controller.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Controller, All, Req, Res, Get, Redirect } from '@nestjs/common';
|
||||
import { Request, Response } from "express";
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) { }
|
||||
|
||||
@Get("/interaction/:uid")
|
||||
async interaction(@Req() req: Request, @Res() res: Response): Promise<object | void> {
|
||||
return this.appService.interaction(req, res);
|
||||
}
|
||||
@Get("/interaction/:uid/callback")
|
||||
async interactionLogin(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
return this.appService.interactionCallback(req, res);
|
||||
}
|
||||
|
||||
@Get("/userinfo")
|
||||
@Redirect("", 303)
|
||||
async userinfo(): Promise<object> {
|
||||
return {
|
||||
url: this.appService.getSiteUrl() + "/yggc/userinfo",
|
||||
};
|
||||
}
|
||||
|
||||
/* @Get('/device')
|
||||
async userCodeVerification(@Req() req: Request, @Res() res: Response, @Query('xsrf') query: string): Promise<void | object> {
|
||||
if(query != undefined) {
|
||||
return this.appService.callback(req, res);
|
||||
} else {
|
||||
const url = this.appService.getSiteUrl() + "/yggc/device";
|
||||
res.redirect(HttpStatus.SEE_OTHER, url);
|
||||
}
|
||||
} */
|
||||
|
||||
@All("/*")
|
||||
getHello(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
return this.appService.callback(req, res);
|
||||
}
|
||||
}
|
||||
38
src/app.module.ts
Normal file
38
src/app.module.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { CustomPrismaModule } from 'nestjs-prisma';
|
||||
import { EXTENDED_PRISMA_SERVICE, getExtendedPrismaClient } from './extended-prisma-client';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { OIDCProviderService } from './oidc-provider.service';
|
||||
import { readFileSync } from 'fs';
|
||||
import { importPKCS8, exportJWK, JWK } from 'jose';
|
||||
import { validate } from './env-validation';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, validate: validate }),
|
||||
CustomPrismaModule.forRootAsync({
|
||||
name: EXTENDED_PRISMA_SERVICE,
|
||||
useFactory: (config: ConfigService) => { // SO FUCKING STUPID, someone refactor this please
|
||||
return getExtendedPrismaClient(config.get<string>('BS_SITE_URL')!);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
AppService,
|
||||
{
|
||||
provide: "JWK",
|
||||
useFactory: async (): Promise<JWK> => {
|
||||
const pkcs8: string = readFileSync("./oauth-private.key", 'utf8');
|
||||
const keyImported: CryptoKey = await importPKCS8(pkcs8, 'RS256', { extractable: true });
|
||||
return exportJWK(keyImported);
|
||||
},
|
||||
},
|
||||
OIDCProviderService
|
||||
],
|
||||
})
|
||||
export class AppModule { }
|
||||
|
||||
155
src/app.service.ts
Normal file
155
src/app.service.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as oidc from 'oidc-provider';
|
||||
import { BS_RESOURCE_INDICATOR, OIDCProviderService } from './oidc-provider.service';
|
||||
import { Request, Response } from "express";
|
||||
import { CustomPrismaService } from 'nestjs-prisma';
|
||||
import { EXTENDED_PRISMA_SERVICE, ExtendedPrismaClient } from './extended-prisma-client';
|
||||
import { getDateWithTimezoneOffset } from './helper';
|
||||
import { CodeIdToUUID, PassportAuthCode, User } from '@prisma/client';
|
||||
import { YggCScopes } from './blessing.types';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
|
||||
readonly callback: (req: Request, res: Response) => Promise<void>;
|
||||
private readonly provider: oidc.Provider;
|
||||
private readonly siteUrl: string;
|
||||
|
||||
constructor(
|
||||
@Inject(EXTENDED_PRISMA_SERVICE) private readonly prisma: CustomPrismaService<ExtendedPrismaClient>,
|
||||
private readonly oidcProvider: OIDCProviderService,
|
||||
) {
|
||||
this.provider = this.oidcProvider.provider;
|
||||
this.siteUrl = this.oidcProvider.siteUrl;
|
||||
this.callback = this.provider.callback();
|
||||
}
|
||||
|
||||
async interaction(req: Request, res: Response): Promise<object | void> {
|
||||
|
||||
const result = await this.provider.interactionDetails(req, res);
|
||||
|
||||
const { params } = result;
|
||||
const url = `${this.siteUrl}/oauth/authorize?client_id=${params.client_id}&response_type=code&scope=${params.scope}&redirect_uri=${this.siteUrl}/yggc/callback&state=${result.jti}&prompt=consent`
|
||||
|
||||
/* if(result.deviceCode) {
|
||||
return res.json({
|
||||
url: url
|
||||
});
|
||||
} */
|
||||
|
||||
return res.redirect(url);
|
||||
}
|
||||
|
||||
async interactionCallback(req: Request, res: Response): Promise<void> {
|
||||
const { code, error, error_description } = req.query as { code: string; error: string | undefined; error_description: string | undefined; };
|
||||
const { client_id, scope } = (await this.provider.interactionDetails(req, res)).params as { client_id: string; scope: string; };
|
||||
|
||||
if (error) {
|
||||
return this.provider.interactionFinished(req, res, {
|
||||
error: error,
|
||||
error_description: error_description
|
||||
}, { mergeWithLastSubmission: false });
|
||||
}
|
||||
|
||||
const authCode: PassportAuthCode | null = await this.prisma.client.passportAuthCode.findFirst({
|
||||
where: {
|
||||
id: code,
|
||||
revoked: false,
|
||||
expires_at: {
|
||||
gte: getDateWithTimezoneOffset()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!authCode) {
|
||||
return this.provider.interactionFinished(req, res, {
|
||||
error: "invalid_grant",
|
||||
error_description: "Invalid or expired code"
|
||||
});
|
||||
}
|
||||
|
||||
const user: User | null = await this.prisma.client.user.findFirst({
|
||||
where: {
|
||||
uid: Number(authCode?.user_id),
|
||||
verified: true,
|
||||
permission: {
|
||||
not: -1
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return this.provider.interactionFinished(req, res, {
|
||||
error: "invalid_grant",
|
||||
error_description: "Invalid or expired code"
|
||||
});
|
||||
}
|
||||
|
||||
const scopesInSession: Set<string> = new Set(scope.split(" "));
|
||||
const scopesInBSAuth: Set<string> = new Set(scopesInSession);
|
||||
const scopes = scopesInSession.intersection(scopesInBSAuth);
|
||||
|
||||
if (scopes.has(YggCScopes.PROFILE_SELECT)) {
|
||||
const codeIdToUUID: CodeIdToUUID | null = await this.prisma.client.codeIdToUUID.findFirst({
|
||||
where: {
|
||||
code_id: code
|
||||
}
|
||||
});
|
||||
if (!codeIdToUUID) {
|
||||
return this.provider.interactionFinished(req, res, {
|
||||
error: "invalid_grant",
|
||||
error_description: "Invalid or expired code"
|
||||
});
|
||||
}
|
||||
const uuid = await this.prisma.client.uUID.findFirst({
|
||||
where: {
|
||||
uuid: codeIdToUUID.uuid
|
||||
},
|
||||
include: {
|
||||
player: true
|
||||
}
|
||||
});
|
||||
if (!uuid) {
|
||||
return this.provider.interactionFinished(req, res, {
|
||||
error: "invalid_grant",
|
||||
error_description: "Invalid or expired code"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const grant: oidc.Grant = new this.provider.Grant({
|
||||
clientId: client_id,
|
||||
accountId: user.uid.toString(),
|
||||
});
|
||||
|
||||
scopes.forEach((scope) => {
|
||||
grant.addOIDCScope(scope);
|
||||
grant.addResourceScope(BS_RESOURCE_INDICATOR, scope);
|
||||
});
|
||||
|
||||
grant.jti = code;
|
||||
await grant.save();
|
||||
|
||||
await this.prisma.client.passportAuthCode.update({
|
||||
where: {
|
||||
id: code
|
||||
},
|
||||
data: {
|
||||
revoked: true,
|
||||
}
|
||||
});
|
||||
|
||||
return this.provider.interactionFinished(req, res, {
|
||||
login: {
|
||||
accountId: user.uid.toString()
|
||||
},
|
||||
consent: {
|
||||
grantId: code
|
||||
}
|
||||
}, { mergeWithLastSubmission: false });
|
||||
}
|
||||
|
||||
getSiteUrl(): string {
|
||||
return this.siteUrl;
|
||||
}
|
||||
}
|
||||
41
src/blessing.types.ts
Normal file
41
src/blessing.types.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export type YggdrasilProfile = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type UserInfo = {
|
||||
sub: string;
|
||||
nickname?: string;
|
||||
email?: string;
|
||||
email_verified?: boolean;
|
||||
picture?: string;
|
||||
selectedProfile?: YggdrasilProfile;
|
||||
availableProfiles?: YggdrasilProfile[];
|
||||
};
|
||||
|
||||
export enum YggCScopes {
|
||||
OPENID = 'openid',
|
||||
PROFILE = 'profile',
|
||||
EMAIL = 'email',
|
||||
PROFILE_SELECT = 'Yggdrasil.PlayerProfiles.Select',
|
||||
PROFILE_READ = 'Yggdrasil.PlayerProfiles.Read',
|
||||
SERVER_JOIN = 'Yggdrasil.Server.Join'
|
||||
}
|
||||
|
||||
export enum YggCClaims {
|
||||
NICKNAME = 'nickname',
|
||||
PICTURE = 'picture',
|
||||
EMAIL = 'email',
|
||||
EMAIL_VERIFIED = 'email_verified',
|
||||
SELECTED_PROFILE = 'selectedProfile',
|
||||
AVAILABLE_PROFILES = 'availableProfiles'
|
||||
}
|
||||
|
||||
export const BS_RESOURCE_INDICATOR: string = "https://github.com/bs-community/blessing-skin-server";
|
||||
export const ACCESS_TOKEN_NAME: string = "Yggdrasil Connect";
|
||||
|
||||
export enum RoutesWithoutCORS {
|
||||
'auth',
|
||||
'device',
|
||||
'interaction'
|
||||
}
|
||||
89
src/env-validation.ts
Normal file
89
src/env-validation.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { plainToInstance } from "class-transformer";
|
||||
import { IsNotEmpty, IsNumber, IsString, IsUrl, Max, Min, registerDecorator, validateSync, ValidationArguments, ValidationOptions } from "class-validator";
|
||||
|
||||
class Env {
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(65535)
|
||||
PORT: number;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
DB_HOST: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(65535)
|
||||
DB_PORT: number;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
DB_USERNAME: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
DB_PASSWORD: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
DB_NAME: string;
|
||||
|
||||
@IsUrl({ require_tld: false })
|
||||
@IsSecureUrl()
|
||||
ISSUER: string;
|
||||
|
||||
@IsUrl({ require_tld: false })
|
||||
@IsSecureUrl()
|
||||
BS_SITE_URL: string;
|
||||
|
||||
@IsString()
|
||||
SHARED_CLIENT_ID: string;
|
||||
|
||||
@IsNumber()
|
||||
TOKEN_EXPIRES_IN_1: number;
|
||||
|
||||
@IsNumber()
|
||||
TOKEN_EXPIRES_IN_2: number;
|
||||
|
||||
@IsNumber()
|
||||
DEVICE_CODE_EXPIRES_IN: number;
|
||||
|
||||
@IsNumber()
|
||||
GRANT_EXPIRES_IN: number;
|
||||
}
|
||||
|
||||
function IsSecureUrl(property?: string, validationOptions?: ValidationOptions) {
|
||||
return function (object: Object, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'isSecureUrl',
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [property],
|
||||
validator: {
|
||||
validate(value: string, args: ValidationArguments) {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === 'https:' || url.hostname == 'localhost') && !url.search.length && !url.hash.length && !value.endsWith('/');
|
||||
},
|
||||
defaultMessage(args: ValidationArguments) {
|
||||
return `${args.property} must be a secure URL (either localhost or HTTPS), not end with a slash, and not contain queries and fragments.`;
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
export function validate(config: Record<string, unknown>) {
|
||||
const validatedConfig = plainToInstance(
|
||||
Env,
|
||||
config,
|
||||
{ enableImplicitConversion: true },
|
||||
);
|
||||
const errors = validateSync(validatedConfig, { skipMissingProperties: false });
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.toString());
|
||||
}
|
||||
return validatedConfig;
|
||||
}
|
||||
53
src/extended-prisma-client.ts
Normal file
53
src/extended-prisma-client.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export function getExtendedPrismaClient(siteUrl: string) {
|
||||
const extendedPrismaClient = new PrismaClient().$extends({
|
||||
result: {
|
||||
client: {
|
||||
client_id: {
|
||||
needs: { id: true },
|
||||
compute(data: { id: bigint; }) {
|
||||
return data.id.toString();
|
||||
}
|
||||
},
|
||||
redirect_uris: {
|
||||
needs: { redirect: true },
|
||||
compute(data: { redirect: string; }) {
|
||||
const splitted: string[] = data.redirect.split(',').map((uri: string) => uri.trim());
|
||||
return splitted;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}).$extends({
|
||||
result: {
|
||||
client: {
|
||||
token_endpoint_auth_method: {
|
||||
needs: { redirect_uris: true },
|
||||
compute: (data: { redirect_uris: string[] }) => {
|
||||
if(data.redirect_uris.indexOf(`${siteUrl}/yggc/client/public`) !== -1) {
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}).$extends({
|
||||
result: {
|
||||
$allModels: {
|
||||
payload: {
|
||||
compute(data: object | undefined) {
|
||||
if (data != undefined) {
|
||||
return data.hasOwnProperty('payload') ? data['payload'] : data;
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
return extendedPrismaClient;
|
||||
}
|
||||
|
||||
export type ExtendedPrismaClient = ReturnType<typeof getExtendedPrismaClient>;
|
||||
export const EXTENDED_PRISMA_SERVICE = 'EXTENDED_PRISMA_SERVICE';
|
||||
3
src/helper.ts
Normal file
3
src/helper.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function getDateWithTimezoneOffset(): Date {
|
||||
return new Date(Date.now() - new Date().getTimezoneOffset() * 60 * 1000);
|
||||
}
|
||||
30
src/main.ts
Normal file
30
src/main.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { RoutesWithoutCORS } from './blessing.types';
|
||||
import { Request } from 'express';
|
||||
|
||||
async function bootstrap() {
|
||||
BigInt.prototype['toJSON'] = function () {
|
||||
const int = Number.parseInt(this.toString());
|
||||
return int ?? this.toString();
|
||||
};
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
/*
|
||||
> The Token Endpoint ... and any other endpoints directly accessed by Clients SHOULD support the use of Cross-Origin Resource Sharing (CORS) ...
|
||||
> The use of CORS at the Authorization Endpoint is NOT RECOMMENDED as it is redirected to by the client and not directly accessed.
|
||||
https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
|
||||
*/
|
||||
app.enableCors((req: Request, callback) => {
|
||||
callback(null, {
|
||||
origin: req.path.split('/', 2)[1] in RoutesWithoutCORS ? false : '*'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
app.enable('trust proxy');
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
136
src/oidc-adapter.ts
Normal file
136
src/oidc-adapter.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { Adapter, AdapterPayload, AdapterFactory } from 'oidc-provider';
|
||||
import { ExtendedPrismaClient } from './extended-prisma-client';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export type ModelType =
|
||||
"Grant" |
|
||||
"Session" |
|
||||
"AccessToken" |
|
||||
"AuthorizationCode" |
|
||||
"RefreshToken" |
|
||||
"ClientCredentials" |
|
||||
"Client" |
|
||||
"InitialAccessToken" |
|
||||
"RegistrationAccessToken" |
|
||||
"DeviceCode" |
|
||||
"Interaction" |
|
||||
"ReplayDetection" |
|
||||
"BackchannelAuthenticationRequest" |
|
||||
"PushedAuthorizationRequest";
|
||||
|
||||
export class OIDCAdapter implements Adapter {
|
||||
|
||||
private readonly model: string;
|
||||
|
||||
constructor(private readonly type: ModelType, private readonly prisma: ExtendedPrismaClient, private readonly config: ConfigService) {
|
||||
this.model = type.charAt(0).toLowerCase() + type.slice(1);
|
||||
}
|
||||
|
||||
public static getAdapterFactory(prisma: ExtendedPrismaClient, config: ConfigService): AdapterFactory {
|
||||
return (type: ModelType) => {
|
||||
return new OIDCAdapter(type, prisma, config);
|
||||
};
|
||||
}
|
||||
|
||||
async upsert(id: string, payload: AdapterPayload): Promise<any> {
|
||||
const data = {
|
||||
uid: payload.uid,
|
||||
payload: payload,
|
||||
userCode: payload.userCode
|
||||
};
|
||||
|
||||
await this.prisma[this.model].upsert({
|
||||
where: {
|
||||
id: id
|
||||
},
|
||||
create: {
|
||||
id: id,
|
||||
...data
|
||||
},
|
||||
update: {
|
||||
...data
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async find(id: string): Promise<AdapterPayload | undefined> {
|
||||
|
||||
const data = await this.prisma[this.model].findFirst({
|
||||
where: {
|
||||
id: this.type == 'Client' ? parseInt(id) : id
|
||||
}
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const accountId = data.payload?.accountId;
|
||||
if (accountId) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
uid: parseInt(accountId)
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return data.payload;
|
||||
}
|
||||
|
||||
async findByUserCode(userCode: string): Promise<AdapterPayload | undefined> {
|
||||
const data = await this.prisma.deviceCode.findFirst({
|
||||
where: {
|
||||
userCode: userCode
|
||||
}
|
||||
});
|
||||
|
||||
if (data) {
|
||||
return data.payload;
|
||||
}
|
||||
}
|
||||
|
||||
async findByUid(uid: string): Promise<AdapterPayload | undefined> {
|
||||
const data = await this.prisma[this.model].findFirst({
|
||||
where: {
|
||||
uid: uid
|
||||
}
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return data.payload;
|
||||
}
|
||||
|
||||
async consume(id: string): Promise<void> {
|
||||
await this.prisma[this.model].update({
|
||||
where: {
|
||||
id: id
|
||||
},
|
||||
data: {
|
||||
consumed: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async destroy(id: string): Promise<void> {
|
||||
await this.prisma[this.model].deleteMany({
|
||||
where: {
|
||||
id: id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async revokeByGrantId(grantId: string): Promise<undefined | void> {
|
||||
await this.prisma.grant.deleteMany({
|
||||
where: {
|
||||
id: grantId
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
411
src/oidc-provider.service.ts
Normal file
411
src/oidc-provider.service.ts
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
import * as oidc from 'oidc-provider';
|
||||
import { OIDCAdapter } from './oidc-adapter';
|
||||
import { EXTENDED_PRISMA_SERVICE, ExtendedPrismaClient } from './extended-prisma-client';
|
||||
import { JWK } from 'jose';
|
||||
import { getDateWithTimezoneOffset } from './helper';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UserInfo, YggCClaims, YggCScopes, YggdrasilProfile } from './blessing.types';
|
||||
import { CodeIdToUUID, PassportAccessToken, Player, UUID } from '@prisma/client';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { CustomPrismaService } from 'nestjs-prisma';
|
||||
|
||||
export const BS_RESOURCE_INDICATOR: string = "https://github.com/bs-community/blessing-skin-server";
|
||||
export const ACCESS_TOKEN_NAME: string = "Yggdrasil Connect";
|
||||
|
||||
@Injectable()
|
||||
export class OIDCProviderService {
|
||||
readonly provider: oidc.Provider;
|
||||
readonly siteUrl: string;
|
||||
readonly session: oidc.Session;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
@Inject(EXTENDED_PRISMA_SERVICE) private readonly prisma: CustomPrismaService<ExtendedPrismaClient>,
|
||||
@Inject("JWK") private readonly jwk: JWK,
|
||||
) {
|
||||
const siteUrl: string = this.config.get<string>("BS_SITE_URL")!;
|
||||
const issuer: string = this.config.get<string>("ISSUER")!;
|
||||
const tokenExpiresIn1: number = this.config.get<number>("TOKEN_EXPIRES_IN_1")!;
|
||||
const tokenExpiresIn2: number = this.config.get<number>("TOKEN_EXPIRES_IN_2")!;
|
||||
const deviceCodeExpiresIn: number = this.config.get<number>("DEVICE_CODE_EXPIRES_IN")!;
|
||||
const grantExpiresIn: number = this.config.get<number>("GRANT_EXPIRES_IN")!;
|
||||
const sharedClientId: string | undefined = this.config.get<string>("SHARED_CLIENT_ID");
|
||||
|
||||
const basePolicy = oidc.interactionPolicy.base();
|
||||
const loginPrompt = basePolicy.get('login')!;
|
||||
const consentPrompt = basePolicy.get('consent')!;
|
||||
const grantPrompt = new oidc.interactionPolicy.Prompt({ name: 'grant', requestable: true }, new oidc.interactionPolicy.Check('grant', 'invalid grant', (ctx) => {
|
||||
const oidcContext = ctx.oidc;
|
||||
if (!oidcContext.entities.Grant) {
|
||||
return oidc.interactionPolicy.Check.REQUEST_PROMPT;
|
||||
}
|
||||
return oidc.interactionPolicy.Check.NO_NEED_TO_PROMPT;
|
||||
}));
|
||||
|
||||
const provider = new oidc.Provider(issuer, {
|
||||
adapter: OIDCAdapter.getAdapterFactory(this.prisma.client, this.config),
|
||||
jwks: {
|
||||
keys: [this.jwk]
|
||||
},
|
||||
clientAuthMethods: [
|
||||
'client_secret_post',
|
||||
'none'
|
||||
],
|
||||
responseTypes: ['code', 'id_token', 'code id_token'],
|
||||
claims: {
|
||||
[YggCScopes.PROFILE]: [YggCClaims.NICKNAME, YggCClaims.PICTURE],
|
||||
[YggCScopes.EMAIL]: [YggCClaims.EMAIL, YggCClaims.EMAIL_VERIFIED],
|
||||
[YggCScopes.PROFILE_SELECT]: [YggCClaims.SELECTED_PROFILE],
|
||||
[YggCScopes.PROFILE_READ]: [YggCClaims.AVAILABLE_PROFILES]
|
||||
},
|
||||
scopes: [
|
||||
YggCScopes.EMAIL,
|
||||
YggCScopes.PROFILE,
|
||||
YggCScopes.PROFILE_SELECT,
|
||||
YggCScopes.PROFILE_READ,
|
||||
YggCScopes.SERVER_JOIN,
|
||||
'offline_access',
|
||||
'openid'
|
||||
],
|
||||
async loadExistingGrant(ctx: oidc.KoaContextWithOIDC) {
|
||||
const grantId = ctx.oidc.result?.consent?.grantId;
|
||||
if (grantId) {
|
||||
return ctx.oidc.provider.Grant.find(grantId);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
findAccount: this.findAccount.bind(this),
|
||||
conformIdTokenClaims: false,
|
||||
features: {
|
||||
deviceFlow: {
|
||||
enabled: true,
|
||||
},
|
||||
dPoP: {
|
||||
enabled: false
|
||||
},
|
||||
devInteractions: { enabled: false },
|
||||
resourceIndicators: {
|
||||
enabled: true,
|
||||
defaultResource(ctx: oidc.KoaContextWithOIDC, client: oidc.Client, oneOf: string[] | undefined) {
|
||||
return BS_RESOURCE_INDICATOR;
|
||||
},
|
||||
getResourceServerInfo(ctx: oidc.KoaContextWithOIDC, resourceIndicator: string, client: oidc.Client) {
|
||||
return {
|
||||
scope: Array.from(ctx.oidc.requestParamScopes).join(' '),
|
||||
audience: BS_RESOURCE_INDICATOR,
|
||||
accessTokenFormat: 'jwt',
|
||||
jwt: {
|
||||
sign: { alg: 'RS256' },
|
||||
},
|
||||
};
|
||||
},
|
||||
useGrantedResource(ctx: oidc.KoaContextWithOIDC, model) {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
pushedAuthorizationRequests: {
|
||||
enabled: false
|
||||
},
|
||||
rpInitiatedLogout: {
|
||||
enabled: false
|
||||
}
|
||||
},
|
||||
formats: {
|
||||
customizers: {
|
||||
async jwt(ctx, token, jwt) {
|
||||
jwt.payload.aud = ctx.oidc.client!.clientId;
|
||||
return jwt;
|
||||
},
|
||||
}
|
||||
},
|
||||
interactions: {
|
||||
policy: [
|
||||
loginPrompt,
|
||||
grantPrompt,
|
||||
consentPrompt
|
||||
],
|
||||
url(ctx, interaction) {
|
||||
const prompt = interaction.prompt;
|
||||
return `/interaction/${interaction.uid}`;
|
||||
},
|
||||
},
|
||||
async extraTokenClaims(ctx: oidc.KoaContextWithOIDC, token: oidc.AccessToken) {
|
||||
const oidcContext = ctx.oidc;
|
||||
const claims: oidc.AccountClaims | undefined = await oidcContext.account?.claims('id_token', token.scope!, {}, []);
|
||||
const selectedProfile = claims?.selectedProfile as YggdrasilProfile | undefined;
|
||||
return {
|
||||
selectedProfile: selectedProfile?.id,
|
||||
scopes: Array.from(oidcContext.entities.RefreshToken?.scopes ?? token.scopes)
|
||||
};
|
||||
},
|
||||
expiresWithSession(ctx: oidc.KoaContextWithOIDC, token): boolean {
|
||||
return false;
|
||||
},
|
||||
ttl: {
|
||||
AccessToken: tokenExpiresIn1,
|
||||
AuthorizationCode: 10 * 60,
|
||||
DeviceCode: deviceCodeExpiresIn,
|
||||
Grant: grantExpiresIn,
|
||||
IdToken: tokenExpiresIn1,
|
||||
RefreshToken: tokenExpiresIn2,
|
||||
Session: 15 * 60,
|
||||
Interaction: 15 * 60,
|
||||
},
|
||||
rotateRefreshToken: true,
|
||||
routes: {
|
||||
userinfo: '/userinfo'
|
||||
},
|
||||
clientDefaults: {
|
||||
application_type: 'native',
|
||||
response_types: ['code', 'id_token', 'code id_token'],
|
||||
grant_types: ['authorization_code', 'implicit', 'refresh_token', 'urn:ietf:params:oauth:grant-type:device_code'],
|
||||
token_endpoint_auth_method: "client_secret_post"
|
||||
},
|
||||
discovery: {
|
||||
shared_client_id: sharedClientId?.length ? sharedClientId : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
provider.proxy = true;
|
||||
|
||||
provider.on('server_error', (ctx, error) => {
|
||||
console.log(error);
|
||||
console.log(error.stack);
|
||||
});
|
||||
|
||||
/*
|
||||
如果签发的 Access Token 是 JWT,oidc-provider 不会把 Access Token 写到数据库里
|
||||
所以要手动把 Access Token 保存到 Laravel Passport 的数据表中
|
||||
*/
|
||||
provider.on('access_token.issued', async (token: oidc.AccessToken) => {
|
||||
|
||||
const date: Date = getDateWithTimezoneOffset();
|
||||
const maxTokenCount = parseInt(await this.getBlessingOption("ygg_tokens_limit", "5"));
|
||||
const tokenIssued: PassportAccessToken[] = await prisma.client.passportAccessToken.findMany({
|
||||
where: {
|
||||
client_id: parseInt(token.clientId!),
|
||||
user_id: parseInt(token.accountId),
|
||||
name: ACCESS_TOKEN_NAME,
|
||||
revoked: false,
|
||||
expires_at: {
|
||||
gte: date
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (tokenIssued.length >= maxTokenCount) {
|
||||
tokenIssued.slice(0, tokenIssued.length - maxTokenCount + 1).forEach(async (token) => {
|
||||
await prisma.client.passportAccessToken.update({
|
||||
where: {
|
||||
id: token.id
|
||||
},
|
||||
data: {
|
||||
revoked: true,
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.client.passportAccessToken.create({
|
||||
data: {
|
||||
id: token.jti,
|
||||
client_id: parseInt(token.clientId!),
|
||||
user_id: parseInt(token.accountId),
|
||||
name: ACCESS_TOKEN_NAME,
|
||||
scopes: JSON.stringify(token.extra?.scopes),
|
||||
revoked: false,
|
||||
created_at: date,
|
||||
expires_at: new Date(date.getTime() + token.expiration * 1000),
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
provider.on('refresh_token.consumed', async (rotatedRefreshToken: oidc.RefreshToken) => {
|
||||
const passportRefreshToken = await prisma.client.passportRefreshToken.findFirst({
|
||||
where: {
|
||||
id: rotatedRefreshToken.jti,
|
||||
revoked: false,
|
||||
expires_at: {
|
||||
gte: getDateWithTimezoneOffset()
|
||||
}
|
||||
},
|
||||
select: {
|
||||
access_token_id: true,
|
||||
}
|
||||
});
|
||||
|
||||
if (passportRefreshToken) {
|
||||
await prisma.client.passportAccessToken.update({
|
||||
where: {
|
||||
id: passportRefreshToken.access_token_id
|
||||
},
|
||||
data: {
|
||||
revoked: true
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.client.passportRefreshToken.update({
|
||||
where: {
|
||||
id: rotatedRefreshToken.jti
|
||||
},
|
||||
data: {
|
||||
revoked: true
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
刷新 Access Token 时也没法吊销原先的 Access Token,因为 oidc-provider 根本不知道上次签发的 Access Token 是哪个
|
||||
所以要把 Refresh Token 对应的 Access Token 存起来,在刷新 Access Token 时手动吊销
|
||||
*/
|
||||
provider.on('access_token.issued', this.saveRefreshTokenToPassport.bind(this));
|
||||
provider.on('refresh_token.saved', this.saveRefreshTokenToPassport.bind(this));
|
||||
|
||||
this.siteUrl = siteUrl;
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
async findAccount(ctx: oidc.KoaContextWithOIDC, id: string, token?: oidc.AuthorizationCode | oidc.AccessToken | oidc.DeviceCode | oidc.RefreshToken): Promise<oidc.Account | undefined> {
|
||||
const grantId = ctx.oidc.result?.consent?.grantId ?? token?.grantId;
|
||||
|
||||
const authCode = await this.prisma.client.passportAuthCode.findFirst({
|
||||
where: {
|
||||
id: grantId,
|
||||
user_id: parseInt(id),
|
||||
}
|
||||
});
|
||||
|
||||
if (!authCode) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const grant = ctx.oidc.entities.Grant ?? await ctx.oidc.provider.Grant.find(grantId!);
|
||||
if (!grant) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const user = await this.prisma.client.user.findFirst({
|
||||
where: {
|
||||
uid: Number(authCode.user_id),
|
||||
verified: true,
|
||||
permission: {
|
||||
not: -1
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const userInfo: UserInfo = {
|
||||
sub: id,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
email_verified: Boolean(user.verified),
|
||||
picture: `${this.siteUrl}/avatar/user/${user.uid}`,
|
||||
};
|
||||
|
||||
const scopes: string[] | undefined = grant.resources?.[BS_RESOURCE_INDICATOR]?.split(' ');
|
||||
|
||||
if (scopes?.includes(YggCScopes.PROFILE_SELECT)) {
|
||||
const codeIdToUUID: CodeIdToUUID | null = await this.prisma.client.codeIdToUUID.findFirst({
|
||||
where: {
|
||||
code_id: grantId
|
||||
}
|
||||
});
|
||||
if (!codeIdToUUID) {
|
||||
return undefined;
|
||||
}
|
||||
const uuid = await this.prisma.client.uUID.findFirst({
|
||||
where: {
|
||||
uuid: codeIdToUUID.uuid
|
||||
},
|
||||
include: {
|
||||
player: true
|
||||
}
|
||||
});
|
||||
if (!uuid) {
|
||||
return undefined;
|
||||
}
|
||||
userInfo.selectedProfile = {
|
||||
id: uuid.uuid,
|
||||
name: uuid.player!.name
|
||||
};
|
||||
}
|
||||
|
||||
if (scopes?.includes(YggCScopes.PROFILE_READ)) {
|
||||
const players: Player[] = await this.prisma.client.player.findMany({
|
||||
where: {
|
||||
uid: user.uid
|
||||
}
|
||||
});
|
||||
userInfo.availableProfiles = await Promise.all(players.map(async (player) => {
|
||||
const uuid: UUID | null = await this.prisma.client.uUID.findFirst({
|
||||
where: {
|
||||
pid: player.pid
|
||||
}
|
||||
});
|
||||
return uuid ? { id: uuid.uuid, name: player.name } : null;
|
||||
})).then(profiles => profiles.filter(profile => profile !== null));
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: userInfo.sub,
|
||||
async claims(use: string, scope: string, claims: object, rejected: string[]) {
|
||||
return userInfo;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
oidc-provider 的事件触发很奇怪
|
||||
当通过请求授权签发 Access Token 时,会先触发 access_token.issued 事件,再触发 refresh_token.saved 事件
|
||||
但在刷新 Access Token 时,会先触发 refresh_token.saved 事件,再触发 access_token.issued 事件
|
||||
所以两个事件的监听器中都需要尝试 upsert,确保 Refresh Token 保存在 Laravel Passport 的数据表中
|
||||
*/
|
||||
async saveRefreshTokenToPassport() {
|
||||
// @ts-ignore
|
||||
const ctx: oidc.OIDCContext | undefined = oidc.Provider.ctx?.oidc;
|
||||
|
||||
if (ctx?.entities.AccessToken && ctx?.entities.RefreshToken) {
|
||||
const accessToken = ctx.entities.AccessToken;
|
||||
const refreshToken = ctx.entities.RefreshToken;
|
||||
await this.prisma.client.passportRefreshToken.upsert({
|
||||
where: {
|
||||
id: refreshToken.jti
|
||||
},
|
||||
create: {
|
||||
id: refreshToken.jti,
|
||||
access_token_id: accessToken.jti,
|
||||
revoked: false,
|
||||
expires_at: new Date(getDateWithTimezoneOffset().getTime() + refreshToken.expiration * 1000),
|
||||
},
|
||||
update: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getBlessingOption(name: string): Promise<string | null>;
|
||||
async getBlessingOption(name: string, defaultValue: string): Promise<string>;
|
||||
async getBlessingOption(name: string, defaultValue?: string): Promise<string | null> {
|
||||
const option = await this.prisma.client.option.findFirst({
|
||||
where: {
|
||||
name: name
|
||||
},
|
||||
select: {
|
||||
value: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!option) {
|
||||
if (defaultValue !== undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return option.value;
|
||||
}
|
||||
}
|
||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ESNext",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user