├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── config └── options.js ├── lib ├── _file_info.js ├── _request_handler.js └── _upload_handler.js ├── package.json ├── public └── images │ ├── github.jpeg │ └── iphone.png ├── server.js ├── test ├── node-gm-test.js ├── node-static-test.js └── server.test.js └── tmp └── test.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Deployed apps should consider commenting this line out: 24 | # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 25 | node_modules 26 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.8" 4 | - "0.10" 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 TonyAdo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TESTS = test/*.test.js 2 | REPORTER = spec 3 | TIMEOUT = 10000 4 | MOCHA_OPTS = 5 | 6 | install-test: 7 | @NODE_ENV=test npm install 8 | 9 | test: install-test 10 | @NODE_ENV=test ./node_modules/.bin/mocha \ 11 | --reporter $(REPORTER) \ 12 | --timeout $(TIMEOUT) \ 13 | $(MOCHA_OPTS) \ 14 | $(TESTS) 15 | 16 | .PHONY: test 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ImageServer 2 | ![Build Status](https://api.travis-ci.org/AdoHe/ImageServer.png?branch=master) 3 | 4 | Node.js based image server. 5 | 6 | ## Features 7 | 8 | * File type validation 9 | * Image resizing 10 | * Cache Control 11 | * URI Generation 12 | * HTTPS Support 13 | 14 | ## Getting started 15 | 16 | First download and install [GraphicsMagick](http://www.graphicsmagick.org/) or [ImageMagick](http://www.imagemagick.org/). In Mac OS X, you can simply use [Homebrew](http://brew.sh/) and do: 17 | 18 | ``` 19 | brew install imagemagick 20 | brew install graphicsmagick 21 | ``` 22 | if you want WebP support with ImageMagick, you must add the WebP option: 23 | 24 | ``` 25 | brew install imagemagick --with-webp 26 | ``` 27 | then clone the repo: 28 | 29 | ``` 30 | git clone git@github.com:AdoHe/ImageServer.git 31 | ``` 32 | 33 | ## Basic Usage 34 | 35 | `Get /images/test.png` 36 | 37 | Get the test.png, returns 200 status code on success, otherwise 404. 38 | 39 | `Post /` 40 | 41 | Upload or post an image to the server. You can use any kind of clients to do this, but must pay 42 | attention to these: 43 | 44 | * Content-Type: multipart/form-data 45 | * Supported Image types: refer the `config/options.js` for supported image types 46 | 47 | Returns a JSON array contains the upload image names with files key. 48 | 49 | `Delete /images/test.png` 50 | 51 | Delete the test.png and related images. Returns `{success: true}` otherwise `{success: false}`. 52 | 53 | 54 | ## Configure 55 | 56 | A simple configuration file is placed in `config/options.js`, just modify this to meet your needs. 57 | 58 | 59 | ## Tests 60 | 61 | ``` 62 | $ make test 63 | ``` 64 | 65 | ## Contributing 66 | 67 | It took me sometime doing this, hope this will help you. If you find anything wrong and you have 68 | better solutions, you are welcome to send pull requests or open issues:) 69 | 70 | ## TODO 71 | 72 | Use [sharp](https://github.com/lovell/sharp) to do image resizing. 73 | 74 | ## Licence 75 | 76 | (The MIT License) 77 | 78 | Copyright (c) 2010 [TonyAdo](https://github.com/AdoHe) 79 | 80 | Permission is hereby granted, free of charge, to any person obtaining 81 | a copy of this software and associated documentation files (the 82 | 'Software'), to deal in the Software without restriction, including 83 | without limitation the rights to use, copy, modify, merge, publish, 84 | distribute, sublicense, and/or sell copies of the Software, and to 85 | permit persons to whom the Software is furnished to do so, subject to 86 | the following conditions: 87 | 88 | The above copyright notice and this permission notice shall be 89 | included in all copies or substantial portions of the Software. 90 | 91 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 92 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 93 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 94 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 95 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 96 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 97 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 98 | -------------------------------------------------------------------------------- /config/options.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | basePath = path.resolve(__dirname, '..'); 3 | 4 | module.exports = { 5 | tmpDir: basePath + '/tmp', 6 | publicDir: basePath + '/public', 7 | uploadDir: basePath + '/public/images', 8 | uploadUrl: '/images/', 9 | minFileSize: 1, 10 | maxFileSize: 10485760, // 10MB 11 | maxPostSize: 10485760, // 10MB 12 | acceptFileTypes: /.+/i, 13 | imageTypes: /\.(gif|jpe?g|png|bmp|swf)$/i, 14 | imageVersions: { 15 | 'thumbnails': { 16 | width: 80, 17 | height: 80 18 | } 19 | }, 20 | accessControl: { 21 | allowOrigin: '*', 22 | allowMethods: 'OPTIONS, HEAD, GET, POST, PUT, DELETE', 23 | allowHeaders: 'Content-Type, Content-Range, Content-Disposition' 24 | }, 25 | /* 26 | ssl: { 27 | key: '', 28 | cert: '' 29 | } 30 | */ 31 | nodeStatic: { 32 | cache: 3600 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /lib/_file_info.js: -------------------------------------------------------------------------------- 1 | var options = require('../config/options'), 2 | fs = require('fs'), 3 | path = require('path'), 4 | _existsSync = fs.existsSync || path.existsSync, 5 | nameCountRegexp = /(?:(?: \(([\d]+)\))?(\.[^.]+))?$/, 6 | nameCountFunc = function (s, index, ext) { 7 | return ' (' + ((parseInt(index, 10) || 0) + 1) + ')' + (ext || ''); 8 | }; 9 | 10 | 11 | function FileInfo (file) { 12 | this.name = file.name; 13 | this.size = file.size; 14 | this.type = file.type; 15 | this.deleteType = 'DELETE'; 16 | } 17 | 18 | FileInfo.prototype.initUrl = function (req) { 19 | if (!this.error) { 20 | var that = this, 21 | baseUrl = (options.ssl ? 'https:' : 'http:') + 22 | '//' + req.headers.host + options.uploadUrl; 23 | this.url = this.deleteUrl = baseUrl + encodeURIComponent(this.name); 24 | Object.keys(options.imageVersions).forEach(function (version) { 25 | if (_existsSync( 26 | options.uploadDir + '/' + version + '/' + that.name 27 | )) { 28 | that[version + 'Url'] = baseUrl + version + '/' + 29 | encodeURIComponent(that.name); 30 | } 31 | }); 32 | } 33 | } 34 | 35 | FileInfo.prototype.safeName = function () { 36 | // prevent directory traversal and creating system hidden files 37 | this.name = path.basename(this.name).replace(/^\.+/, ''); 38 | while (_existsSync(options.uploadDir + '/' + this.name)) { 39 | this.name = this.name.replace(nameCountRegexp, nameCountFunc); 40 | } 41 | } 42 | 43 | FileInfo.prototype.validate = function () { 44 | if (options.minFileSize && options.minFileSize > this.size) { 45 | this.error = 'File is too small'; 46 | } 47 | if (options.maxFileSize && options.maxFileSize < this.size) { 48 | this.error = 'File is too big'; 49 | } 50 | if (!options.acceptFileTypes.test(this.type)) { 51 | this.error = 'File type not wrong'; 52 | } 53 | 54 | return !this.error; 55 | } 56 | 57 | // Expose the file info module 58 | module.exports = exports = FileInfo; 59 | -------------------------------------------------------------------------------- /lib/_request_handler.js: -------------------------------------------------------------------------------- 1 | var options = require('../config/options'), 2 | nodeStatic = require('node-static'), 3 | UploadHandler = require('./_upload_handler'), 4 | path = require('path'), 5 | fileServer = new nodeStatic.Server(options.publicDir, options.nodeStatic); 6 | 7 | module.exports = function (req, res) { 8 | 9 | // Set headers 10 | res.setHeader( 11 | 'Access-Control-Allow-Origin', 12 | options.accessControl.allowOrigin 13 | ); 14 | 15 | res.setHeader( 16 | 'Access-Control-Allow-Methods', 17 | options.accessControl.allowMethods 18 | ); 19 | 20 | res.setHeader( 21 | 'Access-Control-Allow-Headers', 22 | options.accessControl.allowHeaders 23 | ); 24 | 25 | var setNoCacheHeaders = function () { 26 | res.setHeader('Pragma', 'no-cache'); 27 | res.setHeader('Cache-Control', 'private, no-cache, no-store, max-age=0, must-revalidate'); 28 | res.setHeader('Expires', '0'); 29 | res.setHeader('Content-Disposition', 'inline; filename="files.json"'); 30 | }, 31 | utf8encode = function (str) { 32 | return unescape(encodeURIComponent(str)); 33 | }, 34 | handleResult = function (result, redirect) { 35 | if (redirect) { 36 | res.writeHead(302, { 37 | 'Location': redirect.replace( 38 | /%s/, 39 | encodeURIComponent(JSON.stringify(result)) 40 | ) 41 | }); 42 | res.end(); 43 | } else { 44 | if (req.headers.accept) { 45 | res.writeHead(200, { 46 | 'Content-Type': req.headers.accept 47 | .indexOf('application/json') !== -1 ? 48 | 'application/json' : 'text/plain'}); 49 | } else { 50 | res.writeHead(200, {'Content-Type': 'application/json'}); 51 | } 52 | res.end(JSON.stringify(result)); 53 | } 54 | }, 55 | handler = new UploadHandler(req, res, handleResult); 56 | 57 | switch (req.method) { 58 | case 'OPTIONS': 59 | res.end(); 60 | break; 61 | case 'HEAD': 62 | case 'GET': 63 | if (req.url === '/') { 64 | setNoCacheHeaders(); 65 | if (req.method === 'GET') { 66 | handler.get(); 67 | } else { 68 | res.end(); 69 | } 70 | } else { 71 | fileServer.serve(req, res); 72 | } 73 | break; 74 | case 'POST': 75 | setNoCacheHeaders(); 76 | handler.post(); 77 | break; 78 | case 'DELETE': 79 | handler.destroy(); 80 | break; 81 | default: 82 | res.statusCode = 405; 83 | res.end(); 84 | } 85 | 86 | fileServer.respond = function (pathname, status, _headers, files, stat, req, res, finish) { 87 | _headers['X-Content-Type-Options'] = 'nosniff'; 88 | if (!options.imageTypes.test(files[0])) { 89 | _headers['Content-Type'] = 'application/octet-stream'; 90 | _headers['Content-Disposition'] = 'attachment; filename="' + 91 | utf8encode(path.basename(files[0])) + '"'; 92 | } 93 | nodeStatic.Server.prototype.respond.call(this, pathname, status, _headers, files, stat, req, res, finish); 94 | }; 95 | } 96 | -------------------------------------------------------------------------------- /lib/_upload_handler.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'), 2 | path = require('path'), 3 | gm = require('gm'), 4 | options = require('../config/options'), 5 | formidable = require('formidable'), 6 | FileInfo = require('./_file_info'); 7 | 8 | function UploadHandler (req, res, callback) { 9 | this.req = req; 10 | this.res = res; 11 | this.callback = callback; 12 | } 13 | 14 | /** 15 | * Get all upload files 16 | * 17 | */ 18 | UploadHandler.prototype.get = function () { 19 | var handler = this, 20 | files = []; 21 | 22 | fs.readdir(options.uploadDir, function (err, list) { 23 | list.forEach(function (name) { 24 | var stats = fs.statSync(options.uploadDir + '/' + name), 25 | fileInfo; 26 | if (stats.isFile() && name[0] !== '.') { 27 | fileInfo = new FileInfo({ 28 | name: name, 29 | size: stats.size 30 | }); 31 | fileInfo.initUrl(handler.req); 32 | files.push(fileInfo); 33 | } 34 | }); 35 | handler.callback({files: files}); 36 | }); 37 | } 38 | 39 | /** 40 | * Post a new file 41 | * 42 | */ 43 | UploadHandler.prototype.post = function () { 44 | var handler = this, 45 | form = new formidable.IncomingForm(), 46 | tmpFiles = [], 47 | map = {}, 48 | files = [], 49 | counter = 1, 50 | redirect, 51 | finish = function () { 52 | counter -= 1; 53 | if (!counter) { 54 | files.forEach(function (fileInfo) { 55 | fileInfo.initUrl(handler.req); 56 | }); 57 | handler.callback({files: files}, redirect); 58 | } 59 | }; 60 | 61 | form.uploadDir = options.tmpDir; 62 | form.on('fileBegin', function (name, file) { 63 | tmpFiles.push(file.path); 64 | var fileInfo = new FileInfo(file, handler.req, true); 65 | map[path.basename(file.path)] = fileInfo; 66 | fileInfo.safeName(); 67 | files.push(fileInfo); 68 | }).on('field', function (name, value) { 69 | if (name === 'redirect') { 70 | redirect = value; 71 | } 72 | }).on('file', function (name, file) { 73 | var fileInfo = map[path.basename(file.path)]; 74 | fileInfo.size = file.size; 75 | if (!fileInfo.validate()) { 76 | fs.unlink(file.path); 77 | return; 78 | } 79 | fs.renameSync(file.path, options.uploadDir + '/' + fileInfo.name); 80 | if (options.imageTypes.test(fileInfo.name)) { 81 | Object.keys(options.imageVersions).forEach(function (version) { 82 | counter += 1; 83 | var opts = options.imageVersions[version]; 84 | gm(options.uploadDir + '/' + fileInfo.name) 85 | .resize(opts.width, opts.height, '%') 86 | .write(options.uploadDir + '/' + version + '/' + fileInfo.name, finish); 87 | }); 88 | } 89 | }).on('aborted', function () { 90 | tmpFiles.forEach(function (file) { 91 | fs.unlink(file); 92 | }); 93 | }).on('progress', function (bytesReceived) { 94 | if (bytesReceived > options.maxPostSize) { 95 | handler.req.socket.destroy(); 96 | } 97 | }).on('error', function (e) { 98 | console.log(e); 99 | }).on('end', finish).parse(handler.req); 100 | } 101 | 102 | /** 103 | * Delete files 104 | * 105 | */ 106 | UploadHandler.prototype.destroy = function () { 107 | var handler = this, 108 | fileName; 109 | 110 | if (handler.req.url.slice(0, options.uploadUrl.length) === options.uploadUrl) { 111 | fileName = path.basename(decodeURIComponent(handler.req.url)); 112 | if (fileName[0] !== '.') { 113 | fs.unlink(options.uploadDir + '/' + fileName, function (err) { 114 | Object.keys(options.imageVersions).forEach(function (version) { 115 | fs.unlink(options.uploadDir + '/' + version + '/' + fileName); 116 | }); 117 | handler.callback({success: !err}); 118 | }); 119 | return; 120 | } 121 | } 122 | handler.callback({success: false}); 123 | } 124 | 125 | // Expose upload handler 126 | module.exports = exports = UploadHandler; 127 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imageserver", 3 | "version": "1.0.0", 4 | "description": "Node.js based image server.", 5 | "main": "server.js", 6 | "scripts": { 7 | "test": "make test", 8 | "start": "node server.js" 9 | }, 10 | "dependencies": { 11 | "node-static": "*", 12 | "gm": "*", 13 | "formidable": "*" 14 | }, 15 | "devDependencies": { 16 | "mocha": "*", 17 | "chai": "*", 18 | "request": "*" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://AdoHe@github.com/AdoHe/ImageServer.git" 23 | }, 24 | "keywords": [ 25 | "Node", 26 | "image", 27 | "server", 28 | "image resizing" 29 | ], 30 | "author": "TonyHe ", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/AdoHe/ImageServer/issues" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/images/github.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adohe-zz/ImageServer/68e2aebd08e0b442050386d0f175daf31b6c6726/public/images/github.jpeg -------------------------------------------------------------------------------- /public/images/iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adohe-zz/ImageServer/68e2aebd08e0b442050386d0f175daf31b6c6726/public/images/iphone.png -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var requestHandler = require('./lib/_request_handler'), 2 | options = require('./config/options'), 3 | port = process.env.PORT || 8888; 4 | 5 | if (options.ssl) { 6 | require('https').createServer(options.ssl, requestHandler).listen(port); 7 | } else { 8 | require('http').createServer(requestHandler).listen(port); 9 | } 10 | -------------------------------------------------------------------------------- /test/node-gm-test.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'), 2 | gm = require('gm'); 3 | 4 | // obtain the size of an image 5 | gm('../public/dear.jpg') 6 | .size(function (err, size) { 7 | if (!err) { 8 | console.log(size.width + ':' + size.height); 9 | } 10 | }); 11 | 12 | // output all image properties 13 | gm('../public/dear.jpg') 14 | .identify(function (err, data) { 15 | if (!err) { 16 | console.log(data); 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /test/node-static-test.js: -------------------------------------------------------------------------------- 1 | var static = require('node-static'), 2 | options = require('../config/options'), 3 | fileServer = new static.Server(options.publicDir); 4 | 5 | require('http').createServer(function (req, res) { 6 | req.on('end', function () { 7 | fileServer.serve(req, res); 8 | }).resume(); 9 | }).listen(8000); 10 | -------------------------------------------------------------------------------- /test/server.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var http = require('http'), 4 | should = require('chai').should(), 5 | request = require('request'), 6 | fs = require('fs'), 7 | requestHandler = require('../lib/_request_handler'); 8 | 9 | describe('server.test.js', function () { 10 | 11 | before(function (done) { 12 | http.createServer(requestHandler).listen(8080); 13 | done(); 14 | }); 15 | 16 | describe('#get()', function () { 17 | 18 | it('should get an image', function (done) { 19 | request('http://127.0.0.1:8080/images/github.jpeg', function (err, response, body) { 20 | should.not.exist(err); 21 | response.statusCode.should.equal(200); 22 | should.exist(body); 23 | done(); 24 | }); 25 | }); 26 | 27 | it('should get 404 when the image not exists', function (done) { 28 | request('http://127.0.0.1:8080/images/wife.png', function (err, response, body) { 29 | should.not.exist(err); 30 | response.statusCode.should.equal(404); 31 | done(); 32 | }); 33 | }); 34 | 35 | it('should get all images', function (done) { 36 | request('http://127.0.0.1:8080/', function (err, response, body) { 37 | should.not.exist(err); 38 | response.statusCode.should.equal(200); 39 | JSON.parse(body).files.should.have.length(2); 40 | done(); 41 | }); 42 | }); 43 | }); 44 | 45 | describe('#post()', function () { 46 | 47 | /*before(function (done) { 48 | var options = { 49 | url: 'http://127.0.0.1:8080/', 50 | headers: { 51 | 'Content-Type': 'multipart/form-data' 52 | } 53 | }; 54 | fs.createReadStream('./test/ask.png').pipe(request.post(options)); 55 | done(); 56 | }); 57 | 58 | it('should get the ask.png', function (done) { 59 | request('http://127.0.0.1:8080/images/ask.png', function (err, response, body) { 60 | should.not.exist(err); 61 | response.statusCode.should.equal(200); 62 | should.exist(body); 63 | done(); 64 | }); 65 | });*/ 66 | }); 67 | 68 | describe('#destroy()', function () { 69 | 70 | it('should return success', function (done) { 71 | request.del('http://127.0.0.1:8080/images/iphone.png', function (err, response, body) { 72 | should.not.exist(err); 73 | response.statusCode.should.equal(200); 74 | //should.exist(JSON.parse(body).success); 75 | JSON.parse(body).success.should.equal(true); 76 | done(); 77 | }); 78 | }); 79 | 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /tmp/test.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adohe-zz/ImageServer/68e2aebd08e0b442050386d0f175daf31b6c6726/tmp/test.txt --------------------------------------------------------------------------------