diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 0d868e4..0000000 --- a/.eslintrc +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "eslint-config-dabapps/base/.eslintrc", - "env": { - "node": true, - "browser": false, - "mocha": true, - "es6": true - }, - "parserOptions": { - "sourceType": "module", - "ecmaVersion": 6 - }, - "rules": { - "no-console": 0 - } -} diff --git a/.gitignore b/.gitignore index e920c16..893dafe 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ node_modules # Optional REPL history .node_repl_history +typings/ +dist/ diff --git a/README.md b/README.md index cf21fe4..91cd599 100644 --- a/README.md +++ b/README.md @@ -2,29 +2,55 @@ The only static-file server you'll ever need! ### Features: -- Logging -- Basic-Auth _(optional)_ +- Logging - [`winston`](https://www.npmjs.com/package/winston) +- Basic-Auth - [`basic-auth`](https://www.npmjs.com/package/basic-auth) - Custom 404 page - Optimum Compression - [`compression`](https://www.npmjs.com/package/compression) - Security checks / headers - [`helmet`](https://www.npmjs.com/package/helmet) - Opbeat error-reporting - [docs](https://opbeat.com/docs/articles/get-started-with-express/) +- Whitelist IP Addresses - [`express-ip-access-control`](https://www.npmjs.com/package/express-ip-access-control) +- Directory Listing - [`serve-index`](https://www.npmjs.com/package/serve-index) -### Usage / Configuration +### Usage ```bash -tstatic + tstatic [options] + + -h --help Show this screen. + --version Show version. + -p --port= Port to listen on. + -b --basic-auth= Enable basic-auth. + -i --ips= Allowed IP addresses. + -l --list-dir List Directory. + --opbeat Enable Opbeat. + -o --open Open in browser after start. + ``` -`directory` is where your static files are. +`dir` is where your static files are. -404 errors will return with `/.404.html`, with status code 404. If this file doesnt exist, plain error page will be shown. +404 errors will return with `/.404.html`, with status code 404. If this file doesnt exist, the default error page will be shown. -#### Environment -Make sure to set `NODE_ENV` to `production`! +### Configuration -`PORT`: The port you want the server to listen on. Default: `5000`. +##### `port` +The port for the server to listen on. Currently supports plain HTTP only -`BASIC_AUTH_USERNAME` / `BASIC_AUTH_PASSWORD`: Credentials for built-in basic auth +##### `basic-auth` +Enable basic-auth for all paths. Currently only supports single credentals. -Opbeat middleware is configured using documented variables [here](https://opbeat.com/docs/articles/opbeat-for-nodejs-api/#appid). _Requires production `NODE_ENV`!_ +Format:`-b username:password` +##### `ips` +IP addresses that are allowed to connect to the server. + +Format: `-i 192.168.1.100,192.168.1.101` + +##### `list-dir` +Enables directory listing. Allow browseing + +##### `opbeat` +Enable opbeat error reporting. `--opbeat` only enables this, configuration is done using [environment varables](https://opbeat.com/docs/articles/get-started-with-express/#appId). + +##### `open` +Open the server in the browser one started. It will open in your default browser, and use url `http://0.0.0.0:`. diff --git a/package.json b/package.json index 04c328a..9b8eb16 100644 --- a/package.json +++ b/package.json @@ -2,15 +2,17 @@ "name": "tstatic", "version": "1.0.0", "description": "Container to host simple static applications using a node server, so files can be deployed using rsync", - "main": "./src/server.js", + "main": "node ./dist/index.js", "bin": { - "tstatic": "./src/server.js" + "tstatic": "node ./dist/index.js" }, "scripts": { - "test": "npm run mocha && npm run lint && nsp check", - "lint": "eslint src/ tests/", - "start": "./src/server.js site/", - "mocha": "NODE_ENV=test mocha tests/**.test.js" + "start": "node ./dist/index.js", + "postinstall": "typings install", + "build": "tsc", + "test": "npm run build && npm run mocha && nsp check", + "mocha": "mocha --compilers ts:ts-node/register --require scripts/test-helper.js tests/**.test.ts", + "lint": "tslint src/**/*.ts" }, "engines": { "node": "6.9.4" @@ -25,22 +27,30 @@ }, "homepage": "https://github.com/RealOrangeOne/tstatic#readme", "dependencies": { - "basic-auth": "^1.1.0", + "basic-auth": "=1.1.0", "compression": "=1.6.2", "connect-static-file": "=1.1.2", - "express": "=4.14.0", - "express-basic-auth": "=0.2.3", + "docopt": "=0.6.2", + "express": "=4.14.1", + "express-basic-auth": "=0.3.2", "express-ip-access-control": "=1.0.5", - "express-winston": "=2.1.2", + "express-winston": "=2.2.0", "helmet": "=3.4.0", - "opbeat": "=4.7.0", + "opbeat": "=4.11.0", + "open": "=0.0.5", "serve-index": "=1.8.0", - "winston": "=2.3.0" + "winston": "=2.3.1" }, "devDependencies": { - "eslint-config": "dabapps/eslint-config#2.0.5", + "chai": "=3.5.0", + "chai-as-promised": "=6.0.0", "mocha": "=3.2.0", + "node-fetch": "=1.6.3", "nsp": "=2.6.2", - "supertest": "=2.0.1" + "supertest": "=3.0.0", + "ts-node": "=2.1.0", + "tslint": "=4.4.2", + "typescript": "=2.1.6", + "typings": "=2.1.0" } } diff --git a/scripts/test-helper.js b/scripts/test-helper.js new file mode 100644 index 0000000..8cfab6a --- /dev/null +++ b/scripts/test-helper.js @@ -0,0 +1,7 @@ +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); + +chai.expect(); +chai.use(chaiAsPromised); + +process.env.NODE_ENV = 'test'; diff --git a/src/404.js b/src/404.js deleted file mode 100644 index c2675a8..0000000 --- a/src/404.js +++ /dev/null @@ -1,9 +0,0 @@ -const staticFile = require('connect-static-file'); -const path = require('path'); -const { SERVE_DIR } = require('./consts'); - -const handle404 = staticFile(path.join(SERVE_DIR, '.404.html')); -module.exports = function (request, response, next) { - response.statusCode = 404; - return handle404(request, response, next); -}; diff --git a/src/basic-auth.js b/src/basic-auth.js deleted file mode 100644 index 2a22837..0000000 --- a/src/basic-auth.js +++ /dev/null @@ -1,17 +0,0 @@ -const basicAuth = require('express-basic-auth'); -const { BASIC_AUTH_ENABLED } = require('./consts'); - -function basicAuthHandler(username, password) { - return process.env.BASIC_AUTH_USERNAME === username && process.env.BASIC_AUTH_PASSWORD === password; -} - -if (BASIC_AUTH_ENABLED) { - module.exports = basicAuth({ - authorizer: basicAuthHandler, - challenge: true - }); -} else { - module.exports = (req, res, next) => next(); -} - - diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..dc0f91c --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,40 @@ +import { docopt } from 'docopt'; +import { Options } from './types'; + +const PKG = require('../package.json'); + +const ARG_DATA = ` +${PKG.name}. +${PKG.description} + +Usage: + tstatic [options] + tstatic -h | --help + tstatic --version + +Options: + -h --help Show this screen. + --version Show version. + -p --port= Port to listen on. + -b --basic-auth= Enable basic-auth. + -i --ips= Allowed IP addresses. + -l --list-dir List Directory. + --opbeat Enable Opbeat. + -o --open Open in browser after start. +`; + +export default function getArgs() : Options { + const rawArgs = docopt(ARG_DATA, { + version: PKG.version, + help: true + }); + return { + port: rawArgs['--port'] || process.env.PORT || 5000, + allowed_ips: rawArgs['--ips'] ? rawArgs['--ips'].split(',') : [], + basicAuth: rawArgs['--basic-auth'] ? rawArgs['--basic-auth'].split(':') : [], + dirList: rawArgs['--list-dir'], + serveDir: rawArgs[''], + opbeat: rawArgs['--opbeat'], + open: rawArgs['--open'] + }; +} diff --git a/src/consts.js b/src/consts.js deleted file mode 100644 index 7d8f464..0000000 --- a/src/consts.js +++ /dev/null @@ -1,11 +0,0 @@ -const IN_TEST = process.env.NODE_ENV === 'test'; - -module.exports = { - SERVE_DIR: IN_TEST ? 'site/' : process.argv[process.argv.length - 1], - PORT: process.env.PORT || 5000, - ALLOWED_IPS: process.env.ALLOWED_IPS ? process.env.ALLOWED_IPS.split(',') : undefined, - IN_TEST, - IN_PRODUCTION: process.env.NODE_ENV === 'production', - DIR_LIST: process.env.DIR_LIST, - BASIC_AUTH_ENABLED: process.env.BASIC_AUTH_USERNAME && process.env.BASIC_AUTH_PASSWORD -}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6aacc17 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,17 @@ +import { docopt } from 'docopt'; +import createServer from './server'; +import getArgs from './cli'; +import * as open from 'open'; + +console.log("Starting Server..."); + +const ARGS = getArgs(); +const app = createServer(ARGS); + +export const server = app.listen(ARGS.port, function () { + const port = server.address().port; + console.log("Server started on port " + port); + if (ARGS.open) { + open('http://0.0.0.0:' + port); + } +}); diff --git a/src/logging.js b/src/logging.js deleted file mode 100644 index dba9130..0000000 --- a/src/logging.js +++ /dev/null @@ -1,17 +0,0 @@ -const winston = require('winston'); -const expressWinston = require('express-winston'); - -module.exports = expressWinston.logger({ - transports: [ - new winston.transports.Console({ - colorize: true - }) - ], - meta: false, - msg: '{{ req.url }} ' - .concat('status:{{ res.statusCode }} ') - .concat('useragent:{{ req.headers["user-agent"] }} ') - .concat('time:{{ res.responseTime }}ms'), - colorize: true, - statusLevels: true -}); diff --git a/src/middleware/404.ts b/src/middleware/404.ts new file mode 100644 index 0000000..4fe6100 --- /dev/null +++ b/src/middleware/404.ts @@ -0,0 +1,11 @@ +import { Request, Response } from 'express'; +import * as staticFile from 'connect-static-file' +import * as path from 'path'; + +export default function handle404(serveDir : string) { + const handle404Middleware = staticFile(path.join(serveDir, '.404.html')); + return function (request : Request, response : Response, next : Function) { + response.statusCode = 404; + return handle404Middleware(request, response, next); + } +} diff --git a/src/middleware/basic-auth.ts b/src/middleware/basic-auth.ts new file mode 100644 index 0000000..e31f729 --- /dev/null +++ b/src/middleware/basic-auth.ts @@ -0,0 +1,8 @@ +import * as basicAuth from 'express-basic-auth'; + +export default function basicAuthHandler(username : string, password : string) { + return basicAuth({ + authorizer: (req_username : string, req_password : string) => req_username === username && req_password === password, + challenge: true + }); +} diff --git a/src/middleware/logging.ts b/src/middleware/logging.ts new file mode 100644 index 0000000..49e3809 --- /dev/null +++ b/src/middleware/logging.ts @@ -0,0 +1,17 @@ +import * as winston from 'winston'; +import * as expressWinston from 'express-winston'; + +export default expressWinston.logger({ + transports: [ + new winston.transports.Console({ + colorize: true + }) + ], + meta: false, + msg: '{{ req.url }} ' + .concat('status:{{ res.statusCode }} ') + .concat('useragent:{{ req.headers["user-agent"] }} ') + .concat('time:{{ res.responseTime }}ms'), + colorize: true, + statusLevels: true +}); diff --git a/src/middleware/static-files.ts b/src/middleware/static-files.ts new file mode 100644 index 0000000..ba98171 --- /dev/null +++ b/src/middleware/static-files.ts @@ -0,0 +1,28 @@ +import * as express from 'express'; +import * as serveIndex from 'serve-index'; +import * as path from 'path'; + +function isDirectory(url : string) : boolean { + return /\/$/.test(url); +} + +export function indexHandle(request : express.Request, response : express.Response, next : Function) { + if (isDirectory(request.url)) { + request.url = path.join(request.url, 'index.html'); + } + return next(); +} + +export function staticFileHandle(serveDir : string) { + return express.static(serveDir, { + dotfiles: 'ignore', + index: false, + redirect: true + }); +} + +export function serveIndexHandle(serveDir : string) { + return serveIndex(serveDir, { + icons: true + }); +} diff --git a/src/server.js b/src/server.js deleted file mode 100755 index 10da450..0000000 --- a/src/server.js +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env node - -console.log('Starting Server...'); - -const app = require('express')(); -const consts = require('./consts'); - -const compression = require('compression'); -const helmet = require('helmet'); -const serveIndex = require('serve-index'); -const AccessControl = require('express-ip-access-control'); -const opbeat = require('opbeat').start({ - active: consts.IN_PRODUCTION -}); - -const logging = require('./logging'); -const staticFiles = require('./static-files'); -const handle404 = require('./404'); -const basicAuth = require('./basic-auth'); - -if (consts.ALLOWED_IPS) { - app.set('trust proxy', true); - app.use(AccessControl({ - mode: 'allow', - allows: consts.ALLOWED_IPS, - statusCode: 404 - })); -} - -// Custom Middleware -app.use(logging); -app.use(basicAuth); - -if (consts.DIR_LIST) { - app.use(serveIndex(consts.SERVE_DIR, { - icons: true - })); -} else { - app.use(staticFiles.indexHandle); -} - -app.use(staticFiles.static); -app.use(handle404); - -// Library -app.use(compression({ level: 9 })); -app.use(helmet()); -app.use(opbeat.middleware.express()); - -const server = app.listen(consts.PORT, function () { - console.log('Server started on ' + server.address().port); -}); - -module.exports = server; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..6753f79 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,53 @@ +import * as express from 'express'; + +import * as AccessControl from 'express-ip-access-control'; +import * as compression from 'compression'; +import * as helmet from 'helmet'; +import * as opbeat from 'opbeat'; + +import logging from './middleware/logging'; +import basicAuthHandler from './middleware/basic-auth'; +import { serveIndexHandle, indexHandle, staticFileHandle } from './middleware/static-files'; +import handle404 from './middleware/404'; + +import { Options } from './types'; + +export default function createServer(opts : Options) : express.Application { + const app = express(); + + if (process.env.NODE_ENV !== 'test') { + app.use(logging); + } + + if (opts.allowed_ips.length) { + app.set('trust proxy', true); + app.use(AccessControl({ + mode: 'allow', + allows: opts.allowed_ips, + statusCode: 404 + })); + } + + if (opts.basicAuth.length) { + app.use(basicAuthHandler(opts.basicAuth[0], opts.basicAuth[1])); + } + + if (opts.dirList) { + app.use(serveIndexHandle(opts.serveDir)); + } else { + app.use(indexHandle); + } + + app.use(staticFileHandle(opts.serveDir)); + app.use(handle404(opts.serveDir)); + + app.use(compression({ level: 9 })); + app.use(helmet()); + if (opts.opbeat) { + app.use(opbeat.start({ + active: opts.opbeat + }).middleware.express()); + } + + return app; +} diff --git a/src/static-files.js b/src/static-files.js deleted file mode 100644 index 773add7..0000000 --- a/src/static-files.js +++ /dev/null @@ -1,16 +0,0 @@ -const express = require('express'); -const path = require('path'); -const { SERVE_DIR } = require('./consts'); - -module.exports.indexHandle = function (request, response, next) { - if (request.url.endsWith('/')) { - request.url = path.join(request.url, 'index.html'); - } - next(); -}; - -module.exports.static = express.static(SERVE_DIR, { - dotfiles: 'ignore', - index: false, - redirect: true -}); diff --git a/src/types/fakes.d.ts b/src/types/fakes.d.ts new file mode 100644 index 0000000..4f0a349 --- /dev/null +++ b/src/types/fakes.d.ts @@ -0,0 +1,12 @@ +/* Mock types that dont exist */ + +declare module 'express-ip-access-control'; +declare module 'connect-static-file'; +declare module 'express-basic-auth'; +declare module 'winston'; // doesnt like console transport +declare module 'express-winston'; +declare module 'opbeat'; +declare module 'docopt'; +declare module 'open'; +declare module 'node-fetch'; +declare module 'chai'; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..d096ad6 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,10 @@ + +export interface Options { + port: number; + allowed_ips: string[]; + basicAuth: string[]; + dirList: boolean; + serveDir: string; + opbeat: boolean; + open: boolean; +} diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..7132ec0 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,14 @@ +import createServer from '../src/server'; +import { Options } from '../src/types'; +import fetch from 'node-fetch'; + + +export function runServer(opts: Object, url : string, callback: Function) { + const app = createServer(opts as Options); + const server = app.listen(1234, function () { + return fetch('http://0.0.0.0:1234' + url).then(function (response : any) { + server.close(); + callback(response); + }); + }); +} diff --git a/tests/server.test.js b/tests/server.test.js deleted file mode 100644 index d9cca10..0000000 --- a/tests/server.test.js +++ /dev/null @@ -1,42 +0,0 @@ -const request = require('supertest'); -const fs = require('fs'); - - -describe('Server', function () { - var server; - before(function () { - server = require('../src/server'); - }); - - after(function () { - server.close(); - }); - - it('responds to /', function (done) { - request(server) - .get('/') - .expect(200, done); - }); - - it('returns 404 on bad path', function (done) { - request(server) - .get('/foo/bar') - .expect(404, done); - }); - - describe('index route', function () { - const body = fs.readFileSync(__dirname + '/../site/index.html').toString(); - - it('should render /index.html', function (done) { - request(server) - .get('/index.html') - .expect(200, body, done); - }); - - it('should render /', function (done) { - request(server) - .get('/') - .expect(200, body, done); - }); - }); -}); diff --git a/tests/server.test.ts b/tests/server.test.ts new file mode 100644 index 0000000..0aef94c --- /dev/null +++ b/tests/server.test.ts @@ -0,0 +1,61 @@ +import { expect } from 'chai'; +import { runServer } from './helpers'; +import * as fs from 'fs'; +import * as path from 'path'; + + +describe('Server', function () { + it('should test', function () { + expect(2 + 2).to.equal(4); + }); + + it('Should be usable', function (done) { + runServer({ + allowed_ips: [], + basicAuth: [], + dirList: false, + serveDir: 'site/', + opbeat: false, + open: false + }, '/index.html', function (response : any) { + expect(response.status).to.equal(200); + expect(response.url).to.include('/index.html'); + done(); + }); + }); + + it('Should respond with 404 on bad path', function (done) { + runServer({ + allowed_ips: [], + basicAuth: [], + dirList: false, + serveDir: 'site/', + opbeat: false, + open: false + }, '/foo/bar', function (response : any) { + expect(response.ok).to.be.false; + expect(response.status).to.equal(404); + expect(response.text()).to.eventually.include('Cannot GET').notify(done); + }); + }); + + describe('index route', function () { + const body = fs.readFileSync(path.join(__dirname, '..', 'site', 'index.html')).toString(); + + ['', '/', '/index.html'].forEach(function (path : string) { + it('should render ' + path, function (done) { + runServer({ + allowed_ips: [], + basicAuth: [], + dirList: false, + serveDir: 'site/', + opbeat: false, + open: false + }, path, function (response : any) { + expect(response.status).to.equal(200); + expect(response.text()).to.eventually.equal(body).notify(done); + }); + }); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ac8db53 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "outDir": "dist", + "allowSyntheticDefaultImports": true, + "noImplicitAny": true, + "experimentalDecorators": true, + "preserveConstEnums": true, + "allowJs": true, + "sourceMap": true, + "pretty": true + }, + "filesGlob": [ + "typings/index.d.ts", + "src/**/*.ts" + ], + "exclude": [ + "dist", + "node_modules", + "scripts/", + "tests/" + ], + "typeRoots": [ + "node_modules", + "typings", + "src/types" + ], + "compileOnSave": false +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..501c52d --- /dev/null +++ b/tslint.json @@ -0,0 +1,7 @@ +{ + "extends": "tslint:recommended", + "rules": { + "quotemark": [true, "single"], + "trailing-comma": [false] + } +} diff --git a/typings.json b/typings.json new file mode 100644 index 0000000..cea7d4a --- /dev/null +++ b/typings.json @@ -0,0 +1,16 @@ +{ + "dependencies": { + "compression": "registry:dt/compression#0.0.0+20160725212620", + "debug": "registry:npm/debug#2.0.0+20160723033700", + "express": "registry:npm/express#4.14.0+20160925001530", + "helmet": "registry:dt/helmet#0.0.0+20161005184000", + "serve-index": "registry:dt/serve-index#1.7.2+20160428043022" + }, + "globalDependencies": { + "node": "registry:dt/node#7.0.0+20170204020307" + }, + "globalDevDependencies": { + "chai": "registry:dt/chai#3.4.0+20170217154556", + "mocha": "registry:dt/mocha#2.2.5+20170204022515" + } +}