expresser
Version:
A ready-to-use platform for Node.js web apps, built on top of Express.
267 lines (211 loc) • 10.5 kB
text/coffeescript
# EXPRESSER DOWNLOADER
# --------------------------------------------------------------------------
# Handles external downloads.
# <!--
# @see Settings.downloader
# -->
class Downloader
events = require "./events.coffee"
fs = require "fs"
http = require "http"
https = require "https"
lodash = require "lodash"
logger = require "./logger.coffee"
moment = require "moment"
path = require "path"
settings = require "./settings.coffee"
url = require "url"
# The download queue and simultaneous count.
queue = []
downloading = []
# CONSTRUCTOR AND INIT
# --------------------------------------------------------------------------
# Downloader constructor.
constructor: ->
@setEvents() if settings.events.enabled
# Bind event listeners.
setEvents: =>
events.on "downloader.download", @download
# METHODS
# --------------------------------------------------------------------------
# Download an external file and save it to the specified location. The `callback`
# has the signature (error, data). Returns the downloader object which is added
# to the `queue`, which has the download properties and a `stop` helper to force
# stopping it. Returns false on error or duplicate.
# Tip: if you want to get the downloaded data without having to read the target file
# you can get the downloaded contents via the `options.downloadedData`.
# @param [String] remoteUrl The URL of the remote file to be downloaded.
# @param [String] saveTo The full local path and destination filename.
# @param [Object] options Optional, object with request options, for example auth.
# @param [Method] callback Optional, a function (err, result) to be called when download has finished.
# @return [Object] Returns the download job having timestamp, remoteUrl, saveTo, options, callback and stop helper.
download: (remoteUrl, saveTo, options, callback) =>
if not remoteUrl?
logger.warn "Downloader.download", "Aborted, remoteUrl is not defined."
return
# Check options and callback.
if not callback? and lodash.isFunction options
callback = options
options = null
now = new Date().getTime()
# Create the download object.
downloadObj = {timestamp: now, remoteUrl: remoteUrl, saveTo: saveTo, options: options, callback: callback}
# Prevent duplicates?
if settings.downloader.preventDuplicates
existing = lodash.filter downloading, {remoteUrl: remoteUrl, saveTo: saveTo}
# If downloading the same file and to the same location, abort download.
if existing.length > 0
existing = existing[0]
if existing.saveTo is saveTo
logger.warn "Downloader.download", "Aborted, already downloading.", remoteUrl, saveTo
err = {message: "Download aborted: same file is already downloading.", duplicate: true}
callback(err, downloadObj) if callback?
return false
# Create a `stop` method to force stop the download by setting the `stopFlag`.
# Accepts a `keep` boolean, if true the already downloaded data will be kept on forced stop.
stopHelper = (keep) -> @stopFlag = (if keep then 1 else 2)
# Update download object with stop helper and add to queue.
downloadObj.stop = stopHelper
queue.push downloadObj
# Start download immediatelly if not exceeding the `maxSimultaneous` setting.
next() if downloading.length < settings.downloader.maxSimultaneous
return downloadObj
# INTERNAL IMPLEMENTATION
# --------------------------------------------------------------------------
# Helper to remove a download from the `downloading` list.
removeDownloading = (obj) ->
filter = {timestamp: obj.timestamp, remoteUrl: obj.remoteUrl, saveTo: obj.saveTo}
downloading = lodash.reject downloading, filter
# Helper function to proccess download errors.
downloadError = (err, obj) ->
logger.debug "Downloader.downloadError", err, obj
removeDownloading obj
next()
obj.callback(err, obj) if obj.callback?
# Helper function to parse the URL and get its options.
parseUrlOptions = (obj, options) ->
if obj.redirectUrl? and obj.redirectUrl isnt ""
urlInfo = url.parse obj.redirectUrl
else
urlInfo = url.parse obj.remoteUrl
# Set URL options.
options =
host: urlInfo.hostname
hostname: urlInfo.hostname
port: urlInfo.port
path: urlInfo.path
# Check for credentials on the URL.
if urlInfo.auth? and urlInfo.auth isnt ""
options.auth = urlInfo.auth
return options
# Helper function to start a download request.
reqStart = (obj, options) ->
if obj.remoteUrl.indexOf("https") is 0
options.port = 443 if not options.port?
httpHandler = https
else
httpHandler = http
# Start the request.
req = httpHandler.get options, (response) =>
# Downloaded contents will be appended also to the `downloadedData`
# property of the options object.
obj.downloadedData = ""
# Set the estination temp file.
saveToTemp = obj.saveTo + settings.downloader.tempExtension
# If status is 301 or 302, redirect to the specified location and stop the current request.
if response.statusCode is 301 or response.statusCode is 302
obj.redirectUrl = response.headers.location
options = lodash.assign options, parseUrlOptions obj
req.end()
reqStart obj, options
# If status is not 200 or 304, it means something went wrong so do not proceed
# with the download. Otherwise proceed and listen to the `data` and `end` events.
else if response.statusCode isnt 200 and response.statusCode isnt 304
err = {code: response.statusCode, message: "Server returned an unexpected status code: #{response.statusCode}"}
downloadError err, obj
else
# Create the file stream with a .download extension. This will be renamed after the
# download has finished and the file is totally written.
fileWriter = fs.createWriteStream saveToTemp, {"flags": "w+"}
# Helper called response gets new data. The data will also be
# appended to `options.data` property.
onData = (data) ->
if obj.stopFlag
req.end()
onEnd()
else
fileWriter.write data
obj.downloadedData += data
# Helper called when response ends.
onEnd = ->
response.removeListener "data", onData
fileWriter.addListener "close", ->
# Check if temp file exists.
if fs.existsSync?
tempExists = fs.existsSync saveToTemp
else
tempExists = path.existsSync saveToTemp
# If temp download file can't be found, set error message.
# If `stopFlag` is 2 means download was stopped and should not keep partial data.
if not tempExists
err = {message:"Can't find downloaded file: #{saveToTemp}"}
else
fs.unlinkSync saveToTemp if obj.stopFlag is 2
# Check if destination file already exists.
if fs.existsSync?
fileExists = fs.existsSync obj.saveTo
else
fileExists = path.existsSync obj.saveTo
# Only proceed with renaming if `stopFlag` wasn't set and destionation is valid.
if not obj.stopFlag? or obj.stopFlag < 1
fs.unlinkSync obj.saveTo if fileExists
fs.renameSync saveToTemp, obj.saveTo if tempExists
# Remove from `downloading` list and proceed with the callback.
removeDownloading obj
obj.callback(err, obj) if obj.callback?
logger.debug "Downloader.next", "End", obj.remoteUrl, obj.saveTo
fileWriter.end()
fileWriter.destroySoon()
next()
# Attachd response listeners.
response.addListener "data", onData
response.addListener "end", onEnd
# Unhandled error, call the downloadError helper.
req.on "error", (err) =>
downloadError err, obj
# Process next download.
next = ->
return if queue.length < 0
# Get first download from queue.
obj = queue.shift()
# Check if download is valid.
if not obj?
logger.debug "Downloader.next", "Skip", "Downloader object is invalid."
return
else
logger.debug "Downloader.next", obj
# Add to downloading array.
downloading.push obj
if settings.downloader.headers? and settings.downloader.headers isnt ""
headers = settings.web.downloaderHeaders
else
headers = null
# Set default options.
options =
headers: headers
rejectUnauthorized: settings.downloader.rejectUnauthorized
# Extend options.
options = lodash.assign options, obj.options, parseUrlOptions(obj)
# Start download
if obj.stopFlag? and obj.stopFlag > 0
logger.debug "Downloader.next", "Skip, 'stopFlag' is #{obj.stopFlag}.", obj
removeDownloading obj
next()
else
reqStart obj, options
# Singleton implementation
# --------------------------------------------------------------------------
Downloader.getInstance = ->
@instance = new Downloader() if not @instance?
return @instance
module.exports = exports = Downloader.getInstance()