зеркало из https://github.com/Azure/git-rest-api.git
Feature: Throttle fetch to be at most every 30s and handle permissions (#4)
This commit is contained in:
Родитель
d90c0b8daf
Коммит
ca4516703b
|
@ -11,10 +11,10 @@ steps:
|
|||
inputs:
|
||||
versionSpec: "10.x"
|
||||
|
||||
- script: npm ci
|
||||
- script: npm -s ci
|
||||
displayName: "Install dependencies"
|
||||
|
||||
- script: npm run test
|
||||
- script: npm run -s test:ci
|
||||
displayName: "Test"
|
||||
|
||||
- task: PublishTestResults@2
|
||||
|
@ -28,5 +28,5 @@ steps:
|
|||
summaryFileLocation: "$(System.DefaultWorkingDirectory)/**/*coverage.xml"
|
||||
reportDirectory: "$(System.DefaultWorkingDirectory)/**/coverage"
|
||||
|
||||
- script: npm run lint
|
||||
- script: npm run -s lint
|
||||
displayName: "Lint"
|
||||
|
|
|
@ -59,4 +59,6 @@ jspm_packages/
|
|||
|
||||
bin/
|
||||
buildcache/
|
||||
tmp/
|
||||
tmp/
|
||||
# Junit
|
||||
test-results*
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"exclude": ["node_modules", "bin", "test", "**/*.test.ts"]
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "bin", "test", "src/**/*.test.ts"]
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ const config = {
|
|||
},
|
||||
},
|
||||
setupFiles: ["<rootDir>/test/jest-setup.ts"],
|
||||
testMatch: ["**/*.test.ts"],
|
||||
testMatch: ["<rootDir>/src/**/*.test.ts"],
|
||||
verbose: true,
|
||||
testEnvironment: "node",
|
||||
};
|
||||
|
|
|
@ -484,6 +484,15 @@
|
|||
"@babel/types": "^7.3.0"
|
||||
}
|
||||
},
|
||||
"@types/basic-auth": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/basic-auth/-/basic-auth-1.1.2.tgz",
|
||||
"integrity": "sha512-NzkkcC+gkkILWaBi3+/z/3do6Ybk6TWeTqV5zCVXmG2KaBoT5YqlJvfqP44HCyDA+Cu58pp7uKAxy/G58se/TA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/convict": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/convict/-/convict-4.2.1.tgz",
|
||||
|
@ -535,6 +544,15 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.0.2.tgz",
|
||||
"integrity": "sha512-5tabW/i+9mhrfEOUcLDu2xBPsHJ+X5Orqy9FKpale3SjDA17j5AEpYq5vfy3oAeAHGcvANRCO3NV3d2D6q3NiA=="
|
||||
},
|
||||
"@types/node-fetch": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.3.4.tgz",
|
||||
"integrity": "sha512-ZwGXz5osL88SF+jlbbz0WJlINlOZHoSWPrLytQRWRdB6j/KVLup1OoqIxnjO6q9ToqEEP3MZFzJCotgge+IiRw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/nodegit": {
|
||||
"version": "0.24.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodegit/-/nodegit-0.24.6.tgz",
|
||||
|
@ -881,6 +899,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"basic-auth": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
|
||||
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
|
||||
"requires": {
|
||||
"safe-buffer": "5.1.2"
|
||||
}
|
||||
},
|
||||
"bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
|
@ -3646,6 +3672,35 @@
|
|||
"throat": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"jest-junit": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-junit/-/jest-junit-6.4.0.tgz",
|
||||
"integrity": "sha512-GXEZA5WBeUich94BARoEUccJumhCgCerg7mXDFLxWwI2P7wL3Z7sGWk+53x343YdBLjiMR9aD/gYMVKO+0pE4Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"jest-validate": "^24.0.0",
|
||||
"mkdirp": "^0.5.1",
|
||||
"strip-ansi": "^4.0.0",
|
||||
"xml": "^1.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
|
||||
"integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
|
||||
"dev": true
|
||||
},
|
||||
"strip-ansi": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
|
||||
"integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^3.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"jest-leak-detector": {
|
||||
"version": "24.8.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-24.8.0.tgz",
|
||||
|
@ -4103,21 +4158,6 @@
|
|||
"es5-ext": "~0.10.2"
|
||||
}
|
||||
},
|
||||
"make-dir": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.0.0.tgz",
|
||||
"integrity": "sha512-grNJDhb8b1Jm1qeqW5R/O63wUo4UXo2v2HMic6YT9i/HBlF93S8jkMgH7yugvY9ABDShH4VZMn8I+U8+fCNegw==",
|
||||
"requires": {
|
||||
"semver": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.0.0.tgz",
|
||||
"integrity": "sha512-0UewU+9rFapKFnlbirLi3byoOuhrSsli/z/ihNnvM24vgF+8sNBiI1LZPBSH9wJKUwaUbw+s3hToDLCXkrghrQ=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"make-error": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.5.tgz",
|
||||
|
@ -4421,9 +4461,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"node-fetch": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.5.0.tgz",
|
||||
"integrity": "sha512-YuZKluhWGJwCcUu4RlZstdAxr8bFfOVHakc1mplwHkk8J+tqM1Y5yraYvIUpeX8aY7+crCwiELJq7Vl0o0LWXw=="
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
|
||||
"integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA=="
|
||||
},
|
||||
"node-gyp": {
|
||||
"version": "4.0.0",
|
||||
|
@ -6426,6 +6466,12 @@
|
|||
"async-limiter": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"xml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
"integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU=",
|
||||
"dev": true
|
||||
},
|
||||
"xml-name-validator": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz",
|
||||
|
|
11
package.json
11
package.json
|
@ -11,6 +11,7 @@
|
|||
"start:dev": "concurrently --handle-input \"wait-on bin/main.js && nodemon\" \"tsc -w -p tsconfig.build.json\" ",
|
||||
"start:debug": "nodemon --config nodemon-debug.json",
|
||||
"test": "jest",
|
||||
"test:ci": "jest --ci --reporters=default --reporters=jest-junit",
|
||||
"lint": "tslint -p tsconfig.json",
|
||||
"test:watch": "jest --watch --coverage=false --config ./config/jest.dev.config.js",
|
||||
"swagger:gen": "node ./bin/scripts/generate-swagger-specs.js"
|
||||
|
@ -26,11 +27,14 @@
|
|||
},
|
||||
"homepage": "https://github.com/Azure/git-rest-api#readme",
|
||||
"devDependencies": {
|
||||
"@types/basic-auth": "^1.1.2",
|
||||
"@types/convict": "^4.2.1",
|
||||
"@types/jest": "^24.0.13",
|
||||
"@types/node-fetch": "^2.3.4",
|
||||
"@types/nodegit": "^0.24.6",
|
||||
"concurrently": "^4.1.0",
|
||||
"jest": "^24.8.0",
|
||||
"jest-junit": "^6.4.0",
|
||||
"prettier": "^1.17.1",
|
||||
"swagger-ui-express": "^4.0.3",
|
||||
"ts-jest": "^24.0.2",
|
||||
|
@ -45,11 +49,16 @@
|
|||
"@nestjs/platform-express": "^6.2.0",
|
||||
"@nestjs/swagger": "^3.0.2",
|
||||
"@types/node": "^12.0.2",
|
||||
"basic-auth": "^2.0.1",
|
||||
"class-validator": "^0.9.1",
|
||||
"convict": "^5.0.0",
|
||||
"make-dir": "^3.0.0",
|
||||
"node-fetch": "^2.6.0",
|
||||
"nodegit": "^0.24.3",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^6.5.2"
|
||||
},
|
||||
"jest-junit": {
|
||||
"outputDirectory": ".",
|
||||
"outputName": "test-results.xml"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,11 +2,30 @@ import { Module } from "@nestjs/common";
|
|||
|
||||
import { Configuration } from "./config";
|
||||
import { AppController, BranchesController, HealthCheckController } from "./controllers";
|
||||
import { AppService, BranchService, FSService, PermissionService, RepoService } from "./services";
|
||||
import {
|
||||
AppService,
|
||||
BranchService,
|
||||
FSService,
|
||||
GitFetchService,
|
||||
HttpService,
|
||||
PermissionCacheService,
|
||||
PermissionService,
|
||||
RepoService,
|
||||
} from "./services";
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AppController, HealthCheckController, BranchesController],
|
||||
providers: [AppService, RepoService, FSService, BranchService, PermissionService, Configuration],
|
||||
providers: [
|
||||
AppService,
|
||||
RepoService,
|
||||
FSService,
|
||||
BranchService,
|
||||
PermissionService,
|
||||
GitFetchService,
|
||||
PermissionCacheService,
|
||||
HttpService,
|
||||
Configuration,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
@ -27,7 +27,7 @@ describe("BranchController", () => {
|
|||
});
|
||||
|
||||
it("list the branches", async () => {
|
||||
const auth = new RepoAuth({});
|
||||
const auth = new RepoAuth();
|
||||
const branches = await controller.list("github.com/Azure/git-rest-api", auth);
|
||||
|
||||
expect(branches).toEqual([b1, b2]);
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export * from "./repo-auth";
|
||||
export * from "./repo-auth-decorators";
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import { BadRequestException, createParamDecorator } from "@nestjs/common";
|
||||
import { ApiBadRequestResponse, ApiImplicitHeaders } from "@nestjs/swagger";
|
||||
|
||||
import { AUTH_HEADERS, RepoAuth } from "./repo-auth";
|
||||
|
||||
/**
|
||||
* Auth param decorator for controller to inject the repo auth object
|
||||
*/
|
||||
export const Auth = createParamDecorator(
|
||||
(_, req): RepoAuth => {
|
||||
const repoAuth = RepoAuth.fromHeaders(req.headers);
|
||||
if (repoAuth) {
|
||||
return repoAuth;
|
||||
} else {
|
||||
throw new BadRequestException("Repository authorization is malformed");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Helper to add on methods using the Auth parameter for the swagger specs to be generated correctly
|
||||
*/
|
||||
export function ApiHasPassThruAuth(): MethodDecorator {
|
||||
const implicitHeaders = ApiImplicitHeaders(
|
||||
Object.values(AUTH_HEADERS).map(header => {
|
||||
return {
|
||||
name: header,
|
||||
required: false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const badRequestResponse = ApiBadRequestResponse({
|
||||
description: "When the api request header is malformed",
|
||||
});
|
||||
|
||||
return (...args) => {
|
||||
implicitHeaders(...args);
|
||||
badRequestResponse(...args);
|
||||
};
|
||||
}
|
|
@ -5,13 +5,37 @@ import { RepoAuth } from "./repo-auth";
|
|||
describe("RepoAuth", () => {
|
||||
describe("Model", () => {
|
||||
it("doesn't generated any creds when no options are passed", () => {
|
||||
expect(new RepoAuth({}).toCreds()).toBeUndefined();
|
||||
expect(new RepoAuth().toCreds()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined if invalid basic header", () => {
|
||||
expect(RepoAuth.fromHeaders({ "x-authorization": "Basi invlid" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("doesn't generate some basic password creds when using oath token", () => {
|
||||
expect(new RepoAuth({ token: "token-1" }).toCreds()).toEqual(
|
||||
expect(RepoAuth.fromHeaders({ "x-github-token": "token-1" })!.toCreds()).toEqual(
|
||||
Cred.userpassPlaintextNew("token-1", "x-oauth-basic"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return different hashes per cred", () => {
|
||||
const hash1 = new RepoAuth({ username: "foo", password: "bar" }).hash();
|
||||
const hash2 = new RepoAuth({ username: "foo2", password: "bar" }).hash();
|
||||
const hash3 = new RepoAuth({ username: "foo", password: "bar2" }).hash();
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
expect(hash2).not.toBe(hash3);
|
||||
expect(hash1).not.toBe(hash3);
|
||||
|
||||
expect(hash1).toBe(
|
||||
"6bd974a6ce1305174fff5df0cefd2025005861ad19332177290f10af00e0b5dee8e563417bc6f509e50527d35ec48986593d347cfc41069e8bfad24e70c5eabb",
|
||||
);
|
||||
expect(hash2).toBe(
|
||||
"cd6f6487398b42e04b68676f8567d4c9494f49347862272ebe7cba3ce5c9fa2c05cdc3ee302ae42bbb00632e7ad78f1b0ecd1b37634ffcae3756b427e7c8080b",
|
||||
);
|
||||
expect(hash3).toBe(
|
||||
"34f5826bdf4f7863ede840bfc394a585c233862eee7d6c6dcdc0287b98626fae43f91f41700144c22207372b24ecdec35f6b1d4ebd882d2a5b7b25918b3c18aa",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,45 +1,84 @@
|
|||
import { createParamDecorator } from "@nestjs/common";
|
||||
import { ApiImplicitHeaders } from "@nestjs/swagger";
|
||||
import basicAuth from "basic-auth";
|
||||
import { Cred } from "nodegit";
|
||||
|
||||
export interface IRepoAuth {
|
||||
token?: string;
|
||||
}
|
||||
import { SecureUtils } from "../../utils";
|
||||
|
||||
export const AUTH_HEADERS = {
|
||||
generic: "x-authorization",
|
||||
github: "x-github-token",
|
||||
};
|
||||
|
||||
/**
|
||||
* Class that handle repository authentication
|
||||
* Support generic server auth using the `x-authentication` header which is just a pass thru to the repo
|
||||
* Supoort a few helper
|
||||
* - `x-github-token`: Pass a github token to authenticate
|
||||
*/
|
||||
export class RepoAuth {
|
||||
private token: string | undefined;
|
||||
private readonly username?: string;
|
||||
private readonly password?: string;
|
||||
|
||||
constructor(obj: IRepoAuth) {
|
||||
this.token = obj.token;
|
||||
constructor(obj?: { username: string; password: string }) {
|
||||
if (obj) {
|
||||
this.username = obj.username;
|
||||
this.password = obj.password;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a repo auth instance from the header
|
||||
* @param headers: Header string map
|
||||
*
|
||||
* @returns {RepoAuth} if it manage to create an object
|
||||
* @returns {undefined} if the authorization headers are invalid. This should result in an error. This is not the same as not providing any headers. This means the headers were invalid
|
||||
*/
|
||||
public static fromHeaders(headers: { [key: string]: string }): RepoAuth | undefined {
|
||||
if (headers[AUTH_HEADERS.generic]) {
|
||||
const auth = parseAuthorizationHeader(headers[AUTH_HEADERS.generic]);
|
||||
if (!auth) {
|
||||
return undefined;
|
||||
}
|
||||
return new RepoAuth(auth);
|
||||
} else if (headers[AUTH_HEADERS.github]) {
|
||||
return new RepoAuth({
|
||||
username: headers[AUTH_HEADERS.github],
|
||||
password: "x-oauth-basic",
|
||||
});
|
||||
}
|
||||
return new RepoAuth();
|
||||
}
|
||||
|
||||
public toCreds(): Cred | undefined {
|
||||
if (this.token) {
|
||||
return Cred.userpassPlaintextNew(this.token, "x-oauth-basic");
|
||||
} else {
|
||||
return undefined;
|
||||
if (this.username && this.password) {
|
||||
return Cred.userpassPlaintextNew(this.username, this.password);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public toAuthorizationHeader(): string | undefined {
|
||||
if (this.username && this.password) {
|
||||
const header = `${this.username}:${this.password}`;
|
||||
return `Basic ${Buffer.from(header).toString("base64")}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public hash(): string | undefined {
|
||||
const header = this.toAuthorizationHeader();
|
||||
|
||||
return header ? SecureUtils.sha512(header) : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN_HEADER = "x-oauth-basic";
|
||||
|
||||
export const Auth = createParamDecorator(
|
||||
(_, req): RepoAuth => {
|
||||
return new RepoAuth({
|
||||
token: req.headers[TOKEN_HEADER],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Helper to add on methods using the Auth parameter for the swagger specs to be generated correctly
|
||||
* Parse the authorization header into username and password.
|
||||
* Needs to be in this format for now
|
||||
* `Basic [base64Encoded(username:password)`
|
||||
*/
|
||||
export function ApiHasPassThruAuth() {
|
||||
return ApiImplicitHeaders([
|
||||
{
|
||||
name: TOKEN_HEADER,
|
||||
required: false,
|
||||
},
|
||||
]);
|
||||
function parseAuthorizationHeader(header: string) {
|
||||
const result = basicAuth.parse(header);
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
return { username: result.name, password: result.pass };
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
type StringMap<T> = { [key: string]: T };
|
|
@ -0,0 +1,8 @@
|
|||
import { Repository } from "nodegit";
|
||||
|
||||
import { GitRemotePermission } from "../services";
|
||||
|
||||
export interface LocalRepo {
|
||||
repo: Repository;
|
||||
permission: GitRemotePermission;
|
||||
}
|
|
@ -1,19 +1,19 @@
|
|||
import { Injectable } from "@nestjs/common";
|
||||
import fs from "fs";
|
||||
import makeDir from "make-dir";
|
||||
import util from "util";
|
||||
|
||||
const fsPromises = {
|
||||
exists: util.promisify(fs.exists),
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FSService {
|
||||
public async exists(path: string): Promise<boolean> {
|
||||
return fsPromises.exists(path);
|
||||
try {
|
||||
await fs.promises.access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async makeDir(path: string): Promise<string> {
|
||||
return makeDir(path);
|
||||
public async mkdir(path: string): Promise<string> {
|
||||
await fs.promises.mkdir(path, { recursive: true });
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Clone, Fetch, FetchOptions, Repository } from "nodegit";
|
||||
import path from "path";
|
||||
|
||||
import { FSService } from "../fs";
|
||||
import { GitBaseOptions } from "../repo";
|
||||
|
||||
export function credentialsCallback(options: GitBaseOptions) {
|
||||
return () => {
|
||||
if (options.auth) {
|
||||
return options.auth.toCreds();
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultFetchOptions: FetchOptions = {
|
||||
downloadTags: 0,
|
||||
prune: Fetch.PRUNE.GIT_FETCH_PRUNE,
|
||||
};
|
||||
export const repoCacheFolder = path.join("./tmp", "repos");
|
||||
|
||||
const FETCH_TIMEOUT = 30_000; // 30s;
|
||||
|
||||
@Injectable()
|
||||
export class GitFetchService {
|
||||
private currentFetches = new Map<string, Promise<Repository>>();
|
||||
private lastFetch = new Map<string, number>();
|
||||
|
||||
private cacheReady: Promise<string>;
|
||||
|
||||
constructor(fs: FSService) {
|
||||
this.cacheReady = fs.mkdir(repoCacheFolder);
|
||||
}
|
||||
|
||||
public async fetch(remote: string, repo: Repository, options: GitBaseOptions): Promise<Repository> {
|
||||
if (await this.needToFetch(remote)) {
|
||||
return this.ensureSingleFetch(remote, () => this.fetchAll(remote, repo, options).then(() => repo));
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
private ensureSingleFetch(remote: string, callback: () => Promise<Repository>): Promise<Repository> {
|
||||
let promise = this.currentFetches.get(remote);
|
||||
|
||||
if (!promise) {
|
||||
promise = callback().then(repo => {
|
||||
this.currentFetches.delete(remote);
|
||||
return repo;
|
||||
});
|
||||
this.currentFetches.set(remote, promise);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
public async clone(remote: string, repoPath: string, options: GitBaseOptions): Promise<Repository> {
|
||||
await this.cacheReady;
|
||||
try {
|
||||
return await Clone.clone(`https://${remote}`, repoPath, {
|
||||
fetchOpts: {
|
||||
...defaultFetchOptions,
|
||||
callbacks: {
|
||||
credentials: credentialsCallback(options),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchAll(remote: string, repo: Repository, options: GitBaseOptions) {
|
||||
try {
|
||||
await repo.fetchAll({
|
||||
...defaultFetchOptions,
|
||||
callbacks: {
|
||||
credentials: credentialsCallback(options),
|
||||
},
|
||||
});
|
||||
this.lastFetch.set(remote, new Date().getTime());
|
||||
} catch {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If we need to fetch the given remote
|
||||
*/
|
||||
private async needToFetch(remote: string): Promise<boolean> {
|
||||
const lastFetch = this.lastFetch.get(remote);
|
||||
if (!lastFetch) {
|
||||
return true;
|
||||
}
|
||||
const now = new Date().getTime();
|
||||
return now - lastFetch > FETCH_TIMEOUT;
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * from "./git-fetch.service";
|
|
@ -0,0 +1,9 @@
|
|||
import { Injectable } from "@nestjs/common";
|
||||
import fetch, { RequestInit, Response } from "node-fetch";
|
||||
|
||||
@Injectable()
|
||||
export class HttpService {
|
||||
public fetch(uri: string, init?: RequestInit): Promise<Response> {
|
||||
return fetch(uri, init);
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * from "./http.service";
|
|
@ -3,3 +3,5 @@ export * from "./branch";
|
|||
export * from "./fs";
|
||||
export * from "./repo";
|
||||
export * from "./permission";
|
||||
export * from "./git-fetch";
|
||||
export * from "./http";
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export * from "./permission-cache.service";
|
|
@ -0,0 +1,54 @@
|
|||
import { RepoAuth } from "../../../core";
|
||||
import { GitRemotePermission } from "../permissions";
|
||||
import { PermissionCacheService } from "./permission-cache.service";
|
||||
|
||||
const remote = "github.com/Azure/git-rest-specs";
|
||||
|
||||
const auth = new RepoAuth({ username: "token-1", password: "x-oauth-token" });
|
||||
describe("PermissionService", () => {
|
||||
let service: PermissionCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllTimers();
|
||||
jest.useFakeTimers();
|
||||
service = new PermissionCacheService();
|
||||
});
|
||||
|
||||
it("returns undefined when permission has not been set", () => {
|
||||
expect(service.get(auth, remote)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns none when permission is set to none", () => {
|
||||
service.set(auth, remote, GitRemotePermission.None);
|
||||
expect(service.get(auth, remote)).toBe(GitRemotePermission.None);
|
||||
});
|
||||
|
||||
it("set a permission", () => {
|
||||
service.set(auth, remote, GitRemotePermission.Read);
|
||||
expect(service.get(auth, remote)).toBe(GitRemotePermission.Read);
|
||||
});
|
||||
|
||||
it("clear permission after a timeout", () => {
|
||||
const now = Date.now();
|
||||
|
||||
service.set(auth, remote, GitRemotePermission.Read);
|
||||
expect(service.get(auth, remote)).toBe(GitRemotePermission.Read);
|
||||
mockDate(now + 50_000);
|
||||
// Should still be cached
|
||||
expect(service.get(auth, remote)).toBe(GitRemotePermission.Read);
|
||||
|
||||
mockDate(now + 60_010);
|
||||
expect(service.get(auth, remote)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("doesn't get permission from another key", () => {
|
||||
service.set(auth, remote, GitRemotePermission.Read);
|
||||
expect(service.get(new RepoAuth(), remote)).toBeUndefined();
|
||||
expect(service.get(new RepoAuth({ username: "foo", password: "x-oauth-token" }), remote)).toBeUndefined();
|
||||
expect(service.get(new RepoAuth({ username: "token-1", password: "x-other-token" }), remote)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function mockDate(now: number) {
|
||||
jest.spyOn(global.Date, "now").mockImplementation(() => now);
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { RepoAuth } from "../../../core";
|
||||
import { GitRemotePermission } from "../permissions";
|
||||
|
||||
const TOKEN_INVALIDATE_TIMEOUT = 60_000; // 60s
|
||||
|
||||
export interface CachedPermission {
|
||||
lastSync: number; // Date
|
||||
permission: GitRemotePermission;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PermissionCacheService {
|
||||
private tokenPermissions = new Map<string, CachedPermission>();
|
||||
|
||||
public get(auth: RepoAuth, remote: string): GitRemotePermission | undefined {
|
||||
const key = this.getMapKey(auth, remote);
|
||||
const cache = this.tokenPermissions.get(key);
|
||||
if (cache === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const now = Date.now();
|
||||
|
||||
if (now - cache.lastSync > TOKEN_INVALIDATE_TIMEOUT) {
|
||||
this.tokenPermissions.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return cache.permission;
|
||||
}
|
||||
|
||||
public set(auth: RepoAuth, remote: string, permission: GitRemotePermission): GitRemotePermission {
|
||||
const key = this.getMapKey(auth, remote);
|
||||
this.tokenPermissions.set(key, { permission, lastSync: Date.now() });
|
||||
return permission;
|
||||
}
|
||||
|
||||
private getMapKey(auth: RepoAuth, remote: string) {
|
||||
return `${auth.hash()}/${remote}`;
|
||||
}
|
||||
}
|
|
@ -1 +1,3 @@
|
|||
export * from "./permission.service";
|
||||
export * from "./cache";
|
||||
export * from "./permissions";
|
||||
|
|
|
@ -1,28 +1,103 @@
|
|||
import { PermissionService, TokenPermission } from "./permission.service";
|
||||
import { RepoAuth } from "../../core";
|
||||
import { PermissionCacheService } from "./cache";
|
||||
import { PermissionService } from "./permission.service";
|
||||
import { GitRemotePermission } from "./permissions";
|
||||
|
||||
const remote = "github.com/Azure/some-repo";
|
||||
const remotePrivate = "github.com/Azure/some-private";
|
||||
const auth = new RepoAuth({ username: "token-1", password: "x-oauth-token" });
|
||||
|
||||
const remote = "github.com/Azure/git-rest-specs";
|
||||
describe("PermissionService", () => {
|
||||
let service: PermissionService;
|
||||
let cache: PermissionCacheService;
|
||||
const httpSpy = {
|
||||
fetch: jest.fn(uri => {
|
||||
if (uri.includes(remote) && uri.includes("git-upload-pack")) {
|
||||
return { status: 200 };
|
||||
} else if (uri.includes(remotePrivate)) {
|
||||
return { status: 200 };
|
||||
}
|
||||
return {
|
||||
status: 404,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllTimers();
|
||||
jest.useFakeTimers();
|
||||
service = new PermissionService();
|
||||
jest.clearAllMocks();
|
||||
cache = new PermissionCacheService();
|
||||
service = new PermissionService(cache, httpSpy as any);
|
||||
});
|
||||
|
||||
it("returns undefined when permission has not been set", () => {
|
||||
expect(service.getTokenPermission("token-1", remote)).toBeUndefined();
|
||||
it("returns the permission in the cache if its there", async () => {
|
||||
cache.set(auth, remote, GitRemotePermission.Read);
|
||||
expect(await service.get(auth, remote)).toBe(GitRemotePermission.Read);
|
||||
});
|
||||
|
||||
it("set a permission", () => {
|
||||
service.setTokenPermission("token-1", remote, TokenPermission.Read);
|
||||
expect(service.getTokenPermission("token-1", remote)).toBe(TokenPermission.Read);
|
||||
it("returns none when permission is set to none", async () => {
|
||||
service.set(auth, remote, GitRemotePermission.None);
|
||||
expect(await service.get(auth, remote)).toBe(GitRemotePermission.None);
|
||||
});
|
||||
|
||||
it("clear permission after a timeout", () => {
|
||||
service.setTokenPermission("token-1", remote, TokenPermission.Read);
|
||||
expect(service.getTokenPermission("token-1", remote)).toBe(TokenPermission.Read);
|
||||
jest.runTimersToTime(60_000);
|
||||
expect(service.getTokenPermission("token-1", remote)).toBeUndefined();
|
||||
describe("when permission are not cached", () => {
|
||||
it("try write endpoint", async () => {
|
||||
const permission = await service.get(auth, remotePrivate);
|
||||
expect(permission).toBe(GitRemotePermission.Write);
|
||||
expect(cache.get(auth, remotePrivate)).toBe(GitRemotePermission.Write);
|
||||
|
||||
expect(httpSpy.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(httpSpy.fetch).toHaveBeenCalledWith("https://github.com/Azure/some-private.git/git-receive-pack", {
|
||||
headers: {
|
||||
Authorization: auth.toAuthorizationHeader(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("try write endpoint then read if can't write", async () => {
|
||||
const permission = await service.get(auth, remote);
|
||||
expect(permission).toBe(GitRemotePermission.Read);
|
||||
expect(cache.get(auth, remote)).toBe(GitRemotePermission.Read);
|
||||
|
||||
expect(httpSpy.fetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(httpSpy.fetch).toHaveBeenCalledWith("https://github.com/Azure/some-repo.git/git-receive-pack", {
|
||||
headers: {
|
||||
Authorization: auth.toAuthorizationHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(httpSpy.fetch).toHaveBeenCalledWith("https://github.com/Azure/some-repo.git/git-upload-pack", {
|
||||
headers: {
|
||||
Authorization: auth.toAuthorizationHeader(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("try write and read if has no permission", async () => {
|
||||
const permission = await service.get(auth, "github.com/Azure/other-repo-no-permissions");
|
||||
expect(permission).toBe(GitRemotePermission.None);
|
||||
expect(cache.get(auth, "github.com/Azure/other-repo-no-permissions")).toBe(GitRemotePermission.None);
|
||||
|
||||
expect(httpSpy.fetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(httpSpy.fetch).toHaveBeenCalledWith(
|
||||
"https://github.com/Azure/other-repo-no-permissions.git/git-receive-pack",
|
||||
{
|
||||
headers: {
|
||||
Authorization: auth.toAuthorizationHeader(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(httpSpy.fetch).toHaveBeenCalledWith(
|
||||
"https://github.com/Azure/other-repo-no-permissions.git/git-upload-pack",
|
||||
{
|
||||
headers: {
|
||||
Authorization: auth.toAuthorizationHeader(),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,54 +1,67 @@
|
|||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { SecureUtils } from "../../utils";
|
||||
|
||||
export enum TokenPermission {
|
||||
None,
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
const TOKEN_INVALIDATE_TIMEOUT = 60_000; // 60s
|
||||
import { RepoAuth } from "../../core";
|
||||
import { RepoUtils } from "../../utils";
|
||||
import { HttpService } from "../http";
|
||||
import { PermissionCacheService } from "./cache";
|
||||
import { GitRemotePermission } from "./permissions";
|
||||
|
||||
@Injectable()
|
||||
export class PermissionService {
|
||||
private tokenPermissions = new Map<string, TokenPermission>();
|
||||
private timeouts = new Map<string, NodeJS.Timeout>();
|
||||
constructor(private cache: PermissionCacheService, private http: HttpService) {}
|
||||
|
||||
public getTokenPermission(token: string, remote: string): TokenPermission | undefined {
|
||||
return this.tokenPermissions.get(this.getMapKey(token, remote));
|
||||
public async get(auth: RepoAuth, remote: string): Promise<GitRemotePermission> {
|
||||
const cached = this.cache.get(auth, remote);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
return this.retrievePermissions(auth, remote);
|
||||
}
|
||||
|
||||
public setTokenPermission(token: string, remote: string, permission: TokenPermission) {
|
||||
const key = this.getMapKey(token, remote);
|
||||
this.tokenPermissions.set(key, permission);
|
||||
this.timeoutPermission(key);
|
||||
public set(auth: RepoAuth, remote: string, permission: GitRemotePermission) {
|
||||
return this.cache.set(auth, remote, permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the cached permission after a timeout
|
||||
*/
|
||||
private timeoutPermission(key: string) {
|
||||
const existingTimeout = this.timeouts.get(key);
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
this.timeouts.delete(key);
|
||||
private async retrievePermissions(auth: RepoAuth, remote: string) {
|
||||
const gitUrl = RepoUtils.getUrl(remote);
|
||||
const canWrite = await this.checkWritePermission(auth, gitUrl);
|
||||
if (canWrite) {
|
||||
return this.set(auth, remote, GitRemotePermission.Write);
|
||||
}
|
||||
|
||||
this.timeouts.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
this.tokenPermissions.delete(key);
|
||||
this.timeouts.delete(key);
|
||||
}, TOKEN_INVALIDATE_TIMEOUT),
|
||||
);
|
||||
const canRead = await this.checkReadPermission(auth, gitUrl);
|
||||
if (canRead) {
|
||||
return this.set(auth, remote, GitRemotePermission.Read);
|
||||
}
|
||||
return this.set(auth, remote, GitRemotePermission.None);
|
||||
}
|
||||
|
||||
private getMapKey(token: string, remote: string) {
|
||||
return `${this.hashToken(token)}/${remote}`;
|
||||
private async checkWritePermission(auth: RepoAuth, gitUrl: string): Promise<boolean> {
|
||||
const response = await this.http.fetch(`${gitUrl}/${GitServices.Push}`, {
|
||||
headers: this.getHeaders(auth),
|
||||
});
|
||||
return response.status === 200;
|
||||
}
|
||||
|
||||
private hashToken(token: string): string {
|
||||
return SecureUtils.sha512(token);
|
||||
private async checkReadPermission(auth: RepoAuth, gitUrl: string): Promise<boolean> {
|
||||
const response = await this.http.fetch(`${gitUrl}/${GitServices.Pull}`, {
|
||||
headers: this.getHeaders(auth),
|
||||
});
|
||||
return response.status === 200;
|
||||
}
|
||||
|
||||
private getHeaders(auth: RepoAuth): StringMap<string> {
|
||||
const header = auth.toAuthorizationHeader();
|
||||
if (!header) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
Authorization: header,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
enum GitServices {
|
||||
Push = "git-receive-pack",
|
||||
Pull = "git-upload-pack",
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
export enum GitRemotePermission {
|
||||
None,
|
||||
Read,
|
||||
Write,
|
||||
}
|
|
@ -1,77 +1,40 @@
|
|||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Clone, Fetch, FetchOptions, Repository } from "nodegit";
|
||||
import { Repository } from "nodegit";
|
||||
import path from "path";
|
||||
|
||||
import { RepoAuth } from "../../core";
|
||||
import { FSService } from "../fs";
|
||||
import { GitFetchService, repoCacheFolder } from "../git-fetch";
|
||||
import { GitRemotePermission, PermissionService } from "../permission";
|
||||
|
||||
const repoCacheFolder = path.join("./tmp", "repos");
|
||||
export function getRepoMainPath(remote: string) {
|
||||
return path.join(repoCacheFolder, encodeURIComponent(remote));
|
||||
}
|
||||
|
||||
const defaultFetchOptions: FetchOptions = {
|
||||
downloadTags: 0,
|
||||
prune: Fetch.PRUNE.GIT_FETCH_PRUNE,
|
||||
};
|
||||
|
||||
export interface GitBaseOptions {
|
||||
auth?: RepoAuth;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RepoService {
|
||||
private cacheReady: Promise<string>;
|
||||
constructor(private fs: FSService) {
|
||||
this.cacheReady = fs.makeDir(repoCacheFolder);
|
||||
}
|
||||
constructor(
|
||||
private fs: FSService,
|
||||
private fetchService: GitFetchService,
|
||||
private permissionService: PermissionService,
|
||||
) {}
|
||||
|
||||
public async get(remote: string, options: GitBaseOptions = {}): Promise<Repository> {
|
||||
const permission = await this.permissionService.get(options.auth || new RepoAuth(), remote);
|
||||
if (permission === GitRemotePermission.None) {
|
||||
throw new NotFoundException(`Cannot find or missing permission to access '${remote}'`);
|
||||
}
|
||||
const repoPath = getRepoMainPath(remote);
|
||||
|
||||
if (await this.fs.exists(repoPath)) {
|
||||
const repo = await Repository.open(repoPath);
|
||||
await this.fetchAll(repo, options);
|
||||
return repo;
|
||||
return this.fetchService.fetch(remote, repo, options);
|
||||
} else {
|
||||
return this.clone(remote, repoPath, options);
|
||||
}
|
||||
}
|
||||
|
||||
public async fetchAll(repo: Repository, options: GitBaseOptions) {
|
||||
try {
|
||||
await repo.fetchAll({
|
||||
...defaultFetchOptions,
|
||||
callbacks: {
|
||||
credentials: credentialsCallback(options),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
public async clone(remote: string, repoPath: string, options: GitBaseOptions): Promise<Repository> {
|
||||
await this.cacheReady;
|
||||
try {
|
||||
return await Clone.clone(`https://${remote}`, repoPath, {
|
||||
fetchOpts: {
|
||||
...defaultFetchOptions,
|
||||
callbacks: {
|
||||
credentials: credentialsCallback(options),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new NotFoundException();
|
||||
return this.fetchService.clone(remote, repoPath, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function credentialsCallback(options: GitBaseOptions) {
|
||||
return () => {
|
||||
if (options.auth) {
|
||||
return options.auth.toCreds();
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export * from "./secure-utils";
|
||||
export * from "./repo-utils";
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
export const RepoUtils = {
|
||||
getUrl: (remote: string) => {
|
||||
const suffix = remote.endsWith(".git") ? "" : ".git";
|
||||
return `https://${remote}${suffix}`;
|
||||
},
|
||||
};
|
|
@ -52,7 +52,13 @@
|
|||
"in": "path"
|
||||
},
|
||||
{
|
||||
"name": "x-oauth-basic",
|
||||
"name": "x-authorization",
|
||||
"required": false,
|
||||
"in": "header",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "x-github-token",
|
||||
"required": false,
|
||||
"in": "header",
|
||||
"type": "string"
|
||||
|
@ -68,6 +74,9 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "When the api request header is malformed"
|
||||
},
|
||||
"404": {
|
||||
"description": ""
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@
|
|||
"experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
|
||||
"emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */
|
||||
},
|
||||
"files": ["src/definitions.d.ts"],
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "tmp", "bin"]
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче