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, and optionally
45
# accept zip files which will be unpacked.
46
# path: The path to the file to be written. If it exists, will
47
# overwrite. Error if the target file is a directory.
48
# data: Bytes to be written to the file verbatim. May either be
49
# a string variable or a file upload.
50
# unpack: Optional. If "true", and the data is a valid ZIP file,
51
# will create a directory instead and unpack the ZIP file
54
# action=putfiles: Upload multiple files to the student workspace, and
55
# optionally accept zip files which will be unpacked.
56
# path: The path to the DIRECTORY to place files in. Must not be a
58
# data: A file upload (may not be a simple string). The filename
59
# will be used to determine the target filename within
61
# unpack: Optional. If "true", if any data is a valid ZIP file,
62
# will create a directory instead and unpack the ZIP file
65
# action=mkdir: Create a directory. The parent dir must exist.
66
# path: The path to a file which does not exist, but whose parent
67
# does. The dir will be made with this name.
69
# The differences between putfile and putfiles are:
70
# * putfile can only accept a single file.
71
# * putfile can accept string data, doesn't have to be a file upload.
72
# * putfile ignores the upload filename, the entire filename is specified on
73
# path. putfiles calls files after the name on the user's machine.
75
# action=paste: Copy or move the files to a specified dir.
76
# src: The path to the DIRECTORY to get the files from (relative).
77
# dst: The path to the DIRECTORY to paste the files to. Must not
79
# mode: 'copy' or 'move'
80
# file: File to be copied or moved, relative to src, to a destination
81
# relative to dst. Can be specified multiple times.
84
# action=svnadd: Add an existing file(s) to version control.
85
# path: The path to the file to be added. Can be specified multiple
88
# action=svnrevert: Revert a file(s) to its state as of the current revision
90
# path: The path to the file to be reverted. Can be specified multiple
93
# action=svnupdate: Bring a file up to date with the head revision.
94
# path: The path to the file to be updated. Only one file may be
97
# action=svnpublish: Set the "published" flag on a file to True.
98
# path: The path to the file to be published. Can be specified
101
# action=svnunpublish: Set the "published" flag on a file to False.
102
# path: The path to the file to be unpublished. Can be specified
105
# action=svncommit: Commit a file(s) or directory(s) to the repository.
106
# path: The path to the file or directory to be committed. Can be
107
# specified multiple times. Directories are committed
109
# logmsg: Text of the log message. Optional. There is a default log
110
# message if unspecified.
111
# action=svncheckout: Checkout a file/directory from the repository.
112
# path: The [repository] path to the file or directory to be
115
# TODO: Implement the following actions:
116
# putfiles, svnrevert, svnupdate, svncommit
117
# TODO: Implement ZIP unpacking in putfile and putfiles.
118
# TODO: svnupdate needs a digest to tell the user the files that were updated.
119
# This can be implemented by some message passing between action and
120
# listing, and having the digest included in the listing. (Problem if
121
# the listing is not a directory, but we could make it an error to do an
122
# update if the path is not a directory).
130
from common import (util, studpath, zip)
133
def get_login(_realm, _username, _may_save):
134
"""Return the subversion credentials for the user."""
135
return (True, conf.login, conf.svn_pass, True)
137
# Make a Subversion client object
138
svnclient = pysvn.Client()
139
svnclient.callback_get_login = get_login
141
DEFAULT_LOGMESSAGE = "No log message supplied."
144
# application/json is the "best" content type but is not good for
145
# debugging because Firefox just tries to download it
146
mime_dirlisting = "text/html"
147
#mime_dirlisting = "application/json"
149
class ActionError(Exception):
150
"""Represents an error processing an action. This can be
151
raised by any of the action functions, and will be caught
152
by the top-level handler, put into the HTTP response field,
155
Important Security Consideration: The message passed to this
156
exception will be relayed to the client.
160
def handle_action(req, action, fields):
161
"""Perform the "action" part of the response.
162
This function should only be called if the response is a POST.
163
This performs the action's side-effect on the server. If unsuccessful,
164
writes the X-IVLE-Action-Error header to the request object. Otherwise,
165
does not touch the request object. Does NOT write any bytes in response.
167
May throw an ActionError. The caller should put this string into the
168
X-IVLE-Action-Error header, and then continue normally.
170
action: String, the action requested. Not sanitised.
171
fields: FieldStorage object containing all arguments passed.
173
global actions_table # Table of function objects
175
action = actions_table[action]
177
# Default, just send an error but then continue
178
raise ActionError("Unknown action")
181
def actionpath_to_urlpath(req, path):
182
"""Determines the URL path (relative to the student home) upon which the
183
action is intended to act. See actionpath_to_local.
187
elif len(path) > 0 and path[0] == os.sep:
188
# Relative to student home
191
# Relative to req.path
192
return os.path.join(req.path, path)
194
def actionpath_to_local(req, path):
195
"""Determines the local path upon which an action is intended to act.
196
Note that fileservice actions accept two paths: the request path,
197
and the "path" argument given to the action.
198
According to the rules, if the "path" argument begins with a '/' it is
199
relative to the user's home; if it does not, it is relative to the
202
This resolves the path, given the request and path argument.
204
May raise an ActionError("Invalid path"). The caller is expected to
205
let this fall through to the top-level handler, where it will be
206
put into the HTTP response field. Never returns None.
210
(_, _, r) = studpath.url_to_jailpaths(actionpath_to_urlpath(req, path))
212
raise ActionError("Invalid path")
215
def movefile(req, frompath, topath, copy=False):
216
"""Performs a file move, resolving filenames, checking for any errors,
217
and throwing ActionErrors if necessary. Can also be used to do a copy
220
frompath and topath are straight paths from the client. Will be checked.
222
# TODO: Do an SVN mv if the file is versioned.
223
# TODO: Disallow tampering with student's home directory
224
if frompath is None or topath is None:
225
raise ActionError("Required field missing")
226
frompath = actionpath_to_local(req, frompath)
227
topath = actionpath_to_local(req, topath)
228
if not os.path.exists(frompath):
229
raise ActionError("The source file does not exist")
230
if os.path.exists(topath):
231
if frompath == topath:
232
raise ActionError("Source and destination are the same")
233
raise ActionError("Another file already exists with that name")
237
if os.path.isdir(frompath):
238
shutil.copytree(frompath, topath)
240
shutil.copy2(frompath, topath)
242
shutil.move(frompath, topath)
244
raise ActionError("Could not move the file specified")
246
raise ActionError("Could not move the file specified")
250
def action_remove(req, fields):
251
# TODO: Do an SVN rm if the file is versioned.
252
# TODO: Disallow removal of student's home directory
253
"""Removes a list of files or directories.
255
Reads fields: 'path' (multiple)
257
paths = fields.getlist('path')
260
path = actionpath_to_local(req, path)
262
if os.path.isdir(path):
272
raise ActionError("Could not delete the file specified")
275
"Could not delete one or more of the files specified")
277
def action_move(req, fields):
278
# TODO: Do an SVN mv if the file is versioned.
279
# TODO: Disallow tampering with student's home directory
280
"""Removes a list of files or directories.
282
Reads fields: 'from', 'to'
284
frompath = fields.getfirst('from')
285
topath = fields.getfirst('to')
286
movefile(req, frompath, topath)
288
def action_mkdir(req, fields):
289
"""Creates a directory with the given path.
292
path = fields.getfirst('path')
294
raise ActionError("Required field missing")
295
path = actionpath_to_local(req, path)
297
# Create the directory
301
raise ActionError("Could not create directory")
303
def action_putfile(req, fields):
304
"""Writes data to a file, overwriting it if it exists and creating it if
307
Reads fields: 'path', 'data' (file upload)
309
# TODO: Read field "unpack".
310
# Important: Data is "None" if the file submitted is empty.
311
path = fields.getfirst('path')
312
data = fields.getfirst('data')
314
raise ActionError("Required field missing")
316
# Workaround - field reader treats "" as None, so this is the only
317
# way to allow blank file uploads
319
path = actionpath_to_local(req, path)
322
data = cStringIO.StringIO(data)
324
# Copy the contents of file object 'data' to the path 'path'
326
dest = open(path, 'wb')
328
shutil.copyfileobj(data, dest)
330
raise ActionError("Could not write to target file")
332
def action_putfiles(req, fields):
333
"""Writes data to one or more files in a directory, overwriting them if
336
Reads fields: 'path', 'data' (file upload, multiple), 'unpack'
339
# Important: Data is "None" if the file submitted is empty.
340
path = fields.getfirst('path')
341
data = fields['data']
342
if type(data) != type([]):
344
unpack = fields.getfirst('unpack')
350
raise ActionError("Required field missing")
351
path = actionpath_to_urlpath(req, path)
356
# Each of the uploaded files
357
filepath = os.path.join(path, datum.filename)
358
filedata = datum.file
360
if unpack and datum.filename.lower().endswith(".zip"):
361
# A zip file - unpack it instead of just copying
362
# TODO: Use the magic number instead of file extension
363
# Note: Just unzip into the current directory (ignore the
366
# First get the entire path (within jail)
367
_, _, abspath = studpath.url_to_jailpaths(path)
368
abspath = os.path.join(os.sep, abspath)
369
zip.unzip(abspath, filedata)
370
except (OSError, IOError):
374
(_, _, filepath_local) = studpath.url_to_jailpaths(filepath)
375
if filepath_local is None:
376
raise ActionError("Invalid path")
378
# Copy the contents of file object 'data' to the path 'path'
380
dest = open(filepath_local, 'wb')
382
shutil.copyfileobj(cStringIO.StringIO(filedata), dest)
388
raise ActionError("Could not write to target file")
391
"Could not write to one or more of the target files")
393
def action_paste(req, fields):
394
"""Performs the copy or move action with the files specified.
395
Copies/moves the files to the specified directory.
397
Reads fields: 'src', 'dst', 'mode', 'file' (multiple).
398
src: Base path that all the files are relative to (source).
399
dst: Destination path to paste into.
400
mode: 'copy' or 'move'.
401
file: (Multiple) Files relative to base, which will be copied
402
or moved to new locations relative to path.
406
dst = fields.getfirst('dst')
407
src = fields.getfirst('src')
408
mode = fields.getfirst('mode')
409
files = fields.getlist('file')
410
if dst is None or src is None or mode is None:
411
raise ActionError("Required field missing")
417
raise ActionError("Invalid mode (must be 'copy' or 'move')")
418
dst_local = actionpath_to_local(req, dst)
419
if not os.path.isdir(dst_local):
420
raise ActionError("dst is not a directory")
424
# The source must not be interpreted as relative to req.path
425
# Add a slash (relative to top-level)
428
frompath = os.path.join(src, file)
429
# The destination is found by taking just the basename of the file
430
topath = os.path.join(dst, os.path.basename(file))
432
movefile(req, frompath, topath, copy)
433
except ActionError, message:
434
# Store the error for later; we want to copy as many as possible
438
# Multiple errors; generic message
439
errormsg = "One or more files could not be pasted"
440
# Add this file to errorfiles; it will be put back on the
441
# clipboard for possible future pasting.
442
errorfiles.append(file)
443
if errormsg is not None:
444
raise ActionError(errormsg)
446
# XXX errorfiles contains a list of files that couldn't be pasted.
447
# we currently do nothing with this.
449
def action_publish(req,fields):
450
"""Marks the folder as published by adding a '.published' file to the
451
directory and ensuring that the parent directory permissions are correct
455
paths = fields.getlist('path')
456
user = studpath.url_to_local(req.path)[0]
457
homedir = "/home/%s" % user
459
paths = map(lambda path: actionpath_to_local(req, path), paths)
461
paths = [studpath.url_to_jailpaths(req.path)[2]]
463
# Set all the dirs in home dir world browsable (o+r,o+x)
464
#FIXME: Should really only do those in the direct path not all of the
465
# folders in a students home directory
466
for root,dirs,files in os.walk(homedir):
467
os.chmod(root, os.stat(root).st_mode|0005)
471
if os.path.isdir(path):
472
pubfile = open(os.path.join(path,'.published'),'w')
473
pubfile.write("This directory is published\n")
476
raise ActionError("Can only publish directories")
478
raise ActionError("Directory could not be published")
480
def action_unpublish(req,fields):
481
"""Marks the folder as unpublished by removing a '.published' file in the
482
directory (if it exits). It does not change the permissions of the parent
487
paths = fields.getlist('path')
489
paths = map(lambda path: actionpath_to_local(req, path), paths)
491
paths = [studpath.url_to_jailpaths(req.path)[2]]
495
if os.path.isdir(path):
496
pubfile = os.path.join(path,'.published')
497
if os.path.isfile(pubfile):
500
raise ActionError("Can only unpublish directories")
502
raise ActionError("Directory could not be unpublished")
505
def action_svnadd(req, fields):
506
"""Performs a "svn add" to each file specified.
508
Reads fields: 'path' (multiple)
510
paths = fields.getlist('path')
511
paths = map(lambda path: actionpath_to_local(req, path), paths)
514
svnclient.add(paths, recurse=True, force=True)
515
except pysvn.ClientError:
516
raise ActionError("One or more files could not be added")
518
def action_svnupdate(req, fields):
519
"""Performs a "svn update" to each file specified.
523
path = fields.getfirst('path')
525
raise ActionError("Required field missing")
526
path = actionpath_to_local(req, path)
529
svnclient.update(path, recurse=True)
530
except pysvn.ClientError:
531
raise ActionError("One or more files could not be updated")
533
def action_svnrevert(req, fields):
534
"""Performs a "svn revert" to each file specified.
536
Reads fields: 'path' (multiple)
538
paths = fields.getlist('path')
539
paths = map(lambda path: actionpath_to_local(req, path), paths)
542
svnclient.revert(paths, recurse=True)
543
except pysvn.ClientError:
544
raise ActionError("One or more files could not be reverted")
546
def action_svnpublish(req, fields):
547
"""Sets svn property "ivle:published" on each file specified.
548
Should only be called on directories (only effective on directories
553
paths = fields.getlist('path')
555
paths = map(lambda path: actionpath_to_local(req, path), paths)
557
paths = [studpath.url_to_jailpaths(req.path)[2]]
561
# Note: Property value doesn't matter
562
svnclient.propset("ivle:published", "", path, recurse=False)
563
except pysvn.ClientError, e:
564
raise ActionError("Directory could not be published")
566
def action_svnunpublish(req, fields):
567
"""Deletes svn property "ivle:published" on each file specified.
571
paths = fields.getlist('path')
572
paths = map(lambda path: actionpath_to_local(req, path), paths)
576
svnclient.propdel("ivle:published", path, recurse=False)
577
except pysvn.ClientError:
578
raise ActionError("Directory could not be unpublished")
580
def action_svncommit(req, fields):
581
"""Performs a "svn commit" to each file specified.
583
Reads fields: 'path' (multiple), 'logmsg' (optional)
585
paths = fields.getlist('path')
586
paths = map(lambda path: actionpath_to_local(req, str(path)), paths)
587
logmsg = str(fields.getfirst('logmsg', DEFAULT_LOGMESSAGE))
588
if logmsg == '': logmsg = DEFAULT_LOGMESSAGE
591
svnclient.checkin(paths, logmsg, recurse=True)
592
except pysvn.ClientError:
593
raise ActionError("One or more files could not be committed")
595
def action_svncheckout(req, fields):
596
"""Performs a "svn checkout" of each path specified.
598
Reads fields: 'path' (multiple)
600
paths = fields.getlist('path')
602
raise ActionError("usage: svncheckout url local-path")
603
url = conf.svn_addr + "/" + login + "/" + paths[0]
604
local_path = actionpath_to_local(req, str(paths[1]))
606
svnclient.callback_get_login = get_login
607
svnclient.checkout(url, local_path, recurse=True)
608
except pysvn.ClientError:
609
raise ActionError("One or more files could not be checked out")
611
# Table of all action functions #
612
# Each function has the interface f(req, fields).
615
"remove" : action_remove,
616
"move" : action_move,
617
"mkdir" : action_mkdir,
618
"putfile" : action_putfile,
619
"putfiles" : action_putfiles,
620
"paste" : action_paste,
621
"publish" : action_publish,
622
"unpublish" : action_unpublish,
624
"svnadd" : action_svnadd,
625
"svnupdate" : action_svnupdate,
626
"svnrevert" : action_svnrevert,
627
"svnpublish" : action_svnpublish,
628
"svnunpublish" : action_svnunpublish,
629
"svncommit" : action_svncommit,
630
"svncheckout" : action_svncheckout,