├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── config.tmp.js ├── docker-compose.yml ├── index.js ├── nuxt.config.js ├── package.json ├── src ├── api.js ├── api │ └── v2.js ├── app.js ├── auth.js ├── jwt.js ├── logger.js ├── nuxt.js ├── schema │ ├── list.js │ ├── opts.js │ ├── tab.js │ └── user.js ├── service │ ├── github.js │ └── google.js ├── socket.js ├── ssr.js ├── util.js └── web │ ├── assets │ ├── boeffect.png │ ├── icon.css │ └── icon_128.png │ ├── getData.js │ ├── layouts │ └── app.vue │ ├── pages │ ├── index.vue │ ├── info.vue │ ├── login.vue │ └── success.vue │ ├── plugins │ └── vuetify.js │ └── store │ └── index.js └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | config.js 3 | .nuxt 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | config.js 3 | .nuxt 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10.8.0-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | RUN yarn --production && yarn cache clean && yarn build 8 | 9 | EXPOSE 3000:3000 10 | 11 | CMD ["yarn", "start"] 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Better Onetab Sync Server 2 | ====== 3 | 4 | It will provide more features like sharing or management in the future. 5 | 6 | ## Development notes 7 | 8 | ### Todo 9 | 10 | - [ ] complete API paramater validation 11 | - [ ] restrict socket connect from application 12 | - [ ] remove useless API 13 | - [ ] refactor structure 14 | - [ ] front page 15 | 16 | ### Authorization 17 | 18 | Visiting `/auth/:type` will go to OAuth page and the final page will take the jwt token in header. 19 | 20 | available type list: **github google** (case-sensitive) 21 | 22 | Every time you send a request to API with token will get a new token. 23 | 24 | **Tricks for using it in a web extension:** 25 | 26 | **1st way** If query string has a paramater named `ext` it will redirect to `${ext}/#${jwt token}#` at the final. 27 | 28 | *e.g.* 29 | 30 | Visiting 31 | 32 | `/auth/github?ext=https%3A%2F%2Fgeghaancpajoplmhcocjboinmihhmpjf.chromiumapp.org%2F%22` 33 | 34 | will redirect to 35 | 36 | `https://geghaancpajoplmhcocjboinmihhmpjf.chromiumapp.org/#{JWT_TOKEN}#` 37 | 38 | at the final. 39 | 40 | So it is possible to use `chrome.identity.launchWebAuthFlow` to get token. But it has some incompatibility problem and maybe do not work on Firefox or occur some error in Chrome. So in the new version it will take the next way. 41 | 42 | **2nd way** Use content script to send token to background page. 43 | 44 | So in the new version of sync server will provide a or more user friendly login page. 45 | 46 | ### API 47 | 48 | **v1** 49 | 50 | *all of following API require authorization* 51 | 52 | ##### GET `/api/info` Get the infos of user and sync. 53 | 54 | ##### GET `/api/lists` Get all lists. 55 | 56 | ##### GET `/api/opts` Get all options. 57 | 58 | ##### PUT `/api/opts` Modify all options. 59 | 60 | ##### PUT `/api/lists` Modify all lists. 61 | 62 | **v2 (planning)** 63 | 64 | ##### POST `/api/v2/list` Create a new list. 65 | 66 | ##### PUT `/api/v2/list/:id` Change a list. 67 | 68 | ##### DELETE `/api/v2/list/:id` Remove a list. 69 | 70 | ##### PUT `/api/v2/lists/order` Change the order of lists. 71 | 72 | ##### PUT `/api/v2/opt` Change an option. 73 | 74 | ##### GET `/api/v2/lists` Get all lists with pagination. 75 | 76 | ##### PUT `/api/v2/list/:id/public` Make a list public. 77 | -------------------------------------------------------------------------------- /config.tmp.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | port: 3000, 3 | mongodb: '', 4 | url: '', 5 | jwt_secret: '', 6 | jwt_header: '', 7 | google: { 8 | client_id: '', 9 | client_secret: '', 10 | }, 11 | github: { 12 | client_id: '', 13 | client_secret: '', 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | mongo: 4 | image: mongo:4.0.2 5 | restart: always 6 | volumes: 7 | - /data/db:/data/db 8 | ports: 9 | - 6379:6379 10 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const app = require('./src/app') 2 | const socket = require('./src/socket') 3 | const conf = require('@cnwangjie/conf') 4 | const port = conf.port || 3000 5 | const server = app.listen(port, () => { 6 | console.log('server is listening port:', port) 7 | }) 8 | socket.attach(server, { 9 | path: '/ws', 10 | }) 11 | -------------------------------------------------------------------------------- /nuxt.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | dev: false, 3 | srcDir: 'src/web/', 4 | plugins: [ 5 | '~/plugins/vuetify.js', 6 | ], 7 | css: [ 8 | 'vuetify/dist/vuetify.min.css', 9 | 'material-design-icons-iconfont/dist/material-design-icons.css', 10 | ], 11 | loading: { color: '#3B8070' }, 12 | head: { 13 | meta: [ 14 | { charset: 'UTF-8' }, 15 | { name: 'viewport', content: 'width=device-width, initial-scale=1.0' }, 16 | { 'http-equiv': 'X-UA-Compatible', content: 'ie=edge' }, 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "better-onetab-sync-server", 3 | "version": "1.0.0", 4 | "description": "better onetab sync server", 5 | "main": "index.js", 6 | "repository": "git", 7 | "author": "cnwangjie", 8 | "license": "MIT", 9 | "scripts": { 10 | "dev": "NODE_ENV=development nodemon index.js", 11 | "build": "nuxt build", 12 | "start": "NODE_ENV=production node index.js" 13 | }, 14 | "dependencies": { 15 | "@cnwangjie/conf": "^1.0.0", 16 | "@sentry/node": "^4.5.3", 17 | "axios": "^0.18.0", 18 | "googleapis": "^32.0.0", 19 | "js-cookie": "^2.2.0", 20 | "jsonwebtoken": "^8.3.0", 21 | "koa": "^2.5.2", 22 | "koa-bodyparser": "^4.2.1", 23 | "koa-router": "^7.4.0", 24 | "koa2-cors": "^2.0.6", 25 | "lodash": "^4.17.10", 26 | "material-design-icons-iconfont": "^4.0.2", 27 | "mongoose": "^5.3.7", 28 | "nuxt": "^2.1.0", 29 | "request": "^2.88.0", 30 | "request-promise": "^4.2.2", 31 | "socket.io": "^2.2.0", 32 | "vuetify": "^1.2.7" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const cors = require('koa2-cors') 3 | const Router = require('koa-router') 4 | const conf = require('@cnwangjie/conf') 5 | const jwt = require('./jwt') 6 | const { detectAndParseJson } = require('./util') 7 | 8 | const apiRouter = module.exports = new Router({prefix: '/api'}) 9 | apiRouter.use(cors({ 10 | origin: '*', 11 | allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD'], 12 | allowHeaders: [conf.jwt_header], 13 | exposeHeaders: [conf.jwt_header], 14 | })) 15 | apiRouter.options('*', ctx => { 16 | ctx.status = 200 17 | ctx.res.end() 18 | }) 19 | apiRouter.use(async (ctx, next) => { 20 | try { 21 | await next() 22 | } catch (error) { 23 | console.log(error) 24 | if (error.status) ctx.status = error.status 25 | else ctx.status = 500 26 | ctx.body = { status: 'error', message: error.message } 27 | } 28 | if (!ctx.body) { 29 | if (ctx.status === 200) { 30 | ctx.body = { status: 'success' } 31 | } else { 32 | ctx.status = ctx.status || 404 33 | ctx.body = { status: 'error' } 34 | } 35 | } 36 | }) 37 | const authorizeApiRouter = new Router() 38 | authorizeApiRouter.use(jwt.authMiddleware) 39 | 40 | /** 41 | * @api {get} /api/info 权限测试及更新token以及获取更新时间 42 | */ 43 | authorizeApiRouter.get('/info', async ctx => { 44 | if (ctx.user) ctx.body = _.pick(ctx.user, [ 45 | 'uid', 'listsUpdatedAt', 'optsUpdatedAt', 46 | 'googleId', 'googleName', 'githubId', 'githubName', 47 | ]) 48 | }) 49 | /** 50 | * @api {get} /api/lists 获取所有列表 51 | */ 52 | authorizeApiRouter.get('/lists', async ctx => { 53 | if (ctx.user) ctx.body = ctx.user.lists 54 | }) 55 | /** 56 | * @api {get} /api/opts 获取所有选项 57 | */ 58 | authorizeApiRouter.get('/opts', async ctx => { 59 | if (ctx.user) ctx.body = ctx.user.opts 60 | }) 61 | /** 62 | * @api {put} /api/opts 设置所有选项 63 | */ 64 | authorizeApiRouter.put('/opts', async ctx => { 65 | if (ctx.user) { 66 | try { 67 | ctx.user.optsUpdatedAt = new Date() 68 | ctx.user.opts = detectAndParseJson(ctx.input.opts) 69 | await ctx.user.save() 70 | ctx.body = {optsUpdatedAt: ctx.user.optsUpdatedAt} 71 | } catch (error) { 72 | if (error.name === 'ValidationError') ctx.status = 400 73 | else ctx.status = 500 74 | throw error 75 | } 76 | } 77 | }) 78 | /** 79 | * @api {post} /api/lists 设置列表 80 | */ 81 | authorizeApiRouter.put('/lists', async ctx => { 82 | if (ctx.user) { 83 | try { 84 | ctx.user.listsUpdatedAt = new Date() 85 | ctx.user.lists = detectAndParseJson(ctx.input.lists) 86 | await ctx.user.save() 87 | ctx.body = {listsUpdatedAt: ctx.user.listsUpdatedAt} 88 | } catch (error) { 89 | if (error.name === 'ValidationError') ctx.status = 400 90 | else ctx.status = 500 91 | throw error 92 | } 93 | } 94 | }) 95 | apiRouter.use(authorizeApiRouter.routes()) 96 | -------------------------------------------------------------------------------- /src/api/v2.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const cors = require('koa2-cors') 3 | const Router = require('koa-router') 4 | const conf = require('@cnwangjie/conf') 5 | const jwt = require('../jwt') 6 | const socket = require('../socket') 7 | const User = require('../schema/user') 8 | const {detectAndParseJson} = require('../util') 9 | 10 | const apiV2Router = module.exports = new Router({prefix: '/api/v2'}) 11 | apiV2Router.use(cors({ 12 | origin: '*', 13 | allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'], 14 | allowHeaders: [conf.jwt_header], 15 | exposeHeaders: [conf.jwt_header], 16 | })) 17 | apiV2Router.use(jwt.authMiddleware) 18 | apiV2Router.use(async (ctx, next) => { 19 | try { 20 | await next() 21 | } catch (err) { 22 | ctx.body = { status: 'error' } 23 | ctx.status = err.status || 500 24 | if (err.message) ctx.body.message = err.message 25 | } 26 | }) 27 | apiV2Router.post('/list', async ctx => { 28 | const user = ctx.user 29 | if (!user) ctx.throw(401) 30 | const list = detectAndParseJson(ctx.input.list) 31 | if (!list) ctx.throw(400, 'missing param `list`') 32 | user.addList(list) 33 | const validationErr = await user.lists.validateSync() 34 | if (validationErr) ctx.throw(400, validationErr) 35 | ctx.user.listsUpdatedAt = new Date() 36 | await user.save() 37 | socket.emitToUser(user.uid, 'list.update', { method: 'addList', args: [ list ] }) 38 | ctx.body = user.lists[0].toJSON() 39 | }) 40 | apiV2Router.put('/list/:listId', async ctx => { 41 | const user = ctx.user 42 | if (!user) ctx.throw(401) 43 | const newList = detectAndParseJson(ctx.input.list) 44 | if (!newList) ctx.throw(400, 'missing param `list`') 45 | const rawList = user.lists.id(ctx.input.listId) 46 | if (!rawList) ctx.throw(404, 'list not exists') 47 | user.updateListById(ctx.input.listId, newList) 48 | if (!rawList.isModified()) { 49 | const validationErr = rawList.validateSync() 50 | if (validationErr) ctx.throw(400, validationErr) 51 | } 52 | ctx.user.listsUpdatedAt = new Date() 53 | await user.save() 54 | socket.emitToUser(user.uid, 'list.update', { method: 'updateListById', args: [ ctx.input.listId, newList ] }) 55 | ctx.body = rawList.toJSON() 56 | }) 57 | apiV2Router.delete('/list/:listId', async ctx => { 58 | const user = ctx.user 59 | if (!user) ctx.throw(401) 60 | const list = user.lists.id(ctx.input.listId) 61 | if (!list) ctx.throw(404, 'list not exists') 62 | user.removeListById(ctx.input.listId) 63 | ctx.user.listsUpdatedAt = new Date() 64 | await user.save() 65 | socket.emitToUser(user.uid, 'list.update', { method: 'removeListById', args: [ ctx.input.listId ] }) 66 | ctx.body = {status: 'success'} 67 | }) 68 | apiV2Router.post('/list/:listId/order', async ctx => { 69 | const user = ctx.user 70 | if (!user) ctx.throw(401) 71 | const list = user.list.id(ctx.input.listId) 72 | if (!list) ctx.throw(404, 'list not exists') 73 | const diff = ctx.input.diff 74 | if (diff == null) ctx.throw(400, 'missing param `diff`') 75 | if (!isFinite(+diff)) ctx.throw(400, 'bad param `diff`') 76 | user.changeListOrderRelatively(ctx.input.listId, +diff) 77 | if (!rawList.isModified()) { 78 | const validationErr = rawList.validateSync() 79 | if (validationErr) ctx.throw(400, validationErr) 80 | } 81 | ctx.user.listsUpdatedAt = new Date() 82 | await user.save() 83 | socket.emitToUser(user.uid, 'list.update', { method: 'changeListOrderRelatively', args: [ ctx.input.listId, +diff ] }) 84 | ctx.body = {status: 'success'} 85 | }) 86 | apiV2Router.post('/lists/bulk', async ctx => { 87 | const user = ctx.user 88 | if (!user) ctx.throw(401) 89 | const changes = detectAndParseJson(ctx.input.changes) 90 | if (!Array.isArray(changes)) ctx.throw(400, '`changes must be an array`') 91 | for (const [method, ...args] of changes) { 92 | if (!(method in User.schema.methods) 93 | || args.length !== User.schema.methods[method].length) ctx.throw(400) 94 | user[method](...args) 95 | } 96 | ctx.user.listsUpdatedAt = new Date() 97 | await user.save() 98 | for (const [method, ...args] of changes) { 99 | socket.emitToUser(user.uid, 'list.update', { method, args }) 100 | } 101 | ctx.body = {status: 'success', listsUpdatedAt: user.listsUpdatedAt} 102 | }) 103 | apiV2Router.put('/opts', async ctx => { 104 | const user = ctx.user 105 | if (!user) ctx.throw(401) 106 | const opts = detectAndParseJson(ctx.input.opts) 107 | if (!opts) ctx.throw(400, 'missing param `opts`') 108 | user.updateOpts(opts) 109 | user.optsUpdatedAt = new Date() 110 | await user.save() 111 | socket.emitToUser(user.uid, 'opts.set', opts) 112 | ctx.body = {status: 'success', optsUpdatedAt: user.optsUpdatedAt} 113 | }) 114 | apiV2Router.all('*', async ctx => { 115 | ctx.body = 'ok' 116 | }) 117 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | const conf = require('@cnwangjie/conf') 2 | conf().load('../config.js').env() 3 | 4 | const Koa = require('koa') 5 | const mongoose = require('mongoose') 6 | const bodyparser = require('koa-bodyparser') 7 | const apiRouter = require('./api') 8 | const apiV2Router = require('./api/v2') 9 | const ssrRouter = require('./ssr') 10 | const authRouter = require('./auth') 11 | const crypto = require('crypto') 12 | const logger = require('./logger') 13 | const Sentry = require('@sentry/node') 14 | 15 | mongoose.connect(conf.mongodb, { 16 | useCreateIndex: true, 17 | useNewUrlParser: true, 18 | }) 19 | 20 | const app = module.exports = new Koa() 21 | app.on('error', err => { 22 | logger.log(err) 23 | }) 24 | app.use(bodyparser()) 25 | app.use(async (ctx, next) => { 26 | Object.defineProperty(ctx, 'input', { 27 | get() { 28 | return Object.assign({}, ctx.request.body, ctx.query, ctx.params) 29 | } 30 | }) 31 | await next() 32 | }) 33 | app.use(async (ctx, next) => { 34 | const startTime = Date.now() 35 | const id = crypto.randomBytes(3).toString('hex') 36 | let error 37 | console.log(`[${new Date().toLocaleString()}] ${id} <- ${ctx.method} ${ctx.path} `) 38 | try { 39 | await next() 40 | } catch (e) { 41 | error = e 42 | } 43 | const time = Date.now() - startTime 44 | console.log(`[${new Date().toLocaleString()}] ${id} -> (${ctx.status}) +${time}ms\n`) 45 | Sentry.withScope(scope => { 46 | scope.setTag('status', ctx.status) 47 | scope.setTag('url', ctx.request.originalUrl) 48 | scope.setTag('time', time) 49 | if (ctx.user) scope.setUser({id: ctx.user.uid, ip_address: ctx.request.ip}) 50 | 51 | if (error || ctx.status >= 400) { 52 | Sentry.withScope(scope => { 53 | if (ctx.state) scope.setExtra('state', ctx.state) 54 | if (ctx.input) scope.setExtra('input', ctx.input) 55 | if (error) { 56 | Sentry.captureException(error) 57 | } else { 58 | Sentry.captureMessage(ctx.body) 59 | } 60 | }) 61 | } 62 | }) 63 | }) 64 | app.use(apiV2Router.routes()) 65 | app.use(apiRouter.routes()) 66 | app.use(authRouter.routes()) 67 | app.use(ssrRouter.routes()) 68 | -------------------------------------------------------------------------------- /src/auth.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router') 2 | const User = require('./schema/user') 3 | const google = require('./service/google') 4 | const github = require('./service/github') 5 | const conf = require('@cnwangjie/conf') 6 | const jwt = require('./jwt') 7 | 8 | const authRouter = module.exports = new Router({prefix: '/auth'}) 9 | 10 | const oauthServices = { google, github } 11 | 12 | authRouter.get('/', ctx => { 13 | console.log('!!!') 14 | ctx.state.token = 'token!!!' 15 | ctx.ssr('/auth', {req: ctx.req, res: ctx.res, token: 'token!!!!'}) 16 | }) 17 | 18 | authRouter.get('/:type', async ctx => { 19 | 20 | const {type} = ctx.params 21 | if (!Reflect.has(oauthServices, type)) return ctx.status = 404 22 | const oauth = oauthServices[type] 23 | 24 | const state = ctx.input.state ? ctx.input.state.split(';') 25 | .map(i => i.split(':')) 26 | .reduce((r, [k, ...v]) => { 27 | r[k] = v.join(':') 28 | return r 29 | }, {}) : {} 30 | 31 | if (!ctx.input.code) { 32 | let redirectTo = oauth.generateAuthUrl() 33 | if (ctx.input.state) redirectTo += '&state=' + ctx.input.state 34 | ctx.redirect(redirectTo) 35 | } else { 36 | const {id, name} = await oauth.getUserInfoByAuthorizationCode(ctx.input) 37 | 38 | const oauthIdKey = type + 'Id' 39 | const oauthNameKey = type + 'Name' 40 | if (state.uid) { 41 | ctx.user = await User.findOne({uid: state.uid}) 42 | if (!ctx.user[oauthIdKey]) { 43 | ctx.user[oauthIdKey] = id 44 | await ctx.user.save() 45 | } 46 | } else { 47 | ctx.user = await User.findOne({[oauthIdKey]: id}) || await User.create({[oauthIdKey]: id}) 48 | } 49 | 50 | if (ctx.user[oauthNameKey] !== name) { 51 | ctx.user[oauthNameKey] = name 52 | await ctx.user.save() 53 | } 54 | const token = jwt.genTokenForUser(ctx.user) 55 | if (state.ext) { 56 | const lend = state.ext 57 | const to = lend + '#' + token + '#' 58 | ctx.redirect(to) 59 | } else { 60 | ctx.cookies.set(conf.jwt_header, token, { 61 | maxAge: 3600 * 1000 * 24 * 30, 62 | httpOnly: false, 63 | }) 64 | ctx.redirect('/success') 65 | } 66 | } 67 | }) 68 | -------------------------------------------------------------------------------- /src/jwt.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken') 2 | const conf = require('@cnwangjie/conf') 3 | const User = require('./schema/user') 4 | const jwtHeader = conf.jwt_header 5 | const genTokenForUser = (user, payload = {}) => { 6 | return jwt.sign(payload, conf.jwt_secret, { 7 | subject: user.uid, 8 | expiresIn: 24 * 60 * 60, 9 | }) 10 | } 11 | 12 | const verifyToken = token => { 13 | try { 14 | jwt.verify(token, conf.jwt_secret) 15 | return true 16 | } catch (error) { 17 | if (error instanceof jwt.TokenExpiredError 18 | && error.message.startsWith('jwt expired') 19 | && jwt.decode(token).iat + 30 * 24 * 60 * 60 > Date.now() / 1000) { 20 | return true 21 | } 22 | } 23 | return false 24 | } 25 | 26 | const getUserFromToken = token => { 27 | return User.findOne({uid: jwt.decode(token).sub}) 28 | } 29 | 30 | const authMiddleware = async (ctx, next) => { 31 | const token = ctx.header[jwtHeader] || ctx.cookies.get(jwtHeader) 32 | if (token) { 33 | if (verifyToken(token)) { 34 | ctx.user = await getUserFromToken(token) 35 | } else { 36 | ctx.throw(401, 'token expired') 37 | } 38 | } 39 | await next() 40 | if (ctx.user && !ctx.headerSent) { 41 | ctx.res.setHeader(jwtHeader, genTokenForUser(ctx.user)) 42 | } 43 | } 44 | 45 | module.exports = { 46 | decode: jwt.decode, 47 | genTokenForUser, 48 | verifyToken, 49 | getUserFromToken, 50 | authMiddleware, 51 | } 52 | -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | const conf = require('@cnwangjie/conf') 2 | const Sentry = require('@sentry/node') 3 | const os = require('os') 4 | 5 | const logger = module.exports = {} 6 | Sentry.init({ 7 | debug: process.env.NODE_ENV === 'development', 8 | environment: process.env.NODE_ENV, 9 | dsn: conf.sentry_dsn, 10 | serverName: os.hostname(), 11 | }) 12 | 13 | logger.log = (...args) => { 14 | console.log(...args) 15 | args.forEach(arg => { 16 | if ((arg instanceof Error) || arg && arg.message) Sentry.captureException(arg) 17 | else if (args != null) Sentry.captureMessage(arg) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/nuxt.js: -------------------------------------------------------------------------------- 1 | const {Nuxt, Builder} = require('nuxt') 2 | const nuxtConfig = require('../nuxt.config') 3 | const isProd = process.env.NODE_ENV === 'production' 4 | nuxtConfig.dev = !isProd 5 | const nuxt = module.exports = new Nuxt(nuxtConfig) 6 | 7 | if (!isProd) { 8 | const builder = new Builder(nuxt) 9 | builder.build() 10 | } 11 | -------------------------------------------------------------------------------- /src/schema/list.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | const { tabSchema } = require('./tab') 3 | 4 | const listSchema = new mongoose.Schema({ 5 | tabs: { 6 | type: [tabSchema], 7 | }, 8 | title: { 9 | type: String, 10 | default: '', 11 | }, 12 | time: { 13 | type: Date, 14 | default: Date.now(), 15 | }, 16 | pinned: { 17 | type: Boolean, 18 | default: false, 19 | }, 20 | color: { 21 | type: String, 22 | default: '', 23 | }, 24 | tags: { 25 | type: [String], 26 | }, 27 | updatedAt: { 28 | type: Date, 29 | }, 30 | }).pre('save', async function () { 31 | if (this.tabs.length === 0) return this.remove() 32 | }) 33 | 34 | module.exports = { listSchema } 35 | -------------------------------------------------------------------------------- /src/schema/opts.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | const optsSchema = new mongoose.Schema({ 4 | browserAction: String, 5 | itemClickAction: String, 6 | popupItemClickAction: String, 7 | defaultNightMode: Boolean, 8 | itemDisplay: String, 9 | hideFavicon: Boolean, 10 | addHistory: Boolean, 11 | ignorePinned: Boolean, 12 | pinNewList: Boolean, 13 | pageContext: Boolean, 14 | allContext: Boolean, 15 | openTabListWhenNewTab: Boolean, 16 | alertRemoveList: Boolean, 17 | excludeIllegalURL: Boolean, 18 | removeDuplicate: Boolean, 19 | openEnd: Boolean, 20 | openTabListNoTab: Boolean, 21 | listsPerPage: String, 22 | titleFontSize: String, 23 | disableDynamicMenu: Boolean, 24 | disableExpansion: Boolean, 25 | disableTransition: Boolean, 26 | disableSearch: Boolean, 27 | }) 28 | 29 | module.exports = { optsSchema } 30 | -------------------------------------------------------------------------------- /src/schema/tab.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | const tabSchema = new mongoose.Schema({ 4 | favIconUrl: { 5 | type: String, 6 | default: '', 7 | }, 8 | url: { 9 | type: String, 10 | required: true, 11 | }, 12 | title: { 13 | type: String, 14 | default: '', 15 | }, 16 | pinned: { 17 | type: Boolean, 18 | default: false, 19 | } 20 | }) 21 | 22 | module.exports = { tabSchema } 23 | -------------------------------------------------------------------------------- /src/schema/user.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | const { optsSchema } = require('./opts') 3 | const { listSchema } = require('./list') 4 | const { genUid, genToken, verifyToken } = require('../util') 5 | 6 | const userSchema = new mongoose.Schema({ 7 | opts: { 8 | type: optsSchema, 9 | default: {}, 10 | }, 11 | optsUpdatedAt: { 12 | type: Date, 13 | default: 0, 14 | }, 15 | lists: { 16 | type: [listSchema], 17 | default: [], 18 | }, 19 | listsUpdatedAt: { 20 | type: Date, 21 | default: 0, 22 | }, 23 | uid: { 24 | type: String, 25 | default: genUid, 26 | unique: true, 27 | index: true, 28 | }, 29 | encryptedToken: { 30 | type: String, 31 | }, 32 | googleId: String, 33 | googleName: String, 34 | githubId: String, 35 | githubName: String, 36 | }, { 37 | timestamps: true, 38 | toObject: { 39 | getters: true, 40 | versionKey: false, 41 | }, 42 | }).method({ 43 | addList(list) { 44 | if (list._id && this.lists.id(list._id)) return 45 | this.lists.unshift(list) 46 | }, 47 | updateListById(listId, newList, time) { 48 | const list = this.lists.id(listId) 49 | if (!list) return 50 | for (const [k, v] of Object.entries(newList)) { 51 | list[k] = v 52 | } 53 | list.updatedAt = time 54 | }, 55 | removeListById(listId) { 56 | this.lists.pull(listId) 57 | }, 58 | changeListOrderRelatively(listId, diff) { 59 | if (diff === 0) return 60 | const list = this.lists.id(listId) 61 | if (!list) return 62 | const src = this.lists.indexOf(list) 63 | this.lists.pull(listId) 64 | this.lists.splice(src + diff, 0, list) 65 | }, 66 | updateOpts(opts) { 67 | for (const [k, v] of Object.entries(opts)) { 68 | this.opts[k] = v 69 | } 70 | }, 71 | }).static({ 72 | async genToken(uid) { 73 | const user = await this.findOne({uid}) 74 | const token = genToken(user) 75 | await user.save() 76 | return token 77 | }, 78 | async revokeToken(uid) { 79 | return this.findOneAndUpdate({uid}, {$unset: {encryptedToken}}) 80 | }, 81 | async verifyToken(uid, token) { 82 | const user = await this.findOne({uid}) 83 | return verifyToken(user, token) 84 | }, 85 | }) 86 | 87 | const User = mongoose.model('user', userSchema) 88 | 89 | module.exports = User 90 | -------------------------------------------------------------------------------- /src/service/github.js: -------------------------------------------------------------------------------- 1 | const rp = require('request-promise') 2 | const conf = require('@cnwangjie/conf') 3 | 4 | const client_id = conf.github.client_id 5 | const client_secret = conf.github.client_secret 6 | 7 | const generateAuthUrl = () => { 8 | return `https://github.com/login/oauth/authorize?client_id=${client_id}&redirect_uri=${conf.url + '/auth/github'}` 9 | } 10 | 11 | const getToken = async (code, state) => { 12 | const res = await rp({ 13 | url: 'https://github.com/login/oauth/access_token', 14 | method: 'POST', 15 | form: { 16 | client_id, 17 | client_secret, 18 | code, 19 | state, 20 | redirect_uri: conf.url + '/auth/github', 21 | }, 22 | headers: { 23 | 'User-Agent': 'better-onetab-sync-server', 24 | 'Accept': 'application/json', 25 | }, 26 | json: true, 27 | }) 28 | return res.access_token 29 | } 30 | 31 | const getUserInfoByAuthorizationCode = async ({code, state}) => { 32 | const token = await getToken(code, state) 33 | const info = await rp({ 34 | url: 'https://api.github.com/user', 35 | headers: { 36 | 'User-Agent': 'better-onetab-sync-server', 37 | 'Authorization': `token ${token}`, 38 | 'Accept': 'application/json', 39 | }, 40 | json: true, 41 | }) 42 | return { 43 | id: info.id, 44 | name: info.login, 45 | } 46 | } 47 | 48 | module.exports = { 49 | generateAuthUrl, 50 | getUserInfoByAuthorizationCode, 51 | } 52 | -------------------------------------------------------------------------------- /src/service/google.js: -------------------------------------------------------------------------------- 1 | const { google } = require('googleapis') 2 | const conf = require('@cnwangjie/conf') 3 | const scope = ['profile', 'openid'] 4 | const createNewOauth2Client = () => new google.auth.OAuth2( 5 | conf.google.client_id, 6 | conf.google.client_secret, 7 | conf.url + '/auth/google' 8 | ) 9 | // refer: https://github.com/google/google-api-nodejs-client/#oauth2-client 10 | const generateAuthUrl = () => createNewOauth2Client().generateAuthUrl({scope}) 11 | const getUserInfoByAuthorizationCode = async ({code: authorizationCode}) => { 12 | const auth = createNewOauth2Client() 13 | const {tokens} = await auth.getToken(authorizationCode) 14 | auth.setCredentials(tokens) 15 | const oauth2 = google.oauth2('v2') 16 | oauth2._options.auth = auth 17 | // refer: https://google.github.io/google-api-nodejs-client/classes/_apis_oauth2_v2_.resource_userinfo.html 18 | const {data} = await oauth2.userinfo.get() 19 | return { 20 | id: data.id, 21 | name: data.name, 22 | } 23 | } 24 | 25 | module.exports = { generateAuthUrl, getUserInfoByAuthorizationCode } 26 | -------------------------------------------------------------------------------- /src/socket.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const io = require('socket.io')() 3 | const jwt = require('./jwt') 4 | const conf = require('@cnwangjie/conf') 5 | const User = require('./schema/user') 6 | const logger = require('./logger') 7 | 8 | io.on('connection', socket => { 9 | const token = socket.handshake.query[conf.jwt_header] 10 | if (!jwt.verifyToken(token)) return socket.disconnect() 11 | const uid = jwt.decode(token).sub 12 | socket.on('error', error => { 13 | logger.log(error) 14 | }) 15 | socket.join(uid) 16 | 17 | socket.on('list.time', async cb => { 18 | const user = await User.findOne({uid}) 19 | cb(user.listsUpdatedAt.valueOf()) 20 | }) 21 | 22 | socket.on('list.update', async ({method, args}, cb) => { 23 | if (!(method in User.schema.methods) 24 | || args.length !== User.schema.methods[method].length) return cb({ err: 'args error' }) 25 | 26 | const user = await User.findOne({uid}) 27 | user[method](...args) 28 | user.listsUpdatedAt = new Date() 29 | try { 30 | await user.save() 31 | const result = { err: null } 32 | socket.to(uid).emit('list.update', {method, args}) 33 | return cb(result) 34 | } catch (error) { 35 | return cb({ err: 'save err' }) 36 | } 37 | }) 38 | 39 | socket.on('list.all', async cb => { 40 | const user = await User.findOne({uid}) 41 | const lists = user.lists.map(list => _.pick(list, ['_id', 'updatedAt'])) 42 | cb(lists) 43 | }) 44 | 45 | socket.on('list.get', async (id, cb) => { 46 | const user = await User.findOne({uid}) 47 | const list = user.lists.id(id) 48 | cb(list) 49 | }) 50 | 51 | socket.on('opts.time', async cb => { 52 | const user = await User.findOne({uid}) 53 | cb(user.optsUpdatedAt.valueOf()) 54 | }) 55 | 56 | socket.on('opts.all', async cb => { 57 | const user = await User.findOne({uid}) 58 | cb(user.opts.toJSON()) 59 | }) 60 | 61 | socket.on('opts.set', async ({opts, time}, cb) => { 62 | const user = await User.findOne({uid}) 63 | if (time > Date.now() || time < user.optsUpdatedAt.valueOf()) 64 | return cb({ err: 'invalid time' }) 65 | 66 | for (const [k, v] of Object.entries(opts)) { 67 | user.opts[k] = v 68 | } 69 | user.optsUpdatedAt = time 70 | try { 71 | await user.save() 72 | socket.to(uid).emit('opts.set', {opts, time}) 73 | return cb({ err: null }) 74 | } catch (error) { 75 | return cb({ err: 'save err' }) 76 | } 77 | }) 78 | }) 79 | 80 | const emitToUser = (uid, event, data) => { 81 | process.nextTick(() => { 82 | io.sockets.in(uid).emit(event, data) 83 | }) 84 | } 85 | 86 | io.emitToUser = emitToUser 87 | 88 | module.exports = io 89 | -------------------------------------------------------------------------------- /src/ssr.js: -------------------------------------------------------------------------------- 1 | const nuxt = require('./nuxt') 2 | const Router = require('koa-router') 3 | const jwt = require('./jwt') 4 | 5 | const ssrRouter = module.exports = new Router() 6 | 7 | ssrRouter.use(jwt.authMiddleware) 8 | ssrRouter.get('*', async (ctx, next) => { 9 | await next() 10 | if (!ctx.headerSent) { 11 | ctx.status = 200 12 | ctx.respond = false 13 | ctx.req.ctx = ctx 14 | nuxt.render(ctx.req, ctx.res) 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | 3 | const sha256 = str => crypto.createHash('sha256').update(str).digest() 4 | 5 | const genUid = () => sha256(Date.now() + Math.random() + '').toString('hex').substr(0, 20) 6 | 7 | const genToken = user => { 8 | const token = crypto.randomBytes(32) 9 | user.encryptedToken = sha256(token).toString('base64') 10 | return token.toString('base64') 11 | } 12 | 13 | const verifyToken = (user, token) => { 14 | return user.encryptedToken === sha256(token).toString('base64') 15 | } 16 | 17 | const detectAndParseJson = str => { 18 | if (typeof str !== 'string') return str 19 | try { 20 | return JSON.parse(str) 21 | } catch (error) { 22 | return str 23 | } 24 | } 25 | 26 | module.exports = { 27 | genUid, 28 | genToken, 29 | verifyToken, 30 | detectAndParseJson, 31 | } 32 | -------------------------------------------------------------------------------- /src/web/assets/boeffect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnwangjie/better-onetab-sync-server/231e69cd683d0197fe83160a44ef5d357e11934f/src/web/assets/boeffect.png -------------------------------------------------------------------------------- /src/web/assets/icon.css: -------------------------------------------------------------------------------- 1 | 2 | .icon { 3 | width: 24px; 4 | height: 24px; 5 | position: absolute; 6 | left: 0; 7 | } 8 | -------------------------------------------------------------------------------- /src/web/assets/icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnwangjie/better-onetab-sync-server/231e69cd683d0197fe83160a44ef5d357e11934f/src/web/assets/icon_128.png -------------------------------------------------------------------------------- /src/web/getData.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const prepare = context => { 4 | let token 5 | if (process.client) { 6 | token = localStorage._BOSS_TOKEN 7 | } else if (process.server) { 8 | token = context.req.ctx.cookies.get('auth') 9 | } 10 | return axios.create({ 11 | withCredentials: true, 12 | baseURL: 'http://127.0.0.1:3000', 13 | headers: { 14 | auth: token, 15 | } 16 | }) 17 | } 18 | 19 | export default prepare 20 | -------------------------------------------------------------------------------- /src/web/layouts/app.vue: -------------------------------------------------------------------------------- 1 | 8 | 25 | -------------------------------------------------------------------------------- /src/web/pages/index.vue: -------------------------------------------------------------------------------- 1 | 64 | 78 | 84 | -------------------------------------------------------------------------------- /src/web/pages/info.vue: -------------------------------------------------------------------------------- 1 | 80 | 125 | 132 | -------------------------------------------------------------------------------- /src/web/pages/login.vue: -------------------------------------------------------------------------------- 1 | 27 | 42 | 45 | 46 | -------------------------------------------------------------------------------- /src/web/pages/success.vue: -------------------------------------------------------------------------------- 1 | 16 | 21 | -------------------------------------------------------------------------------- /src/web/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuetify from 'vuetify' 3 | Vue.use(Vuetify) 4 | -------------------------------------------------------------------------------- /src/web/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | 4 | Vue.use(Vuex) 5 | 6 | const store = () => new Vuex.Store({ 7 | state: { 8 | uid: '', 9 | }, 10 | mutations: { 11 | setUID(state, uid) { 12 | state.uid = uid 13 | }, 14 | }, 15 | actions: { 16 | nuxtServerInit({commit}, {req}) { 17 | if (req.ctx.user) commit('setUID', req.ctx.user.uid) 18 | }, 19 | } 20 | }) 21 | 22 | export default store 23 | --------------------------------------------------------------------------------