Merge pull request #16 from dzdidi/advanced-acl

Advanced acl
This commit is contained in:
DZ
2024-01-29 09:47:16 +00:00
committed by GitHub
15 changed files with 922 additions and 68 deletions

View File

@@ -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:<public key>/<repo name>`
* `git pear init [-s, --share] <path>` - 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/<repository name>). 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 <path>` later
* `git pear share <path>` - makes repository sharable
* `git pear unshare <path>` - 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] <path>` - ACL managegement
* `git pear acl list [userId] <path>` - list repository visitbility and user's role (or roles of all users if userId is not provided)
* `git pear acl add <userId:role> <path>` - 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 <userId> <path>` - 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 <branch name> <repo path>` - mark branch as protected (defatul repo path is ".")
* `git pear branch remove <branch name> <repo path>` - 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=<Carol's 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 . <private|public>`
5. Add Daviv as a `contirbutor`.
6. List David's npub as a contributor
* `git pear acl add <David npub>: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=<David's nsec> GIT_PEAR_AUTH='nip98' git clone pear://<Carol's url>/<repo name>`
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=<David's 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`

210
npm-shrinkwrap.json generated
View File

@@ -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",

View File

@@ -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"
}

120
src/acl.js Normal file
View File

@@ -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,
}

20
src/auth/index.js Normal file
View File

@@ -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
}

28
src/auth/nip98.js Normal file
View File

@@ -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
}

View File

@@ -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`)

View File

@@ -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 = []

View File

@@ -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 }

View File

@@ -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
}

135
src/rpc.js Normal file → Executable file
View File

@@ -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
}
}

59
test/acl.test.js Normal file
View File

@@ -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'])
})
})

View File

@@ -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 })
})
})

View File

@@ -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]

Binary file not shown.