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
def get_login(_realm, existing_login, _may_save):
150
"""Callback function used by pysvn for authentication.
151
realm, existing_login, _may_save: The 3 arguments passed by pysvn to
153
The following has been determined empirically, not from docs:
154
existing_login will be the name of the user who owns the process on
155
the first attempt, "" on subsequent attempts. We use this fact.
157
# Only provide credentials on the _first_ attempt.
158
# If we're being asked again, then it means the credentials failed for
159
# some reason and we should just fail. (This is not desirable, but it's
160
# better than being asked an infinite number of times).
161
return (existing_login != "", str(ivle.conf.login),
162
str(ivle.conf.svn_pass), True)
164
# Make a Subversion client object
165
svnclient = pysvn.Client()
166
svnclient.callback_get_login = get_login
167
svnclient.exception_style = 0 # Simple (string) exceptions
169
DEFAULT_LOGMESSAGE = "No log message supplied."
172
# application/json is the "best" content type but is not good for
173
# debugging because Firefox just tries to download it
174
mime_dirlisting = "text/html"
175
#mime_dirlisting = "application/json"
177
class ActionError(Exception):
178
"""Represents an error processing an action. This can be
179
raised by any of the action functions, and will be caught
180
by the top-level handler, put into the HTTP response field,
183
Important Security Consideration: The message passed to this
184
exception will be relayed to the client.
188
def handle_action(req, action, fields):
189
"""Perform the "action" part of the response.
190
This function should only be called if the response is a POST.
191
This performs the action's side-effect on the server. If unsuccessful,
192
writes the X-IVLE-Action-Error header to the request object. Otherwise,
193
does not touch the request object. Does NOT write any bytes in response.
195
May throw an ActionError. The caller should put this string into the
196
X-IVLE-Action-Error header, and then continue normally.
198
action: String, the action requested. Not sanitised.
199
fields: FieldStorage object containing all arguments passed.
201
global actions_table # Table of function objects
203
action = actions_table[action]
205
# Default, just send an error but then continue
206
raise ActionError("Unknown action")
207
return action(req, fields)
209
def actionpath_to_urlpath(req, path):
210
"""Determines the URL path (relative to the student home) upon which the
211
action is intended to act. See actionpath_to_local.
215
elif len(path) > 0 and path[0] == os.sep:
216
# Relative to student home
219
# Relative to req.path
220
return os.path.join(req.path, path)
222
def actionpath_to_local(req, path):
223
"""Determines the local path upon which an action is intended to act.
224
Note that fileservice actions accept two paths: the request path,
225
and the "path" argument given to the action.
226
According to the rules, if the "path" argument begins with a '/' it is
227
relative to the user's home; if it does not, it is relative to the
230
This resolves the path, given the request and path argument.
232
May raise an ActionError("Invalid path"). The caller is expected to
233
let this fall through to the top-level handler, where it will be
234
put into the HTTP response field. Never returns None.
238
r = studpath.to_home_path(actionpath_to_urlpath(req, path))
240
raise ActionError("Invalid path")
243
def movefile(req, frompath, topath, copy=False):
244
"""Performs a file move, resolving filenames, checking for any errors,
245
and throwing ActionErrors if necessary. Can also be used to do a copy
248
frompath and topath are straight paths from the client. Will be checked.
250
# TODO: Do an SVN mv if the file is versioned.
251
# TODO: Disallow tampering with student's home directory
252
if frompath is None or topath is None:
253
raise ActionError("Required field missing")
254
frompath = actionpath_to_local(req, frompath)
255
topath = actionpath_to_local(req, topath)
256
if not os.path.exists(frompath):
257
raise ActionError("The source file does not exist")
258
if os.path.exists(topath):
259
if frompath == topath:
260
raise ActionError("Source and destination are the same")
261
raise ActionError("A file already exists with that name")
265
if os.path.isdir(frompath):
266
shutil.copytree(frompath, topath)
268
shutil.copy2(frompath, topath)
270
shutil.move(frompath, topath)
272
raise ActionError("Could not move the file specified")
274
raise ActionError("Could not move the file specified")
276
def svn_movefile(req, frompath, topath, copy=False):
277
"""Performs an svn move, resolving filenames, checking for any errors,
278
and throwing ActionErrors if necessary. Can also be used to do a copy
281
frompath and topath are straight paths from the client. Will be checked.
283
if frompath is None or topath is None:
284
raise ActionError("Required field missing")
285
frompath = actionpath_to_local(req, frompath)
286
topath = actionpath_to_local(req, topath)
287
if not os.path.exists(frompath):
288
raise ActionError("The source file does not exist")
289
if os.path.exists(topath):
290
if frompath == topath:
291
raise ActionError("Source and destination are the same")
292
raise ActionError("A file already exists with that name")
296
svnclient.copy(frompath, topath)
298
svnclient.move(frompath, topath)
300
raise ActionError("Could not move the file specified")
301
except pysvn.ClientError:
302
raise ActionError("Could not move the file specified")
307
def action_delete(req, fields):
308
# TODO: Disallow removal of student's home directory
309
"""Removes a list of files or directories.
311
Reads fields: 'path' (multiple)
313
paths = fields.getlist('path')
316
path = actionpath_to_local(req, path)
318
if os.path.isdir(path):
328
raise ActionError("Could not delete the file specified")
331
"Could not delete one or more of the files specified")
333
def action_move(req, fields):
334
# TODO: Do an SVN mv if the file is versioned.
335
# TODO: Disallow tampering with student's home directory
336
"""Removes a list of files or directories.
338
Reads fields: 'from', 'to'
340
frompath = fields.getfirst('from')
341
topath = fields.getfirst('to')
342
movefile(req, frompath, topath)
344
def action_mkdir(req, fields):
345
"""Creates a directory with the given path.
348
path = fields.getfirst('path')
350
raise ActionError("Required field missing")
351
path = actionpath_to_local(req, path)
353
if os.path.exists(path):
354
raise ActionError("A file already exists with that name")
356
# Create the directory
360
raise ActionError("Could not create directory")
362
def action_putfile(req, fields):
363
"""Writes data to a file, overwriting it if it exists and creating it if
366
Reads fields: 'path', 'data' (file upload), 'overwrite'
368
# TODO: Read field "unpack".
369
# Important: Data is "None" if the file submitted is empty.
370
path = fields.getfirst('path')
371
data = fields.getfirst('data')
373
raise ActionError("Required field missing")
375
# Workaround - field reader treats "" as None, so this is the only
376
# way to allow blank file uploads
378
path = actionpath_to_local(req, path)
381
data = cStringIO.StringIO(data)
383
overwrite = fields.getfirst('overwrite')
384
if overwrite is None:
390
# Overwrite files; but can't if it's a directory
391
if os.path.isdir(path):
392
raise ActionError("A directory already exists "
395
if os.path.exists(path):
396
raise ActionError("A file already exists with that name")
398
# Copy the contents of file object 'data' to the path 'path'
400
dest = open(path, 'wb')
402
shutil.copyfileobj(data, dest)
403
except (IOError, OSError), e:
404
raise ActionError("Could not write to target file: %s" % e.strerror)
406
def action_putfiles(req, fields):
407
"""Writes data to one or more files in a directory, overwriting them if
410
Reads fields: 'path', 'data' (file upload, multiple), 'unpack'
412
# Important: Data is "None" if the file submitted is empty.
413
path = fields.getfirst('path')
414
data = fields['data']
415
if type(data) != type([]):
417
unpack = fields.getfirst('unpack')
423
raise ActionError("Required field missing")
424
path = actionpath_to_urlpath(req, path)
428
# Each of the uploaded files
429
filepath = os.path.join(path, datum.filename)
430
filepath_local = studpath.to_home_path(filepath)
431
if os.path.isdir(filepath_local):
432
raise ActionError("A directory already exists "
435
if os.path.exists(filepath_local):
436
raise ActionError("A file already exists with that name")
437
filedata = datum.file
439
if unpack and datum.filename.lower().endswith(".zip"):
440
# A zip file - unpack it instead of just copying
441
# TODO: Use the magic number instead of file extension
442
# Note: Just unzip into the current directory (ignore the
445
# First get the entire path (within jail)
446
abspath = studpath.to_home_path(path)
447
abspath = os.path.join(os.sep, abspath)
448
zip.unzip(abspath, filedata)
449
except (OSError, IOError):
451
except WillNotOverwrite, e:
452
raise ActionError("File '" + e.filename + "' already exists.")
455
filepath_local = studpath.to_home_path(filepath)
456
if filepath_local is None:
457
raise ActionError("Invalid path")
459
# Copy the contents of file object 'data' to the path 'path'
461
dest = open(filepath_local, 'wb')
463
shutil.copyfileobj(filedata, dest)
464
except (OSError, IOError):
465
# TODO: Be more descriptive.
470
raise ActionError("Could not write to target file")
473
"Could not write to one or more of the target files")
475
def action_paste(req, fields):
476
"""Performs the copy or move action with the files specified.
477
Copies/moves the files to the specified directory.
479
Reads fields: 'src', 'dst', 'mode', 'file' (multiple).
480
src: Base path that all the files are relative to (source).
481
dst: Destination path to paste into.
482
mode: 'copy' or 'move'.
483
file: (Multiple) Files relative to base, which will be copied
484
or moved to new locations relative to path.
488
dst = fields.getfirst('dst')
489
src = fields.getfirst('src')
490
mode = fields.getfirst('mode')
491
files = fields.getlist('file')
492
if dst is None or src is None or mode is None:
493
raise ActionError("Required field missing")
495
dst_local = actionpath_to_local(req, dst)
496
if not os.path.isdir(dst_local):
497
raise ActionError("dst is not a directory")
501
# The source must not be interpreted as relative to req.path
502
# Add a slash (relative to top-level)
505
frompath = os.path.join(src, file)
506
# The destination is found by taking just the basename of the file
507
topath = os.path.join(dst, os.path.basename(file))
510
movefile(req, frompath, topath, True)
512
movefile(req, frompath, topath, False)
513
elif mode == "svncopy":
514
svn_movefile(req, frompath, topath, True)
515
elif mode == "svnmove":
516
svn_movefile(req, frompath, topath, False)
518
raise ActionError("Invalid mode (must be '(svn)copy' or '(svn)move')")
519
except ActionError, message:
520
# Store the error for later; we want to copy as many as possible
524
# Multiple errors; generic message
525
errormsg = "One or more files could not be pasted"
526
# Add this file to errorfiles; it will be put back on the
527
# clipboard for possible future pasting.
528
errorfiles.append(file)
529
if errormsg is not None:
530
raise ActionError(errormsg)
532
# XXX errorfiles contains a list of files that couldn't be pasted.
533
# we currently do nothing with this.
535
def action_publish(req,fields):
536
"""Marks the folder as published by adding a '.published' file to the
537
directory and ensuring that the parent directory permissions are correct
541
paths = fields.getlist('path')
542
user = util.split_path(req.path)[0]
543
homedir = "/home/%s" % user
545
paths = map(lambda path: actionpath_to_local(req, path), paths)
547
paths = [studpath.to_home_path(req.path)]
549
# Set all the dirs in home dir world browsable (o+r,o+x)
550
#FIXME: Should really only do those in the direct path not all of the
551
# folders in a students home directory
552
for root,dirs,files in os.walk(homedir):
553
os.chmod(root, os.stat(root).st_mode|0005)
557
if os.path.isdir(path):
558
pubfile = open(os.path.join(path,'.published'),'w')
559
pubfile.write("This directory is published\n")
562
raise ActionError("Can only publish directories")
564
raise ActionError("Directory could not be published")
566
def action_unpublish(req,fields):
567
"""Marks the folder as unpublished by removing a '.published' file in the
568
directory (if it exits). It does not change the permissions of the parent
573
paths = fields.getlist('path')
575
paths = map(lambda path: actionpath_to_local(req, path), paths)
577
paths = [studpath.to_home_path(req.path)]
581
if os.path.isdir(path):
582
pubfile = os.path.join(path,'.published')
583
if os.path.isfile(pubfile):
586
raise ActionError("Can only unpublish directories")
588
raise ActionError("Directory could not be unpublished")
591
def action_svnadd(req, fields):
592
"""Performs a "svn add" to each file specified.
594
Reads fields: 'path' (multiple)
596
paths = fields.getlist('path')
597
paths = map(lambda path: actionpath_to_local(req, path), paths)
600
svnclient.add(paths, recurse=True, force=True)
601
except pysvn.ClientError, e:
602
raise ActionError(str(e))
604
def action_svnremove(req, fields):
605
"""Performs a "svn remove" on each file specified.
607
Reads fields: 'path' (multiple)
609
paths = fields.getlist('path')
610
paths = map(lambda path: actionpath_to_local(req, path), paths)
613
svnclient.remove(paths, force=True)
614
except pysvn.ClientError, e:
615
raise ActionError(str(e))
617
def action_svnupdate(req, fields):
618
"""Performs a "svn update" to each file specified.
620
Reads fields: 'path' and 'revision'
622
path = fields.getfirst('path')
623
revision = fields.getfirst('revision')
625
raise ActionError("Required field missing")
627
revision = pysvn.Revision( pysvn.opt_revision_kind.head )
630
revision = pysvn.Revision(pysvn.opt_revision_kind.number,
632
except ValueError, e:
633
raise ActionError("Bad revision number: '%s'"%revision,)
634
path = actionpath_to_local(req, path)
637
svnclient.update(path, recurse=True, revision=revision)
638
except pysvn.ClientError, e:
639
raise ActionError(str(e))
641
def action_svnresolved(req, fields):
642
"""Performs a "svn resolved" to each file specified.
646
path = fields.getfirst('path')
648
raise ActionError("Required field missing")
649
path = actionpath_to_local(req, path)
652
svnclient.resolved(path, recurse=True)
653
except pysvn.ClientError, e:
654
raise ActionError(str(e))
656
def action_svnrevert(req, fields):
657
"""Performs a "svn revert" to each file specified.
659
Reads fields: 'path' (multiple)
661
paths = fields.getlist('path')
662
paths = map(lambda path: actionpath_to_local(req, path), paths)
665
svnclient.revert(paths, recurse=True)
666
except pysvn.ClientError, e:
667
raise ActionError(str(e))
669
def action_svnpublish(req, fields):
670
"""Sets svn property "ivle:published" on each file specified.
671
Should only be called on directories (only effective on directories
676
XXX Currently unused by the client (calls action_publish instead, which
677
has a completely different publishing model).
679
paths = fields.getlist('path')
681
paths = map(lambda path: actionpath_to_local(req, path), paths)
683
paths = [studpath.to_home_path(req.path)]
687
# Note: Property value doesn't matter
688
svnclient.propset("ivle:published", "", path, recurse=False)
689
except pysvn.ClientError, e:
690
raise ActionError("Directory could not be published")
692
def action_svnunpublish(req, fields):
693
"""Deletes svn property "ivle:published" on each file specified.
697
XXX Currently unused by the client (calls action_unpublish instead, which
698
has a completely different publishing model).
700
paths = fields.getlist('path')
701
paths = map(lambda path: actionpath_to_local(req, path), paths)
705
svnclient.propdel("ivle:published", path, recurse=False)
706
except pysvn.ClientError, e:
707
raise ActionError("Directory could not be unpublished")
709
def action_svncommit(req, fields):
710
"""Performs a "svn commit" to each file specified.
712
Reads fields: 'path' (multiple), 'logmsg' (optional)
714
paths = fields.getlist('path')
715
paths = map(lambda path: actionpath_to_local(req, str(path)), paths)
716
logmsg = str(fields.getfirst('logmsg', DEFAULT_LOGMESSAGE))
717
if logmsg == '': logmsg = DEFAULT_LOGMESSAGE
720
svnclient.checkin(paths, logmsg, recurse=True)
721
except pysvn.ClientError, e:
722
raise ActionError(str(e))
724
def action_svncheckout(req, fields):
725
"""Performs a "svn checkout" of the first path into the second path.
727
Reads fields: 'path' (multiple)
729
paths = fields.getlist('path')
731
raise ActionError("usage: svncheckout url local-path")
732
url = ivle.conf.svn_addr + "/" + urllib.quote(paths[0])
733
local_path = actionpath_to_local(req, str(paths[1]))
735
svnclient.callback_get_login = get_login
736
svnclient.checkout(url, local_path, recurse=True)
737
except pysvn.ClientError, e:
738
raise ActionError(str(e))
740
def action_svnrepomkdir(req, fields):
741
"""Performs a "svn mkdir" on a path under the IVLE SVN root.
745
path = fields.getfirst('path')
746
logmsg = fields.getfirst('logmsg')
747
url = ivle.conf.svn_addr + "/" + path
749
svnclient.callback_get_login = get_login
750
svnclient.mkdir(url, log_message=logmsg)
751
except pysvn.ClientError, e:
752
raise ActionError(str(e))
754
def action_svnrepostat(req, fields):
755
"""Discovers whether a path exists in a repo under the IVLE SVN root.
757
If it does exist, returns a dict containing its metadata.
761
path = fields.getfirst('path')
762
url = ivle.conf.svn_addr + "/" + path
763
svnclient.exception_style = 1
766
svnclient.callback_get_login = get_login
767
info = svnclient.info2(url,
768
revision=pysvn.Revision(pysvn.opt_revision_kind.head))[0][1]
769
return {'svnrevision': info['rev'].number
771
info['rev'].kind == pysvn.opt_revision_kind.number
773
except pysvn.ClientError, e:
774
# Error code 170000 means ENOENT in this revision.
775
if e[1][0][1] == 170000:
776
raise util.IVLEError(404, 'The specified repository path does not exist')
778
raise ActionError(str(e[0]))
781
def action_svncleanup(req, fields):
782
"""Recursively clean up the working copy, removing locks, resuming
783
unfinished operations, etc.
784
path: The path to be cleaned"""
786
path = fields.getfirst('path')
788
raise ActionError("Required field missing")
789
path = actionpath_to_local(req, path)
792
svnclient.cleanup(path)
793
except pysvn.ClientError, e:
794
raise ActionError(str(e))
797
# Table of all action functions #
798
# Each function has the interface f(req, fields).
801
"delete" : action_delete,
802
"move" : action_move,
803
"mkdir" : action_mkdir,
804
"putfile" : action_putfile,
805
"putfiles" : action_putfiles,
806
"paste" : action_paste,
807
"publish" : action_publish,
808
"unpublish" : action_unpublish,
810
"svnadd" : action_svnadd,
811
"svnremove" : action_svnremove,
812
"svnupdate" : action_svnupdate,
813
"svnresolved" : action_svnresolved,
814
"svnrevert" : action_svnrevert,
815
"svnpublish" : action_svnpublish,
816
"svnunpublish" : action_svnunpublish,
817
"svncommit" : action_svncommit,
818
"svncheckout" : action_svncheckout,
819
"svnrepomkdir" : action_svnrepomkdir,
820
"svnrepostat" : action_svnrepostat,
821
"svncleanup" : action_svncleanup,