diff --git a/config/dev.env.js b/config/dev.env.js index 00822024..0620891b 100644 --- a/config/dev.env.js +++ b/config/dev.env.js @@ -3,6 +3,7 @@ var prodEnv = require('./prod.env') module.exports = merge(prodEnv, { NODE_ENV: '"development"', + // 以下配置是开发临时用的配置 随时可能失效 请替换为自己的 GITHUB_CLIENT_ID: '"845b8f75df48f2ee0563"', GITHUB_CLIENT_SECRET: '"80df676597abded1450926861965cc3f9bead6a0"', GITEE_CLIENT_ID: '"925ba7c78b85dec984f7877e4aca5cab10ae333c6d68e761bdb0b9dfb8f55672"', diff --git a/server/gitee.js b/server/gitee.js index 9ac6eb5f..b7d45b1e 100644 --- a/server/gitee.js +++ b/server/gitee.js @@ -2,11 +2,7 @@ const qs = require('qs'); // eslint-disable-line import/no-extraneous-dependenci const request = require('request'); const conf = require('./conf'); -function giteeToken(clientId, code) { - console.log('clientId: ' + clientId); - console.log('code: ' + code); - console.log('client_secret: ' + conf.values.giteeClientSecret); - console.log('redirect_uri: ' + conf.values.giteeCallback); +function giteeToken(clientId, code, oauth2RedirectUri) { return new Promise((resolve, reject) => { request({ method: 'POST', @@ -17,7 +13,7 @@ function giteeToken(clientId, code) { code, grant_type: 'authorization_code', scope: 'authorization_code', - redirect_uri: conf.values.giteeCallback, + redirect_uri: oauth2RedirectUri, }, json: true }, (err, res, body) => { @@ -35,7 +31,7 @@ function giteeToken(clientId, code) { } exports.giteeToken = (req, res) => { - giteeToken(req.query.clientId, req.query.code) + giteeToken(req.query.clientId, req.query.code, req.query.oauth2RedirectUri) .then( token => res.send(token), err => res diff --git a/src/assets/iconGitea.svg b/src/assets/iconGitea.svg new file mode 100644 index 00000000..35e637be --- /dev/null +++ b/src/assets/iconGitea.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/components/Modal.vue b/src/components/Modal.vue index 6e6babca..b37f1a0c 100644 --- a/src/components/Modal.vue +++ b/src/components/Modal.vue @@ -66,6 +66,11 @@ import GitlabOpenModal from './modals/providers/GitlabOpenModal'; import GitlabPublishModal from './modals/providers/GitlabPublishModal'; import GitlabSaveModal from './modals/providers/GitlabSaveModal'; import GitlabWorkspaceModal from './modals/providers/GitlabWorkspaceModal'; +import GiteaAccountModal from './modals/providers/GiteaAccountModal'; +import GiteaOpenModal from './modals/providers/GiteaOpenModal'; +import GiteaPublishModal from './modals/providers/GiteaPublishModal'; +import GiteaSaveModal from './modals/providers/GiteaSaveModal'; +import GiteaWorkspaceModal from './modals/providers/GiteaWorkspaceModal'; import WordpressPublishModal from './modals/providers/WordpressPublishModal'; import BloggerPublishModal from './modals/providers/BloggerPublishModal'; import BloggerPagePublishModal from './modals/providers/BloggerPagePublishModal'; @@ -122,6 +127,11 @@ export default { GitlabPublishModal, GitlabSaveModal, GitlabWorkspaceModal, + GiteaAccountModal, + GiteaOpenModal, + GiteaPublishModal, + GiteaSaveModal, + GiteaWorkspaceModal, WordpressPublishModal, BloggerPublishModal, BloggerPagePublishModal, diff --git a/src/components/menus/MainMenu.vue b/src/components/menus/MainMenu.vue index 6d16b8bd..fa0d4f7d 100644 --- a/src/components/menus/MainMenu.vue +++ b/src/components/menus/MainMenu.vue @@ -29,6 +29,9 @@ {{currentWorkspace.name}} synced with a GitLab project. + + {{currentWorkspace.name}} synced with a Gitea project. +
diff --git a/src/components/menus/PublishMenu.vue b/src/components/menus/PublishMenu.vue index 37bb77b9..f3d1255c 100644 --- a/src/components/menus/PublishMenu.vue +++ b/src/components/menus/PublishMenu.vue @@ -66,6 +66,13 @@ {{token.name}}
+
+ + +
Publish to Gitea
+ {{token.name}} +
+
@@ -108,6 +115,10 @@ Add GitLab account + + + Add Gitea account + Add Google Drive account @@ -132,6 +143,7 @@ import dropboxHelper from '../../services/providers/helpers/dropboxHelper'; import githubHelper from '../../services/providers/helpers/githubHelper'; import giteeHelper from '../../services/providers/helpers/giteeHelper'; import gitlabHelper from '../../services/providers/helpers/gitlabHelper'; +import giteaHelper from '../../services/providers/helpers/giteaHelper'; import wordpressHelper from '../../services/providers/helpers/wordpressHelper'; import zendeskHelper from '../../services/providers/helpers/zendeskHelper'; import publishSvc from '../../services/publishSvc'; @@ -186,6 +198,9 @@ export default { gitlabTokens() { return tokensToArray(store.getters['data/gitlabTokensBySub']); }, + giteaTokens() { + return tokensToArray(store.getters['data/giteaTokensBySub']); + }, googleDriveTokens() { return tokensToArray(store.getters['data/googleTokensBySub'], token => token.isDrive); }, @@ -199,7 +214,9 @@ export default { return !this.bloggerTokens.length && !this.dropboxTokens.length && !this.githubTokens.length + && !this.giteeTokens.length && !this.gitlabTokens.length + && !this.giteaTokens.length && !this.googleDriveTokens.length && !this.wordpressTokens.length && !this.zendeskTokens.length; @@ -245,6 +262,12 @@ export default { await gitlabHelper.addAccount(serverUrl, applicationId); } catch (e) { /* cancel */ } }, + async addGiteaAccount() { + try { + const { serverUrl, applicationId, applicationSecret } = await store.dispatch('modal/open', { type: 'giteaAccount' }); + await giteaHelper.addAccount(serverUrl, applicationId, applicationSecret); + } catch (e) { /* cancel */ } + }, async addGoogleDriveAccount() { try { await store.dispatch('modal/open', { type: 'googleDriveAccount' }); @@ -269,6 +292,7 @@ export default { publishGitee: publishModalOpener('giteePublish', 'publishToGitee'), publishGist: publishModalOpener('gistPublish', 'publishToGist'), publishGitlab: publishModalOpener('gitlabPublish', 'publishToGitlab'), + publishGitea: publishModalOpener('giteaPublish', 'publishToGitea'), publishGoogleDrive: publishModalOpener('googleDrivePublish', 'publishToGoogleDrive'), publishWordpress: publishModalOpener('wordpressPublish', 'publishToWordPress'), publishZendesk: publishModalOpener('zendeskPublish', 'publishToZendesk'), diff --git a/src/components/menus/SyncMenu.vue b/src/components/menus/SyncMenu.vue index d4bd0319..61200cb5 100644 --- a/src/components/menus/SyncMenu.vue +++ b/src/components/menus/SyncMenu.vue @@ -74,6 +74,18 @@ {{token.name}}
+
+ + +
Open from Gitea
+ {{token.name}} +
+ + +
Save on Gitea
+ {{token.name}} +
+
@@ -103,6 +115,10 @@ Add GitLab account + + + Add Gitea account + Add Google Drive account @@ -119,11 +135,13 @@ import dropboxHelper from '../../services/providers/helpers/dropboxHelper'; import githubHelper from '../../services/providers/helpers/githubHelper'; import giteeHelper from '../../services/providers/helpers/giteeHelper'; import gitlabHelper from '../../services/providers/helpers/gitlabHelper'; +import giteaHelper from '../../services/providers/helpers/giteaHelper'; import googleDriveProvider from '../../services/providers/googleDriveProvider'; import dropboxProvider from '../../services/providers/dropboxProvider'; import githubProvider from '../../services/providers/githubProvider'; import giteeProvider from '../../services/providers/giteeProvider'; import gitlabProvider from '../../services/providers/gitlabProvider'; +import giteaProvider from '../../services/providers/giteaProvider'; import syncSvc from '../../services/syncSvc'; import store from '../../store'; import badgeSvc from '../../services/badgeSvc'; @@ -172,6 +190,9 @@ export default { gitlabTokens() { return tokensToArray(store.getters['data/gitlabTokensBySub']); }, + giteaTokens() { + return tokensToArray(store.getters['data/giteaTokensBySub']); + }, googleDriveTokens() { return tokensToArray(store.getters['data/googleTokensBySub'], token => token.isDrive); }, @@ -217,6 +238,12 @@ export default { await gitlabHelper.addAccount(serverUrl, applicationId); } catch (e) { /* cancel */ } }, + async addGiteaAccount() { + try { + const { serverUrl, applicationId, applicationSecret } = await store.dispatch('modal/open', { type: 'giteaAccount' }); + await giteaHelper.addAccount(serverUrl, applicationId, applicationSecret); + } catch (e) { /* cancel */ } + }, async addGoogleDriveAccount() { try { await store.dispatch('modal/open', { type: 'googleDriveAccount' }); @@ -318,12 +345,33 @@ export default { ); } catch (e) { /* cancel */ } }, + async openGitea(token) { + try { + const syncLocation = await store.dispatch('modal/open', { + type: 'giteaOpen', + token, + }); + store.dispatch( + 'queue/enqueue', + async () => { + await giteaProvider.openFile(token, syncLocation); + badgeSvc.addBadge('openFromGitea'); + }, + ); + } catch (e) { /* cancel */ } + }, async saveGitlab(token) { try { await openSyncModal(token, 'gitlabSave'); badgeSvc.addBadge('saveOnGitlab'); } catch (e) { /* cancel */ } }, + async saveGitea(token) { + try { + await openSyncModal(token, 'giteaSave'); + badgeSvc.addBadge('saveOnGitea'); + } catch (e) { /* cancel */ } + }, }, }; diff --git a/src/components/menus/WorkspacesMenu.vue b/src/components/menus/WorkspacesMenu.vue index bdfd1d59..eee9d725 100644 --- a/src/components/menus/WorkspacesMenu.vue +++ b/src/components/menus/WorkspacesMenu.vue @@ -29,6 +29,10 @@ Add a GitLab workspace + + + Add a Gitea workspace + Add a Google Drive workspace @@ -41,6 +45,7 @@ import { mapGetters } from 'vuex'; import MenuEntry from './common/MenuEntry'; import googleHelper from '../../services/providers/helpers/googleHelper'; import gitlabHelper from '../../services/providers/helpers/gitlabHelper'; +import giteaHelper from '../../services/providers/helpers/giteaHelper'; import store from '../../store'; export default { @@ -88,6 +93,16 @@ export default { }); } catch (e) { /* Cancel */ } }, + async addGiteaWorkspace() { + try { + const { serverUrl, applicationId, applicationSecret } = await store.dispatch('modal/open', { type: 'giteaAccount' }); + const token = await giteaHelper.addAccount(serverUrl, applicationId, applicationSecret); + store.dispatch('modal/open', { + type: 'giteaWorkspace', + token, + }); + } catch (e) { /* Cancel */ } + }, async addGoogleDriveWorkspace() { try { const token = await googleHelper.addDriveAccount(true); diff --git a/src/components/modals/AccountManagementModal.vue b/src/components/modals/AccountManagementModal.vue index dc9e8866..6c88c0d6 100644 --- a/src/components/modals/AccountManagementModal.vue +++ b/src/components/modals/AccountManagementModal.vue @@ -57,6 +57,10 @@ Add GitLab account + + + Add Gitea account + Add Google Drive account @@ -91,6 +95,7 @@ import dropboxHelper from '../../services/providers/helpers/dropboxHelper'; import githubHelper from '../../services/providers/helpers/githubHelper'; import giteeHelper from '../../services/providers/helpers/giteeHelper'; import gitlabHelper from '../../services/providers/helpers/gitlabHelper'; +import giteaHelper from '../../services/providers/helpers/giteaHelper'; import wordpressHelper from '../../services/providers/helpers/wordpressHelper'; import zendeskHelper from '../../services/providers/helpers/zendeskHelper'; import badgeSvc from '../../services/badgeSvc'; @@ -148,6 +153,14 @@ export default { name: token.name, scopes: ['api'], })), + ...Object.values(store.getters['data/giteaTokensBySub']).map(token => ({ + token, + providerId: 'gitea', + url: token.serverUrl, + userId: token.sub, + name: token.name, + scopes: ['api'], + })), ...Object.values(store.getters['data/wordpressTokensBySub']).map(token => ({ token, providerId: 'wordpress', @@ -204,6 +217,12 @@ export default { await gitlabHelper.addAccount(serverUrl, applicationId); } catch (e) { /* cancel */ } }, + async addGiteaAccount() { + try { + const { serverUrl, applicationId, applicationSecret } = await store.dispatch('modal/open', { type: 'giteaAccount' }); + await giteaHelper.addAccount(serverUrl, applicationId, applicationSecret); + } catch (e) { /* cancel */ } + }, async addGoogleDriveAccount() { try { await store.dispatch('modal/open', { type: 'googleDriveAccount' }); diff --git a/src/components/modals/providers/GiteaAccountModal.vue b/src/components/modals/providers/GiteaAccountModal.vue new file mode 100644 index 00000000..d088bcc5 --- /dev/null +++ b/src/components/modals/providers/GiteaAccountModal.vue @@ -0,0 +1,75 @@ + + + diff --git a/src/components/modals/providers/GiteaOpenModal.vue b/src/components/modals/providers/GiteaOpenModal.vue new file mode 100644 index 00000000..aa4358ae --- /dev/null +++ b/src/components/modals/providers/GiteaOpenModal.vue @@ -0,0 +1,69 @@ + + + diff --git a/src/components/modals/providers/GiteaPublishModal.vue b/src/components/modals/providers/GiteaPublishModal.vue new file mode 100644 index 00000000..2fef3e36 --- /dev/null +++ b/src/components/modals/providers/GiteaPublishModal.vue @@ -0,0 +1,85 @@ + + + diff --git a/src/components/modals/providers/GiteaSaveModal.vue b/src/components/modals/providers/GiteaSaveModal.vue new file mode 100644 index 00000000..c1168464 --- /dev/null +++ b/src/components/modals/providers/GiteaSaveModal.vue @@ -0,0 +1,72 @@ + + + diff --git a/src/components/modals/providers/GiteaWorkspaceModal.vue b/src/components/modals/providers/GiteaWorkspaceModal.vue new file mode 100644 index 00000000..04ab0342 --- /dev/null +++ b/src/components/modals/providers/GiteaWorkspaceModal.vue @@ -0,0 +1,67 @@ + + + diff --git a/src/data/defaults/defaultLocalSettings.js b/src/data/defaults/defaultLocalSettings.js index 05cacccc..b5db3a7a 100644 --- a/src/data/defaults/defaultLocalSettings.js +++ b/src/data/defaults/defaultLocalSettings.js @@ -21,11 +21,18 @@ export default () => ({ gistPublishTemplate: 'plainText', giteeRepoUrl: '', giteeWorkspaceRepoUrl: '', + giteePublishTemplate: 'jekyllSite', gitlabServerUrl: '', gitlabApplicationId: '', gitlabProjectUrl: '', gitlabWorkspaceProjectUrl: '', gitlabPublishTemplate: 'plainText', + giteaServerUrl: '', + giteaApplicationId: '', + giteaApplicationSecret: '', + giteaProjectUrl: '', + giteaWorkspaceProjectUrl: '', + giteaPublishTemplate: 'plainText', wordpressDomain: '', wordpressPublishTemplate: 'plainHtml', zendeskSiteUrl: '', diff --git a/src/data/defaults/defaultSettings.yml b/src/data/defaults/defaultSettings.yml index a28ff439..61f18fc7 100644 --- a/src/data/defaults/defaultSettings.yml +++ b/src/data/defaults/defaultSettings.yml @@ -77,7 +77,7 @@ turndown: linkStyle: inlined linkReferenceStyle: full -# GitHub/GitLab commit messages +# GitHub/GitLab/Gitee/Gitea commit messages git: createFileMessage: '{{path}} created from https://edit.qicoder.com/' updateFileMessage: '{{path}} updated from https://edit.qicoder.com/' diff --git a/src/data/features.js b/src/data/features.js index 7092b31c..27836d66 100644 --- a/src/data/features.js +++ b/src/data/features.js @@ -182,6 +182,11 @@ export default [ 'GitLab workspace creator', 'Use the workspace menu to create a GitLab workspace.', ), + new Feature( + 'addGiteaWorkspace', + 'Gitea workspace creator', + 'Use the workspace menu to create a Gitea workspace.', + ), new Feature( 'addGoogleDriveWorkspace', 'Google Drive workspace creator', @@ -229,6 +234,11 @@ export default [ 'GitLab user', 'Link your GitLab account to StackEdit.', ), + new Feature( + 'addGiteaAccount', + 'Gitea user', + 'Link your Gitea account to StackEdit.', + ), new Feature( 'addGoogleDriveAccount', 'Google Drive user', @@ -306,6 +316,16 @@ export default [ 'GitLab writer', 'Use the "Synchronize" menu to save a file in a GitLab repository.', ), + new Feature( + 'openFromGitea', + 'Gitea reader', + 'Use the "Synchronize" menu to open a file from a Gitea repository.', + ), + new Feature( + 'saveOnGitea', + 'Gitea writer', + 'Use the "Synchronize" menu to save a file in a Gitea repository.', + ), new Feature( 'openFromGoogleDrive', 'Google Drive reader', @@ -373,6 +393,11 @@ export default [ 'GitLab publisher', 'Use the "Publish" menu to publish a file to a GitLab repository.', ), + new Feature( + 'publishToGitea', + 'Gitea publisher', + 'Use the "Publish" menu to publish a file to a Gitea repository.', + ), new Feature( 'publishToGoogleDrive', 'Google Drive publisher', diff --git a/src/icons/Provider.vue b/src/icons/Provider.vue index f5f4ddcc..307e2a19 100644 --- a/src/icons/Provider.vue +++ b/src/icons/Provider.vue @@ -22,6 +22,8 @@ export default { return 'github'; case 'gitlabWorkspace': return 'gitlab'; + case 'giteaWorkspace': + return 'gitea'; case 'bloggerPage': return 'blogger'; case 'couchdbWorkspace': @@ -65,6 +67,10 @@ export default { background-image: url(../assets/iconGitlab.svg); } +.icon-provider--gitea { + background-image: url(../assets/iconGitea.svg); +} + .icon-provider--google { background-image: url(../assets/iconGoogle.svg); } diff --git a/src/services/providers/giteaProvider.js b/src/services/providers/giteaProvider.js new file mode 100644 index 00000000..bb77496f --- /dev/null +++ b/src/services/providers/giteaProvider.js @@ -0,0 +1,171 @@ +import store from '../../store'; +import giteaHelper from './helpers/giteaHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import workspaceSvc from '../workspaceSvc'; +import userSvc from '../userSvc'; + +const savedSha = {}; + +export default new Provider({ + id: 'gitea', + name: 'Gitea', + getToken({ sub }) { + return store.getters['data/giteaTokensBySub'][sub]; + }, + getLocationUrl({ + sub, + projectPath, + branch, + path, + }) { + const token = this.getToken({ sub }); + return `${token.serverUrl}/${projectPath}/src/branch/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`; + }, + getLocationDescription({ path }) { + return path; + }, + async downloadContent(token, syncLocation) { + const { sha, data } = await giteaHelper.downloadFile({ + ...syncLocation, + token, + }); + savedSha[syncLocation.id] = sha; + return Provider.parseContent(data, `${syncLocation.fileId}/content`); + }, + async uploadContent(token, content, syncLocation) { + const updatedSyncLocation = { + ...syncLocation, + projectId: await giteaHelper.getProjectId(syncLocation), + }; + if (!savedSha[updatedSyncLocation.id]) { + try { + // Get the last sha + await this.downloadContent(token, updatedSyncLocation); + } catch (e) { + // Ignore error + } + } + const sha = savedSha[updatedSyncLocation.id]; + delete savedSha[updatedSyncLocation.id]; + await giteaHelper.uploadFile({ + ...updatedSyncLocation, + token, + content: Provider.serializeContent(content), + sha, + }); + return updatedSyncLocation; + }, + async publish(token, html, metadata, publishLocation) { + const updatedPublishLocation = { + ...publishLocation, + projectId: await giteaHelper.getProjectId(publishLocation), + }; + try { + // Get the last sha + await this.downloadContent(token, updatedPublishLocation); + } catch (e) { + // Ignore error + } + const sha = savedSha[updatedPublishLocation.id]; + delete savedSha[updatedPublishLocation.id]; + await giteaHelper.uploadFile({ + ...updatedPublishLocation, + token, + content: html, + sha, + }); + return updatedPublishLocation; + }, + async openFile(token, syncLocation) { + const updatedSyncLocation = { + ...syncLocation, + projectId: await giteaHelper.getProjectId(syncLocation), + }; + + // Check if the file exists and open it + if (!Provider.openFileWithLocation(updatedSyncLocation)) { + // Download content from Gitea + let content; + try { + content = await this.downloadContent(token, updatedSyncLocation); + } catch (e) { + store.dispatch('notification/error', `Could not open file ${updatedSyncLocation.path}.`); + return; + } + + // Create the file + let name = updatedSyncLocation.path; + const slashPos = name.lastIndexOf('/'); + if (slashPos > -1 && slashPos < name.length - 1) { + name = name.slice(slashPos + 1); + } + const dotPos = name.lastIndexOf('.'); + if (dotPos > 0 && slashPos < name.length) { + name = name.slice(0, dotPos); + } + const item = await workspaceSvc.createFile({ + name, + parentId: store.getters['file/current'].parentId, + text: content.text, + properties: content.properties, + discussions: content.discussions, + comments: content.comments, + }, true); + store.commit('file/setCurrentId', item.id); + workspaceSvc.addSyncLocation({ + ...updatedSyncLocation, + fileId: item.id, + }); + store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Gitea.`); + } + }, + makeLocation(token, projectPath, branch, path) { + return { + providerId: this.id, + sub: token.sub, + projectPath, + branch, + path, + }; + }, + async listFileRevisions({ token, syncLocation }) { + const entries = await giteaHelper.getCommits({ + ...syncLocation, + token, + }); + + return entries.map((entry) => { + const email = entry.author_email || entry.committer_email; + const sub = `${giteaHelper.subPrefix}:${token.serverUrl}/${email}`; + userSvc.addUserInfo({ + id: sub, + name: entry.author_name || entry.committer_name, + imageUrl: '', + }); + const date = entry.authored_date || entry.committed_date || 1; + return { + id: entry.id, + sub, + created: date ? new Date(date).getTime() : 1, + }; + }); + }, + async loadFileRevision() { + // Revision are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + syncLocation, + revisionId, + }) { + const { data } = await giteaHelper.downloadFile({ + ...syncLocation, + token, + branch: revisionId, + }); + return Provider.parseContent(data, contentId); + }, +}); diff --git a/src/services/providers/giteaWorkspaceProvider.js b/src/services/providers/giteaWorkspaceProvider.js new file mode 100644 index 00000000..0ae8d0d5 --- /dev/null +++ b/src/services/providers/giteaWorkspaceProvider.js @@ -0,0 +1,288 @@ +import store from '../../store'; +import giteaHelper from './helpers/giteaHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import userSvc from '../userSvc'; +import gitWorkspaceSvc from '../gitWorkspaceSvc'; +import badgeSvc from '../badgeSvc'; + +const getAbsolutePath = ({ id }) => + `${store.getters['workspace/currentWorkspace'].path || ''}${id}`; + +export default new Provider({ + id: 'giteaWorkspace', + name: 'Gitea', + getToken() { + return store.getters['workspace/syncToken']; + }, + getWorkspaceParams({ + serverUrl, + projectPath, + branch, + path, + }) { + return { + providerId: this.id, + serverUrl, + projectPath, + branch, + path, + }; + }, + getWorkspaceLocationUrl({ + serverUrl, + projectPath, + branch, + path, + }) { + return `${serverUrl}/${projectPath}/src/branch/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`; + }, + getSyncDataUrl({ id }) { + const { projectPath, branch } = store.getters['workspace/currentWorkspace']; + const { serverUrl } = this.getToken(); + return `${serverUrl}/${projectPath}/src/branch/${encodeURIComponent(branch)}/${utils.encodeUrlPath(getAbsolutePath({ id }))}`; + }, + getSyncDataDescription({ id }) { + return getAbsolutePath({ id }); + }, + async initWorkspace() { + const { serverUrl, branch } = utils.queryParams; + const workspaceParams = this.getWorkspaceParams({ serverUrl, branch }); + if (!branch) { + workspaceParams.branch = 'master'; + } + + // Extract project path param + const projectPath = (utils.queryParams.projectPath || '') + .trim() + .replace(/^\/*/, '') // Remove leading `/` + .replace(/\/*$/, ''); // Remove trailing `/` + workspaceParams.projectPath = projectPath; + + // Extract path param + const path = (utils.queryParams.path || '') + .trim() + .replace(/^\/*/, '') // Remove leading `/` + .replace(/\/*$/, '/'); // Add trailing `/` + if (path !== '/') { + workspaceParams.path = path; + } + + const workspaceId = utils.makeWorkspaceId(workspaceParams); + const workspace = store.getters['workspace/workspacesById'][workspaceId]; + + // See if we already have a token + const sub = workspace ? workspace.sub : utils.queryParams.sub; + let token = store.getters['data/giteaTokensBySub'][sub]; + if (!token) { + const { applicationId, applicationSecret } = await store.dispatch('modal/open', { + type: 'giteaAccount', + forceServerUrl: serverUrl, + }); + token = await giteaHelper.addAccount(serverUrl, applicationId, applicationSecret, sub); + } + + if (!workspace) { + const projectId = await giteaHelper.getProjectId(workspaceParams); + const pathEntries = (path || '').split('/'); + const projectPathEntries = (projectPath || '').split('/'); + const name = pathEntries[pathEntries.length - 2] // path ends with `/` + || projectPathEntries[projectPathEntries.length - 1]; + store.dispatch('workspace/patchWorkspacesById', { + [workspaceId]: { + ...workspaceParams, + projectId, + id: workspaceId, + sub: token.sub, + name, + }, + }); + } + + badgeSvc.addBadge('addGiteaWorkspace'); + return store.getters['workspace/workspacesById'][workspaceId]; + }, + getChanges() { + return giteaHelper.getTree({ + ...store.getters['workspace/currentWorkspace'], + token: this.getToken(), + }); + }, + prepareChanges(tree) { + return gitWorkspaceSvc.makeChanges(tree.tree.map(entry => ({ + ...entry, + id: entry.sha, + }))); + }, + async saveWorkspaceItem({ item }) { + const syncData = { + id: store.getters.gitPathsByItemId[item.id], + type: item.type, + hash: item.hash, + }; + + // Files and folders are not in git, only contents + if (item.type === 'file' || item.type === 'folder') { + return { syncData }; + } + + // locations are stored as paths, so we upload an empty file + const syncToken = store.getters['workspace/syncToken']; + await giteaHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token: syncToken, + path: getAbsolutePath(syncData), + content: '', + sha: gitWorkspaceSvc.shaByPath[syncData.id], + }); + + // Return sync data to save + return { syncData }; + }, + async removeWorkspaceItem({ syncData }) { + if (gitWorkspaceSvc.shaByPath[syncData.id]) { + const syncToken = store.getters['workspace/syncToken']; + await giteaHelper.removeFile({ + ...store.getters['workspace/currentWorkspace'], + token: syncToken, + path: getAbsolutePath(syncData), + sha: gitWorkspaceSvc.shaByPath[syncData.id], + }); + } + }, + async downloadWorkspaceContent({ + token, + contentId, + contentSyncData, + fileSyncData, + }) { + const { sha, data } = await giteaHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(fileSyncData), + }); + gitWorkspaceSvc.shaByPath[fileSyncData.id] = sha; + const content = Provider.parseContent(data, contentId); + return { + content, + contentSyncData: { + ...contentSyncData, + hash: content.hash, + sha, + }, + }; + }, + async downloadWorkspaceData({ token, syncData }) { + if (!syncData) { + return {}; + } + + const { sha, data } = await giteaHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(syncData), + }); + gitWorkspaceSvc.shaByPath[syncData.id] = sha; + const item = JSON.parse(data); + return { + item, + syncData: { + ...syncData, + hash: item.hash, + sha, + }, + }; + }, + async uploadWorkspaceContent({ token, content, file }) { + const path = store.getters.gitPathsByItemId[file.id]; + const absolutePath = `${store.getters['workspace/currentWorkspace'].path || ''}${path}`; + const sha = gitWorkspaceSvc.shaByPath[path]; + await giteaHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: absolutePath, + content: Provider.serializeContent(content), + sha, + }); + + // Return new sync data + return { + contentSyncData: { + id: store.getters.gitPathsByItemId[content.id], + type: content.type, + hash: content.hash, + sha, + }, + fileSyncData: { + id: path, + type: 'file', + hash: file.hash, + }, + }; + }, + async uploadWorkspaceData({ token, item }) { + const path = store.getters.gitPathsByItemId[item.id]; + const syncData = { + id: path, + type: item.type, + hash: item.hash, + }; + const res = await giteaHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(syncData), + content: JSON.stringify(item), + sha: gitWorkspaceSvc.shaByPath[path], + }); + + return { + syncData: { + ...syncData, + sha: res.content.sha, + }, + }; + }, + async listFileRevisions({ token, fileSyncDataId }) { + const { projectId, branch } = store.getters['workspace/currentWorkspace']; + const entries = await giteaHelper.getCommits({ + token, + projectId, + sha: branch, + path: getAbsolutePath({ id: fileSyncDataId }), + }); + + return entries.map((entry) => { + const email = entry.author_email || entry.committer_email; + const sub = `${giteaHelper.subPrefix}:${token.serverUrl}/${email}`; + userSvc.addUserInfo({ + id: sub, + name: entry.author_name || entry.committer_name, + imageUrl: '', // No way to get user's avatar url... + }); + const date = entry.authored_date || entry.committed_date || 1; + return { + id: entry.id, + sub, + created: date ? new Date(date).getTime() : 1, + }; + }); + }, + async loadFileRevision() { + // Revisions are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + fileSyncDataId, + revisionId, + }) { + const { data } = await giteaHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + branch: revisionId, + path: getAbsolutePath({ id: fileSyncDataId }), + }); + return Provider.parseContent(data, contentId); + }, +}); diff --git a/src/services/providers/helpers/giteaHelper.js b/src/services/providers/helpers/giteaHelper.js new file mode 100644 index 00000000..62d5268d --- /dev/null +++ b/src/services/providers/helpers/giteaHelper.js @@ -0,0 +1,222 @@ +import utils from '../../utils'; +import networkSvc from '../../networkSvc'; +import store from '../../../store'; +import userSvc from '../../userSvc'; +import badgeSvc from '../../badgeSvc'; +import constants from '../../../data/constants'; + +const request = ({ accessToken, serverUrl }, options) => networkSvc.request({ + ...options, + url: `${serverUrl}/api/v1/${options.url}`, + headers: { + ...options.headers || {}, + Authorization: `Bearer ${accessToken}`, + }, +}) + .then(res => res.body); + +const getCommitMessage = (name, path) => { + const message = store.getters['data/computedSettings'].git[name]; + return message.replace(/{{path}}/g, path); +}; + +/** + * https://try.gitea.io/api/swagger#/user/userGet + */ +const subPrefix = 'gt'; +userSvc.setInfoResolver('gitea', subPrefix, async (sub) => { + try { + const [, serverUrl, username] = sub.match(/^(.+)\/([^/]+)$/); + const user = (await networkSvc.request({ + url: `${serverUrl}/api/v1/users/${username}`, + })).body; + const uniqueSub = `${serverUrl}/${user.username}`; + + return { + id: `${subPrefix}:${uniqueSub}`, + name: user.username, + imageUrl: user.avatar_url || '', + }; + } catch (err) { + if (err.status !== 404) { + throw new Error('RETRY'); + } + throw err; + } +}); + +export default { + subPrefix, + + /** + * https://docs.gitea.io/en-us/oauth2-provider/ + */ + async startOauth2(serverUrl, applicationId, applicationSecret, sub = null, silent = false) { + // Get an OAuth2 code + const { code } = await networkSvc.startOauth2( + `${serverUrl}/login/oauth/authorize`, + { + client_id: applicationId, + response_type: 'code', + redirect_uri: constants.oauth2RedirectUri, + }, + silent, + ); + + // Exchange code with token + const accessToken = (await networkSvc.request({ + method: 'POST', + url: `${serverUrl}/login/oauth/access_token`, + body: { + client_id: applicationId, + client_secret: applicationSecret, + code, + grant_type: 'authorization_code', + redirect_uri: constants.oauth2RedirectUri, + }, + })).body.access_token; + + // Call the user info endpoint + const user = await request({ accessToken, serverUrl }, { + url: 'user', + }); + const uniqueSub = `${serverUrl}/${user.username}`; + userSvc.addUserInfo({ + id: `${subPrefix}:${uniqueSub}`, + name: user.username, + imageUrl: user.avatar_url || '', + }); + + // Check the returned sub consistency + if (sub && uniqueSub !== sub) { + throw new Error('Gitea account ID not expected.'); + } + + // Build token object including scopes and sub + const token = { + accessToken, + name: user.username, + serverUrl, + sub: uniqueSub, + }; + + // Add token to gitea tokens + store.dispatch('data/addGiteaToken', token); + return token; + }, + async addAccount(serverUrl, applicationId, applicationSecret, sub = null) { + const token = await this.startOauth2(serverUrl, applicationId, applicationSecret, sub); + badgeSvc.addBadge('addGiteaAccount'); + return token; + }, + + /** + * https://try.gitea.io/api/swagger#/repository/repoGet + */ + async getProjectId({ projectPath, projectId }) { + if (projectId) { + return projectId; + } + const [, repoFullName] = projectPath.match(/([^/]+\/[^/]+)$/); + return repoFullName; + }, + + /** + * https://try.gitea.io/api/swagger#/repository/GetTree + */ + async getTree({ + token, + projectId, + branch, + }) { + return request(token, { + url: `repos/${projectId}/git/trees/${branch}`, + params: { + recursive: true, + per_page: 9999, + }, + }); + }, + + /** + * https://try.gitea.io/api/swagger#/repository/repoGetAllCommits + */ + async getCommits({ + token, + projectId, + branch, + path, + }) { + return request(token, { + url: `repos/${projectId}/commits`, + params: { + sha: branch, + path, + }, + }); + }, + + /** + * https://try.gitea.io/api/swagger#/repository/repoCreateFile + * https://try.gitea.io/api/swagger#/repository/repoUpdateFile + */ + async uploadFile({ + token, + projectId, + branch, + path, + content, + sha, + }) { + return request(token, { + method: sha ? 'PUT' : 'POST', + url: `repos/${projectId}/contents/${encodeURIComponent(path)}`, + body: { + message: getCommitMessage(sha ? 'updateFileMessage' : 'createFileMessage', path), + content: utils.encodeBase64(content), + sha, + branch, + }, + }); + }, + + /** + * https://try.gitea.io/api/swagger#/repository/repoDeleteFile + */ + async removeFile({ + token, + projectId, + branch, + path, + sha, + }) { + return request(token, { + method: 'DELETE', + url: `repos/${projectId}/contents/${encodeURIComponent(path)}`, + body: { + message: getCommitMessage('deleteFileMessage', path), + sha, + branch, + }, + }); + }, + + /** + * https://try.gitea.io/api/swagger#/repository/repoGetContents + */ + async downloadFile({ + token, + projectId, + branch, + path, + }) { + const { sha, content } = await request(token, { + url: `repos/${projectId}/contents/${encodeURIComponent(path)}`, + params: { ref: branch }, + }); + return { + sha, + data: utils.decodeBase64(content), + }; + }, +}; diff --git a/src/services/providers/helpers/giteeHelper.js b/src/services/providers/helpers/giteeHelper.js index b8df527b..bff6ca2a 100644 --- a/src/services/providers/helpers/giteeHelper.js +++ b/src/services/providers/helpers/giteeHelper.js @@ -3,6 +3,7 @@ import networkSvc from '../../networkSvc'; import store from '../../../store'; import userSvc from '../../userSvc'; import badgeSvc from '../../badgeSvc'; +import constants from '../../../data/constants'; const request = (token, options) => networkSvc.request({ ...options, @@ -81,6 +82,7 @@ export default { params: { clientId, code, + oauth2RedirectUri: constants.oauth2RedirectUri, }, })).body; diff --git a/src/services/syncSvc.js b/src/services/syncSvc.js index 88542887..a3ef6e4d 100644 --- a/src/services/syncSvc.js +++ b/src/services/syncSvc.js @@ -9,6 +9,7 @@ import './providers/couchdbWorkspaceProvider'; import './providers/githubWorkspaceProvider'; import './providers/giteeWorkspaceProvider'; import './providers/gitlabWorkspaceProvider'; +import './providers/giteaWorkspaceProvider'; import './providers/googleDriveWorkspaceProvider'; import tempFileSvc from './tempFileSvc'; import workspaceSvc from './workspaceSvc'; diff --git a/src/services/utils.js b/src/services/utils.js index ce7722d7..7b7f4d9f 100644 --- a/src/services/utils.js +++ b/src/services/utils.js @@ -296,6 +296,10 @@ export default { const parsedProject = url && url.match(/^https:\/\/[^/]+\/(.+?)(?:\.git|\/)?$/); return parsedProject && parsedProject[1]; }, + parseGiteaProjectPath(url) { + const parsedProject = url && url.match(/^http[s]?:\/\/[^/]+\/(.+?)(?:\.git|\/)?$/); + return parsedProject && parsedProject[1]; + }, createHiddenIframe(url) { const iframeElt = document.createElement('iframe'); iframeElt.style.position = 'absolute'; diff --git a/src/store/data.js b/src/store/data.js index a4a8f9b1..c898155e 100644 --- a/src/store/data.js +++ b/src/store/data.js @@ -213,6 +213,7 @@ export default { githubTokensBySub: (state, { tokensByType }) => tokensByType.github || {}, giteeTokensBySub: (state, { tokensByType }) => tokensByType.gitee || {}, gitlabTokensBySub: (state, { tokensByType }) => tokensByType.gitlab || {}, + giteaTokensBySub: (state, { tokensByType }) => tokensByType.gitea || {}, wordpressTokensBySub: (state, { tokensByType }) => tokensByType.wordpress || {}, zendeskTokensBySub: (state, { tokensByType }) => tokensByType.zendesk || {}, badgeCreations: getter('badgeCreations'), @@ -306,6 +307,7 @@ export default { addGithubToken: tokenAdder('github'), addGiteeToken: tokenAdder('gitee'), addGitlabToken: tokenAdder('gitlab'), + addGiteaToken: tokenAdder('gitea'), addWordpressToken: tokenAdder('wordpress'), addZendeskToken: tokenAdder('zendesk'), patchBadgeCreations: patcher('badgeCreations'), diff --git a/src/store/workspace.js b/src/store/workspace.js index 40ae4535..9fe47a21 100644 --- a/src/store/workspace.js +++ b/src/store/workspace.js @@ -45,11 +45,13 @@ export default { currentWorkspaceIsGit: (state, { currentWorkspace }) => currentWorkspace.providerId === 'githubWorkspace' || currentWorkspace.providerId === 'giteeWorkspace' - || currentWorkspace.providerId === 'gitlabWorkspace', + || currentWorkspace.providerId === 'gitlabWorkspace' + || currentWorkspace.providerId === 'giteaWorkspace', currentWorkspaceHasUniquePaths: (state, { currentWorkspace }) => currentWorkspace.providerId === 'githubWorkspace' || currentWorkspace.providerId === 'giteeWorkspace' - || currentWorkspace.providerId === 'gitlabWorkspace', + || currentWorkspace.providerId === 'gitlabWorkspace' + || currentWorkspace.providerId === 'giteaWorkspace', lastSyncActivityKey: (state, { currentWorkspace }) => `${currentWorkspace.id}/lastSyncActivity`, lastFocusKey: (state, { currentWorkspace }) => `${currentWorkspace.id}/lastWindowFocus`, mainWorkspaceToken: (state, getters, rootState, rootGetters) => @@ -69,6 +71,8 @@ export default { return rootGetters['data/giteeTokensBySub'][currentWorkspace.sub]; case 'gitlabWorkspace': return rootGetters['data/gitlabTokensBySub'][currentWorkspace.sub]; + case 'giteaWorkspace': + return rootGetters['data/giteaTokensBySub'][currentWorkspace.sub]; case 'couchdbWorkspace': return rootGetters['data/couchdbTokensBySub'][currentWorkspace.id]; default: @@ -86,6 +90,8 @@ export default { return 'gitee'; case 'gitlabWorkspace': return 'gitlab'; + case 'giteaWorkspace': + return 'gitea'; } }, loginToken: (state, { loginType, currentWorkspace }, rootState, rootGetters) => {