[feat] Init code
This commit is contained in:
commit
b1e9c5e62a
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
yarn-error.log
|
||||||
|
.env
|
||||||
|
|
||||||
|
deployment/
|
||||||
|
|
||||||
|
# vim swap file
|
||||||
|
*.swp
|
||||||
|
credential.json
|
||||||
|
coverage/
|
||||||
|
fcm_credential.json
|
4
.eslintignore
Normal file
4
.eslintignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
public/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
*.d.ts
|
9
.eslintrc
Normal file
9
.eslintrc
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2020
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-console": [1, { "allow": ["info", "warn", "error", "table"] }],
|
||||||
|
"no-underscore-dangle": ["error", { "allow": ["_id", "_type", "_index", "_source", "super_", "_score", "_modelName"] }]
|
||||||
|
}
|
||||||
|
}
|
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
yarn-error.log
|
||||||
|
.env
|
||||||
|
|
||||||
|
# vim swap file
|
||||||
|
*.swp
|
||||||
|
credential.json
|
||||||
|
coverage/
|
||||||
|
fcm_credential.json
|
92
.gitlab-ci.yml
Normal file
92
.gitlab-ci.yml
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
include:
|
||||||
|
- template: Security/SAST.gitlab-ci.yml
|
||||||
|
|
||||||
|
sast:
|
||||||
|
stage: build
|
||||||
|
|
||||||
|
nodejs-scan-sast:
|
||||||
|
rules:
|
||||||
|
- if: $SAST_DISABLED
|
||||||
|
when: never
|
||||||
|
- if: $CI_COMMIT_BRANCH != "develop"
|
||||||
|
when: never
|
||||||
|
- if: $CI_COMMIT_BRANCH &&
|
||||||
|
$SAST_DEFAULT_ANALYZERS =~ /nodejs-scan/
|
||||||
|
exists:
|
||||||
|
- 'package.json'
|
||||||
|
|
||||||
|
eslint-sast:
|
||||||
|
rules:
|
||||||
|
- if: $SAST_DISABLED
|
||||||
|
when: never
|
||||||
|
- if: $CI_COMMIT_BRANCH != "develop"
|
||||||
|
when: never
|
||||||
|
- if: $CI_COMMIT_BRANCH &&
|
||||||
|
$SAST_DEFAULT_ANALYZERS =~ /eslint/
|
||||||
|
exists:
|
||||||
|
- '**/*.html'
|
||||||
|
- '**/*.js'
|
||||||
|
- '**/*.jsx'
|
||||||
|
- '**/*.ts'
|
||||||
|
- '**/*.tsx'
|
||||||
|
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
dev-build-job:
|
||||||
|
stage: build
|
||||||
|
only:
|
||||||
|
- develop
|
||||||
|
- merge_requests
|
||||||
|
image: docker:stable
|
||||||
|
variables:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
POSTGRES_DB: demo_server
|
||||||
|
services:
|
||||||
|
- name: registry.lawsnote.com/postgres:13-pgroonga
|
||||||
|
alias: postgres
|
||||||
|
- name: redis:5-alpine
|
||||||
|
alias: redis
|
||||||
|
script:
|
||||||
|
# 用 DATE-CI_COMMIT_SHA 當做 docker image 的 tag
|
||||||
|
- DOCKER_IMAGE_TAG=$(date +%Y%m%d%H%M%S)-${CI_COMMIT_SHA:0:8}
|
||||||
|
- DOCKER_BUILDKIT=1 docker build
|
||||||
|
--ssh default="$SSH_PRIVATE_KEY"
|
||||||
|
--build-arg CI_COMMIT_SHA="$CI_COMMIT_SHA"
|
||||||
|
-t registry.lawsnote.com/professorx-dev:$DOCKER_IMAGE_TAG .
|
||||||
|
# 使用 build 好的 image 測試
|
||||||
|
- docker run
|
||||||
|
--rm
|
||||||
|
-e DB_HOST=$POSTGRES_PORT_5432_TCP_ADDR
|
||||||
|
-e DB_USER=postgres
|
||||||
|
-e DB_PASSWORD=password
|
||||||
|
-e DB_NAME=demo_server
|
||||||
|
-e REDIS_HOST=$REDIS_PORT_6379_TCP_ADDR
|
||||||
|
-e SMS_VENDER=empty
|
||||||
|
-e NODE_ENV=test
|
||||||
|
registry.lawsnote.com/professorx-dev:$DOCKER_IMAGE_TAG yarn test-with-db
|
||||||
|
# push docker image
|
||||||
|
- docker push registry.lawsnote.com/professorx-dev:$DOCKER_IMAGE_TAG
|
||||||
|
# tag latest
|
||||||
|
- docker tag registry.lawsnote.com/professorx-dev:$DOCKER_IMAGE_TAG registry.lawsnote.com/professorx-dev:latest
|
||||||
|
- docker push registry.lawsnote.com/professorx-dev:latest
|
||||||
|
# delete local image
|
||||||
|
- docker rmi registry.lawsnote.com/professorx-dev:latest
|
||||||
|
# 執行 Galactus 來刪除不必要的 image,只保留 10 個舊版
|
||||||
|
- docker run --rm --env TARGET_IMAGE=professorx-dev --env KEEP_COUNT=10 --env FORCE=1 registry.lawsnote.com/galactus:latest
|
||||||
|
tags:
|
||||||
|
- docker
|
||||||
|
|
||||||
|
dev-deploy-job:
|
||||||
|
stage: deploy
|
||||||
|
only:
|
||||||
|
- develop
|
||||||
|
script:
|
||||||
|
- docker pull registry.lawsnote.com/professorx-dev:latest
|
||||||
|
- if [ "$(docker inspect -f '{{.State.Running}}' professorx-dev 2> /dev/null)" == "true" ]; then docker rm -f -v professorx-dev; fi
|
||||||
|
- docker run --detach --restart always --log-driver=json-file --log-opt max-size=16m --log-opt max-file=2 --publish 30041:10230 --name professorx-dev --env-file "$DEV_SERVICE_ENV" registry.lawsnote.com/professorx-dev:latest
|
||||||
|
tags:
|
||||||
|
- office
|
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# syntax=docker/dockerfile:experimental
|
||||||
|
FROM node:14.14.0 as deps
|
||||||
|
USER root
|
||||||
|
WORKDIR /data
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
#RUN apk add --no-cache git openssh-client
|
||||||
|
RUN mkdir -p /root/.ssh
|
||||||
|
RUN echo "Host git.lawsnote.com\n\tStrictHostKeyChecking no\n\tUser git\n\tHostname git.lawsnote.com\n\tPort 2222\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config
|
||||||
|
# SSH key is set in docker build argument, ex: docker build --ssh default="$SSH_PRIVATE_KEY"
|
||||||
|
RUN --mount=type=ssh yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM node:14.14.0-slim
|
||||||
|
WORKDIR /data
|
||||||
|
ARG CI_COMMIT_SHA
|
||||||
|
COPY . .
|
||||||
|
COPY --from=deps /data/node_modules ./node_modules/
|
||||||
|
#RUN apk add --no-cache ffmpeg \
|
||||||
|
# && chown -R node:node .
|
||||||
|
RUN chown -R node:node . \
|
||||||
|
&& sed -i "s/__CI_COMMIT_SHA__/$CI_COMMIT_SHA/" ./public/html/version.html
|
||||||
|
USER node
|
||||||
|
CMD ["yarn", "start"]
|
53
README.md
Normal file
53
README.md
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# Demo Server
|
||||||
|
|
||||||
|
KeyCloak 測試 Server
|
||||||
|
|
||||||
|
## 設定環境變數
|
||||||
|
|
||||||
|
```
|
||||||
|
# 伺服器完整網址
|
||||||
|
SERVER_URL
|
||||||
|
|
||||||
|
# 伺服器監聽埠
|
||||||
|
SERVER_PORT
|
||||||
|
|
||||||
|
# JWT 簽名 Secret
|
||||||
|
SERVER_JWT_SECRET
|
||||||
|
|
||||||
|
# Redis 位址
|
||||||
|
REDIS_HOST
|
||||||
|
|
||||||
|
# Redis Port
|
||||||
|
REDIS_PORT
|
||||||
|
|
||||||
|
# Redis 密碼,沒有則留空
|
||||||
|
REDIS_PASSWORD
|
||||||
|
|
||||||
|
# Redis 鍵值前綴
|
||||||
|
REDIS_PREFIX
|
||||||
|
|
||||||
|
# Redis 連線的資料庫號碼
|
||||||
|
REDIS_DB
|
||||||
|
|
||||||
|
# PostgreSQL 資料庫位址
|
||||||
|
DB_HOST
|
||||||
|
|
||||||
|
# PostgreSQL 資料庫連接埠
|
||||||
|
DB_PORT
|
||||||
|
|
||||||
|
# PostgreSQL 資料庫使用者
|
||||||
|
DB_USER
|
||||||
|
|
||||||
|
# PostgreSQL 資料庫密碼
|
||||||
|
DB_PASSWORD
|
||||||
|
|
||||||
|
# PostgreSQL 資料庫名稱
|
||||||
|
DB_NAME
|
||||||
|
|
||||||
|
# PostgreSQL 資料庫連接池最大連線數
|
||||||
|
DB_POOL_MAX
|
||||||
|
|
||||||
|
# PostgreSQL 資料庫連接池閒置連線數
|
||||||
|
DB_POOL_MIN
|
||||||
|
|
||||||
|
```
|
114
bin/db-migrate.js
Normal file
114
bin/db-migrate.js
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
const pg = require('pg');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const config = require('src/config/index.js');
|
||||||
|
|
||||||
|
// schema file name format ######_name.sql
|
||||||
|
const schemaDir = path.resolve(__dirname, '..', 'schemas');
|
||||||
|
|
||||||
|
const db = new pg.Client({
|
||||||
|
host: config.database.host,
|
||||||
|
port: config.database.port,
|
||||||
|
user: config.database.user,
|
||||||
|
password: config.database.password,
|
||||||
|
database: config.database.dbname,
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await db.connect();
|
||||||
|
|
||||||
|
await db.query(`select now();`);
|
||||||
|
|
||||||
|
let version = -1;
|
||||||
|
|
||||||
|
// check migrate record table exists
|
||||||
|
const checkTable = await db.query(
|
||||||
|
`
|
||||||
|
select exists(
|
||||||
|
select 1
|
||||||
|
from "information_schema"."tables"
|
||||||
|
where
|
||||||
|
"table_schema" = $1
|
||||||
|
and "table_name" = $2
|
||||||
|
) as exists
|
||||||
|
`,
|
||||||
|
['public', 'migrate_log']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (checkTable.rowCount > 0 && checkTable.rows[0].exists === true) {
|
||||||
|
// version table exists
|
||||||
|
const maxVersion = await db.query(`select max("version")::integer as version from "public"."migrate_log"`);
|
||||||
|
if (maxVersion.rowCount > 0 && maxVersion.rows[0] && maxVersion.rows[0].version !== null) version = maxVersion.rows[0].version; // eslint-disable-line
|
||||||
|
} else {
|
||||||
|
// create version table
|
||||||
|
await db.query(`create table "public"."migrate_log" (
|
||||||
|
"version" integer not null primary key,
|
||||||
|
"created_time" timestamptz not null default now()
|
||||||
|
);`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`Database Now Version: ${version}`);
|
||||||
|
|
||||||
|
// read all schema files
|
||||||
|
const schemaList = await fs.promises.readdir(schemaDir);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {{[x: number]: boolean}}
|
||||||
|
*/
|
||||||
|
const checkDuplicate = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {{version: number, filename: string}[]}
|
||||||
|
*/
|
||||||
|
const versionList = schemaList
|
||||||
|
.map(file => {
|
||||||
|
const strs = file.split('_');
|
||||||
|
const v = parseInt(strs[0], 10);
|
||||||
|
if (isNaN(version)) throw new Error(`schema filename format error (######_name.sql)`); // eslint-disable-line
|
||||||
|
|
||||||
|
if (v in checkDuplicate) throw new Error(`schema file version (${v}) is duplicate`);
|
||||||
|
|
||||||
|
checkDuplicate[v] = true;
|
||||||
|
|
||||||
|
return { version: v, filename: file };
|
||||||
|
})
|
||||||
|
.filter(t => t && t.version > version)
|
||||||
|
.sort((a, b) => a.version - b.version);
|
||||||
|
|
||||||
|
// 沒有需要更新的檔案
|
||||||
|
if (versionList.length === 0) return;
|
||||||
|
|
||||||
|
await db.query('begin');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const vers = [];
|
||||||
|
// write all schema file
|
||||||
|
for (const it of versionList) {
|
||||||
|
vers.push(`(${it.version})`);
|
||||||
|
|
||||||
|
console.info(`Write Version: ${it.version}`);
|
||||||
|
|
||||||
|
const fileContent = await fs.promises.readFile(path.resolve(schemaDir, it.filename), 'utf-8');
|
||||||
|
|
||||||
|
await db.query(fileContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(`insert into "public"."migrate_log" ("version") values ${vers.join(',')}`);
|
||||||
|
|
||||||
|
await db.query('commit');
|
||||||
|
} catch (err) {
|
||||||
|
await db.query('rollback');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.then(() => {
|
||||||
|
console.info('Database Migrate Finish');
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Database Migrate Failed, ', err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
db.end();
|
||||||
|
});
|
58
bin/migrate-tool.js
Normal file
58
bin/migrate-tool.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const readline = require('readline');
|
||||||
|
const { padLeft } = require('src/utils/index.js');
|
||||||
|
|
||||||
|
const schemaDir = path.resolve(__dirname, '..', 'schemas');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let filename = args[0] || '';
|
||||||
|
if (args.length === 0) {
|
||||||
|
// use readline
|
||||||
|
filename = await new Promise(resolve => {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.prompt();
|
||||||
|
rl.question('schema filename: ', ans => {
|
||||||
|
resolve(ans.replace(' ', '_'));
|
||||||
|
|
||||||
|
rl.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.once('close', resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filename === '') throw new Error('no schema filename');
|
||||||
|
|
||||||
|
const schemaFiles = await fs.promises.readdir(schemaDir);
|
||||||
|
let version = 0;
|
||||||
|
|
||||||
|
schemaFiles.forEach(name => {
|
||||||
|
if (!name.endsWith('.sql')) return;
|
||||||
|
|
||||||
|
const strInt = name.split(/_/g)[0];
|
||||||
|
const v = parseInt(strInt, 10);
|
||||||
|
if (isNaN(v)) return; // eslint-disable-line
|
||||||
|
|
||||||
|
if (v > version) version = v;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 版本要比最後一筆加一
|
||||||
|
version += 1;
|
||||||
|
|
||||||
|
const schemaName = `${padLeft(`${version}`, 6, '0')}_${filename}.sql`;
|
||||||
|
|
||||||
|
const schemaText = `-- Created Time ${new Date().toISOString()}`;
|
||||||
|
|
||||||
|
await fs.promises.writeFile(path.resolve(schemaDir, schemaName), schemaText, 'utf-8');
|
||||||
|
|
||||||
|
console.info(`File: ${path.resolve(schemaDir, schemaName)} Created!`);
|
||||||
|
})().catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
38
config/index.js
Normal file
38
config/index.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
const { env } = process;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
server: {
|
||||||
|
url: env.SERVER_URL || 'http://localhost:10230',
|
||||||
|
cms_api_url: env.SERVER_CMS_API_URL || 'http://localhost:10230',
|
||||||
|
port: parseInt(env.SERVER_PORT, 10) || 10230,
|
||||||
|
jwt_secret: env.SERVER_JWT_SECRET || 'testsecret',
|
||||||
|
jwt_expire: parseInt(env.SERVER_JWT_EXPIRE, 10) || 60 * 60 * 24 * 30, // 30 day
|
||||||
|
tos_url: env.SERVER_TOS_URL || 'http://localhost:10230',
|
||||||
|
course_contract_url: env.SERVER_COURSE_CONTRACT_URL || 'http://localhost:10230',
|
||||||
|
cms_limit_enabled: env.SERVER_CMS_LIMIT_ENABLED !== '0', // 啟用CMS routing 限制
|
||||||
|
cms_limit_token: env.SERVER_CMS_LIMIT_TOKEN || '',
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
host: env.REDIS_HOST || 'localhost',
|
||||||
|
port: parseInt(env.REDIS_PORT, 10) || 6379,
|
||||||
|
password: env.REDIS_PASSWORD || '',
|
||||||
|
prefix: env.REDIS_PREFIX || '',
|
||||||
|
db: parseInt(env.REDIS_DB, 10) || 0,
|
||||||
|
},
|
||||||
|
sso: {
|
||||||
|
authorized_endpoint: env.SSO_AUTHORIZED_ENDPOINT || '',
|
||||||
|
token_endpoint: env.SSO_TOKEN_ENDPOINT || '',
|
||||||
|
logout_endpoint: env.SSO_LOGOUT_ENDPOINT || '',
|
||||||
|
client_id: env.SSO_CLIENT_ID || '',
|
||||||
|
client_secret: env.SSO_CLIENT_SECRET || '',
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
host: env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(env.DB_PORT, 10) || 5432,
|
||||||
|
user: env.DB_USER || 'postgres',
|
||||||
|
password: env.DB_PASSWORD || '',
|
||||||
|
dbname: env.DB_NAME || 'professor_x',
|
||||||
|
pool_max: parseInt(env.DB_POOL_MAX, 10) || 5,
|
||||||
|
pool_min: parseInt(env.DB_POOL_MIN, 10) || 2,
|
||||||
|
},
|
||||||
|
};
|
9
constants/index.js
Normal file
9
constants/index.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/* eslint-disable no-bitwise */
|
||||||
|
const constants = {
|
||||||
|
PAGE_SIZE: 20,
|
||||||
|
OPENID_EXPIRE: 300, // 5min
|
||||||
|
INTERNAL_REGULATION_CACHE_TTL: 1800, // 30min
|
||||||
|
REPORT_CACHE_TTL: 600, // 10 min
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = constants;
|
26
controllers/account/v1/index.js
Normal file
26
controllers/account/v1/index.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
const { resp } = require('src/utils/response/index.js');
|
||||||
|
const redis = require('src/utils/redis.js');
|
||||||
|
const sso = require('src/utils/sso/index.js');
|
||||||
|
const { OPENID_EXPIRE } = require('src/constants/index.js');
|
||||||
|
const uuid = require('uuid');
|
||||||
|
const url = require('url');
|
||||||
|
|
||||||
|
const controller = {};
|
||||||
|
module.exports = controller;
|
||||||
|
|
||||||
|
controller.loginSSO = () => async ctx => {
|
||||||
|
const { back_url: backURL } = ctx.query;
|
||||||
|
|
||||||
|
const state = uuid.v4();
|
||||||
|
|
||||||
|
const authURL = sso.getAuthURL(state);
|
||||||
|
|
||||||
|
// store back url to cache
|
||||||
|
const cacheKey = redis.Key.ssoLoginCache(state);
|
||||||
|
|
||||||
|
await redis.set(cacheKey, JSON.stringify({ back_url: backURL }), 'EX', OPENID_EXPIRE);
|
||||||
|
|
||||||
|
const u = new url.URL(authURL);
|
||||||
|
|
||||||
|
ctx.resp(resp.Success, { url: u.toString() });
|
||||||
|
};
|
113
controllers/common/index.js
Normal file
113
controllers/common/index.js
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
/* eslint-disable no-bitwise */
|
||||||
|
const debug = require('debug')('ctrl:common');
|
||||||
|
const util = require('util');
|
||||||
|
const joi = require('joi');
|
||||||
|
const response = require('src/utils/response/index.js');
|
||||||
|
const { copyObject, toNumber } = require('src/utils/index.js');
|
||||||
|
|
||||||
|
const { Success, InternalError, DataFormat } = response.resp;
|
||||||
|
|
||||||
|
const controller = {};
|
||||||
|
module.exports = controller;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* api reponse function
|
||||||
|
* @param {import('src/utils/response/index.js').respObject} resp
|
||||||
|
* @param {string|Object} body
|
||||||
|
*/
|
||||||
|
function responseFunc(resp, body) {
|
||||||
|
/** @type {import('src/utils/response/index.js').respObject} */
|
||||||
|
const copy = copyObject(response.checkStruct(resp) ? resp : Success);
|
||||||
|
|
||||||
|
if (typeof body === 'string') copy.object.message = body;
|
||||||
|
else if (typeof body === 'object') copy.object = body;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if (!('obj' in this)) this.obj = {};
|
||||||
|
this.obj.status = copy.status;
|
||||||
|
this.obj.object = copy.object;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* api error response function
|
||||||
|
* @param {import('src/utils/response/index.js').respObject} resp
|
||||||
|
* @param {import('src/utils/response/index.js').codeMessage=} code
|
||||||
|
*/
|
||||||
|
function responseError(resp, code) {
|
||||||
|
/** @type {import('src/utils/response/index.js').respObject} */
|
||||||
|
const copy = copyObject(response.checkStruct(resp) ? resp : InternalError);
|
||||||
|
|
||||||
|
if (code && typeof code === 'object' && 'message' in code && 'code' in code) {
|
||||||
|
copy.object = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = new response.APIError(copy.object.message, copy);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.apiHandler = () => async (ctx, next) => {
|
||||||
|
ctx.obj = {};
|
||||||
|
ctx.token = {};
|
||||||
|
|
||||||
|
ctx.resp = responseFunc.bind(ctx);
|
||||||
|
ctx.err = responseError;
|
||||||
|
|
||||||
|
ctx.getBody = key => (ctx.request.body || {})[key];
|
||||||
|
ctx.getFile = key => (ctx.request.files || {})[key];
|
||||||
|
|
||||||
|
// run next
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
} catch (err) {
|
||||||
|
debug(`Get API Throw Error: ${util.inspect(err, false, null)}`);
|
||||||
|
// debug(err.stack);
|
||||||
|
if (!(err instanceof response.APIError)) {
|
||||||
|
ctx.resp(InternalError);
|
||||||
|
} else {
|
||||||
|
ctx.obj = err.object;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
ctx.obj.object.errorStack = err.stack;
|
||||||
|
ctx.obj.object.errorMessage = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(ctx.obj).length > 0) {
|
||||||
|
ctx.status = ctx.obj.status;
|
||||||
|
ctx.body = ctx.obj.object;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* data validate middleware
|
||||||
|
* @param {{query?: any, header?: any, body?: any}} schema body,query and header is joi.Schema
|
||||||
|
*/
|
||||||
|
controller.validate = schema => {
|
||||||
|
if (typeof schema !== 'object') responseError(InternalError);
|
||||||
|
const v = {};
|
||||||
|
if ('body' in schema) v.body = joi.isSchema(schema.body) ? schema.body : joi.object(schema.body).unknown();
|
||||||
|
if ('header' in schema) v.header = joi.isSchema(schema.header) ? schema.header : joi.object(schema.header).unknown();
|
||||||
|
if ('query' in schema) v.query = joi.isSchema(schema.query) ? schema.query : joi.object(schema.query).unknown();
|
||||||
|
|
||||||
|
return async (ctx, next) => {
|
||||||
|
try {
|
||||||
|
await joi.object(v).unknown().validateAsync({ query: ctx.query, header: ctx.headers, body: ctx.request.body });
|
||||||
|
} catch (err) {
|
||||||
|
debug(`data validate error: ${util.inspect(err, false, null)}`);
|
||||||
|
responseError(DataFormat);
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.getAppVersion = () => async (ctx, next) => {
|
||||||
|
// appVersion Format x.y.z (major.minor.patch)
|
||||||
|
const appVersion = ctx.get('x-app-version');
|
||||||
|
const appBuildNumber = toNumber(ctx.get('x-app-buildnumber'), 0);
|
||||||
|
const appPlatform = ctx.get('x-app-platform');
|
||||||
|
|
||||||
|
Object.assign(ctx.state, { appVersion, appBuildNumber, appPlatform });
|
||||||
|
|
||||||
|
return next();
|
||||||
|
};
|
28
controllers/index.js
Normal file
28
controllers/index.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
const controller = {};
|
||||||
|
module.exports = controller;
|
||||||
|
|
||||||
|
controller.healthCheck = async ctx => {
|
||||||
|
ctx.body = 'ok';
|
||||||
|
ctx.status = 200;
|
||||||
|
};
|
||||||
|
|
||||||
|
controller.appleAppSiteAssociation = async ctx => {
|
||||||
|
ctx.status = 200;
|
||||||
|
ctx.body = {
|
||||||
|
applinks: {
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
appID: 'CL3K9D5FDN.com.lawsnote.college.staging',
|
||||||
|
paths: ['*'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appID: 'CL3K9D5FDN.com.lawsnote.college',
|
||||||
|
paths: ['*'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
webcredentials: {
|
||||||
|
apps: ['CL3K9D5FDN.com.lawsnote.college.staging', 'CL3K9D5FDN.com.lawsnote.college'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
73
controllers/oauth/index.js
Normal file
73
controllers/oauth/index.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
const debug = require('debug')('ctrl:common');
|
||||||
|
const util = require('util');
|
||||||
|
const url = require('url');
|
||||||
|
const sso = require('src/utils/sso/index.js');
|
||||||
|
const redis = require('src/utils/redis.js');
|
||||||
|
const { codeMessage, APIError } = require('src/utils/response/index.js');
|
||||||
|
const config = require('src/config/index.js');
|
||||||
|
const { jwt } = require('src/utils/pkgs.js');
|
||||||
|
|
||||||
|
const controller = {};
|
||||||
|
module.exports = controller;
|
||||||
|
|
||||||
|
controller.verifyCode = () => async ctx => {
|
||||||
|
const { code, session_state: sessionState, state } = ctx.query;
|
||||||
|
|
||||||
|
// logout flow redirect tot frontend
|
||||||
|
if (state === 'logout') {
|
||||||
|
ctx.redirect(config.server.frontend_url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get back url from redis
|
||||||
|
const cacheKey = redis.Key.ssoLoginCache(state);
|
||||||
|
|
||||||
|
const data = await redis.get(cacheKey);
|
||||||
|
if (!data) ctx.throw('get login cache fail');
|
||||||
|
const stateObj = JSON.parse(data);
|
||||||
|
const { back_url: backURL } = stateObj;
|
||||||
|
if (!backURL) ctx.throw('cache data missing');
|
||||||
|
|
||||||
|
const u = new url.URL(backURL);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await sso.getToken(code, sessionState);
|
||||||
|
|
||||||
|
// generate jwt token
|
||||||
|
const jwtToken = jwt.sign(
|
||||||
|
{
|
||||||
|
user_id: `${token}-id`,
|
||||||
|
sso: true,
|
||||||
|
},
|
||||||
|
config.server.jwt_secret,
|
||||||
|
{
|
||||||
|
expiresIn: config.server.jwt_expire,
|
||||||
|
issuer: 'lawsnote',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
u.searchParams.append('success', Buffer.from(JSON.stringify({ token: jwtToken })).toString('base64'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await redis.del(cacheKey);
|
||||||
|
} catch (err) {
|
||||||
|
debug(`delete cache fail: ${util.inspect(err, false, null)}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
debug(`openid verify fail: ${util.inspect(err, false, null)}`);
|
||||||
|
|
||||||
|
/** @type {object} */
|
||||||
|
const errObj = { ...codeMessage.CodeInternalError };
|
||||||
|
|
||||||
|
if (err instanceof APIError) {
|
||||||
|
// @ts-ignore
|
||||||
|
Object.assign(errObj, err.object.object);
|
||||||
|
}
|
||||||
|
|
||||||
|
errObj.errorStack = err.stack;
|
||||||
|
errObj.errorMessage = err.message;
|
||||||
|
u.searchParams.append('error', Buffer.from(JSON.stringify(errObj)).toString('base64'));
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.redirect(u.toString());
|
||||||
|
};
|
3
doc/testScript.txt
Normal file
3
doc/testScript.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
curl -X GET -H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-H "Authorization: Bearer " \
|
||||||
|
https://auth.mtfos.xyz/auth/realms/Vision/protocol/openid-connect/userinfo
|
10
example.env
Normal file
10
example.env
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
DB_PASSWORD=password
|
||||||
|
DB_NAME=professorx
|
||||||
|
DEBUG=ctrl*,knex*
|
||||||
|
DB_MIGRATE=1
|
||||||
|
SERVER_CMS_LIMIT_ENABLED=0
|
||||||
|
SSO_AUTHORIZED_ENDPOINT=https://auth.mtfos.xyz/auth/realms/Vision/protocol/openid-connect/auth
|
||||||
|
SSO_TOKEN_ENDPOINT=https://auth.mtfos.xyz/auth/realms/Vision/protocol/openid-connect/token
|
||||||
|
SSO_LOGOUT_ENDPOINT=https://auth.mtfos.xyz/auth/realms/Vision/protocol/openid-connect/logout
|
||||||
|
SSO_CLIENT_ID=vision
|
||||||
|
SSO_CLIENT_SECRET=8f6c1fa0-41a0-4218-a02d-3fb28850eb73
|
13
index.js
Normal file
13
index.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const config = require('src/config/index.js');
|
||||||
|
const app = require('./server.js');
|
||||||
|
|
||||||
|
async function runServer() {
|
||||||
|
const server = app.listen(config.server.port, () => {
|
||||||
|
// @ts-ignore
|
||||||
|
console.info(`server start on port ${server.address().port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
runServer();
|
10
jsconfig.json
Normal file
10
jsconfig.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2019",
|
||||||
|
"lib": ["es2020.string"],
|
||||||
|
"checkJs": true,
|
||||||
|
"module": "commonjs",
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
25
model/base.js
Normal file
25
model/base.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
const db = require('src/utils/database.js');
|
||||||
|
|
||||||
|
class Base {
|
||||||
|
constructor() {
|
||||||
|
this.cols = [];
|
||||||
|
this.schema = 'public';
|
||||||
|
this.table = '';
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
async transaction(trxFunc) {
|
||||||
|
if (typeof trxFunc !== 'function') throw new Error('transaction function type error');
|
||||||
|
return this.db.transaction(trxFunc);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkSchema() {
|
||||||
|
await this.db
|
||||||
|
.withSchema(this.schema)
|
||||||
|
.from(this.table)
|
||||||
|
.select(...this.cols)
|
||||||
|
.limit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Base;
|
14
model/common.js
Normal file
14
model/common.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/* eslint-disable func-names */
|
||||||
|
const Base = require('./base.js');
|
||||||
|
|
||||||
|
class Common extends Base {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async test() {
|
||||||
|
// nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Common;
|
25
model/public/account.js
Normal file
25
model/public/account.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// const debug = require('debug')('models:account');
|
||||||
|
const Base = require('src/model/base.js');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef AccountModel
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} phone
|
||||||
|
* @property {string} password with bcrypt
|
||||||
|
* @property {string} display_name
|
||||||
|
* @property {string} secret
|
||||||
|
* @property {string} created_time
|
||||||
|
* @property {string} updated_time
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Acconut extends Base {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async test() {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Acconut;
|
16103
package-lock.json
generated
Normal file
16103
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
package.json
Normal file
57
package.json
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "demo-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "lawsnote oauth demo server",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"migrate": "node bin/db-migrate.js",
|
||||||
|
"test": "mocha --timeout 5000 --exit test/ && jest --passWithNoTests --runInBand --coverage .",
|
||||||
|
"test-with-db": "npm run migrate && npm run test",
|
||||||
|
"new-schema": "node bin/migrate-tool.js",
|
||||||
|
"postinstall": "node -e \"var s='../',d='node_modules/src',fs=require('fs');fs.exists(d,function(e){e||fs.symlinkSync(s,d,'dir')});\""
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "Jay <admin@trj.tw>",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@google-cloud/storage": "5.4.0",
|
||||||
|
"@koa/cors": "^3.0.0",
|
||||||
|
"@koa/router": "^8.0.5",
|
||||||
|
"@mtfos/swagger-generator": "git+https://github.com/otakukaze/swagger-generator.git#1.2.2",
|
||||||
|
"axios": "0.21.0",
|
||||||
|
"debug": "4.2.0",
|
||||||
|
"dotenv": "^8.2.0",
|
||||||
|
"got": "^11.8.2",
|
||||||
|
"ioredis": "4.19.0",
|
||||||
|
"joi": "17.3.0",
|
||||||
|
"jsonwebtoken": "8.5.1",
|
||||||
|
"knex": "0.21.15",
|
||||||
|
"koa": "^2.11.0",
|
||||||
|
"koa-body": "^4.1.1",
|
||||||
|
"koa-logger": "^3.2.1",
|
||||||
|
"koa-mount": "4.0.0",
|
||||||
|
"koa-range": "0.3.0",
|
||||||
|
"koa-static": "5.0.0",
|
||||||
|
"pg": "8.4.1",
|
||||||
|
"uuid": "8.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"chai": "4.2.0",
|
||||||
|
"eslint": "^7.2.0",
|
||||||
|
"eslint-config-airbnb-base": "^14.2.0",
|
||||||
|
"eslint-config-prettier": "^6.11.0",
|
||||||
|
"eslint-plugin-import": "^2.21.2",
|
||||||
|
"eslint-plugin-prettier": "^3.1.3",
|
||||||
|
"jest": "^26.6.0",
|
||||||
|
"mocha": "^8.2.0",
|
||||||
|
"prettier": "^2.0.5",
|
||||||
|
"supertest": "^5.0.0"
|
||||||
|
},
|
||||||
|
"nodemonConfig": {
|
||||||
|
"ignore": [
|
||||||
|
"credential.json",
|
||||||
|
"fcm_credential.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
10
public/html/liveness.html
Normal file
10
public/html/liveness.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title></title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
live
|
||||||
|
</body>
|
||||||
|
</html>
|
10
public/html/readiness.html
Normal file
10
public/html/readiness.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title></title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
readiness
|
||||||
|
</body>
|
||||||
|
</html>
|
BIN
public/swagger-ui/favicon-16x16.png
Normal file
BIN
public/swagger-ui/favicon-16x16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 665 B |
BIN
public/swagger-ui/favicon-32x32.png
Normal file
BIN
public/swagger-ui/favicon-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 628 B |
62
public/swagger-ui/index.html
Normal file
62
public/swagger-ui/index.html
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<!-- HTML for static distribution bundle build -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Swagger UI</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" >
|
||||||
|
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
|
||||||
|
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
|
||||||
|
<style>
|
||||||
|
html
|
||||||
|
{
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: -moz-scrollbars-vertical;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*:before,
|
||||||
|
*:after
|
||||||
|
{
|
||||||
|
box-sizing: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
body
|
||||||
|
{
|
||||||
|
margin:0;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
|
||||||
|
<script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
|
||||||
|
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
|
||||||
|
<script>
|
||||||
|
window.onload = function() {
|
||||||
|
// Begin Swagger UI call region
|
||||||
|
const ui = SwaggerUIBundle({
|
||||||
|
url: "/api-docs.json",
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
docExpansion: 'none',
|
||||||
|
validatorUrl: null,
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
SwaggerUIBundle.plugins.DownloadUrl
|
||||||
|
],
|
||||||
|
layout: "StandaloneLayout"
|
||||||
|
})
|
||||||
|
// End Swagger UI call region
|
||||||
|
|
||||||
|
window.ui = ui
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
74
public/swagger-ui/oauth2-redirect.html
Normal file
74
public/swagger-ui/oauth2-redirect.html
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en-US">
|
||||||
|
<head>
|
||||||
|
<title>Swagger UI: OAuth2 Redirect</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
function run () {
|
||||||
|
var oauth2 = window.opener.swaggerUIRedirectOauth2;
|
||||||
|
var sentState = oauth2.state;
|
||||||
|
var redirectUrl = oauth2.redirectUrl;
|
||||||
|
var isValid, qp, arr;
|
||||||
|
|
||||||
|
if (/code|token|error/.test(window.location.hash)) {
|
||||||
|
qp = window.location.hash.substring(1);
|
||||||
|
} else {
|
||||||
|
qp = location.search.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
arr = qp.split("&")
|
||||||
|
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';})
|
||||||
|
qp = qp ? JSON.parse('{' + arr.join() + '}',
|
||||||
|
function (key, value) {
|
||||||
|
return key === "" ? value : decodeURIComponent(value)
|
||||||
|
}
|
||||||
|
) : {}
|
||||||
|
|
||||||
|
isValid = qp.state === sentState
|
||||||
|
|
||||||
|
if ((
|
||||||
|
oauth2.auth.schema.get("flow") === "accessCode"||
|
||||||
|
oauth2.auth.schema.get("flow") === "authorizationCode"
|
||||||
|
) && !oauth2.auth.code) {
|
||||||
|
if (!isValid) {
|
||||||
|
oauth2.errCb({
|
||||||
|
authId: oauth2.auth.name,
|
||||||
|
source: "auth",
|
||||||
|
level: "warning",
|
||||||
|
message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qp.code) {
|
||||||
|
delete oauth2.state;
|
||||||
|
oauth2.auth.code = qp.code;
|
||||||
|
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
|
||||||
|
} else {
|
||||||
|
let oauthErrorMsg
|
||||||
|
if (qp.error) {
|
||||||
|
oauthErrorMsg = "["+qp.error+"]: " +
|
||||||
|
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
|
||||||
|
(qp.error_uri ? "More info: "+qp.error_uri : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
oauth2.errCb({
|
||||||
|
authId: oauth2.auth.name,
|
||||||
|
source: "auth",
|
||||||
|
level: "error",
|
||||||
|
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
|
||||||
|
}
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', function () {
|
||||||
|
run();
|
||||||
|
});
|
||||||
|
</script>
|
3
public/swagger-ui/swagger-ui-bundle.js
Normal file
3
public/swagger-ui/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
1
public/swagger-ui/swagger-ui-bundle.js.map
Normal file
1
public/swagger-ui/swagger-ui-bundle.js.map
Normal file
File diff suppressed because one or more lines are too long
3
public/swagger-ui/swagger-ui-es-bundle-core.js
Normal file
3
public/swagger-ui/swagger-ui-es-bundle-core.js
Normal file
File diff suppressed because one or more lines are too long
1
public/swagger-ui/swagger-ui-es-bundle-core.js.map
Normal file
1
public/swagger-ui/swagger-ui-es-bundle-core.js.map
Normal file
File diff suppressed because one or more lines are too long
3
public/swagger-ui/swagger-ui-es-bundle.js
Normal file
3
public/swagger-ui/swagger-ui-es-bundle.js
Normal file
File diff suppressed because one or more lines are too long
1
public/swagger-ui/swagger-ui-es-bundle.js.map
Normal file
1
public/swagger-ui/swagger-ui-es-bundle.js.map
Normal file
File diff suppressed because one or more lines are too long
3
public/swagger-ui/swagger-ui-standalone-preset.js
Normal file
3
public/swagger-ui/swagger-ui-standalone-preset.js
Normal file
File diff suppressed because one or more lines are too long
1
public/swagger-ui/swagger-ui-standalone-preset.js.map
Normal file
1
public/swagger-ui/swagger-ui-standalone-preset.js.map
Normal file
File diff suppressed because one or more lines are too long
4
public/swagger-ui/swagger-ui.css
Normal file
4
public/swagger-ui/swagger-ui.css
Normal file
File diff suppressed because one or more lines are too long
1
public/swagger-ui/swagger-ui.css.map
Normal file
1
public/swagger-ui/swagger-ui.css.map
Normal file
File diff suppressed because one or more lines are too long
3
public/swagger-ui/swagger-ui.js
Normal file
3
public/swagger-ui/swagger-ui.js
Normal file
File diff suppressed because one or more lines are too long
1
public/swagger-ui/swagger-ui.js.map
Normal file
1
public/swagger-ui/swagger-ui.js.map
Normal file
File diff suppressed because one or more lines are too long
174
public/url.json
Normal file
174
public/url.json
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
{
|
||||||
|
"issuer": "https://auth.trj.tw/auth/realms/school",
|
||||||
|
"authorization_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/auth",
|
||||||
|
"token_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/token",
|
||||||
|
"introspection_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/token/introspect",
|
||||||
|
"userinfo_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/userinfo",
|
||||||
|
"end_session_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/logout",
|
||||||
|
"jwks_uri": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/certs",
|
||||||
|
"check_session_iframe": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/login-status-iframe.html",
|
||||||
|
"grant_types_supported": [
|
||||||
|
"authorization_code",
|
||||||
|
"implicit",
|
||||||
|
"refresh_token",
|
||||||
|
"password",
|
||||||
|
"client_credentials"
|
||||||
|
],
|
||||||
|
"response_types_supported": [
|
||||||
|
"code",
|
||||||
|
"none",
|
||||||
|
"id_token",
|
||||||
|
"token",
|
||||||
|
"id_token token",
|
||||||
|
"code id_token",
|
||||||
|
"code token",
|
||||||
|
"code id_token token"
|
||||||
|
],
|
||||||
|
"subject_types_supported": [
|
||||||
|
"public",
|
||||||
|
"pairwise"
|
||||||
|
],
|
||||||
|
"id_token_signing_alg_values_supported": [
|
||||||
|
"PS384",
|
||||||
|
"ES384",
|
||||||
|
"RS384",
|
||||||
|
"HS256",
|
||||||
|
"HS512",
|
||||||
|
"ES256",
|
||||||
|
"RS256",
|
||||||
|
"HS384",
|
||||||
|
"ES512",
|
||||||
|
"PS256",
|
||||||
|
"PS512",
|
||||||
|
"RS512"
|
||||||
|
],
|
||||||
|
"id_token_encryption_alg_values_supported": [
|
||||||
|
"RSA-OAEP",
|
||||||
|
"RSA-OAEP-256",
|
||||||
|
"RSA1_5"
|
||||||
|
],
|
||||||
|
"id_token_encryption_enc_values_supported": [
|
||||||
|
"A256GCM",
|
||||||
|
"A192GCM",
|
||||||
|
"A128GCM",
|
||||||
|
"A128CBC-HS256",
|
||||||
|
"A192CBC-HS384",
|
||||||
|
"A256CBC-HS512"
|
||||||
|
],
|
||||||
|
"userinfo_signing_alg_values_supported": [
|
||||||
|
"PS384",
|
||||||
|
"ES384",
|
||||||
|
"RS384",
|
||||||
|
"HS256",
|
||||||
|
"HS512",
|
||||||
|
"ES256",
|
||||||
|
"RS256",
|
||||||
|
"HS384",
|
||||||
|
"ES512",
|
||||||
|
"PS256",
|
||||||
|
"PS512",
|
||||||
|
"RS512",
|
||||||
|
"none"
|
||||||
|
],
|
||||||
|
"request_object_signing_alg_values_supported": [
|
||||||
|
"PS384",
|
||||||
|
"ES384",
|
||||||
|
"RS384",
|
||||||
|
"HS256",
|
||||||
|
"HS512",
|
||||||
|
"ES256",
|
||||||
|
"RS256",
|
||||||
|
"HS384",
|
||||||
|
"ES512",
|
||||||
|
"PS256",
|
||||||
|
"PS512",
|
||||||
|
"RS512",
|
||||||
|
"none"
|
||||||
|
],
|
||||||
|
"response_modes_supported": [
|
||||||
|
"query",
|
||||||
|
"fragment",
|
||||||
|
"form_post"
|
||||||
|
],
|
||||||
|
"registration_endpoint": "https://auth.trj.tw/auth/realms/school/clients-registrations/openid-connect",
|
||||||
|
"token_endpoint_auth_methods_supported": [
|
||||||
|
"private_key_jwt",
|
||||||
|
"client_secret_basic",
|
||||||
|
"client_secret_post",
|
||||||
|
"tls_client_auth",
|
||||||
|
"client_secret_jwt"
|
||||||
|
],
|
||||||
|
"token_endpoint_auth_signing_alg_values_supported": [
|
||||||
|
"PS384",
|
||||||
|
"ES384",
|
||||||
|
"RS384",
|
||||||
|
"HS256",
|
||||||
|
"HS512",
|
||||||
|
"ES256",
|
||||||
|
"RS256",
|
||||||
|
"HS384",
|
||||||
|
"ES512",
|
||||||
|
"PS256",
|
||||||
|
"PS512",
|
||||||
|
"RS512"
|
||||||
|
],
|
||||||
|
"claims_supported": [
|
||||||
|
"aud",
|
||||||
|
"sub",
|
||||||
|
"iss",
|
||||||
|
"auth_time",
|
||||||
|
"name",
|
||||||
|
"given_name",
|
||||||
|
"family_name",
|
||||||
|
"preferred_username",
|
||||||
|
"email",
|
||||||
|
"acr"
|
||||||
|
],
|
||||||
|
"claim_types_supported": [
|
||||||
|
"normal"
|
||||||
|
],
|
||||||
|
"claims_parameter_supported": true,
|
||||||
|
"scopes_supported": [
|
||||||
|
"openid",
|
||||||
|
"address",
|
||||||
|
"email",
|
||||||
|
"microprofile-jwt",
|
||||||
|
"offline_access",
|
||||||
|
"phone",
|
||||||
|
"profile",
|
||||||
|
"roles",
|
||||||
|
"web-origins"
|
||||||
|
],
|
||||||
|
"request_parameter_supported": true,
|
||||||
|
"request_uri_parameter_supported": true,
|
||||||
|
"require_request_uri_registration": true,
|
||||||
|
"code_challenge_methods_supported": [
|
||||||
|
"plain",
|
||||||
|
"S256"
|
||||||
|
],
|
||||||
|
"tls_client_certificate_bound_access_tokens": true,
|
||||||
|
"revocation_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/revoke",
|
||||||
|
"revocation_endpoint_auth_methods_supported": [
|
||||||
|
"private_key_jwt",
|
||||||
|
"client_secret_basic",
|
||||||
|
"client_secret_post",
|
||||||
|
"tls_client_auth",
|
||||||
|
"client_secret_jwt"
|
||||||
|
],
|
||||||
|
"revocation_endpoint_auth_signing_alg_values_supported": [
|
||||||
|
"PS384",
|
||||||
|
"ES384",
|
||||||
|
"RS384",
|
||||||
|
"HS256",
|
||||||
|
"HS512",
|
||||||
|
"ES256",
|
||||||
|
"RS256",
|
||||||
|
"HS384",
|
||||||
|
"ES512",
|
||||||
|
"PS256",
|
||||||
|
"PS512",
|
||||||
|
"RS512"
|
||||||
|
],
|
||||||
|
"backchannel_logout_supported": true,
|
||||||
|
"backchannel_logout_session_supported": true
|
||||||
|
}
|
12
routes/api/index.js
Normal file
12
routes/api/index.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
const Router = require('@koa/router');
|
||||||
|
|
||||||
|
const commonCtrl = require('src/controllers/common/index.js');
|
||||||
|
const v1Router = require('./v1/index.js');
|
||||||
|
|
||||||
|
const r = new Router({ prefix: '/api' });
|
||||||
|
module.exports = r;
|
||||||
|
|
||||||
|
// set api handler middleware
|
||||||
|
r.use(commonCtrl.apiHandler(), commonCtrl.getAppVersion());
|
||||||
|
|
||||||
|
r.use(v1Router.routes());
|
25
routes/api/v1/account/index.js
Normal file
25
routes/api/v1/account/index.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
const Router = require('@koa/router');
|
||||||
|
const joi = require('joi');
|
||||||
|
const commonCtrl = require('src/controllers/common/index.js');
|
||||||
|
const accCtrl = require('src/controllers/account/v1/index.js');
|
||||||
|
|
||||||
|
const r = new Router({ prefix: '/account' });
|
||||||
|
module.exports = r;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get account info
|
||||||
|
* @swagger
|
||||||
|
* @route GET /api/v1/account/login/sso
|
||||||
|
* @group account - account apis
|
||||||
|
* @param {string} back_url.query.required - back to url
|
||||||
|
* @returns {RespDefault.model} default -
|
||||||
|
*/
|
||||||
|
r.get(
|
||||||
|
'/login/sso',
|
||||||
|
commonCtrl.validate({
|
||||||
|
query: {
|
||||||
|
back_url: joi.string().required(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
accCtrl.loginSSO()
|
||||||
|
);
|
8
routes/api/v1/index.js
Normal file
8
routes/api/v1/index.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
const Router = require('@koa/router');
|
||||||
|
|
||||||
|
const accountRouter = require('./account/index.js');
|
||||||
|
|
||||||
|
const r = new Router({ prefix: '/v1' });
|
||||||
|
module.exports = r;
|
||||||
|
|
||||||
|
r.use(accountRouter.routes());
|
14
routes/index.js
Normal file
14
routes/index.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
const Router = require('@koa/router');
|
||||||
|
|
||||||
|
const controller = require('src/controllers/index.js');
|
||||||
|
const apiRouter = require('./api/index.js');
|
||||||
|
const oauthRouter = require('./oauth/index.js');
|
||||||
|
|
||||||
|
const r = new Router();
|
||||||
|
module.exports = r;
|
||||||
|
|
||||||
|
r.get('/', controller.healthCheck);
|
||||||
|
r.get(['/apple-app-site-association', '/.well-known/apple-app-site-association'], controller.appleAppSiteAssociation);
|
||||||
|
|
||||||
|
r.use(apiRouter.routes());
|
||||||
|
r.use(oauthRouter.routes());
|
8
routes/oauth/index.js
Normal file
8
routes/oauth/index.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
const Router = require('@koa/router');
|
||||||
|
const oauthCtrl = require('src/controllers/oauth/index.js');
|
||||||
|
|
||||||
|
const r = new Router({ prefix: '/oauth' });
|
||||||
|
|
||||||
|
r.get('/redirect', oauthCtrl.verifyCode());
|
||||||
|
|
||||||
|
module.exports = r;
|
29
routes/types.js
Normal file
29
routes/types.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
module.exports = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef RespDefault
|
||||||
|
* @description 預設回傳格式
|
||||||
|
* @property {string} message
|
||||||
|
* @property {number} code MessageCode
|
||||||
|
* @property {string} errorStack api error stack (除了prod以外的環境會有)
|
||||||
|
* @property {string} errorMessage api error message (除了prod以外的環境會有)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef Pager
|
||||||
|
* @description 頁數資訊
|
||||||
|
* @property {number} page 目前頁數
|
||||||
|
* @property {number} count 總筆數
|
||||||
|
* @property {number} total 總頁數
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef Account
|
||||||
|
* @description API回傳使用者資訊
|
||||||
|
* @property {string} id 使用者ID
|
||||||
|
* @property {string} phone 手機
|
||||||
|
* @property {string} display_name 顯示名稱
|
||||||
|
* @property {string} created_time 帳號建立時間
|
||||||
|
* @property {string} updated_time 帳號更新時間
|
||||||
|
*/
|
50
schemas/000000_init.sql
Normal file
50
schemas/000000_init.sql
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
--
|
||||||
|
-- PostgreSQL database dump
|
||||||
|
--
|
||||||
|
|
||||||
|
-- Dumped from database version 11.7
|
||||||
|
-- Dumped by pg_dump version 11.7
|
||||||
|
|
||||||
|
SET statement_timeout = 0;
|
||||||
|
SET lock_timeout = 0;
|
||||||
|
SET idle_in_transaction_session_timeout = 0;
|
||||||
|
SET client_encoding = 'UTF8';
|
||||||
|
SET standard_conforming_strings = on;
|
||||||
|
SELECT pg_catalog.set_config('search_path', '', false);
|
||||||
|
SET check_function_bodies = false;
|
||||||
|
SET xmloption = content;
|
||||||
|
SET client_min_messages = warning;
|
||||||
|
SET row_security = off;
|
||||||
|
--
|
||||||
|
-- Name: log; Type: SCHEMA; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE SCHEMA log;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: ltree; Type: EXTENSION; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS ltree WITH SCHEMA public;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: EXTENSION ltree; Type: COMMENT; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
COMMENT ON EXTENSION ltree IS 'data type for hierarchical tree-like structures';
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions';
|
99
server.js
Normal file
99
server.js
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
const Koa = require('koa');
|
||||||
|
const path = require('path');
|
||||||
|
const url = require('url');
|
||||||
|
const swaggerGenerator = require('@mtfos/swagger-generator');
|
||||||
|
const { copyObject } = require('src/utils/index.js');
|
||||||
|
|
||||||
|
const app = new Koa();
|
||||||
|
const config = require('src/config/index.js');
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
app.proxy = true;
|
||||||
|
|
||||||
|
// const server = app.listen(config.server.port, () => {
|
||||||
|
// console.log(`server start on port ${server.address().port}`); // eslint-disable-line
|
||||||
|
// });
|
||||||
|
|
||||||
|
// load middleware module
|
||||||
|
const koaLogger = require('koa-logger');
|
||||||
|
const koaCors = require('@koa/cors');
|
||||||
|
const koaBody = require('koa-body');
|
||||||
|
const koaMount = require('koa-mount');
|
||||||
|
const koaStatic = require('koa-static');
|
||||||
|
const rootRouter = require('src/routes/index.js');
|
||||||
|
|
||||||
|
const packageJSON = require('./package.json');
|
||||||
|
|
||||||
|
let swaggerDoc = null;
|
||||||
|
// generate swagger document
|
||||||
|
(async () => {
|
||||||
|
const swaggerSpec = {
|
||||||
|
swaggerDefinition: {
|
||||||
|
info: {
|
||||||
|
description: 'KeyCloak OAuth Demo Server',
|
||||||
|
title: 'KeyCloak',
|
||||||
|
version: packageJSON.version,
|
||||||
|
},
|
||||||
|
host: new url.URL(config.server.url).host,
|
||||||
|
basePath: '',
|
||||||
|
produces: ['application/json'],
|
||||||
|
schemes: ['http', 'https'],
|
||||||
|
securityDefinitions: {
|
||||||
|
JWT: {
|
||||||
|
type: 'apiKey',
|
||||||
|
in: 'header',
|
||||||
|
name: 'Authorization',
|
||||||
|
description: 'Bearer token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
basedir: __dirname,
|
||||||
|
files: ['./routes/**/*.js'],
|
||||||
|
};
|
||||||
|
swaggerDoc = await swaggerGenerator.generateSpec(swaggerSpec);
|
||||||
|
})().catch(err => {
|
||||||
|
console.error('[Error] Generate swagger doc failed, ', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// production 不掛上swagger ui
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
app.use(koaMount('/api-docs/', koaStatic(path.resolve(__dirname, 'public', 'swagger-ui'), { index: 'index.html' })));
|
||||||
|
rootRouter.get('/api-docs', async c => {
|
||||||
|
if (!/\/$/.test(c.url)) c.redirect(`${c.url}/`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// set swagger doc route
|
||||||
|
rootRouter.get('/api-docs.json', async c => {
|
||||||
|
c.type = 'application/json';
|
||||||
|
const spec = copyObject(swaggerDoc || {});
|
||||||
|
|
||||||
|
if (c.protocol === 'https') {
|
||||||
|
spec.schemes = ['https'];
|
||||||
|
}
|
||||||
|
|
||||||
|
c.body = spec;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(koaLogger());
|
||||||
|
app.use(
|
||||||
|
koaCors({
|
||||||
|
credentials: true,
|
||||||
|
// allow all origin
|
||||||
|
origin: ctx => ctx.get('origin'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(
|
||||||
|
koaBody({
|
||||||
|
multipart: true,
|
||||||
|
formidable: {
|
||||||
|
maxFileSize: 100 * 1024 * 1024, // 100 mb
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(koaMount('/', koaStatic(path.resolve(__dirname, 'public', 'html'))));
|
||||||
|
app.use(rootRouter.allowedMethods());
|
||||||
|
app.use(rootRouter.routes());
|
||||||
|
|
||||||
|
module.exports = app;
|
19
utils/database.js
Normal file
19
utils/database.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
const knex = require('knex');
|
||||||
|
const config = require('src/config/index.js');
|
||||||
|
|
||||||
|
const pool = knex({
|
||||||
|
client: 'pg',
|
||||||
|
connection: {
|
||||||
|
user: config.database.user,
|
||||||
|
password: config.database.password,
|
||||||
|
host: config.database.host,
|
||||||
|
port: config.database.port,
|
||||||
|
database: config.database.dbname,
|
||||||
|
},
|
||||||
|
pool: {
|
||||||
|
max: config.database.pool_max,
|
||||||
|
min: config.database.pool_min,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = pool;
|
166
utils/index.js
Normal file
166
utils/index.js
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
/* eslint-disable no-restricted-globals,no-param-reassign,guard-for-in,no-restricted-syntax,no-continue */
|
||||||
|
const mod = {};
|
||||||
|
module.exports = mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check value is number
|
||||||
|
* @param {any} v input value
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
mod.isNumber = v => !(!isFinite(v) || v === true || v === false || v === null || v === '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* value to number
|
||||||
|
* @param {object} v source value
|
||||||
|
* @param {number=} defVal default value
|
||||||
|
* @param {number=} min range min
|
||||||
|
* @param {number=} max range max
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
mod.toNumber = (v, defVal, min, max) => {
|
||||||
|
let defaultVal = defVal;
|
||||||
|
let inVal = v;
|
||||||
|
if (!mod.isNumber(defVal)) defVal = 0;
|
||||||
|
if (typeof defVal === 'string') defaultVal = parseFloat(defVal);
|
||||||
|
const minVal = !mod.isNumber(min) ? null : typeof min === 'string' ? parseFloat(min) : min; // eslint-disable-line
|
||||||
|
const maxVal = !mod.isNumber(max) ? null : typeof max === 'string' ? parseFloat(max) : max; // eslint-disable-line
|
||||||
|
|
||||||
|
if (!mod.isNumber(v)) return defaultVal;
|
||||||
|
if (typeof v === 'string') inVal = parseFloat(v);
|
||||||
|
if (minVal !== null && inVal < minVal) inVal = min;
|
||||||
|
if (maxVal !== null && inVal > maxVal) inVal = max;
|
||||||
|
|
||||||
|
return inVal;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @exports
|
||||||
|
* @typedef pageObject
|
||||||
|
* @prop {number} total page number
|
||||||
|
* @prop {number} page number
|
||||||
|
* @prop {number} count all count number
|
||||||
|
* @prop {number} limit query limit item
|
||||||
|
* @prop {number} offset query offset number
|
||||||
|
*
|
||||||
|
* calc page object
|
||||||
|
* @param {number} argCount all count
|
||||||
|
* @param {number} argPage page number
|
||||||
|
* @param {number} argMaxItem per page item show
|
||||||
|
* @return {pageObject}
|
||||||
|
*/
|
||||||
|
mod.calcPage = (argCount, argPage, argMaxItem = 10) => {
|
||||||
|
const count = mod.toNumber(argCount, 0, 0);
|
||||||
|
let page = mod.toNumber(argPage, 1, 1);
|
||||||
|
const maxItem = mod.toNumber(argMaxItem, 10, 1);
|
||||||
|
|
||||||
|
let total = Math.ceil(count / maxItem);
|
||||||
|
if (total < 1) total = 1;
|
||||||
|
if (page > total) page = total;
|
||||||
|
let offset = (page - 1) * maxItem;
|
||||||
|
if (offset > count) offset = count;
|
||||||
|
const limit = maxItem;
|
||||||
|
|
||||||
|
const pageObject = { total, page, count, offset, limit };
|
||||||
|
for (const key in pageObject) {
|
||||||
|
pageObject[key] = mod.toNumber(pageObject[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageObject;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* deep copy object
|
||||||
|
* @param {any} src
|
||||||
|
* @return {any}
|
||||||
|
*/
|
||||||
|
mod.copyObject = src => {
|
||||||
|
if (typeof src !== 'object') return src;
|
||||||
|
|
||||||
|
const isArray = Array.isArray(src);
|
||||||
|
const copy = isArray ? [] : {};
|
||||||
|
for (let it in src) {
|
||||||
|
// @ts-ignore
|
||||||
|
if (isArray) it = parseInt(it, 10);
|
||||||
|
if (typeof src[it] !== 'object') copy[it] = src[it];
|
||||||
|
else copy[it] = mod.copyObject(src[it]);
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {(string|number)} SelectObjectAlias
|
||||||
|
* @description pass string and length > 0 rename key to alias, other keep key name
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @exports
|
||||||
|
* @typedef {{[key: string]: SelectObjectAlias }} SelectObjectParam
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* select object keys
|
||||||
|
* @param {Object<string, any>} obj
|
||||||
|
* @param {SelectObjectParam} param
|
||||||
|
*/
|
||||||
|
mod.selectObject = (obj, param) => {
|
||||||
|
if (typeof obj !== 'object' || typeof param !== 'object') throw new Error('input arg wrong');
|
||||||
|
|
||||||
|
let newObj = {};
|
||||||
|
|
||||||
|
for (const key in param) {
|
||||||
|
const strs = key.split('.');
|
||||||
|
const alias = param[key];
|
||||||
|
if (strs.length > 1) {
|
||||||
|
if (!(strs[0] in obj)) continue;
|
||||||
|
|
||||||
|
newObj = { ...newObj, ...mod.selectObject(obj[strs[0]], { [strs.slice(1).join('.')]: alias }) };
|
||||||
|
}
|
||||||
|
const toAlias = param[key] && typeof param[key] === 'string';
|
||||||
|
if (!(key in obj)) continue;
|
||||||
|
|
||||||
|
newObj[toAlias ? alias : key] = obj[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return newObj;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pad string to target length
|
||||||
|
* @param {any} v source input
|
||||||
|
* @param {number} len target length
|
||||||
|
* @param {number} direct pad direct (-1 left, 1 right)
|
||||||
|
* @param {string} padChar default '0'
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
mod.pad = (v, len = 0, direct = -1, padChar = '0') => {
|
||||||
|
if (v === null || v === undefined) return '';
|
||||||
|
if (typeof v !== 'string' && !v.toString) return '';
|
||||||
|
if (direct !== 1 && direct !== -1) return '';
|
||||||
|
if (typeof v !== 'string') v = v.toString();
|
||||||
|
if (typeof padChar !== 'string') padChar = '0';
|
||||||
|
len = mod.toNumber(len, 0, 0);
|
||||||
|
if (v.length < len) {
|
||||||
|
if (direct < 0) v = `${padChar}${v}`;
|
||||||
|
else v = `${v}${padChar}`;
|
||||||
|
return mod.pad(v, len, direct, padChar);
|
||||||
|
}
|
||||||
|
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pad left
|
||||||
|
* @param {any} v
|
||||||
|
* @param {number} len
|
||||||
|
* @param {string} padChar
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
mod.padLeft = (v, len = 0, padChar = '0') => mod.pad(v, len, -1, padChar);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pad right
|
||||||
|
* @param {any} v
|
||||||
|
* @param {number} len
|
||||||
|
* @param {string} padChar
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
mod.padRight = (v, len = 0, padChar = '0') => mod.pad(v, len, 1, padChar);
|
9
utils/pkgs.js
Normal file
9
utils/pkgs.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const pkg = {};
|
||||||
|
|
||||||
|
pkg.path = path;
|
||||||
|
pkg.jwt = jwt;
|
||||||
|
|
||||||
|
module.exports = pkg;
|
42
utils/redis.js
Normal file
42
utils/redis.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
const IORedis = require('ioredis');
|
||||||
|
const config = require('src/config/index.js');
|
||||||
|
|
||||||
|
class Redis extends IORedis {
|
||||||
|
constructor() {
|
||||||
|
let { prefix } = config.redis;
|
||||||
|
const { host, port, password, db } = config.redis;
|
||||||
|
if (prefix && !/:$/.test(prefix)) prefix += ':';
|
||||||
|
super({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
password,
|
||||||
|
db,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.prefix = prefix;
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
// key pattern functions
|
||||||
|
this.Key = {
|
||||||
|
/**
|
||||||
|
* SSO 登入暫存
|
||||||
|
* @param {string} s state
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
ssoLoginCache: s => self.getKeyWithPrefix(`sso-login:${s}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* combine key and prefix
|
||||||
|
* @param {string} s
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
getKeyWithPrefix(s) {
|
||||||
|
if (typeof s !== 'string') throw new Error('input key not a string');
|
||||||
|
|
||||||
|
return `${this.prefix}${s}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new Redis();
|
72
utils/response/index.js
Normal file
72
utils/response/index.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
/* eslint-disable no-underscore-dangle */
|
||||||
|
/**
|
||||||
|
* @exports
|
||||||
|
* @typedef {Object} codeMessage
|
||||||
|
* @prop {number} code
|
||||||
|
* @prop {string} message
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @exports
|
||||||
|
* @typedef {Object} respObject
|
||||||
|
* @prop {number} status
|
||||||
|
* @prop {codeMessage} object
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* create respobject
|
||||||
|
* @param {number} status
|
||||||
|
* @param {codeMessage} codeMsg
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mod = {};
|
||||||
|
module.exports = mod;
|
||||||
|
|
||||||
|
mod.respDefault = (status = 200, codeMsg) => ({ status, object: codeMsg });
|
||||||
|
|
||||||
|
mod.APIError = class extends Error {
|
||||||
|
/**
|
||||||
|
* @param {string} message
|
||||||
|
* @param {respObject} resp
|
||||||
|
*/
|
||||||
|
constructor(message = '', resp) {
|
||||||
|
super(message);
|
||||||
|
this._object = resp || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
get object() {
|
||||||
|
return this._object;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check response object struct
|
||||||
|
* @param {respObject} v
|
||||||
|
*/
|
||||||
|
mod.checkStruct = v => {
|
||||||
|
if (typeof v !== 'object' || v === null || v === undefined) return false;
|
||||||
|
if (!('status' in v) || !('object' in v)) return false;
|
||||||
|
if (typeof v.object !== 'object' || !('code' in v.object) || !('message' in v.object)) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
mod.codeMessage = {
|
||||||
|
CodeSuccess: { code: 1000, message: 'success' },
|
||||||
|
CodeCreated: { code: 1001, message: 'created' },
|
||||||
|
CodeAccepted: { code: 1002, message: 'accepted' },
|
||||||
|
CodeDataFormat: { code: 1003, message: 'data format error' },
|
||||||
|
CodeUnauthorized: { code: 1004, message: 'unauthorized' },
|
||||||
|
CodeForbidden: { code: 1005, message: 'forbidden' },
|
||||||
|
CodeNotFound: { code: 1006, message: 'not found' },
|
||||||
|
CodeInternalError: { code: 1007, message: 'internal error' },
|
||||||
|
};
|
||||||
|
|
||||||
|
mod.resp = {
|
||||||
|
Success: mod.respDefault(200, mod.codeMessage.CodeSuccess),
|
||||||
|
Created: mod.respDefault(201, mod.codeMessage.CodeCreated),
|
||||||
|
Accepted: mod.respDefault(202, mod.codeMessage.CodeAccepted),
|
||||||
|
DataFormat: mod.respDefault(400, mod.codeMessage.CodeDataFormat),
|
||||||
|
Unauthorized: mod.respDefault(401, mod.codeMessage.CodeUnauthorized),
|
||||||
|
Forbidden: mod.respDefault(403, mod.codeMessage.CodeForbidden),
|
||||||
|
NotFound: mod.respDefault(404, mod.codeMessage.CodeNotFound),
|
||||||
|
InternalError: mod.respDefault(500, mod.codeMessage.CodeInternalError),
|
||||||
|
};
|
130
utils/sso/index.js
Normal file
130
utils/sso/index.js
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
const joi = require('joi');
|
||||||
|
const url = require('url');
|
||||||
|
const querystring = require('querystring');
|
||||||
|
const got = require('got');
|
||||||
|
const config = require('src/config/index.js');
|
||||||
|
const { jwt } = require('src/utils/pkgs.js');
|
||||||
|
|
||||||
|
const mod = {};
|
||||||
|
module.exports = mod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
mod.getAuthURL = state => {
|
||||||
|
const input = joi
|
||||||
|
.object({
|
||||||
|
authorized_endpoint: joi.string().required(),
|
||||||
|
token_endpoint: joi.string().required(),
|
||||||
|
client_id: joi.string().required(),
|
||||||
|
client_secret: joi.string().required(),
|
||||||
|
state: joi.string().allow('', null).default(''),
|
||||||
|
})
|
||||||
|
.unknown()
|
||||||
|
.validate({ ...config.sso, state });
|
||||||
|
if (input.error) throw new Error(input.error.message);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {{value: { authorized_endpoint: string, token_endpoint: string, client_id: string, client_secret: string, state: string }}}
|
||||||
|
*/
|
||||||
|
const { value } = input;
|
||||||
|
|
||||||
|
const redirectUri = new url.URL('/oauth/redirect', config.server.url);
|
||||||
|
|
||||||
|
const qs = {
|
||||||
|
client_id: value.client_id,
|
||||||
|
scope: 'openid',
|
||||||
|
response_type: 'code',
|
||||||
|
redirect_uri: redirectUri.toString(),
|
||||||
|
};
|
||||||
|
if (value.state) qs.state = state;
|
||||||
|
|
||||||
|
return `${value.authorized_endpoint}?${querystring.stringify(qs)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
mod.getLogoutURL = () => {
|
||||||
|
const input = joi
|
||||||
|
.object({
|
||||||
|
logout_endpoint: joi.string().required(),
|
||||||
|
})
|
||||||
|
.unknown()
|
||||||
|
.validate({ ...config.sso });
|
||||||
|
if (input.error) throw new Error(input.error.message);
|
||||||
|
const redirectUri = new url.URL('/oauth/redirect', config.server.url);
|
||||||
|
|
||||||
|
const qs = { state: 'logout', redirect_uri: redirectUri.toString() };
|
||||||
|
|
||||||
|
return `${input.value.logout_endpoint}?${querystring.stringify(qs)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef SSOAccount
|
||||||
|
* @property {string} username
|
||||||
|
* @property {string} display_name
|
||||||
|
* @property {string} email
|
||||||
|
*/
|
||||||
|
|
||||||
|
mod.getToken = async (code, state) => {
|
||||||
|
const input = joi
|
||||||
|
.object({
|
||||||
|
authorized_endpoint: joi.string().required(),
|
||||||
|
token_endpoint: joi.string().required(),
|
||||||
|
client_id: joi.string().required(),
|
||||||
|
client_secret: joi.string().required(),
|
||||||
|
state: joi.string().required(),
|
||||||
|
code: joi.string().required(),
|
||||||
|
})
|
||||||
|
.unknown()
|
||||||
|
.validate({ ...config.sso, state, code });
|
||||||
|
|
||||||
|
if (input.error) throw new Error(input.error.message);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {{value: { authorized_endpoint: string, token_endpoint: string, client_id: string, client_secret: string, state: string, code: string }}}
|
||||||
|
*/
|
||||||
|
const { value } = input;
|
||||||
|
|
||||||
|
const redirectUri = new url.URL('/oauth/redirect', config.server.url);
|
||||||
|
|
||||||
|
const qs = {
|
||||||
|
client_id: value.client_id,
|
||||||
|
client_secret: value.client_secret,
|
||||||
|
redirect_uri: redirectUri.toString(),
|
||||||
|
code: value.code,
|
||||||
|
client_session_state: value.state,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resp = await got.default.post(value.token_endpoint, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: querystring.stringify(qs),
|
||||||
|
responseType: 'json',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { body } = resp;
|
||||||
|
if (!body) throw new Error('resopnse body empty');
|
||||||
|
|
||||||
|
const { id_token: idToken, access_token: accessToken } = body;
|
||||||
|
if (!idToken) throw new Error('get id token fail');
|
||||||
|
|
||||||
|
const decoded = jwt.decode(idToken);
|
||||||
|
if (!decoded || typeof decoded !== 'object') throw new Error('jwt decode fail');
|
||||||
|
console.log(decoded)
|
||||||
|
console.log(body)
|
||||||
|
// @ts-ignore
|
||||||
|
const { preferred_username: preferredUsername } = decoded;
|
||||||
|
if (!preferredUsername) throw new Error('id token field missing');
|
||||||
|
|
||||||
|
const displayName = `${decoded.family_name ?? ''}${decoded.given_name ?? ''}`;
|
||||||
|
|
||||||
|
/** @type {SSOAccount} */
|
||||||
|
const ssoAccount = {
|
||||||
|
username: preferredUsername.toLowerCase(),
|
||||||
|
display_name: displayName ?? preferredUsername,
|
||||||
|
email: decoded.email ?? '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return ssoAccount;
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user