diff --git a/.config/example.yml b/.config/example.yml index 48fc360f3f..2637963e1f 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -148,6 +148,12 @@ drive: # consumer_key: example-twitter-consumer-key # consumer_secret: example-twitter-consumer-secret-key +# GitHub integration +# You need to set the oauth callback url as : https:///api/gh/cb +#github: +# client_id: example-github-client-id +# client_secret: example-github-client-secret + # Ghost # Ghost account is an account used for the purpose of delegating # followers when putting users in the list. diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index ec3896bdf8..6914b57ade 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -417,6 +417,7 @@ common/views/components/signin.vue: signin: "サインイン" or: "または" signin-with-twitter: "Twitterでログイン" + signin-with-github: "GitHubでログイン" login-failed: "ログインできませんでした。ユーザー名とパスワードを確認してください。" common/views/components/signup.vue: @@ -460,6 +461,14 @@ common/views/components/twitter-setting.vue: connect: "Twitterと接続する" disconnect: "切断する" +common/views/components/github-setting.vue: + description: "お使いのGitHubアカウントをお使いのMisskeyアカウントに接続しておくと、プロフィールでGitHubアカウント情報が表示されるようになったり、GitHubを用いた便利なサインインを利用できるようになります。" + connected-to: "次のGitHubアカウントに接続されています" + detail: "詳細..." + reconnect: "再接続する" + connect: "GitHubと接続する" + disconnect: "切断する" + common/views/components/uploader.vue: waiting: "待機中" @@ -1560,6 +1569,10 @@ mobile/views/pages/settings.vue: twitter-connect: "Twitterアカウントに接続する" twitter-reconnect: "再接続する" twitter-disconnect: "切断する" + github: "GitHub連携" + github-connect: "GitHubアカウントに接続する" + github-reconnect: "再接続する" + github-disconnect: "切断する" update: "Misskey Update" version: "バージョン:" latest-version: "最新のバージョン:" diff --git a/package.json b/package.json index e024e31864..3492522aad 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@types/mongodb": "3.1.12", "@types/ms": "0.7.30", "@types/node": "10.12.2", + "@types/oauth": "0.9.1", "@types/portscanner": "2.1.0", "@types/pug": "2.0.4", "@types/qrcode": "1.3.0", diff --git a/src/client/app/common/views/components/github-setting.vue b/src/client/app/common/views/components/github-setting.vue new file mode 100644 index 0000000000..f79a700a92 --- /dev/null +++ b/src/client/app/common/views/components/github-setting.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts index a3ddf10820..3b20d0753d 100644 --- a/src/client/app/common/views/components/index.ts +++ b/src/client/app/common/views/components/index.ts @@ -37,6 +37,7 @@ import messaging from './messaging.vue'; import messagingRoom from './messaging-room.vue'; import urlPreview from './url-preview.vue'; import twitterSetting from './twitter-setting.vue'; +import githubSetting from './github-setting.vue'; import fileTypeIcon from './file-type-icon.vue'; import Reversi from './games/reversi/reversi.vue'; import welcomeTimeline from './welcome-timeline.vue'; @@ -90,6 +91,7 @@ Vue.component('mk-messaging', messaging); Vue.component('mk-messaging-room', messagingRoom); Vue.component('mk-url-preview', urlPreview); Vue.component('mk-twitter-setting', twitterSetting); +Vue.component('mk-github-setting', githubSetting); Vue.component('mk-file-type-icon', fileTypeIcon); Vue.component('mk-reversi', Reversi); Vue.component('mk-welcome-timeline', welcomeTimeline); diff --git a/src/client/app/common/views/components/signin.vue b/src/client/app/common/views/components/signin.vue index 9224f82cb9..0b81daf176 100644 --- a/src/client/app/common/views/components/signin.vue +++ b/src/client/app/common/views/components/signin.vue @@ -13,6 +13,7 @@ {{ signing ? '%i18n:@signing-in%' : '%i18n:@signin%' }}

%i18n:@or% %i18n:@signin-with-twitter%

+

%i18n:@or% %i18n:@signin-with-github%

diff --git a/src/client/app/desktop/views/components/settings.vue b/src/client/app/desktop/views/components/settings.vue index 983a0b9bc2..93bef0e618 100644 --- a/src/client/app/desktop/views/components/settings.vue +++ b/src/client/app/desktop/views/components/settings.vue @@ -23,6 +23,13 @@ + + +
%fa:B github% %i18n:@github%
+
+ +
+
diff --git a/src/client/app/desktop/views/pages/user/user.github.vue b/src/client/app/desktop/views/pages/user/user.github.vue new file mode 100644 index 0000000000..abe99b8456 --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.github.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/src/client/app/desktop/views/pages/user/user.vue b/src/client/app/desktop/views/pages/user/user.vue index 0f58763f03..b137592c69 100644 --- a/src/client/app/desktop/views/pages/user/user.vue +++ b/src/client/app/desktop/views/pages/user/user.vue @@ -2,7 +2,7 @@
%fa:exclamation-triangle% %i18n:@is-suspended%
-
%fa:exclamation-triangle% %i18n:common.is-remote-user%%i18n:common.view-on-remote%
+
%fa:exclamation-triangle% %i18n:common.is-remote-user%%i18n:common.view-on-remote%
@@ -12,14 +12,15 @@
- + + -

%i18n:@last-used-at%:

+

%i18n:@last-used-at%:

@@ -37,6 +38,7 @@ import XPhotos from './user.photos.vue'; import XFollowersYouKnow from './user.followers-you-know.vue'; import XFriends from './user.friends.vue'; import XTwitter from './user.twitter.vue'; +import XGithub from './user.github.vue'; // ?MEM: Don't fix the intentional typo. (XGitHub -> ``) export default Vue.extend({ components: { @@ -46,7 +48,8 @@ export default Vue.extend({ XPhotos, XFollowersYouKnow, XFriends, - XTwitter + XTwitter, + XGithub // ?MEM: Don't fix the intentional typo. (see L41) }, data() { return { diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue index 4512e9b2c2..10d13423a1 100644 --- a/src/client/app/mobile/views/pages/settings.vue +++ b/src/client/app/mobile/views/pages/settings.vue @@ -125,6 +125,19 @@
+ +
%fa:B github% %i18n:@github%
+ +
+ +

+ {{ $store.state.i.github ? '%i18n:@github-reconnect%' : '%i18n:@github-connect%' }} + or + %i18n:@github-disconnect% +

+
+
+ diff --git a/src/config/types.ts b/src/config/types.ts index ee919abdec..ab5ba4c04b 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -74,6 +74,10 @@ export type Source = { consumer_key: string; consumer_secret: string; }; + github?: { + client_id: string; + client_secret: string; + }; github_bot?: { hook_secret: string; username: string; diff --git a/src/models/user.ts b/src/models/user.ts index 1e5b6ad74e..43ca612b51 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -82,6 +82,11 @@ export interface ILocalUser extends IUserBase { userId: string; screenName: string; }; + github: { + accessToken: string; + id: string; + login: string; + }; line: { userId: string; }; @@ -280,6 +285,9 @@ export const pack = ( delete _user.twitter.accessToken; delete _user.twitter.accessTokenSecret; } + if (_user.github) { + delete _user.github.accessToken; + } delete _user.line; // Visible via only the official client diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts index 3d26003a1a..b3027cd5ef 100644 --- a/src/server/api/endpoints/meta.ts +++ b/src/server/api/endpoints/meta.ts @@ -73,6 +73,7 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => { recaptcha: config.recaptcha ? true : false, objectStorage: config.drive && config.drive.storage === 'minio', twitter: config.twitter ? true : false, + github: config.github ? true : false, serviceWorker: config.sw ? true : false, userRecommendation: config.user_recommendation ? config.user_recommendation : {} } : undefined diff --git a/src/server/api/service/github.ts b/src/server/api/service/github.ts index ac18cf90ae..3296f6fd69 100644 --- a/src/server/api/service/github.ts +++ b/src/server/api/service/github.ts @@ -1,11 +1,16 @@ import * as EventEmitter from 'events'; +import * as Koa from 'koa'; import * as Router from 'koa-router'; import * as request from 'request'; -const crypto = require('crypto'); - -import User, { IUser } from '../../../models/user'; +import { OAuth2 } from 'oauth'; +import User, { IUser, pack, ILocalUser } from '../../../models/user'; import createNote from '../../../services/note/create'; import config from '../../../config'; +import { publishMainStream } from '../../../stream'; +import redis from '../../../db/redis'; +import uuid = require('uuid'); +import signin from '../common/signin'; +const crypto = require('crypto'); const handler = new EventEmitter(); @@ -28,10 +33,264 @@ const post = async (text: string, home = true) => { createNote(bot, { text, visibility: home ? 'home' : 'public' }); }; +function getUserToken(ctx: Koa.Context) { + return ((ctx.headers['cookie'] || '').match(/i=(!\w+)/) || [null, null])[1]; +} + +function compareOrigin(ctx: Koa.Context) { + function normalizeUrl(url: string) { + return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : ''; + } + + const referer = ctx.headers['referer']; + + return (normalizeUrl(referer) == normalizeUrl(config.url)); +} + // Init router const router = new Router(); -if (config.github_bot != null) { +router.get('/disconnect/github', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const user = await User.findOneAndUpdate({ + host: null, + 'token': userToken + }, { + $set: { + 'github': null + } + }); + + ctx.body = `GitHubの連携を解除しました :v:`; + + // Publish i updated event + publishMainStream(user._id, 'meUpdated', await pack(user, user, { + detail: true, + includeSecrets: true + })); +}); + +if (!config.github || !redis) { + router.get('/connect/github', ctx => { + ctx.body = '現在GitHubへ接続できません (このインスタンスではGitHubはサポートされていません)'; + }); + + router.get('/signin/github', ctx => { + ctx.body = '現在GitHubへ接続できません (このインスタンスではGitHubはサポートされていません)'; + }); +} else { + const oauth2 = new OAuth2( + config.github.client_id, + config.github.client_secret, + 'https://github.com/', + 'login/oauth/authorize', + 'login/oauth/access_token'); + + router.get('/connect/github', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const params = { + redirect_uri: `${config.url}:8089/api/gh/cb`, + scope: ['read:user'], + state: uuid() + }; + + redis.set(userToken, JSON.stringify(params)); + ctx.redirect(oauth2.getAuthorizeUrl(params)); + }); + + router.get('/signin/github', async ctx => { + const sessid = uuid(); + + const params = { + redirect_uri: `${config.url}:8089/api/gh/cb`, + scope: ['read:user'], + state: uuid() + }; + + const expires = 1000 * 60 * 60; // 1h + ctx.cookies.set('signin_with_github_session_id', sessid, { + path: '/', + domain: config.host, + secure: config.url.startsWith('https'), + httpOnly: true, + expires: new Date(Date.now() + expires), + maxAge: expires + }); + + redis.set(sessid, JSON.stringify(params)); + ctx.redirect(oauth2.getAuthorizeUrl(params)); + }); + + router.get('/gh/cb', async ctx => { + const userToken = getUserToken(ctx); + + if (!userToken) { + const sessid = ctx.cookies.get('signin_with_github_session_id'); + + if (!sessid) { + ctx.throw(400, 'invalid session'); + return; + } + + const code = ctx.query.code; + + if (!code) { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + redis.get(sessid, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken } = await new Promise((res, rej) => + oauth2.getOAuthAccessToken( + code, + { redirect_uri }, + (err, accessToken, refresh, result) => { + if (err) + rej(err); + else if (result.error) + rej(result.error); + else + res({ accessToken }); + })); + + const { login, id } = await new Promise((res, rej) => + request({ + url: 'https://api.github.com/user', + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'Authorization': `bearer ${accessToken}`, + 'User-Agent': config.user_agent + } + }, (err, response, body) => { + if (err) + rej(err); + else + res(JSON.parse(body)); + })); + + if (!login || !id) { + ctx.throw(400, 'invalid session'); + return; + } + + const user = await User.findOne({ + host: null, + 'github.id': id + }) as ILocalUser; + + if (!user) { + ctx.throw(404, `@${login}と連携しているMisskeyアカウントはありませんでした...`); + return; + } + + signin(ctx, user, true); + } else { + const code = ctx.query.code; + + if (!code) { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + redis.get(userToken, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken } = await new Promise((res, rej) => + oauth2.getOAuthAccessToken( + code, + { redirect_uri }, + (err, accessToken, refresh, result) => { + if (err) + rej(err); + else if (result.error) + rej(result.error); + else + res({ accessToken }); + })); + + const { login, id } = await new Promise((res, rej) => + request({ + url: 'https://api.github.com/user', + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'Authorization': `bearer ${accessToken}`, + 'User-Agent': config.user_agent + } + }, (err, response, body) => { + if (err) + rej(err); + else + res(JSON.parse(body)); + })); + + if (!login || !id) { + ctx.throw(400, 'invalid session'); + return; + } + + const user = await User.findOneAndUpdate({ + host: null, + token: userToken + }, { + $set: { + github: { + accessToken, + id, + login + } + } + }); + + ctx.body = `GitHub: @${login} を、Misskey: @${user.username} に接続しました!`; + + // Publish i updated event + publishMainStream(user._id, 'meUpdated', await pack(user, user, { + detail: true, + includeSecrets: true + })); + } + }); +} + +if (config.github_bot) { const secret = config.github_bot.hook_secret; router.post('/hooks/github', ctx => {