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
# action=svncleanup: Recursively clean up the working copy, removing locks,
123
# resuming unfinished operations, etc.
124
# path: The path to the directory to be cleaned
126
# TODO: Implement the following actions:
128
# TODO: Implement ZIP unpacking in putfiles (done?).
129
# TODO: svnupdate needs a digest to tell the user the files that were updated.
130
# This can be implemented by some message passing between action and
131
# listing, and having the digest included in the listing. (Problem if
132
# the listing is not a directory, but we could make it an error to do an
133
# update if the path is not a directory).
142
from ivle import (util, studpath, zip)
143
from ivle.fileservice_lib.exceptions import WillNotOverwrite
147
def get_login(_realm, existing_login, _may_save):
148
"""Callback function used by pysvn for authentication.
149
realm, existing_login, _may_save: The 3 arguments passed by pysvn to
151
The following has been determined empirically, not from docs:
152
existing_login will be the name of the user who owns the process on
153
the first attempt, "" on subsequent attempts. We use this fact.
155
# Only provide credentials on the _first_ attempt.
156
# If we're being asked again, then it means the credentials failed for
157
# some reason and we should just fail. (This is not desirable, but it's
158
# better than being asked an infinite number of times).
159
return (existing_login != "", str(ivle.conf.login),
160
str(ivle.conf.svn_pass), True)
162
# Make a Subversion client object
163
svnclient = pysvn.Client()
164
svnclient.callback_get_login = get_login
165
svnclient.exception_style = 0 # Simple (string) exceptions
167
DEFAULT_LOGMESSAGE = "No log message supplied."
170
# application/json is the "best" content type but is not good for
171
# debugging because Firefox just tries to download it
172
mime_dirlisting = "text/html"
173
#mime_dirlisting = "application/json"
175
class ActionError(Exception):
176
"""Represents an error processing an action. This can be
177
raised by any of the action functions, and will be caught
178
by the top-level handler, put into the HTTP response field,
181
Important Security Consideration: The message passed to this
182
exception will be relayed to the client.
186
def handle_action(req, action, fields):
187
"""Perform the "action" part of the response.
188
This function should only be called if the response is a POST.
189
This performs the action's side-effect on the server. If unsuccessful,
190
writes the X-IVLE-Action-Error header to the request object. Otherwise,
191
does not touch the request object. Does NOT write any bytes in response.
193
May throw an ActionError. The caller should put this string into the
194
X-IVLE-Action-Error header, and then continue normally.
196
action: String, the action requested. Not sanitised.
197
fields: FieldStorage object containing all arguments passed.
199
global actions_table # Table of function objects
201
action = actions_table[action]
203
# Default, just send an error but then continue
204
raise ActionError("Unknown action")
205
return action(req, fields)
207
def actionpath_to_urlpath(req, path):
208
"""Determines the URL path (relative to the student home) upon which the
209
action is intended to act. See actionpath_to_local.
213
elif len(path) > 0 and path[0] == os.sep:
214
# Relative to student home
217
# Relative to req.path
218
return os.path.join(req.path, path)
220
def actionpath_to_local(req, path):
221
"""Determines the local path upon which an action is intended to act.
222
Note that fileservice actions accept two paths: the request path,
223
and the "path" argument given to the action.
224
According to the rules, if the "path" argument begins with a '/' it is
225
relative to the user's home; if it does not, it is relative to the
228
This resolves the path, given the request and path argument.
230
May raise an ActionError("Invalid path"). The caller is expected to
231
let this fall through to the top-level handler, where it will be
232
put into the HTTP response field. Never returns None.
236
r = studpath.to_home_path(actionpath_to_urlpath(req, path))
238
raise ActionError("Invalid path")
241
def movefile(req, frompath, topath, copy=False):
242
"""Performs a file move, resolving filenames, checking for any errors,
243
and throwing ActionErrors if necessary. Can also be used to do a copy
246
frompath and topath are straight paths from the client. Will be checked.
248
# TODO: Do an SVN mv if the file is versioned.
249
# TODO: Disallow tampering with student's home directory
250
if frompath is None or topath is None:
251
raise ActionError("Required field missing")
252
frompath = actionpath_to_local(req, frompath)
253
topath = actionpath_to_local(req, topath)
254
if not os.path.exists(frompath):
255
raise ActionError("The source file does not exist")
256
if os.path.exists(topath):
257
if frompath == topath:
258
raise ActionError("Source and destination are the same")
259
raise ActionError("A file already exists with that name")
263
if os.path.isdir(frompath):
264
shutil.copytree(frompath, topath)
266
shutil.copy2(frompath, topath)
268
shutil.move(frompath, topath)
270
raise ActionError("Could not move the file specified")
272
raise ActionError("Could not move the file specified")
274
def svn_movefile(req, frompath, topath, copy=False):
275
"""Performs an svn move, resolving filenames, checking for any errors,
276
and throwing ActionErrors if necessary. Can also be used to do a copy
279
frompath and topath are straight paths from the client. Will be checked.
281
if frompath is None or topath is None:
282
raise ActionError("Required field missing")
283
frompath = actionpath_to_local(req, frompath)
284
topath = actionpath_to_local(req, topath)
285
if not os.path.exists(frompath):
286
raise ActionError("The source file does not exist")
287
if os.path.exists(topath):
288
if frompath == topath:
289
raise ActionError("Source and destination are the same")
290
raise ActionError("A file already exists with that name")
294
svnclient.copy(frompath, topath)
296
svnclient.move(frompath, topath)
298
raise ActionError("Could not move the file specified")
299
except pysvn.ClientError:
300
raise ActionError("Could not move the file specified")
305
def action_delete(req, fields):
306
# TODO: Disallow removal of student's home directory
307
"""Removes a list of files or directories.
309
Reads fields: 'path' (multiple)
311
paths = fields.getlist('path')
314
path = actionpath_to_local(req, path)
316
if os.path.isdir(path):
326
raise ActionError("Could not delete the file specified")
329
"Could not delete one or more of the files specified")
331
def action_move(req, fields):
332
# TODO: Do an SVN mv if the file is versioned.
333
# TODO: Disallow tampering with student's home directory
334
"""Removes a list of files or directories.
336
Reads fields: 'from', 'to'
338
frompath = fields.getfirst('from')
339
topath = fields.getfirst('to')
340
movefile(req, frompath, topath)
342
def action_mkdir(req, fields):
343
"""Creates a directory with the given path.
346
path = fields.getfirst('path')
348
raise ActionError("Required field missing")
349
path = actionpath_to_local(req, path)
351
if os.path.exists(path):
352
raise ActionError("A file already exists with that name")
354
# Create the directory
358
raise ActionError("Could not create directory")
360
def action_putfile(req, fields):
361
"""Writes data to a file, overwriting it if it exists and creating it if
364
Reads fields: 'path', 'data' (file upload), 'overwrite'
366
# TODO: Read field "unpack".
367
# Important: Data is "None" if the file submitted is empty.
368
path = fields.getfirst('path')
369
data = fields.getfirst('data')
371
raise ActionError("Required field missing")
373
# Workaround - field reader treats "" as None, so this is the only
374
# way to allow blank file uploads
376
path = actionpath_to_local(req, path)
379
data = cStringIO.StringIO(data)
381
overwrite = fields.getfirst('overwrite')
382
if overwrite is None:
388
# Overwrite files; but can't if it's a directory
389
if os.path.isdir(path):
390
raise ActionError("A directory already exists "
393
if os.path.exists(path):
394
raise ActionError("A file already exists with that name")
396
# Copy the contents of file object 'data' to the path 'path'
398
dest = open(path, 'wb')
400
shutil.copyfileobj(data, dest)
401
except (IOError, OSError), e:
402
raise ActionError("Could not write to target file: %s" % e.strerror)
404
def action_putfiles(req, fields):
405
"""Writes data to one or more files in a directory, overwriting them if
408
Reads fields: 'path', 'data' (file upload, multiple), 'unpack'
410
# Important: Data is "None" if the file submitted is empty.
411
path = fields.getfirst('path')
412
data = fields['data']
413
if type(data) != type([]):
415
unpack = fields.getfirst('unpack')
421
raise ActionError("Required field missing")
422
path = actionpath_to_urlpath(req, path)
426
# Each of the uploaded files
427
filepath = os.path.join(path, datum.filename)
428
filepath_local = studpath.to_home_path(filepath)
429
if os.path.isdir(filepath_local):
430
raise ActionError("A directory already exists "
433
if os.path.exists(filepath_local):
434
raise ActionError("A file already exists with that name")
435
filedata = datum.file
437
if unpack and datum.filename.lower().endswith(".zip"):
438
# A zip file - unpack it instead of just copying
439
# TODO: Use the magic number instead of file extension
440
# Note: Just unzip into the current directory (ignore the
443
# First get the entire path (within jail)
444
abspath = studpath.to_home_path(path)
445
abspath = os.path.join(os.sep, abspath)
446
zip.unzip(abspath, filedata)
447
except (OSError, IOError):
449
except WillNotOverwrite, e:
450
raise ActionError("File '" + e.filename + "' already exists.")
453
filepath_local = studpath.to_home_path(filepath)
454
if filepath_local is None:
455
raise ActionError("Invalid path")
457
# Copy the contents of file object 'data' to the path 'path'
459
dest = open(filepath_local, 'wb')
461
shutil.copyfileobj(filedata, dest)
462
except (OSError, IOError):
463
# TODO: Be more descriptive.
468
raise ActionError("Could not write to target file")
471
"Could not write to one or more of the target files")
473
def action_paste(req, fields):
474
"""Performs the copy or move action with the files specified.
475
Copies/moves the files to the specified directory.
477
Reads fields: 'src', 'dst', 'mode', 'file' (multiple).
478
src: Base path that all the files are relative to (source).
479
dst: Destination path to paste into.
480
mode: 'copy' or 'move'.
481
file: (Multiple) Files relative to base, which will be copied
482
or moved to new locations relative to path.
486
dst = fields.getfirst('dst')
487
src = fields.getfirst('src')
488
mode = fields.getfirst('mode')
489
files = fields.getlist('file')
490
if dst is None or src is None or mode is None:
491
raise ActionError("Required field missing")
493
dst_local = actionpath_to_local(req, dst)
494
if not os.path.isdir(dst_local):
495
raise ActionError("dst is not a directory")
499
# The source must not be interpreted as relative to req.path
500
# Add a slash (relative to top-level)
503
frompath = os.path.join(src, file)
504
# The destination is found by taking just the basename of the file
505
topath = os.path.join(dst, os.path.basename(file))
508
movefile(req, frompath, topath, True)
510
movefile(req, frompath, topath, False)
511
elif mode == "svncopy":
512
svn_movefile(req, frompath, topath, True)
513
elif mode == "svnmove":
514
svn_movefile(req, frompath, topath, False)
516
raise ActionError("Invalid mode (must be '(svn)copy' or '(svn)move')")
517
except ActionError, message:
518
# Store the error for later; we want to copy as many as possible
522
# Multiple errors; generic message
523
errormsg = "One or more files could not be pasted"
524
# Add this file to errorfiles; it will be put back on the
525
# clipboard for possible future pasting.
526
errorfiles.append(file)
527
if errormsg is not None:
528
raise ActionError(errormsg)
530
# XXX errorfiles contains a list of files that couldn't be pasted.
531
# we currently do nothing with this.
533
def action_publish(req,fields):
534
"""Marks the folder as published by adding a '.published' file to the
535
directory and ensuring that the parent directory permissions are correct
539
paths = fields.getlist('path')
540
user = util.split_path(req.path)[0]
541
homedir = "/home/%s" % user
543
paths = map(lambda path: actionpath_to_local(req, path), paths)
545
paths = [studpath.to_home_path(req.path)]
547
# Set all the dirs in home dir world browsable (o+r,o+x)
548
#FIXME: Should really only do those in the direct path not all of the
549
# folders in a students home directory
550
for root,dirs,files in os.walk(homedir):
551
os.chmod(root, os.stat(root).st_mode|0005)
555
if os.path.isdir(path):
556
pubfile = open(os.path.join(path,'.published'),'w')
557
pubfile.write("This directory is published\n")
560
raise ActionError("Can only publish directories")
562
raise ActionError("Directory could not be published")
564
def action_unpublish(req,fields):
565
"""Marks the folder as unpublished by removing a '.published' file in the
566
directory (if it exits). It does not change the permissions of the parent
571
paths = fields.getlist('path')
573
paths = map(lambda path: actionpath_to_local(req, path), paths)
575
paths = [studpath.to_home_path(req.path)]
579
if os.path.isdir(path):
580
pubfile = os.path.join(path,'.published')
581
if os.path.isfile(pubfile):
584
raise ActionError("Can only unpublish directories")
586
raise ActionError("Directory could not be unpublished")
589
def action_svnadd(req, fields):
590
"""Performs a "svn add" to each file specified.
592
Reads fields: 'path' (multiple)
594
paths = fields.getlist('path')
595
paths = map(lambda path: actionpath_to_local(req, path), paths)
598
svnclient.add(paths, recurse=True, force=True)
599
except pysvn.ClientError, e:
600
raise ActionError(str(e))
602
def action_svnremove(req, fields):
603
"""Performs a "svn remove" on each file specified.
605
Reads fields: 'path' (multiple)
607
paths = fields.getlist('path')
608
paths = map(lambda path: actionpath_to_local(req, path), paths)
611
svnclient.remove(paths, force=True)
612
except pysvn.ClientError, e:
613
raise ActionError(str(e))
615
def action_svnupdate(req, fields):
616
"""Performs a "svn update" to each file specified.
620
path = fields.getfirst('path')
622
raise ActionError("Required field missing")
623
path = actionpath_to_local(req, path)
626
svnclient.update(path, recurse=True)
627
except pysvn.ClientError, e:
628
raise ActionError(str(e))
630
def action_svnresolved(req, fields):
631
"""Performs a "svn resolved" to each file specified.
635
path = fields.getfirst('path')
637
raise ActionError("Required field missing")
638
path = actionpath_to_local(req, path)
641
svnclient.resolved(path, recurse=True)
642
except pysvn.ClientError, e:
643
raise ActionError(str(e))
645
def action_svnrevert(req, fields):
646
"""Performs a "svn revert" to each file specified.
648
Reads fields: 'path' (multiple)
650
paths = fields.getlist('path')
651
paths = map(lambda path: actionpath_to_local(req, path), paths)
654
svnclient.revert(paths, recurse=True)
655
except pysvn.ClientError, e:
656
raise ActionError(str(e))
658
def action_svnpublish(req, fields):
659
"""Sets svn property "ivle:published" on each file specified.
660
Should only be called on directories (only effective on directories
665
XXX Currently unused by the client (calls action_publish instead, which
666
has a completely different publishing model).
668
paths = fields.getlist('path')
670
paths = map(lambda path: actionpath_to_local(req, path), paths)
672
paths = [studpath.to_home_path(req.path)]
676
# Note: Property value doesn't matter
677
svnclient.propset("ivle:published", "", path, recurse=False)
678
except pysvn.ClientError, e:
679
raise ActionError("Directory could not be published")
681
def action_svnunpublish(req, fields):
682
"""Deletes svn property "ivle:published" on each file specified.
686
XXX Currently unused by the client (calls action_unpublish instead, which
687
has a completely different publishing model).
689
paths = fields.getlist('path')
690
paths = map(lambda path: actionpath_to_local(req, path), paths)
694
svnclient.propdel("ivle:published", path, recurse=False)
695
except pysvn.ClientError, e:
696
raise ActionError("Directory could not be unpublished")
698
def action_svncommit(req, fields):
699
"""Performs a "svn commit" to each file specified.
701
Reads fields: 'path' (multiple), 'logmsg' (optional)
703
paths = fields.getlist('path')
704
paths = map(lambda path: actionpath_to_local(req, str(path)), paths)
705
logmsg = str(fields.getfirst('logmsg', DEFAULT_LOGMESSAGE))
706
if logmsg == '': logmsg = DEFAULT_LOGMESSAGE
709
svnclient.checkin(paths, logmsg, recurse=True)
710
except pysvn.ClientError, e:
711
raise ActionError(str(e))
713
def action_svncheckout(req, fields):
714
"""Performs a "svn checkout" of the first path into the second path.
716
Reads fields: 'path' (multiple)
718
paths = fields.getlist('path')
720
raise ActionError("usage: svncheckout url local-path")
721
url = ivle.conf.svn_addr + "/" + urllib.quote(paths[0])
722
local_path = actionpath_to_local(req, str(paths[1]))
724
svnclient.callback_get_login = get_login
725
svnclient.checkout(url, local_path, recurse=True)
726
except pysvn.ClientError, e:
727
raise ActionError(str(e))
729
def action_svnrepomkdir(req, fields):
730
"""Performs a "svn mkdir" on a path under the IVLE SVN root.
734
path = fields.getfirst('path')
735
logmsg = fields.getfirst('logmsg')
736
url = ivle.conf.svn_addr + "/" + path
738
svnclient.callback_get_login = get_login
739
svnclient.mkdir(url, log_message=logmsg)
740
except pysvn.ClientError, e:
741
raise ActionError(str(e))
743
def action_svnrepostat(req, fields):
744
"""Discovers whether a path exists in a repo under the IVLE SVN root.
746
If it does exist, returns a dict containing its metadata.
750
path = fields.getfirst('path')
751
url = ivle.conf.svn_addr + "/" + path
752
svnclient.exception_style = 1
755
svnclient.callback_get_login = get_login
756
info = svnclient.info2(url,
757
revision=pysvn.Revision(pysvn.opt_revision_kind.head))[0][1]
758
return {'svnrevision': info['rev'].number
760
info['rev'].kind == pysvn.opt_revision_kind.number
762
except pysvn.ClientError, e:
763
# Error code 170000 means ENOENT in this revision.
764
if e[1][0][1] == 170000:
765
raise util.IVLEError(404, 'The specified repository path does not exist')
767
raise ActionError(str(e[0]))
770
def action_svncleanup(req, fields):
771
"""Recursively clean up the working copy, removing locks, resuming
772
unfinished operations, etc.
773
path: The path to be cleaned"""
775
path = fields.getfirst('path')
777
raise ActionError("Required field missing")
778
path = actionpath_to_local(req, path)
781
svnclient.cleanup(path)
782
except pysvn.ClientError, e:
783
raise ActionError(str(e))
786
# Table of all action functions #
787
# Each function has the interface f(req, fields).
790
"delete" : action_delete,
791
"move" : action_move,
792
"mkdir" : action_mkdir,
793
"putfile" : action_putfile,
794
"putfiles" : action_putfiles,
795
"paste" : action_paste,
796
"publish" : action_publish,
797
"unpublish" : action_unpublish,
799
"svnadd" : action_svnadd,
800
"svnremove" : action_svnremove,
801
"svnupdate" : action_svnupdate,
802
"svnresolved" : action_svnresolved,
803
"svnrevert" : action_svnrevert,
804
"svnpublish" : action_svnpublish,
805
"svnunpublish" : action_svnunpublish,
806
"svncommit" : action_svncommit,
807
"svncheckout" : action_svncheckout,
808
"svnrepomkdir" : action_svnrepomkdir,
809
"svnrepostat" : action_svnrepostat,
810
"svncleanup" : action_svncleanup,