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)
140
def get_login(_realm, existing_login, _may_save):
141
"""Callback function used by pysvn for authentication.
142
realm, existing_login, _may_save: The 3 arguments passed by pysvn to
144
The following has been determined empirically, not from docs:
145
existing_login will be the name of the user who owns the process on
146
the first attempt, "" on subsequent attempts. We use this fact.
148
# Only provide credentials on the _first_ attempt.
149
# If we're being asked again, then it means the credentials failed for
150
# some reason and we should just fail. (This is not desirable, but it's
151
# better than being asked an infinite number of times).
152
return (existing_login != "", str(ivle.conf.login),
153
str(ivle.conf.svn_pass), True)
155
# Make a Subversion client object
156
svnclient = pysvn.Client()
157
svnclient.callback_get_login = get_login
158
svnclient.exception_style = 0 # Simple (string) exceptions
160
DEFAULT_LOGMESSAGE = "No log message supplied."
163
# application/json is the "best" content type but is not good for
164
# debugging because Firefox just tries to download it
165
mime_dirlisting = "text/html"
166
#mime_dirlisting = "application/json"
168
class ActionError(Exception):
169
"""Represents an error processing an action. This can be
170
raised by any of the action functions, and will be caught
171
by the top-level handler, put into the HTTP response field,
174
Important Security Consideration: The message passed to this
175
exception will be relayed to the client.
179
def handle_action(req, action, fields):
180
"""Perform the "action" part of the response.
181
This function should only be called if the response is a POST.
182
This performs the action's side-effect on the server. If unsuccessful,
183
writes the X-IVLE-Action-Error header to the request object. Otherwise,
184
does not touch the request object. Does NOT write any bytes in response.
186
May throw an ActionError. The caller should put this string into the
187
X-IVLE-Action-Error header, and then continue normally.
189
action: String, the action requested. Not sanitised.
190
fields: FieldStorage object containing all arguments passed.
192
global actions_table # Table of function objects
194
action = actions_table[action]
196
# Default, just send an error but then continue
197
raise ActionError("Unknown action")
200
def actionpath_to_urlpath(req, path):
201
"""Determines the URL path (relative to the student home) upon which the
202
action is intended to act. See actionpath_to_local.
206
elif len(path) > 0 and path[0] == os.sep:
207
# Relative to student home
210
# Relative to req.path
211
return os.path.join(req.path, path)
213
def actionpath_to_local(req, path):
214
"""Determines the local path upon which an action is intended to act.
215
Note that fileservice actions accept two paths: the request path,
216
and the "path" argument given to the action.
217
According to the rules, if the "path" argument begins with a '/' it is
218
relative to the user's home; if it does not, it is relative to the
221
This resolves the path, given the request and path argument.
223
May raise an ActionError("Invalid path"). The caller is expected to
224
let this fall through to the top-level handler, where it will be
225
put into the HTTP response field. Never returns None.
229
(_, _, r) = studpath.url_to_jailpaths(actionpath_to_urlpath(req, path))
231
raise ActionError("Invalid path")
234
def movefile(req, frompath, topath, copy=False):
235
"""Performs a file move, resolving filenames, checking for any errors,
236
and throwing ActionErrors if necessary. Can also be used to do a copy
239
frompath and topath are straight paths from the client. Will be checked.
241
# TODO: Do an SVN mv if the file is versioned.
242
# TODO: Disallow tampering with student's home directory
243
if frompath is None or topath is None:
244
raise ActionError("Required field missing")
245
frompath = actionpath_to_local(req, frompath)
246
topath = actionpath_to_local(req, topath)
247
if not os.path.exists(frompath):
248
raise ActionError("The source file does not exist")
249
if os.path.exists(topath):
250
if frompath == topath:
251
raise ActionError("Source and destination are the same")
252
raise ActionError("A file already exists with that name")
256
if os.path.isdir(frompath):
257
shutil.copytree(frompath, topath)
259
shutil.copy2(frompath, topath)
261
shutil.move(frompath, topath)
263
raise ActionError("Could not move the file specified")
265
raise ActionError("Could not move the file specified")
269
def action_delete(req, fields):
270
# TODO: Disallow removal of student's home directory
271
"""Removes a list of files or directories.
273
Reads fields: 'path' (multiple)
275
paths = fields.getlist('path')
278
path = actionpath_to_local(req, path)
280
if os.path.isdir(path):
290
raise ActionError("Could not delete the file specified")
293
"Could not delete one or more of the files specified")
295
def action_move(req, fields):
296
# TODO: Do an SVN mv if the file is versioned.
297
# TODO: Disallow tampering with student's home directory
298
"""Removes a list of files or directories.
300
Reads fields: 'from', 'to'
302
frompath = fields.getfirst('from')
303
topath = fields.getfirst('to')
304
movefile(req, frompath, topath)
306
def action_mkdir(req, fields):
307
"""Creates a directory with the given path.
310
path = fields.getfirst('path')
312
raise ActionError("Required field missing")
313
path = actionpath_to_local(req, path)
315
if os.path.exists(path):
316
raise ActionError("A file already exists with that name")
318
# Create the directory
322
raise ActionError("Could not create directory")
324
def action_putfile(req, fields):
325
"""Writes data to a file, overwriting it if it exists and creating it if
328
Reads fields: 'path', 'data' (file upload), 'overwrite'
330
# TODO: Read field "unpack".
331
# Important: Data is "None" if the file submitted is empty.
332
path = fields.getfirst('path')
333
data = fields.getfirst('data')
335
raise ActionError("Required field missing")
337
# Workaround - field reader treats "" as None, so this is the only
338
# way to allow blank file uploads
340
path = actionpath_to_local(req, path)
343
data = cStringIO.StringIO(data)
345
overwrite = fields.getfirst('overwrite')
346
if overwrite is None:
352
# Overwrite files; but can't if it's a directory
353
if os.path.isdir(path):
354
raise ActionError("A directory already exists "
357
if os.path.exists(path):
358
raise ActionError("A file already exists with that name")
360
# Copy the contents of file object 'data' to the path 'path'
362
dest = open(path, 'wb')
364
shutil.copyfileobj(data, dest)
365
except (IOError, OSError), e:
366
raise ActionError("Could not write to target file: %s" % e.strerror)
368
def action_putfiles(req, fields):
369
"""Writes data to one or more files in a directory, overwriting them if
372
Reads fields: 'path', 'data' (file upload, multiple), 'unpack'
375
# Important: Data is "None" if the file submitted is empty.
376
path = fields.getfirst('path')
377
data = fields['data']
378
if type(data) != type([]):
380
unpack = fields.getfirst('unpack')
386
raise ActionError("Required field missing")
387
path = actionpath_to_urlpath(req, path)
391
# Each of the uploaded files
392
filepath = os.path.join(path, datum.filename)
393
filedata = datum.file
395
if unpack and datum.filename.lower().endswith(".zip"):
396
# A zip file - unpack it instead of just copying
397
# TODO: Use the magic number instead of file extension
398
# Note: Just unzip into the current directory (ignore the
401
# First get the entire path (within jail)
402
_, _, abspath = studpath.url_to_jailpaths(path)
403
abspath = os.path.join(os.sep, abspath)
404
zip.unzip(abspath, filedata)
405
except (OSError, IOError):
409
(_, _, filepath_local) = studpath.url_to_jailpaths(filepath)
410
if filepath_local is None:
411
raise ActionError("Invalid path")
413
# Copy the contents of file object 'data' to the path 'path'
415
dest = open(filepath_local, 'wb')
417
shutil.copyfileobj(filedata, dest)
418
except (OSError, IOError):
419
# TODO: Be more descriptive.
424
raise ActionError("Could not write to target file")
427
"Could not write to one or more of the target files")
429
def action_paste(req, fields):
430
"""Performs the copy or move action with the files specified.
431
Copies/moves the files to the specified directory.
433
Reads fields: 'src', 'dst', 'mode', 'file' (multiple).
434
src: Base path that all the files are relative to (source).
435
dst: Destination path to paste into.
436
mode: 'copy' or 'move'.
437
file: (Multiple) Files relative to base, which will be copied
438
or moved to new locations relative to path.
442
dst = fields.getfirst('dst')
443
src = fields.getfirst('src')
444
mode = fields.getfirst('mode')
445
files = fields.getlist('file')
446
if dst is None or src is None or mode is None:
447
raise ActionError("Required field missing")
453
raise ActionError("Invalid mode (must be 'copy' or 'move')")
454
dst_local = actionpath_to_local(req, dst)
455
if not os.path.isdir(dst_local):
456
raise ActionError("dst is not a directory")
460
# The source must not be interpreted as relative to req.path
461
# Add a slash (relative to top-level)
464
frompath = os.path.join(src, file)
465
# The destination is found by taking just the basename of the file
466
topath = os.path.join(dst, os.path.basename(file))
468
movefile(req, frompath, topath, copy)
469
except ActionError, message:
470
# Store the error for later; we want to copy as many as possible
474
# Multiple errors; generic message
475
errormsg = "One or more files could not be pasted"
476
# Add this file to errorfiles; it will be put back on the
477
# clipboard for possible future pasting.
478
errorfiles.append(file)
479
if errormsg is not None:
480
raise ActionError(errormsg)
482
# XXX errorfiles contains a list of files that couldn't be pasted.
483
# we currently do nothing with this.
485
def action_publish(req,fields):
486
"""Marks the folder as published by adding a '.published' file to the
487
directory and ensuring that the parent directory permissions are correct
491
paths = fields.getlist('path')
492
user = studpath.url_to_local(req.path)[0]
493
homedir = "/home/%s" % user
495
paths = map(lambda path: actionpath_to_local(req, path), paths)
497
paths = [studpath.url_to_jailpaths(req.path)[2]]
499
# Set all the dirs in home dir world browsable (o+r,o+x)
500
#FIXME: Should really only do those in the direct path not all of the
501
# folders in a students home directory
502
for root,dirs,files in os.walk(homedir):
503
os.chmod(root, os.stat(root).st_mode|0005)
507
if os.path.isdir(path):
508
pubfile = open(os.path.join(path,'.published'),'w')
509
pubfile.write("This directory is published\n")
512
raise ActionError("Can only publish directories")
514
raise ActionError("Directory could not be published")
516
def action_unpublish(req,fields):
517
"""Marks the folder as unpublished by removing a '.published' file in the
518
directory (if it exits). It does not change the permissions of the parent
523
paths = fields.getlist('path')
525
paths = map(lambda path: actionpath_to_local(req, path), paths)
527
paths = [studpath.url_to_jailpaths(req.path)[2]]
531
if os.path.isdir(path):
532
pubfile = os.path.join(path,'.published')
533
if os.path.isfile(pubfile):
536
raise ActionError("Can only unpublish directories")
538
raise ActionError("Directory could not be unpublished")
541
def action_svnadd(req, fields):
542
"""Performs a "svn add" to each file specified.
544
Reads fields: 'path' (multiple)
546
paths = fields.getlist('path')
547
paths = map(lambda path: actionpath_to_local(req, path), paths)
550
svnclient.add(paths, recurse=True, force=True)
551
except pysvn.ClientError, e:
552
raise ActionError(str(e))
554
def action_svnremove(req, fields):
555
"""Performs a "svn remove" on each file specified.
557
Reads fields: 'path' (multiple)
559
paths = fields.getlist('path')
560
paths = map(lambda path: actionpath_to_local(req, path), paths)
563
svnclient.remove(paths, force=True)
564
except pysvn.ClientError, e:
565
raise ActionError(str(e))
567
def action_svnupdate(req, fields):
568
"""Performs a "svn update" to each file specified.
572
path = fields.getfirst('path')
574
raise ActionError("Required field missing")
575
path = actionpath_to_local(req, path)
578
svnclient.update(path, recurse=True)
579
except pysvn.ClientError, e:
580
raise ActionError(str(e))
582
def action_svnresolved(req, fields):
583
"""Performs a "svn resolved" to each file specified.
587
path = fields.getfirst('path')
589
raise ActionError("Required field missing")
590
path = actionpath_to_local(req, path)
593
svnclient.resolved(path, recurse=True)
594
except pysvn.ClientError, e:
595
raise ActionError(str(e))
597
def action_svnrevert(req, fields):
598
"""Performs a "svn revert" to 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.revert(paths, recurse=True)
607
except pysvn.ClientError, e:
608
raise ActionError(str(e))
610
def action_svnpublish(req, fields):
611
"""Sets svn property "ivle:published" on each file specified.
612
Should only be called on directories (only effective on directories
617
XXX Currently unused by the client (calls action_publish instead, which
618
has a completely different publishing model).
620
paths = fields.getlist('path')
622
paths = map(lambda path: actionpath_to_local(req, path), paths)
624
paths = [studpath.url_to_jailpaths(req.path)[2]]
628
# Note: Property value doesn't matter
629
svnclient.propset("ivle:published", "", path, recurse=False)
630
except pysvn.ClientError, e:
631
raise ActionError("Directory could not be published")
633
def action_svnunpublish(req, fields):
634
"""Deletes svn property "ivle:published" on each file specified.
638
XXX Currently unused by the client (calls action_unpublish instead, which
639
has a completely different publishing model).
641
paths = fields.getlist('path')
642
paths = map(lambda path: actionpath_to_local(req, path), paths)
646
svnclient.propdel("ivle:published", path, recurse=False)
647
except pysvn.ClientError, e:
648
raise ActionError("Directory could not be unpublished")
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')
656
paths = map(lambda path: actionpath_to_local(req, str(path)), paths)
657
logmsg = str(fields.getfirst('logmsg', DEFAULT_LOGMESSAGE))
658
if logmsg == '': logmsg = DEFAULT_LOGMESSAGE
661
svnclient.checkin(paths, logmsg, recurse=True)
662
except pysvn.ClientError, e:
663
raise ActionError(str(e))
665
def action_svncheckout(req, fields):
666
"""Performs a "svn checkout" of the first path into the second path.
668
Reads fields: 'path' (multiple)
670
paths = fields.getlist('path')
672
raise ActionError("usage: svncheckout url local-path")
673
url = ivle.conf.svn_addr + "/" + paths[0]
674
local_path = actionpath_to_local(req, str(paths[1]))
676
svnclient.callback_get_login = get_login
677
svnclient.checkout(url, local_path, recurse=True)
678
except pysvn.ClientError, e:
679
raise ActionError(str(e))
681
def action_svnrepomkdir(req, fields):
682
"""Performs a "svn mkdir" on a path under the IVLE SVN root.
686
path = fields.getfirst('path')
687
logmsg = fields.getfirst('logmsg')
688
url = ivle.conf.svn_addr + "/" + path
690
svnclient.callback_get_login = get_login
691
svnclient.mkdir(url, log_message=logmsg)
692
except pysvn.ClientError, e:
693
raise ActionError(str(e))
695
def action_svnrepostat(req, fields):
696
"""Discovers whether a path exists in a repo under the IVLE SVN root.
700
path = fields.getfirst('path')
701
url = ivle.conf.svn_addr + "/" + path
702
svnclient.exception_style = 1
705
svnclient.callback_get_login = get_login
707
except pysvn.ClientError, e:
708
# Error code 170000 means ENOENT in this revision.
709
if e[1][0][1] == 170000:
710
raise util.IVLEError(404, 'The specified repository path does not exist')
712
raise ActionError(str(e[0]))
714
# Table of all action functions #
715
# Each function has the interface f(req, fields).
718
"delete" : action_delete,
719
"move" : action_move,
720
"mkdir" : action_mkdir,
721
"putfile" : action_putfile,
722
"putfiles" : action_putfiles,
723
"paste" : action_paste,
724
"publish" : action_publish,
725
"unpublish" : action_unpublish,
727
"svnadd" : action_svnadd,
728
"svnremove" : action_svnremove,
729
"svnupdate" : action_svnupdate,
730
"svnresolved" : action_svnresolved,
731
"svnrevert" : action_svnrevert,
732
"svnpublish" : action_svnpublish,
733
"svnunpublish" : action_svnunpublish,
734
"svncommit" : action_svncommit,
735
"svncheckout" : action_svncheckout,
736
"svnrepomkdir" : action_svnrepomkdir,
737
"svnrepostat" : action_svnrepostat,