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
# TODO: Implement the following actions:
115
# TODO: Implement ZIP unpacking in putfiles (done?).
116
# TODO: svnupdate needs a digest to tell the user the files that were updated.
117
# This can be implemented by some message passing between action and
118
# listing, and having the digest included in the listing. (Problem if
119
# the listing is not a directory, but we could make it an error to do an
120
# update if the path is not a directory).
128
from common import (util, studpath, zip)
131
def get_login(_realm, existing_login, _may_save):
132
"""Callback function used by pysvn for authentication.
133
realm, existing_login, _may_save: The 3 arguments passed by pysvn to
135
The following has been determined empirically, not from docs:
136
existing_login will be the name of the user who owns the process on
137
the first attempt, "" on subsequent attempts. We use this fact.
139
# Only provide credentials on the _first_ attempt.
140
# If we're being asked again, then it means the credentials failed for
141
# some reason and we should just fail. (This is not desirable, but it's
142
# better than being asked an infinite number of times).
143
return (existing_login != "", conf.login, conf.svn_pass, True)
145
# Make a Subversion client object
146
svnclient = pysvn.Client()
147
svnclient.callback_get_login = get_login
148
svnclient.exception_style = 0 # Simple (string) exceptions
150
DEFAULT_LOGMESSAGE = "No log message supplied."
153
# application/json is the "best" content type but is not good for
154
# debugging because Firefox just tries to download it
155
mime_dirlisting = "text/html"
156
#mime_dirlisting = "application/json"
158
class ActionError(Exception):
159
"""Represents an error processing an action. This can be
160
raised by any of the action functions, and will be caught
161
by the top-level handler, put into the HTTP response field,
164
Important Security Consideration: The message passed to this
165
exception will be relayed to the client.
169
def handle_action(req, action, fields):
170
"""Perform the "action" part of the response.
171
This function should only be called if the response is a POST.
172
This performs the action's side-effect on the server. If unsuccessful,
173
writes the X-IVLE-Action-Error header to the request object. Otherwise,
174
does not touch the request object. Does NOT write any bytes in response.
176
May throw an ActionError. The caller should put this string into the
177
X-IVLE-Action-Error header, and then continue normally.
179
action: String, the action requested. Not sanitised.
180
fields: FieldStorage object containing all arguments passed.
182
global actions_table # Table of function objects
184
action = actions_table[action]
186
# Default, just send an error but then continue
187
raise ActionError("Unknown action")
190
def actionpath_to_urlpath(req, path):
191
"""Determines the URL path (relative to the student home) upon which the
192
action is intended to act. See actionpath_to_local.
196
elif len(path) > 0 and path[0] == os.sep:
197
# Relative to student home
200
# Relative to req.path
201
return os.path.join(req.path, path)
203
def actionpath_to_local(req, path):
204
"""Determines the local path upon which an action is intended to act.
205
Note that fileservice actions accept two paths: the request path,
206
and the "path" argument given to the action.
207
According to the rules, if the "path" argument begins with a '/' it is
208
relative to the user's home; if it does not, it is relative to the
211
This resolves the path, given the request and path argument.
213
May raise an ActionError("Invalid path"). The caller is expected to
214
let this fall through to the top-level handler, where it will be
215
put into the HTTP response field. Never returns None.
219
(_, _, r) = studpath.url_to_jailpaths(actionpath_to_urlpath(req, path))
221
raise ActionError("Invalid path")
224
def movefile(req, frompath, topath, copy=False):
225
"""Performs a file move, resolving filenames, checking for any errors,
226
and throwing ActionErrors if necessary. Can also be used to do a copy
229
frompath and topath are straight paths from the client. Will be checked.
231
# TODO: Do an SVN mv if the file is versioned.
232
# TODO: Disallow tampering with student's home directory
233
if frompath is None or topath is None:
234
raise ActionError("Required field missing")
235
frompath = actionpath_to_local(req, frompath)
236
topath = actionpath_to_local(req, topath)
237
if not os.path.exists(frompath):
238
raise ActionError("The source file does not exist")
239
if os.path.exists(topath):
240
if frompath == topath:
241
raise ActionError("Source and destination are the same")
242
raise ActionError("A file already exists with that name")
246
if os.path.isdir(frompath):
247
shutil.copytree(frompath, topath)
249
shutil.copy2(frompath, topath)
251
shutil.move(frompath, topath)
253
raise ActionError("Could not move the file specified")
255
raise ActionError("Could not move the file specified")
259
def action_delete(req, fields):
260
# TODO: Disallow removal of student's home directory
261
"""Removes a list of files or directories.
263
Reads fields: 'path' (multiple)
265
paths = fields.getlist('path')
268
path = actionpath_to_local(req, path)
270
if os.path.isdir(path):
280
raise ActionError("Could not delete the file specified")
283
"Could not delete one or more of the files specified")
285
def action_move(req, fields):
286
# TODO: Do an SVN mv if the file is versioned.
287
# TODO: Disallow tampering with student's home directory
288
"""Removes a list of files or directories.
290
Reads fields: 'from', 'to'
292
frompath = fields.getfirst('from')
293
topath = fields.getfirst('to')
294
movefile(req, frompath, topath)
296
def action_mkdir(req, fields):
297
"""Creates a directory with the given path.
300
path = fields.getfirst('path')
302
raise ActionError("Required field missing")
303
path = actionpath_to_local(req, path)
305
if os.path.exists(path):
306
raise ActionError("A file already exists with that name")
308
# Create the directory
312
raise ActionError("Could not create directory")
314
def action_putfile(req, fields):
315
"""Writes data to a file, overwriting it if it exists and creating it if
318
Reads fields: 'path', 'data' (file upload), 'overwrite'
320
# TODO: Read field "unpack".
321
# Important: Data is "None" if the file submitted is empty.
322
path = fields.getfirst('path')
323
data = fields.getfirst('data')
325
raise ActionError("Required field missing")
327
# Workaround - field reader treats "" as None, so this is the only
328
# way to allow blank file uploads
330
path = actionpath_to_local(req, path)
333
data = cStringIO.StringIO(data)
335
overwrite = fields.getfirst('overwrite')
336
if overwrite is None:
342
# Overwrite files; but can't if it's a directory
343
if os.path.isdir(path):
344
raise ActionError("A directory already exists "
347
if os.path.exists(path):
348
raise ActionError("A file already exists with that name")
350
# Copy the contents of file object 'data' to the path 'path'
352
dest = open(path, 'wb')
354
shutil.copyfileobj(data, dest)
355
except (IOError, OSError), e:
356
raise ActionError("Could not write to target file: %s" % e.strerror)
358
def action_putfiles(req, fields):
359
"""Writes data to one or more files in a directory, overwriting them if
362
Reads fields: 'path', 'data' (file upload, multiple), 'unpack'
365
# Important: Data is "None" if the file submitted is empty.
366
path = fields.getfirst('path')
367
data = fields['data']
368
if type(data) != type([]):
370
unpack = fields.getfirst('unpack')
376
raise ActionError("Required field missing")
377
path = actionpath_to_urlpath(req, path)
381
# Each of the uploaded files
382
filepath = os.path.join(path, datum.filename)
383
filedata = datum.file
385
if unpack and datum.filename.lower().endswith(".zip"):
386
# A zip file - unpack it instead of just copying
387
# TODO: Use the magic number instead of file extension
388
# Note: Just unzip into the current directory (ignore the
391
# First get the entire path (within jail)
392
_, _, abspath = studpath.url_to_jailpaths(path)
393
abspath = os.path.join(os.sep, abspath)
394
zip.unzip(abspath, filedata)
395
except (OSError, IOError):
399
(_, _, filepath_local) = studpath.url_to_jailpaths(filepath)
400
if filepath_local is None:
401
raise ActionError("Invalid path")
403
# Copy the contents of file object 'data' to the path 'path'
405
dest = open(filepath_local, 'wb')
407
shutil.copyfileobj(filedata, dest)
413
raise ActionError("Could not write to target file")
416
"Could not write to one or more of the target files")
418
def action_paste(req, fields):
419
"""Performs the copy or move action with the files specified.
420
Copies/moves the files to the specified directory.
422
Reads fields: 'src', 'dst', 'mode', 'file' (multiple).
423
src: Base path that all the files are relative to (source).
424
dst: Destination path to paste into.
425
mode: 'copy' or 'move'.
426
file: (Multiple) Files relative to base, which will be copied
427
or moved to new locations relative to path.
431
dst = fields.getfirst('dst')
432
src = fields.getfirst('src')
433
mode = fields.getfirst('mode')
434
files = fields.getlist('file')
435
if dst is None or src is None or mode is None:
436
raise ActionError("Required field missing")
442
raise ActionError("Invalid mode (must be 'copy' or 'move')")
443
dst_local = actionpath_to_local(req, dst)
444
if not os.path.isdir(dst_local):
445
raise ActionError("dst is not a directory")
449
# The source must not be interpreted as relative to req.path
450
# Add a slash (relative to top-level)
453
frompath = os.path.join(src, file)
454
# The destination is found by taking just the basename of the file
455
topath = os.path.join(dst, os.path.basename(file))
457
movefile(req, frompath, topath, copy)
458
except ActionError, message:
459
# Store the error for later; we want to copy as many as possible
463
# Multiple errors; generic message
464
errormsg = "One or more files could not be pasted"
465
# Add this file to errorfiles; it will be put back on the
466
# clipboard for possible future pasting.
467
errorfiles.append(file)
468
if errormsg is not None:
469
raise ActionError(errormsg)
471
# XXX errorfiles contains a list of files that couldn't be pasted.
472
# we currently do nothing with this.
474
def action_publish(req,fields):
475
"""Marks the folder as published by adding a '.published' file to the
476
directory and ensuring that the parent directory permissions are correct
480
paths = fields.getlist('path')
481
user = studpath.url_to_local(req.path)[0]
482
homedir = "/home/%s" % user
484
paths = map(lambda path: actionpath_to_local(req, path), paths)
486
paths = [studpath.url_to_jailpaths(req.path)[2]]
488
# Set all the dirs in home dir world browsable (o+r,o+x)
489
#FIXME: Should really only do those in the direct path not all of the
490
# folders in a students home directory
491
for root,dirs,files in os.walk(homedir):
492
os.chmod(root, os.stat(root).st_mode|0005)
496
if os.path.isdir(path):
497
pubfile = open(os.path.join(path,'.published'),'w')
498
pubfile.write("This directory is published\n")
501
raise ActionError("Can only publish directories")
503
raise ActionError("Directory could not be published")
505
def action_unpublish(req,fields):
506
"""Marks the folder as unpublished by removing a '.published' file in the
507
directory (if it exits). It does not change the permissions of the parent
512
paths = fields.getlist('path')
514
paths = map(lambda path: actionpath_to_local(req, path), paths)
516
paths = [studpath.url_to_jailpaths(req.path)[2]]
520
if os.path.isdir(path):
521
pubfile = os.path.join(path,'.published')
522
if os.path.isfile(pubfile):
525
raise ActionError("Can only unpublish directories")
527
raise ActionError("Directory could not be unpublished")
530
def action_svnadd(req, fields):
531
"""Performs a "svn add" to each file specified.
533
Reads fields: 'path' (multiple)
535
paths = fields.getlist('path')
536
paths = map(lambda path: actionpath_to_local(req, path), paths)
539
svnclient.add(paths, recurse=True, force=True)
540
except pysvn.ClientError, e:
541
raise ActionError(str(e))
543
def action_svnremove(req, fields):
544
"""Performs a "svn remove" on each file specified.
546
Reads fields: 'path' (multiple)
548
paths = fields.getlist('path')
549
paths = map(lambda path: actionpath_to_local(req, path), paths)
552
svnclient.remove(paths, force=True)
553
except pysvn.ClientError, e:
554
raise ActionError(str(e))
556
def action_svnupdate(req, fields):
557
"""Performs a "svn update" to each file specified.
561
path = fields.getfirst('path')
563
raise ActionError("Required field missing")
564
path = actionpath_to_local(req, path)
567
svnclient.update(path, recurse=True)
568
except pysvn.ClientError, e:
569
raise ActionError(str(e))
571
def action_svnresolved(req, fields):
572
"""Performs a "svn resolved" to each file specified.
576
path = fields.getfirst('path')
578
raise ActionError("Required field missing")
579
path = actionpath_to_local(req, path)
582
svnclient.resolved(path, recurse=True)
583
except pysvn.ClientError, e:
584
raise ActionError(str(e))
586
def action_svnrevert(req, fields):
587
"""Performs a "svn revert" to each file specified.
589
Reads fields: 'path' (multiple)
591
paths = fields.getlist('path')
592
paths = map(lambda path: actionpath_to_local(req, path), paths)
595
svnclient.revert(paths, recurse=True)
596
except pysvn.ClientError, e:
597
raise ActionError(str(e))
599
def action_svnpublish(req, fields):
600
"""Sets svn property "ivle:published" on each file specified.
601
Should only be called on directories (only effective on directories
606
XXX Currently unused by the client (calls action_publish instead, which
607
has a completely different publishing model).
609
paths = fields.getlist('path')
611
paths = map(lambda path: actionpath_to_local(req, path), paths)
613
paths = [studpath.url_to_jailpaths(req.path)[2]]
617
# Note: Property value doesn't matter
618
svnclient.propset("ivle:published", "", path, recurse=False)
619
except pysvn.ClientError, e:
620
raise ActionError("Directory could not be published")
622
def action_svnunpublish(req, fields):
623
"""Deletes svn property "ivle:published" on each file specified.
627
XXX Currently unused by the client (calls action_unpublish instead, which
628
has a completely different publishing model).
630
paths = fields.getlist('path')
631
paths = map(lambda path: actionpath_to_local(req, path), paths)
635
svnclient.propdel("ivle:published", path, recurse=False)
636
except pysvn.ClientError, e:
637
raise ActionError("Directory could not be unpublished")
639
def action_svncommit(req, fields):
640
"""Performs a "svn commit" to each file specified.
642
Reads fields: 'path' (multiple), 'logmsg' (optional)
644
paths = fields.getlist('path')
645
paths = map(lambda path: actionpath_to_local(req, str(path)), paths)
646
logmsg = str(fields.getfirst('logmsg', DEFAULT_LOGMESSAGE))
647
if logmsg == '': logmsg = DEFAULT_LOGMESSAGE
650
svnclient.checkin(paths, logmsg, recurse=True)
651
except pysvn.ClientError, e:
652
raise ActionError(str(e))
654
def action_svncheckout(req, fields):
655
"""Performs a "svn checkout" of each path specified.
657
Reads fields: 'path' (multiple)
659
paths = fields.getlist('path')
661
raise ActionError("usage: svncheckout url local-path")
662
url = conf.svn_addr + "/" + login + "/" + paths[0]
663
local_path = actionpath_to_local(req, str(paths[1]))
665
svnclient.callback_get_login = get_login
666
svnclient.checkout(url, local_path, recurse=True)
667
except pysvn.ClientError, e:
668
raise ActionError(str(e))
670
# Table of all action functions #
671
# Each function has the interface f(req, fields).
674
"delete" : action_delete,
675
"move" : action_move,
676
"mkdir" : action_mkdir,
677
"putfile" : action_putfile,
678
"putfiles" : action_putfiles,
679
"paste" : action_paste,
680
"publish" : action_publish,
681
"unpublish" : action_unpublish,
683
"svnadd" : action_svnadd,
684
"svnremove" : action_svnremove,
685
"svnupdate" : action_svnupdate,
686
"svnresolved" : action_svnresolved,
687
"svnrevert" : action_svnrevert,
688
"svnpublish" : action_svnpublish,
689
"svnunpublish" : action_svnunpublish,
690
"svncommit" : action_svncommit,
691
"svncheckout" : action_svncheckout,