diff --git a/example/index.js b/example/index.js index af17a48..8bd3f1d 100644 --- a/example/index.js +++ b/example/index.js @@ -24,20 +24,19 @@ const port = process.env.PORT || 7005; const repos = new Server(path.normalize(path.resolve(__dirname, 'tmp')), { autoCreate: true, - authenticate: ({ type, repo, user, headers }, next) => { + authenticate: async ({ type, repo, getUser, headers }) => { console.log(type, repo, headers); // eslint-disable-line if (type == 'push') { + const [username, password] = await getUser(); // Decide if this user is allowed to perform this action against this repo. - user((username, password) => { - if (username === '42' && password === '42') { - next(); - } else { - next('wrong password'); - } - }); - } else { - // Check these credentials are correct for this user. - next(); + if (username === '42' && password === '42') { + // This return value can be whatever you want - it is accessible from events. + return { + protectedBranches: ["docs", "main"], + }; + } else { + throw Error('wrong password'); + } } }, }); @@ -55,7 +54,12 @@ repos.on('push', (push) => { push.log(' '); }); - push.accept(); + if (push.context.protectedBranches.indexOf(push.branch) !== -1) { + push.log('You do not have permission to write to this branch'); + push.reject(); + } else { + push.accept(); + } }); repos.on('fetch', (fetch) => { diff --git a/src/git.test.ts b/src/git.test.ts index c22b23b..1044bfd 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -3,7 +3,7 @@ import path from 'path'; import { spawn, exec, SpawnOptionsWithoutStdio } from 'child_process'; import http from 'http'; -import { Git } from './git'; +import { Git, GitAuthenticateOptions } from './git'; jest.setTimeout(15000); @@ -15,7 +15,7 @@ const wrapCallback = (func: { (callback: any): void }) => { describe('git', () => { test('create, push to, and clone a repo', async () => { - expect.assertions(11); + expect.assertions(12); let lastCommit: string; @@ -29,8 +29,11 @@ describe('git', () => { fs.mkdirSync(srcDir, '0700'); fs.mkdirSync(dstDir, '0700'); - const repos = new Git(repoDir, { + const repos = new Git(repoDir, { autoCreate: true, + authenticate: (options: GitAuthenticateOptions) => { + return `my request context for repo: ${options.repo}`; + }, }); const port = Math.floor(Math.random() * ((1 << 16) - 1e4)) + 1e4; const server = http @@ -42,6 +45,8 @@ describe('git', () => { process.chdir(srcDir); repos.on('push', (push) => { + expect(push.context).toBe('my request context for repo: xyz/doom'); + expect(push.repo).toBe('xyz/doom'); expect(push.commit).toBe(lastCommit); expect(push.branch).toBe('master'); @@ -655,17 +660,16 @@ describe('git', () => { const repos = new Git(repoDir, { autoCreate: true, - authenticate: ({ type, repo, user }, next) => { + authenticate: async ({ type, repo, getUser }) => { if (type === 'fetch' && repo === 'doom') { - user((username, password) => { - if (username == 'root' && password == 'root') { - next(); - } else { - next(new Error('that is not the correct password')); - } - }); + const [username, password] = await getUser(); + if (username == 'root' && password == 'root') { + return; + } else { + throw new Error('that is not the correct password'); + } } else { - next(new Error('that is not the correct password')); + throw new Error('that is not the correct password'); } }, }); @@ -721,9 +725,13 @@ describe('git', () => { fs.mkdirSync(srcDir, '0700'); fs.mkdirSync(dstDir, '0700'); - const repos = new Git(repoDir, { + interface Context { + username: string; + } + + const repos = new Git(repoDir, { autoCreate: true, - authenticate: ({ type, repo, user, headers }, next) => { + authenticate: async ({ type, repo, getUser, headers }) => { if (type === 'fetch' && repo === 'doom') { expect(headers['host']).toBeTruthy(); expect(headers['user-agent']).toBeTruthy(); @@ -731,15 +739,16 @@ describe('git', () => { expect(headers['pragma']).toBeTruthy(); expect(headers['accept-encoding']).toBeTruthy(); - user((username, password) => { - if (username == 'root' && password == 'root') { - next(); - } else { - next(new Error('that is not the correct password')); - } - }); + const [username, password] = await getUser(); + if (username == 'root' && password == 'root') { + return { + username: username, + }; + } else { + throw new Error('that is not the correct password'); + } } else { - next(new Error('that is not the correct password')); + throw new Error('that is not the correct password'); } }, }); @@ -795,20 +804,14 @@ describe('git', () => { const repos = new Git(repoDir, { autoCreate: true, - authenticate: ({ type, repo, user }) => { - return new Promise(function (resolve, reject) { - if (type === 'fetch' && repo === 'doom') { - user((username, password) => { - if (username == 'root' && password == 'root') { - return resolve(void 0); - } else { - return reject('that is not the correct password'); - } - }); - } else { - return reject('that is not the correct password'); + authenticate: async ({ type, repo, getUser }) => { + if (type === 'fetch' && repo === 'doom') { + const [username, password] = await getUser(); + if (username == 'root' && password == 'root') { + return; } - }); + } + throw new Error('that is not the correct password'); }, }); const port = Math.floor(Math.random() * ((1 << 16) - 1e4)) + 1e4; diff --git a/src/git.ts b/src/git.ts index e9763ac..739b934 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,56 +1,44 @@ import fs from 'fs'; - import path from 'path'; import http, { ServerOptions } from 'http'; import https from 'https'; -import url from 'url'; -import qs from 'querystring'; -import { HttpDuplex } from './http-duplex'; import { spawn } from 'child_process'; import { EventEmitter } from 'events'; +import { HttpDuplex } from './http-duplex'; +import { parseRequest, HttpError, ParsedGitRequest } from './protocol'; +import { ServiceString } from './types'; import { parseGitName, createAction, - infoResponse, + packSideband, basicAuth, + BasicAuthError, noCache, } from './util'; -import { ServiceString } from './types'; - -const services = ['upload-pack', 'receive-pack']; interface GitServerOptions extends ServerOptions { type: 'http' | 'https'; } -export interface GitOptions { +export interface GitOptions { autoCreate?: boolean; - authenticate?: ( - options: GitAuthenticateOptions, - callback: (error?: Error) => void | undefined - ) => void | Promise | undefined; + authenticate?: (options: GitAuthenticateOptions) => Promise | T; checkout?: boolean; } export interface GitAuthenticateOptions { - type: string; + type: 'fetch' | 'push' | 'info'; repo: string; - user: (() => Promise<[string | undefined, string | undefined]>) & - (( - callback: ( - username?: string | undefined, - password?: string | undefined - ) => void - ) => void); + getUser: () => Promise<[string | undefined, string | undefined]>; headers: http.IncomingHttpHeaders; } /** * An http duplex object (see below) with these extra properties: */ -export interface TagData extends HttpDuplex { +export interface TagData extends HttpDuplex { repo: string; // The string that defines the repo commit: string; // The string that defines the commit sha version: string; // The string that defines the tag being pushed @@ -59,7 +47,7 @@ export interface TagData extends HttpDuplex { /** * Is a http duplex object (see below) with these extra properties */ -export interface PushData extends HttpDuplex { +export interface PushData extends HttpDuplex { repo: string; // The string that defines the repo commit: string; // The string that defines the commit sha branch: string; // The string that defines the branch @@ -68,7 +56,7 @@ export interface PushData extends HttpDuplex { /** * an http duplex object (see below) with these extra properties */ -export interface FetchData extends HttpDuplex { +export interface FetchData extends HttpDuplex { repo: string; // The string that defines the repo commit: string; // The string that defines the commit sha } @@ -76,18 +64,18 @@ export interface FetchData extends HttpDuplex { /** * an http duplex object (see below) with these extra properties */ -export interface InfoData extends HttpDuplex { +export interface InfoData extends HttpDuplex { repo: string; // The string that defines the repo } /** * an http duplex object (see below) with these extra properties */ -export interface HeadData extends HttpDuplex { +export interface HeadData extends HttpDuplex { repo: string; // The string that defines the repo } -export interface GitEvents { +export interface GitEvents { /** * @example * repos.on('push', function (push) { ... } @@ -97,7 +85,7 @@ export interface GitEvents { * Exactly one listener must call `push.accept()` or `push.reject()`. If there are * no listeners, `push.accept()` is called automatically. **/ - on(event: 'push', listener: (push: PushData) => void): this; + on(event: 'push', listener: (push: PushData) => void): this; /** * @example @@ -107,7 +95,7 @@ export interface GitEvents { * Exactly one listener must call `tag.accept()` or `tag.reject()`. If there are * No listeners, `tag.accept()` is called automatically. **/ - on(event: 'tag', listener: (tag: TagData) => void): this; + on(event: 'tag', listener: (tag: TagData) => void): this; /** * @example @@ -119,7 +107,7 @@ export interface GitEvents { * Exactly one listener must call `fetch.accept()` or `fetch.reject()`. If there are * no listeners, `fetch.accept()` is called automatically. **/ - on(event: 'fetch', listener: (fetch: FetchData) => void): this; + on(event: 'fetch', listener: (fetch: FetchData) => void): this; /** * @example @@ -130,7 +118,7 @@ export interface GitEvents { * Exactly one listener must call `info.accept()` or `info.reject()`. If there are * no listeners, `info.accept()` is called automatically. **/ - on(event: 'info', listener: (info: InfoData) => void): this; + on(event: 'info', listener: (info: InfoData) => void): this; /** * @example @@ -142,17 +130,14 @@ export interface GitEvents { * no listeners, `head.accept()` is called automatically. * **/ - on(event: 'head', listener: (head: HeadData) => void): this; + on(event: 'head', listener: (head: HeadData) => void): this; } -export class Git extends EventEmitter implements GitEvents { +export class Git extends EventEmitter implements GitEvents { dirMap: (dir?: string) => string; authenticate: - | (( - options: GitAuthenticateOptions, - callback: (error?: Error) => void | undefined - ) => void | Promise | undefined) - | undefined; + | ((options: GitAuthenticateOptions) => Promise | T) + | undefined = undefined; autoCreate: boolean; checkout: boolean | undefined; @@ -165,27 +150,25 @@ export class Git extends EventEmitter implements GitEvents { * @param options - options that can be applied on the new instance being created * @param options.autoCreate - By default, repository targets will be created if they don't exist. You can disable that behavior with `options.autoCreate = true` - * @param options.authenticate - a function that has the following arguments ({ type, repo, username, password, headers }, next) and will be called when a request comes through if set + * @param options.authenticate - an optionally async function that has the following arguments ({ type, repo, getUser, headers }) and will be called when a request comes through, if set. * - authenticate: ({ type, repo, username, password, headers }, next) => { - console.log(type, repo, username, password); - next(); + authenticate: async ({ type, repo, getUser, headers }) => { + const [username, password] = await getUser(); + // Check user credentials + if (password !== 's3cure!') throw new Error("Wrong password!"); + // Return a context value which can be used to authorize requests in the more specific event handlers (such as 'push') + // The value you return here will eb accessible + if (username === 'admin') { + return { protectedBranches: [] }; + } else { + return { protectedBranches: ["main", "hotfix/*"] }; + } } - // alternatively you can also pass authenticate a promise - authenticate: ({ type, repo, username, password, headers }, next) => { - console.log(type, repo, username, password); - return new Promise((resolve, reject) => { - if(username === 'foo') { - return resolve(); - } - return reject("sorry you don't have access to this content"); - }); - } - * @param options.checkout - If `opts.checkout` is true, create and expected checked-out repos instead of bare repos + * @param options.checkout - If `opts.checkout` is true, create and expect checked-out repos instead of bare repos */ constructor( repoDir: string | ((dir?: string) => string), - options: GitOptions = {} + options: GitOptions = {} ) { super(); @@ -199,9 +182,7 @@ export class Git extends EventEmitter implements GitEvents { }; } - if (options.authenticate) { - this.authenticate = options.authenticate; - } + this.authenticate = options.authenticate; this.autoCreate = options.autoCreate === false ? false : true; this.checkout = options.checkout; @@ -253,13 +234,13 @@ export class Git extends EventEmitter implements GitEvents { * @param callback - Optionally get a callback `cb(err)` to be notified when the repository was created. */ create(repo: string, callback: (error?: Error) => void) { - function next(self: Git) { + const next = () => { let ps; let _error = ''; - const dir = self.dirMap(repo); + const dir = this.dirMap(repo); - if (self.checkout) { + if (this.checkout) { ps = spawn('git', ['init', dir]); } else { ps = spawn('git', ['init', '--bare', dir]); @@ -278,7 +259,7 @@ export class Git extends EventEmitter implements GitEvents { callback(); } }); - } + }; if (typeof callback !== 'function') callback = () => { @@ -293,218 +274,269 @@ export class Git extends EventEmitter implements GitEvents { this.mkdir(repo); } - next(this); + next(); } /** - * returns the typeof service being process. This will respond with either fetch or push. + * returns the type of service being processed. * @param service - the service type */ - getType(service: string): string { + getType(service: string | null): 'fetch' | 'push' | 'info' { switch (service) { case 'upload-pack': return 'fetch'; case 'receive-pack': return 'push'; default: - return 'unknown'; + return 'info'; } } - /** - * Handle incoming HTTP requests with a connect-style middleware - * @param http request object - * @param http response object - */ - handle(req: http.IncomingMessage, res: http.ServerResponse) { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; - - const handlers = [ - (req: http.IncomingMessage, res: http.ServerResponse) => { - if (req.method !== 'GET') return false; - - // eslint-disable-next-line @typescript-eslint/no-this-alias - const u = url.parse(req?.url || ''); - const m = u.pathname?.match(/\/(.+)\/info\/refs$/); - if (!m) return false; - if (/\.\./.test(m[1])) return false; - - const repo = m[1]; - const params = qs.parse(u?.query || ''); - if (!params.service || typeof params.service !== 'string') { - res.statusCode = 400; - res.end('service parameter required'); - return; - } + private _infoServiceResponse( + service: ServiceString, + repoLocation: string, + res: http.ServerResponse + ) { + res.write(packSideband('# service=git-' + service + '\n')); + res.write('0000'); - const service = params.service.replace(/^git-/, ''); + const isWin = /^win/.test(process.platform); - if (services.indexOf(service) < 0) { - res.statusCode = 405; - res.end('service not available'); - return; - } + const cmd = isWin + ? ['git', service, '--stateless-rpc', '--advertise-refs', repoLocation] + : ['git-' + service, '--stateless-rpc', '--advertise-refs', repoLocation]; - const repoName = parseGitName(m[1]); - const next = (error?: Error | void) => { - if (error) { - res.setHeader('Content-Type', 'text/plain'); - res.setHeader( - 'WWW-Authenticate', - 'Basic realm="authorization needed"' - ); - res.writeHead(401); - res.end(typeof error === 'string' ? error : error.toString()); - return; - } else { - return infoResponse(this, repo, service as ServiceString, req, res); - } - }; - - // check if the repo is authenticated - if (this.authenticate) { - const type = this.getType(service); - const headers = req.headers; - const user = ( - callback?: (username?: string, password?: string) => void - ) => - callback - ? basicAuth(req, res, callback) - : new Promise<[string | undefined, string | undefined]>( - (resolve) => basicAuth(req, res, (u, p) => resolve([u, p])) - ); - - const promise = this.authenticate( - { - type, - repo: repoName, - user: user as unknown as GitAuthenticateOptions['user'], - headers, - }, - (error?: Error) => { - return next(error); - } - ); - - if (promise instanceof Promise) { - return promise.then(next).catch(next); - } - } else { - return next(); - } - }, - (req: http.IncomingMessage, res: http.ServerResponse) => { - if (req.method !== 'GET') return false; - - const u = url.parse(req.url || ''); - const m = u.pathname?.match(/^\/(.+)\/HEAD$/); - if (!m) return false; - if (/\.\./.test(m[1])) return false; - - const repo = m[1]; - - const next = () => { - const file = this.dirMap(path.join(m[1], 'HEAD')); - const exists = this.exists(file); - - if (exists) { - fs.createReadStream(file).pipe(res); - } else { - res.statusCode = 404; - res.end('not found'); - } - }; - - const exists = this.exists(repo); - const anyListeners = self.listeners('head').length > 0; - const dup = new HttpDuplex(req, res); - dup.exists = exists; - dup.repo = repo; - dup.cwd = this.dirMap(repo); - - dup.accept = dup.emit.bind(dup, 'accept'); - dup.reject = dup.emit.bind(dup, 'reject'); - - dup.once('reject', (code: number) => { - dup.statusCode = code || 500; - dup.end(); - }); + const ps = spawn(cmd[0], cmd.slice(1)); - if (!exists && self.autoCreate) { - dup.once('accept', (dir: string) => { - self.create(dir || repo, next); - }); - self.emit('head', dup); - if (!anyListeners) dup.accept(); - } else if (!exists) { - res.statusCode = 404; - res.setHeader('content-type', 'text/plain'); - res.end('repository not found'); - } else { - dup.once('accept', next); - self.emit('head', dup); - if (!anyListeners) dup.accept(); - } - }, - (req: http.IncomingMessage, res: http.ServerResponse) => { - if (req.method !== 'POST') return false; - const m = req.url?.match(/\/(.+)\/git-(.+)/); - if (!m) return false; - if (/\.\./.test(m[1])) return false; - - const repo = m[1], - service = m[2]; - - if (services.indexOf(service) < 0) { - res.statusCode = 405; - res.end('service not available'); - return; - } + ps.on('error', (err) => { + this.emit( + 'error', + new Error(`${err.message} running command ${cmd.join(' ')}`) + ); + }); + ps.stdout.pipe(res); + } - res.setHeader( - 'content-type', - 'application/x-git-' + service + '-result' - ); - noCache(res); - - const action = createAction( - { - repo: repo, - service: service as ServiceString, - cwd: self.dirMap(repo), - }, - req, - res - ); + private _infoResponse( + repo: string, + service: ServiceString, + req: http.IncomingMessage, + res: http.ServerResponse, + context: T | undefined + ) { + const next = () => { + res.setHeader( + 'content-type', + 'application/x-git-' + service + '-advertisement' + ); + noCache(res); + this._infoServiceResponse(service, this.dirMap(repo), res); + }; - action.on('header', () => { - const evName = action.evName; - if (evName) { - const anyListeners = self.listeners(evName).length > 0; - self.emit(evName, action); - if (!anyListeners) action.accept(); - } - }); - }, - (req: http.IncomingMessage, res: http.ServerResponse) => { - if (req.method !== 'GET' && req.method !== 'POST') { - res.statusCode = 405; - res.end('method not supported'); - } else { - return false; - } - }, - (req: http.IncomingMessage, res: http.ServerResponse) => { + const dup = new HttpDuplex(req, res, context); + dup.cwd = this.dirMap(repo); + dup.repo = repo; + + dup.accept = dup.emit.bind(dup, 'accept'); + dup.reject = dup.emit.bind(dup, 'reject'); + + dup.once('reject', (code: number) => { + res.statusCode = code || 500; + res.end(); + }); + + const anyListeners = this.listeners('info').length > 0; + + const exists = this.exists(repo); + dup.exists = exists; + + if (!exists && this.autoCreate) { + dup.once('accept', () => { + this.create(repo, next); + }); + + this.emit('info', dup); + if (!anyListeners) dup.accept(); + } else if (!exists) { + res.statusCode = 404; + res.setHeader('content-type', 'text/plain'); + res.end('repository not found'); + } else { + dup.once('accept', next); + this.emit('info', dup); + + if (!anyListeners) dup.accept(); + } + } + + private _headResponse( + repo: string, + req: http.IncomingMessage, + res: http.ServerResponse, + context: T | undefined + ) { + const next = () => { + const file = this.dirMap(path.join(repo, 'HEAD')); + const exists = this.exists(file); + + if (exists) { + fs.createReadStream(file).pipe(res); + } else { res.statusCode = 404; res.end('not found'); + } + }; + + const exists = this.exists(repo); + const anyListeners = this.listeners('head').length > 0; + const dup = new HttpDuplex(req, res, context); + dup.exists = exists; + dup.repo = repo; + dup.cwd = this.dirMap(repo); + + dup.accept = dup.emit.bind(dup, 'accept'); + dup.reject = dup.emit.bind(dup, 'reject'); + + dup.once('reject', (code: number) => { + dup.statusCode = code || 500; + dup.end(); + }); + + if (!exists && this.autoCreate) { + dup.once('accept', (dir: string) => { + this.create(dir || repo, next); + }); + this.emit('head', dup); + if (!anyListeners) dup.accept(); + } else if (!exists) { + res.statusCode = 404; + res.setHeader('content-type', 'text/plain'); + res.end('repository not found'); + } else { + dup.once('accept', next); + this.emit('head', dup); + if (!anyListeners) dup.accept(); + } + } + + private _serviceResponse( + repo: string, + service: ServiceString, + req: http.IncomingMessage, + res: http.ServerResponse, + context: T | undefined + ) { + res.setHeader('content-type', 'application/x-git-' + service + '-result'); + noCache(res); + + const action = createAction( + { + repo: repo, + service: service, + cwd: this.dirMap(repo), }, - ]; + req, + res, + context + ); + + action.on('header', () => { + const evName = action.evName; + if (evName) { + const anyListeners = this.listeners(evName).length > 0; + this.emit(evName, action); + if (!anyListeners) action.accept(); + } + }); + } + + private async _authenticateAndRespond( + info: ParsedGitRequest, + req: http.IncomingMessage, + res: http.ServerResponse + ) { + const repoName = parseGitName(info.repo); + + let context: T | undefined = undefined; + + let next: () => void; + if (info.route === 'info') { + next = () => { + this._infoResponse(info.repo, info.service, req, res, context); + }; + } else if (info.route === 'head') { + next = () => { + this._headResponse(info.repo, req, res, context); + }; + } else { + next = () => { + this._serviceResponse(info.repo, info.service, req, res, context); + }; + } + + const afterAuthenticate = (error?: Error | string | void) => { + if (error instanceof BasicAuthError) { + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('WWW-Authenticate', 'Basic realm="authorization needed"'); + res.writeHead(401); + res.end('401 Unauthorized'); + } else if (error) { + res.setHeader('Content-Type', 'text/plain'); + res.writeHead(403); + res.end(typeof error === 'string' ? error : error.toString()); + } else { + next(); + } + }; + + // check if the repo is authenticated + if (this.authenticate) { + const type = this.getType(info.service); + const headers = req.headers; + const getUser = async () => { + return basicAuth(req); + }; + + try { + context = await this.authenticate({ + type, + repo: repoName, + getUser, + headers, + }); + afterAuthenticate(); + } catch (e: any) { + afterAuthenticate(e); + } + } else { + next(); + } + } + + /** + * Handle incoming HTTP requests with a connect-style middleware + * @param http request object + * @param http response object + */ + handle(req: http.IncomingMessage, res: http.ServerResponse) { res.setHeader('connection', 'close'); - (function next(ix) { - const x = handlers[ix].call(self, req, res); - if (x === false) next(ix + 1); - })(0); + const handleError = (e: any) => { + if (e instanceof HttpError) { + res.statusCode = e.statusCode; + res.end(e.statusText); + } else { + res.statusCode = 500; + console.error(e); + res.end('internal server error'); + } + }; + + try { + const info = parseRequest(req); + this._authenticateAndRespond(info, req, res).catch(handleError); + } catch (e) { + handleError(e); + } } /** * starts a git server on the given port @@ -515,7 +547,11 @@ export class Git extends EventEmitter implements GitEvents { * @param options.cert - the cert file for the https server * @param callback - the function to call when server is started or error has occurred */ - listen(port: number, options?: GitServerOptions, callback?: () => void): Git { + listen( + port: number, + options?: GitServerOptions, + callback?: () => void + ): this { if (!options) { options = { type: 'http' }; } diff --git a/src/http-duplex.ts b/src/http-duplex.ts index 8b4773f..c717cab 100644 --- a/src/http-duplex.ts +++ b/src/http-duplex.ts @@ -1,7 +1,7 @@ import http from 'http'; import EventEmitter from 'events'; -export class HttpDuplex extends EventEmitter { +export class HttpDuplex extends EventEmitter { setHeader(arg0: string, arg1: string) { throw new Error('Method not implemented.'); } @@ -54,7 +54,11 @@ export class HttpDuplex extends EventEmitter { }).listen(80); ``` */ - constructor(input: http.IncomingMessage, output: http.ServerResponse) { + constructor( + input: http.IncomingMessage, + output: http.ServerResponse, + public context?: T | undefined + ) { super(); this.req = input; diff --git a/src/protocol.ts b/src/protocol.ts new file mode 100644 index 0000000..79849aa --- /dev/null +++ b/src/protocol.ts @@ -0,0 +1,132 @@ +import url from 'url'; +import qs from 'querystring'; +import http from 'http'; +import { ServiceString } from './types'; + +const services: ServiceString[] = ['upload-pack', 'receive-pack']; + +export class HttpError extends Error { + constructor(public statusCode: number, public statusText: string) { + super(statusText); + } +} + +interface ParsedServiceGitRequest { + repo: string; + route: 'info' | 'service'; + service: ServiceString; +} + +interface ParsedHeadGitRequest { + repo: string; + route: 'head'; + service: null; +} + +export type ParsedGitRequest = ParsedServiceGitRequest | ParsedHeadGitRequest; + +export function parseRequest(req: http.IncomingMessage): ParsedGitRequest { + const info = parseInfoRequest(req); + if (info) + return { + repo: info.repo, + route: 'info', + service: info.service, + }; + + const head = parseHeadRequest(req); + if (head) + return { + repo: head.repo, + route: 'head', + service: null, + }; + + const service = parseServiceRequest(req); + if (service) + return { + repo: service.repo, + route: 'service', + service: service.service, + }; + + if (req.method !== 'GET' && req.method !== 'POST') { + throw new HttpError(405, 'method not supported'); + } + + throw new HttpError(404, 'not found'); +} + +interface ParsedRequest { + repo: string; +} + +interface ParsedServiceRequest extends ParsedRequest { + service: ServiceString; +} + +function parseInfoRequest( + req: http.IncomingMessage +): ParsedServiceRequest | undefined { + if (req.method !== 'GET') return undefined; + + const u = url.parse(req.url || ''); + const m = u.pathname?.match(/\/(.+)\/info\/refs$/); + if (!m) return undefined; + + const repo = validateRepoPath(m[1]); + + const params = qs.parse(u?.query || ''); + if (!params.service || typeof params.service !== 'string') { + throw new HttpError(400, 'service parameter required'); + } + + const service = validateServiceName(params.service.replace(/^git-/, '')); + + return { + repo, + service, + }; +} + +function parseServiceRequest( + req: http.IncomingMessage +): ParsedServiceRequest | undefined { + if (req.method !== 'POST') return undefined; + const m = req.url?.match(/\/(.+)\/git-(.+)/); + if (!m) return undefined; + + const repo = validateRepoPath(m[1]); + const service = validateServiceName(m[2]); + + return { + repo, + service, + }; +} + +function parseHeadRequest( + req: http.IncomingMessage +): ParsedRequest | undefined { + if (req.method !== 'GET') return undefined; + + const u = url.parse(req.url || ''); + const m = u.pathname?.match(/^\/(.+)\/HEAD$/); + if (!m) return undefined; + const repo = validateRepoPath(m[1]); + + return { + repo, + }; +} + +function validateRepoPath(repo: string) { + if (/\.\./.test(repo)) throw new HttpError(404, 'not found'); + return repo; +} + +function validateServiceName(service: string): ServiceString { + if (services.indexOf(service as ServiceString) === -1) + throw new HttpError(405, 'service not available'); + return service as ServiceString; +} diff --git a/src/service.ts b/src/service.ts index 64f3e9d..f9f7d0d 100644 --- a/src/service.ts +++ b/src/service.ts @@ -24,7 +24,7 @@ export interface ServiceOptions { service: ServiceString; } -export class Service extends HttpDuplex { +export class Service extends HttpDuplex { status: string; repo: string; service: string; @@ -44,9 +44,10 @@ export class Service extends HttpDuplex { constructor( opts: ServiceOptions, req: http.IncomingMessage, - res: http.ServerResponse + res: http.ServerResponse, + context: T | undefined ) { - super(req, res); + super(req, res, context); let data = ''; @@ -164,10 +165,7 @@ export class Service extends HttpDuplex { } ); - (respStream as any).log = () => { - // eslint-disable-next-line prefer-rest-params - (this as any).log(...arguments); - }; + (respStream as any).log = this.log.bind(this); this.emit('response', respStream, function endResponse() { (res as any).queue(Buffer.from('0000')); diff --git a/src/util.test.ts b/src/util.test.ts index 2f5c6df..e5534ab 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -1,56 +1,23 @@ -import { basicAuth, noCache, parseGitName } from './util'; +import { basicAuth, BasicAuthError, noCache, parseGitName } from './util'; describe('util', () => { describe('basicAuth', () => { - test('should send back basic auth headers', (done) => { - const headers: any = {}; - + test('should throw error if headers invalid or missing', () => { const req: any = { headers: {}, }; - const res: any = { - writeHead: function (_code: number) { - code = _code; - }, - setHeader: function (key: string | number, value: any) { - headers[key] = value; - }, - end: function (_status: number) { - status = _status; - expect(code).toBe(401); - expect(headers).toEqual({ - 'Content-Type': 'text/plain', - 'WWW-Authenticate': 'Basic realm="authorization needed"', - }); - expect(status).toBe('401 Unauthorized'); - done(); - }, - }; - - let code = 0; - let status = 0; - - basicAuth(req, res, () => { - expect('').not.toEqual('should not have entered this callback'); - done(); - }); + expect(basicAuth.bind(null, req)).toThrow(new BasicAuthError()); }); - test('should accept headers and call callback', (done) => { + test('should accept headers and return user & password tuple', () => { const req: any = { headers: { authorization: 'Basic T3BlbjpTZXNhbWU=', }, }; - const res: any = {}; - - basicAuth(req, res, (username, password) => { - expect(username).toBe('Open'); - expect(password).toBe('Sesame'); - done(); - }); + expect(basicAuth(req)).toStrictEqual(['Open', 'Sesame']); }); }); diff --git a/src/util.ts b/src/util.ts index 95c8a78..63d6b29 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,10 +1,6 @@ import http from 'http'; -import { spawn } from 'child_process'; -import { Git } from './git'; -import { HttpDuplex } from './http-duplex'; import { Service, ServiceOptions } from './service'; -import { ServiceString } from './types'; export function packSideband(s: string): string { const n = (4 + s.length).toString(16); @@ -21,6 +17,8 @@ export function noCache(res: http.ServerResponse) { res.setHeader('cache-control', 'no-cache, max-age=0, must-revalidate'); } +export class BasicAuthError extends Error {} + /** * sets and parses basic auth headers if they exist * @param req - http request object @@ -28,16 +26,9 @@ export function noCache(res: http.ServerResponse) { * @param callback - function(username, password) */ export function basicAuth( - req: http.IncomingMessage, - res: http.ServerResponse, - callback: (username?: string, password?: string) => void -) { - if (!req.headers['authorization']) { - res.setHeader('Content-Type', 'text/plain'); - res.setHeader('WWW-Authenticate', 'Basic realm="authorization needed"'); - res.writeHead(401); - res.end('401 Unauthorized'); - } else { + req: http.IncomingMessage +): [string | undefined, string | undefined] { + if (req.headers['authorization']) { const tokens = req.headers['authorization'].split(' '); if (tokens[0] === 'Basic') { const splitHash = Buffer.from(tokens[1], 'base64') @@ -45,102 +36,12 @@ export function basicAuth( .split(':'); const username = splitHash.shift(); const password = splitHash.join(':'); - - callback(username, password); + return [username, password]; } } + throw new BasicAuthError(); } -/** - * execute given git operation and respond - * @param dup - duplex object to catch errors - * @param service - the method that is responding infoResponse (push, pull, clone) - * @param repoLocation - the repo path on disk - * @param res - http response - */ -export function serviceRespond( - dup: HttpDuplex | Git, - service: ServiceString, - repoLocation: string, - res: http.ServerResponse -) { - res.write(packSideband('# service=git-' + service + '\n')); - res.write('0000'); - - const isWin = /^win/.test(process.platform); - - const cmd = isWin - ? ['git', service, '--stateless-rpc', '--advertise-refs', repoLocation] - : ['git-' + service, '--stateless-rpc', '--advertise-refs', repoLocation]; - - const ps = spawn(cmd[0], cmd.slice(1)); - - ps.on('error', (err) => { - dup.emit( - 'error', - new Error(`${err.message} running command ${cmd.join(' ')}`) - ); - }); - ps.stdout.pipe(res); -} -/** - * sends http response using the appropriate output from service call - * @param git - an instance of git object - * @param repo - the repository - * @param service - the method that is responding infoResponse (push, pull, clone) - * @param req - http request object - * @param res - http response - */ -export function infoResponse( - git: Git, - repo: string, - service: ServiceString, - req: http.IncomingMessage, - res: http.ServerResponse -) { - function next() { - res.setHeader( - 'content-type', - 'application/x-git-' + service + '-advertisement' - ); - noCache(res); - serviceRespond(git, service, git.dirMap(repo), res); - } - - const dup = new HttpDuplex(req, res); - dup.cwd = git.dirMap(repo); - dup.repo = repo; - - dup.accept = dup.emit.bind(dup, 'accept'); - dup.reject = dup.emit.bind(dup, 'reject'); - - dup.once('reject', (code: number) => { - res.statusCode = code || 500; - res.end(); - }); - const anyListeners = git.listeners('info').length > 0; - - const exists = git.exists(repo); - dup.exists = exists; - - if (!exists && git.autoCreate) { - dup.once('accept', () => { - git.create(repo, next); - }); - - git.emit('info', dup); - if (!anyListeners) dup.accept(); - } else if (!exists) { - res.statusCode = 404; - res.setHeader('content-type', 'text/plain'); - res.end('repository not found'); - } else { - dup.once('accept', next); - git.emit('info', dup); - - if (!anyListeners) dup.accept(); - } -} /** * parses a git string and returns the repo name * @param repo - the raw repo name containing .git @@ -155,12 +56,13 @@ export function parseGitName(repo: string): string { * @param req - http request object * @param res - http response */ -export function createAction( +export function createAction( opts: ServiceOptions, req: http.IncomingMessage, - res: http.ServerResponse -): Service { - const service = new Service(opts, req, res); + res: http.ServerResponse, + context: T | undefined +): Service { + const service = new Service(opts, req, res, context); // TODO: see if this works or not // Object.keys(opts).forEach((key) => { diff --git a/website/docs/intro.md b/website/docs/intro.md index cf2367b..4fe08a7 100644 --- a/website/docs/intro.md +++ b/website/docs/intro.md @@ -123,6 +123,14 @@ To http://localhost:7005/test #### Authentication +Every request runs through the `authenticate` callback given to the `Git` constructor. + +This callback is passed fairly generic information, and should throw an error if the +user given credentials are invalid. + +`authenticate` can return a `context` object which the more specific event listeners +(such as `'push'`) can use to perform fine-grained authorization checks. + ```typescript import { Git } from 'node-git-server'; import { join } from 'path'; @@ -132,20 +140,29 @@ const port = ? 7005 : parseInt(process.env.PORT); -const repos = new Git(join(__dirname, '../repo'), { +interface MyRequestContext { + userInfo: MyUserInfo; +} + +const repos = new Git(join(__dirname, '../repo'), { autoCreate: true, - autheficate: ({ type, user }, next) => - type == 'push' - ? user(([username, password]) => { - console.log(username, password); - next(); - }) - : next(), + authenticate: async ({ type, getUser }): Promise => { + const [username, password] = await getUser(); + // Check these use credentials in your own system + const info = await getUsersInfo(username, password); + if (!info) throw new Error("username or password incorrect"); + return { + userInfo: info, + }; + } }); repos.on('push', (push) => { console.log(`push ${push.repo}/${push.commit} ( ${push.branch} )`); - push.accept(); + if (push.context?.userInfo.canPushToBranch(push.branch)) + push.accept(); + else + push.reject(); }); repos.on('fetch', (fetch) => {