diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 32cf7d075..eb26ba5ee 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -399,6 +399,7 @@ common/views/components/signin.vue:
or: "または"
signin-with-twitter: "Twitterでログイン"
signin-with-github: "GitHubでログイン"
+ signin-with-discord: "Discordでログイン"
login-failed: "ログインできませんでした。ユーザー名とパスワードを確認してください。"
common/views/components/signup.vue:
@@ -450,6 +451,14 @@ common/views/components/github-setting.vue:
connect: "GitHubと接続する"
disconnect: "切断する"
+common/views/components/discord-setting.vue:
+ description: "お使いのDiscordアカウントをお使いのMisskeyアカウントに接続しておくと、プロフィールでDiscordアカウント情報が表示されるようになったり、Discordを用いた便利なサインインを利用できるようになります。"
+ connected-to: "次のDiscordアカウントに接続されています"
+ detail: "詳細..."
+ reconnect: "再接続する"
+ connect: "Discordと接続する"
+ disconnect: "切断する"
+
common/views/components/uploader.vue:
waiting: "待機中"
@@ -1081,7 +1090,12 @@ admin/views/instance.vue:
github-integration-info: "コールバックURLは /api/gh/cb に設定します。"
enable-github-integration: "GitHub連携を有効にする"
github-integration-client-id: "Client ID"
- github-integration-client-secret: "Client secret"
+ github-integration-client-secret: "Client Secret"
+ discord-integration-config: "Discord連携の設定"
+ discord-integration-info: "コールバックURLは /api/dc/cb に設定します。"
+ enable-discord-integration: "Discord連携を有効にする"
+ discord-integration-client-id: "Client ID"
+ discord-integration-client-secret: "Client Secret"
proxy-account-config: "プロキシアカウントの設定"
proxy-account-info: "プロキシアカウントは、特定の条件下でユーザーのリモートフォローを代行するアカウントです。例えば、ユーザーがリモートユーザーをリストに入れたとき、リストに入れられたユーザーを誰もフォローしていないとアクティビティがサーバーに配達されないため、代わりにプロキシアカウントがフォローするようにします。"
proxy-account-username: "プロキシアカウントのユーザー名"
@@ -1530,6 +1544,10 @@ mobile/views/pages/settings.vue:
github-connect: "GitHubアカウントに接続する"
github-reconnect: "再接続する"
github-disconnect: "切断する"
+ discord: "Discord連携"
+ discord-connect: "Discordアカウントに接続する"
+ discord-reconnect: "再接続する"
+ discord-disconnect: "切断する"
update: "Misskey Update"
version: "バージョン:"
latest-version: "最新のバージョン:"
diff --git a/src/client/app/admin/views/instance.vue b/src/client/app/admin/views/instance.vue
index e52a20d70..4c234ec26 100644
--- a/src/client/app/admin/views/instance.vue
+++ b/src/client/app/admin/views/instance.vue
@@ -76,6 +76,17 @@
{{ $t('save') }}
+
+
+ {{ $t('discord-integration-config') }}
+
+ {{ $t('enable-discord-integration') }}
+ {{ $t('discord-integration-info') }}
+ {{ $t('discord-integration-client-id') }}
+ {{ $t('discord-integration-client-secret') }}
+ {{ $t('save') }}
+
+
@@ -113,6 +124,9 @@ export default Vue.extend({
enableGithubIntegration: false,
githubClientId: null,
githubClientSecret: null,
+ enableDiscordIntegration: false,
+ discordClientId: null,
+ discordClientSecret: null,
proxyAccount: null,
inviteCode: null,
faHeadset, faShieldAlt, faGhost
@@ -141,6 +155,9 @@ export default Vue.extend({
this.enableGithubIntegration = meta.enableGithubIntegration;
this.githubClientId = meta.githubClientId;
this.githubClientSecret = meta.githubClientSecret;
+ this.enableDiscordIntegration = meta.enableDiscordIntegration;
+ this.discordClientId = meta.discordClientId;
+ this.discordClientSecret = meta.discordClientSecret;
});
},
@@ -180,6 +197,9 @@ export default Vue.extend({
enableGithubIntegration: this.enableGithubIntegration,
githubClientId: this.githubClientId,
githubClientSecret: this.githubClientSecret,
+ enableDiscordIntegration: this.enableDiscordIntegration,
+ discordClientId: this.discordClientId,
+ discordClientSecret: this.discordClientSecret
}).then(() => {
this.$root.alert({
type: 'success',
diff --git a/src/client/app/common/views/components/discord-setting.vue b/src/client/app/common/views/components/discord-setting.vue
new file mode 100644
index 000000000..113df9b0a
--- /dev/null
+++ b/src/client/app/common/views/components/discord-setting.vue
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts
index eb2bc5aef..30c38ff6c 100644
--- a/src/client/app/common/views/components/index.ts
+++ b/src/client/app/common/views/components/index.ts
@@ -29,6 +29,7 @@ import ellipsis from './ellipsis.vue';
import urlPreview from './url-preview.vue';
import twitterSetting from './twitter-setting.vue';
import githubSetting from './github-setting.vue';
+import discordSetting from './discord-setting.vue';
import fileTypeIcon from './file-type-icon.vue';
import emoji from './emoji.vue';
import welcomeTimeline from './welcome-timeline.vue';
@@ -74,6 +75,7 @@ Vue.component('mk-ellipsis', ellipsis);
Vue.component('mk-url-preview', urlPreview);
Vue.component('mk-twitter-setting', twitterSetting);
Vue.component('mk-github-setting', githubSetting);
+Vue.component('mk-discord-setting', discordSetting);
Vue.component('mk-file-type-icon', fileTypeIcon);
Vue.component('mk-emoji', emoji);
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 e02e61375..6ea7f652d 100644
--- a/src/client/app/common/views/components/signin.vue
+++ b/src/client/app/common/views/components/signin.vue
@@ -14,6 +14,7 @@
{{ signing ? $t('signing-in') : $t('signin') }}
{{ $t('signin-with-twitter') }}
{{ $t('signin-with-github') }}
+ {{ $t('signin-with-discord') /* TODO: Make these layouts better */ }}
diff --git a/src/client/app/desktop/views/components/settings.vue b/src/client/app/desktop/views/components/settings.vue
index 99e8064ce..d7d20583e 100644
--- a/src/client/app/desktop/views/components/settings.vue
+++ b/src/client/app/desktop/views/components/settings.vue
@@ -30,6 +30,13 @@
+
+
+ {{ $t('discord') }}
+
+
diff --git a/src/client/app/desktop/views/pages/user/user.discord.vue b/src/client/app/desktop/views/pages/user/user.discord.vue
new file mode 100644
index 000000000..30db57855
--- /dev/null
+++ b/src/client/app/desktop/views/pages/user/user.discord.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/src/client/app/desktop/views/pages/user/user.vue b/src/client/app/desktop/views/pages/user/user.vue
index 758a98137..1333d313f 100644
--- a/src/client/app/desktop/views/pages/user/user.vue
+++ b/src/client/app/desktop/views/pages/user/user.vue
@@ -14,6 +14,7 @@
+
@@ -39,6 +40,7 @@ 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 -> ``)
+import XDiscord from './user.discord.vue';
export default Vue.extend({
i18n: i18n(),
@@ -50,7 +52,8 @@ export default Vue.extend({
XFollowersYouKnow,
XFriends,
XTwitter,
- XGithub // ?MEM: Don't fix the intentional typo. (see L41)
+ XGithub, // ?MEM: Don't fix the intentional typo. (see L41)
+ XDiscord
},
data() {
return {
diff --git a/src/client/app/init.ts b/src/client/app/init.ts
index 9896cafdd..cc702950b 100644
--- a/src/client/app/init.ts
+++ b/src/client/app/init.ts
@@ -143,6 +143,7 @@ import {
import {
faTwitter as fabTwitter,
faGithub as fabGithub,
+ faDiscord as fabDiscord
} from '@fortawesome/free-brands-svg-icons';
import i18n from './i18n';
@@ -259,7 +260,8 @@ library.add(
farHdd,
fabTwitter,
- fabGithub
+ fabGithub,
+ fabDiscord
);
//#endregion
diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue
index fceaebc66..08cae29a3 100644
--- a/src/client/app/mobile/views/pages/settings.vue
+++ b/src/client/app/mobile/views/pages/settings.vue
@@ -140,6 +140,19 @@
+
+ {{ $t('discord') }}
+
+
+
+
diff --git a/src/misc/fetch-meta.ts b/src/misc/fetch-meta.ts
index cf7d37522..e855097c4 100644
--- a/src/misc/fetch-meta.ts
+++ b/src/misc/fetch-meta.ts
@@ -15,6 +15,7 @@ const defaultMeta: any = {
maxNoteTextLength: 1000,
enableTwitterIntegration: false,
enableGithubIntegration: false,
+ enableDiscordIntegration: false
};
export default async function(): Promise {
diff --git a/src/models/meta.ts b/src/models/meta.ts
index a12747ea3..34117afd2 100644
--- a/src/models/meta.ts
+++ b/src/models/meta.ts
@@ -191,4 +191,8 @@ export type IMeta = {
enableGithubIntegration?: boolean;
githubClientId?: string;
githubClientSecret?: string;
+
+ enableDiscordIntegration?: boolean;
+ discordClientId?: string;
+ discordClientSecret?: string;
};
diff --git a/src/models/user.ts b/src/models/user.ts
index a5863de8d..22eecb571 100644
--- a/src/models/user.ts
+++ b/src/models/user.ts
@@ -88,6 +88,14 @@ export interface ILocalUser extends IUserBase {
id: string;
login: string;
};
+ discord: {
+ accessToken: string;
+ refreshToken: string;
+ expiresDate: number;
+ id: string;
+ username: string;
+ discriminator: string;
+ };
line: {
userId: string;
};
@@ -291,6 +299,11 @@ export const pack = (
if (_user.github) {
delete _user.github.accessToken;
}
+ if (_user.discord) {
+ delete _user.discord.accessToken;
+ delete _user.discord.refreshToken;
+ delete _user.discord.expiresDate;
+ }
delete _user.line;
// Visible via only the official client
diff --git a/src/server/api/endpoints/admin/update-meta.ts b/src/server/api/endpoints/admin/update-meta.ts
index 1e4ff959d..bbae212bd 100644
--- a/src/server/api/endpoints/admin/update-meta.ts
+++ b/src/server/api/endpoints/admin/update-meta.ts
@@ -177,9 +177,30 @@ export const meta = {
githubClientSecret: {
validator: $.str.optional.nullable,
desc: {
- 'ja-JP': 'GitHubアプリのClient secret'
+ 'ja-JP': 'GitHubアプリのClient Secret'
}
},
+
+ enableDiscordIntegration: {
+ validator: $.bool.optional,
+ desc: {
+ 'ja-JP': 'Discord連携機能を有効にするか否か'
+ }
+ },
+
+ discordClientId: {
+ validator: $.str.optional.nullable,
+ desc: {
+ 'ja-JP': 'DiscordアプリのClient ID'
+ }
+ },
+
+ discordClientSecret: {
+ validator: $.str.optional.nullable,
+ desc: {
+ 'ja-JP': 'DiscordアプリのClient Secret'
+ }
+ }
}
};
@@ -282,6 +303,18 @@ export default define(meta, (ps) => new Promise(async (res, rej) => {
set.githubClientSecret = ps.githubClientSecret;
}
+ if (ps.enableDiscordIntegration !== undefined) {
+ set.enableDiscordIntegration = ps.enableDiscordIntegration;
+ }
+
+ if (ps.discordClientId !== undefined) {
+ set.discordClientId = ps.discordClientId;
+ }
+
+ if (ps.discordClientSecret !== undefined) {
+ set.discordClientSecret = ps.discordClientSecret;
+ }
+
await Meta.update({}, {
$set: set
}, { upsert: true });
diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts
index b324b113c..56386cc1f 100644
--- a/src/server/api/endpoints/meta.ts
+++ b/src/server/api/endpoints/meta.ts
@@ -79,6 +79,7 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => {
objectStorage: config.drive && config.drive.storage === 'minio',
twitter: instance.enableTwitterIntegration,
github: instance.enableGithubIntegration,
+ discord: instance.enableDiscordIntegration,
serviceWorker: config.sw ? true : false,
userRecommendation: config.user_recommendation ? config.user_recommendation : {}
};
@@ -94,6 +95,9 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => {
response.enableGithubIntegration = instance.enableGithubIntegration;
response.githubClientId = instance.githubClientId;
response.githubClientSecret = instance.githubClientSecret;
+ response.enableDiscordIntegration = instance.enableDiscordIntegration;
+ response.discordClientId = instance.discordClientId;
+ response.discordClientSecret = instance.discordClientSecret;
}
res(response);
diff --git a/src/server/api/index.ts b/src/server/api/index.ts
index bb8bad8bb..1cd002857 100644
--- a/src/server/api/index.ts
+++ b/src/server/api/index.ts
@@ -43,6 +43,7 @@ endpoints.forEach(endpoint => endpoint.meta.requireFile
router.post('/signup', require('./private/signup').default);
router.post('/signin', require('./private/signin').default);
+router.use(require('./service/discord').routes());
router.use(require('./service/github').routes());
router.use(require('./service/github-bot').routes());
router.use(require('./service/twitter').routes());
diff --git a/src/server/api/service/discord.ts b/src/server/api/service/discord.ts
new file mode 100644
index 000000000..d90f39ffb
--- /dev/null
+++ b/src/server/api/service/discord.ts
@@ -0,0 +1,306 @@
+import * as Koa from 'koa';
+import * as Router from 'koa-router';
+import * as request from 'request';
+import { OAuth2 } from 'oauth';
+import User, { pack, ILocalUser } from '../../../models/user';
+import config from '../../../config';
+import { publishMainStream } from '../../../stream';
+import redis from '../../../db/redis';
+import uuid = require('uuid');
+import signin from '../common/signin';
+import fetchMeta from '../../../misc/fetch-meta';
+
+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();
+
+router.get('/disconnect/discord', 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: {
+ 'discord': null
+ }
+ });
+
+ ctx.body = `Discordの連携を解除しました :v:`;
+
+ // Publish i updated event
+ publishMainStream(user._id, 'meUpdated', await pack(user, user, {
+ detail: true,
+ includeSecrets: true
+ }));
+});
+
+async function getOAuth2() {
+ const meta = await fetchMeta();
+
+ if (meta.enableDiscordIntegration) {
+ return new OAuth2(
+ meta.discordClientId,
+ meta.discordClientSecret,
+ 'https://discordapp.com/',
+ 'api/oauth2/authorize',
+ 'api/oauth2/token');
+ } else {
+ return null;
+ }
+}
+
+router.get('/connect/discord', 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}/api/dc/cb`,
+ scope: ['identify'],
+ state: uuid(),
+ response_type: 'code'
+ };
+
+ redis.set(userToken, JSON.stringify(params));
+
+ const oauth2 = await getOAuth2();
+ ctx.redirect(oauth2.getAuthorizeUrl(params));
+});
+
+router.get('/signin/discord', async ctx => {
+ const sessid = uuid();
+
+ const params = {
+ redirect_uri: `${config.url}/api/dc/cb`,
+ scope: ['identify'],
+ state: uuid(),
+ response_type: 'code'
+ };
+
+ const expires = 1000 * 60 * 60; // 1h
+ ctx.cookies.set('signin_with_discord_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));
+
+ const oauth2 = await getOAuth2();
+ ctx.redirect(oauth2.getAuthorizeUrl(params));
+});
+
+router.get('/dc/cb', async ctx => {
+ const userToken = getUserToken(ctx);
+
+ const oauth2 = await getOAuth2();
+
+ if (!userToken) {
+ const sessid = ctx.cookies.get('signin_with_discord_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, refreshToken, expiresDate } = await new Promise((res, rej) =>
+ oauth2.getOAuthAccessToken(
+ code,
+ {
+ grant_type: 'authorization_code',
+ redirect_uri
+ },
+ (err, accessToken, refreshToken, result) => {
+ if (err)
+ rej(err);
+ else if (result.error)
+ rej(result.error);
+ else
+ res({
+ accessToken,
+ refreshToken,
+ expiresDate: Date.now() + Number(result.expires_in) * 1000
+ });
+ }));
+
+ const { id, username, discriminator } = await new Promise((res, rej) =>
+ request({
+ url: 'https://discordapp.com/api/users/@me',
+ headers: {
+ 'Authorization': `Bearer ${accessToken}`,
+ 'User-Agent': config.user_agent
+ }
+ }, (err, response, body) => {
+ if (err)
+ rej(err);
+ else
+ res(JSON.parse(body));
+ }));
+
+ if (!id || !username || !discriminator) {
+ ctx.throw(400, 'invalid session');
+ return;
+ }
+
+ let user = await User.findOne({
+ host: null,
+ 'discord.id': id
+ }) as ILocalUser;
+
+ if (!user) {
+ ctx.throw(404, `@${username}#${discriminator}と連携しているMisskeyアカウントはありませんでした...`);
+ return;
+ }
+
+ user = await User.findOneAndUpdate({
+ host: null,
+ 'discord.id': id
+ }, {
+ $set: {
+ discord: {
+ accessToken,
+ refreshToken,
+ expiresDate,
+ username,
+ discriminator
+ }
+ }
+ }) as ILocalUser;
+
+ 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, refreshToken, expiresDate } = await new Promise((res, rej) =>
+ oauth2.getOAuthAccessToken(
+ code,
+ {
+ grant_type: 'authorization_code',
+ redirect_uri
+ },
+ (err, accessToken, refreshToken, result) => {
+ if (err)
+ rej(err);
+ else if (result.error)
+ rej(result.error);
+ else
+ res({
+ accessToken,
+ refreshToken,
+ expiresDate: Date.now() + Number(result.expires_in) * 1000
+ });
+ }));
+
+ const { id, username, discriminator } = await new Promise((res, rej) =>
+ request({
+ url: 'https://discordapp.com/api/users/@me',
+ headers: {
+ 'Authorization': `Bearer ${accessToken}`,
+ 'User-Agent': config.user_agent
+ }
+ }, (err, response, body) => {
+ if (err)
+ rej(err);
+ else
+ res(JSON.parse(body));
+ }));
+
+ if (!id || !username || !discriminator) {
+ ctx.throw(400, 'invalid session');
+ return;
+ }
+
+ const user = await User.findOneAndUpdate({
+ host: null,
+ token: userToken
+ }, {
+ $set: {
+ discord: {
+ accessToken,
+ refreshToken,
+ expiresDate,
+ id,
+ username,
+ discriminator
+ }
+ }
+ });
+
+ ctx.body = `Discord: @${username}#${discriminator} を、Misskey: @${user.username} に接続しました!`;
+
+ // Publish i updated event
+ publishMainStream(user._id, 'meUpdated', await pack(user, user, {
+ detail: true,
+ includeSecrets: true
+ }));
+ }
+});
+
+module.exports = router;