[feat] Init code
This commit is contained in:
commit
b1e9c5e62a
|
@ -0,0 +1,11 @@
|
|||
node_modules/
|
||||
yarn-error.log
|
||||
.env
|
||||
|
||||
deployment/
|
||||
|
||||
# vim swap file
|
||||
*.swp
|
||||
credential.json
|
||||
coverage/
|
||||
fcm_credential.json
|
|
@ -0,0 +1,4 @@
|
|||
public/
|
||||
node_modules/
|
||||
|
||||
*.d.ts
|
|
@ -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"] }]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
node_modules/
|
||||
yarn-error.log
|
||||
.env
|
||||
|
||||
# vim swap file
|
||||
*.swp
|
||||
credential.json
|
||||
coverage/
|
||||
fcm_credential.json
|
|
@ -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
|
|
@ -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"]
|
|
@ -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
|
||||
|
||||
```
|
|
@ -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();
|
||||
});
|
|
@ -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);
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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() });
|
||||
};
|
|
@ -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();
|
||||
};
|
|
@ -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'],
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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());
|
||||
};
|
|
@ -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
|
|
@ -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
|
|
@ -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();
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2019",
|
||||
"lib": ["es2020.string"],
|
||||
"checkJs": true,
|
||||
"module": "commonjs",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
live
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
readiness
|
||||
</body>
|
||||
</html>
|
Binary file not shown.
After Width: | Height: | Size: 665 B |
Binary file not shown.
After Width: | Height: | Size: 628 B |
|
@ -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>
|
|
@ -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>
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -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
|
||||
}
|
|
@ -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());
|
|
@ -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()
|
||||
);
|
|
@ -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());
|
|
@ -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());
|
|
@ -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;
|
|
@ -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 帳號更新時間
|
||||
*/
|
|
@ -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';
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
|
@ -0,0 +1,9 @@
|
|||
const path = require('path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const pkg = {};
|
||||
|
||||
pkg.path = path;
|
||||
pkg.jwt = jwt;
|
||||
|
||||
module.exports = pkg;
|
|
@ -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();
|
|
@ -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),
|
||||
};
|
|
@ -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