diff --git a/README.md b/README.md index 50cc1dac..ed2bffde 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ StackEdit中文版 - 导出HTML、PDF支持带预览主题导出(2023-02-26) - 支持分享文档(2023-03-30) - 支持ChatGPT生成内容(2023-04-10) +- GitLab授权接口调整(2023-08-26) ## 国外开源版本弊端: - 作者已经不维护了 @@ -113,6 +114,7 @@ services: - GITEA_CLIENT_SECRET=【不需要支持则删掉】 - GITEA_URL=【不需要支持则删掉】 - GITLAB_CLIENT_ID=【不需要支持则删掉】 + - GITLAB_CLIENT_SECRET=【不需要支持则删掉】 - GITLAB_URL=【不需要支持则删掉】 ports: - 8080:8080/tcp @@ -149,6 +151,7 @@ docker run -itd --name stackedit \ -e GITEA_CLIENT_SECRET=【不需要支持则删掉】 \ -e GITEA_URL=【不需要支持则删掉】 \ -e GITLAB_CLIENT_ID=【不需要支持则删掉】 \ + -e GITLAB_CLIENT_SECRET=【不需要支持则删掉】 \ -e GITLAB_URL=【不需要支持则删掉】 \ mafgwo/stackedit:【docker中央仓库找到最新版本】 @@ -163,7 +166,7 @@ docker run -itd --name stackedit \ - Gitea可选择性配置环境变量(未配置则在关联时前端指定,有配置则仅允许配置的应用信息):GITEA_CLIENT_ID、GITEA_CLIENT_SECRET、GITEA_URL,**[如何创建Gitea应用](./docs/部署之Gitea应用创建.md)** -- Gitlab可选择性配置环境变量(未配置则在关联时前端指定,有配置则仅允许配置的应用信息):GITLAB_CLIENT_ID、GITLAB_URL **如何创建Gitlab应用(待补充文档)** +- Gitlab可选择性配置环境变量(未配置则在关联时前端指定,有配置则仅允许配置的应用信息):GITLAB_CLIENT_ID、GITLAB_CLIENT_SECRET、GITLAB_URL **如何创建Gitlab应用(待补充文档)** (特别说明:自建的Gitea、Gitlab要能接入stackedit必须支持跨域) diff --git a/config/dev.env.js b/config/dev.env.js index 9df17460..4dc35d44 100644 --- a/config/dev.env.js +++ b/config/dev.env.js @@ -12,6 +12,7 @@ module.exports = merge(prodEnv, { // GITEA_CLIENT_ID: '"fe30f8f9-b1e8-4531-8f72-c1a5d3912805"', // GITEA_CLIENT_SECRET: '"lus7oMnb3H6M1hsChndphArE20Txr7erwJLf7SDBQWTw"', // GITEA_URL: '"https://gitea.test.com"', - // GITLAB_CLIENT_ID: '"33e01128c27fe75df3e5b35218d710c7df280e6ee9c90b6ca27ac9d9fdfb92f7"', - // GITLAB_URL: '"http://gitlab.qicoder.com"', + GITLAB_CLIENT_ID: '"074cd5103c62dea0f479dac861039656ac80935e304c8113a02cc64c629496ae"', + GITLAB_CLIENT_SECRET: '"6f406f24216b686d55d28313dec1913c2a8e599afdb08380d5e8ce838e16e41e"', + GITLAB_URL: '"http://gitlab.qicoder.com"', }) \ No newline at end of file diff --git a/server/conf.js b/server/conf.js index e0492325..d9ef0c03 100644 --- a/server/conf.js +++ b/server/conf.js @@ -15,6 +15,7 @@ const giteaClientId = process.env.GITEA_CLIENT_ID; const giteaClientSecret = process.env.GITEA_CLIENT_SECRET; const giteaUrl = process.env.GITEA_URL; const gitlabClientId = process.env.GITLAB_CLIENT_ID; +const gitlabClientSecret = process.env.GITLAB_CLIENT_SECRET; const gitlabUrl = process.env.GITLAB_URL; exports.values = { @@ -33,6 +34,9 @@ exports.values = { giteaClientId, giteaClientSecret, giteaUrl, + gitlabClientId, + gitlabClientSecret, + gitlabUrl, }; exports.publicValues = { diff --git a/server/gitea.js b/server/gitea.js index ea7c5b5a..1fd0d25f 100644 --- a/server/gitea.js +++ b/server/gitea.js @@ -1,4 +1,3 @@ -const qs = require('qs'); const request = require('request'); const conf = require('./conf'); diff --git a/server/gitlab.js b/server/gitlab.js new file mode 100644 index 00000000..170cde34 --- /dev/null +++ b/server/gitlab.js @@ -0,0 +1,40 @@ +const request = require('request'); +const conf = require('./conf'); + +function gitlabToken(queryParam) { + return new Promise((resolve, reject) => { + request({ + method: 'POST', + url: `${conf.values.gitlabUrl}/oauth/token`, + headers: { + 'content-type': 'application/json', + }, + json: true, + qs: { + ...queryParam, + client_id: conf.values.gitlabClientId, + client_secret: conf.values.gitlabClientSecret, + }, + }, (err, res, body) => { + if (err) { + reject(err); + } + const token = body.access_token; + if (token) { + resolve(body); + } else { + reject(res.statusCode + ',body:' + JSON.stringify(body)); + } + }); + }); +} + +exports.gitlabToken = (req, res) => { + gitlabToken(req.query) + .then( + tokenBody => res.send(tokenBody), + err => res + .status(400) + .send(err ? err.message || err.toString() : 'bad_code'), + ); +}; diff --git a/server/index.js b/server/index.js index 9ad7d738..1be80049 100644 --- a/server/index.js +++ b/server/index.js @@ -5,6 +5,7 @@ const path = require('path'); const github = require('./github'); const gitee = require('./gitee'); const gitea = require('./gitea'); +const gitlab = require('./gitlab'); const pdf = require('./pdf'); const pandoc = require('./pandoc'); const conf = require('./conf'); @@ -28,6 +29,7 @@ module.exports = (app) => { app.get('/oauth2/githubToken', github.githubToken); app.get('/oauth2/giteeToken', gitee.giteeToken); app.get('/oauth2/giteaToken', gitea.giteaToken); + app.get('/oauth2/gitlabToken', gitlab.gitlabToken); app.get('/conf', (req, res) => res.send(conf.publicValues)); app.post('/pdfExport', pdf.generate); app.post('/pandocExport', pandoc.generate); diff --git a/src/components/menus/PublishMenu.vue b/src/components/menus/PublishMenu.vue index 9a835acf..bdf03b86 100644 --- a/src/components/menus/PublishMenu.vue +++ b/src/components/menus/PublishMenu.vue @@ -263,8 +263,8 @@ export default { }, async addGitlabAccount() { try { - const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' }); - await gitlabHelper.addAccount(serverUrl, applicationId); + const { serverUrl, applicationId, applicationSecret } = await store.dispatch('modal/open', { type: 'gitlabAccount' }); + await gitlabHelper.addAccount(serverUrl, applicationId, applicationSecret); } catch (e) { /* cancel */ } }, async addGiteaAccount() { diff --git a/src/components/menus/SyncMenu.vue b/src/components/menus/SyncMenu.vue index d01555eb..61f9ee87 100644 --- a/src/components/menus/SyncMenu.vue +++ b/src/components/menus/SyncMenu.vue @@ -239,8 +239,8 @@ export default { }, async addGitlabAccount() { try { - const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' }); - await gitlabHelper.addAccount(serverUrl, applicationId); + const { serverUrl, applicationId, applicationSecret } = await store.dispatch('modal/open', { type: 'gitlabAccount' }); + await gitlabHelper.addAccount(serverUrl, applicationId, applicationSecret); } catch (e) { /* cancel */ } }, async addGiteaAccount() { diff --git a/src/components/menus/WorkspacesMenu.vue b/src/components/menus/WorkspacesMenu.vue index 693acd04..fb6ca5a6 100644 --- a/src/components/menus/WorkspacesMenu.vue +++ b/src/components/menus/WorkspacesMenu.vue @@ -85,8 +85,8 @@ export default { }, async addGitlabWorkspace() { try { - const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' }); - const token = await gitlabHelper.addAccount(serverUrl, applicationId); + const { serverUrl, applicationId, applicationSecret } = await store.dispatch('modal/open', { type: 'gitlabAccount' }); + const token = await gitlabHelper.addAccount(serverUrl, applicationId, applicationSecret); store.dispatch('modal/open', { type: 'gitlabWorkspace', token, diff --git a/src/components/modals/AccountManagementModal.vue b/src/components/modals/AccountManagementModal.vue index 68464a0a..3f7ea141 100644 --- a/src/components/modals/AccountManagementModal.vue +++ b/src/components/modals/AccountManagementModal.vue @@ -248,8 +248,8 @@ export default { }, async addGitlabAccount() { try { - const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' }); - await gitlabHelper.addAccount(serverUrl, applicationId); + const { serverUrl, applicationId, applicationSecret } = await store.dispatch('modal/open', { type: 'gitlabAccount' }); + await gitlabHelper.addAccount(serverUrl, applicationId, applicationSecret); } catch (e) { /* cancel */ } }, async addGiteaAccount() { diff --git a/src/components/modals/providers/GitlabAccountModal.vue b/src/components/modals/providers/GitlabAccountModal.vue index abe48dd2..d62ec28c 100644 --- a/src/components/modals/providers/GitlabAccountModal.vue +++ b/src/components/modals/providers/GitlabAccountModal.vue @@ -18,6 +18,9 @@ + + +
您必须使用重定向url {{redirectUrl}}配置OAuth2应用程序
@@ -47,6 +50,7 @@ export default modalTemplate({ computedLocalSettings: { serverUrl: 'gitlabServerUrl', applicationId: 'gitlabApplicationId', + applicationSecret: 'gitlabApplicationSecret', }, computed: { httpAppUrl() { @@ -78,7 +82,10 @@ export default modalTemplate({ if (!this.applicationId) { this.setError('applicationId'); } - if (serverUrl && this.applicationId) { + if (!this.applicationSecret) { + this.setError('applicationSecret'); + } + if (serverUrl && this.applicationId && this.applicationSecret) { const parsedUrl = serverUrl.match(/^(http[s]?:\/\/[^/]+)/); if (!parsedUrl) { this.setError('serverUrl'); @@ -86,6 +93,7 @@ export default modalTemplate({ this.config.resolve({ serverUrl: parsedUrl[1], applicationId: this.applicationId, + applicationSecret: this.applicationSecret, }); } } diff --git a/src/data/defaults/defaultLocalSettings.js b/src/data/defaults/defaultLocalSettings.js index b5db3a7a..7fe337c0 100644 --- a/src/data/defaults/defaultLocalSettings.js +++ b/src/data/defaults/defaultLocalSettings.js @@ -24,6 +24,7 @@ export default () => ({ giteePublishTemplate: 'jekyllSite', gitlabServerUrl: '', gitlabApplicationId: '', + gitlabApplicationSecret: '', gitlabProjectUrl: '', gitlabWorkspaceProjectUrl: '', gitlabPublishTemplate: 'plainText', diff --git a/src/services/providers/gitlabWorkspaceProvider.js b/src/services/providers/gitlabWorkspaceProvider.js index 3672f095..afb138ae 100644 --- a/src/services/providers/gitlabWorkspaceProvider.js +++ b/src/services/providers/gitlabWorkspaceProvider.js @@ -75,11 +75,11 @@ export default new Provider({ const sub = workspace ? workspace.sub : utils.queryParams.sub; let token = store.getters['data/gitlabTokensBySub'][sub]; if (!token) { - const { applicationId } = await store.dispatch('modal/open', { + const { applicationId, applicationSecret } = await store.dispatch('modal/open', { type: 'gitlabAccount', forceServerUrl: serverUrl, }); - token = await gitlabHelper.addAccount(serverUrl, applicationId, sub); + token = await gitlabHelper.addAccount(serverUrl, applicationId, applicationSecret, sub); } if (!workspace) { diff --git a/src/services/providers/helpers/gitlabHelper.js b/src/services/providers/helpers/gitlabHelper.js index 23c7903b..542b89e2 100644 --- a/src/services/providers/helpers/gitlabHelper.js +++ b/src/services/providers/helpers/gitlabHelper.js @@ -3,6 +3,9 @@ import networkSvc from '../../networkSvc'; import store from '../../../store'; import userSvc from '../../userSvc'; import badgeSvc from '../../badgeSvc'; +import constants from '../../../data/constants'; + +const tokenExpirationMargin = 5 * 60 * 1000; const request = ({ accessToken, serverUrl }, options) => networkSvc.request({ ...options, @@ -50,34 +53,90 @@ export default { /** * https://docs.gitlab.com/ee/api/oauth2.html */ - async startOauth2(serverUrl, applicationId, sub = null, silent = false) { + async startOauth2( + serverUrl, applicationId, applicationSecret, + sub = null, silent = false, refreshToken, + ) { let apiUrl = serverUrl; let clientId = applicationId; - // 获取gitea配置的参数 + let useServerConf = false; + // 获取gitlab配置的参数 await networkSvc.getServerConf(); const confClientId = store.getters['data/serverConf'].gitlabClientId; const confServerUrl = store.getters['data/serverConf'].gitlabUrl; - // 存在gitea配置则使用后端配置 + // 存在gitlab配置则使用后端配置 if (confClientId && confServerUrl) { apiUrl = confServerUrl; clientId = confClientId; + useServerConf = true; + } + let tokenBody; + if (!silent) { + // Get an OAuth2 code + const { code } = await networkSvc.startOauth2( + `${apiUrl}/oauth/authorize`, + { + client_id: clientId, + response_type: 'code', + redirect_uri: constants.oauth2RedirectUri, + }, + silent, + ); + if (useServerConf) { + tokenBody = (await networkSvc.request({ + method: 'GET', + url: 'oauth2/gitlabToken', + params: { + code, + grant_type: 'authorization_code', + redirect_uri: constants.oauth2RedirectUri, + }, + })).body; + } else { + // Exchange code with token + tokenBody = (await networkSvc.request({ + method: 'POST', + url: `${apiUrl}/oauth/token`, + params: { + client_id: clientId, + client_secret: applicationSecret, + code, + grant_type: 'authorization_code', + redirect_uri: constants.oauth2RedirectUri, + }, + })).body; + } + } else if (useServerConf) { + tokenBody = (await networkSvc.request({ + method: 'GET', + url: 'oauth2/gitlabToken', + params: { + refresh_token: refreshToken, + grant_type: 'refresh_token', + redirect_uri: constants.oauth2RedirectUri, + }, + })).body; + } else { + // Exchange refreshToken with token + tokenBody = (await networkSvc.request({ + method: 'POST', + url: `${apiUrl}/oauth/token`, + body: { + client_id: clientId, + client_secret: applicationSecret, + refresh_token: refreshToken, + grant_type: 'refresh_token', + redirect_uri: constants.oauth2RedirectUri, + }, + })).body; } - // Get an OAuth2 code - const { accessToken } = await networkSvc.startOauth2( - `${apiUrl}/oauth/authorize`, - { - client_id: clientId, - response_type: 'token', - scope: 'api', - }, - silent, - ); + const accessToken = tokenBody.access_token; // Call the user info endpoint - const user = await request({ accessToken, serverUrl }, { + const user = await request({ accessToken, serverUrl: apiUrl }, { url: 'user', }); - const uniqueSub = `${serverUrl}/${user.id}`; + const uniqueSub = `${apiUrl}/${user.id}`; userSvc.addUserInfo({ id: `${subPrefix}:${uniqueSub}`, name: user.username, @@ -89,11 +148,17 @@ export default { throw new Error('GitLab account ID not expected.'); } + const oldToken = store.getters['data/gitlabTokensBySub'][uniqueSub]; // Build token object including scopes and sub const token = { accessToken, name: user.username, - serverUrl, + applicationId: clientId, + applicationSecret, + imgStorages: oldToken && oldToken.imgStorages, + refreshToken: tokenBody.refresh_token, + expiresOn: Date.now() + ((tokenBody.expires_in || 7200) * 1000), + serverUrl: apiUrl, sub: uniqueSub, }; @@ -101,12 +166,58 @@ export default { store.dispatch('data/addGitlabToken', token); return token; }, - async addAccount(serverUrl, applicationId, sub = null) { - const token = await this.startOauth2(serverUrl, applicationId, sub); + async addAccount(serverUrl, applicationId, applicationSecret, sub = null) { + const token = await this.startOauth2(serverUrl, applicationId, applicationSecret, sub); badgeSvc.addBadge('addGitLabAccount'); return token; }, + // 刷新token + async refreshToken(token) { + const { + serverUrl, + applicationId, + applicationSecret, + sub, + } = token; + const lastToken = store.getters['data/gitlabTokensBySub'][sub]; + // 兼容旧的没有过期时间 + if (!lastToken.expiresOn || !lastToken.refreshToken) { + await store.dispatch('modal/open', { + type: 'providerRedirection', + name: 'Gitlab', + }); + return this.startOauth2(serverUrl, applicationId, applicationSecret, sub); + } + // lastToken is not expired + if (lastToken.expiresOn > Date.now() + tokenExpirationMargin) { + return lastToken; + } + // existing token is about to expire. + // Try to get a new token in background + try { + return await this.startOauth2( + serverUrl, applicationId, applicationSecret, + sub, true, lastToken.refreshToken, + ); + } catch (err) { + // If it fails try to popup a window + if (store.state.offline) { + throw err; + } + await store.dispatch('modal/open', { + type: 'providerRedirection', + name: 'Gitlab', + }); + return this.startOauth2(serverUrl, applicationId, applicationSecret, sub); + } + }, + // 带刷新token + async requestWithRefreshToken(token, options) { + const refreshedToken = await this.refreshToken(token); + const result = await request(refreshedToken, options); + return result; + }, /** * https://docs.gitlab.com/ee/api/projects.html#get-single-project */ @@ -114,8 +225,7 @@ export default { if (projectId) { return projectId; } - - const project = await request(token, { + const project = await this.requestWithRefreshToken(token, { url: `projects/${encodeURIComponent(projectPath)}`, }); return project.id; @@ -129,7 +239,7 @@ export default { projectId, branch, }) { - return request(token, { + return this.requestWithRefreshToken(token, { url: `projects/${encodeURIComponent(projectId)}/repository/tree`, params: { ref: branch, @@ -148,7 +258,7 @@ export default { branch, path, }) { - return request(token, { + return this.requestWithRefreshToken(token, { url: `projects/${encodeURIComponent(projectId)}/repository/commits`, params: { ref_name: branch, @@ -175,7 +285,7 @@ export default { if (isImg && typeof content !== 'string') { uploadContent = await utils.encodeFiletoBase64(content); } - return request(token, { + return this.requestWithRefreshToken(token, { method: sha ? 'PUT' : 'POST', url: `projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(path)}`, body: { @@ -198,7 +308,7 @@ export default { path, sha, }) { - return request(token, { + return this.requestWithRefreshToken(token, { method: 'DELETE', url: `projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(path)}`, body: { @@ -219,7 +329,7 @@ export default { path, isImg, }) { - const res = await request(token, { + const res = await this.requestWithRefreshToken(token, { url: `projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(path)}`, params: { ref: branch }, });