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=svncommit: Commit a file(s) or directory(s) to the repository.
98
# path: The path to the file or directory to be committed. Can be
99
# specified multiple times. Directories are committed
101
# logmsg: Text of the log message. Optional. There is a default log
102
# message if unspecified.
103
# action=svncheckout: Checkout a file/directory from the repository.
104
# path: The [repository] path to the file or directory to be
107
# action=svnrepomkdir: Create a directory in a repository (not WC).
108
# path: The path to the directory to be created (under the IVLE
110
# logmsg: Text of the log message.
112
# action=svnrepostat: Check if a path exists in a repository (not WC).
113
# path: The path to the directory to be checked (under the IVLE
116
# action=svncleanup: Recursively clean up the working copy, removing locks,
117
# resuming unfinished operations, etc.
118
# path: The path to the directory to be cleaned
120
# TODO: Implement the following actions:
122
# TODO: Implement ZIP unpacking in putfiles (done?).
123
# TODO: svnupdate needs a digest to tell the user the files that were updated.
124
# This can be implemented by some message passing between action and
125
# listing, and having the digest included in the listing. (Problem if
126
# the listing is not a directory, but we could make it an error to do an
127
# update if the path is not a directory).
136
from ivle import (util, studpath, zip)
137
from ivle.fileservice_lib.exceptions import WillNotOverwrite
141
# Make a Subversion client object (which will log in with this user's
142
# credentials, upon request)
143
svnclient = ivle.svn.create_auth_svn_client(username=ivle.conf.login,
144
password=ivle.conf.svn_pass)
145
svnclient.exception_style = 0 # Simple (string) exceptions
147
DEFAULT_LOGMESSAGE = "No log message supplied."
150
# application/json is the "best" content type but is not good for
151
# debugging because Firefox just tries to download it
152
mime_dirlisting = "text/html"
153
#mime_dirlisting = "application/json"
155
class ActionError(Exception):
156
"""Represents an error processing an action. This can be
157
raised by any of the action functions, and will be caught
158
by the top-level handler, put into the HTTP response field,
161
Important Security Consideration: The message passed to this
162
exception will be relayed to the client.
166
def handle_action(req, action, fields):
167
"""Perform the "action" part of the response.
168
This function should only be called if the response is a POST.
169
This performs the action's side-effect on the server. If unsuccessful,
170
writes the X-IVLE-Action-Error header to the request object. Otherwise,
171
does not touch the request object. Does NOT write any bytes in response.
173
May throw an ActionError. The caller should put this string into the
174
X-IVLE-Action-Error header, and then continue normally.
176
action: String, the action requested. Not sanitised.
177
fields: FieldStorage object containing all arguments passed.
179
global actions_table # Table of function objects
181
action = actions_table[action]
183
# Default, just send an error but then continue
184
raise ActionError("Unknown action")
185
return action(req, fields)
187
def actionpath_to_urlpath(req, path):
188
"""Determines the URL path (relative to the student home) upon which the
189
action is intended to act. See actionpath_to_local.
193
elif len(path) > 0 and path[0] == os.sep:
194
# Relative to student home
197
# Relative to req.path
198
return os.path.join(req.path, path)
200
def actionpath_to_local(req, path):
201
"""Determines the local path upon which an action is intended to act.
202
Note that fileservice actions accept two paths: the request path,
203
and the "path" argument given to the action.
204
According to the rules, if the "path" argument begins with a '/' it is
205
relative to the user's home; if it does not, it is relative to the
208
This resolves the path, given the request and path argument.
210
May raise an ActionError("Invalid path"). The caller is expected to
211
let this fall through to the top-level handler, where it will be
212
put into the HTTP response field. Never returns None.
216
r = studpath.to_home_path(actionpath_to_urlpath(req, path))
218
raise ActionError("Invalid path")
221
def movefile(req, frompath, topath, copy=False):
222
"""Performs a file move, resolving filenames, checking for any errors,
223
and throwing ActionErrors if necessary. Can also be used to do a copy
226
frompath and topath are straight paths from the client. Will be checked.
228
# TODO: Do an SVN mv if the file is versioned.
229
# TODO: Disallow tampering with student's home directory
230
if frompath is None or topath is None:
231
raise ActionError("Required field missing")
232
frompath = actionpath_to_local(req, frompath)
233
topath = actionpath_to_local(req, topath)
234
if not os.path.exists(frompath):
235
raise ActionError("The source file does not exist")
236
if os.path.exists(topath):
237
if frompath == topath:
238
raise ActionError("Source and destination are the same")
239
raise ActionError("A file already exists with that name")
243
if os.path.isdir(frompath):
244
shutil.copytree(frompath, topath)
246
shutil.copy2(frompath, topath)
248
shutil.move(frompath, topath)
250
raise ActionError("Could not move the file specified")
252
raise ActionError("Could not move the file specified")
254
def svn_movefile(req, frompath, topath, copy=False):
255
"""Performs an svn move, resolving filenames, checking for any errors,
256
and throwing ActionErrors if necessary. Can also be used to do a copy
259
frompath and topath are straight paths from the client. Will be checked.
261
if frompath is None or topath is None:
262
raise ActionError("Required field missing")
263
frompath = actionpath_to_local(req, frompath)
264
topath = actionpath_to_local(req, topath)
265
if not os.path.exists(frompath):
266
raise ActionError("The source file does not exist")
267
if os.path.exists(topath):
268
if frompath == topath:
269
raise ActionError("Source and destination are the same")
270
raise ActionError("A file already exists with that name")
274
svnclient.copy(frompath, topath)
276
svnclient.move(frompath, topath)
278
raise ActionError("Could not move the file specified")
279
except pysvn.ClientError:
280
raise ActionError("Could not move the file specified")
285
def action_delete(req, fields):
286
# TODO: Disallow removal of student's home directory
287
"""Removes a list of files or directories.
289
Reads fields: 'path' (multiple)
291
paths = fields.getlist('path')
294
path = actionpath_to_local(req, path)
296
if os.path.isdir(path):
306
raise ActionError("Could not delete the file specified")
309
"Could not delete one or more of the files specified")
311
def action_move(req, fields):
312
# TODO: Do an SVN mv if the file is versioned.
313
# TODO: Disallow tampering with student's home directory
314
"""Removes a list of files or directories.
316
Reads fields: 'from', 'to'
318
frompath = fields.getfirst('from')
319
topath = fields.getfirst('to')
320
movefile(req, frompath, topath)
322
def action_mkdir(req, fields):
323
"""Creates a directory with the given path.
326
path = fields.getfirst('path')
328
raise ActionError("Required field missing")
329
path = actionpath_to_local(req, path)
331
if os.path.exists(path):
332
raise ActionError("A file already exists with that name")
334
# Create the directory
338
raise ActionError("Could not create directory")
340
def action_putfile(req, fields):
341
"""Writes data to a file, overwriting it if it exists and creating it if
344
Reads fields: 'path', 'data' (file upload), 'overwrite'
346
# TODO: Read field "unpack".
347
# Important: Data is "None" if the file submitted is empty.
348
path = fields.getfirst('path')
349
data = fields.getfirst('data')
351
raise ActionError("Required field missing")
353
# Workaround - field reader treats "" as None, so this is the only
354
# way to allow blank file uploads
356
path = actionpath_to_local(req, path)
359
data = cStringIO.StringIO(data)
361
overwrite = fields.getfirst('overwrite')
362
if overwrite is None:
368
# Overwrite files; but can't if it's a directory
369
if os.path.isdir(path):
370
raise ActionError("A directory already exists "
373
if os.path.exists(path):
374
raise ActionError("A file already exists with that name")
376
# Copy the contents of file object 'data' to the path 'path'
378
dest = open(path, 'wb')
380
shutil.copyfileobj(data, dest)
381
except (IOError, OSError), e:
382
raise ActionError("Could not write to target file: %s" % e.strerror)
384
def action_putfiles(req, fields):
385
"""Writes data to one or more files in a directory, overwriting them if
388
Reads fields: 'path', 'data' (file upload, multiple), 'unpack'
390
# Important: Data is "None" if the file submitted is empty.
391
path = fields.getfirst('path')
392
data = fields['data']
393
if type(data) != type([]):
395
unpack = fields.getfirst('unpack')
401
raise ActionError("Required field missing")
402
path = actionpath_to_urlpath(req, path)
406
# Each of the uploaded files
407
filepath = os.path.join(path, datum.filename)
408
filepath_local = studpath.to_home_path(filepath)
409
if os.path.isdir(filepath_local):
410
raise ActionError("A directory already exists "
413
if os.path.exists(filepath_local):
414
raise ActionError("A file already exists with that name")
415
filedata = datum.file
417
if unpack and datum.filename.lower().endswith(".zip"):
418
# A zip file - unpack it instead of just copying
419
# TODO: Use the magic number instead of file extension
420
# Note: Just unzip into the current directory (ignore the
423
# First get the entire path (within jail)
424
abspath = studpath.to_home_path(path)
425
abspath = os.path.join(os.sep, abspath)
426
zip.unzip(abspath, filedata)
427
except (OSError, IOError):
429
except WillNotOverwrite, e:
430
raise ActionError("File '" + e.filename + "' already exists.")
433
filepath_local = studpath.to_home_path(filepath)
434
if filepath_local is None:
435
raise ActionError("Invalid path")
437
# Copy the contents of file object 'data' to the path 'path'
439
dest = open(filepath_local, 'wb')
441
shutil.copyfileobj(filedata, dest)
442
except (OSError, IOError):
443
# TODO: Be more descriptive.
448
raise ActionError("Could not write to target file")
451
"Could not write to one or more of the target files")
453
def action_paste(req, fields):
454
"""Performs the copy or move action with the files specified.
455
Copies/moves the files to the specified directory.
457
Reads fields: 'src', 'dst', 'mode', 'file' (multiple).
458
src: Base path that all the files are relative to (source).
459
dst: Destination path to paste into.
460
mode: 'copy' or 'move'.
461
file: (Multiple) Files relative to base, which will be copied
462
or moved to new locations relative to path.
466
dst = fields.getfirst('dst')
467
src = fields.getfirst('src')
468
mode = fields.getfirst('mode')
469
files = fields.getlist('file')
470
if dst is None or src is None or mode is None:
471
raise ActionError("Required field missing")
473
dst_local = actionpath_to_local(req, dst)
474
if not os.path.isdir(dst_local):
475
raise ActionError("dst is not a directory")
479
# The source must not be interpreted as relative to req.path
480
# Add a slash (relative to top-level)
483
frompath = os.path.join(src, file)
484
# The destination is found by taking just the basename of the file
485
topath = os.path.join(dst, os.path.basename(file))
488
movefile(req, frompath, topath, True)
490
movefile(req, frompath, topath, False)
491
elif mode == "svncopy":
492
svn_movefile(req, frompath, topath, True)
493
elif mode == "svnmove":
494
svn_movefile(req, frompath, topath, False)
496
raise ActionError("Invalid mode (must be '(svn)copy' or '(svn)move')")
497
except ActionError, message:
498
# Store the error for later; we want to copy as many as possible
502
# Multiple errors; generic message
503
errormsg = "One or more files could not be pasted"
504
# Add this file to errorfiles; it will be put back on the
505
# clipboard for possible future pasting.
506
errorfiles.append(file)
507
if errormsg is not None:
508
raise ActionError(errormsg)
510
# XXX errorfiles contains a list of files that couldn't be pasted.
511
# we currently do nothing with this.
513
def action_publish(req,fields):
514
"""Marks the folder as published by adding a '.published' file to the
515
directory and ensuring that the parent directory permissions are correct
519
paths = fields.getlist('path')
520
user = util.split_path(req.path)[0]
521
homedir = "/home/%s" % user
523
paths = map(lambda path: actionpath_to_local(req, path), paths)
525
paths = [studpath.to_home_path(req.path)]
527
# Set all the dirs in home dir world browsable (o+r,o+x)
528
#FIXME: Should really only do those in the direct path not all of the
529
# folders in a students home directory
530
for root,dirs,files in os.walk(homedir):
531
os.chmod(root, os.stat(root).st_mode|0005)
535
if os.path.isdir(path):
536
pubfile = open(os.path.join(path,'.published'),'w')
537
pubfile.write("This directory is published\n")
540
raise ActionError("Can only publish directories")
542
raise ActionError("Directory could not be published")
544
def action_unpublish(req,fields):
545
"""Marks the folder as unpublished by removing a '.published' file in the
546
directory (if it exits). It does not change the permissions of the parent
551
paths = fields.getlist('path')
553
paths = map(lambda path: actionpath_to_local(req, path), paths)
555
paths = [studpath.to_home_path(req.path)]
559
if os.path.isdir(path):
560
pubfile = os.path.join(path,'.published')
561
if os.path.isfile(pubfile):
564
raise ActionError("Can only unpublish directories")
566
raise ActionError("Directory could not be unpublished")
569
def action_svnadd(req, fields):
570
"""Performs a "svn add" to each file specified.
572
Reads fields: 'path' (multiple)
574
paths = fields.getlist('path')
575
paths = map(lambda path: actionpath_to_local(req, path).decode('utf-8'),
579
svnclient.add(paths, recurse=True, force=True)
580
except pysvn.ClientError, e:
581
raise ActionError(str(e))
583
def action_svnremove(req, fields):
584
"""Performs a "svn remove" on each file specified.
586
Reads fields: 'path' (multiple)
588
paths = fields.getlist('path')
589
paths = map(lambda path: actionpath_to_local(req, path).decode('utf-8'),
593
svnclient.remove(paths, force=True)
594
except pysvn.ClientError, e:
595
raise ActionError(str(e))
597
def action_svnupdate(req, fields):
598
"""Performs a "svn update" to each file specified.
600
Reads fields: 'path' and 'revision'
602
path = fields.getfirst('path')
603
revision = fields.getfirst('revision')
605
raise ActionError("Required field missing")
607
revision = pysvn.Revision( pysvn.opt_revision_kind.head )
610
revision = pysvn.Revision(pysvn.opt_revision_kind.number,
612
except ValueError, e:
613
raise ActionError("Bad revision number: '%s'"%revision,)
614
path = actionpath_to_local(req, path).decode('utf-8')
617
svnclient.update(path, recurse=True, revision=revision)
618
except pysvn.ClientError, e:
619
raise ActionError(str(e))
621
def action_svnresolved(req, fields):
622
"""Performs a "svn resolved" to each file specified.
626
path = fields.getfirst('path')
628
raise ActionError("Required field missing")
629
path = actionpath_to_local(req, path).decode('utf-8')
632
svnclient.resolved(path, recurse=True)
633
except pysvn.ClientError, e:
634
raise ActionError(str(e))
636
def action_svnrevert(req, fields):
637
"""Performs a "svn revert" to each file specified.
639
Reads fields: 'path' (multiple)
641
paths = fields.getlist('path')
642
paths = map(lambda path: actionpath_to_local(req, path).decode('utf-8'),
646
svnclient.revert(paths, recurse=True)
647
except pysvn.ClientError, e:
648
raise ActionError(str(e))
650
def action_svncommit(req, fields):
651
"""Performs a "svn commit" to each file specified.
653
Reads fields: 'path' (multiple), 'logmsg' (optional)
655
paths = fields.getlist('path')
657
paths = map(lambda path:actionpath_to_local(req,path).decode('utf-8'),
660
paths = [studpath.to_home_path(req.path).decode('utf-8')]
661
logmsg = str(fields.getfirst('logmsg',
662
DEFAULT_LOGMESSAGE)).decode('utf-8')
663
if logmsg == '': logmsg = DEFAULT_LOGMESSAGE
666
svnclient.checkin(paths, logmsg, recurse=True)
667
except pysvn.ClientError, e:
668
raise ActionError(str(e))
670
def action_svncheckout(req, fields):
671
"""Performs a "svn checkout" of the first path into the second path.
673
Reads fields: 'path' (multiple)
675
paths = fields.getlist('path')
677
raise ActionError("usage: svncheckout url local-path")
678
url = ivle.conf.svn_addr + "/" + urllib.quote(paths[0])
679
local_path = actionpath_to_local(req, str(paths[1]))
680
url = url.decode('utf-8')
681
local_path = local_path.decode('utf-8')
683
svnclient.checkout(url, local_path, recurse=True)
684
except pysvn.ClientError, e:
685
raise ActionError(str(e))
687
def action_svnrepomkdir(req, fields):
688
"""Performs a "svn mkdir" on a path under the IVLE SVN root.
692
path = fields.getfirst('path')
693
logmsg = fields.getfirst('logmsg')
694
url = (ivle.conf.svn_addr + "/" + urllib.quote(path)).decode('utf-8')
696
svnclient.mkdir(url, log_message=logmsg)
697
except pysvn.ClientError, e:
698
raise ActionError(str(e))
700
def action_svnrepostat(req, fields):
701
"""Discovers whether a path exists in a repo under the IVLE SVN root.
703
If it does exist, returns a dict containing its metadata.
707
path = fields.getfirst('path')
708
url = (ivle.conf.svn_addr + "/" + urllib.quote(path)).decode('utf-8')
709
svnclient.exception_style = 1
712
info = svnclient.info2(url,
713
revision=pysvn.Revision(pysvn.opt_revision_kind.head))[0][1]
714
return {'svnrevision': info['rev'].number
716
info['rev'].kind == pysvn.opt_revision_kind.number
718
except pysvn.ClientError, e:
719
# Error code 170000 means ENOENT in this revision.
720
if e[1][0][1] == 170000:
721
raise util.IVLEError(404, 'The specified repository path does not exist')
723
raise ActionError(str(e[0]))
726
def action_svncleanup(req, fields):
727
"""Recursively clean up the working copy, removing locks, resuming
728
unfinished operations, etc.
729
path: The path to be cleaned"""
731
path = fields.getfirst('path')
733
raise ActionError("Required field missing")
734
path = actionpath_to_local(req, path).decode('utf-8')
737
svnclient.cleanup(path)
738
except pysvn.ClientError, e:
739
raise ActionError(str(e))
742
# Table of all action functions #
743
# Each function has the interface f(req, fields).
746
"delete" : action_delete,
747
"move" : action_move,
748
"mkdir" : action_mkdir,
749
"putfile" : action_putfile,
750
"putfiles" : action_putfiles,
751
"paste" : action_paste,
752
"publish" : action_publish,
753
"unpublish" : action_unpublish,
755
"svnadd" : action_svnadd,
756
"svnremove" : action_svnremove,
757
"svnupdate" : action_svnupdate,
758
"svnresolved" : action_svnresolved,
759
"svnrevert" : action_svnrevert,
760
"svncommit" : action_svncommit,
761
"svncheckout" : action_svncheckout,
762
"svnrepomkdir" : action_svnrepomkdir,
763
"svnrepostat" : action_svnrepostat,
764
"svncleanup" : action_svncleanup,