├── .travis.yml ├── .codeclimate.yml ├── docker └── Dockerfile ├── test ├── app.js ├── authenticator │ ├── none.js │ └── basic.js ├── store │ ├── grid_store.js │ ├── index.js │ ├── s3_direct_store.js │ └── s3_store.js ├── ssh_server.js ├── verify.js ├── objects.js └── batch.js ├── config ├── test.json ├── default.json └── custom-environment-variables.json ├── Makefile ├── lib ├── authenticator │ ├── none.js │ ├── test.js │ ├── index.js │ └── basic.js ├── app.js ├── routes │ ├── verify.js │ ├── objects.js │ └── batch.js ├── store │ ├── s3_store.js │ ├── grid_store.js │ ├── s3_direct_store.js │ └── index.js └── ssh_server.js ├── ssh ├── client.pub ├── server.pub ├── client.pri └── server.pri ├── schema └── http-v1-batch-request-schema.json ├── .eslintrc ├── package.json ├── git-lfs-server.js ├── .gitignore ├── README.md └── LICENSE /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4.1.2" 4 | services: 5 | - mongodb 6 | env: 7 | - LFS_STORE_TYPE=s3 8 | - LFS_STORE_TYPE=grid 9 | - LFS_STORE_TYPE=s3_direct -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | eslint: 4 | enabled: true 5 | ratings: 6 | paths: 7 | - "**.js" 8 | - "**.jsx" 9 | exclude_paths: 10 | - config/**/* 11 | - test/**/* 12 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:4.2.1 2 | 3 | RUN apt-get update && apt-get install -y \ 4 | git 5 | 6 | ENV APP_DIR /data 7 | 8 | WORKDIR ${APP_DIR} 9 | 10 | RUN git clone --depth=1 https://github.com/kzwang/node-git-lfs.git ${APP_DIR} && \ 11 | npm install 12 | 13 | EXPOSE 3000 2222 14 | 15 | ENTRYPOINT ["./git-lfs-server.js"] -------------------------------------------------------------------------------- /test/app.js: -------------------------------------------------------------------------------- 1 | var request = require('supertest'); 2 | 3 | var should = require('chai').should(); 4 | 5 | var app = require('../lib/app'); 6 | 7 | describe('App', function() { 8 | it('should return 404 for not exist endpoint', function(done) { 9 | request(app) 10 | .get('/does_not_exist') 11 | .expect(404, done); 12 | }) 13 | }); -------------------------------------------------------------------------------- /config/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "store": { 3 | "options": { 4 | "endpoint": "http://localhost:4569", 5 | "bucket": "test", 6 | "access_key": "test", 7 | "secret_key": "test", 8 | "grid_connection": "mongodb://localhost:27017/test" 9 | } 10 | }, 11 | "authenticator": { 12 | "type": "test" 13 | }, 14 | "ssh": { 15 | "enabled": true, 16 | "port": 2222, 17 | "ip": "0.0.0.0", 18 | "key": { 19 | "public": "./ssh/server.pub", 20 | "private": "./ssh/server.pri" 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | REPORTER = spec 2 | 3 | 4 | test: 5 | @NODE_ENV=test ./node_modules/.bin/mocha ./test/ --recursive -b -R $(REPORTER) --require co-mocha 6 | 7 | test-cov: 8 | @NODE_ENV=test ./node_modules/.bin/istanbul cover \ 9 | ./node_modules/mocha/bin/_mocha -- ./test/ --recursive -R $(REPORTER) --require co-mocha 10 | 11 | 12 | test-coveralls: 13 | echo TRAVIS_JOB_ID $(TRAVIS_JOB_ID) 14 | $(MAKE) test 15 | @NODE_ENV=test ./node_modules/.bin/istanbul cover \ 16 | ./node_modules/mocha/bin/_mocha --report lcovonly -- ./test/ --recursive -R $(REPORTER) --require co-mocha && \ 17 | cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js || true -------------------------------------------------------------------------------- /lib/authenticator/none.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Authenticator = require('./'); 4 | 5 | 6 | class NoneAuthenticator extends Authenticator { 7 | 8 | /** 9 | * Construct NoneAuthenticator instance 10 | */ 11 | constructor() { 12 | super(); 13 | } 14 | 15 | canRead(user, repo, authorization) { 16 | return new Promise(function(resolve, reject) { 17 | resolve(true); 18 | }); 19 | } 20 | 21 | canWrite(user, repo, authorization) { 22 | return new Promise(function(resolve, reject) { 23 | resolve(true); 24 | }); 25 | } 26 | 27 | 28 | } 29 | 30 | module.exports = NoneAuthenticator; -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "base_url": "http://localhost:3000/", 3 | "port": 3000, 4 | "private": false, 5 | "store": { 6 | "type": "s3", 7 | "options": {} 8 | }, 9 | "jwt": { 10 | "algorithm": "HS256", 11 | "secret": "node-git-lfs-jwt-test-key", 12 | "issuer": "node-git-lfs", 13 | "expiresIn": "30m" 14 | }, 15 | "authenticator": { 16 | "type": "basic", 17 | "options": { 18 | "username": "test", 19 | "password": "test" 20 | } 21 | }, 22 | "ssh": { 23 | "enabled": true, 24 | "port": 2222, 25 | "ip": "0.0.0.0", 26 | "key": { 27 | "public": "./ssh/server.pub", 28 | "private": "./ssh/server.pri" 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /ssh/client.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCZ35v/2+MVR/Skc/gOd72pB/YE/EUGkXco4XuRYzfShOPdv1HQSXoqdnfL3EHCBibrPR2BWJcMvuPwo9ZBoKvIST9hH0aMyHG6MHty4rJaBk27Zzp2dhpYhwW9/QXEKSPnsoJUaR+mp91qXqqVg8zmE5olE+GgLYlsjwMHRIOx2Z19fHUkAIMPQELNmv+krixcuEArCiZ3UE7UaW/MNMqRYSbveuRCALzjsbR0UftkCwxA0Vf5Nb+FVoj9IFUoAYEHREGqzdCTybTJ7KSiZYOWgC/xvAcmEWmPDh9BdJO+57hfmy6vcqrixbhWTZuPn5H4TaLJlKIMNwqR+ZeOOpFYWM4yEzAfgCnfX01iZ+lHkb3URsqQRMwcDOu6iwwoBRkYkAlDizTe/IAm4gibgUigdLSk/msoYjFJNufjJyCIEgQuwF+SB9i/pApmPhxe6qFR8UxMgpzh51h6Mh290EQAgZB+QTjOrWDHA88AkdinV3asRmOjSN+cTCjOPrqotvzL7wpacF805sJp7aiSO2yLfL5P8PUaZtKJ/ZgJTl7/bak1B4+dU7/vLZG3kn5ez1+qCaig1B4vxWBFtu3lyX0u6Kg4QWe+1WDjudBU5C8aSLFCy74n1pzsNuPR90fBl2j8nHJdSLE25Retusim84+uRHIdNWYY3F2ywwyAiGIYOw== your_email@example.com 2 | -------------------------------------------------------------------------------- /ssh/server.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCzU2Irgq5sb+uLqCia1TymMeF1u5DgfMJo+b4EARclRr8D3ZT/5hhFoTI9TRtBtyLjYB1gH99D/GNnDRylwcW28gbmEm0K3sLz0NayN23YS0bSr9Fv45VhKrP9Ku86/gofG0ct7cd70Fes31fSqK+YFY0ESFVtbxhKRf5TmnadELtUJkAaYmTNf9SUT6O+9vUmsBGIum4afPxYutAId2nF9ey6FVFDMaDO1Vx+dtA/aKek2/Dyaku0KjC2HmGO0xzqDQpwLBvrw1w2CU/7z0ve25rBc406D9ywPwGT7Els3cIrkwryaADGEvBmxj5nHLLrE8vFDBaptnCLbeC/smU2pyE85jibrpb980Uoh25QMfmcTimbtnFDr4Tj0Elw2cO2kLV2RTa2LmnHieFxkTO1xSCp/Rc8aMYkjr0NesTut7NCoMltxf49zt86hlkQDoCud3KNI6t4eAUZzaBdvR02CLCotSIBAXsGrJj0PjxEdzL11rMKW4DLgK5FbHvrvi3O7h8FdOlCSgVOj9DDfg6AUZrUxayzQ4OLHC9mhnMkVPjjOfpEM4Es29iAdkzGbAYjbEXzm3ilUqwfpyvU+yD/t74F+KwICnDizN6g/n6uZN6dO5/mvOAeF6UbpzsNPioXWPlOLPcYsDClyKESkHoalTIlXGHLlI++1saNsTIa2Q== your_email@example.com 2 | -------------------------------------------------------------------------------- /schema/http-v1-batch-request-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema", 3 | "title": "Git LFS HTTPS Batch API v1 Request", 4 | "type": "object", 5 | "properties": { 6 | "operation": { 7 | "type": "string" 8 | }, 9 | "objects": { 10 | "type": "array", 11 | "items": { 12 | "type": "object", 13 | "properties": { 14 | "oid": { 15 | "type": "string" 16 | }, 17 | "size": { 18 | "type": "number" 19 | } 20 | }, 21 | "required": ["oid", "size"], 22 | "additionalProperties": false 23 | } 24 | } 25 | }, 26 | "required": ["objects", "operation"], 27 | "additionalProperties": false 28 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": [ 4 | 1, 5 | 4, 6 | {"SwitchCase": 1} 7 | ], 8 | "quotes": [ 9 | 2, 10 | "single" 11 | ], 12 | "linebreak-style": [ 13 | 2, 14 | "unix" 15 | ], 16 | "semi": [ 17 | 2, 18 | "always" 19 | ], 20 | "no-unused-vars": [ 21 | 1, 22 | {"vars": "all", "args": "none"} 23 | ], 24 | "no-empty": [ 25 | 1 26 | ], 27 | "no-console": [ 28 | 1 29 | ] 30 | }, 31 | "env": { 32 | "es6": true, 33 | "node": true, 34 | "browser": false 35 | }, 36 | "extends": "eslint:recommended" 37 | } -------------------------------------------------------------------------------- /lib/app.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var logger = require('morgan'); 3 | 4 | 5 | var routeBatch = require('./routes/batch'); 6 | var routeObjects = require('./routes/objects'); 7 | var routeVerify = require('./routes/verify'); 8 | 9 | var app = express(); 10 | 11 | 12 | // uncomment after placing your favicon in /public 13 | //app.use(favicon(__dirname + '/public/favicon.ico')); 14 | app.use(logger('dev')); 15 | 16 | 17 | routeBatch(app); 18 | routeObjects(app); 19 | routeVerify(app); 20 | 21 | // catch 404 and forward to error handler 22 | app.use(function(req, res, next) { 23 | var err = new Error('Not Found'); 24 | err.status = 404; 25 | next(err); 26 | }); 27 | 28 | app.use(function(err, req, res, next) { 29 | res.status(err.status || 500); 30 | res.json({ 31 | message: err.message 32 | }); 33 | }); 34 | 35 | 36 | module.exports = app; 37 | -------------------------------------------------------------------------------- /test/authenticator/none.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var should = require('chai').should(); 4 | 5 | var NoneAuthenticator = require('../../lib/authenticator/none'); 6 | 7 | 8 | describe('None Authenticator', function() { 9 | 10 | var authenticator; 11 | 12 | beforeEach(function() { 13 | authenticator = new NoneAuthenticator(); 14 | 15 | }); 16 | 17 | it('should always return true for canRead', function(done) { 18 | authenticator.canRead('a', 'b', null) 19 | .then(function(canRead) { 20 | canRead.should.equal(true); 21 | done(); 22 | }) 23 | .catch(done); 24 | }); 25 | 26 | it('should always return true for canWrite', function(done) { 27 | authenticator.canWrite('a', 'b', null) 28 | .then(function(canWrite) { 29 | canWrite.should.equal(true); 30 | done(); 31 | }) 32 | .catch(done); 33 | }); 34 | }); -------------------------------------------------------------------------------- /lib/authenticator/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Authenticator = require('./'); 4 | 5 | class TestAuthenticator extends Authenticator { 6 | 7 | 8 | constructor() { 9 | super(); 10 | } 11 | 12 | canRead(user, repo, authorization) { 13 | return new Promise(function(resolve) { 14 | resolve(TestAuthenticator.CAN_READ); 15 | }); 16 | } 17 | 18 | canWrite(user, repo, authorization) { 19 | return new Promise(function(resolve) { 20 | resolve(TestAuthenticator.CAN_WRITE); 21 | }); 22 | } 23 | 24 | checkSSHAuthorization(publicAlgo, publicData, signAlgo, blob, signature){ 25 | return new Promise(function(resolve) { 26 | if (TestAuthenticator.SSH_VALID) { 27 | resolve('TEST'); 28 | } else { 29 | resolve(); 30 | } 31 | 32 | }); 33 | 34 | } 35 | 36 | 37 | } 38 | 39 | TestAuthenticator.CAN_READ = true; 40 | TestAuthenticator.CAN_WRITE = true; 41 | TestAuthenticator.SSH_VALID = true; 42 | 43 | 44 | module.exports = TestAuthenticator; -------------------------------------------------------------------------------- /lib/routes/verify.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | var config = require('config'); 5 | var wrap = require('co-express'); 6 | var parse = require('co-body'); 7 | 8 | 9 | var Store = require('../store'); 10 | 11 | const STORE = Store.getStore(config.get('store.type'), config.get('store.options')); 12 | 13 | var checkJWT = require('./objects').checkJWT; 14 | 15 | 16 | 17 | module.exports = function(app) { 18 | app.post('/:user/:repo/objects/verify', checkJWT('verify'), wrap(function* (req, res, next) { 19 | try { 20 | var body = yield parse.json(req); 21 | 22 | var oid = body.oid; 23 | var size = body.size; 24 | if (!oid || !size) { 25 | return res.status(422).end(); 26 | } 27 | 28 | var objectSize = yield STORE.getSize(req.params.user, req.params.repo, oid); 29 | if (size !== objectSize) { 30 | return res.status(422).end(); 31 | } 32 | 33 | res.status(200).end(); 34 | } catch (err) { 35 | next(err); 36 | } 37 | 38 | })); 39 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-git-lfs", 3 | "version": "0.0.2", 4 | "license": "Apache-2.0", 5 | "description": "Git LFS server NodeJS implementation", 6 | "scripts": { 7 | "test": "make test-coveralls" 8 | }, 9 | "bin": { 10 | "node-git-lfs": "./git-lfs-server.js" 11 | }, 12 | "repository": "kzwang/node-git-lfs", 13 | "dependencies": { 14 | "aws-sdk": "^2.2.10", 15 | "btoa": "^1.1.2", 16 | "co": "^4.6.0", 17 | "co-body": "^4.0.0", 18 | "co-express": "^1.2.1", 19 | "config": "^1.16.0", 20 | "express": "~4.13.3", 21 | "jsonschema": "^1.0.2", 22 | "jsonwebtoken": "^5.4.0", 23 | "lodash": "^3.10.1", 24 | "mongodb": "^2.0.46", 25 | "mongoose": "^4.1.11", 26 | "morgan": "~1.6.1", 27 | "ms": "^0.7.1", 28 | "ssh2": "^0.4.11", 29 | "winston": "^1.1.0" 30 | }, 31 | "devDependencies": { 32 | "chai": "^3.3.0", 33 | "chai-string": "^1.1.3", 34 | "co-mocha": "^1.1.2", 35 | "coveralls": "^2.11.4", 36 | "istanbul": "^0.3.22", 37 | "mocha": "^2.3.3", 38 | "mocha-lcov-reporter": "^1.0.0", 39 | "s3rver": "0.0.8", 40 | "supertest": "^1.1.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /config/custom-environment-variables.json: -------------------------------------------------------------------------------- 1 | { 2 | "base_url": "LFS_BASE_URL", 3 | "port": "LFS_PORT", 4 | "private": "LFS_PRIVATE", 5 | "jwt": { 6 | "algorithm": "LFS_JWT_ALGORITHM", 7 | "secret": "LFS_JWT_SECRET", 8 | "issuer": "LFS_JWT_ISSUER", 9 | "expiresIn": "LFS_JWT_EXPIRES" 10 | }, 11 | "store": { 12 | "type": "LFS_STORE_TYPE", 13 | "options": { 14 | "access_key": "AWS_ACCESS_KEY", 15 | "secret_key": "AWS_SECRET_KEY", 16 | "bucket": "LFS_STORE_S3_BUCKET", 17 | "endpoint": "LFS_STORE_S3_ENDPOINT", 18 | "region": "LFS_STORE_S3_REGION", 19 | "storage_class": "LFS_STORE_S3_STORAGE_CLASS", 20 | "grid_connection": "LFS_STORE_GRID_CONNECTION" 21 | } 22 | }, 23 | "authenticator": { 24 | "type": "LFS_AUTHENTICATOR_TYPE", 25 | "options": { 26 | "username": "LFS_AUTHENTICATOR_USERNAME", 27 | "password": "LFS_AUTHENTICATOR_PASSWORD", 28 | "client_public_key": "LFS_AUTHENTICATOR_CLIENT_PUBLIC_KEY" 29 | } 30 | }, 31 | "ssh": { 32 | "enabled": "LFS_SSH_ENABLED", 33 | "port": "LFS_SSH_PORT", 34 | "ip": "LFS_SSH_IP", 35 | "key": { 36 | "public": "LFS_SSH_PUBLIC_KEY", 37 | "private": "LFS_SSH_PRIVATE_KEY" 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /git-lfs-server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | var app = require('./lib/app'); 6 | var http = require('http'); 7 | var config = require('config'); 8 | var logger = require('winston'); 9 | 10 | const PORT = parseInt(config.get('port'), 10) || 3000; 11 | const SSH_ENABLED = config.get('ssh.enabled'); 12 | 13 | app.set('port', PORT); 14 | 15 | /** 16 | * Create HTTP server. 17 | */ 18 | var server = http.createServer(app); 19 | 20 | /** 21 | * Listen on provided port, on all network interfaces. 22 | */ 23 | 24 | server.listen(PORT); 25 | server.on('error', onError); 26 | server.on('listening', onListening); 27 | 28 | 29 | function onError(error) { 30 | if (error.syscall !== 'listen') { 31 | throw error; 32 | } 33 | 34 | // handle specific listen errors with friendly messages 35 | switch (error.code) { 36 | case 'EACCES': 37 | logger.error('Port ' + PORT + ' requires elevated privileges'); 38 | process.exit(1); 39 | break; 40 | case 'EADDRINUSE': 41 | logger.error('Port ' + PORT + ' is already in use'); 42 | process.exit(1); 43 | break; 44 | default: 45 | throw error; 46 | } 47 | } 48 | 49 | function onListening() { 50 | logger.info('Listening LFS on port ' + server.address().port); 51 | } 52 | 53 | if (SSH_ENABLED) { 54 | var sshServer = require('./lib/ssh_server'); 55 | const SSH_PORT = parseInt(config.get('ssh.port')); 56 | const SSH_IP = config.get('ssh.ip'); 57 | sshServer.listen(SSH_PORT, SSH_IP, function() { 58 | logger.info('Listening SSH on port ' + this.address().port); 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 28 | node_modules 29 | 30 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio 31 | 32 | *.iml 33 | 34 | ## Directory-based project format: 35 | .idea/ 36 | # if you remove the above rule, at least ignore the following: 37 | 38 | # User-specific stuff: 39 | # .idea/workspace.xml 40 | # .idea/tasks.xml 41 | # .idea/dictionaries 42 | # .idea/shelf 43 | 44 | # Sensitive or high-churn files: 45 | # .idea/dataSources.ids 46 | # .idea/dataSources.xml 47 | # .idea/sqlDataSources.xml 48 | # .idea/dynamic.xml 49 | # .idea/uiDesigner.xml 50 | 51 | # Gradle: 52 | # .idea/gradle.xml 53 | # .idea/libraries 54 | 55 | # Mongo Explorer plugin: 56 | # .idea/mongoSettings.xml 57 | 58 | ## File-based project format: 59 | *.ipr 60 | *.iws 61 | 62 | ## Plugin-specific files: 63 | 64 | # IntelliJ 65 | /out/ 66 | 67 | # mpeltonen/sbt-idea plugin 68 | .idea_modules/ 69 | 70 | # JIRA plugin 71 | atlassian-ide-plugin.xml 72 | 73 | # Crashlytics plugin (for Android Studio and IntelliJ) 74 | com_crashlytics_export_strings.xml 75 | crashlytics.properties 76 | crashlytics-build.properties 77 | fabric.properties 78 | 79 | # Local config file 80 | config/local.json -------------------------------------------------------------------------------- /lib/routes/objects.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var config = require('config'); 4 | var wrap = require('co-express'); 5 | var jwt = require('jsonwebtoken'); 6 | 7 | var Store = require('../store'); 8 | 9 | 10 | const STORE = Store.getStore(config.get('store.type'), config.get('store.options')); 11 | 12 | const JWT_CONFIG = config.get('jwt'); 13 | 14 | var checkJWT = function(action) { 15 | return wrap(function*(req, res, next) { 16 | let user = req.params.user; 17 | let repo = req.params.repo; 18 | let oid = req.params.oid; 19 | 20 | let authorization = req.header('Authorization'); 21 | 22 | if (!authorization || !authorization.startsWith('JWT ')) { 23 | return res.status(401).end(); 24 | } 25 | 26 | authorization = authorization.substring(4, authorization.length); 27 | try { 28 | var decoded = jwt.verify(authorization, JWT_CONFIG.secret, {issuer: JWT_CONFIG.issuer}); 29 | if (decoded.action != action || decoded.user != user || decoded.repo != repo || (decoded.oid && decoded.oid != oid)) { 30 | return res.status(403).end(); 31 | } 32 | } catch(err) { 33 | // Any JWT error is considered as Forbidden 34 | return res.status(403).end(); 35 | } 36 | 37 | next(); 38 | 39 | }); 40 | }; 41 | 42 | var exports = module.exports = function(app) { 43 | 44 | app.put('/:user/:repo/objects/:oid', checkJWT('upload'), wrap(function* (req, res, next) { 45 | yield STORE.put(req.params.user, req.params.repo, req.params.oid, req); 46 | res.sendStatus(200); 47 | })); 48 | 49 | app.get('/:user/:repo/objects/:oid', checkJWT('download'), wrap(function* (req, res, next) { 50 | var size = yield STORE.getSize(req.params.user, req.params.repo, req.params.oid); 51 | if (size < 0) { 52 | return res.sendStatus(404); 53 | } 54 | res.set('Content-Length', size); 55 | var dataStream = yield STORE.get(req.params.user, req.params.repo, req.params.oid); 56 | dataStream.pipe(res); 57 | })); 58 | }; 59 | 60 | exports.checkJWT = checkJWT; -------------------------------------------------------------------------------- /lib/authenticator/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Abstract Authenticator, should use subclass 5 | */ 6 | class Authenticator { 7 | 8 | /** 9 | * Register authenticator 10 | * 11 | * @param {String} name, name of the authenticator 12 | * @param {Authenticator} authenticator, class of the Authenticator 13 | */ 14 | static registerAuthenticator(name, authenticator){ 15 | this.authenticators[name] = authenticator; 16 | } 17 | 18 | /** 19 | * Get registered authenticator by name 20 | * @param {String} name 21 | * @param {Object} options, optional 22 | * @returns {Authenticator} instance of authenticator 23 | */ 24 | static getAuthenticator(name, options) { 25 | return new this.authenticators[name](options); 26 | } 27 | 28 | /** 29 | * Check request has read access or not 30 | * @param {String} user 31 | * @param {String} repo 32 | * @param {String} authorization, Authorization header 33 | * @returns {Promise} 34 | */ 35 | canRead(user, repo, authorization) { 36 | 37 | } 38 | 39 | /** 40 | * Check request has read access or not 41 | * @param {String} user 42 | * @param {String} repo 43 | * @param {String} authorization, Authorization header 44 | * @returns {Promise} 45 | */ 46 | canWrite(user, repo, authorization) { 47 | 48 | } 49 | 50 | /** 51 | * Check ssh authorization and return HTTP Authorization header if success 52 | * @param {String} publicAlgo, public key algorithm 53 | * @param {Buffer} publicData, public key data 54 | * @param {String} signAlgo, signature algorithm 55 | * @param {Buffer} blob 56 | * @param {Buffer} signature 57 | * @returns {Promise} 58 | */ 59 | checkSSHAuthorization(publicAlgo, publicData, signAlgo, blob, signature){ 60 | 61 | } 62 | 63 | } 64 | 65 | Authenticator.authenticators = {}; 66 | 67 | module.exports = Authenticator; 68 | 69 | Authenticator.registerAuthenticator('basic', require('./basic')); 70 | Authenticator.registerAuthenticator('none', require('./none')); 71 | Authenticator.registerAuthenticator('test', require('./test')); -------------------------------------------------------------------------------- /lib/store/s3_store.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var AWS = require('aws-sdk'); 4 | 5 | var Store = require('./'); 6 | 7 | class S3Store extends Store { 8 | 9 | /** 10 | * Construct S3Store instance 11 | * @param {Object} options, optional 12 | */ 13 | constructor(options) { 14 | super(); 15 | this._options = options || {}; 16 | 17 | let s3_config = { 18 | accessKeyId: this._options.access_key, 19 | secretAccessKey: this._options.secret_key 20 | }; 21 | 22 | // optional S3 endpoint 23 | if (this._options.endpoint) { 24 | s3_config.endpoint = this._options.endpoint; 25 | s3_config.s3ForcePathStyle = true; 26 | } 27 | 28 | // optional S3 region 29 | if (this._options.region) { 30 | s3_config.region = this._options.region; 31 | } 32 | 33 | this._s3 = new AWS.S3(s3_config); 34 | 35 | } 36 | 37 | 38 | put(user, repo, oid, stream) { 39 | var self = this; 40 | return new Promise(function(resolve, reject) { 41 | let storageClass = self._options.storage_class || 'STANDARD'; 42 | let params = { 43 | Bucket: self._options.bucket, 44 | Key: S3Store._getKey(user, repo, oid), 45 | Body: stream, 46 | StorageClass: storageClass 47 | }; 48 | self._s3.upload(params, function(err, data) { 49 | if (err) { 50 | return reject(err); 51 | } 52 | resolve(data); 53 | }); 54 | }); 55 | 56 | } 57 | 58 | 59 | get(user, repo, oid) { 60 | var self = this; 61 | return new Promise(function(resolve) { 62 | var params = {Bucket: self._options.bucket, Key: S3Store._getKey(user, repo, oid)}; 63 | resolve(self._s3.getObject(params).createReadStream()); 64 | }); 65 | 66 | } 67 | 68 | 69 | getSize(user, repo, oid) { 70 | var self = this; 71 | return new Promise(function(resolve, reject) { 72 | var params = {Bucket: self._options.bucket, Key: S3Store._getKey(user, repo, oid)}; 73 | self._s3.headObject(params, function (err, data) { 74 | if (err) { 75 | if (err.statusCode === 404) { 76 | return resolve(-1); 77 | } 78 | reject(err); 79 | } 80 | resolve(Number(data.ContentLength)); 81 | }); 82 | }); 83 | } 84 | 85 | 86 | static _getKey(user, repo, oid) { 87 | return `${user}/${repo}/${oid}`; 88 | } 89 | 90 | } 91 | 92 | 93 | 94 | module.exports = S3Store; -------------------------------------------------------------------------------- /lib/ssh_server.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var config = require('config'); 3 | var ssh2 = require('ssh2'); 4 | var logger = require('winston'); 5 | 6 | 7 | var Authenticator = require('./authenticator'); 8 | 9 | const AUTHENTICATOR = Authenticator.getAuthenticator(config.get('authenticator.type'), config.get('authenticator.options')); 10 | 11 | const BASE_URL = config.get('base_url'); 12 | 13 | module.exports = new ssh2.Server({ 14 | privateKey: fs.readFileSync(config.get('ssh.key.private')) 15 | }, function(client) { 16 | logger.info('SSH Client connected'); 17 | 18 | client.on('authentication', function(ctx) { 19 | if (ctx.username != 'git') { 20 | return ctx.reject(); 21 | } 22 | if (ctx.method === 'publickey') { 23 | if (ctx.signature) { 24 | AUTHENTICATOR.checkSSHAuthorization(ctx.key.algo, ctx.key.data, ctx.sigAlgo, ctx.blob, ctx.signature) 25 | .then(function(authorization) { 26 | if (authorization) { 27 | client.user = { 28 | username: ctx.username, 29 | authorization: authorization 30 | }; 31 | ctx.accept(); 32 | } else { 33 | ctx.reject(); 34 | } 35 | }) 36 | .catch(function(err) { 37 | ctx.reject(); 38 | }); 39 | } else { 40 | // if no signature present, that means the client is just checking 41 | // the validity of the given public key 42 | ctx.accept(); 43 | } 44 | } else { 45 | ctx.reject(); 46 | } 47 | 48 | }).on('ready', function() { 49 | logger.info('SSH Client authenticated'); 50 | 51 | client.on('session', function(accept, reject) { 52 | var session = accept(); 53 | session.once('exec', function(accept, reject, info) { 54 | var stream = accept(); 55 | var sendMessage = function(message) { 56 | stream.write(message); 57 | stream.exit(0); 58 | stream.end(); 59 | }; 60 | 61 | var command = info.command; 62 | var commands = command.split(' '); 63 | if (commands.length < 3 || commands[0].toLowerCase() != 'git-lfs-authenticate' || (commands[2] != 'download' && commands[2] != 'upload')) { 64 | return sendMessage('Unknown command: ' + command); 65 | } 66 | 67 | 68 | var res = { 69 | header: { 70 | Authorization: client.user.authorization 71 | }, 72 | href: `${BASE_URL}${commands[1]}` 73 | 74 | }; 75 | sendMessage(JSON.stringify(res)); 76 | 77 | }); 78 | }); 79 | }).on('end', function() { 80 | logger.info('SSH Client disconnected'); 81 | }); 82 | }); -------------------------------------------------------------------------------- /lib/store/grid_store.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var co = require('co'); 4 | var mongodb = require('mongodb'); 5 | var logger = require('winston'); 6 | 7 | var MongoClient = mongodb.MongoClient, 8 | MongoGridStore = mongodb.GridStore; 9 | 10 | 11 | var Store = require('./'); 12 | 13 | 14 | 15 | class GridStore extends Store { 16 | 17 | /** 18 | * Construct GridStore instance 19 | * @param {Object} options, optional 20 | */ 21 | constructor(options) { 22 | super(); 23 | this._options = options || {}; 24 | } 25 | 26 | 27 | put(user, repo, oid, stream) { 28 | var self = this; 29 | return new Promise(function(resolve, reject) { 30 | co(function*(){ 31 | try { 32 | var db = yield self._getConnection(); 33 | 34 | var gs = new MongoGridStore(db, GridStore._getKey(user, repo, oid), 'w'); 35 | 36 | yield gs.open(); 37 | stream.on('data', co.wrap(function *(chunk) { 38 | yield gs.write(chunk); 39 | })); 40 | 41 | stream.on('error', function(err) { 42 | reject(err); 43 | }); 44 | 45 | 46 | stream.on('end', co.wrap(function* () { 47 | yield gs.close(); 48 | resolve(); 49 | })); 50 | } catch(err) { 51 | reject(err); 52 | } 53 | 54 | }); 55 | }); 56 | 57 | } 58 | 59 | 60 | get(user, repo, oid) { 61 | var self = this; 62 | return co(function*(){ 63 | var db = yield self._getConnection(); 64 | var gs = new MongoGridStore(db, GridStore._getKey(user, repo, oid), 'r'); 65 | yield gs.open(); 66 | return gs.stream(); 67 | }); 68 | } 69 | 70 | 71 | getSize(user, repo, oid) { 72 | var self = this; 73 | return co(function*() { 74 | var db = yield self._getConnection(); 75 | var object = yield db.collection('fs.files').find({filename: GridStore._getKey(user, repo, oid)}).limit(1).next(); 76 | if (!object) { 77 | return -1; 78 | } 79 | return object.length; 80 | }); 81 | } 82 | 83 | 84 | static _getKey(user, repo, oid) { 85 | return `${user}/${repo}/${oid}`; 86 | } 87 | 88 | *_getConnection() { 89 | var self = this; 90 | if (!this._db) { 91 | this._db = yield MongoClient.connect(self._options.grid_connection); 92 | try { 93 | yield this._db.collection('fs.files').createIndex({filename: 1}, {unique: true, background: true}); 94 | } catch(err){ 95 | logger.info('Unable to create filename index, maybe already exist', err); 96 | } 97 | 98 | this._db.on('close', function() { 99 | self._db = null; 100 | }); 101 | } 102 | return this._db; 103 | 104 | } 105 | 106 | 107 | } 108 | 109 | module.exports = GridStore; -------------------------------------------------------------------------------- /ssh/client.pri: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | 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 51 | -----END RSA PRIVATE KEY----- 52 | -------------------------------------------------------------------------------- /ssh/server.pri: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | 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 51 | -----END RSA PRIVATE KEY----- 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node Git LFS 2 | [![Build Status](https://travis-ci.org/kzwang/node-git-lfs.svg?branch=master)](https://travis-ci.org/kzwang/node-git-lfs) 3 | [![Coverage Status](https://coveralls.io/repos/kzwang/node-git-lfs/badge.svg?branch=master&service=github)](https://coveralls.io/github/kzwang/node-git-lfs?branch=master) 4 | [![Code Climate](https://codeclimate.com/github/kzwang/node-git-lfs/badges/gpa.svg)](https://codeclimate.com/github/kzwang/node-git-lfs) 5 | 6 | A NodeJS implementation of [Git LFS](https://git-lfs.github.com/) Server. 7 | 8 | ## Installation 9 | ```shell 10 | npm install node-git-lfs 11 | ``` 12 | 13 | ## Features 14 | 15 | - Support [Git LFS v1 Batch API](https://github.com/github/git-lfs/blob/master/docs/api/http-v1-batch.md) 16 | - Support [SSH Authentication](https://github.com/github/git-lfs/tree/master/docs/api#authentication) 17 | - Multiple store supported - currently `AWS S3` and `MongoDB GridFS` 18 | - Multiple authentication method support - currently `basic` and `none` 19 | - Use [JWT](http://jwt.io) to secure `download`, `upload` and `verify` endpoints 20 | - Option to directly upload to and download from AWS S3 21 | - Use SHA256 checksum when upload directly to AWS S3 22 | 23 | ## Configuration 24 | All configurations can be done via environment variable or configuration file 25 | 26 | #### Environment Variables 27 | 28 | - `LFS_BASE_URL` - URL of the LFS server - **required** 29 | - `LFS_PORT` - HTTP portal of the LFS server, defaults to `3000` - **required** 30 | - `LFS_STORE_TYPE` - Object store type, can be either `s3` (for AWS S3), `s3_direct` (for direct upload and download from AWS S3) or `grid` (for MongoDB GridFS), defaults to `s3` - **required** 31 | - `LFS_AUTHENTICATOR_TYPE` - Authenticator type, can be `basic` (for basic username and password), `none` (for no authentication), defaults to `none` - **required** 32 | - `LFS_JWT_ALGORITHM` - JWT signature algorithm, defaults to `HS256` 33 | - `LFS_JWT_SECRET` - JWT signature secret - **required** 34 | - `LFS_JWT_ISSUER` - Issuer of the JWT token, defaults to `node-git-lfs` 35 | - `LFS_JWT_EXPIRES` - JWT token expire time, defaults to `30m` 36 | 37 | If **storage type** is `s3` or `s3_direct`: 38 | 39 | - `AWS_ACCESS_KEY` - AWS access key - **required** 40 | - `AWS_SECRET_KEY` - AWS secret key - **required** 41 | - `LFS_STORE_S3_BUCKET` - AWS S3 bucket - **required** 42 | - `LFS_STORE_S3_ENDPOINT` - AWS S3 endpoint, normally this will be set by region 43 | - `LFS_STORE_S3_REGION` - AWS S3 region 44 | - `LFS_STORE_S3_STORAGE_CLASS` - AWS S3 storage class, can be `STANDARD`, `STANDARD_IA` or `REDUCED_REDUNDANCY`, defaults to `STANDARD` 45 | 46 | If **storage type** is `grid`: 47 | 48 | - `LFS_STORE_GRID_CONNECTION` - MongoDB connection URL - **required** 49 | 50 | If **authenticator type** is `basic`: 51 | 52 | - `LFS_AUTHENTICATOR_USERNAME` - Username - **required** 53 | - `LFS_AUTHENTICATOR_PASSWORD` - Password - **required** 54 | - `LFS_AUTHENTICATOR_CLIENT_PUBLIC_KEY` - Location of the client's public key 55 | 56 | 57 | ##### SSH Environment Variables 58 | 59 | - `LFS_SSH_ENABLED` - Enable SSH server, defaults to `true` 60 | - `LFS_SSH_PORT` - SSH server port, defaults to `2222` 61 | - `LFS_SSH_IP` - SSH server bind IP, defaults to `0.0.0.0` 62 | - `LFS_SSH_PUBLIC_KEY` - SSH server public key - **required** if SSH is enabled 63 | - `LFS_SSH_PRIVATE_KEY` - SSH server private key - **required** if SSH is enabled -------------------------------------------------------------------------------- /lib/authenticator/basic.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | var fs = require('fs'); 5 | var crypto = require('crypto'); 6 | var ssh_utils = require('ssh2').utils; 7 | 8 | var Authenticator = require('./'); 9 | 10 | /** 11 | * RegExp for basic auth credentials 12 | * 13 | * credentials = auth-scheme 1*SP token68 14 | * auth-scheme = "Basic" ; case insensitive 15 | * token68 = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"=" 16 | * @private 17 | */ 18 | const CREDENTIALS_REG_EXP = /^ *(?:[Bb][Aa][Ss][Ii][Cc]) +([A-Za-z0-9\-\._~\+\/]+=*) *$/; 19 | 20 | 21 | /** 22 | * RegExp for basic auth user/pass 23 | * 24 | * user-pass = userid ":" password 25 | * userid = * 26 | * password = *TEXT 27 | * @private 28 | */ 29 | const USER_PASS_REG_EXP = /^([^:]*):(.*)$/; 30 | 31 | 32 | class Credentials { 33 | constructor(username, password) { 34 | this.username = username; 35 | this.password = password; 36 | } 37 | } 38 | 39 | class BasicAuthenticator extends Authenticator { 40 | 41 | /** 42 | * Construct BasicAuthenticator instance 43 | * @param {Object} options, optional 44 | */ 45 | constructor(options) { 46 | super(); 47 | this._options = options || {}; 48 | var clientPublicKeyPath = _.get(options, 'client_public_key'); 49 | if (clientPublicKeyPath) { 50 | this.clientPublicKey = ssh_utils.genPublicKey(ssh_utils.parseKey(fs.readFileSync(clientPublicKeyPath))); 51 | } 52 | } 53 | 54 | canRead(user, repo, authorization) { 55 | var self = this; 56 | return new Promise(function(resolve, reject) { 57 | var credential = BasicAuthenticator._getCredential(authorization) || {}; 58 | resolve(self._options.username === credential.username && self._options.password === credential.password); 59 | }); 60 | } 61 | 62 | canWrite(user, repo, authorization) { 63 | return this.canRead(user, repo, authorization); 64 | } 65 | 66 | checkSSHAuthorization(publicAlgo, publicData, signAlgo, blob, signature){ 67 | var self = this; 68 | return new Promise(function(resolve, reject) { 69 | if (!self.clientPublicKey) { 70 | return resolve(); 71 | } 72 | 73 | var verifier = crypto.createVerify(signAlgo); 74 | verifier.update(blob); 75 | if (verifier.verify(self.clientPublicKey.publicOrig, signature, 'binary')){ 76 | var encodedUserPass = new Buffer(self._options.username + ':' + self._options.password).toString('base64'); 77 | return resolve(`Basic ${encodedUserPass}`); 78 | } else { 79 | return resolve(); 80 | } 81 | 82 | }); 83 | 84 | } 85 | 86 | static _getCredential(authorization) { 87 | var match = CREDENTIALS_REG_EXP.exec(authorization || ''); 88 | 89 | if (!match) { 90 | return; 91 | } 92 | 93 | var userPass = USER_PASS_REG_EXP.exec(BasicAuthenticator._decodeBase64(match[1])); 94 | 95 | if (!userPass) { 96 | return; 97 | } 98 | 99 | return new Credentials(userPass[1], userPass[2]); 100 | } 101 | 102 | static _decodeBase64(str) { 103 | return new Buffer(str, 'base64').toString(); 104 | } 105 | 106 | } 107 | 108 | module.exports = BasicAuthenticator; -------------------------------------------------------------------------------- /test/store/grid_store.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var stream = require('stream'); 4 | var config = require('config'); 5 | 6 | var MongoClient = require('mongodb').MongoClient, 7 | MongoGridStore = require('mongodb').GridStore, 8 | ObjectID = require('mongodb').ObjectID; 9 | 10 | var chai = require('chai'); 11 | chai.use(require('chai-string')); 12 | 13 | var should = chai.should(); 14 | 15 | var GridStore = require('../../lib/store/grid_store'); 16 | 17 | 18 | 19 | describe('Grid Store', function() { 20 | 21 | var store, db; 22 | 23 | beforeEach(function* () { 24 | store = new GridStore(config.get('store.options')); 25 | db = yield store._getConnection(); 26 | }); 27 | 28 | afterEach(function* () { 29 | yield db.dropDatabase(); 30 | }); 31 | 32 | 33 | it('should be able to put object', function*() { 34 | let body = 'testbody'; 35 | let s = new stream.Readable(); 36 | s.push(body); 37 | s.push(null); 38 | 39 | yield store.put('testuser', 'testrepo', 'testid', s); 40 | 41 | var gs = new MongoGridStore(db, 'testuser/testrepo/testid', 'r'); 42 | 43 | yield gs.open(); 44 | var data = yield gs.read(body.length); 45 | data.toString().should.equal(body); 46 | 47 | yield gs.close(); 48 | 49 | }); 50 | 51 | it('should be able to get object', function*(done) { 52 | try { 53 | let body = 'testbody'; 54 | 55 | var gs = new MongoGridStore(db, 'testuser/testrepo/testid', 'w'); 56 | 57 | yield gs.open(); 58 | yield gs.write(body); 59 | yield gs.close(); 60 | 61 | var s = yield store.get('testuser', 'testrepo', 'testid'); 62 | s.setEncoding('utf8'); 63 | let string = ''; 64 | s.on('data',function(chunk){ 65 | string += chunk; 66 | }); 67 | 68 | s.on('end',function(){ 69 | string.should.equal(body); 70 | done(); 71 | }); 72 | } catch(err) { 73 | done(err); 74 | } 75 | 76 | 77 | }); 78 | 79 | 80 | describe('getSize', function() { 81 | it('should return object size', function* () { 82 | let body = 'testbody'; 83 | 84 | var gs = new MongoGridStore(db, 'testuser/testrepo/testid', 'w'); 85 | 86 | yield gs.open(); 87 | yield gs.write(body); 88 | yield gs.close(); 89 | 90 | var size = yield store.getSize('testuser', 'testrepo', 'testid'); 91 | size.should.equal(body.length); 92 | 93 | 94 | }); 95 | 96 | it('should return -1 for non exist object', function* () { 97 | var size = yield store.getSize('testuser', 'testrepo', 'testid'); 98 | size.should.equal(-1); 99 | }); 100 | }); 101 | 102 | describe('exist', function() { 103 | it('should return false for non exist object', function* () { 104 | var exist = yield store.exist('testuser', 'testrepo', 'not_exist'); 105 | exist.should.equal(false); 106 | 107 | }); 108 | 109 | it('should return true for exist object', function* () { 110 | let body = 'testbody'; 111 | 112 | var gs = new MongoGridStore(db, 'testuser/testrepo/testid', 'w'); 113 | 114 | yield gs.open(); 115 | yield gs.write(body); 116 | yield gs.close(); 117 | 118 | var exist = yield store.exist('testuser', 'testrepo', 'testid'); 119 | 120 | exist.should.equal(true); 121 | }); 122 | }); 123 | 124 | 125 | 126 | }); -------------------------------------------------------------------------------- /test/store/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var config = require('config'); 4 | 5 | var jwt = require('jsonwebtoken'); 6 | 7 | var chai = require('chai'); 8 | chai.use(require('chai-string')); 9 | 10 | var should = chai.should(); 11 | 12 | var expect = chai.expect; 13 | 14 | var Store = require('../../lib/store'); 15 | 16 | const BASE_URL = config.get('base_url'); 17 | 18 | const JWT_CONFIG = config.get('jwt'); 19 | 20 | const TEST_USER = 'testUser'; 21 | const TEST_REPO = 'testRepo'; 22 | const TEST_OID= 'testoid'; 23 | 24 | describe('Abstract Store', function() { 25 | 26 | var store; 27 | 28 | beforeEach(function () { 29 | store = new Store(); 30 | 31 | }); 32 | 33 | 34 | it('should throw error for put', function() { 35 | expect(store.put).to.throw(Error); 36 | }); 37 | 38 | it('should throw error for get', function() { 39 | expect(store.get).to.throw(Error); 40 | }); 41 | 42 | it('should throw error for getSize', function() { 43 | expect(store.getSize).to.throw(Error); 44 | }); 45 | 46 | it('should return upload action', function() { 47 | var action = store.getUploadAction(TEST_USER, TEST_REPO, TEST_OID, 0); 48 | action.href.should.equal(`${BASE_URL}${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`); 49 | should.exist(action.expires_at); 50 | should.exist(action.header); 51 | action.header['Authorization'].should.startWith('JWT '); 52 | 53 | let authorization = action.header['Authorization']; 54 | let token = authorization.substring(4, authorization.length); 55 | let decoded = jwt.verify(token, JWT_CONFIG.secret, {issuer: JWT_CONFIG.issuer}); 56 | decoded.user.should.equal(TEST_USER); 57 | decoded.repo.should.equal(TEST_REPO); 58 | decoded.oid.should.equal(TEST_OID); 59 | decoded.action.should.equal('upload'); 60 | should.exist(decoded.iat); 61 | should.exist(decoded.exp); 62 | }); 63 | 64 | it('should return download action', function() { 65 | var action = store.getDownloadAction(TEST_USER, TEST_REPO, TEST_OID, 0); 66 | action.href.should.equal(`${BASE_URL}${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`); 67 | should.exist(action.expires_at); 68 | should.exist(action.header); 69 | action.header['Authorization'].should.startWith('JWT '); 70 | 71 | let authorization = action.header['Authorization']; 72 | let token = authorization.substring(4, authorization.length); 73 | let decoded = jwt.verify(token, JWT_CONFIG.secret, {issuer: JWT_CONFIG.issuer}); 74 | decoded.user.should.equal(TEST_USER); 75 | decoded.repo.should.equal(TEST_REPO); 76 | decoded.oid.should.equal(TEST_OID); 77 | decoded.action.should.equal('download'); 78 | should.exist(decoded.iat); 79 | should.exist(decoded.exp); 80 | }); 81 | 82 | it('should return verify action', function() { 83 | var action = store.getVerifyAction(TEST_USER, TEST_REPO, TEST_OID, 0); 84 | action.href.should.equal(`${BASE_URL}${TEST_USER}/${TEST_REPO}/objects/verify`); 85 | should.exist(action.expires_at); 86 | should.exist(action.header); 87 | action.header['Authorization'].should.startWith('JWT '); 88 | 89 | let authorization = action.header['Authorization']; 90 | let token = authorization.substring(4, authorization.length); 91 | let decoded = jwt.verify(token, JWT_CONFIG.secret, {issuer: JWT_CONFIG.issuer}); 92 | decoded.user.should.equal(TEST_USER); 93 | decoded.repo.should.equal(TEST_REPO); 94 | decoded.action.should.equal('verify'); 95 | should.exist(decoded.iat); 96 | should.exist(decoded.exp); 97 | should.not.exist(decoded.oid); 98 | }); 99 | 100 | }); -------------------------------------------------------------------------------- /lib/store/s3_direct_store.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var AWS = require('aws-sdk'); 4 | var URL = require('url'); 5 | var ms = require('ms'); 6 | 7 | var S3Store = require('./s3_store'); 8 | 9 | const AWS_EXPIRE_TIME = '15m'; 10 | 11 | class S3DirectStore extends S3Store { 12 | 13 | /** 14 | * Construct S3DirectStore instance 15 | * @param {Object} options, optional 16 | */ 17 | constructor(options) { 18 | super(options); 19 | this._options = options || {}; 20 | } 21 | 22 | 23 | getUploadAction(user, repo, oid, size) { 24 | var resource = this._getResource(user, repo, oid); 25 | 26 | var url = this._getURL(resource); 27 | 28 | let storageClass = this._options.storage_class || 'STANDARD'; 29 | 30 | var headers = { 31 | 'Host': url.hostname, 32 | 'Date': new Date().toUTCString(), 33 | 'Content-Length': String(size), 34 | 'Content-Type': 'application/octet-stream', 35 | 'x-amz-content-sha256': oid, 36 | 'x-amz-storage-class': storageClass 37 | }; 38 | 39 | this._addAuthorizationHeader(headers, 'PUT', resource); 40 | 41 | return { 42 | href: url.href, 43 | expires_at: S3DirectStore._getExpireTime(), 44 | header: headers 45 | }; 46 | } 47 | 48 | getDownloadAction(user, repo, oid, size) { 49 | var resource = this._getResource(user, repo, oid); 50 | var url = this._getURL(resource); 51 | 52 | var headers = { 53 | 'Host': url.hostname, 54 | 'Date': new Date().toUTCString() 55 | }; 56 | 57 | this._addAuthorizationHeader(headers, 'GET', resource); 58 | 59 | return { 60 | href: url.href, 61 | expires_at: S3DirectStore._getExpireTime(), 62 | header: headers 63 | }; 64 | 65 | } 66 | 67 | _getResource(user, repo, oid) { 68 | return `/${this._options.bucket}/${user}/${repo}/${oid}`; 69 | } 70 | 71 | _getEndpoint() { 72 | var endpoint = this._options.endpoint; 73 | if (endpoint) { 74 | return endpoint; 75 | } 76 | var region = this._options.region; 77 | if (!region || region.toLowerCase === 'us-east-1') { 78 | return 'https://s3.amazonaws.com'; 79 | } else { 80 | return `https://s3-${region}.amazonaws.com`; 81 | } 82 | 83 | } 84 | 85 | _getURL(resource) { 86 | var endpoint = this._getEndpoint(); 87 | var urlStr = endpoint; 88 | if (!urlStr.endsWith('/')) { 89 | urlStr = urlStr + '/'; 90 | } 91 | urlStr = urlStr + resource.substring(1, resource.length); 92 | return URL.parse(urlStr); 93 | } 94 | 95 | _addAuthorizationHeader(headers, method, resource){ 96 | 97 | var stringToSign = this._getStringToSign(headers, method, resource); 98 | 99 | var signitureOfHeaders = this._getSignature(this._options.secret_key, stringToSign); 100 | 101 | headers.Authorization = 'AWS ' + this._options.access_key + ':' + signitureOfHeaders; 102 | } 103 | 104 | _getStringToSign(headers, method, canonicalizedResource){ 105 | var parts = []; 106 | parts.push(method); 107 | parts.push(headers['Content-MD5'] || ''); 108 | parts.push(headers['Content-Type'] || ''); 109 | 110 | parts.push(headers.Date); 111 | 112 | var amzHeaders = this._getCanonicalizedAmzHeaders(headers); 113 | if (amzHeaders) parts.push(amzHeaders); 114 | parts.push(canonicalizedResource); 115 | 116 | return parts.join('\n'); 117 | 118 | } 119 | 120 | _getCanonicalizedAmzHeaders(headers){ 121 | 122 | var amzHeaders = []; 123 | 124 | AWS.util.each(headers, function (name) { 125 | if (name.match(/^x-amz-/i)) 126 | amzHeaders.push(name); 127 | }); 128 | 129 | amzHeaders.sort(function (a, b) { 130 | return a.toLowerCase() < b.toLowerCase() ? -1 : 1; 131 | }); 132 | 133 | var parts = []; 134 | AWS.util.arrayEach.call(this, amzHeaders, function (name) { 135 | parts.push(name.toLowerCase() + ':' + String(headers[name])); 136 | }); 137 | 138 | return parts.join('\n'); 139 | } 140 | 141 | _getSignature(secretKey, stringToSign) { 142 | return AWS.util.crypto.hmac(secretKey, stringToSign, 'base64', 'sha1'); 143 | } 144 | 145 | static _getExpireTime() { 146 | return new Date(new Date().getTime() + ms(AWS_EXPIRE_TIME)).toISOString(); 147 | } 148 | 149 | } 150 | 151 | 152 | 153 | module.exports = S3DirectStore; -------------------------------------------------------------------------------- /test/store/s3_direct_store.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var config = require('config'); 4 | 5 | var chai = require('chai'); 6 | chai.use(require('chai-string')); 7 | 8 | var should = chai.should(); 9 | 10 | var S3DirectStore = require('../../lib/store/s3_direct_store'); 11 | 12 | const TEST_AWS_ACEESS_KEY = 'testAccess'; 13 | const TEST_AWS_SECRET_KEY = 'testSecret'; 14 | const TEST_S3_BUCKET = 'testBucket'; 15 | const TEST_S3_ENDPOINT = 'http://localhost:4569'; 16 | 17 | const TEST_USER = 'testUser'; 18 | const TEST_REPO = 'testRepo'; 19 | const TEST_OID= 'testoid'; 20 | 21 | 22 | describe('S3 Direct Store', function() { 23 | 24 | var store; 25 | 26 | beforeEach(function () { 27 | 28 | store = new S3DirectStore({ 29 | 'access_key': TEST_AWS_ACEESS_KEY, 30 | 'secret_key': TEST_AWS_SECRET_KEY, 31 | 'endpoint': TEST_S3_ENDPOINT, 32 | 'bucket': TEST_S3_BUCKET 33 | }); 34 | 35 | }); 36 | 37 | describe('_getEndpoint', function() { 38 | it('should return custom endpoint', function() { 39 | let store = new S3DirectStore({ 40 | 'access_key': TEST_AWS_ACEESS_KEY, 41 | 'secret_key': TEST_AWS_SECRET_KEY, 42 | 'endpoint': TEST_S3_ENDPOINT, 43 | 'bucket': TEST_S3_BUCKET 44 | }); 45 | 46 | store._getEndpoint().should.equal(TEST_S3_ENDPOINT); 47 | }); 48 | 49 | it('should return default endpoint', function() { 50 | let store = new S3DirectStore({ 51 | 'access_key': TEST_AWS_ACEESS_KEY, 52 | 'secret_key': TEST_AWS_SECRET_KEY, 53 | 'bucket': TEST_S3_BUCKET 54 | }); 55 | 56 | store._getEndpoint().should.equal('https://s3.amazonaws.com'); 57 | }); 58 | 59 | it('should return endpoint with region', function() { 60 | var region = 'us-west-1'; 61 | let store = new S3DirectStore({ 62 | 'access_key': TEST_AWS_ACEESS_KEY, 63 | 'secret_key': TEST_AWS_SECRET_KEY, 64 | 'bucket': TEST_S3_BUCKET, 65 | 'region': region 66 | }); 67 | 68 | store._getEndpoint().should.equal('https://s3-us-west-1.amazonaws.com'); 69 | }); 70 | }); 71 | 72 | describe('Upload Action', function() { 73 | it('should default storage class to STANDARD', function() { 74 | var length = 10; 75 | var action = store.getUploadAction(TEST_USER, TEST_REPO, TEST_OID, length); 76 | action.header['x-amz-storage-class'].should.equal('STANDARD'); 77 | }); 78 | 79 | it('should allow customize storage class', function() { 80 | let store = new S3DirectStore({ 81 | 'access_key': TEST_AWS_ACEESS_KEY, 82 | 'secret_key': TEST_AWS_SECRET_KEY, 83 | 'bucket': TEST_S3_BUCKET, 84 | "storage_class": 'STANDARD_IA' 85 | }); 86 | var length = 10; 87 | var action = store.getUploadAction(TEST_USER, TEST_REPO, TEST_OID, length); 88 | action.header['x-amz-storage-class'].should.equal('STANDARD_IA'); 89 | }); 90 | 91 | it('should return upload action', function() { 92 | var length = 10; 93 | var action = store.getUploadAction(TEST_USER, TEST_REPO, TEST_OID, length); 94 | action.href.should.equal(`${TEST_S3_ENDPOINT}/${TEST_S3_BUCKET}/${TEST_USER}/${TEST_REPO}/${TEST_OID}`); 95 | should.exist(action.header); 96 | should.exist(action.expires_at); 97 | action.header['Authorization'].should.startWith(`AWS ${TEST_AWS_ACEESS_KEY}:`); 98 | action.header['Host'].should.equal('localhost'); 99 | should.exist(action.header['Date']); 100 | action.header['Content-Length'].should.equal(String(length)); 101 | action.header['Content-Type'].should.equal('application/octet-stream'); 102 | action.header['x-amz-content-sha256'].should.equal(TEST_OID); 103 | should.exist(action.header['x-amz-storage-class']); 104 | }); 105 | }); 106 | 107 | 108 | 109 | 110 | it('should return download action', function() { 111 | var action = store.getDownloadAction(TEST_USER, TEST_REPO, TEST_OID); 112 | action.href.should.equal(`${TEST_S3_ENDPOINT}/${TEST_S3_BUCKET}/${TEST_USER}/${TEST_REPO}/${TEST_OID}`); 113 | should.exist(action.header); 114 | should.exist(action.expires_at); 115 | action.header['Authorization'].should.startWith(`AWS ${TEST_AWS_ACEESS_KEY}:`); 116 | action.header['Host'].should.equal('localhost'); 117 | should.exist(action.header['Date']); 118 | }); 119 | 120 | 121 | }); -------------------------------------------------------------------------------- /lib/store/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var config = require('config'); 4 | var jwt = require('jsonwebtoken'); 5 | var ms = require('ms'); 6 | 7 | const BASE_URL = config.get('base_url'); 8 | const JWT_CONFIG = config.get('jwt'); 9 | 10 | /** 11 | * Abstract Store, should use subclass 12 | */ 13 | class Store { 14 | 15 | /** 16 | * Register store 17 | * 18 | * @param {String} name, name of the store 19 | * @param {Store} store, class of the store 20 | */ 21 | static registerStore(name, store){ 22 | this.stores[name] = store; 23 | } 24 | 25 | /** 26 | * Get registered store by name 27 | * @param {String} name 28 | * @param {Object} options, optional 29 | * @returns {Store} instance of store 30 | */ 31 | static getStore(name, options) { 32 | return new this.stores[name](options); 33 | } 34 | 35 | /** 36 | * Save object 37 | * @param {String} user 38 | * @param {String} repo 39 | * @param {String} oid 40 | * @param {Stream} stream 41 | * @returns {Promise} 42 | */ 43 | put(user, repo, oid, stream) { 44 | throw new Error('Can not use put from Store class'); 45 | } 46 | 47 | /** 48 | * Download object 49 | * @param {String} user 50 | * @param {String} repo 51 | * @param {String} oid 52 | * @returns {Promise} 53 | */ 54 | get(user, repo, oid) { 55 | throw new Error('Can not use get from Store class'); 56 | } 57 | 58 | /** 59 | * Download object 60 | * @param {String} user 61 | * @param {String} repo 62 | * @param {String} oid 63 | * @returns {Promise} 64 | */ 65 | getSize(user, repo, oid) { 66 | throw new Error('Can not use getSize from Store class'); 67 | } 68 | 69 | /** 70 | * Check object exist or not 71 | * @param {String} user 72 | * @param {String} repo 73 | * @param {String} oid 74 | * @returns {Promise} 75 | */ 76 | exist(user, repo, oid) { 77 | return this.getSize(user, repo, oid).then(function (size) { 78 | return size > 0; 79 | }); 80 | } 81 | 82 | /** 83 | * LFS Batch API upload action 84 | * 85 | * @param {String} user 86 | * @param {String} repo 87 | * @param {String} oid 88 | * @param {Number} size 89 | * @returns upload action 90 | */ 91 | getUploadAction(user, repo, oid, size) { 92 | return { 93 | href: `${BASE_URL}${user}/${repo}/objects/${oid}`, 94 | expires_at: Store._getJWTExpireTime(), 95 | header: { 96 | 'Authorization': 'JWT ' + Store._generateJWTToken('upload', user, repo, oid) 97 | } 98 | }; 99 | } 100 | 101 | /** 102 | * LFS Batch API download action 103 | * 104 | * @param {String} user 105 | * @param {String} repo 106 | * @param {String} oid 107 | * @param {Number} size 108 | * @returns download action 109 | */ 110 | getDownloadAction(user, repo, oid, size) { 111 | return { 112 | href: `${BASE_URL}${user}/${repo}/objects/${oid}`, 113 | expires_at: Store._getJWTExpireTime(), 114 | header: { 115 | 'Authorization': 'JWT ' + Store._generateJWTToken('download', user, repo, oid) 116 | } 117 | }; 118 | } 119 | 120 | /** 121 | * LFS Batch API verify action 122 | * 123 | * @param {String} user 124 | * @param {String} repo 125 | * @param {String} oid 126 | * @param {Number} size 127 | * @returns verify action 128 | */ 129 | getVerifyAction(user, repo, oid, size) { 130 | return { 131 | href: `${BASE_URL}${user}/${repo}/objects/verify`, 132 | expires_at: Store._getJWTExpireTime(), 133 | header: { 134 | 'Authorization': 'JWT ' + Store._generateJWTToken('verify', user, repo) 135 | } 136 | }; 137 | } 138 | 139 | /** 140 | * Create JWT token 141 | * 142 | * @param {String} action, can be 'download', 'upload' or 'verify' 143 | * @param {String} user 144 | * @param {String} repo 145 | * @param {String} [oid], empty for verify request 146 | */ 147 | static _generateJWTToken(action, user, repo, oid) { 148 | var signObject = { 149 | user: user, 150 | repo: repo, 151 | action: action 152 | }; 153 | if (oid) { 154 | signObject.oid = oid; 155 | } 156 | return jwt.sign(signObject, JWT_CONFIG.secret, { 157 | algorithm: JWT_CONFIG.algorithm, 158 | expiresIn: JWT_CONFIG.expiresIn, 159 | issuer: JWT_CONFIG.issuer 160 | }); 161 | } 162 | 163 | static _getJWTExpireTime() { 164 | return new Date(new Date().getTime() + ms(JWT_CONFIG.expiresIn)).toISOString(); 165 | } 166 | } 167 | 168 | Store.stores = {}; 169 | 170 | 171 | 172 | module.exports = Store; 173 | 174 | Store.registerStore('s3', require('./s3_store')); 175 | Store.registerStore('s3_direct', require('./s3_direct_store')); 176 | Store.registerStore('grid', require('./grid_store')); -------------------------------------------------------------------------------- /test/ssh_server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var config = require('config'); 4 | var fs = require('fs'); 5 | var Client = require('ssh2').Client; 6 | 7 | var chai = require('chai'); 8 | chai.use(require('chai-string')); 9 | 10 | var should = chai.should(); 11 | 12 | var sshServer = require('../lib/ssh_server'); 13 | 14 | var TestAuthenticator = require('./../lib/authenticator/test'); 15 | 16 | const BASE_URL = config.get('base_url'); 17 | 18 | const SSH_PORT = config.get('ssh.port'); 19 | 20 | const SSH_IP = config.get('ssh.ip'); 21 | sshServer.listen(SSH_PORT, SSH_IP, function() { 22 | console.log('Listening on port ' + this.address().port); 23 | }); 24 | 25 | describe('SSH Server', function() { 26 | it('should reject if username is not git', function(done) { 27 | var conn = new Client(); 28 | conn.on('error', function(err) { 29 | err.message.should.equal('All configured authentication methods failed'); 30 | done(); 31 | }).connect({ 32 | host: '127.0.0.1', 33 | port: SSH_PORT, 34 | username: 'invalid' 35 | }); 36 | }); 37 | 38 | it('should reject if not using publickey authentication', function(done) { 39 | var conn = new Client(); 40 | conn.on('error', function(err) { 41 | err.message.should.equal('All configured authentication methods failed'); 42 | done(); 43 | }).connect({ 44 | host: '127.0.0.1', 45 | port: SSH_PORT, 46 | username: 'git', 47 | password: 'password' 48 | }); 49 | }); 50 | 51 | it('should reject if not valid publickey', function(done) { 52 | TestAuthenticator.SSH_VALID = false; 53 | var conn = new Client(); 54 | conn.on('error', function(err) { 55 | err.message.should.equal('All configured authentication methods failed'); 56 | done(); 57 | }).connect({ 58 | host: '127.0.0.1', 59 | port: SSH_PORT, 60 | username: 'git', 61 | privateKey: fs.readFileSync('./ssh/client.pri') 62 | }); 63 | }); 64 | 65 | it('should accept if valid publickey', function(done) { 66 | TestAuthenticator.SSH_VALID = true; 67 | var conn = new Client(); 68 | conn.on('ready', function(err) { 69 | done(); 70 | }).connect({ 71 | host: '127.0.0.1', 72 | port: SSH_PORT, 73 | username: 'git', 74 | privateKey: fs.readFileSync('./ssh/client.pri') 75 | }); 76 | }); 77 | 78 | it('should return auth header if success', function(done) { 79 | TestAuthenticator.SSH_VALID = true; 80 | var conn = new Client(); 81 | conn.on('ready', function(err) { 82 | conn.exec('git-lfs-authenticate user/repo.git download', function(err, stream) { 83 | if (err) return done(err); 84 | var result; 85 | stream.on('close', function(code, signal) { 86 | conn.end(); 87 | }).on('data', function(data) { 88 | let resultObject = JSON.parse(data); 89 | should.exist(resultObject.header); 90 | should.exist(resultObject.header['Authorization']); 91 | should.exist(resultObject.href); 92 | resultObject.href.should.equal(`${BASE_URL}user/repo.git`); 93 | done(); 94 | }); 95 | }); 96 | }).connect({ 97 | host: '127.0.0.1', 98 | port: SSH_PORT, 99 | username: 'git', 100 | privateKey: fs.readFileSync('./ssh/client.pri') 101 | }); 102 | }); 103 | 104 | it('should return error if not git-lfs-authenticate command', function(done) { 105 | TestAuthenticator.SSH_VALID = true; 106 | var conn = new Client(); 107 | conn.on('ready', function(err) { 108 | conn.exec('test', function(err, stream) { 109 | if (err) return done(err); 110 | var result; 111 | stream.on('close', function(code, signal) { 112 | conn.end(); 113 | }).on('data', function(data) { 114 | data.should.startWith('Unknown command'); 115 | done(); 116 | }); 117 | }); 118 | }).connect({ 119 | host: '127.0.0.1', 120 | port: SSH_PORT, 121 | username: 'git', 122 | privateKey: fs.readFileSync('./ssh/client.pri') 123 | }); 124 | }); 125 | 126 | it('should return error if invalid git-lfs-authenticate command', function(done) { 127 | TestAuthenticator.SSH_VALID = true; 128 | var conn = new Client(); 129 | conn.on('ready', function(err) { 130 | conn.exec('git-lfs-authenticate user/repo.git test', function(err, stream) { 131 | if (err) return done(err); 132 | var result; 133 | stream.on('close', function(code, signal) { 134 | conn.end(); 135 | }).on('data', function(data) { 136 | data.should.startWith('Unknown command'); 137 | done(); 138 | }); 139 | }); 140 | }).connect({ 141 | host: '127.0.0.1', 142 | port: SSH_PORT, 143 | username: 'git', 144 | privateKey: fs.readFileSync('./ssh/client.pri') 145 | }); 146 | }); 147 | }); -------------------------------------------------------------------------------- /lib/routes/batch.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | var config = require('config'); 5 | var wrap = require('co-express'); 6 | var parse = require('co-body'); 7 | 8 | var validate = require('jsonschema').validate; 9 | 10 | var Store = require('../store'); 11 | var Authenticator = require('../authenticator'); 12 | 13 | const STORE = Store.getStore(config.get('store.type'), config.get('store.options')); 14 | 15 | const AUTHENTICATOR = Authenticator.getAuthenticator(config.get('authenticator.type'), config.get('authenticator.options')); 16 | 17 | const BATCH_REQUEST_SCHEMA = require('../../schema/http-v1-batch-request-schema.json'); 18 | 19 | const PRIVATE_LFS = config.get('private'); 20 | 21 | 22 | 23 | /** 24 | * Process upload object 25 | * 26 | * @param {String} user 27 | * @param {String} repo 28 | * @param {Object} object 29 | * @returns {Object} 30 | */ 31 | var handleUploadObject = function* (user, repo, object) { 32 | var oid = object.oid; 33 | var size = object.size; 34 | 35 | return { 36 | oid: oid, 37 | size: size, 38 | actions: { 39 | upload: STORE.getUploadAction(user, repo, oid, size), 40 | verify: STORE.getVerifyAction(user, repo, oid, size) 41 | } 42 | }; 43 | }; 44 | 45 | /** 46 | * Process download object 47 | * 48 | * @param {String} user 49 | * @param {String} repo 50 | * @param {Object} object 51 | * @returns {Object} 52 | */ 53 | var handleDownloadObject = function* (user, repo, object) { 54 | var oid = object.oid; 55 | var size = object.size; 56 | 57 | var result = { 58 | oid: oid, 59 | size: size 60 | }; 61 | 62 | var exist = yield STORE.exist(user, repo, oid); 63 | if (exist) { 64 | result.actions = { 65 | download: STORE.getDownloadAction(user, repo, oid, size) 66 | }; 67 | } else { 68 | result.error = { 69 | code: 404, 70 | message: 'Object does not exist on the server' 71 | }; 72 | } 73 | return result; 74 | }; 75 | 76 | /** 77 | * Process verify object 78 | * 79 | * @param {String} user 80 | * @param {String} repo 81 | * @param {Object} object 82 | * @returns {Object} 83 | */ 84 | var handleVerifyObject = function* (user, repo, object) { 85 | var oid = object.oid; 86 | var size = object.size; 87 | 88 | return { 89 | oid: oid, 90 | size: size, 91 | actions: { 92 | verify: STORE.getVerifyAction(user, repo, oid, size) 93 | } 94 | }; 95 | }; 96 | 97 | 98 | 99 | module.exports = function(app) { 100 | app.post('/:user/:repo/objects/batch', wrap(function* (req, res, next) { 101 | // validate request body according to JSON Schema 102 | try { 103 | var body = yield parse.json(req); 104 | req.jsonBody = body; 105 | var valid = validate(body, BATCH_REQUEST_SCHEMA).valid; 106 | if (!valid) { 107 | let err = new Error(); 108 | err.status = 422; 109 | next(err); 110 | } else { 111 | next(); 112 | } 113 | } catch (err) { 114 | next(err); 115 | } 116 | }), wrap(function* (req, res, next) { 117 | try { 118 | res.set('Content-Type', 'application/vnd.git-lfs+json'); 119 | 120 | var body = req.jsonBody; 121 | var operation = body.operation; 122 | 123 | // validate operation 124 | if (operation !== 'upload' && operation !== 'verify' && operation !== 'download') { 125 | return res.status(422).end(); 126 | } 127 | 128 | let user = req.params.user; 129 | let repo = req.params.repo; 130 | let authorization = req.header('Authorization'); 131 | 132 | if (PRIVATE_LFS && !authorization) { 133 | res.set('LFS-Authenticate', 'Basic realm="Git LFS"'); 134 | return res.status(401).end(); 135 | } 136 | 137 | 138 | let canRead = yield AUTHENTICATOR.canRead(user, repo, authorization); 139 | 140 | if (!canRead) { 141 | if (authorization) { 142 | return res.status(403).end(); 143 | } else { 144 | res.set('LFS-Authenticate', 'Basic realm="Git LFS"'); 145 | return res.status(401).end(); 146 | } 147 | 148 | } 149 | 150 | // validate objects 151 | let objects = body.objects; 152 | let results; 153 | let func; 154 | let yields = []; 155 | 156 | switch (operation) { 157 | case 'upload': 158 | func = handleUploadObject; 159 | // can Write only need to be checked for upload operation 160 | let canWrite = yield AUTHENTICATOR.canWrite(user, repo, authorization); 161 | if (!canWrite && authorization) { 162 | return res.status(403).end(); 163 | } 164 | break; 165 | case 'download': 166 | func = handleDownloadObject; 167 | break; 168 | case 'verify': 169 | func = handleVerifyObject; 170 | break; 171 | } 172 | _.forEach(objects, function(object) { 173 | yields.push(func(user, repo, object)); 174 | }); 175 | 176 | results = yield yields; 177 | 178 | var response = { 179 | objects: results 180 | }; 181 | res.status(200).json(response); 182 | } catch (err) { 183 | next(err); 184 | } 185 | 186 | })); 187 | }; 188 | -------------------------------------------------------------------------------- /test/authenticator/basic.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var crypto = require('crypto'); 4 | var fs = require('fs'); 5 | var ssh_utils = require('ssh2').utils; 6 | 7 | var should = require('chai').should(); 8 | 9 | 10 | 11 | 12 | var BasicAuthenticator = require('../../lib/authenticator/basic'); 13 | 14 | 15 | describe('Basic Authenticator', function() { 16 | 17 | var authenticator; 18 | 19 | beforeEach(function() { 20 | authenticator = new BasicAuthenticator({ 21 | username: 'testuser', 22 | password: 'testpass' 23 | }); 24 | 25 | }); 26 | 27 | describe('canRead', function() { 28 | it('should return false if no authorization', function(done) { 29 | authenticator.canRead('a', 'b', null) 30 | .then(function(canRead) { 31 | canRead.should.equal(false); 32 | done(); 33 | }) 34 | .catch(done); 35 | }); 36 | 37 | it('should return false if not valid authorization', function(done) { 38 | authenticator.canRead('a', 'b', 'testaaa') 39 | .then(function(canRead) { 40 | canRead.should.equal(false); 41 | done(); 42 | }) 43 | .catch(done); 44 | }); 45 | 46 | it('should return false if user name incorrect', function(done) { 47 | authenticator.canRead('a', 'b', 'Basic bm90Y29ycmVjdDp0ZXN0cGFzcw==') 48 | .then(function(canRead) { 49 | canRead.should.equal(false); 50 | done(); 51 | }) 52 | .catch(done); 53 | }); 54 | 55 | it('should return false if password incorrect', function(done) { 56 | authenticator.canRead('a', 'b', 'Basic dGVzdHVzZXI6bm90Y29ycmVjdA==') 57 | .then(function(canRead) { 58 | canRead.should.equal(false); 59 | done(); 60 | }) 61 | .catch(done); 62 | }); 63 | 64 | it('should return true for correct username and password', function(done) { 65 | authenticator.canRead('a', 'b', 'Basic dGVzdHVzZXI6dGVzdHBhc3M=') 66 | .then(function(canRead) { 67 | canRead.should.equal(true); 68 | done(); 69 | }) 70 | .catch(done); 71 | }); 72 | }); 73 | 74 | describe('canWrite', function() { 75 | it('should return false if no authorization', function(done) { 76 | authenticator.canWrite('a', 'b', null) 77 | .then(function(canWrite) { 78 | canWrite.should.equal(false); 79 | done(); 80 | }) 81 | .catch(done); 82 | }); 83 | 84 | it('should return false if not valid authorization', function(done) { 85 | authenticator.canWrite('a', 'b', 'testaaa') 86 | .then(function(canWrite) { 87 | canWrite.should.equal(false); 88 | done(); 89 | }) 90 | .catch(done); 91 | }); 92 | 93 | it('should return false if user name incorrect', function(done) { 94 | authenticator.canWrite('a', 'b', 'Basic bm90Y29ycmVjdDp0ZXN0cGFzcw==') 95 | .then(function(canWrite) { 96 | canWrite.should.equal(false); 97 | done(); 98 | }) 99 | .catch(done); 100 | }); 101 | 102 | it('should return false if password incorrect', function(done) { 103 | authenticator.canWrite('a', 'b', 'Basic dGVzdHVzZXI6bm90Y29ycmVjdA==') 104 | .then(function(canWrite) { 105 | canWrite.should.equal(false); 106 | done(); 107 | }) 108 | .catch(done); 109 | }); 110 | 111 | it('should return true for correct username and password', function(done) { 112 | authenticator.canWrite('a', 'b', 'Basic dGVzdHVzZXI6dGVzdHBhc3M=') 113 | .then(function(canWrite) { 114 | canWrite.should.equal(true); 115 | done(); 116 | }) 117 | .catch(done); 118 | }); 119 | }); 120 | 121 | describe('checkSSHAuthorization', function() { 122 | beforeEach(function() { 123 | authenticator = new BasicAuthenticator({ 124 | username: 'testuser', 125 | password: 'testpass', 126 | client_public_key: './ssh/client.pub' 127 | }); 128 | }); 129 | 130 | it('should return undefined if no public key in option', function* () { 131 | authenticator = new BasicAuthenticator({ 132 | username: 'testuser', 133 | password: 'testpass' 134 | }); 135 | 136 | var result = yield authenticator.checkSSHAuthorization(); 137 | should.not.exist(result); 138 | }); 139 | 140 | it('should return undefined if signature not valid', function* () { 141 | var key = ssh_utils.parseKey(fs.readFileSync('./ssh/server.pri')); 142 | var algo = 'RSA-SHA1'; 143 | var data = new Buffer('test'); 144 | var sign = crypto.createSign(algo); 145 | sign.update(data); 146 | var sig = sign.sign(key.privateOrig, 'binary'); 147 | var result = yield authenticator.checkSSHAuthorization(null, null, algo, data, sig); 148 | should.not.exist(result); 149 | }); 150 | 151 | it('should return header if signature valid', function* () { 152 | var key = ssh_utils.parseKey(fs.readFileSync('./ssh/client.pri')); 153 | var algo = 'RSA-SHA1'; 154 | var data = new Buffer('test'); 155 | var sign = crypto.createSign(algo); 156 | sign.update(data); 157 | var sig = sign.sign(key.privateOrig, 'binary'); 158 | var result = yield authenticator.checkSSHAuthorization(null, null, algo, data, sig); 159 | should.exist(result); 160 | result.should.equal('Basic ' + new Buffer('testuser:testpass').toString('base64')); 161 | }); 162 | }); 163 | 164 | }); -------------------------------------------------------------------------------- /test/store/s3_store.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var child_process = require('child_process'); 4 | var stream = require('stream'); 5 | var config = require('config'); 6 | var AWS = require('aws-sdk'); 7 | var S3rver = require('s3rver'); 8 | 9 | var chai = require('chai'); 10 | chai.use(require('chai-string')); 11 | 12 | var should = chai.should(); 13 | 14 | var S3Store = require('../../lib/store/s3_store'); 15 | 16 | const TEST_AWS_ACEESS_KEY = 'test'; 17 | const TEST_AWS_SECRET_KEY = 'test'; 18 | const TEST_S3_BUCKET = 'test'; 19 | const TEST_S3_ENDPOINT = 'http://localhost:4569'; 20 | 21 | 22 | describe('S3 Store', function() { 23 | 24 | var s3_server, s3_client, store; 25 | 26 | beforeEach(function (done) { 27 | // cleanup s3 folder 28 | child_process.execSync('rm -rf s3 && mkdir -p s3', { 29 | cwd: '/tmp' 30 | }); 31 | 32 | // setup mock s3 server 33 | s3_server = new S3rver({ 34 | port: 4569, 35 | hostname: 'localhost', 36 | silent: false, 37 | directory: '/tmp/s3' 38 | }).run(function (err, host, port) { 39 | if(err) { 40 | return done(err); 41 | } 42 | 43 | s3_client = new AWS.S3({ 44 | accessKeyId: TEST_AWS_ACEESS_KEY, 45 | secretAccessKey: TEST_AWS_SECRET_KEY, 46 | endpoint: TEST_S3_ENDPOINT, 47 | s3ForcePathStyle: true 48 | }); 49 | 50 | var params = { 51 | Bucket: TEST_S3_BUCKET 52 | }; 53 | 54 | s3_client.createBucket(params, done); 55 | }); 56 | 57 | store = new S3Store({ 58 | 'access_key': TEST_AWS_ACEESS_KEY, 59 | 'secret_key': TEST_AWS_SECRET_KEY, 60 | 'endpoint': TEST_S3_ENDPOINT, 61 | 'bucket': TEST_S3_BUCKET 62 | }); 63 | 64 | }); 65 | 66 | afterEach(function (done) { 67 | if (s3_server) { 68 | s3_server.close(done); 69 | } 70 | 71 | }); 72 | 73 | it('should allow config endpoint', function() { 74 | let endpoint = 'http://localhost:4569'; 75 | let store = new S3Store({ 76 | 'endpoint': endpoint 77 | }); 78 | 79 | store._s3.config.endpoint.should.equal(endpoint); 80 | }); 81 | 82 | it('should allow config region', function() { 83 | let region = 'us-west-2'; 84 | let store = new S3Store({ 85 | 'region': region 86 | }); 87 | 88 | store._s3.config.region.should.equal(region); 89 | }); 90 | 91 | it('should be able to put object', function *(done) { 92 | try { 93 | let body = 'testbody'; 94 | let s = new stream.Readable(); 95 | s.push(body); 96 | s.push(null); 97 | yield store.put('testuser', 'testrepo', 'testid', s); 98 | 99 | let params= { 100 | Bucket: TEST_S3_BUCKET, 101 | Key: 'testuser/testrepo/testid' 102 | }; 103 | s3_client.headObject(params, function(err, data) { 104 | if (err) return done(err); 105 | data.ContentLength.should.equal(String(body.length)); 106 | done(); 107 | }); 108 | } catch(err) { 109 | done(err); 110 | } 111 | }); 112 | 113 | it('should be able to get object', function(done) { 114 | let body = 'testbody'; 115 | let s = new stream.Readable(); 116 | s.push(body); 117 | s.push(null); 118 | 119 | let params= { 120 | Bucket: TEST_S3_BUCKET, 121 | Key: 'testuser/testrepo/testid', 122 | Body: s 123 | }; 124 | s3_client.upload(params, function(err, data) { 125 | if (err) return done(err); 126 | 127 | store.get('testuser', 'testrepo', 'testid').then(function(s) { 128 | s.setEncoding('utf8'); 129 | let string = ''; 130 | s.on('data',function(chunk){ 131 | string += chunk; 132 | }); 133 | 134 | s.on('end',function(){ 135 | string.should.equal(body); 136 | done(); 137 | }); 138 | }) 139 | 140 | 141 | }); 142 | }); 143 | 144 | describe('getSize', function() { 145 | it('should return object size', function(done) { 146 | 147 | let body = 'testbody'; 148 | let s = new stream.Readable(); 149 | s.push(body); 150 | s.push(null); 151 | 152 | let params= { 153 | Bucket: TEST_S3_BUCKET, 154 | Key: 'testuser/testrepo/testid', 155 | Body: s 156 | }; 157 | s3_client.upload(params, function(err, data) { 158 | if (err) return done(err); 159 | 160 | store.getSize('testuser', 'testrepo', 'testid') 161 | .then(function(size) { 162 | size.should.equal(body.length); 163 | done(); 164 | }) 165 | .catch(done); 166 | 167 | }); 168 | }); 169 | 170 | it('should return -1 for non exist object', function(done) { 171 | 172 | store.getSize('testuser', 'testrepo', 'testid') 173 | .then(function(size) { 174 | size.should.equal(-1); 175 | done(); 176 | }) 177 | .catch(done); 178 | }); 179 | }); 180 | 181 | describe('exist', function() { 182 | it('should return false for non exist object', function* () { 183 | 184 | var exist = yield store.exist('testuser', 'testrepo', 'not_exist'); 185 | exist.should.equal(false); 186 | 187 | }); 188 | 189 | it('should return true for exist object', function (done) { 190 | let body = 'testbody'; 191 | let s = new stream.Readable(); 192 | s.push(body); 193 | s.push(null); 194 | 195 | let params= { 196 | Bucket: TEST_S3_BUCKET, 197 | Key: 'testuser/testrepo/testid', 198 | Body: s 199 | }; 200 | s3_client.upload(params, function(err, data) { 201 | if (err) return done(err); 202 | 203 | store.exist('testuser', 'testrepo', 'testid') 204 | .then(function(exist) { 205 | exist.should.equal(true); 206 | done(); 207 | }) 208 | .catch(done); 209 | 210 | }); 211 | 212 | 213 | }); 214 | }); 215 | 216 | 217 | 218 | 219 | }); -------------------------------------------------------------------------------- /test/verify.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var child_process = require('child_process'); 4 | var request = require('supertest'); 5 | var config = require('config'); 6 | var stream = require('stream'); 7 | var AWS = require('aws-sdk'); 8 | var S3rver = require('s3rver'); 9 | 10 | var chai = require('chai'); 11 | chai.use(require('chai-string')); 12 | 13 | var should = chai.should(); 14 | 15 | var app = require('../lib/app'); 16 | var generateJWTToken = require('../lib/store')._generateJWTToken; 17 | 18 | const BASE_URL = config.get('base_url'); 19 | 20 | 21 | const TEST_USER = "testuser"; 22 | const TEST_REPO = "testrepo"; 23 | const TEST_OID = "testoid"; 24 | const TEST_BODY = "testBody"; 25 | 26 | describe('Verify Endpoint', function() { 27 | 28 | var s3_server, s3_client; 29 | 30 | beforeEach(function (done) { 31 | let store_type = config.get('store.type'); 32 | if (store_type === 's3' || store_type === 's3_direct') { 33 | // cleanup s3 folder 34 | child_process.execSync('rm -rf s3 && mkdir -p s3', { 35 | cwd: '/tmp' 36 | }); 37 | 38 | // setup mock s3 server 39 | s3_server = new S3rver({ 40 | port: 4569, 41 | hostname: 'localhost', 42 | silent: false, 43 | directory: '/tmp/s3' 44 | }).run(function (err, host, port) { 45 | if(err) { 46 | return done(err); 47 | } 48 | 49 | s3_client = new AWS.S3({ 50 | accessKeyId: config.get('store.options.access_key'), 51 | secretAccessKey: config.get('store.options.secret_key'), 52 | endpoint: config.get('store.options.endpoint'), 53 | s3ForcePathStyle: true 54 | }); 55 | 56 | var params = { 57 | Bucket: config.get('store.options.bucket') 58 | }; 59 | 60 | s3_client.createBucket(params, done); 61 | }); 62 | } else { 63 | done(); 64 | } 65 | 66 | }); 67 | 68 | afterEach(function (done) { 69 | if (s3_server) { 70 | s3_server.close(done); 71 | } else { 72 | done(); 73 | } 74 | 75 | }); 76 | 77 | it('should return 200 for valid object', function(done) { 78 | // upload test file 79 | request(app) 80 | .put(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 81 | .set('Authorization', 'JWT ' + generateJWTToken('upload', TEST_USER, TEST_REPO, TEST_OID)) 82 | .send(TEST_BODY) 83 | .end(function() { 84 | request(app) 85 | .post(`/${TEST_USER}/${TEST_REPO}/objects/verify`) 86 | .set('Authorization', 'JWT ' + generateJWTToken('verify', TEST_USER, TEST_REPO)) 87 | .send({ 88 | "oid": TEST_OID, 89 | "size": TEST_BODY.length 90 | }) 91 | .expect(200, done); 92 | }); 93 | }); 94 | 95 | it('should return 422 for non exist object', function(done) { 96 | request(app) 97 | .post(`/${TEST_USER}/${TEST_REPO}/objects/verify`) 98 | .set('Authorization', 'JWT ' + generateJWTToken('verify', TEST_USER, TEST_REPO)) 99 | .send({ 100 | "oid": "non_exist", 101 | "size": 100 102 | }) 103 | .expect(422, done); 104 | }); 105 | 106 | it('should return 422 for size not match', function(done) { 107 | // upload test file 108 | request(app) 109 | .put(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 110 | .set('Authorization', 'JWT ' + generateJWTToken('upload', TEST_USER, TEST_REPO, TEST_OID)) 111 | .send(TEST_BODY) 112 | .end(function() { 113 | request(app) 114 | .post(`/${TEST_USER}/${TEST_REPO}/objects/verify`) 115 | .set('Authorization', 'JWT ' + generateJWTToken('verify', TEST_USER, TEST_REPO)) 116 | .send({ 117 | "oid": TEST_OID, 118 | "size": TEST_BODY.length + 1 119 | }) 120 | .expect(422, done); 121 | }); 122 | }); 123 | 124 | it('should return 422 for invalid request', function(done) { 125 | request(app) 126 | .post(`/${TEST_USER}/${TEST_REPO}/objects/verify`) 127 | .set('Authorization', 'JWT ' + generateJWTToken('verify', TEST_USER, TEST_REPO)) 128 | .send({ 129 | "test": "test" 130 | }) 131 | .expect(422, done); 132 | }); 133 | 134 | 135 | it('should return 401 if not Authorization header', function(done) { 136 | request(app) 137 | .post(`/${TEST_USER}/${TEST_REPO}/objects/verify`) 138 | .send({ 139 | "oid": TEST_OID, 140 | "size": TEST_BODY.length 141 | }) 142 | .expect(401, done); 143 | }); 144 | 145 | it('should return 401 if Authorization header not start with JWT', function(done) { 146 | request(app) 147 | .post(`/${TEST_USER}/${TEST_REPO}/objects/verify`) 148 | .set('Authorization', 'Basic test') 149 | .send({ 150 | "oid": TEST_OID, 151 | "size": TEST_BODY.length 152 | }) 153 | .expect(401, done); 154 | }); 155 | 156 | it('should return 403 if user in token not correct', function(done) { 157 | request(app) 158 | .post(`/${TEST_USER}/${TEST_REPO}/objects/verify`) 159 | .set('Authorization', 'JWT ' + generateJWTToken('verify', TEST_USER + "1", TEST_REPO)) 160 | .send({ 161 | "oid": TEST_OID, 162 | "size": TEST_BODY.length 163 | }) 164 | .expect(403, done); 165 | }); 166 | 167 | it('should return 403 if repo in token not correct', function(done) { 168 | request(app) 169 | .post(`/${TEST_USER}/${TEST_REPO}/objects/verify`) 170 | .set('Authorization', 'JWT ' + generateJWTToken('verify', TEST_USER, TEST_REPO + "1")) 171 | .send({ 172 | "oid": TEST_OID, 173 | "size": TEST_BODY.length 174 | }) 175 | .expect(403, done); 176 | }); 177 | 178 | 179 | it('should return 403 if action in token not correct', function(done) { 180 | request(app) 181 | .post(`/${TEST_USER}/${TEST_REPO}/objects/verify`) 182 | .set('Authorization', 'JWT ' + generateJWTToken('verify1', TEST_USER, TEST_REPO)) 183 | .send({ 184 | "oid": TEST_OID, 185 | "size": TEST_BODY.length 186 | }) 187 | .expect(403, done); 188 | }); 189 | 190 | it('should return 403 if invalid JWT token', function(done) { 191 | request(app) 192 | .post(`/${TEST_USER}/${TEST_REPO}/objects/verify`) 193 | .set('Authorization', 'JWT test') 194 | .send({ 195 | "oid": TEST_OID, 196 | "size": TEST_BODY.length 197 | }) 198 | .expect(403, done); 199 | }); 200 | }); -------------------------------------------------------------------------------- /test/objects.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var child_process = require('child_process'); 4 | var request = require('supertest'); 5 | var config = require('config'); 6 | var AWS = require('aws-sdk'); 7 | var S3rver = require('s3rver'); 8 | 9 | var chai = require('chai'); 10 | chai.use(require('chai-string')); 11 | 12 | var should = chai.should(); 13 | 14 | 15 | 16 | var app = require('../lib/app'); 17 | var generateJWTToken = require('../lib/store')._generateJWTToken; 18 | 19 | const BASE_URL = config.get('base_url'); 20 | 21 | const TEST_USER = "testuser"; 22 | const TEST_REPO = "testrepo"; 23 | const TEST_OID = "testoid"; 24 | 25 | 26 | 27 | describe('Objects Endpoint', function() { 28 | 29 | var s3_server; 30 | 31 | beforeEach(function (done) { 32 | let store_type = config.get('store.type'); 33 | if (store_type === 's3' || store_type === 's3_direct') { 34 | // cleanup s3 folder 35 | child_process.execSync('rm -rf s3 && mkdir -p s3', { 36 | cwd: '/tmp' 37 | }); 38 | 39 | // setup mock s3 server 40 | s3_server = new S3rver({ 41 | port: 4569, 42 | hostname: 'localhost', 43 | silent: false, 44 | directory: '/tmp/s3' 45 | }).run(function (err, host, port) { 46 | if(err) { 47 | return done(err); 48 | } 49 | 50 | var s3_client = new AWS.S3({ 51 | accessKeyId: 'test', 52 | secretAccessKey: 'test', 53 | endpoint: 'http://localhost:4569', 54 | s3ForcePathStyle: true 55 | }); 56 | 57 | var params = { 58 | Bucket: config.get('store.options.bucket') 59 | }; 60 | 61 | s3_client.createBucket(params, done); 62 | }); 63 | } else { 64 | done(); 65 | } 66 | 67 | }); 68 | 69 | afterEach(function (done) { 70 | if (s3_server) { 71 | s3_server.close(done); 72 | } else { 73 | done(); 74 | } 75 | 76 | }); 77 | 78 | describe('PUT', function() { 79 | it('should return 200 for put object', function(done) { 80 | request(app) 81 | .put(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 82 | .set('Authorization', 'JWT ' + generateJWTToken('upload', TEST_USER, TEST_REPO, TEST_OID)) 83 | .send('testObject') 84 | .expect(200, done); 85 | }); 86 | 87 | it('should return 401 if not Authorization header', function(done) { 88 | request(app) 89 | .put(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 90 | .send('testObject') 91 | .expect(401, done); 92 | }); 93 | 94 | it('should return 401 if Authorization header not start with JWT', function(done) { 95 | request(app) 96 | .put(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 97 | .set('Authorization', 'Basic test') 98 | .send('testObject') 99 | .expect(401, done); 100 | }); 101 | 102 | it('should return 403 if user in token not correct', function(done) { 103 | request(app) 104 | .put(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 105 | .set('Authorization', 'JWT ' + generateJWTToken('upload', TEST_USER + "1", TEST_REPO, TEST_OID)) 106 | .send('testObject') 107 | .expect(403, done); 108 | }); 109 | 110 | it('should return 403 if repo in token not correct', function(done) { 111 | request(app) 112 | .put(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 113 | .set('Authorization', 'JWT ' + generateJWTToken('upload', TEST_USER, TEST_REPO + "1", TEST_OID)) 114 | .send('testObject') 115 | .expect(403, done); 116 | }); 117 | 118 | it('should return 403 if oid in token not correct', function(done) { 119 | request(app) 120 | .put(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 121 | .set('Authorization', 'JWT ' + generateJWTToken('upload', TEST_USER, TEST_REPO, TEST_OID + "1")) 122 | .send('testObject') 123 | .expect(403, done); 124 | }); 125 | 126 | it('should return 403 if action in token not correct', function(done) { 127 | request(app) 128 | .put(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 129 | .set('Authorization', 'JWT ' + generateJWTToken('upload1', TEST_USER, TEST_REPO, TEST_OID)) 130 | .send('testObject') 131 | .expect(403, done); 132 | }); 133 | 134 | it('should return 403 if invalid JWT token', function(done) { 135 | request(app) 136 | .put(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 137 | .set('Authorization', 'JWT test') 138 | .send('testObject') 139 | .expect(403, done); 140 | }); 141 | }); 142 | 143 | describe('GET', function() { 144 | it('should return 404 for get non exist object', function(done) { 145 | request(app) 146 | .get(`/${TEST_USER}/${TEST_REPO}/objects/not_exist`) 147 | .set('Authorization', 'JWT ' + generateJWTToken('download', TEST_USER, TEST_REPO, "not_exist")) 148 | .expect(404, done); 149 | }); 150 | 151 | it('should success for get exist object', function (done) { 152 | let testObject = 'testObject'; 153 | request(app) 154 | .put(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 155 | .set('Authorization', 'JWT ' + generateJWTToken('upload', TEST_USER, TEST_REPO, TEST_OID)) 156 | .send(testObject) 157 | .end(function(err, data) { 158 | if (err) return done(err); 159 | request(app) 160 | .get(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 161 | .set('Authorization', 'JWT ' + generateJWTToken('download', TEST_USER, TEST_REPO, TEST_OID)) 162 | .expect(testObject) 163 | .expect('Content-Length', String(testObject.length)) 164 | .expect(200, done); 165 | }); 166 | }); 167 | 168 | 169 | it('should return 401 if not Authorization header', function(done) { 170 | request(app) 171 | .get(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 172 | .send('testObject') 173 | .expect(401, done); 174 | }); 175 | 176 | it('should return 401 if Authorization header not start with JWT', function(done) { 177 | request(app) 178 | .get(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 179 | .set('Authorization', 'Basic test') 180 | .send('testObject') 181 | .expect(401, done); 182 | }); 183 | 184 | it('should return 403 if user in token not correct', function(done) { 185 | request(app) 186 | .get(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 187 | .set('Authorization', 'JWT ' + generateJWTToken('download', TEST_USER + "1", TEST_REPO, TEST_OID)) 188 | .send('testObject') 189 | .expect(403, done); 190 | }); 191 | 192 | it('should return 403 if repo in token not correct', function(done) { 193 | request(app) 194 | .get(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 195 | .set('Authorization', 'JWT ' + generateJWTToken('download', TEST_USER, TEST_REPO + "1", TEST_OID)) 196 | .send('testObject') 197 | .expect(403, done); 198 | }); 199 | 200 | it('should return 403 if oid in token not correct', function(done) { 201 | request(app) 202 | .get(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 203 | .set('Authorization', 'JWT ' + generateJWTToken('download', TEST_USER, TEST_REPO, TEST_OID + "1")) 204 | .send('testObject') 205 | .expect(403, done); 206 | }); 207 | 208 | it('should return 403 if action in token not correct', function(done) { 209 | request(app) 210 | .get(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 211 | .set('Authorization', 'JWT ' + generateJWTToken('download1', TEST_USER, TEST_REPO, TEST_OID)) 212 | .send('testObject') 213 | .expect(403, done); 214 | }); 215 | 216 | it('should return 403 if invalid JWT token', function(done) { 217 | request(app) 218 | .get(`/${TEST_USER}/${TEST_REPO}/objects/${TEST_OID}`) 219 | .set('Authorization', 'JWT test') 220 | .send('testObject') 221 | .expect(403, done); 222 | }); 223 | }); 224 | 225 | 226 | 227 | 228 | }); -------------------------------------------------------------------------------- /test/batch.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var child_process = require('child_process'); 4 | var request = require('supertest'); 5 | var config = require('config'); 6 | var stream = require('stream'); 7 | var AWS = require('aws-sdk'); 8 | var jwt = require('jsonwebtoken'); 9 | var S3rver = require('s3rver'); 10 | 11 | var chai = require('chai'); 12 | chai.use(require('chai-string')); 13 | 14 | var should = chai.should(); 15 | 16 | var app = require('../lib/app'); 17 | var generateJWTToken = require('../lib/store')._generateJWTToken; 18 | 19 | const BASE_URL = config.get('base_url'); 20 | 21 | const JWT_CONFIG = config.get('jwt'); 22 | 23 | 24 | describe('Batch Endpoint', function() { 25 | 26 | var s3_server; 27 | 28 | beforeEach(function (done) { 29 | let store_type = config.get('store.type'); 30 | if (store_type === 's3' || store_type === 's3_direct') { 31 | // cleanup s3 folder 32 | child_process.execSync('rm -rf s3 && mkdir -p s3', { 33 | cwd: '/tmp' 34 | }); 35 | 36 | // setup mock s3 server 37 | s3_server = new S3rver({ 38 | port: 4569, 39 | hostname: 'localhost', 40 | silent: false, 41 | directory: '/tmp/s3' 42 | }).run(function (err, host, port) { 43 | if(err) { 44 | return done(err); 45 | } 46 | 47 | var s3_client = new AWS.S3({ 48 | accessKeyId: config.get('store.options.access_key'), 49 | secretAccessKey: config.get('store.options.secret_key'), 50 | endpoint: config.get('store.options.endpoint'), 51 | s3ForcePathStyle: true 52 | }); 53 | 54 | var params = { 55 | Bucket: config.get('store.options.bucket') 56 | }; 57 | 58 | s3_client.createBucket(params, done); 59 | }); 60 | } else { 61 | done(); 62 | } 63 | 64 | }); 65 | 66 | afterEach(function (done) { 67 | if (s3_server) { 68 | s3_server.close(done); 69 | } else { 70 | done(); 71 | } 72 | 73 | }); 74 | 75 | it('should return 422 for invalid request body', function(done) { 76 | request(app) 77 | .post('/testuser/testrepo/objects/batch') 78 | .send({}) 79 | .expect(422, done); 80 | }); 81 | 82 | it('should return 422 for invalid operation', function(done) { 83 | request(app) 84 | .post('/testuser/testrepo/objects/batch') 85 | .send( { 86 | "operation": "invalid_operation", 87 | "objects": [ 88 | { 89 | "oid": "1111111", 90 | "size": 123 91 | } 92 | ] 93 | }) 94 | .expect(422, done); 95 | }); 96 | 97 | it('should return valid content type header', function(done) { 98 | request(app) 99 | .post('/testuser/testrepo/objects/batch') 100 | .send( { 101 | "operation": "upload", 102 | "objects": [ 103 | { 104 | "oid": "1111111", 105 | "size": 123 106 | } 107 | ] 108 | }) 109 | .expect('Content-Type', /application\/vnd\.git-lfs\+json/) 110 | .expect(200, done); 111 | 112 | }); 113 | 114 | 115 | it('should handle upload operation', function(done) { 116 | var oid = 'testid'; 117 | request(app) 118 | .post('/testuser/testrepo/objects/batch') 119 | .send({ 120 | "operation": "upload", 121 | "objects": [ 122 | { 123 | "oid": oid, 124 | "size": 123 125 | } 126 | ] 127 | }) 128 | .expect(function(res) { 129 | should.exist(res.body.objects); 130 | res.body.objects.should.have.length(1); 131 | res.body.objects[0].oid.should.equal(oid); 132 | res.body.objects[0].size.should.equal(123); 133 | 134 | should.exist(res.body.objects[0].actions); 135 | should.exist(res.body.objects[0].actions.upload); 136 | should.exist(res.body.objects[0].actions.verify); 137 | 138 | 139 | should.exist(res.body.objects[0].actions.upload.header); 140 | should.exist(res.body.objects[0].actions.upload.header['Authorization']); 141 | should.exist(res.body.objects[0].actions.verify.header); 142 | should.exist(res.body.objects[0].actions.verify.header['Authorization']); 143 | 144 | should.exist(res.body.objects[0].actions.upload.expires_at); 145 | should.exist(res.body.objects[0].actions.verify.expires_at); 146 | 147 | }) 148 | .expect(200, done); 149 | }); 150 | 151 | it('should handle download non exist object', function(done) { 152 | request(app) 153 | .post('/testuser/testrepo/objects/batch') 154 | .send({ 155 | "operation": "download", 156 | "objects": [ 157 | { 158 | "oid": "1111111", 159 | "size": 123 160 | } 161 | ] 162 | }) 163 | .expect(function(res) { 164 | should.exist(res.body.objects); 165 | res.body.objects.should.have.length(1); 166 | res.body.objects[0].oid.should.equal('1111111'); 167 | res.body.objects[0].size.should.equal(123); 168 | 169 | should.exist(res.body.objects[0].error); 170 | res.body.objects[0].error.code.should.equal(404); 171 | }) 172 | .expect(200, done); 173 | }); 174 | 175 | it('should handle download operation', function(done) { 176 | let body = 'testbody'; 177 | // upload test file 178 | request(app) 179 | .put('/testuser/testrepo/objects/testid') 180 | .set('Authorization', 'JWT ' + generateJWTToken('upload', 'testuser', 'testrepo', 'testid')) 181 | .send(body) 182 | .end(function() { 183 | request(app) 184 | .post('/testuser/testrepo/objects/batch') 185 | .send({ 186 | "operation": "download", 187 | "objects": [ 188 | { 189 | "oid": "testid", 190 | "size": body.length 191 | } 192 | ] 193 | }) 194 | .expect(function(res) { 195 | should.exist(res.body.objects); 196 | res.body.objects.should.have.length(1); 197 | res.body.objects[0].oid.should.equal('testid'); 198 | res.body.objects[0].size.should.equal(body.length); 199 | 200 | should.exist(res.body.objects[0].actions); 201 | should.exist(res.body.objects[0].actions.download); 202 | 203 | 204 | should.exist(res.body.objects[0].actions.download.header); 205 | should.exist(res.body.objects[0].actions.download.header['Authorization']); 206 | 207 | 208 | should.exist(res.body.objects[0].actions.download.expires_at); 209 | 210 | 211 | }) 212 | .expect(200, done); 213 | }); 214 | 215 | }); 216 | 217 | it('should handle verify operation', function(done) { 218 | var oid = 'testid'; 219 | request(app) 220 | .post('/testuser/testrepo/objects/batch') 221 | .send({ 222 | "operation": "verify", 223 | "objects": [ 224 | { 225 | "oid": oid, 226 | "size": 123 227 | } 228 | ] 229 | }) 230 | .expect(function(res) { 231 | should.exist(res.body.objects); 232 | res.body.objects.should.have.length(1); 233 | res.body.objects[0].oid.should.equal(oid); 234 | res.body.objects[0].size.should.equal(123); 235 | 236 | should.exist(res.body.objects[0].actions); 237 | should.exist(res.body.objects[0].actions.verify); 238 | 239 | should.exist(res.body.objects[0].actions.verify.header); 240 | should.exist(res.body.objects[0].actions.verify.header['Authorization']); 241 | 242 | should.exist(res.body.objects[0].actions.verify.expires_at); 243 | 244 | }) 245 | .expect(200, done); 246 | }); 247 | 248 | describe('Authentication', function() { 249 | 250 | var TestAuthenticator = require('./../lib/authenticator/test'); 251 | 252 | afterEach(function() { 253 | TestAuthenticator.CAN_READ = true; 254 | TestAuthenticator.CAN_WRITE = true; 255 | }); 256 | 257 | it('should return 403 if has authorization header but cannot read', function(done) { 258 | TestAuthenticator.CAN_READ = false; 259 | request(app) 260 | .post('/testuser/testrepo/objects/batch') 261 | .set('Authorization', 'test') 262 | .send({ 263 | "operation": "verify", 264 | "objects": [ 265 | { 266 | "oid": "1111111", 267 | "size": 123 268 | } 269 | ] 270 | }) 271 | .expect(403, done); 272 | }); 273 | 274 | it('should return 401 if no authorization header and cannot read', function(done) { 275 | TestAuthenticator.CAN_READ = false; 276 | request(app) 277 | .post('/testuser/testrepo/objects/batch') 278 | .send({ 279 | "operation": "verify", 280 | "objects": [ 281 | { 282 | "oid": "1111111", 283 | "size": 123 284 | } 285 | ] 286 | }) 287 | .expect('LFS-Authenticate', 'Basic realm="Git LFS"') 288 | .expect(401, done); 289 | }); 290 | 291 | it('should return 403 if has authorization header but cannot write', function(done) { 292 | TestAuthenticator.CAN_READ = true; 293 | TestAuthenticator.CAN_WRITE = false; 294 | request(app) 295 | .post('/testuser/testrepo/objects/batch') 296 | .set('Authorization', 'test') 297 | .send({ 298 | "operation": "upload", 299 | "objects": [ 300 | { 301 | "oid": "1111111", 302 | "size": 123 303 | } 304 | ] 305 | }) 306 | .expect(403, done); 307 | }); 308 | }); 309 | }); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. --------------------------------------------------------------------------------