diff --git a/.versions b/.versions index 1c163d58..e5749ea6 100644 --- a/.versions +++ b/.versions @@ -44,8 +44,8 @@ mongo-id@1.0.1 npm-mongo@1.4.39_1 observe-sequence@1.0.7 ordered-dict@1.0.4 -ostrio:cookies@2.0.0 -ostrio:files@1.3.9 +ostrio:cookies@2.0.1 +ostrio:files@1.3.10 promise@0.5.0 random@1.0.4 reactive-dict@1.1.2 diff --git a/README.md b/README.md index cf50828a..f87c8346 100644 --- a/README.md +++ b/README.md @@ -84,17 +84,9 @@ API * Default value: `/assets/app/uploads` - `collectionName` {*String*} - Collection name * Default value: `MeteorUploadFiles` + - `cacheControl` {*String*} - Default `Cache-Control` header, by default: `public, max-age=31536000, s-maxage=31536000` - `downloadRoute` {*String*} - Server Route used to retrieve files * Default value: `/cdn/storage` - - `downloadCallback` {*Function*} - Called right before initiate file download, with next context and only one argument `fileObj`: - * `fileObj` - see __Current schema__ section below - * __context__: - - `@request` - - `@response` - - `@user()` - - `@userId` - * __Notes__: - * Function should return {*Boolean*} value, to abort download - return `false`, to allow download - return `true` - `schema` {*Object*} - Collection Schema (*Not editable for current release*) - `chunkSize` {*Number*} - Upload chunk size * Default value: `272144` @@ -106,6 +98,15 @@ API * Default value: `true` - `strict` {*Boolean*} - Strict mode for partial content, if is `true` server will return `416` response code, when `range` is not specified * Default value: `false` + - `downloadCallback` {*Function*} - Called right before initiate file download, with next context and only one argument `fileObj`: + * `fileObj` - see __Current schema__ section below + * __context__: + - `@request` + - `@response` + - `@user()` + - `@userId` + * __Notes__: + * Function should return {*Boolean*} value, to abort download - return `false`, to allow download - return `true` - `protected` {*Boolean*|*Function*} - If `true` - files will be served only to authorized users, if `function()` - you're able to check visitor's permissions in your own way * Default value: `false` * If function - `function` __context__ has: diff --git a/demo/client/file.jade b/demo/client/file.jade index 9222a024..ad1e2e26 100644 --- a/demo/client/file.jade +++ b/demo/client/file.jade @@ -9,16 +9,16 @@ template(name="file") img(src="{{fileURL .}}" alt="{{name}}" style="max-width: 100%") else if isAudio - audio.center-block(controls style="max-width: 100%") + audio.center-block(controls style="max-width: 100%" autoplay preload="auto" loop) source(src="{{fileURL .}}?play=true" type="{{type}}") else if isVideo - video.center-block(controls style="max-width: 100%") + video.center-block(controls style="max-width: 100%" autoplay preload="auto" loop) source(src="{{fileURL .}}?play=true" type="{{type}}") else .alert.alert-info Preview is not avalible, please download file. - table.table.table-bordered.table-condenced(style="table-layout:fixed") + table.table.table-bordered.table-condensed(style="table-layout:fixed") thead tr th Name @@ -28,10 +28,10 @@ template(name="file") th Downloads tbody tr - td.text-center.ellipsis: span.label.label-default #{name} - td.text-center: span.label.label-default #{type} - td.text-center: span.label.label-default {{filesize size}} - td.text-center: span.label.label-default .#{extension} - td.text-center: a.label.label-default(title="Download \"{{name}}\"" href="{{fileURL .}}?download=true" target="_parent" download) + td.text-center: span.ellipsis.label.label-default #{name} + td.text-center: span.ellipsis.label.label-default #{type} + td.text-center: span.ellipsis.label.label-default {{filesize size}} + td.text-center: span.ellipsis.label.label-default .#{extension} + td.text-center: a.ellipsis.label.label-default(title="Download \"{{name}}\"" href="{{fileURL .}}?download=true" target="_parent" download) i.fa.fa-download | #{meta.downloads} \ No newline at end of file diff --git a/demo/client/index.jade b/demo/client/index.jade index 9bf26798..3405ce86 100644 --- a/demo/client/index.jade +++ b/demo/client/index.jade @@ -29,15 +29,17 @@ template(name="index") tbody each latest tr - td.ellipsis: a(href="{{pathFor 'file' _id=_id}}") #{name} + td: a.ellipsis(href="{{pathFor 'file' _id=_id}}") #{name} td.text-right - span.label.label-default(title="Will be removed {{removedIn}}") - i.fa.fa-fw.fa-history - | {{removedIn}} - | - span.label.label-default(title="Downloads") - i.fa.fa-download - | #{meta.downloads} - if compare filesLength '>' latest.count - tr: td(colspan="100%") - button.btn.btn-default.btn-block#loadMore(type="button" title="Show older files") Load More \ No newline at end of file + span.ellipsis(color="#fafafa") + span.label.label-default(title="Will be removed {{removedIn}}") + i.fa.fa-fw.fa-history + | {{removedIn}} + | + span.label.label-default(title="Downloads") + i.fa.fa-download + | #{meta.downloads} + + if compare filesLength '>' latest.count + .panel-footer + button.btn.btn-default.btn-block#loadMore(type="button" title="Show older files") Load More \ No newline at end of file diff --git a/demo/client/misc/_layout.jade b/demo/client/misc/_layout.jade index 9c17e9c2..33d9ce65 100644 --- a/demo/client/misc/_layout.jade +++ b/demo/client/misc/_layout.jade @@ -1,3 +1,6 @@ +head + meta(name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no") + template(name="_layout") a(href='https://github.com/VeliovGroup/Meteor-Files') img.gh-ribbon(src='https://camo.githubusercontent.com/365986a132ccd6a44c23a9169022c0b5c890c387/68747470733a2f2f73332e616d617a6f6e6177732e636f6d2f6769746875622f726962626f6e732f666f726b6d655f72696768745f7265645f6161303030302e706e67' alt='Fork me on GitHub' data-canonical-src='https://s3.amazonaws.com/github/ribbons/forkme_right_red_aa0000.png') diff --git a/demo/client/styles.sass b/demo/client/styles.sass index 9c28ac9f..b75a9612 100644 --- a/demo/client/styles.sass +++ b/demo/client/styles.sass @@ -1,3 +1,12 @@ +* + animation: newElement 350ms ease-in-out 1 + +@keyframes newElement + from + opacity: 0 + to + opacity: 1 + .container padding-top: 15px max-width: 640px @@ -8,6 +17,7 @@ max-width: 100% text-overflow: ellipsis -o-text-overflow: ellipsis + display: block button, input, select, textarea, .label user-select: none !important @@ -20,6 +30,7 @@ button, select, input, button, form, textarea, a, pre, i th text-align: center vertical-align: middle + word-break: break-word .control-btns > * width: 50% !important diff --git a/demo/lib/files.collection.coffee b/demo/lib/files.collection.coffee index a6f2283c..2d741f5f 100644 --- a/demo/lib/files.collection.coffee +++ b/demo/lib/files.collection.coffee @@ -2,6 +2,7 @@ Collections.files = new Meteor.Files debug: false storagePath: 'assets/app/uploads/uploadedFiles' collectionName: 'uploadedFiles' + chunkSize: 256*256*8 onBeforeUpload: -> if @size <= 100000 * 10 * 128 then true else "Max. file size is 128MB you've tried to upload #{filesize(@size)}" allowClientCode: false downloadCallback: (fileObj) -> diff --git a/files.coffee b/files.coffee index 914b166d..41ccfd49 100755 --- a/files.coffee +++ b/files.coffee @@ -63,25 +63,26 @@ cp = (to, from) -> @namespace Meteor @name Files @param config {Object} - Configuration object with next properties: -@param config.storagePath {String} - Storage path on file system -@param config.collectionName {String} - Collection name -@param config.downloadRoute {String} - Server Route used to retrieve files -@param config.schema {Object} - Collection Schema -@param config.chunkSize {Number} - Upload chunk size -@param config.namingFunction {Function}- Function which returns `String` @param config.debug {Boolean} - Turn on/of debugging and extra logging -@param config.permissions {Number} - Permissions which will be set to uploaded files, like: `511` or `0o777` -@param config.onBeforeUpload {Function}- Function which executes on server after receiving each chunk and on client right before beginning upload. Function context is `File` - so you are able to check for extension, mime-type, size and etc. -return `true` to continue -return `false` or `String` to abort upload -@param config.integrityCheck {Boolean} - Check file's integrity before serving to users +@param config.schema {Object} - Collection Schema +@param config.public {Boolean} - Store files in folder accessible for proxy servers, for limits, and more - read docs +@param config.strict {Boolean} - Strict mode for partial content, if is `true` server will return `416` response code, when `range` is not specified, otherwise server return `206` @param config.protected {Function} - If `true` - files will be served only to authorized users, if `function()` - you're able to check visitor's permissions in your own way function's context has: - `request` - On server only - `response` - On server only - `user()` - `userId` -@param config.public {Boolean} - Store files in folder accessible for proxy servers, for limits, and more - read docs -@param config.strict {Boolean} - Strict mode for partial content, if is `true` server will return `416` response code, when `range` is not specified, otherwise server return `206` +@param config.chunkSize {Number} - Upload chunk size +@param config.permissions {Number} - Permissions which will be set to uploaded files, like: `511` or `0o777` +@param config.storagePath {String} - Storage path on file system +@param config.cacheControl {String} - Default `Cache-Control` header +@param config.downloadRoute {String} - Server Route used to retrieve files +@param config.collectionName {String} - Collection name +@param config.namingFunction {Function}- Function which returns `String` +@param config.integrityCheck {Boolean} - Check file's integrity before serving to users +@param config.onBeforeUpload {Function}- Function which executes on server after receiving each chunk and on client right before beginning upload. Function context is `File` - so you are able to check for extension, mime-type, size and etc. +return `true` to continue +return `false` or `String` to abort upload @param config.allowClientCode {Boolean} - Allow to run `remove` from client @param config.downloadCallback {Function} - Callback triggered each time file is requested @param config.onbeforeunloadMessage {String|Function} - Message shown to user when closing browser's window or tab while upload process is running @@ -89,24 +90,23 @@ return `false` or `String` to abort upload ### class Meteor.Files constructor: (config) -> - {@storagePath, @collectionName, @downloadRoute, @schema, @chunkSize, @namingFunction, @debug, @onbeforeunloadMessage, @permissions, @allowClientCode, @onBeforeUpload, @integrityCheck, @protected, @public, @strict, @downloadCallback} = config if config + {@storagePath, @collectionName, @downloadRoute, @schema, @chunkSize, @namingFunction, @debug, @onbeforeunloadMessage, @permissions, @allowClientCode, @onBeforeUpload, @integrityCheck, @protected, @public, @strict, @downloadCallback, @cacheControl} = config if config - @collectionName ?= 'MeteorUploadFiles' - @chunkSize ?= 272144 - @namingFunction ?= -> Random._randomString 17, 'AZQWXSECDRFVTBGYNHUJMIKOLPzaqwsxecdrfvtgbyhnujimkolp' @debug ?= false - @permissions ?= 0o777 - @allowClientCode ?= true - @integrityCheck ?= true - @protected ?= false @public ?= false @strict ?= true + @protected ?= false + @chunkSize ?= 272144 + @permissions ?= 0o777 + @cacheControl ?= 'public, max-age=31536000, s-maxage=31536000' + @collectionName ?= 'MeteorUploadFiles' + @namingFunction ?= -> Random._randomString 17, 'AZQWXSECDRFVTBGYNHUJMIKOLPzaqwsxecdrfvtgbyhnujimkolp' + @integrityCheck ?= true @onBeforeUpload ?= false + @allowClientCode ?= true @downloadCallback ?= false @onbeforeunloadMessage ?= 'Upload in a progress... Do you want to abort?' - - cookie = new Cookies() if @protected and Meteor.isClient if not cookie.has('meteor_login_token') and Meteor._localStorage.getItem('Meteor.loginToken') @@ -126,14 +126,18 @@ class Meteor.Files if not @schema @schema = - name: - type: String - type: - type: String - extension: - type: String - path: - type: String + size: type: Number + name: type: String + type: type: String + path: type: String + isVideo: type: Boolean + isAudio: type: Boolean + isImage: type: Boolean + _prefix: type: String + extension: type: String + _storagePath: type: String + _downloadRoute: type: String + _collectionName: type: String meta: type: Object blackbox: true @@ -141,55 +145,41 @@ class Meteor.Files userId: type: String optional: true + updatedAt: + type: Date + autoValue: -> new Date() versions: type: Object blackbox: true - isVideo: - type: Boolean - isAudio: - type: Boolean - isImage: - type: Boolean - size: - type: Number - _prefix: - type: String - _collectionName: - type: String - _storagePath: - type: String - _downloadRoute: - type: String + check @debug, Boolean + check @schema, Object + check @public, Boolean + check @strict, Boolean + check @protected, Match.OneOf Boolean, Function + check @chunkSize, Number + check @permissions, Number check @storagePath, String - check @collectionName, String check @downloadRoute, String - check @chunkSize, Number + check @integrityCheck, Boolean + check @collectionName, String check @namingFunction, Function + check @onBeforeUpload, Match.OneOf Boolean, Function check @allowClientCode, Boolean - check @debug, Boolean - check @onbeforeunloadMessage, Match.OneOf String, Function - check @integrityCheck, Boolean - check @public, Boolean - check @protected, Match.OneOf Boolean, Function check @downloadCallback, Match.OneOf Boolean, Function - check @strict, Boolean - check @onBeforeUpload, Match.OneOf Boolean, Function - check @permissions, Number - check @schema, Object + check @onbeforeunloadMessage, Match.OneOf String, Function if @public and @protected throw new Meteor.Error 500, "[Meteor.File.#{@collectionName}]: Files can not be public and protected at the same time!" + @collection = new Mongo.Collection @collectionName @storagePath = @storagePath.replace /\/$/, '' @downloadRoute = @downloadRoute.replace /\/$/, '' - @collection = new Mongo.Collection @collectionName self = @ - @currentFile = null @cursor = null @search = {} - @cacheControl = 'public, max-age=31536000' + @currentFile = null @collection.attachSchema @schema @@ -260,17 +250,17 @@ class Meteor.Files throw new Meteor.Error 401, '[Meteor.Files] [remove()] Run code from client is not allowed!' _methods[self.methodNames.MeteorFileWrite] = (unitArray, fileData, meta = {}, first, chunksQty, currentChunk, totalSentChunks, randFileName, part, partsQty, fileSize) -> - check unitArray, Match.OneOf Uint8Array, Object - check fileData, Object + check part, Number check meta, Match.Optional Object check first, Boolean + check fileSize, Number + check partsQty, Number + check fileData, Object + check unitArray, Match.OneOf Uint8Array, Object check chunksQty, Number + check randFileName, String check currentChunk, Number check totalSentChunks, Number - check randFileName, String - check part, Number - check partsQty, Number - check fileSize, Number console.info "Meteor.Files Debugger: [MeteorFileWrite] {name: #{randFileName}, meta:#{meta}}" if self.debug console.info "Meteor.Files Debugger: Received chunk ##{currentChunk} of #{chunksQty} chunks, in part: #{part}, file: #{fileData.name or fileData.fileName}" if self.debug @@ -298,12 +288,12 @@ class Meteor.Files result = self.dataToSchema - name: fileName - extension: extension - path: path - meta: meta - type: self.getMimeType unitArray, fileData - size: fileData.size + name: fileName + path: path + meta: meta + type: self.getMimeType unitArray, fileData + size: fileData.size + extension: extension result.chunk = currentChunk result.last = last @@ -333,9 +323,9 @@ class Meteor.Files return result _methods[self.methodNames.MeteorFileAbort] = (randFileName, partsQty, fileData) -> - check randFileName, String check partsQty, Number check fileData, Object + check randFileName, String pathName = if self.public then "#{self.storagePath}/original-#{randFileName}" else "#{self.storagePath}/#{randFileName}" extensionWithDot = ".#{fileData.ext}" @@ -467,9 +457,9 @@ class Meteor.Files isAudio: !!~data.type.toLowerCase().indexOf("audio") isImage: !!~data.type.toLowerCase().indexOf("image") _prefix: data._prefix or @_prefix - _collectionName: data._collectionName or @collectionName _storagePath: data._storagePath or @storagePath _downloadRoute: data._downloadRoute or @downloadRoute + _collectionName: data._collectionName or @collectionName } ### @@ -518,12 +508,12 @@ class Meteor.Files opts.size = buffer.length if not opts.size result = @dataToSchema - name: fileName - extension: extension - path: path - meta: opts.meta - type: opts.type - size: opts.size + name: fileName + path: path + meta: opts.meta + type: opts.type + size: opts.size + extension: extension console.info "Meteor.Files Debugger: The file #{fileName} (binary) was added to #{@collectionName}" if @debug @@ -566,12 +556,12 @@ class Meteor.Files ).on('response', (response) -> bound -> result = self.dataToSchema - name: fileName - extension: extension - path: path - meta: opts.meta - type: response.headers['content-type'] - size: response.headers['content-length'] + name: fileName + path: path + meta: opts.meta + type: response.headers['content-type'] + size: response.headers['content-length'] + extension: extension console.info "Meteor.Files Debugger: The file #{fileName} (binary) was loaded to #{@collectionName}" if @debug @@ -602,7 +592,7 @@ class Meteor.Files check callback, Match.Optional Function try - stats = fs.statSync path + stats = fs.statSync path if stat.isFile() fileSize = stats.size @@ -617,11 +607,11 @@ class Meteor.Files result = @dataToSchema name: fileName - extension: extension path: path meta: opts.meta type: opts.type size: opts.size + extension: extension _storagePath: path.replace "/#{fileName}", '' result._id = @collection.insert _.clone result @@ -739,21 +729,21 @@ class Meteor.Files check meta, Match.Optional Object check onAbort, Match.Optional Function + check streams, Match.Optional Number check onUploaded, Match.Optional Function check onProgress, Match.Optional Function check onBeforeUpload, Match.Optional Function - check streams, Match.Optional Number if file console.time('insert') if @debug + self = @ beforeunload = (e) -> message = if _.isFunction(self.onbeforeunloadMessage) then self.onbeforeunloadMessage.call(null) else self.onbeforeunloadMessage e.returnValue = message if e return message window.addEventListener "beforeunload", beforeunload, false - self = @ result = onPause: new ReactiveVar false continueFrom: [] @@ -800,19 +790,19 @@ class Meteor.Files extension: extension 'mime-type': file.type - file = _.extend file, fileData - result.file = file - randFileName = @namingFunction() - partSize = Math.ceil file.size / streams - parts = [] - uploaded = 0 - last = false + file = _.extend file, fileData + last = false + parts = [] + uploaded = 0 + partSize = Math.ceil file.size / streams + result.file = file + randFileName = @namingFunction() i = 1 while i <= streams parts.push - from: partSize * (i-1) to: partSize * i + from: partSize * (i-1) size: partSize part: i chunksQty: if @chunkSize < partSize then Math.ceil(partSize / @chunkSize) else 1 @@ -847,10 +837,10 @@ class Meteor.Files result.progress.set progress onProgress and onProgress(progress) + last = (part is streams and currentChunk >= chunksQtyInPart) uploaded += self.chunkSize arrayBuffer = chunk.srcElement or chunk.target unitArray = new Uint8Array arrayBuffer.result - last = (part is streams and currentChunk >= chunksQtyInPart) if chunksQtyInPart is 1 Meteor.call self.methodNames.MeteorFileWrite, unitArray, fileData, meta, first, chunksQtyInPart, currentChunk, totalSentChunks, randFileName, part, streams, file.size, (error, data) -> @@ -967,10 +957,9 @@ class Meteor.Files unless @downloadCallback.call _.extend(http, @getUser(http)), @currentFile responseType = '404' - partiral = false - reqRange = false - fileStats = fs.statSync fileRef.path - + partiral = false + reqRange = false + fileStats = fs.statSync fileRef.path fileRef.size = fileStats.size if fileStats.size isnt fileRef.size and not @integrityCheck responseType = '400' if fileStats.size isnt fileRef.size and @integrityCheck @@ -979,13 +968,14 @@ class Meteor.Files else dispositionType = 'inline; ' - dispositionName = "filename=\"#{encodeURI(@currentFile.name)}\"; " + dispositionName = "filename=\"#{encodeURIComponent(@currentFile.name)}\"; filename=*UTF-8\"#{encodeURIComponent(@currentFile.name)}\"; " dispositionEncoding = 'charset=utf-8' http.response.setHeader 'Content-Type', fileRef.type http.response.setHeader 'Content-Disposition', dispositionType + dispositionName + dispositionEncoding - http.response.setHeader 'Cache-Control', if (http.params.query.play and http.params.query.play == 'true') then 'public, must-revalidate, post-check=0, pre-check=0' else @cacheControl http.response.setHeader 'Accept-Ranges', 'bytes' + http.response.setHeader 'Last-Modified', @currentFile.updatedAt.toUTCString() + http.response.setHeader 'Connection', 'keep-alive' if http.request.headers.range partiral = true @@ -1000,6 +990,10 @@ class Meteor.Files end = undefined take = @chunkSize + if take > 4096000 + take = 4096000 + end = start + take + if partiral or (http.params.query.play and http.params.query.play == 'true') reqRange = {start, end} if isNaN(start) and not isNaN(end) @@ -1009,15 +1003,17 @@ class Meteor.Files reqRange.start = start reqRange.end = start + take - reqRange.end = reqRange.end - 1 if ((start + @chunkSize) >= fileRef.size) - http.response.setHeader 'Pragma', 'public' - http.response.setHeader 'Expires', -1 + reqRange.end = fileRef.size - 1 if ((start + take) >= fileRef.size) + http.response.setHeader 'Pragma', 'private' + http.response.setHeader 'Expires', new Date(+new Date + 1000*32400).toUTCString() + http.response.setHeader 'Cache-Control', 'private, maxage=10800, s-maxage=32400' if (@strict and not http.request.headers.range) or reqRange.start >= fileRef.size or reqRange.end > fileRef.size responseType = '416' else responseType = '206' else + http.response.setHeader 'Cache-Control', @cacheControl responseType = '200' streamErrorHandler = (error) -> @@ -1029,9 +1025,9 @@ class Meteor.Files console.warn "Meteor.Files Debugger: [download(#{http}, #{version})] [400] Content-Length mismatch!: #{fileRef.path}" if @debug text = "Content-Length mismatch!" http.response.writeHead 400, - 'Cache-Control': 'no-cache' + 'Content-Type': 'text/plain' + 'Cache-Control': 'no-cache' 'Content-Length': text.length - 'Content-Type': "text/plain" http.response.end text break when '404' @@ -1059,15 +1055,15 @@ class Meteor.Files when '206' console.info "Meteor.Files Debugger: [download(#{http}, #{version})] [206]: #{fileRef.path}" if @debug http.response.setHeader 'Content-Range', "bytes #{reqRange.start}-#{reqRange.end}/#{fileRef.size}" - http.response.setHeader 'Content-Length', if (start + @chunkSize) < fileRef.size then take + 1 else take + http.response.setHeader 'Content-Length', take + http.response.setHeader 'Transfer-Encoding', 'chunked' stream = fs.createReadStream fileRef.path, {start: reqRange.start, end: reqRange.end} - stream.on('open', -> - http.response.writeHead 206 - stream.pipe http.response - ).on 'error', streamErrorHandler + stream.on('open', -> http.response.writeHead 206 + ).on('error', streamErrorHandler + ).on('data', (chunk) -> http.response.write chunk + ).on 'end', -> http.response.end() break - undefined else undefined diff --git a/package.js b/package.js index 2cffbf1a..ece504f8 100755 --- a/package.js +++ b/package.js @@ -1,6 +1,6 @@ Package.describe({ name: 'ostrio:files', - version: '1.3.9', + version: '1.3.10', summary: 'Upload, Store and Stream (Video & Audio streaming) files to/from file system (FS) via DDP and HTTP', git: 'https://github.com/VeliovGroup/Meteor-Files', documentation: 'README.md' @@ -10,7 +10,7 @@ Package.onUse(function(api) { api.versionsFrom('1.1'); api.addFiles('files.coffee'); api.use(['templating', 'reactive-var', 'tracker'], 'client'); - api.use(['underscore', 'check', 'sha', 'ostrio:cookies@2.0.0', 'random', 'coffeescript', 'iron:router@1.0.12', 'aldeed:collection2@2.5.0'], ['client', 'server']); + api.use(['underscore', 'check', 'sha', 'ostrio:cookies@2.0.1', 'random', 'coffeescript', 'iron:router@1.0.12', 'aldeed:collection2@2.5.0'], ['client', 'server']); }); Npm.depends({