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=svndiff: Show differences between a file in the working copy and its
87
# state as of the current revision
88
# path: The path to the file to be diffed. Only one file may be
91
# action=svnrevert: Revert a file(s) to its state as of the current revision
93
# path: The path to the file to be reverted. Can be specified multiple
96
# action=svnupdate: Bring a file up to date with the head revision.
97
# path: The path to the file to be updated. Only one file may be
100
# action=svnpublish: Set the "published" flag on a file to True.
101
# path: The path to the file to be published. Can be specified
104
# action=svnunpublish: Set the "published" flag on a file to False.
105
# path: The path to the file to be unpublished. Can be specified
108
# action=svncommit: Commit a file(s) or directory(s) to the repository.
109
# path: The path to the file or directory to be committed. Can be
110
# specified multiple times. Directories are committed
112
# logmsg: Text of the log message. Optional. There is a default log
113
# message if unspecified.
114
# action=svncheckout: Checkout a file/directory from the repository.
115
# path: The [repository] path to the file or directory to be
118
# TODO: Implement the following actions:
120
# TODO: Implement ZIP unpacking in putfiles (done?).
121
# TODO: svnupdate needs a digest to tell the user the files that were updated.
122
# This can be implemented by some message passing between action and
123
# listing, and having the digest included in the listing. (Problem if
124
# the listing is not a directory, but we could make it an error to do an
125
# update if the path is not a directory).
133
from common import (util, studpath, zip)
136
def get_login(_realm, existing_login, _may_save):
137
"""Callback function used by pysvn for authentication.
138
realm, existing_login, _may_save: The 3 arguments passed by pysvn to
140
The following has been determined empirically, not from docs:
141
existing_login will be the name of the user who owns the process on
142
the first attempt, "" on subsequent attempts. We use this fact.
144
# Only provide credentials on the _first_ attempt.
145
# If we're being asked again, then it means the credentials failed for
146
# some reason and we should just fail. (This is not desirable, but it's
147
# better than being asked an infinite number of times).
148
return (existing_login != "", conf.login, conf.svn_pass, True)
150
# Make a Subversion client object
151
svnclient = pysvn.Client()
152
svnclient.callback_get_login = get_login
153
svnclient.exception_style = 0 # Simple (string) exceptions
155
DEFAULT_LOGMESSAGE = "No log message supplied."
158
# application/json is the "best" content type but is not good for
159
# debugging because Firefox just tries to download it
160
mime_dirlisting = "text/html"
161
#mime_dirlisting = "application/json"
163
class ActionError(Exception):
164
"""Represents an error processing an action. This can be
165
raised by any of the action functions, and will be caught
166
by the top-level handler, put into the HTTP response field,
169
Important Security Consideration: The message passed to this
170
exception will be relayed to the client.
174
def handle_action(req, action, fields):
175
"""Perform the "action" part of the response.
176
This function should only be called if the response is a POST.
177
This performs the action's side-effect on the server. If unsuccessful,
178
writes the X-IVLE-Action-Error header to the request object. Otherwise,
179
does not touch the request object. Does NOT write any bytes in response.
181
May throw an ActionError. The caller should put this string into the
182
X-IVLE-Action-Error header, and then continue normally.
184
action: String, the action requested. Not sanitised.
185
fields: FieldStorage object containing all arguments passed.
187
global actions_table # Table of function objects
189
action = actions_table[action]
191
# Default, just send an error but then continue
192
raise ActionError("Unknown action")
195
def actionpath_to_urlpath(req, path):
196
"""Determines the URL path (relative to the student home) upon which the
197
action is intended to act. See actionpath_to_local.
201
elif len(path) > 0 and path[0] == os.sep:
202
# Relative to student home
205
# Relative to req.path
206
return os.path.join(req.path, path)
208
def actionpath_to_local(req, path):
209
"""Determines the local path upon which an action is intended to act.
210
Note that fileservice actions accept two paths: the request path,
211
and the "path" argument given to the action.
212
According to the rules, if the "path" argument begins with a '/' it is
213
relative to the user's home; if it does not, it is relative to the
216
This resolves the path, given the request and path argument.
218
May raise an ActionError("Invalid path"). The caller is expected to
219
let this fall through to the top-level handler, where it will be
220
put into the HTTP response field. Never returns None.
224
(_, _, r) = studpath.url_to_jailpaths(actionpath_to_urlpath(req, path))
226
raise ActionError("Invalid path")
229
def movefile(req, frompath, topath, copy=False):
230
"""Performs a file move, resolving filenames, checking for any errors,
231
and throwing ActionErrors if necessary. Can also be used to do a copy
234
frompath and topath are straight paths from the client. Will be checked.
236
# TODO: Do an SVN mv if the file is versioned.
237
# TODO: Disallow tampering with student's home directory
238
if frompath is None or topath is None:
239
raise ActionError("Required field missing")
240
frompath = actionpath_to_local(req, frompath)
241
topath = actionpath_to_local(req, topath)
242
if not os.path.exists(frompath):
243
raise ActionError("The source file does not exist")
244
if os.path.exists(topath):
245
if frompath == topath:
246
raise ActionError("Source and destination are the same")
247
raise ActionError("A file already exists with that name")
251
if os.path.isdir(frompath):
252
shutil.copytree(frompath, topath)
254
shutil.copy2(frompath, topath)
256
shutil.move(frompath, topath)
258
raise ActionError("Could not move the file specified")
260
raise ActionError("Could not move the file specified")
264
def action_remove(req, fields):
265
# TODO: Do an SVN rm if the file is versioned.
266
# TODO: Disallow removal of student's home directory
267
"""Removes a list of files or directories.
269
Reads fields: 'path' (multiple)
271
paths = fields.getlist('path')
274
path = actionpath_to_local(req, path)
276
if os.path.isdir(path):
286
raise ActionError("Could not delete the file specified")
289
"Could not delete one or more of the files specified")
291
def action_move(req, fields):
292
# TODO: Do an SVN mv if the file is versioned.
293
# TODO: Disallow tampering with student's home directory
294
"""Removes a list of files or directories.
296
Reads fields: 'from', 'to'
298
frompath = fields.getfirst('from')
299
topath = fields.getfirst('to')
300
movefile(req, frompath, topath)
302
def action_mkdir(req, fields):
303
"""Creates a directory with the given path.
306
path = fields.getfirst('path')
308
raise ActionError("Required field missing")
309
path = actionpath_to_local(req, path)
311
if os.path.exists(path):
312
raise ActionError("A file already exists with that name")
314
# Create the directory
318
raise ActionError("Could not create directory")
320
def action_putfile(req, fields):
321
"""Writes data to a file, overwriting it if it exists and creating it if
324
Reads fields: 'path', 'data' (file upload), 'overwrite'
326
# TODO: Read field "unpack".
327
# Important: Data is "None" if the file submitted is empty.
328
path = fields.getfirst('path')
329
data = fields.getfirst('data')
331
raise ActionError("Required field missing")
333
# Workaround - field reader treats "" as None, so this is the only
334
# way to allow blank file uploads
336
path = actionpath_to_local(req, path)
339
data = cStringIO.StringIO(data)
341
overwrite = fields.getfirst('overwrite')
342
if overwrite is None:
348
# Overwrite files; but can't if it's a directory
349
if os.path.isdir(path):
350
raise ActionError("A directory already exists "
353
if os.path.exists(path):
354
raise ActionError("A file already exists with that name")
356
# Copy the contents of file object 'data' to the path 'path'
358
dest = open(path, 'wb')
360
shutil.copyfileobj(data, dest)
362
raise ActionError("Could not write to target file")
364
def action_putfiles(req, fields):
365
"""Writes data to one or more files in a directory, overwriting them if
368
Reads fields: 'path', 'data' (file upload, multiple), 'unpack'
371
# Important: Data is "None" if the file submitted is empty.
372
path = fields.getfirst('path')
373
data = fields['data']
374
if type(data) != type([]):
376
unpack = fields.getfirst('unpack')
382
raise ActionError("Required field missing")
383
path = actionpath_to_urlpath(req, path)
387
# Each of the uploaded files
388
filepath = os.path.join(path, datum.filename)
389
filedata = datum.file
391
if unpack and datum.filename.lower().endswith(".zip"):
392
# A zip file - unpack it instead of just copying
393
# TODO: Use the magic number instead of file extension
394
# Note: Just unzip into the current directory (ignore the
397
# First get the entire path (within jail)
398
_, _, abspath = studpath.url_to_jailpaths(path)
399
abspath = os.path.join(os.sep, abspath)
400
zip.unzip(abspath, filedata)
401
except (OSError, IOError):
405
(_, _, filepath_local) = studpath.url_to_jailpaths(filepath)
406
if filepath_local is None:
407
raise ActionError("Invalid path")
409
# Copy the contents of file object 'data' to the path 'path'
411
dest = open(filepath_local, 'wb')
413
shutil.copyfileobj(filedata, dest)
419
raise ActionError("Could not write to target file")
422
"Could not write to one or more of the target files")
424
def action_paste(req, fields):
425
"""Performs the copy or move action with the files specified.
426
Copies/moves the files to the specified directory.
428
Reads fields: 'src', 'dst', 'mode', 'file' (multiple).
429
src: Base path that all the files are relative to (source).
430
dst: Destination path to paste into.
431
mode: 'copy' or 'move'.
432
file: (Multiple) Files relative to base, which will be copied
433
or moved to new locations relative to path.
437
dst = fields.getfirst('dst')
438
src = fields.getfirst('src')
439
mode = fields.getfirst('mode')
440
files = fields.getlist('file')
441
if dst is None or src is None or mode is None:
442
raise ActionError("Required field missing")
448
raise ActionError("Invalid mode (must be 'copy' or 'move')")
449
dst_local = actionpath_to_local(req, dst)
450
if not os.path.isdir(dst_local):
451
raise ActionError("dst is not a directory")
455
# The source must not be interpreted as relative to req.path
456
# Add a slash (relative to top-level)
459
frompath = os.path.join(src, file)
460
# The destination is found by taking just the basename of the file
461
topath = os.path.join(dst, os.path.basename(file))
463
movefile(req, frompath, topath, copy)
464
except ActionError, message:
465
# Store the error for later; we want to copy as many as possible
469
# Multiple errors; generic message
470
errormsg = "One or more files could not be pasted"
471
# Add this file to errorfiles; it will be put back on the
472
# clipboard for possible future pasting.
473
errorfiles.append(file)
474
if errormsg is not None:
475
raise ActionError(errormsg)
477
# XXX errorfiles contains a list of files that couldn't be pasted.
478
# we currently do nothing with this.
480
def action_publish(req,fields):
481
"""Marks the folder as published by adding a '.published' file to the
482
directory and ensuring that the parent directory permissions are correct
486
paths = fields.getlist('path')
487
user = studpath.url_to_local(req.path)[0]
488
homedir = "/home/%s" % user
490
paths = map(lambda path: actionpath_to_local(req, path), paths)
492
paths = [studpath.url_to_jailpaths(req.path)[2]]
494
# Set all the dirs in home dir world browsable (o+r,o+x)
495
#FIXME: Should really only do those in the direct path not all of the
496
# folders in a students home directory
497
for root,dirs,files in os.walk(homedir):
498
os.chmod(root, os.stat(root).st_mode|0005)
502
if os.path.isdir(path):
503
pubfile = open(os.path.join(path,'.published'),'w')
504
pubfile.write("This directory is published\n")
507
raise ActionError("Can only publish directories")
509
raise ActionError("Directory could not be published")
511
def action_unpublish(req,fields):
512
"""Marks the folder as unpublished by removing a '.published' file in the
513
directory (if it exits). It does not change the permissions of the parent
518
paths = fields.getlist('path')
520
paths = map(lambda path: actionpath_to_local(req, path), paths)
522
paths = [studpath.url_to_jailpaths(req.path)[2]]
526
if os.path.isdir(path):
527
pubfile = os.path.join(path,'.published')
528
if os.path.isfile(pubfile):
531
raise ActionError("Can only unpublish directories")
533
raise ActionError("Directory could not be unpublished")
536
def action_svnadd(req, fields):
537
"""Performs a "svn add" to each file specified.
539
Reads fields: 'path' (multiple)
541
paths = fields.getlist('path')
542
paths = map(lambda path: actionpath_to_local(req, path), paths)
545
svnclient.add(paths, recurse=True, force=True)
546
except pysvn.ClientError, e:
547
raise ActionError(str(e))
549
def action_svnupdate(req, fields):
550
"""Performs a "svn update" to each file specified.
554
path = fields.getfirst('path')
556
raise ActionError("Required field missing")
557
path = actionpath_to_local(req, path)
560
svnclient.update(path, recurse=True)
561
except pysvn.ClientError, e:
562
raise ActionError(str(e))
564
def action_svndiff(req, fields):
565
"""Redirects to a diff of the file specified.
569
paths = fields.getlist('path')
571
raise ActionError(paths[0])
573
def action_svnrevert(req, fields):
574
"""Performs a "svn revert" to each file specified.
576
Reads fields: 'path' (multiple)
578
paths = fields.getlist('path')
579
paths = map(lambda path: actionpath_to_local(req, path), paths)
582
svnclient.revert(paths, recurse=True)
583
except pysvn.ClientError, e:
584
raise ActionError(str(e))
586
def action_svnpublish(req, fields):
587
"""Sets svn property "ivle:published" on each file specified.
588
Should only be called on directories (only effective on directories
593
XXX Currently unused by the client (calls action_publish instead, which
594
has a completely different publishing model).
596
paths = fields.getlist('path')
598
paths = map(lambda path: actionpath_to_local(req, path), paths)
600
paths = [studpath.url_to_jailpaths(req.path)[2]]
604
# Note: Property value doesn't matter
605
svnclient.propset("ivle:published", "", path, recurse=False)
606
except pysvn.ClientError, e:
607
raise ActionError("Directory could not be published")
609
def action_svnunpublish(req, fields):
610
"""Deletes svn property "ivle:published" on each file specified.
614
XXX Currently unused by the client (calls action_unpublish instead, which
615
has a completely different publishing model).
617
paths = fields.getlist('path')
618
paths = map(lambda path: actionpath_to_local(req, path), paths)
622
svnclient.propdel("ivle:published", path, recurse=False)
623
except pysvn.ClientError, e:
624
raise ActionError("Directory could not be unpublished")
626
def action_svncommit(req, fields):
627
"""Performs a "svn commit" to each file specified.
629
Reads fields: 'path' (multiple), 'logmsg' (optional)
631
paths = fields.getlist('path')
632
paths = map(lambda path: actionpath_to_local(req, str(path)), paths)
633
logmsg = str(fields.getfirst('logmsg', DEFAULT_LOGMESSAGE))
634
if logmsg == '': logmsg = DEFAULT_LOGMESSAGE
637
svnclient.checkin(paths, logmsg, recurse=True)
638
except pysvn.ClientError, e:
639
raise ActionError(str(e))
641
def action_svncheckout(req, fields):
642
"""Performs a "svn checkout" of each path specified.
644
Reads fields: 'path' (multiple)
646
paths = fields.getlist('path')
648
raise ActionError("usage: svncheckout url local-path")
649
url = conf.svn_addr + "/" + login + "/" + paths[0]
650
local_path = actionpath_to_local(req, str(paths[1]))
652
svnclient.callback_get_login = get_login
653
svnclient.checkout(url, local_path, recurse=True)
654
except pysvn.ClientError, e:
655
raise ActionError(str(e))
657
# Table of all action functions #
658
# Each function has the interface f(req, fields).
661
"remove" : action_remove,
662
"move" : action_move,
663
"mkdir" : action_mkdir,
664
"putfile" : action_putfile,
665
"putfiles" : action_putfiles,
666
"paste" : action_paste,
667
"publish" : action_publish,
668
"unpublish" : action_unpublish,
670
"svnadd" : action_svnadd,
671
"svnupdate" : action_svnupdate,
672
"svndiff" : action_svndiff,
673
"svnrevert" : action_svnrevert,
674
"svnpublish" : action_svnpublish,
675
"svnunpublish" : action_svnunpublish,
676
"svncommit" : action_svncommit,
677
"svncheckout" : action_svncheckout,