diff --git a/Readme.md b/Readme.md index dbdd75b..c84c4f7 100644 --- a/Readme.md +++ b/Readme.md @@ -32,27 +32,48 @@ npm nix See `./result` - for binaries build by nix. To make the available add to path by running `PATH="${PATH:+${PATH}:}${PWD}/result/bin/"` -## +## Running All data will be persisted in application directory (default `~/.gitpear`). To change it. Provide environment variable `GIT_PEAR` * `git pear daemon <-s, --start | -k, --stop>` - start or stop daemon - * `git pear key` - print out public key. Share it with your peers so that they can do `git pull pear:/` - * `git pear init [-s, --share] ` - It will create [bare repository](https://git-scm.com/docs/git-init#Documentation/git-init.txt---bare) of the same name in application directory (default ~/.gitpear/). It will add [git remote](https://git-scm.com/docs/git-remote) in current repository with name `pear`. So just like in traditional flow doing `git push orign`, here we do `git push pear`. By default repository will not be shared. To enable sharing provide `-s` or call `gitpear share ` later - * `git pear share ` - makes repository sharable - * `git pear unshare ` - stop sharing repository - * `git pear list [-s, --shared]` - list all or (only shared) repositories -## Usage example +### ACL (for authenticated access to enable support of PUSH) -Please not this is only remote helper and its intention is only to enable direct `clone|fetch|pull` of repository hosted on private computer. +Support of `push` capabilities only enabled for authenticated users. Currently supported authentication is based on [NIP98](https://github.com/nostr-protocol/nips/blob/master/98.md). +To start daemon with authenticated support provide environment varibales `GIT_PEAR_AUTH` with value `nip98` and `GIT_PEAR_AUTH_NSEC` with value of your [NIP19 nsec](https://github.com/nostr-protocol/nips/blob/master/19.md). +For example: +``` +GIT_PEAR_AUTH=nip98 GIT_PEAR_AUTH_NSEC=nsec.... git pear daemon -s +``` -Collaboration is possible however with the following flow between Alice and Bob in a pure peer-to-peer manner of git. +To manage access to repository use one or combination of the following commands, if `path` is not provide the command will be executed in the current directory. For `userId` use [NIP19 npub](https://github.com/nostr-protocol/nips/blob/master/19.md). + +* `git pear acl [command] ` - ACL managegement +* `git pear acl list [userId] ` - list repository visitbility and user's role (or roles of all users if userId is not provided) +* `git pear acl add ` - add user as a "role" to repository, available roles are `viewer`, `contributor`, `admin`. Roles exaplained: + * `viewer` - can read all branches; + * `contributor` - can edit all branches except protected (default master) + * `admin` - can edit protected branches +* `git pear acl remove ` - revoke use access to repository + + +### Branch protection rules +It is possible to setup basic branch protection rules (master is proteted by default). +* `git pear branch`, same as `git pear branch list .` - list protection rules +* `git pear branch add ` - mark branch as protected (defatul repo path is ".") +* `git pear branch remove ` - unmark branch as protected + +# Examples of usage + +## Un authenticated usage example (no push) + +Collaboration is possible with the following flow between Alice and Bob in a peer-to-peer manner. 1. Both Alice and Bob have gitpear installed and Alice wants Bob to help her with repo Repo 2. Alice steps are: @@ -94,3 +115,41 @@ git checkout master git fetch origin git pull ``` + +## Authenticated usage example (push) - at your own risk + +Collaboration is possible with the following flow between Carol and David in a peer-to-peer manner. + +### Carol steps (as a server of code) +1. Start daemon +* `GIT_PEAR_AUTH_NSEC= GIT_PEAR_AUTH='nip98' git pear daemon -s` +2. Go to repository +* `cd repo` +3. Initialize git pear repository +* `git pear init .` +4. Share repository wit hviben visibility () - (default is `public`) +* `git pear share . ` +5. Add Daviv as a `contirbutor`. +6. List David's npub as a contributor +* `git pear acl add :contributor` +7. Retreive repo url and share it with Dave +* `git pear list -s` + +### Dave side (a collaborator for code) +1. Start daemon. This will be needed later for push. Not that no auth or sec are provided which means that push to this place will not be supportedd. +* `git pear daemon -s` +2. Clone repository. Authorization data and type are necesary for server (Carol) to grant corresponding access persmissions +* `GIT_PEAR_AUTH_NSEC= GIT_PEAR_AUTH='nip98' git clone pear:///` +3. Do the necessary change in separate branch +* `git checkout -b feat/david` +* do change +* `git add .` +* `git commit -s -m 'made by David'` +4. Push branch to origin +* `GIT_PEAR_AUTH_NSEC= GIT_PEAR_AUTH='nip98' git push origin feat/david` + +### Carol steps +1. For Carol the changes will arrive as branch `feat/david` into her `pear` +* `git fetch pear` +2. From there she can do +* `git diff pear/feat/david` or `git pull pear feat/david` ... merge to master and push to `pear` diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index ce944db..82ec373 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -12,10 +12,11 @@ "dependencies": { "chokidar": "^3.5.3", "commander": "^11.0.0", - "corestore": "^6.10.1", - "hyperdrive": "^11.5.3", - "hyperswarm": "^4.5.1", - "protomux-rpc": "^1.4.1", + "corestore": "^6.15.13", + "hyperdrive": "^11.6.3", + "hyperswarm": "^4.7.13", + "nostr-tools": "^2.1.5", + "protomux-rpc": "^1.5.1", "random-access-memory": "^6.2.0" }, "bin": { @@ -239,6 +240,47 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/ciphers": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.2.0.tgz", + "integrity": "sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -274,6 +316,53 @@ "node": ">= 8" } }, + "node_modules/@scure/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@scure/bip32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz", + "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", + "dependencies": { + "@noble/curves": "~1.1.0", + "@noble/hashes": "~1.3.1", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz", + "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", + "dependencies": { + "@noble/hashes": "1.3.1" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz", + "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", + "dependencies": { + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -3008,6 +3097,36 @@ "node": ">=0.10.0" } }, + "node_modules/nostr-tools": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.1.5.tgz", + "integrity": "sha512-Gug/j54YGQ0ewB09dZW3mS9qfXWFlcOQMlyb1MmqQsuNO/95mfNOQSBi+jZ61O++Y+jG99SzAUPFLopUsKf0MA==", + "dependencies": { + "@noble/ciphers": "0.2.0", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.1", + "@scure/base": "1.1.1", + "@scure/bip32": "1.3.1", + "@scure/bip39": "1.2.1" + }, + "optionalDependencies": { + "nostr-wasm": "v0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "optional": true + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4390,7 +4509,7 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4838,6 +4957,31 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@noble/ciphers": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.2.0.tgz", + "integrity": "sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==" + }, + "@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "requires": { + "@noble/hashes": "1.3.2" + }, + "dependencies": { + "@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==" + } + } + }, + "@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==" + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4864,6 +5008,40 @@ "fastq": "^1.6.0" } }, + "@scure/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==" + }, + "@scure/bip32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz", + "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", + "requires": { + "@noble/curves": "~1.1.0", + "@noble/hashes": "~1.3.1", + "@scure/base": "~1.1.0" + }, + "dependencies": { + "@noble/curves": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz", + "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", + "requires": { + "@noble/hashes": "1.3.1" + } + } + } + }, + "@scure/bip39": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz", + "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", + "requires": { + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + } + }, "@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -6982,6 +7160,26 @@ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" }, + "nostr-tools": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.1.5.tgz", + "integrity": "sha512-Gug/j54YGQ0ewB09dZW3mS9qfXWFlcOQMlyb1MmqQsuNO/95mfNOQSBi+jZ61O++Y+jG99SzAUPFLopUsKf0MA==", + "requires": { + "@noble/ciphers": "0.2.0", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.1", + "@scure/base": "1.1.1", + "@scure/bip32": "1.3.1", + "@scure/bip39": "1.2.1", + "nostr-wasm": "v0.1.0" + } + }, + "nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "optional": true + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8028,7 +8226,7 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true + "devOptional": true }, "udx-native": { "version": "1.7.12", diff --git a/package.json b/package.json index 5299b9b..ed34376 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "corestore": "^6.15.13", "hyperdrive": "^11.6.3", "hyperswarm": "^4.7.13", + "nostr-tools": "^2.1.5", "protomux-rpc": "^1.5.1", "random-access-memory": "^6.2.0" } diff --git a/src/acl.js b/src/acl.js new file mode 100644 index 0000000..b22344c --- /dev/null +++ b/src/acl.js @@ -0,0 +1,120 @@ +const home = require('./home') +const fs = require('fs') + +const ROLES = { + admin: { + description: 'Read and write to all branches', + }, + contributor: { + description: 'Read and write to all branches except protected ones', + }, + viewer: { + description: 'Read all branches', + }, +} +const DEFAULT_ACL = { + visibility: 'public', // public|private + protectedBranches: ['master'], + ACL: {} +} + +function getUserRole (repoName, user) { + const acl = getACL(repoName) + return acl.ACL[user] +} + +function getAdmins (repoName) { + const acl = getACL(repoName) + return Object.keys(acl.ACL).filter(user => acl.ACL[user] === 'admin') +} + +function getContributors (repoName) { + const acl = getACL(repoName) + const contributors = Object.keys(acl.ACL).filter(user => acl.ACL[user] === 'contributor') + const admins = getAdmins(repoName) + return [...contributors, ...admins].filter((user, i, arr) => arr.indexOf(user) === i) +} + +function getViewers (repoName) { + const acl = getACL(repoName) + const viewers = Object.keys(acl.ACL).filter(user => acl.ACL[user] === 'viewer') + const contributors = getContributors(repoName) + const admins = getAdmins(repoName) + + return [...viewers, ...contributors, ...admins].filter((user, i, arr) => arr.indexOf(user) === i) +} + +function grantAccessToUser (repoName, user, role) { + if (!ROLES[role]) throw new Error(`Role ${role} does not exist`) + if (!Object.keys(ROLES).includes(role)) throw new Error(`Role ${role} is not allowed`) + + const acl = getACL(repoName) + acl.ACL[user] = role + setACL(repoName, acl) +} + +function revokeAccessFromUser (repoName, user) { + const acl = getACL(repoName) + delete acl.ACL[user] + setACL(repoName, acl) +} + +function addProtectedBranch (repoName, branch) { + const acl = getACL(repoName) + if (acl.protectedBranches.includes(branch)) throw new Error(`${branch} is already protected`) + acl.protectedBranches.push(branch) + setACL(repoName, acl) +} + +function removeProtectedBranch (repoName, branch) { + const acl = getACL(repoName) + acl.protectedBranches = acl.protectedBranches.filter(b => b !== branch) + setACL(repoName, acl) +} + +function makeRepoPublic (repoName) { + const acl = getACL(repoName) + acl.visibility = 'public' + setACL(repoName, acl) +} + +function getRepoVisibility (repoName) { + const acl = getACL(repoName) + return acl.visibility +} + +function makeRepoPrivate (repoName) { + const acl = getACL(repoName) + acl.visibility = 'private' + setACL(repoName, acl) +} + +function setACL (repoName, acl = DEFAULT_ACL) { + acl = { ...DEFAULT_ACL, ...acl } + if (['public', 'private'].indexOf(acl.visibility) === -1) throw new Error('Visibility must be public or private') + + const content = JSON.stringify(acl, null, 2) + fs.writeFileSync(home.getACLFilePath(repoName), content) + + return acl +} + +function getACL (repoName) { + return JSON.parse(fs.readFileSync(home.getACLFilePath(repoName), 'utf8') || JSON.stringify(DEFAULT_ACL)) +} + +module.exports = { + getUserRole, + grantAccessToUser, + makeRepoPublic, + makeRepoPrivate, + getRepoVisibility, + setACL, + getACL, + addProtectedBranch, + removeProtectedBranch, + getAdmins, + getContributors, + getViewers, + revokeAccessFromUser, +} diff --git a/src/auth/index.js b/src/auth/index.js new file mode 100644 index 0000000..54037db --- /dev/null +++ b/src/auth/index.js @@ -0,0 +1,20 @@ +function getId(data) { + if (!process.env.GIT_PEAR_AUTH) return payload + if (process.env.GIT_PEAR_AUTH === 'nip98') { + const nip98 = require('./nip98') + return nip98.getId(data) + } +} + +async function getToken(payload) { + if (!process.env.GIT_PEAR_AUTH) return userId + if (process.env.GIT_PEAR_AUTH === 'nip98') { + const nip98 = require('./nip98') + return nip98.getToken(payload) + } +} + +module.exports = { + getId, + getToken +} diff --git a/src/auth/nip98.js b/src/auth/nip98.js new file mode 100644 index 0000000..42e2751 --- /dev/null +++ b/src/auth/nip98.js @@ -0,0 +1,28 @@ +const { nip98, nip19, finalizeEvent } = require('nostr-tools') + +async function getToken({ url, method, data }) { + const { data: sK } = nip19.decode(process.env.GIT_PEAR_AUTH_NSEC) + return nip98.getToken( + url, + method, + (e) => finalizeEvent(e, sK), + false, + data + ) +} + +// FIXME +async function getId({ payload, url, method, data }) { + const event = JSON.parse(Buffer.from(payload, 'base64').toString()) + const isValid = await nip98.validateEvent(event, url, method, data) + if (!isValid) throw new Error('Invalid event') + return { + ...event, + userId: nip19.npubEncode(event.pubkey) + } +} + +module.exports = { + getId, + getToken +} diff --git a/src/cli.js b/src/cli.js index 71ab6e8..4a5361b 100755 --- a/src/cli.js +++ b/src/cli.js @@ -9,6 +9,7 @@ const path = require('path') const fs = require('fs') const home = require('./home') +const acl = require('./acl') const git = require('./git') const pkg = require('../package.json') @@ -32,19 +33,28 @@ program const name = fullPath.split(path.sep).pop() if ((home.isInitialized(name))) { console.error(`${name} is already initialized`) + await git.addRemote(name) + console.log(`Added git remote for "${name}" as "pear"`) process.exit(1) } - home.createAppFolder(name) - console.log(`Added project "${name}" to gitpear`) - await git.createBareRepo(name) - console.log(`Created bare repo for "${name}"`) - await git.addRemote(name) - console.log(`Added git remote for "${name}" as "pear"`) + try { + home.createAppFolder(name) + console.log(`Added project "${name}" to gitpear`) + } catch (e) { } + try { + await git.createBareRepo(name) + console.log(`Created bare repo for "${name}"`) + } catch (e) { } + try { + await git.addRemote(name) + console.log(`Added git remote for "${name}" as "pear"`) + } catch (e) { } if (options.share) { - home.shareAppFolder(name) - await git.push() + try { home.shareAppFolder(name) } catch (e) { } + try { acl.setACL(name) } catch (e) { } + try { await git.push() } catch (e) { } console.log(`Shared "${name}" project`) } }) @@ -53,25 +63,153 @@ program .command('share') .description('share a gitpear repo') .addArgument(new commander.Argument('[p]', 'path to the repo').default('.')) - .action(async (p, options) => { - const name = path.resolve(p).split(path.sep).pop() - if ((home.isInitialized(name))) { - home.shareAppFolder(name) - await git.push() - console.log(`Shared "${name}" project`) + .addArgument(new commander.Argument('[v]', 'visibility of the repo').default('public')) + .action(async (p, v, options) => { + const fullPath = path.resolve(p) + if (!fs.existsSync(path.join(fullPath, '.git'))) { + console.error('Not a git repo') + process.exit(1) + } + + const name = fullPath.split(path.sep).pop() + if (!home.isInitialized(name)) { + console.error(`${name} is not initialized`) + process.exit(1) + } + + try { home.shareAppFolder(name) } catch (e) { } + try { acl.setACL(name, { visibility: v }) } catch (e) { } + try { await git.push() } catch (e) { } + console.log(`Shared "${name}" project, as ${v} repo`) + return + }) + +program + .command('branch') + .description('branch protection rules') + .addArgument(new commander.Argument('[a]', 'actiont to perform').choices(['add', 'remove', 'list']).default('list')) + .addArgument(new commander.Argument('[b]', 'branch name').default('')) + .addArgument(new commander.Argument('[p]', 'path to the repo').default('.')) + .action(async (a, b, p, options) => { + const fullPath = path.resolve(p) + if (!fs.existsSync(path.join(fullPath, '.git'))) { + console.error('Not a git repo') + process.exit(1) + } + + const name = fullPath.split(path.sep).pop() + if (!home.isInitialized(name)) { + console.error(`${name} is not initialized`) + process.exit(1) + } + + if (a === 'list' && !b) { logBranches(name) } + + if (a === 'add') { + acl.addProtectedBranch(name, b) + logBranches(name) + } + + if (a === 'remove') { + acl.removeProtectedBranch(name, b) + logBranches(name) + } + + function logBranches(name) { + const repoACL = acl.getACL(name) + console.log('Visibility:', '\t', repoACL.visibility) + console.log('Branch:') + for (const branch of repoACL.protectedBranches) { console.log(branch) } + } + + return + }) + + +program + .command('acl') + .description('set acl of a gitpear repo') + .addArgument(new commander.Argument('[a]', 'actiont to perform').choices(['add', 'remove', 'list']).default('list')) + .addArgument(new commander.Argument('[u]', 'user to add/remove/list').default('')) + .addArgument(new commander.Argument('[p]', 'path to the repo').default('.')) + .action(async (a, u, p, options) => { + + // TODO: add branch protection logic + const fullPath = path.resolve(p) + if (!fs.existsSync(path.join(fullPath, '.git'))) { + console.error('Not a git repo') + process.exit(1) + } + + const name = fullPath.split(path.sep).pop() + if (!home.isInitialized(name)) { + console.error(`${name} is not initialized`) + process.exit(1) + } + const repoACL = acl.getACL(name) + + if (a === 'list' && !u) { + console.log('Repo Visibility:', '\t', repoACL.visibility) + console.log('User:', '\t', 'Role:') + for (const user in repoACL.ACL) { + console.log(user, '\t', repoACL.ACL[user]) + } return } - console.error(`${name} is not initialized`) - process.exit(1) + if (a === 'list') { + console.log('Repo Visibility:', '\t', repoACL.visibility) + console.log('User:', u, '\t', repoACL.ACL[u]) + return + } + + if (a === 'add') { + if (!u) { + console.error('User not provided') + process.exit(1) + } + + const [ userId, role ] = u.split(':') + if (repoACL.ACL[userId]) { + console.error(`${userId} already has access to ${name} as ${repoACL.ACL[userId]}`) + process.exit(1) + } + + acl.grantAccessToUser(name, userId, role) + console.log(`Added ${userId} to ${name} as ${role}`) + return + } + + if (a === 'remove') { + if (!u) { + console.error('User not provided') + process.exit(1) + } + + if (!repoACL.ACL[u]) { + console.error(`${u} does not have access to ${name}`) + process.exit(1) + } + + acl.revokeAccessFromUser(name, u) + console.log(`Removed ${u} from ${name}`) + return + } }) + program .command('unshare') .description('unshare a gitpear repo') .addArgument(new commander.Argument('[p]', 'path to the repo').default('.')) .action((p, options) => { - const name = path.resolve(p).split(path.sep).pop() + const fullPath = path.resolve(p) + if (!fs.existsSync(path.join(fullPath, '.git'))) { + console.error('Not a git repo') + process.exit(1) + } + + const name = fullPath.split(path.sep).pop() if ((home.isInitialized(name))) { home.unshareAppFolder(name) console.log(`Unshared "${name}" project`) diff --git a/src/git-remote-pear.js b/src/git-remote-pear.js index 25a57a4..088b9e2 100755 --- a/src/git-remote-pear.js +++ b/src/git-remote-pear.js @@ -1,5 +1,6 @@ #!/usr/bin/env node +const { spawn } = require('child_process') const ProtomuxRPC = require('protomux-rpc') const RAM = require('random-access-memory') @@ -9,6 +10,11 @@ const Hyperdrive = require('hyperdrive') const crypto = require('hypercore-crypto') const git = require('./git.js') +const home = require('./home') +const auth = require('./auth') +const acl = require('./acl') + +const fs = require('fs') const url = process.argv[3] const matches = url.match(/pear:\/\/([a-f0-9]{64})\/(.*)/) @@ -22,7 +28,12 @@ const targetKey = matches[1] const repoName = matches[2] const store = new Corestore(RAM) -const swarm = new Hyperswarm() +const swarm = new Hyperswarm({ keypair: home.getKeyPair() }) + +if (!home.isDaemonRunning()) { + console.error('Please start git pear daemon') + process.exit(1) +} swarm.join(crypto.discoveryKey(Buffer.from(targetKey, 'hex')), { server: false }) @@ -30,7 +41,12 @@ swarm.on('connection', async (socket) => { store.replicate(socket) const rpc = new ProtomuxRPC(socket) - const reposRes = await rpc.request('get-repos') + let payload = { body: { url, method: 'get-repos' } } + if (process.env.GIT_PEAR_AUTH) { + payload.header = await auth.getToken(payload.body) + } + + const reposRes = await rpc.request('get-repos', Buffer.from(JSON.stringify(payload))) const repositories = JSON.parse(reposRes.toString()) if (!repositories) { console.error('Failed to retrieve repositories') @@ -56,27 +72,87 @@ swarm.on('connection', async (socket) => { await drive.core.update({ wait: true }) - const refsRes = await rpc.request('get-refs', Buffer.from(repoName)) + payload = { body: { url, method: 'get-refs', data: repoName }} + if (process.env.GIT_PEAR_AUTH) { + payload.header = await auth.getToken(payload.body) + } + const refsRes = await rpc.request('get-refs', Buffer.from(JSON.stringify(payload))) - await talkToGit(JSON.parse(refsRes.toString()), drive) + let commit + try { + commit = await git.getCommit() + } catch (e) { } + await talkToGit(JSON.parse(refsRes.toString()), drive, repoName, rpc, commit) }) -async function talkToGit (refs, drive) { - for (const ref in refs) { - console.warn(refs[ref] + '\t' + ref) - } +async function talkToGit (refs, drive, repoName, rpc, commit) { process.stdin.setEncoding('utf8') const didFetch = false process.stdin.on('readable', async function () { const chunk = process.stdin.read() if (chunk === 'capabilities\n') { + process.stdout.write('list\n') + process.stdout.write('push\n') process.stdout.write('fetch\n\n') - } else if (chunk === 'list\n') { + } else if (chunk && chunk.search(/^push/) !== -1) { + const [_command, path] = chunk.split(' ') + let [src, dst] = path.split(':') + + const isDelete = !src + const isForce = src.startsWith('+') + + if (!home.isShared(repoName)) { + home.shareAppFolder(name) + } + + dst = dst.replace('refs/heads/', '').replace('\n\n', '') + + try { home.createAppFolder(repoName) } catch (e) { } + try { await git.createBareRepo(repoName) } catch (e) { } + try { await git.addRemote(repoName) } catch (e) { } + try { await git.push(dst) } catch (e) { } + try { home.shareAppFolder(repoName) } catch (e) { } + try { acl.setACL(repoName, acl.getACL(repoName)) } catch (e) { } + + let method + if (isDelete) { + method = 'd-branch' + } else if (isForce) { + console.warn('To', url) + await git.push(src, isForce) + src = src.replace('+', '') + method = 'f-push' + } else { + console.warn('To', url) + await git.push(src) + method = 'push' + } + + const publicKey = home.readPk() + let payload = { body: { + url: `pear://${publicKey}/${repoName}`, + data: `${dst}#${commit}`, + method + } } + if (process.env.GIT_PEAR_AUTH) { + payload.header = await auth.getToken(payload.body) + } + const res = await rpc.request(method, Buffer.from(JSON.stringify(payload))) + + process.stdout.write('\n\n') + process.exit(0) + } else if (chunk && chunk.search(/^list/) !== -1) { // list && list for-push + for (const ref in refs) { + console.warn(refs[ref] + '\t' + ref) + } Object.keys(refs).forEach(function (branch, i) { process.stdout.write(refs[branch] + ' ' + branch + '\n') }) process.stdout.write('\n') } else if (chunk && chunk.search(/^fetch/) !== -1) { + for (const ref in refs) { + console.warn(refs[ref] + '\t' + ref) + } const lines = chunk.split(/\n/).filter(l => l !== '') const targets = [] diff --git a/src/git.js b/src/git.js index f733555..41bf38d 100644 --- a/src/git.js +++ b/src/git.js @@ -1,6 +1,25 @@ const { getCodePath } = require('./home') const { spawn } = require('child_process') +async function getCommit () { + return await new Promise((resolve, reject) => { + const process = spawn('git', ['rev-parse', 'HEAD']) + let outBuffer = Buffer.from('') + process.stdout.on('data', data => { + outBuffer = Buffer.concat([outBuffer, data]) + }) + + let errBuffer = Buffer.from('') + process.stderr.on('err', data => { + errBuffer = Buffer.concat([errBuffer, data]) + }) + + process.on('close', code => { + return code === 0 ? resolve(outBuffer.toString().replace('\n', '')) : reject(errBuffer) + }) + }) +} + async function lsPromise (url) { const ls = spawn('git', ['ls-remote', url]) const res = {} @@ -31,8 +50,10 @@ async function addRemote (name) { return await doGit(init) } -async function push (branch = 'master') { - const push = spawn('git', ['push', 'pear', branch]) +async function push (branch = 'master', force = false) { + const args = ['push', 'pear', branch] + if (force) args.push('-f') + const push = spawn('git', args) return await doGit(push) } @@ -180,4 +201,4 @@ async function unpackStream (packStream) { }) } -module.exports = { lsPromise, uploadPack, unpackFile, unpackStream, createBareRepo, addRemote, push } +module.exports = { lsPromise, uploadPack, unpackFile, unpackStream, createBareRepo, addRemote, push, getCommit } diff --git a/src/home.js b/src/home.js index 1db2e5d..37d6036 100644 --- a/src/home.js +++ b/src/home.js @@ -14,6 +14,11 @@ function shareAppFolder (name) { fs.openSync(`${APP_HOME}/${name}/.git-daemon-export-ok`, 'w') } +function getACLFilePath (name) { + if (!fs.existsSync(`${APP_HOME}/${name}/.git-daemon-export-ok`)) throw new Error('Repo is not shared') + return `${APP_HOME}/${name}/.git-daemon-export-ok` +} + function unshareAppFolder (name) { fs.unlinkSync(`${APP_HOME}/${name}/.git-daemon-export-ok`) } @@ -90,6 +95,10 @@ function getDaemonPid () { } } +function isDaemonRunning () { + return fs.existsSync(`${APP_HOME}/.daemon.pid`) +} + function removeDaemonPid () { try { fs.unlinkSync(`${APP_HOME}/.daemon.pid`) @@ -114,5 +123,7 @@ module.exports = { getErrStream, storeDaemonPid, getDaemonPid, - removeDaemonPid + isDaemonRunning, + removeDaemonPid, + getACLFilePath } diff --git a/src/rpc.js b/src/rpc.js old mode 100644 new mode 100755 index 34ad1f2..e3338cc --- a/src/rpc.js +++ b/src/rpc.js @@ -1,4 +1,8 @@ const ProtomuxRPC = require('protomux-rpc') +const { spawn } = require('child_process') +const home = require('./home') +const auth = require('./auth') +const acl = require('./acl') module.exports = class RPC { constructor (announcedRefs, repositories, drives) { @@ -16,23 +20,138 @@ module.exports = class RPC { // for example check of peerInfo.publicKey is in a list of allowed keys // which can in turn be stored in a .git-daemon-export-ok file - rpc.respond('get-repos', req => this.getReposHandler(req)) - rpc.respond('get-refs', async req => await this.getRefsHandler(req)) + /* -- PULL HANDLERS -- */ + rpc.respond('get-repos', async req => await this.getReposHandler(peerInfo.publicKey, req)) + rpc.respond('get-refs', async req => await this.getRefsHandler(peerInfo.publicKey, req)) + + if (process.env.GIT_PEAR_AUTH) { + /* -- PUSH HANDLERS -- */ + rpc.respond('push', async req => await this.pushHandler(peerInfo.publicKey, req)) + rpc.respond('f-push', async req => await this.forcePushHandler(peerInfo.publicKey, req)) + rpc.respond('d-branch', async req => await this.deleteBranchHandler(peerInfo.publicKey, req)) + } this.connections[peerInfo.publicKey] = rpc } - getReposHandler (_req) { + async getReposHandler (publicKey, req) { + const { branch, url, userId } = await this.parseReq(publicKey, req) + const res = {} - for (const repo in this.repositories) { - res[repo] = this.drives[repo].key.toString('hex') + for (const repoName in this.repositories) { + // TODO: add only public repos and those which are shared with the peer + // Alternatively return only requested repo + const isPublic = (acl.getACL(repoName).visibility === 'public') + if (isPublic || acl.getViewers(repoName).includes(userId)) { + res[repoName] = this.drives[repoName].key.toString('hex') + } } return Buffer.from(JSON.stringify(res)) } - getRefsHandler (req) { - const res = this.repositories[req.toString()] + async getRefsHandler (publicKey, req) { + const { repoName, branch, url, userId } = await this.parseReq(publicKey, req) + const res = this.repositories[repoName] - return Buffer.from(JSON.stringify(res)) + const isPublic = (acl.getACL(repoName).visibility === 'public') + if (isPublic || acl.getViewers(repoName).includes(userId)) { + return Buffer.from(JSON.stringify(res)) + } else { + throw new Error('You are not allowed to access this repo') + } + } + + async pushHandler (publicKey, req) { + const { url, repoName, branch, userId } = await this.parseReq(publicKey, req) + const isContributor = acl.getContributors(repoName).includes(userId) + + if (!isContributor) throw new Error('You are not allowed to push to this repo') + + const isProtectedBranch = acl.getACL(repoName).protectedBranches.includes(branch) + const isAdmin = acl.getAdmins(repoName).includes(userId) + + if (isProtectedBranch && !isAdmin) throw new Error('You are not allowed to push to this branch') + + return await new Promise((resolve, reject) => { + const env = { ...process.env, GIT_DIR: home.getCodePath(repoName) } + const child = spawn('git', ['fetch', url, `${branch}:${branch}`], { env }) + let errBuffer = Buffer.from('') + child.stderr.on('data', data => { + errBuffer = Buffer.concat([errBuffer, data]) + }) + + child.on('close', code => { + return code === 0 ? resolve(errBuffer) : reject(errBuffer) + }) + }) + } + + async forcePushHandler (publicKey, req) { + const { url, repoName, branch, userId } = await this.parseReq(publicKey, req) + const isContributor = acl.getContributors(repoName).includes(userId) + + if (!isContributor) throw new Error('You are not allowed to push to this repo') + + const isProtectedBranch = acl.getACL(repoName).protectedBranches.includes(branch) + const isAdmin = acl.getAdmins(repoName).includes(userId) + + if (isProtectedBranch && !isAdmin) throw new Error('You are not allowed to push to this branch') + + return await new Promise((resolve, reject) => { + const env = { ...process.env, GIT_DIR: home.getCodePath(repoName) } + const child = spawn('git', ['fetch', url, `${branch}:${branch}`, '--force'], { env }) + let errBuffer = Buffer.from('') + child.stderr.on('data', data => { + errBuffer = Buffer.concat([errBuffer, data]) + }) + + child.on('close', code => { + return code === 0 ? resolve(errBuffer) : reject(errBuffer) + }) + }) + } + + async deleteBranchHandler (publicKey, req) { + const { url, repoName, branch, userId } = await this.parseReq(publicKey, req) + const isContributor = acl.getContributors(repoName).includes(userId) + + if (!isContributor) throw new Error('You are not allowed to push to this repo') + + const isProtectedBranch = acl.getACL(repoName).protectedBranches.includes(branch) + const isAdmin = acl.getAdmins(repoName).includes(userId) + + if (isProtectedBranch && !isAdmin) throw new Error('You are not allowed to push to this branch') + + return await new Promise((resolve, reject) => { + const env = { ...process.env, GIT_DIR: home.getCodePath(repoName) } + const child = spawn('git', ['branch', '-D', branch], { env }) + let errBuffer = Buffer.from('') + child.stderr.on('data', data => { + errBuffer = Buffer.concat([errBuffer, data]) + }) + + child.on('close', code => { + return code === 0 ? resolve(errBuffer) : reject(errBuffer) + }) + }) + } + + async parseReq(publicKey, req) { + if (!req) throw new Error('Request is empty') + const request = JSON.parse(req.toString()) + const parsed = { + repoName: request.body.url?.split('/')?.pop(), + branch: request.body.data?.split('#')[0], + url: request.body.url, + userId: await this.authenticate(publicKey, request), + } + return parsed + } + + async authenticate (publicKey, request) { + if (!process.env.GIT_PEAR_AUTH) return publicKey.toString('hex') + if (!request.header) throw new Error('You are not allowed to access this repo') + + return (await auth.getId({ ...request.body, payload: request.header })).userId } } diff --git a/test/acl.test.js b/test/acl.test.js new file mode 100644 index 0000000..a52ef81 --- /dev/null +++ b/test/acl.test.js @@ -0,0 +1,59 @@ +const test = require('brittle') +const acl = require('../src/acl') + +test('acl', async t => { + const repoName = 'foo' + + t.test('setACL', async t => { + const aclObj = acl.setACL(repoName) + t.is(aclObj.visibility, 'public') + t.is(aclObj.protectedBranches.length, 1) + t.is(aclObj.protectedBranches[0], 'master') + t.is(Object.keys(aclObj.ACL).length, 0) + }) + + test('getACL', async t => { + const aclObj = acl.setACL(repoName) + t.alike(acl.getACL(repoName), aclObj) + }) + + test('makeRepoPublic', async t => { + acl.makeRepoPublic(repoName) + t.is(acl.getRepoVisibility(repoName), 'public') + }) + + test('makeRepoPrivate', async t => { + acl.makeRepoPrivate(repoName) + t.is(acl.getRepoVisibility(repoName), 'private') + }) + + test('grantAccessToUser', async t => { + acl.grantAccessToUser(repoName, 'user1', 'admin') + t.alike(acl.getUserRole(repoName, 'user1'), 'admin') + }) + + test('addProtectedBranch', async t => { + acl.addProtectedBranch(repoName, 'branch1') + t.is(acl.getACL(repoName).protectedBranches.length, 2) + }) + + test('removeProtectedBranch', async t => { + acl.removeProtectedBranch(repoName, 'branch1') + t.is(acl.getACL(repoName).protectedBranches.length, 1) + }) + + test('getAdmins', async t => { + acl.grantAccessToUser(repoName, 'user2', 'admin') + t.alike(acl.getAdmins(repoName), ['user1', 'user2']) + }) + + test('getContributors', async t => { + acl.grantAccessToUser(repoName, 'user3', 'contributor') + t.alike(acl.getContributors(repoName), ['user3', 'user1', 'user2']) + }) + + test('getViewers', async t => { + acl.grantAccessToUser(repoName, 'user4', 'viewer') + t.alike(acl.getViewers(repoName), ['user4', 'user3', 'user1', 'user2']) + }) +}) diff --git a/test/home.test.js b/test/home.test.js index 115b014..2381886 100644 --- a/test/home.test.js +++ b/test/home.test.js @@ -9,33 +9,33 @@ test('getAppHome', t => { }) test('createAppFolder, share, is shared, unshare, isInitialized, list, getCodePath', t => { - home.createAppFolder('test') + home.createAppFolder('test_code') - t.ok(fs.existsSync(path.join(home.APP_HOME, 'test', 'code'))) + t.ok(fs.existsSync(path.join(home.APP_HOME, 'test_code', 'code'))) - t.absent(home.isShared('test')) - t.absent(fs.existsSync(path.join(home.APP_HOME, 'test', '.git-daemon-export-ok'))) + t.absent(home.isShared('test_code')) + t.absent(fs.existsSync(path.join(home.APP_HOME, 'test_code', '.git-daemon-export-ok'))) - home.shareAppFolder('test') + home.shareAppFolder('test_code') - t.ok(home.isShared('test')) - t.ok(fs.existsSync(path.join(home.APP_HOME, 'test', '.git-daemon-export-ok'))) + t.ok(home.isShared('test_code')) + t.ok(fs.existsSync(path.join(home.APP_HOME, 'test_code', '.git-daemon-export-ok'))) - home.unshareAppFolder('test') + home.unshareAppFolder('test_code') - t.absent(home.isShared('test')) - t.absent(fs.existsSync(path.join(home.APP_HOME, 'test', '.git-daemon-export-ok'))) + t.absent(home.isShared('test_code')) + t.absent(fs.existsSync(path.join(home.APP_HOME, 'test_code', '.git-daemon-export-ok'))) - t.absent(home.isInitialized('test')) + t.absent(home.isInitialized('test_code')) t.ok(home.isInitialized('foo')) t.alike(new Set(home.list()), new Set(['foo', 'bar', 'zar'])) t.alike(new Set(home.list(true)), new Set(['foo', 'bar'])) - t.alike(path.resolve(home.getCodePath('test')), path.resolve(path.join(home.APP_HOME, 'test', 'code'))) + t.alike(path.resolve(home.getCodePath('test_code')), path.resolve(path.join(home.APP_HOME, 'test_code', 'code'))) t.teardown(() => { - fs.rmSync(path.join(home.APP_HOME, 'test', 'code'), { recursive: true }) + fs.rmSync(path.join(home.APP_HOME, 'test_code'), { recursive: true }) }) }) diff --git a/test/rpc.test.js b/test/rpc.test.js index 9b9a7d2..fc90b99 100644 --- a/test/rpc.test.js +++ b/test/rpc.test.js @@ -40,7 +40,10 @@ test('e2e', async t => { clientStore.replicate(socket) const rpc = new ProtomuxRPC(socket) - const reposRes = await rpc.request('get-repos') + const repoName = 'foo' + const url = `${serverSwarm.keyPair.publicKey.toString('hex')}/${repoName}` + let payload = Buffer.from(JSON.stringify({ body: { url, method: 'get-repos' } })) + const reposRes = await rpc.request('get-repos', payload) const reposJSON = JSON.parse(reposRes.toString()) const driveKey = Buffer.from(reposJSON.foo, 'hex') @@ -53,7 +56,8 @@ test('e2e', async t => { await drive.core.update({ wait: true }) - const refsRes = await rpc.request('get-refs', Buffer.from('foo')) + payload = Buffer.from(JSON.stringify({ body: { url, method: 'get-refs', data: repoName }})) + const refsRes = await rpc.request('get-refs', payload) t.ok(refsRes) const want = Object.values(JSON.parse(refsRes.toString()))[0] diff --git a/test_home.tar.gz b/test_home.tar.gz index e1291c9..cdbf5e4 100644 Binary files a/test_home.tar.gz and b/test_home.tar.gz differ