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
95
# action=svnpublish: Set the "published" flag on a file to True.
96
# path: The path to the file to be published. Can be specified
99
# action=svnunpublish: Set the "published" flag on a file to False.
100
# path: The path to the file to be unpublished. Can be specified
103
# action=svncommit: Commit a file(s) or directory(s) to the repository.
104
# path: The path to the file or directory to be committed. Can be
105
# specified multiple times. Directories are committed
107
# logmsg: Text of the log message. Optional. There is a default log
108
# message if unspecified.
109
# action=svncheckout: Checkout a file/directory from the repository.
110
# path: The [repository] path to the file or directory to be
113
# action=svnrepomkdir: Create a directory in a repository (not WC).
114
# path: The path to the directory to be created (under the IVLE
116
# logmsg: Text of the log message.
118
# action=svnrepostat: Check if a path exists in a repository (not WC).
119
# path: The path to the directory to be checked (under the IVLE
122
# TODO: Implement the following actions:
124
# TODO: Implement ZIP unpacking in putfiles (done?).
125
# TODO: svnupdate needs a digest to tell the user the files that were updated.
126
# This can be implemented by some message passing between action and
127
# listing, and having the digest included in the listing. (Problem if
128
# the listing is not a directory, but we could make it an error to do an
129
# update if the path is not a directory).
137
from ivle import (util, studpath, zip)
138
from ivle.fileservice_lib.exceptions import WillNotOverwrite
142
def get_login(_realm, existing_login, _may_save):
143
"""Callback function used by pysvn for authentication.
144
realm, existing_login, _may_save: The 3 arguments passed by pysvn to
146
The following has been determined empirically, not from docs:
147
existing_login will be the name of the user who owns the process on
148
the first attempt, "" on subsequent attempts. We use this fact.
150
# Only provide credentials on the _first_ attempt.
151
# If we're being asked again, then it means the credentials failed for
152
# some reason and we should just fail. (This is not desirable, but it's
153
# better than being asked an infinite number of times).
154
return (existing_login != "", str(ivle.conf.login),
155
str(ivle.conf.svn_pass), True)
157
# Make a Subversion client object
158
svnclient = pysvn.Client()
159
svnclient.callback_get_login = get_login
160
svnclient.exception_style = 0 # Simple (string) exceptions
162
DEFAULT_LOGMESSAGE = "No log message supplied."
165
# application/json is the "best" content type but is not good for
166
# debugging because Firefox just tries to download it
167
mime_dirlisting = "text/html"
168
#mime_dirlisting = "application/json"
170
class ActionError(Exception):
171
"""Represents an error processing an action. This can be
172
raised by any of the action functions, and will be caught
173
by the top-level handler, put into the HTTP response field,
176
Important Security Consideration: The message passed to this
177
exception will be relayed to the client.
181
def handle_action(req, action, fields):
182
"""Perform the "action" part of the response.
183
This function should only be called if the response is a POST.
184
This performs the action's side-effect on the server. If unsuccessful,
185
writes the X-IVLE-Action-Error header to the request object. Otherwise,
186
does not touch the request object. Does NOT write any bytes in response.
188
May throw an ActionError. The caller should put this string into the
189
X-IVLE-Action-Error header, and then continue normally.
191
action: String, the action requested. Not sanitised.
192
fields: FieldStorage object containing all arguments passed.
194
global actions_table # Table of function objects
196
action = actions_table[action]
198
# Default, just send an error but then continue
199
raise ActionError("Unknown action")
202
def actionpath_to_urlpath(req, path):
203
"""Determines the URL path (relative to the student home) upon which the
204
action is intended to act. See actionpath_to_local.
208
elif len(path) > 0 and path[0] == os.sep:
209
# Relative to student home
212
# Relative to req.path
213
return os.path.join(req.path, path)
215
def actionpath_to_local(req, path):
216
"""Determines the local path upon which an action is intended to act.
217
Note that fileservice actions accept two paths: the request path,
218
and the "path" argument given to the action.
219
According to the rules, if the "path" argument begins with a '/' it is
220
relative to the user's home; if it does not, it is relative to the
223
This resolves the path, given the request and path argument.
225
May raise an ActionError("Invalid path"). The caller is expected to
226
let this fall through to the top-level handler, where it will be
227
put into the HTTP response field. Never returns None.
231
(_, _, r) = studpath.url_to_jailpaths(actionpath_to_urlpath(req, path))
233
raise ActionError("Invalid path")
236
def movefile(req, frompath, topath, copy=False):
237
"""Performs a file move, resolving filenames, checking for any errors,
238
and throwing ActionErrors if necessary. Can also be used to do a copy
241
frompath and topath are straight paths from the client. Will be checked.
243
# TODO: Do an SVN mv if the file is versioned.
244
# TODO: Disallow tampering with student's home directory
245
if frompath is None or topath is None:
246
raise ActionError("Required field missing")
247
frompath = actionpath_to_local(req, frompath)
248
topath = actionpath_to_local(req, topath)
249
if not os.path.exists(frompath):
250
raise ActionError("The source file does not exist")
251
if os.path.exists(topath):
252
if frompath == topath:
253
raise ActionError("Source and destination are the same")
254
raise ActionError("A file already exists with that name")
258
if os.path.isdir(frompath):
259
shutil.copytree(frompath, topath)
261
shutil.copy2(frompath, topath)
263
shutil.move(frompath, topath)
265
raise ActionError("Could not move the file specified")
267
raise ActionError("Could not move the file specified")
269
def svn_movefile(req, frompath, topath, copy=False):
270
"""Performs an svn move, resolving filenames, checking for any errors,
271
and throwing ActionErrors if necessary. Can also be used to do a copy
274
frompath and topath are straight paths from the client. Will be checked.
276
if frompath is None or topath is None:
277
raise ActionError("Required field missing")
278
frompath = actionpath_to_local(req, frompath)
279
topath = actionpath_to_local(req, topath)
280
if not os.path.exists(frompath):
281
raise ActionError("The source file does not exist")
282
if os.path.exists(topath):
283
if frompath == topath:
284
raise ActionError("Source and destination are the same")
285
raise ActionError("A file already exists with that name")
289
svnclient.copy(frompath, topath)
291
svnclient.move(frompath, topath)
293
raise ActionError("Could not move the file specified")
294
except pysvn.ClientError:
295
raise ActionError("Could not move the file specified")
300
def action_delete(req, fields):
301
# TODO: Disallow removal of student's home directory
302
"""Removes a list of files or directories.
304
Reads fields: 'path' (multiple)
306
paths = fields.getlist('path')
309
path = actionpath_to_local(req, path)
311
if os.path.isdir(path):
321
raise ActionError("Could not delete the file specified")
324
"Could not delete one or more of the files specified")
326
def action_move(req, fields):
327
# TODO: Do an SVN mv if the file is versioned.
328
# TODO: Disallow tampering with student's home directory
329
"""Removes a list of files or directories.
331
Reads fields: 'from', 'to'
333
frompath = fields.getfirst('from')
334
topath = fields.getfirst('to')
335
movefile(req, frompath, topath)
337
def action_mkdir(req, fields):
338
"""Creates a directory with the given path.
341
path = fields.getfirst('path')
343
raise ActionError("Required field missing")
344
path = actionpath_to_local(req, path)
346
if os.path.exists(path):
347
raise ActionError("A file already exists with that name")
349
# Create the directory
353
raise ActionError("Could not create directory")
355
def action_putfile(req, fields):
356
"""Writes data to a file, overwriting it if it exists and creating it if
359
Reads fields: 'path', 'data' (file upload), 'overwrite'
361
# TODO: Read field "unpack".
362
# Important: Data is "None" if the file submitted is empty.
363
path = fields.getfirst('path')
364
data = fields.getfirst('data')
366
raise ActionError("Required field missing")
368
# Workaround - field reader treats "" as None, so this is the only
369
# way to allow blank file uploads
371
path = actionpath_to_local(req, path)
374
data = cStringIO.StringIO(data)
376
overwrite = fields.getfirst('overwrite')
377
if overwrite is None:
383
# Overwrite files; but can't if it's a directory
384
if os.path.isdir(path):
385
raise ActionError("A directory already exists "
388
if os.path.exists(path):
389
raise ActionError("A file already exists with that name")
391
# Copy the contents of file object 'data' to the path 'path'
393
dest = open(path, 'wb')
395
shutil.copyfileobj(data, dest)
396
except (IOError, OSError), e:
397
raise ActionError("Could not write to target file: %s" % e.strerror)
399
def action_putfiles(req, fields):
400
"""Writes data to one or more files in a directory, overwriting them if
403
Reads fields: 'path', 'data' (file upload, multiple), 'unpack'
405
# Important: Data is "None" if the file submitted is empty.
406
path = fields.getfirst('path')
407
data = fields['data']
408
if type(data) != type([]):
410
unpack = fields.getfirst('unpack')
416
raise ActionError("Required field missing")
417
path = actionpath_to_urlpath(req, path)
421
# Each of the uploaded files
422
filepath = os.path.join(path, datum.filename)
423
(_, _, filepath_local) = studpath.url_to_jailpaths(filepath)
424
if os.path.isdir(filepath_local):
425
raise ActionError("A directory already exists "
428
if os.path.exists(filepath_local):
429
raise ActionError("A file already exists with that name")
430
filedata = datum.file
432
if unpack and datum.filename.lower().endswith(".zip"):
433
# A zip file - unpack it instead of just copying
434
# TODO: Use the magic number instead of file extension
435
# Note: Just unzip into the current directory (ignore the
438
# First get the entire path (within jail)
439
_, _, abspath = studpath.url_to_jailpaths(path)
440
abspath = os.path.join(os.sep, abspath)
441
zip.unzip(abspath, filedata)
442
except (OSError, IOError):
444
except WillNotOverwrite, e:
445
raise ActionError("File '" + e.filename + "' already exists.")
448
(_, _, filepath_local) = studpath.url_to_jailpaths(filepath)
449
if filepath_local is None:
450
raise ActionError("Invalid path")
452
# Copy the contents of file object 'data' to the path 'path'
454
dest = open(filepath_local, 'wb')
456
shutil.copyfileobj(filedata, dest)
457
except (OSError, IOError):
458
# TODO: Be more descriptive.
463
raise ActionError("Could not write to target file")
466
"Could not write to one or more of the target files")
468
def action_paste(req, fields):
469
"""Performs the copy or move action with the files specified.
470
Copies/moves the files to the specified directory.
472
Reads fields: 'src', 'dst', 'mode', 'file' (multiple).
473
src: Base path that all the files are relative to (source).
474
dst: Destination path to paste into.
475
mode: 'copy' or 'move'.
476
file: (Multiple) Files relative to base, which will be copied
477
or moved to new locations relative to path.
481
dst = fields.getfirst('dst')
482
src = fields.getfirst('src')
483
mode = fields.getfirst('mode')
484
files = fields.getlist('file')
485
if dst is None or src is None or mode is None:
486
raise ActionError("Required field missing")
488
dst_local = actionpath_to_local(req, dst)
489
if not os.path.isdir(dst_local):
490
raise ActionError("dst is not a directory")
494
# The source must not be interpreted as relative to req.path
495
# Add a slash (relative to top-level)
498
frompath = os.path.join(src, file)
499
# The destination is found by taking just the basename of the file
500
topath = os.path.join(dst, os.path.basename(file))
503
movefile(req, frompath, topath, True)
505
movefile(req, frompath, topath, False)
506
elif mode == "svncopy":
507
svn_movefile(req, frompath, topath, True)
508
elif mode == "svnmove":
509
svn_movefile(req, frompath, topath, False)
511
raise ActionError("Invalid mode (must be '(svn)copy' or '(svn)move')")
512
except ActionError, message:
513
# Store the error for later; we want to copy as many as possible
517
# Multiple errors; generic message
518
errormsg = "One or more files could not be pasted"
519
# Add this file to errorfiles; it will be put back on the
520
# clipboard for possible future pasting.
521
errorfiles.append(file)
522
if errormsg is not None:
523
raise ActionError(errormsg)
525
# XXX errorfiles contains a list of files that couldn't be pasted.
526
# we currently do nothing with this.
528
def action_publish(req,fields):
529
"""Marks the folder as published by adding a '.published' file to the
530
directory and ensuring that the parent directory permissions are correct
534
paths = fields.getlist('path')
535
user = studpath.url_to_local(req.path)[0]
536
homedir = "/home/%s" % user
538
paths = map(lambda path: actionpath_to_local(req, path), paths)
540
paths = [studpath.url_to_jailpaths(req.path)[2]]
542
# Set all the dirs in home dir world browsable (o+r,o+x)
543
#FIXME: Should really only do those in the direct path not all of the
544
# folders in a students home directory
545
for root,dirs,files in os.walk(homedir):
546
os.chmod(root, os.stat(root).st_mode|0005)
550
if os.path.isdir(path):
551
pubfile = open(os.path.join(path,'.published'),'w')
552
pubfile.write("This directory is published\n")
555
raise ActionError("Can only publish directories")
557
raise ActionError("Directory could not be published")
559
def action_unpublish(req,fields):
560
"""Marks the folder as unpublished by removing a '.published' file in the
561
directory (if it exits). It does not change the permissions of the parent
566
paths = fields.getlist('path')
568
paths = map(lambda path: actionpath_to_local(req, path), paths)
570
paths = [studpath.url_to_jailpaths(req.path)[2]]
574
if os.path.isdir(path):
575
pubfile = os.path.join(path,'.published')
576
if os.path.isfile(pubfile):
579
raise ActionError("Can only unpublish directories")
581
raise ActionError("Directory could not be unpublished")
584
def action_svnadd(req, fields):
585
"""Performs a "svn add" to each file specified.
587
Reads fields: 'path' (multiple)
589
paths = fields.getlist('path')
590
paths = map(lambda path: actionpath_to_local(req, path), paths)
593
svnclient.add(paths, recurse=True, force=True)
594
except pysvn.ClientError, e:
595
raise ActionError(str(e))
597
def action_svnremove(req, fields):
598
"""Performs a "svn remove" on each file specified.
600
Reads fields: 'path' (multiple)
602
paths = fields.getlist('path')
603
paths = map(lambda path: actionpath_to_local(req, path), paths)
606
svnclient.remove(paths, force=True)
607
except pysvn.ClientError, e:
608
raise ActionError(str(e))
610
def action_svnupdate(req, fields):
611
"""Performs a "svn update" to each file specified.
615
path = fields.getfirst('path')
617
raise ActionError("Required field missing")
618
path = actionpath_to_local(req, path)
621
svnclient.update(path, recurse=True)
622
except pysvn.ClientError, e:
623
raise ActionError(str(e))
625
def action_svnresolved(req, fields):
626
"""Performs a "svn resolved" to each file specified.
630
path = fields.getfirst('path')
632
raise ActionError("Required field missing")
633
path = actionpath_to_local(req, path)
636
svnclient.resolved(path, recurse=True)
637
except pysvn.ClientError, e:
638
raise ActionError(str(e))
640
def action_svnrevert(req, fields):
641
"""Performs a "svn revert" to each file specified.
643
Reads fields: 'path' (multiple)
645
paths = fields.getlist('path')
646
paths = map(lambda path: actionpath_to_local(req, path), paths)
649
svnclient.revert(paths, recurse=True)
650
except pysvn.ClientError, e:
651
raise ActionError(str(e))
653
def action_svnpublish(req, fields):
654
"""Sets svn property "ivle:published" on each file specified.
655
Should only be called on directories (only effective on directories
660
XXX Currently unused by the client (calls action_publish instead, which
661
has a completely different publishing model).
663
paths = fields.getlist('path')
665
paths = map(lambda path: actionpath_to_local(req, path), paths)
667
paths = [studpath.url_to_jailpaths(req.path)[2]]
671
# Note: Property value doesn't matter
672
svnclient.propset("ivle:published", "", path, recurse=False)
673
except pysvn.ClientError, e:
674
raise ActionError("Directory could not be published")
676
def action_svnunpublish(req, fields):
677
"""Deletes svn property "ivle:published" on each file specified.
681
XXX Currently unused by the client (calls action_unpublish instead, which
682
has a completely different publishing model).
684
paths = fields.getlist('path')
685
paths = map(lambda path: actionpath_to_local(req, path), paths)
689
svnclient.propdel("ivle:published", path, recurse=False)
690
except pysvn.ClientError, e:
691
raise ActionError("Directory could not be unpublished")
693
def action_svncommit(req, fields):
694
"""Performs a "svn commit" to each file specified.
696
Reads fields: 'path' (multiple), 'logmsg' (optional)
698
paths = fields.getlist('path')
699
paths = map(lambda path: actionpath_to_local(req, str(path)), paths)
700
logmsg = str(fields.getfirst('logmsg', DEFAULT_LOGMESSAGE))
701
if logmsg == '': logmsg = DEFAULT_LOGMESSAGE
704
svnclient.checkin(paths, logmsg, recurse=True)
705
except pysvn.ClientError, e:
706
raise ActionError(str(e))
708
def action_svncheckout(req, fields):
709
"""Performs a "svn checkout" of the first path into the second path.
711
Reads fields: 'path' (multiple)
713
paths = fields.getlist('path')
715
raise ActionError("usage: svncheckout url local-path")
716
url = ivle.conf.svn_addr + "/" + paths[0]
717
local_path = actionpath_to_local(req, str(paths[1]))
719
svnclient.callback_get_login = get_login
720
svnclient.checkout(url, local_path, recurse=True)
721
except pysvn.ClientError, e:
722
raise ActionError(str(e))
724
def action_svnrepomkdir(req, fields):
725
"""Performs a "svn mkdir" on a path under the IVLE SVN root.
729
path = fields.getfirst('path')
730
logmsg = fields.getfirst('logmsg')
731
url = ivle.conf.svn_addr + "/" + path
733
svnclient.callback_get_login = get_login
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.
743
path = fields.getfirst('path')
744
url = ivle.conf.svn_addr + "/" + path
745
svnclient.exception_style = 1
748
svnclient.callback_get_login = get_login
750
except pysvn.ClientError, e:
751
# Error code 170000 means ENOENT in this revision.
752
if e[1][0][1] == 170000:
753
raise util.IVLEError(404, 'The specified repository path does not exist')
755
raise ActionError(str(e[0]))
758
# Table of all action functions #
759
# Each function has the interface f(req, fields).
762
"delete" : action_delete,
763
"move" : action_move,
764
"mkdir" : action_mkdir,
765
"putfile" : action_putfile,
766
"putfiles" : action_putfiles,
767
"paste" : action_paste,
768
"publish" : action_publish,
769
"unpublish" : action_unpublish,
771
"svnadd" : action_svnadd,
772
"svnremove" : action_svnremove,
773
"svnupdate" : action_svnupdate,
774
"svnresolved" : action_svnresolved,
775
"svnrevert" : action_svnrevert,
776
"svnpublish" : action_svnpublish,
777
"svnunpublish" : action_svnunpublish,
778
"svncommit" : action_svncommit,
779
"svncheckout" : action_svncheckout,
780
"svnrepomkdir" : action_svnrepomkdir,
781
"svnrepostat" : action_svnrepostat,