├── .dockerignore ├── .editorconfig ├── .gitattributes ├── tests ├── local │ ├── global.yml │ ├── users │ │ ├── payment.yml │ │ └── index.yml │ ├── index.yml │ ├── 2024-12-10.yml │ └── dashboard.yml ├── tunnel-dev │ └── _api │ │ ├── findUsers.js │ │ ├── hello.js │ │ └── index.js ├── next │ ├── package.json │ └── _auth │ │ ├── requestPasswordReset.js │ │ ├── requestOTP.js │ │ ├── connectGoogleDevice.js │ │ ├── _redis.js │ │ ├── getRoles.js │ │ ├── submitOTP.js │ │ ├── connectGoogle.js │ │ ├── submitPassword.js │ │ ├── refreshSession.js │ │ └── connectGoogleCallback.js └── prod │ ├── users │ ├── payment.yml │ └── index.yml │ ├── index.yml │ └── dashboard.yml ├── Dockerfile ├── .gitignore ├── models ├── Team.js ├── State.js ├── UserProfile.js ├── httpGot.js ├── TeamRowResource.js ├── monitor.js ├── dbAny.js ├── logger.js ├── db.js ├── selectConfig.js └── only.js ├── .npmignore ├── docker-compose.yml ├── CHANGELOG.md ├── log.js ├── routes ├── index.js ├── api.js ├── connect.js ├── auth.js └── team.js ├── bin ├── global.js ├── index.js ├── package.json ├── app.js ├── LICENSE ├── connect.js ├── README.md ├── cli └── sample.js /.dockerignore: -------------------------------------------------------------------------------- 1 | * -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_size = 2 3 | indent_style = 2 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /tests/local/global.yml: -------------------------------------------------------------------------------- 1 | apiProducts: &apiProducts https://api.selectfromuser.com/sample-api/products -------------------------------------------------------------------------------- /tests/tunnel-dev/_api/findUsers.js: -------------------------------------------------------------------------------- 1 | // export default async (req) => { 2 | module.exports = async (req) => { 3 | return { 4 | message: 'ok', 5 | rows: [{a: 1}] 6 | } 7 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine 2 | 3 | LABEL org.opencontainers.image.source=https://github.com/eces/select 4 | 5 | WORKDIR /app 6 | 7 | RUN yarn add selectfromuser@latest 8 | 9 | CMD npx slt -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | temp 3 | .vscode 4 | .debugs 5 | .DS_Store 6 | *.dist.js 7 | *.tgz 8 | .env* 9 | !.env.project 10 | !.env.vault 11 | .select 12 | 13 | # *.yml 14 | !docker-compose.yml -------------------------------------------------------------------------------- /tests/tunnel-dev/_api/hello.js: -------------------------------------------------------------------------------- 1 | // export default async (req, res, next) => { 2 | // try { 3 | // res.status(200).json({ 4 | // message: 'ok', 5 | // }) 6 | // } catch (error) { 7 | // next(err) 8 | // } 9 | // } -------------------------------------------------------------------------------- /models/Team.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('select:Team') 2 | 3 | const getEnvConfig = async (opt) => { 4 | debug('>>>>>>TOOD Team', opt) 5 | return { } 6 | } 7 | 8 | module.exports = { 9 | getEnvConfig, 10 | } 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | ._* 3 | .DS_Store 4 | .git 5 | .hg 6 | .npmrc 7 | .lock-wscript 8 | .svn 9 | .wafpickle-* 10 | config.gypi 11 | CVS 12 | npm-debug.log 13 | 14 | temp 15 | .debugs 16 | ui 17 | local.yml 18 | config 19 | app.yml 20 | README.md 21 | *.map 22 | .gitattributes 23 | .editorconfig -------------------------------------------------------------------------------- /models/State.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('select:State') 2 | 3 | const state = {} 4 | 5 | const get = async (name) => { 6 | return state[name] 7 | } 8 | const set = async (name, value) => { 9 | return state[name] = value 10 | } 11 | 12 | module.exports = { 13 | get, set 14 | } 15 | -------------------------------------------------------------------------------- /tests/tunnel-dev/_api/index.js: -------------------------------------------------------------------------------- 1 | const router = useRouter() 2 | 3 | router.get('/hello', async (req, res, next) => { 4 | try { 5 | res.status(200).json({ 6 | message: 'ok', 7 | }) 8 | } catch (error) { 9 | next(err) 10 | } 11 | }) 12 | 13 | module.exports = router 14 | // export default router -------------------------------------------------------------------------------- /tests/next/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "nodemon ~/select/cli", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "debugs": [] 13 | } -------------------------------------------------------------------------------- /tests/next/_auth/requestPasswordReset.js: -------------------------------------------------------------------------------- 1 | // todo 2 | module.exports = async (req, res, next) => { 3 | try { 4 | const email = req.body.email 5 | const url = req.body.url 6 | 7 | // send email 8 | 9 | res.status(200).json({ 10 | message: 'ok', 11 | }) 12 | } catch (error) { 13 | next(error) 14 | } 15 | } -------------------------------------------------------------------------------- /tests/next/_auth/requestOTP.js: -------------------------------------------------------------------------------- 1 | // todo 2 | module.exports = async (req, res, next) => { 3 | try { 4 | const email = req.body.email 5 | console.log('>>>>>>>>>>>', {email}) 6 | 7 | res.status(200).json({ 8 | message: 'ok', 9 | challenge_id: 'wow', 10 | // session, 11 | }) 12 | } catch (error) { 13 | next(error) 14 | } 15 | } -------------------------------------------------------------------------------- /models/UserProfile.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('select:UserProfile') 2 | 3 | const getName = async (opt) => { 4 | debug('>>>>>>TOOD UserProfile', opt) 5 | return { name: 'TODO' } 6 | } 7 | const getEmail = async (opt) => { 8 | debug('>>>>>>TOOD UserProfile', opt) 9 | return { email: 'TODO' } 10 | } 11 | 12 | module.exports = { 13 | getName, 14 | getEmail, 15 | } 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | app: 4 | # image: select:latest 5 | image: selectfromuser/admin:latest 6 | environment: 7 | - TOKEN 8 | - TEAM_ID 9 | - PORT=9300 10 | # - ENV_MYSQL_HOST 11 | # - ENV_MYSQL_DATABASE 12 | # - ENV_MYSQL_USER 13 | # - ENV_MYSQL_PASSWORD 14 | ports: 15 | - "9300:9300" 16 | - "35762:35762" -------------------------------------------------------------------------------- /models/httpGot.js: -------------------------------------------------------------------------------- 1 | const {debug, info, error} = require('../log')('select:got') 2 | 3 | 4 | module.exports.getGotInstance = async () => { 5 | const { default: got } = await import('got') 6 | 7 | const external_got = got.extend({ 8 | timeout: { 9 | request: 5000, 10 | }, 11 | headers: { 12 | 'User-Agent': 'SelectAdmin', 13 | }, 14 | }) 15 | return external_got 16 | } -------------------------------------------------------------------------------- /tests/next/_auth/connectGoogleDevice.js: -------------------------------------------------------------------------------- 1 | const { getRedisConnection } = require('./_redis') 2 | 3 | module.exports = async (req, res, next) => { 4 | try { 5 | const redis = getRedisConnection() 6 | const state = req.body.state 7 | if (!state.startsWith('google:')) throw StatusError(400, 'invalid google state') 8 | const v = await redis.getdel(`${state}:token`) 9 | if (!v) throw StatusError(400, 'google login failed') 10 | res.status(200).json({ 11 | message: 'ok', 12 | token: v, 13 | }) 14 | } catch (error) { 15 | next(error) 16 | } 17 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## `2.3.12` 2024-12-10 2 | 3 | - patch: mapFirstValue 4 | - patch: sqlWith support momentjs 5 | 6 | ## `2.3.11` 2024-10-21 7 | 8 | - IP 정책으로 허용되지 않는 경우 로그인 메타데이터(auth methods)를 포함한 메뉴(config)를 노출하지 않도록 수정 9 | - 로컬에서는 IP 정책 상관없이 표시 10 | 11 | - Updated config menu to hide login metadata (auth methods) when restricted by IP policy 12 | - Config menu always visible when accessed from a local environment, regardless of IP policy 13 | 14 | 15 | ---- 16 | 17 | 과거 변경내역은 아래 블로그에서 확인하실 수 있습니다. 18 | 19 | You can check the past change history on the blog below. 20 | 21 | https://blog.selectfromuser.com/tag/update/ 22 | 23 | https://www.selectfromuser.com/changelog -------------------------------------------------------------------------------- /tests/next/_auth/_redis.js: -------------------------------------------------------------------------------- 1 | const redis = require('ioredis') 2 | 3 | let sub = null 4 | let pub = null 5 | 6 | module.exports.init = async () => { 7 | try { 8 | pub = redis.createClient({ 9 | port: process.env.REDIS_PORT, 10 | host: process.env.REDIS_HOST, 11 | db: process.env.REDIS_DB, 12 | keyPrefix: process.env.NODE_ENV + ':', 13 | }) 14 | pub.on('error', e => { 15 | console.error(e) 16 | }) 17 | } catch (e) { 18 | console.error(e) 19 | } 20 | } 21 | 22 | module.exports.init() 23 | 24 | module.exports.getRedisConnection = (name = null) => { 25 | if (name == 'pub') return pub 26 | if (name === null) return pub 27 | } -------------------------------------------------------------------------------- /log.js: -------------------------------------------------------------------------------- 1 | 2 | const debug = require('debug')('select:app') 3 | debug.log = global.logger.info.bind(global.logger) 4 | const error = require('debug')('select:app') 5 | error.log = global.logger.error.bind(global.logger) 6 | 7 | module.exports = (name) => { 8 | // const info = require('debug')(name) 9 | // const info = { 10 | // log: global.logger.info.bind(global.logger) 11 | // } 12 | const info = (message, json) => { 13 | global.logger.info({ 14 | message, 15 | json, 16 | }) 17 | } 18 | const error = (message, json) => { 19 | global.logger.error({ 20 | message, 21 | json, 22 | }) 23 | } 24 | 25 | return { 26 | debug: require('debug')(name), 27 | info, 28 | error, 29 | } 30 | } -------------------------------------------------------------------------------- /tests/next/_auth/getRoles.js: -------------------------------------------------------------------------------- 1 | 2 | const jwt = require('jsonwebtoken') 3 | const uuidv4 = require('uuid').v4 4 | const qs = require('querystring') 5 | 6 | module.exports = async (req) => { 7 | const db = await req.resource('mysql.sample') 8 | 9 | const rows = await db.query('SELECT * FROM AdminUser WHERE id = ? AND revoked_at IS NULL', [req.session.id]) 10 | const user = rows[0] 11 | 12 | if (!user) throw new Error('user not found') 13 | 14 | user.role ??= [] 15 | 16 | return { 17 | user: { 18 | name: user.name, 19 | email: user.email, 20 | }, 21 | roles: [ 22 | { 23 | user_id: user.id, 24 | name: 'view', 25 | group_json: user.role, 26 | // property_json: [], 27 | // profile_name: '', 28 | } 29 | ] 30 | } 31 | } -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('select:api') 2 | 3 | const router = require('express').Router() 4 | 5 | router.use('/api/block', require('./block')) 6 | router.use('/api/team', require('./team')) 7 | router.use('/api/auth', require('./auth')) 8 | router.use('/api/connect', require('./connect')) 9 | router.get('/healthcheck', (req, res) => { 10 | res.status(200).send('ok') 11 | }) 12 | router.get('/healthcheck/ip', async (req, res, next) => { 13 | try { 14 | res.status(200).json({ 15 | message: 'ok', 16 | ip: global.PRIVATE_IP, 17 | // region: global.config.get('region.current'), 18 | }) 19 | } catch (error) { 20 | next(error) 21 | } 22 | }) 23 | router.get('/', (req, res) => { 24 | res.status(200).send('ok') 25 | }) 26 | 27 | router.use(require('./api')) 28 | 29 | module.exports = router -------------------------------------------------------------------------------- /tests/next/_auth/submitOTP.js: -------------------------------------------------------------------------------- 1 | // todo 2 | const jwt = require('jsonwebtoken') 3 | 4 | module.exports = async (req, res, next) => { 5 | try { 6 | const challenge_id = req.body.challenge_id 7 | const code = req.body.code 8 | 9 | if (code != 'wow') throw StatusError(400, '실패. 인증코드를 확인해주세요.') 10 | 11 | console.log('>>>>>>>>>>>', {challenge_id, code}) 12 | // throw StatusError(400, '인증 횟수 초과 (REATTEMPT)') 13 | 14 | const session = { 15 | id: 1000, 16 | initial_ts: Date.now(), 17 | } 18 | const key = process.env.SECRET_ACCESS_TOKEN || 'secretAccessToken' 19 | const token = jwt.sign(session, key, { 20 | // expiresIn: global.config.get('policy.session_expire') 21 | }) 22 | 23 | res.status(200).json({ 24 | message: 'ok', 25 | token, 26 | // session, 27 | }) 28 | } catch (error) { 29 | next(error) 30 | } 31 | } -------------------------------------------------------------------------------- /routes/api.js: -------------------------------------------------------------------------------- 1 | const {debug, info, error} = require('../log')('select:api') 2 | const path = require('path') 3 | const fs = require('fs') 4 | 5 | const only = require('../models/only') 6 | const db = require('../models/db') 7 | const router = require('express').Router() 8 | 9 | router.use((req, res, next) => { 10 | try { 11 | req._json = true 12 | req.team = global.__TEAM 13 | req.resource = db.get_internal_resource 14 | next() 15 | } catch (error) { 16 | next(error) 17 | } 18 | }) 19 | 20 | // quite not good 21 | global.useRouter = () => { 22 | return require('express').Router() 23 | } 24 | 25 | const p = path.join(process.env.CWD || process.cwd(), '_api', `index.js`) 26 | if (!fs.existsSync(p)) { 27 | // return res.status(500).json({ 28 | // message: `_api/${path}.js not found` 29 | // }) 30 | } else { 31 | console.log(`[_api] index.js loaded`) 32 | router.use(require(p)) 33 | } 34 | 35 | 36 | module.exports = router -------------------------------------------------------------------------------- /models/TeamRowResource.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('select:TeamRowResource') 2 | 3 | const get = async (opt) => { 4 | debug('>>>>>>TOOD TeamRowResource', opt) 5 | return null 6 | } 7 | 8 | // await master_cloud.createQueryBuilder() 9 | // .select('id') 10 | // .addSelect(`json->'$.type'`, 'json_type') 11 | // .addSelect(`json->'$.policy'`, 'policy') 12 | // .addSelect(`json->'$.pg_null_coalescing'`, 'pg_null_coalescing') 13 | // .addSelect(`json->'$.mode'`, 'mode') 14 | // .addSelect('uuid') 15 | // .from('TeamRow') 16 | // .where('team_id = :tid AND name = :name', { 17 | // tid: team_id, 18 | // name: String(tx.resource).trim(), 19 | // }) 20 | // .andWhere('commit_at IS NOT NULL') 21 | // .andWhere('`type` = "RESOURCE" ') 22 | // .andWhere('deleted_at IS NULL') 23 | // .getRawMany() 24 | 25 | module.exports = { 26 | get, 27 | } 28 | -------------------------------------------------------------------------------- /models/monitor.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('select:monitor') 2 | const { getConnection } = require('typeorm') 3 | const crypto = require('crypto') 4 | const moment = require('moment') 5 | // const { getRedisConnection } = require('../models/redis') 6 | 7 | // const publish = (next) => { 8 | // const pub = getRedisConnection('pub') 9 | // const { Emitter } = require('@socket.io/redis-emitter') 10 | // const io = new Emitter(pub) 11 | // next(io) 12 | // } 13 | 14 | const EventEmitter = require('events') 15 | 16 | class MonitorEventEmitter extends EventEmitter {} 17 | 18 | const ee = new MonitorEventEmitter() 19 | 20 | ee.on('query', async ({team_id, resource_uuid, query}) => { 21 | 22 | }) 23 | ee.on('query:count', async ({team_id, hash}) => { 24 | 25 | }) 26 | ee.on('query profile', async ({team_id, query, ms}) => { 27 | 28 | }) 29 | ee.on('activity', async (opt) => { 30 | 31 | }) 32 | 33 | // allow run 34 | const get_confirm_status = async ({team_id, resource_uuid, query}) => { 35 | return '' 36 | } 37 | 38 | module.exports = ee 39 | module.exports.get_confirm_status = get_confirm_status -------------------------------------------------------------------------------- /tests/next/_auth/connectGoogle.js: -------------------------------------------------------------------------------- 1 | 2 | const jwt = require('jsonwebtoken') 3 | const uuidv4 = require('uuid').v4 4 | const qs = require('querystring') 5 | const { getRedisConnection } = require('./_redis') 6 | 7 | module.exports = async (req, res, next) => { 8 | try { 9 | const url = req.query.url 10 | 11 | const redis = getRedisConnection() 12 | 13 | const state = `google:${uuidv4()}` 14 | // 유효기긴 5분 15 | await redis.set(state, 'Y', 'EX', 300, 'NX') 16 | await redis.set(state+':url', url, 'EX', 300, 'NX') 17 | 18 | res.status(200).json({ 19 | message: 'ok', 20 | url: 'https://accounts.google.com/o/oauth2/v2/auth?' + qs.stringify({ 21 | response_type: 'code', 22 | state, 23 | redirect_uri: process.env.GOOGLE_REDIRECT_URI, 24 | client_id: process.env.GOOGLE_CLIENT_ID, 25 | scope: [ 26 | 'https://www.googleapis.com/auth/userinfo.email', 27 | 'https://www.googleapis.com/auth/userinfo.profile', 28 | ].join(' '), 29 | prompt: 'select_account', 30 | }) 31 | }) 32 | } catch (err) { 33 | // error(err) 34 | next(err) 35 | } 36 | } -------------------------------------------------------------------------------- /tests/next/_auth/submitPassword.js: -------------------------------------------------------------------------------- 1 | // todo 2 | 3 | const jwt = require('jsonwebtoken') 4 | const crypto = require('crypto') 5 | 6 | module.exports = async (req, res, next) => { 7 | try { 8 | const email = req.body.email 9 | const password = req.body.password 10 | 11 | const hashKey = process.env.SECRET_HASH || 'secretHash' 12 | const p1 = crypto.createHmac('sha256', hashKey) 13 | .update(Buffer.from(password, 'base64').toString('utf8')) 14 | .digest('hex') 15 | 16 | // jhlee@selectfromuser.com 17 | const p2 = '7bd8c8edc868afc50e7662a8792b6b48cf6ae5f1253b637ed32aff31819fccb6' 18 | 19 | console.log({p1, p2}, password) 20 | 21 | if (p1 != p2) throw StatusError(400, '사용자를 찾을 수 없습니다.') 22 | 23 | 24 | const session = { 25 | id: 1000, 26 | initial_ts: Date.now(), 27 | } 28 | const key = process.env.SECRET_ACCESS_TOKEN || 'secretAccessToken' 29 | const token = jwt.sign(session, key, { 30 | // expiresIn: global.config.get('policy.session_expire') 31 | }) 32 | 33 | res.status(200).json({ 34 | message: 'ok', 35 | token, 36 | // session, 37 | }) 38 | } catch (error) { 39 | next(error) 40 | } 41 | } -------------------------------------------------------------------------------- /routes/connect.js: -------------------------------------------------------------------------------- 1 | const {debug, info, error} = require('../log')('select:api') 2 | const path = require('path') 3 | const fs = require('fs') 4 | 5 | const only = require('../models/only') 6 | const router = require('express').Router() 7 | 8 | router.use((req, res, next) => { 9 | req._json = true 10 | next() 11 | }) 12 | 13 | router.get('/google2', async (req, res, next) => { 14 | const p = path.join(process.env.CWD || process.cwd(), '_auth', 'connectGoogle.js') 15 | if (!fs.existsSync(p)) { 16 | return res.status(500).json({ 17 | message: 'connectGoogle.js not found' 18 | }) 19 | } 20 | const f = require(p) 21 | f(req, res, next) 22 | }) 23 | 24 | router.get('/google/callback', async (req, res, next) => { 25 | const p = path.join(process.env.CWD || process.cwd(), '_auth', 'connectGoogleCallback.js') 26 | if (!fs.existsSync(p)) { 27 | return res.status(500).json({ 28 | message: 'connectGoogleCallback.js not found' 29 | }) 30 | } 31 | const f = require(p) 32 | f(req, res, next) 33 | }) 34 | router.post('/google/device', async (req, res, next) => { 35 | const p = path.join(process.env.CWD || process.cwd(), '_auth', 'connectGoogleDevice.js') 36 | if (!fs.existsSync(p)) { 37 | return res.status(500).json({ 38 | message: 'connectGoogleDevice.js not found' 39 | }) 40 | } 41 | const f = require(p) 42 | f(req, res, next) 43 | }) 44 | 45 | module.exports = router -------------------------------------------------------------------------------- /tests/next/_auth/refreshSession.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken') 2 | 3 | module.exports = async (req, res, next) => { 4 | try { 5 | const db = await req.resource('mysql.sample') 6 | 7 | const rows = await db.query('SELECT * FROM AdminUser WHERE id = ? AND revoked_at IS NULL', [req.session.id]) 8 | const user = rows[0] 9 | 10 | if (!user) throw new Error('user not found') 11 | 12 | req.session = { 13 | id: req.session.id, 14 | scope: [`tid:${req.team.id}:view`], 15 | 16 | // initial_ts: req.session.initial_ts, 17 | // initial_tid: req.session.initial_tid, 18 | // refresh_ts: Date.now(), 19 | // method: req.session.method, 20 | } 21 | 22 | const key = process.env.SECRET_ACCESS_TOKEN || 'secretAccessToken' 23 | const token = jwt.sign(req.session, key, { 24 | expiresIn: process.env.ACCESS_TOKEN_EXPIRE || 259200 25 | }) 26 | 27 | user.role ??= [] 28 | 29 | res.status(200).json({ 30 | message: 'ok', 31 | token, 32 | sso_token: token, 33 | session: Object.assign(req.session, { 34 | email: user.email, 35 | name: user.name, 36 | 37 | roles: [ 38 | { 39 | user_id: user.id, 40 | name: 'view', 41 | group_json: user.role, 42 | } 43 | ] 44 | }), 45 | }) 46 | } catch (error) { 47 | next(error) 48 | } 49 | } -------------------------------------------------------------------------------- /bin: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * @license 5 | * Copyright(c) 2021-2023 Selectfromuser Inc. 6 | * All rights reserved. 7 | * https://www.selectfromuser.com 8 | * {team, support, jhlee}@selectfromuser.com, eces92@gmail.com 9 | * Commercial Licensed. Grant use for paid permitted user only. 10 | */ 11 | 12 | require('./global') 13 | 14 | const app = require('./app.js') 15 | 16 | const http = require('http') 17 | 18 | 19 | /** 20 | * Create HTTP server. 21 | */ 22 | 23 | const server = http.createServer(app) 24 | 25 | /** 26 | * Listen on provided port, on all network interfaces. 27 | */ 28 | 29 | const port = app.get('port') 30 | 31 | app.prehook( () => { 32 | server.listen(port) 33 | server.on('error', onError) 34 | server.on('listening', app.posthook) 35 | }) 36 | 37 | /** 38 | * Event listener for HTTP server "error" event. 39 | */ 40 | 41 | function onError(error) { 42 | if (error.syscall !== 'listen') { 43 | throw error 44 | } 45 | 46 | const bind = typeof port === 'string' 47 | ? 'Pipe ' + port 48 | : 'Port ' + port 49 | 50 | // handle specific listen errors with friendly messages 51 | switch (error.code) { 52 | case 'EACCES': 53 | console.error(bind + ' requires elevated privileges') 54 | process.exit(1) 55 | break 56 | case 'EADDRINUSE': 57 | console.error(bind + ' is already in use') 58 | process.exit(1) 59 | break 60 | default: 61 | throw error 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/local/users/payment.yml: -------------------------------------------------------------------------------- 1 | pages: 2 | - path: payments 3 | title: 결제 및 환불 4 | blocks: 5 | - type: markdown 6 | content: | 7 | > 최근 7일 대상자 목록 8 | 9 | # - type: query 10 | # name: Data from Query 11 | # resource: mysql.dev 12 | # sql: | 13 | # SELECT * 14 | # FROM chat 15 | # ORDER BY id DESC 16 | # LIMIT 3 17 | # tableOptions: 18 | # cell: true 19 | 20 | - type: http 21 | axios: 22 | method: GET 23 | url: https://api.selectfromuser.com/sample-api/users 24 | rowsPath: rows 25 | columns: 26 | name: 27 | label: Name 28 | age: 29 | label: Engagement Point 30 | 31 | showDownload: csv 32 | 33 | viewModal: 34 | useColumn: id 35 | # mode: side 36 | blocks: 37 | - type: http 38 | axios: 39 | method: GET 40 | url: https://api.selectfromuser.com/sample-api/users/{{user_id}} 41 | rowsPath: rows 42 | 43 | params: 44 | - key: user_id 45 | valueFromRow: id 46 | 47 | display: col-2 48 | title: "ID: {{id}}" 49 | showSubmitButton: false 50 | 51 | 52 | tabOptions: 53 | autoload: 1 54 | tabs: 55 | - name: 최근거래내역 56 | blocks: 57 | - type: markdown 58 | content: 거래내역 내용 59 | - name: 프로모션참여 60 | blocks: 61 | - type: markdown 62 | content: 프로모션 내용 -------------------------------------------------------------------------------- /tests/prod/users/payment.yml: -------------------------------------------------------------------------------- 1 | pages: 2 | - path: payments 3 | title: 결제 및 환불 4 | blocks: 5 | - type: markdown 6 | content: | 7 | > 최근 7일 대상자 목록 8 | 9 | # - type: query 10 | # name: Data from Query 11 | # resource: mysql.dev 12 | # sql: | 13 | # SELECT * 14 | # FROM chat 15 | # ORDER BY id DESC 16 | # LIMIT 3 17 | # tableOptions: 18 | # cell: true 19 | 20 | - type: http 21 | axios: 22 | method: GET 23 | url: https://api.selectfromuser.com/sample-api/users 24 | rowsPath: rows 25 | columns: 26 | name: 27 | label: Name 28 | age: 29 | label: Engagement Point 30 | 31 | showDownload: csv 32 | 33 | viewModal: 34 | useColumn: id 35 | # mode: side 36 | blocks: 37 | - type: http 38 | axios: 39 | method: GET 40 | url: https://api.selectfromuser.com/sample-api/users/{{user_id}} 41 | rowsPath: rows 42 | 43 | params: 44 | - key: user_id 45 | valueFromRow: id 46 | 47 | display: col-2 48 | title: "ID: {{id}}" 49 | showSubmitButton: false 50 | 51 | 52 | tabOptions: 53 | autoload: 1 54 | tabs: 55 | - name: 최근거래내역 56 | blocks: 57 | - type: markdown 58 | content: 거래내역 내용 59 | - name: 프로모션참여 60 | blocks: 61 | - type: markdown 62 | content: 프로모션 내용 -------------------------------------------------------------------------------- /tests/prod/index.yml: -------------------------------------------------------------------------------- 1 | title: 셀렉트어드민 2 | 3 | layout: 4 | style: 5 | backgroundColor: "#19234B !important" 6 | 7 | menus: 8 | - group: 회원 9 | name: 고객 관리 10 | path: users 11 | placement: menu-only 12 | redirect: users/active 13 | icon: mdi-account 14 | 15 | menus: 16 | - name: 결제 관리 17 | path: payments 18 | placement: menu-only 19 | icon: mdi-timeline-check 20 | 21 | - group: 회원 22 | name: 최근가입자 목록 23 | path: users/active 24 | placement: tab-only 25 | 26 | - group: 회원 27 | name: 휴면회원 목록 28 | path: users/dormant 29 | placement: tab-only 30 | 31 | - group: 회원 32 | name: 마케팅 수신동의 33 | path: users/promotion 34 | placement: tab-only 35 | 36 | - group: 기타메뉴 37 | name: 공식 문서 38 | path: https://docs.selectfromuser.com 39 | target: _blank 40 | icon: mdi-book-open-variant 41 | iconEnd: 링크 42 | 43 | - group: 기타메뉴 44 | name: 클라우드 이용 45 | path: https://app.selectfromuser.com 46 | target: _blank 47 | icon: mdi-tab 48 | iconEnd: 링크 49 | 50 | # resources: 51 | # - name: mysql.dev 52 | # mode: local 53 | # type: mysql 54 | # host: aaaa.ap-northeast-2.rds.amazonaws.com 55 | # port: 3306 56 | # username: user_aaaa 57 | # password: aaaa 58 | # database: aaaa 59 | # timezone: '+00:00' 60 | # extra: 61 | # charset: utf8mb4_general_ci 62 | 63 | # pages: 64 | # - path: healthcheck/db 65 | # blocks: 66 | # - type: query 67 | # resource: mysql.dev 68 | # sql: SELECT NOW() 69 | 70 | # keys: 71 | # - name: AWS_URL 72 | # value: http://localhost:9300 -------------------------------------------------------------------------------- /models/dbAny.js: -------------------------------------------------------------------------------- 1 | const {debug, info, error} = require('../log')('select:api') 2 | const { MongoClient } = require("mongodb"); 3 | const genericPool = require('generic-pool') 4 | const redis = require('ioredis') 5 | 6 | class AlreadyHasActiveConnectionError extends Error { 7 | constructor(message) { 8 | super(message); 9 | this.name = 'AlreadyHasActiveConnectionError'; 10 | } 11 | } 12 | 13 | module.exports.connections = {} 14 | 15 | module.exports.createConnectionAny = async (config) => { 16 | if (module.exports.connections[config.name]) { 17 | // already connected 18 | throw new AlreadyHasActiveConnectionError() 19 | } 20 | try { 21 | if (config.type == 'mongodb') { 22 | // const client = new MongoClient(config.uri); 23 | // const c = await client.connect() 24 | const c = await MongoClient.connect(config.uri, { 25 | useNewUrlParser: true, 26 | useUnifiedTopology: true 27 | }); 28 | module.exports.connections[config.name] = c.db(config.database) 29 | } 30 | else if (config.type == 'redis') { 31 | const pool = genericPool.createPool({ 32 | create() { 33 | return redis.createClient(config) 34 | }, 35 | destroy(client) { 36 | client.disconnect() 37 | }, 38 | }, { 39 | max: 1, 40 | min: 1, 41 | autostart: false, 42 | acquireTimeoutMillis: 3000, 43 | }) 44 | await pool.use(async client => { 45 | await client.ping(`PING > PONG ${new Date}`) 46 | }) 47 | module.exports.connection_by_name[config.name] = pool 48 | } 49 | } catch (e) { 50 | error('createConnection', e.stack) 51 | } 52 | } 53 | module.exports.getConnectionAny = async (name) => { 54 | if (!module.exports.connections[name]) { 55 | throw new Error(`connection ${name} not found.`) 56 | } 57 | return module.exports.connections[name] 58 | } -------------------------------------------------------------------------------- /models/logger.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('select:log') 2 | const { getConnection } = require('typeorm') 3 | 4 | const EventEmitter = require('events') 5 | 6 | class LogEventEmitter extends EventEmitter {} 7 | 8 | const ee = new LogEventEmitter() 9 | 10 | ee.on('query source', async ({team_id, teamrow_id, user_id, json}) => { 11 | try { 12 | debug('query source', {team_id, teamrow_id, user_id, json}) 13 | } catch (error) { 14 | debug(error.stack) 15 | } 16 | }) 17 | 18 | ee.on('query run', async ({team_id, teamrow_id, user_id, json}) => { 19 | try { 20 | debug('query run', {team_id, teamrow_id, user_id, json}) 21 | } catch (error) { 22 | debug(error.stack) 23 | } 24 | }) 25 | 26 | ee.on('query profile', async ({team_id, teamrow_id, user_id, json}) => { 27 | try { 28 | debug('query profile', {team_id, teamrow_id, user_id, json}) 29 | } catch (error) { 30 | debug(error.stack) 31 | } 32 | }) 33 | 34 | ee.on('query error', async ({team_id, teamrow_id, user_id, json}) => { 35 | try { 36 | debug('query error', {team_id, teamrow_id, user_id, json}) 37 | } catch (error) { 38 | debug(error.stack) 39 | } 40 | }) 41 | 42 | { 43 | const name = 'connection new' 44 | ee.on(name, async (opt) => { 45 | const {team_id, teamrow_id, json} = opt 46 | debug(name, opt) 47 | }) 48 | } 49 | 50 | { 51 | const name = 'connection pool' 52 | ee.on(name, async (opt) => { 53 | const {team_id, teamrow_id, json} = opt 54 | debug(name, opt) 55 | }) 56 | } 57 | 58 | { 59 | const name = 'connection failed' 60 | ee.on(name, async (opt) => { 61 | const {team_id, teamrow_id, json} = opt 62 | debug(name, opt) 63 | }) 64 | } 65 | 66 | { 67 | const name = 'connection server error' 68 | ee.on(name, async (opt) => { 69 | const {team_id, teamrow_id, json} = opt 70 | debug(name, opt) 71 | }) 72 | } 73 | 74 | { 75 | const name = 'connection test error' 76 | ee.on(name, async (opt) => { 77 | const {team_id, user_id, json} = opt 78 | debug(name, opt) 79 | }) 80 | } 81 | 82 | { 83 | const name = 'init connections error' 84 | ee.on(name, async (opt) => { 85 | const {json} = opt 86 | debug(name, opt) 87 | }) 88 | } 89 | 90 | module.exports = ee -------------------------------------------------------------------------------- /routes/auth.js: -------------------------------------------------------------------------------- 1 | const {debug, info, error} = require('../log')('select:api') 2 | const path = require('path') 3 | const fs = require('fs') 4 | 5 | const only = require('../models/only') 6 | const db = require('../models/db') 7 | const router = require('express').Router() 8 | 9 | router.use((req, res, next) => { 10 | try { 11 | req._json = true 12 | req.team = global.__TEAM 13 | req.resource = db.get_internal_resource 14 | next() 15 | } catch (error) { 16 | next(error) 17 | } 18 | }) 19 | 20 | router.post('/request-otp', async (req, res, next) => { 21 | const p = path.join(process.env.CWD || process.cwd(), '_auth', 'requestOTP.js') 22 | if (!fs.existsSync(p)) { 23 | return res.status(500).json({ 24 | message: 'requestOTP.js not found' 25 | }) 26 | } 27 | const f = require(p) 28 | f(req, res, next) 29 | }) 30 | 31 | router.post('/submit-otp', async (req, res, next) => { 32 | const p = path.join(process.env.CWD || process.cwd(), '_auth', 'submitOTP.js') 33 | if (!fs.existsSync(p)) { 34 | return res.status(500).json({ 35 | message: 'submitOTP.js not found' 36 | }) 37 | } 38 | const f = require(p) 39 | f(req, res, next) 40 | }) 41 | 42 | router.post('/submit-password', async (req, res, next) => { 43 | const p = path.join(process.env.CWD || process.cwd(), '_auth', 'submitPassword.js') 44 | if (!fs.existsSync(p)) { 45 | return res.status(500).json({ 46 | message: 'submitPassword.js not found' 47 | }) 48 | } 49 | const f = require(p) 50 | f(req, res, next) 51 | }) 52 | 53 | router.post('/request-password-reset', async (req, res, next) => { 54 | const p = path.join(process.env.CWD || process.cwd(), '_auth', 'requestPasswordReset.js') 55 | if (!fs.existsSync(p)) { 56 | return res.status(500).json({ 57 | message: 'requestPasswordReset.js not found' 58 | }) 59 | } 60 | const f = require(p) 61 | f(req, res, next) 62 | }) 63 | 64 | router.get('/me', [only.id()], async (req, res, next) => { 65 | const p = path.join(process.env.CWD || process.cwd(), '_auth', 'refreshSession.js') 66 | if (!fs.existsSync(p)) { 67 | return res.status(500).json({ 68 | message: 'refreshSession.js not found' 69 | }) 70 | } 71 | const f = require(p) 72 | f(req, res, next) 73 | }) 74 | 75 | module.exports = router -------------------------------------------------------------------------------- /tests/prod/users/index.yml: -------------------------------------------------------------------------------- 1 | pages: 2 | - path: users/active 3 | blocks: 4 | # - type: markdown 5 | # content: > 6 | # ## 7일 가입자 조회 7 | - type: http 8 | sqlType: select 9 | name: LIST 10 | axios: 11 | url: http://localhost:9300/users 12 | method: GET 13 | rowsPath: rows 14 | selectOptions: 15 | enabled: true 16 | actions: 17 | - type: http 18 | modal: true 19 | axios: 20 | url: "{{APP_AWS_URL}}/users/send" 21 | method: POST 22 | data: 23 | "userIds": [1,2,3] 24 | "name": "{{name}}" 25 | "mobile": "{{mobile}}" 26 | "title": "title" 27 | params: 28 | - key: name 29 | valueFromSelectedRows: true 30 | - key: mobile 31 | valueFromSelectedRows: true 32 | - key: APP_AWS_URL 33 | valueFromEnv: true 34 | forEach: true 35 | reloadAfterSubmit: true 36 | # - type: http 37 | # label: Download 38 | # axios: 39 | # url: "{{APP_AWS_URL}}/users/export" 40 | # method: POST 41 | # data: 42 | # "userIds": "{{ name.value }}" 43 | # params: 44 | # - key: name 45 | # valueFromSelectedRows: true 46 | # - key: APP_AWS_URL 47 | # valueFromEnv: true 48 | # # forEach: true 49 | # # reloadAfterSubmit: true 50 | - type: http 51 | label: Download 52 | single: true 53 | axios: 54 | url: "{{APP_AWS_URL}}/users/export" 55 | method: POST 56 | # data: 57 | # "userIds": "{{ name.value }}" 58 | # responseType: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet 59 | responseType: blob 60 | filename: UserExport.xlsx 61 | params: 62 | # - key: name 63 | # valueFromSelectedRows: true 64 | - key: APP_AWS_URL 65 | valueFromEnv: true 66 | # forEach: true 67 | # reloadAfterSubmit: true 68 | 69 | - path: users/dormant 70 | blocks: 71 | - type: markdown 72 | content: > 73 | ## 휴면회원 조회 74 | 75 | - path: users/promotion 76 | blocks: 77 | - type: markdown 78 | content: > 79 | ## 동의/미동의 조회 -------------------------------------------------------------------------------- /tests/local/index.yml: -------------------------------------------------------------------------------- 1 | title: 셀렉트어드민 2 | 3 | layout: 4 | style: 5 | backgroundColor: "#19234B !important" 6 | 7 | menus: 8 | - group: 회원 9 | name: 고객 관리 10 | path: users 11 | placement: menu-only 12 | redirect: users/active 13 | icon: mdi-account 14 | 15 | menus: 16 | - name: 결제 관리 17 | path: payments 18 | placement: menu-only 19 | icon: mdi-timeline-check 20 | 21 | - group: 회원 22 | name: 최근가입자 목록 23 | path: users/active 24 | placement: tab-only 25 | 26 | - group: 회원 27 | name: 휴면회원 목록 28 | path: users/dormant 29 | placement: tab-only 30 | 31 | - group: 회원 32 | name: 마케팅 수신동의 33 | path: users/promotion 34 | placement: tab-only 35 | 36 | - group: 대시보드 37 | name: 대시보드 38 | path: dashboard 39 | placement: menu-only 40 | icon: mdi-monitor-dashboard 41 | roles: 42 | list: 43 | # - email::jhlee@selectfromuser.com 44 | - Employee 45 | view: 46 | # - email::jhlee@selectfromuser.com 47 | - Employee 48 | - group: 메시지 49 | name: 메시지 50 | path: message 51 | placement: expand-only 52 | icon: mdi-email 53 | roles: 54 | list: 55 | - email::jhlee@selectfromuser.com 56 | - Employee_else 57 | view: 58 | - email::jhlee@selectfromuser.com 59 | - Employee_else 60 | menus: 61 | - path: message/A 62 | name: Menu A 63 | - path: message/B 64 | name: Menu B 65 | roles: 66 | list: 67 | # - email::jhlee@selectfromuser.com 68 | - Employee 69 | view: 70 | # - email::jhlee@selectfromuser.com 71 | - Employee_else 72 | 73 | - group: 기타메뉴 74 | name: 공식 문서 75 | path: https://docs.selectfromuser.com 76 | target: _blank 77 | icon: mdi-book-open-variant 78 | iconEnd: 링크 79 | 80 | - group: 기타메뉴 81 | name: 클라우드 이용 82 | path: https://app.selectfromuser.com 83 | target: _blank 84 | icon: mdi-tab 85 | iconEnd: 링크 86 | 87 | resources: 88 | - 89 | name: mysql.qa 90 | type: mysql 91 | host: $DB_HOST 92 | port: $DB_PORT 93 | username: $DB_USER 94 | password: $DB_PASSWORD 95 | database: $DB_NAME 96 | timezone: '+00:00' 97 | extra: 98 | charset: utf8mb4_general_ci 99 | 100 | 101 | # pages: 102 | # - path: healthcheck/db 103 | # blocks: 104 | # - type: query 105 | # resource: mysql.dev 106 | # sql: SELECT NOW() 107 | 108 | # keys: 109 | # - name: AWS_URL 110 | # value: http://localhost:9300 111 | 112 | pages: 113 | - path: message/B 114 | blocks: 115 | - type: markdown 116 | content: Text 117 | 118 | -------------------------------------------------------------------------------- /global.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | 3 | process.env.DEBUGS && require('debugs/init') 4 | 5 | // console.log('ONPREMISE=', process.env.ONPREM) 6 | // global.ONPREM = process.env.ONPREM 7 | // console.log('LICENSE_KEY=', process.env.LICENSE_KEY) 8 | console.log('SELFHOST=', process.env.SELFHOST) 9 | console.log('DEBUG=', process.env.DEBUG) 10 | console.log('NODE_ENV=', process.env.NODE_ENV) 11 | 12 | // if (process.env.ONPREM) { 13 | // const required = ['VITE_API_URL', 'VITE_WS_URL', 'VITE_APP_HOSTNAME', 'LICENSE_KEY', 'SECRET_ACCESS_TOKEN', 14 | // 'SECRET_AES_KEY', 'GOOGLE_CLIENT_ID', 'GOOGLE_REDIRECT_URI', 'GOOGLE_CLIENT_SECRET', 15 | // 'GOOGLE_SHEET_CLIENT_ID', 'GOOGLE_SHEET_REDIRECT_URI', 'GOOGLE_SHEET_CLIENT_SECRET', 16 | // 'WEB_BASE_URL', 'SENDGRID_API_KEY', 'SENDGRID_FROM', 17 | // 'MYSQL_MASTER_HOST', 'MYSQL_MASTER_PORT', 'MYSQL_MASTER_USER', 'MYSQL_MASTER_PASSWORD', 'MYSQL_MASTER_DATABASE', 18 | // 'REDIS_MASTER_HOST', 'REDIS_MASTER_PORT', 'REDIS_MASTER_DB'] 19 | 20 | // const chalk = require('chalk') 21 | // let count_missing = 0 22 | // for (const key of required) { 23 | // if (!process.env[key]) { 24 | // if (count_missing == 0) { 25 | // console.log() 26 | // console.log(chalk.yellow('Required:')) 27 | // } 28 | // console.log(` ${key}=?`) 29 | // count_missing++ 30 | // } 31 | // } 32 | // if (count_missing) { 33 | // console.log() 34 | // console.log(chalk.green('Please make sure your .env file OR correct environment variables.')) 35 | // console.log(`Link: ${ process.cwd()}/.env`) 36 | // process.exit() 37 | // } 38 | // } 39 | 40 | // global.__absolute = __dirname 41 | // require('./ormconfig.js') 42 | // global.config = require('config') 43 | // global.LICENSE = {} 44 | const debug = require('debug')('select:app') 45 | 46 | const os = require('os'); 47 | const winston = require('winston'); 48 | // require('winston-syslog'); 49 | 50 | 51 | if (process.env.NODE_ENV == 'production') { 52 | global.logger = winston.createLogger({ 53 | // level: 'info', 54 | exitOnError: false, 55 | format: winston.format.json(), 56 | transports: [ 57 | new winston.transports.File({ filename: process.env.LOG_PATH || `./select.log` }), 58 | ], 59 | }) 60 | } else { 61 | global.logger = winston.createLogger({ 62 | format: winston.format.simple(), 63 | // format: winston.format.printf(info => info.message), 64 | // format: winston.format.json(), 65 | // levels: winston.config.syslog.levels, 66 | transports: [ 67 | new winston.transports.Console({ 68 | 69 | }) 70 | ], 71 | }); 72 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const __package = path.join(process.cwd(), 'package.json') 4 | 5 | global.__IS_CLI = fs.existsSync(__package) === false 6 | 7 | if(!global.__IS_CLI){ 8 | require('debugs/init') 9 | } 10 | 11 | // const { program } = require('commander'); 12 | const ___package = require('./package.json') 13 | // program.name('selectfromuser') 14 | // program.version(___package.version, '-v, --version, -version, -V') 15 | // program.option('-w, --watch', 'watch config yaml files') 16 | 17 | // const opts = program.opts() 18 | // if (opts.watch) { 19 | // const nodemon = require('nodemon') 20 | // nodemon('-e "yml" ./bin/select') 21 | // return 22 | // } 23 | 24 | // program.parse() 25 | 26 | 27 | global.__absolute = __dirname 28 | process.env.NODE_CONFIG_DIR = process.env.NODE_CONFIG_DIR || '.' 29 | global.config = require('config') 30 | global.DEFAULT_SECRET_HASH = '+x3VR4Fn { 37 | try { 38 | const boxen = (await import('boxen')).default 39 | const chalk = require('chalk') 40 | const pj = require('package-json') 41 | const latest = await pj('selectfromuser') 42 | if (latest.version != ___package.version) { 43 | console.log(boxen(`Update available ${___package.version} -> ${ chalk.bold(latest.version)}\nRun ${ chalk.cyan('npm i -g selectfromuser') } to update`, { 44 | padding: 1, 45 | margin: 1, 46 | borderColor: 'yellow', 47 | // textAlignment: 'center', 48 | title: '업데이트 알림', 49 | titleAlignment: 'center', 50 | })) 51 | } 52 | } catch (error) { 53 | console.error(error) 54 | } 55 | }, 0) 56 | 57 | const os = require('os'); 58 | global.__hostname = os.hostname() 59 | const winston = require('winston'); 60 | // require('winston-syslog'); 61 | 62 | if (process.env.NODE_ENV == 'production') { 63 | global.logger = winston.createLogger({ 64 | // format: winston.format.simple(), 65 | format: winston.format.json(), 66 | levels: winston.config.syslog.levels, 67 | transports: [ 68 | new winston.transports.Console({ 69 | 70 | }) 71 | ], 72 | }); 73 | } else { 74 | global.logger = winston.createLogger({ 75 | // format: winston.format.simple(), 76 | format: winston.format.printf(info => info.message), 77 | levels: winston.config.syslog.levels, 78 | transports: [ 79 | new winston.transports.Console({ 80 | 81 | }) 82 | ], 83 | }); 84 | } 85 | 86 | module.exports = { 87 | app: require('./app.js'), 88 | } -------------------------------------------------------------------------------- /tests/local/2024-12-10.yml: -------------------------------------------------------------------------------- 1 | menus: 2 | - path: pages/ZujJYg 3 | name: 새로운 메뉴 4 | pages: 5 | - path: pages/ZujJYg 6 | title: 제목 7 | subtitle: 내용 8 | blocks: 9 | # - type: query 10 | # resource: sqlWith 11 | 12 | # sqlWith: 13 | # - name: p1 14 | # resource: mysql.qa 15 | # # delay: 3000 16 | # query: > 17 | # SELECT * FROM test_10k LIMIT 100 18 | 19 | # - name: p2 20 | # resource: mysql.qa 21 | # query: > 22 | # SELECT * FROM wine_stock 23 | 24 | # sqlType: select 25 | # sql: > 26 | # SELECT 27 | # p1.id, 28 | # p1.name AS `mysql name`, 29 | # p2.name AS `pgsql name` 30 | # FROM p1 LEFT JOIN p2 ON p1.id = p2.id 31 | 32 | # - type: query 33 | # resource: mysql.qa 34 | # sqlType: select 35 | # sql: > 36 | # SELECT 37 | # DATE_FORMAT(created_at, '%Y-%m-%d') as 'date', 38 | # CONCAT(COUNT(id), ' 건') AS count_order, 39 | # CONCAT(SUM(amount), ' 원') AS sum_order_amount, 40 | # CONCAT('취소 ', COUNT(IF(status = 'cancel', id, NULL)), ' 건') AS count_order_cancel 41 | # FROM orders 42 | # WHERE created_at BETWEEN :calendar1 AND :calendar2 43 | # GROUP BY 1 44 | # params: 45 | # - key: calendar 46 | # range: true 47 | # valueFromCalendar: true 48 | # display: calendar 49 | # autoload: true 50 | # cache: true 51 | # columns: 52 | # count_order: 53 | # label: 총 주문수 54 | # color: blue-600 55 | # formatFn: numberPart 56 | # sum_order_amount: 57 | # label: 주문금액 합계 58 | # color: green-600 59 | # formatFn: numberPart 60 | # openModal: order-list 61 | # count_order_cancel: 62 | # label: 취소수량 63 | # color: gray-500 64 | 65 | - type: query 66 | resource: sqlWith 67 | 68 | sqlWith: 69 | - name: p2 70 | resource: mysql.qa 71 | query: > 72 | SELECT * FROM wine_stock WHERE deleted_at IS NOT NULL 73 | 74 | sqlType: select 75 | sql: > 76 | SELECT moment(deleted_at).format('YYYY-MM-DD') as date, name as name FROM p2 77 | 78 | # sql: > 79 | # SELECT deleted_at as date, name as name FROM p2 80 | 81 | 82 | params: 83 | - key: calendar 84 | range: true 85 | valueFromCalendar: true 86 | display: calendar 87 | autoload: true 88 | # cache: true 89 | 90 | columns: 91 | name: 92 | label: 일시품절기간 93 | 94 | # responseFn: | 95 | # rows = rows.map(e => { 96 | # e.date = moment(e.date).format('YYYY-MM-DD') 97 | # return e 98 | # }) 99 | # console.log(rows) 100 | # return rows -------------------------------------------------------------------------------- /tests/local/dashboard.yml: -------------------------------------------------------------------------------- 1 | pages: 2 | - 3 | id: dashboard 4 | path: dashboard 5 | layout: dashboard 6 | style: 7 | background-color: "#f4f5f8" 8 | 9 | title: 사용자 현황 10 | # subtitle: 대시보드 11 | 12 | params: 13 | - key: date 14 | format: date 15 | 16 | blocks: 17 | - type: left 18 | layout: dashboard 19 | style: 20 | width: 400px 21 | blocks: 22 | - type: http 23 | axios: 24 | method: GET 25 | url: http://localhost:9500/sample-api/dashboard/users 26 | rowsPath: rows 27 | # display: metric 28 | width: 100% 29 | showDownload: csv 30 | params: 31 | - key: date 32 | valueFromRow: date 33 | - key: t1 34 | 35 | # - type: http 36 | # name: 2 37 | # axios: 38 | # method: GET 39 | # url: http://localhost:9500/sample-api/dashboard/revenue 40 | # rowsPath: rows 41 | # display: metric 42 | # width: 100% 43 | # style: 44 | # color: RoyalBlue 45 | # showDownload: false 46 | # params: 47 | # - key: date 48 | # valueFromRow: date 49 | 50 | 51 | 52 | # - type: http 53 | # axios: 54 | # method: GET 55 | # url: http://localhost:9500/sample-api/dashboard/rank 56 | # rowsPath: rows 57 | 58 | # name: category 59 | # display: metric 60 | # width: 100% 61 | 62 | # metricOptions: 63 | # type: category 64 | # names: 65 | # - 활성 66 | # - 비활성 67 | # value: c 68 | # total: 최근가입자 69 | # showDownload: false 70 | 71 | # - type: http 72 | # axios: 73 | # method: GET 74 | # url: http://localhost:9500/sample-api/dashboard/stores 75 | # rowsPath: rows 76 | # name: 신규 가입 업체 77 | # width: 100% 78 | # height: calc(50vh - 150px) 79 | # style: 80 | # overflow: auto 81 | 82 | # display: card 83 | # showDownload: false 84 | 85 | # - type: center 86 | # layout: dashboard 87 | # style: 88 | # width: 50% 89 | # border: 0 90 | # blocks: 91 | # - type: http 92 | # axios: 93 | # method: GET 94 | # url: http://localhost:9500/sample-api/dashboard/orders 95 | # rowsPath: rows 96 | # name: 최근 방문자 97 | # width: 100% 98 | # height: calc(100vh - 200px) 99 | # chartOptions: 100 | # backgroundColor: 101 | # - "#0D6EFD" 102 | # borderWidth: 103 | # - 0 104 | # style: 105 | # # minWidth: 500px 106 | # type: bar 107 | # x: x 108 | # y: y 109 | # label: 일간 로그인 110 | # options: 111 | # layout: 112 | # padding: 10 113 | # interval: day 114 | # gap: true 115 | # showDownload: csv -------------------------------------------------------------------------------- /tests/prod/dashboard.yml: -------------------------------------------------------------------------------- 1 | pages: 2 | - 3 | id: dashboard 4 | path: dashboard 5 | layout: dashboard 6 | style: 7 | background-color: "#f4f5f8" 8 | 9 | title: 사용자 현황 10 | # subtitle: 대시보드 11 | 12 | params: 13 | - key: date 14 | format: date 15 | 16 | blocks: 17 | - type: left 18 | layout: dashboard 19 | style: 20 | width: 400px 21 | blocks: 22 | - type: http 23 | axios: 24 | method: GET 25 | url: http://localhost:9500/sample-api/dashboard/users 26 | rowsPath: rows 27 | # display: metric 28 | width: 100% 29 | showDownload: csv 30 | params: 31 | - key: date 32 | valueFromRow: date 33 | - key: t1 34 | 35 | # - type: http 36 | # name: 2 37 | # axios: 38 | # method: GET 39 | # url: http://localhost:9500/sample-api/dashboard/revenue 40 | # rowsPath: rows 41 | # display: metric 42 | # width: 100% 43 | # style: 44 | # color: RoyalBlue 45 | # showDownload: false 46 | # params: 47 | # - key: date 48 | # valueFromRow: date 49 | 50 | 51 | 52 | # - type: http 53 | # axios: 54 | # method: GET 55 | # url: http://localhost:9500/sample-api/dashboard/rank 56 | # rowsPath: rows 57 | 58 | # name: category 59 | # display: metric 60 | # width: 100% 61 | 62 | # metricOptions: 63 | # type: category 64 | # names: 65 | # - 활성 66 | # - 비활성 67 | # value: c 68 | # total: 최근가입자 69 | # showDownload: false 70 | 71 | # - type: http 72 | # axios: 73 | # method: GET 74 | # url: http://localhost:9500/sample-api/dashboard/stores 75 | # rowsPath: rows 76 | # name: 신규 가입 업체 77 | # width: 100% 78 | # height: calc(50vh - 150px) 79 | # style: 80 | # overflow: auto 81 | 82 | # display: card 83 | # showDownload: false 84 | 85 | # - type: center 86 | # layout: dashboard 87 | # style: 88 | # width: 50% 89 | # border: 0 90 | # blocks: 91 | # - type: http 92 | # axios: 93 | # method: GET 94 | # url: http://localhost:9500/sample-api/dashboard/orders 95 | # rowsPath: rows 96 | # name: 최근 방문자 97 | # width: 100% 98 | # height: calc(100vh - 200px) 99 | # chartOptions: 100 | # backgroundColor: 101 | # - "#0D6EFD" 102 | # borderWidth: 103 | # - 0 104 | # style: 105 | # # minWidth: 500px 106 | # type: bar 107 | # x: x 108 | # y: y 109 | # label: 일간 로그인 110 | # options: 111 | # layout: 112 | # padding: 10 113 | # interval: day 114 | # gap: true 115 | # showDownload: csv -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "selectfromuser", 3 | "version": "2.4.2", 4 | "description": "A tool for admin/backoffice UI using YAML with SQL, RESTful API connectivity.", 5 | "main": "cli.dist.js", 6 | "scripts": { 7 | "prepublishOnly": "npm run build;", 8 | "test": "ava tests/*.js", 9 | "start": "npm run api", 10 | "api": "PORT=9300 NODE_ENV=development nodemon -x \"node --no-node-snapshot\" ./cli --ignore ./tests --ignore ./ui", 11 | "api:dist": "PORT=9300 __SELECT_BUILD=true nodemon -x \"node --no-node-snapshot\" ./cli --ignore ./tests --ignore ./ui", 12 | "dev": "PORT=9300 NODE_ENV=development nodemon -x \"node --no-node-snapshot\" ./cli -e yml,yaml --ignore ./node_modules", 13 | "cli": "PORT=9300 NODE_ENV=development node --no-node-snapshot ./cli", 14 | "cli:dist": "PORT=9300 NODE_ENV=development node --no-node-snapshot ./cli.dist.js", 15 | "build": "node ./build.js" 16 | }, 17 | "bin": { 18 | "selectfromuser": "cli.dist.js", 19 | "slt": "cli.dist.js" 20 | }, 21 | "files": [ 22 | "cli.dist.js", 23 | "README.md", 24 | "package.json" 25 | ], 26 | "dependencies": { 27 | "alasql": "^4.6.0", 28 | "atob": "^2.1.2", 29 | "axios": "^1.5.1", 30 | "boxen": "^7.1.1", 31 | "btoa": "^1.2.1", 32 | "chalk": "^4.1.2", 33 | "chokidar": "^3.5.3", 34 | "commander": "^11.0.0", 35 | "cors": "^2.8.5", 36 | "debug": "^4.1.1", 37 | "debugs": "^1.0.11", 38 | "diff": "^5.1.0", 39 | "dotenv": "^16.3.1", 40 | "errorhandler": "^1.5.0", 41 | "excel-date-to-js": "^1.1.4", 42 | "express": "^4.16.4", 43 | "express-subdomain": "^1.0.5", 44 | "form-data": "^4.0.0", 45 | "generic-pool": "^3.9.0", 46 | "glob": "^10.3.10", 47 | "googleapis": "^92.0.0", 48 | "got": "^13.0.0", 49 | "http-errors": "^1.7.1", 50 | "inquirer": "^8.2.6", 51 | "ioredis": "^4.28.0", 52 | "ip": "^1.1.8", 53 | "ip-cidr": "^3.1.0", 54 | "js-yaml": "^4.1.0", 55 | "jsonwebtoken": "^8.5.0", 56 | "livereload": "^0.9.3", 57 | "lodash": "^4.17.11", 58 | "moment": "^2.24.0", 59 | "mongodb": "^3.7.3", 60 | "morgan": "^1.9.1", 61 | "multer": "^1.4.5-lts.1", 62 | "mysql2": "^2.3.0", 63 | "nanoid": "^3.3.8", 64 | "node-sql-parser": "^4.11.0", 65 | "open": "^8.4.2", 66 | "oracledb": "^6.7.1", 67 | "package-json": "^8.1.1", 68 | "parse-duration": "^1.1.0", 69 | "pg": "^8.7.3", 70 | "prettyjson": "^1.2.5", 71 | "private-ip": "^2.3.3", 72 | "pug": "^2.0.3", 73 | "semver": "^7.5.4", 74 | "serve-favicon": "^2.5.0", 75 | "serve-static": "^1.14.1", 76 | "socket.io-client": "^4.8.1", 77 | "typeorm": "^0.2.12", 78 | "uuid": "^9.0.0", 79 | "vm2": "^3.9.19", 80 | "winston": "^3.3.3" 81 | }, 82 | "devDependencies": { 83 | "ava": "^1.2.0", 84 | "esbuild": "0.19.2", 85 | "nodemon": "^1.18.9", 86 | "supertest": "^4.0.2" 87 | }, 88 | "overrides": { 89 | "mssql": "^9.3.2" 90 | }, 91 | "repository": { 92 | "type": "git", 93 | "url": "git+https://github.com/eces/select.git" 94 | }, 95 | "author": "Selectfromuser Inc.", 96 | "license": "Elastic License 2.0 (SEE IN LICENSE)", 97 | "bugs": { 98 | "url": "https://github.com/eces/select/issues" 99 | }, 100 | "homepage": "https://github.com/eces/select#readme", 101 | "debugs": [ 102 | "select:*" 103 | ] 104 | } 105 | -------------------------------------------------------------------------------- /tests/next/_auth/connectGoogleCallback.js: -------------------------------------------------------------------------------- 1 | 2 | const jwt = require('jsonwebtoken') 3 | const uuidv4 = require('uuid').v4 4 | const qs = require('querystring') 5 | const axios = require('axios') 6 | const { getRedisConnection } = require('./_redis') 7 | 8 | module.exports = async (req, res, next) => { 9 | let state_origin = '' 10 | let state_url = '' 11 | try { 12 | const {error, state, code, scope, authuser, hd, prompt} = req.query 13 | if (error) { 14 | throw new Error(error) 15 | } 16 | 17 | const redis = getRedisConnection() 18 | const value = await redis.get(state) 19 | if (value === null) throw new Error('로그인실패: 유효기간(5분)만료. 다시시도해주세요.') 20 | 21 | state_origin = await redis.get(state+':origin') 22 | state_url = await redis.get(state+':url') 23 | 24 | console.log('>>>>>>>>.state_url', {value, state_origin, state_url}) 25 | 26 | const r = await axios.post('https://oauth2.googleapis.com/token', {}, { 27 | params: { 28 | client_id: process.env.GOOGLE_CLIENT_ID, 29 | client_secret: process.env.GOOGLE_CLIENT_SECRET, 30 | redirect_uri: process.env.GOOGLE_REDIRECT_URI, 31 | grant_type: 'authorization_code', 32 | code, 33 | }, 34 | json: true, 35 | }) 36 | console.log('>>>>>>>>', r.data) 37 | const {access_token} = r.data 38 | if (!access_token) throw new Error('access_token error') 39 | 40 | const p = await axios.get('https://www.googleapis.com/oauth2/v1/userinfo?alt=json', { 41 | headers: { 42 | 'Authorization': 'Bearer ' + access_token, 43 | }, 44 | }) 45 | if (!p?.data?.id) throw new Error('로그인실패: 구글 ID가 없습니다.') 46 | if (!p?.data?.verified_email) throw new Error('로그인실패: 메일인증 미완료 계정입니다.') 47 | 48 | // const master = await getConnection('mysql.master') 49 | // const user = await master.createQueryBuilder() 50 | // .select('*') 51 | // .from('UserProfile') 52 | // .where('email = :email', { 53 | // email: p.email, 54 | // }) 55 | // .getRawOne() 56 | 57 | let user_id = 1000; 58 | // if (!user || !user.id) { 59 | // const inserted = await master.createQueryBuilder() 60 | // .insert().into('UserProfile') 61 | // .values({ 62 | // email: p.email, 63 | // name: p.name, 64 | // created_at: () => 'NOW()', 65 | // signed_at: () => 'NOW()', 66 | // google_config: JSON.stringify(p), 67 | // google_id: p.id, 68 | // picture_url: p.picture, 69 | // }) 70 | // .execute() 71 | // user_id = inserted.raw.insertId 72 | // } else { 73 | // await master.createQueryBuilder() 74 | // .update('UserProfile') 75 | // .set({ 76 | // // email: p.email, 77 | // // name: p.name, 78 | // signed_at: () => 'NOW()', 79 | // google_config: JSON.stringify(p), 80 | // picture_url: p.picture, 81 | // }) 82 | // .where('id = :id', { id: user.id }) 83 | // .execute() 84 | // user_id = user.id 85 | // } 86 | 87 | 88 | const session = { 89 | id: user_id, 90 | initial_ts: Date.now(), 91 | // origin: state_origin, 92 | require_reissue: true, 93 | method: 'google', 94 | state, 95 | } 96 | 97 | const key = process.env.SECRET_ACCESS_TOKEN || 'secretAccessToken' 98 | const token = jwt.sign(session, key, { 99 | // expiresIn: global.config.get('policy.session_expire'), 100 | }) 101 | 102 | await redis.set(state+':token', token, 'EX', 300, 'NX') 103 | 104 | console.log('>>>>>>>>>>>state_url', state_url) 105 | if (state_url) { 106 | return res.redirect(`${state_url}#TOKEN=${state}`) 107 | } 108 | 109 | // res.redirect(`${global.config.get('web.base_url')}/login/process#token=${token}`) 110 | res.redirect(`${state_origin}/login/process#token=${token}`) 111 | } catch (error) { 112 | console.log(error.stack) 113 | if (state_url) { 114 | return res.redirect(`${state_url}#ERROR=${error.message}`) 115 | } 116 | // res.redirect(`${global.config.get('web.base_url')}/login/process#result=${error.message}`) 117 | res.redirect(`${state_origin}/login/process#result=${error.message}`) 118 | } 119 | } -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright(c) 2021-2023 Selectfromuser Inc. 4 | * All rights reserved. 5 | * https://www.selectfromuser.com 6 | * {team, support, jhlee}@selectfromuser.com, eces92@gmail.com 7 | * Commercial Licensed. Grant use for paid permitted user only. 8 | */ 9 | 10 | global.StatusError = require('http-errors') 11 | global._ = require('lodash').noConflict() 12 | 13 | const express = require('express') 14 | const path = require('path') 15 | const cors = require('cors') 16 | const morgan = require('morgan') 17 | const serveStatic = require('serve-static') 18 | const serveFavicon = require('serve-favicon') 19 | 20 | const {debug, info, error} = require('./log.js')('select:app') 21 | 22 | const routes = require('./routes/index.js') 23 | 24 | const app = express() 25 | 26 | app.set('port', process.env.PORT || 9300) 27 | // app.set('views', path.join(__dirname, 'views')) 28 | // app.set('view engine', 'pug') 29 | app.set('trust proxy', true) 30 | 31 | // app.use(serveFavicon(path.join(__dirname, 'public', 'favicon.ico'))) 32 | 33 | if (!process.env.NODE_ENV || process.env.NODE_ENV == 'development') { 34 | app.use(morgan('dev')) 35 | app.disable('etag') 36 | app.set('json spaces', 2) 37 | } else { 38 | morgan.token('uid', (req) => { 39 | return req.session && req.session.id || 0 40 | }) 41 | app.use(morgan(':method :status :url - :response-time ms [u:uid]', { 42 | skip: (req, res) => { 43 | url = req.originalUrl || req.url 44 | if (url.startsWith('/healthcheck')) { 45 | return true 46 | } 47 | return false 48 | } 49 | })) 50 | } 51 | 52 | app.use(express.json({limit: '1MB', type: 'application/json'})); 53 | app.use(express.urlencoded({ extended: false })); 54 | app.use(cors({ origin: true })) 55 | 56 | app.use('/', routes); 57 | 58 | app.use(function(req, res, next) { 59 | req._json = true 60 | next(StatusError(404)); 61 | }); 62 | 63 | app.use(function(err, req, res, next) { 64 | res.locals.message = err.message; 65 | res.locals.error = req.app.get('env') === 'development' ? err : {}; 66 | 67 | if (req._json) { 68 | if (err.name == 'QueryFailedError') { 69 | res.status(200).json({ 70 | status: err.status, 71 | message: err.reason || '캐시 오류', 72 | data: err.data, 73 | }) 74 | } 75 | if (res.locals.error) { 76 | debug(res.locals.error.stack || res.locals.error) 77 | } 78 | res 79 | .status(200) 80 | .json({ 81 | status: err.status, 82 | message: err.message, 83 | data: err.data, 84 | }) 85 | } else { 86 | res.status(err.status || 500); 87 | res.send(`

${error.name}

${error.message}

`) 88 | } 89 | }); 90 | 91 | process.on('SIGINT', () => { 92 | debug('SIGINT = true') 93 | global.SIGINT = true 94 | 95 | setTimeout( () => { 96 | process.exit(0) 97 | }, 100) 98 | }) 99 | 100 | module.exports = app; 101 | 102 | module.exports.prehook = async (next) => { 103 | try { 104 | const selectConfig = require('./models/selectConfig') 105 | await selectConfig.refresh_team_all() 106 | selectConfig.watch() 107 | 108 | const db = require('./models/db') 109 | // await db.init() 110 | 111 | // online teams (queue에서 받아야함 election) 112 | // await db.init_team_resource(1) 113 | info('config[db] connected') 114 | 115 | await db.init_team_resource_all() 116 | // await redis.init_team_resource_all() 117 | 118 | const livereload = require('livereload') 119 | global.__livereload_server = livereload.createServer({ 120 | port: 35729 + 33, 121 | }); 122 | 123 | 124 | // info('resources[db] connected') 125 | 126 | // const axios = require('axios') 127 | const ip = require('ip') 128 | global.PRIVATE_IP = ip.address() 129 | // if (process.env.ONPREM) { 130 | // } else { 131 | // if (process.env.NODE_ENV == 'production') { 132 | // const r = await axios.get('http://169.254.169.254/latest/meta-data/local-ipv4') 133 | // global.PRIVATE_IP = r.data 134 | // } 135 | // } 136 | 137 | process.send && process.send('ready') 138 | info('api connected') 139 | 140 | 141 | next() 142 | } catch (e) { 143 | debug(e.stack) 144 | error(e) 145 | } 146 | } 147 | 148 | module.exports.posthook = async () => { 149 | // debug(`REGION=${process.env.REGION_CURRENT || 'seoul'} ${global.PRIVATE_IP}`) 150 | 151 | } 152 | 153 | // test4 154 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 셀렉트 어드민을 포함한 모든 제품군은 아래의 ELv2 라이센스 정책을 따릅니다. 2 | 3 | NOTE 4 | 이 제품들을 “무료 개방형“이라고 지칭하기 위해 웹사이트와 메시지를 업데이트했고 5 | 라이선스에 대해 직접 이야기할 때는 “소스 사용 가능“이라고 표현했습니다. 6 | 이런 측면에서 누락된 부분을 보시게 되면, 정정할 수 있도록 알려주시기 바랍니다. 7 | 8 | SUMMARY 9 | 소프트웨어를 사용, 복사, 배포, 이용 가능하고 소프트웨어의 파생 작품을 준비할 수 있는 권리를 허용하며, 10 | 높은 수준의 3가지 제한만 두고 있습니다. 제한 사항은 다음과 같습니다. 11 | 12 | - 제품을 다른 사람에게 관리형 서비스로 제공할 수 없습니다. 13 | - 라이선스 키 기능을 우회하거나 라이선스 키로 보호되는 기능을 제거하면/숨길 수 없습니다. 14 | - 라이선스, 저작권 또는 기타 통지를 제거하거나 숨길 수 없습니다. 15 | 16 | -------------- 17 | 18 | Elastic License 2.0 (ELv2) 19 | 20 | Elastic License 21 | 22 | Acceptance 23 | By using the software, you agree to all of the terms and conditions below. 24 | 25 | Copyright License 26 | The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject to the limitations and conditions below 27 | 28 | Limitations 29 | You may not provide the software to third parties as a hosted or managed service, where the service provides users with access to any substantial set of the features or functionality of the software. 30 | 31 | You may not move, change, disable, or circumvent the license key functionality in the software, and you may not remove or obscure any functionality in the software that is protected by the license key. 32 | 33 | You may not alter, remove, or obscure any licensing, copyright, or other notices of the licensor in the software. Any use of the licensor’s trademarks is subject to applicable law. 34 | 35 | Patents 36 | The licensor grants you a license, under any patent claims the licensor can license, or becomes able to license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case subject to the limitations and conditions in this license. This license does not cover any patent claims that you cause to be infringed by modifications or additions to the software. If you or your company make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. 37 | 38 | Notices 39 | You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms. 40 | 41 | If you modify the software, you must include in any modified copies of the software prominent notices stating that you have modified the software. 42 | 43 | No Other Rights 44 | These terms do not imply any licenses other than those expressly granted in these terms. 45 | 46 | Termination 47 | If you use the software in violation of these terms, such use is not licensed, and your licenses will automatically terminate. If the licensor provides you with a notice of your violation, and you cease all violation of this license no later than 30 days after you receive that notice, your licenses will be reinstated retroactively. However, if you violate these terms after such reinstatement, any additional violation of these terms will cause your licenses to terminate automatically and permanently. 48 | 49 | No Liability 50 | As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim. 51 | 52 | Definitions 53 | The licensor is the entity offering these terms, and the software is the software the licensor makes available under these terms, including any portion of it. 54 | 55 | you refers to the individual or entity agreeing to these terms. 56 | 57 | your company is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. control means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect. 58 | 59 | your licenses are all the licenses granted to you for the software under these terms. 60 | 61 | use means anything you do with the software requiring one of your licenses. 62 | 63 | trademark means trademarks, service marks, and similar rights. 64 | 65 | -------------- 66 | 67 | PARTIES 68 | 69 | LEE JINHYUK (eces92@gmail.com, jhlee@selectfromuser.com) 70 | SELECTFROMUSER INC. 71 | team@selectfromuser.com 72 | https://www.selectfromuser.com/about 73 | -------------------------------------------------------------------------------- /connect.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright(c) 2021-2025 Selectfromuser Inc. 4 | * All rights reserved. 5 | * https://www.selectfromuser.com 6 | * {team, support, jhlee}@selectfromuser.com 7 | */ 8 | 9 | require('./global') 10 | 11 | global._ = require('lodash').noConflict() 12 | 13 | const {debug, info, error} = require('./log.js')('select:app') 14 | const db = require('./models/db') 15 | const path = require('path') 16 | const fs = require('fs') 17 | 18 | const external_axios = require('axios').create({ 19 | timeout: process.env.DEFAULT_HTTP_TIMEOUT || 15000, 20 | headers: { 21 | 'User-Agent': 'SelectAdmin', 22 | }, 23 | }) 24 | 25 | const { io } = require('socket.io-client') 26 | 27 | const state = { 28 | connected: false, 29 | } 30 | 31 | module.exports.createServer = () => { 32 | module.exports.prehook(async () => { 33 | await module.exports.server() 34 | await module.exports.posthook() 35 | }) 36 | } 37 | 38 | module.exports.server = async () => { 39 | info('ws[server]') 40 | 41 | let socket = io(global.__WS_BASE, { 42 | path: '/select.websocket/', 43 | }) 44 | 45 | socket.on('connect', () => { 46 | state.connected = true 47 | 48 | socket.emit('server:enter', { 49 | token: global.__CONFIG_TOKEN, 50 | team_id: global.__TEAM.id, 51 | namespace: process.env.NAMESPACE || '', 52 | }) 53 | 54 | }) 55 | socket.on('disconnected', () => { 56 | state.connected = false 57 | }) 58 | 59 | socket.on('ask', async (opt) => { 60 | // socket.emit(`pool:respond`, { 61 | // id: opt.id, 62 | // tid: opt.tid, 63 | // data: { 64 | // message: 'ok', 65 | // rows: [ 66 | // {a: 'hey'} 67 | // ] 68 | // } 69 | // }) 70 | try { 71 | const block = opt.block 72 | const url = new URL(block.url) 73 | if (url.pathname == '/query') { 74 | // built in query 75 | 76 | if (!block.data.resource) throw new Error('axios.data.resource empty') 77 | if (!block.data.resource) throw new Error('axios.data.sql empty') 78 | 79 | const resource = await db.get_internal_resource(block.data.resource) 80 | 81 | let [ escaped_bind_sql, escaped_bind_params ] = resource.driver 82 | .escapeQueryWithParameters(block.data.sql, block.data.params, {}) 83 | 84 | const r = await resource.query(escaped_bind_sql, escaped_bind_params) 85 | 86 | socket.emit(`pool:respond`, { 87 | id: opt.id, 88 | tid: opt.tid, 89 | data: { 90 | message: 'ok', 91 | rows: r, 92 | } 93 | }) 94 | } 95 | else if (url.pathname == '/http') { 96 | // built in http 97 | 98 | const r = await external_axios(block.data) 99 | socket.emit(`pool:respond`, { 100 | id: opt.id, 101 | tid: opt.tid, 102 | data: { 103 | message: 'ok', 104 | data: r.data, 105 | } 106 | }) 107 | } 108 | else if (url.pathname.startsWith('/api')) { 109 | // call _api 110 | 111 | const filename = _.camelCase(url.pathname.slice(4)) 112 | const p = path.join(process.env.CWD || process.cwd(), '_api', `${filename}.js`) 113 | if (!fs.existsSync(p)) { 114 | throw new Error(`_api/${filename}.js not found`) 115 | } else { 116 | const f = require(p) 117 | const req = { 118 | team: global.__TEAM, 119 | resource: db.get_internal_resource, 120 | } 121 | const r = await f(req) 122 | socket.emit(`pool:respond`, { 123 | id: opt.id, 124 | tid: opt.tid, 125 | data: r, 126 | }) 127 | } 128 | } 129 | else { 130 | throw new Error('invalid request') 131 | } 132 | 133 | } catch (error) { 134 | const resp = { 135 | status: error.response?.status, 136 | statusText: error.response?.statusText, 137 | data: error.response?.data, 138 | body: error.response?.body, 139 | message: error?.message, 140 | } 141 | socket.emit(`pool:respond`, { 142 | id: opt.id, 143 | tid: opt.tid, 144 | data: { 145 | message: error.message, 146 | response: resp, 147 | } 148 | }) 149 | } 150 | }) 151 | 152 | const shutdown = async () => { 153 | try { 154 | await module.exports.posthook() 155 | 156 | socket.emit('leave') 157 | state.connected = false 158 | 159 | setTimeout(() => { 160 | process.exit(0) 161 | }, 300) 162 | } catch (error) { 163 | console.error(error.stack) 164 | process.exit(1) 165 | } 166 | } 167 | process.on('SIGTERM', shutdown) 168 | process.on('SIGINT', shutdown) 169 | } 170 | 171 | module.exports.prehook = async (next) => { 172 | try { 173 | const selectConfig = require('./models/selectConfig') 174 | await selectConfig.refresh_team_all() 175 | 176 | const db = require('./models/db') 177 | info('config[db] connected') 178 | 179 | await db.init_team_resource_all() 180 | // await redis.init_team_resource_all() 181 | 182 | info('resources[db] connected') 183 | 184 | process.send && process.send('ready') 185 | info('api connected') 186 | 187 | next() 188 | } catch (e) { 189 | debug(e.stack) 190 | error(e) 191 | } 192 | } 193 | 194 | module.exports.posthook = async () => { 195 | 196 | } -------------------------------------------------------------------------------- /tests/local/users/index.yml: -------------------------------------------------------------------------------- 1 | pages: 2 | - path: users/active 3 | blocks: 4 | # - type: markdown 5 | # content: > 6 | # ## 7일 가입자 조회 7 | - type: http 8 | name: Block Post Download 9 | axios: 10 | url: "{{APP_AWS_URL}}/users/export" 11 | method: POST 12 | responseType: blob 13 | filename: UserExport-{{purpose}}.xlsx 14 | params: 15 | - key: APP_AWS_URL 16 | valueFromEnv: true 17 | - key: purpose 18 | 19 | - type: http 20 | name: Block Get Image 21 | axios: 22 | url: "{{APP_AWS_URL}}/users/image" 23 | method: POST 24 | methodType: GET 25 | responseType: blob 26 | display: image 27 | template: | 28 | 29 | 30 | 31 | style: 32 | maxHeight: 300px 33 | margin: 0 auto 34 | params: 35 | - key: APP_AWS_URL 36 | valueFromEnv: true 37 | 38 | - type: http 39 | sqlType: select 40 | name: LIST 41 | axios: 42 | url: http://localhost:9300/users 43 | method: GET 44 | rowsPath: rows 45 | selectOptions: 46 | enabled: true 47 | actions: 48 | - type: http 49 | # modal: true 50 | axios: 51 | url: "{{APP_AWS_URL}}/users/send" 52 | method: POST 53 | data: 54 | memo: "{{memo}}" 55 | "userIds": [1,2,3] 56 | "name": "{{name.value}}" 57 | "mobile": "{{mobile.value}}" 58 | "email": "{{email}}" 59 | "title": "title" 60 | params: 61 | - key: memo 62 | - key: name 63 | valueFromSelectedRows: true 64 | - key: mobile 65 | valueFromSelectedRows: true 66 | - key: email 67 | valueFromUserProperty: "{{email}}" 68 | - key: APP_AWS_URL 69 | valueFromEnv: true 70 | # forEach: true 71 | # reloadAfterSubmit: true 72 | # - type: http 73 | # label: Download 74 | # axios: 75 | # url: "{{APP_AWS_URL}}/users/export" 76 | # method: POST 77 | # data: 78 | # "userIds": "{{ name.value }}" 79 | # params: 80 | # - key: name 81 | # valueFromSelectedRows: true 82 | # - key: APP_AWS_URL 83 | # valueFromEnv: true 84 | # # forEach: true 85 | # # reloadAfterSubmit: true 86 | - type: http 87 | label: Download 88 | single: true 89 | axios: 90 | url: "{{APP_AWS_URL}}/users/export" 91 | method: POST 92 | # data: 93 | # "userIds": "{{ name.value }}" 94 | # responseType: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet 95 | responseType: blob 96 | filename: UserExport.xlsx 97 | params: 98 | # - key: name 99 | # valueFromSelectedRows: true 100 | - key: APP_AWS_URL 101 | valueFromEnv: true 102 | # forEach: true 103 | # reloadAfterSubmit: true 104 | viewModal: 105 | useColumn: name 106 | blocks: 107 | - type: http 108 | name: 1 109 | axios: 110 | url: "{{APP_AWS_URL}}/users/image" 111 | params: 112 | name: "{{name}}" 113 | method: POST 114 | methodType: GET 115 | responseType: blob 116 | display: image 117 | style: 118 | maxHeight: 300px 119 | margin: 0 auto 120 | params: 121 | - key: APP_AWS_URL 122 | valueFromEnv: true 123 | - key: name 124 | valueFromRow: name 125 | 126 | - path: users/dormant 127 | blocks: 128 | # - type: markdown 129 | # content: > 130 | # ## 휴면회원 조회 131 | - type: http 132 | axios: 133 | method: POST 134 | url: https://api.selectfromuser.com/sample-api/upload 135 | headers: 136 | Content-Type: multipart/form-data 137 | params: 138 | - key: title 139 | value: title 140 | - key: file 141 | format: file 142 | id: multipartFileRequest 143 | rowsPath: result 144 | showResult: table 145 | # showResult: template 146 | # showResultTemplate: | 147 | # object_url: {{object_url}} 148 | # name: {{name}} 149 | columns: 150 | object_url: 151 | format: image 152 | copy: true 153 | file: 154 | hidden: true 155 | name: 156 | format: template 157 | template: | 158 | {{name}} 159 | responseFn: | 160 | rows.name = rows.file.originalname 161 | 162 | - path: users/promotion 163 | blocks: 164 | # - type: markdown 165 | # content: > 166 | # ## 동의/미동의 조회 167 | 168 | - type: http 169 | axios: 170 | method: POST 171 | url: https://httpbin.selectfromuser.com/anything 172 | params: 173 | id: "{{id}}" 174 | email: "{{email}}" 175 | name: "{{name}}" 176 | params: 177 | - key: id 178 | valueFromUserProperty: "{{id}}" 179 | - key: email 180 | valueFromUserProperty: "{{email}}" 181 | - key: name 182 | valueFromUserProperty: "{{name}}" 183 | 184 | - type: http 185 | axios: 186 | method: GET 187 | # url: https://api.selectfromuser.com/sample-api/products 188 | url: *apiProducts 189 | params: 190 | id: "{{id}}" 191 | email: "{{email}}" 192 | name: "{{name}}" 193 | params: 194 | - key: id 195 | valueFromUserProperty: "{{id}}" 196 | - key: email 197 | valueFromUserProperty: "{{email}}" 198 | - key: name 199 | valueFromUserProperty: "{{name}}" 200 | # selectOptions: 201 | # enabled: true 202 | # actions: 203 | # - type: http 204 | # label: DELETE 205 | # axios: 206 | # method: DELETE 207 | # url: https://httpbin.selectfromuser.com/anything 208 | # headers: 209 | # apiKey: 'supabase_key' 210 | # Authorization: 'Bearer supabase_key' 211 | # params: 212 | # - key: id 213 | # valueFromSelectedRows: id 214 | rowsPath: rows 215 | columns: 216 | id: 217 | openModal: modal1-:id 218 | modals: 219 | - path: modal1-:id 220 | blocks: 221 | - type: http 222 | axios: 223 | method: GET 224 | url: https://api.selectfromuser.com/sample-api/products 225 | rowsPath: rows 226 | display: form 227 | columns: 228 | id: 229 | editable: true 230 | name: 231 | editable: true 232 | updateOptions: 233 | type: http 234 | axios: 235 | method: POST 236 | url: https://httpbin.selectfromuser.com/anything 237 | # <<: *commonApiHeader 238 | data: 239 | year: '{{year}}' 240 | confirm: false 241 | request: | 242 | const year = params.find(e ==> e.key == 'year') 243 | const _year = _params.find(e ==> e.key == 'year') 244 | 245 | year.value = _year.value 246 | 247 | # - type: top 248 | # blocks: 249 | # - type: http 250 | # axios: 251 | # method: POST 252 | # url: https://httpbin.selectfromuser.com/anything 253 | # params: 254 | # keyword: '{{keyword || null}}' # 치환이 동작하지 않음 스트링 문자열그대로 반환됨 255 | # params: 256 | # - key: keyword 257 | # roles: 258 | # edit: 259 | # - email::jhlee@selectfromuser.com~~~~~ 260 | 261 | # - type: http 262 | # axios: 263 | # method: POST 264 | # url: https://httpbin.selectfromuser.com/anything 265 | # params: 266 | # keyword: '{{keyword2 || null}}' 267 | # params: 268 | # - key: keyword2 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Select Admin

3 |

Fast build tool for admin/backoffice by YAML declarative way.

4 | 5 |
6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 |

운영 툴을 채우는 새로운 방법

19 |

어드민 페이지를 만들고 배포하고 관리하기에는 어렵고 SQL 쿼리와 API를 매번 만들면 시간이 계속 늘어납니다. 다른 방법은 없을까요?

20 |

최소 비용으로 빠른 기간내 팀 협업을 성공. 고객조회, 매출분석, 지표통계, 컨텐츠관리, 이력조회를 가입 후 3일 안에 해결하고 있습니다.

21 |

더 이상 DB, API 클라이언트에 의존하지 않아도 돼요.

22 | 설치형(로컬 실행 가능한) 오픈소스 업데이트를 준비중입니다! 조금만 기다려주세요. 23 | 24 |
25 | 26 | ## Features 27 | 28 | - [x] [mysql](#) backend support. (production-ready) 29 | - [x] [RESTful HTTP API](#) backend support. (production-ready) 30 | - [x] [pgsql](#) backend support. (production-ready) 31 | - [x] [mssql](#) backend support. (production-ready) 32 | - [x] [redis](#) backend support. 33 | - [x] [Google Spreadsheet](#) backend support. 34 | - [x] [MongoDB](#) backend support. (production-ready) 35 | - [ ] [DynamoDB](#) backend support. 36 | - [ ] [Firebase](#) backend support. 37 | - [x] JavaScript request/response transformation support. 38 | - [x] User management. 39 | - [x] Permission and access control with roles. 40 | - [x] Customizable menus, groups and tabs. 41 | - [x] Mulitple pages with URL to share. 42 | - [x] Table UI 43 | - [x] Local sort, pagination 44 | - [x] Server-side pagination 45 | - [x] Query block type 46 | - [x] Modal(popup) block type 47 | - [x] Markdown block type 48 | - [x] Block-wide parameters 49 | - [x] Page-wide parameters 50 | - [x] Share and open saved search. 51 | - [ ] Publishing API Endpoint 52 | - [ ] SSH tunneling 53 | - [x] VPC Peering 54 | - [ ] OIDC support 55 | 56 | ## Usage 57 | 58 | ### CLI 59 | 60 | - `npm install -g selectfromuser` 61 | - `selectfromuser` 62 | 63 | Others 64 | 65 | > `selectfromuser login` 66 | > `selectfromuser whoami` 67 | > `selectfromuser logout` 68 | > `selectfromuser link` 69 | > `selectfromuser unlink` 70 | > `selectfromuser init` 71 | > `selectfromuser dev` 72 | 73 | ### Docker container image 74 | 75 | https://hub.docker.com/r/selectfromuser/admin 76 | https://github.com/eces/select/blob/main/docker-compose.yml 77 | 78 | ## Documentation 79 | 80 | #### ko-KR 81 | 82 | - [Documentation 개발자 문서](https://docs.selectfromuser.com/docs) 83 | - [Official Website 공식웹사이트](https://www.selectfromuser.com/) 84 | - [UI Components 컴포넌트](https://www.selectfromuser.com/components) 85 | - [Changelog 업데이트 내역](https://docs.selectfromuser.com/changelog) 86 | - [Blog 블로그](https://blog.selectfromuser.com) 87 | - [Discourse 포럼](https://join.slack.com/t/selectcommunity/shared_invite/zt-161fvp0bn-SjJykcvL9ply0CQzUXrL9A?ref=blog.selectfromuser.com) 88 | - [Slack Community 슬랙 커뮤니티](https://join.slack.com/t/selectcommunity/shared_invite/zt-161fvp0bn-SjJykcvL9ply0CQzUXrL9A?ref=blog.selectfromuser.com) 89 | 90 | ### Sample Recipe 91 | 92 | #### `index.yml` 93 | 94 | ```yml 95 | title: 셀렉트어드민 96 | 97 | layout: 98 | style: 99 | backgroundColor: "#19234B !important" 100 | 101 | menus: 102 | - group: 회원 103 | name: 고객 관리 104 | path: users 105 | placement: menu-only 106 | redirect: users/active 107 | icon: mdi-account 108 | 109 | menus: 110 | - name: 결제 관리 111 | path: payments 112 | placement: menu-only 113 | icon: mdi-timeline-check 114 | 115 | - group: 회원 116 | name: 최근가입자 목록 117 | path: users/active 118 | placement: tab-only 119 | 120 | - group: 회원 121 | name: 휴면회원 목록 122 | path: users/dormant 123 | placement: tab-only 124 | 125 | - group: 회원 126 | name: 마케팅 수신동의 127 | path: users/promotion 128 | placement: tab-only 129 | 130 | - group: 기타메뉴 131 | name: 공식 문서 132 | path: https://docs.selectfromuser.com 133 | target: _blank 134 | icon: mdi-book-open-variant 135 | iconEnd: 링크 136 | 137 | - group: 기타메뉴 138 | name: 클라우드 이용 139 | path: https://app.selectfromuser.com 140 | target: _blank 141 | icon: mdi-tab 142 | iconEnd: 링크 143 | 144 | # resources: 145 | # - name: mysql.dev 146 | # mode: local 147 | # type: mysql 148 | # host: aaaa.ap-northeast-2.rds.amazonaws.com 149 | # port: 3306 150 | # username: user_aaaa 151 | # password: aaaa 152 | # database: aaaa 153 | # timezone: '+00:00' 154 | # extra: 155 | # charset: utf8mb4_general_ci 156 | 157 | # pages: 158 | # - path: healthcheck/db 159 | # blocks: 160 | # - type: query 161 | # resource: mysql.dev 162 | # sql: SELECT NOW() 163 | ``` 164 | 165 | 166 | #### `users/index.yml` 167 | 168 | ```yml 169 | pages: 170 | - path: users/active 171 | blocks: 172 | - type: markdown 173 | content: > 174 | ## 7일 가입자 조회 175 | 176 | - path: users/dormant 177 | blocks: 178 | - type: markdown 179 | content: > 180 | ## 휴면회원 조회 181 | 182 | - path: users/promotion 183 | blocks: 184 | - type: markdown 185 | content: > 186 | ## 동의/미동의 조회 187 | ``` 188 | 189 | 190 | #### `users/payment.yml` 191 | 192 | ```yml 193 | pages: 194 | - path: payments 195 | title: 결제 및 환불 196 | blocks: 197 | - type: markdown 198 | content: | 199 | > 최근 7일 대상자 목록 200 | 201 | # - type: query 202 | # name: Data from Query 203 | # resource: mysql.dev 204 | # sql: | 205 | # SELECT * 206 | # FROM chat 207 | # ORDER BY id DESC 208 | # LIMIT 3 209 | # tableOptions: 210 | # cell: true 211 | 212 | - type: http 213 | axios: 214 | method: GET 215 | url: https://api.selectfromuser.com/sample-api/users 216 | rowsPath: rows 217 | columns: 218 | name: 219 | label: Name 220 | age: 221 | label: Engagement Point 222 | 223 | showDownload: csv 224 | 225 | viewModal: 226 | useColumn: id 227 | # mode: side 228 | blocks: 229 | - type: http 230 | axios: 231 | method: GET 232 | url: https://api.selectfromuser.com/sample-api/users/{{user_id}} 233 | rowsPath: rows 234 | 235 | params: 236 | - key: user_id 237 | valueFromRow: id 238 | 239 | display: col-2 240 | title: "ID: {{id}}" 241 | showSubmitButton: false 242 | 243 | 244 | tabOptions: 245 | autoload: 1 246 | tabs: 247 | - name: 최근거래내역 248 | blocks: 249 | - type: markdown 250 | content: 거래내역 내용 251 | - name: 프로모션참여 252 | blocks: 253 | - type: markdown 254 | content: 프로모션 내용 255 | ``` 256 | 257 | ## Tests 258 | 259 | ##### `npm test -- --grep="auth"` 260 | 261 | ##### `npm test -- --grep="block"` 262 | 263 | ##### `npm test -- --grep="config"` 264 | 265 | ## Support 266 | 267 | 해당 프로젝트는 2020년부터 2023년까지 Free/Pro/Team/Enterprise Plan 제공을 위해 Selectfromuser Inc. 개발팀 포함 커뮤니티가 계속 기능추가, 유지보수, 보안패치, 문서화를 하고 있습니다. 268 | 269 | 직접 설치하여 비용없이 무료이용 가능합니다. 그외에 정책은 [라이센스](https://github.com/eces/select/blob/main/LICENSE)를 따릅니다. 기능제안, 기술지원은 해당 페이지로 문의바랍니다. https://www.selectfromuser.com 270 | -------------------------------------------------------------------------------- /models/db.js: -------------------------------------------------------------------------------- 1 | const {createConnection, getConnection, Connection} = require('typeorm') 2 | const {debug, info, error} = require('../log')('select:db') 3 | // const { getRedisConnection } = require('./redis') 4 | const logger = require('./logger') 5 | const {createConnectionAny, getConnectionAny} = require('./dbAny') 6 | const State = require('./State') 7 | 8 | const internal_resources = {} 9 | module.exports.get_internal_resource = async (name) => { 10 | if (!internal_resources[name]) { 11 | throw new Error('internal resource not found') 12 | } 13 | const conn = await getConnection(internal_resources[name]) 14 | return conn 15 | } 16 | 17 | module.exports.init_team_resource_all = async () => { 18 | // get yaml 19 | // load resources db/dbAny 20 | try { 21 | const ids = [process.env.TEAM_ID] 22 | 23 | for (const team_id of ids) { 24 | try { 25 | debug(`try reconnect to tid:${team_id}`) 26 | await module.exports.init_team_resource(team_id) 27 | } catch (error) { 28 | logger.emit('init connections error', { 29 | json: { 30 | error: e.stack, 31 | worker: 'init_team_resource_all', 32 | }, 33 | }) 34 | } 35 | // continue to next 36 | } 37 | 38 | } catch (e) { 39 | error(e.stack) 40 | logger.emit('init connections error', { 41 | json: { 42 | error: e.stack, 43 | worker: 'init_team_resource_all', 44 | }, 45 | }) 46 | } 47 | } 48 | module.exports.init_team_resource = async (team_id) => { 49 | try { 50 | // const master = await getConnection('mysql.master') 51 | // const redis = getRedisConnection() 52 | 53 | // filter region 54 | // const team = await master.createQueryBuilder() 55 | // .select('id, env_config') 56 | // .from('Team') 57 | // .where('id = :id', { 58 | // id: team_id 59 | // }) 60 | // .getRawOne() 61 | 62 | // if (!team) return 63 | 64 | // const region = (team.env_config && team.env_config.regions) || {} 65 | 66 | const _config = await State.get(`admin.${team_id}.yaml`) || {} 67 | const config = JSON.parse(_config) 68 | const rows = config.resources || [] 69 | 70 | for (const idx in rows) { 71 | const row = { 72 | id: idx, 73 | name: rows[idx].name, 74 | json: rows[idx], 75 | } 76 | if (!['mysql', 'mssql', 'postgres', 'mongodb', 'redis', 'oracle'].includes(row.json && row.json.type)) continue 77 | 78 | // filter region 79 | // const current_region = region[row.json.mode || 'production'] || 'seoul' 80 | // if (current_region != global.config.get('region.current')) { 81 | // continue 82 | // } 83 | if ((row.json.zone || 'seoul') != (process.env.REGION_CURRENT || 'seoul')) { 84 | continue 85 | } 86 | 87 | // const active = await State.set(`team.${team_id}.resource.${row.id}.active`, 'Y', 'EX', 3, 'NX') 88 | const active = 'OK' 89 | if (active == 'OK') { 90 | // if (row.json.password) { 91 | // row.json.password = row.json.vault ? Vault.decode(row.json.password) : Buffer.from(row.json.password || '', 'base64').toString('utf-8') 92 | // } 93 | const config = Object.assign({}, row.json, { 94 | name: `${team_id}-${row.id}`, 95 | logging: process.env.NODE_ENV == 'development' ? true : false, 96 | }) 97 | if (row.json.type == 'postgres') { 98 | if (String(row.json.ssl) === 'false') { 99 | config.ssl = false 100 | } else { 101 | config.ssl = { 102 | rejectUnauthorized: false, 103 | } 104 | } 105 | } 106 | if (row.json.type == 'mssql') { 107 | config.options = config.options || {} 108 | config.options.useUTC = true 109 | } 110 | if (row.json.type == 'mysql') { 111 | config.multipleStatements = true 112 | } 113 | if (row.json.type == 'mongodb') { 114 | // if (config.uri) { 115 | // config.uri = row.json.vault ? Vault.decode(config.uri) : Buffer.from(config.uri || '', 'base64').toString('utf-8') 116 | // } 117 | } 118 | 119 | try { 120 | if (config.type == 'mongodb') { 121 | await createConnectionAny(config) 122 | } else if (config.type == 'redis') { 123 | await createConnectionAny(config) 124 | } else { 125 | await createConnection(config) 126 | } 127 | 128 | let team_resource_connection 129 | if (config.type == 'mongodb') { 130 | team_resource_connection = await getConnectionAny(`${team_id}-${row.id}`) 131 | } else if (config.type == 'mongodb') { 132 | team_resource_connection = await getConnectionAny(`${team_id}-${row.id}`) 133 | } else { 134 | team_resource_connection = await getConnection(`${team_id}-${row.id}`) 135 | } 136 | 137 | // patch _auth req.resource 138 | internal_resources[row.json.name] = `${team_id}-${row.id}` 139 | 140 | let timezone = undefined 141 | let version = undefined 142 | if (row.json.type == 'mysql') { 143 | const tz = await team_resource_connection.query(`SELECT @@session.time_zone AS 't' `) 144 | timezone = tz[0].t 145 | if (timezone == 'SYSTEM') timezone = 'UTC' 146 | await State.set(`team.${team_id}.resource.${row.id}.timezone`, timezone) 147 | // debug('>>>>>>', `team.${team_id}.resource.${row.id}.timezone`, timezone) 148 | 149 | const _version = await team_resource_connection.query(`SELECT VERSION() AS v`) 150 | version = _version[0].v 151 | if (version.toUpperCase().includes('MARIADB')) { 152 | await team_resource_connection.query(`SET SESSION max_statement_time = 15000`) 153 | } else { 154 | await team_resource_connection.query(`SET SESSION max_execution_time = 15000`) 155 | } 156 | } else if (row.json.type == 'postgres') { 157 | const tz = await team_resource_connection.query(`SHOW timezone `) 158 | timezone = tz[0].TimeZone 159 | await State.set(`team.${team_id}.resource.${row.id}.timezone`, timezone) 160 | // debug('>>>>>>', `team.${team_id}.resource.${row.id}.timezone`, timezone) 161 | 162 | const _version = await team_resource_connection.query(`SELECT VERSION() AS v`) 163 | version = _version[0].v 164 | await team_resource_connection.query(`SET statement_timeout TO 15000`) 165 | } else if (row.json.type == 'mssql') { 166 | const tz = await team_resource_connection.query(`SELECT CURRENT_TIMEZONE() AS t`) 167 | timezone = tz[0].t 168 | if (timezone.length > 10) { 169 | const tz2 = timezone.match(/\(([A-Z]{3})\)/) 170 | if (tz2) { 171 | timezone = tz2[1] || timezone 172 | } 173 | } 174 | await State.set(`team.${team_id}.resource.${row.id}.timezone`, timezone) 175 | 176 | const _version = await team_resource_connection.query(`SELECT @@VERSION AS v`) 177 | version = _version[0].v 178 | } else if (row.json.type == 'mongodb') { 179 | // version? 180 | const info = await team_resource_connection.admin().serverInfo() 181 | version = info.version 182 | // timezone? 183 | } 184 | 185 | logger.emit('connection new', { 186 | team_id, 187 | teamrow_id: row.id, 188 | json: { 189 | name: row.name, 190 | timezone, 191 | version, 192 | }, 193 | }) 194 | } catch (error) { 195 | if (error.name == 'AlreadyHasActiveConnectionError') { 196 | // ok 197 | logger.emit('connection pool', { 198 | team_id, 199 | teamrow_id: row.id, 200 | json: { 201 | name: row.name, 202 | }, 203 | }) 204 | } else { 205 | logger.emit('connection failed', { 206 | team_id, 207 | teamrow_id: row.id, 208 | json: { 209 | name: row.name, 210 | error: error.message, 211 | }, 212 | }) 213 | debug(error.stack) 214 | // continue 215 | } 216 | } 217 | info(`connected new: team.${team_id}.resource.${row.id}`) 218 | } 219 | } 220 | } catch (e) { 221 | error(e.stack) 222 | logger.emit('connection server error', { 223 | team_id, 224 | json: { 225 | error: e.stack, 226 | }, 227 | }) 228 | } 229 | } 230 | 231 | module.exports.init = async () => { 232 | 233 | // if (process.env.NODE_ENV == 'development') { 234 | // await createConnection('selectbase.master') 235 | // } 236 | // const select_config = global.config.get('select-configuration') 237 | // if (!select_config.resources) throw new Error('server error: no resources configured.') 238 | 239 | // for (const r of select_config.resources) { 240 | // if (r.type == 'mysql') { 241 | // await createConnection({ 242 | // name: r.key, 243 | // type: 'mysql', 244 | // host: r.host, 245 | // port: r.port || 3306, 246 | // username: r.username, 247 | // password: Buffer.from(r.password, 'base64').toString('utf-8'), 248 | // database: r.database, 249 | // synchronize: false, 250 | // logging: process.env.NODE_ENV == 'development' ? true : false, 251 | // requestTimeout: 60*1000, 252 | // timezone: r.timezone || '+00:00', 253 | // dateStrings: true, 254 | // extra: { 255 | // charset: r.charset || "utf8mb4_general_ci", 256 | // }, 257 | // }) 258 | // // debug('created ', r) 259 | // } else { 260 | // throw new Error('server error: not supported resource[type].') 261 | // } 262 | // } 263 | } 264 | 265 | module.exports.init_service = async () => { 266 | 267 | } -------------------------------------------------------------------------------- /models/selectConfig.js: -------------------------------------------------------------------------------- 1 | const {debug, info, error} = require('../log')('select:selectConfig') 2 | const YAML = require('js-yaml') 3 | const { getConnection } = require('typeorm') 4 | const State = require('../models/State') 5 | // const Vault = require('../models/vault') 6 | const {glob} = require('glob') 7 | const fs = require('fs') 8 | const chalk = require('chalk') 9 | const path = require('path') 10 | 11 | const refresh_team_config = async (team_id) => { 12 | const errors = [] 13 | 14 | try { 15 | // check team env 16 | // if prod fixed > get deployment > inject teamrows > yaml + production 17 | // if prod not fixed > yaml + production 18 | 19 | // const rows = await master.createQueryBuilder() 20 | // .select('*') 21 | // .from('TeamRow') 22 | // .where('team_id = :tid AND `type` = "CONFIG" ', { 23 | // tid: team_id, 24 | // }) 25 | // .andWhere('commit_at IS NOT NULL') 26 | // .orderBy('id', 'ASC') 27 | // .getRawMany() 28 | 29 | // if (rows.length === 0) throw StatusError(500, 'no config published') 30 | 31 | // const tree = await master.createQueryBuilder() 32 | // .select('*') 33 | // .from('TeamRow') 34 | // .where('team_id = :tid AND `type` = "TREE" ', { 35 | // tid: team_id, 36 | // }) 37 | // .andWhere('commit_at IS NOT NULL') 38 | // .orderBy('id', 'ASC') 39 | // .limit(1) 40 | // .getRawOne() 41 | 42 | // let sorted_rows 43 | // if (tree && tree.json) { 44 | // sorted_rows = tree.json.map(e => { 45 | // return rows.find(file => file.id == e.team_row_id) 46 | // }).filter(e => !!e) 47 | // } else { 48 | // // not has tree yet 49 | // sorted_rows = rows 50 | // } 51 | 52 | // todo: glob FILES 53 | const files = await glob(path.join(process.cwd(), '**/*.{yml,yaml}'), { 54 | ignore: 'node_modules/**', 55 | }) 56 | 57 | let global_row 58 | for (const path of files) { 59 | const filename = path.split('/').slice(-1)[0] 60 | if (['global.yml', 'global.yaml'].includes(filename)) { 61 | global_row = { 62 | id: path, 63 | json: { 64 | yml: fs.readFileSync(path), 65 | }, 66 | } 67 | } 68 | } 69 | 70 | const config = {} 71 | sorted_rows = files || [] 72 | sorted_rows.sort() 73 | 74 | let json = { 75 | menus: [], 76 | pages: [], 77 | users: [], 78 | title: undefined, 79 | layout: undefined, 80 | resources: [], 81 | // defaultColumns: {}, 82 | // defaultColumnOptions: {}, 83 | } 84 | for (const path of sorted_rows) { 85 | try { 86 | const filename = path.split('/').slice(-1)[0] 87 | if (filename[0] == '_') continue 88 | 89 | const row = { 90 | id: path, 91 | json: { 92 | yml: '', 93 | } 94 | } 95 | row.json.yml = fs.readFileSync(path) 96 | if (global_row && global_row.json && global_row.id != path) { 97 | row.json.yml = `${global_row.json.yml || ''}\n\n${row.json.yml || ''}` 98 | } 99 | // const parsed = YAML.load(row.json.yml || '') || {} 100 | const docs = YAML.loadAll(row.json.yml || '') || [] 101 | for (const parsed of docs) { 102 | if (!_.isObject(parsed)) continue 103 | 104 | if (parsed.title) { 105 | json.title = parsed.title 106 | } 107 | if (parsed.layout) { 108 | json.layout = parsed.layout 109 | } 110 | 111 | if (_.isArray(parsed.menus)) { 112 | parsed.menus = parsed.menus.map(e => { 113 | e._id = row.id 114 | e.order = +e.order || undefined 115 | return e 116 | }) 117 | json.menus.push(...parsed.menus) 118 | } 119 | if (_.isArray(parsed.pages)) { 120 | parsed.pages = parsed.pages.map(e => { 121 | e._id = row.id 122 | e.order = +e.order || undefined 123 | return e 124 | }) 125 | json.pages.push(...parsed.pages) 126 | } 127 | if (_.isArray(parsed.users)) { 128 | json.users.push(...parsed.users) 129 | } 130 | if (_.isArray(parsed.resources)) { 131 | json.resources.push(...parsed.resources) 132 | } 133 | // if (parsed.defaultColumns && _.isObject(parsed.defaultColumns)) { 134 | // json.defaultColumns = Object.assign(json.defaultColumns, parsed.defaultColumns) 135 | // } 136 | // if (parsed.defaultColumnOptions && _.isObject(parsed.defaultColumnOptions)) { 137 | // json.defaultColumnOptions = Object.assign(json.defaultColumnOptions, parsed.defaultColumnOptions) 138 | // } 139 | } 140 | 141 | // fill tabs page 142 | const fill_block = (block) => { 143 | if (block && block.tabOptions && block.tabOptions.tabs) { 144 | block.tabOptions.tabs = block.tabOptions.tabs.map(tab => { 145 | if (tab.path) { 146 | const filler = json.pages.find(e => e.path == tab.path) 147 | if (filler) { 148 | return { 149 | ...filler, 150 | ...tab, 151 | } 152 | } 153 | } 154 | if (tab && tab.blocks) { 155 | tab.blocks = tab.blocks.map(fill_block) 156 | } 157 | return tab 158 | }) 159 | } 160 | if (block && block.viewModal && block.viewModal.usePage) { 161 | const filler = json.pages.find(e => e.path == block.viewModal.usePage) 162 | if (filler) { 163 | block.viewModal = { 164 | ...filler, 165 | ...block.viewModal, 166 | blocks: filler.blocks, 167 | } 168 | } 169 | } 170 | if (block && block.modals) { 171 | block.modals = block.modals.map(viewModal => { 172 | if (viewModal.usePage) { 173 | const filler = json.pages.find(e => e.path == viewModal.usePage) 174 | if (filler) { 175 | return { 176 | ...filler, 177 | ...viewModal, 178 | blocks: filler.blocks, 179 | } 180 | } 181 | } 182 | return viewModal 183 | }) 184 | } 185 | if (block && block.blocks) { 186 | block.blocks = block.blocks.map(fill_block) 187 | } 188 | if (block.got) { 189 | block.axios = block.got 190 | block._use_http_got = true 191 | } 192 | return block 193 | } 194 | json.pages = json.pages.map((page, i) => { 195 | if (page && page.blocks) { 196 | page.blocks = page.blocks.map(fill_block) 197 | } 198 | page._idx = i 199 | return page 200 | }) 201 | 202 | // if (json == null) { 203 | // if (!_.isArray(parsed.menus)) parsed.menus = [] 204 | // if (!_.isArray(parsed.pages)) parsed.pages = [] 205 | // if (!_.isArray(parsed.users)) parsed.users = [] 206 | 207 | // parsed.menus = parsed.menus.map(e => { 208 | // e._id = row.id 209 | // e.order = +e.order || undefined 210 | // return e 211 | // }) 212 | // parsed.pages = parsed.pages.map(e => { 213 | // e._id = row.id 214 | // e.order = +e.order || undefined 215 | // return e 216 | // }) 217 | 218 | // json = parsed 219 | // } else { 220 | // if (!_.isObject(parsed)) continue 221 | 222 | // if (_.isArray(parsed.menus)) { 223 | // parsed.menus = parsed.menus.map(e => { 224 | // e._id = row.id 225 | // e.order = +e.order || undefined 226 | // return e 227 | // }) 228 | // json.menus.push(...parsed.menus) 229 | // } 230 | // if (_.isArray(parsed.pages)) { 231 | // parsed.pages = parsed.pages.map(e => { 232 | // e._id = row.id 233 | // e.order = +e.order || undefined 234 | // return e 235 | // }) 236 | // json.pages.push(...parsed.pages) 237 | // } 238 | // if (_.isArray(parsed.users)) { 239 | // json.users.push(...parsed.users) 240 | // } 241 | // } 242 | } catch (e) { 243 | // debug(e.stack) 244 | // TODO: notify to user 245 | console.log('') 246 | error(path) 247 | error(e) 248 | console.log('') 249 | errors.push({ 250 | path, 251 | message: e.message, 252 | }) 253 | } 254 | } 255 | json.menus = _.sortBy(json.menus, 'order') 256 | 257 | // const keys = await master.createQueryBuilder() 258 | // .select('*') 259 | // .from('TeamRow') 260 | // .where('team_id = :tid AND `type` = "KEY" ', { 261 | // tid: team_id, 262 | // }) 263 | // .andWhere('commit_at IS NOT NULL') 264 | // .getRawMany() 265 | 266 | // TODD: load env from api 267 | 268 | const items = Object.keys(process.env).filter(e => e.startsWith('APP_')) 269 | const keys = items.map(key => { 270 | return { 271 | name: key, 272 | json: { 273 | value: process.env[key], 274 | }, 275 | } 276 | }) 277 | 278 | 279 | json.keys = keys.map(e => { 280 | return { 281 | key: e.name, 282 | // value: e.json.vault ? Vault.decode(e.json.value) : String(e.json.value === undefined ? '' : e.json.value).trim(), 283 | value: String(e.json.value === undefined ? '' : e.json.value).trim(), 284 | // mode: e.json.mode, 285 | } 286 | }) 287 | 288 | const _resources = json.resources || [] 289 | const resources = _resources.map((e, i) => { 290 | e.id = i 291 | e.name = String(e.name).trim() 292 | for (const key in e) { 293 | if (String(e[key]).startsWith('$')) { 294 | const v = process.env[String(e[key]).slice(1)] 295 | if (!v) { 296 | console.log(chalk.yellow('[WARN]'), `process.env.${ String(e[key]).slice(1) } not found.`) 297 | } 298 | e[key] = process.env[String(e[key]).slice(1)] || undefined 299 | } 300 | } 301 | return e 302 | }) 303 | 304 | // cloud overwrite 305 | json.resources = resources 306 | 307 | // ts for reload 308 | json.ts = Date.now() 309 | 310 | // always - auto publish 311 | const next_json = JSON.stringify(json) 312 | // debug(next_json) 313 | await State.set(`admin.${team_id}.yaml`, next_json) 314 | await State.set(`admin.${team_id}.error`, errors.length ? JSON.stringify(errors) : '') 315 | // const cached_json = await redis.get(`admin.${team_id}.yaml`) 316 | // if (cached_json != next_json) { 317 | // } 318 | 319 | if (global.__livereload_server) { 320 | debug('global.__livereload_server') 321 | global.__livereload_server.refresh('/') 322 | } 323 | 324 | } catch (e) { 325 | error(e.stack) 326 | 327 | throw StatusError(500, 'config not updated. ' + e.message) 328 | } 329 | } 330 | 331 | const refresh_team_all = async () => { 332 | try { 333 | const ids = [process.env.TEAM_ID] 334 | for (const team_id of ids) { 335 | try { 336 | debug(`try refresh config to tid:${team_id}`) 337 | await module.exports.refresh_team_config(team_id) 338 | } catch (ee) { 339 | debug('refresh config error', ee.message) 340 | } 341 | // continue to next 342 | } 343 | 344 | } catch (e) { 345 | error(e.stack) 346 | } 347 | } 348 | 349 | const watch = () => { 350 | const chokidar = require('chokidar') 351 | 352 | chokidar.watch(path.join(process.cwd(), '**/*.yml'), { 353 | ignored: 'node_modules/**', 354 | ignoreInitial: true, 355 | }) 356 | .on('all', (event, path) => { 357 | console.log(chalk.blue('[INFO]'), `Reload from ${path} ${event}.`) 358 | refresh_team_config(process.env.TEAM_ID) 359 | }) 360 | } 361 | 362 | module.exports = { 363 | refresh_team_config, 364 | refresh_team_all, 365 | watch, 366 | } -------------------------------------------------------------------------------- /models/only.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('select:only') 2 | const jwt = require('jsonwebtoken') 3 | const { getConnection } = require('typeorm') 4 | const parseDuration = require('parse-duration') 5 | const ipcidr = require('ip-cidr') 6 | const UserProfile = require('./UserProfile') 7 | const Team = require('./Team') 8 | const path = require('path') 9 | const fs = require('fs') 10 | const db = require('./db') 11 | 12 | const axios = require('axios') 13 | const $http = axios.create({ 14 | withCredentials: false, 15 | timeout: 30000, 16 | baseURL: global.__API_BASE, 17 | headers: { 18 | Authorization: `Bearer ${process.env.TOKEN}` 19 | }, 20 | }) 21 | 22 | const id = () => { 23 | return function (req, res, next) { 24 | if (req.is_session_from_hash) { 25 | return next() 26 | } 27 | const parts = (req.get('authorization') || '').split(' ') 28 | const token = parts.length > 1 && parts[1] 29 | if (!token) { 30 | return next(new StatusError(403, '로그인이 필요합니다. #1000')) 31 | } 32 | try { 33 | req.session = jwt.verify(token, process.env.SECRET_ACCESS_TOKEN || 'secretAccessToken') 34 | // for sentry 35 | req.user = { 36 | id: req.session.id, 37 | email: req.session.email, 38 | } 39 | // global.Bugsnag && global.Bugsnag.setUser(req.session.id, req.session.email) 40 | next() 41 | } catch (error) { 42 | debug(error) 43 | if (error.message == 'jwt malformed') { 44 | return next(new StatusError(403, `다시 로그인이 필요합니다. #1101`)) 45 | } 46 | // global.Bugsnag && global.Bugsnag.notify(error) 47 | return next(new StatusError(403, `세션 오류 #1100`)) 48 | } 49 | } 50 | } 51 | 52 | const id_optional = () => { 53 | return function (req, res, next) { 54 | const parts = (req.get('authorization') || '').split(' ') 55 | const token = parts.length > 1 && parts[1] 56 | if (!token) { 57 | return next() 58 | } 59 | try { 60 | req.session = jwt.verify(token, process.env.SECRET_ACCESS_TOKEN || 'secretAccessToken') 61 | // for sentry 62 | req.user = { 63 | id: req.session.id, 64 | email: req.session.email, 65 | } 66 | // global.Bugsnag && global.Bugsnag.setUser(req.session.id, req.session.email) 67 | next() 68 | } catch (error) { 69 | // global.Bugsnag && global.Bugsnag.notify(error) 70 | return next() 71 | } 72 | } 73 | } 74 | 75 | const scope = (scope) => { 76 | return function (req, res, next) { 77 | if (req.is_session_from_hash) { 78 | return next() 79 | } 80 | 81 | const parts = (req.get('authorization') || '').split(' ') 82 | const token = parts.length > 1 && parts[1] 83 | if (!token) { 84 | return next(new StatusError(403, '로그인이 필요합니다. #1000')) 85 | } 86 | try { 87 | req.session = jwt.verify(token, process.env.SECRET_ACCESS_TOKEN || 'secretAccessToken') 88 | // for sentry 89 | req.user = { 90 | id: req.session.id, 91 | email: req.session.email, 92 | } 93 | // global.Bugsnag && global.Bugsnag.setUser(req.session.id, req.session.email) 94 | 95 | if (!req.session.scope || !req.session.scope.includes(scope)) 96 | throw new Error('scope required') 97 | 98 | next() 99 | } catch (error) { 100 | return next(new StatusError(403, `권한이 없습니다. Name=${scope} #2000`)) 101 | } 102 | } 103 | } 104 | 105 | // any 106 | const teamscope_any_of = (...scopes) => { 107 | return function (req, res, next) { 108 | try { 109 | if (!req.team.id) throw new Error('team not loaded') 110 | const teamscopes = scopes.map(scope => `tid:${req.team.id}:${scope}`) 111 | if (!req.session.scope) req.session.scope = [] 112 | if (!req.session.scope || _.intersection(req.session.scope, teamscopes).length == 0) 113 | throw new Error('scope required') 114 | next() 115 | } catch (error) { 116 | return next(new StatusError(403, `권한이 없습니다. 해당 권한: [${scopes.join(', ')}] 문의코드 #2000`)) 117 | } 118 | } 119 | } 120 | 121 | const roles_any_of = (...scopes) => { 122 | return function (req, res, next) { 123 | try { 124 | if (!req.queryrow_roles) throw new Error('queryrow_roles not loaded') 125 | 126 | const roles = [] 127 | for (const role of req.queryrow_roles) { 128 | if (role.user_id == 0 || role.user_id == req.session.id) { 129 | roles.push(role.name) 130 | } 131 | } 132 | req.queryrow_roles_names = roles 133 | if (!roles || _.intersection(roles, scopes).length == 0) 134 | throw new Error('queryrow roles scope required') 135 | 136 | next() 137 | } catch (error) { 138 | return next(new StatusError(403, `권한이 없습니다. 해당 권한: [${scopes.join(', ')}] 문의코드 #2001`)) 139 | } 140 | } 141 | } 142 | 143 | // const menu = () => { 144 | // return async function (req, res, next) { 145 | // try { 146 | // const admin_domain = String(req.query.admin_domain || '') 147 | // const master = await getConnection('mysql.master') 148 | 149 | // const id = +admin_domain || 0 150 | // const menu = await master.createQueryBuilder() 151 | // .select('*') 152 | // .from('TeamRow') 153 | // .where('id = :id AND `type` = "MENU" ', { 154 | // id, 155 | // }) 156 | // .andWhere('commit_at IS NOT NULL') 157 | // .getRawOne() 158 | // if (!menu.id) throw StatusError(400, 'admin page not found') 159 | // const team_id = menu.team_id // from session or selector 160 | 161 | // const roles = await master.createQueryBuilder() 162 | // .select('*') 163 | // .from('UserRole') 164 | // .where('user_id = :uid', { 165 | // uid: req.session.id, 166 | // }) 167 | // .getRawMany() 168 | 169 | // if (roles.length == 0) throw StatusError(400, 'no roles access') 170 | 171 | // let has_role = false 172 | // for (const role of roles) { 173 | // if (role.team_id == team_id) { 174 | // has_role = true 175 | // break 176 | // } 177 | // } 178 | // if (!has_role) throw StatusError(400, 'no roles access') 179 | 180 | // req.menu = menu 181 | // next() 182 | // } catch (error) { 183 | // return next(error) 184 | // } 185 | // } 186 | // } 187 | 188 | const menu = () => { 189 | // block, admin 쪽에만 쓰임 190 | return async function (req, res, next) { 191 | if (req.is_session_from_hash) { 192 | return next() 193 | } 194 | 195 | try { 196 | const admin_domain = String(req.query.admin_domain || '') 197 | 198 | const team_id = +admin_domain || 0 199 | 200 | let roles = [] 201 | let user 202 | 203 | if (process.env.ADMIN_ON_PREMISE) { 204 | const p = path.join(process.env.CWD || process.cwd(), '_auth', 'getRoles.js') 205 | if (!fs.existsSync(p)) { 206 | throw StatusError('getRoles.js not found') 207 | } 208 | const f = require(p) 209 | req.team = global.__TEAM 210 | req.resource = db.get_internal_resource 211 | const r = await f(req) 212 | roles = r.roles 213 | user = r.user 214 | } else { 215 | const r = await $http({ 216 | method: 'GET', 217 | url: '/cli/UserRole/get', 218 | params: { 219 | user_id: req.session.id, 220 | // tid: team_id, 221 | }, 222 | headers: { 223 | Authorization: `${process.env.TOKEN}`, 224 | }, 225 | json: true, 226 | }) 227 | if (r.data?.message != 'ok') throw new Error('Network Error') 228 | roles = r.data.roles 229 | user = r.data.user 230 | } 231 | if (roles.length == 0) throw StatusError(400, 'no roles access') 232 | 233 | // let has_role = false 234 | // for (const role of roles) { 235 | // if (role.team_id == team_id) { 236 | // has_role = true 237 | // break 238 | // } 239 | // } 240 | // if (!has_role) throw StatusError(400, 'no roles access') 241 | 242 | req.menu = { 243 | team_id, 244 | } 245 | req.user_role = roles[0] 246 | 247 | // only after menu (for block/query) 248 | req.session.email = user.email 249 | req.session.name = user.name 250 | // global.Bugsnag && global.Bugsnag.setUser(req.session.id, req.session.email) 251 | next() 252 | } catch (error) { 253 | // global.Bugsnag && global.Bugsnag.notify(error) 254 | return next(error) 255 | } 256 | } 257 | } 258 | 259 | const hash = () => { 260 | return async function (req, res, next) { 261 | next() 262 | // try { 263 | // const hash = String(req.query.admin_domain || '') 264 | // const master = await getConnection('mysql.master') 265 | 266 | // let team_share = await master.createQueryBuilder() 267 | // .select('*') 268 | // .from('TeamShare') 269 | // .where('uuid = :hash', { 270 | // hash, 271 | // }) 272 | // .andWhere('deleted_at IS NULL') 273 | // .getRawOne() 274 | // if (!team_share) { 275 | // return next() 276 | // // throw StatusError(400, 'no share found') 277 | // } 278 | 279 | // if (!team_share.scope_json.allow_view_link) { 280 | // throw StatusError(400, 'view not allowed') 281 | // } 282 | 283 | // req.team_share = team_share 284 | 285 | // req.menu = { 286 | // team_id: team_share.team_id, 287 | // } 288 | // req.user_role = [ 289 | // { 290 | // id: 1, 291 | // user_id: 5, 292 | // name: 'view', 293 | // } 294 | // ] 295 | 296 | // // only after menu (for block/query) 297 | // if (!req.session) { 298 | // req.session = {} 299 | // req.session.email = 'guest@selectfromuser.com' 300 | // req.session.id = 5 301 | // } 302 | // global.Bugsnag && global.Bugsnag.setUser(req.session.id, req.session.email) 303 | 304 | // // pass other ids 305 | // req.is_session_from_hash = true 306 | 307 | // next() 308 | // } catch (error) { 309 | // global.Bugsnag && global.Bugsnag.notify(error) 310 | // return next(error) 311 | // } 312 | } 313 | } 314 | 315 | const expiration = () => { 316 | return async function (req, res, next) { 317 | try { 318 | const tid = String(req.query.admin_domain || req.team.id) 319 | let team = {} 320 | { 321 | const r = await $http({ 322 | method: 'GET', 323 | url: '/cli/Team/get', 324 | params: { 325 | 326 | }, 327 | headers: { 328 | Authorization: `${process.env.TOKEN}`, 329 | }, 330 | json: true, 331 | }) 332 | if (r.data?.message != 'ok') throw new Error('Network Error') 333 | team = r.data.team 334 | } 335 | if (!team) throw StatusError(400, 'team policy not found') 336 | team.env_config = team.env_config || {} 337 | const session_inactivity_timeout = team.env_config.session_inactivity_timeout || '72h' 338 | const session_max_expiration = team.env_config.session_max_expiration || '365d' 339 | // debug({ 340 | // session_inactivity_timeout, 341 | // t1a: (Date.now() - req.session.refresh_ts), 342 | // t1b: parseDuration(session_inactivity_timeout), 343 | // t1: Date.now() - req.session.refresh_ts > parseDuration(session_inactivity_timeout), 344 | // session_max_expiration, 345 | // t2a: (Date.now() - req.session.initial_ts), 346 | // t2b: parseDuration(session_max_expiration), 347 | // t2: Date.now() - req.session.initial_ts > parseDuration(session_max_expiration), 348 | // }) 349 | if (Date.now() - req.session.refresh_ts > parseDuration(session_inactivity_timeout)) { 350 | throw StatusError(400, 'session_inactivity_timeout') 351 | } 352 | if (Date.now() - req.session.initial_ts > parseDuration(session_max_expiration)) { 353 | throw StatusError(400, 'session_max_expiration') 354 | } 355 | 356 | const mode = req.get('user-mode') || req.query.mode || 'production' 357 | if (team.env_config.ip_cidr_enabled && team.env_config.ip_cidr && team.env_config.ip_cidr.length 358 | && mode != 'local' 359 | ) { 360 | const ip = req.ip 361 | let accepted = false 362 | for (const policy of team.env_config.ip_cidr) { 363 | if (policy.mode != mode) continue 364 | if (!ipcidr.isValidAddress(policy.cidr)) { 365 | // debug('invalid cidr', policy) 366 | continue 367 | } 368 | const cidr = new ipcidr(policy.cidr) 369 | if (cidr.contains(ip)) { 370 | // debug('accept cidr', policy) 371 | accepted = true 372 | } 373 | } 374 | if (!accepted) { 375 | throw StatusError(403, '접근 불가능한 네트워크 - 해당 사용자의 세션 IP 대역 오류 (rejected)') 376 | } 377 | } 378 | 379 | next() 380 | } catch (error) { 381 | // global.Bugsnag && global.Bugsnag.notify(error) 382 | return next(error) 383 | } 384 | } 385 | } 386 | 387 | module.exports = { 388 | scope, id, menu, 389 | teamscope_any_of, 390 | id_optional, 391 | roles_any_of, 392 | hash, 393 | expiration, 394 | } -------------------------------------------------------------------------------- /routes/team.js: -------------------------------------------------------------------------------- 1 | const {debug, info, error} = require('../log')('select:api') 2 | const only = require('../models/only') 3 | const logger = require('../models/logger') 4 | const monitor = require('../models/monitor') 5 | const State = require('../models/State') 6 | const db = require('../models/db') 7 | const { getConnection, Db } = require('typeorm') 8 | const { getConnectionAny } = require('../models/dbAny') 9 | const alasql = require('alasql') 10 | const { getGotInstance } = require('../models/httpGot') 11 | const router = require('express').Router() 12 | const path = require('path') 13 | const fs = require('fs') 14 | 15 | const { Parser } = require('node-sql-parser') 16 | const parser = new Parser 17 | 18 | const axios = require('axios') 19 | const $http = axios.create({ 20 | withCredentials: false, 21 | timeout: 30000, 22 | baseURL: global.__API_BASE, 23 | }) 24 | 25 | router.use((req, res, next) => { 26 | req._json = true 27 | next() 28 | }) 29 | 30 | router.param('admin_domain_or_team_id', (req, res, next) => { 31 | req.team = global.__TEAM 32 | next() 33 | }) 34 | 35 | router.get('/:admin_domain_or_team_id/config', [only.id(), only.teamscope_any_of('view', 'edit', 'admin'), only.expiration()], async (req, res, next) => { 36 | try { 37 | // const master = await getConnection('mysql.master') 38 | 39 | // trigger 40 | // // always - auto publish 41 | // const redis = getRedisConnection() 42 | // const cached_json = await redis.get(`admin.${req.team.id}.yaml`) 43 | // const next_json = JSON.stringify(json) 44 | // if (cached_json != next_json) { 45 | // await redis.set(`admin.${req.team.id}.yaml`, next_json) 46 | // } 47 | 48 | // const redis = getRedisConnection('sub') 49 | const cached_json = await State.get(`admin.${req.team.id}.yaml`) 50 | let cached_error = await State.get(`admin.${req.team.id}.error`) 51 | try { 52 | cached_error = JSON.parse(cached_error || '[]') || [] 53 | } catch (error) { 54 | debug(error) 55 | } 56 | const json = JSON.parse(cached_json || '{}') || {} 57 | 58 | // // inject roles 59 | // let role = await master.createQueryBuilder() 60 | // .select('UserRole.id, UserRole.group_json') 61 | // .addSelect('UserProfile.email') 62 | // .from('UserRole') 63 | // .addFrom('UserProfile') 64 | // .where('UserRole.team_id = :tid', { 65 | // tid: req.team.id, 66 | // }) 67 | // .andWhere('UserRole.user_id = :uid', { 68 | // uid: req.session.id, 69 | // }) 70 | // .andWhere('UserRole.team_row_id IS NULL') 71 | // .andWhere('UserRole.deleted_at IS NULL') 72 | // .andWhere('UserProfile.id = UserRole.user_id') 73 | // .andWhere('UserProfile.deleted_at IS NULL') 74 | // .getRawOne() 75 | // if (!process.env.ONPREM && +req.team.id == +global.config.get('template.team_id')) { 76 | // role = { 77 | // id: 1, 78 | // group_json: [], 79 | // email: req.session.email, 80 | // } 81 | // } 82 | // if (!role || !role.id) throw StatusError(403, '권한을 확인해주세요.') 83 | // if (!json.users) json.users = [] 84 | // json.users = json.users.filter(e => { 85 | // return e.email == role.email 86 | // }) 87 | // if (json.users.length === 0) { 88 | // // yml 없고, ui 기준 89 | // json.users.push({ 90 | // email: role.email, 91 | // roles: role.group_json || [], 92 | // }) 93 | // } else { 94 | // if (role.group_json && role.group_json.length) { 95 | // // ui 덮어씌우기 96 | // json.users[0].roles = role.group_json 97 | // } else { 98 | // // yml 유지 99 | // } 100 | // } 101 | // if (json.users[0]) { 102 | // if (!json.users[0].roles) json.users[0].roles = [] 103 | // json.users[0].roles.push(`email::${json.users[0].email}`) 104 | // } 105 | 106 | // if (json.keys) { 107 | // json.keys = json.keys.map(e => { 108 | // e.value = undefined 109 | // return e 110 | // }) 111 | // } 112 | 113 | const clean_sql = (block, use_sqlType = true) => { 114 | if (block.sql) { 115 | if (use_sqlType && !block.sqlType) { 116 | const _resource = json.resources.find(e => e.name == block.resource) 117 | const resource_type = _resource && _resource.type || 'mysql' 118 | 119 | const sql = block.sql 120 | .replace(/::([A-Za-z]+)/g, ``) 121 | .replace(/:(\.\.\.)?([A-Za-z0-9_.]+)/g, `''`) 122 | .replace(/\{\{(\s)?query(\s)?\}\}/g, `1=1`) 123 | .replace(/\{\{(\s)?orderBy(\s)?\}\}/g, ``) 124 | 125 | try { 126 | const ast = parser.astify(sql, { 127 | database: { 128 | 'mysql': 'mysql', 129 | 'postgres': 'postgresql', 130 | // 'bigquery': 'bigquery', 131 | // 'db2': 'db2', 132 | // 'hive': 'hive', 133 | // 'mariadb': 'mariadb', 134 | // 'sqlite': 'sqlite', 135 | // 'transactsql': 'transactsql', 136 | // 'flinksql': 'flinksql', 137 | 'mssql': 'transactsql', 138 | }[resource_type] 139 | }) 140 | block.sqlType = String(ast.type).toLowerCase() 141 | } catch (error) { 142 | debug('failed to astify', error.message, '\n\n', sql, '\n\n') 143 | block._sqlType = 'astify failed' 144 | // empty 145 | } 146 | } 147 | block.sql = '***' 148 | } 149 | if (block.sqlWith) { 150 | block.sqlWith = block.sqlWith.map(e => { 151 | if (e.id) e.id = '***' 152 | if (e.gid) e.gid = '***' 153 | if (e.query) e.query = '***' 154 | return clean_table_block(e) 155 | }) 156 | } 157 | if (block.sqlTransaction) { 158 | block.sqlTransaction = block.sqlTransaction.map(clean_sql) 159 | } 160 | return block 161 | } 162 | const clean_table_block = (block) => { 163 | if (block.params) { 164 | block.params = block.params.map(param => { 165 | if (param.datalistFromQuery) { 166 | param.datalistFromQuery = clean_sql(param.datalistFromQuery, false,) 167 | } 168 | if (param.defaultValueFromQuery) { 169 | param.defaultValueFromQuery = clean_sql(param.defaultValueFromQuery, false) 170 | } 171 | if (param.query) { 172 | for (const key of Object.keys(param.query)) { 173 | if (param.query[key]) param.query[key] = '***' 174 | } 175 | } 176 | if (param.orderBy) { 177 | for (const key of Object.keys(param.orderBy)) { 178 | if (param.orderBy[key]) param.orderBy[key] = '***' 179 | } 180 | } 181 | if (param.valueFromQuery) { 182 | param.valueFromQuery = clean_sql(param.valueFromQuery, false) 183 | } 184 | if (param.value === undefined) { 185 | param.value = '' 186 | } 187 | return param 188 | }) 189 | } 190 | if (block.actions) { 191 | block.actions = block.actions.map(clean_block) 192 | } 193 | if (block.viewModal) { 194 | if (block.viewModal.blocks) { 195 | block.viewModal.blocks = block.viewModal.blocks.map(clean_block) 196 | } 197 | } 198 | if (block.tabOptions) { 199 | if (block.tabOptions.tabs) { 200 | block.tabOptions.tabs = block.tabOptions.tabs.map(tab => { 201 | if (tab.blocks) { 202 | tab.blocks = tab.blocks.map(clean_block) 203 | } 204 | return tab 205 | }) 206 | } 207 | } 208 | if (block.columns) { 209 | for (const key of Object.keys(block.columns)) { 210 | if (block.columns[key]) { 211 | if (block.columns[key].updateOptions) { 212 | block.columns[key].updateOptions = clean_block(block.columns[key].updateOptions) 213 | } 214 | if (block.columns[key].datalistFromQuery) { 215 | block.columns[key].datalistFromQuery = clean_sql(block.columns[key].datalistFromQuery, false) 216 | } 217 | if (block.columns[key].defaultValueFromQuery) { 218 | block.columns[key].defaultValueFromQuery = clean_sql(block.columns[key].defaultValueFromQuery, false) 219 | } 220 | } 221 | } 222 | } 223 | if (block.columnOptions) { 224 | block.columnOptions = block.columnOptions.map(columnOption => { 225 | if (columnOption.updateOptions) { 226 | columnOption.updateOptions = clean_block(columnOption.updateOptions) 227 | } 228 | if (columnOption.datalistFromQuery) { 229 | columnOption.datalistFromQuery = clean_sql(columnOption.datalistFromQuery, false) 230 | } 231 | if (columnOption.defaultValueFromQuery) { 232 | columnOption.defaultValueFromQuery = clean_sql(columnOption.defaultValueFromQuery, false) 233 | } 234 | return columnOption 235 | }) 236 | } 237 | return block 238 | } 239 | const clean_http = (block) => { 240 | if (block && block.axios) { 241 | const json = JSON.stringify(block.axios) 242 | 243 | let _evals = json.match(/\{\{(.*?)\}\}/gm) 244 | if (_evals) { 245 | _evals = _evals.map(e => { 246 | return JSON.parse(`"${e.slice(2, -2)}"`) 247 | }) 248 | } 249 | // debug(_evals) 250 | block.axios = { 251 | method: block.axios.method, 252 | methodType: block.axios.methodType, 253 | _evals, 254 | } 255 | } 256 | return block 257 | } 258 | const clean_block = (block) => { 259 | if (!block) return block 260 | if (block.type == 'query') return clean_table_block(clean_sql(block)) 261 | if (block.type == 'http') return clean_table_block(clean_http(block)) 262 | if (['left', 'right', 'center', 'top', 'bottom'].includes(block.type)) { 263 | if (block.blocks) { 264 | block.blocks = block.blocks.map(clean_block) 265 | } 266 | } 267 | return clean_table_block(block) 268 | } 269 | if (json.pages) { 270 | json.pages = json.pages.map(page => { 271 | if (page.blocks) { 272 | page.blocks = page.blocks.map(clean_block) 273 | } 274 | return page 275 | }) 276 | 277 | const menus = json.menus.map(e => { 278 | return [].concat((e.menus || []), (e.menus || []).map(e => { 279 | return [].concat((e.menus || []), (e.menus || []).map(e => { 280 | return e.menus || [] 281 | })) 282 | })) 283 | }) 284 | const flatten_menus = _.flatten(_.flatten([].concat(menus, json.menus))) 285 | 286 | // update UserRole 287 | const refresh_role = async () => { 288 | try { 289 | if (process.env.ADMIN_ON_PREMISE) { 290 | const p = path.join(process.env.CWD || process.cwd(), '_auth', 'getRoles.js') 291 | if (!fs.existsSync(p)) { 292 | throw StatusError('getRoles.js not found') 293 | } 294 | const f = require(p) 295 | req.team = global.__TEAM 296 | req.resource = db.get_internal_resource 297 | // required: req.session.id 298 | const r = await f(req) 299 | 300 | const roles = [] 301 | if (r.roles[0]) { 302 | roles.push(`select::${r.roles[0].name}`) 303 | if (r.roles[0].group_json) { 304 | roles.push(...r.roles[0].group_json) 305 | } 306 | } 307 | roles.push(`email::${r.user.email}`) 308 | 309 | r.roles = roles 310 | 311 | global.__USER_ROLES[req.session.id] = r 312 | 313 | return 314 | } else { 315 | const r = await $http({ 316 | method: 'GET', 317 | url: '/cli/UserRole/get', 318 | params: { 319 | user_id: req.session.id, 320 | }, 321 | headers: { 322 | Authorization: `${process.env.TOKEN}`, 323 | }, 324 | json: true, 325 | }) 326 | if (r.data?.message != 'ok') throw new Error('Network Error') 327 | 328 | const roles = [] 329 | if (r.data.roles[0]) { 330 | roles.push(`select::${r.data.roles[0].name}`) 331 | if (r.data.roles[0].group_json) { 332 | roles.push(...r.data.roles[0].group_json) 333 | } 334 | } 335 | roles.push(`email::${r.data.user.email}`) 336 | 337 | r.data.roles = roles 338 | 339 | global.__USER_ROLES[req.session.id] = r.data 340 | return 341 | } 342 | } catch (error) { 343 | console.log('Failed to fetch UserRole', error) 344 | } 345 | } 346 | 347 | if (!global.__USER_ROLES) global.__USER_ROLES = {} 348 | if (global.__USER_ROLES[req.session.id]) { 349 | setTimeout(refresh_role, 1000) 350 | } else { 351 | await refresh_role() 352 | } 353 | 354 | json.users = [ 355 | { 356 | ...global.__USER_ROLES[req.session.id].user, 357 | user: global.__USER_ROLES[req.session.id].user, 358 | roles: global.__USER_ROLES[req.session.id].roles, 359 | } 360 | ] 361 | 362 | json.pages = json.pages.map(page => { 363 | if (page && page.path) { 364 | const menu = flatten_menus.find(e => e.path == page.path) 365 | if (menu && menu.roles) { 366 | const r = _.flatten([menu.roles.view]) 367 | if (_.intersection(r, global.__USER_ROLES[req.session.id].roles).length === 0) { 368 | page.blocks = [] 369 | } 370 | } 371 | } 372 | return page 373 | }) 374 | } 375 | 376 | const clean_yaml_id = (e) => { 377 | e._id = undefined 378 | e._idx = undefined 379 | return e 380 | } 381 | json.menus = (json.menus || []).map(clean_yaml_id) 382 | json.pages = (json.pages || []).map(clean_yaml_id) 383 | 384 | const team_env_config = req.team.env_config || {} 385 | if (team_env_config.modes) { 386 | team_env_config.modes = team_env_config.modes.filter(e => { 387 | return e.mode == (req.query.mode || "production") 388 | }) 389 | } 390 | 391 | // hide 392 | json.resources = [] 393 | 394 | 395 | res.status(200).json({ 396 | message: 'ok', 397 | team_id: req.team.id, 398 | team_domain: req.team.domain, 399 | team_flag_config: req.team.flag_config || {}, 400 | team_plan: req.team.plan || '', 401 | team_env_config, 402 | // team_apply_date: billing_method.apply_date, 403 | 'select-configuration': json, 404 | yml: String(cached_json || '').trim().length, // sample 체크에만 쓰는중 405 | cached_error, 406 | }) 407 | 408 | } catch (e) { 409 | error(e.stack) 410 | next(e) 411 | } 412 | }) 413 | 414 | module.exports = router -------------------------------------------------------------------------------- /cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 4 | /** 5 | * @license 6 | * Copyright(c) 2021-2023 Selectfromuser Inc. 7 | * All rights reserved. 8 | * https://www.selectfromuser.com 9 | * {team, support, jhlee}@selectfromuser.com, eces92@gmail.com 10 | * Commercial Licensed. Grant use for paid permitted user only. 11 | */ 12 | 13 | require('dotenv').config() 14 | 15 | global.__API_BASE = process.env.__SELECT_BUILD ? 'https://api.selectfromuser.com' : 'http://localhost:9500' 16 | const WEB_BASE = process.env.__SELECT_BUILD ? 'https://app.selectfromuser.com' : 'http://localhost:5173' 17 | const WEB_BASE_DOMAIN = process.env.__SELECT_BUILD ? 'https://{{DOMAIN}}-local.selectfromuser.com' : 'http://{{DOMAIN}}-local.localhost:5173' 18 | global.__WS_BASE = process.env.__SELECT_BUILD ? 'https://api.selectfromuser.com' : 'ws://localhost:5173' 19 | 20 | const { program } = require('commander'); 21 | const chalk = require('chalk') 22 | const ___package = require('./package.json') 23 | const inquirer = require('inquirer'); 24 | 25 | const axios = require('axios') 26 | const path = require('path') 27 | const fs = require('fs') 28 | const os = require('os') 29 | const {glob} = require('glob') 30 | 31 | setTimeout(async () => { 32 | try { 33 | const boxen = (await import('boxen')).default 34 | const chalk = require('chalk') 35 | const pj = (await import('package-json')).default 36 | const latest = await pj('selectfromuser') 37 | const semver = require('semver') 38 | if (semver.lt(___package.version, latest.version)) { 39 | console.log(boxen(`Update available ${___package.version} -> ${ chalk.bold(latest.version)}\nRun ${ chalk.cyan('npm i -g selectfromuser') } to update`, { 40 | padding: 1, 41 | margin: 1, 42 | borderColor: 'green', 43 | // textAlignment: 'center', 44 | title: 'NEW', 45 | titleAlignment: 'center', 46 | })) 47 | } 48 | } catch (error) { 49 | console.error(error) 50 | } 51 | }, 0) 52 | 53 | if (process.argv.length == 2) { 54 | process.argv.push('dev') 55 | } 56 | 57 | program 58 | .name('selectfromuser') 59 | .version(___package.version, '-v, --version, -version') 60 | // .option('-w, --watch, -watch', 'watch config yaml files') 61 | // .option('-f, --force, -force', 'force create config yaml') 62 | 63 | program.command('logout').action(() => { 64 | let config_path = path.join(process.env.CWD || process.cwd(), '.select') 65 | 66 | if (!fs.existsSync(config_path)) { 67 | fs.mkdirSync(config_path) 68 | } 69 | let config = {} 70 | if (fs.existsSync(path.join(config_path, 'project.json'))) { 71 | config = require(path.join(config_path, 'project.json')) 72 | } 73 | if (!config.token) { 74 | return console.log(chalk.blue('[INFO]'), 'Logged out. (no changes)') 75 | } 76 | config.token = undefined 77 | 78 | fs.writeFileSync(path.join(config_path, 'project.json'), 79 | JSON.stringify(config, null, ' ') 80 | ) 81 | console.log(chalk.blue('[INFO]'), 'Logged out.') 82 | }) 83 | program.command('whoami').action(async () => { 84 | console.log(chalk.bgGreen.white(' selectfromuser.com ')) 85 | try { 86 | let config_path = path.join(process.env.CWD || process.cwd(), '.select') 87 | 88 | if (!fs.existsSync(config_path)) { 89 | fs.mkdirSync(config_path) 90 | } 91 | let config = {} 92 | if (fs.existsSync(path.join(config_path, 'project.json'))) { 93 | config = require(path.join(config_path, 'project.json')) 94 | } 95 | if (!config.token) { 96 | return console.log(chalk.blue('[INFO]'), 'Not logged in.') 97 | } 98 | 99 | const r = await axios({ 100 | method: 'get', 101 | url: global.__API_BASE + '/cli/whoami', 102 | headers: { 103 | Authorization: `${config.token}`, 104 | }, 105 | json: true, 106 | }) 107 | 108 | console.log(JSON.stringify(r.data, null, ' ')) 109 | } catch (error) { 110 | console.error(chalk.red('[ERROR]'), error.message) 111 | } 112 | }) 113 | program.command('login').action(() => { 114 | console.log(chalk.bgGreen.white(' selectfromuser.com ')) 115 | console.log('login') 116 | login() 117 | }) 118 | program.command('link').action(() => { 119 | console.log(chalk.bgGreen.white(' selectfromuser.com ')) 120 | 121 | const config_path = path.join(process.env.CWD || process.cwd(), '.select', 'project.json') 122 | console.log('Using token and configuration from', config_path) 123 | console.log('') 124 | 125 | link() 126 | }) 127 | program.command('unlink').action(() => { 128 | let config_path = path.join(process.env.CWD || process.cwd(), '.select') 129 | 130 | if (!fs.existsSync(config_path)) { 131 | fs.mkdirSync(config_path) 132 | } 133 | let config = {} 134 | if (fs.existsSync(path.join(config_path, 'project.json'))) { 135 | config = require(path.join(config_path, 'project.json')) 136 | } 137 | if (!config.team_id) { 138 | return console.log(chalk.blue('[INFO]'), 'Project has been unlinked. (no changes)') 139 | } 140 | config.team_id = undefined 141 | 142 | fs.writeFileSync(path.join(config_path, 'project.json'), 143 | JSON.stringify(config, null, ' ') 144 | ) 145 | console.log(chalk.blue('[INFO]'), 'Project has been unlinked.') 146 | }) 147 | program.command('dev').action(() => { 148 | console.log(chalk.bgGreen.white(' selectfromuser.com ')) 149 | // console.log('dev') 150 | dev() 151 | }) 152 | program.command('connect').action(() => { 153 | console.log(chalk.bgGreen.white(' selectfromuser.com ')) 154 | connect() 155 | }) 156 | program.command('init').action(() => { 157 | // console.log(chalk.bgGreen.white(' selectfromuser.com ')) 158 | init() 159 | }) 160 | 161 | 162 | 163 | const parsed = program.parse() 164 | const commands = parsed.args 165 | const opts = program.opts() 166 | 167 | 168 | 169 | 170 | async function login() { 171 | // console.log('Trying to sign in...') 172 | 173 | const requestAuth = `${global.__API_BASE}/cli/request-auth` 174 | try { 175 | const r = await axios.get(requestAuth, { 176 | params: { 177 | hostname: os.hostname(), 178 | }, 179 | json: true, 180 | }) 181 | const auth_token = r.data?.auth_token 182 | 183 | // const cloud = `https://app.selectfromuser.com` 184 | const cloud = `${WEB_BASE}/login` 185 | const url = cloud + `?auth=${auth_token}&cli=1` 186 | console.log('Follow this link to complete sign in:\n ', chalk.underline(url)) 187 | 188 | const open = require('open') 189 | 190 | await open(url) 191 | 192 | console.log('') 193 | console.log(chalk.blue('[INFO]'), 'Waiting to finish sign in ...') 194 | 195 | let count = 0 196 | 197 | async function check () { 198 | 199 | if (++count > 300) { 200 | console.log('') 201 | return console.log(chalk.blue('[INFO]'), 'Session expired. Please retry.') 202 | } 203 | // console.log('>', {auth_token}) 204 | 205 | let token, team_id 206 | 207 | try { 208 | // console.log('>>>>33', path + '/finish') 209 | const r = await axios.get(requestAuth + '/finish?auth_token=' + auth_token, { 210 | json: true, 211 | }) 212 | // console.log('>>>>55') 213 | if (r.data?.message == '401') { 214 | process.stdout.write('.') 215 | await new Promise(resolve => setTimeout(resolve, 1000)) 216 | await check() 217 | return 218 | } 219 | 220 | if (r.data?.message != 'ok') throw new Error(r.data?.message) 221 | 222 | if (!r.data.token) { 223 | console.log('.') 224 | await new Promise(resolve => setTimeout(resolve, 1000)) 225 | await check() 226 | return 227 | } 228 | 229 | token = r.data.token 230 | // team_id = r.data.team_id 231 | 232 | } catch (error) { 233 | console.error(error.message) 234 | await new Promise(resolve => setTimeout(resolve, 1000)) 235 | await check() 236 | return 237 | } 238 | 239 | // done! 240 | 241 | const config_path = path.join(process.env.CWD || process.cwd(), '.select') 242 | 243 | if (!fs.existsSync(config_path)) { 244 | fs.mkdirSync(config_path) 245 | } 246 | fs.writeFileSync(path.join(config_path, 'project.json'), 247 | JSON.stringify({ 248 | // team_id, 249 | token, 250 | }, null, ' ') 251 | ) 252 | console.log('') 253 | console.log(chalk.blue('[INFO]'), 'Sign in completed.') 254 | 255 | const gitignore = path.join(process.env.CWD || process.cwd(), '.gitignore') 256 | if (fs.existsSync(gitignore)) { 257 | const data = fs.readFileSync(gitignore) 258 | if (data.toString().split('\n').map(e => String(e).trim()).filter(e => e == '.select').length == 0) { 259 | fs.appendFileSync(gitignore, '\n.select') 260 | } 261 | } 262 | } 263 | 264 | await check() 265 | } catch (error) { 266 | console.error('Cannot process your login request. Please retry.') 267 | console.error(chalk.red('[ERROR]'), error.message) 268 | return process.exit() 269 | } 270 | } 271 | 272 | async function link() { 273 | try { 274 | const config_path = path.join(process.env.CWD || process.cwd(), '.select', 'project.json') 275 | // console.log('Using token and configuration from', config_path) 276 | // console.log('') 277 | if (!fs.existsSync(config_path)) { 278 | console.log(chalk.blue('[INFO]'), 'Please sign in to your team.') 279 | console.log('') 280 | await login() 281 | // console.log('config not found >> try init?') 282 | // return 283 | } 284 | 285 | const config = require(config_path) 286 | // console.log("config", config) 287 | 288 | process.env.TOKEN = config.token || '' 289 | process.env.TEAM_ID = config.team_id || '' 290 | process.env.SELFHOST = true 291 | 292 | // console.log('process.env.TEAM_ID', process.env.TEAM_ID, !process.env.TEAM_ID) 293 | 294 | if (!process.env.TEAM_ID) { 295 | // create or link team 296 | console.log(chalk.blue('[INFO]'), 'Please link this project to your team or create a new team.') 297 | 298 | const answer = await inquirer.prompt([ 299 | { 300 | name: 'team_id', 301 | message: 'Project ID', 302 | default: 'Enter to create new', 303 | } 304 | ]) 305 | if (answer.team_id == 'Enter to create new') { 306 | // create new 307 | // answer.team_id = 'NEW' 308 | const { customAlphabet } = require('nanoid') 309 | const nanoidS = customAlphabet('0123456789abcdef') 310 | 311 | const r = await inquirer.prompt([ 312 | { 313 | name: 'name', 314 | message: 'Project name', 315 | default: `Project from ${os.hostname()}`, 316 | }, 317 | { 318 | name: 'domain', 319 | message: 'Project domain', 320 | default: nanoidS(12), 321 | }, 322 | ]) 323 | // answer.team_name = r.name 324 | 325 | const r2 = await axios({ 326 | method: 'post', 327 | url: global.__API_BASE + '/cli/project', 328 | data: { 329 | name: r.name, 330 | domain: r.domain, 331 | }, 332 | headers: { 333 | Authorization: `${config.token}`, 334 | }, 335 | json: true, 336 | }) 337 | if (r2?.data?.message != 'ok') { 338 | console.log(r2.data) 339 | throw new Error(`Failed to create project: ${r?.data?.message || '(unknown error)'}`) 340 | } 341 | answer.team_id = r2.data.team_id 342 | } else { 343 | 344 | } 345 | // console.log({answer}) 346 | 347 | const r = await axios({ 348 | method: 'post', 349 | url: global.__API_BASE + '/cli/link', 350 | data: { 351 | team_id: answer.team_id, 352 | }, 353 | headers: { 354 | Authorization: `${config.token}`, 355 | }, 356 | json: true, 357 | }) 358 | 359 | // console.log('>>>>55', r.data) 360 | if (r.data?.message != 'ok') { 361 | throw new Error(r.data.message) 362 | } 363 | 364 | config.token = r.data.token 365 | config.team_id = r.data.team_id 366 | fs.writeFileSync(config_path, JSON.stringify(config, null, ' ')) 367 | 368 | console.log(chalk.blue('[INFO]'), 'This project has been linked to cloud team.') 369 | 370 | // return 371 | } 372 | 373 | 374 | } catch (error) { 375 | console.error(chalk.red('[ERROR]'), error.message) 376 | console.debug(error.stack) 377 | return false 378 | } 379 | } 380 | 381 | async function dev() { 382 | try { 383 | const config_path = path.join(process.env.CWD || process.cwd(), '.select', 'project.json') 384 | // console.log('Using token and configuration from', config_path) 385 | // console.log('') 386 | if (!process.env.TOKEN && !fs.existsSync(config_path)) { 387 | console.log(chalk.blue('[INFO]'), 'Please sign in to your team.') 388 | console.log('') 389 | await login() 390 | // console.log('config not found >> try init?') 391 | // return 392 | } 393 | 394 | const require_or_env = (config_path) => { 395 | if (process.env.TOKEN && process.env.TEAM_ID) { 396 | return { 397 | token: process.env.TOKEN, 398 | team_id: process.env.TEAM_ID, 399 | } 400 | } else { 401 | return require(config_path) 402 | } 403 | } 404 | 405 | let config = require_or_env(config_path) 406 | if (!config.token) { 407 | console.log(chalk.blue('[INFO]'), 'Please sign in to your team.') 408 | console.log('') 409 | await login() 410 | } 411 | 412 | process.env.TOKEN = config.token || '' 413 | process.env.TEAM_ID = config.team_id || '' 414 | // console.log("config", config) 415 | 416 | process.env.SELFHOST = true 417 | 418 | // console.log('process.env.TEAM_ID', process.env.TEAM_ID, !process.env.TEAM_ID) 419 | config = require_or_env(config_path) 420 | if (!process.env.TEAM_ID) { 421 | const r = await link() 422 | if (r === false) { 423 | console.log('abort: run dev') 424 | return 425 | } 426 | } 427 | 428 | 429 | const r = await axios({ 430 | method: 'get', 431 | url: global.__API_BASE + '/cli/link', 432 | headers: { 433 | Authorization: `${config.token}`, 434 | }, 435 | json: true, 436 | }) 437 | if (r.data?.message != 'ok') { 438 | if (['jwt malformed', 'jwt expired'].includes(r.data?.message)) { 439 | console.log(chalk.blue('[INFO]'), 'Login expired. Please sign in to your team.') 440 | console.log('') 441 | await login() 442 | await dev() 443 | return 444 | } else { 445 | throw new Error(r.data.message) 446 | } 447 | } 448 | 449 | const files = await glob('**/*.{yml,yaml}', { 450 | ignore: 'node_modules/**', 451 | }) 452 | 453 | if (files && files.length === 0) { 454 | console.log(chalk.blue('[INFO]'), 'No YAML Files found. Adding sample files...') 455 | await init() 456 | } 457 | const cname = r.data.team.cname 458 | const domain = r.data.team.domain 459 | 460 | global.__TEAM = r.data.team 461 | 462 | require('./bin') 463 | 464 | setTimeout(() => { 465 | const url = WEB_BASE_DOMAIN.replace('{{DOMAIN}}', domain) 466 | console.log(chalk.blue('[INFO]'), '✨ Preview URL:') 467 | console.log(' ', chalk.underline(url )) 468 | }, 300) 469 | 470 | 471 | 472 | } catch (error) { 473 | console.error(chalk.red('[ERROR]'), error.message) 474 | console.debug(error.stack) 475 | } 476 | } 477 | 478 | 479 | async function connect() { 480 | try { 481 | const config_path = path.join(process.env.CWD || process.cwd(), '.select', 'project.json') 482 | 483 | if (!process.env.TOKEN && !fs.existsSync(config_path)) { 484 | console.log(chalk.blue('[INFO]'), 'Please sign in to your team.') 485 | console.log('') 486 | await login() 487 | } 488 | 489 | const require_or_env = (config_path) => { 490 | if (process.env.TOKEN && process.env.TEAM_ID) { 491 | return { 492 | token: process.env.TOKEN, 493 | team_id: process.env.TEAM_ID, 494 | } 495 | } else { 496 | return require(config_path) 497 | } 498 | } 499 | 500 | let config = require_or_env(config_path) 501 | if (!config.token) { 502 | console.log(chalk.blue('[INFO]'), 'Please sign in to your team.') 503 | console.log('') 504 | await login() 505 | } 506 | 507 | process.env.TOKEN = config.token || '' 508 | process.env.TEAM_ID = config.team_id || '' 509 | 510 | process.env.SELFHOST = true 511 | 512 | config = require_or_env(config_path) 513 | if (!process.env.TEAM_ID) { 514 | const r = await link() 515 | if (r === false) { 516 | console.log('abort: connect') 517 | return 518 | } 519 | } 520 | 521 | 522 | const r = await axios({ 523 | method: 'get', 524 | url: global.__API_BASE + '/cli/link', 525 | headers: { 526 | Authorization: `${config.token}`, 527 | }, 528 | json: true, 529 | }) 530 | if (r.data?.message != 'ok') { 531 | if (['jwt malformed', 'jwt expired'].includes(r.data?.message)) { 532 | console.log(chalk.blue('[INFO]'), 'Login expired. Please sign in to your team.') 533 | console.log('') 534 | await login() 535 | await dev() 536 | return 537 | } else { 538 | throw new Error(r.data.message) 539 | } 540 | } 541 | 542 | const cname = r.data.team.cname 543 | const domain = r.data.team.domain 544 | 545 | global.__TEAM = r.data.team 546 | global.__CONFIG_TOKEN = config.token 547 | 548 | const { createServer } = require('./connect') 549 | createServer() 550 | 551 | setTimeout(() => { 552 | // const url = WEB_BASE_DOMAIN.replace('{{DOMAIN}}', domain) 553 | console.log(chalk.blue('[INFO]'), `✨ Connected to the project:`) 554 | console.log(' ', chalk.bold(`Project ID: ${global.__TEAM.domain}` )) 555 | console.log(' ', chalk.bold(`Namespace: ${process.env.NAMESPACE || '(default)'}` )) 556 | }, 300) 557 | 558 | 559 | 560 | } catch (error) { 561 | console.error(chalk.red('[ERROR]'), error.message) 562 | console.debug(error.stack) 563 | } 564 | } 565 | 566 | 567 | 568 | 569 | async function init() { 570 | const files = await glob('**/*.{yml,yaml}', { 571 | ignore: 'node_modules/**', 572 | }) 573 | if (files && files.length) { 574 | console.log(chalk.blue('[INFO]'), 'Files are located already in current directory.') 575 | const answer = await inquirer.prompt([ 576 | { 577 | name: 'overwrite', 578 | message: 'Overwrite', 579 | default: 'Enter Y to overwrite anyway', 580 | } 581 | ]) 582 | if (answer.overwrite.toUpperCase() != 'Y') { 583 | return console.log('User cancel') 584 | } 585 | } 586 | 587 | const samples = require('./sample.js') 588 | { 589 | const p = path.join(process.env.CWD || process.cwd(), 'index.yml') 590 | fs.writeFileSync(p, samples['index.yml'].trim()) 591 | console.log(chalk.blue('[INFO]'), 'File added: index.yml') 592 | } 593 | { 594 | const p = path.join(process.env.CWD || process.cwd(), 'dashboard.yml') 595 | fs.writeFileSync(p, samples['dashboard.yml'].trim()) 596 | console.log(chalk.blue('[INFO]'), 'File added: dashboard.yml') 597 | } 598 | { 599 | const p = path.join(process.env.CWD || process.cwd(), 'reference.yml') 600 | fs.writeFileSync(p, samples['reference.yml'].trim()) 601 | console.log(chalk.blue('[INFO]'), 'File added: reference.yml') 602 | } 603 | 604 | { 605 | const p = path.join(process.env.CWD || process.cwd(), 'users') 606 | if (!fs.existsSync(p)) { 607 | fs.mkdirSync(p) 608 | } 609 | } 610 | { 611 | const p = path.join(process.env.CWD || process.cwd(), 'users', 'index.yml') 612 | fs.writeFileSync(p, samples['users/index.yml'].trim()) 613 | console.log(chalk.blue('[INFO]'), 'File added: users/index.yml') 614 | } 615 | { 616 | const p = path.join(process.env.CWD || process.cwd(), 'users', 'payment.yml') 617 | fs.writeFileSync(p, samples['users/payment.yml'].trim()) 618 | console.log(chalk.blue('[INFO]'), 'File added: users/payment.yml') 619 | } 620 | } 621 | 622 | // module.exports.bin = require('./bin') 623 | // module.exports.app = require('./app.js') -------------------------------------------------------------------------------- /sample.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright(c) 2021-2023 Selectfromuser Inc. 4 | * All rights reserved. 5 | * https://www.selectfromuser.com 6 | * {team, support, jhlee}@selectfromuser.com, eces92@gmail.com 7 | * Commercial Licensed. Grant use for paid permitted user only. 8 | */ 9 | 10 | module.exports['index.yml'] = ` 11 | title: 셀렉트어드민 12 | 13 | # layout: 14 | # style: 15 | # backgroundColor: "#19234B !important" 16 | 17 | menus: 18 | - group: 회원 19 | name: 고객 관리 20 | path: users 21 | placement: menu-only 22 | redirect: users/active 23 | icon: mdi-account 24 | 25 | menus: 26 | - name: 결제 관리 27 | path: payments 28 | placement: menu-only 29 | icon: mdi-timeline-check 30 | 31 | - group: 회원 32 | name: 최근가입자 목록 33 | path: users/active 34 | placement: tab-only 35 | 36 | - group: 회원 37 | name: 휴면회원 목록 38 | path: users/dormant 39 | placement: tab-only 40 | 41 | - group: 회원 42 | name: 마케팅 수신동의 43 | path: users/promotion 44 | placement: tab-only 45 | 46 | - group: 기타메뉴 47 | name: 공식 문서 48 | path: https://docs.selectfromuser.com 49 | target: _blank 50 | icon: mdi-book-open-variant 51 | iconEnd: 링크 52 | 53 | - group: 기타메뉴 54 | name: 클라우드 이용 55 | path: https://app.selectfromuser.com 56 | target: _blank 57 | icon: mdi-tab 58 | iconEnd: 링크 59 | 60 | # resources: 61 | # - name: mysql.dev 62 | # mode: local 63 | # type: mysql 64 | # host: aaaa.ap-northeast-2.rds.amazonaws.com 65 | # port: 3306 66 | # username: user_aaaa 67 | # password: aaaa 68 | # database: aaaa 69 | # timezone: '+00:00' 70 | # extra: 71 | # charset: utf8mb4_general_ci 72 | 73 | # pages: 74 | # - path: healthcheck/db 75 | # blocks: 76 | # - type: query 77 | # resource: mysql.dev 78 | # sql: SELECT NOW() 79 | ` 80 | 81 | module.exports['dashboard.yml'] = ` 82 | pages: 83 | - 84 | id: dashboard 85 | path: dashboard 86 | layout: dashboard 87 | style: 88 | background-color: "#f4f5f8" 89 | 90 | title: 사용자 현황 91 | # subtitle: 대시보드 92 | 93 | blocks: 94 | - type: left 95 | layout: dashboard 96 | style: 97 | width: 400px 98 | blocks: 99 | - type: http 100 | name: 1 101 | axios: 102 | method: GET 103 | url: ${global.__API_BASE}/sample-api/dashboard/users 104 | rowsPath: rows 105 | display: metric 106 | width: 100% 107 | showDownload: csv 108 | 109 | - type: http 110 | axios: 111 | method: GET 112 | url: ${global.__API_BASE}/sample-api/dashboard/revenue 113 | rowsPath: rows 114 | display: metric 115 | width: 100% 116 | style: 117 | color: RoyalBlue 118 | showDownload: false 119 | 120 | 121 | 122 | - type: http 123 | axios: 124 | method: GET 125 | url: ${global.__API_BASE}/sample-api/dashboard/rank 126 | rowsPath: rows 127 | 128 | name: category 129 | display: metric 130 | width: 100% 131 | 132 | metricOptions: 133 | type: category 134 | names: 135 | - 활성 136 | - 비활성 137 | value: c 138 | total: 최근가입자 139 | showDownload: false 140 | 141 | - type: http 142 | axios: 143 | method: GET 144 | url: ${global.__API_BASE}/sample-api/dashboard/stores 145 | rowsPath: rows 146 | name: 신규 가입 업체 147 | width: 100% 148 | height: calc(50vh - 150px) 149 | style: 150 | overflow: auto 151 | 152 | display: card 153 | showDownload: false 154 | 155 | - type: center 156 | layout: dashboard 157 | style: 158 | width: 50% 159 | border: 0 160 | blocks: 161 | - type: http 162 | axios: 163 | method: GET 164 | url: ${global.__API_BASE}/sample-api/dashboard/orders 165 | rowsPath: rows 166 | name: 최근 방문자 167 | width: 100% 168 | height: calc(100vh - 200px) 169 | chartOptions: 170 | backgroundColor: 171 | - "#0D6EFD" 172 | borderWidth: 173 | - 0 174 | style: 175 | # minWidth: 500px 176 | type: bar 177 | x: x 178 | y: y 179 | label: 일간 로그인 180 | options: 181 | layout: 182 | padding: 10 183 | interval: day 184 | gap: true 185 | showDownload: csv 186 | 187 | ` 188 | 189 | module.exports['users/index.yml'] = ` 190 | pages: 191 | - path: users/active 192 | blocks: 193 | - type: markdown 194 | content: > 195 | ## 7일 가입자 조회 196 | 197 | - path: users/dormant 198 | blocks: 199 | - type: markdown 200 | content: > 201 | ## 휴면회원 조회 202 | 203 | - path: users/promotion 204 | blocks: 205 | - type: markdown 206 | content: > 207 | ## 동의/미동의 조회 208 | ` 209 | 210 | module.exports['users/payment.yml'] = ` 211 | pages: 212 | - path: payments 213 | title: 결제 및 환불 214 | blocks: 215 | - type: markdown 216 | content: | 217 | > 최근 7일 대상자 목록 218 | 219 | # - type: query 220 | # name: Data from Query 221 | # resource: mysql.dev 222 | # sql: | 223 | # SELECT * 224 | # FROM chat 225 | # ORDER BY id DESC 226 | # LIMIT 3 227 | # tableOptions: 228 | # cell: true 229 | 230 | - type: http 231 | axios: 232 | method: GET 233 | url: https://api.selectfromuser.com/sample-api/users 234 | rowsPath: rows 235 | columns: 236 | name: 237 | label: Name 238 | age: 239 | label: Engagement Point 240 | 241 | showDownload: csv 242 | 243 | viewModal: 244 | useColumn: id 245 | # mode: side 246 | blocks: 247 | - type: http 248 | axios: 249 | method: GET 250 | url: https://api.selectfromuser.com/sample-api/users/{{user_id}} 251 | rowsPath: rows 252 | 253 | params: 254 | - key: user_id 255 | valueFromRow: id 256 | 257 | display: col-2 258 | title: "ID: {{id}}" 259 | showSubmitButton: false 260 | 261 | 262 | tabOptions: 263 | autoload: 1 264 | tabs: 265 | - name: 최근거래내역 266 | blocks: 267 | - type: markdown 268 | content: 거래내역 내용 269 | - name: 프로모션참여 270 | blocks: 271 | - type: markdown 272 | content: 프로모션 내용 273 | ` 274 | 275 | module.exports['reference.yml'] = ` 276 | menus: 277 | - path: PlVvFU 278 | name: Reference 샘플 279 | redirect: PlVvFU/reference 280 | icon: mdi-lightbulb-on-outline 281 | 282 | - path: PlVvFU/reference 283 | name: Guide 284 | group: reference 285 | placement: tab-only 286 | 287 | - path: PlVvFU/columns 288 | name: Columns 289 | group: reference 290 | placement: tab-only 291 | 292 | - path: PlVvFU/params 293 | name: Params 294 | group: reference 295 | placement: tab-only 296 | 297 | - path: PlVvFU/display 298 | name: Display 299 | group: reference 300 | placement: tab-only 301 | 302 | - path: PlVvFU/actions 303 | name: Actions 304 | group: reference 305 | placement: tab-only 306 | 307 | - path: PlVvFU/type 308 | name: Type 309 | group: reference 310 | placement: tab-only 311 | 312 | - path: PlVvFU/layout 313 | name: Layout 314 | group: reference 315 | placement: tab-only 316 | 317 | - path: PlVvFU/dashboard 318 | name: Dashboard 319 | group: reference 320 | placement: tab-only 321 | 322 | - path: PlVvFU/transformation 323 | name: Transformation 324 | group: reference 325 | placement: tab-only 326 | 327 | - path: PlVvFU/api 328 | name: API 329 | group: reference 330 | placement: tab-only 331 | 332 | pages: 333 | - path: PlVvFU/reference 334 | # class: conatiner 335 | blocks: 336 | - type: markdown 337 | content: | 338 | ## 안내사항 339 | 다양한 기능과 컴포넌트를 간단한 샘플과 함께 살펴보실 수 있습니다. 340 | 341 | 비슷한 자료는 아래에서 더 확인하실 수 있습니다. 342 | 343 | |구분|링크| 344 | |------|---| 345 | |가이드 문서|https://docs.selectfromuser.com/| 346 | |쇼룸|https://showroom.selectfromuser.com/| 347 | |업데이트|https://blog.selectfromuser.com/tag/update/| 348 | 349 | 도움이 필요하시다면 이메일, 커뮤니티, 슬랙, 채팅 등으로 문의해주세요. 350 | 351 | |구분|링크| 352 | |------|---| 353 | |이메일|support@selectfromuser.com| 354 | |커뮤니티|https://ask.selectfromuser.com/| 355 | |슬랙|https://bit.ly/3CxsQSt| 356 | 357 | - path: PlVvFU/columns 358 | title: Columns sample 359 | blocks: 360 | - type: query 361 | resource: mysql.sample 362 | sqlType: select 363 | sql: > 364 | SELECT 365 | 1 as id, 366 | '상품A' as name, 367 | '상품 설명1' as description, 368 | 50000 as price, 369 | 'ACTIVE' as status, 370 | 'https://images.unsplash.com/photo-1464278533981-50106e6176b1?q=80&w=2874&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' as image_url, 371 | NOW() as created_at, 372 | 'tag1,tag2,tag3' as tags, 373 | '관리자' as user_type, 374 | '대기' as state, 375 | JSON_OBJECT('key1', 'value1', 'key2', 'value2') as json_data, 376 | '010-1234-5678' as phone, 377 | '기본 메모' as memo, 378 | 'https://google.com/' as url 379 | 380 | UNION 381 | 382 | SELECT 383 | 2 as id, 384 | '상품B' as name, 385 | '상품 설명2' as description, 386 | 100000 as price, 387 | 'ACTIVE' as status, 388 | 'https://images.unsplash.com/photo-1464278533981-50106e6176b1?q=80&w=2874&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D' as image_url, 389 | NOW() as created_at, 390 | 'tag1,tag2,tag3' as tags, 391 | '사용자' as user_type, 392 | '완료' as state, 393 | JSON_OBJECT('key1', 'value1', 'key2', 'value2') as json_data, 394 | '010-1234-5678' as phone, 395 | '' as memo, 396 | '' as url 397 | 398 | searchOptions: 399 | enabled: true 400 | 401 | paginationOptions: 402 | enabled: true 403 | perPage: 10 404 | 405 | showRefresh: true 406 | showDownload: csv formatted xlsx 407 | 408 | columns: 409 | id: 410 | label: 번호 411 | copy: true 412 | sticky: true 413 | prepend: true 414 | hidden: false 415 | filterOptions: 416 | enabled: true 417 | placeholder: '0000' 418 | 419 | name: 420 | label: 상품명 421 | color: 422 | 상품A: blue 423 | buttons: 424 | - label: 상세보기 425 | openModal: product-detail-:id 426 | - label: 링크 열기 427 | openUrl: https://unsplash.com/ko/s/%EC%82%AC%EC%A7%84/{{name}}?license=free 428 | subtitle: description 429 | searchOptions: 430 | enabled: true 431 | 432 | description: 433 | hidden: true 434 | 435 | price: 436 | label: 가격 437 | formatFn: 438 | - number0 439 | - "" 440 | - " 원" 441 | color: 442 | 50000: green 443 | 444 | status: 445 | label: 상태 446 | format: toggle 447 | trueValue: ACTIVE 448 | falseValue: INACTIVE 449 | updateOptions: 450 | type: query 451 | resource: mysql.sample 452 | sqlType: update 453 | sql: SELECT 1 454 | confirm: true 455 | 456 | image_url: 457 | label: 이미지 458 | format: image 459 | thumbnail: true 460 | thumbnailWidth: 100px 461 | 462 | created_at: 463 | label: 등록일 464 | formatFn: datetimeA 465 | sortable: false 466 | 467 | tags: 468 | label: 태그 469 | formatFn: splitComma 470 | 471 | user_type: 472 | label: 사용자 유형 473 | valueAs: 474 | 관리자: 최상위 관리자 475 | 사용자: 일반 사용자 476 | 477 | state: 478 | label: 상태 479 | color: 480 | 대기: yellow 481 | 완료: green 482 | 취소: red 483 | 484 | json_data: 485 | label: JSON 데이터 486 | format: table 487 | # format: json 488 | 489 | phone: 490 | label: 연락처 491 | formatFn: maskCenter4 492 | copy: true 493 | 494 | memo: 495 | label: 메모 496 | formatFn: | 497 | return value ? value : '없음' 498 | 499 | url: 500 | label: 웹사이트 501 | format: url 502 | 503 | viewModal: 504 | useColumn: id 505 | header: false 506 | width: 400px 507 | height: 300px 508 | blocks: 509 | - type: query 510 | resource: mysql.sample 511 | sqlType: select 512 | sql: SELECT 1000 as test 513 | showDownload: false 514 | display: col-1 515 | 516 | modals: 517 | - path: product-detail-:id 518 | header: false 519 | blocks: 520 | - type: query 521 | resource: mysql.sample 522 | sqlType: select 523 | sql: SELECT 1 as field 524 | display: card 525 | params: 526 | - key: id 527 | defaultValueFromRow: id 528 | hidden: false 529 | 530 | - path: PlVvFU/params 531 | title: Params sample 532 | class: container 533 | # style: 534 | # maxWidth: 800px 535 | blocks: 536 | 537 | - type: query 538 | resource: mysql.sample 539 | sqlType: insert 540 | sql: SELECT 1 541 | 542 | display: form 543 | formOptions: 544 | firstLabelWidth: 100px 545 | labelWidth: 100px 546 | width: 400px 547 | 548 | params: 549 | - key: name 550 | label: 이름 551 | required: true 552 | minlength: 2 553 | maxlength: 10 554 | help: 2~10자 555 | 556 | - key: email 557 | label: 이메일 558 | # format: text 559 | placeholder: example@email.com 560 | validateFn: | 561 | const email = params.find(e => e.key == 'email') 562 | 563 | if (!email.value || !/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email.value)) { 564 | return '올바른 이메일 형식이 아닙니다' 565 | } else { 566 | return '' 567 | } 568 | 569 | - key: phone 570 | label: 연락처 571 | format: text 572 | placeholder: 010-0000-0000 573 | 574 | - type: query 575 | resource: mysql.sample 576 | sqlType: insert 577 | sql: SELECT 1 578 | 579 | display: form inline 580 | formOptions: 581 | firstLabelWidth: 100px 582 | labelWidth: 100px 583 | width: 400px 584 | 585 | params: 586 | - key: select 587 | label: 선택 588 | dropdown: 589 | - pinned: 고정 590 | - event: 이벤트 591 | - ad: 광고 592 | dropdownSize: 3 593 | dropdownMultiple: true 594 | 595 | - key: select2 596 | label: 선택2 597 | selectOptions: 598 | enabled: true 599 | multiple: true 600 | taggable: true 601 | pushTags: true 602 | dropdown: 603 | - 호텔 604 | - 리조트 605 | - 캠핑 606 | - 독채 607 | - 수영장 608 | 609 | - key: code 610 | label: 코드 611 | # datalist: 612 | # - value: A000 613 | # label: 분류1 614 | # - value: A002 615 | # label: 분류2 616 | datalist: true 617 | datalistFromQuery: 618 | type: query 619 | resource: mysql.sample 620 | sqlType: select 621 | sql: SELECT id AS value, name AS label FROM users 622 | datalistPreview: true 623 | 624 | - type: query 625 | resource: mysql.sample 626 | sqlType: insert 627 | sql: SELECT 1 628 | formOptions: 629 | display: col 630 | 631 | params: 632 | - key: period 633 | label: 기간 634 | format: date 635 | range: true 636 | shortcuts: 637 | - label: 최근 1년 638 | from: 639 | offset: -1 640 | period: year 641 | to: 642 | offset: 0 643 | period: year 644 | - label: 최근 6개월 645 | from: 646 | offset: -6 647 | period: month 648 | to: 649 | offset: 0 650 | period: month 651 | - label: 최근 3개월 652 | from: 653 | offset: -3 654 | period: month 655 | to: 656 | offset: 0 657 | period: month 658 | - label: 이번달 659 | from: 660 | startOf: month 661 | to: 662 | endOf: month 663 | - label: 지난달 664 | from: 665 | offset: -1 666 | startOf: month 667 | period: month 668 | to: 669 | offset: -1 670 | endOf: month 671 | period: month 672 | disabledDate: | 673 | return date >= new Date() 674 | 675 | - key: working_time 676 | label: 근무 시간 677 | format: time 678 | timeOptions: 679 | start: 09:00 680 | end: 18:00 681 | step: 00:30 682 | format: HH:mm 683 | 684 | - key: color_preference 685 | label: 색상 선호도 686 | format: color 687 | 688 | - key: address 689 | label: 주소 690 | format: address 691 | updateParams: 692 | road_address: roadAddress 693 | postcode: zonecode 694 | longitude: x 695 | latitude: y 696 | 697 | - key: description 698 | label: 자기소개 699 | format: editor 700 | width: 500px 701 | 702 | - key: status 703 | label: 상태 704 | radio: 705 | - draft: 초안 706 | - published: 발행 707 | defaultValue: draft 708 | # radioButtonGroup: true 709 | 710 | - key: amount 711 | label: 금액 712 | format: number 713 | 714 | - key: vintage 715 | label: 연도 선택 716 | format: range 717 | min: 2000 718 | max: 2024 719 | step: 1 720 | 721 | - key: file_upload 722 | label: 파일 업로드 723 | format: s3 724 | multiple: true 725 | max: 3 726 | accept: image/* 727 | 728 | - key: sheet_data 729 | label: 엑셀 업로드 730 | format: sheet 731 | sheetOptions: 732 | convertDate: 733 | - 시작일 734 | - 종료일 735 | accept: .xlsx 736 | 737 | - key: tiptap_content 738 | label: 고급 에디터 739 | format: tiptap 740 | width: 800px 741 | 742 | - key: display_json 743 | label: 리스트박스 744 | format: listbox 745 | multiple: true 746 | listStyle: 747 | minWidth: 300px 748 | height: 300px 749 | overflow: scroll 750 | datalistFromQuery: 751 | type: query 752 | resource: mysql.sample 753 | sql: SELECT DISTINCT id AS value, store_name AS label, receipt_no FROM receiptStoreList 754 | template: | 755 | {{value}} 756 | {{receipt_no}} 757 | 758 | submitButton: 759 | label: 제출 760 | type: primary 761 | 762 | resetButton: 763 | label: 초기화 764 | type: light 765 | 766 | reloadAfterSubmit: true 767 | 768 | - path: PlVvFU/display 769 | class: container 770 | blocks: 771 | - type: query 772 | title: 1. Form Display 773 | resource: mysql.sample 774 | sqlType: select 775 | sql: > 776 | SELECT 1 as id, 'John Doe' as name, 777 | 'john@example.com' as email, 778 | '2024-01-01' as created_at 779 | display: form 780 | columns: 781 | id: 782 | label: ID 783 | name: 784 | label: 이름 785 | email: 786 | label: 이메일 787 | created_at: 788 | label: 가입일 789 | format: date 790 | 791 | - type: query 792 | title: 2. Form Inline Display 793 | resource: mysql.sample 794 | sqlType: select 795 | sql: > 796 | SELECT 1 as id, 'John Doe' as name, 797 | 'Active' as status 798 | display: form inline 799 | columns: 800 | id: 801 | label: ID 802 | name: 803 | label: 이름 804 | status: 805 | label: 상태 806 | 807 | - type: query 808 | title: 3. Column Layout Displays 809 | resource: mysql.sample 810 | sqlType: select 811 | sql: > 812 | SELECT 813 | 'Basic Info' as category, 814 | 'John Doe' as name, 815 | '30' as age, 816 | 'New York' as location 817 | display: col-2 818 | thStyle: 819 | width: 150px 820 | 821 | - type: query 822 | title: 4. Post Display 823 | resource: mysql.sample 824 | sqlType: select 825 | sql: > 826 | SELECT 827 | 'Important Update' as title, 828 | 'This is a sample post content.' as content 829 | display: post 830 | columns: 831 | title: 832 | tdClass: text-lg font-bold 833 | content: 834 | tdClass: p-4 835 | 836 | - type: query 837 | title: 5. Card Display 838 | resource: mysql.sample 839 | sqlType: select 840 | sql: > 841 | SELECT 1 as id, 842 | 'Product A' as name, 843 | 'Description of Product A' as description 844 | display: card 845 | 846 | - type: query 847 | title: 6. Metric Display 848 | resource: mysql.sample 849 | sqlType: select 850 | sql: > 851 | SELECT 852 | 'Sales' as category, 853 | 1000 as value, 854 | 'Monthly Sales Report' as label 855 | display: metric 856 | metricOptions: 857 | type: category 858 | names: 859 | - Monthly Sales 860 | value: value 861 | total: Total Sales 862 | 863 | - type: query 864 | title: 7. Calendar Display 865 | resource: mysql.sample 866 | sqlType: select 867 | sql: > 868 | SELECT 869 | DATE_FORMAT(NOW(), '%Y-%m-%d') as date, 870 | 'Meeting' as event, 871 | 10 as count 872 | params: 873 | - key: calendar 874 | range: true 875 | valueFromCalendar: true 876 | display: calendar 877 | cache: true 878 | columns: 879 | count: 880 | label: 일정수 881 | color: blue-600 882 | openModal: date-modal 883 | modals: 884 | - path: date-modal 885 | blocks: 886 | - type: markdown 887 | content: | 888 | 날짜값 클릭하여 모달 띄우기 889 | 890 | - type: query 891 | title: 8. Timeline Display 892 | resource: mysql.sample 893 | sqlType: select 894 | sql: > 895 | SELECT 896 | NOW() as created_at, 897 | 'System Update' as event, 898 | 'Admin' as user_name 899 | display: timeline 900 | timelineOptions: 901 | useColumn: created_at 902 | template: | 903 | {{user_name}}님이 {{event}} 작업을 진행했습니다. 904 | 905 | - type: query 906 | title: 9. HTML Table Display 907 | resource: mysql.sample 908 | sqlType: select 909 | sql: > 910 | SELECT 911 | 1 as id, 912 | 'Product A' as name, 913 | 100 as stock, 914 | 50000 as price 915 | display: html table 916 | thead: 917 | rows: 918 | - class: bg-neutral-100 text-neutral-800 font-medium divide-x 919 | cells: 920 | - th: { content: "ID" } 921 | - th: { content: "상품명" } 922 | - th: { content: "재고" } 923 | - th: { content: "가격" } 924 | tbody: 925 | rows: 926 | - class: text-center divide-x hover:bg-neutral-100 927 | cells: 928 | - td: { content: "{{id}}" } 929 | - td: { content: "{{name}}" } 930 | - td: { content: "{{stock}}" } 931 | - td: { content: "{{price}}" } 932 | tfoot: 933 | rows: 934 | - class: font-medium divide-x text-center 935 | cells: 936 | - th: { colspan: 2, content: "합계" } 937 | - td: { content: "{{total_stock}}" } 938 | - td: { content: "{{total_price}} 원" } 939 | totalFn: | 940 | total.total_stock = _.sumBy(rows, 'stock') 941 | total.total_price = _.sumBy(rows, 'price') 942 | 943 | - type: query 944 | title: 10. Map Display (네이버 지도) 945 | resource: mysql.sample 946 | sqlType: select 947 | sql: > 948 | SELECT 949 | 37.5665 as latitude, 950 | 126.9780 as longitude, 951 | 'Seoul Office' as name, 952 | 'Main Office Location' as description 953 | display: map 954 | displayFn: | 955 | for (const row of rows) { 956 | const marker = new naver.maps.Marker({ 957 | position: new naver.maps.LatLng(row.latitude, row.longitude), 958 | title: row.name, 959 | map: map, 960 | }); 961 | 962 | const infowindow = new naver.maps.InfoWindow({ 963 | content: \` 964 |
965 |

\${row.name}

966 |

\${row.description}

967 |
968 | \` 969 | }); 970 | 971 | naver.maps.Event.addListener(marker, 'click', function() { 972 | if (infowindow.getMap()) { 973 | infowindow.close(); 974 | } else { 975 | infowindow.open(map, marker); 976 | } 977 | }); 978 | } 979 | 980 | map.setCenter(new naver.maps.LatLng(rows[0].latitude, rows[0].longitude)); 981 | height: 400px 982 | width: 100% 983 | mapOptions: 984 | zoom: 15 985 | zoomControl: true 986 | 987 | - path: PlVvFU/actions 988 | class: container 989 | title: Actions sample 990 | blocks: 991 | - type: query 992 | resource: mysql.sample 993 | sqlType: select 994 | sql: > 995 | SELECT 1 AS id, 'Sample Data' AS name 996 | UNION 997 | SELECT 2 AS id, 'Hello world' AS name 998 | showDownload: false 999 | columns: 1000 | name: 1001 | buttons: 1002 | - label: check 1003 | visible: "{{ row.name == 'Hello world' }}" 1004 | openAction: anything 1005 | selectOptions: 1006 | enabled: true 1007 | selectOnCheckboxOnly: true 1008 | actions: 1009 | - label: 단일 쿼리 실행 1010 | type: query 1011 | resource: mysql.sample 1012 | sqlType: update 1013 | sql: UPDATE test SET status = 'active' WHERE id = :id 1014 | single: true 1015 | placement: right top 1016 | button: 1017 | type: primary 1018 | icon: check-circle 1019 | 1020 | - label: API 호출 1021 | name: anything 1022 | type: http 1023 | axios: 1024 | method: POST 1025 | url: https://httpbin.selectfromuser.com/anything 1026 | data: 1027 | id: "{{id}}" 1028 | params: 1029 | - key: id 1030 | valueFromSelectedRows: id 1031 | placement: right bottom 1032 | button: 1033 | type: success-light 1034 | 1035 | - label: 페이지 이동 1036 | openUrl: https://www.selectfromuser.com 1037 | single: true 1038 | target: _blank 1039 | placement: left top 1040 | button: 1041 | type: default 1042 | icon: link-variant 1043 | 1044 | - label: 모달 열기 1045 | single: true 1046 | openModal: sample-modal 1047 | placement: left bottom 1048 | button: 1049 | type: warning-light 1050 | icon: information 1051 | 1052 | - label: 빠른 정보 보기 1053 | openPopper: true 1054 | forEach: true 1055 | popperOptions: 1056 | placement: right 1057 | popperStyle: 1058 | width: 300px 1059 | padding: 15px 1060 | blocks: 1061 | - type: markdown 1062 | content: | 1063 | ### 상세 정보 1064 | - ID: {{id}} 1065 | - 이름: {{name}} 1066 | 1067 | - label: CSV 다운로드 1068 | showDownload: csv 1069 | single: true 1070 | placement: top right 1071 | button: 1072 | type: primary-light 1073 | 1074 | - label: 삭제 1075 | type: query 1076 | resource: mysql.sample 1077 | sqlType: delete 1078 | sql: DELETE FROM test WHERE id = :id 1079 | confirmText: 정말로 삭제하시겠습니까? 1080 | placement: top left 1081 | button: 1082 | type: danger 1083 | icon: delete 1084 | 1085 | - label: 프롬프트 입력 1086 | type: http 1087 | axios: 1088 | method: POST 1089 | url: https://httpbin.selectfromuser.com/anything 1090 | data: 1091 | id: "{{id}}" 1092 | params: 1093 | - key: id 1094 | valueFromPrompt: true 1095 | promptText: 정보를 입력해주세요. 1096 | 1097 | modals: 1098 | - path: sample-modal 1099 | # blocks: 1100 | # - type: markdown 1101 | # content: | 1102 | # ### 샘플 모달 1103 | # 선택된 데이터의 추가 정보를 표시합니다. 1104 | usePage: usepage-modal 1105 | 1106 | - path: usepage-modal 1107 | blocks: 1108 | - type: markdown 1109 | content: | 1110 | ### 샘플 모달 1111 | 선택된 데이터의 추가 정보를 표시합니다. (usePage) 1112 | 1113 | - path: PlVvFU/type 1114 | blocks: 1115 | - type: header 1116 | items: 1117 | - path: PlVvFU/layout 1118 | label: 레이아웃 1119 | icon: home 1120 | - label: 옵션 1121 | 1122 | - type: tab 1123 | tabOptions: 1124 | autoload: 1 1125 | type: plain 1126 | tabs: 1127 | - name: 사용자 1128 | blocks: 1129 | - type: markdown 1130 | content: 내용 입력 가능1 1131 | - name: 리소스 1132 | blocks: 1133 | - type: markdown 1134 | content: 내용 입력 가능2 1135 | - name: 최근 수정내역 1136 | blocks: 1137 | - type: markdown 1138 | content: 내용 입력 가능3 1139 | - name: 기타 신규 1140 | blocks: 1141 | - type: markdown 1142 | content: 내용 입력 가능 1143 | - type: tab 1144 | tabOptions: 1145 | autoload: 1 1146 | type: button 1147 | tabs: 1148 | - name: 잔고1 1149 | blocks: 1150 | - type: markdown 1151 | content: 내용 입력 가능 a 1152 | - name: 잔고2 1153 | blocks: 1154 | - type: markdown 1155 | content: 내용 입력 가능 b 1156 | - name: 잔고3 1157 | blocks: 1158 | - type: markdown 1159 | content: | 1160 | 내용 입력 가능 c 1161 | 1162 | 👨‍👩‍👧‍👦 1163 | 1164 | - type: toggle 1165 | name: toggle sample 1166 | icon: tree 1167 | class: text-lg p-2 shadow rounded text-green-700 1168 | toggledClass: font-medium text-green-700 bg-green-600/10 1169 | # toggled: true 1170 | blocks: 1171 | - type: markdown 1172 | content: | 1173 | 토글(toggle) 타입 블록을 통해 내용을 접었다 펼칠 수 있습니다. 1174 | 1175 | - type: iframe 1176 | src: https://www.selectfromuser.com/ 1177 | style: 1178 | width: 50% 1179 | minWidth: 550px 1180 | height: 80vh 1181 | 1182 | - path: PlVvFU/layout 1183 | layout: 1184 | style: 1185 | # max-width: 1200px 1186 | margin: 0px auto 1187 | class: flex flex-wrap # gap-3 1188 | div: 1189 | - name: page1 1190 | style: 1191 | width: 100% 1192 | class: bg-amber-100 1193 | 1194 | - name: page2-1 1195 | style: 1196 | overflow: auto 1197 | height: 300px 1198 | class: drop-shadow-lg border border-slate-700 bg-white grow 1199 | - name: page2-2 1200 | style: 1201 | width: 300px 1202 | class: bg-sky-100 1203 | 1204 | - name: page3 1205 | style: 1206 | width: 100% 1207 | background-color: purple !important 1208 | outline: 1px solid red 1209 | color: #fff 1210 | class: bg-indigo-100 1211 | 1212 | blocks: 1213 | - type: http 1214 | layout: page1 1215 | axios: 1216 | method: GET 1217 | url: https://gist.githubusercontent.com/eces/c267436ddeec8917b47ee666b0d5e955/raw/892877e7035c4f61e946848a3f6da7e9983cab15/test.json 1218 | rowsPath: rows 1219 | 1220 | - type: markdown 1221 | layout: page2-1 1222 | content: | 1223 | # Row 1224 | - row1 1225 | # Row 1226 | - row2 1227 | # Row 1228 | - row3 1229 | # Row 1230 | - row4 1231 | # Row 1232 | - row5 1233 | 1234 | - type: http 1235 | layout: page2-2 1236 | axios: 1237 | method: GET 1238 | url: https://gist.githubusercontent.com/eces/c267436ddeec8917b47ee666b0d5e955/raw/892877e7035c4f61e946848a3f6da7e9983cab15/test.json 1239 | rowsPath: rows 1240 | tableOptions: 1241 | cell: true 1242 | 1243 | - type: http 1244 | layout: page3 1245 | axios: 1246 | method: GET 1247 | url: https://gist.githubusercontent.com/eces/c267436ddeec8917b47ee666b0d5e955/raw/892877e7035c4f61e946848a3f6da7e9983cab15/test.json 1248 | rowsPath: rows 1249 | 1250 | - type: toggle 1251 | name: Simple layout 1252 | blocks: 1253 | - 1254 | type: top 1255 | title: title 1256 | blocks: 1257 | - type: markdown 1258 | content: > 1259 | > TOP 1260 | - 1261 | type: left 1262 | title: title 1263 | subtitle: subtitle 1264 | blocks: 1265 | - type: markdown 1266 | content: > 1267 | > LEFT 1268 | - 1269 | type: center 1270 | style: 1271 | width: 50% 1272 | height: 80vh 1273 | maxHeight: calc(100vh - 300px) 1274 | overflow: scroll 1275 | blocks: 1276 | - type: markdown 1277 | content: > 1278 | > CENTER 1279 | - type: query 1280 | title: 내역은 최근 30일 1281 | subtitle: 내역은 최근 30일 1282 | description: 영수증 목록 1283 | resource: mysql.sample 1284 | sql: SELECT * FROM receiptStoreList LIMIT 300 1285 | sqlType: select 1286 | - 1287 | type: right 1288 | blocks: 1289 | - type: markdown 1290 | content: > 1291 | > RIGHT 1292 | - 1293 | type: bottom 1294 | blocks: 1295 | - type: markdown 1296 | content: > 1297 | > BOTTOM 1298 | 1299 | - path: PlVvFU/dashboard 1300 | # id: dashboard 1301 | layout: dashboard 1302 | style: 1303 | background-color: "#f4f5f8" 1304 | margin-top: 100px 1305 | 1306 | title: 사용자 현황 1307 | # subtitle: 대시보드 1308 | 1309 | blocks: 1310 | - type: left 1311 | layout: dashboard 1312 | style: 1313 | width: 400px 1314 | blocks: 1315 | - type: http 1316 | name: 1 1317 | axios: 1318 | method: GET 1319 | url: https://api.selectfromuser.com/sample-api/dashboard/users 1320 | rowsPath: rows 1321 | display: metric 1322 | width: 100% 1323 | showDownload: csv 1324 | 1325 | - type: http 1326 | axios: 1327 | method: GET 1328 | url: https://api.selectfromuser.com/sample-api/dashboard/revenue 1329 | rowsPath: rows 1330 | display: metric 1331 | width: 100% 1332 | style: 1333 | color: RoyalBlue 1334 | showDownload: false 1335 | 1336 | - type: http 1337 | axios: 1338 | method: GET 1339 | url: https://api.selectfromuser.com/sample-api/dashboard/rank 1340 | rowsPath: rows 1341 | 1342 | name: category 1343 | display: metric 1344 | width: 100% 1345 | 1346 | metricOptions: 1347 | type: category 1348 | names: 1349 | - 활성 1350 | - 비활성 1351 | value: c 1352 | total: 최근가입자 1353 | showDownload: false 1354 | 1355 | - type: http 1356 | axios: 1357 | method: GET 1358 | url: https://api.selectfromuser.com/sample-api/dashboard/stores 1359 | rowsPath: rows 1360 | name: 신규 가입 업체 1361 | width: 100% 1362 | height: calc(50vh - 150px) 1363 | style: 1364 | overflow: auto 1365 | 1366 | display: card 1367 | showDownload: false 1368 | 1369 | - type: center 1370 | layout: dashboard 1371 | style: 1372 | width: 50% 1373 | border: 0 1374 | blocks: 1375 | - type: http 1376 | axios: 1377 | method: GET 1378 | url: https://api.selectfromuser.com/sample-api/dashboard/orders 1379 | rowsPath: rows 1380 | title: 최근 방문자2 1381 | name: 최근 방문자 1382 | width: 100% 1383 | height: calc(100vh - 200px) 1384 | chartOptions: 1385 | backgroundColor: 1386 | - "#0D6EFD" 1387 | borderWidth: 1388 | - 0 1389 | style: 1390 | # minWidth: 500px 1391 | type: bar 1392 | x: x 1393 | y: y 1394 | label: 일간 로그인 1395 | options: 1396 | layout: 1397 | padding: 10 1398 | interval: day 1399 | gap: true 1400 | showDownload: csv 1401 | 1402 | - path: PlVvFU/transformation 1403 | blocks: 1404 | - type: toggle 1405 | toggled: true 1406 | name: requestFn + responseFn 1407 | blocks: 1408 | - type: query 1409 | resource: json+sql 1410 | sql: SELECT NOW() 1411 | actions: 1412 | - label: 테스트키 발급 1413 | single: true 1414 | requestFn: | 1415 | if (localStorage.TEST_KEY) { 1416 | throw new Error('이미 키가 있습니다.') 1417 | } 1418 | 1419 | // params, ...blocks 1420 | // query1.params.name.value 1421 | // await query2.trigger() 1422 | 1423 | type: query 1424 | resource: json+sql 1425 | sql: SELECT NOW() 1426 | 1427 | 1428 | toast: 발급 완료 1429 | 1430 | responseFn: | 1431 | localStorage.TEST_KEY = 1234 1432 | alert(\`신규 발급: \${ localStorage.TEST_KEY }\`) 1433 | 1434 | // data, rows, row, _, toast, ...blocks 1435 | // query1.params.name.value 1436 | // await query2.trigger() 1437 | 1438 | - label: 테스트키 삭제 1439 | single: true 1440 | 1441 | 1442 | type: query 1443 | resource: json+sql 1444 | sql: SELECT NOW() 1445 | 1446 | toast: 삭제 완료 1447 | 1448 | responseFn: | 1449 | delete localStorage.TEST_KEY 1450 | 1451 | - type: toggle 1452 | name: validateFn 1453 | blocks: 1454 | - type: query 1455 | resource: mysql.sample 1456 | sqlType: insert 1457 | sql: SELECT 1 1458 | params: 1459 | - key: name 1460 | label: 영수증 번호 1461 | help: 418931123 1462 | required: true 1463 | validateFromQuery: 1464 | type: query 1465 | sql: > 1466 | SELECT COUNT(id) AS count 1467 | FROM receiptStoreList 1468 | WHERE receipt_no = :value 1469 | validateFn: | 1470 | if (+validateFromQuery.count > 0) { 1471 | return '중복된 영수증 번호 입니다.' 1472 | } 1473 | return true 1474 | validateFn: | 1475 | if (param.value.length != 9) { 1476 | return '영수증 번호(9자리)를 입력해주세요.' 1477 | } 1478 | if (!isFinite(+param.value)) { 1479 | return '영수증 번호만 입력해주세요.' 1480 | } 1481 | return true 1482 | 1483 | - key: email 1484 | label: 이메일 1485 | validateFn: | 1486 | const email = String(param.value || ''); 1487 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 1488 | 1489 | if (emailRegex.test(email)) { 1490 | return ''; 1491 | } else { 1492 | return '유효한 이메일 주소를 입력하세요.'; 1493 | } 1494 | display: form 1495 | formOptions: 1496 | firstLabelWidth: 120px 1497 | labelWidth: 100px 1498 | 1499 | - type: toggle 1500 | name: requestSubmitFn + options 1501 | title: 여러건 추가 1502 | subtitle: 연관된 아이템을 일괄 추가합니다. 1503 | blocks: 1504 | - type: query 1505 | resource: mysql.sample 1506 | sqlType: insert 1507 | sql: > 1508 | INSERT INTO wine_stock 1509 | SET name = :name, 1510 | vintage = :vintage, 1511 | price = :price 1512 | params: 1513 | - key: name 1514 | - key: vintage 1515 | - key: price 1516 | id: query_add_wine 1517 | hidden: true 1518 | toast: 추가했습니다. 1519 | 1520 | - type: query 1521 | resource: mysql.sample 1522 | sqlType: insert 1523 | sql: > 1524 | SELECT 1 1525 | requestSubmitFn: | 1526 | for (const i in form.params.vintage.value) { 1527 | const vintage_value = form.params.vintage.value[i] 1528 | 1529 | query_add_wine.params.name.value = form.params.name.value 1530 | query_add_wine.params.vintage.value = vintage_value 1531 | query_add_wine.params.price.value = form.params.vintage.options.price.value[i] || null 1532 | 1533 | await query_add_wine.trigger() 1534 | } 1535 | 1536 | id: form 1537 | params: 1538 | - key: name 1539 | value: test 1540 | - key: vintage 1541 | value: ['2023', '2024'] 1542 | datalist: [] 1543 | selectOptions: 1544 | enabled: true 1545 | multiple: true 1546 | taggable: true 1547 | group: vintage 1548 | label: 수입연도 1549 | help: "*계약금액이 다른경우 하단에 입력" 1550 | 1551 | display: document 1552 | # display: table 1553 | # display: inline 1554 | 1555 | options: 1556 | price: 1557 | label: 가격 1558 | placeholder: 00,000 1559 | prefix: 정가 1560 | postfix: 원 1561 | class: text-right 1562 | value: 1563 | - 30000 1564 | - 30000 1565 | 1566 | - path: PlVvFU/api 1567 | blocks: 1568 | - type: toggle 1569 | name: HTTP GET 1570 | blocks: 1571 | - type: http 1572 | title: 조회 1573 | subtitle: HTTP GET 1574 | axios: 1575 | method: GET 1576 | url: https://api.selectfromuser.com/sample-api/v3/stores?q={{name}}&id={{id}} 1577 | rowsPath: rows 1578 | searchOptions: 1579 | enabled: true 1580 | paginationOptions: 1581 | enabled: true 1582 | perPage: 10 1583 | params: 1584 | - key: id 1585 | label: 아이디 1586 | - key: name 1587 | label: 이름 1588 | 1589 | - type: toggle 1590 | name: HTTP GET + Modal 1591 | blocks: 1592 | - type: http 1593 | title: 연결 조회 1594 | subtitle: HTTP GET + Modal 1595 | axios: 1596 | method: GET 1597 | url: https://api.selectfromuser.com/sample-api/v3/stores 1598 | rowsPath: rows 1599 | 1600 | columns: 1601 | Category: 1602 | color: 1603 | F&B: yellow 1604 | B2B: purple 1605 | Name: 1606 | width: 80% 1607 | openModal: view 1608 | modals: 1609 | - path: view 1610 | useColumn: id 1611 | header: false 1612 | # dismissible: false 1613 | blocks: 1614 | - type: http 1615 | axios: 1616 | method: GET 1617 | url: https://api.selectfromuser.com/sample-api/v3/stores?id={{id}} 1618 | rowsPath: rows 1619 | params: 1620 | - key: id 1621 | valueFromRow: true 1622 | display: post 1623 | 1624 | - type: toggle 1625 | name: HTTP POST 1626 | title: 추가 1627 | subtitle: HTTP POST 1628 | blocks: 1629 | - type: http 1630 | # title: 추가 1631 | # subtitle: HTTP POST 1632 | axios: 1633 | method: POST 1634 | url: https://api.selectfromuser.com/sample-api/products 1635 | data: 1636 | name: "{{name}}" 1637 | params: 1638 | - key: name 1639 | label: 숙소이름 1640 | # dropdown: 1641 | # - A 1642 | 1643 | submitButton: 1644 | label: 추가 1645 | type: primary 1646 | class: px-6 py-3 1647 | 1648 | toast: 추가했습니다. 1649 | 1650 | - type: toggle 1651 | name: HTTP PUT + updateOptions 1652 | title: 인라인 수정 1653 | subtitle: table updateOptions 1654 | blocks: 1655 | - type: http 1656 | axios: 1657 | method: GET 1658 | url: https://api.selectfromuser.com/sample-api/products 1659 | rowsPath: rows 1660 | 1661 | columns: 1662 | id: 1663 | name: 1664 | width: 80% 1665 | updateOptions: 1666 | type: http 1667 | axios: 1668 | method: PUT 1669 | url: https://api.selectfromuser.com/sample-api/products/{{id}} 1670 | data: 1671 | name: "{{value}}" 1672 | params: 1673 | - key: id 1674 | valueFromRow: id 1675 | toast: 수정 완료 1676 | 1677 | - type: toggle 1678 | name: HTTP PUT + selectOptions actions 1679 | blocks: 1680 | - type: http 1681 | axios: 1682 | method: GET 1683 | url: https://api.selectfromuser.com/sample-api/products 1684 | rowsPath: rows 1685 | 1686 | columns: 1687 | id: 1688 | name: 1689 | width: 80% 1690 | 1691 | selectOptions: 1692 | enabled: true 1693 | 1694 | actions: 1695 | - label: 일괄 수정 1696 | type: http 1697 | axios: 1698 | method: PUT 1699 | url: https://api.selectfromuser.com/sample-api/products/{{id}} 1700 | data: 1701 | name: "{{value}}" 1702 | params: 1703 | - key: id 1704 | valueFromSelectedRows: true 1705 | - key: value 1706 | help: 도움이 되는 텍스트를 적어요. 1707 | label: 가게 세부문구 1708 | forEach: true 1709 | reloadAfterSubmit: true 1710 | 1711 | modal: true 1712 | confirm: 일괄수정후 배너 상태가 바뀝니다. 계속하시겠습니까? 1713 | 1714 | - type: toggle 1715 | name: HTTP GET + Modal > PUT 1716 | blocks: 1717 | - type: http 1718 | axios: 1719 | method: GET 1720 | url: https://api.selectfromuser.com/sample-api/products 1721 | rowsPath: rows 1722 | searchOptions: 1723 | enabled: true 1724 | paginationOptions: 1725 | enabled: true 1726 | perPage: 10 1727 | columns: 1728 | name: 1729 | width: 80% 1730 | 상세: 1731 | append: true 1732 | buttons: 1733 | - label: 조회 1734 | openModal: modal1 1735 | modals: 1736 | - path: modal1 1737 | useColumn: id 1738 | # mode: full 1739 | mode: side 1740 | blocks: 1741 | - type: http 1742 | axios: 1743 | method: GET 1744 | url: https://api.selectfromuser.com/sample-api/products/{{id}} 1745 | params: 1746 | - key: id 1747 | valueFromRow: id 1748 | rowsPath: rows 1749 | display: col-1 1750 | columns: 1751 | id: 1752 | name: 1753 | updateOptions: 1754 | type: http 1755 | axios: 1756 | method: PUT 1757 | url: https://api.selectfromuser.com/sample-api/products/{{id}} 1758 | data: 1759 | name: \${{value}} 1760 | 1761 | - type: toggle 1762 | name: HTTP GET + responseFn join 1763 | blocks: 1764 | - type: http 1765 | id: Stores 1766 | axios: 1767 | method: GET 1768 | url: https://api.selectfromuser.com/sample-api/v3/stores 1769 | rowsPath: rows 1770 | searchOptions: 1771 | enabled: true 1772 | 1773 | columns: 1774 | Category: 1775 | color: 1776 | F&B: yellow 1777 | B2B: purple 1778 | products: 1779 | # format: json 1780 | formatFn: splitComma 1781 | 1782 | responseFn: | 1783 | const products = await Products.trigger() 1784 | 1785 | if (!products.rows) throw new Error('상품 가져오기 실패') 1786 | 1787 | console.log(products, rows) 1788 | 1789 | for (const row of rows) { 1790 | row.products = products.rows 1791 | .filter(e => 1000 + +e.id != +row.id) 1792 | .map(e => e.name) 1793 | .join(',') 1794 | } 1795 | 1796 | - type: http 1797 | id: Products 1798 | axios: 1799 | method: GET 1800 | url: https://api.selectfromuser.com/sample-api/products 1801 | hidden: true 1802 | autoload: false 1803 | rowsPath: rows 1804 | ` --------------------------------------------------------------------------------