diff --git a/.versions b/.versions index ce02017d..82425ee8 100644 --- a/.versions +++ b/.versions @@ -1,44 +1,43 @@ -babel-compiler@6.6.4 -babel-runtime@0.1.8 -base64@1.0.8 -blaze@2.1.7 -blaze-tools@1.0.8 -boilerplate-generator@1.0.8 -caching-compiler@1.0.4 +babel-compiler@6.8.3 +babel-runtime@0.1.9_1 +base64@1.0.9 +blaze@2.1.8 +blaze-tools@1.0.9 +boilerplate-generator@1.0.9 +caching-compiler@1.0.5_1 caching-html-compiler@1.0.6 -check@1.2.1 -coffeescript@1.0.17 +check@1.2.3 +coffeescript@1.1.2_1 deps@1.0.12 -diff-sequence@1.0.5 -ecmascript@0.4.3 -ecmascript-runtime@0.2.10 -ejson@1.0.11 -html-tools@1.0.9 -htmljs@1.0.9 -http@1.1.5 -id-map@1.0.7 -jquery@1.11.8 -logging@1.0.12 -meteor@1.1.14 -minifier-js@1.1.11 -modules@0.6.1 -modules-runtime@0.6.3 -mongo-id@1.0.4 -observe-sequence@1.0.11 -ostrio:cookies@2.0.2 -ostrio:files@1.5.6 -promise@0.6.7 -random@1.0.9 -reactive-var@1.0.9 -routepolicy@1.0.10 -sha@1.0.7 -spacebars@1.0.11 -spacebars-compiler@1.0.11 -templating@1.1.9 +diff-sequence@1.0.6 +ecmascript@0.4.6_1 +ecmascript-runtime@0.2.11_1 +ejson@1.0.12 +html-tools@1.0.10 +htmljs@1.0.10 +http@1.1.7 +id-map@1.0.8 +jquery@1.11.9 +logging@1.0.13_1 +meteor@1.1.15_1 +minifier-js@1.1.12_1 +modules@0.6.4 +modules-runtime@0.6.4_1 +mongo-id@1.0.5 +observe-sequence@1.0.12 +ostrio:cookies@2.0.4 +ostrio:files@1.6.0 +promise@0.7.2_1 +random@1.0.10 +reactive-var@1.0.10 +routepolicy@1.0.11 +spacebars@1.0.12 +spacebars-compiler@1.0.12 +templating@1.1.12_1 templating-tools@1.0.4 -tracker@1.0.13 +tracker@1.0.14 ui@1.0.11 -underscore@1.0.8 -url@1.0.9 -webapp@1.2.8 +underscore@1.0.9 +url@1.0.10 +webapp@1.2.9_1 webapp-hashing@1.0.9 diff --git a/README.md b/README.md index fe81e43c..acb89e00 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,16 @@ Support: - [Releases / Changelog / History](https://github.com/VeliovGroup/Meteor-Files/releases) - For more docs and examples [read wiki](https://github.com/VeliovGroup/Meteor-Files/wiki) +Awards: +======== + + + Demo application: ======== - - [Live](https://meteor-files.herokuapp.com/) (*Unavailable after 6 hours of uptime, due to [free plan](https://www.heroku.com/pricing)*) + - [Live](https://files.veliov.com) - [Source](https://github.com/VeliovGroup/Meteor-Files/tree/master/demo) + - [Compiled Demo App](https://github.com/VeliovGroup/Meteor-Files-Demo) - [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/VeliovGroup/Meteor-Files-Demo) ToC: @@ -26,7 +32,7 @@ ToC: - [Install](https://github.com/VeliovGroup/Meteor-Files#install) - [API](https://github.com/VeliovGroup/Meteor-Files#api-overview-full-api): * [Initialize Collection](https://github.com/VeliovGroup/Meteor-Files#new-filescollectionconfig-isomorphic) - * [Upload file](https://github.com/VeliovGroup/Meteor-Files#insertsettings-client) + * [Upload file](https://github.com/VeliovGroup/Meteor-Files#insertsettings-autostart-client) * [Stream files](https://github.com/VeliovGroup/Meteor-Files#stream-files) * [Download Button](https://github.com/VeliovGroup/Meteor-Files#download-button) @@ -34,9 +40,9 @@ Why `Meteor-Files`? ======== The `cfs` is a well known package, but it's huge monster which combines everything. In `Meteor-Files` is nothing to broke, it's simply upload/store/serve files to/from server. - Support for both `HTTP` and `DDP` transports for upload - - You need store to *GridFS*, *AWS* or *DropBox*? (*[Use third-party storage](https://github.com/VeliovGroup/Meteor-Files/wiki/Third-party-storage)*) - *Add it yourself* + - You need store to *[GridFS](https://github.com/VeliovGroup/Meteor-Files/wiki/GridFS-Integration)*, *[AWS S3](https://github.com/VeliovGroup/Meteor-Files/wiki/AWS-S3-Integration)* or *[DropBox](https://github.com/VeliovGroup/Meteor-Files/wiki/Third-party-storage)*? (*[Use 3rd-party storage](https://github.com/VeliovGroup/Meteor-Files/wiki/Third-party-storage)*) - *Add it yourself* - You need to check file mime-type, size or extension? (*[`onBeforeUpload`](https://github.com/VeliovGroup/Meteor-Files/wiki/Constructor)*) - *Add it yourself* - - You need to resize images after upload? (*[`onAfterUpload`](https://github.com/VeliovGroup/Meteor-Files/wiki/Constructor)*, *[file's subversions](https://github.com/VeliovGroup/Meteor-Files/wiki/Create-and-Manage-Subversions)*) - *Add it yourself* + - You need to [resize images](https://github.com/VeliovGroup/Meteor-Files/blob/master/demo/server/image-processing.coffee) after upload? (*[`onAfterUpload`](https://github.com/VeliovGroup/Meteor-Files/wiki/Constructor)*, *[file's subversions](https://github.com/VeliovGroup/Meteor-Files/wiki/Create-and-Manage-Subversions)*) - *Add it yourself* Easy-peasy kids, *yeah*? @@ -58,7 +64,7 @@ var Images = new FilesCollection({ allowClientCode: false, // Disallow remove files from Client onBeforeUpload: function (file) { // Allow upload files under 10MB, and only in png/jpg/jpeg formats - if (file.size <= 10485760 && /png|jpg|jpeg/i.test(file.ext)) { + if (file.size <= 10485760 && /png|jpg|jpeg/i.test(file.extension)) { return true; } else { return 'Please upload image, with size equal or less than 10MB'; @@ -72,7 +78,7 @@ if (Meteor.isClient) { if (Meteor.isServer) { Meteor.publish('files.images.all', function () { - return Images.collection.find({}); + return Images.find().cursor; }); } ``` @@ -84,14 +90,12 @@ Read full docs for [`insert()` method](https://github.com/VeliovGroup/Meteor-Fil Upload form (template): ```html ``` @@ -145,15 +149,19 @@ For more expressive example see [Upload demo app](https://github.com/VeliovGroup #### Stream files -To display files you will use `fileURL` template helper. +To display files you can use `fileURL` template helper or `.link()` method of `FileCursor`. Template: ```html ``` @@ -175,10 +183,10 @@ if (Meteor.isServer) { }); Meteor.publish('files.images.all', function () { - return Images.collection.find({}); + return Images.find().cursor; }); Meteor.publish('files.videos.all', function () { - return Videos.collection.find({}); + return Videos.find().cursor; }); } else { @@ -191,10 +199,10 @@ Client's code: ```javascript Template.file.helpers({ imageFile: function () { - return Images.collection.findOne({}); + return Images.findOne(); }, videoFile: function () { - return Videos.collection.findOne({}); + return Videos.findOne(); } }); ``` @@ -206,8 +214,8 @@ For more expressive example see [Streaming demo app](https://github.com/VeliovGr Template: ```html ``` @@ -226,7 +234,7 @@ if (Meteor.isServer) { }); Meteor.publish('files.images.all', function () { - return Images.collection.find({}); + return Images.find().cursor; }); } else { Meteor.subscribe('files.images.all'); @@ -237,12 +245,19 @@ Client's code: ```javascript Template.file.helpers({ fileRef: function () { - return Images.collection.findOne({}); + return Images.findOne(); } }); ``` For more expressive example see [Download demo](https://github.com/VeliovGroup/Meteor-Files/tree/master/demo-simplest-download-button) + +Supporters: +======== +Big thanks to all supporters. *Only because of this guys this project can have 100% of our attention*. + - [@themeteorchef](https://github.com/themeteorchef) + - [@MeDBejoHok](https://github.com/medbejohok) + ---- | Meteor-Files | Expressive package to manage files within Meteor | diff --git a/demo-simplest-download-button/.meteor/release b/demo-simplest-download-button/.meteor/release index 940e0b5d..f80cc1ce 100644 --- a/demo-simplest-download-button/.meteor/release +++ b/demo-simplest-download-button/.meteor/release @@ -1 +1 @@ -METEOR@1.3.2.4 +METEOR@1.3.4.1 diff --git a/demo-simplest-download-button/.meteor/versions b/demo-simplest-download-button/.meteor/versions index 07cf060b..1d07d958 100644 --- a/demo-simplest-download-button/.meteor/versions +++ b/demo-simplest-download-button/.meteor/versions @@ -3,76 +3,75 @@ aldeed:collection2-core@1.1.1 aldeed:schema-deny@1.0.1 aldeed:schema-index@1.0.1 aldeed:simple-schema@1.5.3 -allow-deny@1.0.4 -autoupdate@1.2.9 -babel-compiler@6.6.4 -babel-runtime@0.1.8 -base64@1.0.8 -binary-heap@1.0.8 -blaze@2.1.7 +allow-deny@1.0.5 +autoupdate@1.2.10 +babel-compiler@6.8.3 +babel-runtime@0.1.9_1 +base64@1.0.9 +binary-heap@1.0.9 +blaze@2.1.8 blaze-html-templates@1.0.4 -blaze-tools@1.0.8 -boilerplate-generator@1.0.8 -caching-compiler@1.0.4 +blaze-tools@1.0.9 +boilerplate-generator@1.0.9 +caching-compiler@1.0.5_1 caching-html-compiler@1.0.6 -callback-hook@1.0.8 -check@1.2.1 -coffeescript@1.0.17 +callback-hook@1.0.9 +check@1.2.3 +coffeescript@1.1.2_1 ddp@1.2.5 -ddp-client@1.2.7 -ddp-common@1.2.5 -ddp-server@1.2.6 +ddp-client@1.2.8_1 +ddp-common@1.2.6 +ddp-server@1.2.8_1 deps@1.0.12 -diff-sequence@1.0.5 -ecmascript@0.4.3 -ecmascript-runtime@0.2.10 -ejson@1.0.11 -es5-shim@4.5.10 -fastclick@1.0.11 -geojson-utils@1.0.8 +diff-sequence@1.0.6 +ecmascript@0.4.6_1 +ecmascript-runtime@0.2.11_1 +ejson@1.0.12 +es5-shim@4.5.12_1 +fastclick@1.0.12 +geojson-utils@1.0.9 hot-code-push@1.0.4 -html-tools@1.0.9 -htmljs@1.0.9 -http@1.1.5 -id-map@1.0.7 -jquery@1.11.8 -launch-screen@1.0.11 +html-tools@1.0.10 +htmljs@1.0.10 +http@1.1.7 +id-map@1.0.8 +jquery@1.11.9 +launch-screen@1.0.12 livedata@1.0.18 -logging@1.0.12 +logging@1.0.13_1 mdg:validation-error@0.2.0 -meteor@1.1.14 +meteor@1.1.15_1 meteor-base@1.0.4 -minifier-css@1.1.11 -minifier-js@1.1.11 -minimongo@1.0.16 +minifier-css@1.1.12_1 +minifier-js@1.1.12_1 +minimongo@1.0.17 mobile-experience@1.0.4 mobile-status-bar@1.0.12 -modules@0.6.1 -modules-runtime@0.6.3 -mongo@1.1.7 -mongo-id@1.0.4 -npm-mongo@1.4.43 -observe-sequence@1.0.11 -ordered-dict@1.0.7 -ostrio:cookies@2.0.2 -ostrio:files@1.5.1 -promise@0.6.7 +modules@0.6.4 +modules-runtime@0.6.4_1 +mongo@1.1.9_1 +mongo-id@1.0.5 +npm-mongo@1.4.44_1 +observe-sequence@1.0.12 +ordered-dict@1.0.8 +ostrio:cookies@2.0.4 +ostrio:files@1.6.0 +promise@0.7.2_1 raix:eventemitter@0.1.3 -random@1.0.9 -reactive-var@1.0.9 -reload@1.1.8 -retry@1.0.7 -routepolicy@1.0.10 -sha@1.0.7 -spacebars@1.0.11 -spacebars-compiler@1.0.11 -standard-minifier-css@1.0.6 -standard-minifier-js@1.0.6 -templating@1.1.9 +random@1.0.10 +reactive-var@1.0.10 +reload@1.1.10 +retry@1.0.8 +routepolicy@1.0.11 +spacebars@1.0.12 +spacebars-compiler@1.0.12 +standard-minifier-css@1.0.7_1 +standard-minifier-js@1.0.7_1 +templating@1.1.12_1 templating-tools@1.0.4 -tracker@1.0.13 +tracker@1.0.14 ui@1.0.11 -underscore@1.0.8 -url@1.0.9 -webapp@1.2.8 +underscore@1.0.9 +url@1.0.10 +webapp@1.2.9_1 webapp-hashing@1.0.9 diff --git a/demo-simplest-download-button/client/main.html b/demo-simplest-download-button/client/main.html index 2c8c9ad0..d8eeb3b0 100644 --- a/demo-simplest-download-button/client/main.html +++ b/demo-simplest-download-button/client/main.html @@ -10,11 +10,11 @@

Meteor-Files: Download Button

\ No newline at end of file diff --git a/demo-simplest-download-button/client/main.js b/demo-simplest-download-button/client/main.js index ed4360d2..a6ef2afd 100644 --- a/demo-simplest-download-button/client/main.js +++ b/demo-simplest-download-button/client/main.js @@ -1,10 +1,9 @@ import { Template } from 'meteor/templating'; -import { ReactiveVar } from 'meteor/reactive-var'; import './main.html'; Template.file.helpers({ - fileRef: function () { - return Images.collection.findOne({}); + file: function () { + return Images.findOne(); } }); \ No newline at end of file diff --git a/demo-simplest-download-button/lib/images.collection.js b/demo-simplest-download-button/lib/images.collection.js index 7106af82..a998dfc8 100644 --- a/demo-simplest-download-button/lib/images.collection.js +++ b/demo-simplest-download-button/lib/images.collection.js @@ -1,4 +1,7 @@ -this.Images = new Meteor.Files({collectionName: 'Images'}); +this.Images = new Meteor.Files({ + debug: true, + collectionName: 'Images' +}); // To have sample image in DB we will upload it on server startup: if (Meteor.isServer) { @@ -6,7 +9,7 @@ if (Meteor.isServer) { Images.collection.attachSchema(Images.schema); Meteor.startup(function () { - if (!Images.collection.findOne({})) { + if (!Images.find().count()) { Images.load('https://raw.githubusercontent.com/VeliovGroup/Meteor-Files/master/logo.png', { fileName: 'logo.png', meta: {} @@ -15,7 +18,7 @@ if (Meteor.isServer) { }); Meteor.publish('files.images.all', function () { - return Images.collection.find({}); + return Images.find().cursor; }); } else { diff --git a/demo-simplest-streaming/.meteor/release b/demo-simplest-streaming/.meteor/release index 940e0b5d..f80cc1ce 100644 --- a/demo-simplest-streaming/.meteor/release +++ b/demo-simplest-streaming/.meteor/release @@ -1 +1 @@ -METEOR@1.3.2.4 +METEOR@1.3.4.1 diff --git a/demo-simplest-streaming/.meteor/versions b/demo-simplest-streaming/.meteor/versions index 46abb20b..4515c410 100644 --- a/demo-simplest-streaming/.meteor/versions +++ b/demo-simplest-streaming/.meteor/versions @@ -1,71 +1,70 @@ -allow-deny@1.0.4 -autoupdate@1.2.9 -babel-compiler@6.6.4 -babel-runtime@0.1.8 -base64@1.0.8 -binary-heap@1.0.8 -blaze@2.1.7 +allow-deny@1.0.5 +autoupdate@1.2.10 +babel-compiler@6.8.3 +babel-runtime@0.1.9_1 +base64@1.0.9 +binary-heap@1.0.9 +blaze@2.1.8 blaze-html-templates@1.0.4 -blaze-tools@1.0.8 -boilerplate-generator@1.0.8 -caching-compiler@1.0.4 +blaze-tools@1.0.9 +boilerplate-generator@1.0.9 +caching-compiler@1.0.5_1 caching-html-compiler@1.0.6 -callback-hook@1.0.8 -check@1.2.1 -coffeescript@1.0.17 +callback-hook@1.0.9 +check@1.2.3 +coffeescript@1.1.2_1 ddp@1.2.5 -ddp-client@1.2.7 -ddp-common@1.2.5 -ddp-server@1.2.6 +ddp-client@1.2.8_1 +ddp-common@1.2.6 +ddp-server@1.2.8_1 deps@1.0.12 -diff-sequence@1.0.5 -ecmascript@0.4.3 -ecmascript-runtime@0.2.10 -ejson@1.0.11 -es5-shim@4.5.10 -fastclick@1.0.11 -geojson-utils@1.0.8 +diff-sequence@1.0.6 +ecmascript@0.4.6_1 +ecmascript-runtime@0.2.11_1 +ejson@1.0.12 +es5-shim@4.5.12_1 +fastclick@1.0.12 +geojson-utils@1.0.9 hot-code-push@1.0.4 -html-tools@1.0.9 -htmljs@1.0.9 -http@1.1.5 -id-map@1.0.7 -jquery@1.11.8 -launch-screen@1.0.11 +html-tools@1.0.10 +htmljs@1.0.10 +http@1.1.7 +id-map@1.0.8 +jquery@1.11.9 +launch-screen@1.0.12 livedata@1.0.18 -logging@1.0.12 -meteor@1.1.14 +logging@1.0.13_1 +meteor@1.1.15_1 meteor-base@1.0.4 -minifier-css@1.1.11 -minifier-js@1.1.11 -minimongo@1.0.16 +minifier-css@1.1.12_1 +minifier-js@1.1.12_1 +minimongo@1.0.17 mobile-experience@1.0.4 mobile-status-bar@1.0.12 -modules@0.6.1 -modules-runtime@0.6.3 -mongo@1.1.7 -mongo-id@1.0.4 -npm-mongo@1.4.43 -observe-sequence@1.0.11 -ordered-dict@1.0.7 -ostrio:cookies@2.0.2 -ostrio:files@1.5.1 -promise@0.6.7 -random@1.0.9 -reactive-var@1.0.9 -reload@1.1.8 -retry@1.0.7 -routepolicy@1.0.10 -sha@1.0.7 -spacebars@1.0.11 -spacebars-compiler@1.0.11 -standard-minifier-css@1.0.6 -standard-minifier-js@1.0.6 -templating@1.1.9 +modules@0.6.4 +modules-runtime@0.6.4_1 +mongo@1.1.9_1 +mongo-id@1.0.5 +npm-mongo@1.4.44_1 +observe-sequence@1.0.12 +ordered-dict@1.0.8 +ostrio:cookies@2.0.4 +ostrio:files@1.6.0 +promise@0.7.2_1 +random@1.0.10 +reactive-var@1.0.10 +reload@1.1.10 +retry@1.0.8 +routepolicy@1.0.11 +spacebars@1.0.12 +spacebars-compiler@1.0.12 +standard-minifier-css@1.0.7_1 +standard-minifier-js@1.0.7_1 +templating@1.1.12_1 templating-tools@1.0.4 -tracker@1.0.13 +tracker@1.0.14 ui@1.0.11 -underscore@1.0.8 -url@1.0.9 -webapp@1.2.8 +underscore@1.0.9 +url@1.0.10 +webapp@1.2.9_1 webapp-hashing@1.0.9 diff --git a/demo-simplest-streaming/client/main.html b/demo-simplest-streaming/client/main.html index 1cafb451..da88e2ed 100644 --- a/demo-simplest-streaming/client/main.html +++ b/demo-simplest-streaming/client/main.html @@ -10,13 +10,17 @@

Meteor-Files: File Streaming

\ No newline at end of file diff --git a/demo-simplest-streaming/client/main.js b/demo-simplest-streaming/client/main.js index 14bf4477..db7b454c 100644 --- a/demo-simplest-streaming/client/main.js +++ b/demo-simplest-streaming/client/main.js @@ -1,13 +1,12 @@ import { Template } from 'meteor/templating'; -import { ReactiveVar } from 'meteor/reactive-var'; import './main.html'; Template.file.helpers({ imageFile: function () { - return Images.collection.findOne({}); + return Images.findOne(); }, videoFile: function () { - return Videos.collection.findOne({}); + return Videos.findOne(); } }); diff --git a/demo-simplest-streaming/lib/files.collections.js b/demo-simplest-streaming/lib/files.collections.js index 5c2252e6..81a8fe1f 100644 --- a/demo-simplest-streaming/lib/files.collections.js +++ b/demo-simplest-streaming/lib/files.collections.js @@ -1,4 +1,5 @@ this.Images = new Meteor.Files({ + debug: true, collectionName: 'Images', onBeforeUpload: function () { // Disallow uploads from client @@ -7,6 +8,7 @@ this.Images = new Meteor.Files({ }); this.Videos = new Meteor.Files({ + debug: true, collectionName: 'Videos', onBeforeUpload: function () { // Disallow uploads from client @@ -20,25 +22,25 @@ if (Meteor.isServer) { Videos.denyClient(); Meteor.startup(function () { - if (!Images.collection.findOne({})) { + if (!Images.findOne()) { Images.load('https://raw.githubusercontent.com/VeliovGroup/Meteor-Files/master/logo.png', { fileName: 'logo.png' }); } - if (!Videos.collection.findOne({})) { - Videos.load('http://www.sample-videos.com/video/mp4/240/big_buck_bunny_240p_5mb.mp4', { + if (!Videos.findOne()) { + Videos.load('http://www.sample-videos.com/video/mp4/240/big_buck_bunny_240p_10mb.mp4', { fileName: 'Big-Buck-Bunny.mp4' }); } }); Meteor.publish('files.images.all', function () { - return Images.collection.find({}); + return Images.find().cursor; }); Meteor.publish('files.videos.all', function () { - return Videos.collection.find({}); + return Videos.find().cursor; }); } else { diff --git a/demo-simplest-upload/.meteor/release b/demo-simplest-upload/.meteor/release index 940e0b5d..f80cc1ce 100644 --- a/demo-simplest-upload/.meteor/release +++ b/demo-simplest-upload/.meteor/release @@ -1 +1 @@ -METEOR@1.3.2.4 +METEOR@1.3.4.1 diff --git a/demo-simplest-upload/.meteor/versions b/demo-simplest-upload/.meteor/versions index f4aca825..e4f31a6f 100644 --- a/demo-simplest-upload/.meteor/versions +++ b/demo-simplest-upload/.meteor/versions @@ -1,72 +1,71 @@ -allow-deny@1.0.4 -autoupdate@1.2.9 -babel-compiler@6.6.4 -babel-runtime@0.1.8 -base64@1.0.8 -binary-heap@1.0.8 -blaze@2.1.7 +allow-deny@1.0.5 +autoupdate@1.2.10 +babel-compiler@6.8.3 +babel-runtime@0.1.9_1 +base64@1.0.9 +binary-heap@1.0.9 +blaze@2.1.8 blaze-html-templates@1.0.4 -blaze-tools@1.0.8 -boilerplate-generator@1.0.8 -caching-compiler@1.0.4 +blaze-tools@1.0.9 +boilerplate-generator@1.0.9 +caching-compiler@1.0.5_1 caching-html-compiler@1.0.6 -callback-hook@1.0.8 -check@1.2.1 -coffeescript@1.0.17 +callback-hook@1.0.9 +check@1.2.3 +coffeescript@1.1.2_1 ddp@1.2.5 -ddp-client@1.2.7 -ddp-common@1.2.5 -ddp-server@1.2.6 +ddp-client@1.2.8_1 +ddp-common@1.2.6 +ddp-server@1.2.8_1 deps@1.0.12 -diff-sequence@1.0.5 -ecmascript@0.4.3 -ecmascript-runtime@0.2.10 -ejson@1.0.11 -es5-shim@4.5.10 -fastclick@1.0.11 -geojson-utils@1.0.8 +diff-sequence@1.0.6 +ecmascript@0.4.6_1 +ecmascript-runtime@0.2.11_1 +ejson@1.0.12 +es5-shim@4.5.12_1 +fastclick@1.0.12 +geojson-utils@1.0.9 hot-code-push@1.0.4 -html-tools@1.0.9 -htmljs@1.0.9 -http@1.1.5 -id-map@1.0.7 -jquery@1.11.8 -launch-screen@1.0.11 +html-tools@1.0.10 +htmljs@1.0.10 +http@1.1.7 +id-map@1.0.8 +jquery@1.11.9 +launch-screen@1.0.12 livedata@1.0.18 -logging@1.0.12 -meteor@1.1.14 +logging@1.0.13_1 +meteor@1.1.15_1 meteor-base@1.0.4 -minifier-css@1.1.11 -minifier-js@1.1.11 -minimongo@1.0.16 +minifier-css@1.1.12_1 +minifier-js@1.1.12_1 +minimongo@1.0.17 mobile-experience@1.0.4 mobile-status-bar@1.0.12 -modules@0.6.1 -modules-runtime@0.6.3 -mongo@1.1.7 -mongo-id@1.0.4 -npm-mongo@1.4.43 -observe-sequence@1.0.11 -ordered-dict@1.0.7 -ostrio:cookies@2.0.2 -ostrio:files@1.5.1 +modules@0.6.4 +modules-runtime@0.6.4_1 +mongo@1.1.9_1 +mongo-id@1.0.5 +npm-mongo@1.4.44_1 +observe-sequence@1.0.12 +ordered-dict@1.0.8 +ostrio:cookies@2.0.4 +ostrio:files@1.6.0 ostrio:templatehelpers@1.1.2 -promise@0.6.7 -random@1.0.9 -reactive-var@1.0.9 -reload@1.1.8 -retry@1.0.7 -routepolicy@1.0.10 -sha@1.0.7 -spacebars@1.0.11 -spacebars-compiler@1.0.11 -standard-minifier-css@1.0.6 -standard-minifier-js@1.0.6 -templating@1.1.9 +promise@0.7.2_1 +random@1.0.10 +reactive-var@1.0.10 +reload@1.1.10 +retry@1.0.8 +routepolicy@1.0.11 +spacebars@1.0.12 +spacebars-compiler@1.0.12 +standard-minifier-css@1.0.7_1 +standard-minifier-js@1.0.7_1 +templating@1.1.12_1 templating-tools@1.0.4 -tracker@1.0.13 +tracker@1.0.14 ui@1.0.11 -underscore@1.0.8 -url@1.0.9 -webapp@1.2.8 +underscore@1.0.9 +url@1.0.10 +webapp@1.2.9_1 webapp-hashing@1.0.9 diff --git a/demo-simplest-upload/client/main.html b/demo-simplest-upload/client/main.html index 8e988374..380762ab 100644 --- a/demo-simplest-upload/client/main.html +++ b/demo-simplest-upload/client/main.html @@ -12,23 +12,25 @@

Simplest upload form!

\ No newline at end of file diff --git a/demo-simplest-upload/client/main.js b/demo-simplest-upload/client/main.js index 7e37c719..aba7a6d6 100644 --- a/demo-simplest-upload/client/main.js +++ b/demo-simplest-upload/client/main.js @@ -5,17 +5,17 @@ import './main.html'; Template.uploadedFiles.helpers({ uploadedFiles: function () { - return Images.collection.find({}); + return Images.find(); } }); Template.uploadForm.onCreated(function () { - this.currentFile = new ReactiveVar(false); + this.currentUpload = new ReactiveVar(false); }); Template.uploadForm.helpers({ - currentFile: function () { - return Template.instance().currentFile.get(); + currentUpload: function () { + return Template.instance().currentUpload.get(); } }); @@ -33,12 +33,7 @@ Template.uploadForm.events({ }, false); uploadInstance.on('start', function() { - template.currentFile.set(this); - }); - - uploadInstance.on('error', function(error) { - console.error(error); - template.currentFile.set(false); + template.currentUpload.set(this); }); uploadInstance.on('end', function(error, fileObj) { @@ -47,7 +42,7 @@ Template.uploadForm.events({ } else { alert('File "' + fileObj.name + '" successfully uploaded'); } - template.currentFile.set(false); + template.currentUpload.set(false); }); uploadInstance.start(); diff --git a/demo-simplest-upload/lib/images.collection.js b/demo-simplest-upload/lib/images.collection.js index 92eec42a..c55740b0 100644 --- a/demo-simplest-upload/lib/images.collection.js +++ b/demo-simplest-upload/lib/images.collection.js @@ -1,10 +1,10 @@ this.Images = new Meteor.Files({ - // debug: true, + debug: true, collectionName: 'Images', allowClientCode: false, // Disallow remove files from Client onBeforeUpload: function (file) { // Allow upload files under 10MB, and only in png/jpg/jpeg formats - if (file.size <= 10485760 && /png|jpg|jpeg/i.test(file.ext)) { + if (file.size <= 10485760 && /png|jpg|jpeg/i.test(file.extension)) { return true; } else { return 'Please upload image, with size equal or less than 10MB'; @@ -14,9 +14,8 @@ this.Images = new Meteor.Files({ if (Meteor.isServer) { Images.denyClient(); - Meteor.publish('files.images.all', function () { - return Images.collection.find({}); + return Images.find().cursor; }); } else { diff --git a/demo/.meteor/packages b/demo/.meteor/packages index 49d9c3da..e1df1c40 100644 --- a/demo/.meteor/packages +++ b/demo/.meteor/packages @@ -15,16 +15,17 @@ audit-argument-checks reactive-var coffeescript markdown +random check http # UI/UX packages +fastclick fortawesome:fontawesome simple:highlight.js mquandalle:jade fourseven:scss@2.1.1 momentjs:moment -twbs:bootstrap perak:markdown mrt:filesize @@ -37,7 +38,19 @@ aldeed:collection2 cfs:graphicsmagick ostrio:files ostrio:flow-router-extra -ostrio:flow-router-title -arillo:flow-router-helpers ostrio:templatehelpers ostrio:cstorage +arillo:flow-router-helpers + +# SEO +ostrio:flow-router-title +ostrio:flow-router-meta +ostrio:spiderable-middleware + +# Accounts Packages +accounts-base +service-configuration +accounts-github +accounts-twitter +accounts-facebook +accounts-meteor-developer \ No newline at end of file diff --git a/demo/.meteor/release b/demo/.meteor/release index 940e0b5d..f80cc1ce 100644 --- a/demo/.meteor/release +++ b/demo/.meteor/release @@ -1 +1 @@ -METEOR@1.3.2.4 +METEOR@1.3.4.1 diff --git a/demo/.meteor/versions b/demo/.meteor/versions index e0ed931f..5180210e 100644 --- a/demo/.meteor/versions +++ b/demo/.meteor/versions @@ -1,100 +1,116 @@ +accounts-base@1.2.8 +accounts-facebook@1.0.10 +accounts-github@1.0.10 +accounts-meteor-developer@1.0.10 +accounts-oauth@1.1.13 +accounts-twitter@1.0.10 aldeed:collection2@2.9.1 aldeed:collection2-core@1.1.1 aldeed:schema-deny@1.0.1 aldeed:schema-index@1.0.1 aldeed:simple-schema@1.5.3 -allow-deny@1.0.4 -arillo:flow-router-helpers@0.5.1 +allow-deny@1.0.5 +arillo:flow-router-helpers@0.5.2 audit-argument-checks@1.0.7 -autoupdate@1.2.9 -babel-compiler@6.6.4 -babel-runtime@0.1.8 -base64@1.0.8 -binary-heap@1.0.8 -blaze@2.1.7 +autoupdate@1.2.10 +babel-compiler@6.8.3 +babel-runtime@0.1.9_1 +base64@1.0.9 +binary-heap@1.0.9 +blaze@2.1.8 blaze-html-templates@1.0.4 -blaze-tools@1.0.8 -boilerplate-generator@1.0.8 -caching-compiler@1.0.4 +blaze-tools@1.0.9 +boilerplate-generator@1.0.9 +caching-compiler@1.0.5_1 caching-html-compiler@1.0.6 -callback-hook@1.0.8 +callback-hook@1.0.9 cfs:graphicsmagick@0.0.18 -check@1.2.1 -coffeescript@1.0.17 +check@1.2.3 +coffeescript@1.1.2_1 ddp@1.2.5 -ddp-client@1.2.7 -ddp-common@1.2.5 -ddp-server@1.2.6 +ddp-client@1.2.8_1 +ddp-common@1.2.6 +ddp-rate-limiter@1.0.5 +ddp-server@1.2.8_1 deps@1.0.12 -diff-sequence@1.0.5 -ecmascript@0.4.3 -ecmascript-runtime@0.2.10 -ejson@1.0.11 -fastclick@1.0.11 +diff-sequence@1.0.6 +ecmascript@0.4.6_1 +ecmascript-runtime@0.2.11_1 +ejson@1.0.12 +facebook@1.2.8 +fastclick@1.0.12 fortawesome:fontawesome@4.5.0 fourseven:scss@2.1.1 -geojson-utils@1.0.8 +geojson-utils@1.0.9 +github@1.1.8 hot-code-push@1.0.4 -html-tools@1.0.9 -htmljs@1.0.9 -http@1.1.5 -id-map@1.0.7 -jquery@1.11.8 -launch-screen@1.0.11 +html-tools@1.0.10 +htmljs@1.0.10 +http@1.1.7 +id-map@1.0.8 +jquery@1.11.9 +launch-screen@1.0.12 livedata@1.0.18 -localstorage@1.0.9 -logging@1.0.12 -markdown@1.0.9 +localstorage@1.0.11 +logging@1.0.13_1 +markdown@1.0.10 mdg:validation-error@0.2.0 -meteor@1.1.14 +meteor@1.1.15_1 meteor-base@1.0.4 +meteor-developer@1.1.9 meteorhacks:subs-manager@1.6.4 -minifier-css@1.1.11 -minifier-js@1.1.11 +minifier-css@1.1.12_1 +minifier-js@1.1.12_1 minifiers@1.1.7 -minimongo@1.0.16 +minimongo@1.0.17 mobile-experience@1.0.4 mobile-status-bar@1.0.12 -modules@0.6.1 -modules-runtime@0.6.3 +modules@0.6.4 +modules-runtime@0.6.4_1 momentjs:moment@2.13.1 -mongo@1.1.7 -mongo-id@1.0.4 +mongo@1.1.9_1 +mongo-id@1.0.5 mquandalle:jade@0.4.9 mquandalle:jade-compiler@0.4.5 mrt:filesize@2.0.3 -npm-mongo@1.4.43 -observe-sequence@1.0.11 -ordered-dict@1.0.7 -ostrio:cookies@2.0.2 -ostrio:cstorage@2.0.4 -ostrio:files@1.5.6 +npm-mongo@1.4.44_1 +oauth@1.1.11 +oauth1@1.1.10 +oauth2@1.1.10 +observe-sequence@1.0.12 +ordered-dict@1.0.8 +ostrio:cookies@2.0.4 +ostrio:cstorage@2.0.5 +ostrio:files@1.6.0 ostrio:flow-router-extra@2.12.2 -ostrio:flow-router-title@2.1.0 +ostrio:flow-router-meta@1.1.1 +ostrio:flow-router-title@2.1.1 +ostrio:spiderable-middleware@1.0.1 ostrio:templatehelpers@1.1.2 perak:markdown@1.0.5 -promise@0.6.7 +promise@0.7.2_1 raix:eventemitter@0.1.3 -random@1.0.9 -reactive-dict@1.1.7 -reactive-var@1.0.9 -reload@1.1.8 -retry@1.0.7 -routepolicy@1.0.10 -session@1.1.5 -sha@1.0.7 +random@1.0.10 +rate-limit@1.0.5 +reactive-dict@1.1.8 +reactive-var@1.0.10 +reload@1.1.10 +retry@1.0.8 +routepolicy@1.0.11 +service-configuration@1.0.10 +session@1.1.6 simple:highlight.js@1.2.0 -spacebars@1.0.11 -spacebars-compiler@1.0.11 -standard-minifier-css@1.0.6 -standard-minifier-js@1.0.6 -templating@1.1.9 +spacebars@1.0.12 +spacebars-compiler@1.0.12 +standard-minifier-css@1.0.7_1 +standard-minifier-js@1.0.7_1 +templating@1.1.12_1 templating-tools@1.0.4 -tracker@1.0.13 -twbs:bootstrap@3.3.6 +tracker@1.0.14 +twitter@1.1.11 ui@1.0.11 -underscore@1.0.8 -url@1.0.9 -webapp@1.2.8 +underscore@1.0.9 +url@1.0.10 +webapp@1.2.9_1 webapp-hashing@1.0.9 zimme:active-route@2.3.2 diff --git a/demo/README.md b/demo/README.md index 877f15c7..0e64a72d 100644 --- a/demo/README.md +++ b/demo/README.md @@ -8,15 +8,39 @@ __Links:__ __Functionality:__ - Upload / Download Files - Stream Audio / Video Files + - Images, PDFs, Texts preview - Drag'n'drop support (*files only, folders is not supported yet*) - Image processing (*thumbnails, preview*) - - DropBox as storage + - DropBox as storage (__note:__ *you can use only one of DropBox or S3 at the same app*) + - AWS:S3 as storage (__note:__ *you can use only one of DropBox or S3 at the same app*) + - Login via social networks (*allows to make uploaded files unlisted and/or private*) + - Heroku support (*including one-click-deploy*) + +Activate AWS:S3 +====== + 1. Read [this article](https://github.com/VeliovGroup/Meteor-Files/wiki/AWS-S3-Integration) + 2. After creating S3 bucket, create CloudFront Distribution and attach it to S3 bucket + 3. Set S3 credentials into `METEOR_SETTINGS` env.var or pass as file, read [here for more info](http://docs.meteor.com/#/full/meteor_settings), alternatively (*if something not working*) set `S3` env.var + 4. You can pass S3 credentials as JSON-string when using "*Heroku's one click install-button*" + +S3 credentials format (*region and cfdomain is required*): +```json +{ + "s3": { + "key": "xxx", + "secret": "xxx", + "bucket": "xxx", + "region": "xxx", + "cfdomain": "https://xxx.cloudfront.net" + } +} +``` Activate DropBox ====== - 1. Read [this article](https://github.com/VeliovGroup/Meteor-Files/wiki/Third-party-storage) + 1. Read [this article](https://github.com/VeliovGroup/Meteor-Files/wiki/DropBox-Integration) 2. Set DropBox credentials into `METEOR_SETTINGS` env.var or pass as file, read [here for more info](http://docs.meteor.com/#/full/meteor_settings), alternatively (*if something not working*) set `DROPBOX` env.var - 3. You can pass DropBox credentials as JSON when using "*Heroku's one click install-button*" + 3. You can pass DropBox credentials as JSON-string when using "*Heroku's one click install-button*" DropBox credentials format: ```json @@ -29,9 +53,25 @@ DropBox credentials format: } ``` +Activate Social Logins +====== +All credentials is set via env.var(s), if you're using "*Heroku's one click install-button*" - you will be able to pass all of them. + - Facebook - [Create an App](https://developers.facebook.com/apps/): + * secret: `ACCOUNTS_FACEBOOK_SEC` + * appId: `ACCOUNTS_FACEBOOK_ID` + - Twitter - [Create an App](https://apps.twitter.com): + * secret: `ACCOUNTS_TWITTER_SEC` + * consumerKey: `ACCOUNTS_TWITTER_ID` + - GitHub - [Create OAuth App](https://github.com/settings/developers): + * secret: `ACCOUNTS_GITHUB_SEC` + * clientId: `ACCOUNTS_GITHUB_ID` + - Meteor Developer - [Create an App](https://www.meteor.com/account-settings): + * secret: `ACCOUNTS_METEOR_SEC` + * clientId: `ACCOUNTS_METEOR_ID` + Deploy to Heroku ====== - - Due to "*ephemeral filesystem*" on Heroku, we suggest to use DropBox as permanent storage, [read DropBox tutorial](https://github.com/VeliovGroup/Meteor-Files/wiki/Third-party-storage) + - Due to "*ephemeral filesystem*" on Heroku, we suggest to use DropBox/AWS:S3 as permanent storage, [read DropBox/S3 tutorial](https://github.com/VeliovGroup/Meteor-Files/wiki/Third-party-storage) - Go to [Heroku](https://signup.heroku.com/dc) create and confirm your new account - Go though [Node.js Tutorial](https://devcenter.heroku.com/articles/getting-started-with-nodejs) - Install [Heroku Toolbet](https://devcenter.heroku.com/articles/getting-started-with-nodejs#set-up) @@ -58,6 +98,8 @@ heroku create --buildpack https://github.com/heroku/heroku-build # This command will output something like: # - https://.herokuapp.com/ # - https://git.heroku.com/.git + +git init heroku git:remote -a # Copy this: `https://.herokuapp.com`, note `http(s)://` protocol @@ -70,10 +112,24 @@ heroku config:set MONGO_URL=mongodb://:@dt754268.mlab.com:19 # For DropBox: # heroku config:set DROPBOX='{"dropbox":{"key": "xxx", "secret": "xxx", "token": "xxx"}}' +# For AWS:S3: +# heroku config:set S3='{"s3":{"key": "xxx", "secret": "xxx", "bucket": "xxx", "region": "xxx", "cfdomain": "https://xxx.cloudfront.net"}}' + +# For Facebook: +# heroku config:set ACCOUNTS_FACEBOOK_ID=xxx ACCOUNTS_FACEBOOK_SEC=yyy + +# For Twitter: +# heroku config:set ACCOUNTS_TWITTER_ID=xxx ACCOUNTS_TWITTER_SEC=yyy + +# For GitHub: +# heroku config:set ACCOUNTS_GITHUB_ID=xxx ACCOUNTS_GITHUB_SEC=yyy + +# For Meteor Developer: +# heroku config:set ACCOUNTS_METEOR_ID=xxx ACCOUNTS_METEOR_SEC=yyy + # Enable sticky sessions, to support HTTP upload: heroku features:enable http-session-affinity -git init git add . git commit -m "initial" git push heroku master diff --git a/demo/app.json b/demo/app.json index f82b21ec..29d624e2 100644 --- a/demo/app.json +++ b/demo/app.json @@ -1,6 +1,6 @@ { "name": "Meteor-Files-Demo", - "version": "1.5.6", + "version": "1.6.0", "description": "Demo application for ostrio:files package", "repository": "https://github.com/VeliovGroup/Meteor-Files-Demo", "website": "https://github.com/VeliovGroup/Meteor-Files-Demo", @@ -10,14 +10,15 @@ }, "dependencies": { "connect": "^3.4.1", - "dropbox": "^0.10.3", - "fibers": "^1.0.8", + "dropbox": "0.10.3", + "knox": "^0.9.2", + "fibers": "^1.0.13", "fs-extra": "0.30.0", "http-proxy": "^1.13.2", "keypress": "^0.2.1", "meteor-deque": "*", "meteor-node-stubs": "~0.2.0", - "meteor-promise": "0.5.1", + "meteor-promise": "0.7.2", "mime": "^1.3.4", "mongodb": "^2.1.15", "progress": "^1.1.8", @@ -42,6 +43,42 @@ "DROPBOX": { "description": "[Optional] DropBox credentials object, format: {\"dropbox\":{\"key\": \"xxx\", \"secret\": \"xxx\", \"token\": \"xxx\"}}", "required": false + }, + "AWS S3 Bucket": { + "description": "[Optional] AWS S3 Bucket credentials object, format: {\"s3\":{\"key\": \"xxx\", \"secret\": \"xxx\", \"bucket\": \"xxx\", \"region\": \"xxx\", \"cfdomain\": \"https://xxx.cloudfront.net\"}}", + "required": false + }, + "ACCOUNTS_METEOR_ID": { + "description": "[Optional] Meteor Account Services App ID (https://www.meteor.com/account-settings)", + "required": false + }, + "ACCOUNTS_METEOR_SEC": { + "description": "[Optional] Meteor Account Services App Secret (https://www.meteor.com/account-settings)", + "required": false + }, + "ACCOUNTS_GITHUB_ID": { + "description": "[Optional] GitHub OAuth application Client ID (https://github.com/settings/developers)", + "required": false + }, + "ACCOUNTS_GITHUB_SEC": { + "description": "[Optional] GitHub OAuth application Client Secret (https://github.com/settings/developers)", + "required": false + }, + "ACCOUNTS_TWITTER_ID": { + "description": "[Optional] Twitter Application API Key (https://apps.twitter.com)", + "required": false + }, + "ACCOUNTS_TWITTER_SEC": { + "description": "[Optional] Twitter Application API Secret (https://apps.twitter.com)", + "required": false + }, + "ACCOUNTS_FACEBOOK_ID": { + "description": "[Optional] Facebook App: App ID (https://developers.facebook.com/apps)", + "required": false + }, + "ACCOUNTS_FACEBOOK_SEC": { + "description": "[Optional] Facebook App: App Secret (https://developers.facebook.com/apps)", + "required": false } }, "keywords": [ @@ -63,7 +100,7 @@ } ], "engines": { - "node": "0.10.43", + "node": "0.10.45", "npm": "2.15.3" } } \ No newline at end of file diff --git a/demo/client/file.coffee b/demo/client/file.coffee deleted file mode 100644 index 682d3444..00000000 --- a/demo/client/file.coffee +++ /dev/null @@ -1,40 +0,0 @@ -Template.file.onCreated -> - @fetchedText = new ReactiveVar false - @warning = new ReactiveVar false - -Template.file.onRendered -> - @warning.set false - @fetchedText.set false - if @data.file.isText or @data.file.isJSON - self = @ - HTTP.call 'GET', Collections.files.link(@data.file), (error, resp) -> - if error - console.error error - else - if !~[500, 404, 400].indexOf resp.statusCode - if resp.content.length < 1024 * 64 - self.fetchedText.set resp.content - else - self.warning.set 'File too big to show, please download.' - -Template.file.helpers - fetchedText: -> Template.instance().fetchedText.get() - warning: -> Template.instance().warning.get() - getCode: -> if @type and !!~@type.indexOf('/') then @type.split('/')[1] else '' - isBlamed: -> !!~_app.blamed.get().indexOf(@_id) - -Template.file.events - 'click #blame': (e, template) -> - e.preventDefault() - blamed = _app.blamed.get() - if !!~blamed.indexOf(@_id) - blamed.splice blamed.indexOf(@_id), 1 - _app.blamed.set blamed - Meteor.call 'unblame', @_id - else - blamed.push @_id - _app.blamed.set blamed - Meteor.call 'blame', @_id - - - return false \ No newline at end of file diff --git a/demo/client/file.jade b/demo/client/file.jade deleted file mode 100644 index 2d19916b..00000000 --- a/demo/client/file.jade +++ /dev/null @@ -1,70 +0,0 @@ -template(name="file") - +with file - .panel.panel-default - .panel-body(class="{{#if compare isText 'or' isJSON}}text-left{{else}}center no-padding{{/if}}") - if isImage - picture.file-preview - source(type="#{type}" srcset="{{fileURL this 'preview'}}") - img.file-preview(src="{{fileURL this 'preview'}}" alt="#{name}") - else if isAudio - audio.file-preview.file-audio(controls autoplay preload="auto" loop) - source(src="{{fileURL .}}?play=true" type="#{type}") - else if isVideo - video.file-preview(controls autoplay preload="auto" loop) - source(src="{{fileURL .}}?play=true" type="#{type}") - else if compare isText 'or' isJSON - if warning - .alert.alert-warning.no-margin {{warning}} - else if fetchedText - if compare extension 'is' 'md|mdown|makrdown|MD|MDOWN|MARKDOWN' - +markdown - {{fetchedText}} - else - +markdown - ```{{getCode}} - #{fetchedText} - ``` - else - .alert.alert-info.no-margin.no-radius Preview is not available, please download file. - else - .alert.alert-info.no-margin.no-radius Preview is not available, please download file. - .panel-heading: h3.panel-title.ellipsis - if isAudio - i.fa.fa-fw.fa-file-audio-o - else if isVideo - i.fa.fa-fw.fa-file-video-o - else if isImage - i.fa.fa-fw.fa-file-image-o - else if isText - i.fa.fa-fw.fa-file-text-o - else if isJSON - i.fa.fa-fw.fa-file-code-o - else - i.fa.fa-fw.fa-file-o - | {{extless name}} - table.table.table-bordered.table-condensed(style="table-layout:fixed") - thead - tr - th.ellipsis Name - th.ellipsis Mime-type - th.ellipsis Size - th.ellipsis Extension - th.ellipsis Downloads - th.ellipsis Abuse - tbody - tr - td.text-center.scroll.scroll-x: span.label.label-default #{name} - td.text-center.scroll.scroll-x: span.label.label-default #{type} - td.text-center.scroll.scroll-x: span.label.label-default {{filesize size}} - td.text-center.scroll.scroll-x - if extension - span.label.label-default .#{extension} - else - small.help-block.no-margin extension-less - td.text-center.scroll.scroll-x: a.label.label-default(title="Download \"#{name}\"" href="{{fileURL .}}?download=true" target="_parent" download="#{name}") - i.fa.fa-download - | #{meta.downloads} - td.text-center.scroll.scroll-x: a.label#blame(title="Mark this upload as inappropriate" href="#" class="{{#if isBlamed}}label-danger{{else}}label-default{{/if}}") - i.fa.fa-flag - .panel-body - a.btn.btn-default.btn-block(title="Download \"#{name}\"" href="{{fileURL .}}?download=true" target="_parent" download="#{name}"): i.fa.fa-download \ No newline at end of file diff --git a/demo/client/file/file.coffee b/demo/client/file/file.coffee new file mode 100644 index 00000000..04aa83d2 --- /dev/null +++ b/demo/client/file/file.coffee @@ -0,0 +1,78 @@ +timer = false +Template.file.onCreated -> + @fetchedText = new ReactiveVar false + @showPreview = new ReactiveVar false + @showInfo = new ReactiveVar false + @warning = new ReactiveVar false + return + +Template.file.onRendered -> + @warning.set false + @fetchedText.set false + if (@data.file.get('isText') or @data.file.get('isJSON')) + self = @ + HTTP.call 'GET', @data.file.link(), (error, resp) -> + self.showPreview.set true + if error + console.error error + else + if !~[500, 404, 400].indexOf resp.statusCode + if resp.content.length < 1024 * 64 + self.fetchedText.set resp.content + else + self.warning.set true + return + + else if @data.file.get('isImage') + self = @ + img = new Image() + img.onload = -> + self.showPreview.set true + return + img.src = @data.file.link 'preview' + window.IS_RENDERED = true + return + +Template.file.helpers + warning: -> Template.instance().warning.get() + getCode: -> if @type and !!~@type.indexOf('/') then @type.split('/')[1] else '' + isBlamed: -> !!~_app.blamed.get().indexOf(@_id) + showInfo: -> Template.instance().showInfo.get() + showPreview: -> Template.instance().showPreview.get() + fetchedText: -> Template.instance().fetchedText.get() + +Template.file.events + 'click [data-blame]': (e) -> + e.preventDefault() + blamed = _app.blamed.get() + if !!~blamed.indexOf(@_id) + blamed.splice blamed.indexOf(@_id), 1 + _app.blamed.set blamed + Meteor.call 'unblame', @_id + else + blamed.push @_id + _app.blamed.set blamed + Meteor.call 'blame', @_id + return false + + 'click [data-show-info]': (e, template) -> + e.preventDefault() + template.showInfo.set !template.showInfo.get() + return false + + 'touchmove .file-overlay': (e) -> + e.preventDefault() + return false + + 'touchmove .file': (e, template) -> + if template.$(e.currentTarget).height() < template.$('.file-table').height() + template.$('a.show-info').hide() + template.$('h1.file-title').hide() + template.$('a.download-file').hide() + Meteor.clearTimeout timer if timer + timer = Meteor.setTimeout -> + template.$('a.show-info').show() + template.$('h1.file-title').show() + template.$('a.download-file').show() + , 768 + return \ No newline at end of file diff --git a/demo/client/file/file.jade b/demo/client/file/file.jade new file mode 100644 index 00000000..5d29bffd --- /dev/null +++ b/demo/client/file/file.jade @@ -0,0 +1,131 @@ +template(name="file") + .col-50.sm-row: .file(itemscope itemtype="http://schema.org/Article") + + div.invisible(itemprop="publisher" itemscope itemtype="https://schema.org/Organization") + meta(itemprop="name" content="Veliov Group, LLC") + picture(itemprop="logo" itemscope itemtype="https://schema.org/ImageObject") + meta(itemprop="caption" content="We code for your Projects, Sites and Apps") + meta(itemprop="exifData" content="image/png") + meta(itemprop="contentUrl" content="https://veliovgroup.com/images/logo-social-1200x630.png") + meta(itemprop="url" content="https://veliovgroup.com") + meta(itemprop="width" content="1200") + meta(itemprop="height" content="630") + + link(itemprop="url" content="{{urlCurrent}}" href="{{urlCurrent}}") + meta(itemscope itemprop="mainEntityOfPage" itemType="https://schema.org/WebPage" itemid="{{urlCurrent}}") + + div.invisible(itemprop="author" itemscope itemtype="https://schema.org/Person") + meta(itemprop="name" content="User of Meteor Files") + + +with file.with + time.invisible(itemprop="datePublished" datetime="{{DateToISO meta.created_at}}") + time.invisible(itemprop="dateModified" datetime="{{DateToISO meta.created_at}}") + h4.invisible(itemprop="headline") Uploaded file: {{name}} + p.invisible(itemprop="description") View uploaded and shared file: {{name}} + + .file-table: article.file-row(itemprop="articleBody") + h1.file-title.ellipsis + a.go-back(href="/") < + |   + span(itemprop="name") {{extless name}} + + if isPDF + .file-cell: iframe.file-preview(src="{{link}}" frameborder="0") + else if isImage + picture.file-preview(itemprop="image" itemscope itemtype="http://schema.org/ImageObject") + meta(itemprop="caption" content="Meteor Files: Uploaded file: {{name}}") + meta(itemprop="exifData" content="{{type}}") + link(itemprop="contentUrl" href="{{link}}" content="{{link}}") + link(itemprop="url" href="{{urlCurrent}}" content="{{urlCurrent}}") + meta(itemprop="width" content="{{meta.width}}") + meta(itemprop="height" content="{{meta.height}}") + source(type="#{type}" srcset="{{link 'preview'}}") + if showPreview + img.file-preview(src="{{link 'preview'}}" alt="#{name}") + else + h1.files-note: i.fa.fa-fw.fa-spin.fa-spinner + else if isAudio + .file-cell: audio.file-preview.file-audio(controls autoplay preload="auto" loop) + source(src="{{link}}?play=true" type="#{type}") + else if isVideo + .file-cell: video.file-preview(controls autoplay preload="auto" loop) + source(src="{{link}}?play=true" type="#{type}") + else if compare isText 'or' isJSON + if warning + .file-cell: h3.files-note + | File too big to show preview, please + a(title="Download \"#{name}\"" href="{{link}}?download=true" download="#{name}") download it + else if fetchedText + .markdown-body + if compare extension 'is' 'md|mdown|makrdown|MD|MDOWN|MARKDOWN' + +markdown + {{fetchedText}} + else + +markdown + ```{{getCode}} + #{fetchedText} + ``` + else + .file-cell + if showPreview + h3.files-note + | Preview is not available, please + a(title="Download \"#{name}\"" href="{{link}}?download=true" download="#{name}") download file + else + h1.files-note: i.fa.fa-fw.fa-spin.fa-spinner + else + .file-cell: h3.files-note + | Preview is not available, please + a(title="Download \"#{name}\"" href="{{link}}?download=true" download="#{name}") download file + + unless isImage + picture.invisible(itemprop="image" itemscope itemtype="http://schema.org/ImageObject") + meta(itemprop="caption" content="Meteor Files: Upload and Share") + meta(itemprop="exifData" content="image/png") + link(itemprop="contentUrl" href="{{url 'icon_1200x630.png'}}" content="{{url 'icon_1200x630.png'}}") + link(itemprop="url" href="{{urlCurrent}}" content="{{urlCurrent}}") + meta(itemprop="width" content="1200") + meta(itemprop="height" content="630") + + if showInfo + .file-overlay: table: tbody: tr: td: table.fixed-table: tbody + tr + th Name: + td #{name} + tr + th Mime-type: + td #{type} + tr + th Size: + td {{filesize size}} + tr + th Extension: + td + if extension + | .#{extension} + else + | extension-less + tr + th Downloads: + td + | #{meta.downloads} + |   + a(title="Download \"#{name}\"" href="{{link}}?download=true" target="_parent" download="#{name}") + i.fa.fa-fw.fa-download + tr + th Abuse: + td + | #{meta.blamed} + |   + a(data-blame title="Mark this upload as inappropriate" href="#" class="{{#if isBlamed}}danger{{/if}}") + i.fa.fa-flag + + a.info-link.show-info(data-show-info href="#" class="{{#if showInfo}}active{{/if}}") + if showInfo + i.fa.fa-fw.fa-times + else + i.fa.fa-fw.fa-info-circle + + unless showInfo + a.info-link.right.download-file(title="Download \"#{name}\"" href="{{link}}?download=true" target="_parent" download="#{name}") + i.fa.fa-fw.fa-download \ No newline at end of file diff --git a/demo/client/index.coffee b/demo/client/index.coffee index d02e7960..21abc939 100644 --- a/demo/client/index.coffee +++ b/demo/client/index.coffee @@ -1,34 +1,58 @@ Template.index.onCreated -> + timer = false self = @ - @take = new ReactiveVar 50 + @take = new ReactiveVar 10 + @latest = new ReactiveVar new Mongo.Cursor @filesLength = new ReactiveVar 0 @getFilesLenght = -> - Meteor.call 'filesLenght', (error, length) -> - if error - console.error error - else - self.filesLength.set length + Meteor.clearTimeout timer if timer + timer = Meteor.setTimeout -> + Meteor.call 'filesLenght', _app.userOnly.get(), (error, length) -> + if error + console.error error + else + self.filesLength.set length + timer = false + return return + , 512 + + observers = + added: -> + self.getFilesLenght() + return + removed: -> + self.filesLength.set self.filesLength.get() - 1 + self.getFilesLenght() + return + + @autorun -> + if _app.userOnly.get() and Meteor.userId() + cursor = Collections.files.find {userId: Meteor.userId()}, sort: 'meta.created_at': -1 + else + cursor = Collections.files.find {}, sort: 'meta.created_at': -1 + + cursor.observeChanges observers + self.latest.set cursor return - @getFilesLenght() - @autorun -> _app.subs.subscribe 'latest', self.take.get() + @autorun -> + _app.subs.subscribe 'latest', self.take.get(), _app.userOnly.get() + return + return + +Template.index.onRendered -> + window.IS_RENDERED = true + return Template.index.helpers take: -> Template.instance().take.get() - removedIn: -> moment(@meta.expireAt).fromNow() + latest: -> Template.instance().latest.get() + uploads: -> _app.uploads.get() + userOnly: -> _app.userOnly.get() filesLength: -> Template.instance().filesLength.get() Template.index.events - 'click #loadMore': (e, template) -> - template.take.set template.take.get() + 50 - return - # Remove example, won't work with allowClientCode: false - 'click #remove': (e, template) -> - Collections.files.remove @_id, (error) -> - if error - console.log error - else - template.getFilesLenght() - return + 'click [data-load-more]': (e, template) -> + template.take.set template.take.get() + 10 return \ No newline at end of file diff --git a/demo/client/index.jade b/demo/client/index.jade index 5e0ff05f..fe0c7cd6 100644 --- a/demo/client/index.jade +++ b/demo/client/index.jade @@ -1,63 +1,16 @@ template(name="index") - .panel.panel-default - .panel-heading: h3.panel-title Recently uploaded files - if latest.count - .table-responsive: table.table.table-bordered.table-striped: tbody - each latest - tr - td.center.width-1.table-preview(class="{{#if compare isImage 'and' versions.thumbnail40}}thumbnail40{{/if}}") - a(href="{{pathFor 'file' _id=_id}}") - if isAudio - i.fa.fa-fw.fa-file-audio-o - else if isVideo - i.fa.fa-fw.fa-file-video-o - else if isImage - if versions.thumbnail40 - img(src="{{fileURL this 'thumbnail40'}}" alt="#{name}") - else - i.fa.fa-fw.fa-file-image-o - else if isText - i.fa.fa-fw.fa-file-text-o - else if isJSON - i.fa.fa-fw.fa-file-code-o - else - i.fa.fa-fw.fa-file-o - td.ellipsis.width-270 - a(href="{{pathFor 'file' _id=_id}}") {{extless name}} - td.text-right.ellipsis - if extension - span.label.label-default(title="Extension") - if isAudio - i.fa.fa-fw.fa-file-audio-o - else if isVideo - i.fa.fa-fw.fa-file-video-o - else if isImage - i.fa.fa-fw.fa-file-image-o - else if isText - i.fa.fa-fw.fa-file-text-o - else if isJSON - i.fa.fa-fw.fa-file-code-o - else - i.fa.fa-fw.fa-file-o - | #{extension} - | - span.label.label-default(title="Size") - i.fa.fa-fw.fa-hdd-o - | {{filesize size}} - | - 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-fw.fa-download - | #{meta.downloads} - //- Remove button example, won't work with allowClientCode: false - //- td - button#remove.btn.btn-sm Remove + .col-50.sm-row(class="{{#unless compare latest.count 'or' uploads}}no-file{{/unless}}") + if compare latest.count 'or' uploads + .listing: table.table: tbody + each uploads + +uploadRow + each latest.each + +listingRow + if compare filesLength '>' latest.count + tr: td(data-load-more colspan="3"): h3.no-margin.center Load More else - .panel-body.center: .alert.alert-info Be the first to upload a file - - 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 + h3.files-note + if userOnly + | You have no uploaded files + else + | Be the first to upload a file \ No newline at end of file diff --git a/demo/client/listing/listing-row.coffee b/demo/client/listing/listing-row.coffee new file mode 100644 index 00000000..34702d35 --- /dev/null +++ b/demo/client/listing/listing-row.coffee @@ -0,0 +1,65 @@ +Template.listingRow.onCreated -> + @showSettings = new ReactiveVar false + @showPreview = new ReactiveVar false + return + +Template.listingRow.onRendered -> + if @data.isImage + self = @ + img = new Image() + img.onload = -> + self.showPreview.set true + return + img.src = self.data.link 'thumbnail40' + return + +Template.listingRow.helpers + removedIn: -> moment(@meta.expireAt).fromNow() + showPreview: -> Template.instance().showPreview.get() + showSettings: -> Template.instance().showSettings.get() is @_id + +Template.listingRow.events + 'click [data-remove-file]': (e) -> + e.stopPropagation() + e.preventDefault() + icon = $(e.currentTarget).find 'i.fa' + icon.removeClass('fa-trash-o').addClass 'fa-spin fa-spinner' + @remove (error) -> + if error + console.log error + return + return + 'click [data-change-access]': (e) -> + e.stopPropagation() + e.preventDefault() + icon = $(e.currentTarget).find 'i.fa' + icon.removeClass('fa-eye-slash fa-eye').addClass 'fa-spin fa-spinner' + Meteor.call 'changeAccess', @_id, (error) -> + if error + console.log error + return + return + 'click [data-change-privacy]': (e) -> + e.stopPropagation() + e.preventDefault() + icon = $(e.currentTarget).find 'i.fa' + icon.removeClass('fa-lock fa-unlock').addClass 'fa-spin fa-spinner' + Meteor.call 'changePrivacy', @_id, (error) -> + if error + console.log error + return + return + 'click [data-show-file]': (e) -> + e.preventDefault() + FlowRouter.go 'file', _id: @_id + false + 'click [data-show-settings]': (e, template) -> + e.stopPropagation() + e.preventDefault() + template.showSettings.set if template.showSettings.get() is @_id then false else @_id + false + 'click [data-close-settings]': (e, template) -> + e.stopPropagation() + e.preventDefault() + template.showSettings.set false + false \ No newline at end of file diff --git a/demo/client/listing/listing-row.jade b/demo/client/listing/listing-row.jade new file mode 100644 index 00000000..f96956ef --- /dev/null +++ b/demo/client/listing/listing-row.jade @@ -0,0 +1,71 @@ +template(name="listingRow") + tr(data-show-file) + td.width-1.preview + .preview-circle + if isPDF + i.fa.fa-fw.fa-file-pdf-o + else if isAudio + i.fa.fa-fw.fa-file-audio-o + else if isVideo + i.fa.fa-fw.fa-file-video-o + else if isImage + if versions.thumbnail40 + if showPreview + img(src="{{link 'thumbnail40'}}" alt="#{name}") + else + i.fa.fa-fw.fa-file-image-o + else + i.fa.fa-fw.fa-file-image-o + else if isText + i.fa.fa-fw.fa-file-text-o + else if isJSON + i.fa.fa-fw.fa-file-code-o + else + i.fa.fa-fw.fa-file-o + td(colspan="{{#unless compare userId 'is' currentUser._id}}2{{/unless}}" class="{{#if showSettings}}no-padding{{/if}}") + table.fixed-table: tbody: tr + if showSettings + unless meta.secured + td.file-settings(data-change-access) + a.file-access(href="#" title="{{#if meta.unlisted}}Show file publicity{{else}}Hide file from public list{{/if}}") + if meta.unlisted + i.fa.fa-fw.fa-eye-slash + else + i.fa.fa-fw.fa-eye + td.file-settings(data-change-privacy) + a.file-secured(href="#" title="{{#if meta.secured}}Allow access by link{{else}}Make file accessible to me only{{/if}}") + if meta.secured + i.fa.fa-fw.fa-lock + else + i.fa.fa-fw.fa-unlock + td.file-settings: a.file-remove(data-remove-file href="#" title="Remove file"): i.fa.fa-fw.fa-trash-o + else + td + .file-name #{name} + br + .file-info + if compare userId 'is' currentUser._id + unless meta.secured + span(title="{{#if meta.unlisted}}Unlisted{{else}}Visible to everyone{{/if}}") + i.fa.fa-fw(class="fa-eye{{#if meta.unlisted}}-slash{{/if}}") + |   + | · + |   + span(title="{{#if meta.secured}}Only you can see this file{{else}}Publicity availble file{{/if}}") + i.fa.fa-fw(class="fa-{{#unless meta.secured}}un{{/unless}}lock") + |   + | · + |   + span.file-size(title="File size") {{filesize size}} + |   + | · + |   + span.file-ttl(title="Will be removed #{removedIn}") #{removedIn} + |   + | · + |   + span.file-downloads(title="File was downloaded for #{meta.downloads} times") #{meta.downloads} + if compare userId 'is' currentUser._id + td.file-settings.width-1(data-show-settings) + a(href="#" title="{{#if showSettings}}Close Settings{{else}}You owns this file: Edit Settings{{/if}}" class="{{#unless showSettings}}show-settings{{/unless}}") + i.fa(class="fa-{{#if showSettings}}times{{else}}gear{{/if}}") \ No newline at end of file diff --git a/demo/client/misc/_404.jade b/demo/client/misc/_404.jade index fb9e94b8..372a6119 100644 --- a/demo/client/misc/_404.jade +++ b/demo/client/misc/_404.jade @@ -1,5 +1,6 @@ template(name="_404") // response:status-code=404 - .panel.panel-danger - .panel-heading: h3.panel-title Whoops... 404. Page not found - .panel-body Page not found, this page may not ever exists or was removed \ No newline at end of file + .col-50._404: .main-cell + h3 Whoops... 404. Page not found + p Page not found, this page may not ever exists or was removed + a(href="/") Go to main page \ No newline at end of file diff --git a/demo/client/misc/_layout.jade b/demo/client/misc/_layout.jade index 6cd0ac2a..2dd15186 100644 --- a/demo/client/misc/_layout.jade +++ b/demo/client/misc/_layout.jade @@ -1,71 +1,60 @@ head - meta(name="fragment" content="!") - meta(name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no") - meta(charset="UTF-8") - meta(http-equiv="X-UA-Compatible" content="IE=edge,chrome=1") + meta(name="fragment" content="!") meta(name="robots" content="index, follow") + meta(http-equiv="X-UA-Compatible" content="IE=edge,chrome=1") meta(name="format-detection" content="telephone=no, date=no, address=no, email=no") + meta(name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no") - link(rel="apple-touch-icon" sizes="57x57" href="/apple-touch-icon-57x57.png") - link(rel="apple-touch-icon" sizes="60x60" href="/apple-touch-icon-60x60.png") - link(rel="apple-touch-icon" sizes="72x72" href="/apple-touch-icon-72x72.png") - link(rel="apple-touch-icon" sizes="76x76" href="/apple-touch-icon-76x76.png") - link(rel="apple-touch-icon" sizes="114x114" href="/apple-touch-icon-114x114.png") - link(rel="apple-touch-icon" sizes="120x120" href="/apple-touch-icon-120x120.png") - link(rel="apple-touch-icon" sizes="144x144" href="/apple-touch-icon-144x144.png") - link(rel="apple-touch-icon" sizes="152x152" href="/apple-touch-icon-152x152.png") - link(rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-180x180.png") - link(rel="icon" type="image/png" href="/favicon-16x16.png" sizes="16x16") - link(rel="icon" type="image/png" href="/favicon-32x32.png" sizes="32x32") - link(rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96") - link(rel="icon" type="image/png" href="/favicon-194x194.png" sizes="194x194") - link(rel="icon" type="image/png" href="/android-chrome-192x192.png" sizes="192x192") - link(rel="manifest" href="/manifest.json") - link(rel="mask-icon" href="/safari-pinned-tab.svg" color="#9f1304") + meta(content="website" name="og:type") + meta(content="summary" name="twitter:card") + //- meta(content="@xxx" name="twitter:site") + //- meta(name="fb:app_id" property="fb:app_id" content="xxx") + //- link(href="https://plus.google.com/+xxx" rel="publisher") + //- meta(name="ostrio-domain" content="xxx") + //- script(async defer type="text/javascript" src="https://analytics.ostr.io/xxx.js") + + link(rel='apple-touch-icon' sizes='57x57' href='/apple-touch-icon-57x57.png') + link(rel='apple-touch-icon' sizes='60x60' href='/apple-touch-icon-60x60.png') + link(rel='apple-touch-icon' sizes='72x72' href='/apple-touch-icon-72x72.png') + link(rel='apple-touch-icon' sizes='76x76' href='/apple-touch-icon-76x76.png') + link(rel='apple-touch-icon' sizes='114x114' href='/apple-touch-icon-114x114.png') + link(rel='apple-touch-icon' sizes='120x120' href='/apple-touch-icon-120x120.png') + link(rel='apple-touch-icon' sizes='144x144' href='/apple-touch-icon-144x144.png') + link(rel='apple-touch-icon' sizes='152x152' href='/apple-touch-icon-152x152.png') + link(rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon-180x180.png') + link(rel='icon' type='image/png' href='/favicon-32x32.png' sizes='32x32') + link(rel='icon' type='image/png' href='/android-chrome-192x192.png' sizes='192x192') + link(rel='icon' type='image/png' href='/favicon-96x96.png' sizes='96x96') + link(rel='icon' type='image/png' href='/favicon-16x16.png' sizes='16x16') + link(rel='manifest' href='/manifest.json') + link(rel='mask-icon' href='/safari-pinned-tab.svg' color='#2b3850') meta(name="apple-mobile-web-app-capable" content="yes") meta(name="apple-mobile-web-app-status-bar-style" content="black") - meta(name="apple-mobile-web-app-title" content="File upload") - meta(name="application-name" content="File upload") + meta(name='apple-mobile-web-app-title' content='Meteor Files') + meta(name='application-name' content='Meteor Files') - meta(name="msapplication-TileColor" content="#da532c") - meta(name="msapplication-TileImage" content="/mstile-144x144.png") - meta(name="msapplication-square70x70logo" content="/mstile-70x70.png") - meta(name="msapplication-square150x150logo" content="/mstile-150x150.png") - meta(name="msapplication-wide310x150logo" content="/mstile-310x150.png") - meta(name="msapplication-square310x310logo" content="/mstile-310x310.png") - meta(name="theme-color" content="#ffffff") + meta(name='msapplication-TileColor' content='#2b5797') + meta(name='msapplication-TileImage' content='/mstile-144x144.png') + meta(name='theme-color' content='#2b3850') - meta(name="description" content="Meteor Files: Upload, Server and Manage files within Meteor application") - title Meteor Files: Upload, Stream and Manage files + meta(itemprop="name" content="Meteor Files: Upload and Share") + meta(name="og:site_name" property="og:site_name" content="Meteor Files: Upload and Share") + title Meteor Files: Upload and Share template(name="_layout") - a(href='https://github.com/VeliovGroup/Meteor-Files') - img.gh-ribbon(src='https://camo.githubusercontent.com/38ef81f8aca64bb9a64448d0d70f1308ef5341ab/68747470733a2f2f73332e616d617a6f6e6177732e636f6d2f6769746875622f726962626f6e732f666f726b6d655f72696768745f6461726b626c75655f3132313632312e706e67' alt='Fork me on GitHub' data-canonical-src='https://s3.amazonaws.com/github/ribbons/forkme_right_darkblue_121621.png') - nav.navbar.navbar-default: .container-fluid - .navbar-header.hidden-xs - a.navbar-brand(href="/" title="Meteor Files") - img(src="/logo-bw.png" height="50" width="50" alt="Meteor Files") - +uploadForm - - .container.full-height: .row: .col-md-12 - +yield - - hr - - p.center This is a demo application for Meteor-Files package. With Meteor-Files you can easily Upload, Download, Serve and Stream files within your Meteor application. - - hr - - footer.container: .row: .col-md-12.center - a(href="https://heroku.com/deploy?template=https://github.com/VeliovGroup/Meteor-Files-Demo" target="_blank") - img.footer-img(src="https://camo.githubusercontent.com/83b0e95b38892b49184e07ad572c94c8038323fb/68747470733a2f2f7777772e6865726f6b7563646e2e636f6d2f6465706c6f792f627574746f6e2e737667" title="Deploy to Heroku in one click!" alt="Deploy to Heroku" data-canonical-src="https://www.herokucdn.com/deploy/button.svg") - - a(href="https://www.meteor.com/" target="_blank") - img.footer-img(src="https://worldvectorlogo.com/logos/meteor-icon.svg" alt="Meteor.com" title="Powered by Meteor") - - - h6 If you found this package useful, please do not hesitate to star it at both GitHub and Atmosphere. Also you may like to Tweet about it or share at Facebook - h6 Brought to you by ostr.io and Veliov Group - h6 2016 ostrio:files \ No newline at end of file + a.gh-ribbon(href='https://github.com/VeliovGroup/Meteor-Files') + img(src='https://camo.githubusercontent.com/38ef81f8aca64bb9a64448d0d70f1308ef5341ab/68747470733a2f2f73332e616d617a6f6e6177732e636f6d2f6769746875622f726962626f6e732f666f726b6d655f72696768745f6461726b626c75655f3132313632312e706e67' alt='Fork me on GitHub' data-canonical-src='https://s3.amazonaws.com/github/ribbons/forkme_right_darkblue_121621.png') + .main-container.full-height + .main-row + .main-cell + .container.container-main + .row(class="{{#if showProjectInfo}}invisible{{/if}}") + +uploadForm + +yield + .row(class="{{#unless showProjectInfo}}invisible{{/unless}}") + .col-50.bg-diagonal: .sm-row: .block-info.no-padding + +projectInfoFirst + .col-50: .sm-row: .block-info + +projectInfoSecond \ No newline at end of file diff --git a/demo/client/misc/_loading.jade b/demo/client/misc/_loading.jade index 4516b0e8..57ce48e5 100644 --- a/demo/client/misc/_loading.jade +++ b/demo/client/misc/_loading.jade @@ -1,4 +1,3 @@ template(name="_loading") - h4.text-center(style="margin-top: 10%") - i.fa.fa-fw.fa-spin.fa-spinner - | Loading... \ No newline at end of file + .col-50._loading: .sm-row + h1.files-note: i.fa.fa-fw.fa-spin.fa-spinner \ No newline at end of file diff --git a/demo/client/misc/project-info.jade b/demo/client/misc/project-info.jade new file mode 100644 index 00000000..a8631e9b --- /dev/null +++ b/demo/client/misc/project-info.jade @@ -0,0 +1,75 @@ +template(name="projectInfoFirst") + picture.invisible(itemscope="" itemtype="http://schema.org/ImageObject" itemprop="primaryImageOfPage") + meta(itemprop="caption" content="Meteor Files: Upload and Share") + meta(itemprop="exifData" content="image/png") + link(itemprop="url" href="{{url}}" content="{{url}}") + link(itemprop="contentUrl" href="{{url 'icon_1200x630.png'}}" content="{{url 'icon_1200x630.png'}}") + + p.project-info + a.info-img.gcaa(href="https://themeteorchef.com/blog/giant-cotton-apron-awards-show" target="_blank") + img(src="https://camo.githubusercontent.com/513a14500082d198359dc4450bc2d1a9cd960776/68747470733a2f2f73332e616d617a6f6e6177732e636f6d2f746d632d706f73742d636f6e74656e742f676361612d323031362d77696e6e65722d62616467652e737667" title="Giant Cotton Apron Awards Winner" alt="GCAA Badge" data-canonical-src="https://s3.amazonaws.com/tmc-post-content/gcaa-2016-winner-badge.svg") + + p.project-info + a.info-img(href="https://heroku.com/deploy?template=https://github.com/VeliovGroup/Meteor-Files-Demo" target="_blank") + img(src="https://camo.githubusercontent.com/83b0e95b38892b49184e07ad572c94c8038323fb/68747470733a2f2f7777772e6865726f6b7563646e2e636f6d2f6465706c6f792f627574746f6e2e737667" title="Deploy to Heroku in one click!" alt="Deploy to Heroku" data-canonical-src="https://www.herokucdn.com/deploy/button.svg") + + //- a.info-img(href="https://ostr.io/en/info/protected/files.veliov.com" target="_blank") + //- img(src="https://ostr.io/images/ostrio-badge-color.png" alt="Protected by ostr.io" title="This website is protected by ostr.io") + + p.project-info + a.info-img(href="https://www.meteor.com/" target="_blank") + img(src="/meteor-icon.svg" alt="Meteor.com" title="Powered by Meteor") + + a.info-link.right.active(data-show-project-info href="#"): i.fa.fa-fw.fa-times + +template(name="projectInfoSecond") + main(itemscope itemtype="http://schema.org/Article") + h1.invisible(itemprop="name") Meteor Files + h4.invisible(itemprop="headline") Upload and Share files + p.invisible(itemprop="description") Upload, Store and Share files with speed of Meteor + + time.invisible(itemprop="datePublished" datetime="2016-06-22T08:00:00+00:00") + time.invisible(itemprop="dateModified" datetime="2016-06-22T08:00:00+00:00") + + link(itemprop="url" content="{{url}}" href="{{url}}") + meta(itemscope itemprop="mainEntityOfPage" itemType="https://schema.org/WebPage" itemid="{{url}}") + picture.invisible(itemprop="image" itemscope itemtype="http://schema.org/ImageObject") + meta(itemprop="caption" content="Meteor Files: Upload and Share") + meta(itemprop="exifData" content="image/png") + link(itemprop="contentUrl" href="{{url 'icon_1200x630.png'}}" content="{{url 'icon_1200x630.png'}}") + link(itemprop="url" href="{{url}}" content="{{url}}") + meta(itemprop="width" content="1200") + meta(itemprop="height" content="630") + + div.invisible(itemprop="author" itemscope itemtype="https://schema.org/Person") + meta(itemprop="name" content="Dmitriy A.") + + div.invisible(itemprop="publisher" itemscope itemtype="https://schema.org/Organization") + meta(itemprop="name" content="Veliov Group, LLC") + picture(itemprop="logo" itemscope itemtype="https://schema.org/ImageObject") + meta(itemprop="caption" content="We code for your Projects, Sites and Apps") + meta(itemprop="exifData" content="image/png") + meta(itemprop="contentUrl" content="https://veliovgroup.com/images/logo-social-1200x630.png") + meta(itemprop="url" content="https://veliovgroup.com") + meta(itemprop="width" content="1200") + meta(itemprop="height" content="630") + + article(itemprop="articleBody") + p.project-info This is a demo application for Meteor-Files package. Meteor-Files will help you Upload, Download, Serve and Stream files with ease. + + p.project-info This package is the GCAA Winner 2016. Big thanks to Benjamin Willems and Ryan Glover (The Chef) and all The Meteor Chef team! + + p.project-info.no-margin Support this project: + h4.share-options + a(href="https://twitter.com/share?url={{url}}&text=Easily%20Upload%20and%20Share%20files%20with%20%23MeteorFiles!" title="Tell the world on Twitter" target="_blank") + i.fa.fa-lg.fa-fw.fa-twitter + a(href="https://www.facebook.com/sharer.php?u={{url}}" title="Tell the world on Facebook" target="_blank") + i.fa.fa-lg.fa-fw.fa-facebook-official + a(href="https://github.com/VeliovGroup/Meteor-Files" title="Star this package on GitHub" target="_blank") + i.fa.fa-lg.fa-fw.fa-github + a(href="https://atmospherejs.com/ostrio/files" title="Star this package on Atmosphere" target="_blank") + img(src="/meteor-icon.svg") + p.project-info + | Brought to you by ostr.io and Veliov Group + br + | 2016 ostrio:files \ No newline at end of file diff --git a/demo/client/router.coffee b/demo/client/router.coffee deleted file mode 100644 index 73b37d21..00000000 --- a/demo/client/router.coffee +++ /dev/null @@ -1,10 +0,0 @@ -FlowRouter.globals.push - title: 'Meteor Files: Upload, Stream and Manage files' - -FlowRouter.notFound = - action: -> - @render '_layout', '_404' - return - title: '404: Page not found' - -new FlowRouterTitle FlowRouter \ No newline at end of file diff --git a/demo/client/router/router.coffee b/demo/client/router/router.coffee new file mode 100644 index 00000000..c084eef3 --- /dev/null +++ b/demo/client/router/router.coffee @@ -0,0 +1,38 @@ +FlowRouter.globals.push + title: 'Meteor Files: Upload and Share' + +FlowRouter.globals.push + meta: + keywords: + name: 'keywords' + itemprop: 'keywords' + content: 'file, files, upload, store, storage, share, share files, meteor, open source, javascript' + 'og:url': + property: 'og:url' + content: -> _app.currentUrl() + 'og:title': + property: 'og:title' + content: -> document.title + description: + name: 'description' + itemprop: 'description' + property: 'og:description' + content: 'Upload, Store and Share files with speed of Meteor' + 'twitter:description': 'Upload, Store and Share files with speed of Meteor' + 'twitter:title': -> document.title + 'twitter:url': -> _app.currentUrl() + 'og:image': + property: 'og:image' + content: Meteor.absoluteUrl 'icon_1200x630.png' + 'twitter:image': + name: 'twitter:image' + content: Meteor.absoluteUrl 'icon_750x560.png' + +FlowRouter.notFound = + action: -> + @render '_layout', '_404' + return + title: '404: Page not found' + +new FlowRouterTitle FlowRouter +new FlowRouterMeta FlowRouter \ No newline at end of file diff --git a/demo/client/router/routes.coffee b/demo/client/router/routes.coffee new file mode 100644 index 00000000..9585367d --- /dev/null +++ b/demo/client/router/routes.coffee @@ -0,0 +1,68 @@ +FlowRouter.route '/', + name: 'index' + action: (params, queryParams) -> + @render '_layout', 'index' + return + waitOn: (params) -> [_app.subs.subscribe('latest', 10, _app.userOnly.get())] + whileWaiting: -> + @render '_layout', '_loading' + return + +FlowRouter.route '/login', + name: 'login' + title: -> if Meteor.userId() then 'Your account settings' else 'Login into Meteor Files' + meta: + keywords: + name: 'keywords' + itemprop: 'keywords' + content: 'private, unlisted, files, upload, meteor, open source, javascript' + description: + name: 'description' + itemprop: 'description' + property: 'og:description' + content: 'Login into Meteor files. After you logged in you can make files private and unlisted' + 'twitter:description': 'Login into Meteor files. After you logged in you can make files private and unlisted' + action: -> + @render '_layout', 'login' + return + +FlowRouter.route '/:_id', + name: 'file' + title: (params, queryParams, file) -> + if file + return "View File: #{file.get('name')}" + else + return '404: Page not found' + meta: (params, queryParams, file) -> + keywords: + name: 'keywords' + itemprop: 'keywords' + content: if file then "file, view, preview, uploaded, shared, #{file.get('name')}, #{file.get('extension')}, #{file.get('type')}, meteor, open source, javascript" else '404, page, not found' + description: + name: 'description' + itemprop: 'description' + property: 'og:description' + content: if file then "View uploaded and shared file: #{file.get('name')}" else '404: No such page' + 'twitter:description': if file then "View uploaded and shared file: #{file.get('name')}" else '404: No such page' + 'og:image': + property: 'og:image' + content: if file and file.get('isImage') then file.link('preview') else Meteor.absoluteUrl 'icon_1200x630.png' + 'twitter:image': + name: 'twitter:image' + content: if file and file.get('isImage') then file.link('preview') else Meteor.absoluteUrl 'icon_750x560.png' + action: (params, queryParams, file) -> + @render '_layout', 'file', {file} + return + waitOn: (params) -> [_app.subs.subscribe('file', params._id)] + whileWaiting: -> + @render '_layout', '_loading' + return + onNoData: -> + @render '_layout', '_404' + return + data: (params) -> + file = Collections.files.findOne params._id + if file + return file + else + return false \ No newline at end of file diff --git a/demo/client/routes.coffee b/demo/client/routes.coffee deleted file mode 100644 index 947363e1..00000000 --- a/demo/client/routes.coffee +++ /dev/null @@ -1,25 +0,0 @@ -FlowRouter.route '/', - name: 'index' - action: (params, queryParams, latest) -> - @render '_layout', 'index', {latest} - return - waitOn: (params) -> [_app.subs.subscribe('latest', 50)] - whileWaiting: -> - @render '_layout', '_loading' - return - data: -> Collections.files.collection.find {}, sort: 'meta.created_at': -1 - -FlowRouter.route '/:_id', - name: 'file' - title: (params, queryParams, file) -> if file and file.name then "View File: #{file.name}" else '404: Page not found' - action: (params, queryParams, file) -> - @render '_layout', 'file', {file} - return - waitOn: (params) -> [_app.subs.subscribe('file', params._id)] - whileWaiting: -> - @render '_layout', '_loading' - return - onNoData: -> - @render '_layout', '_404' - return - data: (params) -> Collections.files.collection.findOne params._id \ No newline at end of file diff --git a/demo/client/styles.sass b/demo/client/styles.sass deleted file mode 100644 index bf6ce57f..00000000 --- a/demo/client/styles.sass +++ /dev/null @@ -1,173 +0,0 @@ -html, body, span, div, p, table, tr, button, video, audio - animation: newElement 350ms ease-in-out 1 - -@keyframes newElement - from - opacity: 0 - to - opacity: 1 - -.width-1 - width: 1% - -.width-270 - max-width: 270px !important - -.width-100 - max-width: 100px !important - -.center - text-align: center - margin-right: auto - margin-left: auto - -.container - padding-top: 15px - max-width: 640px - -.ellipsis - white-space: nowrap - overflow: hidden - max-width: 100% - text-overflow: ellipsis - -o-text-overflow: ellipsis - -td.thumbnail40 - padding: 0px !important - img - height: 36px - width: 36px - max-height: 36px - max-width: 36px - -.navbar-form - min-height: 90px - padding: 10px 15px - border: none !important - background-image: none - &.file-over - background: - image: url(/dnd.png) - repeat: no-repeat - position: 50% 50% - size: 80px 80px - & > * - visibility: hidden - opacity: 0 - -.upload-form-container - max-width: 80% - margin: 0px auto - @media (max-width: 767px) - max-width: 100% - -.scroll - -webkit-overflow-scrolling: touch -.scroll-x - overflow-y: hidden - overflow-x: auto - -button, input, select, textarea, .label - user-select: none !important - -button, select, input, button, form, textarea, a, pre, i - outline: none !important - &:active, &:focus, &:visited - outline: none !important - -pre, pre code - white-space: pre -pre - overflow: auto - -th - text-align: center - vertical-align: middle - word-break: break-word - -.panel-title - line-height: 1.5 - -.no-margin - margin: 0px - -.no-padding - padding: 0px - -.no-radius - border-radius: 0px - -.file-preview - margin: 0 auto - max-width: 100% - &.file-audio - margin: 25px 0px - -.panel - overflow: hidden - -.navbar-brand - position: absolute - padding: 1px - height: auto - opacity: .555 - &:hover - opacity: .777 - & > img - height: 61px - width: 61px - -.full-height - min-height: 100% - min-height: 100vh - -.navbar - table - width: 100% - .progress - min-width: 50vw - height: 28px - margin: 0px - & > .container-fluid - @media (max-width: 767px) - padding: 0px - -.gh-ribbon - position: absolute - top: 0 - right: 0 - border: 0 - z-index: 10 - height: 130px - width: 130px - opacity: .555 - &:hover - opacity: .777 - @media (max-width: 767px) - height: 85px - width: 85px - -.footer-img - height: 32px - width: auto - border-radius: 5px - margin: 5px - opacity: .888 - &:hover - opacity: 1 - -.table-responsive - overflow-y: hidden - -.table-preview - a, a:hover, a:active, a:focus - color: inherit - -small - .radio-inline - user-select: none - input[type="radio"] - height: 11px - width: 11px - margin-left: -15px - margin-top: 2.5px \ No newline at end of file diff --git a/demo/client/styles/moz-styles.css b/demo/client/styles/moz-styles.css new file mode 100644 index 00000000..c3fd4816 --- /dev/null +++ b/demo/client/styles/moz-styles.css @@ -0,0 +1,10 @@ +@-moz-document url-prefix() { + @media(min-width: 480px) { + .full-height { + height: 100vh; + } + .file .file-table { + height: 414px !important; + } + } +} diff --git a/demo/client/styles/styles.css b/demo/client/styles/styles.css new file mode 100644 index 00000000..422aeabe --- /dev/null +++ b/demo/client/styles/styles.css @@ -0,0 +1,832 @@ +.progress-radial { + position: relative; + width: 44px; + height: 44px; + border-radius: 50%; + color: #aebad2; + background-color: #9ca8be; +} + +.progress-radial .overlay { + position: absolute; + width: 40px; + height: 40px; + background-color: #212836; + border-radius: 50%; + margin-left: 2px; + margin-top: 2px; + text-align: center; + font-size: 13px; + line-height: 39px; + transition: background-color 256ms ease-in-out; +} + +.progress-radial .overlay .action { + display: none; + visibility: hidden; +} + +.progress-radial:hover .overlay { + background-color: #45526d; +} + +.progress-radial:hover .overlay a { + color: #8d93ad; + opacity: 1; +} + +.progress-radial:hover .overlay .action { + display: inline; + visibility: visible; +} + +.progress-radial:hover .overlay .progress { + display: none; + visibility: hidden; +} + +.progress-0 { + background-image: linear-gradient(90deg, #212836 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(90deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-5 { + background-image: linear-gradient(90deg, #212836 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(108deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-10 { + background-image: linear-gradient(90deg, #212836 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(126deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-15 { + background-image: linear-gradient(90deg, #212836 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(144deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-20 { + background-image: linear-gradient(90deg, #212836 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(162deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-25 { + background-image: linear-gradient(90deg, #212836 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(180deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-30 { + background-image: linear-gradient(90deg, #212836 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(198deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-35 { + background-image: linear-gradient(90deg, #212836 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(216deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-40 { + background-image: linear-gradient(90deg, #212836 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(234deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-45 { + background-image: linear-gradient(90deg, #212836 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(252deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-50 { + background-image: linear-gradient(-90deg, #9ca8be 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(270deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-55 { + background-image: linear-gradient(-72deg, #9ca8be 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(270deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-60 { + background-image: linear-gradient(-54deg, #9ca8be 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(270deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-65 { + background-image: linear-gradient(-36deg, #9ca8be 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(270deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-70 { + background-image: linear-gradient(-18deg, #9ca8be 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(270deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-75 { + background-image: linear-gradient(0deg, #9ca8be 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(270deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-80 { + background-image: linear-gradient(18deg, #9ca8be 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(270deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-85 { + background-image: linear-gradient(36deg, #9ca8be 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(270deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-90 { + background-image: linear-gradient(54deg, #9ca8be 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(270deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-95 { + background-image: linear-gradient(72deg, #9ca8be 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(270deg, #9ca8be 50%, #212836 50%, #212836); +} + +.progress-100 { + background-image: linear-gradient(90deg, #9ca8be 50%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0)), linear-gradient(270deg, #9ca8be 50%, #212836 50%, #212836); +} + + +/* + @author sindresorhus + @url https://github.com/sindresorhus/github-markdown-css/blob/gh-pages/github-markdown.css + @description The minimal amount of CSS to replicate the GitHub Markdown style +*/ +.markdown-body { + margin-top: 44px; + padding: 15px 7px; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + word-break: break-word; + word-wrap: break-word; + background-color: #FAFAFA; + color: #333; + font-family: "Helvetica Neue", Helvetica, "Segoe UI", Arial, freesans, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-size: 16px; + line-height: 1.6; +} + +.markdown-body a { + background-color: transparent; + -webkit-text-decoration-skip: objects; +} + +.markdown-body a:active, +.markdown-body a:hover { + outline-width: 0; + color: #2b558c; +} + +.markdown-body strong { + font-weight: inherit; +} + +.markdown-body strong { + font-weight: bolder; +} + +.markdown-body h1 { + font-size: 2em; + margin: 0.67em 0; +} + +.markdown-body img { + border-style: none; +} + +.markdown-body svg:not(:root) { + overflow: hidden; +} + +.markdown-body code, +.markdown-body kbd, +.markdown-body pre { + font-family: monospace, monospace; + font-size: 1em; +} + +.markdown-body hr { + box-sizing: content-box; + height: 0; + overflow: visible; +} + +.markdown-body input { + font: inherit; + margin: 0; +} + +.markdown-body input { + overflow: visible; +} + +.markdown-body button:-moz-focusring, +.markdown-body [type="button"]:-moz-focusring, +.markdown-body [type="reset"]:-moz-focusring, +.markdown-body [type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +.markdown-body [type="checkbox"] { + box-sizing: border-box; + padding: 0; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body * { + box-sizing: border-box; +} + +.markdown-body input { + font: 13px/1.4 Helvetica, arial, nimbussansl, liberationsans, freesans, clean, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} + +.markdown-body a { + color: #4078c0; + text-decoration: none; +} + +.markdown-body a:hover, +.markdown-body a:active { + text-decoration: underline; +} + +.markdown-body hr { + height: 0; + margin: 15px 0; + overflow: hidden; + background: transparent; + border: 0; + border-bottom: 1px solid #ddd; +} + +.markdown-body hr::before { + display: table; + content: ""; +} + +.markdown-body hr::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 0; + margin-bottom: 0; + line-height: 1.5; +} + +.markdown-body h1 { + font-size: 30px; +} + +.markdown-body h2 { + font-size: 21px; +} + +.markdown-body h3 { + font-size: 16px; +} + +.markdown-body h4 { + font-size: 14px; +} + +.markdown-body h5 { + font-size: 12px; +} + +.markdown-body h6 { + font-size: 11px; +} + +.markdown-body p { + margin-top: 0; + margin-bottom: 10px; +} + +.markdown-body blockquote { + margin: 0; +} + +.markdown-body ul, +.markdown-body ol { + padding-left: 0; + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ul ul ol, +.markdown-body ul ol ol, +.markdown-body ol ul ol, +.markdown-body ol ol ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body code { + font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; + font-size: 12px; +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; + font: 12px Consolas, "Liberation Mono", Menlo, Courier, monospace; +} + +.markdown-body .pl-0 { + padding-left: 0 !important; +} + +.markdown-body .pl-1 { + padding-left: 3px !important; +} + +.markdown-body .pl-2 { + padding-left: 6px !important; +} + +.markdown-body .pl-3 { + padding-left: 12px !important; +} + +.markdown-body .pl-4 { + padding-left: 24px !important; +} + +.markdown-body .pl-5 { + padding-left: 36px !important; +} + +.markdown-body .pl-6 { + padding-left: 48px !important; +} + +.markdown-body .form-select::-ms-expand { + opacity: 0; +} + +.markdown-body:before { + display: table; + content: ""; +} + +.markdown-body:after { + display: table; + clear: both; + content: ""; +} + +.markdown-body>*:first-child { + margin-top: 0 !important; +} + +.markdown-body>*:last-child { + margin-bottom: 0 !important; +} + +.markdown-body a:not([href]) { + color: inherit; + text-decoration: none; +} + +.markdown-body .anchor { + display: inline-block; + padding-right: 2px; + margin-left: -18px; +} + +.markdown-body .anchor:focus { + outline: none; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 1em; + margin-bottom: 16px; + font-weight: bold; + line-height: 1.4; +} + +.markdown-body h1 .octicon-link, +.markdown-body h2 .octicon-link, +.markdown-body h3 .octicon-link, +.markdown-body h4 .octicon-link, +.markdown-body h5 .octicon-link, +.markdown-body h6 .octicon-link { + color: #000; + vertical-align: middle; + visibility: hidden; +} + +.markdown-body h1:hover .anchor, +.markdown-body h2:hover .anchor, +.markdown-body h3:hover .anchor, +.markdown-body h4:hover .anchor, +.markdown-body h5:hover .anchor, +.markdown-body h6:hover .anchor { + text-decoration: none; +} + +.markdown-body h1:hover .anchor .octicon-link, +.markdown-body h2:hover .anchor .octicon-link, +.markdown-body h3:hover .anchor .octicon-link, +.markdown-body h4:hover .anchor .octicon-link, +.markdown-body h5:hover .anchor .octicon-link, +.markdown-body h6:hover .anchor .octicon-link { + visibility: visible; +} + +.markdown-body h1 { + padding-bottom: 0.3em; + font-size: 2.25em; + line-height: 1.2; + border-bottom: 1px solid #eee; +} + +.markdown-body h1 .anchor { + line-height: 1; +} + +.markdown-body h2 { + padding-bottom: 0.3em; + font-size: 1.75em; + line-height: 1.225; + border-bottom: 1px solid #eee; +} + +.markdown-body h2 .anchor { + line-height: 1; +} + +.markdown-body h3 { + font-size: 1.5em; + line-height: 1.43; +} + +.markdown-body h3 .anchor { + line-height: 1.2; +} + +.markdown-body h4 { + font-size: 1.25em; +} + +.markdown-body h4 .anchor { + line-height: 1.2; +} + +.markdown-body h5 { + font-size: 1em; +} + +.markdown-body h5 .anchor { + line-height: 1.1; +} + +.markdown-body h6 { + font-size: 1em; + color: #777; +} + +.markdown-body h6 .anchor { + line-height: 1.1; +} + +.markdown-body p, +.markdown-body blockquote, +.markdown-body ul, +.markdown-body ol, +.markdown-body dl, +.markdown-body table, +.markdown-body pre { + margin-top: 0; + margin-bottom: 16px; +} + +.markdown-body hr { + height: 4px; + padding: 0; + margin: 16px 0; + background-color: #e7e7e7; + border: 0 none; +} + +.markdown-body ul, +.markdown-body ol { + padding-left: 2em; +} + +.markdown-body ul ul, +.markdown-body ul ol, +.markdown-body ol ol, +.markdown-body ol ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li>p { + margin-top: 16px; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: 16px; + font-size: 1em; + font-style: italic; + font-weight: bold; +} + +.markdown-body dl dd { + padding: 0 16px; + margin-bottom: 16px; +} + +.markdown-body blockquote { + padding: 0 15px; + color: #777; + border-left: 4px solid #ddd; +} + +.markdown-body blockquote>:first-child { + margin-top: 0; +} + +.markdown-body blockquote>:last-child { + margin-bottom: 0; +} + +.markdown-body table { + display: block; + width: 100%; + overflow: auto; + word-break: normal; + word-break: keep-all; +} + +.markdown-body table th { + font-weight: bold; +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid #ddd; +} + +.markdown-body table tr { + background-color: #fff; + border-top: 1px solid #ccc; +} + +.markdown-body table tr:nth-child(2n) { + background-color: #f8f8f8; +} + +.markdown-body img { + max-width: 100%; + box-sizing: content-box; + background-color: #fff; +} + +.markdown-body code { + padding: 0; + padding-top: 0.2em; + padding-bottom: 0.2em; + margin: 0; + font-size: 85%; + background-color: rgba(0,0,0,0.04); + border-radius: 3px; +} + +.markdown-body code:before, +.markdown-body code:after { + letter-spacing: -0.2em; + content: "\00a0"; +} + +.markdown-body pre>code { + padding: 0; + margin: 0; + font-size: 100%; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: 16px; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + background-color: #f7f7f7; + border-radius: 3px; +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body pre { + word-wrap: normal; +} + +.markdown-body pre code { + display: inline; + max-width: initial; + padding: 0; + margin: 0; + overflow: initial; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +.markdown-body pre code:before, +.markdown-body pre code:after { + content: normal; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font-size: 11px; + line-height: 10px; + color: #555; + vertical-align: middle; + background-color: #fcfcfc; + border: solid 1px #ccc; + border-bottom-color: #bbb; + border-radius: 3px; + box-shadow: inset 0 -1px 0 #bbb; +} + +.markdown-body .pl-c { + color: #969896; +} + +.markdown-body .pl-c1, +.markdown-body .pl-s .pl-v { + color: #0086b3; +} + +.markdown-body .pl-e, +.markdown-body .pl-en { + color: #795da3; +} + +.markdown-body .pl-s .pl-s1, +.markdown-body .pl-smi { + color: #333; +} + +.markdown-body .pl-ent { + color: #63a35c; +} + +.markdown-body .pl-k { + color: #a71d5d; +} + +.markdown-body .pl-pds, +.markdown-body .pl-s, +.markdown-body .pl-s .pl-pse .pl-s1, +.markdown-body .pl-sr, +.markdown-body .pl-sr .pl-cce, +.markdown-body .pl-sr .pl-sra, +.markdown-body .pl-sr .pl-sre { + color: #183691; +} + +.markdown-body .pl-v { + color: #ed6a43; +} + +.markdown-body .pl-id { + color: #b52a1d; +} + +.markdown-body .pl-ii { + background-color: #b52a1d; + color: #f8f8f8; +} + +.markdown-body .pl-sr .pl-cce { + color: #63a35c; + font-weight: bold; +} + +.markdown-body .pl-ml { + color: #693a17; +} + +.markdown-body .pl-mh, +.markdown-body .pl-mh .pl-en, +.markdown-body .pl-ms { + color: #1d3e81; + font-weight: bold; +} + +.markdown-body .pl-mq { + color: #008080; +} + +.markdown-body .pl-mi { + color: #333; + font-style: italic; +} + +.markdown-body .pl-mb { + color: #333; + font-weight: bold; +} + +.markdown-body .pl-md { + background-color: #ffecec; + color: #bd2c00; +} + +.markdown-body .pl-mi1 { + background-color: #eaffea; + color: #55a532; +} + +.markdown-body .pl-mdr { + color: #795da3; + font-weight: bold; +} + +.markdown-body .pl-mo { + color: #1d3e81; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font: 11px Consolas, "Liberation Mono", Menlo, Courier, monospace; + line-height: 10px; + color: #555; + vertical-align: middle; + background-color: #fcfcfc; + border: solid 1px #ccc; + border-bottom-color: #bbb; + border-radius: 3px; + box-shadow: inset 0 -1px 0 #bbb; +} + +.markdown-body .full-commit .btn-outline:not(:disabled):hover { + color: #4078c0; + border: 1px solid #4078c0; +} + +.markdown-body :checked+.radio-label { + position: relative; + z-index: 1; + border-color: #4078c0; +} + +.markdown-body .octicon { + display: inline-block; + vertical-align: text-top; + fill: currentColor; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item+.task-list-item { + margin-top: 3px; +} + +.markdown-body .task-list-item input { + margin: 0 0.2em 0.25em -1.6em; + vertical-align: middle; +} + +.markdown-body hr { + border-bottom-color: #eee; +} \ No newline at end of file diff --git a/demo/client/styles/styles.sass b/demo/client/styles/styles.sass new file mode 100644 index 00000000..01306399 --- /dev/null +++ b/demo/client/styles/styles.sass @@ -0,0 +1,814 @@ +$link-color: #4e546d +$text-color: #97a2d0 + +@import url(https://fonts.googleapis.com/css?family=Lato:300,400,400italic,700) + +html, body, span, div, p, td, button, video, audio, img, picture, h1, h2, h3, h4, h5, h6, .preview-circle > img + animation: newElement 350ms ease-in-out 1 + +@keyframes newElement + from + opacity: 0 + to + opacity: 1 + +html, body + background-color: #2b3850 + font-family: 'Lato', sans-serif + font-weight: 400 + padding: 0px + margin: 0px + color: $text-color + font-size: 14px + line-height: 1.2rem + position: relative + scroll-behavior: smooth + -ms-text-size-adjust: 100% + -webkit-text-size-adjust: 100% + -webkit-tap-highlight-color: rgba(0,0,0,0) + -webkit-tap-highlight-color: transparent + -moz-tap-highlight-color: rgba(0,0,0,0) + -moz-tap-highlight-color: transparent + -ms-tap-highlight-color: rgba(0,0,0,0) + -ms-tap-highlight-color: transparent + tap-highlight-color: rgba(0,0,0,0) + tap-highlight-color: transparent + font-smoothing: antialiased !important + font-smooth: always !important + -webkit-font-smoothing: antialiased !important + -webkit-font-smooth: always !important + text-rendering: optimizeLegibility !important + +body + background-size: 50px 50px + background-image: url('/logo-bg.png') + background-repeat: no-repeat + background-position: 50% 96% + +&::selection + background-color: rgba(37,50,107,0.333) + color: #FAFAFA +&::-moz-selection + background-color: rgba(37,50,107,0.333) + color: #FAFAFA + +a, picture, img, video, button, input, pre, i, select, option, label, tr, td, p + user-select: none + outline: none !important + -webkit-tap-highlight-color: rgba(0,0,0,0) + -webkit-tap-highlight-color: transparent + -moz-tap-highlight-color: rgba(0,0,0,0) + -moz-tap-highlight-color: transparent + -ms-tap-highlight-color: rgba(0,0,0,0) + -ms-tap-highlight-color: transparent + tap-highlight-color: rgba(0,0,0,0) + tap-highlight-color: transparent + &:hover, &:focus, &:active + outline: none !important + -webkit-tap-highlight-color: rgba(0,0,0,0) + -webkit-tap-highlight-color: transparent + -moz-tap-highlight-color: rgba(0,0,0,0) + -moz-tap-highlight-color: transparent + -ms-tap-highlight-color: rgba(0,0,0,0) + -ms-tap-highlight-color: transparent + tap-highlight-color: rgba(0,0,0,0) + tap-highlight-color: transparent + +a, a:active + transition: color 128ms ease-in-out, opacity 128ms ease-in-out, text-shadow 128ms ease-in-out + color: lighten($link-color, 20%) + opacity: 0.777 + text-decoration: underline +a + &:hover, &.active + opacity: 1 + text-decoration: none + color: lighten($link-color, 30%) + +strong, b + font-weight: 700 + +p + margin: 1rem 0rem + line-height: 1.5rem + +hr + height: 2px + border: 0 + background-color: rgba(255,255,255,0.05) + +.danger + color: #a94442 + +.invisible + display: none !important + visibility: hidden !important + +.width-1 + width: 1% + +.white + color: #FAFAFA !important + +.center + text-align: center + margin-right: auto + margin-left: auto + +.container + margin: 0px auto + max-width: 768px + width: 100% + position: relative + display: table + +.container-main + box-shadow: 0px 0px 20px rgba(0,0,0,0.5) + background-color: mix(#080c15, #1c222e) + +.preview-circle + height: 40px + width: 40px + text-align: center + overflow: hidden + border-radius: 40px + border: 2px solid #9ca8be + box-shadow: inset 0px 0px 8px rgba(0,0,0,0.25) + position: relative + margin: 0px auto + i + font-size: 20px + line-height: 0px + margin-top: 18px + img + width: 36px + height: 36px + border-radius: 40px + +.main-container + display: table + min-height: 100% + width: 100% + .main-row + display: table-row + min-height: 100% + width: 100% + .main-cell + display: table-cell + min-height: 100% + width: 100% + vertical-align: middle + +.row + display: table-row + width: 100% + +.col-50 + display: table-cell + width: 50% + max-width: 50% + min-height: 455px + max-height: 455px + height: 455px + vertical-align: middle + position: relative + &.info + width: 20% + max-width: 20% + +.ellipsis + white-space: nowrap + overflow: hidden + max-width: 100% + text-overflow: ellipsis + -o-text-overflow: ellipsis + +.scroll + -webkit-overflow-scrolling: touch + transform: translate3d(0,0,0) +.scroll-x + overflow-y: hidden + overflow-x: auto + +button, input, select, textarea, .label + user-select: none !important + +button, select, input, button, form, textarea, a, pre, i + outline: none !important + &:active, &:focus, &:visited + outline: none !important + +pre, pre code + white-space: pre +pre + overflow: auto + transform: translate3d(0,0,0) + -webkit-overflow-scrolling: touch + +th + text-align: center + vertical-align: middle + word-break: break-word + +.no-margin + margin: 0px !important + +.no-padding + padding: 0px !important + +.no-radius + border-radius: 0px + +.file-preview + margin: 0 auto + max-width: 100% + display: block + &.file-audio + margin: 25px 0px + +iframe.file-preview + border: 0 + background: none + margin: 0 + padding: 0 + font: inherit + vertical-align: baseline + height: 100% + width: 100% + +.file-overlay + position: absolute + transform: translate3d(0,0,0) + background-color: rgba(0,0,0,0.777) + -webkit-backdrop-filter: blur(3px) + -moz-backdrop-filter: blur(3px) + -ms-backdrop-filter: blur(3px) + backdrop-filter: blur(3px) + top: 0px + left: 0px + z-index: 200 + width: 100% + height: 100% + vertical-align: middle + text-align: center + overflow: hidden + table + height: 100% + width: 100% + td + vertical-align: middle + text-align: center + table.fixed-table + height: auto + td, th + line-height: 1.4rem + word-break: break-all + padding: 5px 10px + vertical-align: middle + td + text-align: left + th + text-align: right + font-weight: 700 + +.full-height + min-height: 100% + min-height: 100vh + +.gh-ribbon + transition: opacity 256ms ease-in-out + transform: translate3d(0,0,0) + position: absolute + top: 0 + right: 0 + border: 0 + z-index: 300 + opacity: .555 + &:hover + opacity: .777 + img + height: 130px + width: 130px + +.radio input[type="radio"], .radio-inline input[type="radio"] + position: absolute + margin-top: 4px \9 + margin-left: -20px + +.radio + .radio + margin-top: -5px + +.radio-inline + position: relative + display: inline-block + padding-left: 20px + margin-bottom: 0 + font-weight: normal + vertical-align: middle + cursor: pointer + +.radio-inline + .radio-inline + margin-top: 0 + margin-left: 10px + +fieldset[disabled] input + &[type="radio"] + cursor: not-allowed + +.radio-inline.disabled + cursor: not-allowed + +fieldset[disabled] + .radio-inline + cursor: not-allowed + +h1, h2, h3, h4 + font-weight: 300 + +h1 + font-size: 1.7rem + line-height: 2.2rem + +h2 + font-size: 1.5rem + line-height: 2rem + margin: 1rem 0rem + +h3 + font-size: 1.3rem + line-height: 1.8rem + margin: 0.8rem 0rem + +h4 + font-size: 1.1rem + line-height: 1.6rem + margin: 0.6rem 0rem + +h1, h2, h3, h4, h5, h6, div, form, table, p + box-sizing: border-box + +.info-link + color: inherit + font-size: 1.1rem + position: absolute + animation: newElement 350ms ease-in-out 1 + transform: translate3d(0,0,0) + bottom: 6px + right: 8px + padding: 7px + z-index: 201 + &:hover, &.active + color: #BBB + opacity: 1 !important + &.right + left: 8px + right: initial + &.user-account + right: 50px + &.user-only + right: 100px + &.loggingIn + pointer-events: none + +.login-options + line-height: 3rem +.login-options, .share-options + a + margin: 0px 7px + transition: none + i + transition: color 256ms ease-in-out + img + max-height: 1.33333333em + position: relative + bottom: -5px + transition: all 256ms ease-in-out + filter: grayscale(100%) + &:hover + color: lighten($link-color, 10%) + img + filter: grayscale(0%) + i.fa-github + color: #fff + i.fa-twitter + color: #00aced + i.fa-facebook-official + color: #3b5998 + +.block-info + color: $text-color + text-align: center + padding: 20px + .annotation:first-child, .project-info:first-child + margin-top: 0px + .annotation:last-child, .project-info:last-child + margin-bottom: 0px + +.annotation + color: $text-color + font-size: 0.98rem + line-height: 1.6rem + margin: 2rem 0rem + font-weight: 400 + +.bg-diagonal + background: linear-gradient(45deg, #080c15 0%, #1c222e 100%) + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#080c15', endColorstr='#1c222e', GradientType=1) +.bg-horizontal + background: linear-gradient(to right, #222938 0%, #212834 100%) + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#222938', endColorstr='#212834', GradientType=1) + +.listing, .file, ._loading, ._404 + @extend .bg-horizontal + +.upload-form + .percentage + font-size: 3.6rem + margin-bottom: 2.2rem + margin-top: 0px + line-height: 3.4rem + @extend .bg-diagonal + position: relative + user-select: none + i.fa.fa-cloud-upload + font-size: 4rem + h2 + font-size: 1.5rem + line-height: 2rem + color: $link-color + font-weight: 700 + text-align: center + vertical-align: middle + .radio-inline + user-select: none + input[type="radio"] + margin-left: -20px + margin-top: 2.5px + &.file-over + background: + color: mix(#080c15, #1c222e) + image: url(/dnd.png) + repeat: no-repeat + position: 50% 50% + size: 80px 80px + & * + display: none + visibility: hidden + opacity: 0 + pointer-events: none + .fake-upload + cursor: pointer + transition: color 256ms ease-in-out, opacity 256ms ease-in-out + width: 85% + margin: 0px auto + opacity: 0.777 + &:hover + opacity: 1 + color: lighten($link-color, 25%) + .sm-row + width: 85% + margin: 0px auto + +.show-settings + opacity: 0.7 + display: inline-block + transition: color 256ms ease-in-out, transform 256ms ease-in-out, opacity 256ms ease-in-out + &:hover, &.active + opacity: 1 + transform: rotate(144deg) + +.listing, .file, ._loading + width: 100% + max-width: 384px + color: #aebad2 + overflow-x: hidden + overflow-y: auto + -webkit-overflow-scrolling: touch + max-height: 100% + min-height: 455px + +.files-note + text-align: center + +._loading + vertical-align: middle + text-align: center + +.markdown-body + height: 100% + width: 100% + max-width: 384px + max-height: 100% + min-height: 455px + +.file + display: block + .file-table + display: table + width: 100% + min-height: 455px + height: 100% + .file-row + display: table-row + width: 100% + height: 100% + .file-cell + display: table-cell + width: 100% + height: 100% + vertical-align: middle + text-align: center + .file-preview + margin: 0 auto + picture.file-preview + height: 100% + width: 100% + text-align: center + display: table-cell + vertical-align: middle + picture, img + pointer-events: none + h1.file-title + position: absolute + transform: translate3d(0,0,0) + top: 0px + left: 0px + background-color: rgba(0,0,0,0.555) + -webkit-backdrop-filter: blur(2px) + -moz-backdrop-filter: blur(2px) + -ms-backdrop-filter: blur(2px) + backdrop-filter: blur(2px) + margin: 0px + padding: 7px 0px + z-index: 100 + padding-left: 40px + padding-right: 10px + width: 100% + max-width: 100% + .go-back + background-color: rgba(0,0,0,0.15) + border-right: 1px solid rgba(0,0,0,0.1) + padding: 0px 10px + margin: 0px 0px + display: inline-block + height: 100% + text-decoration: none + font-size: 2.2rem + line-height: 3rem + position: absolute + left: 0px + top: 0px + font-weight: 700 + color: rgba(255,255,255,0.777) + &:hover + color: #fff + background-color: rgba(0,0,0,0.25) + .info-link + background-color: rgba(0,0,0,0.7) + border-radius: 20px + overflow: hidden + opacity: 0.5 + transition: color 256ms ease-in-out, opacity 256ms ease-in-out + +.file-settings + text-align: center + vertical-align: middle + font-size: 2rem + +.fixed-table .file-settings + a + width: 100% + height: 100% + min-width: 100% + min-height: 100% + display: inline-block + +.file-name + color: #aebad2 + +.file-info + color: #606c80 + font-size: 0.9rem + +.file-access, .file-secured + i + transition: color 256ms ease-in-out, opacity 256ms ease-in-out + +.file-remove:hover + color: #bd362f +.file-access:hover + i.fa.fa-eye + color: #bd362f +.file-secured:hover + i.fa.fa-unlock + color: #bd362f + +.file-name, .file-info + padding: 0px + margin: 0px + display: inline + +.file-info-container + max-width: 70% + +.btn + padding: 10px 20px + text-align: center + background: none + background-color: transparent + border: 1px solid rgba(0,0,0,0.3) + margin: 0px + +button + cursor: pointer + +.table + border-collapse: collapse + border-spacing: 0 + border: 0 + & > tbody > tr > + & td + padding: 12px 10px + vertical-align: middle + & td.ellipsis + white-space: nowrap + overflow: hidden + text-overflow: ellipsis + & td + border-bottom: 1px solid #1a2333 + & > tbody > tr + border-bottom: 1px solid #1a2333 + &:hover + cursor: pointer + background-color: rgba(0,0,0,0.1) + +.fixed-table + width: 100% + table-layout: fixed + border-collapse: collapse + border-spacing: 0 + border: 0 + td + white-space: nowrap + overflow: hidden + text-overflow: ellipsis + -o-text-overflow: ellipsis + +.info-img + transition: opacity 256ms ease-in-out + opacity: .555 + &:hover + opacity: 1 + img + height: 32px + width: auto + border-radius: 5px + margin: 0px 5px + &.gcaa + img + height: 120px + max-height: 50% + max-width: 50% !important + +._404 + text-align: center + padding: 15px + +progress + display: block + width: 90% + height: 2px + border: none + color: #FEFEFE + background-color: #000 + margin: 0px auto + border-radius: 0px + -webkit-appearance: none + -moz-appearance: none + -ms-appearance: none + appearance: none + +progress::-webkit-progress-bar + background: #000 + color: #000 + border-radius: 0px + +progress::-webkit-progress-value + background: #FEFEFE + color: #FEFEFE + +progress::-moz-progress-bar + background: #aaa + color: #aaa + border-radius: 0px + +progress::-moz-progress-value + background: red + color: #FEFEFE + +pre, code, kbd, samp + font-family: Menlo, Monaco, Consolas, "Courier New", monospace + box-sizing: border-box + +@media(max-height: 455px) + p.project-info + margin: 0.9rem 0rem + .gh-ribbon + left: 0px + right: initial + img + transform: rotate(-90deg) + height: 90px + width: 90px + .upload-form + .fake-upload + width: 100% + #userfile:not(.settings-open) + display: block !important + visibility: visible !important + position: absolute + top: 0px + left: 0px + z-index: 100 + height: 100% + width: 100% + opacity: 0 + .col-50, .listing, .file, ._loading, .file .file-table, .no-file, ._404 + min-height: 100vh + max-height: 100vh + height: 100vh + html, body + font-size: 13px + +@media(max-width: 768px) + .col-50 + max-width: 50vw + +@media(max-width: 480px) + p.project-info + margin: 0.9rem 0rem + .info-link + font-size: 2rem + .col-50 + display: table-row + position: initial + max-height: 50% + max-height: 50vh + height: 50% + height: 50vh + .col-50.upload-form + max-height: 40% + max-height: 40vh + height: 40% + height: 40vh + .sm-row + width: 100% + margin: initial + .fake-upload + width: 100% + #userfile:not(.settings-open) + display: block !important + visibility: visible !important + position: absolute + top: 0px + left: 0px + z-index: 100 + height: 100% + width: 100% + opacity: 0 + .row + display: initial + min-width: 100vw + width: 100vw + .sm-row + display: table-cell + position: relative + min-width: 100vw + width: 100vw + vertical-align: middle + .file, .listing, .file-table, ._loading, ._login, .no-file, ._404 + max-width: 100% + max-width: 100vw + min-width: 100vw + width: 100% + width: 100vw + max-height: 60% + max-height: 60vh + min-height: initial !important + height: 60% + height: 60vh + .markdown-body + max-width: 100% + max-width: 100vw + min-width: 100vw + width: 100% + width: 100vw + .gh-ribbon + left: 0px + right: initial + img + height: 85px + width: 85px + transform: rotate(-90deg) + html, body + font-size: 12px \ No newline at end of file diff --git a/demo/client/upload-form.coffee b/demo/client/upload-form.coffee deleted file mode 100644 index f2588730..00000000 --- a/demo/client/upload-form.coffee +++ /dev/null @@ -1,113 +0,0 @@ -Template.uploadForm.onCreated -> - @error = new ReactiveVar false - @uploadInstance = new ReactiveVar false - - @initiateUpload = (event, files, template) -> - unless files.length - template.error.set "Please select a file to upload" - return false - - if files.length > 6 - template.error.set "Please select up to 6 files" - return - - cleanUploaded = (current) -> - _uploads = _.clone template.uploadInstance.get() - if _.isArray _uploads - _.each _uploads, (upInst, index) -> - if upInst.file.name is current.file.name - _uploads.splice index, 1 - if _uploads.length - template.uploadInstance.set _uploads - else - template.uploadInstance.set false - return - return - - for radio in event.currentTarget.transport - if radio.checked - transport = radio.value - - transport ?= 'ddp' - ClientStorage.set 'uploadTransport', transport - - created_at = +new Date - uploads = [] - _.each files, (file) -> - Collections.files.insert( - file: file - meta: - expireAt: new Date(created_at + _app.storeTTL) - created_at: created_at - downloads: 0 - blamed: 0 - streams: 'dynamic' - chunkSize: 'dynamic' - transport: transport - , - false # Use manual start, so we can attach event listeners - ).on('end', (error, fileObj) -> - if not error and files.length is 1 - # Redirect to uploaded file - # Only then we upload one file - FlowRouter.go('file', _id: fileObj._id) - cleanUploaded @ - return - ).on('abort', -> - cleanUploaded @ - return - ).on('error', (error) -> - template.error.set error?.reason or error - Meteor.setTimeout -> - template.error.set false - , 5000 - cleanUploaded @ - return - ).on('start', -> - uploads.push @ - template.uploadInstance.set uploads - return - ).start() - -Template.uploadForm.helpers - error: -> Template.instance().error.get() - uploadInstance: -> Template.instance().uploadInstance.get() - estimateBitrate: -> filesize(@estimateSpeed.get(), {bits: true}) + '/s' - uploadTransport: -> ClientStorage.get 'uploadTransport' - estimateDuration: -> - duration = moment.duration(@estimateTime.get()) - hours = "#{duration.hours()}" - hours = "0" + hours if hours.length <= 1 - minutes = "#{duration.minutes()}" - minutes = "0" + minutes if minutes.length <= 1 - seconds = "#{duration.seconds()}" - seconds = "0" + seconds if seconds.length <= 1 - return "#{hours}:#{minutes}:#{seconds}" - -Template.uploadForm.events - 'click #pause': -> @pause() - 'click #abort': -> @abort() - 'click #continue': -> @continue() - 'dragenter #uploadFile, dragstart #uploadFile': (e, template) -> - $(e.currentTarget).addClass 'file-over' - return - 'dragleave #uploadFile, dragend #uploadFile': (e, template) -> - $(e.currentTarget).removeClass 'file-over' - return - 'dragover #uploadFile': (e, template) -> - e.preventDefault() - $(e.currentTarget).addClass 'file-over' - e.originalEvent.dataTransfer.dropEffect = 'copy' - return - 'drop #uploadFile': (e, template) -> - e.preventDefault() - template.error.set false - $(e.currentTarget).removeClass 'file-over' - template.initiateUpload e, e.originalEvent.dataTransfer.files, template - false - 'change input[name="userfile"]': (e, template) -> template.$('form#uploadFile').submit() - 'submit form#uploadFile': (e, template) -> - e.preventDefault() - template.error.set false - template.initiateUpload e, e.currentTarget.userfile.files, template - false \ No newline at end of file diff --git a/demo/client/upload-form.jade b/demo/client/upload-form.jade deleted file mode 100644 index 7cacbec6..00000000 --- a/demo/client/upload-form.jade +++ /dev/null @@ -1,43 +0,0 @@ -template(name="uploadForm") - form.navbar-form.center#uploadFile - if error - .alert.alert-danger {{error}} - - unless uploadInstance - .input-group.select-form - input.form-control.btn.btn-default(title="Select File" type="file" name="userfile" required multiple) - span.input-group-btn - button.btn.btn-primary(type="submit" title="Upload File") - i.fa.fa-lg.fa-cloud-upload - | Upload - small.text-center.help-block - | Upload via: - label.radio-inline - input(type="radio" name="transport" value="ddp" checked="{{#if compare uploadTransport 'is' 'ddp'}}checked{{/if}}") - | DDP - label.radio-inline - input(type="radio" name="transport" value="http" checked="{{#if compare uploadTransport 'is' 'http'}}checked{{/if}}") - | HTTP (3x performance) - small.text-center.help-block.no-margin - span Any file-type. With size less or equal to 128MB, up to 6 files. - span.text-info This form supports drag'n'drop. - else - table.upload-form-container: tbody - +each uploadInstance - if compare state.get 'isnt' 'aborted' - tr - td - .btn-group - if onPause.get - button#continue.btn.btn-default.btn-sm(type="button" title="Resume upload") - i.fa.fa-fw.fa-play - else - button#pause.btn.btn-default.btn-sm(type="button" title="Pause upload") - i.fa.fa-fw.fa-pause - button#abort.btn.btn-default.btn-sm(type="button" title="Abort upload") - i.fa.fa-fw.fa-stop - td: .progress.center: .progress-bar.progress-bar-striped.active(aria-valuemin="0" aria-valuemax="100" style="width: {{progress.get}}%") - tr: td.center(colspan="2"): small.text-center.help-block(style="margin-bottom:0px") - b #{file.name}: - | Remaining time: {{estimateDuration}} | Speed: {{estimateBitrate}} - tr: td.center(colspan="2"): small.text-center.help-block(style="margin-bottom:0px") You are free to browse the site while upload in progress \ No newline at end of file diff --git a/demo/client/upload/upload-form.coffee b/demo/client/upload/upload-form.coffee new file mode 100644 index 00000000..4bfcc70c --- /dev/null +++ b/demo/client/upload/upload-form.coffee @@ -0,0 +1,191 @@ +Template.uploadForm.onCreated -> + self = @ + @error = new ReactiveVar false + @uploadQTY = 0 + @showSettings = new ReactiveVar false + + @initiateUpload = (event, files) -> + if _app.uploads.get() + return false + + unless files.length + self.error.set "Please select a file to upload" + return false + + if files.length > 6 + self.error.set "Please select up to 6 files" + return + + self.uploadQTY = files.length + + cleanUploaded = (current) -> + _uploads = _.clone _app.uploads.get() + if _.isArray _uploads + _.each _uploads, (upInst, index) -> + if upInst.file.name is current.file.name + _uploads.splice index, 1 + if _uploads.length + _app.uploads.set _uploads + else + self.uploadQTY = 0 + _app.uploads.set false + return + return + + uploads = [] + transport = ClientStorage.get 'uploadTransport' + created_at = +new Date + + if Meteor.userId() + secured = _app.secured.get() + secured = false unless _.isBoolean secured + if secured + unlisted = true + else + unlisted = _app.unlist.get() + unlisted = true unless _.isBoolean unlisted + ttl = new Date(created_at + _app.storeTTLUser) + else + unlisted = false + secured = false + ttl = new Date(created_at + _app.storeTTL) + + _.each files, (file, i) -> + Collections.files.insert( + file: file + meta: + blamed: 0 + secured: secured + expireAt: ttl + unlisted: unlisted + downloads: 0 + created_at: created_at - 1 - i + streams: 'dynamic' + chunkSize: 'dynamic' + transport: transport + , + false # Use manual start, so we can attach event listeners + ).on('end', (error, fileObj) -> + if not error and files.length is 1 + # Redirect to uploaded file + # Only then we upload one file + FlowRouter.go('file', _id: fileObj._id) + cleanUploaded @ + return + ).on('abort', -> + cleanUploaded @ + return + ).on('error', (error) -> + console.error error + self.error.set (if self.error.get() then self.error.get() + '
' else '') + @file.name + ': ' + (error?.reason or error) + Meteor.setTimeout -> + self.error.set false + , 10000 + cleanUploaded @ + return + ).on('start', -> + uploads.push @ + _app.uploads.set uploads + return + ).start() + +Template.uploadForm.helpers + error: -> Template.instance().error.get() + uploads: -> _app.uploads.get() + status: -> + i = 0 + uploads = _app.uploads.get() + progress = 0 + uploadQTY = Template.instance().uploadQTY + estimateBitrate = 0 + estimateDuration = 0 + onPause = false + if uploads + for upload in uploads + onPause = upload.onPause.get() + progress += upload.progress.get() + estimateBitrate += upload.estimateSpeed.get() + estimateDuration += upload.estimateTime.get() + i++ + + if i < uploadQTY + progress += 100 * (uploadQTY - i) + + progress = Math.ceil(progress / uploadQTY) + estimateBitrate = filesize(Math.ceil(estimateBitrate / i), {bits: true}) + '/s' + estimateDuration = do -> + duration = moment.duration Math.ceil(estimateDuration / i) + hours = "#{duration.hours()}" + hours = "0" + hours if hours.length <= 1 + minutes = "#{duration.minutes()}" + minutes = "0" + minutes if minutes.length <= 1 + seconds = "#{duration.seconds()}" + seconds = "0" + seconds if seconds.length <= 1 + return "#{hours}:#{minutes}:#{seconds}" + return {progress, estimateBitrate, estimateDuration, onPause} + showSettings: -> Template.instance().showSettings.get() + showProjectInfo: -> _app.showProjectInfo.get() + uploadTransport: -> ClientStorage.get 'uploadTransport' + +Template.uploadForm.events + 'click input[type="radio"]': (e, template) -> + ClientStorage.set 'uploadTransport', e.currentTarget.value + true + 'click [data-pause-all]': (e, template) -> + e.preventDefault() + uploads = _app.uploads.get() + if uploads + for upload in uploads + upload.pause() + false + 'click [data-abort-all]': (e, template) -> + e.preventDefault() + uploads = _app.uploads.get() + if uploads + for upload in uploads + upload.abort() + template.error.set false + false + 'click [data-continue-all]': (e, template) -> + e.preventDefault() + uploads = _app.uploads.get() + if uploads + for upload in uploads + upload.continue() + false + 'click #fakeUpload': (e, template) -> + template.$('#userfile').click() + return + 'dragenter #uploadFile, dragstart #uploadFile': (e, template) -> + $('#uploadFile').addClass 'file-over' + return + 'dragleave #uploadFile, dragend #uploadFile': (e, template) -> + $('#uploadFile').removeClass 'file-over' + return + 'dragover #uploadFile': (e, template) -> + e.preventDefault() + $('#uploadFile').addClass 'file-over' + e.originalEvent.dataTransfer.dropEffect = 'copy' + return + 'drop #uploadFile': (e, template) -> + e.preventDefault() + template.error.set false + $('#uploadFile').removeClass 'file-over' + template.initiateUpload e, e.originalEvent.dataTransfer.files, template + false + 'change input[name="userfile"]': (e, template) -> template.$('form#uploadFile').submit() + 'submit form#uploadFile': (e, template) -> + e.preventDefault() + template.error.set false + template.initiateUpload e, e.currentTarget.userfile.files + false + 'click [data-show-settings]': (e, template) -> + e.preventDefault() + $('.gh-ribbon').toggle() + template.showSettings.set !template.showSettings.get() + false + 'click [data-show-project-info]': (e, template) -> + e.preventDefault() + $('.gh-ribbon').toggle() + _app.showProjectInfo.set !_app.showProjectInfo.get() + false \ No newline at end of file diff --git a/demo/client/upload/upload-form.jade b/demo/client/upload/upload-form.jade new file mode 100644 index 00000000..24ec8361 --- /dev/null +++ b/demo/client/upload/upload-form.jade @@ -0,0 +1,47 @@ +template(name="uploadForm") + form.col-50.upload-form#uploadFile: .sm-row + unless uploads + input.invisible#userfile(class="{{#if showSettings}}settings-open{{/if}}" type="file" name="userfile" required multiple) + a.info-link.show-settings(data-show-settings href="#" class="{{#if showSettings}}active{{/if}}"): i.fa.fa-fw.fa-gear + + if showSettings + .block-info + p.annotation.white + h3.no-margin Upload via: + label.radio-inline + input(type="radio" name="transport" value="ddp" checked="{{#if compare uploadTransport 'is' 'ddp'}}checked{{/if}}") + | DDP + label.radio-inline + input(type="radio" name="transport" value="http" checked="{{#if compare uploadTransport 'is' 'http'}}checked{{/if}}") + | HTTP (3x performance) + + p.annotation Any file-type.
With size less or equal to 128MB,
up to 6 files. + p.annotation This form supports drag'n'drop. + else + .fake-upload#fakeUpload + i.fa.fa-cloud-upload + if error + h4.danger {{{error}}} + else + h2 UPLOAD A FILE + + +accounts + a.info-link.right(data-show-project-info href="#"): i.fa.fa-fw.fa-info + else + if error + h4.danger {{{error}}} + h1.percentage #{status.progress}% + progress(max="100" value="#{status.progress}") + p + | #{status.estimateDuration} + |   + | · + |   + | #{status.estimateBitrate} + p + if status.onPause + a(data-continue-all href="#" title="Resume upload"): i.fa.fa-fw.fa-play + else + a(data-pause-all href="#" title="Pause upload"): i.fa.fa-fw.fa-pause + |   + a(data-abort-all href="#" title="Abort upload"): i.fa.fa-fw.fa-stop \ No newline at end of file diff --git a/demo/client/upload/upload-row.coffee b/demo/client/upload/upload-row.coffee new file mode 100644 index 00000000..d11bfbbf --- /dev/null +++ b/demo/client/upload/upload-row.coffee @@ -0,0 +1,22 @@ +Template.uploadRow.helpers + estimateBitrate: -> filesize(@estimateSpeed.get(), {bits: true}) + '/s' + getProgressClass: -> + progress = @progress.get() + progress = Math.ceil(progress / 5) * 5 + progress = 100 if progress > 100 + return progress + estimateDuration: -> + duration = moment.duration(@estimateTime.get()) + hours = "#{duration.hours()}" + hours = "0" + hours if hours.length <= 1 + minutes = "#{duration.minutes()}" + minutes = "0" + minutes if minutes.length <= 1 + seconds = "#{duration.seconds()}" + seconds = "0" + seconds if seconds.length <= 1 + return "#{hours}:#{minutes}:#{seconds}" + +Template.uploadRow.events + 'click [data-toggle-upload]': (e, template) -> + e.preventDefault() + @toggle() + false \ No newline at end of file diff --git a/demo/client/upload/upload-row.jade b/demo/client/upload/upload-row.jade new file mode 100644 index 00000000..af01f0a7 --- /dev/null +++ b/demo/client/upload/upload-row.jade @@ -0,0 +1,24 @@ +template(name="uploadRow") + tr + td.width-1.preview + .progress-radial(class="progress-{{getProgressClass}}") + .overlay(data-toggle-upload) + if onPause.get + span(title="Resume upload"): i.fa.fa-lg.fa-fw.fa-play + else + span.progress #{progress.get}% + span.action(title="Pause upload"): i.fa.fa-lg.fa-fw.fa-pause + td + table.fixed-table: tbody: tr: td + .file-name #{file.name} + br + .file-info + span.file-size(title="File size") {{filesize file.size}} + |   + | · + |   + span.file-estimate-duration(title="Upload in") #{estimateDuration} + |   + | · + |   + span.file-estimate-bitrate(title="Upload speed") #{estimateBitrate} \ No newline at end of file diff --git a/demo/client/user-account/accounts.coffee b/demo/client/user-account/accounts.coffee new file mode 100644 index 00000000..f4f40bcb --- /dev/null +++ b/demo/client/user-account/accounts.coffee @@ -0,0 +1,8 @@ +Template.accounts.helpers + userOnly: -> _app.userOnly.get() + +Template.accounts.events + 'click [data-show-user-only]': (e) -> + e.preventDefault() + _app.userOnly.set !_app.userOnly.get() + return \ No newline at end of file diff --git a/demo/client/user-account/accounts.jade b/demo/client/user-account/accounts.jade new file mode 100644 index 00000000..20bd2086 --- /dev/null +++ b/demo/client/user-account/accounts.jade @@ -0,0 +1,10 @@ +template(name="accounts") + if currentUser + if compare currentRouteName 'is' 'index' + a.info-link.user-only(data-show-user-only href="#" class="{{#if userOnly}}active{{/if}}" title="Show my files only"): i.fa.fa-fw.fa-user-secret + a.info-link.user-account(href="{{#if isActiveRoute 'login'}}{{pathFor 'index'}}{{else}}{{pathFor 'login'}}{{/if}}" class="{{#if isActiveRoute 'login'}}active{{/if}}"): i.fa.fa-fw.fa-user + else + if loggingIn + a.info-link.user-account.loggingIn(href="#"): i.fa.fa-fw.fa-spin.fa-spinner + else + a.info-link.user-account(href="{{#if isActiveRoute 'login'}}{{pathFor 'index'}}{{else}}{{pathFor 'login'}}{{/if}}" class="{{#if isActiveRoute 'login'}}active{{/if}}"): i.fa.fa-fw.fa-sign-in \ No newline at end of file diff --git a/demo/client/user-account/login.coffee b/demo/client/user-account/login.coffee new file mode 100644 index 00000000..97ac8223 --- /dev/null +++ b/demo/client/user-account/login.coffee @@ -0,0 +1,45 @@ +Template.login.onCreated -> + self = @ + @serviceConfiguration = new ReactiveVar {} + Meteor.call 'getServiceConfiguration', (error, serviceConfiguration) -> + if error + console.error error + else + self.serviceConfiguration.set serviceConfiguration + return + return + +Template.login.onRendered -> + window.IS_RENDERED = true + return + +Template.login.helpers + unlist: -> _app.unlist.get() + secured: -> _app.secured.get() + serviceConfiguration: -> Template.instance().serviceConfiguration.get() + +Template.login.events + 'click [data-login-meteor]': (e) -> + e.preventDefault() + Meteor.loginWithMeteorDeveloperAccount() + return + 'click [data-login-github]': (e) -> + e.preventDefault() + Meteor.loginWithGithub() + return + 'click [data-login-twitter]': (e) -> + e.preventDefault() + Meteor.loginWithTwitter {} # Won't work without argument + return + 'click [data-login-facebook]': (e) -> + e.preventDefault() + Meteor.loginWithFacebook {} # Whoot?! Docs? + return + 'click [data-change-unlist]': (e) -> + e.preventDefault() + _app.unlist.set !_app.unlist.get() + false + 'click [data-change-secured]': (e) -> + e.preventDefault() + _app.secured.set !_app.secured.get() + false \ No newline at end of file diff --git a/demo/client/user-account/login.jade b/demo/client/user-account/login.jade new file mode 100644 index 00000000..a3b8c222 --- /dev/null +++ b/demo/client/user-account/login.jade @@ -0,0 +1,40 @@ +template(name="login") + if currentUser + .col-50._login: .sm-row + h1.files-note + | {{currentUser.profile.name}} + |   + +logout + hr + h2.files-note Upload settings: + h3.files-note + unless secured + a(data-change-unlist href="#" title="{{#if unlist}}Show uploaded files publicity{{else}}Hide uploaded files from public list{{/if}}") + if unlist + i.fa.fa-fw.fa-eye-slash + else + i.fa.fa-fw.fa-eye + |   + |   + | · + |   + a(data-change-secured href="#" title="{{#if secured}}Allow access to uploaded files by link{{else}}Make uploaded files accessible to me only{{/if}}") + if secured + i.fa.fa-fw.fa-lock + else + i.fa.fa-fw.fa-unlock + else + if loggingIn + +_loading + else + .col-50._login: .sm-row + h1.files-note Login via: + h1.files-note.login-options + if serviceConfiguration.meteor + a(data-login-meteor href="#" title="Login via: Meteor"): img(src="meteor-icon.svg") + if serviceConfiguration.github + a(data-login-github href="#" title="Login via: GitHub"): i.fa.fa-lg.fa-fw.fa-github + if serviceConfiguration.twitter + a(data-login-twitter href="#" title="Login via: Twitter"): i.fa.fa-lg.fa-fw.fa-twitter + if serviceConfiguration.facebook + a(data-login-facebook href="#" title="Login via: Facebook"): i.fa.fa-lg.fa-fw.fa-facebook-official \ No newline at end of file diff --git a/demo/client/user-account/logout.coffee b/demo/client/user-account/logout.coffee new file mode 100644 index 00000000..92191128 --- /dev/null +++ b/demo/client/user-account/logout.coffee @@ -0,0 +1,5 @@ +Template.logout.events + 'click [data-logout]': (e) -> + e.preventDefault() + Meteor.logout() + return diff --git a/demo/client/user-account/logout.jade b/demo/client/user-account/logout.jade new file mode 100644 index 00000000..d601eefd --- /dev/null +++ b/demo/client/user-account/logout.jade @@ -0,0 +1,2 @@ +template(name="logout") + a(data-logout href="#" title="Logout"): i.fa.fa-fw.fa-sign-out \ No newline at end of file diff --git a/demo/lib/__compatability/__globals.coffee b/demo/lib/__compatability/__globals.coffee index 7030dab4..67cec723 100644 --- a/demo/lib/__compatability/__globals.coffee +++ b/demo/lib/__compatability/__globals.coffee @@ -1,22 +1,68 @@ @Collections = {} -@_app = - subs: new SubsManager() - storeTTL: 259200000 - NOOP: -> return +@_app = NOOP: -> return +Package['kadira:flow-router'] = Package['ostrio:flow-router-extra']; if Meteor.isClient - ClientStorage.set('blamed', []) if not ClientStorage.has('blamed') or not _.isArray ClientStorage.get('blamed') - _app.blamed = new ReactiveVar ClientStorage.get 'blamed' + window.IS_RENDERED = false + ClientStorage.set('blamed', []) if not ClientStorage.has('blamed') or not _.isArray ClientStorage.get 'blamed' + ClientStorage.set('unlist', true) if not ClientStorage.has('unlist') or not _.isBoolean ClientStorage.get 'unlist' + ClientStorage.set('secured', false) if not ClientStorage.has('secured') or not _.isBoolean ClientStorage.get 'secured' + ClientStorage.set('userOnly', false) if not ClientStorage.has('userOnly') or not _.isBoolean ClientStorage.get 'userOnly' + + _app.subs = new SubsManager() + _app.blamed = new ReactiveVar ClientStorage.get 'blamed' + _app.unlist = new ReactiveVar ClientStorage.get 'unlist' + _app.secured = new ReactiveVar ClientStorage.get 'secured' + _app.uploads = new ReactiveVar false + _app.userOnly = new ReactiveVar ClientStorage.get 'userOnly' + _app.storeTTL = 86400000 + _app.currentUrl = -> Meteor.absoluteUrl((FlowRouter.current().path or document.location.pathname).replace(/^\//g, '')).split('?')[0].split('#')[0].replace '!', '' + _app.storeTTLUser = 432000000 + _app.showProjectInfo = new ReactiveVar false + Meteor.autorun -> ClientStorage.set 'blamed', _app.blamed.get() return + Meteor.autorun -> + ClientStorage.set 'unlist', _app.unlist.get() + return + + Meteor.autorun -> + ClientStorage.set 'secured', _app.secured.get() + return + + Meteor.autorun -> + ClientStorage.set 'userOnly', _app.userOnly.get() + return + ClientStorage.set('uploadTransport', 'ddp') unless ClientStorage.has 'uploadTransport' + Template.registerHelper 'urlCurrent', -> _app.currentUrl() + Template.registerHelper 'url', (string = null) -> Meteor.absoluteUrl string Template.registerHelper 'filesize', (size = 0) -> filesize size Template.registerHelper 'extless', (filename = '') -> parts = filename.split '.' parts.pop() if parts.length > 1 return parts.join '.' + Template.registerHelper 'DateToISO', (time) -> + return 0 unless time + if _.isString(time) or _.isNumber time + time = new Date time + time.toISOString() + + Template._404.onRendered -> + window.IS_RENDERED = true + return + + Template._layout.helpers + showProjectInfo: -> _app.showProjectInfo.get() + + Template._layout.events + 'click [data-show-project-info]': (e, template) -> + e.preventDefault() + $('.gh-ribbon').toggle() + _app.showProjectInfo.set !_app.showProjectInfo.get() + false marked.setOptions highlight: (code) -> hljs.highlightAuto(code).value @@ -27,4 +73,11 @@ if Meteor.isClient pedantic: false sanitize: true smartLists: true - smartypants: false \ No newline at end of file + smartypants: false + + Meteor.startup -> + $('html').attr 'itemscope', '' + $('html').attr 'itemtype', 'http://schema.org/WebPage' + $('html').attr 'xmlns:og', 'http://ogp.me/ns#' + $('html').attr 'xml:lang', 'en' + $('html').attr 'lang', 'en' \ No newline at end of file diff --git a/demo/lib/files.collection.coffee b/demo/lib/files.collection.coffee index 06799418..574d1f00 100644 --- a/demo/lib/files.collection.coffee +++ b/demo/lib/files.collection.coffee @@ -1,13 +1,26 @@ # DropBox usage: -# Read: https://github.com/VeliovGroup/Meteor-Files/wiki/Third-party-storage +# Read: https://github.com/VeliovGroup/Meteor-Files/wiki/DropBox-Integration +# env.var example: DROPBOX='{"dropbox":{"key": "xxx", "secret": "xxx", "token": "xxx"}}' useDropBox = false + +# AWS:S3 usage: +# Read: https://github.com/Lepozepo/S3#create-your-amazon-s3 +# Read: https://github.com/VeliovGroup/Meteor-Files/wiki/AWS-S3-Integration +# Create and attach CloudFront to S3 bucket: https://console.aws.amazon.com/cloudfront/ + +# env.var example: S3='{"s3":{"key": "xxx", "secret": "xxx", "bucket": "xxx", "region": "xxx", "cfdomain": "https://xxx.cloudfront.net"}}' +useS3 = false + if Meteor.isServer if process.env?.DROPBOX Meteor.settings.dropbox = JSON.parse(process.env.DROPBOX)?.dropbox + else if process.env?.S3 + Meteor.settings.s3 = JSON.parse(process.env.S3)?.s3 if Meteor.settings.dropbox and Meteor.settings.dropbox.key and Meteor.settings.dropbox.secret and Meteor.settings.dropbox.token useDropBox = true Dropbox = Npm.require 'dropbox' + Request = Npm.require 'request' fs = Npm.require 'fs' bound = Meteor.bindEnvironment (callback) -> return callback() client = new (Dropbox.Client)({ @@ -15,6 +28,24 @@ if Meteor.isServer secret: Meteor.settings.dropbox.secret token: Meteor.settings.dropbox.token }) + else if Meteor.settings.s3 and Meteor.settings.s3.key and Meteor.settings.s3.secret and Meteor.settings.s3.bucket and Meteor.settings.s3.region and Meteor.settings.s3.cfdomain + + # Fix CloudFront certificate issue + # Read: https://github.com/chilts/awssum/issues/164 + process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0 + + useS3 = true + knox = Npm.require 'knox' + Request = Npm.require 'request' + bound = Meteor.bindEnvironment (callback) -> return callback() + client = knox.createClient + key: Meteor.settings.s3.key + secret: Meteor.settings.s3.secret + bucket: Meteor.settings.s3.bucket + region: Meteor.settings.s3.region + + # Normalize cfdomain + Meteor.settings.s3.cfdomain = Meteor.settings.s3.cfdomain.replace /\/+$/, '' Collections.files = new FilesCollection debug: false @@ -22,24 +53,46 @@ Collections.files = new FilesCollection chunkSize: 1024*1024 storagePath: 'assets/app/uploads/uploadedFiles' collectionName: 'uploadedFiles' - allowClientCode: false - onBeforeUpload: -> + allowClientCode: true + protected: (fileObj) -> + if not fileObj.meta?.secured + return true + else if fileObj.meta?.secured and @userId is fileObj.userId + return true + return false + onBeforeRemove: (cursor) -> + self = @ + res = cursor.map (file) -> + return file?.userId is self.userId + return !~res.indexOf false + onBeforeUpload: -> return if @file.size <= 1024 * 1024 * 128 then true else "Max. file size is 128MB you've tried to upload #{filesize(@file.size)}" - downloadCallback: (fileObj) -> + downloadCallback: (fileObj) -> if @params?.query.download is 'true' Collections.files.collection.update fileObj._id, $inc: 'meta.downloads': 1 return true interceptDownload: (http, fileRef, version) -> - if useDropBox + if useDropBox or useS3 path = fileRef?.versions?[version]?.meta?.pipeFrom if path - # If file is moved to DropBox - # We will redirect browser to DropBox - http.response.writeHead 302, 'Location': path - http.response.end() + # If file is successfully moved to Storage + # We will pipe request to Storage + # So, original link will stay always secure + + # To force ?play and ?download parameters + # and to keep original file name, content-type, + # content-disposition and cache-control + # we're' using low-level .serve() method + @serve http, + fileRef, + fileRef.versions[version], + version, + Request + url: path + headers: _.pick http.request.headers, 'range', 'accept-language', 'accept', 'cache-control', 'pragma', 'connection', 'upgrade-insecure-requests', 'user-agent' return true else - # While file is not yet uploaded to DropBox + # While file is not yet uploaded to Storage # We will serve file from FS return false else @@ -50,14 +103,16 @@ if Meteor.isServer Collections.files.collection.attachSchema Collections.files.schema Collections.files.on 'afterUpload', (fileRef) -> + self = @ if useDropBox makeUrl = (stat, fileRef, version, triesUrl = 0) -> client.makeUrl stat.path, {long: true, downloadHack: true}, (error, xml) -> bound -> - # Store downloadable in file's meta object + # Store downloadable link in file's meta object if error if triesUrl < 10 Meteor.setTimeout -> makeUrl stat, fileRef, version, ++triesUrl + return , 2048 else console.error error, {triesUrl} @@ -65,16 +120,19 @@ if Meteor.isServer upd = $set: {} upd['$set']["versions.#{version}.meta.pipeFrom"] = xml.url upd['$set']["versions.#{version}.meta.pipePath"] = stat.path - Collections.files.collection.update {_id: fileRef._id}, upd, (error) -> + self.collection.update {_id: fileRef._id}, upd, (error) -> if error console.error error else - Collections.files.unlink Collections.files.collection.findOne(fileRef._id), version + # Unlink original files from FS + # after successful upload to DropBox + self.unlink self.collection.findOne(fileRef._id), version return else if triesUrl < 10 Meteor.setTimeout -> makeUrl stat, fileRef, version, ++triesUrl + return , 2048 else console.error "client.makeUrl doesn't returns xml", {triesUrl} @@ -82,11 +140,14 @@ if Meteor.isServer return writeToDB = (fileRef, version, data, triesSend = 0) -> + # DropBox already uses random URLs + # No need to use random file names client.writeFile "#{fileRef._id}-#{version}.#{fileRef.extension}", data, (error, stat) -> bound -> if error if triesSend < 10 Meteor.setTimeout -> writeToDB fileRef, version, data, ++triesSend + return , 2048 else console.error error, {triesSend} @@ -109,40 +170,78 @@ if Meteor.isServer return return - sendToDB = (fileRef) -> + sendToStorage = (fileRef) -> _.each fileRef.versions, (vRef, version) -> readFile fileRef, vRef, version return return - if !!~fileRef.type.indexOf 'image' - _app.createThumbnails Collections.files, fileRef, (fileRef) -> - if useDropBox - sendToDB Collections.files.collection.findOne fileRef._id + else if useS3 + sendToStorage = (fileRef) -> + _.each fileRef.versions, (vRef, version) -> + # We use Random.id() instead of real file's _id + # to secure files from reverse engineering + # As after viewing this code it will be easy + # to get access to unlisted and protected files + filePath = "files/#{Random.id()}-#{version}.#{fileRef.extension}" + client.putFile vRef.path, filePath, (error, res) -> bound -> + if error + console.error error + else + upd = $set: {} + upd['$set']["versions.#{version}.meta.pipeFrom"] = Meteor.settings.s3.cfdomain + '/' + filePath + upd['$set']["versions.#{version}.meta.pipePath"] = filePath + self.collection.update {_id: fileRef._id}, upd, (error) -> + if error + console.error error + else + # Unlink original files from FS + # after successful upload to AWS:S3 + self.unlink self.collection.findOne(fileRef._id), version + return + return + return + return + + if !!~['png', 'jpg', 'jpeg'].indexOf (fileRef.extension or '').toLowerCase() + _app.createThumbnails self, fileRef, (fileRef) -> + if useDropBox or useS3 + sendToStorage self.collection.findOne fileRef._id return else - if useDropBox - sendToDB fileRef + if useDropBox or useS3 + sendToStorage fileRef return # This line now commented due to Heroku usage # Collections.files.collection._ensureIndex {'meta.expireAt': 1}, {expireAfterSeconds: 0, background: true} - # DropBox usage: - if useDropBox - # Intercept File's collection remove method - # to remove file from DropBox + # Intercept FileCollection's remove method + # to remove file from DropBox or AWS S3 + if useDropBox or useS3 _origRemove = Collections.files.remove Collections.files.remove = (search) -> cursor = @collection.find search cursor.forEach (fileRef) -> - if fileRef?.meta?.pipePath - client.remove fileRef.meta.pipePath, (error) -> - if error - console.error error - return + _.each fileRef.versions, (vRef, version) -> + if vRef?.meta?.pipePath + if useDropBox + # DropBox usage: + client.remove vRef.meta.pipePath, (error) -> bound -> + if error + console.error error + return + else + # AWS:S3 usage: + client.deleteFile vRef.meta.pipePath, (error) -> bound -> + if error + console.error error + return + return + return # Call original method _origRemove.call @, search + return # Remove all files on server load/reload, useful while testing/development # Meteor.startup -> Collections.files.remove {} @@ -156,14 +255,22 @@ if Meteor.isServer , 120000 - Meteor.publish 'latest', (take = 50)-> + Meteor.publish 'latest', (take = 10, userOnly = false)-> check take, Number - return Collections.files.collection.find { - $or: [ - {'meta.blamed': $lt: 3}, - {'meta.blamed': $exists: false} - ] - }, { + check userOnly, Boolean + if userOnly and @userId + selector = userId: @userId + else + selector = { + $or: [{ + 'meta.unlisted': false + 'meta.secured': false + 'meta.blamed': $lt: 3 + },{ + userId: @userId + }] + } + return Collections.files.find(selector, { limit: take sort: 'meta.created_at': -1 fields: @@ -171,36 +278,89 @@ if Meteor.isServer name: 1 size: 1 meta: 1 + isPDF: 1 isText: 1 isJSON: 1 isVideo: 1 isAudio: 1 isImage: 1 - 'versions.thumbnail40.path': 1 + userId: 1 + 'versions.thumbnail40.type': 1 extension: 1 _collectionName: 1 _downloadRoute: 1 - } + }).cursor Meteor.publish 'file', (_id)-> check _id, String - return Collections.files.collection.find _id + return Collections.files.find({ + $or: [{ + _id: _id + 'meta.secured': false + },{ + _id: _id + 'meta.secured': true + userId: @userId + }] + }, { + fields: + _id: 1 + name: 1 + size: 1 + type: 1 + meta: 1 + isPDF: 1 + isText: 1 + isJSON: 1 + isVideo: 1 + isAudio: 1 + isImage: 1 + extension: 1 + _collectionName: 1 + _downloadRoute: 1 + }).cursor Meteor.methods - filesLenght: -> - return Collections.files.collection.find({ - $or: [ - {'meta.blamed': $lt: 3}, - {'meta.blamed': $exists: false} - ] - }).count() + filesLenght: (userOnly = false) -> + check userOnly, Boolean + if userOnly and @userId + selector = userId: @userId + else + selector = { + $or: [{ + 'meta.unlisted': false + 'meta.secured': false + 'meta.blamed': $lt: 3 + },{ + userId: @userId + }] + } + return Collections.files.find(selector).count() unblame: (_id) -> check _id, String - Collections.files.collection.update {_id}, {$inc: 'meta.blamed': -1}, _app.NOOP + Collections.files.update {_id}, {$inc: 'meta.blamed': -1}, _app.NOOP return true blame: (_id) -> check _id, String - Collections.files.collection.update {_id}, {$inc: 'meta.blamed': 1}, _app.NOOP - return true \ No newline at end of file + Collections.files.update {_id}, {$inc: 'meta.blamed': 1}, _app.NOOP + return true + + changeAccess: (_id) -> + check _id, String + if Meteor.userId() + file = Collections.files.findOne {_id, userId: Meteor.userId()} + if file + Collections.files.update _id, {$set: 'meta.unlisted': if file.meta.unlisted then false else true}, _app.NOOP + return true + throw new Meteor.Error 401, 'Access denied!' + + changePrivacy: (_id) -> + check _id, String + if Meteor.userId() + file = Collections.files.findOne {_id, userId: Meteor.userId()} + if file + Collections.files.update _id, {$set: 'meta.unlisted': true, 'meta.secured': if file.meta.secured then false else true}, _app.NOOP + return true + throw new Meteor.Error 401, 'Access denied!' \ No newline at end of file diff --git a/demo/package.json b/demo/package.json index cb8468dd..63c25755 100644 --- a/demo/package.json +++ b/demo/package.json @@ -1,6 +1,6 @@ { "name": "Meteor-Files-Demo", - "version": "1.5.6", + "version": "1.6.0", "description": "Demo application for ostrio:files package", "main": "main.js", "scripts": { @@ -31,14 +31,15 @@ "homepage": "https://github.com/VeliovGroup/Meteor-Files-Demo", "dependencies": { "connect": "^3.4.1", - "dropbox": "^0.10.3", - "fibers": "^1.0.8", + "dropbox": "0.10.3", + "knox": "^0.9.2", + "fibers": "^1.0.13", "fs-extra": "0.30.0", "http-proxy": "^1.13.2", "keypress": "^0.2.1", "meteor-deque": "*", "meteor-node-stubs": "~0.2.0", - "meteor-promise": "0.5.1", + "meteor-promise": "0.7.2", "mime": "^1.3.4", "mongodb": "^2.1.15", "progress": "^1.1.8", @@ -52,7 +53,7 @@ "request": "2.72.0" }, "engines": { - "node": "0.10.43", + "node": "0.10.45", "npm": "2.15.3" } } \ No newline at end of file diff --git a/demo/public/android-chrome-144x144.png b/demo/public/android-chrome-144x144.png index 69ee2408..69bc8511 100644 Binary files a/demo/public/android-chrome-144x144.png and b/demo/public/android-chrome-144x144.png differ diff --git a/demo/public/android-chrome-192x192.png b/demo/public/android-chrome-192x192.png index 79bb1c06..4b3b35b2 100644 Binary files a/demo/public/android-chrome-192x192.png and b/demo/public/android-chrome-192x192.png differ diff --git a/demo/public/android-chrome-36x36.png b/demo/public/android-chrome-36x36.png index 8864efa5..f6773b15 100644 Binary files a/demo/public/android-chrome-36x36.png and b/demo/public/android-chrome-36x36.png differ diff --git a/demo/public/android-chrome-48x48.png b/demo/public/android-chrome-48x48.png index 03033a69..b4617e38 100644 Binary files a/demo/public/android-chrome-48x48.png and b/demo/public/android-chrome-48x48.png differ diff --git a/demo/public/android-chrome-72x72.png b/demo/public/android-chrome-72x72.png index 0516aaca..f457cc48 100644 Binary files a/demo/public/android-chrome-72x72.png and b/demo/public/android-chrome-72x72.png differ diff --git a/demo/public/android-chrome-96x96.png b/demo/public/android-chrome-96x96.png index 8fb8d1fe..c57b4386 100644 Binary files a/demo/public/android-chrome-96x96.png and b/demo/public/android-chrome-96x96.png differ diff --git a/demo/public/apple-touch-icon-114x114.png b/demo/public/apple-touch-icon-114x114.png index 0ba9ed51..28364d46 100644 Binary files a/demo/public/apple-touch-icon-114x114.png and b/demo/public/apple-touch-icon-114x114.png differ diff --git a/demo/public/apple-touch-icon-120x120.png b/demo/public/apple-touch-icon-120x120.png index 1553d2f1..be388c6a 100644 Binary files a/demo/public/apple-touch-icon-120x120.png and b/demo/public/apple-touch-icon-120x120.png differ diff --git a/demo/public/apple-touch-icon-144x144.png b/demo/public/apple-touch-icon-144x144.png index 77b2aedc..77ec7e50 100644 Binary files a/demo/public/apple-touch-icon-144x144.png and b/demo/public/apple-touch-icon-144x144.png differ diff --git a/demo/public/apple-touch-icon-152x152.png b/demo/public/apple-touch-icon-152x152.png index 366514f3..da76f5c3 100644 Binary files a/demo/public/apple-touch-icon-152x152.png and b/demo/public/apple-touch-icon-152x152.png differ diff --git a/demo/public/apple-touch-icon-180x180.png b/demo/public/apple-touch-icon-180x180.png index 8268fafe..03f3eda9 100644 Binary files a/demo/public/apple-touch-icon-180x180.png and b/demo/public/apple-touch-icon-180x180.png differ diff --git a/demo/public/apple-touch-icon-57x57.png b/demo/public/apple-touch-icon-57x57.png index 7b24ca49..4c06be78 100644 Binary files a/demo/public/apple-touch-icon-57x57.png and b/demo/public/apple-touch-icon-57x57.png differ diff --git a/demo/public/apple-touch-icon-60x60.png b/demo/public/apple-touch-icon-60x60.png index 72b906bf..a9ea1f69 100644 Binary files a/demo/public/apple-touch-icon-60x60.png and b/demo/public/apple-touch-icon-60x60.png differ diff --git a/demo/public/apple-touch-icon-72x72.png b/demo/public/apple-touch-icon-72x72.png index 62836932..2d806046 100644 Binary files a/demo/public/apple-touch-icon-72x72.png and b/demo/public/apple-touch-icon-72x72.png differ diff --git a/demo/public/apple-touch-icon-76x76.png b/demo/public/apple-touch-icon-76x76.png index 71489885..e6cd117a 100644 Binary files a/demo/public/apple-touch-icon-76x76.png and b/demo/public/apple-touch-icon-76x76.png differ diff --git a/demo/public/apple-touch-icon-precomposed.png b/demo/public/apple-touch-icon-precomposed.png index 7d90871a..78910598 100644 Binary files a/demo/public/apple-touch-icon-precomposed.png and b/demo/public/apple-touch-icon-precomposed.png differ diff --git a/demo/public/apple-touch-icon.png b/demo/public/apple-touch-icon.png index e83b5768..ca7ecc4f 100644 Binary files a/demo/public/apple-touch-icon.png and b/demo/public/apple-touch-icon.png differ diff --git a/demo/public/browserconfig.xml b/demo/public/browserconfig.xml index 65380f38..bcbd6c9f 100644 --- a/demo/public/browserconfig.xml +++ b/demo/public/browserconfig.xml @@ -6,7 +6,7 @@ - #da532c + #2b5797 diff --git a/demo/public/dnd.png b/demo/public/dnd.png index 5aed0b26..8e0241cc 100644 Binary files a/demo/public/dnd.png and b/demo/public/dnd.png differ diff --git a/demo/public/favicon-16x16.png b/demo/public/favicon-16x16.png index e8ccb5a3..99b3605a 100644 Binary files a/demo/public/favicon-16x16.png and b/demo/public/favicon-16x16.png differ diff --git a/demo/public/favicon-194x194.png b/demo/public/favicon-194x194.png deleted file mode 100644 index 59528d52..00000000 Binary files a/demo/public/favicon-194x194.png and /dev/null differ diff --git a/demo/public/favicon-32x32.png b/demo/public/favicon-32x32.png index cd26260c..6b6048f8 100644 Binary files a/demo/public/favicon-32x32.png and b/demo/public/favicon-32x32.png differ diff --git a/demo/public/favicon-96x96.png b/demo/public/favicon-96x96.png index 23c34e5c..c10ace1b 100644 Binary files a/demo/public/favicon-96x96.png and b/demo/public/favicon-96x96.png differ diff --git a/demo/public/favicon.ico b/demo/public/favicon.ico index fd3c3882..95e7beef 100644 Binary files a/demo/public/favicon.ico and b/demo/public/favicon.ico differ diff --git a/demo/public/icon_1200x630.png b/demo/public/icon_1200x630.png new file mode 100644 index 00000000..50e435f7 Binary files /dev/null and b/demo/public/icon_1200x630.png differ diff --git a/demo/public/icon_750x560.png b/demo/public/icon_750x560.png new file mode 100644 index 00000000..20c2c7a4 Binary files /dev/null and b/demo/public/icon_750x560.png differ diff --git a/demo/public/logo-bg.png b/demo/public/logo-bg.png new file mode 100644 index 00000000..c1506425 Binary files /dev/null and b/demo/public/logo-bg.png differ diff --git a/demo/public/logo-bw.png b/demo/public/logo-bw.png index 0ac11227..c62ccdec 100644 Binary files a/demo/public/logo-bw.png and b/demo/public/logo-bw.png differ diff --git a/demo/public/manifest.json b/demo/public/manifest.json index e43b16b0..cd179cbc 100644 --- a/demo/public/manifest.json +++ b/demo/public/manifest.json @@ -1,5 +1,5 @@ { - "name": "File upload", + "name": "Meteor Files", "icons": [ { "src": "\/android-chrome-36x36.png", @@ -38,6 +38,7 @@ "density": 4 } ], + "start_url": "https:\/\/files.veliov.com", "display": "standalone", - "orientation": "landscape" + "orientation": "portrait" } diff --git a/demo/public/meteor-icon.svg b/demo/public/meteor-icon.svg new file mode 100644 index 00000000..39b015eb --- /dev/null +++ b/demo/public/meteor-icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demo/public/mstile-144x144.png b/demo/public/mstile-144x144.png index 80541f53..e75c27b1 100644 Binary files a/demo/public/mstile-144x144.png and b/demo/public/mstile-144x144.png differ diff --git a/demo/public/mstile-150x150.png b/demo/public/mstile-150x150.png index 1bdded8a..399ef026 100644 Binary files a/demo/public/mstile-150x150.png and b/demo/public/mstile-150x150.png differ diff --git a/demo/public/mstile-310x150.png b/demo/public/mstile-310x150.png index d53c3464..76da10d4 100644 Binary files a/demo/public/mstile-310x150.png and b/demo/public/mstile-310x150.png differ diff --git a/demo/public/mstile-310x310.png b/demo/public/mstile-310x310.png index 4fc188cd..8ddd149a 100644 Binary files a/demo/public/mstile-310x310.png and b/demo/public/mstile-310x310.png differ diff --git a/demo/public/mstile-70x70.png b/demo/public/mstile-70x70.png index 3529a156..d92014f2 100644 Binary files a/demo/public/mstile-70x70.png and b/demo/public/mstile-70x70.png differ diff --git a/demo/public/safari-pinned-tab.svg b/demo/public/safari-pinned-tab.svg index 40671c7e..b8d6879c 100644 --- a/demo/public/safari-pinned-tab.svg +++ b/demo/public/safari-pinned-tab.svg @@ -2,201 +2,52 @@ Created by potrace 1.11, written by Peter Selinger 2001-2013 - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + diff --git a/demo/server/image-processing.coffee b/demo/server/image-processing.coffee index 03d72046..f00fa9a8 100644 --- a/demo/server/image-processing.coffee +++ b/demo/server/image-processing.coffee @@ -20,7 +20,7 @@ _app.createThumbnails = (collection, fileRef, cb) -> sizes = preview: - width: 640 + width: 400 thumbnail40: width: 40 square: true diff --git a/demo/server/service-configurations.coffee b/demo/server/service-configurations.coffee new file mode 100644 index 00000000..ec008852 --- /dev/null +++ b/demo/server/service-configurations.coffee @@ -0,0 +1,46 @@ +_sc = {} +ServiceConfiguration.configurations.remove {} + +if process.env['ACCOUNTS_METEOR_ID'] and process.env['ACCOUNTS_METEOR_SEC'] + _sc.meteor = true + ServiceConfiguration.configurations.upsert + service: 'meteor-developer' + , + $set: + secret: process.env['ACCOUNTS_METEOR_SEC'] + clientId: process.env['ACCOUNTS_METEOR_ID'] + loginStyle: 'redirect' + +if process.env['ACCOUNTS_GITHUB_ID'] and process.env['ACCOUNTS_GITHUB_SEC'] + _sc.github = true + ServiceConfiguration.configurations.upsert + service: 'github' + , + $set: + secret: process.env['ACCOUNTS_GITHUB_SEC'] + clientId: process.env['ACCOUNTS_GITHUB_ID'] + loginStyle: 'redirect' + +if process.env['ACCOUNTS_TWITTER_ID'] and process.env['ACCOUNTS_TWITTER_SEC'] + _sc.twitter = true + ServiceConfiguration.configurations.upsert + service: 'twitter' + , + $set: + loginStyle: 'redirect' + secret: process.env['ACCOUNTS_TWITTER_SEC'] + consumerKey: process.env['ACCOUNTS_TWITTER_ID'] # consumerKey, really?! F*** this should be in docs + +if process.env['ACCOUNTS_FACEBOOK_ID'] and process.env['ACCOUNTS_FACEBOOK_SEC'] + _sc.facebook = true + ServiceConfiguration.configurations.upsert + service: 'facebook' + , + $set: + secret: process.env['ACCOUNTS_FACEBOOK_SEC'] + appId: process.env['ACCOUNTS_FACEBOOK_ID'] # appId, really?! F*** this should be in docs + loginStyle: 'redirect' + +Meteor.methods + 'getServiceConfiguration': -> + return _sc \ No newline at end of file diff --git a/demo/server/spierable.js b/demo/server/spierable.js new file mode 100644 index 00000000..ecd5b743 --- /dev/null +++ b/demo/server/spierable.js @@ -0,0 +1,5 @@ +// WebApp.connectHandlers.use(new Spiderable({ +// rootURL: 'https://files.veliov.com', +// serviceURL: 'https://render.ostr.io', +// auth: 'xxx:yyy' +// })); \ No newline at end of file diff --git a/docs/3rd-party-storage.md b/docs/3rd-party-storage.md new file mode 100644 index 00000000..920e8439 --- /dev/null +++ b/docs/3rd-party-storage.md @@ -0,0 +1,10 @@ +##### How to use third-party storage + +MeteorFiles (*MF*) package has very flexible API, so you're free to integrate it with any 3rd party storage. Basically any 3rd party storage with REST API or NodeJS SDK may be easily integrated. + +We made a next integration examples for you: + - [AWS S3 Bucket Integration](https://github.com/VeliovGroup/Meteor-Files/wiki/AWS-S3-Integration) + - [DropBox Integration](https://github.com/VeliovGroup/Meteor-Files/wiki/DropBox-Integration) + - [GridFS Integration](https://github.com/VeliovGroup/Meteor-Files/wiki/GridFS-Integration) + +*AWS S3* and *DropBox* is available in [demo app](https://github.com/VeliovGroup/Meteor-Files/tree/master/demo) out-of-the box \ No newline at end of file diff --git a/docs/FileCursor.md b/docs/FileCursor.md new file mode 100644 index 00000000..49c8f318 --- /dev/null +++ b/docs/FileCursor.md @@ -0,0 +1,16 @@ +##### FileCursor [*Anywhere*] + +FileCursor Class represents each record in `FilesCursor#each()` or document returned from `FilesCollection#findOne()` method. +All document's original properties is available directly by name, like: `FileCursor#propertyName` + +```js +var Images = new FilesCollection(); +var cursor = Images.findOne(); // <-- Returns FileCursor Instance +``` + +##### Methods: + - `remove(callback)` - {*undefined*} - Remove document. Callback has `error` argument + - `link()` - {*String*} - Returns downloadable URL to File + - `get(property)` - {*Object*|*mix*} - Returns current document as a plain Object, if `property` is specified - returns value of sub-object property + - `fetch()` - {*[Object]*}- Returns current document as plain Object in Array + - `with()` - {*FileCursor*} - Returns reactive version of current FileCursor, useful to use with `{{#with FileCursor#with}}...{{/with}}` block template helper \ No newline at end of file diff --git a/docs/FilesCursor.md b/docs/FilesCursor.md new file mode 100644 index 00000000..bb6c9ea1 --- /dev/null +++ b/docs/FilesCursor.md @@ -0,0 +1,30 @@ +##### FilesCursor [*Anywhere*] + +Implementation of Cursor for FilesCollection. Returned from `FilesCollection#find()`. + +```js +var Images = new FilesCollection(); +var cursor = Images.find(); // <-- Returns FilesCursor Instance +``` + +##### Methods: + - `get()` - {*[Object]*} - Returns all matching document(s) as an Array. Alias of `.fetch()` + - `hasNext()`- {*Boolean*} - Returns `true` if there is next item available on Cursor + - `next()` - {*Object*|*undefined*} - Returns next available object on Cursor + - `hasPrevious()` - {*Boolean*} - Returns `true` if there is previous item available on Cursor + - `previous()` - {*Object*|*undefined*} - Returns previous object on Cursor + - `fetch()` - {*[Object]*} - Returns all matching document(s) as an Array + - `first()` - {*Object*|*undefined*} - Returns first item on Cursor, if available + - `last()` - {*Object*|*undefined*} - Returns last item on Cursor, if available + - `count()` - {*Number*} - Returns the number of documents that match a query + - `remove(callback)` - {*undefined*} - Removes all documents that match a query. Callback has `error` argument + - `forEach(callback, context)` - {*undefined*} - Call `callback` once for each matching document, sequentially and synchronously. + * `callback` - {*Function*} - Function to call. It will be called with three arguments: the `file`, a 0-based index, and cursor itself + * `context` - {*Object*} - An object which will be the value of `this` inside `callback` + - `each()` - {*[FileCursor]*} - Returns an Array of `FileCursor` made for each document on current Cursor. Useful when using in `{{#each FilesCursor#each}}...{{/each}}` block template helper + - `map(callback, context)` - {*Array*} - Map `callback` over all matching documents. Returns an Array + * `callback` - {*Function*} - Function to call. It will be called with three arguments: the `file`, a 0-based index, and cursor itself + * `context` - {*Object*} - An object which will be the value of `this` inside `callback` + - `current()` - {*Object*|*undefined*} - Returns current item on Cursor, if available + - `observe(callbacks)` - {*Object*} - Functions to call to deliver the result set as it changes. Watch a query. Receive callbacks as the result set changes. Read more [here](http://docs.meteor.com/api/collections.html#Mongo-Cursor-observe) + - `observeChanges(callbacks)` - {*Object*} - Watch a query. Receive callbacks as the result set changes. Only the differences between the old and new documents are passed to the callbacks.. Read more [here](http://docs.meteor.com/api/collections.html#Mongo-Cursor-observeChanges) \ No newline at end of file diff --git a/docs/aws-s3-integration.md b/docs/aws-s3-integration.md new file mode 100644 index 00000000..f0c60be4 --- /dev/null +++ b/docs/aws-s3-integration.md @@ -0,0 +1,135 @@ +##### Use AWS:S3 As Storage + +Example below shows how to store and serve uploaded file via S3. This example also covers file removing from both your application and S3. + +See [real, production code](https://github.com/VeliovGroup/Meteor-Files/blob/master/demo/lib/files.collection.coffee) + +Prepare: install [knox](https://github.com/Automattic/knox): +```shell +npm install --save knox +``` +Or: +```shell +meteor npm install knox +``` + +Prepare: Get access to AWS S3: + - Go to http://aws.amazon.com/s3/ (*Sign(in|up) if required*) + - Click on [Create Bucket](https://console.aws.amazon.com/s3/home) + - Follow steps __1-3__ from [this docs](https://github.com/Lepozepo/S3#create-your-amazon-s3) + - Create new [CloudFront Distribution](https://console.aws.amazon.com/cloudfront/home) + * Select __Web__ as delivery method + * In __Origin Domain Name__ select your previously created S3 Bucket + * Click __Create Distribution__ + * After *Distribution* is *Deployed* pick __Domain Name__ for `cfdomain` + +```javascript +var knox, bound, client, Request, cfdomain, Collections = {}; + +if (Meteor.isServer) { + // Fix CloudFront certificate issue + // Read: https://github.com/chilts/awssum/issues/164 + process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0; + + knox = Npm.require('knox'); + Request = Npm.require('request'); + bound = Meteor.bindEnvironment(function(callback) { + return callback(); + }); + cfdomain = 'https://xxx.cloudfront.net'; // <-- Change to your Cloud Front Domain + client = knox.createClient({ + key: 'xxx', + secret: 'yyy', + bucket: 'zzz', + region: 'jjj' + }); +} + +Collections.files = new FilesCollection({ + debug: false, // Change to `true` for debugging + throttle: false, + storagePath: 'assets/app/uploads/uploadedFiles', + collectionName: 'uploadedFiles', + allowClientCode: false, + onAfterUpload: function(fileRef) { + // In onAfterUpload callback we will move file to AWS:S3 + var self = this; + _.each(fileRef.versions, function(vRef, version) { + // We use Random.id() instead of real file's _id + // to secure files from reverse engineering + // As after viewing this code it will be easy + // to get access to unlisted and protected files + var filePath = "files/" + (Random.id()) + "-" + version + "." + fileRef.extension; + client.putFile(vRef.path, filePath, function(error, res) { + bound(function() { + var upd; + if (error) { + console.error(error); + } else { + upd = { + $set: {} + }; + upd['$set']["versions." + version + ".meta.pipeFrom"] = cfdomain + '/' + filePath; + upd['$set']["versions." + version + ".meta.pipePath"] = filePath; + self.collection.update({ + _id: fileRef._id + }, upd, function(error) { + if (error) { + console.error(error); + } else { + // Unlink original files from FS + // after successful upload to AWS:S3 + self.unlink(self.collection.findOne(fileRef._id), version); + } + }); + } + }); + }); + }); + }, + interceptDownload: function(http, fileRef, version) { + var path, ref, ref1, ref2; + path = (ref = fileRef.versions) != null ? (ref1 = ref[version]) != null ? (ref2 = ref1.meta) != null ? ref2.pipeFrom : void 0 : void 0 : void 0; + if (path) { + // If file is moved to S3 + // We will pipe request to S3 + // So, original link will stay always secure + Request({ + url: path, + headers: _.pick(http.request.headers, 'range', 'accept-language', 'accept', 'cache-control', 'pragma', 'connection', 'upgrade-insecure-requests', 'user-agent') + }).pipe(http.response); + return true; + } else { + // While file is not yet uploaded to S3 + // We will serve file from FS + return false; + } + } +}); + +if (Meteor.isServer) { + // Intercept File's collection remove method + // to remove file from S3 + var _origRemove = Collections.files.remove; + + Collections.files.remove = function(search) { + var cursor = this.collection.find(search); + cursor.forEach(function(fileRef) { + _.each(fileRef.versions, function(vRef) { + var ref; + if (vRef != null ? (ref = vRef.meta) != null ? ref.pipePath : void 0 : void 0) { + client.deleteFile(vRef.meta.pipePath, function(error) { + bound(function() { + if (error) { + console.error(error); + } + }); + }); + } + }); + }); + // Call original method + _origRemove.call(this, search); + }; +} +``` \ No newline at end of file diff --git a/docs/constructor.md b/docs/constructor.md index a377dfd7..b80f0d62 100644 --- a/docs/constructor.md +++ b/docs/constructor.md @@ -41,7 +41,7 @@ - config.storagePath {String} + config.storagePath {String|Function} Server @@ -53,7 +53,30 @@ assets/app/uploads - Relative to running script + Relative to running script
+ If Function is passed it must return String, arguments: +
    +
  • + defaultPath - Default recommended path +
  • +
+ Context is current FilesCollction instance + + + + + config.collection {Mongo.Collection} + + + Isomorphic + + + Mongo.Collection Instance + + + + + You can pass your own Mongo Collection instance {collection: new Mongo.Collection('myFiles')} @@ -71,6 +94,23 @@ + + + config.continueUploadTTL {String} + + + Server + + + Time in seconds, during upload may be continued, default 3 hours (10800 seconds) + + + 10800 (3 hours) + + + If upload is not continued during this time, memory used for this upload will be freed. And uploaded chunks is removed. Server will no longer wait for upload, and if upload will be tied to be continued - Server will return 408 Error (Can't continue upload, session expired. Start upload again.) + + config.cacheControl {String} @@ -159,9 +199,13 @@ Function which returns String - Random.id() + false + + + Primary sets file name on `FS`
+ if namingFunction is not set
+ `FS`-name is equal to file's record `_id` - @@ -396,7 +440,7 @@ return false to abort or {String} to abort upload with message -

note: Because sending meta data as part of every chunk would hit the performance, meta is always empty ({}) except on the first chunk (chunkId=1) and on eof (eof=true)

+

note: Because sending meta data as part of every chunk would hit the performance, meta is always empty ({}) except on the first chunk (chunkId=1 or chunkId=-1) and on eof (eof=true or chunkId=-1) (Fixed. Since v1.6.0 full file object is available in onBeforeUpload callback)

@@ -465,6 +509,27 @@ Alternatively use: addListener('afterUpload', func) + + + config.onAfterRemove {Function} + + + Server + + + Callback, triggered after file(s) is removed from Collection
+ Arguments: +
    +
  • + files {[Object]} - Array of removed documents +
  • +
+ + + false + + + config.onbeforeunloadMessage {String|Function} @@ -657,7 +722,7 @@ Images.collection.attachSchema(new SimpleSchema(Images.schema)); *Deny insert/update/remove from client* ```javascript if (Meteor.isServer) { - var Images = new new FilesCollection({/* ... */}); + var Images = new FilesCollection({/* ... */}); Images.deny({ insert: function() { return true; @@ -679,7 +744,7 @@ if (Meteor.isServer) { *Allow insert/update/remove from client* ```javascript if (Meteor.isServer) { - var Images = new new FilesCollection({/* ... */}); + var Images = new FilesCollection({/* ... */}); Images.allow({ insert: function() { return true; @@ -699,7 +764,7 @@ if (Meteor.isServer) { #### Events listeners: ```javascript -var Images = new new FilesCollection({/* ... */}); +var Images = new FilesCollection({/* ... */}); // Alias addListener Images.on('afterUpload', function (fileRef) { /* ... */ diff --git a/docs/dropbox-integration.md b/docs/dropbox-integration.md new file mode 100644 index 00000000..991c7d9c --- /dev/null +++ b/docs/dropbox-integration.md @@ -0,0 +1,204 @@ +##### Use DropBox As Storage + +Example below shows how to store and serve uploaded file via DropBox. This example also covers file removing from both your application and DropBox. + +See [real, production code](https://github.com/VeliovGroup/Meteor-Files/blob/master/demo/lib/files.collection.coffee) + +Prepare: install [dropbox-js](https://github.com/dropbox/dropbox-js): +```shell +npm install --save dropbox@=0.10.3 +``` +Or: +```shell +meteor npm install dropbox@=0.10.3 +``` + +Prepare: Get access to DropBox API: + - Go to https://www.dropbox.com/developers (*Sign(in|up) if required*) + - Click on [Create your app](https://www.dropbox.com/developers/apps/create) + - Choose "*Dropbox API*" + - Choose "*App folder*" + - Type-in your application name + - Go to you application's *settings* + - Click on "*Enable additional users*" + - Obtain "*App key*" for `key` in `new Dropbox.Client({})` + - Obtain "*App secret*" for `secret` in `new Dropbox.Client({})` + - Obtain "*Generated access token*" (Click on "*Generate Access token*") for `token` in `new Dropbox.Client({})` + +```javascript +var Dropbox, Request, bound, client, fs, Collections = {}; + +if (Meteor.isServer) { + Dropbox = Npm.require('dropbox'); + Request = Npm.require('request'); + fs = Npm.require('fs'); + bound = Meteor.bindEnvironment(function(callback) { + return callback(); + }); + client = new Dropbox.Client({ + key: 'xxx', + secret: 'xxx', + token: 'xxxxxxxxxxxxxxxxxx' + }); +} + +Collections.files = new FilesCollection({ + debug: false, // Change to `true` for debugging + throttle: false, + storagePath: 'assets/app/uploads/uploadedFiles', + collectionName: 'uploadedFiles', + allowClientCode: false, + onAfterUpload: function(fileRef) { + // In onAfterUpload callback we will move file to DropBox + var self = this; + var makeUrl = function(stat, fileRef, version, triesUrl) { + if (triesUrl == null) { + triesUrl = 0; + } + client.makeUrl(stat.path, { + long: true, + downloadHack: true + }, function(error, xml) { + // Store downloadable link in file's meta object + bound(function() { + if (error) { + if (triesUrl < 10) { + Meteor.setTimeout(function() { + makeUrl(stat, fileRef, version, ++triesUrl); + }, 2048); + } else { + console.error(error, { + triesUrl: triesUrl + }); + } + } else if (xml) { + var upd = { + $set: {} + }; + upd['$set']["versions." + version + ".meta.pipeFrom"] = xml.url; + upd['$set']["versions." + version + ".meta.pipePath"] = stat.path; + self.collection.update({ + _id: fileRef._id + }, upd, function(error) { + if (error) { + console.error(error); + } else { + // Unlink original files from FS + // after successful upload to DropBox + self.unlink(self.collection.findOne(fileRef._id), version); + } + }); + } else { + if (triesUrl < 10) { + Meteor.setTimeout(function() { + makeUrl(stat, fileRef, version, ++triesUrl); + }, 2048); + } else { + console.error("client.makeUrl doesn't returns xml", { + triesUrl: triesUrl + }); + } + } + }); + }); + }; + + var writeToDB = function(fileRef, version, data, triesSend) { + // DropBox already uses random URLs + // No need to use random file names + if (triesSend == null) { + triesSend = 0; + } + client.writeFile(fileRef._id + "-" + version + "." + fileRef.extension, data, function(error, stat) { + bound(function() { + if (error) { + if (triesSend < 10) { + Meteor.setTimeout(function() { + writeToDB(fileRef, version, data, ++triesSend); + }, 2048); + } else { + console.error(error, { + triesSend: triesSend + }); + } + } else { + // Generate downloadable link + makeUrl(stat, fileRef, version); + } + }); + }); + }; + + var readFile = function(fileRef, vRef, version, triesRead) { + if (triesRead == null) { + triesRead = 0; + } + fs.readFile(vRef.path, function(error, data) { + bound(function() { + if (error) { + if (triesRead < 10) { + readFile(fileRef, vRef, version, ++triesRead); + } else { + console.error(error); + } + } else { + writeToDB(fileRef, version, data); + } + }); + }); + }; + + var sendToStorage = function(fileRef) { + _.each(fileRef.versions, function(vRef, version) { + readFile(fileRef, vRef, version); + }); + }; + + sendToStorage(fileRef); + }, + interceptDownload: function(http, fileRef, version) { + var path, ref, ref1, ref2; + path = (ref = fileRef.versions) != null ? (ref1 = ref[version]) != null ? (ref2 = ref1.meta) != null ? ref2.pipeFrom : void 0 : void 0 : void 0; + if (path) { + // If file is moved to DropBox + // We will pipe request to DropBox + // So, original link will stay always secure + Request({ + url: path, + headers: _.pick(http.request.headers, 'range', 'accept-language', 'accept', 'cache-control', 'pragma', 'connection', 'upgrade-insecure-requests', 'user-agent') + }).pipe(http.response); + return true; + } else { + // While file is not yet uploaded to DropBox + // We will serve file from FS + return false; + } + } +}); + +if (Meteor.isServer) { + // Intercept File's collection remove method + // to remove file from DropBox + var _origRemove = Collections.files.remove; + + Collections.files.remove = function(search) { + var cursor = this.collection.find(search); + cursor.forEach(function(fileRef) { + _.each(fileRef.versions, function(vRef) { + var ref; + if (vRef != null ? (ref = vRef.meta) != null ? ref.pipePath : void 0 : void 0) { + client.remove(vRef.meta.pipePath, function(error) { + bound(function() { + if (error) { + console.error(error); + } + }); + }); + } + }); + }); + // Call original method + _origRemove.call(this, search); + }; +} +``` \ No newline at end of file diff --git a/docs/find.md b/docs/find.md index 35bbfe60..bc3b1354 100644 --- a/docs/find.md +++ b/docs/find.md @@ -1,29 +1,42 @@ -##### `find(selector)` [*Isomorphic*] +##### `find(selector, options)` [*Isomorphic*] -Set cursor in FilesCollection. *This method doesn't returns file, it only sets cursor to files (if files exists)*. To get records from collection use `files.collection.find({}).fetch()`. To get cursor use `files.find({}).cursor` +Find and return Cursor for matching documents. - - `selector` {*Object*} - See [Mongo Selectors](http://docs.meteor.com/#selectors) - - Returns {*FilesCollection*} - Current FilesCollection instance + - `selector` {*String*|*Object*} - [Mongo-Style selector](http://docs.meteor.com/api/collections.html#selectors) + - `options` {*Object*} - [Mongo-Style selector Options](http://docs.meteor.com/api/collections.html#sortspecifiers) + - Returns {*[FilesCursor](https://github.com/VeliovGroup/Meteor-Files/wiki/FilesCursor)*} ```javascript var Images = new FilesCollection({collectionName: 'Images'}); // Usage: // Set cursor -Images.find({}); -// Get cursor -Images.find({}).cursor +var filesCursor = Images.find(); + +// Get Mongo cursor +Meteor.publish('images', function() { + Images.find().cursor; +}); + // Get cursor's data -Images.find({}).fetch(); +filesCursor.fetch(); // Get cursor's data (alternative) -Images.find({}).get(); -// Remove all cursor's records and associated files -Images.find({}).remove(); -// Remove all files and records -Images.remove(); +filesCursor.get(); -// Direct Collection usage -Images.collection.find({}) +// Remove all cursor's records and associated files +filesCursor.remove(function (error) { + if (error) { + console.error('File(s) is not removed!', error); + } +}); // Remove only Collection records from DB -Images.collection.remove({}) +Images.collection.remove(); + +// Each +filesCursor.each(function (file) { + // Only available in .each() + file.link(); + file.remove(); + file.with(); // <-- Reactive object +}); ``` \ No newline at end of file diff --git a/docs/findOne.md b/docs/findOne.md index e08e7d59..56c3055e 100644 --- a/docs/findOne.md +++ b/docs/findOne.md @@ -1,28 +1,36 @@ ##### `findOne(selector)` [*Isomorphic*] -Find one file in FilesCollection. *This method doesn't returns file, it only sets cursor to file (if file exists)*. To get record from collection use `files.collection.findOne({})`. +Finds the first document that matches the selector, as ordered by sort and skip options. - - `selector` {*Object*} - See [Mongo Selectors](http://docs.meteor.com/#selectors) - - Returns {*FilesCollection*} - Current FilesCollection instance + - `selector` {*String*|*Object*} - [Mongo-Style selector](http://docs.meteor.com/api/collections.html#selectors) + - `options` {*Object*} - [Mongo-Style selector Options](http://docs.meteor.com/api/collections.html#sortspecifiers) + - Returns {*[FileCollection](https://github.com/VeliovGroup/Meteor-Files/wiki/FileCursor)*} ```javascript var Images = new FilesCollection({collectionName: 'Images'}); // Usage: // Set cursor -Images.findOne({_id: 'Rfy2HLutYK4XWkwhm'}); +var file = Images.findOne({_id: 'Rfy2HLutYK4XWkwhm'}); // Generate downloadable link: -Images.findOne({_id: 'Rfy2HLutYK4XWkwhm'}).link() -// Get cursor's data -Images.findOne({_id: 'Rfy2HLutYK4XWkwhm'}).get(); +file.link(); +// Get cursor's data as plain Object +file.get(); +file.get('_id'); // <-- returns sub-property value, if exists +// Get cursor's data as reactive Object +file.with(); // Get cursor as array: -Images.findOne({_id: 'Rfy2HLutYK4XWkwhm'}).fetch() +file.fetch(); // Remove record from collection and file from FS -Images.findOne({_id: 'Rfy2HLutYK4XWkwhm'}).remove(); +file.remove(function (error) { + if (error) { + console.error('File wasn\'t removed', error); + } +}); // Direct Collection usage -Images.collection.findOne({_id: 'Rfy2HLutYK4XWkwhm'}) +Images.collection.findOne({_id: 'Rfy2HLutYK4XWkwhm'}); // Remove record from collection -Images.collection.remove({_id: 'Rfy2HLutYK4XWkwhm'}) +Images.collection.remove({_id: 'Rfy2HLutYK4XWkwhm'}); ``` \ No newline at end of file diff --git a/docs/gridfs-integration.md b/docs/gridfs-integration.md new file mode 100644 index 00000000..fdd1fb8b --- /dev/null +++ b/docs/gridfs-integration.md @@ -0,0 +1,174 @@ +##### Use GridFS as a storage + +Example below shows how to handle (store, serve, remove) uploaded files via GridFS. + +#### Preparation + +Firstly you need to install [gridfs-stream](https://github.com/aheckmann/gridfs-stream): +```shell +npm install --save gridfs-stream +``` +Or: +```shell +meteor npm install gridfs-stream +``` + +##### Create collection + +Create a `FilesCollection` instance: + +```javascript +import { Meteor } from 'meteor/meteor'; +import { FilesCollection } from 'meteor/ostrio:files'; + +export const Images = new FilesCollection({ + debug: false, // Change to `true` for debugging + collectionName: 'images', + allowClientCode: false, + onBeforeUpload(file) { + if (file.size <= 10485760 && /png|jpg|jpeg/i.test(file.extension)) return true; + return 'Please upload image, with size equal or less than 10MB'; + }, +}); + +if (Meteor.isServer) { + Images.denyClient(); +} +``` + +##### Get required packages and create up gfs instance + +Import and set up required variables: + +```javascript +import Grid from 'gridfs-stream'; // We'll use this package to work with GridFS +import fs from 'fs'; // Required to read files initially uploaded via Meteor-Files + +// Set up gfs instance +let gfs; +if (Meteor.isServer) { + const mongo = MongoInternals.NpmModules.mongodb.module; // eslint-disable-line no-undef + gfs = Grid(Meteor.users.rawDatabase(), mongo); +} +``` + +##### Store and serve files from GridFS + +Add `onAfterUpload` and `interceptDownload` hooks that would move file to GridFS once it's uploaded, and serve file from GridFS on request: + +```javascript + onAfterUpload(image) { + // Move file to GridFS + Object.keys(image.versions).forEach(versionName => { + const metadata = { versionName, imageId: image._id, storedAt: new Date() }; // Optional + const writeStream = gfs.createWriteStream({ filename: image.name, metadata }); + + fs.createReadStream(image.versions[versionName].path).pipe(writeStream); + + writeStream.on('close', Meteor.bindEnvironment(file => { + const property = `versions.${versionName}.meta.gridFsFileId`; + + // Convert ObjectID to String. Because Meteor (EJSON?) seems to convert it to a + // LocalCollection.ObjectID, which GFS doesn't understand. + this.collection.update(image._id, { $set: { [property]: file._id.toString() } }); + this.unlink(this.collection.findOne(image._id), versionName); // Unlink file by version from FS + })); + }); + }, + interceptDownload(http, image, versionName) { + const _id = (image.versions[versionName].meta || {}).gridFsFileId; + if (_id) { + const readStream = gfs.createReadStream({ _id }); + readStream.on('error', err => { throw err; }); + readStream.pipe(http.response); + } + return Boolean(_id); // Serve file from either GridFS or FS if it wasn't uploaded yet + } +``` + +##### Handle removing + +From now we can store/serve files to/from GridFS. But what will happen if we decide to +delete an image? An Image document will be deleted, but a GridFS record will stay in db forever! +That's not what we want, right? + +So let's fix this by adding `onAfterRemove` hook: + +```javascript + onAfterRemove(images) { + images.forEach(image => { + Object.keys(image.versions).forEach(versionName => { + const _id = (image.versions[versionName].meta || {}).gridFsFileId; + if (_id) gfs.remove({ _id }, err => { if (err) throw err; }); + }); + }); + } +``` + +##### Final result + +Here's a final code: + +```javascript +import { Meteor } from 'meteor/meteor'; +import { FilesCollection } from 'meteor/ostrio:files'; +import Grid from 'gridfs-stream'; +import fs from 'fs'; + +let gfs; +if (Meteor.isServer) { + const mongo = MongoInternals.NpmModules.mongodb.module; // eslint-disable-line no-undef + gfs = Grid(Meteor.users.rawDatabase(), mongo); +} + +export const Images = new FilesCollection({ + collectionName: 'images', + allowClientCode: false, + debug: Meteor.isServer && process.env.NODE_ENV === 'development', + onBeforeUpload(file) { + if (file.size <= 10485760 && /png|jpg|jpeg/i.test(file.extension)) return true; + return 'Please upload image, with size equal or less than 10MB'; + }, + onAfterUpload(image) { + // Move file to GridFS + Object.keys(image.versions).forEach(versionName => { + const metadata = { versionName, imageId: image._id, storedAt: new Date() }; // Optional + const writeStream = gfs.createWriteStream({ filename: image.name, metadata }); + + fs.createReadStream(image.versions[versionName].path).pipe(writeStream); + + writeStream.on('close', Meteor.bindEnvironment(file => { + const property = `versions.${versionName}.meta.gridFsFileId`; + + // If we store the ObjectID itself, Meteor (EJSON?) seems to convert it to a + // LocalCollection.ObjectID, which GFS doesn't understand. + this.collection.update(image._id, { $set: { [property]: file._id.toString() } }); + this.unlink(this.collection.findOne(image._id), versionName); // Unlink files from FS + })); + }); + }, + interceptDownload(http, image, versionName) { + // Serve file from GridFS + const _id = (image.versions[versionName].meta || {}).gridFsFileId; + if (_id) { + const readStream = gfs.createReadStream({ _id }); + readStream.on('error', err => { throw err; }); + readStream.pipe(http.response); + } + return Boolean(_id); // Serve file from either GridFS or FS if it wasn't uploaded yet + }, + onAfterRemove(images) { + // Remove corresponding file from GridFS + images.forEach(image => { + Object.keys(image.versions).forEach(versionName => { + const _id = (image.versions[versionName].meta || {}).gridFsFileId; + if (_id) gfs.remove({ _id }, err => { if (err) throw err; }); + }); + }); + } +}); + +if (Meteor.isServer) { + Images.denyClient(); +} +``` \ No newline at end of file diff --git a/docs/insert.md b/docs/insert.md index e2822311..91e9fa1e 100644 --- a/docs/insert.md +++ b/docs/insert.md @@ -265,7 +265,7 @@ - toggleUpload() {Function} + toggle() {Function} Toggle continue/pause if upload in the progress @@ -298,7 +298,7 @@ Current upload speed in bytes/second - To convert into speed, take a look on [filesize](https://github.com/avoidwork/filesize.js) package, usage: + To convert into speed, take a look on filesize package, usage: filesize(estimateSpeed, {bits: true}) + '/s'; diff --git a/docs/schema.md b/docs/schema.md index 07da6291..49dc36f9 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -33,8 +33,8 @@ var defaultSchema = { isJSON: { type: Boolean }, - _prefix: { - type: String + isPDF: { + type: Boolean }, extension: { type: String, @@ -64,9 +64,7 @@ var defaultSchema = { }, updatedAt: { type: Date, - autoValue: function() { - return new Date(); - } + optional: true }, versions: { type: Object, diff --git a/docs/third-party-storage.md b/docs/third-party-storage.md deleted file mode 100644 index 94d00360..00000000 --- a/docs/third-party-storage.md +++ /dev/null @@ -1,137 +0,0 @@ -##### How to use third-party storage - -Example below shows how to store and serve uploaded file via third-party storage (*DropBox in this case*). This example also covers file removing from both your application and DropBox. - -Prepare: install [dropbox-js](https://github.com/dropbox/dropbox-js): -```shell -npm install --save dropbox -``` -Or: -```shell -meteor npm install dropbox -``` - -Prepare: Get access to DropBox API: - - Go to https://www.dropbox.com/developers (*Sign(in|up) if required*) - - Click on [Create your app](https://www.dropbox.com/developers/apps/create) - - Choose "*Dropbox API*" - - Choose "*App folder*" - - Type-in your application name - - Go to you application's *settings* - - Click on "*Enable additional users*" - - Obtain "*App key*" for `key` in `new Dropbox.Client({})` - - Obtain "*App secret*" for `secret` in `new Dropbox.Client({})` - - Obtain "*Generated access token*" (Click on "*Generate Access token*") for `token` in `new Dropbox.Client({})` - -```javascript -var Dropbox, bound, client, fs, Collections = {}; - -if (Meteor.isServer) { - Dropbox = Npm.require('dropbox'); - fs = Npm.require('fs'); - bound = Meteor.bindEnvironment(function(callback) { - return callback(); - }); - client = new Dropbox.Client({ - key: 'xxx', - secret: 'xxx', - token: 'xxxxxxxxxxxxxxxxxx' - }); -} - -Collections.files = new FilesCollection({ - debug: false, // Change to `true` for debugging - throttle: false, - storagePath: 'assets/app/uploads/uploadedFiles', - collectionName: 'uploadedFiles', - allowClientCode: false, - onAfterUpload: function(fileRef) { - // In onAfterUpload callback we will move file to DropBox - var self; - self = this; - fs.readFile(fileRef.path, function(error, data) { - bound(function() { - if (error) { - console.error(error); - } else { - // Write file to DropBox - client.writeFile(fileRef._id + "." + fileRef.extension, data, function(error, stat) { - bound(function() { - if (error) { - console.error(error); - } else { - client.makeUrl(stat.path, { - long: true, - downloadHack: true // Used to get permanent link - }, function(error, xml) { - bound(function() { - // Store downloadable in file's meta object - self.collection.update({ - _id: fileRef._id - }, { - $set: { - 'meta.pipeFrom': xml.url, - 'meta.pipePath': stat.path - } - }, function(error) { - if (error) { - console.error(error); - } else { - // Remove file from FS - self.unlink(fileRef); - } - }); - }); - }); - } - }); - }); - } - }); - }); - }, - interceptDownload: function(http, fileRef, version) { - var path, ref; - path = fileRef != null ? (ref = fileRef.meta) != null ? ref.pipeFrom : void 0 : void 0; - if (path) { - // If file is moved to DropBox - // We will redirect browser to DropBox - http.response.writeHead(302, { - 'Location': path - }); - // Alternatively you can pipe request to DropBox - // like: `request.get(path).pipe(http.response)` - // But you need to handle all headers yourself - // This example just more simple - http.response.end(); - return true; - } else { - // While file is not uploaded to DropBox - // We will serve file from FS - return false; - } - } -}); - -if (Meteor.isServer) { - // Intercept File's collection remove method - // to remove file from DropBox - var _origRemove = Collections.files.remove; - Collections.files.remove = function(search) { - var cursor; - cursor = this.collection.find(search); - cursor.forEach(function(fileRef) { - var ref; - if (fileRef != null ? (ref = fileRef.meta) != null ? ref.pipePath : void 0 : void 0) { - client.remove(fileRef.meta.pipePath, function(error) { - if (error) { - console.error(error); - } - }); - } - }); - // Call original method - _origRemove.call(this, search); - }; -} -``` \ No newline at end of file diff --git a/docs/toc.md b/docs/toc.md index 9e4f4b2f..be2904ab 100644 --- a/docs/toc.md +++ b/docs/toc.md @@ -1,6 +1,20 @@ Meteor-Files ======== + + + + + + + +
+ + + This package is the GCAA Winner 2016. Big thanks to Benjamin Willems and Ryan Glover (The Chef) and all The Meteor Chef team! +
+ + ### About: - Event-driven API - Upload / Read files in Cordova app: __Cordva support__ (Any with support of `FileReader`) @@ -9,10 +23,11 @@ Meteor-Files * Ready for small and large files (RAM used only for chunk reading - [read about `chunkSize`](https://github.com/VeliovGroup/Meteor-Files/wiki/Insert-(Upload))) * Pause / Resume upload * Auto-pause when connection to server is interrupted - * Multi-stream async upload (faster than ever) + * Multi-stream async upload (*faster than ever*) - Use third-party storage: - * AWS + * [AWS S3](https://github.com/VeliovGroup/Meteor-Files/wiki/AWS-S3-Integration) * [DropBox](https://github.com/VeliovGroup/Meteor-Files/wiki/Third-party-storage) + * [GridFS](https://github.com/VeliovGroup/Meteor-Files/wiki/GridFS-Integration) * Google Drive * Google Storage * any other with JS/REST API @@ -35,19 +50,29 @@ Meteor-Files * Download is ready for small and large files, with support of progressive (`chunked`) download - Store wherever you like * You may use `Meteor-Files` as temporary storage - * After file is uploaded and stored on FS you able to `mv` or `cp` its content + * After file is uploaded and stored on FS you able to `mv` or `cp` its content, see [AWS S3](https://github.com/VeliovGroup/Meteor-Files/wiki/AWS-S3-Integration) and [DropBox](https://github.com/VeliovGroup/Meteor-Files/wiki/Third-party-storage) integration - Support of non-latin (non-Roman) file names - Subscribe on files (*collections*) you need ### ToC: ##### API: - [`FilesCollection` Constructor](https://github.com/VeliovGroup/Meteor-Files/wiki/Constructor) [*Isomorphic*] - Initialize FilesCollection + * [SimpleSchema Integration](https://github.com/VeliovGroup/Meteor-Files/wiki/Constructor#attach-schema-isomorphic) + * [Collection `deny` rules](https://github.com/VeliovGroup/Meteor-Files/wiki/Constructor#deny-collection-interaction-on-client-server) + * [Collection `allow` rules](https://github.com/VeliovGroup/Meteor-Files/wiki/Constructor#allow-collection-interaction-on-client-server) + * [Control upload access](https://github.com/VeliovGroup/Meteor-Files/wiki/Constructor#use-onbeforeupload-to-avoid-unauthorized-upload) + * [Control remove access](https://github.com/VeliovGroup/Meteor-Files/wiki/Constructor#use-onbeforeremove-to-avoid-unauthorized-remove) + - [Default Collection Schema](https://github.com/VeliovGroup/Meteor-Files/wiki/Schema) + * [Attach SimpleSchema and Collection2](https://github.com/VeliovGroup/Meteor-Files/wiki/Schema#attach-schema-recommended) + * [Extend Schema](https://github.com/VeliovGroup/Meteor-Files/wiki/Schema#extend-default-schema) + * [Override Schema](https://github.com/VeliovGroup/Meteor-Files/wiki/Schema#pass-your-own-schema-not-recommended) - [`write()`](https://github.com/VeliovGroup/Meteor-Files/wiki/Write) [*Server*] - Write `Buffer` to FS and FilesCollection - [`load()`](https://github.com/VeliovGroup/Meteor-Files/wiki/Load) [*Server*] - Write file to FS and FilesCollection from remote URL - [`addFile()`](https://github.com/VeliovGroup/Meteor-Files/wiki/addFile) [*Server*] - Add local file to FilesCollection from FS - [`findOne()`](https://github.com/VeliovGroup/Meteor-Files/wiki/findOne) [*Isomorphic*] - Find one file in FilesCollection - [`find()`](https://github.com/VeliovGroup/Meteor-Files/wiki/find) [*Isomorphic*] - Create cursor for FilesCollection - [`insert()`](https://github.com/VeliovGroup/Meteor-Files/wiki/Insert-(Upload)) [*Client*] - Upload file to server + * [`FileUpload.pipe()`](https://github.com/VeliovGroup/Meteor-Files/wiki/Insert-(Upload)#piping) - [`remove()`](https://github.com/VeliovGroup/Meteor-Files/wiki/remove) [*Isomorphic*] - Remove files from FilesCollection and unlink from FS - [`unlink()`](https://github.com/VeliovGroup/Meteor-Files/wiki/unlink) [*Server*] - Unlink file from FS - [`link()`](https://github.com/VeliovGroup/Meteor-Files/wiki/link) [*Isomorphic*] - Generate downloadable link @@ -55,6 +80,9 @@ Meteor-Files - [Template helper `fileURL`](https://github.com/VeliovGroup/Meteor-Files/wiki/Template-Helper) [*Client*] - Generate downloadable link in template ##### Examples: - - [Third-party storage (DropBox example)](https://github.com/VeliovGroup/Meteor-Files/wiki/Third-party-storage) + - [Third-party storage (AWS S3 & DropBox)](https://github.com/VeliovGroup/Meteor-Files/wiki/Third-party-storage) - [Resize, create thumbnail for uploaded image](https://github.com/VeliovGroup/Meteor-Files/blob/master/demo/server/image-processing.coffee) - - [File subversions](https://github.com/VeliovGroup/Meteor-Files/wiki/Create-and-Manage-Subversions) - Create video file with preview and multiple formats \ No newline at end of file + - [File subversions](https://github.com/VeliovGroup/Meteor-Files/wiki/Create-and-Manage-Subversions) - Create video file with preview and multiple formats + +##### Articles: + - [MongoDB with Replica Set & OpLog setup](https://veliovgroup.com/article/2qsjtNf8NSB9XxZDh/mongodb-replica-set-with-oplog) - Find out how to speed-up *reactivity* in you Meteor application \ No newline at end of file diff --git a/files.coffee b/files.coffee index 52da029b..a2654bb1 100755 --- a/files.coffee +++ b/files.coffee @@ -4,12 +4,12 @@ if Meteor.isServer ### @summary Require NPM packages ### - fs = Npm.require 'fs-extra' - events = Npm.require 'events' - request = Npm.require 'request' - Throttle = Npm.require 'throttle' - fileType = Npm.require 'file-type' - nodePath = Npm.require 'path' + fs = Npm.require 'fs-extra' + events = Npm.require 'events' + request = Npm.require 'request' + Throttle = Npm.require 'throttle' + fileType = Npm.require 'file-type' + nodePath = Npm.require 'path' ### @var {Object} bound - Meteor.bindEnvironment (Fiber wrapper) @@ -17,9 +17,389 @@ if Meteor.isServer bound = Meteor.bindEnvironment (callback) -> return callback() ### - @var {Function} sortNumber - Natural Number sort + @private + @locus Server + @class writeStream + @param path {String} - Path to file on FS + @param maxLength {Number} - Max amount of chunks in stream + @param file {Object} - fileRef Object + @summary writableStream wrapper class, makes sure chunks is written in given order + ### + class writeStream + constructor: (@path, @maxLength, @file) -> + self = @ + @stream = fs.createWriteStream @path, {flags: 'a', mode: self.permissions, highWaterMark: 0} + @drained = true + @writtenChunks = 0 + + @stream.on 'drain', -> bound -> + ++self.writtenChunks + self.drained = true + return + + @stream.on 'error', (error) -> bound -> + return + + ### + @memberOf writeStream + @name write + @param {Number} num - Chunk position in stream + @param {Buffer} chunk - Chunk binary data + @param {Function} callback - Callback + @summary Write chunk in given order + @returns {Boolean} - True if chunk is sent to stream, false if chunk is set into queue + ### + write: (num, chunk, callback) -> + if not @stream._writableState.ended and num > @writtenChunks + if @drained and num is (@writtenChunks + 1) + @drained = false + @stream.write chunk, callback + return true + else + self = @ + Meteor.setTimeout -> + self.write num, chunk + , 25 + return false + + ### + @memberOf writeStream + @name end + @param {Function} callback - Callback + @summary Write chunk in given order + @returns {Boolean} - True if stream is fulfilled, false if queue is in progress + ### + end: (callback) -> + unless @stream._writableState.ended + if @writtenChunks is @maxLength + @stream.end callback + return true + else + self = @ + Meteor.setTimeout -> + self.end callback + , 25 + return false + +### +@private +@locus Anywhere +@class FileCursor +@param _fileRef {Object} - Mongo-Style selector (http://docs.meteor.com/api/collections.html#selectors) +@param _collection {FilesCollection} - FilesCollection Instance +@summary Internal class, represents each record in `FilesCursor.each()` or document returned from `.findOne()` method +### +class FileCursor + constructor: (@_fileRef, @_collection) -> + self = @ + self = _.extend self, @_fileRef + + ### + @locus Anywhere + @memberOf FileCursor + @name remove + @param callback {Function} - Triggered asynchronously after item is removed or failed to be removed + @summary Remove document + @returns {FileCursor} + ### + remove: (callback) -> + console.info '[FilesCollection] [FileCursor] [remove()]' if @_collection.debug + if @_fileRef then @_collection.remove(@_fileRef._id, callback) else callback new Meteor.Error 404, 'No such file' + return @ + + ### + @locus Anywhere + @memberOf FileCursor + @name link + @param version {String} - Name of file's subversion + @summary Returns downloadable URL to File + @returns {String} + ### + link: (version) -> + console.info "[FilesCollection] [FileCursor] [link(#{version})]" if @_collection.debug + return if @_fileRef then @_collection.link(@_fileRef, version) else '' + + ### + @locus Anywhere + @memberOf FileCursor + @name get + @param property {String} - Name of sub-object property + @summary Returns current document as a plain Object, if `property` is specified - returns value of sub-object property + @returns {Object|mix} + ### + get: (property) -> + console.info "[FilesCollection] [FileCursor] [get(#{property})]" if @_collection.debug + if property + return @_fileRef[property] + else + return @_fileRef + + ### + @locus Anywhere + @memberOf FileCursor + @name fetch + @summary Returns document as plain Object in Array + @returns {[Object]} ### - sortNumber = (a, b) -> return a - b + fetch: -> + console.info '[FilesCollection] [FileCursor] [fetch()]' if @_collection.debug + return [@_fileRef] + + ### + @locus Anywhere + @memberOf FileCursor + @name with + @summary Returns reactive version of current FileCursor, useful to use with `{{#with}}...{{/with}}` block template helper + @returns {[Object]} + ### + with: -> + console.info '[FilesCollection] [FileCursor] [with()]' if @_collection.debug + self = @ + return _.extend self, @_collection.collection.findOne @_fileRef._id + +### +@private +@locus Anywhere +@class FilesCursor +@param _selector {String|Object} - Mongo-Style selector (http://docs.meteor.com/api/collections.html#selectors) +@param options {Object} - Mongo-Style selector Options (http://docs.meteor.com/api/collections.html#selectors) +@param _collection {FilesCollection} - FilesCollection Instance +@summary Implementation of Cursor for FilesCollection +### +class FilesCursor + constructor: (@_selector = {}, options, @_collection) -> + @_current = -1 + @cursor = @_collection.collection.find @_selector, options + + ### + @locus Anywhere + @memberOf FilesCursor + @name get + @summary Returns all matching document(s) as an Array. Alias of `.fetch()` + @returns {[Object]} + ### + get: -> + console.info "[FilesCollection] [FilesCursor] [get()]" if @_collection.debug + return @cursor.fetch() + + ### + @locus Anywhere + @memberOf FilesCursor + @name hasNext + @summary Returns `true` if there is next item available on Cursor + @returns {Boolean} + ### + hasNext: -> + console.info '[FilesCollection] [FilesCursor] [hasNext()]' if @_collection.debug + return @_current < @cursor.count() - 1 + + ### + @locus Anywhere + @memberOf FilesCursor + @name next + @summary Returns next item on Cursor, if available + @returns {Object|undefined} + ### + next: -> + console.info '[FilesCollection] [FilesCursor] [next()]' if @_collection.debug + if @hasNext() + return @cursor.fetch()[++@_current] + + ### + @locus Anywhere + @memberOf FilesCursor + @name hasPrevious + @summary Returns `true` if there is previous item available on Cursor + @returns {Boolean} + ### + hasPrevious: -> + console.info '[FilesCollection] [FilesCursor] [hasPrevious()]' if @_collection.debug + return @_current isnt -1 + + ### + @locus Anywhere + @memberOf FilesCursor + @name previous + @summary Returns previous item on Cursor, if available + @returns {Object|undefined} + ### + previous: -> + console.info '[FilesCollection] [FilesCursor] [previous()]' if @_collection.debug + if @hasPrevious() + return @cursor.fetch()[--@_current] + + ### + @locus Anywhere + @memberOf FilesCursor + @name fetch + @summary Returns all matching document(s) as an Array. + @returns {[Object]} + ### + fetch: -> + console.info '[FilesCollection] [FilesCursor] [fetch()]' if @_collection.debug + return @cursor.fetch() + + ### + @locus Anywhere + @memberOf FilesCursor + @name first + @summary Returns first item on Cursor, if available + @returns {Object|undefined} + ### + first: -> + console.info '[FilesCollection] [FilesCursor] [first()]' if @_collection.debug + @_current = 0 + return @fetch()?[@_current] + + ### + @locus Anywhere + @memberOf FilesCursor + @name last + @summary Returns last item on Cursor, if available + @returns {Object|undefined} + ### + last: -> + console.info '[FilesCollection] [FilesCursor] [last()]' if @_collection.debug + @_current = @count() - 1 + return @fetch()?[@_current] + + ### + @locus Anywhere + @memberOf FilesCursor + @name count + @summary Returns the number of documents that match a query + @returns {Number} + ### + count: -> + console.info '[FilesCollection] [FilesCursor] [count()]' if @_collection.debug + return @cursor.count() + + ### + @locus Anywhere + @memberOf FilesCursor + @name remove + @param callback {Function} - Triggered asynchronously after item is removed or failed to be removed + @summary Removes all documents that match a query + @returns {FilesCursor} + ### + remove: (callback) -> + console.info '[FilesCollection] [FilesCursor] [remove()]' if @_collection.debug + @_collection.remove @_selector, callback, @ + return @ + + ### + @locus Anywhere + @memberOf FilesCursor + @name forEach + @param callback {Function} - Function to call. It will be called with three arguments: the `file`, a 0-based index, and cursor itself + @param context {Object} - An object which will be the value of `this` inside `callback` + @summary Call `callback` once for each matching document, sequentially and synchronously. + @returns {undefined} + ### + forEach: (callback, context = {}) -> + console.info '[FilesCollection] [FilesCursor] [forEach()]' if @_collection.debug + @cursor.forEach callback, context + return + + ### + @locus Anywhere + @memberOf FilesCursor + @name each + @summary Returns an Array of FileCursor made for each document on current cursor + Useful when using in {{#each FilesCursor#each}}...{{/each}} block template helper + @returns {[FileCursor]} + ### + each: -> + self = @ + return @map (file) -> + return new FileCursor file, self._collection + + ### + @locus Anywhere + @memberOf FilesCursor + @name map + @param callback {Function} - Function to call. It will be called with three arguments: the `file`, a 0-based index, and cursor itself + @param context {Object} - An object which will be the value of `this` inside `callback` + @summary Map `callback` over all matching documents. Returns an Array. + @returns {Array} + ### + map: (callback, context = {}) -> + console.info '[FilesCollection] [FilesCursor] [map()]' if @_collection.debug + return @cursor.map callback, context + + ### + @locus Anywhere + @memberOf FilesCursor + @name current + @summary Returns current item on Cursor, if available + @returns {Object|undefined} + ### + current: -> + console.info '[FilesCollection] [FilesCursor] [current()]' if @_collection.debug + @_current = 0 if @_current < 0 + return @fetch()[@_current] + + ### + @locus Anywhere + @memberOf FilesCursor + @name observe + @param callbacks {Object} - Functions to call to deliver the result set as it changes + @summary Watch a query. Receive callbacks as the result set changes. + @url http://docs.meteor.com/api/collections.html#Mongo-Cursor-observe + @returns {Object} - live query handle + ### + observe: (callbacks) -> + console.info '[FilesCollection] [FilesCursor] [observe()]' if @_collection.debug + return @cursor.observe callbacks + + ### + @locus Anywhere + @memberOf FilesCursor + @name observeChanges + @param callbacks {Object} - Functions to call to deliver the result set as it changes + @summary Watch a query. Receive callbacks as the result set changes. Only the differences between the old and new documents are passed to the callbacks. + @url http://docs.meteor.com/api/collections.html#Mongo-Cursor-observeChanges + @returns {Object} - live query handle + ### + observeChanges: (callbacks) -> + console.info '[FilesCollection] [FilesCursor] [observeChanges()]' if @_collection.debug + return @cursor.observeChanges callbacks + +### +@var {Function} fixJSONParse - Fix issue with Date parse +### +fixJSONParse = (obj) -> + for key, value of obj + if _.isString(value) and !!~value.indexOf '=--JSON-DATE--=' + value = value.replace '=--JSON-DATE--=', '' + obj[key] = new Date parseInt value + else if _.isObject value + obj[key] = fixJSONParse value + else if _.isArray value + for v, i in value + if _.isObject(v) + obj[key][i] = fixJSONParse v + else if _.isString(v) and !!~v.indexOf '=--JSON-DATE--=' + v = v.replace '=--JSON-DATE--=', '' + obj[key][i] = new Date parseInt v + return obj + +### +@var {Function} fixJSONStringify - Fix issue with Date stringify +### +fixJSONStringify = (obj) -> + for key, value of obj + if _.isDate value + obj[key] = '=--JSON-DATE--=' + (+value) + else if _.isObject value + obj[key] = fixJSONStringify value + else if _.isArray value + for v, i in value + if _.isObject(v) + obj[key][i] = fixJSONStringify v + else if _.isDate v + obj[key][i] = '=--JSON-DATE--=' + (+v) + return obj ### @locus Anywhere @@ -37,14 +417,17 @@ if Meteor.isServer @param config.chunkSize {Number} - [Both] Upload chunk size, default: 524288 bytes (0,5 Mb) @param config.permissions {Number} - [Server] Permissions which will be set to uploaded files (octal), like: `511` or `0o755`. Default: 0644 @param config.parentDirPermissions {Number} - [Server] Permissions which will be set to parent directory of uploaded files (octal), like: `611` or `0o777`. Default: 0755 -@param config.storagePath {String} - [Server] Storage path on file system +@param config.storagePath {String|Function} - [Server] Storage path on file system @param config.cacheControl {String} - [Server] Default `Cache-Control` header @param config.throttle {Number} - [Server] bps throttle threshold @param config.downloadRoute {String} - [Both] Server Route used to retrieve files +@param config.collection {Mongo.Collection} - [Both] Mongo Collection Instance @param config.collectionName {String} - [Both] Collection name @param config.namingFunction {Function}- [Both] Function which returns `String` @param config.integrityCheck {Boolean} - [Server] Check file's integrity before serving to users @param config.onAfterUpload {Function}- [Server] Called right after file is ready on FS. Use to transfer file somewhere else, or do other thing with file directly +@param config.onAfterRemove {Function} - [Server] Called right after file is removed. Removed objects is passed to callback +@param config.continueUploadTTL {Number} - [Server] Time in seconds, during upload may be continued, default 3 hours (10800 seconds) @param config.onBeforeUpload {Function}- [Both] 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 @@ -62,77 +445,107 @@ class FilesCollection events.EventEmitter.call @ else EventEmitter.call @ - {@storagePath, @collectionName, @downloadRoute, @schema, @chunkSize, @namingFunction, @debug, @onbeforeunloadMessage, @permissions, @parentDirPermissions, @allowClientCode, @onBeforeUpload, @integrityCheck, @protected, @public, @strict, @downloadCallback, @cacheControl, @throttle, @onAfterUpload, @interceptDownload, @onBeforeRemove} = config if config - - self = @ - cookie = new Cookies() - @debug ?= false - @public ?= false - @protected ?= false - @chunkSize ?= 1024*512 - @chunkSize = Math.floor(@chunkSize / 8) * 8 + {storagePath, @collection, @collectionName, @downloadRoute, @schema, @chunkSize, @namingFunction, @debug, @onbeforeunloadMessage, @permissions, @parentDirPermissions, @allowClientCode, @onBeforeUpload, @integrityCheck, @protected, @public, @strict, @downloadCallback, @cacheControl, @throttle, @onAfterUpload, @onAfterRemove, @interceptDownload, @onBeforeRemove, @continueUploadTTL} = config if config + + self = @ + cookie = new Cookies() + @debug ?= false + @public ?= false + @protected ?= false + @chunkSize ?= 1024*512 + @chunkSize = Math.floor(@chunkSize / 8) * 8 + if @public and not @downloadRoute - throw new Meteor.Error 500, "[FilesCollection.#{@collectionName}]: \"downloadRoute\" must be explicitly provided on \"public\" collections! Note: \"downloadRoute\" must be equal on be inside of your web/proxy-server (relative) root." - @downloadRoute ?= '/cdn/storage' - @downloadRoute = @downloadRoute.replace /\/$/, '' - @collectionName ?= 'MeteorUploadFiles' - @namingFunction ?= false - @onBeforeUpload ?= false - @allowClientCode ?= true - @interceptDownload?= false + throw new Meteor.Error 500, "[FilesCollection.#{@collectionName}]: \"downloadRoute\" must be precisely provided on \"public\" collections! Note: \"downloadRoute\" must be equal on be inside of your web/proxy-server (relative) root." + + @collection ?= new Mongo.Collection @collectionName + @collectionName ?= @collection._name + check @collectionName, String + @downloadRoute ?= '/cdn/storage' + @downloadRoute = @downloadRoute.replace /\/$/, '' + @collectionName ?= 'MeteorUploadFiles' + @namingFunction ?= false + @onBeforeUpload ?= false + @allowClientCode ?= true + @interceptDownload ?= false if Meteor.isClient @onbeforeunloadMessage ?= 'Upload in a progress... Do you want to abort?' delete @strict delete @throttle - delete @storagePath delete @permissions delete @parentDirPermissions delete @cacheControl delete @onAfterUpload + delete @onAfterRemove + delete @onBeforeRemove delete @integrityCheck delete @downloadCallback delete @interceptDownload - delete @onBeforeRemove + delete @continueUploadTTL - if @protected - localStorageSupport = do -> - try - support = "localStorage" of window and window.localStorage isnt null - if support - window.localStorage.setItem '___test___', 'test' - window.localStorage.removeItem '___test___' - return true - else - return false - catch - return false + if _.has(Package, 'accounts-base') and Accounts + setTokenCookie = -> + if (not cookie.has('meteor_login_token') and Accounts._lastLoginTokenWhenPolled) or (cookie.has('meteor_login_token') and (cookie.get('meteor_login_token') isnt Accounts._lastLoginTokenWhenPolled)) + cookie.set 'meteor_login_token', Accounts._lastLoginTokenWhenPolled, null, '/' + cookie.send() + + unsetTokenCookie = -> + if cookie.has 'meteor_login_token' + cookie.remove 'meteor_login_token' + cookie.send() + + Accounts.onLogin -> + setTokenCookie() + return + Accounts.onLogout -> + unsetTokenCookie() + return - if localStorageSupport - if not cookie.has('meteor_login_token') and window.localStorage.getItem('Meteor.loginToken') - cookie.set 'meteor_login_token', window.localStorage.getItem('Meteor.loginToken'), null, '/' + if Accounts._lastLoginTokenWhenPolled + setTokenCookie() + else + unsetTokenCookie() check @onbeforeunloadMessage, Match.OneOf String, Function else - @_writableStreams ?= {} - @strict ?= true - @throttle ?= false - @permissions ?= parseInt('644', 8) + @strict ?= true + @throttle ?= false + @permissions ?= parseInt('644', 8) @parentDirPermissions ?= parseInt('755', 8) - @cacheControl ?= 'public, max-age=31536000, s-maxage=31536000' - @onBeforeRemove ?= false - @onAfterUpload ?= false - @integrityCheck ?= true - @downloadCallback ?= false - if @public and not @storagePath + @cacheControl ?= 'public, max-age=31536000, s-maxage=31536000' + @onAfterUpload ?= false + @onAfterRemove ?= false + @onBeforeRemove ?= false + @integrityCheck ?= true + @_currentUploads ?= {} + @downloadCallback ?= false + @continueUploadTTL ?= 10800 + + if @public and not storagePath throw new Meteor.Error 500, "[FilesCollection.#{@collectionName}] \"storagePath\" must be set on \"public\" collections! Note: \"storagePath\" must be equal on be inside of your web/proxy-server (absolute) root." - @storagePath ?= "assets/app/uploads/#{@collectionName}" - @storagePath = @storagePath.replace /\/$/, '' - @storagePath = nodePath.normalize @storagePath + + storagePath ?= "assets/app/uploads/#{@collectionName}" + Object.defineProperty self, 'storagePath', { + get: -> + sp = '' + if _.isString storagePath + sp = storagePath + else if _.isFunction storagePath + sp = storagePath.call self, "assets/app/uploads/#{self.collectionName}" + + unless _.isString sp + throw new Meteor.Error 400, "[FilesCollection.#{self.collectionName}] \"storagePath\" function must return a String!" + + sp = sp.replace /\/$/, '' + return nodePath.normalize sp + } + + console.info('[FilesCollection.storagePath] Set to:', @storagePath) if @debug fs.mkdirs @storagePath, {mode: @parentDirPermissions}, (error) -> if error - throw new Meteor.Error 401, "[FilesCollection.#{self.collectionName}] Path #{self.storagePath} is not writable!", error + throw new Meteor.Error 401, "[FilesCollection.#{self.collectionName}] Path \"#{self.storagePath}\" is not writable!", error return check @strict, Boolean @@ -140,11 +553,44 @@ class FilesCollection check @permissions, Number check @storagePath, String check @cacheControl, String + check @onAfterRemove, Match.OneOf false, Function check @onAfterUpload, Match.OneOf false, Function check @integrityCheck, Boolean check @onBeforeRemove, Match.OneOf false, Function check @downloadCallback, Match.OneOf false, Function check @interceptDownload, Match.OneOf false, Function + check @continueUploadTTL, Number + + @_preCollection = new Mongo.Collection '__pre_' + @collectionName + @_preCollection._ensureIndex {'createdAt': 1}, {expireAfterSeconds: @continueUploadTTL, background: true} + _preCollectionCursor = @_preCollection.find {} + _preCollectionCursor.observeChanges removed: (_id) -> + # Free memory after upload is done + # Or if upload is unfinished + console.info "[FilesCollection] [_preCollectionCursor.observeChanges] [removed]: #{_id}" if self.debug + if self._currentUploads?[_id] + self._currentUploads[_id].end() + delete self._currentUploads[_id] + return + + @_createStream = (_id, path, opts) -> + self._currentUploads[_id] = new writeStream path, opts.fileLength, opts + return self._currentUploads[_id] + + # This little function allows to continue upload + # even after server is restarted (*not on dev-stage*) + @_continueUpload = (_id) -> + if self._currentUploads?[_id]?.file + unless self._currentUploads[_id].stream._writableState.ended + return self._currentUploads[_id].file + else + self._createStream _id, self._currentUploads[_id].file.file.path, self._currentUploads[_id].file + return self._currentUploads[_id].file + else + contUpld = self._preCollection.findOne {_id} + if contUpld + self._createStream _id, contUpld.file.path, contUpld.file + return contUpld if not @schema @schema = @@ -157,7 +603,7 @@ class FilesCollection isImage: type: Boolean isText: type: Boolean isJSON: type: Boolean - _prefix: type: String + isPDF: type: Boolean extension: type: String optional: true @@ -176,7 +622,7 @@ class FilesCollection optional: true updatedAt: type: Date - autoValue: -> new Date() + optional: true versions: type: Object blackbox: true @@ -187,29 +633,25 @@ class FilesCollection check @protected, Match.OneOf Boolean, Function check @chunkSize, Number check @downloadRoute, String - check @collectionName, String check @namingFunction, Match.OneOf false, Function check @onBeforeUpload, Match.OneOf false, Function check @allowClientCode, Boolean if @public and @protected throw new Meteor.Error 500, "[FilesCollection.#{@collectionName}]: Files can not be public and protected at the same time!" - - @cursor = null - @search = {} - @collection = new Mongo.Collection @collectionName - @currentFile = null - @_prefix = SHA256 @collectionName + @downloadRoute - @checkAccess = (http) -> + @_checkAccess = (http) -> if self.protected user = false - userFuncs = self.getUser http + userFuncs = self._getUser http {user, userId} = userFuncs user = user() if _.isFunction self.protected - result = if http then self.protected.call(_.extend(http, userFuncs), (self.currentFile or null)) else self.protected.call userFuncs, (self.currentFile or null) + if http?.params?._id + fileRef = self.collection.findOne http.params._id + + result = if http then self.protected.call(_.extend(http, userFuncs), (fileRef or null)) else self.protected.call userFuncs, (fileRef or null) else result = !!user @@ -217,7 +659,7 @@ class FilesCollection return true else rc = if _.isNumber(result) then result else 401 - console.warn '[FilesCollection.checkAccess] WARN: Access denied!' if self.debug + console.warn '[FilesCollection._checkAccess] WARN: Access denied!' if self.debug if http text = 'Access denied!' http.response.writeHead rc, @@ -228,47 +670,53 @@ class FilesCollection else return true - @methodNames = - MeteorFileAbort: "MeteorFileAbort#{@_prefix}" - MeteorFileWrite: "MeteorFileWrite#{@_prefix}" - MeteorFileUnlink: "MeteorFileUnlink#{@_prefix}" + @_methodNames = + _Abort: "_FilesCollectionAbort_#{@collectionName}" + _Write: "_FilesCollectionWrite_#{@collectionName}" + _Start: "_FilesCollectionStart_#{@collectionName}" + _Remove: "_FilesCollectionRemove_#{@collectionName}" if Meteor.isServer - @on 'handleUpload', @handleUpload - @on 'finishUpload', @finishUpload + @on '_handleUpload', @_handleUpload + @on '_finishUpload', @_finishUpload WebApp.connectHandlers.use (request, response, next) -> if !!~request._parsedUrl.path.indexOf "#{self.downloadRoute}/#{self.collectionName}/__upload" if request.method is 'POST' - body = '' + + body = '' + handleError = (error) -> + console.warn "[FilesCollection] [Upload] [HTTP] Exception:", e + response.writeHead 500 + response.end JSON.stringify {error} + return + request.on 'data', (data) -> bound -> body += data return + request.on 'end', -> bound -> try - opts = JSON.parse body - user = self.getUser http - {result, opts} = self.prepareUpload opts, user.userId, 'HTTP' + opts = JSON.parse body + user = self._getUser {request, response} + _continueUpload = self._continueUpload opts.fileId + unless _continueUpload + throw new Meteor.Error 408, 'Can\'t continue upload, session expired. Start upload again.' + + {result, opts} = self._prepareUpload _.extend(opts, _continueUpload), user.userId, 'HTTP' if opts.eof - try - Meteor.wrapAsync(self.handleUpload.bind(self, result, opts))() - response.writeHead 200 - response.end JSON.stringify result - return - catch e - console.warn "[FilesCollection] [Write Method] [HTTP] Exception:", e - response.writeHead 500 - response.end JSON.stringify {error: 2} + Meteor.wrapAsync(self._handleUpload.bind(self, result, opts))() + response.writeHead 200 + result.file.meta = fixJSONStringify result.file.meta if result?.file?.meta + response.end JSON.stringify result else - self.emit 'handleUpload', result, opts, NOOP + self.emit '_handleUpload', result, opts, NOOP response.writeHead 200 response.end JSON.stringify {success: true} - catch e - console.warn "[FilesCollection] [Write Method] [HTTP] Exception:", e - response.writeHead 500 - response.end JSON.stringify {error: e} + catch error + handleError error return else next() @@ -288,7 +736,7 @@ class FilesCollection version: uris[1] name: uris[2] http = {request, response, params} - self.findOne(uris[0]).download.call(self, http, uris[1]) if self.checkAccess http + self.download http, uris[1], self.collection.findOne(uris[0]) if self._checkAccess http else next() else @@ -316,7 +764,7 @@ class FilesCollection version: version name: _file http = {request, response, params} - self.findOne(params._id).download.call self, http, version + self.download http, version, self.collection.findOne params._id else next() else @@ -324,88 +772,120 @@ class FilesCollection return _methods = {} - _methods[self.methodNames.MeteorFileUnlink] = (search) -> - check search, Match.OneOf String, Object - console.info "[FilesCollection] [Unlink Method] [.remove(#{search})]" if self.debug + + + # Method used to remove file + # from Client side + _methods[self._methodNames._Remove] = (selector) -> + check selector, Match.OneOf String, Object + console.info "[FilesCollection] [Unlink Method] [.remove(#{selector})]" if self.debug if self.allowClientCode if self.onBeforeRemove and _.isFunction self.onBeforeRemove user = false userFuncs = { userId: @userId - user: -> if Meteor.users then Meteor.users.findOne(@userId) else undefined + user: -> if Meteor.users then Meteor.users.findOne(@userId) else null } - unless self.onBeforeRemove.call userFuncs, (self.find(search) or null) + unless self.onBeforeRemove.call userFuncs, (self.find(selector) or null) throw new Meteor.Error 403, '[FilesCollection] [remove] Not permitted!' - self.remove search + self.remove selector return true else throw new Meteor.Error 401, '[FilesCollection] [remove] Run code from client is not allowed!' return - _methods[self.methodNames.MeteorFileWrite] = (opts) -> - @unblock() + + # Method used to receive "first byte" of upload + # and all file's meta-data, so + # it won't be transferred with every chunk + # Basically it prepares everything + # So user can pause/disconnect and + # continue upload later, during `continueUploadTTL` + _methods[self._methodNames._Start] = (opts) -> check opts, { - eof: Match.Optional Boolean file: Object fileId: String FSName: Match.Optional String - binData: Match.Optional String - chunkId: Match.Optional Number chunkSize: Number fileLength: Number } - {result, opts} = self.prepareUpload opts, @userId, 'DDP' + console.info "[FilesCollection] [File Start Method] #{opts.file.name} - #{opts.fileId}" if self.debug + {result} = self._prepareUpload _.clone(opts), @userId, 'Start Method' + opts._id = opts.fileId + opts.createdAt = new Date() + self._preCollection.insert opts + self._createStream result._id, result.path, opts + return true + + + # Method used to write file chunks + # it receives very limited amount of meta-data + # This method also responsible for EOF + _methods[self._methodNames._Write] = (opts) -> + check opts, { + eof: Match.Optional Boolean + fileId: String + binData: Match.Optional String + chunkId: Match.Optional Number + } + + _continueUpload = self._continueUpload opts.fileId + unless _continueUpload + throw new Meteor.Error 408, 'Can\'t continue upload, session expired. Start upload again.' + + @unblock() + {result, opts} = self._prepareUpload _.extend(opts, _continueUpload), @userId, 'DDP' if opts.eof try - return Meteor.wrapAsync(self.handleUpload.bind(self, result, opts))() + return Meteor.wrapAsync(self._handleUpload.bind(self, result, opts))() catch e console.warn "[FilesCollection] [Write Method] [DDP] Exception:", e if self.debug throw e else - self.emit 'handleUpload', result, opts, NOOP + self.emit '_handleUpload', result, opts, NOOP return true - _methods[self.methodNames.MeteorFileAbort] = (opts) -> - check opts, { - fileId: String - fileData: Object - fileLength: Number - } - - ext = ".#{opts.fileData.ext}" - path = "#{self.storagePath}/#{opts.fileId}#{ext}" - - console.info "[FilesCollection] [Abort Method]: For #{path}" if self.debug - if self._writableStreams?[opts.fileId] - self._writableStreams[opts.fileId].stream.end() - delete self._writableStreams[opts.fileId] - self.remove({_id: opts.fileId}) - self.unlink({_id: opts.fileId, path}) - + # Method used to Abort upload + # - Feeing memory by .end()ing writableStreams + # - Removing temporary record from @_preCollection + # - Removing record from @collection + # - .unlink()ing chunks from FS + _methods[self._methodNames._Abort] = (_id) -> + check _id, String + + _continueUpload = self._continueUpload _id + console.info "[FilesCollection] [Abort Method]: #{_id} - #{_continueUpload?.file?.path}" if self.debug + if _continueUpload + self._preCollection.remove {_id} + self.remove {_id} + self.unlink {_id, path: _continueUpload.file.path} return true + Meteor.methods _methods ### @locus Server @memberOf FilesCollection - @name prepareUpload + @name _prepareUpload @summary Internal method. Used to optimize received data and check upload permission @returns {Object} ### - prepareUpload: if Meteor.isServer then (opts, userId, transport) -> - opts.eof ?= false - opts.meta ?= {} - opts.binData ?= 'EOF' - opts.chunkId ?= -1 - opts.FSName ?= opts.fileId + _prepareUpload: if Meteor.isServer then (opts, userId, transport) -> + opts.eof ?= false + opts.binData ?= 'EOF' + opts.chunkId ?= -1 + opts.FSName ?= opts.fileId + opts.file.meta ?= {} - fileName = @getFileName opts.file - {extension, extensionWithDot} = @getExt fileName + console.info "[FilesCollection] [Upload] [#{transport}] Got ##{opts.chunkId}/#{opts.fileLength} chunks, dst: #{opts.file.name or opts.file.fileName}" if @debug + + fileName = @_getFileName opts.file + {extension, extensionWithDot} = @_getExt fileName result = opts.file result.path = "#{@storagePath}/#{opts.FSName}#{extensionWithDot}" @@ -413,20 +893,18 @@ class FilesCollection result.meta = opts.file.meta result.extension = extension result.ext = extension - result = @dataToSchema result + result = @_dataToSchema result result._id = opts.fileId result.userId = userId if userId - console.info "[FilesCollection] [Write Method] [#{transport}] Got ##{opts.chunkId}/#{opts.fileLength} chunks, dst: #{opts.file.name or opts.file.fileName}" if @debug - if @onBeforeUpload and _.isFunction @onBeforeUpload isUploadAllowed = @onBeforeUpload.call(_.extend({ file: opts.file }, { - userId: result.userId - user: -> if Meteor.users then Meteor.users.findOne(result.userId) else undefined chunkId: opts.chunkId - eof: opts.eof + userId: result.userId + user: -> if Meteor.users then Meteor.users.findOne(result.userId) else null + eof: opts.eof }), result) if isUploadAllowed isnt true @@ -438,74 +916,54 @@ class FilesCollection ### @locus Server @memberOf FilesCollection - @name finishUpload + @name _finishUpload @summary Internal method. Finish upload, close Writable stream, add recored to MongoDB and flush used memory @returns {undefined} ### - finishUpload: if Meteor.isServer then (result, opts, cb) -> + _finishUpload: if Meteor.isServer then (result, opts, cb) -> + console.info "[FilesCollection] [Upload] [finish(ing)Upload] -> #{result.path}" if @debug fs.chmod result.path, @permissions, NOOP self = @ - result.type = @getMimeType opts.file + result.type = @_getMimeType opts.file result.public = @public @collection.insert _.clone(result), (error, _id) -> if error - cb new Meteor.Error 500, error + cb and cb error + console.error '[FilesCollection] [Upload] [_finishUpload] Error:', error if self.debug else - result._id = _id - console.info "[FilesCollection] [Write Method] [finishUpload] -> #{result.path}" if self.debug - self.onAfterUpload and self.onAfterUpload.call self, result - self.emit 'afterUpload', result - cb null, result + self._preCollection.remove {_id: opts.fileId}, (error) -> + if error + cb and cb error + console.error '[FilesCollection] [Upload] [_finishUpload] Error:', error if self.debug + else + result._id = _id + console.info "[FilesCollection] [Upload] [finish(ed)Upload] -> #{result.path}" if self.debug + self.onAfterUpload and self.onAfterUpload.call self, result + self.emit 'afterUpload', result + cb and cb null, result + return + return return else undefined ### @locus Server @memberOf FilesCollection - @name handleUpload + @name _handleUpload @summary Internal method to handle upload process, pipe incoming data to Writable stream @returns {undefined} ### - handleUpload: if Meteor.isServer then (result, opts, cb) -> + _handleUpload: if Meteor.isServer then (result, opts, cb) -> self = @ - if opts.eof - binary = opts.binData - else - binary = new Buffer opts.binData, 'base64' try - writeDelayed = -> - chunks = Object.keys self._writableStreams[result._id].delayed - if chunks.length - chunks.sort sortNumber - for chunk in chunks - if self._writableStreams[result._id].stream.bytesWritten is opts.chunkSize * (chunk - 1) - self._writableStreams[result._id].stream.write self._writableStreams[result._id].delayed?[chunk] - delete self._writableStreams[result._id].delayed[chunk] - return true - - @_writableStreams[result._id] ?= - stream: fs.createWriteStream result.path, {flags: 'a', mode: @permissions} - delayed: {} - if opts.eof - writeDelayed() - @_writableStreams[result._id].stream.end() - delete @_writableStreams[result._id] - @emit 'finishUpload', result, opts, cb - - else if opts.chunkId is 1 - @_writableStreams[result._id].stream.write binary - @_writableStreams[result._id].stream.on 'drain', () -> - writeDelayed() + @_currentUploads[result._id].end -> bound -> + self.emit '_finishUpload', result, opts, cb return - - else if opts.chunkId > 0 - start = opts.chunkSize * (opts.chunkId - 1) - @_writableStreams[result._id].delayed[opts.chunkId] = binary - writeDelayed() - + else + @_currentUploads[result._id].write opts.chunkId, new Buffer(opts.binData, 'base64'), cb catch e cb and cb e return @@ -514,12 +972,12 @@ class FilesCollection ### @locus Anywhere @memberOf FilesCollection - @name getMimeType + @name _getMimeType @param {Object} fileData - File Object @summary Returns file's mime-type @returns {String} ### - getMimeType: (fileData) -> + _getMimeType: (fileData) -> check fileData, Object mime = fileData.type if fileData?.type if Meteor.isServer and fileData.path and (not mime or not _.isString mime) @@ -538,12 +996,12 @@ class FilesCollection ### @locus Anywhere @memberOf FilesCollection - @name getFileName + @name _getFileName @param {Object} fileData - File Object @summary Returns file's name @returns {String} ### - getFileName: (fileData) -> + _getFileName: (fileData) -> fileName = fileData.name or fileData.fileName if _.isString(fileName) and fileName.length > 0 cleanName = (str) -> str.replace(/\.\./g, '').replace /\//g, '' @@ -554,11 +1012,11 @@ class FilesCollection ### @locus Anywhere @memberOf FilesCollection - @name getUser + @name _getUser @summary Returns object with `userId` and `user()` method which return user's object @returns {Object} ### - getUser: (http) -> + _getUser: (http) -> result = user: -> return null userId: null @@ -566,26 +1024,27 @@ class FilesCollection if Meteor.isServer if http cookie = http.request.Cookies - if _.has(Package, 'accounts-base') and cookie.has 'meteor_login_token' + if _.has(Package, 'accounts-base') and Accounts and cookie.has 'meteor_login_token' user = Meteor.users.findOne 'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken cookie.get 'meteor_login_token' if user result.user = () -> return user result.userId = user._id else - if _.has(Package, 'accounts-base') and Meteor.userId() + if _.has(Package, 'accounts-base') and Accounts and Meteor.userId() result.user = -> return Meteor.user() result.userId = Meteor.userId() + return result ### @locus Anywhere @memberOf FilesCollection - @name getExt + @name _getExt @param {String} FileName - File name @summary Get extension from FileName @returns {Object} ### - getExt: (fileName) -> + _getExt: (fileName) -> if !!~fileName.indexOf('.') extension = fileName.split('.').pop() return { ext: extension, extension, extensionWithDot: '.' + extension } @@ -595,12 +1054,12 @@ class FilesCollection ### @locus Anywhere @memberOf FilesCollection - @name dataToSchema + @name _dataToSchema @param {Object} data - File data - @summary Build object in accordance with schema from File data + @summary Internal method. Build object in accordance with default schema from File data @returns {Object} ### - dataToSchema: (data) -> + _dataToSchema: (data) -> return { name: data.name extension: data.extension @@ -619,28 +1078,12 @@ class FilesCollection isImage: !!~data.type.toLowerCase().indexOf('image') isText: !!~data.type.toLowerCase().indexOf('text') isJSON: !!~data.type.toLowerCase().indexOf('json') - _prefix: data._prefix or @_prefix + isPDF: !!~data.type.toLowerCase().indexOf('pdf') _storagePath: data._storagePath or @storagePath _downloadRoute: data._downloadRoute or @downloadRoute _collectionName: data._collectionName or @collectionName } - ### - @locus Anywhere - @memberOf FilesCollection - @name srch - @param {String|Object} search - Search data - @summary Build search object - @returns {Object} - ### - srch: (search) -> - if search and _.isString search - @search = - _id: search - else - @search = search or {} - @search - ### @locus Server @memberOf FilesCollection @@ -655,7 +1098,7 @@ class FilesCollection @returns {FilesCollection} Instance ### write: if Meteor.isServer then (buffer, opts = {}, callback) -> - console.info "[FilesCollection] [write()]" if @debug + console.info '[FilesCollection] [write()]' if @debug if _.isFunction opts callback = opts @@ -668,16 +1111,16 @@ class FilesCollection FSName = if @namingFunction then @namingFunction() else fileId fileName = if (opts.name or opts.fileName) then (opts.name or opts.fileName) else FSName - {extension, extensionWithDot} = @getExt fileName + {extension, extensionWithDot} = @_getExt fileName self = @ opts ?= {} opts.path = "#{@storagePath}/#{FSName}#{extensionWithDot}" - opts.type = @getMimeType opts + opts.type = @_getMimeType opts opts.meta ?= {} opts.size ?= buffer.length - result = @dataToSchema + result = @_dataToSchema name: fileName path: opts.path meta: opts.meta @@ -734,33 +1177,50 @@ class FilesCollection pathParts = url.split('/') fileName = if (opts.name or opts.fileName) then (opts.name or opts.fileName) else pathParts[pathParts.length - 1] or FSName - {extension, extensionWithDot} = @getExt fileName - opts.path = "#{@storagePath}/#{FSName}#{extensionWithDot}" + {extension, extensionWithDot} = @_getExt fileName opts.meta ?= {} + opts.path = "#{@storagePath}/#{FSName}#{extensionWithDot}" - request.get(url).on('error', (error)-> bound -> - throw new Meteor.Error 500, "Error on [load(#{url})]:" + JSON.stringify error - ).on('response', (response) -> bound -> - - console.info "[FilesCollection] [load] Received: #{url}" if self.debug - - result = self.dataToSchema - name: fileName - path: opts.path - meta: opts.meta - type: opts.type or response.headers['content-type'] - size: opts.size or parseInt(response.headers['content-length'] or 0) - extension: extension - + storeResult = (result, callback) -> result._id = fileId - self.collection.insert _.clone(result), (error) -> + self.collection.insert result, (error) -> if error callback and callback error - console.warn "[FilesCollection] [load] [insert] Error: #{fileName} -> #{self.collectionName}", error if self.debug + console.error "[FilesCollection] [load] [insert] Error: #{fileName} -> #{self.collectionName}", error if self.debug else callback and callback null, result console.info "[FilesCollection] [load] [insert] #{fileName} -> #{self.collectionName}" if self.debug + return + return + + request.get(url).on('error', (error)-> bound -> + callback and callback error + console.error "[FilesCollection] [load] [request.get(#{url})] Error:", error if self.debug + ).on('response', (response) -> bound -> + response.on 'end', -> bound -> + console.info "[FilesCollection] [load] Received: #{url}" if self.debug + result = self._dataToSchema + name: fileName + path: opts.path + meta: opts.meta + type: opts.type or response.headers['content-type'] or self._getMimeType {path: opts.path} + size: opts.size or parseInt(response.headers['content-length'] or 0) + extension: extension + + unless result.size + fs.stat opts.path, (error, stats) -> bound -> + if error + callback and callback error + else + result.versions.original.size = result.size = stats.size + storeResult result, callback + return + else + storeResult result, callback + return + return + ).pipe fs.createWriteStream(opts.path, {flags: 'w', mode: @permissions}) return @ @@ -799,15 +1259,15 @@ class FilesCollection pathParts = path.split '/' fileName = pathParts[pathParts.length - 1] - {extension, extensionWithDot} = self.getExt fileName + {extension, extensionWithDot} = self._getExt fileName opts ?= {} opts.path = path - opts.type ?= self.getMimeType opts + opts.type ?= self._getMimeType opts opts.meta ?= {} opts.size ?= stats.size - result = self.dataToSchema + result = self._dataToSchema name: fileName path: path meta: opts.meta @@ -836,64 +1296,32 @@ class FilesCollection @locus Anywhere @memberOf FilesCollection @name findOne - @param {String|Object} search - `_id` of the file or `Object` like, {prop:'val'} - @summary Load file - @returns {FilesCollection} Instance + @param {String|Object} selector - Mongo-Style selector (http://docs.meteor.com/api/collections.html#selectors) + @param {Object} options - Mongo-Style selector Options (http://docs.meteor.com/api/collections.html#sortspecifiers) + @summary Find and return Cursor for matching document Object + @returns {FileCursor} Instance ### - findOne: (search) -> - console.info "[FilesCollection] [findOne(#{JSON.stringify(search)})]" if @debug - check search, Match.Optional Match.OneOf Object, String - @srch search - - if @checkAccess() - @currentFile = @collection.findOne @search - @cursor = null - return @ + findOne: (selector = {}, options) -> + console.info "[FilesCollection] [findOne(#{JSON.stringify(selector)})]" if @debug + check selector, Match.OneOf Object, String + check options, Match.Optional Object + doc = @collection.findOne selector, options + return if doc then new FileCursor(doc, @) else doc ### @locus Anywhere @memberOf FilesCollection @name find - @param {String|Object} search - `_id` of the file or `Object` like, {prop:'val'} - @summary Load file or bunch of files - @returns {FilesCollection} Instance - ### - find: (search) -> - console.info "[FilesCollection] [find(#{JSON.stringify(search)})]" if @debug - check search, Match.Optional Match.OneOf Object, String - @srch search - - if @checkAccess() - @currentFile = null - @cursor = @collection.find @search - return @ - - ### - @locus Anywhere - @memberOf FilesCollection - @name get - @summary Return value of current cursor or file - @returns {Object|[Object]} + @param {String|Object} selector - Mongo-Style selector (http://docs.meteor.com/api/collections.html#selectors) + @param {Object} options - Mongo-Style selector Options (http://docs.meteor.com/api/collections.html#sortspecifiers) + @summary Find and return Cursor for matching documents + @returns {FilesCursor} Instance ### - get: () -> - console.info '[FilesCollection] [get()]' if @debug - return @cursor.fetch() if @cursor - return @currentFile - - ### - @locus Anywhere - @memberOf FilesCollection - @name fetch - @summary Alias for `get()` method - @returns {[Object]} - ### - fetch: () -> - console.info '[FilesCollection] [fetch()]' if @debug - data = @get() - if not _.isArray data - return [data] - else - data + find: (selector = {}, options) -> + console.info "[FilesCollection] [find(#{JSON.stringify(selector)}, #{JSON.stringify(options)})]" if @debug + check selector, Match.OneOf Object, String + check options, Match.Optional Object + return new FilesCursor selector, options, @ ### @locus Client @@ -906,6 +1334,7 @@ class FilesCollection {Boolean} allowWebWorkers- Allow/Deny WebWorkers usage {Number|dynamic} streams - Quantity of parallel upload streams, default: 2 {Number|dynamic} chunkSize - Chunk size for upload + {String} transport - Upload transport `http` or `ddp` {Function} onUploaded - Callback triggered when upload is finished, with two arguments `error` and `fileRef` {Function} onStart - Callback triggered when upload is started after all successful validations, with two arguments `error` (always null) and `fileRef` {Function} onError - Callback triggered on error in upload and/or FileReader, with two arguments `error` and `fileData` @@ -926,11 +1355,8 @@ class FilesCollection {Function} readAsDataURL - Current file as data URL, use to create image preview and etc. Be aware of big files, may lead to browser crash ### insert: if Meteor.isClient then (config, autoStart = true) -> - if @checkAccess() - mName = if autoStart then 'start' else 'manual' - return (new @_UploadInstance(config, @))[mName]() - else - throw new Meteor.Error 401, "[FilesCollection] [insert] Access Denied" + mName = if autoStart then 'start' else 'manual' + return (new @_UploadInstance(config, @))[mName]() else undefined ### @@ -956,6 +1382,7 @@ class FilesCollection check @config, { file: Match.Any + fileName: Match.Optional String meta: Match.Optional Object onError: Match.Optional Function onAbort: Match.Optional Function @@ -970,33 +1397,35 @@ class FilesCollection } if @config.file - console.time('insert ' + @config.file.name) if @collection.debug - console.time('loadFile ' + @config.file.name) if @collection.debug + if @collection.debug + console.time('insert ' + @config.file.name) + console.time('loadFile ' + @config.file.name) if Worker and @config.allowWebWorkers @worker = new Worker '/packages/ostrio_files/worker.js' else @worker = null - @trackerComp = null + @config.debug = @collection.debug @currentChunk = 0 - @sentChunks = 0 - @EOFsent = false @transferTime = 0 + @trackerComp = null + @sentChunks = 0 @fileLength = 1 + @EOFsent = false + @FSName = if @collection.namingFunction then @collection.namingFunction(@config.file) else @fileId @fileId = Random.id() - @FSName = if @namingFunction then @namingFunction() else @fileId @pipes = [] @fileData = size: @config.file.size type: @config.file.type - name: @config.file.name + name: @config.fileName or @config.file.name meta: @config.meta - @fileData = _.extend @fileData, @collection.getExt(self.config.file.name), {mime: @collection.getMimeType(@fileData)} + @fileData = _.extend @fileData, @collection._getExt(self.config.file.name), {mime: @collection._getMimeType(@fileData)} @fileData['mime-type'] = @fileData.mime - @result = new @collection._FileUpload _.extend self.config, {@fileData, @fileId, MeteorFileAbort: @collection.methodNames.MeteorFileAbort} + @result = new @collection._FileUpload _.extend self.config, {@fileData, @fileId, _Abort: @collection._methodNames._Abort} @beforeunload = (e) -> message = if _.isFunction(self.collection.onbeforeunloadMessage) then self.collection.onbeforeunloadMessage.call(self.result, self.fileData) else self.collection.onbeforeunloadMessage @@ -1004,7 +1433,7 @@ class FilesCollection return message @result.config.beforeunload = @beforeunload window.addEventListener 'beforeunload', @beforeunload, false - + @result.config._onEnd = -> self.emitEvent '_onEnd' @addListener 'end', @end @@ -1027,13 +1456,14 @@ class FilesCollection return , 250 - @addListener '_onEnd', -> + @addListener '_onEnd', -> + Meteor.clearInterval(self.result.estimateTimer) if self.result.estimateTimer self.worker.terminate() if self.worker self.trackerComp.stop() if self.trackerComp window.removeEventListener('beforeunload', self.beforeunload, false) if self.beforeunload self.result.progress.set(0) if self.result else - throw new Meteor.Error 500, "[FilesCollection] [insert] Have you forget to pass a File itself?" + throw new Meteor.Error 500, '[FilesCollection] [insert] Have you forget to pass a File itself?' end: (error, data) -> console.timeEnd('insert ' + @config.file.name) if @collection.debug @@ -1041,7 +1471,7 @@ class FilesCollection @result.emitEvent 'uploaded', [error, data] @config.onUploaded and @config.onUploaded.call @result, error, data if error - console.warn "[FilesCollection] [insert] [end] Error: ", error if @collection.debug + console.error '[FilesCollection] [insert] [end] Error:', error if @collection.debug @result.abort() @result.state.set 'aborted' @result.emitEvent 'error', [error, @fileData] @@ -1055,17 +1485,9 @@ class FilesCollection sendChunk: (evt) -> self = @ opts = - file: @fileData fileId: @fileId binData: evt.data.bin chunkId: evt.data.chunkId - chunkSize: @config.chunkSize - fileLength: @fileLength - - opts.FSName = @FSName if @FSName isnt @fileId - - if evt.data.chunkId isnt 1 - opts.file = _.omit opts.file, 'meta', 'ext', 'extensionWithDot', 'mime', 'mime-type' @emitEvent 'data', [evt.data.bin] if @pipes.length @@ -1078,12 +1500,13 @@ class FilesCollection if opts.binData and opts.binData.length if @config.transport is 'ddp' - Meteor.call @collection.methodNames.MeteorFileWrite, opts, (error) -> - ++self.sentChunks + Meteor.call @collection._methodNames._Write, opts, (error) -> self.transferTime += (+new Date) - evt.data.start if error - self.emitEvent 'end', [error] + if self.result.state.get() isnt 'aborted' + self.emitEvent 'end', [error] else + ++self.sentChunks if self.sentChunks >= self.fileLength self.emitEvent 'sendEOF', [opts] else if self.currentChunk < self.fileLength @@ -1091,12 +1514,17 @@ class FilesCollection self.emitEvent 'calculateStats' return else + opts.file.meta = fixJSONStringify opts.file.meta if opts?.file?.meta HTTP.call 'POST', "#{@collection.downloadRoute}/#{@collection.collectionName}/__upload", {data: opts}, (error, result) -> - ++self.sentChunks self.transferTime += (+new Date) - evt.data.start if error - self.emitEvent 'end', [error] + if "#{error}" is "Error: network" + self.result.pause() + else + if self.result.state.get() isnt 'aborted' + self.emitEvent 'end', [error] else + ++self.sentChunks if self.sentChunks >= self.fileLength self.emitEvent 'sendEOF', [opts] else if self.currentChunk < self.fileLength @@ -1110,21 +1538,18 @@ class FilesCollection @EOFsent = true self = @ opts = - eof: true - file: @fileData - fileId: @fileId - chunkSize: @config.chunkSize - fileLength: @fileLength - - opts.FSName = @FSName if @FSName isnt @fileId + eof: true + fileId: @fileId if @config.transport is 'ddp' - Meteor.call @collection.methodNames.MeteorFileWrite, opts, -> + Meteor.call @collection._methodNames._Write, opts, -> self.emitEvent 'end', arguments return else HTTP.call 'POST', "#{@collection.downloadRoute}/#{@collection.collectionName}/__upload", {data: opts}, (error, result) -> - self.emitEvent 'end', [error, JSON.parse(result?.content or {})] + res = JSON.parse result?.content or {} + res.meta = fixJSONParse res.meta if res?.meta + self.emitEvent 'end', [error, res] return return @@ -1142,6 +1567,7 @@ class FilesCollection } }] return + fileReader.onerror = (e) -> self.emitEvent 'end', [(e.target or e.srcElement).error] return @@ -1152,10 +1578,6 @@ class FilesCollection upload: -> start = +new Date if @result.onPause.get() - self = @ - @result.continueFunc = -> - self.emitEvent 'createStreams' - return return if @result.state.get() is 'aborted' @@ -1190,17 +1612,40 @@ class FilesCollection else if @config.chunkSize > 1048576 @config.chunkSize = 1048576 + if @config.transport is 'http' + @config.chunkSize = Math.round @config.chunkSize / 2 + @config.chunkSize = Math.floor(@config.chunkSize / 8) * 8 _len = Math.ceil(@config.file.size / @config.chunkSize) if @config.streams is 'dynamic' @config.streams = _.clone _len @config.streams = 24 if @config.streams > 24 + if @config.transport is 'http' + @config.streams = Math.round @config.streams / 2 + @fileLength = if _len <= 0 then 1 else _len @config.streams = @fileLength if @config.streams > @fileLength @result.config.fileLength = @fileLength - self.emitEvent 'createStreams' + opts = + file: @fileData + fileId: @fileId + chunkSize: @config.chunkSize + fileLength: @fileLength + opts.FSName = @FSName if @FSName isnt @fileId + + Meteor.call @collection._methodNames._Start, opts, (error) -> + if error + console.error '[FilesCollection] [.call(_Start)] Error:', error if self.collection.debug + self.emitEvent 'end', [error] + else + self.result.continueFunc = -> + console.info '[FilesCollection] [insert] [continueFunc]' if self.collection.debug + self.emitEvent 'createStreams' + return + self.emitEvent 'createStreams' + return return pipe: (func) -> @@ -1214,12 +1659,12 @@ class FilesCollection return @result if @config.onBeforeUpload and _.isFunction @config.onBeforeUpload - isUploadAllowed = @config.onBeforeUpload.call _.extend(@result, @collection.getUser()), @fileData + isUploadAllowed = @config.onBeforeUpload.call _.extend(@result, @collection._getUser()), @fileData if isUploadAllowed isnt true return @end new Meteor.Error(403, if _.isString(isUploadAllowed) then isUploadAllowed else 'config.onBeforeUpload() returned false') if @collection.onBeforeUpload and _.isFunction @collection.onBeforeUpload - isUploadAllowed = @collection.onBeforeUpload.call _.extend(@result, @collection.getUser()), @fileData + isUploadAllowed = @collection.onBeforeUpload.call _.extend(@result, @collection._getUser()), @fileData if isUploadAllowed isnt true return @end new Meteor.Error(403, if _.isString(isUploadAllowed) then isUploadAllowed else 'collection.onBeforeUpload() returned false') @@ -1227,11 +1672,11 @@ class FilesCollection self.trackerComp = computation unless self.result.onPause.get() if Meteor.status().connected - self.result.continue() console.info '[FilesCollection] [insert] [Tracker] [continue]' if self.collection.debug + self.result.continue() else - self.result.pause() console.info '[FilesCollection] [insert] [Tracker] [pause]' if self.collection.debug + self.result.pause() return if @worker @@ -1248,9 +1693,9 @@ class FilesCollection if @collection.debug if @worker - console.info "[FilesCollection] [insert] using WebWorkers" + console.info '[FilesCollection] [insert] using WebWorkers' else - console.info "[FilesCollection] [insert] using MainThread" + console.info '[FilesCollection] [insert] using MainThread' self.emitEvent 'prepare' return @result @@ -1277,31 +1722,42 @@ class FilesCollection __proto__: EventEmitter.prototype constructor: (@config) -> EventEmitter.call @ + self = @ @file = _.extend @config.file, @config.fileData @state = new ReactiveVar 'active' @onPause = new ReactiveVar false @progress = new ReactiveVar 0 @estimateTime = new ReactiveVar 1000 @estimateSpeed = new ReactiveVar 0 + @estimateTimer = Meteor.setInterval -> + if self.state.get() is 'active' + _currentTime = self.estimateTime.get() + if _currentTime > 1000 + self.estimateTime.set _currentTime - 1000 + return + , 1000 continueFunc: -> return pause: -> + console.info '[FilesCollection] [insert] [.pause()]' if @config.debug unless @onPause.get() @onPause.set true @state.set 'paused' @emitEvent 'pause', [@file] return continue: -> + console.info '[FilesCollection] [insert] [.continue()]' if @config.debug if @onPause.get() @onPause.set false @state.set 'active' @emitEvent 'continue', [@file] - @continueFunc.call() - @continueFunc = -> return + @continueFunc() return toggle: -> + console.info '[FilesCollection] [insert] [.toggle()]' if @config.debug if @onPause.get() then @continue() else @pause() return abort: -> + console.info '[FilesCollection] [insert] [.abort()]' if @config.debug window.removeEventListener 'beforeunload', @config.beforeunload, false @config.onAbort and @config.onAbort.call @, @file @emitEvent 'abort', [@file] @@ -1309,8 +1765,7 @@ class FilesCollection @config._onEnd() @state.set 'aborted' console.timeEnd('insert ' + @config.file.name) if @config.debug - if @config.fileLength - Meteor.call @config.MeteorFileAbort, {fileId: @config.fileId, fileLength: @config.fileLength, fileData: @config.fileData} + Meteor.call @config._Abort, @config.fileId return else undefined @@ -1318,38 +1773,38 @@ class FilesCollection @locus Anywhere @memberOf FilesCollection @name remove - @param {String|Object} search - `_id` of the file or `Object` like, {prop:'val'} - @param {Function} cb - Callback with one `error` argument - @summary Remove file(s) on cursor or find and remove file(s) if search is set + @param {String|Object} selector - Mongo-Style selector (http://docs.meteor.com/api/collections.html#selectors) + @param {Function} callback - Callback with one `error` argument + @summary Remove documents from the collection @returns {FilesCollection} Instance ### - remove: (search, cb) -> - console.info "[FilesCollection] [remove(#{JSON.stringify(search)})]" if @debug - check search, Match.Optional Match.OneOf Object, String - check cb, Match.Optional Function - - if @checkAccess() - @srch search - if Meteor.isClient - if @allowClientCode - Meteor.call @methodNames.MeteorFileUnlink, search, (if cb then cb else NOOP) - else - if cb - cb new Meteor.Error 401, '[FilesCollection] [remove] Run code from client is not allowed!' - else - throw new Meteor.Error 401, '[FilesCollection] [remove] Run code from client is not allowed!' + remove: (selector, callback) -> + console.info "[FilesCollection] [remove(#{JSON.stringify(selector)})]" if @debug + check selector, Match.Optional Match.OneOf Object, String + check callback, Match.Optional Function - if Meteor.isServer - files = @collection.find @search - if files.count() > 0 - self = @ - files.forEach (file) -> self.unlink file - @collection.remove @search, cb + if Meteor.isClient + if @allowClientCode + Meteor.call @_methodNames._Remove, selector, (callback or NOOP) + else + callback and callback new Meteor.Error 401, '[FilesCollection] [remove] Run code from client is not allowed!' + console.warn '[FilesCollection] [remove] Run code from client is not allowed!' if @debug else - if cb - cb new Meteor.Error 401, '[FilesCollection] [remove] Access denied!' + files = @collection.find selector + if files.count() > 0 + self = @ + files.forEach (file) -> self.unlink file + + if @onAfterRemove + self = @ + docs = files.fetch() + + @collection.remove selector, -> + callback and callback.apply @, arguments + self.onAfterRemove docs + return else - throw new Meteor.Error 401, '[FilesCollection] [remove] Access denied!' + @collection.remove selector, (callback or NOOP) return @ ### @@ -1451,167 +1906,189 @@ class FilesCollection @locus Server @memberOf FilesCollection @name download - @param {Object|Files} self - Instance of FilesCollection + @param {Object} http - Server HTTP object + @param {String} version - Requested file version + @param {Object} fileRef - Requested file Object @summary Initiates the HTTP response @returns {undefined} ### - download: if Meteor.isServer then (http, version = 'original') -> + download: if Meteor.isServer then (http, version = 'original', fileRef) -> console.info "[FilesCollection] [download(#{http.request.originalUrl}, #{version})]" if @debug - responseType = '200' - if @currentFile - if _.has(@currentFile, 'versions') and _.has @currentFile.versions, version - fileRef = @currentFile.versions[version] + if fileRef + if _.has(fileRef, 'versions') and _.has fileRef.versions, version + vRef = fileRef.versions[version] + vRef._id = fileRef._id else - fileRef = @currentFile + vRef = fileRef else - fileRef = false + vRef = false - if not fileRef or not _.isObject(fileRef) + if not vRef or not _.isObject(vRef) return @_404 http - else if @currentFile + else if fileRef self = @ if @downloadCallback - unless @downloadCallback.call _.extend(http, @getUser(http)), @currentFile + unless @downloadCallback.call _.extend(http, @_getUser(http)), fileRef return @_404 http if @interceptDownload and _.isFunction @interceptDownload - if @interceptDownload(http, @currentFile, version) is true + if @interceptDownload(http, fileRef, version) is true return - fs.stat fileRef.path, (statErr, stats) -> bound -> + fs.stat vRef.path, (statErr, stats) -> bound -> if statErr or not stats.isFile() return self._404 http - fileRef.size = stats.size if stats.size isnt fileRef.size and not self.integrityCheck - responseType = '400' if stats.size isnt fileRef.size and self.integrityCheck - partiral = false - reqRange = false - - if http.params.query.download and http.params.query.download == 'true' - dispositionType = 'attachment; ' - else - dispositionType = 'inline; ' - - dispositionName = "filename=\"#{encodeURIComponent(self.currentFile.name)}\"; filename=*UTF-8\"#{encodeURIComponent(self.currentFile.name)}\"; " - dispositionEncoding = 'charset=utf-8' - - http.response.setHeader 'Content-Type', fileRef.type - http.response.setHeader 'Content-Disposition', dispositionType + dispositionName + dispositionEncoding - http.response.setHeader 'Accept-Ranges', 'bytes' - http.response.setHeader 'Last-Modified', self.currentFile?.updatedAt?.toUTCString() if self.currentFile?.updatedAt?.toUTCString() - http.response.setHeader 'Connection', 'keep-alive' - - if http.request.headers.range - partiral = true - array = http.request.headers.range.split /bytes=([0-9]*)-([0-9]*)/ - start = parseInt array[1] - end = parseInt array[2] - if isNaN(end) - end = fileRef.size - 1 - take = end - start - else - start = 0 - end = fileRef.size - 1 - take = fileRef.size - - if partiral or (http.params.query.play and http.params.query.play == 'true') - reqRange = {start, end} - if isNaN(start) and not isNaN end - reqRange.start = end - take - reqRange.end = end - if not isNaN(start) and isNaN end - reqRange.start = start - reqRange.end = start + take - - 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 self.strict and (reqRange.start >= (fileRef.size - 1) or reqRange.end > (fileRef.size - 1)) - responseType = '416' - else - responseType = '206' - else - http.response.setHeader 'Cache-Control', self.cacheControl - responseType = '200' - - streamErrorHandler = (error) -> - http.response.writeHead 500 - http.response.end error.toString() - - switch responseType - when '400' - console.warn "[FilesCollection] [download(#{fileRef.path}, #{version})] [400] Content-Length mismatch!" if self.debug - text = 'Content-Length mismatch!' - http.response.writeHead 400, - 'Content-Type': 'text/plain' - 'Cache-Control': 'no-cache' - 'Content-Length': text.length - http.response.end text - break - when '404' - return self._404 http - break - when '416' - console.info "[FilesCollection] [download(#{fileRef.path}, #{version})] [416] Content-Range is not specified!" if self.debug - http.response.writeHead 416, - 'Content-Range': "bytes */#{fileRef.size}" - http.response.end() - break - when '200' - console.info "[FilesCollection] [download(#{fileRef.path}, #{version})] [200]" if self.debug - stream = fs.createReadStream fileRef.path - stream.on('open', => - http.response.writeHead 200 - if self.throttle - stream.pipe( new Throttle {bps: self.throttle, chunksize: self.chunkSize} - ).pipe http.response - else - stream.pipe http.response - ).on 'error', streamErrorHandler - break - when '206' - console.info "[FilesCollection] [download(#{fileRef.path}, #{version})] [206]" if self.debug - http.response.setHeader 'Content-Range', "bytes #{reqRange.start}-#{reqRange.end}/#{fileRef.size}" - http.response.setHeader 'Trailer', 'expires' - http.response.setHeader 'Transfer-Encoding', 'chunked' - if self.throttle - stream = fs.createReadStream fileRef.path, {start: reqRange.start, end: reqRange.end} - stream.on('open', -> http.response.writeHead 206 - ).on('error', streamErrorHandler - ).on('end', -> http.response.end() - ).pipe( new Throttle {bps: self.throttle, chunksize: self.chunkSize} - ).pipe http.response - else - stream = fs.createReadStream fileRef.path, {start: reqRange.start, end: reqRange.end} - stream.on('open', -> http.response.writeHead 206 - ).on('error', streamErrorHandler - ).on('end', -> http.response.end() - ).pipe http.response - break + vRef.size = stats.size if stats.size isnt vRef.size and not self.integrityCheck + responseType = '400' if stats.size isnt vRef.size and self.integrityCheck + self.serve http, fileRef, vRef, version, null, (responseType or '200') return else return @_404 http else undefined + ### + @locus Server + @memberOf FilesCollection + @name serve + @param {Object} http - Server HTTP object + @param {Object} fileRef - Requested file Object + @param {Object} vRef - Requested file version Object + @param {String} version - Requested file version + @param {stream.Readable|null} readableStream - Readable stream, which serves binary file data + @param {String} responseType - Response code + @param {Boolean} force200 - Force 200 response code over 206 + @summary Handle and reply to incoming request + @returns {undefined} + ### + serve: if Meteor.isServer then (http, fileRef, vRef, version = 'original', readableStream = null, responseType = '200', force200 = false) -> + self = @ + partiral = false + reqRange = false + + if http.params.query.download and http.params.query.download == 'true' + dispositionType = 'attachment; ' + else + dispositionType = 'inline; ' + + dispositionName = "filename=\"#{encodeURIComponent(fileRef.name)}\"; filename=*UTF-8\"#{encodeURIComponent(fileRef.name)}\"; " + dispositionEncoding = 'charset=utf-8' + + http.response.setHeader 'Content-Type', vRef.type + http.response.setHeader 'Content-Disposition', dispositionType + dispositionName + dispositionEncoding + http.response.setHeader 'Accept-Ranges', 'bytes' + http.response.setHeader 'Last-Modified', fileRef?.updatedAt?.toUTCString() if fileRef?.updatedAt?.toUTCString() + http.response.setHeader 'Connection', 'keep-alive' + + if http.request.headers.range and not force200 + partiral = true + array = http.request.headers.range.split /bytes=([0-9]*)-([0-9]*)/ + start = parseInt array[1] + end = parseInt array[2] + end = vRef.size - 1 if isNaN(end) + take = end - start + else + start = 0 + end = vRef.size - 1 + take = vRef.size + + if partiral or (http.params.query.play and http.params.query.play == 'true') + reqRange = {start, end} + if isNaN(start) and not isNaN end + reqRange.start = end - take + reqRange.end = end + if not isNaN(start) and isNaN end + reqRange.start = start + reqRange.end = start + take + + reqRange.end = vRef.size - 1 if ((start + take) >= vRef.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 self.strict and (reqRange.start >= (vRef.size - 1) or reqRange.end > (vRef.size - 1)) + responseType = '416' + else + responseType = '206' + else + http.response.setHeader 'Cache-Control', self.cacheControl + responseType = '200' + + streamErrorHandler = (error) -> + http.response.writeHead 500 + http.response.end error.toString() + console.error "[FilesCollection] [serve(#{vRef.path}, #{version})] [500]", error if self.debug + return + + switch responseType + when '400' + console.warn "[FilesCollection] [serve(#{vRef.path}, #{version})] [400] Content-Length mismatch!" if self.debug + text = 'Content-Length mismatch!' + http.response.writeHead 400, + 'Content-Type': 'text/plain' + 'Cache-Control': 'no-cache' + 'Content-Length': text.length + http.response.end text + break + when '404' + return self._404 http + break + when '416' + console.warn "[FilesCollection] [serve(#{vRef.path}, #{version})] [416] Content-Range is not specified!" if self.debug + http.response.writeHead 416, + 'Content-Range': "bytes */#{vRef.size}" + http.response.end() + break + when '200' + console.info "[FilesCollection] [serve(#{vRef.path}, #{version})] [200]" if self.debug + stream = readableStream or fs.createReadStream vRef.path + http.response.writeHead 200 if readableStream + stream.on('open', -> + http.response.writeHead 200 + return + ).on('error', streamErrorHandler + ).on 'end', -> + http.response.end() + return + stream.pipe new Throttle {bps: self.throttle, chunksize: self.chunkSize} if self.throttle + stream.pipe http.response + break + when '206' + console.info "[FilesCollection] [serve(#{vRef.path}, #{version})] [206]" if self.debug + http.response.setHeader 'Content-Range', "bytes #{reqRange.start}-#{reqRange.end}/#{vRef.size}" + http.response.setHeader 'Trailer', 'expires' + http.response.setHeader 'Transfer-Encoding', 'chunked' + stream = readableStream or fs.createReadStream vRef.path, {start: reqRange.start, end: reqRange.end} + http.response.writeHead 206 if readableStream + stream.on('open', -> + http.response.writeHead 206 + return + ).on('error', streamErrorHandler + ).on 'end', -> + http.response.end() + return + stream.pipe new Throttle {bps: self.throttle, chunksize: self.chunkSize} if self.throttle + stream.pipe http.response + break + return + else undefined + ### @locus Anywhere @memberOf FilesCollection @name link - @param {Object} fileRef - File reference object - @param {String} version - [Optional] Version of file you would like to request + @param {Object} fileRef - File reference object + @param {String} version - Version of file you would like to request @summary Returns downloadable URL @returns {String} Empty string returned in case if file not found in DB ### link: (fileRef, version = 'original') -> - console.info '[FilesCollection] [link()]' if @debug - if _.isString fileRef - version = fileRef - fileRef = null - return '' if not fileRef and not @currentFile - return formatFleURL (fileRef or @currentFile), version + console.info "[FilesCollection] [link(#{fileRef?._id}, #{version})]" if @debug + check fileRef, Object + check version, String + return '' if not fileRef + return formatFleURL fileRef, version ### @locus Anywhere @@ -1619,7 +2096,6 @@ class FilesCollection @name formatFleURL @param {Object} fileRef - File reference object @param {String} version - [Optional] Version of file you would like build URL for -@param {Boolean} pub - [Optional] is file located in publicity available folder? @summary Returns formatted URL for file @returns {String} Downloadable link ### diff --git a/logo-bw.png b/logo-bw.png index 0ac11227..c62ccdec 100644 Binary files a/logo-bw.png and b/logo-bw.png differ diff --git a/logo.png b/logo.png index e2e82a3c..6aa7b426 100644 Binary files a/logo.png and b/logo.png differ diff --git a/package.js b/package.js index 1d4dcc0c..7dbc4216 100755 --- a/package.js +++ b/package.js @@ -1,19 +1,20 @@ Package.describe({ name: 'ostrio:files', - version: '1.5.6', + version: '1.6.0', summary: 'Fast and robust file uploads and streaming (Audio & Video), support FS or AWS, DropBox, Google Drive', git: 'https://github.com/VeliovGroup/Meteor-Files', documentation: 'README.md' }); Package.onUse(function(api) { - api.versionsFrom('1.1'); + api.versionsFrom('1.3.3.1'); + api.use('ostrio:cookies@2.0.4', ['server', 'client']); api.addFiles('event-emitter.js', 'client'); api.addAssets('worker.js', 'client'); api.addFiles('files.coffee', ['server', 'client']); api.use('webapp', 'server'); api.use(['templating', 'reactive-var', 'tracker', 'http'], 'client'); - api.use(['underscore', 'check', 'sha', 'ostrio:cookies@2.0.2', 'random', 'coffeescript'], ['client', 'server']); + api.use(['underscore', 'check', 'random', 'coffeescript'], ['client', 'server']); api.export('FilesCollection'); });