diff --git a/.babelrc b/.babelrc index 4eed4f31..2348ad18 100644 --- a/.babelrc +++ b/.babelrc @@ -1,11 +1,12 @@ { "presets": [ - "env", - "stage-0", - "react" + ["@babel/preset-env", { "loose": true }], + "@babel/preset-react" ], "plugins": [ - "transform-runtime", - "add-react-displayname" + ["@babel/plugin-transform-class-properties", { "loose": true }], + ["@babel/plugin-transform-private-methods", { "loose": true }], + ["@babel/plugin-transform-private-property-in-object", { "loose": true }], + ["@babel/plugin-transform-runtime", { "regenerator": true }] ] } diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..fe6b8673 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "hacknical-dev", + "runtimeExecutable": "/tmp/start-hacknical.sh", + "runtimeArgs": [], + "port": 4000 + } + ] +} diff --git a/.eslintrc b/.eslintrc index 6d77903f..d8009f4e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,5 +1,11 @@ { - "parser": "babel-eslint", + "parser": "@babel/eslint-parser", + "parserOptions": { + "requireConfigFile": false, + "babelOptions": { + "presets": ["@babel/preset-env", "@babel/preset-react"] + } + }, "extends": "eslint-config-airbnb", "plugins": [ "react" @@ -48,7 +54,20 @@ "no-return-await": 0, "no-continue": 0, "no-return-assign": 0, - "no-restricted-syntax": 0 + "no-restricted-syntax": 0, + "arrow-parens": 0, + "implicit-arrow-linebreak": 0, + "operator-linebreak": 0, + "no-multiple-empty-lines": 0, + "no-promise-executor-return": 0, + "no-else-return": 0, + "default-param-last": 0, + "prefer-regex-literals": 0, + "prefer-object-spread": 0, + "import/order": 0, + "import/no-import-module-exports": 0, + "import/no-relative-packages": 0, + "react/jsx-props-no-spreading": 0 }, "globals": { "require": true, diff --git a/.gitignore b/.gitignore index 235d988a..d35636b0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,11 @@ /node_modules /public/dll /public/downloads +/public/assets +/data/*.sqlite +/data/*.sqlite-* .idea .vscode /log -/app/config/webpack-assets.json \ No newline at end of file +/app/config/webpack-assets.json +.DS_Store diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..3867a0fe --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npm run lint diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..e60bb56c --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +legacy-peer-deps=true +audit=false +fund=false diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000..810bce16 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,36 @@ +# Node 22 开发镜像,兼容现代工具链,也能在 Apple Silicon 上原生运行 +FROM node:22-bookworm-slim + +# node-gyp 需要 python3 和构建工具 +# chromium 给 puppeteer-core 用(生成简历 PDF) +# fonts-noto-cjk 用于中文字符渲染 +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + make \ + g++ \ + ca-certificates \ + git \ + chromium \ + fonts-liberation \ + fonts-noto-cjk \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# 先拷贝依赖描述和补丁,用 docker 缓存层加速重建 +COPY package.json package-lock.json .npmrc ./ +COPY patches ./patches + +# puppeteer-core 不下载内置 chromium,用 apt 装的那个 +ENV NPM_CONFIG_LOGLEVEL=warn \ + PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \ + PUPPETEER_SKIP_DOWNLOAD=true + +# 用 lockfile 保证 Node 22/npm 10 下依赖可复现 +RUN npm config set registry https://registry.npmmirror.com \ + && npm ci --unsafe-perm + +# 源码后续通过 volume 挂载进来 +EXPOSE 4000 + +CMD ["npm", "run", "start-dev"] diff --git a/README.md b/README.md index bd58d45d..6529da55 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,17 @@ ![hacknical-logo-with-text](./doc/screenshots/logos/hacknical-logo-large.png) -> A website for GitHub user to generate his GitHub data analysis (contributions/commits/languages/repos datas), helps to make a better resume. +> A lightweight online resume editor and sharing service backed by local SQLite storage. [中文版 README](./doc/README-ZH.md) -**Attention:Most of the pages support English now😁😁😁, including github data analysis page.** +## Features -Extract dependency: - -- UI Components --> [light-ui](https://github.com/ecmadao/light-ui) -- GitHub API crawler --> [hacknical-github](https://github.com/ecmadao/hacknical-github) - -## Examples - -- [My GitHub data analysis](https://hacknical.com/ecmadao/github) +- Local username login, without third-party OAuth. +- Create, edit, share, and export online resumes. +- SQLite storage for users, resumes, share settings, and basic stats. +- Optional favicon lookup through `besticon`. +- Optional Ali OSS integration for image/PDF upload paths. ## Screenshots @@ -25,46 +22,51 @@ Extract dependency: ![login page](./doc/screenshots/login-en.png) -> github datas analysis +## Local Development -![github datas](./doc/screenshots/github-en.png) +Requirements: -## About +- Node.js 22 +- npm 10 -[中文版说明](./doc/ABOUT-zh.md) +```bash +npm ci +npm run start-app +``` + +The app runs at `http://localhost:4000`. Local data is stored in `data/hacknical-local.sqlite`. -## Todos +Docker development is also available: -- [x] support English -- [x] support orgs -- [ ] support forked repos -- [ ] support edit resume in mobile -- [x] support show resume in mobile -- [x] support export resume to PDF +```bash +docker compose up -d --build +docker compose logs -f app +``` -## Techs +The Docker setup starts the app plus `besticon`; it does not start Redis because the default cache and queue are in memory. -- backend +## Configuration - - koa2 - - redis - - mongoose - - nunjucks - - request - - pm2 +Runtime config lives in `config/default.json` and `config/localdev.json`. Environment overrides are declared in `config/custom-environment-variables.json`. -- frontend +Common overrides: - - react - - redux - - react-router - - particles - - scrollreveal - - chart.js - - clipboard - - headroom.js - - webpack +- `PORT`: server port; `config/localdev.json` defaults to `4000`. +- `HACKNICAL_DATABASE_CLIENT`: storage backend; defaults to `sqlite`. +- `HACKNICAL_SQLITE_PATH`: SQLite file path. +- `HACKNICAL_BESTICON_URL`: favicon service URL. +- `HACKNICAL_CACHE_SOURCE`: cache backend; defaults to `memory`. +- `HACKNICAL_REDIS_HOST` / `HACKNICAL_REDIS_PORT`: optional Redis cache connection when `HACKNICAL_CACHE_SOURCE=redis`. +- `HACKNICAL_ALI_ACCESS_ID` / `HACKNICAL_ALI_ACCESS_KEY`: Ali OSS credentials for upload features. +## Tech Stack + +- backend: Node.js 22, Koa, SQLite, Nunjucks, Puppeteer, PM2 +- frontend: React 19, Redux, React Router, Chart.js, Webpack 5 + +## About + +[中文版说明](./doc/ABOUT-zh.md) ## License diff --git a/app/bin/app.js b/app/bin/app.js index 569bc972..ca513eb5 100644 --- a/app/bin/app.js +++ b/app/bin/app.js @@ -15,6 +15,7 @@ import staticServer from 'koa-static' import router from '../routes' import logger from '../utils/logger' +import network from '../services/network' import mqMiddleware from '../middlewares/mq' import errorMiddleware from '../middlewares/error' import localeMiddleware from '../middlewares/locale' @@ -26,7 +27,14 @@ import firewallMiddleware from '../middlewares/firewall' // Handle unhandled promise rejections for Redis queue process.on('unhandledRejection', (reason, promise) => { - if (reason && reason.message && reason.message.includes('queueExists')) { + const reasonText = [ + reason && reason.name, + reason && reason.message, + typeof reason === 'string' ? reason : '', + reason && typeof reason.inspect === 'function' ? String(reason.inspect()) : '' + ].filter(Boolean).join(' ') + + if (reasonText.includes('queueExists')) { logger.debug('Redis queue already exists, continuing...') return } @@ -51,7 +59,7 @@ app.use(cors()) // bodyparser app.use(bodyParser({ onerror: (err, ctx) => { - ctx.throw('body parse error', 422) + ctx.throw(422, 'body parse error') } })) @@ -83,6 +91,23 @@ const CONFIG = { } app.use(session(CONFIG, app)) +// mock session (localdev only) - auto-logs-in as demo user +if (config.has('mock') && config.get('mock')) { + app.use(async (ctx, next) => { + if (!ctx.session.githubLogin) { + const user = await network.user.createUser({ + login: 'demo', + userName: 'demo' + }) + ctx.session.githubLogin = 'demo' + ctx.session.userId = user.userId + ctx.session.githubToken = null + ctx.session.githubAvator = user.avator + } + await next() + }) +} + // cache app.use(redisMiddleware()) // mq @@ -97,7 +122,7 @@ app.use(new Csrf()) app.use(async (ctx, next) => { ctx.state = Object.assign({}, ctx.state, { assetsPath: assetsMiddleware, - csrf: ctx.csrf, + csrf: ctx.state._csrf, env: process.env.NODE_ENV, footer: { about: ctx.__('dashboard.about'), diff --git a/app/config/locales/en-US.json b/app/config/locales/en-US.json index fb639810..57b69e18 100644 --- a/app/config/locales/en-US.json +++ b/app/config/locales/en-US.json @@ -3,8 +3,8 @@ "text": "En", "id": "en" }, - "description": "hacknical - A website for GitHub user to make a better resume.", - "keywords": "GitHub, GitHub-Contributions, GitHub-Analysis, Resume, Resume-Template, Data-Analysis", + "description": "hacknical - Create, edit, share, and export online resumes.", + "keywords": "Resume, Online Resume, Resume Template, Resume Export", "loginPage": { "title": "hacknical | Make a better resume" }, @@ -27,7 +27,7 @@ "code": "source code" }, "sharePage": { - "title": "%s's GitHub summary | hacknical", + "title": "%s's share page | hacknical", "joinAt": "Joined at" }, "resumePage": { @@ -38,7 +38,7 @@ "analysis": "Share records", "menu": { "shareDatas": "Share Records", - "githubShare": "GitHub Analysis", + "githubShare": "Resume Overview", "resumeShare": "Personal Resume", "dataRefresh": "Settings", "about": "About", @@ -55,6 +55,7 @@ "update": "Update failed", "resume": "Can not find your resume", "emptyResume": "Please create your resume", + "loginName": "Use a 2-39 character username with letters, numbers, underscore, or hyphen", "invalidateEmail": "Invalidate email", "logout": "Session is missed", "download": "Download failed. Please retry later" @@ -67,14 +68,14 @@ "updateOrgs": "User orgs are successfully update" }, "share": { - "text": "Wanna check your GitHub data analysis visually?", - "mobileText": "Check your GitHub data analysis", + "text": "Check out my online resume.", + "mobileText": "Check my online resume", "toggleOpen": "Share enabled", "toggleClose": "Share disabled" }, "resume": { - "linkGithub": "Resume linked with GitHub", - "unlinkGithub": "Resume unlinked with GitHub", + "linkGithub": "Resume attachment enabled", + "unlinkGithub": "Resume attachment disabled", "template": "Resume template successfully changed", "hireAvailable": "Hire status was successfully updated", "simplifyUrl": "Simplify share url opened", @@ -84,7 +85,7 @@ "succeed": "Data update succeed", "pending": "Data is updating in the background", "running": "Data is updating in the background", - "failed": "GitHub token expired, please re-login", + "failed": "Session expired, please re-login", "frequently": "Update too frequently, please retry later" } } diff --git a/app/config/locales/fr-FR.json b/app/config/locales/fr-FR.json index 55835a87..260a27ab 100644 --- a/app/config/locales/fr-FR.json +++ b/app/config/locales/fr-FR.json @@ -3,8 +3,8 @@ "text": "Fr", "id": "fr" }, - "description": "Hacknical - Une plateforme destinée aux utilisateurs GitHub afin de promouvoir un CV plus adapté.", - "keywords": "GitHub, GitHub-Contributions, GitHub-Analyse, Curriculum Vitae, CV", + "description": "Hacknical - Créer, modifier, partager et exporter un CV en ligne.", + "keywords": "CV, CV en ligne, modèle de CV, export de CV", "loginPage": { "title": "Hacknical | Un meilleur CV" }, @@ -24,7 +24,7 @@ "title": "Hacknical | %s" }, "sharePage": { - "github": "%s's GitHub résumé | Hacknical", + "github": "%s's résumé | Hacknical", "joinAt": "À rejoint le" }, "resumePage": { @@ -35,7 +35,7 @@ "analysis": "Partager les résultats", "menu": { "shareDatas": "Partager les données", - "githubAnalysis": "Analyse GitHub", + "githubAnalysis": "Aperçu du CV", "dataRefresh": "Paramètres", "logout": "Se déconnecter" } @@ -51,14 +51,14 @@ "update": "Mise à jour réussie. Merci de rafraichir la page pour vérifier." }, "share": { - "text": "Souhaitez-vous visualiser vos données GitHub ?", - "mobileText": "Vérifier votre analyse de données GitHub", + "text": "Consultez mon CV en ligne.", + "mobileText": "Consulter mon CV", "toggleOpen": "Partage activé", "toggleClose": "Partage désactivé" }, "resume": { - "linkGithub": "CV associé avec GitHub", - "unlinkGithub": "CV dissocié de GitHub" + "linkGithub": "Contenu associé au CV activé", + "unlinkGithub": "Contenu associé au CV désactivé" } } } diff --git a/app/config/locales/zh-CN.json b/app/config/locales/zh-CN.json index fd5198d5..2d634234 100644 --- a/app/config/locales/zh-CN.json +++ b/app/config/locales/zh-CN.json @@ -3,8 +3,8 @@ "text": "中文", "id": "zh" }, - "description": "hacknical - 查看自己的 GitHub 总结,并协助你完善自我简历", - "keywords": "GitHub,数据可视化,简历,在线简历", + "description": "hacknical - 创建、编辑、分享并导出在线简历", + "keywords": "简历,在线简历,简历模板,简历导出", "loginPage": { "title": "hacknical | 更加高效的在线简历" }, @@ -27,7 +27,7 @@ "code": "源码" }, "sharePage": { - "title": "%s 的 GitHub 总结 | hacknical", + "title": "%s 的分享页 | hacknical", "joinAt": "加入于" }, "resumePage": { @@ -38,7 +38,7 @@ "analysis": "分享记录", "menu": { "shareDatas": "数据记录", - "githubShare": "GitHub 战绩", + "githubShare": "简历概览", "resumeShare": "个人简历", "dataRefresh": "相关设置", "about": "关于", @@ -55,6 +55,7 @@ "update": "数据更新失败", "resume": "没有找到简历", "emptyResume": "请先创建简历", + "loginName": "请填写 2-39 位用户名,仅支持字母、数字、下划线和短横线", "invalidateEmail": "请填写正确的邮箱", "logout": "session 缺失,请登录", "download": "简历下载失败,请稍后重试" @@ -67,14 +68,14 @@ "updateOrgs": "隶属组织信息更新完毕" }, "share": { - "text": "想知道自己在致力于哪些编程语言?戳我查看自己的 GitHub 总结", - "mobileText": "想知道自己在致力于哪些编程语言?", + "text": "戳我查看我的在线简历", + "mobileText": "查看我的在线简历", "toggleOpen": "分享链接已开启", "toggleClose": "分享链接已关闭" }, "resume": { - "linkGithub": "已开启简历和 GitHub 的关联", - "unlinkGithub": "已关闭简历和 GitHub 的关联", + "linkGithub": "已开启简历附加内容", + "unlinkGithub": "已关闭简历附加内容", "template": "简历模板更改成功", "hireAvailable": "求职状态更新成功", "simplifyUrl": "已开启简化的分享链接", @@ -84,7 +85,7 @@ "succeed": "数据更新完成", "pending": "数据在后台更新中", "running": "数据在后台更新中", - "failed": "GitHub token 过期,请退出后重新登录", + "failed": "登录状态过期,请退出后重新登录", "frequently": "更新频繁,请稍后重试" } } diff --git a/app/controllers/helper/record.js b/app/controllers/helper/record.js index 44135d1c..fcd6ecb6 100644 --- a/app/controllers/helper/record.js +++ b/app/controllers/helper/record.js @@ -3,6 +3,7 @@ import logger from '../../utils/logger' import notify from '../../services/notify' import { getValue } from '../../utils/helper' import network from '../../services/network' +import config from 'config' const updateViewData = options => async (ctx, login) => { const { platform, browser, device } = ctx.state @@ -64,6 +65,7 @@ const dataRecord = async (ctx, key, record) => { const { notrace } = ctx.query const user = await getUser(ctx, key) + if (!user) return const login = user.githubLogin const { githubLogin, fromDownload } = ctx.session @@ -86,10 +88,13 @@ const updateLogData = options => async (ctx, login) => { try { const { ip, ...others } = options let ipInfo = { ip } - ipInfo = await network.ip.getInfo(ip) - ipInfo = JSON.parse(ipInfo) - Object.assign({}, ipInfo, { ip }) - logger.info(`[IP:${ip}] ${JSON.stringify(ipInfo)}`) + const hasIpService = config.has('services.ip.url') && Boolean(config.get('services.ip.url')) + if (hasIpService) { + ipInfo = await network.ip.getInfo(ip) + ipInfo = JSON.parse(ipInfo) + Object.assign({}, ipInfo, { ip }) + logger.info(`[IP:${ip}] ${JSON.stringify(ipInfo)}`) + } await network.stat.putLogs(Object.assign({}, others, { login, ipInfo })) } catch (e) { logger.error(e.stack || e) diff --git a/app/controllers/helper/share.js b/app/controllers/helper/share.js index a7b5df40..366486fa 100644 --- a/app/controllers/helper/share.js +++ b/app/controllers/helper/share.js @@ -39,6 +39,7 @@ const resumeParamsFormatter = async (ctx, source) => { if (key === 'login') { const user = await network.user.getUser({ login: value }) + if (!user) return {} return { userId: user.userId } diff --git a/app/controllers/home.js b/app/controllers/home.js index 325c4047..c9d8bc1e 100644 --- a/app/controllers/home.js +++ b/app/controllers/home.js @@ -1,10 +1,9 @@ +import { Readable } from 'stream' import network from '../services/network' import getLanguages from '../config/languages' import logger from '../utils/logger' import notify from '../services/notify' -import request from 'request' -import auth0 from "../services/network/lib/auth0" const cacheControl = (ctx) => { ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate') @@ -15,20 +14,9 @@ const cacheControl = (ctx) => { const renderLandingPage = async (ctx) => { cacheControl(ctx) - const params = new URLSearchParams({ - response_type: "code", - client_id: auth0.auth0Credential.clientId, - redirect_uri: auth0.auth0Credential.redirectUri, - scope: "openid profile email", - state: 'auth0.github.' + Date.now(), - connection: 'github' // Specify GitHub connection - }); - - const loginLink = `${auth0.auth0Credential.domain}/authorize?${params.toString()}`; const { messageCode, messageType } = ctx.request.query await ctx.render('user/login', { - loginLink, messageCode, messageType, title: ctx.__('loginPage.title') @@ -56,7 +44,7 @@ const render404Page = async (ctx) => { const renderDashboard = async (ctx) => { const { device, browser, platform } = ctx.state const { githubLogin, userId } = ctx.session - const { login, dashboardRoute = 'visualize' } = ctx.params + const { login, dashboardRoute = 'archive' } = ctx.params const user = await network.user.getUser({ userId }) logger.debug(`githubLogin: ${githubLogin}, userId: ${userId}`) @@ -171,7 +159,12 @@ const getIcon = async (ctx, next) => { if (diff === 0) break } - if (icon) iconUrl = request(icon.url) + if (icon) { + const iconResponse = await fetch(icon.url) + if (iconResponse.ok && iconResponse.body) { + iconUrl = Readable.fromWeb(iconResponse.body) + } + } } catch (e) { logger.error(e.stack || e.message || e) iconUrl = '' diff --git a/app/controllers/resume.js b/app/controllers/resume.js index 0c71aeb4..9583e1bd 100644 --- a/app/controllers/resume.js +++ b/app/controllers/resume.js @@ -20,7 +20,8 @@ const ossConfig = config.get('services.oss') const getResumeShareStatus = (resumeInfo, locale) => { return { ...resumeInfo, - githubUrl: `https://hacknical.com/${resumeInfo.login}/github?locale=${locale}`, + useGithub: false, + githubUrl: null, url: resumeInfo.simplifyUrl && resumeInfo.login ? `${resumeInfo.login}/resume?locale=${locale}` : `resume/${resumeInfo.resumeHash}?locale=${locale}` @@ -32,23 +33,13 @@ const getResumeShareStatus = (resumeInfo, locale) => { const getResume = async (ctx) => { const { userId, - githubToken, githubLogin } = ctx.session const { locale } = ctx.query const data = await network.user.getResume({ userId, locale }) const { resume = null } = (data || {}) - if ( - resume && resume.info - ) { - if (!resume.info.languages || !resume.info.languages.length) { - const languages = await network.github.getUserLanguages(githubLogin, githubToken) - resume.info.languages = Object.keys(languages) - .slice(0, 5) - .sort((k1, k2) => languages[k2] - languages[k1]) - } - } + logger.debug(`[RESUME:GET][${githubLogin}] ${Boolean(resume)}`) ctx.body = { success: true, @@ -85,7 +76,7 @@ const setResume = async (ctx, next) => { mq: ctx.mq, data: { type: 'resume', - data: `Resume create or update by ` + data: `Resume create or update by ${githubLogin}` } }) diff --git a/app/controllers/user.js b/app/controllers/user.js index 2e32b7c3..75f70a16 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -1,173 +1,63 @@ import network from '../services/network' -import getCacheKey from './helper/cacheKey' import logger from '../utils/logger' import notify from '../services/notify' - -const clearCache = async (ctx, next) => { - const cacheKey = getCacheKey(ctx) - ctx.query.deleteKeys = [ - cacheKey('user-repositories', { - query: ['login'] - }), - cacheKey('user-contributed', { - query: ['login'] - }), - cacheKey('allRepositories', { - query: ['login'] - }), - cacheKey('user-github', { - query: ['login'] - }), - cacheKey('user-hotmap', { - query: ['login'], - session: ['locale'] - }), - cacheKey('user-organizations', { - query: ['login'], - }), - cacheKey('user-commits', { - query: ['login'], - }) - ] - ctx.body = { - success: true - } - await next() -} +import NewError from '../utils/error' const logout = async (ctx) => { ctx.session.userId = null ctx.session.githubToken = null ctx.session.githubLogin = null + ctx.session.githubAvator = null const { messageCode, messageType } = ctx.request.query ctx.redirect(`/?messageCode=${messageCode}&messageType=${messageType}`) } -const loginByAuth0 = async (ctx) => { - const { code } = ctx.request.query - - if (!code) { - logger.error('[AUTH0:LOGIN] no authorization code provided') - return ctx.redirect('/api/user/logout?messageCode=auth0&messageType=error') - } - - try { - const [ - userTokenResponse, - managementTokenResponse - ] = await Promise.all([ - network.auth0.getAccessToken(code), - network.auth0.getManagementToken() - ]) - const { access_token: userToken } = userTokenResponse - const { access_token: managementToken } = managementTokenResponse - const userInfoResponse = await network.auth0.getUserInfo(userToken) - - logger.debug(`[AUTH0:LOGIN] User info: ${JSON.stringify(userInfoResponse)}`) - - const fullUserInfo = await network.auth0.getUserById(userInfoResponse.sub, managementToken) - // Find GitHub identity to get the access token - const githubIdentity = fullUserInfo.identities && fullUserInfo.identities.find(id => id.provider === 'github') - if (!githubIdentity) { - logger.error(`[AUTH0:LOGIN] cannot found github identity for ${JSON.stringify(fullUserInfo)}`) - return ctx.redirect('/api/user/logout?messageCode=github&messageType=error') - } - - // Extract GitHub access token from the identity - const githubToken = githubIdentity.access_token - if (!githubToken) { - logger.error(`[AUTH0:LOGIN] cannot found github token for ${JSON.stringify(fullUserInfo)}`) - return ctx.redirect('/api/user/logout?messageCode=github&messageType=error') - } - - // Get GitHub user info using the GitHub token - const githubUserInfo = await network.github.getLogin(githubToken) - logger.debug(`[AUTH0:LOGIN] GitHub user info: ${JSON.stringify(githubUserInfo)}`) - if (!githubUserInfo.login) { - logger.error(`[AUTH0:LOGIN] cannot found github login for ${JSON.stringify(userInfoResponse)}`) - return ctx.redirect('/api/user/logout?messageCode=github&messageType=error') - } - - // Set GitHub session data - ctx.session.githubToken = githubToken - ctx.session.githubLogin = githubUserInfo.login - ctx.session.githubAvator = githubUserInfo.avator || githubUserInfo.avatar_url +const normalizeLogin = login => + String(login || '') + .trim() + .replace(/[^a-zA-Z0-9_-]/g, '') - // Create or update user - const user = await network.user.createUser(githubUserInfo) - notify.slack({ - mq: ctx.mq, - data: { - type: 'login', - data: ` logged in via Auth0!` - } - }) +const loginLocal = async (ctx) => { + const login = normalizeLogin(ctx.request.body.login) - logger.info(`[AUTH0:LOGIN] ${JSON.stringify(user)}`) - ctx.session.userId = user.userId - - // Update user data if already initialized - if (user && user.initialed) { - network.github.updateUserData(ctx.session.githubLogin, githubToken) - } - - return ctx.redirect(`/${ctx.session.githubLogin}`) - } catch (err) { - logger.error(`[AUTH0:LOGIN] ${err.stack || err.message || err}`) - return ctx.redirect('/api/user/logout?messageCode=auth0&messageType=error') + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{1,38}$/.test(login)) { + throw new NewError.ParamsError(ctx.__('messages.error.loginName')) } -} -const loginByGitHub = async (ctx) => { - const { code } = ctx.request.query - try { - const githubToken = await network.github.getToken(code) - const userInfo = await network.github.getLogin(githubToken) - logger.debug(userInfo) - - if (userInfo.login) { - ctx.session.githubToken = githubToken - ctx.session.githubLogin = userInfo.login - ctx.session.githubAvator = userInfo.avator - - const user = await network.user.createUser(userInfo) - notify.slack({ - mq: ctx.mq, - data: { - type: 'login', - data: ` logined!` - } - }) + const user = await network.user.createUser({ + login, + userName: login + }) - logger.info(`[USER:LOGIN] ${JSON.stringify(user)}`) - ctx.session.userId = user.userId - if (user && user.initialed) { - network.github.updateUserData(ctx.session.githubLogin, githubToken) - } + ctx.session.githubToken = null + ctx.session.githubLogin = user.login + ctx.session.githubAvator = user.avator + ctx.session.userId = user.userId - return ctx.redirect(`/${ctx.session.githubLogin}`) + notify.slack({ + mq: ctx.mq, + data: { + type: 'login', + data: `${user.login} logged in` } + }) + + logger.info(`[USER:LOGIN] ${JSON.stringify(user)}`) - return ctx.redirect('/api/user/logout') - } catch (err) { - logger.error(`[GITHUB:LOGIN] ${err.stack || err.message || err}`) - return ctx.redirect('/api/user/logout?messageCode=github&messageType=error') + ctx.body = { + success: true, + result: '', + url: `/${user.login}/archive` } } const initialFinished = async (ctx) => { const { userId } = ctx.session - await Promise.all([ - network.user.updateUser(userId, { initialed: true }), - network.stat.putStat({ - type: 'github', - action: 'count' - }) - ]) + await network.user.updateUser(userId, { initialed: true }) ctx.body = { success: true, @@ -175,19 +65,6 @@ const initialFinished = async (ctx) => { } } -const getGitHubSections = async (ctx) => { - const { login } = ctx.query - const user = await network.user.getUser({ - login: login || ctx.session.githubLogin - }) - const resumeInfo = await network.user.getResumeInfo({ userId: user.userId }) - - ctx.body = { - result: resumeInfo.githubSections, - success: true - } -} - const getUserInfo = async (ctx) => { const { login } = ctx.query const user = await network.user.getUser({ @@ -249,7 +126,7 @@ const voteNotify = async (ctx) => { notify.slack({ mq: ctx.mq, data: { - data: `${type.toUpperCase()} ${messageId} by ` + data: `${type.toUpperCase()} ${messageId} by ${githubLogin}` } }) @@ -266,9 +143,7 @@ const voteNotify = async (ctx) => { export default { // user logout, - clearCache, getUserInfo, - getGitHubSections, patchUserInfo, initialFinished, // notify @@ -276,6 +151,5 @@ export default { voteNotify, getUnreadNotifies, // login - loginByGitHub, - loginByAuth0 + loginLocal } diff --git a/app/index.js b/app/index.js index 4e982fa5..6c80e82b 100644 --- a/app/index.js +++ b/app/index.js @@ -1,4 +1,3 @@ -require('babel-core/register') -require('babel-polyfill') +require('@babel/register') require('./bin/app.js') diff --git a/app/middlewares/assets.js b/app/middlewares/assets.js index 04c6fd1f..fa6494fa 100644 --- a/app/middlewares/assets.js +++ b/app/middlewares/assets.js @@ -5,18 +5,45 @@ import fs from 'fs' import PATH from '../../config/path' import logger from '../utils/logger' -let manifest = {} -const manifestPath = +const manifestPathList = [ + path.resolve(PATH.BUILD_PATH, 'webpack-assets.json'), path.resolve(__dirname, '../config', 'webpack-assets.json') +] + +let manifest = {} +let manifestMtimeMs = 0 -if (fs.existsSync(manifestPath)) { - manifest = require(`${manifestPath}`) +const loadManifest = () => { + const manifestPath = manifestPathList.find(filePath => fs.existsSync(filePath)) + if (!manifestPath) return + + try { + const { mtimeMs } = fs.statSync(manifestPath) + if (mtimeMs === manifestMtimeMs) return + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + manifestMtimeMs = mtimeMs + logger.info(`[ASSETS] manifest reloaded from ${manifestPath}`) + } catch (e) { + logger.error(`[ASSETS] failed to load manifest: ${e.message}`) + } } -const getAssetName = asset => manifest[asset] +loadManifest() + +const getAssetName = (asset) => { + loadManifest() + return manifest[asset] +} const assetsMiddleware = (assetsName) => { const finalName = assetsName.split('/').slice(-1)[0] + const directAsset = getAssetName(finalName) || getAssetName(assetsName) + + if (typeof directAsset === 'string') { + logger.info(`[ASSETS] ${directAsset}`) + return directAsset + } + const sections = finalName.split('.') // So file base name should not has dot const name = sections[0] diff --git a/app/middlewares/cache.js b/app/middlewares/cache.js index bb510dcb..329c8f83 100644 --- a/app/middlewares/cache.js +++ b/app/middlewares/cache.js @@ -1,7 +1,41 @@ +import config from 'config' import redisConnect from '../utils/redis' +const mockEnabled = config.has('mock') && config.get('mock') +const cacheSource = config.has('cache.source') ? config.get('cache.source') : 'redis' + +const memoryStore = new Map() +const memoryCache = { + async get(key) { + const entry = memoryStore.get(key) + if (!entry) return null + if (entry.expireAt && entry.expireAt < Date.now()) { + memoryStore.delete(key) + return null + } + return entry.value + }, + async set(key, value, _mode, seconds) { + const expireAt = seconds ? Date.now() + (seconds * 1000) : null + memoryStore.set(key, { value, expireAt }) + return 'OK' + }, + async expire(key) { + memoryStore.delete(key) + return 1 + }, + async del(key) { + memoryStore.delete(key) + return 1 + } +} + export const redisMiddleware = () => async (ctx, next) => { - ctx.cache = await redisConnect() + if (mockEnabled || cacheSource === 'memory' || !config.has('services.redis')) { + ctx.cache = memoryCache + } else { + ctx.cache = await redisConnect() + } await next() } diff --git a/app/middlewares/error.js b/app/middlewares/error.js index 9b76733b..163d1d6f 100644 --- a/app/middlewares/error.js +++ b/app/middlewares/error.js @@ -95,6 +95,15 @@ const catchError = () => async (ctx, next) => { break } + if (/^\/api\//.test(pathname)) { + ctx.status = err.status || err.statusCode || 500 + ctx.body = { + message: message || ctx.__('500Page.text'), + success: false + } + return + } + await render500(ctx, err) } } diff --git a/app/middlewares/mq.js b/app/middlewares/mq.js index 3bb55df8..ec177c20 100644 --- a/app/middlewares/mq.js +++ b/app/middlewares/mq.js @@ -2,11 +2,29 @@ import config from 'config' import mq from 'mq-utils' -const mqConfig = config.get('mq') -const MQ = mq[mqConfig.source](mqConfig.config) +const mqConfig = config.has('mq') ? config.get('mq') : { source: 'memory' } +const mockEnabled = config.has('mock') && config.get('mock') + +const memoryQueue = { + async send() { + return null + }, + async sendMulti() { + return null + } +} + +const createQueue = () => { + if (mockEnabled || mqConfig.source === 'memory' || !mq[mqConfig.source]) { + return memoryQueue + } + + const MQ = mq[mqConfig.source](mqConfig.config) + return new MQ(mqConfig.channels.messenger, mqConfig.options) +} const mqMiddleware = () => { - const queue = new MQ(mqConfig.channels.messenger, mqConfig.options) + const queue = createQueue() return async (ctx, next) => { ctx.mq = queue await next() diff --git a/app/routes/github.js b/app/routes/github.js index 52ca555f..b5511315 100644 --- a/app/routes/github.js +++ b/app/routes/github.js @@ -1,11 +1,11 @@ -import koaRouter from 'koa-router' +import Router from '@koa/router' import GitHub from '../controllers/github' import user from '../controllers/helper/user' import cache from '../controllers/helper/cache' import share from '../controllers/helper/share' -const router = koaRouter({ +const router = new Router({ prefix: '/api/github' }) diff --git a/app/routes/index.js b/app/routes/index.js index aa660a72..16d653e5 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -2,9 +2,8 @@ import fs from 'fs' import path from 'path' -import koaRouter from 'koa-router' +import Router from '@koa/router' import Home from '../controllers/home' -import GitHub from '../controllers/github' import Resume from '../controllers/resume' import user from '../controllers/helper/user' import record from '../controllers/helper/record' @@ -13,13 +12,20 @@ import check from '../controllers/helper/check' import cache from '../controllers/helper/cache' import session from '../controllers/helper/session' -const router = koaRouter() +const router = new Router() const basename = path.basename(module.filename) +const disabledRoutes = new Set([ + 'github.js', + 'scientific.js' +]) fs.readdirSync(__dirname) - .filter(file => - (file.indexOf('.') !== 0) && (file.split('.').slice(-1)[0] === 'js') && (file !== basename) - ) + .filter(file => ( + file.indexOf('.') !== 0 + && file.split('.').slice(-1)[0] === 'js' + && file !== basename + && !disabledRoutes.has(file) + )) .forEach((file) => { const route = require(path.join(__dirname, file)) router.use(route.routes(), route.allowedMethods()) @@ -69,25 +75,12 @@ router.get( ) router.get( - '/github/:login', - share.githubEnable(), - record.github('params.login'), - record.ipGitHub('params.login'), - GitHub.renderGitHubPage -) -router.get('/resume/:hash', + '/resume/:hash', share.resumeEnable('params.hash'), record.resume('params.hash'), record.ipResume('params.hash'), Resume.renderResumePage ) -router.get( - '/:login/github', - share.githubEnable(), - record.github('params.login'), - record.ipGitHub('params.login'), - GitHub.renderGitHubPage -) router.get( '/:login/resume', share.resumeEnable('params.login'), diff --git a/app/routes/resume.js b/app/routes/resume.js index 7545e1c7..3e2fc48e 100644 --- a/app/routes/resume.js +++ b/app/routes/resume.js @@ -1,11 +1,11 @@ -import koaRouter from 'koa-router' +import Router from '@koa/router' import Resume from '../controllers/resume' import session from '../controllers/helper/session' import cache from '../controllers/helper/cache' import check from '../controllers/helper/check' -const router = koaRouter({ +const router = new Router({ prefix: '/api/resume' }) diff --git a/app/routes/scientific.js b/app/routes/scientific.js index 0e3dee26..889cd30e 100644 --- a/app/routes/scientific.js +++ b/app/routes/scientific.js @@ -1,11 +1,11 @@ -import koaRouter from 'koa-router' +import Router from '@koa/router' import Scientific from '../controllers/scientific' import cache from '../controllers/helper/cache' import check from '../controllers/helper/check' import share from '../controllers/helper/share' -const router = koaRouter({ +const router = new Router({ prefix: '/api/scientific' }) diff --git a/app/routes/user.js b/app/routes/user.js index 4fe264d5..3f97cea6 100644 --- a/app/routes/user.js +++ b/app/routes/user.js @@ -1,11 +1,10 @@ -import koaRouter from 'koa-router' +import Router from '@koa/router' import User from '../controllers/user' import user from '../controllers/helper/user' import check from '../controllers/helper/check' -import cache from '../controllers/helper/cache' -const router = koaRouter({ +const router = new Router({ prefix: '/api/user' }) @@ -21,36 +20,20 @@ router.get( User.logout ) -// API -router.get( - '/clearCache', - check.query('login'), - User.clearCache, - cache.del() -) - router.get( '/info', User.getUserInfo ) -router.get( - '/github', - User.getGitHubSections -) router.patch( '/info', check.body('info'), User.patchUserInfo ) -router.get( - '/login/github', - User.loginByGitHub -) - -router.get( - '/login/auth0', - User.loginByAuth0 +router.post( + '/login', + check.body('login'), + User.loginLocal ) router.get( diff --git a/app/services/downloads.js b/app/services/downloads.js index dad208c9..5022cf3f 100644 --- a/app/services/downloads.js +++ b/app/services/downloads.js @@ -2,53 +2,57 @@ import fs from 'fs' import path from 'path' import config from 'config' -import phantom from 'phantom' +import puppeteer from 'puppeteer-core' import PATH from '../../config/path' import logger from '../utils/logger' import { ensureFolder } from '../utils/files' import { uploadFile } from '../utils/uploader' -const waitUntil = asyncFunc => new Promise((resolve, reject) => { - const wait = () => { - asyncFunc().then((value) => { - if (value === true) { - resolve() - } else { - setTimeout(wait, 100) - } - }).catch(reject) - } - wait() +const CHROMIUM_PATH = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium' + +const launchBrowser = () => puppeteer.launch({ + executablePath: CHROMIUM_PATH, + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage' + ] }) -const renderScreenshot = async ({ input, output, pageConfig = {} }) => { - const instance = await phantom.create() +const renderPdf = async ({ input, output, pageConfig = {} }) => { + const browser = await launchBrowser() try { - const page = await instance.createPage() + const page = await browser.newPage() if (pageConfig.pageStyle === 'onePage') { - await page.property('viewportSize', { width: 1024, height: 600 }) - } else { - await page.property('paperSize', { - width: 1024, - height: 1448, - format: 'A4', - margin: { - top: '1cm', - bottom: '1cm', - }, - orientation: 'portrait' - }) + await page.setViewport({ width: 1024, height: 600 }) } - await page.open(input) - await waitUntil(() => page.evaluate(() => window.done)) - await page.render(output) + await page.goto(input, { waitUntil: 'networkidle0', timeout: 60000 }) + // 等前端渲染完成(与原 phantom 行为一致:window.done 为 true) + await page.waitForFunction('window.done === true', { timeout: 60000 }) + + const pdfOptions = pageConfig.pageStyle === 'onePage' + ? { + path: output, + width: '1024px', + height: '600px', + printBackground: true + } + : { + path: output, + format: 'A4', + printBackground: true, + margin: { top: '1cm', bottom: '1cm' } + } + + await page.pdf(pdfOptions) } catch (e) { logger.error(e.stack || e) } finally { - await instance.exit() + await browser.close() } } @@ -76,7 +80,7 @@ export const downloadResume = async (url, options = {}) => { return resultPath } - await renderScreenshot({ + await renderPdf({ input: url, output: filePath, pageConfig: { diff --git a/app/services/mock/fixtures.js b/app/services/mock/fixtures.js new file mode 100644 index 00000000..18494684 --- /dev/null +++ b/app/services/mock/fixtures.js @@ -0,0 +1,397 @@ +const DEMO_LOGIN = 'demo' +const DEMO_USER_ID = 'demo-user-id' + +const demoUser = { + userId: DEMO_USER_ID, + login: DEMO_LOGIN, + userName: 'Demo User', + githubLogin: DEMO_LOGIN, + githubId: 1, + githubAvator: 'https://avatars.githubusercontent.com/u/1?v=4', + githubShare: true, + initialed: true, + disabled: false, + openShare: true, + resumeShare: true, + freshUser: false, + admin: true, + locale: 'zh', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: new Date().toISOString() +} + +const demoGithubUser = { + login: DEMO_LOGIN, + id: 1, + name: 'Demo User', + avatar_url: demoUser.githubAvator, + html_url: `https://github.com/${DEMO_LOGIN}`, + bio: 'Hacknical demo account', + company: 'Hacknical', + blog: 'https://hacknical.com', + email: 'demo@hacknical.com', + location: 'Beijing', + hireable: true, + public_repos: 3, + public_gists: 0, + followers: 42, + following: 7, + created_at: '2020-01-01T00:00:00Z', + updated_at: new Date().toISOString() +} + +const makeRepo = (name, language, stars, forks) => ({ + name, + full_name: `${DEMO_LOGIN}/${name}`, + owner: { login: DEMO_LOGIN }, + html_url: `https://github.com/${DEMO_LOGIN}/${name}`, + description: `${name} is a demo repository`, + fork: false, + language, + languages: { [language]: Math.max(stars, 1) * 1000 }, + stargazers_count: stars, + forks_count: forks, + watchers_count: stars, + size: 1024, + open_issues_count: 0, + pushed_at: '2025-01-01T00:00:00Z', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + topics: [language.toLowerCase()] +}) + +const demoRepos = [ + makeRepo('hacknical-web', 'JavaScript', 128, 22), + makeRepo('hacknical-server', 'TypeScript', 96, 14), + makeRepo('light-ui', 'JavaScript', 58, 6) +] + +const makeCommits = (weeks) => weeks.map(week => ({ + week, + total: 7, + days: [1, 2, 0, 1, 1, 2, 0] +})) + +const demoCommits = [ + { + name: 'hacknical-web', + language: 'JavaScript', + totalCommits: 420, + created_at: '2024-01-01T00:00:00Z', + commits: makeCommits([1704067200, 1704672000, 1705276800, 1705881600]) + }, + { + name: 'hacknical-server', + language: 'TypeScript', + totalCommits: 210, + created_at: '2024-06-01T00:00:00Z', + commits: makeCommits([1704067200, 1704672000, 1705276800]) + }, + { + name: 'light-ui', + language: 'JavaScript', + totalCommits: 96, + created_at: '2024-03-01T00:00:00Z', + commits: makeCommits([1704672000, 1705276800]) + } +] + +const demoLanguages = { + JavaScript: 42000, + TypeScript: 21000, + CSS: 8000, + HTML: 5000 +} + +const pad = (n) => (n < 10 ? `0${n}` : `${n}`) +const formatDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +const seedRand = (seed) => { + let s = seed + return () => { + s = (s * 9301 + 49297) % 233280 + return s / 233280 + } +} +const buildHotmapDatas = () => { + const end = new Date() + end.setHours(0, 0, 0, 0) + const start = new Date(end) + start.setDate(start.getDate() - 364) + const rand = seedRand(20260419) + const datas = [] + for (let i = 0; i < 365; i += 1) { + const d = new Date(start) + d.setDate(start.getDate() + i) + const weekday = d.getDay() + const burst = rand() < 0.08 ? 6 : 0 + const weekend = weekday === 0 || weekday === 6 + const base = weekend ? rand() * 2 : rand() * 4 + const data = Math.floor(base + burst) + const level = data === 0 ? 0 : Math.min(4, Math.max(1, Math.ceil(data / 2))) + datas.push({ date: formatDate(d), data, level }) + } + return datas +} + +const demoHotmapDatas = buildHotmapDatas() +const demoHotmap = { + datas: demoHotmapDatas, + start: demoHotmapDatas[0].date, + end: demoHotmapDatas[demoHotmapDatas.length - 1].date, + streak: 5, + total: demoHotmapDatas.reduce((sum, d) => sum + d.data, 0), + max: demoHotmapDatas.reduce((m, d) => Math.max(m, d.data), 0), + longestStreak: 24 +} + +const demoOrgs = [] + +const demoResumeContent = { + info: { + name: 'Demo User', + gender: 'male', + phone: '13800000000', + email: 'demo@hacknical.com', + intention: 'Frontend Engineer', + location: 'Beijing', + avator: demoUser.githubAvator, + hireAvailable: true, + privacyProtect: false, + freshGraduate: false, + freshGraduateYear: 2020, + birthday: '1990-01-01', + startWorkingYear: 2020, + languages: ['JavaScript', 'TypeScript', 'Go'] + }, + workExperiences: [ + { + company: 'Hacknical', + url: 'https://hacknical.com', + startTime: '2022-03', + endTime: '', + untilNow: true, + position: 'Senior Frontend Engineer', + projects: [ + { + name: 'Hacknical Web', + url: 'https://hacknical.com', + details: [ + '负责 Hacknical 简历与 GitHub 数据展示页面的整体前端架构设计与演进。', + '基于 React + Redux 搭建组件体系,沉淀 light-ui 组件库,提升团队研发效率。', + '推动前端构建工具从 webpack 4 升级到 webpack 5,构建耗时下降约 40%。' + ] + }, + { + name: 'Resume Builder', + url: '', + details: [ + '设计并实现所见即所得的在线简历编辑器,支持自定义模块、一键分享、PDF 导出。', + '引入单元测试和 E2E 测试体系,核心模块覆盖率 > 85%。' + ] + } + ] + }, + { + company: 'Example Tech', + url: 'https://example.com', + startTime: '2020-07', + endTime: '2022-02', + position: 'Frontend Engineer', + projects: [ + { + name: 'Growth Platform', + url: '', + details: [ + '参与公司增长平台的前端研发,基于 React + TypeScript 构建数据可视化大盘。', + '独立完成 A/B 实验投放 SDK,日均 PV 超过 200w。' + ] + } + ] + } + ], + educations: [ + { + school: '清华大学', + major: '计算机科学与技术', + education: '本科', + startTime: '2016-09', + endTime: '2020-06', + experiences: [ + '曾担任校 ACM 集训队队长,带队获得区域赛银牌。', + '主修方向为软件工程与分布式系统,GPA 3.8 / 4.0。' + ] + } + ], + personalProjects: [ + { + url: 'https://github.com/demo/hacknical-web', + desc: '开源的在线简历平台,支持多语言、多模板、一键分享与导出。', + title: 'hacknical-web', + techs: ['React', 'Redux', 'Koa', 'SQLite'] + }, + { + url: 'https://github.com/demo/light-ui', + desc: '轻量的 React 组件库,覆盖表单、弹窗、导航等常用交互场景。', + title: 'light-ui', + techs: ['React', 'Sass', 'Rollup'] + }, + { + url: 'https://github.com/demo/hacknical-server', + desc: 'Hacknical 后端服务,基于 Koa 3 + SQLite,提供简历编辑与分享能力。', + title: 'hacknical-server', + techs: ['Koa', 'TypeScript', 'SQLite', 'Redis'] + } + ], + others: { + expectLocation: '北京', + expectLocations: ['北京', '上海', '远程'], + expectSalary: '面议', + dream: '持续打磨有趣的产品,把工程体验做到极致。', + supplements: [ + '活跃的开源贡献者,关注前端基础设施与可视化方向。', + '喜欢写技术博客,分享工程实践与学习笔记。' + ], + socialLinks: [ + { + name: 'github', + text: 'GitHub', + icon: 'github.png', + url: `https://github.com/${DEMO_LOGIN}` + }, + { + name: 'blog', + text: '个人博客', + icon: 'blog.png', + url: 'https://hacknical.com' + } + ] + }, + educationList: [], + workList: [], + projectList: [] +} + +const demoResumeInfo = { + userId: DEMO_USER_ID, + login: DEMO_LOGIN, + openShare: true, + simplifyUrl: DEMO_LOGIN, + resumeHash: 'demo-hash', + template: 'v3', + resumeSections: [ + { + id: 'info', + enabled: true, + canbeDisabled: false, + canbeReorder: false + }, + { + id: 'workExperiences', + enabled: true, + canbeDisabled: true, + canbeReorder: true + }, + { + id: 'personalProjects', + enabled: true, + canbeDisabled: true, + canbeReorder: true + }, + { + id: 'educations', + enabled: true, + canbeDisabled: true, + canbeReorder: true + }, + { + id: 'others', + enabled: true, + canbeDisabled: true, + canbeReorder: true + } + ], + githubSections: [ + { id: 'hotmap', enabled: true }, + { id: 'info', enabled: true }, + { id: 'repos', enabled: true }, + { id: 'course', enabled: true }, + { id: 'languages', enabled: true }, + { id: 'orgs', enabled: true }, + { id: 'contributed', enabled: true }, + { id: 'commits', enabled: true } + ], + useGithub: false, + url: DEMO_LOGIN, + viewTimes: 12, + downloadTimes: 3 +} + +const demoUpdateStatus = { + status: 1, + startUpdateAt: '2025-01-01T00:00:00Z', + lastUpdateTime: new Date().toISOString() +} + +const exact = (path, data, method = 'get') => ({ match: path, data, method }) +const regex = (re, data, method = 'get') => ({ match: re, data, method }) + +const fixtures = { + user: [ + exact('/user', demoUser), + exact('/user', { success: true }, 'put'), + exact('/user', { success: true, userId: DEMO_USER_ID }, 'post'), + exact('/user/count', 1024), + exact('/resume', { + resume: demoResumeContent, + info: demoResumeInfo, + languages: [ + { id: 'zh', text: '中文' }, + { id: 'en', text: 'English' } + ], + updated_at: new Date().toISOString() + }), + exact('/resume', { success: true }, 'put'), + exact('/resume/information', demoResumeInfo), + exact('/resume/information', { success: true }, 'put'), + exact('/resume/count', 256) + ], + github: [ + regex(/^\/github\/[^/]+\/repositories$/, demoRepos), + regex(/^\/github\/[^/]+\/contributed$/, []), + regex(/^\/github\/[^/]+\/commits$/, demoCommits), + regex(/^\/github\/[^/]+\/languages$/, demoLanguages), + regex(/^\/github\/[^/]+\/organizations$/, demoOrgs), + regex(/^\/github\/[^/]+\/hotmap$/, demoHotmap), + regex(/^\/github\/[^/]+\/update$/, demoUpdateStatus), + regex(/^\/github\/[^/]+\/update$/, { success: true }, 'put'), + regex(/^\/github\/[^/]+$/, { success: true }, 'patch'), + regex(/^\/scientific\/[^/]+\/statistic$/, { total: 0 }), + regex(/^\/scientific\/[^/]+\/predictions$/, []), + regex(/^\/scientific\/[^/]+\/predictions$/, { success: true }, 'delete'), + regex(/^\/scientific\/[^/]+\/predictions$/, { success: true }, 'put'), + exact('/github/zen', 'Design for failure.'), + exact('/github/octocat', 'MU. MU. MU.'), + exact('/github/verify', { ok: true }), + exact('/github/token', { access_token: 'demo-token' }), + exact('/github/login', { login: DEMO_LOGIN }), + regex(/^\/github\/[^/]+$/, demoGithubUser) + ], + stat: [ + exact('/notify/all', []), + regex(/^\/notify\/[^/]+$/, []), + regex(/^\/notify\/[^/]+$/, { success: true }, 'put'), + regex(/^\/notify\/[^/]+$/, { success: true }, 'patch'), + exact('/stat', { total: 0 }), + exact('/stat', { success: true }, 'put'), + exact('/logs', []), + exact('/logs', { success: true }, 'put'), + exact('/records', []), + exact('/records/all', []), + exact('/records', { success: true }, 'put') + ], + besticon: [], + ip: [], + auth0: [] +} + +export default fixtures diff --git a/app/services/mock/index.js b/app/services/mock/index.js new file mode 100644 index 00000000..0f803d58 --- /dev/null +++ b/app/services/mock/index.js @@ -0,0 +1,46 @@ +/* eslint no-unused-vars: "off" */ +import config from 'config' +import fixtures from './fixtures' + +export const MOCK_MISS = Symbol('mock-miss') + +const stripBase = (source, fullUrl) => { + if (!fullUrl) return '' + try { + const service = config.get(`services.${source}`) + const base = service && service.url + if (base && fullUrl.startsWith(base)) return fullUrl.slice(base.length) || '/' + } catch (e) { /* ignore */ } + const idx = fullUrl.indexOf('/api') + if (idx !== -1) return fullUrl.slice(idx + 4) || '/' + return fullUrl +} + +const matchRoute = (source, fullUrl, method) => { + const table = fixtures[source] + if (!table) return MOCK_MISS + + const url = stripBase(source, fullUrl).split('?')[0] + const normalized = method ? method.toLowerCase() : 'get' + for (const route of table) { + if (route.method && route.method.toLowerCase() !== normalized) continue + if (typeof route.match === 'function') { + const params = route.match(url) + if (params) return typeof route.data === 'function' ? route.data(params) : route.data + } else if (route.match instanceof RegExp) { + const m = route.match.exec(url) + if (m) return typeof route.data === 'function' ? route.data(m) : route.data + } else if (route.match === url) { + return typeof route.data === 'function' ? route.data() : route.data + } + } + return MOCK_MISS +} + +export const mockRequest = (options) => { + const { source, url, method } = options + if (!source) return MOCK_MISS + return matchRoute(source, url || '', method) +} + +export default mockRequest diff --git a/app/services/network/index.js b/app/services/network/index.js index fd26a1f4..a24ebfff 100644 --- a/app/services/network/index.js +++ b/app/services/network/index.js @@ -6,6 +6,7 @@ import fetch from '../../utils/fetch' import cache from '../../utils/cache' import NewError from '../../utils/error' import { shadowImport } from '../../utils/files' +import sqlite from '../sqlite' const protocolRegex = /^https?:/ @@ -29,8 +30,10 @@ const fetchApi = (baseUrl, source, timeouts) => (options = {}) => { } const PREFIX = __dirname.split('/').slice(-1)[0] +const useSqlite = config.has('database.client') && config.get('database.client') === 'sqlite' const DELIVER = shadowImport(path.join(__dirname, 'lib'), { prefix: PREFIX, + excludes: useSqlite ? ['auth0.js', 'github.js', 'stat.js', 'user.js'] : [], loader: (fullpath, baseName) => { const module = require(fullpath) const handler = { @@ -64,6 +67,10 @@ const DELIVER = shadowImport(path.join(__dirname, 'lib'), { const handler = { get(_, name) { + if (useSqlite && sqlite[name]) { + return sqlite[name] + } + const key = `${PREFIX}.${name}` if (!DELIVER[Symbol.for(key)]) { throw new NewError.UnknownError(`[INVALIDATE SOURCE] unknown source ${name}`) diff --git a/app/services/network/lib/auth0.js b/app/services/network/lib/auth0.js index 84135495..7e80b8b4 100644 --- a/app/services/network/lib/auth0.js +++ b/app/services/network/lib/auth0.js @@ -2,20 +2,22 @@ import config from 'config' const ttl = 604800 -const auth0Credential = (function() { - const auth0Config = config.get("services.auth0") - const auth = auth0Config.auth +const auth0Credential = (() => { + const auth0Config = config.has('services.auth0') + ? config.get('services.auth0') + : {} + const { auth = {} } = auth0Config const domain = auth0Config.url || process.env.AUTH0_DOMAIN return { clientId: auth.clientId || process.env.AUTH0_CLIENT_ID, clientSecret: auth.clientSecret || process.env.AUTH0_CLIENT_SECRET, redirectUri: auth.redirectUri || process.env.AUTH0_REDIRECT_URI, audience: `${domain}/api/v2/`, - domain, + domain } })() -const getAccessToken = (code) => ({ +const getAccessToken = code => ({ url: '/oauth/token', method: 'post', headers: { @@ -30,7 +32,7 @@ const getAccessToken = (code) => ({ } }) -const getUserInfo = (accessToken) => ({ +const getUserInfo = accessToken => ({ url: '/userinfo', method: 'get', headers: { @@ -80,4 +82,4 @@ module.exports = { getManagementToken, getUserById, updateUser -} \ No newline at end of file +} diff --git a/app/services/sqlite/index.js b/app/services/sqlite/index.js new file mode 100644 index 00000000..113757ca --- /dev/null +++ b/app/services/sqlite/index.js @@ -0,0 +1,701 @@ +import fs from 'fs' +import path from 'path' +import crypto from 'crypto' +import config from 'config' +import { DatabaseSync } from 'node:sqlite' + +const DEFAULT_RESUME_SECTIONS = [ + { + id: 'info', + enabled: true, + canbeDisabled: false, + canbeReorder: false + }, + { + id: 'workExperiences', + enabled: true, + canbeDisabled: true, + canbeReorder: true + }, + { + id: 'personalProjects', + enabled: true, + canbeDisabled: true, + canbeReorder: true + }, + { + id: 'educations', + enabled: true, + canbeDisabled: true, + canbeReorder: true + }, + { + id: 'others', + enabled: true, + canbeDisabled: true, + canbeReorder: true + } +] + +const DEFAULT_REMINDER = { + enable: false, + type: 'quarterly', + email: '' +} + +const now = () => new Date().toISOString() + +const jsonStringify = value => JSON.stringify(value || null) + +const jsonParse = (value, fallback) => { + if (!value) return fallback + + try { + return JSON.parse(value) + } catch (e) { + return fallback + } +} + +const bool = value => Boolean(Number(value)) + +const getDatabaseFilename = () => { + if (config.has('database.filename')) { + return config.get('database.filename') + } + return 'data/hacknical.sqlite' +} + +const filename = getDatabaseFilename() +const databasePath = filename === ':memory:' + ? filename + : path.resolve(process.cwd(), filename) + +if (databasePath !== ':memory:') { + fs.mkdirSync(path.dirname(databasePath), { recursive: true }) +} + +const db = new DatabaseSync(databasePath) + +db.exec(` + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY, + login TEXT NOT NULL UNIQUE, + user_name TEXT NOT NULL, + avatar_url TEXT, + github_share INTEGER NOT NULL DEFAULT 0, + initialed INTEGER NOT NULL DEFAULT 1, + raw_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS resume_infos ( + user_id TEXT PRIMARY KEY, + login TEXT NOT NULL UNIQUE, + resume_hash TEXT NOT NULL UNIQUE, + open_share INTEGER NOT NULL DEFAULT 0, + simplify_url INTEGER NOT NULL DEFAULT 1, + use_github INTEGER NOT NULL DEFAULT 0, + template TEXT NOT NULL DEFAULT 'v1', + autosave INTEGER NOT NULL DEFAULT 0, + reminder_json TEXT, + resume_sections_json TEXT, + github_sections_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS resumes ( + user_id TEXT NOT NULL, + locale TEXT NOT NULL, + resume_json TEXT NOT NULL, + languages_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, locale), + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS stats ( + type TEXT NOT NULL, + action TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (type, action) + ); + + CREATE TABLE IF NOT EXISTS view_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT, + login TEXT, + platform TEXT, + browser TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS view_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + login TEXT, + type TEXT, + ip_info_json TEXT, + device TEXT, + browser TEXT, + platform TEXT, + action TEXT, + datetime TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); +`) + +const getUserRow = (qs = {}) => { + if (qs.userId) { + return db.prepare('SELECT * FROM users WHERE user_id = ?').get(qs.userId) + } + if (qs.login) { + return db.prepare('SELECT * FROM users WHERE login = ?').get(qs.login) + } + return null +} + +const userFromRow = (row) => { + if (!row) return null + + const raw = jsonParse(row.raw_json, {}) + return { + ...raw, + userId: row.user_id, + login: row.login, + githubLogin: row.login, + userName: row.user_name, + name: row.user_name, + avator: row.avatar_url || raw.avator || raw.avatar_url || '', + avatar_url: row.avatar_url || raw.avatar_url || raw.avator || '', + githubShare: bool(row.github_share), + initialed: bool(row.initialed), + created_at: row.created_at, + updated_at: row.updated_at + } +} + +const resumeInfoFromRow = (row) => { + if (!row) return null + + return { + userId: row.user_id, + login: row.login, + hash: row.resume_hash, + resumeHash: row.resume_hash, + openShare: bool(row.open_share), + simplifyUrl: bool(row.simplify_url), + useGithub: false, + template: row.template || 'v1', + autosave: bool(row.autosave), + reminder: jsonParse(row.reminder_json, { ...DEFAULT_REMINDER }), + resumeSections: jsonParse(row.resume_sections_json, [...DEFAULT_RESUME_SECTIONS]), + githubSections: [], + created_at: row.created_at, + updated_at: row.updated_at + } +} + +const ensureResumeInfo = (user) => { + if (!user) return null + + const existing = db + .prepare('SELECT * FROM resume_infos WHERE user_id = ?') + .get(user.userId) + if (existing) return resumeInfoFromRow(existing) + + const timestamp = now() + const hash = crypto.randomUUID().replace(/-/g, '') + db.prepare(` + INSERT INTO resume_infos ( + user_id, + login, + resume_hash, + open_share, + simplify_url, + use_github, + template, + autosave, + reminder_json, + resume_sections_json, + github_sections_json, + created_at, + updated_at + ) VALUES (?, ?, ?, 0, 1, 0, 'v1', 0, ?, ?, ?, ?, ?) + `).run( + user.userId, + user.githubLogin || user.login, + hash, + jsonStringify(DEFAULT_REMINDER), + jsonStringify(DEFAULT_RESUME_SECTIONS), + jsonStringify([]), + timestamp, + timestamp + ) + + return resumeInfoFromRow( + db.prepare('SELECT * FROM resume_infos WHERE user_id = ?').get(user.userId) + ) +} + +const getResumeInfoRow = (qs = {}) => { + if (qs.userId) { + return db.prepare('SELECT * FROM resume_infos WHERE user_id = ?').get(qs.userId) + } + if (qs.hash) { + return db.prepare('SELECT * FROM resume_infos WHERE resume_hash = ?').get(qs.hash) + } + if (qs.login) { + return db.prepare('SELECT * FROM resume_infos WHERE login = ?').get(qs.login) + } + return null +} + +const getResumeLanguages = (userId) => db + .prepare('SELECT locale FROM resumes WHERE user_id = ? ORDER BY updated_at DESC') + .all(userId) + .map(row => ({ + id: row.locale, + text: row.locale + })) + +const formatResumeRow = (row, info) => { + if (!row || !info) return null + + return { + userId: row.user_id, + login: info.login, + hash: info.resumeHash, + resumeHash: info.resumeHash, + locale: row.locale, + resume: jsonParse(row.resume_json, null), + languages: jsonParse(row.languages_json, getResumeLanguages(row.user_id)), + update_at: row.updated_at, + updated_at: row.updated_at, + created_at: row.created_at + } +} + +const getResumeRow = (qs = {}) => { + const info = qs.hash + ? resumeInfoFromRow(getResumeInfoRow({ hash: qs.hash })) + : resumeInfoFromRow(getResumeInfoRow({ userId: qs.userId })) + + if (!info) return null + + const locale = qs.locale || 'zh' + let row = db + .prepare('SELECT * FROM resumes WHERE user_id = ? AND locale = ?') + .get(info.userId, locale) + + if (!row) { + row = db + .prepare('SELECT * FROM resumes WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1') + .get(info.userId) + } + + return formatResumeRow(row, info) +} + +const createUser = (data = {}) => { + const login = String(data.login || data.githubLogin || data.userName || '').trim() + if (!login) return null + + const existing = getUserRow({ login }) + const timestamp = now() + const userName = data.userName || data.name || login + const avatarUrl = data.avator || data.avatar_url || '' + + if (existing) { + db.prepare(` + UPDATE users + SET user_name = ?, avatar_url = ?, raw_json = ?, updated_at = ? + WHERE user_id = ? + `).run( + userName, + avatarUrl, + jsonStringify(data), + timestamp, + existing.user_id + ) + const user = userFromRow(getUserRow({ login })) + ensureResumeInfo(user) + return user + } + + const userId = crypto.randomUUID() + db.prepare(` + INSERT INTO users ( + user_id, + login, + user_name, + avatar_url, + github_share, + initialed, + raw_json, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, 0, 1, ?, ?, ?) + `).run( + userId, + login, + userName, + avatarUrl, + jsonStringify(data), + timestamp, + timestamp + ) + + const user = userFromRow(getUserRow({ userId })) + ensureResumeInfo(user) + return user +} + +const getUser = qs => userFromRow(getUserRow(qs)) + +const updateUser = (userId, data = {}) => { + const user = getUser({ userId }) + if (!user) return null + + const timestamp = now() + const userName = data.userName || data.name || user.userName || user.login + const avatarUrl = data.avator || data.avatar_url || user.avatar_url || '' + const githubShare = data.githubShare === undefined ? user.githubShare : data.githubShare + const initialed = data.initialed === undefined ? user.initialed : data.initialed + + db.prepare(` + UPDATE users + SET user_name = ?, + avatar_url = ?, + github_share = ?, + initialed = ?, + raw_json = ?, + updated_at = ? + WHERE user_id = ? + `).run( + userName, + avatarUrl, + githubShare ? 1 : 0, + initialed ? 1 : 0, + jsonStringify({ ...user, ...data, login: user.login }), + timestamp, + userId + ) + + return getUser({ userId }) +} + +const getUserCount = () => { + const row = db.prepare('SELECT COUNT(*) AS count FROM users').get() + return row.count +} + +const getResume = qs => getResumeRow(qs) + +const updateResume = ({ + userId, + login, + resume, + locale = 'zh' +}) => { + const user = getUser({ userId }) || createUser({ login }) + const info = ensureResumeInfo(user) + const timestamp = now() + const existing = db + .prepare('SELECT * FROM resumes WHERE user_id = ? AND locale = ?') + .get(user.userId, locale) + + const languages = getResumeLanguages(user.userId) + const nextLanguages = languages.find(language => language.id === locale) + ? languages + : [{ id: locale, text: locale }, ...languages] + + db.prepare(` + INSERT INTO resumes ( + user_id, + locale, + resume_json, + languages_json, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, locale) DO UPDATE SET + resume_json = excluded.resume_json, + languages_json = excluded.languages_json, + updated_at = excluded.updated_at + `).run( + user.userId, + locale, + jsonStringify(resume), + jsonStringify(nextLanguages), + existing ? existing.created_at || timestamp : timestamp, + timestamp + ) + + db.prepare(` + UPDATE resume_infos + SET login = ?, updated_at = ?, use_github = 0 + WHERE user_id = ? + `).run(login || user.login, timestamp, user.userId) + + return { + ...getResumeInfo({ userId: user.userId }), + hash: info.resumeHash, + newResume: !existing + } +} + +const getResumeInfo = (qs = {}) => { + const info = resumeInfoFromRow(getResumeInfoRow(qs)) + if (info) return info + + let user = null + if (qs.userId) { + user = getUser({ userId: qs.userId }) + } else if (qs.login) { + user = getUser({ login: qs.login }) + } + return ensureResumeInfo(user) +} + +const setResumeInfo = ({ + userId, + login, + info = {} +}) => { + const user = getUser({ userId }) || createUser({ login }) + const current = ensureResumeInfo(user) + const timestamp = now() + + const next = { + ...current, + ...info, + useGithub: false, + login: login || current.login || user.login + } + + db.prepare(` + UPDATE resume_infos + SET login = ?, + open_share = ?, + simplify_url = ?, + use_github = 0, + template = ?, + autosave = ?, + reminder_json = ?, + resume_sections_json = ?, + github_sections_json = ?, + updated_at = ? + WHERE user_id = ? + `).run( + next.login, + next.openShare ? 1 : 0, + next.simplifyUrl ? 1 : 0, + next.template || 'v1', + next.autosave ? 1 : 0, + jsonStringify(next.reminder || DEFAULT_REMINDER), + jsonStringify(next.resumeSections || DEFAULT_RESUME_SECTIONS), + jsonStringify([]), + timestamp, + user.userId + ) + + return getResumeInfo({ userId: user.userId }) +} + +const getResumeCount = () => { + const row = db.prepare('SELECT COUNT(DISTINCT user_id) AS count FROM resumes').get() + return row.count +} + +const aggregate = (rows, key, targetKey = key) => { + const counts = rows.reduce((dict, row) => { + const value = row[key] || 'unknown' + dict[value] = (dict[value] || 0) + 1 + return dict + }, {}) + + return Object.keys(counts).map(value => ({ + [targetKey]: value, + count: counts[value] + })) +} + +const getRecords = (query = {}) => { + const conditions = [] + const params = [] + + if (query.login) { + conditions.push('login = ?') + params.push(query.login) + } + if (query.type) { + conditions.push('type = ?') + params.push(query.type) + } + + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '' + const rows = db + .prepare(`SELECT * FROM view_records ${where} ORDER BY created_at ASC`) + .all(...params) + + const pageViewCounts = rows.reduce((dict, row) => { + const date = row.created_at.slice(0, 13).replace('T', ' ') + dict[date] = (dict[date] || 0) + 1 + return dict + }, {}) + + return [{ + viewDevices: aggregate(rows, 'platform'), + viewSources: aggregate(rows, 'browser').map(item => ({ + ...item, + from: item.browser + })), + pageViews: Object.keys(pageViewCounts).map(date => ({ + date, + count: pageViewCounts[date] + })) + }] +} + +const getAllRecords = () => getRecords() + +const putRecords = (data = {}) => { + db.prepare(` + INSERT INTO view_records (type, login, platform, browser, created_at) + VALUES (?, ?, ?, ?, ?) + `).run( + data.type || '', + data.login || '', + data.platform || 'unknown', + data.browser || 'unknown', + now() + ) + return { success: true } +} + +const getLogs = (query = {}) => { + const rawQs = query.qs ? jsonParse(query.qs, {}) : query + const limit = Number(query.limit || 30) + const conditions = [] + const params = [] + + if (rawQs.login) { + conditions.push('login = ?') + params.push(rawQs.login) + } + if (rawQs.type) { + conditions.push('type = ?') + params.push(rawQs.type) + } + + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '' + return db + .prepare(`SELECT * FROM view_logs ${where} ORDER BY datetime DESC LIMIT ?`) + .all(...params, limit) + .map(row => ({ + _id: row.id, + login: row.login, + type: row.type, + ipInfo: jsonParse(row.ip_info_json, {}), + device: row.device, + browser: row.browser, + platform: row.platform, + action: row.action, + datetime: row.datetime, + createdAt: row.created_at, + updatedAt: row.updated_at + })) +} + +const putLogs = (data = {}) => { + const timestamp = now() + db.prepare(` + INSERT INTO view_logs ( + login, + type, + ip_info_json, + device, + browser, + platform, + action, + datetime, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + data.login || '', + data.type || '', + jsonStringify(data.ipInfo || {}), + data.device || '', + data.browser || '', + data.platform || '', + data.action || '', + data.datetime ? new Date(data.datetime).toISOString() : timestamp, + timestamp, + timestamp + ) + return { success: true } +} + +const getStat = (query = {}) => { + const rows = query.type + ? db.prepare('SELECT action, count FROM stats WHERE type = ?').all(query.type) + : db.prepare('SELECT action, count FROM stats').all() + + return rows.map(row => ({ + action: row.action, + count: row.count + })) +} + +const putStat = (data = {}) => { + const timestamp = now() + db.prepare(` + INSERT INTO stats (type, action, count, created_at, updated_at) + VALUES (?, ?, 1, ?, ?) + ON CONFLICT(type, action) DO UPDATE SET + count = count + 1, + updated_at = excluded.updated_at + `).run(data.type || '', data.action || '', timestamp, timestamp) + return { success: true } +} + +const noop = () => ({ success: true }) +const emptyList = () => [] + +export default { + user: { + getUser, + createUser, + updateUser, + getUserCount, + getResume, + updateResume, + getResumeInfo, + setResumeInfo, + getResumeCount + }, + stat: { + getStat, + putStat, + getLogs, + putLogs, + getRecords, + putRecords, + getAllRecords, + getNotifies: emptyList, + getUnreadNotifies: emptyList, + markNotifies: noop, + voteNotify: noop + } +} diff --git a/app/templates/github/desktop.html b/app/templates/github/desktop.html deleted file mode 100644 index 1af4e7d8..00000000 --- a/app/templates/github/desktop.html +++ /dev/null @@ -1,40 +0,0 @@ -{% extends "layouts/base.html" %} - -{% block css %} - {% if not user.isAdmin %} - - {% endif %} - -{% endblock %} - -{% block body %} -
-
-
-
-
-
-
-
-
- {% if not user.isAdmin %} - - {% endif %} -{% endblock %} - -{% block js %} - - -{% endblock %} diff --git a/app/templates/github/mobile.html b/app/templates/github/mobile.html deleted file mode 100644 index b120d9f7..00000000 --- a/app/templates/github/mobile.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "layouts/base.html" %} - -{% block css %} - -{% endblock %} - -{% block body %} -
-
-
-
-
-
-
-
- - {% if not user.isAdmin %} - - {% endif %} -{% endblock %} - -{% block js %} - - -{% endblock %} diff --git a/app/templates/layouts/base.html b/app/templates/layouts/base.html index e478fe6d..c41b0a09 100644 --- a/app/templates/layouts/base.html +++ b/app/templates/layouts/base.html @@ -13,7 +13,7 @@ - + @@ -60,7 +60,7 @@ - + {% block body %} {% endblock %} {% if not hideFooter %} @@ -72,8 +72,6 @@ window.device = "{{ device }}"; window.isMobile = "{{ isMobile }}"; - - {% block js %} {% endblock %} diff --git a/app/templates/user/login.html b/app/templates/user/login.html index fe24b493..7626bd8c 100644 --- a/app/templates/user/login.html +++ b/app/templates/user/login.html @@ -28,8 +28,5 @@ {% endblock %} {% block js %} - {% endblock %} diff --git a/app/utils/cache.js b/app/utils/cache.js index 7d2e528f..89c433da 100644 --- a/app/utils/cache.js +++ b/app/utils/cache.js @@ -1,8 +1,8 @@ -import cacheManager from 'cache-manager' +import { createCache } from 'cache-manager' import logger from './logger' -const cache = cacheManager.caching({ store: 'memory', max: 3000 }) +const cache = createCache() const getCacheKey = (args) => { let cacheKey = '' @@ -21,7 +21,8 @@ const getCacheKey = (args) => { } function wrapFn(fn, prefix = 'cache', options) { - const finallyOptions = options || {} + const ttlSeconds = (options && options.ttl) || 0 + const ttlMs = ttlSeconds * 1000 return (...args) => { let hitCache = true @@ -31,7 +32,7 @@ function wrapFn(fn, prefix = 'cache', options) { return cache.wrap(cacheKey, () => { hitCache = false return fn(...args) - }, finallyOptions).then((data) => { + }, ttlMs || undefined).then((data) => { if (hitCache) { logger.info(`[FUNC-CACHE:GET][${cacheKey}]`) } else { diff --git a/app/utils/constant/index.js b/app/utils/constant/index.js index cf2b3063..07199b64 100644 --- a/app/utils/constant/index.js +++ b/app/utils/constant/index.js @@ -1,14 +1,11 @@ export const VALIDATE_DASHBOARD = new Set([ - 'records', 'archive', - 'visualize', 'setting' ]) export const REQUIRED_SESSIONS = [ 'userId', - 'githubToken', 'githubLogin' ] diff --git a/app/utils/fetch.js b/app/utils/fetch.js index 4231b964..e6206e32 100644 --- a/app/utils/fetch.js +++ b/app/utils/fetch.js @@ -1,12 +1,31 @@ -import request from 'request' import config from 'config' import iconv from 'iconv-lite' import logger from './logger' import getSignature from './signature' import { REQUEST_JSON_METHODS } from './constant' import NewError from './error' +import { mockRequest, MOCK_MISS } from '../services/mock' const name = config.get('appName') +const mockEnabled = config.has('mock') && config.get('mock') + +const buildQueryString = (qs) => { + if (!qs) return '' + if (typeof qs === 'string') return qs + const params = new URLSearchParams() + Object.entries(qs).forEach(([key, value]) => { + if (value === undefined || value === null) return + params.append(key, String(value)) + }) + return params.toString() +} + +const appendQueryString = (url, qs) => { + const queryString = buildQueryString(qs) + if (!queryString) return url + const separator = url.includes('?') ? '&' : '?' + return `${url}${separator}${queryString}` +} const verify = (options = {}, appName = name) => { const { json = true } = options @@ -43,24 +62,88 @@ const verify = (options = {}, appName = name) => { } } -const fetchData = options => new Promise((resolve, reject) => { - request(options, (err, httpResponse, body) => { - if (err) { - reject(err) +const fetchData = async (options) => { + const { + url, + method = 'GET', + headers = {}, + body, + json = true, + qs, + timeout, + encoding + } = options + + const targetUrl = appendQueryString(url, qs) + + const init = { + method: method.toUpperCase(), + headers: { ...headers } + } + + if (body !== undefined && body !== null) { + if (Buffer.isBuffer(body) || typeof body === 'string') { + init.body = body + } else if (json) { + init.body = JSON.stringify(body) + if (!init.headers['Content-Type']) { + init.headers['Content-Type'] = 'application/json' + } + } else { + init.body = body } - if (body) { - if (Buffer.isBuffer(body)) { - resolve(iconv.decode(body, 'gbk')) + } + + let timer + if (timeout) { + const controller = new AbortController() + init.signal = controller.signal + timer = setTimeout(() => controller.abort(), timeout) + } + + try { + const response = await fetch(targetUrl, init) + + if (encoding === null) { + const arrayBuffer = await response.arrayBuffer() + return iconv.decode(Buffer.from(arrayBuffer), 'gbk') + } + + const text = await response.text() + + if (json) { + if (!text) { + throw new NewError.ServerError( + `Empty response when fetch: ${JSON.stringify(options)}` + ) + } + let parsed + try { + parsed = JSON.parse(text) + } catch (e) { + throw new NewError.ServerError( + `Invalid JSON response when fetch: ${JSON.stringify(options)} - ${text}` + ) } - resolve(body.result || body) + return parsed.result || parsed } - reject( - new NewError.ServerError(`Unknown Error when fetch: ${JSON.stringify(options)}`) - ) - }) -}) -const fetch = async (options, timeouts = [2000]) => { + return text + } finally { + if (timer) clearTimeout(timer) + } +} + +const fetchWithRetry = async (options, timeouts = [2000]) => { + if (mockEnabled) { + const mocked = mockRequest(options) + if (mocked !== MOCK_MISS) { + logger.info(`[FETCH:MOCK] ${options.source} ${options.method || 'GET'} ${options.url}`) + return mocked + } + logger.info(`[FETCH:MOCK-MISS] ${options.source} ${options.method || 'GET'} ${options.url}`) + } + verify(options) let err = null @@ -88,7 +171,7 @@ const handler = { return (...args) => { const [options, timeouts] = args options.method = method.toUpperCase() - return fetch(options, timeouts) + return fetchWithRetry(options, timeouts) } } } diff --git a/app/utils/logger.js b/app/utils/logger.js index 75911d5b..46e86fd6 100644 --- a/app/utils/logger.js +++ b/app/utils/logger.js @@ -4,17 +4,25 @@ import config from 'config' const logConfig = config.get('log') const appName = config.get('appName') +const category = `[${appName.toUpperCase()}]` -// Create a deep copy to avoid immutability issues with newer config versions -const logAppenderCopy = JSON.parse(JSON.stringify(logConfig.appender)) - +// log4js 6.x: appenders is a named map, categories wires them up log4js.configure({ - appenders: [ - logAppenderCopy - ] + appenders: { + out: { ...logConfig.appender } + }, + categories: { + default: { + appenders: ['out'], + level: logConfig.level + }, + [category]: { + appenders: ['out'], + level: logConfig.level + } + } }) -const logger = log4js.getLogger(`[${appName.toUpperCase()}]`) -logger.setLevel(logConfig.level) +const logger = log4js.getLogger(category) export default logger diff --git a/app/utils/redis.js b/app/utils/redis.js index 3a306af0..2e662d9f 100644 --- a/app/utils/redis.js +++ b/app/utils/redis.js @@ -3,7 +3,9 @@ import config from 'config' import IoRedis from 'ioredis' const appName = config.get('appName') -const redisConfig = config.get('services.redis') +const redisConfig = config.has('services.redis') + ? config.get('services.redis') + : {} let instance = null const redisConnect = async (prefix = appName.toUpperCase()) => { @@ -12,7 +14,7 @@ const redisConnect = async (prefix = appName.toUpperCase()) => { const dbConf = Object.assign({}, redisConfig, { keyPrefix: `${prefix}.` }) - const redisDB = await new IoRedis(dbConf) + const redisDB = new IoRedis(dbConf) instance = redisDB return instance } diff --git a/app/utils/uploader.js b/app/utils/uploader.js index 0df55940..e9f97d96 100644 --- a/app/utils/uploader.js +++ b/app/utils/uploader.js @@ -6,17 +6,43 @@ import oss from 'ali-oss' import logger from './logger' const g = (key, defaultValue) => process.env[key] || defaultValue || '' +const mockEnabled = config.has('mock') && config.get('mock') +const ossConfig = config.get('services.oss') +const accessKeyId = g('HACKNICAL_ALI_ACCESS_ID') +const accessKeySecret = g('HACKNICAL_ALI_ACCESS_KEY') +const hasCredentials = Boolean(accessKeyId && accessKeySecret) -const store = oss({ - accessKeyId: g('HACKNICAL_ALI_ACCESS_ID'), - accessKeySecret: g('HACKNICAL_ALI_ACCESS_KEY'), - bucket: config.get('services.oss.bucket'), - region: config.get('services.oss.region'), - internal: false -}) +let store = null + +const getStore = () => { + if (store) { + return store + } + + if (!hasCredentials) { + if (mockEnabled) { + logger.warn('[OSS] Missing OSS credentials in mock mode, falling back to local URLs') + return null + } + + throw new Error('require HACKNICAL_ALI_ACCESS_ID and HACKNICAL_ALI_ACCESS_KEY') + } + + store = oss({ + accessKeyId, + accessKeySecret, + bucket: ossConfig.bucket, + region: ossConfig.region, + internal: false + }) + + return store +} const nextTick = (func, ...params) => process.nextTick(async () => { + if (!func) return + try { await func(...params) } catch (e) { @@ -29,9 +55,15 @@ export const uploadFile = ({ filePath, prefix = '' }) => { const filename = filePath.split('/').slice(-1)[0] const storePrefix = path.join(prefix, filename) + const ossStore = getStore() logger.info(`[OSS:UPLOAD] ${filePath} -> ${storePrefix}`) - nextTick(store.put.bind(store), storePrefix, filePath) + if (!ossStore) { + logger.warn(`[OSS:SKIP] ${storePrefix}`) + return + } + + nextTick(ossStore.put.bind(ossStore), storePrefix, filePath) } export const uploadFolder = ({ folderPath, prefix = '' }) => { @@ -50,11 +82,15 @@ export const uploadFolder = ({ folderPath, prefix = '' }) => { } export const getUploadUrl = ({ filePath, expires = 60, mimeType }) => - store.signatureUrl(filePath, { - expires, - method: 'PUT', - 'Content-Type': mimeType - }) + (getStore() + ? getStore().signatureUrl(filePath, { + expires, + method: 'PUT', + 'Content-Type': mimeType + }) + : `${ossConfig.raw}${filePath}`) export const getOssObjectUrl = ({ filePath, baseUrl = '' }) => - store.generateObjectUrl(filePath, baseUrl) + (getStore() + ? getStore().generateObjectUrl(filePath, baseUrl) + : `${baseUrl}${filePath}`) diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json new file mode 100644 index 00000000..bda93c40 --- /dev/null +++ b/config/custom-environment-variables.json @@ -0,0 +1,28 @@ +{ + "database": { + "client": "HACKNICAL_DATABASE_CLIENT", + "filename": "HACKNICAL_SQLITE_PATH" + }, + "cache": { + "source": "HACKNICAL_CACHE_SOURCE" + }, + "services": { + "redis": { + "host": "HACKNICAL_REDIS_HOST", + "port": "HACKNICAL_REDIS_PORT" + }, + "besticon": { + "url": "HACKNICAL_BESTICON_URL" + }, + "ip": { + "url": "HACKNICAL_IP_SERVICE_URL" + }, + "oss": { + "bucket": "HACKNICAL_OSS_BUCKET", + "region": "HACKNICAL_OSS_REGION", + "prefix": "HACKNICAL_OSS_PREFIX", + "url": "HACKNICAL_OSS_URL", + "raw": "HACKNICAL_OSS_RAW_URL" + } + } +} diff --git a/config/default.json b/config/default.json index 3e7b783e..3ffa1132 100644 --- a/config/default.json +++ b/config/default.json @@ -2,14 +2,36 @@ "port": "", "appKey": "", "appName": "hacknical", - "links": { - "github": "https://github.com/ecmadao/hacknical", - "issue": "https://github.com/ecmadao/hacknical/issues", - "homePage": "https://github.com/ecmadao/hacknical" + "database": { + "client": "sqlite", + "filename": "data/hacknical.sqlite" + }, + "cache": { + "source": "memory" + }, + "services": { + "messenger": { + "slack": { + "channel": "" + }, + "email": { + "type": "", + "channel": "", + "template": "" + } + }, + "oss": { + "bucket": "", + "region": "", + "prefix": "/resumes", + "url": "", + "raw": "" + } + }, + "mq": { + "source": "memory" }, - "services": {}, "cdn": "", - "url": "", "log": { "appender": { "type": "console" diff --git a/config/localdev.json b/config/localdev.json index 58621093..88476b5a 100644 --- a/config/localdev.json +++ b/config/localdev.json @@ -2,56 +2,25 @@ "port": "4000", "appKey": "hacknical-key", "appName": "hacknical-local", + "mock": false, + "database": { + "client": "sqlite", + "filename": "data/hacknical-local.sqlite" + }, + "cache": { + "source": "memory" + }, "services": { - "redis": { - "host": "127.0.0.1", - "port": 6379, - "db": 1 - }, "besticon": { - "url": "http://127.0.0.1:8080", + "url": "http://127.0.0.1:8088", "timeouts": [10000, 10000, 10000], "auth": null }, - "github": { - "url": "http://127.0.0.1:5002/api", - "timeouts": [4000, 4000, 4000], - "refreshInterval": 6000, - "auth": { - "publicKey": "07a9c1c", - "secretKey": "6cf75c66dcc02caa7e0eef7170a9" - } - }, - "user": { - "url": "http://127.0.0.1:6001/api", - "timeouts": [4000, 4000, 4000], - "auth": { - "publicKey": "12345", - "secretKey": "6cf75c66dcc02caa7e0eef7170a9" - } - }, - "stat": { - "url": "http://127.0.0.1:6002/api", - "timeouts": [4000, 4000, 4000], - "auth": { - "publicKey": "12345", - "secretKey": "6cf75c66dcc02caa7e0eef7170a9" - } - }, "ip": { "url": "", "timeouts": [4000, 4000, 4000], "auth": null }, - "auth0": { - "url": "https://hacknical-local.us.auth0.com", - "timeouts": [4000, 4000, 4000], - "auth": { - "clientId": "", - "clientSecret": "", - "redirectUri": "http://localhost:4000/api/user/login/auth0" - } - }, "messenger": { "slack": { "channel": "hacknical" @@ -69,16 +38,5 @@ "url": "//files.hacknical.com", "raw": "http://hacknical-private.oss-cn-beijing.aliyuncs.com" } - }, - "mq": { - "source": "redis", - "config": { - "host": "127.0.0.1", - "port": 6379 - }, - "options": {}, - "channels": { - "messenger": "messenger-local" - } } } diff --git a/doc/ABOUT-en.md b/doc/ABOUT-en.md index 76a15d20..5165444f 100644 --- a/doc/ABOUT-en.md +++ b/doc/ABOUT-en.md @@ -19,13 +19,14 @@ Considering the issues raised above, Hacknical will attempt to optimize each ste Right now, the app works like this: - It provides a better resume fill process. -- It allows users to log in via github and grab their public repos, commits, languages, stars and followers to generate a more detailed visual summary report. -- It lets users select between a Github report for their resume, or the Hacknical generated online resume, so that the company they're applying to can know them more accurately. -- Users can share their Github summary report and generated resume at any time. +- It uses local username login without third-party OAuth. +- It stores users, resumes, and sharing settings in SQLite for simple single-server deployment. +- Users can choose whether their online resume is public or private. +- Users can export their online resume to PDF. ## How Do I Use It? -Users can use the app as a tool to view their Github summary report, attach the summary report to your resume and generate their own online resume through it, while optionally attaching the Github summary report. +Users can use the app to create an online resume, publish a share link, and export a PDF version for offline use. ## What's Next? @@ -33,17 +34,14 @@ Right now, Hacknical is in the beta stages and not everything works perfectly. **Planned Features** -- [x] Predict users' language preferences and trends by analyzing their Github star information - [x] Convert users' online resume to PDF - [x] Display resumes on mobile -- [x] Increase statistics available for organisations +- [x] SQLite local data storage - [ ] Mobile resume editing - [ ] More and better resume templates -- [ ] Increase statistics available contributions to forked projects **Known Issues** -- [x] Users' repos are not fully crawled - [x] After WeChat shares to a circle of friends, the page will be forced to reflow (requires https) [Raise an issue](https://github.com/ecmadao/hacknical/issues) if you have any other suggestions or find any other problems with the app. @@ -53,6 +51,6 @@ Right now, Hacknical is in the beta stages and not everything works perfectly. - Hacknical is an open source project licensed under the [Apache License](https://github.com/ecmadao/hacknical/blob/master/LICENSE). - The project repository is located on Github at [ecmadao/hacknical](https://github.com/ecmadao/hacknical). - This project is guaranteed to be free for users permanently. -- To see this app in action, take a look at my [resume]https://hacknical.com/ecmadao/resume) and [Github data analysis report](https://hacknical.com/ecmadao/github), both adapted for the mobile site. +- To see this app in action, take a look at my [resume](https://hacknical.com/ecmadao/resume), adapted for the mobile site. Leave any comments, suggestions or issues at the [issues page](https://github.com/ecmadao/hacknical/issues) to help me improve this app. Thank you. diff --git a/doc/ABOUT-fr.md b/doc/ABOUT-fr.md index 97b138e4..369d8956 100644 --- a/doc/ABOUT-fr.md +++ b/doc/ABOUT-fr.md @@ -21,15 +21,16 @@ 目前,功能如下: - 一个体验更好的简历填写流程。 -- 通过 github 登录,抓取你公开的 repos/commits/languages/star/followers 等信息,生成一张较为详细的可视化总结报告。 -- 你可以选择性的在自己的简历,或者 hacknical 生成的在线简历后面附件上这份 github 报告(推荐),以便让心仪的公司更加精准的了解你。 -- 可以随时选择 公开/私密 自己的在线简历以及 github 总结报告。 +- 本地用户名登录,不依赖第三方 OAuth。 +- 基于 SQLite 保存用户、简历与分享配置,方便单机部署。 +- 可以随时选择公开或私密自己的在线简历。 +- 支持将在线简历导出为 PDF。 ### How to use ? -- 可以单纯的把它作为查看自己 github 总结报告的工具 -- 可以把 github 总结报告附属在自己的简历上 -- 可以通过它生成自己的在线简历,并选择性的附加 github 总结报告 +- 可以把它作为自己的在线简历编辑器 +- 可以生成公开分享链接,投递或展示给他人 +- 可以导出 PDF,作为离线简历使用 ### Next ? @@ -37,16 +38,14 @@ **计划中的功能:** -- 通过分析用户的 github star 信息,来预测其技术偏好和趋势 -- 将在线简历转为 PDF -- 移动端简历的编辑和展示 -- 更多更好的简历模板 -- 增加对 orgs 的统计 -- 增加对 fork 项目的 contributions 的统计 +- [x] 将在线简历转为 PDF +- [x] 移动端简历展示 +- [x] SQLite 本地数据储存 +- [ ] 移动端简历编辑 +- [ ] 更多更好的简历模板 **已知的问题:** -- [x] 用户的 repos 没有抓取完全 - [x] 微信分享到朋友圈后,页面会被强制重排(需要 HTTPS) 如果有其他的建议,欢迎 [提出 issue](https://github.com/ecmadao/hacknical/issues) @@ -57,8 +56,6 @@ - 项目地址位于:[ecmadao/hacknical](https://github.com/ecmadao/hacknical) - 保证对用户永久免费 - 线上 DEMO - - [我的在线简历](https://hacknical.com/ecmadao/resume) -- -- 暂不支持移动端 - - [我的 github 数据分析报告](https://hacknical.com/ecmadao/github) -- -- 已适配移动端 + - [我的在线简历](https://hacknical.com/ecmadao/resume) -- -- 已适配移动端 你可以 [戳这里](https://github.com/ecmadao/hacknical/issues),通过 issue 提出你的意见和建议,帮助我更好的完善它,谢谢。 - diff --git a/doc/ABOUT-zh.md b/doc/ABOUT-zh.md index 1fccc259..b52ac65c 100644 --- a/doc/ABOUT-zh.md +++ b/doc/ABOUT-zh.md @@ -19,15 +19,16 @@ 目前,功能如下: - 一个体验更好的简历填写流程。 -- 通过 github 登录,抓取你公开的 repos/commits/languages/star/followers 等信息,生成一张较为详细的可视化总结报告。 -- 你可以选择性的在自己的简历,或者 hacknical 生成的在线简历后面附件上这份 github 报告(推荐),以便让心仪的公司更加精准的了解你。 -- 可以随时选择 公开/私密 自己的在线简历以及 github 总结报告。 +- 本地用户名登录,不依赖第三方 OAuth。 +- 基于 SQLite 保存用户、简历与分享配置,方便单机部署。 +- 可以随时选择公开或私密自己的在线简历。 +- 支持将在线简历导出为 PDF。 ### 你可以怎么玩? -- 可以单纯的把它作为查看自己 github 总结报告的工具 -- 可以把 github 总结报告附属在自己的简历上 -- 可以通过它生成自己的在线简历,并选择性的附加 github 总结报告 +- 可以把它作为自己的在线简历编辑器 +- 可以生成公开分享链接,投递或展示给他人 +- 可以导出 PDF,作为离线简历使用 ### Next ? @@ -35,17 +36,14 @@ **计划中的功能:** -- [x] 通过分析用户的 github star 信息,来预测其技术偏好和趋势 - [x] 将在线简历转为 PDF - [ ] 移动端简历的编辑 - [x] 移动端简历的展示 - [ ] 更多更好的简历模板 -- [x] 增加对 orgs 的统计 -- [ ] 增加对 fork 项目的 contributions 的统计 +- [x] SQLite 本地数据储存 **已知的问题:** -- [x] 用户的 repos 没有抓取完全 - [x] 微信分享到朋友圈后,页面会被强制重排(需要 https) 如果有其他的建议,欢迎 [提出 issue](https://github.com/ecmadao/hacknical/issues) @@ -57,7 +55,5 @@ - 保证对用户永久免费 - 线上 DEMO - [我的在线简历](https://hacknical.com/ecmadao/resume) -- -- 已适配移动端 - - [我的 github 数据分析报告](https://hacknical.com/ecmadao/github) -- -- 已适配移动端 你可以 [戳这里](https://github.com/ecmadao/hacknical/issues),通过 issue 提出你的意见和建议,帮助我更好的完善它,谢谢。 - diff --git a/doc/README-ZH.md b/doc/README-ZH.md index 490048a5..c5777a78 100644 --- a/doc/README-ZH.md +++ b/doc/README-ZH.md @@ -2,19 +2,17 @@ ![hacknical-logo-with-text](./screenshots/logos/hacknical-logo-large.png) -> hacknical 通过抓取用户的 github 数据,来形成一个可视化展示的 github 分析报告,以此辅助用户更好的完善自己的简历。 +> hacknical 是一个轻量级在线简历编辑与分享工具,使用 SQLite 进行本地数据储存。 [English Version of README](../README.md) -抽离依赖: +## 功能 -- UI 组件 --> [light-ui](https://github.com/ecmadao/light-ui) -- GitHub API 爬虫 --> [hacknical-github](https://github.com/ecmadao/hacknical-github) - -## 案例 - -- [我的 github 数据分析报告](http://hacknical.com/ecmadao/github) -- [我的在线简历](http://hacknical.com/ecmadao/resume) +- 本地用户名登录,不依赖第三方 OAuth。 +- 创建、编辑、分享并导出在线简历。 +- 使用 SQLite 保存用户、简历、分享设置和基础统计。 +- 可选接入 `besticon` 服务,用于抓取网站 favicon。 +- 可选接入阿里云 OSS,用于头像和 PDF 上传链路。 ## 截图 @@ -22,78 +20,63 @@ ![login page](./screenshots/login-zh.png) -> github 数据分析 +## 本地开发 -![github datas](./screenshots/github-zh.png) +环境要求: -## 关于 +- Node.js 22 +- npm 10 -更加详细的 [项目说明](./doc/ABOUT-zh.md) +```bash +git clone git@github.com:ecmadao/hacknical.git +cd hacknical +npm ci +npm run start-app +``` -## 参与贡献 +服务默认运行在 `http://localhost:4000`,本地 SQLite 数据库会写入 `data/hacknical-local.sqlite`。 -### 架构说明 +也可以使用 Docker 开发环境: -hacknical 目前拆分成为了两个 server,以及一个 UI 组件库: +```bash +docker compose up -d --build +docker compose logs -f app +``` -- [hacknical](https://github.com/ecmadao/hacknical) 主 server,除去前端渲染等作用外,链接用户管理、用户简历等数据库 -- [hacknical-github](https://github.com/ecmadao/hacknical-github) 负责提供 GitHub 数据的 server,负责用户 GitHub 数据的抓取、存储 -- [light-ui](https://github.com/ecmadao/light-ui) 一个 React UI 组件库 +Docker 环境会启动 app 和 `besticon`。默认缓存和消息队列都使用内存实现,因此不再启动 Redis。 -在储存方面,使用 redis 做缓存,并统一使用 MongoDB 作为数据库储存。 +## 配置 -### 本地开发 +基础配置位于 `config/default.json`,本地开发覆盖项位于 `config/localdev.json`,环境变量覆盖关系位于 `config/custom-environment-variables.json`。 -```bash -$ git clone git@github.com:ecmadao/hacknical.git -$ git clone git@github.com:ecmadao/hacknical-github.git +常用环境变量: -$ cd hacknical -$ npm i -$ cd hacknical-github -$ npm i +- `PORT`:服务端口;`config/localdev.json` 默认是 `4000`。 +- `HACKNICAL_DATABASE_CLIENT`:储存后端,默认是 `sqlite`。 +- `HACKNICAL_SQLITE_PATH`:SQLite 数据库文件路径。 +- `HACKNICAL_BESTICON_URL`:favicon 服务地址。 +- `HACKNICAL_CACHE_SOURCE`:缓存后端,默认是 `memory`。 +- `HACKNICAL_REDIS_HOST` / `HACKNICAL_REDIS_PORT`:当 `HACKNICAL_CACHE_SOURCE=redis` 时使用的可选 Redis 连接配置。 +- `HACKNICAL_ALI_ACCESS_ID` / `HACKNICAL_ALI_ACCESS_KEY`:阿里云 OSS 凭证,头像上传和 PDF 上传链路会用到。 -# 安装 redis 以及 MongoDB -# 具体安装教程忽略,请自行搜索 -``` +## 关于 -在 [OAuth application](https://github.com/settings/applications/new) 注册一个新应用以便进行本地开发,配置如下: +更加详细的 [项目说明](./ABOUT-zh.md) -```text -Application name: hacknical-local -Homepage URL: http://localhost:4000/ -Authorization callback URL: http://localhost:4000/user/login/github -``` +## 参与贡献 -注册成功之后,将获取的 `Client ID` 、`Client Secret` 以及 `Application name` 填充至 `hacknical-github/config/localdev.json` 文件中: - -```json -// hacknical-github/config/localdev.json -{ - "production": false, - "port": "5002", - "appKey": "hacknical-github-local", - "appName": "hacknical-github-local", - "app": { - "hacknical-local": { - "clientId": "将你的 Client ID 填充至此", - "clientSecret": "将你的 Client Secret 填充至此", - "appName": "hacknical-local", - "token": "" - } - } -} -``` +### 架构说明 -除此以外,还可以在 `hacknical/config/localdev.json` 以及 `hacknical-github/config/localdev.json` 中修改数据库链接、server 端口等配置。 +hacknical 目前由一个主 server 和一个 UI 组件库组成: -### 提交说明 +- [hacknical](https://github.com/ecmadao/hacknical) 主 server,负责前端渲染、用户登录、简历编辑与分享 +- [light-ui](https://github.com/ecmadao/light-ui) React UI 组件库 -#### Bug 修复 +### 提交说明 Bug 相关的修复可以直接发起 pull request,当然也欢迎在 issue 中指出,我会尽快进行修复。 -#### 新 feature +新 feature: - 在相关项目下开启新 issue - 同步最新 master 分支 @@ -102,38 +85,19 @@ Bug 相关的修复可以直接发起 pull request,当然也欢迎在 issue ## Todos - [x] 支持英语 -- [x] 支持抓取 github 上的组织 -- [ ] 支持分析用户 fork 的项目 +- [x] 支持 SQLite 本地储存 - [ ] 支持移动端简历编辑 - [x] 支持移动端简历展示 - [x] 支持简历导出 ## 技术栈 -- backend - - - koa2 - - redis - - mongoose - - nunjucks - - request - - pm2 - -- frontend - - - react - - redux - - react-router - - particles - - scrollreveal - - chart.js - - clipboard - - headroom.js - - webpack +- backend: Node.js 22, Koa, SQLite, Nunjucks, Puppeteer, PM2 +- frontend: React 19, Redux, React Router, Chart.js, Webpack 5 ## License -[Apache License](./LICENSE) +[Apache License](../LICENSE) ## Author diff --git a/doc/UPGRADE-NODE22-REACT19-TODO.md b/doc/UPGRADE-NODE22-REACT19-TODO.md new file mode 100644 index 00000000..4814080b --- /dev/null +++ b/doc/UPGRADE-NODE22-REACT19-TODO.md @@ -0,0 +1,22 @@ +# Node 22 / React 19 Upgrade TODO + +## Done + +- Pin the runtime contract to Node 22 and npm 10 in `package.json`. +- Add `.npmrc` so npm 10 can consistently install the legacy `light-ui` peer dependency tree. +- Make the dev Docker image install from `package-lock.json` with `npm ci`. +- Copy `patches/` into the Docker build so `patch-package` applies the React 19 `light-ui` fixes. +- Keep the React 19 root API migration on `createRoot`. +- Replace function-component `defaultProps` with JavaScript default parameters. +- Verify there are no remaining function-component `defaultProps`. +- Verify `npm ci`, `npm run lint`, `npm run build-src`, and `npm run build-app`. +- Trim the default/localdev config to SQLite, memory cache/queue, OSS placeholders, and active local services. +- Remove the unused Redis service from the Docker development stack. +- Refresh README configuration and local development instructions. + +## Remaining + +- Replace or fork `light-ui`; it still declares React 16 peer dependencies, so `.npmrc` is currently required. +- Remove the old `core-js@2` chain pulled by `mq-utils` / Babel 6 transitive dependencies. +- Review bundle splitting later; the webpack build still reports existing asset-size warnings. +- Decide whether to remove the tracked duplicate `GitHub` / `Github` path casing once the team can handle a case-only rename safely. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..e27abfc1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +# Hacknical 本地开发环境 +# 使用 Node 22 开发镜像,整个 app 跑在容器里,避免宿主机依赖差异 +# +# 启动: docker compose up -d --build +# 查看日志: docker compose logs -f app +# 进入容器: docker compose exec app bash +# 停止: docker compose down +# 清空数据: docker compose down -v + +services: + besticon: + # 抓取网站 favicon 的小服务,对应 services.besticon + image: matthiasluedtke/iconserver:latest + container_name: hacknical-besticon + ports: + - "127.0.0.1:8088:8080" + restart: unless-stopped + + app: + build: + context: . + dockerfile: Dockerfile.dev + container_name: hacknical-app + ports: + - "4000:4000" + environment: + - NODE_ENV=localdev + - DEBUG=hacknical-local* + - HACKNICAL_BESTICON_URL=http://besticon:8080 + - HACKNICAL_DATABASE_CLIENT=sqlite + - HACKNICAL_SQLITE_PATH=/app/data/hacknical-local.sqlite + # 假的 OSS 凭证,让 ali-oss 模块能加载(本地不会真正上传文件) + - HACKNICAL_ALI_ACCESS_ID=dummy-id + - HACKNICAL_ALI_ACCESS_KEY=dummy-key + volumes: + # 把源码挂进去做热更新 + - ./app:/app/app + - ./frontend:/app/frontend + - ./config:/app/config + - ./public:/app/public + - hacknical-sqlite-data:/app/data + - ./scripts:/app/scripts + - ./.babelrc:/app/.babelrc + - ./.eslintrc:/app/.eslintrc + - ./.eslintignore:/app/.eslintignore + # git-rev-sync 需要读 commit hash 做版本号 + - ./.git:/app/.git:ro + - ./package.json:/app/package.json + - ./package-lock.json:/app/package-lock.json + - ./.npmrc:/app/.npmrc + - ./patches:/app/patches + - ./nodemon.json:/app/nodemon.json + # node_modules 用容器自己的,不被宿主覆盖 + - hacknical-node-modules:/app/node_modules + depends_on: + besticon: + condition: service_started + restart: unless-stopped + +volumes: + hacknical-sqlite-data: + hacknical-node-modules: diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 00000000..17c7d20f --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,13 @@ +module.exports = { + apps: [{ + name: 'hacknical', + script: 'dist/bin/app.js', + env: { + NODE_ENV: 'production' + }, + merge_logs: true, + out_file: '/var/log/ecmadao/hacknical/forever_log', + error_file: '/var/log/ecmadao/hacknical/error_log', + kill_timeout: 5000 + }] +} diff --git a/frontend/api/index.js b/frontend/api/index.js index 84f64608..3cfcacfb 100644 --- a/frontend/api/index.js +++ b/frontend/api/index.js @@ -1,14 +1,10 @@ import user from './user' import resume from './resume' -import github from './github' import home from './home' -import scientific from './scientific' export default { user, home, - resume, - github, - scientific + resume } diff --git a/frontend/api/user.js b/frontend/api/user.js index 962ed919..5c0923a6 100644 --- a/frontend/api/user.js +++ b/frontend/api/user.js @@ -2,10 +2,10 @@ import API from './base' const logout = () => API.get('/user/logout') +const login = loginName => API.post('/user/login', { login: loginName }) -const getUserInfo = login => API.get('/user/info', { login }) +const getUserInfo = loginName => API.get('/user/info', { login: loginName }) const patchUserInfo = info => API.patch('/user/info', { info }) -const getGitHubSections = login => API.get('/user/github', { login }) const initialed = () => API.patch('/user/initialed') @@ -14,11 +14,11 @@ const getNotifies = () => API.get('/user/notifies') const voteNotify = (messageId, data) => API.patch(`/user/notifies/${messageId}`, data) export default { + login, logout, initialed, getUserInfo, patchUserInfo, - getGitHubSections, // notify markNotifies, getNotifies, diff --git a/frontend/components/AsyncComponent/index.jsx b/frontend/components/AsyncComponent/index.jsx index 50b72323..6c530d3a 100644 --- a/frontend/components/AsyncComponent/index.jsx +++ b/frontend/components/AsyncComponent/index.jsx @@ -1,16 +1,43 @@ import React from 'react' -const asyncComponent = getComponent => +const normalizeComponent = (Component) => { + let result = Component + + while (result && typeof result === 'object' && result.default) { + result = result.default + } + + return result +} + +const getOptions = (input) => { + if (typeof input === 'function') { + return { + resolve: input, + LoadingComponent: null + } + } + + return { + resolve: input.resolve, + LoadingComponent: input.LoadingComponent || null + } +} + +const asyncComponent = input => { + const options = getOptions(input) + class AsyncComponent extends React.Component { static Component = null state = { Component: AsyncComponent.Component } - componentWillMount() { + componentDidMount() { if (!this.state.Component) { - getComponent().then(Component => { - AsyncComponent.Component = Component - this.setState({ Component }) + options.resolve().then(Component => { + const LoadedComponent = normalizeComponent(Component) + AsyncComponent.Component = LoadedComponent + this.setState({ Component: LoadedComponent }) }) } } @@ -20,8 +47,12 @@ const asyncComponent = getComponent => if (Component) { return } - return null + + const { LoadingComponent } = options + return LoadingComponent ? : null } } + return AsyncComponent +} export default asyncComponent diff --git a/frontend/components/Avator/index.jsx b/frontend/components/Avator/index.jsx index d6865896..5b491eb2 100644 --- a/frontend/components/Avator/index.jsx +++ b/frontend/components/Avator/index.jsx @@ -1,7 +1,7 @@ import React from 'react' import cx from 'classnames' -import Img from 'react-image' +import { Img } from 'react-image' import { Loading } from 'light-ui' import styles from './styles.css' diff --git a/frontend/components/Count/BaseCount.jsx b/frontend/components/Count/BaseCount.jsx index 051048dd..0f8282e8 100644 --- a/frontend/components/Count/BaseCount.jsx +++ b/frontend/components/Count/BaseCount.jsx @@ -16,17 +16,14 @@ class BaseCount extends React.Component { this.start() } - componentDidUpdate(preProps) { - const { end } = preProps - if (this.props.end !== end) { - this.start() + componentDidUpdate(prevProps) { + const { end, start } = prevProps + if (this.props.start !== start) { + this.setState({ current: this.props.start }) } - } - componentWillReceiveProps(nextProps) { - const { start } = nextProps - if (start !== this.props.start) { - this.setState({ current: start }) + if (this.props.end !== end) { + this.start() } } diff --git a/frontend/components/DateSlider/index.jsx b/frontend/components/DateSlider/index.jsx index 1a3653eb..d7027e16 100644 --- a/frontend/components/DateSlider/index.jsx +++ b/frontend/components/DateSlider/index.jsx @@ -41,8 +41,21 @@ class DateSlider extends React.Component { }) } - componentWillReceiveProps(nextProps) { - const { initialStart, initialEnd, maxDate } = nextProps + componentDidUpdate(prevProps) { + const { + initialStart, + initialEnd, + maxDate + } = this.props + + if ( + prevProps.initialStart === initialStart && + prevProps.initialEnd === initialEnd && + prevProps.maxDate === maxDate + ) { + return + } + this.setState({ startDate: initialStart, endDate: initialEnd || maxDate diff --git a/frontend/components/DragAndDrop/index.jsx b/frontend/components/DragAndDrop/index.jsx index 4c44d4f3..7134c312 100644 --- a/frontend/components/DragAndDrop/index.jsx +++ b/frontend/components/DragAndDrop/index.jsx @@ -3,7 +3,7 @@ import React from "react" import cx from 'classnames' import PropTypes from 'prop-types' import styles from './dad.css' -import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd" +import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd" class DragAndDrop extends React.Component { constructor(props) { diff --git a/frontend/components/Favicon/index.jsx b/frontend/components/Favicon/index.jsx index 5ddf24ee..1363be6a 100644 --- a/frontend/components/Favicon/index.jsx +++ b/frontend/components/Favicon/index.jsx @@ -1,58 +1,61 @@ import React from 'react' -import Img from 'react-image' +import { Img } from 'react-image' import cx from 'classnames' import { Loading } from 'light-ui' import styles from './favicon.css' const defaultIcon = require('SRC/images/browser.png') -const getIconUrls = (props) => { - const res = [props.fallback] +const getIconUrls = ({ + fallback, + size, + src = '' +}) => { + const res = [fallback] - const website = props.src.replace(/^(https?:)?\/\//, '') - if (props.src && props.size && website) { + const website = src.replace(/^(https?:)?\/\//, '') + if (src && size && website) { res.unshift( - `/api/icon?url=${website}&size=${props.size}` + `/api/icon?url=${website}&size=${size}` ) } return res } -const Favicon = props => ( +const Favicon = ({ + fallback = defaultIcon, + name = 'favicon', + size = '80', + className = '', + loader = null, + unloader = null, + src = '' +}) => ( {props.name} ) } unloader={ - props.unloader || ( + unloader || ( {props.name} ) } - className={cx(styles.favicon, props.className)} + className={cx(styles.favicon, className)} /> ) -Favicon.defaultProps = { - fallback: defaultIcon, - name: 'favicon', - size: '80', - className: '', - loader: null, - unloader: null -} - export default Favicon diff --git a/frontend/components/GitHub/CommitInfo/index.jsx b/frontend/components/GitHub/CommitInfo/index.jsx index f10ab64e..889f08d8 100644 --- a/frontend/components/GitHub/CommitInfo/index.jsx +++ b/frontend/components/GitHub/CommitInfo/index.jsx @@ -1,7 +1,7 @@ import React from 'react' import cx from 'classnames' -import Chart from 'chart.js' +import Chart from 'chart.js/auto' import objectAssign from 'UTILS/object-assign' import { Loading, InfoCard, CardGroup } from 'light-ui' @@ -169,23 +169,25 @@ class CommitInfo extends React.Component { }, options: { scales: { - xAxes: [{ - gridLines: { + x: { + grid: { display: false } - }], - yAxes: [{ - gridLines: { + }, + y: { + grid: { display: false }, ticks: { beginAtZero: true } - }], + }, }, - tooltips: { - callbacks: { - label: item => `${item.yLabel} commits` + plugins: { + tooltip: { + callbacks: { + label: item => `${item.yLabel} commits` + } } } } diff --git a/frontend/components/GitHub/Hotmap/index.jsx b/frontend/components/GitHub/Hotmap/index.jsx index e1610dab..8035c29d 100644 --- a/frontend/components/GitHub/Hotmap/index.jsx +++ b/frontend/components/GitHub/Hotmap/index.jsx @@ -2,7 +2,7 @@ import React from 'react' import cx from 'classnames' -import CalHeatMap from 'cal-heatmap' +import CalHeatmap from 'cal-heatmap' import 'cal-heatmap/cal-heatmap.css' import { Loading, InfoCard, CardGroup } from 'light-ui' import styles from '../styles/github.css' @@ -12,47 +12,142 @@ import dateHelper from 'UTILS/date' import Icon from 'COMPONENTS/Icon' const githubTexts = locales('github.sections.hotmap') +const HOTMAP_COLORS = ['#ededed', '#dae289', '#8cc665', '#44a340', '#3b6427'] + +const normalizeHotmapData = (datas = {}) => Object.keys(datas).map(timestamp => ({ + date: Number(timestamp) * 1000, + value: datas[timestamp] +})) + +const getScaleThresholds = (levelRanges = [], hotmapData = []) => { + const thresholdCount = HOTMAP_COLORS.length - 1 + const normalizedRanges = levelRanges + .slice(1) + .map(level => Number(level)) + .filter(level => Number.isFinite(level) && level > 0) + .sort((left, right) => left - right) + + if (normalizedRanges.length >= thresholdCount) { + return normalizedRanges.slice(0, thresholdCount) + } + + const maxValue = Math.max( + thresholdCount, + ...hotmapData.map(item => Number(item.value) || 0) + ) + + return Array.from({ length: thresholdCount }, (_, index) => + Math.max(index + 1, Math.ceil(((index + 1) * maxValue) / thresholdCount)) + ) +} class Hotmap extends React.Component { constructor(props) { super(props) - this.githubCalendar = false + this.githubCalendar = null + this.hotmapRoot = null + this.handleNext = this.handleNext.bind(this) + this.handlePrevious = this.handlePrevious.bind(this) + this.setHotmapRoot = this.setHotmapRoot.bind(this) } - componentDidUpdate() { - if (!this.githubCalendar && $('#cal-heatmap')[0]) { + componentDidMount() { + if (this.props.loaded) { this.renderHotmap() } } - renderHotmap() { + componentDidUpdate(preProps) { const { loaded, data } = this.props - if (!loaded) return - this.githubCalendar = true + if ( + loaded + && (!this.githubCalendar || loaded !== preProps.loaded || data !== preProps.data) + ) { + this.renderHotmap() + } + } + + componentWillUnmount() { + if (this.githubCalendar) { + this.githubCalendar.destroy() + this.githubCalendar = null + } + } + + setHotmapRoot(ref) { + this.hotmapRoot = ref + } + + handlePrevious() { + if (this.githubCalendar) { + this.githubCalendar.previous() + } + } + + handleNext() { + if (this.githubCalendar) { + this.githubCalendar.next() + } + } + + async renderHotmap() { + const { loaded, data } = this.props + if (!loaded || !this.hotmapRoot) return + const local = formatLocale() - const cal = new CalHeatMap() const { - datas, - levelRanges + datas = {}, + levelRanges = [] } = (data || {}) + const hotmapData = normalizeHotmapData(datas) + const thresholds = getScaleThresholds(levelRanges, hotmapData) + const startTimestamp = hotmapData.length + ? Math.min(...hotmapData.map(item => item.date)) + : new Date(dateHelper.date.beforeYears(1)).getTime() + const endTimestamp = hotmapData.length + ? Math.max(...hotmapData.map(item => item.date)) + : Date.now() + + if (this.githubCalendar) { + await this.githubCalendar.destroy() + } + + const calendar = new CalHeatmap() + this.githubCalendar = calendar - cal.init({ - domain: 'month', - start: new Date(dateHelper.date.beforeYears(1)), - data: datas, - weekStartOnMonday: local === 'zh-CN', - subDomain: 'day', + await calendar.paint({ + itemSelector: this.hotmapRoot, range: 13, - displayLegend: false, - previousSelector: '#hotmap-left', - nextSelector: '#hotmap-right', - legend: levelRanges, - domainLabelFormat: '%Y-%m', - legendColors: { - min: '#dae289', - max: '#3b6427', - empty: '#ededed' + domain: { + type: 'month', + gutter: 4 + }, + subDomain: { + type: 'ghDay', + width: 11, + height: 11, + gutter: 4, + radius: 2 + }, + date: { + start: new Date(startTimestamp), + min: new Date(startTimestamp), + max: new Date(endTimestamp), + locale: local === 'zh-CN' ? { weekStart: 1 } : 'en' + }, + data: { + source: hotmapData, + x: 'date', + y: 'value', + defaultValue: 0 + }, + scale: { + color: { + type: 'threshold', + range: HOTMAP_COLORS, + domain: thresholds + } } }) } @@ -139,12 +234,20 @@ class Hotmap extends React.Component { )} > -
+
-
+
-
+
@@ -163,7 +266,7 @@ Hotmap.defaultProps = { start: '', total: '', streak: '', - datas: [], + datas: {}, levelRanges: [] } } diff --git a/frontend/components/GitHub/LanguageInfo/index.jsx b/frontend/components/GitHub/LanguageInfo/index.jsx index ec1e9883..381a35ec 100644 --- a/frontend/components/GitHub/LanguageInfo/index.jsx +++ b/frontend/components/GitHub/LanguageInfo/index.jsx @@ -28,12 +28,12 @@ class LanguageInfo extends React.Component { this.setShowLanguage = this.setShowLanguage.bind(this) } - componentWillReceiveProps(nextProps) { - const { loaded } = this.props - if (!loaded && nextProps.loaded) { + componentDidUpdate(prevProps) { + const { loaded, data } = this.props + if (!prevProps.loaded && loaded) { this.setState({ - showLanguage: Object.keys(this.props.data.languages || {}) - .sort(github.sortByLanguage(this.props.data.languages || {}))[0] + showLanguage: Object.keys(data.languages || {}) + .sort(github.sortByLanguage(data.languages || {}))[0] }) } } diff --git a/frontend/components/GitHub/OrganizationsInfo/ContributionChart.jsx b/frontend/components/GitHub/OrganizationsInfo/ContributionChart.jsx index 72bd5f35..c46f940a 100644 --- a/frontend/components/GitHub/OrganizationsInfo/ContributionChart.jsx +++ b/frontend/components/GitHub/OrganizationsInfo/ContributionChart.jsx @@ -1,7 +1,7 @@ import React from 'react' import PropTypes from 'prop-types' -import Chart from 'chart.js' +import Chart from 'chart.js/auto' import cx from 'classnames' import objectAssign from 'UTILS/object-assign' import { Tipso, Label } from 'light-ui' @@ -80,32 +80,34 @@ class ContributionChart extends React.Component { options: { responsive: true, maintainAspectRatio: false, - title: { - display: false, - text: '' - }, - legend: { - display: false, + plugins: { + title: { + display: false, + text: '' + }, + legend: { + display: false, + }, }, scales: { - xAxes: [{ + x: { display: false, - gridLines: { + grid: { display: false }, ticks: { beginAtZero: true } - }], - yAxes: [{ + }, + y: { display: false, - gridLines: { + grid: { display: false }, ticks: { beginAtZero: true } - }], + }, } } }) diff --git a/frontend/components/GitHub/OrganizationsInfo/index.jsx b/frontend/components/GitHub/OrganizationsInfo/index.jsx index 39c67d0c..9219d66a 100644 --- a/frontend/components/GitHub/OrganizationsInfo/index.jsx +++ b/frontend/components/GitHub/OrganizationsInfo/index.jsx @@ -107,7 +107,7 @@ class OrganizationsInfo extends React.Component { className={itemClass} onClick={() => this.changeAcitveOrganization(index)} > - org-avatar + org-avatar {name || login}
diff --git a/frontend/components/GitHub/RepositoryInfo/index.jsx b/frontend/components/GitHub/RepositoryInfo/index.jsx index e0fd9248..e568c733 100644 --- a/frontend/components/GitHub/RepositoryInfo/index.jsx +++ b/frontend/components/GitHub/RepositoryInfo/index.jsx @@ -1,6 +1,6 @@ import React from 'react' -import Chart from 'chart.js' +import Chart from 'chart.js/auto' import cx from 'classnames' import { Loading, InfoCard, CardGroup } from 'light-ui' @@ -106,24 +106,26 @@ class RepositoryInfo extends React.Component { labels: github.getReposNames(ownedRepositories) }, options: { - title: { - display: true, - text: githubTexts.chartTitle + plugins: { + title: { + display: true, + text: githubTexts.chartTitle + }, }, scales: { - xAxes: [{ - gridLines: { + x: { + grid: { display: false } - }], - yAxes: [{ - gridLines: { + }, + y: { + grid: { display: false }, ticks: { beginAtZero: true } - }] + } }, } }) diff --git a/frontend/components/GitHub/SocialInfo/index.jsx b/frontend/components/GitHub/SocialInfo/index.jsx index 9ba0d0a3..0088c0fd 100644 --- a/frontend/components/GitHub/SocialInfo/index.jsx +++ b/frontend/components/GitHub/SocialInfo/index.jsx @@ -8,53 +8,49 @@ import { ClassicText, InfoCard, CardGroup } from 'light-ui' const githubTexts = locales('github.sections.social') -const SocialInfo = (props) => { - const { - user, - showLink, - sideTextStyle - } = props - - return ( - - ) -} +const SocialInfo = ({ + user = {}, + showLink = true, + sideTextStyle = {} +}) => ( + +) SocialInfo.propTypes = { user: PropTypes.object, @@ -64,12 +60,4 @@ SocialInfo.propTypes = { showLink: PropTypes.bool } -SocialInfo.defaultProps = { - user: {}, - style: null, - mainTextStyle: {}, - sideTextStyle: {}, - showLink: true -} - export default SocialInfo diff --git a/frontend/components/GitHub/Statistic/index.jsx b/frontend/components/GitHub/Statistic/index.jsx index 8b561aa0..8280d7c6 100644 --- a/frontend/components/GitHub/Statistic/index.jsx +++ b/frontend/components/GitHub/Statistic/index.jsx @@ -1,6 +1,6 @@ import React from 'react' import deepcopy from 'deepcopy' -import Chart from 'chart.js' +import Chart from 'chart.js/auto' import styles from '../styles/statistic.css' import githubStyles from '../styles/github.css' import cardStyles from '../styles/info_card.css' @@ -51,7 +51,7 @@ class Statistic extends React.Component { const radarConfig = deepcopy(RADAR_CONFIG) radarConfig.data.labels = labels radarConfig.data.datasets[0].data = datas - radarConfig.options.title.text = title + radarConfig.options.plugins.title.text = title this[pointTo] = new Chart(ref, radarConfig) } diff --git a/frontend/components/GitHub/UserInfo/index.jsx b/frontend/components/GitHub/UserInfo/index.jsx index 6de6c1e3..8b25626f 100644 --- a/frontend/components/GitHub/UserInfo/index.jsx +++ b/frontend/components/GitHub/UserInfo/index.jsx @@ -8,8 +8,18 @@ import locales from 'LOCALES' const githubTexts = locales('github.sections.baseInfo') -const UserInfo = (props) => { - const { data, className } = props +const DEFAULT_USER_DATA = { + bio: '', + login: '', + name: '', + avatar_url: '', + created_at: '' +} + +const UserInfo = ({ + data = DEFAULT_USER_DATA, + className = '' +}) => { if (!data) return
const { joinedAt } = githubTexts @@ -17,7 +27,7 @@ const UserInfo = (props) => {
- +
{ ) } -UserInfo.defaultProps = { - className: '', - data: { - bio: '', - login: '', - name: '', - avatar_url: '', - created_at: '' - } -} - export default UserInfo diff --git a/frontend/components/Github/CommitInfo/index.jsx b/frontend/components/Github/CommitInfo/index.jsx index f10ab64e..889f08d8 100644 --- a/frontend/components/Github/CommitInfo/index.jsx +++ b/frontend/components/Github/CommitInfo/index.jsx @@ -1,7 +1,7 @@ import React from 'react' import cx from 'classnames' -import Chart from 'chart.js' +import Chart from 'chart.js/auto' import objectAssign from 'UTILS/object-assign' import { Loading, InfoCard, CardGroup } from 'light-ui' @@ -169,23 +169,25 @@ class CommitInfo extends React.Component { }, options: { scales: { - xAxes: [{ - gridLines: { + x: { + grid: { display: false } - }], - yAxes: [{ - gridLines: { + }, + y: { + grid: { display: false }, ticks: { beginAtZero: true } - }], + }, }, - tooltips: { - callbacks: { - label: item => `${item.yLabel} commits` + plugins: { + tooltip: { + callbacks: { + label: item => `${item.yLabel} commits` + } } } } diff --git a/frontend/components/Github/Hotmap/index.jsx b/frontend/components/Github/Hotmap/index.jsx index e1610dab..8035c29d 100644 --- a/frontend/components/Github/Hotmap/index.jsx +++ b/frontend/components/Github/Hotmap/index.jsx @@ -2,7 +2,7 @@ import React from 'react' import cx from 'classnames' -import CalHeatMap from 'cal-heatmap' +import CalHeatmap from 'cal-heatmap' import 'cal-heatmap/cal-heatmap.css' import { Loading, InfoCard, CardGroup } from 'light-ui' import styles from '../styles/github.css' @@ -12,47 +12,142 @@ import dateHelper from 'UTILS/date' import Icon from 'COMPONENTS/Icon' const githubTexts = locales('github.sections.hotmap') +const HOTMAP_COLORS = ['#ededed', '#dae289', '#8cc665', '#44a340', '#3b6427'] + +const normalizeHotmapData = (datas = {}) => Object.keys(datas).map(timestamp => ({ + date: Number(timestamp) * 1000, + value: datas[timestamp] +})) + +const getScaleThresholds = (levelRanges = [], hotmapData = []) => { + const thresholdCount = HOTMAP_COLORS.length - 1 + const normalizedRanges = levelRanges + .slice(1) + .map(level => Number(level)) + .filter(level => Number.isFinite(level) && level > 0) + .sort((left, right) => left - right) + + if (normalizedRanges.length >= thresholdCount) { + return normalizedRanges.slice(0, thresholdCount) + } + + const maxValue = Math.max( + thresholdCount, + ...hotmapData.map(item => Number(item.value) || 0) + ) + + return Array.from({ length: thresholdCount }, (_, index) => + Math.max(index + 1, Math.ceil(((index + 1) * maxValue) / thresholdCount)) + ) +} class Hotmap extends React.Component { constructor(props) { super(props) - this.githubCalendar = false + this.githubCalendar = null + this.hotmapRoot = null + this.handleNext = this.handleNext.bind(this) + this.handlePrevious = this.handlePrevious.bind(this) + this.setHotmapRoot = this.setHotmapRoot.bind(this) } - componentDidUpdate() { - if (!this.githubCalendar && $('#cal-heatmap')[0]) { + componentDidMount() { + if (this.props.loaded) { this.renderHotmap() } } - renderHotmap() { + componentDidUpdate(preProps) { const { loaded, data } = this.props - if (!loaded) return - this.githubCalendar = true + if ( + loaded + && (!this.githubCalendar || loaded !== preProps.loaded || data !== preProps.data) + ) { + this.renderHotmap() + } + } + + componentWillUnmount() { + if (this.githubCalendar) { + this.githubCalendar.destroy() + this.githubCalendar = null + } + } + + setHotmapRoot(ref) { + this.hotmapRoot = ref + } + + handlePrevious() { + if (this.githubCalendar) { + this.githubCalendar.previous() + } + } + + handleNext() { + if (this.githubCalendar) { + this.githubCalendar.next() + } + } + + async renderHotmap() { + const { loaded, data } = this.props + if (!loaded || !this.hotmapRoot) return + const local = formatLocale() - const cal = new CalHeatMap() const { - datas, - levelRanges + datas = {}, + levelRanges = [] } = (data || {}) + const hotmapData = normalizeHotmapData(datas) + const thresholds = getScaleThresholds(levelRanges, hotmapData) + const startTimestamp = hotmapData.length + ? Math.min(...hotmapData.map(item => item.date)) + : new Date(dateHelper.date.beforeYears(1)).getTime() + const endTimestamp = hotmapData.length + ? Math.max(...hotmapData.map(item => item.date)) + : Date.now() + + if (this.githubCalendar) { + await this.githubCalendar.destroy() + } + + const calendar = new CalHeatmap() + this.githubCalendar = calendar - cal.init({ - domain: 'month', - start: new Date(dateHelper.date.beforeYears(1)), - data: datas, - weekStartOnMonday: local === 'zh-CN', - subDomain: 'day', + await calendar.paint({ + itemSelector: this.hotmapRoot, range: 13, - displayLegend: false, - previousSelector: '#hotmap-left', - nextSelector: '#hotmap-right', - legend: levelRanges, - domainLabelFormat: '%Y-%m', - legendColors: { - min: '#dae289', - max: '#3b6427', - empty: '#ededed' + domain: { + type: 'month', + gutter: 4 + }, + subDomain: { + type: 'ghDay', + width: 11, + height: 11, + gutter: 4, + radius: 2 + }, + date: { + start: new Date(startTimestamp), + min: new Date(startTimestamp), + max: new Date(endTimestamp), + locale: local === 'zh-CN' ? { weekStart: 1 } : 'en' + }, + data: { + source: hotmapData, + x: 'date', + y: 'value', + defaultValue: 0 + }, + scale: { + color: { + type: 'threshold', + range: HOTMAP_COLORS, + domain: thresholds + } } }) } @@ -139,12 +234,20 @@ class Hotmap extends React.Component { )} > -
+
-
+
-
+
@@ -163,7 +266,7 @@ Hotmap.defaultProps = { start: '', total: '', streak: '', - datas: [], + datas: {}, levelRanges: [] } } diff --git a/frontend/components/Github/LanguageInfo/index.jsx b/frontend/components/Github/LanguageInfo/index.jsx index ec1e9883..381a35ec 100644 --- a/frontend/components/Github/LanguageInfo/index.jsx +++ b/frontend/components/Github/LanguageInfo/index.jsx @@ -28,12 +28,12 @@ class LanguageInfo extends React.Component { this.setShowLanguage = this.setShowLanguage.bind(this) } - componentWillReceiveProps(nextProps) { - const { loaded } = this.props - if (!loaded && nextProps.loaded) { + componentDidUpdate(prevProps) { + const { loaded, data } = this.props + if (!prevProps.loaded && loaded) { this.setState({ - showLanguage: Object.keys(this.props.data.languages || {}) - .sort(github.sortByLanguage(this.props.data.languages || {}))[0] + showLanguage: Object.keys(data.languages || {}) + .sort(github.sortByLanguage(data.languages || {}))[0] }) } } diff --git a/frontend/components/Github/RepositoryInfo/index.jsx b/frontend/components/Github/RepositoryInfo/index.jsx index e0fd9248..e568c733 100644 --- a/frontend/components/Github/RepositoryInfo/index.jsx +++ b/frontend/components/Github/RepositoryInfo/index.jsx @@ -1,6 +1,6 @@ import React from 'react' -import Chart from 'chart.js' +import Chart from 'chart.js/auto' import cx from 'classnames' import { Loading, InfoCard, CardGroup } from 'light-ui' @@ -106,24 +106,26 @@ class RepositoryInfo extends React.Component { labels: github.getReposNames(ownedRepositories) }, options: { - title: { - display: true, - text: githubTexts.chartTitle + plugins: { + title: { + display: true, + text: githubTexts.chartTitle + }, }, scales: { - xAxes: [{ - gridLines: { + x: { + grid: { display: false } - }], - yAxes: [{ - gridLines: { + }, + y: { + grid: { display: false }, ticks: { beginAtZero: true } - }] + } }, } }) diff --git a/frontend/components/Github/SocialInfo/index.jsx b/frontend/components/Github/SocialInfo/index.jsx index 9ba0d0a3..0088c0fd 100644 --- a/frontend/components/Github/SocialInfo/index.jsx +++ b/frontend/components/Github/SocialInfo/index.jsx @@ -8,53 +8,49 @@ import { ClassicText, InfoCard, CardGroup } from 'light-ui' const githubTexts = locales('github.sections.social') -const SocialInfo = (props) => { - const { - user, - showLink, - sideTextStyle - } = props - - return ( -
- ) -} +const SocialInfo = ({ + user = {}, + showLink = true, + sideTextStyle = {} +}) => ( + +) SocialInfo.propTypes = { user: PropTypes.object, @@ -64,12 +60,4 @@ SocialInfo.propTypes = { showLink: PropTypes.bool } -SocialInfo.defaultProps = { - user: {}, - style: null, - mainTextStyle: {}, - sideTextStyle: {}, - showLink: true -} - export default SocialInfo diff --git a/frontend/components/Github/Statistic/index.jsx b/frontend/components/Github/Statistic/index.jsx index 8b561aa0..8280d7c6 100644 --- a/frontend/components/Github/Statistic/index.jsx +++ b/frontend/components/Github/Statistic/index.jsx @@ -1,6 +1,6 @@ import React from 'react' import deepcopy from 'deepcopy' -import Chart from 'chart.js' +import Chart from 'chart.js/auto' import styles from '../styles/statistic.css' import githubStyles from '../styles/github.css' import cardStyles from '../styles/info_card.css' @@ -51,7 +51,7 @@ class Statistic extends React.Component { const radarConfig = deepcopy(RADAR_CONFIG) radarConfig.data.labels = labels radarConfig.data.datasets[0].data = datas - radarConfig.options.title.text = title + radarConfig.options.plugins.title.text = title this[pointTo] = new Chart(ref, radarConfig) } diff --git a/frontend/components/Github/UserInfo/index.jsx b/frontend/components/Github/UserInfo/index.jsx index 6de6c1e3..8b25626f 100644 --- a/frontend/components/Github/UserInfo/index.jsx +++ b/frontend/components/Github/UserInfo/index.jsx @@ -8,8 +8,18 @@ import locales from 'LOCALES' const githubTexts = locales('github.sections.baseInfo') -const UserInfo = (props) => { - const { data, className } = props +const DEFAULT_USER_DATA = { + bio: '', + login: '', + name: '', + avatar_url: '', + created_at: '' +} + +const UserInfo = ({ + data = DEFAULT_USER_DATA, + className = '' +}) => { if (!data) return
const { joinedAt } = githubTexts @@ -17,7 +27,7 @@ const UserInfo = (props) => {
- +
{ ) } -UserInfo.defaultProps = { - className: '', - data: { - bio: '', - login: '', - name: '', - avatar_url: '', - created_at: '' - } -} - export default UserInfo diff --git a/frontend/components/MinInfoCard/index.jsx b/frontend/components/MinInfoCard/index.jsx index 1570e829..bf4d4fcf 100644 --- a/frontend/components/MinInfoCard/index.jsx +++ b/frontend/components/MinInfoCard/index.jsx @@ -3,29 +3,23 @@ import styles from './card.css' import Icon from 'COMPONENTS/Icon' import { ClassicCard } from 'light-ui' -const MinInfoCard = (props) => { - const { mainText, subText, className, icon } = props - - return ( - -
-
- {subText} -
-
- - {mainText} -
+const MinInfoCard = ({ + mainText = '', + subText = '', + className = '', + icon = '' +}) => ( + +
+
+ {subText}
- - ) -} - -MinInfoCard.defaultProps = { - mainText: '', - subText: '', - className: '', - icon: '' -} +
+ + {mainText} +
+
+
+) export default MinInfoCard diff --git a/frontend/components/Navigation/index.jsx b/frontend/components/Navigation/index.jsx index 7ae2e23a..36c6c047 100644 --- a/frontend/components/Navigation/index.jsx +++ b/frontend/components/Navigation/index.jsx @@ -7,29 +7,61 @@ import styles from './navigation.css' class Nav extends React.PureComponent { componentDidMount() { - if (this.props.fixed) { - const $navigation = $(`#${this.props.id}`) - const navTop = 200 - const $document = $(document) - - $(window).scroll(() => { - const currentTop = $document.scrollTop() - if (currentTop + 80 + 65 >= navTop) { - const navLeft = $navigation.offset().left - $navigation.css({ - position: 'fixed', - left: navLeft, - top: 80 - }) - } else { - $navigation.css({ - position: 'absolute', - left: -120, - top: 63 - }) - } + if (!this.props.fixed) return + + this.fixedTop = 80 + this.absoluteLeft = -120 + this.absoluteTop = 63 + this.$navigation = $(`#${this.props.id}`) + this.$document = $(document) + this.$window = $(window) + this.isNavigationFixed = false + + if (!this.$navigation.length) return + + this.navTop = this.$navigation.offset().top + this.navLeft = this.$navigation.offset().left + + this.handleWindowScroll = () => { + const currentTop = this.$document.scrollTop() + + if (!this.isNavigationFixed) { + this.navTop = this.$navigation.offset().top + this.navLeft = this.$navigation.offset().left + } + + if (currentTop + this.fixedTop >= this.navTop) { + this.isNavigationFixed = true + this.$navigation.css({ + position: 'fixed', + left: this.navLeft, + top: this.fixedTop + }) + } else { + this.isNavigationFixed = false + this.$navigation.css({ + position: 'absolute', + left: this.absoluteLeft, + top: this.absoluteTop + }) + } + } + + this.handleWindowResize = () => { + this.isNavigationFixed = false + this.$navigation.css({ + position: 'absolute', + left: this.absoluteLeft, + top: this.absoluteTop }) + this.navTop = this.$navigation.offset().top + this.navLeft = this.$navigation.offset().left + this.handleWindowScroll() } + + this.$window.on('scroll', this.handleWindowScroll) + this.$window.on('resize', this.handleWindowResize) + this.handleWindowScroll() } componentDidUpdate(prevProps) { @@ -43,6 +75,13 @@ class Nav extends React.PureComponent { } } + componentWillUnmount() { + if (!this.$window) return + + this.$window.off('scroll', this.handleWindowScroll) + this.$window.off('resize', this.handleWindowResize) + } + render() { const { id, @@ -101,9 +140,22 @@ class Nav extends React.PureComponent { } } -const Navigation = props => ( +const Navigation = ({ + sections = [], + children = null, + navigationCardClassName = '', + navigationCardBgClassName = '', + ...props +}) => ( -