Contact
CoCalc Logo Icon
StoreFeaturesDocsShareSupport News AboutSign UpSign In
| Download
Views: 39536
1
###############################################################################
2
#
3
# CoCalc: Collaborative Calculation in the Cloud
4
#
5
# Copyright (C) 2015, CoCalc Authors
6
#
7
# This program is free software: you can redistribute it and/or modify
8
# it under the terms of the GNU General Public License as published by
9
# the Free Software Foundation, either version 3 of the License, or
10
# (at your option) any later version.
11
#
12
# This program is distributed in the hope that it will be useful,
13
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
# GNU General Public License for more details.
16
#
17
# You should have received a copy of the GNU General Public License
18
# along with this program. If not, see <http://www.gnu.org/licenses/>.
19
#
20
###############################################################################
21
#
22
# AUTHORS:
23
# - Christopher Swenson wrote the first version of this at Sage Days 64.25.
24
25
# pass in environment variables DROPBOX_API_KEY, DROPBOX_API_SECRET, DROPBOX_USER_TOKEN,
26
# DROPBOX_LOCAL_DIR, DROPBOX_PATH_PREFIX
27
28
cluster = require('cluster')
29
30
# ensure that we respawn cleanly if an error occurs
31
# TODO: consider using forever / start-stop daemon since we have it.
32
if cluster.isMaster
33
cluster.fork()
34
35
cluster.on 'disconnect', (worker) ->
36
console.error('restarting!')
37
cluster.fork()
38
return
39
40
async = require('async')
41
crypto = require('crypto')
42
dirty = require('dirty') # I really hate this key-value store
43
Dropbox = require('dropbox')
44
fs = require('fs')
45
path = require('path')
46
readline = require("readline")
47
mkdirp = require('mkdirp')
48
gaze = require('gaze')
49
50
# random uuid to put in the file cache to store cursor between session
51
# (this is a hack because we're using a key:value store instead of storing
52
# this in a file or using sqlite.)
53
cursorId = '0d32db1a-17f2-4aa8-9060-2a9eb72ec355'
54
55
56
# SECURITY TODO: For security reasons it's necessary to move these secrets to the hub.
57
# Instead of the project directly communicating with dropbox, all the communication
58
# would be proxied through the hub. So we would define messages that correspond
59
# to each of the API calls below.
60
apiKey = process.env.DROPBOX_API_KEY
61
apiSecret = process.env.DROPBOX_API_SECRET
62
userToken = process.env.DROPBOX_USER_TOKEN
63
base = process.env.DROPBOX_LOCAL_DIR
64
pathPrefix = process.env.DROPBOX_PATH_PREFIX
65
66
if base
67
if base[0] == '/'
68
base = base.substring(1)
69
if pathPrefix
70
if pathPrefix[0] == '/'
71
pathPrefix = pathPrefix.substring(1)
72
73
hash = (data) ->
74
shasum = crypto.createHash('sha1')
75
shasum.update(data)
76
shasum.digest('hex')
77
78
unless fs.existsSync(base)
79
fs.mkdirSync(base)
80
81
# if .smc-dropbox is messed or deleted, then this daemon will likely crash
82
# and on next restart will resync from scratch clobbering everything.
83
unless fs.existsSync(process.env.HOME + '/.smc-dropbox/filecache.db')
84
unless fs.existsSync(process.env.HOME + '/.smc-dropbox')
85
fs.mkdirSync(process.env.HOME + '/.smc-dropbox')
86
fs.writeFileSync(process.env.HOME + '/.smc-dropbox/filecache.db', '')
87
88
filecache = dirty(process.env.HOME + '/.smc-dropbox/filecache.db')
89
90
params =
91
key : apiKey
92
secret : apiSecret
93
token : userToken? && userToken
94
95
client = new Dropbox.Client(params)
96
97
###
98
# test code: simple command-line interactive token grabbing thing
99
unless userToken? && uid?
100
simpleDriver =
101
authType: -> 'code'
102
url: -> ''
103
doAuthorize: (authUrl, stateParm, client, callback) ->
104
iface = readline.createInterface
105
input: process.stdin,
106
output: process.stdout
107
iface.write('Open the URL below in a browser and paste the ' +
108
'provided authentication code.\n' + authUrl + '\n')
109
iface.question '> ', (authCode) ->
110
iface.close()
111
callback
112
code: authCode
113
client.authDriver(simpleDriver)
114
###
115
116
# converts dropbox paths to local file paths
117
localToDropboxPath (path) ->
118
return '' unless path
119
if path[0] == '/'
120
path = path.substring(1)
121
if path.indexOf(pathPrefix) == 0
122
return path
123
if path.indexOf(base) == 0
124
return pathPrefix + path.substring(base.length())
125
else
126
return path
127
128
# converts local file paths to dropbox paths
129
dropboxToLocalPath (path) ->
130
return '' unless path
131
if path[0] == '/'
132
path = path.substring(1)
133
if path.indexOf(base) == 0
134
return path
135
if path.indexOf(pathPrefix) == 0
136
return base + path.substring(pathPrefix.length())
137
else
138
return path
139
140
141
writeFile = (filepath, data, stat, cb) ->
142
if stat == null
143
stat = {}
144
console.log("writing file")
145
fs.exists filepath, (exists) ->
146
if exists
147
console.log("file exists", filepath, "... overwriting")
148
fs.writeFile filepath, data, (err) ->
149
if err
150
throw(err) unless cb?
151
cb(err)
152
console.log('file written')
153
# the filecache wants a sha1sum of the file
154
stat.hash = hash(data)
155
filecache.set(filepath, stat)
156
cb() if cb?
157
158
# handle dropbox changes
159
# TODO: big files are not handled by this, but will require different API calls.
160
onDropboxChanges = (db, delta, cb) ->
161
onDropboxChange = (change, cb) ->
162
console.log("Dropbox announced change for", change.path)
163
# check cache for tag
164
#console.log(change)
165
if change.wasRemoved
166
console.log("nuking file from dropbox change")
167
filepath = dropboxToLocalPath(change.path)
168
fs.exists filepath, (exists) ->
169
if exists
170
fs.stat filepath, (error, stats) ->
171
throw(error) if error
172
if stats.isFile()
173
console.log("removing file")
174
filecache.rm(filepath)
175
fs.unlink(filepath, cb)
176
else if stats.isDirectory()
177
console.log("removing directory")
178
filecache.rm(filepath)
179
fs.rmdir(filepath, cb)
180
else
181
console.log("Non-file non-directory is ignored")
182
cb()
183
else
184
filecache.rm(filepath)
185
cb()
186
return
187
188
# created / modified
189
console.log("change.stat.path", change.stat.path)
190
filepath = dropboxToLocalPath(change.stat.path)
191
cache = filecache.get(filepath)
192
if cache?.versionTag == change.stat.versionTag
193
console.log('ignoring because same version')
194
cb()
195
return
196
if change.stat.isFolder
197
console.log('adding folder')
198
mkdirp(filepath, cb)
199
return
200
201
db.readFile change.path, { buffer: true, rev: change.stat.versionTag }, (error, data, stat, rangeInfo) ->
202
if error
203
console.log(error)
204
process.exit(1)
205
if rangeInfo
206
console.log("RangeInfo not supported")
207
process.exit(1)
208
console.log("Read", filepath, data)
209
210
fs.exists path.dirname(filepath), (exists) ->
211
console.log(path.dirname(filepath), "exists")
212
if exists
213
writeFile(filepath, data, stat, cb)
214
else
215
mkdirp path.dirname(filepath), (error) ->
216
console.log("mkdir error", error)
217
writeFile(filepath, data, stat, cb)
218
# TODO: when the Dropbox.Client supports it, pass this in the /delta endpoint.
219
changes = delta.changes.filter (change) ->
220
i = change.path.indexOf(pathPrefix)
221
(i == 0) || (i == 1)
222
223
async.each changes, onDropboxChange, (error) ->
224
if error
225
if cb?
226
cb(error)
227
else
228
throw(error)
229
console.log("Writing cursor", delta.cursor())
230
filecache.set(cursorId, delta.cursor())
231
cb() if cb?
232
233
234
# executed when a local file has changed
235
onLocalFileChange = (db, event, filename) ->
236
console.log("Event", event, "on", filename)
237
filepath = base + '/' + filename
238
# todo: handle permission
239
if event == 'deleted'
240
if filecache.get(filepath)?
241
console.log("nuking", filepath, "from dropbox")
242
db.delete localToDropboxPath(filename), (error) ->
243
throw(error) if error?
244
filecache.rm(filepath)
245
else
246
console.log("file already deleted")
247
else if event == 'changed' || event == 'added'
248
fs.stat filepath, (error, stats) ->
249
throw(error) if error
250
cache = filecache.get(filepath)
251
if stats.isDirectory()
252
unless cache?.isFolder
253
db.mkdir localToDropboxPath(filename), (error) ->
254
throw(error) if error
255
else
256
fs.readFile localToDropboxPath(filepath), (error, data) ->
257
if error
258
console.log("Error", error)
259
process.exit(1)
260
cache = filecache.get(base + '/' + filename)
261
datahash = hash(data)
262
if cache?.hash != datahash
263
console.log("Stale cache. Triggering Dropbox update", cache?.hash, datahash)
264
db.writeFile filename, data, (error, stat) ->
265
throw(error) if error
266
console.log("file written to dropbox")
267
stat.hash = datahash
268
filecache.set(filepath, stat)
269
else
270
console.log("File up-to-date!")
271
272
# wait for the filecache to load, then authenticate and start polling for changes
273
filecache.on 'load', ->
274
client.authenticate (error, client) ->
275
# grab deltas from Dropbox API, and process them with onDropboxChanges
276
grabChanges = (cursor, cb) ->
277
client.pullChanges cursor, (error, delta) ->
278
if error
279
console.log(error)
280
process.exit(1)
281
onDropboxChanges(client, delta, cb)
282
if error
283
console.log(error)
284
process.exit(1)
285
poll = ->
286
callback = (wait) ->
287
(error) ->
288
if error
289
console.log("Error:", error)
290
console.log("will poll in", wait, "ms")
291
setTimeout(poll, wait)
292
293
cursor = filecache.get(cursorId)
294
cursor = null unless cursor?
295
if cursor?
296
# take up to 60 seconds for dropbox to tell us something changed, or loop
297
client.pollForChanges cursor, (error, result) ->
298
console.log('poll for changes returned')
299
throw(error) if error
300
console.log(result)
301
wait = result.retryAfter * 1000
302
if result.hasChanges
303
grabChanges(cursor, callback(wait))
304
else
305
callback(wait)()
306
else
307
# first sync, just call pullChanges
308
grabChanges(null, callback(0))
309
310
cwd = process.cwd()
311
# BUG: gaze won't detect local directory deletions. It throws an exception instead sometimes.
312
console.log("gaze starting")
313
# gaze is an improved version of fs.watch
314
gaze base + '/**/*', (error, watcher) ->
315
throw error if error
316
watcher.on 'all', (event, filepath) ->
317
filepath = filepath.substring(cwd.length + 1 + base.length + 1)
318
onLocalFileChange(client, event, filepath)
319
320
console.log("polling")
321
poll()
322
323