├── .gitignore ├── .npmignore ├── Cakefile ├── LICENSE ├── README.md ├── package.json ├── src └── index.coffee └── test └── test.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | .swp 2 | node_modules/ 3 | lib/* 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .swp 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /Cakefile: -------------------------------------------------------------------------------- 1 | fs = require 'fs' 2 | {exec} = require 'child_process' 3 | util = require 'util' 4 | glob = require 'glob' 5 | muffin = require 'muffin' 6 | 7 | option '-w', '--watch', 'continue to watch the files and rebuild them when they change' 8 | option '-c', '--commit', 'operate on the git index instead of the working tree' 9 | option '-m', '--compare', 'compare across git refs, stats task only.' 10 | 11 | # Define a Cake task called build 12 | task 'build', 'compile library', (options) -> 13 | # Run a map on all the files in the top directory 14 | muffin.run 15 | files: './src/**/*' 16 | options: options 17 | # For any file matching 'src/*.coffee', compile it to 'lib/*.js' 18 | map: 19 | 'src/(.+).coffee' : (matches) -> muffin.compileScript(matches[0], "lib/#{matches[1]}.js", options) 20 | console.log "Watching src..." if options.watch 21 | 22 | task 'stats', 'print source code stats', (options) -> 23 | muffin.statFiles(['lib/index.js'], options) 24 | 25 | task 'doc', 'autogenerate docco anotated source and node IDL files', (options) -> 26 | muffin.run 27 | files: './src/**/*' 28 | options: options 29 | map: 30 | 'src/index.coffee' : (matches) -> muffin.doccoFile(matches[0], options) 31 | 32 | task 'test', -> -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2012 Marco Pantaleoni 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## About mongoose-file 2 | 3 | [mongoose][] plugin that adds a file field to a mongoose schema. 4 | This is especially suited to handle file uploads with [nodejs][]/[expressjs][]. 5 | 6 | ## Install 7 | 8 | npm install mongoose-file 9 | 10 | ## Usage 11 | 12 | The plugin adds a file field to the mongoose schema. Assigning to the **file** property of the field causes (optionally) the file to be moved into place (see `upload_to` below) and the field sub-properties to be assigned. 13 | This field expects to be assigned (to its `file` sub-field, as said) an object with a semantic like that of an [expressjs][] request file object (see [req.files](http://expressjs.com/api.html#req.files)). 14 | Assigning to the field `file` property caused the instance to be marked as modified (but it's not saved automatically). 15 | 16 | The field added to the schema is a compound JavaScript object, containing the following fields: 17 | 18 | * `name` - original name of the uploaded file, without the directory components 19 | * `path` - the full final path where the uploaded file is stored 20 | * `rel` - path relative to a user specified directory (see the `relative_to` option below) 21 | * `type` - the file type 22 | * `size` - the file size 23 | * `lastModified` - the uploaded file object `lastModifiedDate` value 24 | 25 | These values are extracted from the request-like file object received on assignment. 26 | 27 | When attaching the plugin, it's possible to specify an options object containing the following parameters: 28 | 29 | * `name` - the field name (`name`), which defaults to `file` 30 | * `change_cb` - a callback function called whenever the file path is changed. It's called in the context of the model instance, and receives as parameters the field name, the new path value and the previous one. 31 | * `upload_to` - the directory name where the file will be moved from the temporary upload directory. If this is a function instead of a string, it will be called in the context of the model instance with the file object as a parameter, and it must return a string 32 | * `relative_to` - the base directory name used to construct the relative path stored in the `rel` subfield. If this is a function, it will be called in the context of the model instance with the file object as a parameter. This can be useful to get a path usable from HTML, like in `` `src` attribute. 33 | 34 | ### JavaScript 35 | 36 | ```javascript 37 | var mongoose = require('mongoose'); 38 | var filePluginLib = require('mongoose-file'); 39 | var filePlugin = filePluginLib.filePlugin; 40 | var make_upload_to_model = filePluginLib.make_upload_to_model; 41 | 42 | ... 43 | 44 | var uploads_base = path.join(__dirname, "uploads"); 45 | var uploads = path.join(uploads_base, "u"); 46 | ... 47 | 48 | var SampleSchema = new Schema({ 49 | ... 50 | }); 51 | SampleSchema.plugin(filePlugin, { 52 | name: "photo", 53 | upload_to: make_upload_to_model(uploads, 'photos'), 54 | relative_to: uploads_base 55 | }); 56 | var SampleModel = db.model("SampleModel", SampleSchema); 57 | ``` 58 | 59 | ### [CoffeeScript][] 60 | 61 | ```coffeescript 62 | mongoose = require 'mongoose' 63 | filePluginLib = require 'mongoose-file' 64 | filePlugin = filePluginLib.filePlugin 65 | make_upload_to_model = filePluginLib.make_upload_to_model 66 | 67 | ... 68 | uploads_base = path.join(__dirname, "uploads") 69 | uploads = path.join(uploads_base, "u") 70 | ... 71 | 72 | SampleSchema = new Schema 73 | ... 74 | SampleSchema.plugin filePlugin, 75 | name: "photo" 76 | upload_to: make_upload_to_model(uploads, 'photos') 77 | relative_to: uploads_base 78 | SampleModel = db.model("SampleModel", SampleSchema) 79 | ``` 80 | 81 | ## Bugs and pull requests 82 | 83 | Please use the github [repository][] to notify bugs and make pull requests. 84 | 85 | ## License 86 | 87 | This software is © 2012 Marco Pantaleoni, released under the MIT licence. Use it, fork it. 88 | 89 | See the LICENSE file for details. 90 | 91 | [mongoose]: http://mongoosejs.com 92 | [CoffeeScript]: http://jashkenas.github.com/coffee-script/ 93 | [nodejs]: http://nodejs.org/ 94 | [expressjs]: http://expressjs.com 95 | [Mocha]: http://visionmedia.github.com/mocha/ 96 | [repository]: http://github.com/panta/mongoose-file 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { "name": "mongoose-file" 2 | , "description": "Mongoose plugin adding a file field to a schema - useful for nodejs file uploads" 3 | , "version": "0.0.2" 4 | , "homepage": "https://github.com/panta/mongoose-file" 5 | , "author": "Marco Pantaleoni " 6 | , "dependencies": { 7 | "mongoose": ">= 3.0.1" 8 | , "mkdirp": ">= 0.3.4" 9 | } 10 | , "devDependencies": { 11 | "coffee-script": ">= 1.3.3" 12 | , "muffin": ">= 0.6.2" 13 | , "glob": ">= 3.0.1" 14 | , "mocha": ">= 1.4.2" 15 | , "chai": ">= 1.2.0" 16 | } 17 | , "keywords": ["mongoose", "plugin", "plugins", "types", "file", "upload", "express"] 18 | , "repository": { 19 | "type": "git" 20 | , "url": "git://github.com/panta/mongoose-file.git" 21 | } 22 | , "bugs": { 23 | "url" : "https://github.com/panta/mongoose-file/issues" 24 | } 25 | , "licenses": [{ 26 | "type": "MIT", 27 | "url": "https://raw.github.com/panta/mongoose-file/master/LICENSE" 28 | }] 29 | , "directories" : { 30 | "lib" : "./lib" 31 | , "test" : "./test" 32 | } 33 | , "scripts": { 34 | "watch": "coffee -c -w -o lib src" 35 | , "prepublish": "cake build" 36 | , "test": "NODE_ENV=test node_modules/.bin/mocha --compilers coffee:coffee-script --timeout 10000 -R spec test/*.coffee" 37 | } 38 | , "main": "lib/index.js" 39 | , "engines": { 40 | "node": ">= 0.6.0" 41 | , "npm": ">= 1.0.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/index.coffee: -------------------------------------------------------------------------------- 1 | mongoose = require('mongoose') 2 | 3 | path = require('path') 4 | fs = require('fs') 5 | mkdirp = require('mkdirp') 6 | 7 | Schema = mongoose.Schema 8 | ObjectId = Schema.ObjectId 9 | 10 | # --------------------------------------------------------------------- 11 | # helper functions 12 | # --------------------------------------------------------------------- 13 | 14 | # Extend a source object with the properties of another object (shallow copy). 15 | extend = (dst, src) -> 16 | for key, val of src 17 | dst[key] = val 18 | dst 19 | 20 | # Add missing properties from a `src` object. 21 | defaults = (dst, src) -> 22 | for key, val of src 23 | if not (key of dst) 24 | dst[key] = val 25 | dst 26 | 27 | # Add a new field by name to a mongoose schema 28 | addSchemaField = (schema, pathname, fieldSpec) -> 29 | fieldSchema = {} 30 | fieldSchema[pathname] = fieldSpec 31 | schema.add fieldSchema 32 | 33 | addSchemaSubField = (schema, masterPathname, subName, fieldSpec) -> 34 | addSchemaField schema, "#{masterPathname}.#{subName}", fieldSpec 35 | 36 | is_callable = (f) -> 37 | (typeof f is 'function') 38 | 39 | # --------------------------------------------------------------------- 40 | # M O N G O O S E P L U G I N S 41 | # --------------------------------------------------------------------- 42 | # http://mongoosejs.com/docs/plugins.html 43 | 44 | filePlugin = (schema, options={}) -> 45 | pathname = options.name or 'file' 46 | onChangeCb = options.change_cb or null 47 | upload_to = options.upload_to or null # if null, uploaded file is left in the temp upload dir 48 | relative_to = options.relative_to or null # if null, .rel field is equal to .path 49 | 50 | # fieldSchema = {} 51 | # fieldSchema[pathname] = {} # mixed: { type: Schema.Types.Mixed } 52 | # schema.add fieldSchema 53 | # fieldSchema = {} 54 | # fieldSchema["#{pathname}.name"] = String 55 | # schema.add fieldSchema 56 | # fieldSchema = {} 57 | # fieldSchema["#{pathname}.path"] = String 58 | # schema.add fieldSchema 59 | # fieldSchema = {} 60 | # fieldSchema["#{pathname}.type"] = {type: String} 61 | # schema.add fieldSchema 62 | # fieldSchema = {} 63 | # fieldSchema["#{pathname}.size"] = Number 64 | # schema.add fieldSchema 65 | # fieldSchema = {} 66 | # fieldSchema["#{pathname}.lastModified"] = Date 67 | # schema.add fieldSchema 68 | 69 | # fieldSchema = {} 70 | # fieldSchema[pathname] = 71 | # name: String 72 | # path: String 73 | # rel: String 74 | # type: String 75 | # size: Number 76 | # lastModified: Date 77 | # schema.add fieldSchema 78 | # fieldSchema = {} 79 | # fieldSchema[pathname] = {} # mixed: { type: Schema.Types.Mixed } 80 | # schema.add fieldSchema 81 | 82 | addSchemaField schema, pathname, {} # mixed: { type: Schema.Types.Mixed } 83 | addSchemaSubField schema, pathname, 'name', { type: String, default: () -> null } 84 | addSchemaSubField schema, pathname, 'path', { type: String, default: () -> null } 85 | addSchemaSubField schema, pathname, 'rel', { type: String, default: () -> null } 86 | addSchemaSubField schema, pathname, 'type', { type: String, default: () -> null } 87 | addSchemaSubField schema, pathname, 'size', { type: Number, default: () -> null } 88 | addSchemaSubField schema, pathname, 'lastModified', { type: Date, default: () -> null } 89 | 90 | schema.virtual("#{pathname}.file").set (fileObj) -> 91 | u_path = fileObj.path 92 | if upload_to 93 | # move from temp. upload directory to final destination 94 | if is_callable(upload_to) 95 | dst = upload_to.call(@, fileObj) 96 | else 97 | dst = path.join(upload_to, fileObj.name) 98 | dst_dirname = path.dirname(dst) 99 | mkdirp dst_dirname, (err) => 100 | throw err if err 101 | fs.rename u_path, dst, (err) => 102 | if (err) 103 | # delete the temporary file, so that the explicitly set temporary upload dir does not get filled with unwanted files 104 | fs.unlink u_path, (err) => 105 | throw err if err 106 | throw err 107 | console.log("moved from #{u_path} to #{dst}") 108 | rel = dst 109 | if relative_to 110 | if is_callable(relative_to) 111 | rel = relative_to.call(@, fileObj) 112 | else 113 | rel = path.relative(relative_to, dst) 114 | @set("#{pathname}.name", fileObj.name) 115 | @set("#{pathname}.path", dst) 116 | @set("#{pathname}.rel", rel) 117 | @set("#{pathname}.type", fileObj.type) 118 | @set("#{pathname}.size", fileObj.size) 119 | @set("#{pathname}.lastModified", fileObj.lastModifiedDate) 120 | @markModified(pathname) 121 | else 122 | dst = u_path 123 | rel = dst 124 | if relative_to 125 | if is_callable(relative_to) 126 | rel = relative_to.call(@, fileObj) 127 | else 128 | rel = path.relative(relative_to, dst) 129 | @set("#{pathname}.name", fileObj.name) 130 | @set("#{pathname}.path", dst) 131 | @set("#{pathname}.rel", rel) 132 | @set("#{pathname}.type", fileObj.type) 133 | @set("#{pathname}.size", fileObj.size) 134 | @set("#{pathname}.lastModified", fileObj.lastModifiedDate) 135 | @markModified(pathname) 136 | schema.pre 'set', (next, path, val, typel) -> 137 | if path is "#{pathname}.path" 138 | if onChangeCb 139 | oldValue = @get("#{pathname}.path") 140 | console.log("old: #{oldValue} new: #{val}") 141 | onChangeCb.call(@, pathname, val, oldValue) 142 | next() 143 | 144 | make_upload_to_model = (basedir, subdir) -> 145 | b_dir = basedir 146 | s_dir = subdir 147 | upload_to_model = (fileObj) -> 148 | dstdir = b_dir 149 | if s_dir 150 | dstdir = path.join(dstdir, s_dir) 151 | id = @get('id') 152 | if id 153 | dstdir = path.join(dstdir, "#{id}") 154 | path.join(dstdir, fileObj.name) 155 | upload_to_model 156 | 157 | # -- exports ---------------------------------------------------------- 158 | 159 | module.exports = 160 | filePlugin: filePlugin 161 | make_upload_to_model: make_upload_to_model 162 | -------------------------------------------------------------------------------- /test/test.coffee: -------------------------------------------------------------------------------- 1 | chai = require 'chai' 2 | assert = chai.assert 3 | expect = chai.expect 4 | should = chai.should() 5 | mongoose = require 'mongoose' 6 | 7 | fs = require 'fs' 8 | path = require 'path' 9 | 10 | index = require '../src/index' 11 | 12 | PLUGIN_TIMEOUT = 800 13 | 14 | rmDir = (dirPath) -> 15 | try 16 | files = fs.readdirSync(dirPath) 17 | catch e 18 | return 19 | if files.length > 0 20 | i = 0 21 | 22 | while i < files.length 23 | continue if files[i] in ['.', '..'] 24 | filePath = dirPath + "/" + files[i] 25 | if fs.statSync(filePath).isFile() 26 | fs.unlinkSync filePath 27 | else 28 | rmDir filePath 29 | i++ 30 | fs.rmdirSync dirPath 31 | 32 | db = mongoose.createConnection('localhost', 'mongoose_file_tests') 33 | db.on('error', console.error.bind(console, 'connection error:')) 34 | 35 | uploads_base = __dirname + "/uploads" 36 | uploads = uploads_base + "/u" 37 | 38 | tmpFilePath = '/tmp/mongoose-file-test.txt' 39 | uploadedDate = new Date() 40 | uploadedFile = 41 | size: 12345 42 | path: tmpFilePath 43 | name: 'photo.png' 44 | type: 'image/png', 45 | hash: false, 46 | lastModifiedDate: uploadedDate 47 | 48 | Schema = mongoose.Schema 49 | ObjectId = Schema.ObjectId 50 | 51 | SimpleSchema = new Schema 52 | name: String 53 | title: String 54 | 55 | describe 'WHEN working with the plugin', -> 56 | before (done) -> 57 | done() 58 | 59 | after (done) -> 60 | SimpleModel = db.model("SimpleModel", SimpleSchema) 61 | SimpleModel.remove {}, (err) -> 62 | return done(err) if err 63 | rmDir(uploads_base) 64 | done() 65 | 66 | describe 'library', -> 67 | it 'should exist', (done) -> 68 | should.exist index 69 | done() 70 | 71 | describe 'adding the plugin', -> 72 | it 'should work', (done) -> 73 | 74 | SimpleSchema.plugin index.filePlugin, 75 | name: "photo", 76 | upload_to: index.make_upload_to_model(uploads, 'photos'), 77 | relative_to: uploads_base 78 | SimpleModel = db.model("SimpleModel", SimpleSchema) 79 | 80 | instance = new SimpleModel({name: 'testName', title: 'testTitle'}) 81 | should.exist instance 82 | should.equal instance.isModified(), true 83 | instance.should.have.property 'name', 'testName' 84 | instance.should.have.property 'title', 'testTitle' 85 | instance.should.have.property 'photo' 86 | should.exist instance.photo 87 | instance.photo.should.have.property 'name' 88 | instance.photo.should.have.property 'path' 89 | instance.photo.should.have.property 'rel' 90 | instance.photo.should.have.property 'type' 91 | instance.photo.should.have.property 'size' 92 | instance.photo.should.have.property 'lastModified' 93 | should.not.exist instance.photo.name 94 | should.not.exist instance.photo.path 95 | should.not.exist instance.photo.rel 96 | should.not.exist instance.photo.type 97 | should.not.exist instance.photo.size 98 | should.not.exist instance.photo.lastModified 99 | done() 100 | 101 | describe 'assigning to the instance field', -> 102 | it 'should populate subfields', (done) -> 103 | 104 | SimpleSchema.plugin index.filePlugin, 105 | name: "photo", 106 | upload_to: index.make_upload_to_model(uploads, 'photos'), 107 | relative_to: uploads_base 108 | SimpleModel = db.model("SimpleModel", SimpleSchema) 109 | 110 | instance = new SimpleModel({name: 'testName', title: 'testTitle'}) 111 | should.exist instance 112 | should.exist instance.photo 113 | should.equal instance.isModified(), true 114 | 115 | fs.writeFile tmpFilePath, "Dummy content here.\n", (err) -> 116 | return done(err) if (err) 117 | 118 | instance.set('photo.file', uploadedFile) 119 | # give the plugin some time to notice the assignment and execute its 120 | # asynchronous code 121 | setTimeout -> 122 | should.equal instance.isModified(), true 123 | should.exist instance.photo.name 124 | should.exist instance.photo.path 125 | should.exist instance.photo.rel 126 | should.exist instance.photo.type 127 | should.exist instance.photo.size 128 | should.exist instance.photo.lastModified 129 | 130 | should.equal instance.photo.name, uploadedFile.name 131 | should.not.equal instance.photo.path, uploadedFile.path 132 | should.equal instance.photo.type, uploadedFile.type 133 | should.equal instance.photo.size, uploadedFile.size 134 | should.equal instance.photo.lastModified, uploadedFile.lastModifiedDate 135 | 136 | done() 137 | , PLUGIN_TIMEOUT 138 | 139 | describe 'assigning to the instance field', -> 140 | it 'should mark as modified', (done) -> 141 | 142 | SimpleSchema.plugin index.filePlugin, 143 | name: "photo", 144 | upload_to: index.make_upload_to_model(uploads, 'photos'), 145 | relative_to: uploads_base 146 | SimpleModel = db.model("SimpleModel", SimpleSchema) 147 | 148 | instance = new SimpleModel({name: 'testName', title: 'testTitle'}) 149 | should.exist instance 150 | should.equal instance.isModified(), true 151 | 152 | instance.save (err) -> 153 | return done(err) if err 154 | 155 | should.equal instance.isModified(), false 156 | 157 | fs.writeFile tmpFilePath, "Dummy content here.\n", (err) -> 158 | return done(err) if (err) 159 | 160 | instance.set('photo.file', uploadedFile) 161 | # give the plugin some time to notice the assignment and execute its 162 | # asynchronous code 163 | setTimeout -> 164 | should.equal instance.isModified(), true 165 | 166 | instance.save (err) -> 167 | return done(err) if err 168 | 169 | should.equal instance.isModified(), false 170 | 171 | done() 172 | , PLUGIN_TIMEOUT 173 | --------------------------------------------------------------------------------