2
# Copyright (C) 2007-2008 The University of Melbourne
4
# This program is free software; you can redistribute it and/or modify
5
# it under the terms of the GNU General Public License as published by
6
# the Free Software Foundation; either version 2 of the License, or
7
# (at your option) any later version.
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
# GNU General Public License for more details.
14
# You should have received a copy of the GNU General Public License
15
# along with this program; if not, write to the Free Software
16
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
18
# Module: File Service / Action
22
# Handles actions requested by the client as part of the 2-stage process of
23
# fileservice (the second part being the return listing).
27
# The most important argument is "action". This determines which action is
28
# taken. Note that action, and all other arguments, are ignored unless the
29
# request is a POST request. The other arguments depend upon the action.
30
# Note that paths are often specified as arguments. Paths that begin with a
31
# slash are taken relative to the user's home directory (the top-level
32
# directory seen when fileservice has no arguments or path). Paths without a
33
# slash are taken relative to the specified path.
35
# action=remove: Delete a file(s) or directory(s) (recursively).
36
# path: The path to the file or directory to delete. Can be specified
39
# action=move: Move or rename a file or directory.
40
# from: The path to the file or directory to be renamed.
41
# to: The path of the target filename. Error if the file already
44
# action=putfile: Upload a file to the student workspace, and optionally
45
# accept zip files which will be unpacked.
46
# path: The path to the file to be written. If it exists, will
47
# overwrite. Error if the target file is a directory.
48
# data: Bytes to be written to the file verbatim. May either be
49
# a string variable or a file upload.
50
# unpack: Optional. If "true", and the data is a valid ZIP file,
51
# will create a directory instead and unpack the ZIP file
54
# action=putfiles: Upload multiple files to the student workspace, and
55
# optionally accept zip files which will be unpacked.
56
# path: The path to the DIRECTORY to place files in. Must not be a
58
# data: A file upload (may not be a simple string). The filename
59
# will be used to determine the target filename within
61
# unpack: Optional. If "true", if any data is a valid ZIP file,
62
# will create a directory instead and unpack the ZIP file
65
# action=mkdir: Create a directory. The parent dir must exist.
66
# path: The path to a file which does not exist, but whose parent
67
# does. The dir will be made with this name.
69
# The differences between putfile and putfiles are:
70
# * putfile can only accept a single file.
71
# * putfile can accept string data, doesn't have to be a file upload.
72
# * putfile ignores the upload filename, the entire filename is specified on
73
# path. putfiles calls files after the name on the user's machine.
75
# action=paste: Copy or move the files to a specified dir.
76
# src: The path to the DIRECTORY to get the files from (relative).
77
# dst: The path to the DIRECTORY to paste the files to. Must not
79
# mode: 'copy' or 'move'
80
# file: File to be copied or moved, relative to src, to a destination
81
# relative to dst. Can be specified multiple times.
84
# action=svnadd: Add an existing file(s) to version control.
85
# path: The path to the file to be added. Can be specified multiple
88
# action=svnrevert: Revert a file(s) to its state as of the current revision
90
# path: The path to the file to be reverted. Can be specified multiple
93
# action=svnupdate: Bring a file up to date with the head revision.
94
# path: The path to the file to be updated. Only one file may be
97
# action=svnpublish: Set the "published" flag on a file to True.
98
# path: The path to the file to be published. Can be specified
101
# action=svnunpublish: Set the "published" flag on a file to False.
102
# path: The path to the file to be unpublished. Can be specified
105
# action=svncommit: Commit a file(s) or directory(s) to the repository.
106
# path: The path to the file or directory to be committed. Can be
107
# specified multiple times. Directories are committed
109
# logmsg: Text of the log message. Optional. There is a default log
110
# message if unspecified.
111
# action=svncheckout: Checkout a file/directory from the repository.
112
# path: The [repository] path to the file or directory to be
115
# TODO: Implement the following actions:
116
# putfiles, svnrevert, svnupdate, svncommit
117
# TODO: Implement ZIP unpacking in putfile and putfiles.
118
# TODO: svnupdate needs a digest to tell the user the files that were updated.
119
# This can be implemented by some message passing between action and
120
# listing, and having the digest included in the listing. (Problem if
121
# the listing is not a directory, but we could make it an error to do an
122
# update if the path is not a directory).
130
from common import (util, studpath, zip)
133
def get_login(_realm, _username, _may_save):
134
"""Return the subversion credentials for the user."""
135
return (True, conf.login, conf.passwd, True)
137
# Make a Subversion client object
138
svnclient = pysvn.Client()
139
svnclient.callback_get_login = get_login
141
DEFAULT_LOGMESSAGE = "No log message supplied."
144
# application/json is the "best" content type but is not good for
145
# debugging because Firefox just tries to download it
146
mime_dirlisting = "text/html"
147
#mime_dirlisting = "application/json"
149
class ActionError(Exception):
150
"""Represents an error processing an action. This can be
151
raised by any of the action functions, and will be caught
152
by the top-level handler, put into the HTTP response field,
155
Important Security Consideration: The message passed to this
156
exception will be relayed to the client.
160
def handle_action(req, action, fields):
161
"""Perform the "action" part of the response.
162
This function should only be called if the response is a POST.
163
This performs the action's side-effect on the server. If unsuccessful,
164
writes the X-IVLE-Action-Error header to the request object. Otherwise,
165
does not touch the request object. Does NOT write any bytes in response.
167
May throw an ActionError. The caller should put this string into the
168
X-IVLE-Action-Error header, and then continue normally.
170
action: String, the action requested. Not sanitised.
171
fields: FieldStorage object containing all arguments passed.
173
global actions_table # Table of function objects
175
action = actions_table[action]
177
# Default, just send an error but then continue
178
raise ActionError("Unknown action")
181
def actionpath_to_urlpath(req, path):
182
"""Determines the URL path (relative to the student home) upon which the
183
action is intended to act. See actionpath_to_local.
187
elif len(path) > 0 and path[0] == os.sep:
188
# Relative to student home
191
# Relative to req.path
192
return os.path.join(req.path, path)
194
def actionpath_to_local(req, path):
195
"""Determines the local path upon which an action is intended to act.
196
Note that fileservice actions accept two paths: the request path,
197
and the "path" argument given to the action.
198
According to the rules, if the "path" argument begins with a '/' it is
199
relative to the user's home; if it does not, it is relative to the
202
This resolves the path, given the request and path argument.
204
May raise an ActionError("Invalid path"). The caller is expected to
205
let this fall through to the top-level handler, where it will be
206
put into the HTTP response field. Never returns None.
210
(_, _, r) = studpath.url_to_jailpaths(actionpath_to_urlpath(req, path))
212
raise ActionError("Invalid path")
215
def movefile(req, frompath, topath, copy=False):
216
"""Performs a file move, resolving filenames, checking for any errors,
217
and throwing ActionErrors if necessary. Can also be used to do a copy
220
frompath and topath are straight paths from the client. Will be checked.
222
# TODO: Do an SVN mv if the file is versioned.
223
# TODO: Disallow tampering with student's home directory
224
if frompath is None or topath is None:
225
raise ActionError("Required field missing")
226
frompath = actionpath_to_local(req, frompath)
227
topath = actionpath_to_local(req, topath)
228
if not os.path.exists(frompath):
229
raise ActionError("The source file does not exist")
230
if os.path.exists(topath):
231
if frompath == topath:
232
raise ActionError("Source and destination are the same")
233
raise ActionError("Another file already exists with that name")
237
if os.path.isdir(frompath):
238
shutil.copytree(frompath, topath)
240
shutil.copy2(frompath, topath)
242
shutil.move(frompath, topath)
244
raise ActionError("Could not move the file specified")
246
raise ActionError("Could not move the file specified")
250
def action_remove(req, fields):
251
# TODO: Do an SVN rm if the file is versioned.
252
# TODO: Disallow removal of student's home directory
253
"""Removes a list of files or directories.
255
Reads fields: 'path' (multiple)
257
paths = fields.getlist('path')
260
path = actionpath_to_local(req, path)
262
if os.path.isdir(path):
272
raise ActionError("Could not delete the file specified")
275
"Could not delete one or more of the files specified")
277
def action_move(req, fields):
278
# TODO: Do an SVN mv if the file is versioned.
279
# TODO: Disallow tampering with student's home directory
280
"""Removes a list of files or directories.
282
Reads fields: 'from', 'to'
284
frompath = fields.getfirst('from')
285
topath = fields.getfirst('to')
286
movefile(req, frompath, topath)
288
def action_mkdir(req, fields):
289
"""Creates a directory with the given path.
292
path = fields.getfirst('path')
294
raise ActionError("Required field missing")
295
path = actionpath_to_local(req, path)
297
# Create the directory
301
raise ActionError("Could not create directory")
303
def action_putfile(req, fields):
304
"""Writes data to a file, overwriting it if it exists and creating it if
307
Reads fields: 'path', 'data' (file upload)
309
# TODO: Read field "unpack".
310
# Important: Data is "None" if the file submitted is empty.
311
path = fields.getfirst('path')
312
data = fields.getfirst('data')
314
raise ActionError("Required field missing")
316
# Workaround - field reader treats "" as None, so this is the only
317
# way to allow blank file uploads
319
path = actionpath_to_local(req, path)
322
data = cStringIO.StringIO(data)
324
# Copy the contents of file object 'data' to the path 'path'
326
dest = open(path, 'wb')
328
shutil.copyfileobj(data, dest)
330
raise ActionError("Could not write to target file")
332
def action_putfiles(req, fields):
333
"""Writes data to one or more files in a directory, overwriting them if
336
Reads fields: 'path', 'data' (file upload, multiple), 'unpack'
339
# Important: Data is "None" if the file submitted is empty.
340
path = fields.getfirst('path')
341
data = fields['data']
342
if type(data) != type([]):
344
unpack = fields.getfirst('unpack')
350
raise ActionError("Required field missing")
351
path = actionpath_to_urlpath(req, path)
356
# Each of the uploaded files
357
filepath = os.path.join(path, datum.filename)
358
filedata = datum.value
360
if unpack and datum.filename.lower().endswith(".zip"):
361
# A zip file - unpack it instead of just copying
362
# TODO: Use the magic number instead of file extension
363
# Note: Just unzip into the current directory (ignore the
366
zip.unzip(path, filedata)
367
except (OSError, IOError):
371
(_, _, filepath_local) = studpath.url_to_jailpaths(filepath)
372
if filepath_local is None:
373
raise ActionError("Invalid path")
375
# Copy the contents of file object 'data' to the path 'path'
377
dest = open(filepath_local, 'wb')
379
shutil.copyfileobj(cStringIO.StringIO(filedata), dest)
385
raise ActionError("Could not write to target file")
388
"Could not write to one or more of the target files")
390
def action_paste(req, fields):
391
"""Performs the copy or move action with the files specified.
392
Copies/moves the files to the specified directory.
394
Reads fields: 'src', 'dst', 'mode', 'file' (multiple).
395
src: Base path that all the files are relative to (source).
396
dst: Destination path to paste into.
397
mode: 'copy' or 'move'.
398
file: (Multiple) Files relative to base, which will be copied
399
or moved to new locations relative to path.
403
dst = fields.getfirst('dst')
404
src = fields.getfirst('src')
405
mode = fields.getfirst('mode')
406
files = fields.getlist('file')
407
if dst is None or src is None or mode is None:
408
raise ActionError("Required field missing")
414
raise ActionError("Invalid mode (must be 'copy' or 'move')")
415
dst_local = actionpath_to_local(req, dst)
416
if not os.path.isdir(dst_local):
417
raise ActionError("dst is not a directory")
421
# The source must not be interpreted as relative to req.path
422
# Add a slash (relative to top-level)
425
frompath = os.path.join(src, file)
426
# The destination is found by taking just the basename of the file
427
topath = os.path.join(dst, os.path.basename(file))
429
movefile(req, frompath, topath, copy)
430
except ActionError, message:
431
# Store the error for later; we want to copy as many as possible
435
# Multiple errors; generic message
436
errormsg = "One or more files could not be pasted"
437
# Add this file to errorfiles; it will be put back on the
438
# clipboard for possible future pasting.
439
errorfiles.append(file)
440
if errormsg is not None:
441
raise ActionError(errormsg)
443
# XXX errorfiles contains a list of files that couldn't be pasted.
444
# we currently do nothing with this.
446
def action_svnadd(req, fields):
447
"""Performs a "svn add" to each file specified.
449
Reads fields: 'path' (multiple)
451
paths = fields.getlist('path')
452
paths = map(lambda path: actionpath_to_local(req, path), paths)
455
svnclient.add(paths, recurse=True, force=True)
456
except pysvn.ClientError:
457
raise ActionError("One or more files could not be added")
459
def action_svnupdate(req, fields):
460
"""Performs a "svn update" to each file specified.
464
path = fields.getfirst('path')
466
raise ActionError("Required field missing")
467
path = actionpath_to_local(req, path)
470
svnclient.update(path, recurse=True)
471
except pysvn.ClientError:
472
raise ActionError("One or more files could not be updated")
474
def action_svnrevert(req, fields):
475
"""Performs a "svn revert" to each file specified.
477
Reads fields: 'path' (multiple)
479
paths = fields.getlist('path')
480
paths = map(lambda path: actionpath_to_local(req, path), paths)
483
svnclient.revert(paths, recurse=True)
484
except pysvn.ClientError:
485
raise ActionError("One or more files could not be reverted")
487
def action_svnpublish(req, fields):
488
"""Sets svn property "ivle:published" on each file specified.
489
Should only be called on directories (only effective on directories
494
paths = fields.getlist('path')
496
paths = map(lambda path: actionpath_to_local(req, path), paths)
498
paths = [studpath.url_to_jailpaths(req.path)[2]]
502
# Note: Property value doesn't matter
503
svnclient.propset("ivle:published", "", path, recurse=False)
504
except pysvn.ClientError, e:
505
raise ActionError("Directory could not be published")
507
def action_svnunpublish(req, fields):
508
"""Deletes svn property "ivle:published" on each file specified.
512
paths = fields.getlist('path')
513
paths = map(lambda path: actionpath_to_local(req, path), paths)
517
svnclient.propdel("ivle:published", path, recurse=False)
518
except pysvn.ClientError:
519
raise ActionError("Directory could not be unpublished")
521
def action_svncommit(req, fields):
522
"""Performs a "svn commit" to each file specified.
524
Reads fields: 'path' (multiple), 'logmsg' (optional)
526
paths = fields.getlist('path')
527
paths = map(lambda path: actionpath_to_local(req, str(path)), paths)
528
logmsg = str(fields.getfirst('logmsg', DEFAULT_LOGMESSAGE))
529
if logmsg == '': logmsg = DEFAULT_LOGMESSAGE
532
svnclient.checkin(paths, logmsg, recurse=True)
533
except pysvn.ClientError:
534
raise ActionError("One or more files could not be committed")
536
def action_svncheckout(req, fields):
537
"""Performs a "svn checkout" of each path specified.
539
Reads fields: 'path' (multiple)
541
paths = fields.getlist('path')
543
raise ActionError("usage: svncheckout url local-path")
544
url = conf.svn_addr + "/" + login + "/" + paths[0]
545
local_path = actionpath_to_local(req, str(paths[1]))
547
svnclient.callback_get_login = get_login
548
svnclient.checkout(url, local_path, recurse=True)
549
except pysvn.ClientError:
550
raise ActionError("One or more files could not be checked out")
552
# Table of all action functions #
553
# Each function has the interface f(req, fields).
556
"remove" : action_remove,
557
"move" : action_move,
558
"mkdir" : action_mkdir,
559
"putfile" : action_putfile,
560
"putfiles" : action_putfiles,
561
"paste" : action_paste,
563
"svnadd" : action_svnadd,
564
"svnupdate" : action_svnupdate,
565
"svnrevert" : action_svnrevert,
566
"svnpublish" : action_svnpublish,
567
"svnunpublish" : action_svnunpublish,
568
"svncommit" : action_svncommit,
569
"svncheckout" : action_svncheckout,