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 != "", ivle.conf.login, ivle.conf.svn_pass, True)
154
# Make a Subversion client object
155
svnclient = pysvn.Client()
156
svnclient.callback_get_login = get_login
157
svnclient.exception_style = 0 # Simple (string) exceptions
159
DEFAULT_LOGMESSAGE = "No log message supplied."
162
# application/json is the "best" content type but is not good for
163
# debugging because Firefox just tries to download it
164
mime_dirlisting = "text/html"
165
#mime_dirlisting = "application/json"
167
class ActionError(Exception):
168
"""Represents an error processing an action. This can be
169
raised by any of the action functions, and will be caught
170
by the top-level handler, put into the HTTP response field,
173
Important Security Consideration: The message passed to this
174
exception will be relayed to the client.
178
def handle_action(req, action, fields):
179
"""Perform the "action" part of the response.
180
This function should only be called if the response is a POST.
181
This performs the action's side-effect on the server. If unsuccessful,
182
writes the X-IVLE-Action-Error header to the request object. Otherwise,
183
does not touch the request object. Does NOT write any bytes in response.
185
May throw an ActionError. The caller should put this string into the
186
X-IVLE-Action-Error header, and then continue normally.
188
action: String, the action requested. Not sanitised.
189
fields: FieldStorage object containing all arguments passed.
191
global actions_table # Table of function objects
193
action = actions_table[action]
195
# Default, just send an error but then continue
196
raise ActionError("Unknown action")
199
def actionpath_to_urlpath(req, path):
200
"""Determines the URL path (relative to the student home) upon which the
201
action is intended to act. See actionpath_to_local.
205
elif len(path) > 0 and path[0] == os.sep:
206
# Relative to student home
209
# Relative to req.path
210
return os.path.join(req.path, path)
212
def actionpath_to_local(req, path):
213
"""Determines the local path upon which an action is intended to act.
214
Note that fileservice actions accept two paths: the request path,
215
and the "path" argument given to the action.
216
According to the rules, if the "path" argument begins with a '/' it is
217
relative to the user's home; if it does not, it is relative to the
220
This resolves the path, given the request and path argument.
222
May raise an ActionError("Invalid path"). The caller is expected to
223
let this fall through to the top-level handler, where it will be
224
put into the HTTP response field. Never returns None.
228
(_, _, r) = studpath.url_to_jailpaths(actionpath_to_urlpath(req, path))
230
raise ActionError("Invalid path")
233
def movefile(req, frompath, topath, copy=False):
234
"""Performs a file move, resolving filenames, checking for any errors,
235
and throwing ActionErrors if necessary. Can also be used to do a copy
238
frompath and topath are straight paths from the client. Will be checked.
240
# TODO: Do an SVN mv if the file is versioned.
241
# TODO: Disallow tampering with student's home directory
242
if frompath is None or topath is None:
243
raise ActionError("Required field missing")
244
frompath = actionpath_to_local(req, frompath)
245
topath = actionpath_to_local(req, topath)
246
if not os.path.exists(frompath):
247
raise ActionError("The source file does not exist")
248
if os.path.exists(topath):
249
if frompath == topath:
250
raise ActionError("Source and destination are the same")
251
raise ActionError("A file already exists with that name")
255
if os.path.isdir(frompath):
256
shutil.copytree(frompath, topath)
258
shutil.copy2(frompath, topath)
260
shutil.move(frompath, topath)
262
raise ActionError("Could not move the file specified")
264
raise ActionError("Could not move the file specified")
268
def action_delete(req, fields):
269
# TODO: Disallow removal of student's home directory
270
"""Removes a list of files or directories.
272
Reads fields: 'path' (multiple)
274
paths = fields.getlist('path')
277
path = actionpath_to_local(req, path)
279
if os.path.isdir(path):
289
raise ActionError("Could not delete the file specified")
292
"Could not delete one or more of the files specified")
294
def action_move(req, fields):
295
# TODO: Do an SVN mv if the file is versioned.
296
# TODO: Disallow tampering with student's home directory
297
"""Removes a list of files or directories.
299
Reads fields: 'from', 'to'
301
frompath = fields.getfirst('from')
302
topath = fields.getfirst('to')
303
movefile(req, frompath, topath)
305
def action_mkdir(req, fields):
306
"""Creates a directory with the given path.
309
path = fields.getfirst('path')
311
raise ActionError("Required field missing")
312
path = actionpath_to_local(req, path)
314
if os.path.exists(path):
315
raise ActionError("A file already exists with that name")
317
# Create the directory
321
raise ActionError("Could not create directory")
323
def action_putfile(req, fields):
324
"""Writes data to a file, overwriting it if it exists and creating it if
327
Reads fields: 'path', 'data' (file upload), 'overwrite'
329
# TODO: Read field "unpack".
330
# Important: Data is "None" if the file submitted is empty.
331
path = fields.getfirst('path')
332
data = fields.getfirst('data')
334
raise ActionError("Required field missing")
336
# Workaround - field reader treats "" as None, so this is the only
337
# way to allow blank file uploads
339
path = actionpath_to_local(req, path)
342
data = cStringIO.StringIO(data)
344
overwrite = fields.getfirst('overwrite')
345
if overwrite is None:
351
# Overwrite files; but can't if it's a directory
352
if os.path.isdir(path):
353
raise ActionError("A directory already exists "
356
if os.path.exists(path):
357
raise ActionError("A file already exists with that name")
359
# Copy the contents of file object 'data' to the path 'path'
361
dest = open(path, 'wb')
363
shutil.copyfileobj(data, dest)
364
except (IOError, OSError), e:
365
raise ActionError("Could not write to target file: %s" % e.strerror)
367
def action_putfiles(req, fields):
368
"""Writes data to one or more files in a directory, overwriting them if
371
Reads fields: 'path', 'data' (file upload, multiple), 'unpack'
374
# Important: Data is "None" if the file submitted is empty.
375
path = fields.getfirst('path')
376
data = fields['data']
377
if type(data) != type([]):
379
unpack = fields.getfirst('unpack')
385
raise ActionError("Required field missing")
386
path = actionpath_to_urlpath(req, path)
390
# Each of the uploaded files
391
filepath = os.path.join(path, datum.filename)
392
filedata = datum.file
394
if unpack and datum.filename.lower().endswith(".zip"):
395
# A zip file - unpack it instead of just copying
396
# TODO: Use the magic number instead of file extension
397
# Note: Just unzip into the current directory (ignore the
400
# First get the entire path (within jail)
401
_, _, abspath = studpath.url_to_jailpaths(path)
402
abspath = os.path.join(os.sep, abspath)
403
zip.unzip(abspath, filedata)
404
except (OSError, IOError):
408
(_, _, filepath_local) = studpath.url_to_jailpaths(filepath)
409
if filepath_local is None:
410
raise ActionError("Invalid path")
412
# Copy the contents of file object 'data' to the path 'path'
414
dest = open(filepath_local, 'wb')
416
shutil.copyfileobj(filedata, dest)
417
except (OSError, IOError):
418
# TODO: Be more descriptive.
423
raise ActionError("Could not write to target file")
426
"Could not write to one or more of the target files")
428
def action_paste(req, fields):
429
"""Performs the copy or move action with the files specified.
430
Copies/moves the files to the specified directory.
432
Reads fields: 'src', 'dst', 'mode', 'file' (multiple).
433
src: Base path that all the files are relative to (source).
434
dst: Destination path to paste into.
435
mode: 'copy' or 'move'.
436
file: (Multiple) Files relative to base, which will be copied
437
or moved to new locations relative to path.
441
dst = fields.getfirst('dst')
442
src = fields.getfirst('src')
443
mode = fields.getfirst('mode')
444
files = fields.getlist('file')
445
if dst is None or src is None or mode is None:
446
raise ActionError("Required field missing")
452
raise ActionError("Invalid mode (must be 'copy' or 'move')")
453
dst_local = actionpath_to_local(req, dst)
454
if not os.path.isdir(dst_local):
455
raise ActionError("dst is not a directory")
459
# The source must not be interpreted as relative to req.path
460
# Add a slash (relative to top-level)
463
frompath = os.path.join(src, file)
464
# The destination is found by taking just the basename of the file
465
topath = os.path.join(dst, os.path.basename(file))
467
movefile(req, frompath, topath, copy)
468
except ActionError, message:
469
# Store the error for later; we want to copy as many as possible
473
# Multiple errors; generic message
474
errormsg = "One or more files could not be pasted"
475
# Add this file to errorfiles; it will be put back on the
476
# clipboard for possible future pasting.
477
errorfiles.append(file)
478
if errormsg is not None:
479
raise ActionError(errormsg)
481
# XXX errorfiles contains a list of files that couldn't be pasted.
482
# we currently do nothing with this.
484
def action_publish(req,fields):
485
"""Marks the folder as published by adding a '.published' file to the
486
directory and ensuring that the parent directory permissions are correct
490
paths = fields.getlist('path')
491
user = studpath.url_to_local(req.path)[0]
492
homedir = "/home/%s" % user
494
paths = map(lambda path: actionpath_to_local(req, path), paths)
496
paths = [studpath.url_to_jailpaths(req.path)[2]]
498
# Set all the dirs in home dir world browsable (o+r,o+x)
499
#FIXME: Should really only do those in the direct path not all of the
500
# folders in a students home directory
501
for root,dirs,files in os.walk(homedir):
502
os.chmod(root, os.stat(root).st_mode|0005)
506
if os.path.isdir(path):
507
pubfile = open(os.path.join(path,'.published'),'w')
508
pubfile.write("This directory is published\n")
511
raise ActionError("Can only publish directories")
513
raise ActionError("Directory could not be published")
515
def action_unpublish(req,fields):
516
"""Marks the folder as unpublished by removing a '.published' file in the
517
directory (if it exits). It does not change the permissions of the parent
522
paths = fields.getlist('path')
524
paths = map(lambda path: actionpath_to_local(req, path), paths)
526
paths = [studpath.url_to_jailpaths(req.path)[2]]
530
if os.path.isdir(path):
531
pubfile = os.path.join(path,'.published')
532
if os.path.isfile(pubfile):
535
raise ActionError("Can only unpublish directories")
537
raise ActionError("Directory could not be unpublished")
540
def action_svnadd(req, fields):
541
"""Performs a "svn add" to each file specified.
543
Reads fields: 'path' (multiple)
545
paths = fields.getlist('path')
546
paths = map(lambda path: actionpath_to_local(req, path), paths)
549
svnclient.add(paths, recurse=True, force=True)
550
except pysvn.ClientError, e:
551
raise ActionError(str(e))
553
def action_svnremove(req, fields):
554
"""Performs a "svn remove" on each file specified.
556
Reads fields: 'path' (multiple)
558
paths = fields.getlist('path')
559
paths = map(lambda path: actionpath_to_local(req, path), paths)
562
svnclient.remove(paths, force=True)
563
except pysvn.ClientError, e:
564
raise ActionError(str(e))
566
def action_svnupdate(req, fields):
567
"""Performs a "svn update" to each file specified.
571
path = fields.getfirst('path')
573
raise ActionError("Required field missing")
574
path = actionpath_to_local(req, path)
577
svnclient.update(path, recurse=True)
578
except pysvn.ClientError, e:
579
raise ActionError(str(e))
581
def action_svnresolved(req, fields):
582
"""Performs a "svn resolved" to each file specified.
586
path = fields.getfirst('path')
588
raise ActionError("Required field missing")
589
path = actionpath_to_local(req, path)
592
svnclient.resolved(path, recurse=True)
593
except pysvn.ClientError, e:
594
raise ActionError(str(e))
596
def action_svnrevert(req, fields):
597
"""Performs a "svn revert" to each file specified.
599
Reads fields: 'path' (multiple)
601
paths = fields.getlist('path')
602
paths = map(lambda path: actionpath_to_local(req, path), paths)
605
svnclient.revert(paths, recurse=True)
606
except pysvn.ClientError, e:
607
raise ActionError(str(e))
609
def action_svnpublish(req, fields):
610
"""Sets svn property "ivle:published" on each file specified.
611
Should only be called on directories (only effective on directories
616
XXX Currently unused by the client (calls action_publish instead, which
617
has a completely different publishing model).
619
paths = fields.getlist('path')
621
paths = map(lambda path: actionpath_to_local(req, path), paths)
623
paths = [studpath.url_to_jailpaths(req.path)[2]]
627
# Note: Property value doesn't matter
628
svnclient.propset("ivle:published", "", path, recurse=False)
629
except pysvn.ClientError, e:
630
raise ActionError("Directory could not be published")
632
def action_svnunpublish(req, fields):
633
"""Deletes svn property "ivle:published" on each file specified.
637
XXX Currently unused by the client (calls action_unpublish instead, which
638
has a completely different publishing model).
640
paths = fields.getlist('path')
641
paths = map(lambda path: actionpath_to_local(req, path), paths)
645
svnclient.propdel("ivle:published", path, recurse=False)
646
except pysvn.ClientError, e:
647
raise ActionError("Directory could not be unpublished")
649
def action_svncommit(req, fields):
650
"""Performs a "svn commit" to each file specified.
652
Reads fields: 'path' (multiple), 'logmsg' (optional)
654
paths = fields.getlist('path')
655
paths = map(lambda path: actionpath_to_local(req, str(path)), paths)
656
logmsg = str(fields.getfirst('logmsg', DEFAULT_LOGMESSAGE))
657
if logmsg == '': logmsg = DEFAULT_LOGMESSAGE
660
svnclient.checkin(paths, logmsg, recurse=True)
661
except pysvn.ClientError, e:
662
raise ActionError(str(e))
664
def action_svncheckout(req, fields):
665
"""Performs a "svn checkout" of the first path into the second path.
667
Reads fields: 'path' (multiple)
669
paths = fields.getlist('path')
671
raise ActionError("usage: svncheckout url local-path")
672
url = ivle.conf.svn_addr + "/" + paths[0]
673
local_path = actionpath_to_local(req, str(paths[1]))
675
svnclient.callback_get_login = get_login
676
svnclient.checkout(url, local_path, recurse=True)
677
except pysvn.ClientError, e:
678
raise ActionError(str(e))
680
def action_svnrepomkdir(req, fields):
681
"""Performs a "svn mkdir" on a path under the IVLE SVN root.
685
path = fields.getfirst('path')
686
logmsg = fields.getfirst('logmsg')
687
url = ivle.conf.svn_addr + "/" + path
689
svnclient.callback_get_login = get_login
690
svnclient.mkdir(url, log_message=logmsg)
691
except pysvn.ClientError, e:
692
raise ActionError(str(e))
694
def action_svnrepostat(req, fields):
695
"""Discovers whether a path exists in a repo under the IVLE SVN root.
699
path = fields.getfirst('path')
700
url = ivle.conf.svn_addr + "/" + path
701
svnclient.exception_style = 1
704
svnclient.callback_get_login = get_login
706
except pysvn.ClientError, e:
707
# Error code 170000 means ENOENT in this revision.
708
if e[1][0][1] == 170000:
709
raise util.IVLEError(404, 'The specified repository path does not exist')
711
raise ActionError(str(e[0]))
713
# Table of all action functions #
714
# Each function has the interface f(req, fields).
717
"delete" : action_delete,
718
"move" : action_move,
719
"mkdir" : action_mkdir,
720
"putfile" : action_putfile,
721
"putfiles" : action_putfiles,
722
"paste" : action_paste,
723
"publish" : action_publish,
724
"unpublish" : action_unpublish,
726
"svnadd" : action_svnadd,
727
"svnremove" : action_svnremove,
728
"svnupdate" : action_svnupdate,
729
"svnresolved" : action_svnresolved,
730
"svnrevert" : action_svnrevert,
731
"svnpublish" : action_svnpublish,
732
"svnunpublish" : action_svnunpublish,
733
"svncommit" : action_svncommit,
734
"svncheckout" : action_svncheckout,
735
"svnrepomkdir" : action_svnrepomkdir,
736
"svnrepostat" : action_svnrepostat,