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.
45
# path: The path to the file to be written. Error if the target
46
# file is a directory.
47
# data: Bytes to be written to the file verbatim. May either be
48
# a string variable or a file upload.
49
# overwrite: Optional. If supplied, the file will be overwritten.
50
# Otherwise, error if path already exists.
52
# action=putfiles: Upload multiple files to the student workspace, and
53
# optionally accept zip files which will be unpacked.
54
# path: The path to the DIRECTORY to place files in. Must not be a
56
# data: A file upload (may not be a simple string). The filename
57
# will be used to determine the target filename within
59
# unpack: Optional. If supplied, if any data is a valid ZIP file,
60
# will create a directory instead and unpack the ZIP file
63
# action=mkdir: Create a directory. The parent dir must exist.
64
# path: The path to a file which does not exist, but whose parent
65
# does. The dir will be made with this name.
67
# The differences between putfile and putfiles are:
68
# * putfile can only accept a single file, and can't unpack zipfiles.
69
# * putfile can accept string data, doesn't have to be a file upload.
70
# * putfile ignores the upload filename, the entire filename is specified on
71
# path. putfiles calls files after the name on the user's machine.
73
# action=paste: Copy or move the files to a specified dir.
74
# src: The path to the DIRECTORY to get the files from (relative).
75
# dst: The path to the DIRECTORY to paste the files to. Must not
77
# mode: 'copy' or 'move'
78
# file: File to be copied or moved, relative to src, to a destination
79
# relative to dst. Can be specified multiple times.
82
# action=svnadd: Add an existing file(s) to version control.
83
# path: The path to the file to be added. Can be specified multiple
86
# action=svnrevert: Revert a file(s) to its state as of the current revision
88
# path: The path to the file to be reverted. Can be specified multiple
91
# action=svnupdate: Bring a file up to date with the head revision.
92
# path: The path to the file to be updated. Only one file may be
94
# revision: The revision number to update to. If not provided this
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
# action=svnrepomkdir: Create a directory in a repository (not WC).
116
# path: The path to the directory to be created (under the IVLE
118
# logmsg: Text of the log message.
120
# action=svnrepostat: Check if a path exists in a repository (not WC).
121
# path: The path to the directory to be checked (under the IVLE
124
# action=svncleanup: Recursively clean up the working copy, removing locks,
125
# resuming unfinished operations, etc.
126
# path: The path to the directory to be cleaned
128
# TODO: Implement the following actions:
130
# TODO: Implement ZIP unpacking in putfiles (done?).
131
# TODO: svnupdate needs a digest to tell the user the files that were updated.
132
# This can be implemented by some message passing between action and
133
# listing, and having the digest included in the listing. (Problem if
134
# the listing is not a directory, but we could make it an error to do an
135
# update if the path is not a directory).
144
from ivle import (util, studpath, zip)
145
from ivle.fileservice_lib.exceptions import WillNotOverwrite
149
# Make a Subversion client object (which will log in with this user's
150
# credentials, upon request)
151
svnclient = ivle.svn.create_auth_svn_client(username=ivle.conf.login,
152
password=ivle.conf.svn_pass)
153
svnclient.exception_style = 0 # Simple (string) exceptions
155
DEFAULT_LOGMESSAGE = "No log message supplied."
158
# application/json is the "best" content type but is not good for
159
# debugging because Firefox just tries to download it
160
mime_dirlisting = "text/html"
161
#mime_dirlisting = "application/json"
163
class ActionError(Exception):
164
"""Represents an error processing an action. This can be
165
raised by any of the action functions, and will be caught
166
by the top-level handler, put into the HTTP response field,
169
Important Security Consideration: The message passed to this
170
exception will be relayed to the client.
174
def handle_action(req, action, fields):
175
"""Perform the "action" part of the response.
176
This function should only be called if the response is a POST.
177
This performs the action's side-effect on the server. If unsuccessful,
178
writes the X-IVLE-Action-Error header to the request object. Otherwise,
179
does not touch the request object. Does NOT write any bytes in response.
181
May throw an ActionError. The caller should put this string into the
182
X-IVLE-Action-Error header, and then continue normally.
184
action: String, the action requested. Not sanitised.
185
fields: FieldStorage object containing all arguments passed.
187
global actions_table # Table of function objects
189
action = actions_table[action]
191
# Default, just send an error but then continue
192
raise ActionError("Unknown action")
193
return action(req, fields)
195
def actionpath_to_urlpath(req, path):
196
"""Determines the URL path (relative to the student home) upon which the
197
action is intended to act. See actionpath_to_local.
201
elif len(path) > 0 and path[0] == os.sep:
202
# Relative to student home
205
# Relative to req.path
206
return os.path.join(req.path, path)
208
def actionpath_to_local(req, path):
209
"""Determines the local path upon which an action is intended to act.
210
Note that fileservice actions accept two paths: the request path,
211
and the "path" argument given to the action.
212
According to the rules, if the "path" argument begins with a '/' it is
213
relative to the user's home; if it does not, it is relative to the
216
This resolves the path, given the request and path argument.
218
May raise an ActionError("Invalid path"). The caller is expected to
219
let this fall through to the top-level handler, where it will be
220
put into the HTTP response field. Never returns None.
224
r = studpath.to_home_path(actionpath_to_urlpath(req, path))
226
raise ActionError("Invalid path")
229
def movefile(req, frompath, topath, copy=False):
230
"""Performs a file move, resolving filenames, checking for any errors,
231
and throwing ActionErrors if necessary. Can also be used to do a copy
234
frompath and topath are straight paths from the client. Will be checked.
236
# TODO: Do an SVN mv if the file is versioned.
237
# TODO: Disallow tampering with student's home directory
238
if frompath is None or topath is None:
239
raise ActionError("Required field missing")
240
frompath = actionpath_to_local(req, frompath)
241
topath = actionpath_to_local(req, topath)
242
if not os.path.exists(frompath):
243
raise ActionError("The source file does not exist")
244
if os.path.exists(topath):
245
if frompath == topath:
246
raise ActionError("Source and destination are the same")
247
raise ActionError("A file already exists with that name")
251
if os.path.isdir(frompath):
252
shutil.copytree(frompath, topath)
254
shutil.copy2(frompath, topath)
256
shutil.move(frompath, topath)
258
raise ActionError("Could not move the file specified")
260
raise ActionError("Could not move the file specified")
262
def svn_movefile(req, frompath, topath, copy=False):
263
"""Performs an svn move, resolving filenames, checking for any errors,
264
and throwing ActionErrors if necessary. Can also be used to do a copy
267
frompath and topath are straight paths from the client. Will be checked.
269
if frompath is None or topath is None:
270
raise ActionError("Required field missing")
271
frompath = actionpath_to_local(req, frompath)
272
topath = actionpath_to_local(req, topath)
273
if not os.path.exists(frompath):
274
raise ActionError("The source file does not exist")
275
if os.path.exists(topath):
276
if frompath == topath:
277
raise ActionError("Source and destination are the same")
278
raise ActionError("A file already exists with that name")
282
svnclient.copy(frompath, topath)
284
svnclient.move(frompath, topath)
286
raise ActionError("Could not move the file specified")
287
except pysvn.ClientError:
288
raise ActionError("Could not move the file specified")
293
def action_delete(req, fields):
294
# TODO: Disallow removal of student's home directory
295
"""Removes a list of files or directories.
297
Reads fields: 'path' (multiple)
299
paths = fields.getlist('path')
302
path = actionpath_to_local(req, path)
304
if os.path.isdir(path):
314
raise ActionError("Could not delete the file specified")
317
"Could not delete one or more of the files specified")
319
def action_move(req, fields):
320
# TODO: Do an SVN mv if the file is versioned.
321
# TODO: Disallow tampering with student's home directory
322
"""Removes a list of files or directories.
324
Reads fields: 'from', 'to'
326
frompath = fields.getfirst('from')
327
topath = fields.getfirst('to')
328
movefile(req, frompath, topath)
330
def action_mkdir(req, fields):
331
"""Creates a directory with the given path.
334
path = fields.getfirst('path')
336
raise ActionError("Required field missing")
337
path = actionpath_to_local(req, path)
339
if os.path.exists(path):
340
raise ActionError("A file already exists with that name")
342
# Create the directory
346
raise ActionError("Could not create directory")
348
def action_putfile(req, fields):
349
"""Writes data to a file, overwriting it if it exists and creating it if
352
Reads fields: 'path', 'data' (file upload), 'overwrite'
354
# TODO: Read field "unpack".
355
# Important: Data is "None" if the file submitted is empty.
356
path = fields.getfirst('path')
357
data = fields.getfirst('data')
359
raise ActionError("Required field missing")
361
# Workaround - field reader treats "" as None, so this is the only
362
# way to allow blank file uploads
364
path = actionpath_to_local(req, path)
367
data = cStringIO.StringIO(data)
369
overwrite = fields.getfirst('overwrite')
370
if overwrite is None:
376
# Overwrite files; but can't if it's a directory
377
if os.path.isdir(path):
378
raise ActionError("A directory already exists "
381
if os.path.exists(path):
382
raise ActionError("A file already exists with that name")
384
# Copy the contents of file object 'data' to the path 'path'
386
dest = open(path, 'wb')
388
shutil.copyfileobj(data, dest)
389
except (IOError, OSError), e:
390
raise ActionError("Could not write to target file: %s" % e.strerror)
392
def action_putfiles(req, fields):
393
"""Writes data to one or more files in a directory, overwriting them if
396
Reads fields: 'path', 'data' (file upload, multiple), 'unpack'
398
# Important: Data is "None" if the file submitted is empty.
399
path = fields.getfirst('path')
400
data = fields['data']
401
if type(data) != type([]):
403
unpack = fields.getfirst('unpack')
409
raise ActionError("Required field missing")
410
path = actionpath_to_urlpath(req, path)
414
# Each of the uploaded files
415
filepath = os.path.join(path, datum.filename)
416
filepath_local = studpath.to_home_path(filepath)
417
if os.path.isdir(filepath_local):
418
raise ActionError("A directory already exists "
421
if os.path.exists(filepath_local):
422
raise ActionError("A file already exists with that name")
423
filedata = datum.file
425
if unpack and datum.filename.lower().endswith(".zip"):
426
# A zip file - unpack it instead of just copying
427
# TODO: Use the magic number instead of file extension
428
# Note: Just unzip into the current directory (ignore the
431
# First get the entire path (within jail)
432
abspath = studpath.to_home_path(path)
433
abspath = os.path.join(os.sep, abspath)
434
zip.unzip(abspath, filedata)
435
except (OSError, IOError):
437
except WillNotOverwrite, e:
438
raise ActionError("File '" + e.filename + "' already exists.")
441
filepath_local = studpath.to_home_path(filepath)
442
if filepath_local is None:
443
raise ActionError("Invalid path")
445
# Copy the contents of file object 'data' to the path 'path'
447
dest = open(filepath_local, 'wb')
449
shutil.copyfileobj(filedata, dest)
450
except (OSError, IOError):
451
# TODO: Be more descriptive.
456
raise ActionError("Could not write to target file")
459
"Could not write to one or more of the target files")
461
def action_paste(req, fields):
462
"""Performs the copy or move action with the files specified.
463
Copies/moves the files to the specified directory.
465
Reads fields: 'src', 'dst', 'mode', 'file' (multiple).
466
src: Base path that all the files are relative to (source).
467
dst: Destination path to paste into.
468
mode: 'copy' or 'move'.
469
file: (Multiple) Files relative to base, which will be copied
470
or moved to new locations relative to path.
474
dst = fields.getfirst('dst')
475
src = fields.getfirst('src')
476
mode = fields.getfirst('mode')
477
files = fields.getlist('file')
478
if dst is None or src is None or mode is None:
479
raise ActionError("Required field missing")
481
dst_local = actionpath_to_local(req, dst)
482
if not os.path.isdir(dst_local):
483
raise ActionError("dst is not a directory")
487
# The source must not be interpreted as relative to req.path
488
# Add a slash (relative to top-level)
491
frompath = os.path.join(src, file)
492
# The destination is found by taking just the basename of the file
493
topath = os.path.join(dst, os.path.basename(file))
496
movefile(req, frompath, topath, True)
498
movefile(req, frompath, topath, False)
499
elif mode == "svncopy":
500
svn_movefile(req, frompath, topath, True)
501
elif mode == "svnmove":
502
svn_movefile(req, frompath, topath, False)
504
raise ActionError("Invalid mode (must be '(svn)copy' or '(svn)move')")
505
except ActionError, message:
506
# Store the error for later; we want to copy as many as possible
510
# Multiple errors; generic message
511
errormsg = "One or more files could not be pasted"
512
# Add this file to errorfiles; it will be put back on the
513
# clipboard for possible future pasting.
514
errorfiles.append(file)
515
if errormsg is not None:
516
raise ActionError(errormsg)
518
# XXX errorfiles contains a list of files that couldn't be pasted.
519
# we currently do nothing with this.
521
def action_publish(req,fields):
522
"""Marks the folder as published by adding a '.published' file to the
523
directory and ensuring that the parent directory permissions are correct
527
paths = fields.getlist('path')
528
user = util.split_path(req.path)[0]
529
homedir = "/home/%s" % user
531
paths = map(lambda path: actionpath_to_local(req, path), paths)
533
paths = [studpath.to_home_path(req.path)]
535
# Set all the dirs in home dir world browsable (o+r,o+x)
536
#FIXME: Should really only do those in the direct path not all of the
537
# folders in a students home directory
538
for root,dirs,files in os.walk(homedir):
539
os.chmod(root, os.stat(root).st_mode|0005)
543
if os.path.isdir(path):
544
pubfile = open(os.path.join(path,'.published'),'w')
545
pubfile.write("This directory is published\n")
548
raise ActionError("Can only publish directories")
550
raise ActionError("Directory could not be published")
552
def action_unpublish(req,fields):
553
"""Marks the folder as unpublished by removing a '.published' file in the
554
directory (if it exits). It does not change the permissions of the parent
559
paths = fields.getlist('path')
561
paths = map(lambda path: actionpath_to_local(req, path), paths)
563
paths = [studpath.to_home_path(req.path)]
567
if os.path.isdir(path):
568
pubfile = os.path.join(path,'.published')
569
if os.path.isfile(pubfile):
572
raise ActionError("Can only unpublish directories")
574
raise ActionError("Directory could not be unpublished")
577
def action_svnadd(req, fields):
578
"""Performs a "svn add" to each file specified.
580
Reads fields: 'path' (multiple)
582
paths = fields.getlist('path')
583
paths = map(lambda path: actionpath_to_local(req, path), paths)
586
svnclient.add(paths, recurse=True, force=True)
587
except pysvn.ClientError, e:
588
raise ActionError(str(e))
590
def action_svnremove(req, fields):
591
"""Performs a "svn remove" on each file specified.
593
Reads fields: 'path' (multiple)
595
paths = fields.getlist('path')
596
paths = map(lambda path: actionpath_to_local(req, path), paths)
599
svnclient.remove(paths, force=True)
600
except pysvn.ClientError, e:
601
raise ActionError(str(e))
603
def action_svnupdate(req, fields):
604
"""Performs a "svn update" to each file specified.
606
Reads fields: 'path' and 'revision'
608
path = fields.getfirst('path')
609
revision = fields.getfirst('revision')
611
raise ActionError("Required field missing")
613
revision = pysvn.Revision( pysvn.opt_revision_kind.head )
616
revision = pysvn.Revision(pysvn.opt_revision_kind.number,
618
except ValueError, e:
619
raise ActionError("Bad revision number: '%s'"%revision,)
620
path = actionpath_to_local(req, path)
623
svnclient.update(path, recurse=True, revision=revision)
624
except pysvn.ClientError, e:
625
raise ActionError(str(e))
627
def action_svnresolved(req, fields):
628
"""Performs a "svn resolved" to each file specified.
632
path = fields.getfirst('path')
634
raise ActionError("Required field missing")
635
path = actionpath_to_local(req, path)
638
svnclient.resolved(path, recurse=True)
639
except pysvn.ClientError, e:
640
raise ActionError(str(e))
642
def action_svnrevert(req, fields):
643
"""Performs a "svn revert" to each file specified.
645
Reads fields: 'path' (multiple)
647
paths = fields.getlist('path')
648
paths = map(lambda path: actionpath_to_local(req, path), paths)
651
svnclient.revert(paths, recurse=True)
652
except pysvn.ClientError, e:
653
raise ActionError(str(e))
655
def action_svnpublish(req, fields):
656
"""Sets svn property "ivle:published" on each file specified.
657
Should only be called on directories (only effective on directories
662
XXX Currently unused by the client (calls action_publish instead, which
663
has a completely different publishing model).
665
paths = fields.getlist('path')
667
paths = map(lambda path: actionpath_to_local(req, path), paths)
669
paths = [studpath.to_home_path(req.path)]
673
# Note: Property value doesn't matter
674
svnclient.propset("ivle:published", "", path, recurse=False)
675
except pysvn.ClientError, e:
676
raise ActionError("Directory could not be published")
678
def action_svnunpublish(req, fields):
679
"""Deletes svn property "ivle:published" on each file specified.
683
XXX Currently unused by the client (calls action_unpublish instead, which
684
has a completely different publishing model).
686
paths = fields.getlist('path')
687
paths = map(lambda path: actionpath_to_local(req, path), paths)
691
svnclient.propdel("ivle:published", path, recurse=False)
692
except pysvn.ClientError, e:
693
raise ActionError("Directory could not be unpublished")
695
def action_svncommit(req, fields):
696
"""Performs a "svn commit" to each file specified.
698
Reads fields: 'path' (multiple), 'logmsg' (optional)
700
paths = fields.getlist('path')
701
paths = map(lambda path: actionpath_to_local(req, str(path)), paths)
702
logmsg = str(fields.getfirst('logmsg', DEFAULT_LOGMESSAGE))
703
if logmsg == '': logmsg = DEFAULT_LOGMESSAGE
706
svnclient.checkin(paths, logmsg, recurse=True)
707
except pysvn.ClientError, e:
708
raise ActionError(str(e))
710
def action_svncheckout(req, fields):
711
"""Performs a "svn checkout" of the first path into the second path.
713
Reads fields: 'path' (multiple)
715
paths = fields.getlist('path')
717
raise ActionError("usage: svncheckout url local-path")
718
url = ivle.conf.svn_addr + "/" + urllib.quote(paths[0])
719
local_path = actionpath_to_local(req, str(paths[1]))
721
svnclient.checkout(url, local_path, recurse=True)
722
except pysvn.ClientError, e:
723
raise ActionError(str(e))
725
def action_svnrepomkdir(req, fields):
726
"""Performs a "svn mkdir" on a path under the IVLE SVN root.
730
path = fields.getfirst('path')
731
logmsg = fields.getfirst('logmsg')
732
url = ivle.conf.svn_addr + "/" + urllib.quote(path)
734
svnclient.mkdir(url, log_message=logmsg)
735
except pysvn.ClientError, e:
736
raise ActionError(str(e))
738
def action_svnrepostat(req, fields):
739
"""Discovers whether a path exists in a repo under the IVLE SVN root.
741
If it does exist, returns a dict containing its metadata.
745
path = fields.getfirst('path')
746
url = ivle.conf.svn_addr + "/" + urllib.quote(path)
747
svnclient.exception_style = 1
750
info = svnclient.info2(url,
751
revision=pysvn.Revision(pysvn.opt_revision_kind.head))[0][1]
752
return {'svnrevision': info['rev'].number
754
info['rev'].kind == pysvn.opt_revision_kind.number
756
except pysvn.ClientError, e:
757
# Error code 170000 means ENOENT in this revision.
758
if e[1][0][1] == 170000:
759
raise util.IVLEError(404, 'The specified repository path does not exist')
761
raise ActionError(str(e[0]))
764
def action_svncleanup(req, fields):
765
"""Recursively clean up the working copy, removing locks, resuming
766
unfinished operations, etc.
767
path: The path to be cleaned"""
769
path = fields.getfirst('path')
771
raise ActionError("Required field missing")
772
path = actionpath_to_local(req, path)
775
svnclient.cleanup(path)
776
except pysvn.ClientError, e:
777
raise ActionError(str(e))
780
# Table of all action functions #
781
# Each function has the interface f(req, fields).
784
"delete" : action_delete,
785
"move" : action_move,
786
"mkdir" : action_mkdir,
787
"putfile" : action_putfile,
788
"putfiles" : action_putfiles,
789
"paste" : action_paste,
790
"publish" : action_publish,
791
"unpublish" : action_unpublish,
793
"svnadd" : action_svnadd,
794
"svnremove" : action_svnremove,
795
"svnupdate" : action_svnupdate,
796
"svnresolved" : action_svnresolved,
797
"svnrevert" : action_svnrevert,
798
"svnpublish" : action_svnpublish,
799
"svnunpublish" : action_svnunpublish,
800
"svncommit" : action_svncommit,
801
"svncheckout" : action_svncheckout,
802
"svnrepomkdir" : action_svnrepomkdir,
803
"svnrepostat" : action_svnrepostat,
804
"svncleanup" : action_svncleanup,