Merge branch 'dzdidi:master' into master

This commit is contained in:
2024-02-18 17:48:01 +00:00
committed by GitHub
21 changed files with 650 additions and 344 deletions

View File

@@ -38,13 +38,13 @@ All data will be persisted in application directory (default `~/.gitpear`). To c
* `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 <path> [-s, --share [branch]]` - 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 | --share [branch]` (default branch to share is current) or call `gitpear share <path>` later
* `git pear share [-p, --path [path (default: ".")]> [-b, --branch [branch name (default: "_current_")] [-v, --visibility <private|public> (default: "public")]` - share repository, if branch is not specified, default branch will be shared
* `git pear unshare <path>` - stop sharing repository
* `git pear init [-s, --share [branch]]` - 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 | --share [branch]` (default branch to share is current) or call `gitpear share` later
* `git pear share [-b, --branch [branch name (default: "_current_")] [-v, --visibility <private|public> (default: "public")]` - share current repository, if branch is not specified, default branch will be shared
* `git pear unshare` - stop sharing current repository
* `git pear list [-s, --shared]` - list all or (only shared) repositories
* `git pear list <url>` - list repositories of a peer
### ACL (for authenticated access to enable support of PUSH)
### User Access and Branch Protection Rules (for authenticated access to enable support of PUSH)
Support of `push` capabilities only enabled for authenticated users. Currently supported authentications are based on:
* [noise](https://github.com/libp2p/specs/blob/master/noise/README.md);
@@ -62,22 +62,26 @@ or
GIT_PEAR_AUTH=native git pear daemon -s
```
#### User Access Control
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
* `git pear acl -u [command] -p <repo path or url (default ".")>` - ACL managegement of for users access in local or remote repo (requires `owner` permission for remote repositories)
* `git pear acl -u list [userId] -p <repo path or url (default ".")>` - list repository visitbility and user's role (or roles of all users if userId is not provided)
* `git pear acl add -u <userId:role> -p <repo path or url (default ".")>` - add user as a "role" to repository available roles are `viewer`, `contributor`, `admin`, `owner`. Roles exaplained:
* `viewer` - can read all branches;
* `contributor` - can edit all branches except protected (default master)
* `admin` - can edit protected branches
* `owner` - can edit repo ack
* `git pear acl remove -u| <userId> -p <repo path or url (default ".")>` - 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
* `git pear acl -b [command] -p <repo path or url (deafult ".")`, same as `git pear branch list` - list protection rules
* `git pear acl -b list` - git pear branch list .` - list protection rules;
* `git pear acl -b add <branchName> <repo path or url (default ".")>` - mark branch as protected;
* `git pear acl -b remove <branchName> <repo path or url (deafult ".")>` - unmark branch as protected;
# Examples of usage
@@ -128,7 +132,7 @@ 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.
Collaboration is possible with the following flow between Carol and Dave in a peer-to-peer manner.
Supported authentication methods are `native` and `nip98`. The `nip98` authentication, requires environment variable `GIT_PEAR_AUTH_NSEC` with nsec
@@ -138,12 +142,12 @@ Supported authentication methods are `native` and `nip98`. The `nip98` authentic
2. Go to repository
* `cd repo`
3. Initialize git pear repository
* `git pear init .`
* `git pear init`
4. Share repository wit hviben visibility () - (default is `public`)
* `git pear share . public`
5. Add Daviv as a `contirbutor`.
6. List David's npub as a contributor
* `git pear acl add <David pub key hex>:contributor`
* `git pear share public`
5. Add Dave as a `contirbutor`.
6. List Dave's npub as a contributor
* `git pear acl add <Dave pub key hex>:contributor`
7. Retreive repo url and share it with Dave
* `git pear list -s`

View File

@@ -2,15 +2,18 @@ const home = require('./home')
const fs = require('fs')
const ROLES = {
owner: {
description: 'Read and write to all branches, and ACL management'
},
admin: {
description: 'Read and write to all branches',
description: 'Read and write to all branches'
},
contributor: {
description: 'Read and write to all branches except protected ones',
description: 'Read and write to all branches except protected ones'
},
viewer: {
description: 'Read all branches',
},
description: 'Read all branches'
}
}
const DEFAULT_ACL = {
visibility: 'public', // public|private
@@ -23,9 +26,16 @@ function getUserRole (repoName, user) {
return acl.ACL[user]
}
function getOwners (repoName) {
const acl = getACL(repoName)
return Object.keys(acl.ACL).filter(user => acl.ACL[user] === 'owner')
}
function getAdmins (repoName) {
const acl = getACL(repoName)
return Object.keys(acl.ACL).filter(user => acl.ACL[user] === 'admin')
const admins = Object.keys(acl.ACL).filter(user => acl.ACL[user] === 'admin')
const owners = getOwners(repoName)
return [...admins, ...owners].filter((user, i, arr) => arr.indexOf(user) === i)
}
function getContributors (repoName) {
@@ -113,8 +123,9 @@ module.exports = {
getACL,
addProtectedBranch,
removeProtectedBranch,
getOwners,
getAdmins,
getContributors,
getViewers,
revokeAccessFromUser,
revokeAccessFromUser
}

View File

@@ -1,13 +1,13 @@
function getId(data) {
if (!process.env.GIT_PEAR_AUTH) return payload
function getId (data) {
if (!process.env.GIT_PEAR_AUTH) return data
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
async function getToken (payload) {
if (!process.env.GIT_PEAR_AUTH) return payload
if (process.env.GIT_PEAR_AUTH === 'nip98') {
const nip98 = require('./nip98')
return nip98.getToken(payload)

View File

@@ -1,6 +1,7 @@
const { nip98, nip19, finalizeEvent } = require('nostr-tools')
async function getToken({ url, method, data }) {
async function getToken ({ url, method, data }) {
if (!process.env.GIT_PEAR_AUTH_NSEC) throw new Error('Missing NSEC')
const { data: sK } = nip19.decode(process.env.GIT_PEAR_AUTH_NSEC)
return nip98.getToken(
url,
@@ -12,11 +13,11 @@ async function getToken({ url, method, data }) {
}
// FIXME
async function getId({ payload, url, method, data }) {
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 {
return {
...event,
userId: nip19.npubEncode(event.pubkey)
}

156
src/cli-helpers.js Normal file
View File

@@ -0,0 +1,156 @@
const path = require('path')
const home = require('./home')
const acl = require('./acl')
const git = require('./git')
const { aclRemote } = require('./rpc-requests')
const { printACL, printACLForUser, checkIfGitRepo, logBranches } = require('./utils')
async function remoteACL (a, b, p, options) {
if (a === 'list') {
await aclRemote.list(p, b)
} else if (a === 'add') {
if (!b) {
console.error('User not provided')
process.exit(1)
}
if (b.split(':').length !== 2) {
console.error('Invalid role')
process.exit(1)
}
await aclRemote.add(p, b)
} else if (a === 'remove') {
if (!b) {
console.error('User not provided')
process.exit(1)
}
await aclRemote.remove(p, b)
} else {
throw new Error('Invalid action')
}
}
async function share (name, branchToShare, options) {
let aclOptions
let message = `Shared "${name}" project, ${branchToShare} branch`
if (options?.visibility) {
aclOptions = { visibility: options.visibility }
message = `${message}, as ${options.visibility} repo`
}
try { home.shareAppFolder(name) } catch (e) { }
try { acl.setACL(name, aclOptions) } catch (e) { }
try { await git.push(branchToShare) } catch (e) { }
console.log(message)
}
async function remoteBranchProtectionRules (a, b, p, options) {
if (a === 'list') {
await aclRemote.list(p, b, { branch: true })
} else if (a === 'add') {
await aclRemote.add(p, b, { branch: true })
if (!b) {
console.error('branch is not provided')
process.exit(1)
}
} else if (a === 'remove') {
if (!b) {
console.error('branch is not provided')
process.exit(1)
}
await aclRemote.remove(p, b, { branch: true })
} else {
throw new Error('Invalid action')
}
}
function localACL (a, u, p, options) {
const fullPath = path.resolve(p)
checkIfGitRepo(fullPath)
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) {
printACL(repoACL)
return
}
if (a === 'list') {
printACLForUser(repoACL, 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}`)
}
}
function localBranchProtectionRules (a, b, p, options) {
const fullPath = path.resolve(p)
checkIfGitRepo(fullPath)
const name = fullPath.split(path.sep).pop()
if (!home.isInitialized(name)) {
console.error(`${name} is not initialized`)
process.exit(1)
}
if (a === 'list' && !b) {
const repoACL = acl.getACL(name)
logBranches(repoACL)
}
if (a === 'add') {
acl.addProtectedBranch(name, b)
const repoACL = acl.getACL(name)
logBranches(repoACL)
}
if (a === 'remove') {
acl.removeProtectedBranch(name, b)
const repoACL = acl.getACL(name)
logBranches(repoACL)
}
}
module.exports = {
remoteACL,
share,
remoteBranchProtectionRules,
localACL,
localBranchProtectionRules
}

View File

@@ -6,12 +6,15 @@ const commander = require('commander')
const program = new commander.Command()
const path = require('path')
const fs = require('fs')
const home = require('./home')
const acl = require('./acl')
const git = require('./git')
const { listRemote } = require('./rpc-requests')
const { checkIfGitRepo } = require('./utils')
const { remoteACL, share, remoteBranchProtectionRules, localACL, localBranchProtectionRules } = require('./cli-helpers')
const pkg = require('../package.json')
program
.name('gitpear')
@@ -21,20 +24,18 @@ program
program
.command('init')
.description('initialize a gitpear repo')
.addArgument(new commander.Argument('[p]', 'path to the repo').default('.'))
.option('-s, --share [branch]', 'share the repo as public, default false, default branch is current', '')
.action(async (p, options) => {
const fullPath = path.resolve(p)
if (!fs.existsSync(path.join(fullPath, '.git'))) {
console.error('Not a git repo')
process.exit(1)
}
.action(async (options) => {
const fullPath = path.resolve('.')
checkIfGitRepo(fullPath)
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"`)
try {
await git.addRemote(name)
console.log(`Added git remote for "${name}" as "pear"`)
} catch (e) { }
process.exit(1)
}
@@ -56,12 +57,7 @@ program
branchToShare = options.share
}
if (options.share) {
try { home.shareAppFolder(name) } catch (e) { }
try { acl.setACL(name) } catch (e) { }
try { await git.push(branchToShare) } catch (e) { }
console.log(`Shared "${name}" project, ${branchToShare} branch`)
}
if (options.share) await share(name, branchToShare)
})
program
@@ -69,13 +65,9 @@ program
.description('share a gitpear repo')
.option('-b, --branch [b]', 'branch to share, default is current branch', '')
.option('-v, --visibility [v]', 'visibility of the repo', 'public')
.option('-p, --path [p]', 'path to the repo', '.')
.action(async (options) => {
const fullPath = path.resolve(options.path)
if (!fs.existsSync(path.join(fullPath, '.git'))) {
console.error('Not a git repo')
process.exit(1)
}
const fullPath = path.resolve('.')
checkIfGitRepo(fullPath)
const name = fullPath.split(path.sep).pop()
if (!home.isInitialized(name)) {
@@ -85,137 +77,49 @@ program
const currentBranch = await git.getCurrentBranch()
const branchToShare = options.branch || currentBranch
try { home.shareAppFolder(name) } catch (e) { }
try { acl.setACL(name, { visibility: options.visibility }) } catch (e) { }
try { await git.push(branchToShare) } catch (e) { }
console.log(`Shared "${name}" project, ${branchToShare} branch, as ${options.visibility} repo`)
return
await share(name, branchToShare, options)
})
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')
.description('manage acl of a gitpear repo')
.option('-u, --user', 'user to add/remove/list')
.option('-b, --branch', 'branch to add/remove/list in protected branches')
.option('-p, --path [path]', 'path to the 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)
.addArgument(new commander.Argument('[n]', 'user or branch to add/remove/list').default(''))
.action(async (a, n, options) => {
if (options.user && options.branch) {
throw new Error('Cannot perform both user and branch action at the same time')
}
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
if (!options.user && !options.branch) {
throw new Error('Either user or branch option is required')
}
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)
if (options.user) {
if (options.path.startsWith('pear://')) {
if (n === '.') n = ''
await remoteACL(a, n, options.path, options)
} else {
localACL(a, n, options.path, options)
}
const [ userId, role ] = u.split(':')
if (repoACL.ACL[userId]) {
console.error(`${userId} already has access to ${name} as ${repoACL.ACL[userId]}`)
process.exit(1)
} else if (options.branch) {
if (options.path.startsWith('pear://')) {
await remoteBranchProtectionRules(a, n, options.path, options)
} else {
localBranchProtectionRules(a, n, options.path, options)
}
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 fullPath = path.resolve(p)
if (!fs.existsSync(path.join(fullPath, '.git'))) {
console.error('Not a git repo')
process.exit(1)
}
.option('-p, --path [path]', 'path to the repo', '.')
.action((options) => {
const fullPath = path.resolve(options.path)
checkIfGitRepo(fullPath)
const name = fullPath.split(path.sep).pop()
if ((home.isInitialized(name))) {
@@ -235,7 +139,7 @@ program
.addArgument(new commander.Argument('[u]', 'url to remote pear').default(''))
.option('-s, --shared', 'list only shared repos')
.action((u, options) => {
if (u) return require('./list-remote')(u)
if (u) return listRemote(u)
const k = home.readPk()
const s = options.shared
@@ -276,7 +180,7 @@ program
opts.stdio = 'inherit'
} else {
opts.detached = true
opts.stdio = [ 'ignore', home.getOutStream(), home.getErrStream() ]
opts.stdio = ['ignore', home.getOutStream(), home.getErrStream()]
}
const daemon = spawn('git-peard', opts)

View File

@@ -1,6 +1,5 @@
#!/usr/bin/env node
const { spawn } = require('child_process')
const ProtomuxRPC = require('protomux-rpc')
const RAM = require('random-access-memory')
@@ -14,8 +13,6 @@ 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})\/(.*)/)
@@ -29,7 +26,7 @@ const repoName = matches[2]
const store = new Corestore(RAM)
const swarmOpts = {}
if (process.env.GIT_PEAR_AUTH && process.env.GIT_PEAR_AUTH !== 'native') {
if (process.env.GIT_PEAR_AUTH === 'native') {
swarmOpts.keyPair = home.getKeyPair()
}
const swarm = new Hyperswarm(swarmOpts)
@@ -76,7 +73,7 @@ swarm.on('connection', async (socket) => {
await drive.core.update({ wait: true })
payload = { body: { url, method: 'get-refs', data: repoName }}
payload = { body: { url, method: 'get-refs', data: repoName } }
if (process.env.GIT_PEAR_AUTH && process.env.GIT_PEAR_AUTH !== 'native') {
payload.header = await auth.getToken(payload.body)
}
@@ -99,7 +96,7 @@ async function talkToGit (refs, drive, repoName, rpc, commit) {
process.stdout.write('push\n')
process.stdout.write('fetch\n\n')
} else if (chunk && chunk.search(/^push/) !== -1) {
const [_command, path] = chunk.split(' ')
const path = chunk.split(' ')[1]
let [src, dst] = path.split(':')
const isDelete = !src
@@ -107,13 +104,6 @@ async function talkToGit (refs, drive, repoName, rpc, commit) {
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'
@@ -128,16 +118,25 @@ async function talkToGit (refs, drive, repoName, rpc, commit) {
method = 'push'
}
try { home.createAppFolder(repoName) } catch (e) { }
try { await git.createBareRepo(repoName) } catch (e) { }
try { await git.addRemote(repoName, { quite: true }) } catch (e) { }
try { await git.push(dst, isForce) } catch (e) { }
try { home.shareAppFolder(repoName) } catch (e) { }
try { acl.setACL(repoName, acl.getACL(repoName)) } catch (e) { }
const publicKey = home.readPk()
let payload = { body: {
url: `pear://${publicKey}/${repoName}`,
data: `${dst}#${commit}`,
method
} }
const payload = {
body: {
url: `pear://${publicKey}/${repoName}`,
data: `${dst}#${commit}`,
method
}
}
if (process.env.GIT_PEAR_AUTH && process.env.GIT_PEAR_AUTH !== 'native') {
payload.header = await auth.getToken(payload.body)
}
const res = await rpc.request(method, Buffer.from(JSON.stringify(payload)))
await rpc.request(method, Buffer.from(JSON.stringify(payload)))
process.stdout.write('\n\n')
process.exit(0)

View File

@@ -64,9 +64,9 @@ async function createBareRepo (name) {
return await doGit(init)
}
async function addRemote (name) {
async function addRemote (name, opts = { quiet: false }) {
const init = spawn('git', ['remote', 'add', 'pear', getCodePath(name)])
return await doGit(init)
return await doGit(init, opts)
}
async function push (branch = 'master', force = false) {
@@ -76,8 +76,8 @@ async function push (branch = 'master', force = false) {
return await doGit(push)
}
async function doGit (child) {
child.stderr.pipe(process.stderr)
async function doGit (child, opts = { quiet: false }) {
if (!opts.quiet) child.stderr.pipe(process.stderr)
return new Promise((resolve, reject) => {
child.on('close', (code) => {
if (code) {
@@ -229,5 +229,5 @@ module.exports = {
addRemote,
push,
getCommit,
getCurrentBranch,
getCurrentBranch
}

53
src/rpc-handlers/acl.js Normal file
View File

@@ -0,0 +1,53 @@
const ACL = require('../acl')
const home = require('../home')
async function getACLHandler (publicKey, req) {
const { repoName } = await parseACLRequest.bind(this)(publicKey, req)
const repoACL = ACL.getACL(repoName)
return Buffer.from(JSON.stringify(repoACL))
}
async function addACLHandler (publicKey, req) {
const { repoName, isBranch, name } = await parseACLRequest.bind(this)(publicKey, req)
isBranch ? ACL.addProtectedBranch(repoName, name) : ACL.grantAccessToUser(repoName, ...name.split(':'))
const repoACL = ACL.getACL(repoName)
return Buffer.from(JSON.stringify(repoACL))
}
async function delACLHandler (publicKey, req) {
const { repoName, isBranch, name } = await parseACLRequest.bind(this)(publicKey, req)
isBranch ? ACL.removeProtectedBranch(repoName, name) : ACL.revokeAccessFromUser(repoName, name)
const repoACL = ACL.getACL(repoName)
return Buffer.from(JSON.stringify(repoACL))
}
async function parseACLRequest (publicKey, req) {
if (!req) throw new Error('Request is empty')
const request = JSON.parse(req.toString())
const userId = await this.authenticate(publicKey, request)
const repoName = request.body.url?.split('/')?.pop()
if (!home.isInitialized(repoName)) throw new Error('Repo does not exist')
const isOwner = ACL.getOwners(repoName).includes(userId)
if (!isOwner) throw new Error('You are not allowed to access this repo')
return {
repoName,
name: request.body.name,
userId,
acl: request.body.acl,
isBranch: !!request.body.branch
}
}
module.exports = {
getACLHandler,
addACLHandler,
delACLHandler
}

122
src/rpc-handlers/git.js Normal file
View File

@@ -0,0 +1,122 @@
const ACL = require('../acl')
const home = require('../home')
const { spawn } = require('child_process')
async function getReposHandler (publicKey, req) {
const { userId } = await parseReq.bind(this)(publicKey, req)
const res = {}
for (const repoName in this.repositories) {
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))
}
async function getRefsHandler (publicKey, req) {
const { repoName, userId } = await parseReq.bind(this)(publicKey, req)
const res = this.repositories[repoName]
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 function pushHandler (publicKey, req) {
const { url, repoName, branch, userId } = await parseReq.bind(this)(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 })
return doGit(child, resolve, reject)
})
}
async function forcePushHandler (publicKey, req) {
const { url, repoName, branch, userId } = await parseReq.bind(this)(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 })
return doGit(child, resolve, reject)
})
}
async function deleteBranchHandler (publicKey, req) {
const { repoName, branch, userId } = await parseReq.bind(this)(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 })
return doGit(child, resolve, reject)
})
}
async function 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
}
function doGit (child, resolve, reject) {
let errBuffer = Buffer.from('')
let outBuffer = Buffer.from('')
child.stdout.on('data', data => {
outBuffer = Buffer.concat([outBuffer, data])
})
child.stderr.on('data', data => {
errBuffer = Buffer.concat([errBuffer, data])
})
child.on('close', code => {
console.error('errBuffer', errBuffer.toString())
console.log('outBuffer', outBuffer.toString())
return code === 0 ? resolve(outBuffer) : reject(errBuffer)
})
}
module.exports = {
getReposHandler,
getRefsHandler,
pushHandler,
forcePushHandler,
deleteBranchHandler
}

View File

@@ -0,0 +1,7 @@
const git = require('./git')
const acl = require('./acl')
module.exports = {
git,
acl
}

View File

@@ -0,0 +1,116 @@
const ProtomuxRPC = require('protomux-rpc')
const Hyperswarm = require('hyperswarm')
const crypto = require('hypercore-crypto')
const home = require('../home')
const auth = require('../auth')
const { printACL, printACLForUser, logBranches } = require('../utils')
async function list (url, name, rpc, opts) {
const payload = { body: { url, method: 'get-acl' } }
if (process.env.GIT_PEAR_AUTH && process.env.GIT_PEAR_AUTH !== 'native') {
payload.header = await auth.getToken(payload.body)
}
const repoACLres = await rpc.request('get-acl', Buffer.from(JSON.stringify(payload)))
const repoACL = JSON.parse(repoACLres.toString())
opts.branch ? listACLBranch(repoACL) : listACLUser(repoACL, name)
process.exit(0)
}
function listACLUser (repoACL, u) {
u ? printACLForUser(repoACL, u) : printACL(repoACL)
}
function listACLBranch (repoACL) {
logBranches(repoACL)
}
async function add (url, name, rpc, opts) {
const payload = { body: { url, method: 'add-acl', name } }
if (opts.branch) payload.body.branch = true
if (process.env.GIT_PEAR_AUTH && process.env.GIT_PEAR_AUTH !== 'native') {
payload.header = await auth.getToken(payload.body)
}
const repoACLres = await rpc.request('add-acl', Buffer.from(JSON.stringify(payload)))
const repoACL = JSON.parse(repoACLres.toString())
opts.branch ? listACLBranch(repoACL) : listACLUser(repoACL, name.split(':')[0])
process.exit(0)
}
async function del (url, name, rpc, opts) {
const payload = { body: { url, method: 'del-acl', name } }
if (opts.branch) payload.body.branch = true
if (process.env.GIT_PEAR_AUTH && process.env.GIT_PEAR_AUTH !== 'native') {
payload.header = await auth.getToken(payload.body)
}
const repoACLres = await rpc.request('del-acl', Buffer.from(JSON.stringify(payload)))
const repoACL = JSON.parse(repoACLres.toString())
opts.branch ? listACLBranch(repoACL) : listACLUser(repoACL, name)
process.exit(0)
}
async function wrapper (url, name, opts = {}, cb) {
if (typeof opts === 'function') {
cb = opts
opts = {}
}
const matches = url.match(/pear:\/\/([a-f0-9]{64})\/(.*)/)
if (!matches || matches.length < 3) {
console.error('Invalid URL')
process.exit(1)
}
const targetKey = matches[1]
const repoName = matches[2]
console.log('Connecting to:', targetKey)
const swarmOpts = {}
if (process.env.GIT_PEAR_AUTH === 'native') {
swarmOpts.keyPair = home.getKeyPair()
}
const swarm = new Hyperswarm(swarmOpts)
swarm.join(crypto.discoveryKey(Buffer.from(targetKey, 'hex')), { server: false })
swarm.on('connection', async (socket) => {
const rpc = new ProtomuxRPC(socket)
const payload = { body: { url, method: 'get-repos' } }
if (!process.env.GIT_PEAR_AUTH) {
console.debug('Retreiving data using un-authenticated access')
} else {
console.debug('Retreiving data using authenticated access')
}
if (process.env.GIT_PEAR_AUTH && process.env.GIT_PEAR_AUTH !== 'native') {
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')
process.exit(1)
}
if (!repositories[repoName]) {
console.error('Repository not found')
process.exit(1)
}
await cb(url, name, rpc, opts)
})
}
module.exports = {
list: (url, name, opts) => wrapper(url, name, opts, list),
add: (url, name, opts) => wrapper(url, name, opts, add),
remove: (url, name, opts) => wrapper(url, name, opts, del)
}

View File

@@ -0,0 +1,7 @@
const listRemote = require('./list-remote')
const aclRemote = require('./acl-remote')
module.exports = {
listRemote,
aclRemote
}

View File

@@ -3,8 +3,8 @@ const ProtomuxRPC = require('protomux-rpc')
const Hyperswarm = require('hyperswarm')
const crypto = require('hypercore-crypto')
const home = require('./home')
const auth = require('./auth')
const home = require('../home')
const auth = require('../auth')
module.exports = async function listRemote (url) {
const matches = url.match(/pear:\/\/([a-f0-9]{64})/)
@@ -18,7 +18,7 @@ module.exports = async function listRemote (url) {
console.log('Connecting to:', targetKey)
const swarmOpts = {}
if (process.env.GIT_PEAR_AUTH && process.env.GIT_PEAR_AUTH !== 'native') {
if (process.env.GIT_PEAR_AUTH === 'native') {
swarmOpts.keyPair = home.getKeyPair()
}
const swarm = new Hyperswarm(swarmOpts)
@@ -28,14 +28,15 @@ module.exports = async function listRemote (url) {
swarm.on('connection', async (socket) => {
const rpc = new ProtomuxRPC(socket)
let payload = { body: { url, method: 'get-repos' } }
const payload = { body: { url, method: 'get-repos' } }
if (!process.env.GIT_PEAR_AUTH) {
console.debug('Retreiving data using un-authenticated access')
} else {
console.debug('Retreiving data using authenticated access')
}
if (process.env.GIT_PEAR_AUTH && process.env.GIT_PEAR_AUTH !== 'native') {
payload.header = await auth.getToken(payload.body)
console.debug('Retreiving data using authenticated access')
} else {
console.debug('Retreiving data using un-authenticated access')
}
console.log()
const reposRes = await rpc.request('get-repos', Buffer.from(JSON.stringify(payload)))
const repositories = JSON.parse(reposRes.toString())

View File

@@ -1,9 +1,6 @@
const ProtomuxRPC = require('protomux-rpc')
const SecretStream = require('@hyperswarm/secret-stream')
const { spawn } = require('child_process')
const home = require('./home')
const auth = require('./auth')
const acl = require('./acl')
const { git, acl } = require('./rpc-handlers')
module.exports = class RPC {
constructor (announcedRefs, repositories, drives) {
@@ -15,8 +12,9 @@ module.exports = class RPC {
async setHandlers (socket, peerInfo) {
if (this.connections[peerInfo.publicKey]) return this.connections[peerInfo.publicKey]
const rpc = new ProtomuxRPC(socket)
this.connections[peerInfo.publicKey] = rpc
rpc.on('error', err => console.error('rpc error', err))
rpc.on('close', () => delete this.connections[peerInfo.publicKey])
// XXX: handshaking can be used for access and permission management
@@ -24,131 +22,22 @@ module.exports = class RPC {
// which can in turn be stored in a .git-daemon-export-ok file
/* -- PULL HANDLERS -- */
rpc.respond('get-repos', async req => await this.getReposHandler(socket.remotePublicKey, req))
rpc.respond('get-refs', async req => await this.getRefsHandler(socket.remotePublicKey, req))
rpc.respond('get-repos', async req => await git.getReposHandler.bind(this)(socket.remotePublicKey, req))
rpc.respond('get-refs', async req => await git.getRefsHandler.bind(this)(socket.remotePublicKey, req))
if (process.env.GIT_PEAR_AUTH) {
/* -- PUSH HANDLERS -- */
rpc.respond('push', async req => await this.pushHandler(socket.remotePublicKey, req))
rpc.respond('f-push', async req => await this.forcePushHandler(socket.remotePublicKey, req))
rpc.respond('d-branch', async req => await this.deleteBranchHandler(socket.remotePublicKey, req))
}
if (!process.env.GIT_PEAR_AUTH) return
this.connections[peerInfo.publicKey] = rpc
}
/* -- PUSH HANDLERS -- */
rpc.respond('push', async req => await git.pushHandler.bind(this)(socket.remotePublicKey, req))
rpc.respond('f-push', async req => await git.forcePushHandler.bind(this)(socket.remotePublicKey, req))
rpc.respond('d-branch', async req => await git.deleteBranchHandler.bind(this)(socket.remotePublicKey, req))
async getReposHandler (publicKey, req) {
const { branch, url, userId } = await this.parseReq(publicKey, req)
/* -- REPO ADMINISTRATION HANDLERS -- */
const res = {}
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))
}
async getRefsHandler (publicKey, req) {
const { repoName, branch, url, userId } = await this.parseReq(publicKey, req)
const res = this.repositories[repoName]
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
/* -- ACL HANDLERS -- */
rpc.respond('get-acl', async req => await acl.getACLHandler.bind(this)(socket.remotePublicKey, req))
rpc.respond('add-acl', async req => await acl.addACLHandler.bind(this)(socket.remotePublicKey, req))
rpc.respond('del-acl', async req => await acl.delACLHandler.bind(this)(socket.remotePublicKey, req))
}
async authenticate (publicKey, request) {

36
src/utils.js Normal file
View File

@@ -0,0 +1,36 @@
const fs = require('fs')
const path = require('path')
function printACL (repoACL) {
console.log('Repo Visibility:', '\t', repoACL.visibility)
console.log('Protected Branch(s):', '\t', repoACL.protectedBranches.join(', '))
console.log('User:', '\t', 'Role:')
for (const user in repoACL.ACL) {
console.log(user, '\t', repoACL.ACL[user])
}
}
function printACLForUser (repoACL, u) {
console.log('Repo Visibility:', '\t', repoACL.visibility)
console.log('Protected Branch(s):', '\t', repoACL.protectedBranches.join(', '))
console.log('User:', u, '\t', repoACL.ACL[u])
}
function checkIfGitRepo (p) {
if (!fs.existsSync(path.join(p, '.git'))) {
console.error(` ${p} is not a git repo`)
process.exit(1)
}
}
function logBranches (repoACL) {
console.log('Repo Visibility:', '\t', repoACL.visibility)
console.log('Protected Branch(s):', '\t', repoACL.protectedBranches.join(', '))
}
module.exports = {
printACL,
printACLForUser,
checkIfGitRepo,
logBranches
}

View File

@@ -7,8 +7,8 @@ test('acl', async t => {
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(aclObj.protectedBranches.length, 2)
t.ok(aclObj.protectedBranches.includes('master') && aclObj.protectedBranches.includes('main'))
t.is(Object.keys(aclObj.ACL).length, 0)
})
@@ -34,12 +34,12 @@ test('acl', async t => {
test('addProtectedBranch', async t => {
acl.addProtectedBranch(repoName, 'branch1')
t.is(acl.getACL(repoName).protectedBranches.length, 2)
t.is(acl.getACL(repoName).protectedBranches.length, 3)
})
test('removeProtectedBranch', async t => {
acl.removeProtectedBranch(repoName, 'branch1')
t.is(acl.getACL(repoName).protectedBranches.length, 1)
t.is(acl.getACL(repoName).protectedBranches.length, 2)
})
test('getAdmins', async t => {

View File

@@ -51,13 +51,13 @@ test('git - uploadPack (w have)', { skip: true }, async t => {
})
test('git - createBareRepo', async t => {
t.absent(fs.existsSync(path.join(home.APP_HOME, 'test-git', 'code')))
t.absent(fs.existsSync(path.join(home.APP_HOME, 'test-git')))
home.createAppFolder('test-git')
t.absent(fs.existsSync(path.join(home.APP_HOME, 'test-git', 'code', 'HEAD')))
t.absent(fs.existsSync(path.join(home.APP_HOME, 'test-git', 'HEAD')))
await git.createBareRepo('test-git')
t.ok(fs.existsSync(path.join(home.APP_HOME, 'test-git', 'code', 'HEAD')))
t.ok(fs.existsSync(path.join(home.APP_HOME, 'test-git', 'HEAD')))
t.teardown(() => {
fs.rmSync(path.join(home.APP_HOME, 'test-git'), { recursive: true })

View File

@@ -11,7 +11,7 @@ test('getAppHome', t => {
test('createAppFolder, share, is shared, unshare, isInitialized, list, getCodePath', t => {
home.createAppFolder('test_code')
t.ok(fs.existsSync(path.join(home.APP_HOME, 'test_code', 'code')))
t.ok(fs.existsSync(path.join(home.APP_HOME, 'test_code')))
t.absent(home.isShared('test_code'))
t.absent(fs.existsSync(path.join(home.APP_HOME, 'test_code', '.git-daemon-export-ok')))
@@ -32,7 +32,7 @@ test('createAppFolder, share, is shared, unshare, isInitialized, list, getCodePa
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_code')), path.resolve(path.join(home.APP_HOME, 'test_code', 'code')))
t.alike(path.resolve(home.getCodePath('test_code')), path.resolve(path.join(home.APP_HOME, 'test_code')))
t.teardown(() => {
fs.rmSync(path.join(home.APP_HOME, 'test_code'), { recursive: true })

View File

@@ -56,7 +56,7 @@ test('e2e', async t => {
await drive.core.update({ wait: true })
payload = Buffer.from(JSON.stringify({ body: { url, method: 'get-refs', data: repoName }}))
payload = Buffer.from(JSON.stringify({ body: { url, method: 'get-refs', data: repoName } }))
const refsRes = await rpc.request('get-refs', payload)
t.ok(refsRes)

Binary file not shown.